Self-attention lets every position in a sequence look at every other position and build a context-aware representation by taking a weighted average of value vectors, where the weights come from the compatibility between queries and keys.

Scaled Dot-Product Attention

Each token is projected into three vectors: a query , a key , and a value . Stacking these over a sequence of length gives matrices and . Attention is defined as:

The score matrix holds a dot product for every query-key pair. Softmax over the last axis turns each row into a probability distribution, and multiplying by produces a convex combination of value vectors.

Why divide by ?

If the components of and are independent with mean 0 and variance 1, then has variance . Large-magnitude scores push softmax into saturated regions where gradients vanish. Scaling by restores unit variance and keeps the softmax well conditioned.

Causal Masking

For autoregressive decoding a token must not attend to future positions. We add a mask to the scores before softmax, setting disallowed entries to so their softmax weight becomes 0:

Complexity

The score matrix and the softmax are both in memory, and forming scores plus the context costs in compute. This quadratic dependence on sequence length is the main scaling bottleneck of attention and the motivation behind the KV Cache at inference time and many efficient-attention variants.

Multi-Head Attention

Rather than one attention with dimension , we run heads in parallel, each of width . Heads can specialize (some track syntax, some track long-range coreference), and their outputs are concatenated and projected:

Code: einsum and einops

import torch
import torch.nn.functional as F
from einops import rearrange
 
def scaled_dot_product_attention(q, k, v, causal=False):
    # q, k, v: (batch, seq, d_k)
    d_k = q.shape[-1]
    scores = torch.einsum('bqd,bkd->bqk', q, k) / (d_k ** 0.5)
    if causal:
        T = scores.shape[-1]
        mask = torch.triu(torch.ones(T, T, device=q.device, dtype=torch.bool), diagonal=1)
        scores = scores.masked_fill(mask, float('-inf'))
    attn = F.softmax(scores, dim=-1)
    context = torch.einsum('bqk,bkd->bqd', attn, v)
    return context, attn

The multi-head version uses einops.rearrange to split the model dimension into heads, run attention per head, then merge back:

import torch.nn as nn
 
class MultiHeadAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        assert d_model % n_heads == 0
        self.n_heads = n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model)
        self.out = nn.Linear(d_model, d_model)
 
    def forward(self, x, causal=False):
        # x: (b, t, d_model)
        qkv = self.qkv(x)
        q, k, v = qkv.chunk(3, dim=-1)
        # split heads: (b, t, (h d)) -> (b, h, t, d)
        q, k, v = (rearrange(t, 'b t (h d) -> b h t d', h=self.n_heads) for t in (q, k, v))
        d = q.shape[-1]
        scores = torch.einsum('bhqd,bhkd->bhqk', q, k) / (d ** 0.5)
        if causal:
            T = scores.shape[-1]
            mask = torch.triu(torch.ones(T, T, device=x.device, dtype=torch.bool), diagonal=1)
            scores = scores.masked_fill(mask, float('-inf'))
        attn = torch.softmax(scores, dim=-1)
        ctx = torch.einsum('bhqk,bhkd->bhqd', attn, v)
        # merge heads back: (b, h, t, d) -> (b, t, (h d))
        ctx = rearrange(ctx, 'b h t d -> b t (h d)')
        return self.out(ctx)

Shape discipline

Keeping named axes with einops (b h t d) makes the head split/merge unambiguous and removes error-prone transpose/view chains.

Self-attention is the core operation of Transformers. Because it is permutation-equivariant, order information must be injected via Positional Encoding. Masked self-attention drives Decoder-Only Transformers, and caching past keys and values via the KV Cache avoids recomputing attention over the whole prefix at every generation step.