perf(regex): replace without exec result objects when every step is the builtin - #10225
Closed
proggeramlug wants to merge 2 commits into
Closed
proggeramlug wants to merge 2 commits into
proggeramlug wants to merge 2 commits into
Conversation
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
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
proggeramlug
pushed a commit
that referenced
this pull request
Sep 13, 2026
Contributor
Author
|
Landed on |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #10165:
String.prototype.replacewith a RegExp builds its output without per-match exec result objects.Problem
RegExp
@@replaceran the spec loop literally. For every match it:length,0,index, each capture andgroupsback through generic property gets;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
replaceover 400,000 matches took 5.8 s of CPU onmain; Node takes about 0.1 s.Change
regex/perex_replace_direct.rs, entered fromperex_replace::regexpafter the observable prologue:flagsand thelastIndexreset, because either can run user code. It requires a valid RegExp whoseregexp_view_uses_builtinholds (no ownexec/test, canonical prototype, builtinexec), 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 thegroupsobject for$<name>and for replacers. Anything else runs the ordinary loop unchanged.lastIndex, installs an ownexecor recompiles the pattern cannot change which matches are replaced. Each search goes through a newExecOutput::Spansmode ofexecute_with_resources, whose signature and callers are unchanged. It appends group zero's and every capture's UTF-16 span to a nativeVec<u32>instead of creating objects. Empty global matches still readlastIndex,AdvanceStringIndexand write it back exactly as before. Inside the loop no user code can run:lastIndexwas 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.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.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.undefined), position and input. They are materialized from spans only for the call.Tests (
gc::tests::runtime_roots::perex_replace_direct)lastIndex. Cases:$form, including$0,$10with 2 captures,$<x>with no groups and a trailing$;uover a surrogate pair;lastIndexand installs an ownexecchanges nothing.exec(the exec is called) and for named groups.SCRATCH_BYTES / 8-entry cap (63 groups, 65,537 matches) completes.perex_replacetests 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:
exec$'starts at the match startuSCRATCH_BYTES / 8entriesRangeError, as #10207's witness doesMeasurements (perrymaster, release builds of
mainbb9aa5a64and 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.'ab12 cd345;'.repeat(200000), 400,000 matches)main→ this PRmain→ this PRs.replace(/[0-9]+/g, m => "[" + m + "]")s.replace(/[0-9]+/g, "[$&]")s.replace(/([a-z]+)([0-9]+)/g, "$2$1")"ä中12 Ö漢345😀".repeat(40000).replace(/[0-9]+/gu, cb), 80,000 matchesStill 20–30× Node per match; the remaining cost is outside the replace loop's object churn.
Large inputs: a 1M-record
replacestill fails on this branch, exactly as onmain, 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 -- --checkcargo check -p perry-runtime --no-default-features --features full --lib(regex feature off): 0 warningscargo check -p perry-runtime --lib --tests: no warnings outsideglobal_this_webassembly.rsandmain's ownic_slow.rs:544cargo test -p perry-runtime --lib -- --test-threads=1: 3754 passed, 1 failed. The failure isnative_stack::tests::stack_top_respects_custom_thread_stack_sizes, red on main.cargo clippy -p perry-runtime --lib --tests: identical tomainatbb9aa5a64(empty diff)scripts/run_lint_gates.shscript tier: 76 of 77 pass. The failure is public benchmark evidence freshness, identical on main.gc_runtime_root_holders.pyandcheck_file_size.shpass.https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9