Skip to content

Reuse hoisted function definitions across re-evaluations on an engine#2613

Open
lahma wants to merge 1 commit into
sebastienros:mainfrom
lahma:perf-function-handler-reuse
Open

Reuse hoisted function definitions across re-evaluations on an engine#2613
lahma wants to merge 1 commit into
sebastienros:mainfrom
lahma:perf-function-handler-reuse

Conversation

@lahma

@lahma lahma commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

Declaration instantiation constructed a fresh JintFunctionDefinition for every hoisted function on
every evaluation — and the definition owns the lazily-built body handler tree, so re-running a
prepared script rebuilt each called function's interpreter subtree and reset every per-node inline
cache. Function-level and block-level DI paid the same cost per call for inner declarations.

Definitions are now cached per engine, keyed on the declaration AST node.

Why this exact shape (two rejected designs)

  • Not the AST's shared UserData State: handler trees accumulate engine-affine cache entries,
    and sharing them across engines retains dead engines —
    GarbageCollectionTests.SharedPreparedScriptDoesNotRetainEngines failed immediately.
  • Not a ConditionalWeakTable: a dependent handle keeps its value alive on key reachability
    alone, so with the key rooted by a live Prepared<Script>, the value → inline caches → engine
    chain pins the dropped engine — the same test failed again, in the opposite direction of the
    naive mental model.
  • The shipped shape: a strong engine-owned Dictionary (dies with the engine; retention scoped
    to engine lifetime, mirroring Realm._templateMap), with the global-DI site engaging the cache
    only on re-evaluation of a script this engine has seen (one HashSet probe) — so
    fresh-engine-per-run hosts execute the exact uncached path. Block/function-level DI sites cache
    unconditionally since repeated calls within one evaluation already amortize them. A 2048-entry
    reset backstops hosts streaming endless distinct sources through one engine.

Results

Default job; the measurement window drifted uniformly +5–8% across all rows (including untouched
guards), so in-window ratios and the deterministic allocation columns carry the signal:

measurement before after
PreparedReusedEngine (new row: long-lived engine re-evaluating a cached Prepared<Script>) 14.61 KB/op 9.99 KB/op −32% allocation; in-window time ratio 1.10× → 1.08× vs SourcePerOp
diagnostic probe (small function-declaring script, engine reuse) 3,288 B/op 1,740 B/op −47% allocation
SourcePerOp / PreparedShared / PreparedPerOp / LinqJs / EvalExecution (fresh-engine guards) allocation byte-identical (gated path is code-identical to base)

Gating

All-TFM build ✅ · Jint.Tests ×2 ✅ (including all GC retention pins) · PublicInterface ×2 ✅ ·
Test262 99,426/0 ✅

🤖 Generated with Claude Code

Declaration instantiation constructed a fresh JintFunctionDefinition
for every hoisted function on every evaluation - and the definition
owns the lazily-built body handler tree, so re-running a prepared
script rebuilt each called function's interpreter subtree and reset
every per-node inline cache. Function-level and block-level DI paid
the same cost per call for inner declarations.

Definitions are now cached per engine, keyed on the declaration AST
node:

- a strong engine-owned Dictionary, NOT the AST's shared UserData State
  (handler trees accumulate engine-affine cache entries; sharing them
  across engines retains dead engines - the
  SharedPreparedScriptDoesNotRetainEngines tripwire) and NOT a
  ConditionalWeakTable (a dependent handle keeps its value alive on KEY
  reachability alone, so with the key rooted by a live Prepared<Script>
  the value->inline caches->engine chain pins the dropped engine)
- the global-DI site engages the cache only on RE-evaluation of a
  script this engine has seen (one HashSet probe), so
  fresh-engine-per-run hosts execute the exact uncached path;
  block/function-level DI sites cache unconditionally since repeated
  calls within a single evaluation already amortize them
- a 2048-entry reset backstops hosts streaming endless distinct
  sources through one engine

Measurements (default job; the whole window drifted uniformly +5-8%,
so in-window ratios and the deterministic allocation columns carry the
signal):

- new PreparedAnomalyBenchmarks.PreparedReusedEngine (the cached-
  Prepared embedding pattern): allocation 14.61 -> 9.99 KB/op (-32%),
  in-window time ratio 1.10x -> 1.08x vs SourcePerOp
- diagnostic probe (small function-declaring script, engine reuse):
  3,288 -> 1,740 B/op (-47%)
- every fresh-engine row allocation-byte-identical (SourcePerOp /
  PreparedShared / PreparedPerOp / LinqJs / EvalExecution)

Full Jint.Tests green including all GarbageCollectionTests retention
pins on both TFMs.

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