Skip to content

perf(codegen): version nested packed loops over immutable closure captures #8773

Description

@proggeramlug

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-cache

Current 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 with PERRY_GC_FORCE_EVACUATE=1.
  • The stable fast arms for both query[i] and current[j] contain no js_packed_arraylike_index_get; explicit generic fallback/continuation arms remain present and are exercised.
  • --explain-lowering records 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_iter case 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    performanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions