perf(vm): iterator fast path — step built-in iterators without a call or result object - #29
Merged
Merged
Conversation
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>
This was referenced Jul 22, 2026
mparrett
marked this pull request as ready for review
July 22, 2026 06:04
This was referenced Jul 22, 2026
nooga
approved these changes
Jul 25, 2026
nooga
left a comment
Owner
There was a problem hiding this comment.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
for-ofover the built-in iterables spent a method call and a{value, done}allocation per element. When the loop's cachednextis a recognized built-in iterator, step it directly with no call and no result object.What this does
OpIterFastCheck172,OpFastIterNext173). The compiler already caches the iterator'snextin a register once per loop (matching the spec'sIteratorRecord.[[NextMethod]]caching).OpIterFastCheckinspects that cached value once;OpFastIterNextadvances the built-in iterator state in place. Anything unrecognized — a user iterator, a replacednext— fails the check and takes the unchanged generic call path.keys()/entries()(via a sharedBuiltinIterStatewith a per-kindStep), then Map / Set iterators.nexton their prototype, so per-iterator state can't ride thenextclosure the way the array family's does. It lives in a newPlainObject.internalIterStatepointer (+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).Stepre-reads the source length/content every iteration, so growth, truncation, and hole normalization during iteration behave exactly like the nativenextit bypasses (both call the sameStep).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
TestScriptsgreen;tests/scripts/for_of_array_fast_path.tsand the builtin/Map-Set iterator scripts cover hole normalization, length re-read on growth/truncation, shared state with manualnext(), deopt on replacednext, 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.