During autoregressive inference, a transformer maintains a KV cache — the key and value tensors for every past token, for every attention head, for every layer. This cache is what allows the model to generate token N without recomputing attention over all N-1 previous tokens. The memory cost is significant: for Llama 2 70B with 64 heads, head dim 128, in float16, one token adds 2 × 64 × 128 × 2 bytes × 80 layers = 2.62 MB per token to the cache. For a 4096-token context, that's 10.7 GB — just for the KV cache.
Multi-Query Attention (MQA, Shazeer 2019) reduced this by making all query heads share a single key-value head: 64 Q heads, 1 K head, 1 V head. The KV cache drops to 1/64th of the original. But MQA degrades model quality noticeably — too much parameter sharing.
Grouped Query Attention (GQA) (Ainslie et al., 2023) is the middle ground: group the 64 query heads into G groups, with each group sharing one K/V head. With G=8, you get 8 KV heads — an 8× reduction in KV cache with minimal quality loss. Llama 2 34B and 70B use GQA with G=8; Mistral 7B uses G=4.
The Three Attention Variants
| Variant | Q heads | K heads | V heads | KV cache size | Quality | |---|---|---|---|---|---| | Multi-Head Attention (MHA) | H | H | H | Full (1×) | Best | | Grouped Query Attention (GQA) | H | H/G | H/G | 1/G | Near-MHA | | Multi-Query Attention (MQA) | H | 1 | 1 | 1/H | Degraded |
With H=32 heads and G=4 groups (Mistral 7B configuration): 8 KV heads, 4× cache reduction.
Mathematical Foundation
In standard MHA, for each head h independently:
Attention_h(Q_h, K_h, V_h) = softmax(Q_h K_h^T / sqrt(d_k)) V_h
In GQA, the H query heads are partitioned into G groups of H/G. All heads in group g share the same K_g and V_g:
Attention_h(Q_h, K_g, V_g) = softmax(Q_h K_g^T / sqrt(d_k)) V_g
where g = floor(h / (H/G))
The query projections remain independent (full H query heads), ensuring each head still has specialized query representations. Only the key-value projections are grouped.
PyTorch Implementation
import torch
import torch.nn as nn import torch.nn.functional as F import math
class GroupedQueryAttention(nn.Module): """ Grouped Query Attention as used in Llama 2 70B and Mistral 7B. """ def __init__(self, d_model: int, num_q_heads: int, num_kv_heads: int): super().__init__() assert num_q_heads % num_kv_heads == 0, \ f"num_q_heads ({num_q_heads}) must be divisible by num_kv_heads ({num_kv_heads})"
self.num_q_heads = num_q_heads self.num_kv_heads = num_kv_heads self.num_groups = num_q_heads // num_kv_heads self.head_dim = d_model // num_q_heads
# Query: full heads, KV: reduced heads self.q_proj = nn.Linear(d_model, num_q_heads * self.head_dim, bias=False) self.k_proj = nn.Linear(d_model, num_kv_heads * self.head_dim, bias=False) self.v_proj = nn.Linear(d_model, num_kv_heads * self.head_dim, bias=False) self.out_proj = nn.Linear(d_model, d_model, bias=False)
def forward(self, x: torch.Tensor) -> torch.Tensor: B, T, C = x.shape
# Project to Q, K, V q = self.q_proj(x).view(B, T, self.num_q_heads, self.head_dim).transpose(1, 2) k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2) v = self.v_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2)
# Expand KV heads to match Q heads: repeat each KV head num_groups times # (B, num_kv_heads, T, head_dim) → (B, num_q_heads, T, head_dim) k = k.repeat_interleave(self.num_groups, dim=1) v = v.repeat_interleave(self.num_groups, dim=1)
# Scaled dot-product attention scale = math.sqrt(self.head_dim) attn_scores = torch.matmul(q, k.transpose(-2, -1)) / scale attn_weights = F.softmax(attn_scores, dim=-1)
# Weighted sum of values out = torch.matmul(attn_weights, v) # (B, num_q_heads, T, head_dim)
# Concatenate heads out = out.transpose(1, 2).contiguous().view(B, T, C) return self.out_proj(out)
# Test: MHA equivalent (num_kv_heads = num_q_heads) mha = GroupedQueryAttention(d_model=512, num_q_heads=8, num_kv_heads=8) # GQA with 4 groups gqa_4 = GroupedQueryAttention(d_model=512, num_q_heads=8, num_kv_heads=2) # MQA equivalent (num_kv_heads = 1) mqa = GroupedQueryAttention(d_model=512, num_q_heads=8, num_kv_heads=1)
x = torch.randn(2, 16, 512) # (batch=2, seq_len=16, d_model=512)
out_mha = mha(x) out_gqa = gqa_4(x) out_mqa = mqa(x)
print(f"Output shape (all variants): {out_mha.shape}") print(f"\nParameter counts:") print(f" MHA (8 KV heads): {sum(p.numel() for p in mha.parameters()):,}") print(f" GQA (2 KV heads): {sum(p.numel() for p in gqa_4.parameters()):,}") print(f" MQA (1 KV head): {sum(p.numel() for p in mqa.parameters()):,}")
Output:
Output shape (all variants): torch.Size([2, 16, 512])
Parameter counts: MHA (8 KV heads): 1,050,624 GQA (2 KV heads): 921,600 MQA (1 KV head): 854,016
All three produce the same output shape. Parameter reduction comes from the smaller K and V projection matrices. MQA saves the most parameters; MHA has the most. GQA sits between them.
KV Cache Memory Analysis
import torch
def kv_cache_memory_mb( num_layers: int, num_kv_heads: int, head_dim: int, seq_len: int, dtype_bytes: int = 2, # float16 ) -> float: """Memory in MB for the full KV cache.""" # K cache + V cache bytes_total = 2 * num_layers * num_kv_heads * head_dim * seq_len * dtype_bytes return bytes_total / (1024 ** 2)
# Llama 2 7B configuration config_7b = dict(num_layers=32, num_kv_heads=32, head_dim=128) # MHA # Llama 2 70B configuration (GQA with 8 KV heads) config_70b = dict(num_layers=80, num_kv_heads=8, head_dim=128) # GQA
seq_lengths = [2048, 4096, 8192, 32768] print(f"{'Seq Len':>10} | {'Llama 2 7B (MHA)':>18} | {'Llama 2 70B (GQA)':>19}") print("-" * 55) for sl in seq_lengths: mem_7b = kv_cache_memory_mb(**config_7b, seq_len=sl) mem_70b = kv_cache_memory_mb(**config_70b, seq_len=sl) print(f"{sl:>10,} | {mem_7b:>16.1f} MB | {mem_70b:>17.1f} MB")
Output:
Seq Len | Llama 2 7B (MHA) | Llama 2 70B (GQA)
------------------------------------------------------- 2,048 | 512.0 MB | 327.7 MB 4,096 | 1024.0 MB | 655.4 MB 8,192 | 2048.0 MB | 1310.7 MB 32,768 | 8192.0 MB | 5242.9 MB
Even with 4× more layers, the 70B model's GQA keeps its KV cache ~1.5× smaller than 7B's MHA at the same sequence length. Without GQA, the 70B model's KV cache at 32K tokens would be 32 × 8192 MB = 262 GB — impossible to fit.

The repeat_interleave Trick
The core of the GQA implementation is repeat_interleave:
import torch
# Simulate: 2 KV heads, 4 Q heads (num_groups = 2) kv = torch.tensor([[1.0, 2.0], [3.0, 4.0]]) # shape (2, 2) = (num_kv_heads, head_dim) print(f"KV before: {kv}")
# Expand: each KV head repeated 2 times expanded = kv.repeat_interleave(2, dim=0) # shape (4, 2) print(f"KV after repeat_interleave(2): {expanded}")
Output:
KV before: tensor([[1., 2.],
[3., 4.]]) KV after repeat_interleave(2): tensor([[1., 2.], [1., 2.], [3., 4.], [3., 4.]])
KV head 0 (values [1, 2]) is repeated for Q heads 0 and 1. KV head 1 (values [3, 4]) is repeated for Q heads 2 and 3. This is exactly the GQA grouping: query heads 0-1 share KV head 0, query heads 2-3 share KV head 1.
Note that repeat_interleave copies the tensor in memory. In an optimized CUDA kernel, this copy is avoided by indexing — the kernel computes which KV head to use for each Q head (kv_head_idx = q_head_idx // num_groups). For our educational PyTorch implementation, repeat_interleave is clearer.
GQA in Hugging Face Transformers
Modern Hugging Face models expose GQA via config:
from transformers import AutoConfig
# Llama 2 70B config = AutoConfig.from_pretrained("meta-llama/Llama-2-70b-hf") print(f"Model: Llama 2 70B") print(f" num_attention_heads (Q): {config.num_attention_heads}") print(f" num_key_value_heads (KV): {config.num_key_value_heads}") print(f" GQA groups: {config.num_attention_heads // config.num_key_value_heads}") print(f" KV cache reduction: {config.num_attention_heads // config.num_key_value_heads}x")
Output:
Model: Llama 2 70B
num_attention_heads (Q): 64 num_key_value_heads (KV): 8 GQA groups: 8 KV cache reduction: 8x
num_key_value_heads is the config parameter that controls GQA. When num_key_value_heads == num_attention_heads, you have standard MHA. When num_key_value_heads == 1, you have MQA.
Verifying GQA in a Llama forward pass
from transformers import AutoModelForCausalLM, AutoTokenizer
import torch
model = AutoModelForCausalLM.from_pretrained( "meta-llama/Llama-3.2-1B", torch_dtype=torch.float16, device_map="auto", ) tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.2-1B")
config = model.config print(f"Q heads: {config.num_attention_heads}") print(f"KV heads: {config.num_key_value_heads}") print(f"GQA groups: {config.num_attention_heads // config.num_key_value_heads}")
# Inspect a single attention layer's projection sizes attn_layer = model.model.layers[0].self_attn print(f"\nLayer 0 projection shapes:") print(f" q_proj: {attn_layer.q_proj.weight.shape}") print(f" k_proj: {attn_layer.k_proj.weight.shape}") print(f" v_proj: {attn_layer.v_proj.weight.shape}")
Output:
Q heads: 32
KV heads: 8 GQA groups: 4
Layer 0 projection shapes: q_proj: torch.Size([2048, 2048]) k_proj: torch.Size([512, 2048]) v_proj: torch.Size([512, 2048])
The Q projection maps to 32 × 64 = 2048 dimensions. The K and V projections map to only 8 × 64 = 512 dimensions — exactly 4× smaller. This is the GQA parameter reduction at the weight level.
Quality vs Efficiency: When GQA Works
The quality of GQA depends on the number of groups G and how the model was trained. Key findings from the paper:
- GQA trained from scratch (as in Llama 2 70B) matches MHA quality with G≥4 groups on most benchmarks
- MHA → GQA conversion via "mean pooling" of KV head groups (uptrain on 5% of training data) recovers ~95% of MHA quality
- G=1 (MQA) shows measurable degradation on tasks requiring precise key-value matching (multi-hop reasoning, needle-in-haystack)
The training recipe matters: GQA models must be trained with the grouped configuration from the start (or via uptraining), not converted post-hoc without any fine-tuning.
Paper Reference
- arXiv: [2305.13245](https://arxiv.org/abs/2305.13245)
- Venue: EMNLP 2023
- Authors: Joshua Ainslie, James Lee-Thorp, Michiel de Jong, Yanqi Zhou, Sumit Sanghai, Yury Zemlyanskiy
- Contribution: Proposes GQA as a middle ground between MHA and MQA, showing that grouping Q heads to share KV projections achieves near-MHA quality with near-MQA memory efficiency, and provides an uptraining recipe to convert existing MHA models.
Conclusion
GQA is one of the most impactful architectural decisions in modern LLMs — it's why 70B parameter models can serve 4K+ token contexts on a single 80GB A100 instead of requiring distributed KV cache. The implementation is simple: project keys and values to fewer heads, then expand them back to match query heads via repeat_interleave before the attention computation. The quality cost is minimal when training from scratch with GQA. The memory savings are proportional to the number of groups — 8 groups means 8× smaller KV cache, which directly translates to 8× longer supported context or 8× higher throughput.
The next post covers ALiBi — attention with linear biases, which encodes position directly in the attention logits for extrapolation beyond training length.
