Byte-level BPE, merge by merge

Start from the 256 byte values, then keep gluing the most frequent adjacent pair into a new token. Nothing is ever unknown, and common fragments become single tokens.

The character tokenizer had two problems it just couldn't fix, unknown characters, and one position per character. Byte pair encoding fixes the first one by starting from bytes instead of characters, and the second one by learning which runs of bytes show up together often enough to deserve their own token.

This is the tokenizer every octlm model since Day 1 has used, and the Day 4 model uses it at 8,192 entries.

Step 1

Bytes, an alphabet with no unknowns

Every string is stored as UTF-8 bytes, and a byte has 256 possible values. So if the base vocabulary is those 256 values, every possible text has an encoding. An emoji is 4 bytes, so it becomes 4 byte tokens. A Japanese character is 3 bytes. Nothing maps to "unknown", so there's no unknown token at all.

octlm's BPE vocabulary starts like this:

IDsMeaning
0 to 255One raw byte each
256<|pad|>
257<|bos|>
258<|eos|>
259<|tool_call|>, reserved for the tool-use work on Day 7
260<|tool_result|>, reserved for the same
261 onwardLearned merges, in the order they were learned

The two tool tokens are reserved now so their IDs never move. If you change a special-token ID after training, you silently change what a checkpoint means, and nothing will warn you.

The cost of bytes is length. Plain bytes make sequences even longer than characters for non-ASCII text. That's what merges fix.

Playground
CharacterCode pointBytesByte IDsBits
aU+006119701100001
éU+00E92195 16911000011 10101001
€U+20AC3226 130 17211100010 10000010 10101100
😀U+1F6004240 159 152 12811110000 10011111 10011000 10000000

The orange bits are UTF-8's markers. A lead byte starting 0 is a whole ASCII character. A lead byte starting 110, 1110 or 11110 says 2, 3 or 4 bytes follow in total, and every continuation byte starts 10. The gray bits carry the code point. Byte-level BPE starts from these 256 byte values, so any character, even one never seen in training, has an encoding.

Type any characters. Each one becomes 1 to 4 bytes, and each byte is one of the 256 base token IDs.
Step 2

Pre-tokenization decides where merges can't cross

Before learning merges, octlm cuts the text into chunks at every change of character type. There are four types, whitespace, word characters (letters, combining marks and _), digits, and everything else.

Playground
spacewordnumbersymbol

Chunk def is 3 characters and 3 UTF-8 bytes: 64 65 66. BPE starts from these bytes and never merges across a chunk edge.

Click or hover a chunk to see its bytes. Colors show the character type of each run. BPE only ever merges bytes inside one chunk.

So what happened ?

Why cut at all ? Without boundaries, the most frequent pairs in English include a letter followed by a space or a period, so BPE would learn tokens like e. and d that mix a word with punctuation. The GPT-2 tokenizer cuts at similar boundaries for the same reason. Chunks also keep each document separate, octlm never merges across two documents.

octlm/tokenizer.pyline 34
def _kind(character: str) -> int:
    if character.isspace():
        return 0
    category = unicodedata.category(character)
    if character == "_" or category[0] in {"L", "M"}:
        return 1
    if category[0] == "N":
        return 2
    return 3
Step 3

Training glues the most frequent pair, then repeats

Training starts with each chunk as a list of byte IDs, and counts each distinct chunk once with its frequency. Then it loops:

  1. Count every adjacent pair of IDs inside every chunk, weighted by how often that chunk occurs.
  2. Pick the pair with the highest count. On a tie, pick the one with the smallest left ID, then the smallest right ID.
  3. Give the pair the next free ID, starting at 261.
  4. Replace every occurrence of the pair with the new ID.
  5. Stop when the vocabulary hits its target size, or when the best pair occurs fewer than 2 times.

The tie rule matters more than it looks. Two runs over the same text have to learn the same merges in the same order, otherwise two tokenizers with the same settings would disagree on what ID 400 means. Picking by (-count, pair) makes the order total, so the result never depends on dictionary iteration order.

The stop rule matters too. A pair that shows up once teaches the tokenizer nothing it could reuse, so a merge for it just wastes a vocabulary slot.

Playground
Merge 0 of 30 learnable
Most frequent adjacent pairs now
PairIDsCount
␠ + ␠32 + 3248
a + l97 + 10827
o + t111 + 11615
t + a116 + 9715
t + o116 + 11115
\n + ␠10 + 3212
l + u108 + 11712
u + e117 + 10112

Next merge: 32 + 32 becomes ID 261.

Merges learned
#RuleNew token
def␠total_number(values):\n␠␠␠␠total␠=␠0\n␠␠␠␠for␠value␠in␠values:\n␠␠␠␠␠␠␠␠total␠=␠total␠+␠value\n␠␠␠␠return␠total\ndef␠tota…
UTF-8 bytes336
Tokens now336
Bytes per token1.00
Vocabulary261256 bytes + 5 specials + merges
Step through the merges on the text below, or paste your own. The table on the left is the pair count that decides the next merge. This is the text the parity test trains on, and the test checks that this page learns the same merges as octlm's Python code.

So what happened ?

Here's the actual training loop:

octlm/tokenizer.pyline 148
@classmethod
def train(
    cls, texts: Iterable[str], vocab_size: int, min_frequency: int = 2
) -> ByteBPETokenizer:
    documents = tuple(texts)
    base_size = 256 + len(BPE_SPECIALS)
    if vocab_size < base_size or min_frequency < 1:
        raise ValueError(f"vocab_size must be at least {base_size}")
    sequences = Counter(tuple(chunk) for text in documents for chunk in pretokenize(text))
    merges: list[tuple[int, int, int]] = []
    while base_size + len(merges) < vocab_size:
        counts: Counter[tuple[int, int]] = Counter()
        for sequence, frequency in sequences.items():
            counts.update({pair: count * frequency for pair, count in _pairs(sequence).items()})
        if not counts:
            break
        pair = min(counts, key=lambda item: (-counts[item], item))
        if counts[pair] < min_frequency:
            break
        token_id = base_size + len(merges)
        merged: Counter[tuple[int, ...]] = Counter()
        for sequence, frequency in sequences.items():
            merged[_merge(sequence, pair, token_id)] += frequency
        sequences = merged
        merges.append((*pair, token_id))
    corpus_hash = _document_hash(documents)
    return cls(tuple(merges), corpus_hash, vocab_size, min_frequency)

min(counts, key=lambda item: (-counts[item], item)) is the tie rule in one line. The loop recounts every pair after every merge. That's simple to check and slow at scale, which comes back below.

Step 4

Encoding replays the merges in the order they were learned

To encode new text, octlm pre-tokenizes it, turns each chunk into bytes, and then applies merges by rank. Among the pairs present in the chunk, it merges the one learned earliest, and keeps going until no pair in the chunk has a merge.

octlm/tokenizer.pyline 207
def encode_chunk(self, chunk: bytes) -> tuple[int, ...]:
    ranks = self.merge_ranks
    sequence = tuple(chunk)
    while len(sequence) > 1:
        candidates = (pair for pair in _pairs(sequence) if pair in ranks)
        pair = min(candidates, key=lambda item: ranks[item][0], default=None)
        if pair is None:
            break
        sequence = _merge(sequence, pair, ranks[pair][1])
    return sequence

Rank order is what makes encoding match training. The merges were learned in that order, so applying them in that order rebuilds the same tokens the training text produced. Decoding is simpler, every ID maps to a fixed byte string and you just concatenate the bytes.

The paper exercise

Twenty merges traced by hand

Day 1's reading plan asked for 20 merges traced on a Python function. The note has the first 20 on one repeated function, using the same code path. IDs start at 261 because 256 to 260 are specials.

MergeInput IDsNew IDBytes after the merge
132 + 32261two spaces
297 + 108262al
395 + 110263_n
498 + 101264be
5109 + 264265mbe
6117 + 265266umbe
7263 + 266267_numbe
8267 + 114268_number
9105 + 110269in
10111 + 116270ot
11116 + 270271tot
12269 + 116272int
13271 + 262273total
1410 + 261274newline and two spaces
1595 + 118275_v
1698 + 273276btotal
1799 + 111277co
18100 + 268278d_number
19101 + 277279eco
20102 + 105280fi

Merge 5 is 109 + 264, byte m plus token 264, which is be from merge 4. Merges 6 to 8 grow that into _number. Merge 16 creates btotal, a piece that spans the end of one name and the start of another inside the same chunk. The table is parsed from notes/day1.md when this site builds.

EXP-004

What we measured

Problem, the character tokenizer gave one token per byte on English and 77.8 percent unknowns on Unicode.

Hypothesis, a byte-level BPE trained on the same PLAN.md compresses the held-out text and represents every sample byte.

Decision, keep the 1,024-entry tokenizer for the Day 1 model. The note is clear that this didn't choose the final vocabulary. Day 2 trained a 2,048-entry tokenizer on a bigger corpus, and Day 4 an 8,192-entry one on TinyStories.

The cost

A full recount per merge

Recounting every pair after every merge costs time proportional to the number of distinct chunks times the number of merges. On Day 1's 111 KB that took seconds. Day 2 measured it before training on 7 MB, 2.7 seconds for 142 KB at 512 entries, and 57 seconds for 702 KB at 2,048 entries. The cost tracked the 11,665 distinct chunks in that sample, not the raw bytes. So Day 2 trained on every tenth document. Day 4 trained on the first 10 million characters of TinyStories, which took 303 seconds, and then added a chunk cache to make encoding fast.

Skipped

What we didn't build, and why