Skip to content

bitsplit: one crate, all model grammars, atomsplit deleted - #2317

Merged
ArthurZucker merged 11 commits into
poc/target-encode-cleanfrom
poc/bitsplit-single-crate
Aug 7, 2026
Merged

bitsplit: one crate, all model grammars, atomsplit deleted#2317
ArthurZucker merged 11 commits into
poc/target-encode-cleanfrom
poc/bitsplit-single-crate

Conversation

@ArthurZucker

Copy link
Copy Markdown
Collaborator

Cleanup on top of #2306. bitsplit is now the only crateatomsplit is deleted — and every
model family you listed routes to a byte-exact bitstream grammar.

Coverage

Every regex below was fetched from the model's live tokenizer.json (or tokenization_kimi.py),
not assumed. Six of the seven collapse onto two existing grammars:

Model Regex Grammar
DeepSeek V3.2 / v4 NUM → CJK → BIG Sequence deepseek.rs
GLM-4.6 CL100K verbatim cl100k.rs
Qwen 2/3 CL100K with \p{N} cl100k.rs (cap 1)
Llama-4 O200K verbatim o200k.rs
gpt-oss O200K verbatim o200k.rs
MiniMax-M2 O200K verbatim (Removed+invert, canonicalized) o200k.rs
Kimi K2/K3 O200K + [\p{Han}]+, Han removed from the letter classes, [\r\n]* tail kimi.rs
Mistral tekken O200K, no contraction suffix, one token per digit tekken.rs

Structure

One file per regex (models sharing a regex share a file), each unrolled against the shared
primitives in lib.rs (scanthru, adv, to_lead, fill_to_last, digit_groups, emit).
An earlier <AUX, CONTRACTION, DIGIT_CAP> const-generic version is gone: it made every fix a
three-way risk and none of the three could be read on its own.

  • classify + tables + the three SIMD kernels + regexes + Span moved into bitsplit.
  • The scalar regex FSMs are deleted. fast_builder() went with them — with no second engine
    there was nothing left to gate.
  • The class-run family and CharDelimiterSplit live in bitsplit::classes.
  • literal.rs is a bitstream (see below); memchr is no longer a bitsplit dependency.

Byte-exactness

#2306 shipped bitsplit with no testslib.rs cited a src/bin/verify.rs that is not in the
tree. tests/parity.rs is the gate now:

  • oniguruma oracle, composed as HF applies it (deepseek = three Isolated splits, not one regex)
  • a block-phase sweep — every char-boundary prefix and suffix of a 4 KB corpus, so each rule
    crosses the 64-byte grid in every alignment. This is what actually exercises starts[bi-1] |= patch
    and the anl retraction, and it caught every bug below.
  • 4000 fuzz strings (10000 run locally, clean)
  • a #[should_panic] negative control, so the gate cannot silently stop comparing

8 gates green: gpt2, cl100k, qwen, deepseek, o200k, tekken, kimi + the control.

Two findings worth calling out, both caught by the sweep:

  1. The o200k case split has no local form. 中Qz is one token but ʰABC is two — the difference
    is whether an L appears later in the run, so no p1 decides it. It is a scalar escape behind
    a cheap bit gate; all-lowercase and Capitalised text never pays.
  2. \p{M} is in both the letter classes and rule 4's [^\s\p{L}\p{N}]. !\u{301}a is one token,
    !!\u{301}a is two. Real text hits this via emoji + variation selector (U+FE0F is \p{Mn}).

Throughput

cargo run --release -p bitsplit --example grammars, MB/s, split only (classify excluded):

english code chinese mixed
gpt2 1809 1969 3714 3131
cl100k 1696 1592 3509 2559
qwen 1652 1674 3595 2334
o200k 1091 1249 1171 1419
tekken 1507 1230 1231 1438
kimi 550 617 427 687
deepseek 2054 1531 1910 1644

Literal (metaspace U+2581 needle, 8 MB, --example litbench): 1433 MB/s vs memmem's 1126 (1.27×).
A scalar first-byte compare measured 0.64× — the SIMD first-byte bitmap is what makes it a win.

Metaspace (llama2 / gemma)

to_normalizer_and_split already decomposes Metaspace at load time into a MetaspaceNormalizer

  • a Split on the U+2581 delimiter. That Split's search is now the literal bitstream, so
    metaspace runs on bitsplit end to end with no new dispatch.

Known gaps

  • bitsplit::classes was moved, not ported to bitstreams. A class-run boundary is c & !(c << 1),
    which would delete simd_classes.rs outright — marked with a ponytail: comment. Pure cleanup.
  • kimi's Han range test is scalar inside the kernel (ponytail: comment), which is most of why it
    trails the others. Vectorising it mirrors the existing CJK path.
  • atomsplit's benches/ (the cross-engine onig / fancy-regex / logos / pcre2 comparison) went with
    the crate. The two examples/ above replace the throughput half; say the word and I'll port the
    comparison bench over.
  • Non-NEON / non-SSSE3 targets (wasm32) now fall to the portable scalar builder, which is slower
    than the FSM was. simd128 build64 is the upgrade path.

…ctness gate

bitsplit had no tests at all -- lib.rs pointed at a `src/bin/verify.rs` that is not
in the tree. Add `tests/parity.rs` first, so the rest of the cleanup has a gate:
oniguruma as the oracle (deepseek composed as HF applies it, three Isolated splits),
plus the block-phase sweep that is the only thing that actually exercises the
cross-block carries and `starts[bi-1] |= patch`. A `#[should_panic]` negative control
keeps the gate honest. All three existing grammars pass.

Then move the classify cluster (classify, atom_tables, tables, the three SIMD
kernels), `regexes` and `literal` into bitsplit, and put `Span` in its lib.rs.
atomsplit is now a shim over bitsplit holding only the scalar FSMs; it goes away
once every caller is rewired.

Also resolves a bad merge already on this branch: tk-encode/src/models/bpe/model.rs
carried two `fn tokenize_spans`, so tk-encode did not compile at all. Kept the
fold_id/symbols mechanics (the ones matching BpeScratch and merge_word's arity) and
restored the word-cache logic the other side had, mirroring tokenize_pipeline.
…imi), 4th plane, aux streams

Adds `o200k.rs`, const-generic over <AUX, CONTRACTION, DIGIT_CAP>. AUX doubles as the
variant selector because the two text-derived streams are mutually exclusive: the
o200k/tekken line needs `/` for rule 4's `[\r\n/]*`, kimi needs `\p{Han}` for its
leading arm and has a plain `[\r\n]*` tail.

Kernel changes:
- `Blk` gains `p3`. o200k needs 9 tag classes and code 7 is reserved -- both SIMD
  kernels find continuation lanes by testing `lut[tag] == 7` -- so "other" cannot be
  the leftover code. Const-gated off for the 3-plane grammars.
- `Blk.cjk` generalised to `Blk.aux` with a const AUX selector (none/cjk/slash/han).
- `digit_groups::<CAP>` lifted into lib.rs: the `\p{N}{1,CAP}` block was duplicated
  verbatim in cl100k and deepseek, and hardcoding 3 is what blocked Qwen.

The case split is a scalar escape, not bit algebra: `[UC]*[LC]+|[UC]+[LC]*` has no
local form (`中Qz` is one token, `ʰABC` is two -- the difference is whether an L
appears LATER in the run). The bit half computes a cheap gate instead, so
all-lowercase and Capitalised text never pays for it.

o200k/tekken/kimi parity is #[ignore]d with the reason: \p{M} is in BOTH the letter
classes and rule 4's class, and that interaction is not modelled yet. The other four
gates (gpt2, cl100k, deepseek, negative control) stay green.
The duplicate-tokenize_spans repair I wrote landed upstream as #2314 on
feat/train_encode_split, which is NOT an ancestor of poc/target-encode-clean --
hence the stale base. Take the upstream text so the two cannot diverge; it also
documents why the fold must be probed before the cache.
Drop the <AUX, CONTRACTION, DIGIT_CAP> const generics. Folding o200k, tekken and kimi
into one parameterised grammar meant every fix was a three-way risk and nothing could
be read on its own. Now: o200k.rs (llama4 / gpt-oss / minimax), tekken.rs, kimi.rs --
each unrolled against the shared primitives in lib.rs, each independently debuggable.

The small scalar helpers (member, run_end, ws_tail, letter_match) move to lib.rs; they
are genuinely shared and copying them per file would be worse.

kimi is byte-exact. o200k/tekken still carry two cross-block bugs.
Three real bugs, all in the interaction between rule 4's `[\r\n/]*` tail and everything
around it. `/` is in BOTH the `+` body and the tail, which cl100k never has to deal with
(its tail is newlines, and a newline is not "other"):

- One tail run can collect SEVERAL markers (`!\n/\n`: the `\n` after `!` and the `\n`
  after `/`). `nl_e - nl_m` spans only from the last one -- that is exactly what
  `fill_to_last` exists for.
- A char the tail absorbed is not part of the `+` body, so an "other" after it opens a
  fresh run (`<emoji>\r\n/#` = tail, then `#` starts again). Needs a `prev_absorbed` carry
  for the block edge.
- ...but a char INSIDE the tail must not open one either, or `osf` picks up the `/` and
  the `[^\r\n\p{L}\p{N}]?` prefix rule then eats the letter start after it (`#\r\n/aA`
  came out as one token instead of `a` + `A`).

Plus: re-flag the last letter token whenever the escape trigger fires, since the trigger
can land in a later block than the token it belongs to.

Gate: oniguruma oracle, block-phase sweep, 4000 fuzz strings (10000 run locally, clean).
All 7 grammars green -- gpt2, cl100k, deepseek, o200k, tekken, kimi + negative control.
One file per regex, finishing the split. cl100k's digit cap becomes a plain argument
rather than a const generic -- 3 = cl100k / Llama-3 / GLM-4.6, 1 = Qwen. cl100k was
still running its own inline hardcoded-3 digit block; it now shares digit_groups with
o200k, which is what made the cap knob real.

8 gates green: gpt2, cl100k, qwen, deepseek, o200k, tekken, kimi + negative control.
The regex FSMs are gone -- every recognized pattern now routes to the byte-exact
bitstream grammar. `fast_builder()` went with them: there is no second engine left to
route to, so the gate had nothing to gate.

- `GptFsm` -> `Grammar`, one variant per distinct regex, with the Kimi key added. The
  cl100k digit cap now actually reaches the splitter (Qwen routes to `bitsplit_qwen`).
- The class-run family and `CharDelimiterSplit` move to `bitsplit::classes`.
- Workspace members drop atomsplit; bitmap_gen writes bitsplit/src/atom_tables.rs;
  the CI workflow is renamed.

Whole workspace green: 8 bitsplit gates, 356 tk-encode unit tests, the bpe/decode/
pipeline oracles.
…rSplit

Replaces memmem with the same shape as the grammars: a `u64` match bitmap per 64-byte
block, walked with `trailing_zeros`. The first needle byte is compared in SIMD (the
existing `mm64` fold on NEON, `movemask` on SSE); the remaining bytes only ever run on
the survivors, so a 3-byte needle costs one vector pass plus a handful of scalar checks.

This is what puts metaspace (llama2 / gemma) fully on bitsplit: `to_normalizer_and_split`
already decomposes Metaspace at load time into a MetaspaceNormalizer + a `Split` on the
`U+2581` delimiter, and that Split's search is now a bitstream.

Measured on the metaspace needle over 8 MB (examples/litbench):
  scalar first byte   727 MB/s   0.64x memmem   <- not good enough
  SIMD first byte    1433 MB/s   1.27x memmem
memchr is no longer a bitsplit dependency.
The gate was "any trigger in this block flags every letter token in it", so a single
`'s` in a paragraph dragged every word through the scalar pass. Narrow it with a reverse
fill over the letter stream: a run needs the escape only if an interior upper (or the
letter before a contraction apostrophe) sits at-or-after the run start.

`last_lt` keeps tracking ALL letter tokens -- it is what the cross-block patch uses, and
narrowing it broke every o200k-family gate until the two were separated.

english MB/s: o200k 504 -> 1091, tekken 1547 -> 1507, kimi 287 -> 550. 8 gates green.
@HuggingFaceDocBuilderDev

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

- `classify/` owns its own pieces instead of scattering them at the crate root: the
  generated `atom_tables.rs`, the `tables.rs` layout it bakes, and the three per-arch
  kernels (`neon.rs`, `avx.rs`, `wasm.rs`, renamed from `simd_*_classify.rs`).
- `models/<name>/` — one folder per unrolled pre-tokenization regex. Models sharing a
  regex share a folder: o200k covers Llama-4 / gpt-oss / MiniMax-M2, cl100k covers
  Llama-3 / GLM-4.6 and Qwen at digit cap 1.

bitmap_gen now writes classify/atom_tables.rs and emits `use super::tables::Tables;`.
Regenerating produces a byte-identical file apart from that import.

Root re-exports (`bitsplit::bitsplit_o200k`, ...) are unchanged, so tk-encode is untouched.
23 suites green.
- `models/<name>.rs` — a file per regex, not a folder.
- `simd/` holds the per-arch kernels: `neon.rs` + `x86.rs` (block builder) and
  `classes.rs` (class-run boundaries), replacing the loose `simd*.rs` at the root.
  classify keeps its own kernels next to the tables they index.
- `bitsplit/examples/` removed, along with the memchr dev-dep it needed. The numbers
  they produced are recorded in the PR description.
@ArthurZucker
ArthurZucker merged commit 28fbb60 into poc/target-encode-clean Aug 7, 2026
35 of 47 checks passed
@ArthurZucker
ArthurZucker deleted the poc/bitsplit-single-crate branch August 7, 2026 05:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants