Self-Attention Mechanism
QKV projections, scaled dot-product attention, multi-head attention — step-by-step with shapes, math, and PyTorch implementation.
Self-attention is the computational heart of every LLM — the most FLOPs-intensive, most memory-hungry, and most optimized operation in the entire stack. Understanding it deeply is essential for everything from kernel engineering to serving optimization.
0. A Brief History of Attention
Before Self-Attention, sequence models relied on RNNs/LSTMs which processed tokens sequentially — creating a bottleneck for long sequences and preventing parallelization.
- Bahdanau Attention (2014): Added attention to RNN encoder-decoder for translation. The decoder could “look back” at any encoder position instead of relying on a single fixed-length vector.
- Luong Attention (2015): Simplified the attention score computation (dot product instead of learned alignment).
- Self-Attention (2017, “Attention Is All You Need”): Removed RNNs entirely. Every token attends to every other token in parallel. This unlocked massive parallelism on GPUs and became the foundation of all modern LLMs.
The key insight: attention replaces sequential processing with parallel matrix operations — exactly what GPUs are built for.
1. The QKV Framework
Self-attention uses three projections of the same input to create a retrieval system:
- Query (Q): “what am I looking for?”
- Key (K): “what do I contain?” (used for matching)
- Value (V): “what information do I provide?” (used for output)
Think of it like a library: Q is your search query, K is the index card on each book, V is the book’s content. You match your query against all index cards, then read the most relevant books.
Linear Projections
Each is a GEMM: .
Implementation optimization: In practice, , , are fused into a single matrix of shape , computed as one GEMM then split — better GPU utilization.
2. Scaled Dot-Product Attention
Five steps, each with clear shape tracking:
Step 1: Compute Attention Scores
Element is the dot product between token ‘s query and token ‘s key — how much token should attend to token .
This produces the attention matrix — the source of attention’s complexity.
Step 2: Scale
Without scaling, dot products grow with dimension , pushing softmax into saturation (near-zero gradients). Dividing by keeps values in a range where softmax produces useful gradients.
Step 3: Causal Mask (Decoder Only)
Where is upper-triangular :
M = [ 0, -∞, -∞, -∞ ]
[ 0, 0, -∞, -∞ ]
[ 0, 0, 0, -∞ ]
[ 0, 0, 0, 0 ]
After softmax, — future tokens get zero attention weight. This enforces autoregressive generation: each token can only see itself and previous tokens.
Step 4: Softmax
is the attention weight from token to token .
Step 5: Weighted Sum
Each token’s output is a weighted combination of all (visible) value vectors, with weights determined by the attention scores.
Complete Formula
3. Multi-Head Attention
Instead of one attention function with dimension , use heads each with dimension :
class MultiHeadSelfAttention(nn.Module):
def __init__(self, d_model: int, num_heads: int):
super().__init__()
self.num_heads = num_heads
self.head_dim = d_model // num_heads
self.W_QKV = nn.Linear(d_model, 3 * d_model, bias=False) # fused
self.W_O = nn.Linear(d_model, d_model, bias=False)
def forward(self, x, mask=None):
B, S, d = x.shape
# Fused QKV projection: one GEMM instead of three
QKV = self.W_QKV(x) # (B, S, 3d)
Q, K, V = QKV.chunk(3, dim=-1) # each (B, S, d)
# Reshape into heads: (B, S, d) → (B, Nh, S, dk)
Q = Q.view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
K = K.view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
V = V.view(B, S, self.num_heads, self.head_dim).transpose(1, 2)
# Attention scores: (B, Nh, S, dk) × (B, Nh, dk, S) → (B, Nh, S, S)
scores = (Q @ K.transpose(-2, -1)) / (self.head_dim ** 0.5)
if mask is not None:
scores = scores.masked_fill(mask == 0, float('-inf'))
attn = torch.softmax(scores, dim=-1)
# Weighted sum: (B, Nh, S, S) × (B, Nh, S, dk) → (B, Nh, S, dk)
out = attn @ V
# Merge heads: (B, Nh, S, dk) → (B, S, d)
out = out.transpose(1, 2).contiguous().view(B, S, d)
return self.W_O(out) # output projection
Why multiple heads? Each head learns a different attention pattern — one might track syntactic relationships, another semantic similarity, another positional proximity. The output projection combines all heads.
4. Compute and Memory Cost
For a single attention layer with batch , sequence , heads, dimension :
| Operation | FLOPs | Memory |
|---|---|---|
| QKV projections | (Q, K, V tensors) | |
| (attention matrix) | ||
| Softmax | In-place | |
| Attention × V | (output) | |
| Output projection |
Key bottleneck: the attention matrix is elements. For :
- Attention matrix: bytes (FP16) = 1 TB — doesn’t fit in memory
- This is why FlashAttention (which never materializes this matrix) is essential
FlashAttention: The Solution
The attention matrix is the reason FlashAttention exists. Instead of materializing the full matrix in HBM, FlashAttention:
- Computes attention in tiles that fit in SRAM (shared memory)
- Uses online softmax to accumulate results without the full matrix
- Never writes the matrix to HBM
Result: exact attention with memory instead of , and 2-4× faster due to reduced HBM access. Covered in depth in the CUDA > Attention Kernels module.
Self-Check
- Explain the roles of Q, K, V and their intuition
- Trace attention shapes step by step for given B, S, d, Nh values
- Explain why scaling by is necessary
- Explain the causal mask and what happens to masked positions after softmax
- Implement multi-head attention from scratch in PyTorch
- Calculate attention memory for a given sequence length and explain why FlashAttention is needed