From 4ae0f5fd2818378943f6ec665076a8d3bfc56aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 20:25:35 +0000 Subject: [PATCH 1/2] perf(regex): replace without exec result objects when every step is the 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 --- .../src/gc/tests/runtime_roots.rs | 2 + .../gc/tests/runtime_roots/perex_replace.rs | 22 +- .../runtime_roots/perex_replace_direct.rs | 237 ++++++++++++++ crates/perry-runtime/src/regex.rs | 2 + crates/perry-runtime/src/regex/perex_api.rs | 74 ++++- .../perry-runtime/src/regex/perex_replace.rs | 15 + .../src/regex/perex_replace_direct.rs | 307 ++++++++++++++++++ 7 files changed, 644 insertions(+), 15 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs create mode 100644 crates/perry-runtime/src/regex/perex_replace_direct.rs diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index dea88d140a..cfa84c979c 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -41,6 +41,8 @@ mod perex_public; #[cfg(feature = "regex-engine")] mod perex_replace; #[cfg(feature = "regex-engine")] +mod perex_replace_direct; +#[cfg(feature = "regex-engine")] mod perex_reuse; #[cfg(feature = "regex-engine")] mod perex_split; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs index b17ba58705..ae48894c50 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace.rs @@ -4,7 +4,7 @@ use crate::regex::{perex_api as api, perex_dispatch as dispatch, perex_replace a use crate::value::{ js_nanbox_get_pointer, js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED, }; -fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { +pub(super) fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { scope.root_nanbox_f64(js_nanbox_string(crate::string::js_string_from_bytes( bytes.as_ptr(), bytes.len() as u32, @@ -15,7 +15,11 @@ fn object<'s>(scope: &'s RuntimeHandleScope) -> RuntimeHandle<'s> { crate::object::js_object_alloc(0, 8) as i64 )) } -fn regex<'s>(scope: &'s RuntimeHandleScope, source: &[u8], flags: &[u8]) -> RuntimeHandle<'s> { +pub(super) fn regex<'s>( + scope: &'s RuntimeHandleScope, + source: &[u8], + flags: &[u8], +) -> RuntimeHandle<'s> { let source = text(scope, source); let flags = text(scope, flags); scope.root_nanbox_f64(js_nanbox_pointer(crate::regex::js_regexp_construct( @@ -23,16 +27,20 @@ fn regex<'s>(scope: &'s RuntimeHandleScope, source: &[u8], flags: &[u8]) -> Runt flags.get_nanbox_f64(), ) as i64)) } -fn function<'s>(scope: &'s RuntimeHandleScope, fp: *const u8, arity: u32) -> RuntimeHandle<'s> { +pub(super) fn function<'s>( + scope: &'s RuntimeHandleScope, + fp: *const u8, + arity: u32, +) -> RuntimeHandle<'s> { crate::closure::js_register_closure_arity(fp, arity); scope.root_nanbox_f64(js_nanbox_pointer( crate::closure::js_closure_alloc_singleton(fp) as i64, )) } -fn get(owner: &RuntimeHandle<'_>, name: &[u8]) -> f64 { +pub(super) fn get(owner: &RuntimeHandle<'_>, name: &[u8]) -> f64 { api::finish(dispatch::get(owner, name)) } -fn put(owner: &RuntimeHandle<'_>, name: &[u8], value: f64) { +pub(super) fn put(owner: &RuntimeHandle<'_>, name: &[u8], value: f64) { let scope = RuntimeHandleScope::new(); let value = scope.root_nanbox_f64(value); let key = crate::string::canonical_key(name); @@ -76,12 +84,12 @@ fn accessor(owner: &RuntimeHandle<'_>, key: f64, getter: f64, setter: f64) { ); } -fn bytes(value: f64) -> Vec { +pub(super) fn bytes(value: f64) -> Vec { let mut short = [0; crate::value::SHORT_STRING_MAX_LEN]; let (data, n) = crate::string::str_bytes_from_jsvalue(value, &mut short).unwrap(); unsafe { std::slice::from_raw_parts(data, n as usize).to_vec() } } -fn captured<'s>( +pub(super) fn captured<'s>( scope: &'s RuntimeHandleScope, fp: *const u8, arity: u32, diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs new file mode 100644 index 0000000000..8e83c9a2f1 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_replace_direct.rs @@ -0,0 +1,237 @@ +//! RegExp `@@replace` without exec result objects (#10165): the same output +//! and final `lastIndex` as the ordinary loop, and only for receivers where +//! skipping the objects cannot be seen. +use super::perex_replace::{bytes, captured, function, get, put, regex, text}; +use super::*; +use crate::regex::perex_api as api; +use crate::regex::perex_replace_direct::{direct_replaces, DisableDirectReplaceForTest}; +use crate::value::{js_nanbox_string, TAG_UNDEFINED}; + +/// Replace through the public ABI; the output bytes and the receiver's final +/// `lastIndex`, and whether the direct path served it. +fn replace_all( + source: &[u8], + flags: &[u8], + input: &[u8], + replacement: impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64, +) -> (Vec, f64, bool) { + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, source, flags); + let input = text(&scope, input); + let replacement = scope.root_nanbox_f64(replacement(&scope, &re)); + let before = direct_replaces(); + let result = scope.root_nanbox_f64(crate::regex::js_string_replace_js( + input.get_nanbox_f64(), + re.get_nanbox_f64(), + replacement.get_nanbox_f64(), + )); + ( + bytes(result.get_nanbox_f64()), + get(&re, b"lastIndex"), + direct_replaces() > before, + ) +} + +fn both( + source: &[u8], + flags: &[u8], + input: &[u8], + replacement: impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64 + Copy, +) -> (Vec, f64) { + let (direct, direct_last, served) = replace_all(source, flags, input, replacement); + assert!( + served, + "{:?} /{:?}/ must take the direct path", + std::str::from_utf8(source), + flags + ); + let (ordinary, ordinary_last, served) = { + let _off = DisableDirectReplaceForTest::new(); + replace_all(source, flags, input, replacement) + }; + assert!(!served); + assert_eq!( + (String::from_utf8_lossy(&direct), direct_last), + (String::from_utf8_lossy(&ordinary), ordinary_last), + "/{}/{} over {:?}", + String::from_utf8_lossy(source), + String::from_utf8_lossy(flags), + String::from_utf8_lossy(input) + ); + (direct, direct_last) +} + +fn template( + value: &'static [u8], +) -> impl Fn(&RuntimeHandleScope, &RuntimeHandle<'_>) -> f64 + Copy { + move |scope, _| text(scope, value).get_nanbox_f64() +} + +/// Pattern, flags, input, template, and the expected output when it is pinned. +type Case = ( + &'static [u8], + &'static [u8], + &'static [u8], + &'static [u8], + &'static str, +); + +#[test] +fn direct_templates_match_the_ordinary_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let cases: &[Case] = &[ + ( + b"(a)(z)?", + b"g", + b"abaz a", + b"[$&|$1|$2|$`|$'|$$|$0|$3|$10|$|$]", + "", + ), + (b"(\\d)(\\d)?", b"g", b"1 23 4", b"<$2$1$11$01$12$00>", ""), + (b"a", b"", b"banana", b"[$`|$']", "b[b|nana]nana"), + (b"", b"g", "aé😀b".as_bytes(), b"<$&>", ""), + (b"", b"gu", "aé😀b".as_bytes(), b"<$&>", "<>a<>é<>😀<>b<>"), + (b"b", b"y", b"abba", b"X", "abba"), + (b"b", b"gy", b"bba", b"X", "XXa"), + ( + "[äö]+".as_bytes(), + b"g", + "xäöyö".as_bytes(), + b"($&)", + "x(äö)y(ö)", + ), + (b"x", b"g", b"no match here", b"Y", "no match here"), + ]; + for (source, flags, input, replacement, expected) in cases { + let (output, _) = both(source, flags, input, template(replacement)); + if !expected.is_empty() { + assert_eq!(String::from_utf8_lossy(&output), *expected); + } + } +} + +extern "C" fn describe( + _: *const crate::closure::ClosureHeader, + matched: f64, + capture: f64, + position: f64, + input: f64, +) -> f64 { + let capture = if capture.to_bits() == TAG_UNDEFINED { + "undefined".to_string() + } else { + String::from_utf8_lossy(&bytes(capture)).into_owned() + }; + let text = format!( + "{{{}:{}@{}/{}}}", + String::from_utf8_lossy(&bytes(matched)), + capture, + position, + bytes(input).len() + ); + gc_collect_minor(); + js_nanbox_string(crate::string::js_string_from_bytes(text.as_ptr(), text.len() as u32) as i64) +} + +#[test] +fn direct_callbacks_receive_the_ordinary_arguments() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let callback = |scope: &RuntimeHandleScope, _: &RuntimeHandle<'_>| { + function(scope, describe as *const u8, 4).get_nanbox_f64() + }; + let (output, _) = both(b"(b)?a", b"g", "bä a ba".as_bytes(), callback); + assert_eq!( + String::from_utf8_lossy(&output), + "bä {a:undefined@3/8} {ba:b@5/8}" + ); +} + +extern "C" fn meddle( + c: *const crate::closure::ClosureHeader, + matched: f64, + _position: f64, + _input: f64, +) -> f64 { + let scope = RuntimeHandleScope::new(); + let matched = scope.root_nanbox_f64(matched); + let state = scope.root_nanbox_f64(crate::closure::js_closure_get_capture_f64(c, 0)); + // Rewind the receiver and give it an own exec that matches nothing. Every + // match was collected before the first call, so neither can change them. + api::finish(crate::regex::perex_dispatch::set_last_index(&state, 0.0)); + let never = function(&scope, never_exec as *const u8, 1); + put(&state, b"exec", never.get_nanbox_f64()); + matched.get_nanbox_f64() +} + +extern "C" fn never_exec(_: *const crate::closure::ClosureHeader, _: f64) -> f64 { + f64::from_bits(crate::value::TAG_NULL) +} + +#[test] +fn a_replacer_cannot_change_which_matches_are_replaced() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let callback = |scope: &RuntimeHandleScope, re: &RuntimeHandle<'_>| { + captured(scope, meddle as *const u8, 3, re).get_nanbox_f64() + }; + let (output, last) = both(b"o", b"g", b"foo boo", callback); + assert_eq!(output, b"foo boo"); + assert_eq!(last, 0.0); +} + +extern "C" fn counting_exec(c: *const crate::closure::ClosureHeader, _: f64) -> f64 { + let scope = RuntimeHandleScope::new(); + let state = scope.root_nanbox_f64(crate::closure::js_closure_get_capture_f64(c, 0)); + put(&state, b"calls", get(&state, b"calls") + 1.0); + f64::from_bits(crate::value::TAG_NULL) +} + +#[test] +fn an_own_exec_or_named_groups_keep_the_ordinary_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + + let (_, _, served) = replace_all(b"a", b"g", b"banana", |scope, re| { + put(re, b"calls", 0.0); + let exec = captured(scope, counting_exec as *const u8, 1, re); + put(re, b"exec", exec.get_nanbox_f64()); + text(scope, b"X").get_nanbox_f64() + }); + assert!( + !served, + "an own exec must be called, so the direct path must decline" + ); + + let (output, _, served) = replace_all(b"(?a)", b"g", b"banana", template(b"[$]")); + assert!( + !served, + "named groups need the groups object, so the direct path must decline" + ); + assert_eq!(output, b"b[a]n[a]n[a]"); +} + +#[test] +fn direct_match_spans_are_not_capped_by_the_scratch_limit() { + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + // 63 groups: each match stores 64 span pairs, 128 entries. One match more + // than a SCRATCH_BYTES / 8 entry cap (the limit #10207 removed from the + // piece lists) allows, so a cap on span storage would throw here. + let groups = 63; + let entries_per_match = (groups + 1) * 2; + let matches = api::SCRATCH_BYTES / 8 / entries_per_match + 1; + let source = "(a)".repeat(groups) + "a"; + let input = "a".repeat((groups + 1) * matches); + let (output, last, served) = + replace_all(source.as_bytes(), b"g", input.as_bytes(), template(b"$1")); + assert!(served, "the witness must run on the direct path"); + assert_eq!(output.len(), matches); + assert!(output.iter().all(|&byte| byte == b'a')); + assert_eq!(last, 0.0); +} diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index 8594911285..740cc5ea15 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -50,6 +50,8 @@ pub(crate) mod perex_position_hint; #[cfg(feature = "regex-engine")] pub(crate) mod perex_replace; #[cfg(feature = "regex-engine")] +pub(crate) mod perex_replace_direct; +#[cfg(feature = "regex-engine")] mod perex_replace_storage; #[cfg(feature = "regex-engine")] mod perex_substitution; diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index f003a4e640..7d7e4c5f74 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -271,6 +271,17 @@ impl<'b, 's> Reuse<'b, 's> { /// Both roots are live, so equal addresses name the same objects even after /// either moved; a replaced program cannot reuse a cell this root retains. + /// How many named groups the program of the RegExp at `receiver` declares, + /// when this binding is for that receiver's current program. + pub(crate) fn name_count(&self, receiver: *const RegExpHeader) -> Option { + let reused = self.program.as_ref()?; + let bound = reused.receiver.with_const_ptr::(|p| p); + let cell = reused.cell.with_const_ptr::(|p| p); + (receiver == bound && unsafe { (*receiver).perex_program } == cell) + .then(|| reused.bound.with_view(|program| program.name_count()).ok()) + .flatten() + } + fn program_for(&self, receiver: &RuntimeHandle<'_>) -> Option<&BoundProgram>> { let reused = self.program.as_ref()?; let current = receiver.with_const_ptr::(|p| p); @@ -384,6 +395,36 @@ pub(crate) fn execute_with_resources( memory: &MemoryBudget, poll: &mut impl FnMut() -> Result<(), EngineError>, reuse: Option<&Reuse<'_, '_>>, +) -> Result, EngineError> { + let output = if materialize { + ExecOutput::Object + } else { + ExecOutput::Test + }; + execute_output(receiver, input, output, budget, memory, poll, reuse) +} + +/// What a builtin search produces when it matches. +pub(crate) enum ExecOutput<'v> { + /// Only the full match (`test`). + Test, + /// The exec result array and its groups object. + Object, + /// Every capture span, group zero first, appended to the vector as + /// UTF-16 `start, end` pairs, with `u32::MAX, u32::MAX` for an unset + /// group. No JS object is created (#10165). + Spans(&'v mut Vec), +} + +/// [`execute_with_resources`] with an explicit output. +pub(crate) fn execute_output( + receiver: *mut RegExpHeader, + input: *const StringHeader, + output: ExecOutput<'_>, + budget: &mut Budget, + memory: &MemoryBudget, + poll: &mut impl FnMut() -> Result<(), EngineError>, + reuse: Option<&Reuse<'_, '_>>, ) -> Result, EngineError> { let scope = RuntimeHandleScope::new(); let receiver = scope.root_raw_mut_ptr(receiver); @@ -434,10 +475,10 @@ pub(crate) fn execute_with_resources( subject, start, near, - if materialize { - CaptureMode::All - } else { + if matches!(output, ExecOutput::Test) { CaptureMode::Full + } else { + CaptureMode::All }, budget, memory, @@ -462,10 +503,10 @@ pub(crate) fn execute_with_resources( let Some(found) = found else { return Ok(None); }; - let (array, groups) = if materialize { + let (array, groups) = match output { // Captures and all native owners remain ABOVE the JS trap. A thrown // allocation/property operation returns here before they are dropped. - caught(|| { + ExecOutput::Object => caught(|| { super::perex_results::materialize( &input, subject, @@ -477,9 +518,26 @@ pub(crate) fn execute_with_resources( budget, poll, ) - })?? - } else { - (std::ptr::null_mut(), std::ptr::null_mut()) + })??, + ExecOutput::Spans(spans) => { + let captures = found.captures.as_ref().ok_or(EngineError::InvalidSpan)?; + spans + .try_reserve(captures.len() * 2) + .map_err(|_| StorageError::Allocation)?; + for capture in captures.iter() { + let (start, end) = match capture { + Some(span) => ( + u32::try_from(span.start()).map_err(|_| StorageError::Limit)?, + u32::try_from(span.end()).map_err(|_| StorageError::Limit)?, + ), + None => (u32::MAX, u32::MAX), + }; + spans.push(start); + spans.push(end); + } + (std::ptr::null_mut(), std::ptr::null_mut()) + } + ExecOutput::Test => (std::ptr::null_mut(), std::ptr::null_mut()), }; Ok(Some(ExecMatch { full: found.full, diff --git a/crates/perry-runtime/src/regex/perex_replace.rs b/crates/perry-runtime/src/regex/perex_replace.rs index 303c6fa1c1..91dbed8d25 100644 --- a/crates/perry-runtime/src/regex/perex_replace.rs +++ b/crates/perry-runtime/src/regex/perex_replace.rs @@ -75,6 +75,21 @@ pub(crate) fn regexp(receiver: f64, argument: f64, replacement: f64) -> Result = const { std::cell::Cell::new(0) }; + static DIRECT_DISABLED: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +#[cfg(test)] +pub(crate) fn direct_replaces() -> usize { + DIRECT_REPLACES.with(std::cell::Cell::get) +} + +/// The ordinary loop runs while this is held, so a test can compare the two. +#[cfg(test)] +pub(crate) struct DisableDirectReplaceForTest(bool); + +#[cfg(test)] +impl DisableDirectReplaceForTest { + pub(crate) fn new() -> Self { + Self(DIRECT_DISABLED.with(|d| d.replace(true))) + } +} + +#[cfg(test)] +impl Drop for DisableDirectReplaceForTest { + fn drop(&mut self) { + DIRECT_DISABLED.with(|d| d.set(self.0)); + } +} + +/// Whether this replacement may skip exec result objects. Non-observable. +pub(super) fn admissible(receiver: &RuntimeHandle<'_>, reuse: &Reuse<'_, '_>) -> bool { + #[cfg(test)] + if DIRECT_DISABLED.with(std::cell::Cell::get) { + return false; + } + let value = receiver.get_nanbox_f64(); + let re = crate::value::js_nanbox_get_pointer(value) as *const RegExpHeader; + super::is_valid_regex_ptr(re) + && crate::object::regex_proto_thunks::regexp_view_uses_builtin(value) + && reuse.name_count(re) == Some(0) +} + +/// 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)] +enum Token { + Template(usize, usize), + Matched, + Before, + After, + Capture(usize), +} + +/// GetSubstitution's scan (as `perex_substitution` performs it) with no named +/// groups, recorded once instead of per match. +fn parse( + template: &RuntimeHandle<'_>, + captures: usize, + budget: &mut Budget, +) -> Result, EngineError> { + let bound = super::perex_match_search::subject(*template)?; + let mut reader = Units::new(&bound)?; + let n = length(template); + let mut tokens = Vec::new(); + let (mut i, mut literal) = (0, 0); + while i < n { + if reader.at(i, budget)? != b'$' as u16 || i + 1 == n { + i += 1; + continue; + } + let marker = reader.at(i + 1, budget)?; + let mut next = i + 2; + let token = match marker { + 0x24 => Token::Template(i, i + 1), + 0x26 => Token::Matched, + 0x60 => Token::Before, + 0x27 => Token::After, + 0x30..=0x39 => { + let mut index = (marker - 0x30) as usize; + if next < n { + let second = reader.at(next, budget)?; + if (0x30..=0x39).contains(&second) { + let two = index * 10 + (second - 0x30) as usize; + if two > 0 && two <= captures { + index = two; + next += 1; + } + } + } + if index == 0 || index > captures { + i += 1; + continue; + } + Token::Capture(index) + } + _ => { + i += 1; + continue; + } + }; + tokens + .try_reserve(2) + .map_err(|_| StorageError::Allocation)?; + tokens.push(Token::Template(literal, i)); + tokens.push(token); + i = next; + literal = i; + } + tokens.push(Token::Template(literal, n)); + Ok(tokens) +} + +/// Every match's capture spans for one replacement. Their size follows the +/// subject (matches times captures), not a fixed operation limit, so they are +/// not charged to the `MemoryBudget`: a cap there made large replacements +/// throw where Node completes (#10164). The collector is told about them as +/// external bytes, like other runtime side storage. +struct Spans { + values: Vec, + noted: usize, +} + +impl Spans { + fn note_growth(&mut self) -> Result<(), EngineError> { + let bytes = self.values.capacity() * std::mem::size_of::(); + if bytes > self.noted { + let grown = bytes - self.noted; + self.noted = bytes; + api::caught(|| crate::gc::gc_note_external_side_alloc(grown))?; + } + Ok(()) + } +} + +impl Drop for Spans { + fn drop(&mut self) { + crate::gc::gc_note_external_side_free(self.noted); + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn replace( + scope: &RuntimeHandleScope, + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + bound: &BoundSubject>, + reuse: &Reuse<'_, '_>, + global: bool, + unicode: bool, + replacement: &RuntimeHandle<'_>, + template: Option<&RuntimeHandle<'_>>, + budget: &mut Budget, + memory: &MemoryBudget, +) -> Result { + #[cfg(test)] + DIRECT_REPLACES.with(|n| n.set(n.get() + 1)); + // The ordinary loop's RegExpExec adds a reference to the input per search. + input.with_mut_ptr::(|input| crate::string::js_string_addref(input)); + let input_length = length(input); + let mut spans = Spans { + values: Vec::new(), + noted: 0, + }; + let mut width = 0; + let mut searches = 0usize; + loop { + host::charge(budget, 1)?; + // Each match ends at or after the next search's start, and an empty one + // advances `lastIndex`, so a global loop runs at most once per position + // plus the final failing search. More means it stopped advancing. + searches += 1; + debug_assert!( + searches <= input_length + 2, + "a global replace searched more often than its input has positions" + ); + let before = spans.values.len(); + // Re-read the receiver every search: the previous one may have collected. + let re = + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader; + let found = input.with_const_ptr::(|input| { + api::execute_output( + re, + input, + ExecOutput::Spans(&mut spans.values), + budget, + memory, + &mut host::poll, + Some(reuse), + ) + })?; + spans.note_growth()?; + let Some(found) = found else { + break; + }; + width = spans.values.len() - before; + if !global { + break; + } + if found.full.is_empty() { + let local = RuntimeHandleScope::new(); + let index = local.root_nanbox_f64(super::perex_dispatch::get(receiver, b"lastIndex")?); + let index = super::perex_dispatch::to_length(&index)?; + let next = advance(bound, index, input_length, unicode, budget)?; + super::perex_dispatch::set_last_index(receiver, next)?; + } + host::poll()?; + } + if spans.values.is_empty() { + return Ok(boxed(input)); + } + let captures = (width / 2).saturating_sub(1); + let tokens = template.map(|t| parse(t, captures, budget)).transpose()?; + let mut copies = SpanCopies::new(bound)?; + let mut output = Pieces::new(scope)?; + let mut next_source = 0; + for record in spans.values.chunks_exact(width) { + let local = RuntimeHandleScope::new(); + let (start, end) = (record[0] as usize, record[1] as usize); + let position = start.min(input_length); + let accepted = position >= next_source; + if accepted { + output.append(input, next_source, position, budget)?; + } + if let Some(tokens) = tokens.as_ref() { + if accepted { + for token in tokens { + match *token { + Token::Template(a, b) => output.append(template.unwrap(), a, b, budget)?, + Token::Matched => output.append(input, start, end, budget)?, + Token::Before => output.append(input, 0, position, budget)?, + Token::After => { + output.append(input, end.min(input_length), input_length, budget)? + } + Token::Capture(index) => { + let (a, b) = (record[2 * index], record[2 * index + 1]); + if a != u32::MAX { + output.append(input, a as usize, b as usize, budget)?; + } + } + } + } + } + } else { + let mut args = List::new(&local)?; + let matched = copies.copy(start, end, budget)?; + args.push(js_nanbox_string(matched as i64), budget)?; + for pair in record[2..].as_chunks::<2>().0 { + if pair[0] == u32::MAX { + args.push(f64::from_bits(TAG_UNDEFINED), budget)?; + } else { + let capture = copies.copy(pair[0] as usize, pair[1] as usize, budget)?; + args.push(js_nanbox_string(capture as i64), budget)?; + } + } + args.push(position as f64, budget)?; + args.push(boxed(input), budget)?; + let this = local.root_nanbox_f64(f64::from_bits(TAG_UNDEFINED)); + let value = local.root_nanbox_f64(call(replacement, &this, &args, memory)?); + let value = text(&local, &value)?; + if accepted { + output.whole(&value, budget)?; + } + } + if accepted { + next_source = end; + } + host::poll()?; + } + if next_source < input_length { + output.append(input, next_source, input_length, budget)?; + } + output + .finish(input, template, budget) + .map(|s| js_nanbox_string(s as i64)) +} From 7a7f1d46219d841e644b96dc13d37c705b6cef81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 20:55:02 +0000 Subject: [PATCH 2/2] changelog: add fragment for #10225 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10225-regex-replace-direct.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10225-regex-replace-direct.md diff --git a/changelog.d/10225-regex-replace-direct.md b/changelog.d/10225-regex-replace-direct.md new file mode 100644 index 0000000000..ae407c4dea --- /dev/null +++ b/changelog.d/10225-regex-replace-direct.md @@ -0,0 +1,3 @@ +### Performance + +- **RegExp `replace` no longer builds an exec result object per match** (#10225). When a regular expression's `exec` is the builtin and it has no named groups, `str.replace(re, …)` collects each match's positions directly and builds the output from pieces of the original string, instead of creating a result array per match and reading it back property by property. A callback replacement over 400,000 matches uses 2.7× less CPU, a `"[$&]"` template 3.0× less, and a `"$2$1"` template 4.5× less; results, callback arguments and `lastIndex` are unchanged, and every match is still found before the first callback runs.