Learning 03 · learned once, replayed forever
We kept saying BPE “keeps merging common adjacent pairs.” That phrase hides the single most important fact about tokenizers: there are two phases, and only one of them involves learning. The model creators learn the merges from a corpus; at M0 we only replay their frozen rules.
(vocab, merges). Inference replays those
merges in rank order. Encoding does zero learning and zero counting — it is
deterministic table lookup.
bpe_tokenize_text (~ds4.c:21140), str_i32_table vocab / merge-rank tables
🧭 Raschka · “Build a Large Language Model (From Scratch)” — clean BPE walkthrough
The whole idea
Frequency-driven merging happens once, upstream, when the model is built. What ships with the model is two static artifacts — a vocab (token string → id) and an ordered merges list (the rules, in the order they were learned; order = frequency rank, and rank is everything). Our tokenizer never re-derives them.
Unsupervised ML over a big corpus:
t+h)
into a new symbol, give it the next free id, and append the rule
("t","h") → "th" to the ordered merges list.Zero learning, zero frequency counting — just replay the frozen rules:
That is why ds4 stores a merge_rank hash table
(ds4.c:20791): “given this pair, what is its rule rank?” — pure exact lookup,
no counting. The learning already happened upstream.
"low"One chunk, printable ASCII, so its byte-level form is just low.
Each pass applies the lowest-rank merge present (ranks are illustrative):
| pass | symbols | candidate pairs (rank) | winner |
|---|---|---|---|
| 1 | l o w | (l,o)=5, (o,w)=2 | (o,w) |
| 2 | l ow | (l,ow)=rule | (l,ow) |
| 3 | low | none | — stop |
(o,w) — the pair
in the middle — because rank 2 beats rank 5, even though (l,o) sat
right at the front. Left-to-right greedy would be wrong; you must pick the
global-minimum rank. (The M0 milestone pins the full
hello → 14990 version of this as a test.)
The encoding question
“Split into raw bytes” hides an assumption: the input is UTF-8.
There is no encoding-detection step. The pipeline treats the text as a UTF-8 byte sequence
and walks it one byte (0–255) at a time — ds4's byte_encode
(ds4.c:20914) iterates the input as raw uint8_t. In Rust this is
free: a &str is always valid UTF-8, so .as_bytes() is
already the right bytes.
The nice consequence: a character is its UTF-8 bytes, not one symbol.
é (U+00E9) enters BPE as 2 bytes, 中 as 3, an emoji as 4. Because
all 256 byte values are in the base vocab, anything is representable — no
out-of-vocabulary case, no UNK token. That is the whole reason
byte-level BPE exists.
One subtlety: you do not merge over raw 0x00–0xFF directly. Each
byte is first remapped to a printable codepoint via the GPT-2 byte↔unicode
table (gpt2_byte_to_codepoint, ds4.c:20896): printable
ASCII/Latin-1 stay as-is; the ~68 awkward bytes (control chars, space, newline) map to
codepoints from 256 up — which is why a space shows as Ġ in vocab dumps. Each
remapped codepoint still stands for exactly one original byte; it just keeps the merge
symbols printable and whitespace-free.
A guard rail, not the algorithm
Before BPE runs, the text is split into coarse chunks by a single fixed regex. The classic GPT-2 pattern:
's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+
Read it as: contractions · optional-space + letters ·
optional-space + digits · optional-space + punctuation · whitespace.
So " hello world!" pre-splits into
[" hello", " world", "!"]. Qwen uses a more elaborate cl100k/GPT-4-style
variant, same idea.
"dog.", "the quick"),
wasting vocab and making segmentation wildly context-dependent.
Where the regex comes from depends on the model's format — and either way it is part of the model's definition, not something we invent:
tokenizer.json: the literal regex string is in
the pre_tokenizer field. You read it out verbatim.tokenizer.ggml.pre = "qwen2" (or "llama-bpe", "gpt-2", …)
— not the pattern. The engine must already know the regex that name refers to; that
is why ds4 hardcodes per-model pre-tokenizers (ds4.c:21121).Largely yes — lexical analysis is the textbook use of regular
expressions (lex/flex compile regexes to a DFA). But the parallel
breaks in ways worth holding onto:
| Compiler lexer | LLM BPE tokenizer | |
|---|---|---|
| Rules are | hand-designed in the language spec | learned from a corpus by frequency |
| A token is | a grammatical category (IDENT, IF, +) — dozens | a statistical fragment (" dog", "tion") — ~150k |
| The regex | is the whole tokenizer | only pre-splits; a learned merge loop does the real work |
| Greedy rule | longest-match + rule priority | apply the lowest-rank merge |
Deeper: a lexer is lossless and meaningful (every token is one lexical unit the parser depends on; there is a right answer). BPE segmentation is arbitrary — the same word tokenizes differently with a leading space, and the model is fine because it trained on that messiness. Fancier schemes exist (Unigram/SentencePiece uses Viterbi; WordPiece uses greedy longest-match; ByT5 drops tokenization entirely). BPE's greedy-merge is one of the simpler methods; the regex pre-tokenizer is widely seen as an inelegant-but-necessary wart.
Correct is relative to one model
A tokenizer is correct only relative to one model's vocab + merges. “Implementing BPE” is not enough — we must reproduce that model's exact rules and conventions. For our target (Qwen3, GPT-2-style byte-level BPE):
tokenizer.ggml.tokens, tokenizer.ggml.merges) or HuggingFace
tokenizer.json — the same source the model trained with.Ġ). Get it exactly right or nothing lines up.<|im_start|>,
<|endoftext|>, etc. are inserted as whole tokens and bypass
BPE.encode → decode must reproduce the input byte-for-byte.Keep this
(vocab, merges). Inference = replay merges in rank order,
deterministic, no learning. Correct = byte-for-byte ID parity with the
model's own official tokenizer.
For M0 (Rust):
HashMap<Vec<u8>, u32> for token→id, plus a pair→rank map for
merges (mirrors ds4's two str_i32_tables). No trie needed — byte-level BPE is
exact lookups.hello → 14990 worked trace, special tokens, and the
exact-ID parity harness.bpe_tokenize_text (~ds4.c:21140), bpe_emit_piece,
the str_i32_table vocab / merge-rank hash tables. · Full note:
docs/learnings/03-bpe.md.