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

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