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
Under the Perex engine, four common whole-string regex operations still do, or now do, work that grows with the square of the input. "ab12,cd345;ef6 ".repeat(n).split(/[,; ]+/) was linear and about 7x Node on the pre-Perex runtime; on current main it takes seconds at n=10,000 and the benchmark times out. Global exec loops, matchAll and replace with a callback were already quadratic on the old engine and remain quadratic, timing out at n=100,000 where Node needs about 12 ms. Checksums match Node at every completed size. The Unicode variants of these workloads additionally throw, which is tracked separately.
Measured against Node v26.8.1 using Perry perry 0.5.1545 at 8a058e205385ec8ebb353ce0f231530871ce4a49. 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.
regex-split — TIMEOUT
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.009743
3.026491
310.63×
705330170
705330170
1000
0.100322
89.910230
896.21×
502537147
502537147
10000
0.998414
TIMEOUT
—
417172305
—
100000
10.670293
SKIPPED
—
155868597
—
1000000
239.836757
SKIPPED
—
536314919
—
Log(time)/log(n) least-squares slopes: Perry 1.473, Node 1.081, delta 0.392.
Workload: Regex baseline, excluded from the main ranking.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000].
perry n=10000: TIMEOUT, exit -9
Process exceeded 60 s
regex-exec-global — TIMEOUT
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.012898
0.496530
38.50×
702256350
702256350
1000
0.127505
10.578161
82.96×
275951190
275951190
10000
1.209385
677.357363
560.08×
454333185
454333185
100000
11.887562
TIMEOUT
—
934096856
—
1000000
116.560667
SKIPPED
—
916735987
—
Log(time)/log(n) least-squares slopes: Perry 1.567, Node 0.988, delta 0.579.
Workload: Regex baseline, excluded from the main ranking. Fresh regex in setup resets lastIndex outside timing; n records each contain two nonempty matches.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000].
perry n=100000: TIMEOUT, exit -9
Process exceeded 60 s
regex-match-all — TIMEOUT
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.012705
0.801369
63.08×
702256350
702256350
1000
0.125690
13.645045
108.56×
275951190
275951190
10000
1.272216
714.595513
561.69×
454333185
454333185
100000
12.297022
TIMEOUT
—
934096856
—
1000000
116.738874
SKIPPED
—
916735987
—
Log(time)/log(n) least-squares slopes: Perry 1.475, Node 0.992, delta 0.483.
Workload: Regex baseline, excluded from the main ranking. n records each contain two matches; full iteration makes all matches observable.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000].
perry n=100000: TIMEOUT, exit -9
Process exceeded 60 s
regex-replace-callback — TIMEOUT
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.010074
0.957875
95.08×
363948310
363948310
1000
0.097525
15.420992
158.12×
684918147
684918147
10000
0.958640
726.967271
758.33×
987261466
987261466
100000
12.134925
TIMEOUT
—
620332332
—
1000000
268.444463
SKIPPED
—
994649528
—
Log(time)/log(n) least-squares slopes: Perry 1.440, Node 1.095, delta 0.345.
Workload: Regex baseline, excluded from the main ranking.
Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000, 10000].
perry n=100000: TIMEOUT, exit -9
Process exceeded 60 s
What is expected / acceptance criteria
Rerun all four full reproducers on the fix and on the pre-Perex revision on one host at 100, 1k, 10k, 100k and 1M. Every size Node completes must complete within the existing 60-second budget, with checksums matching Node at every size.
Show linear total work: the measured Perry-minus-Node log-log slope delta must be below 0.25 over a shared 1k–100k range for each of the four workloads, and the standalone program's rows must roughly double, not quadruple, per doubling of n.
split must at minimum return to the pre-Perex engine's linear behaviour and constant factor; report its ratio against Node at 1M alongside the old engine's.
Confirm by profiling or instrumentation, and record on the issue, where per-attempt positioning cost went — for example that a match attempt resumes from the previous position rather than seeking from the start of the subject — before the change is considered done.
Preserve semantics with regression coverage: lastIndex read/write and reset-to-0 on failure for g and y, exec result index/input/groups, matchAll requiring the g flag and iterating lazily, zero-width matches advancing by one code point under u and one code unit without it, replacement patterns ($&, $1, $<name>) and callback arguments, and split with captures and a limit.
Implementation to inspect
Same-host before/after. The "old engine" column is the pre-Perex runtime at 9495bfc95e and the "Perex" column is main at 8a058e2053, both built from source with the identical release command and measured sequentially on the same host with the same Node. Perex became the runtime's only regex engine in #10142 (landed via merge train #10146); #10149 then resolved it from crates.io as perex = "0.1".
Before/after, full suite workloads (median ms per invocation, same host):
Single-call scaling from a standalone program (one timed call each, not medians; the program is below). Doubling n should roughly double the time for linear work:
probe
Node
old engine
Perex
split ascii n=1000
0.2 ms
2.6 ms
93.0 ms
split ascii n=2000
0.1 ms
1.2 ms
311.3 ms
split ascii n=4000
0.6 ms
2.3 ms
1,157.2 ms
split ascii n=10000
1.2 ms
5.6 ms
6,904.1 ms
exec global ascii n=1000
0.4 ms
12.5 ms
10.2 ms
exec global ascii n=2000
0.5 ms
48.1 ms
33.7 ms
exec global ascii n=4000
0.4 ms
188.1 ms
119.1 ms
exec global ascii n=10000
2.2 ms
1,200.4 ms
771.9 ms
This mechanism is a source-reading hypothesis. The Perex maintainers are checking where each cost actually sits and have not confirmed it yet. Treat the line references as places to look, not as a diagnosis.
At perex_api.rs:15 the runtime sets WORK = 100_000_000, and every split/replace/match entry point creates one Budget::new(api::WORK) for the whole operation (perex_split.rs:80, perex_replace.rs:68, perex_match_search.rs:154, match_all.rs:227). Perex's own documentation on Budget (src/lib.rs, line 27) says it is a "work allowance shared across an entire compile or search operation" that "is never reset when trying another start position". In src/span/bound.rs the span reader seeks toward span.start() one UTF-16 unit at a time from its saved mark, charging the budget one unit per step (the loop at line 161). If each match attempt seeks from the start of the subject rather than resuming from the previous attempt, one attempt costs O(position), a whole split or replace costs O(n²), and a fixed 100M allowance is exhausted once roughly n²/2 exceeds it — at which point the operation throws instead of merely running slowly. RegExp.prototype[@@split] is implemented as the specification's sticky loop in perex_split.rs, trying a match at each successive position, which would explain why split in particular changed from linear to quadratic.
For global exec and matchAll the same per-attempt seek would produce the quadratic independently of the budget: each call starts at lastIndex and, if positioning the reader costs O(lastIndex), a loop over n matches costs O(n²). The pre-Perex engine showed the same shape, attributed in the original report to converting lastIndex between UTF-16 units and bytes on every call, so this part is not new — but the engine replacement was the natural point to remove it, and it remains.
Standalone scaling program
functiont(label: string,f: ()=>number): void{consts=performance.now();try{constr=f();console.log(`${label.padEnd(34)} ok ${(performance.now()-s).toFixed(1).padStart(9)} ms result=${r}`);}catch(e){console.log(`${label.padEnd(34)} THREW ${(performance.now()-s).toFixed(1).padStart(9)} ms ${String(e)}`);}}for(constnof[1000,2000,4000,10000]){t(`split ascii n=${n}`,()=>"ab12,cd345;ef6 ".repeat(n).split(/[,;]+/).length);t(`split unicode n=${n}`,()=>"ä中12,Ö漢345;ef6😀".repeat(n).split(/[,;😀]+/u).length);t(`replace cb unicode n=${n}`,()=>"ä中😀12 Ö漢🦊345;".repeat(n).replace(/[ä中😀Ö漢🦊]+/gu,(m)=>"["+m+"]").length);t(`exec global ascii n=${n}`,()=>{consts="ab12 cd345;".repeat(n),re=/([a-z]+)([0-9]+)/g;letc=0;while(re.exec(s)!==null)c++;returnc;});}constvals: string[]=[];for(leti=0;i<1000000;i++)vals.push((i%2 ? "record_" : "!bad_")+i);t("test literal-in-loop 1M",()=>{letc=0;for(leti=0;i<vals.length;i++)if(/^[a-z]+_[0-9]+$/.test(vals[i]))c++;returnc;});consthoisted=/^[a-z]+_[0-9]+$/;t("test hoisted regex 1M",()=>{letc=0;for(leti=0;i<vals.length;i++)if(hoisted.test(vals[i]))c++;returnc;});
The benchmark metadata comments embedded below still name the pre-Perex runtime functions they were written against; #10142 deleted those files. "Implementation to inspect" lists the code paths that exist at the measured revision.
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
These three issues are owned by the Perex maintainers, who have taken them and are confirming cost attribution before splitting ownership. Do not start a source fix without coordinating on the issue first, so work does not collide. Changes belong in the Perex crate and/or the runtime adapter under crates/perry-runtime/src/regex/perex_*. The acceptance numbers should be re-measured against the same pre-Perex revision on one host, as above, not against the original September 11 sweep, which ran on a different machine.
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.8.1 to match this measurement; 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.8.1; Perry: perry 0.5.1545; 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: [3.537109375, 4.07470703125, 5.009765625]; end: [3.58251953125, 3.47265625, 4.1953125].
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": "regex-split", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex/replace_expand_fancy.rs", "function": "js_string_split_regex_n"}], "hypothesis": "Hypothesis: regex split first owns a copy of the source, materializes a Vec of parts, then allocates runtime strings and the result array.", "notes": "Regex baseline, excluded from the main ranking. ", "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): string{return'ab12,cd345;ef6 '.repeat(n);}functionrun(input: string): number{constparts=input.split(/[,;]+/);leth=parts.length;for(leti=0;i<parts.length;i++)h=(h*31+hashString(parts[i]))%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: "regex-split",category: "regex", 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": "regex-exec-global", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex/exec.rs", "function": "js_regexp_exec"}], "hypothesis": "Hypothesis: global exec repeatedly converts lastIndex between UTF-16 and bytes and allocates match/capture results.", "notes": "Regex baseline, excluded from the main ranking. Fresh regex in setup resets lastIndex outside timing; n records each contain two nonempty matches.", "asynchronous": false, "output_stderr": false, "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;}functionsetup(n: number): {s: string,re: RegExp}{return{s: 'ab12 cd345;'.repeat(n),re: /([a-z]+)([0-9]+)/g};}functionrun(input: {s: string,re: RegExp}): number{leth=0;letmatch=input.re.exec(input.s);while(match!==null){h=(h*31+match.index+hashString(match[0]))%1000000007;match=input.re.exec(input.s);}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;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.log(JSON.stringify({name: "regex-exec-global",category: "regex", 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": "regex-match-all", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex/match_all.rs", "function": "js_string_match_all_value"}], "hypothesis": "Hypothesis: matchAll materializes every match before returning an iterator, adding memory and upfront allocation compared with lazy iteration.", "notes": "Regex baseline, excluded from the main ranking. n records each contain two matches; full iteration makes all matches observable.", "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): string{return'ab12 cd345;'.repeat(n);}functionrun(input: string): number{leth=0;for(constmatchofinput.matchAll(/([a-z]+)([0-9]+)/g))h=(h*31+match.index!+hashString(match[0]))%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: "regex-match-all",category: "regex", 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": "regex-replace-callback", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex/replace_expand.rs", "function": "js_string_replace_regex_fn"}], "hypothesis": "Hypothesis: regex replacement snapshots match data into owned strings, invokes the callback for each match, and copies the final output.", "notes": "Regex baseline, excluded from the main ranking. ", "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): string{return'ab12 cd345;'.repeat(n);}functionrun(input: string): number{returnhashString(input.replace(/[0-9]+/g,(match)=>'['+match+']'));}// 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: "regex-replace-callback",category: "regex", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
What happened
Under the Perex engine, four common whole-string regex operations still do, or now do, work that grows with the square of the input.
"ab12,cd345;ef6 ".repeat(n).split(/[,; ]+/)was linear and about 7x Node on the pre-Perex runtime; on currentmainit takes seconds at n=10,000 and the benchmark times out. Globalexecloops,matchAllandreplacewith a callback were already quadratic on the old engine and remain quadratic, timing out at n=100,000 where Node needs about 12 ms. Checksums match Node at every completed size. The Unicode variants of these workloads additionally throw, which is tracked separately.Measured against Node
v26.8.1using Perryperry 0.5.1545at8a058e205385ec8ebb353ce0f231530871ce4a49. 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.
regex-split— TIMEOUTLog(time)/log(n) least-squares slopes: Perry 1.473, Node 1.081, delta 0.392.
Workload: Regex baseline, excluded from the main ranking.
Slopes cover different completed sizes: Node
[100, 1000, 10000, 100000, 1000000], Perry[100, 1000].perry n=10000: TIMEOUT, exit
-9regex-exec-global— TIMEOUTLog(time)/log(n) least-squares slopes: Perry 1.567, Node 0.988, delta 0.579.
Workload: Regex baseline, excluded from the main ranking. Fresh regex in setup resets lastIndex outside timing; n records each contain two nonempty matches.
Slopes cover different completed sizes: Node
[100, 1000, 10000, 100000, 1000000], Perry[100, 1000, 10000].perry n=100000: TIMEOUT, exit
-9regex-match-all— TIMEOUTLog(time)/log(n) least-squares slopes: Perry 1.475, Node 0.992, delta 0.483.
Workload: Regex baseline, excluded from the main ranking. n records each contain two matches; full iteration makes all matches observable.
Slopes cover different completed sizes: Node
[100, 1000, 10000, 100000, 1000000], Perry[100, 1000, 10000].perry n=100000: TIMEOUT, exit
-9regex-replace-callback— TIMEOUTLog(time)/log(n) least-squares slopes: Perry 1.440, Node 1.095, delta 0.345.
Workload: Regex baseline, excluded from the main ranking.
Slopes cover different completed sizes: Node
[100, 1000, 10000, 100000, 1000000], Perry[100, 1000, 10000].perry n=100000: TIMEOUT, exit
-9What is expected / acceptance criteria
splitmust at minimum return to the pre-Perex engine's linear behaviour and constant factor; report its ratio against Node at 1M alongside the old engine's.lastIndexread/write and reset-to-0 on failure forgandy,execresultindex/input/groups,matchAllrequiring thegflag and iterating lazily, zero-width matches advancing by one code point underuand one code unit without it, replacement patterns ($&,$1,$<name>) and callback arguments, and split with captures and alimit.Implementation to inspect
Same-host before/after. The "old engine" column is the pre-Perex runtime at
9495bfc95eand the "Perex" column ismainat8a058e2053, both built from source with the identical release command and measured sequentially on the same host with the same Node. Perex became the runtime's only regex engine in #10142 (landed via merge train #10146); #10149 then resolved it from crates.io asperex = "0.1".Before/after, full suite workloads (median ms per invocation, same host):
regex-splitregex-splitregex-splitregex-splitregex-splitregex-exec-globalregex-exec-globalregex-exec-globalregex-exec-globalregex-exec-globalregex-match-allregex-match-allregex-match-allregex-match-allregex-match-allregex-replace-callbackregex-replace-callbackregex-replace-callbackregex-replace-callbackregex-replace-callbackregex-split: Perry slope 1.03 → 1.47 (SLOW → TIMEOUT), Node 1.08regex-exec-global: Perry slope 1.79 → 1.57 (TIMEOUT → TIMEOUT), Node 0.99regex-match-all: Perry slope 1.50 → 1.48 (TIMEOUT → TIMEOUT), Node 0.99regex-replace-callback: Perry slope 1.84 → 1.44 (TIMEOUT → TIMEOUT), Node 1.09Single-call scaling from a standalone program (one timed call each, not medians; the program is below). Doubling n should roughly double the time for linear work:
split ascii n=1000split ascii n=2000split ascii n=4000split ascii n=10000exec global ascii n=1000exec global ascii n=2000exec global ascii n=4000exec global ascii n=10000This mechanism is a source-reading hypothesis. The Perex maintainers are checking where each cost actually sits and have not confirmed it yet. Treat the line references as places to look, not as a diagnosis.
At
perex_api.rs:15the runtime setsWORK = 100_000_000, and every split/replace/match entry point creates oneBudget::new(api::WORK)for the whole operation (perex_split.rs:80,perex_replace.rs:68,perex_match_search.rs:154,match_all.rs:227). Perex's own documentation onBudget(src/lib.rs, line 27) says it is a "work allowance shared across an entire compile or search operation" that "is never reset when trying another start position". Insrc/span/bound.rsthe span reader seeks towardspan.start()one UTF-16 unit at a time from its saved mark, charging the budget one unit per step (the loop at line 161). If each match attempt seeks from the start of the subject rather than resuming from the previous attempt, one attempt costs O(position), a whole split or replace costs O(n²), and a fixed 100M allowance is exhausted once roughly n²/2 exceeds it — at which point the operation throws instead of merely running slowly.RegExp.prototype[@@split]is implemented as the specification's sticky loop inperex_split.rs, trying a match at each successive position, which would explain why split in particular changed from linear to quadratic.For global
execandmatchAllthe same per-attempt seek would produce the quadratic independently of the budget: each call starts atlastIndexand, if positioning the reader costs O(lastIndex), a loop over n matches costs O(n²). The pre-Perex engine showed the same shape, attributed in the original report to convertinglastIndexbetween UTF-16 units and bytes on every call, so this part is not new — but the engine replacement was the natural point to remove it, and it remains.Standalone scaling program
The benchmark metadata comments embedded below still name the pre-Perex runtime functions they were written against; #10142 deleted those files. "Implementation to inspect" lists the code paths that exist at the measured revision.
js_string_split_regexregexpjs_regexp_execregexpfindjs_string_match_allregexpSource 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
These three issues are owned by the Perex maintainers, who have taken them and are confirming cost attribution before splitting ownership. Do not start a source fix without coordinating on the issue first, so work does not collide. Changes belong in the Perex crate and/or the runtime adapter under
crates/perry-runtime/src/regex/perex_*. The acceptance numbers should be re-measured against the same pre-Perex revision on one host, as above, not against the original September 11 sweep, which ran on a different machine.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: bug(regex): split and replace throw "Regular expression work limit exceeded" on 32,000-unit strings Node handles in under a millisecond #10164
Coordinate with this benchmark task: perf(regex): RegExp.prototype.test costs ~1.5 µs per call under Perex — 44x slower than the old engine even with the regex hoisted #10166
Related history/context: Make Perex the runtime's only regular-expression engine #10142
Related history/context: Merge train 170: #10142 #10146
Related history/context: Merge train 172: #10148 #10149
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.8.1 to match this measurement; 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
Linux-6.17.0-23-generic-x86_64-with-glibc2.39; target: native host.v26.8.1; Perry:perry 0.5.1545; build: release from source.--no-auto-optimize; compiler and both matching runtime archives were rebuilt together.[3.537109375, 4.07470703125, 5.009765625]; end:[3.58251953125, 3.47265625, 4.1953125].Minimal correctness reductions
This issue is a performance workload; the complete checksum-gated reproducer follows.
Complete standalone benchmark sources
regex-split.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.regex-exec-global.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.regex-match-all.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.regex-replace-callback.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.