Skip to content

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

Open

Description

@proggeramlug

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

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

Activity

  1. added
    performanceRuntime, compile-time, build-size, or memory performance
    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindings
    on Sep 17, 2026
  2. proggeramlug commented on Sep 27, 2026

    @proggeramlug
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    package-auditFound by the 2026 package audit: compiling real npm packages from source instead of native bindingsperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions