From 5dea1c734a94a6a7ea3089d957649518d35d64cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sat, 5 Sep 2026 00:28:32 +0200 Subject: [PATCH] perf(regex): content-keyed construction cache, header-authoritative program lookups, find-only global test RegExp construction hashed and copied the pattern text on every evaluation of a literal (three copies + one SipHash in js_regexp_new, three more probes in the first lazy build), and lookup_fancy_regex / lookup_repeat_matcher fell through to a full clone + hash of the pattern on EVERY exec of an ordinary pattern. On the claude-code TUI, whose layout pass evaluates emoji-regex / ansi-regex literals per text segment, SipHash over pattern text was 31 % of the main thread in the 20 s after a 400-char reply (regex 38 % inclusive). * regex/site_cache.rs: thread-local, content-fingerprinted (len + three 8-byte windows + flags) and byte-verified construction cache. A hit skips validation, shares the owned pattern/flags as Arc, and installs the programs the first executed header compiled, so the header is born built. Kill switch PERRY_REGEX_SITE_CACHE=0. * lookup_fancy_regex / lookup_repeat_matcher: a built header is authoritative (null program pointer = no fallback); no per-exec cache probe. * js_regexp_test on a global/sticky receiver uses regexp_find_advancing, the find-only twin of exec's engine phase (same engine order, lastIndex advance/reset, sticky anchoring) instead of materializing an exec array. * REGEX_SOURCE_TABLE holds (Arc, Arc); the address-keyed regex tables use the pointer hasher. * hot_diag.rs: PERRY_REGEX_DIAG= and PERRY_IC_DIAG= counters (periodic snapshots; diagnostic only). Tests: site_cache_reconstruction_is_born_built, global_test_advances_and_resets_last_index. Claude-Session: https://claude.ai/code/session_01YPfnmWZmSpSWpmnoXvH8z2 --- changelog.d/keystroke-regex-site-cache.md | 43 ++ crates/perry-runtime/src/hot_diag.rs | 489 ++++++++++++++++++ crates/perry-runtime/src/lib.rs | 1 + .../src/object/field_get_set/ic_miss.rs | 81 +++ crates/perry-runtime/src/regex.rs | 148 +++++- crates/perry-runtime/src/regex/compile.rs | 6 +- crates/perry-runtime/src/regex/exec.rs | 82 +++ crates/perry-runtime/src/regex/exec_array.rs | 11 + crates/perry-runtime/src/regex/lazy.rs | 66 ++- .../perry-runtime/src/regex/match_string.rs | 3 + .../perry-runtime/src/regex/repeat_matcher.rs | 3 +- crates/perry-runtime/src/regex/site_cache.rs | 258 +++++++++ crates/perry-runtime/src/regex/tests.rs | 109 ++++ 13 files changed, 1259 insertions(+), 41 deletions(-) create mode 100644 changelog.d/keystroke-regex-site-cache.md create mode 100644 crates/perry-runtime/src/hot_diag.rs create mode 100644 crates/perry-runtime/src/regex/site_cache.rs diff --git a/changelog.d/keystroke-regex-site-cache.md b/changelog.d/keystroke-regex-site-cache.md new file mode 100644 index 0000000000..0815906d5a --- /dev/null +++ b/changelog.d/keystroke-regex-site-cache.md @@ -0,0 +1,43 @@ +### Performance + +- **RegExp construction and exec no longer hash or copy the pattern text.** + On the claude-code TUI a keystroke re-runs ink's layout, whose text + measurement (`string-width` / `emoji-regex` / `ansi-regex`) evaluates a + regex literal per text segment — `emojiRegex()` is a fresh ~12 KB `/…/g` + per call. Each `js_regexp_new` copied that pattern three times and + SipHashed it once (the `VALIDATED_PATTERNS` probe key, `owned_pattern`, + the `REGEX_SOURCE_TABLE` entry); the first operation on each header did + the same three more times in `build_and_install_programs`; and, for the + common pattern with no fancy fallback, `lookup_fancy_regex` and + `lookup_repeat_matcher` fell through to a full clone + hash of the pattern + on EVERY exec. SipHash over pattern text was 31 % of the main thread in + the 20 s after a 400-char reply had rendered (regex 38 % inclusive). + + - `crates/perry-runtime/src/regex/site_cache.rs` (new) — a thread-local, + content-keyed construction cache: a cheap fingerprint (length, three + 8-byte windows, canonical flags) plus a full byte compare, so identity + never depends on an address. A hit skips validation (validity is a pure + function of the pair), shares the owned pattern/flags as `Arc`, and + installs the programs the first executed header compiled — the new + header is born built and never touches the `(pattern, flags)` caches. + Kill switch `PERRY_REGEX_SITE_CACHE=0`. + - `regex.rs` — `lookup_fancy_regex` / `lookup_repeat_matcher` treat a + built header as authoritative (a null program pointer after the build + IS the answer; every install path publishes all three together), so no + per-exec cache probe remains. `REGEX_SOURCE_TABLE` holds `Arc` + pairs; the two address-keyed regex tables use the pointer hasher. + - `regex/exec.rs` — `test` on a global/sticky receiver runs + `regexp_find_advancing`, the find-only twin of `exec`'s engine phase + (same engine order, `lastIndex` advance/reset and sticky anchoring), + instead of materializing a captures array plus one string per capture + that it then discarded. + - `hot_diag.rs` (new) — `PERRY_REGEX_DIAG=` (constructions, + validated/site hits, pattern bytes, compiles, cache clears, lazy builds, + exec/test/match/replace counts, capture bytes, per-pattern table) and + `PERRY_IC_DIAG=` (property-read IC misses by reason and by site). + Snapshots every ~1 s of activity; diagnostic only. + + Tests: `site_cache_reconstruction_is_born_built` (fails without the + cache) and `global_test_advances_and_resets_last_index` (every + `lastIndex` branch of the find-only path, all three engines, UTF-16 + units) in `regex/tests.rs`. diff --git a/crates/perry-runtime/src/hot_diag.rs b/crates/perry-runtime/src/hot_diag.rs new file mode 100644 index 0000000000..059f6e153a --- /dev/null +++ b/crates/perry-runtime/src/hot_diag.rs @@ -0,0 +1,489 @@ +//! Counter-first instruments for the mutator paths a TUI keystroke exercises. +//! +//! * `PERRY_REGEX_DIAG=` — RegExp construction / lazy build / cache +//! clears / exec-family calls, plus a per-pattern table (keyed by the +//! pattern `StringHeader` address, merged by content prefix at dump time). +//! * `PERRY_IC_DIAG=` — property-read inline-cache misses split by the +//! REASON the handler took (receiver kind, own/inherited, prime outcome), +//! with a per-site table keyed by the site's cache slot. +//! +//! `` is a file; `1`/`stderr` writes to stderr. A snapshot is written +//! every ~1 s of activity — the measurement rig kills the process with +//! `SIGKILL`, so an exit hook alone would never fire — and the snapshot +//! replaces the previous one (write to `.tmp`, then rename). Both +//! instruments are diagnostic only: nothing may branch on them for behaviour, +//! and when the variable is unset every probe is one relaxed atomic load. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; +use std::time::Instant; + +/// How the diag output is delivered. +#[derive(Clone)] +enum Sink { + Stderr, + File(String), +} + +fn sink_from_env(name: &str) -> Option { + let raw = std::env::var(name).ok()?; + let raw = raw.trim(); + match raw { + "" | "0" | "off" | "false" | "no" => None, + "1" | "stderr" | "on" | "true" | "yes" => Some(Sink::Stderr), + path => Some(Sink::File(path.to_string())), + } +} + +fn write_sink(sink: &Sink, text: &str) { + match sink { + Sink::Stderr => eprint!("{text}"), + Sink::File(path) => { + let tmp = format!("{path}.tmp"); + if std::fs::write(&tmp, text).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } + } +} + +/// Events between two "should we dump?" clock reads. +const TICK_EVERY: u32 = 256; +const DUMP_INTERVAL_MS: u128 = 1000; + +// --------------------------------------------------------------------------- +// RegExp +// --------------------------------------------------------------------------- + +static REGEX_SINK: OnceLock> = OnceLock::new(); +static REGEX_ON: AtomicBool = AtomicBool::new(false); + +/// One-time env parse; arms [`REGEX_ON`]. Called from the first probe. +fn regex_sink() -> &'static Option { + REGEX_SINK.get_or_init(|| { + let sink = sink_from_env("PERRY_REGEX_DIAG"); + REGEX_ON.store(sink.is_some(), Ordering::Relaxed); + sink + }) +} + +/// Is the regex instrument armed? One relaxed load once initialised. +#[inline] +pub fn regex_on() -> bool { + if REGEX_SINK.get().is_none() { + regex_sink(); + } + REGEX_ON.load(Ordering::Relaxed) +} + +#[derive(Default)] +struct PatStat { + prefix: String, + byte_len: u32, + flags: String, + news: u64, + builds: u64, + execs: u64, + tests: u64, + replaces: u64, + matches: u64, +} + +#[derive(Default)] +pub struct RegexDiag { + started: Option, + last_dump: Option, + events: u32, + pub new_calls: u64, + /// `js_regexp_new` found `(pattern, flags)` in `VALIDATED_PATTERNS`. + pub new_validated_hit: u64, + /// `js_regexp_new` answered from the literal-site cache (no validation, + /// no owned copies, programs installed eagerly). + pub new_site_hit: u64, + /// Sum of pattern bytes seen by `js_regexp_new` (what a content hash or + /// copy of the pattern costs per construction). + pub new_pattern_bytes: u64, + pub compiles_std: u64, + pub compiles_fancy: u64, + pub compiles_repeat: u64, + pub cache_clears: u64, + /// `lazy::build_and_install_programs` runs (one per header that is + /// executed at least once). + pub lazy_builds: u64, + /// Of those, the standard-engine program came from `REGEX_CACHE`. + pub lazy_cache_hits: u64, + pub exec_calls: u64, + pub exec_matched: u64, + pub exec_capture_slots: u64, + pub exec_capture_bytes: u64, + pub test_calls: u64, + /// `test` on a global/sticky receiver (used to build a full exec array). + pub test_global: u64, + pub match_calls: u64, + pub replace_calls: u64, + pub replace_matches: u64, + pub split_calls: u64, + per_pattern: HashMap, +} + +thread_local! { + static REGEX_DIAG: RefCell = RefCell::new(RegexDiag::default()); +} + +/// Run `f` against the thread's regex counters, then maybe dump. +#[inline] +pub fn regex_with(f: impl FnOnce(&mut RegexDiag)) { + REGEX_DIAG.with(|d| { + let mut d = d.borrow_mut(); + if d.started.is_none() { + d.started = Some(Instant::now()); + d.last_dump = d.started; + } + f(&mut d); + d.events = d.events.wrapping_add(1); + if d.events % TICK_EVERY == 0 { + let due = d + .last_dump + .is_some_and(|t| t.elapsed().as_millis() >= DUMP_INTERVAL_MS); + if due { + d.last_dump = Some(Instant::now()); + if let Some(sink) = regex_sink() { + write_sink(sink, &d.render()); + } + } + } + }); +} + +impl RegexDiag { + fn pat(&mut self, pattern_addr: usize, pattern: &[u8], flags: &str) -> &mut PatStat { + let entry = self.per_pattern.entry(pattern_addr).or_default(); + if entry.prefix.is_empty() && entry.byte_len == 0 { + let n = pattern.len().min(48); + entry.prefix = String::from_utf8_lossy(&pattern[..n]).into_owned(); + entry.byte_len = pattern.len() as u32; + entry.flags = flags.to_string(); + } + entry + } + + /// Record one `js_regexp_new`. + pub fn note_new( + &mut self, + pattern_addr: usize, + pattern: &[u8], + flags: &str, + validated_hit: bool, + site_hit: bool, + ) { + self.new_calls += 1; + self.new_pattern_bytes += pattern.len() as u64; + if validated_hit { + self.new_validated_hit += 1; + } + if site_hit { + self.new_site_hit += 1; + } + self.pat(pattern_addr, pattern, flags).news += 1; + } + + /// Record one lazy program build for a header. + pub fn note_build( + &mut self, + pattern_addr: usize, + pattern: &[u8], + flags: &str, + cache_hit: bool, + ) { + self.lazy_builds += 1; + if cache_hit { + self.lazy_cache_hits += 1; + } + self.pat(pattern_addr, pattern, flags).builds += 1; + } + + /// Record one exec-family call against a header's pattern. + pub fn note_op(&mut self, pattern_addr: usize, pattern: &[u8], flags: &str, op: RegexOp) { + let stat = self.pat(pattern_addr, pattern, flags); + match op { + RegexOp::Exec => { + stat.execs += 1; + } + RegexOp::Test => { + stat.tests += 1; + } + RegexOp::Replace => { + stat.replaces += 1; + } + RegexOp::Match => { + stat.matches += 1; + } + } + match op { + RegexOp::Exec => self.exec_calls += 1, + RegexOp::Test => self.test_calls += 1, + RegexOp::Replace => self.replace_calls += 1, + RegexOp::Match => self.match_calls += 1, + } + } + + fn render(&self) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(4096); + let secs = self.started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let _ = writeln!( + out, + "[regex-diag] t={secs:.1}s new={} validated_hit={} site_hit={} pattern_bytes={} \ + compiles std={} fancy={} repeat={} cache_clears={} lazy_builds={} lazy_cache_hits={} \ + exec={} exec_matched={} capture_slots={} capture_bytes={} test={} test_global={} \ + match={} replace={} replace_matches={} split={}", + self.new_calls, + self.new_validated_hit, + self.new_site_hit, + self.new_pattern_bytes, + self.compiles_std, + self.compiles_fancy, + self.compiles_repeat, + self.cache_clears, + self.lazy_builds, + self.lazy_cache_hits, + self.exec_calls, + self.exec_matched, + self.exec_capture_slots, + self.exec_capture_bytes, + self.test_calls, + self.test_global, + self.match_calls, + self.replace_calls, + self.replace_matches, + self.split_calls, + ); + // Merge by content (prefix, len, flags): distinct literal sites with + // the same pattern are one row. + let mut merged: HashMap<(String, u32, String), PatStat> = HashMap::new(); + for p in self.per_pattern.values() { + let e = merged + .entry((p.prefix.clone(), p.byte_len, p.flags.clone())) + .or_default(); + e.news += p.news; + e.builds += p.builds; + e.execs += p.execs; + e.tests += p.tests; + e.replaces += p.replaces; + e.matches += p.matches; + } + let mut rows: Vec<_> = merged.into_iter().collect(); + rows.sort_by_key(|(_, s)| { + std::cmp::Reverse(s.news * (1 + s.builds) + s.execs + s.tests + s.replaces + s.matches) + }); + let _ = writeln!( + out, + " news builds execs tests replaces matches len flags pattern-prefix ({} distinct)", + rows.len() + ); + for ((prefix, len, flags), s) in rows.iter().take(40) { + let _ = writeln!( + out, + " {:5} {:6} {:5} {:5} {:8} {:7} {len:5} /{flags}/ {}", + s.news, + s.builds, + s.execs, + s.tests, + s.replaces, + s.matches, + prefix.replace('\n', "\\n") + ); + } + out + } +} + +/// Which exec-family entry point recorded an operation. +#[derive(Clone, Copy)] +pub enum RegexOp { + Exec, + Test, + Replace, + Match, +} + +// --------------------------------------------------------------------------- +// Property-read inline-cache misses +// --------------------------------------------------------------------------- + +static IC_SINK: OnceLock> = OnceLock::new(); +static IC_ON: AtomicBool = AtomicBool::new(false); + +fn ic_sink() -> &'static Option { + IC_SINK.get_or_init(|| { + let sink = sink_from_env("PERRY_IC_DIAG"); + IC_ON.store(sink.is_some(), Ordering::Relaxed); + sink + }) +} + +/// Is the IC-miss instrument armed? One relaxed load once initialised. +#[inline] +pub fn ic_on() -> bool { + if IC_SINK.get().is_none() { + ic_sink(); + } + IC_ON.load(Ordering::Relaxed) +} + +/// Why `js_object_get_field_ic_miss` answered the way it did. The order is +/// the order of the handler's ladder. +#[derive(Clone, Copy, Debug)] +#[repr(u8)] +pub enum IcMissReason { + /// SSO (short-string) receiver — never cacheable. + SsoReceiver = 0, + /// Null receiver or key. + NullArgs, + /// Proxy id band. + Proxy, + /// Async-resource handle property. + AsyncResource, + /// Array-subclass elements store answered. + SubclassElements, + /// `.length` on a dense array / object-backed Array subclass. + ArrayLength, + /// Closure receiver (function object with expando props). + ClosureProp, + /// Registered Buffer receiver. + Buffer, + /// Registered typed array receiver. + TypedArray, + /// Small native handle (timers, text codecs, handle dispatch). + SmallHandle, + /// Receiver is a heap pointer but not `GC_TYPE_OBJECT` (array, string, + /// map, set, promise, ...): the IC can never serve it. + NonObjectGcType, + /// `GC_TYPE_OBJECT` whose shape kind is not `Ordinary` (dictionary / + /// exotic) or whose header is forwarded. + ObjectIrregular, + /// Ordinary object with no keys array yet. + ObjectNoKeys, + /// Own inline field found: primed the MRU entry and returned. + OwnInlinePrimed, + /// Own overflow field found: primed with the overflow bit. + OwnOverflowPrimed, + /// Own field found but the receiver carries descriptors (or the overflow + /// value was not readable) — fell through to the generic read. + OwnDescriptorFallthrough, + /// Key is not an own property of the receiver: inherited (prototype + /// method / accessor) or absent. The generic read walks the chain. + NotOwn, +} + +pub const IC_MISS_REASONS: usize = 17; + +const IC_REASON_NAMES: [&str; IC_MISS_REASONS] = [ + "sso_receiver", + "null_args", + "proxy", + "async_resource", + "subclass_elements", + "array_length", + "closure_prop", + "buffer", + "typed_array", + "small_handle", + "non_object_gc_type", + "object_irregular", + "object_no_keys", + "own_inline_primed", + "own_overflow_primed", + "own_descriptor_fallthrough", + "not_own", +]; + +#[derive(Default)] +struct SiteStat { + key: String, + misses: u64, + by_reason: [u32; IC_MISS_REASONS], +} + +#[derive(Default)] +pub struct IcDiag { + started: Option, + last_dump: Option, + events: u32, + pub misses: u64, + by_reason: [u64; IC_MISS_REASONS], + sites: HashMap, +} + +thread_local! { + static IC_DIAG: RefCell = RefCell::new(IcDiag::default()); +} + +/// Record one IC miss. `site` is the per-site cache slot address (stable for +/// the process lifetime), `key` the property-name string bytes. +pub fn ic_note(site: usize, key: &[u8], reason: IcMissReason) { + IC_DIAG.with(|d| { + let mut d = d.borrow_mut(); + if d.started.is_none() { + d.started = Some(Instant::now()); + d.last_dump = d.started; + } + d.misses += 1; + d.by_reason[reason as usize] += 1; + let s = d.sites.entry(site).or_default(); + if s.key.is_empty() { + s.key = String::from_utf8_lossy(&key[..key.len().min(40)]).into_owned(); + } + s.misses += 1; + s.by_reason[reason as usize] += 1; + d.events = d.events.wrapping_add(1); + if d.events % TICK_EVERY == 0 { + let due = d + .last_dump + .is_some_and(|t| t.elapsed().as_millis() >= DUMP_INTERVAL_MS); + if due { + d.last_dump = Some(Instant::now()); + if let Some(sink) = ic_sink() { + write_sink(sink, &d.render()); + } + } + } + }); +} + +impl IcDiag { + fn render(&self) -> String { + use std::fmt::Write as _; + let mut out = String::with_capacity(4096); + let secs = self.started.map_or(0.0, |t| t.elapsed().as_secs_f64()); + let _ = write!( + out, + "[ic-diag] t={secs:.1}s misses={} sites={}", + self.misses, + self.sites.len() + ); + for (i, name) in IC_REASON_NAMES.iter().enumerate() { + if self.by_reason[i] != 0 { + let _ = write!(out, " {name}={}", self.by_reason[i]); + } + } + out.push('\n'); + let mut rows: Vec<&SiteStat> = self.sites.values().collect(); + rows.sort_by_key(|s| std::cmp::Reverse(s.misses)); + let _ = writeln!(out, " misses key reasons"); + for s in rows.iter().take(40) { + let mut reasons = String::new(); + let mut idx: Vec = (0..IC_MISS_REASONS) + .filter(|&i| s.by_reason[i] != 0) + .collect(); + idx.sort_by(|a, b| s.by_reason[*b].cmp(&s.by_reason[*a])); + for i in idx.iter().take(3) { + let _ = write!(reasons, " {}={}", IC_REASON_NAMES[*i], s.by_reason[*i]); + } + let _ = writeln!(out, " {:6} {:<24}{reasons}", s.misses, s.key); + } + out + } +} diff --git a/crates/perry-runtime/src/lib.rs b/crates/perry-runtime/src/lib.rs index 92276b544f..a69d4c3291 100644 --- a/crates/perry-runtime/src/lib.rs +++ b/crates/perry-runtime/src/lib.rs @@ -88,6 +88,7 @@ pub mod ffi; pub mod frame; pub mod fs; pub mod gc; +pub mod hot_diag; pub mod intl; pub mod iter_result; pub mod iterator_helpers; diff --git a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs index be3027adb6..1a62a5ae04 100644 --- a/crates/perry-runtime/src/object/field_get_set/ic_miss.rs +++ b/crates/perry-runtime/src/object/field_get_set/ic_miss.rs @@ -476,23 +476,50 @@ pub extern "C" fn js_object_get_field_ic_overflow_load( /// Overflow fields (slot >= alloc_limit) are NOT cached and fall through to /// the slow path — the fast path loads from `obj_ptr + 24 + slot*8` which /// would read past the inline allocation. +/// `PERRY_IC_DIAG`: record why this miss took the arm it took. `key` may be +/// null on the earliest exits. +#[inline(never)] +#[cold] +fn ic_diag_note( + cache_slot: *mut PicCacheSlot, + key: *const crate::StringHeader, + reason: crate::hot_diag::IcMissReason, +) { + let bytes: &[u8] = if key.is_null() || (key as usize) < 0x1000 { + b"" + } else { + unsafe { + std::slice::from_raw_parts(crate::string::string_data(key), (*key).byte_len as usize) + } + }; + crate::hot_diag::ic_note(cache_slot as usize, bytes, reason); +} + #[no_mangle] pub extern "C" fn js_object_get_field_ic_miss( obj: *const ObjectHeader, key: *const crate::StringHeader, cache_slot: *mut PicCacheSlot, ) -> f64 { + use crate::hot_diag::IcMissReason as R; + let diag = crate::hot_diag::ic_on(); // SSO receiver — never cacheable. Route through the SSO-aware // `js_object_get_field_by_name` which handles `.length` inline // and returns undefined for other keys. if !key.is_null() { let obj_bits = obj as u64; if (obj_bits & crate::value::TAG_MASK) == crate::value::SHORT_STRING_TAG { + if diag { + ic_diag_note(cache_slot, key, R::SsoReceiver); + } let v = js_object_get_field_by_name(obj, key); return f64::from_bits(v.bits()); } } if obj.is_null() || key.is_null() { + if diag { + ic_diag_note(cache_slot, key, R::NullArgs); + } return f64::from_bits(crate::value::TAG_UNDEFINED); } // A Proxy value may reach the inline-cache miss handler when a fused @@ -511,6 +538,9 @@ pub extern "C" fn js_object_get_field_ic_miss( const POINTER_TAG: u64 = 0x7FFD_0000_0000_0000; let boxed = f64::from_bits(POINTER_TAG | (addr & 0x0000_FFFF_FFFF_FFFF)); if crate::proxy::js_proxy_is_proxy(boxed) != 0 { + if diag { + ic_diag_note(cache_slot, key, R::Proxy); + } let key_f64 = f64::from_bits(crate::value::js_nanbox_string(key as i64).to_bits()); return crate::proxy::js_proxy_get(boxed, key_f64); } @@ -534,6 +564,9 @@ pub extern "C" fn js_object_get_field_ic_miss( if let Some(value) = crate::async_hooks::try_async_resource_property_dispatch(obj as i64, name) { + if diag { + ic_diag_note(cache_slot, key, R::AsyncResource); + } return value; } } @@ -572,6 +605,9 @@ pub extern "C" fn js_object_get_field_ic_miss( if let Some(value) = unsafe { crate::array::subclass_elements::get_by_key(elements, elements_key) } { + if diag { + ic_diag_note(cache_slot, key, R::SubclassElements); + } return value; } } @@ -579,6 +615,9 @@ pub extern "C" fn js_object_get_field_ic_miss( if unsafe { key_bytes_are(key, b"length") } { match unsafe { gc_type_of(obj) } { Some(crate::gc::GC_TYPE_ARRAY) => { + if diag { + ic_diag_note(cache_slot, key, R::ArrayLength); + } let arr = obj as *const crate::array::ArrayHeader; return crate::array::js_array_length(arr) as f64; } @@ -594,6 +633,9 @@ pub extern "C" fn js_object_get_field_ic_miss( // lookup below for every case it cannot prove. let receiver = crate::value::js_nanbox_pointer(obj as i64); if let Some(length) = crate::array::array_subclass_fast_length(receiver) { + if diag { + ic_diag_note(cache_slot, key, R::ArrayLength); + } return length; } } @@ -602,16 +644,25 @@ pub extern "C" fn js_object_get_field_ic_miss( } unsafe { if let Some(val) = closure_dynamic_prop_by_key(obj as usize, key) { + if diag { + ic_diag_note(cache_slot, key, R::ClosureProp); + } return val; } // Buffers have no GcHeader. The generic IC-miss object path below may // inspect GC/object metadata, so mirror js_object_get_field_by_name's // buffer-first dispatch here. if crate::buffer::is_registered_buffer(obj as usize) { + if diag { + ic_diag_note(cache_slot, key, R::Buffer); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } if crate::typedarray::lookup_typed_array_kind(obj as usize).is_some() { + if diag { + ic_diag_note(cache_slot, key, R::TypedArray); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } @@ -626,6 +677,9 @@ pub extern "C" fn js_object_get_field_ic_miss( // dispatch to the per-module accessor instead of silently // returning undefined. if crate::value::addr_class::is_small_handle(obj as usize) { + if diag { + ic_diag_note(cache_slot, key, R::SmallHandle); + } // #2846: a revocable Proxy is encoded as a small fake pointer in the // proxy-id range (also `< 0x100000`). A generic `proxy.key` read funnels // here via the IC-miss path; route it to the proxy get dispatch (which @@ -720,8 +774,12 @@ pub extern "C" fn js_object_get_field_ic_miss( return f64::from_bits(crate::value::TAG_UNDEFINED); } if (obj as usize) < 0x10000 { + if diag { + ic_diag_note(cache_slot, key, R::SmallHandle); + } return f64::from_bits(crate::value::TAG_UNDEFINED); } + let mut miss_reason = R::NotOwn; unsafe { // Issue #72: validate this really is a GC_TYPE_OBJECT before reading // crate::object::object_keys_array(obj) — otherwise an Array/String/Buffer/etc. receiver @@ -760,6 +818,15 @@ pub extern "C" fn js_object_get_field_ic_miss( let is_regular = shape.is_some_and(|shape| { shape.object_kind == crate::object::shapes::ShapeObjectKind::Ordinary }); + if diag { + miss_reason = if !is_object { + R::NonObjectGcType + } else if !is_regular { + R::ObjectIrregular + } else { + R::NotOwn + }; + } // Descriptor-bearing receivers ordinarily must not prime a raw-load // PIC. One narrow exception is an object-backed Array subclass whose // complete class-declared prefix has been proved data-only: its @@ -774,6 +841,9 @@ pub extern "C" fn js_object_get_field_ic_miss( }; let keys = shape.keys as usize as *mut crate::array::ArrayHeader; if keys.is_null() || (keys as usize) <= 0x10000 { + if diag { + ic_diag_note(cache_slot, key, R::ObjectNoKeys); + } let value = js_object_get_field_by_name(obj, key); return f64::from_bits(value.bits()); } @@ -813,12 +883,16 @@ pub extern "C" fn js_object_get_field_ic_miss( token, (i as u32 | crate::proxy::IC_SLOT_OVERFLOW_BIT) as i64, ); + if diag { + ic_diag_note(cache_slot, key, R::OwnOverflowPrimed); + } return f64::from_bits(bits); } } } // Field is in the overflow map — fall through to the // slow path which handles overflow correctly. + miss_reason = R::OwnDescriptorFallthrough; break; } // The codegen IC fast path computes `obj + object_header_size + slot*8` @@ -854,11 +928,15 @@ pub extern "C" fn js_object_get_field_ic_miss( 0 }; if has_own_descriptors && named_prefix_token == 0 { + miss_reason = R::OwnDescriptorFallthrough; break; } let cache = pic_slot_resolve(cache_slot); (*cache)[2] = named_prefix_token; pic_prime_get(cache, token, i as i64); + if diag { + ic_diag_note(cache_slot, key, R::OwnInlinePrimed); + } let field_ptr = (obj as *const u8) .add(std::mem::size_of::() + i * 8) as *const f64; @@ -867,6 +945,9 @@ pub extern "C" fn js_object_get_field_ic_miss( } } } + if diag { + ic_diag_note(cache_slot, key, miss_reason); + } let value = js_object_get_field_by_name(obj, key); f64::from_bits(value.bits()) } diff --git a/crates/perry-runtime/src/regex.rs b/crates/perry-runtime/src/regex.rs index bfa75950ab..73577ece3c 100644 --- a/crates/perry-runtime/src/regex.rs +++ b/crates/perry-runtime/src/regex.rs @@ -6,9 +6,8 @@ #[cfg(feature = "regex-engine")] use regex::Regex; use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ptr; -#[cfg(feature = "regex-engine")] use std::sync::Arc; #[cfg(feature = "regex-engine")] @@ -49,6 +48,8 @@ mod repeat_matcher; mod replace_expand; mod replace_fn; #[cfg(feature = "regex-engine")] +mod site_cache; +#[cfg(feature = "regex-engine")] mod unicode17; #[cfg(feature = "regex-engine")] mod unicode17_data; @@ -112,7 +113,7 @@ crate::perry_thread_local! { /// delimiter from a string delimiter when the codegen can't tell /// statically. GC move/death hooks rekey and remove entries as cells /// relocate or die. Header magic remains the primary identity check. - static REGEX_POINTERS: RefCell> = RefCell::new(HashSet::new()); + static REGEX_POINTERS: RefCell> = RefCell::new(crate::fast_hash::new_ptr_hash_set()); /// Issue #637: Owned copies of pattern and flags strings keyed by /// the RegExpHeader pointer. The header's `pattern_ptr` / `flags_ptr` @@ -123,7 +124,12 @@ crate::perry_thread_local! { /// `.flags` reads dereference dangling memory. We side-table an /// owned `String` copy at construction time; readers prefer this /// over `pattern_ptr` whenever an entry exists. - static REGEX_SOURCE_TABLE: RefCell> = RefCell::new(HashMap::new()); + /// + /// The copies are `Arc` shared with `regex::site_cache`: every + /// header built from the same literal text bumps two refcounts instead + /// of copying the pattern (12 KB for emoji-class patterns, once per + /// evaluation of the literal). + static REGEX_SOURCE_TABLE: RefCell, Arc)>> = RefCell::new(crate::fast_hash::new_ptr_hash_map()); } /// Check whether `ptr` is a RegExpHeader pointer that was allocated in @@ -279,7 +285,7 @@ pub(crate) fn test_alloc_nursery_regexp_for_move(source: &str, flags: &str) -> * REGEX_SOURCE_TABLE.with(|table| { table .borrow_mut() - .insert(ptr as usize, (source.to_string(), flags.to_string())); + .insert(ptr as usize, (Arc::from(source), Arc::from(flags))); }); ptr } @@ -464,6 +470,9 @@ const REGEX_CACHE_MAX_ENTRIES: usize = 512; fn evict_regex_cache_if_full(cache: &mut HashMap<(String, String), V>) { if cache.len() >= REGEX_CACHE_MAX_ENTRIES { cache.clear(); + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.cache_clears += 1); + } } } @@ -496,6 +505,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { return true; } if let Some(repeat_matcher) = repeat_matcher::compile(pattern, flags) { + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_repeat += 1); + } REPEAT_MATCHER_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); evict_regex_cache_if_full(&mut cache); @@ -520,6 +532,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { // callers don't crash. let fancy_ok = FANCY_CACHE.with(|fc| { if let Ok(fre) = build_fancy_regex(®ex_pattern) { + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_fancy += 1); + } let mut fc = fc.borrow_mut(); evict_regex_cache_if_full(&mut fc); fc.insert( @@ -537,6 +552,9 @@ fn compile_and_cache_regex_checked(pattern: &str, flags: &str) -> bool { Regex::new(r"[^\s\S]").unwrap() } }; + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| d.compiles_std += 1); + } REGEX_CACHE.with(|cache| { let mut cache = cache.borrow_mut(); evict_regex_cache_if_full(&mut cache); @@ -872,6 +890,28 @@ pub extern "C" fn js_regexp_new( let unicode = flags_str.contains('u') || flags_str.contains('v'); let has_indices = flags_str.contains('d'); + // Content-keyed construction cache (`regex::site_cache`): a verified hit + // means this exact `(pattern, canonical flags)` already cleared the + // validation below — validity is a pure function of the pair — and hands + // back the shared owned copies plus, once some header built from this + // text has been executed, its compiled programs. The probe is one + // fingerprint and one byte compare; everything below it that copies or + // hashes the pattern is skipped. + let site_hit = site_cache::lookup(pattern_str, flags_str); + let validated_hit = + site_hit.is_some() || lazy::pattern_already_validated(pattern_str, flags_str); + if crate::hot_diag::regex_on() { + crate::hot_diag::regex_with(|d| { + d.note_new( + pattern as usize, + pattern_str.as_bytes(), + flags_str, + validated_hit && site_hit.is_none(), + site_hit.is_some(), + ) + }); + } + // #2829: reject invalid pattern syntax with a SyntaxError. A pattern the // `regex` crate rejects is only a real error if `fancy-regex` (which // covers the full JS feature set: lookbehind/lookahead/backreferences) @@ -892,7 +932,7 @@ pub extern "C" fn js_regexp_new( // hit, which worked only because construction also COMPILED; with the // build deferred, the fact is recorded directly in `VALIDATED_PATTERNS`. { - if !lazy::pattern_already_validated(pattern_str, flags_str) { + if !validated_hit { if has_invalid_repeated_quantifier(pattern_str) { throw_regexp_syntax_error(&format!( "Invalid regular expression: /{}/: invalid pattern", @@ -976,10 +1016,16 @@ pub extern "C" fn js_regexp_new( // ★ Last use of the borrowed pattern text before this function allocates. // `pattern_str` borrows the GC string; the two allocations below can move // it, and everything after this point reads the pattern from `owned_pattern` - // (a Rust `String`, which relocation cannot invalidate) or from + // (a shared `Arc`, which relocation cannot invalidate) or from // `pattern_root` (a runtime handle the collector rewrites). Nothing below // may use `pattern_str` or the incoming `pattern` argument again. - let owned_pattern = pattern_str.to_string(); + let (owned_pattern, owned_flags, programs) = match site_hit { + Some(hit) => (hit.pattern, hit.flags, hit.programs), + None => { + let (p, f) = site_cache::insert(pattern_str, flags_str); + (p, f, None) + } + }; #[allow(unused_variables)] let pattern_str: () = (); @@ -1079,6 +1125,19 @@ pub extern "C" fn js_regexp_new( // a sound built/not-built flag. (*ptr).fancy_ptr = std::ptr::null(); (*ptr).repeat_matcher_ptr = std::ptr::null(); + // Born built: the site cache already holds the programs the first + // execution of this text compiled. Install the same three owned + // references `lazy::build_and_install_programs` would, publishing + // `regex_ptr` last for the same reason it does. + if let Some(programs) = programs { + (*ptr).fancy_ptr = programs + .fancy + .map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); + (*ptr).repeat_matcher_ptr = programs + .repeat + .map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); + (*ptr).regex_ptr = Arc::into_raw(programs.std) as *mut Regex; + } // Record the pointer so that js_string_split can detect // `s.split(regex)` without a dedicated runtime decl. @@ -1092,7 +1151,7 @@ pub extern "C" fn js_regexp_new( // `.source` / `.flags` survive GC of the input StringHeaders. REGEX_SOURCE_TABLE.with(|t| { t.borrow_mut() - .insert(ptr as usize, (owned_pattern.clone(), flags_str.to_string())); + .insert(ptr as usize, (owned_pattern, owned_flags)); }); ptr @@ -1125,7 +1184,7 @@ pub extern "C" fn js_regexp_construct(pattern: f64, flags: f64) -> *mut RegExpHe let re = pv.as_pointer::(); let entry = REGEX_SOURCE_TABLE.with(|t| t.borrow().get(&(re as usize)).cloned()); match entry { - Some((pat, fl)) => (pat, Some(fl)), + Some((pat, fl)) => (pat.to_string(), Some(fl.to_string())), None => (String::new(), Some(String::new())), } } else if pv.is_undefined() { @@ -1240,13 +1299,25 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader let str_data = string_as_str(s); unsafe { + if crate::hot_diag::regex_on() { + diag_note_op(re, crate::hot_diag::RegexOp::Test); + if (*re).global || (*re).sticky { + crate::hot_diag::regex_with(|d| d.test_global += 1); + } + } // For global/sticky regexes `test` is stateful — it must consult and - // advance `lastIndex` (and anchor for sticky) exactly like `exec`. Route - // through `exec` so the lastIndex bookkeeping stays in one place; `test` - // just reports whether a match was produced. + // advance `lastIndex` (and anchor for sticky) exactly like `exec`. The + // find-only twin of `exec`'s engine phase does that bookkeeping without + // materializing a result array: `test` only reports whether a match + // was produced, and building the captures array plus one string per + // capture per call was the allocation `ansi-regex`-style `g` tests + // paid on every text segment. if (*re).global || (*re).sticky { - let arr = js_regexp_exec(re as *mut RegExpHeader, s); - return if arr.is_null() { 0 } else { 1 }; + return if exec::regexp_find_advancing(re as *mut RegExpHeader, s).is_some() { + 1 + } else { + 0 + }; } if let Some(repeat_matcher) = lookup_repeat_matcher(re) { @@ -1273,6 +1344,27 @@ pub extern "C" fn js_regexp_test(re: *const RegExpHeader, s: *const StringHeader } } +/// `PERRY_REGEX_DIAG`: attribute one exec-family operation to the receiver's +/// pattern. Callers have already validated `re`. +#[cfg(feature = "regex-engine")] +pub(super) fn diag_note_op(re: *const RegExpHeader, op: crate::hot_diag::RegexOp) { + unsafe { + let pattern_ptr = (*re).pattern_ptr; + let flags_ptr = (*re).flags_ptr; + let pattern = if is_valid_ptr(pattern_ptr) { + string_as_bytes(pattern_ptr) + } else { + b"" + }; + let flags = if is_valid_ptr(flags_ptr) { + string_as_str(flags_ptr) + } else { + "" + }; + crate::hot_diag::regex_with(|d| d.note_op(pattern_ptr as usize, pattern, flags, op)); + } +} + /// Look up a fancy-regex fallback for the given header, if one was /// registered at compile-time because the `regex` crate rejected the /// pattern (backreferences, lookbehind, etc.). @@ -1288,7 +1380,17 @@ pub(crate) fn lookup_fancy_regex(re: *const RegExpHeader) -> Option length` / no-match resets mirror `js_regexp_exec` line for +/// line; a divergence here would make `test` and `exec` disagree on where the +/// next search starts. +#[cfg(feature = "regex-engine")] +pub(super) fn regexp_find_advancing( + re: *mut RegExpHeader, + s: *const StringHeader, +) -> Option<(usize, usize)> { + // Same rooting discipline as `js_regexp_exec`: the `ToLength(lastIndex)` + // read may run user JS. + let scope = crate::gc::RuntimeHandleScope::new(); + let re_handle = scope.root_raw_mut_ptr(re); + let s_handle = scope.root_string_ptr(s); + let ((last_index, re), s) = s_handle.across_const::(|| { + re_handle + .across_mut::(|| re_handle.with_const_ptr(regex_last_index_offset)) + }); + unsafe { + let str_data = string_as_str(s); + let regex = super::lazy::header_std_regex(re); + let sticky = (*re).sticky; + if last_index > (*s).utf16_len as usize { + set_last_index_throwing(re, 0); + return None; + } + let search_start_byte = if last_index > 0 { + super::exec_array::utf16_index_to_byte(str_data, last_index) + } else { + 0 + }; + let found = if let Some(repeat_matcher) = lookup_repeat_matcher(re) { + repeat_matcher + .regex + .find_from(str_data, search_start_byte) + .next() + .filter(|matched| !sticky || matched.start() == search_start_byte) + .map(|matched| (matched.start(), matched.end())) + } else if let Some(fre) = lookup_fancy_regex(re) { + match fre.find_from_pos(str_data, search_start_byte) { + Ok(Some(matched)) if !sticky || matched.start() == search_start_byte => { + Some((matched.start(), matched.end())) + } + _ => None, + } + } else { + regex + .find_at(str_data, search_start_byte) + .filter(|matched| !sticky || matched.start() == search_start_byte) + .map(|matched| (matched.start(), matched.end())) + }; + match found { + Some((_, end)) => set_last_index_throwing( + re, + super::exec_array::byte_index_to_utf16_index(str_data, end), + ), + None => set_last_index_throwing(re, 0), + } + found + } +} diff --git a/crates/perry-runtime/src/regex/exec_array.rs b/crates/perry-runtime/src/regex/exec_array.rs index 461136cdab..923a9e942a 100644 --- a/crates/perry-runtime/src/regex/exec_array.rs +++ b/crates/perry-runtime/src/regex/exec_array.rs @@ -76,6 +76,17 @@ pub(super) struct OwnedExecMatch { } impl OwnedExecMatch { + /// `PERRY_REGEX_DIAG`: (result-array slots, bytes copied for captures). + pub(super) fn capture_stats(&self) -> (usize, usize) { + let bytes = self + .captures + .iter() + .flatten() + .map(|c| c.byte_len as usize) + .sum(); + (self.captures.len(), bytes) + } + pub(super) fn from_standard( str_data: &str, regex: ®ex::Regex, diff --git a/crates/perry-runtime/src/regex/lazy.rs b/crates/perry-runtime/src/regex/lazy.rs index ec6b194b50..438d8eca4b 100644 --- a/crates/perry-runtime/src/regex/lazy.rs +++ b/crates/perry-runtime/src/regex/lazy.rs @@ -163,22 +163,22 @@ pub(super) fn mark_pattern_validated(pattern: &str, flags: &str) { /// Prefers the GC-survivable side table (issue #637) and falls back to the /// header's own string payloads, which — unlike the thread-local table — are /// readable from a second statically-linked copy of the runtime (Wall 18). -pub(super) fn source_and_flags(re: *const RegExpHeader) -> (String, String) { +pub(super) fn source_and_flags(re: *const RegExpHeader) -> (Arc, Arc) { if let Some(source) = REGEX_SOURCE_TABLE.with(|table| table.borrow().get(&(re as usize)).cloned()) { return source; } unsafe { - let pattern = if is_valid_ptr((*re).pattern_ptr) { - string_as_str((*re).pattern_ptr).to_string() + let pattern: Arc = if is_valid_ptr((*re).pattern_ptr) { + Arc::from(string_as_str((*re).pattern_ptr)) } else { - String::new() + Arc::from("") }; - let flags = if is_valid_ptr((*re).flags_ptr) { - string_as_str((*re).flags_ptr).to_string() + let flags: Arc = if is_valid_ptr((*re).flags_ptr) { + Arc::from(string_as_str((*re).flags_ptr)) } else { - String::new() + Arc::from("") }; (pattern, flags) } @@ -229,20 +229,48 @@ fn build_and_install_programs(re: *const RegExpHeader) { return; } let (pattern, flags) = source_and_flags(re); - let arc = get_or_compile_regex(&pattern, &flags); - let regex_ptr = Arc::into_raw(arc) as *mut Regex; + if crate::hot_diag::regex_on() { + let cache_hit = super::REGEX_CACHE.with(|cache| { + cache + .borrow() + .contains_key(&(pattern.to_string(), flags.to_string())) + }); + unsafe { + let pattern_ptr = (*re).pattern_ptr; + crate::hot_diag::regex_with(|d| { + d.note_build(pattern_ptr as usize, pattern.as_bytes(), &flags, cache_hit) + }); + } + } + let std_arc = get_or_compile_regex(&pattern, &flags); + let fancy_arc: Option> = FANCY_CACHE.with(|fc| { + fc.borrow() + .get(&(pattern.to_string(), flags.to_string())) + .cloned() + }); + let repeat_arc: Option> = REPEAT_MATCHER_CACHE + .with(|cache| { + cache + .borrow() + .get(&(pattern.to_string(), flags.to_string())) + .cloned() + }); + // Remember the built programs against the pattern text, so the next + // construction of the same literal is born built (`js_regexp_new`). + super::site_cache::install_programs( + &pattern, + &flags, + super::site_cache::Programs { + std: std_arc.clone(), + fancy: fancy_arc.clone(), + repeat: repeat_arc.clone(), + }, + ); + let regex_ptr = Arc::into_raw(std_arc) as *mut Regex; let fancy_ptr: *const () = - FANCY_CACHE.with( - |fc| match fc.borrow().get(&(pattern.clone(), flags.clone())) { - Some(arc) => Arc::into_raw(arc.clone()) as *const (), - None => std::ptr::null(), - }, - ); + fancy_arc.map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); let repeat_matcher_ptr: *const () = - REPEAT_MATCHER_CACHE.with(|cache| match cache.borrow().get(&(pattern, flags)) { - Some(arc) => Arc::into_raw(arc.clone()) as *const (), - None => std::ptr::null(), - }); + repeat_arc.map_or(std::ptr::null(), |arc| Arc::into_raw(arc) as *const ()); unsafe { let re = re as *mut RegExpHeader; (*re).fancy_ptr = fancy_ptr; diff --git a/crates/perry-runtime/src/regex/match_string.rs b/crates/perry-runtime/src/regex/match_string.rs index 9b06e60bd8..6611923493 100644 --- a/crates/perry-runtime/src/regex/match_string.rs +++ b/crates/perry-runtime/src/regex/match_string.rs @@ -73,6 +73,9 @@ pub extern "C" fn js_string_match( if !is_valid_ptr(s) || !is_valid_regex_ptr(re) { return ptr::null_mut(); } + if crate::hot_diag::regex_on() { + super::diag_note_op(re, crate::hot_diag::RegexOp::Match); + } // Phase 1 (borrowing, no JS allocation): capture byte ranges and all // UTF-16/WTF-8 metadata while the engine's `Captures` may borrow `s`. diff --git a/crates/perry-runtime/src/regex/repeat_matcher.rs b/crates/perry-runtime/src/regex/repeat_matcher.rs index 107e17dded..e2e19129c9 100644 --- a/crates/perry-runtime/src/regex/repeat_matcher.rs +++ b/crates/perry-runtime/src/regex/repeat_matcher.rs @@ -304,7 +304,8 @@ fn source_and_flags(re: *const super::RegExpHeader) -> (String, String) { // One definition, shared with the lazy first-use builder: both need the // `(source, flags)` a header was constructed from, and a second copy of // the side-table-then-header fallback would be a place for them to drift. - super::lazy::source_and_flags(re) + let (source, flags) = super::lazy::source_and_flags(re); + (source.to_string(), flags.to_string()) } fn decode_wtf8_units(bytes: &[u8]) -> Vec { diff --git a/crates/perry-runtime/src/regex/site_cache.rs b/crates/perry-runtime/src/regex/site_cache.rs new file mode 100644 index 0000000000..b8e68af0da --- /dev/null +++ b/crates/perry-runtime/src/regex/site_cache.rs @@ -0,0 +1,258 @@ +//! Content-keyed construction cache for `RegExp`. +//! +//! # Why +//! +//! `js_regexp_new` runs once per EVALUATION of a regex literal (ECMA-262: a +//! literal is a new object every time), and TUI code evaluates literals inside +//! hot functions: `string-width`'s `emojiRegex()` returns a fresh ~12 KB +//! `/…/g` on every call, once per text segment per layout pass, and +//! `ansi-regex` builds the same `new RegExp(parts.join("|"), "g")` per call. +//! Each construction used to copy the pattern three times (the +//! `VALIDATED_PATTERNS` probe key, `owned_pattern`, the `REGEX_SOURCE_TABLE` +//! entry) and SipHash all of it once; the first operation on each header then +//! did the same three more times — `build_and_install_programs` probes the +//! three `(String, String)`-keyed program caches — and, for the common +//! no-fallback pattern, `lookup_fancy_regex` / `lookup_repeat_matcher` +//! re-probed two of them on EVERY exec. On the claude-code keystroke profile +//! SipHash over pattern text was 31 % of the post-turn window (regex 38 % +//! inclusive), all of it under these five functions. +//! +//! # What +//! +//! A direct-mapped, thread-local table keyed by a cheap CONTENT fingerprint +//! (length, first / middle / last 8 bytes, canonical flags) and verified by a +//! full byte compare — identity never depends on an address, so nothing is +//! rekeyed on a GC move and a dynamic `new RegExp(sameText)` hits too; a hit +//! costs one `memcmp` instead of a hash plus three copies. An entry owns the +//! pattern and canonical flags as `Arc` (shared into +//! `REGEX_SOURCE_TABLE`, so a header costs two refcount bumps instead of two +//! `String`s) and, once the first header built from it has been executed, the +//! compiled programs: a later construction installs those eagerly, so the +//! header is born built and never touches the `(pattern, flags)` caches. +//! +//! Validity is a pure function of `(pattern, flags)`, so a hit legitimately +//! skips validation: an entry is only ever written on the validated path, and +//! the programs it hands out were built for exactly this text. +//! +//! Kill switch: `PERRY_REGEX_SITE_CACHE=0` (lookups miss, nothing is stored). + +use std::cell::RefCell; +use std::sync::Arc; + +use regex::Regex; + +/// The compiled programs a header owns, in the form `lazy` installs them. +pub(super) struct Programs { + pub(super) std: Arc, + pub(super) fancy: Option>, + pub(super) repeat: Option>, +} + +impl Clone for Programs { + fn clone(&self) -> Self { + Self { + std: self.std.clone(), + fancy: self.fancy.clone(), + repeat: self.repeat.clone(), + } + } +} + +/// What a construction gets back on a hit. +pub(super) struct Hit { + pub(super) pattern: Arc, + pub(super) flags: Arc, + pub(super) programs: Option, +} + +struct Entry { + fp: u64, + pattern: Arc, + flags: Arc, + programs: Option, +} + +/// Direct-mapped slots (2-way: a fingerprint may live in `slot` or +/// `slot ^ 1`). Sized for a bundle's live literal working set; the +/// claude-code TUI cycles through a few dozen per render. +const SLOTS: usize = 1024; + +crate::perry_thread_local! { + static SITE_CACHE: RefCell>> = RefCell::new(Vec::new()); +} + +fn enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| { + crate::gc::env_default_on_from_value( + std::env::var("PERRY_REGEX_SITE_CACHE").ok().as_deref(), + ) + }) +} + +/// Cheap content fingerprint: length, three 8-byte windows of the pattern, +/// the (≤ 8 byte) canonical flags. Collisions are harmless — every hit is +/// verified by a full compare — they only cost the verify and a re-insert. +fn fingerprint(pattern: &[u8], flags: &[u8]) -> u64 { + #[inline] + fn window(bytes: &[u8], at: usize) -> u64 { + let mut w = [0u8; 8]; + let end = (at + 8).min(bytes.len()); + if at < end { + w[..end - at].copy_from_slice(&bytes[at..end]); + } + u64::from_le_bytes(w) + } + #[inline] + fn mix(h: u64, w: u64) -> u64 { + (h ^ w).wrapping_mul(0xC6BC_2796_92B5_C323).rotate_left(29) + } + let n = pattern.len(); + let mut h = (n as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + h = mix(h, window(pattern, 0)); + h = mix(h, window(pattern, n / 2)); + h = mix(h, window(pattern, n.saturating_sub(8))); + h = mix(h, window(flags, 0)); + h +} + +#[inline] +fn slot_of(fp: u64) -> usize { + (fp as usize) & (SLOTS - 1) +} + +fn entry_matches(entry: &Entry, fp: u64, pattern: &str, flags: &str) -> bool { + entry.fp == fp && &*entry.flags == flags && &*entry.pattern == pattern +} + +/// Find the verified entry for `(pattern, canonical flags)`. +pub(super) fn lookup(pattern: &str, flags: &str) -> Option { + if !enabled() { + return None; + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let cache = cache.borrow(); + if cache.is_empty() { + return None; + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return Some(Hit { + pattern: entry.pattern.clone(), + flags: entry.flags.clone(), + programs: entry.programs.clone(), + }); + } + } + } + None + }) +} + +/// Record a validated `(pattern, canonical flags)`, returning the shared +/// owned copies a header should keep. An existing verified entry is reused +/// (its programs are kept); otherwise the fresh entry has none yet. +pub(super) fn insert(pattern: &str, flags: &str) -> (Arc, Arc) { + if !enabled() { + return (Arc::from(pattern), Arc::from(flags)); + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.is_empty() { + cache.resize_with(SLOTS, || None); + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return (entry.pattern.clone(), entry.flags.clone()); + } + } + } + let victim = if cache[slot].is_none() { + slot + } else if cache[slot ^ 1].is_none() { + slot ^ 1 + } else { + slot ^ ((fp >> 11) as usize & 1) + }; + let pattern: Arc = Arc::from(pattern); + let flags: Arc = Arc::from(flags); + cache[victim] = Some(Entry { + fp, + pattern: pattern.clone(), + flags: flags.clone(), + programs: None, + }); + (pattern, flags) + }) +} + +/// Attach the programs the first execution built to the entry for +/// `(pattern, canonical flags)`, so every later construction of the same +/// text is born built. Inserts the entry if it was evicted meanwhile. +pub(super) fn install_programs(pattern: &str, flags: &str, programs: Programs) { + if !enabled() { + return; + } + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + if cache.is_empty() { + cache.resize_with(SLOTS, || None); + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &mut cache[s] { + if entry_matches(entry, fp, pattern, flags) { + if entry.programs.is_none() { + entry.programs = Some(programs); + } + return; + } + } + } + let victim = if cache[slot].is_none() { + slot + } else if cache[slot ^ 1].is_none() { + slot ^ 1 + } else { + slot ^ ((fp >> 11) as usize & 1) + }; + cache[victim] = Some(Entry { + fp, + pattern: Arc::from(pattern), + flags: Arc::from(flags), + programs: Some(programs), + }); + }); +} + +#[cfg(test)] +pub(super) fn test_reset() { + SITE_CACHE.with(|cache| cache.borrow_mut().clear()); +} + +#[cfg(test)] +pub(super) fn test_has_programs(pattern: &str, flags: &str) -> Option { + let fp = fingerprint(pattern.as_bytes(), flags.as_bytes()); + let slot = slot_of(fp); + SITE_CACHE.with(|cache| { + let cache = cache.borrow(); + if cache.is_empty() { + return None; + } + for s in [slot, slot ^ 1] { + if let Some(entry) = &cache[s] { + if entry_matches(entry, fp, pattern, flags) { + return Some(entry.programs.is_some()); + } + } + } + None + }) +} diff --git a/crates/perry-runtime/src/regex/tests.rs b/crates/perry-runtime/src/regex/tests.rs index ea91ba279e..e41c2abc61 100644 --- a/crates/perry-runtime/src/regex/tests.rs +++ b/crates/perry-runtime/src/regex/tests.rs @@ -1648,3 +1648,112 @@ fn global_replace_substitutes_at_every_empty_match() { let out = js_string_replace_regex_named(make_string("a"), named, make_string("[$]")); assert_eq!(string_as_str(out), "[a][]"); } + +/// The construction cache (`regex::site_cache`): once a header built from +/// some `(pattern, flags)` has been executed, the next construction of the +/// same text is born built — it shares the executed header's program and +/// never runs the lazy build. Fails on a runtime without the cache (the +/// second header stays lazy). +#[test] +fn site_cache_reconstruction_is_born_built() { + let _lock = crate::gc::global_side_table_test_lock(); + site_cache::test_reset(); + let re1 = js_regexp_new(make_string("born[0-9]+built"), make_string("g")); + assert!( + unsafe { (*re1).regex_ptr.is_null() }, + "construction stays lazy" + ); + assert_eq!( + site_cache::test_has_programs("born[0-9]+built", "g"), + Some(false), + "construction records the validated text without programs" + ); + assert!(js_regexp_test(re1, make_string("xx born42built")) != 0); + assert_eq!( + site_cache::test_has_programs("born[0-9]+built", "g"), + Some(true), + "the first execution's build is remembered against the text" + ); + let re2 = js_regexp_new(make_string("born[0-9]+built"), make_string("g")); + assert!( + !unsafe { (*re2).regex_ptr.is_null() }, + "the second construction installs the programs eagerly" + ); + assert!( + std::ptr::eq(unsafe { (*re1).regex_ptr }, unsafe { (*re2).regex_ptr }), + "both headers share one compiled program" + ); + // The owned source copies are shared too (two refcount bumps per header, + // not two `String`s). + let (p1, p2) = REGEX_SOURCE_TABLE.with(|t| { + let t = t.borrow(); + ( + t.get(&(re1 as usize)).map(|(p, _)| p.clone()).unwrap(), + t.get(&(re2 as usize)).map(|(p, _)| p.clone()).unwrap(), + ) + }); + assert!(Arc::ptr_eq(&p1, &p2), "source text is shared, not copied"); + assert_eq!(js_regexp_test(re2, make_string("born7built")), 1); + assert_eq!(js_regexp_test(re2, make_string("nothing")), 0); + // Different flags are a different entry. + let re3 = js_regexp_new(make_string("born[0-9]+built"), make_string("i")); + assert!(unsafe { (*re3).regex_ptr.is_null() }); +} + +/// `test` on a global/sticky receiver advances `lastIndex` exactly like +/// `exec` and resets it on failure, through the find-only engine phase (no +/// exec array). Pinned against node for every branch of that bookkeeping. +#[test] +fn global_test_advances_and_resets_last_index() { + let _lock = crate::gc::global_side_table_test_lock(); + let re = js_regexp_new(make_string("a"), make_string("g")); + let s = make_string("aXa"); + assert_eq!(js_regexp_test(re, s), 1); + assert_eq!(js_regexp_get_last_index(re), 1.0); + assert_eq!(js_regexp_test(re, s), 1); + assert_eq!(js_regexp_get_last_index(re), 3.0); + assert_eq!(js_regexp_test(re, s), 0); + assert_eq!(js_regexp_get_last_index(re), 0.0); + + // `lastIndex > length` is "no match" and resets. + js_regexp_set_last_index(re, 10.0); + assert_eq!(js_regexp_test(re, s), 0); + assert_eq!(js_regexp_get_last_index(re), 0.0); + + // sticky anchors at lastIndex. + let sticky = js_regexp_new(make_string("a"), make_string("y")); + let t = make_string("ba"); + assert_eq!(js_regexp_test(sticky, t), 0); + assert_eq!(js_regexp_get_last_index(sticky), 0.0); + js_regexp_set_last_index(sticky, 1.0); + assert_eq!(js_regexp_test(sticky, t), 1); + assert_eq!(js_regexp_get_last_index(sticky), 2.0); + + // lastIndex counts UTF-16 code units, not bytes. + let astral = js_regexp_new(make_string("b"), make_string("g")); + let u = make_string("😀b😀b"); + assert_eq!(js_regexp_test(astral, u), 1); + assert_eq!(js_regexp_get_last_index(astral), 3.0); + assert_eq!(js_regexp_test(astral, u), 1); + assert_eq!(js_regexp_get_last_index(astral), 6.0); + assert_eq!(js_regexp_test(astral, u), 0); + + // The fancy-regex fallback (lookbehind) takes the same path. + let fancy = js_regexp_new(make_string("(?<=x)a"), make_string("g")); + let f = make_string("xa xa a"); + assert_eq!(js_regexp_test(fancy, f), 1); + assert_eq!(js_regexp_get_last_index(fancy), 2.0); + assert_eq!(js_regexp_test(fancy, f), 1); + assert_eq!(js_regexp_get_last_index(fancy), 5.0); + assert_eq!(js_regexp_test(fancy, f), 0); + assert_eq!(js_regexp_get_last_index(fancy), 0.0); + + // The backtracking matcher (quantified capture) likewise. + let repeat = js_regexp_new(make_string("(a?b??)*c"), make_string("g")); + let r = make_string("abc c"); + assert_eq!(js_regexp_test(repeat, r), 1); + assert_eq!(js_regexp_get_last_index(repeat), 3.0); + assert_eq!(js_regexp_test(repeat, r), 1); + assert_eq!(js_regexp_get_last_index(repeat), 5.0); + assert_eq!(js_regexp_test(repeat, r), 0); +}