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, a loop of bound(a, b) calls costs 2642.184 ms at 1M iterations versus Node's 7.487 ms — 352.87x — and the ratio is essentially flat from n=100 (364.36x) upward, so this is a fixed per-operation cost rather than a scaling problem. Checksums match at every size. bind is the most expensive of the three Function.prototype dispatch entry points by a factor of six; call/apply are filed separately because their cost sits in a different helper.
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-bind — SEVERE
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.000445
0.161965
363.63×
53206851
53206851
1000
0.004237
1.509089
356.13×
509020824
509020824
10000
0.042677
16.548625
387.76×
6312813
6312813
100000
0.436631
366.727542
839.90×
65781663
65781663
1000000
7.487333
2642.183958
352.89×
468838303
468838303
Log(time)/log(n) least-squares slopes: Perry 1.081, Node 1.046, delta 0.035.
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 the complete reproducer on the candidate release build and Node at 100, 1k, 10k, 100k and 1M iterations. Checksums must match at every size, and the Perry/Node ratio must improve materially at every size rather than only at the largest.
Remove the per-bind eager construction of the "bound …" name string and of the two constant-key property-attribute records. Demonstrate with bounded allocation counts or profiling that a bind which never reads .name/.length performs no runtime-string allocation for the name and no String allocation for the literal keys.
Preserve the full spec surface with regression coverage: f.bind().name === 'bound f', chained f.bind().bind().name === 'bound bound f', an Object.defineProperty(f, 'name', …) override observed through the bound function, name/length non-enumerable in for-in and absent from Object.keys, length equal to max(0, target.length - boundArgs.length), and the +Infinity / beyond-u32 length path at lines 560-570.
Keep the existing Test262 bind coverage green, including bind/instance-name* and the class-ref (target_class_id) branch where the target is a constructor rather than a heap closure.
Verify moving-GC safety: the target value, bound this, and partial-argument array must stay rooted across every allocating call, and any lazily-derived name must not retain a stale closure address across a copying collection.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: js_function_bind builds the full spec-visible function metadata on every single bind, even though the overwhelmingly common case never reads .name or .length off the bound function.
In bound.rs:463 each call performs, in order: a partial-argument array allocation through js_array_alloc plus one js_array_push_f64 per bound argument (lines 513-522); a 3-capture closure allocation (line 525); a .length resolution that goes through closure_get_own_dynamic_prop(target, "length") (line 535); then the part that dominates. Line 588 runs format!("bound {target_name}"), allocating a Rust String and copying the target name, followed by js_string_from_bytes (line 590) to allocate a second runtime string. Line 592 inserts that through closure_set_dynamic_prop(bound, "name", …). Lines 597-606 then call set_builtin_property_attrs twice, each of which allocates another Rust String for the literal key ("name".to_string(), "length".to_string()) and inserts into a side table keyed by the closure address.
So a single bind performs at minimum two runtime-string allocations, two transient Rust String allocations for constant keys, one array allocation, one closure allocation, and three address-keyed side-table inserts. Node stores a bound function's name and length lazily and materializes them only when read. This is source attribution consistent with the flat ~355x ratio; it is not a profiler result, and the relative weight of the individual allocations above has not been measured.
The target-name read at line 579 (read_function_name_property, falling back to function_name_for_ptr) is itself only needed to build the eagerly-stored "bound …" string. If the name is derived on demand, the target closure pointer already captured in slot 0 is enough to reconstruct it.
Note for whoever picks this up: the two set_builtin_property_attrs calls exist to keep name/length non-enumerable (Test262 bind/instance-name*), so they cannot simply be deleted — the attributes have to survive whatever lazy scheme replaces them. js_closure_alloc already carries a builtin-length channel (set_builtin_closure_length, line 561) that does not allocate a key string.
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/bound.rs and the closure dynamic-prop/attribute helpers it calls. Do not also change the per-call dispatch registry lookup — that is the sibling Function.prototype.call/apply issue and touches closure/dispatch/value_call.rs plus closure/registry.rs. If a shared lazy-metadata helper turns out to be the right fix for both, agree the interface on the two issues before either branch lands.
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: False.
// @runtime {"name": "function-bind", "category": "functions-async", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/closure/dispatch/bound.rs", "function": "js_function_bind"}], "hypothesis": "Bind builds partial-argument storage and a wrapper closure.", "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.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;}functionadd(this: {bias: number},a: number,b: number): number{returnthis.bias+a+b;}functionsetup(n: number): number[]{returnnumbers(n);}functionrun(input: number[]): number{constreceiver={bias: 11};leth=0;for(leti=0;i<input.length;i++)h=(h+add.bind(receiver,input[i])(i%7))%1000000007;returnh;}// 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;constpreparedInput=setup(n);letchecksum=0;letseen=false;letwarmMs=0;letwarmRuns=0;while(warmMs<200||warmRuns<5){seed=0x12345678;constinput=preparedInput;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=preparedInput;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.log(JSON.stringify({name: "function-bind",category: "functions-async", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
What happened
On pinned Perry 9495bfc, a loop of
bound(a, b)calls costs 2642.184 ms at 1M iterations versus Node's 7.487 ms — 352.87x — and the ratio is essentially flat from n=100 (364.36x) upward, so this is a fixed per-operation cost rather than a scaling problem. Checksums match at every size.bindis the most expensive of the three Function.prototype dispatch entry points by a factor of six;call/applyare filed separately because their cost sits in a different helper.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.
function-bind— SEVERELog(time)/log(n) least-squares slopes: Perry 1.081, Node 1.046, delta 0.035.
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
"bound …"name string and of the two constant-key property-attribute records. Demonstrate with bounded allocation counts or profiling that a bind which never reads.name/.lengthperforms no runtime-string allocation for the name and noStringallocation for the literal keys.f.bind().name === 'bound f', chainedf.bind().bind().name === 'bound bound f', anObject.defineProperty(f, 'name', …)override observed through the bound function,name/lengthnon-enumerable infor-inand absent fromObject.keys,lengthequal tomax(0, target.length - boundArgs.length), and the +Infinity / beyond-u32 length path at lines 560-570.bind/instance-name*and the class-ref (target_class_id) branch where the target is a constructor rather than a heap closure.this, and partial-argument array must stay rooted across every allocating call, and any lazily-derived name must not retain a stale closure address across a copying collection.Implementation to inspect
Hypothesis: Hypothesis from the measured source:
js_function_bindbuilds the full spec-visible function metadata on every single bind, even though the overwhelmingly common case never reads.nameor.lengthoff the bound function.In bound.rs:463 each call performs, in order: a partial-argument array allocation through
js_array_allocplus onejs_array_push_f64per bound argument (lines 513-522); a 3-capture closure allocation (line 525); a.lengthresolution that goes throughclosure_get_own_dynamic_prop(target, "length")(line 535); then the part that dominates. Line 588 runsformat!("bound {target_name}"), allocating a RustStringand copying the target name, followed byjs_string_from_bytes(line 590) to allocate a second runtime string. Line 592 inserts that throughclosure_set_dynamic_prop(bound, "name", …). Lines 597-606 then callset_builtin_property_attrstwice, each of which allocates another RustStringfor the literal key ("name".to_string(),"length".to_string()) and inserts into a side table keyed by the closure address.So a single
bindperforms at minimum two runtime-string allocations, two transient RustStringallocations for constant keys, one array allocation, one closure allocation, and three address-keyed side-table inserts. Node stores a bound function'snameandlengthlazily and materializes them only when read. This is source attribution consistent with the flat ~355x ratio; it is not a profiler result, and the relative weight of the individual allocations above has not been measured.The target-name read at line 579 (
read_function_name_property, falling back tofunction_name_for_ptr) is itself only needed to build the eagerly-stored"bound …"string. If the name is derived on demand, the target closure pointer already captured in slot 0 is enough to reconstruct it.Note for whoever picks this up: the two
set_builtin_property_attrscalls exist to keepname/lengthnon-enumerable (Test262bind/instance-name*), so they cannot simply be deleted — the attributes have to survive whatever lazy scheme replaces them.js_closure_allocalready carries a builtin-length channel (set_builtin_closure_length, line 561) that does not allocate a key string.js_function_bindSource 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/bound.rsand the closure dynamic-prop/attribute helpers it calls. Do not also change the per-call dispatch registry lookup — that is the siblingFunction.prototype.call/applyissue and touchesclosure/dispatch/value_call.rsplusclosure/registry.rs. If a shared lazy-metadata helper turns out to be the right fix for both, agree the interface on the two issues before either branch lands.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.
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
function-bind.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.