Why AI servers waste memory, and how paging fixes it

An AI server keeps working notes for every chat. Storing them in small equal blocks, not one big reserved stretch, lets one GPU serve far more chats.

  • 6 min read
  • 14 min to go deeper
On this page
  1. In one minute
  2. Explore the numbers
  3. How it works
  4. What this means when you build
  5. Go deeperoptional
  6. Skills this lesson builds
  7. Check yourself
  8. Further reading
  9. Codeoptional

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.

Two ways to hand out the same 20 memory slots among three chats. The old way reserves 8 slots each for chats 1 and 2, wastes 8 and leaves chat 3 waiting. The new way uses blocks of 2 slots, fits all three chats and wastes 2.

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 (not num_attention_heads), the head size (hidden_size divided by num_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_utilization or lowering max_num_seqs gives 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.

Two strips of 32 slots. Top: three partly empty buffers. Bottom: after R2 finishes, free gaps of 8 and 4, and a request needing 12 in a row is refused.

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.

  1. At start-up, cut the cache memory into blocks of B token slots each. Allocate them once.
  2. Keep a free list of blocks nobody uses.
  3. Give each request a block table: the physical blocks that hold its tokens, in order.
  4. 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.
  5. 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.

Request A’s 9 tokens in logical blocks 0, 1 and 2, mapped by its block table to physical blocks 0, 1 and 5 in a pool of eight.

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.

A pool of eight blocks before and after B finishes: the free list grows from 7 to 7, 2, 6.

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.

Two requests share blocks 0 and 1 after a fork. After both write, only block 1 has been copied.

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

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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

Deep

Optional

Code

Each file runs on its own. Download it, or read it here.

contiguous.py64 linesDownload contiguous.py
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}
kv_sizes.py80 linesDownload kv_sizes.py
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_b
paged_kv.py161 linesDownload paged_kv.py
Show 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 steps
show_numbers.py63 linesDownload show_numbers.py
Show 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()
test_contiguous.py40 linesDownload test_contiguous.py
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)
test_kv_sizes.py20 linesDownload test_kv_sizes.py
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)
test_paged_kv.py167 linesDownload test_paged_kv.py
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]),
    ]