In the previous post we talked about subword tokenization and using Byte Pair Encoding (BPE) to train on a large corpus of data. Don’t get confused by the word “training” here — it’s not training a neural network. You’re running an algorithm that produces a vocabulary and a set of merge rules, which a BPE tokenizer then uses to encode and decode text before it ever reaches the transformer.
Contents
- Input
- Output
- Starting the Implementation
- GPT-2 Regex
- Setup: Imports and Constants
- Step 1: Finding Chunk Boundaries
- Step 2: Pre-tokenizing a Single Chunk
- Step 3: Parallel Pre-tokenization
- Step 4: Counting Pair Frequencies
- Step 5: Merging the Top Pair
- Putting It All Together:
train_bpe - Running the Training
- The Output: Vocabulary and Merge Rules
- Sanity Checks Worth Running
- What’s Next
- References
Before jumping into the algorithm, let’s clarify BPE’s inputs and outputs.
Input
Imagine the vast range of languages and vocabulary GPT has to handle. BPE training takes a large corpus of raw text as input. For this walkthrough I’ll be using OpenWebText and TinyStories combined — about 15 GB of raw text.
Output
BPE produces two things: a fixed vocabulary (a dictionary of tokens the model knows) and an ordered list of merge rules. GPT-2 has ~50k tokens; GPT-4 uses ~100k. I’ll explain what merge rules mean as we get into the algorithm. Later, when we build and train the transformer, every sentence in the corpus will be broken down using this vocabulary.
Question: What is the scale of the data GPT/Claude models actually train on?
Petabytes. Here we’re working with a few GB — enough to understand and implement the same algorithm that powers tiktoken (the tokenizer behind GPT-2/4), just at a smaller scale.
Starting the implementation
My machine is a Mac M3 with 8 GB of RAM. The training data is 15 GB, so reading the entire corpus into memory at once isn’t an option. The solution is pre-tokenization.
Here’s the full pipeline at a glance — each section below walks through one of these boxes:
Pre-tokenization
Read the data in parallel chunks (a few MB at a time) using multiprocessing, apply the GPT-2 regex to split each chunk into pieces, and count the frequency of each resulting word piece across the full corpus.
GPT-2 Regex
The GPT-2 regex is a pre-tokenization pattern. Before BPE sees any text, this regex splits it into chunks so that BPE merges never cross certain boundaries — like between a word and its punctuation, or between two different words.
Here’s the pattern:
import regex as re
GPT2_SPLIT_PATTERN = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
It’s an alternation — Python’s re.findall returns the first branch that matches at each position. Breaking it apart:
| Branch | Matches | Example |
|---|---|---|
'(?:[sdmt]\|ll\|ve\|re) | English contractions after the apostrophe | 's, 't, 're, 've, 'm, 'll, 'd |
' ?\p{L}+' | An optional leading space followed by one or more letters | ' Hello', 'world' |
' ?\p{N}+' | An optional leading space followed by one or more digits | ' 42', '2024' |
' ?[^\s\p{L}\p{N}]+' | An optional leading space followed by punctuation/symbols | '.', ', ', '!!' |
'\s+(?!\S)' | Whitespace that is not immediately followed by a non-whitespace character (trailing whitespace) | spaces at end of line |
'\s+' | Any remaining whitespace | newlines, tabs |
Why does this matter?
Without pre-tokenization, BPE is free to merge across any character boundary. That leads to unwanted tokens like " dog." (space + word + punctuation as a single unit), or merges that bleed across word boundaries. The regex enforces three key constraints:
- Contractions stay intact.
"don't"splits into["don", "'t"], not["do", "n't"]or something worse. - Punctuation is always separate from words.
"hello,"→["hello", ","]. BPE will never create a token that fuses letters and punctuation. - Numbers are isolated. Digits form their own chunks and won’t merge with letters.
The leading-space design ( ?\p{L}+) is deliberate: in most text, words appear with a preceding space ( Hello), and by including that space inside the token, the tokenizer can distinguish "Hello" at the start of a sentence from " Hello" in the middle — without a special whitespace token.
A concrete example:
Input: "don't run 3 times."
After GPT-2 regex split:
["don", "'t", " run", " 3", " times", "."]
BPE then trains on these pieces, never merging across them.
This is why the pre-tokenization step happens before counting frequencies: you count the frequency of each regex-produced chunk, and BPE operates within those chunks only.
Setup: Imports and Constants
import os
import time
from typing import BinaryIO
from concurrent.futures import ProcessPoolExecutor, as_completed
from collections import Counter
import regex as re
from tqdm import tqdm
import pickle
PAT = r"""'(?:[sdmt]|ll|ve|re)| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"""
COMPILED_PAT = re.compile(PAT)
Step 1: Finding Chunk Boundaries
I cannot read 15 GB into 8 GB of RAM, so the file has to be processed in pieces — and those pieces have to be handed to separate processes. The naive approach is to divide the file size by the number of chunks and cut at those byte offsets, but a blind cut lands in the middle of a document, and often in the middle of a UTF-8 character.
So the boundaries are only a starting guess. From each guessed offset the function reads forward 4 KB at a time until it finds the next <|endoftext|> marker, and cuts there instead. Two things fall out of this: every chunk contains whole documents, and duplicate boundaries (which happen when two guesses land inside the same document) collapse away because the result is passed through sorted(set(...)).
def find_chunk_boundaries(
file: BinaryIO, desired_num_chunks: int, split_special_token: bytes
) -> list[int]:
assert isinstance(split_special_token, bytes)
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
chunk_size = file_size // desired_num_chunks
chunk_boundaries = [i * chunk_size for i in range(desired_num_chunks + 1)]
chunk_boundaries[-1] = file_size
mini_chunk_size = 4096
for bi in range(1, len(chunk_boundaries) - 1):
initial_position = chunk_boundaries[bi]
file.seek(initial_position)
while True:
mini_chunk = file.read(mini_chunk_size)
if mini_chunk == b"":
chunk_boundaries[bi] = file_size
break
found_at = mini_chunk.find(split_special_token)
if found_at != -1:
chunk_boundaries[bi] = initial_position + found_at
break
initial_position += mini_chunk_size
return sorted(set(chunk_boundaries))
Note that this returns offsets, not data. Nothing large is in memory yet — each worker seeks to its own (start, end) and reads only its slice.
Step 2: Pre-tokenizing a Single Chunk
This is the function each worker runs, and it does four things in order: read the byte range and decode it, remove the special tokens, apply the GPT-2 regex to what’s left, and count the pieces.
Removing special tokens first is not cosmetic. <|endoftext|> is a control token — it must exist in the vocabulary as one indivisible unit, and BPE must never learn merges that reach across it or chew it into fragments. Splitting the text on those markers guarantees BPE never sees them. The split pattern is built longest-first and each token is passed through re.escape, so that a token like <|endoftext|> is matched before any shorter token that happens to be a prefix of it, and regex metacharacters like | are treated as literal text.
What comes back is a Counter of word pieces for this chunk only — a few thousand distinct keys instead of megabytes of text.
def pre_tokenize_text(file_path, start, end, special_tokens):
with open(file_path, "rb") as f:
f.seek(start)
chunk = f.read(end - start).decode("utf-8", errors="ignore")
# split out special tokens so they never reach BPE; longest-first + escaped
if special_tokens:
split_pat = "|".join(
re.escape(s) for s in sorted(special_tokens, key=len, reverse=True)
)
segments = re.split(split_pat, chunk)
else:
segments = [chunk]
counts = Counter()
for seg in segments:
counts.update(COMPILED_PAT.findall(seg))
return counts
Step 3: Parallel Pre-tokenization
Now the two previous steps get wired together. find_chunk_boundaries produces 64 ranges, each range is submitted to a ProcessPoolExecutor, and every completed future returns a small Counter that the parent process folds into one table with counts_dict.update(...).
Three details are worth calling out:
max_workers=3is a memory decision, not a CPU one. Each worker holds its own chunk plus its own counter; on an 8 GB machine three concurrent slices is the comfortable ceiling.as_completedmeans results are merged the moment any worker finishes, so no chunk’s counter sits around waiting for a slower neighbour.- The counters shrink the data enormously. 15 GB of text collapses into roughly a million unique word pieces with counts, which fits in memory easily — and that table, not the corpus, is what BPE actually trains on.
def _get_pretokenized_word_count(input_path, special_tokens) -> dict[str, int]:
start_time = time.time()
with open(input_path, "rb") as f:
boundaries = find_chunk_boundaries(f, 64, b"<|endoftext|>")
num_chunks = len(boundaries) - 1
print(f"Processing {num_chunks} chunks...")
counts_dict = Counter()
with ProcessPoolExecutor(max_workers=3) as pe:
futures = {
pe.submit(pre_tokenize_text, input_path, start, end, special_tokens): i
for i, (start, end) in enumerate(zip(boundaries[:-1], boundaries[1:]))
}
with tqdm(total=num_chunks, desc="Tokenizing", unit="chunk") as pbar:
for future in as_completed(futures):
chunk_idx = futures[future]
counts_dict.update(future.result())
pbar.set_postfix(
{
"chunk": chunk_idx,
"unique_tokens": len(counts_dict),
"elapsed": f"{time.time() - start_time:.1f}s",
}
)
pbar.update(1)
return counts_dict
Pre-tokenization ends here. Everything from this point on is the BPE algorithm itself.
Step 4: Counting Pair Frequencies
Each word piece is encoded to UTF-8 and treated as a list of single-byte tokens — this is why the base vocabulary is 256 entries: any text in any language is representable, so there is no such thing as an unknown character.
For every word, every adjacent pair of tokens is counted weighted by the word’s frequency. If ' low' appears 5 times, the pair (b'l', b'o') gets +5, not +1. The corpus is never scanned again after this; from here on BPE only ever reads and updates this table.
The second return value is the part that makes the whole thing tractable: pair_words is a reverse index from a pair to the set of words containing it. When a pair wins and gets merged, only those words need rewriting — a few thousand instead of a million.
def _pair_frequency(
pretokenized_word_count: dict[str, int],
) -> tuple[dict[tuple[bytes, bytes], int], dict[tuple[bytes, bytes], set[str]]]:
byte_pair_count: dict[tuple[bytes, bytes], int] = {}
pair_words: dict[tuple[bytes, bytes], set[str]] = {}
for word, count in pretokenized_word_count.items():
word_bytes: bytes = word.encode("utf-8")
for i in range(len(word_bytes) - 1):
pair = (word_bytes[i : i + 1], word_bytes[i + 1 : i + 2])
byte_pair_count[pair] = byte_pair_count.get(pair, 0) + count
pair_words.setdefault(pair, set()).add(word)
return byte_pair_count, pair_words
Step 5: Merging the Top Pair
This is the heart of the algorithm, and the only function where a subtle bug can quietly corrupt the vocabulary.
Given the winning pair, it looks up the affected words in pair_words and, for each one, rewrites the token list greedily, left to right, without overlaps — so b'aaa' with the pair (a, a) becomes [aa, a], exactly as tiktoken behaves.
Then the pair counts have to be repaired, and the order of operations matters:
- Pass 1 is arithmetic only. Subtract the word’s frequency from every pair in the old token list, add it to every pair in the new list. No key is deleted here, even if it momentarily reaches zero — the same pair may be added back a line later by a different position in the same word.
- Pass 2 runs once the counts are final. The reverse index is updated (discard the word from pairs it no longer has, add it to the new ones), and only then are pairs whose count fell to zero or below popped from both dictionaries.
If you evict during pass 1, you delete pairs that were about to come back, and later merges silently pick the wrong winner.
def _merge_top_pair(
best_pair: tuple[bytes, bytes],
byte_pair_frequency: dict[tuple[bytes, bytes], int],
word_frequency: dict[str, int],
word_token: dict[str, list[bytes]],
pair_words: dict[tuple[bytes, bytes], set[str]],
) -> dict[tuple[bytes, bytes], int]:
new_token = best_pair[0] + best_pair[1]
affected_words = list(pair_words.get(best_pair, set()))
for word in affected_words:
old_tokens = word_token[word]
freq = word_frequency[word]
# rewrite this word's token list (greedy, left-to-right, non-overlapping)
new_tokens: list[bytes] = []
i = 0
while i < len(old_tokens):
if (
i + 1 < len(old_tokens)
and old_tokens[i] == best_pair[0]
and old_tokens[i + 1] == best_pair[1]
):
new_tokens.append(new_token)
i += 2
else:
new_tokens.append(old_tokens[i])
i += 1
# PASS 1: arithmetic only -- subtract old pairs, add new pairs. No eviction yet.
touched = set()
for j in range(len(old_tokens) - 1):
p = (old_tokens[j], old_tokens[j + 1])
byte_pair_frequency[p] = byte_pair_frequency.get(p, 0) - freq
touched.add(p)
for j in range(len(new_tokens) - 1):
p = (new_tokens[j], new_tokens[j + 1])
byte_pair_frequency[p] = byte_pair_frequency.get(p, 0) + freq
touched.add(p)
# PASS 2: counts are final now -- fix reverse index, then evict zeroed pairs.
old_pairs = {(old_tokens[j], old_tokens[j + 1]) for j in range(len(old_tokens) - 1)}
new_pairs = {(new_tokens[j], new_tokens[j + 1]) for j in range(len(new_tokens) - 1)}
for p in old_pairs - new_pairs:
s = pair_words.get(p)
if s is not None:
s.discard(word)
if not s:
pair_words.pop(p, None)
for p in new_pairs - old_pairs:
pair_words.setdefault(p, set()).add(word)
for p in touched:
if byte_pair_frequency.get(p, 0) <= 0:
byte_pair_frequency.pop(p, None)
pair_words.pop(p, None)
word_token[word] = new_tokens
byte_pair_frequency.pop(best_pair, None)
pair_words.pop(best_pair, None)
return byte_pair_frequency
Putting It All Together: train_bpe
The driver is short because the work is in the pieces above. It seeds the vocabulary with the 256 byte values, appends the special tokens, builds the word counts and the pair table, and then loops.
Each iteration does four things: pick the highest-count pair, add the merged token to the vocabulary, append the pair to merge_list, and call _merge_top_pair to update the counts. The loop ends when the vocabulary reaches vocab_size (or when there are no pairs left, which happens on tiny corpora).
Two details:
max(byte_pair_frequency, key=lambda k: (byte_pair_frequency[k], k))breaks count ties by the pair itself. Ties are common, and without a deterministic rule two runs on the same data would produce different vocabularies.merge_listis ordered, and the order is the rule. Merge 4 may depend on the token merge 1 created, so at encode time the merges must be replayed in exactly this sequence.
def train_bpe(
input_path: str, vocab_size: int, special_tokens: list[str]
) -> tuple[dict[int, bytes], list[tuple[bytes, bytes]]]:
vocabulary = {}
for i in range(256):
vocabulary[i] = bytes([i])
for token in special_tokens:
vocabulary[len(vocabulary)] = token.encode("UTF-8")
word_frequency = _get_pretokenized_word_count(
input_path=input_path, special_tokens=special_tokens
)
word_token = {}
for key, value in word_frequency.items():
word_token[key] = [bytes([b]) for b in key.encode("utf-8")]
byte_pair_frequency, byte_pair_words = _pair_frequency(word_frequency)
merge_list = []
while len(vocabulary) < vocab_size:
if not byte_pair_frequency:
break
highest_frequency = max(
byte_pair_frequency, key=lambda k: (byte_pair_frequency[k], k)
)
new_token = highest_frequency[0] + highest_frequency[1]
vocabulary[len(vocabulary)] = new_token
merge_list.append(highest_frequency)
byte_pair_frequency = _merge_top_pair(
highest_frequency, byte_pair_frequency, word_frequency, word_token, byte_pair_words
)
return vocabulary, merge_list
Running the Training
if __name__ == "__main__":
input_path = "data/owt_train.txt"
vocab_size = 32000 # CS336's OWT target
special_tokens = ["<|endoftext|>"]
out_dir = "artifacts"
os.makedirs(out_dir, exist_ok=True)
start_time = time.time()
vocab, merges = train_bpe(
input_path=input_path,
vocab_size=vocab_size,
special_tokens=special_tokens,
)
elapsed = time.time() - start_time
print(f"Done: {len(vocab)} tokens, {len(merges)} merges in {elapsed / 60:.1f} min")
vocab_path = os.path.join(out_dir, "owt_vocab.pkl")
merges_path = os.path.join(out_dir, "owt_merges.pkl")
with open(vocab_path, "wb") as f:
pickle.dump(vocab, f)
with open(merges_path, "wb") as f:
pickle.dump(merges, f)
print(f"Saved vocab -> {vocab_path}")
print(f"Saved merges -> {merges_path}")
The Output: Vocabulary and Merge Rules
Two pickled files, and that’s the entire tokenizer. owt_vocab.pkl is a dict[int, bytes] — ids 0–255 are the raw byte values, then the special tokens, then one entry per merge in the order they were learned. owt_merges.pkl is the ordered list[(bytes, bytes)].
import pickle
with open("artifacts/owt_vocab.pkl", "rb") as f:
vocab = pickle.load(f)
with open("artifacts/owt_merges.pkl", "rb") as f:
merges = pickle.load(f)
print(len(vocab), len(merges))
# 32000 31743
# the base bytes
print({i: vocab[i] for i in (0, 65, 97, 255)})
# {0: b'\x00', 65: b'A', 97: b'a', 255: b'\xff'}
# the special token, right after the bytes
print(vocab[256])
# b'<|endoftext|>'
# the first merges BPE learned, and what they became
for i, pair in enumerate(merges[:8]):
print(i + 1, pair, "->", pair[0] + pair[1])
# 1 (b'e', b' ') -> b'e '
# 2 (b' ', b't') -> b' t'
# 3 (b'h', b'e') -> b'he'
# 4 (b' t', b'he') -> b' the'
# 5 (b'i', b'n') -> b'in'
# 6 (b'e', b'r') -> b'er'
# 7 (b'a', b'n') -> b'an'
# 8 (b' ', b'a') -> b' a'
# the longest tokens are whole common words, with their leading space
longest = sorted(vocab.values(), key=len, reverse=True)[:5]
print(longest)
# [b' unfortunately', b' international', b' administration', b' organization', b' Nevertheless']
Notice merge 4: (b' t', b'he') only exists because merges 2 and 3 already created b' t' and b'he'. That dependency is why the list is ordered and why encoding replays it from the top.
Also notice what BPE spent its first merges on — spaces, the, in, er. Nothing here was designed; the ranking came entirely out of the corpus counts.
Sanity checks worth running
Before trusting a tokenizer, three checks catch almost every implementation bug:
- Round-trip.
decode(encode(text)) == textfor a few thousand random documents, including emoji and non-Latin scripts. Because the base vocabulary is all 256 bytes, this must hold exactly. - Determinism. Train twice on the same file and diff the merge lists. Any difference means a tie is being broken non-deterministically.
- Compression ratio. Bytes divided by tokens on held-out text. For a 32k vocabulary on English, roughly 4 bytes per token is the expected neighbourhood — much lower and the merges aren’t being applied; much higher and the vocabulary is overfitting to the corpus.
What’s next
We now have a vocabulary and an ordered list of merges — everything needed to turn text into token IDs. In Part 3 I’ll implement the encoder and decoder on top of these two files, run the full corpus through them, and store the result as a flat array of IDs ready for the transformer.
Acknowledgments: Grammar and prose lightly edited with Claude (Anthropic) assistance.
References
- Hashimoto, T. & Liang, P. (2026). CS336 LLM from Scratch. Stanford University, Spring 2026.
- Karpathy, A. (2023). Let’s build the GPT Tokenizer. YouTube. — Hands-on walkthrough of BPE from scratch.
- Sennrich, R., Haddow, B., & Birch, A. (2016). Neural Machine Translation of Rare Words with Subword Units. ACL 2016. — The original BPE paper applied to NLP.
- Radford, A. et al. (2019). Language Models are Unsupervised Multitask Learners. OpenAI. — GPT-2 paper, source of the pre-tokenization regex.