Pool environments for direct-recursive functions (fib(30): -99.75% allocation)#2549
Merged
Merged
Conversation
A direct-recursive function (fib/ackermann/tree-walks) could not use the single-slot env/slot
pool: with several frames live at once, one cached slot only serves the topmost frame, so every
recursive call allocated a fresh FunctionEnvironment + Binding[]. fib(30) alone allocated 412 MB.
Add a small bounded, best-effort reuse pool per JintFunctionDefinition.State for the
direct-recursive, non-escaping case. Each returned env is pooled with its cleared fixed-slot
Binding[] still attached, so a sibling recursive frame rents back both env and slots. The pool is
a fixed slot-array with per-slot Interlocked (safe for the parallel test fixtures that share a
cached State) plus an occupancy-count hint so the common "pool empty during descent" and "pool
full during a deep unwind" cases skip the scan. NewRecursiveFunctionEnvironment handles the
recursive case; the non-recursive single-slot path is byte-for-byte unchanged.
RecursionBenchmark (fresh engine, default job, .NET 10, same-runtime A/B):
Fib(30): 619 ms / 412 MB -> 528 ms / 1.0 MB (-17% time, -99.75% alloc)
Tak(18,12,6): 16.7 ms / 12.9 MB -> 14.4 ms / 0.34 MB (-14% time, -97.4% alloc)
DeepSum(400): 227 ms / 197 MB -> 234 ms / 197 MB (+2.8% time, flat — depth far exceeds the
pool cap so linear recursion can't reuse)
Non-recursive call benchmarks: allocation flat, time within noise (path unchanged).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jun 30, 2026
lahma
added a commit
to lahma/jint
that referenced
this pull request
Jul 3, 2026
… instance (sebastienros#2560) Since sebastienros#2413 a call's FunctionEnvironment is pooled on the shared JintFunctionDefinition.State. An environment roots its creating engine (Environment._engine plus its outer chain to the global), and a prepared script's State is shared by every engine that runs it — so the pool slot pins the last-caller engine (and its realm) until the function is next invoked by any engine. For idle functions in long-lived cached prepared scripts the engine is pinned indefinitely (issue sebastienros#2560, secondary cause). Move the pool (single cached env, cached slot array, and the bounded recursive pool from sebastienros#2549) onto the ScriptFunction instance. Function instances are created per engine, so the pool is per-engine by construction and dies with the engine — while same-instance call reuse and recursive frame reuse, the workloads the pools exist for, are unchanged. Because a function instance is single-threaded like its engine, the Interlocked handshakes and cross-engine identity checks the shared-State pool needed are no longer necessary. Includes a WeakReference regression test: 20 throwaway engines running one shared prepared script must all be collectable (fails before this change — the last engine stayed pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9
lahma
added a commit
to lahma/jint
that referenced
this pull request
Jul 4, 2026
… instance (sebastienros#2560) Since sebastienros#2413 a call's FunctionEnvironment is pooled on the shared JintFunctionDefinition.State. An environment roots its creating engine (Environment._engine plus its outer chain to the global), and a prepared script's State is shared by every engine that runs it — so the pool slot pins the last-caller engine (and its realm) until the function is next invoked by any engine. For idle functions in long-lived cached prepared scripts the engine is pinned indefinitely (issue sebastienros#2560, secondary cause). Move the pool (single cached env, cached slot array, and the bounded recursive pool from sebastienros#2549) onto the ScriptFunction instance. Function instances are created per engine, so the pool is per-engine by construction and dies with the engine — while same-instance call reuse and recursive frame reuse, the workloads the pools exist for, are unchanged. Because a function instance is single-threaded like its engine, the Interlocked handshakes and cross-engine identity checks the shared-State pool needed are no longer necessary. Includes a WeakReference regression test: 20 throwaway engines running one shared prepared script must all be collectable (fails before this change — the last engine stayed pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9
lahma
added a commit
to lahma/jint
that referenced
this pull request
Jul 4, 2026
… instance (sebastienros#2560) Since sebastienros#2413 a call's FunctionEnvironment is pooled on the shared JintFunctionDefinition.State. An environment roots its creating engine (Environment._engine plus its outer chain to the global), and a prepared script's State is shared by every engine that runs it — so the pool slot pins the last-caller engine (and its realm) until the function is next invoked by any engine. For idle functions in long-lived cached prepared scripts the engine is pinned indefinitely (issue sebastienros#2560, secondary cause). Move the pool (single cached env, cached slot array, and the bounded recursive pool from sebastienros#2549) onto the ScriptFunction instance. Function instances are created per engine, so the pool is per-engine by construction and dies with the engine — while same-instance call reuse and recursive frame reuse, the workloads the pools exist for, are unchanged. Because a function instance is single-threaded like its engine, the Interlocked handshakes and cross-engine identity checks the shared-State pool needed are no longer necessary. Includes a WeakReference regression test: 20 throwaway engines running one shared prepared script must all be collectable (fails before this change — the last engine stayed pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9
lahma
added a commit
that referenced
this pull request
Jul 4, 2026
…; computed-key and escape-analysis fixes (#2568) * Run deep call-stack test on a dedicated large-stack thread ShouldAllowReasonableCallStackDepth nests 990 calls (Release) on a default engine, which has no stack guard (MaxExecutionStackCount is disabled), so the depth runs directly against the native stack. The xUnit worker thread's remaining stack varies per run, making the test crash the whole process intermittently with STACK_OVERFLOW (0xC00000FD) — reproducible on unmodified main on Windows/net10.0. Run the body on a dedicated thread with an explicit 16 MB stack so the available stack is deterministic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Move the function-env reuse pool off the shared State to the function instance (#2560) Since #2413 a call's FunctionEnvironment is pooled on the shared JintFunctionDefinition.State. An environment roots its creating engine (Environment._engine plus its outer chain to the global), and a prepared script's State is shared by every engine that runs it — so the pool slot pins the last-caller engine (and its realm) until the function is next invoked by any engine. For idle functions in long-lived cached prepared scripts the engine is pinned indefinitely (issue #2560, secondary cause). Move the pool (single cached env, cached slot array, and the bounded recursive pool from #2549) onto the ScriptFunction instance. Function instances are created per engine, so the pool is per-engine by construction and dies with the engine — while same-instance call reuse and recursive frame reuse, the workloads the pools exist for, are unchanged. Because a function instance is single-threaded like its engine, the Interlocked handshakes and cross-engine identity checks the shared-State pool needed are no longer necessary. Includes a WeakReference regression test: 20 throwaway engines running one shared prepared script must all be collectable (fails before this change — the last engine stayed pinned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Keep slot arrays shared on State; cache the env directly on the instance Refine the per-instance env cache based on A/B benchmarking: - Cleared fixed-slot Binding[] hold no engine references, so restore them to the shared State cache (exactly as before) — freshly created instances of the same function (e.g. a prepared script re-evaluated, or a function expression re-created per loop iteration) reuse the slot array again. - Store the cached FunctionEnvironment directly in a single object field on ScriptFunction, interpreted via State.IsDirectRecursive (direct-recursive functions store the bounded RecursiveEnvPool there instead). This drops the pool wrapper allocation and keeps the instance-size cost at one field. vs main: recursion and closure benchmarks are allocation-identical and 3-13% faster (the env-side Interlocked handshake is gone); the remaining deltas are the leak fix itself — one env allocation per re-created-and- called function instance (~0.1 KB) and 8 bytes per ScriptFunction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Apply code-review findings: scan parameter expressions in escape analysis The environment-escape analysis (EnvironmentEscapeAstVisitor.MayEscape) scanned only the function body, so a closure created in a parameter default expression escaped undetected and the call's environment was cached for reuse while still reachable from the closure — the next call's Reset then rebound the bindings the escaped closure resolves through: function f(a, get = function() { return a; }) { return get; } var g1 = f(1); f(2); g1(); // returned 2 instead of 1 (pre-existing before this branch too) Scan function.Params as well; the slot-aware MayEscapeWithReferences path needs no change because fixed slots require !HasParameterExpressions. Also from the review: - RecursiveEnvPool: plain LIFO stack pointer instead of the O(16) slot scans inherited from the lock-free shared-State pool (single-threaded now); fib -9.5% vs main (was -5.0%) - GC engine-retention test: cover the direct-recursive pool path too, and NoInlining on the helper so the engine can't be stack-rooted; document that block/loop environments cached on shared AST state can still pin the last-caller engine (pre-existing, tracked separately) - EngineLimitTests: 4 MB dedicated stack instead of 16 MB so a large per-frame native-stack regression still fails the test, and drop the macOS skip (the explicit stack is platform-independent) - hoist the duplicate (ScriptFunction) cast; fix stale comments describing the deleted shared-State pool Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Apply second-review findings: this/new.target capture, construct rent gate - ClosureReferencesAny (the slot-aware escape analysis) only matched identifiers against slot names, so an escaping closure whose only lexical dependency is `this`/`new.target`/`super` was classified non-capturing and its FunctionEnvironment was cached and rebound by the next call: function f(a) { var g = function() { return 1; }; var h = () => this; return h; } var h1 = f.call({name:'o1'}, 1); f.call({name:'o2'}, 2); h1().name; // returned 'o2' — also reproducible on main with the old State cache The environment carries the this-binding, new.target and super base, so any such node inside a closure now counts as an environment reference (conservative). Regression tests for the this and new.target variants. - The construct path rents from the reuse cache via PrepareForOrdinaryCall but never returns its environment, so every `new f()` drained the cache and starved the next call's reuse (upstream parity). Rent only for plain calls (newTarget undefined). - EngineLimitTests: back to a generous 16 MB dedicated stack — StackOverflowException is uncatchable and would kill the whole test process on platforms with larger native frames; the test is a functional smoke test of deep call chains, not a per-frame stack-budget test. Benchmarks after these fixes (vs main): Fib -10.3%, ParamLocalCall -12.7%, closures -6..7%, Warm_* -3..6%, allocations unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Cache the block environment on the statement handler, not the shared BlockState Same #2560 engine-retention family as the function-env cache: BlockState lives on the AST node's UserData and is shared by every engine running a prepared script, and the DeclarativeEnvironment cached on it roots its creating engine — so any prepared script containing a let/const block kept the last-caller engine (and its realm) alive. Reproduced: 1 of 20 throwaway engines stayed pinned. Move the cache to the JintBlockStatement handler instance, which is built per statement list and therefore per engine; the Interlocked handshake and engine-identity check become unnecessary. BlockState keeps only the immutable, engine-agnostic data (declarations, slot names/templates). JintForStatement._cachedLoopEnv needed no change: statement handlers are never cached on AST UserData (only BlockState is), so the loop-env cache was already per engine — confirmed by the extended regression test, which covers an ordinary function, a direct-recursive one, a let/const block and a for-let loop and requires all 20 engines to be collectable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Evaluate any expression type in computed property keys AstExtensions.TryGetComputedPropertyKey had a node-type allowlist with a silent JsValue.Undefined fallback: computed keys whose expression type was missing from the list (SequenceExpression, NewExpression, TaggedTemplateExpression, ChainExpression, ClassExpression, ...) bound the property "undefined" and never evaluated the key expression at all — side effects were silently skipped: var calls = 0; function fb(x, { [(calls++, "k")]: v }) { return v; } fb(1, { k: 6 }); // returned undefined with calls === 0 Every expression type is buildable by JintExpression.Build and both call sites already handle abrupt completions and generator suspension after evaluating the key, so drop the allowlist and always evaluate. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 * Apply third-review findings: evaluate decorated computed keys exactly once - Decorator bookkeeping re-called GetKey after the class element was already defined, re-running computed key side effects and — when the key changed between evaluations — silently applying the decorator to the wrong (nonexistent) property. Thread the defined key out of MethodDefinitionEvaluation/ClassElementEvaluation, reuse ClassFieldDefinition.Name for fields and carry the declared key in AutoAccessorResult (FieldDefinition.Name is the private backing name). Pre-existing for previously-allowlisted key types; became more visible once computed keys evaluate all expression types. - Object literals: check for generator suspension after evaluating a computed method key (mirrors the non-method path) so the build loop doesn't keep executing properties while the generator is suspended. - Block env cache: park only after leaving the block, and reset slots + detach the outer chain at park time so the cached env doesn't root the completed call's scope chain and last binding values until reuse. - Remove the write-only BlockState.CanReuseEnvironment; document the handlers-must-not-be-shared invariant at JintStatement.Build, where a future UserData caching change would violate it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bf1LESD4X8dMNPwLKJCAh9 --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 6, 2026
This was referenced Jul 13, 2026
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.
Summary
A direct-recursive function (
fib,ackermann, tree-walks…) could not benefit from the per-function env/slot reuse pool. The pool has a single cached slot, but recursion keeps several frames live at once, so one slot can only ever serve the topmost frame — every recursive call allocated a freshFunctionEnvironment+Binding[].fib(30)alone allocated 412 MB.This adds a small bounded, best-effort reuse pool per
JintFunctionDefinition.State, used only for the direct-recursive, non-escaping case:Binding[]still attached, so a sibling recursive frame rents back both the env and its slots.Interlocked(so it stays safe for the parallel Test262 fixtures that share a cachedState), plus an occupancy-count hint so the two hot cases — pool empty during a deep descent and pool full during a deep unwind — skip the slot scan entirely.NewRecursiveFunctionEnvironmenthandles the recursive path; the non-recursive single-slot path is byte-for-byte unchanged, so non-recursive calls cannot regress.Correctness invariant: a pooled env is removed from the pool while rented and only returned when its frame completes, so no two simultaneously live frames ever share an env. Escaping (closure-capturing) recursive functions keep
EnvironmentMayEscape, so they are never pooled.Benchmark
New
Jint.Benchmark/RecursionBenchmark(fresh engine per invocation, default BDN job, .NET 10, Ryzen 9 5950X, same-runtime worktree A/B):Wide branching recursion (the common shape, and the one the pool targets) sees near-total allocation elimination and a double-digit time win. Deep linear recursion whose depth far exceeds the pool cap can't reuse (all frames are live at peak), so its allocation is unchanged and it pays a small (~3%, within the noise band) cost for the pooling attempts.
Non-recursive call benchmarks (
ClosureCallBenchmarks,MethodCallBenchmark,FunctionInvocationBenchmarks): allocation flat, time within run-to-run noise — the non-recursive path is unchanged.Correctness
dotnet test Jint.Tests: 3138 passed, 0 failed.dotnet test Jint.Tests.PublicInterface: 79 passed, 0 failed.🤖 Generated with Claude Code