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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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