ML Interview Notes
33 min read11 sections
Part 3 · Attention kernels · 03-05

MHA → MQA → GQA → MLA

Status
SOURCE PINNED
Primary sources
  • vllm/v1/attention/backends/mla/
  • vllm/model_executor/layers/
  • python/sglang/srt/layers/radix_attention.py
Edition pins
vllm a556f3f · sglang 7d89325

Four attention variants, one question: the KV cache is too big and decode is bandwidth-bound, so how few bytes per token can you get away with? MHA, MQA and GQA answer by deleting KV heads; MLA answers by refusing to store heads at all. The first three are the same trade at three points on a line. The fourth is a different trade, and it behaves differently the moment you add a second GPU.

64×
KV bytes MQA saves on Llama-3-70B geometry (derived)
What GQA-8 actually takes — and it raises decode intensity 8×
TP=8
First common valid TP degree beyond the 4.66 crossover (fixed pool)
§1

The problem

Take Llama-3-70B's attention geometry — $L = 80$ layers, $h = 64$ query heads, head dimension $d_h = 128$ — and store keys and values the way the original Transformer did, one K and one V per query head. Feed the cell-size formula from §2.1:

$$c_{\text{MHA}} = 2 \cdot 80 \cdot 64 \cdot 128 \cdot 2 = 2{,}621{,}440 \text{ bytes} = 2.5 \text{ MiB per token}$$

One 8,192-token conversation would need 20 GiB of KV cache. On an 80 GB H100 running the model at TP=8 with roughly 50 GiB of pool per card (§2.1, derived), the whole eight-GPU node would hold twenty concurrent 8k requests — exactly one eighth of the 162 the shipped model manages, because MHA's per-rank cell is eight times GQA-8's. Twenty. On half a million dollars of hardware.

The shipped Llama-3-70B does not do that. It declares num_key_value_heads: 8, and the same arithmetic gives 320 KiB per token, 2.5 GiB at 8k, and 162 concurrent requests. The QK and PV attention FLOPs remain unchanged for fixed query heads and dimensions, but K/V projection FLOPs decrease; all 64 query heads still attend over the context. What changed is how many distinct K/V vectors have to exist in HBM, and therefore how many bytes the decode kernel drags across the memory bus on every single step.

That single config field is the whole subject of this chapter. Its four settings — $h_{kv} = h$, $h_{kv} = 1$, $h_{kv} = h/g$, and "there is no $h_{kv}$, cache a latent instead" — are MHA, MQA, GQA and MLA.

§2

Mental model

Attention has two head counts that people habitually conflate. The query head count $h$ decides how many independent attention patterns the layer can express — it is a capacity knob, and it costs FLOPs and query-projection parameters. The KV head count $h_{kv}$ decides how many distinct key/value vectors must be written to and re-read from the cache — it is a bandwidth knob, and it costs bytes on every decode step forever. The variants in this chapter all hold $h$ fixed and shrink $h_{kv}$. Query heads are broadcast against whatever KV heads exist; the broadcast is free in memory, which is exactly the point.

Figure 1 — query heads fanning onto KV heads, cache strips drawn to scale. One drawn cell = 4 attention heads, so the strips are in true 64 : 16 : 8 : 1 proportion for Llama-3-70B's geometry ($h = 64$). Byte and intensity figures are derived from $L=80$, $d_h=128$, bf16.

Query-to-KV head mapping for MHA, GQA-4, GQA-8 and MQA Four panels. Each shows sixteen drawn query cells above a key-value strip. MHA's strip is sixteen cells wide, GQA with group size four is four cells, GQA with group size eight is two cells, and MQA is a quarter-cell sliver. Per-token cache costs are 2560, 640, 320 and 40 kibibytes respectively. MHA GQA, g = 4 GQA, g = 8 MQA h_kv = h h_kv = h/4 h_kv = h/8 h_kv = 1 Q KV h_kv = 64 h_kv = 16 h_kv = 8 h_kv = 1 2,560 KiB/tok 640 KiB/tok 320 KiB/tok 40 KiB/tok I = 1 FLOP/byte I = 4 I = 8 I = 64 20.0 GiB at 8k 5.0 GiB at 8k 2.5 GiB at 8k 0.31 GiB at 8k Llama-3-70B ships the third panel. The first is what the 2017 Transformer would have done.

Read the strips, not the fans. Every doubling of the group size $g = h/h_{kv}$ halves the copper strip. That is the entire mechanism, and it is why the progression is a line and not a set of unrelated ideas.

§3

First principles: one knob, two effects

Two results already derived elsewhere in this book meet here, and they move in the same direction. From §2.1, the per-token cell size:

$$c \;=\; 2 \cdot L \cdot h_{kv} \cdot d_h \cdot b \;=\; \frac{2 \cdot L \cdot h \cdot d_h \cdot b}{g}, \qquad g \equiv \frac{h}{h_{kv}}$$

with $L$ layers, $h$ query heads, $h_{kv}$ KV heads, head dimension $d_h$, $b$ bytes per stored element, and $g$ the group size — how many query heads share one KV head. From §0.4, the decode attention kernel's arithmetic intensity:

$$I \;=\; \frac{4\,h\,s\,d_h}{2\,s\,h_{kv}\,d_h\,b} \;=\; \frac{2h}{b\,h_{kv}} \;=\; \frac{2g}{b}$$

Context length $s$ cancelled. Head dimension cancelled. What survives is $g$ and the dtype. So one knob buys two things at once, and this is the sentence to remember:

The GQA identity

Each doubling of the group size $g$ halves the KV bytes per token and doubles the decode-attention arithmetic intensity. It is the same doubling: you moved half as many bytes for the same FLOPs. For bf16 ($b = 2$) the intensity is numerically just $g$.

On an H100 SXM the ridge point is $I^{*} = 295$ FLOP/byte (§0.4), so every one of these variants is still deep in the bandwidth-bound region — GQA-8 at $I = 8$ reaches about 2.7% of the card's bf16 peak. Nobody escapes the roofline by picking a group size. What they buy is a factor, and factors of 8 are worth having.

What each variant costs in weights, too

The KV projections shrink with $h_{kv}$ as well, which is often forgotten. For Llama-3-70B ($d = 8192$, $h \cdot d_h = 8192$), MHA would need $W_K$ and $W_V$ of $8192 \times 8192$ each, 134.2M parameters per layer; GQA-8 needs $8192 \times 1024$ each, 16.8M per layer. Over 80 layers that is 9.4B parameters saved — 17.5 GiB of bf16 weights, or roughly one whole H100's worth of the eight-card node. GQA is a weight optimisation that happens to be a bandwidth optimisation, which is part of why it was cheap for model builders to adopt.

The four variants, arithmetically

All four on Llama-3-70B's geometry ($L=80$, $h=64$, $d_h=128$, bf16 KV), plus DeepSeek-V3's real MLA shape. Every byte and intensity figure is derived arithmetic from the formulas above; quality columns are cited, never measured here.
Varianth_kvgKiB/tokenGiB @ 8kdecode IQuality evidence
MHA6412,56020.001Baseline by definition
GQA, g=23221,28010.002
GQA, g=41646405.004Llama-3-8B's setting
GQA, g=8 shipped883202.508GQA paper Table 1: 47.1 avg vs MHA 47.2
GQA, g=164161601.2516
MQA164400.3164GQA paper Table 1: 46.6 avg; “can lead to quality degradation”
MLA (DeepSeek-V3) different trade1*68.60.54242*DeepSeek-V2 abstract: 93.3% KV reduction, 5.76× max generation throughput

*MLA's row is DeepSeek-V3's own shape ($L = 61$, $r_{kv} = 512$, $d_{\text{rope}} = 64$, $h = 128$), not Llama-3-70B's — the architectures are not interchangeable. Its "$h_{kv} = 1$" is what the engines allocate, not what the config declares; see §5. Its intensity is derived below and is a genuinely different number from anything in the GQA family.

MQA: correct, and too aggressive

Shazeer's 2019 paper proposed exactly the extreme: one KV head, shared by every query head. The abstract's claim is the honest one — "We verify experimentally that the resulting models can indeed be much faster to decode, and incur only minor quality degradation from the baseline" (arXiv:1911.02150). Four years later the GQA paper opens by naming the two problems that stopped MQA from becoming the default: "MQA can lead to quality degradation, and moreover it may not be desirable to train a separate model just for faster inference" (arXiv:2305.13245). Its introduction adds training instability to the charge sheet. On the paper's T5-XXL comparison, MQA scores 46.6 average against MHA's 47.2 — small, but consistently down, and one KV head across 64 query heads is a very thin channel through which every attention pattern in the layer must be expressed.

The second objection is the practical one. A 70B checkpoint is not retrained because a serving team wants a smaller cache. The GQA paper's contribution is as much a conversion recipe as an architecture: mean-pool the existing KV heads within each group and uptrain with "5% of original pre-training compute". That is what made the switch tractable for everyone shipping models.

Why g = 4 or 8 and not 32

The GQA paper's own numbers show the shape of the curve: MHA-XXL takes 1.51 s per sample at 47.2 average, MQA-XXL takes 0.24 s at 46.6, and GQA-8-XXL takes 0.28 s at 47.1 (Table 1). GQA-8 recovers essentially all of MQA's speed and essentially all of MHA's quality. In the paper, GQA-8 means eight KV groups, not universally eight queries per group. Its measured speed approaches MQA on that workload because, past that point, KV traffic is no longer what the decode step is waiting for — the weights are. Halving KV bytes again when KV is 10% of the step's traffic buys 5%.

But there is a second, purely mechanical reason $h_{kv} = 8$ is the near-universal choice, and it is written in the serving code rather than the paper. Tensor parallelism shards KV heads across ranks by integer division, and once $h_{kv}$ drops below the TP size the heads get replicated instead of sharded — you pay the full cell size on every rank and gain nothing. Eight is the number of GPUs in a node. Setting $h_{kv} = 8$ makes TP=8 the exact point where each rank owns precisely one KV head, which is the largest useful $g$ for the standard deployment shape.

The sharpest form of that argument: at TP=8, a GQA-8 model and an MQA model have the same per-rank cache. Both give max(1, h_kv // 8) = 1 KV head per GPU, and both cost 40 KiB per token per rank on Llama-3-70B's geometry. MQA's entire remaining byte advantage over GQA-8 evaporates at this common eight-way tensor-parallel shape — while its quality cost does not. Choosing $h_{kv} = 8$ is choosing the smallest KV cache that TP=8 can still shard. §6 makes this quantitative.

Figure 2 — bytes per resident token, log-2 axis. On a doubling axis, every step of $g$ removes a constant slab, which is what makes the GQA family a straight line. Derived from $L=80$, $h=64$, $d_h=128$, bf16; the MLA bar is DeepSeek-V3's own shape and is shown for magnitude, not as a drop-in swap.

KV bytes per token across attention variants on a log-2 axis Horizontal bars on a logarithmic axis from 32 to 4096 kibibytes per token. MHA 2560, GQA-2 1280, GQA-4 640, GQA-8 320, GQA-16 160, MQA 40, and DeepSeek-V3 MLA 68.6. 32 64 128 256 512 1024 2048 4096 KiB per resident token, bf16 KV (log-2 scale) MHA, h_kv=64 GQA g=2 GQA g=4 GQA g=8 GQA g=16 MQA, h_kv=1 MLA (DS-V3) 2,560 1,280 640 320 — shipped 160 40 68.6 — and no factor of 2 one drop per doubling of g — constant width on this axis

MLA: not another point on the line

MLA does not choose an $h_{kv}$. It stores, per token per layer, a single compressed latent of width $r_{kv} + d_{\text{rope}}$ and reconstructs per-head keys and values inside the kernel. §2.1 derived the resulting cell size, which has neither a head count nor a factor of two:

$$c_{\text{MLA}} = L \cdot (r_{kv} + d_{\text{rope}}) \cdot b \;=\; 61 \times 576 \times 2 \;=\; 70{,}272 \text{ bytes} = 68.6 \text{ KiB per token}$$

The mechanism — the absorbed-weight trick, and why prefill and decode use different formulations — is §7.2's subject, and this chapter deliberately does not go there. What belongs here is the shape of the trade, and it is visible in the intensity. At decode, all $h$ query heads attend over one shared latent: each head computes scores against $r_{kv} + d_{\text{rope}} = 576$ dimensions and an output over $r_{kv} = 512$ dimensions, while the bytes read are one 576-wide latent per position:

$$I_{\text{MLA}} = \frac{2h\,s\,(r_{kv} + d_{\text{rope}}) + 2h\,s\,r_{kv}}{s\,(r_{kv}+d_{\text{rope}})\,b} = \frac{2h\,(2r_{kv} + d_{\text{rope}})}{b\,(r_{kv} + d_{\text{rope}})} = \frac{2 \times 128 \times 1088}{2 \times 576} \approx 242 \text{ FLOP/byte}$$

Against the H100's ridge of 295, DeepSeek-V3's decode attention at TP=1 sits at 82% of the way to compute-bound — a place no GQA model gets near. That is the honest description of MLA: it did not find a cheaper way to store attention state, it found a way to pay in FLOPs instead of bytes. GQA moves you along the roofline's bandwidth ramp; MLA moves closer to the ridge but 242 FLOP/byte remains below 295; actual tile rereads can move it farther left. Which is better depends entirely on which resource you are short of, and — as §6 shows — on how many GPUs you have.

§4

How each variant appears in code

Neither engine has an "attention variant" enum with four members. MHA, MQA and GQA are all the same code path with a different integer; MLA is a separate layer class. SGLang states this in its type system with a two-member enum — there is no GQA member because GQA is not a different architecture, just a different head count:

python/sglang/srt/configs/model_config.py:L85-L87 SGLang
class AttentionArch(IntEnum):
    MLA = auto()
    MHA = auto()

Where h_kv comes from

vLLM resolves the total KV head count by trying a list of config aliases and falling back to the query head count — that fallback is the MHA case, and it is why a pre-2023 checkpoint with no num_key_value_heads field simply behaves as MHA:

vllm/transformers_utils/model_arch_config_convertor.py:L147-L164 vLLM
    def get_total_num_kv_heads(self) -> int:
        attributes = [
            # For Falcon:
            "n_head_kv",
            "num_kv_heads",
            # For LLaMA-2:
            "num_key_value_heads",
            # For ChatGLM:
            "multi_query_group_num",
            # For Step3p5:
            "num_attention_groups",
        ]
        # For non-grouped-query attention models, the number of KV heads is
        # equal to the number of attention heads.
        default_factory = self.get_total_num_attention_heads
        return getattr_iter(
            self.hf_text_config, attributes, default_factory=default_factory
        )

SGLang's equivalent carries a fossil of the MQA era: a boolean multi_query flag, used by Falcon and GPTBigCode, that short-circuits to one KV head — with a comment recording that this path never got TP support:

python/sglang/srt/configs/model_config.py:L1106-L1111 SGLang
        if not new_decoder_arch_falcon and getattr(
            self.hf_text_config, "multi_query", False
        ):
            # Multi-query attention, only one KV head.
            # Currently, tensor parallelism is not supported in this case.
            return 1

Where TP divides it

vLLM performs the division on the config object, and the MLA early-return is the first appearance of this chapter's punchline — MLA returns 1 before the TP division exists:

vllm/config/model.py:L1517-L1537 vLLM
    def get_num_kv_heads(
        self,
        parallel_config: ParallelConfig,
        arch_config: ModelArchitectureConfig | None = None,
    ) -> int:
        """Returns the number of KV heads per GPU.

        Pass ``arch_config`` (from ``model_arch_config[layer_idx]``) to size a
        single layer of a heterogeneous model rather than the model as a whole.
        """
        if self.use_mla:
            # When using MLA during decode it becomes MQA
            return 1

        arch_config = arch_config or self.model_arch_config
        total_num_kv_heads = arch_config.total_num_kv_heads
        # If tensor parallelism is used, we divide the number of KV heads by
        # the tensor parallel size. We will replicate the KV heads in the
        # case where the number of KV heads is smaller than the tensor
        # parallel size so each GPU has at least one KV head.
        return max(1, total_num_kv_heads // parallel_config.tensor_parallel_size)

The max(1, ...) is the replication clause, spelled out in the comment. Past $\text{TP} = h_{kv}$ the division saturates and extra ranks duplicate rather than shard.

A GQA model repeats the same division locally. Llama's attention module divides both head counts, asserts the two divisibility regimes explicitly, and — critically — resolves head_dim from the config with a fallback, not by assumption:

vllm/model_executor/models/llama.py:L140-L158 vLLM
        tp_size = get_tensor_model_parallel_world_size()
        self.total_num_heads = num_heads
        assert self.total_num_heads % tp_size == 0
        self.num_heads = self.total_num_heads // tp_size
        self.total_num_kv_heads = num_kv_heads
        if self.total_num_kv_heads >= tp_size:
            # Number of KV heads is greater than TP size, so we partition
            # the KV heads across multiple tensor parallel GPUs.
            assert self.total_num_kv_heads % tp_size == 0
        else:
            # Number of KV heads is less than TP size, so we replicate
            # the KV heads across multiple tensor parallel GPUs.
            assert tp_size % self.total_num_kv_heads == 0
        self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size)

        head_dim = getattr(config, "head_dim", None)
        self.head_dim = head_dim or self.hidden_size // self.total_num_heads
        self.q_size = self.num_heads * self.head_dim
        self.kv_size = self.num_kv_heads * self.head_dim

Note that self.num_heads and self.num_kv_heads are divided by the same $\text{TP}$, so $g$ is invariant under tensor parallelism as long as $h_{kv} \geq \text{TP}$. Llama-3-70B at TP=8 has 8 query heads and 1 KV head per rank: still $g = 8$, still $I = 8$. Intensity is a per-rank property that TP does not change.

The per-rank counts go straight into the generic Attention layer, whose only statement about variants is a default and an assertion:

vllm/model_executor/layers/attention/attention.py:L317-L328 vLLM
        if num_kv_heads is None:
            num_kv_heads = num_heads
        assert num_heads % num_kv_heads == 0, (
            f"num_heads ({num_heads}) is not divisible by num_kv_heads ({num_kv_heads})"
        )
        self.quant_config = quant_config
        self.layer_name = prefix

        self.num_heads = num_heads
        self.head_size = head_size
        self.head_size_v = self.head_size if head_size_v is None else head_size_v
        self.num_kv_heads = num_kv_heads

SGLang's layer names the same thing more honestly — the tp_ prefixes say out loud that these are already per-rank counts:

python/sglang/srt/layers/radix_attention.py:L114-L120 SGLang
        super().__init__()
        self.tp_q_head_num = num_heads
        self.tp_k_head_num = num_kv_heads
        self.tp_v_head_num = num_kv_heads
        self.head_dim = head_dim
        self.qk_head_dim = head_dim
        self.v_head_dim = v_head_dim if v_head_dim != -1 else head_dim

Where the head count becomes bytes

In vLLM the layer publishes a cache spec, and the spec's page arithmetic is where $h_{kv}$ finally turns into memory. The factor of two is written as a sum of K and V head sizes rather than a multiplication, which is precisely the hook MLA needs:

vllm/v1/kv_cache_interface.py:L233-L248 vLLM
    def __post_init__(self):
        if self.head_size_v is None:
            object.__setattr__(self, "head_size_v", self.head_size)

    @property
    def num_heads(self) -> int:
        if self.num_head_slots is not None:
            return self.num_head_slots
        return self.num_kv_heads

    @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)

A GQA layer returns a FullAttentionSpec carrying its per-rank num_kv_heads:

vllm/model_executor/layers/attention/attention.py:L648-L655 vLLM
            return FullAttentionSpec(
                block_size=block_size,
                num_kv_heads=self.num_kv_heads,
                head_size=self.head_size,
                head_size_v=self.head_size_v,
                dtype=self.kv_cache_torch_dtype,
                kv_quant_mode=quant_mode,
            )

An MLA layer is a different class entirely, and pins both fields as literals. Note self.num_kv_heads = 1 and head_size = kv_lora_rank + qk_rope_head_dim — no TP anywhere in either:

vllm/model_executor/layers/attention/mla_attention.py:L427-L444 vLLM
        self.num_heads = num_heads
        self.scale = scale
        self.qk_nope_head_dim = qk_nope_head_dim
        self.qk_rope_head_dim = qk_rope_head_dim
        self.v_head_dim = v_head_dim
        self.q_lora_rank = q_lora_rank
        self.kv_lora_rank = kv_lora_rank
        self.kv_b_proj = kv_b_proj
        self.dcp_q_replicate = dcp_q_replicate
        self.W_UK_T_dcp_qrep: torch.Tensor | None = None
        self.head_size = kv_lora_rank + qk_rope_head_dim
        self.layer_name = prefix
        self.indexer = indexer
        self.non_causal_multi_token_decode = non_causal_multi_token_decode
        self.sliding_window = sliding_window
        self.num_kv_heads = 1
        self.qk_head_dim = self.qk_nope_head_dim + self.qk_rope_head_dim
vllm/model_executor/layers/attention/mla_attention.py:L1190-L1204 vLLM
    def get_kv_cache_spec(self, vllm_config: VllmConfig) -> KVCacheSpec:
        kv_cache_dtype = kv_cache_dtype_str_to_dtype(
            self.kv_cache_dtype, vllm_config.model_config
        )
        common_kwargs = dict(
            block_size=vllm_config.cache_config.block_size,
            num_kv_heads=1,
            head_size=self.head_size,
            dtype=kv_cache_dtype,
            cache_dtype_str=self.kv_cache_dtype,
            kv_quant_mode=get_kv_quant_mode(self.kv_cache_dtype),
            # fp8_ds_mla: 656-byte custom layout (kv_lora_rank=512 +
            # qk_rope_head_dim=64, head_size=576). See flashmla_sparse.py.
            state_content_bytes=656 if self.kv_cache_dtype == "fp8_ds_mla" else None,
        )

MLAAttentionSpec then sets head_size_v = 0 (vllm/v1/kv_cache_interface.py:L392, quoted in §2.1), which deletes the factor of two from state_content_size_bytes above. Two literals — a 1 and a 0 — convert the general formula into $c_{\text{MLA}}$.

SGLang says the same thing at the model level, and its naming is the clearest statement in either codebase of what MLA is at decode time: DeepSeek's layer builds two RadixAttention objects, one literally called attn_mqa:

python/sglang/srt/models/deepseek_v2.py:L1896-L1928 and L1919-L1928 SGLang
        self.attn_mqa = RadixAttention(
            self.num_local_heads,
            self.kv_lora_rank + self.qk_rope_head_dim,
            self.scaling,
            num_kv_heads=1,
            layer_id=layer_id,
            v_head_dim=self.kv_lora_rank,
            quant_config=quant_config,
            prefix=add_prefix("attn_mqa", prefix),
        )
# ...
        self.attn_mha = RadixAttention(
            self.num_local_heads,
            self.qk_nope_head_dim + self.qk_rope_head_dim,
            self.scaling,
            num_kv_heads=self.num_local_heads,
            layer_id=layer_id,
            v_head_dim=self.v_head_dim,
            quant_config=quant_config,
            prefix=add_prefix("attn_mha", prefix),
        )

The query head count is sharded — self.num_local_heads = num_heads // attn_tp_size (python/sglang/srt/models/deepseek_v2.py:L1773) — but num_kv_heads=1 is a literal, at every TP size. Which layer runs when, and why prefill wants a different one, is §7.2.

Figure 3 — from a config field to bytes on the card, both branches. Ctrl/Cmd + wheel to zoom · drag to pan · double-click to fit

Loading…
§5

The uncomfortable part: TP shrinks GQA's cache, not MLA's

SGLang computes one scalar cell size per model and divides the pool by it. The function has two branches, and the difference between them is the whole argument of this section. First the GQA branch:

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
            )

Now the MLA branch, from earlier in the same function. tp_size and dcp_size are bound at the top of the function and then never referenced here:

python/sglang/srt/model_executor/pool_configurator.py:L248-L265 SGLang
        kv_size = torch._utils._element_size(kv_cache_dtype)
        tp_size = get_parallel().attn_tp_size
        dcp_size = get_parallel().attn_dcp_size

        if kvc.use_mla_backend:
            from sglang.srt.mem_cache.kv_cache_configurator import (
                calculate_mla_kv_cache_dim,
            )

            cell_size = (
                calculate_mla_kv_cache_dim(
                    model_config=model_config,
                    kv_cache_dtype=kv_cache_dtype,
                    server_args=kvc.server_args,
                )
                * effective_num_layers
                * kv_size
            )

Confirmed at 7d89325: the MLA cell size is a pure function of the checkpoint's latent width, the layer count and the dtype. It is identical at TP=1 and TP=16. vLLM says the same thing differently, with the if self.use_mla: return 1 early return quoted in §5. MLA KV caches are replicated on every tensor-parallel rank.

The consequence is a capacity law. Let $P$ be the per-GPU KV pool in bytes. For a GQA model, each rank stores $c / \min(\text{TP}, h_{kv})$ bytes per token, so the cluster holds

$$N_{\text{GQA}} = \frac{P \cdot \min(\text{TP},\, h_{kv})}{c}, \qquad N_{\text{MLA}} = \frac{P}{c_{\text{MLA}}}$$

Holding the per-rank byte pool P fixed, GQA logical-token capacity grows with min(TP,h_kv), while a replicated MLA latent has fixed capacity. In actual deployment P changes when weights are sharded, so adding ranks can improve either capacity even after KV-cell sharding saturates. The following comparison deliberately fixes P=40 GiB to isolate cache layout, not whole-deployment memory accounting.

Cluster-wide resident tokens at a fixed 40 GiB KV pool per GPU. All derived from the two formulas above; no measurement, and the two models' weight footprints are deliberately factored out so the KV term is visible on its own.
TPLlama-3-70B per-rank cellLlama-3-70B cluster tokensDeepSeek-V3 per-rank cellDeepSeek-V3 cluster tokensWinner
1320 KiB131,07268.6 KiB611,192MLA, 4.7×
2160 KiB262,14468.6 KiB611,192MLA, 2.3×
480 KiB524,28868.6 KiB611,192MLA, 1.17×
840 KiB1,048,57668.6 KiB611,192GQA, 1.72×
1640 KiB saturated1,048,57668.6 KiB611,192GQA, 1.72×

MLA's per-token cell is 4.7× smaller and it still loses at TP=8. The crossover is where $\min(\text{TP}, h_{kv})$ passes $c / c_{\text{MLA}} = 320/68.6 = 4.66$, so the real-valued crossover is TP=4.66; a supported divisor such as TP=8 lies beyond it, while TP=5 is not valid for these head counts. GQA cell-size sharding stops once TP reaches the KV-head count; extra MLA ranks reduce per-rank weights and can thereby free more pool space, although latent bytes per token remain replicated.

The same asymmetry shows up in the intensity. GQA's $I = 2g/b$ is TP-invariant because both head counts shard together. MLA's is not: the query heads shard and the latent does not, so per-rank intensity is $2h_{\text{local}}(2r_{kv}+d_{\text{rope}})/(b(r_{kv}+d_{\text{rope}}))$ and falls linearly with TP — 242 at TP=1, 30 at TP=8, 15 at TP=16 (derived). MLA's spectacular roofline position is a TP=1 property that tensor parallelism spends.

Figure 4 — cluster KV capacity against tensor-parallel size. Derived at a fixed 40 GiB KV pool per GPU. GQA doubles per rank doubling until it saturates at TP = h_kv = 8; MLA is flat because every rank keeps a full copy of the latent.

Cluster resident-token capacity versus tensor-parallel size Two lines against TP of 1, 2, 4, 8, 16. The GQA line rises from 131 thousand tokens to 1.05 million at TP 8 and then flattens. The MLA line is flat at 611 thousand tokens at every TP. They cross between TP 4 and TP 8. 1.05M 524k 0 TP = 1 2 4 8 16 tensor-parallel size (same total KV pool per GPU) cluster resident tokens Llama-3-70B, GQA-8 DeepSeek-V3, MLA saturates: h_kv = 8 crossover near TP = 4.7 131k 611k — flat at every TP
The escape hatch

Attention data parallelism is an important alternative to replicated-cache TP; deployment choices depend on hardware, batch size, and engine support. The fix is data-parallel attention: each rank owns a different sub-batch, so the replicated caches are no longer duplicates of each other. SGLang's flag documents the split exactly — "Enabling data parallelism for attention and tensor parallelism for FFN" (python/sglang/srt/server_args.py:L1163-L1170). With dp_size == tp_size == 8 the cluster holds 8 × 611,192 tokens again. The reason this is affordable for MLA-plus-MoE models and not for dense ones is that replicating the attention weights is cheap when the parameters live in the experts; that mechanism belongs to §5.3.

The other escape hatch is decode context parallelism, which shards along the sequence axis rather than the head axis — vLLM implements it by dividing the per-request length, max_model_len = cdiv(max_model_len, dcp_world_size) (vllm/v1/kv_cache_interface.py:L300-L305), not by touching the cell size. That is why DCP works for MLA at all: there are no heads left to split.

§6

Worked trace: Llama-3-70B at TP=8, one layer

Follow the number 8 from the checkpoint to a page of HBM, in vLLM, function by function.

  1. config.json declares num_attention_heads: 64, num_key_value_heads: 8, hidden_size: 8192, num_hidden_layers: 80. No head_dim field.
  2. ModelArchConfigConvertor.get_total_num_kv_heads (model_arch_config_convertor.py:L147-L164) scans the alias list, hits num_key_value_heads, returns 8.
  3. get_head_size (model_arch_config_convertor.py:L99-L113) finds no head_dim, no hidden_size_per_head, and falls through to $8192 / 64 = $ 128.
  4. LlamaAttention.__init__ (llama.py:L140-L158) divides: num_heads = 64 // 8 = 8, num_kv_heads = max(1, 8 // 8) = 1. The total_num_kv_heads >= tp_size assertion passes on equality.
  5. Attention.__init__ (attention.py:L317-L328) asserts 8 % 1 == 0, stores num_kv_heads = 1, head_size = head_size_v = 128.
  6. Attention.get_kv_cache_spec (attention.py:L648-L655) returns FullAttentionSpec(num_kv_heads=1, head_size=128, head_size_v=128, block_size=16).
  7. AttentionSpec.state_content_size_bytes (kv_cache_interface.py:L245-L248) computes $(128 + 128) \times 2 = 512$ bytes per token per head slot; unpadded_page_size_bytes multiplies by num_heads = 1 and storage_block_size = 16 to give 8,192 bytes per page.
  8. get_num_blocks (vllm/v1/core/kv_cache_utils.py:L1038-L1040) divides the measured pool by the page size and then by 80 layers. Per-rank cell size: $8192 / 16 \times 80 = 40{,}960$ bytes = 40 KiB.
  9. The engine logs it (vllm/v1/core/kv_cache_utils.py:L1925-L1931): "GPU KV cache size: %s tokens, Maximum concurrency for %s tokens per request: %.2fx".

The same trace on DeepSeek-V3 diverges at step 2: ModelConfig.get_num_kv_heads returns 1 at the use_mla branch without ever reading parallel_config.tensor_parallel_size, and steps 4–8 run through MLAAttention and MLAAttentionSpec instead, with head_size = 576, head_size_v = 0, giving $576 \times 2 \times 61 = 70{,}272$ bytes per token on every rank.

§7

Pitfalls and war stories

trap 1

head_dim is not hidden_size / num_heads

Qwen3-32B declares hidden_size: 5120, num_attention_heads: 64 — and head_dim: 128. The division gives 80. Both engines read the declared field first and only fall back to the division, and vLLM's fallback carries a # FIXME(woosuk): This may not be true for all models. right above it. Compute Qwen3-32B's cell size from the division and you get 160 KiB instead of 256 KiB — a 37.5% under-count, and a server that OOMs at the concurrency your capacity plan promised.

trap 2

Sizing MLA with the GQA formula

DeepSeek-V3's config says num_key_value_heads: 128. Feed that to $2 L h_{kv} d_h b$ and you get 4.77 MiB per token instead of 68.6 KiB — a 71× over-estimate (§2.1). The declared heads are the logical attention shape; nothing stores them.

trap 3

Raising TP past h_kv buys no KV

max(1, total_num_kv_heads // tensor_parallel_size) saturates at 1. Going from TP=8 to TP=16 on Llama-3-70B halves the weight footprint per card but leaves the per-rank cell size at 40 KiB — the extra ranks hold duplicate KV heads. If your concurrency did not improve after doubling TP, this is why, and it is the same clause that makes MLA replicate.

trap 4

DCP on a GQA model needs TP > h_kv

vLLM refuses the configuration outright, with a message that reads as a summary of this chapter: "Decode context parallelism for GQA/MQA requires `--tensor-parallel-size` (N) to be greater than the model's total number of KV heads (M)." (vllm/config/model.py:L1436-L1442). Sequence-axis sharding only helps once the head axis is exhausted.

vllm/config/model.py:L1432-L1442 vLLM
        decode_context_parallel_size = parallel_config.decode_context_parallel_size
        if decode_context_parallel_size > 1 and not self.use_mla:
            total_num_kv_heads = self.get_total_num_kv_heads()
            if tensor_parallel_size <= total_num_kv_heads:
                raise ValueError(
                    "Decode context parallelism for GQA/MQA requires "
                    f"`--tensor-parallel-size` ({tensor_parallel_size}) to be "
                    "greater than the model's total number of KV heads "
                    f"({total_num_kv_heads}). Increase `--tensor-parallel-size` "
                    "or set `--decode-context-parallel-size 1`."
                )

A fifth failure mode is subtler and has no error message. Because $I = 2g/b$ is TP-invariant for GQA but MLA's per-rank intensity falls as $1/\text{TP}$, an MLA model that profiled beautifully on one card can look ordinary on eight — the attention kernel that was near the roofline ridge is now four to eight times further from it, and it is also reading a replicated cache. If you benchmark MLA at TP=1 and extrapolate, you will be wrong in a direction that flatters the architecture.

§8

Hands-on

First, resolve the shapes the way the engines do, from the config rather than from arithmetic:

read a checkpoint's attention shape the way both engines do shell
python3 - <<'PY'
import json, urllib.request
for repo in ["NousResearch/Meta-Llama-3-70B", "Qwen/Qwen3-32B"]:
    u = f"https://huggingface.co/{repo}/raw/main/config.json"
    c = json.load(urllib.request.urlopen(u))
    L  = c["num_hidden_layers"]
    h  = c["num_attention_heads"]
    hk = c.get("num_key_value_heads", h)          # MHA fallback, as in vLLM
    dh = c.get("head_dim") or c["hidden_size"] // h   # declared first, THEN divide
    cell = 2 * L * hk * dh * 2
    print(f"{repo:34s} L={L:3d} h={h:3d} h_kv={hk:3d} d_h={dh:3d} "
          f"g={h//hk:2d}  {cell/1024:7.1f} KiB/tok  I={h//hk} FLOP/byte")
PY

Then watch the TP division move the number. Launch the same model twice and read the KV log line; token capacity can grow by more than two from TP=1 to TP=2, since halving per-rank weights also increases the byte pool while the cell size halves:

observe the per-rank cell size change with TP shell
# vLLM: grep the capacity line emitted by kv_cache_utils.py:L1925
vllm serve NousResearch/Meta-Llama-3-8B --tensor-parallel-size 1 --max-model-len 8192 2>&1 | grep "GPU KV cache size"
vllm serve NousResearch/Meta-Llama-3-8B --tensor-parallel-size 2 --max-model-len 8192 2>&1 | grep "GPU KV cache size"

# SGLang: the scheduler logs max_total_num_tokens (python/sglang/srt/managers/scheduler.py:L1096)
python3 -m sglang.launch_server --model-path NousResearch/Meta-Llama-3-8B --tp 1 2>&1 | grep max_total_num_tokens
python3 -m sglang.launch_server --model-path NousResearch/Meta-Llama-3-8B --tp 2 2>&1 | grep max_total_num_tokens

# Now do it at --tp 16 on a model with h_kv=8 and watch the token count NOT double.

The last line is the experiment worth doing. Llama-3-8B has $h_{kv} = 8$; at TP=16 the cell size stops shrinking, so the only remaining gain is the weight footprint, and the resident-token count will grow by far less than 2×.

§9

Exercises

  1. Mistral-7B has $L=32$, $h=32$, $h_{kv}=8$, $d_h=128$. Compute (a) its bf16 cell size, (b) its decode-attention intensity, (c) what both become if you set --kv-cache-dtype fp8. Which of the two changes, and which does not?
  2. Read vllm/config/model.py around get_num_kv_heads and vllm/model_executor/models/llama.py around line 145. Both compute a per-rank KV head count; one of them asserts something the other does not. What, and what breaks silently in the version without the assertion?
  3. A colleague proposes serving DeepSeek-V3 at TP=8 without DP attention and reports that “MLA has a 4.7× smaller cache than Llama-3-70B so we will get 4.7× the concurrency.” Using only §6's two formulas, compute the actual ratio and name the exact line of SGLang source that makes the claim wrong.
  4. Predict, then verify by reading python/sglang/srt/model_executor/pool_configurator.py: if you launch an MLA model with --tp 8 --dcp-size 2, does SGLang's _compute_cell_size return a smaller number than at --dcp-size 1? If not, where must the sharding happen instead for DCP to mean anything?
  5. Suppose you could retrain Llama-3-70B with any $h_{kv}$, and serve it at TP=1 on a hypothetical card large enough to hold it. A decode step at batch 64 and 8k context reads 131.4 GiB of bf16 weights in an artificial fixed-weight comparison that isolates KV traffic; retraining h_kv would also change projection parameters. Using $c = 2Lh_{kv}d_hb$, compute the fraction of total HBM traffic that is KV at $h_{kv} \in \{64, 8, 1\}$. At which setting does further shrinking stop mattering? Then say what happens to the $h_{kv}=1$ answer at TP=8, and why.
Answers

1. (a) $2 \times 32 \times 8 \times 128 \times 2 = 131{,}072$ bytes = 128 KiB — identical to Llama-3-8B, which has the same four numbers. (b) $g = 4$, so $I = 2 \times 4 / 2 = 4$ FLOP/byte. (c) At $b = 1$ the cell size halves to 64 KiB, and the intensity doubles to $2g/b = 8$. Both change, in the same direction as a group-size doubling — KV quantisation and GQA are the same lever applied to different factors of the same product. See §2.5.

2. llama.py:L145-L152 asserts both regimes: total_num_kv_heads % tp_size == 0 when heads exceed ranks, and tp_size % total_num_kv_heads == 0 when they do not. ModelConfig.get_num_kv_heads has no assertion at all — it just computes max(1, total // tp). With, say, $h_{kv} = 6$ and TP=4, the config-level helper silently returns 1 while the true sharding is ill-defined; the model-level assertion is what turns that into a startup failure instead of a wrong capacity estimate.

3. Cluster tokens are $P \cdot \min(\text{TP}, h_{kv}) / c$ for GQA and $P / c_{\text{MLA}}$ for MLA. At TP=8: $8P/320\text{KiB}$ versus $P/68.6\text{KiB}$, a ratio of $8 \times 68.6 / 320 = 1.72$ in GQA's favour. The line is python/sglang/srt/model_executor/pool_configurator.py:L257-L265 — the MLA branch of _compute_cell_size, which never references the tp_size bound two lines above it.

4. No. At 7d89325 the MLA branch takes neither tp_size nor dcp_size, so the cell size is unchanged. DCP shards along the sequence axis: vLLM makes this explicit in FullAttentionSpec.max_memory_usage_bytes (vllm/v1/kv_cache_interface.py:L300-L305), which divides max_model_len by dcp_world_size rather than dividing the page size. Each rank holds a slice of the context, not a slice of the heads.

5. KV traffic is $c \times 64 \times 8192 = c \times 524{,}288$. At $h_{kv} = 64$: $2.5\,\text{MiB} \times 524{,}288 = 1{,}280$ GiB, so KV is $1280/1411.4 = 90.7\%$ of traffic. At $h_{kv} = 8$: 160 GiB, $54.9\%$. At $h_{kv} = 1$: 20 GiB, $13.2\%$. Shrinking below MQA would be pointless — you are paying for weights by then — but note that even at the shipped $h_{kv} = 8$, KV is still the majority of the traffic at this batch and context. The GQA paper's "GQA-8 is as fast as MQA" result (0.28 s vs 0.24 s) is a statement about its own workload, not a universal one; at long context with large batches there is real headroom left, which is why long-context serving keeps reaching for KV quantisation and MLA rather than declaring GQA-8 sufficient. At TP=8 the $h_{kv}=1$ answer gets worse in a specific way: max(1, 1 // 8) = 1, so every rank holds the same single KV head and the per-rank cell is 40 KiB — identical to $h_{kv}=8$. At TP=8 their per-rank KV cell sizes match in this geometry, but weights, quality, and other execution costs still differ.

§10

Key takeaways

  • MHA, MQA and GQA are one parameter at three settings. The group size $g = h/h_{kv}$ divides the KV cell size and multiplies decode arithmetic intensity by exactly the same factor — $c = 2Lhd_hb/g$ and $I = 2g/b$ are the same statement written twice.
  • Choosing $h_{kv}$ trades model quality, cache traffic, projection size, and parallel placement. GQA-8 in the paper denotes eight KV groups and reports a workload-specific quality tradeoff. Models with four KV heads do ship: Qwen2-7B is one example. The placement constraint is that max(1, total_num_kv_heads // tensor_parallel_size) saturates, and 8 is the number of GPUs in a node.
  • MLA is not the endpoint of the same line. It has no group size; its intensity formula is different, and at TP=1 DeepSeek-V3's decode attention sits at $I \approx 242$ against an H100 ridge of 295 — it trades bytes for FLOPs rather than heads for bytes.
  • MLA caches are replicated per tensor-parallel rank. Verified at 7d89325: the MLA branch of SGLang's _compute_cell_size ignores the tp_size bound two lines above it, and vLLM's get_num_kv_heads returns 1 before the division. At a fixed pool per GPU, GQA's cluster capacity grows with TP and MLA's does not; the crossover against a GQA-8 model is at TP ≈ 4.7, so at TP=8 the GQA model holds 1.72× more context despite a 4.7× larger cell.
  • Two literals turn the general cache formula into MLA's: num_kv_heads = 1 and head_size_v = 0. The second is what deletes the factor of two, and it works only because both engines write that factor as head_size + head_size_v rather than 2 * head_size.
  • Always read head_dim from the config before dividing. The division is a fallback in both engines, and vLLM annotates it # FIXME(woosuk): This may not be true for all models. Qwen3-32B is the model that proves the FIXME right.
§11

Further reading

  • Shazeer, Fast Transformer Decoding: One Write-Head is All You Need (2019) — the MQA paper. Short, and the framing of incremental decoding as a memory-bandwidth problem is the origin of half this book.
  • Ainslie et al., GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints (2023) — Table 1 is the quality/speed evidence quoted above; §3 is the mean-pooling uptraining recipe that made conversion of existing checkpoints practical.
  • DeepSeek-AI, DeepSeek-V2 (2024) — where MLA is introduced, with the 93.3% KV reduction and 5.76× throughput claims. The MHA-vs-MLA ablation is in its architecture section; the serving consequences are §7.2.
  • vLLM PRs #10927 (DeepSeek-V2 MLA, the first version to store the latent rather than per-head K/V) and #12601 (DeepSeek-V3 MLA with fp8 compute) — the discussions are where the num_kv_heads = 1 / head_size_v = 0 encoding gets argued out.
  • SGLang PR #4521 — "Reduce computation and communication in DP attention", part of the data-parallel-attention line of work that is the direct engineering response to the replication problem in §6.
  • Pope et al., Efficiently Scaling Transformer Inference (2022) — the partitioning analysis that explains why the KV-head count and the accelerator count want to be the same number.

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