Skip to content

perf(regex): resume searches and capture reads from the previous position (#10164) - #10181

Closed
proggeramlug wants to merge 7 commits into
mainfrom
perf/10164-perex-position
Closed

perf(regex): resume searches and capture reads from the previous position (#10164)#10181
proggeramlug wants to merge 7 commits into
mainfrom
perf/10164-perex-position

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes the quadratic-time half of #10164. Stacked on #10174: this branch contains #10174's two commits, so please review only the top commit, perf(regex): resume searches and capture reads from the previous position.

Problem

On non-ASCII (byte) storage a Perex search seeks to its start from the nearer end of the subject, up to half its length. A global replace or match starts a search per match, and split one per match (or per position), so the seeks summed to about n²/4. At 32,000 units that exceeded the former 100,000,000-unit allowance and threw RangeError; without a cap (#10176) it is quadratic time. Materializing captures seeked from an end the same way. The placement is on #10164.

Change

Perex 0.1.2 adds a search-from-position API: Search::new_near and Search::position (the match end, or the start of the last attempt), and BoundSpan::new_near.

Results

Same host (AMD Ryzen 7 7700X, Node 26.8.1), release builds from source; checksums match Node at every completed size.

workload main slope bind once slope + positions slope n = 10,000 on main n = 10,000 with positions
regex-split-unicode /[,;😀]+/u 1.62 1.78 1.02 unsupported completes
regex-replace-callback-unicode /[ä中😀Ö漢🦊]+/gu + callback 1.81 1.68 1.05 unsupported completes

At n = 1,000,000 both rows still stop on the fixed 100M allowance in this branch alone; #10176 removes it.

Standalone reproducer, mean of two alternating rounds (bind once / positions interleaved):

probe (non-ASCII) Node bind once + positions
split unicode n = 1,000 4.1 ms 397.9 ms 44.7 ms
split unicode n = 2,000 0.7 ms throws RangeError 70.3 ms
split unicode n = 4,000 1.0 ms throws RangeError 166.4 ms
split unicode n = 10,000 2.0 ms throws RangeError 404.2 ms
replace cb unicode n = 1,000 2.0 ms 139.4 ms 18.8 ms
replace cb unicode n = 2,000 0.5 ms 517.4 ms 31.2 ms
replace cb unicode n = 4,000 1.6 ms throws RangeError 74.2 ms
replace cb unicode n = 10,000 3.9 ms throws RangeError 236.3 ms

ASCII rows are unchanged, as expected: ASCII storage already seeks in constant work. That was checked with interleaved rounds, after a first non-interleaved run was skewed by a concurrent build on the host.

Still quadratic: JS-level exec loops, matchAll and .test() do one search per JavaScript call. Their cross-call reuse is a separate, approved design being worked out with the Perex maintainer.

Tests

  • perex_reuse:
  • perex_split: a non-ASCII forward split's work roughly doubles when the input doubles (3.97× when it never resumes).
  • Sabotage: each of those fails when positions are not used, including materializing captures from an end, which fails the reduction with WorkLimit.
  • All 85 gc::tests::runtime_roots::perex* tests and all 71 regex:: unit tests pass on the crates.io perex 0.1.2, and rustfmt is clean.

Dependency

perex = "0.1.2" was published 2026-09-13 from PerryTS/perex d9f395d88931f0cfee1fe89ddf455cda606fd9e1, crates.io checksum 21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db.

It is inside the workspace's 7-day min-publish-age window, so Cargo.lock was resolved once with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow, as for 0.1.0, with the maintainer's approval. An ordinary cargo check --locked without the override was verified to download and build the locked 0.1.2.

https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

Summary by CodeRabbit

  • Performance

    • Improved global matching, splitting, and replacement by reusing search state across repeated searches.
    • Resumed searches from prior positions, improving performance on non-ASCII and large inputs.
    • Optimized forward searches for eligible regular-expression splits.
  • Compatibility

    • Preserved expected behavior for custom exec methods, species constructors, subclasses, and Unicode text.
  • Reliability

    • Improved stability during garbage collection.
    • Kept large Unicode split and replacement operations within configured work limits.

…10165)

String split, replace and global match run a search at every position or
match of one string with one matcher, but execute_with_resources bound both
afresh for each search. Binding a subject decodes the entire string
(perex Input::wtf8, uncharged) and binding a program revalidates every
word, so each of those operations did O(n) binding work per search and
O(n²) overall. On ASCII input this is why `str.split(/[,; ]+/)` took 8.1 s
for a 150,000-unit string.

The three loops already held a BoundSubject over their input. A new
perex_api::Reuse carries it, plus a program binding taken from the
receiver before the loop, into execute_with_resources. Perex's binding
contract allows a binding to outlive allocation, collection and JS
callbacks: Perry's owners hold registered roots and reacquire their base
on every view. A search uses a reused binding only while it is provably
the same object (same string; same receiver still holding the same
program cell); otherwise it binds afresh exactly as before, which covers
an exec override, RegExp.prototype.compile and a different string. Reuse
is built before each loop because runtime handle scopes are a stack.

matchAll's next(), JS-level exec/test and search are one search per JS
call and are unchanged; cross-call reuse is a separate contract.

The non-ASCII seek charge behind the work-limit RangeError (#10164) is on
the Perex side and needs its search-from-position API; this change does
not affect it.

Tests: gc::tests::runtime_roots::perex_reuse covers a whole global loop
with a minor collection at every poll under forced evacuation (subject
and program cell both relocate; fresh work equals reused work plus six
program validations, proving reuse engages), a recompile between
searches, and a different string. Each test fails when its guard is
sabotaged.

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

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a6249697-dac5-44f6-b668-181f24e56472

📥 Commits

Reviewing files that changed from the base of the PR and between d175675 and d5d1974.

📒 Files selected for processing (4)
  • changelog.d/10174-regex-bind-once-forward-split.md
  • changelog.d/10181-regex-resume-from-position.md
  • crates/perry-runtime/src/object/regex_proto_thunks.rs
  • scripts/gc_runtime_root_holders.json

Included review availability: Your plan provides up to 8 included reviews per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

The regex runtime now reuses bound programs and subjects across compound operations, resumes searches from prior positions, and adds a forward-search split path for intrinsic built-in RegExp behavior. Tests cover reuse, Unicode workloads, fallback behavior, and work limits.

Changes

PEREx execution

Layer / File(s) Summary
Reuse and positioned search primitives
crates/perry-runtime/src/regex/perex_api.rs, crates/perry-runtime/src/regex/perex_runtime.rs, crates/perry-runtime/src/regex/perex_strings.rs, crates/perry-runtime/src/regex/perex_results.rs
Adds reusable program and subject bindings, nearby search positions, and position-aware span materialization.
Compound-operation reuse wiring
crates/perry-runtime/src/regex/perex_dispatch.rs, crates/perry-runtime/src/regex/perex_match_search.rs, crates/perry-runtime/src/regex/perex_replace.rs, crates/perry-runtime/src/regex/match_all.rs, crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs
Passes optional reuse handles through global matching and replacement paths. Other execution paths pass None.
Forward-search split path
crates/perry-runtime/src/regex/perex_split.rs, crates/perry-runtime/src/regex/perex_construct.rs, crates/perry-runtime/src/object/regex_proto_thunks.rs
Adds non-sticky forward search for intrinsic built-in RegExp behavior and retains the sticky loop for custom or observable behavior.
Runtime and split validation
crates/perry-runtime/src/gc/tests/runtime_roots.rs, crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs, crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs, Cargo.toml, changelog.d/*, scripts/gc_runtime_root_holders.json
Adds feature-gated reuse tests, split instrumentation tests, Unicode workloads, changelog entries, test-only GC holder entries, and the perex version requirement 0.1.2.

Priority: ➖ Normal

Estimated code review effort: 4 (Complex) | ~45 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant RegexOperation
  participant dispatch_execute
  participant Reuse
  participant perex_runtime
  participant ResultMaterialization
  RegexOperation->>dispatch_execute: execute with optional Reuse
  dispatch_execute->>Reuse: reuse matching program and subject
  Reuse->>perex_runtime: find_near from prior Position
  perex_runtime-->>Reuse: return match and new Position
  Reuse->>ResultMaterialization: materialize with nearby position
  ResultMaterialization-->>RegexOperation: return regex result
Loading

Merge Risk: ⚪ Minimal · up to d5d19

The regex performance changes preserve tested fallback and rebinding behavior, with no established production risk; the change is mergeable.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.11% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 15 files. (3 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the primary performance change: resuming regex searches and capture reads from prior positions.
Description check ✅ Passed The description is detailed and covers the problem, implementation, results, tests, related issue, dependency update, and scope limitations. It does not use every template heading or include the check…
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.
Full details: Docstring Coverage

Explanation

Docstring coverage is 61.11% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 54 functions across 15 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/10164-perex-position

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.

Ralph Küpper added 2 commits September 13, 2026 10:02
…10165)

RegExp.prototype[@@split] tries a sticky match at every position q.
Each attempt starts a whole search, so split pays a search's fixed setup
per subject unit: about 27.6 work units per unit for `/[,; ]+/`, against
about 9 for a global exec loop over the same subject.

A non-sticky search from q returns the leftmost position s >= q where
the pattern matches, with the same match a sticky attempt at s finds.
The attempts at q..s-1 can therefore be skipped without changing any
piece or capture, and empty matches and Unicode advancement line up.
Skipping them is unobservable only when nothing can see a RegExpExec:

- the species is absent or the intrinsic RegExp (recognised by its call
  thunk), so the splitter is a fresh object no user code holds and its
  skipped lastIndex writes cannot be seen; a user species could return
  a real RegExp and read lastIndex afterwards;
- the splitter's exec resolves, without running a getter, to the builtin
  data property (regexp_view_uses_builtin), so the skipped Get(exec)
  calls cannot be seen either.

When both hold, split compiles a program from the splitter's own internal
source and canonical flags without `y` (never from the receiver, whose
program a limit valueOf could replace via RegExp.prototype.compile after
the splitter was built) and searches forward with it, reusing the
operation's subject binding. Anything else runs the unchanged per-position
sticky loop.

Tests (gc::tests::runtime_roots::perex_split):
- forward search matches ten results derived by hand from the sticky
  algorithm (repeated and unmatched captures, empty matches, `$` at the
  end, limits inside captures, Unicode empty-match advancement, the
  non-ASCII #10164 record), each asserted to take the forward path;
- a user species returning a real RegExp keeps the sticky loop and
  leaves the splitter's lastIndex at 2, as the specification requires;
- the existing species-factory and custom-exec tests now also assert
  the sticky loop ran.
Sabotage: admitting any species fails the user-species test; trying the
end of the input or dropping captures fails the forward-search test.

Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9
Ralph Küpper added 4 commits September 13, 2026 10:46
gc_runtime_root_holders.py flags the new #[cfg(test)] FORWARD_SPLITS Cell<usize> under rule B. It is a test-only count, never an address.

Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9
…tion (#10164)

On non-ASCII (byte) storage a Perex search seeks to its start from the
nearer end of the subject, up to half its length. A global replace or
match starts a search per match, and split one per match or position,
so the seeks summed to about n²/4: at 32,000 units that exceeded the
former 100,000,000-unit allowance and threw, and without a cap it is
quadratic time. Materializing captures seeked from an end the same way.

Perex 0.1.2 adds a search-from-position API: Search::new_near and
Search::position (the match end, or the start of the last attempt), and
BoundSpan::new_near. perex_runtime::find_near and
perex_strings::copy_span_near take an optional position; find and
copy_span delegate to them with none.

- perex_api::Reuse carries the last position of the reused subject, set
  only from and used only with that binding, because a position from
  another string with the same layout cannot be detected.
  execute_with_resources seeds each search and its capture
  materialization from it; global match copies each result from its
  search's end.
- Split's forward search seeds each search from the previous one and
  materializes captures from the search's position.

Tests:
- perex_reuse: a non-ASCII global loop's work roughly doubles when the
  subject doubles (3.71x without positions); the #10164 reduction, a
  32,000-unit split (6,001 pieces) and a 60,000-unit global replace
  (76,000 units), completes.
- perex_split: a non-ASCII forward split's work roughly doubles when the
  input doubles (3.97x when it never resumes).
Sabotage: each of those fails when positions are not used.

Requires perex 0.1.2, published 2026-09-13 from PerryTS/perex
d9f395d88931f0cfee1fe89ddf455cda606fd9e1 (crates.io checksum
21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db). It is
inside the workspace's 7-day min-publish-age window, so Cargo.lock was
resolved once with CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow, as for
perex 0.1.0, with the maintainer's approval. Ordinary --locked builds use
the locked version without the override.

Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9
gc_runtime_root_holders.py flags the new #[cfg(test)] LAST_FORWARD_WORK Cell<usize> under rule B. It is a test-only work count, never an address.

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

Copy link
Copy Markdown
Contributor Author

Restacked onto the landed #10174 and pushed as d5d197432, adding one commit: gc: record the forward-split work counter's holder verdict. scripts/gc_runtime_root_holders.py flagged the new #[cfg(test)] LAST_FORWARD_WORK: Cell<usize> under rule B, and the commit records it as test_only. It is a work count, never an address.

Full replay of d5d197432 (perrymaster, --locked, no publish-age override):

  • fmt clean
  • regex-off cargo check: 0 warnings
  • cargo check -p perry-runtime --lib --tests: no warnings outside the known global_this_webassembly.rs dead code
  • cargo test -p perry-runtime --lib -- --test-threads=1: 3685 passed, 1 failed. The failure is native_stack::tests::stack_top_respects_custom_thread_stack_sizes, red on main.
  • lint script tier: 76/77, with the same public-baseline freshness failure as main
  • changeset fragment gate: pass

It lands via its merge train after #10176.

https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9

@proggeramlug

Copy link
Copy Markdown
Contributor Author

Landed on main via merge train 179 (#10194) as ea81588406..f3685fafcb, with the version bump 820cf76729 (0.5.1551). Validation of the combined tree is in #10194. 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