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-cache
The 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_value representations versus only 34 i32 and 16 i1 representations.
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-lowering and 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-lowering records 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-benchmarks perform-ecs/destroy with 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
Summary
Perry does not scalar-replace short-lived aggregate literals passed through known ECS helper calls. The public
perform-ecs/destroyloop allocates fresh arrays and descriptor objects several times per entity: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
Expected checksum:
3000000.Build and retain artifacts:
The public integration reproduction is
ddmills/js-ecs-benchmarks,perform-ecs@0.7.8, suiteDestroy.Current evidence
For the corrected public workload on Perry
7ad718ab4287641cb2b29dce3a056edc45d4c7f8, the lowering report contains: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
--explain-loweringand native-region verification.Semantic constraints
Acceptance criteria
3000000.--explain-loweringrecords scalar replacement of the two descriptor objects and their fixed-length carrier arrays in the optimized fixture.ddmills/js-ecs-benchmarksperform-ecs/destroywith exact component-ID/view-count parity and the complete adapter suite.Related work
thisis the principal enabler for the realperform-ecscall graph.