Index Ai Academy
Phase 5 · Transformers · The transformer

The GPT architecture

A stack of decoder blocks that predicts the next token.

About 35 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

GPT stands for Generative Pre-trained Transformer. Its design is surprisingly simple. Turn each token into a list of numbers. Pass those lists through a stack of identical blocks. Then turn the final lists into a score for every possible next token. GPT-2, GPT-3, Llama and the models behind today's chat assistants all share this skeleton. They differ mainly in size, in training data and in details you've already met. A model card is the page that describes a released model. After this lesson, the numbers on one will mean something concrete to you.

What you'll be able to do

  • Assemble a GPT: embeddings, a stack of blocks, a final norm and a language-model head
  • Count a GPT's parameters and reproduce GPT-2 small's 124 million
  • Write the generation loop: predict, sample, append, repeat
  • Read a model card's configuration (layers, heads, width, context, vocabulary)

What it covers

  1. A stack of identical blocks
  2. One output, two uses
  3. One table, two jobs
  4. Counting the parameters
  5. Worked example: count a tiny GPT by hand
  6. Counting GPT-2 small
  7. Generation: predict, sample, append, repeat
  8. Reading a configuration

A stack of identical blocks

Picture a draft passed down a line of 12 editors. Every editor has the same job description. Each one reads the draft, adds notes, and passes it on. Nobody throws the draft away. Each editor builds on the work of the ones before.

A GPT works the same way. Every token (a word or a piece of a word) starts as a list of numbers, its vector. The vectors pass through a stack of blocks. Every block has the same design: the attention, MLP, residual connections and layer norms from the transformer block lesson. Each block adds its own changes to the vectors. Each block also has its own learned weights. So the editors share a job description, not a brain.

The whole model is four steps:

  1. Embed. Look up each token's vector in the token embedding table. Add a position vector that says where the token sits.
  2. Run the blocks. Pass the vectors through every block in turn.
  3. Normalize. Apply one last layer norm.
  4. Score. The language-model head (LM head for short) turns each vector into logits. Logits are raw scores, one for every token in the vocabulary. A softmax turns them into probabilities.

Here are the four steps as code. This is a sketch to read, not a cell to run:

x = token_embedding(idx) + position_embedding(positions)   # (B, T, d)
for block in blocks:                                       # N identical blocks
    x = block(x)
x = final_layer_norm(x)
logits = lm_head(x)                                        # (B, T, vocab): next-token scores at every position

The comments show the shapes. B is the batch, the number of sequences handled together. T is the number of tokens in each sequence. d is the model's width, the length of each token's vector. vocab is the vocabulary size, the number of different tokens the model knows.

In plain words: tokens go in. A score for every possible next token comes out, at every position.

Quick check
Where do a GPT's logits come from?
  1. The embedding table looks them up for each token
  2. The LM head turns each token's vector into one score per vocabulary token
  3. The final layer norm produces them directly
Show the answer

The LM head turns each token's vector into one score per vocabulary token

After the blocks and the last layer norm, the language-model head turns each vector into raw scores, one for every token in the vocabulary. A softmax then turns those scores into probabilities.

One output, two uses

The model makes logits at every position. How they're used depends on the job.

  • Training. Every position's logits are compared with the token that really comes next. These are the shifted targets from the causal mask lesson. So one sequence gives a prediction to learn from at every position.
  • Generation. Only the last position's logits matter. That position has seen the whole prompt. Its logits are the model's guess for the token that comes next.
Quick check
A GPT knows 200 different tokens. It gets a batch of 3 sequences, each 10 tokens long. What shape are the logits?
  1. (3, 10)
  2. (3, 200)
  3. (10, 200)
  4. (3, 10, 200)
Show the answer

(3, 10, 200)

There's one score per vocabulary token (200), at every position (10), for every sequence (3). The model in the next cell prints this exact shape.

One table, two jobs

Your phone's contacts list works in two directions. Type a name and it finds the number. When a call comes in, it matches the number back to a name. One list does both jobs.

Many GPTs do the same with the token embedding table. On the way in, the table turns a token into a vector. On the way out, the LM head must turn a vector back into a score for each token. It can reuse the same table. It takes the dot product of the final vector with every token's row. Rows that match the vector well get high scores. Sharing the table like this is called weight tying.

The table has one row per token and one column per number in the vector. Write VV for the vocabulary size. With width dd, the table is V×dV \times d. A parameter is one learned number in the model. Tying stores the table once instead of twice, which saves V⋅dV \cdot d parameters.

For example, with 200 tokens and width 64, the table holds 200 × 64 = 12,800 numbers. Tying saves all 12,800 of them.

A complete small GPT

Here is a whole GPT you can run. The Block class is a compact transformer block. The GPT class follows the four steps. The line marked # weight tying makes the head share the token table.

import math
import torch
import torch.nn as nn

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

    def forward(self, x):
        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.h, d // self.h).transpose(1, 2) for t in (q, k, v))
        s = (q @ k.transpose(-2, -1) / math.sqrt(d // self.h)).masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf"))
        x = x + self.proj((torch.softmax(s, dim=-1) @ v).transpose(1, 2).reshape(B, T, d))
        return x + self.mlp(self.ln2(x))

class GPT(nn.Module):
    def __init__(self, vocab, context, d, n_layers, n_heads):
        super().__init__()
        self.context = context
        self.tok = nn.Embedding(vocab, d)
        self.pos = nn.Embedding(context, d)
        self.blocks = nn.Sequential(*[Block(d, n_heads) for _ in range(n_layers)])
        self.ln_f = nn.LayerNorm(d)
        self.head = nn.Linear(d, vocab, bias=False)
        self.head.weight = self.tok.weight                  # weight tying

    def forward(self, idx):
        T = idx.shape[1]
        x = self.tok(idx) + self.pos(torch.arange(T))
        return self.head(self.ln_f(self.blocks(x)))

torch.manual_seed(0)
model = GPT(vocab=200, context=32, d=64, n_layers=2, n_heads=4)
idx = torch.randint(0, 200, (3, 10))
print("logits:", tuple(model(idx).shape), "  parameters:", f"{sum(p.numel() for p in model.parameters()):,}")
It runs right here in your browser, with nothing to install.

It prints (3, 10, 200) for the logits: 3 sequences, 10 tokens each, 200 scores per token. It also prints 114,944 parameters. Most of them sit in the two blocks, at 49,984 each. The token table holds the 12,800 you worked out above.

Here torch is the course's small in-browser version of PyTorch. The next lesson trains this exact model.

Quick check
The small model above has 200 tokens, width 64 and 114,944 parameters. If you deleted the weight-tying line, how many parameters would it have?
  1. 102,144
  2. 114,944
  3. 229,888
  4. 127,744
Show the answer

127,744

The head would get its own 200 × 64 table of 12,800 numbers. So 114,944 + 12,800 = 127,744.

Counting the parameters

A model's size is usually given as its number of parameters. You can work it out from four numbers on the model card. Here are the symbols, one per line:

  • VV is the vocabulary size.
  • nctxn_{\text{ctx}} is the context length, the longest sequence the model can take. It's also the number of rows in the position table.
  • dd is the width.
  • LL is the number of layers, which means the number of blocks.
parameters=Vd+nctx d+L (12d2+13d)+2d\text{parameters} = V d + n_{\text{ctx}}\, d + L\,(12 d^2 + 13 d) + 2 d

In plain words: count the token table, VV rows of dd numbers. Add the position table, nctxn_{\text{ctx}} rows of dd numbers. Add one block's worth for every layer. Add 2d2d for the final layer norm. The head adds nothing, because it's tied.

The 12d2+13d12 d^2 + 13 d is one block's count. The transformer block lesson showed that a block holds about 12d212 d^2 parameters. Attention has 4d24 d^2 of them and the MLP has 8d28 d^2. The 13d13 d covers small extras: the bias terms and the layer norms. The panel below has the full tally.

The number of heads isn't in the formula. As the multi-head lesson showed, splitting into heads adds no parameters.

Go deeper Where 12d² + 13d comes from

Count one block, piece by piece, for width dd.

  • Attention: the query, key, value and output projections are each d×dd \times d weights, plus dd biases. That's 4d2+4d4 d^2 + 4 d.
  • MLP: the first layer widens dd numbers to 4d4d. That takes 4d24 d^2 weights plus 4d4d biases. The second layer narrows back, with 4d24 d^2 weights plus dd biases. That's 8d2+5d8 d^2 + 5 d.
  • Two layer norms, each with a scale and a shift of dd numbers: 4d4d.

Add them up to get 12d2+13d12 d^2 + 13 d. The final layer norm after the blocks has its own scale and shift. That's the 2d2d at the end of the formula.

Try it now

Set it to 50,257 / 1,024 / 768 / 12 and you get GPT-2 small's roughly 124 million. Width costs the most, because it's squared.

Worked example

Worked example: count a tiny GPT by hand

Take a toy GPT with a vocabulary of 10, a context of 4, width 2 and 1 layer. Work out each part of the formula, then add them up.

V, n_ctx, d, L = 10, 4, 2, 1
tokens = V * d                          # token table
positions = n_ctx * d                   # position table
blocks = L * (12 * d * d + 13 * d)      # every block
final_norm = 2 * d                      # final layer norm (the head is tied)
print(tokens, positions, blocks, final_norm)
print("total:", tokens + positions + blocks + final_norm)
In the lesson you can change this and run it.

The token table is 10 × 2 = 20. The position table is 4 × 2 = 8. One block is 12 × 2 × 2 + 13 × 2 = 48 + 26 = 74. The final norm is 2 × 2 = 4. The total is 20 + 8 + 74 + 4 = 106. At this tiny width, the 13d extras are about a third of the block. At real widths they shrink to almost nothing.

Counting GPT-2 small

The same formula works for real models. GPT-2 small has a vocabulary of 50,257 and a context of 1,024. Its width is 768, with 12 layers and 12 heads. Its head is tied. The function below is the formula, with the block split into its parts. It also counts the three bigger GPT-2 models.

def gpt_params(vocab, context, d, n_layers):
    embeddings = vocab * d + context * d
    per_block = 4 * d * d + 4 * d          # attention: Q, K, V, output projection + biases
    per_block += 8 * d * d + 5 * d         # MLP: d → 4d → d, with biases
    per_block += 4 * d                     # two layer norms (scale and shift)
    return embeddings + n_layers * per_block + 2 * d   # + final layer norm (head is tied)

configs = {"GPT-2 small": (50257, 1024, 768, 12), "GPT-2 medium": (50257, 1024, 1024, 24),
           "GPT-2 large": (50257, 1024, 1280, 36), "GPT-2 XL": (50257, 1024, 1600, 48)}
for name, cfg in configs.items():
    print(f"{name:<13} {gpt_params(*cfg) / 1e6:7.1f} million parameters")

GPT-2 small comes out at 124.4 million, its published size. Medium, large and XL come out at about 355 million, 774 million and 1.56 billion. Those match the published family. About 39 million of small's parameters are the token table alone.

Quick check
What share of GPT-2 small is its token table? What does this print?
table = 50257 * 768
total = 124439808
print(round(table / total * 100))
  1. 12
  2. 31
  3. 69
  4. 3
Show the answer

31

50,257 × 768 = 38,597,376. That's about 31% of 124,439,808. In small models, the token table is a big share of the whole.

Generation: predict, sample, append, repeat

Think of the word suggestions on your phone's keyboard. Tap one, and it suggests the next word based on everything typed so far. A GPT writes text the same way, one token at a time:

  1. Run the model on the tokens so far.
  2. Take the logits at the last position.
  3. Pick one token, using a sampling strategy from the sampling lesson.
  4. Add that token to the end of the sequence.
  5. Repeat.
@torch.no_grad()
def generate(model, idx, new_tokens, temperature=1.0):
    for _ in range(new_tokens):
        logits = model(idx[:, -model.context:])[:, -1, :]       # last position only
        probs = torch.softmax(logits / temperature, dim=-1)
        next_id = torch.multinomial(probs, num_samples=1)
        idx = torch.cat([idx, next_id], dim=1)
    return idx

Here's what each line does.

  • @torch.no_grad() tells PyTorch not to record gradients. Nothing is being trained here, so that work would be wasted.
  • idx[:, -model.context:] keeps only the last model.context tokens. The model has one learned position vector per slot, up to its context length. It has none for later slots, so longer input must be cut down first.
  • [:, -1, :] picks the logits at the last position.
  • Dividing by temperature before the softmax sharpens or flattens the probabilities, as in the sampling lesson.
  • torch.multinomial draws one token at random, weighted by the probabilities.
  • torch.cat adds the new token to the end.

This simple loop redoes the work for the whole sequence at every step. Real systems save the keys and values from earlier steps instead. That store is the KV cache, which has its own lesson.

Quick check
During generation, which part of the model's output is used to choose the next token?
  1. The average of all positions' logits
  2. The final layer norm's output
  3. The logits at the first position
  4. The logits at the last position
Show the answer

The logits at the last position

The last position has seen the whole prompt, so its logits are the prediction for the next token.

Reading a configuration

A model card lists a few settings, called its configuration. Here's what each one controls, with GPT-2 small's values.

SettingWhat it controlsGPT-2 small
vocabulary sizerows of the embedding table and width of the output50,257
context lengththe longest sequence the model can attend over1,024
width (dmodeld_{\text{model}})size of every token's vector in the residual stream768
layersnumber of stacked blocks12
headsattention heads per block (head size = width / heads)12 (64 each)

Width is often written dmodeld_{\text{model}} ("d model"). It's the same dd as in the formula.

The biggest models today scale up every row. Their widths run into the thousands. They have around a hundred layers and vocabularies of 100,000 tokens or more. Their context windows reach hundreds of thousands of tokens, sometimes more. The skeleton is still the one in this lesson.

Quick check
A model card lists width 4,096 and 32 heads. What is each head's size?
  1. 128
  2. 131,072
  3. 4,096
  4. 32
Show the answer

128

Head size is width divided by heads: 4,096 / 32 = 128. The heads share out the width.

Practice what you just read

  • 5 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

What does weight tying do in a GPT?

  1. Uses the token embedding matrix as the output layer's weights too
  2. Makes every block share one set of weights, which it reuses
  3. Ties the learning rate to the batch size throughout training
  4. Freezes the embeddings, so they stop changing during training

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson