Skip to content

Run expression-only for-loop bodies through a tight per-iteration cycle#2605

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-loop-floor
Jul 9, 2026
Merged

Run expression-only for-loop bodies through a tight per-iteration cycle#2605
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-loop-floor

Conversation

@lahma

@lahma lahma commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

A for statement whose body contains only expression statements cannot break, continue, carry
labels or declare bindings — so when the frame additionally cannot suspend (no generator/async), is
not being debugged, has no per-statement constraint checks and its completion values are dead
(function bodies), the per-iteration bookkeeping in ForBodyEvaluation is unobservable: suspension
probes, Completion struct churn, per-statement PrepareFor/RunBeforeExecuteStatementChecks and
accumulated-value tracking. Such loops now run a bare test → body-expressions → update cycle.

Correctness notes

  • deferred errors (Engine._error) are converted per body statement via
    JintStatementList.HandleError, exactly like the statement list does; .NET exceptions propagate
    to the enclosing list unchanged
  • the tight path references the body's own handler instances
    (JintBlockStatement.SingleStatement/StatementList) instead of building duplicates: the
    enclosing function's handler subtree is constructed per evaluation, so ctor-time Build() calls
    would allocate a fresh subtree per op (measured +264 B/op with duplicates, byte-parity after)
  • constraint-configured engines (MaxStatements etc.) keep the generic path via the frozen
    ShouldRunBeforeExecuteStatementChecks gate — pinned by a test

Results

LoopDispatchBenchmarks A/B (default job, adjacent runs):

row before after
EmptyLoop 3.037 ms 1.046 ms −65.6%
VariableBoundLoop 3.374 ms 1.248 ms −63.0%
CounterAdd 3.889 ms 1.933 ms −50.3%
LocalCopy 4.592 ms 2.805 ms −38.9%
StringAppend 4.486 ms 2.601 ms −42.0%
ComparisonOnly (if-statement body, stays generic) 4.960 ms 4.917 ms flat

The function-local loop floor drops from ~30 to ~10 ns/iteration. Allocations flat (+10–20 B/op =
three new handler fields, constructed per op like the rest of the function-body subtree).
EvalHotReusedEngine +1.5–3%: observable frames (eval/script top level) still take the generic
path; the follow-up dead-completion-value PR moves them onto the tight path and is gated against
exactly that row.

Tests

Function-local computation through the tight path, script top-level completion values staying intact
(the tight path must not engage there), throw propagation with loop state, let-loop
reusable-environment interaction, MaxStatements enforcement.

Gating

All-TFM build ✅ · Jint.Tests ×2 ✅ · PublicInterface ×2 ✅ · Test262 99,260/0 ✅

🤖 Generated with Claude Code

A for statement whose body contains only expression statements cannot
break, continue, carry labels or declare bindings - so when the frame
additionally cannot suspend (no generator/async), is not being debugged,
has no per-statement constraint checks and its completion values are
dead (function bodies), the per-iteration bookkeeping in
ForBodyEvaluation is unobservable: suspension probes, Completion struct
churn, per-statement PrepareFor/RunBeforeExecuteStatementChecks and the
accumulated-value tracking. Such loops now run a bare
test -> body-expressions -> update cycle.

Correctness notes:
- deferred errors (Engine._error) are converted per body statement via
  JintStatementList.HandleError, exactly like the statement list does;
  .NET exceptions propagate to the enclosing list unchanged
- the tight path REFERENCES the body's own handler instances
  (JintBlockStatement.SingleStatement/StatementList) instead of building
  duplicates: the enclosing function's handler subtree is constructed
  per evaluation, so ctor-time Build() calls would allocate a fresh
  subtree per op (measured +264 B/op before, byte-parity after)
- constraint-configured engines (MaxStatements etc.) keep the generic
  path via the frozen ShouldRunBeforeExecuteStatementChecks gate

LoopDispatchBenchmarks A/B (default job, adjacent runs):

| row               | base     | tight    |        |
|-------------------|----------|----------|--------|
| EmptyLoop         | 3.037 ms | 1.046 ms | -65.6% |
| VariableBoundLoop | 3.374 ms | 1.248 ms | -63.0% |
| CounterAdd        | 3.889 ms | 1.933 ms | -50.3% |
| LocalCopy         | 4.592 ms | 2.805 ms | -38.9% |
| StringAppend      | 4.486 ms | 2.601 ms | -42.0% |
| ComparisonOnly    | 4.960 ms | 4.917 ms | flat (if-statement body stays generic) |

The loop floor drops from ~30 to ~10 ns/iteration. Allocations flat
(+10-20 B/op = the three new handler fields, constructed per op like
the rest of the function-body subtree). EvalHotReusedEngine +1.5-3%:
observable frames (eval/script top level) still take the generic path;
the follow-up dead-completion-value analysis moves them onto the tight
path and is gated against exactly that row.

Tests pin: function-local computation through the tight path, script
top-level completion values staying intact (tight path must not engage),
throw propagation with loop state, let-loop reusable-environment
interaction, and MaxStatements enforcement.

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