Skip to content

Slot fast path for compound assignment to non-number bindings#2566

Merged
lahma merged 6 commits into
sebastienros:mainfrom
lahma:eval-slot-env
Jul 4, 2026
Merged

Slot fast path for compound assignment to non-number bindings#2566
lahma merged 6 commits into
sebastienros:mainfrom
lahma:eval-slot-env

Conversation

@lahma

@lahma lahma commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #2565 (the first two commits; review the last commit only).

Compound assignment to a slot-stored non-number binding (the s += "x" loop shape) fell off the fast path on both execution lanes: the discard lane's TryCompoundUnboxed bailed for any non-number left value, and completion-value positions (eval/script bodies, where expression statements must produce values) always took the materialized lane — an uncached environment-chain walk plus a Reference rent/return per iteration.

What this does

  • TryCompoundUnboxed keeps its numeric lane byte-for-byte and, when the resolved slot holds an initialized mutable reference value, completes through a shared TryCompoundSlotValue: slot read → right-hand side exactly once → ComputeCompound → write-back via SetMutableBinding unless the rope concat mutated the value in place (the materialized lane's exact skip).
  • EvaluateInternal gets the value-producing twin behind the same gates (no suspendable frame, no operator overloading, logical/nullish forms excluded) — this is what reaches eval bodies, whose completion-value semantics keep their statements off the discard lane entirely.
  • Const and TDZ bindings bail before any evaluation, so the slow path preserves exact error ordering (RHS-then-TypeError for const, ReferenceError for TDZ).
  • SlotLocationCache.ResolveAndPopulate now probes only declarative environments (their sealed HasBinding is pure) and disables without touching anything else once a non-declarative environment is reached. Test262's set-mutable-binding-idref-compound-assign-with-proxy-env.js caught the new value lane firing an extra has/Symbol.unscopables proxy trap while probing a with environment; the same latent hazard existed in the pre-existing discard lanes (i++/n += 1 inside with) and is fixed for all users of the cache.

Measurements (same-window stash A/B against the slot-backed-eval baseline, BenchmarkDotNet default job)

Benchmark before after Δ
EvalHotReusedEngine 1,086.1 µs 945.4 µs −12.9%
EvalFreshEngine 1,053.2 µs 947.9 µs −10.0%
NewFunctionHotReusedEngine 1,166.7 µs 947.3 µs −18.8%
PlainFunctionLoopReference 1,168.6 µs 968.4 µs −17.1%
NewFunctionFreshEngine 1,141.6 µs 1,001.5 µs −12.3%
ParseOnlyTmp 39.4 µs 38.9 µs noise

Numeric guards (FunctionLocalNumberLoopBenchmark, PlainNumericAssignBenchmark): the numeric lane is unchanged; flagged rows varied ±4–5% with different rows flagging in different runs, including untouched plain-assignment code, and a same-binary triple run spans the same range (e.g. AccumulatorWithCallArg 31.5–34.0 ms on one binary) — run-to-run variance, not a code effect. The stopwatch driver, the stable canary, reads ±0.8% across all shapes.

Shape note: an earlier iteration marked the shared helper [MethodImpl(NoInlining)] to protect the numeric lane's code size; that taxed the per-iteration discard call +3..7% on the string loops and was dropped after measurement.

Combined effect with the slot-backed-eval PR this stacks on — EngineComparison rows (same-runtime worktree A/B against the common base):

Row base both PRs Δ
Jint / dromaeo-core-eval 2.507 ms 1.941 ms −22.6%
Jint_ParsedScript / dromaeo-core-eval 2.480 ms 1.909 ms −23.0%
Jint / dromaeo-core-eval-modern 2.439 ms 1.949 ms −20.1%
Jint_ParsedScript / dromaeo-core-eval-modern 2.427 ms 1.995 ms −17.8%

Allocation unchanged on all rows.

Gates

  • Pinning tests (CompoundAssignmentTests, 10 cases) verified to pass identically on the code before and after this change via stash cycles — including s += (s = 'z', 'a') (in-place rope append skips the write-back, so the interleaved write wins) in both discard and completion-value positions.
  • Jint.Tests 3169/3107 (net10/net472), PublicInterface 79/79, CommonScripts 28/28, Test262 99,260 passed / 0 failed, all-TFM build.

🤖 Generated with Claude Code

lahma and others added 5 commits July 3, 2026 06:52
EvalExecutionBenchmarks isolates the dromaeo-core-eval execution shape
(hot/fresh eval, new Function, plain-function reference floor, parse-only
bound); TimeProbe (--profile-time) splits dromaeo scripts per test() with
Stopwatch, complementing MemoryProbe's allocation split.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Strict eval created a fresh dictionary-backed environment per call, so
every identifier access in the eval body paid a dictionary probe and the
per-AST-node slot caches (identifier reads, unboxed i++) could never
engage - while the identical body under new Function() runs on fixed
slots. dromaeo-core-eval spends about half of each op inside one such
body.

The eval compile-cache entry now precomputes the environment slot layout
(deduplicated var + function names initialized to undefined, let/const
as TDZ templates) plus the hoisting scope; PerformEval instantiates the
strict-eval environment from the templates and
EvalDeclarationInstantiation skips the var/lexical passes via a new
bindingsPreInitialized flag. Bodies that cannot capture the environment
(no closures, nested eval or with) pool it across calls, keeping the
shared statement tree per-node caches valid. The layout is built only
when it can amortize: a loop in the body, or a source seen twice
(promotion). Function bookkeeping in EvalDeclarationInstantiation is
now lazily allocated, and ShadowRealm reuses CachedHoistingScope for
prepared scripts instead of re-walking the AST per eval.

Same-window stash A/B: EvalHotReusedEngine -18.6%, EvalFreshEngine
-17.5%, EvalSameSource -24.7% time / -47.9% alloc, EvalDistinctSources
flat (miss guard), ShadowRealm_ParsedScript -9.2%, dromaeo-core-eval
driver -8.5% source / -5.7% prepared; new Function and plain-function
reference rows unchanged. Jint.Tests 3159/3097, PublicInterface 79/79,
CommonScripts 28/28, Test262 99260/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review findings on the slot-backed eval work, all reproduced before
fixing:

The identifier read fast path's cached-slot walk hopped over intermediate
declarative environments by reference comparison only, so a node whose
cache pointed at an outer environment read a stale binding when executed
under a chain that shadows the name - reachable through the eval
compilation cache (the same body under a shadowing block or a nested
eval of the same source) and through sloppy direct eval injecting a var
into an enclosing function environment. The walk now probes each hopped
declarative environment with HasBinding (pure; the common hop-0 hit pays
nothing) and falls back to full resolution when an intermediate
environment owns the name.

EvalDeclarationInstantiation validated eval-declared var names with
CanDeclareGlobalFunction where the spec requires CanDeclareGlobalVar
(sec-evaldeclarationinstantiation step 8.a.ii.1), so eval("var undefined;")
and redeclaration of any existing non-configurable global threw a
spurious TypeError; pre-existing, but the lines live in this change.

TryPoolEvalEnvironment parked environments with the completed call's
binding values still in the slots, keeping arbitrary object graphs
reachable from the eval cache for the engine's lifetime; the slots are
now reset to templates (and the dictionary and dispose capability
cleared) at park time, and the rent path no longer needs per-rent
cleanup.

EvalScriptAnalyzer no longer lets loops inside arrow functions mark the
eval body as loop-bearing (arrows still descend for arguments/new.target
detection); those loops run in the arrow's own environment and cannot
amortize the eval body's slot layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compound assignment to a slot-stored non-number binding (the s += "x"
loop shape) fell off the fast path on both lanes: the discard lane
bailed for any non-number left value, and completion-value positions
(eval and script bodies, where expression statements must produce
values) always took the materialized lane - an uncached environment
walk plus a Reference rent/return per iteration.

TryCompoundUnboxed keeps its numeric lane unchanged and completes
initialized mutable reference values through a shared
TryCompoundSlotValue (slot read, right-hand side exactly once,
ComputeCompound, SetMutableBinding write-back unless the rope concat
mutated in place - the materialized lane exact skip). EvaluateInternal
gets the value-producing twin behind the same gates (no suspendable
frame, no operator overloading, logical/nullish forms excluded), which
is what reaches eval bodies. Const and TDZ bail before any evaluation
so the slow path keeps exact error ordering.

SlotLocationCache.ResolveAndPopulate now probes only declarative
environments (their sealed HasBinding is pure) and disables without
touching object or global environments: test262
set-mutable-binding-idref-compound-assign-with-proxy-env.js caught the
new value lane firing an extra has/Symbol.unscopables proxy trap while
probing a with environment; the same latent hazard existed in the
pre-existing discard lanes and is fixed for all users of the cache.

Same-window stash A/B against the slot-backed-eval baseline:
EvalHotReusedEngine -12.9% (945 us, stable within 0.2% across runs),
EvalFreshEngine -10.0%, NewFunctionHotReusedEngine -18.8%,
PlainFunctionLoopReference -17.1%, NewFunctionFreshEngine -12.3%.
Numeric guard micros vary +/-4-6% between identical binaries with
different rows flagging each run (including untouched plain-assign
code) - documented run-to-run variance, stopwatch driver stable within
1%. Ten pinning tests (CompoundAssignmentTests) verified to pass
identically before and after the change, including the in-place
rope-append interleaved-write case in both discard and completion-value
positions. Jint.Tests 3169/3107, PublicInterface 79/79, CommonScripts
28/28, Test262 99260/0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Code-review findings on the compound-assignment slot lanes, all
reproduced before fixing:

SlotLocationCache.TryResolve validated a cached hit by reference-walking
to the cached environment without checking whether an intermediate
declarative environment shadows the name, so the new write lanes stored
compound-assignment results into a stale outer binding (silently
corrupting the wrong variable, and swallowing the TypeError for a
shadowing const) when the same node ran under a different chain - an
eval-cache-shared body under a shadowing block, a nested eval of the
same source, or a sloppy direct eval injecting the name into an
enclosing function environment. The hit walk now probes each hopped
declarative environment with HasBinding, mirroring the identifier read
walk fix; the common hop-0 hit pays nothing. The class remarks now state
the real invariant instead of the per-function-lifetime claim.

TryCompoundSlotValue takes the slot ref through the same null/bounds
guard TryGetNumberSlot documents for stale or torn caches instead of
indexing unguarded.

EvaluateAndDiscard's fallback now goes straight to the materialized path
(extracted as EvaluateMaterialized) instead of re-running the fast-lane
gates and slot resolution that TryCompoundUnboxed just failed, which
also leaves the gate list in exactly one place per lane.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma

lahma commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Adversarial multi-agent review of the stack surfaced issues; fixed in 1722d13 (branch rebased on the updated #2565):

  • Shadow-aware slot-cache hit walk (the critical one, reproduced with A/B runs): SlotLocationCache.TryResolve validated a cached hit by reference-walking to the cached environment without checking whether an intermediate environment shadows the name. The new write lanes turned a pre-existing stale-read hazard into silently writing the wrong variable (and could swallow the TypeError for a shadowing const) — reachable via cached-eval bodies under shadowing blocks, nested self-eval, and sloppy direct eval injecting a var into an enclosing function environment. The hit walk now probes each hopped declarative environment with HasBinding, mirroring the read-walk fix in Slot-backed strict-eval environments with per-source pooling #2565.
  • Bounds guard in TryCompoundSlotValue (stale/torn-cache discipline, matching TryGetNumberSlot).
  • Lane dedup: the discard-lane fallback goes straight to the extracted EvaluateMaterialized instead of re-running the fast-lane gates and slot resolution; each gate list now exists in exactly one place.

Perf note, measured with interleaved A/B/A/B driver runs: the shadow probe costs ~2% on the stopwatch script (closure-captured variables resolve at hop 1, paying one pure HasBinding per access); hop-0 hits — the eval/function loop rows this stack targets — are unaffected (all deltas re-verified within noise). That 2% is the price of validating against confirmed silent corruption; a cheaper validation scheme (e.g. binding-shape versioning) could be follow-up work.

Full suites + Test262 99,260/0 re-verified on the fixed stack.

🤖 Generated with Claude Code

@lahma
lahma enabled auto-merge (squash) July 4, 2026 08:47
@lahma
lahma merged commit 7262b52 into sebastienros:main Jul 4, 2026
7 of 8 checks passed
@lahma
lahma deleted the eval-slot-env branch July 13, 2026 06:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant