⚙️ LLM Engineering
Build an LLM from Scratch — The Code
Every stage, in Python, step by step. Inspired by Andrej Karpathy's nanoGPT — readable code that you can actually run, with the frameworks, tips, and gotchas that matter.
⚡ Quick Setup — Install Everything
Core ML Stack
pip install torch torchvision torchaudio \
--index-url https://download.pytorch.org/whl/cu121
pip install transformers datasets tokenizers
pip install tiktoken sentencepiece
Training & Alignment
pip install trl peft bitsandbytes accelerate
pip install deepspeed wandb
pip install datatrove lm-eval
Serving
pip install vllm fastapi uvicorn
pip install llama-cpp-python
pip install openai anthropic
karpathy/nanoGPT
Start here. 300 lines of PyTorch that trains a GPT. Read every line before anything else.
karpathy/minbpe
BPE tokenizer in pure Python. The only way to truly understand how tokens are built.
karpathy/llm.c
GPT-2 in pure C/CUDA. Teaches what PyTorch abstracts away — memory, kernels, math.
Lightning-AI/litgpt
Production LLaMA/Gemma/Mistral implementations. Study config systems and model variants.
Stage-by-Stage Code Breakdown
Each of the 8 pipeline stages, with working Python code, frameworks, and key engineering insights.
01
Data Collection — Crawling, Filtering & Deduplication
›
⚙️ Code — Stage 1: Data Collection
Python
Datatrove
requests
BeautifulSoup
fastText
data_collection.pyPython
# Step 1: Download & filter Common Crawl with Datatrove
from datatrove.pipeline.readers import WarcReader
from datatrove.pipeline.filters import LanguageFilter, GopherQualityFilter
from datatrove.pipeline.writers import JsonlWriter
from datatrove.executor import LocalPipelineExecutor
pipeline = LocalPipelineExecutor(pipeline=[
WarcReader("s3://commoncrawl/crawl-data/CC-MAIN-2024-10/segments/"),
LanguageFilter(languages=["en"]), # Keep English only
GopherQualityFilter(min_stop_words=2), # Quality heuristics
JsonlWriter("output/train_data"),
], tasks=100)
pipeline.run()
# Step 2: Custom scraper for domain-specific data
import requests
from bs4 import BeautifulSoup
def scrape_page(url):
soup = BeautifulSoup(requests.get(url).text, "html.parser")
return " ".join(p.get_text() for p in soup.find_all("p"))
🔑 Key: Datatrove processes WARC files from Common Crawl at petabyte scale. For a small experiment, start with the
datasets library — load_dataset("HuggingFaceFW/fineweb", split="train") gives you 15T tokens of already-cleaned web text.02
Tokenization — BPE from Scratch to Binary Shards
›
⚙️ Code — Stage 2: Tokenization
Python
tiktoken
minbpe
sentencepiece
numpy
tokenize_shards.pyPython
import numpy as np
import tiktoken
enc = tiktoken.get_encoding("gpt2") # 50,257 tokens
def tokenize(doc):
tokens = [enc.eot_token] # <|endoftext|> between docs
tokens.extend(enc.encode_ordinary(doc["text"]))
return np.array(tokens, dtype=np.uint16)
# Write 100M-token shards (nanoGPT style)
shard, idx, total = np.empty((100_000_000,), dtype=np.uint16), 0, 0
for doc in ds:
toks = tokenize(doc)
if idx + len(toks) > len(shard):
np.save(f"shard_{total:04d}.npy", shard[:idx]); total += 1; idx = 0
shard[idx:idx+len(toks)] = toks; idx += len(toks)
🔑 Key:
uint16 stores tokens up to 65,535 — enough for GPT-2's 50,257-token vocab. Each 100M-token shard is ~200MB on disk. Memory-map shards with np.load(..., mmap_mode="r") to avoid loading them entirely into RAM.03
Architecture — GPT Model Design & Weight Init
›
⚙️ Code — Stage 3: Architecture
Python
PyTorch
Flash Attention
RoPE
model.pyPython
# CausalSelfAttention with PyTorch 2.0 FlashAttention
class CausalSelfAttention(nn.Module):
def forward(self, x):
B, T, C = x.size()
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
head_dim = C // self.n_head
q = q.view(B, T, self.n_head, head_dim).transpose(1, 2)
k = k.view(B, T, self.n_head, head_dim).transpose(1, 2)
v = v.view(B, T, self.n_head, head_dim).transpose(1, 2)
# Fused FlashAttention — no O(T²) attention matrix stored in memory
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
return self.c_proj(y.transpose(1, 2).contiguous().view(B, T, C))
# Weight tying: token embedding = output projection (halves params)
self.transformer.wte.weight = self.lm_head.weight
# Scale-down residual projections to prevent explosion at depth
for name, p in model.named_parameters():
if 'c_proj' in name:
std = 0.02 * (2 * config.n_layer) ** -0.5
nn.init.normal_(p, mean=0.0, std=std)
🔑 Key:
F.scaled_dot_product_attention(..., is_causal=True) triggers PyTorch's FlashAttention kernel — 2–4× faster and O(T) memory instead of O(T²). No code change needed; it's automatic if inputs are on CUDA.04
Pre-training — Distributed Training Loop
›
⚙️ Code — Stage 4: Pre-training
Python
PyTorch DDP
bfloat16
torch.compile
wandb
train.pyPython
# Run: torchrun --nproc_per_node=8 train.py
from torch.nn.parallel import DistributedDataParallel as DDP
dist.init_process_group(backend="nccl")
rank = dist.get_rank()
model = GPT(GPTConfig()).to(rank)
model = torch.compile(model) # PyTorch 2.0: fuse ops, ~2× faster
model = DDP(model, device_ids=[rank])
# Gradient accumulation (simulate large batch on few GPUs)
grad_accum_steps = total_batch_size // (B * T * world_size)
for micro_step in range(grad_accum_steps):
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
logits, loss = model(x, y)
loss = loss / grad_accum_steps
loss.backward()
optimizer.step(); optimizer.zero_grad()
# Cosine LR schedule with warmup
def get_lr(step):
if step < warmup_steps: return max_lr * step / warmup_steps
if step > max_steps: return min_lr
decay = (step - warmup_steps) / (max_steps - warmup_steps)
return min_lr + 0.5 * (max_lr - min_lr) * (1 + math.cos(math.pi * decay))
🔑 Key:
bfloat16 (not float16) is the right choice — same range as float32 so no loss scaling needed. torch.compile alone gives ~20–30% speedup on A100s by fusing elementwise ops and removing Python overhead.05
Supervised Fine-tuning (SFT) + DPO Alignment
›
⚙️ Code — Stage 5: SFT + DPO
Python
TRL
PEFT
Axolotl
transformers
sft_dpo.pyPython
from trl import SFTTrainer, DPOTrainer, SFTConfig, DPOConfig
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3.1-8B")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")
# Stage 1: SFT on instruction data
sft = SFTTrainer(
model=model,
train_dataset=sft_dataset, # {"prompt": ..., "completion": ...}
args=SFTConfig(max_seq_length=2048, num_train_epochs=3),
)
sft.train()
# Stage 2: DPO on preference data (no reward model needed)
dpo = DPOTrainer(
model=sft.model,
train_dataset=dpo_dataset, # {"prompt", "chosen", "rejected"}
args=DPOConfig(beta=0.1), # beta controls KL penalty strength
)
dpo.train()
# One-liner alternative (Axolotl handles everything above)
# axolotl train config.yaml
🔑 Key: DPO (Direct Preference Optimization) eliminates the need for a separate reward model — it directly optimizes the policy from (chosen, rejected) pairs.
beta=0.1 is the standard starting point; lower = more aggressive preference learning.06
LoRA / QLoRA — Efficient Fine-tuning
›
⚙️ Code — Stage 6: LoRA / QLoRA
Python
PEFT
bitsandbytes
accelerate
qlora_train.pyPython
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from transformers import BitsAndBytesConfig
# QLoRA: load in 4-bit, train LoRA adapters in bf16
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16,
)
model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B", quantization_config=bnb_config
)
model = prepare_model_for_kbit_training(model)
lora_cfg = LoraConfig(
r=16, lora_alpha=32, # rank=16, scale=alpha/r=2
target_modules=["q_proj","v_proj"], # only attention projections
lora_dropout=0.05, bias="none",
)
model = get_peft_model(model, lora_cfg)
model.print_trainable_parameters()
# trainable params: 6,815,744 || all params: 8,036,564,992 || 0.08%
🔑 Key: QLoRA fine-tunes a 7B model on a single 24GB GPU. NF4 (NormalFloat4) is specifically designed for normally-distributed weights — better than int4 for LLMs. Only 0.08% of parameters are trained, but quality is close to full fine-tuning.
07
Evaluation — Benchmarks & Automated Harness
›
⚙️ Code — Stage 7: Evaluation
Python
lm-eval
EleutherAI Harness
HELM
eval.sh + eval_custom.pyPython + Shell
# Run standard benchmarks via lm-evaluation-harness
lm_eval --model hf \
--model_args pretrained=./model \
--tasks hellaswag,arc_challenge,mmlu,truthfulqa \
--num_fewshot 5 \
--output_path ./results.json
# Custom eval: measure exact match on your domain
from lm_eval.models.huggingface import HFLM
evaluator = HFLM(pretrained="./model")
def eval_exact_match(model, tokenizer, pairs):
correct = 0
for prompt, expected in pairs:
out = model.generate(**tokenizer(prompt, return_tensors="pt").to("cuda"),
max_new_tokens=50)
pred = tokenizer.decode(out[0], skip_special_tokens=True)
if expected.lower() in pred.lower(): correct += 1
return correct / len(pairs)
🔑 Key: Always eval at the end of pre-training on HellaSwag and ARC — they're cheap to run and correlate well with overall quality. For fine-tuned models, MT-Bench (GPT-4 judge) is the most informative single number. Never trust training loss alone.
08
Deployment — vLLM Serving & Quantization
›
⚙️ Code — Stage 8: Deployment
Python
vLLM
llama.cpp
FastAPI
GGUF
serve.sh + api.pyShell + Python
# vLLM — OpenAI-compatible server with PagedAttention
vllm serve ./merged-model \
--port 8000 \
--tensor-parallel-size 2 \
--served-model-name my-llm \
--max-model-len 8192
# Convert to GGUF for CPU / edge inference
python llama.cpp/convert_hf_to_gguf.py \
./merged-model --outtype q4_k_m \
--outfile model.gguf
./llama.cpp/llama-server -m model.gguf --port 8080
# Call either server with the OpenAI client
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="-")
resp = client.chat.completions.create(
model="my-llm",
messages=[{"role": "user", "content": "Explain attention in one paragraph."}]
)
print(resp.choices[0].message.content)
🔑 Key: vLLM's PagedAttention manages KV-cache as non-contiguous pages — eliminates memory fragmentation and enables 3–10× higher throughput than naive HuggingFace generation. Use
--tensor-parallel-size to shard across multiple GPUs without code changes.Deep Dive Modules
Four complete, runnable modules covering the most complex engineering parts of LLM building.
01
Datatrove
datasets
Apache Spark
1
Download & Filter Common Crawl
# Fastest path: use pre-cleaned FineWeb dataset (15T tokens)
from datasets import load_dataset
ds = load_dataset("HuggingFaceFW/fineweb", name="sample-10BT", split="train")
# Or raw CC with Datatrove pipeline (see per-stage code above)
2
Tokenize & Write Binary Shards (nanoGPT style)
import numpy as np
import tiktoken
enc = tiktoken.get_encoding("gpt2")
def tokenize(doc):
tokens = [enc.eot_token] # <|endoftext|> between documents
tokens.extend(enc.encode_ordinary(doc["text"]))
return np.array(tokens, dtype=np.uint16)
# Write shards of 100M tokens each
shard, idx, total = np.empty((100_000_000,), dtype=np.uint16), 0, 0
for doc in ds:
toks = tokenize(doc)
if idx + len(toks) > len(shard):
np.save(f"shard_{total:04d}.npy", shard[:idx]); total += 1; idx = 0
shard[idx:idx+len(toks)] = toks; idx += len(toks)
3
DataLoader for Training
import torch, numpy as np, glob
shards = sorted(glob.glob("data/shard_*.npy"))
current_shard = np.load(shards[0], mmap_mode="r") # memory-mapped
def get_batch(split, B=32, T=1024):
buf = torch.from_numpy(current_shard.astype(np.int32))
ix = torch.randint(len(buf) - T, (B,))
x = torch.stack([buf[i:i+T] for i in ix]).long()
y = torch.stack([buf[i+1:i+T+1] for i in ix]).long()
return x.cuda(), y.cuda()
02
minbpe
tiktoken
sentencepiece
Karpathy's minbpe (github.com/karpathy/minbpe) is 200 lines that implement the complete BPE algorithm used in GPT-2/3/4. This is the right way to understand tokenizers — run it on your text and trace every merge.
1
Train a BPE Tokenizer from Scratch
# minbpe BasicTokenizer — full working implementation
class BasicTokenizer:
def train(self, text, vocab_size):
assert vocab_size >= 256
ids = list(text.encode("utf-8"))
merges = {} # (pair) → new_token_id
vocab = {i: bytes([i]) for i in range(256)}
for i in range(vocab_size - 256):
stats = get_stats(ids)
pair = max(stats, key=stats.get)
idx = 256 + i
ids = merge(ids, pair, idx)
merges[pair] = idx
vocab[idx] = vocab[pair[0]] + vocab[pair[1]]
self.merges = merges; self.vocab = vocab
def encode(self, text):
ids = list(text.encode("utf-8"))
while len(ids) >= 2:
stats = get_stats(ids)
pair = min(stats, key=lambda p: self.merges.get(p, float("inf")))
if pair not in self.merges: break
ids = merge(ids, pair, self.merges[pair])
return ids
t = BasicTokenizer()
t.train(open("input.txt").read(), vocab_size=512)
2
Train with HuggingFace tokenizers (faster, production-ready)
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel
tokenizer = Tokenizer(BPE(unk_token="[UNK]"))
tokenizer.pre_tokenizer = ByteLevel()
trainer = BpeTrainer(vocab_size=32_000, special_tokens=["[UNK]", "[BOS]", "[EOS]"])
tokenizer.train(["data.txt"], trainer)
tokenizer.save("my_tokenizer.json")
03
PyTorch
Flash Attention
weight tying
This is the complete GPT model from nanoGPT. ~120 lines. Every frontier LLM is a scaled version of this structure — same blocks, same residual connections, bigger numbers.
from dataclasses import dataclass
import torch, torch.nn as nn, torch.nn.functional as F
@dataclass
class GPTConfig:
block_size: int = 1024 # context window length
vocab_size: int = 50304 # GPT-2 vocab, padded to multiple of 64
n_layer: int = 12 # transformer blocks
n_head: int = 12 # attention heads
n_embd: int = 768 # embedding dim (GPT-2 small)
class CausalSelfAttention(nn.Module):
def __init__(self, config):
super().__init__()
self.n_head = config.n_head; self.n_embd = config.n_embd
self.c_attn = nn.Linear(config.n_embd, 3 * config.n_embd, bias=False)
self.c_proj = nn.Linear(config.n_embd, config.n_embd, bias=False)
self.c_proj.NANOGPT_SCALE_INIT = 1
def forward(self, x):
B, T, C = x.size()
q, k, v = self.c_attn(x).split(self.n_embd, dim=2)
head_dim = C // self.n_head
q = q.view(B,T,self.n_head,head_dim).transpose(1,2)
k = k.view(B,T,self.n_head,head_dim).transpose(1,2)
v = v.view(B,T,self.n_head,head_dim).transpose(1,2)
y = F.scaled_dot_product_attention(q, k, v, is_causal=True)
return self.c_proj(y.transpose(1,2).contiguous().view(B,T,C))
class MLP(nn.Module):
def __init__(self, config):
super().__init__()
self.c_fc = nn.Linear(config.n_embd, 4*config.n_embd, bias=False)
self.c_proj = nn.Linear(4*config.n_embd, config.n_embd, bias=False)
self.gelu = nn.GELU(approximate="tanh")
self.c_proj.NANOGPT_SCALE_INIT = 1
def forward(self, x): return self.c_proj(self.gelu(self.c_fc(x)))
class Block(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)
def forward(self, x):
x = x + self.attn(self.ln_1(x))
x = x + self.mlp(self.ln_2(x))
return x
class GPT(nn.Module):
def __init__(self, config):
super().__init__()
self.config = config
self.transformer = nn.ModuleDict(dict(
wte = nn.Embedding(config.vocab_size, config.n_embd),
wpe = nn.Embedding(config.block_size, config.n_embd),
h = nn.ModuleList([Block(config) for _ in range(config.n_layer)]),
ln_f = nn.LayerNorm(config.n_embd),
))
self.lm_head = nn.Linear(config.n_embd, config.vocab_size, bias=False)
self.transformer.wte.weight = self.lm_head.weight # weight tying
def forward(self, idx, targets=None):
B, T = idx.size()
x = self.transformer.wte(idx) + self.transformer.wpe(torch.arange(T, device=idx.device))
for block in self.transformer.h: x = block(x)
logits = self.lm_head(self.transformer.ln_f(x))
loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1)) if targets is not None else None
return logits, loss
model = GPT(GPTConfig()).cuda()
print(f"Parameters: {sum(p.numel() for p in model.parameters()):,}")
# Parameters: 124,439,808
04
PyTorch DDP
bfloat16
wandb
1
Configure AdamW with weight decay
def configure_optimizers(model, weight_decay, lr):
decay = {n for n,p in model.named_parameters() if p.dim() >= 2}
no_decay = {n for n,p in model.named_parameters() if p.dim() < 2}
param_groups = [
{"params": [p for n,p in model.named_parameters() if n in decay],
"weight_decay": weight_decay},
{"params": [p for n,p in model.named_parameters() if n in no_decay],
"weight_decay": 0.0},
]
return torch.optim.AdamW(param_groups, lr=lr, betas=(0.9,0.95), fused=True)
2
Multi-GPU with DDP + compile for 2× speed
# Run with: torchrun --nproc_per_node=8 train.py
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
dist.init_process_group(backend="nccl")
rank = dist.get_rank()
model = GPT(GPTConfig()).to(rank)
model = torch.compile(model) # fuse ops, ~2× faster training
model = DDP(model, device_ids=[rank])
if rank == 0:
import wandb; wandb.init(project="my-gpt")
3
Checkpoint and resume
# Save checkpoint every 1000 steps
if step % 1000 == 0 and rank == 0:
torch.save({
'step': step, 'model': model.module.state_dict(),
'optimizer': optimizer.state_dict(),
}, f"ckpt_{step:06d}.pt")
# Resume
ckpt = torch.load("ckpt_050000.pt")
model.load_state_dict(ckpt['model'])
optimizer.load_state_dict(ckpt['optimizer'])
start_step = ckpt['step'] + 1
⚡ Stages 5–8: Quick Reference Commands
05 — SFT + DPO
# Full SFT pipeline (Axolotl)
axolotl train config.yaml
# Or TRL SFT + DPO
python sft_train.py \
--model llama3.1-8b
python dpo_train.py \
--beta 0.1
06 — LoRA / QLoRA
# Merge LoRA adapters
python merge_lora.py \
--base meta-llama/Llama-3.1-8B \
--adapter ./lora-output \
--out ./merged-model
huggingface-cli upload \
./merged-model \
my-org/my-llama-3.1-8b
07 — Eval
lm_eval --model hf \
--model_args \
pretrained=./model \
--tasks \
hellaswag,arc_challenge,\
mmlu \
--num_fewshot 5 \
--output_path ./results.json
08 — Serve
vllm serve ./merged-model \
--port 8000 \
--tensor-parallel-size 2
# CPU/edge: GGUF
python convert_hf_to_gguf.py \
./merged-model \
--outtype q4_k_m \
--outfile model.gguf
📚 Essential Reading Order
1
Neural Networks: Zero to Hero (Karpathy, YouTube)
Watch all 8 videos in order. Build micrograd → makemore → nanoGPT. The single best engineering path into LLMs.
2
nanoGPT source code (github.com/karpathy/nanoGPT)
Read train.py and model.py line by line. Train on Shakespeare (~10 min on a single GPU). Then train on OpenWebText.
3
Attention Is All You Need (Vaswani et al. 2017)
Read the paper after implementing nanoGPT — every design choice will be obvious. Pay attention to the multi-head attention diagram and positional encoding.
4
Dive into Deep Learning (d2l.ai) — Chapters 9–11
Attention mechanisms, transformer encoder/decoder, and BERT chapters. Every code block is runnable in the browser.
5
TRL Documentation + HuggingFace Alignment Handbook
github.com/huggingface/alignment-handbook — production SFT+DPO recipes for LLaMA, Mistral, Gemma with working configs.
6
llm.c (Karpathy) — GPT-2 in pure C/CUDA
After nanoGPT, read llm.c to understand what PyTorch abstracts away. CUDA kernels, memory layout, attention math at the metal level.