From 222bab2f5db1683ef5cba954153c23a98ba118ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 20 Sep 2026 13:40:49 +0000 Subject: [PATCH] perf(regex): answer a plain-string split without the engine Linking `regex-engine` replaces `String.prototype.split` wholesale (see `string::mod`), so a program that uses a regex anywhere ran every split through the engine's per-UTF-16-unit subject reader -- even `"a b".split(" ")`, where the engine has nothing to contribute. Measured on one source compiled twice: split(" "), engine linked 37,558 instructions split(" "), engine absent 3,662 node 26.5.1 2,714 Both arms auto-optimized, so that is the implementation swap rather than the build mode; specialization accounts for about 5% of it. Roughly 48% of the engine path is `Units::at`, `BoundSpan::retarget`, `Cursor::next_unit` and `copy_units`. The plain algorithm now answers the call when it provably agrees, below the `@@split` check so a custom splitter still wins. Three input classes are excluded rather than repaired, because the two implementations genuinely differ on them: * a separator holding a lone surrogate, which the engine matches against one half of a valid pair and a WTF-8 byte scan cannot; * a separator that is not already a string, whose ToString can run user code or throw -- a Symbol must raise TypeError; * a `limit` that is not already a number or undefined, whose ToNumber can throw. Everything excluded takes the engine path, so this only narrows what the fast path answers. The plain algorithm also reports failure by throwing where this module returns Err, so the call is wrapped in `api::caught`. split(" "): 37,558 -> 5,081 instructions, -86.5%, 13.84x -> 1.87x node Answers are identical to Node on 27 cases where a byte scan and a unit scan can disagree -- empty separator, separator longer than the subject, every `limit` form, lone surrogates, an astral pair split by units, a separator that is a prefix of itself at the tail -- and on 12 non-string separator forms including `@@split` callable and not callable. Claude-Session: https://claude.ai/code/session_018M47oWitg2Hf1jzfhLqgQ9 --- crates/perry-runtime/src/regex/perex_split.rs | 89 +++++++++++++++++++ crates/perry-runtime/src/string/mod.rs | 4 + crates/perry-runtime/src/string/split.rs | 18 ++-- 3 files changed, 101 insertions(+), 10 deletions(-) diff --git a/crates/perry-runtime/src/regex/perex_split.rs b/crates/perry-runtime/src/regex/perex_split.rs index 7dfd71645e..053897f191 100644 --- a/crates/perry-runtime/src/regex/perex_split.rs +++ b/crates/perry-runtime/src/regex/perex_split.rs @@ -319,6 +319,45 @@ pub(crate) fn regexp(receiver: f64, argument: f64, limit_value: f64) -> Result) -> bool { + let bits = limit_value.get_nanbox_f64().to_bits(); + bits == TAG_UNDEFINED || crate::value::JSValue::from_bits(bits).is_number() +} + +/// Is this separator already a string with no lone surrogate in it? +/// +/// The plain algorithm scans WTF-8 bytes where the engine reads UTF-16 units, +/// so it cannot match a separator that is one half of a valid pair -- +/// `"\u{1F600}\u{1F600}".split(lowHalf)` is three parts to the engine and one +/// to a byte scan. WTF-8 spells a surrogate `ED A0..BF xx`, so this test is +/// exact rather than conservative. A separator that is not already a string is +/// excluded too: its `ToString` can run user code, and a Symbol must throw. +fn separator_is_plain( + scope: &RuntimeHandleScope, + separator: &RuntimeHandle<'_>, +) -> Result { + let jv = crate::value::JSValue::from_bits(separator.get_nanbox_f64().to_bits()); + if !jv.is_string() && !jv.is_short_string() { + return Ok(false); + } + // Already a string, so this coercion runs no user code; it only puts the + // value in the one representation whose bytes can be read. + let sep = text(scope, separator)?; + // SAFETY: a rooted string handle; the borrow spans no allocation or call. + Ok(unsafe { + sep.with_string_bytes(|bytes| { + !bytes + .windows(2) + .any(|w| w[0] == 0xED && (0xA0..=0xBF).contains(&w[1])) + }) + }) +} + pub(crate) fn string(receiver: f64, separator: f64, limit_value: f64) -> Result { if matches!(receiver.to_bits(), TAG_NULL | TAG_UNDEFINED) { return Err(EngineError::Type( @@ -343,6 +382,56 @@ pub(crate) fn string(receiver: f64, separator: f64, limit_value: f64) -> Result< return call(&method, &separator, &args, &memory); } } + // No `@@split`, so the plain string algorithm applies and the engine has + // nothing to contribute. Hand it to the implementation a build without the + // engine uses. + // + // Linking `regex-engine` replaces `String.prototype.split` with this module + // wholesale, so a program using a regex *anywhere* ran every split through + // the engine's per-unit subject reader: 35,826 instructions for + // `"alpha beta gamma delta eps0".split(" ")` against 3,662 without the + // engine, and 2,729 in Node 26.5.1. Both arms auto-optimized, so that is the + // implementation swap rather than the build mode. + // + // The plain algorithm agrees with Node on 27 cases where a byte scan and a + // UTF-16 unit scan can disagree -- empty separator, separator longer than + // the subject, every `limit` form, lone surrogates, an astral pair split by + // units, a separator that is a prefix of itself at the tail -- and on every + // non-string separator form. The one thing it does not implement is + // `@@split`, which is why this sits below that check. + // The two implementations report failure differently: this one returns + // `Err(EngineError)` for `api::finish` to raise at the ABI boundary, while + // the plain algorithm throws directly (its own boundary is the ABI). A + // coercion that throws -- `ToNumber` on a BigInt `limit`, say -- would + // otherwise escape as an uncaught exception, so the throw is captured here + // and re-raised by `finish` like any other engine error. + if limit_is_plain(&limit_value) && separator_is_plain(&scope, &separator)? { + // The plain algorithm reports failure by throwing, where this one + // returns `Err` for `api::finish` to raise; `delegable` has already + // excluded every input whose coercion can throw, so nothing escapes. + return api::caught(|| { + crate::string::js_string_split_plain( + receiver.get_nanbox_f64(), + separator.get_nanbox_f64(), + limit_value.get_nanbox_f64(), + ) + }); + } + string_via_engine( + receiver.get_nanbox_f64(), + separator.get_nanbox_f64(), + limit_value.get_nanbox_f64(), + ) +} + +/// The engine's split, for inputs `delegable` excludes. +fn string_via_engine(receiver: f64, separator: f64, limit_value: f64) -> Result { + let scope = RuntimeHandleScope::new(); + let receiver = scope.root_nanbox_f64(receiver); + let separator = scope.root_nanbox_f64(separator); + let limit_value = scope.root_nanbox_f64(limit_value); + let mut budget = Budget::new(api::WORK); + let memory = MemoryBudget::new(api::SCRATCH_BYTES); let input = text(&scope, &receiver)?; let lim = limit(&limit_value)?; let needle = text(&scope, &separator)?; diff --git a/crates/perry-runtime/src/string/mod.rs b/crates/perry-runtime/src/string/mod.rs index 858dde3518..6762d72506 100644 --- a/crates/perry-runtime/src/string/mod.rs +++ b/crates/perry-runtime/src/string/mod.rs @@ -247,6 +247,10 @@ pub use slice_ops::{ js_string_trim_start, }; pub use split::js_string_split; +/// The engine-free `String.prototype.split`, for the engine path to delegate to +/// once it has ruled out `@@split`. +#[cfg(feature = "regex-engine")] +pub(crate) use split::js_string_split_js as js_string_split_plain; #[cfg(not(feature = "regex-engine"))] pub use split::{js_string_split_js, js_string_split_n}; diff --git a/crates/perry-runtime/src/string/split.rs b/crates/perry-runtime/src/string/split.rs index 4bce6e71e5..389aa16c3a 100644 --- a/crates/perry-runtime/src/string/split.rs +++ b/crates/perry-runtime/src/string/split.rs @@ -9,7 +9,6 @@ use crate::array::ArrayHeader; /// a per-element layout-map update. The write barrier remains necessary if a /// collection has promoted the rooted result array while it is being built. #[inline] -#[cfg(not(feature = "regex-engine"))] unsafe fn store_split_string(arr: *mut ArrayHeader, index: usize, string: *mut StringHeader) { const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -298,8 +297,12 @@ pub extern "C" fn js_string_to_upper_case_split_part_utf16_length( /// `limit < 0` → no limit (matches `js_string_split`). /// `limit == 0` → empty array. /// `limit > 0` → at most `limit` substrings. -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +// Compiled in both builds. Linking `regex-engine` re-exports the engine's +// `split` from `crate::string` instead of this one (see `string::mod`), but the +// engine path delegates back here once it has ruled out `@@split`, so the +// implementation must exist either way. Only the exported C symbols are +// conditional -- they would collide with the engine's. +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_n( s: *const StringHeader, delimiter: *const StringHeader, @@ -539,7 +542,6 @@ pub extern "C" fn js_string_split_n( /// `ToUint32(ToNumber(value))` (ECMA-262 §7.1.7). Runs the full `ToNumber` /// (so a boxed `{ valueOf }` / `{ toString }` argument is coerced and may /// throw), then reduces mod 2^32. `NaN`/`±Infinity`/`0` → 0. -#[cfg(not(feature = "regex-engine"))] fn split_limit_to_uint32(boxed: f64) -> u32 { let n = crate::builtins::js_number_coerce(boxed); if !n.is_finite() || n == 0.0 { @@ -550,7 +552,6 @@ fn split_limit_to_uint32(boxed: f64) -> u32 { /// Build the single-element array `[S]` (the `separator === undefined` result /// of `String.prototype.split`). -#[cfg(not(feature = "regex-engine"))] fn split_single_element(s: *const StringHeader) -> *mut ArrayHeader { const STRING_TAG: u64 = 0x7FFF_0000_0000_0000; const POINTER_MASK: u64 = 0x0000_FFFF_FFFF_FFFF; @@ -579,15 +580,13 @@ fn split_single_element(s: *const StringHeader) -> *mut ArrayHeader { /// - `limit === 0` ⇒ empty array; /// - `separator === undefined` ⇒ single-element `[S]`; /// - otherwise split by `ToString(separator)`, capped at `lim`. -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_value( s: *const StringHeader, separator: f64, limit: f64, ) -> *mut ArrayHeader { use crate::value::JSValue; - #[cfg(feature = "regex-engine")] let sep_jv = JSValue::from_bits(separator.to_bits()); let lim_jv = JSValue::from_bits(limit.to_bits()); let scope = crate::gc::RuntimeHandleScope::new(); @@ -661,8 +660,7 @@ pub extern "C" fn js_string_split_value( js_string_split_n(s, r_str, limit_i32) } -#[cfg(not(feature = "regex-engine"))] -#[no_mangle] +#[cfg_attr(not(feature = "regex-engine"), no_mangle)] pub extern "C" fn js_string_split_js(receiver: f64, separator: f64, limit: f64) -> f64 { let scope = crate::gc::RuntimeHandleScope::new(); let receiver = scope.root_nanbox_f64(receiver);