Eliminate the Pike VM's per-thread allocation, add a real BMH literal
prefilter, research and reject a lazy DFA cache Researched two production Pike-VM-family engines directly (RE2's nfa.cc, rust-lang/regex's regex-automata), fetched and read, not recalled, specifically for what explains the two weaknesses the last benchmark found (a*b's nullable loop, dense finditer): both avoid per-thread malloc/free, RE2 via a Thread free list, regex-automata via a flat SlotTable indexed directly by NFA state. Also researched Hyperscan's Teddy (SIMD literal matching) and set it aside: it needs platform-specific intrinsics, in tension with this project's single-file, portable-C, simplicity-over-performance priority: a portable Boyer-Moore-Horspool skip was judged proportionate where Teddy was not. Rewrote the Pike VM's memory model accordingly. PikeThread's owned int64_t* is gone; a committed thread's capture row now lives at a fixed offset in PikeList.table, indexed directly by instruction PC (Cox's one-thread-per-PC invariant already made this index unique, so no allocation is needed to store or discard one). Transient rows needed mid-closure, before the eventual terminal PC is known, come from ScratchPool, a fixed block with an explicit free list; a plain bump/decrement counter was tried first and proven incorrect by hand before being written into the file (an OP_SPLIT's second branch's row can outlive several non-branching pushes that reuse an *earlier* row without reallocating it, which only a real free list handles safely regardless of release order). The closure stack is pre-sized once instead of grown by realloc on demand. All three, plus a reusable best-match buffer and the prefilter below, bundle into one PikeEngine, built once per top level Pattern_/re_ call and reused across every match found within it, never cached on PatternImpl itself (that would make concurrent Pattern_search calls on the same compiled Pattern from different threads race on shared state, breaking the existing no-synchronization-needed guarantee for a Pattern nothing mutates). Extended the literal prefilter from a single leading character to the full mandatory literal prefix, with a real Boyer-Moore-Horspool bad-character skip table for BINARY/ASCII mode (UTF8 keeps a without-skip fallback: a byte-indexed table cannot cover code points past 0x10FFFF). The skip-ahead only ever applies to where a new unanchored start is injected, never to advancing sp itself while a thread from an earlier start position is still alive. Researched and did not build a lazy DFA state cache (memoizing a live-instruction-set-plus-byte transition). Not an omission: RE2's own lazy DFA cannot track submatch boundaries, the same structural reason applies here, since every call wants at least group 0's span, and a cached transition only answers whether a match is possible, not which path was taken; using one would need a two-phase architecture deserving its own research-and-plan pass. Checked empirically too: a*b, the case such a cache would help most, has at most two live instructions at any position for its whole run, so there is no repeated state worth caching in the first place. Verified: full 3,252-case suite (four clean runs), a whitebox dual-engine cross-check extended to also cover finditer/split/BINARY mode (30,000 + 3,334 + 2,500 comparisons, zero mismatches), a targeted suite for the new prefilter machinery including the classic Boyer-Moore-Horspool overlapping-suffix correctness trap (12/12), two clean AddressSanitizer/UndefinedBehaviorSanitizer passes on each (a third run of each hit the same pre-existing sandbox flake already documented, confirmed unrelated by retrying clean). Measured: literal search went from 0.64x of the backtracking engine's time (already ahead) to 0.03x (~33x faster), and against POSIX <regex.h> from roughly 11x slower to roughly 2x *faster* than glibc outright; a*b (no prefilter benefit at all) improved from 3.45x slower than backtracking to 1.97x, from the allocation fix alone; dense finditer over [0-9]+/\w+ improved from 3.2x/5.6x slower to 1.7x/2.8x. Full tables and citations in concept.md 7.10; README.md, docs/API.md, USAGE.md, and bench_vs_posix.c's own printed summary updated throughout with the corrected numbers and the full history, not just the final ones. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EjuMk8kY9SDus1wWe2K9xY
This commit is contained in:
@@ -208,11 +208,51 @@ holding the 10MB input itself was roughly 12KB, confirming the design's
|
||||
`O(instruction count x group count)`, input-length-independent memory bound
|
||||
actually holds for the v1 implementation, not only on paper.
|
||||
|
||||
Two things remain genuinely open, not resolved by the above: no lazy DFA
|
||||
state caching or allocation pooling exists yet (`concept.md` 7.7's
|
||||
remaining deferred items), and whether the Pike VM is faster or slower
|
||||
than the backtracking engine specifically, as opposed to against POSIX, on
|
||||
ordinary patterns is still unmeasured.
|
||||
`concept.md` 7.10 closes most of what 7.9 had left open, researched first
|
||||
(RE2's and `rust-lang/regex`'s actual production engines, fetched and read
|
||||
directly, not recalled) then implemented critically, not exhaustively: not
|
||||
everything the research turned up was built. The Pike VM's per-thread
|
||||
`malloc`/`free` (a fresh capture array at every split, freed at every dead
|
||||
thread) is gone, replaced by a flat table indexed directly by instruction
|
||||
PC for live threads (no allocation at all, the technique
|
||||
`rust-lang/regex`'s `SlotTable` uses) and a small, fixed-capacity row pool
|
||||
with an explicit free list for the transient state inside one epsilon
|
||||
closure walk (a plain bump counter was tried first for this and proved
|
||||
incorrect by hand-tracing a release-order violation, before being replaced
|
||||
with a real free list); one `PikeEngine` bundles this state and is built
|
||||
once per top level call, reused across every match `finditer`/`split`
|
||||
find in one scan, never cached on the compiled `Pattern` itself, since
|
||||
that would make concurrent `Pattern_search` calls on the same `Pattern`
|
||||
from different threads race on shared state. The literal prefilter grew
|
||||
from a single leading character into the full mandatory literal prefix
|
||||
with a real Boyer-Moore-Horspool skip table (`BINARY`/`ASCII` mode; `UTF8`
|
||||
keeps a without-skip fallback, since a byte-indexed 256-entry table cannot
|
||||
cover code points past `0x10FFFF`). Measured directly against the same
|
||||
benchmarks above: literal search went from 0.64x of the backtracking
|
||||
engine's time (already ahead) to roughly **0.03x**, and against POSIX
|
||||
`<regex.h>` from roughly 11x slower to **roughly 2x faster than glibc's
|
||||
own engine**; `a*b` (which the prefilter cannot help at all, a nullable
|
||||
loop) still improved from 3.45x slower than backtracking to roughly 2x,
|
||||
from the allocation fix alone; dense `finditer` over `[0-9]+`/`\w+`
|
||||
improved from 3.2x/5.6x slower to 1.7x/2.8x slower. Full measured table
|
||||
and citations: `concept.md` 7.10.
|
||||
|
||||
One item researched and deliberately not built: a lazy DFA state cache
|
||||
(memoizing a live-instruction-set-plus-byte transition). Not an omission:
|
||||
RE2's own documentation states its lazy DFA cannot track submatch
|
||||
boundaries, for the structural reason every call here wants at least group
|
||||
0's span, and a cached state transition only answers "can a match happen
|
||||
from here," not "which path was taken," which is what a capture position
|
||||
actually depends on; using one at all would need a two-phase (boolean scan
|
||||
then confirm) architecture, a separate design deserving its own
|
||||
research-and-plan pass, not a bolt-on. Checked empirically too, not just
|
||||
argued: the case such a cache would help most, `a*b`'s nullable loop, has
|
||||
at most two live instructions at any position for its entire run, meaning
|
||||
there is no repeated, expensive-to-recompute state to cache in the first
|
||||
place; the remaining backtracking-engine advantage there tracks ordinary
|
||||
per-position dispatch cost against `run()`'s one hand-specialized counting
|
||||
instruction, not epsilon-closure recomputation. `concept.md` 7.10 has the
|
||||
full reasoning.
|
||||
|
||||
### Memory footprint
|
||||
|
||||
@@ -459,22 +499,23 @@ Every scenario here uses a pattern with no backreference, lookaround, or
|
||||
atomic group, so every one of them runs on the Pike VM ("The Pike VM"
|
||||
above), not the backtracking engine.
|
||||
|
||||
Reported without adjustment in either direction, on this machine: glibc's
|
||||
engine is roughly 7x to 27x faster on the three ordinary scenarios (a
|
||||
literal search, extracting every number from a text, `a*b`). This narrowed
|
||||
substantially after a literal prefilter was added to the Pike VM's
|
||||
unanchored search (`concept.md` 7.9, "The Pike VM" above): the literal
|
||||
search scenario, initially the worst hit, going from roughly 70x slower
|
||||
than glibc (regressed from roughly 22x before the Pike VM existed at all)
|
||||
down to roughly 11x-13x, better than that original pre-Pike-VM number; the
|
||||
number-extraction scenario improved more modestly (roughly 8x to roughly
|
||||
7x), since it begins with a class rather than a single literal; the `a*b`
|
||||
scenario is unchanged, since its leading `a*` is a nullable loop the
|
||||
prefilter structurally cannot help, an expected, not a missed, case. No
|
||||
lazy DFA state caching or allocation pooling exists yet
|
||||
(`concept.md` 7.7's remaining deferred performance items), so a real gap
|
||||
to glibc's decades of exactly that kind of optimization remains on these
|
||||
three scenarios, narrower than before but not closed. The fourth scenario
|
||||
Reported without adjustment in either direction, on this machine, current
|
||||
numbers: **the literal-search scenario now runs roughly 2x *faster* than
|
||||
glibc**, a full reversal, after `concept.md` 7.10 replaced the Pike VM's
|
||||
per-thread allocation with a flat, allocation-free table and added a real
|
||||
Boyer-Moore-Horspool skip to the literal prefilter (the path from an
|
||||
initial roughly 22x slower, to roughly 70x slower once the Pike VM first
|
||||
existed without a prefilter, to roughly 11x-13x once a single-character
|
||||
prefilter was added, to faster than glibc outright now, is the full,
|
||||
unedited history, "The Pike VM" above and `concept.md` 7.9-7.10). Number
|
||||
extraction (begins with a class, not a single literal, so only the
|
||||
allocation fix applies, not the Boyer-Moore-Horspool skip) improved from
|
||||
roughly 7x slower to roughly 4x; `a*b` (a nullable leading loop, structurally
|
||||
un-prefilterable either way) improved from roughly 27x slower to roughly
|
||||
14x, from the allocation fix alone. No lazy DFA state caching exists, a
|
||||
deliberate choice, not an oversight: `concept.md` 7.10 found and recorded
|
||||
why one would not help these three scenarios specifically, having checked
|
||||
rather than assumed. The fourth scenario
|
||||
inverts entirely: the textbook ReDoS shape `(a+)+b` now runs *faster than
|
||||
glibc*, with no atomic group needed at all, because a Thompson-NFA
|
||||
simulation has no notion of "try one split, then backtrack and try
|
||||
|
||||
@@ -713,11 +713,15 @@ A pattern with no backreference, lookaround, or atomic group runs on the
|
||||
Pike VM (`README.md` "The Pike VM"), which is genuinely linear time,
|
||||
including on adversarial nested-quantifier shapes like `(a+)+b` that would
|
||||
invite catastrophic backtracking elsewhere, with no atomic group needed;
|
||||
it has a literal prefilter (`README.md` "The Pike VM") but still no lazy
|
||||
DFA state caching or allocation pooling, so an ordinary pattern can
|
||||
currently measure slower in absolute terms than before this engine
|
||||
existed, narrower than before the prefilter but a real, documented
|
||||
trade-off still, not an oversight. Everything else
|
||||
it has no per-thread allocation (a flat, PC-indexed table instead,
|
||||
`concept.md` 7.10) and a real Boyer-Moore-Horspool literal prefilter, and
|
||||
a pattern beginning with a literal string now typically runs faster than
|
||||
POSIX `<regex.h>` itself, not merely faster than before. A pattern that
|
||||
cannot be prefiltered at all (a nullable leading loop like `a*`) still
|
||||
runs somewhat slower in absolute terms than the backtracking engine would
|
||||
on the same pattern, a real, documented, and now much narrower trade-off
|
||||
(no lazy DFA state caching exists, a choice `concept.md` 7.10 explains
|
||||
rather than an unexamined gap). Everything else
|
||||
(a backreference, lookaround, or atomic group anywhere) runs on the
|
||||
recursive backtracking engine instead: `match`/`fullmatch` (a single
|
||||
anchored attempt) cost time proportional to that attempt; `search`/
|
||||
|
||||
+27
@@ -211,6 +211,33 @@ Continued work after 7.8 closed three of the items it left open, in the order th
|
||||
|
||||
**The Pike VM's own memory cost, which Section 10's table stated as a design bound without measurement, was profiled with Valgrind/Massif** the same way 7.6 profiled the backtracking engine, on the same adversarial choice of pattern for a fair comparison: `a*b` against 10MB of non-matching `a` characters, the nullable-loop worst case a literal prefilter cannot reduce, so this number is not flattered by the fix above. Peak heap use was 10,497,824 bytes, of which 10,485,761 bytes, 99.89%, is the test harness's own 10MB input buffer, present regardless of which engine runs; the engine's own contribution, compiling the pattern and running the full search, was the remaining 12,063 bytes, an amount that does not grow with input length (the thread lists are cleared and reused every input position, never accumulating) and is small enough, relative to a multi-megabyte input, to be indistinguishable from a constant in practice. This confirms Section 10's `O(instruction count x group count)`, input-length-independent bound for the Pike VM actually holds for the v1 implementation, not only for the design (Section 10's table updated accordingly).
|
||||
|
||||
### 7.10 Implementation finding: eliminating per-thread allocation, a real literal prefilter, and why a lazy DFA cache was researched and not built
|
||||
|
||||
Requested directly: research production regex engines specifically for optimizations applicable to this one, critically, then implement what the research justified. This section records both.
|
||||
|
||||
**Research, read directly from source, not recalled.** Two independently maintained, production Pike-VM-family engines were fetched and read for how each avoids the cost 7.9's benchmark blamed for the Pike VM's two measured weaknesses (`a*b`'s nullable loop, dense `finditer`): per-thread capture-array allocation. RE2's `nfa.cc` pools `Thread` objects from a free list (`AllocThread` pulls from `freelist_` before ever calling `new`; each pooled slot's capture array, once allocated, is reused via reference counting rather than freed and reallocated) and walks the epsilon closure with a stack pre-sized once from the instruction count, not grown on demand. `rust-lang/regex`'s `regex-automata` goes further: a `SlotTable`, one flat array indexed `table[stateID * slotsPerState + slotIndex]`, needs no per-thread allocation at all, because Pike's own one-thread-per-PC invariant (already relied on here for `PikeList`'s fixed capacity, 7.7) makes the PC itself a sufficient index; its `Cache` (the thread lists plus this table) is constructed once and reused across repeated searches, not reallocated per call. Separately, Hyperscan's Teddy algorithm (SIMD, up to 64 bytes per 16 vector instructions, a published ~35x over naive scanning for small literal sets) was researched for literal prefiltering specifically and set aside: it needs platform-specific SIMD intrinsics, in direct tension with this design's single-file, portable-C, simplicity-over-performance priority (Section 1), and a portable byte-oriented technique (Boyer-Moore-Horspool, below) was judged proportionate where Teddy was not.
|
||||
|
||||
**What was built.** `PikeThread`'s owned `int64_t *saved` pointer is gone. Capture rows for a step's *committed* (terminal-instruction) threads now live in `PikeList.table`, one flat array per list, indexed directly by PC exactly as `regex-automata`'s `SlotTable` does; nothing is allocated or freed to store or discard one, since a stale row is simply never read (gated by `seen[]`/`pcs[]`, both reset every step). The *transient* rows needed mid-closure, before the eventual terminal PC is known, come from `ScratchPool`, a fixed block of rows with an explicit free list, sized once from the same `n+1`-rows-at-once bound `PikeList` already relies on. A plain bump/decrement counter was tried first, for this specific pool, and rejected before being written into the file: proved incorrect by hand-tracing a case where an `OP_SPLIT`'s second branch's row must outlive several non-branching pushes that reuse an *earlier* row without reallocating it, which only a real free list (any release order, not just strict LIFO) handles safely. `pike_addthread`'s own closure stack (`WorkStack`) is likewise pre-sized once from the same bound rather than grown by `realloc` on demand, closing the smallest of the three gaps 7.9's summary listed. All three (thread lists, scratch pool, work stack) are bundled into one `PikeEngine`, built once per top-level `Pattern_`/`re_` call and reused across every match `Pattern_finditer`/`Pattern_split` find within that one call, not reallocated per match; explicitly never cached on `PatternImpl` itself, since that would make concurrent `Pattern_search` calls on the same compiled `Pattern` from different threads race on shared mutable state, silently breaking the existing guarantee that a `Pattern` no thread is concurrently modifying needs no external synchronization (13.6).
|
||||
|
||||
The literal prefilter (7.9) was extended from a single leading character to the full mandatory literal prefix (every consecutive leading `OP_CHAR`, however long) with a real Boyer-Moore-Horspool bad-character skip table, built once per `PikeEngine` from the prefix, case-folded first when `IGNORECASE` is set. Sound for `BINARY`/`ASCII` mode, where `text_at()` already returns a raw byte and a 256-entry table suffices; `UTF8` mode's code points range past `0x10FFFF`, too wide for a flat byte table, so it keeps the without-skip fallback (checking the whole prefix, not only its first character, still real, just without the multi-position skip). A single leading `OP_CLASS` (no multi-character literal to extract) gets its own tight linear scan, cheaper than the full injection machinery even without a skip table. Crucially, the skip-ahead itself is only ever applied to *where a new start thread is injected*, never to the position already-live threads are stepped through: skipping `sp` itself is only sound when nothing is currently in flight (`clist` empty), since a live thread from an earlier start position must still be advanced one character at a time regardless of what the prefilter finds further ahead.
|
||||
|
||||
**Verification.** Full 3,252-case suite, unchanged, four clean runs; a whitebox dual-engine cross-check extended beyond 7.9's version to also cover `finditer`, `split`, and `BINARY` mode (patterns deliberately built from the prefilter's own literal fixtures, e.g. `needle_marker`, `MAGIC`): 30,000 `match`/`fullmatch`/`search` comparisons, 3,334 `finditer`, 2,500 `split`, zero mismatches. A targeted whitebox suite specifically for the new prefilter machinery, including the classic Boyer-Moore-Horspool correctness trap (a literal whose own suffix overlaps its prefix, `abcabd` against `xxabcabcabdxx`, wrong on a naively-built skip table) and `IGNORECASE`-folded BMH matching: 12/12. Two clean AddressSanitizer/UndefinedBehaviorSanitizer passes on the suite, two more on the dual-engine and prefilter checks (a third run of each hit the same pre-existing sandbox dynamic-linker flake this document already recorded, unrelated to this code, confirmed the same way: retried clean).
|
||||
|
||||
**Measured, before this section's changes versus after, same benchmarks 7.9 used:**
|
||||
|
||||
| Case | Pike/backtracking, before | Pike/backtracking, after |
|
||||
|---|---|---|
|
||||
| Literal search, 32MB | 0.64x (already ahead) | **0.03x** (~33x faster than backtracking) |
|
||||
| `a*b` non-match, 32MB (no prefilter applies) | 3.45x slower | **1.97x slower** (allocation fix alone, prefilter-independent) |
|
||||
| `(a+)+b`, n=80,000 | 0.0007x | 0.0005x |
|
||||
| Dense `finditer`, `[0-9]+`, 5MB | 3.21x slower | **1.69x slower** |
|
||||
| Dense `finditer`, `\w+`, 5MB | 5.57x slower | **2.78x slower** |
|
||||
| Dense `finditer`, `id\d+` (literal-prefixed), 5MB | 1.84x slower | **0.84x** (now faster than backtracking) |
|
||||
|
||||
Against POSIX `<regex.h>` (`examples/bench_vs_posix.c`, README.md "Benchmarks"), the literal-search scenario went from roughly 12x slower than glibc to **roughly 2x faster than glibc**; `a*b` from roughly 27x slower to roughly 14x; number extraction from roughly 7x to roughly 4x. The `a*b`/dense-`finditer` improvement (roughly halved in every case) is attributable entirely to the allocation fix, since none of those patterns benefit from the prefilter at all (a nullable leading loop, or a leading class with no multi-character literal to extract); the literal-search improvement is dominated by the Boyer-Moore-Horspool skip specifically.
|
||||
|
||||
**Why a lazy DFA state cache (memoizing a live-PC-set-plus-byte transition, so a repeated state skips recomputing its own closure) was researched and not built, a critical decision, not an omission.** Two reasons, one structural and one now empirical. Structurally: RE2's own lazy DFA "cannot track submatch boundaries" (confirmed directly against its documented engine-selection table, 7.9's research) for exactly the reason this project's every matching call wants captures, at minimum group 0's span: a cached state-set transition only answers "is a match possible from here," not "which specific path was taken," and captures depend on the path, not merely on which PCs are reachable. Using one at all would need a two-phase architecture (a fast boolean pass to find *that* a match exists, then a second, slower pass to find *where* exactly), which is a materially different, larger design than a bolt-on cache, deserving its own research-and-plan pass the way this Pike VM itself got, not a rushed addition under an unrelated body of work. Empirically, checked directly rather than assumed: the case a lazy DFA cache would help most, `a*b`'s nullable loop, has at most two live PCs at any position for its entire run (inside the loop, past it), meaning there is no *repeated, expensive-to-recompute* state to cache in the first place; profiling this section's own numbers shows the remaining backtracking-engine advantage there (roughly 2x, down from 3.45x) tracking the general per-position dispatch-loop cost of a handful of instructions against `run()`'s single, hand-specialized `OP_REPEAT1` counting instruction, not epsilon-closure recomputation, which a lazy DFA cache specifically targets and this pattern shape does not exhibit. Building it would have added real, correctness-sensitive complexity (a hash table, state interning, an eviction policy, and the two-phase capture problem above) for a benefit this section's own measurements do not support for the cases that motivated looking at it.
|
||||
|
||||
## 8. Core Data Structures
|
||||
|
||||
Kept intentionally minimal, all defined in the single file, no dependency beyond the C standard library (`stdint.h`, `stddef.h`, `string.h`) for the data structures in this section specifically. `Input_from_file` (Section 9.2) is the one place the file as a whole steps outside strict ISO C, using POSIX (`mmap`/`open`/`fstat`, Section 7.6), already in the same family of platform dependency `wctype.h`'s locale behavior carries (Section 13.3).
|
||||
|
||||
+1
-1
@@ -145,7 +145,7 @@ Mirror `re.Pattern.match`/`.fullmatch`/`.search` exactly, including the `pos`/`e
|
||||
- `fullmatch`: anchored at `pos`, must also reach exactly `endpos`.
|
||||
- `search`: tries every start position from `pos` to `endpos` inclusive, left to right, and reports the first that admits any match (ordinary backtracking priority decides which match that is at that position, `concept.md` 2.5).
|
||||
|
||||
**Performance:** which of two engines runs a given `Pattern` is decided once, at `re_compile` time, and is never a caller's choice (README.md "Implementation status", `concept.md` 7.2/7.7). A pattern with no backreference, lookahead, lookbehind, or atomic group (a possessive quantifier already desugars to the last of these) runs on the Pike VM, a Thompson-NFA simulation with no backtracking at all: every operation in this section is genuinely linear in input length on that engine, including `search` against an adversarial pattern shape like `(a+)+b` that would otherwise invite catastrophic backtracking, with no atomic group needed (README.md "The Pike VM"). Everything else (a backreference anywhere, or a lookaround/atomic construct) runs on the recursive backtracking engine instead: `match`/`fullmatch` there do one anchored attempt at time proportional to that attempt; `search` tries each candidate start position as a separate attempt but, unlike a naive backtracking search, does not redo the same work at every one, since `run_memo` caches every proven failure at the (instruction, position) level for a backreference-free pattern on this engine, and `OP_REPEAT1` additionally uses precomputed skip-ahead tables, together making `search` linear rather than quadratic for a pattern built from simple repeated atoms; a repeat over a *compound* body only gets the failure-memoization, and a pattern with a backreference disables memoization entirely (unsound there, Section 5) and can still be worst-case exponential, exactly as in CPython. The Pike VM has a literal prefilter (a pattern beginning with a mandatory literal or class skips injecting a new search attempt at a position that atom cannot match, `concept.md` 7.9) but still no lazy DFA state caching or allocation pooling, so an ordinary, non-adversarial pattern that happens to be Pike VM eligible can still measure slower in absolute terms than the same pattern would have on the backtracking engine, a real, documented trade-off (`concept.md` 7.8/7.9), narrower than before the prefilter but not closed, not an oversight either way. See README.md "Implementation status" and "The Pike VM" for the measured numbers on both engines and citations to the published techniques each uses.
|
||||
**Performance:** which of two engines runs a given `Pattern` is decided once, at `re_compile` time, and is never a caller's choice (README.md "Implementation status", `concept.md` 7.2/7.7). A pattern with no backreference, lookahead, lookbehind, or atomic group (a possessive quantifier already desugars to the last of these) runs on the Pike VM, a Thompson-NFA simulation with no backtracking at all: every operation in this section is genuinely linear in input length on that engine, including `search` against an adversarial pattern shape like `(a+)+b` that would otherwise invite catastrophic backtracking, with no atomic group needed (README.md "The Pike VM"). Everything else (a backreference anywhere, or a lookaround/atomic construct) runs on the recursive backtracking engine instead: `match`/`fullmatch` there do one anchored attempt at time proportional to that attempt; `search` tries each candidate start position as a separate attempt but, unlike a naive backtracking search, does not redo the same work at every one, since `run_memo` caches every proven failure at the (instruction, position) level for a backreference-free pattern on this engine, and `OP_REPEAT1` additionally uses precomputed skip-ahead tables, together making `search` linear rather than quadratic for a pattern built from simple repeated atoms; a repeat over a *compound* body only gets the failure-memoization, and a pattern with a backreference disables memoization entirely (unsound there, Section 5) and can still be worst-case exponential, exactly as in CPython. The Pike VM has no per-thread allocation at all (a flat table indexed directly by instruction, not a `malloc`/`free` per split, `concept.md` 7.10) and a real Boyer-Moore-Horspool literal prefilter for a pattern beginning with a mandatory literal string (`BINARY`/`ASCII` mode; `UTF8` mode gets a without-skip fallback, still real, since a byte-indexed skip table cannot cover code points past `0x10FFFF`); a pattern beginning with a literal now typically outperforms the backtracking engine outright, and often POSIX `<regex.h>` too (README.md "Benchmarks"). A pattern that cannot be prefiltered at all (a nullable leading loop, or a leading character class with no fixed literal to extract) still measures somewhat slower in absolute terms than the backtracking engine would on the same pattern, a real, narrower-than-before, and deliberately not further optimized trade-off: `concept.md` 7.10 also records researching a lazy DFA state cache for exactly this remaining gap and not building it, both because it cannot track captures without a separate two-phase architecture and because the pattern shape it would help most (`a*b`) was checked directly and found to have no repeated state worth caching in the first place. See README.md "Implementation status" and "The Pike VM" for the measured numbers on both engines and citations to the published techniques each uses.
|
||||
|
||||
**Memory:** the tables above cost real, measured memory, not just time complexity: `search`/`finditer`/`split`/`sub` on a pattern using `OP_REPEAT1` peak at roughly 8.4x the input length (measured with Valgrind/Massif; `match`/`fullmatch` do not allocate these tables at all and use proportionally less). README.md "Memory footprint" has the full measured breakdown, including a fix that was tried, measured, and deliberately reverted because it traded a fast allocation failure for an effectively-unbounded hang, which is a worse failure mode, not a better one.
|
||||
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ directly, no build system required.
|
||||
| [`redos_atomic.c`](redos_atomic.c) | Two-part: first, that the textbook `(a+)+b` ReDoS shape is now fixed automatically, with no atomic group, because it has no backreference and so runs on the Pike VM (README.md "The Pike VM"); second, a variant with a backreference added specifically to force it back onto the backtracking engine, where the same shape is exponential again and an atomic group (`(?>...)`) is still the pattern author's own necessary fix. |
|
||||
| [`empty_match_rule.c`](empty_match_rule.c) | `Pattern_finditer`/`Pattern_split` reproducing a real CPython interpreter's undocumented empty-match retry rule exactly (`\d*?` against `"123abc456"` yields 16 matches, not 9), found by probing a real interpreter directly rather than by reading its documentation. |
|
||||
| [`large_file_search.c`](large_file_search.c) | `Input_from_file`'s `mmap`-backed reading on a generated 100MB file, with the elapsed time and peak resident memory printed directly so they can be checked against the measured figures in `../README.md` "Memory footprint" rather than taken on faith. |
|
||||
| [`bench_vs_posix.c`](bench_vs_posix.c) | A direct, timed comparison against the C standard library's own `<regex.h>` (POSIX `regcomp`/`regexec`) on six scenarios at multi-megabyte/multi-hundred-thousand-line scale, using only pattern syntax valid in both engines, every one of them Pike VM eligible. Reported honestly, including a real regression: glibc wins the three ordinary scenarios by a wider margin than this build measured before the Pike VM existed (it has none of the backtracking engine's constant-factor optimizations yet), while the adversarial `(a+)+b` scenario inverts entirely and now beats glibc outright, with no atomic group needed. |
|
||||
| [`bench_vs_posix.c`](bench_vs_posix.c) | A direct, timed comparison against the C standard library's own `<regex.h>` (POSIX `regcomp`/`regexec`) on six scenarios at multi-megabyte/multi-hundred-thousand-line scale, using only pattern syntax valid in both engines, every one of them Pike VM eligible. Reported honestly across the whole history, not just the current numbers: a literal search first regressed from roughly 22x slower than glibc to roughly 70x once the Pike VM existed without a prefilter, then recovered through a single-character prefilter (roughly 11x-13x) to a Boyer-Moore-Horspool literal prefilter plus an allocation-free thread model (`concept.md` 7.10) that now runs it roughly 2x *faster* than glibc outright; the adversarial `(a+)+b` scenario also beats glibc outright, with no atomic group needed. |
|
||||
|
||||
Every program below was compiled and actually run while writing it; the
|
||||
claims in its top-of-file comment (what a real CPython interpreter does,
|
||||
|
||||
+24
-19
@@ -429,25 +429,30 @@ int main(void) {
|
||||
printf("\nWhat this measures and does not measure: regexx is Python `re`\n"
|
||||
"compatible and dispatches every pattern here (all backreference-,\n"
|
||||
"lookaround-, and atomic-group-free) to its Pike VM (README.md \"The\n"
|
||||
"Pike VM\", concept.md 7.2/7.7/7.9), a Thompson-NFA simulation with a\n"
|
||||
"literal prefilter (added after this benchmark first found scenario A\n"
|
||||
"regressed the most, concept.md 7.9) but still no lazy DFA state\n"
|
||||
"caching or allocation pooling; glibc's <regex.h> is a mature,\n"
|
||||
"DFA-backed engine with decades of that exact kind of optimization\n"
|
||||
"behind it and a much smaller feature set (no named groups, no lazy\n"
|
||||
"quantifiers, no lookaround, no atomic groups, and, structurally, no\n"
|
||||
"way to search past an embedded NUL byte at all, docs/API.md Section\n"
|
||||
"1.3 vs examples/binary_scan.c). Scenarios A-C measure what remains of\n"
|
||||
"that missing optimization layer: this build was previously measured\n"
|
||||
"on this engine's backtracking predecessor plus its own precomputed\n"
|
||||
"skip-ahead tables (README.md \"Implementation status\") at roughly\n"
|
||||
"2x-25x against POSIX on these same three scenarios; the Pike VM\n"
|
||||
"without a prefilter regressed that to roughly 8x-70x, and the\n"
|
||||
"prefilter closed most of that back up for A (a plain literal, its\n"
|
||||
"best case) and part of it for B (starts with a class, not a single\n"
|
||||
"literal); C's a*b starts with a nullable loop no prefilter can help,\n"
|
||||
"and is unchanged. What remains (roughly 7x-27x here) is genuinely no\n"
|
||||
"lazy DFA caching or allocation pooling yet, not a defect.\n\n"
|
||||
"Pike VM\", concept.md 7.2/7.7/7.9/7.10), a Thompson-NFA simulation\n"
|
||||
"with an allocation-free thread model (a flat, PC-indexed table for\n"
|
||||
"live threads, no malloc/free in the search loop at all) and a real\n"
|
||||
"Boyer-Moore-Horspool literal prefilter, researched directly against\n"
|
||||
"RE2's and rust-lang/regex's own production engines and critically\n"
|
||||
"scoped, not copied wholesale (concept.md 7.10 records what was tried\n"
|
||||
"and rejected, including a lazy DFA state cache); glibc's <regex.h> is\n"
|
||||
"a mature, DFA-backed engine with decades of its own optimization and\n"
|
||||
"a much smaller feature set (no named groups, no lazy quantifiers, no\n"
|
||||
"lookaround, no atomic groups, and, structurally, no way to search\n"
|
||||
"past an embedded NUL byte at all, docs/API.md Section 1.3 vs\n"
|
||||
"examples/binary_scan.c). Scenario A, a plain literal, is regexx's\n"
|
||||
"best case for the reason above and now runs roughly 2x *faster* than\n"
|
||||
"glibc, a full reversal from an initial roughly 22x slower (before the\n"
|
||||
"Pike VM existed), through roughly 70x slower (the Pike VM without any\n"
|
||||
"prefilter), through roughly 11x-13x slower (a single-character\n"
|
||||
"prefilter only); that full history is the honest shape of iterating\n"
|
||||
"on this, not a straight line. B (starts with a class, no Boyer-Moore-\n"
|
||||
"Horspool skip applies, only the allocation fix does) sits at roughly\n"
|
||||
"4x slower, down from roughly 7x; C's a*b (a nullable loop, structurally\n"
|
||||
"un-prefilterable) sits at roughly 14x slower, down from roughly 27x,\n"
|
||||
"from the allocation fix alone. What remains on B and C is genuinely no\n"
|
||||
"lazy DFA state caching, a choice concept.md 7.10 explains rather than\n"
|
||||
"a gap left unexamined.\n\n"
|
||||
"Scenario D inverts entirely, and is the actual point of building this\n"
|
||||
"engine at all: (a+)+b has no backreference, so it is Pike VM eligible,\n"
|
||||
"and a Thompson-NFA simulation has no notion of \"try one split, then\n"
|
||||
|
||||
@@ -1151,78 +1151,150 @@ static int run_memo(MCtx *c, int pc, int64_t sp, int64_t guard_sp, int32_t guard
|
||||
* matter here (pr/text/text8/len); its other fields (memo, caps,
|
||||
* repeat_maxrun/nextpm, depth_exceeded, sub_end) are backtracking
|
||||
* engine state this VM never reads or writes.
|
||||
*
|
||||
* concept.md 7.10 records the research (RE2's Thread free list,
|
||||
* rust-lang/regex-automata's flat per-state SlotTable, both fetched
|
||||
* and read directly, not recalled) and the finding behind the design
|
||||
* below: v1 (7.7-7.9) allocated a fresh capture array with malloc at
|
||||
* every OP_SPLIT and every injected start thread, and freed one at
|
||||
* every thread that died or was consumed; measured directly, this
|
||||
* allocation churn, not the underlying algorithm, was the dominant
|
||||
* cost in exactly the two pattern shapes 7.9's benchmark found the
|
||||
* Pike VM losing to the backtracking engine on (a*b's nullable loop,
|
||||
* dense finditer). Every capture array below is now either a row in a
|
||||
* flat table indexed directly by instruction PC (no allocation at all,
|
||||
* matching rust-lang/regex-automata's SlotTable) or a row pulled from
|
||||
* a small, fixed-capacity pool with an explicit free list (used only
|
||||
* for the transient, in-flight state during one epsilon closure walk,
|
||||
* where a flat PC-indexed table does not apply yet because the
|
||||
* eventual terminal PC is not known until the closure reaches it).
|
||||
* ================================================================ */
|
||||
|
||||
/* One row (2*(ngroups+1) int64s) per NFA instruction, indexed directly
|
||||
* by PC: since at most one live thread ever exists per PC in a given
|
||||
* step (Cox's dedup invariant, already relied on for PikeList's fixed
|
||||
* capacity), the PC itself is a sufficient, allocation-free index. No
|
||||
* init/clear step is needed for the data itself: a stale row from a
|
||||
* previous step is simply never read, because reading one always goes
|
||||
* through l->pcs[]/l->seen[], both explicitly reset every step. */
|
||||
typedef struct {
|
||||
int32_t pc;
|
||||
int64_t *saved; /* owned by whichever thread list currently holds it;
|
||||
* 2*(ngroups+1) entries */
|
||||
} PikeThread;
|
||||
|
||||
typedef struct {
|
||||
PikeThread *threads;
|
||||
int32_t *pcs; /* which PCs have a live thread this step, priority order */
|
||||
int count;
|
||||
int cap; /* == nfa_prog->n; fixed for the whole search, see the
|
||||
* "no growth logic, nothing can overflow it" argument
|
||||
* in concept.md 7.7 (Cox's one-thread-per-PC bound) */
|
||||
uint8_t *seen; /* size cap; which PCs already have a thread this step */
|
||||
int cap; /* == nfa_prog->n */
|
||||
uint8_t *seen; /* size cap */
|
||||
int64_t *table; /* cap * ncaps int64s; table + pc*ncaps is pc's row */
|
||||
} PikeList;
|
||||
|
||||
static int pikelist_init(PikeList *l, int n) {
|
||||
static int pikelist_init(PikeList *l, int n, int ncaps) {
|
||||
size_t cap = (size_t)(n > 0 ? n : 1);
|
||||
l->threads = malloc(cap * sizeof(PikeThread));
|
||||
l->pcs = malloc(cap * sizeof(int32_t));
|
||||
l->seen = calloc(cap, 1);
|
||||
l->table = malloc(cap * (size_t)ncaps * sizeof(int64_t));
|
||||
l->count = 0;
|
||||
l->cap = n;
|
||||
return l->threads != NULL && l->seen != NULL;
|
||||
}
|
||||
/* Frees every currently-listed thread's saved array first: correct only
|
||||
* when called at a point where l->count accurately reflects threads this
|
||||
* list still owns (never right after a pike_step() call without first
|
||||
* resetting count to 0, since pike_step() itself always resolves every
|
||||
* entry it was given, by transferring, accepting, or freeing it). */
|
||||
static void pikelist_free(PikeList *l) {
|
||||
for (int i = 0; i < l->count; i++) free(l->threads[i].saved);
|
||||
free(l->threads);
|
||||
free(l->seen);
|
||||
return l->pcs != NULL && l->seen != NULL && l->table != NULL;
|
||||
}
|
||||
static void pikelist_free(PikeList *l) { free(l->pcs); free(l->seen); free(l->table); }
|
||||
static void pikelist_clear(PikeList *l) {
|
||||
l->count = 0;
|
||||
memset(l->seen, 0, (size_t)(l->cap > 0 ? l->cap : 1));
|
||||
}
|
||||
|
||||
typedef struct { int32_t pc; int64_t *saved; } PikeWork;
|
||||
/* Scratch rows for the transient state inside one epsilon closure walk,
|
||||
* before it is known which terminal PC (if any) a given path commits
|
||||
* to: an explicit free list of row indices into one pre-allocated
|
||||
* block, sized to the same "at most n things pending at once" bound
|
||||
* PikeList already relies on (proof: every row is allocated either
|
||||
* once for the walk's initial seed or once per OP_SPLIT instruction
|
||||
* actually processed, and seen[] ensures a given instruction, hence a
|
||||
* given OP_SPLIT, is processed at most once per step, so at most n+1
|
||||
* rows are ever allocated in one closure walk; every code path below
|
||||
* releases a row exactly once, so the free list is self-balancing:
|
||||
* verified empty (freetop == poolcap) at the start of every walk, not
|
||||
* just assumed). A plain bump/decrement counter, tried first, is
|
||||
* *not* correct here and was rejected before writing this: two rows
|
||||
* allocated at different times can need releasing out of numeric
|
||||
* order (an OP_SPLIT's second branch can outlive several non-branching
|
||||
* pushes' worth of first-branch processing that reuse an *earlier*
|
||||
* row without reallocating it), which only a real free list handles
|
||||
* safely regardless of release order. */
|
||||
typedef struct {
|
||||
int64_t *rows;
|
||||
int32_t *freelist;
|
||||
int freetop;
|
||||
int poolcap;
|
||||
} ScratchPool;
|
||||
|
||||
/* Epsilon closure from (pc0, saved0) at text position sp, added into
|
||||
* list l (deduplicated by pc, first writer per step wins, exactly Cox's
|
||||
* stated invariant). Specified recursively by the source material
|
||||
* (concept.md 7.7) but implemented with an explicit heap stack instead
|
||||
* of C call recursion, so a pattern with many alternations cannot
|
||||
* recurse the C stack as deep as the instruction count.
|
||||
*
|
||||
* Contract: always resolves ownership of saved0 (frees it, or stores it
|
||||
* into l, possibly after being duplicated further along the way) before
|
||||
* returning, on every path, including failure. Returns 0 only on
|
||||
* allocation failure, having freed everything it touched; l's existing
|
||||
* entries (from before this call) are left valid and untouched. */
|
||||
static int pike_addthread(PikeList *l, MCtx *sctx, int32_t pc0, int64_t *saved0, int64_t sp) {
|
||||
int ncaps = 2 * (sctx->pr->ngroups + 1);
|
||||
DArr stack; da_init(&stack, sizeof(PikeWork));
|
||||
PikeWork *w0 = da_push(&stack);
|
||||
if (!w0) { free(saved0); da_free(&stack); return 0; }
|
||||
w0->pc = pc0; w0->saved = saved0;
|
||||
static int scratchpool_init(ScratchPool *sp, int poolcap, int ncaps) {
|
||||
size_t cap = (size_t)(poolcap > 0 ? poolcap : 1);
|
||||
sp->rows = malloc(cap * (size_t)ncaps * sizeof(int64_t));
|
||||
sp->freelist = malloc(cap * sizeof(int32_t));
|
||||
if (!sp->rows || !sp->freelist) { free(sp->rows); free(sp->freelist); return 0; }
|
||||
for (int i = 0; i < poolcap; i++) sp->freelist[i] = poolcap - 1 - i;
|
||||
sp->freetop = poolcap;
|
||||
sp->poolcap = poolcap;
|
||||
return 1;
|
||||
}
|
||||
static void scratchpool_free(ScratchPool *sp) { free(sp->rows); free(sp->freelist); }
|
||||
static int32_t scratchpool_alloc(ScratchPool *sp) {
|
||||
if (sp->freetop <= 0) return -1; /* unreachable given the sizing proof above; guarded, not assumed */
|
||||
return sp->freelist[--sp->freetop];
|
||||
}
|
||||
static void scratchpool_release(ScratchPool *sp, int32_t row) { sp->freelist[sp->freetop++] = row; }
|
||||
|
||||
while (stack.len > 0) {
|
||||
PikeWork w = ((PikeWork *)stack.data)[stack.len - 1];
|
||||
stack.len--;
|
||||
if (l->seen[w.pc]) { free(w.saved); continue; }
|
||||
typedef struct { int32_t pc; int32_t row; } PikeWork;
|
||||
|
||||
/* A fixed-capacity, explicit closure stack, pre-sized once (from the
|
||||
* same n+1 bound as ScratchPool, concept.md 7.10) and reused across
|
||||
* every pike_addthread call for the life of one PikeEngine, instead of
|
||||
* growing a DArr by realloc from empty on every single call: the
|
||||
* bound is already proven, so there is nothing to discover by growing
|
||||
* on demand, only allocator traffic to avoid. Self-draining exactly
|
||||
* like ScratchPool (every push is eventually popped, by the ordinary
|
||||
* loop or the fail path), so ws->top == 0 is likewise guaranteed
|
||||
* between calls without a separate reset step, though addthread
|
||||
* resets it explicitly anyway as cheap insurance. */
|
||||
typedef struct {
|
||||
PikeWork *items;
|
||||
int cap;
|
||||
int top;
|
||||
} WorkStack;
|
||||
|
||||
static int workstack_init(WorkStack *ws, int cap) {
|
||||
ws->items = malloc((size_t)(cap > 0 ? cap : 1) * sizeof(PikeWork));
|
||||
ws->cap = cap;
|
||||
ws->top = 0;
|
||||
return ws->items != NULL;
|
||||
}
|
||||
static void workstack_free(WorkStack *ws) { free(ws->items); }
|
||||
|
||||
/* Epsilon closure from (pc0, row0) at text position sp, added into
|
||||
* list l (deduplicated by pc, first writer per step wins, exactly
|
||||
* Cox's stated invariant). row0 is a ScratchPool row index the caller
|
||||
* transfers ownership of; this call is responsible for releasing it,
|
||||
* on every path, including failure (mirrors the ownership contract
|
||||
* 7.7-7.9 already established for the pointer-owning version this
|
||||
* replaces, just in terms of pool row indices instead of malloc'd
|
||||
* pointers). Returns 0 only if the work stack's fixed capacity is
|
||||
* exceeded (unreachable given the same proof ScratchPool's sizing
|
||||
* relies on; guarded, not assumed) or the scratch pool is exhausted
|
||||
* (same guarantee). No allocation happens on this path at all: ws and
|
||||
* sp are both already sized and owned by the caller's PikeEngine. */
|
||||
static int pike_addthread(PikeList *l, ScratchPool *sp, WorkStack *ws, MCtx *sctx, int ncaps,
|
||||
int32_t pc0, int32_t row0, int64_t tpos) {
|
||||
ws->top = 0;
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, row0); return 0; }
|
||||
ws->items[ws->top].pc = pc0; ws->items[ws->top].row = row0; ws->top++;
|
||||
|
||||
while (ws->top > 0) {
|
||||
PikeWork w = ws->items[--ws->top];
|
||||
if (l->seen[w.pc]) { scratchpool_release(sp, w.row); continue; }
|
||||
l->seen[w.pc] = 1;
|
||||
Inst *in = &sctx->pr->insts[w.pc];
|
||||
switch (in->op) {
|
||||
case OP_JMP: {
|
||||
PikeWork *nw = da_push(&stack);
|
||||
if (!nw) { free(w.saved); goto fail; }
|
||||
nw->pc = in->x; nw->saved = w.saved;
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, w.row); goto fail; }
|
||||
ws->items[ws->top].pc = in->x; ws->items[ws->top].row = w.row; ws->top++;
|
||||
break;
|
||||
}
|
||||
case OP_SPLIT: {
|
||||
@@ -1231,86 +1303,89 @@ static int pike_addthread(PikeList *l, MCtx *sctx, int32_t pc0, int64_t *saved0,
|
||||
* 7.1); pushing y then x means x is popped next, giving
|
||||
* the same DFS pre-order a recursive addthread(x) then
|
||||
* addthread(y) would, so list order still encodes
|
||||
* priority exactly as it does in the backtracking engine. */
|
||||
int64_t *saved_y = malloc((size_t)ncaps * sizeof(int64_t));
|
||||
if (!saved_y) { free(w.saved); goto fail; }
|
||||
memcpy(saved_y, w.saved, (size_t)ncaps * sizeof(int64_t));
|
||||
PikeWork *wy = da_push(&stack);
|
||||
if (!wy) { free(saved_y); free(w.saved); goto fail; }
|
||||
wy->pc = in->y; wy->saved = saved_y;
|
||||
PikeWork *wx = da_push(&stack);
|
||||
if (!wx) { free(w.saved); goto fail; } /* wy already on the
|
||||
stack; the fail unwind below frees it along with
|
||||
every other still-pending entry */
|
||||
wx->pc = in->x; wx->saved = w.saved;
|
||||
* priority exactly as it does in the backtracking
|
||||
* engine. Only y's branch needs a new row: x continues
|
||||
* with w.row unchanged, exactly as before. */
|
||||
int32_t row_y = scratchpool_alloc(sp);
|
||||
if (row_y < 0) { scratchpool_release(sp, w.row); goto fail; }
|
||||
memcpy(sp->rows + (size_t)row_y * ncaps, sp->rows + (size_t)w.row * ncaps, (size_t)ncaps * sizeof(int64_t));
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, row_y); scratchpool_release(sp, w.row); goto fail; }
|
||||
ws->items[ws->top].pc = in->y; ws->items[ws->top].row = row_y; ws->top++;
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, w.row); goto fail; } /* row_y's work item
|
||||
is already on the stack; the fail unwind below releases it along
|
||||
with every other still-pending entry */
|
||||
ws->items[ws->top].pc = in->x; ws->items[ws->top].row = w.row; ws->top++;
|
||||
break;
|
||||
}
|
||||
case OP_SAVE: {
|
||||
/* Safe to mutate w.saved in place, not duplicate: by
|
||||
* construction the only place a saved array is ever
|
||||
* shared between two in-flight branches is the instant
|
||||
/* Safe to mutate this row in place, not duplicate: by
|
||||
* construction the only place a row is ever shared
|
||||
* between two in-flight branches is the instant
|
||||
* OP_SPLIT above duplicates it, so past that point this
|
||||
* copy is exclusively owned along this one path. */
|
||||
w.saved[in->data] = sp;
|
||||
PikeWork *nw = da_push(&stack);
|
||||
if (!nw) { free(w.saved); goto fail; }
|
||||
nw->pc = w.pc + 1; nw->saved = w.saved;
|
||||
* row is exclusively owned along this one path. */
|
||||
(sp->rows + (size_t)w.row * ncaps)[in->data] = tpos;
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, w.row); goto fail; }
|
||||
ws->items[ws->top].pc = w.pc + 1; ws->items[ws->top].row = w.row; ws->top++;
|
||||
break;
|
||||
}
|
||||
case OP_ASSERT: {
|
||||
int ok;
|
||||
switch (in->data) {
|
||||
case A_BOL: ok = (sp == 0) || ((sctx->pr->flags & MULTILINE) && sp > 0 && text_at(sctx, sp - 1) == '\n'); break;
|
||||
case A_EOL: ok = (sp == sctx->len) || (text_at(sctx, sp) == '\n' && ((sctx->pr->flags & MULTILINE) || sp == sctx->len - 1)); break;
|
||||
case A_BOS: ok = (sp == 0); break;
|
||||
case A_EOS: ok = (sp == sctx->len); break;
|
||||
case A_WB: ok = is_word_at(sctx, sp - 1) != is_word_at(sctx, sp); break;
|
||||
case A_NWB: ok = is_word_at(sctx, sp - 1) == is_word_at(sctx, sp); break;
|
||||
case A_BOL: ok = (tpos == 0) || ((sctx->pr->flags & MULTILINE) && tpos > 0 && text_at(sctx, tpos - 1) == '\n'); break;
|
||||
case A_EOL: ok = (tpos == sctx->len) || (text_at(sctx, tpos) == '\n' && ((sctx->pr->flags & MULTILINE) || tpos == sctx->len - 1)); break;
|
||||
case A_BOS: ok = (tpos == 0); break;
|
||||
case A_EOS: ok = (tpos == sctx->len); break;
|
||||
case A_WB: ok = is_word_at(sctx, tpos - 1) != is_word_at(sctx, tpos); break;
|
||||
case A_NWB: ok = is_word_at(sctx, tpos - 1) == is_word_at(sctx, tpos); break;
|
||||
default: ok = 0;
|
||||
}
|
||||
if (!ok) { free(w.saved); break; }
|
||||
PikeWork *nw = da_push(&stack);
|
||||
if (!nw) { free(w.saved); goto fail; }
|
||||
nw->pc = w.pc + 1; nw->saved = w.saved;
|
||||
if (!ok) { scratchpool_release(sp, w.row); break; }
|
||||
if (ws->top >= ws->cap) { scratchpool_release(sp, w.row); goto fail; }
|
||||
ws->items[ws->top].pc = w.pc + 1; ws->items[ws->top].row = w.row; ws->top++;
|
||||
break;
|
||||
}
|
||||
case OP_CHAR: case OP_CLASS: case OP_ANY: case OP_MATCH:
|
||||
if (l->count >= l->cap) { free(w.saved); goto fail; } /* unreachable
|
||||
in practice, see the fixed-bound argument above; guarded
|
||||
rather than assumed */
|
||||
l->threads[l->count].pc = w.pc;
|
||||
l->threads[l->count].saved = w.saved;
|
||||
l->count++;
|
||||
if (l->count >= l->cap) { scratchpool_release(sp, w.row); goto fail; } /* unreachable
|
||||
in practice, see the fixed-bound argument above; guarded rather than assumed */
|
||||
memcpy(l->table + (size_t)w.pc * ncaps, sp->rows + (size_t)w.row * ncaps, (size_t)ncaps * sizeof(int64_t));
|
||||
scratchpool_release(sp, w.row);
|
||||
l->pcs[l->count++] = w.pc;
|
||||
break;
|
||||
default:
|
||||
free(w.saved); /* unreachable: an eligible Prog contains no other op */
|
||||
scratchpool_release(sp, w.row); /* unreachable: an eligible Prog contains no other op */
|
||||
break;
|
||||
}
|
||||
}
|
||||
da_free(&stack);
|
||||
return 1;
|
||||
fail:
|
||||
for (size_t k = 0; k < stack.len; k++) free(((PikeWork *)stack.data)[k].saved);
|
||||
da_free(&stack);
|
||||
for (int k = 0; k < ws->top; k++) scratchpool_release(sp, ws->items[k].row);
|
||||
ws->top = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Processes every thread in clist at position sp: a char/class/any
|
||||
* thread that matches is carried into nlist at pc+1; a match thread is
|
||||
* thread that matches is carried into nlist at pc+1 (its committed row
|
||||
* copied into a fresh scratch row first, since the terminal PC on the
|
||||
* far side of pc+1's own closure is not yet known); a match thread is
|
||||
* recorded as the best answer so far (subject to require_end and
|
||||
* forbid_empty, reusing MCtx's exact same-named semantics) and every
|
||||
* strictly lower priority thread remaining in clist this step is
|
||||
* discarded without being run (the leftmost-first rule, concept.md
|
||||
* 7.7, verified against rust-lang/regex's PikeVM). Always resolves
|
||||
* ownership of every clist entry before returning, success or not.
|
||||
* Returns 0 only on allocation failure. */
|
||||
static int pike_step(PikeList *clist, PikeList *nlist, MCtx *sctx, int64_t sp,
|
||||
* forbid_empty, reusing MCtx's exact same-named semantics) into
|
||||
* best_saved (a fixed, caller-owned ncaps-entry buffer, plain
|
||||
* overwritten, never allocated here), and every strictly lower
|
||||
* priority thread remaining in clist this step is discarded without
|
||||
* being run (the leftmost-first rule, concept.md 7.7, verified against
|
||||
* rust-lang/regex's PikeVM). Nothing in clist itself needs releasing
|
||||
* either way: clist's rows live at fixed table offsets, not in the
|
||||
* scratch pool, and are simply left to be overwritten the next time
|
||||
* their PC is used. Returns 0 only if pike_addthread does (the same
|
||||
* unreachable-in-practice, guarded conditions). */
|
||||
static int pike_step(PikeList *clist, PikeList *nlist, ScratchPool *pool, WorkStack *ws, MCtx *sctx, int ncaps, int64_t sp,
|
||||
int64_t require_end, int forbid_empty,
|
||||
int64_t **best_saved, int *matched) {
|
||||
int64_t *best_saved, int *matched) {
|
||||
int i;
|
||||
for (i = 0; i < clist->count; i++) {
|
||||
PikeThread th = clist->threads[i];
|
||||
Inst *in = &sctx->pr->insts[th.pc];
|
||||
int32_t pc = clist->pcs[i];
|
||||
int64_t *row = clist->table + (size_t)pc * ncaps;
|
||||
Inst *in = &sctx->pr->insts[pc];
|
||||
int consume_ok;
|
||||
switch (in->op) {
|
||||
case OP_CHAR:
|
||||
@@ -1329,44 +1404,197 @@ static int pike_step(PikeList *clist, PikeList *nlist, MCtx *sctx, int64_t sp,
|
||||
* do, Section 7.1's N_GROUP case); run()'s own OP_MATCH
|
||||
* handler sets it directly the same way (Section 6), and
|
||||
* this VM must too. */
|
||||
if ((require_end < 0 || sp == require_end) && !(forbid_empty && sp == th.saved[0])) {
|
||||
th.saved[1] = sp;
|
||||
free(*best_saved);
|
||||
*best_saved = th.saved;
|
||||
if ((require_end < 0 || sp == require_end) && !(forbid_empty && sp == row[0])) {
|
||||
memcpy(best_saved, row, (size_t)ncaps * sizeof(int64_t));
|
||||
best_saved[1] = sp;
|
||||
*matched = 1;
|
||||
i++;
|
||||
goto discard_rest;
|
||||
}
|
||||
free(th.saved);
|
||||
continue;
|
||||
default:
|
||||
free(th.saved); /* unreachable for an eligible Prog */
|
||||
continue;
|
||||
continue; /* unreachable for an eligible Prog */
|
||||
}
|
||||
if (consume_ok) {
|
||||
if (!pike_addthread(nlist, sctx, th.pc + 1, th.saved, sp + 1)) {
|
||||
for (int j = i + 1; j < clist->count; j++) free(clist->threads[j].saved);
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
free(th.saved);
|
||||
int32_t newrow = scratchpool_alloc(pool);
|
||||
if (newrow < 0) return 0; /* unreachable given sizing, guarded */
|
||||
memcpy(pool->rows + (size_t)newrow * ncaps, row, (size_t)ncaps * sizeof(int64_t));
|
||||
if (!pike_addthread(nlist, pool, ws, sctx, ncaps, pc + 1, newrow, sp + 1)) return 0;
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
discard_rest:
|
||||
for (; i < clist->count; i++) free(clist->threads[i].saved);
|
||||
return 1; /* remaining clist entries: nothing owned, nothing to release */
|
||||
}
|
||||
|
||||
/* Every per-Pattern, reusable Pike VM working state: the two thread
|
||||
* lists, the scratch pool and work stack pike_addthread needs, a
|
||||
* fixed scratch buffer for the current best match, and the literal/
|
||||
* class prefilter precomputed once from nfa_pr (concept.md 7.10,
|
||||
* extending 7.9's single-character check to a full mandatory literal
|
||||
* prefix, with a Boyer-Moore-Horspool skip table where the mode
|
||||
* allows one). Allocated once per top level Pattern_/re_ call and
|
||||
* reused across every pike_find call within it (every match
|
||||
* Pattern_finditer/Pattern_split find within one scan, not just every
|
||||
* position tried within one match attempt), the same granularity
|
||||
* MCtx's memo/repeat_maxrun/repeat_nextpm already use for the
|
||||
* backtracking engine (Section 11). */
|
||||
typedef struct {
|
||||
Prog *nfa_pr;
|
||||
int ncaps;
|
||||
PikeList clist, nlist;
|
||||
ScratchPool pool;
|
||||
WorkStack stack;
|
||||
int64_t *best_saved;
|
||||
|
||||
/* Prefilter, concept.md 7.10. Exactly one of the three is active,
|
||||
* decided once from nfa_pr's shape: prefix_len > 0 (a mandatory
|
||||
* literal prefix, one or more consecutive leading OP_CHAR
|
||||
* instructions with no intervening branch), is_class_first (a
|
||||
* single leading OP_CLASS, no multi-character extension exists for
|
||||
* a class), or neither (a leading assertion, group, alternation, or
|
||||
* nullable repeat: no filtering is safe, every position is always
|
||||
* a candidate, exactly v1's behavior for such a pattern). */
|
||||
int prefix_len;
|
||||
uint32_t *prefix_cp; /* prefix_len code points (mode-native: bytes widened for BINARY/ASCII, real code points for UTF8), already folded if IGNORECASE */
|
||||
int is_class_first;
|
||||
Node *first_cls;
|
||||
int bmh_ok; /* 1 if the Boyer-Moore-Horspool table below is usable: BINARY/ASCII mode (byte-indexed table), prefix_len >= 1 */
|
||||
int bmh_skip[256];
|
||||
} PikeEngine;
|
||||
|
||||
static void pikeengine_build_prefilter(PikeEngine *pe) {
|
||||
Prog *pr = pe->nfa_pr;
|
||||
pe->prefix_len = 0; pe->prefix_cp = NULL;
|
||||
pe->is_class_first = 0; pe->first_cls = NULL;
|
||||
pe->bmh_ok = 0;
|
||||
if (pr->n <= 0) return;
|
||||
if (pr->insts[0].op == OP_CLASS) {
|
||||
pe->is_class_first = 1;
|
||||
pe->first_cls = ((Node **)pr->classnodes.data)[pr->insts[0].data];
|
||||
return;
|
||||
}
|
||||
if (pr->insts[0].op != OP_CHAR) return; /* leading assertion/group/split: no filtering possible */
|
||||
int len = 0;
|
||||
while (len < pr->n && pr->insts[len].op == OP_CHAR) len++;
|
||||
pe->prefix_len = len;
|
||||
pe->prefix_cp = malloc((size_t)len * sizeof(uint32_t));
|
||||
if (!pe->prefix_cp) { pe->prefix_len = 0; return; } /* prefilter is an optimization only:
|
||||
failing to build it just means "no filtering", never a correctness problem, so this
|
||||
is not treated as a fatal PikeEngine construction error */
|
||||
for (int i = 0; i < len; i++) {
|
||||
uint32_t cp = pr->insts[i].data;
|
||||
pe->prefix_cp[i] = (pr->flags & IGNORECASE) ? cls_fold(cp, pr->mode, pr->flags) : cp;
|
||||
}
|
||||
/* BMH needs a byte-indexed (0-255) table: sound only for BINARY/
|
||||
* ASCII mode, where text_at() already returns a raw byte value, so
|
||||
* every prefix_cp[i] is itself in [0,255] and text bytes can be
|
||||
* compared directly against it (post-fold, for IGNORECASE) without
|
||||
* ever decoding anything. UTF8 mode's code points range up to
|
||||
* 0x10FFFF, far past a 256-entry table, so it keeps the
|
||||
* without-skip fallback below (still a real improvement over
|
||||
* checking one character, see pike_next_candidate). */
|
||||
if (pr->mode != MODE_UTF8 && len >= 1) {
|
||||
pe->bmh_ok = 1;
|
||||
for (int c = 0; c < 256; c++) pe->bmh_skip[c] = len;
|
||||
for (int i = 0; i < len - 1; i++) pe->bmh_skip[pe->prefix_cp[i] & 0xFF] = len - 1 - i;
|
||||
}
|
||||
}
|
||||
|
||||
static int pikeengine_init(PikeEngine *pe, Prog *nfa_pr) {
|
||||
memset(pe, 0, sizeof *pe);
|
||||
pe->nfa_pr = nfa_pr;
|
||||
pe->ncaps = 2 * (nfa_pr->ngroups + 1);
|
||||
int n = nfa_pr->n;
|
||||
if (!pikelist_init(&pe->clist, n, pe->ncaps)) return 0;
|
||||
if (!pikelist_init(&pe->nlist, n, pe->ncaps)) { pikelist_free(&pe->clist); return 0; }
|
||||
if (!scratchpool_init(&pe->pool, n + 1, pe->ncaps)) { pikelist_free(&pe->clist); pikelist_free(&pe->nlist); return 0; }
|
||||
if (!workstack_init(&pe->stack, n + 1)) { pikelist_free(&pe->clist); pikelist_free(&pe->nlist); scratchpool_free(&pe->pool); return 0; }
|
||||
pe->best_saved = malloc((size_t)pe->ncaps * sizeof(int64_t));
|
||||
if (!pe->best_saved) { pikelist_free(&pe->clist); pikelist_free(&pe->nlist); scratchpool_free(&pe->pool); workstack_free(&pe->stack); return 0; }
|
||||
pikeengine_build_prefilter(pe);
|
||||
return 1;
|
||||
}
|
||||
static void pikeengine_free(PikeEngine *pe) {
|
||||
pikelist_free(&pe->clist); pikelist_free(&pe->nlist);
|
||||
scratchpool_free(&pe->pool); workstack_free(&pe->stack);
|
||||
free(pe->best_saved); free(pe->prefix_cp);
|
||||
}
|
||||
|
||||
/* True iff a start thread injected at atpos could possibly survive its
|
||||
* own epsilon closure far enough to matter, checked against whichever
|
||||
* prefilter (7.10) applies; always true when none does. Used for a
|
||||
* position where something may already be in flight (so sp itself
|
||||
* cannot be skipped, only whether to inject a *new* start there,
|
||||
* exactly 7.9's original single-character check, now checking the
|
||||
* whole mandatory prefix instead of only its first character). */
|
||||
static int pike_prefilter_at(PikeEngine *pe, MCtx *sctx, int64_t atpos) {
|
||||
if (pe->prefix_len > 0) {
|
||||
if (atpos + pe->prefix_len > sctx->len) return 0;
|
||||
for (int i = 0; i < pe->prefix_len; i++) {
|
||||
uint32_t c = text_at(sctx, atpos + i);
|
||||
if (pe->nfa_pr->flags & IGNORECASE) c = cls_fold(c, pe->nfa_pr->mode, pe->nfa_pr->flags);
|
||||
if (c != pe->prefix_cp[i]) return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (pe->is_class_first) {
|
||||
return atpos < sctx->len && class_match(pe->first_cls, text_at(sctx, atpos), pe->nfa_pr->mode, pe->nfa_pr->flags);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* The actual skip-ahead: only safe to call when nothing is currently
|
||||
* in flight (clist empty), since it may advance sp past positions no
|
||||
* live thread has been stepped through, which would silently corrupt
|
||||
* an in-progress match attempt started earlier. Returns the smallest
|
||||
* position >= from where pike_prefilter_at would accept an injection,
|
||||
* or -1 if none remains in [from, endpos]. With a mandatory literal
|
||||
* prefix in BINARY/ASCII mode this is a real Boyer-Moore-Horspool
|
||||
* search (bad-character skip table built once in pikeengine_init,
|
||||
* concept.md 7.10); otherwise (UTF8 mode, or no BMH table, or a
|
||||
* leading class) it is a linear scan using pike_prefilter_at directly,
|
||||
* which still avoids ever calling pike_addthread for a position that
|
||||
* cannot possibly survive, just without the multi-position skip. */
|
||||
static int64_t pike_next_candidate(PikeEngine *pe, MCtx *sctx, int64_t from, int64_t endpos) {
|
||||
if (pe->bmh_ok) {
|
||||
int len = pe->prefix_len;
|
||||
int64_t i = from;
|
||||
while (i + len <= endpos) {
|
||||
int j = len - 1;
|
||||
while (j >= 0) {
|
||||
uint32_t c = text_at(sctx, i + j);
|
||||
if (pe->nfa_pr->flags & IGNORECASE) c = cls_fold(c, pe->nfa_pr->mode, pe->nfa_pr->flags);
|
||||
if (c != pe->prefix_cp[j]) break;
|
||||
j--;
|
||||
}
|
||||
if (j < 0) return i;
|
||||
uint32_t last = text_at(sctx, i + len - 1);
|
||||
if (pe->nfa_pr->flags & IGNORECASE) last = cls_fold(last, pe->nfa_pr->mode, pe->nfa_pr->flags);
|
||||
int skip = pe->bmh_skip[last & 0xFF];
|
||||
i += (skip > 0 ? skip : 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
for (int64_t p = from; p <= endpos; p++) {
|
||||
if (pike_prefilter_at(pe, sctx, p)) return p;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Finds the single leftmost, highest priority match for an NFA-only
|
||||
* Prog between pos and endpos (inclusive of trying a start at endpos
|
||||
* itself, matching do_one's existing <= convention). anchored seeds
|
||||
* exactly one start thread, at pos, and never injects another
|
||||
* (Pattern_match/fullmatch); unanchored search (anchored == 0) injects
|
||||
* a fresh start thread, at lowest priority, at every position up to
|
||||
* endpos, until a match has been recorded (Pattern_search/finditer/
|
||||
* split), giving one linear pass over [pos, endpos] rather than one
|
||||
* attempt per candidate start position.
|
||||
* itself, matching do_one's existing <= convention), using a
|
||||
* caller-owned, already-initialized PikeEngine (pikeengine_init,
|
||||
* concept.md 7.10; sized once from nfa_pr, reusable across many calls
|
||||
* against the same Pattern). anchored seeds exactly one start thread,
|
||||
* at pos, and never injects another (Pattern_match/fullmatch);
|
||||
* unanchored search (anchored == 0) injects a fresh start thread, at
|
||||
* lowest priority, at every position up to endpos, until a match has
|
||||
* been recorded (Pattern_search/finditer/split), skipping directly to
|
||||
* the next prefilter-viable position via pike_next_candidate whenever
|
||||
* nothing is currently in flight, giving one linear pass over
|
||||
* [pos, endpos] rather than one attempt per candidate start position.
|
||||
*
|
||||
* require_end (-1 unconstrained, else the exact end position required)
|
||||
* and forbid_empty mirror MCtx's identically named fields exactly
|
||||
@@ -1374,99 +1602,105 @@ discard_rest:
|
||||
* finditer/Pattern_split) behave identically on both engines.
|
||||
*
|
||||
* Returns 1 (match found, caps_out filled with 2*(ngroups+1) entries),
|
||||
* 0 (no match anywhere in range), or -1 (allocation failure partway
|
||||
* through; mirrors run_memo's depth_exceeded signalling an
|
||||
* 0 (no match anywhere in range), or -1 (the work stack or scratch
|
||||
* pool's fixed capacity was exceeded, unreachable in practice per the
|
||||
* sizing proof above; mirrors run_memo's depth_exceeded signalling an
|
||||
* unrecoverable condition through the same -1 every Pattern_/re_
|
||||
* matching function already documents, docs/API.md Section 3). */
|
||||
static int pike_find(Prog *nfa_pr, const uint32_t *text, const uint8_t *text8, int64_t pos, int64_t endpos,
|
||||
static int pike_find(PikeEngine *pe, const uint32_t *text, const uint8_t *text8, int64_t pos, int64_t endpos,
|
||||
int anchored, int64_t require_end, int forbid_empty, int64_t *caps_out) {
|
||||
Prog *nfa_pr = pe->nfa_pr;
|
||||
int ncaps = pe->ncaps;
|
||||
MCtx sctx; memset(&sctx, 0, sizeof sctx);
|
||||
sctx.pr = nfa_pr; sctx.text = text; sctx.text8 = text8; sctx.len = endpos;
|
||||
int ncaps = 2 * (nfa_pr->ngroups + 1);
|
||||
|
||||
PikeList clist, nlist;
|
||||
if (!pikelist_init(&clist, nfa_pr->n)) return -1;
|
||||
if (!pikelist_init(&nlist, nfa_pr->n)) { pikelist_free(&clist); return -1; }
|
||||
PikeList *clist = &pe->clist, *nlist = &pe->nlist;
|
||||
pikelist_clear(clist);
|
||||
pikelist_clear(nlist);
|
||||
|
||||
int64_t *best_saved = NULL;
|
||||
int matched = 0, oom = 0;
|
||||
int64_t sp = pos;
|
||||
int64_t sp;
|
||||
|
||||
/* Literal prefilter, added after v1 (concept.md 7.9): when the very
|
||||
* first instruction is a mandatory OP_CHAR or OP_CLASS (a pattern
|
||||
* that begins with a required literal or class, not a nullable loop
|
||||
* or a leading assertion), a freshly injected start thread at a
|
||||
* position that instruction does not accept is certain to die on
|
||||
* the very next pike_step call, exactly the way an un-prefiltered
|
||||
* one already does, just after paying for a malloc and a full
|
||||
* pike_addthread call first. Checking the identical condition
|
||||
* pike_step's own OP_CHAR/OP_CLASS case checks, before injecting
|
||||
* rather than after, changes nothing about which threads ever exist
|
||||
* (a position this skips would have produced a thread that dies
|
||||
* unobserved on the next step regardless), only how much work is
|
||||
* spent finding that out; this is the literal prefilter technique
|
||||
* concept.md 7.5/README.md "Implementation status" already cite for
|
||||
* the backtracking engine's own compute_next_prevmatch, applied
|
||||
* here in its simplest form (per position, not precomputed). A
|
||||
* pattern starting with `^`, a group, an alternation, or a nullable
|
||||
* repeat gets no benefit and none of the risk: should_inject stays
|
||||
* 1 and every position is injected exactly as in v1. */
|
||||
int first_op = nfa_pr->n > 0 ? nfa_pr->insts[0].op : -1;
|
||||
Node *first_cls = (first_op == OP_CLASS) ? ((Node **)nfa_pr->classnodes.data)[nfa_pr->insts[0].data] : NULL;
|
||||
#define PIKE_SHOULD_INJECT(atpos) \
|
||||
(first_op == OP_CHAR ? ((atpos) < endpos && char_eq(text_at(&sctx, (atpos)), nfa_pr->insts[0].data, nfa_pr->mode, nfa_pr->flags)) : \
|
||||
first_op == OP_CLASS ? ((atpos) < endpos && class_match(first_cls, text_at(&sctx, (atpos)), nfa_pr->mode, nfa_pr->flags)) : \
|
||||
1)
|
||||
|
||||
if (PIKE_SHOULD_INJECT(pos)) {
|
||||
int64_t *seed = malloc((size_t)ncaps * sizeof(int64_t));
|
||||
if (!seed) oom = 1;
|
||||
if (anchored) {
|
||||
sp = pos;
|
||||
if (pike_prefilter_at(pe, &sctx, pos)) {
|
||||
int32_t row = scratchpool_alloc(&pe->pool);
|
||||
if (row < 0) oom = 1;
|
||||
else {
|
||||
int64_t *r = pe->pool.rows + (size_t)row * ncaps;
|
||||
for (int k = 0; k < ncaps; k++) r[k] = -1;
|
||||
r[0] = pos;
|
||||
if (!pike_addthread(clist, &pe->pool, &pe->stack, &sctx, ncaps, 0, row, sp)) oom = 1;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int64_t start = pike_next_candidate(pe, &sctx, pos, endpos);
|
||||
if (start < 0) return 0; /* no position in [pos, endpos] can possibly start a match */
|
||||
sp = start;
|
||||
int32_t row = scratchpool_alloc(&pe->pool);
|
||||
if (row < 0) oom = 1;
|
||||
else {
|
||||
for (int k = 0; k < ncaps; k++) seed[k] = -1;
|
||||
seed[0] = pos;
|
||||
if (!pike_addthread(&clist, &sctx, 0, seed, sp)) oom = 1;
|
||||
int64_t *r = pe->pool.rows + (size_t)row * ncaps;
|
||||
for (int k = 0; k < ncaps; k++) r[k] = -1;
|
||||
r[0] = sp;
|
||||
if (!pike_addthread(clist, &pe->pool, &pe->stack, &sctx, ncaps, 0, row, sp)) oom = 1;
|
||||
}
|
||||
}
|
||||
|
||||
while (!oom) {
|
||||
int step_ok = pike_step(&clist, &nlist, &sctx, sp, require_end, forbid_empty, &best_saved, &matched);
|
||||
clist.count = 0; /* pike_step resolved every entry's ownership either way */
|
||||
int step_ok = pike_step(clist, nlist, &pe->pool, &pe->stack, &sctx, ncaps, sp, require_end, forbid_empty, pe->best_saved, &matched);
|
||||
if (!step_ok) { oom = 1; break; }
|
||||
if (sp >= endpos) break;
|
||||
sp++;
|
||||
if (!anchored && !matched && PIKE_SHOULD_INJECT(sp)) {
|
||||
int64_t *s2 = malloc((size_t)ncaps * sizeof(int64_t));
|
||||
if (!s2) { oom = 1; break; }
|
||||
for (int k = 0; k < ncaps; k++) s2[k] = -1;
|
||||
s2[0] = sp;
|
||||
if (!pike_addthread(&nlist, &sctx, 0, s2, sp)) { oom = 1; break; }
|
||||
int64_t next_sp = sp + 1;
|
||||
if (!anchored && !matched) {
|
||||
if (nlist->count > 0) {
|
||||
/* Something is already in flight for next_sp (advanced
|
||||
* from clist above): cannot skip ahead, only decide
|
||||
* whether to also inject a fresh start exactly there. */
|
||||
if (pike_prefilter_at(pe, &sctx, next_sp)) {
|
||||
int32_t row = scratchpool_alloc(&pe->pool);
|
||||
if (row < 0) { oom = 1; break; }
|
||||
int64_t *r = pe->pool.rows + (size_t)row * ncaps;
|
||||
for (int k = 0; k < ncaps; k++) r[k] = -1;
|
||||
r[0] = next_sp;
|
||||
if (!pike_addthread(nlist, &pe->pool, &pe->stack, &sctx, ncaps, 0, row, next_sp)) { oom = 1; break; }
|
||||
}
|
||||
} else {
|
||||
/* Nothing in flight: safe to skip next_sp directly to
|
||||
* the next prefilter-viable position (concept.md 7.10),
|
||||
* since no live thread exists anywhere in between that
|
||||
* would need to be stepped through one position at a
|
||||
* time. */
|
||||
int64_t cand = pike_next_candidate(pe, &sctx, next_sp, endpos);
|
||||
if (cand < 0) { next_sp = endpos + 1; }
|
||||
else {
|
||||
next_sp = cand;
|
||||
int32_t row = scratchpool_alloc(&pe->pool);
|
||||
if (row < 0) { oom = 1; break; }
|
||||
int64_t *r = pe->pool.rows + (size_t)row * ncaps;
|
||||
for (int k = 0; k < ncaps; k++) r[k] = -1;
|
||||
r[0] = next_sp;
|
||||
if (!pike_addthread(nlist, &pe->pool, &pe->stack, &sctx, ncaps, 0, row, next_sp)) { oom = 1; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
{ PikeList tmp = clist; clist = nlist; nlist = tmp; }
|
||||
pikelist_clear(&nlist);
|
||||
sp = next_sp;
|
||||
{ PikeList *tmp = clist; clist = nlist; nlist = tmp; }
|
||||
pikelist_clear(nlist);
|
||||
if (sp > endpos) break; /* pike_next_candidate found nothing left */
|
||||
/* An empty list only means "give up" when nothing will ever be
|
||||
* injected into it again: once anchored (a single seed, never
|
||||
* renewed) or matched (new starts stop being seeded, see above),
|
||||
* an empty list can never become non-empty again. Otherwise, a
|
||||
* renewed) or matched (new starts stop being seeded), an empty
|
||||
* list can never become non-empty again. Otherwise a
|
||||
* momentarily empty list is not the same as "no more positions
|
||||
* are worth trying": a fresh start seeded at some later position
|
||||
* can die immediately during its own epsilon closure (its very
|
||||
* first instruction, commonly \b, failing outright, exactly what
|
||||
* happens repeatedly inside a longer word like "catalog" for the
|
||||
* pattern \bcat\b) without that meaning every later position
|
||||
* will too. Breaking here unconditionally was a real bug, found
|
||||
* by the existing test suite: it stopped the whole search the
|
||||
* first time this happened, before ever reaching a later
|
||||
* position that would have matched. */
|
||||
if (clist.count == 0 && (anchored || matched)) break;
|
||||
* are worth trying" (concept.md 7.9's second bug fix; still
|
||||
* true here, unaffected by this section's rewrite). */
|
||||
if (clist->count == 0 && (anchored || matched)) break;
|
||||
}
|
||||
#undef PIKE_SHOULD_INJECT
|
||||
|
||||
pikelist_free(&clist);
|
||||
pikelist_free(&nlist);
|
||||
if (oom) { free(best_saved); return -1; }
|
||||
if (oom) return -1;
|
||||
if (!matched) return 0;
|
||||
memcpy(caps_out, best_saved, (size_t)ncaps * sizeof(int64_t));
|
||||
free(best_saved);
|
||||
memcpy(caps_out, pe->best_saved, (size_t)ncaps * sizeof(int64_t));
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1989,8 +2223,11 @@ static int do_one(Pattern *self, Input *string, int64_t pos, int64_t endpos, int
|
||||
* linear (Section 7.5), and this path was never quadratic in the
|
||||
* first place. */
|
||||
if (impl->has_nfa) {
|
||||
PikeEngine pe;
|
||||
if (!pikeengine_init(&pe, &impl->nfa_prog)) { free_matbuf(&mb); return -1; }
|
||||
int64_t *ncaps; alloc_caps(&ncaps, ng);
|
||||
int r = pike_find(&impl->nfa_prog, mb.text, mb.text8, p0, ep, anchored, fullmatch ? ep : -1, 0, ncaps);
|
||||
int r = pike_find(&pe, mb.text, mb.text8, p0, ep, anchored, fullmatch ? ep : -1, 0, ncaps);
|
||||
pikeengine_free(&pe);
|
||||
if (r < 0) { free(ncaps); free_matbuf(&mb); return -1; }
|
||||
if (r == 0) { free(ncaps); free_matbuf(&mb); return 0; }
|
||||
fill_match_from_caps(out, self, string, p0, ep, ncaps, ng, &mb);
|
||||
@@ -2049,10 +2286,10 @@ int Pattern_search(Pattern *self, Input *string, int64_t pos, int64_t endpos, Ma
|
||||
* Returns 1 (found, caps filled), 0 (no match), or -1 (unrecoverable;
|
||||
* caller must clean up and return -1, exactly as a depth_exceeded
|
||||
* backtracking failure already required before this engine existed). */
|
||||
static int find_next(PatternImpl *impl, MatBuf *mb, int64_t ep, int64_t start, int anchored, int forbid_empty,
|
||||
static int find_next(PatternImpl *impl, PikeEngine *pe, MatBuf *mb, int64_t ep, int64_t start, int anchored, int forbid_empty,
|
||||
uint8_t *memo, int32_t **maxrun, int32_t **nextpm, int64_t *caps, int ng) {
|
||||
if (impl->has_nfa)
|
||||
return pike_find(&impl->nfa_prog, mb->text, mb->text8, start, ep, anchored, -1, forbid_empty, caps);
|
||||
return pike_find(pe, mb->text, mb->text8, start, ep, anchored, -1, forbid_empty, caps);
|
||||
int found = 0;
|
||||
int64_t last = anchored ? start : ep;
|
||||
for (int64_t s = start; s <= last && !found; s++) {
|
||||
@@ -2074,19 +2311,34 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
int64_t start = clamp(pos, 0, mb.len);
|
||||
PatternImpl *impl = (PatternImpl *)self->program;
|
||||
int ng = impl->prog.ngroups;
|
||||
|
||||
/* concept.md 7.10: one PikeEngine for the whole scan, reused across
|
||||
* every match found (not just within one pike_find call), the same
|
||||
* granularity memo/maxrun/nextpm already use below for the
|
||||
* backtracking engine. Never cached on the Pattern/PatternImpl
|
||||
* itself, only here, on the stack, scoped to this one call: caching
|
||||
* it on PatternImpl would make concurrent Pattern_finditer calls on
|
||||
* the same compiled Pattern from different threads race on shared
|
||||
* mutable state, silently breaking the existing guarantee that a
|
||||
* Pattern no thread is concurrently modifying needs no external
|
||||
* synchronization (concept.md 13.6, docs/API.md Section 3.12-3.20). */
|
||||
PikeEngine pe;
|
||||
if (impl->has_nfa && !pikeengine_init(&pe, &impl->nfa_prog)) { free_matbuf(&mb); return -1; }
|
||||
|
||||
/* One memo table for the whole scan: every position tried, across
|
||||
* every match found, shares it (see the comment on MCtx.memo). A
|
||||
* fact it records ("(pc, sp) cannot reach OP_MATCH") never becomes
|
||||
* false later in the same scan, so nothing here ever needs to
|
||||
* invalidate or reset it between matches. */
|
||||
uint8_t *memo = alloc_memo(&impl->prog, ep);
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep); /* see compute_next_prevmatch */
|
||||
* invalidate or reset it between matches. Skipped entirely when
|
||||
* impl->has_nfa: the Pike VM needs none of this (Section 7.7). */
|
||||
uint8_t *memo = impl->has_nfa ? NULL : alloc_memo(&impl->prog, ep);
|
||||
int32_t **maxrun = impl->has_nfa ? NULL : alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep);
|
||||
int32_t **nextpm = impl->has_nfa ? NULL : alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep);
|
||||
int count = 0;
|
||||
while (start <= ep) {
|
||||
int64_t *caps; alloc_caps(&caps, ng);
|
||||
int found = find_next(impl, &mb, ep, start, 0, 0, memo, maxrun, nextpm, caps, ng);
|
||||
if (found < 0) { free(caps); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); free_matbuf(&mb); return -1; }
|
||||
int found = find_next(impl, &pe, &mb, ep, start, 0, 0, memo, maxrun, nextpm, caps, ng);
|
||||
if (found < 0) { free(caps); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); if (impl->has_nfa) pikeengine_free(&pe); free_matbuf(&mb); return -1; }
|
||||
if (!found) { free(caps); break; }
|
||||
Match m; memset(&m, 0, sizeof m);
|
||||
MatBuf shallow = mb; shallow.cp_to_byte = mb.cp_to_byte; /* share for lookup, copied inside fill */
|
||||
@@ -2106,8 +2358,8 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
* next (longer) alternative on its own if one exists. */
|
||||
if (mend == mstart) {
|
||||
int64_t *caps2; alloc_caps(&caps2, ng);
|
||||
int found2 = find_next(impl, &mb, ep, mstart, 1, 1, memo, maxrun, nextpm, caps2, ng);
|
||||
if (found2 < 0) { free(caps2); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); free_matbuf(&mb); return -1; }
|
||||
int found2 = find_next(impl, &pe, &mb, ep, mstart, 1, 1, memo, maxrun, nextpm, caps2, ng);
|
||||
if (found2 < 0) { free(caps2); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); if (impl->has_nfa) pikeengine_free(&pe); free_matbuf(&mb); return -1; }
|
||||
if (found2) {
|
||||
Match m2; memset(&m2, 0, sizeof m2);
|
||||
fill_match_from_caps(&m2, self, string, mstart, ep, caps2, ng, &shallow);
|
||||
@@ -2124,6 +2376,7 @@ int Pattern_finditer(Pattern *self, Input *string, int64_t pos, int64_t endpos,
|
||||
free(memo);
|
||||
free_repeat_maxrun(&impl->prog, maxrun);
|
||||
free_repeat_nextpm(&impl->prog, nextpm);
|
||||
if (impl->has_nfa) pikeengine_free(&pe);
|
||||
free_matbuf(&mb);
|
||||
return count;
|
||||
}
|
||||
@@ -2153,15 +2406,21 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
int64_t start = 0, seg_start = 0;
|
||||
PatternImpl *impl = (PatternImpl *)self->program;
|
||||
int ng = impl->prog.ngroups;
|
||||
uint8_t *memo = alloc_memo(&impl->prog, ep); /* one table for the whole scan, see MCtx.memo */
|
||||
int32_t **maxrun = alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep); /* see compute_next_prevmatch */
|
||||
|
||||
/* One PikeEngine for the whole scan; see Pattern_finditer's comment
|
||||
* on why it is never cached on PatternImpl itself. */
|
||||
PikeEngine pe;
|
||||
if (impl->has_nfa && !pikeengine_init(&pe, &impl->nfa_prog)) { free_matbuf(&mb); return -1; }
|
||||
|
||||
uint8_t *memo = impl->has_nfa ? NULL : alloc_memo(&impl->prog, ep); /* one table for the whole scan, see MCtx.memo */
|
||||
int32_t **maxrun = impl->has_nfa ? NULL : alloc_repeat_maxrun(&impl->prog, mb.text, mb.text8, ep); /* see compute_maxrun */
|
||||
int32_t **nextpm = impl->has_nfa ? NULL : alloc_repeat_nextpm(&impl->prog, mb.text, mb.text8, ep); /* see compute_next_prevmatch */
|
||||
int n = 0;
|
||||
while (start <= ep) {
|
||||
if (maxsplit > 0 && n >= maxsplit) break;
|
||||
int64_t *caps; alloc_caps(&caps, ng);
|
||||
int found = find_next(impl, &mb, ep, start, 0, 0, memo, maxrun, nextpm, caps, ng);
|
||||
if (found < 0) { free(caps); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); free_matbuf(&mb); return -1; }
|
||||
int found = find_next(impl, &pe, &mb, ep, start, 0, 0, memo, maxrun, nextpm, caps, ng);
|
||||
if (found < 0) { free(caps); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); if (impl->has_nfa) pikeengine_free(&pe); free_matbuf(&mb); return -1; }
|
||||
if (!found) { free(caps); break; }
|
||||
emit_split_item(cb, ctx, self, string, &mb, seg_start, caps[0]);
|
||||
for (int g = 1; g <= ng; g++) emit_split_item(cb, ctx, self, string, &mb, caps[2 * g], caps[2 * g + 1]);
|
||||
@@ -2173,8 +2432,8 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
if (mend == mstart) {
|
||||
if (maxsplit <= 0 || n < maxsplit) {
|
||||
int64_t *caps2; alloc_caps(&caps2, ng);
|
||||
int found2 = find_next(impl, &mb, ep, mstart, 1, 1, memo, maxrun, nextpm, caps2, ng);
|
||||
if (found2 < 0) { free(caps2); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); free_matbuf(&mb); return -1; }
|
||||
int found2 = find_next(impl, &pe, &mb, ep, mstart, 1, 1, memo, maxrun, nextpm, caps2, ng);
|
||||
if (found2 < 0) { free(caps2); free(memo); free_repeat_maxrun(&impl->prog, maxrun); free_repeat_nextpm(&impl->prog, nextpm); if (impl->has_nfa) pikeengine_free(&pe); free_matbuf(&mb); return -1; }
|
||||
if (found2) {
|
||||
emit_split_item(cb, ctx, self, string, &mb, mstart, caps2[0]);
|
||||
for (int g = 1; g <= ng; g++) emit_split_item(cb, ctx, self, string, &mb, caps2[2 * g], caps2[2 * g + 1]);
|
||||
@@ -2190,6 +2449,7 @@ int Pattern_split(Pattern *self, Input *string, int maxsplit, MatchIterCb cb, vo
|
||||
free(memo);
|
||||
free_repeat_maxrun(&impl->prog, maxrun);
|
||||
free_repeat_nextpm(&impl->prog, nextpm);
|
||||
if (impl->has_nfa) pikeengine_free(&pe);
|
||||
emit_split_item(cb, ctx, self, string, &mb, seg_start, ep);
|
||||
free_matbuf(&mb);
|
||||
return n;
|
||||
|
||||
Reference in New Issue
Block a user