Run attention operations in parallel on -dimensional slices of the residual stream so different heads can attend to different relations, then concatenate and project.

Equation

Head dim , so total FLOPs match a single-head attention of width .

From scratch

import torch, torch.nn as nn
from einops import rearrange
 
class MHA(nn.Module):
    def __init__(self, d, n_heads):
        super().__init__()
        self.h, self.dh = n_heads, d // n_heads
        self.qkv = nn.Linear(d, 3 * d, bias=False)
        self.proj = nn.Linear(d, d, bias=False)
 
    def forward(self, x):                       # x: (b, n, d)
        qkv = self.qkv(x)                       # (b, n, 3d)
        q, k, v = rearrange(qkv, 'b n (three h dh) -> three b h n dh',
                            three=3, h=self.h)  # each (b, h, n, dh)
        scores = torch.einsum('bhid,bhjd->bhij', q, k) / self.dh**0.5
        n = x.shape[1]
        mask = torch.ones(n, n, dtype=torch.bool, device=x.device).triu(1)
        scores = scores.masked_fill(mask, float('-inf'))
        out = torch.einsum('bhij,bhjd->bhid', scores.softmax(-1), v)
        return self.proj(rearrange(out, 'b h n dh -> b n (h dh)'))

Notes

  • The fused qkv projection is one GEMM instead of three, which is why real implementations do it that way.
  • The three h dh split order must match how weights were saved; (h dh) vs (dh h) is a classic checkpoint-conversion bug.
  • Params: (Q, K, V, O) ignoring biases.
  • Grouped-Query Attention keeps query heads but fewer K/V heads to shrink the KV Cache.

Related: Scaled Dot-Product Attention, Self-Attention, Transformer Block