WORKBENCH
05 · InferenceLesson 5.6

Paged attention and prefix caching.

How do servers share KV cache between requests with the same prefix, and when do they evict it?

Listen · 15 min0:00 / 14:37

Before a model writes a single word of its answer, it reads your whole prompt and builds a set of notes about it in GPU memory, one entry per word-piece. Prefix caching means keeping those notes after the answer is done, so the next request that starts with the same text can pick up where the last one stopped instead of reading it all over again.

This matters because almost nothing you send is new. Your agents resend the same long instructions on every step, and every chat turn resends the whole conversation. Without a cache, your Spark server spends about a second rereading text it saw a moment ago, every single time. It is also why Anthropic and OpenAI charge less for input they have seen recently, and part of why Fireworks can look faster than you on repeated prompts.

You already know where the notes come from (The KV cache, by hand) and how an operating system chops memory into small numbered pieces it can hand out and share (Virtual memory). Here those two ideas meet, inside the server from Baseline: my Spark vs Fireworks. After this, the memory managers inside vLLM and SGLang will look familiar, because you will have built a small one.

Today

You need the server, harness and SCOREBOARD.md from Baseline: my Spark vs Fireworks, plus pages, page tables and copy-on-write from Virtual memory and the OOM killer. The KV cache, by hand and Batching help, but everything needed from them is recapped here. Plan on two evenings: one for the idea and the cache, one for wiring it in and measuring.

The Problem

Your agent has a system prompt. Tool descriptions, rules, a few worked examples: about 2,000 tokens, and it never changes. Each step of the agent sends that same system prompt, plus the conversation so far, plus one new line.

Watch what your server does with it. Every request, it runs prefill over all 2,000 tokens of system prompt, computes exactly the same keys and values it computed a second ago, uses them for one answer, and throws them away. From the baseline lesson you have a rough number for this: prefill of 2,000 tokens on the Spark is on the order of a second. So every agent step pays about a second of time to first token to rediscover something it already knew.

It gets worse with chat. Turn 10 of a conversation resends turns 1 to 9. The prompt grows every turn, and the work your server repeats grows with it.

And there is a second, quieter problem, about memory. When you batch requests (the next step after the naive server), each one needs room for its KV cache. How much room? You don’t know how long the answer will be. So the obvious design reserves room for the longest possible one. Most requests use a fraction of it. The GPU fills up with reserved, empty memory, and you can fit far fewer users than the hardware could hold.

Both problems have the same fix: stop treating a request’s KV cache as one private, contiguous lump. Cut it into small fixed-size blocks, keep a table of where each block lives, and let requests point at the same block when their tokens are the same.

The Idea

How big is one request’s KV cache?

Recap. A transformer layer turns each token into a query, a key and a value. To produce the next token, the new token’s query is compared against the keys of every earlier token, and the matching values are mixed together. The KV cacheThe stored keys and values of every token processed so far, for every layer. Kept so that each new token only computes its own K and V instead of recomputing everyone’s. keeps those keys and values around, so each decode step only computes one new token’s worth.

Now the bytes, for Llama 3.1 8B in bf16. It has 32 layers. It uses grouped-query attentionGQA. Many query heads share a smaller set of key/value heads. Llama 3.1 8B has 32 query heads but only 8 KV heads, which cuts the KV cache 4 times. with 8 key/value heads, each 128 numbers wide. Per token:

2 (K and V) × 32 layers × 8 heads × 128 dims × 2 bytes = 131,072 bytes = 128 KiB per token

So a 2,000-token system prompt is about 256 MiB of KV cache. A full 128k-token context is 16 GiB, the same size as the weights. How much memory does a model need? does this carefully; for today, remember 128 KiB per token, and that one prompt’s KV is hundreds of megabytes.

Predict firstwrite it down

You batch 16 users on the Spark. To be safe, each request reserves KV room for 8,192 tokens. In practice the average request ends at about 1,000 tokens of prompt plus answer. How much memory is reserved, how much holds real data, and what fraction is wasted?

Reveal

Reserved: 16 × 8,192 tokens × 128 KiB = 16 GiB. Used: 16 × 1,000 × 128 KiB ≈ 2 GiB. About 87% of the reservation is empty. On a data-center GPU with 80 GB of memory, 16 GB of weights and 64 GB for KV, that rule fits 64 users when the real data would fit about 500. The waste is the reason “how many users fit” comes out so small.

Reserving the worst case

There are three ways memory gets wasted when each sequence gets one contiguous slab:

FIG_001[ SLABS VS BLOCKS ]
RESERVE MAX LENGTH PER SEQUENCES1S2FREE: 160RESERVED, NEVER USEDRESERVED, NEVER USEDS3 NEEDS 220: WON'T FITUSED 200 OF 600 · S3 IS REFUSED EVEN THOUGH 400 ARE EMPTYPAGED: 16-TOKEN BLOCKS, ANYWHERE122312321232323LAST BLOCK, PART FULLS3 FITS · 15 STILL FREEWASTE: UNDER ONE BLOCK PER SEQUENCE · ROOM FOR S3 AND MORE
Top: each sequence reserves a contiguous slab for its maximum length. Most of each slab is empty, and the leftover free space is one hole too small for a third sequence. Bottom: the same sequences in 16-token blocks placed anywhere. The only waste is the unfilled end of each sequence's last block.

You have seen this exact problem before. In Virtual memory, physical RAM is cut into 4 KiB frames and every process gets a page tableA per-process map from virtual page numbers to physical frames. The CPU’s MMU walks it on every memory access (with the TLB as a cache) so a process can treat scattered frames as one contiguous range. that maps its pages to frames anywhere. No process needs a contiguous chunk of physical RAM. A frame is handed out only when a page is first touched. Pages can be shared between processes, and copied only when someone writes.

PagedAttention is that, for KV caches.

PagedAttention: pages for attention

Cut the KV memory into fixed-size KV blocksFixed-size chunks of KV cache memory, each holding the keys and values of a fixed number of tokens (16 by default in vLLM) for every layer. The unit of allocation, sharing and eviction., each holding the keys and values for 16 tokens (vLLM’s default). For Llama 3.1 8B a block is 16 × 128 KiB = 2 MiB. At startup, the server allocates one big pool of blocks with whatever memory is left after the weights, and keeps a list of the free ones.

Each sequence then has a block tableA small array per sequence: entry i is the physical block that holds the sequence’s tokens 16i to 16i+15. The KV-cache version of a page table.. Logical block 0 (tokens 0 to 15) lives in physical block 7; logical block 1 (tokens 16 to 31) in physical block 1; and so on. When a sequence fills its last block, the server grabs any free block and appends one entry to the table. No reservation in advance, no need for neighbours.

The part that needed new code is the attention computation itself. The usual attention kernel assumes a sequence’s keys sit in one contiguous tensor. The PagedAttention kernel takes the block table as an extra input and, for each block of keys, looks up where it lives before reading it. That costs a little indirection per block, the same deal the CPU makes with page tables. In return, memory waste drops to less than one block per sequence: the paper reports under 4%.

The translation is direct:

Virtual memoryPagedAttention
processsequence (one request, or one sample of it)
page (4 KiB)KV block (16 tokens)
physical framephysical block in the KV pool
page tableblock table
lazy allocation on first touchallocate a block when the last one fills
shared pages after fork, copy-on-writeshared blocks, copy-on-write

Sharing inside one request

Once a sequence is a list of pointers, two sequences can point at the same block.

The first place this pays off is parallel samplingAsking for several independent answers to one prompt (the n parameter in OpenAI-style APIs). All samples start from the same prompt, so their prompt KV is identical.. Ask for four samples of a 2,000-token prompt and the naive server stores four identical copies of 256 MiB of prompt KV. With blocks, all four block tables point at the same prompt blocks, and each block carries a reference countA counter on a shared object: how many users currently point at it. Incremented when someone starts using it, decremented when they stop. At zero, nobody needs it. of 4.

What happens when a sample writes? The prompt’s last block is usually only partly full: 2,000 tokens is 125 full blocks exactly, but a 2,008-token prompt leaves 8 tokens in block 125 and 8 empty slots. Each sample’s first generated token wants to go into those empty slots, and each sample generates a different token. They can’t all write into one shared block.

So the server does what the kernel does after fork: copy-on-writeSharing a block read-only and copying it only at the moment one of the sharers needs to write to it. Only the blocks that actually differ get duplicated.. Before a sample writes into a block whose reference count is above 1, the server copies that block to a fresh one, points the sample’s table at the copy, and drops the old block’s count by one. Every full prompt block stays shared forever.

FIG_002[ BLOCK TABLES, SHARED BLOCKS ]
LOGICAL BLOCKSBLOCK TABLESPHYSICAL KV POOLSAMPLE 1L0TOK 0-150 → P7L1TOK 16-311 → P1L2TOK 32-472 → P3SAMPLE 2L0TOK 0-150 → P7L1TOK 16-311 → P1L2TOK 32-472 → P5P0freeP1PROMPT 16-31 · REF 2P2freeP332-39 + S1 OUT · REF 1P4freeP532-39 + S2 OUT · REF 1P6freeP7PROMPT 0-15 · REF 2P5 = COPY OF P3, MADE ON S2'S FIRST WRITE
Two samples of one 40-token prompt. Logical blocks 0 and 1 of both samples point at the same physical blocks (P7, P1), each with reference count 2. Logical block 2 was partly prompt, partly output: when sample 2 first wrote to it, the server copied P3 into P5.

beam searchA decoding method that keeps the k most likely partial answers at every step, extends each, and keeps the best k again. Beams constantly split and die, so they share long common histories. is the extreme case. Beams branch off each other constantly, so their histories overlap almost entirely. With reference counts, a beam that dies just decrements its blocks’ counts; a block whose count reaches zero goes back to the free list. The paper reports large memory savings for beam search from this sharing alone.

Predict firstwrite it down

Prompt: 2,008 tokens. You ask for n = 4 samples of 256 tokens each, with 16-token blocks and Llama 3.1 8B. How many blocks does the naive approach (four private copies) use, and how many does PagedAttention with sharing use?

Reveal

Each sample holds 2,008 + 256 = 2,264 tokens, which is 141.5 blocks, so 142. Four private copies: 568 blocks, about 1.1 GiB.

Shared: the prompt’s 125 full blocks (2,000 tokens) are stored once. The partly filled block 125 holds the last 8 prompt tokens. On their first writes, three samples each copy it (the last one left, now at reference count 1, writes into the original), so 4 blocks. After that, each sample’s remaining 248 tokens fill 15.5 more blocks, so 16 each. Total 125 + 4 + 4 × 16 = 193 blocks, about 386 MiB. A third of the memory, for identical results.

Sharing across requests: prefix caching

Here is the leap. Within one request, the samples share a prompt because they are the same prompt. But two different requests from two different users can share a prompt too: the same system prompt, the same few-shot examples, the same document, the same conversation history.

Can request B reuse request A’s KV blocks? Only for a prefixThe first n tokens of a sequence. Two prompts share a prefix of length n if their first n tokens are identical., and here is why. The key and value for token i at layer 2 depend on the output of layer 1 at token i, which, through attention, depends on every token before it. And with RoPERotary position embeddings. Each key is rotated by an angle that depends on its position, so the same word at position 5 and at position 500 has different keys., each key also depends on its position. So token i’s KV is a function of tokens 0 to i, at positions 0 to i. Nothing after it matters, because of the causal maskThe rule in decoder attention that a token may only attend to itself and tokens before it, never after. It is why generation works left to right.. Nothing else does either.

That gives a crisp rule: if two prompts agree on their first n tokens, their KV for those n tokens is identical. Not similar. Identical, up to floating-point rounding. Request B can skip prefill for those n tokens and only run the rest.

Predict firstwrite it down

A RAG app builds prompts as: system prompt, then a retrieved document, then the question. Two requests use the same system prompt and the same document, but the document appears after a different date line in each (“Today is Oct 14” vs “Today is Oct 15”), which sits between the system prompt and the document. How much can prefix caching reuse for the second request?

Reveal

Only up to the date line. The document’s tokens are identical, but they come after a token that differs, so their keys and values differ: each document token attended to a different date. The cache matches the longest identical prefix, and the prefix ends at the first differing token. Moving the date line to the end of the prompt (after the document) would make the whole system prompt and document reusable. Prompt layout decides your hit rate. Systems that try to reuse chunks in the middle of a prompt (CacheBlend and others) exist, but they have to recompute part of the attention to correct for the different past, and the results are approximate.

Naming a block: the hash chain

To reuse a block, the server needs to find it. It needs a name for “the KV of these 16 tokens, given everything before them”, which it can compute from the new request’s tokens alone, without running the model.

vLLM’s answer: hash each full block together with the hash of the block before it.

key_0 = H(root,  tokens 0-15)
key_1 = H(key_0, tokens 16-31)
key_2 = H(key_1, tokens 32-47)

Because each key includes the previous key, key_2 depends on tokens 0 to 47, exactly like the KV it names. The same 16 tokens after a different past get a different key. It is the same chaining idea as a git commit hash, which covers the parent commit’s hash and so the whole history.

FIG_003[ THE HASH CHAIN ]
REQUEST 1SYS 0-15h1 · MISSSYS 16-31h2 · MISSSYS 32-47h3 · MISSUSER: WEATHER?h4 · MISSTAILNOT CACHEDREQUEST 2SYS 0-15h1 · HITSYS 16-31h2 · HITSYS 32-47h3 · HITUSER: CODE?h4' · MISSTAILNOT CACHEDh1 = H(ROOT, tok 0-15) h2 = H(h1, tok 16-31) h3 = H(h2, tok 32-47)A KEY NAMES A BLOCK AND ALL BEFORE IT. SAME TOKENS, NEW PAST: NEW KEY.
Each full block's key is a hash of its tokens and the previous block's key. Request 2 computes keys h1, h2, h3, finds them in the cache, and stops at its first miss. The partial tail block is never cached: it isn't finished, and its key would change as soon as one more token arrived.

The cache is then just a hash tableA dictionary: a structure that finds a value from its key in roughly constant time, by turning the key into an array position. Python’s dict is one. from key to physical block. A cache lookupAsking a cache “do you have this?” and getting back either the stored thing (a hit) or nothing (a miss), in which case you compute it yourself. for a new request:

  1. Tokenize the prompt. Cut it into full 16-token blocks; ignore the partial tail.
  2. Compute the keys left to right.
  3. Walk them: if key_0 is in the table, that’s a hit; try key_1; keep going until the first miss. Everything after the first miss is a miss even if it happens to be in the table, because a block is only usable if every block before it is too. (In practice, with chained keys, a later block can’t be present without its parents unless they were evicted.)
  4. Point the request’s block table at the hit blocks. Run prefill only on the tokens after them.

One catch: the model has to run on at least one prompt token, because the last prompt position’s output is what produces the first new token. If the entire prompt is cached, the server still recomputes the last token. vLLM does exactly this.

A few details matter in production, and each is a good interview answer:

The tree version: RadixAttention

A hash table knows “this block exists”. It doesn’t know the shape of what is cached: which prefixes branch from which. SGLang’s RadixAttentionSGLang’s prefix cache. Stores all cached prefixes as a radix tree whose edges are runs of tokens, so the longest cached prefix of a new prompt is found by walking down the tree. keeps that shape explicitly as a radix treeA compressed prefix tree (trie). Each edge is labelled with a run of tokens rather than one token, and a node exists only where two cached sequences branch apart..

FIG_004[ A RADIX TREE OF PREFIXES ]
ROOTSYS PROMPT A2,048 TOK · REF 2SYS PROMPT B1,800 TOK · REF 0USER: Q1REF 1 · NOWTURN 1 + REPLYREF 1 · NOWUSER: Q3REF 0 · 9 MIN AGOTURN 2REF 1 · NOWEVICT FIRST: LEAF, REF 0, OLDESTA STAYS: CHILDREN IN USE
Two system prompts, each with conversations hanging off them. A running request pins every node on its path (ref count). Eviction removes the oldest leaf with ref 0 first, so shared stems like SYS PROMPT A survive while their one-off branches go.

Lookup is walking down from the root, matching tokens, as far as they agree. Insertion is adding or splitting an edge. The tree gives two things the flat table doesn’t make easy:

vLLM’s flat hash table gets close to the first point with a trick you will build yourself: when a request finishes, it puts the request’s blocks onto the free list tail first, so the end of a chain is always older than its head and gets evicted first. Same effect, no tree.

Eviction: who leaves when the pool is full

The pool is finite. A cache that never forgets would eventually hold every prefix ever seen and have no room for running requests. So when a request needs a new block and the free list is empty, something cached has to go. Three rules decide what.

Rule 1: never evict a block in use. A block a running request points at holds part of that request’s attention context. Free it and hand it to someone else, and the running request reads another request’s keys on its next step. That is not a slow request; it is a wrong one. So a block can be evicted only when its reference count is zero: nobody running is using it. If every block is pinned, the new request waits. (In vLLM, a running request can also be preempted: its blocks freed and recomputed later, a deliberate choice rather than eviction.)

Rule 2: among unpinned blocks, evict the least recently used. LRULeast recently used: when full, evict the item whose last use is oldest. Bets that what was used recently will be used again soon. is the default because traffic is bursty: the system prompt that served a request a second ago will probably serve another one in the next second. It is cheap to implement too: an ordered list of unpinned blocks, move a block to the back when it is released, pop from the front to evict.

Rule 3 (optional): drop anything idle longer than a TTL. A TTLTime to live: how long an entry may stay in a cache without being used. After that it is treated as gone, even if nothing else needed its space. answers a different question from LRU. LRU answers “who leaves when we are full?” TTL answers “how long may an idle thing stay?” A pure LRU cache that is never under pressure keeps a prefix from last Tuesday forever. A TTL frees that memory on a schedule, which matters when the memory could be doing something else (more concurrent requests), or when the cache is a promise to a customer who is paying for it. Hits refresh the timer, so a prompt in steady use never expires.

Now play with it. Sixteen blocks of 16 tokens. Prompt A and prompt B are system prompts of four blocks each. The chat turn reuses A and grows one block of history per turn. The one-off prompt is five blocks nobody will ever send again.

FIG_005[ A KV POOL WITH PREFIX CACHING ]

clock 0 min · running 0 · block hits 0 · misses 0 · hit rate -
evicted 0 · expired 0 · prefill tokens skipped 0

Send prompt A twice. The second time, every block is already there.

Requests pin their blocks while running (shaded, ref above 0). Finish all releases them: they stay cached at ref 0 until evicted. Blue outline: a hit. Black outline: computed just now. Try: A, A (all hits). Then chat turns. Then several one-offs without finishing, until a request has to wait. Then switch on TTL and advance the clock.

Things to notice:

What it buys: TTFT, not decode

Predict firstwrite it down

On the Spark, a request has a 2,000-token cached system prompt plus a 50-token new question, and generates 256 tokens. Cold, TTFT was about a second and decode ran at about 13 tokens per second. With the prefix cached, roughly what TTFT and what decode speed do you expect?

Reveal

TTFT drops to roughly the cost of prefilling 50 tokens. A forward pass over a few dozen tokens is dominated by reading the 16 GB of weights once, about 60 ms at spec-sheet bandwidth, plus Python overhead and the cost of assembling the cached KV. Something like 100 to 200 ms, instead of about a second.

Decode speed doesn’t change. Each decode step still reads all the weights and still attends over all 2,050+ cached tokens; the cache didn’t make any of those tokens disappear, it only avoided recomputing them once. The end-to-end time for 256 tokens drops by the TTFT saving (about 0.8 s out of about 20 s), which is why caching barely shows up on a long-answer benchmark and dominates on short-answer agent steps.

The precise statement: prefix caching skips prefill compute for the cached tokens. Prefill is the compute-bound phase, its cost grows with prompt length, and it is most of TTFT for long prompts. Decode is the bandwidth-bound phase from Why ~17 tokens per second?: each step reads the weights plus the KV, and none of that changes.

There are two indirect wins on a busy batched server. Prefill that doesn’t run frees compute for other requests, so throughput goes up when prompts are long and shared. And shared blocks are stored once, so more concurrent requests fit in the pool. On a one-at-a-time server like yours today, you will only see the TTFT win.

Many replicas: cache-aware routing

Run eight replicas of your server behind a load balancer. Each replica has its own KV pool, and its own cache. Round robin sends turn 2 of a conversation to a different replica than turn 1 seven times out of eight. The prefix is recomputed, and now seven replicas waste pool space on copies of it.

You met the fix in Load balancers and state: cache-aware routingRouting each request to the replica most likely to already hold its prompt prefix, while still avoiding overloaded replicas.. The simple version hashes something stable (the conversation ID, or the first few blocks of the prompt) and uses consistent hashing so the same prefix keeps landing on the same replica. The better version, used by SGLang’s router, keeps an approximate radix tree of what each replica has been sent, routes to the longest match, and falls back to the least-loaded replica when the match is overloaded. Pure affinity is dangerous: one hugely popular system prompt would send everyone to one GPU.

This is a sticky session, and it is fine, because it is a cache. If the replica dies, the next request goes elsewhere and pays the prefill. Nothing is lost but time.

The productized version: API prompt caching

Every major LLM API now sells this mechanism back to you as a line item. Under the hood it is the same idea: the provider keeps the KV for your prefix somewhere (GPU memory, and for some providers host memory or disk) and skips prefill when your next request starts with the same tokens. What changes is the contract, because now someone is paying.

Why do APIs use TTLs when your server will mostly use LRU? Because an API is a promise. “Your cache lives 5 minutes after the last use” is something a customer can plan around and a provider can budget memory for. “Your cache lives until something more popular pushes it out” is neither. The write premium pays for holding the memory; the read discount is the prefill compute you didn’t burn.

The habits that make API caching work are the same ones that make your server’s cache work: put stable content first (tools, system prompt, examples), volatile content last (the date, the user’s question), and never put a timestamp or request ID at the top.

Build It

You are adding a prefix cache to the server from the baseline lesson. If you already rewrote it for batching, the cache plugs in the same way; reference counts start mattering for real once requests overlap.

An honest note before you start. Real engines share blocks in place: the attention kernel reads the cached blocks directly through the block table, with no copying. Hugging Face’s generate expects each request’s KV as ordinary contiguous tensors. So your version stores each cached block as its own small tensors, and on a hit copies the hit blocks into a fresh cache for the request. That costs a copy of the cached prefix per request (about 256 MiB for a 2,000-token prompt, a few milliseconds of memory bandwidth plus Python overhead), and during a request the prefix lives in memory twice. You get the hashing, the lookup, the reference counting and the eviction exactly right, and the TTFT win; you do not get PagedAttention’s memory sharing. That is the trade.

inference/
  prefix_cache.py          # new: the cache (yours)
  test_prefix_cache.py     # new: the spec
  server_naive.py          # gains a cache, behind PREFIX_CACHE=1
  bench.py                 # records cached_tokens
  prompts/shared_2k.jsonl  # new
  prompts/multi_prefix_zipf.jsonl, multi_prefix_rr.jsonl   # new
Shared-prefix prompt setsAgent: fine

Three prompt files in the format your harness already reads. Boilerplate; let an agent write the generator, and check token counts with the model’s tokenizer.

  • shared_2k.jsonl: one system prompt of about 2,000 tokens (tool descriptions, rules and a few worked examples, like a real agent’s), and 50 lines that each pair it with a different short user question.
  • multi_prefix_zipf.jsonl: 8 different system prompts of about 2,000 tokens each, 400 requests. Pick each request’s system prompt with a seeded random choice weighted 1, 1/2, 1/3, …, 1/8 (a Zipf distributionA popularity curve where the k-th most popular item is used about 1/k as often as the most popular. Real traffic (web pages, words, prompts) often looks like this: a few hot items and a long tail.), and give each a different question.
  • multi_prefix_rr.jsonl: the same 8 prompts, 400 requests, in strict rotation: 1, 2, …, 8, 1, 2, …

Make sure the system prompt is the very first thing in each request (no date line above it), so the shared prefix really is a prefix.

Write the cacheHands only

One file, prefix_cache.py, with no model code in it at all, so you can test it in a second. Here is the shape:

# prefix_cache.py: block-hashed prefix cache. Knows nothing about the model.
import hashlib, threading, time
from collections import OrderedDict
from dataclasses import dataclass


@dataclass
class Block:
    key: bytes          # chained hash: names this block AND everything before it
    kv: object          # per layer (K, V) for these B tokens; opaque to the cache
    ref: int = 0        # running requests using it right now
    last_used: float = 0.0


class PrefixCache:
    def __init__(self, block_size=16, max_blocks=1024, ttl_s=None, salt=b"", clock=time.monotonic):
        self.B, self.max_blocks, self.ttl = block_size, max_blocks, ttl_s
        self.root = hashlib.sha256(b"prefix-cache|" + salt).digest()
        self.blocks: dict[bytes, Block] = {}
        self.free_lru: OrderedDict[bytes, None] = OrderedDict()  # ref == 0 only, oldest first
        self.lock = threading.Lock()
        self.clock = clock
        self.stats = dict(lookups=0, hit_blocks=0, miss_blocks=0, evicted=0, expired=0)

    def hash_blocks(self, ids: list[int]) -> list[bytes]:
        """One key per FULL block: key_i = sha256(key_(i-1) + bytes of the block's token ids)."""

    def lookup(self, keys: list[bytes], max_hit: int) -> list[Block]:
        """Longest cached prefix, at most max_hit blocks. Sweep expired blocks first.
        Pin each returned block: ref += 1, and take it off free_lru if it was there."""

    def insert(self, key: bytes, kv) -> bool:
        """Store a block the current request just computed, pinned (ref = 1).
        Evict until there is room. Return False (don't cache it) if everything is pinned."""

    def release(self, blocks: list[Block]) -> None:
        """The request is done with these. ref -= 1; stamp last_used; any block reaching
        ref 0 goes to the BACK of free_lru. Think hard about the order you do this in."""

    def _evict_one(self) -> bool:
        """Drop the front of free_lru. False if free_lru is empty."""

    def sweep(self, now=None) -> None:
        """TTL: drop unpinned blocks idle longer than ttl_s. They are at the front of free_lru."""

Hints:

  • Turn token ids into bytes with array("i", ids).tobytes() (from the standard array module) or struct.pack. Any fixed encoding works, as long as it is the same every time. Don’t use Python’s built-in hash(): it is randomized per process for strings and only 64 bits.
  • free_lru holds only unpinned blocks, which makes eviction O(1): pinned blocks are simply not in it. When a block is hit, it leaves free_lru; when its count drops to 0, it rejoins at the back.
  • The order in release decides whether heads or tails get evicted first. One of the tests checks it. Figure 005 behaves the right way.
  • max_hit exists so the server can keep at least one prompt token uncached.
  • Under TTL, free_lru is ordered by release time, and last_used is stamped at release, so expired blocks are always at the front. The sweep can stop at the first block that is still alive.
  • One lock around each public method is enough. Don’t call lookup from inside insert (deadlock).

Your spec is this test file. Take it as is; it is the definition of done:

# test_prefix_cache.py   run: python -m pytest -q
from prefix_cache import PrefixCache


class Clock:
    def __init__(self): self.t = 0.0
    def __call__(self): return self.t


def fill(c, ids):
    """Simulate one request: look up, insert what was missing, finish."""
    keys = c.hash_blocks(ids)
    hit = c.lookup(keys, max_hit=len(keys))
    new = [c.blocks[k] for k in keys[len(hit):] if c.insert(k, kv=None)]
    c.release(hit + new)
    return len(hit), len(keys)


def test_hash_chain():
    c = PrefixCache(block_size=4)
    a = c.hash_blocks([1, 2, 3, 4, 5, 6, 7, 8, 9])
    b = c.hash_blocks([1, 2, 3, 4, 5, 6, 7, 0])
    assert len(a) == 2                       # the partial tail is not a block
    assert a[0] == b[0] and a[1] != b[1]
    x = c.hash_blocks([9, 9, 9, 9, 5, 6, 7, 8])
    assert x[1] != a[1]                      # same tokens, different past, different key


def test_hit_after_insert():
    c = PrefixCache(block_size=4)
    assert fill(c, list(range(12))) == (0, 3)
    assert fill(c, list(range(12))) == (3, 3)
    assert fill(c, list(range(8)) + [99] * 4) == (2, 3)


def test_pinned_blocks_are_never_evicted():
    c = PrefixCache(block_size=4, max_blocks=2)
    keys = c.hash_blocks(list(range(8)))
    for k in keys:
        assert c.insert(k, kv=None)          # pinned (ref 1), never released
    other = c.hash_blocks([7] * 4)
    assert c.insert(other[0], kv=None) is False
    assert all(k in c.blocks for k in keys)


def test_lru_evicts_tails_first():
    c = PrefixCache(block_size=4, max_blocks=3)
    fill(c, list(range(12)))                 # one chain of 3 blocks, then released
    fill(c, [50] * 4)                        # needs 1 block, so one must go
    keys = c.hash_blocks(list(range(12)))
    assert keys[0] in c.blocks and keys[1] in c.blocks and keys[2] not in c.blocks


def test_ttl():
    clk = Clock()
    c = PrefixCache(block_size=4, ttl_s=300, clock=clk)
    fill(c, list(range(8)))
    clk.t = 200; assert fill(c, list(range(8)))[0] == 2   # hit, and the timer restarts
    clk.t = 450; assert fill(c, list(range(8)))[0] == 2   # idle 250 s: still alive
    clk.t = 800; assert fill(c, list(range(8)))[0] == 0   # idle 350 s: expired

I wrote a reference implementation against these tests on a Mac (about 70 lines) and all five pass, so they are consistent with each other. Write yours without looking for one.

Wire it into the serverAgent: review after

Three helpers touch Hugging Face’s cache internals, and that API has moved between versions. These were checked against transformers 5.20 (where DynamicCache has .layers, each with .keys and .values of shape [batch, kv_heads, seq, head_dim]); older 4.x versions used cache.key_cache[i] and cache.value_cache[i]. Take them as boilerplate, and check your version’s docs on “KV cache strategies” if they fail:

import torch
from transformers import DynamicCache


def layer_kv(cache):
    """[(K, V)] per layer, each [batch, kv_heads, seq, head_dim]."""
    if hasattr(cache, "layers"):
        return [(l.keys, l.values) for l in cache.layers]
    return list(zip(cache.key_cache, cache.value_cache))  # older transformers


def slice_block(kv, i, B):
    """Copy out block i. clone() matters: a slice is a view that keeps the whole tensor alive."""
    return [(k[:, :, i * B:(i + 1) * B].clone(), v[:, :, i * B:(i + 1) * B].clone()) for k, v in kv]


def build_cache(blocks):
    """A fresh DynamicCache holding the hit blocks, concatenated along the sequence axis."""
    c = DynamicCache()
    for li in range(len(blocks[0].kv)):
        c.update(torch.cat([b.kv[li][0] for b in blocks], dim=2),
                 torch.cat([b.kv[li][1] for b in blocks], dim=2), li)
    return c

The wiring is yours. Inside events(), under the GPU lock:

# ids = inputs["input_ids"][0].tolist()
# keys = cache.hash_blocks(ids)
# hit = cache.lookup(keys, max_hit=(len(ids) - 1) // cache.B)    # leave >= 1 token to compute
# past = build_cache(hit) if hit else DynamicCache()
# generate(**inputs, past_key_values=past, ...)   # in the thread, as before
#    generate sees the full input_ids, notices past already covers the first len(hit)*B tokens,
#    and only prefills the rest. It also fills `past` in place with every new token.
# after join:
#    kv = layer_kv(past); new = []
#    for i in range(len(hit), len(keys)): if cache.insert(keys[i], slice_block(kv, i, B)): new.append(...)
#    cache.release(hit + new)          # in a finally: block, so an exception can't leak pins
# usage gains "prompt_tokens_details": {"cached_tokens": len(hit) * B}

Plus: environment variables PREFIX_CACHE (0 or 1), CACHE_BLOCKS, CACHE_TTL_S; a GET /cache_stats endpoint that returns cache.stats and the current block count; and a log line per request with hit blocks out of total.

Check on the Mac first, with a tiny model (HuggingFaceTB/SmolLM2-135M-Instruct works) on CPU in fp32: the same prompt with the cache off and on must give identical greedy output. I ran this check: a 742-token prompt with 720 tokens from cache produced the same 20 tokens as a cold run, with first-token time going from 339 ms to 58 ms on this Mac’s CPU. On the Spark in bf16 you may see outputs diverge after many tokens: prefilling 50 tokens on top of a cache runs different kernel shapes than prefilling 2,050 at once, and bf16 rounding differs slightly. Compare the first 32 tokens, and if they differ early, suspect a bug (positions, a missing block, the wrong max_hit) before blaming rounding.

What this version does not cache: the generated tokens. Turn 2 of a chat resends turn 1’s answer as part of the prompt; your cache will miss on it because you only stored prompt blocks. vLLM caches generated blocks too. It’s a good extension, with a trap: the chat template may tokenize the answer slightly differently from how it was generated, and then the keys don’t match. Measure before assuming.

Teach the harness about hitsAgent: review after

In one_request, read usage.prompt_tokens_details.cached_tokens (missing means 0) and store it in each record. In the summary, report TTFT p50 separately for requests with cached_tokens > 0 and with 0, plus the overall token hit rate: total cached tokens over total prompt tokens.

Then a small sweep_cache.sh that, for each CACHE_BLOCKS in 64, 128, 256, 512, 1024, 2048, restarts the server, waits for /health, runs bench.py at c = 1 on one multi-prefix file with --max-tokens 16, and saves the results with the capacity in the metadata. Restarting is the point: each run starts from a cold cache.

Done when
  • All five tests pass.
  • On the Mac with a tiny model, cache on and cache off produce identical greedy output, and the log shows hits on the second request.
  • On the Spark, the second request of shared_2k.jsonl reports about 2,000 cached tokens, and /cache_stats agrees.
  • Results for cache off, cache on, and the capacity sweep are in results/, and SCOREBOARD.md has a new row.

Measure

TTFT on a shared system prompt

Run shared_2k.jsonl at c = 1, --max-tokens 256 --ignore-eos, once with PREFIX_CACHE=0 and once with PREFIX_CACHE=1. Then once against Fireworks.

runTTFT, first requestTTFT p50, restcached tokens p50tok/s p50e2e p50
Spark, cache off0
Spark, cache on
Fireworks(if reported)

What to expect, and why:

Then account for the hit overhead. Time build_cache on its own: 128 blocks × 32 layers is about 8,000 small tensors concatenated, roughly 256 MiB of copying. If it costs tens of milliseconds, try 64-token blocks: four times fewer pieces, at the cost of coarser matching. That trade (block size against match granularity and bookkeeping) is a real tuning knob in vLLM too.

Hit rate against cache size

Run the sweep on both multi-prefix files. Each 2,000-token prompt needs 128 blocks, and each request’s unique question adds about 3 more blocks, so all 8 prompts need about 1,024 blocks. For Llama 3.1 8B at 2 MiB per block, that is 2 GiB.

CACHE_BLOCKSmemory (8B, bf16)hit rate, Zipfhit rate, round robinTTFT p50
64128 MiB
128256 MiB
256512 MiB
5121 GiB
1,0242 GiB
2,0484 GiB
Predict firstwrite it down

For the round-robin file (8 prompts in strict rotation, each needing 128 blocks), sketch hit rate against capacity. What happens at 512 blocks, which holds 4 of the 8 prompts?

Reveal

Zero. Not half: zero. With LRU, by the time prompt 1 comes back, the 7 others have been used more recently, and 512 blocks only hold about 4 prompts’ worth, so prompt 1 is always the one that got evicted. Every request evicts the prompt that is about to come back next. This is the classic LRU failure on a cyclic access pattern bigger than the cache. Hit rate jumps from 0 to nearly the maximum only when the cache holds the whole cycle. The Zipf file degrades gracefully instead, because its hot prompts are reused before they age out.

Before you run it on the Spark, here is what a pure-Python simulation of this exact workload gives, using a reference implementation of the cache from Task 2 (2,000 requests, 2,048-token prompts plus 50 unique tokens, 16-token blocks). Your measurements should land close to these, since the cache logic is the same; the model only adds time.

CACHE_BLOCKSZipf (simulated)round robin (simulated)
640.090.00
1280.180.00
2560.360.00
5120.620.00
1,0240.930.81
2,0480.970.97

The ceiling is 2,048 / 2,098 ≈ 0.976, because the unique question at the end of every prompt can never hit. Notice that even at 64 blocks the Zipf file gets some hits: tail-first eviction keeps the heads of the hottest prompt, and a partial prefix is still a prefix.

Plot TTFT p50 against hit rate. It should be close to a straight line from full-prefill TTFT at 0 to the warm TTFT at 1, because TTFT is roughly a fixed cost plus a per-uncached-token cost.

The scoreboard row

Your scoreboard’s main columns (short prompts, c = 1) barely move: those prompts share almost nothing. That is the honest result, and worth writing down. Put the real effect in the notes:

| date | spark | prefix cache, 16-tok blocks, LRU (+TTL) | (short prompts: ~unchanged) | ~unchanged | ~unchanged | shared 2k sys prompt TTFT p50: ___ ms off → ___ ms on; Zipf-8 hit rate ___ at 1,024 blocks (2 GiB) |

Break It

Put a timestamp at the top. Add Current time: {now} as the first line of the system prompt and rerun shared_2k.jsonl. The hit rate drops to zero: the very first block differs, so every key after it differs. Move the line to the end of the prompt and the hits come back. Every API’s prompt caching guide warns about exactly this, because it is the most common way people silently lose their cache.

Release blocks head first. Change release to put blocks on the free list in order, head before tail. test_lru_evicts_tails_first fails. Rerun the sweep in simulation: at 256 blocks the Zipf hit rate falls from about 0.36 to 0.18, and the round-robin file at 1,024 blocks falls from 0.81 to 0.00. Evicting a head block makes every block after it unreachable (lookup stops at the first miss), but those orphans still occupy the pool until LRU reaches them. One line, half the hit rate.

Store views instead of copies. Remove .clone() from slice_block. Everything still works, and memory climbs request after request. A slice of a tensor is a view: it points into the original storage, so each cached 16-token block keeps the request’s entire KV tensor alive (prompt plus generated tokens, every layer). Watch the process’s memory with the cache sized at 1,024 blocks; it grows far past 2 GiB. The same bug exists in plain Python with NumPy slices. It is why “cache size in blocks” is not the same as “cache size in bytes” unless you are careful.

Use a tiny hash. Replace SHA-256 with something that keeps only 16 bits of the key. With 65,536 possible keys and thousands of blocks, collisions start within minutes of the multi-prefix run, and some requests get another prompt’s KV. Look at the outputs: they are fluent and wrong, a model confidently answering a question nobody asked, with no error anywhere. This is why a cache key must be collision-resistant, and why cache bugs are the worst kind of bug: they return an answer.

Explain It Back

Answer each out loud before opening it.

What is PagedAttention, and what problem does it solve?

Each request’s KV cache grows by about 128 KiB per token for an 8B model, and you don’t know in advance how long a request will get. The simple design reserves a contiguous slab for the maximum length, which wastes most of the memory: the unused tail of each slab, and gaps between slabs too small for a new request. PagedAttention does what operating systems do with virtual memory. The KV pool is cut into fixed-size blocks of, say, 16 tokens, and each sequence has a block table mapping its logical blocks to physical blocks anywhere in the pool. Blocks are allocated only when the previous one fills, so waste is under one block per sequence. The attention kernel reads keys and values through the block table. The result is that many more sequences fit in memory at once, so the batch is bigger and throughput goes up; and because a block is just a pointer, sequences can share blocks.

How does automatic prefix caching find reusable KV, and why is it exact?

A token’s key and value depend only on the tokens before it and its position, because attention is causal. So two prompts that share their first n tokens have identical KV for those n tokens. The server cuts each prompt into full blocks and gives each block a key: a hash of its tokens plus the key of the block before it, so a key names the entire prefix up to that block. Lookup computes the keys for a new prompt and walks them left to right in a hash table until the first miss. The hit blocks are reused and prefill runs only on the rest, plus at least the last token. It is exact up to floating-point rounding, as long as everything else that affects KV (model, adapter, images) is part of the key and the hash doesn’t collide.

LRU vs TTL vs reference counts: what does each one decide?

Reference counts decide what may be evicted: a block that a running request is using has a count above zero and must never be evicted, or that request starts reading someone else’s KV. LRU decides which unpinned block goes when the pool is full: the one unused for longest, betting that recent prefixes will be reused soon. And with tail-first ordering, the ends of chains go before the heads. TTL decides how long an idle block may stay even when there’s room: it frees memory on a schedule and makes the cache’s lifetime something you can promise, which is why APIs (5 minutes, refreshed on every hit) use it. A server under constant pressure is effectively all LRU; a TTL matters when it isn’t.

Prefix caching made TTFT 8× faster. Why didn’t tokens per second change?

Caching skips prefill for the cached tokens: the compute-heavy step that processes the prompt, which dominates TTFT on long prompts. Decode is a different phase: each new token needs one forward pass that reads all the weights, plus attention over the whole context. The cached tokens are still in the context and are still read every step; we only avoided computing them a second time. Decode on the Spark is limited by memory bandwidth, and the bytes per step are unchanged. So the gain is a fixed amount of time saved per request, large for short answers and agent steps, small relative to long generations. On a batched server there are indirect gains: prefill compute freed for other requests, and shared blocks leaving room for more requests.

How does prefix caching scale to 50 replicas and a million users?

Each replica has its own cache, so the first question is routing. With random or round-robin routing, a conversation’s next turn lands on a replica that has its prefix only 1 time in 50, and the effective cache is each replica’s memory duplicating everyone’s popular prefixes. Cache-aware routing sends requests to the replica that most likely holds the longest prefix (consistent hashing on the conversation ID or the first blocks, or a per-replica prefix tree like SGLang’s router), with a fallback to the least-loaded replica so one hot system prompt can’t melt one GPU. Then capacity: work out the hot set. If 1,000 active conversations each hold 8,000 tokens at 128 KiB per token, that is about 1 TB of KV, far more than GPU memory. So you rely on Zipf-like reuse (a few system prompts are very hot), eviction of the long tail, and possibly a second tier in host memory or SSD for the warm middle, which is roughly what API providers’ longer TTLs imply. The numbers to know: KV bytes per token, the size of the hot set, and how fast prefill recomputes a miss.

Go Deeper

Connections

This lesson builds on Virtual memory (pages, page tables, copy-on-write: the same ideas, one level up), Load balancers and state (cache-aware routing is a sticky session that is safe because it’s a cache), and the server and harness from Baseline: my Spark vs Fireworks.

It connects forward to Caching and Redis, where the same questions (what is the key, how long does it live, what happens when it’s stale) come up for ordinary application data; to How much memory does a model need?, where KV bytes per token decide how big the pool can be; and to Inside vLLM and SGLang, where you will find the block manager and radix tree you just built, in production form.