Skip to content

Trim per-iteration block ceremony; slot lane for lexical declaration initialization#2583

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-block-ceremony-lexical-init
Jul 6, 2026
Merged

Trim per-iteration block ceremony; slot lane for lexical declaration initialization#2583
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-block-ceremony-lexical-init

Conversation

@lahma

@lahma lahma commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Modern let/const loop bodies pay a large per-iteration tax that var-based code doesn't: in the engine-comparison table, stopwatch-modern runs ~55 ms/iter slower than classic stopwatch on identical logic. Probe attribution (A/B script variants via CpuProfileDriver) decomposed that gap into block-environment ceremony (~42%), lexical const-init overhead (~20%), let-loop header residual (~22%), and nested global reads (~13%). This PR removes the first two components with three targeted changes:

  1. JintBlockStatement.ExecuteBlock: when the block environment registered no dispose resources (no using/await using declaration ran), finalize inline — restore the outer environment and park the reusable env — instead of round-tripping a DisposeStepResult through CompleteDispose. BeginDispose on an empty capability is a pure Done(c), so semantics are unchanged.

  2. ExecutionContextStack.ReplaceTopLexicalEnvironment / ReplaceTopVariableEnvironment: write the single field in place (Unsafe.AsRef) instead of constructing and copying back the whole 10-field ExecutionContext struct. The instance lives in a mutable array slot, so casting away the field's readonly is safe; every block entry/exit and function-call env switch benefits.

  3. JintVariableDeclaration: a slot lane for let/const identifier declarations. The declaration's own binding lives in the current environment by construction (blocks/loops instantiate their environment before executing statements), so when that environment stores bindings in fixed slots the initializer value is written straight into the resolved slot. The slot index is cached per slot-layout identity (Key[] identity is stable across engines for shared prepared scripts; publication is a single reference write, so shared handler trees stay race-free). This skips the pooled Reference rent/evaluate/return round-trip and the SlotIndexOf scan. Excluded statically and left on the existing path: var, using/await using, destructuring targets, eval/arguments names, and function-definition/class-expression initializers (those need the reference's name for SetFunctionName/EvaluateWithName). The Reference-based arm moved to a non-inlined method so the var-declaration path stays compact (an earlier inline version cost var-only workloads ~6% through pure code-size effects).

Benchmarks

BenchmarkDotNet default jobs, win-x64, .NET 10, A/B against upstream/main from separate worktrees (each run from its own CWD).

StopwatchBenchmark (the target):

case main PR delta
Execute (modern) 215.3 ms 176.2 ms −18.2%
Execute_ParsedScript (modern) 210.2 ms 172.7 ms −17.8%
Execute (classic) 157.5 ms 156.7 ms −0.5%
Execute_ParsedScript (classic) 156.4 ms 154.6 ms −1.2%

GeneratorBenchmark (block-heavy bodies benefit):

case main PR delta
SmallGenerators_1000 1.306 ms 1.156 ms −11.5%
LargeBodyGenerators_500 2.853 ms 2.627 ms −7.9%
InterleavedGenerators_500 1.137 ms 1.097 ms −3.5%

No-regression gates: ClosureCallBenchmarks (EmptyClosureCall −8.2%, CapturedVarReadWrite +1.3%, ManyLocalCall −1.5%; ParamLocalCall statistically identical across 5-launch runs: 104.2 ± 5.7 → 103.2 ± 4.1 ms), FunctionLocalNumberLoopBenchmark (−1.2%/−2.8%/+1.1%/+1.4%), DromaeoBenchmark Cube modern −4..−12%, Cube classic within its observed run-to-run band. Dromaeo CoreEval turned out to be bimodal on identical bits (baseline itself swung 2.9 → 9.6 ms between runs) and cannot gate either way.

Allocations unchanged on every suite.

Verification

  • Jint.Tests green (net10.0 + net472), Jint.Tests.PublicInterface green
  • Full test262: 99,260 passed, 0 failed

🤖 Generated with Claude Code

lahma and others added 2 commits July 6, 2026 13:47
…l slot lane

Three per-iteration cost cuts for lexical blocks in hot loops (stopwatch-modern
227.0 -> 190.6 ms/iter locally, -16%; classic stopwatch unchanged):

- JintBlockStatement.ExecuteBlock: when the block environment registered no
  dispose resources, finalize inline instead of round-tripping a
  DisposeStepResult through CompleteDispose (BeginDispose on an empty
  capability is a pure Done).
- ExecutionContextStack.ReplaceTopLexical/VariableEnvironment: write the
  single field in place instead of rebuilding the 10-field ExecutionContext
  struct; the instance lives in a mutable array slot.
- JintVariableDeclaration: slot lane for let/const identifier declarations —
  resolve the slot index once per slot-layout identity (Key[] identity is
  stable across engines for shared prepared scripts) and initialize the slot
  directly, skipping the pooled Reference round-trip and name scan.
  Function-definition/class initializers, using declarations, destructuring,
  and eval/arguments names stay on the existing path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inline lexical arm grew ExecuteInternal enough to cost var-only call
workloads ~6% through pure code-size/layout effects (MixedArithmetic
15.38 -> 16.28 ms). Keeping the slot lane inline and extracting only the
Reference-based slow arm restores the var path (15.61 ms) without giving
back the lane win.

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