Repository navigation
perf(codegen): loop-version stable packed Array and Array-subclass iteration #8690
Description
Summary
After #8655, Perry can hit an inline packed-arraylike path for Array-subclass numeric reads, but it still repeats the full representation proof inside the entity loop. Stable ECS iteration therefore pays header, kind, forwarding, version, bounds, inline/spill, prototype, and fallback checks for every element instead of proving the loop once.
The remaining optimization is general loop versioning: enter a guarded private packed loop from a preheader, execute direct indexed loads in the fast arm, and transfer to the generic loop at the first semantically safe index if a proof changes.
Typed-array read-modify-write is tracked separately in #8692; this issue owns the source Array/Array-subclass iteration and guard placement.
Self-contained reproduction
This read-only variant isolates packed Array-subclass iteration from component TypedArrays:
class Query extends Array {}
class Archetype extends Array {}
const entityCount = 10_000;
const iterations = 2_000;
const query = new Query();
const archetype = new Archetype();
for (let i = 0; i < entityCount; i++) archetype.push(i);
query.push(archetype);
function scan() {
let sum = 0;
for (let i = 0, length = query.length; i < length; i++) {
const current = query[i];
for (let j = 0, length = current.length; j < length; j++) {
sum = (sum + current[j]) | 0;
}
}
return sum;
}
let checksum = 0;
const start = performance.now();
for (let i = 0; i < iterations; i++) checksum ^= scan();
console.log(JSON.stringify({ elapsedMs: performance.now() - start, checksum }));Build and retain LLVM/lowering evidence:
cargo build --release -p perry -p perry-runtime-static -p perry-stdlib-static
PERRY_NO_AUTO_OPTIMIZE=1 \
PERRY_RUNTIME_DIR=target/release \
target/release/perry compile repro.js -o repro-perry \
--trace llvm --opt-report=json --explain-lowering --no-cacheThe original #8655 reproduction should remain a second integration fixture because it matches wolf-ecs and has a stable before/after cohort.
Current evidence
On Perry 7ad718ab4287641cb2b29dce3a056edc45d4c7f8, the original #8655 reproduction improved from 445.038 ms to 70.496 ms, but remains 31.76x slower than Node on an M1 Mac mini.
Fresh LLVM inspection shows:
query[i]still callsjs_object_get_index_polymorphicafter object coercibility and symbol/string/numeric dispatch.current.lengthuses a generic PIC/property path with repeated class/string/null checks.current[j]has an inline packed-arraylike arm, but repeats header validity, array classification, element kind, forwarding/version, bounds, and inline/spill checks for every entity, withjs_packed_arraylike_index_getas fallback.- GC poll/root/barrier checks remain in the inner loop even when the fast arm cannot allocate or call arbitrary user code.
The unchanged codehz/ecs query profile shows the same architectural ceiling: after packed method-shape guard work, Perry remains 2.897x slower than Node for accumulation and 6.125x for read-only iteration. The dominant accumulation subtree is repeated method/array proof inside Archetype.forEachWithComponents, not one remaining header-load micro-optimization.
Proposed direction
- Recognize general counted loops over exact packed Array or Array-subclass inputs with a stable element representation.
- Emit a preheader that proves receiver/class/shape, descriptor state, prototype/indexed-accessor state, forwarding/version, element kind, holes policy, length, and backing storage once.
- Enter a private fast loop containing direct length/indexed loads and only the minimal checks required by calls or safepoints inside the body.
- If the body can mutate the receiver, prototype, or aliased arrays, validate sticky generations after the mutation point and side-exit to the generic loop at the first safe index.
- Refresh relocated roots after possible collection without discarding unrelated stable proofs.
- Keep the generic loop as an explicit fallback; do not bake in ECS class names, method names, source locations, or fixed loop sizes.
Semantic constraints
- Preserve holes, inherited indexed accessors, proxies, species/subclass behavior where relevant, descriptors, prototype mutation, array growth/shrink, element-kind transitions, and exception order.
- Preserve JavaScript
forloop ordering iflength, the indexed getter, or the body has side effects. - A callback capable of mutating the same array through an alias must either force fallback or trigger safe revalidation.
- Remain correct when arrays move or grow under forced GC; forwarding compression alone is not a proof that a cached pointer stays valid.
Acceptance criteria
- Register the read-only reproduction and the original perf: Array-subclass numeric indexing leaves Wolf ECS hot loop 191x slower than Node #8655 reproduction as semantic/compiler-output fixtures.
- The read-only fast loop contains no
js_object_get_index_polymorphicorjs_packed_arraylike_index_get, and does not repeat full array classification/shape/prototype checks per element. - Native-region artifacts identify the preheader proof, minimal in-loop revalidations, and generic side exit.
- Mutation tests cover holes, indexed prototype accessors, prototype replacement, descriptors, proxies, growth/shrink, element-kind changes, aliasing from the loop body/callback, thrown getters/callbacks, and forced-moving GC.
- On the quiet M1 alternating protocol, improve the isolated read-only reproduction by at least 2x and the unchanged ECS accumulation row by at least 2%, each with at least 9/11 paired wins.
- Re-run the unchanged
codehz/ecssuite andnoctjs/ecs-benchmarkwolf-ecs/simple_iter, reporting Node/Perry medians, semantic checksums, RSS, and executable-size changes.
Related work
- perf: Array-subclass numeric indexing leaves Wolf ECS hot loop 191x slower than Node #8655 added the first guarded Array-subclass numeric-index path and met its 5x ratchet; this issue owns the remaining repeated-proof/loop-versioning layer.
- perf(codegen): pack monomorphic method shape guards #8676 packed monomorphic method-shape guards and produced another 3-4% win, but deliberately retained per-call/per-element guards.
- tracking: close the remaining 7 losing rows vs Node (12/19 won, geomean 0.7775x) #8410 tracks the remaining benchmark rows globally; this ticket is the self-contained iteration mechanism needed by several rows.
Activity
proggeramlug commentedon Aug 24, 2026
Implemented by #8755 and remeasured on current main
20a388974d7333ccaec9da9175fbcee54ad49902on the M1 Mac mini.The reduced Array-subclass +
Uint32Arraykernel improved from 70.495958 ms to 12.993875 ms (5.43x faster) with checksum2000in every Node/Perry process. Perry is now 5.839x slower than Node on that kernel (95% bootstrap interval 5.813-5.860x). The previously blocked upstreamnoctjs/wolf-ecs/simple_itercase now completes and measures 0.780749 ms/op in Perry versus 0.004938 ms/op in Node.Protocol: 3 discarded process warmups, 11 alternating measured processes per runtime, 60-sample >=75% CPU-idle gate, AC power,
taskpolicy -t 0 -l 0. The remaining full-workload gap will be tracked in narrower profile-backed follow-ups.perf(runtime): one validity word replaces the per-hop chain walk, and…
perf(runtime): one validity word replaces the per-hop chain walk, and…
perf(runtime): one validity word replaces the per-hop chain walk, and…
perf(runtime): one validity word replaces the per-hop chain walk, and…
perf(runtime): one validity word replaces the per-hop chain walk, and…
fix(runtime): the own-override guard's predicate and its arm (#10943 …
fix(runtime): the own-override guard's predicate and its arm (#10943 …
fix(runtime): the own-override guard's predicate and its arm (#10943 …
fix(runtime): the own-override guard's predicate and its arm (#10943 …