Skip to content

perf(codegen/runtime): fast-path for-in over stable monomorphic keys #8694

Description

@proggeramlug

Summary

Perry implements for...in over a stable monomorphic registry object by rebuilding a key list through generic object enumeration on every call. In perform-ecs@0.7.8, ComponentGroupRegistry.pushEntity/removeEntity repeatedly enumerate a registry that normally has one stable own key, so the hot path allocates arrays, walks descriptors/prototypes, converts keys, and hashes strings for each entity operation.

Add a semantics-preserving fast path for for...in when the receiver and its prototype chain have stable enumerable-key generations. The fast path may use compiler-known keys or a cached enumeration plan, but must fall back when JavaScript enumeration semantics could differ.

Self-contained reproduction

const groups = {};
groups[3] = [];

function pushEntity(entity) {
  for (const groupHash in groups) {
    groups[groupHash].push(entity);
  }
}

function removeEntity(entity) {
  for (const groupHash in groups) {
    const entities = groups[groupHash];
    const index = entities.indexOf(entity);
    if (index !== -1) entities.splice(index, 1);
  }
}

const iterations = 200_000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
  const entity = { id: i };
  pushEntity(entity);
  removeEntity(entity);
}
console.log(JSON.stringify({
  elapsedMs: performance.now() - start,
  remaining: groups[3].length,
}));

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

Node and Perry must both finish with remaining: 0.

Current evidence

In a symbolized profile of the corrected public ddmills/js-ecs-benchmarks perform-ecs/destroy workload, ComponentGroupRegistry.pushEntity and removeEntity enter:

js_for_in_keys_value
  -> js_object_keys / js_object_get_own_property_names
     -> key-array allocation and push
     -> prototype/class/descriptor/shape traversal

Leading leaf samples included shape_descriptor_by_id (8.2%), the HashMap key iterator used by object-key enumeration (5.6%), UTF-8 key conversion (4.7%), SipHash (3.2%), and descriptor attribute lookup (2.3%). These are not all exclusively for...in, but the call graph places a substantial part of them below repeated stable registry enumeration.

The full workload remains 33.091x slower than Node after semantic fixes. About 82% of time is entity creation/component addition, where the registry is enumerated repeatedly; only 16% is destruction.

Proposed direction

Two acceptable implementation shapes are:

  1. Compiler specialization: when an exact object shape and stable prototype chain are proven, emit the ordered enumerable keys directly under shape/enumerability/prototype generation guards.
  2. Runtime enumeration cache: cache an immutable enumeration plan by exact shape plus prototype/enumerability generations, avoiding descriptor walks, hashing, and key-array allocation on cache hits.

Whichever layer owns it:

  • preserve the generic for...in implementation as fallback;
  • avoid allocating a fresh key array on the stable hit path;
  • represent integer-index and string-key ordering explicitly;
  • invalidate on own or inherited key/descriptor/prototype changes;
  • expose hit/fallback decisions in trace or lowering artifacts so the optimization is non-vacuous.

Semantic constraints

  • Preserve ECMAScript for...in ordering for integer-index keys and strings; symbols remain excluded.
  • Preserve duplicate suppression across the prototype chain.
  • Respect enumerable/non-enumerable descriptors, deletions before visitation, additions during enumeration, own shadowing, prototype replacement, and proxies.
  • If loop-body mutation cannot be proven absent, either revalidate at the required point or use the generic path.
  • Do not reuse an enumeration plan across realms/heaps or incompatible shapes/prototype generations.
  • Keep cached plans rooted or non-GC-bearing according to their representation, with forced-moving-GC coverage.

Acceptance criteria

  • Register the reproduction as a semantic/performance/compiler-output ratchet.
  • The stable one-key arm does not call js_for_in_keys_value, js_object_keys, or js_object_get_own_property_names, and does not allocate a key array per invocation.
  • Artifacts or trace counters prove the optimized arm is selected in the fixture and the generic fallback remains reachable.
  • Tests cover integer/string ordering, inherited enumerables, duplicate suppression, non-enumerables, deletion/addition during iteration, descriptor/enumerability changes, prototype mutation/replacement, proxies, exceptions, realms/heaps if supported, and forced-moving GC.
  • Re-run ddmills/js-ecs-benchmarks perform-ecs/destroy and its full perform-ecs adapter set with exact state/checksum parity.
  • On the quiet M1 alternating protocol, require at least a 5% median perform-ecs/destroy improvement with at least 9/11 wins; report enumeration-helper samples, allocations/op if available, RSS, and executable-size deltas.

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