Skip to content

Reuse a fixed-slot per-iteration environment in for-of/for-in#2586

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-forof-iteration-env
Jul 6, 2026
Merged

Reuse a fixed-slot per-iteration environment in for-of/for-in#2586
lahma merged 2 commits into
sebastienros:mainfrom
lahma:perf-forof-iteration-env

Conversation

@lahma

@lahma lahma commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

for (const x of arr) / for (const k in obj) previously paid per iteration: a fresh DeclarativeEnvironment, a List<Key> + Key[] from GetBoundNames inside BindingInstantiation, dictionary-backed bindings (HybridDictionary + nodes — the fresh environment has no slot layout), and a pooled-Reference round-trip through ResolveBinding + InitializeReferencedBinding.

The spec creates a fresh binding per iteration with no copy step (unlike for (let ...)), so when nothing can observe environment identity the loop now reuses one fixed-slot environment:

  • Eligibility (static, computed once per handler): lexical head (let/const, 1–16 bound names), and EnvironmentEscapeAstVisitor proves nothing in the body — or in a destructuring pattern's default-value expressions — captures or escapes (closures, direct eval). using/await using heads stay on the existing path (they register per-iteration dispose resources).
  • Per iteration: ResetSlots re-establishes TDZ from the templates, the single identifier binding initializes straight into its slot, destructuring initializes into slots by name via the existing ProcessPatterns path. The environment's stable identity also keeps the body's per-node slot caches hot.
  • Pooling across loop entries on the handler instance with the Interlocked + engine-identity discipline of JintForStatement._cachedLoopEnv; parked (and reset) in the finally so the cached env never roots the completed loop's values.
  • Suspendable contexts (generators/async) are excluded — a pooled environment must never round-trip through suspend/resume — and keep the existing per-iteration path unchanged.
  • Rider: HeadEvaluation no longer allocates its TDZ environment when there are no TDZ names (var/assignment forms).

Measurements

BenchmarkDotNet default jobs, A/B vs main (with #2585) from separate worktrees:

case main PR delta
IterationBenchmark.ForOfArray 38.77 ms / 59.83 MB 29.98 ms / 22.44 MB −22.7% time / −62.5% alloc
IterationBenchmark.MapIteration 50.98 ms / 89.87 MB 42.16 ms / 50.96 MB −17.3% / −43.3%
IterationBenchmark.SpreadArray 32.94 ms 33.08 ms flat (no lexical head)
IterationBenchmark.Destructuring 46.38 ms 46.88 ms within noise, alloc identical
DromaeoBenchmark.ObjectArray (4 cases) flat to slightly better

Allocation census (fresh engine per iteration, for (const x of arr) over 1000 elements × 300 loops): 148.9 → 36.8 MB/iter (−75%), 52.9 → 35.2 ms/iter (−33%). The environment types (DeclarativeEnvironment, Key[], DictionaryNode[Binding], HybridDictionary, Reference) disappear from the profile entirely; what remains is iterator-protocol IteratorResult steps — a separate follow-up.

Verification

🤖 Generated with Claude Code

for-of/for-in with a lexical head previously allocated per ITERATION: a
DeclarativeEnvironment, a List<Key> + Key[] from GetBoundNames, dictionary-
backed bindings (HybridDictionary + nodes), and a pooled-Reference
round-trip from ResolveBinding + InitializeReferencedBinding.

The spec creates a fresh binding per iteration with no copy step, so when
nothing captures the environment the loop now reuses ONE fixed-slot
environment: ResetSlots at iteration start re-establishes TDZ, the single
identifier binding initializes straight into its slot, and destructuring
initializes into slots by name. Escape analysis (EnvironmentEscapeAstVisitor
over the body, and over destructuring default-value expressions) gates
eligibility; using/await-using heads (per-iteration dispose resources) and
suspendable contexts (generators/async — a pooled env must never cross a
suspension) stay on the existing path. The env is pooled across loop entries
on the handler with the Interlocked + engine-identity discipline of
JintForStatement._cachedLoopEnv, and its stable identity keeps body slot
caches hot.

Also: HeadEvaluation no longer allocates the TDZ environment when there are
no TDZ names (var/assignment forms).

for-of over a 1000-element array (fresh engine, 300 loops):
148.9 -> 36.8 MB/iter (-75%), 52.9 -> 35.2 ms/iter (-33%); remaining
allocations are iterator-protocol IteratorResult steps, not environments.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma enabled auto-merge (squash) July 6, 2026 16:05
@lahma
lahma merged commit 500140d into sebastienros:main Jul 6, 2026
4 checks passed
lahma added a commit that referenced this pull request Jul 6, 2026
#2594)

Analysis of #2587 confirmed that everything a prepared script retains
after engine disposal is engine-independent prepare-time metadata by
design (constant-folded primitives, FDI State, block declaration
caches, binding names) and that no Engine/Realm/Environment can be
reached from the shared AST.

The regression test guarding that invariant predates two environment
cache shapes added since: the for-of/for-in per-iteration environment
cache on the JintForInForOfStatement handler (#2586) and the
Function-constructor definition-level environment parked on the
realm-cached dynamic State (#2579). Cover both so a future refactor
moving either onto shared AST state fails the test.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@lahma
lahma deleted the perf-forof-iteration-env 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