Repository navigation
perf(codegen): direct guarded typed-array RMW through dynamic numeric indices #8692
Description
Summary
Perry still lowers a statically specialized Uint32Array element update through three generic runtime helpers per iteration:
js_typed_array_index_get_dynamicjs_dynamic_string_or_number_addjs_typed_array_index_set_dynamic
In the reduced wolf-ecs loop below, that is approximately 6,000,000 helper calls for 2,000,000 component updates. On an M1 Mac mini, current Perry is 31.76x slower than Node even after #8655's Array-subclass indexing improvement.
This issue is specifically about preserving the typed-array representation through a numeric indexed read-modify-write. Guard hoisting and direct iteration over the source Array/Array-subclass are tracked separately in #8690.
Self-contained reproduction
class Query extends Array {
archetypes = this;
}
class Archetype extends Array {
entities = this;
}
const entityCount = 1_000;
const iterations = 2_000;
const query = new Query();
const archetype = new Archetype();
for (let i = 0; i < entityCount; i++) archetype.push(i);
query.push(archetype);
const components = new Uint32Array(entityCount);
function system(values) {
for (let i = 0, length = query.length; i < length; i++) {
const current = query[i];
for (let j = 0, length = current.length; j < length; j++) {
values[current[j]] += 1;
}
}
}
const start = performance.now();
for (let i = 0; i < iterations; i++) system(components);
const elapsedMs = performance.now() - start;
console.log(JSON.stringify({ elapsedMs, checksum: components[0] }));Build from a Perry checkout:
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-cacheCurrent evidence
Measured on Perry 7ad718ab4287641cb2b29dce3a056edc45d4c7f8 (0.5.1519) and Node 26.5.1. Current main differs by #8688, a class-semantics/TLS merge that does not implement this optimization.
Machine: Apple M1 Mac mini, 8 GiB, macOS 26.5.1, AC power. Three process warmups followed by 11 alternating Node/Perry processes. Every process reported checksum 2000.
| Runtime | median | CV | relative MAD |
|---|---|---|---|
| Node 26.5.1 | 2.219416 ms | 2.23% | 0.29% |
| Perry | 70.495958 ms | 0.078% | 0.041% |
Perry/Node: 31.763x.
The compiler emits a specialized system$spec_ta5x1000 symbol, so the Uint32Array kind and length are known at the function boundary. Nevertheless, the inner loop calls all three helpers above. --explain-lowering records typed-array fallbacks with mutable_alias, unknown_call_escape, and typed_array_fallback=untracked_or_unproven; the indexed set is boxed at a polymorphic helper edge.
Proposed direction
- Recognize fixed-width typed-array read-modify-write expressions such as
values[index] += numericValueafter specialization. - Preserve the typed-array kind, length/backing-store facts, and numeric-index proof through the get/add/set sequence.
- Emit a guarded direct load, JavaScript-correct numeric addition/conversion, and direct store.
- Hoist loop-invariant kind, detachment, backing-store/version, and length guards when legal.
- Retain an explicit semantics-preserving fallback or side exit when any proof fails.
- Record the selection, guards, and fallback in native-region artifacts and
--explain-lowering.
An initial slice may target Uint32Array plus a numeric constant RHS, provided the mechanism and tests are representation-driven rather than source-name- or benchmark-specific.
Semantic constraints
- Preserve evaluation order and abrupt completion behavior for base, key, get, RHS, numeric coercion/addition, and set.
- Preserve
Uint32wrapping and behavior for negative, fractional, NaN, infinite, and out-of-bounds indices. - Handle detached/resized backing stores according to Perry's supported TypedArray semantics.
- Do not use the direct path for proxies, accessors, unknown calls that can invalidate the proof, unsupported key representations, or an aliased value that may change kind.
- Remain correct under moving/forced GC; reload any relocatable roots at required safepoints.
Acceptance criteria
- Add the reproduction as a semantic and compiler-output ratchet; Node and Perry must both print checksum
2000. - The optimized inner-loop arm contains none of
js_typed_array_index_get_dynamic,js_dynamic_string_or_number_add, orjs_typed_array_index_set_dynamic. - Emitted artifacts prove the typed-array guard and an explicit fallback/side exit remain present.
- Tests cover parameter and captured/module-global typed arrays, guard failure, aliasing, bounds, wrapping, detachment/resizing where supported, exceptions/evaluation order, and forced-moving GC.
--explain-loweringreports why the direct RMW was selected or rejected.- On the same quiet M1 protocol, improve the reduced reproduction by at least 2x versus
7ad718a, with at least 9/11 paired wins and no ordinary packed-array or typed-array regression. - Re-run
noctjs/ecs-benchmarkwolf-ecs/simple_iter,becsy/simple_iter,javelin-ecs/simple_iter, andpiecs/simple_iter, reporting semantic parity, medians, RSS, and executable-size deltas.
Related work
- perf: Array-subclass numeric indexing leaves Wolf ECS hot loop 191x slower than Node #8655 fixed the first Array-subclass indexing bottleneck and improved this reproduction 6.31x, but deliberately did not eliminate the typed-array get/add/set helpers.
- codegen: type-directed specialization — emit native ops where the type is statically known (#5334 Tier 3 / Lever E) #5497 is the broader type-directed specialization tracker; this ticket is the self-contained guarded dynamic-index RMW slice exposed by the ECS profile.
- perf(codegen): inline typed-array reads (f64 + i32) through module-global bindings #8617 added inline typed-array reads through module-global bindings; this issue requires a read-modify-write through a dynamic numeric index and a specialized function parameter.
Activity
proggeramlug commentedon Aug 24, 2026
Closed by #8706, landed via #8708 (squash
c76b43930).The implementing PR was landed through a batch wrapper PR rather than merged directly, so GitHub's
Closes #8692keyword never fired at merge time. Closing manually.