ML Interview Notes
26 min read13 sections
Part 2 · Memory and the KV cache · 02-01

The KV cache size formula

Status
SOURCE PINNED
Primary sources
  • vllm/v1/kv_cache_interface.py
  • vllm/v1/core/kv_cache_utils.py
  • python/sglang/srt/mem_cache/memory_pool.py
Edition pins
vllm a556f3f · sglang 7d89325

Every capacity question in LLM serving reduces to one division: how many bytes are left after the weights, divided by how many bytes one token of context costs. This chapter derives the numerator and the denominator carefully, works both for four real models, and shows the two lines of Python in each engine that compute exactly this at startup.

52
Concurrent 8k requests, Llama-3-8B, one H100 (derived)
3
Same card, Qwen3-32B in bf16
68.6 KiB
Per token, DeepSeek-V3 — smaller than Llama-3-8B
§1

The problem

You have an 80 GB H100 and a Qwen3-32B checkpoint. The weights are 61 GiB in bf16, which fits with room to spare, so you start the server. It comes up. Then the second concurrent user arrives and throughput collapses; by the fourth, requests are being preempted and recomputed. Push the context window up and vLLM refuses to start at all:

vllm/v1/core/kv_cache_utils.py:L798-L809 vLLM
        raise ValueError(
            f"To serve at least one request with the model's max seq len "
            f"({max_model_len}), ({format_gib(needed_memory)} GiB KV "
            f"cache is needed, which is larger than the available KV cache "
            f"memory ({format_gib(available_memory)} GiB). {estimated_msg}"
            f"Try increasing `gpu_memory_utilization` (which also controls "
            f"CPU memory on the CPU backend) or decreasing `max_model_len` "
            f"when initializing the engine. "
            f"See https://docs.vllm.ai/en/latest/configuration/conserving_memory/ "
            f"for more details."
        )

Nothing here is a bug. The card had 6.3 GiB left for KV after weights, activations and headroom, and one Qwen3-32B token costs 256 KiB, so the pool holds ~25,600 tokens — three 8k conversations. The whole failure is arithmetic you could have done before provisioning. §0.2 established why the cache exists and quoted 128 KiB per token for Llama-3-8B; this chapter derives that number from layer shapes, generalises it to four architectures, and turns it into a fleet plan.

§2

Mental model

An 80 GB H100 is a fixed box with four tenants, only one of them elastic. Weights are a rent paid once, identical whether you serve one request or a thousand. Activations and CUDA graph buffers are a roughly fixed working set sized by the prefill chunk and the largest captured batch. The engine holds back a slice as headroom against fragmentation. Whatever survives is the KV pool, and the KV pool is your concurrency — there is no second lever.

Figure 1 — the same 79.65 GiB card, three deployments. All segment widths are derived arithmetic from published model shapes and the engines' default fractions; see §5 for the budget and its assumptions. Nothing is measured.

HBM budget of one H100 for three model deployments Three stacked horizontal bars each totalling 79.65 GiB. Llama-3-8B in bf16 spends 14.96 GiB on weights and leaves 52.32 GiB for KV. Qwen3-32B in bf16 spends 61.02 GiB on weights and leaves only 6.26 GiB for KV. Llama-3-70B sharded over eight GPUs spends 16.43 GiB per GPU on weights and leaves 50.85 GiB for KV. weights activations + graphs KV pool utilisation headroom (8%) Llama-3-8B bf16, TP=1 Qwen3-32B bf16, TP=1 Llama-3-70B bf16, TP=8, per GPU 14.96 52.32 GiB KV = 52 x 8k 61.02 16.43 50.85 GiB KV = 162 x 8k 6.26 GiB KV = 3 x 8k 0 GiB of HBM 79.65

The middle bar is the whole chapter in one picture: Qwen3-32B has 4× the parameters of Llama-3-8B but does not get 1/4 of the concurrency — it gets 1/17, because weights eat the budget before the division happens.

§3

First principles: deriving the formula

Walk one layer and count what must survive to the next decode step. The layer takes a token's hidden state $x \in \mathbb{R}^{d}$ and projects it three ways:

$$q = W_Q x \in \mathbb{R}^{h \cdot d_h}, \qquad k = W_K x \in \mathbb{R}^{h_{kv} \cdot d_h}, \qquad v = W_V x \in \mathbb{R}^{h_{kv} \cdot d_h}$$

with $L$ layers, $h$ query heads, $h_{kv}$ key/value heads, head dimension $d_h$, and $b$ bytes per stored element. Attention computes $\text{softmax}(qK^\top/\sqrt{d_h})V$ against all previous positions. Three facts fall out:

factor 2

K and V, not Q

$K$ and $V$ appear inside the sum over past positions; $q$ appears only as the current row, consumed the instant it is used and never referenced again. Two tensors survive per token per layer — hence the leading 2.

factor L

Every layer, independently

Layer $\ell$'s attention is over layer $\ell$'s own keys, so the per-token cost multiplies by $L$. (Cross-layer sharing exists — vLLM's add_kv_sharing_layers_to_kv_cache_groups in vllm/v1/worker/utils.py — but no model here uses it.)

h_kv, not h

Grouped-query attention

Under GQA, $h$ query heads are partitioned into $h_{kv}$ groups sharing one K/V pair, so $W_K$ and $W_V$ project to $h_{kv} \cdot d_h$. Query heads are broadcast against the shared keys at compute time; nothing extra is stored. That is why $h$ never appears in the size formula.

Multiplying through gives the formula this book uses everywhere, for a batch of $B$ requests with lengths $s_1 \ldots s_B$:

$$\text{bytes}_{\text{KV}} \;=\; 2 \cdot L \cdot h_{kv} \cdot d_h \cdot b \cdot \sum_{i=1}^{B} s_i$$

Everything before the sum is a per-token constant fixed by the checkpoint and the cache dtype. Call it $c$, the cell size — SGLang's own name for it. For Llama-3-8B in bf16 ($L=32$, $h_{kv}=8$, $d_h=128$, $b=2$):

$$c = 2 \times 32 \times 8 \times 128 \times 2 = 131{,}072 \text{ bytes} = 128 \text{ KiB per token}$$

Check it a second way: one K vector is $8 \times 128 = 1024$ elements at 2 bytes = 2 KiB, K plus V is 4 KiB per layer, 32 layers is 128 KiB. Run that agreement check whenever you meet a new architecture.

The trap

$d_h$ is not always $d/h$. Qwen3-32B has $d = 5120$, $h = 64$, so $d/h = 80$ — but its config declares head_dim: 128, and $64 \times 128 = 8192 \neq 5120$; the attention projections deliberately widen. Read head_dim from the config when it exists. Deriving it under-counts Qwen3-32B's cache by 37%.

Where MLA breaks the formula

DeepSeek-V3 stores one compressed latent vector per token per layer, of width $\text{kv\_lora\_rank} + \text{qk\_rope\_head\_dim}$, and reconstructs per-head keys and values inside the kernel. vLLM encodes that by setting the KV-head count to one and the V-side head size to zero:

vllm/v1/kv_cache_interface.py:L381-L392 vLLM
@dataclass(frozen=True, kw_only=True)
class MLAAttentionSpec(FullAttentionSpec):
    # TODO(Lucas/Chen): less hacky way to do this
    cache_dtype_str: str | None = None
    # DeepseekV4 only fields. Non-DeepseekV4 MLA models leave these at defaults.
    alignment: int | None = None  # Default to None for no padding.
    compress_ratio: int = 1  # Default to 1 for no compression.
    model_version: str | None = None
    # Marks draft groups that flatten a non-causal query block into decode rows.
    non_causal_multi_token_decode: bool = False
    # MLA stores a single latent vector per state; there is no separate V.
    head_size_v: int = 0

The layer supplies num_kv_heads=1 and a head size that is the sum of the two latent widths (vllm/model_executor/layers/attention/mla_attention.py:L438 and :L1193-L1197), so the formula collapses to

$$c_{\text{MLA}} = L \cdot (r_{kv} + d_{\text{rope}}) \cdot b$$

with no factor of 2 and no head count. For DeepSeek-V3 ($L = 61$, $r_{kv} = 512$, $d_{\text{rope}} = 64$, $b = 2$): $61 \times 576 \times 2 = 70{,}272$ bytes, or 68.6 KiB per token — smaller than Llama-3-8B, for a 671B-parameter model. Why it works, and what it costs in kernel complexity, is §7.2; here it is just a different $c$.

§4

Four models, worked

Every shape below was read from the model's published config.json on the Hugging Face Hub (fetched August 2026), not from memory — Llama-3 from the ungated NousResearch/Meta-Llama-3-{8B,70B} mirrors, Qwen from Qwen/Qwen3-32B and Qwen/Qwen2.5-72B-Instruct, DeepSeek-V3 from deepseek-ai/DeepSeek-V3. Parameter counts are recomputed from those shapes rather than quoted, and land on the published headline figures (8.03B, 70.6B, 32.8B) — itself a check that the shapes were read correctly.

Per-token KV cost. All byte figures derived from the cited config shapes at $b = 2$ (bf16 KV); no measurement.
ModelLhh_kvd_hbytes/tokenKiB/tokenGiB @ 8k
DeepSeek-V3 MLA611281*576*70,27268.60.536
Llama-3-8B32328128131,0721281.000
Qwen3-32B64648128262,1442562.000
Llama-3-70B80648128327,6803202.500
Qwen2.5-72B-Instruct80648128327,6803202.500

*DeepSeek-V3's config declares num_key_value_heads: 128, qk_nope_head_dim: 128, qk_rope_head_dim: 64, v_head_dim: 128. Those are the logical attention shapes, not what is stored; the 1 and 576 in the table are what the engines allocate. Plugging the logical numbers into the GQA formula — $61 \times 128 \times (192 + 128) \times 2$ — gives 4.77 MiB per token, a 71× over-estimate, and it is the most common sizing mistake on MLA models.

Two other things. Llama-3-70B and Qwen2.5-72B-Instruct cost identical bytes because $L$, $h_{kv}$ and $d_h$ match exactly — parameter count tells you nothing about cache cost. And Llama-3-8B lands on exactly 1 GiB at 8,192 tokens, a useful mental unit: at 8k, one gibibyte is one request.

Figure 2 — bytes per resident token, to scale. Derived from the config shapes above. The bottom bar is the counterfactual: what DeepSeek-V3 would cost if it stored per-head K and V like a GQA model.

Bytes per token across five models Horizontal bars of KiB per token: DeepSeek-V3 at 68.6, Llama-3-8B at 128, Qwen3-32B at 256, Llama-3-70B and Qwen2.5-72B both at 320, and an off-scale bar for DeepSeek-V3 hypothetically stored as grouped-query attention at 4,880 KiB. 0 80 160 240 320 KiB per token, bf16 KV DeepSeek-V3 Llama-3-8B Qwen3-32B Llama-3-70B Qwen2.5-72B 68.6 128 256 320 320 if stored as GQA 4,880
§5

How many 8k requests fit on one H100

The subtraction has four terms, and three of them are not exact, so every assumption is stated.

$$\text{pool} = C_{\text{total}} \cdot u \;-\; \text{weights} \;-\; \text{(activations + graphs + non-torch)}$$
  1. $C_{\text{total}} = 79.65$ GiB — an H100 SXM 80 GB reports 81,559 MiB to the driver. Conventional reported figure, not measured here; if your card differs, everything below scales.
  2. $u = 0.92$, vLLM's gpu_memory_utilization default at this SHA (vllm/config/cache.py:L80), leaving 6.37 GiB untouched.
  3. Weights = parameter count × bytes per weight, both derived from config.
  4. Activations + CUDA graphs + non-torch = 6.0 GiB. The soft term. vLLM measures it with a profiling forward pass (§7); SGLang estimates it a priori and reserves 13.1 GiB at H100 defaults. 6.0 sits between the two; §2.6 owns it.

Because Llama-3-8B costs exactly 1 GiB per 8k request, the sensitivity reads straight off the page: every gibibyte of error in that allowance is one request.

Capacity on one 80 GB H100. Every column derived: $C = 79.65$ GiB, $u = 0.92$, overhead allowance 6.0 GiB, KV in bf16. Not measured.
Deploymentweights GiBKV pool GiBKiB/token/GPUresident tokens8k requests
Llama-3-8B, bf16, TP=114.9652.32128428,56952
Qwen3-32B, bf16, TP=161.026.2625625,6233
Qwen3-32B, fp8 weights, TP=130.5136.77256150,59218
Llama-3-70B, fp8 weights, TP=165.711.573205,1300
Llama-3-70B, bf16, TP=8 (per GPU)16.4350.85401,332,954162
DeepSeek-V3, fp8, 16×H100 (per GPU)39.0628.2268.6431,18652

Four things in that table are worth more than the table.

Llama-3-70B in bf16 does not fit on one H100 under this budget. With fp8 weights, the illustrative 1.57 GiB KV pool cannot admit an 8192-token request, but can hold shorter contexts. Zero in the last column means zero full 8k requests, not an impossibility of every single-GPU quantized serving configuration.

Tensor parallelism divides the KV cache too. Under TP=8 each rank owns one KV head, so the per-GPU cell size is $320/8 = 40$ KiB — the division is explicit in get_num_kv_heads, quoted in §7. Every GPU holds one eighth of every token, so 1.33M resident tokens is a cluster-wide figure and 162 concurrent 8k requests is what the node serves. §5.1 owns the mechanism.

MLA's cache is not divided by TP. With $h_{kv} = 1$ there is nothing to shard; every rank keeps the full latent. Hence 68.6 KiB per token per GPU and 52 concurrent 8k requests across a 16-GPU deployment — the same concurrency as Llama-3-8B on one card. Adding GPUs does not divide the latent cell size, but can free KV capacity by reducing per-rank weight memory. (Decode context parallelism is the escape hatch; both engines carry a dcp_size that shards the latent.)

Quantising the weights is a KV lever. Qwen3-32B goes from 3 concurrent requests to 18 by halving the weights alone. Where weights dominate the card, weight quantisation buys concurrency more cheaply than KV quantisation does — the reverse of the usual intuition. §2.5 covers the other half.

Sizing DeepSeek-V3

671B fp8 weights are ~625 GiB, so 8×H100 (78 GiB/GPU before anything else) cannot hold them; 16×H100 at 39 GiB/GPU is the smallest H100 count that works. The 671B figure is cited from the DeepSeek-V3 report; the released checkpoint is larger once bf16 modules, per-block scales and the MTP head are counted, so treat 625 GiB as a floor. DeepSeek-V3.2's sparse-attention indexer adds a second per-token cache (_compute_dsa_indexer_cell_size, python/sglang/srt/model_executor/pool_configurator.py:L352).

§6

It scales with resident tokens, not batch size

The batch enters the formula only through $\sum_i s_i$; nothing multiplies $B$ on its own. The consequence is the most useful fact in this chapter, and it is routinely got wrong.

Three workloads on Llama-3-8B, one H100, 52.32 GiB pool. Derived.
Workloadrequestsmean lengthresident tokensKV GiB
Long chats528,192425,98452.0
Short classification calls1,664256425,98452.0
Mixed RAG traffic2082,048425,98452.0

All three saturate the same pool. A server that comfortably runs 1,600 concurrent 256-token requests falls over at 60 concurrent 8k requests, and no batch-size tuning changes that: the constraint is a token budget, not a request budget. This is why both engines' admission control is written against token counts, why --max-num-seqs is a weak knob beside the pool size, why the scheduler is best understood as a memory allocator (§1.4), and why prefix caching is a capacity feature rather than only a latency one — shared prefixes are shared tokens (§2.3, §2.4).

The other bound

Fitting is not the same as being fast. §0.4 derived a KV-bandwidth ceiling of ~3,120 tok/s at 8k context on this card: reading 1 GiB of cache per decoded token at 3.35 TB/s caps aggregate output however many requests you admit. Sizing gives the maximum you can hold; the roofline gives what holding it costs per step. Provision against whichever binds first.

§7

How the engines compute this themselves

Both engines take the same three steps at startup — measure what is free, subtract what is spent, divide by a per-token constant — but factor them differently, and the difference shows up in what you can predict before launching.

Figure 3 — the two startup paths to the same integer. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…

vLLM: a per-layer page, then integer division

vLLM never writes the size formula as one expression. It writes the size of one page of one layer and lets the allocator multiply:

vllm/v1/kv_cache_interface.py:L243-L259 vLLM
    @property
    def state_content_size_bytes(self) -> int:
        """Bytes per (head slot, stored state) cell of the page."""
        if self.state_content_bytes is not None:
            return self.state_content_bytes
        return (self.head_size + self.head_size_v) * get_dtype_size(self.dtype)

    @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

Map it onto the derivation. head_size + head_size_v is the factor of 2 — written as a sum rather than a product so MLA can set head_size_v = 0 and asymmetric-head models ($d_v \neq d_k$) work without a special case. num_heads is $h_{kv}$; storage_block_size is the block length in tokens, default 16 (vllm/config/cache.py:L59); $L$ is absent because this is one layer. For Llama-3-8B: $8 \times 16 \times 256 \times 2 = 65{,}536$ bytes per page.

The per-request ceiling multiplies by the block count for the longest allowed sequence:

vllm/v1/kv_cache_interface.py:L300-L305 vLLM
    def max_memory_usage_bytes(self, vllm_config: VllmConfig) -> int:
        max_model_len = vllm_config.model_config.max_model_len
        dcp_world_size = vllm_config.parallel_config.decode_context_parallel_size
        if dcp_world_size > 1:
            max_model_len = cdiv(max_model_len, dcp_world_size)
        return cdiv(max_model_len, self.block_size) * self.page_size_bytes

And the pool-to-blocks conversion is one line — note the second division, which is where $L$ finally enters:

vllm/v1/core/kv_cache_utils.py:L1038-L1040 vLLM
    num_blocks = int(available_memory // page_size // num_layers)
    num_blocks = max(num_blocks, 0)
    return may_override_num_blocks(vllm_config, num_blocks)

SGLang: one cell size, one division

SGLang collapses the same product into a single scalar it calls the cell size, computed once for the whole model:

python/sglang/srt/model_executor/pool_configurator.py:L326-L333 SGLang
        else:
            n = model_config.get_num_kv_heads(tp_size, dcp_size)
            cell_size = (
                n
                * (model_config.head_dim + model_config.v_head_dim)
                * effective_num_layers
                * kv_size
            )

That is $\;h_{kv} \cdot (d_k + d_v) \cdot L \cdot b\;$ — the chapter's formula verbatim, with the same sum-instead-of-2 trick. The KV-head count is already the per-rank count, which is the citation behind the TP claim in §4:

python/sglang/srt/configs/model_config.py:L1166-L1177 SGLang
    def get_num_kv_heads(self, tensor_parallel_size: int, dcp_size: int = 1) -> int:
        """Number of KV heads per GPU.

        DCP ranks replicate KV, so heads shard across ``tp // dcp`` groups.
        Drafts never join the group and ignore ``dcp_size``. With fewer heads
        than groups, each GPU keeps one.
        """
        total_num_kv_heads = self.get_total_num_kv_heads()
        if self.is_draft_model:
            dcp_size = 1
        kv_tensor_parallel_size = tensor_parallel_size // dcp_size
        return max(1, total_num_kv_heads // kv_tensor_parallel_size)

The MLA branch a few lines earlier (pool_configurator.py:L257-L265) takes the latent dimension instead — calculate_mla_kv_cache_dim(...) * effective_num_layers * kv_size — and crucially does not divide by tp_size. That asymmetry is the code-level statement that MLA caches are replicated per rank. The latent width is the sum from §3:

python/sglang/srt/mem_cache/kv_cache_configurator.py:L2147-L2153 SGLang
    kv_lora_rank = model_config.kv_lora_rank
    qk_rope_head_dim = model_config.qk_rope_head_dim
    kv_cache_dim = kv_lora_rank + qk_rope_head_dim  # default mla kv cache dim

    # For non-DSA models, MLA kv cache dim is simply kv_lora_rank + qk_rope_head_dim
    if not is_dsa_model:
        return kv_cache_dim

The division is then trivial:

python/sglang/srt/model_executor/pool_configurator.py:L415-L424 SGLang
    def calculate_pool_sizes(
        self, available_bytes: int, page_size: int
    ) -> MemoryPoolConfig:
        max_total_num_tokens = (
            available_bytes // self._cell_size
            if self._cell_size
            else self._zero_kv_max_tokens
        )
        max_total_num_tokens = max_total_num_tokens // page_size * page_size
        return MemoryPoolConfig(max_total_num_tokens=max_total_num_tokens)

The two shapes are visible in the allocated tensors too — MHA takes two buffers per layer indexed by KV head, MLA one with a head axis pinned to 1:

python/sglang/srt/mem_cache/memory_pool.py:L2056-L2060 and L4010-L4018 SGLang
        rows = self.size + self.page_size
        return (
            (rows, self.head_num, self.head_dim),
            (rows, self.head_num, self.v_head_dim),
        )
# ...
                self.kv_buffer = [
                    torch.zeros(
                        (self.size + self.page_size, 1, self.kv_cache_dim),
                        dtype=self.store_dtype,
                        device=self.device,
                    )
                    for _ in range(self.layer_num)
                ]

Where they differ, and why it matters

vLLM

Measure, then divide

determine_available_memory runs a real forward pass and subtracts what it observed. Accurate for your batch shape, but the pool size is unknowable until the engine starts, and it moves when --max-num-batched-tokens moves.

SGLang

Estimate, then divide

mem_fraction_static comes from a formula over GPU class and prefill chunk size, before anything runs. Predictable and scriptable, but conservative: ~13.1 GiB held back at H100 defaults, and the source calls its key coefficient a heuristic.

python/sglang/srt/server_args.py:L5005-L5020 SGLang
                # Constant meta data (e.g., from attention backend) + activation slack.
                reserved_mem = 512
                reserved_mem += activation_tokens * 1.5
                # Some adjustments for large parallel size
                reserved_mem += self.tp_size * self.pp_size / 8 * 1024
                reserved_mem += self.reserve_for_graph_mb()
                if gpu_mem is not None and gpu_mem > 60 * 1024:
                    reserved_mem = max(reserved_mem, 10 * 1024)
                # Reserve headroom for DeepEP all-to-all buffers on top of the floor.
                reserved_mem += self.reserve_for_deepep_a2a_mb()

            self.mem_fraction_static = (
                round((gpu_mem - reserved_mem) / gpu_mem, 3)
                if gpu_mem is not None
                else 0.88
            )

Work it for an H100 at defaults: chunked_prefill_size is 8192 in the 60–90 GB band (server_args.py:L4894-L4903), so activation_tokens = 8192, and reserve_for_graph_mb contributes decode max_bs * 2 = 512 MB at max_bs = 256 (server_args.py:L5124). Total $= 512 + 12{,}288 + 128 + 512 = 13{,}440$ MB, above the 10,240 MB floor, so $\text{mem\_fraction\_static} = \text{round}((81559 - 13440)/81559, 3) = 0.835$ — computable on paper before touching a GPU, which is the point.

§8

Worked trace: Llama-3-8B from launch to log line

What happens between vllm serve and the line that tells you your capacity.

  1. Worker.init_device takes a MemorySnapshot and calls request_memory (vllm/v1/worker/utils.py:L444-L451), computing ceil(total_memory * gpu_memory_utilization) = $0.92 \times 79.65 = 73.28$ GiB and raising if that much is not already free.
  2. Weights load: 14.96 GiB, recorded as model_memory_usage.
  3. Worker.determine_available_memory (vllm/v1/worker/gpu_worker.py:L475) enters memory_profiling and calls model_runner.profile_run() — a real forward pass on a dummy batch of max_num_batched_tokens tokens, which is what makes the activation term measured rather than guessed — then profile_cudagraph_memory().
  4. The subtraction:
    vllm/v1/worker/gpu_worker.py:L559-L562 vLLM
            self.available_kv_cache_memory_bytes = (
                self.requested_memory
                - profile_result.non_kv_cache_memory
                - cudagraph_memory_estimate_applied
    With our 6.0 GiB allowance for everything non-weight and non-KV, this is $73.28 - 14.96 - 6.0 = 52.32$ GiB.
  5. Each Attention layer contributes a FullAttentionSpec (vllm/model_executor/layers/attention/attention.py:L648-L654) with num_kv_heads=8, head_size=128, head_size_v=128, dtype=bfloat16. All 32 are identical, so get_kv_cache_config_from_groups makes one group of group_size = 32.
  6. get_num_blocks divides. Carry the pool at full precision — it is $73.27566 - 14.96 - 6.0 = 52.31566$ GiB, displayed as 52.32 above — because the floor divisions amplify a two-decimal rounding into a two-block error: $52.31566 \times 2^{30} \div 65{,}536 \div 32 = \mathbf{26{,}785}$ blocks, or $428{,}560$ tokens.
  7. get_max_concurrency_for_kv_cache_config (vllm/v1/core/kv_cache_utils.py:L967-L989) divides that by the blocks one max-length request needs — $\lceil 8192/16 \rceil = 512$ — giving $52.31$. Then:
    vllm/v1/core/kv_cache_utils.py:L1925-L1931 vLLM
        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 lands within 9 tokens of the same answer by a different route: _profile_available_bytes (python/sglang/srt/mem_cache/kv_cache_configurator.py:L1764-L1811) computes rest_memory = available_gpu_memory - slack_gb - mm_reservation_gb; calculate_pool_sizes divides by cell_size = 131,072 to get 428,569 tokens; MHATokenToKVPool._finalize_allocation_log (memory_pool.py:L1674-L1701) prints the K and V sizes in GB, and the scheduler prints max_total_num_tokens=... (python/sglang/srt/managers/scheduler.py:L1094-L1099). The 9-token gap is vLLM's 16-token block granularity against SGLang's default page size of 1.

§9

Pitfalls and war stories

Three ways to get $c$ wrong. Using $h$ instead of $h_{kv}$ over-estimates Llama-3-8B by 4× and an MQA model by $h$× — the tell is that your predicted pool is a clean multiple of the logged one. Deriving $d_h$ as $d/h$ under-counts Qwen3-32B by 37%. And applying the GQA formula to MLA is the 71× error from §4, which makes DeepSeek-V3 look unservable when it has the cheapest cache in the table.

Forgetting the block-granularity rounding. vLLM allocates whole blocks, so a 17-token request occupies 32 slots at block_size=16 — a real tax on short-request workloads, and the subject of §2.2.

Assuming max_model_len does not matter. It does not change bytes per token, but every capacity check is written against it (max_memory_usage_bytes above) and it is the denominator in the concurrency number: cut it from 128k to 8k and the reported concurrency rises 16× on the same pool. When the ValueError from §1 fires, vLLM binary-searches for a length that fits (estimate_max_model_len, kv_cache_utils.py:L821-L840) and puts that suggestion in the message.

Chasing OOM by lowering gpu_memory_utilization. It works, but read the causation: lowering it shrinks the KV pool, usually the thing you wanted. If the OOM is in the activation term — a large prefill chunk, a big captured graph — the fix is --max-num-batched-tokens or the graph batch list, not the utilisation. §2.6.

§10

Hands-on

Compute $c$ from the config, subtract, divide — then check against what the engine says.

shell shell
python3 - <<'PY'
import json, urllib.request
url = "https://huggingface.co/NousResearch/Meta-Llama-3-8B/raw/main/config.json"
c = json.load(urllib.request.urlopen(url))
L   = c["num_hidden_layers"]
hkv = c["num_key_value_heads"]
dh  = c.get("head_dim") or c["hidden_size"] // c["num_attention_heads"]
cell = 2 * L * hkv * dh * 2
print(f"L={L} h_kv={hkv} d_h={dh}  ->  {cell} B/token = {cell/1024:.1f} KiB")
pool_gib = 52.32
print(f"pool {pool_gib} GiB -> {int(pool_gib*2**30//cell):,} tokens")
PY
shell shell
vllm serve meta-llama/Meta-Llama-3-8B-Instruct --max-model-len 8192 2>&1 \
  | grep -E "Available KV cache memory|GPU KV cache size|Maximum concurrency"

python3 -m sglang.launch_server --model-path meta-llama/Meta-Llama-3-8B-Instruct 2>&1 \
  | grep -E "KV Cache is allocated|max_total_num_tokens|Memory pool end"

Three consistency checks. (a) vLLM's token count divided by max_model_len must equal its reported concurrency. (b) That token count times 131,072 must equal the "Available KV cache memory" line to within one block. (c) SGLang's max_total_num_tokens times 131,072 must equal its K size plus V size in the allocation log. Then flip one knob at a time and predict first: --kv-cache-dtype fp8 doubles the token count, --tensor-parallel-size 2 halves per-rank KV bytes while also freeing weight memory, so capacity can grow by more than two, --max-model-len 4096 leaves it alone and doubles the concurrency figure.

§11

Exercises

  1. Size a model you have not met. From Qwen/Qwen2.5-72B-Instruct/config.json, compute bytes per token and the GiB for one request at its full 32,768-token window. How many H100s of KV pool is that for 64 such requests, ignoring weights entirely?
  2. Read this file and answer. In vllm/v1/kv_cache_interface.py, find SlidingWindowSpec.max_admission_blocks_per_request. For a 4,096-token window with max_model_len = 131,072, how many blocks does one request need at block_size=16, why is there a + 1, and what does that do to concurrency versus a full-attention model of the same shape?
  3. Predict, then verify. Serve Llama-3-8B with 200 concurrent 8k requests. Using the budget in §5, find the combination of KV dtype and tensor parallelism that gets you there, and say whether any single-card configuration can. Then check against state_content_size_bytes: which factor does --kv-cache-dtype fp8 change?
  4. Find the asymmetry. In python/sglang/srt/model_executor/pool_configurator.py, compare the MLA branch of _compute_cell_size with the else branch. Name the one call the else branch makes that the MLA branch does not, and say what it means for a DeepSeek-V3 operator who doubles their GPU count.
  5. Break the engine on purpose. Predict the largest --max-model-len Llama-3-8B can serve on one H100 at --gpu-memory-utilization 0.25, then run it and read the error — vLLM's estimate_max_model_len prints its own answer.
Answers

1. $L=80$, $h_{kv}=8$, $d_h=128$, so $c = 327{,}680$ B = 320 KiB. One 32,768-token request is exactly 10 GiB; 64 of them is 640 GiB — roughly 10 H100s' worth of pool using (0.92*79.65-6)=67.278 GiB per GPU with weights deliberately excluded; actual deployment needs additional GPUs or less cache once weights are restored. Long context is a fleet-sizing problem, not a flag.

2. Substitute the actual in-flight-token allowance into min(sliding_window - 1 + extra + max_in_flight_tokens, max_model_len), then apply ceiling division and the extra alignment block. With window 4096, extra=0 and one in-flight token, this gives ceil(4096/16)+1=257 blocks. A larger prefill chunk needs more; 257 is not an unconditional cap. Full attention at 131072 positions needs 8192 blocks, so the approximate 32-fold comparison is specific to the one-token allowance.

3. 200 × 8,192 = 1,638,400 resident tokens = 200 GiB at 128 KiB — impossible on one card at any dtype (fp8 KV halves it to 100 GiB, still over). TP=2 halves cell size to 64 KiB and weights to 7.48 GiB, giving ~978k tokens per GPU, or 119 requests; TP=2 plus fp8 KV clears 200. The fp8 flag changes get_dtype_size(self.dtype), i.e. $b$, and nothing else.

4. The else branch calls model_config.get_num_kv_heads(tp_size, dcp_size), dividing the head count by the TP group size; the MLA branch calls calculate_mla_kv_cache_dim, which takes no parallel argument. Doubling GPU count on DeepSeek-V3 with plain TP buys weight capacity and compute, does not shard the latent bytes per token. It can nevertheless increase concurrency by reducing each rank's weight footprint and freeing more KV pool; fixed-pool capacity is unchanged.

5. The nominal budget is 0.25*79.65=19.9125 GiB, leaving 4.9525 GiB after 14.96 GiB weights. With the chapter's 6 GiB overhead assumption the remainder is negative; with lower measured overhead it may be positive. Therefore startup failure and maximum length cannot be predicted from utilization alone. Profile the actual non-KV reserve and account for block rounding and reserved blocks before computing the maximum.

§12

Key takeaways

  • The cell size $c = 2 \cdot L \cdot h_{kv} \cdot d_h \cdot b$ depends on the checkpoint and the cache dtype alone. Both engines compute it in one expression (AttentionSpec.page_size_bytes, DefaultPoolConfigurator._compute_cell_size) and both write the leading 2 as head_size + head_size_v so MLA can zero out the V half.
  • Capacity is $\lfloor \text{pool} / c \rfloor$ tokens, linear in total resident tokens and never in request count: 52×8k and 1,664×256 cost the same 52 GiB. Any plan phrased in requests per GPU is implicitly a claim about mean length.
  • On one 80 GB H100 at 8k, derived: Llama-3-8B 52 requests; Qwen3-32B 3 in bf16 and 18 with fp8 weights; Llama-3-70B zero, because 131 GiB of bf16 weights do not fit — at TP=8 the same arithmetic per GPU yields 162.
  • Weight quantisation is a concurrency lever wherever weights dominate the card: halving Qwen3-32B's weights multiplies its 8k concurrency by six without touching the cache. The KV-side lever is §2.5.
  • MLA changes the formula, not just the constants — no factor of 2, no head count, no division by TP size. DeepSeek-V3 costs 68.6 KiB/token, less than Llama-3-8B, but every rank holds the whole thing, so adding GPUs does not add KV capacity.
  • The engines differ in one place: vLLM measures the activation term with a profiling forward pass, SGLang predicts it from GPU class and prefill chunk size. Hence vLLM's pool is unknowable before launch, SGLang's is computable on paper — and more conservative.
§13

Further reading

  • The specs, in code. vllm/v1/kv_cache_interface.py in full — 1,007 lines in which every layout vLLM supports (full attention, sliding window, chunked local, sink, MLA, Mamba, cross-attention) states its own page_size_bytes and max_memory_usage_bytes. The fastest way to learn what "the KV cache" means across modern architectures.
  • Config sources used here. config.json from NousResearch/Meta-Llama-3-8B and -70B (ungated mirrors of the Meta repos), Qwen/Qwen3-32B, Qwen/Qwen2.5-72B-Instruct, deepseek-ai/DeepSeek-V3 on the Hugging Face Hub, fetched August 2026.
  • GQA. Ainslie et al., arXiv 2305.13245 — the paper that made $h_{kv}$ a free parameter and shrank every number here by 4× to 8×. Mechanism in §3.5.
  • MLA. DeepSeek-V2 (arXiv 2405.04434) introduces the latent cache; the DeepSeek-V3 technical report (arXiv 2412.19437) carries the 671B/37B figures and the shapes used above. The absorbed-weight trick is §7.2.
  • PagedAttention. Kwon et al., SOSP 2023 — how the pool this chapter sizes is actually carved up. §2.2. Symbols and the concurrency expression live in FORMULAS.

The Inference Engineering Course · code read at vllm@a556f3fccb and sglang@7d893255c3, pinned 2026-08-21. See SOURCES for the full provenance record.

Explore the library

Reading preferences

Appearance
18 px