rml / Tokenizers

Tokenizers

How rml turns text into token ids and back for the models it runs: one BPE tokenizer with two spellings (byte-level and SentencePiece), hand-written matchers for each model family's pre-split regex, generated Unicode tables, loaders for Hugging Face tokenizer.json and GGUF metadata, and the chat formats a conversation is laid out in.

On this page
  1. The spec and the two kinds
  2. The byte alphabet
  3. Pre-splitting
  4. The three patterns on the same text
  5. UTF-8 and Unicode classes
  6. The merge loop
  7. A worked example
  8. Decoding
  9. Hugging Face tokenizer.json
  10. GGUF tokenizer metadata
  11. Chat templates
  12. Who uses it
  13. Tests and fixtures

In short: every tokenizer is a TokenizerSpec (vocabulary, token types, merges or scores, pre-splits, special ids) checked and indexed by Tokenizer::Create. Encoding cuts special tokens out of the text first, then spells each remaining segment: byte-level BPE (GPT-2, Llama 3, Qwen) pre-splits it into words and writes every byte as a printable character, so nothing is ever unknown; SentencePiece BPE (Llama 2, TinyLlama, Mistral, Gemma) writes spaces as "▁" and keeps the segment whole. A priority queue then applies the earliest-ranked merge (or highest-scoring piece), leftmost on ties, until none applies. The pre-split regexes are matched by hand rather than by a regex engine, against Unicode 15.1 category tables generated offline, and every split, encoding and decoding is checked against Python's regex, Hugging Face tokenizers and sentencepiece outputs committed as fixtures. No Jinja either: seven chat formats are recognized and written directly.

The concept is on /ml-models: tokenization. All of this lives in rml/llm as six libraries: tokenizer, pretokenizer, unicode (which compiles the generated unicode_tables), hf_tokenizer, gguf_tokenizer and chat_template (BUILD.bazel). The model they feed is on The decoder.

The spec and the two kinds

The header describes BPE in four steps (tokenizer.h L4–25): special and added tokens are cut out first and map straight to their ids; byte-level BPE pre-splits the rest and spells each byte as a printable character, while SentencePiece spells spaces as "▁" and prefixes one; merges run, lowest rank (highest score) first, leftmost on ties; and pieces map to ids, a SentencePiece piece outside the vocabulary falling back to one <0xXX> token per byte.

Field of TokenizerSpecMeaning
kindkByteLevelBpe or kSentencePieceBpe.
tokens, typesToken strings by id as the vocabulary spells them ("Ġthe", "▁the"), and a TokenType per id: normal 1, unknown 2, control 3 (<s>, <|eot_id|>), user-defined 4 (added tokens matched verbatim), unused 5, byte 6 (<0xXX>). Empty types mean all normal.
merges, scoresMerge rules, earliest first. When a SentencePiece spec has none, it merges by per-token score instead, as GGUF stores it.
pre_splits, ignore_mergesByte-level only: the pre-split patterns, applied in order; and Llama 3's rule that a pre-split word that is itself a token skips the merges.
add_space_prefix, byte_fallbackSentencePiece only: prefix each segment with "▁" (default on); spell unknown pieces as byte tokens (default on).
unk_id, bos_id, eos_ids, add_bosSpecial ids, several end-of-sequence ids allowed, and whether Encode prepends BOS by default.

tokenizer.h L40–81. Create rejects an empty vocabulary, a types list of the wrong length, a score-merged SentencePiece spec without one score per token, out-of-range special ids, add_bos without a BOS id, and any merge whose two halves or result are not in the vocabulary (tokenizer.cc L103–151). It then builds the string-to-id map, the merge table keyed by the two ids, each token's raw bytes, and the list of control and user-defined tokens sorted longest first, so that the longest special wins when two share a prefix (L153–194).

The header says Create also checks that a byte-level vocabulary covers all 256 bytes (tokenizer.h L92–93); the code deliberately does not, because SmolLM2's vocabulary has no control bytes. Like Hugging Face, such a byte encodes to the unknown token if there is one and is dropped otherwise (tokenizer.cc L153–156).

The byte alphabet

Byte-level BPE needs a printable spelling for every byte. Bytes that print as themselves (! to ~, 0xA1–0xAC, 0xAE–0xFF) keep their code point; the other 68, in order, take U+0100, U+0101 and so on (tokenizer.cc L27–51). Space is the 33rd of them (bytes 0x00–0x20 come first), so it becomes U+0120 "Ġ", and newline (0x0A, the 11th) becomes U+010A "Ċ". The last lands on U+0143, which is why the inverse table has 0x144 entries. ByteToUnicode and UnicodeToByte expose the bijection (tokenizer.h L150–154), and a token's raw bytes are its spelling run back through it.

Tokenizer::Encode for the two kinds Encode optionally prepends BOS, then scans the text for control and user-defined tokens, longest first; each match becomes its id and splits the text into segments. A byte-level segment is pre-split into words by the spec's patterns, each word's bytes are spelled with the byte alphabet, and Llama 3 looks the whole word up first. A SentencePiece segment has its spaces replaced by the U+2581 marker and one marker prefixed, and stays one word. Each word then goes through EncodeWord, the merge loop, and any piece left without an id becomes byte-fallback tokens or the unknown token. Encode(text, options) BOS if add_bos; cut out specials, longest first each segment between specials byte-level BPE GPT-2, Llama 3, Qwen, SmolLM SentencePiece BPE Llama 2, TinyLlama, Mistral, Gemma PreSplitText → words no merge ever crosses a boundary " " → "▁", prefix one "▁" the segment stays one word each byte → ByteToUnicode Llama 3: a whole-word token skips merges EncodeWord: the merge loop best rank (or score) first, leftmost on ties ids; an unknown piece → <0xXX> per byte (SentencePiece) or unk_id
The encoding path (tokenizer.cc L223–297, L408–421). With parse_special = false, control tokens written in the text are encoded as ordinary text; user-defined tokens are always matched.

Pre-splitting

Byte-level BPE never merges across a pre-split boundary, so the split decides what a token can be: " hello" may be one token, "hello world" never is. Each model family defines its split as a regex; rml matches each known pattern by hand, alternative by alternative with the same leftmost-alternative semantics, instead of embedding a regex engine (pretokenizer.h L4–12).

PreSplitPatternMatcher
kGpt2's|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+Gpt2 L95–110
kLlama3(?i:'s|'t|'re|'ve|'m|'ll|'d)|[^\r\n\p{L}\p{N}]?\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]+[\r\n]*|\s*[\r\n]+|\s+(?!\S)|\s+Llama3(i, 3) L112–149
kQwen2Llama 3's with \p{N} in place of \p{N}{1,3}Llama3(i, 1)
kDigitsNo regex: every \p{N} on its own, runs of anything else whole (Hugging Face's Digits with individual_digits)Digits L151–156

The patterns are stored verbatim (pretokenizer.cc L14–19) so PreSplitFromPattern can recognize a tokenizer.json by exact string. The pieces of the hand matchers each mirror one alternative: Contraction (ASCII-case-insensitive for Llama 3), OptionalSpaceThenRun for ?X+, and TrailingSpace for \s+(?!\S)|\s+, a whitespace run minus its last character when a non-space follows and the run is longer than one (L48–93). Every code point is a letter, number, space or other and every pattern has an alternative for each, so a match always advances; a safety step of one code point covers the rest. Pieces are byte ranges of the input, so they cover it exactly, malformed UTF-8 included (L187–218). A list of splits applies each to every piece of the one before (L220–236).

The three patterns on the same text

From the committed reference splits, produced by Python's regex module on the original patterns (pretokenizer.json, written by make_pretokenizer_fixtures.py); pieces are shown between bars, · is a space, and each text is the start of a fixture string:

TextGPT-2Llama 3Qwen2
1234567 and 89|1234567|·and|·89||123|456|7|·and|·|89||1|2|3|4|5|6|7|·and|·|8|9|
I'M sure|I|'|M|·sure||I|'M|·sure||I|'M|·sure|
a\n\nb|a|\n|\n|b||a|\n\n|b||a|\n\n|b|
def f(x):\n return|def|·f|(|x|):|\n···|·return||def|·f|(x|):\n|···|·return||def|·f|(x|):\n|···|·return|

GPT-2 keeps digit runs whole and its contractions are case-sensitive; Llama 3 caps digit runs at three and matches 'M; Qwen2 isolates every digit. Llama 3's [^\r\n\p{L}\p{N}]?\p{L}+ lets one punctuation character lead a word, hence (x, and its [\r\n]* tail keeps newlines with the punctuation before them. The fixture has 25 such texts, also covering tabs, CRLF, combining marks, CJK, emoji and no-break spaces, and the test compares all 75 splits (pretokenizer_test.cc L33–50).

UTF-8 and Unicode classes

The patterns speak in code points and categories, the tokenizer in bytes; unicode.h bridges the two (unicode.h L4–10).

ItemBehaviourSource
DecodeUtf8Returns CodePoint{value, offset, length}. A malformed or truncated sequence, an overlong encoding, a surrogate or a value above U+10FFFF decodes as U+FFFD spanning one byte, so every input byte is covered exactly once..h L21–38, .cc L24–74
AppendUtf8, EncodeUtf8The inverse; a surrogate or out-of-range value is a programmer error (RML_CHECK)..cc L76–100
CategoryOfBinary search (upper_bound) over sorted, non-overlapping ranges; a code point in no range is kOther..cc L102–113
IsLetter, IsNumber, IsMark\p{L} (Lu, Ll, Lt, Lm, Lo), \p{N} (Nd and Nl/No), \p{M}..cc L115–133
IsWhitespaceThe White_Space property that \s matches in Unicode mode, as a switch: tab to CR, space, U+0085, U+00A0, U+1680, U+2000–200A, U+2028, U+2029, U+202F, U+205F, U+3000..cc L135–155
AsciiLowerASCII-only lowering, enough for the case-insensitive English contractions..h L52–54

The ranges are generated: tools/make_unicode_tables.py walks all 0x110000 code points through Python's unicodedata, keeps the eleven categories the patterns use (mapped to eight enum values), and merges consecutive code points of one category into ranges, so the C++ library needs no Unicode dependency (make_unicode_tables.py L1–41, unicode_tables.h L9–33). The committed table has 2,351 ranges from Unicode 15.1.0 (unicode_tables.cc L1–7).

The merge loop

EncodeWord runs BPE over one spelled word (tokenizer.cc L325–406). The word starts as one symbol per unit (a byte's spelling, or a code point for SentencePiece), held as a linked list over byte ranges. Every adjacent pair with a merge goes into a min-priority queue keyed by priority, then by left position. Popping a candidate merges it only if it is still current: the left symbol must still point at the right one and both must have the lengths recorded when it was queued; otherwise it is stale and skipped. A merge grows the left symbol, empties the right, and queues the two new neighbouring pairs.

  • Priority. With merge rules, a pair's priority is its rank, looked up by the two ids. Without them (SentencePiece from GGUF), the merged string itself is looked up and must be a normal or user-defined token; its priority is minus its score (FindMerge L299–323).
  • Leftovers. A final symbol with no id becomes one byte token per byte under SentencePiece byte fallback (or unk_id for a byte without one), and unk_id or nothing otherwise (AppendPiece L408–421).

A worked example

tokenizer_test builds a byte-level vocabulary of the 256 byte symbols (ids 0–255, so id 97 is "a"), then "ab" (256), "abc" (257) and the control token <|end|> (258), with merges a b (rank 0) and ab c (rank 1) (tokenizer_test.cc L80–94). Encoding "abcab", which the GPT-2 split leaves as one word:

Merging abcab with the ranks a b = 0 and ab c = 1 Start with five symbols a, b, c, a, b. The queue holds a b at position 0 and a b at position 3, both rank 0. Popping position 0 merges it into ab and queues ab c at rank 1. Popping position 3 merges the second a b. Popping ab c merges it into abc. Nothing else merges, so the ids are 257 for abc and 256 for ab. start a b c a b queue: (0, pos 0) a b (0, pos 3) a b pop (0, 0) ab c a b queues (1, pos 0) ab c pop (0, 3) ab c ab c ab has no merge pop (1, 0) abc ab abc ab has no merge; queue empty ids 257 256 Encode("abcab") = {257, 256}
Equal ranks pop leftmost first, which is the tie rule BPE needs. The same test encodes ab<|end|>c as {256, 258, 99}, and with parse_special = false spells <|end|> as seven byte tokens that decode back to the same text (tokenizer_test.cc L96–111).

Decoding

Decode concatenates each token's raw bytes, skipping control tokens when asked. SentencePiece's own prefix space is removed from the first text token, since it is not part of the text (tokenizer.cc L423–443). Bytes, not characters, come out: one character can be split across tokens, so the header asks streaming output to buffer to a character boundary (tokenizer.h L23–25). TokenBytes, TokenText and TypeOf die on an out-of-range id.

Hugging Face tokenizer.json

ParseHfTokenizer reads a BPE model with byte-level pre-tokenization (GPT-2, Llama 3, Qwen2, SmolLM) or with SentencePiece-style "▁" spaces and byte fallback (Llama 2, TinyLlama, Mistral, Gemma); anything else is Unsupported (hf_tokenizer.h L4–11, hf_tokenizer.cc L237–293). It works component by component, flattening Sequences:

ComponentAcceptedSource
modelBPE with no subword prefix or suffix. vocab ids and added_tokens fill one table sized by the largest id; gaps are unused, added tokens are control if special and user-defined otherwise. Merges as "a b" strings or ["a", "b"] pairs. ignore_merges, byte_fallback (which retypes <0xXX> tokens as bytes) and unk_token, which must be in the vocabulary.L163–233
normalizerPrepend "▁" and Replace " " → "▁" (SentencePiece style); NFC, accepted and skipped because it changes only text that is not already NFC.L77–94
pre_tokenizerByteLevel without add_prefix_space (its regex is the GPT-2 split); Split with one of the three known patterns, Isolated, not inverted; Gemma's space Split after spaces are already replaced, which never applies; Digits with individual_digits; Metaspace with "▁" and no split.L96–136
post_processorA TemplateProcessing whose single-sequence template starts with a special token makes that token BOS and turns add_bos on.L138–161

Exactly one of the two space styles must be present; both or neither is Unsupported. LoadHfTokenizer reads the file through a MappedFile (Model files) and prefixes errors with the path (L295–303).

GGUF tokenizer metadata

A GGUF file carries its tokenizer under tokenizer.ggml.* keys (gguf_tokenizer.h L4–17, gguf_tokenizer.cc L47–123):

KeyUse
model"llama": SentencePiece BPE with scores and byte fallback, BOS on by default. "gpt2": byte-level BPE. Anything else is Unsupported.
pre (byte-level; default "gpt-2")llama-bpe, llama3: the Llama 3 split, and whole-word tokens skip merges. qwen2: the Qwen2 split. gpt-2, olmo: the GPT-2 split. smollm, starcoder, refact: single digits, then GPT-2. Others are Unsupported (L29–43).
tokens, token_type, scores, mergesThe vocabulary (required), types 1–6, per-token scores, and merges as "a b" strings.
unknown_token_id, bos_token_idOptional; range-checked against the vocabulary.
eos_token_id, eot_token_id, eom_token_idTheir union, deduplicated, becomes eos_ids, so generation stops at any of them (L104–110).
add_bos_token, add_space_prefixOverride the defaults; BOS is added only if a BOS id exists.

Chat templates

A model's tokenizer config ships its prompt format as a Jinja template. Rather than run Jinja, rml recognizes the formats current open models use and writes them directly. The output leaves out the leading BOS, which the tokenizer adds, and the default system prompts and dates some templates inject (chat_template.h L4–20).

FormatModelsOne user turnDetected by
kChatMlQwen, SmolLM<|im_start|>user\nHi<|im_end|>\n<|im_start|>
kLlama3Llama 3<|start_header_id|>user<|end_header_id|>\n\nHi<|eot_id|>, content trimmed<|start_header_id|>
kTuluOLMo, OLMoE<|user|>\nHi\n; only assistant turns end with <|endoftext|>, and the prompt opens with one because OLMo's tokenizer adds no BOSIts '<|user|>, newline, ' + message['content'] }} fragment (a real newline inside the quotes, where Zephyr's test template has a backslash-n), checked before Zephyr
kZephyrTinyLlama<|user|>\nHi</s>\n<|user|>
kLlama2Llama 2[INST] <<SYS>>\nsys\n<</SYS>>\n\nHi [/INST]; later turns open with <s><<SYS>>
kGemmaGemma<start_of_turn>user\nsys\n\nHi<end_of_turn>\n; no system role, so the system text opens the first user turn<start_of_turn>
kMistralMistral[INST] Hi [/INST], the system text folded into the first turn[INST]

DetectChatFormat takes either a name ("chatml", "llama3", …) or a whole Jinja template and checks the markers in the order above, so the more specific ones win (chat_template.cc L163–189). FormatChat rejects roles other than system, user and assistant, and the [INST] and Gemma formats also require an optional first system message followed by strictly alternating user and assistant turns (L65–82, L191–213). With add_generation_prompt the prompt ends where the assistant's reply begins; the [INST] formats already end there.

Who uses it

examples/rml_chat is the one caller that puts the pieces together (chat.cc L56–144). A directory loads tokenizer.json and the Hugging Face chat template; a GGUF file loads the tokenizer from its metadata and the template from tokenizer.chat_template; a format given on the command line wins over either. The stop ids are the tokenizer's end-of-sequence ids, the model config's, and the format's turn terminator (<|im_end|>, <|eot_id|>, <|endoftext|>, </s> or <end_of_turn>) when it is in the vocabulary. Each reply formats the whole conversation, encodes it, and streams every non-control token's raw bytes to the caller as it is generated (L186–205). The generation loop itself is on Generation, and the program on Examples.

Tests and fixtures

tools/make_tokenizer_fixtures.py trains tiny tokenizers on a small mixed-language corpus with Hugging Face tokenizers (byte-level BPE with the Llama 3, Qwen2 and GPT-2 + digits splits, vocabulary 600, and SentencePiece-style BPE with byte fallback, 700) and with sentencepiece itself (score-based, 500, as GGUF stores it), and saves each with the ids the reference library gives a set of test strings (make_tokenizer_fixtures.py L1–13). The tests then need no Python.

FileTestsWhat they pin down
tokenizer_test.cc7The score-merged SentencePiece fixture encodes and decodes like sentencepiece; BOS on request and skipped on decode; the byte alphabet is a bijection with Ġ and Ċ; the merge example above, specials and literal specials; bytes missing from the vocabulary are dropped or become unk; bad specs are rejected; out-of-range ids die.
pretokenizer_test.cc5 (one parameterized over 3 patterns)Every reference split; isolated digits; a sequence of splits; pieces cover malformed UTF-8; patterns round-trip through PreSplitFromPattern.
unicode_test.cc4UTF-8 round trips at every length; each malformed byte becomes one U+FFFD; categories; encoding a surrogate dies.
hf_tokenizer_test.cc4 (one parameterized over 4 fixtures)The Llama 3, Qwen2, GPT-2 + digits and SentencePiece-BPE fixtures give Hugging Face's ids; special tokens and BOS are read; Gemma's redundant space split is ignored; five unsupported and five malformed files fail with the right code, and a missing file is NotFound.
gguf_tokenizer_test.cc3 (one parameterized over 3 fixtures)The byte-level fixtures written as GGUF metadata with pre llama-bpe, qwen2 and smollm give Hugging Face's ids; the SentencePiece vocabulary as GGUF matches sentencepiece with BOS; an unknown model or pre-split, an out-of-range BOS and missing tokens fail.
chat_template_test.cc11The exact prompt text of each of the seven formats; alternation and role errors; names round-trip; detection by name and by template.