Repository navigation
perf(codegen/runtime): fast-path for-in over stable monomorphic keys #8694
Description
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-cacheNode 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...inimplementation 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...inordering 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, orjs_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-benchmarksperform-ecs/destroyand its fullperform-ecsadapter set with exact state/checksum parity. - On the quiet M1 alternating protocol, require at least a 5% median
perform-ecs/destroyimprovement with at least 9/11 wins; report enumeration-helper samples, allocations/op if available, RSS, and executable-size deltas.
Related work
- perf(codegen): specialize imported class methods that capture this #8693 is complementary: it removes imported-
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. - codegen: type-directed specialization — emit native ops where the type is statically known (#5334 Tier 3 / Lever E) #5497 is broader type-directed specialization; this issue is a concrete dynamic-language key-enumeration slice with an exact public workload and semantic gate.
Activity
proggeramlug commentedon Aug 24, 2026
Closed by #8709, landed on
mainvia #8710 (squash2ecdd1adf).That PR was landed through a batch wrapper PR rather than merged directly, so GitHub's
Closes #8694keyword never fired at merge time — itsmergedAtis null even though the work is onmain. Verified before closing:crates/perry-runtime/src/object/field_get_set/for_in_stable.rsis present onmain, along withchangelog.d/8709-stable-for-in-snapshot.mdand theissue_8694_stable_for_inacceptance test.