Repository navigation
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
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 refillU8Measurements
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_slow30.9 %,buffer_data14.8 %,is_uint8array_buffer_slow9.1 %. - The trigger is
buffer[i] = charCodes[buffer[i] & mask](index.js:100).
- Typed-array/Buffer element access is about 67 % of Perry time, and the polymorphic index get another 3.5 %. The
- uuid 14.0.1: typed arrays are 12 % of Perry time, about 23 % including dispatch (
byteToHex[arr[i]],
Uint8Array.of).is_registered_buffer_slowis 8.0 % self. - @noble/hashes 2.2.0:
- blake3: typed arrays are 27.0 % of Perry time (
msg[offset + s[j++]]with aUint8Arraypermutation parameter). - sha256: 17.4 %, together with perf: one ArrayBuffer-aliasing view anywhere, even a dead one, makes all typed-array access 81× slower than Node (process-global PERRY_TA_VIEW_GUARD) #10516. This figure is from the numeric-group profile.
- blake3: typed arrays are 27.0 % of Perry time (
- jsonwebtoken 9.0.3: 3–6 %.
- pg 8.22.0:
is_registered_buffer_slowis 2.07 % self (io-group profile).
Mechanism
Verified unless marked (inferred).
-
Declared
Uint8Array/Bufferreceiver.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_kindmiss is the normal case for a Uint8Array. From there:is_registered_buffer(crates/perry-runtime/src/buffer/header.rs:485).is_registered_buffer_slow(header.rs:549): a thread-localRefCell<HashSet>probe plus the external and
shared-SAB registries.js_buffer_get.buffer_data(header.rs:1274):view::lookup, plus theforeign_backingthread-local map (header.rs:1067).
js_uint8array_set(access.rs:735) has the same shape. -
Why
Uint16Arrayis 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_getandjs_dyn_index_set_strictask the same
registries, plusis_non_indexed_buffer_view,is_shared_sabandis_temporal_cell_addr, on every element (profile). -
nanoid's closure. The captured
let maskturnsb[i] & maskintojs_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
charCodesread goes throughjs_object_get_index_polymorphic.
What fast looks like
- Inline byte lane. Give Buffer-backed
Uint8Array/Bufferreceivers a guarded inline byte load/store equivalent to
the oneUint16Arrayalready gets:- Guard on the tag.
- Guard on the GC type, or on a Uint8Array brand in the header.
- Guard on a "not a view / not foreign-backed" flag.
- Guard on index < length.
- Then
load i8/store i8atheader + size_of::<BufferHeader>(). - Registry lookups happen only on the cold arm. This shape also removes the split classification that audit: lookup_typed_array_kind miss-consumers vs buffer-kind Uint8Array (split-brain classification, #9342 siblings) #9347 audits.
- Target:
u8andbufferat or below theu16instruction count (≤ ~150 per element);u8any≈u16any.
With 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 also fixed, the nanoid refill should be ≤3× Node.
Notes
- audit: lookup_typed_array_kind miss-consumers vs buffer-kind Uint8Array (split-brain classification, #9342 siblings) #9347 (open) audits the correctness side of the same Uint8Array-is-a-buffer split: kind-registry misses treated
as "not indexable". It does not track this element-access cost. - perf(runtime): classify native-call receivers from the tracked header; no Buffer/typed-array registry probe for known kinds #9937 (open, draft PR) removes registry probes from native method-call receiver classification, not from element
access. - In-function Uint8Array reads are 12x slower than the identical top-level loop (560 vs 46 ms) — and the obvious fix returns wrong answers #9342 and perf: typed-array element access ~10x Node on BOTH paths (#5525's untyped/typed ratio is closed at 1.05) #8485 (closed) made reads inline for module-global and proven receivers. Parameters, captures and
untyped receivers remain out of line. - Other drafts: perf: one ArrayBuffer-aliasing view anywhere, even a dead one, makes all typed-array access 81× slower than Node (process-global PERRY_TA_VIEW_GUARD) #10516 is the whole-program typed-array deopt; 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 is the dynamic&.
Activity
perf(buffer): admit Buffers to the byte-view inline cache; inline gua…
proggeramlug commentedon Sep 28, 2026
Progress: #11589 landed.
Uint8Array/Bufferelement 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.
perf(codegen): inline byte store for an untyped store into an admitte…
proggeramlug commentedon Oct 3, 2026
Partial progress in #11794: untyped Uint8Array 22.2M → 6.2M (node 0.53M, ~11.6×); nanoid 14.6M → 8.3M. Leaving open.
perf(codegen): an untyped index store declines from both tiers to one…