Skip to content

perf(regex): replace without exec result objects when every step is the builtin - #10225

Closed
proggeramlug wants to merge 2 commits into
mainfrom
perf/regex-replace-direct
Closed

proggeramlug wants to merge 2 commits into
mainfrom
perf/regex-replace-direct

Conversation

@proggeramlug

Copy link
Copy Markdown
Contributor

Part of #10165: String.prototype.replace with a RegExp builds its output without per-match exec result objects.

Problem

RegExp @@replace ran the spec loop literally. For every match it:

  • materialized a full exec result array;
  • read length, 0, index, each capture and groups back through generic property gets;
  • pushed every capture into a traced list;
  • only then built the output.

The result arrays are fresh own-data-property objects that no user code can reach, but they cost most of the per-match work. A callback replace over 400,000 matches took 5.8 s of CPU on main; Node takes about 0.1 s.

Change

regex/perex_replace_direct.rs, entered from perex_replace::regexp after the observable prologue:

  • Admission is non-observable and happens after flags and the lastIndex reset, because either can run user code. It requires a valid RegExp whose regexp_view_uses_builtin holds (no own exec/test, canonical prototype, builtin exec), the same gate split's forward path and perf(regex): skip the unobservable exec lookup, keep small match scratch inline, copy ASCII captures in one pass (#10166) #10212's exec skip use. The program must also have no named groups, since those need the groups object for $<name> and for replacers. Anything else runs the ordinary loop unchanged.
  • Collection: the spec's order is kept. Every match is collected before the first replacer call, so a replacer that rewinds lastIndex, installs an own exec or recompiles the pattern cannot change which matches are replaced. Each search goes through a new ExecOutput::Spans mode of execute_with_resources, whose signature and callers are unchanged. It appends group zero's and every capture's UTF-16 span to a native Vec<u32> instead of creating objects. Empty global matches still read lastIndex, AdvanceStringIndex and write it back exactly as before. Inside the loop no user code can run: lastIndex was reset to a Number and the builtin search writes only Numbers. A debug assertion bounds the loop at one search per input position plus the final one.
  • Span storage grows with the subject (matches × captures). It is not charged to the operation's MemoryBudget, since a fixed cap there made large replacements throw (bug(regex): split and replace throw "Regular expression work limit exceeded" on 32,000-unit strings Node handles in under a millisecond #10164, fix(regex): stop capping replace and split output lists at the scratch limit (#10164) #10207). It is reported to the collector as external bytes and released on drop.
  • Output:
    • Templates are parsed once (GetSubstitution without named groups, the same scan as perex_substitution) into tokens. Per match, $&, $n, $` and $' append spans of the input, and $$ and literals append spans of the template. Nothing is allocated per match.
    • Replacers get the same arguments: the matched string, captures (strings or undefined), position and input. They are materialized from spans only for the call.

Tests (gc::tests::runtime_roots::perex_replace_direct)

  • Differential templates: each case runs once on the direct path (asserted taken) and once with it disabled, and compares output and final lastIndex. Cases:
    • every $ form, including $0, $10 with 2 captures, $<x> with no groups and a trailing $;
    • two-digit capture rules;
    • zero-width global matches with and without u over a surrogate pair;
    • sticky, sticky-global and non-global receivers;
    • non-ASCII input, and no match.
  • Replacer arguments: compared the same way, with an unset capture and collections forced inside the replacer.
  • Spec order: a replacer that rewinds lastIndex and installs an own exec changes nothing.
  • Admission declines for an own exec (the exec is called) and for named groups.
  • No span cap: span storage one match past a SCRATCH_BYTES / 8-entry cap (63 groups, 65,537 matches) completes.
  • The existing perex_replace tests pass, and several of them now run on the direct path.

Fault injections, each run as one cargo test, source restored byte-identical afterwards. All 8 were caught:

injection how it was caught
admission ignores an own/replaced exec FAILED
admission ignores named groups FAILED
$' starts at the match start FAILED
two-digit captures past the capture count FAILED
empty matches advance by code unit under u FAILED through the loop-bound assertion (without it, the loop never terminates)
next source resumes at the match start FAILED
replacer capture arguments shifted by one FAILED
span storage capped at SCRATCH_BYTES / 8 entries the witness process dies with the memory-limit RangeError, as #10207's witness does

Measurements (perrymaster, release builds of main bb9aa5a64 and this branch, 3 alternating rounds)

perf stat, whole program minus a program that builds the same strings. Instruction spread across rounds is below 0.03 % on the branch. Every output matches Node.

workload ('ab12 cd345;'.repeat(200000), 400,000 matches) instructions main → this PR CPU main → this PR Node (wall)
s.replace(/[0-9]+/g, m => "[" + m + "]") 56.0 G → 23.0 G (−58.9 %) 5.76 s → 2.12 s (2.7×) 0.10 s
s.replace(/[0-9]+/g, "[$&]") 48.3 G → 22.9 G (−52.6 %) 4.60 s → 1.53 s (3.0×) 0.07 s
s.replace(/([a-z]+)([0-9]+)/g, "$2$1") 53.7 G → 16.4 G (−69.5 %) 6.20 s → 1.37 s (4.5×) 0.08 s
non-ASCII "ä中12 Ö漢345😀".repeat(40000).replace(/[0-9]+/gu, cb), 80,000 matches 7.5 G → 4.2 G (−44.2 %) 0.86 s → 0.38 s (2.3×) 0.04 s

Still 20–30× Node per match; the remaining cost is outside the replace loop's object churn.

Large inputs: a 1M-record replace still fails on this branch, exactly as on main, because of #10215. The output goes through the same pieces list, which is a GC array, and arrays past about 9M heap-string elements read back corrupted. This PR does not change that: its own span storage is a native vector that #10215 does not reach.

Validation (perrymaster, --locked)

  • cargo fmt --all -- --check
  • cargo check -p perry-runtime --no-default-features --features full --lib (regex feature off): 0 warnings
  • cargo check -p perry-runtime --lib --tests: no warnings outside global_this_webassembly.rs and main's own ic_slow.rs:544
  • cargo test -p perry-runtime --lib -- --test-threads=1: 3754 passed, 1 failed. The failure is native_stack::tests::stack_top_respects_custom_thread_stack_sizes, red on main.
  • cargo clippy -p perry-runtime --lib --tests: identical to main at bb9aa5a64 (empty diff)
  • scripts/run_lint_gates.sh script tier: 76 of 77 pass. The failure is public benchmark evidence freshness, identical on main. gc_runtime_root_holders.py and check_file_size.sh pass.

https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

Ralph Küpper added 2 commits September 13, 2026 20:25
…he builtin (#10165)

RegExp @@replace materialized a full exec result array per match, then read
length, 0, index, each capture and groups back through generic property
gets, and pushed every capture into a traced list before building the
output. For a receiver whose exec is the builtin (regexp_view_uses_builtin)
and whose program has no named groups, those objects and reads cannot be
observed, so this path collects each match's capture spans natively instead
and builds the output from spans of the input.

The specification's order is kept: every match is collected before the first
replacer call, so a replacer that changes lastIndex, exec or the pattern
cannot change which matches are replaced. flags and the lastIndex reset still
run first, and admission is decided after them because either can run user
code; inside the collection loop no user code can run. Templates are parsed
once (GetSubstitution without named groups) and emit input spans, so $&, $n,
$` and $' allocate nothing per match. Replacer calls get the same arguments.

The spans follow the subject (matches x captures), so they live in a plain
Vec reported to the collector as external bytes and are not charged to the
operation's MemoryBudget (#10164/#10207). execute_with_resources gains an
ExecOutput::Spans mode; its signature is unchanged.

Tests: the direct path against the ordinary loop (output and final lastIndex)
over templates with every $ form, zero-width global matches with and without
u, sticky and non-global receivers, non-ASCII input; replacer arguments; a
replacer that rewinds lastIndex and installs an own exec; admission declining
for an own exec and for named groups; span storage one match past a
SCRATCH_BYTES/8-entry cap. A debug assertion bounds the collection loop.

Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/regex-replace-direct

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

Landed on main via merge train 185 (#10227) as 90354ebb72..7e92362fc5, with the version bump 6000a00dfe (0.5.1561). Closing, since this landed through the train.

https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

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