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
A million .test() calls against short strings take about 1.5 seconds on current main, a flat per-call cost at every input size. On the pre-Perex runtime the same loop took about a tenth of a second. The regression is the same whether the regex literal sits inside the loop or is hoisted into a single RegExp created once, so it is not explained by losing literal-site or compilation caching: the cost is in each match call. Checksums match Node at every size.
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-test-literal — SEVERE
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.002062
0.153508
74.46×
952009575
952009575
1000
0.021067
1.525797
72.42×
515425129
515425129
10000
0.206593
15.459840
74.83×
248696985
248696985
100000
2.027071
156.154489
77.03×
603020940
603020940
1000000
20.348576
1560.366017
76.68×
36901593
36901593
Log(time)/log(n) least-squares slopes: Perry 1.002, Node 0.997, delta 0.005.
Workload: Regex baseline, excluded from the main ranking. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.
regex-test-literal-unicode — SEVERE
n
Node ms / status
Perry ms / status
ratio
Node checksum
Perry checksum
100
0.002134
0.155277
72.77×
952009575
952009575
1000
0.026292
1.573017
59.83×
515425129
515425129
10000
0.211933
15.949114
75.26×
248696985
248696985
100000
2.067177
160.227477
77.51×
603020940
603020940
1000000
20.259069
1597.342334
78.85×
36901593
36901593
Log(time)/log(n) least-squares slopes: Perry 1.003, Node 0.985, delta 0.018.
Workload: Unicode regex baseline, excluded from the main ranking. Subject and pattern contain umlauts, CJK and/or emoji, using the Unicode flag. n counts records; non-ASCII /g indices are UTF-16 offsets, not UTF-8 byte offsets. Checksums consume test outcomes, actual match contents/indices, or replacement/split outputs. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.
What is expected / acceptance criteria
Before changing behaviour, measure and record on this issue how the per-call time divides between setup, subject binding/positioning, matching, and result construction for a hoisted .test() on a short ASCII string. (Recorded 2026-09-14 on 92eadb77ab: scratch setup ~2,710, matching ~2,295, binding/dispatch ~1,820, GC ~440, exception frame ~370, property lookup ~155, result construction 0, of 8,315 instructions per call.)
Rerun both full reproducers and the standalone hoisted-versus-literal program on the fix and on the pre-Perex revision on one host at every size. Checksums must match Node, and the Perex/Node ratio must return at least to the pre-Perex engine's ratio for both the literal-in-loop and hoisted forms.
Keep the hoisted and literal forms within a small constant of each other, and add the hoisted form to any future .test() measurement so a construction fix cannot be mistaken for a per-call fix.
Preserve semantics: test on a g or y regex reads and advances lastIndex exactly as exec does, a RegExp subclass or patched exec is observed, and ToString coercion of a non-string argument still runs.
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):
Hoisted control, from a standalone program (one timed pass of 1M calls; source below). The first row is the benchmark's shape, a literal evaluated inside the loop; the second creates the regex once:
probe
Node
old engine
Perex
test literal-in-loop 1M
67.6 ms
99.1 ms
1,603.7 ms
test hoisted regex 1M
12.8 ms
33.6 ms
1,509.6 ms
The hoisted row is the decisive one. If the regression were construction — for example the removal of the old engine's compile caches in #10142 — hoisting would recover most of it. Instead the hoisted loop is as slow as the literal loop. A literal-site path still exists at site_test.rs (lookup / install keyed by site), which is consistent with construction not being the cost.
Where the per-call time goes has not been located. Candidates to measure, not conclusions: per-call setup of the work and memory budgets and a runtime handle scope, binding the subject and positioning the span reader, and result or capture materialization that a boolean test does not need. As with the DataView setter work in #10089, the first step should be an attribution measurement recorded on this issue before any change.
Standalone 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-test-literal", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex.rs", "function": "js_regexp_test"}], "hypothesis": "Hypothesis: literal-site compilation caching and matcher dispatch determine per-record overhead.", "notes": "Regex baseline, excluded from the main ranking. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.", "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[]{constvalues: string[]=[];for(leti=0;i<n;i++)values.push((rnd()<0.5 ? 'record_' : '!bad_')+String(i));returnvalues;}functionrun(input: string[]): number{leth=0;for(leti=0;i<input.length;i++)h=(h*31+(/^[a-z]+_[0-9]+$/.test(input[i]) ? 1 : 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-test-literal",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-test-literal-unicode", "category": "regex", "verification": "checksum", "sources": [{"file": "crates/perry-runtime/src/regex.rs", "function": "js_regexp_test"}], "hypothesis": "Hypothesis: literal-site compilation caching and matcher dispatch determine per-record overhead.", "notes": "Unicode regex baseline, excluded from the main ranking. Subject and pattern contain umlauts, CJK and/or emoji, using the Unicode flag. n counts records; non-ASCII /g indices are UTF-16 offsets, not UTF-8 byte offsets. Checksums consume test outcomes, actual match contents/indices, or replacement/split outputs. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.", "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[]{constvalues: string[]=[];for(leti=0;i<n;i++)values.push((rnd()<0.5 ? 'ä中😀_' : '!ä中😀_')+String(i));returnvalues;}functionrun(input: string[]): number{leth=0;for(leti=0;i<input.length;i++)h=(h*31+(/^[ä中😀]+_[0-9]+$/u.test(input[i]) ? 1 : 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-test-literal-unicode",category: "regex", n,ms_per_run: samples[3], runs, checksum}));}benchmarkMain();
What happened
A million
.test()calls against short strings take about 1.5 seconds on currentmain, a flat per-call cost at every input size. On the pre-Perex runtime the same loop took about a tenth of a second. The regression is the same whether the regex literal sits inside the loop or is hoisted into a singleRegExpcreated once, so it is not explained by losing literal-site or compilation caching: the cost is in each match call. Checksums match Node at every size.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-test-literal— SEVERELog(time)/log(n) least-squares slopes: Perry 1.002, Node 0.997, delta 0.005.
Workload: Regex baseline, excluded from the main ranking. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.
regex-test-literal-unicode— SEVERELog(time)/log(n) least-squares slopes: Perry 1.003, Node 0.985, delta 0.018.
Workload: Unicode regex baseline, excluded from the main ranking. Subject and pattern contain umlauts, CJK and/or emoji, using the Unicode flag. n counts records; non-ASCII /g indices are UTF-16 offsets, not UTF-8 byte offsets. Checksums consume test outcomes, actual match contents/indices, or replacement/split outputs. Literal is intentionally inside the loop; mixed valid/invalid seeded records prevent a constant all-true checksum.
What is expected / acceptance criteria
.test()on a short ASCII string. (Recorded 2026-09-14 on92eadb77ab: scratch setup ~2,710, matching ~2,295, binding/dispatch ~1,820, GC ~440, exception frame ~370, property lookup ~155, result construction 0, of 8,315 instructions per call.).test()measurement so a construction fix cannot be mistaken for a per-call fix.teston agoryregex reads and advanceslastIndexexactly asexecdoes, aRegExpsubclass or patchedexecis observed, and ToString coercion of a non-string argument still runs.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-test-literalregex-test-literalregex-test-literalregex-test-literalregex-test-literalregex-test-literal-unicoderegex-test-literal-unicoderegex-test-literal-unicoderegex-test-literal-unicoderegex-test-literal-unicoderegex-test-literal: Perry slope 1.01 → 1.00 (SLOW → SEVERE), Node 1.00regex-test-literal-unicode: Perry slope 1.01 → 1.00 (SLOW → SEVERE), Node 0.99Hoisted control, from a standalone program (one timed pass of 1M calls; source below). The first row is the benchmark's shape, a literal evaluated inside the loop; the second creates the regex once:
test literal-in-loop 1Mtest hoisted regex 1MThe hoisted row is the decisive one. If the regression were construction — for example the removal of the old engine's compile caches in #10142 — hoisting would recover most of it. Instead the hoisted loop is as slow as the literal loop. A literal-site path still exists at
site_test.rs(lookup/installkeyed by site), which is consistent with construction not being the cost.Where the per-call time goes has not been located. Candidates to measure, not conclusions: per-call setup of the work and memory budgets and a runtime handle scope, binding the subject and positioning the span reader, and result or capture materialization that a boolean
testdoes not need. As with the DataView setter work in #10089, the first step should be an attribution measurement recorded on this issue before any change.Standalone 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_regexp_testlookupSource 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): split, replace, matchAll and global exec do quadratic work under Perex — split regressed from ~7x Node to timing out #10165
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-test-literal.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-test-literal-unicode.ts — sizes [100, 1000, 10000, 100000, 1000000]
Size meanings and fresh-input policy are in the leading metadata.
result_on_stderrfor this file:False.