Skip to content

v5: More UTF-8! More Bio! More Hardware!#318

Open
ashvardanian wants to merge 352 commits into
mainfrom
main-v5
Open

v5: More UTF-8! More Bio! More Hardware!#318
ashvardanian wants to merge 352 commits into
mainfrom
main-v5

Conversation

@ashvardanian

@ashvardanian ashvardanian commented Jun 8, 2026

Copy link
Copy Markdown
Owner
Domain StringZilla v4 StringZilla v5
Unicode SIMD-vectorized case folding, uncased search + NFC/NFD/NFKC/NFKD normalization, + grapheme, + sentence, + word, and + line-break iterators
Bioinformatics ~700 GCUPS Levenshtein & ~10 GCUPS NW/SW on an H100 up to ~6 TCUPS Levenshtein and ~700 GCUPS NW/SW on the same H100
Portability x86 & Arm + WebAssembly SIMD, + RISC-V RVV, + LoongArch LASX, + IBM POWER VSX
Hashing on par with xxHash & aHash + up to 3x faster multi-seed digests of one input — for Cuckoo tables, Count-Min & Bloom sketches
Fingerprints streaming ~390 MB/s over 700 MB/s on an H100 at the same embedding dimension — for large-scale MinHash content dedup

v4 vectorized sorting, hashing, and edit distances.
v5 goes after the part everyone agreed was too irregular for SIMD — Unicode — plus the hardware and packaging that make it portable.

…ll ISAs

Replace the single-match `sz_utf8_find_newline`/`_whitespace` API with a unified
multistep boundary family that emits `(offset, length)` batches per call and
resumes via `bytes_consumed`, and rebuild every backend's peel on a single-pass,
native left-pack design with no per-match `ctz` and no nested drain loop.

Core API
- One canonical `sz_utf8_find_boundaries_t` typedef replaces the four single-match
  function-pointer types; the single-match kernels (including serial) are deleted.
- `sz_iterators_default_steps_k = 16` is the shared default batch size.

Newline / whitespace peels (classify -> compact -> emit, per window)
- Ice Lake: `vpcompressb` packs the matching lane indices and per-lane lengths;
  one masked `vpmovzxbq` widen-store emits up to 8 `(offset, length)` pairs in a
  single straight-line wave. A window with more than 8 matches resumes past the
  8th and re-classifies, so there is no register-shift loop.
- Haswell / NEON / LASX / PowerVSX / WASM: the native left-pack
  (`vpermd` / `vqtbl2q` / `xvperm` / `vec_perm` / `i8x16_swizzle`) plus per-file
  compact LUTs replace the scalar `ctz` index-find and the `group==4`/scalar-tail
  store branch; bounded by a masked store where available, else a small fixed
  stack scratch.
- SVE2 / RVV keep their native `svcompact` / `vcompress`. Tails are masked /
  predicated in-SIMD on Ice Lake / SVE2 / RVV, serial-scalar on the fixed-width ISAs.

UAX-29 word-boundary kernels (forward + reverse, every ISA)
- Replace the nested `while (boundary) { ctz/clz }` drain - and Ice Lake's
  `boundary_lanes[64]` stack round-trip - with one flat window loop: an
  early-`continue` scalar fallback, then a single fixed sub-block loop that folds
  compaction and the shifted-difference (`lengths = boundary - shift1(boundary,
  carry)`, carrying the open word's start/end). Reverse walks sub-blocks
  high-to-low through a descending compaction LUT. Ice Lake emits up to 8 and
  re-classifies; the wider-mask ISAs emit-all per window. A re-classified window
  clears the open word's own boundary (lane 2 forward, lane 62 reverse) so it is
  not re-emitted as a zero-length word.
- `sz_u64_bits_reverse` (explicit SWAR over `sz_u64_bytes_reverse`) drives the
  descending `vpcompressb`; x86 has no scalar bit-reverse instruction.

Bindings (C++ / Rust / Python / Swift)
- Split iterators carry no `done`/`exhausted` state bool (count==0 + sentinel),
  gain compile-time `steps`/`STEPS` and `skip_empty` knobs, and default to KEEPING
  empty segments (N+1) with opt-in skipping across every split range. Removes the
  deprecated Rust split aliases and the duplicate `WORD_BOUNDARIES_BATCH` constant;
  word splitters are const-generic over `STEPS`.

Validated byte-exact against the serial reference on every backend (native,
QEMU for sve2/rvv/lasx/ppc, wasmtime for wasm) over a full capacity tiling with
dense-boundary and EOF-truncation fixtures, plus the C++20 suite and `cargo test`.
Land the case-insensitive find correctness fix and its test coverage atop a
project-wide vocabulary cleanup.

Semantic changes:
- Fix a real offset bug in the serial 2/3-folded uncased find: a match whose
  folded runes cross an expansion boundary (needle ſß folding to "sss" inside
  haystack ßß folding to "ssss") reconstructed its start from per-codepoint byte
  lengths and returned a negative/underflowed offset. It now tracks the source-
  codepoint begin pointer per history slot, so it reports offset 0, length 4.
- Redesign `uncased_violation` (was `case_invariant`) to return a pointer to the
  first cased codepoint, or NULL when fully caseless, mirroring `norm_violation`.
  The invariant-needle find shortcut becomes a `== SZ_NULL_CHAR` check.
- Add an independent fold-subset reference oracle and a three-way checker, a
  generative cross-expansion enumerator, and a uniform per-backend battery, so the
  serial-vs-SIMD differential is also validated against ground truth (it was blind
  to bugs the SIMD kernels share through their serial fallback). Rust gains the
  same coverage (exercising the v128 kernel under Wasmtime) and Python a crossing
  test.

Mechanical: rename `case_insensitive`/`case_fold`/`case_invariant` to
`uncased`/`uncased_fold`/`uncased_violation` and purge the `ci` abbreviation across
the C core, all SIMD backends, the Rust/Python/Swift/JS bindings, build files, and
tests; move the `utf8_case_insensitive/` and `utf8_case_fold/` trees to
`utf8_uncased/` and `utf8_uncased_fold/`. External Unicode references
(`CaseFolding.txt`, "Unicode Case Folding", UAX #21) are preserved.
Split the monolithic entry file into one translation unit per domain
(hash, utf8, uncased, string, find, sort) that compile into a single
binary, so the standards matrix rebuilds incrementally instead of
recompiling one 5K-line file.

Unify each domain on a fixed aspect taxonomy: one known-answer
`test_<domain>_unit` (the former golden + API battery, merged), a
`test_<domain>_all` per-ISA driver that replaces the monolithic
`test_equivalence`, and differential/safety helpers reshaped to take
templated backend wrappers (`<op>_from_sz_<func_>`, mirroring the bench
files) instead of loose kernel-pointer lists. main() registers every
`_unit` and `_all` uniformly through `run_test`.

Native focus (serial/haswell/icelake); every `#if SZ_USE_*` branch for
other ISAs is preserved. Restore two Kelvin/Angstrom test literals that
an editing pass had NFKC-normalized (U+212A->K, U+212B->Å), now pinned
as byte-exact \x escapes.
…odec

Decoding now goes through a single validating primitive (sz_rune_parse, ex-_strict) that returns the
codepoint length or sz_utf8_invalid_k: well-formed runes fold/search/sort/normalize exactly as before,
while any byte that does not begin a well-formed codepoint is passed through unchanged as a one-byte
maximal subpart (resync at the next byte). This kills the data-mutating behaviour on malformed input -
serial silently dropped surrogates and collapsed overlongs - and makes serial byte-identical to every
SIMD backend. Also fixes a folded-iterator out-of-bounds read and a reverse-iterator byte-loss on
truncated input.

The rune codec (parse/parse_unchecked/export/valid) moves out of the foundational types.h into
utf8_runes.h, the bare name sz_rune_parse goes to the validating variant (unsafe one keeps _unchecked),
the dead sz_rune_rparse/sz_runes_parse are removed, and every decoder now returns the length with a
single rune out-param. Carries the supporting types.h changes: SZ_MAY_ALIAS on the SIMD vector unions
(fixes strict-aliasing UB), the sz_u*_nth_set_bit / bit-reverse helpers, and the SVE2-AES feature-macro
gate for Clang. Valid-UTF-8 output is byte-identical across all paths.
sz_hash_state_update only absorbed a 64-byte block once it filled, and sz_hash_state_digest finalized
without consuming the buffered remainder - so two inputs differing only past the last block boundary
collided, and streaming disagreed with the one-shot sz_hash. Defer the filling block and fold it in
finalize, so one-shot and streaming share one finalization and pick the minimal/full construction by
total length identically. The per-ISA buffering is rewritten with masked/predicated loads (AVX-512
mask, RVV vl, contiguous copy elsewhere) instead of byte-cursor loops and head/body/tail bookkeeping.
Canonical sz_hash values are unchanged across all backends.
find_nth becomes a flat early-continue using the shared sz_u*_nth_set_bit helper (no nested branch or
bit-clear loop); the multistep newline/whitespace peels and word-boundary kernels drop the per-ISA
head/body/tail bookkeeping; the RVV newline/whitespace twins and word-boundary fwd/rev paths are folded
into shared drivers; unpack_chunk is un-nested; popcount/ctz use native intrinsics where the mask is not
already materialized; and the essay-length docstrings are trimmed. Also flattens the AVX2 sort 3-way
partition. Output stays byte-exact vs serial across all ISAs.
Restructures the per-domain test binaries and harness, and migrates the rune-codec call sites
(sz_rune_parse signature, malformed-input guards) to match the consolidated codec.
`sz_copy`/`sz_move` operate on arbitrary byte buffers, so their head/tail scalar
copies and small-length SIMD stores routinely run on misaligned addresses. The
Haswell kernel reached them through `*(sz_u64_t *)ptr` casts and `&ptr->ymm`
union-member access, and the LASX kernel through the same `*(sz_u64_t *)ptr`
casts - all undefined behavior under `-fsanitize=alignment`, even though they
emit correct unaligned moves on x86/LoongArch.

Add symmetric `sz_u16/32/64_store` helpers mirroring `sz_u16/32/64_load` (both
honor `SZ_USE_MISALIGNED_LOADS`, so production builds still emit a single
unaligned access), route the scalar head/tail through the load/store helpers,
and store the SIMD lanes straight to the `__m128i*`/`__m256i*` byte pointer
instead of a vec-union member. A full ASan+UBSan sweep of the suite is now clean.
Read and write the packed public `sz_hash_state_t` through an aligned,
vector-typed twin loaded and stored with each backend's widest native
unaligned op, instead of casting the packed bytes to an aligned vector
type (which `vmovdqa`-faulted on an unaligned streaming state). One-shot
hashing builds the twin directly; streaming loads it at entry and stores
it back at exit. The AES round math is untouched, so the hash output is
byte-identical on every backend.
`split`/iterate/slice churn mints a fixed-size `Str` (or `Strs`) header per element and
frees it moments later. Cache dead headers on an intrusive singly-linked free-list - the
`next` link is threaded through the dead object's own storage (`Str::parent`, the `Strs::data`
union), so the state is just a head pointer and a counter per type, no array. The allocation
helpers pop a cached header back, collapsing the per-element `PyObject_Malloc`/`Free`
round-trip (~7x faster header churn in isolation).

The lists live in per-interpreter module state reached via `PyState_FindModule`, so the
types stay static and it is sub-interpreter / free-threading clean. Capacity is bounded at
64 headers per type; the existing module `m_free` hook drains both lists at teardown.
Pivot the four alignment engines (Levenshtein, Levenshtein-UTF8, Needleman-Wunsch,
Smith-Waterman) from a pairwise batch (results[i] = dist(first[i], second[i])) to a
cross-product: results[query][candidate] = dist(query, candidate) over all pairs,
row-major and query-major into a strided matrix. Passing no candidates requests
symmetric self-similarity (lower triangle + diagonal, mirrored on write). This is the
shape clustering, dedup, retrieval reranking, and all-vs-all bioinformatics need, and
it is what unlocks the per-lane work below.

Add an inter-sequence kernel family, candidate_lane_walker, that packs one shared
query against many candidates one-per-SIMD-lane and walks the DP row-by-row, so every
lane stays busy independent of string length (the intra-sequence anti-diagonal wastes
lanes at short lengths). Specialized for Ice Lake (AVX-512, 64xu8 / 32xu16 / 32xi16),
Haswell (AVX2, 16xu16 / 16xi16), and NEON (16xu8 / 8xu16 / 8xi16), with a scalar
serial reference oracle. Levenshtein reuses the query's Myers match-bitmasks across
its candidate run; weighted NW/SW reuse the query's 32-class substitution profile.
Each engine owns grow-only scratch sized once per call and threaded down as a span
view, so scoring leaves never allocate.

On the GPU, replace the per-call host grouping sort with cub::DeviceMergeSort over a
hoisted grow-only scratch member, and fix a latent out-of-bounds read in the warp
tile-scorers (the fixed-width SIMD window over-read one char past the string ends on
the final odd tail) with predicated masked tail loads rather than padding the inputs.

Bindings, C ABI, and tests move to the matrix shape: C signals symmetric with a NULL
candidates pointer; Rust returns UnifiedMat<T> with row()/dimensions(); Python returns
a 2-D Q-by-C ndarray with candidates=None for symmetric.
Add an optional `out=` writable buffer-protocol argument to `Strs.argsort`,
mirroring the `hash_multiseed` precedent. When given a contiguous `uint64`
buffer (e.g. `numpy.uintp` or `array('Q')`), the permutation is written into
it and the buffer is returned, avoiding the per-element `PyLong` tuple. A full
sort writes straight into `out`; top-k sorts into scratch and copies only the
leading `top` indices, never clobbering past `result_count`.

Inline the previously shared sort-option parser into `sorted` and `argsort`
since their argument sets now differ, and convert both docstrings to the
structured `Args:`/`Returns:` form used elsewhere.
Consolidate the inter-sequence cross-product onto one shared host-side
candidate-lane driver with dyadic length-bucket tiling, reused by every
byte NW/SW (linear+affine) and the new non-unit/affine byte Levenshtein
SIMD batch engines across haswell/icelake/neon; the GPU weighted engines
share the Levenshtein dyadic tier router. Replace the parallel
pointer-to-spans arguments of the inter-sequence Myers batch kernels with
a single lane_pairs_view batch type and give the multi-word kernels
snake_case names.

Redesign the similarity benchmark to feed independent, randomly-sampled
query and candidate batches in all-pairs and pairwise modes over the exact
aggregate cell-update denominator. Strip duplicated overload comments and
prose across the similarity engines.
Add 32-bit-cell inter-sequence candidate-lane kernels per CPU backend
(haswell 8-lane, icelake 16-lane, neon 4-lane): i32 weighted
Needleman-Wunsch/Smith-Waterman (linear+affine, global+local) and u32
uniform Levenshtein (linear+affine). These are strict width-mirrors of the
existing 16-bit kernels and will back the width-tiered driver so weighted
pairs whose accumulated score exceeds the i16 range stay vectorized instead
of dropping to the scalar diagonal walker. Kernels are added but not yet
wired into the engines.

Also convert the Ice Lake AVX-512 score load/stores to the width-typed
_mm*_loadu/storeu_epi* forms (no reinterpret_cast).
Restructure the shared host driver to dispatch each length bucket to a
narrow (i16/u16) or wide (i32/u32) candidate-lane kernel by its score reach,
via a reusable per-width tier loop, so weighted Needleman-Wunsch /
Smith-Waterman and non-unit/affine Levenshtein cells whose accumulated score
exceeds the 16-bit range stay vectorized instead of dropping to the scalar
diagonal walker. Wire every CPU candidate-lane engine to the two-kernel
driver with narrow+wide fits predicates. Add cross-product tests at lengths
and costs that overflow the narrow tier, pinning the new kernels cell-by-cell
against the serial oracle on haswell, icelake, and neon.
Extend the per-thread weighted Needleman-Wunsch / Smith-Waterman register
and batch kernels to compute their recurrence with Hopper's three-input
DPX add-max intrinsics under a compile-time capability guard, matching the
device-tier scorer. Results stay bit-identical to the scalar path (the
Smith-Waterman zero-clamp remains a post-fold max to preserve exactness);
the non-Hopper path is unchanged. u32/u64 Levenshtein and i64 NW/SW keep
inheriting the scalar base, as Hopper has no DPX for those widths.
Give the Haswell (4-lane) and NEON (2-lane) UTF-8 Levenshtein engines a
bit-parallel rune Myers fast path, mirroring the Ice Lake rune Myers and
its rune-keyed open-addressing Peq hash, so unit-cost UTF-8 edit distance
batches in SIMD on every CPU backend instead of falling back to the
per-pair rune diagonal walker. The per-pair rune fallback for non-unit and
over-long cells now binds the scalar serial scorer (no per-backend rune
tile_scorer exists). Also add the delegating single-pair layout()/operator()
shim to the Haswell byte Myers for the per-pair engine path.
Pack the dyadic bucket bit_width(shorter-1) into the GPU tier-sort key
(Levenshtein and weighted alike) instead of the raw saturated length, so
the radix sort bands tasks into power-of-two length groups within each
priority tier and launches run at more uniform depth - matching the CPU
candidate-lane dyadic tiling. Pure re-ordering; results unchanged.
Template the shared host driver, scratch sizing, and transpose buffer over
the kernel's element type (char for byte engines, rune_t for UTF-8) instead
of hardcoding char, so the UTF-8 engines can route rune candidates through
the same width-tiered candidate-lane path. Backward-compatible: byte engines
deduce char and are unchanged.
Remove the unreachable score_range_lane_walker_ method (and its orphaned
fits_lane_range_ / transcode_runes_ / reach-limit helpers) from the
haswell, icelake, and neon UTF-8 engines. Superseded by the rune Myers
fast path and the shared candidate-lane driver; grep-confirmed zero callers.
At exactly 16 bytes `cuda_status_t` is returned in the `RAX:RDX` register
pair; NVCC 12.x and host g++ miscompile the leading `status`/`cuda_error`
eightbyte inside large translation units (the elapsed-time eightbyte
survives), so a GPU engine that genuinely succeeds reports a garbage
status to its caller. This surfaced as the `stringzillas_bench_similarities_cu20`
benchmark aborting with an "unrecognized" status even though the kernels
ran and produced correct results.

Padding the struct past 16 bytes moves it into the SysV memory-return
class (sret), which both toolchains compile correctly, fixing every GPU
engine's status read without touching any call site.
…shtein

Non-unit and affine UTF-8 Levenshtein previously fell back to a per-pair
serial rune DP on every CPU backend - the cross-product never packed
independent rune pairs across SIMD lanes. Add explicit `rune_t` candidate-lane
walkers (u16 narrow + u32 wide, linear and affine) on haswell, icelake and
neon, and route the linear-non-unit and affine UTF-8 engines through the
width-tiered cross-product driver, leaving the serial rune DP only as the
can't-fill-lanes per-pair fallback.

Each kernel is bit-exact to the serial UTF-8 oracle; tests fill the
inter-lane group with uniform >512-rune rows so the batched path is actually
exercised rather than collapsing to 1x1.
The warp-tier GPU aligners pack the DP diagonals back-to-back at
`k * bytes_per_diagonal`, each band holding `max_diagonal_length` cells.
The DP only indexes `[0, max_diagonal_length)`, so the bands themselves
fit exactly - but GPU shared memory is serviced in `register_width`-byte
transaction words, and a cell narrower than that word (`u8`/`u16`) is read
by fetching the enclosing word. Reading the last cell of the final band
therefore touches up to `register_width - bytes_per_cell` bytes past it.
Between bands that overhang lands in the next band; past the final band it
runs off the end of the tightly-packed per-warp shared allocation and
faults - an `Invalid __shared__` access (cudaErrorLaunchFailure) that hit
the affine warp kernel on short, length-diverse inputs (tokenized words).

Account for it in `diagonal_memory_requirements` by reserving exactly one
widened-read overhang (`register_width - bytes_per_cell`, zero once cells
reach the word width). The CPU walkers were already immune - they cache-
line-pad between bands. Verified compute-sanitizer-clean and bit-exact on
cpp20 + cu20.
…eries

The per-query Myers reuse kernel mapped one warp per query, so a
single-query / many-candidate cross-product (the retrieval regime) lit up
exactly one warp: ncu showed 1.6% occupancy, 0.1% SM throughput, a 113 ms
launch where the full-device case finishes in 145 us.

Decompose the work as 2-D `(query, candidate-segment)` units instead: each
query's candidate row is split into `ceil(warps_per_device / queries)`
contiguous segments (clamped to the candidate count), and warps grid-stride
over `queries * segments` units. When queries already fill the device this
reduces to one segment per query (unchanged); when they don't, the spare
warps take candidate slices and the device fills regardless of query count.
The Peq build (rebuilt once per query-segment - cheap for single-word
queries) and the Myers recurrence are byte-identical, so results are
unchanged.

q=1, len=64: 37 -> 29,286 GCUPS (~790x), now matching the q=4096 rate and
compute-bound at 81% SM. Bit-exact on cu20.
Match the StringWars knob: score `STRINGWARS_BATCH_PER_CORE` pairs per core
(default 256), where a CPU logical core and a GPU streaming multiprocessor
each count as one core, so the per-device pair budget auto-scales as
`STRINGWARS_BATCH_PER_CORE * device_parallelism` with no fixed CPU/GPU
multiplier. Because the workload is a 2-D cross-product, that budget is
split as a square - each axis (queries, candidates) grows with the square
root of the per-device budget - so the balanced shape is `side x side` and
the retrieval corner (one query) can grow its candidate axis with the knob
instead of the old hard 256K ceiling.

The query sweep is now anchored on that square side (retrieval, candidate-
skew, balanced, query-skew); `STRINGWARS_BATCH` still overrides with
explicit per-query counts. On an H100 the default tile is `256 * 132 SMs`,
and e.g. `STRINGWARS_BATCH_PER_CORE=16384` grows the q=1 shape from
q1xc33792 to q1xc1048576.
…queries

Unit-cost Levenshtein on queries longer than 64 bytes fell off a cliff: the
size-generic thread-per-pair kernel keeps its multi-word Peq in GLOBAL
scratch, so the inner loop's per-(word x text-char) match-table load is
L2-bound (profiled lts 82%, sm 5%, 0.04 inst/cycle) - ~1 TCUPS vs the
single-word warp-reuse kernel's ~28.

Generalize the warp-per-query reuse design to a compile-time `words_count_`
(1/2/4 -> shorter <= 64/128/256): one warp owns a query, builds its
multi-word Peq once into SHARED, reuses it across the query's candidates
(2-D query/candidate-segment spread), with per-lane VP/VN in registers. The
recurrence is copied verbatim from the proven generic kernel, so it stays
bit-exact. Single-word (<= 64) still routes to the original hand-tuned
kernel - its scalar recurrence beats the one-word-specialized generic body
(~2.3x), so warp1 keeps it and only warp2/warp4 use the new kernel.

len128: 998 -> ~11,800 GCUPS (12x); len256: 853 -> ~12,100 (14x); len64
unchanged (~28,800). ncu len128: sm 5% -> 48%, Peq L2 traffic eliminated
(residual L2 is candidate-text re-reads). Bit-exact on cu20.
The cu20 suite drove the device only through 1x1 make_pairwise tiles and a
single-word (<= 24) reuse case, so the multi-word Peq-in-shared reuse path
(65-256 byte queries) and the >256 generic fallback had no cell-by-cell
coverage. Add W2/W4/mixed-W and generic GPU cases to
`test_similarities_cross_product`, each filling a candidate row wider than a
warp and pinned against the serial oracle.
The thread-per-pair "batch tier" held the per-thread DP row in local memory
once a pair exceeded the register tier, so device-timed blosum62 scoring
collapsed to ~60-190 GCUPS over 160-512 chars while the lane-split warp
anti-diagonal kernel (already used above 512) sustains ~420-660 there.

Narrow the batch tier to the register width (batch_text_limit_k 512 -> 128)
so its DP row can never spill, and send longer pairs straight to the warp
kernel. Measured on H100 (NW): len160 60 -> 424 (7x), len256 96 -> 527,
len512 193 -> 657; no change <=128 or >512. Added weighted NW/SW GPU
cross-product cases over the rerouted 129-512 band, bit-exact vs the serial
oracle (cu20).
…UTF-8 OOM

The GPU similarity engines gain a codepoint-level UTF-8 Levenshtein spanning
all three tiers (register thread-per-pair, warp anti-diagonal, device-tiled
wavefront). Data stays UTF-8-encoded on the device: a rune-offset index
prefix-scans each string once, and the scorers decode codepoints on the fly.
The branchless decode clamps each continuation read to the rune's own length,
matching sz_rune_parse_unchecked while never reading past a byte tape.

Levenshtein no longer dispatches slower than Needleman-Wunsch: unit-cost edit
distance is gated onto bit-parallel Myers only within
levenshtein_myers_max_shorter_k, and the device-tiled tier scores uniform
substitution cost branchlessly instead of through the matrix substituter.

The affine register kernels (unit_gotoh / weighted_gotoh) relax to
__launch_bounds__(256, 1) so ptxas stops spilling (weighted_gotoh 136 B -> 0,
unit_gotoh 8 B -> 0 of local spill traffic on the hot recurrence).

The Ice Lake UTF-8 cross-product no longer aborts at 100k-byte inputs: the
worst_cell_scratch_ estimate caps the rune-Myers Peq reservation by the
runtime fits_myers gate (26 GB -> 34 MB) for a kernel that never runs at that
length.

Also lands the GPU kernel reorganization (Hopper specializations out of
cuda.cuh via device functors; per-tier dispatch tables; author-named
recurrences) and the Haswell/Ice Lake weighted-walker class-hoist plus the
affine E-track rolling register.
The uncased family had no scalable Arm kernels. The fold kernel walks
64-byte superchunks as vector-sized sub-chunks with carried after-lead
views, classifies leads through the shared 64-entry family LUT, and
routes each superchunk to a per-script handler - caseless, Latin,
Cyrillic, Greek, Armenian, Georgian, or the guarded serial delegate -
with predicated loads and stores replacing NEON's zero-padded stack
staging. Stop lanes lower to a window mask and a shared serial helper
finds the boundary. The lead-family and delta tables move to the
serial header under ISA-neutral names; NEON keeps thin aliases.

The search kernel mirrors NEON's probe-filtered design: a three-probe
ASCII driver and a scripted driver whose fold and alarm callbacks
receive driver-built neighbour views. Chunks span TWO sub-vectors -
single-vector windows degenerated to byte-at-a-time stepping on long
Cyrillic folded runs - and every lead rewrite (eszett, Greek promotes,
micro, Armenian ligature leads) anchors on the carried next view so
rewrites survive sub-vector edges; the shared central-Europe and Greek
delta tables also migrate to the serial header. The case-invariant
probe, the cased-rune finder, and the order delegate complete the API.

At the minimal 128-bit vector length the predicate-setting compares
serialize on one pipe and both kernels trail NEON - fold reaches ~70%
and search 40-60% of its throughput while beating serial - so dispatch
stays behind the wider-than-NEON gate where growing registers shift
the balance. Validated bit-exact against serial over hundred-thousand
mixed-script differentials and fuzz batteries at three vector lengths.
StringZilla now exports its header-only target through a `stringzillaTargets` export set with generated version & config files, so `find_package(stringzilla)` works after installation. The vendored ForkUnion becomes a fallback rather than a mandate: when a parent project already defines the `forkunion_header` target, the nested copy is skipped instead of colliding, and an unpopulated submodule fails with an actionable hint about `git submodule update`. Subproject consumption stops mutating global state: the build-type default is no longer forced into the shared cache, CTest machinery arms only for top-level builds, CUDA targets no longer auto-enable just because `nvcc` is on the PATH, the shared-library include paths wear `BUILD_INTERFACE` genexes, and the main-project check rides the builtin `PROJECT_IS_TOP_LEVEL` on a CMake 3.21 floor.
The first big-endian execution of the hash family - an s390x wheel
built and tested locally under QEMU - caught `sz_hash_multiseed`
diverging from `sz_hash` at every 17..63-byte length that is not a
multiple of 16. The ladder de-overlaps its tail block with
`sz_hash_shift_in_register_serial_`, whose 64-bit lane shifts realize
the intended byte-array shift only in little-endian lane order; on
big-endian the bytes moved the wrong way, so the ladder disagreed with
the byte-wise lanes the multi-seed prepare and the streaming path
build. The shift now mirrors under `SZ_IS_BIG_ENDIAN_` - a left shift
of the big-endian-composed 128-bit value - and states the byte-array
contract in a comment. Little-endian code is unchanged; the full
s390x wheel suite now passes 6951 tests with zero failures, and POWER
inherits the fix wherever big-endian delegates hashing to serial.
The SVE2 delimiter scan fed BMP and astral membership through chains
of two- and three-level byte gathers - nine-cycle serialized loads
that lost to NEON everywhere but Chinese at the minimal vector length.
The rewrite keeps every verdict in registers and re-organizes the
walk around 64-byte windows streamed as register chunks, each peeking
one vector ahead: the shifted views cover every lane's continuations,
so the trusted-margin re-scan disappears and the per-chunk resolution
chains overlap in the out-of-order core instead of serializing per
16-byte tile.

Membership needs no gathers. Lanes below U+0100 read bitmap row 0
through a pair of zero-padded table registers - the byteset kernel's
wrapped two-TBL pattern. Other leads survive a 256-bit pre-filter
over the high byte only if their 256-codepoint block holds any
delimiter at all - a new 32-byte table; row 1 of the block table is
the unique empty row and covers 198 of 256 highs, including the whole
CJK range. Survivors resolve one DISTINCT high byte per round:
running text shares one to three highs per chunk, and each round
loads that block's 32-byte row once and settles every lane carrying
it. Astral lanes ride the same distinct-value loop keyed on the
(super, sub) pair. The 3-byte and 4-byte validity refinements run
only when such leads are present, so 2-byte scripts skip them.

Each window lowers its verdicts to one 64-bit lane mask and emits
once through a shared ctz drain that rereads only the lead nibble
for the length - no re-decode, no per-tile compaction fixed costs.
The scan now beats NEON on all eleven corpora - English 1.30 GiB/s
vs 692 MiB/s, German 1.07 GiB/s vs 435, mixed multilingual 800 vs
475, Vietnamese 725 vs 339 - and beats the scalar reference on six,
so dispatch takes it unconditionally at every vector length.
The NEON delimiter scan read its bitmap bytes through a sixteen-lane
scalar walk - indices stored to the stack, gathered byte by byte,
reloaded into a register - once per quarter for BMP membership and
again for astral lanes. The walk is gone. Lanes below U+0100 read
bitmap row 0 directly through a vqtbl2q pair; other leads survive a
256-bit pre-filter over the high byte only if their block holds any
delimiter (most of the plane, including all CJK letters, maps to the
unique empty row); survivors resolve one DISTINCT high byte per
round, loading that block's 32-byte row into a table pair and
settling every lane carrying it in-register. vmaxvq extracts each
round's high, which is never zero because sub-U+0100 lanes were
split off. Astral lanes ride the same loop keyed on the (plane, sub)
pair, with the plane kept one-biased so an empty set reads as zero
and clamped to the sixteen addressable planes so a malformed lead
can never index past the L1 table.

Every corpus gains 5-60%: Russian 368 to 494 MiB/s, Chinese 345 to
462, Korean 349 to 557, Thai 407 to 564, German 434 to 572, mixed
multilingual 475 to 619, English 692 to 726.
The SVE2 decoder's two clean-chunk lanes each prove one homogeneous
shape - ASCII with 2-byte pairs, or ASCII with 3-byte triples - so
scripts that interleave all three widths in a single chunk, the
steady state of Vietnamese prose, fell through to the general
gather-fed engine and trailed the scalar reference by a fifth.

A third lane now catches the mix once both specialists decline: the
same coverage algebra with the owed-lane smear built from both lead
kinds at once, both-width completeness and the E0/ED bounds in one
pass, codepoints assembled through a two-stage width select, and the
trailing overhang combining a last-lane 2-byte lead (owes one), a
last-lane 3-byte lead (owes two), and a second-to-last 3-byte lead
(owes one) - mutually exclusive in a structure-clean chunk. Carries
export the union: any multi-byte lead owes lane one, 3-byte leads
owe lane two.

Replacing the specialists with one generalized lane was measured
first and rejected: Vietnamese gained but every pure script paid
5-12% for the width selects it never uses. As a third tier the mix
decodes at 614 MiB/s - up 49%, ahead of the 520 MiB/s scalar
reference - mixed multilingual gains 4%, and every other corpus
stays within measurement noise.
…spaces

The NEON whitespace scan built its shifted views by rotating the
window onto itself, which poisons the last lanes and forced a
two-lane trusted margin - 12.5% of every window re-scanned. The
views now come from overlapping unaligned loads riding the free
load pipes, so every lane is trusted and the step is the full
register.

A window is also probed for the four leads that can actually start
a multi-byte whitespace - C2, E1, E2, E3 - and skips the entire
fourteen-compare class block when none is present, which covers
both ASCII prose and every non-Latin script whose letters use other
leads: Cyrillic, Arabic, CJK, and Thai text never pays for spaces
it cannot contain.

The scan now beats the scalar reference on the mixed multilingual
corpus for the first time - 737 vs 693 MiB/s (was 550) - and on
Russian 770 vs 760 (was 580), English 626 vs 586 (was 483), and
Chinese 1.41 vs 0.82 GiB/s (was 1.16).
A batched SVE2+AES intersection kernel - the 4-lane analog of
sz_hash_sve2_upto16_, mirroring the Ice Lake donor with predicated
loads and interleaved AES streams - was implemented, validated, and
benchmarked on Graviton 5, then deleted for failing the 1.5x gate
over serial on short-token workloads (~0.9-1.16x, tied). The
intersection is probe-bound, not hash-bound: Ice Lake wins on x86
only because it also vectorizes the table probe with 64-bit gathers
and scatters, and at the 128-bit vector length SVE gathers are a
documented trap on this core, so batching the hash alone cannot
clear the bar. The serial passthrough stays, with the design, the
measured numbers, and the re-entry condition recorded in the stub.
Four dispatch decisions were accidents of code order or stale
gates; all are now explicit and backed by Graviton 5 numbers, and
the tie-break policy is documented beside sz_sve_wider_than_neon_:
a scalable kernel dispatches unconditionally only when it wins at
the minimal 128-bit length on the mixed corpus; width-dependent
winners wait behind the gate; and families whose scalar walk beats
every 128-bit front install no Arm SIMD at all.

The SVE argsort block preceded NEON's unconditionally, so NEON
silently won by ordering - now it wins by measurement (53 vs 43
MiB/s at 128 bits) and the scalable kernel waits for wider
registers. Byte search keeps its unconditional SVE dispatch
deliberately: predicated heads make short inputs nearly twice as
fast as NEON (1.55 vs 0.82 GiB/s) while long scans tie. The
whitespace scan returns to NEON at 128 bits - the load-view rework
overtook the scalable front on the mixed corpus (737 vs 663 MiB/s).
Grapheme clustering installs no Arm SIMD front: clusters are dense
enough that the scalar walk leads every corpus at every capacity
(mixed 185 vs 64 NEON / 101 SVE2 MiB/s), so the vector kernels stay
compiled, tested reserves until wider registers flip the economics.
The multiseed hash keeps NEONAES with the reason recorded inline.
The NEON decoder classified every window through the general engine
regardless of shape, trailing the scalar reference on all multibyte
scripts. Two fast lanes now sit in front of it, ported from the SVE2
decoder's design.

On an E0..EF lead one vld3q_u8 structure load deinterleaves sixteen
(lead, continuation, continuation) triples - the steady state of CJK,
Hangul, and Thai prose - and a handful of range compares validates
all of them at once, including the E0-overlong and ED-surrogate
first-continuation bounds; the clean prefix assembles with zips and
widening stores, no compaction. The tile accepts a prefix covering
the whole attempt or at least four runes, so separator-dense scripts
do not pay for failed attempts, and it declines entirely within 48
bytes of the buffer end so the unpredicated load never over-reads.

Windows that mix ASCII with well-formed 2- and 3-byte sequences -
every alphabetic script and their mixes - are proven clean by a few
shifted-mask tests (the leads' owed-lane smear must equal the
continuation pattern, every lead must see its continuations, and the
E0/ED bounds must hold) and skip the general classification, with
the 3-byte machinery built only when such leads are present. Both
lanes leave the maximal-subpart U+FFFD handling to the unchanged
general engine.

Every multibyte corpus gains: Chinese 335 to 643 MiB/s, Japanese 336
to 658, Thai 378 to 650, mixed multilingual 528 to 653, Russian 394
to 481, Korean 346 to 435; English and pure ASCII hold at 1.44 and
2.8 GiB/s. The scalar reference keeps a lead on some dense scripts -
the remaining gap is windows with astral content that decline both
lanes - which only matters on pre-SVE2 cores where this path
dispatches.
Re-measured every Arm row on an AWS Graviton5 c9g instance (Neoverse-V3,
128-bit SVE2), replacing the Graviton3 figures. Substring search reaches
10.9 GB/s (3.6x LibC strstr), byte-set line splitting 4.3 GB/s, argsort
0.92 s over eight million words, random generation 678 MB/s, and the new
Unicode rows gain their Arm numbers - case folding 0.8 GB/s and uncased
search 2.1 GB/s (30x ICU). The provenance note now records the exact
instance and core.

The parallel-layer StringZillas edit-distance and alignment rows are
left at their prior figures: the similarities matrix benchmark returns
an engine-status error on this box that needs a separate look, and that
layer is out of the current release's scope.
Merges origin/main-v5 into the local main-v5, joining two independent
lines of work with no overlapping files.

From this branch - the Graviton 5 (Neoverse-V3, 128-bit SVE2) campaign,
eighteen commits:
  - New SVE2 kernels completing the UTF-8 family: grapheme clusters,
    sentence and line boundaries, case folding, and case-insensitive
    search now have scalable Arm siblings alongside the existing runes,
    tokens, norm, and word-break kernels.
  - Re-vectorized the delimiter scan on both SVE2 (64-byte windowed
    streaming, distinct-high in-register membership) and NEON (the same
    distinct-high scheme replacing a per-lane stack walk), and closed
    the decode residuals with a third clean-window lane and a NEON vld3
    tile - purging the vectorized-detector/serial-handler shortcuts.
  - A correctness fix for the cased-rune probe across five backends,
    dispatch tie-breaks aligned with measured 128-bit throughput, and a
    refreshed Arm benchmark column in the README.

From upstream - six commits: a byte-order fix in the serial short-hash
tail ladder, 32-bit-safe argsort indices in the wheel tests, POWER VSX
lead-classify and Clang 20 cast fixes, Debian arch recognition, and
package-export hygiene.

Coverage after the merge: every UTF-8 family carries both a NEON and an
SVE2 kernel. On this 128-bit core the SVE2 sibling is dispatched where
it wins - decode, count, seek, delimiters, sentences - while NEON is
kept for the compare-bound families (newlines, whitespaces, line
breaks, case folding and search) and the scalar walk is kept for dense
grapheme clusters; the scalable kernels stay gated for the wider
registers that flip the balance. All backends build clean and the
filtered suites pass.
…line-break chunks

The sentence-break and line-break classifiers ran the eight-gather
BMP class cascade on every chunk, even when the whole 16-byte window
is ASCII - the common shape in markup, code, and English prose. A
shared helper now reads the class bytes from page 0 of the flat table
with a cheap svtbl LUT when the entire chunk is below U+0100 (bit-
identical to the gather at high=0, low=byte) and takes the full gather
otherwise, so a pure-ASCII chunk skips the cascade while a mixed or
multibyte chunk pays only one compare and a predicate test.

The win is modest at the 128-bit vector length - the shared rule
engine, not the classify gather, dominates these kernels there
(English +2-5%, multilingual flat, bit-exact everywhere) - but it is
free, and the skip grows more valuable as the register widens and the
gather's fixed latency rises relative to the LUT. A first attempt that
always computed the LUT and blended per lane regressed multilingual
text and was replaced by the whole-chunk branch that never adds cost
to a non-ASCII chunk.
The RISC-V decoder had an ASCII run lane and a vlseg2e8 2-byte lane
but sent every 3-byte sequence - the steady state of CJK, Hangul,
Devanagari, and Thai prose - through the general classify/gather
window path, even though vlseg3e8 was available and unused. A 3-byte
tile now mirrors the 2-byte sibling and the Arm svld3/vld3 decode
tiles: one segmented load deinterleaves the lead and two continuation
streams, the triples validate entirely in-register (lead in E0..EF,
both continuations well-formed, and the first continuation tightened
for the E0 overlong and ED surrogate ranges), a vfirst bounds the
maximal good prefix, and the kept run assembles and stores with no
gather. Whatever ends the run defers to the general window path,
which keeps the maximal-subpart U+FFFD contract.

Validated bit-exact against the serial reference under qemu-riscv64
over 200k mixed-script fuzz iterations at full and capacity-limited
output, and confirmed the tile decodes a pure-CJK run directly.
…assify

The AVX2 decoder classified every lane of every window through the
length LUT, overrun scan, range checks, and orphan promotion. A clean
fast lane now sits in front of it, the AVX2 twin of the NEON and SVE2
clean lanes: when the whole decodable window is ASCII plus well-formed
C2..DF 2-byte pairs and E0..EF 3-byte triples - the shape of Latin,
Cyrillic, CJK, Hangul, Thai, and Vietnamese prose with ASCII
separators - a handful of shifted-mask tests prove the structure
(every start is ASCII or a 2-/3-byte lead, every lead owns its
continuations, no continuation is orphaned, the E0-overlong and
ED-surrogate first-continuation bounds hold) and the codepoints drain
through the same path the general engine uses, skipping the per-lane
classification that dominates its cost. Windows with any 4-byte or
invalid lead, or any irregularity, decline to the unchanged general
path at the same cursor.

Validated bit-exact against the serial reference under qemu-x86_64
over 60k mixed-script fuzz iterations at full and capacity-limited
output; the lane was confirmed to fire on Cyrillic, CJK, and mixed
2/3-byte inputs.
The shared byte-lane bridges the NEON and SVE/SVE2 kernels rely on -
the predicate-to-u64 movemask multiplies and the vreinterpret-based
lane extracts - assume little-endian lane-to-byte order and would
mis-execute on a big-endian aarch64_be build, which the endianness
probe already recognizes. The three dispatch macros now require
!SZ_IS_BIG_ENDIAN_, so such a target falls back to the portable serial
path instead of compiling in miscomputing kernels. Little-endian Arm
(every shipping part, and MSVC AArch64) is unaffected; the merged
serial short-hash byte-order fix is the precedent that this lane-order
class of bug is real.
The AVX2 `sz_copy_haswell` used a bare `size_t` for its tail-skip
counter, which is undeclared under `-ffreestanding -DSZ_AVOID_LIBC=1`
(no `<stddef.h>`). It compiled only because the normal build pulls in
libc; the no-libc configuration failed to build. Uses `sz_size_t`,
the project's own width type, like every other counter in the file.
…lassify

The AVX-512 twin of the NEON, Haswell, and SVE2 clean lanes: an ASCII +
well-formed C2..DF 2-byte + E0..EF 3-byte window is proven with a
handful of shifted 64-bit-mask tests and drained through the same path
the general engine uses, skipping the per-lane validity, orphan, and
maximal-subpart machinery. A window with any 4-byte or invalid lead, or
any irregularity, declines to the unchanged general path at the same
cursor. Bit-exact by construction: a genuinely clean window makes the
general path build the identical emit_starts, zero ill_formed, and
declared consumed_length this feeds to sz_utf8_rune_drain_icelake_.

The instruction-identical Haswell twin is validated bit-exact under
qemu-x86_64 over 60k fuzz iterations; Ice Lake's AVX-512-VBMI cannot be
executed under emulation, so this is compile-verified under the full
VBMI/VBMI2 feature set and covered at runtime by real-x86 CI.
… classify

The LASX twin of the NEON, Haswell, Ice Lake, and SVE2 clean lanes:
an ASCII + well-formed C2..DF 2-byte + E0..EF 3-byte window is proven
with shifted 32-bit-mask tests and drained through the existing path,
skipping the per-lane length and range classification. Any 4-byte or
irregular window declines to the unchanged general path at the same
cursor. Validated bit-exact against the serial reference under
qemu-loongarch64 over 60k mixed-script fuzz iterations; the lane was
confirmed to fire on Cyrillic, CJK, Hangul, and Greek prose.
…sify

The VSX twin of the NEON, Haswell, Ice Lake, and SVE2 clean lanes,
between the existing ASCII lane and the general classify: an ASCII +
well-formed 2-byte + 3-byte window is proven with shifted lane-mask
tests and drained through the existing path, skipping the length and
range classification. Irregular windows decline unchanged; the
big-endian serial path is untouched. Validated bit-exact against the
serial reference under qemu-ppc64le over 60k mixed-script fuzz
iterations, firing on essentially every window of clean prose.
…serial stride

The v128 decoder vectorized only the ASCII prefix and handed the entire
multibyte, malformed, and truncation remainder to sz_utf8_decode_serial
over a span - the last live instance of the vectorized-front / serial-
handler pattern the other backends had already shed. It now carries a
real vector multibyte decoder built on the existing 64-byte window
substrate, mirroring the POWER VSX and LASX general decoders: a single-
source high-nibble length classification, branchless overrun defer,
well-formed plus orphan-promotion mask algebra shared verbatim with the
other backends, a maximal-subpart length table, and a gather-free drain
that left-packs the emitted starts with swizzles and width-blends the
1/2/3/4-byte codepoints. The public entry keeps the ASCII prefix peel,
loops the window decoder, and finalizes a declined window-edge
truncation through the shared bounded maximal-subpart helper - no
serial span call remains.

Validated bit-exact against the serial reference under wasmtime: the
60k mixed-script fuzz differential (full and capacity-limited) plus an
exhaustive sweep of all one- and two-byte strings, all ~16.8M
three-byte strings, and 400k window-straddling inputs at every output
capacity all matched.
`sz_utf8_fold_lead_family_t_` was defined three times with identical values:
in `serial.h`, in `icelake.h`, and under a back-end prefix in `haswell.h`.
`utf8_uncased_fold.h` includes `serial.h` and `icelake.h` unconditionally and
back to back, so every translation unit reaching `stringzilla.h` failed to
compile on x86-64, breaking nine CI jobs.

Keep the definition in `serial.h`, the arch-neutral header every back-end
already includes, and let Ice Lake and Haswell consume it alongside NEON and
SVE2. The per-back-end rationale moves onto the code it explains: the `VPERMB`
indexing note onto the Ice Lake table, the compare-tree and `VPMOVMSKB` note
onto the Haswell flag construction.

Rename the flags to the `_k` suffix every other enum constant in the tree uses
and drop the private `_t_` marker from the type.
Twenty-one private types carried a trailing underscore after `_t` to mark them
internal - four in `hash/`, sixteen in `utf8_uncased/`, one in
`utf8_uncased_fold/`. The marker reads poorly and buys nothing: these types are
already unexported implementation details, and the public enums have always used
a plain `_t`.

Rename them all to `_t`. No collisions with existing names, and the change is a
pure identifier substitution.
Thirty `SZ_UTF8_NORM_*` object-like macros become grouped enums with the `_k`
suffix. Six of them were hand-computed from others - the three-value
`bits`/`size`/`mask` triples for both tries, and the two Hangul counts - and are
now derived in the enum body, so retuning a stage means editing one number
instead of keeping three in sync. Enum constants are also visible to a debugger.

Twenty-one of the macros lived in the generated `tables.h`. They were never
emitted by the generator recipe, which derives array contents only, so they move
to `serial.h` beside the nine Hangul constants already there. That leaves
`tables.h` holding nothing but generated data and lets the full Hangul family
share one enum, where `S_COUNT = L_COUNT * N_COUNT` is expressible.

Rescope the `do NOT edit by hand` banner accordingly: it now marks the array
initializers as generated and states plainly that the surrounding code and
comments are hand-written and editable, and that the recipe emits table data
rather than the file.

Add a Hangul sweep to the normalization oracles. Hangul is algorithmic and
absent from the tables, so neither `NormalizationTest.txt` nor the ICU sweep
pinned the syllable-block constants; the new sweep walks the block precomposed
and decomposed against `unicodedata`, overrunning it on both sides so an
off-by-one syllable count surfaces as an unassigned codepoint decomposing.
`push` and `pull_request` both filtered on `main*`, but the two filters match
different things: `push` matches the branch being pushed, `pull_request` matches
the PR's base. A branch like `main-v5` with a PR into `main` satisfied both, so
every push built the same commit twice - around 45 redundant runner VMs each
time, enough to leave the queue backed up for ten minutes.

Restrict `push` to `main` alone. Branches with an open PR are still covered
through `pull_request`, and fork PRs keep their coverage, which they would lose
if `push` were kept as the primary trigger instead.

Add a concurrency group keyed on the source branch. `head_ref` on a PR event and
`ref_name` on a push both name that branch, so any number of PRs from one branch
share a group. Cancellation is limited to PR events, leaving a `main` build that
feeds the release pipeline to finish.
A single Linux wheel job built every architecture through cibuildwheel's
`archs = ["all"]`, pulling around eleven manylinux and musllinux images onto one
runner. That exhausts the ~14 GB of disk and dies with `no space left on device`
after roughly two hours of QEMU emulation - the failure that surfaced once the
compile was unblocked and the wheel jobs finally ran.

Split the Linux wheels by architecture class in both workflows. Native x86_64
and native aarch64 each build on their own runner - aarch64 on `ubuntu-24.04-arm`
with no emulation, matching the CUDA job that already does this - and the
emulation-only arches (ppc64le, s390x, i686, armv7l, riscv64) get one runner
each, so no runner pulls more than a single image. Every architecture still
builds all five CPython versions, so PR coverage is unchanged.

Under emulation the fuzz baselines run at `SZ_TESTS_MULTIPLIER=0.05`, the depth
the C++ `test_cross_qemu` job already uses; native jobs keep full depth. The
release publish and status jobs are rewired to the new split job keys, and the
per-job artifact names stay within the `cibw-wheels-<pkg>-*` globs the publish
steps download by.

Refresh the actions while here: `setup-python` to v7.0.0 (drops only the unused
`pip-install` input), `checkout` to v7, and the prerelease artifact actions up
to the upload v7 / download v8 pairing release already uses. Also fix a
`setup-python` reference that had lost its `actions/` owner prefix.
The Windows wheel jobs relied on cibuildwheel's implicit per-runner defaults,
which left two gaps. `stringzilla` set no `CIBW_ARCHS_WINDOWS`, so on the Intel
runner it built AMD64 and 32-bit x86 and shipped no ARM64 wheel at all.
`stringzillas-cpus` requested `AMD64 ARM64` on the same Intel runner, so its
ARM64 wheel was cross-compiled and never tested - the Windows-on-Arm platform
cannot run ARM64 binaries on an Intel host.

In `release.yml`, split both into an explicit x86 job on `windows-2022` and an
ARM job on the native `windows-11-arm` runner, mirroring the Linux `_x86` /
`_arm` layout and setting `CIBW_ARCHS_WINDOWS` explicitly rather than leaning on
defaults. On the native ARM runner the ARM64 wheels are built and tested
natively. The ARM matrix starts at 3.11, the first CPython with an official
Windows ARM64 build.

In `prerelease.yml`, add `windows-11-arm` to the `build_wheels_native` matrix -
excluding 3.10 for the same reason - so every PR validates the Windows ARM64
wheel path release now ships, instead of it first running live at a release.

`stringzilla` now ships a tested Windows ARM64 wheel it never produced before,
and the `stringzillas-cpus` ARM64 wheel is tested instead of built blind. The
publish and status jobs are rewired to the four new keys, and the artifact names
stay within the `cibw-wheels-stringzilla-*` and `cibw-wheels-stringzillas-cpus-*`
globs the publish steps download by.
@ashvardanian ashvardanian changed the title v5: More backends! More UTF8! v5: More UTF-8! More Bio! More Hardware! Jul 20, 2026
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