What Makes LLM Tokenization Slow? LLM tokenization, while a small part of overall latency, sits on the hot path and can occur multiple times per request, according to a technical analysis of GPT-2's reference encoder. The analysis shows that converting text to token IDs via byte-pair encoding (BPE) involves pre-tokenization with regex, merging byte pairs based on trained ranks, and looking up token strings in a vocabulary, with the example 'goldshire' requiring eight merge rounds to become two token IDs. The author ported the encoder to Rust to explore performance improvements. LLMs receive sequences of token IDs instead of raw text. For example, the GPT-2 tokenizer encodes what's the weather in goldshire? into 10919, 338, 262, 6193, 287, 3869, 10932, 30 . While it's a small part of the overall latency of a modern LLM call, tokenization does sit on the hot path. In some LLM products, it may even happen multiple times, e.g. to decide if it's time to compress the context, estimating cost, or how to route the prompt when it lands in the provider's infrastructure. I wanted to learn more about how tokenization works and what the performance constraints are. I chose to study GPT-2's reference encoder https://github.com/openai/gpt-2/blob/master/src/encoder.py because it's compact and readable. Even though this post will be fairly GPT-2-specific, the underlying ideas and performance considerations haven't changed that much in the time since. As you'll see in the following sections, I converted GPT-2's reference encoder to Rust and then tried to make it a lot faster. Quick Intro to Byte-Pair Encoding GPT-2 uses byte-pair encoding https://en.wikipedia.org/wiki/Byte-pair encoding , or BPE. It converts arbitrary UTF-8 text into a reversible sequence of token IDs. It starts from byte symbols and uses a trained list of merge ranks to make common sequences into single tokens. Before this, GPT-2 runs a regex https://github.com/openai/gpt-2/blob/master/src/encoder.py L53C20-L53C113 over the input to divide it into regions that BPE will process separately word-like text, numbers, contractions, punctuation, and whitespace . what's the weather in goldshire? | v what 's the weather in goldshire ? This pre-tokenization step stops BPE from merging across obvious changes in text type. For example, goldshire and ? are separate regions, so BPE should not try to merge across that boundary. The goal of the regex is to provide some weak assumptions about where useful boundaries probably exist. GPT-2 needs all 256 byte values as its base alphabet in order to represent arbitrary UTF-8 without an unknown-token fallback. Literal bytes include spaces, controls, and invalid standalone UTF-8 values, so encoder.py maps them to safe visible Unicode symbols for string-based BPE, e.g. UTF-8 é is bytes c3 a9 , represented as é . In the GPT-2 source, vocab.bpe lists the pairs that may merge, with earlier lines having higher priority. I've trimmed it to show all the pairs needed to merge goldshire : rank left right---- ---- -----4 r e52 Ġ g79 l d211 Ġg o301 i re1221 s h3613 Ġgo ld10676 sh ire After merging, the token strings are looked up in encoder.json which contains every token in GPT-2's vocabulary 256 base byte tokens and ~50k merged byte-sequence tokens . base byte symbols token ID----------------- --------a 64b 65Ġ space 220 merged BPE pieces token ID------------------ --------the 1169ing 278Ġhello 23748Ġworld 995 So here's the complete path for goldshire : Ġ | g | o | l | d | s | h | i | r | e \ / r + e, rank 4 Ġ | g | o | l | d | s | h | i | re - Ġg | o | l | d | s | h | i | re - Ġg | o | ld | s | h | i | re - Ġgo | ld | s | h | i | re - Ġgo | ld | s | h | ire - Ġgo | ld | sh | ire - Ġgold | sh | ire - Ġgold | shire Ġgold - 3869shire - 10932 Each round chooses the valid pair with the lowest rank. A merge can create a new pair, so BPE cannot just scan from left to right. goldshire takes eight merge rounds to become two IDs. The full sentence takes 24 rounds to become eight IDs. Baseline Version GPT-2's encoder.py https://github.com/openai/gpt-2/blob/master/src/encoder.py ports fairly simply to Rust. I've used the same regex, byte map, merge ranks, and encoder.json . And it returns the complete Vec