KV cache sizing calculator
Compute the cache footprint for a model config, then verify it against the number the engine reports at startup.
You can compute a model's KV cache footprint on paper from four numbers in its config. The engine will then tell you a different number at startup. This lab is about the gap: predicting it, measuring it, and being able to explain every byte of it.
The arithmetic half runs anywhere — laptop, no GPU, no network. The reconciliation half needs one GPU big enough to load a model: a 24 GB card with an 8B model is plenty. If you have no GPU, do Part A and read Part B; the numbers you would reconcile against are shown, and they are labelled cited, not measured.
run.py was written against the config shapes and engine log lines cited below,
and its argument handling and arithmetic were exercised, but it has not been run against a
live engine — no GPU was available while writing. Treat the reconciliation output as a
worked prediction, and report anything that disagrees.
What you measure
Three things, in order:
- Bytes per token, derived from the model config alone.
- Token capacity for a given card and a given memory-utilisation fraction.
- The residual — engine-reported capacity minus your prediction — and a named cause for it.
The third is the real exercise. A prediction that lands within a few percent teaches you nothing you did not already believe. A 13% gap that you can attribute to a padded page size or a reserved activation buffer teaches you how the allocator actually works.
The formula is FORMULAS' KV cell size, and the derivation is §2.1:
| Attention | Per token, all layers | Why |
|---|---|---|
| MHA / GQA / MQA | 2 · L · h_kv · d_h · b |
One K and one V entry per KV head per layer. |
| MLA | L · (kv_lora_rank + qk_rope_head_dim) · b |
One compressed latent per token per layer — not one per head, and not doubled. Applying the GQA formula to DeepSeek-V3 overstates the cache by more than an order of magnitude. See §7.2. |
Running it
Part A needs only a config.json:
$ python3 run.py --config ./config.json --vram 80
$ python3 run.py --model meta-llama/Meta-Llama-3-8B-Instruct --vram 24 --dtype fp8
$ python3 run.py --help # every knob, including --tp and --weights-gib
--util defaults to 0.92, matching vLLM's
--gpu-memory-utilization at the pinned SHA
(vllm/config/cache.py:L80). SGLang's analogue is
--mem-fraction-static and does not hold the same value, so when you reconcile
against SGLang, pass the number that engine actually used rather than assuming the default carries
across — see §2.6.
Predict before you read the output. For Llama-3-8B in bf16 the four numbers you need are
L=32, h_kv=8, d_h=128, b=2. Do that arithmetic
in your head first; the script should agree exactly, because it is the same arithmetic.
Part B starts an engine and reads the line it prints. vLLM's is emitted once, from the KV cache capacity path:
max_model_len = vllm_config.model_config.max_model_len
logger.info_once(
"GPU KV cache size: %s tokens, "
"Maximum concurrency for %s tokens per request: %.2fx",
f"{num_tokens:,}",
f"{max_model_len:,}",
max_concurrency,
)
SGLang prints its equivalent from the scheduler, on rank 0 only — which matters, because
under TP the other ranks stay silent and a grep without tp_rank == 0 in mind looks
like a missing log:
if self.ps.tp_rank == 0:
logger.info(
f"max_total_num_tokens={self.max_total_num_tokens}, "
f"chunked_prefill_size={get_schedule().chunked_prefill_size}, "
f"max_prefill_tokens={self.max_prefill_tokens}, "
f"max_running_requests={self.max_running_requests}, "
f"context_len={self.model_config.context_len}, "
$ vllm serve meta-llama/Meta-Llama-3-8B-Instruct 2>&1 | grep "GPU KV cache size"
$ python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct 2>&1 \
| grep max_total_num_tokens
$ python3 run.py --config ./config.json --vram 80 --reported <the number you just read>
What to expect
Your prediction will be high. The engine has costs your arithmetic does not model, and they run in this rough order of size:
| Cause | Direction | How to confirm it |
|---|---|---|
| Activation and CUDA-graph memory reserved before the cache is sized | large | Re-run with --enforce-eager (vLLM) and watch capacity rise. |
| Weights larger than a 2-bytes-per-param estimate | medium | Pass the engine's own reported weight footprint via --weights-gib. |
| Page size padded to a block boundary | small | Read page_size_bytes, below. |
| KV dtype differs from what you assumed | 2× either way | fp8 halves the per-token cost; check the flag you actually passed. |
TP does not divide h_kv evenly | small | Compare tp=1 against tp=2 against tp=4 on the same card count. |
The padding one is worth reading in the source, because it is the only one that is invisible from outside — the allocator will happily hand out a page larger than the data in it:
@property
def unpadded_page_size_bytes(self) -> int:
return self.num_heads * self.storage_block_size * self.state_content_size_bytes
@property
def page_size_bytes(self) -> int:
if self.page_size_padded is not None:
assert self.page_size_padded >= self.unpadded_page_size_bytes
return self.page_size_padded
return self.unpadded_page_size_bytes
Exercises
- Compute bytes-per-token for Llama-3-70B (
L=80,h_kv=8,d_h=128, bf16) by hand, then check it with the script. How many tokens fit in the KV cache of one 80 GB card after the weights, at tp=1? Is that even possible? - Run the script against a DeepSeek-V3 config. Then deliberately force the GQA formula by
deleting
kv_lora_rankfrom a copy of the config. By what factor do the two answers differ, and which one would you have written on a whiteboard? - Predict what happens to capacity when you halve
--gpu-memory-utilization. Is the relationship linear? Explain why not, in terms of the table above. - Start the same model at tp=1 and tp=2 and compare reported capacity. It will not be exactly double. Account for the difference.
- Find, in either engine, the code that decides how much memory is left for the cache. Then answer: is the cache sized before or after a profiling forward pass, and why does the order matter?
Answers
- Per token:
2 × 80 × 8 × 128 × 2 = 327,680bytes = 320 KiB. Weights at bf16 are ~132 GiB, which does not fit on one 80 GB card at all — so the honest answer is that the question is malformed, and that is the point. 70B in bf16 needs at least two cards before a single KV token exists. (Derived; arithmetic.) - MLA gives
61 × (512 + 64) × 2 = 70,272bytes/token. The GQA formula with 128 heads atd_h=128would give2 × 61 × 128 × 128 × 2 = 3,997,696bytes/token — roughly 57× larger. The whiteboard answer is wrong by that factor. (Derived; arithmetic.) - Affine in utilization while overhead is fixed, not proportional. Weights are subtracted first and do not shrink, so halving the utilisation fraction removes a fixed amount from a budget that was already net of weights — capacity falls by more than half, and can go negative, which is the "engine refuses to start" case.
- Weights shard cleanly, but the reserved activation and graph memory does not shard in
proportion, and
h_kv=8shards evenly only up to tp=8. Past that, KV heads are replicated rather than split. The cache cell then stops shrinking, but further weight sharding can still enlarge the available pool. - vLLM profiles activation use and separately accounts for graph and non-torch allocations before finalizing its cache budget. At the cited SGLang path,
mem_fraction_staticsets a heuristic reserve rather than an identical activation-profiling procedure. Check the selected runner and post-capture adjustments. Budget comparisons require identical explicit assumptions, not equal-looking utilization flags.
Key takeaways
- Four config numbers give you bytes-per-token exactly. There is no measurement in that step and no excuse for guessing it.
- MLA does not obey the GQA formula, and applying the wrong one is a ~57× error on DeepSeek-V3 — the single most common KV sizing mistake.
- Your prediction should come out high. If it comes out low, you have the dtype wrong.
- The residual is the lesson. Name its cause before you move on; "close enough" means you learned nothing.
- Capacity is not linear in
--gpu-memory-utilization, because weights are subtracted first.