Skip to content

Cache the top-level statement list per engine for re-executed scripts#2649

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-handler-rebuild-probe
Jul 12, 2026
Merged

Cache the top-level statement list per engine for re-executed scripts#2649
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-handler-rebuild-probe

Conversation

@lahma

@lahma lahma commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

Problem

The top-level Program statement list of a script was rebuilt (new JintStatementList(...)) on every Engine.Execute — even when the same Prepared<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 (fails SharedPreparedScriptDoesNotRetainEngines) 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 JintStatementList per engine in an engine-owned Dictionary<Script, JintStatementList>, keyed on the stable AST and gated on reEvaluation exactly like the existing _functionDefinitions cache. GlobalDeclarationInstantiation returns whether this is a re-evaluation; the first evaluation on any engine takes the historical new JintStatementList path and caches nothing.

Why it cannot regress the slot lanes (the mandatory check for anything touching handler-tree caching):

  • Fresh-engine byte-identical — first evaluation is unchanged, so fresh-engine rows can't move.
  • Per-engine, not cross-engine — the cache dies with the engine; no dropped-engine pinning.
  • Function-declaration body trees are untouched (still _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:

Row main PR Δ alloc
PreparedReusedEngine 10.45 ms / 9.95 KB 10.75 ms / 3.20 KB −67.8%
SourcePerOp (fresh) 9.50 ms / 45.6 KB 9.32 ms / 45.6 KB byte-identical
PreparedShared (fresh) 9.18 ms / 24.0 KB 9.42 ms / 24.0 KB byte-identical
PreparedPerOp (fresh) 9.72 ms / 47.2 KB 9.24 ms / 47.2 KB byte-identical

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

  • New 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.
  • Full Jint.Tests 3496/3496 (net10.0) + 3433/3433 (net472); Jint.Tests.CommonScripts 28/28; PublicInterface 82/82 both TFMs (no public API change — additions are private).
  • Full Test262: 99,426 passed / 0 failed.

🤖 Generated with Claude Code

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
lahma enabled auto-merge (squash) July 12, 2026 08:57
@lahma
lahma merged commit c4ae158 into sebastienros:main Jul 12, 2026
7 of 8 checks passed
@lahma
lahma deleted the perf-handler-rebuild-probe branch July 12, 2026 09:37
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