Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Every active token consumes key-value (KV) cache memory during decode. Model architecture can shrink that cache before serving tricks like paging, scheduling, or quantization are applied.
Picture a transformer serving stack where every attention head keeps its own K/V projection. With five heads, the paperwork pile is small. With sixty-four heads and long contexts, the KV memory grows quickly. One fix is to have groups of heads share one K/V projection instead of giving every picker a personal copy.
That's the idea behind Grouped-Query Attention (GQA) and Multi-Query Attention (MQA). In decoder-side self-attention with standard multi-head attention (MHA), each query head has its own Key and Value projections.[1] Incremental decode therefore keeps separate cached K/V state per head. As conversations get longer and you serve more users simultaneously, this cache can grow to hundreds of gigabytes. GQA and MQA reduce that dynamic memory cost by sharing cached data across heads. If KV memory is the admission bottleneck, that saving can increase concurrency; it isn't an automatic throughput or quality guarantee.
What do MQA and GQA change compared with standard multi-head attention?
Answer
They keep multiple query heads but reduce the number of cached key/value heads. MQA shares one KV head across all queries, while GQA shares KV heads within groups of query heads.
Why KV cache memory becomes the bottleneck
Every time a model generates a token, it saves the Key and Value vectors from that step so it doesn't have to recompute them later. This KV cache is essential for performance, but it grows linearly with sequence length, and in standard Multi-Head Attention (MHA), every single head keeps its own separate copy. For big models, this adds up fast.
A tiny example to build intuition
Before scaling up to billions of parameters, use a miniature case you can count on your fingers.
Suppose a single layer has 2 attention heads and each head has a dimension of 4. For one token, we must store a Key vector and a Value vector for every head. That's 2 heads times 2 vectors (K and V) times 4 numbers each:
| Component | Count | Numbers stored |
|---|---|---|
| Key vectors | 2 heads | 2 x 4 = 8 |
| Value vectors | 2 heads | 2 x 4 = 8 |
| Total per token | 16 |
If you serve 8 requests in parallel and each request reaches 2,048 tokens, the cache explodes to 16 x 2,048 x 8 = 262,144 numbers for that one layer alone. Scale that to 80 layers and the pile becomes enormous. The problem isn't the math itself; it's the memory needed to hold all those numbers while the GPU streams them through High Bandwidth Memory (HBM) on every decoding step.
1def kv_elements(layers: int, tokens: int, batch: int, kv_heads: int, head_dim: int) -> int:
2 return 2 * layers * tokens * batch * kv_heads * head_dim
3
4tiny_one_layer = kv_elements(layers=1, tokens=2048, batch=8, kv_heads=2, head_dim=4)
5scaled_layers = kv_elements(layers=80, tokens=2048, batch=8, kv_heads=2, head_dim=4)
6print("tiny one-layer elements:", tiny_one_layer)
7print("with 80 layers:", scaled_layers)1tiny one-layer elements: 262144
2with 80 layers: 20971520For the tiny 2-head example, how much KV cache does one layer need for 8 concurrent requests at 2,048 tokens each?
Answer
Each token stores 16 numbers. The one-layer cache is 16 x 2,048 x 8 = 262,144 numbers. A real model then multiplies again by layers and bytes per number.

In standard MHA, each of the attention heads maintains its own Key () and Value () projections. The attention computation for a single layer uses softmax over scaled query-key scores:
Where , , and for a sequence of length . Each head learns a different attention pattern by attending to different aspects of the input. The KV cache stores and for all previously computed tokens so the model doesn't have to recompute them on each decoding step.
KV cache memory with standard MHA
For standard Multi-Head Attention (MHA) with heads, each with dimension , read the cache size formula term by term before you apply it:
- 2 counts the Key and Value tensors separately.
- is the number of attention heads.
- is the dimension inside each head.
- bytes per element depends on the storage format: 2 bytes for FP16 (16-bit floating point), 1 byte for INT8 (8-bit integer), and so on.
Multiply those four numbers and you get the cache for a single token in a single layer. For the full cache across all layers, sequence length, and batch:
Where is layers, is sequence length, and is batch size.
Common trap: is only the cache for one token in one layer. For a full serving estimate, you still need to multiply by layers, sequence length, and batch size.
Concrete example (72B-style decoder dimensions)
Qwen2.5-72B[2] is a useful reference point because it uses 80 layers, 64 query heads, and a head dimension of 128. The published model uses GQA-8, not full MHA, but if a model with those same dimensions used FP16 MHA, the KV cache would be:
| Parameter | Value |
|---|---|
| 8192 | |
| (heads) | 64 |
| 128 | |
| Layers | 80 |
| Sequence length | 4096 |
| Batch size | 32 |
(Equivalent formulation using : bytes.)
counts K and V tensors, is layers, is tokens per request, is batch size, is heads, is head dimension (), and the final is FP16 bytes per element.
That's more than twice the model weights themselves (~144 GB in FP16). For long-context or high-concurrency decoding, moving that cache through HBM can dominate the incremental attention path.[3]
The next figure uses the same 72B-style dimensions, but shifts the shape of the workload from 32 parallel 4K requests to one 128K request. Both cases contain 131,072 cached token positions, so full MHA lands in the same 344 GB range.

In model serving, this is the difference between a full-MHA cache that doesn't fit on a single 80 GB GPU and a GQA-8 cache around 43 GB before weights, runtime buffers, and fragmentation. Whether either configuration is viable still depends on weights, quantization, hardware layout, scheduler policy, and measured traffic.
1def kv_cache_gb(kv_heads: int, tokens: int = 4096, batch: int = 32) -> float:
2 bytes_used = 2 * 80 * tokens * batch * kv_heads * 128 * 2
3 return bytes_used / 1e9
4
5for name, kv_heads in (("MHA", 64), ("GQA-8", 8), ("MQA", 1)):
6 print(f"{name}: {kv_cache_gb(kv_heads):.2f} GB")1MHA: 343.60 GB
2GQA-8: 42.95 GB
3MQA: 5.37 GBWhat is the most common KV-cache sizing mistake?
Answer
Using the per-token-per-layer formula as if it were the full cache. A full serving estimate must multiply by layers, sequence length, batch size, KV heads, head dimension, K/V tensors, and bytes per element.
Multi-Query Attention (MQA)
The extreme compression
Back to the K/V-sharing picture: MQA takes the extreme approach. Instead of every query head keeping its own K/V projection, all query heads share one K/V projection. This saves enormous storage, but some lanes might lose details that would have helped their specific work.
MQA[4] uses one shared K head and one shared V head across all query heads:
- separate query projections (same as standard MHA)
- 1 shared key projection for all query heads
- 1 shared value projection for all query heads
In the head-sharing figure above, MQA is the rightmost design: every query head routes through the same cached K/V pair.
How much memory MQA saves
Return to our tiny example: 2 heads, dimension 4, one token. Under MHA we stored 16 numbers. Under MQA we store only 1 Key and 1 Value head, so the count drops to numbers. That's a 2x reduction for 2 heads. At 64 heads the reduction becomes 64x.
The memory footprint of MQA is drastically smaller because the number of attention heads () in the standard MHA formula is replaced by :
Where means a single shared KV head (instead of heads), so cache per token per layer scales with rather than with .
Instead of storing separate K and V for each of the 64 heads, MQA stores just one shared K and one shared V, cutting cache by 64x. All query heads look up the same key-value pair, like every picking lane using the same route note.
This scaling is proportional to the number of query heads: with query heads and a single KV head, you get an x reduction. The 8-head illustration above shows this with 8x reduction; the same principle applies at scale with 64 heads for a 64x reduction.
| Method | KV heads | Cache per token per layer (FP16) | Savings |
|---|---|---|---|
| MHA | 64 | 1x | |
| MQA | 1 | 64x |
For the same 72B-style dimensions with batch=32 and seq=4096, MHA uses 344 GB of KV cache. MQA cuts that by 64x to ~5.4 GB.
Your 64-head serving model runs out of memory under long chats. You swap from MHA to MQA. What single term in the KV-cache formula changed, and why does that produce a 64x reduction?
Answer
The KV-head count dropped from 64 to 1. MHA stores K and V for all 64 heads, while MQA stores one shared K head and one shared V head, so the cache shrinks by the same 64x factor.
Performance vs. quality tradeoffs in MQA
MQA can reduce quality because all heads share the same key-value representation. Query heads can still ask different questions, but they no longer get separate K/V subspaces.
Common mistake: Treating MQA quality loss as either zero or catastrophic. Real impact depends on model size and task. Shazeer's original paper motivates MQA as a serving optimization, and later GQA work shows why many larger models want more than one KV head.[4][5]
Return to the transformer serving stack: MQA gives every query head the same K/V representation. Each query head can still attend to a different aspect of the context (a separate query), but every answer starts from one shared K/V representation. That's efficient, but it can hide a detail that a specialized K/V projection would have preserved.
After an MQA migration, latency is great but relation-heavy evals regress. What bottleneck did you probably introduce?
Answer
All query heads still ask different questions, but they now read from one shared K/V representation. That single shared subspace can bottleneck tasks that need several distinct attention patterns.
A minimal MQA implementation
This basic PyTorch implementation of Multi-Query Attention projects the input into multiple query heads, but uses only a single shared key and value projection across all query heads. The key and value tensors are broadcast across query heads during attention scoring to produce the final output.
1import torch
2import torch.nn as nn
3import torch.nn.functional as F
4import math
5
6class MultiQueryAttention(nn.Module):
7 """
8 Multi-Query Attention (MQA).
9 Q gets h separate heads; K and V share a single head.
10 """
11 def __init__(self, d_model: int, n_heads: int):
12 super().__init__()
13 self.n_heads = n_heads
14 self.d_k = d_model // n_heads
15
16 # h separate query projections
17 self.W_q = nn.Linear(d_model, d_model)
18 # ONE shared key and value projection (output dim is d_k, not d_model)
19 self.W_k = nn.Linear(d_model, self.d_k)
20 self.W_v = nn.Linear(d_model, self.d_k)
21 self.W_o = nn.Linear(d_model, d_model)
22
23 def forward(self, x: torch.Tensor, mask: torch.Tensor | None = None) -> torch.Tensor:
24 B, N, D = x.shape
25
26 # Q: [batch, seq, d_model] -> [batch, heads, seq, d_k]
27 Q = self.W_q(x).view(B, N, self.n_heads, self.d_k).transpose(1, 2)
28
29 # K and V: [batch, seq, d_k] -> [batch, 1, seq, d_k]
30 # The singleton head dimension broadcasts across all query heads automatically
31 K = self.W_k(x).unsqueeze(1) # [B, 1, N, d_k]
32 V = self.W_v(x).unsqueeze(1) # [B, 1, N, d_k]
33
34 # Attention scores: Q @ K^T
35 # PyTorch broadcasts the singleton head dimension to match h
36 scores = torch.matmul(Q, K.transpose(-2, -1)) / math.sqrt(self.d_k)
37 # shape: [B, h, N, N]
38
39 if mask is not None:
40 scores = scores.masked_fill(mask == 0, float('-inf'))
41
42 attn = F.softmax(scores, dim=-1)
43
44 # Weighted sum: attn @ V
45 # V's singleton head dimension broadcasts to h
46 out = torch.matmul(attn, V) # [B, h, N, d_k]
47 out = out.transpose(1, 2).contiguous().view(B, N, D)
48 return self.W_o(out)
49
50# Shape smoke test
51mqa = MultiQueryAttention(d_model=64, n_heads=4)
52x = torch.randn(2, 10, 64) # batch=2, seq=10
53out = mqa(x)
54print("MQA output shape:", out.shape)1MQA output shape: torch.Size([2, 10, 64])Why does the output stay at width 64 even though K and V use only one head?
Answer
The model still keeps four separate query heads and concatenates their attended outputs back into the full model width. Sharing K and V changes cached state, not the final hidden-size contract.
Grouped-Query Attention (GQA)
The balanced compromise
MQA saves the most memory, but forcing all query heads to share one K/V pair can reduce quality on some workloads. The compromise is to split the lanes into groups, with each group sharing one route note. That's GQA: less aggressive than MQA (one note for everyone), but more memory-efficient than MHA (one note per query head).
GQA[5] is the compromise between MHA and MQA. Instead of 1 KV head (MQA) or KV heads (MHA), use KV groups where :
With groups and total query heads, each group of queries shares one K and one V. For example, Mistral 7B's 32 query heads with 8 KV groups means 4 queries share each KV pair. That's a 4:1 ratio: 4x less KV cache than MHA, but with more representational diversity than MQA's single KV pair.
GQA is GPU-side reference sharing: instead of every query head carrying its own K/V cache view (MHA) or all heads sharing one global view (MQA), you organize heads into groups that share one K/V pair. Each group can still ask different queries, but it reads shared cached reference data.
1def kv_group_for_query(query_head: int, query_heads: int, kv_heads: int) -> int:
2 assert query_heads % kv_heads == 0
3 return query_head // (query_heads // kv_heads)
4
5assignments = [kv_group_for_query(head, query_heads=8, kv_heads=2) for head in range(8)]
6print("query to KV group:", assignments)
7print("cache reduction:", 8 // 2, "x")1query to KV group: [0, 0, 0, 0, 1, 1, 1, 1]
2cache reduction: 4 xGQA in practice
Production model configurations make the pattern easier to compare:
| Model | Total heads () | KV groups () | Ratio |
|---|---|---|---|
| Qwen2.5-72B[2] | 64 | 8 | 8:1 |
| Llama 2 70B[6] | 64 | 8 | 8:1 |
| Mistral 7B[7] | 32 | 8 | 4:1 |
| Gemma 2 9B[8] | 16 | 8 | 2:1 |
Beyond memory savings, GQA interacts with tensor-parallel serving. Tensor parallelism splits one model's work across several accelerators. Each runtime has its own sharding rules. For example, vLLM first requires the total query-head count to divide evenly by the tensor-parallel (TP) degree. It divides KV heads across shards while that's possible, then replicates KV heads when TP exceeds the KV-head count so every shard still owns at least one.[9]
1def vllm_sharding_plan(query_heads: int, kv_heads: int, tensor_parallel: int) -> str:
2 if query_heads % tensor_parallel != 0:
3 return "invalid: query heads must divide evenly across TP shards"
4 if tensor_parallel <= kv_heads:
5 return f"even: {kv_heads // tensor_parallel} KV heads per shard"
6 if tensor_parallel % kv_heads != 0:
7 return "replicated: runtime-specific KV ownership"
8 replicas_per_kv_head = tensor_parallel // kv_heads
9 return f"replicated: each KV head appears on {replicas_per_kv_head} shards"
10
11print("TP=4:", vllm_sharding_plan(query_heads=64, kv_heads=8, tensor_parallel=4))
12print("TP=16:", vllm_sharding_plan(query_heads=64, kv_heads=8, tensor_parallel=16))1TP=4: even: 2 KV heads per shard
2TP=16: replicated: each KV head appears on 2 shardsA teammate says "32 query heads and 8 KV groups means 8x savings." What is correct, and why?
Answer
It is 4x savings, not 8x. The factor comes from num_query_heads / num_key_value_heads = 32 / 8 = 4, so each KV head is shared by four query heads.
Why can a 64-query-head, 8-KV-head model lose part of its cache saving at TP=16 in vLLM?
Answer
The 64 query heads still divide cleanly across 16 shards, but there are only 8 KV heads. vLLM gives every shard at least one KV head, so each KV head is replicated on two shards. The model still uses GQA, but the physical serving layout duplicates some cached state.
Memory comparison (72B model, seq=4096)
These are per-request KV cache sizes at the given sequence length:
| Method | KV groups | KV cache per request | Quality posture |
|---|---|---|---|
| MHA | 64 | ~10.7 GB | Reference architecture |
| GQA-8 | 8 | ~1.34 GB | Validate converted or trained model on task evals |
| MQA | 1 | ~0.17 GB | Strongest sharing constraint; evaluate carefully |
In Ainslie et al., intermediate group counts recovered much of MHA quality while retaining MQA-like inference benefits after uptraining.[5] The correct group count for another model remains an architecture and evaluation choice, not a guarantee inherited from that experiment.
You are serving a large support model and need more concurrency without hurting answer quality too much. Why is GQA often safer than pure MQA?
Answer
GQA keeps several KV subspaces instead of collapsing everything into one shared pair. That preserves more representational diversity while still delivering most of the KV-cache savings.
Adoption across model families
Different model families have made distinct architectural choices based on their serving requirements and quality targets:
| Architecture | Typical usage | Design Rationale |
|---|---|---|
| MHA | Older decoder designs, or deployments where KV memory is less constrained | Maximum per-head flexibility, highest KV-cache cost |
| MQA | Serving-first deployments that need the smallest possible KV cache | Aggressive memory reduction with the strongest sharing constraint |
| GQA | Published models such as Llama 2 34B/70B, Qwen2.5-72B, Mistral 7B, and Gemma 2 9B | Intermediate KV-head count; evaluate quality and serving together |
Key observations
- MQA is best understood as a serving optimization: shared KV heads cut cache size and reduce memory traffic during incremental decoding.[4][3]
- Many modern decoder-only LLMs use grouped KV heads, but the choice is model-specific rather than family-wide. Published examples here include Llama 2 34B/70B, Mistral 7B, Qwen2.5-72B, and Gemma 2 9B.[6][7][2][8]
- There's no universal best group count. Model size, quality target, and serving stack decide whether 2:1, 4:1, or 8:1 is right.[5]
Converting MHA to GQA via uptraining
A key practical insight for teams adapting older models: you don't need to train a GQA architecture from scratch. You can convert an existing MHA model to GQA through a process called "uptraining."
MHA to GQA conversion: Partition the existing K/V heads into the desired groups, mean-pool each group's projection weights, then continue training so the model adapts to shared K/V representations. The pooled checkpoint is an initialization, not an exact preservation of the original attention computation, so quality and serving behavior both need evaluation.
The conversion has two main steps:
- Mean-pool KV heads: Group the existing Key and Value heads into the desired number of partitions (e.g. merging 64 heads into 8 groups of 8). Take the mean of the weights for the and projection matrices within each group. This retains information from the original MHA heads as a useful initialization, but it doesn't preserve the original attention computation exactly.
- Fine-tune (uptrain): Train the converted model for a small fraction of the original pretraining budget using the standard next-token prediction objective. This allows the model to adapt its internal representations to the newly merged KV projections.

The original GQA paper[5] showed that this recipe reaches quality close to MHA with 5% of the original pretraining compute in its T5.1.1 encoder-decoder experiments. The paper also calls out an important limit: it didn't compare decoder-only models or a same-size GQA model trained from scratch. For a fresh pretraining run, teams usually bake the desired KV-head pattern into the architecture from day one. Uptraining matters when you inherit an older MHA checkpoint and want serving gains without restarting pretraining.
1def mean_pool_heads(heads: list[list[float]], group_size: int) -> list[list[float]]:
2 assert len(heads) % group_size == 0
3 pooled: list[list[float]] = []
4 for start in range(0, len(heads), group_size):
5 group = heads[start : start + group_size]
6 pooled.append([
7 sum(values) / len(group)
8 for values in zip(*group)
9 ])
10 return pooled
11
12mha_k_heads = [[1.0, 3.0], [3.0, 5.0], [10.0, 12.0], [14.0, 16.0]]
13print("GQA initial K heads:", mean_pool_heads(mha_k_heads, group_size=2))1GQA initial K heads: [[2.0, 4.0], [12.0, 14.0]]How do you initialize GQA from an existing MHA checkpoint?
Answer
Group the old K/V heads and mean-pool their projection weights into fewer KV heads. Then uptrain the converted model so it adapts to the reduced K/V capacity.
Beyond GQA: Multi-Head Latent Attention (MLA)
GQA reduces the number of distinct KV heads stored in the cache. Multi-Head Latent Attention (MLA) takes a different route: it reduces the dimensionality of what must be stored per position by learning a low-rank latent representation.
How MLA works (high-level)
Instead of caching full per-head Key and Value content vectors for every token, the model does the following:
-
Input hidden states are projected into a compact latent vector of dimension .
-
Only this compact latent state (plus a small amount of decoupled positional information) is written into the KV cache.
-
The architecture defines learned up-projections from that latent state. In an optimized inference implementation, those projection matrices can be absorbed into the query and output paths so decode doesn't materialize full per-head cached K/V again.[10]
DeepSeek-V2 also separates a positional RoPE component from compressed content so the cache can retain the required position-dependent term without preventing content compression.[10]
DeepSeek-V2 reports a 93.3% reduction in deployed KV-cache memory footprint compared with DeepSeek 67B.[10] That deployed comparison also includes KV-cache quantization, so don't treat 93.3% as an MLA-only head-count ratio. MLA's architectural payload depends on the chosen latent width , positional component, and execution path.

You need long-context serving on a runtime that already supports GQA kernels but not MLA-specific execution. Why is GQA operationally simpler, even if MLA compresses harder on paper?
Answer
GQA keeps a conventional K/V-head contract that existing grouped-attention kernels and sharding layouts support. MLA can compress harder, but the model and runtime must implement its latent-cache attention path correctly.
Trade-offs and adoption
| Aspect | GQA | MLA |
|---|---|---|
| Mechanism | Fewer KV heads () | Low-rank latent compression + up-projection |
| Kernel compatibility | Fits GQA-aware attention kernels | Needs an MLA-aware latent-cache implementation |
| Compression measure | KV-head ratio gives exact cache-factor comparison | Cached latent width and positional component determine savings |
| Published examples | Llama 2 70B, Qwen2.5-72B, Mistral 7B, Gemma 2 9B | DeepSeek-V2 |
Why the runtime distinction matters. GQA slots into grouped-attention implementations and familiar tensor-parallel strategies. MLA changes the cached representation and needs a compatible execution path. A smaller cache on paper is only useful when the selected runtime implements that path efficiently.
This architectural fork appears in production interviews: "When would you choose GQA over a more aggressive compression scheme like MLA?" The defensible answer is that GQA has a simpler K/V-head contract, while MLA can compress harder if the selected model and runtime both support its latent path.
DeepSeek-V2 documents a 512-dimensional compressed K/V latent and a 64-dimensional decoupled key component. That means MLA stores 576 values per layer and token before storage-precision choices. For a shape comparison, an illustrative GQA layout with 128-dimensional heads stores values, so MLA's 576-value payload sits at the same width as 2.25 GQA heads. This is an architecture-level payload comparison, not a claim that unrelated models will have the same latency or quality.[10]
1def gqa_cached_values_per_token(kv_heads: int, head_dim: int) -> int:
2 return 2 * kv_heads * head_dim
3
4gqa_8_values = gqa_cached_values_per_token(kv_heads=8, head_dim=128)
5mla_example_values = 512 + 64 # compact content latent plus positional component
6equivalent_gqa_heads = mla_example_values / (2 * 128)
7print("GQA-8 cached values:", gqa_8_values)
8print("MLA-style cached values:", mla_example_values)
9print("MLA-style width in GQA-head units:", equivalent_gqa_heads)1GQA-8 cached values: 2048
2MLA-style cached values: 576
3MLA-style width in GQA-head units: 2.25Your stack already ships MLA kernels and cache pressure is still dominant after other optimizations. When does MLA become worth extra complexity?
Answer
MLA becomes worth it when the runtime already supports its fused kernels and the main remaining limiter is KV-cache footprint. In that setting, extra compression can buy more concurrency than GQA alone.
Serving impact
Concurrency impact (72B-style dimensions, 4K context)
Reducing KV cache doesn't guarantee an equal throughput gain, but it does raise the memory-limited concurrency ceiling. Using the same 80-layer, 64-query-head, 128-dim-head example:

| Config | KV Cache per Request | Relative Memory-Limited Concurrency Ceiling | Relative KV bytes / decode step (same ) |
|---|---|---|---|
| MHA | ~10.7 GB | 1x | 1x |
| GQA-8 | ~1.34 GB | ~8x | ~1/8 |
| MQA | ~0.17 GB | ~64x | ~1/64 |
If KV cache is what limits batch size, those ratios are first-order good estimates. There is a second effect beyond admission capacity: fewer KV heads mean less HBM traffic per decode step when attention is reading the growing cache, which can improve inter-token latency when attention bandwidth binds. That ITL gain is not automatic TPS: weight streaming, kernels, prefill mix, and the scheduler still dominate many deployments, so treat concurrency ceilings and traffic ratios as capacity math, not a promise of 8× tokens/sec.[3]
Long-context impact
KV savings become larger in absolute bytes with long-context models. For the same 72B-style dimensions, per-request cache grows linearly with sequence length:
| Sequence Length | MHA KV Cache (approx.) | GQA-8 KV Cache (approx.) | Savings |
|---|---|---|---|
| 4K | ~10.7 GB | ~1.34 GB | 8x |
| 32K | ~85.9 GB | ~10.7 GB | 8x |
| 128K | ~343.6 GB | ~42.9 GB | 8x |
| 1M | ~2.75 TB | ~343.6 GB | 8x |
At long context, head-sharing or another cache compression strategy becomes an explicit capacity decision. At 128K, full MHA uses roughly 344 GB of KV state per request in this example, before weights, allocator fragmentation, or extra concurrency. GQA-8 drops that cache to about 43 GB, which is still expensive and still needs an end-to-end memory budget.
1def kv_cache_gb(kv_heads: int, tokens: int) -> float:
2 return 2 * 80 * tokens * kv_heads * 128 * 2 / 1e9
3
4mha_128k = kv_cache_gb(kv_heads=64, tokens=131_072)
5gqa_128k = kv_cache_gb(kv_heads=8, tokens=131_072)
6print(f"MHA 128K cache: {mha_128k:.1f} GB")
7print(f"GQA-8 128K cache: {gqa_128k:.1f} GB")
8print("GQA cache plus 144 GB weights fits in 4x80 GB raw:", gqa_128k + 144 <= 320)1MHA 128K cache: 343.6 GB
2GQA-8 128K cache: 42.9 GB
3GQA cache plus 144 GB weights fits in 4x80 GB raw: TrueCommon mistake: Treating long-context support as an architectural context-window claim rather than a serving budget. A long support history, codebase prompt, or retrieved document bundle can consume the same KV allocation. Calculate active-token memory before promising concurrency.
You are targeting 128K context on an 80 GB GPU. Why is GQA-8 alone still not enough, and what else usually joins it?
Answer
GQA-8 cuts the example from about 344 GB to about 43 GB per request, but 43 GB is still too large once weights, batching, fragmentation, and other requests are included. Long-context systems still need paging, quantized KV cache, scheduling, or retrieval.
How serving engines handle GQA head expansion
When building or working with inference engines, you must handle the KV head expansion efficiently. This conceptual implementation shows how an engine matches the smaller number of KV heads to the larger number of query heads before computing attention.
1import math
2import torch
3import torch.nn.functional as F
4
5def gqa_attention_reference(Q: torch.Tensor, K: torch.Tensor, V: torch.Tensor,
6 n_heads: int, n_kv_heads: int) -> torch.Tensor:
7 """
8 Readable GQA implementation concept.
9
10 Args:
11 Q: [batch, n_heads, seq, d_k]
12 K: [batch, n_kv_heads, seq, d_k]
13 V: [batch, n_kv_heads, seq, d_k]
14 n_heads: Number of query heads
15 n_kv_heads: Number of KV heads
16 """
17 # Expand KV heads to match Q heads.
18 # This materializes repeated K/V for clarity. Production serving kernels
19 # compute grouped attention without building these larger tensors.
20 heads_per_group = n_heads // n_kv_heads
21
22 # [batch, n_kv_heads, seq, d_k] -> [batch, n_kv_heads * group_size, seq, d_k]
23 K_expanded = K.repeat_interleave(heads_per_group, dim=1)
24 V_expanded = V.repeat_interleave(heads_per_group, dim=1)
25
26 # Standard attention from here
27 d_k = Q.size(-1)
28 scores = torch.matmul(Q, K_expanded.transpose(-2, -1)) / math.sqrt(d_k)
29 attn = F.softmax(scores, dim=-1)
30 return torch.matmul(attn, V_expanded)
31
32# Shape smoke test
33B, N, h, h_kv, d = 1, 8, 32, 8, 64
34Q = torch.randn(B, h, N, d)
35K = torch.randn(B, h_kv, N, d)
36V = torch.randn(B, h_kv, N, d)
37out = gqa_attention_reference(Q, K, V, h, h_kv)
38print("GQA output shape:", out.shape)1GQA output shape: torch.Size([1, 32, 8, 64])Modern GQA-aware kernels, including FlashAttention's current flash_attn_with_kvcache path and FlashInfer[11], handle fewer KV heads without materializing the expanded tensors. This avoids the memory overhead of repeat_interleave and computes grouped attention directly in the fused kernel. The next chapter explains the serving-side cache layout; FlashAttention[12] gets its own kernel-level chapter after prefix caching.
Production tip: When evaluating models for serving, total parameter count isn't enough.
num_kv_heads, context length, and KV-cache dtype often dominate concurrency. GQA-8 raises the memory-limited concurrency ceiling by roughly 8x versus same-dimension MHA, but realized cost per query still depends on batching, quantization, and kernels.
Why should a serving engine avoid materializing repeat_interleave for GQA?
Answer
Materializing repeated K/V heads recreates the larger MHA-shaped tensor and wastes memory. Native kernels compute grouped attention directly from the smaller KV-head cache.
KV cache update correctness
In serving code, the hardest GQA bug is often not the attention formula. It's updating the cache correctly one token at a time.

For one decode step, the runtime should:
- Project only the new token into Q, K, and V.
- Apply RoPE or another positional transform using the absolute position of that token.
- Append the new K/V slice at the next cache position.
- Run attention with Q from the new token against all cached K/V positions.
- Track cache length per request, because different requests finish at different times.
The cache shape should use KV heads, not query heads:
1K_cache: (batch, n_kv_heads, max_seq, d_k)
2V_cache: (batch, n_kv_heads, max_seq, d_k)In an MHA model, n_kv_heads == n_heads. MQA sets n_kv_heads == 1. GQA sits between them with 1 < n_kv_heads < n_heads.
1def append_kv(cache: list[str], cursor: int, token_value: str) -> int:
2 if cursor != len(cache):
3 raise ValueError("cursor would overwrite or skip cache state")
4 cache.append(token_value)
5 return cursor + 1
6
7cache: list[str] = []
8cursor = append_kv(cache, cursor=0, token_value="token-0-kv")
9cursor = append_kv(cache, cursor=cursor, token_value="token-1-kv")
10print("cache length:", len(cache))
11try:
12 append_kv(cache, cursor=1, token_value="stale-write")
13except ValueError as exc:
14 print("blocked:", exc)1cache length: 2
2blocked: cursor would overwrite or skip cache stateCommon cache-update bugs look like this:
| Symptom | Likely bug | Check |
|---|---|---|
| answer quality degrades after a few tokens | overwrote position t - 1 instead of appending at t | print cache length after every decode step |
| works at batch size 1, fails under batching | used one global cache length for all requests | store per-request positions |
| GQA memory is still huge | allocated cache with query heads instead of KV heads | assert K_cache.size(1) == n_kv_heads |
| long-context output becomes incoherent | applied RoPE with local chunk position instead of absolute position | log the position id used for each appended token |
That's why cache tests should compare a cached decode path against a full-prefix recompute path on the same tiny prompt. The next-token logits (raw scores before probabilities) should match closely. If they don't, the bug is usually position IDs, mask shape, or cache append order.
Cached decode starts drifting after token 200, while full-prefix recompute still looks correct. Which invariants do you inspect first?
Answer
First, verify the cache shape is (batch, n_kv_heads, max_seq, d_k), not query heads. Then compare cached decode logits against a full-prefix recompute on a tiny prompt, because late-token drift usually comes from wrong position IDs, bad mask shape, or incorrect append order.
Common pitfalls
"Model weights are the same thing as KV cache"
-
Symptom: You calculate that a quantized model fits in GPU memory, then the server still runs out of memory under long context or high concurrency.
-
Cause: Weights are static model parameters. The KV cache is dynamic per-request state. GQA mainly shrinks dynamic K/V activations, not the feed-forward layers or the full weight tensor.
-
Fix: Budget weights and KV cache separately. Then add activation buffers, allocator slack, scheduler state, and fragmentation.
"GQA speeds up training"
-
Symptom: You benchmark training throughput and see almost no change after switching from MHA to GQA.
-
Cause: Training often has a different bottleneck from incremental decoding. GQA reduces decode cache state, while training processes whole sequences and may remain dominated by large attention and feed-forward computations.
-
Fix: Measure decode throughput (tokens per second during autoregressive generation), not training throughput. GQA's wins show up when you're repeatedly loading the KV cache for each new token, not when you're computing the full attention matrix once.
"MQA always destroys quality"
-
Symptom: You reject MQA outright even for small, latency-sensitive models where the quality difference isn't visible on your task.
-
Cause: MQA imposes the strongest sharing constraint, but impact depends on model size, task, and training recipe. Treating it as always catastrophic is as wrong as treating it as free.
-
Fix: Compare task evals and serving metrics for your workload. Use GQA when you need a safer quality/serving compromise.
"I saved 8x on KV cache, so my serving cost dropped 8x"
-
Symptom: You calculate a huge KV cache reduction, but measured latency or cost only improves by a fraction of that.
-
Cause: KV cache is one piece of the puzzle. Model weights, feed-forward network (FFN) compute, attention arithmetic, scheduler overhead, and interconnect traffic still matter. If your batch was previously limited by compute rather than memory, shrinking the cache won't move the needle as much.
-
Fix: Profile end-to-end latency with a realistic batch size. Use a tool like NVIDIA Nsight Systems or vLLM's built-in metrics to see whether you're memory-bound or compute-bound before betting on GQA alone.
"repeat_interleave exploded my memory during GQA training"
-
Symptom: Out-of-memory errors when you naively expand KV heads with
repeat_interleaveinside a training loop. -
Cause:
repeat_interleavematerializes a larger tensor in memory. For 32 query heads and 8 KV groups, that temporarily creates a 4x larger K/V tensor before the matmul. -
Fix: Use FlashAttention or FlashInfer, which handle GQA natively without materializing the expanded tensor. If you must write a custom kernel, use an implicit broadcast or fused kernel rather than explicit expansion.
"I can regroup heads any way I want during GQA conversion"
-
Symptom: The converted checkpoint loads, but quality drops sharply or tensor-parallel shards disagree after serving rollout.
-
Cause: Head grouping isn't arbitrary. Query and KV heads often follow a fixed ordering in the weight matrices, and tensor-parallel sharding can interleave or chunk that order in specific ways. Regrouping without respecting the original layout silently changes which heads share a K/V projection.
-
Fix: Preserve the model's published head ordering during regrouping, then run a tiny parity test and shard-level smoke test before broader evals. Don't assume
head 0-7is always the intended first group.
"Architecture-level KV savings always survive tensor parallelism"
-
Symptom: Tensor-parallel serving uses more memory than your head-ratio estimate predicted.
-
Cause: Physical cache layout is runtime-specific. In vLLM, query heads must divide evenly across TP shards, and KV heads are replicated when TP degree exceeds KV-head count so each shard owns at least one.
-
Fix: Check model config and runtime sharding rules before choosing TP degree. Benchmark the physical layout you intend to deploy.[9]
Validate a GQA conversion before rollout
Start with a tiny parity fixture: preserve published head ordering, convert the checkpoint, then compare the converted model's cached decoding against its own full-prefix recomputation. Next, compare held-out quality with the original MHA checkpoint and measure physical KV allocation under the intended tensor-parallel degree and target context length. Promote only if quality stays inside budget and realistic concurrency improves memory or decode throughput without cache drift.