Repository navigation
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
Description
What happened
On pinned Perry 9495bfc, 1M setUint32 writes cost 350.954 ms versus Node's 5.764 ms (60.89x), while the getter control on the same buffer costs 9.08x. Both accessors do the same bounds checking and endianness work, so the ~7x spread between them isolates what the setter does extra. Checksums match at every size.
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-dataview-set — SEVERE
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.000987 | 0.092058 | 93.29× | 53205556 | 53205556 |
| 1000 | 0.014146 | 0.426926 | 30.18× | 509007827 | 509007827 |
| 10000 | 0.153159 | 2.303841 | 15.04× | 6182819 | 6182819 |
| 100000 | 2.096716 | 20.088250 | 9.58× | 64481668 | 64481668 |
| 1000000 | 5.763479 | 350.954083 | 60.89× | 455838306 | 455838306 |
Log(time)/log(n) least-squares slopes: Perry 0.883, Node 0.970, delta -0.087.
Workload: n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.
binary-dataview-get — SLOW
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.001497 | 0.011187 | 7.47× | 53205456 | 53205456 |
| 1000 | 0.021669 | 0.141077 | 6.51× | 509006827 | 509006827 |
| 10000 | 0.081347 | 0.733503 | 9.02× | 6172819 | 6172819 |
| 100000 | 1.133113 | 7.817156 | 6.90× | 64381668 | 64381668 |
| 1000000 | 10.254917 | 93.542667 | 9.12× | 454838306 | 454838306 |
Log(time)/log(n) least-squares slopes: Perry 0.959, Node 0.939, delta 0.020.
Workload: n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.
What is expected / acceptance criteria
- Rerun both complete reproducers on the candidate release build and Node at 100, 1k, 10k, 100k and 1M accesses, on a quiet host. Checksums must match at every size, and the setter's ratio must approach the getter's rather than remaining several times worse.
- Establish by measurement which of the per-write handle scope and the per-write view-table propagation dominates, and record that split in the issue before changing behaviour. A fix landed without this attribution cannot be shown to have addressed the right cost.
- Remove the dominant per-write cost from the numeric (non-BigInt) fast path while preserving view coherency: a write through a DataView must remain visible to reads through any other registered view over the same backing buffer, and vice versa. Add a regression test covering a DataView and a Uint8Array view over one ArrayBuffer, written through each and read through the other.
- Preserve spec behaviour with regression coverage: out-of-range and negative byteOffset throwing RangeError, both endiannesses for every kind, Int8/Uint8/Int16/Uint16/Int32/Uint32/Float32/Float64 round-trips, ToNumber coercion of non-number values (including a valueOf that throws and one that detaches the buffer), the BigInt64/BigUint64 kinds still throwing TypeError on a Number argument, and the documented ordering where ToBigInt runs before the bounds check.
- If the GC handle scope is removed from the setter, prove the receiver cannot move across the remaining calls on that path — including
to_numberon a value with a uservalueOf— or keep rooting for exactly the paths that can allocate. Exercise a moving collection triggered from inside a coercion callback.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: the setter carries two per-write costs the getter does not.
js_data_view_set at dataview.rs opens a crate::gc::RuntimeHandleScope::new() and roots the receiver with scope.root_nanbox_f64(buf_f64) as its first action, on every call. js_data_view_get opens no scope and unboxes the pointer directly. For the non-BigInt kinds the setter's only allocating-capable call is to_number(value); if that cannot allocate or trigger a collection for the numeric fast path, the handle scope is pure overhead on the hot path.
Second, every setter write ends in write_bytes, which after its ptr::copy_nonoverlapping calls super::view::propagate_written_range_from_receiver(buf, offset, base, len). That is an address-keyed view side-table consultation per write — four bytes written, one table probe. The getter's read_bytes has no equivalent.
So the measured 61x versus 9x split is consistent with: handle-scope setup/teardown per call, plus a view-table probe per call, on top of the ~9x baseline the getter already shows. The getter's own 9.08x is a separate, smaller finding and is included here only as the control that isolates the setter's extra work — do not treat closing the setter gap as also fixing the getter.
This is source attribution, not a profiler result. The natural first step is to measure the two costs separately: build with the propagate_written_range_from_receiver call hoisted out of the per-write path (correctness aside) and separately with the handle scope removed, and see which of the two accounts for the spread. Only then choose the fix. A likely shape is resolving the receiver's backing and view registration once at DataView construction or on first use, and caching it in the header, so neither the scope nor the table probe is needed per accessor call.
Note the 1M row is the only size where the ratio jumps (10.11x at 100k, 60.89x at 1M); the smaller sizes sit between 10x and 93x without a clean trend. This sweep ran under heavy host load, so re-measure the size series on a quiet machine before concluding the shape — the getter-versus-setter comparison at a fixed size is the robust part of this evidence.
- crates/perry-runtime/src/buffer/dataview.rs:
js_data_view_set - crates/perry-runtime/src/buffer/dataview.rs:
js_data_view_get
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/dataview.rs. It shares the view side table in crates/perry-runtime/src/buffer/view.rs with the Uint8Array.set issue and with #10056's in-flight zero-copy subarray work; if your fix changes the table's shape or lifetime rather than just how often it is consulted, agree it on those issues first. The getter's own baseline gap is explicitly out of scope here.
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): Uint8Array.set copies byte-by-byte through the view side table instead of memcpy, reaching 171x Node #10088
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:
mkdir -p /tmp/perry-builtin-repro
cargo build --release --locked -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR="$PWD/target/release"
export TZ=UTC LC_ALL=en_US.UTF-8
git rev-parse HEAD
node --version
target/release/perry compile /tmp/perry-builtin-repro/binary-dataview-set.ts --no-auto-optimize -o /tmp/perry-builtin-repro/appTo 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:
import json, math, subprocess
name = 'binary-dataview-set'
sizes = [100, 1000, 10000, 100000, 1000000]
result_on_stderr = True
stopped = set()
points = {"node": [], "perry": []}
for n in sizes:
pair = {}
for engine, cmd in [("node", ["node", f"/tmp/perry-builtin-repro/{name}.ts"]),
("perry", ["/tmp/perry-builtin-repro/app"])]:
if engine in stopped: continue
try:
p = subprocess.run(cmd + [str(n)], capture_output=True, text=True, timeout=60)
except subprocess.TimeoutExpired:
print(engine, n, "TIMEOUT"); stopped.add(engine); continue
if p.returncode:
print(engine, n, "ERROR", p.returncode, p.stderr, p.stdout); continue
r = json.loads(p.stderr if result_on_stderr else p.stdout)
pair[engine] = r
points[engine].append((n, r["ms_per_run"]))
print(engine, r)
if len(pair) == 2:
print("ratio", n, pair["perry"]["ms_per_run"] / pair["node"]["ms_per_run"],
"checksum_match", pair["perry"]["checksum"] == pair["node"]["checksum"])
def slope(rows):
if len(rows) < 2: return None
x = [math.log(n) for n, t in rows]; y = [math.log(t) for n, t in rows]
mx = sum(x)/len(x); my = sum(y)/len(y)
return sum((a-mx)*(b-my) for a,b in zip(x,y))/sum((a-mx)**2 for a in x)
print("slopes", {engine: slope(rows) for engine, rows in points.items()})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
- CPU: Apple M1 Max; 10 logical cores; arm64.
- OS:
macOS-26.5-arm64-arm-64bit-Mach-O; target: native host. - 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.
Complete standalone benchmark sources
binary-dataview-set.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: True.
// @runtime {"name": "binary-dataview-set", "category": "binary-node", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/buffer/dataview.rs", "function": "js_data_view_set"}], "hypothesis": "Each accessor validates/coerces offsets and dispatches the requested numeric kind and byte order.", "notes": "n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.", "asynchronous": false, "output_stderr": true, "fresh_input": true}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): {view: DataView, values: number[]} {
const view = new DataView(new ArrayBuffer(n * 4));
const values = numbers(n);
for (let i = 0; i < n; i++) view.setUint32(i * 4, values[i], true);
return {view, values};
}
function run(input: {view: DataView, values: number[]}): number {
let h = 0;
for (let i = 0; i < input.values.length; i++) {
input.view.setUint32(i * 4, input.values[i] + 1, true);
h = (h + input.view.getUint32(i * 4, true)) % 1000000007;
}
return h;
}
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = setup(n);
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = setup(n);
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("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 (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.error(JSON.stringify({name: "binary-dataview-set", category: "binary-node", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();binary-dataview-get.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: True.
// @runtime {"name": "binary-dataview-get", "category": "binary-node", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/buffer/dataview.rs", "function": "js_data_view_get"}], "hypothesis": "Each accessor validates/coerces offsets and dispatches the requested numeric kind and byte order.", "notes": "n uint32 accesses. The setter case immediately reads each written value to validate writes; getter-only control separates that cost.", "asynchronous": false, "output_stderr": true, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.
let seed = 0x12345678;
function rnd(): number {
seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
const a: number[] = [];
for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
return a;
}
function hashArray(a: number[]): number {
let h = a.length;
for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
let h = s.length;
const step = Math.max(1, Math.floor(s.length / 32));
for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
return h;
}
function setup(n: number): {view: DataView, values: number[]} {
const view = new DataView(new ArrayBuffer(n * 4));
const values = numbers(n);
for (let i = 0; i < n; i++) view.setUint32(i * 4, values[i], true);
return {view, values};
}
function run(input: {view: DataView, values: number[]}): number {
let h = 0;
for (let i = 0; i < input.values.length; i++) {
h = (h + input.view.getUint32(i * 4, true)) % 1000000007;
}
return h;
}
// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
seed = 0x12345678;
const preparedInput = setup(n);
let checksum = 0;
let seen = false;
let warmMs = 0;
let warmRuns = 0;
while (warmMs < 200 || warmRuns < 5) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const elapsed = performance.now() - start;
if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
warmMs += elapsed;
warmRuns++;
if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
checksum = value;
seen = true;
}
const samples: number[] = [];
let runs = 0;
for (let sample = 0; sample < 7; sample++) {
let elapsed = 0;
let count = 0;
// Mutable workloads prepare fresh input BEFORE each timer; immutable
// workloads reuse setup. Neither preparation nor validation is measured.
while (elapsed < 20) {
seed = 0x12345678;
const input = preparedInput;
const start = performance.now();
const value = run(input);
const duration = performance.now() - start;
if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
elapsed += duration;
count++;
if (value !== checksum) throw new Error("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 (let i = 1; i < samples.length; i++) {
const v = samples[i];
let j = i - 1;
while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
samples[j + 1] = v;
}
console.error(JSON.stringify({name: "binary-dataview-get", category: "binary-node", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
Activity
proggeramlug commentedon Sep 12, 2026
Attribution measured before changing behavior.
Host/build:
Pinned 9495bfc A/B results (Perry ms):
Checksums matched Node in the baseline, no-scope, and coherent direct-backing variants at every size. The no-scope diagnostic intentionally removed rooting only for attribution; it is not the candidate fix. Getter controls across the three runs were stable except one isolated 10k sample in the direct-backing run; at 100k they were 2.9060 / 2.8957 / 2.9139 ms.
Conclusion: over the stable 100–100k range, the propagation/view-table path is consistently the dominant extra setter cost, about 3.2–3.6x the handle-scope cost. The 1M propagation result is the same threshold anomaly called out in the issue: it does not agree with the stable four-size series, so I am not using it to reverse the attribution.
Part of that dominant path was subsequently removed by #10071, landed as 1ae0f84: views now share canonical backing storage and DataView writes no longer call propagate_written_range_from_receiver. That fixes the reverse-table scan, but each setter still resolves its DataView through VIEW_REGISTRY and still opens a handle scope.
Current main 50e08e9 remeasurement (Perry/Node ratio, matching checksums at all sizes):
So current main removed the old propagation scan but has not met the acceptance target: the setter workload remains about 1.84x the getter ratio. I will cache the stable backing data pointer in the DataView header at construction, use it on numeric writes, and skip the handle scope only when both byteOffset and value are already Numbers (no allocating/coercing call remains). Coercing and BigInt paths will retain the root and reload the receiver after user code.
proggeramlug commentedon Sep 12, 2026
Fixed by #10134, landed on
mainvia merge train #10139 at08faa4c881.The close keyword does not fire through a train branch, so closing manually.