Skip to content

perf(regex): cut split and replace host overhead on ASCII subjects - #11543

Merged
proggeramlug merged 3 commits into
mainfrom
perf/regex-split-replace
Sep 27, 2026
Merged

proggeramlug merged 3 commits into
mainfrom
perf/regex-split-replace

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Part of #10165
Part of #10518

This PR cuts host-side regex costs in String.prototype.split and in string-template replace. None of the changed code is in the matcher. The matcher side of the package regex cost is PerryTS/perex#3; a Perry PR adopting it is linked below.

Changes

  • ASCII split pieces and replace captures are one byte copy. SpanCopies::copy now uses copy_ascii_span, which exec captures already used, whenever the subject is ASCII. Before, each piece took two unit-by-unit cursor passes and two safepoint polls.
  • ASCII string-template replacements build their output in one allocation. Pieces::finish_ascii handles native pieces over an ASCII subject and an ASCII template (or no template) with one allocation plus one copy per piece. Before, every unit was decoded and re-encoded twice. Any other combination still takes the existing two-pass path.
  • The forward split loop no longer polls once per piece. It polls once per 512 units of pieces (PieceStride). That is POLL_UNITS, the trade perf(regex): poll the safepoint on units read, not on pieces #10657 measured for the replace passes.
  • Split searches no longer ask for captures they cannot have. Forward split uses CaptureMode::Full when the splitter has no capture groups. Before, every piece built, filled and copied a capture slot array (plus a poll) only to find it empty.
  • Split output appends skip the catch frame when they cannot throw. List::push_unseen is used only for a split's output list, which no code outside the operation has seen yet, so nothing can have frozen, sealed or proxied it. When the append fits capacity it cannot throw, so it skips the catch_js_throw frame. An append that has to grow still takes push.
  • New gap test. test-files/test_gap_regex_engine_package_shapes.ts covers the package regex shapes: uuid validate, JWS_REGEX, dotenv LINE, validator's split(/%..|./), cron tokens, and string-template replaces with ASCII and non-ASCII on each side, every $ form, empty pieces, astral characters and lone surrogates. It also covers the fold, counted, lazy, sticky and global edge cases the perex fast paths must keep exact. Output is byte-identical to Node 26.5.1 on unpatched main and on this branch. It is a regression guard, not a fails-without-fix test: this PR changes cost, not answers.

Measurements (qb2: AMD EPYC 9254, idle; both arms built here with --release; PERRY_NO_AUTO_OPTIMIZE=1)

Instruction counts come from perf stat -e instructions:u as a two-N differential (median of 3 at each N). The base arm is main at d57f513; the only difference in the other arm is this diff. Main has since moved to 98dd568, which touches none of these files.

Microbenchmarks, instructions per iteration:

probe main this PR Δ
encodeURI(s).split(/%..|./), about 60 pieces 293,660 166,370 −43.3%
"a-b-c".replace(/-/g, "+") 24,889 21,223 −14.7%
that plus "Hello".replace(/l/, "L") (literals in loop) 45,268 38,863 −14.1%
dotenv LINE.exec loop (control) 1,609,584 1,609,579 0.0%
REGEX.test uuid (control) 23,500 23,500 0.0%
bare loop (control) 93 94 —

Package workloads (scripts/package_bench.py run --arms node,perry --modes instr), instructions per iteration. Every Perry run's output was byte-identical to Node 26.5.1:

workload node main this PR Δ
validator/sanitize 32,462 632,228 605,803 −4.2%
validator/batch 957,163 29,569,318 28,593,841 −3.3%
commander/parse_argv 193,121 4,059,355 4,024,680 −0.9%
date-fns/format_add 85,895 1,176,138 1,166,059 −0.9%
dotenv/parse 203,214 2,975,546 2,948,837 −0.9%
dayjs/parse_format 65,779 2,189,269 2,173,745 −0.7%
uuid/v4, v5_parse, v7, jsonwebtoken/decode, nanoid, moment, node-cron, decimal.js, date-fns/diff_interval within ±0.1%

Peak RSS, median of 5 /usr/bin/time runs at the harness's n2: validator/sanitize +0.4%, date-fns/format_add −0.0%, commander +0.6%, validator/batch −0.0%, moment +0.7%, dotenv +0.0%. Microbench peak RSS: split +0.6%, replace +0.0%. None of these is an RSS-for-compute trade.

A change I left out: RSS cost

Growing the lent match scratch past its fixed 32 registers removes the owned-path rebuild on every search for bigger programs. dotenv's LINE has 42 registers. With it, dotenv/parse is −31% instructions and moment/parse_format −16%, but peak RSS rises +27.6% and +13.4%. The owned path notes its per-call buffers with gc_note_external_side_alloc, and that phantom external pressure is what keeps these loops in frequent budgeted full cycles (82 per run against about 12 copying minors without it). Taking the phantom pressure away moves them to the nursery-cap regime. That is a GC pacing decision, not a regex one, so it is not in this PR. The measurement is on branch wip/perf-regex-matcher (commit "grow the lent scratch's registers").

Tests (qb2)

  • cargo test --release -p perry-runtime --lib -- perex split replace regexp (RUST_TEST_THREADS=1): 222 passed.
  • Gap/parity: run_parity_tests.sh with PERRY_SKIP_BUILD=1 PERRY_NO_AUTO_OPTIMIZE=1, filters regex, regexp, split, replace, match, against Node 26.5.1. Both main and this branch: 65 PASS, plus the same pre-existing COMPILE_FAIL (test_gap_regex_replace_dyn_regex_with_http).
  • SKIP_COMPILE_GATES=1 scripts/run_lint_gates.sh: 92 of 94 script gates passed; the compile tier was not run. The two failures are cargo xwin not installed on this host, and "Public benchmark evidence freshness", which is known-red on main. git diff --stat was clean afterwards.
  • cargo fmt --all -- --check: clean.

Not run: the full gap sweep, cargo test --workspace, macOS, the auto-optimize (default perry compile) arm, and the Windows xwin check.

No test outside the touched regex modules is expected to change.

Related

Summary by CodeRabbit

  • Performance
    • Regex-based String.prototype.split and string-template String.prototype.replace now handle ASCII strings with less host work, including more efficient copying and output construction.
    • Split operations without capture groups avoid unnecessary capture handling, and split-loop polling occurs less frequently.
    • Benchmarks report reduced host work for these operations, with peak memory usage remaining within 1% of previous levels.

Ralph Küpper added 2 commits September 27, 2026 14:13
The package profile (benchmarks/packages/PROFILE.md) charges three host-side
costs to regex. None of them is in the matcher:

- A split piece, or a replace capture, over an ASCII subject is now one byte
  copy (`copy_ascii_span`, which exec captures already used). It used to be
  two unit-by-unit cursor passes with two safepoint polls per piece.
- A string-template replacement whose subject and template are both ASCII now
  builds its output as one allocation plus a copy per native piece. It used to
  decode and re-encode every unit twice.
- The forward split loop polls once per 512 units of pieces (the POLL_UNITS
  trade #10657 measured for replace), not once per piece. It asks the search
  for captures only when the splitter has capture groups. The output list has
  not been seen by any code outside the operation yet, so it appends without a
  catch frame whenever the append fits the list's capacity.

Adds test-files/test_gap_regex_engine_package_shapes.ts. It covers the regex
shapes the package workloads spend their time in, plus the lastIndex, sticky,
fold, split and replace-template edges these paths must keep exact.
@coderabbitai

coderabbitai Bot commented Sep 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 7d218e77-307c-47f8-a6b3-c4641bac547b

📥 Commits

Reviewing files that changed from the base of the PR and between b4111b1 and 7df9739.

📒 Files selected for processing (1)
  • crates/perry-runtime/src/regex/perex_replace_storage.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/perry-runtime/src/regex/perex_replace_storage.rs

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 3 remain after this review.


📝 Walkthrough

Walkthrough

The regex runtime adds stride-based polling and updates split capture and list handling. It adds direct ASCII span-copy and replacement-output paths. A new package-shaped test covers regex matching, splitting, and replacement.

Changes

Regex span handling

Layer / File(s) Summary
Piece accounting and split output
crates/perry-runtime/src/regex/perex_runtime.rs, crates/perry-runtime/src/regex/perex_replace_storage.rs, crates/perry-runtime/src/regex/perex_split.rs
PieceStride polls after accumulated piece work reaches POLL_UNITS. forward_split selects captures based on the program’s capture count, uses stride ticks for polling, and appends copied spans with push_unseen.
ASCII span copying and replacement output
crates/perry-runtime/src/regex/perex_strings.rs, crates/perry-runtime/src/regex/perex_replace_storage.rs, test-files/test_gap_regex_engine_package_shapes.ts, changelog.d/11543-regex-split-replace-ascii.md
SpanCopies tries direct copying for ASCII subjects. Pieces.finish tries native ASCII output before the existing encoded path. The package-shape test adds regex matching, split, and replacement cases. The changelog reports benchmark results and peak RSS unchanged within 1%.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to 7df97

No actionable merge-blocking risk was established for the regex split and replacement optimizations. Merge after normal checks pass.

Security Architecture Review

Security architecture risk: 🔵 Low · up to b4111

The inspected paths retain checks around ASCII copying and split output construction. Split reaches garbage-collection checkpoints less often, but other checkpoints remain. No new privilege or external boundary was identified; security coverage is incomplete.

Retained concerns
No architecture-level concerns identified.

Security review details

Security Blast Radius

  • inferred — JavaScript callers that supply regexes and subjects to canonical split can exercise the changed polling cadence. The inspected path does not cross a newly identified service or privilege boundary.

Trust Boundaries and Controls

  • observed — Searches retain an independent poll every 64 lent searches, polls on pending search progress, and a poll before the owned-buffer search path. These controls limit the effect of removing the per-piece split poll, but are not a finite execution limit.

Resilience and Maintainability Implications

  • inferred — The reduced split-loop poll frequency can increase time between GC safepoints on quick, repeated matches. The independent search polls and unpublished split output limit the observed failure-containment implications; the inspected evidence does not establish a material security regression.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 15 functions across 5 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: reducing regex split and replace overhead for ASCII subjects.
Description check ✅ Passed The description provides a detailed summary, concrete changes, related issue references, test results, benchmark data, and limitations. It does not use every template heading and omits the checklist, …
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Ready to merge once CI is clean. It is Perry-side regex host overhead: ASCII split pieces and replace captures become single byte copies, template replacements use one allocation, the safepoint in the split loop fires every 512 units, there are no capture requests for group-free patterns, and no catch frame. Split −43%, short global replace −15%, validator −3 to −4%, RSS within 1%. The regex/split/replace gap tests are identical on main and the branch (65 pass). The engine-side win is perex#3, adopted via #11548 once released.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant