hands-on labon a real NVIDIA GPU

Train a Small Language Model from Scratch

Train a real GPT-style language model from zero on TinyStories: tokenize, wire up the optimizer and LR schedule, run the training loop with validation perplexity, and generate coherent text from your own weights.

time
55 min
checked steps
4
level
Advanced
setup
None
NCP-GENLNCP-ADSNCA-GENLPart of the AI Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit55 min · 4 stepsSession running
3 / 4 steps passingGenerate + checkpoint · step 4 of 4
lab.ipynb

Step 4 — Generate + checkpoint

Two last production-critical capabilities:

[ ]:
@torch.no_grad()def generate(model, prompt, max_new=80, temperature=0.8, top_k=40):    model.eval()    ids = torch.tensor([tokenizer.encode(prompt)], dtype=torch.long, device=device)    for _ in range(max_new):        # Crop to model's max context        idx_cond = ids[:, -model.max_len:]        logits, _ = model(idx_cond)        logits = logits[:, -1, :] / temperature        # Top-k filter: keep only the k most likely tokens, zero out the rest        if top_k is not None:            v, _ = torch.topk(logits, top_k)            logits[logits < v[:, [-1]]] = -float('inf')        probs = F.softmax(logits, dim=-1)        next_id = torch.multinomial(probs, num_samples=1)        ids = torch.cat([ids, next_id], dim=1)    return tokenizer.decode(ids[0].tolist()) prompt = 'Once upon a time, there was' # Untrained baseline for comparison (we need a fresh model — our `model` is already trained)fresh = SmallGPT(vocab_size=tokenizer.vocab_size, d_model=128, n_heads=4, n_layers=4, max_len=128).to(device)story_before = generate(fresh, prompt, max_new=60, temperature=1.0, top_k=None)del fresh story_after = generate(model, prompt, max_new=60) print('UNTRAINED (random weights):')print(f'  {story_before!r}')print()print('TRAINED:')print(f'  {story_after!r}')
[ ]:
# Checkpoint round-trip — save, clear, reload, verify output matchesCKPT = '/tmp/slm_checkpoint.pt'torch.save({    'model_state_dict': model.state_dict(),    'config': {'vocab_size': tokenizer.vocab_size, 'd_model': 128, 'n_heads': 4, 'n_layers': 4, 'max_len': 128},    'train_step': TOTAL_STEPS,    'final_val_loss': val_losses[-1],}, CKPT)print(f'Saved to {CKPT}') # Load into a fresh instance and confirm identical outputckpt = torch.load(CKPT, weights_only=True)cfg = ckpt['config']reloaded = SmallGPT(**cfg).to(device)reloaded.load_state_dict(ckpt['model_state_dict'])reloaded.eval() # Deterministic sanity check — same seed, same outputtorch.manual_seed(42)ids = torch.tensor([tokenizer.encode('The')], dtype=torch.long, device=device)orig_logits, _ = model(ids)reload_logits, _ = reloaded(ids)checkpoint_ok = torch.allclose(orig_logits, reload_logits, atol=1e-5)print(f'Checkpoint round-trip: {"OK" if checkpoint_ok else "MISMATCH!"} (max abs diff: {(orig_logits-reload_logits).abs().max().item():.2e})')
[ ]:
lab.check(4)
step 4 of 4, replayed: Generate + checkpoint. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1
  2. 2

    Model + optimizer + LR schedule

  3. 3

    Training loop with validation + perplexity

  4. 4

    Generate + checkpoint

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Data pipeline

Lab 16 built the transformer architecture. This lab builds the entire training pipeline around it — real tokenizer, real corpus, LR schedule, validation loop, checkpointing, perplexity tracking, sampling. Everything you'd find in a production training run at 100–1000× this scale.

We're training on TinyStories (Eldan & Li, 2023 — arXiv:2305.07759). The dataset is a breakthrough contribution: ~2M short English stories at a 3–4-year-old's vocabulary level, synthetically generated by GPT-3.5/4. Eldan and Li showed that with this data you can train a 10–30M param model that produces coherent, grammatical, internally-consistent stories — debunking the idea that you need a frontier-scale model for coherent text. The paper kicked off an entire wave of research into "SLM" (Small Language Model) training.

The key insight

At very small scales, model quality is almost entirely bottlenecked by the difficulty of the training data. On web-crawled text, a 10M-param model produces gibberish. On TinyStories (curated simple data), the same 10M-param model produces correct children's stories. Data > parameters at this scale.

This maps to Microsoft's Phi-3 strategy at larger scale: filter/generate high-quality data → train a much smaller model to frontier quality.

References to read while training runs

What you'll build

  1. Data pipeline — stream TinyStories, tokenize with GPT-2 BPE, pack into efficient training tensors, held-out validation split
  2. Model + optimizer — compact GPT (~1.5M params), AdamW, linear warmup → cosine decay LR schedule
  3. Training loop — loss tracking, periodic validation, perplexity reporting
  4. Generation + checkpointing — before/after samples, save + reload the model

Runtime target on RTX 3060 Ti: ~2 min.


Production training does three things in the data layer and we'll mirror all three:

  1. Real tokenizer. Char-level toys (Lab 16) train fast but produce terrible text. Real LMs use BPE — we'll reuse GPT-2's pretrained tokenizer (50,257 vocab). Same tokenizer used by GPT-3, Codex, many Llama variants.
  2. Packed concatenation. Rather than per-story padding, real training concatenates all documents with an EOS token between them and chops into fixed-length sequences. Much less padding waste, ~2× throughput.
  3. Train/val split. Held-out tokens for tracking generalization. ~1% of the corpus is plenty for perplexity tracking.
import torch
import torch.nn as nn
import torch.nn.functional as F
from datasets import load_dataset
from transformers import AutoTokenizer
import time
import math

device = torch.device('cuda')
torch.manual_seed(1337)

# 1. Load a slice of TinyStories. Full set is ~2M stories (~4GB tokenized);
#    we take the first 5000 for fast iteration. Stream to avoid the full download.
print('Loading TinyStories (streaming first 5000 entries)...')
ds = load_dataset('roneneldan/TinyStories', split='train')
stories = []
for i, ex in enumerate(ds):
    if i >= 5000:
        break
    stories.append(ex['text'])

total_chars = sum(len(s) for s in stories)
print(f'Loaded {len(stories):,} stories, {total_chars:,} chars total')
# 2. Tokenize + pack into contiguous token streams
tokenizer = AutoTokenizer.from_pretrained('gpt2')
EOS = tokenizer.eos_token_id
print(f'Vocab: {tokenizer.vocab_size:,}, EOS token id: {EOS}')

# Tokenize each story, concatenate with EOS between them
print('Tokenizing...')
all_ids = []
for s in stories:
    all_ids.extend(tokenizer.encode(s) + [EOS])

all_tokens = torch.tensor(all_ids, dtype=torch.long)

# Held-out val split — last ~1% of the corpus
split = int(0.99 * len(all_tokens))
train_tokens = all_tokens[:split].to(device)
val_tokens = all_tokens[split:].to(device)

print(f'Train: {len(train_tokens):,} tokens')
print(f'Val:   {len(val_tokens):,} tokens')
print(f'Total tokens: {len(all_tokens):,} (vs 20 * params rule-of-thumb for a compute-optimal model)')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Strong PyTorch fundamentals (nn.Module, autograd, training loops)
  • Familiarity with transformer architecture
  • Understanding of tokenization and cross-entropy loss

Exam domains covered

LLM Integration and DevelopmentGPU Acceleration & Distributed TrainingExperimentation

Part of the LLM training and fine-tuning track

Lab 14 of 14. LoRA and QLoRA fine-tuning, DPO alignment, and a tokenizer and tiny GPT built from scratch.

Questions about this lab

Why train a small LM from scratch when Llama 3 exists?

Because the point is to understand the machinery, not to compete with frontier models. A 10M-parameter model on TinyStories is the smallest laboratory that still exhibits real pretraining dynamics: loss decreasing under a warmup-then-cosine schedule, validation perplexity bottoming out, checkpointing round-tripping cleanly, generation moving from token salad to recognisable English. Everything you learn here, sharded optimizer state, mixed precision, gradient accumulation, resumable checkpoints, scales structurally identically to an 8B run. You also get something no pretrained model gives you: full control over the vocab, architecture, and data, which matters when you want to prototype a new attention variant or a new data-mixing strategy.

Why a warmup-then-cosine LR schedule instead of flat or step decay?

Warmup exists because AdamW's running estimates of first and second gradient moments need a few hundred steps to stabilize; slamming the full LR in step 1 causes them to diverge and the loss spikes. Linearly ramping the LR from 0 to peak over ~5-10% of training lets those estimates settle. Cosine decay afterward cools the LR smoothly to near-zero over the remaining steps, which empirically finds a sharper loss minimum than step decay does. The exact shape is borrowed straight from GPT-3 / Chinchilla / Llama pretraining, same curve, just compressed in wall time.

What does the checkpoint_ok round-trip test actually catch?

A surprising amount. It catches missing or mismatched config when you reconstruct the model (different n_layers, different n_heads, different embed_dim → shape mismatches on load). It catches tied-embedding bugs where the output projection was sharing weights with the input embedding and the save/load didn't preserve the tying. It catches dtype mismatches when you accidentally save an fp16 state dict and reload into an fp32 shell. It catches tokenizer drift when you saved the model but forgot to pin the tokenizer version. In production pretraining where a single run spans weeks and multiple node failures, this discipline is the difference between resumable and catastrophic.

Why concatenate all documents into one long token stream instead of per-document batches?

Because variable-length document batches waste a lot of attention computation on padding, and batched random-window sampling from a flat token stream is dramatically simpler. You still mark document boundaries by inserting eos_token_id between them, which teaches the model that sequences terminate, without that token, windows that span document boundaries would incorrectly train the model that the end of one story continues into the next. train_tokens.reshape(-1, seq_len) or random-offset windowing are both common after that concatenation.

Why target 2-20M parameters specifically?

Because it's the sweet spot for this sandbox: small enough that a 100-step loop finishes in a couple of minutes on one GPU, large enough that TinyStories produces recognisable English rather than markov-chain noise, and large enough that the perplexity delta across training (say from 40 to 8) is readable against validation variance. Below ~2M you don't have enough capacity to model TinyStories; above ~50M you start starving for training tokens relative to capacity, and Chinchilla-scaling pressure shows up as the loss curve flattening early.

What would break first if I tried to scale this exact code to 7B parameters?

Memory, in three places. AdamW optimizer state alone is 8 bytes per parameter (two FP32 accumulators), so 7B × 8 = 56 GB just for the optimizer, won't fit on a single consumer GPU. FP32 activations for a single training step are also 30+ GB. Even before those, the weights themselves at FP32 are 28 GB. The fixes are compound: shard the optimizer state with ZeRO / FSDP across multiple GPUs, run the model in bf16 to halve weight and activation memory, enable gradient checkpointing to trade compute for activation memory, and accumulate gradients over multiple micro-batches. The Step 4 reflection walks through memory / numerical / systems failure modes in detail.

What you'll build in this from-scratch SLM pretraining lab

Pretraining an LLM from random weights is the exercise that collapses the mystery of how frontier models actually come into existence. In 55 minutes you'll train a real GPT-style language model on TinyStories until it produces coherent English, starting from pure noise, same phases, same failure modes, same knobs as an 8B or 70B run, just compressed onto a single GPU in minutes instead of spanning a multi-node cluster for weeks. You walk away with a working model whose weights you saved, reloaded, and regenerated from; a visceral feel for why a warmup-then-cosine LR schedule is non-negotiable (AdamW's β2=0.999 second-moment estimate needs hundreds of steps to stabilize, so slamming full LR at step 0 makes loss diverge); and the scaling intuition for what specifically breaks when you extrapolate the same code to 7B, memory (AdamW state alone is 8 bytes/param), numerical (FP32 is too slow, FP16 loses precision at scale), and systems (a week-long run will crash, so resumable checkpointing matters more than the training loop).

The technical substance is the full pretraining anatomy at micro-scale. Data: load TinyStories, tokenize with the GPT-2 BPE tokenizer, concatenate the whole corpus into one 1-D token stream with eos_token_id marking document boundaries, variable-length per-document batches waste attention compute on padding, flat-stream random-window sampling is dramatically simpler. Model: a 2-20M parameter GPT (embed_dim ~128-256, 4-6 layers, 4-8 heads) is the sweet spot, small enough for a 100-step loop to finish in minutes, large enough to actually model TinyStories. Optimizer: AdamW with weight decay plus get_cosine_schedule_with_warmup or a custom LambdaLR, the exact curve GPT-3 and Llama pretraining use, just compressed in wall time. Training loop: sample random (batch, seq_len) windows, cross-entropy on the shifted targets, loss.backward() → optimizer.step() → scheduler.step(), and every N steps freeze-evaluate on the held-out set, reporting perplexity as math.exp(val_loss) (directly interpretable as 'the average effective branching factor per token'). Checkpoint discipline: torch.save(model.state_dict(), path) is necessary but not sufficient, the round-trip test (load into a freshly-constructed model and verify generations still match) catches the mismatched config, wrong vocab size, and missing tied-embedding wiring that silently corrupts real pretraining runs.

Prerequisites are strong PyTorch fundamentals (nn.Module, autograd, training loops), familiarity with transformer architecture (if you haven't built one by hand, the transformer-from-scratch lab is the direct prerequisite), and a working grip on tokenization plus cross-entropy loss. The sandbox is a real NVIDIA GPU pod we provision per session, TinyStories, the GPT-2 tokenizer, and PyTorch are pre-staged. Checks enforce kernel-resident state throughout: train_tokens must be a 1-D tensor of ≥100k tokens with all IDs in vocab; the model must land 0.5M-50M params on CUDA with optimizer and LR schedule present; training must log ≥20 steps with >20% loss drop between first-5 and last-5 averages plus val_losses[-1] < val_losses[0]; and the final sampled story_after must be ≥10 words at ≥60% alphabetic ratio with checkpoint_ok set to True after a real save-reload-regenerate round-trip.