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

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. 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

  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 28, 2026

    @proggeramlug
    ContributorAuthor

    Progress: #11589 landed. Uint8Array/Buffer element access now goes through the byte-view admission cache: u8[i] on typed params 876 → 76 instructions per op, untyped params 2695 → 763, closure-captured nanoid table 1336 → 561. Package workloads: nanoid/generate −34%, uuid/v7 −13%, uuid/v5_parse −10% (big.js +1.2% from the admission test on the runtime Array paths).

    Still open: the untyped Buffer store has no inline arm yet (it uses the runtime fast path); that is a follow-up now that #11588's untyped-Array store structure has landed. The untyped read is still ~10× the typed path.

  3. proggeramlug commented on Oct 3, 2026

    @proggeramlug
    ContributorAuthor

    Partial progress in #11794: untyped Uint8Array 22.2M → 6.2M (node 0.53M, ~11.6×); nanoid 14.6M → 8.3M. Leaving open.

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