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, includes over a 1M-element numeric array costs 49.809 ms versus Node's 2.521 ms (19.76x) and indexOf costs 19.29x, with both ratios flat from n=1000 upward. Checksums match at every size. The workload is 16 precomputed queries alternating guaranteed hits and guaranteed misses against a dense f64 array — the loop Node compiles into a vectorizable double scan.
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.
array-includes-numbers — SEVERE
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.000514
0.005537
10.78×
820299987
820299987
1000
0.002947
0.051388
17.44×
820299987
820299987
10000
0.029037
0.548847
18.90×
820299987
820299987
100000
0.280611
5.428969
19.35×
820299987
820299987
1000000
2.521344
49.808834
19.75×
820299987
820299987
Log(time)/log(n) least-squares slopes: Perry 0.993, Node 0.936, delta 0.057.
Workload: n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.
array-indexof-numbers — SEVERE
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.000449
0.005590
12.45×
974887955
974887955
1000
0.002897
0.049393
17.05×
627491663
627491663
10000
0.029235
0.575608
19.69×
720740965
720740965
100000
0.280435
5.632094
20.08×
671043086
671043086
1000000
2.555630
49.291875
19.29×
980023316
980023316
Log(time)/log(n) least-squares slopes: Perry 0.995, Node 0.950, delta 0.045.
Workload: n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.
What is expected / acceptance criteria
Rerun both complete reproducers on the candidate release build and Node at 100, 1k, 10k, 100k and 1M elements. Checksums must match at every size, and the ratio must improve at every size from 1k upward.
Confirm by profiling or disassembly, and record on the issue, whether the per-element js_jsvalue_same_value_zero call survives in the shipping build. If it is already inlined, the hypothesis above is wrong and the real cost must be identified before any change lands.
Add a specialized scan for a proven-numeric dense array searched for a plain number, hoisting the NaN test out of the loop. Show the measured improvement is not an artefact of the query batch by also testing a search value guaranteed absent (full-length scan) and one at the first index (early exit).
Preserve the SameValueZero/strict-equality split exactly: [1, NaN, 3].indexOf(NaN) === -1, [1, NaN, 3].includes(NaN) === true, [0].includes(-0) === true, [-0].indexOf(0) === 0. Add coverage for undefined and holes: [, 1].includes(undefined) === true versus [, 1].indexOf(undefined) === -1.
Preserve the TypedArray receiver path at the top of js_array_includes_jsvalue (which reads through js_typed_array_get) and the fromIndex handling, including negative, fractional, out-of-range and omitted values, and the §23.1.3.16 step-3 ordering where a zero-length array returns before ToIntegerOrInfinity runs. Cover a mixed-kind array that must fall back to the generic path, and an array whose elements are mutated to a non-numeric kind between searches.
Implementation to inspect
Hypothesis: Hypothesis from the measured source: the scan is a tight loop around a non-inlinable cross-crate call that re-derives the value's type on every element.
js_array_includes_jsvalue in search.rs ends in for i in start..length { let element = array_element_get_value(elements_ptr, i as usize); if crate::value::js_jsvalue_same_value_zero(element, value) == 1 { return 1; } }.
js_jsvalue_same_value_zero at equality.rs is a #[no_mangle] pub extern "C" function. It performs two is_number_nan bit tests and then calls js_jsvalue_equals, itself another #[no_mangle] extern "C" function, which runs normalize_raw_object_bits on both operands (a shift, a compare and a conditional tag OR, documented at equality.rs as necessary for the raw-pointer-in-f64-slot representation from #3576), then a NaN check, then dispatches to string and BigInt comparison paths that a numeric array can never reach.
Because both are no_mangle extern "C", they are call boundaries the optimizer will not inline or hoist, so the per-element cost is a full call plus the generic dispatch, repeated for every slot. Node's scan for a packed double array is a bounds-checked double compare in a loop it can unroll and vectorize.
The element kind is already known in principle: the array header carries a numeric layout and an element-shape proof (see clear_element_shape and the numeric layout helpers in crates/perry-runtime/src/array/). When the array is proven all-f64 and the search value is a plain number, the entire loop can be a raw double comparison, with SameValueZero's only divergence from === — NaN equals NaN — handled by hoisting the searchElement is NaN test out of the loop. That single hoisted branch is what separates indexOf from includes semantically, so both can share one specialized scan with a flag.
This is source attribution consistent with the flat ratio, not a profiler result. Confirm the per-element call is actually being made (rather than being inlined through LTO in the shipping build) before designing around it — the archives here were built with the standard release profile, and --no-auto-optimize was used for compilation, so the auto-optimize whole-program rebuild that ships for some workloads may behave differently.
The string-element siblings (array-includes-strings-ascii, array-indexof-strings-ascii and their Unicode variants) sit at 2.1-2.4x and are not part of this task: their per-element work is genuine string comparison, and a numeric specialization does not apply.
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/array/search.rs and any numeric specialization it needs from the array element-shape helpers. Do not change js_jsvalue_equals or js_jsvalue_same_value_zero themselves — they are on many hot paths beyond array search, and the #3576 raw-pointer normalization they perform is load-bearing. The correct shape is a specialized caller, not a modified shared helper. The element-shape proof is also touched by the splice/unshift layout task; coordinate if you change how the proof is read.
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": "array-includes-numbers", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/search.rs", "function": "js_array_includes_jsvalue"}], "hypothesis": "Search scans slots and applies the runtime equality semantics per candidate; a fixed query batch exposes the cost as array length grows.", "notes": "n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.", "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;}functionsetup(n: number): {a: number[],queries: number[]}{consta=numbers(n);constqueries: number[]=[];for(leti=0;i<16;i++)queries.push(i%2===0 ? a[Math.floor(rnd()*n)] : -1-i);return{a, queries};}functionrun(input: {a: number[],queries: number[]}): number{consta=input.a;constqueries=input.queries;leth=0;for(leti=0;i<queries.length;i++)h=(h*31+(a.includes(queries[i]) ? 1 : 0)+2)%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: "array-includes-numbers",category: "arrays", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.
// @runtime {"name": "array-indexof-numbers", "category": "arrays", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/array/search.rs", "function": "js_array_indexOf_jsvalue"}], "hypothesis": "Search scans slots and applies the runtime equality semantics per candidate; a fixed query batch exposes the cost as array length grows.", "notes": "n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.", "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;}functionsetup(n: number): {a: number[],queries: number[]}{consta=numbers(n);constqueries: number[]=[];for(leti=0;i<16;i++)queries.push(i%2===0 ? a[Math.floor(rnd()*n)] : -1-i);return{a, queries};}functionrun(input: {a: number[],queries: number[]}): number{consta=input.a;constqueries=input.queries;leth=0;for(leti=0;i<queries.length;i++)h=(h*31+a.indexOf(queries[i])+2)%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: "array-indexof-numbers",category: "arrays", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
What happened
On pinned Perry 9495bfc,
includesover a 1M-element numeric array costs 49.809 ms versus Node's 2.521 ms (19.76x) andindexOfcosts 19.29x, with both ratios flat from n=1000 upward. Checksums match at every size. The workload is 16 precomputed queries alternating guaranteed hits and guaranteed misses against a dense f64 array — the loop Node compiles into a vectorizable double scan.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.
array-includes-numbers— SEVERELog(time)/log(n) least-squares slopes: Perry 0.993, Node 0.936, delta 0.057.
Workload: n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.
array-indexof-numbers— SEVERELog(time)/log(n) least-squares slopes: Perry 0.995, Node 0.950, delta 0.045.
Workload: n is array length; 16 precomputed queries alternate guaranteed hits with guaranteed misses.
What is expected / acceptance criteria
js_jsvalue_same_value_zerocall survives in the shipping build. If it is already inlined, the hypothesis above is wrong and the real cost must be identified before any change lands.[1, NaN, 3].indexOf(NaN) === -1,[1, NaN, 3].includes(NaN) === true,[0].includes(-0) === true,[-0].indexOf(0) === 0. Add coverage forundefinedand holes:[, 1].includes(undefined) === trueversus[, 1].indexOf(undefined) === -1.js_array_includes_jsvalue(which reads throughjs_typed_array_get) and thefromIndexhandling, including negative, fractional, out-of-range and omitted values, and the §23.1.3.16 step-3 ordering where a zero-length array returns before ToIntegerOrInfinity runs. Cover a mixed-kind array that must fall back to the generic path, and an array whose elements are mutated to a non-numeric kind between searches.Implementation to inspect
Hypothesis: Hypothesis from the measured source: the scan is a tight loop around a non-inlinable cross-crate call that re-derives the value's type on every element.
js_array_includes_jsvaluein search.rs ends infor i in start..length { let element = array_element_get_value(elements_ptr, i as usize); if crate::value::js_jsvalue_same_value_zero(element, value) == 1 { return 1; } }.js_jsvalue_same_value_zeroat equality.rs is a#[no_mangle] pub extern "C"function. It performs twois_number_nanbit tests and then callsjs_jsvalue_equals, itself another#[no_mangle] extern "C"function, which runsnormalize_raw_object_bitson both operands (a shift, a compare and a conditional tag OR, documented at equality.rs as necessary for the raw-pointer-in-f64-slot representation from #3576), then a NaN check, then dispatches to string and BigInt comparison paths that a numeric array can never reach.Because both are
no_mangle extern "C", they are call boundaries the optimizer will not inline or hoist, so the per-element cost is a full call plus the generic dispatch, repeated for every slot. Node's scan for a packed double array is a bounds-checked double compare in a loop it can unroll and vectorize.The element kind is already known in principle: the array header carries a numeric layout and an element-shape proof (see
clear_element_shapeand the numeric layout helpers incrates/perry-runtime/src/array/). When the array is proven all-f64 and the search value is a plain number, the entire loop can be a raw double comparison, with SameValueZero's only divergence from===— NaN equals NaN — handled by hoisting thesearchElement is NaNtest out of the loop. That single hoisted branch is what separatesindexOffromincludessemantically, so both can share one specialized scan with a flag.This is source attribution consistent with the flat ratio, not a profiler result. Confirm the per-element call is actually being made (rather than being inlined through LTO in the shipping build) before designing around it — the archives here were built with the standard release profile, and
--no-auto-optimizewas used for compilation, so the auto-optimize whole-program rebuild that ships for some workloads may behave differently.The string-element siblings (
array-includes-strings-ascii,array-indexof-strings-asciiand their Unicode variants) sit at 2.1-2.4x and are not part of this task: their per-element work is genuine string comparison, and a numeric specialization does not apply.js_array_includes_jsvaluejs_array_indexOf_jsvalueSource 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/array/search.rsand any numeric specialization it needs from the array element-shape helpers. Do not changejs_jsvalue_equalsorjs_jsvalue_same_value_zerothemselves — they are on many hot paths beyond array search, and the #3576 raw-pointer normalization they perform is load-bearing. The correct shape is a specialized caller, not a modified shared helper. The element-shape proof is also touched by the splice/unshift layout task; coordinate if you change how the proof is read.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
array-includes-numbers.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.array-indexof-numbers.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.