From 9e272e6cb54b06c035d155d1c0174a1367cb4cec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 28 Oct 2026 06:58:21 +0000 Subject: [PATCH 1/4] style(native): iterate the initialized patch prefix directly --- src/native/emit.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/native/emit.rs b/src/native/emit.rs index 1ab222f..62745ee 100644 --- a/src/native/emit.rs +++ b/src/native/emit.rs @@ -85,8 +85,8 @@ impl Patches { Ok(()) } fn take(&mut self, other: &mut Patches) -> Result<(), EmitError> { - for index in 0..other.count { - self.push(other.list[index])?; + for &patch in &other.list[..other.count] { + self.push(patch)?; } Ok(()) } @@ -687,8 +687,7 @@ fn generate(program: Program<'_>, asm: &mut M) -> Result Date: Wed, 28 Oct 2026 08:14:50 +0000 Subject: [PATCH 2/4] perf(executor): borrow validated program sections for each active view --- CHANGELOG.md | 5 +++ src/executor.rs | 61 +++++++++++++++++++---------- src/executor/admission.rs | 10 ++--- src/executor/atom.rs | 40 ++++++++----------- src/executor/candidate.rs | 8 ++-- src/program.rs | 24 ++++++++++++ tests/sections.rs | 81 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 177 insertions(+), 52 deletions(-) create mode 100644 tests/sections.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a14e3d3..9a29ea8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Borrow the immutable instruction, class-range and repeat records once per evaluator view, avoiding repeated layout decoding and whole-word bounds checks in dispatch and atom execution. +- Preserve program format 16, complete captures, work accounting, resource rebinding and offset-only resumable state. Record views do not survive an advance. + ## 0.1.14 — 2026-10-18 - Execute bounded start-anchored literal runs directly from immutable program data, without DFA storage or evaluator scratch. diff --git a/src/executor.rs b/src/executor.rs index 4b5afb7..af65c9f 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -370,6 +370,7 @@ impl<'r, R: Resources, B: ScratchOwner> Search<'r, R, B> { } let mut vm = Vm { program, + sections: program.sections(), input, cursor: input.cursor(), scratch: &mut scratch, @@ -579,6 +580,7 @@ impl<'r, R: Resources, B: ScratchOwner> Search<'r, R, B> { } let mut vm = Vm { program, + sections: program.sections(), input, cursor, scratch: &mut scratch, @@ -737,6 +739,7 @@ impl<'r, R: Resources, B: ScratchOwner> Search<'r, R, B> { struct Vm<'a, 'p, 's, 'w> { program: Program<'p>, + sections: ProgramSections<'p>, input: Input<'a>, cursor: Cursor<'a>, scratch: &'w mut Scratch<'s>, @@ -756,6 +759,23 @@ enum Step { Phase, } impl Vm<'_, '_, '_, '_> { + #[inline] + fn instruction_count(&self) -> usize { + self.sections.instructions.len() + } + #[inline] + fn opcode(&self, pc: usize) -> [u32; 3] { + self.sections.instructions[pc] + } + #[inline] + fn class_range(&self, index: usize) -> [u32; 2] { + self.sections.ranges[index] + } + #[inline] + fn repeat_record(&self, index: usize) -> [u32; 8] { + self.sections.repeats[index] + } + fn charge(&mut self, n: usize) -> Result<(), ExecError> { self.budget.charge(n).map_err(|_| ExecError::WorkLimit) } @@ -883,7 +903,7 @@ impl Vm<'_, '_, '_, '_> { // The saved next PC still identifies the initiating // backreference after a paused seek or named-group scan. fold: matches!( - self.program.instruction(self.state.pc - 1)[0], + self.opcode(self.state.pc - 1)[0], BACKREF_I | NAMED_BACKREF_I ), }; @@ -1143,7 +1163,7 @@ impl Vm<'_, '_, '_, '_> { while low < high { self.charge(1)?; let middle = low + (high - low) / 2; - let [lo, hi] = self.program.range(middle as usize); + let [lo, hi] = self.class_range(middle as usize); if c < lo { high = middle; } else if c > hi { @@ -1159,7 +1179,7 @@ impl Vm<'_, '_, '_, '_> { } for index in index..end { self.charge(1)?; - let [lo, hi] = self.program.range(index as usize); + let [lo, hi] = self.class_range(index as usize); let found = if lo & PROPERTY != 0 { let inside = properties::contains(lo & !PROPERTY, c); // A `v` complement (kind 2): see `class_step`. @@ -1204,7 +1224,7 @@ impl Vm<'_, '_, '_, '_> { while low < high { self.charge(1)?; let middle = low + (high - low) / 2; - let [lo, hi] = self.program.range(middle as usize); + let [lo, hi] = self.class_range(middle as usize); if value < lo { high = middle; } else if value > hi { @@ -1223,7 +1243,7 @@ impl Vm<'_, '_, '_, '_> { let stop = end.min(index.saturating_add(available.min(256) as u32)); for index in index..stop { self.charge(1)?; - let [lo, hi] = self.program.range(index as usize); + let [lo, hi] = self.class_range(index as usize); // A `v` complement (kind 2) negates membership of the whole case // closure, not of each equivalent separately. Under `i` those give // different answers: `[\P{Ll}]` excludes `A`, because `a` folds @@ -1278,7 +1298,7 @@ impl Vm<'_, '_, '_, '_> { self.charge(entry + verified)?; let position = self.cursor.position(); for pc in 0..entry { - let [op, a, _] = self.program.instruction(pc); + let [op, a, _] = self.opcode(pc); if op != SAVE { return Err(ExecError::InvalidProgram); } @@ -1299,15 +1319,15 @@ impl Vm<'_, '_, '_, '_> { let body = self.state.pc; let end = after.checked_sub(1)?; let length = end.checked_sub(body)?; - if length < 1 || length > LEADING_MAX as usize || end >= self.program.instructions() { + if length < 1 || length > LEADING_MAX as usize || end >= self.instruction_count() { return None; } - if self.program.instruction(end)[0] != ASSERT_END { + if self.opcode(end)[0] != ASSERT_END { return None; } (body..end) .all(|pc| { - let [op, a, _] = self.program.instruction(pc); + let [op, a, _] = self.opcode(pc); op == CHAR && a < 128 }) .then_some(length) @@ -1341,10 +1361,10 @@ impl Vm<'_, '_, '_, '_> { opcode } else { self.charge(1)?; - if self.state.pc >= self.program.instructions() { + if self.state.pc >= self.instruction_count() { return Err(ExecError::InvalidProgram); } - let opcode = self.program.instruction(self.state.pc); + let opcode = self.opcode(self.state.pc); self.state.pc += 1; opcode }; @@ -1687,10 +1707,10 @@ impl Vm<'_, '_, '_, '_> { // the set, its group and its filter come from // the program rather than from saved state. let pc = self.state.pc; - if pc >= self.program.instructions() { + if pc >= self.instruction_count() { return Err(ExecError::InvalidProgram); } - let [op, a, filter] = self.program.instruction(pc); + let [op, a, filter] = self.opcode(pc); if !matches!(op, SEQUENCE | SEQUENCE_I) { return Err(ExecError::InvalidProgram); } @@ -1861,7 +1881,7 @@ impl Vm<'_, '_, '_, '_> { // next branch as it takes any instruction. let (mut mask, mut right, mut chained) = (a, b as usize, 1); while mask & bucket == 0 { - let [op, a, b] = self.program.instruction(right); + let [op, a, b] = self.opcode(right); if op != BRANCH || chained >= available { self.state.pc = right; return Ok(Step::Next); @@ -1926,8 +1946,7 @@ impl Vm<'_, '_, '_, '_> { let body = self.state.pc; let held = at >= length && (0..length).all(|offset| { - u32::from(bytes[at - 1 - offset]) - == self.program.instruction(body + offset)[1] + u32::from(bytes[at - 1 - offset]) == self.opcode(body + offset)[1] }); // `b & 1` is the negative form, which succeeds when the // text it describes is not there. @@ -1971,7 +1990,7 @@ impl Vm<'_, '_, '_, '_> { self.store(slot + 1, UNSET)?; } ATOM_REPEAT => { - let r = self.program.repeat(a as usize); + let r = self.repeat_record(a as usize); self.state.pc -= 1; self.state.work = Work::Atom(AtomState { minimum_end: self.cursor.position(), @@ -1983,7 +2002,7 @@ impl Vm<'_, '_, '_, '_> { return Ok(Step::Phase); } REPEAT_CHOICE => { - let r = self.program.repeat(a as usize); + let r = self.repeat_record(a as usize); let slot = self.program.capture_count() * 2 + a as usize * 2; let count = self.scratch.registers[slot]; if r[2] & 1 == 0 && count >= r[1] as usize { @@ -1998,7 +2017,7 @@ impl Vm<'_, '_, '_, '_> { } } REPEAT_BODY => { - let r = self.program.repeat(a as usize); + let r = self.repeat_record(a as usize); let slot = self.program.capture_count() * 2 + a as usize * 2 + 1; if r[5] == r[6] { self.store(slot, self.cursor.position())?; @@ -2012,7 +2031,7 @@ impl Vm<'_, '_, '_, '_> { } } REPEAT_NEXT => { - let r = self.program.repeat(a as usize); + let r = self.repeat_record(a as usize); let slot = self.program.capture_count() * 2 + a as usize * 2; let count = self.scratch.registers[slot]; if self.cursor.position() == self.scratch.registers[slot + 1] @@ -2079,6 +2098,7 @@ pub fn find( let mut state = State::new(start_utf16, input.len_utf16()); let mut vm = Vm { program, + sections: program.sections(), input, cursor: input.cursor(), scratch: &mut scratch, @@ -2119,6 +2139,7 @@ pub fn is_match( state.boolean = true; let mut vm = Vm { program, + sections: program.sections(), input, cursor: input.cursor(), scratch: &mut scratch, diff --git a/src/executor/admission.rs b/src/executor/admission.rs index 3c3060d..536fa87 100644 --- a/src/executor/admission.rs +++ b/src/executor/admission.rs @@ -36,7 +36,7 @@ impl Vm<'_, '_, '_, '_> { } fn required(&self, index: usize) -> u32 { let (pc, reverse) = self.program.admission().unwrap(); - self.program.instruction( + self.opcode( pc + if reverse { self.needle().len() - 1 - index } else { @@ -48,7 +48,7 @@ impl Vm<'_, '_, '_, '_> { /// character is ASCII, which is what permits the original-byte search. fn build_needle(&mut self) -> Result { let (pc, _) = self.program.admission().ok_or(ExecError::InvalidProgram)?; - let op = self.program.instruction(pc)[0]; + let op = self.opcode(pc)[0]; let len = self.program.literal_run(pc); self.charge(len)?; self.state.work = Work::Needle { @@ -140,10 +140,10 @@ impl Vm<'_, '_, '_, '_> { return Ok(()); } let (pc, _) = self.program.admission().ok_or(ExecError::InvalidProgram)?; - let [op, a, b] = self.program.instruction(pc); + let [op, a, b] = self.opcode(pc); if matches!(op, CLASS | CLASS_I | CLASS_SORTED | CLASS_SORTED_I) { if b == 1 && op == CLASS { - let [lo, hi] = self.program.range(a as usize); + let [lo, hi] = self.class_range(a as usize); if hi < 128 && lo <= hi && self.input.original_bytes().is_some() { self.state.phase = Phase::AdmitByteClass { offset: 0, @@ -252,7 +252,7 @@ impl Vm<'_, '_, '_, '_> { if let Some(c) = read(&mut self.cursor, self.program.unicode(), false) { self.charge(1)?; let (pc, _) = self.program.admission().ok_or(ExecError::InvalidProgram)?; - let [op, a, b] = self.program.instruction(pc); + let [op, a, b] = self.opcode(pc); self.begin_class( a, b, diff --git a/src/executor/atom.rs b/src/executor/atom.rs index 6809ff9..8e04444 100644 --- a/src/executor/atom.rs +++ b/src/executor/atom.rs @@ -62,14 +62,14 @@ impl Vm<'_, '_, '_, '_> { } } fn atom_record(&self) -> Result<[u32; 8], ExecError> { - if self.state.pc >= self.program.instructions() { + if self.state.pc >= self.instruction_count() { return Err(ExecError::InvalidProgram); } - let [op, id, _] = self.program.instruction(self.state.pc); + let [op, id, _] = self.opcode(self.state.pc); if op != ATOM_REPEAT { return Err(ExecError::InvalidProgram); } - Ok(self.program.repeat(id as usize)) + Ok(self.repeat_record(id as usize)) } /// Whether this atom can be decided without the resumable class phase. fn inline_atom(&self, op: u32, b: u32) -> bool { @@ -97,7 +97,7 @@ impl Vm<'_, '_, '_, '_> { _ => { let mut found = false; for index in a..a + (b & !NEGATED) { - let [lo, hi] = self.program.range(index as usize); + let [lo, hi] = self.class_range(index as usize); found = if lo & PROPERTY != 0 { if hi == 2 { !properties::contains(lo & !PROPERTY, c) @@ -135,7 +135,7 @@ impl Vm<'_, '_, '_, '_> { let mut ranges = [(0, 0); ATOM_CLASS_RANGES as usize]; let mut kept = 0; for index in a..a + count { - let [lo, hi] = self.program.range(index as usize); + let [lo, hi] = self.class_range(index as usize); if lo & PROPERTY != 0 || lo > hi { return None; } @@ -316,7 +316,7 @@ impl Vm<'_, '_, '_, '_> { if extend && (atom.needed != 0 || (!infinite && atom.remaining == 0)) { return Err(ExecError::InvalidProgram); } - let [op, a, b] = self.program.instruction(self.state.pc + 3); + let [op, a, b] = self.opcode(self.state.pc + 3); // Characters this scan may consume before anything but the atom itself // decides: the minimum still owed, then, for a greedy repeat, its // remaining allowance (unbounded when it has none). A lazy repeat stops @@ -585,22 +585,19 @@ impl Vm<'_, '_, '_, '_> { fn continuation_probe(&self, at: usize) -> Option<[u32; 3]> { let mut at = at; let mut skipped = 0; - while skipped < RETREAT_SCAN - && at < self.program.instructions() - && self.program.instruction(at)[0] == SAVE + while skipped < RETREAT_SCAN && at < self.instruction_count() && self.opcode(at)[0] == SAVE { at += 1; skipped += 1; } - if at >= self.program.instructions() { + if at >= self.instruction_count() { return None; } - let [next, value, third] = self.program.instruction(at); + let [next, value, third] = self.opcode(at); let probe = if next == ATOM_REPEAT { - let inner = self.program.repeat(value as usize); + let inner = self.repeat_record(value as usize); let body = at + 3; - (inner[0] >= 1 && body < self.program.instructions()) - .then(|| self.program.instruction(body))? + (inner[0] >= 1 && body < self.instruction_count()).then(|| self.opcode(body))? } else { [next, value, third] }; @@ -631,7 +628,7 @@ impl Vm<'_, '_, '_, '_> { /// sends the endpoint to the ordinary commit. #[inline(never)] fn lazy_skip(&mut self, r: [u32; 8], available: usize) -> Result { - let [op, a, b] = self.program.instruction(self.state.pc + 3); + let [op, a, b] = self.opcode(self.state.pc + 3); if !self.inline_atom(op, b) { return Ok(false); } @@ -782,17 +779,15 @@ impl Vm<'_, '_, '_, '_> { // The scan is bounded, so it cannot outrun a budget it does not charge. let mut at = r[4] as usize; let mut skipped = 0; - while skipped < RETREAT_SCAN - && at < self.program.instructions() - && self.program.instruction(at)[0] == SAVE + while skipped < RETREAT_SCAN && at < self.instruction_count() && self.opcode(at)[0] == SAVE { at += 1; skipped += 1; } - if at >= self.program.instructions() { + if at >= self.instruction_count() { return Err(ExecError::InvalidProgram); } - let [next, value, third] = self.program.instruction(at); + let [next, value, third] = self.opcode(at); if matches!(next, CHAR | CHAR_I) { return self.retreat_literal(atom.minimum_end, value, next == CHAR_I, available); } @@ -801,10 +796,9 @@ impl Vm<'_, '_, '_, '_> { // needs no retry frame either. A repeat that can match nothing consumes // no character and so states no condition. let probe = if next == ATOM_REPEAT { - let inner = self.program.repeat(value as usize); + let inner = self.repeat_record(value as usize); let body = at + 3; - (inner[0] >= 1 && body < self.program.instructions()) - .then(|| self.program.instruction(body)) + (inner[0] >= 1 && body < self.instruction_count()).then(|| self.opcode(body)) } else { Some([next, value, third]) }; diff --git a/src/executor/candidate.rs b/src/executor/candidate.rs index 864d530..61c5a0b 100644 --- a/src/executor/candidate.rs +++ b/src/executor/candidate.rs @@ -120,7 +120,7 @@ impl Vm<'_, '_, '_, '_> { let mut pc = self.program.leading_pc(); loop { self.charge(1)?; - if let Some((first, second)) = branch_arms(pc, self.program.instruction(pc)) { + if let Some((first, second)) = branch_arms(pc, self.opcode(pc)) { // The derivation proved the branch count fits. if depth == pending.len() { return Ok(true); @@ -132,9 +132,9 @@ impl Vm<'_, '_, '_, '_> { } let mut matched = true; let mut next = pc; - while self.program.instruction(next)[0] == CHAR { + while self.opcode(next)[0] == CHAR { self.charge(1)?; - let want = self.program.instruction(next)[1]; + let want = self.opcode(next)[1]; match bytes.get(at + (next - pc)) { Some(&byte) if u32::from(byte) == want => next += 1, _ => { @@ -176,7 +176,7 @@ impl Vm<'_, '_, '_, '_> { // ASCII-insensitive comparison is exact for a folded run there. let fold = self.program.leading_fold(); for (i, &byte) in window.iter().enumerate() { - let want = self.program.instruction(pc + i)[1]; + let want = self.opcode(pc + i)[1]; let same = if fold { byte.eq_ignore_ascii_case(&(want as u8)) } else { diff --git a/src/program.rs b/src/program.rs index eac429a..a375639 100644 --- a/src/program.rs +++ b/src/program.rs @@ -605,6 +605,16 @@ pub(crate) fn derive_run_skip(words: &[u32], p: Program<'_>) -> u32 { if packed & !RUN_SKIP != 0 { 0 } else { packed } } +/// Borrowed record slices of the program's existing immutable word storage. +/// Constructed for one active evaluator borrow, never retained across advances. +/// No records are copied, decoded into another table, or attached to an owner. +#[derive(Clone, Copy)] +pub(crate) struct ProgramSections<'a> { + pub instructions: &'a [[u32; 3]], + pub ranges: &'a [[u32; 2]], + pub repeats: &'a [[u32; 8]], +} + /// A validated immutable view. All persistent data is in `words`, with no /// pointers, reference counts, source borrow, or separate owning allocation. /// Release this borrow before moving the storage and validate its new borrow. @@ -614,6 +624,20 @@ pub struct Program<'a> { } impl<'a> Program<'a> { + /// The validated header fixes these section extents for this borrow. + /// Taking each slice once lets the evaluator index records against their + /// own bounds instead of rebuilding an offset into all words per opcode. + pub(crate) fn sections(self) -> ProgramSections<'a> { + let (instructions, tail) = self.words[HEADER..].split_at(self.instructions() * 3); + let (ranges, tail) = tail.split_at(self.words[5] as usize * 2); + let repeats = &tail[..self.words[6] as usize * 8]; + ProgramSections { + instructions: instructions.as_chunks::<3>().0, + ranges: ranges.as_chunks::<2>().0, + repeats: repeats.as_chunks::<8>().0, + } + } + /// Validate the entire format, including every opcode, target and table range. /// The experimental native-u32 format requires four-byte alignment; it is /// not a stable wire format or a cross-endian serialization format. diff --git a/tests/sections.rs b/tests/sections.rs new file mode 100644 index 0000000..c774126 --- /dev/null +++ b/tests/sections.rs @@ -0,0 +1,81 @@ +//! Complete public answers across empty/nonempty record sections and a named +//! capture tail. Relocation and every small work quantum are also exercised +//! by the existing resumption suite, which poisons the old program allocation. +use perex::{ + Budget, + compiler::{Node, Range, compile}, + executor::{Frame, Scratch, Undo, find}, + input::Input, + span::Span, +}; + +#[test] +fn record_section_boundaries_preserve_complete_captures_in_both_storages() { + type Captures<'a> = &'a [Option<(usize, usize)>]; + type Case<'a> = (&'a str, &'a str, &'a str, Captures<'a>); + let cases: &[Case<'_>] = &[ + ("a", "", "a", &[Some((0, 1))]), + ("[a-c]", "", "b", &[Some((0, 1))]), + ("a{2}", "", "aa", &[Some((0, 2))]), + ( + "(?[a-c]+)(?:-|_)([0-9]{2,4})(z)?", + "", + "abc-123", + &[Some((0, 7)), Some((0, 3)), Some((4, 7)), None], + ), + ( + "(?é+)([0-9]{2})", + "u", + "éé12", + &[Some((0, 4)), Some((0, 2)), Some((2, 4))], + ), + ( + "(?😀+)([0-9]{2})", + "u", + "😀😀12", + &[Some((0, 6)), Some((0, 4)), Some((4, 6))], + ), + ]; + for &(source, flags, text, expected) in cases { + let mut nodes = vec![Node::default(); 1024]; + let mut ranges = vec![Range::default(); 2048]; + let mut words = vec![0; 16384]; + let program = compile( + Input::utf8(source), + flags, + &mut nodes, + &mut ranges, + &mut words, + &mut Budget::new(1_000_000), + ) + .unwrap(); + let units: Vec<_> = text.encode_utf16().collect(); + for input in [Input::utf8(text), Input::utf16(&units)] { + let mut registers = vec![0; program.register_count()]; + let mut frames = vec![Frame::default(); 256]; + let mut undo = vec![Undo::default(); 2048]; + let mut captures = vec![Span::new(900, 901); program.capture_count()]; + assert!( + find( + program, + input, + 0, + Scratch { + registers: &mut registers, + frames: &mut frames, + undo: &mut undo + }, + &mut captures, + &mut Budget::new(1_000_000), + ) + .unwrap(), + "/{source}/{flags}: {text:?}" + ); + let actual: Vec<_> = captures + .iter() + .map(|s| s.map(|s| (s.start(), s.end()))) + .collect(); + assert_eq!(actual, expected, "/{source}/{flags}: {text:?}"); + } + } +} From a7ae86a9e260c2b434c440e9075091c0d08ffb34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 28 Oct 2026 08:58:22 +0000 Subject: [PATCH 3/4] perf(executor): chain atom phases and borrow class predicates --- CHANGELOG.md | 3 ++ src/executor.rs | 27 ++++++++++++- src/executor/atom.rs | 94 ++++++++++++++++++++++---------------------- tests/run.rs | 6 +++ 4 files changed, 82 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a29ea8..89c6030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- Chain repeated-atom phases within bytecode trials while retaining every work and quantum boundary. +- Borrow ASCII atom class predicates from validated record storage instead of constructing a maximum-sized temporary array on each scan. + - Borrow the immutable instruction, class-range and repeat records once per evaluator view, avoiding repeated layout decoding and whole-word bounds checks in dispatch and atom execution. - Preserve program format 16, complete captures, work accounting, resource rebinding and offset-only resumable state. Record views do not survive an advance. diff --git a/src/executor.rs b/src/executor.rs index af65c9f..c335a12 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1379,7 +1379,32 @@ impl Vm<'_, '_, '_, '_> { self.state.phase = Phase::Fail; return Ok(()); } - Ok(Step::Phase) => return Ok(()), + Ok(Step::Phase) => { + // Repeated atoms hand off between scan and commit before + // continuing ordinary bytecode. Keep those transitions in + // this trial, with exactly the outer loop's budget/quantum + // boundary before every phase. No view or derived state + // survives an advance, and capacity errors retain the + // phase that must be retried rather than replaying an op. + loop { + let used = initial - self.budget.remaining(); + if PAUSABLE && used >= quantum { + return Ok(()); + } + let available = if PAUSABLE { quantum - used } else { usize::MAX }; + match self.state.phase { + Phase::AtomScan => self.atom_scan(false, available)?, + Phase::AtomExtend => self.atom_scan(true, available)?, + Phase::AtomResult { matched, extend } => { + self.atom_result(matched, extend)? + } + Phase::AtomCommit => self.atom_commit(available)?, + Phase::AtomRetreat => self.atom_retreat(available)?, + Phase::Trial => break, + _ => return Ok(()), + } + } + } Err(error) => { if matches!(error, ExecError::Frames | ExecError::Undo) { self.state.phase = Phase::Execute { op, a, b }; diff --git a/src/executor/atom.rs b/src/executor/atom.rs index 8e04444..b771635 100644 --- a/src/executor/atom.rs +++ b/src/executor/atom.rs @@ -4,7 +4,7 @@ use super::*; /// What a fixed-charge atom accepts, as a predicate over original ASCII bytes. #[derive(Clone, Copy)] -enum AtomBytes { +enum AtomBytes<'p> { /// Inside an inclusive range, as a plain single-range class. Inside(u8, u8), /// Outside one, as that class negated. @@ -15,21 +15,24 @@ enum AtomBytes { /// Anything, as `.` with the `s` flag. Every, /// Inside any of several inclusive ranges, or outside all of them when - /// negated. Only ranges an ASCII subject can reach are kept. - Ranges([(u8, u8); ATOM_CLASS_RANGES as usize], u8, bool), + /// negated. Borrow the original class records for this active view. + Ranges(&'p [[u32; 2]], bool), + /// Either ASCII case of a letter in a continuation probe. + Either(u8, u8), } -impl AtomBytes { +impl AtomBytes<'_> { fn holds(self, byte: u8) -> bool { match self { Self::Inside(lo, hi) => byte >= lo && byte <= hi, Self::Outside(lo, hi) => byte < lo || byte > hi, Self::Unlined => byte != b'\n' && byte != b'\r', Self::Every => true, - Self::Ranges(ranges, count, negated) => { - let inside = ranges[..count as usize] + Self::Either(a, b) => byte == a || byte == b, + Self::Ranges(ranges, negated) => { + let inside = ranges .iter() - .any(|&(lo, hi)| byte >= lo && byte <= hi); + .any(|&[lo, hi]| u32::from(byte) >= lo && u32::from(byte) <= hi); inside != negated } } @@ -54,7 +57,7 @@ const RETREAT_SCAN: usize = 8; /// classes ordinary patterns repeat. const ATOM_CLASS_RANGES: u32 = 16; -impl Vm<'_, '_, '_, '_> { +impl<'p> Vm<'_, 'p, '_, '_> { fn atom_state(&self) -> Result { match self.state.work { Work::Atom(state) => Ok(state), @@ -122,7 +125,7 @@ impl Vm<'_, '_, '_, '_> { /// The charge one character of this atom always costs, when that is the /// same whatever the character is, with the predicate to walk it by. `None` /// when the charge is data-dependent, which a byte scan cannot reproduce. - fn fixed_atom_charge(&self, op: u32, a: u32, b: u32) -> Option<(usize, AtomBytes)> { + fn fixed_atom_charge(&self, op: u32, a: u32, b: u32) -> Option<(usize, AtomBytes<'p>)> { match op { // One unit for the character; `equal` charges nothing. CHAR if a < 128 => Some((1, AtomBytes::Inside(a as u8, a as u8))), @@ -132,28 +135,26 @@ impl Vm<'_, '_, '_, '_> { // which is the same whether or not it matches. CLASS if b & !NEGATED <= ATOM_CLASS_RANGES && b & !NEGATED != 0 => { let count = b & !NEGATED; - let mut ranges = [(0, 0); ATOM_CLASS_RANGES as usize]; - let mut kept = 0; - for index in a..a + count { - let [lo, hi] = self.class_range(index as usize); + // Borrow the already validated class records for this view. + // Do not zero, fill and return a maximum-sized predicate array + // at every repeat. No reference enters resumable state. + let records = self.sections.ranges; + let ranges = records.get(a as usize..(a + count) as usize)?; + for &[lo, hi] in ranges { if lo & PROPERTY != 0 || lo > hi { return None; } - // A range beyond ASCII can never match this storage, so it - // is dropped rather than making the class ineligible. - if lo < 128 { - ranges[kept] = (lo as u8, hi.min(127) as u8); - kept += 1; - } } let negated = b & NEGATED != 0; - // One range keeps its own predicate: the general form walks a - // slice per word, which costs more than the single comparison - // the common case needs. - let accepts = match (kept, negated) { - (1, false) => AtomBytes::Inside(ranges[0].0, ranges[0].1), - (1, true) => AtomBytes::Outside(ranges[0].0, ranges[0].1), - _ => AtomBytes::Ranges(ranges, kept as u8, negated), + let accepts = match ranges { + &[[lo, hi]] if lo < 128 => { + if negated { + AtomBytes::Outside(lo as u8, hi.min(127) as u8) + } else { + AtomBytes::Inside(lo as u8, hi.min(127) as u8) + } + } + _ => AtomBytes::Ranges(ranges, negated), }; Some((1 + count as usize, accepts)) } @@ -172,16 +173,20 @@ impl Vm<'_, '_, '_, '_> { /// and rebuilding its comparison words there, costs more than the /// comparison itself; decided once, each loop is a fixed sequence over a /// word the compiler can widen further where the target allows. - fn byte_run(bytes: &[u8], start: usize, accepts: AtomBytes, limit: usize) -> usize { + fn byte_run(bytes: &[u8], start: usize, accepts: AtomBytes<'_>, limit: usize) -> usize { let end = bytes.len().min(start + limit); match accepts { AtomBytes::Every => end - start, AtomBytes::Inside(lo, hi) => Self::run_range(bytes, start, end, lo, hi, false), AtomBytes::Outside(lo, hi) => Self::run_range(bytes, start, end, lo, hi, true), AtomBytes::Unlined => Self::run_unlined(bytes, start, end), - AtomBytes::Ranges(ranges, count, negated) => { - Self::run_ranges(bytes, start, end, ranges, count as usize, negated) + AtomBytes::Ranges(ranges, negated) => { + Self::run_ranges(bytes, start, end, ranges, negated) } + AtomBytes::Either(a, b) => bytes[start..end] + .iter() + .take_while(|&&byte| byte == a || byte == b) + .count(), } } @@ -236,11 +241,10 @@ impl Vm<'_, '_, '_, '_> { bytes: &[u8], start: usize, end: usize, - ranges: [(u8, u8); ATOM_CLASS_RANGES as usize], - count: usize, + ranges: &[[u32; 2]], negated: bool, ) -> usize { - let ranges = &ranges[..count]; + let count = ranges.len(); // Past a few blocks, testing each range against every lane of a block // widens into vector comparisons where the target has them. Below that // the block's own setup costs more than the lane arithmetic it @@ -251,9 +255,10 @@ impl Vm<'_, '_, '_, '_> { while base + LANES <= end { let block: [u8; LANES] = bytes[base..base + LANES].try_into().unwrap(); let mut inside = [0u8; LANES]; - for &(lo, hi) in ranges { + for &[lo, hi] in ranges { for lane in 0..LANES { - inside[lane] |= u8::from(block[lane] >= lo && block[lane] <= hi); + inside[lane] |= + u8::from(u32::from(block[lane]) >= lo && u32::from(block[lane]) <= hi); } } // Find the lane the run stops at through the word it lands @@ -277,8 +282,12 @@ impl Vm<'_, '_, '_, '_> { // matches, so it does not build them either. if base + 8 <= end { let mut bounds = [(0u64, 0u64); ATOM_CLASS_RANGES as usize]; - for (bound, &(lo, hi)) in bounds.iter_mut().zip(ranges) { - *bound = (u64::from(lo) * ONES, u64::from(hi) * ONES); + for (bound, &[lo, hi]) in bounds.iter_mut().zip(ranges) { + *bound = if lo < 128 { + (u64::from(lo) * ONES, u64::from(hi.min(127)) * ONES) + } else { + (ONES, 0) + }; } let bounds = &bounds[..count]; while base + 8 <= end { @@ -297,7 +306,7 @@ impl Vm<'_, '_, '_, '_> { while base < end && (ranges .iter() - .any(|&(lo, hi)| bytes[base] >= lo && bytes[base] <= hi) + .any(|&[lo, hi]| u32::from(bytes[base]) >= lo && u32::from(bytes[base]) <= hi) != negated) { base += 1; @@ -657,16 +666,7 @@ impl Vm<'_, '_, '_, '_> { let byte = value as u8; let folded = next == CHAR_I && byte.is_ascii_alphabetic(); Some(if folded { - AtomBytes::Ranges( - { - let mut ranges = [(0, 0); ATOM_CLASS_RANGES as usize]; - ranges[0] = (byte | 32, byte | 32); - ranges[1] = (byte & !32, byte & !32); - ranges - }, - 2, - false, - ) + AtomBytes::Either(byte | 32, byte & !32) } else { AtomBytes::Inside(byte, byte) }) diff --git a/tests/run.rs b/tests/run.rs index 3555475..a38e184 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -91,6 +91,12 @@ impl ScratchOwner for Buffers { } const PATTERNS: &[(&str, &str)] = &[ + ("(?[a-z0-9_]+)([0-9]*?)(?:!|$)", ""), + ("([a-z]+?)([a-z]*)z", ""), + ("([^b]+)(b+)", "u"), + ("(?<=([a-z]+))b", ""), + ("[a-z]*?A", "i"), + ("[a-z\\u0080-\\uffff]+", "u"), ("a", ""), ("z", ""), ("needle", ""), From fda0af11c42f5c84d365a59d4c8d7d9bdcd30e88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Wed, 28 Oct 2026 10:58:59 +0000 Subject: [PATCH 4/4] Release 0.1.15: reduce evaluator dispatch and record decoding --- CHANGELOG.md | 10 ++++------ Cargo.lock | 2 +- Cargo.toml | 2 +- bench/Cargo.lock | 2 +- 4 files changed, 7 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c6030..80c5eec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,10 @@ # Changelog -## Unreleased +## 0.1.15 — 2026-10-28 -- Chain repeated-atom phases within bytecode trials while retaining every work and quantum boundary. -- Borrow ASCII atom class predicates from validated record storage instead of constructing a maximum-sized temporary array on each scan. - -- Borrow the immutable instruction, class-range and repeat records once per evaluator view, avoiding repeated layout decoding and whole-word bounds checks in dispatch and atom execution. -- Preserve program format 16, complete captures, work accounting, resource rebinding and offset-only resumable state. Record views do not survive an advance. +- Reduce repeated work when matching character repetitions and reading compiled programs. +- Read ASCII character classes directly from validated program storage. +- Preserve program format 16, complete captures, work limits and resumable searches across storage relocation. ## 0.1.14 — 2026-10-18 diff --git a/Cargo.lock b/Cargo.lock index be4d212..b971726 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,4 +4,4 @@ version = 4 [[package]] name = "perex" -version = "0.1.14" +version = "0.1.15" diff --git a/Cargo.toml b/Cargo.toml index 84d29cd..168d11b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "perex" -version = "0.1.14" +version = "0.1.15" edition = "2024" rust-version = "1.88" description = "An ECMAScript regex engine designed for explicit host memory ownership" diff --git a/bench/Cargo.lock b/bench/Cargo.lock index 5e2cb72..612ee5b 100644 --- a/bench/Cargo.lock +++ b/bench/Cargo.lock @@ -80,7 +80,7 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "perex" -version = "0.1.13" +version = "0.1.15" [[package]] name = "perex-bench"