Repository navigation
perf: arr[i] = v on an untyped plain Array is 52× slower than Node (no inline store: one js_dyn_index_set_strict call per element; the same loop with a number[] annotation is 1.7×) #10513
Description
Found by the package performance audit (real npm packages compiled from source, profiled against Node 26.5.1) and
re-measured on Perry 7661bc0 (v0.5.1589), Linux x64. An index store into a plain Array whose receiver is not
declared as an array (every receiver in plain .js, and any any) is never inlined. Each element write calls
js_dyn_index_set_strict, which re-classifies the receiver through a stack of registry, prototype and arguments checks.
A fill/sum loop is 52× slower than Node. The identical loop with the parameter annotated number[] is 1.7×.
Reproduction
bench.ts (27 lines):
const variant = process.argv[2] || "any"; const N = Number(process.argv[3] || "400000");
// node-forge jsbn: BigInteger digits live in a plain Array, written/read by index through an untyped receiver
function fill(d: any, n: number): void { for (let j = 0; j < n; j++) d[j] = (j * 7) & 0xfffff; }
function sum(d: any, n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; }
function fillT(d: number[], n: number): void { for (let j = 0; j < n; j++) d[j] = (j * 7) & 0xfffff; } // CONTROL: same body,
function sumT(d: number[], n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; } // declared number[]
let d: any = variant === "grown" || variant === "grown_gc" ? [] : new Array(80).fill(0); // "grown": grown by d[j] = v
fill(d, 80);
if (variant === "grown_gc" && typeof (globalThis as any).gc === "function") (globalThis as any).gc();
function BigInteger(this: any) { this.data = []; } // jsbn am1 verbatim (Montgomery multiply-add inner loop)
function am1(this: any, i: number, x: number, w: any, j: number, c: number, n: number): number {
while (--n >= 0) { const v = x * this.data[i++] + w.data[j] + c; c = Math.floor(v / 0x4000000); w.data[j++] = v & 0x3ffffff; }
return c;
}
BigInteger.prototype.am = am1;
const a = new (BigInteger as any)(), w = new (BigInteger as any)();
for (let k = 0; k < 80; k++) { a.data[k] = (k * 2654435761) % 0x4000000; w.data[k] = (k * 40503) % 0x4000000; }
function run(n: number): number {
let acc = 0;
for (let r = 0; r < n; r++) {
if (variant === "am1") acc = (acc + a.am(0, (0x3456789 + r * 7) % 0x4000000, w, r % 3, 0, 76)) % 1000000007;
else if (variant === "typed") { fillT(d, 76); acc = (acc + sumT(d, 76)) % 1000000007; }
else { fill(d, 76); acc = (acc + sum(d, 76)) % 1000000007; }
}
return acc;
}
run(N / 5 | 0); const t0 = performance.now(); const cs = run(N); console.log(`${variant} checksum=${cs} ms=${(performance.now() - t0).toFixed(1)}`);PERRY_NO_AUTO_OPTIMIZE=1 perry compile bench.ts -o bench
for v in any typed am1; do node bench.ts $v 400000; ./bench $v 400000; doneMeasurements
Medians of 3 runs on a shared, loaded host. Instruction counts are the load-independent figure (whole-process
instructions:u, which includes the N/5 warm-up). N = 400,000. Each fill/sum iteration does 76 writes and 76 reads.
Each am1 call does 76 inner iterations.
| variant | Node loop ms | Perry loop ms | ratio | Perry instructions | Node wall | Perry wall |
|---|---|---|---|---|---|---|
| any (untyped receiver, presized array) | 47.7 | 2,480 | 52× | 28.05 G (384 per element op) | 257 ms | 3,059 ms |
typed (same body, d: number[], control) |
49.1 | 85.9 | 1.7× | 1.25 G (17 per element op) | 197 ms | 143 ms |
| am1 (jsbn verbatim) | 194.7 | 5,051 | 26× | 62.34 G (1,709 per inner iteration) | 365 ms | 6,354 ms |
Checksums are identical in all variants. The any variant uses 22× the instructions of the typed variant, and the
only source difference is the parameter annotation.
perf record of the any variant (reads on this presized array are inline; there is no js_array_get_f64 in the
profile):
| symbol | share |
|---|---|
js_dyn_index_set_strict |
24.7 % |
js_array_set_f64_extend_strict_impl |
14.1 % |
js_array_set_index_or_string_with_strictness |
7.5 % |
object_static_prototype |
5.4 % |
is_registered_buffer |
4.4 % |
array_prototype_addr |
4.2 % |
trunc |
3.8 % |
arguments_object_set_index |
3.7 % |
meta_capable_object |
3.5 % |
is_temporal_cell_addr |
3.4 % |
object_prototype_addr |
3.4 % |
compiled fill/sum |
15.8 % |
The typed control spends 82 % in the compiled loops and 6 % in js_typed_feedback_packed_f64_range_loop_guard.
Impact
- node-forge 1.4.0 RSA-2048 sign / keygen (98× / 80× Node; numeric-group audit profile, v0.5.1587):
- The "array ops" bucket is 29.0 % / 30.6 % of Perry time.
- Buffer-registry checks called from the array get/set path add another 10.3 % (sign).
- jsbn's
am1(lib/jsbn.js:81-88) is 64.8 % / 76.5 % inclusive. It storesw.data[j++] = v&0x3ffffff. - One signature is 276k
am()calls and 6.29 M inner iterations. Perry takes ~178 ns per iteration; Node takes
≤2.8 ns.
- @noble/hashes blake3: about 3 %.
- Other affected code: every jsbn-derived bignum library and every plain-JS algorithm that keeps numbers in a
[]-built array (matrices, DP tables, pools).
Mechanism
- Only typed-array kinds get inline arms (verified). The generic
obj[i] = vlowering in
crates/perry-codegen/src/expr/index_set_typed_array.rsemits inline stores for the typed-array kinds only. The
slow arm callsjs_dyn_index_set_strictfor every other receiver, including a live plainArray(lines 280-295). - Each store re-classifies the receiver (verified by source and profile). The call path is
js_dyn_index_set_strict(crates/perry-runtime/src/value/dyn_index.rs:550) →
js_array_set_index_or_string_with_strictness(crates/perry-runtime/src/array/indexing_keyed.rs:361) →
js_array_set_f64_extend_strict_impl(crates/perry-runtime/src/array/indexing.rs:1184-1250). Along the way, every
store runs:- three dense-store lanes;
clean_arr_ptr_mut,is_registered_bufferandlookup_typed_array_kind;- the Array/Object prototype index latches and resolved flags;
js_string_addref_if_heap_string;- the dispatcher's temporal-cell, arguments-object and prototype-address checks.
- The inline tier exists but needs a declared type (profile verified; the admission rule is inferred). With
d: number[]the same loops run in compiled code behindjs_typed_feedback_packed_f64_range_loop_guard, a
runtime-checked loop guard. So a guarded inline packed-f64 store tier exists, but only declared-array receivers are
admitted to it.
What fast looks like
- Option A: admit untyped receivers to the same guarded packed-f64 loop/store tier. The guard re-validates the
receiver at runtime, so no declared type has to be trusted. - Option B: emit a per-site guarded inline store with the existing call as the cold arm. The guard checks: GC type
== ARRAY, not forwarded, no descriptor flag, index< length, and the value is a plain double. - Targets:
anywithin 2× oftyped(≤ ~35 instructions per element op) and ≤2× Node.am1≤3× Node, once perf: bitwise operators on destructured,number-annotated or untyped values are 56× slower than Node (a js_dynamic_bit* call per operator, ToInt32 via software fmod) #10511 (the&) and perf: reads of an Array grown bya[i] = vorpushare 54× slower than Node (binding keeps the forwarded old head; every inline read bails out) #10514 (reads of grown arrays) are fixed too.
Notes
- perf: reads of an Array grown by
a[i] = vorpushare 54× slower than Node (binding keeps the forwarded old head; every inline read bails out) #10514 is the read-side cliff thatam1also hits: its arrays are grown by index, so reads bail out of the
inline path. - Boolean array stores have no inline fast path: 20x node, ~82% of the time in per-store bookkeeping calls #9237 (closed) added an inline path for boolean array stores. perf(codegen): loop-version stable packed Array and Array-subclass iteration #8690 and perf(codegen): version nested packed loops over immutable closure captures #8773 (closed) loop-version stable packed
arrays for proven receivers. Open PR perf(runtime): stop re-deriving the receiver on every array push (−74%) #10414 speeds uppush, not index stores.
Activity
perf(codegen): inline ordinary-Array store and one forwarding hop on …
proggeramlug commentedon Sep 27, 2026
Progress: #11588 landed. Untyped
arr[i] = von an ordinary Array now takes an inline guarded store. The issue repro drops from 447 → 100 instructions per op (jsbnam12461 → 895 per inner iteration). Package workloads: node-forge/rsa_sign −50%, big.js −43%, bignumber.js −21%, decimal.js −17%.Still open: Node is ~20 instructions per op on the typed control, so the untyped path is ~5× off it. Appends, holes past
lengthand non-canonical keys still take thejs_dyn_index_set_strictexit. The new arm costs typed arrays reached throughany+7 instructions per store.perf(codegen): an untyped index store declines from both tiers to one…