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:
- 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.
- 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
Summary
Perry implements
for...inover a stable monomorphic registry object by rebuilding a key list through generic object enumeration on every call. Inperform-ecs@0.7.8,ComponentGroupRegistry.pushEntity/removeEntityrepeatedly 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...inwhen 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
Build and retain LLVM/lowering evidence:
Node and Perry must both finish with
remaining: 0.Current evidence
In a symbolized profile of the corrected public
ddmills/js-ecs-benchmarksperform-ecs/destroyworkload,ComponentGroupRegistry.pushEntityandremoveEntityenter: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 exclusivelyfor...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:
Whichever layer owns it:
for...inimplementation as fallback;Semantic constraints
for...inordering for integer-index keys and strings; symbols remain excluded.Acceptance criteria
js_for_in_keys_value,js_object_keys, orjs_object_get_own_property_names, and does not allocate a key array per invocation.ddmills/js-ecs-benchmarksperform-ecs/destroyand its fullperform-ecsadapter set with exact state/checksum parity.perform-ecs/destroyimprovement with at least 9/11 wins; report enumeration-helper samples, allocations/op if available, RSS, and executable-size deltas.Related work
thismethod dispatch and exposes more registry bodies to compiler optimization, while this ticket removes enumeration work even when the body remains a separate compiled function.