Skip to content

TARGET MINIMAL RUST CRATE + .tok FORMAT - #2293

Open
ArthurZucker wants to merge 97 commits into
feat/train_encode_splitfrom
feat/tok-format
Open

TARGET MINIMAL RUST CRATE + .tok FORMAT#2293
ArthurZucker wants to merge 97 commits into
feat/train_encode_splitfrom
feat/tok-format

Conversation

@ArthurZucker

@ArthurZucker ArthurZucker commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Based on perf/bpe-merge-review — review that first, this stacks on it.

Target: on-device

A tokenizer that ships inside a phone app, a browser bundle, or an edge runtime is judged on what it adds to the binary. Ours added 2 MB gzipped, and almost none of it was the encoder — it was the JSON parser, the wrapper enums that exist only to be serde's deserialization target, and ~200 KB of Unicode tables kept alive by enum arms nothing ever constructs.

.tok is the fix: a container with no parser. 16-byte header, 16-byte section descriptors, sections at 64-byte alignment, read with a bounds check and a pointer cast. No pretty-printing, no keys, no escaping. v0 (tokenizer.json) stays supported — it moves to tk-convert, which runs offline.

Before / after

The shipped artifact, gzipped:

raw gzipped
tokenizers.darwin-arm64.node today 5,145,664 2,010,745
.tok-only binding, v1, all four models 588,600 300,542

6.69x.

Same crate, same flags (opt-level=z, panic=abort, cgu=1, lto=fat, stripped):

probe stripped gzipped
binsize_pipeline — v0 JSON load path reachable 1,399,744 662,086
binsize_tok — v1, .tok only 569,608 286,130

Where it went, step by step, each measured:

step gzipped
v0 baseline 662,086
stop naming the wrapper enums in from_tok 542,128
gate the pipeline enums (config) — 202 KB of __const 451,377
serde + serde_json optional 317,443
rayon optional 303,177
.tok v1 gains Unigram / WordPiece / WordLevel 334,651
Unicode tables optional (normalizers) + ptr_hash without rayon/serde 286,130

__const is down from 221,096 to 62,896 — what remains is atomsplit's classification tables, which are the pre-tokenizer.

cargo tree -p tk-encode -e normal -i {serde,serde_json,rayon,unicode-normalization,spm_precompiled} is empty. The last two of those were never ours: both came from ptr_hash, where they were mandatory. Upstream PR makes each optional, patched in until it lands — RagnarGrootKoerkamp/PtrHash#32.

Crates

crate role deps
tk-serialization container and schema none; write behind write
tk-encode v1: reads .tok, encodes no serde, no JSON, no rayon
tk-convert v0 -> v1, offline the whole JSON stack
bindings/node-tok a Node binding whose whole surface is the read path

Features, all off by default: config (the v0 layer — Tokenizer, the wrapper enums, serde), normalizers (the table-backed normalizers).

What .tok v1 carries

All four models — the format describes a tokenizer, not one family of them.

model sections
BPE MERGE_PAIRS in rank order, so a merge's rank is its index
Unigram VOCAB_SCORES, model_param = unk id
WordPiece model_param = max_input_chars_per_word
WordLevel vocab + unk

Pre-tokenizers are named by FSM family, never by regex source, so loading a .tok needs no regex engine: GPT-2/cl100k/o200k/tekken/DeepSeek drive atomsplit natively and a literal pattern is searched for directly.

Verification

cargo run --release -p tk-convert --example tok_check

Converts, reloads through the read-only path, compares every id against the v0 path. 50/50 byte-exact across gpt2, roberta, llama-3, deepseek-v4 and gemma-3 x 10 scripts.

cargo run --release -p tk-encode --example tok_ids -- data/*.tok

Digests every id and runs in every build config: 40/40 pairs identical between the full build and the v1-only one. 332 existing tests pass with and without config.

File sizes: gemma-3 33.4 -> 9.8 MB, llama-3 17.2 -> 4.9 MB, deepseek-v4 6.4 -> 3.9 MB, gpt2 1.36 -> 1.36 MB (its JSON is mostly merges, already near-minimal as id pairs).

Not in here

  • A .tok cannot yet name a table-backed normalizer, so albert / xlm-roberta / bert convert only once the NORMALIZER section grows a kind list. The models work; their normalizer chains are the blocker.
  • gemma-2b fails on a pre-existing pipeline limit: <0x09> is absent from its model.vocab, so byte-fallback cannot resolve. Unrelated to the format.

Bugs found along the way

Both would bite any build without a regex backend, .tok or not:

  • Split::new compiled every regex through the system backend. DeepSeek's three are not individually FSM-recognised (the pipeline runs them as one native pass), so constructing them demanded an engine. Added Split::native.
  • is_deepseek compares against patterns with literal CR/LF, the way the shipped config spells them; atomsplit::regexes escapes them. Not interchangeable — rebuilding from the wrong one silently fell off the native FSM onto the regex fallback. DEEPSEEK_PATTERNS is now the single source.
  • Display for SplitDelimiterBehavior and for PrependScheme got their names by delegating to the serializer, so both broke without serde. Spelled out now, matching what serde emitted.
  • An empty normalizer Sequence (deepseek ships one) was carried as a no-op call per segment.

ArthurZucker and others added 21 commits August 3, 2026 16:50
- Use a trait generic instead of a const generic
- 2 Specialized struct instead of mangling all into SymbolSink
- fn that had no reason to live in impl PipelineBpe are moved oout
`tokenizer.json` can only be read by linking `serde_json`, and once that is
reachable LTO has to keep the whole JSON stack in every binary that can load a
tokenizer. Measured on this workspace: 937 KB gzipped for the encode path with
the parser reachable, 354 KB for the same encoder without it.

`.tok` v1 is a flat container — 16-byte header, 16-byte section descriptors,
sections at 64-byte alignment — read with a bounds check and a pointer cast.

It stores only what a `tokenizer.json` stores: vocabulary, merges in rank order,
added tokens, and which pre-tokenizer FSM to run. The derived tables (internal
ids, merge grid, codepoint fold, the perfect hashes) are rebuilt at load exactly
as today. Baking them too would save tens of milliseconds once per process, and
would freeze tk-encode's internal layout into a file format.

- tk-serialization: container and schema, no dependencies, write behind `write`
- tk-encode: `PipelineTokenizer::from_tok`, and `to_tok` behind `tok-write`
- tk-convert: the offline `tokenizer.json` -> `.tok` CLI
- bindings/node-tok: a Node binding whose whole surface is the read path

Byte-exact against the JSON path on gpt2, roberta, llama-3, deepseek-v4 and
gemma-3 across 10 scripts (50/50), verified by `tk-convert --example tok_check`.

Two fixes fell out: an empty normalizer `Sequence` (deepseek ships one) is no
longer carried as a no-op call per segment, and `PipelineToken` is `repr(C)` so
a slice of them views as a slice of ids.
`from_tok` routed through `Tokenizer` -> `PipelineTokenizer::try_from` to reuse
the added-token id assignment. That was the lazy wiring, and it cost 91 KB
gzipped: constructing a `Tokenizer` names `ModelWrapper`, `PreTokenizerWrapper`
and `PostProcessorWrapper`, and each of those holds every variant, so all of
them link.

The reader now fills the pipeline's fields itself. The model is passed to
`add_tokens` as a concrete `BPE` and the normalizer as a concrete `Replace`, so
neither wrapper is named on the read path; `PipelineNormalizer` gains a
`Replace` variant for that, and `read_pre_tokenizer` builds
`PipelinePreTokenizer` rather than the config-level enum. The wrapper imports
now live inside the `tok-write` module, where they belong.

  binsize_tok, opt-level=z, stripped, gzipped:  542,128 -> 451,377

Still byte-exact: 50/50 across gpt2, roberta, llama-3, deepseek-v4 and gemma-3.
@ArthurZucker
ArthurZucker changed the base branch from main to feat/train_encode_split August 5, 2026 06:04
The 189 KB left over the engine floor was not code, it was 202 KB of static
Unicode tables in `__const`. Nothing constructed the variants that need them —
but a match arm keeps its table alive whether or not the variant is ever built,
and `PipelineNormalizer::Declared`, `PipelineModel`'s three non-BPE arms and
`PipelinePreTokenizer`'s eight classifying arms are all such arms.

So they go behind `config` (default on): the layer that turns a parsed
`tokenizer.json` into a pipeline. With it off the crate loads a `.tok` and
nothing else, and the tables have nothing keeping them alive.

  binsize_tok, opt-level=z, stripped, gzipped:  451,377 -> 317,545
  the .node:                                    553,759 -> 331,777

Two bugs the stripped build found, both real on any build without a regex
backend:

  - `Split::new` compiles every regex pattern through the system backend.
    deepseek's three are not individually FSM-recognised (the pipeline runs
    them as one native pass), so constructing them needed an engine that a
    read-only build has no reason to carry. `Split::native` skips it.
  - `is_deepseek` compares against patterns with literal CR/LF, the way the
    shipped config spells them; `atomsplit::regexes` escapes them. The two are
    not interchangeable, and rebuilding from the wrong one silently fell off
    the native path. `DEEPSEEK_PATTERNS` is now the single source.

Verified by `tok_ids`, which digests every id and runs in both builds: 40/40
model/corpus pairs identical between the full build and the read-only one.
Gating the pipeline enums removed the Unicode tables but left `serde_json` (67
symbols) and `rayon` (243) in a binary that cannot parse JSON and never spawns
a thread. So `serde`, `serde_json`, `rayon` and `rayon-cond` become optional and
join `config`.

The reason `serde_json` survived turned out to be one line:

    JsonError(#[from] serde_json::Error)

in the BPE error enum. `Error` is `Box<dyn std::error::Error>`, so that variant
puts a `serde_json::Error` vtable behind every `Result` on the encode path, and
the parser follows it in. One `#[cfg]` and it is gone.

The pass itself is mechanical — `use serde` imports, `derive(Serialize,
Deserialize)`, `#[serde(...)]` field attributes (inert, so they travel with the
derive), hand-written serde impls, and the `serialization` modules. The bulk of
it lives in one place: `impl_serde_type!` generates most of the component types,
so gating the macro covered ~280 of the 438 errors at once.

Three things needed more than a `#[cfg]`:

  - `Display for SplitDelimiterBehavior` and `for PrependScheme` delegated to
    the serializer to get their names. Spelled out instead, matching what serde
    emitted: verbatim for the former (no `rename_all`), snake_case for the
    latter.
  - `pad_encodings` and `Encoding::pad` are on the ordinary encode path, so they
    keep working and just pick a serial iterator when rayon is absent.
  - `Model::save`, the `read_file` builders and the batch entry points are
    legacy JSON artefacts, so they travel with `config`.

  binsize_tok, opt-level=z, stripped, gzipped:  317,443 -> 303,177
  the .node:                                    331,777 -> 317,774  (6.33x)

`serde_json` 0 symbols, `unicode_normalization` 0, `spm_precompiled` 0. `rayon`
remains only because `ptr_hash` depends on it unconditionally for parallel MPHF
construction — upstream, not ours, and worth ~45 KB of text.

Verified both ways: 50/50 byte-exact against the JSON path, and 40/40 pairs
identical between the full build and the serde-free one. 332 tests pass.
`config` leaves the default feature set. The default `tk-encode` is the v1 crate:
it reads a `.tok`, encodes, and has no serde in its dependency tree at all. v0 —
the legacy `tokenizer.json` reader, the wrapper enums, `Tokenizer` — is what
`config` turns on, and `tk-convert` is the only thing that turns it on.

`tokenizer/tok.rs` keeps only the read half, which is how a v1 build constructs a
pipeline. The writer moves to `tk-convert`, where the conversion belongs: it is
now the only crate that names `Tokenizer` or a wrapper enum.

  cargo tree -p tk-encode -i serde  ->  only via criterion (dev-dependency)

50/50 byte-exact, 332 tests pass with and without `config`.
The format describes a tokenizer, not one family of them. `Config` gains `model`
and `model_param`, there is a `VOCAB_SCORES` section for Unigram, and the four
pipeline model arms come back out from behind `config` — a v1 build has to be
able to hold whatever a `.tok` names.

  BPE       MERGE_PAIRS + the three MODEL_STRINGS + ignore_merges/byte_fallback/fuse_unk
  Unigram   VOCAB_SCORES, `model_param` = unk id (u32::MAX for none), byte_fallback
  WordPiece `model_param` = max_input_chars_per_word, unk + continuing prefix from MODEL_STRINGS
  WordLevel vocab + unk

The vocabulary decode is shared: one slab walk into `(token, id)` pairs, plus
scores where the model has them. Unigram's vocabulary is positional, so the
writer refuses one whose ids are sparse or reordered rather than silently
shifting every piece.

  binsize_tok, opt-level=z, stripped, gzipped:  303,177 -> 334,651

That is what the other three models cost. Per-model features would let a build
pay only for what it serves; not done here, because the interesting gate is the
normalizer one below.

Known gap: a Unigram tokenizer in the wild (albert, xlm-roberta) is blocked by
its *normalizer*, not its model — `Sequence[Replace, NFKD, StripAccents,
Lowercase, Precompiled]`. Carrying those means linking unicode-normalization and
spm_precompiled, i.e. handing back the 154 KB of tables. That wants per-normalizer
features rather than one switch, and it is a design call, so it is not in here.

50/50 byte-exact on the BPE families, 332 tests pass with and without `config`.
…on/serde

Two things, both about paying only for what a build serves.

`normalizers` gates the table-backed normalizers — NFC/NFD/NFKC/NFKD,
StripAccents, Bert, and SentencePiece's precompiled charsmap — so
`unicode-normalization`, `unicode-normalization-alignments` and `spm_precompiled`
leave the dependency tree entirely rather than being compiled and then stripped.
Off by default, implied by `config`. `Strip` (lstrip/rstrip) and `Lowercase` need
no tables and stay.

The last rayon and serde in the tree were never ours: both came from `ptr_hash`,
which had them as mandatory dependencies. Upstream PR makes each optional —
rayon behind `parallel`, and serde behind `serde`, since the only uses in that
crate are two `Serialize` derives on its build statistics. Patched in until it
lands:

  RagnarGrootKoerkamp/PtrHash#32

`cargo tree -p tk-encode -e normal -i {rayon,serde,serde_json}` is now empty, and
dropping ptr_hash's parallel construction took it from 273 symbols to 67.

  binsize_tok, opt-level=z, stripped, gzipped:  334,651 -> 286,130
  the .node:                                    2,010,745 -> 300,542  (6.69x)

  __text   405,064 -> 380,716
  __const   62,896  (atomsplit's classification tables, which are the pre-tokenizer)

50/50 byte-exact, 40/40 ids identical across build configs, 332 tests pass.
Mirrors the ExecuTorch C++ driver it is compared against: best-of-5 per corpus,
ids only, one thread, so the two numbers mean the same thing.
@ArthurZucker

Copy link
Copy Markdown
Collaborator Author

vs ExecuTorch's C++ tokenizers

pytorch-labs/tokenizers @ 9b96c39 (v1.4.0) is the closest comparison: a C++ reimplementation aimed at exactly the same on-device target. Both sides built here, same machine (M4), same corpora, single thread.

Their stack for the HF-json path: nlohmann/json, RE2 (+ 97 abseil archives), and PCRE2 as a lookahead fallback. cl100k's \s+(?!\S) is not RE2-compilable, so llama-3 and every cl100k/o200k tokenizer requires the PCRE2 fallback — the RE2-only build fails to load them.

Size

Smallest program that loads a tokenizer and encodes. Stripped, LTO, -Os / opt-level=z.

can load llama-3 stripped gzipped
ExecuTorch, RE2 only no 903,624 401,076
ExecuTorch, + PCRE2 lookahead yes 1,219,496 520,881
tokenizers v0 (tokenizer.json) yes 1,399,744 662,086
tokenizers v1 (.tok) yes 569,608 286,130

Against the ExecuTorch build that can actually serve llama-3: 1.82x smaller. Against their RE2-only build: 1.40x.

Throughput

Best of 5, ids only, one thread, -O3 / release. Every id matches ours exactly on every cell — a good cross-check that both are correct.

gpt2 — their RE2 path:

corpus ExecuTorch tokenizers v1
english 5.5 MB/s 92.1 MB/s 17x
chinese 8.2 MB/s 166.0 MB/s 20x
code 3.0 MB/s 123.7 MB/s 41x

llama-3 — their PCRE2 lookahead path:

corpus ExecuTorch tokenizers v1
english 0.2 MB/s 246.4 MB/s ~1200x
chinese 0.7 MB/s 129.1 MB/s ~180x
code 0.1 MB/s 188.5 MB/s ~1900x

The llama-3 row wants a caveat: that is their lookahead fallback being pathological, not a fair reading of their BPE engine. The honest summary is 17-41x on the path they are actually optimised for, and that the most popular tokenizer family on the Hub falls off that path entirely because RE2 has no lookahead.

This is where atomsplit earns the 62 KB of tables it does keep: cl100k is a hand-written FSM, so there is no regex engine to be unable to compile it, no fallback, and no cliff.

Load

ExecuTorch (JSON) tokenizers v1 (.tok)
gpt2 38.0 ms 44.0 ms
llama-3 265.8 ms 142.0 ms

Not a headline: we rebuild the derived tables at load on purpose, rather than freeze tk-encode's internal layout into a file format. gpt2 is a wash; llama-3 is 1.9x.

Reproducing

# ExecuTorch
git clone --recurse-submodules https://github.com/pytorch-labs/tokenizers
cmake -S tokenizers -B build -DCMAKE_BUILD_TYPE=Release -DSUPPORT_REGEX_LOOKAHEAD=ON
cmake --build build -j
c++ -std=c++20 -O3 -flto -DNDEBUG -I... et_bench.cpp -Wl,-force_load,build/libregex_lookahead.a $(find build -name '*.a')

# ours
cargo run --release -p tk-convert -- data/gpt2.json
cargo run --release -p tk-encode --example tok_bench -- data/gpt2.tok data/corpora/*.txt

-Wl,-force_load on libregex_lookahead.a matters: without it the linker drops the fallback's static registration and llama-3 fails to load with a message pointing at the missing link.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants