From c9a541bb47a122eecc71f5268a39486e457a5472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 27 Sep 2026 11:26:32 +0000 Subject: [PATCH] Take the package-shaped repeats and classes out of the per-character phase loop Perry's package benchmarks put a sixth of its excess over Node across eleven npm packages in regular expressions, three quarters of that in this matcher, and the patterns doing it were short subjects with counted, folded and lazy repeats: uuid's validate, jws's JWS_REGEX, dotenv's line pattern, date format tokens. Each spent its time in phase round trips per character. - A folded class of at most 256 characters with no property is compiled to its case closure as an unfolded class. Equivalence is symmetric, so this answers every character as folding at match time did, and the class can now use the inline scan and the byte run. - A repeat's owed characters, a bounded allowance and a lazy minimum are walked by the run loop and the byte run, not one per round trip. Charges are unchanged per character. - A lazy repeat's commit extends directly past endpoints its continuation's first consumed instruction rejects, byte-wise over ASCII storage, reaching the state commit, fail, rollback and extend reached. The condition's test is uncharged so a resumed commit does not pay it twice. - An unfolded class whose decision fits the phase's batch is decided in the instruction, examining and charging the same ranges. - The inline class test takes up to sixteen ranges, so `\s` (ten) qualifies. Instructions per search against 0.1.10: uuid validate -70.9%, JWS_REGEX -84.9%, `<(.+?)>` -66.1%, trim -40.9%, format tokens -23.6%, dotenv's line -4.8%; searches none of this reaches move by at most +0.3% (docs/performance.md has the table and the method). `[a-z]/i` is no longer refused by the native emitter, since it is now an unfolded class, so it leaves that test's refusal list. --- docs/casefold.md | 23 ++++ docs/classes.md | 11 ++ docs/performance.md | 60 +++++++++ docs/repetition.md | 65 ++++++++- src/compiler.rs | 10 ++ src/compiler/classes.rs | 92 +++++++++++++ src/executor.rs | 67 +++++++++- src/executor/atom.rs | 287 +++++++++++++++++++++++++++++++++++++--- src/native/emit.rs | 1 - tests/atom_repeat.rs | 72 ++++++++++ tests/classes.rs | 43 ++++++ 11 files changed, 705 insertions(+), 26 deletions(-) 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); + } + } + } +}