Grouped Query Attention › Implementation for Inference

Michael BrenndoerferUpdated August 6, 20254 min read
Language AI HandbookAll chapters
Part 29 of 67Modern Decoder Models
Chapter 209Grouped Query Attention › Implementation for Inference
Reading section 7 of 14 · Implementation for Inference · 4 minSection 7 of 14 · 4 min

Book contents

Section 7 of 14 · 4 min read

Grouped Query Attention › Implementation for InferenceOne section · Sections in this chapter7 / 14 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

For production deployment, efficient KV cache management matters. Let's implement a complete GQA module optimized for autoregressive generation with proper caching.

In[16]:
Code
class GQAForGeneration(nn.Module):
    """GQA implementation optimized for autoregressive generation."""

    def __init__(
        self,
        d_model: int,
        num_heads: int,
        num_kv_heads: int,
        max_seq_len: int = 4096,
        dropout: float = 0.0,
    ):
        super().__init__()
        assert d_model % num_heads == 0
        assert num_heads % num_kv_heads == 0

        self.d_model = d_model
        self.num_heads = num_heads
        self.num_kv_heads = num_kv_heads
        self.num_queries_per_kv = num_heads // num_kv_heads
        self.head_dim = d_model // num_heads
        self.max_seq_len = max_seq_len

        # Projections
        self.w_q = nn.Linear(d_model, num_heads * self.head_dim, bias=False)
        self.w_k = nn.Linear(d_model, num_kv_heads * self.head_dim, bias=False)
        self.w_v = nn.Linear(d_model, num_kv_heads * self.head_dim, bias=False)
        self.w_o = nn.Linear(d_model, d_model, bias=False)

        self.dropout = nn.Dropout(dropout)
        self.scale = self.head_dim**-0.5

        # Pre-compute causal mask
        self.register_buffer(
            "causal_mask", torch.tril(torch.ones(max_seq_len, max_seq_len))
        )

    def forward(
        self,
        x: torch.Tensor,
        start_pos: int = 0,
        kv_cache: tuple = None,
    ) -> tuple:
        batch_size, seq_len, _ = x.shape

        # Compute queries
        q = self.w_q(x).view(batch_size, seq_len, self.num_heads, self.head_dim)
        q = q.transpose(1, 2)  # (batch, num_heads, seq, head_dim)

        # Compute new keys and values
        k_new = self.w_k(x).view(
            batch_size, seq_len, self.num_kv_heads, self.head_dim
        )
        v_new = self.w_v(x).view(
            batch_size, seq_len, self.num_kv_heads, self.head_dim
        )

        # Update cache
        if kv_cache is not None:
            k_cache, v_cache = kv_cache
            k = torch.cat([k_cache, k_new], dim=1)
            v = torch.cat([v_cache, v_new], dim=1)
        else:
            k = k_new
            v = v_new

        new_kv_cache = (k, v)
        kv_seq_len = k.size(1)

        # Reshape for attention
        k = k.transpose(1, 2)  # (batch, num_kv_heads, kv_seq, head_dim)
        v = v.transpose(1, 2)

        # Expand KV to match query heads
        k = k.repeat_interleave(self.num_queries_per_kv, dim=1)
        v = v.repeat_interleave(self.num_queries_per_kv, dim=1)

        # Attention
        attn_scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale

        # Apply causal mask
        mask = self.causal_mask[start_pos : start_pos + seq_len, :kv_seq_len]
        mask = mask.unsqueeze(0).unsqueeze(0)  # (1, 1, seq, kv_seq)
        attn_scores = attn_scores.masked_fill(mask == 0, float("-inf"))

        attn_weights = F.softmax(attn_scores, dim=-1)
        attn_weights = self.dropout(attn_weights)

        # Output
        attn_output = torch.matmul(attn_weights, v)
        attn_output = attn_output.transpose(1, 2).contiguous()
        attn_output = attn_output.view(batch_size, seq_len, self.d_model)

        return self.w_o(attn_output), new_kv_cache


def simulate_generation(model: GQAForGeneration, prompt_len: int, gen_len: int):
    """Simulate token-by-token generation and track cache growth."""
    batch_size = 1
    d_model = model.d_model

    # Process prompt
    prompt = torch.randn(batch_size, prompt_len, d_model)
    _, kv_cache = model(prompt, start_pos=0)

    cache_sizes = []
    current_pos = prompt_len

    # Generate tokens one by one
    for _ in range(gen_len):
        # Simulate next token embedding
        next_token = torch.randn(batch_size, 1, d_model)

        # Process with cached KV
        _, kv_cache = model(
            next_token, start_pos=current_pos, kv_cache=kv_cache
        )
        current_pos += 1

        # Track cache size
        k_cache, v_cache = kv_cache
        cache_bytes = (k_cache.numel() + v_cache.numel()) * 4  # float32
        cache_sizes.append(
            {
                "position": current_pos,
                "cache_kb": cache_bytes / 1024,
                "k_shape": tuple(k_cache.shape),
            }
        )

    return cache_sizes
Out[17]:
Console
GQA Incremental Generation Demo
=======================================================

Model config: d_model=512, heads=8, kv_heads=2
Prompt: 20 tokens, Generated: 30 tokens

Cache growth:
  After prompt: 21.0 KB, shape: (1, 21, 2, 64)
  After 15 tokens: 35.0 KB, shape: (1, 35, 2, 64)
  After 30 tokens: 50.0 KB, shape: (1, 50, 2, 64)

With MHA, cache would be 4x larger: 200.0 KB

The cache shape shows (batch, seq, num_kv_heads, head_dim) with only 2 KV heads instead of 8 query heads. During attention, these are expanded via repeat_interleave to match the query head count, but the cache itself remains compact.

Out[18]:
Visualization
Line plot showing KV cache size in KB growing linearly with token position, with annotations showing MHA equivalent.
KV cache memory growth during token-by-token generation. The cache grows linearly with sequence length, but GQA-2 requires only 25% of the memory that MHA would need for the same sequence. This difference matters most for long-context generation.

The visualization clearly shows the linear growth of the KV cache during generation. The shaded region represents the memory savings from using GQA-2 instead of MHA. For longer sequences and larger models, these savings become substantial, often determining whether a model fits in GPU memory or requires model parallelism.