1Day 1

Byte-level BPE, merge by merge

Start from the 256 byte values, then repeatedly glue 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 could not fix: unknown characters, and one position per character. Byte pair encoding fixes the first by starting from bytes instead of characters, and the second by learning which runs of bytes appear together often enough to deserve their own token.

This is the tokenizer every octlm model since Day 1 has used, and the one the Day 4 model uses 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. If the base vocabulary is those 256 values, every possible text has an encoding. An emoji is 4 bytes and becomes 4 byte tokens. A Japanese character is 3 bytes. Nothing maps to "unknown", so there is no unknown token.

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. Changing a special-token ID after training would silently change what a checkpoint means.

The cost of bytes is length. Plain bytes make sequences even longer than characters for non-ASCII text. Merges fix that.

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 may not 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.

What happened?

Why cut at all? Without boundaries, the most frequent pairs in English include a letter followed by a space or a period, and 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 pair 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 reaches 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 must learn the same merges in the same order, or two tokenizers with the same settings would disagree about 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 occurs once teaches the tokenizer nothing it could reuse, and a merge for it would waste 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.

What happened?

Here is 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 is 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 repeats 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 the bytes concatenate.

The paper exercise

Twenty merges traced by hand

Day 1's reading plan asked for 20 merges traced on a Python function. The note records the first 20 on one function repeated, 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 produced 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 explicit that this did not choose the final vocabulary. Day 2 trained a 2,048-entry tokenizer on a larger 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, 57 seconds for 702 KB at 2,048 entries. The cost tracked the 11,665 distinct chunks in that sample, not the raw bytes. Day 2 therefore 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 did not build, and why