Repository navigation
perf: reads of an Array grown by a[i] = v or push are 54× slower than Node (binding keeps the forwarded old head; every inline read bails out) #10514
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. 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; doneMeasurements
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):
- Every
BigIntegerstarts asthis.data = [](jsbn.js:62) and is filled by index, so its digit arrays are grown
arrays read throughthis.data/w.data. - The audit's gdb check in the forge loop found receiver flags 0x82.
- Removing growth from its fill/sum microbenchmark (presizing the array) cut Perry's time by 38 %.
- The "array ops" bucket is 29–31 % of forge sign and keygen time. This issue is its read half; perf:
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 half.
- Every
- General: any code that builds an array with
pushor 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-270installs 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 & 0x80and 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.
- The dynamic element read tests
- Nothing repairs the stale binding in a compute loop. A loop that does not allocate never triggers a collection.
In this benchmark an explicitgc()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
- perf:
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 compiled
loop, 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). - Growth forwarding exists for correctness (array growth forwarding: build()-and-return leaves a stale head in the binding (minimal N = 17) #7661, release blocker: stable packed loop reads stale forwarded array after cross-call growth #9117, both closed). This issue is about its steady-state read cost.
Activity
perf(codegen): inline ordinary-Array store and one forwarding hop on …
proggeramlug commentedon Sep 27, 2026
Progress: #11588 landed. The inline dynamic read now follows one growth-forwarding hop, and the packed loop tier heals a forwarded head before its guard. Grown and pushed arrays drop from ~565 → ~73 instructions per read, on par with the presized control (68). The grown typed case goes from 116 → 23.
Still open: the presized control itself is ~69 instructions per read vs Node's handful, so the remaining gap is the general untyped read path rather than growth. Keeping this open for that.