Skip to content

perf: arr[i] = v on 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 a number[] annotation is 1.7×) #10513

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. An index store into a plain Array whose receiver is not
declared as an array (every receiver in plain .js, and any any) is never inlined. Each element write calls
js_dyn_index_set_strict, which re-classifies the receiver through a stack of registry, prototype and arguments checks.
A fill/sum loop is 52× slower than Node. The identical loop with the parameter annotated number[] is 1.7×.

Reproduction

bench.ts (27 lines):

const variant = process.argv[2] || "any"; const N = Number(process.argv[3] || "400000");
// node-forge jsbn: BigInteger digits live in a plain Array, written/read by index through an untyped receiver
function fill(d: any, n: number): void { for (let j = 0; j < n; j++) d[j] = (j * 7) & 0xfffff; }
function sum(d: any, n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; }
function fillT(d: number[], n: number): void { for (let j = 0; j < n; j++) d[j] = (j * 7) & 0xfffff; } // CONTROL: same body,
function sumT(d: number[], n: number): number { let s = 0; for (let j = 0; j < n; j++) s += d[j]; return s; } // declared number[]
let d: any = variant === "grown" || variant === "grown_gc" ? [] : new Array(80).fill(0); // "grown": grown by d[j] = v
fill(d, 80);
if (variant === "grown_gc" && typeof (globalThis as any).gc === "function") (globalThis as any).gc();
function BigInteger(this: any) { this.data = []; } // jsbn am1 verbatim (Montgomery multiply-add inner loop)
function am1(this: any, i: number, x: number, w: any, j: number, c: number, n: number): number {
  while (--n >= 0) { const v = x * this.data[i++] + w.data[j] + c; c = Math.floor(v / 0x4000000); w.data[j++] = v & 0x3ffffff; }
  return c;
}
BigInteger.prototype.am = am1;
const a = new (BigInteger as any)(), w = new (BigInteger as any)();
for (let k = 0; k < 80; k++) { a.data[k] = (k * 2654435761) % 0x4000000; w.data[k] = (k * 40503) % 0x4000000; }
function run(n: number): number {
  let acc = 0;
  for (let r = 0; r < n; r++) {
    if (variant === "am1") acc = (acc + a.am(0, (0x3456789 + r * 7) % 0x4000000, w, r % 3, 0, 76)) % 1000000007;
    else if (variant === "typed") { fillT(d, 76); acc = (acc + sumT(d, 76)) % 1000000007; }
    else { fill(d, 76); acc = (acc + sum(d, 76)) % 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 any typed am1; do node bench.ts $v 400000; ./bench $v 400000; done

Measurements

Medians of 3 runs on a shared, loaded host. Instruction counts are the load-independent figure (whole-process
instructions:u, which includes the N/5 warm-up). N = 400,000. Each fill/sum iteration does 76 writes and 76 reads.
Each am1 call does 76 inner iterations.

variant Node loop ms Perry loop ms ratio Perry instructions Node wall Perry wall
any (untyped receiver, presized array) 47.7 2,480 52× 28.05 G (384 per element op) 257 ms 3,059 ms
typed (same body, d: number[], control) 49.1 85.9 1.7× 1.25 G (17 per element op) 197 ms 143 ms
am1 (jsbn verbatim) 194.7 5,051 26× 62.34 G (1,709 per inner iteration) 365 ms 6,354 ms

Checksums are identical in all variants. The any variant uses 22× the instructions of the typed variant, and the
only source difference is the parameter annotation.

perf record of the any variant (reads on this presized array are inline; there is no js_array_get_f64 in the
profile):

symbol share
js_dyn_index_set_strict 24.7 %
js_array_set_f64_extend_strict_impl 14.1 %
js_array_set_index_or_string_with_strictness 7.5 %
object_static_prototype 5.4 %
is_registered_buffer 4.4 %
array_prototype_addr 4.2 %
trunc 3.8 %
arguments_object_set_index 3.7 %
meta_capable_object 3.5 %
is_temporal_cell_addr 3.4 %
object_prototype_addr 3.4 %
compiled fill/sum 15.8 %

The typed control spends 82 % in the compiled loops and 6 % in js_typed_feedback_packed_f64_range_loop_guard.

Impact

  • node-forge 1.4.0 RSA-2048 sign / keygen (98× / 80× Node; numeric-group audit profile, v0.5.1587):
    • The "array ops" bucket is 29.0 % / 30.6 % of Perry time.
    • Buffer-registry checks called from the array get/set path add another 10.3 % (sign).
    • jsbn's am1 (lib/jsbn.js:81-88) is 64.8 % / 76.5 % inclusive. It stores w.data[j++] = v&0x3ffffff.
    • One signature is 276k am() calls and 6.29 M inner iterations. Perry takes ~178 ns per iteration; Node takes
      ≤2.8 ns.
  • @noble/hashes blake3: about 3 %.
  • Other affected code: every jsbn-derived bignum library and every plain-JS algorithm that keeps numbers in a
    []-built array (matrices, DP tables, pools).

Mechanism

  • Only typed-array kinds get inline arms (verified). The generic obj[i] = v lowering in
    crates/perry-codegen/src/expr/index_set_typed_array.rs emits inline stores for the typed-array kinds only. The
    slow arm calls js_dyn_index_set_strict for every other receiver, including a live plain Array (lines 280-295).
  • Each store re-classifies the receiver (verified by source and profile). The call path is
    js_dyn_index_set_strict (crates/perry-runtime/src/value/dyn_index.rs:550) →
    js_array_set_index_or_string_with_strictness (crates/perry-runtime/src/array/indexing_keyed.rs:361) →
    js_array_set_f64_extend_strict_impl (crates/perry-runtime/src/array/indexing.rs:1184-1250). Along the way, every
    store runs:
    • three dense-store lanes;
    • clean_arr_ptr_mut, is_registered_buffer and lookup_typed_array_kind;
    • the Array/Object prototype index latches and resolved flags;
    • js_string_addref_if_heap_string;
    • the dispatcher's temporal-cell, arguments-object and prototype-address checks.
  • The inline tier exists but needs a declared type (profile verified; the admission rule is inferred). With
    d: number[] the same loops run in compiled code behind js_typed_feedback_packed_f64_range_loop_guard, a
    runtime-checked loop guard. So a guarded inline packed-f64 store tier exists, but only declared-array receivers are
    admitted to it.

What fast looks like

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