PyTorch Attention Shapes: Q, K, V, Multi-Head Attention Masks and Transformer Dimension Errors
PyTorch: Zero to Hero — Step 07
Attention code is where tensor-shape mistakes stop being annoying and start becoming architectural.
A CNN usually makes its dimensional assumptions fairly obvious. Attention does not.
A tensor that starts as:
(batch, sequence, embedding)
is projected into Q, K and V, split into heads, transposed, multiplied, masked, normalized, multiplied again, transposed again, concatenated and projected back to the embedding dimension.
A single bad view, transpose, mask shape or head calculation can produce anything from an immediate runtime error to a model that trains while attending to the wrong tokens.
This post is a programmer-focused guide to making those transformations explicit.
We will build attention manually, inspect every shape, reproduce the common failures, and then compare the result with PyTorch’s higher-level attention APIs.
The shape contract to memorize
For most transformer code using batch_first=True, start with:
x: (B, T, C)
where:
B = batch size
T = sequence length
C = embedding dimension
For multi-head attention:
H = number of heads
D = head dimension
and therefore:
C = H * D
That identity is not optional.
If:
embed_dim = 512
num_heads = 8
then:
head_dim = embed_dim // num_heads
which gives:
64
The moment you split heads, your shape becomes:
(B, H, T, D)
That is the internal shape we will use throughout this article.
We can visualize the entire flow in one diagram:
flowchart TD
X["x: (B,T,C)"] --> LIN["Linear projections"]
LIN --> Q["Q: (B,T,C)"] & K["K: (B,T,C)"] & V["V: (B,T,C)"]
Q --> SH["split heads"]
K --> SH
V --> SH
SH --> QH["Q: (B,H,T,D)"] & KH["K: (B,H,T,D)"] & VH["V: (B,H,T,D)"]
QH --> SCORES["scores = Q @ K^T"]
KH --> SCORES
SCORES --> S["scores: (B,H,T,T)"]
S --> MASK["mask + softmax"]
MASK --> W["weights: (B,H,T,T)"]
W --> OUT["output = W @ V"]
VH --> OUT
OUT --> O1["out: (B,H,T,D)"]
O1 --> MERGE["merge heads"]
MERGE --> M1["(B,T,C)"]
M1 --> PROJ["output projection"]
PROJ --> Y["y: (B,T,C)"]
Start with a small executable example
import torch
B = 2
T = 5
C = 12
H = 3
D = C // H
x = torch.randn(B, T, C)
print('x:', x.shape)
print('head_dim:', D)
Output:
x: torch.Size([2, 5, 12])
head_dim: 4
Before doing anything else, assert the contract:
assert C % H == 0
Do this in production code too.
A surprising amount of transformer debugging starts with code that should have rejected an invalid configuration earlier.
Q, K and V are just projections
Create three linear projections:
import torch.nn as nn
q_proj = nn.Linear(C, C, bias=False)
k_proj = nn.Linear(C, C, bias=False)
v_proj = nn.Linear(C, C, bias=False)
q = q_proj(x)
k = k_proj(x)
v = v_proj(x)
print(q.shape)
print(k.shape)
print(v.shape)
All three are still:
(B, T, C)
For our example:
torch.Size([2, 5, 12])
Nothing attention-specific has happened yet.
The projections simply create three learned representations of the same input.
Split the embedding dimension into heads
We want:
(B, T, C)
to become:
(B, H, T, D)
The first step is reshape:
def split_heads(x: torch.Tensor, num_heads: int) -> torch.Tensor:
B, T, C = x.shape
assert C % num_heads == 0
D = C // num_heads
x = x.reshape(B, T, num_heads, D)
x = x.transpose(1, 2)
return x
Now:
qh = split_heads(q, H)
kh = split_heads(k, H)
vh = split_heads(v, H)
print('Q:', qh.shape)
print('K:', kh.shape)
print('V:', vh.shape)
Output:
Q: torch.Size([2, 3, 5, 4])
K: torch.Size([2, 3, 5, 4])
V: torch.Size([2, 3, 5, 4])
That is:
(B, H, T, D)
Why transpose is necessary
After the reshape alone:
q.reshape(B, T, H, D)
we have:
(B, T, H, D)
But we want each head to perform attention independently across sequence positions.
So we move the head dimension before the sequence dimension:
q = q.reshape(B, T, H, D).transpose(1, 2)
Now:
(B, H, T, D)
That arrangement makes the matrix multiplication convenient.
The first classic bug: reshaping directly into the wrong order
This is wrong:
q_bad = q.reshape(B, H, T, D)
It has the desired shape.
But it does not have the desired meaning.
You did not move the head dimension.
You reinterpreted contiguous memory as if it were already arranged by head.
This is one of the nastiest tensor bugs because the shape check passes.
Correct:
q_good = q.reshape(B, T, H, D).transpose(1, 2)
The lesson is important:
Correct shape does not imply correct layout semantics.
Scaled dot-product attention from scratch
With:
Q: (B, H, T, D)
K: (B, H, T, D)
we want attention scores between every query position and every key position.
Transpose K’s final two dimensions:
scores = qh @ kh.transpose(-2, -1)
print(scores.shape)
Output:
torch.Size([2, 3, 5, 5])
The score matrix is:
(B, H, T, T)
Each head has a T x T matrix describing which token attends to which token.
Scale by the square root of the head dimension:
import math
scores = scores / math.sqrt(D)
Then softmax over the key dimension:
weights = torch.softmax(scores, dim=-1)
Finally multiply by V:
out = weights @ vh
print(out.shape)
Output:
torch.Size([2, 3, 5, 4])
Still:
(B, H, T, D)
A complete manual attention function
import math
import torch
def scaled_dot_product_attention(q, k, v, mask=None):
"""
q: (B, H, Tq, D)
k: (B, H, Tk, D)
v: (B, H, Tk, Dv)
"""
D = q.shape[-1]
scores = q @ k.transpose(-2, -1)
scores = scores / math.sqrt(D)
if mask is not None:
scores = scores.masked_fill(mask, float('-inf'))
weights = torch.softmax(scores, dim=-1)
output = weights @ v
return output, weights
For self-attention:
out, weights = scaled_dot_product_attention(qh, kh, vh)
print('out:', out.shape)
print('weights:', weights.shape)
Output:
out: torch.Size([2, 3, 5, 4])
weights: torch.Size([2, 3, 5, 5])
Cross-attention changes one dimension
Self-attention uses the same sequence for Q, K and V.
Cross-attention does not.
For cross-attention:
Q: (B, H, Tq, D)
K: (B, H, Tk, D)
V: (B, H, Tk, Dv)
The score tensor becomes:
(B, H, Tq, Tk)
That difference matters when constructing masks.
Do not hardcode square masks unless the operation is guaranteed to be self-attention.
Merge heads back together
After attention:
(B, H, T, D)
we need to return to:
(B, T, C)
Use:
def merge_heads(x: torch.Tensor) -> torch.Tensor:
B, H, T, D = x.shape
x = x.transpose(1, 2)
x = x.reshape(B, T, H * D)
return x
Test it:
merged = merge_heads(out)
print(merged.shape)
Output:
torch.Size([2, 5, 12])
Back to the original embedding dimension.
Why reshape is safer than view here
After:
x.transpose(1, 2)
the tensor may be non-contiguous.
This can break:
x.view(B, T, H * D)
A safer option is:
x.reshape(B, T, H * D)
Or explicitly:
x = x.transpose(1, 2).contiguous()
x = x.view(B, T, H * D)
When debugging transformer code, inspect:
print(x.is_contiguous())
print(x.stride())
The problem may not be the dimensions themselves.
It may be the memory layout after a transpose.
Build multi-head self-attention from scratch
import torch
import torch.nn as nn
import torch.nn.functional as F
class MultiHeadSelfAttention(nn.Module):
def __init__(self, embed_dim: int, num_heads: int, dropout: float = 0.0):
super().__init__()
if embed_dim % num_heads != 0:
raise ValueError(
f'embed_dim={embed_dim} must be divisible by num_heads={num_heads}'
)
self.embed_dim = embed_dim
self.num_heads = num_heads
self.head_dim = embed_dim // num_heads
self.q_proj = nn.Linear(embed_dim, embed_dim, bias=False)
self.k_proj = nn.Linear(embed_dim, embed_dim, bias=False)
self.v_proj = nn.Linear(embed_dim, embed_dim, bias=False)
self.out_proj = nn.Linear(embed_dim, embed_dim, bias=False)
self.dropout = dropout
def split_heads(self, x):
B, T, C = x.shape
x = x.reshape(B, T, self.num_heads, self.head_dim)
x = x.transpose(1, 2)
return x
def merge_heads(self, x):
B, H, T, D = x.shape
x = x.transpose(1, 2)
x = x.reshape(B, T, H * D)
return x
def forward(self, x, attn_mask=None, is_causal=False):
q = self.split_heads(self.q_proj(x))
k = self.split_heads(self.k_proj(x))
v = self.split_heads(self.v_proj(x))
y = F.scaled_dot_product_attention(
q,
k,
v,
attn_mask=attn_mask,
dropout_p=self.dropout if self.training else 0.0,
is_causal=is_causal,
)
y = self.merge_heads(y)
y = self.out_proj(y)
return y
Test it:
model = MultiHeadSelfAttention(embed_dim=12, num_heads=3)
x = torch.randn(2, 5, 12)
y = model(x)
print(y.shape)
Expected:
torch.Size([2, 5, 12])
The head divisibility error
This configuration is invalid:
MultiHeadSelfAttention(embed_dim=10, num_heads=3)
because:
10 / 3
is not an integer.
Do not rely on a later reshape to fail.
Reject it explicitly:
if embed_dim % num_heads != 0:
raise ValueError(...)
This produces a much better error than a mysterious reshape failure 40 lines later.
batch_first is a source of endless shape bugs
PyTorch’s nn.MultiheadAttention historically defaulted to sequence-first ordering:
(T, B, C)
When:
batch_first=True
it uses:
(B, T, C)
For most modern application code, I strongly prefer:
mha = nn.MultiheadAttention(
embed_dim=64,
num_heads=8,
batch_first=True,
)
Then:
x = torch.randn(4, 32, 64)
out, weights = mha(x, x, x)
print(out.shape)
print(weights.shape)
The output is:
(4, 32, 64)
If attention weights are averaged across heads, their shape is typically:
(4, 32, 32)
Debug batch_first explicitly
Write this helper:
def assert_btc(x, *, embed_dim=None):
assert x.ndim == 3, f'expected (B,T,C), got {tuple(x.shape)}'
B, T, C = x.shape
if embed_dim is not None:
assert C == embed_dim, (
f'expected embedding dimension {embed_dim}, got {C}'
)
return B, T, C
Then:
B, T, C = assert_btc(x, embed_dim=64)
Treat tensor shapes as interfaces.
Do not leave them as assumptions in your head.
Causal attention
A language model must not look at future tokens while predicting the current token.
For a sequence of length 5, conceptually the allowed attention pattern is:
1 0 0 0 0
1 1 0 0 0
1 1 1 0 0
1 1 1 1 0
1 1 1 1 1
A manual boolean mask that blocks the upper triangle can be built with:
T = 5
causal_mask = torch.triu(
torch.ones(T, T, dtype=torch.bool),
diagonal=1,
)
print(causal_mask)
For the manual implementation in this article, True means blocked because we use:
scores.masked_fill(mask, float('-inf'))
That convention is extremely common.
But do not assume every PyTorch attention API uses boolean masks with exactly the same meaning.
This is one of the most important debugging rules in attention code.
Mask semantics are API-specific
This is where experienced programmers still lose time.
For nn.MultiheadAttention, a boolean attn_mask uses True to mean the position is not allowed to attend.
For torch.nn.functional.scaled_dot_product_attention, a boolean attn_mask uses True to mean the position participates in attention.
Those are opposite boolean conventions.
So this:
mask = torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1)
may need inversion depending on which API you pass it to.
Do not write generic mask code without documenting the target API.
Prefer is_causal=True when appropriate
With scaled dot-product attention:
y = F.scaled_dot_product_attention(
q,
k,
v,
is_causal=True,
)
This avoids manually constructing a causal mask in many cases.
It also gives PyTorch more information about the operation.
For debugging, this is valuable because you eliminate one whole source of mask-shape and mask-semantics bugs.
Padding masks are different from causal masks
Suppose we batch variable-length sequences:
sequence 1: [A, B, C, D]
sequence 2: [E, F, PAD, PAD]
A padding mask marks tokens that should not be attended to.
For nn.MultiheadAttention:
key_padding_mask = torch.tensor([
[False, False, False, False],
[False, False, True, True ],
])
Shape:
(B, T)
Then:
out, weights = mha(
x,
x,
x,
key_padding_mask=key_padding_mask,
)
This is not the same object as an attention mask.
Attention mask vs padding mask
Think of them as answering different questions.
Attention mask
Can query position i attend to key position j?
Typical shape:
(Tq, Tk)
or sometimes a batch/head-expanded form.
Padding mask
Is this key position real data or padding?
Typical shape:
(B, Tk)
Mixing the two is a classic transformer bug.
Inspect masks like tensors, not metadata
Add a helper:
def describe_mask(name, mask):
if mask is None:
print(f'{name}: None')
return
print(
f'{name}: '
f'shape={tuple(mask.shape)} '
f'dtype={mask.dtype} '
f'device={mask.device} '
f'true={int(mask.bool().sum())}/{mask.numel()}'
)
Use it before attention calls:
describe_mask('attn_mask', attn_mask)
describe_mask('key_padding_mask', key_padding_mask)
This catches many bugs immediately.
The mask device bug
This fails when the model is on CUDA but the mask is on CPU:
x = x.cuda()
mask = torch.ones(T, T, dtype=torch.bool)
Then attention mixes devices.
Build masks on the same device:
mask = torch.ones(
T,
T,
dtype=torch.bool,
device=x.device,
)
Or:
mask = mask.to(x.device)
For reusable modules, derive the device from an input tensor rather than hardcoding 'cuda'.
The mask dtype bug
A mask may be boolean or floating-point depending on the API and intended behavior.
A floating mask usually contains additive bias values such as:
0
-inf
Example:
mask = torch.zeros(T, T)
mask = mask.masked_fill(
torch.triu(torch.ones(T, T, dtype=torch.bool), diagonal=1),
float('-inf'),
)
Always print:
mask.dtype
when debugging attention.
Shape alone is not enough.
A reusable attention shape tracer
def trace_attention_shapes(q, k, v, mask=None):
print('Q', tuple(q.shape), q.dtype, q.device)
print('K', tuple(k.shape), k.dtype, k.device)
print('V', tuple(v.shape), v.dtype, v.device)
assert q.ndim >= 3
assert k.ndim >= 3
assert v.ndim >= 3
assert q.shape[-1] == k.shape[-1], (
f'Q/K head dimensions differ: {q.shape[-1]} vs {k.shape[-1]}'
)
assert k.shape[-2] == v.shape[-2], (
f'K/V sequence lengths differ: {k.shape[-2]} vs {v.shape[-2]}'
)
if mask is not None:
print('MASK', tuple(mask.shape), mask.dtype, mask.device)
Call it immediately before attention:
trace_attention_shapes(q, k, v, mask)
Check the score matrix directly
When attention is behaving strangely, stop hiding behind the module.
Compute:
scores = q @ k.transpose(-2, -1)
Then inspect:
print(scores.shape)
print(scores.min().item())
print(scores.max().item())
print(scores.mean().item())
print(torch.isfinite(scores).all().item())
After masking:
masked_scores = scores.masked_fill(mask, float('-inf'))
check:
print(torch.isfinite(masked_scores).any(dim=-1))
A row that is entirely -inf will produce invalid softmax behavior.
Entirely masked rows
This is a subtle and important failure mode.
If every key for a query position is masked:
[-inf, -inf, -inf, -inf]
then there is no valid distribution to normalize.
That can lead to NaNs or undefined behavior depending on the implementation and dtype.
Write a diagnostic:
def assert_not_fully_masked(mask):
if mask.dtype != torch.bool:
return
fully_masked = mask.all(dim=-1)
if fully_masked.any():
bad = fully_masked.nonzero(as_tuple=False)
raise ValueError(f'fully masked attention rows at {bad.tolist()}')
Adapt the logic to your API’s boolean convention.
Attention weights should sum to one
For a manual softmax implementation:
weights = torch.softmax(scores, dim=-1)
verify:
row_sums = weights.sum(dim=-1)
print(row_sums)
They should be approximately one for valid rows.
Assert it:
assert torch.allclose(
row_sums,
torch.ones_like(row_sums),
atol=1e-5,
)
This is a very useful unit test.
Verify causal attention directly
For causal attention, the upper triangle should have zero attention probability.
future = torch.triu(
torch.ones(T, T, dtype=torch.bool),
diagonal=1,
)
Given:
weights: (B, H, T, T)
check:
future_weights = weights[..., future]
print(future_weights.abs().max())
For a properly masked implementation, this should be effectively zero.
Compare manual attention with PyTorch SDPA
A very good debugging technique is to compare your implementation against scaled_dot_product_attention.
import torch.nn.functional as F
manual_out, _ = scaled_dot_product_attention(
qh,
kh,
vh,
)
pytorch_out = F.scaled_dot_product_attention(
qh,
kh,
vh,
dropout_p=0.0,
)
print(torch.allclose(manual_out, pytorch_out, atol=1e-5))
If this is false, inspect:
scale
mask convention
softmax dimension
Q/K transpose
head order
before blaming autograd or the optimizer.
nn.MultiheadAttention as a reference implementation
PyTorch also exposes:
mha = nn.MultiheadAttention(
embed_dim=C,
num_heads=H,
batch_first=True,
)
Use:
out, weights = mha(
x,
x,
x,
need_weights=True,
)
print(out.shape)
print(weights.shape)
If you need weights per head:
out, weights = mha(
x,
x,
x,
need_weights=True,
average_attn_weights=False,
)
print(weights.shape)
For batched input, this gives a shape like:
(B, H, T, T)
That is far more useful for debugging individual heads.
Disable weight return when you do not need it
For production code:
out, _ = mha(
x,
x,
x,
need_weights=False,
)
This can allow PyTorch to use a more optimized attention path.
Do not keep attention weights just because a tutorial did.
They can be expensive for long sequences.
Attention memory grows quadratically with sequence length
The score tensor shape is:
(B, H, T, T)
Its element count is:
B * H * T * T
Write a helper:
def attention_score_memory_mb(
batch_size,
num_heads,
seq_len,
bytes_per_element=4,
):
elements = batch_size * num_heads * seq_len * seq_len
return elements * bytes_per_element / (1024 ** 2)
Example:
for T in [128, 256, 512, 1024, 2048]:
mb = attention_score_memory_mb(
batch_size=8,
num_heads=8,
seq_len=T,
)
print(T, f'{mb:.1f} MB')
This is only the score tensor.
Training needs considerably more memory than this simple estimate.
A transformer block with explicit shapes
class TransformerBlock(nn.Module):
def __init__(
self,
embed_dim: int,
num_heads: int,
mlp_ratio: int = 4,
dropout: float = 0.0,
):
super().__init__()
self.norm1 = nn.LayerNorm(embed_dim)
self.attn = MultiHeadSelfAttention(
embed_dim=embed_dim,
num_heads=num_heads,
dropout=dropout,
)
self.norm2 = nn.LayerNorm(embed_dim)
hidden_dim = embed_dim * mlp_ratio
self.mlp = nn.Sequential(
nn.Linear(embed_dim, hidden_dim),
nn.GELU(),
nn.Linear(hidden_dim, embed_dim),
)
def forward(self, x, is_causal=True):
assert x.ndim == 3
x = x + self.attn(
self.norm1(x),
is_causal=is_causal,
)
x = x + self.mlp(self.norm2(x))
return x
Test:
block = TransformerBlock(
embed_dim=64,
num_heads=8,
)
x = torch.randn(4, 32, 64)
y = block(x)
print(y.shape)
Expected:
torch.Size([4, 32, 64])
A transformer block should preserve (B, T, C).
That is an excellent invariant to test.
Unit-test your transformer shapes
def test_transformer_block_preserves_shape():
block = TransformerBlock(
embed_dim=64,
num_heads=8,
)
x = torch.randn(3, 17, 64)
y = block(x)
assert y.shape == x.shape
Test several sequence lengths:
import pytest
@pytest.mark.parametrize('seq_len', [1, 2, 7, 32, 129])
def test_transformer_variable_sequence_length(seq_len):
block = TransformerBlock(64, 8)
x = torch.randn(2, seq_len, 64)
y = block(x)
assert y.shape == x.shape
Do not test only the sequence length from your training config.
Shape bugs often hide in boundary cases.
Test sequence length 1
Sequence length one is particularly useful:
x = torch.randn(2, 1, 64)
A surprising amount of attention code accidentally squeezes dimensions and breaks here.
Avoid unqualified:
x.squeeze()
Prefer explicit dimensions:
x.squeeze(dim=1)
when that is actually what you intend.
Test batch size 1
The same rule applies to:
x = torch.randn(1, 32, 64)
If your code behaves differently for batch size 1, look for:
squeeze()
indexing that removes dimensions
manual batch broadcasting
incorrect mask expansion
A practical attention debugging function
def diagnose_attention(
q,
k,
v,
*,
mask=None,
num_heads=None,
):
print('--- attention diagnostic ---')
for name, tensor in [('Q', q), ('K', k), ('V', v)]:
print(
f'{name}: '
f'shape={tuple(tensor.shape)} '
f'dtype={tensor.dtype} '
f'device={tensor.device} '
f'contiguous={tensor.is_contiguous()}'
)
if q.shape[-1] != k.shape[-1]:
print('ERROR: Q and K head dimensions differ')
if k.shape[-2] != v.shape[-2]:
print('ERROR: K and V sequence lengths differ')
if num_heads is not None:
if q.ndim >= 4 and q.shape[-3] != num_heads:
print(
f'WARNING: expected {num_heads} heads, '
f'Q has dimension {q.shape[-3]}'
)
if mask is not None:
print(
f'MASK: '
f'shape={tuple(mask.shape)} '
f'dtype={mask.dtype} '
f'device={mask.device}'
)
if mask.device != q.device:
print('ERROR: mask and Q are on different devices')
This is intentionally boring.
Boring diagnostics are good diagnostics.
Common error: mat1 and mat2 shapes cannot be multiplied
Suppose your attention output is:
(B, T, 256)
but your output projection expects:
nn.Linear(512, 512)
PyTorch eventually reports a matrix multiplication error.
The real bug happened earlier.
Print:
print('before out_proj:', y.shape)
print('weight:', self.out_proj.weight.shape)
The final dimension going into nn.Linear must match in_features.
Common error: wrong transpose on K
This is wrong:
scores = q @ k.transpose(1, 2)
when Q and K have:
(B, H, T, D)
You want to transpose:
T and D
which are the final two dimensions:
scores = q @ k.transpose(-2, -1)
Using negative dimension indices makes attention code much easier to reason about.
Common error: softmax over the wrong dimension
Correct:
weights = torch.softmax(scores, dim=-1)
The final dimension represents the key positions being selected for each query.
If you accidentally use:
softmax(scores, dim=-2)
you normalize across queries instead.
The code runs.
The model is wrong.
This is exactly why attention weights should be tested to ensure the expected axis sums to one.
Common error: forgetting the scale
This:
scores = q @ k.transpose(-2, -1)
should normally be scaled by:
scores = scores / math.sqrt(q.shape[-1])
Without scaling, score magnitudes grow with head dimension and softmax can become excessively sharp.
Again, the code runs.
The training dynamics change.
Common error: applying softmax before masking
Wrong:
weights = torch.softmax(scores, dim=-1)
weights = weights.masked_fill(mask, 0)
Now the remaining probabilities no longer necessarily sum to one.
Correct order:
scores = scores.masked_fill(mask, float('-inf'))
weights = torch.softmax(scores, dim=-1)
Mask logits first.
Normalize second.
Common error: mask broadcasts but across the wrong axes
Broadcasting can make incorrect code look valid.
Suppose scores are:
(B, H, T, T)
and your mask is accidentally:
(B, T)
Depending on the operation, broadcasting may fail or may align dimensions differently from what you intended.
When you want a batch padding mask to broadcast across heads and query positions, make the transformation explicit:
padding_mask = padding_mask[:, None, None, :]
Now:
(B, 1, 1, T)
which broadcasts clearly against:
(B, H, T, T)
Explicit broadcasting is easier to debug than clever broadcasting.
Combined causal and padding masks
For a manual implementation where True means blocked:
B, T = padding_mask.shape
causal = torch.triu(
torch.ones(T, T, dtype=torch.bool, device=x.device),
diagonal=1,
)
causal = causal[None, None, :, :]
padding = padding_mask[:, None, None, :]
combined = causal | padding
Shapes:
graph TD
CAUSAL["causal: (1,1,T,T)"] --> COMBINED[combined: (B,1,T,T)]
PAD["padding: (B,1,1,T)"] --> COMBINED
COMBINED --> BROADCAST["broadcasts over heads → (B,H,T,T)"]
causal: (1, 1, T, T)
padding: (B, 1, 1, T)
combined:(B, 1, T, T)
That broadcasts over heads.
Write the shapes down.
Do not trust yourself to remember them.
Validate mask broadcasting before training
scores = torch.randn(B, H, T, T)
try:
test = scores.masked_fill(combined, float('-inf'))
print('broadcasted shape:', test.shape)
except RuntimeError as exc:
print('mask broadcast failed:', exc)
Do this in a unit test rather than discovering the problem at step 80,000.
A tiny causal language-model attention test
B = 2
T = 8
C = 32
H = 4
attn = MultiHeadSelfAttention(C, H)
x = torch.randn(B, T, C)
y = attn(x, is_causal=True)
assert y.shape == x.shape
assert torch.isfinite(y).all()
This should be the minimum smoke test for every custom attention module.
Test gradients too
A forward pass is not enough.
attn = MultiHeadSelfAttention(32, 4)
x = torch.randn(2, 8, 32, requires_grad=True)
y = attn(x, is_causal=True)
loss = y.square().mean()
loss.backward()
assert x.grad is not None
assert torch.isfinite(x.grad).all()
for name, param in attn.named_parameters():
assert param.grad is not None, f'missing gradient: {name}'
assert torch.isfinite(param.grad).all(), f'bad gradient: {name}'
Attention code that only works in inference is not finished.
Compare gradients between manual and reference implementations
For low-level debugging, initialize two implementations with matching weights and compare:
forward outputs
gradients with respect to Q/K/V
parameter gradients
torch.autograd.grad is useful here:
grads = torch.autograd.grad(
outputs=loss,
inputs=(q, k, v),
)
If outputs match but gradients do not, you have found a deeper implementation problem.
Do not materialize attention weights unless you need them
This matters for memory.
If your application does not need attention maps:
out, _ = mha(
x,
x,
x,
need_weights=False,
)
Returning full attention matrices for long sequences can add meaningful memory and compute overhead.
Debug with weights when debugging.
Turn them off when you are done.
Shape contracts for a transformer stack
A useful transformer invariant is:
input: (B, T, C)
block: (B, T, C)
block: (B, T, C)
block: (B, T, C)
output: (B, T, C)
Only specialized boundaries should change that contract.
For example:
token IDs: (B, T)
embedding: (B, T, C)
transformer: (B, T, C)
LM head: (B, T, V)
where:
V = vocabulary size
This is the architecture we will assemble in Step 10.
Build a shape-audited transformer stack
class ShapeCheckedBlock(TransformerBlock):
def forward(self, x, is_causal=True):
input_shape = x.shape
y = super().forward(x, is_causal=is_causal)
if y.shape != input_shape:
raise RuntimeError(
f'transformer block changed shape: '
f'{tuple(input_shape)} -> {tuple(y.shape)}'
)
return y
You can remove these checks later if profiling proves they matter.
During development, they are cheap insurance.
Hooks for transformer debugging
You can reuse forward hooks from earlier posts:
def shape_hook(name):
def hook(module, inputs, output):
def shape_of(value):
if isinstance(value, torch.Tensor):
return tuple(value.shape)
return type(value).__name__
print(
name,
'in=', [shape_of(v) for v in inputs],
'out=', shape_of(output),
)
return hook
Register them selectively:
handles = []
for name, module in model.named_modules():
if isinstance(module, (nn.Linear, nn.LayerNorm, MultiHeadSelfAttention)):
handles.append(
module.register_forward_hook(shape_hook(name))
)
After debugging:
for handle in handles:
handle.remove()
Debug values as well as shapes
A tensor can have the perfect shape and still be broken.
Add:
def tensor_health(name, x):
print(
name,
'shape=', tuple(x.shape),
'mean=', x.mean().item(),
'std=', x.std().item(),
'min=', x.min().item(),
'max=', x.max().item(),
'finite=', torch.isfinite(x).all().item(),
)
Use it on:
Q
K
V
attention scores
attention weights
attention output
Attention failures are often numerical, not dimensional.
Detect suspiciously uniform attention
Uniform attention is not always wrong, especially at initialization.
But if it persists unexpectedly, inspect it.
entropy = -(weights * (weights + 1e-12).log()).sum(dim=-1)
print('attention entropy:', entropy.mean().item())
You can compare this over training.
Very high entropy means broadly distributed attention.
Very low entropy means highly concentrated attention.
Do not optimize this metric blindly.
Use it as an observation.
Detect collapsed heads
If multiple heads become nearly identical, compare them:
# weights: (B, H, T, T)
head_means = weights.mean(dim=(0, 2, 3))
print(head_means)
For deeper analysis, flatten each head’s attention map and compute similarity.
Again, this is diagnostic evidence, not a universal quality metric.
A compact debugging checklist
When attention code fails, check these in order:
flowchart TD
A["input shape (B,T,C)?"] -->|No| A1[Fix input pipeline]
A -->|Yes| B["C divisible by H?"]
B -->|No| B1[Adjust embed_dim or num_heads]
B -->|Yes| C["After split: (B,H,T,D)?"]
C -->|No| C1[Check reshape + transpose]
C -->|Yes| D["K transposed (-2,-1)?"]
D -->|No| D1[Fix transpose axes]
D -->|Yes| E["Softmax over dim=-1?"]
E -->|No| E1[Correct softmax axis]
E -->|Yes| F["Mask device, dtype, semantics correct?"]
F -->|No| F1[Align mask with API]
F -->|Yes| G["Any row fully masked?"]
G -->|Yes| G1[Check padding / causal logic]
G -->|No| H["Weights sum ≈1?"]
H -->|No| H1[Check mask before softmax]
H -->|Yes| I["After merge: (B,T,C)?"]
I -->|No| I1[Fix merge heads]
I -->|Yes| J["Output proj in_features matches?"]
J -->|No| J1[Verify C in out_proj]
J -->|Yes| K["Gradients finite?"]
K -->|No| K1[Check numerical stability]
K -->|Yes| L["All good"]
That list will solve a surprising percentage of transformer bugs.
The key mental model
Multi-head attention looks complicated because a lot of tensor transformations happen close together.
But the core shape flow is simple:
(B, T, C)
|
| linear projections
v
Q, K, V: (B, T, C)
|
| split heads
v
(B, H, T, D)
|
| Q @ K^T
v
scores: (B, H, Tq, Tk)
|
| mask + softmax
v
weights: (B, H, Tq, Tk)
|
| weights @ V
v
(B, H, Tq, D)
|
| merge heads
v
(B, Tq, C)
|
| output projection
v
(B, Tq, C)
If you can account for every dimension in that pipeline, attention stops being magic.
It becomes matrix multiplication with disciplined bookkeeping.
Challenge 1: break head splitting
Start with:
x = torch.randn(2, 8, 32)
Implement head splitting incorrectly with:
x.reshape(2, 4, 8, 8)
Then implement it correctly with reshape + transpose.
Compare values, not only shapes.
Explain why the first version is semantically wrong despite having the same dimensions.
Challenge 2: create a causal-mask bug
Build a boolean causal mask.
Use it once with your manual attention implementation and once with F.scaled_dot_product_attention.
Observe the difference in boolean-mask convention.
Then write a helper that makes the convention explicit in the function name.
For example:
def blocked_positions_to_sdpa_mask(blocked):
return ~blocked
The point is not the helper itself.
The point is to make semantics visible in code.
Challenge 3: test cross-attention
Create:
q = torch.randn(2, 4, 7, 16)
k = torch.randn(2, 4, 11, 16)
v = torch.randn(2, 4, 11, 16)
Predict the output shape before running anything.
Then verify it.
The answer should be:
(2, 4, 7, 16)
The score matrix should be:
(2, 4, 7, 11)
Challenge 4: instrument a transformer block
Add diagnostics that record:
input shape
Q shape
K shape
V shape
score shape
attention-weight shape
merged-head shape
output shape
Make the code raise as soon as one contract is violated.
Do not wait for a later matrix multiplication to fail.
Where the series goes next
We now have enough PyTorch machinery to build and debug real architectures.
The remaining stages are:
Step 08 — PyTorch Model Not Learning? A Systematic Debugging Guide
Step 09 — PyTorch Performance: CUDA Memory, Profiling and torch.compile
Step 10 — Build a Small Language Model From Scratch
Step 08 moves away from isolated APIs and into the question that eventually hits every machine-learning programmer:
The code runs. The loss exists. The optimizer steps. Why is the model still not learning?
That is where we go next.