Production concerns: memory management, gradient accumulation, mixed precision, checkpointing.
✦ Definition~90s read
What is Build GPT?
A GPT (Generative Pre-trained Transformer) is a decoder-only transformer model that autoregressively predicts the next token in a sequence. It consists of stacked transformer blocks, each containing multi-head self-attention and feed-forward layers, with residual connections and layer normalization.
★
Think of GPT as a supercharged autocomplete.
The model is pre-trained on a large corpus of text using a language modeling objective (next token prediction).
Plain-English First
Think of GPT as a supercharged autocomplete. It reads a sequence of words (or characters) and learns patterns from massive text data to predict what comes next. The 'attention' mechanism lets it focus on relevant parts of the input, like a reader scanning back to earlier sentences for context.
Building a GPT from scratch is a core skill for any ML engineer working with language models — not just an academic exercise. Understanding the internals lets you fine-tune, debug, and scale models beyond what off-the-shelf APIs offer. The canonical nanoGPT repo by Andrej Karpathy provides a clean, minimal implementation that reproduces GPT-2 (124M) on a single node, making it the perfect starting point for serious developers.
This article walks through every component of a GPT: tokenization, embedding, multi-head self-attention, feed-forward networks, layer normalization, and the training loop. We'll reference nanoGPT's ~300-line model.py and train.py, but go deeper into production considerations like memory profiling, gradient accumulation, and checkpointing strategies.
You'll learn not just how to code a GPT, but how to debug it when training diverges, optimize throughput on GPUs, and avoid common pitfalls that waste compute. By the end, you'll have a mental model that scales from a character-level Shakespeare model to a 1.3B parameter GPT-2.
This is not a beginner tutorial. You need working knowledge of PyTorch, transformers, and basic deep learning. We'll assume you've read the Attention Is All You Need paper and understand backpropagation. If not, start with Karpathy's 'Neural Networks: Zero to Hero' series first.
Introduction: Why Build GPT from Scratch
By 2026, GPTs are commodity infrastructure. You don't build one to beat OpenAI — you build one to own your stack, control your data, and ship models that fit in a single GPU for under $500. The era of 'just call the API' is over for production systems that need predictable latency, zero data leakage, and custom tokenization for domain-specific corpora like legal documents, medical records, or codebases with proprietary syntax. Building from scratch gives you surgical control over every parameter, from embedding dimension to attention head count, and lets you deploy on edge devices or air-gapped environments where no cloud API reaches.
This walkthrough implements a decoder-only transformer that mirrors GPT-2's architecture at 124M parameters — the smallest viable model that exhibits coherent long-range dependencies. We use PyTorch 2.x with compile, Flash Attention kernels, and the tiktoken BPE tokenizer. The final model trains on OpenWebText in under 4 days on a single 8x A100 node, reproducing GPT-2's loss curve. But more importantly, you'll understand every line of code: tokenization, embeddings, causal self-attention, feedforward blocks, layer normalization, weight tying, and the training loop with cosine decay and gradient clipping.
Why 2026 specifically? Because hardware has shifted: consumer GPUs now have 24-48GB VRAM, Flash Attention is standard in PyTorch, and quantization (FP8, INT4) is trivial. The barrier to training a 124M model from scratch is a weekend project on a single RTX 4090. The knowledge you gain transfers directly to scaling laws, mixture-of-experts, and multi-modal architectures. If you can build GPT from scratch, you can debug any transformer-based system in production.
This is not a tutorial for beginners. You need working knowledge of PyTorch, backpropagation, and basic NLP. We skip the 'what is attention' hand-waving and go straight to tensor shapes, masking logic, and numerical stability. Every code block is runnable and tested against PyTorch 2.5. Let's build.
import torch, tiktoken, sys
print(f'PyTorch {torch.__version__}, CUDA {torch.version.cuda}')
print(f'tiktoken {tiktoken.__version__}')
enc = tiktoken.get_encoding('gpt2')
tokens = enc.encode('Hello, world!')
print(f'Tokens: {tokens}')
assert torch.cuda.is_available(), 'Need CUDA for training'print('Setup OK')
Output
PyTorch 2.5.0, CUDA 12.4
tiktoken 0.7.0
Tokens: [15496, 11, 995, 0]
Setup OK
🔥Why Not Fine-Tune?
Fine-tuning a 7B model costs $100+ per run and locks you into someone else's tokenizer and architecture. Building from scratch costs $5 in compute for a 124M model and gives you full ownership.
📊 Production Insight
In production, you rarely train from scratch — you start from a pretrained checkpoint and fine-tune. But understanding the internals means you can debug attention head collapse, embedding drift, and tokenization mismatches that plague fine-tuned models. Every production incident I've seen traces back to a misunderstanding of one of these layers.
🎯 Key Takeaway
Building GPT from scratch is about control, not capability. You get a 124M model that trains in 4 days on a single node, costs <$500, and runs on a laptop. The architecture is the same as GPT-2, but the tooling (Flash Attention, torch.compile, tiktoken) makes it production-ready.
thecodeforge.io
Gpt From Scratch
Embedding Layer: Token and Positional Embeddings
The embedding layer converts token IDs (integers) into dense vectors. GPT-2 uses a learned token embedding matrix of shape (vocab_size, n_embd) where n_embd = 768 for the 124M model. Each token ID indexes a row in this matrix, producing a vector of size 768. This is a simple lookup: no computation, just memory access. The embedding matrix is shared with the output projection layer (weight tying) to reduce parameters and improve training stability.
Positional embeddings are also learned, not sinusoidal. GPT-2 uses a separate learned embedding of shape (block_size, n_embd) where block_size = 1024. The position index (0 to 1023) is added to the token embedding element-wise. This gives the model a sense of order without any inductive bias. The sum of token and positional embeddings is then passed through layer normalization before the first transformer block.
In code, we implement a combined Embedding module that stores both token and position embeddings. The forward pass takes token IDs of shape (batch, seq_len) and returns embeddings of shape (batch, seq_len, n_embd). We use PyTorch's nn.Embedding with padding_idx=None (no padding token in GPT-2). The position indices are generated on the fly as torch.arange(seq_len, device=x.device).
Weight tying is implemented by setting the output linear layer's weight equal to the token embedding weight. This is done after model initialization: model.lm_head.weight = model.transformer.wte.weight. This halves the embedding parameter count and empirically improves convergence. For the 124M model, the embedding layer accounts for 50,257 * 768 ≈ 38.6M parameters, about 31% of total parameters.
Always tie the token embedding and output projection weights. It reduces parameters by ~38M for GPT-2 124M and improves perplexity by 0.1-0.2 nats. Set model.lm_head.weight = model.transformer.wte.weight after init.
📊 Production Insight
In production, consider using FP16 or BF16 for embeddings to save memory. The embedding layer is memory-bound, not compute-bound, so half-precision has negligible impact on throughput. For very large vocabularies (>100k), use adaptive softmax or hierarchical softmax to avoid the massive output projection.
🎯 Key Takeaway
Token embeddings are a learned lookup table of shape (vocab_size, n_embd). Positional embeddings are also learned and added element-wise. Weight tying shares the embedding matrix with the output layer, saving ~38M parameters. The combined embedding output is (batch, seq, n_embd) and is the input to the first transformer block.
Multi-Head Self-Attention: Implementation with Causal Masking
Multi-head self-attention is the core of the transformer. For each head, we compute queries (Q), keys (K), and values (V) from the input via learned linear projections. The attention scores are Q @ K^T / sqrt(d_k) where d_k = n_embd / n_head. For GPT-2 124M, n_embd=768 and n_head=12, so d_k=64. The scores are masked to prevent attending to future tokens (causal masking) by setting upper-triangular entries to -inf before softmax. The softmax output is then multiplied by V to produce the head's output. All heads are concatenated and projected back to n_embd.
Causal masking is implemented as a boolean mask of shape (1, 1, seq_len, seq_len) where mask[i,j] = 0 if i >= j else -inf. We add this mask to the attention scores before softmax. In practice, we use torch.triu with diagonal=1 to create the mask. For efficiency, we use Flash Attention (torch.nn.functional.scaled_dot_product_attention with is_causal=True) which fuses the QKV projections, masking, and softmax into a single kernel, reducing memory from O(n^2) to O(n).
The attention mechanism has O(n^2 * d_k) complexity per head, where n is sequence length. For GPT-2's block_size=1024, this is manageable. But for longer sequences (e.g., 8k), Flash Attention is essential. Our implementation falls back to manual attention for clarity but includes a flag to use Flash Attention when available.
In code, we implement a single attention head as a module, then combine multiple heads in MultiHeadAttention. The forward pass: (1) project input to Q, K, V for all heads simultaneously using a single linear layer, (2) split into heads, (3) compute attention with causal mask, (4) concatenate heads, (5) final projection. We include dropout on attention weights and output for regularization.
Think of attention as a soft lookup table where each token 'queries' the past tokens and aggregates their 'values' weighted by compatibility. The causal mask ensures the model can't cheat by looking at future tokens — it's the fundamental constraint that makes autoregressive generation possible.
📊 Production Insight
Always use Flash Attention (F.scaled_dot_product_attention with is_causal=True) in production. It's 2-5x faster and uses O(n) memory instead of O(n^2). For very long sequences (>4k), consider sparse attention patterns or sliding window attention to reduce quadratic cost. Never use manual attention for sequences longer than 2k tokens.
🎯 Key Takeaway
Multi-head self-attention with causal masking is the core of GPT. Each head computes Q, K, V from the input, applies a causal mask to prevent future token leakage, and aggregates values. Use Flash Attention for efficiency. The output shape is (batch, seq, n_embd), same as input. Parameters: ~2.36M for GPT-2 124M.
thecodeforge.io
Gpt From Scratch
Transformer Block: Attention, Feed-Forward, Layer Norm, and Residuals
The transformer block is the fundamental building unit of GPT. Each block consists of two sub-layers: multi-head causal self-attention and a position-wise feed-forward network (FFN). Both sub-layers are wrapped with residual connections and preceded by layer normalization (pre-norm). The pre-norm formulation, where LayerNorm is applied before the sub-layer rather than after, has become standard in GPT-style models because it stabilizes training at depth. The residual path allows gradients to flow directly through the stack, mitigating vanishing gradient problems even with 12, 24, or 48 blocks.
Multi-head attention splits the embedding dimension into h heads, each of dimension d_k = d_model / h. For each head, we compute queries Q, keys K, and values V via learned linear projections. The attention scores are computed as softmax(QK^T / sqrt(d_k) + M), where M is a causal mask that sets all future positions to -inf. This ensures position i can only attend to positions j ≤ i. The mask is typically implemented as a lower-triangular matrix filled with 0s in the lower triangle and -inf in the upper triangle. After computing attention, the heads are concatenated and projected back to d_model.
The feed-forward network is a simple two-layer MLP with a GELU activation in between. The typical GPT-2 configuration uses an inner dimension of 4 d_model. For d_model=768, the FFN expands to 3072 and then projects back to 768. This expansion-contraction pattern allows the model to learn complex non-linear transformations. The GELU activation is approximated as 0.5 x (1 + tanh(sqrt(2/pi) (x + 0.044715 * x^3))), though modern implementations often use the exact erf-based version.
Residual connections are critical: each sub-layer's output is added to its input. If we denote the input to a block as x, the output is x + Attention(LayerNorm(x)) + FFN(LayerNorm(x + Attention(LayerNorm(x)))). This additive structure means the model can learn to ignore sub-layers by learning near-zero weights, effectively reducing depth if needed. In practice, we initialize the output projection of each sub-layer with a small weight (e.g., N(0, 0.02)) and often use a scaling factor of 1/sqrt(2 * num_layers) to keep activations stable.
Layer normalization computes mean and variance across the feature dimension (not the sequence dimension). For an input x of shape (batch, seq_len, d_model), LayerNorm computes μ = mean(x, dim=-1) and σ = std(x, dim=-1), then outputs γ * (x - μ) / (σ + ε) + β, where γ and β are learnable parameters of size d_model. The epsilon (typically 1e-5) prevents division by zero. This normalization is crucial for training stability, especially when using FP16 or BF16 mixed precision.
io/thecodeforge/gpt/transformer_block.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
import torch
import torch.nn as nn
import torch.nn.functional as F
import math
classCausalSelfAttention(nn.Module):
def__init__(self, config):
super().__init__()
assert config.n_embd % config.n_head == 0self.n_head = config.n_head
self.n_embd = config.n_embd
self.head_dim = config.n_embd // config.n_head
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=config.bias)
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=config.bias)
self.c_proj.NANOGPT_SCALE_INIT = 1# flag for scaled init# causal mask: (1, 1, block_size, block_size)self.register_buffer(
"mask",
torch.tril(torch.ones(config.block_size, config.block_size))
.view(1, 1, config.block_size, config.block_size)
)
defforward(self, x):
B, T, C = x.shape # batch, seq_len, embedding_dim
qkv = self.c_attn(x) # (B, T, 3*C)
q, k, v = qkv.split(self.n_embd, dim=2)
# reshape to (B, n_head, T, head_dim)
q = q.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
k = k.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
v = v.view(B, T, self.n_head, self.head_dim).transpose(1, 2)
# scaled dot-product attention with causal mask
att = (q @ k.transpose(-2, -1)) * (1.0 / math.sqrt(self.head_dim))
att = att.masked_fill(self.mask[:, :, :T, :T] == 0, float('-inf'))
att = F.softmax(att, dim=-1)
y = att @ v # (B, n_head, T, head_dim)# reassemble all head outputs
y = y.transpose(1, 2).contiguous().view(B, T, C)
y = self.c_proj(y)
return y
classMLP(nn.Module):
def__init__(self, config):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4 * config.n_embd, bias=config.bias)
self.c_proj = nn.Linear(4 * config.n_embd, config.n_embd, bias=config.bias)
self.c_proj.NANOGPT_SCALE_INIT = 1defforward(self, x):
x = self.c_fc(x)
x = F.gelu(x, approximate='tanh') # exact GELU is default, tanh approx for GPT-2 compat
x = self.c_proj(x)
return x
classTransformerBlock(nn.Module):
def__init__(self, config):
super().__init__()
self.ln_1 = nn.LayerNorm(config.n_embd)
self.attn = CausalSelfAttention(config)
self.ln_2 = nn.LayerNorm(config.n_embd)
self.mlp = MLP(config)
defforward(self, x):
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x
Mental Model
Residual Stream as Gradient Highway
Think of the residual stream as a gradient highway: at initialization, the model is essentially an identity function, and each block learns to make small additive modifications. This is why deep transformers (100+ layers) can train without vanishing gradients.
📊 Production Insight
Always use pre-norm (LayerNorm before sub-layer) for GPT-style models. Post-norm (original Transformer) leads to training instability at depth. Also, initialize the output projection of each sub-layer with a small std (e.g., 0.02 / sqrt(2 * num_layers)) to keep activations from exploding in deep stacks.
🎯 Key Takeaway
The transformer block combines multi-head causal self-attention, a position-wise MLP, pre-layer normalization, and residual connections. This design enables stable training of deep models by preserving gradient flow and normalizing activations before each sub-layer.
The Full GPT Model: Stacking Blocks and the Language Modeling Head
The full GPT model is a stack of N transformer blocks (typically 12 for GPT-2 small, 24 for medium, 36 for large, 48 for XL) followed by a language modeling head. The input pipeline starts with token embeddings and position embeddings, which are summed to produce the initial hidden state. There is no segment embedding (unlike BERT) because GPT is a unidirectional decoder-only model. The token embeddings are a learned lookup table of size vocab_size × n_embd, and the position embeddings are a learned lookup table of size block_size × n_embd.
After the embedding layer, the hidden state passes through the stack of transformer blocks. Each block maintains the same dimensionality (n_embd) throughout. After the final block, a layer normalization is applied, followed by a linear projection (the LM head) that maps from n_embd to vocab_size. This produces logits of shape (batch, seq_len, vocab_size). During training, we compute cross-entropy loss between these logits and the target tokens (shifted by one position). During inference, we sample from the logits to generate the next token.
The weight tying trick, popularized by the original Transformer paper and used in GPT-2, shares the weight matrix between the token embedding layer and the LM head. This reduces the number of parameters by vocab_size × n_embd (e.g., ~38M for GPT-2 small with vocab_size=50257 and n_embd=768). The shared weights are typically scaled by sqrt(n_embd) in the embedding layer to keep the variance of the summed embeddings consistent with the residual stream.
The model also includes dropout layers for regularization. In the original GPT-2, dropout is applied to the embedding layer (with rate 0.1) and to the output of each attention sub-layer (also 0.1). However, many modern implementations (including nanoGPT) set dropout to 0 during pretraining on large datasets, as the regularization from large-scale data is sufficient. Dropout is more commonly used during fine-tuning on smaller datasets.
For the GPT-2 124M parameter configuration, the architecture is: vocab_size=50257, block_size=1024, n_embd=768, n_head=12, n_layer=12, bias=True. The total parameter count is approximately 124M, which includes embeddings (50257 × 768 ≈ 38.6M), transformer blocks (12 × (attention: 4 × 768² + MLP: 2 × 768 × 3072) ≈ 85M), and layer norms (12 × 2 × 768 × 2 ≈ 36K). The bias terms add a small fraction.
Weight tying between the embedding layer and the LM head saves ~38M parameters for GPT-2 small. This is a free lunch: it reduces memory and often improves perplexity because the model learns a consistent representation for each token in both input and output spaces.
📊 Production Insight
When loading pretrained GPT-2 weights, be careful with the bias parameter. OpenAI's GPT-2 uses bias=True in all linear layers, but many reimplementations default to bias=False. Also, the GELU activation in GPT-2 uses the tanh approximation, not the exact erf version. Use F.gelu(..., approximate='tanh') for exact compatibility.
🎯 Key Takeaway
GPT is a decoder-only transformer with token + position embeddings, a stack of N transformer blocks, final layer norm, and a tied LM head. The architecture is simple but scales: from 124M to 1.5B parameters, the only change is increasing n_layer, n_embd, and n_head proportionally.
Training Loop: Data Loading, Loss Computation, Backpropagation, and Optimization
The training loop for GPT follows the standard autoregressive language modeling setup. Data is preprocessed into a flat array of token IDs (typically using tiktoken for BPE or a simple character-level encoding for small experiments). The data loader samples random contiguous chunks of length block_size from this array, creating input-target pairs where the target is the input shifted by one position. For example, if the input sequence is [t0, t1, ..., t_{n-1}], the target is [t1, t2, ..., t_n]. This is implemented efficiently by memory-mapping the token array and using random offsets to avoid loading the entire dataset into memory.
Loss computation uses cross-entropy between the predicted logits and the target tokens. The loss is averaged over all non-padding tokens (padding tokens are masked with ignore_index=-1 in the cross-entropy function). For a batch of B sequences of length T, the loss is: L = -1/(BT) Σ_b Σ_t log P(t_{b,t+1} | t_{b,≤t}). This is equivalent to the negative log-likelihood of the next token given all previous tokens. The perplexity, often reported as a metric, is exp(L).
Backpropagation computes gradients of the loss with respect to all model parameters. The AdamW optimizer is the standard choice for training GPTs. AdamW decouples weight decay from the adaptive learning rate, applying L2 regularization only to the weights (not biases or layer norms). The typical configuration is: learning_rate=3e-4 for 124M model, β1=0.9, β2=0.95, weight_decay=0.1, and epsilon=1e-8. A cosine learning rate schedule with linear warmup is used: the learning rate linearly increases from 0 to max_lr over the first few thousand steps (e.g., 2000), then follows a cosine decay to a minimum value (typically 10% of max_lr).
Gradient clipping is essential to prevent exploding gradients. The typical threshold is max_grad_norm=1.0. After computing gradients via loss.backward(), we call torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm). This scales down gradients whose L2 norm exceeds the threshold, ensuring stable training. Without clipping, a single outlier batch can destabilize the entire training run.
The evaluation loop runs periodically (e.g., every 1000 iterations) on a held-out validation set. It computes the loss without gradient computation (torch.no_grad()) and reports validation loss/perplexity. This is used for checkpoint selection: we save the model whenever validation loss improves. The training loop also logs metrics (loss, learning rate, gradient norm) to a dashboard like Weights & Biases or TensorBoard for monitoring.
io/thecodeforge/gpt/train.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import torch
import torch.nn as nn
from torch.utils.data importDataset, DataLoaderimport numpy as np
import math
import os
classTokenDataset(Dataset):
def__init__(self, data_path, block_size):
self.data = np.memmap(data_path, dtype=np.uint16, mode='r')
self.block_size = block_size
def__len__(self):
returnlen(self.data) - self.block_size
def__getitem__(self, idx):
chunk = self.data[idx:idx + self.block_size + 1]
x = torch.from_numpy(chunk[:-1].astype(np.int64))
y = torch.from_numpy(chunk[1:].astype(np.int64))
return x, y
defget_batch(dataloader):
returnnext(iter(dataloader))
deftrain_step(model, optimizer, scheduler, scaler, x, y, max_grad_norm=1.0):
model.train()
optimizer.zero_grad()
with torch.amp.autocast(device_type='cuda', dtype=torch.float16):
logits, loss = model(x, targets=y)
scaler.scale(loss).backward()
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
scaler.step(optimizer)
scaler.update()
scheduler.step()
return loss.item()
@torch.no_grad()
defevaluate(model, dataloader, num_batches=100):
model.eval()
total_loss = 0.0for i, (x, y) inenumerate(dataloader):
if i >= num_batches:
break
x, y = x.cuda(), y.cuda()
logits, loss = model(x, targets=y)
total_loss += loss.item()
return total_loss / min(num_batches, len(dataloader))
# Example training loop (simplified)deftrain(model, train_loader, val_loader, config):
optimizer = torch.optim.AdamW(
model.parameters(),
lr=config.learning_rate,
betas=(0.9, 0.95),
weight_decay=0.1
)
# Cosine schedule with warmupdeflr_lambda(step):
warmup_steps = config.warmup_steps
if step < warmup_steps:
return step / warmup_steps
else:
progress = (step - warmup_steps) / (config.max_steps - warmup_steps)
return0.5 * (1.0 + math.cos(math.pi * progress))
scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
scaler = torch.cuda.amp.GradScaler()
best_val_loss = float('inf')
for step inrange(config.max_steps):
x, y = get_batch(train_loader)
x, y = x.cuda(), y.cuda()
loss = train_step(model, optimizer, scheduler, scaler, x, y)
if step % config.eval_interval == 0:
val_loss = evaluate(model, val_loader)
print(f"Step {step}: train_loss={loss:.4f}, val_loss={val_loss:.4f}, lr={scheduler.get_last_lr()[0]:.6f}")
if val_loss < best_val_loss:
best_val_loss = val_loss
torch.save(model.state_dict(), os.path.join(config.out_dir, 'best_model.pt'))
Never skip gradient clipping when training GPTs from scratch. A single batch with an outlier can push your model into a bad region of the loss landscape from which it may never recover. Clip at max_grad_norm=1.0 as a starting point.
📊 Production Insight
Use memory-mapped numpy arrays for large datasets (e.g., OpenWebText at ~17GB). Loading the entire dataset into RAM is wasteful and often impossible. The memmap approach allows random access to any position without loading the whole file. Also, use uint16 for token IDs (vocab_size ≤ 65535) to halve memory usage compared to int64.
🎯 Key Takeaway
The training loop samples contiguous chunks from a memory-mapped token array, computes cross-entropy loss on next-token prediction, backpropagates with gradient clipping, and uses AdamW with cosine LR schedule and linear warmup. Validation loss determines checkpoint selection.
thecodeforge.io
Gpt From Scratch
Production Considerations: Mixed Precision, Gradient Accumulation, Checkpointing, and Debugging
Mixed precision training (FP16 or BF16) is essential for training large GPT models efficiently. Modern GPUs (A100, H100) have dedicated Tensor Cores that provide 2-4x throughput for FP16/BF16 operations compared to FP32. The standard approach uses torch.cuda.amp (automatic mixed precision) with a GradScaler to prevent underflow in the loss during backpropagation. The scaler multiplies the loss by a scale factor before backward, then divides the gradients by the same factor after. If gradients overflow (become inf/nan), the scaler skips the step and reduces the scale. BF16 is preferred over FP16 when available because it has the same exponent range as FP32, eliminating the need for loss scaling in many cases.
Gradient accumulation allows training with effective batch sizes larger than what fits in GPU memory. Instead of computing the gradient over one large batch, we accumulate gradients over multiple micro-batches. For example, to achieve an effective batch size of 512 with micro-batch size 16, we accumulate gradients over 32 steps. The loss for each micro-batch is divided by the number of accumulation steps to keep the gradient magnitude consistent. This is implemented by calling loss.backward() on each micro-batch without zeroing gradients, then calling optimizer.step() after the accumulation is complete. Gradient accumulation is transparent to the optimizer: it sees the sum of gradients, which is equivalent to the gradient of the full batch.
Checkpointing strategy is critical for long training runs (days to weeks). Save checkpoints at regular intervals (e.g., every 1000 steps) and always keep the best model based on validation loss. A checkpoint should include: model state_dict, optimizer state_dict, scheduler state_dict, current step, and best validation loss. This allows resuming training from any checkpoint. Use a naming convention like 'ckpt_{step}_{val_loss:.4f}.pt' and implement a retention policy (e.g., keep last 5 checkpoints plus best). For distributed training, only save from rank 0 to avoid file corruption.
Debugging training issues requires systematic monitoring. Log these metrics every step: training loss, learning rate, gradient norm, and scale factor (for AMP). Watch for these red flags: (1) Loss not decreasing after 1000 steps → check learning rate, data loading, or model initialization. (2) Gradient norm suddenly spiking to >10 → reduce learning rate or increase gradient clipping. (3) Loss going to NaN → check for numerical instability in attention (use torch.nn.functional.scaled_dot_product_attention which is numerically stable), or reduce learning rate. (4) Validation loss diverging from training loss → overfitting; increase dropout or reduce model size.
For distributed training across multiple GPUs, use PyTorch's DistributedDataParallel (DDP). The key is to split the batch across GPUs and synchronize gradients during backward. With gradient accumulation, each GPU processes micro-batches independently and gradients are synchronized only at the optimizer step. The effective batch size is micro_batch_size × gradient_accumulation_steps × num_gpus. For example, with micro_batch_size=8, accumulation_steps=4, and 8 GPUs, the effective batch size is 256. DDP adds communication overhead, but for models up to 1.5B parameters on 8 GPUs, the overhead is negligible compared to compute time.
Best model saved at step 10000 with val_loss=2.6389
💡BF16 > FP16 for Training
If your GPU supports BF16 (A100, H100, RTX 3090+), use it. BF16 has the same dynamic range as FP32, eliminating the need for loss scaling and reducing the risk of overflow/underflow. FP16 requires careful tuning of the GradScaler and is more prone to numerical issues.
📊 Production Insight
Always validate your gradient accumulation implementation by comparing loss curves with and without accumulation for a few steps. A common bug is forgetting to divide the loss by accumulation_steps, which results in gradients that are N times too large. Also, use torch.cuda.amp.GradScaler even with BF16 if you want to be safe, though it's technically unnecessary.
🎯 Key Takeaway
Production training requires mixed precision (BF16 preferred), gradient accumulation for large effective batch sizes, systematic checkpointing with best-model tracking, and careful monitoring of loss, gradient norm, and learning rate. Distributed training with DDP scales linearly with GPU count for models up to 1.5B parameters.
● Production incidentPOST-MORTEMseverity: high
The Silent Divergence: Training a 1.3B GPT-2 on a Single Node
Symptom
Loss decreased normally for the first 2 days, then plateaued and slowly increased. Generated text became repetitive and nonsensical.
Assumption
The training was stable because the loss was decreasing initially. The issue must be a bug in the data pipeline or a learning rate schedule problem.
Root cause
Layer normalization was implemented with a small epsilon (1e-5) that caused numerical instability in float16 mixed precision. After many iterations, the variance became zero for some channels, leading to NaN gradients.
Fix
Increased epsilon to 1e-3 and added gradient clipping. Also switched to using PyTorch's built-in nn.LayerNorm which handles edge cases robustly.
Key lesson
Always validate numerical stability with mixed precision training by monitoring for NaNs in activations and gradients.
Use built-in PyTorch layers when possible; they are proven for edge cases.
Implement early stopping on NaN detection to avoid wasting compute on corrupted runs.
Production debug guideSystematic approach to diagnose and fix common training failures4 entries
Symptom · 01
Loss is NaN after a few iterations
→
Fix
Check for division by zero in attention softmax (masking issue). Verify learning rate is not too high. Inspect gradient norms for explosion. Add gradient clipping and reduce LR.
Symptom · 02
Loss decreases but generated text is repetitive
→
Fix
Check if the model is overfitting: increase dropout, reduce model size, or add more data. Also verify the temperature and top-k sampling parameters during generation.
Symptom · 03
Training is very slow (low GPU utilization)
→
Fix
Profile data loading: ensure DataLoader uses num_workers > 0 and prefetch_factor. Check if model is too small for the GPU (underutilization). Use mixed precision and gradient accumulation to increase batch size.
Symptom · 04
Validation loss is much higher than training loss
→
Fix
Indicates overfitting. Add regularization (dropout, weight decay), reduce model capacity, or increase dataset size. Also check for data leakage between train and validation sets.
★ GPT Training Debug Cheat SheetImmediate actions for common training issues
Loss is NaN−
Immediate action
Stop training. Check for NaN in model parameters.
Commands
torch.isnan(model.parameters()).any()
torch.autograd.set_detect_anomaly(True)
Fix now
Reduce learning rate by 10x, add gradient clipping (max_norm=1.0), and ensure attention mask is correct.
Loss not decreasing+
Immediate action
Check if model is learning on a single batch.
Commands
overfit_single_batch(model, x, y, optimizer)
print(loss.item())
Fix now
Verify data pipeline: ensure tokenization is correct and labels are shifted. Increase learning rate or adjust optimizer (AdamW with betas=(0.9, 0.95)).
GPU out of memory+
Immediate action
Reduce batch size or sequence length.
Commands
torch.cuda.empty_cache()
nvidia-smi
Fix now
Use gradient accumulation to simulate larger batch size. Enable mixed precision (torch.cuda.amp). Reduce model size (n_layer, n_embd).
GPT Implementation Comparison
Feature
nanoGPT
minGPT
Custom (This Guide)
Codebase size
~300 lines model.py
~500 lines
~400 lines
Weight loading
GPT-2 from OpenAI
GPT-2/GPT-3
Manual init
Training loop
train.py with config
Jupyter notebook
Modular script
Optimization
Gradient accumulation, mixed precision
Basic
Gradient checkpointing
Tokenization
tiktoken BPE
tiktoken BPE
tiktoken BPE
Data loading
Memory-mapped .bin files
In-memory
Memory-mapped + streaming
⚙ Quick Reference
7 commands from this guide
File
Command / Code
Purpose
iothecodeforgegpt_from_scratchverify_setup.py
print(f'PyTorch {torch.__version__}, CUDA {torch.version.cuda}')
Introduction
iothecodeforgegpt_from_scratchembedding.py
class GPTEmbeddings(nn.Module):
Embedding Layer
iothecodeforgegpt_from_scratchattention.py
class CausalSelfAttention(nn.Module):
Multi-Head Self-Attention
iothecodeforgegpttransformer_block.py
class CausalSelfAttention(nn.Module):
Transformer Block
iothecodeforgegptmodel.py
from .transformer_block import TransformerBlock
The Full GPT Model
iothecodeforgegpttrain.py
from torch.utils.data import Dataset, DataLoader
Training Loop
iothecodeforgegptproduction_train.py
from torch.nn.parallel import DistributedDataParallel as DDP
Production Considerations
Key takeaways
1
GPT architecture is a stack of decoder-only transformer blocks with causal masking.
2
Multi-head self-attention computes weighted sums of value vectors based on query-key dot products.
3
Training requires careful hyperparameter tuning
learning rate, batch size, gradient clipping.
4
Memory optimization
gradient checkpointing, mixed precision, and efficient data loading are critical for scaling.
5
Debugging training
monitor loss curves, gradient norms, and activation statistics to catch divergence early.
Common mistakes to avoid
4 patterns
×
Forgetting causal masking in self-attention
Symptom
Model achieves unreasonably low loss quickly, but generates incoherent text because it can see future tokens.
Fix
Ensure attention mask is upper triangular with -inf in the upper triangle (or use PyTorch's nn.Transformer with is_causal=True).
×
Incorrect weight initialization
Symptom
Training loss starts very high and doesn't decrease, or gradients explode/ vanish.
Fix
Use small random initialization for embeddings and linear layers. NanoGPT uses a normal distribution with std=0.02 for embeddings and a scaled initialization for linear layers.
×
Not using gradient clipping
Symptom
Loss spikes or diverges after a few iterations, especially with large models.
Fix
Clip gradients to a max norm (e.g., 1.0) using torch.nn.utils.clip_grad_norm_.
×
Overfitting on small datasets
Symptom
Training loss near zero but validation loss high; generated text repeats phrases.
Fix
Increase dropout, reduce model size, or use data augmentation. For character-level models, ensure dataset is large enough (e.g., Shakespeare works with ~1MB).
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain the multi-head self-attention mechanism in GPT. How does it diff...
Q02SENIOR
How would you debug a GPT training run where the loss is not decreasing ...
Q03JUNIOR
What is the role of layer normalization in GPT? Where is it applied?
Q01 of 03SENIOR
Explain the multi-head self-attention mechanism in GPT. How does it differ from the original transformer?
ANSWER
Multi-head self-attention computes scaled dot-product attention multiple times in parallel with different learned linear projections. Each head produces a weighted sum of value vectors based on query-key similarities. In GPT, the attention is causal: each position can only attend to previous positions (including itself). This is enforced by a triangular mask that sets attention scores for future tokens to -inf before softmax. Unlike the original transformer, GPT has no encoder-decoder cross-attention; it only uses self-attention within the decoder stack.
Q02 of 03SENIOR
How would you debug a GPT training run where the loss is not decreasing after the first 1000 iterations?
ANSWER
First, check if the loss is stuck at a high value (e.g., near the theoretical maximum for the vocabulary size). If so, verify the data pipeline: ensure tokenization is correct and the model is not seeing all zeros. Next, inspect gradient norms: if they are near zero, the model may have dead activations (e.g., from saturated tanh or ReLU). Use gradient clipping to prevent explosion. Check learning rate: too high can cause divergence, too low can stall. Also verify the attention mask is causal. Finally, reduce the model to a tiny version (e.g., 2 layers, 2 heads) and overfit a single batch to ensure the model can learn.
Q03 of 03JUNIOR
What is the role of layer normalization in GPT? Where is it applied?
ANSWER
Layer normalization stabilizes training by normalizing activations across the feature dimension (not batch dimension). In GPT, it is applied before each sub-layer (pre-norm) in the transformer block: first before multi-head attention, then before the feed-forward network. This differs from the original 'post-norm' placement. Pre-norm has been shown to improve training stability and allows deeper models. The normalization is followed by learnable affine parameters (scale and shift).
01
Explain the multi-head self-attention mechanism in GPT. How does it differ from the original transformer?
SENIOR
02
How would you debug a GPT training run where the loss is not decreasing after the first 1000 iterations?
SENIOR
03
What is the role of layer normalization in GPT? Where is it applied?
JUNIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What is the difference between GPT and a standard transformer?
GPT uses only the decoder stack of the original transformer, with causal masking in self-attention to prevent attending to future tokens. It does not have an encoder-decoder cross-attention layer. The model is trained autoregressively to predict the next token.
Was this helpful?
02
How long does it take to train a GPT from scratch?
Training a 124M parameter GPT-2 on OpenWebText takes about 4 days on a single 8xA100 40GB node using nanoGPT. Smaller models (e.g., character-level Shakespeare) can train in minutes on a single GPU. Training time scales with model size, dataset size, and hardware.
Was this helpful?
03
Can I fine-tune a pretrained GPT-2 on my own data?
Yes. NanoGPT supports loading OpenAI's GPT-2 weights (124M, 355M, 774M, 1.3B) and fine-tuning on custom datasets. You need to tokenize your data using the same BPE tokenizer (tiktoken) and adjust the training script to point to your data files.
Was this helpful?
04
What are the key hyperparameters to tune for GPT training?
Critical hyperparameters include: block_size (context length), n_layer (depth), n_head (attention heads), n_embd (embedding dimension), learning rate (typically 3e-4 for AdamW), batch size, gradient accumulation steps, and weight decay. Learning rate warmup and cosine decay are standard.