From 437fe12dfd2e8357170fd5a724551f2ae491ed25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 21 Sep 2026 16:20:31 +0000 Subject: [PATCH] perf(string): scan for ASCII a word at a time, and memchr a one-byte split Two per-call costs in `js_string_split_n`, both measured on #10519's own reproduction -- a 211-byte JWT split on "." -- after the engine path stopped answering plain-string splits. The source is scanned once per call to decide whether every part can take the all-ASCII metadata shortcut. That scan was `bytes.iter().all(|&b| b < 0x80)`, one byte per iteration; `perf annotate` put about 74% of this function's own time in its six-instruction loop, roughly 19% of the whole call. `is_ascii()` is the same predicate and std tests a word at a time. The parts are then found with `str::split(&str)`, which takes the two-way `StrSearcher` and pays its setup in full for a single-character needle -- the common `split(".")`, `split(",")`, `split(" ")` shape. A one-byte delimiter takes std's memchr-based searcher instead. The `byte < 0x80` guard on that second path is load-bearing rather than decorative: string storage can hold malformed bytes (the comment above the ASCII scan documents `[0x80, b'|', 0xF0]`), and `0x80 as char` is U+0080, which re-encodes as two bytes and would split on the wrong occurrences. JWT split on ".": 5,424 -> 4,268 instructions per call, -21.3% Against #10519's 345x and ~230k instructions, that row is now 9.2x Node 26.5.1. Split conformance is unchanged: the 27 cases where a byte scan and a UTF-16 unit scan can disagree, and the 12 non-string separator forms, all still match Node. --- crates/perry-runtime/src/string/split.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/perry-runtime/src/string/split.rs b/crates/perry-runtime/src/string/split.rs index f9c53eaea9..8c995e142b 100644 --- a/crates/perry-runtime/src/string/split.rs +++ b/crates/perry-runtime/src/string/split.rs @@ -360,7 +360,10 @@ pub extern "C" fn js_string_split_n( let (src_all_ascii, src_has_lone_surrogates) = unsafe { let bytes = slice::from_raw_parts(string_data(s), (*s).byte_len as usize); ( - bytes.iter().all(|&b| b < 0x80), + // `is_ascii` is the same predicate as `all(|b| b < 0x80)`, but std + // tests a word at a time. The byte-at-a-time form was ~74% of this + // function's own time splitting a 211-byte JWT on "." (#10519). + bytes.is_ascii(), (*s).flags & STRING_FLAG_HAS_LONE_SURROGATES != 0, ) }; @@ -488,10 +491,19 @@ pub extern "C" fn js_string_split_n( // stale address is the #5062 class. Offsets stay valid across a move; the // source address is re-read from a rooted handle on every iteration. let src_base = str_data.as_ptr() as usize; - let mut part_ranges: Vec<(usize, usize)> = str_data - .split(delim) - .map(|part| (part.as_ptr() as usize - src_base, part.len())) - .collect(); + let range = |part: &str| (part.as_ptr() as usize - src_base, part.len()); + // A one-byte delimiter is ASCII (WTF-8 spells every non-ASCII unit, lone + // surrogates included, in two or more bytes), so a `char` pattern names + // exactly the same occurrences as the `&str` one. It takes std's + // memchr-based searcher instead of the two-way `StrSearcher`, whose + // per-call setup is paid in full for a single-character needle -- the + // common `split(".")`, `split(",")`, `split(" ")` shape (#10519). + let mut part_ranges: Vec<(usize, usize)> = match delim.as_bytes() { + // `byte < 0x80` is not implied: storage can hold malformed bytes, and + // `0x80 as char` is U+0080, which is two bytes wide. + &[byte] if byte < 0x80 => str_data.split(byte as char).map(range).collect(), + _ => str_data.split(delim).map(range).collect(), + }; if limit > 0 && (part_ranges.len() as i64) > (limit as i64) { part_ranges.truncate(limit as usize); }