Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

ch05 · The KV Cache

The problem

Watch ch01 · What an Inference Server Actually Does’s engine produce its two-hundredth token. To do it, the model runs over all 199 previous tokens. Every one of those was computed on the previous step, produced exactly the same result, and was thrown away.

The arithmetic from ch03 · The Arithmetic of Inference says this should be expensive: generating n tokens without a cache costs work proportional to , against n with one. For a prompt of a few dozen tokens that predicts a penalty of well over an order of magnitude.

Whether it actually costs that much is the interesting question, and the answer is instructive.

The idea

What is worth keeping

Attention computes, for each position, a query against the keys and values of every earlier position. During decode there is exactly one new query — the token just produced — and the keys and values of every previous token are identical to what they were on the previous step.

They depend only on the token at that position and its position in the sequence. Neither changes. So compute them once and keep them. That is the entire idea, and it is the single highest-return optimisation in inference.

Queries are not cached, because there is only ever one live query per step and it is new every time.

This is also where rotary position embeddings earn their place. RoPE rotates keys by their position at the moment they are computed, so a cached key already carries its position and stays valid however long the sequence grows. A model that added a position embedding to the input instead would need care here — and, more importantly, could not share cached keys between two requests whose prompts start the same way. ch09 · Prefix Caching depends on that property entirely.

What it costs

From ch03 · The Arithmetic of Inference:

kv_bytes_per_token = 2 × n_layers × n_kv_heads × head_dim × bytes_per_element

The number to hold on to is not the per-token figure but what it becomes. For a 7B model in fp16 with grouped-query attention, a single 8k-token conversation costs on the order of a gigabyte of KV cache. Weights are a fixed cost paid once; KV cache is a per-concurrent-request cost, and it is what actually limits how many users a GPU can serve.

We have traded compute for memory. Memory is now the binding constraint, and stays that way for the rest of Part III.

The build

The change to the engine is small, which is part of the point — the cache is an optimisation, not a different model:

naive.py
        elif self.use_cache:
            # Decode with a cache: one new token, attending to everything already stored.
            last = torch.tensor([[state.output_token_ids[-1]]], dtype=torch.long)
            positions = torch.tensor([state.total_len - 1], dtype=torch.long)
            logits, past = self.model(last, state.past, positions)
            state.past = past

Three details in those few lines matter more than their size suggests.

Only the newest token goes in. Not the whole sequence — just the one token produced last. The cache supplies the rest.

The position is passed explicitly. During decode the new token sits at position total_len - 1, not at zero. Get this wrong and there is no crash and no exception: the model simply attends as though every token were at the start of the sequence, and the output degrades in a way that looks like a bad model rather than a bug. This is the most common KV cache error and it is silent.

The cache is returned and reassigned. The model appends the new key and value and hands back the extended cache. ch08 · Paged Attention and the Block Manager replaces this contiguous tensor with paged blocks, but the interface stays exactly the same.

Isolating the cache as the only difference from ch01 · What an Inference Server Actually Does’s engine is deliberate — it is what makes its contribution measurable rather than merely plausible:

naive.py
class CachedEngine(NaiveEngine):
    """Chapter 5's engine: still one request at a time, but with a KV cache.

    Isolating the cache as the *only* change from ``NaiveEngine`` is what makes its contribution
    measurable. Chapter 6 then adds batching on top, and chapter 7 replaces the scheduling.
    """

    name = "cached"

The measurement

Same trace, same model, same machine — one engine with a cache and one without:

ConfigurationTTFT p50TTFT p95ITL p50Output tok/sGoodput req/s
ch01 Naive, 1 req/s0.0174s0.6816s0.0131s35.071.043
ch05 KV cache, 1 req/s0.0154s0.1868s0.0046s35.421.053
ch01 Naive, 8 req/s4.278s7.239s0.0137s73.70.274
ch05 KV cache, 8 req/s0.4786s1.012s0.0047s197.95.396
ch01 Naive, 16 req/s4.811s8.463s0.0137s74.490.277
ch05 KV cache, 16 req/s1.35s2.336s0.0047s199.92.477

Conditions: TinyGPT (reference, random weights) (5,838,080 params), 4x x86_64 CPU, torch 2.14.0+cu130, poisson, prompt 32-96, output 16-48, seed 7, arrival rate 8.0/s, measured 2026-09-13T15:28:05+00:00.

Read it in two parts.

ITL falls by roughly a factor of three, and stays flat. That is the cache doing its job: per-token cost no longer grows with sequence length.

Capacity roughly doubles, and the effect on TTFT at load is much larger than the ITL improvement alone suggests. At 8 requests per second the median wait for a first token drops from seconds to a fraction of one. This is a queueing effect rather than a compute one: faster service means a shorter queue, and at high utilisation a small service improvement produces a large latency improvement. ch10 · Chunked Prefill and Scheduling Policy makes that relationship explicit.

And the measured gain is nowhere near the predicted one. ch03 · The Arithmetic of Inference forecast an order of magnitude more than we got, and the explanation is the two-bound model. A decode step on this model reads 23 MB of weights whether it processes one token or ninety-six. Removing the redundant arithmetic removed work the machine was doing in time it was spending on memory anyway.

That gap is not a disappointment, it is the lesson: on a bandwidth-bound operation, saving FLOPs buys less than arithmetic suggests. Scale the model up and the ratio shifts — a 7B model moves far more bytes per step, but its redundant prefix computation grows faster still. The prediction was not wrong so much as it was answering a question about a resource that was not scarce.

The cost

The cache is close to a free lunch, but not quite:

Key takeaways

Looking ahead

The engine is much faster per request and still serves exactly one request at a time. Meanwhile it reads all 23 MB of weights to produce a single token for a single user — and would read the same 23 MB to produce a token for thirty users. ch06 · Static Batching and Its Limits starts collecting that free lunch, and runs straight into why the obvious way of doing it wastes most of the gain.

Further reading

The KV cache is old enough to be folklore rather than a paper; it appears in the original transformer decoding implementations without much ceremony. Its consequences are the subject of the vLLM paper (Appendix E), which is best read after ch08 · Paged Attention and the Block Manager when the fragmentation problem it solves is something you have already run into.