Repository navigation
perf(runtime): every .call/.apply pays a thread-local registry hash lookup, costing a flat 58x/68x Node #10085
Description
What happened
On pinned Perry 9495bfc, f.call(recv, a, b) costs 237.785 ms at 1M iterations versus Node's 4.120 ms (57.71x) and f.apply(recv, args) costs 263.731 ms versus 7.362 ms (35.82x at 1M, 67-71x at every smaller size). Both ratios are flat across four decades of n — 57.63x at n=100 for call, 67.42x at n=100 for apply — so the cost is per invocation, not per element. 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.
function-call — SEVERE
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.000420 | 0.024292 | 57.85× | 53206851 | 53206851 |
| 1000 | 0.004161 | 0.237040 | 56.97× | 509020824 | 509020824 |
| 10000 | 0.042409 | 2.409551 | 56.82× | 6312813 | 6312813 |
| 100000 | 0.415761 | 24.202042 | 58.21× | 65781663 | 65781663 |
| 1000000 | 4.120109 | 237.784833 | 57.71× | 468838303 | 468838303 |
Log(time)/log(n) least-squares slopes: Perry 0.999, Node 0.998, delta 0.001.
Workload: n calls with two numeric arguments and a nontrivial this receiver; apply/bind include their natural per-call argument/wrapper allocation.
function-apply — SEVERE
| n | Node ms / status | Perry ms / status | ratio | Node checksum | Perry checksum |
|---|---|---|---|---|---|
| 100 | 0.000439 | 0.029346 | 66.91× | 53206851 | 53206851 |
| 1000 | 0.004199 | 0.286183 | 68.15× | 509020824 | 509020824 |
| 10000 | 0.043915 | 2.971940 | 67.67× | 6312813 | 6312813 |
| 100000 | 0.426342 | 30.089125 | 70.58× | 65781663 | 65781663 |
| 1000000 | 7.362153 | 263.730750 | 35.82× | 468838303 | 468838303 |
Log(time)/log(n) least-squares slopes: Perry 0.993, Node 1.046, delta -0.053.
Workload: n calls with two numeric arguments and a nontrivial this receiver; apply/bind include their natural per-call argument/wrapper allocation.
What is expected / acceptance criteria
- Rerun both complete reproducers on the candidate release build and Node at 100, 1k, 10k, 100k and 1M iterations. Checksums must match at every size, and the ratio must improve at every size — a fix that only helps the largest size has not addressed a per-call cost.
- Eliminate the unconditional thread-local registry lookup from the hot non-rest dispatch path. Show with bounded counters or profiling that a repeated call to the same closure performs no per-call hash-map lookup after the first.
- Preserve rest-parameter correctness: keep AOT: spread of non-empty typed array passes denormal placeholders to callee #653's fix intact so arities of 7 and above still bundle rest arguments (the regression it names is
rest.lengthreading 0). Cover a rest closure invoked through.call,.applyand spread at arities 1 through 16, and a rest closure whose registration happens after a first call (the methods inherited via Object.setPrototypeOf(obj, proto) run with this=undefined — effect Pipeable/Tag statics return the wrong pipe stage (blocks web.ts, 'Not a valid effect: undefined') #6475 cache-poisoning hazard the registry documents at registry.rs:575). - Preserve the class-ref carve-out at value_call.rs:477 and 511: a 0x7FFE-tagged registered class ref must not be unboxed into a plain number, or an imported class passed through
f(...args)stops being callable. - Keep arrow-vs-method receiver binding, bound-method routing and
js_closure_call0-through-call16arity behaviour unchanged; add coverage for a closure invoked through all three of a direct call,.calland.applyreturning identical results.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: the shared entry point does a hash-map lookup in thread-local storage before it can dispatch, on every call.
js_closure_call_array at value_call.rs:429 is the entry point both call and apply reach. Before any dispatch decision it runs get_valid_func_ptr(closure) (line 461) and then lookup_closure_rest_full(fp_for_rest) (line 462). That second call resolves to registry.rs:559 → body_record at registry.rs:221, which is CLOSURE_BODY_REGISTRY.with(|r| r.borrow().get(&(func_ptr as usize)).copied()) — a thread-local access, a RefCell borrow, and a hash of the function pointer, per call.
The registry comment at registry.rs:237-240 documents a DISPATCH_RECENT memo built precisely so that repeat dispatch can "avoid a second TLS registry lookup". js_closure_call_array does not consult it: the rest-param probe runs unconditionally and ahead of the per-arity js_closure_callN dispatchers that do use the memo. A closure body's rest-param shape is fixed at module-init registration time and never changes for a given func_ptr, so this lookup is answering the same question with the same answer on every iteration of a hot call loop.
A cached answer — in the memoized dispatch strategy the file already maintains, or in a spare ClosureHeader field — would make the common non-rest case a branch on an already-loaded word. This is source attribution, not a profiler result: it explains a fixed per-call cost consistent with the flat ratios, but the split between this lookup, the a(i) NaN-box unboxing closure (lines 501-517) and the js_closure_callN trampoline has not been measured. Profile before choosing where to cache.
apply is consistently slower than call at matching sizes, which is expected given it also materializes the argument list; the 1M apply row (35.82x) is the only point where the two diverge from their otherwise flat behaviour and should be re-confirmed on a quiet host before being read as a real shape change.
- crates/perry-runtime/src/closure/dispatch/value_call.rs:
js_closure_call_array
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/closure/dispatch/value_call.rs and the caching structures in crates/perry-runtime/src/closure/registry.rs. Do not also change Function.prototype.bind's metadata construction — that is the sibling issue and lives in closure/dispatch/bound.rs. Any change to the shared dispatch-strategy memo must be agreed across both before landing, because both issues may want to extend it.
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): Function.prototype.bind eagerly materializes name/length metadata, costing ~355x Node at every size #10084
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/function-call.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 = 'function-call'
sizes = [100, 1000, 10000, 100000, 1000000]
result_on_stderr = False
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
function-call.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "function-call", "category": "functions-async", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/closure/dispatch/value_call.rs", "function": "js_closure_call_array"}], "hypothesis": "Dynamic call dispatch checks callable metadata and marshals boxed arguments.", "notes": "n calls with two numeric arguments and a nontrivial this receiver; apply/bind include their natural per-call argument/wrapper allocation.", "asynchronous": false, "output_stderr": false, "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 add(this: {bias: number}, a: number, b: number): number { return this.bias + a + b; }
function setup(n: number): number[] { return numbers(n); }
function run(input: number[]): number {
const receiver = {bias: 11};
let h = 0;
for (let i = 0; i < input.length; i++) h = (h + add.call(receiver, input[i], i % 7)) % 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.log(JSON.stringify({name: "function-call", category: "functions-async", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();function-apply.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "function-apply", "category": "functions-async", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/closure/dispatch/value_call.rs", "function": "js_closure_call_array"}], "hypothesis": "Dynamic call dispatch checks callable metadata and marshals boxed arguments.", "notes": "n calls with two numeric arguments and a nontrivial this receiver; apply/bind include their natural per-call argument/wrapper allocation.", "asynchronous": false, "output_stderr": false, "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 add(this: {bias: number}, a: number, b: number): number { return this.bias + a + b; }
function setup(n: number): number[] { return numbers(n); }
function run(input: number[]): number {
const receiver = {bias: 11};
let h = 0;
for (let i = 0; i < input.length; i++) h = (h + add.apply(receiver, [input[i], i % 7])) % 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.log(JSON.stringify({name: "function-apply", category: "functions-async", n,
ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
Activity
proggeramlug commentedon Sep 12, 2026
Fixed by #10127, landed on
mainvia merge train #10139 at46120666c5.The close keyword does not fire through a train branch, so closing manually.