From 5082d2c21d69a941396e5f6f248e3b904d1b6e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 19:55:09 +0000 Subject: [PATCH 1/2] perf(regex): stride the replace collection loop's safepoint poll 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 (#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 #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. --- .../src/regex/perex_replace_direct.rs | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/regex/perex_replace_direct.rs b/crates/perry-runtime/src/regex/perex_replace_direct.rs index dd715048ff..4dafcee987 100644 --- a/crates/perry-runtime/src/regex/perex_replace_direct.rs +++ b/crates/perry-runtime/src/regex/perex_replace_direct.rs @@ -74,6 +74,16 @@ pub(super) fn admissible(receiver: &RuntimeHandle<'_>, reuse: &Reuse<'_, '_>) -> && reuse.name_count(re) == Some(0) } +/// One collection-loop round in this many runs the GC safepoint poll. +/// +/// The value matches `PRE_SEARCH_POLL_STRIDE`, and deliberately so: both count +/// one search, so they are parallel on the same unit rather than nested. Note +/// that 64 is a chosen margin in #10494, not a derived one -- the evidence +/// there (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 relies on 64 being the right number; see the call site. +const COLLECT_POLL_STRIDE: usize = 64; + /// One piece of a template: a span of the template itself, or a part of the /// current match. Parsed once; the capture count is the program's. #[derive(Clone, Copy)] @@ -237,7 +247,27 @@ pub(super) fn replace( let next = advance(bound, index, input_length, unicode, budget)?; super::perex_dispatch::set_last_index(receiver, next)?; } - host::poll()?; + // One collection-loop round in COLLECT_POLL_STRIDE runs the safepoint. + // + // This loop writes each match's spans into a native buffer and creates + // no JS garbage, so its poll enables no collection: nulling it moves + // peak RSS by +0.0% median over nine interleaved rounds of an + // allocating replace at n=1,000,000. (For contrast, polling 8x less + // often in `Pieces::finish`, which does produce garbage, moved the same + // figure +13.2%.) + // + // 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, 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 whether this poll is strided + // or not. Striding a site whose own counter advanced on a *different* + // unit would not be safe on this argument. + if searches % COLLECT_POLL_STRIDE == 0 { + host::poll()?; + } } if spans.values.is_empty() { return Ok(boxed(input)); From da5ac8370f4325cef1880653c3e4e013de4e3461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Fri, 18 Sep 2026 19:56:02 +0000 Subject: [PATCH 2/2] docs(changelog): fragment for #10666 --- changelog.d/10666-collection-poll-stride.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10666-collection-poll-stride.md diff --git a/changelog.d/10666-collection-poll-stride.md b/changelog.d/10666-collection-poll-stride.md new file mode 100644 index 0000000000..a2cafec7ba --- /dev/null +++ b/changelog.d/10666-collection-poll-stride.md @@ -0,0 +1,3 @@ +### Faster + +- A global regular-expression replace spends 1.4-3.5% fewer instructions. The loop that collects the matches asked the collector whether it was due to run once per match, which costs about 436 instructions and could never do anything there — that loop writes its matches into a native buffer and creates nothing the collector can free. It now asks once per 64 matches, which is the same bound each search was already keeping. Peak memory is unchanged, measured over nine interleaved rounds of a replace at n=1,000,000 (#10165).