You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On pinned Perry 9495bfc, copying n bytes into a fresh destination with dest.set(src) costs 5.290 ms at 1M bytes versus Node's 0.064 ms (82.05x), peaking at 171.09x at 100k. The gap widens steadily with size — 4.86x at n=100, 34.63x at 10k — while Node's slope stays near-flat at 0.31 because it is doing a bulk memory copy. Checksums match at every size. This is the same view-table hot spot as the already-filed subarray issue #10056.
Measured against Node v26.5.1 using Perry perry 0.5.1531 at 9495bfc95e2afcfb5a7cb535e440e61ec0722cb1. This is evidence from that pinned revision, not a claim that current main was remeasured. First reproduce on current main; if it is already fixed, identify the fixing commit and attach the comparison.
Measurements
Times are median milliseconds per workload invocation. Ratios are Perry/Node. A correctness or timeout classification takes precedence over performance; successful smaller-size timings on those rows are diagnostic evidence.
binary-uint8array-set — ASYMPTOTIC
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.003321
0.015914
4.79×
235566482
235566482
1000
0.003967
0.045983
11.59×
315573297
315573297
10000
0.005556
0.192674
34.68×
167708261
167708261
100000
0.012455
2.134678
171.39×
826687860
826687860
1000000
0.064463
5.289698
82.06×
745253071
745253071
Log(time)/log(n) least-squares slopes: Perry 0.671, Node 0.307, delta 0.364.
Workload: n bytes copied into a fresh destination, prepared outside timing.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
What is expected / acceptance criteria
Rerun the complete reproducer on the candidate release build and Node at 100, 1k, 10k, 100k and 1M bytes. Checksums must match at every size, and the ratio must improve at every size above 1k.
Give the Buffer-to-Buffer and TypedArray-to-TypedArray same-element-width cases a bulk copy that resolves the view indirection once per call rather than once per byte, and remove the intermediate Vec<u8> for those cases. Demonstrate with bounded counters or profiling that a 1M-byte set performs a constant number of view-table lookups.
Preserve overlapping-range semantics: a set between two views over the same backing buffer, in both forward and backward overlap, must produce the same bytes as Node. Add regression coverage for overlap at the start, at the end, and fully nested.
Preserve the Improve node:buffer slice/subarray shared backing-store parity #1205 view-coherency guarantee that motivated the per-byte lookup: a write made directly to a backing buffer through codegen's fast path must still be visible to a set that reads through a registered view. Add a test that writes through the backing and reads through the view in the same operation.
Keep the other source arms correct and covered: a plain Array source with values needing ToUint8 wrapping (negative, fractional, >255, NaN), a BigInt-kind typed array mixed with a Number source (must still throw per typedarray::bigint::throw_bigint_number_mix at access.rs:147), an Object source with index-named properties, out-of-range offsets, and a zero-length source.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: the set path never reaches a bulk copy — it materializes a temporary byte vector one accessor call at a time.
collect_buffer_set_bytes at access.rs:136 builds a Vec<u8> by looping over the source and calling one accessor per byte: js_buffer_get(ptr, i) for the Buffer source (line 141), js_typed_array_get for the TypedArray source (line 151), js_array_get_f64 for the Array source (line 158). The result is then copied again into the destination, so even the copy itself is done twice.
The per-byte accessor is the expensive part. js_buffer_get resolves through read_buffer_byte at access.rs:180, which performs super::view::lookup(buf_addr) at line 189 — an address-keyed side-table lookup — for every single byte, so that a registered view reads through to its ultimate backing buffer (#1205). For a 1M-byte copy that is one million side-table lookups plus one million bounds checks, where Node issues a single memmove.
The view indirection is only needed once per operation, not once per byte: the source's backing pointer, offset and length can be resolved a single time and the whole range copied with ptr::copy_nonoverlapping. The spec-visible behaviour that must survive is the overlapping-range case (set on views sharing a backing store must behave as if the source were copied first) and the per-element coercion that the Array and Object sources genuinely require.
This is source attribution consistent with the measured growth, not a profiler result. Note that the classifier flagged this ASYMPTOTIC on a finite range; the source shows linear-but-heavy work rather than a growing-prefix quadratic, and the 100k row (171.09x) being worse than the 1M row (82.05x) is consistent with allocation/cache transitions rather than a clean exponent gap. Treat the constant factor as the finding.
The Buffer/TypedArray fast path is the valuable one. The BufferSetSource::Object arm at line 161 legitimately needs per-index property reads (it even allocates a key string per index via i.to_string()), and the BufferSetSource::Array arm needs numeric coercion; neither is the measured workload and neither should be forced into a bulk copy.
Source reading narrows the investigation; it does not establish exclusive runtime/compiler attribution. No compiler or runtime changes were made to obtain these measurements.
Agent scope and coordination
Owner scope is crates/perry-runtime/src/buffer/access.rs and the view resolution in crates/perry-runtime/src/buffer/view.rs. This shares the view side table with #10056 (Uint8Array.subarray), which has a fix in flight making subarrays zero-copy — read that branch first, because a zero-copy subarray changes what view::lookup must resolve and the two fixes could otherwise conflict. The DataView accessor issue touches the same view table from buffer/dataview.rs; coordinate if you change the table's shape rather than just its call frequency.
Implementation work can proceed in separate branches. Serialize benchmark runs on any shared host; parallel timing runs invalidate small performance comparisons. Preserve language semantics and moving-GC safety.
Everything needed for the workload is embedded below; no private repository, fixture, npm package, or shared prelude is required. Save a complete benchmark block under its indicated filename in /tmp/perry-builtin-repro/. Use Node 26.5.1 to match this baseline; it runs these TypeScript files directly.
To reproduce the historical baseline, use the pinned commit above in a separate checkout and build the compiler and both libraries there. Repeat compilation for each additional benchmark below. Run this small driver from the same checkout, changing name and sizes for that benchmark:
Record before/after results from the same unchanged source, engine versions and host. The measured driver uses seeded setup outside timers, at least 200 ms AND five warmup runs, then seven samples with at least 20 ms measured work each. Fresh input is prepared before each timer for mutating workloads. The median per-run time is reported, with checksum consistency checked on every invocation. Timeouts cover setup, warmup and sampling, not just one builtin call.
Node: v26.5.1; Perry: perry 0.5.1531; build: release from source.
Compile flag: --no-auto-optimize; compiler and both matching runtime archives were rebuilt together.
The pinned source revision and compiler/runtime/Node artifact hashes were unchanged throughout the sweep.
Load average at measurement start: [58.3896484375, 53.017578125, 57.90087890625]; end: [25.240234375, 30.416015625, 24.8681640625].
Host contention limits precise constant-factor claims; repeat on a quiet host before asserting an improvement.
Timings include timer overhead and checksum calculation. String hashes bound lookup count, not Unicode lookup cost; indexed consumption may also force Node string materialization.
Minimal correctness reductions
This issue is a performance workload; the complete checksum-gated reproducer follows.
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: True.
// @runtime {"name": "binary-uint8array-set", "category": "binary-node", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/buffer/access.rs", "function": "js_buffer_set_from_value"}, {"file": "crates/perry-runtime/src/object/buffer_dispatch.rs", "function": "dispatch_buffer_method"}, {"file": "crates/perry-runtime/src/buffer/access.rs", "function": "collect_buffer_set_bytes / read_buffer_byte"}], "hypothesis": "Uint8Array dispatch uses the BufferHeader path: collect_buffer_set_bytes reads each byte through js_buffer_get (including view lookup) into a temporary Vec, then copies that Vec into the destination. This is linear extra work; the finite-range slope flag does not establish quadratic complexity.", "notes": "n bytes copied into a fresh destination, prepared outside timing.", "asynchronous": false, "output_stderr": true, "fresh_input": true}// Standalone file. Shared helpers/driver are inlined by common.py.letseed=0x12345678;functionrnd(): number{seed^=seed<<13;seed^=seed>>>17;seed^=seed<<5;return(seed>>>0)/4294967296;}functionnumbers(n: number): number[]{consta: number[]=[];for(leti=0;i<n;i++)a.push(Math.floor(rnd()*1000000));returna;}functionhashArray(a: number[]): number{leth=a.length;for(leti=0;i<a.length;i++)h=(h*31+a[i])%1000000007;returnh;}// Bounded checksum work avoids making string slicing/indexing part of every// string benchmark's asymptotic cost. The workload itself consumes its result.functionhashString(s: string): number{leth=s.length;conststep=Math.max(1,Math.floor(s.length/32));for(leti=0;i<s.length;i+=step)h=(h*31+s.charCodeAt(i))%1000000007;returnh;}functionhashBytes(a: Uint8Array): number{leth=a.length;conststep=Math.max(1,Math.floor(a.length/32));for(leti=0;i<a.length;i+=step)h=(h*31+a[i])%1000000007;returnh;}functionsetup(n: number): {src: Uint8Array,dest: Uint8Array}{constsrc=newUint8Array(n);for(leti=0;i<n;i++)src[i]=Math.floor(rnd()*256);return{src,dest: newUint8Array(n+3)};}functionrun(input: {src: Uint8Array,dest: Uint8Array}): number{input.dest.set(input.src,3);returnhashBytes(input.dest);}// Size is the final argument: both native Perry and Node expose it reliably.constn=Number(process.argv[process.argv.length-1]);if(!(n>0))thrownewError("Expected a positive size argument");functionbenchmarkMain(): void{seed=0x12345678;letchecksum=0;letseen=false;letwarmMs=0;letwarmRuns=0;while(warmMs<200||warmRuns<5){seed=0x12345678;constinput=setup(n);conststart=performance.now();constvalue=run(input);constelapsed=performance.now()-start;if(!(elapsed>=0))thrownewError("Invalid monotonic timer");warmMs+=elapsed;warmRuns++;if(seen&&value!==checksum)thrownewError("CORRECTNESS: unstable checksum during warmup");checksum=value;seen=true;}constsamples: number[]=[];letruns=0;for(letsample=0;sample<7;sample++){letelapsed=0;letcount=0;// Mutable workloads prepare fresh input BEFORE each timer; immutable// workloads reuse setup. Neither preparation nor validation is measured.while(elapsed<20){seed=0x12345678;constinput=setup(n);conststart=performance.now();constvalue=run(input);constduration=performance.now()-start;if(!(duration>=0))thrownewError("Invalid monotonic timer");elapsed+=duration;count++;if(value!==checksum)thrownewError("CORRECTNESS: unstable checksum during sampling");}samples.push(elapsed/count);runs+=count;}// Do not depend on Array.sort to compute the median of a sort benchmark.for(leti=1;i<samples.length;i++){constv=samples[i];letj=i-1;while(j>=0&&samples[j]>v){samples[j+1]=samples[j];j--;}samples[j+1]=v;}console.error(JSON.stringify({name: "binary-uint8array-set",category: "binary-node", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
What happened
On pinned Perry 9495bfc, copying n bytes into a fresh destination with
dest.set(src)costs 5.290 ms at 1M bytes versus Node's 0.064 ms (82.05x), peaking at 171.09x at 100k. The gap widens steadily with size — 4.86x at n=100, 34.63x at 10k — while Node's slope stays near-flat at 0.31 because it is doing a bulk memory copy. Checksums match at every size. This is the same view-table hot spot as the already-filed subarray issue #10056.Measured against Node
v26.5.1using Perryperry 0.5.1531at9495bfc95e2afcfb5a7cb535e440e61ec0722cb1. This is evidence from that pinned revision, not a claim that current main was remeasured. First reproduce on current main; if it is already fixed, identify the fixing commit and attach the comparison.Measurements
Times are median milliseconds per workload invocation. Ratios are Perry/Node. A correctness or timeout classification takes precedence over performance; successful smaller-size timings on those rows are diagnostic evidence.
binary-uint8array-set— ASYMPTOTICLog(time)/log(n) least-squares slopes: Perry 0.671, Node 0.307, delta 0.364.
Workload: n bytes copied into a fresh destination, prepared outside timing.
Interpretation note: Both fitted slopes are below one; fixed overhead or allocation thresholds can influence this finite-range classification.
What is expected / acceptance criteria
Vec<u8>for those cases. Demonstrate with bounded counters or profiling that a 1M-byte set performs a constant number of view-table lookups.setbetween two views over the same backing buffer, in both forward and backward overlap, must produce the same bytes as Node. Add regression coverage for overlap at the start, at the end, and fully nested.setthat reads through a registered view. Add a test that writes through the backing and reads through the view in the same operation.Implementation to inspect
Hypothesis: Hypothesis from the measured source: the set path never reaches a bulk copy — it materializes a temporary byte vector one accessor call at a time.
collect_buffer_set_bytesat access.rs:136 builds aVec<u8>by looping over the source and calling one accessor per byte:js_buffer_get(ptr, i)for the Buffer source (line 141),js_typed_array_getfor the TypedArray source (line 151),js_array_get_f64for the Array source (line 158). The result is then copied again into the destination, so even the copy itself is done twice.The per-byte accessor is the expensive part.
js_buffer_getresolves throughread_buffer_byteat access.rs:180, which performssuper::view::lookup(buf_addr)at line 189 — an address-keyed side-table lookup — for every single byte, so that a registered view reads through to its ultimate backing buffer (#1205). For a 1M-byte copy that is one million side-table lookups plus one million bounds checks, where Node issues a singlememmove.The view indirection is only needed once per operation, not once per byte: the source's backing pointer, offset and length can be resolved a single time and the whole range copied with
ptr::copy_nonoverlapping. The spec-visible behaviour that must survive is the overlapping-range case (seton views sharing a backing store must behave as if the source were copied first) and the per-element coercion that the Array and Object sources genuinely require.This is source attribution consistent with the measured growth, not a profiler result. Note that the classifier flagged this ASYMPTOTIC on a finite range; the source shows linear-but-heavy work rather than a growing-prefix quadratic, and the 100k row (171.09x) being worse than the 1M row (82.05x) is consistent with allocation/cache transitions rather than a clean exponent gap. Treat the constant factor as the finding.
The Buffer/TypedArray fast path is the valuable one. The
BufferSetSource::Objectarm at line 161 legitimately needs per-index property reads (it even allocates a key string per index viai.to_string()), and theBufferSetSource::Arrayarm needs numeric coercion; neither is the measured workload and neither should be forced into a bulk copy.js_buffer_set_from_valuedispatch_buffer_methodcollect_buffer_set_bytes / read_buffer_byteSource reading narrows the investigation; it does not establish exclusive runtime/compiler attribution. No compiler or runtime changes were made to obtain these measurements.
Agent scope and coordination
Owner scope is
crates/perry-runtime/src/buffer/access.rsand the view resolution incrates/perry-runtime/src/buffer/view.rs. This shares the view side table with #10056 (Uint8Array.subarray), which has a fix in flight making subarrays zero-copy — read that branch first, because a zero-copy subarray changes whatview::lookupmust resolve and the two fixes could otherwise conflict. The DataView accessor issue touches the same view table frombuffer/dataview.rs; coordinate if you change the table's shape rather than just its call frequency.Implementation work can proceed in separate branches. Serialize benchmark runs on any shared host; parallel timing runs invalidate small performance comparisons. Preserve language semantics and moving-GC safety.
Coordinate with this benchmark task: perf(runtime): DataView setters open a GC handle scope and re-scan the view table per write — 61x Node vs 9x for the getter #10089
Related history/context: perf(runtime): Uint8Array.subarray copies every suffix; 1,251x Node at 10k views #10056
Reproduce and remeasure
Everything needed for the workload is embedded below; no private repository, fixture, npm package, or shared prelude is required. Save a complete benchmark block under its indicated filename in
/tmp/perry-builtin-repro/. Use Node 26.5.1 to match this baseline; it runs these TypeScript files directly.From the Perry checkout/branch being evaluated:
To reproduce the historical baseline, use the pinned commit above in a separate checkout and build the compiler and both libraries there. Repeat compilation for each additional benchmark below. Run this small driver from the same checkout, changing
nameandsizesfor that benchmark:Record before/after results from the same unchanged source, engine versions and host. The measured driver uses seeded setup outside timers, at least 200 ms AND five warmup runs, then seven samples with at least 20 ms measured work each. Fresh input is prepared before each timer for mutating workloads. The median per-run time is reported, with checksum consistency checked on every invocation. Timeouts cover setup, warmup and sampling, not just one builtin call.
Environment and limits
macOS-26.5-arm64-arm-64bit-Mach-O; target: native host.v26.5.1; Perry:perry 0.5.1531; build: release from source.--no-auto-optimize; compiler and both matching runtime archives were rebuilt together.[58.3896484375, 53.017578125, 57.90087890625]; end:[25.240234375, 30.416015625, 24.8681640625].Minimal correctness reductions
This issue is a performance workload; the complete checksum-gated reproducer follows.
Complete standalone benchmark sources
binary-uint8array-set.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:True.