perf(vm): array-destructuring fast path for pristine arrays - #35
perf(vm): array-destructuring fast path for pristine arrays#35mparrett wants to merge 2 commits into
Conversation
nooga
left a comment
There was a problem hiding this comment.
Thanks for this — the register-cleanup, holes/defaults/rest/nested-pattern handling, and the fast-arm/generic-arm split are all solid. But I found a confirmed correctness regression, so requesting changes before merge.
Bug: subclassed arrays silently bypass a custom Symbol.iterator in the fast path.
isFastDestructureArray approves the fast path via vm.GetSymbolProperty(v, vm.SymbolIterator), which checks the array's own symbol props then falls back unconditionally to vm.ArrayPrototype — it never consults the per-instance arr.prototype override used for class S extends Array {} subclasses. The existing opGetPropSymbol handles this correctly today (checks base.AsArray().prototype first), so the fast-path gate needs the same walk.
Repro:
class MyArray extends Array<number> {
[Symbol.iterator]() { /* yields value*1000 */ }
}
const a = new MyArray(); a.push(1,2,3);
const [x,y,z] = a; // destructuring
for (const v of a) out.push(v); // for-of- On
main(pre-PR): both destructuring and for-of give1000,2000,3000(custom iterator honored, consistent). - On this branch: destructuring gives
1,2,3(raw values — custom iterator silently skipped), for-of still gives1000,2000,3000.
This is exactly the "silent correctness regression" failure mode the project treats as worst-case, since it affects a real (if less common) pattern — Array subclassing with custom iteration — and the existing test suite (tests/scripts/destructure_array_fast_path.ts) only covers prototype-level Symbol.iterator reassignment, not subclassing, so it didn't catch this.
Ask: make isFastDestructureArray walk arr.prototype (falling back to vm.ArrayPrototype) the same way opGetPropSymbol does, and add a subclassed-array regression test alongside the existing fast-path tests. Happy to re-review once that's in.
…ine arrays
const [k, v] = pair (and for-of element patterns) previously ran the full
iterator protocol per pattern: build an iterator object (~4 allocations),
then call next() per element, each allocating a {value, done} result. When
the source is a plain array whose Symbol.iterator is still the canonical
array iterator, default iteration is exactly Arr.Get(0..len-1) - so the
elements can be read by direct index with no iterator, no calls, and no
result allocations.
Two opcodes: OpArrayDestructFastCheck identity-checks the source's
Symbol.iterator against the recorded canonical values() function (any
instance- or prototype-level override fails the check and takes the generic
path); OpArrayRawGetInt reads Arr.Get(idx) - bit-identical to what
BuiltinIterState.Step yields, so no accessor/proxy divergence. A per-element
branch selects fast vs generic and both converge on the value register, so
the existing binding, default, nested-pattern, and elision logic is unchanged.
On the fast arm doneReg is forced true, which short-circuits the existing
iterator-cleanup emit to a no-op (a pristine array iterator has no return()
method, so IteratorClose was always a no-op there anyway). Rest patterns keep
the generic path.
Applied at all three destructuring sites (const/let/assignment declaration,
for-of declaration, for-of assignment) via shared helpers in
compile_iterator_helpers.go.
Measured (Apple M2, min of 5 interleaved CLI A/B runs):
- const [k,v]=pair hot loop: 1360ms -> 281ms (4.8x)
- for (const [k,v] of map): 1586ms -> 415ms (3.8x)
Verification: TestScripts green (+ new regression script covering short
source, holes, defaults, elisions, nested patterns, rest, Map for-of, and
prototype-override deopt); compiler + vm unit tests green; Test262 language
suite 0 new failures / 0 new passes vs baseline_language.txt; targeted
built-ins (Array/Map/Set Symbol.iterator) 0 new failures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isFastDestructureArray gated on vm.GetSymbolProperty, whose array branch checks own symbol properties and then falls back to vm.ArrayPrototype unconditionally - it never consults the per-instance prototype used by `class X extends Array`. A subclass whose Symbol.iterator lives on X.prototype therefore looked pristine, the fast path was approved, and destructuring silently skipped the custom iterator that for-of still honored: `const [a,b,c] = sub` yielded raw elements while `for (const v of sub)` yielded the overridden values, for the same object. Resolve Symbol.iterator the way opGetPropSymbol does instead - own symbol properties, then arr.prototype falling back to the intrinsic Array.prototype, then that chain - and take the generic path on any link we cannot inspect. Pristine arrays still take the fast path; only the subclass case deopts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6da1173 to
336ed8c
Compare
|
Fixed in Root cause. Fix. Resolves Verification.
Also rebased. Three of the four commits (the for-of fast paths over plain arrays, over string/arguments/keys/entries, and over Map/Set) had already landed on 🤖 Generated with Claude Code |
|
Regression, largest on
Floors from null controls in the same run: two commits compiling to byte-identical binaries measure up to 3.7% apart on |
|
Correcting my verdict here: the +43.8% is real, and it is not this change. No benchmark fixture in What is left is code layout — this change re-encodes 44.1% of I would leave this open and unmeasured rather than close it as a regression. |
const [k, v] = pairandfor-ofelement patterns ran the full iterator protocol per pattern — build an iterator object, then anext()call per element with a{value, done}allocation each. For a pristine array the whole thing is equivalent to indexed reads.What this does
OpArrayDestructFastCheckidentity-checks the source'sSymbol.iteratoragainst the recorded canonical array iterator; any instance- or prototype-level override fails the check and takes the generic path.OpArrayRawGetIntreadsArr.Get(idx)— bit-identical to what the built-in array iterator'sStepyields, so no accessor/proxy divergence.next()); both converge on the value register, so the existing binding, default, nested-pattern, and elision logic is untouched. On the fast armdoneis forced true, which short-circuits the existing iterator-cleanup emit to a no-op — correct, since a pristine array iterator has noreturn()anyway.Behavior preserved: short source → undefined tail, holes → undefined, defaults, nested patterns, and (verified) deopt on a prototype-level
Symbol.iteratoroverride.Design point for review: the
Symbol.iteratoridentity check that guards the fast path.Numbers
Local (M2, min of 5 interleaved A/B):
const [k,v]=pairhot loop 4.8×;for (const [k,v] of map)3.8×. Theperf-label A/B is authoritative.Verification
TestScriptsgreen +tests/scripts/destructure_array_fast_path.ts(short source, holes, defaults, elisions, nested patterns, rest, Map for-of, prototype-override deopt). Test262 language 0 new failures (differential vs upstream/main control); targeted built-ins 0 new failures.Aside (filed separately, not part of this PR): while testing, instance-level
arr[Symbol.iterator] = ...overrides on arrays turn out to be ignored by the existing generic for-of/spread paths too. The fast path matches that behavior, so no regression — but it's a pre-existing conformance gap worth an issue.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.