Cache the top-level statement list per engine for re-executed scripts#2649
Merged
Conversation
ScriptEvaluation rebuilt the Program-level JintStatementList on every Execute/Evaluate, even when a long-lived engine re-ran the same prepared script (the recommended cached-Prepared<Script> embedding pattern). Function-declaration bodies already avoid this via the per-engine _functionDefinitions cache, but the top-level list -- and through it every top-level function EXPRESSION's definition and warm inline caches, i.e. the dominant module-pattern IIFE shape -- was thrown away and rebuilt each run. Cache the top-level list per engine, keyed on the stable Script AST and gated on re-evaluation exactly like _functionDefinitions: a FIRST evaluation (the fresh-engine-per-op shape) still builds fresh and caches nothing, so those hosts stay byte-identical; re-evaluations reuse the tree. Engine-owned (dies with the engine) so it never pins a dropped engine through the shared AST -- the same lifetime contract the function-definition cache relies on. No cross-engine sharing, so per-node slot caches stay strictly per engine and the function-declaration body trees (where the slot lanes live) are untouched. Measured with GC.GetAllocatedBytesForCurrentThread on a reused engine: linq-js -46%, evaluation -59%, minimal -57%; fresh-engine rows byte-identical. Note: this addresses the reused-engine rebuild found during the handler-tree investigation. The fresh-engine-per-op residual (sharing the immutable tree ACROSS engines) remains a no-go: an inline cache in a cross-engine-shared handler either pins the last engine or needs a per-engine side table that regresses the slot lanes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lahma
enabled auto-merge (squash)
July 12, 2026 08:57
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The top-level
Programstatement list of a script was rebuilt (new JintStatementList(...)) on everyEngine.Execute— even when the samePrepared<Script>is re-run on a reused engine, the recommended production pattern. At ~316 bytes per top-level statement that is pure waste on re-execution, and because a top-level function expression (e.g. a module-pattern IIFE) lives inside that list, rebuilding it also discarded the IIFE's already-warm body handler tree each run.This came out of an investigation into a suspected per-operation function-body rebuild. That part is a genuine NO-GO: the only way to save the fresh-engine body rebuild is sharing the immutable handler tree across engines via the AST's
UserData, but handlers embed per-engine mutable inline caches (SlotLocationCache, block/loop env caches), so cross-engine sharing either pins dropped engines (failsSharedPreparedScriptDoesNotRetainEngines) or moves the caches behind an indirection that kills the slot lanes — exactly the trap #2613/#2616 documents. This PR ships only the safe, adjacent win the investigation surfaced.Approach
Cache the top-level
JintStatementListper engine in an engine-ownedDictionary<Script, JintStatementList>, keyed on the stable AST and gated onreEvaluationexactly like the existing_functionDefinitionscache.GlobalDeclarationInstantiationreturns whether this is a re-evaluation; the first evaluation on any engine takes the historicalnew JintStatementListpath and caches nothing.Why it cannot regress the slot lanes (the mandatory check for anything touching handler-tree caching):
_functionDefinitions) — the slot lanes live there, not in the top-level list.Benchmarks (default job, baseline =
ee6683b59, back-to-back)PreparedAnomalyBenchmarks— the reused-engine row is the target:Per-op allocation probe on reused engines: linq-js −46%, evaluation −59%, minimal −57%, top-level var-decl (k=40) −81%.
LoopDispatchBenchmarks — the slot-lane tripwire — held: allocations went down (EmptyLoop / CounterAdd / equality rows 1.63 → 1.27 KB, the saved per-op top-level-list rebuild), rather than exploding to MB as a slot-lane regression would. Times are within the documented bimodal band (CounterAdd +5.6% and ModuloEqual −6.8% opposite-sign in the same run); the deterministic allocation floor is the gate and it improved.
Verification
ScriptStatementListReuseTests(12): re-execution equivalence across var/blocks/let-const/for/for-of/for-in/switch/try-catch/IIFE/map-reduce, top-level function-expression re-capture, and IIFE per-run state independence.🤖 Generated with Claude Code