On this page
A company that runs a chatbot pays for graphics chips (GPUs), and the memory on those chips is the scarce part. Every chat needs some of it for working notes about the words so far. Kwon and colleagues (2023) measured earlier programs that run chatbots. Those programs set aside one long stretch of memory for each chat’s notes. In them, only 20.4% to 38.2% of that memory held real notes (Figure 2 of their paper). The rest sat reserved and empty, or stranded in gaps too small to use. Their own program, vLLM (free software for running AI models), raised that to 96.3% by borrowing an old idea from operating systems. It handled 2 to 4 times as many requests per second, with the same delay per reply.
In one minute
vLLM, a free program that runs AI models for many people at once, saves memory with PagedAttention: it keeps each chat’s working notes in small, equal blocks handed out only when needed, not in one big stretch reserved up front. Picture two hotels: one gives each group a whole floor in case it grows, the other hands out single rooms as it grows, and fits far more groups. A chatbot writes one word at a time and keeps notes on every word so far (the KV cache). The computer running it cannot know how long a reply will be, and small blocks let it guess wrong without wasting much. Each chat keeps a short list of which blocks are its own, like a room list at the front desk.
Explore the numbers
Open the widget. Drag the top slider down to 16 and all three chats fit the old way; drag it up to 32 and chat C is shut out. Then press “+4 tokens” on chat A and watch the new way take one small block at a time.
How it works
The notes pile up
A chatbot writes its reply one word at a time (more exactly, one token: a word or a piece of a word). For each new word it must look back at all the earlier ones. Redoing that from scratch every time would be slow, so the server computes notes about each earlier word once and keeps them. These notes are the KV cache (“K” and “V” stand for “key” and “value”, the two kinds of note). They live in GPU memory, and they are big: for Llama 3 8B, an AI model whose design is public, they take about 130,000 bytes for each token, which is about 1 gigabyte for a conversation of 8,192 tokens. (Exactly 128 KiB and 1 GiB, where a KiB is 1,024 bytes and a GiB is 1,024 x 1,024 KiB.)
Reserving a stretch wastes room
The server cannot know how long a reply will be. The simplest plan reserves one long stretch of memory per chat, big enough for the longest reply allowed, so the notes can grow in place. Two kinds of waste follow. Inside a stretch, whatever the chat has not used yet sits empty, and a chat that stops early leaves most of it unused. Between stretches, finished chats leave holes. A new chat needs one long stretch, and the free memory may be there in total but split into pieces too short for it.
Figure 1. A toy pool of 20 slots (one slot holds the notes for one token). Colour shows which chat owns a slot, and a paler shade is a slot that is reserved but empty. Old way: chats 1 and 2 reserve 8 slots each and use 3 and 5, so only 4 slots stay free and chat 3 must wait. Chat 3 holds 4 tokens so far and would reserve 8 the old way. New way: blocks of 2 slots, with chat 1 in blocks 0 and 3, chat 2 in 1, 4 and 6, and chat 3 in 2 and 5.
Small blocks fix both
The fix comes from operating systems, which met the same problem decades ago. Cut the memory into many equal small blocks. Hand a block to a chat only when it needs one. Let each chat keep a list of its own blocks (the block table), which need not sit side by side. Every block is the same size, so any free block suits any chat, and the holes problem goes away. The only waste left is the unfilled end of each chat’s last block, always less than one block. In the bottom half of Figure 1, all three chats fit and only 2 slots are empty. When a chat finishes, its blocks go straight back to the unused pile.
Sharing is cheap
Many chats begin with the same text, such as the instructions an app adds every time. With block lists, two chats can point at the same block instead of each keeping a copy. If one chat must add notes to a shared block that still has room, it first makes a private copy and writes there, and the other chat keeps the original. This is called copy-on-write.
What it costs
Each lookup goes through the list, and the part of the model that looks back at the notes must read block by block. Block size is a trade: small blocks waste less per chat but make longer lists. The vLLM authors chose blocks of 16 slots.
What this means when you build
- Memory, not only speed, limits how many users one GPU serves. Each live chat holds notes for every token so far. With Llama 3 8B, ten chats of 8,192 tokens hold 10 GiB of notes before anything else is counted.
- Do the arithmetic before you rent a GPU. The notes cost a fixed number of bytes per token. From the model’s config file, multiply 2 (one key, one value),
num_hidden_layers,num_key_value_heads(notnum_attention_heads), the head size (hidden_sizedivided bynum_attention_heads) and the bytes per number (2 for 16-bit). Llama 3 8B: 2 x 32 x 8 x 128 x 2 = 131,072 bytes. Multiply by tokens and chats, add the model itself (8 billion numbers at 2 bytes is about 16 GB), and compare with the GPU’s memory. - Use a server that already pages. If you host an open model yourself, choose software that manages its cache in blocks. vLLM is open source, and SGLang (page size is a setting) and TensorRT-LLM document paged caches too. Writing your own allocator is a project for learning, not for shipping. If you call a hosted model instead, the provider handles the cache.
- Put the text that never changes at the start of the prompt. Servers that share cached blocks between requests, such as vLLM with its automatic prefix caching option, can only reuse a start that is identical. Hosted APIs that offer prompt caching also reuse only an identical start. Put your fixed instructions first and the changing part last.
- Expect waiting when the blocks run out. With no free block, a busy server makes new chats wait and may pause a running chat and redo its notes later (vLLM calls this preemption). Both show up as delay, not as a wrong answer. In vLLM, raising
gpu_memory_utilizationor loweringmax_num_seqsgives the cache more room.
Optionalabout 14 min
Go deeper
Optional. For readers who write Python. The code is in the code/ folder. Every number below was printed by python3 show_numbers.py, worked out by hand in the quiz, or comes from a cited source.
Servers call a chat a request; this section uses that word.
The naive approach: one big buffer per request
What the cache holds
Each pass that produces one new token is a decode step. At each step the newest token makes a query, compared with the key of every earlier token, and the resulting weights mix their values. An earlier token’s key and value never change, so the server computes them once and reads them back at every later step.
Without the store, step t must rebuild the keys and values of all t positions. A pair is one key and its value for one position, so over n steps that is 1 + 2 + … + n pairs. With the store, each step computes one new pair.
def pairs_without_cache(n_steps): # 1 + 2 + ... + n
return n_steps * (n_steps + 1) // 2
def pairs_with_cache(n_steps): # one new position per step
return n_steps
| Steps n | Without cache (pairs) | With cache (pairs) | Ratio |
|---|---|---|---|
| 10 | 55 | 10 | 5.5 |
| 100 | 5,050 | 100 | 50.5 |
| 1,000 | 500,500 | 1,000 | 500.5 |
The toy counts only this work and ignores the rest of the layer.
decode_both_ways in code/kv_sizes.py runs one attention head both ways, counts the pairs, and gets matching outputs.
The cache sits in GPU memory, and every decode step reads all of it, so its size sets the memory traffic of each new token.
How big it gets
For each token, each layer stores one key vector and one value vector for each key/value head. That gives the size per token in bytes.
def kv_bytes_per_token(cfg):
return 2 * cfg.n_layers * cfg.n_kv_heads * cfg.head_dim * cfg.bytes_per_elem
The leading 2 is one key plus one value. Take Llama 3 8B: 32 layers, 8 key/value heads (grouped-query attention lets 32 query heads share 8 of them), head size 128, and 2 bytes per number, the size of the 16-bit formats bf16 and fp16. The layer and head counts are in Table 3 of the Llama 3 paper and in the model’s config.json: num_hidden_layers is 32, num_key_value_heads is 8, num_attention_heads is 32 and hidden_size is 4096, so the head size is 4096 / 32 = 128. Meta’s model page asks for a login, so read the file in a public mirror. The 8,192-token context is max_position_embeddings in that file. A MiB is 1,024 KiB, and a GiB is 1,024 MiB.
| Quantity | Value |
|---|---|
| Bytes per token | 2 x 32 x 8 x 128 x 2 = 131,072 (128 KiB) |
| A 1,000-token request | 125 MiB |
| A full 8,192-token request | 1 GiB exactly |
Older models without grouped-query attention are heavier. OPT-13B (40 layers, 40 heads of size 128, fp16) needs 819,200 bytes, or 800 KiB, per token, matching the 800 KB that Kwon et al. round it to. A full 2,048-token request needs 1.5625 GiB (they round to 1.6 GB), and at a 128K-token context one request would need 16 GiB.
Why one big buffer wastes it
The simplest layout reserves one contiguous run of slots per request, sized for a per-request limit (8,192 tokens for the model below), so the cache can grow in place. A slot holds the keys and values of one token. Memory is wasted two ways.
- Internal fragmentation. Reserved slots sit empty inside a request’s own buffer. A request that stops after 200 tokens still holds the reservation for 8,192.
- External fragmentation. Free slots sit in gaps between buffers, too short to use. A new request needs one run in a row, and no gap is long enough even when the free total is large.
Figure 2. A toy pool of 32 slots (a) with three requests running and (b) after R2 finishes. Black slots hold a token, pale orange slots are reserved but empty, and dashed boxes are free. R1 reserves 12 and holds 5, R2 reserves 8 and holds 3, R3 reserves 8 and holds 6.
ContiguousPool in code/contiguous.py reproduces Figure 2. In (a), 14 reserved slots hold nothing. In (b), the 12 free slots come in runs of 8 and 4, so a request that needs 12 in a row gets None.
At a realistic scale, a 16 GiB cache pool for Llama 3 8B holds 131,072 token slots (16 GiB divided by 128 KiB). Reserve 8,192 slots per request and you admit 16 requests. Now suppose 500 requests arrive with lengths drawn uniformly between 100 and 2,000 tokens. This invented workload (a fixed random seed in show_numbers.py, every request counted at its final length) is not a measurement. The 16 admitted requests use 12.9% of the slots they reserved. The block layout of the next section, with 16 slots per block, admits 122 of the same stream, 7.6 times as many, and wastes 913 of 130,800 allocated slots (0.7%). This shows the memory effect, not a throughput benchmark.
The idea: a block table
An operating system keeps a page table that maps a program’s numbered virtual pages to physical pages, real chunks of memory that need not touch each other. Kwon et al. carry this over to keys and values: each request keeps a block table that says where its first, second and later blocks are stored.
- At start-up, cut the cache memory into blocks of
Btoken slots each. Allocate them once. - Keep a free list of blocks nobody uses.
- Give each request a block table: the physical blocks that hold its tokens, in order.
- To store a new token, write it into the request’s last block if that block has a free slot. Otherwise take a block from the free list and append it to the table.
- When a request finishes, put its blocks back on the free list.
This removes external waste, since any free block serves any request, and caps internal waste at the empty tail of a request’s last block, fewer than B slots.
Positions start at 0. Token position p lives in logical block p // B at slot p % B, and the physical block is table[p // B]. Figure 3 shows request A with 9 tokens and B = 4.
Figure 3. Request A’s block table, with B = 4. Token 8 sits in logical block 8 // 4 = 2, which the table sends to physical block 5. Black is A, orange is B, dark grey is C, and a paler shade is an empty slot.
The price is one extra lookup per block, and an attention routine that reads block by block. Kwon et al. chose 16 tokens as their default block size.
A hand-worked example
Use B = 4 and a pool of 8 blocks (32 slots). The free list starts as 0 to 7. New blocks come from the front, and freed blocks join the back. A prompt is the text a request starts with. A request needs a new block exactly when its token count is a multiple of 4, including 0. “Wasted” is allocated slots minus stored tokens.
| Step | Event | Tokens (A, B, C) | Block tables | Free list | Wasted slots |
|---|---|---|---|---|---|
| 0 | Start | none | none | 0 1 2 3 4 5 6 7 | 0 |
| 1 | A arrives with a 6-token prompt | 6, -, - | A: [0, 1] | 2 3 4 5 6 7 | 2 |
| 2 | B arrives with 3 tokens | 6, 3, - | A: [0, 1]; B: [2] | 3 4 5 6 7 | 3 |
| 3 | C arrives with 5 tokens | 6, 3, 5 | A: [0, 1]; B: [2]; C: [3, 4] | 5 6 7 | 6 |
| 4 | A generates 2 tokens | 8, 3, 5 | unchanged | 5 6 7 | 4 |
| 5 | A generates 1 token | 9, 3, 5 | A: [0, 1, 5]; B: [2]; C: [3, 4] | 6 7 | 7 |
| 6 | B generates 2 tokens | 9, 5, 5 | A: [0, 1, 5]; B: [2, 6]; C: [3, 4] | 7 | 9 |
| 7 | B finishes | 9, -, 5 | A: [0, 1, 5]; C: [3, 4] | 7 2 6 | 6 |
| 8 | C generates 4 tokens | 9, -, 9 | A: [0, 1, 5]; C: [3, 4, 7] | 2 6 | 6 |
- Step 1. Tokens 0 to 3 fill block 0. Tokens 4 and 5 take block 1 and leave 2 slots empty.
- Step 4. Positions 6 and 7 fit in block 1’s empty slots, so the free list does not change.
- Step 5. A has 8 tokens, a multiple of 4, so token 8 takes block 5, and A’s table becomes
[0, 1, 5]. - Step 6. B’s token 3 fills block 2. Its token 4 needs a new block and takes 6, leaving 7 as the only free block.
- Step 7. B finishes. Blocks 2 and 6 join the back of the free list, which reads 7, 2, 6. Nothing is copied or cleared. Attention reads only the slots a request’s length marks as valid, so stale data in a free block is harmless.
- Step 8. Three of C’s four new tokens fit in block 4. The fourth takes block 7 from the front, not 2.
Figure 4 shows steps 6 and 7.
Figure 4. The free list (a) before and (b) after B finishes. The letter under each block is its owner, and a paler shade is an empty slot. Dashed orange boxes are the blocks B freed, which join the back of the list.
The wasted column never exceeds 9 slots; show_numbers.py prints it, but no test pins it. The widget lets you replay this on a 64-slot pool, with live counts for both layouts.
The real implementation
The full file is code/paged_kv.py. It handles one attention head, so the logic stays visible.
The manager owns the pool, the free list, one table per request, and a reference count per block (used when blocks are shared).
class BlockManager:
def __init__(self, num_blocks, block_size, head_dim=1):
self.block_size = block_size
self.k_pool = np.zeros((num_blocks, block_size, head_dim))
self.v_pool = np.zeros((num_blocks, block_size, head_dim))
self.free_list = deque(range(num_blocks)) # take from the left, give back on the right
self.ref_count = [0] * num_blocks # how many block tables point at each block
self.tables = {} # request id -> physical block ids, in logical order
self.lengths = {} # request id -> tokens stored
Appending a token is the heart of it.
def append(self, rid, k, v):
table, n = self.tables[rid], self.lengths[rid]
offset = n % self.block_size
last_is_full = offset == 0 # also true when the table is empty
last_is_shared = not last_is_full and self.ref_count[table[-1]] > 1
if (last_is_full or last_is_shared) and not self.free_list:
raise OutOfBlocks(rid) # nothing has changed yet
if last_is_full:
table.append(self._take_block())
elif last_is_shared: # copy-on-write: give this request its own copy
old, new = table[-1], self._take_block()
self.k_pool[new], self.v_pool[new] = self.k_pool[old], self.v_pool[old]
self.ref_count[old] -= 1
table[-1] = new
self.k_pool[table[-1], offset] = k
self.v_pool[table[-1], offset] = v
self.lengths[rid] = n + 1
The check for a missing block comes first: if the pool is empty, the call raises OutOfBlocks and changes nothing. Finishing returns a block only once no table points at it.
def free(self, rid):
for block in self.tables.pop(rid):
self.ref_count[block] -= 1
if self.ref_count[block] == 0:
self.free_list.append(block)
del self.lengths[rid]
Reading through the table
Attention needs one score per earlier token. The code walks the block table: for logical block j it reads the first valid slots of physical block table[j], and valid is less than B in a partly full last block.
def paged_attention(q, mgr, rid):
n, size, table = mgr.lengths[rid], mgr.block_size, mgr.tables[rid]
scores = []
for j, block in enumerate(table):
valid = min(size, n - j * size) # the last block may be partly filled
scores.append(mgr.k_pool[block, :valid] @ q)
scores = np.concatenate(scores) / np.sqrt(len(q))
weights = np.exp(scores - scores.max())
weights /= weights.sum()
out = np.zeros_like(q, dtype=float)
for j, block in enumerate(table):
valid = min(size, n - j * size)
out += weights[j * size : j * size + valid] @ mgr.v_pool[block, :valid]
return out
The math is ordinary attention; only the addressing changes. Production systems do the same lookup inside a GPU kernel, a function that runs on the GPU.
Sharing a prefix with copy-on-write
Requests that start with the same text, such as a system prompt, have identical keys and values for it, so two block tables can name the same physical block. The rule: many requests may read a block, but one may write only into a block it owns alone. A reference count tracks the owners. Forking copies the parent’s table and adds one to each named block’s count, so no key or value moves. A request that must write into a shared block with room first copies it (copy-on-write); a full shared block is never written. Figure 5 shows a fork and the first writes.
Figure 5. Copy-on-write after a fork, with B = 4. Dark grey is shared by both requests, black is S1 only, orange is S2 only, and dashed is free. “refs” is the reference count, and a paler shade is an empty slot.
Here is the run from prefix_sharing_example with B = 4, a pool of 6 blocks, and a 6-token prompt.
| Step | Block tables | References (blocks 0, 1, 2) | Free list |
|---|---|---|---|
| S1 holds the prompt | S1: [0, 1] | 1, 1, 0 | 2 3 4 5 |
| S2 forks from S1 | S1: [0, 1]; S2: [0, 1] | 2, 2, 0 | 2 3 4 5 |
| S1 writes token 6 | S1: [0, 2]; S2: [0, 1] | 2, 1, 1 | 3 4 5 |
| S2 writes token 6 | S1: [0, 2]; S2: [0, 1] | 2, 1, 1 | 3 4 5 |
The fork allocated no block. S1 wrote first: block 1 was shared and had room, so S1 copied it into block 2 and wrote token 6 there. S2 then owned block 1 alone and wrote in place. Block 0 stays shared, one block instead of two.
Proof it works
The claim to test: scattered blocks give the same answer as one flat array. The test grows two requests in turn, so at block sizes 1 and 4 their blocks alternate in the pool, and it runs at block sizes 1, 4 and 16.
@pytest.mark.parametrize("block_size", [1, 4, 16])
def test_reading_through_the_table_equals_reading_one_array(block_size):
rng = np.random.default_rng(block_size)
mgr = BlockManager(num_blocks=64, block_size=block_size, head_dim=4)
for rid in ("x", "y"):
mgr.add_request(rid)
for i in range(13):
for rid, n in (("x", 13), ("y", 7)):
if i < n:
mgr.append(rid, rng.standard_normal(4), rng.standard_normal(4))
q = rng.standard_normal(4)
for rid in ("x", "y"):
keys, values = mgr.gather(rid)
assert np.allclose(paged_attention(q, mgr, rid), attention_contiguous(q, keys, values))
It fails if the code maps a position to the wrong block or slot, forgets that the last block is partly full, or reads a stale slot. A randomized test runs 400 mixed operations (add, append, fork, free) for each of five seeds and checks after each that no block is lost, double-booked or listed twice, every reference count matches the tables, and every request reads back what it wrote. One test checks that a copy-on-write append that cannot get a block changes nothing. Two more pin the output to literals: the two tables above and the capacity figures (16 and 122 requests, 913 wasted slots, 12.9% used). The wasted column is not pinned by a test; show_numbers.py prints it.
Run everything from the code/ folder:
python3 -m pytest -q .
The last line begins 15 passed. The tests run NumPy on a CPU. They show the logic is right, not that it is fast.
Where it is used
- vLLM and the paper behind it. Kwon et al. introduced PagedAttention and the open-source vLLM server at SOSP 2023. They report 2 to 4 times the throughput (requests served per second) of FasterTransformer and Orca, two earlier systems, at the same latency (time to answer one request). Its automatic prefix caching finds shared blocks across separate requests by hashing each block’s tokens together with the tokens before it, and caches full blocks only.
- SGLang. RadixAttention keeps cached prefixes in a radix tree, a close relative of the prefix sharing above.
- TensorRT-LLM. NVIDIA’s inference library documents a contiguous and a paged KV cache, with block sizes of 8 to 128 tokens for the paged one.
Skills this lesson builds
- Compute the KV-cache size of a model from its published config (layers, key/value heads, head size, number format) and compare it with GPU memory.
- Explain internal and external fragmentation, and why fixed-size blocks remove one and cap the other.
- Write a block allocator with a free list and per-request block tables, and test that no block is lost or double-booked.
- Implement copy-on-write with reference counts, and show when a copy happens and when it does not.
- Check an optimized read path against a plain reference (here, paged attention against one flat array).
Portfolio project: build a small block manager in Python with a randomized test of mixed operations. Chart how many requests it admits for block sizes 1 to 16, against a contiguous baseline, on an invented length distribution that you state. Label the result as a memory effect, not a speed benchmark.
Check yourself
- In the old way, every chat reserves a long stretch of memory. Why does memory run out even when most of that reserved space is empty, and how much can the new way waste per chat?
Answer
Reserved space belongs to its chat even while empty, so other chats cannot use it. Finished chats also leave holes too short for a new chat that needs one long stretch. With small equal blocks, a chat takes a block only when it needs one, and the only waste left is the unfilled end of its last block: always less than one block.
- Two chats start with the same long instructions. How can the new way avoid storing them twice, and what happens when one chat must add notes to a shared block that still has room?
Answer
Both chats’ block lists name the same blocks, so the notes exist once. A chat that must add notes to a shared block that still has room first copies it into a block of its own (copy-on-write) and writes there, and the other chat keeps the original.
- A model has 24 layers, 4 key/value heads, head size 64, and stores numbers in fp16 (2 bytes). How many bytes of cache does one token need, and how many does a 2,000-token request need?
Answer
2 x 24 x 4 x 64 x 2 = 24,576 bytes per token (24 KiB). For 2,000 tokens that is 49,152,000 bytes, about 46.9 MiB.
- Start from step 8 of the hand-worked table (A: [0, 1, 5], C: [3, 4, 7], free list 2 6), with a block size of 4. A new request D arrives with a 5-token prompt. Which blocks does D get, what is the free list afterward, how many of D’s slots are empty, and where does D’s token at position 4 live?
Answer
Five tokens need 2 blocks. D takes 2, then 6, from the front of the free list, so D’s table is [2, 6] and the free list is empty. Two blocks hold 8 slots, so 3 are empty. Position 4 is in logical block 4 // 4 = 1, at slot 4 % 4 = 0, which is physical block table[1], block 6.
- In the prefix-sharing run, why is block 1 copied but block 0 never copied?
Answer
A copy happens only when a request must write into a block that another request also uses. Block 1 was shared and had two empty slots, so the first writer needed its own copy. Block 0 was full, so nobody writes into it, and it stays shared.
Further reading
Gentle
- vLLM: Easy, Fast, and Cheap LLM Serving with PagedAttention (vLLM blog)
- How caching works (Hugging Face documentation)
- Automatic prefix caching (vLLM documentation)
- Optimization and tuning (vLLM documentation; the preemption section)
- Paging: Introduction (Operating Systems: Three Easy Pieces)
- NumPy quickstart (NumPy documentation)
Deep
- Efficient Memory Management for Large Language Model Serving with PagedAttention (Kwon et al.)
- PagedAttention kernel walk-through (vLLM documentation, historical)
- Prefix caching design (vLLM documentation)
- SGLang and RadixAttention (Zheng et al.)
- SGLang server arguments (SGLang documentation;
--page-size) - Attention and the contiguous and paged KV cache (TensorRT-LLM documentation)
- The Llama 3 Herd of Models
- Llama 3 8B model page (Meta; login needed for the files) and a public mirror of its config
- Attention Is All You Need (Vaswani et al.; section 3.2 on scaled dot-product attention)
Optional
Code
Each file runs on its own. Download it, or read it here.
Show code
"""The naive layout: one contiguous slab of slots per request, sized for the maximum length."""
class ContiguousPool:
"""A row of `n_slots` token slots. Each request gets one slab, first fit."""
def __init__(self, n_slots: int):
self.n_slots = n_slots
self.slabs = {} # request id -> (start, size)
self.used = {} # request id -> tokens actually stored
def allocate(self, rid, size: int):
"""Reserve `size` adjacent slots. Return the start slot, or None if no gap is big enough."""
for start, length in self.free_runs():
if length >= size:
self.slabs[rid] = (start, size)
self.used[rid] = 0
return start
return None
def release(self, rid) -> None:
del self.slabs[rid]
del self.used[rid]
def free_runs(self):
"""Gaps between slabs as (start, length), left to right."""
runs, cursor = [], 0
for start, size in sorted(self.slabs.values()):
if start > cursor:
runs.append((cursor, start - cursor))
cursor = start + size
if cursor < self.n_slots:
runs.append((cursor, self.n_slots - cursor))
return runs
def free_total(self) -> int:
return sum(length for _, length in self.free_runs())
def largest_free_run(self) -> int:
return max((length for _, length in self.free_runs()), default=0)
def internal_waste(self) -> int:
"""Reserved slots that hold no token."""
return sum(size - self.used[rid] for rid, (_, size) in self.slabs.items())
def requests_that_fit_contiguous(pool_tokens: int, max_len: int) -> int:
"""Each request reserves max_len slots, whatever its real length turns out to be."""
return pool_tokens // max_len
def requests_that_fit_paged(lengths, pool_tokens: int, block_size: int) -> dict:
"""Admit requests in order while their blocks fit. Returns counts and slot accounting."""
free_blocks = pool_tokens // block_size
admitted = used = 0
for n in map(int, lengths):
need = -(-n // block_size) # ceil(n / block_size)
if need > free_blocks:
break
free_blocks -= need
admitted += 1
used += n
allocated = (pool_tokens // block_size - free_blocks) * block_size
return {"admitted": admitted, "used": used, "allocated": allocated, "waste": allocated - used}Show code
"""How big the KV cache is, and what it saves.
Standard library plus numpy. Run `python3 show_numbers.py` to print every
number the lesson quotes.
"""
from dataclasses import dataclass
import numpy as np
@dataclass(frozen=True)
class ModelConfig:
name: str
n_layers: int
n_kv_heads: int # key/value heads, not query heads
head_dim: int
bytes_per_elem: int # 2 for 16-bit floats (fp16 or bf16)
max_tokens: int # longest sequence the model accepts
# Llama 3 8B: 32 layers, 8 key/value heads (grouped-query attention),
# hidden size 4096 / 32 query heads = head_dim 128, bf16, 8,192-token context.
LLAMA3_8B = ModelConfig("Llama 3 8B", 32, 8, 128, 2, 8192)
# OPT-13B: 40 layers, 40 heads of size 128 (hidden size 5120), fp16, 2,048 tokens.
OPT_13B = ModelConfig("OPT-13B", 40, 40, 128, 2, 2048)
def kv_bytes_per_token(cfg: ModelConfig) -> int:
"""2 (one key, one value) x layers x kv_heads x head_dim x bytes."""
return 2 * cfg.n_layers * cfg.n_kv_heads * cfg.head_dim * cfg.bytes_per_elem
def kv_bytes(cfg: ModelConfig, tokens: int) -> int:
return kv_bytes_per_token(cfg) * tokens
def pairs_without_cache(n_steps: int) -> int:
"""Step t must have keys and values for all t positions: 1 + 2 + ... + n."""
return n_steps * (n_steps + 1) // 2
def pairs_with_cache(n_steps: int) -> int:
"""Each step computes the key and value of the one new position."""
return n_steps
def _softmax(s):
e = np.exp(s - s.max())
return e / e.sum()
def decode_both_ways(n_steps: int, d: int = 8, seed: int = 0):
"""One attention head generating n_steps positions, with and without a cache.
Returns (outputs_without, outputs_with, pairs_without, pairs_with).
A "pair" is one key vector plus one value vector computed for one position.
The input vectors are fixed random numbers; a real model feeds each output
back in, but the counting is the same.
"""
rng = np.random.default_rng(seed)
w_q, w_k, w_v = rng.standard_normal((3, d, d)) / np.sqrt(d)
x = rng.standard_normal((n_steps, d))
out_a, pairs_a = [], 0
for t in range(1, n_steps + 1):
keys, values = x[:t] @ w_k, x[:t] @ w_v # recompute the whole past
pairs_a += t
q = x[t - 1] @ w_q
out_a.append(_softmax(keys @ q / np.sqrt(d)) @ values)
out_b, pairs_b = [], 0
k_cache, v_cache = [], []
for t in range(1, n_steps + 1):
k_cache.append(x[t - 1] @ w_k) # only the new position
v_cache.append(x[t - 1] @ w_v)
pairs_b += 1
q = x[t - 1] @ w_q
keys, values = np.array(k_cache), np.array(v_cache)
out_b.append(_softmax(keys @ q / np.sqrt(d)) @ values)
return np.array(out_a), np.array(out_b), pairs_a, pairs_bShow code
"""A paged KV cache for one attention head: block pool, free list, block tables, copy-on-write."""
from collections import deque
import numpy as np
class OutOfBlocks(Exception):
"""A block is needed and the free list is empty."""
class BlockManager:
def __init__(self, num_blocks: int, block_size: int, head_dim: int = 1):
self.block_size = block_size
# The pool: all physical memory, cut into fixed-size blocks, allocated once.
self.k_pool = np.zeros((num_blocks, block_size, head_dim))
self.v_pool = np.zeros((num_blocks, block_size, head_dim))
self.free_list = deque(range(num_blocks)) # take from the left, give back on the right
self.ref_count = [0] * num_blocks # how many block tables point at each block
self.tables = {} # request id -> physical block ids, in logical order
self.lengths = {} # request id -> tokens stored
def add_request(self, rid) -> None:
self.tables[rid] = []
self.lengths[rid] = 0 # allocates nothing
def _take_block(self) -> int:
block = self.free_list.popleft()
self.ref_count[block] = 1
return block
def append(self, rid, k: np.ndarray, v: np.ndarray) -> None:
"""Store the key and value of one new token at the end of request `rid`."""
table, n = self.tables[rid], self.lengths[rid]
offset = n % self.block_size
last_is_full = offset == 0 # also true when the table is empty
last_is_shared = not last_is_full and self.ref_count[table[-1]] > 1
if (last_is_full or last_is_shared) and not self.free_list:
raise OutOfBlocks(rid) # nothing has changed yet
if last_is_full:
table.append(self._take_block())
elif last_is_shared: # copy-on-write: give this request its own copy
old, new = table[-1], self._take_block()
self.k_pool[new], self.v_pool[new] = self.k_pool[old], self.v_pool[old]
self.ref_count[old] -= 1
table[-1] = new
self.k_pool[table[-1], offset] = k
self.v_pool[table[-1], offset] = v
self.lengths[rid] = n + 1
def fork(self, parent, child) -> None:
"""The child starts as the parent's twin. No data is copied: both tables name the same blocks."""
self.tables[child] = list(self.tables[parent])
self.lengths[child] = self.lengths[parent]
for block in self.tables[child]:
self.ref_count[block] += 1
def free(self, rid) -> None:
"""Finish a request. A block returns to the free list when no table points at it any more."""
for block in self.tables.pop(rid):
self.ref_count[block] -= 1
if self.ref_count[block] == 0:
self.free_list.append(block)
del self.lengths[rid]
def locate(self, rid, position: int):
"""Logical token position -> (physical block, slot inside the block)."""
return self.tables[rid][position // self.block_size], position % self.block_size
def gather(self, rid):
"""All keys and values of `rid` in logical order, shape [tokens, head_dim]."""
n, head_dim = self.lengths[rid], self.k_pool.shape[2]
keys, values = np.zeros((n, head_dim)), np.zeros((n, head_dim))
for pos in range(n):
block, slot = self.locate(rid, pos)
keys[pos], values[pos] = self.k_pool[block, slot], self.v_pool[block, slot]
return keys, values
def paged_attention(q: np.ndarray, mgr: BlockManager, rid) -> np.ndarray:
"""One query vector attends over request `rid`, reading the cache one block at a time."""
n, size, table = mgr.lengths[rid], mgr.block_size, mgr.tables[rid]
scores = []
for j, block in enumerate(table):
valid = min(size, n - j * size) # the last block may be partly filled
scores.append(mgr.k_pool[block, :valid] @ q)
scores = np.concatenate(scores) / np.sqrt(len(q))
weights = np.exp(scores - scores.max())
weights /= weights.sum()
out = np.zeros_like(q, dtype=float)
for j, block in enumerate(table):
valid = min(size, n - j * size)
out += weights[j * size : j * size + valid] @ mgr.v_pool[block, :valid]
return out
def attention_contiguous(q: np.ndarray, keys: np.ndarray, values: np.ndarray) -> np.ndarray:
"""Reference: the same attention over keys and values already laid out in one array."""
scores = keys @ q / np.sqrt(len(q))
weights = np.exp(scores - scores.max())
return (weights / weights.sum()) @ values
def worked_example():
"""The lesson's hand-worked example: block size 4, a pool of 8 blocks, requests A, B and C.
Returns a list of (event, tables, free_list, lengths), one per step.
"""
mgr = BlockManager(num_blocks=8, block_size=4)
steps = []
def grow(rid, tokens):
for _ in range(tokens):
n = mgr.lengths[rid]
mgr.append(rid, np.array([n]), np.array([n]))
def snap(event):
steps.append((event, {r: list(t) for r, t in mgr.tables.items()}, list(mgr.free_list),
dict(mgr.lengths)))
snap("start")
for rid, prompt in (("A", 6), ("B", 3), ("C", 5)):
mgr.add_request(rid)
grow(rid, prompt)
snap(f"{rid} arrives with a {prompt}-token prompt")
grow("A", 2)
snap("A generates 2 tokens")
grow("A", 1)
snap("A generates 1 more token")
grow("B", 2)
snap("B generates 2 tokens")
mgr.free("B")
snap("B finishes")
grow("C", 4)
snap("C generates 4 tokens")
return steps
def prefix_sharing_example():
"""Block size 4, a 6-token prompt shared by two samples. Returns (event, tables, ref_counts, free_list)."""
mgr = BlockManager(num_blocks=6, block_size=4)
steps = []
def snap(event):
steps.append((event, {r: list(t) for r, t in mgr.tables.items()}, list(mgr.ref_count),
list(mgr.free_list)))
def one(rid):
n = mgr.lengths[rid]
mgr.append(rid, np.array([n]), np.array([n]))
mgr.add_request("S1")
for _ in range(6):
one("S1")
snap("S1 holds the 6-token prompt")
mgr.fork("S1", "S2")
snap("S2 forks from S1")
one("S1")
snap("S1 writes token 6")
one("S2")
snap("S2 writes token 6")
return stepsShow code
"""Print every number the lesson quotes. Run: python3 show_numbers.py"""
import numpy as np
from contiguous import ContiguousPool, requests_that_fit_contiguous, requests_that_fit_paged
from kv_sizes import (LLAMA3_8B, OPT_13B, decode_both_ways, kv_bytes, kv_bytes_per_token,
pairs_with_cache, pairs_without_cache)
from paged_kv import prefix_sharing_example, worked_example
GIB = 1024**3
def main():
print("== steps with and without a cache")
for n in (10, 100, 1000):
print(f"n={n}: without={pairs_without_cache(n)} with={pairs_with_cache(n)} "
f"ratio={pairs_without_cache(n) / pairs_with_cache(n):.1f}")
out_a, out_b, pa, pb = decode_both_ways(1000)
print(f"demo n=1000: counted without={pa} with={pb} outputs equal={np.allclose(out_a, out_b)}")
print("== sizes")
for cfg in (LLAMA3_8B, OPT_13B):
per = kv_bytes_per_token(cfg)
print(f"{cfg.name}: {per} bytes/token = {per / 1024:.0f} KiB; "
f"full context ({cfg.max_tokens}) = {kv_bytes(cfg, cfg.max_tokens)} bytes = "
f"{kv_bytes(cfg, cfg.max_tokens) / GIB:.4f} GiB")
print("Llama 3 8B, 1000 tokens:", kv_bytes(LLAMA3_8B, 1000) / 1024**2, "MiB")
print("Llama 3 8B, 131072 tokens:", kv_bytes(LLAMA3_8B, 131072) / GIB, "GiB")
print("== contiguous slabs (figure 1): 32 slots")
pool = ContiguousPool(32)
pool.allocate("R1", 12); pool.allocate("R2", 8); pool.allocate("R3", 8)
pool.used.update(R1=5, R2=3, R3=6)
print("before: internal waste", pool.internal_waste(), "runs", pool.free_runs())
pool.release("R2")
print("after R2: runs", pool.free_runs(), "free_total", pool.free_total(),
"largest", pool.largest_free_run(), "internal waste", pool.internal_waste(),
"D(12) ->", pool.allocate("D", 12))
eq = ContiguousPool(32)
print("12 reserved each:", [eq.allocate(r, 12) for r in "ABC"])
print("== capacity: 16 GiB of cache, Llama 3 8B, max_len 8192, block size 16")
pool_tokens = 16 * GIB // kv_bytes_per_token(LLAMA3_8B)
lengths = np.random.default_rng(0).integers(100, 2001, size=500)
fit_c = requests_that_fit_contiguous(pool_tokens, 8192)
paged = requests_that_fit_paged(lengths, pool_tokens, 16)
print("pool tokens", pool_tokens, "mean length", lengths.mean())
print("contiguous admits", fit_c, "-> utilisation of reserved slots",
f"{lengths[:fit_c].sum() / (fit_c * 8192):.3f}")
print("paged", paged, f"waste fraction of allocated {paged['waste'] / paged['allocated']:.4f}",
f"gain {paged['admitted'] / fit_c:.1f}x")
print("== worked example")
for event, tables, free, lengths_ in worked_example():
used = sum(lengths_.values())
allocated = 4 * sum(len(t) for t in tables.values())
print(event, tables, free, lengths_, f"used={used} allocated={allocated} wasted={allocated - used}")
print("== prefix sharing")
for row in prefix_sharing_example():
print(*row)
if __name__ == "__main__":
main()Show code
import numpy as np
from contiguous import ContiguousPool, requests_that_fit_contiguous, requests_that_fit_paged
def test_free_memory_in_two_gaps_cannot_hold_one_big_request():
"""External fragmentation: 12 slots are free, but never 12 in a row."""
pool = ContiguousPool(32)
for rid, size in (("R1", 12), ("R2", 8), ("R3", 8)):
pool.allocate(rid, size)
pool.used.update(R1=5, R2=3, R3=6)
pool.release("R2")
assert pool.free_runs() == [(12, 8), (28, 4)]
assert pool.free_total() == 12 and pool.largest_free_run() == 8
assert pool.allocate("D", 12) is None
assert pool.internal_waste() == (12 - 5) + (8 - 6) # reserved slots that hold no token
def test_equal_reservations_turn_the_third_request_away():
"""Reserving 12 slots each: the lesson's 32-slot pool fits two requests, the paged pool fits all three."""
pool = ContiguousPool(32)
assert [pool.allocate(r, 12) for r in "ABC"] == [0, 12, None]
def test_paged_waste_is_under_one_block_per_request():
lengths = np.random.default_rng(1).integers(1, 500, size=300)
result = requests_that_fit_paged(lengths, pool_tokens=20000, block_size=16)
assert result["admitted"] > requests_that_fit_contiguous(20000, 2048)
assert 0 <= result["waste"] < result["admitted"] * 16 # rounding down would make it negative
def test_lesson_capacity_example_matches_the_text():
"""16 GiB of Llama 3 8B cache is 131,072 slots; seed 0 draws 500 lengths from 100 to 2,000."""
pool_tokens = 131072
lengths = np.random.default_rng(0).integers(100, 2001, size=500)
fit_contiguous = requests_that_fit_contiguous(pool_tokens, 8192)
paged = requests_that_fit_paged(lengths, pool_tokens, block_size=16)
assert fit_contiguous == 16
assert round(lengths[:fit_contiguous].sum() / (fit_contiguous * 8192), 3) == 0.129
assert (paged["admitted"], paged["allocated"], paged["waste"]) == (122, 130800, 913)Show code
import numpy as np
from kv_sizes import (LLAMA3_8B, OPT_13B, decode_both_ways, kv_bytes, kv_bytes_per_token,
pairs_with_cache, pairs_without_cache)
def test_formula_matches_public_configurations():
# Llama 3 8B: 2 x 32 layers x 8 kv heads x 128 x 2 bytes = 128 KiB per token
assert kv_bytes_per_token(LLAMA3_8B) == 131072
assert kv_bytes(LLAMA3_8B, 8192) == 1024**3 # exactly 1 GiB at the full 8,192-token context
# The PagedAttention paper computes OPT-13B as 2 x 5120 x 40 x 2 bytes = 800 KB per token
assert kv_bytes_per_token(OPT_13B) == 2 * 5120 * 40 * 2 == 819200
def test_cache_changes_the_cost_not_the_answer():
"""A cache that reads the wrong rows or is off by one would change the outputs."""
n = 60
without, with_cache, pairs_a, pairs_b = decode_both_ways(n)
assert np.allclose(without, with_cache)
assert (pairs_a, pairs_b) == (pairs_without_cache(n), pairs_with_cache(n)) == (n * (n + 1) // 2, n)Show code
import math
import random
import numpy as np
import pytest
from paged_kv import (BlockManager, OutOfBlocks, attention_contiguous, paged_attention,
prefix_sharing_example, worked_example)
D = 3
def vec(rng):
return rng.standard_normal(D)
class Reference:
"""No blocks: one Python list of (key, value) per request."""
def __init__(self):
self.seqs = {}
def check(mgr, ref, rid):
keys, values = mgr.gather(rid)
want_k = np.array([k for k, _ in ref.seqs[rid]]).reshape(-1, D)
want_v = np.array([v for _, v in ref.seqs[rid]]).reshape(-1, D)
assert np.allclose(keys, want_k) and np.allclose(values, want_v), f"request {rid} reads wrong data"
def test_blocks_in_use_is_ceil_of_length_over_block_size():
rng = np.random.default_rng(0)
mgr = BlockManager(num_blocks=10, block_size=4, head_dim=D)
mgr.add_request("s")
assert len(mgr.free_list) == 10 # a new request holds no block
for n in range(1, 18):
mgr.append("s", vec(rng), vec(rng))
assert len(mgr.tables["s"]) == math.ceil(n / 4)
assert len(mgr.free_list) == 10 - math.ceil(n / 4)
def test_gather_follows_the_table_not_physical_order():
"""Two requests grow in turn, so their blocks interleave in the pool."""
rng = np.random.default_rng(1)
mgr, ref = BlockManager(8, 4, D), Reference()
for rid in "ab":
mgr.add_request(rid)
ref.seqs[rid] = []
for _ in range(9):
for rid in "ab":
k, v = vec(rng), vec(rng)
mgr.append(rid, k, v)
ref.seqs[rid].append((k, v))
assert mgr.tables["a"] == [0, 2, 4] and mgr.tables["b"] == [1, 3, 5] # interleaved in the pool
check(mgr, ref, "a")
check(mgr, ref, "b")
def test_out_of_blocks_changes_nothing_even_for_copy_on_write():
rng = np.random.default_rng(2)
mgr = BlockManager(num_blocks=2, block_size=4, head_dim=D)
mgr.add_request("p")
for _ in range(6):
mgr.append("p", vec(rng), vec(rng)) # both blocks in use, last one partial
mgr.fork("p", "c")
before = (dict(mgr.tables), list(mgr.ref_count), list(mgr.free_list), dict(mgr.lengths))
with pytest.raises(OutOfBlocks):
mgr.append("c", vec(rng), vec(rng)) # needs a copy, no block is free
assert (dict(mgr.tables), list(mgr.ref_count), list(mgr.free_list), dict(mgr.lengths)) == before
def test_copy_on_write_does_not_leak_into_the_sibling():
rng = np.random.default_rng(3)
mgr, ref = BlockManager(8, 4, D), Reference()
mgr.add_request("p")
ref.seqs["p"] = []
for _ in range(6):
k, v = vec(rng), vec(rng)
mgr.append("p", k, v)
ref.seqs["p"].append((k, v))
mgr.fork("p", "c")
ref.seqs["c"] = list(ref.seqs["p"])
for rid in ("c", "p", "c"): # either side may write first
k, v = vec(rng), vec(rng)
mgr.append(rid, k, v)
ref.seqs[rid].append((k, v))
check(mgr, ref, "p")
check(mgr, ref, "c")
assert mgr.tables["p"][0] == mgr.tables["c"][0] # the full block is still shared
def test_random_operations_match_the_reference_and_never_lose_a_block():
"""Guards double frees and leaked blocks across add, append, fork and free."""
for seed in range(5):
rnd, rng = random.Random(seed), np.random.default_rng(seed)
mgr, ref, next_id = BlockManager(12, 4, D), Reference(), 0
for _ in range(400):
op = rnd.choice(["add", "append", "append", "append", "fork", "free"])
live = list(ref.seqs)
if op == "add" or not live:
mgr.add_request(next_id)
ref.seqs[next_id] = []
next_id += 1
elif op == "append":
rid = rnd.choice(live)
k, v = vec(rng), vec(rng)
try:
mgr.append(rid, k, v)
ref.seqs[rid].append((k, v))
except OutOfBlocks:
pass
elif op == "fork":
rid = rnd.choice(live)
mgr.fork(rid, next_id)
ref.seqs[next_id] = list(ref.seqs[rid])
next_id += 1
else:
rid = rnd.choice(live)
mgr.free(rid)
del ref.seqs[rid]
held = [b for t in mgr.tables.values() for b in t]
assert sorted(set(held) | set(mgr.free_list)) == list(range(12)), "a block was lost"
assert not set(held) & set(mgr.free_list), "a block is both in use and free"
assert len(set(mgr.free_list)) == len(mgr.free_list), "a block is on the free list twice"
assert all(mgr.ref_count[b] == held.count(b) for b in range(12))
for rid in ref.seqs:
check(mgr, ref, rid)
@pytest.mark.parametrize("block_size", [1, 4, 16])
def test_reading_through_the_table_equals_reading_one_array(block_size):
"""The point of the design: scattered blocks give the same attention output."""
rng = np.random.default_rng(block_size)
mgr = BlockManager(num_blocks=64, block_size=block_size, head_dim=4)
for rid in ("x", "y"): # grown in turn below, so blocks are scattered
mgr.add_request(rid)
for i in range(13):
for rid, n in (("x", 13), ("y", 7)):
if i < n:
mgr.append(rid, rng.standard_normal(4), rng.standard_normal(4))
q = rng.standard_normal(4)
for rid in ("x", "y"):
keys, values = mgr.gather(rid)
assert np.allclose(paged_attention(q, mgr, rid), attention_contiguous(q, keys, values))
def test_worked_example_matches_the_lesson_tables():
"""The tables printed in the lesson text. If the code changes, the lesson must change."""
rows = [(tables, free) for _, tables, free, _ in worked_example()]
assert rows == [
({}, [0, 1, 2, 3, 4, 5, 6, 7]),
({"A": [0, 1]}, [2, 3, 4, 5, 6, 7]),
({"A": [0, 1], "B": [2]}, [3, 4, 5, 6, 7]),
({"A": [0, 1], "B": [2], "C": [3, 4]}, [5, 6, 7]),
({"A": [0, 1], "B": [2], "C": [3, 4]}, [5, 6, 7]),
({"A": [0, 1, 5], "B": [2], "C": [3, 4]}, [6, 7]),
({"A": [0, 1, 5], "B": [2, 6], "C": [3, 4]}, [7]),
({"A": [0, 1, 5], "C": [3, 4]}, [7, 2, 6]),
({"A": [0, 1, 5], "C": [3, 4, 7]}, [2, 6]),
]
steps = prefix_sharing_example()
assert [(t, r[:3], f) for _, t, r, f in steps] == [
({"S1": [0, 1]}, [1, 1, 0], [2, 3, 4, 5]),
({"S1": [0, 1], "S2": [0, 1]}, [2, 2, 0], [2, 3, 4, 5]),
({"S1": [0, 2], "S2": [0, 1]}, [2, 1, 1], [3, 4, 5]),
({"S1": [0, 2], "S2": [0, 1]}, [2, 1, 1], [3, 4, 5]),
]