Skip to content

perf: Array-subclass numeric indexing leaves Wolf ECS hot loop 191x slower than Node #8655

Description

@proggeramlug

Summary

Numeric iteration over stable Array subclasses stays on Perry's fully generic dynamic-index/property machinery. A self-contained current-main repro is 191.1x slower than Node on an M1 Mac mini even though both produce the same checksum.

This is the dominant shape behind the public wolf-ecs iteration results. The newest class PRs do not address the hot loop: #8653 accelerates safe class-field initialization, #8649 removes some derived-constructor scope setup, and #8645 restores Array-subclass correctness. The repeated numeric reads and stable .length accesses remain generic after setup.

Tested with current main c2da03439e6e848cfed73163c131acb601ae96dc (Perry 0.5.1519) and Node 26.5.1.

Self-contained repro

class Query extends Array {
  archetypes = this;
}

class Archetype extends Array {
  entities = this;
}

const entityCount = 1_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);

const components = new Uint32Array(entityCount);

function system(values) {
  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;
    }
  }
}

const start = performance.now();
for (let i = 0; i < iterations; i++) system(components);
const elapsedMs = performance.now() - start;
console.log(JSON.stringify({ elapsedMs, checksum: components[0] }));

Build from a Perry checkout:

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

Measurement

Machine: M1 Mac mini, 8 GiB, macOS 26.5.1. Three process warmups, then 11 alternating Node/Perry samples. The timed region is inside each process; both runtimes report checksum: 2000.

Runtime median CV relative MAD
Node 26.5.1 2.329 ms 0.96% 0.57%
Perry 0.5.1519 445.038 ms 0.40% 0.10%

Perry slowdown: 191.06x.

The upstream noctjs/ecs-benchmark wolf-ecs/simple_iter case is worse: the full auto-optimized sweep measured Perry 1,372.02x slower than Node (95% bootstrap interval 1,369.44-1,381.39x).

Native profile from the upstream case

A symbolized 5-second sample of wolf-ecs/simple_iter captured 3,772 main-thread samples:

  • four shape/property functions: 30.1% of all leaf samples;
  • js_dyn_index_get: 8.9%;
  • the three compiled workload closures combined: about 3%.

The remaining leading frames repeatedly classify typed arrays/arguments/class objects, create and convert key strings, hash/compare keys, validate GC headers, create runtime handle scopes, and allocate. The component arithmetic itself is not the bottleneck.

The relevant Wolf shape is the same as the repro: a captured Query extends Array, query[i] returning Archetype extends Array, current[j] returning an entity index, and a typed component-array access at that index.

Suggested direction

  • Preserve a guarded Array/Array-subclass element representation for captured and imported bindings.
  • Lower stable packed numeric reads without entering the typed-array, arguments-object, class-object, and dynamic-string-key dispatch chain.
  • Hoist invariant shape/prototype/version guards and .length resolution outside the entity loop, with a semantics-preserving side exit for indexed accessors, proxies, prototype mutation, holes, or element-kind changes.
  • Keep the typed component-array access specialized when its numeric index comes from a guarded packed array.

This is narrower than #5497: the repro is untyped JavaScript and needs runtime-guarded specialization. It also still reproduces after the captured-array fix in #6377/#6369.

Acceptance criteria

  • Add the repro as a native semantic/performance ratchet; checksum must remain 2000 in Node and Perry.
  • Prove in emitted IR/native-region artifacts that the inner loop does not call js_dyn_index_get or redo constant .length key construction/shape lookup per entity.
  • Cover ordinary arrays and Array subclasses, captured and parameter forms, plus guard-failure tests for holes, indexed prototype accessors, prototype mutation, and proxies.
  • Improve the reduced repro by at least 5x on the same host/configuration without regressing ordinary packed-array iteration.
  • Re-run wolf-ecs/simple_iter, becsy/simple_iter, javelin-ecs/simple_iter, and piecs/simple_iter with semantic parity, RSS, and executable-size deltas reported.

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