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; done
Measurements
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 stores w.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] = v lowering in
crates/perry-codegen/src/expr/index_set_typed_array.rs emits inline stores for the typed-array kinds only. The
slow arm calls js_dyn_index_set_strict for every other receiver, including a live plain Array (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_buffer and lookup_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 behind js_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:
Notes
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
Arraywhose receiver is notdeclared as an array (every receiver in plain
.js, and anyany) is never inlined. Each element write callsjs_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):Measurements
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
am1call does 76 inner iterations.d: number[], control)Checksums are identical in all variants. The
anyvariant uses 22× the instructions of thetypedvariant, and theonly source difference is the parameter annotation.
perf recordof theanyvariant (reads on this presized array are inline; there is nojs_array_get_f64in theprofile):
js_dyn_index_set_strictjs_array_set_f64_extend_strict_impljs_array_set_index_or_string_with_strictnessobject_static_prototypeis_registered_bufferarray_prototype_addrtruncarguments_object_set_indexmeta_capable_objectis_temporal_cell_addrobject_prototype_addrfill/sumThe
typedcontrol spends 82 % in the compiled loops and 6 % injs_typed_feedback_packed_f64_range_loop_guard.Impact
am1(lib/jsbn.js:81-88) is 64.8 % / 76.5 % inclusive. It storesw.data[j++] = v&0x3ffffff.am()calls and 6.29 M inner iterations. Perry takes ~178 ns per iteration; Node takes≤2.8 ns.
[]-built array (matrices, DP tables, pools).Mechanism
obj[i] = vlowering incrates/perry-codegen/src/expr/index_set_typed_array.rsemits inline stores for the typed-array kinds only. Theslow arm calls
js_dyn_index_set_strictfor every other receiver, including a live plainArray(lines 280-295).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, everystore runs:
clean_arr_ptr_mut,is_registered_bufferandlookup_typed_array_kind;js_string_addref_if_heap_string;d: number[]the same loops run in compiled code behindjs_typed_feedback_packed_f64_range_loop_guard, aruntime-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
receiver at runtime, so no declared type has to be trusted.
== ARRAY, not forwarded, no descriptor flag, index< length, and the value is a plain double.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
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 theinline path.
arrays for proven receivers. Open PR perf(runtime): stop re-deriving the receiver on every array push (−74%) #10414 speeds up
push, not index stores.