Skip to content

bug(regex): split and replace throw "Regular expression work limit exceeded" on 32,000-unit strings Node handles in under a millisecond #10164

Description

@proggeramlug

What happened

Since Perex became the only regex engine, ordinary linear-work string operations throw a RangeError once the input is a few tens of thousands of UTF-16 units long. "ä中12,Ö漢345;ef6😀".repeat(2000).split(/[,;😀]+/u) — a 32,000-unit string split on a character class — throws RangeError: Regular expression work limit exceeded; a global replace with a callback throws at 60,000 units. Node and the pre-Perex runtime return identical, correct results for every probe. A valid program that previously ran now crashes, so this is a correctness and compatibility regression, not only a performance one. Where the Perex build does complete, its output matches Node.

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-unicode — UNSUPPORTED

n Node ms / status Perry ms / status ratio Node checksum Perry checksum
100 0.010416 5.785521 555.43× 988275708 988275708
1000 0.110043 420.655683 3822.64× 881354188 881354188
10000 1.063676 UNSUPPORTED — 579710442 —
100000 12.635114 UNSUPPORTED — 818568466 —
1000000 274.247402 TIMEOUT — 471181671 —

Log(time)/log(n) least-squares slopes: Perry 1.862, Node 1.090, delta 0.771.

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.

Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000].

perry n=10000: UNSUPPORTED, exit 1

RangeError: Regular expression work limit exceeded

perry n=100000: UNSUPPORTED, exit 1

RangeError: Regular expression work limit exceeded

perry n=1000000: TIMEOUT, exit -9

Process exceeded 60 s

regex-replace-callback-unicode — UNSUPPORTED

n Node ms / status Perry ms / status ratio Node checksum Perry checksum
100 0.011184 1.686220 150.77× 703758584 703758584
1000 0.102391 92.158305 900.06× 650793024 650793024
10000 1.079091 UNSUPPORTED — 951250866 —
100000 19.644986 UNSUPPORTED — 925216216 —
1000000 333.497677 UNSUPPORTED — 932684661 —

Log(time)/log(n) least-squares slopes: Perry 1.738, Node 1.123, delta 0.614.

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.

Slopes cover different completed sizes: Node [100, 1000, 10000, 100000, 1000000], Perry [100, 1000].

perry n=10000: UNSUPPORTED, exit 1

RangeError: Regular expression work limit exceeded

perry n=100000: UNSUPPORTED, exit 1

RangeError: Regular expression work limit exceeded

perry n=1000000: UNSUPPORTED, exit 1

RangeError: Regular expression work limit exceeded

What is expected / acceptance criteria

  • Reproduce the reduction on current main first and confirm split throws at 32,000 units and replace at 60,000 units while Node returns results, before changing anything.
  • The reduction prints results identical to Node for every probe, and continues to do so when its sizes are extended to n=100,000 (1.6 million units): no RangeError, no other thrown error.
  • Rerun both full reproducers at 100, 1k, 10k, 100k and 1M on the fix and on the pre-Perex revision on one host: every size that Node completes must complete, with checksums matching Node at every size.
  • Keep the protection the limit exists for: add regression coverage showing a genuinely catastrophic pattern (for example /^(a+)+$/ against "a".repeat(40) + "b") still terminates promptly with a clear error rather than hanging. Do not fix this by raising WORK until the reduction passes; a larger fixed constant only moves the input size at which valid programs crash.
  • Show with a bounded counter or instrumentation that the work charged for a character-class split grows linearly with subject length (doubling the input roughly doubles the charged work), so the budget measures real matching work rather than repeated seeking.
  • Preserve specification behaviour with regression coverage: split with a limit argument, empty matches and AdvanceStringIndex across surrogate pairs under the u flag, captures included in split output, lastIndex handling for g and y, replacement callbacks receiving correct offset and groups, and the existing mapping of abrupt completions, cancellation and memory-limit errors in perex_api.rs.

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):

workload n Node old engine Perex old ÷ Node Perex ÷ Node Perex ÷ old
regex-split-unicode 100 0.010 0.088 5.786 8.4× 555.4× 65.9×
regex-split-unicode 1,000 0.110 0.886 420.656 8.1× 3,822.6× 474.8×
regex-split-unicode 10,000 1.064 8.964 UNSUPPORTED 8.4× — —
regex-split-unicode 100,000 12.635 89.541 UNSUPPORTED 7.0× — —
regex-split-unicode 1,000,000 274.247 1,288.977 TIMEOUT 4.7× — —
regex-replace-callback-unicode 100 0.011 0.132 1.686 11.8× 150.8× 12.7×
regex-replace-callback-unicode 1,000 0.102 7.745 92.158 73.7× 900.1× 11.9×
regex-replace-callback-unicode 10,000 1.079 703.552 UNSUPPORTED 642.1× — —
regex-replace-callback-unicode 100,000 19.645 TIMEOUT UNSUPPORTED — — —
regex-replace-callback-unicode 1,000,000 333.498 SKIPPED UNSUPPORTED — — —
  • regex-split-unicode: Perry slope 1.03 → 1.86 (SLOW → UNSUPPORTED), Node 1.09
  • regex-replace-callback-unicode: Perry slope 1.86 → 1.74 (TIMEOUT → UNSUPPORTED), Node 1.12

Pre-Perex engine output for the reduction below (identical to Node, byte for byte):

{"label":"split n=1000","chars":16000,"result":3001}
{"label":"replace n=1000","chars":15000,"result":19000}
{"label":"split n=2000","chars":32000,"result":6001}
{"label":"replace n=2000","chars":30000,"result":38000}
{"label":"split n=4000","chars":64000,"result":12001}
{"label":"replace n=4000","chars":60000,"result":76000}

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.

Whatever the root cause turns out to be, the observable defect is that the limit fires on inputs whose required work is linear in their length. A work limit exists for a good reason — it stops catastrophic backtracking from hanging a process — and the fix must keep that protection for genuinely pathological patterns while never tripping on ordinary linear scans.

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.

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:

mkdir -p /tmp/perry-builtin-repro
cargo build --release --locked -p perry -p perry-runtime-static -p perry-stdlib-static
export PERRY_RUNTIME_DIR="$PWD/target/release"
export TZ=UTC LC_ALL=en_US.UTF-8
git rev-parse HEAD
node --version
target/release/perry compile /tmp/perry-builtin-repro/regex-split-unicode.ts --no-auto-optimize -o /tmp/perry-builtin-repro/app

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:

import json, math, subprocess
name = 'regex-split-unicode'
sizes = [100, 1000, 10000, 100000, 1000000]
result_on_stderr = False
stopped = set()
points = {"node": [], "perry": []}
for n in sizes:
    pair = {}
    for engine, cmd in [("node", ["node", f"/tmp/perry-builtin-repro/{name}.ts"]),
                        ("perry", ["/tmp/perry-builtin-repro/app"])]:
        if engine in stopped: continue
        try:
            p = subprocess.run(cmd + [str(n)], capture_output=True, text=True, timeout=60)
        except subprocess.TimeoutExpired:
            print(engine, n, "TIMEOUT"); stopped.add(engine); continue
        if p.returncode:
            print(engine, n, "ERROR", p.returncode, p.stderr, p.stdout); continue
        r = json.loads(p.stderr if result_on_stderr else p.stdout)
        pair[engine] = r
        points[engine].append((n, r["ms_per_run"]))
        print(engine, r)
    if len(pair) == 2:
        print("ratio", n, pair["perry"]["ms_per_run"] / pair["node"]["ms_per_run"],
              "checksum_match", pair["perry"]["checksum"] == pair["node"]["checksum"])
def slope(rows):
    if len(rows) < 2: return None
    x = [math.log(n) for n, t in rows]; y = [math.log(t) for n, t in rows]
    mx = sum(x)/len(x); my = sum(y)/len(y)
    return sum((a-mx)*(b-my) for a,b in zip(x,y))/sum((a-mx)**2 for a in x)
print("slopes", {engine: slope(rows) for engine, rows in points.items()})

Record before/after results from the same unchanged source, engine versions and host. The measured driver uses seeded setup outside timers, at least 200 ms AND five warmup runs, then seven samples with at least 20 ms measured work each. Fresh input is prepared before each timer for mutating workloads. The median per-run time is reported, with checksum consistency checked on every invocation. Timeouts cover setup, warmup and sampling, not just one builtin call.

Environment and limits

  • CPU: AMD Ryzen 7 7700X 8-Core Processor; 16 logical cores; x86_64.
  • OS: Linux-6.17.0-23-generic-x86_64-with-glibc2.39; target: native host.
  • 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

diagnostic-regex-work-limit.ts

Each probe prints the input length (UTF-16 units) and either the result or the thrown error. The pre-Perex engine prints output byte-identical to Node for every probe (reproduced in the issue text).

// Linear-work inputs: one character-class split and one global replace with a callback.
function probe(label: string, f: () => number, chars: number): void {
  try { console.log(JSON.stringify({ label, chars, result: f() })); }
  catch (e) { console.log(JSON.stringify({ label, chars, threw: String(e) })); }
}
for (const n of [1000, 2000, 4000]) {
  const s = "ä中12,Ö漢345;ef6😀".repeat(n);
  probe(`split n=${n}`, () => s.split(/[,;😀]+/u).length, s.length);
  const t = "ä中😀12 Ö漢🦊345;".repeat(n);
  probe(`replace n=${n}`, () => t.replace(/[ä中😀Ö漢🦊]+/gu, (m) => "[" + m + "]").length, t.length);
}

Compile this reduction with the same command, substituting its filename. Run each engine once; no size argument is needed.

node (SUCCESS):

{"label":"split n=1000","chars":16000,"result":3001}
{"label":"replace n=1000","chars":15000,"result":19000}
{"label":"split n=2000","chars":32000,"result":6001}
{"label":"replace n=2000","chars":30000,"result":38000}
{"label":"split n=4000","chars":64000,"result":12001}
{"label":"replace n=4000","chars":60000,"result":76000}

perry (SUCCESS):

{"label":"split n=1000","chars":16000,"result":3001}
{"label":"replace n=1000","chars":15000,"result":19000}
{"label":"split n=2000","chars":32000,"threw":"RangeError: Regular expression work limit exceeded"}
{"label":"replace n=2000","chars":30000,"result":38000}
{"label":"split n=4000","chars":64000,"threw":"RangeError: Regular expression work limit exceeded"}
{"label":"replace n=4000","chars":60000,"threw":"RangeError: Regular expression work limit exceeded"}

Complete standalone benchmark sources

regex-split-unicode.ts — sizes [100, 1000, 10000, 100000, 1000000]

Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.

// @runtime {"name": "regex-split-unicode", "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": "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. ", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.

let seed = 0x12345678;
function rnd(): number {
  seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
  return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
  const a: number[] = [];
  for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
  return a;
}
function hashArray(a: number[]): number {
  let h = a.length;
  for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
  return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
  let h = s.length;
  const step = Math.max(1, Math.floor(s.length / 32));
  for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
  return h;
}

function setup(n: number): string { return 'ä中12,Ö漢345;ef6😀'.repeat(n); }
function run(input: string): number {
  const parts = input.split(/[,;😀]+/u);
  let h = parts.length;
  for (let i = 0; i < parts.length; i++) h = (h * 31 + hashString(parts[i])) % 1000000007;
  return h;
}

// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
  seed = 0x12345678;
  const preparedInput = setup(n);
  let checksum = 0;
  let seen = false;
  let warmMs = 0;
  let warmRuns = 0;
  while (warmMs < 200 || warmRuns < 5) {
    seed = 0x12345678;
    const input = preparedInput;
    const start = performance.now();
    const value = run(input);
    const elapsed = performance.now() - start;
    if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
    warmMs += elapsed;
    warmRuns++;
    if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
    checksum = value;
    seen = true;
  }
  const samples: number[] = [];
  let runs = 0;
  for (let sample = 0; sample < 7; sample++) {
    let elapsed = 0;
    let count = 0;
    // Mutable workloads prepare fresh input BEFORE each timer; immutable
    // workloads reuse setup. Neither preparation nor validation is measured.
    while (elapsed < 20) {
      seed = 0x12345678;
      const input = preparedInput;
      const start = performance.now();
      const value = run(input);
      const duration = performance.now() - start;
      if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
      elapsed += duration;
      count++;
      if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
    }
    samples.push(elapsed / count);
    runs += count;
  }
  // Do not depend on Array.sort to compute the median of a sort benchmark.
  for (let i = 1; i < samples.length; i++) {
    const v = samples[i];
    let j = i - 1;
    while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
    samples[j + 1] = v;
  }
  console.log(JSON.stringify({name: "regex-split-unicode", category: "regex", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
regex-replace-callback-unicode.ts — sizes [100, 1000, 10000, 100000, 1000000]

Size meanings and fresh-input policy are in the leading metadata. result_on_stderr for this file: False.

// @runtime {"name": "regex-replace-callback-unicode", "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": "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. ", "asynchronous": false, "output_stderr": false, "fresh_input": false}
// Standalone file. Shared helpers/driver are inlined by common.py.

let seed = 0x12345678;
function rnd(): number {
  seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5;
  return (seed >>> 0) / 4294967296;
}
function numbers(n: number): number[] {
  const a: number[] = [];
  for (let i = 0; i < n; i++) a.push(Math.floor(rnd() * 1000000));
  return a;
}
function hashArray(a: number[]): number {
  let h = a.length;
  for (let i = 0; i < a.length; i++) h = (h * 31 + a[i]) % 1000000007;
  return h;
}
// Bounded checksum work avoids making string slicing/indexing part of every
// string benchmark's asymptotic cost. The workload itself consumes its result.
function hashString(s: string): number {
  let h = s.length;
  const step = Math.max(1, Math.floor(s.length / 32));
  for (let i = 0; i < s.length; i += step) h = (h * 31 + s.charCodeAt(i)) % 1000000007;
  return h;
}

function setup(n: number): string { return 'ä中😀12 Ö漢🦊345;'.repeat(n); }
function run(input: string): number {
  return hashString(input.replace(/[ä中😀Ö漢🦊]+/gu, (match) => '[' + match + ']'));
}

// Size is the final argument: both native Perry and Node expose it reliably.
const n = Number(process.argv[process.argv.length - 1]);
if (!(n > 0)) throw new Error("Expected a positive size argument");
function benchmarkMain(): void {
  seed = 0x12345678;
  const preparedInput = setup(n);
  let checksum = 0;
  let seen = false;
  let warmMs = 0;
  let warmRuns = 0;
  while (warmMs < 200 || warmRuns < 5) {
    seed = 0x12345678;
    const input = preparedInput;
    const start = performance.now();
    const value = run(input);
    const elapsed = performance.now() - start;
    if (!(elapsed >= 0)) throw new Error("Invalid monotonic timer");
    warmMs += elapsed;
    warmRuns++;
    if (seen && value !== checksum) throw new Error("CORRECTNESS: unstable checksum during warmup");
    checksum = value;
    seen = true;
  }
  const samples: number[] = [];
  let runs = 0;
  for (let sample = 0; sample < 7; sample++) {
    let elapsed = 0;
    let count = 0;
    // Mutable workloads prepare fresh input BEFORE each timer; immutable
    // workloads reuse setup. Neither preparation nor validation is measured.
    while (elapsed < 20) {
      seed = 0x12345678;
      const input = preparedInput;
      const start = performance.now();
      const value = run(input);
      const duration = performance.now() - start;
      if (!(duration >= 0)) throw new Error("Invalid monotonic timer");
      elapsed += duration;
      count++;
      if (value !== checksum) throw new Error("CORRECTNESS: unstable checksum during sampling");
    }
    samples.push(elapsed / count);
    runs += count;
  }
  // Do not depend on Array.sort to compute the median of a sort benchmark.
  for (let i = 1; i < samples.length; i++) {
    const v = samples[i];
    let j = i - 1;
    while (j >= 0 && samples[j] > v) { samples[j + 1] = samples[j]; j--; }
    samples[j + 1] = v;
  }
  console.log(JSON.stringify({name: "regex-replace-callback-unicode", category: "regex", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();

Activity

  1. added
    bugConfirmed defect or regression
    parityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem
    on Sep 13, 2026
  2. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Cost placement: the throw is Perex's executor seek charge, not the span reader

    This corrects the mechanism in the issue body, which pointed at the unit-by-unit seek in span/bound.rs. The budget is actually exhausted by a different charge.

    • For byte (WTF-8, non-ASCII) storage, Input::seek_work (src/input.rs:173) charges min(q, n − q) + 1 to start a search at UTF-16 position q: it seeks from whichever end is nearer. ASCII and UTF-16 storage charge 1, which is why the ASCII variants of these workloads are slow but never throw.
    • src/executor.rs:436 charges that seek work to the operation's budget.
    • Perry starts a fresh search at every position of a split: perex_split.rs runs while q < size { set_last_index(q); dispatch::execute(…) } → dispatch::execute → api::execute_with_resources → host::find, all under the single Budget::new(WORK) with WORK = 100_000_000.

    Summing the seek charge over n positions gives about n²/4, which reproduces the measured thresholds exactly:

    operation subject length searches ≈ charged seek work limit 1e8 measured
    split 16,000 ~16,000 16,000²/4 = 6.4e7 under completes
    split 32,000 ~32,000 32,000²/4 = 2.56e8 over throws
    replace 30,000 4,000 matches 4,000 × 30,000/4 = 3.0e7 under completes
    replace 60,000 8,000 matches 8,000 × 60,000/4 = 1.2e8 over throws

    Proposed direction (Perex side): an API that starts a search from a mark (byte offset plus UTF-16 offset) left by the previous search on the same bound subject. Seek cost becomes |q − mark|, a global loop's total seek work becomes linear, and the budget goes back to only stopping real backtracking.

    Status: placement from reading source, by the Perex maintainer and checked line by line against perry main b5a82cfeae and the published perex 0.1.0 crate. It is not yet measured. Ownership and scheduling are awaiting a maintainer decision, so please do not start source changes yet.

    Related: #10165 (the uncharged per-call rebinding, the ASCII half of this) and #10166.

  3. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Status: the Perry-side changes for this issue have landed on main.

    Evidence recorded on those PRs:

    • The reduction completes with output identical to Node.
    • The non-ASCII split and replace reproducers no longer throw and scale linearly (log-log slopes 1.02 and 1.05).
    • The work-policy witness charges more than 1.2e8 units on a linear search and fails if the old 100M cap is restored.

    Not yet done against the acceptance list: the same-host rerun of both full reproducers at 100 to 1M against the pre-Perex revision.

    Still quadratic after this: JS-level exec/matchAll loops on non-ASCII subjects. Each call seeks once from the nearer end, because the lastIndex position hint across calls was excluded (it would need a heap generation counter). They measure 42–44 s at n = 40,000 against 7–8 ms on Node; see #10183's measurements. A separate Perex fix for required-text patterns scanning from byte 0 (for example /[a-z]+[0-9]+ /g) is on perex main for 0.1.4. Whether it is published and taken is still open.

    https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

  4. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Same-host rerun of the full reproducers, as the acceptance list asks.

    Setup: perrymaster (Linux x86_64, 16 logical CPUs, shared with other lanes; load average 11–23 during the runs), Node v26.8.1, hostile-benchmarks/runtime/run.py --filter regex, release builds from source.

    Verdict: not closable yet

    Resolved since the original report: the 32,000-unit split and 60,000-unit replace no longer throw the work-limit error (#10176, #10181), and every completed size matches Node's checksum.

    regex-split-unicode

    n Node pre-Perex 9495bfc95 main 5d3bf85f9 checksums (main)
    100 0.012 ms 0.09 ms (7.6×) 0.51 ms (44.0×) = Node
    1,000 0.130 ms 0.89 ms (6.9×) 2.80 ms (21.5×) = Node
    10,000 1.670 ms 8.91 ms (5.3×) 33.98 ms (20.3×) = Node
    100,000 14.292 ms 90.83 ms (6.4×) 279.64 ms (19.6×) = Node
    1,000,000 325.317 ms 934.07 ms (2.9×) TIMEOUT —

    Log-log slope over 1k–100k: Node 1.020, main 1.000 (delta -0.020), pre-Perex 1.003.

    regex-replace-callback-unicode

    n Node pre-Perex 9495bfc95 main 5d3bf85f9 checksums (main)
    100 0.012 ms 0.22 ms (18.3×) 0.99 ms (81.9×) = Node
    1,000 0.113 ms 8.13 ms (71.9×) 10.04 ms (88.9×) = Node
    10,000 2.120 ms 748.77 ms (353.1×) 185.94 ms (87.7×) = Node
    100,000 27.521 ms TIMEOUT 1,725.68 ms (62.7×) = Node
    1,000,000 481.988 ms SKIPPED memory-limit RangeError —

    Log-log slope over 1k–100k: Node 1.193, main 1.118 (delta -0.076), pre-Perex n/a (a size did not complete).

    regex-replace-callback

    n Node pre-Perex 9495bfc95 main 5d3bf85f9 checksums (main)
    100 0.016 ms 0.08 ms (5.0×) 1.63 ms (100.3×) = Node
    1,000 0.174 ms 4.33 ms (24.9×) 14.93 ms (85.8×) = Node
    10,000 1.115 ms 376.66 ms (337.9×) 164.58 ms (147.7×) = Node
    100,000 18.501 ms TIMEOUT 1,736.29 ms (93.8×) = Node
    1,000,000 394.274 ms SKIPPED memory-limit RangeError —

    Log-log slope over 1k–100k: Node 1.013, main 1.033 (delta +0.019), pre-Perex n/a (a size did not complete).

    https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

  5. proggeramlug commented on Sep 13, 2026

    @proggeramlug
    ContributorAuthor

    Non-ASCII rows rerun after #10205 (JS-level searches resume from the previous call's position), landed via train 182 as 0956673b5. Same host, harness and Node v26.8.1 as the earlier rerun. The pre-Perex (9495bfc95) and pre-#10205 (5d3bf85f9) columns are copied from that run.

    Neither issue meets its acceptance list yet, for the reasons above and in the earlier comment.

    regex-exec-global-unicode

    n Node pre-Perex 9495bfc95 5d3bf85f9 (before #10205) 0956673b5 (with #10205) checksum
    100 0.045 ms 0.61 ms 0.99 ms 0.64 ms (14.0×) = Node
    1,000 0.324 ms 33.82 ms 33.80 ms 5.60 ms (17.3×) = Node
    10,000 3.878 ms 2,981.25 ms 3,344.79 ms 61.13 ms (15.8×) = Node
    100,000 32.132 ms TIMEOUT TIMEOUT 614.73 ms (19.1×) = Node
    1,000,000 318.011 ms SKIPPED SKIPPED TIMEOUT —

    regex-match-all-unicode

    n Node pre-Perex 9495bfc95 5d3bf85f9 (before #10205) 0956673b5 (with #10205) checksum
    100 0.032 ms 0.72 ms 1.80 ms 0.86 ms (27.3×) = Node
    1,000 0.316 ms 18.46 ms 46.24 ms 8.46 ms (26.8×) = Node
    10,000 3.213 ms 741.78 ms 2,639.47 ms 84.51 ms (26.3×) = Node
    100,000 31.088 ms TIMEOUT TIMEOUT 946.93 ms (30.5×) = Node
    1,000,000 328.592 ms SKIPPED SKIPPED TIMEOUT —

    regex-replace-callback-unicode

    n Node pre-Perex 9495bfc95 5d3bf85f9 (before #10205) 0956673b5 (with #10205) checksum
    100 0.012 ms 0.22 ms 0.99 ms 0.98 ms (84.2×) = Node
    1,000 0.144 ms 8.13 ms 10.04 ms 10.23 ms (70.9×) = Node
    10,000 1.196 ms 748.77 ms 185.94 ms 115.96 ms (96.9×) = Node
    100,000 28.077 ms TIMEOUT 1,725.68 ms 1,754.77 ms (62.5×) = Node
    1,000,000 437.341 ms SKIPPED memory-limit RangeError memory-limit RangeError —

    regex-split-unicode

    n Node pre-Perex 9495bfc95 5d3bf85f9 (before #10205) 0956673b5 (with #10205) checksum
    100 0.010 ms 0.09 ms 0.51 ms 0.25 ms (24.6×) = Node
    1,000 0.108 ms 0.89 ms 2.80 ms 2.40 ms (22.3×) = Node
    10,000 1.189 ms 8.91 ms 33.98 ms 24.03 ms (20.2×) = Node
    100,000 12.283 ms 90.83 ms 279.64 ms 254.28 ms (20.7×) = Node
    1,000,000 273.272 ms 934.07 ms TIMEOUT TIMEOUT —

    regex-test-literal-unicode

    n Node pre-Perex 9495bfc95 5d3bf85f9 (before #10205) 0956673b5 (with #10205) checksum
    100 0.002 ms 0.01 ms 0.24 ms 0.15 ms (71.0×) = Node
    1,000 0.021 ms 0.11 ms 2.77 ms 1.55 ms (72.2×) = Node
    10,000 0.211 ms 1.15 ms 15.57 ms 15.46 ms (73.2×) = Node
    100,000 2.053 ms 11.63 ms 157.62 ms 159.85 ms (77.9×) = Node
    1,000,000 20.091 ms 114.78 ms 2,013.81 ms 1,599.12 ms (79.6×) = Node

    https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

  6. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Rerun at every size now that #10215 is fixed

    #10215 (arrays of ~9M+ elements holding heap strings corrupting) was the reason the 1M rows could not be trusted; it landed in train 186, so here is the full sweep. Same host as the earlier reruns (perrymaster), Node v26.8.1, release builds from source, --no-auto-optimize, each size given the harness's 60 s process budget covering at least 5 warm-ups and 7 samples. Perry is main at 33690c563 (v0.5.1580, after train 201).

    Four of the eight now complete at 1M; four still exceed the budget. Every completed row matches Node's checksum at every size.

    regex-split

    n Node main x Node checksum
    100 0.010 ms 0.219 ms 23.0x = Node
    1,000 0.095 ms 2.128 ms 22.4x = Node
    10,000 0.991 ms 21.215 ms 21.4x = Node
    100,000 10.608 ms 218.548 ms 20.6x = Node
    1,000,000 240.403 ms 3883.172 ms 16.2x = Node

    regex-split-unicode

    n Node main x Node checksum
    100 0.010 ms 0.224 ms 21.6x = Node
    1,000 0.111 ms 2.267 ms 20.5x = Node
    10,000 1.056 ms 21.797 ms 20.6x = Node
    100,000 12.643 ms 214.484 ms 17.0x = Node
    1,000,000 266.168 ms 4005.514 ms 15.0x = Node

    regex-replace-callback

    n Node main x Node checksum
    100 0.010 ms 0.330 ms 33.0x = Node
    1,000 0.096 ms 3.207 ms 33.4x = Node
    10,000 0.968 ms 41.518 ms 42.9x = Node
    100,000 12.010 ms 413.099 ms 34.4x = Node
    1,000,000 275.524 ms TIMEOUT >60 s — —

    regex-replace-callback-unicode

    n Node main x Node checksum
    100 0.011 ms 0.365 ms 33.1x = Node
    1,000 0.106 ms 4.064 ms 38.2x = Node
    10,000 1.085 ms 40.789 ms 37.6x = Node
    100,000 19.891 ms 429.205 ms 21.6x = Node
    1,000,000 335.314 ms TIMEOUT >60 s — —

    regex-match-all

    n Node main x Node checksum
    100 0.013 ms 0.407 ms 32.2x = Node
    1,000 0.125 ms 3.958 ms 31.6x = Node
    10,000 1.242 ms 40.684 ms 32.8x = Node
    100,000 12.321 ms 434.500 ms 35.3x = Node
    1,000,000 123.279 ms 4291.779 ms 34.8x = Node

    regex-match-all-unicode

    n Node main x Node checksum
    100 0.031 ms 0.556 ms 17.9x = Node
    1,000 0.306 ms 5.429 ms 17.7x = Node
    10,000 3.058 ms 55.778 ms 18.2x = Node
    100,000 30.436 ms 584.858 ms 19.2x = Node
    1,000,000 304.821 ms TIMEOUT >60 s — —

    regex-exec-global

    n Node main x Node checksum
    100 0.013 ms 0.278 ms 21.3x = Node
    1,000 0.125 ms 2.856 ms 22.8x = Node
    10,000 1.232 ms 28.562 ms 23.2x = Node
    100,000 11.527 ms 305.772 ms 26.5x = Node
    1,000,000 118.293 ms 3055.277 ms 25.8x = Node

    regex-exec-global-unicode

    n Node main x Node checksum
    100 0.031 ms 0.424 ms 13.5x = Node
    1,000 0.309 ms 4.351 ms 14.1x = Node
    10,000 3.087 ms 43.320 ms 14.0x = Node
    100,000 30.712 ms 467.876 ms 15.2x = Node
    1,000,000 307.591 ms TIMEOUT >60 s — —

    Where this leaves the two issues

    Completing at 1M for the first time: regex-split (16.2× Node), regex-split-unicode (15.0×), regex-match-all (34.8×) and regex-exec-global (25.8×). On the 2026-09-13 rerun exec and matchAll completed only to 100k.

    Still over the 60 s budget at 1M: both regex-replace-callback rows, regex-match-all-unicode and regex-exec-global-unicode. These are what block acceptance on #10164 and #10165. Note the shape: the two that time out on the non-ASCII side complete comfortably at 100k (19.2× and 15.2×), so the wall is between 100k and 1M rather than a per-call constant, and replace with a callback times out on both encodings while split completes on both.

    Ratios are otherwise flat in n, which is what the earlier reruns established and this confirms: 20–23× for split, 32–35× for matchAll, 21–27× for exec, 33–43× for callback replace. No row shows a slope that grows with size up to 100k.

    Two things worth recording for whoever takes the remaining four:

  7. proggeramlug commented on Sep 16, 2026

    @proggeramlug
    ContributorAuthor

    Two of the four 1M "timeouts" are a harness budget artifact, not a cliff

    Correcting my own framing from earlier today. I reported four rows exceeding the 60 s budget at n=1,000,000 and called the shape "a cliff between 100k and 1M". For two of them that is wrong, and anyone chasing it would be chasing something that does not exist.

    Run outside the harness with a 600 s ceiling, same binaries, same host (main at 33690c563):

    workload ms per run at 1M wall for the full protocol × Node checksum
    regex-match-all-unicode 6,785.2 83 s 22.3× = Node
    regex-exec-global-unicode 5,345.7 64 s 17.4× = Node
    regex-replace-callback 7,986.8 103 s 29.0× = Node
    regex-replace-callback-unicode 8,672.5 114 s 25.9× = Node

    All four complete and all four match Node's checksum. The 60 s budget covers at least five warm-up runs plus seven samples — twelve runs — so a workload taking 5–8 s per run needs 60–100 s of budget to report at all. The timeout therefore restates the Perry/Node ratio; it carries no information about asymptotics.

    For regex-match-all-unicode and regex-exec-global-unicode that is the whole story. Their 1M ratios (22.3× and 17.4×) sit in the same band as their 100k ratios (19.2× and 15.2×), so they are linear and there is nothing between 100k and 1M to find.

    The two replace rows ARE superlinear, and it is GC

    regex-replace-callback, ms per run, same binary:

    n 100k 200k 250k 300k 350k 400k 700k 1M
    ms 413.1 864.3 1,184.6 1,772.5 2,394.2 2,829.7 5,743.9 7,986.8
    µs per 1k records 4.13 4.32 4.74 5.91 6.84 7.07 8.21 7.99

    Per-unit cost roughly doubles between 200k and 700k and then flattens — a transition, not an asymptotic blow-up. PERRY_GC_DIAG names it:

    n=200k n=700k
    cycles 179 698
    triggers — 1,178 OldGenBytes fulls vs 218 ArenaBytes minors
    step_us 1.70 s 29.58 s
    mark barrier armed 0.97 s 24.82 s
    GC share of wall 230‰ 567‰

    At 700k more than half of the run is collection, and the per-cycle cost has grown 4.5× (9.5 ms → 42.4 ms) because each cycle meets a larger live heap. At 200k the median collection frees 0 bytes — over half of them reclaim nothing at all.

    So the remaining work on this issue is the full cadence on an allocating regex loop, not the engine and not the per-call path.

    One caution for whoever takes it, learned the hard way today: the obvious fix is not simply "fire fewer cycles". I tried exactly that (#10377, now withdrawn) and it cut cycles by 79 % at 700k while making the workload 8–16 % slower, because fewer cycles meet a bigger heap — more marking each, and a longer armed-barrier window taxing every write in between. Any candidate needs measuring on this workload at n ≥ 700,000, where the cost lives; a small-live-set reproducer cannot show it.

  8. proggeramlug commented on Sep 17, 2026

    @proggeramlug
    ContributorAuthor

    Where the replace cost actually is: string replacement, not the callback — and the direct fast path is the memory hog

    Re-baselined on e6dcb6274d (v0.5.1587, which carries #10372) and then isolated the paths against each other. The result inverts what I expected, and it points at code I wrote (#10225).

    The two forms of the same replacement

    Identical work — "ab12 cd345;".repeat(n) replaced 12 times — differing only in whether the replacement is a string or a callback. Peak arena_live from PERRY_GC_DIAG, wall for the whole program, Node 26.5.1 for scale:

    n form peak live heap Perry wall Node wall × Node
    50,000 callback (m) => "[" + m + "]" 39 MB 1,887 ms — —
    50,000 string "[$&]" 406 MB 5,950 ms — —
    100,000 callback 27 MB 4,367 ms 304 ms 14.4×
    100,000 string 807 MB 18,609 ms 202 ms 92×
    200,000 callback 53 MB 8,958 ms 618 ms 14.5×
    200,000 string 1,613 MB 50,171 ms 413 ms 121×

    The callback form holds a roughly flat live heap and sits at ~14.5× Node. The string form holds a live heap that doubles exactly with n — 406 → 807 → 1,613 MB, about 8 KB of live heap per match — and is 92–121× Node. A 2.2 MB subject produces 1.6 GB of live heap.

    It is the direct fast path, not the generic one

    perex_replace_direct::admissible declines when the pattern has named groups, so adding one runs the generic path over identical work. Same subject, same template, same match count:

    n direct fast path /[0-9]+/g generic path /(?<d>[0-9]+)/g
    50,000 406 MB, 5,950 ms 60 MB, 9,117 ms
    100,000 807 MB, 18,609 ms 120 MB, 20,863 ms

    The fast path retains 6.7× more live heap per match than the generic path it replaces (~8 KB versus ~1.2 KB). It buys a genuine speed win at 50k (5.9 s vs 9.1 s) — and that win is gone by 100k (18.6 s vs 20.9 s), because the collector's cost on the live set it creates catches up and overtakes it. Extrapolating the live-heap line, the fast path is a net loss above ~100k matches and an increasingly expensive one after that.

    That path is #10225, which I wrote. Collecting every match span before any replacement runs is required for spec order, but the retention that comes with it was never measured at this scale — the acceptance evidence for #10225 was throughput on short subjects.

    What this is not

    Not the engine, and not GC pacing. On current main every collection this workload runs is productive (at n=700,000 on the benchmark fixture: 38 fulls freeing a median 180 MB, 151 minors freeing a median 154 MB, none freeing zero), so the 667‰ GC share is real collection of real garbage. A profile of the string arm is 60 %+ collector — RootScanCycleState::step_current_subphase 18.1 %, gc_malloc_header_is_tracked 16.6 %, remembered-set root marking 8.5 %, trace_heap_rewrite_slots 8.1 % — which is what a linearly growing live set costs, not what a mis-paced collector costs.

    Earlier I attributed this to a pacing bug and proposed a collector fix (#10377, withdrawn — it regressed the same workload by 8–16 %). That was wrong. The pacing issue was real but separate, and #10372 removed it: see #10376.

    What should happen next

    Bound the direct path's retention. The options, in the order I would test them:

    1. Emit incrementally. Spec order requires every match to be found before any replacement is observed, but for a string template no user code runs at all — nothing can observe an incremental build. The spans could be consumed into the output as they are produced rather than all retained.
    2. Cap the fast path by match count or subject size, falling back to the generic path beyond it. Cheap, and the crossover is already measured at ~100k matches.
    3. Find the 8 KB. ~8 KB of live heap per match, for a match whose data is about 10 bytes and whose replacement is 4, is unexplained by the span list itself (two u32 per match). Something per-piece is far larger than it needs to be, and that is worth understanding before choosing between 1 and 2.

    I have not started any of these. Anyone picking it up: the two-arm comparison above is the measurement that matters, and it needs n ≥ 100,000 to show the crossover — below that the fast path still looks like a win.

  9. proggeramlug commented on Sep 18, 2026

    @proggeramlug
    ContributorAuthor

    Does not reproduce on current main — fixed by 0ec7ee1

    Per the instruction in the description ("First reproduce on current main; if it is already fixed, identify the fixing commit and attach the comparison").

    The fixing commit is 0ec7ee1a8 — "fix(regex): do not cap RegExp operations by work (#10164)", which raised perry-runtime/src/regex/perex_api.rs's WORK from 100_000_000 to usize::MAX. That constant was introduced at 100,000,000 by the Perex adoption commit 178a2bb36, which is what made these throw. Every Budget::new in crates/perry-runtime/src/regex/ takes api::WORK, and no env or diagnostic knob lowers it, so there is no longer a finite execution budget on any JS regex path.

    Both workloads the description names now complete with correct results and no RangeError:

    {"label":"split n=1000","chars":16000,"result":3001}
    {"label":"replace n=1000","chars":15000,"result":19000}
    {"label":"split n=2000","chars":32000,"result":6001}      <- the 32,000-unit split that threw
    {"label":"replace n=2000","chars":30000,"result":38000}
    {"label":"split n=4000","chars":64000,"result":12001}
    {"label":"replace n=4000","chars":60000,"result":76000}   <- the 60,000-unit callback replace that threw
    

    Scaling well past the sizes in the table, against Node 26.5.1 on the same host, milliseconds:

    chars Perry split Node split Perry replace+cb Node replace+cb
    ~1,600 1 1 1 2
    ~16,000 3 0 6 6
    ~160,000 38 3 54 6
    ~1,600,000 349 36 645 58

    n=10000 and n=100000, recorded here as UNSUPPORTED and TIMEOUT, both complete. The 1,000,000-record row is not retested; the 1.6M-character row above is already two decades past the failing size.

    The correctness and compatibility regression this issue is about is resolved. What the table above still shows is a constant-factor gap of roughly 10x Node, which is a performance matter and belongs to #10165 rather than here — and #10165's own headline (quadratic work) no longer holds either; see the comparison I have attached there.

    Measured on c8cf45056 plus the adoption in #10580. That patch is a fixed ~300-instruction saving per regex call and cannot affect a scaling exponent, so the conclusion is independent of it; the absolute milliseconds were taken on a host running other sessions' builds and are shape evidence, not benchmark figures.

    Suggest closing.

  10. proggeramlug commented on Sep 20, 2026

    @proggeramlug
    ContributorAuthor

    Fixed by 0ec7ee1a81 and 2ce989c093 — WORK: usize = usize::MAX in regex/perex_api.rs:28, with a comment naming this issue.

    This issue's 32,000-unit reproducer threw RangeError: Regular expression work limit exceeded; it now splits in ~5 ms byte-identically to node, and stays clean at 60,000 and 320,000 units.

    The performance half of the regex work is separate and still open — see #10165 (quadratic gone, but its own ~7× target unmet) and #10166.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugConfirmed defect or regressionparityCompatibility gap with Node.js, ECMAScript, or the supported ecosystem

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions