cs336 / lessons/01 · tokenizationlesson 2 / 20

Part I — Basics

Tokenization — BPE from raw bytes

Lesson 00 fixed the budget: a fixed pile of FLOPs, C ≈ 6·N·D, and a mandate to buy the most capable model it can. But a neural net does not consume text — it consumes a tensor of integers indexed into an embedding table. Before a single FLOP can be spent, raw bytes have to become a sequence of token ids, losslessly and compactly. This lesson builds that map from the ground up: byte-level BPE, the encoder that sits between the disk and the model. It is CS336 Assignment 1.

The previous step left this broken
00 said "spend C = 6·N·D FLOPs well," where D is a token count. But a model's forward pass is logits = Embedding[idx] followed by matmuls — it can only read integer ids, and it has exactly V rows in that embedding table. Text is a stream of arbitrary Unicode. Nothing yet turns "Hello, 世界 🌍" into a vector of ids, and the choice of how you do it silently sets D (sequence length per document), the cost of attention (O(T²) in tokens), and how much of your parameter budget the vocabulary eats (V·d). Tokenization is the first place the budget gets spent.
Linear position
Forced by: a model can only train on integer token ids drawn from a finite, fixed-size vocabulary — and text is unbounded Unicode.
New idea: byte-level Byte-Pair Encoding. Start from the 256 raw UTF-8 byte values (so anything is representable — no out-of-vocabulary token can ever exist), then greedily merge the most frequent adjacent pair into a new symbol, repeat until the vocabulary reaches size V. Tokens become subwords; sequence length ≈ bytes / compression-ratio (≈ 4 bytes/token for English).
Forces next: we now have sequences of ids x1…xT. We need a differentiable function that predicts the next id p(xt | x<t) for every position in one parallel pass — the Transformer (lesson 2).
The plan
Six moves. (1) Rule out the two obvious encodings — characters and words. (2) Drop to UTF-8 bytes so the base vocabulary is exactly 256 and OOV is impossible. (3) Derive the BPE training algorithm with a tiny worked merge example. (4) Add the pre-tokenization regex that keeps merges from crossing word and whitespace boundaries. (5) Reason about the vocab-size trade-off — sequence length vs V·d params vs attention O(T²). (6) Catalogue the pathologies that fall out of this design (digits, whitespace/code, multilingual blow-up, glitch tokens, "count the r's in strawberry").

1 · Why not characters, why not words

The two encodings everyone reaches for first both break, and the way they break maps directly onto the budget.

Characters (or Unicode code points). Map every character to an id. The vocabulary is small and there is no out-of-vocabulary problem for a fixed script. The fatal cost is sequence length. A 1,000-word English document is ≈ 6,000 characters but only ≈ 1,500 subword tokens. Attention is O(T²) in the sequence length T, so a 4× longer sequence is roughly 16× the attention compute and memory, and the model must learn long-range structure across far more positions. You would burn most of the C = 6·N·D budget re-deriving that "t-h-e" is a word. Characters waste FLOPs on spelling.

Words. Map every whitespace-delimited word to an id. Sequences get short, but the vocabulary becomes unbounded: every typo, URL, hashtag, product code, and rare proper noun is a new "word." You cap the table at, say, 50,000 entries and everything past the cap collapses to a single <UNK> token — information destroyed, and the model is blind to exactly the long tail where most of language's surprise lives. Worse, the table does not transfer across languages or even across domains (medicine vs. code). Words waste the vocabulary on a tail it can never finish, and lose data at the cap.

Characters
Tiny vocab, no OOV — but sequences ≈ 4–6× longer. Attention is O(T²), so you pay quadratically to re-learn spelling. Throws the budget at the wrong problem.
Words
Short sequences — but an unbounded vocab forces a hard cap, and everything past it becomes <UNK>. Lossy by construction; the tail is where the information is.
Subwords (BPE)
The middle: frequent words stay whole, rare words split into reusable pieces, and a byte fallback guarantees nothing is OOV. Sequence length and vocab are both tunable knobs.

We want the short sequences of word-level encoding and the losslessness of character-level. Subword tokenization gets both: common strings become single tokens (short sequences), rare strings decompose into smaller known pieces, and — the key move — the pieces bottom out at raw bytes, so the encoder can represent any string at all.

2 · UTF-8 bytes: a base vocabulary of exactly 256, and no OOV ever

Unicode assigns every character a code point — an integer like U+0041 ('A', 65) or U+4E16 ('世', 19,990) or U+1F30D ('🌍', 127,757). There are ~150,000 assigned code points and room for over a million, so a code-point vocabulary is huge and mostly empty. The trick is to not tokenize code points at all — tokenize their UTF-8 byte encoding.

UTF-8 writes each code point as 1–4 bytes: ASCII fits in 1 byte (so English is byte-for-character), most Latin/Greek/Cyrillic and common symbols take 2, most CJK takes 3, and emoji and rarer scripts take 4. A byte is just an integer in [0, 255]. So:

  "A"   U+0041   ->  [ 65 ]                       1 byte
  "é"   U+00E9   ->  [ 195, 169 ]                 2 bytes
  "世"  U+4E16   ->  [ 228, 184, 150 ]            3 bytes
  "🌍"  U+1F30D  ->  [ 240, 159, 140, 141 ]       4 bytes

This single decision pays off enormously. The base vocabulary is exactly 256 tokens — one per possible byte value — and it covers every string that can exist on a computer. No character, no language, no emoji, no binary blob can ever be out-of-vocabulary, because any input is, at worst, a sequence of its raw bytes. The <UNK> token simply does not need to exist. That is the structural advantage byte-level tokenizers (GPT-2 onward) have over the older word-piece tokenizers that fell back to <UNK>.

The price is that we have re-created the character problem in a worse form: a byte stream is even longer than a character stream (a CJK document is 3× its code-point length in bytes). So 256 byte-tokens are only the floor. We now learn merges on top of them to claw the sequence length back down.

3 · The BPE training algorithm — with a worked example

Byte-Pair Encoding (Sennrich et al., 2016, adapting a 1994 compression scheme) is greedy and almost embarrassingly simple. Starting from the 256 byte tokens, you repeat one move until the vocabulary hits the target size V:

countOver the whole training corpus (already split into byte sequences), count how often each adjacent pair of current tokens occurs.
pickFind the single most frequent adjacent pair.
mergeMint a new token id for that pair, add it to the vocabulary, and replace every occurrence of the pair with the new token everywhere in the corpus.
repeatGo back to count. Stop when |vocab| = V. The ordered list of merges is the tokenizer.

Run it on a toy corpus — the string low low low low low lower lower newest newest (the classic BPE example), looking only inside words for clarity. Write each word as its letters; the counts are the per-word frequency. After step 2 the remaining pairs all tie at count 2, so we break ties by earliest first occurrence in the corpus (the same rule the widget below uses), which is why lower is completed before the newest pairs are touched.

stepmost frequent adjacent paircountnew tokenvocab size
start— (just the letters l,o,w,e,r,n,s,t)8
1l + o7lo9
2lo + w7low10
3low + e2lowe11
4lowe + r2lower12
5n + e2ne13

After step 2, every one of the seven low occurrences (5 from "low", 2 inside "lower") is a single token. After step 4, the whole word lower is one token, shared by both "lower". The corpus that started as ~40 letter-tokens is now a dozen-ish chunky tokens, and the vocabulary learned the morphology of this corpus: the stem low, the word lower — without anyone labelling a single morpheme. That is the whole magic. Frequent strings get promoted to single tokens; the merge order records the priority.

Encoding new text replays the learned merges in order: split into bytes, then repeatedly apply the highest-priority merge whose pair is present, until none applies. Decoding is a pure table lookup — concatenate the byte expansion of each id — which is why BPE is exactly invertible (lossless). The compression ratio is the headline number: on English, mature BPE vocabularies land around ≈ 4 bytes per token (≈ 4 characters/token for ASCII). So T ≈ bytes / 4, and that ratio is what shrank the sequence by 4× versus raw bytes and bought back the attention cost.

# BPE training, sketched (counts over pre-tokenized "words")
vocab = {i: bytes([i]) for i in range(256)}      # 256 base byte tokens
merges = []                                       # ordered list (the tokenizer)
corpus = [list(word_bytes) for word_bytes in pretokenize(text)]

while len(vocab) < V:
    pairs = Counter()
    for word in corpus:
        for a, b in zip(word, word[1:]):
            pairs[(a, b)] += freq[word]           # weight by word frequency
    if not pairs: break
    best = max(pairs, key=pairs.get)              # most frequent adjacent pair
    new_id = len(vocab)
    vocab[new_id] = vocab[best[0]] + vocab[best[1]]
    merges.append(best)
    corpus = [merge_pair(word, best, new_id) for word in corpus]

4 · Pre-tokenization: don't let merges cross word boundaries

There is a subtlety the toy example hid by saying "inside words." If you run BPE on the raw byte stream with spaces included, the most frequent pair early on is often (space, t) or (e, space), and you start minting tokens that straddle word boundaries — "e the", ". The" — which is wasteful (the same word gets many spacing-dependent variants) and makes the vocabulary depend on punctuation accidents.

The fix, introduced with GPT-2, is pre-tokenization: before BPE runs, split the text with a regex into coarse chunks (roughly: words, runs of whitespace, numbers, punctuation), and only ever count and merge pairs within a chunk — never across chunk boundaries. GPT-2's regex (slightly paraphrased) is:

's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+

Read it left to right: common English contractions get their own chunk; then "an optional leading space plus a run of letters" (so " the" is one chunk, keeping the leading space attached to the word — which is why GPT-2 tokens print the leading space as "Ġthe"); then runs of digits; then runs of symbols; then runs of whitespace. Merges happen only inside these chunks. This keeps the learned vocabulary about language units, not about where spaces happened to land, and it bounds how long any single token can get. The leading-space convention also means the tokenizer never has to choose between "the" and " the" as separate unrelated tokens for every word — the space is part of the chunk.

5 · The vocab-size trade-off: V is a budget knob

Target vocabulary size V is the one real hyperparameter, and it is a genuine trade-off — bigger is not simply better. Three quantities move with it.

raise Veffectbudget consequence
tokens get longer / sequences shortercompression ratio rises (more bytes/token); same document is fewer tokens; more "knowledge" packed per tokengood: fewer tokens T → cheaper attention O(T²) and fewer steps to read D bytes
embedding + softmax growthe input embedding and the output head are each V·d parameters (often the largest single matrices in a small model)bad: more of N spent on a lookup table, not on layers; the final softmax over V logits costs more per step
tokens get rarereach new token appears in fewer training examples, so its embedding row gets fewer gradient updatesbad: a token seen a handful of times in D is under-trained — a future glitch token (§6)

Put numbers on the parameter cost. With model dimension d = 4,096, the input embedding is V·d parameters; the (untied) output head is another V·d. Going from GPT-2's V = 50,257 to a modern V = 128,000 adds about (128,000 − 50,257) × 4,096 ≈ 0.32 billion parameters per embedding matrix — real budget, taken straight out of the layers that do reasoning. The flip side: the larger vocab might cut the token count of a given corpus by ~15–20%, which means the same D bytes train in fewer steps and a fixed context window holds more text.

That tension is why the field has drifted upward over time but not to the moon. GPT-2 shipped V = 50,257 (50,000 BPE merges + 256 base bytes + 1 end-of-text token). Modern models sit in the 100k–256k range — GPT-4 / Llama-3 use ≈ 100k–128k, and some multilingual models push to 256k specifically to keep non-English compression from collapsing (§6). The sweet spot rises with model size (a bigger d amortizes the V·d cost) and with how multilingual the data is. The widget below lets you watch sequence length fall as you mint more merges.

6 · Pathologies — everything weird about LLMs that is really a tokenizer bug

Because the tokenizer is a frozen, statistics-driven layer the model cannot see around, its quirks leak into model behavior in ways that look like reasoning failures but are not.

Digits
BPE merges frequent digit strings ("2023", "100") into single tokens but leaves rare ones split arbitrarily, so the model sees no consistent place-value structure. This is a big part of why LLMs are shaky at arithmetic. Llama and others now force-split every digit into its own token to fix it.
Whitespace & code
Indentation is runs of spaces; a naïve tokenizer wastes a token per space, so Python with 4-space indents tokenizes terribly. Code-aware tokenizers add explicit multi-space tokens (" ") — directly improving both compression and code quality.
Multilingual blow-up
A vocab trained mostly on English learns few non-English merges, so other languages fall back toward raw bytes. The same sentence can be 2–3× more tokens in Hindi or Thai than English — you literally pay more per word, and the context window holds less. The motivation for 256k vocabularies.
Glitch tokens
A string frequent in the BPE-training corpus but near-absent in the model-training corpus gets a vocab id whose embedding is barely trained — e.g. the infamous " SolidGoldMagikarp" (a Reddit username). Feed it in and the model emits gibberish or evasions, because that embedding row never received signal. A direct symptom of the rare-token problem in §5.

And the famous one: "how many r's in strawberry?" The model often answers "2." Not because it cannot count — because it never sees the letters. "strawberry" arrives as a couple of subword tokens (something like " straw" + "berry"), each an opaque id. The character-level fact "there are three r's" was destroyed at the tokenizer before the model ever ran, exactly as the <UNK> cap destroyed information in §1 — only here the loss is silent. Any task that needs to operate below the token (counting letters, reversing strings, character-level edits) is fighting the encoder. It is a tokenization artifact, full stop, and it is the cleanest demonstration that the choices in this lesson propagate all the way to user-visible behavior.

7 · Interactive: BPE merge visualizer

Type text with repeated substrings, then press step merge to run one round of BPE: the widget finds the most frequent adjacent pair in the current segmentation, mints it as a new token, and re-segments. Watch the token count fall and the vocabulary rise with each merge — and watch bytes/token (the compression ratio) climb toward the ≈ 4 you would see on real English. This is the §3 algorithm in miniature; the only simplification is that it operates on characters rather than UTF-8 bytes so the chips stay readable.

BPE merge visualizer — step merges, watch the sequence shrink
Each step merge = one round of BPE: pick the most frequent adjacent token pair (shown below), merge it everywhere, re-segment. Tokens falls and vocab rises with every merge; bytes/token is the compression ratio. Reset to retype. Note that early merges shrink the sequence fastest — the most frequent pair is the cheapest win — exactly why BPE is greedy.
No merges yet — the segmentation is one token per character (the 256-byte floor, shown here as characters).
Tokens (T)
Vocab size
Bytes / token
Merges done

Failure modes & checklist

Failure modes

  • Training the tokenizer on the wrong corpus. Fit BPE on English, deploy on code or Hindi, and compression collapses toward raw bytes. Signal: bytes/token far below ~4 on your real traffic; non-English prompts blow past the context window.
  • Skipping pre-tokenization. Letting merges cross spaces and punctuation. Signal: vocabulary full of fragments like " the", ". The", "e the" — many near-duplicate tokens differing only by adjacent spacing.
  • Vocab too large for the data. A 256k vocab on a small corpus leaves rare tokens with a handful of occurrences. Signal: glitch tokens at inference; long flat tail of token frequencies; under-trained embedding rows.
  • Assuming the model sees characters. Expecting reliable letter-counting, string reversal, or precise digit math. Signal: "strawberry has 2 r's"; arithmetic that fails on numbers split oddly by the merge order.
  • Forgetting it is lossless and frozen. Changing the tokenizer means re-pretraining — the embedding ids no longer mean the same thing. Signal: swapping tokenizers mid-project and seeing the loss reset to scratch.

Checklist

  • Bytes at the bottom. Base vocabulary = 256 byte values, so OOV is structurally impossible. No <UNK>.
  • Pre-tokenize first. Split on a regex; merge only within chunks; keep the leading space attached to words.
  • Pick V against the budget. Trade shorter sequences (cheaper O(T²)) against V·d embedding params and rarer tokens. 50k–256k depending on size and languages.
  • Measure compression on real traffic. Report bytes/token per language/domain, not just on English.
  • Split digits (and add whitespace tokens for code) if arithmetic or code matters.
  • Freeze it before pretraining and train the tokenizer on the final data mix — it is part of the model's contract.

Checkpoint

Try it
Open the widget and replace the text with a string of your own that has heavy repetition — e.g. paste a few copies of the same sentence. Before pressing anything, predict the first merge (the most frequent adjacent character pair). Step until bytes/token climbs past 2.0 and note how many merges it took and which pairs got promoted first. Then reason about V·d by hand: at d = 4,096, how many parameters does one embedding matrix cost at V = 50,257 vs V = 128,000, and how many extra training steps would the larger vocab save on a 1-trillion-byte corpus if it improves compression from 4.0 to 4.6 bytes/token? (Answer the second part as a ratio of token counts.)

Where this points next

We now have a clean, lossless, frozen map from text to a sequence of integer ids x1…xT, with T ≈ bytes / 4 and a vocabulary V chosen against the budget. That is the input contract for everything downstream — and it immediately exposes the next wall. A sequence of ids is inert; the entire point (lesson 0's mandate) is to model p(xt | x<t) and lower its negative log-likelihood. We need a function that, in one parallel, differentiable pass, predicts the next id at every position at once — fast enough that the 6·N·D budget actually buys a model. That function is the decoder-only Transformer, built shape by shape in 02 · The Transformer, from scratch.

Takeaway
A model reads integer ids, not text, so the first FLOP cannot be spent until text becomes a token sequence — and that encoding silently sets sequence length T (hence attention's O(T²) cost and how fast you consume D) and the V·d slice of N the vocabulary eats. Characters waste FLOPs re-learning spelling; words lose the long tail to <UNK>. Byte-level BPE threads the needle: a base vocabulary of exactly 256 UTF-8 byte values makes OOV structurally impossible, then greedily merging the most frequent adjacent pair up to size V promotes frequent strings to single tokens, giving ≈ 4 bytes/token of compression. Pre-tokenization keeps merges inside word/space boundaries; V (GPT-2's 50,257 → modern 100k–256k) trades shorter sequences against embedding params and token rarity. Its quirks are not bugs in the model — digit math, code whitespace, multilingual blow-up, glitch tokens like " SolidGoldMagikarp", and "two r's in strawberry" are all the encoder leaking through. This is CS336 Assignment 1, and it produces the integer sequence the Transformer will model next.

Interview prompts