KV Cache¶
Baseline: vLLM 0.26.0 · model Qwen2.5-7B-Instruct · single RTX 4090 (24 GB)
All flags/APIs on this page are verified against vLLM 0.26.0 via Context7 (ADR-0004). Performance figures are illustrative / order-of-magnitude references — measure the real ones on your own AutoDL box. The KV-size arithmetic below is exact (it's just multiplication), not a benchmark.
1 · Intuition & why it matters¶
An LLM generates text one token at a time. To produce token t, attention needs the Key and Value vectors of every previous token. The naive approach recomputes all of them at every step — quadratic, wasteful work.
The KV cache is the fix: compute each token's K and V once, store them, and reuse them for all future steps. This turns per-step work from "re-attend to the whole history" into "attend using cached history + one new token."
Here's the catch, and why this is the lesson to internalize first: the cache is not free — it lives in GPU memory and grows with every token and every concurrent request. On a 24 GB card, it is usually the KV cache — not the model weights — that decides how many requests you can serve at once. Every serving optimization later in this path (PagedAttention, continuous batching, quantization, prefix caching) is, at heart, a way to fit more useful KV cache into the same HBM.
2 · Mental model¶
The one picture to hold: your 24 GB of VRAM is split between weights (paid once) and KV cache (paid per token, per concurrent sequence) — and it's the KV part that runs out first:
24 GB VRAM, split two ways (BF16 weights; illustrative)
|<------------------------------ 24 GB total ------------------------------>|
+----------------------+------+------+------+------+------+ ~ +------+-------+
| weights ~14 GB | KV#1 | KV#2 | KV#3 | KV#4 | KV#5 | ~ | KV#N | free |
+----------------------+------+------+------+------+------+ ~ +------+-------+
paid ONCE, fixed \_____ KV cache: ~0.44 GiB per 8k-token sequence _____/
each grows +1 token / step;
N ~= 10 GB / 0.44 ~= 22 sequences = concurrency ceiling
Two consequences fall out immediately:
- Decode is memory-bound. Each step re-reads the entire KV cache from HBM but does a tiny amount of compute (one new token). The bottleneck is bandwidth, not FLOPs. → see the Glossary entries for Memory-bound and Roofline.
- Concurrency is a memory-budgeting problem. Weights are a fixed cost paid once; KV cache is a per-sequence cost that scales with
batch × sequence_length. Serving throughput is largely "how much KV cache fits" — every free byte the weights don't take is another slice of that KV region above.
3 · Principle & math¶
For a decoder-only Transformer, the KV cache stores one K tensor and one V tensor per layer, per KV head, per token. Its size is:
where \(L\) = number of layers, \(n_{\text{kv}}\) = number of KV heads (after grouping), \(d_h\) = head dimension, \(b_{\text{dtype}}\) = bytes per element (2 for BF16/FP16), \(S\) = sequence length, \(B\) = batch size.
Per single token (\(S = B = 1\)):
Worked example — Qwen2.5-7B-Instruct (\(L=28\), \(n_{\text{kv}}=4\), \(d_h=128\), BF16 so \(b=2\)):
At its default 32 768-token context, a single sequence's KV cache is \(56\,\text{KiB} \times 32768 \approx 1.75\) GiB. Run 8 such sequences concurrently and you've spent ~14 GiB on KV cache alone — on top of the weights.
Why GQA matters. Qwen2.5-7B uses \(n_{\text{kv}}=4\) KV heads while having 28 attention heads. If it used plain MHA (\(n_{\text{kv}}=28\)), the cache would be \(28/4 = 7\times\) larger — 392 KiB/token. That is GQA buying you a 7× smaller KV cache, and it's why nearly every modern model uses it.
4 · Complete runnable code + line-by-line¶
This calculator is offline-runnable — pure CPU, no GPU, no network. It turns the formula above into something you can poke at.
"""KV cache size calculator (pure CPU, offline-runnable)."""
from dataclasses import dataclass
@dataclass
class ModelConfig:
name: str
num_layers: int # transformer blocks (L)
num_kv_heads: int # KV heads AFTER grouping; == num_attention_heads for MHA
head_dim: int # dimension per head (d_h)
dtype_bytes: int = 2 # BF16/FP16 = 2; FP8/INT8 = 1
def kv_bytes_per_token(cfg: ModelConfig) -> int:
# leading 2 = one K tensor + one V tensor
return 2 * cfg.num_layers * cfg.num_kv_heads * cfg.head_dim * cfg.dtype_bytes
def kv_bytes(cfg: ModelConfig, seq_len: int, batch: int = 1) -> int:
return kv_bytes_per_token(cfg) * seq_len * batch
def gib(n: int) -> float:
return n / (1024 ** 3)
if __name__ == "__main__":
# Verified Qwen2.5-7B-Instruct config: 28 layers, 4 KV heads (GQA), head_dim 128.
qwen = ModelConfig("Qwen2.5-7B-Instruct", num_layers=28, num_kv_heads=4, head_dim=128)
per_tok = kv_bytes_per_token(qwen)
print(f"{qwen.name}: {per_tok} bytes/token = {per_tok / 1024:.0f} KiB/token")
for s in (2048, 8192, 32768):
print(f" seq_len={s:>6}: {gib(kv_bytes(qwen, s)):.2f} GiB (1 sequence)")
# Same model, hypothetical plain MHA (num_kv_heads == num_attention_heads == 28):
mha = ModelConfig("Qwen2.5-7B (hypothetical MHA)", num_layers=28, num_kv_heads=28, head_dim=128)
ratio = kv_bytes_per_token(mha) / per_tok
print(f"MHA would be {ratio:.0f}x larger: {kv_bytes_per_token(mha) / 1024:.0f} KiB/token")
Line-by-line:
ModelConfig— the four architecture numbers that drive KV size, plusdtype_bytes. Notenum_kv_heads, not attention heads: that distinction is the whole GQA story.kv_bytes_per_token— the formula from §3 withS = B = 1. The leading2is K and V; forgetting it is the most common factor-of-two bug.kv_bytes— scales per-token cost byseq_len × batch, exactly the \(S \times B\) term.gib— bytes → GiB (1024**3), so the numbers are comparable to a card's advertised VRAM.__main__— plugs in the verified Qwen2.5-7B config, prints per-token and per-sequence sizes at three context lengths, then contrasts against a hypothetical MHA variant to expose the 7× GQA win.
Expected output (exact arithmetic, not a benchmark):
Qwen2.5-7B-Instruct: 57344 bytes/token = 56 KiB/token
seq_len= 2048: 0.11 GiB (1 sequence)
seq_len= 8192: 0.44 GiB (1 sequence)
seq_len= 32768: 1.75 GiB (1 sequence)
MHA would be 7x larger: 392 KiB/token
5 · Lab — see the KV cache govern capacity in vLLM¶
GPU Lab
- Min VRAM: 24 GB
- Suggested AutoDL card: RTX 4090 (24 GB)
- Est. time / cost: ~15 min · ~¥1 of GPU time (illustrative)
- Platform: NVIDIA CUDA (default)
- Non-NVIDIA: vLLM also runs on AMD ROCm and CPU builds;
kv_cache_dtype="fp8"support and exact memory behavior vary by backend — check your platform's vLLM build notes.
The knobs that control the KV cache in vLLM 0.26.0 (all verified via Context7):
from vllm import LLM, SamplingParams
# AWQ 4-bit weights (~5–6 GB) leave most of the 24 GB for KV cache.
# vLLM auto-detects AWQ from this checkpoint (quantization itself is Part 4).
llm = LLM(
model="Qwen/Qwen2.5-7B-Instruct-AWQ",
max_model_len=8192, # caps per-sequence KV length -> caps its KV cache
gpu_memory_utilization=0.90, # fraction of 24 GB vLLM may use (default is 0.92)
kv_cache_dtype="fp8", # ~halve KV bytes vs bf16 -> more concurrent seqs (illustrative)
enable_prefix_caching=True, # reuse KV of shared prefixes across requests
)
out = llm.generate(["Why is LLM decode memory-bound?"], SamplingParams(max_tokens=64))
print(out[0].outputs[0].text)
Equivalent server CLI:
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--kv-cache-dtype fp8 \
--enable-prefix-caching
What to observe: on startup vLLM prints the number of KV cache blocks it allocated. Lower max_model_len or kv_cache_dtype fp8 → more blocks → more concurrent sequences. That block count is your concurrency ceiling, made concrete.
6 · Common pitfalls / counter-intuitive points¶
- It's the KV cache, not the weights, that caps concurrency. Weights are paid once; KV scales with
#sequences × length. Doubling context roughly doubles KV, not compute. - The factor-of-2 bug. K and V. Drop it and every estimate is half the truth.
- Decode being memory-bound feels wrong ("it's a huge model, surely it's compute!"). But each decode step re-reads the entire KV cache from HBM and does one token's worth of math — bandwidth is the wall.
gpu_memory_utilizationis a double-edged knob. Too high → OOM under real load (activations spike); too low → you leave KV capacity (throughput) on the table.kv_cache_dtype="fp8"is not free accuracy-wise. It halves KV bytes but can shift outputs — treat the quality delta as something to measure, not assume.- PagedAttention still has (small) waste. vLLM allocates KV in fixed-size blocks (
--block-sizetokens); a sequence's final block is usually partly empty. This internal fragmentation is tiny compared to naive contiguous allocation — that's the point of paging.
7 · Interview links¶
- KV cache & throughput ceiling — the high-frequency question this lesson prepares you for: why is the KV cache, not compute, usually the bottleneck?
8 · Summary & further reading¶
One line: the KV cache trades recomputation for memory, and that memory — growing with tokens and concurrency — is the central constraint every serving optimization exists to relax.
Further reading:
- vLLM docs — Automatic Prefix Caching and engine arguments (baseline v0.26.0).
- Efficient Memory Management for Large Language Model Serving with PagedAttention (the vLLM paper).
- GQA: Training Generalized Multi-Query Transformer Models — why fewer KV heads.
- The KV-cache math lesson (Part 2) — the full sizing arithmetic (bytes/token → how many sequences fit).
- The PagedAttention lesson (Part 5) — how vLLM actually stores and manages this cache in fixed-size blocks.
9 · Self-check¶
A model has L=32 layers, n_kv=8 KV heads, head_dim=128, BF16. What's its per-token KV cache?
\(2 \times 32 \times 8 \times 128 \times 2 = 131072\) bytes \(= 128\) KiB/token. (Then multiply by sequence length and batch for the total.)
Why is the decode phase memory-bound rather than compute-bound?
Each decode step reads the entire KV cache from HBM but computes only one new token's worth of attention/FFN. The work-per-byte-moved is tiny, so the GPU stalls on memory bandwidth, not on arithmetic. Prefill, which processes many tokens at once, is the compute-bound counterpart.
You have 24 GB and ~14 GB of weights. Roughly how many 8192-token Qwen2.5-7B sequences fit in KV cache (BF16), ignoring activations?
Per sequence at 8192 tokens ≈ 0.44 GiB. Remaining ≈ 10 GiB / 0.44 ≈ ~22 sequences (illustrative; real capacity is lower once activations and block padding are counted — and higher with kv_cache_dtype fp8 or a quantized model that shrinks the weights).