SingleHeadAttention
Operations Pipeline
This class takes in token embeddings and returns attended representations. Here is every operation and its shape, top to bottom:
Where S = sequence length, H = head_size. Input dimension n_embd gets projected down to H. In a full Transformer, multiple heads run in parallel with smaller H, and their outputs are concatenated back to n_embd.
The rest of this page walks through each operation in the C++ code, focusing on what happens to the tensor shapes at every step.
The Code
Here is the complete class. Read it once to get the full picture, then we will break down each part below.
Constructor: Setting Up Layers
Three linear layers (Q, K, V), each mapping n_embd inputs to head_size outputs, no bias.
| Layer | Weight shape | Parameters |
|---|---|---|
Q | (head_size, n_embd) | head_size * n_embd |
K | (head_size, n_embd) | head_size * n_embd |
V | (head_size, n_embd) | head_size * n_embd |
torch::nn::Linear K{nullptr} creates an empty placeholder. We cannot construct the layer in the class body because the dimensions (n_embd, head_size) come from constructor arguments. So we initialize as null first, then assign real layers inside the constructor body.
register_module("K", ...) does three things: (1) adds the layer's parameters to the module's parameter list so the optimizer can find them, (2) gives it a named key for checkpoint saving/loading, and (3) ensures it moves to GPU when you call model->to(torch::kCUDA).
LinearOptions(n_embd, head_size).bias(false) creates a pure matrix multiply with no bias term. This follows the original Transformer paper. The LinearOptions pattern is LibTorch's C++ equivalent of PyTorch's keyword arguments.
torch::nn::Linear(LinearOptions(n_embd, head_size).bias(false)) creates a weight tensor of shape (head_size, n_embd) with Kaiming uniform initialization. When called as Q(x), it computes x @ weight.T.
Q, K, V Projections
Each linear layer multiplies the last dimension of x by its weight matrix. The operation is applied independently to every token in every sequence. It is a per-token projection from n_embd dimensions down to head_size dimensions.
Why transpose(-2, -1)
This is the single most important mechanical detail in the attention computation. If you understand why we transpose, you understand how attention scores are computed. Let us build the reasoning from first principles.
What We Are Trying to Compute
We want to compute a score between every pair of tokens. Specifically, for token i and token j, the attention score is the dot product of token i's query vector and token j's key vector:
If we have S tokens, we need to compute S x S scores: every query dotted with every key. This is exactly the definition of matrix multiplication. If we stack all query vectors as rows of a matrix Q and all key vectors as columns of a matrix K^T, the matrix product Q @ K^T gives us all S x S scores in one operation.
Row i, column j of the result is exactly the attention score from token i to token j. This is what we want. But there is a problem with shapes.
The Shape Problem
After the projections, q_vec and k_vec both have shape (batch, S, H), where S is the sequence length and H is head_size. Let us try to multiply them directly:
Recall the fundamental rule of matrix multiplication: for 2D matrices, (m x n) @ (n x k) = (m x k). The inner dimensions must match: the last dimension of the first matrix must equal the first dimension of the second matrix.
Applying this to our shapes: for (S, H) @ (?, ?), the second matrix must have its first dimension equal to H. But k_vec's last two dimensions are (S, H), so the first of those is S, not H. The multiplication is impossible as-is.
We need the second matrix to have shape (H, S) so that the inner dimensions align: (S, H) @ (H, S). The inner H dimensions match, and the result is (S, S), which is exactly the S x S attention score matrix we want.
What transpose(-2, -1) Does
k_vec.transpose(-2, -1) swaps the last two dimensions of the tensor. Before and after:
Now the matmul works:
The result is a (batch, S, S) tensor. For each sequence in the batch, we get an S x S matrix where entry [i][j] is the dot product of query i with key j. That is the raw attention score.
An important detail: .transpose(-2, -1) does not copy or rearrange any data in memory. It changes the stride metadata of the tensor. LibTorch tensors store data as a flat 1D array plus a shape and stride descriptor. Transposing simply swaps the stride values for the two dimensions, so the same memory is read in a different order. This makes the transpose operation nearly free, just a metadata update, not a data copy.
Why -2 and -1, Not 0 and 1?
Negative indices count from the end of the dimension list:
The reason for using negative indices instead of .transpose(1, 2) is generality. If the tensor has additional batch dimensions (for example, in multi-head attention the shape might be (batch, n_heads, S, H), a 4D tensor), .transpose(-2, -1) still swaps the correct pair (S and H). Using .transpose(1, 2) on a 4D tensor would swap n_heads and S, which is wrong.
By always using -2 and -1, the code says "swap the last two dimensions, no matter how many dimensions exist." This is robust and future-proof.
Batched matmul
When torch::matmul receives tensors with more than 2 dimensions, it does not follow the simple "first dim of B matches last dim of A" rule from 2D linear algebra. Instead, it does batched matrix multiplication: all dimensions except the last two are treated as batch dimensions, and the actual multiplication only happens on the last two dimensions.
The matching rule for 3D+ tensors: PyTorch matches the last dimension of A with the second-to-last dimension of B. The batch dimensions are carried through unchanged.
Applying this to attention:
Concretely, for each index in the batch dimension:
Each sequence in the batch gets its own independent matrix multiplication. The batch dimension is never mixed: tokens in batch 0 never interact with tokens in batch 1. This is correct, because each sequence is an independent input and should be processed independently.
Interactive Animation: Transpose and Matmul
Step through the transpose and matmul operation visually. Watch how the dimensions align and produce the attention score matrix.
Transpose(-2, -1) and Matmul Walkthrough
k_vec.transpose(-2, -1) swaps the sequence length and head_size dimensions. This turns each key row vector into a column, so that matrix multiplication produces one dot product per (query, key) pair. The batch dimension passes through untouched. The result is an (S x S) attention score matrix per sequence.
Scaling by sqrt(head_size)
After the raw dot products, we divide by sqrt(head_size). Without this, the dot products grow with the dimension: if head_size = 64, typical scores range from -16 to +16. Large inputs push softmax into saturation (nearly one-hot), which kills gradient flow.
head_size is int. C++ sqrt on an int returns double. Dividing a float tensor by a double can cause type promotion issues. The (float) cast ensures the division stays in float32.
Causal Mask
Prevents tokens from attending to future positions. torch::tril zeros out everything above the diagonal. masked_fill_ sets those positions to -1e9, which softmax converts to ~0 weight.
x.options() creates the mask on the same device/dtype as the input (avoids CPU vs GPU mismatch). The trailing underscore in masked_fill_ means in-place modification, avoiding a tensor copy. We use -1e9 instead of -INFINITY to avoid potential NaN issues in edge cases.
The mask is (S, S) but raw_attn is (batch, S, S). Broadcasting treats the mask as (1, S, S) and applies it identically across the batch.
Softmax and Weighted Sum
softmax(..., -1) normalizes each row of the score matrix into a probability distribution. Masked positions (-1e9) become ~0 weight. Then the final matmul computes a weighted sum of value vectors for each token:
The variable output is reused for both softmax and the final matmul. After the second assignment, the intermediate (batch, S, S) softmax tensor is released, which matters for long sequences where that matrix is large.
Complete Shape Trace
Here is every variable in the forward pass with its shape, traced from input to output. Use S for sequence length and H for head_size.
| Line | Variable | Shape |
|---|---|---|
x (input) |
x |
(batch, S, n_embd) |
q_vec = Q(x) |
q_vec |
(batch, S, head_size) |
k_vec = K(x) |
k_vec |
(batch, S, head_size) |
v_vec = V(x) |
v_vec |
(batch, S, head_size) |
k_vec.transpose(-2, -1) |
(intermediate) | (batch, head_size, S) |
matmul(q_vec, k_vec^T) |
raw_attn |
(batch, S, S) |
/ sqrt(head_size) |
raw_attn |
(batch, S, S) |
torch::tril(torch::ones(...)) |
mask |
(S, S) |
raw_attn.masked_fill_(...) |
raw_attn |
(batch, S, S) |
torch::softmax(raw_attn, -1) |
output |
(batch, S, S) |
matmul(output, v_vec) |
output |
(batch, S, head_size) |
(batch, S, n_embd) and leaves as (batch, S, head_size). The sequence length S is preserved (every token still has its own representation). The embedding dimension changes from n_embd to head_size. This dimensionality reduction is intentional: in multi-head attention, each head works in a smaller subspace, and the outputs are concatenated to recover the full dimension.
Full Code
The complete SingleHeadAttention class, ready to compile with LibTorch:
Usage example
With n_embd=64 and head_size=16, each linear layer has a weight matrix of shape (16, 64) = 1024 parameters. Three layers (Q, K, V) give 3 * 1024 = 3072 total trainable parameters. No bias parameters since .bias(false) is set.
What Comes Next
A single attention head captures one type of relationship between tokens. But language has many simultaneous relationships: syntactic structure, coreference, semantic similarity, positional proximity, and more. A single head with a small head_size cannot capture all of these at once.
MultiHeadAttention solves this by running multiple SingleHeadAttention modules in parallel, each with its own Q, K, V weight matrices. Each head learns to focus on a different type of relationship. Their outputs are concatenated along the last dimension and projected back to the original embedding size:
The concatenation recovers the full embedding dimension: n_heads * head_size = 4 * 16 = 64 = n_embd. The final linear projection lets the model learn how to combine information from all the heads.