The Merge Table Is the Tokenizer: Why Tokenize, and BPE from Scratch
Part 23 said a token is something you define, using stoi with one number per character; part 27 said text becomes
bytes before it reaches the tokenizer. This part fills in the stretch between: how bytes become tokens.
text → UTF-8 bytes (part 27) → BPE merges (this part) → token IDs → modelA model sees nothing but IDs
Parts 23 to 26 tokenized by character: the 65 characters that occur in Shakespeare, one number each, a 65-row embedding table.
"hii there" ↓ split by character, look up stoi[46, 47, 47, 1, 58, 46, 43, 56, 43] ← 9 tokens ↓ each number fetches a row of the embedding tableinto the modelA model never sees text, only IDs. The tokenizer is the layer that translates back and forth between the two. Real models do not split by character but by “chunk”: a common word is one chunk, a rarer one breaks into a few. GPT-2’s vocabulary has 50,257 tokens and its context is 1024 — and that 1024 counts tokens, not characters.
The token is the base unit of a large model: context length, training data size and API billing are all counted in tokens.
The split decides what the model sees
Karpathy opens the video with a list of large models’ odd habits, all rooted in tokenization. Four of them:
| habit | why |
|---|---|
| can’t spell, can’t count the r’s in a word | egg is one ID to the model, which never sees the letters e, g, g; Egg and EGG are other IDs again, and that they are the same word has to be learned from data |
| bad at arithmetic | numbers are cut into chunks that don’t line up with digit places; addition goes units to units, but the chunks the model sees are ragged |
| worse outside English | the tokenizer was trained mostly on English, so other languages rarely get merged into big chunks and the same content costs far more tokens |
| GPT-2 struggles with Python | every space of indentation takes its own token, and code is full of indentation |
The last one has a sequel in the video: GPT-4’s tokenizer merges runs of spaces into a single chunk, one of the reasons it writes code better than GPT-2.
Same text, different tokenizer, different IDs
When OpenAI released GPT-4o it published a comparison: its new tokenizer, o200k_base (a vocabulary of about 200k), cut the same text as GPT-4’s cl100k_base (about 100k) into 1.4—1.7× fewer tokens for East Asian languages such as Chinese, Japanese, Korean and Vietnamese, and 2.9—4.4× fewer for Indian languages such as Hindi.
The reason lies in BPE itself: a bigger vocabulary can merge more, and longer, chunks; and the more of a language the tokenizer sees in training, the more readily its fragments merge into whole chunks — the algorithm below shows why.
🔑 Same model family, same text, only the tokenizer changed, and the token count moves by multiples — how many tokens a passage costs is decided by the tokenizer, not the model. Part 23’s “choose Qwen because Chinese costs fewer tokens” rests on exactly this.
A vocabulary cannot grow without limit
Every extra token is one more row in the embedding table and one more column in the output layer. Take the model from experiment D in part 25 (384 dimensions per token):
| vocabulary | total parameters | embedding + output layer | |
|---|---|---|---|
| character level | 65 | 10,788,929 | 0.5% |
| with GPT-2’s vocabulary | 50,257 | about 49.39M | 78% |
All 38.6M extra parameters sit in those two layers. Part 23 said “a small model with a large vocabulary is badly proportioned”; this table puts numbers on it. The bigger the vocabulary, the fewer tokens the same text cuts into — but the more parameters the model spends just storing the tokens.
Code points are too many, bytes too long
What the model wants is a string of integers. There are two ready-made ways to turn text into integers, and neither works — which is precisely the problem BPE solves.
Code points as tokens
A Python string is already a sequence of code points:
>>> [ord(c) for c in "你好,世界"][20320, 22909, 65292, 19990, 30028]Two things rule it out: the vocabulary is too big — Unicode holds about 150,000 characters, and the vocabulary would be the whole table; and it is unstable — Unicode adds characters every year, so the vocabulary would change and the model would have to be retrained.
Bytes as tokens
UTF-8 has only 256 byte values, fixed forever, able to represent any text (part 27). UTF-16 and UTF-32 are out of the
running: English h is [104, 0] and [104, 0, 0, 0] in them, stuffed with zeros.
The drawback is sequences far too long. Part 27’s Chinese sentence:
分词是大模型许多怪现象的根源。 15 charactersas byte tokens → 45 tokensThe context holds a third as much; and attention computes a T×T table (part 23’s O(T²)), so three times the length is
nine times the work.
BPE: start from 256 bytes, merge what often sits together
| approach | vocabulary | sequence length | problem |
|---|---|---|---|
| code points | about 150k, and changing | same as characters | too big, unstable |
| UTF-8 bytes | 256 | very long | the context holds little |
| BPE | your choice (GPT-2 about 50k) | short | — |
🔑 The smaller the vocabulary, the longer the sequence; the bigger the vocabulary, the shorter. The two trade off, and BPE picks a point in between: starting from 256 bytes, it merges a pair that often appears together into a new token, again and again, until the vocabulary reaches the size wanted — keeping “bytes can represent anything” while shortening the sequence.
The video also mentions the other road: skip tokenization and let the model read bytes directly (MEGABYTE, 2023, a Transformer with a hierarchical structure). Karpathy’s verdict was that it had not yet been proven on large models, and for now BPE is still the way.
Training BPE: count adjacent pairs, merge the commonest, repeat
The algorithm has a single rule: find the adjacent pair that occurs most often, replace it with a new symbol, and repeat. The video uses Wikipedia’s example:
aaabdaaabac the commonest adjacent pair is aa → new symbol ZZabdZabac the commonest is ab → YZYdZYac the commonest is ZY → XXdXac no pair occurs twice any more, stopThe sequence shrinks from 11 symbols to 5, the symbol set grows from {a, b, c, d} to {a, b, c, d, Z, Y, X}: each
merge makes the sequence a little shorter and the vocabulary one entry larger. The third step merges the two new
symbols Z and Y into X — merged symbols can take part in further merges, X = ZY = aa + ab = aaab.
On bytes, the starting point is the 256 values 0—255, and new tokens are numbered from 256.
Only adjacent pairs, overlaps left to right, ties recorded in order
“Replace the commonest pair with a new symbol” replaces the two together, and only where they truly sit side by side:
# merging (101, 32) in "the tree ", i.e. e plus a space → 256[116, 104, 101, 32, 116, 114, 101, 101, 32][116, 104, 256, 116, 114, 101, 256] # 9 become 7The first e in “tree” is followed by an e, not a space, so it stays 101. 101 and 32 also stay in the vocabulary — merging only adds, never removes.
Two places where intuition slips:
- Overlaps go left to right, first come first paired:
aaabecomesZa, and the third a has no partner and stays. So in the step aboveaacounts 4 times but is replaced in only 2 places - A tie can go either way, but the order has to be recorded: in
ZabdZabac,Zaandabboth occur twice. Wikipedia takesab; the code below, usingmax, takesZa, counted first. Both are valid BPE — what matters is that the order merges happen in during training goes into the table exactly as it happened
get_stats and merge
Two functions are the whole algorithm:
tokens = list(text.encode("utf-8")) # integers 0—255 first
def get_stats(ids): counts = {} for pair in zip(ids, ids[1:]): # (ids[0],ids[1]), (ids[1],ids[2]), ... counts[pair] = counts.get(pair, 0) + 1 return counts
def merge(ids, pair, idx): newids, i = [], 0 while i < len(ids): if i < len(ids) - 1 and ids[i] == pair[0] and ids[i + 1] == pair[1]: newids.append(idx) # this pair becomes the new ID i += 2 # skip both else: newids.append(ids[i]) i += 1 return newids
merge([5, 6, 6, 7, 9, 1], (6, 7), 99) # → [5, 6, 99, 9, 1]zip(ids, ids[1:])lines the sequence up against itself shifted by one, giving every adjacent pair at oncemergescans left to right, emitting the new ID and jumping two on a match, copying and stepping one otherwise — which is where “overlaps go left to right” comes fromi < len(ids) - 1guards against running off the end at the last position
On the whole of Shakespeare the top pair is (101, 32), e plus a space, 27,643 times: a great many English words
end in e with a space right after.
The training loop: the merge table is the tokenizer
vocab_size = 276 # the vocabulary size wantednum_merges = vocab_size - 256 # = 20 mergesids = list(tokens) # a copy, leaving the data untouchedmerges = {} # (p0, p1) -> new IDfor i in range(num_merges): stats = get_stats(ids) pair = max(stats, key=stats.get) idx = 256 + i ids = merge(ids, pair, idx) merges[pair] = idxTwenty merges on the whole of Shakespeare:
merge 1: (101, 32) -> 256 'e ' merge 11: (97, 110) -> 266 'an'merge 2: (116, 104) -> 257 'th' merge 12: (58, 10) -> 267 ':\n'merge 3: (116, 32) -> 258 't ' merge 13: (111, 114) -> 268 'or'merge 4: (115, 32) -> 259 's ' merge 14: (111, 32) -> 269 'o 'merge 5: (100, 32) -> 260 'd ' merge 15: (101, 110) -> 270 'en'merge 6: (44, 32) -> 261 ', ' merge 16: (10, 10) -> 271 '\n\n'merge 7: (111, 117) -> 262 'ou' merge 17: (97, 114) -> 272 'ar'merge 8: (101, 114) -> 263 'er' merge 18: (32, 257) -> 273 ' th' ← 257 merged againmerge 9: (105, 110) -> 264 'in' merge 19: (111, 110) -> 274 'on'merge 10: (121, 32) -> 265 'y ' merge 20: (108, 108) -> 275 'll'
bytes 1,115,394 → tokens 882,737, compression 1.26x- New tokens merge again: merge 18 takes the
thmade by merge 2 (257) together with the space before it intoth; a few more rounds andtheappears - The rules mirror the data: merge 12,
:\n, comes from the play’s layout, every speaker’s name followed by a colon and a newline (“First Citizen:”) - Compression = bytes ÷ tokens: twenty merges compress by 1.26×. More merges, more compression, a bigger vocabulary — the trade-off from the previous section
🔑 What training produces is the
mergestable: twenty rules in the order learned. A trained tokenizer holds nothing but that table. Vocabulary size = 256 + number of merges: GPT-2’s 50,257 = 256 + 50,000 merges + 1 special token (<|endoftext|>). A real tokenizer does not stop at “no repeats”; it stops after a set number of merges.
A Hanzi’s 3 bytes take two merges
Ten merges on a passage of Chinese (423 characters, 1,227 bytes), a few picked out:
merge 4: (231, 154) -> 259 b'\xe7\x9a' the first two bytes of 的merge 5: (259, 132) -> 260 b'\xe7\x9a\x84' = 的 ← one more merge completes the charactermerge 2: (229, 173) -> 257 b'\xe5\xad'merge 6: (257, 151) -> 261 b'\xe5\xad\x97' = 字merge 9: (136, 229) -> 264 b'\x88\xe5' ← a fragment across two characters
bytes 1,227 → tokens 1,011, compression 1.21x的 is E7 9A 84 and takes two merges to become one token: first its first two bytes, then that result with the
third.
Merge 9 is more interesting still: 88 is the last byte of 合 (E5 90 88), and E5 the first byte of 并
(E5 B9 B6). Of this pair’s 18 occurrences, 15 come from 合并 and 3 from 先合 — two unrelated pairs of characters,
counted as one pair because the bytes at the join are the same. BPE sees only bytes and has no idea where a
character begins or ends.
“Is it the same pair?” depends only on the IDs
At the start an ID is a byte value; after merging, IDs are still what gets compared, and every new ID stands for a fixed string of bytes — so ultimately it is byte sequences being compared. The tokenizer knows nothing of letters, Hanzi, words, case or meaning. Three consequences:
- Looks the same, different bytes, counts as different: case (
E= 69,e= 101), a leading space or not; several ways of writing one character —écan be the single code pointC3 A9oreplus a combining accent65 CC 81, identical on screen; full-widthABC(EF BC A1 …) against half-widthABC(41 42 43) likewise. Some tokenizers unify these before BPE, which is Unicode normalization (NFC, NFKC); GPT’s tokenizers don’t, sentencepiece does by default - Different characters sharing bytes get counted together: 你
E4 BD A0, 住E4 BD 8F, 位E4 BD 8D, 体E4 BD 93, 作E4 BD 9C, 低E4 BD 8Eall begin withE4 BD. The count for(E4, BD)is theirs added up, more than any one whole character — so it tends to be merged early, and each character’s remaining byte is left over as a separate fragment - Fragments spanning two characters: merge 9 above
So a Hanzi’s 3 bytes being cut apart is routine: common characters learned the full merge and are 1 token; less common ones merged only partly; rare ones are three separate bytes. For the model itself this is no error — it only sees IDs, and learns from data that “these two pieces together are such-and-such a character”. The trouble comes when turning output back into text:
b = "你".encode("utf-8") # b'\xe4\xbd\xa0'b[:2].decode("utf-8", errors="replace") # '�' with only the first two bytes inb.decode("utf-8") # '你' once the third has arrivedA model emits tokens piece by piece, and after the first two bytes nothing forms a character yet — streaming output
has to hold bytes back until a character is complete before showing it, or part 27’s � flashes on screen.
decode and encode
The tokenizer is a stage of its own
tokenizer training data ──BPE training──→ merges table (fixed from then on) │ text ──encode──→ IDs ──→ model (training, generation) text ←──decode── IDs ←──┘- The tokenizer has its own training data, which can differ from the model’s. It is trained once and then fixed
- The model sees nothing but IDs from start to finish: before training, all training data is encoded into one long string of IDs; the model generates IDs, which must be decoded to be read
- The mix of languages in the tokenizer’s training data matters: the more of a language in it, the more merges that language learns and the fewer tokens it cuts into. That is one of the reasons the same Chinese can differ by multiples in token count between tokenizers
decode: build the vocab in training order
def build_vocab(merges): vocab = {idx: bytes([idx]) for idx in range(256)} # 0—255: each ID is one byte for (p0, p1), idx in merges.items(): # in training order vocab[idx] = vocab[p0] + vocab[p1] # new ID = the bytes of two old IDs joined return vocab
def decode(ids, vocab): b = b"".join(vocab[idx] for idx in ids) # IDs → bytes, one long string return b.decode("utf-8", errors="replace") # bytes → text- Why training order is required: a new ID may be built from an earlier new ID — 273 = 32 + 257, so 257 has to
exist before 273 can be computed. Python dicts keep insertion order, so iterating
merges.items()is exactly the training order - Why
errors="replace": not every string of IDs forms valid text
bytes([128]).decode("utf-8") # error: can't decode byte 0x80 in position 0: invalid start bytedecode([128], vocab) # '�'128 = 10000000, and a byte opening with 10 is a continuation byte that can never start a character (part 27). A
model may emit any combination of IDs while generating, including ones like this; with replace the program does not
crash, and whatever cannot form a character becomes �.
encode: take the pair learned earliest, every step
def encode(text, merges): ids = list(text.encode("utf-8")) # text → bytes while len(ids) >= 2: stats = get_stats(ids) pair = min(stats, key=lambda p: merges.get(p, float("inf"))) # the pair learned earliest if pair not in merges: # none of the remaining pairs are in the table break ids = merge(ids, pair, merges[pair]) return idsThe crux is which pair each step picks: not whichever pair is most frequent in this new text, but, among all the current adjacent pairs, the one with the smallest number in the merges table — the one learned earliest in training. Later merges are built on earlier ones, so encoding has to replay them in training order.
merges.get(p, float("inf")): a pair not in the table counts as infinity and is never chosen; if even the chosen pair is missing from the table, nothing can merge any morewhile len(ids) >= 2: an empty string or a single byte has no adjacent pairs, and without the checkminwould raise on an empty dict
Encoding the thin with the twenty rules learned from Shakespeare:
start [116, 104, 101, 32, 116, 104, 105, 110] t h e ␣ t h i nmerge (101, 32) → 256 [116, 104, 256, 116, 104, 105, 110] 'e ' is rule 1, merged firstmerge (116, 104) → 257 [257, 256, 257, 105, 110] 'th', both places at oncemerge (105, 110) → 264 [257, 256, 257, 264] 'in'nothing left to merge → [257, 256, 257, 264] = 'th' 'e ' 'th' 'in'❗ ’ th’ is in the table and goes unused
Rule 18, th (273), is in the table, yet cutting the thin never uses it: rule 1, 'e ', first merged the space
with the e before it, and the second th no longer has a space of its own in front.
🔑 The order decides the result. The same table applied in a different order gives a different string of IDs — and the model learned from the split made in training, so if the split changes at encode time, what it sees is a string of unfamiliar IDs. Which is why encode has to replay the table strictly in order.
The round trip
decode(encode(s)) == s always holds. The first 5000 characters of Shakespeare give True; so does
“Hello, 世界!分词器没见过中文也能处理。👋”, which this tokenizer has never seen — no merges were learned for the Chinese
or the emoji, so they stay as raw bytes, and since 256 bytes can represent any text the round trip always works.
encode("the other", merges) # [257, 256, 111, 257, 263] 'er' uses rule 8encode("你好", merges) # [228, 189, 160, 229, 165, 189] 6 raw bytes, not one Chinese mergeThat 你好 line is what GPT-2 does with Chinese: rules learned from English find almost nothing to apply to, only on a
much smaller scale.
The reverse does not always hold: encode(decode(ids)) need not equal ids — [128] decodes to �, which does
not encode back to 128.
The code in this part adds up to a few dozen lines: get_stats counts adjacent pairs, merge replaces a pair, the
training loop records the order of merges, decode looks things up, encode replays in order.
A merge table in order is the entire tokenizer.
🔮 GPT-2’s real tokenizer has one more step: a regular expression first cuts the text into chunks (words, numbers, punctuation, spaces), and merging happens only inside each chunk, never across. So the
'e 'above would never appear in GPT-2 — it attaches the space to the following word, cuttingthe eggintotheandegg. That rule, OpenAI’s tiktoken and special tokens are the next section (1.3); sentencepiece and a quirk-by-quirk explanation are 1.4.