Skip to content

Fastest Aho-Corasick on GPUs - #334

Open
ashvardanian wants to merge 50 commits into
mainfrom
main-multipattern
Open

Fastest Aho-Corasick on GPUs#334
ashvardanian wants to merge 50 commits into
mainfrom
main-multipattern

Conversation

@ashvardanian

@ashvardanian ashvardanian commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Most Frequent 1% of the Vocabulary

Library Count Cover Find Replace
Rust
aho_corasick<DFA> 181.1 MB/s
aho_corasick<ContiguousNFA> 106.1 MB/s
aho_corasick 106.0 MB/s 105.6 MB/s 100.6 MB/s
daachorse 129.2 MB/s 128.8 MB/s
regex::find_iter 106.2 MB/s
stringzillas::Substrings<1xSPR> 239.8 MB/s 183.6 MB/s 112.9 MB/s 89.1 MB/s
stringzillas::Substrings<16xSPR> 2,520.0 MB/s 1,380.0 MB/s 1,180.0 MB/s 719.4 MB/s
stringzillas::Substrings<H100> 28,610.0 MB/s 21,230.0 MB/s 13,130.0 MB/s 8,180.0 MB/s
Python
pyahocorasick 23.0 MB/s
ahocorasick_rs 81.9 MB/s 84.3 MB/s 81.2 MB/s
re.finditer 188.4 kB/s
stringzillas.Substrings<1xSPR> 241.2 MB/s 184.0 MB/s 74.2 MB/s 71.7 MB/s
stringzillas.Substrings<16xSPR> 2,210.0 MB/s 1,380.0 MB/s 618.3 MB/s 292.3 MB/s
stringzillas.Substrings<H100> 27,360.0 MB/s 19,420.0 MB/s 3,970.0 MB/s 1,030.0 MB/s

Most Frequent 10% of the Vocabulary

Library Count Cover Find Replace
Rust
aho_corasick<DFA> 54.0 MB/s
aho_corasick<ContiguousNFA> 74.0 MB/s
aho_corasick 77.2 MB/s 71.4 MB/s 65.0 MB/s
daachorse 111.9 MB/s 126.7 MB/s
regex::find_iter 77.4 MB/s
stringzillas::Substrings<1xSPR> 90.8 MB/s 65.1 MB/s 42.5 MB/s 30.4 MB/s
stringzillas::Substrings<16xSPR> 780.9 MB/s 547.6 MB/s 392.6 MB/s 247.8 MB/s
stringzillas::Substrings<H100> 15,220.0 MB/s 8,150.0 MB/s 3,810.0 MB/s 4,340.0 MB/s
Python
pyahocorasick 15.1 MB/s
ahocorasick_rs 47.3 MB/s 53.3 MB/s 50.3 MB/s
stringzillas.Substrings<1xSPR> 95.1 MB/s 66.8 MB/s 27.7 MB/s 29.4 MB/s
stringzillas.Substrings<16xSPR> 794.2 MB/s 496.9 MB/s 158.9 MB/s 195.2 MB/s
stringzillas.Substrings<H100> 15,230.0 MB/s 8,120.0 MB/s 617.2 MB/s 2,160.0 MB/s

Least Frequent 1% of the Vocabulary

Library Count Cover Find Replace
Rust
aho_corasick<DFA> 465.0 MB/s
aho_corasick<ContiguousNFA> 417.7 MB/s
aho_corasick 435.9 MB/s 432.0 MB/s 430.7 MB/s
daachorse 1,170.0 MB/s 1,070.0 MB/s
regex::find_iter 440.6 MB/s
stringzillas::Substrings<1xSPR> 360.2 MB/s 354.9 MB/s 182.7 MB/s 175.2 MB/s
stringzillas::Substrings<16xSPR> 4,090.0 MB/s 4,040.0 MB/s 2,000.0 MB/s 2,010.0 MB/s
stringzillas::Substrings<H100> 32,170.0 MB/s 25,980.0 MB/s 28,160.0 MB/s 8,540.0 MB/s
Python
pyahocorasick 7.6 MB/s
ahocorasick_rs 228.5 MB/s 237.7 MB/s 236.4 MB/s
stringzillas.Substrings<1xSPR> 376.4 MB/s 360.3 MB/s 125.9 MB/s 157.4 MB/s
stringzillas.Substrings<16xSPR> 3,680.0 MB/s 3,420.0 MB/s 1,340.0 MB/s 1,010.0 MB/s
stringzillas.Substrings<H100> 25,040.0 MB/s 26,680.0 MB/s 15,400.0 MB/s 2,670.0 MB/s

Least Frequent 10% of the Vocabulary

Library Count Cover Find Replace
Rust
aho_corasick<DFA> 141.2 MB/s
aho_corasick<ContiguousNFA> 174.6 MB/s
aho_corasick 167.7 MB/s 170.8 MB/s 153.3 MB/s
daachorse 277.9 MB/s 279.3 MB/s
regex::find_iter 178.1 MB/s
stringzillas::Substrings<1xSPR> 171.5 MB/s 150.6 MB/s 86.1 MB/s 73.3 MB/s
stringzillas::Substrings<16xSPR> 1,740.0 MB/s 1,380.0 MB/s 872.2 MB/s 702.1 MB/s
stringzillas::Substrings<H100> 18,050.0 MB/s 11,380.0 MB/s 9,660.0 MB/s 4,810.0 MB/s
Python
pyahocorasick 5.0 MB/s
ahocorasick_rs 108.8 MB/s 114.8 MB/s 121.1 MB/s
stringzillas.Substrings<1xSPR> 182.2 MB/s 153.1 MB/s 61.2 MB/s 75.1 MB/s
stringzillas.Substrings<16xSPR> 1,250.0 MB/s 1,140.0 MB/s 538.3 MB/s 423.9 MB/s
stringzillas.Substrings<H100> 17,960.0 MB/s 10,900.0 MB/s 3,990.0 MB/s 2,350.0 MB/s

Entire Vocabulary

Library Count Cover Find Replace
Rust
aho_corasick<DFA> 24.6 MB/s
aho_corasick<ContiguousNFA> 40.9 MB/s
aho_corasick 42.3 MB/s 40.5 MB/s 35.7 MB/s
daachorse 65.6 MB/s 81.4 MB/s
regex::find_iter 49.6 MB/s
stringzillas::Substrings<1xSPR> 43.4 MB/s 26.7 MB/s 22.2 MB/s 14.4 MB/s
stringzillas::Substrings<16xSPR> 552.6 MB/s 315.7 MB/s 248.1 MB/s 145.0 MB/s
stringzillas::Substrings<H100> 9,310.0 MB/s 4,000.0 MB/s 1,670.0 MB/s 2,020.0 MB/s
Python
pyahocorasick 8.1 MB/s
ahocorasick_rs 24.2 MB/s 32.3 MB/s 25.4 MB/s
stringzillas.Substrings<1xSPR> 62.6 MB/s 41.1 MB/s 13.4 MB/s 17.2 MB/s
stringzillas.Substrings<16xSPR> 602.5 MB/s 264.0 MB/s 101.0 MB/s 145.7 MB/s
stringzillas.Substrings<H100> 8,740.0 MB/s 3,780.0 MB/s 255.0 MB/s 1,340.0 MB/s

Introduces the `find_many` headers: a goto-completed Aho-Corasick automaton
built once on the host and matched against a whole batch of haystacks, by CPU
engines and CUDA kernels sharing one published `aho_corasick_view`.

Transitions live in two tiers - a dense 256-wide table for the most-visited
states, a double array with failure links for the rest - so the split follows
how often a state is actually visited rather than the alphabet. Matches are
reported through a CSR output pool that inherits along failure chains, and
`find_many_uncased_k` bakes full Unicode case folding into the transitions
themselves via preimage reconvergence, so the haystack is never folded while
matching.

The GPU backend gives one thread one chunk of a concatenated tape, warming up
`max_match_bytes - 1` bytes ahead of its chunk so chunking stays exact rather
than approximate, and stages a frequency-ordered prefix of the hot tier into
shared memory. A single kernel serves both the counting and scattering pass,
selected by a compile-time pass enum.

State ids are parameterized over `u16` and `u32` to keep occupancy high, while
every container index and byte offset is `size_t`, so one haystack may exceed
4 GB. `scratch_amount_t` moves up from the similarities engine into the shared
types header, since more than one family now lays out scratch that way.
Expose the Aho-Corasick multi-pattern engine through the stable C ABI:
`szs_find_many_init` compiles a dictionary from a needle collection with
cased or case-folded matching, and `szs_find_many_count` / `szs_find_many`
run over `sz_sequence_t` and both tape shapes on whichever device scope the
caller names. The backends type is a `std::variant` of bare engines selected
by the capability mask at construction, dispatched by the same
capability-driven visitor every sibling domain uses.

Normalize the engine signatures across all three backends: `try_build`
takes needles plus case sensitivity everywhere, and `try_count` / `try_find`
share one shape - per-haystack counts with a grand total, or a match span
with a written count - on serial, parallel, and CUDA alike. One offset-based
`find_many_match_t`, layout-identical to the C struct, flows from the
dictionary callback to the caller's array with no conversions or staging
copies on any CPU path; the CUDA engine owns its host dictionary, uploads
to managed memory at build, and drains host-bound outputs with one
stream-ordered driver copy.

Harden the width discipline along the way: the cold-tier probe indexes wide
before narrowing, edge ordinals gain a ceiling check against the shared
`state_id_t` bound, and the parallel scatter guards its overlap pointer on
empty dictionaries.

Cover it with an adversarial needle-by-haystack test sweep against a
brute-force oracle, backend-agreement and buffer-contract checks, a forced
all-cores-on-one-haystack slicing test, device-memory contract tests for
scattered unified allocations and host-input rejection, and a Zipf-sliced
benchmark reporting construction in needle-bytes and search in
haystack-bytes per second across vocabulary cutoffs.
The multi-pattern family is growing operations beyond locating matches -
`replace` rewrites and BM25-style scoring share the same compiled
Aho-Corasick dictionary - so it takes a plural product noun beside
`similarities` and `fingerprints` instead of one operation's verb.

The bare locate call `szs_find_many` becomes `szs_substrings_find`, and
its tape twins follow the operation name as `szs_substrings_find_u32tape`
and `szs_substrings_find_u64tape`. Every other symbol renames
mechanically, and `aho_corasick_*` keeps its algorithm name.
Nothing in the tree ever filled the struct, so every engine tuned to a
fictional 256 KB L2 and 8 MB L3. The spawned `forkunion_executor_t` now
answers with a `specs()` method reading its own detected topology: the
deepest domain-confined cache fills `l3_bytes` - 105 MB instead of 8 MB
on this box - and the logical-core and memory-domain counts fill the
core fields. `l1_bytes` and `l2_bytes` keep their conservative
defaults, since the ForkUnion C API exposes no per-core cache-level
query yet.

The C runtime's CPU scope stores the spawned executor's real specs
instead of a defaulted local, so every `get_specs` consumer across the
five domain shims inherits the fix. The serial default scope keeps
defaulted specs - it owns no topology to ask.

All three benches now thread `pool.specs()` into every CPU engine cell
beside the executor, mirroring how the GPU cells already pass their
fetched `gpu_specs_t`. The UTF-8 baselines spell the `dummy_executor_t`
explicitly to reach the specs slot of the greedy executor overload.
Tests intentionally stay on hand-crafted specs - they force both sides
of every cache threshold deterministically on any machine.
Five cross-TU helpers in the CPython extension were defined with
`SZ_API_RUNTIME` while their declarations in `python/stringzilla/stringzilla.h`
are plain `extern`.
On MSVC that macro is `__declspec(dllexport)`, so the pair is a linkage
mismatch and both Windows jobs failed with `error C2375: redefinition;
different linkage`; GCC and Clang accept it silently.

Nothing inside the module needs exporting beyond its init function, and the
sibling helpers in the same files - `sz_py_is_mutable`,
`sz_py_export_optional_index`, `Strs_get_start_` - were already plain.
`sz_assert_` is gated on `SZ_DEBUG`, and `CMakeLists.txt` defines `SZ_DEBUG=0`
for every configuration but `Debug`, where `types.h` expands it to
`((void)(condition))`.
Every CI test build is `Release` or `RelWithDebInfo`, so 77 assertions in the
GPU test families evaluated their argument and discarded the verdict - the
differential comparison at the heart of the similarity suite could not fail.
The rest of `test/` had already moved to `verify()`, which no configuration
strips; these two files and one helper in the shared header had not.

24 of them performed the work inside the assertion, such as filling a tape
through `try_append`.
Those become `let_verify`, hoisting the call into its own statement so the
result is named before it is checked, rather than depending on
`((void)(condition))` still evaluating its argument.

Also drops the `unique` parameter of `randomize_strings`: no caller has ever
passed it, and the path was unsound - it deduplicated after `resize`, handing
back fewer strings than the requested batch size with no diagnostic.
A helper that chooses or encodes now returns its result and the caller
appends, so every arm of a corpus `switch` reads the same way instead of
mixing `append_codepoint_(out, cp)` against arms that append inline.
`append_codepoint_` becomes `encoded_rune_`, returning a short-string-optimized
`std::string` that never reaches the allocator at four bytes, across 53 call
sites.
The builders that stream many items into a reused buffer keep their sink
parameter - the rule is about choosing and encoding, not accumulating.

`utf8_runes.cpp` and `utf8_tokens.cpp` included `stringzilla.hpp` directly and
re-derived helpers the shared header already had, so `append_codepoint_` was
triplicated and `random_valid_utf8_` duplicated verbatim.
Both now include `utf8.hpp`, which gains `random_valid_utf8_` and
`random_valid_utf8_bytes_` as their single home; the hex-dump helper collapses
from four copies under two names to one.

Spells out `rng` as `generator` in the 16 signatures that carried it, matching
the call sites, and gives the two sentence straddle builders the family infix
every sibling already had.
The adversarial sweep now crosses three declared axes - needle shapers,
byte-agnostic placement skeletons, and needle transforms - instead of
two fused generator enums, and every planting records its ground truth:
the exact match record and whether the engine must report or must miss
it. A new referee asserts those declared effects on every cell, a third
witness beside the brute-force oracle that cannot share a misconception
with either walk, since the generator knows where it planted what.

Vocabularies now sample the shipped fold-preimage tables instead of
hand-picked literals: narrow-image needles cover Greek, Cyrillic,
Cherokee, and astral fold pairs by construction, wide-image needles
exercise 2:1 and 3:1 byte expansions, and a mixed-width shaper drags
every needle through all four UTF-8 decode widths. One table-driven
inverter re-spells needles through fold space for the fixtures and the
agreement pool alike.

The old near-miss arm was an ASCII case flip mislabeled as a negative
control - under an uncased dictionary those haystacks still match. The
transform axis states each effect per sensitivity mode, and a true
negative control perturbs a non-case bit. New placements interrupt
variants mid-codepoint with an unbridgeable byte, and plant matches
across slice seams of one large haystack that sliced-L2 cells walk with
a real thread pool; cased cells also sprinkle malformed continuation
bytes into the noise.

The independent oracle returns matched haystack slices as spans rather
than offset pairs, and both consumers recover the record fields in
place.
Add STRINGWARS_DATASET_LIMIT to cap how much of the corpus each benchmark
reads, chosen by what the benchmark measures. Compute-bound families take a
64 MiB slice, enough to exercise every control-flow path, while memory-bound
families read the whole file so their steady-state bandwidth is measured on as
much data as is available. The bound is applied at read time, so a capped run
never touches the file tail. This replaces STRINGWARS_MAX_TOKENS, whose token
count conflated a byte budget with a byte-independent token budget.
Aho-Corasick walks a data-dependent transition load, so the CUDA engine is
latency-bound rather than bandwidth-bound - it sustains under one percent of an
H100's memory throughput. Resident warps are the only thing hiding that latency,
which inverts the staging trade: the hot tier is now copied into shared memory
only when all of it fits without costing a resident block, and read through the
cache hierarchy otherwise. A partial prefix pays the copy per block and a bounds
test per byte while most transitions miss it anyway, so staging is all-or-nothing.

The budget behind that rule comes from the occupancy query rather than from
arithmetic or a tuned constant. Dividing a multiprocessor's shared memory by a
target block count silently yields one block fewer, because the driver charges
every block a reserve of its own and rounds to a granularity no attribute
reports; `cuOccupancyAvailableDynamicSMemPerBlock` repeats that same division, so
`shared_memory_budget_for_resident_blocks` binary-searches
`cuOccupancyMaxActiveBlocksPerMultiprocessor` instead, and a zero target derives
its block count from the occupancy the kernel reaches with no shared memory at
all. Measured on an H100 over 64 MiB of news text, counting rises from 14.8 to
29.2 GB/s and finding from 8.1 to 28.2 GB/s at 3.4K needles, and from 5.2 and 2.5
to 14.8 and 10.2 GB/s at 346K needles, with match counts unchanged throughout.

The dictionary now publishes a walking automaton rather than the raw spelling
trie. A case-folded trie is a DAG - several spellings reconverge on one state at
different byte widths - so its failure links are not single-valued. Splitting
each state by its `(spelling, failure)` pair is the coarsest split that restores
them. The doubling family, "ssssss" folded, is provably exponential and is
declined with `overflow_risk_k` once the walking count would overflow the id
type, rather than built wrong.

Both walkers feed four transitions per tape load. The state chain stays strictly
serial - only the fetch widens - so `count` and the parallel prefix counter drop
three of every four loads. On the device the cursor is peeled to its own 4-byte
boundary and the load issued as one `ld.u32`: PTX traps on a misaligned `ld.u32`
rather than slowing down, so the compiler otherwise lowers a 4-byte `memcpy` from
an unproven pointer into four byte loads plus three `prmt` merges.

The CUDA walk tests one acceptance bit before touching `outputs_counts`, so the
common no-match byte never reads the global counts array. The warm-up prefix is
peeled out of the emit loop instead of re-testing its bound on every byte, and
both passes share one `chunk_match_slots` buffer, which the host's in-place
exclusive scan had already made the same allocation.

Hot-tier ordering is shallow-first with each depth band sorted by out-degree
descending. Per-state growth during insertion reserves in power-of-two steps,
since `try_reserve` allocates exactly what it is asked and a bare
`try_resize(count + 1)` per state re-moved the whole array every time.
…chmarks

Every StringZillas test and benchmark inherits `SZ_DYNAMIC_DISPATCH=1` from the
engine libraries it links, which externs the `sz_*` entry points and leaves them
to be resolved at link time. Nothing resolved them, so the moment a benchmark
called one directly - `sz::string_view::split` and the `sz::hash` / `sz::equal_to`
pair behind the substrings vocabulary - both bench targets failed to link.

Neither shipped library could take that job: `stringzilla_shared` and
`stringzilla_bare` both set `SZ_OVERRIDE_LIBC=1`, interpose LibC's own symbols,
and fault the process before `main`. The shim units default `SZ_OVERRIDE_LIBC`
to `SZ_AVOID_LIBC`, so `stringzilla_static` leaves both at zero and is otherwise
the same dispatch core, built only when tests or benchmarks are. It carries no
install rule, being an in-tree consumer's archive rather than a shipped one.
`define_stringzilla_shared` becomes `define_stringzilla_library`, taking the link
type as a parameter, so the new target reuses the aliases, position-independence,
baseline architecture flags, and SIMD definitions instead of copying them.

The substrings vocabulary now counts word frequencies over the whole dataset
rather than over `env.tokens`, so `lines` and `file` searches draw needles from
the same vocabulary a `words` search does, and `STRINGWARS_UNIQUE` no longer
silently flattens every count to one. One hashed pass replaces sorting every
occurrence, since the corpus holds hundreds of millions of words but only a few
million distinct ones. Words under 3 bytes match at nearly every position and
would measure match materialization rather than the walk; words over 32 bytes are
unsegmented CJK or Thai runs and URLs, and the longest needle sets
`max_match_bytes`, which every GPU chunk re-walks as its warm-up.

The README tables are measured rather than pending, in MB/s per cell with the
fastest backend of each column in bold, on 64 MiB of `xlsum.csv` split into lines.
Engines that size a working set against the cache the GPU actually reads through
had nothing to read it from: `gpu_specs_t` carried VRAM, constant, and shared
memory but not L2, so anything cache-resident had to borrow `cpu_specs_t` and
inherit a host cache size instead. `CU_DEVICE_ATTRIBUTE_L2_CACHE_SIZE` fills it
alongside the attributes `gpu_specs_fetch` already reads in its one cold pass.

The default stays an A100's 40 MB, matching how every other field in the struct
documents its fallback.
…d capacity

The CUDA engine chose its hot/cold split from a default `cpu_specs_t`, so the
tier a GPU walks was sized by an 8 MB host last-level cache regardless of the
device: 8192 rows on an H100 whose own L2 holds five times that. It now sizes
against `gpu_specs_t::l2_bytes`, the cache the walk actually reads through, which
puts the whole automaton in the hot tier for small dictionaries and raises
counting from 29.2 to 30.3 GB/s and finding from 28.2 to 31.3 GB/s at 3.4K
needles, and counting from 14.8 to 17.0 GB/s at 346K, on 64 MiB of news text.

A refused output buffer no longer zeroes the count it just computed. Every
`try_find` counts internally before it can check capacity, so the number the
caller needs was already in hand and thrown away, leaving a size query to
recover it with a second walk - and on the GPU a second counting kernel, a
second device-wide scan, and a second blocking synchronize. The status already
separates a refusal from a success, so reporting the need costs no ambiguity and
makes an empty buffer a single-call size query.

The uncased fixtures that pin the walking automaton's reconvergence splits come
with it: the codepoint-boundary end test in the independent oracle, the
`(spelling, failure)` hazard table, and the doubling family declining with
`overflow_risk_k` at the `u16` ceiling while `u32` still compiles it.
…er state id

A `u16` automaton halves the transition row - 512 bytes against `u32`'s 1024 -
so twice as much of it stays cache-resident, which is what the hot tier is sized
against. Reaching that width meant guessing it before construction, and the
guess cannot be made: for cased vocabularies the trie node count is exactly the
sorted needles' summed prefix deltas, but folding turns the uncased automaton
into a DAG whose `(spelling, failure)` split multiplies states unpredictably.

So the width stops being an input. `try_build` gains an overload taking an
already-built wider dictionary and adopting its published arrays element by
element, which costs a copy rather than a second derivation of the walking
automaton. Every ceiling the narrower id imposes - published slots, needle
index, match length, outputs per state - is tested before anything is copied,
and a vocabulary that does not fit declines with `overflow_risk_k` leaving the
wider dictionary usable.

It is named by the dictionary rather than the engine holding it: the narrowing
needs nothing else, and the concrete parameter type is what keeps the overload
from competing with the needles one, which an unconstrained template argument
could not.

Measured on an H100 over 64 MiB of news text, counting a 3,462-needle
vocabulary of 19,291 states rises from 30.9 to 34.0 GB/s once narrowed, with
identical occurrence counts; a 34,620-needle vocabulary of 144,929 states
declines as it should.

Both state-id widths of both CPU capabilities are now instantiated in the shim's
translation unit. The serial engines were absent, so every consumer recompiled
them despite the C shim building one for a single-threaded scope.
Matching gains a per-call overlap policy: every match as before, or a leftmost
cover resolved by longest span or by lowest needle index. One dictionary serves
all three, since the policy never shapes the compiled automaton. It is resolved
in exactly one place - `count` and `visit` pick the walk - so neither engine
branches on it and a new backend implements that pair and nothing else. The
overlapping tally keeps its dedicated four-byte-load walk, which is a genuinely
different kernel rather than a duplicated dispatch.

Rewriting substitutes each match with its needle's replacement, tape in and tape
out, because a rewrite's product is itself a tape. It sizes with a zero-capacity
call and refuses a short buffer whole while naming the size it wanted, so the
capacity contract matches the one `find` already offers. An overlapping rewrite
is refused outright: two matches sharing a byte have no single answer, so it is
not a function.

Scoring walks each haystack once for classic BM25 against one weight per needle.
The dictionary is the query here rather than an index to probe, so a second
weighting is a second scan, not a second row of a matrix that would be mostly
zeros over a large vocabulary. Term frequencies are raw counts and take no
policy - a leftmost cover suppresses genuine occurrences of any needle nested in
another, which is no longer a term frequency - and the reduction runs in
ascending needle order, so scores are bit-stable across runs of one backend.

The leftmost walk settles a start once the scan is `max_match_bytes` past it,
holding the undecided ones in a window rounded up to a power of two so the slot
lookup masks instead of dividing by a value known only at runtime. It drains to
the last start a match actually claimed rather than to the end of the haystack,
which is what keeps a haystack no needle hits from paying a slot visit per byte.

The C entry points for scoring and rewriting land separately.
`sz_capabilities_to_string_implementation_` wrote into a function-local
`static char[256]` and returned a pointer to it, so two threads formatting
capabilities at once overwrote each other's text, and so did two calls whose
results were meant to be compared. It now takes the caller's buffer and its
capacity, truncates rather than overflowing, and returns the length written.

The four Python call sites pass a stack array. The exported
`sz_capabilities_to_string` keeps a `static`, because its signature returns a
string it never receives - that is now the only shared buffer left.
`SZ_HELPER_AUTO` now carries `constexpr` wherever the language has the word,
so every scalar helper folds at compile time when its arguments allow it -
and, more usefully, becomes callable from a CUDA kernel: `nvcc` reaches a host
`constexpr` function from device code under `--expt-relaxed-constexpr`. The C
layer therefore never has to name an execution space of its own, and needs no
`__host__ __device__` token, no per-function portability macro, and no
include-order rule.

Three constructs stood in the way, all of which C++23 legalizes and no `nvcc`
accepts - CUDA 13.2 still tops out at `-std=c++20`:

- The uncased danger-zone search chained three near-identical blocks with 13
  `goto`s into 3 labels, one per folded rune the needle's anchor could sit on.
  They collapse into a loop over the anchor index, calling a single
  `sz_utf8_uncased_verify_at_folded_rune_`. The runes of one source codepoint
  on either side of the anchor never reach a folded iterator, so they are
  compared against the image directly, which is what the per-block pre-checks
  were doing by hand. Behaviour is identical across 1184 fold-anomaly cases,
  533 of them matches, at a measured 2-5% cost on that path alone - it fires
  only on chunks holding one of the 139 boundary-breaking codepoints.
- Two AVX2 left-pack tables were function-local statics; they move to file
  scope unchanged.

The folded rune iterators move to `utf8_uncased_fold/serial.h`, beside the
fold they call, which is also the header a GPU walk can include on its own.

`-Wno-invalid-constexpr` joins the C++ warning set: a helper reaching an
intrinsic can never be constant-evaluated, which the diagnostic correctly
reports and which is not a defect here.
The C shims carried three guard schemes at once - `STRINGZILLAS_SZS_<FILE>_CUH_`
for five files, `STRINGZILLAS_RUNTIME_CUH_` for a sixth, and
`STRINGZILLAS_SCAFFOLDING_CUH_` for a seventh, which named a concept rather
than its file. They now mirror the path under `c/` behind the prefix every
function in them already uses: `SZS_SUBSTRINGS_CUH_`, `SZ_DISPATCH_H_`. Headers
under `include/` already mirrored their paths and are untouched, as are the
three umbrella guards a downstream consumer might test for.

44 `@file` annotations pointed at paths that have not existed for a long time -
most of them at a retired `scripts/` directory with `bench_`/`test_` prefixes,
the rest at bare filenames or a renamed table header.
The multi-pattern engine and `sz_utf8_uncased_search` disagreed about what a
case-insensitive match is, and the multi-pattern side was wrong. It inverted
the fold onto the needle - enumerating every source codepoint whose fold equals
a 1-3 rune window and giving each a raw-byte trie path - so every match had to
consume whole source codepoints, and `rune_arrival_t::atomic_k` existed to
document that refusal. A needle "s" could not match inside "ss".

That refusal was not a bug to patch but the only semantics the architecture
could express. Any deterministic automaton whose alphabet is raw haystack bytes
and which must report match spans is exponential in needle length: the fooling
set is a run of sharp-S against a run of long-S pairs, fold-identical at 2 bytes
versus 4, so the reported span encodes the byte width of every spelling behind
it and all prefixes are pairwise inequivalent. Uncased "ssssss" measured 454824
states, and wider dictionaries declined with `overflow_risk_k`.

The fold now happens in the byte stream instead. `substrings_folded_cursor_t`
walks one codepoint at a time and feeds folded bytes to a plain trie, so a
needle contributes one path and the automaton is the cased one. Matching is the
fold-subset rule the single-pattern kernel already follows: `fold(needle)` is a
contiguous run of `fold(haystack)`, with both ends snapped outward to codepoint
boundaries. "ssssss" is now 7 states.

Folded and source offsets agree everywhere except within a window touching one
of the 139 boundary-breaking codepoints, so `substrings_folded_span` resolves a
match by subtraction in the common case and walks backwards only past the last
break. `substrings_resolve_match` also settles whether an earlier rune of the
same codepoint already reported that span, which is what keeps "s" in a sharp-S
to one match while leaving "ss" over two sharp-S at three distinct spans.

`sz_utf8_lead_may_fold_` answers "can anything under this lead byte fold" from
four immediates, with no table and no memory access on either a CPU or a GPU;
without it, deleting the old classifier cost 27% on Chinese and 23% on Arabic.

`match_bytes` splits into folded and source pairs throughout: the automaton
walks folded bytes, every haystack extent - halo widths, slice sizes, pending
starts - needs the source bound, and the two must never be compared.

Validated against an independent fold-subset oracle over 3000 cased and 30000
uncased random dictionaries with zero mismatches, plus per-needle agreement
with `sz_utf8_uncased_search` itself.
The device walk stepped raw haystack bytes, so an uncased dictionary would have
answered differently from every CPU backend. It now runs the same
`substrings_folded_cursor_t` the host walk uses, resolving each match through
the shared `substrings_folded_span` - one subtraction while folded and source
offsets agree, a bounded backward walk past the last boundary break.

Leftmost policies reach the GPU with no host resolution and no atomics on the
hot path. A survivor whose start lies at or after the maximum end seen so far is
accepted unconditionally, independent of everything before it, which makes the
cover resolvable inside one chunk: each thread keeps a small ring of pending
starts and settles them locally. Measured over the corpora, the prefix-max
segments have a median length of 1 and a maximum of 29, all within the ring.

That ring is `substrings_device_pending_width_k` wide, which nothing previously
checked against the dictionary. A needle whose worst source span exceeds it
would have aliased slots silently, so `try_count`/`try_find` now refuse with
`overflow_risk_k` up front.

The leftmost walk also gates on the acceptance bitmap its two sibling walks
already used, instead of discarding it.

`bench/substrings.cuh` had never compiled: its `try_count`/`try_find` calls
omitted the `overlap_policy` argument the engine has required since before this
work.
`multiplying_rolling_hasher` carries no modulo and says so - it relies on the
integer type's natural overflow. That is defined for unsigned types and
undefined for signed ones, so instantiating it over `i32_t` made every `push`,
every `roll`, and the `highest_power_` loop undefined at exactly the width they
were meant to wrap at. It is not a sanitizer nuisance: a compiler may assume
signed overflow cannot happen and optimize on that basis, and the reported
values sat right at the signed maximum.

`buz_rolling_hasher` needs the same guarantee for a different reason. Its
rotation shifts both ways, and a signed left shift pushes a sign bit out while a
signed right shift replicates it rather than feeding in zeros, so the rotation
would neither be a rotation nor be defined. Only unsigned instantiations exist
today, and now only those compile. `rabin_karp_rolling_hasher` already named the
three widths it accepts and needed nothing.

Signedness buys nothing here to weigh against that. Two's complement makes
addition, subtraction, and the low half of a multiply the same operation either
way, and `push`, `roll`, and a whole-buffer sweep compile to identical
instructions on x86-64 and AArch64 alike. Signed arithmetic does help a loop
induction variable, where undefined overflow lets an index widen into 64-bit
addressing, but that is a different variable from the accumulator. At the point
of use it costs instead: reducing a hash modulo a slot count is one mask for an
unsigned value and a sign correction for a signed one.

So the state types are constrained rather than the arithmetic widened, which
keeps the hot path exactly as it compiles today. The narrow widths deserve the
same scrutiny for a different reason - anything below `int` promotes on every
operation and smuggles the same signed overflow back in - but no such
instantiation exists.
Three ways the build silently did the wrong thing with CUDA, all of them
independent of which compilers a machine happens to carry.

`check_language(CUDA)` caches the compiler it finds together with the host
compiler it happened to pair with it, and that pairing then outranks a
caller's `CMAKE_CUDA_HOST_COMPILER`, so setting one was ignored without a
word. The probe now runs only to pick the default for the option; asking
for CUDA outright leaves the detection to `enable_language`.

`-march=native` for CUDA sources was decided by `check_cxx_compiler_flag`,
which puts the question to the C++ compiler - the wrong toolchain. NVCC
delegates host compilation but parses the host's headers itself on the
device pass, so the pair decides, and the C++ compiler accepts flags NVCC
then chokes on. A probe over `probes/cuda_native_arch.cu` asks NVCC
instead, carrying the header that actually settles it.

`-Wno-invalid-constexpr` never reached the CUDA host, where a Clang host
makes that diagnostic an error and every source including the `constexpr`
C layer fails.

The presets carry intent and no versions: which host compiler a toolkit
accepts is a fact about the machine, so it belongs in the per-developer
`CMakeUserPresets.json`, now ignored.
The GPU backend gained the three operations it was missing. A leftmost cover now
resolves on-device: every chunk emits its matches, a resolve pass marks which
survive, and a compaction keeps them - so the walk is never repeated to decide
ownership. On top of that, `try_replace` rewrites whole haystacks on the device,
splicing each match's replacement against offsets a scan produced, and
`try_score_bm25` reduces each haystack to one float, merging per-block frequency
rows in a shape no grid size perturbs. A leftmost `try_find`, a rewrite, and a
scoring pass no longer fall back to the host.

On the CPU, a haystack too large for one core's cache is now rewritten by every
core at once. Each core owns the matches starting in its slice and writes into a
disjoint output range, so neither a mutex nor an atomic sits on the write path,
and the per-core shares settled while sizing survive into the writing pass - the
cover is resolved once per haystack rather than twice.

Construction splits in two. `szs_substrings_init` takes only the capability mask,
like every sibling engine, and `szs_substrings_index` compiles a needle set
against a named device. The hot/cold tier is sized to fill the cache that walks
it, so the tuning input and the tuning decision now sit in one call; indexing
again replaces the needle set, which is what makes re-tiering for another device
something a caller asks for rather than something that happens silently.

The state-id width stops being an engine template parameter: each engine holds
whichever of the two automata its needle set fits, instead of leaking u16/u32 out
to the C shim as two engine types and a four-arm backend variant.

The CUDA engine also stored its automaton twice. Its allocator is unified memory,
reachable from every device, so the seven `device_*` mirrors and the upload that
filled them were copies from unified memory into unified memory; there is no
`cudaMemcpy` anywhere in the file. The kernels now walk the dictionary's own
arrays, and the kernel table carries one shape per state-id width in a single
per-device cache.

`szs_substrings_stats` is dropped along with the struct it filled.
The two chunk-walk launchers built byte-identical ten-argument blocks, differing
only in which kernel handle they passed and whether the output span was empty -
which is exactly what `substrings_pass_t` already names. They collapse into one
`launch_walk_at_`, so the argument order the kernels depend on is written once
rather than three times, and the docstring claiming as much stops being wrong.
The three per-width shape accessors were the same ternary three times over;
`kernels_t::by_width_t` now answers for itself.

Three of the parallel engine's scratch buffers handed out rows through a named
accessor and three did the offset arithmetic inline, at six call sites. Every row
is now reached the same way, which makes the BM25 merge legible for the first
time: it indexes the touched row by slot and the frequency row by needle index,
where the old flat arithmetic hid both behind one shared offset. Release codegen
is unchanged - the accessors fold to the same pointer arithmetic.

`try_reserve_rewrite_shares_` sized two unrelated buffers under two separate
guards and spent eight docstring lines justifying the pairing; the coverage row
gets its own reserve and the paragraph goes.

The benchmark spelled out a four-operation by three-backend grid cell by cell,
about twelve repetitions of allocate-wrap-name-measure. The backends differ only
in which engine runs and in the `(executor, specs)` pair every entry point
already takes, so one shape per operation covers all three. Skip conditions stay
where they were: the occurrence cap, GPU availability and the rewritable policy
are control flow, not repetition.
`try_index` was the only substrings entry point whose executor template parameter
carried no `executor_like` constraint, so passing specs in the executor slot
compiled. Three benchmark call sites did exactly that, and the automaton was
tiered against a default one-core machine rather than the pool's probed cache -
the benchmark reported hot and cold tier sizes it had not measured. Constraining
the parameter like its four siblings turns that into a compile error.

The CUDA twins of those calls passed a `gpu_specs_t` into a `cuda_executor_t
const &`, which cannot bind at all. That they were committed is direct evidence
the CUDA substrings paths were never built after the signature changed; the GPU
rewriting and scoring tests had never compiled. They do now, and pass.

The benchmark's timed callables capture their executor by reference and outlive
the expression that builds them, so the executors they were handed as temporaries
died before the first measured iteration. Both are now owned for the whole sweep
cell and passed as lvalues.

Two test sites dropped a probed `gpu_specs_t` that was live in the same function
while every neighbouring call threaded it.
Every batch operation in the C layer takes three input shapes - `sz_sequence_u32tape_t`,
`sz_sequence_u64tape_t`, and the callback-addressed `sz_sequence_t` - but `AnyBytesTape` and
`AnyCharsTape` carried only the tapes, so the sequence entry points were reachable from the
owned-return `compute` and from nowhere else. A caller holding `Vec<String>` had to materialize a
tape before `compute_into` would look at it.

Both enums gain the missing arm, built by `from_slices`, and every `compute_into` dispatches it to
the sequence extern it already declared. `SzSequence` becomes a public opaque type carrying the
lifetime of what it borrows, which is what keeps the raw handle honest, and sheds the two fields it
had beyond the four `sz_sequence_t` actually defines.

Mixing a tape with borrowed slices across the two sides of a cross-product stays refused: satisfying
it would mean copying one of them behind the caller's back.
`szs::Substrings` compiles a needle set into one Aho-Corasick automaton and reuses it across every
later call: `count_into`, `find_into`, `score_bm25_into`, `replace_bound` and `replace_into`, each
writing into caller-owned buffers so a pipeline allocates once. Haystacks arrive as `AnyBytesTape`,
so all three input shapes the C layer accepts reach the engine; only the rewrite narrows to the
tapes, since its product is itself a tape and a callback-addressed sequence has nowhere to put one.

`Bm25Params` has no `Default`. A corpus mean is a property of the corpus rather than a tunable, and
pairing the literature's `b = 0.75` with a zero mean asks the engine to divide by a mean that is not
there. The two configurations that exist get names instead - `normalized(mean)` and `unnormalized()`
- so the impossible one is unrepresentable.

`aho-corasick` arrives as a dev-dependency, not an optional feature, so it never reaches a consumer's
build graph. Its three `MatchKind`s are our three overlap policies under different names, which makes
it an independent witness for every policy and for the rewrite; the tests differential all four
against it over seeded corpora.
`szs.Substrings` exposes the engine through a `tp_methods` table rather than the single `tp_call` its
siblings use, since `count`, `find`, `score_bm25`, `replace` and `replace_bound` cannot share one
keyword set. `find` returns four `uint64` arrays instead of one structured array, so a caller wanting
a single column takes it without learning a compound dtype, and it negotiates its own capacity
through the zero-capacity size query the C layer documents.

`sz_py_export_strings_as_sequence` becomes total over all five `Strs` layouts. Needles have no tape
overload, so this is the first caller that reaches it without probing the tapes first, and its
`STRS_FRAGMENTED` assertion was a precondition only the probe order upheld.

`average_document_length` is required rather than defaulted: it is a property of the corpus, and a
zero mean beside the customary `b = 0.75` asks the engine to normalize by a mean that is not there.

The rewrite answers an empty batch before it sorts the input into a layout. An empty `Strs` is
fragmented when built from a list but becomes a tape whose offsets are still NULL once the unified
allocator swap runs, so reading the trailing offset dereferenced NULL on a CUDA engine and refused
the same input as "not a tape" everywhere else.

`stringzillas` links the core sources for the first time here: the rewrite path calls the dispatched
`sz_copy`, the way every CMake target links `stringzilla_static` rather than leaving it undefined.
…uites

BM25's `average_document_length` was a sentinel: a zero mean collapsed the length term to one, so it
silently discarded both `length_normalization` and every `document_lengths` entry the caller had
computed - and said what `length_normalization = 0` already says. The C entry point now refuses the
pair, once, for all three languages, and the header states what the kernel's own floor is for.

The C++ binaries register the four substrings tests that were declared but never run, and the
`cuda_clang` preset pins all three compilers rather than the CUDA host alone. Clang mangles a C++20
requires-clause into the symbol name and GCC does not, so compiling the C++ sources with one and the
CUDA sources with the other gives the constrained engine entry points two different symbols and no
link.

Two guards join the pre-commit hook: one for `clang-format`'s `BreakStringLiterals` mangle, whose
signature is two literal fragments left on one line, and one for indexing straight through a call,
where the temporary the index reads from dies at the semicolon.
All three `substrings_pass_t` enumerators read as "count something", and the
one that actually counts occurrences per needle was the one not called
counting. Rotate them so the name states the pass: `sizing_k` sizes the
output, `writing_k` writes matches at known offsets, `counting_k` counts.

`shared_rows_` becomes `staged_rows_` to pair with the `staged_accepts_words_`
it is budgeted and launched beside - one concept that carried two prefixes.
…8 battery

The C++ `stringzillas` suites never ran in CI. `test_ubuntu_cpus` already built
`stringzillas_test_cpp20` on every push and then discarded it, so 14 substrings,
5 similarities and 3 fingerprints tests were only ever exercised by hand. It now
runs there, which costs runtime alone since the binary was already compiled.

Five families hand-rolled the same malformed-input sweep that
`for_each_adversarial_utf8_input_` already drives for the segmentation tests.
They now share it. `uncased` gains the most: it walked all 256 singles and all
65,536 pairs regardless of `SZ_TESTS_MULTIPLIER`, never replayed through
`for_each_cacheline_offset_`, and skipped the four named malformed shapes - so
sharing the battery makes it both cheaper and stricter, and it finally honours
the dial.

`test_similarities_memory_usage` documented its table as cheapest-first so a
reduced multiplier keeps the cheap prefix, but sized itself from the table
length, so the default pass ran every row including the 8192-byte ones, and a
raised multiplier resized past the table and appended default-constructed
experiments rather than harder ones. Both ends are fixed: 10.4s to 0.3s at the
default, still widening to the full table at 2x.

Device tests are gated inside their bodies rather than around their definitions,
so `stringzillas.cpp` and `stringzillas.cu` register identical lists and cannot
drift apart again. The repeated unified-memory staging becomes `unified_texts_t`,
and `stringzilla.cpp` calls `log_environment()` instead of its own copy, which
had drifted to printing Goldmont twice.
Scoring counted into one `u32` per needle and then dense-scanned the whole row
per document. At the 346,205-needle XLSum vocabulary that is 2.64 MiB of traffic
against a 4,898-byte mean document - 557x amplification - and 348 MiB of rows
live across 264 blocks against a 50 MiB L2. The right size was never the
vocabulary: a document touches a few hundred needles.

A block now counts into its own shared table. Under one slot per needle the
needle index is the slot, so there is no hash and no probe and the cost matches
the dense row it replaces; above that the table is hashed with a per-block
overflow row catching what will not seat, and only a document that overflows
pays a pass over the vocabulary. Four of the five benchmark slices allocate no
overflow row at all.

Contributions accumulate as fixed-point integers rather than through a
`cub::BlockReduce`. Integer addition is associative, so the block total no
longer depends on the order its lanes finish in - a stronger promise than the
fixed reduction shape the header used to publish, and one that survives a
different grid size. Measured 1 distinct result over 20 chunk-boundary
placements at 2.8e-15 relative error, against 3 results at 9.7e-08 for `f32`.

Skipping the slots a document never touched also retires a NaN: the dense scan
evaluated `substrings_bm25_term` for every needle, so an empty haystack under
`length_normalization = 1` computed `0/0` and poisoned the score.

On the CPU the ordering that the header publishes is reached by a radix sort
over the touched keys instead of an insertion sort quadratic in them, and past
half the vocabulary by an ascending walk of the row itself. Scores stay
bit-identical, which the split-haystack equality test pins.

Entire-vocabulary CUDA scoring goes from 944.9 to 3972.8 MB/s cased and 703.6
to 1567.7 uncased. The counting columns are the control and reproduce the
previous table within 1.6x, which is what makes the score columns comparable.
`SZ_HELPER_AUTO` gains a `constexpr` qualifier from C++20 onward, but SVE and RVV
vectors are sizeless and therefore non-literal: a `constexpr` function may neither
name one in its signature nor hold one in a local until C++23. Every SVE and RVV
translation unit consequently failed to compile at exactly the language level the
test suite and CI default to, while C++17 and C++23 both built clean.

The NEON headers tripped over the same qualifier for a second reason - a `static`
local inside a `constexpr` function is itself a C++23 extension, which `-Werror`
promotes to a hard failure.

Both are a misuse of the macro rather than a property of the targets. Its own
comment opens "A scalar helper", and on a vector helper the qualifier is inert in
every configuration: below C++23 it fails to compile, and at C++23 it can never
fold, since no constant expression of a sizeless type exists. So the fix removes
the qualifier where it never did anything, reusing the `SZ_HELPER_INLINE` that was
already there, rather than teaching the macro to detect the target.

Scoping was measured before landing. Applying `always_inline` to all 627 helper
sites cost `test/uncased.cpp` 7.4s -> 243.8s of compile time and 367KB -> 3.96MB
of object code; confined to the 101 vector sites it sits inside run-to-run noise
and the object is marginally smaller.

Validated at C++17, C++20 and C++23 across armv8-a, armv8-a+aes+sha2,
armv9-a+sve2+sve2-aes, rv64gcv, rv64gcv_zvkned_zvknhb and x86-64.
Several benchmark rows measured or named something other than what they advertised.
`sz_hash_westmere` sat behind `SZ_USE_HASWELL`, so a Westmere-only build lost the
hashed-container row entirely; the guard now names both families, since the fastest
x86 pairing genuinely mixes a Westmere hash with a Haswell comparator and either can
be compiled out alone. Two rows reported `neon` while running the crypto-extension
kernels `sz_hash_state_*_neonaes` and `sz_sha256_state_*_neonsha`.

The ISA ladders had also drifted behind the headers. `sz_equal` and `sz_order` were
missing Westmere, `sz_order` also SVE, the streaming hash and `sz_fill_random` were
missing SVE2-AES, multi-seed hashing was missing both V128 tiers, and the whole
cipher sweep omitted POWERVSX, RVV-crypto and both V128 tiers - so on those targets
the benchmark reported nothing while the kernel existed.

`STRINGWARS_TOKENS` rejected the very spellings its own error message told the user
to type: the parser accepts `lines` and `words`, the message named `line` and `word`,
and two file headers documented the invalid singular as their default. Four more
headers documented a default that contradicted the code.

Also drops the two UTF-8 wrapper structs stranded in `token.cpp` when those
benchmarks moved to `utf8_traverse.cpp`, and `cpu_cycles_per_second`, which had no
caller anywhere in the tree.
The suite had no written contract, so a driver's suffix meant whatever its author
intended. `test/stringzilla.hpp` now states what each tier costs and may assume, and
the drivers were moved to match: `_unit` is fixed-cost known-answer vectors, pinned
at every `SZ_TESTS_MULTIPLIER`; `_equivalence` owns randomness and scales; `_safety`
drives malformed and boundary inputs; `_all` walks the backend table and holds no
assertions of its own.

Ten drivers contradicted their own suffix - `test_memory_stability_unit` encoded the
violation in its default argument, reading `scale_iterations` from the header. They
are renamed or split so the claim holds, and it is now observable rather than
asserted: every remaining `_unit` driver reports the same wall time at multiplier 1
and 4, while the renamed `_equivalence` drivers scale.

Coverage gaps closed behind that. Sweeps that were written per-ISA by hand had
drifted: intersect was missing SVE, uncased ordering SVE2, cased-search RVV, LASX and
POWERVSX, and the whole RVV-crypto hash and SHA256 tier was absent. `find`, `hash` and
`sort` had no `_safety` driver at all; both new ones initially passed with a planted
defect, because they only called dispatched entry points, which resolve to Haswell on
x86 and never reach the serial kernel - they now sweep every compiled backend by name.
`sz_lookup_init_lower`, `sz_lookup_init_ascii` and the four `sz_find_byte_from`
variants had no C++ caller anywhere and are now driven.

Comments were brought back in line with the code they describe: driver names that
outlived their comments, backends cited by a number the project never assigns, seeds
that stopped being replayable once `seed_generator_for_test` mixed in the test name,
and 65 trailing comments restating the kernel already spelled on the line.

CI ran neither C++11 nor C++14 despite building both, while `find.cpp` gates a block
on C++17; the language floor now runs.
The README told readers StringZilla has no Aho-Corasick automaton and pointed them
at hyperscan and pyahocorasick instead. It has one - goto-completed, two-tier, running
across CPU cores or a CUDA GPU, reachable as `szs.Substrings` and `szs::Substrings` -
and its own README describes it in detail. The functionality table omitted the row too.

Paths and names had drifted behind two renames. There is no `scripts/` directory, and
the test and benchmark files lost their prefixes, so `bench/utf8_iterate.cpp` - a file
that never existed under any name - was cited as the source of six benchmark tables,
each of which actually belongs to one of `utf8_traverse`, `utf8_scan` or `utf8_segment`.
Six more tables named a corpus, `leipzig1M_en.txt`, that is nowhere in the tree.

`CONTRIBUTING.md` documented `STRINGWARS_MAX_TOKENS`, which nothing reads, listed 7 of
the 13 variables the harness does read, passed a dataset positionally to a harness that
declares `argv` unused, and named a `_serial` test target that CMake never defines.

The bindings misdescribed their own behaviour. `translate` documented a bytes-to-bytes
mapping where the code requires single-character strings, and promised a whole new
string where a `start`/`end` pair returns only that slice. Go's `Utf8Count` claimed to
match `utf8.RuneCount`, while it counts non-continuation bytes and so returns 0 where
`RuneCount` returns 2. Swift documented a `szScope` that does not exist and a `reset()`
that restores the original seed, though the seed is never retained.

Every claim here was checked against the code, and where the code could answer, by
running it: the two `translate` cases and the `Utf8Count` divergence are measured, not
read. All 294 doctests still pass and the pytest suite still collects.
@ashvardanian ashvardanian changed the title Multi-Pattern Search Fastest Aho-Corasick on GPUs Aug 17, 2026
Pulls the released `sz_string_reserve` shrink no-op, the `arrow_strings_tape`
offset-overflow guards, the forward-iterator `static_assert`, and the CPython
helper linkage fix into the multi-pattern branch.

The only conflict was the test roster: `main` added `test_string_reserve_unit`
where this branch had renamed the two neighbouring drivers to `_equivalence`,
so the new unit keeps its name beside the renamed pair.
`SZ_HELPER_AUTO` carries `constexpr` from C++20 onwards, which is what lets a CUDA
kernel reach a scalar helper without the C layer naming an execution space. A helper
that touches an intrinsic can never be constant-evaluated, though, and a `constexpr`
function with no constant-evaluated path is ill-formed: Clang and MSVC reject the
definition, GCC 12 too, and only GCC 13+ softens it to `-Winvalid-constexpr`. Carrying
the qualifier across every backend cost nine CI jobs at once - both GCC 12 runners,
both Windows runners under C3615, StringZillas-CPUs and StringZillas-CUDA whose host
compiler is `gcc-12`, and both Clang runners through the Rust `cc` build, which has no
warning flags of its own.

The 259 intrinsic-reaching helpers move to `SZ_HELPER_INLINE`, so no ISA backend header
holds a `SZ_HELPER_AUTO` at all, and `-Wno-invalid-constexpr` leaves the warning set
rather than spreading to `build.rs`: nothing that includes these headers needs a flag to
compile them. The function-local tables in seven backends become legal where they stand,
since only the qualifier made them a C++23 extension. The compiler enumerates the whole
set - `-Winvalid-constexpr` under GCC 13+, once per ISA - and its silence is the proof
the sweep is complete.

The four `substrings` helpers a CUDA walk calls keep the qualifier and now earn it: they
take `span<char const>` rather than punning a `span<byte_t const>` into the C iterators,
so nothing in the bodies blocks constant evaluation.

`szs_substrings_replace_*` spells its offsets `sz_size_t`, the width the engines address
the output tape with. Apple Clang had refused the mismatch outright, `sz_u64_t` being a
distinct type from `size_t` there rather than merely a same-width one.
`stringzilla_shared` no longer defines `SZ_OVERRIDE_LIBC=1`, so it exports no
`memcpy`, `memmove`, `memset`, `memchr`, or `memfrob` of its own. The shim units
default that macro to `SZ_AVOID_LIBC`, which this target already set to zero, so
deleting the line is the whole change.

This changes the behaviour of an installed artifact: `LD_PRELOAD`-ing
`libstringzilla.so` no longer accelerates a program's LibC calls. That capability
moves to `stringzilla_bare`, which keeps the override and whose whole purpose is
replacing LibC. What the default buys is that anything may now link the library -
symbol interposition no longer redirects `libstdc++`'s internal `memset` into a
dispatch table that is not up yet, which used to fault the process before `main`.
`SZ_DYNAMIC_DISPATCH=1` externs the `sz_*` entry points the engines call - `sz_copy`
from the rewriting paths among them - and nothing resolved them for the dynamic
variant. ELF defers that to load time, so Linux never complained; Mach-O demands
every symbol at link time, and `libstringzillas_cpus_shared.dylib` failed to link
once the macOS job got far enough to try.

`stringzillas_cpus_shared` now links `stringzilla_shared`, which no longer interposes
LibC and is therefore safe to depend on. The archive gets no such line: it resolves
nothing itself, and whoever links it brings its own core - tests and benchmarks keep
linking `stringzilla_static`, whose `-static` executables cannot take a `.so` at all
and whose capability set is fixed at build time rather than by a shared library's own
runtime detection.

A dynamic StringZillas without a dynamic core is now refused at configure time, where
it used to surface as `cannot find -lstringzilla_shared` deep into the build.
@ashvardanian
ashvardanian force-pushed the main-multipattern branch 2 times, most recently from 46bb882 to 0abd25c Compare August 19, 2026 14:55
Two causes behind the remaining red jobs, neither of them new to this branch - both
were only reachable once the earlier `constexpr` fix let those jobs get further.

`SZ_HELPER_AUTO` drops `constexpr` on MSVC. Its bit-scan and byte-swap intrinsics are
not constant-evaluable, so `sz_u64_ctz` and everything that reaches one transitively -
`sz_size_bit_ceil`, `sz_u64_bits_reverse`, the folded rune iterators, the uncased
search - is rejected with C3615, an error no `/wd` silences. Demoting the callers is
not the answer: the folded iterators are exactly what a CUDA walk reaches through
`--expt-relaxed-constexpr`, and stripping the qualifier there would cost the device
its access. MSVC hosts neither `nvcc` nor a fold through those intrinsics, so it loses
nothing by seeing the plain inline helper.

The substrings fixtures now build haystacks that carry their needles. Ten letters
spell the whole corpus, so at `SZ_TESTS_MULTIPLIER=0.05` - what the QEMU jobs run -
ten needles drawn against three haystacks matched nothing, and the construction
fixture said so through its own guard. The cover and rewriting fixtures share the
shape without the guard: every check there sits inside a loop over the match set, so
an empty one passes having compared nothing. `random_haystacks_with_needles_` plants
one needle per haystack at rotating offsets, which keeps the matches interior, where
covers and neighbours mean something.
`stringzillas_*_shared` reaches `cuGetErrorName` and `cuInit`, which resolve out of `cuda` rather than
`cudart`. An ELF shared object carries them undefined until load time, so only a Windows DLL reports it.
This is the repair 15b336c already made on the per-target call, lost when that call folded into
`define_stringzillas_shared` and only `cudart` came along.

`RelWithDebInfo` matched both nvcc flag lists and handed `ptxas` a `-G` beside `-O2`, which it refuses with
"Optimized debugging not supported". `-G` stays with the unoptimized `Debug` build and `RelWithDebInfo`
takes `-lineinfo`, the same source correlation carried through optimized device code.

`MANIFEST.in` drops the two entries whose files left with the old benchmarks and that every sdist warned
about. The parallel Python targets declare the NumPy their modules already call `import_array()` against,
which the build requirements named and the install requirements did not.
Mark the scalar UTF-8 helpers `constexpr` through `SZ_HELPER_AUTO`, the qualifier
`--expt-relaxed-constexpr` needs to resolve a host helper from a kernel at all.
Two constructs blocked constant evaluation and both are gone.

The `sz_u8_t const *` reinterpretation of the input pointer cannot appear in a
constant expression, so `sz_utf8_byte_at_` takes the sign off one byte at a time
instead. The 16-entry lead-length table becomes three comparisons, matching the
form `sz_rune_decode_unchecked` already uses, so the two can never disagree on how
far a lead byte advances and the uncased search path carries no lookup at all.

A `__global__` in the substrings suite calls `sz_rune_decode` and `sz_rune_encode`
directly, so a helper that stops being reachable stops the file from compiling.
Without it `nvcc` resolves the call from inside the folded cursor without a
diagnostic, every multi-byte codepoint decodes as a run of malformed single bytes,
and an uncased device search matches nothing outside ASCII while every host backend
still agrees with the oracle. The device backend is compiled on every CUDA runner
and executed on none of them, so a build error is the only form this can be caught
in without a GPU.
`pack_hot_children_` advanced `lowest_free_cursor_` after every placement, so its
walk was amortized linear as the docblock claimed. `pack_cold_children_` read the
same cursor but never moved it, so every cold parent re-scanned the whole occupied
prefix from the same low point - linear per state, quadratic per build. Above the
107,520-state hot cap every state is cold, so the largest dictionaries paid all of it.

The cold path now publishes the first vacancy its scan found. Every slot below that
one is occupied by construction, and the row's own lowest claim is `candidate`, so
the cursor steps past it only when the row landed there - no arena is wasted and no
transition changes.

On 245 MB of XLSum, serial construction over the full 346,205-needle vocabulary drops
from 9.23 s to 1.82 s cased and 9.35 s to 1.00 s uncased, and cost per cold state stops
climbing across the sweep. The benchmark also stops building the serial automaton twice
per cell: the measured row leaves one behind, and only a filtered-out row still owes a build.
The three CUDA domains gave three different answers to where an output must live.
Fingerprints refused host memory in one engine and validated nothing in the other -
the one the C API picks on its common fast path. Similarities probed the result matrix
and staged it when it was host memory. Substrings staged some outputs and required
others to be host-writable, because a host loop filled them after the launch.

Now one rule holds everywhere: on a GPU scope every buffer an operation reads or writes
must be device-accessible, unified or plain device memory, and page-locked host memory
is host memory here since the driver reports it as such. `check_device_accessible_memory`
and `check_device_accessible_sequence` state that once and every engine calls them.
`is_device_accessible_memory(nullptr)` now answers false, which closes the hole that let
a null output pass every check.

Every staging buffer and drain copy is gone rather than made conditional: the match and
output-byte staging, the three BM25 staging vectors, the rewrite offsets, and the
similarities host-scatter fallback with its `results_staging_`. Deleting that fallback
turns on the direct single-word Myers kernel, which was gated on the results being
device-accessible and which no Python caller had ever reached.

The rewrite also stops zero-filling the caller's offsets before a capacity refusal, so a
refused CUDA call names the size it wanted - which the CPU engines and the C header
already promised. Heavy operation bodies move behind a `*_described_` seam so they stop
being instantiated once per container shape, and `is_tape_like` gives `corpus_is_ascii_`
one `find_byteset` over a whole tape instead of one launch per element.
A GPU scope now refuses host buffers, which left Python users no way to satisfy it for
the arrays they supply themselves: `to_device` only accepts a `Strs`, so `score_bm25` was
unreachable on a GPU scope without CuPy or Torch. `szs.unified_array(shape, dtype)` closes
that - it wraps the unified allocator the engines already use, and the returned NumPy array
frees the allocation through its capsule base. Element width comes from `PyDataType_ELSIZE`,
since NumPy 2 made `PyArray_Descr::elsize` private.

Result matrices and rewrite offsets are allocated device-side on a GPU scope, and an `out=`
argument goes through `parse_device_buffer`, which reads `__cuda_array_interface__` and
`__dlpack__` and refuses anything else. `sz_device_memory_mismatch_k` raises `BufferError`
rather than falling through to a bare `RuntimeError`.

`try_swap_to_unified_allocator` now separates the two ways it could fail: a non-`Strs` is a
type error and says so, where before every wrong-typed argument on a CUDA build was reported
as a device-memory mismatch. `document_lengths` is validated for shape and length against the
haystack count, which was a live out-of-bounds read.

The dependency groups become one family, `tests-oracles` and `tests-cuda`, both installed by
`pip install --group` and both kept out of cibuildwheel's `test-requires`, which runs under
QEMU. CuPy is not an oracle but a producer: a source of device buffers that is not our own
`unified_array`.
`test_substrings_cuda_memory_safety` covered inputs only, and only `try_count`; nothing
tested the fingerprints or similarities guards at all. Each domain now has a memory-safety
driver covering unified inputs accepted, host inputs refused, host outputs refused per verb,
pure device outputs accepted, and pinned outputs refused. The fingerprints one runs twice,
once per CUDA engine, choosing an aligned and a misaligned `dimensions` so the C ABI routes
to each. Both new drivers were checked against a deliberately disabled guard first, so they
are not vacuous.

`substrings_rewrite_tape_t` held `std::string` and `std::vector` while driving the CUDA
engine, and the benchmark's rewrite offsets and BM25 weights were host buffers fed to CUDA
engines; all are unified now. `device_vector` is `safe_vector` with `device_alloc` rather
than a new container, since `std::vector` moves elements through host stores.

On the Python side, `test_alignment_out_buffer_matches_returned_matrix` branched on
`szs.__capabilities__` - a build fact - to choose a per-scope assertion, and rebound
`out_buffer` so its trailing oracle assertion no longer mentioned `out=` at all. It now
parametrizes over `DEVICE_NAMES` like its siblings. `device_float32_array` in `szs_helpers`
allocates BM25 weights and lengths for whichever scope a test runs on.
`@section <id> <title>` binds the first token as the anchor and the rest as the visible
title, so `@section Device Memory` silently anchored "Device" and titled the section
"Memory" - the heading read wrong and nothing could link to it. Thirteen sections were
written that way. Each now carries a lowercase snake_case id prefixed by its domain, since
ids share one namespace across the project and two headers both document "Buffer Sizing".

The pre-commit hook grows a check for it. A Title-Case first token is the exact signature
of a missing id, and an id with no title renders an empty heading, so both are refused.
The existing decorative-separator rule only read comments, so a banner printed at runtime -
`printf("\n=== Fingerprints ===\n")` - passed straight through it; it now reads string
literals too, requiring the run of dashes or equals to sit against whitespace so a rule used
as a divider is caught while `"---"` as test data is not. The four such banners in the tree
became plain sentences.

The tree is also brought to clang-format-23 across every tracked C/C++ file outside the
submodule, which was 18 files of drift. One docstring in `python/stringzilla/memory.c` had a
two-fragment literal with no trailing `//` to pin it, which the formatter shreds; it is
re-broken so every line carries its own tail.
`pack_cold_children_` scanned interior vacancies until one fit the parent's whole child mask, and the
vacancies a packed arena leaves behind are mostly singletons no multi-byte row can ever cover. The scan
therefore re-walked the same unfillable holes for every later row, and candidates per row grew linearly
with the state count - 10.8 at 0.85M states, 545.2 at 6.2M - making the phase quadratic overall. It was
70-94% of build time and the only reason a multi-million-state dictionary took tens of seconds.

The search now gives up after `max_interior_probes_k` rejections and settles the row on the arena
frontier, tracked in `claim_slot_` as one past the highest claimed slot. Every slot from there up is
unclaimed by construction, so the fallback always fits, and anchoring the row's smallest child byte on
the frontier rather than an anchor byte past it leaves nothing stranded behind it.

Packing drops from 23.9s to 1.52s on 400K DNA k-mers at 6.2M states, and from 0.58s to 0.19s on 318K
English words. The published slot count, the transition-table footprint, and the emitted match lists are
all unchanged - byte-identical on both corpora against the previous automaton.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant