The lossy compression layer between human text and the model — where 'strawberry' becomes three tokens the model can't spell and a glitch token can make GPT speak in tongues.
ai-eng-wiki/examples/transformers/bpe.pyA transformer never sees text. It sees integers. Tokenization is the deterministic function that maps a UTF-8 string to a list of integer IDs (and back), and it is the only lossy, learned-but-frozen layer in the stack that you typically cannot fine-tune away. The dominant scheme — byte-level BPE — starts from the 256 raw bytes and greedily merges the most frequent adjacent pairs into a fixed vocabulary of subword "tokens," so common words become one token and rare strings shatter into many. This is why token counts are not word counts (your bill and your context window are denominated in tokens), why the model "can't spell" (it sees straw+berry, not letters), and why a single corrupted training token can make a frontier model babble. Get the tokenizer wrong and you pay for it in every forward pass, in every language, forever.
len(text) ≠ token count, why digits and rare words cost more, and the basic spelling/counting failure mode? Do you reach for tiktoken before guessing context limits? This is table-stakes literacy: people who skip it ship code that silently truncates prompts.The words first.
straw or ing).Step by step.
Remember this: the tokenizer is a frozen, lossy compressor trained once on a corpus; everything downstream — cost, context length, spelling, multilingual fairness — inherits its biases.
Imagine three naive vocabularies. Characters/bytes: vocab of ~256, never OOV, but a 1,000-character sentence becomes ~1,000 tokens — attention is O(N²), so you've made every forward pass catastrophically expensive. Whole words: short sequences, but English alone has millions of word forms; you'd need a huge embedding matrix, and you'd still hit OOV on antidisestablishmentarianism, typos, and every new product name. Subwords are the Goldilocks point: frequent words get one token (the → 1), rare words decompose into reusable pieces (tokenization → token+ization), and nothing is ever OOV if you work at the byte level. The whole game is choosing where to put the vocabulary's finite "budget."
BPE (Sennrich et al., 2016, originally for machine translation) builds the vocab bottom-up. Start with bytes; count adjacent pairs over the corpus; merge the most frequent pair into a new symbol; repeat. The learned artifact is an ordered list of merge rules. The order matters enormously: to encode new text you replay merges in the order they were learned, because earlier merges are the high-frequency building blocks later merges depend on.
15 tokens · type to see the split. Real tokenizers learn subword merges (BPE) so common words are one token and rare words split into pieces — which is why token counts ≠ word counts and why spelling/maths can trip models up.
A subtlety that trips people up: BPE pre-tokenizes on word boundaries (via regex) first, so merges never fuse across spaces or punctuation. That's why dog. and dog tokenize differently, and why a leading space is part of the token ( the is a distinct token from the). This boundary handling is exactly where BPE's known failure mode lives — when a high-frequency pair straddles a morpheme boundary, you get linguistically nonsensical splits.
Symbols. corpus = the training text. pair_counts[(a,b)] = how many times symbol a is immediately followed by b. A merge = fusing one pair into a new ID.
Setup. Tiny corpus: the word low appears 5 times, lower 2 times, newest 6 times, widest 3 times. Work at the character level for readability. Start: l o w (×5), l o w e r (×2), n e w e s t (×6), w i d e s t (×3).
Round 1 — count pairs. (e,s) appears in newest (6) + widest (3) = 9. (l,o) appears in low (5) + lower (2) = 7. (s,t) = 6+3 = 9. The max is a tie at 9; ties break by first-seen, say (e,s). Merge → new token es. Now newest = n e w es t, widest = w i d es t.
Round 2 — recount. (es,t) = 6+3 = 9, now the clear winner. Merge → est. Now newest = n e w est, widest = w i d est.
Round 3. (l,o) = 7 wins. Merge → lo. low = lo w, lower = lo w e r.
What it did: in 3 merges the vocab learned the reusable suffix est and the stem lo purely from co-occurrence statistics — no linguistics, no labels. Encode slowest (unseen): apply merges in order → s lo w est = 4 tokens, every piece known. Zero OOV.
GPT-2 (Radford et al., 2019) made one decision that every frontier lab copied: run BPE over bytes, not Unicode characters. The base vocabulary is the 256 possible byte values. Because UTF-8 encodes every possible string as bytes, byte-level BPE can round-trip emoji, Chinese, malformed Unicode, and binary garbage with zero OOV tokens — there is literally no input it can't represent. (GPT-2 added a clever byte-to-printable-char remapping so control bytes don't break the regex, but conceptually the base is bytes.) The cost: a single non-ASCII character is multiple bytes, so café or 日本語 cost more tokens than their character count suggests — a multibyte character that didn't get merged stays as 2–4 separate byte tokens. This is the seed of the multilingual inequity we'll hit in §5.
Three siblings you must be able to contrast:
SentencePiece (Kudo & Richardson, 2018) is a framework, not an algorithm. Its real innovation: treat the raw input as a stream of Unicode characters with spaces escaped as a visible ▁ token, so tokenization is fully reversible and language-agnostic (no reliance on whitespace, which matters for Chinese/Japanese/Thai that don't space-separate words). It can run either BPE or Unigram underneath. Used by T5, Gemma, Llama (Llama 2 used SentencePiece-BPE).
Unigram LM (Kudo, 2018) goes top-down instead of bottom-up: start with a huge candidate vocab, assign each token a probability, then iteratively prune the tokens whose removal least hurts the corpus likelihood under a unigram language model. At encode time it picks the segmentation with highest total probability (via Viterbi), and during training it can sample alternate segmentations ("subword regularization") as data augmentation. It tends to produce more linguistically clean splits than greedy BPE.
WordPiece (BERT, Google) is BPE's cousin: same greedy bottom-up merging, but instead of merging the most frequent pair, it merges the pair that most increases corpus likelihood — roughly, it maximizes count(ab) / (count(a)·count(b)), favoring pairs that are "surprisingly" common rather than merely frequent. It marks word-interior pieces with ## (playing → play, ##ing).
| Scheme | Direction | Selection criterion | Marks | Notable users |
|---|---|---|---|---|
| BPE | bottom-up merge | most frequent pair | leading space | GPT-2/4o, Llama 3, Qwen |
| WordPiece | bottom-up merge | max likelihood gain | ## interior |
BERT, DistilBERT |
| Unigram | top-down prune | min likelihood loss | ▁ space |
T5, Gemma, mBART |
Three forces decouple tokens from words. (1) Common words are one token, rare words are many — the is 1, antidisestablishmentarianism might be 6+. (2) Leading spaces and case create distinct tokens — The, the, the, THE can be four different IDs, so the same word costs differently by position. (3) Non-English and code over-fragment — a language poorly represented in the training corpus never got its frequent pairs merged, so it falls back toward raw bytes. A rough English heuristic is ~0.75 words per token (≈1.3 tokens/word), but treat it as a guess: always measure with the real tokenizer (tiktoken, or the HF tokenizers for the model you actually use). This number is your economics — context windows, API pricing, and prefill latency are all denominated in tokens, not words.
The file examples/transformers/bpe.py is a complete, runnable byte-level BPE — train, encode, decode, with round-trip assertions. It's the same algorithm as tiktoken, minus the regex pre-tokenizer and Rust. The core is three functions: count pairs, merge a pair, and replay merges in learned order.
from collections import Counter
def get_pair_counts(ids_per_word):
counts = Counter()
for ids, freq in ids_per_word:
for a, b in zip(ids, ids[1:]):
counts[(a, b)] += freq # weight by word frequency
return counts
def merge(ids, pair, new_id):
out, i = [], 0
while i < len(ids):
if i < len(ids) - 1 and (ids[i], ids[i+1]) == pair:
out.append(new_id); i += 2 # collapse the pair
else:
out.append(ids[i]); i += 1
return out
def train(text, vocab_size):
merges, vocab = {}, {i: bytes([i]) for i in range(256)} # byte-level base
word_freqs = Counter(text.split()) # pre-tokenize
ids_per_word = [(list((" " + w).encode()), f) for w, f in word_freqs.items()]
for step in range(vocab_size - 256):
counts = get_pair_counts(ids_per_word)
if not counts: break
best = max(counts, key=counts.get) # greedy
if counts[best] < 2: break
new_id = 256 + step
merges[best] = new_id
vocab[new_id] = vocab[best[0]] + vocab[best[1]]
ids_per_word = [(merge(ids, best, new_id), f) for ids, f in ids_per_word]
return merges, vocabRunning the full file on a tiny corpus prints the first merges and tokenizes test strings:
learned 30 merges, vocab size 286
merge 0: (116, 104) -> 256 ('th')
merge 1: (256, 101) -> 257 ('the')
merge 2: (32, 257) -> 258 (' the')
merge 6: (261, 259) -> 262 (' cat')
'the cat' -> 2 tokens [258, 262]
'running' -> 1 tokens [281]
'strawberry' -> 10 tokens [265, 116, 114, 97, ...] # unseen → shatters to bytes
'café' -> 5 tokens # 'é' is 2 UTF-8 bytes, never mergedThree things to notice, each load-bearing for the interview. (1) Order is the model: th (256) must be learned before the (258) can form — encoding replays merges by ascending ID. (2) Byte-level = no OOV: strawberry, absent from this corpus, doesn't error — it gracefully degrades to byte tokens. (3) é costs 2 byte tokens because it's 2 UTF-8 bytes that never got frequent enough to merge — the multilingual tax in miniature. The encoder applies the globally earliest applicable merge each pass (min by merge ID), which is the standard greedy BPE encode; production tiktoken does the same with a faster priority-queue inner loop.
| Lever | Small vocab (~32k) | Large vocab (~128–200k) |
|---|---|---|
| Embedding + LM-head memory | Small (vocab × d_model × 2) | Large — can be 10–20% of params at d_model=4k |
| Sequence length / token count | Longer (more fragmentation) | Shorter (more text per token) |
| Inference cost per char | Higher (more steps) | Lower |
| Rare-word / multilingual coverage | Worse (over-fragments) | Better |
| Softmax over vocab (per step) | Cheap | More expensive |
Cost & latency. The vocab sits in two places: the embedding table (in) and the LM head (out), each vocab_size × d_model. At 200k vocab and d_model 4096 in fp16 that's ~1.6 GB per table — a real chunk of a small model's footprint and a fatter softmax every decode step. Against that, a bigger vocab packs more characters per token, so sequences are shorter, attention's O(N²) is cheaper, and you fit more content in a fixed context window. Llama 3 jumped to a 128k vocab (from Llama 2's 32k) precisely to shorten sequences and improve multilingual/code efficiency; GPT-4o went to ~200k. The crossover is empirical: past a point, extra vocab entries are rare tokens whose embeddings are under-trained and whose memory cost outweighs the sequence-length win.
The multilingual failure mode (the IC5 trap). A tokenizer trained on English-dominated data never merged the frequent pairs of low-resource languages, so Hindi or Burmese text falls back toward raw bytes — the same sentence can cost 2–5× more tokens than its English translation. That's a triple penalty: users pay more, fit less context, and the model effectively sees their language at lower resolution (each token carries less meaning), which degrades quality. This is a fairness and cost problem baked into a frozen artifact. Mitigations: train the tokenizer on a balanced multilingual corpus from day one (best, but requires committing before pretrain), parity-aware BPE that caps per-language fragmentation, or — post-hoc — vocab extension: add new tokens for the underserved language and continue-pretrain only the new embedding rows. Extension is cheap but the new tokens start under-trained and never integrate as cleanly as native ones.
Glitch tokens (the data-hygiene failure). A token can exist in the vocab but be almost absent from training text — classically because the tokenizer corpus and the model corpus differed, or because a Reddit-username string (SolidGoldMagikarp, _davidjl) was frequent enough to earn a merge but then got filtered out of pretraining. Its embedding is essentially random/untrained, so prompting the model with it produces bizarre, off-distribution behavior — evasion, hallucination, or spelling something entirely different (Rumbelow & Watkins, 2023). The fix is process: build the tokenizer and the model on the same cleaned corpus, and audit low-frequency token embeddings.
The bet at scale. Tokenization is increasingly seen as a wart — it's why models can't do character-level tasks, it bakes in language bias, and it's a non-differentiable seam in an otherwise end-to-end system. Byte-level / tokenizer-free architectures (e.g. byte-latent approaches that dynamically group bytes) are an active research direction; as of mid-2026 they're not yet the production default — the O(N²) cost of long byte sequences is the blocker — but it's the direction a strong IC5 should name. See /inference for how token granularity drives KV-cache and throughput math, and /context-engineering for budgeting tokens in long prompts.
strawberry is typically 2–3 tokens (e.g. straw + berry), so the spelling is hidden inside opaque embeddings. Asking for a character count or a reversal requires recovering sub-token structure the model was never given directly; it has to have memorized the spelling of each token, which it does unevenly. The same mechanism explains weak arithmetic on long numbers: digits group into multi-digit tokens inconsistently (12345 may be one token, 123456 two), so the model can't cleanly align place values.bpe.py as the ~120-line reference.count(ab)/(count(a)count(b)) — so it favors surprisingly-common pairs and marks interior pieces with ##. Unigram goes top-down: overshoot the vocab, then prune the tokens whose removal least hurts a unigram-LM corpus likelihood, and at encode time pick the most-probable segmentation via Viterbi (and can sample segmentations for regularization). BPE/WordPiece are deterministic and greedy; Unigram is probabilistic and tends to produce cleaner morphological splits, which is why T5/Gemma use it via SentencePiece.SolidGoldMagikarp family). Its existence is a tell that the tokenizer corpus and the pretraining corpus diverged, or that aggressive data filtering ran after tokenizer fitting. The fix is process hygiene: fit the tokenizer on the same cleaned corpus the model trains on, and audit the embedding norms of rare tokens before release.tiktoken for OpenAI, the HF tokenizers for the open model). A 0.75-words-per-token rule of thumb is fine for English back-of-envelope and wrong for code, JSON, and non-English. The, the, THE can be distinct tokens; few-shot prompt formatting that changes spacing can change tokenization and behavior.Flashcard. BPE = start from 256 bytes, greedily merge the most frequent adjacent pair into a new token, repeat to target vocab; encode by replaying merges in learned order. Byte-level ⇒ zero OOV. Token count ≠ word count, and that delta is your cost, context, and multilingual fairness.
Next: Self-attention and the QKV mechanism — once text is IDs, the embedding table turns them into vectors and attention does the rest.