Repository navigation
perf(codegen): version nested packed loops over immutable closure captures #8773
Description
Summary
Perry's version-stable packed-loop optimization does not admit immutable arrays held in closure-capture slots. That excludes the actual wolf-ecs/simple_iter shape even after #8755 and the forwarded-array follow-up #8767: each system closes over a Query extends Array, loads an Archetype extends Array from it, then performs a nested indexed loop.
Add guarded loop versioning for immutable closure-captured packed Arrays/Array subclasses, including the nested case where the inner array is obtained from a guarded outer indexed read. Keep the generic loop as the side exit whenever capture storage, identity, layout, version, or mutation proofs fail.
Self-contained reproduction
class Query extends Array {}
class Archetype extends Array {}
function setup(entityCount) {
const query = new Query();
const archetype = new Archetype();
for (let i = 0; i < entityCount; i++) archetype.push(i);
query.push(archetype);
const values = new Uint32Array(entityCount);
function system() {
for (let i = 0, length = query.length; i < length; i++) {
const current = query[i];
for (let j = 0, length = current.length; j < length; j++) {
values[current[j]] += 1;
}
}
}
return () => {
system();
return values[0];
};
}
const run = setup(1_000);
const iterations = 2_000;
let checksum = 0;
const start = performance.now();
for (let i = 0; i < iterations; i++) checksum = run();
console.log(JSON.stringify({
elapsedMs: performance.now() - start,
checksum,
}));Node and Perry must both finish with checksum: 2000. The public integration case is noctjs/ecs-benchmark at 7b53a36606118e8b2a450a2ba4919939c86bbd2e, src/cases/wolf-ecs/simple_iter.js, with wolf-ecs from that lockfile.
Build and retain 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-cacheCurrent evidence
Measured on an Apple M1 Mac mini (8 GiB, macOS 26.5.1, Node 26.5.1), AC power, after a 60-sample CPU quiet gate, three discarded process warmups, and 11 alternating Node/Perry process pairs:
| Runtime | median |
|---|---|
| Node | 0.004938 ms/op |
Perry 20a388974d7333ccaec9da9175fbcee54ad49902 |
0.780749 ms/op |
Perry is 158.10x slower. Every process completed successfully.
A symbolized one-second profile synchronized to the timed phase collected 759 main-thread samples. js_packed_arraylike_index_get was the exclusive leaf in 413/759 samples (54.4%). The three system closures accounted for another 81, 76, and 69 exclusive samples.
The same full Wolf executable compiled from #8767 head 7e223e9dcaa87ad6415d85aec7ff201428e5f716 measured 0.780296 ms/op in a confirmation run: effectively unchanged. Its retained LLVM module contains 21 calls to js_packed_arraylike_index_get across the three systems (seven indexed reads per system) and zero stable_packed.loop.fast / stable_packed.loop.slow blocks.
This is expected from the current admission rule: packed_loop_array_binding_storage_is_addressable explicitly rejects ctx.closure_captures, and stable_packed_loop::match_candidate separately rejects a captured array_id. #8767 follows one forwarding edge after a candidate has been admitted; it does not make closure captures candidates and is not a duplicate of this issue.
Proposed direction
- Represent an immutable closure-capture slot as addressable versioned-loop storage, or copy its tagged value into a shadow-bound compiler-private local at the loop preheader.
- Admit the captured outer array only after the full packed Array/Array-subclass layout, prototype/descriptor, identity, forwarding, and version guards used by perf(codegen): version stable packed array loops #8755/perf(codegen): admit forwarded arrays in versioned loops #8767.
- Reload the capture/root after any collecting operation; never retain a raw movable pointer across a safepoint.
- Let the guarded outer indexed read nominate the resulting element for a second guarded packed-loop version, so
const current = query[i]can feed the inner loop without reverting to per-index helpers. - Revalidate the relevant compact fingerprints before effects at the same points as the existing versioned loop and side-exit to the unchanged generic iteration on failure.
- Record selection and rejection reasons for closure-capture and nested-derived candidates in
--explain-lowering; the current silent absence of a loop record makes this gap harder to diagnose.
Semantic constraints
- Preserve closure binding identity and rebinding semantics; mutable captures, boxed captures, aliases that can rebind, and unknown capture layouts must remain generic.
- Preserve Array-subclass species/prototype/indexed descriptor behavior, holes, accessors, proxies, length changes, and forwarding identity.
- Preserve mutation and exception ordering. A callback/getter/proxy or loop-body effect that can invalidate the layout must revalidate or side-exit before the next indexed effect.
- Preserve moving/forced-GC correctness: capture and derived-element roots must be rewritten by GC, and raw addresses must not survive safepoints.
Acceptance criteria
- Register the reproduction as semantic and compiler-output coverage; Node and Perry print checksum
2000, including withPERRY_GC_FORCE_EVACUATE=1. - The stable fast arms for both
query[i]andcurrent[j]contain nojs_packed_arraylike_index_get; explicit generic fallback/continuation arms remain present and are exercised. --explain-loweringrecords that the closure-captured outer binding and nested derived array were selected, including guard/fallback identities.- Negative tests cover capture rebinding, capture aliasing, holes, accessors, proxies, subclass/prototype mutation, length growth/shrink, forwarding, exceptions, and forced-moving GC.
- Re-run the full upstream
wolf-ecs/simple_itercase with a state/checksum oracle added to the harness. On the quiet M1 alternating protocol, require at least a 1.5x Perry median improvement and at least 9/11 paired wins; report helper samples, RSS, and executable-size delta.
Related work
- perf(codegen): version stable packed array loops #8755 added version-stable packed loops for addressable locals/module globals.
- perf(codegen): admit forwarded arrays in versioned loops #8767 admits one-edge-forwarded arrays after the existing candidate check; it does not cover closure captures.
- perf(codegen): direct guarded typed-array RMW through dynamic numeric indices #8692/perf(codegen): guard direct Uint32Array RMW; scalar-replace aggregate call literals #8708 added guarded direct
Uint32ArrayRMW, which should become the inner value-update path once the packed index reads stop falling back.
Activity