|
| 1 | +## Prerequisites |
| 2 | + |
| 3 | +Before attempting this problem, you should be comfortable with: |
| 4 | + |
| 5 | +- **Multi-Head Self-Attention** - GQA modifies how heads share K and V, so understanding standard MHA with independent KV heads per query head is essential |
| 6 | +- **KV-Cache** - GQA's primary motivation is reducing the KV-Cache memory footprint during inference, so understanding the cache size problem comes first |
| 7 | +- **Tensor Reshaping** - The implementation requires `view`, `transpose`, and `repeat_interleave` to reshape between `(B, T, D)` and `(B, heads, T, head_dim)` formats |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Concept |
| 12 | + |
| 13 | +Standard Multi-Head Attention gives every query head its own K and V projections. During inference with KV-Cache, all those K and V tensors must be stored, and the memory cost scales linearly with the number of heads. Grouped Query Attention reduces this by sharing K, V across groups of query heads. |
| 14 | + |
| 15 | +With $h$ query heads and $g$ KV heads, each KV head serves $h/g$ query heads. The key operation is `repeat_interleave`: it expands the $g$ KV heads to $h$ by repeating each one $h/g$ times, making the shapes match for standard attention math. When $g = h$, GQA is identical to MHA. When $g = 1$, it becomes Multi-Query Attention. |
| 16 | + |
| 17 | +--- |
| 18 | + |
| 19 | +## Solution |
| 20 | + |
| 21 | +### Intuition |
| 22 | + |
| 23 | +Project x into Q with `num_heads` heads and K, V with `num_kv_heads` heads. Reshape into the multi-head format. Expand K and V by repeating each KV head to match Q's head count. From here, it's standard scaled dot-product attention with a causal mask. Concatenate the heads and apply the output projection. |
| 24 | + |
| 25 | +### Implementation |
| 26 | + |
| 27 | +::tabs-start |
| 28 | +```python |
| 29 | +import torch |
| 30 | +import torch.nn as nn |
| 31 | +from torchtyping import TensorType |
| 32 | + |
| 33 | +class GroupedQueryAttention(nn.Module): |
| 34 | + def __init__(self, model_dim: int, num_heads: int, num_kv_heads: int): |
| 35 | + super().__init__() |
| 36 | + torch.manual_seed(0) |
| 37 | + self.num_heads = num_heads |
| 38 | + self.num_kv_heads = num_kv_heads |
| 39 | + self.head_dim = model_dim // num_heads |
| 40 | + |
| 41 | + self.q_proj = nn.Linear(model_dim, num_heads * self.head_dim, bias=False) |
| 42 | + self.k_proj = nn.Linear(model_dim, num_kv_heads * self.head_dim, bias=False) |
| 43 | + self.v_proj = nn.Linear(model_dim, num_kv_heads * self.head_dim, bias=False) |
| 44 | + self.output_proj = nn.Linear(num_heads * self.head_dim, model_dim, bias=False) |
| 45 | + |
| 46 | + def forward(self, x: TensorType[float]) -> TensorType[float]: |
| 47 | + B, T, D = x.shape |
| 48 | + |
| 49 | + # Project to Q, K, V and reshape into heads |
| 50 | + q = self.q_proj(x).view(B, T, self.num_heads, self.head_dim).transpose(1, 2) |
| 51 | + k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2) |
| 52 | + v = self.v_proj(x).view(B, T, self.num_kv_heads, self.head_dim).transpose(1, 2) |
| 53 | + |
| 54 | + # Expand K, V to match Q's num_heads by repeating each KV head |
| 55 | + repeats = self.num_heads // self.num_kv_heads |
| 56 | + k = k.repeat_interleave(repeats, dim=1) |
| 57 | + v = v.repeat_interleave(repeats, dim=1) |
| 58 | + |
| 59 | + # Scaled dot-product attention with causal mask |
| 60 | + scores = (q @ k.transpose(-2, -1)) * (self.head_dim ** -0.5) |
| 61 | + mask = torch.tril(torch.ones(T, T, device=x.device)) |
| 62 | + scores = scores.masked_fill(mask == 0, float('-inf')) |
| 63 | + weights = torch.softmax(scores, dim=-1) |
| 64 | + |
| 65 | + out = (weights @ v).transpose(1, 2).contiguous().view(B, T, -1) |
| 66 | + return torch.round(self.output_proj(out), decimals=4) |
| 67 | +``` |
| 68 | +::tabs-end |
| 69 | + |
| 70 | + |
| 71 | +### Walkthrough |
| 72 | + |
| 73 | +For `model_dim=8`, `num_heads=4`, `num_kv_heads=2`: |
| 74 | + |
| 75 | +| Step | Operation | Shape | |
| 76 | +|---|---|---| |
| 77 | +| Q projection | `q_proj(x)` then reshape | `(B, 4, T, 2)` -- 4 query heads, head_dim=2 | |
| 78 | +| K projection | `k_proj(x)` then reshape | `(B, 2, T, 2)` -- only 2 KV heads | |
| 79 | +| V projection | `v_proj(x)` then reshape | `(B, 2, T, 2)` -- only 2 KV heads | |
| 80 | +| Expand K | `repeat_interleave(2, dim=1)` | `(B, 4, T, 2)` -- each KV head repeated 2x | |
| 81 | +| Expand V | `repeat_interleave(2, dim=1)` | `(B, 4, T, 2)` -- now matches Q | |
| 82 | +| Attention | Standard scaled dot-product | `(B, 4, T, 2)` per head | |
| 83 | +| Concat + project | Merge heads, output projection | `(B, T, 8)` | |
| 84 | + |
| 85 | +**Memory savings:** K and V projections produce `num_kv_heads * head_dim = 2 * 2 = 4` values per token instead of `num_heads * head_dim = 4 * 2 = 8`. In the KV-Cache, this halves the memory per layer. For Llama 2 70B (64 query heads, 8 KV heads), the savings are 8x. |
| 86 | + |
| 87 | +### Time & Space Complexity |
| 88 | + |
| 89 | +- Time: $O(T^2 \cdot d)$ for attention (same as MHA, since K, V are expanded before the attention computation) |
| 90 | +- Space: $O(g \cdot T \cdot d_h)$ for KV-Cache storage, where $g$ is the number of KV heads and $d_h$ is the head dimension. This is $g/h$ of standard MHA. |
| 91 | + |
| 92 | +--- |
| 93 | + |
| 94 | +## Common Pitfalls |
| 95 | + |
| 96 | +### Using `repeat` Instead of `repeat_interleave` |
| 97 | + |
| 98 | +`repeat` tiles the entire tensor, while `repeat_interleave` repeats each element individually. With 2 KV heads and 4 query heads, you want `[KV0, KV0, KV1, KV1]`, not `[KV0, KV1, KV0, KV1]`. |
| 99 | + |
| 100 | +::tabs-start |
| 101 | +```python |
| 102 | +# Wrong: repeat tiles the whole tensor |
| 103 | +k = k.repeat(1, repeats, 1, 1) # [KV0, KV1, KV0, KV1] -- wrong grouping! |
| 104 | + |
| 105 | +# Correct: repeat_interleave repeats each element |
| 106 | +k = k.repeat_interleave(repeats, dim=1) # [KV0, KV0, KV1, KV1] -- correct groups |
| 107 | +``` |
| 108 | +::tabs-end |
| 109 | + |
| 110 | + |
| 111 | +### Forgetting the Causal Mask |
| 112 | + |
| 113 | +GQA is used in decoder-only models (GPT, Llama) where tokens must not attend to future positions. Without the lower-triangular mask, the model breaks autoregressive generation. |
| 114 | + |
| 115 | +::tabs-start |
| 116 | +```python |
| 117 | +# Wrong: no causal mask |
| 118 | +weights = torch.softmax(scores, dim=-1) |
| 119 | + |
| 120 | +# Correct: apply causal mask before softmax |
| 121 | +mask = torch.tril(torch.ones(T, T, device=x.device)) |
| 122 | +scores = scores.masked_fill(mask == 0, float('-inf')) |
| 123 | +weights = torch.softmax(scores, dim=-1) |
| 124 | +``` |
| 125 | +::tabs-end |
| 126 | + |
| 127 | + |
| 128 | +### Wrong Reshape Order |
| 129 | + |
| 130 | +The view/transpose sequence matters. Q has `num_heads` heads, but K and V have `num_kv_heads` heads. Using `num_heads` for the K reshape would produce incorrect head dimensions. |
| 131 | + |
| 132 | +::tabs-start |
| 133 | +```python |
| 134 | +# Wrong: reshaping K with num_heads |
| 135 | +k = self.k_proj(x).view(B, T, self.num_heads, self.head_dim) # Shape mismatch! |
| 136 | + |
| 137 | +# Correct: reshaping K with num_kv_heads |
| 138 | +k = self.k_proj(x).view(B, T, self.num_kv_heads, self.head_dim) |
| 139 | +``` |
| 140 | +::tabs-end |
| 141 | + |
| 142 | + |
| 143 | +--- |
| 144 | + |
| 145 | +## In the GPT Project |
| 146 | + |
| 147 | +GQA is the attention variant used by Llama 2/3, Mistral, and Gemma. In a full GPT implementation, you would combine GQA with KV-Cache from the previous problem: the cache stores only `num_kv_heads` worth of K, V per layer (not `num_heads`), giving a direct memory reduction proportional to the head ratio. |
| 148 | + |
| 149 | +--- |
| 150 | + |
| 151 | +## Key Takeaways |
| 152 | + |
| 153 | +- GQA shares K and V across groups of query heads, reducing KV-Cache memory by a factor of `num_heads / num_kv_heads` with minimal quality loss. |
| 154 | +- `repeat_interleave` is the key operation: it expands each KV head to serve its assigned group of query heads, making the shapes compatible for standard attention. |
| 155 | +- GQA generalizes both MHA ($g = h$) and MQA ($g = 1$). Most production models use an intermediate value (e.g., Llama 2 70B uses 64 query heads with 8 KV heads). |
| 156 | +- The attention computation after expansion is identical to standard MHA. The savings come entirely from the smaller projection layers and smaller cache. |
0 commit comments