Skip to content

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

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:perf-function-handler-reuse
Jul 10, 2026
Merged

Reuse hoisted function definitions across re-evaluations on an engine#2613
lahma merged 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>
@lahma
lahma merged commit 8fbbd99 into sebastienros:main Jul 10, 2026
4 checks passed
@lahma
lahma deleted the perf-function-handler-reuse branch July 10, 2026 04:43
lahma added a commit that referenced this pull request Jul 10, 2026
… instance (#2616)

Per-engine definition reuse (#2613) shares one handler tree across the fresh
function instances a re-evaluated prepared script creates. SlotLocationCache
cached the first instance's environment and returned a permanent miss once that
environment became unreachable, silently disabling every slot-backed fast lane
- unboxed loop counters, comparison/equality lanes, sum-of-products - from the
second re-evaluation onward (a JsNumber materialization per identifier read in
tight loops). On an unreachable cached environment, fall through and re-resolve
against the current chain exactly like the cross-engine Prepared<Script> path;
one bounded walk per instance switch, subsequent reads hit the re-populated
cache.


Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma added a commit that referenced this pull request Jul 10, 2026
* Re-resolve slot locations when a shared handler tree changes function instance

Per-engine definition reuse (#2613) shares one handler tree across the fresh
function instances a re-evaluated prepared script creates. SlotLocationCache
cached the first instance's environment and returned a permanent miss once that
environment became unreachable, silently disabling every slot-backed fast lane
- unboxed loop counters, comparison/equality lanes, sum-of-products - from the
second re-evaluation onward (a JsNumber materialization per identifier read in
tight loops). On an unreachable cached environment, fall through and re-resolve
against the current chain exactly like the cross-engine Prepared<Script> path;
one bounded walk per instance switch, subsequent reads hit the re-populated
cache.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv

* Add member-bound loop test lane for i < arr.length / i < s.length

The comparison/equality lane gains a third right-operand shape: a non-computed,
non-optional .length read off a slot-resolved identifier base. JsArray bases read
the own length data property (accessors impossible) and JsString bases read the
virtual Length (no materialization); everything else - typed arrays, proxies,
arguments, plain objects with getters - declines to the generic path before any
side effect. The length is re-read live every iteration.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
lahma added a commit that referenced this pull request Jul 10, 2026
Full re-run after the 2026-07-10 batch (#2613, #2615-#2620). Highlights vs
the 2026-07-09 refresh: dromaeo-string-base64 allocation -30% (2.30 -> 1.62
MB/op) and -modern -4.3% time, the classic row now ranked first outright
(previously tied with NiL.JS) - loop tests of the form i < s.length read the
length unboxed through the comparison lane (#2617). The batch's engine-reuse
wins (#2613/#2615 definition reuse, #2616 slot-cache lane restoration) target
long-lived-engine embeddings and are not visible in this fresh-engine-per-op
table. Everything else moved within documented noise bands; competitor rows
served as thermal canaries (times within +/-3%, allocations byte-identical).


Claude-Session: https://claude.ai/code/session_01BY8HoKam5H8vTPn1bMY2Hv

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