From 5e3447021b278c59107a52c7fac6294fe5397195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 07:43:31 +0000 Subject: [PATCH 1/7] perf(regex): bind subject and program once per split/replace/match (#10165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/gc/tests/runtime_roots.rs | 2 + .../gc/tests/runtime_roots/perex_dispatch.rs | 7 +- .../src/gc/tests/runtime_roots/perex_reuse.rs | 226 ++++++++++++++++++ crates/perry-runtime/src/regex/match_all.rs | 1 + crates/perry-runtime/src/regex/perex_api.rs | 118 ++++++++- .../perry-runtime/src/regex/perex_dispatch.rs | 5 +- .../src/regex/perex_match_search.rs | 15 +- .../perry-runtime/src/regex/perex_replace.rs | 2 + crates/perry-runtime/src/regex/perex_split.rs | 3 + 9 files changed, 362 insertions(+), 17 deletions(-) create mode 100644 crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots.rs b/crates/perry-runtime/src/gc/tests/runtime_roots.rs index 58feac5ab8..d21d61fe5c 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots.rs @@ -37,6 +37,8 @@ mod perex_public; #[cfg(feature = "regex-engine")] mod perex_replace; #[cfg(feature = "regex-engine")] +mod perex_reuse; +#[cfg(feature = "regex-engine")] mod perex_split; #[cfg(feature = "regex-engine")] mod perex_strings; diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs index 0c38320d46..2c4438effd 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_dispatch.rs @@ -238,6 +238,7 @@ fn perex_dispatch_getter_and_callback_reacquire_original_input_after_gc() { &mut Budget::new(api::WORK), &MemoryBudget::new(api::SCRATCH_BYTES), &mut crate::regex::perex_runtime::poll, + None, )) .unwrap() .object(); @@ -349,7 +350,8 @@ fn perex_dispatch_validates_override_results_and_keeps_one_work_allowance() { false, &mut budget, &memory, - &mut crate::regex::perex_runtime::poll + &mut crate::regex::perex_runtime::poll, + None, )) .is_some()); assert_eq!(budget.remaining(), expected); @@ -361,7 +363,8 @@ fn perex_dispatch_validates_override_results_and_keeps_one_work_allowance() { false, &mut budget, &memory, - &mut crate::regex::perex_runtime::poll + &mut crate::regex::perex_runtime::poll, + None, ), Err(EngineError::Execution( perex::executor::ExecError::WorkLimit diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs new file mode 100644 index 0000000000..9050c8c439 --- /dev/null +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -0,0 +1,226 @@ +//! Compound-operation binding reuse (#10165): one subject and program binding +//! serves every search of an operation, across actual moving collections, and +//! is abandoned whenever the receiver's program or the string is not the one +//! it bound. +use super::*; +use crate::array::ArrayHeader; +use crate::regex::perex_api::{self as api, Reuse}; +use crate::regex::perex_memory::MemoryBudget; +use crate::regex::perex_owner::HeapSubject; +use crate::regex::RegExpHeader; +use crate::string::StringHeader; +use crate::value::{js_nanbox_pointer, js_nanbox_string}; +use perex::binding::BoundSubject; +use perex::Budget; + +fn text<'s>(scope: &'s RuntimeHandleScope, bytes: &[u8]) -> RuntimeHandle<'s> { + scope.root_string_ptr(crate::string::js_string_from_bytes( + bytes.as_ptr(), + bytes.len() as u32, + )) +} + +/// A NaN-boxed receiver handle, as split/replace/match root their receivers. +fn regex<'s>(scope: &'s RuntimeHandleScope, pattern: &str, flags: &str) -> RuntimeHandle<'s> { + let pattern = text(scope, pattern.as_bytes()); + let flags = text(scope, flags.as_bytes()); + let re = pattern.with_const_ptr::(|pattern| { + flags.with_const_ptr::(|flags| crate::regex::js_regexp_new(pattern, flags)) + }); + scope.root_nanbox_f64(js_nanbox_pointer(re as i64)) +} + +fn receiver_ptr(receiver: &RuntimeHandle<'_>) -> *mut RegExpHeader { + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *mut RegExpHeader +} + +fn first_item(scope: &RuntimeHandleScope, array: *mut ArrayHeader) -> Vec { + let array = scope.root_raw_mut_ptr(array); + let value = array.with_const_ptr::(|a| crate::array::js_array_get_f64(a, 0)); + let mut scratch = [0; crate::value::SHORT_STRING_MAX_LEN]; + let (data, len) = crate::string::str_bytes_from_jsvalue(value, &mut scratch).unwrap(); + unsafe { std::slice::from_raw_parts(data, len as usize).to_vec() } +} + +/// Run a global exec loop to exhaustion with a collection at every poll, +/// returning each full match and the work the loop charged. +fn global_loop( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + reuse: Option<&Reuse<'_, '_>>, +) -> (Vec>, usize) { + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let mut budget = Budget::new(api::WORK); + let mut matches = Vec::new(); + let roots = RuntimeHandleScope::active_len_for_tests(); + loop { + let iteration = RuntimeHandleScope::new(); + // Re-read both addresses every search: the previous one collected. + let found = input + .with_const_ptr::(|input| { + api::execute_with_resources( + receiver_ptr(receiver), + input, + true, + &mut budget, + &memory, + &mut || { + gc_collect_minor(); + Ok(()) + }, + reuse, + ) + }) + .unwrap(); + let Some(found) = found else { break }; + matches.push(first_item(&iteration, found.array)); + drop(iteration); + assert_eq!(RuntimeHandleScope::active_len_for_tests(), roots); + } + (matches, api::WORK - budget.remaining()) +} + +#[test] +fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + // Non-ASCII storage (the byte representation, not the ASCII layout). Every + // object is allocated immediately before its loop so it is still young + // and the loop's collections must actually relocate it. + const SUBJECT: &str = "ä1 b22 c333 ä4444 é55555"; + const PATTERN: &str = "[a-zäé]+\\d+"; + let expected: Vec> = ["ä1", "b22", "c333", "ä4444", "é55555"] + .iter() + .map(|s| s.as_bytes().to_vec()) + .collect(); + + let input = text(&scope, SUBJECT.as_bytes()); + let reused = regex(&scope, PATTERN, "gu"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let mut setup = Budget::new(api::WORK); + let reuse = Reuse::new(&scope, &reused, input, &subject, &mut setup); + let input_before = input.with_const_ptr::(|p| p as usize); + let program_before = unsafe { (*receiver_ptr(&reused)).perex_program as usize }; + let cycles = copying_minor_cycles(); + let (reused_matches, reused_work) = global_loop(&reused, &input, Some(&reuse)); + + assert_eq!(reused_matches, expected); + assert!( + copying_minor_cycles() > cycles, + "the loop must actually collect" + ); + assert_ne!( + input.with_const_ptr::(|p| p as usize), + input_before, + "the bound subject must have been relocated during the loop" + ); + assert_ne!( + unsafe { (*receiver_ptr(&reused)).perex_program as usize }, + program_before, + "the reused program must have moved, and still be recognised as the same cell" + ); + + // The same operation on identical, independent objects without reuse. + let fresh_input = text(&scope, SUBJECT.as_bytes()); + let fresh = regex(&scope, PATTERN, "gu"); + let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); + assert_eq!(fresh_matches, expected); + // Six searches (five matches and the final miss). Binding per search charges + // program validation six times; reuse charged it once, in `setup`. + let validation = api::WORK - setup.remaining(); + assert!(validation > 0); + assert_eq!(fresh_work, reused_work + 6 * validation); +} + +#[test] +fn perex_reuse_uses_the_receivers_current_program_after_recompile() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let input = text(&scope, b"aaa bbb aaa"); + let receiver = regex(&scope, "a+", "g"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let reuse = Reuse::new( + &scope, + &receiver, + input, + &subject, + &mut Budget::new(api::WORK), + ); + let search = |scope: &RuntimeHandleScope| { + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + input + .with_const_ptr::(|s| { + api::execute_with_resources( + receiver_ptr(&receiver), + s, + true, + &mut Budget::new(api::WORK), + &memory, + &mut || { + gc_collect_minor(); + Ok(()) + }, + Some(&reuse), + ) + }) + .unwrap() + .map(|found| first_item(scope, found.array)) + }; + let first = RuntimeHandleScope::new(); + assert_eq!(search(&first).as_deref(), Some(&b"aaa"[..])); + drop(first); + // RegExp.prototype.compile publishes a new program and resets lastIndex. + let pattern = text(&scope, b"b+"); + let flags = text(&scope, b"g"); + crate::regex::js_regexp_compile_value( + receiver_ptr(&receiver), + pattern.with_const_ptr::(|p| js_nanbox_string(p as i64)), + flags.with_const_ptr::(|p| js_nanbox_string(p as i64)), + ); + let second = RuntimeHandleScope::new(); + assert_eq!(search(&second).as_deref(), Some(&b"bbb"[..])); +} + +#[test] +fn perex_reuse_binds_a_different_string_afresh() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let bound_input = text(&scope, b"x1"); + let other_input = text(&scope, b"yy22"); + let receiver = regex(&scope, "\\d+", ""); + let subject = BoundSubject::new(unsafe { HeapSubject::new(bound_input) }.unwrap()).unwrap(); + let reuse = Reuse::new( + &scope, + &receiver, + bound_input, + &subject, + &mut Budget::new(api::WORK), + ); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); + let found = other_input + .with_const_ptr::(|s| { + api::execute_with_resources( + receiver_ptr(&receiver), + s, + true, + &mut Budget::new(api::WORK), + &memory, + &mut || Ok(()), + Some(&reuse), + ) + }) + .unwrap() + .unwrap(); + assert_eq!(first_item(&scope, found.array), b"22"); +} diff --git a/crates/perry-runtime/src/regex/match_all.rs b/crates/perry-runtime/src/regex/match_all.rs index 65bca84ef2..5c3ff102b6 100644 --- a/crates/perry-runtime/src/regex/match_all.rs +++ b/crates/perry-runtime/src/regex/match_all.rs @@ -232,6 +232,7 @@ fn next(iter: *mut ObjectHeader) -> Result { &mut budget, &memory, &mut host::poll, + None, )?; let Some(found) = found else { complete(&iter); diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index c14d1cf097..900e603280 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -97,6 +97,86 @@ pub(crate) fn program<'s>( BoundProgram::new(owner, budget).map_err(|e| EngineError::Program(e.error)) } +/// Bindings one compound operation reuses across its searches (#10165). +/// +/// Split, replace and global match run many searches over one string with one +/// matcher. Binding per search decodes the entire subject and revalidates the +/// entire program every time, which made those loops quadratic in the input. +/// Perex's binding contract lets a binding outlive allocation, collection and +/// JS callbacks: both owners hold registered roots and reacquire their base on +/// every view, so no search needs to rebind because the collector moved them. +/// +/// Build it before the operation's loop. Runtime handle scopes are a stack, so +/// its roots must sit below every per-iteration scope; nothing here roots +/// lazily. A search uses a binding only while it is provably the same object: +/// the same string, and the same receiver still holding the same program cell. +/// Anything else (an `exec` override, a recompiled receiver, another string) +/// binds afresh for that search exactly as before. +pub(crate) struct Reuse<'b, 's> { + input: RuntimeHandle<'s>, + subject: &'b BoundSubject>, + program: Option>, +} + +struct ReusedProgram<'s> { + receiver: RuntimeHandle<'s>, + cell: RuntimeHandle<'s>, + bound: BoundProgram>, +} + +impl<'b, 's> Reuse<'b, 's> { + /// `subject` must bind the whole of `input` (not a window), as the + /// operations' own `subject(input)` bindings do. + pub(crate) fn new( + scope: &'s RuntimeHandleScope, + receiver: &RuntimeHandle<'_>, + input: RuntimeHandle<'s>, + subject: &'b BoundSubject>, + budget: &mut Budget, + ) -> Self { + let re = + crate::value::js_nanbox_get_pointer(receiver.get_nanbox_f64()) as *const RegExpHeader; + // A receiver that is not a RegExp with a published program runs no + // builtin search here; its failure belongs to the ordinary path. + let program = (super::is_valid_regex_ptr(re) && unsafe { !(*re).perex_program.is_null() }) + .then(|| { + // Rooting pushes a handle slot and never collects, so `re` and + // its program edge are still current for every read below. + let receiver = scope.root_raw_const_ptr(re); + let cell = scope.root_raw_const_ptr(unsafe { (*re).perex_program }); + let owner = unsafe { GcProgram::from_receiver(scope, &receiver) }.ok()?; + let bound = BoundProgram::new(owner, budget).ok()?; + Some(ReusedProgram { + receiver, + cell, + bound, + }) + }) + .flatten(); + Self { + input, + subject, + program, + } + } + + fn subject_for(&self, input: &RuntimeHandle<'_>) -> Option<&BoundSubject>> { + let current = input.with_const_ptr::(|p| p); + let bound = self.input.with_const_ptr::(|p| p); + (current == bound).then_some(self.subject) + } + + /// 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. + fn program_for(&self, receiver: &RuntimeHandle<'_>) -> Option<&BoundProgram>> { + let reused = self.program.as_ref()?; + let current = receiver.with_const_ptr::(|p| p); + let bound = reused.receiver.with_const_ptr::(|p| p); + let cell = reused.cell.with_const_ptr::(|p| p); + (current == bound && unsafe { (*current).perex_program } == cell).then_some(&reused.bound) + } +} + pub(crate) struct ExecMatch { pub(crate) full: Span, pub(crate) array: *mut crate::array::ArrayHeader, @@ -186,12 +266,13 @@ pub(crate) fn execute( &mut Budget::new(WORK), &MemoryBudget::new(SCRATCH_BYTES), poll, + None, ) } /// Compound String operations keep one allowance across successive matches. /// Each execution has its own root scope, so a global loop cannot retain a -/// root for every previous result. +/// root for every previous result. `reuse` carries the operation's bindings. pub(crate) fn execute_with_resources( receiver: *mut RegExpHeader, input: *const StringHeader, @@ -199,6 +280,7 @@ pub(crate) fn execute_with_resources( 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); @@ -217,15 +299,29 @@ pub(crate) fn execute_with_resources( } return Ok(None); } - let program = program(&scope, &receiver, budget, memory, poll)?; - let subject = BoundSubject::new( - unsafe { HeapSubject::new(input) } - .map_err(|e| EngineError::Subject(perex::binding::SubjectError::Resource(e)))?, - ) - .map_err(|e| EngineError::Subject(e.error))?; + let fresh_program; + let program = match reuse.and_then(|reuse| reuse.program_for(&receiver)) { + Some(program) => program, + None => { + fresh_program = program(&scope, &receiver, budget, memory, poll)?; + &fresh_program + } + }; + let fresh_subject; + let subject = match reuse.and_then(|reuse| reuse.subject_for(&input)) { + Some(subject) => subject, + None => { + fresh_subject = + BoundSubject::new(unsafe { HeapSubject::new(input) }.map_err(|e| { + EngineError::Subject(perex::binding::SubjectError::Resource(e)) + })?) + .map_err(|e| EngineError::Subject(e.error))?; + &fresh_subject + } + }; let found = host::find( - &program, - &subject, + program, + subject, start, if materialize { CaptureMode::All @@ -252,8 +348,8 @@ pub(crate) fn execute_with_resources( caught(|| { super::perex_results::materialize( &input, - &subject, - &program, + subject, + program, &found, has_indices, budget, diff --git a/crates/perry-runtime/src/regex/perex_dispatch.rs b/crates/perry-runtime/src/regex/perex_dispatch.rs index 14fc0aa6e1..e6f17c5a43 100644 --- a/crates/perry-runtime/src/regex/perex_dispatch.rs +++ b/crates/perry-runtime/src/regex/perex_dispatch.rs @@ -97,6 +97,7 @@ pub(crate) fn call_one( /// RegExpExec with operation-owned limits. Lookup happens on every iteration; /// a callback may replace exec or recompile the receiver before the next one. /// Only the known builtin may omit materialization for a boolean test. +/// `reuse` is consulted only on the builtin path, after the observable lookup. pub(crate) fn execute( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, @@ -104,6 +105,7 @@ pub(crate) fn execute( budget: &mut Budget, memory: &MemoryBudget, poll: &mut impl FnMut() -> Result<(), EngineError>, + reuse: Option<&api::Reuse<'_, '_>>, ) -> Result, EngineError> { host::charge(budget, 1)?; require_object(receiver.get_nanbox_f64())?; @@ -137,7 +139,7 @@ pub(crate) fn execute( // `execute_with_resources` roots both before it allocates. input .with_const_ptr::(|input| { - api::execute_with_resources(re, input, materialize, budget, memory, poll) + api::execute_with_resources(re, input, materialize, budget, memory, poll, reuse) }) .map(|result| result.map(ExecResult::Builtin)) } @@ -318,6 +320,7 @@ pub(crate) fn test_string(receiver: f64, input: *const StringHeader) -> Result Result { let (global, unicode) = match_flags(receiver, budget)?; if !global { - return dispatch::execute(receiver, input, true, budget, memory, &mut host::poll) + return dispatch::execute(receiver, input, true, budget, memory, &mut host::poll, None) .map(|result| result.map_or(f64::from_bits(TAG_NULL), |r| r.object())); } dispatch::set_last_index(receiver, 0.0)?; let scope = RuntimeHandleScope::new(); let array = scope.root_raw_mut_ptr(api::caught(|| crate::array::js_array_alloc(0))?); let subject = subject(*input)?; + let reuse = api::Reuse::new(&scope, receiver, *input, &subject, budget); let length = input.with_const_ptr::(|s| unsafe { (*s).utf16_len as usize }); let mut count = 0u32; loop { // A fresh scope per iteration bounds roots regardless of match count. let iteration = RuntimeHandleScope::new(); - let result = dispatch::execute(receiver, input, false, budget, memory, &mut host::poll)?; + let result = dispatch::execute( + receiver, + input, + false, + budget, + memory, + &mut host::poll, + Some(&reuse), + )?; let Some(result) = result else { return Ok(if count == 0 { f64::from_bits(TAG_NULL) diff --git a/crates/perry-runtime/src/regex/perex_replace.rs b/crates/perry-runtime/src/regex/perex_replace.rs index cc07bcdf1f..303c6fa1c1 100644 --- a/crates/perry-runtime/src/regex/perex_replace.rs +++ b/crates/perry-runtime/src/regex/perex_replace.rs @@ -74,6 +74,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, replacement: f64) -> Result Result Result Result Result Date: Sun, 13 Sep 2026 08:30:18 +0000 Subject: [PATCH 2/7] perf(regex): split searches forward instead of trying every position (#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 --- .../src/gc/tests/runtime_roots/perex_split.rs | 146 ++++++++++++++++++ .../src/object/regex_proto_thunks.rs | 11 ++ .../src/regex/perex_construct.rs | 24 +++ crates/perry-runtime/src/regex/perex_split.rs | 116 +++++++++++++- 4 files changed, 295 insertions(+), 2 deletions(-) diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs index d66dcc84f2..142cec6c40 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs @@ -641,6 +641,7 @@ fn perex_split_species_order_zero_limit_and_empty_input() { let f = function(&scope, throwing as *const u8, 0); getter(&receiver, b"lastIndex", &f); getter(&matcher, b"lastIndex", &f); + let forward_before = forward_splits(); for (number, expected, count) in [(0.0, 123456, 0.0), (1.0, 1234567, 1.0)] { ORDER.with(|o| o.set(0)); put(&lim, b"number", number); @@ -653,6 +654,11 @@ fn perex_split_species_order_zero_limit_and_empty_input() { assert_eq!(get(&out, b"length"), count); assert_eq!(bytes(get(&matcher, b"seenFlags")), b"vy"); } + assert_eq!( + forward_splits(), + forward_before, + "a species factory must keep the per-position sticky loop" + ); } extern "C" fn custom_exec(c: *const crate::closure::ClosureHeader, input: f64) -> f64 { @@ -716,6 +722,7 @@ fn perex_split_custom_exec_capture_values_reentrancy_and_limit_short_circuit() { getter(&result, b"0", &throws); getter(&result, b"index", &throws); put(&capture, b"toString", throws.get_nanbox_f64()); + let forward_before = forward_splits(); let before = input.get_nanbox_f64().to_bits(); let out = scope.root_nanbox_f64(api::finish(split::regexp( re.get_nanbox_f64(), @@ -735,6 +742,11 @@ fn perex_split_custom_exec_capture_values_reentrancy_and_limit_short_circuit() { 1.0, ))); check(&out, &[Some(b"a")]); + assert_eq!( + forward_splits(), + forward_before, + "a custom exec must keep the per-position sticky loop" + ); } extern "C" fn throwing_hook(_: *const crate::closure::ClosureHeader, _: f64, _: f64) -> f64 { @@ -1141,3 +1153,137 @@ fn perex_numeric_arguments_reject_bigint_after_observable_primitive_conversion() Err(crate::regex::perex_runtime::EngineError::Type(_)) )); } + +fn forward_splits() -> usize { + split::FORWARD_SPLITS.with(Cell::get) +} + +/// Split's forward search (#10165) returns exactly the specification's +/// per-position sticky result. Each expectation below was derived by running +/// the sticky algorithm by hand, not by observing either implementation. +#[test] +fn perex_split_forward_search_matches_the_sticky_specification() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + type Parts = &'static [Option<&'static [u8]>]; + let cases: &[(&[u8], &str, &[u8], f64, Parts)] = &[ + // A repeated group keeps its last iteration. + ( + b"a1b22c", + r"(\d)+", + b"", + -1.0, + &[Some(b"a"), Some(b"1"), Some(b"b"), Some(b"2"), Some(b"c")], + ), + // An unmatched group is undefined, and a match at the end leaves "". + (b"ab", r"(x)?b", b"", -1.0, &[Some(b"a"), None, Some(b"")]), + // Empty matches everywhere: every position is stepped past once. + ( + b"abc", + "x*", + b"", + -1.0, + &[Some(b"a"), Some(b"b"), Some(b"c")], + ), + // Empty at 0, a real match at 1, empty again at 2. + (b"abc", "b*", b"", -1.0, &[Some(b"a"), Some(b"c")]), + (b",a,", ",", b"", -1.0, &[Some(b""), Some(b"a"), Some(b"")]), + // `$` matches only at the end, which the sticky loop never tries. + (b"ab", "$", b"", -1.0, &[Some(b"ab")]), + (b"a,b,c", ",", b"", 2.0, &[Some(b"a"), Some(b"b")]), + // The limit can fall inside a match's captures. + ( + b"a1b2c3", + r"(\d)", + b"", + 3.0, + &[Some(b"a"), Some(b"1"), Some(b"b")], + ), + // Unicode mode advances an empty match by a whole code point. + ( + "😀😀".as_bytes(), + "", + b"u", + -1.0, + &[Some(b"\xf0\x9f\x98\x80"), Some(b"\xf0\x9f\x98\x80")], + ), + ( + "ä中12,Ö漢345;ef6😀".as_bytes(), + "[,;😀]+", + b"u", + -1.0, + &[ + Some(b"\xc3\xa4\xe4\xb8\xad12"), + Some(b"\xc3\x96\xe6\xbc\xa2345"), + Some(b"ef6"), + Some(b""), + ], + ), + ]; + for (index, (subject, pattern, flags, limit, expected)) in cases.iter().enumerate() { + let local = RuntimeHandleScope::new(); + let input = text(&local, subject); + let separator = regex(&local, pattern.as_bytes(), flags); + let before = forward_splits(); + let out = run(&local, &input, &separator, *limit); + assert_eq!( + forward_splits(), + before + 1, + "case {index} must take the forward search" + ); + check(&out, expected); + } +} + +/// A user species constructor that builds a genuine RegExp and keeps it where +/// JavaScript can reach it afterwards, as any user factory could. +extern "C" fn recording_regexp_species( + _: *const crate::closure::ClosureHeader, + receiver: f64, + flags: f64, +) -> f64 { + let scope = RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let flags = scope.root_nanbox_f64(flags); + let splitter = scope.root_nanbox_f64(js_nanbox_pointer(crate::regex::js_regexp_construct( + receiver.get_nanbox_f64(), + flags.get_nanbox_f64(), + ) as i64)); + put(&receiver, b"splitter", splitter.get_nanbox_f64()); + splitter.get_nanbox_f64() +} + +/// The species condition is what keeps the forward search unobservable: a user +/// species can return a real RegExp with the builtin exec, which passes every +/// other admission check, and still hold the splitter and read what the +/// per-position loop wrote to it. +#[test] +fn perex_split_user_species_regexp_keeps_the_observable_sticky_loop() { + let _guard = CopyingNurseryTestGuard::new(0); + let _scan = ConservativeScanDisabledGuard::new(); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + let _force = ForcedEvacuationTestGuard::on(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + let re = regex(&scope, b",", b""); + let holder = object(&scope); + let species = function(&scope, recording_regexp_species as *const u8, 2); + symbol(&holder, "species", species.get_nanbox_f64()); + put(&re, b"constructor", holder.get_nanbox_f64()); + let input = text(&scope, b"a,"); + let before = forward_splits(); + let out = run(&scope, &input, &re, -1.0); + check(&out, &[Some(b"a"), Some(b"")]); + assert_eq!( + forward_splits(), + before, + "a user species must keep the per-position sticky loop" + ); + // The sticky loop's last RegExpExec matched "," at 1 and left lastIndex at + // 2; a forward search would never have written it and left 0. + let splitter = scope.root_nanbox_f64(get(&re, b"splitter")); + assert_eq!(get(&splitter, b"lastIndex"), 2.0); +} diff --git a/crates/perry-runtime/src/object/regex_proto_thunks.rs b/crates/perry-runtime/src/object/regex_proto_thunks.rs index 9ffc0ce0c9..1d1203ba95 100644 --- a/crates/perry-runtime/src/object/regex_proto_thunks.rs +++ b/crates/perry-runtime/src/object/regex_proto_thunks.rs @@ -491,6 +491,17 @@ pub(crate) fn regexp_prototype_test_is_canonical(value: f64) -> bool { }) } +#[cfg(feature = "regex-engine")] +/// The intrinsic `RegExp` constructor, recognised the way the class registry +/// recognises it: by its dedicated call thunk. A subclass, a bound function or +/// a proxy has a different function pointer. +pub(crate) fn is_intrinsic_regexp_constructor(value: f64) -> bool { + let closure = + crate::value::js_nanbox_get_pointer(value) as *const crate::closure::ClosureHeader; + crate::closure::get_valid_func_ptr(closure) + == super::global_this::regexp_constructor_call_thunk as *const u8 +} + /// Non-observable admission for a substring view. An exec/test accessor or /// override must run once on the materialized JS argument, so never invoke /// one while deciding whether to take this optimization. diff --git a/crates/perry-runtime/src/regex/perex_construct.rs b/crates/perry-runtime/src/regex/perex_construct.rs index aa7def894b..b13d326e41 100644 --- a/crates/perry-runtime/src/regex/perex_construct.rs +++ b/crates/perry-runtime/src/regex/perex_construct.rs @@ -60,6 +60,30 @@ fn compile<'s>( ) } +/// A program for `re`'s own source and canonical flags with `y` removed, +/// compiled from its internal slots, so no property of `re` is observed. +/// Split's forward search uses it in place of the sticky splitter (#10165). +pub(crate) fn nonsticky_program<'s>( + scope: &'s RuntimeHandleScope, + re: &RuntimeHandle<'_>, +) -> Result, EngineError> { + let (source, flags) = + re.with_const_ptr::(|re| unsafe { ((*re).pattern_ptr, (*re).flags_ptr) }); + if source.is_null() || flags.is_null() { + return Err(EngineError::InvalidFlags); + } + let source = scope.root_string_ptr(source); + let flags = scope.root_string_ptr(flags); + let canonical = unsafe { + flags.with_string_bytes(|bytes| { + let without: Vec = bytes.iter().copied().filter(|&b| b != b'y').collect(); + CanonicalFlags::parse(&without) + }) + } + .ok_or(EngineError::InvalidFlags)?; + compile(scope, source, canonical) +} + unsafe fn publish( receiver: &RuntimeHandle<'_>, source: &RuntimeHandle<'_>, diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index f3ccb13e08..56dc16542a 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -4,14 +4,15 @@ use super::perex_api as api; use super::perex_dispatch as dispatch; use super::perex_match_search::subject; use super::perex_memory::MemoryBudget; +use super::perex_owner::GcProgram; use super::perex_owner::HeapSubject; use super::perex_replace::{callable, index_property}; use super::perex_replace_storage::{boxed, call, length, text, List, Pieces, Units}; -use super::perex_runtime::{self as host, EngineError}; +use super::perex_runtime::{self as host, CaptureMode, EngineError}; use super::perex_strings::SpanCopies; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::value::{js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED}; -use perex::binding::{BoundSubject, SubjectError}; +use perex::binding::{BoundProgram, BoundSubject, SubjectError}; use perex::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -56,6 +57,51 @@ fn advance( Ok(index + 1) } +// Counts forward splits taken, so tests can tell which path ran. +#[cfg(test)] +thread_local! { + pub(crate) static FORWARD_SPLITS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// The program for split's forward search, when it is admissible (#10165). +/// +/// The specification tries a sticky match at every position `q`. 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, so the attempts +/// at `q..s` can be skipped without changing any piece or capture, and empty +/// matches and Unicode advancement line up. The skipped attempts are +/// unobservable only when nothing can see a RegExpExec happen: +/// - the splitter came from the intrinsic `RegExp` (absent or intrinsic +/// species), so it is a fresh object no user code holds, and its skipped +/// `lastIndex` writes cannot be seen; +/// - its `exec` resolves, without running a getter, to the builtin data +/// property, so the skipped `Get(exec)` calls cannot be seen either. +/// +/// The program is compiled from the splitter's own internal source and flags +/// without `y`. Anything else keeps the per-position sticky loop. +fn forward_program<'s>( + scope: &'s RuntimeHandleScope, + constructor: Option<&RuntimeHandle<'_>>, + splitter: &RuntimeHandle<'_>, + budget: &mut Budget, +) -> Option>> { + if constructor.is_some_and(|c| { + !crate::object::regex_proto_thunks::is_intrinsic_regexp_constructor(c.get_nanbox_f64()) + }) { + return None; + } + let value = splitter.get_nanbox_f64(); + let re = crate::value::js_nanbox_get_pointer(value) as *const super::RegExpHeader; + if !super::is_valid_regex_ptr(re) + || !crate::object::regex_proto_thunks::regexp_view_uses_builtin(value) + { + return None; + } + let splitter = scope.root_raw_const_ptr(re); + let program = super::perex_construct::nonsticky_program(scope, &splitter).ok()?; + BoundProgram::new(program, budget).ok() +} + fn push_span( output: &mut List<'_>, copies: &mut SpanCopies<'_, '_>, @@ -141,6 +187,72 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result= size { + break; + } + let end = found.full.end().min(size); + if end == p { + // Only an empty match at `p` itself: step past it, as the + // sticky loop does. + q = advance(&mut units, start, size, unicode, &mut budget)?; + host::poll()?; + continue; + } + push_span(&mut output, &mut copies, p, start, &mut budget)?; + if output.len() == lim { + return Ok(output.value()); + } + p = end; + let count = found.captures.as_ref().map_or(0, |captures| captures.len()); + if count > 1 { + let (array, _) = api::caught(|| { + super::perex_results::materialize( + &input, + &bound, + &forward, + &found, + false, + &mut budget, + &mut host::poll, + ) + })??; + let array = local.root_raw_mut_ptr(array); + for capture in 1..count { + let value = array.with_const_ptr::(|array| { + crate::array::js_array_get_f64(array, capture as u32) + }); + output.push(value, &mut budget)?; + if output.len() == lim { + return Ok(output.value()); + } + } + } + q = p; + host::poll()?; + } + push_span(&mut output, &mut copies, p, size, &mut budget)?; + return Ok(output.value()); + } while q < size { let local = RuntimeHandleScope::new(); dispatch::set_last_index(&splitter, q as f64)?; From f8301013b5862a8ba6c49a2e4c9df63b6370b4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:57:30 +0000 Subject: [PATCH 3/7] changelog: add fragment for #10174 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10174-regex-bind-once-forward-split.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10174-regex-bind-once-forward-split.md diff --git a/changelog.d/10174-regex-bind-once-forward-split.md b/changelog.d/10174-regex-bind-once-forward-split.md new file mode 100644 index 0000000000..d0a641b80c --- /dev/null +++ b/changelog.d/10174-regex-bind-once-forward-split.md @@ -0,0 +1,3 @@ +### Performance + +- **RegExp `split`, `replace` and global `match` bind the subject and program once per operation, and `split` searches forward** (#10165). Each search used to decode the whole string and revalidate the whole program, so these operations did quadratic work: an ASCII `split(/[,; ]+/)` of 150,000 units took 8.1 s. They are now linear. `split` also searches for the next match instead of trying a sticky match at every position, whenever nothing can observe the difference (an absent or intrinsic `RegExp` species and the builtin `exec`), which brings ASCII split to about 13× Node from about 220×. From d61fe859139d23504953fdc5faaa9eae78f58aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:46:33 +0000 Subject: [PATCH 4/7] gc: record the forward-split test counter's holder verdict (#10165) gc_runtime_root_holders.py flags the new #[cfg(test)] FORWARD_SPLITS Cell under rule B. It is a test-only count, never an address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- scripts/gc_runtime_root_holders.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 93387d5fa6..46d9d4c191 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -805,6 +805,12 @@ "scanner": "regex::regex_header_moved_for_gc (called from gc/types.rs on relocation), regex::regex_header_finalize_for_gc (gc/types.rs per-object finalize), regex::finalize_dead_copied_minor_from_space_regexps (gc/copying_phase.rs) and regex::collect_dead_registered_regexps_post_trace / finalize_collected_dead_regexp (gc/oldgen.rs)", "why": "Address-KEYED owner set, not a root, and the successor of REGEX_POINTERS under the single engine: its map is `usize` header address -> `RegexMetadata { registered_owner: bool }`, so the VALUE holds no heap address at all (source and flags live only in the header's traced string edges, and the compiled program is a traced GC child of the header). The key is rekeyed by `regex_header_moved_for_gc` when a RegExpHeader moves and removed on death by the finalize hook, the copying-minor from-space walk and the full-cycle post-trace walk; it never keeps a header alive. Reached from those GC hooks rather than a registered scanner, so the walk misses it." }, + { + "file": "crates/perry-runtime/src/regex/perex_split.rs", + "name": "FORWARD_SPLITS", + "verdict": "test_only", + "why": "#10165: #[cfg(test)] Cell counter of how many splits took the forward-search path, so tests can tell which path ran. It stores only a count and is absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/site_test.rs", "name": "DIRECT_G", From 050ecb71ba4ccbca6ef655dc65f21b7b3e532416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 08:31:17 +0000 Subject: [PATCH 5/7] perf(regex): resume searches and capture reads from the previous position (#10164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Cargo.lock | 4 +- Cargo.toml | 2 +- .../src/gc/tests/runtime_roots/perex_reuse.rs | 104 +++++++++++++++++- .../src/gc/tests/runtime_roots/perex_split.rs | 28 +++++ crates/perry-runtime/src/regex/perex_api.rs | 27 ++++- .../src/regex/perex_match_search.rs | 4 +- .../perry-runtime/src/regex/perex_results.rs | 5 +- .../perry-runtime/src/regex/perex_runtime.rs | 36 +++++- crates/perry-runtime/src/regex/perex_split.rs | 26 ++++- .../perry-runtime/src/regex/perex_strings.rs | 29 ++++- 10 files changed, 243 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 554bbe3def..6e9faa0212 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5684,9 +5684,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perex" -version = "0.1.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b2094dda4d997bf73a372cb660d02e9abdb0a13cbf834ddd2b2f8847bffe2cf" +checksum = "21df239ee18f99de6abff50953f6f15be1b5ebd11e6ae9661acdd93026e983db" [[package]] name = "perry" diff --git a/Cargo.toml b/Cargo.toml index c12fd3abd3..24d94eb23b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -399,7 +399,7 @@ chrono = "0.4" regex = "1.12" aho-corasick = "1.1" # The single regular-expression engine, through crates/perry-perex. -perex = "0.1" +perex = "0.1.2" hex = "0.4" tempfile = "3" itoa = "1.0" diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs index 9050c8c439..e6fdd12f94 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_reuse.rs @@ -48,6 +48,15 @@ fn global_loop( receiver: &RuntimeHandle<'_>, input: &RuntimeHandle<'_>, reuse: Option<&Reuse<'_, '_>>, +) -> (Vec>, usize) { + global_loop_collecting(receiver, input, reuse, true) +} + +fn global_loop_collecting( + receiver: &RuntimeHandle<'_>, + input: &RuntimeHandle<'_>, + reuse: Option<&Reuse<'_, '_>>, + collect: bool, ) -> (Vec>, usize) { let memory = MemoryBudget::new(api::SCRATCH_BYTES); let mut budget = Budget::new(api::WORK); @@ -65,7 +74,9 @@ fn global_loop( &mut budget, &memory, &mut || { - gc_collect_minor(); + if collect { + gc_collect_minor(); + } Ok(()) }, reuse, @@ -130,10 +141,15 @@ fn perex_reuse_serves_a_whole_global_loop_across_moving_collections() { let (fresh_matches, fresh_work) = global_loop(&fresh, &fresh_input, None); assert_eq!(fresh_matches, expected); // Six searches (five matches and the final miss). Binding per search charges - // program validation six times; reuse charged it once, in `setup`. + // program validation six times; reuse charged it once, in `setup`. Reuse + // also resumes each search from the previous one instead of seeking from + // an end of this non-ASCII subject, so it saves at least the validations. let validation = api::WORK - setup.remaining(); assert!(validation > 0); - assert_eq!(fresh_work, reused_work + 6 * validation); + assert!( + fresh_work >= reused_work + 6 * validation, + "reuse must save at least six program validations: fresh {fresh_work}, reused {reused_work}, one validation {validation}" + ); } #[test] @@ -224,3 +240,85 @@ fn perex_reuse_binds_a_different_string_afresh() { .unwrap(); assert_eq!(first_item(&scope, found.array), b"22"); } + +/// Work a global loop over `repeats` copies of a non-ASCII record charges. +fn non_ascii_loop_work(repeats: usize, reuse: bool) -> usize { + let local = RuntimeHandleScope::new(); + let input = text(&local, "ä1 ö22 ".repeat(repeats).as_bytes()); + let receiver = regex(&local, "[a-zäö]+\\d+", "gu"); + let subject = BoundSubject::new(unsafe { HeapSubject::new(input) }.unwrap()).unwrap(); + let mut setup = Budget::new(api::WORK); + let reused = Reuse::new(&local, &receiver, input, &subject, &mut setup); + let (matches, work) = + global_loop_collecting(&receiver, &input, reuse.then_some(&reused), false); + assert_eq!(matches.len(), 2 * repeats); + work +} + +#[test] +fn perex_reuse_positions_keep_a_non_ascii_global_loop_linear() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + // Doubling the subject doubles the searches. Seeking each one from an end + // of the subject makes the total roughly quadruple (#10164); resuming from + // the previous search keeps it roughly double. + let fresh = non_ascii_loop_work(2_000, false) as f64 / non_ascii_loop_work(1_000, false) as f64; + let reused = non_ascii_loop_work(2_000, true) as f64 / non_ascii_loop_work(1_000, true) as f64; + assert!( + fresh > 3.0, + "the unpositioned loop must be quadratic here, got {fresh:.2}x" + ); + assert!( + reused < 2.2, + "the positioned loop must be linear, got {reused:.2}x" + ); +} + +fn nanbox_text(scope: &RuntimeHandleScope, value: &str) -> f64 { + text(scope, value.as_bytes()).with_const_ptr::(|p| js_nanbox_string(p as i64)) +} + +fn utf16_length(value: f64) -> u32 { + let string = crate::value::js_nanbox_get_pointer(value) as *const StringHeader; + unsafe { (*string).utf16_len } +} + +#[test] +fn perex_split_and_replace_no_longer_hit_the_work_limit_on_linear_inputs() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let scope = RuntimeHandleScope::new(); + + // The #10164 reduction: a 32,000-unit split threw RangeError("Regular + // expression work limit exceeded"); Node returns 6,001 pieces. + let subject = "ä中12,Ö漢345;ef6😀".repeat(2_000); + let input = nanbox_text(&scope, &subject); + let input = scope.root_nanbox_f64(input); + assert_eq!(utf16_length(input.get_nanbox_f64()), 32_000); + let re = regex(&scope, "[,;😀]+", "u"); + let pieces = crate::regex::perex_split::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + f64::from_bits(crate::value::TAG_UNDEFINED), + ) + .expect("a 32,000-unit split must not exhaust the work allowance"); + let pieces = crate::value::js_nanbox_get_pointer(pieces) as *const ArrayHeader; + assert_eq!(unsafe { (*pieces).length }, 6_001); + + // A 60,000-unit global replace threw the same error; Node's result wraps + // each of the 8,000 matches in brackets, 76,000 units in all. + let subject = "ä中😀12 Ö漢🦊345;".repeat(4_000); + let input = scope.root_nanbox_f64(nanbox_text(&scope, &subject)); + assert_eq!(utf16_length(input.get_nanbox_f64()), 60_000); + let re = regex(&scope, "[ä中😀Ö漢🦊]+", "gu"); + let template = scope.root_nanbox_f64(nanbox_text(&scope, "[$&]")); + let replaced = crate::regex::perex_replace::regexp( + re.get_nanbox_f64(), + input.get_nanbox_f64(), + template.get_nanbox_f64(), + ) + .expect("a 60,000-unit global replace must not exhaust the work allowance"); + assert_eq!(utf16_length(replaced), 76_000); +} diff --git a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs index 142cec6c40..8c4c192ea1 100644 --- a/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs +++ b/crates/perry-runtime/src/gc/tests/runtime_roots/perex_split.rs @@ -1287,3 +1287,31 @@ fn perex_split_user_species_regexp_keeps_the_observable_sticky_loop() { let splitter = scope.root_nanbox_f64(get(&re, b"splitter")); assert_eq!(get(&splitter, b"lastIndex"), 2.0); } + +/// Work one forward split of `repeats` non-ASCII records charges. +fn forward_split_work(repeats: usize) -> usize { + let scope = RuntimeHandleScope::new(); + let input = text(&scope, "ä中12,Ö漢345;ef6😀".repeat(repeats).as_bytes()); + let re = regex(&scope, "[,;😀]+".as_bytes(), b"u"); + let before = forward_splits(); + let out = run(&scope, &input, &re, -1.0); + assert_eq!(forward_splits(), before + 1, "the forward search must run"); + // Three pieces per record and the empty piece after the final emoji. + assert_eq!(get(&out, b"length"), (3 * repeats + 1) as f64); + split::LAST_FORWARD_WORK.with(Cell::get) +} + +/// On non-ASCII storage a search that seeks from an end of the subject makes a +/// loop of them quadratic (#10164). Resuming each from the previous one keeps +/// the forward split's work proportional to the input. +#[test] +fn perex_split_forward_search_resumes_each_search_on_non_ascii_input() { + let _guard = CopyingNurseryTestGuard::new(0); + let _triggers = GcTriggerThresholdTestGuard::suppress_automatic_triggers(); + super::perex_public::register_host_roots(); + let ratio = forward_split_work(2_000) as f64 / forward_split_work(1_000) as f64; + assert!( + ratio < 2.3, + "doubling the input must roughly double the work, got {ratio:.2}x" + ); +} diff --git a/crates/perry-runtime/src/regex/perex_api.rs b/crates/perry-runtime/src/regex/perex_api.rs index 900e603280..24105efc28 100644 --- a/crates/perry-runtime/src/regex/perex_api.rs +++ b/crates/perry-runtime/src/regex/perex_api.rs @@ -9,6 +9,7 @@ use crate::string::StringHeader; use perex::binding::{BoundProgram, BoundSubject}; use perex::compiler::CompileError; use perex::executor::ExecError; +use perex::input::Position; use perex::{span::Span, Budget}; // One explicit host policy; no retained scratch cache or alternate engine. @@ -112,10 +113,15 @@ pub(crate) fn program<'s>( /// the same string, and the same receiver still holding the same program cell. /// Anything else (an `exec` override, a recompiled receiver, another string) /// binds afresh for that search exactly as before. +/// +/// `near` is where the previous search over the reused subject stood (#10164), +/// so the next search seeks from there instead of from an end of the subject. +/// It is only ever set from, and only ever used with, the reused binding. pub(crate) struct Reuse<'b, 's> { input: RuntimeHandle<'s>, subject: &'b BoundSubject>, program: Option>, + near: std::cell::Cell>, } struct ReusedProgram<'s> { @@ -157,9 +163,15 @@ impl<'b, 's> Reuse<'b, 's> { input, subject, program, + near: std::cell::Cell::new(None), } } + /// Where the last search over the reused subject stood, if any. + pub(crate) fn near(&self) -> Option { + self.near.get() + } + fn subject_for(&self, input: &RuntimeHandle<'_>) -> Option<&BoundSubject>> { let current = input.with_const_ptr::(|p| p); let bound = self.input.with_const_ptr::(|p| p); @@ -308,7 +320,12 @@ pub(crate) fn execute_with_resources( } }; let fresh_subject; - let subject = match reuse.and_then(|reuse| reuse.subject_for(&input)) { + let reused_subject = reuse.and_then(|reuse| reuse.subject_for(&input)); + // A position is valid only on the binding it came from. + let near = reuse + .filter(|_| reused_subject.is_some()) + .and_then(|reuse| reuse.near()); + let subject = match reused_subject { Some(subject) => subject, None => { fresh_subject = @@ -319,10 +336,11 @@ pub(crate) fn execute_with_resources( &fresh_subject } }; - let found = host::find( + let (found, position) = host::find_near( program, subject, start, + near, if materialize { CaptureMode::All } else { @@ -333,6 +351,9 @@ pub(crate) fn execute_with_resources( QUANTUM, poll, )?; + if let (Some(reuse), Some(_)) = (reuse, reused_subject) { + reuse.near.set(Some(position)); + } if stateful { let next = found.as_ref().map_or(0, |m| m.full.end()); caught(|| { @@ -351,6 +372,8 @@ pub(crate) fn execute_with_resources( subject, program, &found, + // From the search that just ran over this same binding. + Some(position), has_indices, budget, poll, diff --git a/crates/perry-runtime/src/regex/perex_match_search.rs b/crates/perry-runtime/src/regex/perex_match_search.rs index 0ef7af35ef..c09a371025 100644 --- a/crates/perry-runtime/src/regex/perex_match_search.rs +++ b/crates/perry-runtime/src/regex/perex_match_search.rs @@ -223,9 +223,11 @@ fn matches( }; let string = match result { dispatch::ExecResult::Builtin(found) => api::caught(|| { - super::perex_strings::copy_span( + super::perex_strings::copy_span_near( &subject, found.full, + // `reuse` binds this same `subject`. + reuse.near(), budget, api::OUTPUT_BYTES, api::QUANTUM, diff --git a/crates/perry-runtime/src/regex/perex_results.rs b/crates/perry-runtime/src/regex/perex_results.rs index 142aceecbc..4590b95318 100644 --- a/crates/perry-runtime/src/regex/perex_results.rs +++ b/crates/perry-runtime/src/regex/perex_results.rs @@ -3,7 +3,7 @@ use super::perex_api::{OUTPUT_BYTES, QUANTUM}; use super::perex_owner::{GcProgram, HeapSubject}; use super::perex_runtime::{self as host, EngineError, Match}; -use super::perex_strings::{copy_name, copy_span}; +use super::perex_strings::{copy_name, copy_span_near}; use crate::array::ArrayHeader; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::object::ObjectHeader; @@ -21,6 +21,7 @@ pub(super) fn materialize( subject: &BoundSubject>, program: &BoundProgram>, found: &Match<'_>, + near: Option, has_indices: bool, budget: &mut Budget, poll: &mut impl FnMut() -> Result<(), EngineError>, @@ -34,7 +35,7 @@ pub(super) fn materialize( }); for (index, capture) in captures.iter().enumerate() { let value = if let Some(span) = capture { - let text = copy_span(subject, *span, budget, OUTPUT_BYTES, QUANTUM, poll)?; + let text = copy_span_near(subject, *span, near, budget, OUTPUT_BYTES, QUANTUM, poll)?; crate::value::js_nanbox_string(text as i64).to_bits() } else { crate::value::TAG_UNDEFINED diff --git a/crates/perry-runtime/src/regex/perex_runtime.rs b/crates/perry-runtime/src/regex/perex_runtime.rs index cb71897144..802b2c98b8 100644 --- a/crates/perry-runtime/src/regex/perex_runtime.rs +++ b/crates/perry-runtime/src/regex/perex_runtime.rs @@ -14,6 +14,7 @@ use perex::executor::{ ExecError, Frame, Progress, Scratch, ScratchOwner, ScratchRequirements, Search, SearchError, Undo, }; +use perex::input::Position; use perex::span::Span; use perex::Budget; @@ -164,6 +165,31 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( quantum: usize, poll: &mut impl FnMut() -> Result<(), EngineError>, ) -> Result>, EngineError> { + find_near( + program, subject, start, None, mode, budget, memory, quantum, poll, + ) + .map(|(found, _)| found) +} + +/// `find`, seeking to `start` from `near` when that is closer than either end +/// of the subject, and returning where the search stood: the match's end, or +/// the start of its last attempt (#10164). On non-ASCII storage a search from +/// an end costs up to half the subject, so a loop of them is quadratic. +/// +/// `near` must come from a search or reader over this same binding. Another +/// string with an identical layout cannot be detected and would give wrong +/// answers, so callers keep a position only as long as the binding it came from. +pub(crate) fn find_near<'mem, S: ImmutableSubject>( + program: &BoundProgram>, + subject: &BoundSubject, + start: usize, + near: Option, + mode: CaptureMode, + budget: &mut Budget, + memory: &'mem MemoryBudget, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<(Option>, Position), EngineError> { if quantum == 0 { return Err(EngineError::InvalidQuantum); } @@ -178,14 +204,18 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( }; poll()?; let buffers = MatchBuffers::new(memory, size)?; - let mut search = Search::new(&resources, start, buffers, *budget).map_err(search_error)?; + let mut search = match near { + Some(near) => Search::new_near(&resources, start, near, buffers, *budget), + None => Search::new(&resources, start, buffers, *budget), + } + .map_err(search_error)?; loop { let result = search.advance(quantum); // Preserve consumed work even when the following poll cancels/throws, // allocation fails, or a scratch replacement cannot fit the cap. *budget = Budget::new(search.remaining_work()); match result { - Ok(Progress::NoMatch) => return Ok(None), + Ok(Progress::NoMatch) => return Ok((None, search.position())), Ok(Progress::Matched) => { let full = search .capture(0) @@ -202,7 +232,7 @@ pub(crate) fn find<'mem, S: ImmutableSubject>( Some(output) } }; - return Ok(Some(Match { full, captures })); + return Ok((Some(Match { full, captures }), search.position())); } Ok(Progress::Pending) => poll()?, Err(SearchError::Execution(ExecError::Frames | ExecError::Undo)) => { diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index 56dc16542a..7dfd71645e 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -13,6 +13,7 @@ use super::perex_strings::SpanCopies; use crate::gc::{RuntimeHandle, RuntimeHandleScope}; use crate::value::{js_nanbox_pointer, js_nanbox_string, TAG_NULL, TAG_UNDEFINED}; use perex::binding::{BoundProgram, BoundSubject, SubjectError}; +use perex::input::Position; use perex::Budget; /// Literal String operations also accept Perry's raw Buffer/FFI payloads. @@ -57,10 +58,12 @@ fn advance( Ok(index + 1) } -// Counts forward splits taken, so tests can tell which path ran. +// Counts forward splits taken, and the work the last one charged, so tests can +// tell which path ran and how its cost scales. #[cfg(test)] thread_local! { pub(crate) static FORWARD_SPLITS: std::cell::Cell = const { std::cell::Cell::new(0) }; + pub(crate) static LAST_FORWARD_WORK: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// The program for split's forward search, when it is admissible (#10165). @@ -190,19 +193,29 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result = None; while q < size { let local = RuntimeHandleScope::new(); - let Some(found) = host::find( + let (found, position) = host::find_near( &forward, &bound, q, + near, CaptureMode::All, &mut budget, &memory, api::QUANTUM, &mut host::poll, - )? - else { + )?; + near = Some(position); + let Some(found) = found else { break; }; let start = found.full.start(); @@ -220,6 +233,7 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result Result Result Result Result<(), EngineError>, ) -> Result<*mut StringHeader, EngineError> { - let mut readers = [ - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - BoundSpan::new(subject, span).map_err(|e| read_error(e, |never| match never {}))?, - ]; + copy_span_near(subject, span, None, budget, max_output_bytes, quantum, poll) +} + +/// `copy_span`, with both reader passes seeking to the span from `near` when +/// that is closer than either end. Materializing a match's captures from its +/// search's position seeks back by at most the match length (#10164). `near` +/// has the same same-binding requirement as `perex_runtime::find_near`. +pub(crate) fn copy_span_near( + subject: &BoundSubject>, + span: Span, + near: Option, + budget: &mut Budget, + max_output_bytes: usize, + quantum: usize, + poll: &mut impl FnMut() -> Result<(), EngineError>, +) -> Result<*mut StringHeader, EngineError> { + let reader = || { + match near { + Some(near) => BoundSpan::new_near(subject, span, near), + None => BoundSpan::new(subject, span), + } + .map_err(|e| read_error(e, |never| match never {})) + }; + let mut readers = [reader()?, reader()?]; copy_units( Some(span.len()), budget, From dab9993471310e255c040f3333acf92512b43121 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 09:57:30 +0000 Subject: [PATCH 6/7] changelog: add fragment for #10181 Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- changelog.d/10181-regex-resume-from-position.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog.d/10181-regex-resume-from-position.md diff --git a/changelog.d/10181-regex-resume-from-position.md b/changelog.d/10181-regex-resume-from-position.md new file mode 100644 index 0000000000..30f275d619 --- /dev/null +++ b/changelog.d/10181-regex-resume-from-position.md @@ -0,0 +1,3 @@ +### Performance + +- **Non-ASCII RegExp `split`, `replace` and global `match` resume each search from where the previous one stopped** (#10164). On non-ASCII strings every search sought its start from an end of the string, which made these operations quadratic; they are now linear (log-log slope 1.02–1.05, from about 1.8). Capture strings are read from the match's position the same way. Requires `perex` 0.1.2. From d5d19743245390093b97737969a436394eb9d36c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 13 Sep 2026 10:46:41 +0000 Subject: [PATCH 7/7] gc: record the forward-split work counter's holder verdict (#10164) gc_runtime_root_holders.py flags the new #[cfg(test)] LAST_FORWARD_WORK Cell under rule B. It is a test-only work count, never an address. Claude-Session: https://claude.ai/code/session_01Da12JXeG5XuVBma5yWp5C9 --- scripts/gc_runtime_root_holders.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/gc_runtime_root_holders.json b/scripts/gc_runtime_root_holders.json index 46d9d4c191..997ca6399a 100644 --- a/scripts/gc_runtime_root_holders.json +++ b/scripts/gc_runtime_root_holders.json @@ -811,6 +811,12 @@ "verdict": "test_only", "why": "#10165: #[cfg(test)] Cell counter of how many splits took the forward-search path, so tests can tell which path ran. It stores only a count and is absent from shipped binaries." }, + { + "file": "crates/perry-runtime/src/regex/perex_split.rs", + "name": "LAST_FORWARD_WORK", + "verdict": "test_only", + "why": "#10164: #[cfg(test)] Cell holding the Perex work units the last forward split charged, so tests can assert its cost scales linearly. A quantity, never an address, and absent from shipped binaries." + }, { "file": "crates/perry-runtime/src/regex/site_test.rs", "name": "DIRECT_G",