AI Engineering No. 258

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.

  • namepytorch-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

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.

~/.claude/skills/pytorch-training/SKILL.md

--- name: pytorch-training description: Writes and debugs PyTorch training loops - Dataset and DataLoader setup, train/eval modes, mixed precision with bf16 or fp16 GradScaler,… license: MIT ---

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 with pin_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__ or worker_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; forgetting eval() makes validation noisy and wrong with dropout or BatchNorm.
  • Wrap evaluation in torch.no_grad() (or torch.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

HardwareSetting
Ampere / Hopper / newer NVIDIA, recent AMD, TPUtorch.autocast(..., dtype=torch.bfloat16), no GradScaler
Older NVIDIA (V100, T4)fp16 autocast + torch.amp.GradScaler("cuda")
Apple MPSStart 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.load defaults to weights_only=True in current PyTorch; full training state with config objects needs weights_only=False, which unpickles arbitrary code. Only do that for files you created.
  • With torch.compile or DDP, save model._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

  1. Profile one window with torch.profiler or simply time data vs. forward/backward.
  2. Fix data loading first (see above).
  3. Raise batch size until memory is ~85-90% used; scale learning rate accordingly and re-check stability.
  4. model = torch.compile(model); expect a slow first iteration and recompiles if shapes vary (pad or bucket sequence lengths).
  5. 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.

SymptomFirst checks
NaN/Inf after some stepsLearning rate too high, fp16 overflow (switch to bf16 or check scaler), log of zero, division by zero in custom loss
Loss flat from step 0Gradients not flowing (requires_grad, detached tensors), LR far too low, labels misaligned with inputs
Train down, val upOverfitting: more data/augmentation, regularization, early stopping
Val far better than trainDropout/augmentation only in train (normal), or leakage between splits
Spiky lossLR 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 110 lines · 5.6 KB
# 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.

esc