KV Cache Memory Math: Sizing a Deployment¶
Baseline: vLLM 0.26.0 · model Qwen2.5-7B-Instruct · single RTX 4090 (24 GB)
Flags/log lines (gpu_memory_utilization, the startup "GPU KV cache size" report) are verified against vLLM 0.26.0 via Context7 (ADR-0004). VRAM sizes for weights/activations are illustrative / order-of-magnitude references — read the real ones off nvidia-smi and vLLM's own startup log. The budget arithmetic (a subtraction and a division) is exact.
1 · Intuition & why it matters¶
"How many users can one 4090 serve at 8k context?" is a capacity question you answer with arithmetic, before renting the GPU. Part 0's KV cache lesson gave you the per-token size \(\kappa = 2\,L\,n_{\text{kv}}\,d_h\,b\) and one back-of-envelope (~22 sequences). This lesson turns that single number into a deployment plan: assemble the full VRAM budget — weights + KV + activations + framework overhead — solve for the number you actually care about (max concurrency, or the max context you can promise), and know which knob buys how much.
This is the math behind every serving decision and half the system-design interview: given a model, a card, and an SLO, how many concurrent requests fit, and what do you change to hit a target? Get it right and your box holds its latency promise; get it wrong and it OOMs at peak load with the queue full. → see the Glossary for KV cache, PagedAttention, SLO.
2 · Mental model¶
VRAM is a stacked bar, and the KV cache is whatever's left after the fixed costs:
24 GB card (gpu_memory_utilization caps the usable height, default 0.92)
┌──────────────────────────────────────────────┐ ── util · 24 GB ──┐
│ CUDA context + framework ~1–2 GB │ fixed overhead │
├──────────────────────────────────────────────┤ │
│ model weights ~14 GB BF16 / ~5–6 GB AWQ │ fixed, paid once │ usable
├──────────────────────────────────────────────┤ │
│ activations / workspace scales with batch │ ~1 GB-ish │
├──────────────────────────────────────────────┤ │
│ KV CACHE ◄── everything left = your concurrency budget │
└──────────────────────────────────────────────┘ ───────────────────┘
concurrency = (KV budget) / (KV per sequence)
= (util·VRAM − weights − activations − overhead) / (κ · S)
Two shapes to hold:
- Concurrency is a leftover, not a target you set directly. You don't "set" how many sequences fit — you set weights (quantize or not), overhead margin (
gpu_memory_utilization), and per-sequence length (max_model_len), and the KV budget — hence concurrency — is what remains. Every capacity lever works by enlarging the leftover or shrinking the per-sequence cost. - The biggest lever is usually the weights, not the KV dtype. On a 24 GB card, BF16 weights (~14 GiB) eat most of the budget; quantizing them to 4-bit (~5–6 GiB) frees ~8 GiB straight into the KV budget — often a bigger concurrency win than halving KV bytes. Reach for weight quantization first, KV quantization second.
3 · Principle & math¶
From Part 0, one sequence of length \(S\) costs \(\kappa S\) bytes of KV, where \(\kappa = 2\,L\,n_{\text{kv}}\,d_h\,b\) (for Qwen2.5-7B: \(\kappa = 2\cdot28\cdot4\cdot128\cdot2 = 57{,}344\) bytes/token = 56 KiB). We don't re-derive it — we spend it.
The KV budget is the usable VRAM minus everything fixed:
where \(u\) = gpu_memory_utilization (default 0.92), \(V\) = card VRAM, \(W\) = weight bytes, \(A\) = activation/workspace, \(O\) = CUDA-context + framework overhead. Then the two questions you actually ask:
Max concurrency at a fixed context \(S\), and the inverse — the longest context you can promise if you must serve \(N\) concurrent streams.
Worked plan — Qwen2.5-7B on a 24 GB 4090, \(S = 8192\), \(u=0.90\), overhead+activations \(\approx 1.6\) GiB:
| weights | KV dtype | \(\kappa\) / token | KV per seq | KV budget | max concurrent |
|---|---|---|---|---|---|
| BF16 (~14.2 GiB) | BF16 | 56 KiB | 0.44 GiB | ~5.8 GiB | ~13 |
| AWQ 4-bit (~5.5 GiB) | BF16 | 56 KiB | 0.44 GiB | ~14.5 GiB | ~33 |
| AWQ 4-bit (~5.5 GiB) | FP8 | 28 KiB | 0.22 GiB | ~14.5 GiB | ~66 |
Read the escalation: quantizing weights roughly \(2.5\times\) the concurrency (it frees the biggest block); then quantizing the KV doubles it again (each sequence is half the bytes). Two knobs take you from ~13 to ~66 concurrent 8k-context streams on the same card — the entire serving-throughput story is visible in this one table. (All figures illustrative; the exact numbers are the engine's to report — see the Lab.)
Note this is more conservative than Part 0's ~22: that back-of-envelope ignored activations/overhead and used the full 24 GiB. Subtracting real overhead and applying gpu_memory_utilization is what a production plan does.
4 · Complete runnable code + line-by-line¶
A capacity planner — pure CPU, offline-runnable, no GPU. It's exactly the arithmetic vLLM does internally to decide how many KV blocks to allocate.
"""VRAM capacity planner: max concurrency & max context (pure CPU, offline)."""
from dataclasses import dataclass
GIB = 1024 ** 3
@dataclass
class Card:
vram_gib: float = 24.0 # RTX 4090
util: float = 0.90 # gpu_memory_utilization (vLLM default 0.92)
overhead_gib: float = 1.6 # CUDA context + activations/workspace (illustrative)
def kv_budget_gib(self, weight_gib: float) -> float:
return self.util * self.vram_gib - weight_gib - self.overhead_gib
def kv_per_seq_gib(kappa_bytes: int, seq_len: int) -> float:
return kappa_bytes * seq_len / GIB # kappa * S
def max_concurrency(card: Card, weight_gib: float, kappa_bytes: int, seq_len: int) -> int:
return int(card.kv_budget_gib(weight_gib) / kv_per_seq_gib(kappa_bytes, seq_len))
def max_context(card: Card, weight_gib: float, kappa_bytes: int, n_seq: int) -> int:
budget_bytes = card.kv_budget_gib(weight_gib) * GIB
return int(budget_bytes / (kappa_bytes * n_seq)) # the inverse question
if __name__ == "__main__":
card = Card()
KAPPA_BF16, KAPPA_FP8 = 57344, 28672 # Qwen2.5-7B KV bytes/token (Part 0)
W_BF16 = 7.615e9 * 2 / GIB # ~14.2 GiB dense weights
W_AWQ = 5.5 # ~5.5 GiB 4-bit (illustrative, measured)
S = 8192
plans = [
("BF16 weights, BF16 KV", W_BF16, KAPPA_BF16),
("AWQ weights, BF16 KV", W_AWQ, KAPPA_BF16),
("AWQ weights, FP8 KV", W_AWQ, KAPPA_FP8),
]
print(f"S={S} tokens, util={card.util}, overhead={card.overhead_gib} GiB\n")
for label, w, kappa in plans:
n = max_concurrency(card, w, kappa, S)
budget = card.kv_budget_gib(w)
print(f"{label}: KV budget {budget:5.1f} GiB -> ~{n:>3} concurrent seqs")
# Inverse: to serve 64 concurrent streams on the best config, how long a context?
s_max = max_context(card, W_AWQ, KAPPA_FP8, n_seq=64)
print(f"\nAWQ + FP8 KV, target 64 concurrent -> max context ~= {s_max} tokens")
Line-by-line:
Card.kv_budget_gib— the §3 budget: usable VRAM (\(u\cdot V\)) minus weights minus fixed overhead. This is the leftover the KV cache lives in.kv_per_seq_gib— \(\kappa\cdot S\) from Part 0, in GiB.kappa_bytesalready includes the ×2 for K and V and usesn_kv(GQA), so don't re-apply either.max_concurrency/max_context— the two boxed formulas: floor-divide the budget by per-sequence cost, or by per-token cost × target concurrency. Floors because a partial sequence doesn't fit.__main__— three plans escalating weight then KV quantization, plus the inverse question (fix concurrency, solve for context).W_BF16is computed from the param count;W_AWQis an illustrative measured size (4-bit weights carry scales/zeros, so it's not exactly params/4).
Expected output (exact arithmetic, not a benchmark):
S=8192 tokens, util=0.9, overhead=1.6 GiB
BF16 weights, BF16 KV: KV budget 5.8 GiB -> ~ 13 concurrent seqs
AWQ weights, BF16 KV: KV budget 14.5 GiB -> ~ 33 concurrent seqs
AWQ weights, FP8 KV: KV budget 14.5 GiB -> ~ 66 concurrent seqs
AWQ + FP8 KV, target 64 concurrent -> max context ~= 8484 tokens
The first three lines are the §3 table as code; the last line answers the inverse question a capacity planner actually gets asked ("we need 64 concurrent streams — how much context can we promise?"). Change one field — util, overhead_gib, S — and re-read the plan.
Reading it in vLLM's source (v0.26.0)¶
The two startup log lines the Lab reads are printed by the code that does this lesson's arithmetic — a short read-along closes the loop:
vllm/v1/core/kv_cache_utils.pycomputes the available KV bytes and logsGPU KV cache size: … tokensandMaximum concurrency for … tokens per request: …x— i.e. \(\text{KV}_{\text{budget}}/\kappa\) and that divided bymax_model_len, exactly yourmax_concurrency().- That budget then sizes the pool:
BlockPoolinvllm/v1/core/block_pool.pyallocatesnum_gpu_blocksfixed-size blocks, andKVCacheManagerinvllm/v1/core/kv_cache_manager.py(with a per-attention-typeSingleTypeKVCacheManager) hands blocks to sequences. The block size defaults toDEFAULT_BLOCK_SIZE = 16tokens (vllm.config.cache) — and §6's "block padding" pitfall is exactly the last, partly-filledKVCacheBlock.
So when the Lab's reported number sits just below your planner's, you're watching kv_cache_utils.py's honest budget (utilization + real overhead) against your ideal one — the same subtraction, done by the engine.
5 · Lab — check your plan against vLLM's own report¶
GPU Lab
- Min VRAM: 24 GB (loads
Qwen2.5-7B-Instruct-AWQ) - Suggested AutoDL card: RTX 4090 (24 GB)
- Est. time / cost: ~10 min · ~¥1 of GPU time (illustrative)
- Platform: NVIDIA CUDA (default)
- Non-NVIDIA: the startup KV-cache report is backend-agnostic, but achievable sizes and
kv_cache_dtype="fp8"support vary on ROCm/CPU — check your platform's vLLM build notes.
vLLM computes this exact budget at startup and prints it. Serve the model, read the log, and compare its number to your planner's:
vllm serve Qwen/Qwen2.5-7B-Instruct-AWQ \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--kv-cache-dtype fp8
On startup vLLM logs two verified lines (v0.26.0):
What to observe: "GPU KV cache size" (in tokens) is exactly your \(\text{KV}_{\text{budget}} / \kappa\), and "Maximum concurrency for 8,192 tokens" is that divided by max_model_len — vLLM's own version of max_concurrency(). Your planner derived ~66 for this config; the engine here reports ~64, and that small gap is the overhead/activation term you estimated versus what vLLM actually reserved — the engine's number sits just below the ideal-budget planner, never above. Now sweep: drop --kv-cache-dtype fp8 (KV doubles → the token count roughly halves), or lower --max-model-len (more concurrency at less context). Each change moves the reported number exactly as the formula predicts — the budget math made observable. (Numbers above are illustrative; yours depend on the checkpoint and driver.)
6 · Common pitfalls / counter-intuitive points¶
- Ignoring activations and overhead. The naive "free VRAM ÷ KV per seq" (Part 0's ~22) overstates capacity. Real deployments lose ~1–2 GiB to CUDA context + activation/workspace before any KV — subtract it or you OOM under load.
- Cranking
gpu_memory_utilizationto 0.99. Activations spike with concurrent prefills; too thin a margin boots fine then OOMs at peak. The default 0.92 leaves headroom on purpose. - Optimizing KV dtype before weights. On a 24 GB card the weights are the biggest block; quantizing them frees ~8 GiB — usually a bigger concurrency win than halving KV bytes. Do weights first.
- Re-applying the ×2 or using
n_heads. \(\kappa\) already has the factor of 2 (K and V) and usesn_kv(post-GQA), not attention heads. Double-counting either doubles/7×'s your estimate. - Treating
max_model_lenas free. It caps per-sequence KV, so raising it for long context directly cuts concurrency (\(N \propto 1/S\)). It's a trade, not a default to max out. - Forgetting block padding. PagedAttention allocates KV in fixed-size blocks; each sequence's last block is partly empty, so real concurrency is a hair below the formula. Paging trades a little padding for the elimination of external fragmentation.
7 · Interview links¶
- VRAM budget & max concurrency — the high-frequency question this lesson prepares you for: given Qwen2.5-7B on a 24 GB card at 8k context, walk the full VRAM budget, estimate max concurrency, and say how you'd hit a target of ~60 concurrent streams.
8 · Summary & further reading¶
One line: concurrency is a leftover — \(N_{\text{seq}} = \lfloor (u\cdot V - W - A - O)/(\kappa S)\rfloor\) — so capacity planning is subtract-the-fixed-costs then divide, and the biggest lever is quantizing the weights (frees the largest block) before quantizing the KV.
Further reading:
- vLLM docs — Conserving Memory / engine args (
gpu_memory_utilization,max_model_len,kv_cache_dtype) and the startup KV-cache report, baseline v0.26.0. - Efficient Memory Management for Large Language Model Serving with PagedAttention — why blocks, and where the leftover actually goes.
- The KV Cache lesson — the per-token \(\kappa\) this lesson spends.
- The sibling Operator Roofline lesson — why decode (which this KV feeds) is memory-bound in the first place.
9 · Self-check¶
A 24 GB card, util 0.90, ~1.6 GiB overhead, BF16 weights ~14.2 GiB, Qwen2.5-7B (κ=56 KiB/token), 8192-token context. How many concurrent sequences fit?
KV budget \(= 0.90\times24 - 14.2 - 1.6 = 5.8\) GiB. Per sequence \(= 56\,\text{KiB}\times8192 = 0.4375\) GiB. $N = \lfloor 5.8/0.4375\rfloor = $ ~13 (illustrative; ignores block padding). Quantizing the weights to AWQ (~5.5 GiB) raises the budget to ~14.5 GiB → ~33.
You need ~60 concurrent streams at 8k context on one 4090. What do you change, in what order?
(1) Quantize the weights (AWQ/GPTQ 4-bit) — frees ~8 GiB, the biggest single gain (~13 → ~33). (2) Quantize the KV (kv_cache_dtype=fp8) — halves per-sequence bytes (~33 → ~66), clearing 60. (3) If still short, cap max_model_len to the workload's real context (concurrency \(\propto 1/S\)). Weights first because they're the largest fixed block.
Why is the real max concurrency lower than (24 GB − weights) / (κ·S)?
That formula forgets three things: gpu_memory_utilization caps usable VRAM below the full 24 GB (default 0.92), CUDA context + activations/workspace consume ~1–2 GiB before any KV, and PagedAttention's fixed blocks leave each sequence's last block partly empty. The honest budget subtracts overhead and applies the utilization factor first — which is what vLLM's startup "GPU KV cache size" report reflects.