PROMETHEUS OS
NATIVE AI OPERATING SYSTEM
[INIT] Prometheus OS loading...
[CORE] Neural interface initializing...
[AI] LLM router connecting...
[MEM] Memory graph loading...
[AGT] Agent chains on standby...
[SEC] Security protocols active...
[NET] Provider mesh established...
[UI] Workspace rendering...
[DONE] All systems nominal.
Welcome to PROMETHEUS OS.
READY · OFFLINE · SECURE · PRIVATE
🎨 THEME ENGINE
PROMETHEUS OS · 23 THEMES
Trial version: Dark + Light themes only.
Full theme access with PROMETHEUS OS license.
Dark theme active
Experience all 23 themes in PROMETHEUS OS

Transformers: A Deep Dive

Advanced 19 min· 4 exercises

Derive and implement every part of a transformer, from BPE tokens and positional encodings through scaled dot-product, multi-head and causal attention to pre-norm blocks, and size a model's parameters, attention cost and KV cache.

By the end of this lesson you will be able to
  • Explain how byte-pair encoding builds a vocabulary and tokenize a word with a learned merge list
  • Derive scaled dot-product attention, including the reason for the square-root scaling, and compute it by hand
  • Implement multi-head causal self-attention and a pre-norm transformer block in PyTorch
  • Compare sinusoidal, learned and rotary positional encodings and encoder, decoder and encoder-decoder families
  • Count the parameters and KV-cache size of a decoder layer and explain the O(n^2) cost of attention
  • Explain what MQA, GQA and FlashAttention optimize and when each matters

Before this: Neural Networks and Backpropagation, Training Deep Networks in Practice, Recurrent Networks and Sequence Modelling

Why the transformer matters

The transformer (Vaswani et al., 2017) is the architecture behind essentially every modern large language model, most speech and translation systems, many vision backbones, and the denoisers inside leading image and video generators. If you work in AI engineering you will read transformer code, size transformer deployments, and debug transformer training. This lesson builds the whole thing from tokens to a working attention layer, with every equation defined and a numeric example you can check by hand.

Intuition: a room full of people passing notes

Picture each token in a sentence as a person in a room holding a card with their word on it. In every round, each person writes a query ("what am I looking for?"), a key ("what do I offer?") and a value ("what I will tell you if you pick me"). Each person compares their query against everyone's keys, decides how much to listen to each person, and updates their card with a weighted blend of the values they heard. After a few dozen rounds, the word "bank" in "river bank" has absorbed information from "river", and the card for "it" knows which noun it refers to. That round is self-attention; the room is one layer; stacking rounds gives the deep network. Unlike an RNN, everyone talks at once, which is what makes the transformer fast to train.

Step 1: tokenization with byte-pair encoding

Models operate on integer tokens, not characters or words. Word-level vocabularies cannot handle unseen words; character-level sequences are long. Byte-pair encoding (BPE), adapted for neural translation by Sennrich, Haddow and Birch (2015), finds a middle ground: start from characters (or raw bytes) and repeatedly merge the most frequent adjacent pair into a new symbol.

Worked example. Corpus word counts: hug ×10, pug ×5, pun ×12, bun ×4, hugs ×5. Start with characters.

  1. Pair counts: u g = 10 + 5 + 5 = 20, p u = 5 + 12 = 17, u n = 12 + 4 = 16, h u = 15. Merge u g → ug.
  2. Recount: u n = 16, h ug = 15, p u = 12 (only pun still has a separate u). Merge u n → un.
  3. Recount: h ug = 15, p un = 12. Merge h ug → hug.
  4. Merge p un → pun.

The learned merge list is applied in order to new text: bugs → b u g s → b ug s, three tokens; hugs → hug s. Production tokenizers run tens of thousands of merges, operate on UTF-8 bytes so that any string is representable (byte-level BPE, used since GPT-2), and pre-split text on spaces and punctuation. Related schemes include WordPiece (BERT) and the Unigram model in SentencePiece. Vocabulary sizes of roughly 32k to a few hundred thousand tokens are typical. Because merges are learned from the training corpus, common English words often become single tokens, while code, numbers, rare words and many non-English languages are split into more pieces per word, which raises their cost and eats context length.

Step 2: embeddings and positional information

Each token id indexes a row of an embedding matrix E∈RV×dE \in \mathbb{R}^{V \times d}, where VV is vocabulary size and dd is the model width. Many models tie the output projection to E⊤E^\top to save parameters.

Self-attention, as defined below, is permutation-equivariant: shuffle the input tokens and the outputs shuffle identically. Word order must therefore be injected explicitly.

Sinusoidal encodings (the original transformer) add a fixed vector to each embedding:

PE(p, 2i)=sin⁡ ⁣(p100002i/d),PE(p, 2i+1)=cos⁡ ⁣(p100002i/d)PE_{(p,\,2i)} = \sin\!\left(\frac{p}{10000^{2i/d}}\right), \qquad PE_{(p,\,2i+1)} = \cos\!\left(\frac{p}{10000^{2i/d}}\right)

for position pp and dimension index ii. Each pair of dimensions oscillates at a different frequency, from fast to very slow, giving every position a unique signature. BERT and GPT-2 instead learned a position-embedding table, which cannot extrapolate past its trained length.

Rotary position embedding (RoPE) (Su et al., 2021) rotates the query and key vectors instead of adding to the input. Split a dd-dimensional vector into d/2d/2 pairs; at position mm, rotate pair ii by angle mθim\theta_i with θi=10000−2i/d\theta_i = 10000^{-2i/d}:

[x2i′x2i+1′]=[cos⁡mθi−sin⁡mθisin⁡mθicos⁡mθi][x2ix2i+1]\begin{bmatrix} x'_{2i} \\ x'_{2i+1} \end{bmatrix} = \begin{bmatrix} \cos m\theta_i & -\sin m\theta_i \\ \sin m\theta_i & \cos m\theta_i \end{bmatrix} \begin{bmatrix} x_{2i} \\ x_{2i+1} \end{bmatrix}

Because rotations compose, ⟨Rmq,Rnk⟩=⟨q,Rn−mk⟩\langle R_m q, R_n k \rangle = \langle q, R_{n-m} k \rangle: the attention score depends only on the relative offset n−mn - m. Mini-check in two dimensions with q=k=(1,0)q = k = (1, 0) and θ=π/6\theta = \pi/6: positions (1,3)(1, 3) give a rotation difference of 2θ=π/32\theta = \pi/3 and a score of cos⁡(π/3)=0.5\cos(\pi/3) = 0.5; positions (5,7)(5, 7) give exactly the same 0.5. RoPE was adopted by GPT-J and GPT-NeoX and then by LLaMA, and is now widely used in open-weight decoder models; many context-extension methods work by rescaling its frequencies.

Step 3: scaled dot-product attention

Stack the nn token vectors of a sequence as rows of X∈Rn×dX \in \mathbb{R}^{n \times d}. Learned matrices project them to queries, keys and values:

Q=XWQ,K=XWK,V=XWV,WQ,WK∈Rd×dk,  WV∈Rd×dvQ = X W_Q, \quad K = X W_K, \quad V = X W_V, \qquad W_Q, W_K \in \mathbb{R}^{d \times d_k},\; W_V \in \mathbb{R}^{d \times d_v}

Then

Attention⁡(Q,K,V)=softmax⁡ ⁣(QK⊤dk+M)V\operatorname{Attention}(Q, K, V) = \operatorname{softmax}\!\left(\frac{Q K^\top}{\sqrt{d_k}} + M\right) V

where the softmax is applied to each row, and MM is a mask matrix (all zeros for unmasked attention). Row ii of QK⊤QK^\top holds the similarity of query ii to every key; softmax turns it into weights that sum to 1; multiplying by VV returns a weighted average of value vectors.

Why divide by dk\sqrt{d_k}? If the components of qq and kk are independent with mean 0 and variance 1, then q⋅k=∑j=1dkqjkjq \cdot k = \sum_{j=1}^{d_k} q_j k_j has variance dkd_k. For dk=128d_k = 128 the raw scores have a standard deviation around 11, pushing the softmax into saturation where one weight is near 1 and gradients for the others are near 0. Dividing by dk\sqrt{d_k} restores unit variance.

Worked example: three tokens, dk=2d_k = 2

Q=K=[100111],V=[123456]Q = K = \begin{bmatrix} 1 & 0 \\ 0 & 1 \\ 1 & 1 \end{bmatrix}, \qquad V = \begin{bmatrix} 1 & 2 \\ 3 & 4 \\ 5 & 6 \end{bmatrix}

Scores. QK⊤=[101011112]QK^\top = \begin{bmatrix} 1 & 0 & 1 \\ 0 & 1 & 1 \\ 1 & 1 & 2 \end{bmatrix}; dividing by 2=1.4142\sqrt{2} = 1.4142 gives [0.70700.70700.7070.7070.7070.7071.414]\begin{bmatrix} 0.707 & 0 & 0.707 \\ 0 & 0.707 & 0.707 \\ 0.707 & 0.707 & 1.414 \end{bmatrix}.

Softmax, row 1. e0.707=2.028e^{0.707} = 2.028, e0=1e^{0} = 1, so the row sums to 5.0565.056 and the weights are (0.401,0.198,0.401)(0.401, 0.198, 0.401). Row 2 by symmetry: (0.198,0.401,0.401)(0.198, 0.401, 0.401). Row 3: e0.707=2.028e^{0.707} = 2.028 twice and e1.414=4.113e^{1.414} = 4.113, sum 8.1698.169, weights (0.248,0.248,0.503)(0.248, 0.248, 0.503).

Outputs (weights times VV):

  • Token 1: 0.401(1,2)+0.198(3,4)+0.401(5,6)=(3.000,4.000)0.401(1,2) + 0.198(3,4) + 0.401(5,6) = (3.000, 4.000)
  • Token 2: 0.198(1,2)+0.401(3,4)+0.401(5,6)=(3.407,4.407)0.198(1,2) + 0.401(3,4) + 0.401(5,6) = (3.407, 4.407)
  • Token 3: 0.248(1,2)+0.248(3,4)+0.503(5,6)=(3.510,4.510)0.248(1,2) + 0.248(3,4) + 0.503(5,6) = (3.510, 4.510)

Each output is a blend of all values, tilted toward the tokens whose keys best match that token's query.

Causal masking

A language model predicting token i+1i+1 must not see tokens after ii. The causal mask sets Mij=−∞M_{ij} = -\infty for j>ij > i and 0 otherwise, so those entries become e−∞=0e^{-\infty} = 0 after the softmax. In our example: token 1 can see only itself, so its output is v1=(1,2)v_1 = (1, 2). Token 2 sees scores (0,0.707)(0, 0.707), giving weights (0.330,0.670)(0.330, 0.670) and output 0.330(1,2)+0.670(3,4)=(2.340,3.340)0.330(1,2) + 0.670(3,4) = (2.340, 3.340). Token 3 sees everything and is unchanged at (3.510,4.510)(3.510, 4.510). Masking is what lets a decoder train on all positions of a sequence in parallel while each position still only uses its past.

Step 4: multi-head attention

One attention pattern cannot track syntax, coreference and position all at once. Multi-head attention runs hh attentions in parallel, each with its own projections of width dk=d/hd_k = d/h, concatenates the results and mixes them:

head⁡j=Attention⁡(XWQ(j),XWK(j),XWV(j)),MHA⁡(X)=Concat⁡(head⁡1,…,head⁡h) WO\operatorname{head}_j = \operatorname{Attention}(X W_Q^{(j)}, X W_K^{(j)}, X W_V^{(j)}), \qquad \operatorname{MHA}(X) = \operatorname{Concat}(\operatorname{head}_1, \dots, \operatorname{head}_h)\, W_O

with WO∈Rd×dW_O \in \mathbb{R}^{d \times d}. In code, all heads come from one d×3dd \times 3d matrix multiply reshaped into heads, so the cost is the same as one wide attention.

import math, torch, torch.nn as nn, torch.nn.functional as F

class CausalSelfAttention(nn.Module):
    def __init__(self, d_model, n_heads):
        super().__init__()
        assert d_model % n_heads == 0
        self.h, self.dk = n_heads, d_model // n_heads
        self.qkv = nn.Linear(d_model, 3 * d_model, bias=False)
        self.proj = nn.Linear(d_model, d_model, bias=False)       # W_O

    def forward(self, x):                                         # x: (B, T, D)
        B, T, D = x.shape
        q, k, v = self.qkv(x).split(D, dim=-1)
        q, k, v = (t.view(B, T, self.h, self.dk).transpose(1, 2) for t in (q, k, v))
        scores = q @ k.transpose(-2, -1) / math.sqrt(self.dk)     # (B, h, T, T)
        future = torch.triu(torch.ones(T, T, dtype=torch.bool, device=x.device), 1)
        scores = scores.masked_fill(future, float("-inf"))        # causal mask
        out = scores.softmax(dim=-1) @ v                          # (B, h, T, dk)
        return self.proj(out.transpose(1, 2).reshape(B, T, D))    # merge heads

torch.manual_seed(0)
layer = CausalSelfAttention(d_model=64, n_heads=4)
x = torch.randn(2, 10, 64)
q, k, v = (t.view(2, 10, 4, 16).transpose(1, 2) for t in layer.qkv(x).split(64, dim=-1))
fused = F.scaled_dot_product_attention(q, k, v, is_causal=True)   # PyTorch fused kernel
ref = layer.proj(fused.transpose(1, 2).reshape(2, 10, 64))
print(torch.allclose(layer(x), ref, atol=1e-5))                    # True

torch.triu(..., 1) marks the strictly upper triangle — the future. F.scaled_dot_product_attention computes the same thing and, on supported GPUs, dispatches to memory-efficient or FlashAttention kernels; use it in real code.

Step 5: the transformer block

A block wraps attention and a position-wise MLP in residual connections and normalization. Modern models use pre-norm, normalizing the input to each sub-layer:

x←x+MHA⁡(LN⁡(x)),x←x+MLP⁡(LN⁡(x))x \leftarrow x + \operatorname{MHA}(\operatorname{LN}(x)), \qquad x \leftarrow x + \operatorname{MLP}(\operatorname{LN}(x))

The original paper used post-norm, x←LN⁡(x+Sublayer⁡(x))x \leftarrow \operatorname{LN}(x + \operatorname{Sublayer}(x)), which needs careful learning-rate warmup; pre-norm keeps an unnormalized residual "stream" from input to output and trains stably at depth (analysed by Xiong et al., 2020). LayerNorm normalizes each token vector across its features: LN⁡(x)=γ⊙(x−μ)/σ2+ϵ+β\operatorname{LN}(x) = \gamma \odot (x - \mu)/\sqrt{\sigma^2 + \epsilon} + \beta, with μ\mu, σ2\sigma^2 the mean and variance of xx's components and γ,β\gamma, \beta learned. Many LLMs use RMSNorm, which skips the mean subtraction and β\beta.

The MLP acts on each token independently: classically W2 ϕ(W1x)W_2\, \phi(W_1 x) with hidden width dff=4dd_{ff} = 4d and GELU activation ϕ\phi. Many LLMs use SwiGLU, W2(SiLU⁡(W1x)⊙W3x)W_2\big(\operatorname{SiLU}(W_1 x) \odot W_3 x\big), with three matrices and dff≈83dd_{ff} \approx \tfrac{8}{3} d to keep the parameter count similar. Roughly: attention moves information between tokens; the MLP processes information within each token and holds much of the model's stored knowledge.

class Block(nn.Module):
    def __init__(self, d_model, n_heads, d_ff):
        super().__init__()
        self.ln1, self.ln2 = nn.LayerNorm(d_model), nn.LayerNorm(d_model)
        self.attn = CausalSelfAttention(d_model, n_heads)
        self.mlp = nn.Sequential(nn.Linear(d_model, d_ff), nn.GELU(), nn.Linear(d_ff, d_model))
    def forward(self, x):
        x = x + self.attn(self.ln1(x))     # pre-norm residual around attention
        return x + self.mlp(self.ln2(x))   # pre-norm residual around the MLP

A full decoder-only model is: embedding → LL blocks → final norm → linear projection to VV logits, trained with cross-entropy on next-token prediction.

Three families

FamilyAttentionTraining objectiveClassic exampleTypical use
Encoder-onlyBidirectional (no mask)Masked-token predictionBERT (Devlin et al., 2018)Classification, retrieval embeddings, tagging
Decoder-onlyCausalNext-token predictionGPT series (Radford et al., 2018 onward)Generation; nearly all chat LLMs
Encoder–decoderEncoder bidirectional; decoder causal plus cross-attention to encoder outputsSequence-to-sequence (T5 used span corruption)T5 (Raffel et al., 2019)Translation, summarization, speech-to-text

In cross-attention the queries come from the decoder and the keys and values from the encoder — exactly Bahdanau attention from the previous lesson, in matrix form.

Counting parameters in a decoder layer

Take d=4096d = 4096, 32 heads of size 128, standard multi-head attention without biases, a SwiGLU MLP with dff=11,008d_{ff} = 11{,}008, and two RMSNorms.

  • Attention: WQ,WK,WV,WOW_Q, W_K, W_V, W_O are each d×dd \times d: 4d2=4×16,777,216=67,108,8644d^2 = 4 \times 16{,}777{,}216 = 67{,}108{,}864.
  • MLP: three matrices of d×dffd \times d_{ff}: 3×4096×11,008=135,266,3043 \times 4096 \times 11{,}008 = 135{,}266{,}304.
  • Norms: 2d=8,1922d = 8{,}192.
  • Per layer: 202,383,360 ≈ 202M, two-thirds of it in the MLP.

Thirty-two layers give 6.4766.476B. Add an input embedding and an untied output head with V=32,000V = 32{,}000: 2Vd=262,144,0002Vd = 262{,}144{,}000, plus a final norm. Total ≈ 6.74B — the shape of the familiar 2023-era "7B" models. Rule of thumb: a block has about 12d212d^2 parameters with a 4d4d GELU MLP (4d24d^2 attention + 8d28d^2 MLP).

Cost: O(n²) attention and the KV cache

For a sequence of nn tokens, QK⊤QK^\top is an n×nn \times n matrix per head. Computing it and multiplying by VV costs O(n2d)O(n^2 d) per layer, and storing it naively costs O(n2)O(n^2) memory per head. Doubling context quadruples the attention cost, while the projections and MLP grow only linearly. At short contexts the MLP dominates; at long contexts attention does.

During generation, a decoder caches each past token's keys and values so it never recomputes them. Per token, the KV cache takes 2×layers×KV heads×head dim×bytes per element2 \times \text{layers} \times \text{KV heads} \times \text{head dim} \times \text{bytes per element}. For the model above in 16-bit: 2×32×32×128×2=524,2882 \times 32 \times 32 \times 128 \times 2 = 524{,}288 bytes = 512 KiB per token, or 16 GiB at a 32k-token context. See kv-cache-and-context-cost and the AI Index methodology for how this feeds memory estimates.

Efficient attention variants

Multi-query attention (MQA) (Shazeer, 2019) shares a single key head and value head across all query heads. Grouped-query attention (GQA) (Ainslie et al., 2023) is the middle ground: hh query heads share gg KV heads in groups. With 32 query heads and 8 KV heads, the KV cache above drops 4× to 128 KiB per token (4 GiB at 32k), and WK,WVW_K, W_V shrink from d×dd \times d to d×1024d \times 1024, cutting attention parameters from 67.1M to 41.9M per layer. Decoding is usually limited by memory bandwidth, so a smaller cache directly means faster generation and larger batches, with little quality loss for GQA. GQA and other KV-compression schemes are now common in open-weight LLMs; check a model's config for num_key_value_heads.

FlashAttention (Dao et al., 2022) does not change the math — it computes exact attention. It changes where data lives. GPUs have large but comparatively slow high-bandwidth memory (HBM) and tiny, fast on-chip SRAM. Naive attention writes the full n×nn \times n score matrix to HBM, reads it back for the softmax, and again for the multiply by VV; for long sequences this memory traffic, not arithmetic, is the bottleneck. FlashAttention splits QQ, KK, VV into tiles that fit in SRAM and computes the softmax incrementally with the online softmax trick: for each new block of scores ss it keeps a running maximum mm, normalizer ℓ\ell and output accumulator oo, updating

m′=max⁡(m,max⁡jsj),ℓ′=em−m′ℓ+∑jesj−m′,o′=em−m′o+∑jesj−m′vjm' = \max(m, \max_j s_j), \quad \ell' = e^{m - m'}\ell + \sum_j e^{s_j - m'}, \quad o' = e^{m - m'} o + \sum_j e^{s_j - m'} v_j

and dividing oo by ℓ\ell at the end. The n×nn \times n matrix is never materialized, memory becomes linear in nn, and the backward pass recomputes attention tiles instead of storing them. The result is the same output, much less memory, and substantially faster wall-clock time. Later versions improved GPU work partitioning further.

Common pitfalls

  • Mask errors: an off-by-one causal mask (masking the diagonal, or not masking the future) gives a model that looks brilliant in training and fails at generation because it was peeking.
  • Padding masks forgotten in batched encoders, or combined incorrectly with causal masks; also left- versus right-padding mismatches in batched generation.
  • Softmax in low precision: compute attention softmax and LayerNorm statistics in float32 or with fused kernels; fp16 overflow produces NaNs.
  • Missing the dk\sqrt{d_k} scale when hand-writing attention, which saturates the softmax.
  • Tokenizer mismatch: using a different tokenizer, or different special tokens or chat template, from the one the model was trained with.
  • Assuming linear cost in context: prefill attention is quadratic, and KV-cache memory grows linearly per sequence and per concurrent user.

In practice

  • Use maintained implementations (PyTorch scaled_dot_product_attention, Hugging Face transformers, vLLM and similar serving engines) rather than hand-rolled attention in production; hand-roll only to learn and test.
  • Pick architecture by task: encoders for embeddings and classification, decoders for generation, encoder–decoders where input and output are clearly separate (speech recognition, translation).
  • Budget memory from parameters plus KV cache, and prefer GQA models for long-context or high-concurrency serving.
  • When fine-tuning, keep the original tokenizer and template; when extending context, use the model's documented RoPE scaling rather than inventing your own.
  • The next lesson, beyond-transformers, covers architectures that attack the O(n2)O(n^2) cost.

Key takeaways

  • Attention computes softmax(QK^T / sqrt(d_k)) V: each token takes a weighted average of value vectors, weighted by query-key similarity.
  • A causal mask of minus infinity above the diagonal lets a decoder train on every position in parallel without seeing the future.
  • Modern blocks are pre-norm: x + Attn(Norm(x)) then x + MLP(Norm(x)), with the MLP holding about two-thirds of the parameters.
  • RoPE rotates queries and keys so attention scores depend only on relative position.
  • Attention cost grows with the square of sequence length, and the KV cache grows linearly per token, layer and KV head.
  • GQA shrinks the KV cache by sharing key-value heads, while FlashAttention computes exact attention with far less memory traffic by tiling and online softmax.

Terms in this lesson

Byte-pair encoding (BPE)
A tokenization algorithm that builds a vocabulary by repeatedly merging the most frequent adjacent pair of symbols in a training corpus.
Query, key, value
Three learned linear projections of each token: queries are compared with keys to produce attention weights that are applied to values.
Scaled dot-product attention
The operation softmax(QK^T / sqrt(d_k) + M) V, where M is an optional mask.
Causal mask
A mask that sets attention scores from each position to later positions to minus infinity so a token can attend only to itself and earlier tokens.
Multi-head attention
Running several attention operations in parallel on lower-dimensional projections and concatenating their outputs through an output projection.
Rotary position embedding (RoPE)
A positional scheme that rotates pairs of query and key dimensions by position-dependent angles so their dot product depends on relative position.
Pre-norm
A transformer block layout that applies normalization to the input of each sub-layer inside the residual branch rather than after the residual addition.
KV cache
Stored keys and values of previously processed tokens, reused during autoregressive generation to avoid recomputation.
Grouped-query attention (GQA)
An attention variant in which groups of query heads share a single key and value head, reducing KV-cache size.
FlashAttention
An exact attention algorithm that tiles the computation in fast on-chip memory and uses an online softmax to avoid writing the full attention matrix to GPU main memory.

Check your understanding

  1. 1.

    Why are attention scores divided by sqrt(d_k)?

  2. 2.

    A model has 40 layers, 8 KV heads, head dimension 128, and stores its KV cache in 16-bit. How large is the KV cache per token?

  3. 3.

    You need sentence embeddings for semantic search over a document collection and will not generate text. Which family is the most natural starting point?

  4. 4.

    What does FlashAttention primarily optimize?

  5. 5.

    In the decoder-layer example (d = 4096, SwiGLU with d_ff = 11,008, standard multi-head attention), roughly what fraction of the layer's parameters are in the MLP?

Exercises

Work each problem before opening the solution.

  1. Exercise 1Core

    Two tokens have q1=(1,1)q_1 = (1, 1), q2=(0,2)q_2 = (0, 2), k1=(1,0)k_1 = (1, 0), k2=(0,1)k_2 = (0, 1), v1=(1,0)v_1 = (1, 0), v2=(0,1)v_2 = (0, 1), with dk=2d_k = 2 and a causal mask. Compute both attention outputs, showing the scaled scores and softmax weights.

    Show solution

    Token 1 can attend only to position 1 (position 2 is masked to −∞-\infty). The softmax over a single entry is 1, so the output is v1=(1,0)v_1 = (1, 0).

    Token 2 attends to both. Raw scores: q2⋅k1=0q_2 \cdot k_1 = 0, q2⋅k2=2q_2 \cdot k_2 = 2. Scaled by 1/21/\sqrt{2}: (0,1.4142)(0, 1.4142). Softmax: e0=1e^0 = 1, e1.4142=4.1133e^{1.4142} = 4.1133, sum 5.11335.1133, weights (0.1956,0.8044)(0.1956, 0.8044). Output: 0.1956(1,0)+0.8044(0,1)=(0.1956,0.8044)0.1956(1, 0) + 0.8044(0, 1) = (0.1956, 0.8044).

    Note that token 1's query was never used against k2k_2; that is the mask at work.

  2. Exercise 2Core

    A decoder-only model has d=2048d = 2048, 24 layers, 16 query heads of size 128, GQA with 4 KV heads, a SwiGLU MLP with dff=5632d_{ff} = 5632, two RMSNorms per layer, a final RMSNorm, no biases, and tied input/output embeddings with V=50,000V = 50{,}000. (a) Count total parameters. (b) Compute the 16-bit KV cache per token and for an 8,192-token context.

    Show solution

    (a) Per layer:

    • WQW_Q: 2048×2048=4,194,3042048 \times 2048 = 4{,}194{,}304; WOW_O: same, 4,194,3044{,}194{,}304.
    • WKW_K, WVW_V: 2048×(4×128)=2048×512=1,048,5762048 \times (4 \times 128) = 2048 \times 512 = 1{,}048{,}576 each, 2,097,1522{,}097{,}152 together.
    • Attention total: 10,485,76010{,}485{,}760.
    • MLP: 3×2048×5632=34,603,0083 \times 2048 \times 5632 = 34{,}603{,}008.
    • Norms: 2×2048=4,0962 \times 2048 = 4{,}096.
    • Layer total: 45,092,86445{,}092{,}864.

    24 layers: 1,082,228,7361{,}082{,}228{,}736. Tied embedding: 50,000×2048=102,400,00050{,}000 \times 2048 = 102{,}400{,}000 (counted once). Final norm: 2,0482{,}048. Total: 1,184,630,784 ≈ 1.18B parameters.

    (b) KV cache per token =2×24×4×128×2=49,152= 2 \times 24 \times 4 \times 128 \times 2 = 49{,}152 bytes =48= 48 KiB. For 8,192 tokens: 48 KiB×8192=38448 \text{ KiB} \times 8192 = 384 MiB per sequence. Without GQA (16 KV heads) it would be 4x larger, 1.5 GiB.

  3. Exercise 3Challenge

    Implement RoPE in PyTorch for a tensor of shape (T, d) given integer positions, and verify numerically that the attention score between a rotated query at position m and a rotated key at position n depends only on n - m.

    Show solution
    import torch
    
    def rope(x, pos, base=10000.0):
        # x: (T, d) with d even; pos: (T,) integer positions
        d = x.shape[-1]
        theta = base ** (-torch.arange(0, d, 2, dtype=torch.float32) / d)  # theta_i = base^(-2i/d)
        ang = pos[:, None].float() * theta[None, :]                        # (T, d/2)
        cos, sin = ang.cos(), ang.sin()
        x1, x2 = x[..., 0::2], x[..., 1::2]                                # the pairs
        out = torch.empty_like(x)
        out[..., 0::2] = x1 * cos - x2 * sin
        out[..., 1::2] = x1 * sin + x2 * cos
        return out
    
    torch.manual_seed(0)
    q, k = torch.randn(1, 64), torch.randn(1, 64)
    def score(m, n):
        return (rope(q, torch.tensor([m])) * rope(k, torch.tensor([n]))).sum().item()
    
    print(score(3, 7), score(103, 107), score(0, 4))   # equal up to float error
    print(score(3, 8))                                # different offset, different score
    print(abs(score(3, 7) - score(103, 107)) < 1e-3)  # True
    

    Each pair is rotated by angle mθim\theta_i; the dot product of two vectors rotated by mθim\theta_i and nθin\theta_i equals the dot product of the originals with a single rotation by (n−m)θi(n-m)\theta_i, so offsets of 4 give the same score whatever the absolute positions. Small differences (around 10−510^{-5}) come from float32 rounding of larger angles. This interleaved-pair layout is the one in the RoFormer paper; some libraries instead pair dimension ii with i+d/2i + d/2, which is equivalent up to a fixed permutation of dimensions but not interchangeable with weights trained the other way.

  4. Exercise 4Challenge

    Implement FlashAttention's online-softmax idea for a single query: process the keys and values in blocks of 128, keeping only a running maximum, normalizer and output accumulator, and verify the result equals standard softmax attention. Explain the memory difference.

    Show solution
    import torch
    torch.manual_seed(0)
    n, d, block = 1000, 64, 128
    q, K, V = torch.randn(d), torch.randn(n, d), torch.randn(n, d)
    scale = d ** -0.5
    
    ref = torch.softmax(K @ q * scale, dim=0) @ V        # materializes all n scores
    
    m = torch.tensor(float("-inf")); l = torch.tensor(0.0); o = torch.zeros(d)
    for start in range(0, n, block):
        s = K[start:start + block] @ q * scale             # this block's scores only
        m_new = torch.maximum(m, s.max())
        corr = torch.exp(m - m_new)                         # rescale old sums to new max
        p = torch.exp(s - m_new)
        l = corr * l + p.sum()
        o = corr * o + p @ V[start:start + block]
        m = m_new
    out = o / l
    print(torch.allclose(out, ref, atol=1e-5))            # True
    

    The correction factor em−m′e^{m - m'} rescales everything accumulated so far whenever a larger score appears, so the final o/ℓo/\ell equals ∑jesj−m∗vj/∑jesj−m∗\sum_j e^{s_j - m^*} v_j / \sum_j e^{s_j - m^*} with m∗m^* the global maximum, which is exactly the softmax-weighted average. On the first block m=−∞m = -\infty makes corr zero, which is harmless because ℓ\ell and oo start at zero.

    Memory: the reference keeps nn scores per query, n2n^2 for all queries. The blocked version keeps one block of scores plus O(d)O(d) accumulators per query. On a GPU, FlashAttention does this for tiles of queries in SRAM, so the n×nn \times n matrix never touches HBM.

Further reading

Go further

Sources

Fact-checked and last reviewed 2026-11-05

Stay in the loop
Prometheus OS™ · Native AI OS · Built for the future © 2025 - 2026