Skip to content

perf(regex): stride the replace collection loop's safepoint poll - #10666

Closed
proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/replace-collection-poll-stride
Closed

proggeramlug wants to merge 2 commits into
PerryTS:mainfrom
proggeramlug:perf/replace-collection-poll-stride

Conversation

@proggeramlug

@proggeramlug proggeramlug commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

The GC safepoint poll on the replace collection loop, strided 1-in-64. Part of #10165.

This is the second attempt at the poll-frequency lever in this path. The first, #10657, is drafted and will not land: it took −27.9% instructions and +13.2% median peak RSS, over the campaign's ≤ +10% budget. This one is the site where the same lever is free, and the difference is measured rather than assumed.

What it does

The loop that collects a global replace's matches polled the safepoint once per match. That poll costs ~436 instructions — it evaluates the whole budgeted trigger ladder, which has no cheap "nothing is due" precheck — and on this loop it enables no collection at all: matches are written into a native span buffer, so the loop creates nothing traced.

The controlled contrast

Same change, two sites in the same file, nine interleaved rounds on replace1m (a callback and a template replace at n = 250k/500k/700k/1,000,000 over an 11M-character subject, ~550 MB peak):

site instructions peak RSS rounds higher
Pieces::finish (#10657, drafted) −27.9% +13.2% median 8 of 9
this PR — collection loop −3.5% −0.5% median 4 of 9

4 of 9 is a coin flip: no direction. The predicate that falls out is reusable — polling less is free exactly where the loop produces nothing traced to collect.

Numbers

Both arms from one commit, release, arms verified distinct by md5.

workload before after
replace, string template 27,837,069,685 26,852,985,515 −3.5%
replace, callback, ASCII 51,289,880,556 50,427,663,998 −1.7%
replace, callback, Unicode 61,277,245,689 60,415,684,343 −1.4%
replace1m, both forms 275,177,993,181 270,666,099,260 −1.6%

Peak RSS on replace1m, nine interleaved rounds: median −0.5%, mean +0.4%. Answers identical to Node 26.5.1 and to the previous build on every probe, including the correctness differential from #10605 (unset captures, proxy replacers, a replacer allocating hard enough to collect mid-loop, empty matches, non-global, non-string returns).

Which axes are measured, and which are asserted

Measured: instructions, peak RSS.

Asserted, with evidence: max time-to-safepoint is not observable today. gc_runtime_safepoint_poll is let _ = gc_runtime_safepoint_report(); and nothing else; perex_runtime::poll returns Ok(()) unconditionally, so it services no interrupt and raises no cancellation. There is no cross-thread safepoint protocol in gc/ — no stop_the_world, safepoint_request, rendezvous or handshake — and the source states the collector is "stop-the-world relative to this TLS" (gc/roots/shadow_stack.rs:96, gc/roots/temp_roots.rs:57), i.e. it runs on the allocating thread. The one cross-thread mechanism, GC_UNSAFE_ZONES (gc/policy.rs:4668), is a suppression counter explicitly "without a full stop-the-world mutex", not a rendezvous anyone waits at.

And worst-case work between executed polls does not grow. Every search this loop performs goes through find_near, which either polls unconditionally (the owned path and any lent fallback) or ticks PRE_SEARCH_POLL_TICK and polls on one search in 64 (#10494). That tick advances once per search — once per iteration of this loop — so the two strides run in parallel on the same unit rather than composing: the bound stays 64 searches whether this poll is strided or not.

That argument is unit-dependent and the comment says so: striding a site whose counter advanced on a different unit would not be safe on it. Strides that count different units genuinely multiply, which is the concern tracked in #10665.

On the stride value

It matches PRE_SEARCH_POLL_STRIDE because the two must count the same unit — not because 64 is derived. It is a chosen margin in #10494, whose own evidence (removing the poll left cycle_starts, completions and steps identical across 48,000,000 calls) argues for removal and does not pick a stride. Nothing here depends on 64 being the right number, only on not exceeding the value already bounding this path. If 64 is ever derived properly, both move together and the parallel-unit argument still holds.

How stale participation in an incremental cycle can get before it suffers is, as far as I can find, unmeasured anywhere — see #10665.

Validation (local; runners are unreliable)

Base 60922041c. perry-runtime lib suite 4042 passed, 0 failed; --locked build, fmt, regex-off -D warnings, product -D warnings, GC root holders, file size and the release build all OK.

Lint gates: 2 of 83 fail, both pre-existing on pristine main and neither reachable from this diff (31 lines in one file, 30 of them comment):

Thanks to @hello-world-perf's lane, whose questions produced the RSS pairing that drafted #10657, the pointer to measure this site first, and the correction that stopped this PR citing an underived constant as precedent.

Summary by CodeRabbit

  • Performance

    • Improved global regular-expression replacement performance, reducing instruction overhead by approximately 1.4–3.5%.
    • Peak memory usage remains unchanged.
  • Documentation

    • Added release documentation describing the performance improvement and unchanged memory characteristics.

Ralph Küpper added 2 commits September 18, 2026 19:55
The loop that collects a global replace's matches polled the GC safepoint once
per match. That poll costs about 436 instructions -- it evaluates the whole
budgeted trigger ladder, which has no cheap "nothing is due" precheck -- and on
this loop it enables no collection at all: matches are written into a native
span buffer, so the loop creates nothing traced.

Measured rather than argued. Nulling this poll entirely moves peak RSS on an
allocating replace at n=1,000,000 by +0.0% median over nine interleaved rounds,
4 of 9 rounds in each direction. The same change applied to `Pieces::finish`,
which does produce garbage, moved that figure +13.2% with 8 of 9 rounds against
-- so this is a controlled contrast between an exposed and an unexposed site,
not an assumption that polls are cheap to drop.

Instructions, both arms from one commit, release, min of repeated rounds:

  replace, string template        27,837,069,685 -> 26,852,985,515   -3.5%
  replace, callback, ASCII        51,289,880,556 -> 50,427,663,998   -1.7%
  replace, callback, Unicode      61,277,245,689 -> 60,415,684,343   -1.4%
  replace1m (both, to n=1,000,000)   275,177,993,181 -> 270,666,099,260  -1.6%

Peak RSS, nine interleaved rounds on replace1m: median -0.5%, mean +0.4%,
4 of 9 rounds higher. Answers are identical to Node 26.5.1 and to the previous
build on every probe.

Worst-case work between executed polls does not grow. Every search this loop
performs goes through `find_near`, which either polls unconditionally (the owned
path and any lent fallback) or ticks `PRE_SEARCH_POLL_TICK` and polls on one
search in 64 (PerryTS#10494). That tick advances once per search, which is once per
iteration of this loop, so the two strides run in parallel on the same unit
rather than composing: the bound stays 64 searches either way.

The stride value matches `PRE_SEARCH_POLL_STRIDE` because they must count the
same unit, not because 64 is derived. It is a chosen margin in PerryTS#10494 -- the
evidence there argues for removing the poll, not for any particular stride --
and nothing here depends on it being the right number, only on not exceeding
the value already bounding this path.
@coderabbitai

coderabbitai Bot commented Sep 18, 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: 798e3882-15ee-4293-a51d-b3f5142658ed

📥 Commits

Reviewing files that changed from the base of the PR and between 6092204 and da5ac83.

📒 Files selected for processing (2)
  • changelog.d/10666-collection-poll-stride.md
  • crates/perry-runtime/src/regex/perex_replace_direct.rs

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


📝 Walkthrough

Walkthrough

The regex replacement collection loop now polls the collector every 64 searches instead of after every search. A constant documents the stride, and the changelog records the instruction reduction and unchanged peak memory.

Changes

Regex replacement polling

Layer / File(s) Summary
Strided collection polling
crates/perry-runtime/src/regex/perex_replace_direct.rs, changelog.d/10666-collection-poll-stride.md
The collection loop defines a 64-search polling stride and calls host::poll() only at that interval. The changelog records a 1.4–3.5% instruction reduction and unchanged peak memory.

Priority: ⬇️ Low

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Refactor

Merge Risk: ⚪ Minimal · up to da5ac

The optimization preserves replacement behavior and bounded collector polling, so no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: reducing the frequency of the regex replace collection loop's safepoint poll.
Description check ✅ Passed The description is comprehensive and covers the change, rationale, related issue, measured results, correctness evidence, safety argument, and validation results. It does not use the template's exact …
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 2 functions across 1 files. (1 skipped: 1 …
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.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • 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

Landed in merge train 221 (#10729), released as v0.5.1599 — main is now 91c6a05012.

Closing rather than merging is how trains work here: the six PRs were cherry-picked onto one tree, validated together, and landed under the train's own commit, so GitHub cannot mark this one merged even though your change is on main. Your commits are in 91c6a05012's history; git log origin/main will show them.

Because close-keywords in a source PR body never fire under this scheme, the issues this resolved were closed from the train's body instead. All 11 across the train are confirmed closed.

Validation the tree passed as a whole: 12 gap areas (every one asserted to have run a non-zero number of tests), zero unexplained regressions, artifacts byte-identical to their pin before and after the sweep, both derived integration suites green, and run_lint_gates.sh complete at 6/6 compile commands with only the known-red public-baseline step failing.

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