diff --git a/.github/scripts/render_pipeline_bench.py b/.github/scripts/render_pipeline_bench.py
index ed7e92c71..41670db0b 100644
--- a/.github/scripts/render_pipeline_bench.py
+++ b/.github/scripts/render_pipeline_bench.py
@@ -47,6 +47,12 @@
# `added_split` = added/special-token scan (AddedVocabulary), `pre_tokenize` =
# pre-tokenizer split — two distinct splitting costs. `post` = special-token
# id-frame splice (~0 for models with no post-processor). The five sum to `total`.
+#
+# `total` is the ladder's own full encode, NOT the public `encode` call the throughput
+# phase times: that additionally acquires a pooled scratch and returns an owned Vec.
+# The subtitle names that exclusion rather than the stack absorbing it, because the
+# difference is part real cost and part pooled-vs-fresh cache state, and no measurement in
+# the bench separates the two.
STAGES = [("added_split", "added-token"), ("normalize", "normalize"),
("pre_tokenize", "pre-tokenize"), ("model", "model"), ("post", "post")]
STAGE_INK = {"added_split": "#7a5ea8", "normalize": "#2a9d8f",
@@ -640,12 +646,20 @@ def stage_chart_svg(model, subtitle_base, meta, baseline_label):
sink = STAGE_INK
rows = [r for r in model["results"] if "stage_ns_per_byte" in r]
+ # A row's stages are shares of that row's `total`, and they can sum past 100% when a
+ # rung measured at or below the noise floor and clamped to zero. Scaling the plot by the
+ # widest row keeps such a bar on the canvas and visibly past the 100% gridline, instead
+ # of running off the right edge as though the chart were broken.
+ span = max([1.0] + [sum(r["stage_ns_per_byte"].get(k, 0.0) for k, _ in STAGES)
+ / (r["stage_ns_per_byte"]["total"] or 1.0) for r in rows])
+ scale = PLOT_W / span
+
top = 84
col_x = GUTTER + PLOT_W + PAD_R + COL_W - 16
body = [f'total ns/B · ×speedup',
f''
- f'share of pipeline encode time (each bar = 100%) · label = share% · ns/B']
+ f'share of pipeline encode time (100% = that fixture\'s total) · label = share% · ns/B']
y = top
for key, title in GROUPS:
group_rows = sorted((r for r in rows if r["group"] == key),
@@ -665,7 +679,7 @@ def stage_chart_svg(model, subtitle_base, meta, baseline_label):
for skey, _ in STAGES:
val = s.get(skey, 0.0)
frac = val / total
- seg = frac * PLOT_W # each bar fills PLOT_W (100% of this fixture's total)
+ seg = frac * scale # 100% of this fixture's total is `scale` wide
if seg > 0.4:
body.append(f'')
@@ -689,7 +703,7 @@ def stage_chart_svg(model, subtitle_base, meta, baseline_label):
grid = []
for pct in (0, 25, 50, 75, 100):
- gx = GUTTER + pct / 100 * PLOT_W
+ gx = GUTTER + pct / 100 * scale
grid.append(f'')
grid.append(f'= 0.02)
+ # The route is part of the reading, not decoration: each one decomposes into stages
+ # differently, and the split bar only means the shipped splitter because every rung
+ # drives this route. Older JSON has no `route` key, so say so rather than guess.
+ route = model.get("route") or "route not recorded"
+ subtitle = f'{model["shape"]} · {route} route · {mix_txt}'
+ # Two things the reader would otherwise have to infer. The rungs are independent
+ # medians, so one at or below the noise floor clamps to zero and the parts then sum
+ # past 100%, which is visible as a bar overrunning the axis. And `total` is the
+ # ladder's own encode, without the pooled scratch and returned Vec of a real call.
+ sums = [sum(r["stage_ns_per_byte"].get(k, 0.0) for k, _ in STAGES)
+ / r["stage_ns_per_byte"]["total"]
+ for r in rows if r["stage_ns_per_byte"].get("total")]
+ if sums:
+ subtitle += f' · parts sum {100 * sum(sums) / len(sums):.0f}% (rung noise)'
+ if any("alloc" in r["stage_ns_per_byte"] for r in rows):
+ subtitle += " · excludes encode() call overhead"
return svg_doc(ink, height, f'{model["model"]} — Pipeline encode stage mix',
subtitle, "".join(grid) + "".join(body) + legend, meta, subtitle_base)
@@ -884,9 +915,9 @@ def x(v):
def pretok_compare_md(model):
- """Per-fixture 'classify + fsm vs a regex engine' table — the pre-tokenize split
- vs onig/fancy/pcre2/logos on the model's own regex, WITH and WITHOUT SIMD.
- Rendered only for regex pre-tokenizers (a reference is non-null)."""
+ """Per-fixture table of the shipped pre-tokenize split against onig, fancy, pcre2 and
+ logos on the model's own regex. Rendered only for regex pre-tokenizers (a reference is
+ non-null)."""
engines = ("onig", "fancy", "pcre2", "logos")
def has_ref(r):
@@ -898,13 +929,15 @@ def has_ref(r):
cell = lambda v: fnum(v, "{:.1f}") # noqa: E731 — "—" for null
def ratio(v, sp, cp):
return f"{v / sp:.1f}× / {v / cp:.1f}×" if (v is not None and sp > 0 and cp > 0) else "—"
- cols = 4 + 2 * len(engines) # cls×2 + pipe×2 + one abs + one ×vs per engine
- md = ["", "**Pre-tokenize: `classify + fsm` vs regex engines** — ns/byte, lower better. The fsm is "
- "the scalar jump-table in both pipe columns; **SIMD / scalar is the classify pass** (regex "
- "pre-tokenizers have no SIMD fsm). `×vs` = engine ÷ our pipeline (SIMD / scalar classify); "
- "`onig` & `pcre2` (JIT) are C, `fancy` is pure-Rust fancy-regex, `logos` is a compile-time DFA "
- "lexer (approximate grammar; n/a for deepseek).", "",
- "| Fixture | classify SIMD | classify scalar | pipe (SIMD cls + fsm) | pipe (scalar cls + fsm) | "
+ cols = 4 + 2 * len(engines) # cls×2 + split×2 + one abs + one ×vs per engine
+ md = ["", "**Pre-tokenize: our split vs regex engines.** ns/byte, lower better. `split` is the "
+ "splitter this model's route actually runs, straight off the stage ladder. "
+ "`scalar-cls` swaps **only the classify pass** for its scalar version. It is not the split "
+ "without SIMD, since a regex-shaped fsm is a boundary-mask scanner and carries SIMD of its "
+ "own. `×vs` = engine ÷ our split (shipped / scalar-classify); `onig` & `pcre2` (JIT) are C, "
+ "`fancy` is pure-Rust fancy-regex, `logos` is a compile-time DFA lexer (approximate grammar; "
+ "n/a for deepseek).", "",
+ "| Fixture | classify SIMD | classify scalar | split (shipped) | split (scalar-cls) | "
+ " | ".join(engines) + " | " + " | ".join(f"×vs {e}" for e in engines) + " |",
"|---" + "|---:" * cols + "|"]
for r in sorted(rows, key=lambda r: (r["group"], r["fixture"])):
diff --git a/tokenizers/atomsplit/Cargo.toml b/tokenizers/atomsplit/Cargo.toml
index fb8947f3f..dce664880 100644
--- a/tokenizers/atomsplit/Cargo.toml
+++ b/tokenizers/atomsplit/Cargo.toml
@@ -43,6 +43,10 @@ harness = false
name = "classify"
harness = false
+[[bench]]
+name = "literal"
+harness = false
+
[[bench]]
name = "class_runs"
harness = false
diff --git a/tokenizers/atomsplit/benches/literal.rs b/tokenizers/atomsplit/benches/literal.rs
new file mode 100644
index 000000000..c165b3f6c
--- /dev/null
+++ b/tokenizers/atomsplit/benches/literal.rs
@@ -0,0 +1,67 @@
+//! `Literal::matches` (one search per match) against `Literal::matches_into` (one scan per
+//! text), across the delimiter densities pre-tokenizers see: a space about every six bytes of
+//! English, a `▁` per word after a SentencePiece replace, and a pattern the text does not
+//! contain at all (where `memmem`'s skip-ahead is at its best and the scan must keep up).
+//! Run: cargo bench --bench literal
+use atomsplit::literal::Literal;
+use std::hint::black_box;
+use std::time::Instant;
+
+fn best_ns_per_byte(text_len: usize, mut pass: impl FnMut() -> usize) -> f64 {
+ let iters = (16_000_000 / text_len.max(1)).clamp(4, 400) as u32;
+ for _ in 0..3 {
+ black_box(pass());
+ }
+ let mut best = f64::INFINITY;
+ for _ in 0..9 {
+ let t = Instant::now();
+ for _ in 0..iters {
+ black_box(pass());
+ }
+ best = best.min(t.elapsed().as_nanos() as f64 / (iters as usize * text_len) as f64);
+ }
+ best
+}
+
+fn main() {
+ let manifest = env!("CARGO_MANIFEST_DIR");
+ let english =
+ std::fs::read_to_string(format!("{manifest}/../data/big.txt")).unwrap_or_default();
+ let english: String = english.chars().take(2_000_000).collect();
+ if english.is_empty() {
+ println!("empty corpus — data/big.txt missing? (make test downloads it)");
+ return;
+ }
+ let metaspaced = english.replace(' ', "\u{2581}");
+
+ println!(
+ "{:<22} {:>9} {:>12} {:>12} {:>9} {:>12}",
+ "", "matches", "iterator", "batch", "speedup", "count-only"
+ );
+ for (label, text, pattern) in [
+ ("space in English", english.as_bytes(), " "),
+ ("▁ in metaspaced", metaspaced.as_bytes(), "\u{2581}"),
+ ("▁ absent", english.as_bytes(), "\u{2581}"),
+ ] {
+ let literal = Literal::new(pattern.as_bytes()).unwrap();
+ let mut offsets: Vec = Vec::with_capacity(text.len());
+ let mut buffer = vec![0u32; text.len() + 4];
+
+ let count = literal.matches(text).count();
+ let iterator = best_ns_per_byte(text.len(), || {
+ offsets.clear();
+ offsets.extend(literal.matches(text));
+ offsets.len()
+ });
+ let batch = best_ns_per_byte(text.len(), || literal.matches_into(text, &mut buffer));
+ let counting = best_ns_per_byte(text.len(), || literal.count_matches(text));
+
+ println!(
+ "{label:<22} {count:>9} {:>7.2} GB/s {:>7.2} GB/s {:>8.2}x {:>7.2} GB/s",
+ 1.0 / iterator,
+ 1.0 / batch,
+ iterator / batch,
+ 1.0 / counting
+ );
+ }
+}
diff --git a/tokenizers/atomsplit/src/fsm.rs b/tokenizers/atomsplit/src/fsm.rs
index 45c8ca286..5d6b950d4 100644
--- a/tokenizers/atomsplit/src/fsm.rs
+++ b/tokenizers/atomsplit/src/fsm.rs
@@ -6,8 +6,8 @@
//! [`class_runs_into`]: on aarch64/wasm the SIMD movemask boundary-extractor + homogeneous-chunk
//! early-out (in `simd_fsm`), elsewhere the scalar run-end core ([`emit_class_spans`]). The
//! regex-shaped ones ([`fsm_cl100k`] / [`fsm_o200k`] / [`fsm_tekken`] / [`fsm_deepseek`] /
-//! [`fsm_byte_level`]) are scalar jump-tables (only the class family's [`class_runs_into`] has a SIMD
-//! path).
+//! [`fsm_byte_level`]) are scalar jump-tables; byte_level additionally has a boundary-mask SIMD
+//! form ([`scan_byte_level_masked`], aarch64 only).
pub(crate) use crate::classify::{Atom, char_len, classify, in_mask, mask};
// Atom-tag aliases, shared with the per-tokenizer FSM submodules (`fsm/*.rs`) via `use super::*`.
@@ -237,11 +237,17 @@ pub fn emit_class_spans(
mod byte_level;
mod cl100k;
mod deepseek;
+mod masked;
mod o200k;
-pub use byte_level::fsm_byte_level;
-pub use cl100k::{fsm_cl100k, fsm_cl100k_cap};
-pub use deepseek::fsm_deepseek;
-pub use o200k::{fsm_o200k, fsm_tekken};
+pub use byte_level::{fsm_byte_level, scan_byte_level};
+pub use cl100k::{fsm_cl100k, fsm_cl100k_cap, scan_cl100k_cap};
+pub use deepseek::{fsm_deepseek, scan_deepseek};
+pub use masked::{
+ fsm_byte_level_masked, fsm_cl100k_cap_masked, fsm_deepseek_masked, fsm_o200k_masked,
+ fsm_tekken_masked, scan_byte_level_masked, scan_cl100k_cap_masked, scan_deepseek_masked,
+ scan_o200k_masked, scan_tekken_masked,
+};
+pub use o200k::{fsm_o200k, fsm_tekken, scan_o200k, scan_tekken};
// ── Composition recipes ────────────────────────────────────────────────────────────────────────
// Each pre-tokenizer = (classify → fsm shape + params). `tags` and `out` are caller-owned
@@ -339,49 +345,3 @@ impl ByteLevel {
fsm_byte_level(text, tags, out)
}
}
-
-/// `Split(char, Removed)` — the only pre-tokenizer that keys on a *literal char* rather than an atom
-/// class, so it scans bytes directly (no classify pass). UTF-8 is self-synchronizing, so the
-/// delimiter's byte pattern only matches on char boundaries.
-pub struct CharDelimiterSplit(pub char);
-impl CharDelimiterSplit {
- /// Split on the literal char (Removed); writes spans into `out` (len ≥ `text.len()`), returns count.
- #[inline]
- #[must_use]
- pub fn pre_tokenize(&self, text: &[u8], _tags: &mut [u8], out: &mut [Span]) -> usize {
- debug_assert!(out.len() >= text.len());
- let mut buf = [0u8; 4];
- let delim = self.0.encode_utf8(&mut buf).as_bytes();
- let (n, dl) = (text.len(), delim.len());
- let (mut start, mut i, mut w) = (0usize, 0usize, 0usize);
- while i + dl <= n {
- // memchr the first delimiter byte, then confirm the full pattern. memchr (already a
- // workspace dep) beats a scalar scan 1.4–23× here — the gap widening as the delimiter
- // gets rarer over large inputs, since its SIMD skips whole 16/32/64-byte strides.
- match memchr::memchr(delim[0], &text[i..n - dl + 1]) {
- Some(off) if text[i + off..i + off + dl] == *delim => {
- let m = i + off;
- if m > start {
- out[w] = Span {
- start: start as u32,
- end: m as u32,
- }; // gap before the delimiter (Removed)
- w += 1;
- }
- i = m + dl;
- start = i;
- }
- Some(off) => i += off + 1, // first byte matched mid-pattern; keep scanning
- None => break,
- }
- }
- if start < n {
- out[w] = Span {
- start: start as u32,
- end: n as u32,
- };
- w += 1;
- }
- w
- }
-}
diff --git a/tokenizers/atomsplit/src/fsm/byte_level.rs b/tokenizers/atomsplit/src/fsm/byte_level.rs
index 73f84ac9f..479a4b1de 100644
--- a/tokenizers/atomsplit/src/fsm/byte_level.rs
+++ b/tokenizers/atomsplit/src/fsm/byte_level.rs
@@ -8,10 +8,21 @@ use super::*;
#[must_use]
pub fn fsm_byte_level(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
- let end = text.len();
- // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
- // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
- let tags = &tags[..end];
+ let mut w = 0usize;
+ scan_byte_level(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// End of the token starting at `i` (`i < end`, `i` on a token boundary): one rule dispatch of the
+/// byte-level regex. [`scan_byte_level`] loops it over the whole text; the masked scanner
+/// ([`super::scan_byte_level_masked`]) re-derives tokens with it where its batch masks are not
+/// trustworthy.
+#[inline(always)]
+pub(super) fn advance_byte_level(text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
// `\s+(?!\S)|\s+`: the whole run at EOF, else leave the last ws char for the next ` ?`-prefixed run.
let ws = |i: usize| -> usize {
let re = run_end(tags, i, end, mask::WS);
@@ -25,57 +36,57 @@ pub fn fsm_byte_level(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
if last > i { last } else { re }
}
};
-
- let mut i = 0;
- let mut w = 0usize;
- while i < end {
- let start = i;
- match tags[i] & 0x0F {
- LET => i = run_end(tags, i, end, mask::LETTER), // ` ?\p{L}+` (space taken by the Space arm)
- NW | NO => i = run_end(tags, i, end, mask::NUMBER), // ` ?\p{N}+` — UNBOUNDED
- MRK | CON | PUN | SYM | NMO | CTL => i = run_end(tags, i, end, mask::NOT_WS_L_N), // ` ?[^…]+`
- // `'s|'t|'re|'ve|'m|'ll|'d` (case-sensitive), else `[^\s\p{L}\p{N}]+` (apostrophe ∈ that set)
- APO => {
- let adv = match (text.get(i + 1), text.get(i + 2)) {
- (Some(b's' | b't' | b'm' | b'd'), _) => 2,
- (Some(b'r'), Some(b'e'))
- | (Some(b'v'), Some(b'e'))
- | (Some(b'l'), Some(b'l')) => 3,
- _ => 0,
- };
- i = if adv > 0 {
- i + adv
- } else {
- run_end(tags, i, end, mask::NOT_WS_L_N)
- };
+ match tags[i] & 0x0F {
+ LET => run_end(tags, i, end, mask::LETTER), // ` ?\p{L}+` (space taken by the Space arm)
+ NW | NO => run_end(tags, i, end, mask::NUMBER), // ` ?\p{N}+` — UNBOUNDED
+ MRK | CON | PUN | SYM | NMO | CTL => run_end(tags, i, end, mask::NOT_WS_L_N), // ` ?[^…]+`
+ // `'s|'t|'re|'ve|'m|'ll|'d` (case-sensitive), else `[^\s\p{L}\p{N}]+` (apostrophe ∈ that set)
+ APO => {
+ let adv = match (text.get(i + 1), text.get(i + 2)) {
+ (Some(b's' | b't' | b'm' | b'd'), _) => 2,
+ (Some(b'r'), Some(b'e')) | (Some(b'v'), Some(b'e')) | (Some(b'l'), Some(b'l')) => 3,
+ _ => 0,
+ };
+ if adv > 0 {
+ i + adv
+ } else {
+ run_end(tags, i, end, mask::NOT_WS_L_N)
}
- // Space: the ` ?` prefix — attach one space to a following letter / number / "other" run,
- // else it's whitespace (rules `\s+(?!\S)|\s+`, which leave one space for the next run).
- SPC => {
- let a = i + 1; // Space is ASCII (0x20)
- i = match tags.get(a).map(|&t| t & 0x0F) {
- Some(LET) => run_end(tags, a, end, mask::LETTER),
- Some(NW) | Some(NO) => run_end(tags, a, end, mask::NUMBER),
- Some(t) if in_mask(t, mask::NOT_WS_L_N) => {
- run_end(tags, a, end, mask::NOT_WS_L_N)
- }
- _ => ws(i),
- };
- }
- // WsOther / Newline: whitespace only — the ` ?` prefix is a literal 0x20, so tabs/newlines
- // never prefix a run.
- WSO | NLN => i = ws(i),
- // Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
- _ => i += char_len(text[i]),
}
- // SAFETY: tokens partition the input, so `w < #tokens <= end < out.len()` (out ≥ text.len()+? ; callers size n+1).
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: i as u32,
+ // Space: the ` ?` prefix — attach one space to a following letter / number / "other" run,
+ // else it's whitespace (rules `\s+(?!\S)|\s+`, which leave one space for the next run).
+ SPC => {
+ let a = i + 1; // Space is ASCII (0x20)
+ match tags.get(a).map(|&t| t & 0x0F) {
+ Some(LET) => run_end(tags, a, end, mask::LETTER),
+ Some(NW) | Some(NO) => run_end(tags, a, end, mask::NUMBER),
+ Some(t) if in_mask(t, mask::NOT_WS_L_N) => run_end(tags, a, end, mask::NOT_WS_L_N),
+ _ => ws(i),
}
- };
- w += 1;
+ }
+ // WsOther / Newline: whitespace only — the ` ?` prefix is a literal 0x20, so tabs/newlines
+ // never prefix a run.
+ WSO | NLN => ws(i),
+ // Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
+ _ => i + char_len(text[i]),
+ }
+}
+
+/// The scan under [`fsm_byte_level`]: hands each token to `emit` the moment it is cut,
+/// so a caller can consume tokens in place instead of collecting a span buffer first.
+pub fn scan_byte_level(text: &[u8], tags: &[u8], mut emit: impl FnMut(Span)) {
+ debug_assert!(tags.len() >= text.len());
+ let end = text.len();
+ // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
+ // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
+ let tags = &tags[..end];
+ let mut i = 0;
+ while i < end {
+ let e = advance_byte_level(text, tags, i, end);
+ emit(Span {
+ start: i as u32,
+ end: e as u32,
+ });
+ i = e;
}
- w
}
diff --git a/tokenizers/atomsplit/src/fsm/cl100k.rs b/tokenizers/atomsplit/src/fsm/cl100k.rs
index 65976ecef..5450fd284 100644
--- a/tokenizers/atomsplit/src/fsm/cl100k.rs
+++ b/tokenizers/atomsplit/src/fsm/cl100k.rs
@@ -14,17 +14,26 @@ pub fn fsm_cl100k(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
#[must_use]
pub fn fsm_cl100k_cap(text: &[u8], tags: &[u8], out: &mut [Span], digit_cap: usize) -> usize {
debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
- cl100k(text, tags, out, digit_cap)
+ let mut w = 0usize;
+ scan_cl100k_cap(text, tags, digit_cap, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
}
-fn cl100k(text: &[u8], tags: &[u8], out: &mut [Span], digit_cap: usize) -> usize {
- // Leading-atom values, as `const` so the `match` below is a dense jump table (not an if-cascade):
- // the dispatch is O(1) and a token never pays for a rule it can't start (e.g. non-number tokens
- // never test the number rule — which is what the POC's const-gating removed by hand; here it's free).
- let end = text.len();
- // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
- // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
- let tags = &tags[..end];
+/// End of the token starting at `i` (`i < end`, `i` on a token boundary): one rule dispatch of
+/// the cl100k-family regex. [`scan_cl100k_cap`] loops it over the whole text; the masked scanner
+/// re-derives tokens with it where its batch masks are not trustworthy.
+#[inline(always)]
+pub(super) fn advance_cl100k_cap(
+ text: &[u8],
+ tags: &[u8],
+ i: usize,
+ end: usize,
+ digit_cap: usize,
+) -> usize {
let letters = |a: usize| run_end(tags, a, end, mask::LETTER);
// rule 4 body: `[^\s\p{L}\p{N}]+[\r\n]*` from `sp0` (any leading space already consumed). Returns
// the run end, or `sp0` if there is no "other" run there (caller then treats it as whitespace).
@@ -40,79 +49,84 @@ fn cl100k(text: &[u8], tags: &[u8], out: &mut [Span], digit_cap: usize) -> usize
// rules 5-7 (`\s*[\r\n] | \s+(?!\S) | \s+`) → the shared `ws_tail`.
let ws = |i: usize| -> usize { ws_tail(text, tags, i, end) };
- let mut i = 0;
- let mut w = 0usize;
- while i < end {
- let start = i;
- let b = text[i];
- match tags[i] & 0x0F {
- // rule 2: `\p{L}+`
- LET => i = letters(i),
- // rule 3: `\p{N}{1,cap}` (cap = 3 cl100k, 1 Qwen2, MAX for `\p{N}+`)
- NW | NO => {
- let (mut p, mut cnt) = (i, 0);
- while p < end && cnt < digit_cap && in_mask(tags[p], mask::NUMBER) {
- p += char_len(text[p]);
- cnt += 1;
- }
- i = p;
- }
- // Space: rule 2 (space prefix + `\p{L}+`) | rule 4 (` ` + "other") | rules 5-7
- SPC => {
- let a = i + 1; // Space is ASCII (0x20)
- i = if a < end && (tags[a] & 0x0F) == LET {
- letters(a)
- } else {
- let p = other(a);
- if p > a { p } else { ws(i) }
- };
+ let b = text[i];
+ match tags[i] & 0x0F {
+ // rule 2: `\p{L}+`
+ LET => letters(i),
+ // rule 3: `\p{N}{1,cap}` (cap = 3 cl100k, 1 Qwen2, MAX for `\p{N}+`)
+ NW | NO => {
+ let (mut p, mut cnt) = (i, 0);
+ while p < end && cnt < digit_cap && in_mask(tags[p], mask::NUMBER) {
+ p += char_len(text[p]);
+ cnt += 1;
}
- // WsOther: rule 2 (prefix + `\p{L}+`) | whitespace (never rule 4 — not in NOT_WS_L_N)
- WSO => {
- let a = i + char_len(b);
- i = if a < end && (tags[a] & 0x0F) == LET {
- letters(a)
- } else {
- ws(i)
- };
+ p
+ }
+ // Space: rule 2 (space prefix + `\p{L}+`) | rule 4 (` ` + "other") | rules 5-7
+ SPC => {
+ let a = i + 1; // Space is ASCII (0x20)
+ if a < end && (tags[a] & 0x0F) == LET {
+ letters(a)
+ } else {
+ let p = other(a);
+ if p > a { p } else { ws(i) }
}
- // Newline: whitespace (rule 5 ends at the last newline)
- NLN => i = ws(i),
- // Apostrophe: rule 1 (contraction) | rule 2 (prefix + `\p{L}+`) | rule 4
- APO => {
- let adv = contraction(text, i); // rule 1: `'s 't 're 've 'm 'll 'd` (case-insensitive)
- i = if adv > 0 {
- i + adv
- } else {
- let a = i + 1; // Apostrophe is ASCII (0x27)
- if a < end && (tags[a] & 0x0F) == LET {
- letters(a)
- } else {
- other(i)
- } // c ∈ NOT_WS_L_N ⇒ > i
- };
+ }
+ // WsOther: rule 2 (prefix + `\p{L}+`) | whitespace (never rule 4 — not in NOT_WS_L_N)
+ WSO => {
+ let a = i + char_len(b);
+ if a < end && (tags[a] & 0x0F) == LET {
+ letters(a)
+ } else {
+ ws(i)
}
- // Mark | Connector | Punct | SymOther | NumericOther | Control (all in NOT_WS_L_N):
- // rule 2 (prefix + `\p{L}+`) | rule 4
- MRK | CON | PUN | SYM | NMO | CTL => {
- let a = i + char_len(b);
- i = if a < end && (tags[a] & 0x0F) == LET {
+ }
+ // Newline: whitespace (rule 5 ends at the last newline)
+ NLN => ws(i),
+ // Apostrophe: rule 1 (contraction) | rule 2 (prefix + `\p{L}+`) | rule 4
+ APO => {
+ let adv = contraction(text, i); // rule 1: `'s 't 're 've 'm 'll 'd` (case-insensitive)
+ if adv > 0 {
+ i + adv
+ } else {
+ let a = i + 1; // Apostrophe is ASCII (0x27)
+ if a < end && (tags[a] & 0x0F) == LET {
letters(a)
} else {
other(i)
- }; // c ∈ NOT_WS_L_N ⇒ > i
+ } // c ∈ NOT_WS_L_N ⇒ > i
}
- // Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
- _ => i += char_len(b),
}
- // SAFETY: tokens partition the input, so `w < #tokens <= end < out.len()` (out ≥ text.len()+? ; callers size n+1).
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: i as u32,
- }
- };
- w += 1;
+ // Mark | Connector | Punct | SymOther | NumericOther | Control (all in NOT_WS_L_N):
+ // rule 2 (prefix + `\p{L}+`) | rule 4
+ MRK | CON | PUN | SYM | NMO | CTL => {
+ let a = i + char_len(b);
+ if a < end && (tags[a] & 0x0F) == LET {
+ letters(a)
+ } else {
+ other(i)
+ } // c ∈ NOT_WS_L_N ⇒ > i
+ }
+ // Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
+ _ => i + char_len(b),
+ }
+}
+
+/// The scan under [`fsm_cl100k_cap`]: hands each token to `emit` the moment it is cut,
+/// so a caller can consume tokens in place instead of collecting a span buffer first.
+pub fn scan_cl100k_cap(text: &[u8], tags: &[u8], digit_cap: usize, mut emit: impl FnMut(Span)) {
+ debug_assert!(tags.len() >= text.len());
+ let end = text.len();
+ // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
+ // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
+ let tags = &tags[..end];
+ let mut i = 0;
+ while i < end {
+ let e = advance_cl100k_cap(text, tags, i, end, digit_cap);
+ emit(Span {
+ start: i as u32,
+ end: e as u32,
+ });
+ i = e;
}
- w
}
diff --git a/tokenizers/atomsplit/src/fsm/deepseek.rs b/tokenizers/atomsplit/src/fsm/deepseek.rs
index f54afb8fa..b130ebf0c 100644
--- a/tokenizers/atomsplit/src/fsm/deepseek.rs
+++ b/tokenizers/atomsplit/src/fsm/deepseek.rs
@@ -39,81 +39,187 @@ fn ds_is_cjk_at(text: &[u8], p: usize) -> bool {
/// `\w` but categorically `\p{S}`) take the `[\p{P}\p{S}]` path, not the letter run.
#[must_use]
pub fn fsm_deepseek(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
- debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
- // Leading-atom values as `const` → the `match` is a dense jump table (see `cl100k`). The Split
- // precedence (digits → CJK → big-regex alts) is preserved because the atom partition is disjoint.
- // `Mark` refined as an Other_Alphabetic symbol (Ⓘ …): coarse `LETTER_MARK`, but categorically `\p{S}`
- // — excluded from `[\p{L}\p{M}]`, routed to the `[\p{P}\p{S}]+` run instead (see `punct`).
- let end = text.len();
- // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
- // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
- let tags = &tags[..end];
- // maximal `[\p{L}\p{M}]+` run from `a`, stopping at CJK-range chars (Split-2 took those), ZWJ/ZWNJ
- // (not `\p{L}∪\p{M}` — see `ds_breaks`), and Other_Alphabetic symbols (`ASM`, categorically `\p{S}`).
- // BYTE-wise (`p += 1`, continuation bytes stay in-run, `ds_breaks` only fires at a lead) — the
- // `char_len`-per-char form was ~2× slower (see `run_end`'s note). Hot inner loop of the latin path.
- let letter_run = |a: usize| -> usize {
- let mut p = a;
- // ZWJ/ASM are now tags (no text peek); only the CJK-range exclusion still peeks text.
- while p < end {
- let t = tags[p];
- if t == CONT
- || (in_mask(t, mask::LETTER_MARK) && t != ASM && t != ZWJ && !ds_is_cjk_at(text, p))
- {
- p += 1;
- } else {
- break;
- }
- }
- p
- };
- // is `text[a]` the start of a deepseek letter/mark char (alt-2 run body / space-prefix target)?
- let is_lm = |a: usize| {
- a < end
- && in_mask(tags[a], mask::LETTER_MARK)
- && tags[a] != ASM
- && tags[a] != ZWJ
- && !ds_is_cjk_at(text, a)
- };
- // Split-3 alt-3 tail `[\p{P}\p{S}]+[\r\n]*` from `sp0` (a leading space is already consumed); `sp0`
- // if there is no punct/sym run there. STOPS at CJK-range chars — Split-1 isolated those, so a CJK
- // punct (・) is never merged into a non-CJK punct run (`!・` → `!`, `・`, not `!・`).
- let punct = |sp0: usize| -> usize {
- let mut p = sp0;
- while p < end
- && (in_mask(tags[p], mask::PUNCT_SYM) || tags[p] == ASM)
- && !ds_is_cjk_at(text, p)
+ debug_assert!(out.len() >= text.len());
+ let mut w = 0usize;
+ scan_deepseek(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// Maximal `[\p{L}\p{M}]+` run from `a`, stopping at CJK-range chars (Split-2 took those),
+/// ZWJ/ZWNJ (not `\p{L}∪\p{M}`), and Other_Alphabetic symbols (`ASM`, categorically `\p{S}`).
+/// BYTE-wise (`p += 1`, continuation bytes stay in-run) — the `char_len`-per-char form was ~2×
+/// slower (see `run_end`'s note). Hot inner loop of the latin path.
+#[inline(always)]
+fn letter_run(text: &[u8], tags: &[u8], a: usize, end: usize) -> usize {
+ let mut p = a;
+ // ZWJ/ASM are tags (no text peek); only the CJK-range exclusion still peeks text.
+ while p < end {
+ let t = tags[p];
+ if t == CONT
+ || (in_mask(t, mask::LETTER_MARK) && t != ASM && t != ZWJ && !ds_is_cjk_at(text, p))
{
+ p += 1;
+ } else {
+ break;
+ }
+ }
+ p
+}
+
+/// Is `text[a]` the start of a deepseek letter/mark char (alt-2 run body / space-prefix target)?
+#[inline(always)]
+fn is_lm(text: &[u8], tags: &[u8], a: usize, end: usize) -> bool {
+ a < end
+ && in_mask(tags[a], mask::LETTER_MARK)
+ && tags[a] != ASM
+ && tags[a] != ZWJ
+ && !ds_is_cjk_at(text, a)
+}
+
+/// Split-3 alt-3 tail `[\p{P}\p{S}]+[\r\n]*` from `sp0` (a leading space is already consumed);
+/// `sp0` if there is no punct/sym run there. STOPS at CJK-range chars — Split-1 isolated those,
+/// so a CJK punct (・) is never merged into a non-CJK punct run (`!・` → `!`, `・`, not `!・`).
+#[inline(always)]
+fn punct(text: &[u8], tags: &[u8], sp0: usize, end: usize) -> usize {
+ let mut p = sp0;
+ while p < end && (in_mask(tags[p], mask::PUNCT_SYM) || tags[p] == ASM) && !ds_is_cjk_at(text, p)
+ {
+ p += char_len(text[p]);
+ }
+ if p > sp0 {
+ while p < end && tags[p] == NLN {
p += char_len(text[p]);
}
- if p > sp0 {
- while p < end && tags[p] == NLN {
+ }
+ p
+}
+
+/// Split-3 alts d/e/f (whitespace). Unlike cl100k: a ws run FOLLOWED BY a digit/CJK is its own
+/// Sequence piece (Split-1/2 isolated the next match) → `\s+(?!\S)` takes the WHOLE run; only a
+/// following letter/punct (same Split-3 piece) leaves the last ws char for its ` ?`/`[^…]?`
+/// prefix.
+#[inline(always)]
+fn ds_ws(text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ let re = run_end(tags, i, end, mask::WS);
+ let next_isolated = re < end && (in_mask(tags[re], mask::NUMBER) || ds_is_cjk_at(text, re));
+ if let Some(r) = text[i..re].iter().rposition(|&x| x == 0x0A || x == 0x0D) {
+ i + r + 1
+ } else if re == end || next_isolated {
+ re // whole ws run is one token
+ } else {
+ let mut last = re - 1;
+ while last > i && text[last] & 0xC0 == 0x80 {
+ last -= 1;
+ }
+ if last > i { last } else { re }
+ }
+}
+
+/// End of the token starting at `i` (`i < end`, `i` on a token boundary): one rule dispatch of
+/// the deepseek Sequence. The two multi-emit paths of [`scan_deepseek`] decompose per token: a
+/// gap run followed by letters ends at its LAST gap char (the next dispatch, on that char,
+/// takes the prefix-plus-letters path), and a CJK run is one same-kind sub-run per call. The
+/// masked scanner re-derives tokens with this where its batch masks are not trustworthy.
+#[inline(always)]
+pub(super) fn advance_deepseek(text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ if ds_is_cjk_at(text, i) {
+ let is_letter = in_mask(tags[i], mask::LETTER_MARK);
+ let mut p = i + 3; // CJK-range chars are all 3-byte (leads E3..E9)
+ while p < end && ds_is_cjk_at(text, p) && in_mask(tags[p], mask::LETTER_MARK) == is_letter {
+ p += 3;
+ }
+ return p;
+ }
+ if matches!(tags[i] & 0x0F, NMO | CTL) || tags[i] == ZWJ {
+ let (mut p, mut last) = (i, i);
+ while p < end && (matches!(tags[p] & 0x0F, NMO | CTL) || tags[p] == ZWJ) {
+ last = p;
+ p += char_len(text[p]);
+ }
+ return if is_lm(text, tags, p, end) {
+ if last > i {
+ last // gap sans the prefix char
+ } else {
+ letter_run(text, tags, p, end) // prefix char + `[\p{L}\p{M}]+`
+ }
+ } else {
+ p
+ };
+ }
+ let b = text[i];
+ match tags[i] & 0x0F {
+ NW | NO => {
+ let (mut p, mut cnt) = (i, 0);
+ while p < end && cnt < 3 && in_mask(tags[p], mask::NUMBER) {
p += char_len(text[p]);
+ cnt += 1;
}
+ p
}
- p
- };
- // Split-3 alts d/e/f (whitespace). Unlike cl100k: a ws run FOLLOWED BY a digit/CJK is its own
- // Sequence piece (Split-1/2 isolated the next match) → `\s+(?!\S)` takes the WHOLE run; only a
- // following letter/punct (same Split-3 piece) leaves the last ws char for its ` ?`/`[^…]?` prefix.
- let ws = |i: usize| -> usize {
- let re = run_end(tags, i, end, mask::WS);
- let next_isolated = re < end && (in_mask(tags[re], mask::NUMBER) || ds_is_cjk_at(text, re));
- if let Some(r) = text[i..re].iter().rposition(|&x| x == 0x0A || x == 0x0D) {
- i + r + 1
- } else if re == end || next_isolated {
- re // whole ws run is one token
- } else {
- let mut last = re - 1;
- while last > i && text[last] & 0xC0 == 0x80 {
- last -= 1;
+ LET | MRK => {
+ if tags[i] == ASM {
+ punct(text, tags, i, end)
+ } else {
+ letter_run(text, tags, i, end)
+ }
+ }
+ SPC => {
+ let a = i + 1; // Space is ASCII (0x20)
+ if is_lm(text, tags, a, end) {
+ letter_run(text, tags, a, end)
+ } else if a < end && ds_is_cjk_at(text, a) {
+ ds_ws(text, tags, i, end)
+ } else {
+ let p = punct(text, tags, a, end);
+ if p > a { p } else { ds_ws(text, tags, i, end) }
+ }
+ }
+ WSO => {
+ let a = i + char_len(b);
+ if is_lm(text, tags, a, end) {
+ letter_run(text, tags, a, end)
+ } else {
+ ds_ws(text, tags, i, end)
}
- if last > i { last } else { re }
}
- };
+ NLN => ds_ws(text, tags, i, end),
+ CON | PUN | APO | SYM => {
+ if b.is_ascii_punctuation() && i + 1 < end && text[i + 1].is_ascii_alphabetic() {
+ let mut p = i + 1;
+ while p < end && text[p].is_ascii_alphabetic() {
+ p += 1;
+ }
+ p
+ } else {
+ punct(text, tags, i, end) // c ∈ PUNCT_SYM ⇒ > i
+ }
+ }
+ _ => i + char_len(b),
+ }
+}
+
+/// The scan under [`fsm_deepseek`]: hands each token to `emit` the moment it is cut,
+/// so a caller can consume tokens in place instead of collecting a span buffer first.
+pub fn scan_deepseek(text: &[u8], tags: &[u8], mut emit: impl FnMut(Span)) {
+ debug_assert!(tags.len() >= text.len());
+ // Leading-atom values as `const` → the `match` is a dense jump table (see `cl100k`). The Split
+ // precedence (digits → CJK → big-regex alts) is preserved because the atom partition is disjoint.
+ // `Mark` refined as an Other_Alphabetic symbol (Ⓘ …): coarse `LETTER_MARK`, but categorically `\p{S}`
+ // — excluded from `[\p{L}\p{M}]`, routed to the `[\p{P}\p{S}]+` run instead (see `punct`).
+ let end = text.len();
+ // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
+ // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
+ let tags = &tags[..end];
+ let letter_run = |a: usize| letter_run(text, tags, a, end);
+ let is_lm = |a: usize| is_lm(text, tags, a, end);
+ let punct = |sp0: usize| punct(text, tags, sp0, end);
+ let ws = |i: usize| ds_ws(text, tags, i, end);
let mut i = 0;
- let mut w = 0usize;
while i < end {
let start = i;
let b = text[i];
@@ -129,13 +235,10 @@ pub fn fsm_deepseek(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
{
p += 3;
}
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: p as u32,
- }
- };
- w += 1;
+ emit(Span {
+ start: start as u32,
+ end: p as u32,
+ });
i = p;
continue;
}
@@ -150,31 +253,22 @@ pub fn fsm_deepseek(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
}
if is_lm(p) {
if last > i {
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: last as u32,
- }
- }; // gap sans prefix char
- w += 1;
+ emit(Span {
+ start: start as u32,
+ end: last as u32,
+ }); // gap sans prefix char
}
let e = letter_run(p);
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: last as u32,
- end: e as u32,
- }
- }; // prefix char + `[\p{L}\p{M}]+`
- w += 1;
+ emit(Span {
+ start: last as u32,
+ end: e as u32,
+ }); // prefix char + `[\p{L}\p{M}]+`
i = e;
} else {
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: p as u32,
- }
- }; // whole gap run is one piece
- w += 1;
+ emit(Span {
+ start: start as u32,
+ end: p as u32,
+ }); // whole gap run is one piece
i = p;
}
continue;
@@ -234,14 +328,9 @@ pub fn fsm_deepseek(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
// Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
_ => i += char_len(b),
}
- // SAFETY: tokens partition the input, so `w < #tokens <= end < out.len()` (out ≥ text.len()+? ; callers size n+1).
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: i as u32,
- }
- };
- w += 1;
+ emit(Span {
+ start: start as u32,
+ end: i as u32,
+ });
}
- w
}
diff --git a/tokenizers/atomsplit/src/fsm/masked.rs b/tokenizers/atomsplit/src/fsm/masked.rs
new file mode 100644
index 000000000..aa6f1795a
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked.rs
@@ -0,0 +1,440 @@
+//! Boundary-mask scanners: the SIMD replacement for the per-run scalar walk of the regex-shaped
+//! FSMs (one scheme module per regex family, sharing this walker).
+//!
+//! A scalar FSM advances one token at a time: a rule dispatch, then a per-byte run scan. A
+//! masked scanner instead classifies 64 bytes at once into per-class bitmasks (bit k set =
+//! "byte k is a letter") and computes every token start in the batch with a few dozen
+//! branch-free u64 operations. That works because these regexes are class-run languages with
+//! one or two chars of context: a token starts exactly at a class change that is not an
+//! absorbed prefix, at a whitespace-run edge, or at a contraction edge, and each of those
+//! conditions is a shifted-mask expression. The boundary algebra is transcribed per scheme from
+//! gigatoken's `src/pretokenize/fast/` scanners (MIT), with one structural difference:
+//! gigatoken classifies raw bytes in-batch and falls back to a per-char loop whenever a batch
+//! contains a non-ASCII byte, while these scanners build their masks from the
+//! [`crate::classify`] tag stream, which is already SIMD and covers all of Unicode. A
+//! continuation byte's tag is [`Atom::Cont`]; the fill step gives it its char's class, so byte
+//! adjacency equals char adjacency and the same algebra applies to non-ASCII batches.
+//!
+//! # Trust boundaries
+//!
+//! Each scheme's `batch_masks` returns `(boundary, bad)` for one 64-byte batch. A `boundary`
+//! bit is a proven token start. A `bad` bit means the algebra cannot decide that byte (batch-
+//! edge straddles, char-counted rules over multi-byte chars, run-contextual classes; each
+//! scheme documents its own list). `boundary & bad` is always 0, and no span is emitted across
+//! a bad zone: the walker re-derives tokens there with the scheme's scalar `advance`, the same
+//! rules the plain scan runs, and resumes on masks at the next batch. The scalar scans stay the
+//! ground truth; the `masked_*` tests (tests/fsm.rs) pin byte-exactness at every batch-edge
+//! offset.
+//!
+//! # Targets
+//!
+//! The per-target work is confined to [`block`] (64-byte predicate masks): aarch64 NEON,
+//! x86_64 SSE2 and wasm32 simd128 have kernels; every other target delegates the
+//! `scan_*_masked` entry points to the scalar scans, so correctness never depends on a SIMD
+//! path being present.
+//!
+//! Inputs must be well-formed UTF-8 (the crate-level contract); the fill step relies on
+//! continuation runs of at most 3 bytes.
+
+use super::*;
+
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod block;
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod byte_level;
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod cl100k;
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod deepseek;
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod o200k;
+
+/// [`fsm_byte_level`] over the masked scanner: writes spans into `out` (len >= `text.len()`)
+/// and returns the count.
+#[must_use]
+pub fn fsm_byte_level_masked(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
+ debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
+ let mut w = 0usize;
+ scan_byte_level_masked(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// The masked twin of [`scan_byte_level`]: same tokens, same emit order. Targets without a
+/// [`block`] kernel delegate to the scalar scan.
+pub fn scan_byte_level_masked(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ walk(&byte_level::ByteLevelMasked, text, tags, emit);
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ scan_byte_level(text, tags, emit);
+}
+
+/// [`fsm_cl100k_cap`] over the masked scanner: writes spans into `out` (len >= `text.len()`)
+/// and returns the count.
+#[must_use]
+pub fn fsm_cl100k_cap_masked(
+ text: &[u8],
+ tags: &[u8],
+ out: &mut [Span],
+ digit_cap: usize,
+) -> usize {
+ debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
+ let mut w = 0usize;
+ scan_cl100k_cap_masked(text, tags, digit_cap, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// The masked twin of [`scan_cl100k_cap`]: same tokens, same emit order. Digit caps other than
+/// 1, 3 and `usize::MAX` (none ship today) and targets without a [`block`] kernel delegate to
+/// the scalar scan.
+pub fn scan_cl100k_cap_masked(text: &[u8], tags: &[u8], digit_cap: usize, emit: impl FnMut(Span)) {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ {
+ if matches!(digit_cap, 1 | 3 | usize::MAX) {
+ walk(&cl100k::Cl100kMasked { digit_cap }, text, tags, emit);
+ } else {
+ scan_cl100k_cap(text, tags, digit_cap, emit);
+ }
+ }
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ scan_cl100k_cap(text, tags, digit_cap, emit);
+}
+
+/// [`fsm_o200k`] over the masked scanner: writes spans into `out` (len >= `text.len()`) and
+/// returns the count.
+#[must_use]
+pub fn fsm_o200k_masked(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
+ debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
+ let mut w = 0usize;
+ scan_o200k_masked(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// [`fsm_tekken`] over the masked scanner: writes spans into `out` (len >= `text.len()`) and
+/// returns the count.
+#[must_use]
+pub fn fsm_tekken_masked(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
+ debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
+ let mut w = 0usize;
+ scan_tekken_masked(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// The masked twin of [`scan_o200k`]: same tokens, same emit order. Targets without a
+/// [`block`] kernel delegate to the scalar scan.
+pub fn scan_o200k_masked(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ walk(&o200k::O200kMasked::, text, tags, emit);
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ scan_o200k(text, tags, emit);
+}
+
+/// The masked twin of [`scan_tekken`]: same tokens, same emit order. Targets without a
+/// [`block`] kernel delegate to the scalar scan.
+pub fn scan_tekken_masked(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ walk(&o200k::O200kMasked::, text, tags, emit);
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ scan_tekken(text, tags, emit);
+}
+
+/// [`fsm_deepseek`] over the masked scanner: writes spans into `out` (len >= `text.len()`) and
+/// returns the count.
+#[must_use]
+pub fn fsm_deepseek_masked(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
+ debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
+ let mut w = 0usize;
+ scan_deepseek_masked(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// The masked twin of [`scan_deepseek`]: same tokens, same emit order. Targets without a
+/// [`block`] kernel delegate to the scalar scan.
+pub fn scan_deepseek_masked(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ walk(&deepseek::DeepSeekMasked, text, tags, emit);
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ scan_deepseek(text, tags, emit);
+}
+
+/// One masked scheme: the batch classifier and the scalar rules the walker falls back on.
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+trait MaskedFsm {
+ /// `(boundary, bad)` for `text[scan..scan + 64]`: `boundary` bit k = proven token start at
+ /// `scan + k`, `bad` bit k = the walker must re-derive byte `scan + k` with
+ /// [`Self::advance`]. `boundary & bad` must be 0. Callers guarantee `scan + 64 <
+ /// text.len()` (one lookahead tag is readable).
+ fn batch_masks(&self, text: &[u8], tags: &[u8], scan: usize) -> (u64, u64);
+
+ /// Scalar ground truth: end of the token starting at `i` (`i < end`, `i` on a token
+ /// boundary).
+ fn advance(&self, text: &[u8], tags: &[u8], i: usize, end: usize) -> usize;
+}
+
+/// The batch walker: consume proven token starts batch by batch, re-derive bad zones with the
+/// scheme's scalar rules. `pending` is always the start of the open (not yet emitted) token.
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+fn walk(scheme: &impl MaskedFsm, text: &[u8], tags: &[u8], mut emit: impl FnMut(Span)) {
+ let end = text.len();
+ let tags = &tags[..end];
+ let mut pending = 0usize;
+ let mut scan = 0usize;
+ // Each batch reads the lookahead tag at scan + 64 (the `\s+(?!\S)` bit-63 rules), so the
+ // last <= 64 bytes always go through the scalar tail below.
+ while scan + 64 < end {
+ if scan + 64 <= pending {
+ // A scalar re-derivation ran past this whole batch.
+ scan += 64;
+ continue;
+ }
+ let (boundary, mut bad) = scheme.batch_masks(text, tags, scan);
+ let mut m = boundary;
+ if pending > scan {
+ // Bits at or below `pending` are inside or at the start of the open token; they are
+ // stale leftovers of a scalar re-derivation that entered this batch.
+ let done = pending - scan + 1;
+ m = if done >= 64 {
+ 0
+ } else {
+ m & (u64::MAX << done)
+ };
+ }
+ // Interleave: consume proven starts below the next bad zone, re-derive the zone with
+ // the scalar rules, repeat. A span must never be emitted across an unresolved zone, so
+ // starts above one cannot pair with `pending` from below it.
+ loop {
+ let zone = if bad == 0 {
+ 64
+ } else {
+ bad.trailing_zeros() as usize
+ };
+ while m != 0 {
+ let j = m.trailing_zeros() as usize;
+ if j >= zone {
+ break;
+ }
+ let p = scan + j;
+ if p > pending {
+ emit(Span {
+ start: pending as u32,
+ end: p as u32,
+ });
+ pending = p;
+ }
+ m &= m - 1;
+ }
+ if bad == 0 {
+ break;
+ }
+ // The zone's contiguous extent; the scalar rules resolve through its end (their
+ // tokens may overshoot it, or the whole batch — later bits fall to the guards).
+ let zone_end = zone + ((!(bad >> zone)).trailing_zeros() as usize).min(64 - zone);
+ while pending < scan + zone_end {
+ let e = scheme.advance(text, tags, pending, end);
+ emit(Span {
+ start: pending as u32,
+ end: e as u32,
+ });
+ pending = e;
+ }
+ bad = if zone_end >= 64 {
+ 0
+ } else {
+ bad & (u64::MAX << zone_end)
+ };
+ }
+ scan += 64;
+ }
+ while pending < end {
+ let e = scheme.advance(text, tags, pending, end);
+ emit(Span {
+ start: pending as u32,
+ end: e as u32,
+ });
+ pending = e;
+ }
+}
+
+// ── shared u64 helpers (platform-independent; the scheme modules compose these) ────────────────
+
+/// The two continuation-run masks the fill steps need: `c2` bit k = bytes k and k-1 are both
+/// continuations, `c3` = bytes k, k-1, k-2 all are.
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn cont_runs(c: u64) -> (u64, u64) {
+ let c2 = c & (c << 1);
+ (c2, c2 & (c << 2))
+}
+
+/// Fill: every continuation byte of a char whose lead is in `m` joins `m`, so byte adjacency
+/// equals char adjacency (UTF-8 chars are at most 4 bytes: 3 hops cover every continuation).
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn fill(m: u64, c: u64, c2: u64, c3: u64) -> u64 {
+ m | ((m << 1) & c) | ((m << 2) & c2) | ((m << 3) & c3)
+}
+
+/// Smear `seed` upward (toward higher bits) through contiguous set bits of `within`, in log
+/// steps (via gigatoken's `cl100k_family.rs`, MIT).
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn smear_up(seed: u64, within: u64) -> u64 {
+ let mut a = seed;
+ let mut m = within;
+ let mut sh = 1u32;
+ while sh < 64 {
+ a |= (a << sh) & m;
+ m &= m << sh;
+ sh <<= 1;
+ }
+ a
+}
+
+/// Token-start bits inside ASCII digit runs for `\p{N}{1,3}`: each run splits into 3-char
+/// tokens, so boundaries sit at run start + 3k (via gigatoken's `mask.rs`, MIT). Callers keep
+/// multi-byte digit chars out of `d`: their grouping is char-counted, and byte hops would
+/// misphase it.
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn digit_run_splits3(d: u64) -> u64 {
+ let mut b = d & !(d << 1); // run starts
+ // A start at p re-arms at p+3 while the run continues: hop condition c = "p..p+3 all
+ // digits". Log-doubling covers 64-bit runs in 5 steps.
+ let mut c = d & (d >> 1) & (d >> 2) & (d >> 3);
+ let mut sh = 3u32;
+ while sh < 64 {
+ b |= (b & c) << sh;
+ c &= c >> sh;
+ sh <<= 1;
+ }
+ b
+}
+
+/// `x << n`, saturating to 0 at `n >= 64` (`trailing_zeros` on an empty mask yields 64).
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn shl_sat(x: u64, n: u32) -> u64 {
+ if n >= 64 { 0 } else { x << n }
+}
+
+/// The lead of the char containing `tags[p]`: walk back over continuation tags (at most 3 on
+/// well-formed UTF-8; `p` itself may already be the lead).
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+#[inline(always)]
+fn char_lead(tags: &[u8], mut p: usize) -> usize {
+ while p > 0 && tags[p] & 0x0F == CONT {
+ p -= 1;
+ }
+ p
+}
diff --git a/tokenizers/atomsplit/src/fsm/masked/block.rs b/tokenizers/atomsplit/src/fsm/masked/block.rs
new file mode 100644
index 000000000..3e06e7c89
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked/block.rs
@@ -0,0 +1,427 @@
+//! Per-target 64-byte block classifiers for the masked scanners. A block loads 64 consecutive
+//! bytes into four SIMD registers once; each method then answers one per-byte predicate for the
+//! whole block as a u64 bitmask (bit k = byte k passes). Everything downstream of these masks is
+//! platform-independent u64 arithmetic in the scheme modules, so this file is the entire
+//! per-target surface.
+//!
+//! Three targets have a kernel: aarch64 (NEON, baseline), x86_64 (SSE2, baseline) and wasm32
+//! with `simd128`. Any other target never reaches this module — the `scan_*_masked` entry
+//! points delegate to the scalar scans there.
+
+#![allow(dead_code)] // arch-gated: each build compiles one target's kernel, and schemes land
+// one by one, so some predicates are unused until their scheme arrives.
+
+// ── aarch64 / NEON ──────────────────────────────────────────────────────────────────────────────
+#[cfg(target_arch = "aarch64")]
+pub(crate) use neon::Block;
+
+#[cfg(target_arch = "aarch64")]
+mod neon {
+ use core::arch::aarch64::*;
+
+ /// 64 bytes in four NEON registers. `tag` methods fold the refinement nibble away first
+ /// (`& 0x0F`), `full`/byte methods compare the raw byte.
+ pub(crate) struct Block {
+ v: [uint8x16_t; 4],
+ }
+
+ /// simdjson's arm64 movemask: 4 mask vectors (64 lanes of 0x00/0xFF) to one u64, bit i =
+ /// lane i. The 4-`addp` reduction is pinned as asm (via gigatoken's `mask.rs`, MIT): written
+ /// with `vpaddq_u8`, LLVM rewrites the pairwise adds into uzp1/uzp2/orr triples and the call
+ /// grows from 9 to 17 vector ops.
+ #[inline(always)]
+ unsafe fn movemask64(v0: uint8x16_t, v1: uint8x16_t, v2: uint8x16_t, v3: uint8x16_t) -> u64 {
+ // SAFETY: pure NEON register arithmetic, no memory access beyond the 16-byte constant.
+ unsafe {
+ const W: [u8; 16] = [1, 2, 4, 8, 16, 32, 64, 128, 1, 2, 4, 8, 16, 32, 64, 128];
+ let w = vld1q_u8(W.as_ptr());
+ let mut a0 = vandq_u8(v0, w);
+ let a1 = vandq_u8(v1, w);
+ let a2 = vandq_u8(v2, w);
+ let a3 = vandq_u8(v3, w);
+ core::arch::asm!(
+ "addp {a0:v}.16b, {a0:v}.16b, {a1:v}.16b",
+ "addp {a2:v}.16b, {a2:v}.16b, {a3:v}.16b",
+ "addp {a0:v}.16b, {a0:v}.16b, {a2:v}.16b",
+ "addp {a0:v}.16b, {a0:v}.16b, {a0:v}.16b",
+ a0 = inout(vreg) a0,
+ a1 = in(vreg) a1,
+ a2 = inout(vreg) a2 => _,
+ a3 = in(vreg) a3,
+ options(pure, nomem, nostack, preserves_flags),
+ );
+ vgetq_lane_u64::<0>(vreinterpretq_u64_u8(a0))
+ }
+ }
+
+ impl Block {
+ /// Load `bytes[at..at + 64]`.
+ ///
+ /// # Safety
+ ///
+ /// `at + 64 <= bytes.len()`. NEON loads are alignment-free.
+ #[inline(always)]
+ pub(crate) unsafe fn load(bytes: &[u8], at: usize) -> Self {
+ debug_assert!(at + 64 <= bytes.len());
+ // SAFETY: the fn contract puts all four 16-byte loads in bounds.
+ unsafe {
+ let p = bytes.as_ptr().add(at);
+ Self {
+ v: [
+ vld1q_u8(p),
+ vld1q_u8(p.add(16)),
+ vld1q_u8(p.add(32)),
+ vld1q_u8(p.add(48)),
+ ],
+ }
+ }
+ }
+
+ #[inline(always)]
+ fn mask(&self, f: impl Fn(uint8x16_t) -> uint8x16_t) -> u64 {
+ // SAFETY: register arithmetic only.
+ unsafe { movemask64(f(self.v[0]), f(self.v[1]), f(self.v[2]), f(self.v[3])) }
+ }
+
+ #[inline(always)]
+ fn any(&self, f: impl Fn(uint8x16_t) -> uint8x16_t) -> bool {
+ // SAFETY: register arithmetic only.
+ unsafe {
+ let o = vorrq_u8(
+ vorrq_u8(f(self.v[0]), f(self.v[1])),
+ vorrq_u8(f(self.v[2]), f(self.v[3])),
+ );
+ vmaxvq_u8(o) != 0
+ }
+ }
+
+ /// Bytes whose low nibble equals `k`.
+ #[inline(always)]
+ pub(crate) fn eq_tag(&self, k: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe { vceqq_u8(vandq_u8(v, vdupq_n_u8(0x0F)), vdupq_n_u8(k)) })
+ }
+
+ /// Bytes in `lo..=lo + span` (the raw byte — text blocks).
+ #[inline(always)]
+ pub(crate) fn range_full(&self, lo: u8, span: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe { vcleq_u8(vsubq_u8(v, vdupq_n_u8(lo)), vdupq_n_u8(span)) })
+ }
+
+ /// Bytes whose low nibble is in `lo..=lo + span`.
+ #[inline(always)]
+ pub(crate) fn range_tag(&self, lo: u8, span: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ vcleq_u8(
+ vsubq_u8(vandq_u8(v, vdupq_n_u8(0x0F)), vdupq_n_u8(lo)),
+ vdupq_n_u8(span),
+ )
+ })
+ }
+
+ /// Bytes equal to `k` (the raw byte — refined tags, or text bytes).
+ #[inline(always)]
+ pub(crate) fn eq_full(&self, k: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe { vceqq_u8(v, vdupq_n_u8(k)) })
+ }
+
+ /// Any byte with low nibble in `lo..=lo + span`? (Cheaper than `range_tag` when only
+ /// presence matters — no movemask.)
+ #[inline(always)]
+ pub(crate) fn any_range_tag(&self, lo: u8, span: u8) -> bool {
+ // SAFETY: register arithmetic only.
+ self.any(|v| unsafe {
+ vcleq_u8(
+ vsubq_u8(vandq_u8(v, vdupq_n_u8(0x0F)), vdupq_n_u8(lo)),
+ vdupq_n_u8(span),
+ )
+ })
+ }
+
+ /// Any byte equal to `k`?
+ #[inline(always)]
+ pub(crate) fn any_eq_full(&self, k: u8) -> bool {
+ // SAFETY: register arithmetic only.
+ self.any(|v| unsafe { vceqq_u8(v, vdupq_n_u8(k)) })
+ }
+
+ /// ASCII letters `[A-Za-z]` (text blocks).
+ #[inline(always)]
+ pub(crate) fn ascii_alpha(&self) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ vcleq_u8(
+ vsubq_u8(vorrq_u8(v, vdupq_n_u8(0x20)), vdupq_n_u8(b'a')),
+ vdupq_n_u8(25),
+ )
+ })
+ }
+
+ /// ASCII punctuation (the four `is_ascii_punctuation` ranges, text blocks).
+ #[inline(always)]
+ pub(crate) fn ascii_punct(&self) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ let r = |v: uint8x16_t, lo: u8, span: u8| {
+ vcleq_u8(vsubq_u8(v, vdupq_n_u8(lo)), vdupq_n_u8(span))
+ };
+ vorrq_u8(
+ vorrq_u8(r(v, 0x21, 0x0E), r(v, 0x3A, 0x06)),
+ vorrq_u8(r(v, 0x5B, 0x05), r(v, 0x7B, 0x03)),
+ )
+ })
+ }
+ }
+}
+
+// ── x86_64 / SSE2 ──────────────────────────────────────────────────────────────────────────────
+#[cfg(target_arch = "x86_64")]
+pub(crate) use sse2::Block;
+
+#[cfg(target_arch = "x86_64")]
+mod sse2 {
+ use core::arch::x86_64::*;
+
+ /// 64 bytes in four SSE2 registers; `pmovmskb` is the native movemask, 16 bits per register.
+ /// SSE2 is baseline on x86_64, so no runtime detection is needed. (SSE2 has no unsigned
+ /// byte compare: `x <= span` is done as `max(x, span) == span`.)
+ pub(crate) struct Block {
+ v: [__m128i; 4],
+ }
+
+ #[inline(always)]
+ fn movemask64(v0: __m128i, v1: __m128i, v2: __m128i, v3: __m128i) -> u64 {
+ // SAFETY: register arithmetic only; SSE2 is baseline on x86_64.
+ unsafe {
+ (_mm_movemask_epi8(v0) as u16 as u64)
+ | ((_mm_movemask_epi8(v1) as u16 as u64) << 16)
+ | ((_mm_movemask_epi8(v2) as u16 as u64) << 32)
+ | ((_mm_movemask_epi8(v3) as u16 as u64) << 48)
+ }
+ }
+
+ #[inline(always)]
+ fn le(x: __m128i, span: u8) -> __m128i {
+ // SAFETY: register arithmetic only.
+ unsafe {
+ let s = _mm_set1_epi8(span as i8);
+ _mm_cmpeq_epi8(_mm_max_epu8(x, s), s)
+ }
+ }
+
+ impl Block {
+ /// Load `bytes[at..at + 64]`.
+ ///
+ /// # Safety
+ ///
+ /// `at + 64 <= bytes.len()`. Unaligned loads (`loadu`).
+ #[inline(always)]
+ pub(crate) unsafe fn load(bytes: &[u8], at: usize) -> Self {
+ debug_assert!(at + 64 <= bytes.len());
+ // SAFETY: the fn contract puts all four 16-byte loads in bounds.
+ unsafe {
+ let p = bytes.as_ptr().add(at);
+ Self {
+ v: [
+ _mm_loadu_si128(p.cast()),
+ _mm_loadu_si128(p.add(16).cast()),
+ _mm_loadu_si128(p.add(32).cast()),
+ _mm_loadu_si128(p.add(48).cast()),
+ ],
+ }
+ }
+ }
+
+ #[inline(always)]
+ fn mask(&self, f: impl Fn(__m128i) -> __m128i) -> u64 {
+ movemask64(f(self.v[0]), f(self.v[1]), f(self.v[2]), f(self.v[3]))
+ }
+
+ #[inline(always)]
+ pub(crate) fn eq_tag(&self, k: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ _mm_cmpeq_epi8(
+ _mm_and_si128(v, _mm_set1_epi8(0x0F)),
+ _mm_set1_epi8(k as i8),
+ )
+ })
+ }
+
+ #[inline(always)]
+ pub(crate) fn range_tag(&self, lo: u8, span: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ le(
+ _mm_sub_epi8(
+ _mm_and_si128(v, _mm_set1_epi8(0x0F)),
+ _mm_set1_epi8(lo as i8),
+ ),
+ span,
+ )
+ })
+ }
+
+ #[inline(always)]
+ pub(crate) fn range_full(&self, lo: u8, span: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe { le(_mm_sub_epi8(v, _mm_set1_epi8(lo as i8)), span) })
+ }
+
+ #[inline(always)]
+ pub(crate) fn eq_full(&self, k: u8) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe { _mm_cmpeq_epi8(v, _mm_set1_epi8(k as i8)) })
+ }
+
+ #[inline(always)]
+ pub(crate) fn any_range_tag(&self, lo: u8, span: u8) -> bool {
+ self.range_tag(lo, span) != 0
+ }
+
+ #[inline(always)]
+ pub(crate) fn any_eq_full(&self, k: u8) -> bool {
+ self.eq_full(k) != 0
+ }
+
+ #[inline(always)]
+ pub(crate) fn ascii_alpha(&self) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ le(
+ _mm_sub_epi8(
+ _mm_or_si128(v, _mm_set1_epi8(0x20)),
+ _mm_set1_epi8(b'a' as i8),
+ ),
+ 25,
+ )
+ })
+ }
+
+ #[inline(always)]
+ pub(crate) fn ascii_punct(&self) -> u64 {
+ // SAFETY: register arithmetic only.
+ self.mask(|v| unsafe {
+ let r = |v: __m128i, lo: u8, span: u8| {
+ le(_mm_sub_epi8(v, _mm_set1_epi8(lo as i8)), span)
+ };
+ _mm_or_si128(
+ _mm_or_si128(r(v, 0x21, 0x0E), r(v, 0x3A, 0x06)),
+ _mm_or_si128(r(v, 0x5B, 0x05), r(v, 0x7B, 0x03)),
+ )
+ })
+ }
+ }
+}
+
+// ── wasm32 / SIMD128 ────────────────────────────────────────────────────────────────────────────
+#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
+pub(crate) use wasm::Block;
+
+#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
+mod wasm {
+ use core::arch::wasm32::*;
+
+ /// 64 bytes in four v128 registers; `u8x16_bitmask` is the native movemask.
+ pub(crate) struct Block {
+ v: [v128; 4],
+ }
+
+ #[inline(always)]
+ fn movemask64(v0: v128, v1: v128, v2: v128, v3: v128) -> u64 {
+ (u8x16_bitmask(v0) as u64)
+ | ((u8x16_bitmask(v1) as u64) << 16)
+ | ((u8x16_bitmask(v2) as u64) << 32)
+ | ((u8x16_bitmask(v3) as u64) << 48)
+ }
+
+ impl Block {
+ /// Load `bytes[at..at + 64]`.
+ ///
+ /// # Safety
+ ///
+ /// `at + 64 <= bytes.len()`. `v128_load` is alignment-free.
+ #[inline(always)]
+ pub(crate) unsafe fn load(bytes: &[u8], at: usize) -> Self {
+ debug_assert!(at + 64 <= bytes.len());
+ // SAFETY: the fn contract puts all four 16-byte loads in bounds.
+ unsafe {
+ let p = bytes.as_ptr().add(at);
+ Self {
+ v: [
+ v128_load(p.cast()),
+ v128_load(p.add(16).cast()),
+ v128_load(p.add(32).cast()),
+ v128_load(p.add(48).cast()),
+ ],
+ }
+ }
+ }
+
+ #[inline(always)]
+ fn mask(&self, f: impl Fn(v128) -> v128) -> u64 {
+ movemask64(f(self.v[0]), f(self.v[1]), f(self.v[2]), f(self.v[3]))
+ }
+
+ #[inline(always)]
+ pub(crate) fn eq_tag(&self, k: u8) -> u64 {
+ self.mask(|v| u8x16_eq(v128_and(v, u8x16_splat(0x0F)), u8x16_splat(k)))
+ }
+
+ #[inline(always)]
+ pub(crate) fn range_tag(&self, lo: u8, span: u8) -> u64 {
+ self.mask(|v| {
+ u8x16_le(
+ u8x16_sub(v128_and(v, u8x16_splat(0x0F)), u8x16_splat(lo)),
+ u8x16_splat(span),
+ )
+ })
+ }
+
+ #[inline(always)]
+ pub(crate) fn range_full(&self, lo: u8, span: u8) -> u64 {
+ self.mask(|v| u8x16_le(u8x16_sub(v, u8x16_splat(lo)), u8x16_splat(span)))
+ }
+
+ #[inline(always)]
+ pub(crate) fn eq_full(&self, k: u8) -> u64 {
+ self.mask(|v| u8x16_eq(v, u8x16_splat(k)))
+ }
+
+ #[inline(always)]
+ pub(crate) fn any_range_tag(&self, lo: u8, span: u8) -> bool {
+ self.range_tag(lo, span) != 0
+ }
+
+ #[inline(always)]
+ pub(crate) fn any_eq_full(&self, k: u8) -> bool {
+ self.eq_full(k) != 0
+ }
+
+ #[inline(always)]
+ pub(crate) fn ascii_alpha(&self) -> u64 {
+ self.mask(|v| {
+ u8x16_le(
+ u8x16_sub(v128_or(v, u8x16_splat(0x20)), u8x16_splat(b'a')),
+ u8x16_splat(25),
+ )
+ })
+ }
+
+ #[inline(always)]
+ pub(crate) fn ascii_punct(&self) -> u64 {
+ self.mask(|v| {
+ let r = |v: v128, lo: u8, span: u8| {
+ u8x16_le(u8x16_sub(v, u8x16_splat(lo)), u8x16_splat(span))
+ };
+ v128_or(
+ v128_or(r(v, 0x21, 0x0E), r(v, 0x3A, 0x06)),
+ v128_or(r(v, 0x5B, 0x05), r(v, 0x7B, 0x03)),
+ )
+ })
+ }
+ }
+}
diff --git a/tokenizers/atomsplit/src/fsm/masked/byte_level.rs b/tokenizers/atomsplit/src/fsm/masked/byte_level.rs
new file mode 100644
index 000000000..900885434
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked/byte_level.rs
@@ -0,0 +1,146 @@
+//! Masked scheme for the byte-level (GPT-2) regex — the r50k boundary algebra (via gigatoken's
+//! `r50k.rs`, MIT) over tag-fed class masks.
+//!
+//! Every byte-level rule is local: a token starts exactly at a class change that is not a space
+//! prefix, at the first byte of a whitespace run, at the last whitespace byte before a
+//! non-whitespace (the `\s+(?!\S)` give-back), or at a contraction edge. Bad zones: a
+//! whitespace char straddling the batch edge (its give-back is per char, not per byte), an
+//! apostrophe too close to the edge for the contraction peek, a multi-byte whitespace char, or
+//! a `Sentinel`/`MultiByte` tag.
+
+use super::super::byte_level::advance_byte_level;
+use super::block::Block;
+use super::{MaskedFsm, char_lead, cont_runs, fill};
+use crate::fsm::{APO, CONT, LET, NLN, NO, NW, SPC, WSO, in_mask, mask};
+
+pub(super) struct ByteLevelMasked;
+
+impl MaskedFsm for ByteLevelMasked {
+ #[inline(always)]
+ fn batch_masks(&self, text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ batch_masks(text, tags, scan)
+ }
+
+ #[inline(always)]
+ fn advance(&self, text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ advance_byte_level(text, tags, i, end)
+ }
+}
+
+#[inline(always)]
+fn batch_masks(text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ debug_assert!(scan + 64 < tags.len() && tags.len() == text.len());
+ // SAFETY: `scan + 64 < tags.len()` (walker guarantee), the block's load contract.
+ let b = unsafe { Block::load(tags, scan) };
+ if b.any_range_tag(13, 1) {
+ // Sentinel / MultiByte: the scalar dispatch has a defensive arm for these; keep its
+ // behavior by refusing the whole batch.
+ return (0, u64::MAX);
+ }
+ let l0 = b.eq_tag(LET);
+ let d0 = b.range_tag(NW, 1);
+ let ws0 = b.range_tag(NLN, 2);
+ let s = b.eq_tag(SPC);
+ // Apostrophes and continuations only matter to the fixups below; skip their movemask when
+ // an any-test says the batch has none.
+ let ap = if b.any_eq_full(APO) {
+ b.eq_full(APO)
+ } else {
+ 0
+ };
+ let c = if b.any_eq_full(CONT) {
+ b.eq_full(CONT)
+ } else {
+ 0
+ };
+ let (mut l, mut d, mut ws) = (l0, d0, ws0);
+
+ // Carries: the classes of the byte just before the batch, which is the class of the char
+ // containing it.
+ let (pl, pd, pws, ps, po) = if scan == 0 {
+ (0u64, 0u64, 0u64, 0u64, 0u64)
+ } else {
+ match tags[char_lead(tags, scan - 1)] & 0x0F {
+ LET => (1, 0, 0, 0, 0),
+ NW | NO => (0, 1, 0, 0, 0),
+ SPC => (0, 0, 1, 1, 0),
+ NLN | WSO => (0, 0, 1, 0, 0),
+ _ => (0, 0, 0, 0, 1),
+ }
+ };
+
+ if c != 0 {
+ // A char straddling into the batch has its leading continuation bytes take the carry
+ // class ("other" needs no action: it is derived as the complement).
+ if c & 1 != 0 {
+ let lead_in = c & ((1u64 << (!c).trailing_zeros()) - 1);
+ l |= lead_in * pl;
+ d |= lead_in * pd;
+ ws |= lead_in * pws;
+ }
+ let (c2, c3) = cont_runs(c);
+ l = fill(l, c, c2, c3);
+ d = fill(d, c, c2, c3);
+ ws = fill(ws, c, c2, c3);
+ if ws & c != 0 {
+ // Multi-byte whitespace: the `\s+(?!\S)` give-back is one char, not one byte, and
+ // the algebra below works in bytes. Rare (NBSP and friends); scalar batch.
+ return (0, u64::MAX);
+ }
+ }
+ let o = !(l | d | ws);
+
+ // The r50k boundary algebra (gigatoken, MIT). A byte starts a token when it is not
+ // whitespace, does not continue a same-class run, and does not follow a space (the ` ?`
+ // prefix glues it to the space instead).
+ let cont_same = (l & ((l << 1) | pl)) | (d & ((d << 1) | pd)) | (o & ((o << 1) | po));
+ let after_sp = (s << 1) | ps;
+ let nb = !ws & !cont_same & !after_sp;
+
+ let mut bad = 0u64;
+
+ // Whitespace-run splits. `split_ok` = the last whitespace byte before a non-whitespace (the
+ // `\s+(?!\S)` give-back starts a token there); bit 63 needs the lookahead tag.
+ let mut split_ok = ws & (!ws >> 1);
+ let la = tags[scan + 64] & 0x0F;
+ if la == CONT {
+ // The char at the batch edge straddles out. Whitespace is the only class whose rules
+ // look at char ends, so only a whitespace lead poisons the tail.
+ let p = char_lead(tags, scan + 63);
+ if in_mask(tags[p], mask::WS) {
+ bad |= u64::MAX << (p - scan);
+ }
+ } else if !in_mask(la, mask::WS) {
+ split_ok |= ws & (1u64 << 63);
+ }
+ let pwsb = (ws << 1) | pws;
+ let wsboundary = ws & (!pwsb | split_ok);
+ let mut boundary = nb | wsboundary;
+
+ // Contraction fixup, only when the batch has an apostrophe that starts a token: a match
+ // (case-sensitive, as in the scalar dispatch) absorbs the next 1-2 letters and re-opens a
+ // token after them. Too close to the edge (i >= 61) the peek and the re-opened bit can
+ // cross the batch; refuse the tail instead.
+ if ap != 0 {
+ let mut cand = ap & boundary;
+ while cand != 0 {
+ let i = cand.trailing_zeros() as usize;
+ cand &= cand - 1;
+ if i >= 61 {
+ bad |= u64::MAX << i;
+ break;
+ }
+ let k = match text[scan + i + 1] {
+ b's' | b't' | b'm' | b'd' => 2,
+ b'r' | b'v' if text[scan + i + 2] == b'e' => 3,
+ b'l' if text[scan + i + 2] == b'l' => 3,
+ _ => 0,
+ };
+ if k != 0 {
+ boundary &= !(1u64 << (i + 1));
+ boundary |= 1u64 << (i + k);
+ }
+ }
+ }
+ (boundary & !bad, bad)
+}
diff --git a/tokenizers/atomsplit/src/fsm/masked/cl100k.rs b/tokenizers/atomsplit/src/fsm/masked/cl100k.rs
new file mode 100644
index 000000000..438835073
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked/cl100k.rs
@@ -0,0 +1,313 @@
+//! Masked scheme for the cl100k regex family (cl100k / Llama-3 / GLM at digit cap 3, Qwen2 at
+//! cap 1, unbounded `\p{N}+` at `usize::MAX`) — the boundary algebra via gigatoken's
+//! `cl100k_family.rs` (MIT) over tag-fed class masks.
+//!
+//! Boundary rules (the regex is `'(?i:contractions)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,cap}|
+//! ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+`):
+//! - A letter starts a token unless it continues a letter run, follows a space or a non-newline
+//! whitespace char (those always sit at a boundary before a non-ws char and absorb one letter
+//! run via the `[^\r\n\p{L}\p{N}]?` prefix), or follows a punct char that is itself at a
+//! boundary, i.e. whose own predecessor is neither punct nor space (a two-chars-back test,
+//! made char-aware by shifting per the previous char's byte length).
+//! - Digits split every `cap` chars from each run start and never absorb a preceding space.
+//! - A punct char starts a token unless it continues a punct run or follows a space.
+//! - Newlines directly after a punct run are absorbed (`[\r\n]*`).
+//! - A whitespace run containing newlines emits one token through its LAST newline
+//! (`\s*[\r\n]`), then the give-back rules; NL-free runs split before their last char when
+//! followed by non-ws (`\s+(?!\S)`).
+//!
+//! Bad zones: multi-byte `\p{N}` chars under cap 3 (the grouping is char-counted, byte hops
+//! would misphase it) and any digit run whose phase starts before the batch; whitespace runs
+//! touching the batch end while the next char is whitespace (their last newline may lie
+//! beyond); apostrophes near the batch edge or before a non-ASCII char (`(?i:'s)` also matches
+//! `'ſ`); `Sentinel`/`MultiByte` tags.
+
+use super::super::cl100k::advance_cl100k_cap;
+use super::block::Block;
+use super::{MaskedFsm, char_lead, cont_runs, digit_run_splits3, fill, shl_sat, smear_up};
+use crate::fsm::{APO, CONT, LET, NLN, NO, NW, SPC, WSO, in_mask, mask};
+
+pub(super) struct Cl100kMasked {
+ /// 1, 3 or `usize::MAX`; the entry point routes any other cap to the scalar scan.
+ pub(super) digit_cap: usize,
+}
+
+impl MaskedFsm for Cl100kMasked {
+ #[inline(always)]
+ fn batch_masks(&self, text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ batch_masks(text, tags, scan, self.digit_cap)
+ }
+
+ #[inline(always)]
+ fn advance(&self, text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ advance_cl100k_cap(text, tags, i, end, self.digit_cap)
+ }
+}
+
+/// Boundary carries from the two chars before the batch: P1 is the char containing byte
+/// `scan - 1`, P2 the one before it (the two-chars-back absorb test).
+#[derive(Default)]
+struct Carries {
+ pl: u64,
+ ps: u64,
+ pwt: u64,
+ po: u64,
+ pws: u64,
+ pd: u64,
+ /// P2 is punct-or-space, for a char lead at bit 0 (P1 entirely before the batch).
+ c2_os: u64,
+ /// The same test positioned at the first lead after a P1 that straddles into the batch
+ /// (P1's own predecessor is then P2).
+ b2b_in: u64,
+}
+
+fn carries(tags: &[u8], scan: usize, c: u64) -> Carries {
+ let mut cr = Carries::default();
+ if scan == 0 {
+ return cr;
+ }
+ let p1 = char_lead(tags, scan - 1);
+ let c2v = if p1 == 0 {
+ 0
+ } else {
+ let t2 = tags[char_lead(tags, p1 - 1)] & 0x0F;
+ u64::from(t2 == SPC || in_mask(t2, mask::NOT_WS_L_N))
+ };
+ if c & 1 != 0 {
+ cr.b2b_in = c2v << (!c).trailing_zeros();
+ } else {
+ cr.c2_os = c2v;
+ }
+ match tags[p1] & 0x0F {
+ LET => cr.pl = 1,
+ NW | NO => cr.pd = 1,
+ SPC => {
+ cr.pws = 1;
+ cr.ps = 1;
+ }
+ WSO => {
+ cr.pws = 1;
+ cr.pwt = 1;
+ }
+ // A newline is whitespace but never a `[^\r\n\p{L}\p{N}]?` prefix, so pwt stays 0.
+ NLN => cr.pws = 1,
+ _ => cr.po = 1,
+ }
+ cr
+}
+
+fn batch_masks(text: &[u8], tags: &[u8], scan: usize, digit_cap: usize) -> (u64, u64) {
+ debug_assert!(scan + 64 < tags.len() && tags.len() == text.len());
+ // SAFETY: `scan + 64 < tags.len()` (walker guarantee), the block's load contract.
+ let blk = unsafe { Block::load(tags, scan) };
+ if blk.any_range_tag(13, 1) {
+ // Sentinel / MultiByte: the scalar dispatch has a defensive arm for these.
+ return (0, u64::MAX);
+ }
+ let l0 = blk.eq_tag(LET);
+ let d0 = blk.range_tag(NW, 1);
+ let nl = blk.eq_tag(NLN);
+ let s = blk.eq_tag(SPC);
+ let wt0 = blk.eq_tag(WSO);
+ let ap = if blk.any_eq_full(APO) {
+ blk.eq_full(APO)
+ } else {
+ 0
+ };
+ let c = if blk.any_eq_full(CONT) {
+ blk.eq_full(CONT)
+ } else {
+ 0
+ };
+
+ let cr = carries(tags, scan, c);
+
+ // Fill: continuation bytes join their char's class; a char straddling into the batch has
+ // its leading continuation bytes take P1's class (punct needs no action: it is derived as
+ // the complement).
+ let (mut l, mut d, mut wt) = (l0, d0, wt0);
+ let (c2, c3) = cont_runs(c);
+ if c != 0 {
+ if c & 1 != 0 {
+ let lead_in = c & ((1u64 << (!c).trailing_zeros()) - 1);
+ l |= lead_in * cr.pl;
+ d |= lead_in * cr.pd;
+ wt |= lead_in * cr.pwt;
+ }
+ l = fill(l, c, c2, c3);
+ d = fill(d, c, c2, c3);
+ wt = fill(wt, c, c2, c3);
+ }
+ // Per-length char leads (`\s` chars are at most 3 bytes; letters up to 4).
+ let lead = !c;
+ let len1 = lead & !(c >> 1);
+ let len2 = lead & (c >> 1) & !(c >> 2);
+ let len3 = lead & (c >> 1) & (c >> 2) & !(c >> 3);
+ let len4 = lead & (c >> 1) & (c >> 2) & (c >> 3);
+ let w2 = wt0 & len2;
+ let w3 = wt0 & len3;
+
+ let ws_f = s | nl | wt;
+ let o = !(l | d | ws_f);
+
+ // --- Letters: `[^\r\n\p{L}\p{N}]?\p{L}+` --------------------------------------------------
+ // b2back: "the char two back is punct or space", evaluated at each char's lead by shifting
+ // the prev-byte test by the PREVIOUS char's byte length.
+ let c_test = ((o | s) << 1) | cr.po | cr.ps;
+ let b2back = ((c_test & len1) << 1)
+ | ((c_test & len2) << 2)
+ | ((c_test & len3) << 3)
+ | ((c_test & len4) << 4)
+ | cr.c2_os
+ | cr.b2b_in;
+ let p_l = (l << 1) | cr.pl;
+ let p_s = (s << 1) | cr.ps;
+ let p_wt = (wt << 1) | cr.pwt;
+ let p_o = (o << 1) | cr.po;
+ let absorb = p_o & !b2back;
+ let b_letters = l0 & !p_l & !p_s & !p_wt & !absorb;
+
+ // --- Digits: `\p{N}{1,cap}` ----------------------------------------------------------------
+ // Only 1-byte digit chars can be split by byte hops; multi-byte `\p{N}` runs go to the
+ // scalar path under cap 3 (bad below). Cap 1 tokens are single chars (any width), and the
+ // unbounded cap only needs run starts.
+ let d_ascii = d0 & len1;
+ let dmb = (d & c) | (d0 & !len1);
+ let b_digits = match digit_cap {
+ 3 => {
+ if d_ascii & (d_ascii >> 1) != 0 {
+ digit_run_splits3(d_ascii)
+ } else {
+ d_ascii
+ }
+ }
+ 1 => d0,
+ _ => d0 & !((d << 1) | cr.pd),
+ };
+
+ // --- Punct: ` ?[^\s\p{L}\p{N}]+[\r\n]*` ----------------------------------------------------
+ let b_punct = (o & lead) & !p_o & !p_s;
+
+ // Newlines directly after a punct run are absorbed (`[\r\n]*`).
+ let abs_seed = nl & ((o << 1) | cr.po);
+ let abs_n = if abs_seed == 0 {
+ 0
+ } else {
+ smear_up(abs_seed, nl)
+ };
+ let ws_eff = ws_f & !abs_n;
+
+ let mut bad = if digit_cap == 3 {
+ dmb | dmb << 1 | dmb >> 1
+ } else {
+ 0
+ };
+
+ // Byte-64 lookahead: is the char at the next batch's first byte non-ws? Decides whether
+ // ws-like runs touching bit 63 resolve in-batch. A CONT lookahead means a char straddles
+ // out; treating that as "ws" is safe: a live give-back at bit 63 would need the next char's
+ // lead at byte 64, which contradicts the straddle.
+ let la = tags[scan + 64] & 0x0F;
+ let nn64 = la != CONT && !in_mask(la, mask::WS);
+ let nn64m = u64::from(nn64).wrapping_neg();
+
+ // An absorbed newline touching the batch end: if byte 64 is ws the token may continue with
+ // another newline, and the next batch cannot tell an absorbed `\n` before its bit 0 from a
+ // ws-run `\n` — defer.
+ if abs_n >> 63 != 0 && !nn64 {
+ bad |= 1u64 << 63;
+ }
+
+ // A ws run touching the batch end resolves in-batch only when byte 64's char is non-ws
+ // (its last newline and `(?!\S)` split are then all visible); otherwise defer it.
+ let nonws = !ws_eff;
+ if ws_eff >> 63 != 0 && !nn64 {
+ if nonws == 0 {
+ return (0, u64::MAX); // whole batch one ws run
+ }
+ let h = 63 - nonws.leading_zeros();
+ bad |= u64::MAX << (h + 1);
+ }
+
+ // A digit run whose grouping phase did not start inside this batch (a continuation from
+ // before it, or following a bad zone that may hold digit chars) defers too.
+ if digit_cap == 3 {
+ let seed = (d_ascii & (bad << 1)) | (d_ascii & cr.pd);
+ if seed != 0 {
+ bad |= smear_up(seed, d_ascii);
+ }
+ }
+
+ // --- Whitespace ---------------------------------------------------------------------------
+ // Base rule (correct for NL-free runs; NL runs are overridden below): run start, or split
+ // before the last char when followed by non-ws.
+ let ws_leads1 = (s | nl | (wt0 & len1)) & ws_eff;
+ let ws_leads = (ws_leads1 | w2 | w3) & !abs_n;
+ let p_ws = (ws_eff << 1) | cr.pws;
+ let edge_last = (ws_leads1 & (1 << 63)) | (w2 & (1 << 62)) | (w3 & (1 << 61));
+ let split_ok = (ws_leads1 & (nonws >> 1))
+ | (w2 & (nonws >> 2))
+ | (w3 & (nonws >> 3))
+ | (edge_last & nn64m);
+ let mut b_ws = ws_leads & (!p_ws | split_ok);
+
+ // Override every run containing a (non-absorbed) newline: one token through the run's last
+ // newline, then the give-back rules on the remainder.
+ let mut runs_n = nl & ws_eff & !bad;
+ while runs_n != 0 {
+ let f = runs_n.trailing_zeros();
+ let below_gap = nonws & ((1u64 << f) - 1);
+ let a = if below_gap == 0 {
+ 0
+ } else {
+ 64 - below_gap.leading_zeros()
+ };
+ let e = (nonws & (u64::MAX << f)).trailing_zeros();
+ let run_mask = (u64::MAX << a) & !shl_sat(u64::MAX, e);
+ b_ws &= !run_mask;
+ b_ws |= 1u64 << a;
+ let q = 63 - (nl & run_mask).leading_zeros(); // last newline in the run
+ if q + 1 < e {
+ // Tail after the last newline: starts a token, and its last char splits off before
+ // the following non-ws char.
+ b_ws |= 1u64 << (q + 1);
+ let tail = run_mask & (u64::MAX << (q + 1));
+ let tail_leads = ws_leads & tail;
+ b_ws |= 1u64 << (63 - tail_leads.leading_zeros());
+ }
+ runs_n &= !run_mask;
+ }
+
+ let mut boundary = b_letters | b_digits | b_punct | b_ws;
+
+ // --- Contractions: `'(?i:[sdmt]|ll|ve|re)` -------------------------------------------------
+ let mut cand = ap & boundary & !bad;
+ while cand != 0 {
+ let i = cand.trailing_zeros() as usize;
+ cand &= cand - 1;
+ if i >= 61 {
+ bad |= u64::MAX << i;
+ break;
+ }
+ let b1 = text[scan + i + 1];
+ if b1 >= 0x80 {
+ // `(?i:'s)` also matches 'ſ (U+017F): an apostrophe before any non-ASCII char
+ // defers to the scalar path.
+ bad |= 0b111u64 << i;
+ continue;
+ }
+ let k = match b1 | 0x20 {
+ b's' | b'd' | b'm' | b't' => 2,
+ b'l' if text[scan + i + 2] | 0x20 == b'l' => 3,
+ b'v' if text[scan + i + 2] | 0x20 == b'e' => 3,
+ b'r' if text[scan + i + 2] | 0x20 == b'e' => 3,
+ _ => 0,
+ };
+ if k != 0 {
+ boundary &= !(1u64 << (i + 1));
+ boundary |= 1u64 << (i + k);
+ }
+ }
+
+ (boundary & !bad, bad)
+}
diff --git a/tokenizers/atomsplit/src/fsm/masked/deepseek.rs b/tokenizers/atomsplit/src/fsm/masked/deepseek.rs
new file mode 100644
index 000000000..4598d4ae7
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked/deepseek.rs
@@ -0,0 +1,306 @@
+//! Masked scheme for the deepseek-v3 Sequence (digits `\p{N}{1,3}` → CJK-range runs → the big
+//! regex) — boundary rules derived from [`scan_deepseek`]'s dispatch, following gigatoken's
+//! `deepseek_v3.rs` scoping (MIT).
+//!
+//! Deepseek has no case rules and no contractions, but three shapes of its own:
+//! - The alt-2 prefix class is `[^\r\n\p{L}\p{P}\p{S}]?`: letters absorb a preceding space,
+//! non-newline whitespace char, or the LAST char of a gap run (Control / NumericOther /
+//! ZWJ — chars matching no alternative), and never a punct char or a digit.
+//! - alt-1 `[ascii-punct][A-Za-z]+`: an ASCII punct char at a token start absorbs a following
+//! ASCII-letter run. Where that run collides with a non-ASCII letter or mark, the two rules
+//! diverge (`[A-Za-z]+` stops, `[\p{L}\p{M}]+` would continue); those collision bits defer.
+//! - A whitespace run followed by a digit or CJK char is one whole token (Split-1/2 isolated
+//! the follower), so the `\s+(?!\S)` give-back is gated on the follower's class.
+//!
+//! CJK-range chars are closed units re-split into same-kind sub-runs, and every other rule
+//! stops at them; a batch containing any byte with a lead in `0xE3..=0xE9` (a superset of the
+//! CJK ranges) defers whole, before the tag masks are built. On CJK-dominated text the scanner
+//! is then the scalar scan plus one 64-byte test per batch, which is the accepted trade: the
+//! win is on latin/code text, and CJK batches resolve through the same scalar rules as before.
+
+use super::super::deepseek::advance_deepseek;
+use super::block::Block;
+use super::{MaskedFsm, char_lead, cont_runs, digit_run_splits3, fill, shl_sat, smear_up};
+use crate::fsm::{ASM, CON, CONT, CTL, LET, MRK, NLN, NMO, NO, NW, SPC, WSO, ZWJ, in_mask, mask};
+
+pub(super) struct DeepSeekMasked;
+
+impl MaskedFsm for DeepSeekMasked {
+ #[inline(always)]
+ fn batch_masks(&self, text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ batch_masks(text, tags, scan)
+ }
+
+ #[inline(always)]
+ fn advance(&self, text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ advance_deepseek(text, tags, i, end)
+ }
+}
+
+/// A deepseek letter-run member tag: `[\p{L}\p{M}]` minus the refined Marks (ASM/ZWJ). The
+/// CJK-range exclusion is handled by the bad cover, not here.
+#[inline(always)]
+fn is_member(t: u8) -> bool {
+ in_mask(t, mask::LETTER_MARK) && t != ASM && t != ZWJ
+}
+
+#[derive(Default)]
+struct Carries {
+ pl: u64,
+ ps: u64,
+ pwt: u64,
+ po: u64,
+ pws: u64,
+ pd: u64,
+ pgap: u64,
+ /// Byte `scan - 1` is an ASCII punct char at a token start: an alt-1 absorb may reach into
+ /// this batch.
+ alt1: u64,
+ /// P1 is a CJK-range char. Its all-zero carries say "closed unit", which is right for the
+ /// char AFTER it; bytes of the char itself straddling into the batch stay unclassified and
+ /// must defer (see `batch_masks`).
+ cjk: bool,
+}
+
+fn carries(text: &[u8], tags: &[u8], scan: usize) -> Carries {
+ let mut cr = Carries::default();
+ if scan == 0 {
+ return cr;
+ }
+ let p1 = char_lead(tags, scan - 1);
+ if (0xE3..=0xE9).contains(&text[p1]) {
+ // A CJK-range char is a closed unit: everything after it starts fresh, which is what
+ // all-zero carries say.
+ cr.cjk = true;
+ return cr;
+ }
+ let t1 = tags[p1];
+ match t1 & 0x0F {
+ LET | MRK if is_member(t1) => cr.pl = 1,
+ NW | NO => cr.pd = 1,
+ SPC => {
+ cr.pws = 1;
+ cr.ps = 1;
+ }
+ WSO => {
+ cr.pws = 1;
+ cr.pwt = 1;
+ }
+ NLN => cr.pws = 1,
+ NMO | CTL => cr.pgap = 1,
+ _ if t1 == ZWJ => cr.pgap = 1,
+ _ => cr.po = 1,
+ }
+ if text[scan - 1].is_ascii_punctuation() {
+ // Was P1 (one byte) at a token start? Not when it continues a punct run or follows a
+ // space; a CJK-range P2 is a closed unit, so P1 starts fresh after it.
+ let at_start = if p1 == 0 {
+ true
+ } else {
+ let p2 = char_lead(tags, p1 - 1);
+ let t2 = tags[p2];
+ (0xE3..=0xE9).contains(&text[p2])
+ || !(t2 & 0x0F == SPC || in_mask(t2, mask::PUNCT_SYM) || t2 == ASM)
+ };
+ cr.alt1 = u64::from(at_start);
+ }
+ cr
+}
+
+fn batch_masks(text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ debug_assert!(scan + 64 < tags.len() && tags.len() == text.len());
+ // SAFETY: `scan + 64 < tags.len()` (walker guarantee), the blocks' load contract.
+ let tb = unsafe { Block::load(text, scan) };
+ // CJK-range chars (lead 0xE3..=0xE9, a superset of the Split-2 ranges) are closed units
+ // that every other rule stops at; a batch containing any defers whole, BEFORE the tag
+ // masks are built. On CJK-dominated text the scanner degenerates to the scalar scan plus
+ // this one test (see the module doc).
+ if tb.range_full(0xE3, 6) != 0 {
+ return (0, u64::MAX);
+ }
+ // SAFETY: `scan + 64 < tags.len()` (walker guarantee), the block's load contract.
+ let blk = unsafe { Block::load(tags, scan) };
+ if blk.any_range_tag(13, 1) {
+ return (0, u64::MAX);
+ }
+ let l0 = blk.eq_tag(LET);
+ let mk0 = blk.eq_full(MRK); // true marks join deepseek letter runs
+ let o0 = blk.range_tag(CON, 3) | blk.eq_full(ASM); // `[\p{P}\p{S}]` = Con|Pun|Apo|Sym (+ASM)
+ let gap0 = blk.range_tag(NMO, 1) | blk.eq_full(ZWJ);
+ let d0 = blk.range_tag(NW, 1);
+ let nl = blk.eq_tag(NLN);
+ let s = blk.eq_tag(SPC);
+ let wt0 = blk.eq_tag(WSO);
+ let c = if blk.any_eq_full(CONT) {
+ blk.eq_full(CONT)
+ } else {
+ 0
+ };
+ let pa = tb.ascii_punct();
+ let alpha = tb.ascii_alpha();
+
+ let cr = carries(text, tags, scan);
+
+ let lml0 = l0 | mk0;
+ let (mut lm, mut d, mut wt, mut g) = (lml0, d0, wt0, gap0);
+ let (c2, c3) = cont_runs(c);
+ if c != 0 {
+ if c & 1 != 0 {
+ let lead_in = c & ((1u64 << (!c).trailing_zeros()) - 1);
+ lm |= lead_in * cr.pl;
+ d |= lead_in * cr.pd;
+ wt |= lead_in * cr.pwt;
+ g |= lead_in * cr.pgap;
+ }
+ lm = fill(lm, c, c2, c3);
+ d = fill(d, c, c2, c3);
+ wt = fill(wt, c, c2, c3);
+ g = fill(g, c, c2, c3);
+ }
+ let lead = !c;
+ let len1 = lead & !(c >> 1);
+ let len2 = lead & (c >> 1) & !(c >> 2);
+ let len3 = lead & (c >> 1) & (c >> 2) & !(c >> 3);
+ let len4 = lead & (c >> 1) & (c >> 2) & (c >> 3);
+ let w2 = wt0 & len2;
+ let w3 = wt0 & len3;
+
+ let ws_f = s | nl | wt;
+ // The complement is the punct-run class here too (gap and CJK bytes land in it; both are
+ // covered by their own masks or the bad cover below).
+ let o = !(lm | d | ws_f | g);
+
+ let mut bad = 0u64;
+ if cr.cjk && c & 1 != 0 {
+ // A CJK char straddling into the batch: its continuation bytes carry no class (the
+ // all-zero carries only cover the char after it), so they and the char right after
+ // them defer.
+ let e1 = (!c).trailing_zeros();
+ bad |= (c & ((1u64 << e1) - 1)) | (1u64 << e1);
+ }
+
+ // --- Letters: `[^\r\n\p{L}\p{P}\p{S}]?[\p{L}\p{M}]+` and alt-1 `[ascii-punct][A-Za-z]+` ---
+ let p_lm = (lm << 1) | cr.pl;
+ let p_s = (s << 1) | cr.ps;
+ let p_wt = (wt << 1) | cr.pwt;
+ let p_g = (g << 1) | cr.pgap;
+ let p_o = (o << 1) | cr.po;
+
+ // --- Punct: ` ?[\p{P}\p{S}]+[\r\n]*` -------------------------------------------------------
+ let b_punct = o0 & !p_o & !p_s;
+ let abs_seed = nl & ((o << 1) | cr.po);
+ let abs_n = if abs_seed == 0 {
+ 0
+ } else {
+ smear_up(abs_seed, nl)
+ };
+ let ws_eff = ws_f & !abs_n;
+
+ // alt-1 absorbs: an ASCII-letter byte right after a token-starting ASCII punct char, and
+ // the rest of that `[A-Za-z]+` run. Where the run's end meets a letter-run member the two
+ // letter rules diverge: defer that bit. A run touching the batch end defers too (the next
+ // batch cannot tell an alt-1 run from a plain letter run).
+ let absorb_a = alpha & (((pa & b_punct) << 1) | cr.alt1);
+ let b_letters = lml0 & !p_lm & !p_s & !p_wt & !p_g & !absorb_a;
+ if absorb_a != 0 {
+ let zone = smear_up(absorb_a, alpha);
+ bad |= (zone << 1) & !alpha & lml0;
+ if zone >> 63 != 0 {
+ bad |= 1u64 << 63;
+ }
+ }
+
+ // --- Gap runs: boundary at the run start, and at the last char before an absorbed letter
+ // run (the prefix split; one and the same bit for a single-char gap). The prefix split
+ // reads the NEXT char's lead, so a gap char whose follower sits past the batch edge
+ // defers.
+ let b_gap = (gap0 & !p_g)
+ | (gap0
+ & ((len1 & (lml0 >> 1))
+ | (len2 & (lml0 >> 2))
+ | (len3 & (lml0 >> 3))
+ | (len4 & (lml0 >> 4))));
+ bad |= (gap0 & len1 & (u64::MAX << 63))
+ | (gap0 & len2 & (u64::MAX << 62))
+ | (gap0 & len3 & (u64::MAX << 61))
+ | (gap0 & len4 & (u64::MAX << 60));
+
+ // --- Digits: `\p{N}{1,3}` (the cl100k cap-3 machinery) -------------------------------------
+ let d_ascii = d0 & len1;
+ let dmb = (d & c) | (d0 & !len1);
+ let b_digits = if d_ascii & (d_ascii >> 1) != 0 {
+ digit_run_splits3(d_ascii)
+ } else {
+ d_ascii
+ };
+ bad |= dmb | dmb << 1 | dmb >> 1;
+
+ // --- Whitespace -----------------------------------------------------------------------------
+ // The give-back is gated on the follower: a digit or CJK char after the run means the whole
+ // run is one token (`iso` covers the follower's bytes; CJK is inside `bad` anyway, but the
+ // gate keeps the algebra honest about why).
+ let la = tags[scan + 64] & 0x0F;
+ let la_iso = matches!(la, NW | NO) || (0xE3..=0xE9).contains(&text[scan + 64]);
+ let nn64 = la != CONT && !in_mask(la, mask::WS);
+ let nn64m = u64::from(nn64 && !la_iso).wrapping_neg();
+ if abs_n >> 63 != 0 && !nn64 {
+ bad |= 1u64 << 63;
+ }
+ let nonws = !ws_eff;
+ if ws_eff >> 63 != 0 && !nn64 {
+ if nonws == 0 {
+ return (0, u64::MAX);
+ }
+ let h = 63 - nonws.leading_zeros();
+ bad |= u64::MAX << (h + 1);
+ }
+ let seed = (d_ascii & (bad << 1)) | (d_ascii & cr.pd);
+ if seed != 0 {
+ bad |= smear_up(seed, d_ascii);
+ }
+
+ // A whitespace run followed by a digit keeps its last char (no give-back); the CJK case
+ // never reaches this path (any in-batch CJK deferred above), leaving only the lookahead.
+ let iso = d;
+ let nonws_ni = nonws & !iso;
+ let p_ws = (ws_eff << 1) | cr.pws;
+ let ws_leads1 = (s | nl | (wt0 & len1)) & ws_eff;
+ let ws_leads = (ws_leads1 | w2 | w3) & !abs_n;
+ let edge_last = (ws_leads1 & (1 << 63)) | (w2 & (1 << 62)) | (w3 & (1 << 61));
+ let split_ok = (ws_leads1 & (nonws_ni >> 1))
+ | (w2 & (nonws_ni >> 2))
+ | (w3 & (nonws_ni >> 3))
+ | (edge_last & nn64m);
+ let mut b_ws = ws_leads & (!p_ws | split_ok);
+
+ let mut runs_n = nl & ws_eff & !bad;
+ while runs_n != 0 {
+ let f = runs_n.trailing_zeros();
+ let below_gap = nonws & ((1u64 << f) - 1);
+ let a = if below_gap == 0 {
+ 0
+ } else {
+ 64 - below_gap.leading_zeros()
+ };
+ let e = (nonws & (u64::MAX << f)).trailing_zeros();
+ let run_mask = (u64::MAX << a) & !shl_sat(u64::MAX, e);
+ b_ws &= !run_mask;
+ b_ws |= 1u64 << a;
+ let q = 63 - (nl & run_mask).leading_zeros();
+ // The post-newline tail: no give-back when the run's follower is a digit or CJK char
+ // (the whole tail is then one token).
+ let follower_iso = if e >= 64 { la_iso } else { iso >> e & 1 != 0 };
+ if q + 1 < e {
+ b_ws |= 1u64 << (q + 1);
+ if !follower_iso {
+ let tail = run_mask & (u64::MAX << (q + 1));
+ let tail_leads = ws_leads & tail;
+ b_ws |= 1u64 << (63 - tail_leads.leading_zeros());
+ }
+ }
+ runs_n &= !run_mask;
+ }
+
+ let boundary = b_letters | b_digits | b_punct | b_gap | b_ws;
+ (boundary & !bad, bad)
+}
diff --git a/tokenizers/atomsplit/src/fsm/masked/o200k.rs b/tokenizers/atomsplit/src/fsm/masked/o200k.rs
new file mode 100644
index 000000000..dbc77c477
--- /dev/null
+++ b/tokenizers/atomsplit/src/fsm/masked/o200k.rs
@@ -0,0 +1,473 @@
+//! Masked scheme for the o200k regex family (o200k / GPT-4o / gpt-oss with contractions and
+//! digit cap 3, Mistral tekken without contractions at cap 1) — the boundary algebra via
+//! gigatoken's `o200k_family.rs` (MIT) over tag-fed class masks.
+//!
+//! Differences from the cl100k family:
+//! - Letter runs are case-structured. Under leftmost-greedy backtracking the two letter
+//! alternatives reduce to a phase automaton: a strict-upper char (`\p{Lu}\p{Lt}`) ends a
+//! token exactly when the previous char is strict-lower (`\p{Ll}`) — "camelCase" splits
+//! `camel|Case`, "HTTPResponse" stays one token. A strict-upper after a CASELESS letter
+//! needs the phase and lookahead (the backtrack to the last caseless char), so those chars
+//! defer to the scalar path.
+//! - Contractions are attached suffixes of letter tokens, not a standalone alternative:
+//! "don't" is ONE token and the char after a consumed suffix always starts a new one
+//! ("can'ts" is `can't|s`). A contraction applies only when the apostrophe directly follows
+//! a letter-run char; elsewhere `'` is ordinary punctuation.
+//! - Punct runs absorb a `[\r\n/]*` tail. `/` is itself punct, so an absorbed tail always
+//! begins with a newline; whether a batch-leading `[\r\n/]` run continues such a tail is
+//! resolved by a bounded walkback over the preceding text.
+//! - Marks (`\p{M}`) are dual-class: they join letter runs AND continue punct runs, so their
+//! effective class is run-contextual. Mark chars (rare) defer to the scalar path with a bad
+//! smear wide enough (8 bytes forward) to cover every boundary their class can influence
+//! (two chars of multi-byte followers).
+
+use super::super::o200k::advance_o200k;
+use super::block::Block;
+use super::{MaskedFsm, char_lead, cont_runs, digit_run_splits3, fill, shl_sat, smear_up};
+use crate::fsm::{APO, ASM, Atom, CONT, LET, MRK, NLN, NO, NW, SPC, WSO, ZWJ, in_mask, mask};
+
+pub(super) struct O200kMasked;
+
+impl MaskedFsm
+ for O200kMasked
+{
+ #[inline(always)]
+ fn batch_masks(&self, text: &[u8], tags: &[u8], scan: usize) -> (u64, u64) {
+ batch_masks::(text, tags, scan)
+ }
+
+ #[inline(always)]
+ fn advance(&self, text: &[u8], tags: &[u8], i: usize, end: usize) -> usize {
+ advance_o200k::(text, tags, i, end)
+ }
+}
+
+#[inline(always)]
+fn is_tail_byte(b: u8) -> bool {
+ matches!(b, b'\r' | b'\n' | b'/')
+}
+
+#[inline(always)]
+fn is_member_mark(t: u8) -> bool {
+ t & 0x0F == MRK && t != ASM && t != ZWJ
+}
+
+/// Was the tail-class byte at `scan - 1` absorbed by a punct run's `[\r\n/]*` tail (as opposed
+/// to being a fresh punct-run `/` or a ws-run newline)? Walks the tail-class run back (bounded)
+/// and classifies the char before it. `None`: unresolved (over-long run, or a preceding mark
+/// whose own class is run-contextual).
+fn prev_tail_absorbed(text: &[u8], tags: &[u8], scan: usize) -> Option {
+ debug_assert!(scan >= 1 && is_tail_byte(text[scan - 1]));
+ let mut r = scan - 1;
+ let mut steps = 0;
+ while r > 0 && is_tail_byte(text[r - 1]) {
+ r -= 1;
+ steps += 1;
+ if steps > 8 {
+ return None;
+ }
+ }
+ // T-run = text[r..scan]. The `[\r\n/]*` tail is greedy, so once absorption triggers — at
+ // the first newline that directly follows a punct-run char (an in-run slash, or the
+ // pre-run char for a run-leading newline) — everything to the run's end is absorbed.
+ // Before the trigger, newlines are ws-run members and slashes ordinary punct-run bytes.
+ let run = &text[r..scan];
+ let mut trigger = usize::MAX;
+ let mut seen_slash = false;
+ for (j, &b) in run.iter().enumerate() {
+ if b == b'/' {
+ seen_slash = true;
+ continue;
+ }
+ if seen_slash {
+ trigger = j;
+ break;
+ }
+ if j == 0 {
+ if r == 0 {
+ continue;
+ }
+ let t = tags[char_lead(tags, r - 1)];
+ if is_member_mark(t) || t & 0x0F >= 13 {
+ // A mark continues whatever run precedes it; Sentinel/MultiByte are opaque.
+ return None;
+ }
+ if !in_mask(t, mask::LETTER | mask::NUMBER | mask::WS) {
+ trigger = 0;
+ break;
+ }
+ }
+ }
+ Some(scan - 1 - r >= trigger)
+}
+
+/// Two-back "punct or space" test for the char whose lead is `p2`. A slash may be an absorbed
+/// tail byte (a token end, neither punct-run member nor space), so it resolves through the
+/// walkback. `None`: unresolved (the caller sets `force_bad_lead`). A mark P2 answers 0: the
+/// bits that read it wrongly sit inside the previous batch's mark smear, and the scalar
+/// overrun from there covers them (the walker's resume masking).
+fn c2_os_at(text: &[u8], tags: &[u8], p2: usize) -> Option {
+ if text[p2] == b'/' {
+ return prev_tail_absorbed(text, tags, p2 + 1).map(|abs| u64::from(!abs));
+ }
+ let t2 = tags[p2];
+ Some(u64::from(
+ t2 & 0x0F == SPC || (!is_member_mark(t2) && in_mask(t2, mask::NOT_WS_L_N)),
+ ))
+}
+
+/// Boundary carries from the two chars before the batch (the cl100k set, plus the case classes
+/// and the absorbed-tail resolution).
+#[derive(Default)]
+struct Carries {
+ pl: u64,
+ pu: u64,
+ pcl: u64,
+ ps: u64,
+ pwt: u64,
+ po: u64,
+ pws: u64,
+ pd: u64,
+ /// P1 is a member-mark (seeds the mark smear at bit 0).
+ pmk: u64,
+ c2_os: u64,
+ b2b_in: u64,
+ /// P1 is an absorbed `[\r\n/]*` tail byte whose token may continue into this batch.
+ p_abs: bool,
+ /// The tail walkback could not resolve: the batch's leading tail-class run (plus the byte
+ /// after it) can't be trusted.
+ force_bad_lead: bool,
+}
+
+fn carries(text: &[u8], tags: &[u8], scan: usize, c: u64) -> Carries {
+ let mut cr = Carries::default();
+ if scan == 0 {
+ return cr;
+ }
+ if is_tail_byte(text[scan - 1]) {
+ // An absorbed tail ended the previous token, so every "P1 is X" carry is zero and only
+ // the tail-continuation seed survives. A fresh `/` is an ordinary punct byte; fresh
+ // `\r\n` are ws-run newlines.
+ match prev_tail_absorbed(text, tags, scan) {
+ None => cr.force_bad_lead = true,
+ Some(true) => cr.p_abs = true,
+ Some(false) => {
+ if text[scan - 1] == b'/' {
+ cr.po = 1;
+ } else {
+ cr.pws = 1;
+ }
+ match c2_os_at(text, tags, char_lead(tags, scan - 2)) {
+ Some(v) => cr.c2_os = v,
+ None => cr.force_bad_lead = true,
+ }
+ }
+ }
+ return cr;
+ }
+ let p1 = char_lead(tags, scan - 1);
+ let c2v = if p1 == 0 {
+ Some(0)
+ } else {
+ c2_os_at(text, tags, char_lead(tags, p1 - 1))
+ };
+ match c2v {
+ Some(v) => {
+ if c & 1 != 0 {
+ cr.b2b_in = v << (!c).trailing_zeros();
+ } else {
+ cr.c2_os = v;
+ }
+ }
+ None => cr.force_bad_lead = true,
+ }
+ let t1 = tags[p1];
+ match t1 & 0x0F {
+ LET => {
+ cr.pl = 1;
+ cr.pu = u64::from(t1 == Atom::UpperLetter as u8);
+ cr.pcl = u64::from(t1 == Atom::Letter as u8);
+ }
+ NW | NO => cr.pd = 1,
+ SPC => {
+ cr.pws = 1;
+ cr.ps = 1;
+ }
+ WSO => {
+ cr.pws = 1;
+ cr.pwt = 1;
+ }
+ MRK if is_member_mark(t1) => cr.pmk = 1,
+ _ => cr.po = 1,
+ }
+ cr
+}
+
+fn batch_masks(
+ text: &[u8],
+ tags: &[u8],
+ scan: usize,
+) -> (u64, u64) {
+ debug_assert!(scan + 64 < tags.len() && tags.len() == text.len());
+ // SAFETY: `scan + 64 < tags.len()` (walker guarantee), the block's load contract.
+ let blk = unsafe { Block::load(tags, scan) };
+ if blk.any_range_tag(13, 1) {
+ return (0, u64::MAX);
+ }
+ let l0 = blk.eq_tag(LET);
+ let ub0 = blk.eq_full(Atom::UpperLetter as u8);
+ let cl0 = blk.eq_full(Atom::Letter as u8);
+ let mk0 = blk.eq_full(MRK); // true marks: refined Mark tags (ASM/ZWJ) are punct-class
+ let d0 = blk.range_tag(NW, 1);
+ let nl = blk.eq_tag(NLN);
+ let s = blk.eq_tag(SPC);
+ let wt0 = blk.eq_tag(WSO);
+ let ap = if blk.any_eq_full(APO) {
+ blk.eq_full(APO)
+ } else {
+ 0
+ };
+ let c = if blk.any_eq_full(CONT) {
+ blk.eq_full(CONT)
+ } else {
+ 0
+ };
+
+ let cr = carries(text, tags, scan, c);
+
+ let (mut l, mut u, mut clb, mut d, mut wt, mut mk) = (l0, ub0, cl0, d0, wt0, mk0);
+ let (c2, c3) = cont_runs(c);
+ if c != 0 {
+ if c & 1 != 0 {
+ let lead_in = c & ((1u64 << (!c).trailing_zeros()) - 1);
+ l |= lead_in * cr.pl;
+ u |= lead_in * cr.pu;
+ clb |= lead_in * cr.pcl;
+ d |= lead_in * cr.pd;
+ wt |= lead_in * cr.pwt;
+ mk |= lead_in * cr.pmk;
+ }
+ l = fill(l, c, c2, c3);
+ u = fill(u, c, c2, c3);
+ clb = fill(clb, c, c2, c3);
+ d = fill(d, c, c2, c3);
+ wt = fill(wt, c, c2, c3);
+ mk = fill(mk, c, c2, c3);
+ }
+ mk |= cr.pmk; // a mark P1 poisons bit 0 even when it ends exactly at the batch edge
+ let lead = !c;
+ let len1 = lead & !(c >> 1);
+ let len2 = lead & (c >> 1) & !(c >> 2);
+ let len3 = lead & (c >> 1) & (c >> 2) & !(c >> 3);
+ let len4 = lead & (c >> 1) & (c >> 2) & (c >> 3);
+ let w2 = wt0 & len2;
+ let w3 = wt0 & len3;
+
+ let ws_f = s | nl | wt;
+ // Marks land in the complement (dual-class); every bit that can read them is inside the
+ // mark bad smear below, so their punct-class reading is never trusted.
+ let o = !(l | d | ws_f);
+
+ // --- Absorbed `[\r\n/]*` tails ---------------------------------------------------------
+ // The tail class needs the slash mask from the TEXT block; skip that load when the batch
+ // has no newline and no tail context carried in.
+ let (tcls, abs_t) = if nl != 0 || cr.p_abs || cr.force_bad_lead {
+ // SAFETY: `scan + 64 < text.len()` (walker guarantee), the block's load contract.
+ let sl = unsafe { Block::load(text, scan) }.eq_full(b'/');
+ let tcls = nl | sl;
+ let abs_seed = (nl & ((o << 1) | cr.po)) | (u64::from(cr.p_abs) & tcls);
+ let abs_t = if abs_seed == 0 {
+ 0
+ } else {
+ smear_up(abs_seed, tcls)
+ };
+ (tcls, abs_t)
+ } else {
+ (nl, 0)
+ };
+ let ob_eff = o & !abs_t;
+
+ // --- Letters (see the cl100k scheme for the base rules) ---------------------------------
+ let c_test = ((ob_eff | s) << 1) | cr.po | cr.ps;
+ let b2back = ((c_test & len1) << 1)
+ | ((c_test & len2) << 2)
+ | ((c_test & len3) << 3)
+ | ((c_test & len4) << 4)
+ | cr.c2_os
+ | cr.b2b_in;
+ let p_l = (l << 1) | cr.pl;
+ let p_u = (u << 1) | cr.pu;
+ let p_cl = (clb << 1) | cr.pcl;
+ let p_s = (s << 1) | cr.ps;
+ let p_wt = (wt << 1) | cr.pwt;
+ let p_o = (ob_eff << 1) | cr.po;
+ let absorb = p_o & !b2back;
+ // Casing boundary: a strict-upper char after a strict-lower one. (For ASCII text this is
+ // the whole rule; upper-after-caseless defers below.)
+ let p_sl = p_l & !p_u & !p_cl;
+ let b_letters = (l0 & !p_l & !p_s & !p_wt & !absorb) | (ub0 & p_sl);
+
+ // --- Digits ------------------------------------------------------------------------------
+ let d_ascii = d0 & len1;
+ let dmb = (d & c) | (d0 & !len1);
+ let b_digits = if DIGIT_CAP == 3 {
+ if d_ascii & (d_ascii >> 1) != 0 {
+ digit_run_splits3(d_ascii)
+ } else {
+ d_ascii
+ }
+ } else {
+ d0 // cap 1: every digit char its own token
+ };
+
+ // --- Punct: ` ?[^\s\p{L}\p{N}]+[\r\n/]*` --------------------------------------------------
+ let b_punct = (ob_eff & lead) & !p_o & !p_s;
+
+ // --- Bad zones ----------------------------------------------------------------------------
+ let mut bad = if DIGIT_CAP == 3 {
+ dmb | dmb << 1 | dmb >> 1
+ } else {
+ 0
+ };
+ if mk != 0 {
+ // A mark's run-contextual class can affect boundaries up to two chars after it (8
+ // bytes of multi-byte followers) and the byte before.
+ bad |= mk
+ | (mk << 1)
+ | (mk << 2)
+ | (mk << 3)
+ | (mk << 4)
+ | (mk << 5)
+ | (mk << 6)
+ | (mk << 7)
+ | (mk << 8)
+ | (mk >> 1);
+ }
+ // A strict-upper char after a caseless letter: phase- and lookahead-dependent.
+ bad |= ub0 & ((clb << 1) | cr.pcl);
+ if cr.force_bad_lead {
+ bad |= (smear_up(tcls & 1, tcls) << 1) | 0b11;
+ }
+
+ // --- Whitespace ---------------------------------------------------------------------------
+ let ws_eff = ws_f & !abs_t;
+ let la = tags[scan + 64] & 0x0F;
+ let nn64 = la != CONT && !in_mask(la, mask::WS);
+ let nn64m = u64::from(nn64).wrapping_neg();
+
+ // An absorbed tail touching the batch end continues iff byte 64 is tail-class; the next
+ // batch's tail walkback re-derives the context either way, so nothing defers here. A ws
+ // run touching the batch end still defers when byte 64 is ws.
+ let nonws = !ws_eff;
+ if ws_eff >> 63 != 0 && !nn64 {
+ if nonws == 0 {
+ return (0, u64::MAX);
+ }
+ let h = 63 - nonws.leading_zeros();
+ bad |= u64::MAX << (h + 1);
+ }
+ if DIGIT_CAP == 3 {
+ let seed = (d_ascii & (bad << 1)) | (d_ascii & cr.pd);
+ if seed != 0 {
+ bad |= smear_up(seed, d_ascii);
+ }
+ }
+
+ let ws_leads1 = (s | nl | (wt0 & len1)) & ws_eff;
+ let ws_leads = (ws_leads1 | w2 | w3) & !abs_t;
+ let p_ws = (ws_eff << 1) | cr.pws;
+ let edge_last = (ws_leads1 & (1 << 63)) | (w2 & (1 << 62)) | (w3 & (1 << 61));
+ let split_ok = (ws_leads1 & (nonws >> 1))
+ | (w2 & (nonws >> 2))
+ | (w3 & (nonws >> 3))
+ | (edge_last & nn64m);
+ let mut b_ws = ws_leads & (!p_ws | split_ok);
+
+ let mut runs_n = nl & ws_eff & !bad;
+ while runs_n != 0 {
+ let f = runs_n.trailing_zeros();
+ let below_gap = nonws & ((1u64 << f) - 1);
+ let a = if below_gap == 0 {
+ 0
+ } else {
+ 64 - below_gap.leading_zeros()
+ };
+ let e = (nonws & (u64::MAX << f)).trailing_zeros();
+ let run_mask = (u64::MAX << a) & !shl_sat(u64::MAX, e);
+ b_ws &= !run_mask;
+ b_ws |= 1u64 << a;
+ let q = 63 - (nl & run_mask).leading_zeros();
+ if q + 1 < e {
+ b_ws |= 1u64 << (q + 1);
+ let tail = run_mask & (u64::MAX << (q + 1));
+ let tail_leads = ws_leads & tail;
+ b_ws |= 1u64 << (63 - tail_leads.leading_zeros());
+ }
+ runs_n &= !run_mask;
+ }
+
+ let mut boundary = b_letters | b_digits | b_punct | b_ws;
+
+ // --- Contractions: suffix `(?i:'s|'t|'re|'ve|'m|'ll|'d)?` ---------------------------------
+ // An apostrophe at a boundary right after a letter-run char merges the suffix into that
+ // token and forces a boundary right after it.
+ if CONTRACTION {
+ let mut cand = ap & boundary & p_l & !bad;
+ let mut last_forced = usize::MAX;
+ while cand != 0 {
+ let i = cand.trailing_zeros() as usize;
+ cand &= cand - 1;
+ if i <= 2 {
+ // The preceding letter could itself end an earlier contraction that started
+ // before the batch: scalar.
+ bad |= 0b111u64 << i;
+ continue;
+ }
+ if i >= 61 {
+ bad |= u64::MAX << i;
+ break;
+ }
+ if i == last_forced {
+ // "x'll'd": the letter before this apostrophe is a consumed suffix's last
+ // char; a new (prefix) match starts here instead.
+ continue;
+ }
+ // The letter before this apostrophe may itself be a consumed suffix's last char
+ // resolved where `last_forced` can't see it (a scalar-walked zone, or a fixup
+ // before the batch): locally ambiguous, defer.
+ let p = scan + i;
+ let prev_suffix_possible = (text[p - 2] == b'\''
+ && matches!(text[p - 1] | 0x20, b's' | b'd' | b'm' | b't'))
+ || (text[p - 3] == b'\''
+ && (matches!(
+ (text[p - 2] | 0x20, text[p - 1] | 0x20),
+ (b'l', b'l') | (b'v', b'e') | (b'r', b'e')
+ ) || (text[p - 2] == 0xC5 && text[p - 1] == 0xBF)));
+ if prev_suffix_possible {
+ bad |= 0b111u64 << i;
+ continue;
+ }
+ let b1 = text[p + 1];
+ if b1 >= 0x80 {
+ // `(?i:'s)` also matches 'ſ (U+017F): defer.
+ bad |= 0b111u64 << i;
+ continue;
+ }
+ let k = match b1 | 0x20 {
+ b's' | b'd' | b'm' | b't' => 2,
+ b'l' if text[p + 2] | 0x20 == b'l' => 3,
+ b'v' if text[p + 2] | 0x20 == b'e' => 3,
+ b'r' if text[p + 2] | 0x20 == b'e' => 3,
+ _ => 0,
+ };
+ if k != 0 {
+ boundary &= !(1u64 << i);
+ boundary &= !(((1u64 << (k - 1)) - 1) << (i + 1));
+ boundary |= 1u64 << (i + k);
+ last_forced = i + k;
+ }
+ }
+ }
+
+ (boundary & !bad, bad)
+}
diff --git a/tokenizers/atomsplit/src/fsm/o200k.rs b/tokenizers/atomsplit/src/fsm/o200k.rs
index 28e15640a..6914e02c7 100644
--- a/tokenizers/atomsplit/src/fsm/o200k.rs
+++ b/tokenizers/atomsplit/src/fsm/o200k.rs
@@ -50,19 +50,18 @@ fn o200k_letter_match(tags: &[u8], p: usize, re: usize) -> usize {
e
}
-/// Emit the o200k case-split of the letter run `[ls, re)` into `out[*w..]`: the first sub-token starts at
+/// Emit the o200k case-split of the letter run `[ls, re)`: the first sub-token starts at
/// `pfx` (the optional `[^\r\n\p{L}\p{N}]?` prefix; `pfx == ls` when none), the last absorbs a trailing
/// contraction (`CONTRACTION` — off for tekken). Returns the new cursor (past the contraction).
/// `ls < re` (caller-guaranteed).
#[inline(always)]
-fn emit_o200k_letters(
+fn emit_o200k_letters(
text: &[u8],
tags: &[u8],
pfx: usize,
ls: usize,
re: usize,
- out: &mut [Span],
- w: &mut usize,
+ emit: &mut E,
) -> usize {
let (mut p, mut first, mut cursor) = (ls, true, re);
while p < re {
@@ -73,13 +72,10 @@ fn emit_o200k_letters(
} else {
e
};
- unsafe {
- *out.get_unchecked_mut(*w) = Span {
- start: start as u32,
- end: tok_end as u32,
- }
- };
- *w += 1;
+ emit(Span {
+ start: start as u32,
+ end: tok_end as u32,
+ });
first = false;
cursor = tok_end;
p = e;
@@ -94,86 +90,199 @@ fn emit_o200k_letters(
/// Unlike deepseek there are no gaps: rule 4's `[^\s\p{L}\p{N}]+` is a catch-all. Scalar; ┌ OWNER: shared ┐
#[must_use]
pub fn fsm_o200k(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
- o200k::(text, tags, out)
+ debug_assert!(out.len() >= text.len());
+ let mut w = 0usize;
+ scan_o200k(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
}
/// Mistral tekken ([`crate::regexes::TEKKEN`]) — the o200k FSM with the contraction suffix off and one
/// token per digit. Every other rule is shared, so both are the same code monomorphized twice.
#[must_use]
pub fn fsm_tekken(text: &[u8], tags: &[u8], out: &mut [Span]) -> usize {
- o200k::(text, tags, out)
+ debug_assert!(out.len() >= text.len());
+ let mut w = 0usize;
+ scan_tekken(text, tags, |span| {
+ // SAFETY: tokens partition the input, so `w < #tokens <= text.len() <= out.len()`.
+ unsafe { *out.get_unchecked_mut(w) = span };
+ w += 1;
+ });
+ w
+}
+
+/// The scan under [`fsm_o200k`]: hands each token to `emit` the moment it is cut,
+/// so a caller can consume tokens in place instead of collecting a span buffer first.
+pub fn scan_o200k(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ o200k::(text, tags, emit);
+}
+
+/// The scan under [`fsm_tekken`]; see [`scan_o200k`].
+pub fn scan_tekken(text: &[u8], tags: &[u8], emit: impl FnMut(Span)) {
+ o200k::(text, tags, emit);
+}
+
+/// Is tag `t` a real `[\p{L}\p{M}]` member? Coarse `Letter` (any case) is always in; coarse
+/// `Mark` is in only as a true `\p{M}` — ALPHA_SYM (`\p{S}`) and ZWJ/ZWNJ (`\p{Cf}`) are `\w`
+/// but not `[\p{L}\p{M}]`.
+#[inline(always)]
+fn member(t: u8) -> bool {
+ let c = t & 0x0F;
+ c == LET || (c == MRK && t != ASM && t != ZWJ)
+}
+
+#[inline(always)]
+fn is_lm(tags: &[u8], a: usize, end: usize) -> bool {
+ a < end && member(tags[a])
}
-fn o200k(
+/// Maximal `[\p{L}\p{M}]+` run from `a` (byte-wise; continuation bytes ride along — see `run_end`).
+#[inline(always)]
+fn letter_end(tags: &[u8], a: usize, end: usize) -> usize {
+ let mut p = a;
+ // logos-style fast loop: 16 tags/chunk, one bounds check, unchecked reads. A plain `Letter`
+ // (low nibble 0, incl Han — o200k keeps all letters) or a `Cont` byte stays in-run; only a
+ // coarse `Mark` lane pays the refinement test. Byte-exact with the plain scan below.
+ // SAFETY: `p + 16 <= end <= tags.len()` in the body.
+ while p + 16 <= end {
+ let mut brk = 16;
+ for k in 0..16 {
+ let t = unsafe { *tags.get_unchecked(p + k) };
+ if t == CONT || t & 0x0F == LET {
+ continue;
+ }
+ if t & 0x0F == MRK && t != ASM && t != ZWJ {
+ continue;
+ }
+ brk = k;
+ break;
+ }
+ if brk < 16 {
+ return p + brk;
+ }
+ p += 16;
+ }
+ while p < end && (tags[p] == CONT || member(tags[p])) {
+ p += 1;
+ }
+ p
+}
+
+/// rule 4 `[^\s\p{L}\p{N}]+[\r\n/]*` from `sp0` (any leading space already consumed); `sp0` if
+/// none. `/` is in the `+` body too — the trailing class only matters after the `+` stops at a
+/// `\r\n`.
+#[inline(always)]
+fn other(text: &[u8], tags: &[u8], sp0: usize, end: usize) -> usize {
+ let mut p = run_end(tags, sp0, end, mask::NOT_WS_L_N);
+ if p > sp0 {
+ while p < end && (tags[p] == NLN || text[p] == b'/') {
+ p += char_len(text[p]);
+ }
+ }
+ p
+}
+
+/// End of the token starting at `i` (`i < end`, `i` on a token boundary): one rule dispatch of
+/// the o200k-family regex. A letter-path token is the FIRST case sub-run from its start (the
+/// case split restarts at every sub-token, so one dispatch per sub-token equals the run loop in
+/// [`emit_o200k_letters`]); the masked scanner re-derives tokens with this where its batch
+/// masks are not trustworthy.
+#[inline(always)]
+pub(super) fn advance_o200k(
text: &[u8],
tags: &[u8],
- out: &mut [Span],
+ i: usize,
+ end: usize,
) -> usize {
- debug_assert!(out.len() >= text.len() && tags.len() >= text.len());
- let end = text.len();
- // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
- // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
- let tags = &tags[..end];
-
- // Is tag `t` (at byte `p`) a real `[\p{L}\p{M}]` member? Coarse `Letter` (any case) is always in;
- // coarse `Mark` is in only as a true `\p{M}` — ALPHA_SYM (`\p{S}`) and ZWJ/ZWNJ (`\p{Cf}`) are `\w`
- // but not `[\p{L}\p{M}]`. The `ds_is_zwj` byte-peek is thus paid ONLY for Marks, never for letters.
- let member = |t: u8, p: usize| -> bool {
- let c = t & 0x0F;
- let _ = p;
- c == LET || (c == MRK && t != ASM && t != ZWJ)
+ let one_letter = |ls: usize| -> usize {
+ let re = letter_end(tags, ls, end);
+ let e = o200k_letter_match(tags, ls, re);
+ if CONTRACTION && e == re {
+ e + contraction(text, e)
+ } else {
+ e
+ }
};
- let is_lm = |a: usize| a < end && member(tags[a], a);
- // maximal `[\p{L}\p{M}]+` run from `a` (byte-wise; continuation bytes ride along — see `run_end`).
- let letter_end = |a: usize| -> usize {
- let mut p = a;
- // logos-style fast loop: 16 tags/chunk, one bounds check, unchecked reads. A plain `Letter`
- // (low nibble 0, incl Han — o200k keeps all letters) or a `Cont` byte stays in-run with no
- // `ds_is_zwj` peek; only a coarse `Mark` lane pays the peek. Byte-exact with the scalar scan.
- // SAFETY: `p + 16 <= end <= tags.len()`/`text.len()` in the body.
- while p + 16 <= end {
- let mut brk = 16;
- for k in 0..16 {
- let t = unsafe { *tags.get_unchecked(p + k) };
- if t == CONT || t & 0x0F == LET {
- continue;
- }
- if t & 0x0F == MRK && t != ASM && t != ZWJ {
- continue;
+ let b = text[i];
+ match tags[i] & 0x0F {
+ NW | NO => {
+ let (mut p, mut cnt) = (i, 0);
+ while p < end && cnt < DIGIT_CAP && in_mask(tags[p], mask::NUMBER) {
+ p += char_len(text[p]);
+ cnt += 1;
+ }
+ p
+ }
+ LET | MRK => {
+ if tags[i] != ASM && tags[i] != ZWJ {
+ one_letter(i)
+ } else {
+ let a = i + char_len(b);
+ if is_lm(tags, a, end) {
+ one_letter(a)
+ } else {
+ other(text, tags, i, end)
}
- brk = k;
- break;
}
- if brk < 16 {
- return p + brk;
+ }
+ SPC => {
+ let a = i + 1; // Space is ASCII (0x20)
+ if is_lm(tags, a, end) {
+ one_letter(a)
+ } else {
+ let p = other(text, tags, a, end);
+ if p > a {
+ p
+ } else {
+ ws_tail(text, tags, i, end)
+ }
}
- p += 16;
}
- while p < end && (tags[p] == CONT || member(tags[p], p)) {
- p += 1;
+ WSO => {
+ let a = i + char_len(b);
+ if is_lm(tags, a, end) {
+ one_letter(a)
+ } else {
+ ws_tail(text, tags, i, end)
+ }
}
- p
- };
- // rule 4 `[^\s\p{L}\p{N}]+[\r\n/]*` from `sp0` (any leading space already consumed); `sp0` if none.
- // `/` is in the `+` body too — the trailing class only matters after the `+` stops at a `\r\n`.
- let other = |sp0: usize| -> usize {
- let mut p = run_end(tags, sp0, end, mask::NOT_WS_L_N);
- if p > sp0 {
- while p < end && (tags[p] == NLN || text[p] == b'/') {
- p += char_len(text[p]);
+ NLN => ws_tail(text, tags, i, end),
+ CON | PUN | APO | SYM | NMO | CTL => {
+ let a = i + char_len(b);
+ if is_lm(tags, a, end) {
+ one_letter(a)
+ } else {
+ other(text, tags, i, end)
}
}
- p
- };
+ _ => i + char_len(b),
+ }
+}
+
+fn o200k(
+ text: &[u8],
+ tags: &[u8],
+ mut emit: E,
+) {
+ debug_assert!(tags.len() >= text.len());
+ let end = text.len();
+ // Tie `tags.len() == end` so the optimizer drops the per-byte bounds check on every interior
+ // `tags[i]` in this fsm + its `run_end`/`letter_*` scans. (Callers guarantee `tags.len() >= end`.)
+ let tags = &tags[..end];
+
+ let is_lm = |a: usize| is_lm(tags, a, end);
// rules 5-7 (`\s*[\r\n]+ | \s+(?!\S) | \s+`) → the shared `ws_tail` (identical to cl100k).
let ws = |i: usize| -> usize { ws_tail(text, tags, i, end) };
+ let other = |sp0: usize| -> usize { other(text, tags, sp0, end) };
// The letter rules: case-split the run starting at `ls`, first sub-token starting at the prefix `pfx`.
- let letters = |pfx: usize, ls: usize, out: &mut [Span], w: &mut usize| -> usize {
- emit_o200k_letters::(text, tags, pfx, ls, letter_end(ls), out, w)
+ let letters = |pfx: usize, ls: usize, emit: &mut E| -> usize {
+ emit_o200k_letters::(text, tags, pfx, ls, letter_end(tags, ls, end), emit)
};
let mut i = 0;
- let mut w = 0usize;
while i < end {
let start = i;
let b = text[i];
@@ -191,12 +300,12 @@ fn o200k(
// take the `[^\r\n\p{L}\p{N}]?` prefix / rule-4 path instead.
LET | MRK => {
if tags[i] != ASM && tags[i] != ZWJ {
- i = letters(i, i, out, &mut w);
+ i = letters(i, i, &mut emit);
continue;
}
let a = i + char_len(b);
if is_lm(a) {
- i = letters(i, a, out, &mut w);
+ i = letters(i, a, &mut emit);
continue;
}
i = other(i); // ∈ NOT_WS_L_N ⇒ > i
@@ -205,7 +314,7 @@ fn o200k(
SPC => {
let a = i + 1; // Space is ASCII (0x20)
if is_lm(a) {
- i = letters(i, a, out, &mut w);
+ i = letters(i, a, &mut emit);
continue;
}
let p = other(a);
@@ -215,7 +324,7 @@ fn o200k(
WSO => {
let a = i + char_len(b);
if is_lm(a) {
- i = letters(i, a, out, &mut w);
+ i = letters(i, a, &mut emit);
continue;
}
i = ws(i);
@@ -225,7 +334,7 @@ fn o200k(
CON | PUN | APO | SYM | NMO | CTL => {
let a = i + char_len(b);
if is_lm(a) {
- i = letters(i, a, out, &mut w);
+ i = letters(i, a, &mut emit);
continue;
}
i = other(i); // ∈ NOT_WS_L_N ⇒ > i
@@ -233,14 +342,9 @@ fn o200k(
// Sentinel / MultiByte / Cont — never a char-start atom; emit one char defensively.
_ => i += char_len(b),
}
- // SAFETY: tokens partition the input, so `w < #tokens <= end < out.len()` (out ≥ text.len()+? ; callers size n+1).
- unsafe {
- *out.get_unchecked_mut(w) = Span {
- start: start as u32,
- end: i as u32,
- }
- };
- w += 1;
+ emit(Span {
+ start: start as u32,
+ end: i as u32,
+ });
}
- w
}
diff --git a/tokenizers/atomsplit/src/lib.rs b/tokenizers/atomsplit/src/lib.rs
index 5f22f52d0..888263190 100644
--- a/tokenizers/atomsplit/src/lib.rs
+++ b/tokenizers/atomsplit/src/lib.rs
@@ -3,7 +3,7 @@
//! One SIMD pass ([`classify`]) maps every codepoint to a tiny "atom" alphabet; a family of no-push
//! FSMs ([`fsm`]) turn that atom stream into token spans (byte ranges) — the pre-tokenizer stage that
//! runs before a BPE/WordPiece model. Pre-tokenizers implemented: `WhitespaceSplit`, `Punctuation`,
-//! `Digits`, `Whitespace`, `Bert`, `Cl100k`, `DeepSeek`, `ByteLevel`, `CharDelimiterSplit` (o200k and
+//! `Digits`, `Whitespace`, `Bert`, `Cl100k`, `DeepSeek`, `ByteLevel` (o200k and
//! Mistral's tekken are exposed as the [`fsm::fsm_o200k`] / [`fsm::fsm_tekken`] functions rather than
//! recipe structs).
//!
@@ -28,6 +28,12 @@ mod simd_avx_classify;
#[cfg(target_arch = "aarch64")]
mod simd_classify;
mod simd_fsm;
+#[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+))]
+mod simd_literal;
#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
mod simd_wasm_classify;
pub mod tables;
diff --git a/tokenizers/atomsplit/src/literal.rs b/tokenizers/atomsplit/src/literal.rs
index c826b2dda..9e7106c1a 100644
--- a/tokenizers/atomsplit/src/literal.rs
+++ b/tokenizers/atomsplit/src/literal.rs
@@ -4,7 +4,15 @@
//! FSMs cut where the class changes ([`crate::fsm`]).
//!
//! The atom classification is unnecessary for pre-tokenizers splitting on an exact character or literal string:
-//! We can use a simpler byte search looking at 16 bytes at a time.
+//! a plain byte search finds the same cuts. Two searches cover the two match densities:
+//!
+//! - [`Literal::matches`] iterates with [`memmem`], which is built for needles that are rare in
+//! the haystack: it skips ahead fast and restarts after every match.
+//! - [`Literal::matches_into`] scans the whole text once and writes every offset into a caller
+//! buffer. Pre-tokenizer delimiters are the opposite of rare (running English text has a space
+//! about every six bytes), and at that density the per-match restarts dominate the iterator.
+//! The scan answers a whole block of text at once and turns the answers into offsets;
+//! `simd_literal.rs` explains how, step by step.
use memchr::memmem;
use std::fmt;
@@ -53,4 +61,135 @@ impl Literal {
pub fn matches<'t>(&'t self, text: &'t [u8]) -> impl Iterator- + 't {
self.finder.find_iter(text)
}
+
+ /// The same offsets as [`Literal::matches`], written into `out[..count]` in one scan of
+ /// `text`; returns the count. Use this when matches are frequent: the iterator restarts its
+ /// search machinery after every match, the scan never stops.
+ ///
+ /// # Preconditions
+ /// - `out.len() >= text.len() / pattern.len() + 4` (asserted). The division is the most
+ /// matches the text can hold; the `+ 4` is slack the SIMD path writes past the last match.
+ /// - `text.len()` must fit in `u32` (asserted), so that every offset does too.
+ pub fn matches_into(&self, text: &[u8], out: &mut [u32]) -> usize {
+ assert!(
+ out.len() >= text.len() / self.pattern().len() + 4,
+ "matches_into needs out.len() >= text.len() / pattern.len() + 4"
+ );
+ assert!(
+ u32::try_from(text.len()).is_ok(),
+ "matches_into writes u32 offsets"
+ );
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ if self.scannable() {
+ match *self.pattern() {
+ [a] => return crate::simd_literal::matches_into([a], text, out),
+ [a, b] => return crate::simd_literal::matches_into([a, b], text, out),
+ [a, b, c] => return crate::simd_literal::matches_into([a, b, c], text, out),
+ _ => unreachable!("scannable patterns are one to three bytes"),
+ }
+ }
+ let mut count = 0;
+ for position in self.finder.find_iter(text) {
+ out[count] = position as u32;
+ count += 1;
+ }
+ count
+ }
+
+ /// How many matches [`Literal::matches`] would report. Counting runs the compare step
+ /// without producing any offsets, so it is several times faster than a full scan; count
+ /// first to size an output exactly, then fill it with a [`Literal::for_each_match`] pass.
+ #[must_use]
+ pub fn count_matches(&self, text: &[u8]) -> usize {
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ if self.scannable() {
+ match *self.pattern() {
+ [a] => return crate::simd_literal::count_matches([a], text),
+ [a, b] => return crate::simd_literal::count_matches([a, b], text),
+ [a, b, c] => return crate::simd_literal::count_matches([a, b, c], text),
+ _ => unreachable!("scannable patterns are one to three bytes"),
+ }
+ }
+ self.finder.find_iter(text).count()
+ }
+
+ /// Calls `on_match` with the byte offset of every match, left to right: the same offsets
+ /// as [`Literal::matches`] at batch-scan speed, with no buffer for the caller to provide.
+ /// The scan streams through a small stack window, so its footprint stays flat however
+ /// long the text is.
+ pub fn for_each_match(&self, text: &[u8], mut on_match: impl FnMut(usize)) {
+ if !self.scannable() {
+ for position in self.finder.find_iter(text) {
+ on_match(position);
+ }
+ return;
+ }
+ self.scan_windows(text, |base, matches| {
+ for &position in matches {
+ on_match(base + position as usize);
+ }
+ });
+ }
+
+ /// Whether the batch scan covers this pattern. The scan emits every position where the
+ /// pattern matches, with no ordering check between them; that is only the non-overlapping
+ /// match list when the pattern cannot overlap itself, which one byte comparison per
+ /// length decides: a two-byte pattern overlaps itself when both bytes are equal, a
+ /// three-byte one when last equals first. Longer or self-overlapping patterns search
+ /// through the [`memmem`] engine instead.
+ #[cfg(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ ))]
+ fn scannable(&self) -> bool {
+ match *self.pattern() {
+ [_] => true,
+ [a, b] => a != b,
+ [a, _, c] => a != c,
+ _ => false,
+ }
+ }
+
+ /// Without a SIMD kernel there is no batch scan; everything searches through [`memmem`].
+ #[cfg(not(any(
+ target_arch = "aarch64",
+ target_arch = "x86_64",
+ all(target_arch = "wasm32", target_feature = "simd128")
+ )))]
+ fn scannable(&self) -> bool {
+ false
+ }
+
+ /// Streams the batch scan window by window: [`Literal::matches_into`] fills a stack
+ /// buffer for one stretch of text, `on_window` consumes it (offsets are relative to the
+ /// window's `base`), and the next window starts where a match could no longer fit in the
+ /// previous one, so nothing at a window edge is missed or reported twice.
+ fn scan_windows(&self, text: &[u8], mut on_window: impl FnMut(usize, &[u32])) {
+ // 4KB of stack; a window of (1024 - 4) * pattern.len() bytes fills it exactly.
+ let mut buffer = [0u32; 1024];
+ let width = self.pattern().len();
+ let window = (buffer.len() - 4) * width;
+ let mut base = 0;
+ loop {
+ let end = usize::min(base + window, text.len());
+ let count = self.matches_into(&text[base..end], &mut buffer);
+ on_window(base, &buffer[..count]);
+ if end == text.len() {
+ return;
+ }
+ // A match crossing `end` was not reported; the next window re-covers its
+ // possible starts. Matches of a scannable pattern never overlap, so the reports
+ // stay disjoint.
+ base = end - (width - 1);
+ }
+ }
}
diff --git a/tokenizers/atomsplit/src/simd_literal.rs b/tokenizers/atomsplit/src/simd_literal.rs
new file mode 100644
index 000000000..78e6bfa58
--- /dev/null
+++ b/tokenizers/atomsplit/src/simd_literal.rs
@@ -0,0 +1,783 @@
+//! Finds every place a short pattern (up to three bytes) occurs in a text, in one pass.
+//! This is the scan behind [`Literal::matches_into`](crate::literal::Literal::matches_into);
+//! [`crate::literal`] explains when it is picked over the plain iterator.
+//!
+//! # Why a dedicated scan
+//!
+//! A pre-tokenizer splits on a delimiter that shows up every few bytes: running English text
+//! has a space about every six bytes, SentencePiece text has a `▁` at every word. A
+//! next-match engine like `memmem` is built for the opposite case, a needle that is rare, so
+//! at this density it spends most of its time stopping and restarting: every match returns to
+//! the caller, and every call re-enters the search machinery. This scan never stops. It
+//! answers "where are ALL the matches in these 16 bytes?" with a handful of instructions,
+//! block after block.
+//!
+//! # How: compare, mask, decode
+//!
+//! Two words come up in everything below. A SIMD **register** is an extra-wide CPU value
+//! holding 16 bytes side by side; one instruction operates on all 16 at once. Each of those
+//! 16 byte slots is a **lane**. A SIMD compare answers lane by lane: a lane becomes all ones
+//! where the answer is yes, all zeros where it is no.
+//!
+//! **Step 1, compare.** Compare the text against the pattern's first byte (copied across all
+//! 16 lanes), the text shifted by one against the second byte, and so on, then AND the
+//! answers together: a lane that passed every compare is the start of a whole match, and
+//! nothing needs a second look. Searching for `▁` (three bytes, `E2 96 81`) in
+//! `"a…b▁c"`, where the `…` (`E2 80 A6`) shares a first byte with `▁` and acts as the decoy:
+//!
+//! ```text
+//! offset 0 1 2 3 4 5 6 7 8
+//! text a E2 80 A6 b E2 96 81 c
+//! └── … ──────┘ └── ▁ ──────┘
+//! text[j] == E2 ? · ✓ · · · ✓ · · ·
+//! text[j+1] == 96 ? · · · · · ✓ · · ·
+//! text[j+2] == 81 ? · · · · · ✓ · · ·
+//! AND of the three · · · · · ✓ · · ·
+//! ```
+//!
+//! Only offset 5 passes all three rows: a match starts at 5, and the decoy at 1 is out after
+//! the second row.
+//!
+//! **Step 2, mask.** The 16 lane answers are squeezed into one ordinary integer so plain
+//! arithmetic can take over: bit `j` set means "a match starts at offset `j`". For the
+//! example above the mask is `0b100000`, bit 5. Each target has a one-or-two instruction way
+//! to build this integer; on NEON the bits come out spaced four positions apart instead of
+//! one, a spacing the shared code carries along as `SHIFT` (the per-target layer below).
+//!
+//! **Step 3, decode.** Count-trailing-zeros on the mask gives the lowest set bit, which is the
+//! next match offset; clear that bit and repeat. The one twist is that the code decodes four
+//! offsets *without checking whether any bits are left*. Say a block starting at text offset
+//! `base` had matches at offsets 2 and 5 (shown with the one-bit spacing):
+//!
+//! ```text
+//! mask 0b100100 trailing zeros = 2 write base + 2 clear bit → 0b100000
+//! mask 0b100000 trailing zeros = 5 write base + 5 clear bit → 0
+//! mask 0 trailing zeros = 64 write garbage (slot 3)
+//! mask 0 trailing zeros = 64 write garbage (slot 4)
+//! cursor += 2 (how many bits were set)
+//! ```
+//!
+//! The two garbage slots sit past the cursor, so the next block's writes (or nothing) land on
+//! them and they never reach the caller. The honest alternative, checking "still something
+//! left?" before each decode, puts a branch in the hottest loop; with a match every four to
+//! eight bytes that branch guesses wrong about once per block, which costs more than all the
+//! compares together.
+//!
+//! Sparse text takes none of this: one instruction per 64-byte block answers "nothing here"
+//! (see `any`), so a pattern that is rare or absent stays close to `memmem` speed.
+//!
+//! The compare trick is Wojciech Muła's ("SIMD-friendly algorithms for substring searching");
+//! the branch-free decode is how simdjson reads its masks.
+//!
+//! # The per-target layer
+//!
+//! Everything from the mask on is plain integer arithmetic and is shared. A target provides
+//! four primitives in its `arch` module:
+//!
+//! - `splat`: one byte copied into every lane, the shape a compare wants.
+//! - `match_starts`: step 1, the shifted compares ANDed together.
+//! - `any`: "did anything in these 64 bytes match?", the cheap exit for sparse text.
+//! - `bits`: step 2, lane answers to integer mask.
+//!
+//! x86 (`movemask`) and wasm (`bitmask`) each have an instruction that grabs one bit from
+//! every lane, so offset `j` lands on bit `j`. NEON has no such instruction; its standard
+//! substitute (Danila Kutenin's) leaves the bits four positions apart, so offset `j` lands
+//! on bit `4 * j`. `SHIFT` is that spacing (`bit position >> SHIFT` recovers the offset),
+//! and it is the only difference the shared code ever sees.
+//!
+//! On x86 the 16-byte baseline is SSE2, which every x86_64 CPU has; when the CPU reports
+//! wider vectors, [`matches_into`] and [`count_matches`] pick a faster front end per call
+//! (the `wide` module: AVX2 and AVX-512).
+//!
+//! The wasm layer is compile-checked but not run in CI; the other targets run the full
+//! `tests/literal.rs` parity suite.
+
+#[cfg(target_arch = "aarch64")]
+mod arch {
+ use core::arch::aarch64::*;
+
+ /// 16 bytes of text in one register.
+ pub(super) type Chunk = uint8x16_t;
+ /// Offsets sit four bit positions apart in a mask from [`bits`]: offset `j` is bit `4 * j`.
+ pub(super) const SHIFT: u32 = 2;
+
+ /// One byte copied into every lane.
+ #[inline(always)]
+ pub(super) fn splat(byte: u8) -> Chunk {
+ unsafe { vdupq_n_u8(byte) }
+ }
+
+ /// All-ones in every lane where a whole `K`-byte match starts.
+ ///
+ /// # Safety
+ /// `p .. p + 16 + K - 1` must be readable: the shifted compares load 16 bytes from each
+ /// of `p`, `p + 1`, .., `p + K - 1`.
+ #[inline(always)]
+ pub(super) unsafe fn match_starts(p: *const u8, pattern: &[Chunk; K]) -> Chunk {
+ // SAFETY: the caller's contract above; each load of 16 bytes from `p + k`, `k < K`,
+ // stays inside that readable range.
+ let mut starts = unsafe { vceqq_u8(vld1q_u8(p), pattern[0]) };
+ for (k, &byte) in pattern.iter().enumerate().skip(1) {
+ starts = unsafe { vandq_u8(starts, vceqq_u8(vld1q_u8(p.add(k)), byte)) };
+ }
+ starts
+ }
+
+ /// `true` when anything matched in the four chunks.
+ #[inline(always)]
+ pub(super) fn any(s0: Chunk, s1: Chunk, s2: Chunk, s3: Chunk) -> bool {
+ unsafe { vmaxvq_u8(vorrq_u8(vorrq_u8(s0, s1), vorrq_u8(s2, s3))) != 0 }
+ }
+
+ /// The lane answers as one integer: one bit per lane, four bit positions apart.
+ ///
+ /// NEON cannot grab one bit from every lane in a single instruction. Instead `vshrn`
+ /// ("shift right and narrow") halves the register to 64 bits, shrinking each lane's
+ /// answer from eight bits to four (still all ones or all zeros); the `0x1111..` mask
+ /// then keeps one bit of each four, so clearing one bit in the decode drops exactly
+ /// one match.
+ #[inline(always)]
+ pub(super) fn bits(starts: Chunk) -> u64 {
+ unsafe {
+ let halved = vshrn_n_u16::<4>(vreinterpretq_u16_u8(starts));
+ vget_lane_u64::<0>(vreinterpret_u64_u8(halved)) & 0x1111_1111_1111_1111
+ }
+ }
+}
+
+#[cfg(target_arch = "x86_64")]
+mod arch {
+ use core::arch::x86_64::*;
+
+ /// 16 bytes of text in one register.
+ pub(super) type Chunk = __m128i;
+ /// One bit per lane in a mask from [`bits`]: offset `j` is bit `j`, nothing to shift.
+ pub(super) const SHIFT: u32 = 0;
+
+ /// One byte copied into every lane.
+ #[inline(always)]
+ pub(super) fn splat(byte: u8) -> Chunk {
+ unsafe { _mm_set1_epi8(byte as i8) }
+ }
+
+ /// All-ones in every lane where a whole `K`-byte match starts.
+ ///
+ /// # Safety
+ /// `p .. p + 16 + K - 1` must be readable: the shifted compares load 16 bytes from each
+ /// of `p`, `p + 1`, .., `p + K - 1`.
+ #[inline(always)]
+ pub(super) unsafe fn match_starts(p: *const u8, pattern: &[Chunk; K]) -> Chunk {
+ // SAFETY: the caller's contract above; each load of 16 bytes from `p + k`, `k < K`,
+ // stays inside that readable range.
+ let mut starts = unsafe { _mm_cmpeq_epi8(_mm_loadu_si128(p.cast()), pattern[0]) };
+ for (k, &byte) in pattern.iter().enumerate().skip(1) {
+ starts = unsafe {
+ _mm_and_si128(
+ starts,
+ _mm_cmpeq_epi8(_mm_loadu_si128(p.add(k).cast()), byte),
+ )
+ };
+ }
+ starts
+ }
+
+ /// `true` when anything matched in the four chunks.
+ #[inline(always)]
+ pub(super) fn any(s0: Chunk, s1: Chunk, s2: Chunk, s3: Chunk) -> bool {
+ unsafe { _mm_movemask_epi8(_mm_or_si128(_mm_or_si128(s0, s1), _mm_or_si128(s2, s3))) != 0 }
+ }
+
+ /// The lane answers as one integer: `movemask` grabs each lane's top bit, so offset `j`
+ /// is bit `j`.
+ #[inline(always)]
+ pub(super) fn bits(starts: Chunk) -> u64 {
+ unsafe { _mm_movemask_epi8(starts) as u32 as u64 }
+ }
+}
+
+#[cfg(all(target_arch = "wasm32", target_feature = "simd128"))]
+mod arch {
+ use core::arch::wasm32::*;
+
+ /// 16 bytes of text in one register.
+ pub(super) type Chunk = v128;
+ /// One bit per lane in a mask from [`bits`]: offset `j` is bit `j`, nothing to shift.
+ pub(super) const SHIFT: u32 = 0;
+
+ /// One byte copied into every lane.
+ #[inline(always)]
+ pub(super) fn splat(byte: u8) -> Chunk {
+ u8x16_splat(byte)
+ }
+
+ /// All-ones in every lane where a whole `K`-byte match starts.
+ ///
+ /// # Safety
+ /// `p .. p + 16 + K - 1` must be readable: the shifted compares load 16 bytes from each
+ /// of `p`, `p + 1`, .., `p + K - 1`.
+ #[inline(always)]
+ pub(super) unsafe fn match_starts(p: *const u8, pattern: &[Chunk; K]) -> Chunk {
+ // SAFETY: the caller's contract above; each load of 16 bytes from `p + k`, `k < K`,
+ // stays inside that readable range.
+ let mut starts = unsafe { u8x16_eq(v128_load(p.cast()), pattern[0]) };
+ for (k, &byte) in pattern.iter().enumerate().skip(1) {
+ starts = unsafe { v128_and(starts, u8x16_eq(v128_load(p.add(k).cast()), byte)) };
+ }
+ starts
+ }
+
+ /// `true` when anything matched in the four chunks.
+ #[inline(always)]
+ pub(super) fn any(s0: Chunk, s1: Chunk, s2: Chunk, s3: Chunk) -> bool {
+ v128_any_true(v128_or(v128_or(s0, s1), v128_or(s2, s3)))
+ }
+
+ /// The lane answers as one integer: `bitmask` grabs each lane's top bit, so offset `j`
+ /// is bit `j`.
+ #[inline(always)]
+ pub(super) fn bits(starts: Chunk) -> u64 {
+ u8x16_bitmask(starts) as u64
+ }
+}
+
+/// Decodes every set bit of `bits` into a `base + offset` value at `cursor`, and returns the
+/// cursor advanced by the true match count.
+///
+/// The first four decodes run without checking `bits` (the module docs say why): once the mask
+/// is used up, `trailing_zeros` is 64 and the slot gets a garbage offset, scratch that the
+/// count never covers and a later call overwrites. Blocks with more than four matches finish
+/// in the loop.
+///
+/// # Safety
+/// There must be room for `max(4, count_ones(bits))` writes between `cursor` and `end`: four
+/// slots for the unconditional decodes, one per match when a block has more. Debug builds
+/// check this bound before writing anything.
+#[inline(always)]
+unsafe fn decode(mut bits: u64, base: u32, cursor: *mut u32, end: *const u32) -> *mut u32 {
+ let count = bits.count_ones() as usize;
+ debug_assert!(
+ end as usize - cursor as usize >= count.max(4) * size_of::(),
+ "decode would write past the match buffer"
+ );
+ // SAFETY: the caller's contract above gives these four slots.
+ for slot in 0..4 {
+ unsafe { *cursor.add(slot) = base + (bits.trailing_zeros() >> arch::SHIFT) };
+ bits &= bits.wrapping_sub(1);
+ }
+ if bits != 0 {
+ let mut slot = 4;
+ // SAFETY: one slot per remaining match, still within the caller's `count` slots.
+ while bits != 0 {
+ unsafe { *cursor.add(slot) = base + (bits.trailing_zeros() >> arch::SHIFT) };
+ bits &= bits.wrapping_sub(1);
+ slot += 1;
+ }
+ }
+ // SAFETY: `count` slots were just written, so one past the last of them is in bounds.
+ unsafe { cursor.add(count) }
+}
+
+/// How many matches of `pattern` are in `text`: the compare and mask steps only, counting set
+/// bits instead of decoding them, which makes counting several times faster than the full
+/// scan. Same pattern rules as [`matches_into`].
+pub(crate) fn count_matches(pattern: [u8; K], text: &[u8]) -> usize {
+ const {
+ assert!(
+ K >= 1 && K <= 3,
+ "the scan is built for patterns of 1 to 3 bytes"
+ );
+ }
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ #[cfg(target_arch = "x86_64")]
+ if let Some(count) = wide::count_matches_wide::(pattern, text) {
+ return count;
+ }
+ let p = text.as_ptr();
+ let mut pattern_chunks = [arch::splat(0); K];
+ for (chunk, &byte) in pattern_chunks.iter_mut().zip(&pattern) {
+ *chunk = arch::splat(byte);
+ }
+ let mut count = 0usize;
+ let mut i = 0usize;
+
+ // The same block layout as `scan`, and the same SAFETY story for the reads: each loop
+ // condition bounds its loads inside `text`. Nothing here writes.
+ while i + 64 + (K - 1) <= len {
+ let (s0, s1, s2, s3) = unsafe {
+ (
+ arch::match_starts::(p.add(i), &pattern_chunks),
+ arch::match_starts::(p.add(i + 16), &pattern_chunks),
+ arch::match_starts::(p.add(i + 32), &pattern_chunks),
+ arch::match_starts::(p.add(i + 48), &pattern_chunks),
+ )
+ };
+ if arch::any(s0, s1, s2, s3) {
+ count += (arch::bits(s0).count_ones()
+ + arch::bits(s1).count_ones()
+ + arch::bits(s2).count_ones()
+ + arch::bits(s3).count_ones()) as usize;
+ }
+ i += 64;
+ }
+
+ count + unsafe { count_tail::(pattern, &pattern_chunks, text, i) }
+}
+
+/// The counting twin of [`scan_tail`]: 16-byte rounds from `i`, the overlapped final block,
+/// or the byte-by-byte walk for texts shorter than one block.
+///
+/// # Safety
+/// Reads are bounded by the loop conditions, exactly as in [`scan_tail`]. Nothing writes.
+unsafe fn count_tail(
+ pattern: [u8; K],
+ pattern_chunks: &[arch::Chunk; K],
+ text: &[u8],
+ mut i: usize,
+) -> usize {
+ let len = text.len();
+ let p = text.as_ptr();
+ let mut count = 0usize;
+
+ // SAFETY: reads end at `i + 16 + (K - 1) <= len`.
+ while i + 16 + (K - 1) <= len {
+ let starts = unsafe { arch::match_starts::(p.add(i), pattern_chunks) };
+ count += arch::bits(starts).count_ones() as usize;
+ i += 16;
+ }
+
+ if i + K <= len {
+ if len >= 16 + (K - 1) {
+ // The overlapped final block: bits below `i` were counted above and are masked off.
+ // SAFETY: the block reads `base .. base + 16 + K - 1`, which ends exactly at `len`.
+ let base = len - 16 - (K - 1);
+ let starts = unsafe { arch::match_starts::(p.add(base), pattern_chunks) };
+ let bits = arch::bits(starts) & (!0u64 << ((i - base) << arch::SHIFT));
+ count += bits.count_ones() as usize;
+ } else {
+ while i + K <= len {
+ count += (text[i..i + K] == pattern[..]) as usize;
+ i += 1;
+ }
+ }
+ }
+ count
+}
+
+/// The wider x86 front ends. Every x86_64 CPU has the SSE2 baseline above; these two rungs
+/// are picked per call when the CPU reports the feature:
+///
+/// - **AVX2** compares 32 bytes per instruction, so one round covers 64 bytes with two
+/// compares and one combined mask.
+/// - **AVX-512** compares 64 bytes straight into a bit mask (`_mm512_cmpeq_epi8_mask`, no
+/// separate mask step) and decodes with `vpcompressd`: the CPU packs the matching offsets
+/// itself, 16 at a time, replacing the whole count-trailing-zeros loop.
+///
+/// Each rung is its own function because `#[target_feature]` applies per function; the bodies
+/// mirror [`scan`] and [`count_matches`] round for round and share [`decode`], [`scan_tail`]
+/// and [`count_tail`]. The AVX2 rung is exercised by the `tests/literal.rs` suite on any
+/// AVX2 machine or emulator; the AVX-512 rung runs the same suite only on hardware that has
+/// it, so run the tests on such a machine before trusting changes to it.
+#[cfg(target_arch = "x86_64")]
+mod wide {
+ use super::{arch, count_tail, decode, scan_tail};
+ use core::arch::x86_64::*;
+
+ /// Runs the widest scan this CPU supports; `None` means only the baseline exists.
+ #[inline]
+ pub(super) fn matches_into_wide(
+ pattern: [u8; K],
+ text: &[u8],
+ out: &mut [u32],
+ ) -> Option {
+ if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw") {
+ // SAFETY: the features were just detected; the buffer contract is the caller's.
+ return Some(unsafe { scan_avx512::(pattern, text, out) });
+ }
+ if is_x86_feature_detected!("avx2") {
+ // SAFETY: the feature was just detected; the buffer contract is the caller's.
+ return Some(unsafe { scan_avx2::(pattern, text, out) });
+ }
+ None
+ }
+
+ /// The counting twin of [`matches_into_wide`].
+ #[inline]
+ pub(super) fn count_matches_wide(
+ pattern: [u8; K],
+ text: &[u8],
+ ) -> Option {
+ if is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512bw") {
+ // SAFETY: the features were just detected. Counting never writes.
+ return Some(unsafe { count_avx512::(pattern, text) });
+ }
+ if is_x86_feature_detected!("avx2") {
+ // SAFETY: the feature was just detected. Counting never writes.
+ return Some(unsafe { count_avx2::(pattern, text) });
+ }
+ None
+ }
+
+ /// All-ones per lane where a whole `K`-byte match starts, for 32 positions at `p`.
+ ///
+ /// # Safety
+ /// AVX2 must be available, and `p .. p + 32 + K - 1` must be readable.
+ #[inline]
+ #[target_feature(enable = "avx2")]
+ unsafe fn match_starts32(p: *const u8, pattern: &[__m256i; K]) -> __m256i {
+ // SAFETY: the caller's contract above; each load of 32 bytes from `p + k`, `k < K`,
+ // stays inside that readable range.
+ let mut starts = _mm256_cmpeq_epi8(unsafe { _mm256_loadu_si256(p.cast()) }, pattern[0]);
+ for (k, &byte) in pattern.iter().enumerate().skip(1) {
+ starts = _mm256_and_si256(
+ starts,
+ _mm256_cmpeq_epi8(unsafe { _mm256_loadu_si256(p.add(k).cast()) }, byte),
+ );
+ }
+ starts
+ }
+
+ /// One byte copied into every lane, and the baseline chunks [`scan_tail`] wants.
+ #[target_feature(enable = "avx2")]
+ fn splat32(pattern: [u8; K]) -> ([__m256i; K], [arch::Chunk; K]) {
+ let mut wide = [_mm256_set1_epi8(0); K];
+ let mut baseline = [arch::splat(0); K];
+ for k in 0..K {
+ wide[k] = _mm256_set1_epi8(pattern[k] as i8);
+ baseline[k] = arch::splat(pattern[k]);
+ }
+ (wide, baseline)
+ }
+
+ /// [`scan`](super::scan) with the AVX2 front end: 64 bytes per round as two 32-byte
+ /// compares, one combined mask, decoded in the same 16-bit quarters as the baseline so
+ /// [`decode`]'s four-slot unroll stays matched to typical match density.
+ ///
+ /// # Safety
+ /// AVX2 must be available, and `out` must satisfy
+ /// [`matches_into`](super::matches_into)'s buffer bound, which it asserts.
+ #[target_feature(enable = "avx2")]
+ unsafe fn scan_avx2(pattern: [u8; K], text: &[u8], out: &mut [u32]) -> usize {
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ let p = text.as_ptr();
+ let (pattern_chunks, baseline) = splat32::(pattern);
+ let start = out.as_mut_ptr();
+ // SAFETY: one past the buffer's last slot is a valid address to form (never read).
+ let end = unsafe { start.add(out.len()) } as *const u32;
+ let mut cursor = start;
+ let mut i = 0usize;
+
+ // SAFETY: reads end at `i + 32 + 32 + (K - 1) <= len`; writes are the buffer
+ // contract, handed to `decode` as `end`.
+ while i + 64 + (K - 1) <= len {
+ let (s0, s1) = unsafe {
+ (
+ match_starts32::(p.add(i), &pattern_chunks),
+ match_starts32::(p.add(i + 32), &pattern_chunks),
+ )
+ };
+ let bits = (_mm256_movemask_epi8(s0) as u32 as u64)
+ | ((_mm256_movemask_epi8(s1) as u32 as u64) << 32);
+ if bits != 0 {
+ unsafe {
+ cursor = decode(bits & 0xFFFF, i as u32, cursor, end);
+ cursor = decode((bits >> 16) & 0xFFFF, i as u32 + 16, cursor, end);
+ cursor = decode((bits >> 32) & 0xFFFF, i as u32 + 32, cursor, end);
+ cursor = decode(bits >> 48, i as u32 + 48, cursor, end);
+ }
+ }
+ i += 64;
+ }
+ // SAFETY: `scan_tail` continues under the same contract.
+ cursor = unsafe { scan_tail::(pattern, &baseline, text, i, cursor, end) };
+ // SAFETY: `cursor` and `start` both point into `out`'s buffer.
+ unsafe { cursor.offset_from(start) as usize }
+ }
+
+ /// The counting twin of [`scan_avx2`].
+ ///
+ /// # Safety
+ /// AVX2 must be available.
+ #[target_feature(enable = "avx2")]
+ unsafe fn count_avx2(pattern: [u8; K], text: &[u8]) -> usize {
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ let p = text.as_ptr();
+ let (pattern_chunks, baseline) = splat32::(pattern);
+ let mut count = 0usize;
+ let mut i = 0usize;
+
+ // SAFETY: reads end at `i + 64 + (K - 1) <= len`. Nothing writes.
+ while i + 64 + (K - 1) <= len {
+ let (s0, s1) = unsafe {
+ (
+ match_starts32::(p.add(i), &pattern_chunks),
+ match_starts32::(p.add(i + 32), &pattern_chunks),
+ )
+ };
+ count += (_mm256_movemask_epi8(s0).count_ones() + _mm256_movemask_epi8(s1).count_ones())
+ as usize;
+ i += 64;
+ }
+ // SAFETY: `count_tail` continues under the same read bounds.
+ count + unsafe { count_tail::(pattern, &baseline, text, i) }
+ }
+
+ /// The 64-byte match mask, straight from the compares: AVX-512 byte compares produce a
+ /// bit per lane natively, so there is no separate mask step to pay for.
+ ///
+ /// # Safety
+ /// AVX-512 F and BW must be available, and `p .. p + 64 + K - 1` must be readable.
+ #[inline]
+ #[target_feature(enable = "avx512f,avx512bw")]
+ unsafe fn match_bits64(p: *const u8, pattern: &[__m512i; K]) -> u64 {
+ // SAFETY: the caller's contract above; each load of 64 bytes from `p + k`, `k < K`,
+ // stays inside that readable range.
+ let mut bits = _mm512_cmpeq_epi8_mask(unsafe { _mm512_loadu_si512(p.cast()) }, pattern[0]);
+ for (k, &byte) in pattern.iter().enumerate().skip(1) {
+ bits &= _mm512_cmpeq_epi8_mask(unsafe { _mm512_loadu_si512(p.add(k).cast()) }, byte);
+ }
+ bits
+ }
+
+ /// One byte copied into every lane, and the baseline chunks the shared tails want.
+ #[target_feature(enable = "avx512f")]
+ fn splat64(pattern: [u8; K]) -> ([__m512i; K], [arch::Chunk; K]) {
+ let mut wide = [_mm512_set1_epi8(0); K];
+ let mut baseline = [arch::splat(0); K];
+ for k in 0..K {
+ wide[k] = _mm512_set1_epi8(pattern[k] as i8);
+ baseline[k] = arch::splat(pattern[k]);
+ }
+ (wide, baseline)
+ }
+
+ /// [`scan`](super::scan) with the AVX-512 front end. The decode is `vpcompressd`
+ /// (`_mm512_mask_compressstoreu_epi32`): give the CPU sixteen candidate offsets and a
+ /// mask, and it stores exactly the matching ones, packed. The count-trailing-zeros loop
+ /// and its density concerns disappear; four compress-stores cover a 64-byte round.
+ ///
+ /// # Safety
+ /// AVX-512 F and BW must be available, and `out` must satisfy
+ /// [`matches_into`](super::matches_into)'s buffer bound, which it asserts.
+ #[target_feature(enable = "avx512f,avx512bw")]
+ unsafe fn scan_avx512(pattern: [u8; K], text: &[u8], out: &mut [u32]) -> usize {
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ let p = text.as_ptr();
+ let (pattern_chunks, baseline) = splat64::(pattern);
+ let start = out.as_mut_ptr();
+ // SAFETY: one past the buffer's last slot is a valid address to form (never read).
+ let end = unsafe { start.add(out.len()) } as *const u32;
+ let mut cursor = start;
+ let mut i = 0usize;
+ // Lane numbers 0..16, the base of every offset vector.
+ let lanes = _mm512_set_epi32(15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0);
+
+ // SAFETY: reads end at `i + 64 + (K - 1) <= len`. Each compress-store writes exactly
+ // the matches of its quarter, within the buffer contract.
+ while i + 64 + (K - 1) <= len {
+ let bits = unsafe { match_bits64::(p.add(i), &pattern_chunks) };
+ if bits != 0 {
+ let base = _mm512_add_epi32(lanes, _mm512_set1_epi32(i as i32));
+ for quarter in 0..4 {
+ let mask = ((bits >> (16 * quarter)) & 0xFFFF) as __mmask16;
+ let offsets = _mm512_add_epi32(base, _mm512_set1_epi32(16 * quarter));
+ debug_assert!(
+ end as usize - cursor as usize
+ >= mask.count_ones() as usize * size_of::(),
+ "compress-store would write past the match buffer"
+ );
+ unsafe {
+ _mm512_mask_compressstoreu_epi32(cursor.cast(), mask, offsets);
+ cursor = cursor.add(mask.count_ones() as usize);
+ }
+ }
+ }
+ i += 64;
+ }
+ // SAFETY: `scan_tail` continues under the same contract.
+ cursor = unsafe { scan_tail::(pattern, &baseline, text, i, cursor, end) };
+ // SAFETY: `cursor` and `start` both point into `out`'s buffer.
+ unsafe { cursor.offset_from(start) as usize }
+ }
+
+ /// The counting twin of [`scan_avx512`].
+ ///
+ /// # Safety
+ /// AVX-512 F and BW must be available.
+ #[target_feature(enable = "avx512f,avx512bw")]
+ unsafe fn count_avx512(pattern: [u8; K], text: &[u8]) -> usize {
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ let p = text.as_ptr();
+ let (pattern_chunks, baseline) = splat64::(pattern);
+ let mut count = 0usize;
+ let mut i = 0usize;
+
+ // SAFETY: reads end at `i + 64 + (K - 1) <= len`. Nothing writes.
+ while i + 64 + (K - 1) <= len {
+ count += unsafe { match_bits64::(p.add(i), &pattern_chunks) }.count_ones() as usize;
+ i += 64;
+ }
+ // SAFETY: `count_tail` continues under the same read bounds.
+ count + unsafe { count_tail::(pattern, &baseline, text, i) }
+ }
+}
+
+/// One-pass scan: writes the start offset of every match of `pattern` in `text` into `out` and
+/// returns how many. The pattern must not be able to overlap itself, so that every matching
+/// position belongs to the non-overlapping match list;
+/// [`Literal::matches_into`](crate::literal::Literal::matches_into) routes self-overlapping
+/// patterns away.
+///
+/// The buffer bound is asserted here, not trusted from the caller: everything unsafe in this
+/// module leans on it, so the check lives next to what it protects.
+pub(crate) fn matches_into(
+ pattern: [u8; K],
+ text: &[u8],
+ out: &mut [u32],
+) -> usize {
+ const {
+ assert!(
+ K >= 1 && K <= 3,
+ "the scan is built for patterns of 1 to 3 bytes"
+ );
+ }
+ assert!(
+ out.len() >= text.len() / K + 4,
+ "the match buffer must hold text.len() / pattern.len() + 4 offsets"
+ );
+ #[cfg(target_arch = "x86_64")]
+ if let Some(count) = wide::matches_into_wide::(pattern, text, out) {
+ return count;
+ }
+ // SAFETY: the assert above is exactly `scan`'s buffer contract.
+ unsafe { scan::(pattern, text, out) }
+}
+
+/// # Safety
+/// `out.len() >= text.len() / K + 4` must hold; [`matches_into`] asserts it. Every match found
+/// counts one slot and matches cannot overlap, so at most `text.len() / K` slots are counted
+/// and the four extra keep `decode`'s unconditional writes inside the buffer. Reads from
+/// `text` are justified region by region below.
+unsafe fn scan(pattern: [u8; K], text: &[u8], out: &mut [u32]) -> usize {
+ let len = text.len();
+ if len < K {
+ return 0;
+ }
+ let p = text.as_ptr();
+ let mut pattern_chunks = [arch::splat(0); K];
+ for (chunk, &byte) in pattern_chunks.iter_mut().zip(&pattern) {
+ *chunk = arch::splat(byte);
+ }
+ let start = out.as_mut_ptr();
+ // SAFETY: one past the buffer's last slot is a valid address to form (never read);
+ // only `decode`'s debug bound check uses it.
+ let end = unsafe { start.add(out.len()) } as *const u32;
+ let mut cursor = start;
+ let mut i = 0usize;
+
+ // 64 bytes per round; `any` lets a match-free round skip the masks and decodes.
+ // SAFETY: the loop condition keeps every read inside `text`: the furthest `match_starts`
+ // begins at `p + i + 48` and reads `16 + K - 1` bytes, ending at `i + 64 + (K - 1) <= len`.
+ // Writes are `scan`'s buffer contract, handed to `decode` as `end`.
+ while i + 64 + (K - 1) <= len {
+ let (s0, s1, s2, s3) = unsafe {
+ (
+ arch::match_starts::(p.add(i), &pattern_chunks),
+ arch::match_starts::(p.add(i + 16), &pattern_chunks),
+ arch::match_starts::(p.add(i + 32), &pattern_chunks),
+ arch::match_starts::(p.add(i + 48), &pattern_chunks),
+ )
+ };
+ if arch::any(s0, s1, s2, s3) {
+ unsafe {
+ cursor = decode(arch::bits(s0), i as u32, cursor, end);
+ cursor = decode(arch::bits(s1), i as u32 + 16, cursor, end);
+ cursor = decode(arch::bits(s2), i as u32 + 32, cursor, end);
+ cursor = decode(arch::bits(s3), i as u32 + 48, cursor, end);
+ }
+ }
+ i += 64;
+ }
+
+ cursor = unsafe { scan_tail::(pattern, &pattern_chunks, text, i, cursor, end) };
+ // SAFETY: `cursor` and `start` both point into `out`'s buffer.
+ unsafe { cursor.offset_from(start) as usize }
+}
+
+/// The remainder every scan front end shares, once its 64-byte rounds got to `i`: 16-byte
+/// rounds, then one overlapped final block, or a byte-by-byte walk when the whole text is
+/// shorter than a block. Returns the advanced cursor.
+///
+/// # Safety
+/// Same contract as [`scan`]: reads are bounded by the loop conditions, and `cursor .. end`
+/// must have room for every remaining match plus `decode`'s four-slot slack.
+unsafe fn scan_tail(
+ pattern: [u8; K],
+ pattern_chunks: &[arch::Chunk; K],
+ text: &[u8],
+ mut i: usize,
+ mut cursor: *mut u32,
+ end: *const u32,
+) -> *mut u32 {
+ let len = text.len();
+ let p = text.as_ptr();
+
+ // 16 bytes per round over what the 64-byte rounds left.
+ // SAFETY: reads end at `i + 16 + (K - 1) <= len`; writes as in [`scan`].
+ while i + 16 + (K - 1) <= len {
+ cursor = unsafe {
+ decode(
+ arch::bits(arch::match_starts::(p.add(i), pattern_chunks)),
+ i as u32,
+ cursor,
+ end,
+ )
+ };
+ i += 16;
+ }
+
+ if i + K <= len {
+ if len >= 16 + (K - 1) {
+ // One last 16-byte block, moved back to end flush with the last position a match
+ // can still start at. Its low bits re-cover positions the rounds above already
+ // decoded, so they are masked off.
+ // SAFETY: the block reads `base .. base + 16 + K - 1`, which ends exactly at `len`;
+ // writes as in [`scan`].
+ let base = len - 16 - (K - 1);
+ unsafe {
+ let mut bits = arch::bits(arch::match_starts::(p.add(base), pattern_chunks));
+ bits &= !0u64 << ((i - base) << arch::SHIFT);
+ cursor = decode(bits, base as u32, cursor, end);
+ }
+ } else {
+ // The whole text is shorter than one block: byte-by-byte costs nothing here.
+ while i + K <= len {
+ if text[i..i + K] == pattern[..] {
+ debug_assert!(end as usize - cursor as usize >= size_of::());
+ // SAFETY: one slot per match; the buffer holds a slot for every possible
+ // match plus four ([`matches_into`]'s assert).
+ unsafe {
+ *cursor = i as u32;
+ cursor = cursor.add(1);
+ }
+ }
+ i += 1;
+ }
+ }
+ }
+ cursor
+}
diff --git a/tokenizers/atomsplit/tests/fsm.rs b/tokenizers/atomsplit/tests/fsm.rs
index 1cad95e1b..3aa75ab7f 100644
--- a/tokenizers/atomsplit/tests/fsm.rs
+++ b/tokenizers/atomsplit/tests/fsm.rs
@@ -1,10 +1,48 @@
//! Integration tests for the FSM pre-tokenizers. Kept out of `src/` so the core stays production-only.
use atomsplit::classify::{classify, mask};
use atomsplit::fsm::{
- CharDelimiterSplit, Span, class_runs_into, emit_class_spans, fsm_byte_level, fsm_cl100k,
- fsm_deepseek, fsm_o200k, fsm_tekken,
+ Span, class_runs_into, emit_class_spans, fsm_byte_level, fsm_cl100k, fsm_deepseek, fsm_o200k,
+ fsm_tekken, scan_byte_level, scan_byte_level_masked, scan_cl100k_cap, scan_cl100k_cap_masked,
+ scan_deepseek, scan_deepseek_masked, scan_o200k, scan_o200k_masked, scan_tekken,
+ scan_tekken_masked,
};
+/// The shared sweep for a masked/scalar scanner pair: compare spans on the corpus behind
+/// every 64-byte-edge offset (leading padding 0..=70), and with truncations exercising the
+/// scalar tail at every remaining length.
+type ScanFn = dyn Fn(&[u8], &[u8], &mut dyn FnMut(Span));
+
+fn masked_matches_scalar(corpus: &str, scalar: &ScanFn, masked: &ScanFn) {
+ fn spans_of(scan: &ScanFn, s: &[u8]) -> Vec {
+ let mut tags = vec![0u8; s.len()];
+ classify(s, &mut tags);
+ let mut v = Vec::new();
+ scan(s, &tags, &mut |sp| v.push(sp));
+ v
+ }
+ let check = |s: &str| {
+ let a = spans_of(scalar, s.as_bytes());
+ let b = spans_of(masked, s.as_bytes());
+ assert_eq!(b, a, "input len {}: {:?}", s.len(), s);
+ };
+ for pad in 0..=70 {
+ check(&format!("{}{}", "x".repeat(pad), corpus));
+ }
+ for pad in [0, 37] {
+ let padded = format!("{}{}", "x".repeat(pad), corpus);
+ for len in padded.len().saturating_sub(140)..=padded.len() {
+ if padded.is_char_boundary(len) {
+ check(&padded[..len]);
+ }
+ }
+ }
+ for len in 0..=70 {
+ if corpus.is_char_boundary(len) {
+ check(&corpus[..len]);
+ }
+ }
+}
+
/// Run a no-push fsm into a fresh buffer and return the emitted spans.
fn spans(f: impl Fn(&[u8], &[u8], &mut [Span]) -> usize, s: &str) -> Vec {
let mut tags = vec![0u8; s.len()];
@@ -48,6 +86,111 @@ fn deepseek_rules() {
assert_eq!(ds("!!!"), vec![(0, 3)]); // \p{P}∪\p{S} run
}
+/// Byte-exactness gate for the masked byte-level scanner: `scan_byte_level_masked` must emit the
+/// spans `scan_byte_level` emits, on every input. The corpus stresses every rule shape the batch
+/// algebra rewrites: contractions in both cases and at rejection edges, prefix spaces before all
+/// three run classes, unbounded numbers including non-ASCII digits, multi-byte whitespace (the
+/// bad-zone route), CJK/emoji/ZWJ runs, CRLF and tab runs, and runs longer than a batch. The
+/// padding sweep (0..=70 leading bytes) moves every shape across every 64-byte batch edge, so
+/// batch-edge carries, the bit-63 lookahead and every bad-zone route are hit at every offset; the
+/// truncation sweep exercises the scalar tail at every remaining length.
+#[test]
+fn masked_scan_matches_scan_byte_level() {
+ let corpus = concat!(
+ "I'm 12345 ok, don't they'll 've 'lx x's ''s IT'S 's mid'dle end' ",
+ "hello world\r\nmore\ttabs\t\t spaced end ",
+ "no.1 中文漢字テスト مرحبا १२३४५६७८९० ¹²³ ½¾ ",
+ "emoji 😀😀 zwj 👩\u{200d}🔬 nbsp\u{a0}x thin\u{2009}y wide\u{3000}z ",
+ "((()))!!!??? #$%&' “curly” apostrophe’d ",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ",
+ "9999999999999999999999999999999999999999999999999999999999999999999999999999999 ",
+ "中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中 end",
+ );
+ masked_matches_scalar(
+ corpus,
+ &|t, tg, e| scan_byte_level(t, tg, e),
+ &|t, tg, e| scan_byte_level_masked(t, tg, e),
+ );
+}
+
+/// Byte-exactness gate for the masked cl100k-family scanner, at every shipped digit cap. On top
+/// of the byte-level shapes: capped digit runs (pure ASCII, pure Devanagari, and mixed, so the
+/// char-counted bad route is hit mid-run), letters after punct at run starts vs mid-run (the
+/// two-chars-back absorb test), newlines absorbed after punct runs (`[\r\n]*`), whitespace runs
+/// with interior newlines (`\s*[\r\n]`), tab prefixes, and an apostrophe before a non-ASCII
+/// char (the `'ſ` defer).
+#[test]
+fn masked_scan_matches_scan_cl100k_cap() {
+ let corpus = concat!(
+ "I'm 12345 ok, don't they'LL 've 'lx x's ''s IT'S 's 3'ts end' ",
+ "a1234 12٣45 ١٢٣٤٥٦ १२३४५६७८९० 999999999999999999999999999999999999999999999999 ",
+ "!!a x!a ?!x. ;\n\n};\r\n\r\n foo();\nbar() //x\n\n\n end\n",
+ "hello world\r\nmore\ttabs\t\t spaced \ta \r\n \n\t\r\n\t x ",
+ "no.1 中文漢字テスト مرحبا nbsp\u{a0}x thin\u{2009}y wide\u{3000}z 'ſok “curly”’d ",
+ "((()))!!!??? #$%&' 😀😀 👩\u{200d}🔬 ",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa ",
+ "中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中中 end",
+ );
+ for cap in [3, 1, usize::MAX] {
+ masked_matches_scalar(
+ corpus,
+ &move |t, tg, e| scan_cl100k_cap(t, tg, cap, e),
+ &move |t, tg, e| scan_cl100k_cap_masked(t, tg, cap, e),
+ );
+ }
+}
+
+/// Byte-exactness gate for the masked o200k/tekken scanner. On top of the cl100k shapes: case
+/// splits (camelCase, all-upper runs, upper after caseless — the deferred backtrack), suffix
+/// contractions incl. chains ("can'ts", "x'll'd") and prefix apostrophes after digits, `[\r\n/]*`
+/// tails with slash runs before and after newlines (the walkback shapes), and combining marks
+/// (run-contextual class, the wide bad smear).
+#[test]
+fn masked_scan_matches_scan_o200k_and_tekken() {
+ let corpus = concat!(
+ "camelCase HTTPResponse XMLHttpRequest AAAA aaaa aA Aa 中B B中b ʰupper Xʰa 中中中中 ",
+ "don't they'LL CAN'TS x'll'd 3'ts I'm'll a'sit 's'' end' 'ſok ",
+ "a1234 12٣45 १२३४५६ 99999999999999999999999999999999999999999999999999999999999999999 ",
+ "foo();\nbar() .\n// ///x\r\n/// a/b//c\n\n//d e\u{301}f g\u{5bf}h zwj\u{200c}x ",
+ "hello world\r\nmore\ttabs\t\t spaced \ta \r\n \n\t\r\n\t x nbsp\u{a0}x wide\u{3000}z ",
+ "((()))!!!??? #$%&' “curly”’d 😀😀 مرحبا מבחן ",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa end"
+ );
+ masked_matches_scalar(
+ corpus,
+ &|t, tg, e| scan_o200k(t, tg, e),
+ &|t, tg, e| scan_o200k_masked(t, tg, e),
+ );
+ masked_matches_scalar(
+ corpus,
+ &|t, tg, e| scan_tekken(t, tg, e),
+ &|t, tg, e| scan_tekken_masked(t, tg, e),
+ );
+}
+
+/// Byte-exactness gate for the masked deepseek scanner. On top of the shared shapes: CJK
+/// letter/punct runs and their neighborhoods (the closed-unit rule and the bad cover), gap runs
+/// (controls / NumericOther / ZWJ) with and without a following letter run (the prefix split),
+/// alt-1 `[ascii-punct][A-Za-z]+` including collisions with non-ASCII letters ("_naïve"), and
+/// whitespace runs followed by digits or CJK (no give-back).
+#[test]
+fn masked_scan_matches_scan_deepseek() {
+ let corpus = concat!(
+ "abc中def 中文漢字テスト!ひらがな・カタカナ 中中中中中中中 mixed中123中ok 拼音列表(e.g. 表!x ",
+ "_abc (foo) .py x!a _naïve _né !!a a/b.c 3'ts don't ½x ¼¼y\u{7f}z zwj\u{200c}gap\u{200c}\u{200c}word ",
+ "a1234 12٣45 999999999999999999999999999999999999999999999999999999999999999999 12 中 ",
+ "x 123 y\t\t\t45 z \n 99 w 中 Model):\n money = f(x):\n\n\t indent ",
+ "hello world\r\nmore\ttabs\t\t spaced \ta \r\n \n\t\r\n\t x wide\u{3000}z 中 12\n\n34 ",
+ "((()))!!!??? #$%&' “curly”’d 😀😀 مرحبا ",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa end"
+ );
+ masked_matches_scalar(
+ corpus,
+ &|t, tg, e| scan_deepseek(t, tg, e),
+ &|t, tg, e| scan_deepseek_masked(t, tg, e),
+ );
+}
+
#[test]
fn byte_level_rules() {
let bl = |s| spans(fsm_byte_level, s);
@@ -57,14 +200,6 @@ fn byte_level_rules() {
assert_eq!(bl("hi ok"), vec![(0, 2), (2, 4), (4, 7)]); // \s+(?!\S) leaves one space
}
-#[test]
-fn char_delimiter_split() {
- let mut out = vec![Span::default(); 8];
- // split on '/', Removed → drop delimiters, drop the empty gap between "//"
- let k = CharDelimiterSplit('/').pre_tokenize(b"a/bc//d", &mut [], &mut out);
- assert_eq!(&out[..k], &[(0, 1), (2, 4), (6, 7)]);
-}
-
/// Byte-exactness gate for the class family: the NEON boundary extractor (`class_runs_into`) must equal
/// the scalar run-end core (`emit_class_spans`) for every recipe, at every char-aligned truncation length so
/// the < 16-byte NEON tail starts at every offset — including mid-char (chunk loop steps by 16). Corpus
diff --git a/tokenizers/atomsplit/tests/literal.rs b/tokenizers/atomsplit/tests/literal.rs
index 14ceed9a0..894dde46a 100644
--- a/tokenizers/atomsplit/tests/literal.rs
+++ b/tokenizers/atomsplit/tests/literal.rs
@@ -48,3 +48,203 @@ fn an_empty_pattern_is_rejected() {
"an empty pattern matches everywhere"
);
}
+
+// ---- the batch scan, `matches_into` ----
+
+/// Run `matches_into` with a buffer of exactly the documented size and return what it wrote.
+fn batch(literal: &Literal, text: &[u8]) -> Vec {
+ let mut out = vec![0u32; text.len() / literal.pattern().len() + 4];
+ let count = literal.matches_into(text, &mut out);
+ out.truncate(count);
+ out
+}
+
+fn iterated(literal: &Literal, text: &[u8]) -> Vec {
+ literal.matches(text).map(|p| p as u32).collect()
+}
+
+#[test]
+fn matches_into_reports_the_iterator_positions() {
+ let dash = Literal::new(b"-").unwrap();
+ assert_eq!(batch(&dash, b"a-b--c"), [1, 3, 4]);
+ assert_eq!(batch(&dash, b"-starts and ends-"), [0, 16]);
+ assert_eq!(batch(&dash, b"none here"), []);
+ assert_eq!(batch(&dash, b""), []);
+
+ let space = Literal::new(b" ").unwrap();
+ let text = b"the quick brown fox jumps over the lazy dog".repeat(40);
+ assert_eq!(batch(&space, &text), iterated(&space, &text));
+
+ let metaspace = Literal::new("\u{2581}".as_bytes()).unwrap();
+ let text = "\u{2581}the\u{2581}quick\u{2581}brown\u{2581}fox".repeat(40);
+ assert_eq!(
+ batch(&metaspace, text.as_bytes()),
+ iterated(&metaspace, text.as_bytes())
+ );
+}
+
+/// The scan works in blocks; a match starting on either side of an internal block edge, or on
+/// the last position where the pattern still fits, must not be missed or doubled.
+#[test]
+fn matches_into_is_seamless_across_block_boundaries() {
+ for pattern in [&b"-"[..], "\u{2581}".as_bytes()] {
+ let literal = Literal::new(pattern).unwrap();
+ for start in [0, 13, 14, 15, 16, 17, 61, 62, 63, 64, 65, 127, 128] {
+ for len in [start + pattern.len(), 90, 129, 200] {
+ if start + pattern.len() > len {
+ continue;
+ }
+ let mut text = vec![b'x'; len];
+ text[start..start + pattern.len()].copy_from_slice(pattern);
+ assert_eq!(
+ batch(&literal, &text),
+ [start as u32],
+ "pattern {pattern:?} at {start} in {len} bytes"
+ );
+ }
+ }
+ }
+}
+
+/// Every position matches: the largest count a text can produce, written into a buffer of
+/// exactly the documented size.
+#[test]
+fn matches_into_handles_the_densest_text() {
+ let a = Literal::new(b"a").unwrap();
+ let text = vec![b'a'; 300];
+ assert_eq!(batch(&a, &text), (0..300).collect::>());
+
+ let metaspace = Literal::new("\u{2581}".as_bytes()).unwrap();
+ let text = "\u{2581}".repeat(100);
+ assert_eq!(
+ batch(&metaspace, text.as_bytes()),
+ (0..100).map(|i| i * 3).collect::>()
+ );
+}
+
+#[test]
+fn a_self_overlapping_pattern_keeps_non_overlapping_matches() {
+ let aa = Literal::new(b"aa").unwrap();
+ assert_eq!(batch(&aa, b"aaa"), [0]);
+ let run = [b'a'; 100];
+ assert_eq!(batch(&aa, &run), iterated(&aa, &run));
+
+ let aba = Literal::new(b"aba").unwrap();
+ assert_eq!(batch(&aba, b"ababa"), [0]);
+}
+
+#[test]
+fn a_pattern_longer_than_three_bytes_still_matches() {
+ let literal = Literal::new(b"abcd").unwrap();
+ let text = b"abcd xabcdx abcabcd".repeat(20);
+ assert_eq!(batch(&literal, &text), iterated(&literal, &text));
+}
+
+#[test]
+#[should_panic(expected = "matches_into")]
+fn an_undersized_buffer_is_rejected() {
+ let a = Literal::new(b"a").unwrap();
+ let mut out = vec![0u32; 7]; // "aaaa" needs 4 / 1 + 4 = 8
+ a.matches_into(b"aaaa", &mut out);
+}
+
+// ---- the streaming pair, `count_matches` + `for_each_match` ----
+
+fn streamed(literal: &Literal, text: &[u8]) -> Vec {
+ let mut out = Vec::new();
+ literal.for_each_match(text, |start| out.push(start as u32));
+ out
+}
+
+/// The streaming scan works through a fixed window; a text much longer than any plausible
+/// window must report the same matches as the iterator, including around every window edge.
+#[test]
+fn for_each_match_streams_the_iterator_offsets_across_windows() {
+ for pattern in [&b" "[..], "\u{2581}".as_bytes(), b"ab"] {
+ let literal = Literal::new(pattern).unwrap();
+ let mut text = b"word ".repeat(4_000); // ~20KB, matches every 5 bytes
+ text.extend_from_slice("\u{2581}ab".repeat(2_000).as_bytes());
+ assert_eq!(streamed(&literal, &text), iterated(&literal, &text));
+ assert_eq!(
+ literal.count_matches(&text),
+ literal.matches(&text).count(),
+ "count for {pattern:?}"
+ );
+ }
+}
+
+/// The densest possible long text: every byte starts a match, in every window.
+#[test]
+fn for_each_match_handles_dense_windows() {
+ let a = Literal::new(b"a").unwrap();
+ let text = vec![b'a'; 10_000];
+ assert_eq!(streamed(&a, &text), (0..10_000).collect::>());
+ assert_eq!(a.count_matches(&text), 10_000);
+}
+
+#[test]
+fn for_each_match_takes_the_iterator_route_for_uncovered_patterns() {
+ // Self-overlapping and longer-than-three patterns are exactly the ones the batch scan
+ // refuses; the streaming pair must still report the iterator's non-overlapping matches.
+ for pattern in [&b"aa"[..], b"aba", b"abcd"] {
+ let literal = Literal::new(pattern).unwrap();
+ let text = b"aabaabcdaaaba".repeat(2_000);
+ assert_eq!(streamed(&literal, &text), iterated(&literal, &text));
+ assert_eq!(literal.count_matches(&text), literal.matches(&text).count());
+ }
+}
+
+#[test]
+fn streaming_agrees_with_the_iterator_on_random_lengths() {
+ let mut state = 0x1234_5678_9ABC_DEF0u64;
+ let mut next = move || {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ state
+ };
+ let patterns: &[&[u8]] = &[b"a", b"ab", b"aa", b"aba", b"abc", b"abca"];
+ for _ in 0..500 {
+ // lengths spread around plausible window sizes, so edges get hit both exactly and off by one
+ let len = (next() % 8_000) as usize;
+ let text: Vec = (0..len).map(|_| b'a' + (next() % 3) as u8).collect();
+ let literal = Literal::new(patterns[(next() % patterns.len() as u64) as usize]).unwrap();
+ assert_eq!(
+ streamed(&literal, &text),
+ iterated(&literal, &text),
+ "pattern {:?} len {len}",
+ literal.pattern()
+ );
+ assert_eq!(literal.count_matches(&text), literal.matches(&text).count());
+ }
+}
+
+#[test]
+fn matches_into_agrees_with_the_iterator_on_random_inputs() {
+ // xorshift64: deterministic, no dev-dependency needed.
+ let mut state = 0x9E37_79B9_7F4A_7C15u64;
+ let mut next = move || {
+ state ^= state << 13;
+ state ^= state >> 7;
+ state ^= state << 17;
+ state
+ };
+ let patterns: &[&[u8]] = &[
+ b"a", b"b", b"ab", b"aa", b"ba", b"aab", b"aba", b"abc", b"abca",
+ ];
+ for _ in 0..20_000 {
+ let len = (next() % 300) as usize;
+ let alphabet = 2 + (next() % 2) as u8;
+ let text: Vec = (0..len)
+ .map(|_| b'a' + (next() % alphabet as u64) as u8)
+ .collect();
+ let literal = Literal::new(patterns[(next() % patterns.len() as u64) as usize]).unwrap();
+ assert_eq!(
+ batch(&literal, &text),
+ iterated(&literal, &text),
+ "pattern {:?} text {:?}",
+ literal.pattern(),
+ text
+ );
+ }
+}
diff --git a/tokenizers/tk-encode/examples/fixture_bench.rs b/tokenizers/tk-encode/examples/fixture_bench.rs
index 9ac6d89bf..947e566e5 100644
--- a/tokenizers/tk-encode/examples/fixture_bench.rs
+++ b/tokenizers/tk-encode/examples/fixture_bench.rs
@@ -20,7 +20,10 @@
//! on, so the headline includes the post-process stage the ladder charges.
//! 2. **Stage breakdown** — the `encode_generic::` ablation ladder plus
//! the pre-tokenize-vs-regex-engine references, on fresh caller-owned
-//! scratches, fully separate from the phase-1 timings.
+//! scratches, fully separate from the phase-1 timings. Every rung drives the
+//! same route a full `encode` takes (fused scan, zero-copy or staged, reported
+//! as `route`), so the split bar is the splitter that actually ships and the
+//! model bar is a difference between two runs of it. See `stage_secs`.
//! 3. **Scaling & memory** — a multi-thread throughput sweep (1/2/4/8/max) over
//! the whole corpus on fresh instances, and resident-set deltas measured by
//! re-spawning this binary as `--memory ` children — one
@@ -299,6 +302,23 @@ fn bench_throughput(
/// successive levels and subtracting gives each stage's marginal cost (the ablation
/// ladder), no profiler and no per-segment instrumentation.
///
+/// Every rung runs the **route a full encode runs**, which is what makes subtracting
+/// them meaningful. A fused pipeline's split rung drives the same masked FSM scan the
+/// model rung does, emitting each span into `pre_tokens` instead of the model; a
+/// zero-copy pipeline's split rung is its proven-cut pass, with neither the rewrite
+/// write nor the added-token scan over rewritten text that the route never performs.
+/// Two consequences to read the numbers with:
+///
+/// - The split rung pays one span write per pre-token that a full fused encode does not
+/// (it hands the span straight to the model), so the split bar reads a little high and
+/// the model bar a little low.
+/// - The `&str` slice the model call makes is charged to the model, not to the split. On
+/// these corpora that is UTF-8 boundary checking, and it is not a small share.
+///
+/// Before this was route-aware the split rung ran the *staged* array FSM, a splitter no
+/// shipping encode uses. That put the split bar about twice too high and made the model
+/// bar a subtraction across two different pipelines.
+///
/// The scratch is created fresh here (never taken from the pipeline's pool), so the
/// stage numbers are warmed on this fixture alone and can't perturb — or be flattered
/// by — the phase-1 cache state. Both caller-owned buffers are reused across chunks
@@ -333,6 +353,46 @@ fn stage_secs(pipeline: &PipelineTokenizer, chunks: &[String])
median_secs(samples)
}
+/// The top of the ladder with the output and span buffers allocated **per chunk**, the way
+/// `PipelineTokenizer::encode` does for every call. The pipeline, the fresh scratch and
+/// the warm-up are all identical to `stage_secs`, so subtracting `t_post` isolates
+/// allocation with no other variable in play.
+///
+/// It comes out near zero on these corpora: growing and freeing the output `Vec` is not a
+/// cost worth chasing. It is reported anyway, because it looks like one.
+///
+/// Timing the real `encode` here instead was tried and dropped. Phase 2 shares one pipeline
+/// across every fixture, so its scratch pool holds the words of all previously benched
+/// corpora, and against 64k slots that saturates. The delta then came out larger than the
+/// whole encode: it measures cache eviction, not call overhead. A number that size in the
+/// report would have read as a per-call cost.
+fn fresh_buffer_secs(pipeline: &PipelineTokenizer, chunks: &[String]) -> f64 {
+ let mut scratch = pipeline.get_model().init_scratch();
+ let mut run = || {
+ for chunk in chunks {
+ let mut out = Vec::new();
+ let mut pre_tokens = Vec::new();
+ let _ = pipeline.encode_generic::<{ PipelineTokenizer::STAGE_POSTPROCESS }>(
+ chunk,
+ true,
+ &mut pre_tokens,
+ &mut scratch,
+ &mut out,
+ );
+ black_box(&out);
+ black_box(&pre_tokens);
+ }
+ };
+ run(); // warm-up
+ let mut samples = Vec::with_capacity(REPS);
+ for _ in 0..REPS {
+ let start = Instant::now();
+ run();
+ samples.push(start.elapsed().as_secs_f64());
+ }
+ median_secs(samples)
+}
+
/// Stage decomposition + regex-engine references for one fixture: the
/// `stage_ns_per_byte` and `pretok_vs_regex` objects of its report row.
fn bench_stages(pipeline: &PipelineTokenizer, f: &Fixture, regexes: &[String]) -> (Value, Value) {
@@ -341,10 +401,23 @@ fn bench_stages(pipeline: &PipelineTokenizer, f: &Fixture, regexes: &[String]) -
let t_split = stage_secs::<{ PipelineTokenizer::STAGE_SPLIT }>(pipeline, &f.chunks);
let t_model = stage_secs::<{ PipelineTokenizer::STAGE_MODEL }>(pipeline, &f.chunks);
let t_post = stage_secs::<{ PipelineTokenizer::STAGE_POSTPROCESS }>(pipeline, &f.chunks);
+ let t_fresh = fresh_buffer_secs(pipeline, &f.chunks);
// Two distinct "split" costs: `added_split` is the added/special-token scan (the
// SpecialSegmentIterator over the AddedVocabulary, captured by the FRAME level),
- // `pre_tokenize` is the pre-tokenizer split, `post` the special-token id-frame
- // splice. All five stages sum exactly to `total`.
+ // `pre_tokenize` is the pre-tokenizer split *as this model's route performs it*,
+ // `post` the special-token id-frame splice. The five stages sum to `total`, which is
+ // the ladder's own full encode.
+ //
+ // `clamped` is what the five do NOT account for: the rungs are independent medians, so
+ // a stage at or below the noise floor goes negative and clamps to zero, after which the
+ // parts sum slightly above `total`. A `clamped` that is not near zero means this
+ // fixture's rungs are noise-dominated and its breakdown should not be trusted.
+ //
+ // `alloc` is the one cost outside the decomposition that can be isolated cleanly; see
+ // `fresh_buffer_secs`. `total` is still below what phase 1 measures through the public
+ // `encode`, which also acquires a pooled scratch and hands back an owned `Vec`; that
+ // remainder is NOT reported here, because no measurement in this bench separates it
+ // from the pool's cache state. Better an acknowledged gap than a mislabelled bar.
let nspb = |secs: f64| secs * 1e9 / f.bytes as f64;
let (ns_added, ns_norm, ns_split, ns_model, ns_post) = (
nspb(t_frame.max(0.0)),
@@ -353,15 +426,23 @@ fn bench_stages(pipeline: &PipelineTokenizer, f: &Fixture, regexes: &[String]) -
nspb((t_model - t_split).max(0.0)),
nspb((t_post - t_model).max(0.0)),
);
+ let ns_total = nspb(t_post);
+ let clamped = ns_total - (ns_added + ns_norm + ns_split + ns_model + ns_post);
+ let ns_alloc = nspb(t_fresh - t_post);
eprintln!(
- " {} stages ns/byte: added-split {ns_added:.2}, norm {ns_norm:.2}, pre-split {ns_split:.2}, model {ns_model:.2}, post {ns_post:.2}",
+ " {} stages ns/byte: added-split {ns_added:.2}, norm {ns_norm:.2}, pre-split {ns_split:.2}, model {ns_model:.2}, post {ns_post:.2} → total {ns_total:.2} (clamped {clamped:+.2}) · buffer alloc {ns_alloc:+.2}",
f.name
);
- // pre_tokenize (= classify SIMD + fsm) vs classify-scalar and vs real regex engines
- // over the same corpus, so the report shows the split beating a regex engine both
- // WITH and WITHOUT SIMD. `scalar_pipe` = pre_tokenize + (cls_scalar − cls_simd):
- // fsm is the scalar jump-table in both pipes, SIMD/scalar is the classify pass only.
+ // The shipped split vs real regex engines over the same corpus. `ns_split` is now the
+ // route's own splitter, so these ratios describe the code that runs. Before the ladder
+ // was route-aware they compared the engines against the staged array FSM and undersold
+ // the split by roughly 2x.
+ //
+ // `scalar_pipe` = ns_split + (cls_scalar − cls_simd) swaps *only* the classify pass
+ // for its scalar version. It is not "the split without SIMD": for a regex-shaped FSM
+ // the scan is a boundary-mask scanner, so SIMD lives in the scan too and cannot be
+ // subtracted out this way. Read it as the split with a scalar classify pass.
let corpus: String = f.chunks.concat();
let cls_simd = classify_ns(corpus.as_bytes(), false);
let cls_scalar = classify_ns(corpus.as_bytes(), true);
@@ -384,7 +465,7 @@ fn bench_stages(pipeline: &PipelineTokenizer, f: &Fixture, regexes: &[String]) -
})
};
eprintln!(
- " {} pre-tok: SIMD-cls {ns_split:.2} / scalar-cls {scalar_pipe:.2} ns/B · vs onig {} · vs fancy {} · vs pcre2 {} · vs logos {}",
+ " {} split: {ns_split:.2} / scalar-classify {scalar_pipe:.2} ns/B · vs onig {} · vs fancy {} · vs pcre2 {} · vs logos {}",
f.name,
vs(onig_ns),
vs(fancy_ns),
@@ -400,7 +481,9 @@ fn bench_stages(pipeline: &PipelineTokenizer, f: &Fixture, regexes: &[String]) -
"pre_tokenize": ns_split,
"model": ns_model,
"post": ns_post,
- "total": nspb(t_post),
+ "total": ns_total,
+ "clamped": clamped,
+ "alloc": ns_alloc,
}),
json!({
"cls_simd": cls_simd,
@@ -1188,6 +1271,9 @@ fn main() {
models.push(json!({
"model": name, "desc": desc, "shape": shape,
+ // Which encode route the stage numbers decompose: the three split the work
+ // differently, so a stage chart is only readable against the route it timed.
+ "route": pipeline.encode_route().as_str(),
"results": rows, "memory": memory, "threads": threads,
"decode_threads": decode_threads, "decode_reason": decode_reason,
}));
diff --git a/tokenizers/tk-encode/examples/fold_ab.rs b/tokenizers/tk-encode/examples/fold_ab.rs
new file mode 100644
index 000000000..65e57aa27
--- /dev/null
+++ b/tokenizers/tk-encode/examples/fold_ab.rs
@@ -0,0 +1,80 @@
+//! One end-to-end throughput number for one model on one corpus, for A/B runs
+//! between two builds of this crate (fold on vs fold off, run alternately at
+//! process level so binary-layout noise averages out).
+//!
+//! Mirrors `fixture_bench`'s throughput phase: single thread, ~10 kB chunks,
+//! `add_special_tokens` on, one warm-up pass to fill the caches, then the
+//! median of the timed passes.
+//!
+//! cargo run --release --example fold_ab -- [passes]
+
+use std::convert::TryFrom;
+use std::time::Instant;
+
+use tk_encode::Tokenizer;
+use tk_encode::pipeline::PipelineTokenizer;
+
+const CHUNK: usize = 10_000;
+const CORPUS_CAP: usize = 4 << 20;
+
+fn main() {
+ let args: Vec = std::env::args().collect();
+ let [_, model, corpus] = &args[..3] else {
+ eprintln!("usage: fold_ab [passes]");
+ std::process::exit(2);
+ };
+ let passes: usize = args.get(3).map_or(5, |p| p.parse().unwrap());
+
+ let tok = Tokenizer::from_file(model).unwrap();
+ let pipeline = PipelineTokenizer::try_from(&tok).unwrap();
+
+ let mut text = std::fs::read_to_string(corpus).unwrap();
+ if text.len() > CORPUS_CAP {
+ let mut cap = CORPUS_CAP;
+ while !text.is_char_boundary(cap) {
+ cap -= 1;
+ }
+ text.truncate(cap);
+ }
+ let chunks: Vec<&str> = {
+ let mut chunks = Vec::new();
+ let mut rest = text.as_str();
+ while !rest.is_empty() {
+ let mut cut = CHUNK.min(rest.len());
+ while !rest.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ let (head, tail) = rest.split_at(cut);
+ chunks.push(head);
+ rest = tail;
+ }
+ chunks
+ };
+ let bytes: usize = chunks.iter().map(|c| c.len()).sum();
+
+ let encode_all = || {
+ let mut ids = 0usize;
+ for chunk in &chunks {
+ ids += pipeline.encode(chunk, true).unwrap().len();
+ }
+ ids
+ };
+
+ let ids = encode_all(); // warm-up: fills the scratch pool and the word cache
+ let mut timings: Vec = (0..passes)
+ .map(|_| {
+ let start = Instant::now();
+ encode_all();
+ start.elapsed().as_secs_f64()
+ })
+ .collect();
+ timings.sort_by(f64::total_cmp);
+ let median = timings[timings.len() / 2];
+
+ println!(
+ "{:.3} MB/s ({} B, {} ids, {passes} passes)",
+ bytes as f64 / median / 1e6,
+ bytes,
+ ids,
+ );
+}
diff --git a/tokenizers/tk-encode/examples/normalize_ab.rs b/tokenizers/tk-encode/examples/normalize_ab.rs
new file mode 100644
index 000000000..2c8a8304a
--- /dev/null
+++ b/tokenizers/tk-encode/examples/normalize_ab.rs
@@ -0,0 +1,98 @@
+//! A/B harness for changes to the pipeline's normalize stage: for each tokenizer config given on
+//! the command line, times the `encode_generic` ablation ladder over `data/big.txt` and prints the
+//! frame and normalize levels plus the full encode throughput.
+//!
+//! cargo run --release -p tk-encode --example normalize_ab -- data/llama-2.json data/gemma-4.json
+//!
+//! Binary layout alone moves numbers by a few percent, so never compare two builds from one
+//! process each: build one binary per side, keep both, and alternate runs.
+
+use std::convert::TryFrom;
+use std::hint::black_box;
+use std::path::Path;
+use std::time::Instant;
+
+use tk_encode::Tokenizer;
+use tk_encode::pipeline::{Model, PipelineTokenizer};
+
+const DATA_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data");
+/// Per-input-overhead is amortized at this size; same regime as `fixture_bench`.
+const CHUNK_BYTES: usize = 10 * 1024;
+const TOTAL_BYTES: usize = 4 * 1024 * 1024;
+const REPS: usize = 9;
+
+fn median(mut samples: Vec) -> f64 {
+ samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ samples[samples.len() / 2]
+}
+
+/// Median seconds of one warm pass over `chunks` at ladder level `STAGE`;
+/// same shape as `fixture_bench::stage_secs`.
+fn stage_secs(pipeline: &PipelineTokenizer, chunks: &[&str]) -> f64 {
+ let mut out = Vec::new();
+ let mut pre_tokens = Vec::new();
+ let mut scratch = pipeline.get_model().init_scratch();
+ let mut run = || {
+ for chunk in chunks {
+ out.clear();
+ let _ = pipeline.encode_generic::(
+ chunk,
+ true,
+ &mut pre_tokens,
+ &mut scratch,
+ &mut out,
+ );
+ black_box(&out);
+ black_box(&pre_tokens);
+ }
+ };
+ run(); // warm-up
+ let mut samples = Vec::with_capacity(REPS);
+ for _ in 0..REPS {
+ let start = Instant::now();
+ run();
+ samples.push(start.elapsed().as_secs_f64());
+ }
+ median(samples)
+}
+
+/// The first `TOTAL_BYTES` of big.txt in `CHUNK_BYTES` pieces, cut on char boundaries.
+fn chunks_of(text: &str) -> Vec<&str> {
+ let mut chunks = Vec::new();
+ let mut start = 0;
+ while start < text.len().min(TOTAL_BYTES) {
+ let mut end = (start + CHUNK_BYTES).min(text.len());
+ while !text.is_char_boundary(end) {
+ end += 1;
+ }
+ chunks.push(&text[start..end]);
+ start = end;
+ }
+ chunks
+}
+
+fn main() {
+ let text = std::fs::read_to_string(Path::new(DATA_DIR).join("big.txt"))
+ .expect("data/big.txt (fetch with `make data/big.txt`)");
+ let chunks = chunks_of(&text);
+ let bytes: usize = chunks.iter().map(|c| c.len()).sum();
+
+ for config in std::env::args().skip(1) {
+ let tok = Tokenizer::from_file(&config).expect("tokenizer config");
+ let pipeline = PipelineTokenizer::try_from(&tok).expect("pipeline builds");
+
+ let t_frame = stage_secs::<{ PipelineTokenizer::STAGE_FRAME }>(&pipeline, &chunks);
+ let t_norm = stage_secs::<{ PipelineTokenizer::STAGE_NORMALIZE }>(&pipeline, &chunks);
+ let t_full = stage_secs::<{ PipelineTokenizer::STAGE_POSTPROCESS }>(&pipeline, &chunks);
+
+ let nspb = |secs: f64| secs * 1e9 / bytes as f64;
+ println!(
+ "{config}: frame {:.3} ns/B, normalize {:.3} ns/B (marginal {:.3}), full {:.3} ns/B = {:.1} MB/s",
+ nspb(t_frame),
+ nspb(t_norm),
+ nspb(t_norm - t_frame),
+ nspb(t_full),
+ bytes as f64 / t_full / 1e6,
+ );
+ }
+}
diff --git a/tokenizers/tk-encode/examples/normalize_claims.rs b/tokenizers/tk-encode/examples/normalize_claims.rs
new file mode 100644
index 000000000..75dd97cf9
--- /dev/null
+++ b/tokenizers/tk-encode/examples/normalize_claims.rs
@@ -0,0 +1,337 @@
+//! What the space rewrite costs when written, and what the zero-copy path wins back.
+//!
+//! For each SentencePiece-shaped model this runs the `encode_generic::` ablation
+//! ladder (the same methodology as `fixture_bench`) over long and short inputs, then
+//! decomposes the fused normalizer's pass into its three parts: counting the spaces,
+//! allocating the rewrite `String`, and writing it. Models taking the zero-copy path
+//! (see `ZeroCopyMetaspace`) get an interleaved A/B against the written rewrite, with
+//! ids compared over the whole corpus first. A last probe measures what the second
+//! special-token scan adds once a tokenizer holds `normalized` added tokens.
+
+use std::hint::black_box;
+use std::path::Path;
+use std::time::Instant;
+
+use atomsplit::literal::Literal;
+use tk_encode::pipeline::{Model, PipelineTokenizer};
+use tk_encode::{AddedToken, NormalizerWrapper, Tokenizer};
+
+const DATA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../data");
+const REPS: usize = 9;
+const CHUNK_BYTES: usize = 10 * 1024;
+const MAX_BYTES: usize = 512 * 1024;
+
+/// (config file, prepend for the decomposition replica; `None` skips it because the
+/// model runs the `drop_whitespace` path, which is a different rewrite)
+const MODELS: &[(&str, Option)] = &[
+ ("llama-2.json", Some(true)),
+ ("gemma-4.json", Some(false)),
+ ("t5-base.json", None),
+ ("albert-base-v1-tokenizer.json", None),
+];
+
+const FIXTURES: &[&str] = &[
+ "fixtures/lang/eng_Latn.txt",
+ "fixtures/lang/cmn_Hani.txt",
+ "fixtures/modalities/code_mixed.txt",
+];
+
+/// The words `fixture_bench` injects as `normalized:true` added tokens, so the probe
+/// exercises the same second-scan state the comparative benchmark runs under.
+const NORMALIZED_WORDS: &[&str] = &["widgetron", "flibberjast", "zorptastic", "quibblenaut"];
+
+fn median(mut samples: Vec) -> f64 {
+ samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
+ samples[samples.len() / 2]
+}
+
+fn timed(mut run: impl FnMut()) -> f64 {
+ run(); // warm-up
+ let mut samples = Vec::with_capacity(REPS);
+ for _ in 0..REPS {
+ let start = Instant::now();
+ run();
+ samples.push(start.elapsed().as_secs_f64());
+ }
+ median(samples)
+}
+
+fn stage_secs(pipeline: &PipelineTokenizer, chunks: &[String]) -> f64 {
+ let mut out = Vec::new();
+ let mut pre_tokens = Vec::new();
+ let mut scratch = pipeline.get_model().init_scratch();
+ timed(|| {
+ for chunk in chunks {
+ out.clear();
+ let _ = pipeline.encode_generic::(
+ chunk,
+ true,
+ &mut pre_tokens,
+ &mut scratch,
+ &mut out,
+ );
+ black_box(&out);
+ black_box(&pre_tokens);
+ }
+ })
+}
+
+/// Whole lines accumulated up to `chunk_bytes`, `MAX_BYTES` in total. `chunk_bytes = 0`
+/// keeps each line its own chunk (the short-input regime).
+fn chunks_of(text: &str, chunk_bytes: usize) -> Vec {
+ let mut chunks = Vec::new();
+ let mut current = String::new();
+ let mut total = 0usize;
+ for line in text.lines().filter(|l| !l.trim().is_empty()) {
+ current.push_str(line);
+ if current.len() >= chunk_bytes {
+ total += current.len();
+ chunks.push(std::mem::take(&mut current));
+ if total >= MAX_BYTES {
+ return chunks;
+ }
+ } else {
+ current.push(' ');
+ }
+ }
+ if !current.is_empty() {
+ chunks.push(current);
+ }
+ chunks
+}
+
+struct Ladder {
+ added: f64,
+ norm: f64,
+ split: f64,
+ model: f64,
+ post: f64,
+ total_mbs: f64,
+}
+
+fn ladder(pipeline: &PipelineTokenizer, chunks: &[String], bytes: usize) -> Ladder {
+ let t_frame = stage_secs::<{ PipelineTokenizer::STAGE_FRAME }>(pipeline, chunks);
+ let t_norm = stage_secs::<{ PipelineTokenizer::STAGE_NORMALIZE }>(pipeline, chunks);
+ let t_split = stage_secs::<{ PipelineTokenizer::STAGE_SPLIT }>(pipeline, chunks);
+ let t_model = stage_secs::<{ PipelineTokenizer::STAGE_MODEL }>(pipeline, chunks);
+ let t_post = stage_secs::<{ PipelineTokenizer::STAGE_POSTPROCESS }>(pipeline, chunks);
+ let nspb = |secs: f64| secs * 1e9 / bytes as f64;
+ Ladder {
+ added: nspb(t_frame.max(0.0)),
+ norm: nspb((t_norm - t_frame).max(0.0)),
+ split: nspb((t_split - t_norm).max(0.0)),
+ model: nspb((t_model - t_split).max(0.0)),
+ post: nspb((t_post - t_model).max(0.0)),
+ total_mbs: bytes as f64 / t_post / 1e6,
+ }
+}
+
+/// The fused normalizer's pass, split into its three costs over the same chunks:
+/// the space count, the `String` allocation, and the full rewrite (count + alloc +
+/// write). All three follow `MetaspaceNormalizer::normalize`'s non-`drop_whitespace`
+/// arm byte for byte, so `rewrite` should land on the ladder's norm marginal.
+fn decompose(chunks: &[String], bytes: usize, prepend: bool) -> (f64, f64, f64) {
+ let space = Literal::new(b" ").unwrap();
+ let delimiter = "\u{2581}";
+ let counts: Vec = chunks
+ .iter()
+ .map(|c| space.count_matches(c.as_bytes()))
+ .collect();
+ let nspb = |secs: f64| secs * 1e9 / bytes as f64;
+
+ let count_only = timed(|| {
+ for chunk in chunks {
+ black_box(space.count_matches(chunk.as_bytes()));
+ }
+ });
+ let alloc_only = timed(|| {
+ for (chunk, &count) in chunks.iter().zip(&counts) {
+ let s = String::with_capacity(chunk.len() + 2 * count + if prepend { 3 } else { 0 });
+ black_box(&s);
+ }
+ });
+ let rewrite = timed(|| {
+ for chunk in chunks {
+ let count = space.count_matches(chunk.as_bytes());
+ if !prepend && count == 0 {
+ black_box(chunk.as_str());
+ continue;
+ }
+ let mut rewritten =
+ String::with_capacity(chunk.len() + 2 * count + if prepend { 3 } else { 0 });
+ if prepend {
+ rewritten.push_str(delimiter);
+ }
+ let mut prev = 0;
+ space.for_each_match(chunk.as_bytes(), |start| {
+ rewritten.push_str(&chunk[prev..start]);
+ rewritten.push_str(delimiter);
+ prev = start + 1;
+ });
+ rewritten.push_str(&chunk[prev..]);
+ black_box(&rewritten);
+ }
+ });
+ (nspb(count_only), nspb(alloc_only), nspb(rewrite))
+}
+
+fn main() {
+ let fixtures: Vec<(String, String)> = FIXTURES
+ .iter()
+ .map(|rel| {
+ let path = Path::new(DATA).join(rel);
+ let name = path.file_stem().unwrap().to_str().unwrap().to_string();
+ (name, std::fs::read_to_string(&path).unwrap())
+ })
+ .collect();
+
+ for &(file, replica_prepend) in MODELS {
+ let path = Path::new(DATA).join(file);
+ let mut tok = match Tokenizer::from_file(&path) {
+ Ok(t) => t,
+ Err(e) => {
+ println!("== {file}: load failed: {e}");
+ continue;
+ }
+ };
+ let mut pipeline = match PipelineTokenizer::try_from(&tok) {
+ Ok(p) => p,
+ Err(e) => {
+ println!("== {file}: no pipeline: {e}");
+ continue;
+ }
+ };
+ // The ladder times the written stages; the zero-copy path gets its own A/B below.
+ pipeline.disable_zero_copy();
+ let pretok = format!("{:?}", pipeline.get_pre_tokenizer());
+ let pretok = pretok.split(['(', ' ']).next().unwrap_or("?");
+ println!("== {file} (pre-tokenizer: {pretok})");
+
+ for (name, text) in &fixtures {
+ for (regime, chunk_bytes) in [("10kB", CHUNK_BYTES), ("line", 0)] {
+ let chunks = chunks_of(text, chunk_bytes);
+ let bytes: usize = chunks.iter().map(String::len).sum();
+ let l = ladder(&pipeline, &chunks, bytes);
+ let total = l.added + l.norm + l.split + l.model + l.post;
+ println!(
+ " {name:<12} {regime:<4} ({:>5} chunks, {:>4} kB): added {:.3} | norm {:.3} | split {:.3} | model {:.3} | post {:.3} ns/B e2e {:.0} MB/s norm = {:.1}% of encode",
+ chunks.len(),
+ bytes / 1024,
+ l.added,
+ l.norm,
+ l.split,
+ l.model,
+ l.post,
+ l.total_mbs,
+ 100.0 * l.norm / total.max(1e-9),
+ );
+ if let Some(prepend) = replica_prepend {
+ let (count, alloc, rewrite) = decompose(&chunks, bytes, prepend);
+ println!(
+ " {:12} {regime:<4} norm decomposed: count {count:.3} + alloc {alloc:.3} + write {:.3} = replica {rewrite:.3} (ladder said {:.3})",
+ "",
+ (rewrite - count - alloc).max(0.0),
+ l.norm,
+ );
+ }
+ }
+ }
+
+ // Zero-copy A/B: the same binary and corpus, the two paths interleaved rep by rep so
+ // frequency drift hits both equally. Ids are compared over the whole corpus first.
+ if replica_prepend.is_some() {
+ let zero_copy = PipelineTokenizer::try_from(&tok).unwrap();
+ assert!(
+ zero_copy.has_zero_copy(),
+ "{file}: the zero-copy path should fire"
+ );
+ let mut written = PipelineTokenizer::try_from(&tok).unwrap();
+ written.disable_zero_copy();
+ for (name, text) in &fixtures {
+ for (regime, chunk_bytes) in [("10kB", CHUNK_BYTES), ("line", 0)] {
+ let chunks = chunks_of(text, chunk_bytes);
+ let bytes: usize = chunks.iter().map(String::len).sum();
+ for chunk in &chunks {
+ let ids = |p: &PipelineTokenizer| -> Vec {
+ p.encode(chunk, true)
+ .unwrap()
+ .iter()
+ .map(|t| t.id)
+ .collect()
+ };
+ assert_eq!(ids(&zero_copy), ids(&written), "{file} {name}: ids diverge");
+ }
+ let pass = |p: &PipelineTokenizer| {
+ let mut n = 0usize;
+ for chunk in &chunks {
+ n += p.encode(chunk, true).unwrap().len();
+ }
+ black_box(n);
+ };
+ pass(&zero_copy); // warm-up
+ pass(&written);
+ let mut zc = Vec::with_capacity(REPS);
+ let mut wr = Vec::with_capacity(REPS);
+ for _ in 0..REPS {
+ let t = Instant::now();
+ pass(&zero_copy);
+ zc.push(t.elapsed().as_secs_f64());
+ let t = Instant::now();
+ pass(&written);
+ wr.push(t.elapsed().as_secs_f64());
+ }
+ let (zc, wr) = (median(zc), median(wr));
+ println!(
+ " A/B {name:<12} {regime:<4}: zero-copy {:.0} MB/s vs written {:.0} MB/s ({:+.1}%)",
+ bytes as f64 / zc / 1e6,
+ bytes as f64 / wr / 1e6,
+ 100.0 * (wr / zc - 1.0),
+ );
+ }
+ }
+ }
+
+ // t5 and albert declare more normalizers than the space rewrite (t5 a Precompiled
+ // charsmap, albert five steps and then one). The design only removes the rewrite,
+ // so its share is measured by stripping the declared normalizer: what is left in
+ // the norm marginal comes from the Metaspace pre-tokenizer's rewriting half.
+ if replica_prepend.is_none() {
+ let mut stripped = Tokenizer::from_file(&path).unwrap();
+ let _ = stripped.with_normalizer(None::);
+ if let Ok(metaspace_only) = PipelineTokenizer::try_from(&stripped) {
+ let (name, text) = &fixtures[0];
+ let chunks = chunks_of(text, CHUNK_BYTES);
+ let bytes: usize = chunks.iter().map(String::len).sum();
+ let l = ladder(&metaspace_only, &chunks, bytes);
+ println!(
+ " declared normalizer stripped, {name} 10kB: norm {:.3} ns/B is the metaspace share",
+ l.norm,
+ );
+ }
+ }
+
+ // The second scan runs over every normalized chunk, but on an empty normalized
+ // vocabulary `Buckets::match_bytes` returns before touching the text. Injecting
+ // normalized added tokens (as `fixture_bench` does for every model) makes it a
+ // real pass; the frame marginal shows what the design's build-time gate saves.
+ let injected: Vec = NORMALIZED_WORDS
+ .iter()
+ .map(|w| AddedToken::from(*w, false).normalized(true))
+ .collect();
+ let _ = tok.add_tokens(injected);
+ if let Ok(with_normalized) = PipelineTokenizer::try_from(&tok) {
+ let (name, text) = &fixtures[0];
+ let chunks = chunks_of(text, CHUNK_BYTES);
+ let bytes: usize = chunks.iter().map(String::len).sum();
+ let before = ladder(&pipeline, &chunks, bytes);
+ let after = ladder(&with_normalized, &chunks, bytes);
+ println!(
+ " 2nd-scan probe on {name} 10kB: added {:.3} -> {:.3} ns/B with {} normalized added tokens",
+ before.added,
+ after.added,
+ NORMALIZED_WORDS.len(),
+ );
+ }
+ println!();
+ }
+}
diff --git a/tokenizers/tk-encode/src/models/bpe/bytelevel_folding.rs b/tokenizers/tk-encode/src/models/bpe/bytelevel_folding.rs
new file mode 100644
index 000000000..a19a13712
--- /dev/null
+++ b/tokenizers/tk-encode/src/models/bpe/bytelevel_folding.rs
@@ -0,0 +1,327 @@
+//! Which characters a byte-level vocabulary can emit as one token instead of as their bytes.
+//!
+//! A byte-level model's atoms are the 256 bytes, so a multi-byte character like え reaches the
+//! merge loop as three symbols that the merges then reassemble. When that reassembly is
+//! predetermined, seeding the merge loop with the character's own token skips the work. Two
+//! conditions make it predetermined:
+//!
+//! 1. The character's bytes must collapse into exactly one symbol when the merges are replayed
+//! the way BPE picks them: lowest rank first.
+//! 2. No step of that replay may be taken over from outside the character. The merge loop does
+//! not know where the character ends: if a neighbouring symbol can merge with the character's
+//! first or last symbol at a lower rank, that merge fires first and the assembly never
+//! happens.
+//!
+//! A character failing either test gets no table entry; its bytes go through the merge loop as
+//! usual, which is always exact. The fold is a shortcut, never a requirement.
+
+use std::cmp;
+
+use crate::models::bpe::MergeMap;
+use crate::utils::byte_level::CHAR_BYTES_LOOKUP;
+
+/// A character outside the table, or outside the Basic Multilingual Plane. The seeding loop
+/// falls back to per-byte symbols when it reads this.
+pub(super) const NO_FOLD: u32 = u32::MAX;
+
+/// `table[codepoint]` is the id of the token this character folds to, or [`NO_FOLD`].
+///
+/// Indexed by Basic Multilingual Plane codepoints only: a `char` above `u16::MAX` never folds.
+/// Single-byte characters are also left out, their byte symbol already is the seed.
+pub(super) type FoldTable = Box<[u32; FOLD_TABLE_LEN]>;
+pub(super) const FOLD_TABLE_LEN: usize = 1 << 16;
+
+/// Builds the fold table for a byte-level vocabulary.
+///
+/// `vocab` still spells its tokens in byte-level characters ("é" for é), which is what
+/// [`ByteLevelFold::fold`] expects; call this before the store is rebuilt on raw bytes.
+pub(super) fn build_fold_table(vocab: &[(String, u32)], merges: &MergeMap) -> FoldTable {
+ let fold = ByteLevelFold::new(vocab, merges);
+ let mut table = vec![NO_FOLD; FOLD_TABLE_LEN];
+ for (token, id) in vocab {
+ if let Fold::Folds(ch, id) = fold.fold(token, *id)
+ && ch.len_utf8() > 1
+ && (ch as usize) < FOLD_TABLE_LEN
+ {
+ table[ch as usize] = id;
+ }
+ }
+ table
+ .into_boxed_slice()
+ .try_into()
+ .expect("length is FOLD_TABLE_LEN")
+}
+
+/// What one vocab token is worth to the fold table.
+pub(super) enum Fold {
+ /// A single character whose bytes assemble to exactly this token, un-stealably.
+ Folds(char, u32),
+ /// Formable, but some step could be taken over by a neighbour.
+ Unsafe,
+ /// Not a single character, or its bytes never assemble at all. Nothing to record.
+ Skip,
+}
+
+pub(super) struct ByteLevelFold<'a> {
+ /// byte -> id of that byte's own one-character token. A byte's value is not its id
+ /// (gpt2: 0x41 -> 32, 0x20 -> 220), which is why this indirection exists.
+ byte_token: [u32; 256],
+ /// `stolen_from_left[id]`: the lowest rank at which a left neighbour merges with `id`,
+ /// counting only neighbours reachable at a character boundary. Same on the other side for
+ /// `stolen_from_right`.
+ stolen_from_left: Vec,
+ stolen_from_right: Vec,
+ merges: &'a MergeMap,
+}
+
+impl<'a> ByteLevelFold<'a> {
+ pub(super) fn new(vocab: &[(String, u32)], merges: &'a MergeMap) -> Self {
+ let mut byte_token = [u32::MAX; 256];
+ for (token, id) in vocab {
+ let mut chars = token.chars();
+ if let (Some(ch), None) = (chars.next(), chars.next())
+ && let Some(&b) = CHAR_BYTES_LOOKUP.get(&ch)
+ {
+ byte_token[b as usize] = *id;
+ }
+ }
+
+ let (stolen_from_left, stolen_from_right) = boundary_merge_ranks(vocab, merges);
+
+ Self {
+ byte_token,
+ stolen_from_left,
+ stolen_from_right,
+ merges,
+ }
+ }
+
+ /// Verdict for `token`, whose id is `id`.
+ pub(super) fn fold(&self, token: &str, id: u32) -> Fold {
+ let Some(bytes) = token
+ .chars()
+ .map(|ch| CHAR_BYTES_LOOKUP.get(&ch).copied())
+ .collect::