From 5bb50c03c46e18aa1aa6cfb187b5439a30ea3537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:24:57 +0000 Subject: [PATCH 1/2] perf(regex): poll the safepoint on units read, not on pieces Building a replacement's output walks its pieces twice, measuring and then encoding, and each pass polled the GC safepoint once per piece. `try_fold` stops at QUANTUM units *or* at the end of a piece, and a piece is usually two or three units -- an original span, a template span, a capture -- so a subject with 200,000 matches ran the safepoint hundreds of thousands of times per pass for a handful of units of reading each. That check costs about 436 instructions: it evaluates the whole budgeted trigger ladder, which has no cheap "nothing is due" precheck. Both passes now poll once per POLL_UNITS units read. Unlike the collection loop in `perex_replace_direct`, these passes are downstream of the replacement's traced pieces and its replacer's strings, so they do produce garbage and polling far less often costs peak RSS. POLL_UNITS is therefore a measured trade, not a bound inherited from elsewhere: at `api::QUANTUM` (4096) the instruction win is the same but peak RSS is +13.2% median on an allocating replace at n=1,000,000, over the accepted +10% budget. At 512 the win survives and the cost does not. Instructions, both arms from one commit, release, plain main: replace, string template 27,837,140,955 -> 20,090,148,511 -27.8% replace1m (both forms) 275,168,155,979 -> 249,292,630,136 -9.4% replace, callback, ASCII 51,289,448,826 -> 47,903,744,797 -6.6% replace, callback, Unicode 61,275,226,072 -> 58,023,887,665 -5.3% Peak RSS on replace1m, nine interleaved rounds: median +0.3%, mean +0.1%, max -0.4%, against a +10% budget. Answers are identical to Node 26.5.1 on every probe, including the correctness differential from #10605. Why 512 rather than 4096: a piece is two or three units, so 512 still removes about 99 percent of the polls while giving the collector eight times the openings. Both figures above are measured; the knee between them is not located. --- .../src/regex/perex_replace_storage.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/regex/perex_replace_storage.rs b/crates/perry-runtime/src/regex/perex_replace_storage.rs index 27e9bdbcc4..15e5ce04b5 100644 --- a/crates/perry-runtime/src/regex/perex_replace_storage.rs +++ b/crates/perry-runtime/src/regex/perex_replace_storage.rs @@ -214,6 +214,23 @@ pub(super) fn call_native( result } +/// How many units of output a `Pieces::finish` pass may read between GC +/// safepoint polls. +/// +/// The passes walk the output's pieces, and a piece is usually a handful of +/// units, so polling per piece ran the whole budgeted trigger ladder (~436 +/// instructions, no cheap "nothing is due" precheck) thousands of times per +/// QUANTUM of real reading. But these passes are downstream of the replacement's +/// traced pieces and its replacer's strings, so unlike the collection loop in +/// `perex_replace_direct` they DO produce garbage, and polling far less often +/// costs peak RSS: at `api::QUANTUM` (4096) it was +13.2% median on an +/// allocating replace at n=1,000,000, over the accepted +10% budget. +/// +/// This value is therefore a measured trade rather than a bound inherited from +/// elsewhere: small enough to keep the collector's openings, large enough that +/// a piece of two or three units no longer buys a poll of its own. +const POLL_UNITS: usize = 512; + /// A reusable original-input reader. A read retains only Perex offsets across /// collection, and adjacent reads do not repeat the initial Unicode seek. pub(super) struct Units<'a, 's> { @@ -524,13 +541,17 @@ impl<'a> Pieces<'a> { u32::MAX as usize - crate::gc::GC_HEADER_SIZE - std::mem::size_of::() - 7, ); let mut measured = Encoder::default(); + let mut measured_polled_at = 0usize; self.walk(original, template, budget, |reader, budget| loop { let p = reader .try_fold(api::QUANTUM, budget, |u| { measured.push(u, limit, &mut |_| Ok(())) }) .map_err(|e| read_error(e, |e| e))?; - host::poll()?; + if measured.units.saturating_sub(measured_polled_at) >= POLL_UNITS { + measured_polled_at = measured.units; + host::poll()?; + } if p == ReadProgress::Complete { return Ok(()); } @@ -550,6 +571,7 @@ impl<'a> Pieces<'a> { let output = scope.root_string_ptr(output); let mut encoded = Encoder::default(); let mut written = 0usize; + let mut encoded_polled_at = 0usize; self.walk(original, template, budget, |reader, budget| loop { let p = output.with_mut_ptr::(|header| { let mut emit = |bytes: &[u8]| { @@ -582,7 +604,10 @@ impl<'a> Pieces<'a> { } p })?; - host::poll()?; + if encoded.units.saturating_sub(encoded_polled_at) >= POLL_UNITS { + encoded_polled_at = encoded.units; + host::poll()?; + } if p == ReadProgress::Complete { return Ok(()); } From 60473039af6fdafbbb36bca40f386caea10bab61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 19 Sep 2026 04:24:57 +0000 Subject: [PATCH 2/2] docs(changelog): fragment for #10657 --- changelog.d/10657-replace-poll-on-units.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10657-replace-poll-on-units.md diff --git a/changelog.d/10657-replace-poll-on-units.md b/changelog.d/10657-replace-poll-on-units.md new file mode 100644 index 0000000000..c790109ad4 --- /dev/null +++ b/changelog.d/10657-replace-poll-on-units.md @@ -0,0 +1,3 @@ +### Faster + +- Building the output of a `String.prototype.replace` spends about 28% fewer instructions with a string replacement, and 5-7% fewer with a callback. Both passes over the output's pieces asked the collector whether it was due to run once per piece, and a piece is usually a few characters, while answering that question costs about 650 instructions. It is now asked once per 512 characters read, which is the bound it was always meant to keep. Peak memory is unchanged (#10165).