Skip to content

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

Description

@proggeramlug

What happened

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

workload n Node old engine Perex old ÷ Node Perex ÷ Node Perex ÷ old
regex-test-literal 100 0.002 0.010 0.154 4.8× 74.5× 15.5×
regex-test-literal 1,000 0.021 0.108 1.526 5.1× 72.4× 14.2×
regex-test-literal 10,000 0.207 1.104 15.460 5.3× 74.8× 14.0×
regex-test-literal 100,000 2.027 11.021 156.154 5.5× 77.0× 14.2×
regex-test-literal 1,000,000 20.349 111.067 1,560.366 5.4× 76.7× 14.0×
regex-test-literal-unicode 100 0.002 0.010 0.155 4.8× 72.8× 15.4×
regex-test-literal-unicode 1,000 0.026 0.117 1.573 5.5× 59.8× 13.5×
regex-test-literal-unicode 10,000 0.212 1.104 15.949 5.3× 75.3× 14.5×
regex-test-literal-unicode 100,000 2.067 11.073 160.227 5.4× 77.5× 14.5×
regex-test-literal-unicode 1,000,000 20.259 111.796 1,597.342 5.5× 78.8× 14.3×
  • regex-test-literal: Perry slope 1.01 → 1.00 (SLOW → SEVERE), Node 1.00
  • regex-test-literal-unicode: Perry slope 1.01 → 1.00 (SLOW → SEVERE), Node 0.99

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
function t(label: string, f: () => number): void {
  const s = performance.now();
  try { const r = 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 (const n of [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}`, () => { const s = "ab12 cd345;".repeat(n), re = /([a-z]+)([0-9]+)/g; let c = 0; while (re.exec(s) !== null) c++; return c; });
}
const vals: string[] = []; for (let i = 0; i < 1000000; i++) vals.push((i % 2 ? "record_" : "!bad_") + i);
t("test literal-in-loop 1M", () => { let c = 0; for (let i = 0; i < vals.length; i++) if (/^[a-z]+_[0-9]+$/.test(vals[i])) c++; return c; });
const hoisted = /^[a-z]+_[0-9]+$/;
t("test hoisted regex  1M", () => { let c = 0; for (let i = 0; i < vals.length; i++) if (hoisted.test(vals[i])) c++; return c; });

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-test-literal.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-test-literal'
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

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_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.

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[] {
  const values: string[] = [];
  for (let i = 0; i < n; i++) values.push((rnd() < 0.5 ? 'record_' : '!bad_') + String(i));
  return values;
}
function run(input: string[]): number {
  let h = 0;
  for (let i = 0; i < input.length; i++) h = (h * 31 + (/^[a-z]+_[0-9]+$/.test(input[i]) ? 1 : 0)) % 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-test-literal", category: "regex", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();
regex-test-literal-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-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.

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[] {
  const values: string[] = [];
  for (let i = 0; i < n; i++) values.push((rnd() < 0.5 ? 'ä中😀_' : '!ä中😀_') + String(i));
  return values;
}
function run(input: string[]): number {
  let h = 0;
  for (let i = 0; i < input.length; i++) h = (h * 31 + (/^[ä😀]+_[0-9]+$/u.test(input[i]) ? 1 : 0)) % 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-test-literal-unicode", category: "regex", n,
    ms_per_run: samples[3], runs, checksum}));
}
benchmarkMain();

Activity

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 regressionperformanceRuntime, compile-time, build-size, or memory performance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions