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

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

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