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/4] 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/4] 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/4] 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/4] 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",