Skip to content

perf(vm): iterator fast path — step built-in iterators without a call or result object - #29

Merged
nooga merged 3 commits into
nooga:mainfrom
mparrett:perf/vm-iterator-fastpath
Jul 25, 2026
Merged

perf(vm): iterator fast path — step built-in iterators without a call or result object#29
nooga merged 3 commits into
nooga:mainfrom
mparrett:perf/vm-iterator-fastpath

Conversation

@mparrett

Copy link
Copy Markdown
Contributor

for-of over the built-in iterables spent a method call and a {value, done} allocation per element. When the loop's cached next is a recognized built-in iterator, step it directly with no call and no result object.

What this does

  • Two opcodes (OpIterFastCheck 172, OpFastIterNext 173). The compiler already caches the iterator's next in a register once per loop (matching the spec's IteratorRecord.[[NextMethod]] caching). OpIterFastCheck inspects that cached value once; OpFastIterNext advances the built-in iterator state in place. Anything unrecognized — a user iterator, a replaced next — fails the check and takes the unchanged generic call path.
  • Coverage grows across the three commits: plain arrays first, then string / arguments / array-likes / keys() / entries() (via a shared BuiltinIterState with a per-kind Step), then Map / Set iterators.
  • Design point for review: Map/Set iterators share a single next on their prototype, so per-iterator state can't ride the next closure the way the array family's does. It lives in a new PlainObject.internalIterState pointer (+8 bytes per object). Offsetting correctness win: those slots were previously enumerable user-visible properties; moving them into the struct makes them unobservable internal slots (spec-correct) and the brand check O(1).

Step re-reads the source length/content every iteration, so growth, truncation, and hole normalization during iteration behave exactly like the native next it bypasses (both call the same Step).

Numbers

Local (M2): for-of over a plain array 4.1×, string iteration 2.4×, Set iteration 2.6×, Map for-of ~10% (destructuring-bound; see the destructuring PR). The perf-label same-runner A/B is the authoritative check.

Verification

TestScripts green; tests/scripts/for_of_array_fast_path.ts and the builtin/Map-Set iterator scripts cover hole normalization, length re-read on growth/truncation, shared state with manual next(), deopt on replaced next, and reuse after early break. Test262 language 0 new failures (differential vs upstream/main control); targeted built-ins 0 new failures.


Part of a VM micro-optimization series (profile-driven, one slice per PR). A tracking issue with the full map and a reviewer's guide follows.

mparrett and others added 3 commits July 21, 2026 06:01
for-of previously paid, per element, a full OpCallMethod into the array
iterator's native next closure, a fresh {value, done} PlainObject, and
two property gets to unpack it. Two new opcodes remove all of that:

- OpIterFastCheck (once per loop): true iff the cached next method is
  the built-in array iterator's closure. The compiler caches next in a
  register per the spec's IteratorRecord.[[NextMethod]], so a single
  loop-start check is exactly conformant; user iterators, generators,
  and replaced next methods fail it and take the generic path, which is
  emitted unchanged.
- OpArrayIterNext (per iteration): steps the iterator via ArrayIterState
  hung off the next closure's NativeFunctionObject - value and done land
  directly in registers.

The state is shared between the closure and the opcode, so manual
it.next() calls interleaved with for-of advance one index, and the
iterator object remains an ordinary PlainObject (own props, prototype,
Symbol.iterator self-return all unchanged). Length is re-read per step
and elements go through ArrayObject.Get, so growth, truncation, and
hole normalization behave exactly like the closure they bypass.

for-of workload (3M steps over a 1000-element array, min of 5
interleaved CLI runs): 0.70s -> 0.17s (4.1x). Fib/matrix unaffected.

TestScripts green incl. new for_of_array_fast_path.ts (holes, growth,
truncation, manual-next interleaving, replaced-next deopt, break/reuse).
Test262 language/statements/for-of: 0 new failures (5 pre-existing);
built-ins/ArrayIteratorPrototype: 0 new failures (10 pre-existing).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Generalizes ArrayIterState into BuiltinIterState with a Kind enum
(array values/keys/entries, array-like values, arguments, string) and a
single Step() that both the native next closures and the renamed
OpFastIterNext opcode call - one source of truth for index state and
per-step semantics (length re-read, hole normalization, surrogate-pair
code-point iteration with WTF-8 lone-surrogate preservation).

The five closure-based iterator creators in array_init.go and the
string iterator now share one makeBuiltinIterNext wrapper, deleting
five hand-rolled copies of the {value, done} closure. OpIterFastCheck
is unchanged: any next method carrying state qualifies, so the compiler
needed no changes beyond the opcode rename.

String iteration workload (3.6M code-point steps, min of 5 interleaved
CLI runs): 1.12s -> 0.47s (2.4x; the yielded per-step string allocation
is inherent). keys/entries drop the same call+result overhead (entries
keeps its inherent pair allocation).

TestScripts green incl. new for_of_builtin_iterators.ts (surrogate
pairs, lone surrogates, arguments, keys, entries destructuring, manual
next() interleaving on a string iterator).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Map/Set iterators keep the spec shape - shared %MapIteratorPrototype%/
%SetIteratorPrototype% next, no own next - so their state cannot live
on the next closure like the array family's. Instead the internal
slots ([[IteratedMap]], [[MapNextIndex]], [[MapIterationKind]],
[[Exhausted]]) move from user-visible enumerable properties (a spec
leak: internal slots must be unobservable) into a BuiltinIterState
hung off a new PlainObject.internalIterState pointer. The shared next
natives shrink to a brand check plus Step(), and branding becomes the
state's presence and family kind - %MapIteratorPrototype%.next.call on
a Set iterator still throws.

Both opcodes gain an iterator operand: OpIterFastCheck and
OpFastIterNext resolve state from the next closure first (array
family) and fall back to the iterator object when next carries the
IterKindStateOnIterator sentinel (the shared Map/Set next). Step
preserves live-iteration semantics exactly: tombstoned entries are
skipped, entries added mid-iteration are visited, Exhausted is sticky.

Set iteration workload (2M steps, min of 5 interleaved CLI runs):
0.45s -> 0.17s (2.6x). Map for-of improves only ~10%: its per-step
cost is dominated by [k, v] pair destructuring, which runs the full
iterator protocol per pair - filed as the next optimization target.

TestScripts green incl. new for_of_map_set_iterators.ts (delete/add
during iteration, keys/values/entries, manual next interleave,
cross-brand next.call rejection).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Matt — the fast-path gating is fail-safe (only triggers on Paserati's own IterState-tagged natives, anything else falls to the untouched general path), and .return()-on-break/throw is preserved since cleanup keys off the iterator object itself, independent of the fast path. Verified this manually too. Merging.

@nooga
nooga merged commit 1466698 into nooga:main Jul 25, 2026
8 checks passed
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.

2 participants