Skip to content

Share compiled statement lists across generator and async invocations#2571

Merged
lahma merged 4 commits into
sebastienros:mainfrom
lahma:shared-statement-handler-tree
Jul 4, 2026
Merged

Share compiled statement lists across generator and async invocations#2571
lahma merged 4 commits into
sebastienros:mainfrom
lahma:shared-statement-handler-tree

Conversation

@lahma

@lahma lahma commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #2567. That fix gave each generator instance its own JintStatementList to stop live instances from corrupting each other's resume position — at the cost of rebuilding the whole compiled statement handler tree on every generator instantiation (a price async statement bodies had also been paying per invocation since their introduction).

Change

JintStatementList is now immutable after construction: the mutable resume position (_index) moves into the suspendable instance's SuspendDataDictionary, keyed by the list — completing the pattern already used for every other piece of suspension state (loop iterators, destructuring, block environments). With positions per instance, the cached list is safe to share, so:

  • sync and async generator bodies reuse the cached statement list again (_bodyStatementList ??=),
  • async function statement bodies stop allocating a fresh list (and handler tree) per call.

Most suspendables only ever track one list (the function body), so a one-entry inline slot fronts the position dictionary; nested-list suspensions fall back to the dictionary. A key never moves between slot and dictionary, so lookups cannot observe stale entries. The sync execution path only gains one frame-suspendable read per list execution — it was already read per statement for the suspension checks.

Benchmarks

BenchmarkDotNet default job, .NET 10, A/B against main (059f174) from separate worktrees, idle machine. GeneratorBenchmark is added in this PR (small bodies, a large body with nested blocks/loops/switch, and interleaved instances).

Benchmark main PR Δ time Δ alloc
SmallGenerators_1000 1,310.0 us / 2,640 KB 1,247.4 us / 2,417 KB −4.8% −8.5%
LargeBodyGenerators_500 3,725.6 us / 7,813 KB 2,711.7 us / 3,010 KB −27.2% −61.5%
InterleavedGenerators_500 1,166.3 us / 1,752 KB 1,150.9 us / 1,382 KB −1.3% −21.1%
PlainAsyncFunctionExit_1000 556.3 us / 2,033 KB 461.9 us / 1,802 KB −17.0% −11.4%
AsyncFunctionWithSyncUsing_1000 961.4 us / 2,818 KB 768.1 us / 2,263 KB −20.1% −19.7%
PlainAsyncGeneratorExit_500 1,453.1 us / 4,955 KB 1,349.2 us / 4,843 KB −7.1% −2.3%

Sync neutrality probes (StopwatchBenchmark ×4, RecursionBenchmark Fib/Tak/DeepSum): allocations byte-identical to main on all seven; time deltas ranged −4.9%..+6.4% with inconsistent signs across closely related methods (e.g. Stopwatch Execute +5.3% while Execute_ParsedScript −2.8%), i.e. run-to-run noise rather than a systematic shift.

Verification

  • Jint.Tests: 3160 (net10.0) / 3098 (net472) passed, 0 failed — including the Fix async function parameter binding after await resumption #2567 interleaved-generator regression tests (top-level, nested-block, for..of termination, async generators)
  • Jint.Tests.PublicInterface: 79 + 79 passed
  • Test262: 99260 passed, 0 failed (baseline parity)

🤖 Generated with Claude Code

lahma and others added 3 commits July 3, 2026 22:42
JintStatementList._index made the compiled statement handler tree mutable,
forcing generators (sebastienros#2567) to rebuild the whole tree per invocation to keep
resume positions from being shared across live instances. Positions now
live in the suspendable's SuspendDataDictionary — completing the pattern
already used for all other suspension state — so the handler tree is
immutable and generator/async generator bodies can share the cached list
again. Sync execution reads the frame suspendable once per list execution
(it was already read per statement for the suspension checks).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Async statement bodies allocated a fresh JintStatementList (rebuilding the
whole compiled handler tree) on every invocation to keep resume positions
per instance. With positions stored on the AsyncFunctionInstance, the
cached list is safe to share, so async calls stop paying a per-call tree
rebuild.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Small generators regressed ~11% from paying dictionary hash operations on
every yield/resume for their single body list. Most suspendables only ever
track one list, so a one-entry slot serves the common case with reference
comparisons and field access; nested-list suspensions fall back to the
dictionary. A key never moves between slot and dictionary, so lookups
cannot observe stale entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 4, 2026 09:16
@lahma
lahma merged commit f7935bb into sebastienros:main Jul 4, 2026
4 checks passed
@lahma
lahma deleted the shared-statement-handler-tree branch July 13, 2026 06:52
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