PyTorch Training skill for Claude Code
Overfit one batch, resume exactly, then scale. Claude writes training scripts that prove themselves on a tiny run first: overfit one batch, confirm the data loader is not the bottleneck, and verify that a resumed checkpoint reproduces the loss curve. It adds bf16 or fp16 precision, accumulation, clipping, torch.compile and DDP in the right order, and debugs NaN, flat or diverging loss with a step-by-step reference instead of random hyperparameter changes.
- name
pytorch-training - size224 lines · ≈2,250 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and debugs PyTorch training loops - Dataset and DataLoader setup, train/eval modes, mixed precision with bf16 or fp16 GradScaler, gradient accumulation and clipping, torch.compile, full-state checkpointing and resume, multi-GPU with torchrun and DDP, reproducibility, and diagnosing loss that is NaN, flat, or diverging. Use when building or reviewing a training script, when GPU utilization is low, training is slow or runs out of memory, resumes incorrectly, or the loss curve looks wrong. Try asking
- Write a PyTorch training loop with bf16, gradient accumulation and resumable checkpoints
- My loss turns into NaN around step 3000. Track down the cause
- GPU utilization sits at 40% during training. Find the bottleneck
Use it when
- You are writing or reviewing a PyTorch training script
- The loss is NaN, flat, spiky, or diverging
- Training is slow, underuses the GPU, or runs out of memory
- Resuming from a checkpoint changes the loss curve or crashes
Skip it when
- You are fine-tuning an LLM with LoRA via standard trainers — use fine-tuning
- You only need inference with a pretrained Hub model — use huggingface-transformers
How to check the result
A single batch overfits to near-zero loss, a run resumed from a mid-training checkpoint matches the uninterrupted loss curve, and loader-only timing is faster than a training step.
The common mistake it prevents
Stepping a per-step learning-rate scheduler on every micro-batch under gradient accumulation, which finishes the schedule early and quietly cripples training.
A training script is correct when it overfits a single small batch to near-zero loss, resumes from a checkpoint to the same loss curve, keeps the GPU busy (data loading is not the bottleneck), and logs train and validation metrics you can compare across runs. Scale up only after those hold on a tiny run.
Ground rules
- Debug small: tiny model or subset, a few hundred steps, seconds per iteration. Scale only once the small run behaves.
- Always overfit one batch first. If the model cannot memorize 8 examples, there is a bug.
- Log the configuration, seed, git commit, and library versions with every run.
- Checkpoint everything needed to resume exactly: model, optimizer, scheduler, scaler, step, epoch, RNG states.
- Validate on a held-out split on a fixed schedule; never select checkpoints by train loss.
Workflow
- [ ] 1. Inspect data: shapes, dtypes, label distribution, a few decoded samples
- [ ] 2. Build Dataset/DataLoader; time an epoch of loading alone
- [ ] 3. Write the loop; overfit one batch
- [ ] 4. Add mixed precision, accumulation, clipping, scheduler
- [ ] 5. Add checkpoint save/resume; verify resume
- [ ] 6. Profile throughput; then torch.compile / DDP
- [ ] 7. Full run with validation, early stopping, experiment tracking
2. Data loading
from torch.utils.data import DataLoader
train_loader = DataLoader(
train_ds,
batch_size=64,
shuffle=True,
num_workers=8, # start near CPU cores per GPU; measure
pin_memory=True, # faster host-to-GPU copies
persistent_workers=True, # avoid re-forking each epoch
prefetch_factor=4,
drop_last=True, # stable batch size for BatchNorm/compile
worker_init_fn=seed_worker, generator=g, # reproducible augmentation
)
- Do heavy decoding and augmentation in workers, not in the main loop.
- Move batches with
x.to(device, non_blocking=True)(pairs withpin_memory). - Measure: iterate the loader alone for 200 batches. If that is slower than a training step, data loading is the bottleneck; fix it before anything else.
- With
num_workers > 0, each worker gets a copy of the dataset object; open files and DB handles lazily in__getitem__orworker_init_fn, not in__init__.
3. The loop
import torch
from torch.nn.utils import clip_grad_norm_
torch.set_float32_matmul_precision("high") # TF32 on Ampere+ for fp32 matmuls
device = "cuda"
model = Model().to(device)
opt = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=0.1, fused=True)
sched = torch.optim.lr_scheduler.OneCycleLR(opt, max_lr=3e-4, total_steps=total_steps, pct_start=0.05)
accum = 4
for epoch in range(start_epoch, epochs):
model.train()
for i, (x, y) in enumerate(train_loader):
x, y = x.to(device, non_blocking=True), y.to(device, non_blocking=True)
with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
loss = criterion(model(x), y) / accum
loss.backward()
if (i + 1) % accum == 0:
grad_norm = clip_grad_norm_(model.parameters(), max_norm=1.0)
opt.step()
sched.step()
opt.zero_grad(set_to_none=True)
step += 1
if step % log_every == 0:
log(step=step, loss=loss.item() * accum, grad_norm=grad_norm.item(),
lr=sched.get_last_lr()[0])
evaluate(model, val_loader)
save_checkpoint(...)
@torch.no_grad()
def evaluate(model, loader):
model.eval() # disables dropout, uses BatchNorm running stats
...
model.train()
Loop rules:
model.train()/model.eval()at the right places; forgettingeval()makes validation noisy and wrong with dropout or BatchNorm.- Wrap evaluation in
torch.no_grad()(ortorch.inference_mode()). - Call
loss.item()only when logging; it forces a GPU sync every time. - Scheduler steps per optimizer step (not per micro-batch) for step-based schedules.
4. Precision
| Hardware | Setting |
|---|---|
| Ampere / Hopper / newer NVIDIA, recent AMD, TPU | torch.autocast(..., dtype=torch.bfloat16), no GradScaler |
| Older NVIDIA (V100, T4) | fp16 autocast + torch.amp.GradScaler("cuda") |
| Apple MPS | Start in fp32; try torch.autocast("mps", dtype=torch.float16) if supported and stable |
fp16 with GradScaler:
scaler = torch.amp.GradScaler("cuda")
with torch.autocast(device_type="cuda", dtype=torch.float16):
loss = criterion(model(x), y)
scaler.scale(loss).backward()
scaler.unscale_(opt) # before clipping
clip_grad_norm_(model.parameters(), 1.0)
scaler.step(opt)
scaler.update()
opt.zero_grad(set_to_none=True)
Keep the loss computation in fp32 when it involves large reductions or log-softmax outside the model (autocast handles common ops; check custom ones).
5. Checkpointing
def save_checkpoint(path):
state = {
"model": model.state_dict(), "optimizer": opt.state_dict(),
"scheduler": sched.state_dict(), "scaler": scaler.state_dict() if scaler else None,
"step": step, "epoch": epoch, "config": cfg,
"rng": {"torch": torch.get_rng_state(), "cuda": torch.cuda.get_rng_state_all()},
}
tmp = path + ".tmp"
torch.save(state, tmp)
os.replace(tmp, path) # atomic: a crash never leaves a corrupt checkpoint
state = torch.load(path, map_location="cpu", weights_only=False) # trusted files only
torch.loaddefaults toweights_only=Truein current PyTorch; full training state with config objects needsweights_only=False, which unpickles arbitrary code. Only do that for files you created.- With
torch.compileor DDP, savemodel._orig_mod.state_dict()/model.module.state_dict()so keys load into a plain model. - Verify resume: train 200 steps, checkpoint at 100, resume, and compare the loss at steps 101-200. They should match closely (exactly with deterministic settings).
- Keep the last N and the best-by-validation checkpoint; delete the rest automatically.
6. Speed
- Profile one window with
torch.profileror simply time data vs. forward/backward. - Fix data loading first (see above).
- Raise batch size until memory is ~85-90% used; scale learning rate accordingly and re-check stability.
model = torch.compile(model); expect a slow first iteration and recompiles if shapes vary (pad or bucket sequence lengths).- Out of memory: lower micro-batch and raise accumulation, enable activation checkpointing, use bf16, or shard with FSDP.
Multi-GPU (single node):
torchrun --standalone --nproc_per_node=8 train.py --config cfg.yaml
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler
dist.init_process_group("nccl")
local_rank = int(os.environ["LOCAL_RANK"])
torch.cuda.set_device(local_rank)
model = DDP(model.to(local_rank), device_ids=[local_rank])
sampler = DistributedSampler(train_ds, shuffle=True)
loader = DataLoader(train_ds, batch_size=per_gpu_bs, sampler=sampler, num_workers=8, pin_memory=True)
for epoch in range(epochs):
sampler.set_epoch(epoch) # otherwise every epoch has the same order
...
# log, evaluate, and save only on rank 0: if dist.get_rank() == 0: ...
Use model.no_sync() on non-final accumulation micro-steps to skip redundant gradient
all-reduces. Models that do not fit on one GPU need FSDP; that is a separate design.
Reproducibility
Seed Python, NumPy, and torch; seed DataLoader workers via worker_init_fn and a
torch.Generator. For bit-exact debugging, torch.use_deterministic_algorithms(True)
(slower; some ops raise or need CUBLAS_WORKSPACE_CONFIG=:4096:8).
Loss looks wrong
Quick triage; for the full decision tree read references/loss-debugging.md.
| Symptom | First checks |
|---|---|
| NaN/Inf after some steps | Learning rate too high, fp16 overflow (switch to bf16 or check scaler), log of zero, division by zero in custom loss |
| Loss flat from step 0 | Gradients not flowing (requires_grad, detached tensors), LR far too low, labels misaligned with inputs |
| Train down, val up | Overfitting: more data/augmentation, regularization, early stopping |
| Val far better than train | Dropout/augmentation only in train (normal), or leakage between splits |
| Spiky loss | LR too high or no warmup, bad samples, batch too small |
Output contract
**Run:** <name> commit <sha>, config <path>, seed <n>, torch <version>
**Sanity:** overfit-one-batch loss <x>; resume check matched: <yes/no>
**Throughput:** <samples/s>, GPU util <x%>, data-loader time share <y%>
**Results:** best val <metric> at step <n>; checkpoint <path>
**Issues fixed:** <bug -> evidence -> fix> Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/loss-debugging.md
# Debugging loss curves
Read this when the loss is NaN, flat, spiky, or diverging and the quick triage table in
SKILL.md did not resolve it. Work top to bottom; each step rules out a class of bugs.
## Step 0: make it small and fast
Reduce to a run that shows the problem in under two minutes: subset of data, fewer
layers if needed, same code path. Every hypothesis below needs several quick experiments.
## Step 1: check the data, not the model
- Decode and print 5 batches as a human would see them: images saved to disk, token IDs
decoded back to text, labels mapped back to class names.
- Confirm inputs and labels line up after shuffling and augmentation (a common bug is
shuffling inputs and labels separately).
- Check label range: `y.min(), y.max()` against the number of classes;
`CrossEntropyLoss` wants class indices in `[0, C)`, not one-hot, unless you use
probabilities deliberately.
- Look for NaN/Inf in inputs: `torch.isfinite(x).all()`.
- Check normalization: inputs with mean in the hundreds or std near zero break training.
- For sequence models, confirm padding is masked in the loss (`ignore_index=-100`).
## Step 2: overfit one batch
```python
x, y = next(iter(train_loader))
for i in range(300):
loss = criterion(model(x.to(dev)), y.to(dev))
opt.zero_grad(set_to_none=True); loss.backward(); opt.step()
if i % 50 == 0: print(i, loss.item())
```
| Outcome | Meaning |
|---|---|
| Loss goes to ~0 | Model, loss, and optimizer wiring work; the problem is data, regularization, or scale |
| Loss decreases slowly then plateaus high | Capacity or LR issue; try LR x10 and /10 |
| Loss does not move | Gradient flow bug (Step 3) |
| Loss becomes NaN | Numerical bug (Step 4) |
Turn off dropout, augmentation, and weight decay for this test.
## Step 3: gradient flow
```python
loss.backward()
for name, p in model.named_parameters():
if p.requires_grad:
g = None if p.grad is None else p.grad.norm().item()
print(f"{name:60} grad={g}")
```
- `None` gradients: the parameter is not in the graph. Causes: `.detach()`, `.item()`,
`.numpy()` in the forward pass, `torch.no_grad()` left on, building a new tensor from
values, or the module not registered (plain Python list instead of `nn.ModuleList`).
- Parameters missing from the optimizer: built the optimizer before replacing a layer,
or froze/unfroze after creating it. Compare `sum(p.numel() for g in opt.param_groups for p in g["params"])`
with the model's trainable count.
- Zero gradients everywhere: dead ReLUs (LR too high earlier), saturated sigmoid/tanh,
or a loss that does not depend on the output (wrong variable passed).
- Exploding norms (> 1e3): add clipping (`max_norm=1.0`), lower LR, add warmup, check for
missing normalization layers.
## Step 4: NaN and Inf
1. Find the first step it appears; log loss and grad norm every step around it.
2. `torch.autograd.set_detect_anomaly(True)` for a short run pinpoints the op producing
NaN in backward (very slow; debugging only).
3. Usual causes:
- `log(0)` or `log(negative)`: use `log_softmax` + `NLLLoss`, or `CrossEntropyLoss`
on logits, never `log(softmax(x))`.
- `BCELoss` on probabilities hitting exactly 0/1: use `BCEWithLogitsLoss` on logits.
- Division by a zero norm or std: add an epsilon.
- fp16 overflow: activations above 65,504. Switch to bf16, or confirm GradScaler is
used correctly (`unscale_` before clipping; scaler state saved and restored).
- LR too high: NaN after a spike in grad norm.
- Bad data sample: log the batch indices when the loss becomes non-finite and inspect.
4. Guard in production runs: skip the step and log when `not torch.isfinite(loss)`, but
treat frequent skips as a bug, not a fix.
## Step 5: learning rate and schedule
- Run a short LR range test: increase LR exponentially from 1e-7 to 1 over ~300 steps and
plot loss vs. LR. Pick a value about 10x below where loss starts rising sharply.
- Transformers usually need warmup (1-5% of steps) with AdamW; without it early updates
can blow up.
- Verify the schedule by logging the actual LR every N steps. Common bug: scheduler stepped
per micro-batch with gradient accumulation, finishing the schedule too early.
- Effective batch size changes (more GPUs, more accumulation) need LR re-tuning.
## Step 6: generalization problems
| Pattern | Likely cause | Action |
|---|---|---|
| Train loss low, val loss rising early | Overfitting or train/val distribution mismatch | More data/augmentation, weight decay, dropout, smaller model, early stop |
| Val metric great from epoch 1 | Leakage: duplicates across splits, target in features, future data in training | Deduplicate across splits; split by entity/time |
| Val loss lower than train throughout | Train-time regularization (dropout, augmentation) | Usually fine; compare in eval mode on a train subset |
| Val metric noisy between evals | Small val set or `model.eval()` missing | Larger val set; check eval mode and `no_grad` |
| Loss fine, metric bad | Metric computed wrong (argmax axis, threshold, averaging) | Unit-test the metric on hand-made inputs |
## Step 7: distributed-only failures
- Loss differs between 1 GPU and N GPUs at the same effective batch: missing
`sampler.set_epoch`, BatchNorm statistics per GPU (consider `SyncBatchNorm`), or LR not
scaled.
- Hangs: a rank skipped a collective (conditional code path, uneven last batch). Use
`drop_last=True` and identical control flow on every rank; set
`TORCH_NCCL_ASYNC_ERROR_HANDLING=1` (or the current equivalent) and a timeout in
`init_process_group` to surface hangs as errors.
- Only rank 0 should write logs and checkpoints; others writing the same file corrupt it.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.