Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions docs/casefold.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 11 additions & 0 deletions docs/classes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
60 changes: 60 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
65 changes: 63 additions & 2 deletions docs/repetition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
10 changes: 10 additions & 0 deletions src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,16 @@ impl Parser<'_, '_> {
}
fn leaf(&mut self, kind: u32, a: u32, mut b: u32) -> Result<u32, CompileError> {
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)?
{
Expand Down
92 changes: 92 additions & 0 deletions src/compiler/classes.rs
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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<Option<usize>, 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,
Expand Down
67 changes: 66 additions & 1 deletion src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<bool>, 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading