Skip to content

Module 03 · Chapter 9

Assembling a GPT-Style Language Model

Build a small runnable GPT-style decoder-only model by stacking decoder blocks, adding final normalisation, tying or untying the output projection, and verifying forward shapes and parameter counts.

Intermediate15 min readUpdated Nov 1, 2026

Learning objectives

  • Stack N decoder blocks into a model with explicit tensor-shape tracking at each stage.
  • Add final normalisation and an output projection to vocabulary logits, choosing tied or untied weights.
  • Count total model parameters (embeddings, blocks, output projection) and compare tied vs untied.
  • Verify the full model forward pass produces [B, T, V] logits and each component preserves [B, T, d].

Before you start

  • transformer-architecture (lesson 1): decoder block overview, residual stream, pre-norm trace
  • self-attention-in-pytorch (lesson 4): Q/K/V projections, scaled dot-product, stable softmax
  • causal-attention-masks (lesson 5): causal mask construction
  • multi-head-attention (lesson 6): head splitting, merging, output projection
  • positional-encoding-and-rope (lesson 7): position methods (learned, sinusoidal, RoPE)
  • transformer-blocks (lesson 8): decoder block with pre-norm, RMSNorm, SwiGLU, residual connections

A decoder block is a complete unit — it takes [B, T, d] and returns [B, T, d]. Stack N of them, add an embedding table at the front and an output projection at the back, and you have a complete GPT-style language model. This lesson assembles the components from lessons 4–8 into a runnable model, counts its parameters, and verifies the forward pass.

A GPT-style model = token embeddings + N stacked decoder blocks (each preserving [B, T, d]) + final normalisation + output projection to [B, T, V] logits, with the output projection either tied to the embedding table or learned separately.

Everything else in this lesson makes that concrete.

Lab: Run the companion CPU exercise at /labs/track1/02-tiny-decoder/ (stack blocks, final norm, weight tying, parameter count).

Use these dimensions throughout the lesson:

Symbol Meaning Value in this trace
B Batch size (sequences processed together) 2
T Sequence length (tokens per sequence) 5
d Model width (hidden dimension) 32
h Number of attention heads 4
d_head Width per head (d / h) 8
V Vocabulary size 100
N Number of decoder blocks 2

The numbers are deliberately small. A real model uses the same pattern with larger values; the pattern is what matters.

A GPT-style decoder-only model has four parts:

  1. Token embedding table E of shape [V, d] — maps token IDs to d-dim vectors.
  2. Position information — learned position table P of shape [max_position, d] (GPT-2 style) or RoPE rotation (0 params, applied inside attention).
  3. N decoder blocks — each preserves [B, T, d] through pre-norm attention + SwiGLU MLP with residual connections.
  4. Final normalisation + output projection — RMSNorm/LayerNorm, then project [B, T, d] → [B, T, V] logits.
input_ids [B, T] → look up in E → [B, T, d]
+ position info [B, T, d] → [B, T, d]
DecoderBlock 1 → [B, T, d]
DecoderBlock 2 → [B, T, d]
... (N blocks total) → [B, T, d]
Final norm → [B, T, d]
Output projection → [B, T, V] logits

Worked example (B=2, T=5, d=32, V=100, N=2)

Section titled “Worked example (B=2, T=5, d=32, V=100, N=2)”
input_ids: [2, 5]
token embeddings: [2, 5, 32] E lookup
position info: [2, 5, 32] added to embeddings
block 1: [2, 5, 32]
block 2: [2, 5, 32]
final norm: [2, 5, 32]
output projection:[2, 5, 100] logits

2. Weight tying vs untied output projection

Section titled “2. Weight tying vs untied output projection”

The output projection matrix is the transpose of the embedding table: W_out = E^T. No separate output matrix is stored.

logits = hidden @ E^T # [B, T, d] × [d, V] → [B, T, V]
  • Saves V × d parameters
  • No bias term
  • Default in Hugging Face GPT2Config (tie_word_embeddings = True)

A separate output matrix W_out of shape [d, V] is learned independently.

logits = hidden @ W_out # [B, T, d] × [d, V] → [B, T, V]
  • Adds d × V parameters
  • No bias term
  • Default in Hugging Face LlamaConfig (tie_word_embeddings = False)
Question Recommendation Rationale
Tie or untie for a new decoder-only model? Tie (default) Saves V × d params with negligible quality cost at small-to-medium scale; simpler implementation.
When to untie? When the output task differs significantly from pre-training (e.g., classification head, specialised vocabulary) Untied output has extra expressive capacity for the output layer.
Bias in output projection? No bias GPT-2 and Llama 2 both omit bias; adding one adds V params with no demonstrated benefit.

Staff/Principal judgment: For a small-to-medium GPT-style model (100M–1B parameters), tie the output projection by default. The parameter savings are real (V × d, e.g., ~205M params for V=50k, d=4k), the implementation is simpler, and the quality difference is typically negligible. Untie only if you have a concrete reason (different output task, vocabulary skew).

For a model with vocabulary V, width d, N decoder blocks:

Component Parameters (tied) Parameters (untied)
Token embeddings V × d V × d
Position embeddings (learned) max_position × d max_position × d
Position embeddings (RoPE) 0 0
Per decoder block 10 × d² + 6d 10 × d² + 6d
All N blocks N × (10 × d² + 6d) N × (10 × d² + 6d)
Final norm d d
Output projection 0 (tied) d × V
Total V×d + N×(10d²+6d) + d 2V×d + N×(10d²+6d) + d
Sub-component Parameters
Attention Q/K/V/O (bias=True) 4 × d² + 4d
MLP combined gate+value (bias=False) d × 2d = 2d²
MLP output projection (bias=False) 2d × d = 2d²
RMSNorm × 2 2 × d
Total per block 10d² + 6d

Worked parameter count (V=100, d=32, N=2, RoPE, tied)

Section titled “Worked parameter count (V=100, d=32, N=2, RoPE, tied)”
Component Calculation Parameters
Token embeddings 100 × 32 3,200
Position info (RoPE) 0 0
Decoder blocks (2 × (10 × 32² + 6 × 32)) 2 × (10,240 + 192) 20,864
Final norm 32 32
Output projection (tied) 0 0
Total 24,096

Same model untied: 24,096 + (32 × 100) = 27,296 (3,200 more params).

import torch
import torch.nn as nn
import torch.nn.functional as F
class RMSNorm(nn.Module):
def __init__(self, d, eps=1e-6):
super().__init__()
self.eps = eps
self.weight = nn.Parameter(torch.ones(d))
def forward(self, x):
rms = torch.sqrt(x.pow(2).mean(dim=-1, keepdim=True) + self.eps)
return x / rms * self.weight
class SwiGLU(nn.Module):
def __init__(self, d, hidden_mult=4):
super().__init__()
hidden = d * hidden_mult
self.w = nn.Linear(d, hidden, bias=False)
self.w_out = nn.Linear(hidden // 2, d, bias=False)
def forward(self, x):
x_proj = self.w(x) # [B, T, 4d]
gate, val = x_proj.chunk(2, dim=-1) # each [B, T, 2d]
x_swish = F.silu(gate) * val # [B, T, 2d]
return self.w_out(x_swish) # [B, T, d]
class DecoderBlock(nn.Module):
def __init__(self, d, n_heads=4):
super().__init__()
self.norm1 = RMSNorm(d)
self.attn = nn.MultiheadAttention(d, n_heads, batch_first=True)
self.norm2 = RMSNorm(d)
self.mlp = SwiGLU(d)
def forward(self, x, attn_mask=None):
x_norm = self.norm1(x)
T = x_norm.size(1)
if attn_mask is None:
attn_mask = torch.zeros(T, T, device=x.device, dtype=x.dtype)
attn_out, _ = self.attn(x_norm, x_norm, x_norm,
attn_mask=attn_mask, need_weights=False,
is_causal=True)
x = x + attn_out
x = x + self.mlp(self.norm2(x))
return x
class GPTStyleModel(nn.Module):
def __init__(self, vocab_size, d, n_heads, n_blocks, max_position=1024,
tie_weights=True, use_rope=True):
super().__init__()
self.d = d
self.vocab_size = vocab_size
self.tie_weights = tie_weights
# Token embeddings
self.tok_emb = nn.Embedding(vocab_size, d)
# Position info: learned table or RoPE (0 params)
if use_rope:
self.pos_emb = None # RoPE applied inside attention, 0 params
else:
self.pos_emb = nn.Embedding(max_position, d)
# Decoder blocks
self.blocks = nn.ModuleList([
DecoderBlock(d, n_heads) for _ in range(n_blocks)
])
# Final norm
self.final_norm = RMSNorm(d)
# Output projection
if tie_weights:
# Reuse embedding table transposed: no separate params
self.output_proj = None
else:
self.output_proj = nn.Linear(d, vocab_size, bias=False)
def forward(self, input_ids):
B, T = input_ids.shape
# Token embeddings [B, T, d]
h = self.tok_emb(input_ids)
# Position info: RoPE applied inside attention blocks (0 params)
# Learned positions would be added here: h = h + self.pos_emb(positions)
# Decoder blocks (RoPE applied inside each block's attention)
for block in self.blocks:
h = block(h) # [B, T, d] → [B, T, d]
# Final norm [B, T, d]
h = self.final_norm(h)
# Output projection [B, T, V]
if self.tie_weights:
logits = h @ self.tok_emb.weight.T # [B, T, d] × [d, V]
else:
logits = self.output_proj(h) # [B, T, d] × [d, V]
return logits
# Test
torch.manual_seed(42)
model = GPTStyleModel(vocab_size=100, d=32, n_heads=4, n_blocks=2,
tie_weights=True, use_rope=True)
input_ids = torch.randint(0, 100, (2, 5)) # [B=2, T=5]
logits = model(input_ids)
print(f"Input: {input_ids.shape}, Logits: {logits.shape}") # [2, 5, 100]
assert logits.shape == (2, 5, 100), "Output shape mismatch"
# Count parameters
total_params = sum(p.numel() for p in model.parameters())
print(f"Total parameters: {total_params:,}")
# Expected: 100×32 + 2×(10×32² + 6×32) + 32 = 3,200 + 20,864 + 32 = 24,096
assert total_params == 24_096, f"Expected 24,096 params, got {total_params}"
print("PASS: Parameter count matches formula (tied, RoPE)")

Before assembling the full model, verify each component independently:

Component Test Expected
RMSNorm Match nn.RMSNorm on random input [2, 16, 512] max_diff < 1e-6
SwiGLU Check intermediate shapes (gate/val split, output) gate/val each [B, T, 2d], output [B, T, d]
DecoderBlock Shape preservation [B, T, d] → [B, T, d] Output shape == input shape
DecoderBlock Causal safety: corrupt future position, earlier outputs unchanged allclose(atol=1e-5)
Full model Forward pass [B, T] → [B, T, V] Logits shape matches vocab
Full model Parameter count matches formula Exact match

GPT-style model assembly

GPT-style model assemblyFour panels. Panel A: token IDs [B, T] look up in embedding table E [V, d] → [B, T, d], position info added. Panel B: N stacked decoder blocks, each preserving [B, T, d]. Panel C: final RMSNorm [B, T, d], then output projection — tied (reuses E^T, 0 extra params) or untied (separate W [d, V]). Panel D: parameter count formula with breakdown by component.Panel A: Token Embeddings + PositionInput token IDs: [B, T]Embedding table E: [V, d]Row lookup per token ID → d-dim vectorToken embeddings: [B, T, d]Position info addedLearned table [max_pos, d] or RoPE (0 params)Position-aware embeddings: [B, T, d]✓ RoPE: 0 parametersRotation applied inside attentionLearned table: max_position × d paramsPanel B: N Stacked Decoder BlocksEach block preserves [B, T, d] through pre-norm attention + SwiGLU MLPDecoder Block 1[B, T, d] → [B, T, d]Pre-norm attention + SwiGLU MLP + residualsDecoder Block 2[B, T, d] → [B, T, d]Pre-norm attention + SwiGLU MLP + residuals… Block NEach block preserves [B, T, d]After N blocks: [B, T, d]✓ Per block: ~12 × d² paramsQ,K,V,O: 4 × d²MLP: 6 × d² (gate+value + output)Norms: 2 × dPanel C: Final Norm + Output ProjectionFinal RMSNorm: [B, T, d]Tied output (GPT-2 style)logits = hidden @ E^T[B, T, d] × [d, V] → [B, T, V]Reuses embedding table E: 0 extra paramsLogits: [B, T, V]Untied output (Llama 2 style)logits = hidden @ W_out[B, T, d] × [d, V] → [B, T, V]Separate matrix W_out: +d × V paramsLogits: [B, T, V]No bias in either caseGPT-2 and Llama 2 both omit output biasAdding bias: +V params, no demonstrated benefitPanel D: Parameter CountWorked example: V=100, d=32, N=2, RoPE, tiedToken embeddingsV × d = 100 × 32 = 3,200N decoder blocksN × (10 × d² + 6d) = 2 × (10 × 1,024 + 192) = 20,864Final normd = 32Output projection (tied)0 extra params (reuses E)Total: 24,096 paramsUntied: +d × V = +3,200Total: 27,296 paramsTied saves ~12% at this scale
Token embeddings → N stacked decoder blocks → final norm → output projection (tied/untied), with tensor shapes at each stage.
Read this diagram as text

Text equivalent. Panel A: input token IDs [B, T] look up in embedding table E [V, d] → [B, T, d]; position info added (RoPE or learned table). Panel B: N stacked decoder blocks, each preserving [B, T, d] through pre-norm attention + SwiGLU MLP with residuals. Panel C: final RMSNorm [B, T, d], then output projection — tied (reuses E^T, 0 extra params) or untied (separate W [d, V]). Panel D: parameter count formula with breakdown by component (embeddings, blocks, output).

Text equivalent: The diagram has four panels. Panel A shows token IDs [B, T] looked up in the embedding table E [V, d] to produce [B, T, d], with position information added (either RoPE rotation applied inside attention, or a learned position table). Panel B shows N stacked decoder blocks, each taking [B, T, d] and returning [B, T, d] through pre-norm attention + SwiGLU MLP with residual connections. Panel C shows final RMSNorm [B, T, d], then the output projection — either tied (reuses E^T, zero extra parameters) or untied (separate matrix W [d, V]). Panel D shows the parameter counting formula broken down by component: token embeddings V×d, position info (0 for RoPE), N blocks N×(10d²+6d), final norm d, output projection (0 tied or d×V untied).

Source Relevance
Radford et al., “Language Models are Unsupervised Multitask Learners” (GPT-2 report, 2019) GPT-2 architecture: token embeddings, N decoder blocks, final norm, tied output projection
GPT-2 model source: src/model.py Tied output projection (wte reused), no bias, learned positional embeddings, pre-norm
Llama 2 model source: llama/model.py Untied output projection, no bias, RoPE, RMSNorm, SwiGLU, pre-norm
Vaswani et al., “Attention Is All You Need” (arXiv:1706.03762) Original Transformer architecture
Lesson 8 (transformer-blocks.mdx): decoder block implementation Pre-norm, RMSNorm, SwiGLU, residual connections, is_causal=True
Lesson 7 (positional-encoding-and-rope.mdx): RoPE mechanism RoPE applied to Q/K inside attention, 0 params, shape preserved
Lesson 5 (causal-attention-masks.mdx): causal mask construction Causal masking in decoder blocks

Sign in to unlock this chapter

“Assembling a GPT-Style Language Model” is part of the int8ai curriculum. Primers are freely accessible to everyone without login. Free reader accounts unlock all chapters, code labs, and progress syncing.

or sign in with email