Day 11: the KV cache, 5.7x faster and token-for-token identical
Date: 2026-06-26 · Week 3 · Phase 1 Correct generation
What I added today
src/nanoserve/cache.py: NaiveKVCache, a contiguous per-layer K/V store that grows by torch.cat each step and hands back the running history. Attention now takes an optional cache/layer_idx: it appends this step’s rotated K/V and scores the query against the whole history, with the causal mask generalized from a square triangle to a rectangle (triu(..., diagonal=past+1)) so the same code path is prefill (past=0) and one decode step (seq=1). LlamaModel threads the cache through forward by layer index and adds greedy_generate_cached: prefill the prompt once, then forward a single token per step. Eight new tests in tests/test_cache.py; full suite 80 green. On the real Llama-3.2-1B, 40 tokens went from 87.3s to 15.2s, a 5.74x speedup, output bit-identical to the naive path.
Why it matters
This is the one optimization the whole arc is named after. Week 2’s greedy decode re-ran attention over the entire growing prefix every step, which is O(n^2): the fortieth token redoes the work of the first thirty-nine. But a past token’s key and value never change once it is fixed, so that recompute is pure waste. Cache them, append one column per step, and decode is O(n). Every paged-cache and scheduler day after this is about managing this cache under memory pressure and across many sequences; today is the cache existing at all.
What I learned
The cache is an optimization, so the entire job is for it to change nothing observable, and the design pressure all came from keeping the verified Week-2 math untouched while making it incremental:
- One rectangular mask covers prefill and decode. Week 2 used a square
[seq, seq]causal triangle. With a cache theseqnew queries score againstkv_lencached keys, so the mask is[seq, kv_len]: query i sits at absolute positionpast + i(wherepast = kv_len - seq) and may see keys0..past+i.triu(full(-inf), diagonal=past+1)is exactly that band. The pretty part is thatpast=0collapses it back to the Week-2 square triangle, so the prefill path is numerically unchanged, and a single decode query (seq=1,past=kv_len -1) gets an all-zero row: it sees the whole history, which is the point. - Cache the compact GQA K/V, not the repeated version. The 8 KV heads get expanded to 32 for the score matmul, but that expansion is a read-time view. Append before
repeat_kvand the cache stores 8 heads; append after and it stores 32 and you have quietly thrown away the entire reason GQA exists. The 4x memory saving GQA buys is only real if the cache respects it. - Position is now explicit, because the prefix is gone. In Week 2 the whole sequence was passed every step, so
position_idsdefaulting to0..len-1was always right. A decode step forwards one token with no prefix attached, so its absolute position has to be handed in (it is exactlycache.seq_lenjust before the append). Get this wrong and RoPE rotates the new K for the wrong slot, which does not throw, it just slowly drifts the tokens off the reference.
The test that matters is not the speed, it is torch.equal: cached greedy decode produces the same tokens as the Week-2 recompute path, and both equal HF. A cache that is faster but changes one token in forty is not faster, it is broken. The 5.74x is on a 5-token prompt generating 40 on CPU; the gap widens with sequence length, because that is the whole O(n^2)-versus-O(n) story made visible.
Diagram
n/a today. The paged-cache diagram is already drawn for Weeks 4-5; this naive contiguous cache is the thing that diagram replaces, so I will draw the contiguous-versus-paged before/after when the paging lands.
Tomorrow
Week 3’s remaining thread: wire sample (Day 10) into a cached generate so a do_sample request runs the same fast decode loop, and start measuring the cache properly (tokens/sec versus prompt length) to set up the Week-4 motivation, which is that this contiguous buffer wastes most of its VRAM.
Post angle: Day 11 of building an LLM inference engine from scratch. Today is the KV cache, the one idea that makes inference fast. Yesterday’s greedy decode re-ran attention over the entire prefix every single step, which is O(n^2): the 40th token redoes the work of the first 39. But a past token’s key and value never change once the token is fixed, so all that recompute is waste. So I store them: compute each token’s K and V once, append one column per step, and decode drops to O(n). On the real Llama-3.2-1B, 40 tokens went from 87 seconds to 15, a 5.7x speedup, and the tokens come out bit-for-bit identical to the slow path. That identity is the actual test, not the clock. A cache that is faster but changes one token in forty is not faster, it is broken. Two things that bite: cache the compact 8-head GQA K/V, not the 32-head expanded version, or you throw away the entire reason GQA exists; and a decode step now has to be told its token’s absolute position explicitly, because the prefix it used to be inferred from is gone. The mask is the neat part: one rectangular causal mask is both the prefill triangle and the single-query decode row, depending only on how much history is already cached. #AI #LLM #vLLM #BuildInPublic #Claude #OpenAI