Skip to content

perf: u8[i]/buf[i] on a Uint8Array or Buffer parameter is 53× slower than Node (out-of-line js_uint8array_get/set probing the buffer registries) #10515

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. Perry implements Uint8Array and Buffer as buffer-registry
objects, not as a typed-array kind. When the receiver is not a proven local (a parameter, a closure capture, or any
untyped value), no inline lane admits them. Every element read and write is then a runtime call that walks the
thread-local buffer registries.

nanoid's pool refill, buffer[i] = charCodes[buffer[i] & mask], is 114× slower than Node. The same loop over a
Uint16Array runs 5× fewer instructions than the Uint8Array version.

Reproduction

bench.ts (27 lines):

const variant = process.argv[2] || "u8"; const N = Number(process.argv[3] || "2000");
const ALPHA = "useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";
const L = 32768;
// nanoid customAlphabet pool refill: `buffer[i] = charCodes[buffer[i] & mask]`
function refillU8(b: Uint8Array, cc: Uint8Array): number { for (let i = 0; i < b.length; i++) b[i] = cc[b[i] & 63]; return b[7]; }
function refillU16(b: Uint16Array, cc: Uint16Array): number { for (let i = 0; i < b.length; i++) b[i] = cc[b[i] & 63]; return b[7]; } // CONTROL
function refillAny(b: any, cc: any): number { for (let i = 0; i < b.length; i++) b[i] = cc[b[i] & 63]; return b[7]; } // untyped params (plain .js)
function mk() { // nanoid shape: charCodes and `let mask` captured by the closure, Buffer pool
  const charCodes = Uint8Array.from(ALPHA, (s: string) => s.charCodeAt(0)); let mask = 63;
  return (b: Uint8Array) => { for (let i = 0; i < b.length; i++) b[i] = charCodes[b[i] & mask]; return b[7]; };
}
const clo = mk();
const cc8 = new Uint8Array(64), cc16 = new Uint16Array(64); for (let i = 0; i < 64; i++) { cc8[i] = ALPHA.charCodeAt(i); cc16[i] = ALPHA.charCodeAt(i); }
const pools: any[] = [];
for (let k = 0; k < 8; k++) {
  const p: any = variant.startsWith("u16") ? new Uint16Array(L) : variant.startsWith("u8") ? new Uint8Array(L) : Buffer.alloc(L);
  for (let i = 0; i < L; i++) p[i] = (i * 7 + k) & 255; pools.push(p);
}
function run(n: number): number {
  let acc = 0;
  for (let k = 0; k < n; k++) {
    const p = pools[k & 7]; p[0] = k & 255;
    acc = (acc + (variant === "u16" ? refillU16(p, cc16) : variant === "u16any" ? refillAny(p, cc16) : variant === "u8any" ? refillAny(p, cc8) : variant === "nanoid" ? clo(p) : refillU8(p, cc8))) % 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 u8 buffer nanoid u8any u16 u16any; do node bench.ts $v 2000; ./bench $v 2000; done   # "buffer" = Buffer via refillU8

Measurements

These are medians of 3 runs on a shared, loaded host. Instruction counts do not depend on host load, so they are the
reliable figure. The instruction totals are whole-process instructions:u and include the N/5 warm-up. The
per-element figure divides by 78.6 M element steps. N = 2,000 refills of a 32,768-element array.

variant Node loop ms Perry loop ms ratio Perry instructions (per element) Node wall Perry wall
u8 (Uint8Array-typed params) 83.9 4,413 53× 55.87 G (710) 244 ms 5,307 ms
buffer (Buffer through the same Uint8Array params) 78.9 4,174 53× 55.87 G (710) 227 ms 5,098 ms
nanoid (closure-captured charCodes + let mask, Buffer) 48.1 5,505 114× 103.84 G (1,320) 140 ms 7,033 ms
u8any (untyped params, Uint8Array) 81.6 15,804 194× 193.03 G (2,455) 261 ms 18,968 ms
u16 (Uint16Array-typed params, control) 53.9 566 10.5× 11.23 G (143) 149 ms 713 ms
u16any (untyped params, Uint16Array, control) 83.7 2,423 29× 29.16 G (371) 240 ms 2,937 ms

Checksums are identical in all variants. Changing only the element kind from Uint16 to Uint8 costs 5× the instructions
with typed params and 6.6× with untyped params.

u8 profile (compiled loop 2.9 %):

symbol share
is_registered_buffer_slow 35.0 %
js_uint8array_get 24.0 %
buffer_data 15.1 %
js_uint8array_set 11.8 %
foreign_backing 8.2 %

u8any profile:

symbol share
is_registered_buffer_slow 20.2 %
js_dyn_index_get 13.4 %
is_registered_buffer 10.7 %
js_dyn_index_set_strict 10.1 %
js_dynamic_bitand + fmod + trunc 10.5 %
is_non_indexed_buffer_view 6.2 %
js_packed_arraylike_index_get 5.7 %
is_shared_sab 3.9 %
buffer_data 3.1 %
is_temporal_cell_addr 2.4 %

nanoid profile:

symbol share
is_registered_buffer_slow 17.5 %
buffer_data 10.6 %
typed_array_get_numeric_index 7.9 %
js_dynamic_bitand 7.9 %
js_uint8array_get 7.1 %
foreign_backing 6.2 %
js_uint8array_index_get_value 6.1 %
js_uint8array_set 5.7 %
is_uint8array_buffer_slow 5.1 %
js_object_get_index_polymorphic 4.1 %
fmod + trunc 6.6 %

u16 profile: 93 % of samples are in the compiled loop (inline load and store).

Impact

Shares below come from the audit profiles taken on v0.5.1587.

  • nanoid 6.0.0 (nanoid() ×10M, 48× Node):
    • Typed-array/Buffer element access is about 67 % of Perry time, and the polymorphic index get another 3.5 %. The
      objects-group attribution puts the construct at about 86 %.
    • Top self symbols: is_registered_buffer_slow 30.9 %, buffer_data 14.8 %, is_uint8array_buffer_slow 9.1 %.
    • The trigger is buffer[i] = charCodes[buffer[i] & mask] (index.js:100).
  • uuid 14.0.1: typed arrays are 12 % of Perry time, about 23 % including dispatch (byteToHex[arr[i]],
    Uint8Array.of). is_registered_buffer_slow is 8.0 % self.
  • @noble/hashes 2.2.0:
  • jsonwebtoken 9.0.3: 3–6 %.
  • pg 8.22.0: is_registered_buffer_slow is 2.07 % self (io-group profile).

Mechanism

Verified unless marked (inferred).

  • Declared Uint8Array/Buffer receiver.

    • lower_uint8array_get_i32 (crates/perry-codegen/src/expr/arrays_finds.rs:229-275) first tries
      lower_buffer_load (crates/perry-codegen/src/expr/buffer_access.rs:458).
    • That needs a tracked buffer-view descriptor for a local (lower_buffer_access_proof, buffer_access.rs:295-300). It
      also declines closure-captured buffers (buffer_access.rs:323). A parameter never has such a descriptor (inferred).
    • When the proof is missing, the lowering emits one call per element: js_uint8array_get (arrays_finds.rs:267) or
      js_uint8array_set (arrays_finds.rs:1129).
  • The runtime path (js_uint8array_get, crates/perry-runtime/src/typedarray/access.rs:650-679). A
    lookup_typed_array_kind miss is the normal case for a Uint8Array. From there:

    1. is_registered_buffer (crates/perry-runtime/src/buffer/header.rs:485).
    2. is_registered_buffer_slow (header.rs:549): a thread-local RefCell<HashSet> probe plus the external and
      shared-SAB registries.
    3. js_buffer_get.
    4. buffer_data (header.rs:1274): view::lookup, plus the foreign_backing thread-local map (header.rs:1067).

    js_uint8array_set (access.rs:735) has the same shape.

  • Why Uint16Array is fast. It is a real typed-array kind, so it gets the inline guarded element lane: kind cache,
    PERRY_TA_VIEW_GUARD, bounds check, load/store.

  • Untyped receivers. js_packed_arraylike_index_get, js_dyn_index_get and js_dyn_index_set_strict ask the same
    registries, plus is_non_indexed_buffer_view, is_shared_sab and is_temporal_cell_addr, on every element (profile).

  • nanoid's closure. The captured let mask turns b[i] & mask into js_dynamic_bitand (perf: bitwise operators on destructured, number-annotated or untyped values are 56× slower than Node (a js_dynamic_bit* call per operator, ToInt32 via software fmod) #10511). The captured
    charCodes read goes through js_object_get_index_polymorphic.

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