Repository navigation
perf(codegen): scalar-replace non-escaping aggregate literals #8691
Description
Summary
Perry does not scalar-replace short-lived aggregate literals passed through known ECS helper calls. The public perform-ecs/destroy loop allocates fresh arrays and descriptor objects several times per entity:
this.ecs.createEntity([
{ component: Position },
{ component: Velocity },
]);
this.ecs.addComponentsToEntity(entity, [{ component: Position }]);
this.ecs.addComponentsToEntity(entity, [{ component: Velocity }]);Once the consumers are known/inlined, these aggregates do not need material object/array identity on the common path. Current lowering performs zero scalar replacements, leaving allocation, GC-layout tracking, write barriers, array pushes/gets, and boxing in the hottest 82% of the benchmark.
Add escape analysis and scalar replacement for non-escaping object/array literals across specialized/inlined calls, with explicit materialization when identity or a generic call makes the allocation observable.
Self-contained reproduction
class Position {}
class Velocity {}
let checksum = 0;
function consume(initializers) {
for (let i = 0; i < initializers.length; i++) {
const initializer = initializers[i];
if (initializer.component === Position) checksum += 1;
if (initializer.component === Velocity) checksum += 2;
}
}
const iterations = 500_000;
const start = performance.now();
for (let i = 0; i < iterations; i++) {
consume([{ component: Position }, { component: Velocity }]);
consume([{ component: Position }]);
consume([{ component: Velocity }]);
}
console.log(JSON.stringify({
elapsedMs: performance.now() - start,
checksum,
}));Expected checksum: 3000000.
Build and retain artifacts:
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-cacheThe public integration reproduction is ddmills/js-ecs-benchmarks, perform-ecs@0.7.8, suite Destroy.
Current evidence
For the corrected public workload on Perry 7ad718ab4287641cb2b29dce3a056edc45d4c7f8, the lowering report contains:
- 494 records;
- 0 scalar replacements;
- 61 inserted boxes;
- 11 emitted barriers and 0 barrier eliminations;
- 9 dynamic fallbacks;
- 332
js_valuerepresentations versus only 34i32and 16i1representations.
Materialization reasons are dominated by runtime API edges (46), function ABI (8), and return ABI (7). A symbolized profile shows repeated allocation/GC-layout bookkeeping, generic array operations, GC-header validation, and barriers. The workload remains 33.091x slower than Node; entity creation and component addition account for approximately 82% of its time.
This optimization should follow or consume #8693's guarded direct-call/inlining work for imported methods that capture this; unknown calls must continue to force escape/materialization.
Proposed direction
- Run escape analysis after specialization/inlining, when actual consumers and field/index uses are visible.
- Track object and fixed-length array literals field-by-field when they do not escape to a heap store, return, closure, unknown call, reflective operation, proxy, weak reference, or identity comparison.
- Replace their property/index reads with SSA/native values and eliminate their allocations, layout registration, roots, and barriers.
- Support path-sensitive materialization at an explicit bailout/escape edge instead of rejecting the entire function when only a cold path observes identity.
- Start with plain data object literals and fixed-length packed array literals; do not include arbitrary constructors or observable custom prototypes in the first slice.
- Record escape, scalar-replacement, and forced-materialization decisions and reasons in
--explain-loweringand native-region verification.
Semantic constraints
- Preserve object/array identity when compared, returned, captured, stored, reflected upon, passed to an unknown call, proxied, or used with WeakRef/WeakMap/WeakSet/FinalizationRegistry behavior.
- Preserve property creation/evaluation order, computed-key side effects, spreads, getters/setters, prototype semantics, exceptions, and array holes/length behavior.
- Preserve GC root visibility when a value materializes at a side exit or collecting call.
- Never scalar-replace a value merely because the benchmark consumer happens not to inspect it; the non-escape proof must be general and verifier-backed.
Acceptance criteria
- Register the reproduction as semantic and compiler-output coverage; Node and Perry must print checksum
3000000. --explain-loweringrecords scalar replacement of the two descriptor objects and their fixed-length carrier arrays in the optimized fixture.- The hot arm contains no heap allocation, array push/get helper, layout-slot registration, or write barrier for those replaced aggregates.
- Native-region verification rejects a claimed scalar replacement without a complete escape proof and materialization records for all escape edges.
- Negative tests cover return/store/capture, identity comparison, unknown and collecting calls, reflection, spreads/computed keys, getters/setters, prototypes, proxies, WeakMap/WeakSet/WeakRef where supported, exceptions, and forced-moving GC.
- Re-run
ddmills/js-ecs-benchmarksperform-ecs/destroywith exact component-ID/view-count parity and the complete adapter suite. - On the quiet M1 alternating protocol, require at least a 5% median improvement with at least 9/11 wins; report allocations/op or allocation samples, barrier counts, peak RSS, executable size, and scalar-replacement/materialization counts.
Related work
- perf(codegen): specialize imported class methods that capture this #8693's guarded specialization/inlining of imported class methods that capture
thisis the principal enabler for the realperform-ecscall graph. - codegen: type-directed specialization — emit native ops where the type is statically known (#5334 Tier 3 / Lever E) #5497 tracks type-directed native operations broadly; this ticket is specifically aggregate escape analysis and scalar replacement.
- gc: reduce safepoint density (safepoint polling instead of every-call statepoints) #8596 reduces safepoint density, but eliminating the temporary allocations and generic calls should remove more safepoint/barrier work at its source.
Activity
proggeramlug commentedon Aug 24, 2026
Closed by #8703, landed via #8708 (squash
c76b43930).The implementing PR was landed through a batch wrapper PR rather than merged directly, so GitHub's
Closes #8691keyword never fired at merge time. Closing manually.