eval: add TraceMode to omit the value Trace on export#659
eval: add TraceMode to omit the value Trace on export#659borisschlosser wants to merge 7 commits into
Conversation
`Value.UnmarshalJSON` json.Unmarshal'd the input twice per nesting level — once into a RawMessage and once over the captured "value" subtree — so cost scaled as O(size × depth). The hot path now reuses one json.Decoder for the entire subtree, including Trace.Base, decoding each byte exactly once per level. Object merges set Trace.Base on every produced value, and serialization recurses through it. As import-merge depth grew, opened payloads serialized many partial copies of the same logical content. New eval.TraceMode + EvalOptions arguments control this: - TraceModeCollapsed (default): keep the immediate parent only. - TraceModeFull: preserve the entire chain. - TraceModeNone: drop Trace.Base entirely. esc.Trace is unchanged (Base stays omitempty). The three eval entry points gain a final EvalOptions parameter; the zero value selects the safe default. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3b565af to
7c0428b
Compare
This comment was marked as outdated.
This comment was marked as outdated.
The single-pass decoder diverged from the previous json.Unmarshal path in
two edge cases. Restore exact behavioral parity:
- (*Trace).decodeFrom now accepts a JSON null for "trace", leaving the
Trace at its zero value instead of erroring. The old struct-based path
silently zeroed any field on null; forward-compatible payloads that null
out unknown fields depend on this.
- decodeValueField initializes the array accumulator as []Value{} so an
empty array stays a non-nil slice. json.Marshal renders a nil []Value as
null and a non-nil empty one as [], so without this a decode->encode
round trip silently rewrote "value":[] to "value":null.
Add table cases plus a round-trip test pinning both behaviors, and fix the
chainValue test helper to use fmt.Sprintf so labels stay correct past
depth 9 (string(rune('0'+i)) produced non-digit characters).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
applyTraceMode early-returns on TraceModeFull and otherwise switched on None/Collapsed with no default, so an out-of-range TraceMode value stripped nothing and recursed — silently producing the most expensive behavior (full chains), the opposite of the documented safe default. TraceMode is public API, so make Collapsed the switch default: any unrecognized mode now degrades to the bounded default. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
|
Having a
|
|
Also--wondering if we have profile data backing up these changes? We should take them regardless but I'm curious to know what else is hot |
…ecoder
Reworks the trace-mode change per PR review and the May 2026 CPU root-cause
findings:
- Drop TraceModeCollapsed. The only consumer of Trace.Base is `esc env get`,
which walks the *entire* chain (Check path); a single-level collapse would
feed it a silently-truncated provenance stack while the service (Open path)
reads Trace.Base not at all. So the useful axis is Full vs None, with no
middle ground.
- Make TraceModeFull the zero value, restoring it as the default. Callers
passing EvalOptions{} keep the historical full-chain behavior, so no
Trace.Base consumer regresses without opting in. The read-only Open path
opts into TraceModeNone in the service.
- Build the chain lazily in (*value).export via an includeBase flag threaded
off the evalContext, instead of building the full chain and stripping it in
a post-evaluation pass. Under TraceModeNone the dropped chain is never
allocated. Deletes applyTraceMode / applyTraceModeToEnvironment.
- Replace the hand-rolled streaming Value decoder with a struct-based one that
hand-rolls only the polymorphic "value" field; secret/unknown/trace (and
Trace.Base recursion) go back through the standard decoder. Once TraceModeNone
removes the deep merge chain from Open-path payloads, the import-depth factor
that drove the O(size × depth) decode cost is gone, so the simpler decoder is
sufficient.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment was marked as resolved.
This comment was marked as resolved.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
This comment was marked as outdated.
The decode-CPU cost identified in profiling was driven by deep Trace.Base merge-history chains in opened payloads (the O(size × depth) factor), not by the decoder itself. TraceModeNone removes those chains at the source, so the original stdlib-based Value.UnmarshalJSON is sufficient and there is no need to hand-roll any JSON parsing. Reverts value.go to its original decoder (now byte-identical to main). The added UnmarshalJSON characterization tests and depth benchmark are kept as regression guards for the existing decoder. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pass the TraceMode enum to (*value).export instead of a derived bool, and gate the entire esc.Trace on it: TraceModeNone now omits Def as well as the Base chain, so a None-exported value carries a zero Trace and the dropped data is never built. Removes the includeTraceBase() helper; export call sites pass the evalContext's traceMode directly. Also trims the new/changed comments across the change to focus on why. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These exercised Value.UnmarshalJSON, which this PR no longer changes (the decoder revert left value.go identical to main). Removing them keeps the PR scoped to the TraceMode change; the decoder coverage can land on its own. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
@pgavlin this PR now focuses on not calculating traces when If we want to further improve performance during trace calculation we can switch the json library for improved processing. But we should wait until the TraceMode changes land and profile the code again. Please find an AI research below: JSON library options for The intrinsic cost the stdlib decoder leaves on the table is the double-scan of the
Ranking for this codebase
|
Summary
Object merges set
Trace.Baseon every produced value, and serialization recurses through the whole chain. A leaf at the end of a D-deep import-merge chain therefore carried ~D partial copies of its ancestors on the wire, so opened payloads grew super-linearly in import depth for the same logical content. Combined with aValue.UnmarshalJSONwhose cost wasO(size × depth), opened-payload decode came to dominate CPU on import-heavy environments (a large fraction ofesc.(*Value).UnmarshalJSONon a production profile).This PR lets callers drop the
Traceon paths that never read it, so those payloads stay bounded by their logical content.What changed
eval.TraceMode+EvalOptionsTwo modes, with the historical behavior as the default:
TraceModeFull— the zero value. Builds the entireTrace(Def+ the fullBasechain), soEvalOptions{}is byte-for-byte the prior behavior and no consumer ofTraceregresses without opting in.TraceModeNone— exports each value with a zeroTrace(noDef, noBasechain). Bounds opened-payload size by logical content instead of import-merge depth.The
Traceis built directly during export —(*value).exporttakes theTraceMode(sourced from theevalContext, which is set once per evaluation and shared with imports, so export memoization stays consistent). UnderTraceModeNonethe droppedTraceis never allocated; there is no build-then-strip post-processing pass.The three eval entry points (
EvalEnvironment,CheckEnvironment,RotateEnvironment) gain a finalEvalOptionsargument.