Build an LLM from scratch
Tiny GPT / mini-LLM end-to-end
Assemble tokenizer + transformer blocks into a tiny GPT
- Tokenization (browse)
- Self-attention (browse)
- Alignment basics — RLHF and DPO (browse)
- Scaling laws and compute (browse)
Learning objectives
- Assemble tokenizer + transformer blocks into a tiny GPT
- Train on CPU or Colab and generate samples
- Document hyperparameters and hardware used
Milestone centerpiece: your mini-LLM
This lesson is the capstone of the language-modeling module. You assemble tokenizer + embedding + stacked transformer blocks + LM head into a trainable GPT-style model, run a real training loop, and generate text from your own checkpoint.
You are not reproducing ChatGPT. You are building the same software shape at a scale that runs on a laptop or free Colab GPU: train.py, model.py, sample.py, checkpoints, samples, README. That shape is what teams ship — scaled up with more data, layers, and compute.
Callout — ship the pipeline, not just the math: Hiring managers and future you care that the repo runs. A tiny model with clean code beats a large notebook that only worked once.
GPT architecture (decoder-only transformer)
GPT is a decoder-only stack: causal self-attention blocks with no encoder cross-attention. Each block:
x = x + MultiHeadAttention(LayerNorm(x), causal_mask=True)
x = x + MLP(LayerNorm(x)) # two linear layers with GELU, expand 4x typicalFull model:
token_ids → TokenEmbedding + PositionEmbedding
→ N × TransformerBlock
→ LayerNorm
→ Linear (lm_head) → logits over vocabWeight tying: Often share weights between token embedding and lm_head — reduces parameters and can improve small models. Optional for your tiny GPT.
Position embeddings
Attention is permutation-invariant without position info. GPT-2 uses learned absolute position embeddings added to token embeddings, one vector per position up to block_size.
Alternative in modern models: RoPE (rotary positional embeddings). For your first GPT, learned positions match Karpathy's nanoGPT and are simpler to implement.
Hyperparameters for a trainable tiny model
Start here if you have no GPU; scale slightly if you do:
| Parameter | CPU-friendly | Notes |
|---|---|---|
block_size |
64–128 | Max context; drives attention O(T²) |
n_layer |
4–6 | Depth |
n_head |
4 | Must divide n_embd |
n_embd |
128–256 | Model width |
vocab_size |
8k–16k | From your BPE tokenizer |
batch_size |
16–64 | Lower if OOM |
lr |
3e-4 | AdamW; cosine decay optional |
max_iters |
5k–20k | Until val loss plateaus |
Parameter count might land in 1–10M range — small enough for CPU overnight training on a modest corpus (1–10 MB text).
Corpus suggestions: tiny Shakespeare, TinyStories subset, or your own markdown collection. Domain-specific corpora (your notes, project READMEs) produce fun personalized samples.
Training loop: same harness, new model
Reuse the training patterns from the foundations module:
- Load tokenized corpus as flat integer array or memmap.
- Sample random contiguous chunks of length
block_size + 1(inputs = first T, targets = shifted by 1). - Forward → cross-entropy over vocab → backward.
- Log train/val loss; checkpoint best val.
# One training step (conceptual)
xb, yb = get_batch() # (B, T) each
logits = model(xb) # (B, T, vocab)
loss = F.cross_entropy(logits.view(-1, vocab), yb.view(-1))Val loss on language modeling is meaningful — compare runs with identical val holdout. Target: val loss clearly below untrained baseline (random ≈ ln(vocab_size)).
Hardware notes
- CPU: Set small model; expect hours. Use
--max_iterssmoke tests first. - Colab free GPU: Move model and batches to CUDA; increase batch or model modestly.
- Apple MPS:
device = "mps"often works; fall back to CPU on ops errors.
Document exactly what you used in README — reviewers want honest compute stories.
Generation after training
Load best checkpoint; run autoregressive sampling (lesson 3.6 goes deeper on decoding):
model.eval()
context = torch.tensor([[start_token_id]], device=device)
for _ in range(max_new_tokens):
logits = model(context[:, -block_size:])
next_logits = logits[0, -1, :] / temperature
probs = F.softmax(next_logits, dim=-1)
next_id = torch.multinomial(probs, 1)
context = torch.cat([context, next_id], dim=1)Decode with your BPE decoder. Save 3–5 samples of 300+ tokens each in samples/ with prompts noted.
Good samples on tiny models: local style matching, some valid words/phrases, thematic vocabulary from corpus. Bad signs: infinite repetition (fix with temperature/top-k), Unicode garbage (tokenizer bug), unchanged loss (broken mask or labels).
Debugging checklist
| Symptom | Likely cause |
|---|---|
| Loss NaN | LR too high; no grad clip |
| Loss flat at ln(V) | Labels misaligned; mask wrong |
| OOM | Reduce batch, block_size, or model |
| Gibberish decode | Tokenizer mismatch train vs sample |
| Perfect train, bad samples | Need more iters or better decoding |
Run a 10-batch overfit test on 1 batch — loss should approach zero. If not, bug in model or data.
Repo layout
m3/mini_llm/
train.py
model.py
sample.py
config.yaml
checkpoints/best.pt
samples/
README.mdREADME must include: architecture summary, hyperparameters, final val loss, hardware, commands to train and sample.
Engineering problem (staff framing)
Wire tokenizer→embeddings→blocks→LM head→loss→sample into one trainable GPT. Integration bugs dominate.
Diagram — Tiny GPT data path
flowchart TD
Tok[Tokens] --> Emb[Tok + Pos emb]
Emb --> Blk[N × Transformer blocks]
Blk --> Head[LM head]
Head --> CE[CE loss / sample]
Precise definitions & mental model
Positional encodings, residual stream, weight tying optional, context length limit.
Tradeoffs — when to use what
Tiny GPT teaches algorithms; scale teaches emergence — do not conflate.
Failure modes (interview + on-call)
Pos emb longer than train context at inference; untied vocab mismatch; eval in train mode.
Production & OSS practices
Config dataclass + seed + checkpoint schema; compare curves to known nanoGPT-style baselines.
Deep dive (FAANG / OSS bar)
Minimal config surface (treat as API)
Pin these in a dataclass and log them into every checkpoint:
n_layer, n_head, d_model, d_ff, vocab_size, context_len, dropout, lr, batch_size, seed
Gradient sanity checklist
- Loss on random init ≈
ln(vocab_size). - Overfit 1–2 batches to ~0 loss (proves capacity + labeling).
- Then generalize on held-out; if you cannot overfit, debug before scaling data.
Diagram — train vs generate modes
flowchart TD
subgraph Train
Batches --> Fwd_train[Forward parallel]
Fwd_train --> CE[CE loss]
end
subgraph Generate
Prompt --> Loop[Sample one token]
Loop --> Append --> Loop
end
Micro-project: Train on CPU/Colab; generate text
Assemble and ship the full mini-LLM:
- Wire tokenizer vocab into model
vocab_size. - Implement GPT with at least 4 layers; causal mask verified.
- Train until val loss plateaus; save best checkpoint.
- Generate ≥3 samples; commit to repo.
- Document hyperparameters, iters, wall time, and device in README.
This folder is the milestone artifact — polish matters. Future modules reference this checkpoint for sampling ablations and honest limits essays.
Optional stretch: integrate Weights & Biases; export loss curve; share one sample in module README.
Checklist
- End-to-end train + sample scripts run from README commands
- Best checkpoint committed or reproducible within stated iters
- Val loss logged and reasonable vs random baseline
- Samples committed with prompts and decoding settings noted
- Hardware and hyperparameters documented
ShipAI delivery model is: