Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 100m read

DL S042 · KV Cache — Why Inference Is 100× Faster With It

Add a key-value cache to your decoder-only transformer and watch generation latency go from O(N²) per token to O(N). The single biggest optimization in every LLM serving stack.

🧠SoftwareM07 · Transformers from scratch· Session 042 of 130 100 min

🎯 Understand why naive autoregressive generation is quadratically wasteful, implement a KV cache, and measure the 10–100× speedup on real generation.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 42 / 80 · Module M07 · ~1.7 hours

The story

You wrote generate() in S040. Look at it again. Every generation step, you feed the entire growing context back through the model — all N positions get recomputed from scratch, all their Q/K/V projections, all their attention, all their FFNs. To generate 500 tokens starting from a 1-token prompt, you do work proportional to 1² + 2² + 3² + ... + 500² ≈ 42 million position-computes. To generate the same 500 tokens with a KV cache, you do 500 position-computes. That's ~85,000× fewer.

The insight: at generation step t, you're only producing a new key, value, and query for position t. All previous positions' K and V vectors were computed on earlier steps and haven't changed. So cache them. On step t, compute only q_t, k_t, v_t, append k_t and v_t to a running cache, and compute attention as softmax(q_t · [K_cache; k_t]ᵀ) · [V_cache; v_t]. Attention scores are now shape (1, t+1) instead of (t+1, t+1). FFN runs on one position instead of t+1.

This is the trick that makes LLM chatbots economically viable. vLLM, TensorRT-LLM, TGI, and every commercial serving framework live and die by KV cache management. Today we build the basic version; in S066 and S071 we cover PagedAttention (vLLM's contribution) and continuous batching.

You will be able to
  • Explain why naive generation is O(N²) per token and O(N³) for a full sequence.
  • Derive the KV cache from the observation that past K/V don't change during generation.
  • Modify the S040 CausalSelfAttention to accept and update a KV cache.
  • Measure and interpret the speedup vs sequence length (should be roughly linear in N).
  • Estimate KV cache memory as 2 · n_layer · n_head · head_dim · dtype_bytes · seq_len.

Prerequisites

  • S040, S041 — the model and generation loop we'll optimise.


1 · Why naive generation is wasteful

Recall the S040 generate loop:

for _ in range(max_new_tokens): idx_cond = idx[:, -block_size:] logits, _ = self(idx_cond) # recomputes everything for all positions ... idx = torch.cat((idx, idx_next), dim=1)

Every iteration feeds idx_cond (length t) through the model, computes Q, K, V for every position, computes the full (t, t) attention matrix, runs the FFN on all t positions. Then we grab the last position's logits and throw away all the intermediate work for positions 0..t-2 — even though they were computed identically on the previous step.

Total work for generating N tokens: Σ_{t=1}^{N} O(t²) ≈ O(N³). That's cubic. For N = 2048, that's ~8 billion operations of overhead you didn't need to do.

The KV cache brings it down to O(N²) total (each step is O(N) in the attention). And in practice, most of the "N" here is memory bandwidth, not compute — you're reading a growing KV tensor, not multiplying huge things.


2 · The core observation

For a decoder-only causal transformer, during generation:

  • K and V for past positions are FIXED once computed. Position t's key vector k_t depends only on the input token at position t (and layer inputs at position t, which also depend only on earlier positions). Feeding token t+1 doesn't change k_t or v_t.
  • Q for past positions is IRRELEVANT. We only need attention from the new position — i.e., q_t · K[:t+1]. We don't need q_{t-1} any more; that computation was for producing the previous token.
  • FFN for past positions is IRRELEVANT. The residual stream at position < t doesn't affect the output at position t in a decoder-only model, once its K/V have been cached.
A note-taker at a very long meeting
🌍 Real world
💻 Code world

So on step t, the compute is:

  1. Take the new token embedding (single position).
  2. Run it through the model: for each layer, compute q_t, k_t, v_t (single position each), append k_t, v_t to that layer's cache.
  3. Attention: q_t · [K_cache; k_t]ᵀ, softmax, times [V_cache; v_t]. Result is a single new position's attention output.
  4. FFN on single position. Residual add. Onto the next layer.
  5. Final LN + head, single position. Sample the next token.

Per step: O(t) for the attention (dot product against growing cache), O(1) for everything else. Total for N tokens: O(N²).


3 · The implementation

Modify CausalSelfAttention.forward to accept an optional cache:

class CausalSelfAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        # ... same as S040 ...
 
    def forward(self, x, kv_cache=None):
        B, T, C = x.size()
        d_k = C // self.n_head
        q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
        q = q.view(B, T, self.n_head, d_k).transpose(1, 2)   # (B, h, T, d_k)
        k = k.view(B, T, self.n_head, d_k).transpose(1, 2)
        v = v.view(B, T, self.n_head, d_k).transpose(1, 2)
 
        # Append to cache
        if kv_cache is not None:
            past_k, past_v = kv_cache
            k = torch.cat([past_k, k], dim=2)               # (B, h, T_past + T, d_k)
            v = torch.cat([past_v, v], dim=2)
        new_cache = (k, v)
 
        # Attention
        # When using cache, T=1 (new token only), T_total = T_past + 1
        # No causal mask needed — the new query can attend to all cached positions
        att = (q @ k.transpose(-2, -1)) / math.sqrt(d_k)     # (B, h, T, T_total)
        # Note: if T > 1 (e.g. processing a full prompt), we still need a causal mask
        # on the "new" positions relative to each other. Standard trick: apply mask only
        # to the T × T sub-block on the right.
        if T > 1:
            T_total = k.size(2)
            mask = torch.tril(torch.ones(T, T, device=x.device))
            # Position i in the new block can see cached positions (all) + new positions 0..i
            full_mask = torch.ones(T, T_total, device=x.device)
            full_mask[:, T_total-T:] = mask
            att = att.masked_fill(full_mask == 0, float('-inf'))
        att = F.softmax(att, dim=-1)
        y = att @ v                                          # (B, h, T, d_k)
 
        y = y.transpose(1, 2).contiguous().view(B, T, C)
        return self.c_proj(y), new_cache

The GPT forward propagates the cache through all layers:

def forward(self, idx, kv_caches=None):
    B, T = idx.size()
    past_len = 0 if kv_caches is None else kv_caches[0][0].size(2)
    pos = torch.arange(past_len, past_len + T, device=idx.device)
 
    tok = self.transformer.wte(idx)
    pos_emb = self.transformer.wpe(pos)
    x = self.transformer.drop(tok + pos_emb)
 
    new_caches = []
    for i, block in enumerate(self.transformer.h):
        past = None if kv_caches is None else kv_caches[i]
        x, new = block(x, kv_cache=past)
        new_caches.append(new)
 
    x = self.transformer.ln_f(x)
    logits = self.lm_head(x[:, [-1], :])
    return logits, new_caches

Note the pos computation — the new tokens' positions are past_len, past_len+1, …, NOT 0, 1, …. Get this wrong and generation gets scrambled after the first cached step.

And the generate loop:

@torch.no_grad()
def generate_kv(self, idx, max_new_tokens, temperature=1.0, top_k=None):
    kv_caches = None
    # Process the prompt in one shot (fills the cache with T positions)
    logits, kv_caches = self(idx, kv_caches=kv_caches)
    for _ in range(max_new_tokens):
        logits = logits[:, -1, :] / temperature
        if top_k is not None:
            v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
            logits[logits < v[:, [-1]]] = -float('Inf')
        probs = F.softmax(logits, dim=-1)
        idx_next = torch.multinomial(probs, num_samples=1)
        idx = torch.cat((idx, idx_next), dim=1)
        # Only pass the NEW token through the model
        logits, kv_caches = self(idx_next, kv_caches=kv_caches)
    return idx

The critical line: self(idx_next, kv_caches=kv_caches) — passing only the ONE new token, not the whole context. That's what makes each step O(N) instead of O(N²).


4 · Benchmark — measure the speedup

import time
torch.manual_seed(0)
prompt = torch.zeros((1, 1), dtype=torch.long, device='cuda')
 
t0 = time.time()
_ = model.generate(prompt, max_new_tokens=1000, temperature=1.0)      # naive
print(f"Naive: {time.time() - t0:.2f}s")
 
t0 = time.time()
_ = model.generate_kv(prompt, max_new_tokens=1000, temperature=1.0)   # cached
print(f"KV cache: {time.time() - t0:.2f}s")

Typical results (small model on A100):

  • Naive: 12.4 s
  • KV cache: 1.6 s
  • Speedup: ~8×

At max_new_tokens = 100, speedup is only ~2×. At 4096, it's ~30×. The ratio grows with sequence length because naive is O(N²) in the length and cached is O(N).

Try itTime naive vs cached generation and watch the gap open with length

Take the nanoGPT rebuild from S040 and add a use_cache toggle to generate(). Then time both variants across several lengths:

import time
lengths = [64, 128, 256, 512, 1024, 2048]
for N in lengths:
    for tag, use_cache in [("naive", False), ("cache", True)]:
        t0 = time.perf_counter()
        _ = model.generate(prompt, max_new_tokens=N, use_cache=use_cache)
        dt = time.perf_counter() - t0
        print(f"N={N:5d}  {tag:>5s}  {dt:6.3f}s")

On the toy 10M-parameter model the gap starts small (≈2× at 128 tokens) and grows fast. Plot time vs N on a log-log axis: naive slopes like , cache slopes like N. This is one of those experiments where you can see the algorithm's complexity class as a line on a chart — rare and satisfying. Bonus: print the peak memory usage per length for the cache variant and confirm it grows linearly — that's the trade you're making for the speed.

💡 Hint · Plot generation time as a function of `max_new_tokens` for both variants on the same tiny model.

5 · The memory cost

KV cache is not free. Memory per token, per layer, per batch element:

bytes=2nheaddkdtype_bytes=2nembddtype_bytes\text{bytes} = 2 \cdot n_{\text{head}} \cdot d_k \cdot \text{dtype\_bytes} = 2 \cdot n_{\text{embd}} \cdot \text{dtype\_bytes}

(the 2 is for K and V; the head/dim factors collapse to n_embd).

For Llama-2-7B (n_embd = 4096, n_layer = 32) in fp16 (2 bytes):

KV per token=24096322=524,288 bytes0.5 MB\text{KV per token} = 2 \cdot 4096 \cdot 32 \cdot 2 = 524{,}288 \text{ bytes} \approx 0.5 \text{ MB}

For a 4096-token context: 0.5 MB × 4096 = 2 GB PER SEQUENCE.

A single 40-GB A100 running Llama-2-7B has ~13 GB left after model weights, so it can hold ~6 concurrent 4k-context sequences before running out of KV cache memory. This is why KV cache is the memory bottleneck for LLM serving, not model weights.

vLLM's PagedAttention (S066) attacks this by allocating KV cache in fixed-size "pages" like an OS virtual memory system, dramatically reducing fragmentation and enabling ~4× more concurrent requests. Same math, different memory management.


6 · Pitfalls

War story Wrong position embeddings after caching

The most common bug: continuing to feed position 0 to the new token instead of position past_len. Fix: track cache length and compute pos = torch.arange(past_len, past_len + T). If you forget, generation still runs but produces garbage after the first cached step because position embeddings for the new tokens are wrong.

War story Prompt processing without cache init

People call generate_kv with a long prompt but pass kv_caches=None in a loop, re-computing the prompt every time. Correct: process the prompt ONCE with the full prompt tensor, THEN loop over new tokens one at a time.

War story Concatenating along the wrong dim

torch.cat([past_k, k], dim=2) — dim 2 is the T dimension in our (B, h, T, d_k) shape. If you cat on dim 3, you'll grow d_k and the model will crash on the next attention.


7 · Beyond basic KV cache — a preview

Real-world LLM serving has many KV-cache optimizations we'll cover later:

Advanced KV cache techniques (later sessions)
  • PagedAttention (vLLM, S066) — page-based allocation to eliminate fragmentation.
  • Grouped-Query Attention (GQA) / Multi-Query Attention (MQA) — share K/V across heads to reduce cache size by 4-8×. Llama-2 70B uses GQA.
  • Prefix caching — reuse KV cache for repeated system prompts across users.
  • Speculative decoding (S063) — use a small draft model to propose tokens, verify with big model in parallel; multiple tokens per step.
  • Quantized KV cache — store K/V in int8 or int4. 2-4× memory reduction with small quality hit.

Every one of these builds on the basic KV cache you just implemented.


8 · Modern-2025 twist — what actually runs in production

The basic KV cache you just wrote is table stakes. Every 2025 open-model serving stack layers a stack of tricks on top, each solving a different pathology of the naive approach:

1. PagedAttention — [vLLM (Kwon et al., SOSP 2023, arXiv:2309.06180)]. Fragmentation kills naive KV cache: if you allocate contiguous buffers of max_len per request, ~60% of KV memory is wasted on unused padding. vLLM allocates in 16-token "pages" like OS virtual memory, hits ~96% utilisation, serves ~2–4× more concurrent requests. Every serious open-source serving stack has now adopted the pattern (vLLM v1, SGLang, TensorRT-LLM, TGI).

2. Prefix caching — same system prompt for 10,000 users? Compute the KV cache once, share it. RadixAttention in SGLang (Zheng et al., 2024, arXiv:2312.07104) stores prefix caches in a radix tree keyed on token prefix. For chatbot workloads with long system prompts this is a 5–10× throughput win.

3. GQA / MQA / MLA — covered in S037's modern-twist section. Shrinks the KV cache 4–64× by sharing K/V across query heads (GQA) or compressing them (MLA). This is why Llama 3 70B (GQA g=8) can serve 4k contexts on a single H100 while an equivalent MHA model can only serve a few.

4. Quantized KV cache — store K and V in int8 or int4 instead of fp16. KIVI (Liu et al., 2024, arXiv:2402.02750) shows per-channel int2 KV for K and per-token int2 for V hits ~4× memory reduction with <1% quality loss. TensorRT-LLM ships int8 KV out of the box.

5. Speculative decoding (S063 preview) — use a small draft model to propose K tokens per step, verify with the big model in one batched forward. Realistic 2–4× wall-clock speedup on top of KV cache. Chinese labs (DeepSeek-V3 with multi-token prediction, Zhipu GLM-4) increasingly train models with native MTP heads to skip the draft-model dance.

6. Sliding window + attention sinksStreamingLLM (Xiao et al., 2023, arXiv:2309.17453). Keep only the first few "sink" tokens plus a sliding window of recent tokens. Enables infinite-length inference at bounded memory, at the cost of losing mid-history detail. Used in Mistral 7B's original release and several 2024 forks.

Cheat-sheet KV cache size, per token per layer, K+V together, fp16:

Modeln_embdn_kv_head·d_kKV bytes4k context per seq
GPT-2 XL 1.5B160016003.2 KB200 MB
Llama 3 8B (GQA g=4)409610244.1 KB4.3 GB (32 layers)
Llama 3 70B (GQA g=8)819210244.1 KB10.5 GB (80 layers)
DeepSeek-V3 (MLA)7168latent 5121.0 KB2.6 GB (61 layers)

This table is worth memorising: it's the difference between "we can serve 20 users on this GPU" and "we can serve 2."

Further reading:


Common misconception
✗ What most people think

"The KV cache makes generation faster by avoiding recomputation. It is a compute optimisation — I am trading memory for FLOPs, the same as any other caching."

✓ What is actually true

The FLOP saving is real but it is not what makes decoding fast, because decoding is not compute-bound. Generating one token with a batch of one performs a handful of matrix-vector products, which use a tiny fraction of an accelerator's arithmetic throughput; the step time is dominated by the time to read the model weights out of memory. The cache converts an operation whose cost grows with the square of sequence length into one that grows linearly, which is the asymptotic win — but it then becomes the new bottleneck, because the cache must itself be read in full at every step and it grows with every token generated. That is why cache size, not cache existence, is what production inference work is actually about.

Why the myth is so sticky

Because "caching trades memory for compute" is a correct and deeply-drilled generalisation from everywhere else in systems, and it is not wrong here — the FLOPs really are saved. Nothing about the code contradicts it. The gap only appears when you measure: recomputing from scratch each step is quadratic and clearly worse, so the cache is obviously right, and you never need a finer model to justify the decision. It breaks the first time you try to explain why doubling the batch size costs almost no extra time per step, or why a longer prompt slows generation even though the per-step FLOPs barely moved. Both are memory-bandwidth facts, and the compute framing has nothing to say about either.

Prove it to yourself

Separate the two effects by scaling batch and context independently:

# 1. Batch scaling. If decoding were compute-bound, per-step time
#    should scale with batch. Measure it.
for bs in (1, 2, 4, 8, 16, 32):
    t = time_one_decode_step(model, batch=bs, ctx=128)
    print(bs, t, t / bs)     # watch time-per-sequence fall sharply

# 2. Context scaling at fixed batch. Per-step FLOPs grow only
#    with the attention term, which is small -- but the cache read
#    grows linearly with ctx.
for ctx in (128, 512, 2048, 8192):
    print(ctx, time_one_decode_step(model, batch=1, ctx=ctx))

# Near-flat time across batch + rising time with context
# = memory bandwidth, not arithmetic.
From first principles
Start with the question

Why can you cache keys and values but not queries? All three are computed the same way from the same input by the same kind of projection — the asymmetry looks arbitrary.

  1. 1
    In causal decoding, at step t the model produces exactly one new token, so there is exactly one new query — the one belonging to position t.
    forced by · autoregressive generation emits one position at a time, and only the newest position needs an output
  2. 2
    That query must be scored against the keys of every position from 0 to t, and the resulting weights applied to the values of every position from 0 to t.
    forced by · the causal mask permits attending to all previous positions, and the model needs all of them to produce a good next token
  3. 3
    The key and value at position i are computed from the hidden state at position i, which was fixed the moment position i was processed and can never change afterwards.
    forced by · causality means position i's representation depends only on positions up to i, and those are all already final
  4. 4
    So keys and values are reused across steps and immutable once written — the exact profile of something worth caching. Queries are the opposite: each one is used at precisely one step, by precisely one position, and is never needed again.
    forced by · caching pays off only for values that are expensive to produce and consumed more than once
  5. 5
    Therefore the asymmetry is not about the tensors, it is about the direction of the causal mask. Keys and values are read by the future; queries read from the past. Only the thing the future will read repeatedly is worth keeping.
    forced by · the mask is lower-triangular, which makes past-position keys and values many-times-read and every query exactly-once-read
⇒ Therefore

Therefore what makes something cacheable here is reuse under the causal mask, and the K/V versus Q split falls straight out of it rather than being a design decision at all.

This predicts two things you can go and confirm. First: in an encoder with bidirectional attention there is no such asymmetry and no useful KV cache, because every position is recomputed whenever anything changes — which is exactly why encoder-style models get no benefit from this technique. Second, and more practically: since the cache depends only on the prefix, two requests sharing a prompt prefix can share its cache entries. That is prefix caching, and it is the single largest win available in a serving system where many requests share a long system prompt. The derivation tells you it must work before you ever read about it.

Mental modelThe growing ledger

Each layer keeps a ledger with one row per token processed so far, holding that token's key and value. Generating a token means: compute one query, read the entire ledger to score against it, take the weighted blend, append your own new row, move on.

The ledger only ever grows, it is never revised, and every single step reads all of it. That last property is the whole economics of inference: your per-token cost is set by the size of the ledger, so context length is not free even when the FLOPs say it should be. Every technique in production serving is either shrinking a row, sharing rows between requests, or evicting rows.

  • Cache size is layers x key-value heads x head dimension x context x batch x 2 (K and V) x bytes per element. Compute this before you deploy; it is often comparable to or larger than the weights.
  • Prefill and decode are different workloads. Prefill processes the whole prompt at once and is compute-bound; decode processes one token and is memory-bandwidth-bound. Metrics that average them together tell you nothing.
  • The cache depends only on the prefix, so identical prefixes can share it. Shared system prompts are the highest-leverage saving available.
  • Positional handling is where cache bugs hide: the new token's position must be its absolute index in the full sequence, not its index within the current forward call. Getting this wrong produces fluent but subtly wrong output rather than an error.
🔔 Fires when you see

Fire this model the moment you see: generation slowing down as the output gets longer · out-of-memory at high batch size that the weights alone do not explain · a discussion of MQA, GQA, quantised caches, or paged attention · time-to-first-token quoted separately from tokens-per-second · a serving system with a long shared system prompt.

The tradeoff

Your KV cache does not fit at the batch size you need. What do you give up?

Fewer key-value heads (MQA / GQA)
+ you gain cuts the cache by the ratio of query heads to key-value heads, which is the largest single reduction available and applies at every layer and every position; query-side diversity is untouched, so much of the expressivity survives
− you pay it is an architectural change — you cannot apply it to an existing checkpoint without converting the key-value heads and then continuing training to recover, so it is not a knob you turn at deployment time; and it costs some quality permanently
pick when you control training or can afford an uptraining run, and cache memory is the dominant term rather than a marginal one
Quantise the cache
+ you gain halves or quarters the cache with no architectural change and no retraining, applies to any existing checkpoint, and reduces bytes read per step — which directly speeds up the memory-bound decode as well as saving space
− you pay precision loss accumulates over long generations because errors in early cached entries influence every subsequent token; keys are typically more sensitive than values, so uniform quantisation of both is the wrong default; and it adds dequantisation work in the attention hot path
pick when you need a fast win on a model you did not train, and you can measure quality on long generations specifically rather than on short benchmarks
Evict or window the cache
+ you gain caps memory at a constant regardless of how long the generation runs, which is the only option that makes truly unbounded streaming possible; and it is a pure serving-side change
− you pay evicted context is gone, so the model genuinely loses access to it — this is not a compression, it is forgetting, and it reintroduces exactly the bounded-memory problem that attention was adopted to solve
pick when generations are unbounded in length and the task is genuinely local — live transcription, streaming assistants — where distant context has little bearing on the next token
Paged allocation
+ you gain eliminates the waste from allocating every sequence its maximum length up front, since real requests vary widely in length; this often recovers a large fraction of nominal capacity with no quality cost whatsoever
− you pay substantial serving-system complexity — a block allocator, indirection in the attention kernel, and fragmentation to manage; not something to build yourself
pick when you are serving heterogeneous request lengths at scale, which is the normal case, and can adopt an inference engine that implements it
What a senior engineer actually does

Take them in order of cost to you. Paged allocation first, because it is pure waste recovery with no quality tradeoff at all and is available off the shelf. Quantisation second, since it needs no retraining — but evaluate it on long generations, because that is where its failure mode lives and short benchmarks will not show it. Architectural changes last, since they require training.

Eviction is different in kind from the other three and deserves separate thought: the first three preserve the model's access to its context and only change how it is stored, while eviction removes information. Reach for it only when you have established that the task does not need the distant context — and establish that by measurement, not by assumption.


Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________________________

Spaced review: re-read §5 (memory math) and §8 (the production tricks) in 24 hours. On day 7, memorise the KV-cache cheat-sheet table for Llama 3 sizes.

Next session (S043): swap sinusoidal PE for RoPE, and explain FlashAttention-3's tiling trick — how a single kernel can compute softmax(QKᵀ)V without ever materialising the full (T, T) score matrix, saving huge memory on long contexts. We also close the loop on MLA.

Sticky note (keep on your desk): KV cache: past K,V frozen → cache them, only compute new q_t, k_t, v_t. O(N³) → O(N²). Memory dominates weights past 2k tokens. GQA/MLA + PagedAttention are how you make production math work.


Previous: ← DL S041 · Next: DL S043 →