Repository navigation
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.
TheHypnoo commentedon Oct 26, 2026
Related (untyped Array element access): #10513. These likely share a root cause or fix site, so check them together.