ml-models / Language models / Decoder-only LLMs core

Decoder-only LLMs

GPT-2, GPT-3, Llama and most of today's chat models are one idea repeated: a stack of Transformer blocks in which every token may look only at the tokens before it, trained to predict the next token. This page builds that stack from the pieces on the transformer page, counts every parameter of GPT-2 small (124,439,808), shows why training is parallel while generation is a loop, and builds one in PyTorch v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Embeddings: tokens and positions
  5. Causal self-attention
  6. The block, pre-norm
  7. The LM head and weight tying
  8. Training versus generation
  9. Worked example
  10. Counting GPT-2 small, parameter by parameter
  11. Teacher forcing and perplexity on a tiny trained model
  12. Implementation
  13. Tradeoffs
  14. Check yourself
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

We want a model that writes text. Text is a sequence of tokens, and the most general thing a model can do with a sequence is predict what comes next given everything so far: P(tokent | token1, …, tokent−1). Learn that well and you can continue any prompt, answer questions phrased as prompts, translate, summarize and write code, because all of those are "text that continues this text".

Two requirements follow. The model must never use the future when predicting a token (otherwise it would just copy the answer), and it must be cheap to train on trillions of tokens, which means scoring every position of a long sequence in one pass instead of one position at a time. The decoder-only Transformer meets both with a single trick, the causal mask.

Intuition

Read a sentence with the next words covered by a card, sliding the card right one word at a time. At each position you guess the covered word. A decoder-only model does exactly that, but with every card position at once: it sees the whole sequence, yet each position's computation is blocked from looking at anything to its right. One forward pass therefore gives T separate predictions for a sequence of T tokens, and the loss is the average of T cross-entropies (see the training loop).

A decoder-only language model, bottom to top Token ids enter at the bottom. A token embedding table and a learned position table are added. The sum passes through 12 identical blocks; each block applies LayerNorm, causal self-attention and a residual add, then LayerNorm, a feed-forward network and a second residual add. A final LayerNorm follows the last block. A linear head, whose weights are shared with the token table, turns each position's vector into one score per vocabulary entry. The scores at position t are the prediction for token t plus one. token ids [B, T] token table [50257, 768] position table [1024, 768] x = token + position [B, T, 768] × 12 blocks (pre-norm) LayerNorm → causal attention x = x + attention(…) LayerNorm → feed-forward x = x + ff(…) final LayerNorm LM head (tied) → logits [B, T, 50257]

The stack is the Transformer's decoder with the cross-attention half removed: there is no encoder to attend to, so each block has just two sublayers.

Mechanics

Embeddings: tokens and positions

Attention treats its input as an unordered set, so order has to be put in by hand. GPT-2 does it the simplest way: a second lookup table with one learned vector for each position 0 … 1023, added to the token vector. Both tables are nn.Embedding (a row lookup, lines 188-197); by default its weights are drawn from a standard normal (lines 179-181), so GPT-style code overrides that with a small standard deviation such as 0.02. The context length is a hard limit: a learned table has no row for position 1024. See embeddings for what the vectors come to mean.

Causal self-attention

Inside each block, every position makes a query, key and value, scores its query against the keys and mixes the values (see attention). The causal mask adds −∞ to every score where the key's position is later than the query's, so softmax gives those keys weight exactly 0. For four tokens the allowed pattern is a lower triangle:

The causal mask for four tokens A four by four grid with queries as rows and keys as columns. Cells on and below the diagonal are allowed and shaded; cells above the diagonal are blocked. Token 1 sees only itself, token 2 sees tokens 1 and 2, and token 4 sees all four. key position (what is looked at) 1234 1 ok −∞ −∞ −∞ 2 ok ok −∞ −∞ 3 ok ok ok −∞ 4 ok ok ok ok query position

In PyTorch's float-mask form (0 allowed, -inf blocked) the grid above is torch.triu(torch.full((T, T), -inf), diagonal=1) (lines 29-42, exposed as Transformer.generate_square_subsequent_mask, lines 302-310).

The mask gives a property worth stating exactly: the output at position t depends only on tokens 1 … t. Run causal attention on the four-token example from the attention page (the, cat, sat, down), then run it again on just the first three tokens. The first three output rows agree to within 0e+00. Without the mask, dropping the last token changes those rows by up to 0.36. This is why a decoder can be trained on whole sequences and still be used one token at a time: the answer for the prefix is the same either way. (Stacking layers keeps the property, because layer k+1 at position t only reads layer k at positions ≤ t.)

The block, pre-norm

Each block is x ← x + Attention(LayerNorm(x)), then x ← x + FFN(LayerNorm(x)). This "pre-norm" order is the norm_first=True branch of TransformerEncoderLayer (lines 946-952), and it leaves the residual stream untouched by any normalization, which keeps gradients healthy in deep stacks (see LayerNorm and residuals). Because the stream is never normalized inside the blocks, one extra LayerNorm is applied after the last block. GPT-2's paper describes both changes: layer normalization moved to the input of each sub-block, and an additional one after the final block. The feed-forward network is Linear(768 → 3072) → GELU → Linear(3072 → 768), a width of four times the model dimension.

Why reuse an encoder layer for a decoder? Because "encoder" and "decoder" in PyTorch differ by cross-attention, not by masking. TransformerDecoderLayer (lines 985-1201) takes a memory from an encoder and attends to it. A decoder-only model has none, so what it needs is a self-attention layer plus a feed-forward network: exactly an encoder layer, run with a causal mask.

After the final LayerNorm each position holds a vector of 768 numbers. A linear map to 50257 numbers gives one logit per vocabulary entry; softmax turns them into the next-token distribution. That map has shape [50257, 768], the same as the token table, and GPT-2 reuses the table's weights for it (weight tying): the vector that represents a token on the way in is the vector it is scored against on the way out. It saves 38,597,376 parameters, 23.7% of what the untied model would have. In PyTorch it is one line, head.weight = tok.weight, because Embedding and Linear store weights with the same layout.

Training versus generation

Training uses teacher forcing. Take a sequence, feed tokens 1 … T−1 as inputs, and use tokens 2 … T as targets. For [a, b, c, d] the inputs are [a, b, c] and the targets [b, c, d]. The model sees the true previous tokens at every position and all 3 predictions are scored in one pass.

Generation is a loop. Feed the prompt, read the logits at the last position, choose a token (the largest logit for greedy decoding, or a sample, see decoding), append it, and repeat. Each new token needs the model run again. Without a cache the work at step i covers the whole prefix; for a 10-token prompt and 20 new tokens that is 390 token positions processed, against 29 when each position's keys and values are remembered (the KV cache).

ComponentShape/ParamsMeaningSource
nn.Embedding (tokens) [50257, 768]; 38,597,376 params Row lookup: token id → vector. Default init is N(0, 1). sparse.py
nn.Embedding (positions) [1024, 768]; 786,432 params One learned vector per position; fixes the maximum context. sparse.py
TransformerEncoderLayer(norm_first=True) d_model=768, nhead=12, dim_feedforward=3072; 7,087,872 params each A pre-norm block: self-attention and feed-forward, each with a residual add. transformer.py
TransformerEncoder(layer, 12, norm=…) 12 deep-copied layers plus an optional final norm Runs the blocks in order, then norm. transformer.py
generate_square_subsequent_mask [T, T] float, -inf above the diagonal The causal mask, added to attention scores. transformer.py
F.scaled_dot_product_attention(…, is_causal=True) q, k, v of shape [B, H, T, 64] Fused attention with the causal mask built in; errors if attn_mask is also given. functional.py
nn.Linear (LM head) [50257, 768]; 0 extra params when tied Hidden vector → one logit per token. linear.py
F.cross_entropy(logits, targets) logits [B·T, 50257], targets [B·T] Mean next-token loss over all positions. functional.py

Worked example

Counting GPT-2 small, parameter by parameter

GPT-2 small has vocabulary 50257, context 1024, width d = 768, 12 blocks and 12 heads of size 64. Every count below comes from decoder_param_count, which the tests check against the encoder-layer count in transformer_param_count and against the 124,439,808 total.

PartFormulaParameters
Token table50257 × 76838,597,376
Position table1024 × 768786,432
Attention, per block4·d² + 4·d (Q, K, V, output; weights and biases)2,362,368
Feed-forward, per block2·d·3072 + 3072 + d4,722,432
Two LayerNorms, per block2 × (2·d)3,072
One blocksum of the three rows above7,087,872
12 blocks12 × 7,087,87285,054,464
Final LayerNorm2·d1,536
LM headtied to the token table0
Total124,439,808
Total if the head were untiedadds 50257 × 768163,037,184

The blocks hold 68.3% of the parameters, the token table 31.0%, and the position table 0.6%. GPT-2's paper lists this size as 117M; the arithmetic here gives 124.4M, which is also the size of the released checkpoint when loaded in common libraries.

Where GPT-2 small's parameters live One horizontal bar split into three parts: the 12 transformer blocks hold 68.3 percent, the token embedding table (shared with the output head) holds 31.0 percent, and the position table with the final LayerNorm holds 0.6 percent. 12 blocks 85,054,464 (68.3%) token table 31.0% position table + final LayerNorm: 787,968 (0.6%), the sliver at the right edge total 124,439,808

Teacher forcing and perplexity on a tiny trained model

Take the eight-step bigram model from the training-loop page (its weights after training are the 4 × 4 table where row x scores the next letter). On the sequence [a, b, c, d] the three next-token losses are 0.3317, 0.3317, 0.3317; their mean is 0.3317 and the perplexity exp(mean) is 1.393: on average the model is as uncertain as if it were choosing among 1.39 equally likely letters (an untrained model scores 4.000, the vocabulary size).

Generating with the same table, always taking the largest logit from a: a → b → c → d → a → b. The last row of the table is all zeros (the letter d was never a training input with a kept target), so its logits tie and the first index wins, wrapping around to a. A tie like that, or a loop that repeats forever, is typical of greedy decoding; sampling exists to break it.

Implementation

Two ways to build the stack. The short one reuses PyTorch's layer, and works because an encoder layer run with a causal mask is a decoder-only block:

import torch
from torch import nn

class GPT(nn.Module):
    def __init__(self, vocab=50257, context=1024, d=768, layers=12, heads=12):
        super().__init__()
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(context, d)
        block = nn.TransformerEncoderLayer(
            d, heads, dim_feedforward=4 * d, dropout=0.1,
            activation="gelu", batch_first=True, norm_first=True,
        )
        self.blocks = nn.TransformerEncoder(
            block, layers, norm=nn.LayerNorm(d), enable_nested_tensor=False
        )
        self.head = nn.Linear(d, vocab, bias=False)
        self.head.weight = self.tok.weight          # weight tying
        nn.init.normal_(self.tok.weight, std=0.02)
        nn.init.normal_(self.pos.weight, std=0.02)

    def forward(self, ids):                          # ids: [B, T]
        T = ids.size(1)
        x = self.tok(ids) + self.pos(torch.arange(T, device=ids.device))
        mask = nn.Transformer.generate_square_subsequent_mask(T, device=ids.device)
        x = self.blocks(x, mask=mask, is_causal=True)
        return self.head(x)                          # [B, T, vocab]

model = GPT()
print(sum(p.numel() for p in model.parameters()))  # tied weights are counted once

ids = torch.randint(0, 50257, (2, 16))
logits = model(ids)
loss = nn.functional.cross_entropy(
    logits[:, :-1].reshape(-1, 50257), ids[:, 1:].reshape(-1)
)

@torch.no_grad()
def generate(model, ids, new_tokens):
    model.eval()
    for _ in range(new_tokens):
        logits = model(ids[:, -1024:])               # crop to the context
        ids = torch.cat([ids, logits[:, -1].argmax(-1, keepdim=True)], dim=1)
    return ids

The details that matter: norm_first=True selects the pre-norm branch; batch_first=True makes inputs [B, T, d]; the encoder passes the mask to every layer and, if you give is_causal=None, works out for itself whether the mask is causal (_detect_is_causal_mask), while a wrong is_causal=True hint on a non-causal mask is documented as unpredictable. enable_nested_tensor=False silences a warning: the nested-tensor fast path is disabled for norm_first anyway (line 378). The generate loop recomputes the whole prefix each step, which is correct but wasteful; real implementations cache keys and values.

The longer way builds the block from primitives, replacing the layer with a few lines you control:

class Block(nn.Module):
    def __init__(self, d, heads):
        super().__init__()
        self.ln1, self.ln2 = nn.LayerNorm(d), nn.LayerNorm(d)
        self.qkv, self.proj = nn.Linear(d, 3 * d), nn.Linear(d, d)
        self.ff = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
        self.heads = heads

    def forward(self, x):                            # x: [B, T, d]
        B, T, d = x.shape
        q, k, v = self.qkv(self.ln1(x)).split(d, dim=-1)
        q, k, v = (t.view(B, T, self.heads, -1).transpose(1, 2) for t in (q, k, v))
        a = nn.functional.scaled_dot_product_attention(q, k, v, is_causal=True)
        x = x + self.proj(a.transpose(1, 2).reshape(B, T, d))
        return x + self.ff(self.ln2(x))

Here scaled_dot_product_attention applies the causal mask itself (the reference code in its docstring builds the lower-triangular mask, adds −∞ elsewhere, softmaxes and multiplies by V), and raises an error if you also pass attn_mask.

Tradeoffs

  • Decoder-only versus encoder-decoder or encoder-only. A single causal stack handles any task phrased as text continuation, trains on raw text with no labels, and scales simply. Bidirectional models (encoder-decoder, encoder-only) see the whole input and often win at classification and translation for a given size, but need paired data or a masking objective and can't generate freely.
  • The context window is a budget. Attention compares every pair of positions, so cost grows with T²; a learned position table can't go past 1024. Later models replace learned positions with rotary or relative schemes and use grouped-query attention to shrink the KV cache; the Llama 3 paper describes one such recipe (RMSNorm, SwiGLU, rotary positions, grouped-query attention). Those variants are planned for the KV-cache page.
  • Tying the head. It removes 23.7% of the untied parameters here and often costs little accuracy, but it forces the input and output spaces to agree. With a small model and a big vocabulary it is a large saving; with a huge model it is a rounding error, which is why larger models often untie it.
  • Generation is sequential. Training parallelizes over T; generating T tokens takes T forward passes however many GPUs you own. This is why serving cost is dominated by memory traffic, not arithmetic, and why caching, batching and speculative decoding exist.
  • Pitfalls. Exposure bias: the model is trained on true prefixes but generates from its own, so small errors compound. Forgetting to shift targets by one trains the model to copy its input. Padding tokens must be excluded from the loss with ignore_index. And a mask that points the wrong way silently lets the model read the future, giving a suspiciously low training loss and useless generation.

Check yourself

Why can the same causal model be trained on whole sequences yet used one token at a time?

Position t's output depends only on tokens ≤ t. The check above shows the first rows are unchanged when later tokens are removed, so training on the full sequence gives exactly what generation will see at each step.

The inputs are tokens 1…T−1. What are the targets, and how many loss terms are there?

Tokens 2…T; T−1 terms, averaged. Position t's logits are scored against token t+1.

How many parameters does tying the head save for a vocabulary of V and width d?

V·d (for GPT-2 small, 38,597,376); the head would otherwise be a second [V, d] matrix.

Why is a perplexity of 4 on a four-letter vocabulary the same as guessing?

A uniform guess gives probability ¼ everywhere, so each loss is ln 4 and exp(ln 4) = 4.

Connections

References

  • Radford et al. Language Models are Unsupervised Multitask Learners (GPT-2). OpenAI, 2019 (PDF).
  • Radford, Narasimhan, Salimans and Sutskever. Improving Language Understanding by Generative Pre-Training (GPT-1). OpenAI, 2018.
  • Brown et al. Language Models are Few-Shot Learners (GPT-3). NeurIPS, 2020 (arXiv 2005.14165).
  • Vaswani et al. Attention Is All You Need. NeurIPS, 2017 (arXiv 1706.03762).
  • Press and Wolf. Using the Output Embedding to Improve Language Models. EACL, 2017 (arXiv 1608.05859; weight tying).
  • Xiong et al. On Layer Normalization in the Transformer Architecture. ICML, 2020 (arXiv 2002.04745; pre-norm).
  • Llama Team. The Llama 3 Herd of Models. 2024 (arXiv 2407.21783).
  • PyTorch v2.15.0: transformer.py, functional.py, sparse.py, loss.py.