ml-models / Model architectures / The Transformer core

The Transformer

The 2017 model behind almost every large model since. It reads a whole sentence at once, lets every word look at every other word (attention), and stacks that idea six times. An encoder turns the input into a grid of context-aware vectors; a decoder writes the output one token at a time, reading that grid as it goes. This page walks through every block, with the shapes, the parameter counts, the numbers worked by hand, and the exact lines of nn.Transformer in PyTorch v2.15.0 that implement it.

On this page
  1. Problem
  2. Intuition
  3. Follow one sentence through it
  4. Mechanics
  5. The big picture
  6. Embeddings and positions
  7. Attention, by hand
  8. Multi-head attention
  9. The feed-forward block
  10. Residuals and LayerNorm
  11. The encoder stack
  12. The decoder stack
  13. Masks: who may look at whom
  14. The output head
  15. Worked example
  16. Worked example: four tokens, two dimensions
  17. Counting parameters
  18. Implementation
  19. In PyTorch
  20. Tradeoffs
  21. What it costs
  22. Training versus inference
  23. Pitfalls
  24. Check yourself
  25. Connections
  26. Read first
  27. Read next
  28. References

The whole idea in one sentence: turn tokens into vectors, then repeat "let every position gather information from the others, then think about it on its own", with a shortcut around each step so nothing gets lost. Everything else on this page is detail on that sentence.

Problem

Before 2017, the best systems for translation and other sequence tasks read a sentence one word at a time, carrying a running summary from word to word (see neurons and layers for the building block). That has two costs. Each step waits for the previous one, so the work cannot be spread across many chips at once; and a fact from the start of a long sentence must survive many re-summarizations to reach the end. The Transformer asks whether a model can drop the one-at-a-time reading and let every word look directly at every other word.

Intuition

Picture a room where every word is a person holding a note. In one round of attention, each person asks a question, glances at everyone's note, decides whose matter most, and copies a weighted blend of them. Then each person thinks privately about what they gathered (the feed-forward step). Do a round, then another: after six, each word's note carries the sentence's context. Because everyone looks at everyone in the same round, all the words can be processed at once, and any two words are one step apart however far apart they sit.

Follow one sentence through it

Translating "the cat sat down" into French, with a batch of one and the base sizes. Drag the slider (or read straight down with JavaScript off). Shapes are written batch-first, which is what you get with batch_first=True.

Tokenize. A tokenizer maps text to integer IDs from a fixed vocabulary (the paper used about 37,000 shared byte-pair tokens for English-German). "the cat sat down" becomes 4 IDs. Shape: [1, 4].

Embed and add positions. Each ID picks a row of a [vocab, 512] table; the paper multiplies by √512 ≈ 22.6 and adds a sinusoidal position vector so the model knows word order. Shape: [1, 4, 512].

Encode. Six encoder layers each run self-attention (every word looks at all 4 words) and a feed-forward block. The shape never changes: [1, 4, 512] in, [1, 4, 512] out. The result is the memory: one context-aware vector per source word.

Start decoding. The decoder gets the output so far, starting with only a begin-of-sequence token: [1, 1] IDs, embedded to [1, 1, 512].

Decode. Six decoder layers: masked self-attention over the French so far, cross-attention into the 4 memory vectors, feed-forward. Output [1, 1, 512].

Predict. A linear layer turns the last vector into one score per vocabulary entry, [1, 1, vocab]; softmax makes them probabilities; we pick "le".

Repeat. Append "le" and decode again with [1, 2] IDs, then [1, 3]… until an end-of-sequence token. The encoder ran once; only the decoder loops. That is autoregressive generation.

Mechanics

The pieces, in the order a token meets them. Each one has its own shapes and parameters; the tables give the PyTorch classes that implement them.

The big picture

Click any block to jump to its section. Solid boxes are what nn.Transformer builds; dashed boxes are the parts you add yourself, because PyTorch's module starts at vectors and stops at vectors. The paper's base model uses exactly these defaults: d_model=512, 8 heads, 6 encoder and 6 decoder layers, a 2048-wide feed-forward block and dropout 0.1 (constructor defaults).

The encoder-decoder Transformer, block by block Left column, the encoder: source tokens are embedded and given positions, then pass through six identical layers, each a self-attention sublayer followed by add-and-norm and a feed-forward sublayer followed by add-and-norm, and finally a LayerNorm. Its output, the memory, feeds the cross-attention sublayer of every decoder layer. Right column, the decoder: target tokens shifted right are embedded and given positions, then pass through six layers of masked self-attention, cross-attention over the memory, and feed-forward, each followed by add-and-norm, then a final LayerNorm, a linear layer to vocabulary size and a softmax giving next-token probabilities. Curved residual arrows skip around every sublayer. Embeddings, the linear head and softmax are not part of nn.Transformer. ENCODER LAYER × 6 DECODER LAYER × 6 memory next-token probabilities Source tokens Embedding + positions Self-attention 8 heads, sees every token Add & LayerNorm Feed-forward 512 → 2048 → 512 Add & LayerNorm Final LayerNorm Target tokens, shifted right Embedding + positions Masked self-attention can't see the future Add & LayerNorm Cross-attention queries here, keys/values from memory Add & LayerNorm Feed-forward 512 → 2048 → 512 Add & LayerNorm Final LayerNorm Linear to vocabulary Softmax

Mixing and thinking Tokens in Residual shortcut You add this

Embeddings and positions

An embedding is just a learnable lookup table: weight[token_id]. Attention, the next step, treats its input as an unordered set: swap two words and every output swaps with them, nothing else changes. So the model must be told the order. The paper adds a fixed pattern of sines and cosines of different frequencies to each position:

PE(pos, 2i) = sin(pos / 100002i/d)    PE(pos, 2i+1) = cos(pos / 100002i/d)

Low dimensions wiggle fast, high dimensions slowly, like the hands of a clock, so every position gets a unique fingerprint and nearby positions get similar ones. The paper found learned position embeddings worked about as well. Modern LLMs mostly use rotary embeddings (RoPE) instead; the Positional encoding page covers all three. nn.Transformer includes neither the embedding nor any positional encoding.

ComponentShape/ParamsMeaningSource
Token embedding weight [vocab, d_model]; out [batch, seq, d_model] Row i is the learned vector for token i. The forward pass is a plain index lookup through F.embedding. Embedding.weight, forward
Scale by √d_model no parameters The paper multiplies embeddings by √d_model before adding positions (section 3.4), so the two are of comparable size. You do this yourself. paper, §3.4
Sinusoidal positions [seq, d_model], fixed, 0 parameters Added (not concatenated) to the embeddings. Not in PyTorch; the snippet below builds it. paper, §3.5

Attention, by hand

Attention answers one question for every position: "which other positions should I read from, and how much?" Each position makes three vectors from its own embedding with three learned matrices:

  • Query q: what am I looking for?
  • Key k: what do I contain, as an address?
  • Value v: what do I hand over if you pick me?

Score every query against every key with a dot product, divide by √dk so the numbers don't grow with the width, softmax each row into weights that sum to 1, and take that weighted average of the values:

Attention(Q, K, V) = softmax(Q·Kᵀ / √dk) · V

That's the whole mechanism. PyTorch's reference version of it (the docstring of F.scaled_dot_product_attention) is a dozen lines: a 1/√d scale factor, an optional -inf mask, q @ kᵀ, softmax, then @ value.

Scaled dot-product attention as a data flow The input x of shape seq by d_model is multiplied by three weight matrices to give Q, K and V. Q times K transposed gives a seq by seq score matrix, which is divided by the square root of d_k, optionally masked with minus infinity, and softmaxed row by row into attention weights. The weights times V give the output, seq by d_k. x [seq, d_model] Q = x·WQ [seq, d_k] K = x·WK [seq, d_k] V = x·WV [seq, d_k] Q·Kᵀ / √dk [seq, seq] scores + mask −∞ where blocked softmax each row sums to 1 weights · V weighted average output [seq, d_k]

Multi-head attention

One attention pattern per layer is limiting: a word might need its subject and the word right before it. So the layer runs 8 attentions in parallel, each on its own 64-wide slice (512 / 8), then concatenates the 8 outputs back to 512 and mixes them with one more matrix. Total cost is about the same as one 512-wide head; you just get 8 different patterns. PyTorch asserts that the width divides evenly (head_dim = embed_dim // num_heads).

ComponentShape/ParamsMeaningSource
in_proj_weight [3·512, 512] + bias [1536]: 787,968 WQ, WK, WV stacked in one matrix, split again with chunk(3) when query, key and value are the same tensor. MultiheadAttention.__init__, _in_projection_packed
Split into heads [seq, batch, 512] → [batch·8, seq, 64] A reshape, not a copy of weights: each head gets its own 64 columns. multi_head_attention_forward
Per-head attention scores [batch·8, seq, seq] The formula above, run on all heads at once by the fused kernel. SDPA path; manual path when weights are returned
out_proj Linear(512, 512): 262,656 WO: mixes the 8 concatenated head outputs back into one vector. out_proj
Whole module 4·d² + 4·d = 1,050,624 Independent of the number of heads. nn.MultiheadAttention

The feed-forward block

Attention moves information between positions. The feed-forward block then works on each position alone: widen to 2048, apply ReLU, narrow back to 512. Same weights at every position. It holds two thirds of each encoder layer's parameters and is where much of a model's stored knowledge is thought to live. In PyTorch it is linear2(dropout(activation(linear1(x)))); activation is ReLU by default, or GELU if you pass "gelu" (_get_activation_fn). Modern LLMs use a gated variant, SwiGLU.

ComponentShape/ParamsMeaningSource
linear1 Linear(512, 2048): 1,050,624 Expand to 4× width so the non-linearity has room to work. TransformerEncoderLayer.__init__
activation ReLU, 0 parameters max(0, x), element-wise. F.relu, GELU
linear2 Linear(2048, 512): 1,049,088 Project back to the residual width. Linear.weight, bias

Residuals and LayerNorm

Every sublayer is wrapped the same way: keep a copy of the input, add the sublayer's output to it (the residual, from ResNet), and normalize. The residual means each layer only learns a small edit to a running vector, the residual stream, and gradients get a straight path back to the first layer. LayerNorm rescales each position's 512 numbers to mean 0 and variance 1, then applies a learned scale and shift.

Where the norm goes turned out to matter. The paper normalizes after the add (post-norm, PyTorch's default). Almost every model since normalizes before the sublayer (pre-norm), which trains stably without a careful warmup. PyTorch supports both with norm_first, in twelve lines that cite the pre-norm paper:

Post-norm sublayer x goes through the sublayer, the result is added to x, and the sum is passed through LayerNorm. The norm sits on the residual path, so every layer's output is renormalized. x sublayer + LayerNorm x = norm(x + sublayer(x))
Pre-norm sublayer x is normalized, passed through the sublayer, and the result is added back to the untouched x. The residual path has no norm on it, so the stream flows straight from the first layer to the last. x LayerNorm sublayer + x = x + sublayer(norm(x)); add one final norm after the last layer
ComponentShape/ParamsMeaningSource
LayerNorm weight + bias [512]: 1,024; eps 1e-5 (x − mean) / √(var + eps) · weight + bias over the last dimension. nn.LayerNorm
Dropout on each sublayer p = 0.1, 0 parameters Applied to the sublayer output before the add (dropout1, dropout2). _sa_block, _ff_block
norm_first bool, default False Switches the layer between post-norm and pre-norm. encoder, decoder

The encoder stack

An encoder layer is self-attention then feed-forward, each wrapped as above. TransformerEncoder deep-copies one layer N times (_get_clones, so the copies start identical and train apart), runs them in a plain loop, and applies the optional final norm. At inference time, under the right conditions, the layer skips the Python code entirely and calls one fused kernel (torch._transformer_encoder_layer_fwd).

ComponentShape/ParamsMeaningSource
TransformerEncoderLayer 3,152,384 parameters self_attn, linear1, linear2, norm1, norm2, three dropouts. class, submodules
TransformerEncoder 6 layers + final norm: 18,915,328 [batch, src_len, 512] in and out: the memory. class
src_key_padding_mask bool [batch, src_len] True marks padding tokens so nothing attends to them. Transformer.forward

The decoder stack

A decoder layer has three sublayers. Masked self-attention lets each output position read the earlier outputs only. Cross-attention is the bridge: its queries come from the decoder, but its keys and values come from the encoder's memory, so each French word can look up the English words it needs (_mha_block calls multihead_attn(x, mem, mem)). Then the usual feed-forward.

ComponentShape/ParamsMeaningSource
self_attn MHA, 1,050,624 Over the target so far, with the causal tgt_mask. _sa_block
multihead_attn MHA, 1,050,624; scores [tgt_len, src_len] Cross-attention: query = decoder state, key = value = memory. __init__, _mha_block
linear1, linear2, norm1-3 2,099,712 + 3,072 The feed-forward block and one norm per sublayer. __init__
TransformerDecoder 6 layers + final norm: 25,225,216 Every layer reads the same memory. class

Masks: who may look at whom

A mask is added to the scores before softmax. -inf becomes a weight of exactly 0, so a blocked key contributes nothing. The decoder needs a causal mask during training: it sees the whole French sentence at once, and without the mask position 2 would simply copy the answer from position 3. Transformer.generate_square_subsequent_mask(n) builds it as torch.triu(torch.full((n, n), -inf), diagonal=1) (source):

tensor([[0., -inf, -inf, -inf],
        [0.,   0., -inf, -inf],
        [0.,   0.,   0., -inf],
        [0.,   0.,   0.,   0.]])

Watch out for the boolean form: in PyTorch a True means blocked (docs; converted to -inf by _canonical_mask). That's the opposite of F.scaled_dot_product_attention's attn_mask, where True means "may attend". Padding masks (*_key_padding_mask) work the same way, one row per batch item.

The output head

The decoder's final vectors become predictions with one Linear(512, vocab) and a softmax. The paper shares one matrix between the source embedding, target embedding and this output layer (weight tying), which saves parameters and helps. None of it is inside nn.Transformer: its forward returns the decoder output, [batch, tgt_len, d_model]. You add the head, and train with cross-entropy on the logits (softmax is folded into the loss).

Worked example

Worked example: four tokens, two dimensions

Real models use dk = 64; here dk = 2 so you can check it with a pencil. The query, key and value rows below are picked by hand, standing in for what the learned projections would produce:

Tokenqkv
the [0, 1] [0, 1] [1, 0]
cat [1, 1] [2, 0] [0, 1]
sat [2, 0] [1, 1] [1, 1]
down [1, 2] [0, 2] [0, 0]

Take sat, with q = [2, 0]. Its dot products with the four keys are 0, 4, 2 and 0. Divide by √2: [0.00, 2.83, 1.41, 0.00]. Softmax: e2.83 ≈ 16.9 dominates, giving weights [0.04, 0.73, 0.18, 0.04]. So sat reads 73% from cat: in this toy, the verb found its subject. Its output is the weighted average of the values, [0.22, 0.91].

Every token sees every token (encoder). Rows are queries, columns are keys; each row sums to 1.

thecatsatdown
the 0.22 0.11 0.22 0.45
cat 0.14 0.29 0.29 0.29
sat 0.04 0.73 0.18 0.04
down 0.12 0.12 0.25 0.51

Causal mask: no peeking ahead (decoder). Rows are queries, columns are keys; each row sums to 1.

thecatsatdown
the 1.00 — — —
cat 0.33 0.67 — —
sat 0.05 0.77 0.19 —
down 0.12 0.12 0.25 0.51

With JavaScript on, the table above is live: toggle the causal mask, turn off the √d scaling, or sharpen and flatten the softmax with a temperature, and watch each row re-weight. Try a temperature of 0.2: every row collapses onto its single best key, which is why the scale factor matters. Without it, dot products in 64 dimensions get large, softmax saturates, and gradients vanish (paper, §3.2.1).

Counting parameters

Worked example: why nn.Transformer() has 44,140,544 parameters

With d = 512 and ff = 2048:

  • Multi-head attention: 4·d² + 4·d = 1,048,576 + 2,048 = 1,050,624
  • Feed-forward: 2·d·ff + ff + d = 2,097,152 + 2,048 + 512 = 2,099,712
  • LayerNorm: 2·d = 1,024
  • Encoder layer: 1 attention + 1 FFN + 2 norms = 3,152,384
  • Decoder layer: 2 attentions + 1 FFN + 3 norms = 4,204,032
  • Encoder: 6 layers + final norm = 18,915,328; decoder: 6 layers + final norm = 25,225,216
  • Total: 44,140,544, about 44M.

The paper reports about 65M for its base model. The difference is the embeddings: a shared vocabulary of about 37,000 tokens × 512 adds about 19M, and with weight tying that one matrix also serves as the output layer. Notice that attention is only a third of each encoder layer; the feed-forward block is two thirds.

Configurationd_model · heads · layers · ffPer encoder layernn.Transformer total
nn.Transformer() defaults 512 · 8 · 6+6 · 2048 3,152,384 44,140,544
Paper, big 1024 · 16 · 6+6 · 4096 12,596,224 176,361,472
Tiny (toy) 64 · 4 · 2+2 · 256 49,984 233,728

Implementation

In PyTorch

The library version, with the pieces nn.Transformer leaves out:

import math
import torch
from torch import nn

def sinusoidal(n, d):
    pos = torch.arange(n).unsqueeze(1)
    angle = pos / 10000 ** (torch.arange(0, d, 2) / d)
    pe = torch.zeros(n, d)
    pe[:, 0::2], pe[:, 1::2] = angle.sin(), angle.cos()
    return pe

d_model, vocab = 512, 37000
embed = nn.Embedding(vocab, d_model)
model = nn.Transformer(d_model=d_model, nhead=8, batch_first=True)
head = nn.Linear(d_model, vocab, bias=False)
head.weight = embed.weight                      # weight tying

def encode(ids):                                # [batch, seq] -> [batch, seq, d_model]
    return embed(ids) * math.sqrt(d_model) + sinusoidal(ids.size(1), d_model)

src = torch.randint(vocab, (2, 10))
tgt = torch.randint(vocab, (2, 7))
mask = nn.Transformer.generate_square_subsequent_mask(7)
out = model(encode(src), encode(tgt), tgt_mask=mask, tgt_is_causal=True)
logits = head(out)                              # [2, 7, vocab]

And attention plus one encoder layer from scratch, matching PyTorch's post-norm layer parameter for parameter:

def attention(q, k, v, causal=False):
    scores = q @ k.transpose(-2, -1) / math.sqrt(q.size(-1))
    if causal:
        n = scores.size(-1)
        blocked = torch.ones(n, n, dtype=torch.bool).triu(1)
        scores = scores.masked_fill(blocked, float("-inf"))
    return scores.softmax(dim=-1) @ v

class EncoderLayer(nn.Module):
    def __init__(self, d=512, heads=8, ff=2048, p=0.1):
        super().__init__()
        self.attn = nn.MultiheadAttention(d, heads, dropout=p, batch_first=True)
        self.ff = nn.Sequential(nn.Linear(d, ff), nn.ReLU(), nn.Dropout(p), nn.Linear(ff, d))
        self.norm1, self.norm2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.drop1, self.drop2 = nn.Dropout(p), nn.Dropout(p)

    def forward(self, x, mask=None):
        a, _ = self.attn(x, x, x, attn_mask=mask, need_weights=False)
        x = self.norm1(x + self.drop1(a))
        return self.norm2(x + self.drop2(self.ff(x)))

sum(p.numel() for p in EncoderLayer().parameters())   # 3,152,384

Tradeoffs

What it costs

Attention compares every position with every other, so its score matrix is seq × seq per head: doubling the context quadruples that work and memory, the O(n²·d) term. The feed-forward block is O(n·d²): linear in length. For short sequences the feed-forward block dominates; for long ones attention does. That trade-off is why the paper could drop recurrence (every position is computed in parallel, with a path of length 1 between any two words), and why long-context models fight so hard over attention. Optimizing it without changing the math is what fused kernels such as FlashAttention do, and what F.scaled_dot_product_attention dispatches to.

Training versus inference

TrainingInference (generation)
Decoder inputThe whole correct target, shifted right by one ("teacher forcing")Its own previous outputs, one new token per step
Passes per sentenceOne: all positions predicted in parallelOne decoder pass per output token; the encoder runs once
Causal maskEssential: stops position i reading the answer at i+1Still applied; caching past keys and values (the KV cache) avoids recomputing them
DropoutOn (p = 0.1)Off: call model.eval()
Paper recipeAdam (β₂ = 0.98), 4,000 warmup steps then inverse-sqrt decay, label smoothing 0.1Beam search, beam size 4

Pitfalls

  • batch_first defaults to False. Without it, PyTorch reads [seq, batch, d]; a [batch, seq, d] tensor runs without error and silently attends across the batch (defaults).
  • Boolean masks are inverted between APIs. True blocks in nn.MultiheadAttention and nn.Transformer, but allows in F.scaled_dot_product_attention.
  • No positions, no order. Forget the positional encoding and the encoder treats the input as a bag of words.
  • need_weights=True is the default for nn.MultiheadAttention, which takes the slower path and averages weights over heads (signature). Pass need_weights=False when you only need the output; the transformer layers already do.
  • Post-norm needs warmup. With the default norm_first=False, a large learning rate early on diverges; use warmup or norm_first=True.
  • Don't forget model.eval() at inference, or dropout stays on.

Check yourself

Why divide the scores by √dk?

If q and k have unit-variance entries, their dot product has variance dk. Dividing by √dk brings it back to 1, so softmax doesn't saturate into a one-hot with near-zero gradients.

Does adding more heads add parameters?

No. The heads split d_model between them; nn.MultiheadAttention has 4·d² + 4·d parameters for any number of heads that divides d.

Which sublayer lets the decoder see the source sentence?

Cross-attention (multihead_attn): queries from the decoder, keys and values from the encoder's memory.

If you drop the encoder and the cross-attention, what do you get?

A decoder-only model: masked self-attention plus feed-forward, trained to predict the next token. That's GPT, and the next family of pages.

Connections

References

  • Vaswani, Shazeer, Parmar, Uszkoreit, Jones, Gomez, Kaiser and Polosukhin. Attention Is All You Need. 2017. arXiv:1706.03762
  • Ba, Kiros and Hinton. Layer Normalization. 2016. arXiv:1607.06450
  • He, Zhang, Ren and Sun. Deep Residual Learning for Image Recognition. 2015. arXiv:1512.03385
  • Xiong et al. On Layer Normalization in the Transformer Architecture. 2020. arXiv:2002.04745
  • Press and Wolf. Using the Output Embedding to Improve Language Models. 2016. arXiv:1608.05859
  • Bahdanau, Cho and Bengio. Neural Machine Translation by Jointly Learning to Align and Translate. 2014. arXiv:1409.0473
  • Dao, Fu, Ermon, Rudra and Ré. FlashAttention. 2022. arXiv:2205.14135