LLM Basics 2 — Tokens and Embeddings
Series: 1 · What a model is on disk → 2 · Tokens and embeddings → 3 · A layer is matrix multiplication → 4 · The forward pass → 5 · Generating text → 6 · Attention and the KV cache → 7 · The hardware fit → Capstone · Why-LLM-Decode-Is-Memory-Bound
A model only does arithmetic, so text has to become numbers before anything else can happen. That takes two steps, and neither involves any math:
- Text → token IDs. The tokenizer splits text into pieces from a fixed vocabulary and replaces each piece with its index. Integers.
- Token ID → vector. Each ID selects one row of the embedding table,
token_embd. That gives 4,096 numbers per token.
The examples use the same specimen as step 1, deepseek-coder-6.7b-instruct. Everything below was observed by running its vocabulary and embedding table directly.
First: text is already numbers
Before a tokenizer gets involved, text on disk or in memory is already a sequence of numbers: bytes.
A byte is a number from 0 to 255
- A byte is 8 bits. Eight binary digits give 2⁸ = 256 possible patterns, from
00000000to11111111.- Read as an unsigned integer that is 0–255, written in hex as
00–FF. - In C it is
unsigned char.
- Read as an unsigned integer that is 0–255, written in hex as
- “The 256 possible bytes” just means every value one byte can hold. No byte value exists outside that list, so any file of any kind — text, image, executable — is a sequence drawn from those 256 values.
Which number stands for which character
That mapping is a character encoding, and for text today it is almost always UTF-8.
- Bytes 0–127 are ASCII, unchanged since the 1960s, at one byte per character.
- Letters, digits and punctuation:
A= 65 (0x41),a= 97 (0x61),0= 48 (0x30). - Space = 32 (
0x20). - Control codes that are not visible characters: tab = 9 (
0x09), newline = 10 (0x0A).
- Letters, digits and punctuation:
- Everything else takes two to four bytes, each in the range 128–255.
- Accented letters, other alphabets, CJK characters and emoji are built this way.
- Observed with Python’s
str.encode("utf-8"):
| Character | UTF-8 bytes (hex) | Bytes |
|---|---|---|
A | 41 | 1 |
é | C3 A9 | 2 |
猫 (Chinese, “cat”) | E7 8C AB | 3 |
🦙 (llama emoji) | F0 9F A6 99 | 4 |
So “the cat” in a text file is literally the 7 bytes 74 68 65 20 63 61 74. For the full story of encodings, see Text Encoding from First Principles.
Why this matters for tokenizing: if the vocabulary contains all 256 single-byte values, then any input whatsoever can be represented. In the worst case it is spelled one byte at a time. That is the safety net under everything below.
Step 1: text → token IDs
The vocabulary is a string table
The GGUF metadata holds the vocabulary as an array of 32,256 strings (tokenizer.ggml.tokens). A token ID is just an index into that array. It is an enum with 32,256 members.
const char *vocab[32256] = { "!", "\"", "#", "$", ... }; // ID = position
Some entries, by ID:
| ID | Entry |
|---|---|
| 0 | ! |
| 5000 | eks |
| 5939 | Ġcat |
| 20000 | ĠOri |
| 31000 | Ġestable |
The Ġ and Ċ characters are bytes in disguise
The vocabulary is stored as strings, and some bytes are awkward to store or display that way: space, tab, newline, and the 128–255 range that makes no sense on its own. So this tokenizer family (the “byte-level BPE” first used by GPT-2) gives each of the 256 bytes a visible stand-in character:
- Printable ASCII bytes stand for themselves.
AisA. - The rest are shifted to visible characters from further up Unicode.
- Observed:
| Byte | Meaning | Shown in the vocabulary as | Vocab ID |
|---|---|---|---|
0x20 (32) | space | Ġ | 207 |
0x0A (10) | newline | Ċ | 185 |
0x09 (9) | tab | ĉ | 184 |
0x41 (65) | A | A | 32 |
0xE7 (231) | first byte of 猫 | ç | 161 |
So Ġcat means " cat", with the leading space, and ĉint is a tab followed by int. The odd characters are purely a display convention; underneath, every token is a string of bytes.
Observed: all 256 byte stand-ins are present in this vocabulary, with none missing. That is the safety net from the previous section, checked rather than assumed.
The space is part of the token. " cat" (5939) and “cat” are different IDs, so the model sees “cat” mid-sentence and at the start of a word as different inputs.
Real tokenizations
| Text | Pieces | IDs |
|---|---|---|
The cat sat on the mat. | The Ġcat Ġsat Ġon Ġthe Ġmat . | 546 5939 2645 331 254 1575 13 |
def foo(x):⏎ return x * 2 | def Ġfoo ( x ): Ċ ĠĠĠ Ġreturn Ġx Ġ * Ġ 2 | 1551 24531 7 87 1772 185 315 967 1371 207 9 207 17 |
antidisestablishmentarianism | ant id is estab lish ment arian ism | 400 304 262 18118 2217 469 9373 1990 |
␣configured the DGX Spark. | Ġconfigured Ġthe ĠD G X ĠSp ark . | 14578 254 413 38 55 2566 856 13 |
Patterns worth noticing:
- Common words are one token each — " cat", " the", " return", " configured".
- Rare words are split into pieces. “antidisestablishmentarianism” is 8 tokens.
- Names and acronyms fragment badly. “DGX” is three tokens; “Spark” is two.
- Code has tokens for its own idioms:
):is one token, and four spaces of indentation becomeĠĠĠplus the space glued toreturn. - Every tokenization round-trips exactly. Decoding the IDs reproduces the original bytes.
␣marks a leading space in the Text column.configuredmid-sentence is one token, but at the very start of a text, with no space before it,configuredsplits intoconfigure+d— another case of the space being part of the token.
Why pieces, and not characters or words
| Unit | Problem |
|---|---|
| Characters | Sequences become ~4× longer. Every generated token costs a full read of the weights (Why-LLM-Decode-Is-Memory-Bound), so that is ~4× slower and uses ~4× the context. |
| Words | Vocabulary is unbounded. Every typo, name, and identifier would need an entry, and an unseen word could not be represented at all. |
| Subword pieces (BPE) | Common strings get one token; anything else is built from smaller pieces. Nothing is ever unrepresentable. |
How the vocabulary was built: BPE
Byte Pair Encoding is a dictionary compressor, close in spirit to LZW:
- Start with the 256 possible byte values as the vocabulary.
- In a large training corpus, find the most frequent adjacent pair — say
t+h. - Add
thas a new entry, and record the merge. - Repeat until the vocabulary reaches its target size.
Observed: this file stores 31,757 recorded merges (tokenizer.ggml.merges). Encoding text replays those merges in the order they were learned, which is deterministic — the tokenizer is a lookup procedure, not a neural network.
Because the 256 single bytes are always in the vocabulary, any input can be encoded: emoji, binary garbage, a language the corpus barely saw. It just costs more tokens. Observed in this tokenizer:
| Text | UTF-8 bytes | Tokens | Why |
|---|---|---|---|
猫 | E7 8C AB | 1 (çĮ«) | common enough in training data that its three bytes were merged into one token |
🦙 | F0 9F A6 99 | 3 | rare, so only partly merged |
ᚠᚢᚦ (Norse runes) | 9 bytes | 9 | almost never seen, so spelled out one byte per token |
All three decode back to the exact original text. Rare text is never rejected — it just gets more expensive.
Rule of thumb, and why counts matter
- English prose: roughly 4 characters per token. Code, names, numbers, and most non-English text tokenize worse.
- Everything LLM is priced and measured in tokens: context windows, API cost, and tok/s.
- Different models have different tokenizers. The same text is a different number of tokens on Llama, Qwen, and GPT models, so token counts don’t transfer between them.
Step 2: token ID → vector
The embedding table is a 2-D array, and lookup is indexing
token_embd.weight has one row of 4,096 numbers per vocabulary entry: 32,256 rows × 4,096 columns. Converting a token to a vector is an array index — no multiplication:
float E[32256][4096]; // token_embd, loaded from the file
float *x = E[token_id]; // the token's vector: 4096 floats
Observed: row 5939, " cat", begins
-0.1781 -0.0918 +0.1531 +0.2644 -0.1252 -0.1837 -0.0891 +0.0111 ... (4096 values)
For a sentence of N tokens, the model now holds an N × 4,096 array. That array is what flows through the 32 layers. The token IDs are never used again on the way in.
Why a vector of 4,096 numbers?
An ID is just a label. ID 5939 is not “closer” to 5940 than to 12, so the integer itself tells the model nothing. A vector is a position in a 4,096-dimensional space, and training moves tokens that are used in similar ways toward each other.
The standard closeness measure is cosine similarity, which is built from two simpler pieces:
- Dot product: multiply the two vectors element by element, then add up the results. It comes out large when both vectors have big values in the same places with the same signs.
- Length: the square root of the sum of a vector’s squared elements — Pythagoras, in 4,096 dimensions. For
[3, 4]that is √(9 + 16) = 5.
Cosine similarity = dot product ÷ (length of one × length of the other). Dividing by the lengths removes size, leaving only direction. The result is the cosine of the angle between the two vectors:
- +1: same direction (0°)
- 0: unrelated (90°)
- −1: opposite directions (180°)
Lesson 3 works an example through by hand.
Observed similarities between rows of this model’s embedding table:
| Pair | Cosine |
|---|---|
Monday · Tuesday | +0.410 |
int · float | +0.370 |
int · string | +0.283 |
cat · dog | +0.232 |
def · function | +0.214 |
cat · int | +0.083 |
cat · return | +0.010 |
Monday · return | +0.007 |
Observed nearest neighbours across all 32,256 rows:
Monday→onday,Friday,Saturday,Sunday,Tuesday,Wednesday,uesday,Thursdayint→⇥int,int,Int,Int,float,uint,bool,INTdef→def,Def,Def,DEF,fun,ef,void,functioncat→cat,Cat,Cat,CAT,cats,dog,猫,Kat
Nobody programmed any of that. The days of the week cluster together, C types cluster together, and the Chinese character for cat (猫) sits beside the English word. Training arranged the vectors this way because it made predicting the next token easier.
What an embedding does not hold yet
- The 4,096 dimensions have no names. No column means “is an animal”. Meaning lives in directions through the space, spread across many columns at once — the same way knowledge is spread across the weights in step 1.
- The similarities are modest (0.2–0.4), because this is only the starting point. The vector is the same every time the token appears.
- It carries no context. " bank" gets the same row whether the sentence is about rivers or money. Mixing in context is the job of the layers — steps 3 through 6.
The exit door
At the other end of the model, output.weight (also 32,256 rows × 4,096 columns) runs the process in reverse. The final 4,096-number vector is compared against every row, giving one score per vocabulary entry: 32,256 scores for “which token comes next.” Turning those scores into a choice is step 4.
How these numbers were produced on a DGX Spark
Reproduce it: every number in this lesson is printed by lessons/02_tokens_and_embeddings.py in the course’s companion code repository, llm-bytes-up-code. Run it with --server to repeat the check against llama.cpp’s tokenizer. The run this lesson quotes is recorded in that repository’s outputs/2026-10-12/, produced from a fresh clone on the Spark.
Everything in this lesson was read on the course’s DGX Spark on 2026-10-12, from the same GGUF file as lesson 1.
- The vocabulary and merges came from the file’s metadata:
tokenizer.ggml.tokens(32,256 strings) andtokenizer.ggml.merges(31,757 rules), read with llama.cpp’sgguf-py. - The tokenizations were first produced by loading that vocabulary and merge list into the Hugging Face
tokenizerslibrary as a byte-level BPE, and then checked against llama.cpp’s own tokenizer. That check ran on a llama.cpp server loaded with this model on the Spark, using its/tokenizeendpoint.- One example differed, and the table above shows llama.cpp’s answer. For
x * 2, the reconstruction produced*,,2, while llama.cpp produced,*,,2. - The cause is the pre-tokenizer. Before any merges run, text is split into chunks by a regular expression, and the reconstruction used the generic GPT-2 one rather than this model’s. Same vocabulary, same merges, different chunk boundaries — so a slightly different result.
- Every other example matched exactly, including the emoji,
猫and the runes. - The check also caught a copying error in the first draft of the
DGX Sparkrow, whose ID list had been taken from a longer sentence. - The lesson in the lesson: a tokenizer is the vocabulary and the exact procedure. Two tools with identical vocabularies can still disagree.
- One example differed, and the table above shows llama.cpp’s answer. For
- The embedding rows were dequantized by hand from Q8_0, using the block layout from lesson 1: each 4,352-byte row is 128 blocks of a 2-byte scale plus 32 int8s.
- The nearest neighbours come from computing cosine similarity against all 32,256 rows. That fits easily in the Spark’s 128 GB; the full table is about 0.5 GB as FP32.