Skip to content

Memoize slot-cache chain reachability for closure reads#2726

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/slot-cache-strengthening
Jul 21, 2026
Merged

Memoize slot-cache chain reachability for closure reads#2726
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf/slot-cache-strengthening

Conversation

@lahma

@lahma lahma commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Campaign-2 item (follow-up to #2716#2723). Profiles show closure-variable reads re-validating their slot location on every access — SlotLocationCache.CanReachAtOuterHop 3.5% + TryResolveNonLocal 2.4% on dromaeo-string-base64-modern, driven from JintIdentifierExpression.GetValue (closure-captured lookup tables read in tight loops).

Change

Plain per-node (lastEnv, hop, slot) caching guarded by environment identity alone is unsound — environment shapes are not immutable (sloppy direct eval injects vars into pre-existing function environments; AnnexB block-function semantics create var-scope bindings mid-execution). Instead this extends the guard pattern the codebase already ships for exactly this hazard in the global-read path (NestedChainMemo + Engine._envBindingInjectionEpoch):

  • New immutable, single-reference-published SlotChainMemo pins the probed intermediate chain links by identity plus the injection epoch after a successful hop-1..3 walk. Steady-state closure reads validate with ~5 reference compares + 1 int compare instead of per-hop HasBinding probes.
  • The walk stays authoritative for every miss and re-publishes; with insertion breaks link identity by construction; pooled/reused environments re-initialize with per-definition-deterministic name sets (every reuse channel audited); all dynamic injections bump the epoch (every CreateMutableBinding caller audited); deletions only shrink name sets, so a cached "not here" verdict stays valid.
  • Cross-engine safety: memos publish as immutable single references (no torn reads on shared AST) and foreign-engine memos fail identity checks trivially since environments are per-engine.

Numbers (launchCount-5, both sides, default job otherwise)

Row main this PR delta
dromaeo-string-base64-modern (Jint_ParsedScript) 22.48 ms 21.08 ms −6.2%
dromaeo-string-base64-modern (Jint) 22.94 ms 21.48 ms −6.4%
dromaeo-object-string-modern flat (few closure hops, expected)

Gates

Jint.Tests 3706/3643 (net10/net472) including 6 new soundness tests (alternating closure instances at one callsite, sloppy direct-eval shadowing mid-loop, with around closure reads with mid-loop injection, deep nesting past the memo depth, pooled loop environments re-attached across entries); PublicInterface 114/114; Test262 99,431 / 0 failed; CommonScripts 28/28.

🤖 Generated with Claude Code

Closure-variable reads at hops 1-3 re-validated their slot location on
every access: CanReachAtOuterHop re-probed every intermediate declarative
environment (linear slot-name scan + dictionary probe per hop) on each
read, ~6% of dromaeo-string-base64-modern (3.5% CanReachAtOuterHop +
2.4% TryResolveNonLocal).

A successful walk is now published as an immutable SlotChainMemo pinning
the probed chain links by identity plus Engine._envBindingInjectionEpoch,
mirroring the global read cache's NestedChainMemo. Steady-state closure
reads then validate with a handful of reference compares instead of
re-walking. Soundness: a declarative environment's name set is a
deterministic function of its defining AST node/definition (every reuse
channel re-initializes an instance with the identical name set), the only
mutations that grow a pre-existing environment's name set (sloppy direct
eval hoisting, AnnexB block-function var copies) bump the injection
epoch, deletions only shrink it, and an environment's class is immutable
- so pinned "intermediate does not own the name" probes stay false while
identity and epoch hold. Validation follows the CURRENT _outerEnv
pointers against the pinned links, so pooled environments re-attached
under different outers and with-ObjectEnvironments (which break link
identity by construction) fall through to the authoritative walk, which
also stays in charge of every miss and re-publishes on success.
ResolveAndPopulate publishes the memo directly when it lands at hops 1-3
since its traversal proves the same condition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 21, 2026 17:00
@lahma
lahma merged commit 2149c67 into sebastienros:main Jul 21, 2026
4 checks passed
@lahma
lahma deleted the perf/slot-cache-strengthening branch July 21, 2026 17:17
lahma added a commit that referenced this pull request Jul 21, 2026
Single-session re-measure of all lanes on main with #2725/#2726
merged. Extends the campaign notes with the round-2 wins (JSON scan
fast paths, closure-read chain memoization) and records the
measured-and-dropped direct-dispatch experiment: the builtin-call
path is already at its cost floor, so the residual string-row gap is
interpreter dispatch itself, not call ceremony.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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