hands-on labon a real NVIDIA GPU

Build a Transformer from Scratch: Attention, Masking & LayerNorm

Build every piece of a decoder-only transformer by hand, scaled dot-product attention, multi-head attention, the full block with residuals and LayerNorm, then assemble a tiny GPT and train it.

time
50 min
checked steps
4
level
Advanced
setup
None
NCP-GENLNCA-GENLNCP-ADSNCA-GENMPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit50 min · 4 stepsSession running
2 / 4 steps passingThe Transformer Block · step 3 of 4
lab.ipynb

Step 3 — The Transformer Block

A block is:

[ ]:
class FeedForward(nn.Module):    def __init__(self, d_model: int, mult: int = 4):        super().__init__()        self.net = nn.Sequential(            nn.Linear(d_model, mult * d_model),            nn.GELU(),            nn.Linear(mult * d_model, d_model),        )     def forward(self, x):        return self.net(x) class TransformerBlock(nn.Module):    def __init__(self, d_model: int, n_heads: int):        super().__init__()        self.ln1 = nn.LayerNorm(d_model)        self.attn = MultiHeadAttention(d_model, n_heads)        self.ln2 = nn.LayerNorm(d_model)        self.ffn = FeedForward(d_model)     def forward(self, x):        # Pre-LN variant (stable at depth)        x = x + self.attn(self.ln1(x))        x = x + self.ffn(self.ln2(x))        return x block = TransformerBlock(d_model=32, n_heads=4).to(device)y = block(torch.randn(2, 8, 32, device=device))print(f'Block output shape: {tuple(y.shape)}')print(f'Block params: {sum(p.numel() for p in block.parameters()):,}')
[ ]:
lab.check(3)
step 3 of 4, replayed: The Transformer Block. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Scaled dot-product attention

    read this step below ↓

  2. 2

    Multi-Head Attention

  3. 3

    The Transformer Block

  4. 4

    Build a tiny GPT, train it, generate

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Scaled dot-product attention

June 12, 2017. Eight researchers at Google Brain and Google Research upload a 15-page paper titled Attention Is All You Need. They discard recurrence, they discard convolutions, and they build an entire sequence model out of one operation: attention. Every major language model of the 2020s — GPT-4, Claude, Gemini, Llama, Mistral — is a direct descendant of that one paper. The core 70 lines of Python haven't meaningfully changed in 8 years.

By the end of this lab you will have built a real transformer from four basic operations (matrix multiply, softmax, layernorm, addition), trained it on GPU, and used it to generate text. No nn.Transformer, no F.scaled_dot_product_attention — you'll write every piece.

Canonical references (read alongside)

What you'll build

A decoder-only (GPT-style) transformer, piece by piece:

  1. Scaled dot-product attention with a causal mask — the one operation that started it all.
  2. Multi-head attention — attention in parallel, concatenated.
  3. The Transformer Block — attention + feed-forward + LayerNorm + residual connections.
  4. Stack blocks into a tiny GPT, train it, generate from it.

Scale context

ModelLayersd_modelHeadsTotal params
(ours, this lab)2644~60K
Original Transformer Base (2017)65128~65M
GPT-2 (small)1276812124M
GPT-3 (175B)9612,28896175B
Llama 3 8B324,096328B

The architecture is the same at every scale — you multiply d_model, layers, and heads by constants. Understanding 60K params means understanding 175B.


Attention answers: "For each position in my sequence, how much should I pay attention to every other position?" It outputs, per position, a weighted mix of the values at all other positions.

The formula (it fits on one line)

$$\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right) V$$

Three matrices per input:

  • Q (queries) — "what am I looking for?" — one vector per position
  • K (keys) — "what do I contain?" — one vector per position
  • V (values) — "what do I contribute?" — one vector per position

QK^T gives a T × T score matrix: score[i, j] = how much position i cares about position j. Softmax turns the scores into probabilities. Multiply by V to get the weighted mix.

Why the √d_k scaling?

Without it, dot products grow with the embedding dim and softmax gets too peaky (one position gets ~100% of the weight, everything else ~0%). Gradients die. The /√d_k keeps the variance of QK^T ~ 1 regardless of embedding size. Skipping this is one of the top-5 bugs people hit when implementing attention from scratch.

The causal mask

For a decoder (generative) transformer, position i must only see positions ≤ i. Seeing the future is cheating — the model would learn to copy the next token instead of predicting it. We enforce this by setting future-position scores to −∞ before softmax, so they become 0 after softmax.

Scaled dot-product attention — one function, five steps

The core operation of every transformer. Five steps, each numbered in the comments:

  1. Score each query against each key (dot product).
  2. Scale by √d to keep softmax sharpness bounded as d grows.
  3. Mask future positions to -∞ (causal attention — token t can only see tokens ≤ t).
  4. Softmax over keys turns scores into a probability distribution per query.
  5. Weighted sum of value vectors using those probabilities.
import torch
import torch.nn as nn
import torch.nn.functional as F

device = torch.device('cuda')
torch.manual_seed(42)

def scaled_dot_product_attention(Q, K, V, causal=True):
    """
    Q, K, V: (B, T, D) tensors.
    Returns: output (B, T, D) and attention weights (B, T, T).
    """
    B, T, D = Q.shape
    # 1. Score each query against each key
    scores = Q @ K.transpose(-2, -1)              # (B, T, T)
    # 2. Scale by √d_k to keep softmax sharpness bounded
    scores = scores / (D ** 0.5)
    # 3. Apply causal mask — set future positions to -inf so softmax zeroes them
    if causal:
        mask = torch.triu(torch.ones(T, T, device=Q.device), diagonal=1).bool()
        scores = scores.masked_fill(mask, float('-inf'))
    # 4. Softmax over the key dimension → probability distribution per row
    weights = F.softmax(scores, dim=-1)          # (B, T, T)
    # 5. Weighted sum of values
    output = weights @ V                          # (B, T, D)
    return output, weights

Sanity check — shapes, row sums, causality

Three invariants every attention implementation should pass:

  • Output shape matches input shape (B, T, D in → B, T, D out).
  • Each row of the attention weights sums to 1.0 (softmax normalization).
  • With causal masking, the top-right triangle of the weights should be zero.
# Verify it runs and the shapes match expectation
B, T, D = 2, 8, 16
Q = torch.randn(B, T, D, device=device)
K = torch.randn(B, T, D, device=device)
V = torch.randn(B, T, D, device=device)
attention_output, attention_weights = scaled_dot_product_attention(Q, K, V, causal=True)
print(f'output shape:     {tuple(attention_output.shape)}')
print(f'weights shape:    {tuple(attention_weights.shape)}')
print(f'row sum (should be 1.0 everywhere): {attention_weights.sum(dim=-1).mean().item():.4f}')
# Visual sanity: show the causal mask in action — upper triangle should be exactly 0.
print('Attention weights for the first example (rows=query, cols=key):')
print(attention_weights[0].cpu().numpy().round(2))
print()
print('Notice the upper triangle is all zeros — that is the causal mask at work.')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Strong PyTorch fundamentals and nn.Module
  • Linear algebra: matrix multiplication, softmax
  • Comfortable with attention intuition (Q, K, V)

Exam domains covered

LLM Integration and DevelopmentGPU Acceleration & Distributed TrainingExperimentation

Part of the LLM training and fine-tuning track

Lab 13 of 14. LoRA and QLoRA fine-tuning, DPO alignment, and a tokenizer and tiny GPT built from scratch.

Questions about this lab

Why divide by sqrt(d_k) in scaled dot-product attention?

Because without it, the magnitudes of Q @ K.T grow proportionally to d_k (roughly, it's a sum of d_k products of unit-variance values). Large pre-softmax scores push softmax into saturation, where gradients effectively vanish for all but the largest entry, attention collapses to "pick one token, zero out everything else" and backprop can't learn. Dividing by sqrt(d_k) keeps the variance of the pre-softmax logits stable across head sizes, which keeps the softmax in its well-behaved region and the gradient flowing.

Why pre-norm (x + MHA(LN(x))) instead of post-norm LN(x + MHA(x))?

Because pre-norm has materially better gradient flow at depth. GPT-2 used post-norm and needed careful warmup plus gradient clipping to train stably; GPT-3 moved to pre-norm and every major modern decoder-only architecture (Llama, Mistral, Qwen, Gemma) kept it. With pre-norm, the residual path is a clean identity stream that each block adds an update to, so gradients can flow through untouched from the final layer back to embeddings. Post-norm interleaves normalisation with the residual addition, which subtly attenuates gradient magnitudes per layer and gets worse the deeper you stack.

Why apply the causal mask with -inf instead of just zeroing out the upper triangle after softmax?

Because softmax is non-linear: zeroing post-softmax would renormalise the remaining entries to something that's no longer a proper probability distribution, and gradients would still flow through the zeroed positions during backprop. masked_fill(mask, float('-inf')) applied BEFORE the softmax sends those logits to a value that exponentiates to exactly 0, which softmax's denominator safely ignores, and the upper triangle gets no gradient contribution because d/dx exp(-inf) = 0. The Step 1 checker enforces this by asserting triu(attention_weights, diagonal=1).abs().max() < 1e-6, if you masked post-softmax, that would fail on numerical rounding.

Why is the MLP's inner dimension 4D?

Mostly empirical tradition carried from "Attention is All You Need", 4× expansion gives the MLP enough capacity to do meaningful non-linear transformation per block without making the parameter count of the feedforward path dominate the model. Modern architectures revisit this: SwiGLU uses roughly 8D/3 with a gate, Llama 3 uses ~14336 for a 4096-dim model (also around 3.5D to offset the extra SwiGLU parameters), etc. The exact multiplier is a tuning knob, but 4× remains the default for vanilla GELU-based blocks.

Why use a single nn.Linear(D, 3*D) for Q/K/V instead of three separate linears?

Performance. A single linear projection fuses three matrix multiplications into one BLAS call, which is noticeably faster on GPU than three sequential matmuls, the kernel launch overhead disappears and the math gets a slightly larger batched GEMM. The output is then sliced or chunked into Q/K/V along the last dimension. Functionally it's identical to three separate linears with the same weights; it's purely a compute-efficiency refactor, and every serious production implementation does it.

What's the smallest architectural change that turns this from GPT-style into Llama-style?

Four surgical swaps, in order of how much they change: replace nn.LayerNorm with RMSNorm (drop mean-centering and bias, keep the scaling parameter), swap the learned positional embedding in the embedding lookup for RoPE rotation applied to Q and K inside multi-head attention, replace Linear(D, 4D) → GELU → Linear(4D, D) with SwiGLU (Linear(D, 2 * hidden) → split into gate and up, silu(gate) * up → Linear(hidden, D)), and switch vanilla MHA to grouped-query attention by projecting fewer K/V heads than Q heads and broadcasting. Each change is 10-30 lines of code against the lab's scaffolding, and each has a cleanly measurable win on the scaling-law / inference-cost axis it targets.

What you'll build in this transformer-from-scratch lab

Every frontier LLM in 2026, Llama 3, Mistral, Qwen, GPT-4, is a decoder-only transformer plus a short list of named upgrades. If you can't build the vanilla version from scratch, those upgrades (RMSNorm, RoPE, SwiGLU, grouped-query attention) stay incantations. In 50 minutes you'll write every piece by hand with no nn.MultiheadAttention or nn.TransformerEncoder shortcuts, train the result on a short repeating pattern until loss drops by 30%+ and it produces coherent output, and walk away with a concrete mental model of why each modern architectural choice exists, why pre-norm replaced post-norm in GPT-3, why sqrt(d_k) matters for gradient flow, why the causal mask goes before softmax not after, and why nn.Linear(D, 3*D) fuses the Q/K/V projections into one BLAS call.

The technical substance is the four pieces plus the geometry traps that catch everyone the first time. Scaled dot-product attention is Q @ K.transpose(-2, -1) / sqrt(d_k) through masked_fill(..., float('-inf')) above the diagonal, softmax(dim=-1), then multiply by V, the checker asserts triu(attention_weights, diagonal=1).abs().max() < 1e-6 to catch mask leakage, and row sums must equal 1.0 to catch misplaced softmax. Multi-head attention reshapes Q/K/V to (B, n_heads, T, d_head) with d_head = D / n_heads and runs your step-1 attention per head in parallel. The GPT-style block uses pre-norm residuals (x = x + MHA(LN(x)) then x = x + MLP(LN(x))) with the MLP as Linear(D, 4D) → GELU → Linear(4D, D), the checker forces a backward pass to confirm gradient flows through the residual stream. The final tiny GPT stacks token + positional embeddings → N blocks → LayerNorm → Linear(D, vocab_size) and trains with AdamW + cross-entropy. Once you've written it, the Llama-style swaps are surgical: RMSNorm drops LayerNorm's mean-centering to save a reduction per token, RoPE rotates Q and K in 2D pairs so position becomes relative and extrapolates past training length, SwiGLU adds a gated activation branch that learns richer features per parameter, and grouped-query attention shares K/V projections across query-head clusters to shrink the KV cache during inference.

Prerequisites are strong PyTorch fundamentals (nn.Module, autograd, broadcasting), linear algebra comfort (matmul, softmax), and an intuition for Q/K/V, you don't need prior transformer-implementation experience; the hints walk through the exact tensor shapes. The sandbox is a real NVIDIA GPU pod we provision per session, with PyTorch preinstalled. Grading is mechanical and strict throughout: output shape must be (B=2, T=8, D=16) with per-row sum = 1.0 and upper-triangular attention weights strictly zero; a MultiHeadAttention class and mha instance must exist with n_heads > 1 and D % n_heads == 0; the full block must pass a smoke-test of shape preservation plus successful backward; training must log ≥20 steps with >30% loss reduction between first-5 and last-5 averages plus a non-empty sampled string.