From a1a273c152f29b48c50d469d19eabdcb877c5a30 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Thu, 9 Jul 2026 14:36:21 +0300 Subject: [PATCH] Flatten eligible loop-body lexicals into the pooled loop environment for (let ...) { const z = ...; ... } paid the block-environment ceremony on every iteration: unpark/attach the cached env, two UpdateLexicalEnvironment swaps, park + ResetSlots. When the body block's bindings are slot-eligible (BlockState.SlotNames non-null already implies all-lexical declarations, <= 16 bindings and no escaping closures), contain no using declarations and don't shadow the loop header's names, the body's slot layout is now concatenated onto the pooled loop environment. Each iteration re-establishes the body range's TDZ with a handful of Binding struct copies and runs the block contents in place (JintBlockStatement.ExecuteFlattenedContents - the statement list or single statement with the usual error protocol, no environment work). Under a debugger the normal block path runs instead; its fresh environment shadows the unused flattened slots, so the fallback is semantically inert. Suspendable frames never take the pooled path to begin with. A/B (default job, adjacent runs): | row | before | after | | |------------------------------|-----------|-----------|--------| | stopwatch-modern driver | 185.3 ms | 175.2 ms | -5.4% | | StopwatchBenchmark modern Execute | 175.0 ms | 165.4 ms | -5.5% | | StopwatchBenchmark modern Prepared | 168.5 ms | 159.0 ms | -5.6% | | StopwatchBenchmark classic (both) | 152.2/154.5 | 152.4/150.0 | flat | | stopwatch classic driver | 168.3 ms | 168.5 ms | flat | ForBencmark guard rows resolved flat via --launchCount 5 (ReenteredInnerLetLoop 5-launch mean 48.16 ms == base 48.09; its body has no lexicals, so flattening never arms there). Allocations byte-identical everywhere; modern rows drop a Gen0 bucket. Tests pin the stopwatch-modern shape, per-iteration const freshness, TDZ re-established across continue-skipped declarations, header-name shadowing (declined), capturing bodies (declined via escape analysis), throw-in-body state, and using-declaration dispose ordering (declined). Co-Authored-By: Claude Fable 5 --- Jint.Tests/Runtime/LoopBodyFlatteningTests.cs | 163 ++++++++++++++++++ .../Statements/JintBlockStatement.cs | 10 ++ .../Statements/JintForStatement.cs | 97 ++++++++++- 3 files changed, 267 insertions(+), 3 deletions(-) create mode 100644 Jint.Tests/Runtime/LoopBodyFlatteningTests.cs diff --git a/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs new file mode 100644 index 000000000..325134434 --- /dev/null +++ b/Jint.Tests/Runtime/LoopBodyFlatteningTests.cs @@ -0,0 +1,163 @@ +namespace Jint.Tests.Runtime; + +/// +/// Pins the loop-body lexical flattening (JintForStatement): an eligible body block's let/const +/// bindings fold into the pooled loop environment, and the semantics must be indistinguishable +/// from the per-iteration block environment. +/// +public class LoopBodyFlatteningTests +{ + [Fact] + public void StopwatchModernShapeComputesCorrectly() + { + var engine = new Engine(); + // the stopwatch-modern inner-loop shape: let header, const body bindings, if/else chain + var result = engine.Evaluate(""" + (function () { + let hits = 0; + for (let x = 0; x < 8; x++) { + const z = x ^ 1; + const doubled = z * 2; + if (z % 2 == 0) { hits += doubled; } + else if (z % 3 == 0) { hits += 100; } + } + return hits; + })() + """).AsNumber(); + + // z values: 1,0,3,2,5,4,7,6 → even z (0,2,4,6) add 2z = 0+4+8+12 = 24; z=3 adds 100 + Assert.Equal(124, result); + } + + [Fact] + public void ConstIsFreshPerIterationAndTdzReestablished() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let i = 0; i < 3; i++) { + try { touch; } catch (e) { seen.push('tdz' + i); } + const touch = i * 10; + seen.push(touch); + } + return seen.join(','); + })() + """).AsString(); + + Assert.Equal("tdz0,0,tdz1,10,tdz2,20", result); + } + + [Fact] + public void ContinueReestablishesTdzForSkippedDeclarations() + { + var engine = new Engine(); + // continue jumps before `late` is initialized; the next iteration must still see TDZ, + // not the previous iteration's value + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let i = 0; i < 4; i++) { + const early = i; + if (i % 2 == 0) { continue; } + try { seen.push(late); } catch (e) { seen.push('tdz'); } + const late = 'v' + i; + seen.push(late, early); + } + return seen.join(','); + })() + """).AsString(); + + Assert.Equal("tdz,v1,1,tdz,v3,3", result); + } + + [Fact] + public void ShadowingHeaderNameKeepsBlockScoping() + { + var engine = new Engine(); + // body const shadows the header let: names overlap, flattening must decline and the + // block env must keep proper shadowing semantics + var result = engine.Evaluate(""" + (function () { + var seen = []; + for (let v = 0; v < 3; v++) { + seen.push(v); + { + const v = 'inner'; + seen.push(v); + } + } + var direct = []; + for (let w = 0; w < 2; w++) { + const w2 = w + 10; + direct.push(w2); + } + return seen.join(',') + '|' + direct.join(','); + })() + """).AsString(); + + Assert.Equal("0,inner,1,inner,2,inner|10,11", result); + } + + [Fact] + public void CapturingBodyKeepsPerIterationSemantics() + { + var engine = new Engine(); + // closures capture the body const: escape analysis must exclude flattening (and env + // reuse), so each captured binding is distinct + var result = engine.Evaluate(""" + (function () { + const fns = []; + for (let i = 0; i < 3; i++) { + const snapshot = i * 2; + fns.push(() => snapshot); + } + return fns.map(f => f()).join(','); + })() + """).AsString(); + + Assert.Equal("0,2,4", result); + } + + [Fact] + public void ThrowMidBodyLeavesConsistentState() + { + var engine = new Engine(); + var result = engine.Evaluate(""" + (function () { + var caught = 0, total = 0; + for (let i = 0; i < 5; i++) { + try { + const val = i; + if (i === 2) { throw new Error('x'); } + total += val; + } catch (e) { + caught++; + } + } + return total + ':' + caught; + })() + """).AsString(); + + Assert.Equal("8:1", result); // 0+1+3+4, one catch + } + + [Fact] + public void UsingDeclarationsKeepBlockDisposeSemantics() + { + var engine = new Engine(); + // using declarations need block-exit dispose per iteration: flattening must decline + var result = engine.Evaluate(""" + (function () { + const order = []; + for (let i = 0; i < 2; i++) { + using res = { [Symbol.dispose]() { order.push('d' + i); } }; + order.push('b' + i); + } + return order.join(','); + })() + """).AsString(); + + Assert.Equal("b0,d0,b1,d1", result); + } +} diff --git a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs index adb4b7ba6..ae938f6b8 100644 --- a/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintBlockStatement.cs @@ -19,6 +19,7 @@ internal sealed class JintBlockStatement : JintStatement // exposed so enclosing loop fast paths can reuse the exact handler instances of this block internal JintStatement? SingleStatement => _singleStatement; internal JintStatementList? StatementList => _statementList; + internal BlockState State => _blockState; // Reuse cache for this block's fixed-slot environment. Held on the handler instance — which is built // per statement list, i.e. per engine — rather than on the shared BlockState: BlockState lives on the @@ -323,6 +324,15 @@ private void SetupDisposeSuspension(Engine engine, AsyncFunctionInstance asyncFn PromiseOperations.PerformPromiseThen(engine, promise, onFulfilled, onRejected, null!); } + /// + /// Executes the block's contents against the CURRENT lexical environment, without creating, + /// attaching or parking the block environment. For enclosing-loop flattening only: the caller + /// owns an environment that already carries this block's slot layout (re-TDZ'd per iteration) + /// and has verified there are no dispose resources to run. + /// + internal Completion ExecuteFlattenedContents(EvaluationContext context) + => _statementList is not null ? _statementList.Execute(context) : ExecuteSingle(context); + private Completion ExecuteSingle(EvaluationContext context) { Completion blockValue; diff --git a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs index b7d3fefcf..03591f12d 100644 --- a/Jint/Runtime/Interpreter/Statements/JintForStatement.cs +++ b/Jint/Runtime/Interpreter/Statements/JintForStatement.cs @@ -30,6 +30,16 @@ internal sealed class JintForStatement : JintStatement private readonly JintExpressionStatement? _tightSingleStatement; private readonly JintStatementList? _tightBodyList; + // Body-lexical flattening: when the body block's let/const bindings are slot-eligible + // (no function/class declarations, no escaping closures — BlockState.SlotNames implies both), + // contain no using declarations and don't shadow the loop header's names, they fold into the + // pooled loop environment. The body then runs against that environment directly, eliding the + // block-env attach/swap/park ceremony per iteration; the body's slot range is re-TDZ'd before + // each iteration instead. + private readonly bool _bodyFlattened; + private readonly int _flattenedHeaderSlotCount; + private readonly JintBlockStatement? _flattenedBodyBlock; + private readonly bool _shouldCreatePerIterationEnvironment; private readonly bool _canReuseIterationEnvironment; @@ -100,6 +110,30 @@ public JintForStatement(ForStatement statement) : base(statement) _incrementCanDiscard = _increment.HasDiscardFastPath; } + if (_canPoolLoopEnv && _body.BlockStatement is { } flattenCandidate) + { + var bodyState = flattenCandidate.State; + if (bodyState.SlotNames is { } bodyNames + && _boundNames!.Count + bodyNames.Length <= 16 + && !HasUsingDeclarations(bodyState) + && !NamesOverlap(_loopSlotNames!, bodyNames)) + { + var headerCount = _loopSlotNames!.Length; + var combinedNames = new Key[headerCount + bodyNames.Length]; + var combinedTemplates = new Binding[combinedNames.Length]; + System.Array.Copy(_loopSlotNames, combinedNames, headerCount); + System.Array.Copy(_loopSlotTemplates!, combinedTemplates, headerCount); + System.Array.Copy(bodyNames, 0, combinedNames, headerCount, bodyNames.Length); + System.Array.Copy(bodyState.SlotTemplates!, 0, combinedTemplates, headerCount, bodyNames.Length); + + _loopSlotNames = combinedNames; + _loopSlotTemplates = combinedTemplates; + _flattenedHeaderSlotCount = headerCount; + _flattenedBodyBlock = flattenCandidate; + _bodyFlattened = true; + } + } + if (_test is not null && IsTightBodyShape(statement.Body)) { _tightBodyEligible = true; @@ -255,7 +289,9 @@ protected override Completion ExecuteInternal(EvaluationContext context) } } - completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate); + // body flattening engages only when the pooled combined-slot environment is live + var flattenActive = _bodyFlattened && loopEnv is not null && _canPoolLoopEnv && suspendable is null; + completion = ForBodyEvaluation(context, suspendData?.AccumulatedValue ?? JsValue.Undefined, skipTestOnce: resumingInBody, resumeUpdateOnce: resumingInUpdate, flattenActive); return completion; } finally @@ -319,6 +355,48 @@ private static void ResetSlots(Binding[] slots, Binding[] templates) } } + /// + /// Re-establishes the TDZ of the flattened body's slot range before an iteration; the header + /// range is left untouched (a reused iteration environment keeps its header bindings). + /// + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static void ResetBodySlotRange(Binding[] slots, Binding[] templates, int headerCount) + { + for (var i = headerCount; i < templates.Length; i++) + { + slots[i] = templates[i]; + } + } + + private static bool HasUsingDeclarations(JintBlockStatement.BlockState state) + { + foreach (var declaration in state.Declarations) + { + if (declaration.Declaration is VariableDeclaration { Kind: VariableDeclarationKind.Using or VariableDeclarationKind.AwaitUsing }) + { + return true; + } + } + + return false; + } + + private static bool NamesOverlap(Key[] headerNames, Key[] bodyNames) + { + foreach (var bodyName in bodyNames) + { + foreach (var headerName in headerNames) + { + if (bodyName == headerName) + { + return true; + } + } + } + + return false; + } + /// /// Checks if the given node is inside this for statement's body, test, or update (but NOT init). /// Used to determine if we're resuming from a yield/await inside the loop. @@ -362,7 +440,7 @@ private bool IsNodeInsideForStatementExcludingInit(Node node) /// /// https://tc39.es/ecma262/#sec-forbodyevaluation /// - private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce) + private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialValue, bool skipTestOnce, bool resumeUpdateOnce, bool flattenActive = false) { var v = initialValue; @@ -416,7 +494,20 @@ private Completion ForBodyEvaluation(EvaluationContext context, JsValue initialV var suspendable = context.Engine.ExecutionContext.Suspendable; if (!resumeUpdateOnce) { - var result = _body.Execute(context); + Completion result; + if (flattenActive && !context.DebugMode) + { + // the pooled loop environment carries the body's slots: re-establish their TDZ + // and run the block contents in place. Under a debugger the normal block path + // runs instead — its fresh env shadows the (uninitialized) flattened slots. + var env = (DeclarativeEnvironment) context.Engine.ExecutionContext.LexicalEnvironment; + ResetBodySlotRange(env._slots!, _loopSlotTemplates!, _flattenedHeaderSlotCount); + result = _flattenedBodyBlock!.ExecuteFlattenedContents(context); + } + else + { + result = _body.Execute(context); + } if (!result.Value.IsEmpty) { v = result.Value;