rustbpe — the missing tiktoken trainer
One-line mental model: train the merges in Rust with `rustbpe`, run inference with `tiktoken`.rustbpeis the trainer that OpenAI'stiktokennever shipped.
What it is
rustbpe (Karpathy, MIT-licensed) is a small Rust library — with Python bindings — whose entire job is to train a GPT-style byte-pair-encoding (BPE) tokenizer: feed it a text corpus and a target vocabulary size, and it learns the ordered list of token-pair merges that define the tokenizer. Its own tagline is "The missing tiktoken training code."
The name says everything about where it sits in the ecosystem. There are three pre-existing options and each has a gap:
- `tiktoken` (OpenAI) — "excellent for inference but doesn't support training." It can apply a tokenizer at very high speed, but it cannot learn one. There is no public OpenAI trainer.
- HuggingFace `tokenizers` — can train, but "carries significant complexity from years of accumulated tokenizer variants" (many algorithms, configs, normalizers, and legacy formats).
- `minbpe` (Karpathy's earlier teaching repo) — "handles both training and inference, but only in Python and not optimized for speed." Great to read, too slow to train a real vocab on a real corpus.
rustbpe is the fourth corner: a simple, fast, training-only BPE that learns the merges and then hands them off. The intended workflow is literally "Train your tokenizer with `rustbpe`, then export to `tiktoken` for fast inference." It is the same byte-level, regex-pre-tokenized algorithm OpenAI uses, so the vocab it produces is tiktoken-compatible by construction.
If you have never met BPE, read [[tokenization]] first (what a tokenizer is, why subwords, byte-level vs. char-level), and [[minbpe]] is the slow, readable Python sibling that rustbpe is a fast re-implementation of — start there if the algorithm below feels dense. (Note: at the time of writing the `minbpe` library article is referenced here in prose; if a `[[minbpe]]` slug does not yet exist in this library, treat the link as a forward pointer.)
Why it matters
For a project that needs its own tokenizer, the trainer is the missing third of the pipeline:
- Train the merges (learn the vocab) — this is what almost no public tool does well in a fast, dependency-light way.
rustbpe. - Encode/decode at training-and-serving time — fast, batched.
tiktoken. - Ship the vocab as a portable artifact (a list of
(bytes, rank)pairs + a regex pattern + special tokens).
A custom tokenizer is not a luxury. Vocabulary choice changes the number of tokens per document, which directly changes training cost, context-window efficiency, and downstream perplexity. A tokenizer tuned to your corpus (your code, your language mix, your domain jargon) packs more meaning per token. But to get one you must be able to train it cheaply — and that is exactly the step that was awkward before rustbpe.
This matters concretely for OpenAlice, a Rust shop whose brain (alice-core) is Rust: rustbpe is the rare ML-infra component that is native to our stack rather than a Python dependency we tunnel into. See the OpenAlice section below.
How it works (real mechanics + code walkthrough)
The textbook algorithm (the slow truth)
BPE training, as the README states, is four steps:
- Start with 256 byte-level tokens (
0x00–0xff). Working over raw bytes means every possible input is representable with zero "unknown token" problems — this is the byte-level trick. - Count all adjacent token pairs in the corpus.
- Merge the most frequent pair into a single new token (the next ID,
256 + merges_done). - Repeat until the vocabulary reaches the target size.
Naively, step 2 re-scans the whole corpus every iteration → roughly O(N × V) work (N = corpus size, V = vocab size). That's what minbpe does, and why it's slow. The interesting engineering in rustbpe is making the merge loop incremental so each merge touches only what changed.
Pre-tokenization: the GPT-4 split regex
Before counting anything, text is chopped into chunks by a regex so that merges never cross "obvious" boundaries (you don't want a token spanning the space between two words, or gluing a word to trailing punctuation). The default is the GPT-4 split pattern, used verbatim:
'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+In English: split off contractions ('s, 'll, 've…), runs of letters (optionally with one leading non-letter like a space), runs of 1–3 digits, runs of punctuation, and whitespace. You can override it via the pattern argument; the default makes the output tiktoken/GPT-4 compatible. This is the same idea covered in [[tokenization]].
The core data structures (src/lib.rs)
type Pair = (u32, u32);
struct Tokenizer {
merges: StdHashMap<Pair, u32>, // (a,b) -> new_id : the learned vocab
pattern: String, // the split regex (source)
compiled_pattern: Regex, // precompiled for speed
}vocab_size() is simply 256 + merges.len(). Two internal helpers carry the algorithm:
struct Word { ids: Vec<u32> } // one pre-tokenized chunk as a token sequence
// .pairs(&self) -> adjacent pairs via windows(2)
// .merge_pair(&mut self, pair, new_id) -> non-overlapping, left-to-right merge
struct MergeJob {
pair: Pair,
count: u64,
pos: AHashSet<usize>, // indices of the Words that contain this pair
}MergeJob implements Ord to behave as a max-heap by frequency, with deterministic tie-breaking on the pair values (so two equally-frequent pairs always resolve the same way — reproducible vocabs).
The incremental merge loop — the actual speedup
This is the heart of it, in train_core_incremental(...):
- Parallel pair counting.
count_pairs_parallel()uses `rayon` to count every adjacent pair across allWords at once, weighting each word by its corpus count (identical words are deduped to(Word, count), so a pair seen in a word that appears 10,000× is counted 10,000× via multiplication, not by reprocessing). - Build the heap. Every pair becomes a
MergeJoband goes into an `OctonaryHeap` — an 8-ary (d-ary) heap. A wider-branching heap trades a slightly more expensive sift for shallower depth and better cache behavior than a binary heap, which pays off when you push/pop millions of jobs. - Merge `vocab_size - 256` times. Each iteration: - Pop the highest-count
MergeJob. - Lazy-validation (lazy deletion). Compare the poppedjob.countagainst the live count for that pair. The loop never deletes stale entries from the heap eagerly; instead, when a pair's true count has dropped (because an earlier merge consumed some of its occurrences), the staleMergeJobis simply skipped on pop and the refreshed one is trusted. This "re-enqueue, validate-on-pop" pattern is what avoids O(N) bookkeeping per merge. - Assign the new id256 + merges_doneand record it inmerges. - Apply `merge_pair()` — but only to the `Word`s in that job's `pos` set, not the whole corpus. This is the second half of the incrementality: thepos: AHashSet<usize>bounds the work to words that actually contain the pair. - Collect deltas. Eachmerge_pair()returns how pair-counts changed locally (mergingAB→Xdestroys the pairs(left,A)and(B,right)and creates(left,X)and(X,right)). Multiply those deltas by the word's corpus count and fold them into the global pair-frequency table. - Re-enqueue the affected pairs (with updated counts andpossets) back onto the heap.
So each merge does work proportional to the neighborhoods it disturbs, not the whole corpus — the difference between "re-count everything V times" and "patch the diff V times." This is the same family of optimization the GitHub engineering blog and the Rust-NLP write-up describe: keep eligible pairs in a priority queue, track positions, patch incrementally rather than rescan. (Both linked in Sources.)
Encoding and decoding
- `encode(text) -> Vec<u32>`: split with the compiled regex → bytes (0–255) → repeatedly find the pair whose merged id is lowest (i.e. the earliest-learned merge — merges must be replayed in learning order to reproduce the vocab) and apply it, until no learned pair remains.
- `decode(ids) -> String`: rebuild each token's byte string from base bytes + the sorted merges, concatenate, and UTF-8-decode with error handling (because an arbitrary id-slice can land mid-multibyte-character).
- `batch_encode(texts)`:
rayon-parallel encode across many strings.
The Python bridge and the hand-off to tiktoken
rustbpe exposes the Tokenizer to Python through `PyO3` (#[pyclass] / #[pymethods] / #[pymodule] fn rustbpe()), with pyo3_log wiring Rust logs into Python. train_from_iterator refills a buffer under the GIL, then releases the GIL to do the heavy regex+count work in parallel — the correct pattern for a CPU-bound Rust extension.
Two getters exist purely for the export step: get_pattern() and get_mergeable_ranks() -> Vec<(Vec<u8>, u32)>. The whole point of the library is this five-line hand-off:
import rustbpe, tiktoken
tok = rustbpe.Tokenizer()
tok.train_from_iterator(open("corpus.txt"), vocab_size=8192)
enc = tiktoken.Encoding(
name="my_tokenizer",
pat_str=tok.get_pattern(),
mergeable_ranks={bytes(k): v for k, v in tok.get_mergeable_ranks()},
special_tokens={},
)
ids = enc.encode("hello world") # fast inference via tiktokenInstall is pip install rustbpe, or from source with maturin develop --release (the standard Rust-extension build via uv/maturin).
How nanochat actually uses it
[[nanochat]] — Karpathy's "$100 ChatGPT" full pipeline — is the real-world consumer. In nanochat/tokenizer.py, the RustBPETokenizer:
- trains with
tokenizer.train_from_iterator(text_iterator, vocab_size_no_special, pattern=SPLIT_PATTERN), wherevocab_size_no_special = vocab_size - len(SPECIAL_TOKENS)(special tokens are not learned — they're reserved IDs appended after the learned merges); - exports to a
tiktoken.Encodingviaget_pattern()+get_mergeable_ranks()exactly as above; - registers chat special tokens separately:
<|bos|>,<|user_start|>,<|user_end|>,<|assistant_start|>,<|assistant_end|>,<|python_start|>,<|python_end|>— the turn/role markers that make a base tokenizer chat-aware.
The division of labor is explicit and is the whole thesis: Rust (`rustbpe`) for the performance-critical training, `tiktoken` for high-throughput inference at serving time, with an algorithm "identical to the one used by OpenAI (regex splitting, byte-level BPE)."
Key ideas & tradeoffs
- Training-only, on purpose. It does ship
encode/decode(needed for tests and quick checks), but it does not try to be a fast inference engine. That job is delegated totiktoken, which is faster and battle-tested. Doing one thing keeps the codebase tiny. - Byte-level base (256 tokens). No
<unk>, every input is representable, language-agnostic. Standard modern practice (GPT-2 onward). - Incremental + parallel is the entire reason to exist.
minbpeis the same algorithm;rustbpeisminbpemade fast via Rust +rayon+ a d-ary heap with lazy deletion + position-bounded re-counting. The README is honest that this is "relatively efficient," not a benchmark-crowned speed king. - `tiktoken`-compatible output by default. The default GPT-4 pattern + byte-level merges mean the exported vocab drops straight into
tiktoken.Encodingwith no translation layer. - Deterministic. Tie-breaking in
MergeJob::Ordmakes the learned vocab reproducible across runs — important for caching and for "did my tokenizer change?" diffs. - Small dependency surface:
rayon(parallelism),PyO3/pyo3_log(bindings), a regex engine, andahashfor fast hashing. No giant tokenizer framework.
Honest caveats
- No published benchmark numbers. The README claims efficiency but ships no head-to-head timings against
minbpeor HuggingFace. "Fast" here is "fast enough to train a real vocab without pain," asserted, not measured. The complexity intuition (incremental beats O(N×V) rescans) is sound and matches the broader literature, but treat specific speed claims as unverified. - It is young and small. Fewer knobs than HuggingFace
tokenizers(no normalizers, no alternative algorithms like Unigram/WordPiece, limited config surface). That's the design — but if you need those, this is the wrong tool. - Author's own disclosure on provenance. Karpathy is unusually candid: "I wrote the Python reference code personally and from scratch and I am expert there and understand it fully. I then wrote the Rust code against this implementation with tests for equality. However, I am not a Rust developer by background so I had significant help from ChatGPT and Claude Code Opus 4.5." So: the algorithm is expert-authored and test-pinned against a reference; the Rust is LLM-assisted. The equality tests against the Python reference are what give it credibility — read
tests/python/before trusting it in production. - Encode/decode are for parity, not production. Use
tiktokenfor serving. The library's own README steers you there. - Fetch transparency for this article: the canonical
src/lib.rs, the repo/README, and the nanochattokenizer.pyall fetched cleanly and corroborate each other. One attempt — the nanochat-vendored copy ofrustbpe/src/lib.rsat a guessed path — returned HTTP 404 and was dropped; nothing from it is asserted here. The README on raw-githubusercontent returned a summary (it lacks an explicit benchmark/comparison-table section), and the comparison framing in this article comes from the rendered GitHub README, which is consistent across fetches. No numbers were invented.
OpenAlice + Academy ladder
Why OpenAlice cares. alice-core is Rust. Most ML infra reaches us as a Python dependency we have to wrap, host, or shell out to. rustbpe is the opposite: a tokenizer trainer that is already in our language, with a tiny dependency set (rayon, pyo3, ahash, a regex). If OpenAlice ever wants a domain-tuned tokenizer — one fit to our code corpus, our multilingual chat logs, our tool-call / special-token grammar — rustbpe is the reference design for the trainer, and it shows the exact pattern (get_pattern() + get_mergeable_ranks() → portable artifact) for shipping that vocab to any inference path. The nanochat special-token list (<|bos|>, <|user_start|>, role/turn markers, <|python_start|>) is also a clean template for how Alice's own turn/tool grammar would reserve IDs after the learned merges. It pairs naturally with our interest in small, controllable models — see [[nanochat]] and [[nanogpt]].
Academy ladder (beginner → builder → contributor):
- Concept. Read [[tokenization]] end to end: bytes → pairs → merges → vocab; why subwords; why byte-level. Know what "a merge" is before reading code.
- Read the slow version. Walk
minbpe(the Python referencerustbpeis built against) — small enough to hold in your head. (prose link; verify the `[[minbpe]]` slug exists in this library before relying on the cross-link.) - Train one.
pip install rustbpe, train a 4k–8k vocab on a small corpus, export totiktoken, and diff token counts before/after. Feel how vocab size trades against tokens-per-document. - Read the fast version. Open
src/lib.rsand find the four moving parts this article named: the GPT-4 split regex,count_pairs_parallel(rayon), the `OctonaryHeap` + `MergeJob` lazy-deletion loop, and thepos-boundedmerge_pairre-counting. That quartet is the optimization. - See it in a real pipeline. Trace [[nanochat]]'s
tokenizer.py: how training (rustbpe) and inference (tiktoken) are deliberately split, and how special tokens are layered on after the learned merges. - Build context. Pair this with [[llm-from-scratch]], [[microgpt-build-an-llm-from-scratch]], [[nn-zero-to-hero]], and [[embeddings]] (what those token IDs feed into). Tokenization is step zero of the whole stack.
See also: [[tokenization]] · [[nanochat]] · [[nanogpt]] · [[llm-from-scratch]] · [[microgpt-build-an-llm-from-scratch]] · [[nn-zero-to-hero]] · [[embeddings]]. (The minbpe slug is referenced in prose; promote to a [[minbpe]] link once that library page exists.)