PidokuInfra

A Tokenizer

Expert Advanced 55 min Difficulty 3/5 Topic 04 of 09

Prerequisites II.02, II.03

The idea in one minute#

A model consumes integers, not text. A tokenizer maps text to a sequence of token IDs and back. Modern LLM tokenizers use byte-pair encoding (BPE): start with the 256 possible bytes as the vocabulary, then repeatedly find the most frequent adjacent pair in a training corpus and add it as a new token. After tens of thousands of merges, common words are one token and rare ones are a few pieces.

Because the base vocabulary is all 256 bytes, any input — any language, emoji, binary junk — can be encoded, and decoding always returns exactly the original bytes.

An analogy#

Shorthand. A stenographer starts by writing every letter. They notice “t” and “h” always come together and invent one stroke for “th”. Then “th” and “e” — one stroke for “the”. Keep going and frequent words become a single mark, while an unusual name is still spelled out from smaller pieces. Nothing is ever unwritable.

A picture#

flowchart TB
  TXT["'low lower lowest'"] --> BYTES["bytes: l o w _ l o w e r _ l o w e s t"]
  BYTES --> COUNT["count adjacent pairs"]
  COUNT --> BEST["most frequent: (l, o) x3"]
  BEST --> MERGE["new token 256 = 'lo'<br/>replace every (l, o)"]
  MERGE -->|"repeat for N merges"| COUNT
  MERGE --> VOCAB[("vocabulary: 256 bytes + learned merges<br/>256='lo', 257='low', 258='low_' ...")]
  VOCAB --> ENC["encode: apply merges in the order learned"]
  VOCAB --> DEC["decode: concatenate each token's bytes"]
  class TXT,BYTES neutral
  class COUNT,BEST,MERGE compute
  class VOCAB memory
  class ENC,DEC queue

How it really works#

Training#

ids   = the corpus as a list of byte values (0–255)
repeat until the vocabulary is the size you want:
    count every adjacent pair in ids
    pick the most frequent pair (a, b)
    assign it the next free ID
    replace every occurrence of (a, b) in ids with the new ID
    record the merge: (a, b) → new ID

The output is an ordered list of merges. The vocabulary is derived from it: token 256+i is the bytes of its two parts concatenated.

Encoding#

Turn the text into bytes, then apply the merges in the order they were learned: at each step, among all adjacent pairs in the current sequence, merge the one that was learned earliest. Stop when no pair in the sequence is a known merge.

Decoding#

Look up each ID’s bytes and concatenate. Decoding is a table lookup; it cannot fail. But a single token’s bytes may be half of a multi-byte character, so a streaming server must hold bytes back until they form complete UTF-8 (II.02).

What production tokenizers add#

FeaturePurpose
Pre-tokenization with a regular expressionSplit text into words, numbers and punctuation first so merges never cross those boundaries
A leading-space convention (" the" is one token)Words carry their preceding space; no separate space tokens
Special tokens (`<begin_of_text
A chat templateTurns a list of messages into the exact token sequence the model was trained on. Getting it wrong silently degrades quality
Variants: WordPiece, Unigram / SentencePieceDifferent training algorithms; same job
Vocabulary of 32k–200kLarger vocabularies give shorter sequences and a larger embedding table

Rules of thumb for English: one token is about four characters, or three-quarters of a word. Code, non-Latin scripts and numbers tokenize less efficiently. Token count is the unit of cost, latency and context length, so counting tokens accurately is a core service function.

Performance#

Tokenization is CPU work on the request path, ahead of the GPU. For long prompts it is not negligible: a naive encoder is quadratic in the length of each word.

TechniqueEffect
Encode word by word and cache per wordText is repetitive; most words are a map hit
Store merges in a map[[2]int32]int32 keyed by the pairO(1) rank lookup; an array key needs no allocation
Work on []byte and reuse the output sliceNo per-call allocation (III.05)
A trie or automaton over the vocabularyLinear-time encoding
Tokenize requests in parallel goroutinesIt is embarrassingly parallel and independent of the GPU

Go is well suited to this: several production tokenizer libraries are Rust or C++ with bindings, and a pure-Go BPE with a word cache is competitive and has no cgo boundary (V.04).

Correctness matters more than speed#

A tokenizer must match the one used in training exactly — same merges, same special tokens, same normalization, same template. A mismatch does not raise an error; the model just reads slightly wrong input and answers slightly worse. Always test a port against the reference implementation on a large and varied corpus, including emoji, mixed scripts and whitespace edge cases.

Code#

Go
// bpe.go — train a byte-pair encoder, encode, decode, and check the round trip.
package main

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

type Pair [2]int32

type BPE struct {
	Merges map[Pair]int32 // pair → new token ID; lower ID = learned earlier
	Vocab  [][]byte       // token ID → bytes
}

// replace rewrites ids, replacing each occurrence of pair p with id, in place.
func replace(ids []int32, p Pair, id int32) []int32 {
	out := ids[:0]
	for i := 0; i < len(ids); i++ {
		if i+1 < len(ids) && ids[i] == p[0] && ids[i+1] == p[1] {
			out = append(out, id)
			i++
		} else {
			out = append(out, ids[i])
		}
	}
	return out
}

func Train(corpus string, merges int) *BPE {
	t := &BPE{Merges: map[Pair]int32{}}
	for b := 0; b < 256; b++ {
		t.Vocab = append(t.Vocab, []byte{byte(b)})
	}
	ids := make([]int32, len(corpus))
	for i := 0; i < len(corpus); i++ {
		ids[i] = int32(corpus[i])
	}
	for m := 0; m < merges; m++ {
		counts := map[Pair]int{}
		for i := 0; i+1 < len(ids); i++ {
			counts[Pair{ids[i], ids[i+1]}]++
		}
		best, bestN := Pair{}, 1
		for p, n := range counts {
			// most frequent; ties broken deterministically so training is reproducible
			if n > bestN || (n == bestN && n > 1 && (p[0] < best[0] || (p[0] == best[0] && p[1] < best[1]))) {
				best, bestN = p, n
			}
		}
		if bestN < 2 {
			break // nothing repeats any more
		}
		id := int32(len(t.Vocab))
		t.Merges[best] = id
		t.Vocab = append(t.Vocab, append(append([]byte{}, t.Vocab[best[0]]...), t.Vocab[best[1]]...))
		ids = replace(ids, best, id)
	}
	return t
}

// Encode appends the tokens for text to dst (caller-owned buffer: no allocation when reused).
func (t *BPE) Encode(dst []int32, text string) []int32 {
	start := len(dst)
	for i := 0; i < len(text); i++ {
		dst = append(dst, int32(text[i]))
	}
	ids := dst[start:]
	for len(ids) >= 2 {
		// find the pair in the sequence that was learned earliest
		best, bestID := Pair{}, int32(-1)
		for i := 0; i+1 < len(ids); i++ {
			if id, ok := t.Merges[Pair{ids[i], ids[i+1]}]; ok && (bestID < 0 || id < bestID) {
				best, bestID = Pair{ids[i], ids[i+1]}, id
			}
		}
		if bestID < 0 {
			break
		}
		ids = replace(ids, best, bestID)
	}
	return dst[:start+len(ids)]
}

func (t *BPE) Decode(ids []int32) string {
	var sb strings.Builder
	for _, id := range ids {
		sb.Write(t.Vocab[id])
	}
	return sb.String()
}

// show renders a token for printing: text when it is valid UTF-8, hex when it is a fragment
// of a multi-byte character.
func show(b []byte) string {
	if utf8.Valid(b) {
		return strings.ReplaceAll(string(b), " ", "_")
	}
	return fmt.Sprintf("0x%x", b)
}

func main() {
	corpus := strings.Repeat("the lower the latency the lower the cost. low latency, low cost, lowest loss. ", 20) +
		"tokens are the unit of cost and of latency. "

	tok := Train(corpus, 60)
	fmt.Printf("vocabulary: 256 bytes + %d merges\n", len(tok.Merges))
	fmt.Print("first learned tokens: ")
	for id := 256; id < 256+12 && id < len(tok.Vocab); id++ {
		fmt.Printf("%q ", tok.Vocab[id])
	}
	fmt.Println()

	for _, text := range []string{
		"the lowest latency",
		"low cost tokens",
		"unseen wörds and 🙂 still encode",
	} {
		ids := tok.Encode(nil, text)
		fmt.Printf("\n%q\n  %d bytes → %d tokens: ", text, len(text), len(ids))
		for _, id := range ids {
			fmt.Printf("[%s]", show(tok.Vocab[id]))
		}
		back := tok.Decode(ids)
		fmt.Printf("\n  round trip exact: %v\n", back == text)
	}

	// Reusing the output buffer: the hot-path shape from III.05.
	buf := make([]int32, 0, 256)
	total := 0
	for i := 0; i < 1000; i++ {
		buf = tok.Encode(buf[:0], "the lowest latency and the lowest cost")
		total += len(buf)
	}
	fmt.Printf("\nencoded 1,000 prompts into one reused buffer: %d tokens\n", total)
}

Remember this#

  • BPE: start from 256 bytes, repeatedly merge the most frequent adjacent pair.
  • Encode by applying merges in learned order; decode by concatenating bytes. Any input encodes; decoding is exact.
  • Special tokens and the chat template are part of the tokenizer and must match training.
  • Tokens are the unit of cost and context. Count them precisely; cache per word; reuse buffers.

Try it#

  1. Run bpe.go. Train with 5 merges and with 200. How does the token count of the test sentences change?
  2. Add a per-word cache (map[string][]int32) to Encode and benchmark it on a long repeated text.
  3. Add one special token <|end|> that is matched before BPE and can never be produced by encoding ordinary text containing those characters. Why is that property important?

Check yourself#

  1. Why can a byte-level BPE tokenizer encode any input?
  2. In what order are merges applied during encoding?
  3. Why might a single decoded token not be valid UTF-8 on its own?

↑↓ navigate↵ openesc close