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. When a plain Array outgrows its storage, Perry leaves a
growth-forwarding stub at the old address. Bindings and fields that still hold that address are never updated. Every
inline element read tests GC_FLAG_FORWARDED and leaves the fast path when it is set, so reads through such a binding
go out of line for the rest of the program. An 80-element sum loop costs 5.4× the instructions of the same loop over a
presized array, 468 vs 87 instructions per read. That makes it 54× slower than Node. An explicit gc() changes nothing.
Reproduction
bench.ts (16 lines):
const variant = process.argv[2] || "grown"; const N = Number(process.argv[3] || "1000000");
// Read-only loop over an 80-element plain Array; the variants differ ONLY in how the array was built.
function sum(d: any, n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; }
function sumT(d: number[], n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; }
let d: any;
if (variant === "presized" || variant === "presized_typed") d = new Array(80).fill(0); // CONTROL: never grows
else if (variant === "pushed") { d = []; for (let j = 0; j < 80; j++) d.push(0); } // grown by push
else d = []; // grown by d[j] = v
for (let j = 0; j < 80; j++) d[j] = (j * 7) & 0xfffff;
if (variant === "grown_gc" && typeof (globalThis as any).gc === "function") (globalThis as any).gc(); // does not help
function run(n: number): number {
let acc = 0;
for (let r = 0; r < n; r++) acc = (acc + (variant.endsWith("_typed") ? sumT(d, 80) : sum(d, 80))) % 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 presized grown pushed grown_gc presized_typed grown_typed; do node bench.ts $v 1000000; ./bench $v 1000000; done
Measurements
These are medians of 3 runs on a shared, loaded host. Instruction counts are the load-independent figure. They are
whole-process instructions:u and include the N/5 warm-up. N = 1,000,000 iterations of 80 reads, so the per-read column
divides by 96 M.
| variant |
Node loop ms |
Perry loop ms |
ratio |
Perry instructions (per read) |
Node wall |
Perry wall |
| presized (control) |
63.1 |
583.9 |
9.3× |
8.33 G (87) |
214 ms |
746 ms |
grown (d[j] = v from []) |
65.4 |
3,541 |
54× |
44.98 G (468) |
232 ms |
4,660 ms |
| pushed |
61.5 |
3,755 |
61× |
45.00 G (468) |
212 ms |
4,571 ms |
grown_gc (gc() after growth) |
61.5 |
3,636 |
59× |
45.02 G (469) |
213 ms |
4,413 ms |
presized_typed (d: number[], control) |
87.7 |
104.2 |
1.2× |
1.46 G (15) |
247 ms |
163 ms |
| grown_typed |
82.3 |
587.3 |
7.1× |
9.04 G (94) |
227 ms |
746 ms |
Checksums are identical in every variant.
- Untyped receiver: growth adds about 380 instructions to every read.
number[] receiver: the same growth multiplies read cost 6×.
perf record on grown:
| symbol |
share |
js_array_get_f64 |
31.4 % |
try_read_tracked_gc_header |
29.8 % |
js_packed_arraylike_index_get |
17.7 % |
| compiled loop |
13.8 % |
On presized, 90 % of samples are in the compiled loop.
gdb breakpoint on js_packed_arraylike_index_get for grown, pushed and grown_gc: the receiver header is
obj_type=0x01 (ARRAY) with gc_flags=0x82 (FORWARDED | ARENA). For grown_gc this is after the gc() call.
Impact
- node-forge 1.4.0, the RSA/bignum path (numeric-group audit profile, v0.5.1587):
- General: any code that builds an array with
push or index writes and keeps reading it through the original
reference (object fields, closure captures, module globals).
Mechanism
Verified unless marked (inferred).
- The stub is installed and retained on purpose.
crates/perry-runtime/src/array/push_pop.rs:250-270 installs a
forwarding stub on the old head when the array reallocates (install_array_growth_forwarding_with, line 265). The
comment there says the stubs are retained "because stale array references rely on clean_arr_ptr following this
chain".
- Every inline read lane rejects a forwarded header.
- The dynamic element read tests
gc_flags & 0x80 and branches to the miss exit
(crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs:185-189). That exit calls
js_packed_arraylike_index_get → js_array_get_f64, which classifies the address again
(try_read_tracked_gc_header) and follows the chain on every read.
- The bounded-index lane does the same (
crates/perry-codegen/src/expr/index_get.rs:1050-1063), as does
index_get/guarded_array.rs:303,341.
- Nothing repairs the stale binding in a compute loop. A loop that does not allocate never triggers a collection.
In this benchmark an explicit gc() did not rewrite the module-global binding either: after it, the receiver still
had flags 0x82 (gdb).
- Typed receivers suffer too (inferred). For a
number[] receiver the loop stays in compiled code, but costs 6×.
The likely cause is the loop-version guard rejecting the forwarded head and taking the slower clone.
What fast looks like
Any of these would work:
- Heal in place. Follow one forwarding hop inline (load the forwarding word from the stub, re-check the header),
instead of bailing to the runtime.
- Write the resolved head back. The miss path returns the resolved head, and the read site writes it back into the
local or field that held the stub.
- Rewrite at collection. Have every collection, including explicit
gc(), rewrite slots that point at growth stubs.
Target: grown and pushed within 10 % of presized instructions per read (≤ ~95 now, ~15 for the number[]
receiver), and grown_typed ≈ presized_typed.
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. When a plain
Arrayoutgrows its storage, Perry leaves agrowth-forwarding stub at the old address. Bindings and fields that still hold that address are never updated. Every
inline element read tests
GC_FLAG_FORWARDEDand leaves the fast path when it is set, so reads through such a bindinggo out of line for the rest of the program. An 80-element sum loop costs 5.4× the instructions of the same loop over a
presized array, 468 vs 87 instructions per read. That makes it 54× slower than Node. An explicit
gc()changes nothing.Reproduction
bench.ts(16 lines):Measurements
These are medians of 3 runs on a shared, loaded host. Instruction counts are the load-independent figure. They are
whole-process
instructions:uand include the N/5 warm-up. N = 1,000,000 iterations of 80 reads, so the per-read columndivides by 96 M.
d[j] = vfrom[])gc()after growth)d: number[], control)Checksums are identical in every variant.
number[]receiver: the same growth multiplies read cost 6×.perf recordongrown:js_array_get_f64try_read_tracked_gc_headerjs_packed_arraylike_index_getOn
presized, 90 % of samples are in the compiled loop.gdb breakpoint on
js_packed_arraylike_index_getforgrown,pushedandgrown_gc: the receiver header isobj_type=0x01(ARRAY) withgc_flags=0x82(FORWARDED | ARENA). Forgrown_gcthis is after thegc()call.Impact
BigIntegerstarts asthis.data = [](jsbn.js:62) and is filled by index, so its digit arrays are grownarrays read through
this.data/w.data.arr[i] = von 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 anumber[]annotation is 1.7×) #10513 is thestore half.
pushor index writes and keeps reading it through the originalreference (object fields, closure captures, module globals).
Mechanism
Verified unless marked (inferred).
crates/perry-runtime/src/array/push_pop.rs:250-270installs aforwarding stub on the old head when the array reallocates (
install_array_growth_forwarding_with, line 265). Thecomment there says the stubs are retained "because stale array references rely on clean_arr_ptr following this
chain".
gc_flags & 0x80and branches to the miss exit(
crates/perry-codegen/src/expr/index_get/inline_dyn_typed_array.rs:185-189). That exit callsjs_packed_arraylike_index_get→js_array_get_f64, which classifies the address again(
try_read_tracked_gc_header) and follows the chain on every read.crates/perry-codegen/src/expr/index_get.rs:1050-1063), as doesindex_get/guarded_array.rs:303,341.In this benchmark an explicit
gc()did not rewrite the module-global binding either: after it, the receiver stillhad flags 0x82 (gdb).
number[]receiver the loop stays in compiled code, but costs 6×.The likely cause is the loop-version guard rejecting the forwarded head and taking the slower clone.
What fast looks like
Any of these would work:
instead of bailing to the runtime.
local or field that held the stub.
gc(), rewrite slots that point at growth stubs.Target:
grownandpushedwithin 10 % ofpresizedinstructions per read (≤ ~95 now, ~15 for thenumber[]receiver), and
grown_typed≈presized_typed.Notes
arr[i] = von 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 anumber[]annotation is 1.7×) #10513 is the store-side cost for the same receivers. Thepresizedcontrol's own 9.3× is spent inside the compiledloop, most likely the guarded dynamic
+=accumulator described in perf: accumulate-over-reads loops pay a per-element rooting diamond when the read isn't served by a proven tier #9361 (inferred).