diff --git a/docs/casefold.md b/docs/casefold.md index ee3f66e..5470968 100644 --- a/docs/casefold.md +++ b/docs/casefold.md @@ -16,3 +16,26 @@ The format version also binds Unicode semantics; a later Unicode update must explicitly version compatibility. Unicode character properties are implemented, while Unicode sets/string properties (`v`), full conformance, host integration, efficient suspension/resumption and per-case CPU/RSS acceptance remain outstanding. + +## Small folded classes are closed at compile time + +A folded class tested each subject character by computing its case equivalents +and searching the class for any of them, and that search is the resumable class +phase, so a repeated folded class could not use the inline scan or the byte run +either. `/[0-9a-f]{8}/i` cost about five hundred instructions a character. + +Equivalence is symmetric — `equivalents` walks one cycle of a family from any +of its members — so a character has an equivalent in the class exactly when it +is in the class's case closure: every character some member is equivalent to. +A folded class of at most 256 characters with no property is therefore compiled +to its closure, sorted and merged, as an unfolded class. `[0-9a-f]` closes to +three ranges, `[a-z]` under `u` to `[A-Za-z\u017f\u212a]`. Negation applies to +membership either way and is kept. Larger classes, and classes holding a +property, keep folding at match time. + +`folded_class_closure_matches_match_time_folding` in `tests/classes.rs` compares +each closed class with the same class plus the Private Use Area, which has no +case mappings and is too large to close, over the BMP's cased blocks and the +astral ones, plain and negated, under `i` and `iu`. Dropping one equivalent from +the closure fails it. `tools/check-casefold.mjs` and `tools/check-classes.mjs` +supply Node's answers for the same forms. diff --git a/docs/classes.md b/docs/classes.md index 2efa6da..3721698 100644 --- a/docs/classes.md +++ b/docs/classes.md @@ -34,3 +34,14 @@ instructions, corrupt ordering, and complete captures/work across relocation. duplicated, overlapping, complemented and folded classes, including original surrogate units, assertions, repeats, backreferences and sticky starts. Passing these checks is not a measured CPU/RSS improvement or full host conformance. + +## Deciding an unfolded class in the instruction + +A class instruction built the resumable class phase for every character, decided +it and took it apart again, even when the whole decision fit in the batch that +phase takes. An unfolded class whose decision fits that batch — a sorted class +with any quantum left, or a linear one of at most 256 ranges within the quantum +— is now decided in the instruction itself: the same ranges examined in the +same order, each charged as the phase charges it, so totals are unchanged at any +quantum. Folded classes, which small ones rarely are now (see +[casefold](casefold.md)), and larger ones keep the phase. diff --git a/docs/performance.md b/docs/performance.md index 18e26ae..1281d2e 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -359,3 +359,63 @@ matches costs a round through admission, one through the candidate scan and trial, and one through the answer, each doing bounds-checked work, where V8 runs native code. The three changes removed what could be removed without removing a round a paused search needs, and what is left is those rounds. + +## Package-shaped searches, 2026-09-27 + +Perry's package benchmarks attributed about a sixth of Perry's excess over +Node across eleven npm packages to regular expressions, three quarters of it in +this matcher. The patterns doing the work were not long scans but short +subjects with counted, folded and lazy repeats: uuid's `validate` +(`[0-9a-f]{8}-…` under `i`), jws's `JWS_REGEX` (`[a-zA-Z0-9\-_]+?\.` twice), +dotenv's line pattern (`\s*` everywhere, `[^#\r\n]+`), date-fns and dayjs +format tokens. Each of those spent its time in phase round trips per character +that no scan could shortcut: + +- a folded class ran the resumable class phase for every character, and so + could not use the inline scan or the byte run ([casefold](casefold.md)); +- a counted repeat's owed characters, a bounded allowance and a lazy minimum + were decided one per round trip ([repetition](repetition.md)); +- a lazy repeat committed, ran its continuation, failed, rolled back and + extended, six round trips per character ([repetition](repetition.md)); +- `\s` has ten ranges, two more than the inline class test took; +- every plain class instruction built and tore down the class phase + ([classes](classes.md)). + +Instructions per search (`perf stat -e instructions:u`, user space, the +difference between 11,000 and 1,000 searches divided by 10,000, so setup +cancels), one warm `find` from position 0 with reused scratch, both arms built +from the same driver against this crate at `3fcc37e` (0.1.10) and with these +changes, on one otherwise idle AMD EPYC 9254 host: + +| Case | Pattern | 0.1.10 | Now | Change | +|---|---|---:|---:|---:| +| uuid | `^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\|0{8}-…\|f{8}-…)$/i` | 20,211 | 5,891 | −70.9% | +| uuid, unfolded | the same without `i` | 15,202 | 5,680 | −62.6% | +| jws | `^[a-zA-Z0-9\-_]+?\.[a-zA-Z0-9\-_]+?\.([a-zA-Z0-9\-_]+)?$` on a 155-character token | 89,373 | 13,491 | −84.9% | +| dotenv | dotenv's line pattern, `mg`, first line of a 36-line document | 38,569 | 36,719 | −4.8% | +| split piece | `%..\|.` on `hello%20world` | 1,342 | 1,346 | +0.3% | +| cron | `^l-\d{1,2}$/i` | 2,828 | 2,054 | −27.4% | +| digits | `^\d+$` on seven digits | 2,345 | 2,059 | −12.2% | +| short literal | `needle` in 29 characters | 1,386 | 1,389 | +0.2% | +| captures | `(\w+)@(\w+)\.com` | 7,950 | 6,881 | −13.4% | +| class repeat | `[a-z]+[0-9]+` | 10,621 | 10,116 | −4.8% | +| word run | `\w+!` over 60 characters | 3,185 | 2,869 | −9.9% | +| folded literal | `needle/i` | 1,754 | 1,758 | +0.2% | +| alternation | `cat\|dog\|bird` after 200 characters | 4,673 | 4,678 | +0.1% | +| lookbehind | `(?<=\$)\d+` | 2,346 | 2,058 | −12.3% | +| lazy any | `<(.+?)>` | 7,653 | 2,592 | −66.1% | +| trim | `^\s+\|\s+$` on 18 characters | 4,390 | 2,596 | −40.9% | +| backreference | `(\w)\1` | 6,245 | 5,577 | −10.7% | +| format tokens | dayjs-style `\[([^\]]+)]\|Y{1,4}\|M{1,4}\|…`, `g` | 4,374 | 3,340 | −23.6% | +| property | `\p{L}+/u` | 2,941 | 2,814 | −4.3% | +| long miss | `zzz` over 1,000 characters | 2,065 | 2,070 | +0.2% | + +The five `+0.x%` rows are searches whose paths these changes do not alter; +they move by three to five instructions a search, and where those few come from +was not isolated. The per +search floor ([above](#the-floor-and-what-it-is)) is untouched: `%..|.` on its +first character, the piece `String.prototype.split` pays per match, is still +about 1,340 instructions, most of it the dispatcher rounds and trial setup. +Measured in Perry on the same host, where these patterns run through the host +runtime, `uuid`'s `REGEX.test` fell from 23,500 to 8,225 instructions per call +and `JWS_REGEX.test` from 96,711 to 15,394. diff --git a/docs/repetition.md b/docs/repetition.md index 6c258fa..6fe6332 100644 --- a/docs/repetition.md +++ b/docs/repetition.md @@ -181,8 +181,9 @@ large for one step; for the small classes ordinary patterns repeat, building it and taking it apart again costs several times the membership test itself, and this is the engine's hottest loop. -A plain class of at most eight ranges is now tested in the scan, which leaves -the step bounded in the same way the sorted-class search is. Folding, sorted +A plain class of at most sixteen ranges (eight when this landed; `\s` has ten, +so it kept the phase until the bound was raised) is now tested in the scan, +which leaves the step bounded in the same way the sorted-class search is. Folding, sorted classes and larger ones keep the phase. Work is charged per range exactly as before, and a charge that fails hands the character to the phase, which re-tests the class from its first range, so resumption is unchanged. @@ -291,3 +292,63 @@ and in the same units: `/a+!/` to 87.8 ns against V8's 38.0, `/[a-z]+!/` to against 39.5. These remain the shapes furthest behind V8 after the flat per-search cost in [performance](performance.md), which about forty-six of each of those nanoseconds is. + +## Counted minimums and bounded allowances as runs + +The run loop and the byte run above served one case: a greedy unbounded repeat +that had already met its minimum. Everything else — the characters a repeat +still owes (`{8}`, the one character `+` owes), a bounded allowance (`{1,3}`) +and a lazy repeat's minimum — went one character per phase round trip, each +re-reading the repeat record and rewriting the work state twice. For a counted +repeat that is every character: `[0-9a-f]{8}` cost about four hundred +instructions a character. + +Within its quota a repeat's characters are decided by the atom alone. The quota +is the minimum still owed plus, for a greedy repeat, its remaining allowance +(unbounded when it has none); a lazy repeat's quota is its minimum. So the same +two loops now take the whole quota, spending the owed count first, then the +allowance, and recording the minimum's end where the ordinary step records it. +A run that ends short of the minimum fails, and one that stops at a character +the atom rejects commits, both as the ordinary step decides them; a run that +takes its whole quota commits without the extra round trip the next scan would +have spent finding nothing left to take. Every character is charged exactly as +the ordinary step charges it, and the quantum still bounds each call, so a +paused search reaches the same total. + +## Taking a lazy repeat past endpoints its continuation cannot start at + +A lazy repeat commits each endpoint by pushing a retry frame and running its +continuation. When the continuation's first consumed instruction rejects the +next character, the continuation fails there having consumed and changed +nothing (the `SAVE`s before it are rolled back), the frame is popped, and the +repeat extends by one character: six phase round trips per character for +`/^[a-z]+?\./`. The state that sequence reaches is exactly the one extending +directly reaches. + +So a commit first looks past the `SAVE`s at the continuation's first consumed +instruction, the same condition [retreating](#retreating-past-endpoints-the-continuation-cannot-start-at) +uses: a literal, folded or not, an inline class or `.`, or a repeat of one of +those that owes at least one character. While the next character is one that +condition rejects, the repeat extends directly, each character decided by the +repeat's own atom and charged as the extension step charges it. Where the atom +rejects the character, or the allowance is spent, the retry the commit would +have left fails, and so does this. The endpoint the continuation could start +at, and the end of the input, go to the ordinary commit. The condition's own +test is not charged, because a commit that asks for more frames is resumed and +asks it again; a quantum that runs out part-way resumes the same commit on the +next round, so a paused search reaches the same total. + +Over ASCII storage the endpoints are walked as bytes: a folded ASCII literal is +its two cases there, since no character that folds into ASCII from outside it +can occur in that storage, and a literal outside ASCII can never start there at +all unless it is folded, which keeps the character path. + +### Checks + +`counted_runs_and_lazy_skips_keep_general_vm_captures_and_errors` in +`tests/atom_repeat.rs` compares these patterns, in all four `i`/`u` +combinations over byte and UTF-16 storage, against the general repetition +instructions of the same VM, including every insufficient work allowance. A +change that skipped an endpoint the continuation could start at fails it +(checked by breaking the condition on purpose). The resumption tests and the +paused and relocating runs of `tools/check-atom-filter.mjs` cover pausing. diff --git a/src/compiler.rs b/src/compiler.rs index 32c54ce..e49b478 100644 --- a/src/compiler.rs +++ b/src/compiler.rs @@ -161,6 +161,16 @@ impl Parser<'_, '_> { } fn leaf(&mut self, kind: u32, a: u32, mut b: u32) -> Result { let mut flags = self.flags; + // A small folded class is replaced by its case closure, which is the + // same membership test without folding each subject character at + // match time (see `close_fold`). + if kind == CLASS + && flags & I != 0 + && let Some(count) = self.close_fold(a as usize, (b & !NEGATED) as usize)? + { + b = (b & NEGATED) | count as u32; + flags &= !I; + } if kind == CLASS && let Some(count) = self.normalize_class(a as usize, (b & !NEGATED) as usize)? { diff --git a/src/compiler/classes.rs b/src/compiler/classes.rs index db844ae..139bcb8 100644 --- a/src/compiler/classes.rs +++ b/src/compiler/classes.rs @@ -1,6 +1,13 @@ //! Normalize large literal-only classes in caller scratch. Properties retain //! their existing representation and membership rules. No extra arena is used. use super::*; +use crate::casefold; + +/// Members a folded class may have and still be replaced by its case closure +/// at compile time. Each member costs one equivalence lookup here, once, in +/// place of one per subject character per test at match time; past this the +/// class keeps folding at match time as before. +const FOLD_CLOSURE_MEMBERS: u32 = 256; fn charge(budget: &mut Budget) -> Result<(), CompileError> { budget.charge(1).map_err(|_| CompileError::WorkLimit) @@ -33,6 +40,91 @@ fn sift( } impl Parser<'_, '_> { + /// Replace a folded class by its case closure: every character some + /// member is case-equivalent to. The evaluator folds a subject character + /// `c` into `equivalents(c)` and accepts when any of them is a member. + /// Case equivalence is symmetric (`casefold::equivalents` walks one cycle + /// of an equivalence family from any of its members), so that holds + /// exactly when `c` is in the closure, and the unfolded class over the + /// closure answers every character identically. Negation applies to the + /// membership either way, so it is kept as it is. + /// + /// Returns the closed class's range count, or `None` when the class keeps + /// its folded form: it holds a property, or more than + /// `FOLD_CLOSURE_MEMBERS` characters. + pub(super) fn close_fold( + &mut self, + start: usize, + count: usize, + ) -> Result, CompileError> { + if count == 0 || start.checked_add(count) != Some(self.range_used) { + return Ok(None); + } + let mut members = 0u32; + for range in &self.ranges[start..self.range_used] { + charge(self.budget)?; + if range.lo & PROPERTY != 0 || range.lo > range.hi { + return Ok(None); + } + members = members.saturating_add(range.hi - range.lo + 1); + if members > FOLD_CLOSURE_MEMBERS { + return Ok(None); + } + } + let unicode = self.flags & U != 0; + let original = start + count; + for index in start..original { + let Range { lo, hi } = self.ranges[index]; + for c in lo..=hi { + charge(self.budget)?; + for value in casefold::equivalents(c, unicode) { + if value == c + || self.ranges[start..original] + .iter() + .any(|r| value >= r.lo && value <= r.hi) + { + continue; + } + // Consecutive members fold to consecutive partners, as a + // letter range does, so extend the last addition where + // that continues it. + let last = self.range_used - 1; + if self.range_used > original + && self.ranges[last].hi.checked_add(1) == Some(value) + { + self.ranges[last].hi = value; + } else { + self.range(value, value)?; + } + } + } + } + // Sort by lower bound and merge, so the closure is as few ranges as + // it can be: `[0-9a-f]` closes to three, not twenty-two. + let ranges = &mut self.ranges[start..self.range_used]; + for i in 1..ranges.len() { + let mut j = i; + while j > 0 && (ranges[j - 1].lo, ranges[j - 1].hi) > (ranges[j].lo, ranges[j].hi) { + charge(self.budget)?; + ranges.swap(j - 1, j); + j -= 1; + } + } + let mut used = 0; + for i in 0..ranges.len() { + charge(self.budget)?; + let next = ranges[i]; + if used != 0 && next.lo <= ranges[used - 1].hi.saturating_add(1) { + ranges[used - 1].hi = ranges[used - 1].hi.max(next.hi); + } else { + ranges[used] = next; + used += 1; + } + } + self.range_used = start + used; + Ok(Some(used)) + } + pub(super) fn normalize_class( &mut self, start: usize, diff --git a/src/executor.rs b/src/executor.rs index 597af43..cc573a6 100644 --- a/src/executor.rs +++ b/src/executor.rs @@ -1118,6 +1118,65 @@ impl Vm<'_, '_, '_, '_> { }, }; } + /// Decide an unfolded class for one character without the resumable class + /// phase, when the decision fits in the batch that phase would take: the + /// same ranges examined in the same order, each charged exactly as + /// `class_step` charges it, so totals are unchanged at any quantum. + /// Building that phase and taking it apart again cost more than the test + /// itself on most classes. `None` when the phase is needed: a sorted class + /// with no quantum left, or more ranges than one batch covers. + #[inline] + fn class_decide( + &mut self, + a: u32, + b: u32, + c: u32, + sorted: bool, + available: usize, + ) -> Result, ExecError> { + let (index, end, negated) = (a, a + (b & !NEGATED), b & NEGATED != 0); + if sorted { + if available == 0 { + return Ok(None); + } + let (mut low, mut high) = (index, end); + while low < high { + self.charge(1)?; + let middle = low + (high - low) / 2; + let [lo, hi] = self.program.range(middle as usize); + if c < lo { + high = middle; + } else if c > hi { + low = middle + 1; + } else { + return Ok(Some(!negated)); + } + } + return Ok(Some(negated)); + } + if (end - index) as usize > available.min(256) { + return Ok(None); + } + for index in index..end { + self.charge(1)?; + let [lo, hi] = self.program.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`. + if hi == 2 { + !inside + } else { + inside != (hi != 0) + } + } else { + c >= lo && c <= hi + }; + if found { + return Ok(Some(!negated)); + } + } + Ok(Some(negated)) + } fn class_step(&mut self, available: usize) -> Result<(), ExecError> { let Phase::Class { index, @@ -1456,7 +1515,7 @@ impl Vm<'_, '_, '_, '_> { 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()?, + Phase::AtomCommit => self.atom_commit(available)?, Phase::AtomRetreat => self.atom_retreat(available)?, Phase::Named { group, @@ -1743,6 +1802,12 @@ impl Vm<'_, '_, '_, '_> { } CLASS | CLASS_I | CLASS_SORTED | CLASS_SORTED_I => { if let Some(c) = self.read() { + if matches!(op, CLASS | CLASS_SORTED) + && let Some(found) = + self.class_decide(a, b, c, op == CLASS_SORTED, available)? + { + return Ok(if found { Step::Next } else { Step::Fail }); + } self.begin_class( a, b, diff --git a/src/executor/atom.rs b/src/executor/atom.rs index f0ad707..6809ff9 100644 --- a/src/executor/atom.rs +++ b/src/executor/atom.rs @@ -52,7 +52,7 @@ const RETREAT_SCAN: usize = 8; /// Ranges a repeated class may hold and still be tested in one step. The step /// stays bounded work, like the sorted-class search, while covering the small /// classes ordinary patterns repeat. -const ATOM_CLASS_RANGES: u32 = 8; +const ATOM_CLASS_RANGES: u32 = 16; impl Vm<'_, '_, '_, '_> { fn atom_state(&self) -> Result { @@ -78,15 +78,23 @@ impl Vm<'_, '_, '_, '_> { /// Membership for an atom `inline_atom` accepted. fn atom_holds(&mut self, op: u32, a: u32, b: u32, c: u32) -> Result { - Ok(match op { + if op == CLASS { + // Charged for the whole class rather than per range examined. + // A byte scan cannot know which range matched, and a paused + // search must reach the same total as an unpaused one, so the + // cost of a repeated class is its size either way. + self.charge((b & !NEGATED) as usize)?; + } + Ok(self.atom_member(op, a, b, c)) + } + + /// `atom_holds` without its charge, for a test that decides no step of its + /// own and so must not pay again when the step it serves is resumed. + fn atom_member(&self, op: u32, a: u32, b: u32, c: u32) -> bool { + match op { CHAR => equal(self.program, c, a, false), ANY | ANY_S => op == ANY_S || !line_terminator(c), _ => { - // Charged for the whole class rather than per range examined. - // A byte scan cannot know which range matched, and a paused - // search must reach the same total as an unpaused one, so the - // cost of a repeated class is its size either way. - self.charge((b & !NEGATED) as usize)?; let mut found = false; for index in a..a + (b & !NEGATED) { let [lo, hi] = self.program.range(index as usize); @@ -105,7 +113,7 @@ impl Vm<'_, '_, '_, '_> { } found != (b & NEGATED != 0) } - }) + } } /// The charge one character of this atom always costs, when that is the @@ -309,17 +317,25 @@ impl Vm<'_, '_, '_, '_> { return Err(ExecError::InvalidProgram); } let [op, a, b] = self.program.instruction(self.state.pc + 3); - // Once a greedy unbounded repeat has met its minimum, consuming another - // character changes no counter: it only advances the cursor. Walking - // that run here instead of one character per phase round trip avoids - // re-reading the repeat record and rewriting the work state twice each - // time. Every decision still belongs to the code below, which re-reads - // the character the run stopped at. - if !extend && r[2] & 2 == 0 && infinite && atom.needed == 0 && self.inline_atom(op, b) { - // The most one character can charge: its own unit plus a range - // walk. Keeping that much quantum and budget in hand means no - // charge inside the loop can fail, so the cursor never stops - // between a character's read and its decision. + // 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 + // at its minimum. Within that quota each consumed character only + // advances the cursor and a counter, so it is walked here instead of + // one character per phase round trip, which re-reads the repeat record + // and rewrites the work state twice per character. Every character is + // charged exactly as the ordinary step below charges it, and the + // character a run stops at is decided by that step, as before. + let lazy = r[2] & 2 != 0; + let allowance = if lazy { + 0 + } else if infinite { + usize::MAX + } else { + atom.remaining as usize + }; + let quota = (atom.needed as usize).saturating_add(allowance); + if !extend && quota != 0 && self.inline_atom(op, b) { // A fixed-charge atom over ASCII storage is walked as bytes. The // charge is identical to deciding each character separately, so a // pause still reaches the same total, and the cursor moves once. @@ -336,9 +352,18 @@ impl Vm<'_, '_, '_, '_> { } else { (available / per).min(self.budget.remaining() / per) }; + let limit = limit.min(quota); let run = Self::byte_run(bytes, start, accepts, limit); if run != 0 { self.charge(run * per)?; + let owed = (atom.needed as usize).min(run); + if owed != 0 { + atom.needed -= owed as u32; + atom.minimum_end = start + owed; + } + if !infinite && !lazy { + atom.remaining -= (run - owed) as u32; + } atom.before = self .input .cursor_at(start + run - 1) @@ -349,6 +374,13 @@ impl Vm<'_, '_, '_, '_> { .cursor_at(start + run) .ok_or(ExecError::InvalidProgram)?; self.state.work = Work::Atom(atom); + if run == quota { + // The minimum is met and nothing more may be taken + // without a decision: commit, which is where the next + // round of this phase would go. + self.state.phase = Phase::AtomCommit; + return Ok(()); + } } // The stopping character is left to the ordinary step below, // which charges and decides it exactly as it always has. @@ -356,10 +388,18 @@ impl Vm<'_, '_, '_, '_> { return Ok(()); } } + let quota = (atom.needed as usize).saturating_add(if lazy { + 0 + } else if infinite { + usize::MAX + } else { + atom.remaining as usize + }); let cost = 1 + if op == CLASS { b & !NEGATED } else { 0 } as usize; let mut used = 0; + let mut taken = 0; let mut ended = false; - while used + cost <= available && self.budget.remaining() > cost { + while taken < quota && used + cost <= available && self.budget.remaining() > cost { let before = self.cursor.mark(); // Charged in the same order as the ordinary step: the character // first, then whatever deciding it costs. A pause must not @@ -375,17 +415,33 @@ impl Vm<'_, '_, '_, '_> { ended = true; break; } + if atom.needed > 0 { + atom.needed -= 1; + atom.minimum_end = self.cursor.position(); + } else if !infinite { + atom.remaining -= 1; + } atom.before = before; used += cost; + taken += 1; } self.state.work = Work::Atom(atom); if ended { // The run stops here. This is the same conclusion the ordinary // step reaches, taken without charging the stopping character - // twice, and the run's end is recorded for the next start. + // twice, and the run's end is recorded for the next start. A + // run that stops short of its minimum fails, as there. if self.program.run_skip() == Some(self.state.pc) { self.state.run_end = self.cursor.position(); } + self.state.phase = if atom.needed != 0 { + Phase::Fail + } else { + Phase::AtomCommit + }; + return Ok(()); + } + if taken != 0 && taken == quota { self.state.phase = Phase::AtomCommit; return Ok(()); } @@ -490,13 +546,17 @@ impl Vm<'_, '_, '_, '_> { Ok(()) } - pub(super) fn atom_commit(&mut self) -> Result<(), ExecError> { + pub(super) fn atom_commit(&mut self, available: usize) -> Result<(), ExecError> { let atom = self.atom_state()?; let r = self.atom_record()?; if atom.needed != 0 { return Err(ExecError::InvalidProgram); } let lazy = r[2] & 2 != 0; + if lazy && (r[2] & 1 != 0 || atom.remaining != 0) && self.lazy_skip(r, available)? { + return Ok(()); + } + let atom = self.atom_state()?; let retry = if lazy { r[2] & 1 != 0 || atom.remaining != 0 } else { @@ -518,6 +578,189 @@ impl Vm<'_, '_, '_, '_> { Ok(()) } + /// A condition the first character a continuation consumes must meet, for + /// the continuation starting at `at`: the `SAVE`s before it consume nothing + /// and cannot fail, so they are looked past, as a retreat does. `None` when + /// the continuation states no such condition this can test in one step. + 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 + { + at += 1; + skipped += 1; + } + if at >= self.program.instructions() { + return None; + } + let [next, value, third] = self.program.instruction(at); + let probe = if next == ATOM_REPEAT { + let inner = self.program.repeat(value as usize); + let body = at + 3; + (inner[0] >= 1 && body < self.program.instructions()) + .then(|| self.program.instruction(body))? + } else { + [next, value, third] + }; + (matches!(probe[0], CHAR | CHAR_I) || self.inline_atom(probe[0], probe[2])).then_some(probe) + } + + /// Take a lazy repeat past the endpoints its continuation cannot start at. + /// + /// Committing a lazy repeat's endpoint pushes a retry frame and runs the + /// continuation; when the continuation's first consumed character fails + /// there, the frame is popped, rolled back and the repeat extended by one + /// character, which is decided by the phase round trips after it. At an + /// endpoint whose next character the continuation's first consumed + /// instruction rejects, all of that reaches the same state as extending + /// directly: the continuation consumed nothing and changed nothing before + /// failing, and its `SAVE`s, if any, are undone by the rollback. So this + /// extends directly while that holds, and leaves the endpoint the + /// continuation could start at, if any, to the ordinary commit. The + /// repeat's own atom decides each character exactly as an extension would; + /// where it rejects one, or the allowance runs out, the retry the commit + /// would have left fails, and so does this. + /// + /// Each skipped endpoint is charged one for the character and whatever its + /// atom test costs, as an extension step is, and at least one endpoint is + /// taken per call; a skip the quantum interrupts resumes in this same phase, + /// so a paused skip reaches the same total. Returns whether this left the + /// phase decided (a failure, or more to walk on a later round); `false` + /// 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); + if !self.inline_atom(op, b) { + return Ok(false); + } + let Some([next, value, third]) = self.continuation_probe(r[4] as usize) else { + return Ok(false); + }; + let mut atom = self.atom_state()?; + let infinite = r[2] & 1 != 0; + let cost = 1 + if op == CLASS { b & !NEGATED } else { 0 } as usize; + let limit = available.min(256).max(cost); + let mut used = 0; + // Over ASCII storage the endpoints are bytes. Walk the ones the + // continuation rejects and the atom accepts as a byte scan, charging + // each exactly as the step below does; the endpoint it stops at is + // decided by that step. + if !self.state.reverse + && let Some(bytes) = self.input.ascii_bytes() + && let Some((per, accepts)) = self.fixed_atom_charge(op, a, b) + && per == cost + { + // Where the continuation could start: its literal's byte (either + // case of a folded ASCII letter, as `equal` decides it on ASCII + // storage), or its first atom's byte predicate. + let stops = match next { + CHAR | CHAR_I if value < 128 => { + 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, + ) + } else { + AtomBytes::Inside(byte, byte) + }) + } + // A literal outside ASCII: an unfolded one never occurs in + // this storage, and a folded one is left to the step below. + CHAR => Some(AtomBytes::Outside(0, 127)), + CHAR_I => None, + _ => self.fixed_atom_charge(next, value, third).map(|(_, p)| p), + }; + if let Some(stops) = stops { + let start = self.cursor.position(); + let mut end = start; + let most = (limit / cost).min(if infinite { + usize::MAX + } else { + atom.remaining as usize + }); + while end < bytes.len() + && end - start < most + && !stops.holds(bytes[end]) + && accepts.holds(bytes[end]) + { + end += 1; + } + let taken = end - start; + if taken != 0 { + self.charge(taken * cost)?; + if !infinite { + atom.remaining -= taken as u32; + } + atom.before = self + .input + .cursor_at(end - 1) + .ok_or(ExecError::InvalidProgram)? + .mark(); + self.cursor = self.input.cursor_at(end).ok_or(ExecError::InvalidProgram)?; + used = taken * cost; + } + } + } + loop { + let before = self.cursor.mark(); + let found = self.read(); + self.restore(before); + let Some(c) = found else { + // Neither the continuation nor an extension can consume + // anything here; the retry the commit leaves would fail. + return Ok(false); + }; + // Uncharged: a commit that asks for more frames is resumed and + // asks this again, and must not pay for it twice. + let possible = match next { + CHAR | CHAR_I => equal(self.program, c, value, next == CHAR_I), + _ => self.atom_member(next, value, third, c), + }; + if possible { + self.state.work = Work::Atom(atom); + return Ok(false); + } + if used + cost > limit { + // The quantum is spent: resume this same commit on the next + // round, so where a pause falls changes no total. + self.state.work = Work::Atom(atom); + return Ok(true); + } + if !infinite && atom.remaining == 0 { + // No retry frame would be left, and the continuation fails. + self.state.work = Work::Atom(atom); + self.state.phase = Phase::Fail; + return Ok(true); + } + self.charge(1)?; + if !self.atom_holds(op, a, b, c)? { + // The extension the retry would try fails at this character. + self.state.work = Work::Atom(atom); + if self.program.run_skip() == Some(self.state.pc) { + self.state.run_end = self.cursor.position(); + } + self.state.phase = Phase::Fail; + return Ok(true); + } + self.read(); + if !infinite { + atom.remaining -= 1; + } + atom.before = before; + used += cost; + } + } + pub(super) fn atom_retreat(&mut self, available: usize) -> Result<(), ExecError> { let atom = self.atom_state()?; let r = self.atom_record()?; diff --git a/src/native/emit.rs b/src/native/emit.rs index afb25e1..1ab222f 100644 --- a/src/native/emit.rs +++ b/src/native/emit.rs @@ -1775,7 +1775,6 @@ mod tests { for (pattern, flags) in [ ("a+?", ""), ("a|b", ""), - ("[a-z]", "i"), ("a", "y"), ("^a", "m"), ("a$", "m"), diff --git a/tests/atom_repeat.rs b/tests/atom_repeat.rs index 410f26a..dec988e 100644 --- a/tests/atom_repeat.rs +++ b/tests/atom_repeat.rs @@ -297,3 +297,75 @@ fn literal_retry_filter_keeps_general_vm_captures_and_errors() { } } } + +/// Counted repeats walked as runs, and lazy repeats taken past endpoints their +/// continuation cannot start at, give the general VM's answers and captures, +/// and every insufficient work allowance still fails without output. +#[test] +fn counted_runs_and_lazy_skips_keep_general_vm_captures_and_errors() { + for source in [ + r"^([0-9a-f]{8})-([0-9a-f]{4})$", + r"^(?:[0-9a-f]{2}-[0-9a-f]{3}|0{2}-0{3})$", + r"^([a-z]{2,4}?)\.([a-z]+)$", + r"^([a-z]+?)\.([a-z]+?)\.([a-z]+)?$", + r"^(\w+?)(\d+)$", + r"<(.+?)>", + r"^(\s{2,})(\S+?)\s*$", + r"^([^,]{1,3}?),", + r"(?<=^([a-z]+?)\.)x", + r"^(x{2,3}?)k", + r"^(.+?)ſ", + r"^([a-z]{1,2}?)K", + r"^(\d{1,2})\D", + r"^([a-z]+?)(?:\.|$)", + ] { + for flags in ["", "u", "i", "ui"] { + let optimized = words(source, flags); + let original = general(optimized.clone()); + for subject in [ + "", + "3b241101-e2bb", + "3B241101-E2BB", + "ab-cde", + "00-000", + "abc.def", + "aBc.dEf.gh", + "a.b.", + "abc123", + "", + " word ", + "ab,cd", + "abcd,", + "abc.x", + "xxxk", + "xxK", + "xxxxk", + "abſ", + "abS", + "ak", + "a\u{212a}", + "12x", + "123x", + "é.é.é", + "😀.a.b", + ] { + let units: Vec<_> = subject.encode_utf16().collect(); + for input in [Input::utf8(subject), Input::utf16(&units)] { + let a = execute(&optimized, input, 1024, 8192, 2_000_000); + let b = execute(&original, input, 1024, 8192, 2_000_000); + assert_eq!((&a.0, &a.1), (&b.0, &b.1), "{source} {flags} {subject}"); + for allowance in 0..a.2 { + let (error, captures, _) = + execute(&optimized, input, 1024, 8192, allowance); + assert_eq!( + error, + Err(ExecError::WorkLimit), + "{source} {flags} {subject} {allowance}" + ); + assert!(captures.iter().all(|s| *s == Span::new(900, 901))); + } + } + } + } + } +} diff --git a/tests/classes.rs b/tests/classes.rs index 696fb56..ef0e33b 100644 --- a/tests/classes.rs +++ b/tests/classes.rs @@ -129,3 +129,46 @@ fn binary_and_linear_class_membership_preserve_full_capture_results() { } } } + +/// A small folded class is compiled to its case closure. The closure must +/// answer every character exactly as folding at match time does. The same +/// class plus the Private Use Area (which has no case mappings, so it changes +/// no answer outside itself) is too large to close and keeps folding at match +/// time, so the two programs are a differential of the two forms. +#[test] +fn folded_class_closure_matches_match_time_folding() { + const PUA: &str = r"-"; + let classes = [ + "a-z", "0-9a-f", "k", "s", r"K", r"ſ", "σ", "à-þ", "a-f-", r"\w", r"\d", "Dž", r"ẞ", "ß", + r"İı", "Ω", + ]; + for flags in ["i", "iu"] { + for class in classes { + for negated in ["", "^"] { + let closed = program(&format!("^[{negated}{class}]$"), flags); + let folded = program(&format!("^[{negated}{class}{PUA}]$"), flags); + let mut checked = 0; + for c in (0..0x3000u32) + .chain(0xa640..0xa800) + .chain(0xfb00..0xfb20) + .chain(0xff00..0xff60) + .chain(0x10400..0x10450) + .chain(0x1e900..0x1e950) + { + let Some(ch) = char::from_u32(c) else { + continue; + }; + let mut buffer = [0u16; 2]; + let units = ch.encode_utf16(&mut buffer); + // Astral characters are two units without `u`, which a + // one-character class never matches either way. + let a = answer(&closed, units).is_some(); + let b = answer(&folded, units).is_some(); + assert_eq!(a, b, "[{negated}{class}]/{flags} at U+{c:04X}"); + checked += 1; + } + assert!(checked > 12_000); + } + } + } +}