PyTorch Fluency & Debugging Under Interview Pressure
Write a correct training loop from memory, explain autograd precisely, and debug a broken model live — the three things a PyTorch round actually tests.
Covers: Autograd, Dataset/DataLoader, training loops, debugging NaNs and shapes, custom layers, profiling
PyTorch rounds are less about exotic knowledge and more about fluency: can you write a correct training loop without a template, do you know what zero_grad actually does, and can you find a bug in someone else's model in ten minutes?
30-second answer
PyTorch builds a dynamic computation graph during the forward pass, recording each operation and its inputs. Calling `.backward()` traverses it in reverse applying the chain rule and accumulating into `.grad`. `requires_grad` marks a tensor as needing gradients; `detach()` returns a tensor sharing storage but cut from the graph; `no_grad()` disables graph construction entirely for a block; `retain_graph=True` keeps the graph alive so you can backward through it twice.
import torch
x = torch.tensor([2.0], requires_grad=True)
y = x ** 2
z = 3 * y + 1
print(f"y.grad_fn = {y.grad_fn}") # <PowBackward0>
print(f"z.grad_fn = {z.grad_fn}") # <AddBackward0>
print(f"parents = {z.grad_fn.next_functions}")
# ((<MulBackward0>, 0), (None, 0))
# The graph is: x -> Pow -> Mul -> Add -> z, built during the FORWARD pass.
z.backward()
print(f"dz/dx = {x.grad}") # 6x = 12
# Gradients ACCUMULATE -- this is why zero_grad() exists
z2 = 3 * x ** 2 + 1
z2.backward()
print(f"after second backward: {x.grad}") # 24, not 12!| Construct | Effect | Typical use |
|---|---|---|
requires_grad=True | Track ops on this tensor, allocate .grad | Model parameters, adversarial inputs |
.detach() | Same storage, cut from the graph | Logging, stopping gradient through a branch |
torch.no_grad() | No graph built at all inside the block | Inference, validation, manual param updates |
torch.inference_mode() | Stronger than no_grad; also disables version counters | Production inference (slightly faster) |
retain_graph=True | Keep buffers after backward | Multiple backwards through one graph (GANs, meta-learning) |
create_graph=True | Make the backward itself differentiable | Second-order gradients, MAML |
.item() | Extract a Python scalar, breaking the graph | Logging a loss without holding the graph |
import torch
x = torch.tensor([2.0], requires_grad=True)
# detach(): the OUTPUT is cut from the graph, but x still tracks gradients
d = x.detach()
print(d.requires_grad) # False
d2 = d * 3
print(d2.requires_grad) # False -- nothing downstream tracks
# no_grad(): NOTHING inside the block is tracked, regardless of inputs
with torch.no_grad():
n = x * 3
print(n.requires_grad) # False
# The key difference: detach shares STORAGE with the original
d[0] = 99.0
print(x) # tensor([99.], requires_grad=True)
# Mutating a detached tensor mutates the original -- use .clone().detach()
# when you want an independent copy.
# Practical: stopping gradient through one branch (as in a target network)
with torch.no_grad():
target = target_net(next_state).max(1).values
loss = F.mse_loss(q_net(state).gather(1, action), reward + gamma * target)
# The target network receives no gradient -- essential for DQN stability.Say these points
- The graph is built during the forward pass and traversed in reverse by `.backward()`.
- Gradients accumulate — hence `zero_grad()` every step.
- `detach()` cuts the output from the graph but shares storage; `no_grad()` disables tracking wholesale.
- `total_loss += loss` without `.item()` leaks the entire graph each step.
- Dynamic graphs are what make Python control flow work inside `forward`.
Avoid these mistakes
- Accumulating loss tensors instead of floats.
- Assuming `detach()` gives an independent copy — it shares storage.
- Forgetting `zero_grad()` and silently summing gradients across steps.
Expect these follow-ups
- Why is gradient accumulation across micro-batches useful, and what must you divide by?
- How would you compute a second-order gradient?
- What is a graph break in torch.compile and why does it hurt?
30-second answer
Set the model to train mode, iterate batches, move to device, zero gradients, forward, compute loss, backward, clip gradients, optimiser step, scheduler step. Then a validation pass under `no_grad()` in eval mode, tracking the best checkpoint. The details that separate correct from nearly-correct are `zero_grad(set_to_none=True)`, `.item()` for accumulation, model.train()/eval() toggling, and stepping the scheduler at the right granularity.
import torch, torch.nn as nn
from torch.amp import autocast
def train_one_epoch(model, loader, criterion, optimizer, scheduler,
device, max_grad_norm=1.0, accum_steps=1):
model.train() # dropout ON, BN uses batch stats
total_loss, n_seen = 0.0, 0
optimizer.zero_grad(set_to_none=True) # set_to_none is faster than =0
for step, (x, y) in enumerate(loader):
x = x.to(device, non_blocking=True) # non_blocking + pin_memory
y = y.to(device, non_blocking=True) # overlaps H2D with compute
with autocast(device_type=device.type, dtype=torch.bfloat16):
out = model(x)
loss = criterion(out, y)
(loss / accum_steps).backward() # divide for accumulation
if (step + 1) % accum_steps == 0:
nn.utils.clip_grad_norm_(model.parameters(), max_grad_norm)
optimizer.step()
optimizer.zero_grad(set_to_none=True)
if scheduler is not None:
scheduler.step() # per-STEP for warmup schedules
total_loss += loss.item() * len(y) # .item() -- do NOT keep the graph
n_seen += len(y)
return total_loss / n_seen
@torch.no_grad() # decorator form is cleaner
def evaluate(model, loader, criterion, device):
model.eval() # dropout OFF, BN uses running stats
total_loss, correct, n = 0.0, 0, 0
for x, y in loader:
x, y = x.to(device), y.to(device)
with autocast(device_type=device.type, dtype=torch.bfloat16):
out = model(x)
loss = criterion(out, y)
total_loss += loss.item() * len(y)
correct += (out.argmax(1) == y).sum().item()
n += len(y)
return total_loss / n, correct / n
def fit(model, train_loader, val_loader, epochs=20, lr=3e-4, patience=5):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
optimizer = torch.optim.AdamW(model.parameters(), lr=lr, weight_decay=0.01)
scheduler = torch.optim.lr_scheduler.OneCycleLR(
optimizer, max_lr=lr, total_steps=epochs * len(train_loader))
best_val, bad_epochs = float("inf"), 0
for epoch in range(epochs):
tr = train_one_epoch(model, train_loader, criterion, optimizer,
scheduler, device)
vl, acc = evaluate(model, val_loader, criterion, device)
print(f"epoch {epoch:3d} train {tr:.4f} val {vl:.4f} acc {acc:.4f} "
f"lr {scheduler.get_last_lr()[0]:.2e}")
if vl < best_val - 1e-4:
best_val, bad_epochs = vl, 0
torch.save({"model": model.state_dict(),
"optimizer": optimizer.state_dict(),
"epoch": epoch, "val_loss": vl}, "best.pt")
else:
bad_epochs += 1
if bad_epochs >= patience:
print(f"early stopping at epoch {epoch}")
break
model.load_state_dict(torch.load("best.pt")["model"])
return model| Detail | Why it matters |
|---|---|
model.train() / model.eval() | Toggles dropout and BatchNorm behaviour — the classic silent bug |
zero_grad(set_to_none=True) | Frees memory and skips a kernel vs. filling with zeros |
.item() when accumulating | Otherwise every step's graph stays alive → OOM |
non_blocking=True + pin_memory | Overlaps host-to-device copy with compute |
clip_grad_norm_ before step() | After backward, before the update — order matters |
| Scheduler granularity | Per-step for warmup/OneCycle; per-epoch for StepLR |
| Save the optimiser state too | Required to resume training correctly |
Say these points
- train()/eval() toggling, zero_grad(set_to_none=True), .item() accumulation.
- Clip gradients after backward and before optimiser step.
- Step per-batch schedulers inside the loop, per-epoch ones outside.
- Save optimiser and scheduler state alongside the model to resume correctly.
- Overfit a single batch first as a sanity check.
Avoid these mistakes
- Forgetting model.eval() in validation, so dropout is active.
- Accumulating the loss tensor instead of a float.
- Stepping a per-step scheduler once per epoch (or vice versa).
Expect these follow-ups
- How would you add distributed data parallel to this loop?
- How do you make this resumable from a mid-epoch checkpoint?
- Your GPU utilisation is 30%. Where do you look?
30-second answer
For NaN: check for division by zero or log(0) in the loss, exploding gradients (log the gradient norm), fp16 overflow, and bad input data. For not-learning: verify the loss is actually connected to the parameters, that `zero_grad` and `step` are both called, that the learning rate is sane, that labels align with predictions, and that the model can overfit a single batch.
import torch, math
def diagnose(model, loader, criterion, optimizer, device):
x, y = next(iter(loader))
x, y = x.to(device), y.to(device)
# 1. INPUT SANITY
print(f"x: shape={tuple(x.shape)} dtype={x.dtype} "
f"range=[{x.min():.3f},{x.max():.3f}] nan={torch.isnan(x).any().item()}")
print(f"y: shape={tuple(y.shape)} dtype={y.dtype} "
f"unique={y.unique().tolist()[:10]}")
# Common bugs: unnormalised inputs (0-255), labels 1-indexed, wrong dtype
# 2. FORWARD SANITY
model.eval()
with torch.no_grad():
out = model(x)
print(f"out: shape={tuple(out.shape)} range=[{out.min():.3f},{out.max():.3f}]")
# Shape mismatch with y is the single most common cause of silent nonsense
# 3. INITIAL LOSS -- is it what theory predicts?
loss = criterion(out, y)
n_classes = out.shape[-1]
print(f"initial loss={loss.item():.4f} expected~{math.log(n_classes):.4f}")
# For balanced k-class cross-entropy the untrained loss should be ~ln(k).
# Far off means wrong loss, wrong label encoding, or a bad init.
# 4. GRADIENTS -- do they exist and are they sane?
model.train()
optimizer.zero_grad()
criterion(model(x), y).backward()
no_grad, zero_grad_params, big = [], [], []
for name, p in model.named_parameters():
if not p.requires_grad: continue
if p.grad is None: no_grad.append(name); continue
g = p.grad.norm().item()
if g == 0: zero_grad_params.append(name)
if g > 100 or math.isnan(g): big.append((name, g))
print(f"params with NO grad: {no_grad[:5]}") # detached / unused branch
print(f"params with ZERO grad: {zero_grad_params[:5]}") # dead ReLU / frozen
print(f"params with HUGE/NaN grad: {big[:5]}")
# 5. CAN IT OVERFIT ONE BATCH? The decisive test.
for i in range(200):
optimizer.zero_grad()
l = criterion(model(x), y)
l.backward(); optimizer.step()
if i % 50 == 0:
print(f" overfit step {i:3d}: loss={l.item():.6f}")
print("PASS -- the pipeline is wired correctly"
if l.item() < 0.01 else
"FAIL -- there is a bug; tuning will not help")| Symptom | Likely cause | Check |
|---|---|---|
| NaN loss immediately | log(0), division by zero, or bad input data | torch.isnan(x).any(); add ε to denominators |
| NaN after N steps | Exploding gradients or fp16 overflow | Log grad norm each step; switch to bf16; clip |
| Loss flat at ln(k) | Gradients are not reaching the parameters | Check for .detach(), missing step(), requires_grad=False |
| Loss decreases then plateaus high | LR too high or too low; capacity too small | LR range test; try to overfit one batch |
| Train loss down, val loss up | Overfitting | Regularise, augment, early stop |
| Val loss lower than train | Dropout active in train only, or a split bug | Normal with heavy dropout; check for leakage |
| Loss oscillates wildly | LR too high or batch too small | Lower LR; increase batch or accumulate |
| Accuracy stuck at 1/k | Label/prediction misalignment | Print predictions vs labels for one batch |
import torch
# Development only -- pinpoints the exact op that produced the NaN
torch.autograd.set_detect_anomaly(True) # ~3x slower; never leave on
# Or hook every module's output
def add_nan_hooks(model):
def hook(module, inp, out):
t = out[0] if isinstance(out, tuple) else out
if torch.is_tensor(t) and not torch.isfinite(t).all():
raise RuntimeError(f"non-finite output from {module.__class__.__name__}")
for m in model.modules():
m.register_forward_hook(hook)
# Log gradient norms every step -- NaNs are almost always preceded by growth
def grad_norm(model):
return math.sqrt(sum(p.grad.pow(2).sum().item()
for p in model.parameters() if p.grad is not None))
# step 40: grad_norm= 1.83
# step 45: grad_norm= 8.91
# step 48: grad_norm= 61.40 <- the divergence started HERE, not at step 50
# step 50: grad_norm= nanSay these points
- Check inputs → shapes → initial loss vs ln(k) → gradient norms → overfit one batch.
- NaN after N steps means growing gradients; log the norm to find the real start.
- Params with `grad is None` indicate a detached or unused branch.
- Loss stuck at exactly ln(k) means gradients are not reaching the parameters.
- `set_detect_anomaly(True)` pinpoints the op, at ~3× slowdown.
Avoid these mistakes
- Lowering the learning rate reflexively without diagnosing.
- Not checking whether labels and predictions are aligned.
- Leaving anomaly detection on in a real run.
Expect these follow-ups
- Your GPU utilisation is 25% — walk me through diagnosing that.
- How would you debug a model that works in eager mode but breaks under torch.compile?
- How do you debug a divergence that only occurs in distributed training?
30-second answer
Implement `__len__` and `__getitem__` returning a single unbatched example, then write a `collate_fn` that pads sequences to the batch maximum and builds an attention mask. Pad to the longest in the batch rather than a global maximum, and use a length-grouped sampler so batches contain similar lengths — that alone often cuts wasted compute by 30–50%.
import torch
from torch.utils.data import Dataset, DataLoader, Sampler
import random
class TextClassificationDataset(Dataset):
def __init__(self, texts, labels, tokenizer, max_length=512):
self.texts, self.labels = texts, labels
self.tokenizer, self.max_length = tokenizer, max_length
# Cache lengths for the length-grouped sampler
self.lengths = [min(len(tokenizer.encode(t)), max_length) for t in texts]
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
ids = self.tokenizer.encode(self.texts[idx])[: self.max_length]
return {"input_ids": torch.tensor(ids, dtype=torch.long),
"label": torch.tensor(self.labels[idx], dtype=torch.long),
"length": len(ids)}
def collate_fn(batch, pad_id=0):
"""Pad to the batch max -- NOT a global max."""
max_len = max(item["length"] for item in batch)
input_ids = torch.full((len(batch), max_len), pad_id, dtype=torch.long)
attention_mask = torch.zeros((len(batch), max_len), dtype=torch.bool)
for i, item in enumerate(batch):
n = item["length"]
input_ids[i, :n] = item["input_ids"]
attention_mask[i, :n] = True
return {"input_ids": input_ids,
"attention_mask": attention_mask,
"labels": torch.stack([b["label"] for b in batch])}
class LengthGroupedSampler(Sampler):
"""Group similar-length examples so padding waste collapses."""
def __init__(self, lengths, batch_size, mega_batch_mult=50, seed=0):
self.lengths, self.batch_size = lengths, batch_size
self.mega = batch_size * mega_batch_mult
self.rng = random.Random(seed)
def __iter__(self):
idx = list(range(len(self.lengths)))
self.rng.shuffle(idx) # shuffle first -> keeps randomness
out = []
for i in range(0, len(idx), self.mega):
chunk = idx[i:i + self.mega]
chunk.sort(key=lambda j: self.lengths[j]) # sort WITHIN the mega-batch
out.extend(chunk)
return iter(out)
def __len__(self):
return len(self.lengths)
loader = DataLoader(
dataset,
batch_size=32,
sampler=LengthGroupedSampler(dataset.lengths, 32),
collate_fn=collate_fn,
num_workers=4,
pin_memory=True,
persistent_workers=True,
drop_last=True,
)
# Measured padding waste on a real corpus (mean length 87, p99 length 480):
# random batching : 61% of tokens are padding
# length-grouped batching: 9% of tokens are padding
# Same data, same model, ~1.8x faster epochs.| DataLoader argument | What it does | Recommendation |
|---|---|---|
num_workers | Parallel data loading processes | 4–8; 0 for debugging (real tracebacks) |
pin_memory | Page-locked host memory for fast H2D copy | True with CUDA |
persistent_workers | Keep workers alive across epochs | True when num_workers > 0 |
prefetch_factor | Batches prefetched per worker | 2–4 |
drop_last | Discard the final partial batch | True for training with BatchNorm |
collate_fn | Assemble a batch from samples | Required for variable-length data |
Say these points
- `__getitem__` returns one unbatched example; `collate_fn` assembles the batch.
- Pad to the batch maximum, never a global maximum.
- Length-grouped sampling can cut padding waste from ~60% to ~10%.
- num_workers + pin_memory + persistent_workers keep the GPU fed.
- Each worker copies the dataset object — avoid holding large data in memory.
Avoid these mistakes
- Padding every batch to a fixed global max length.
- Not seeding workers, so augmentations repeat identically.
- Debugging with num_workers > 0 and getting unusable tracebacks.
Expect these follow-ups
- How would you stream a dataset too large to enumerate?
- How do you shard a dataset correctly across distributed ranks?
- How would you handle a dataset where loading one item takes 200 ms?
30-second answer
30% utilisation almost always means the GPU is starved by the input pipeline, not that the model is slow. Check data loading first (num_workers, pin_memory, expensive per-item transforms, slow storage), then synchronisation points (`.item()`, `.cpu()`, printing inside the loop), then the model itself. Use the PyTorch profiler to see the actual gaps rather than guessing.
import torch
from torch.profiler import profile, record_function, ProfilerActivity, schedule
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=1, warmup=1, active=5), # skip warmup iterations
on_trace_ready=torch.profiler.tensorboard_trace_handler("./log"),
record_shapes=True, profile_memory=True, with_stack=True,
) as prof:
for step, (x, y) in enumerate(loader):
if step >= 8: break
with record_function("h2d"):
x, y = x.cuda(non_blocking=True), y.cuda(non_blocking=True)
with record_function("forward"):
out = model(x); loss = criterion(out, y)
with record_function("backward"):
loss.backward()
with record_function("step"):
optimizer.step(); optimizer.zero_grad(set_to_none=True)
prof.step()
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=12))
# Name Self CPU CUDA total # of Calls
# --------------------------------------------------------------
# enumerate(DataLoader) 21.4s 0.000ms 8 <-- THE BOTTLENECK
# forward 0.1s 412.331ms 8
# backward 0.1s 798.117ms 8
# step 0.0s 41.220ms 8
#
# 21 seconds waiting for data vs 1.2 seconds of GPU work. The model is fine.| Cause | Symptom | Fix |
|---|---|---|
| num_workers=0 | GPU idle between batches | Set to 4–8 with persistent_workers |
| Expensive per-item transform | High worker CPU, GPU idle | Precompute; move augmentation to GPU |
| Slow storage / small files | Workers I/O-bound | WebDataset/tar shards; local NVMe cache |
.item() in the loop | Frequent CPU-GPU syncs | Accumulate on GPU; sync once per epoch |
print / logging per step | Implicit sync | Log every N steps |
| Small batch size | GPU underutilised per kernel | Increase batch; use accumulation for memory |
| fp32 on tensor-core hardware | Half the achievable throughput | Enable bf16 autocast |
| Unfused elementwise ops | Many small kernels | torch.compile |
# 1. Feed the GPU (usually the whole problem)
loader = DataLoader(ds, batch_size=64, num_workers=8, pin_memory=True,
persistent_workers=True, prefetch_factor=4)
# 2. Remove synchronisation points from the hot loop
running = torch.zeros((), device="cuda")
for x, y in loader:
loss = criterion(model(x), y)
loss.backward(); optimizer.step(); optimizer.zero_grad(set_to_none=True)
running += loss.detach() # stays on GPU -- no sync
epoch_loss = (running / len(loader)).item() # ONE sync per epoch
# 3. Mixed precision -- roughly 2x on tensor-core GPUs
with torch.autocast("cuda", dtype=torch.bfloat16):
loss = criterion(model(x), y)
# 4. torch.compile -- kernel fusion, typically 1.3-2x
model = torch.compile(model, mode="max-autotune")
# 5. Channels-last for convnets
model = model.to(memory_format=torch.channels_last)
x = x.to(memory_format=torch.channels_last)
# 6. cuDNN autotuning for fixed input shapes
torch.backends.cudnn.benchmark = True # NOT with variable shapes
# Cumulative result on a real ResNet training job:
# baseline 40.0 min/epoch, 30% GPU util
# + num_workers=8, pin_memory 14.2 min/epoch, 78% util
# + bf16 autocast 8.1 min/epoch, 82% util
# + torch.compile 6.4 min/epoch, 88% util
# + channels_last 5.7 min/epoch, 91% util (7x total)Say these points
- Low GPU utilisation means the input pipeline is the bottleneck, not the model.
- Profile with torch.profiler; do not guess.
- num_workers, pin_memory and persistent_workers are the first fixes.
- `.item()` and prints in the hot loop force CPU-GPU synchronisation.
- Then bf16 autocast, torch.compile, channels_last — roughly 7× cumulative in practice.
Avoid these mistakes
- Optimising the model when the DataLoader is the bottleneck.
- Calling `.item()` every step for logging.
- Enabling cudnn.benchmark with variable input shapes.
Expect these follow-ups
- How does torch.compile achieve its speedup, and what is a graph break?
- How would you scale this to 8 GPUs?
- When is gradient checkpointing worth the recompute cost?
Showing 5 of 5 questions for pytorch-and-ml-engineering-coding.
Check your understanding
5 questions · no sign-up, nothing stored
Hands-on challenge
Build it — this is what you talk about in a deep-dive round.
Fix a deliberately broken training script
Build a training script, plant realistic bugs, then write the diagnostic tooling that finds them.
Requirements
- A complete training loop with AMP, gradient clipping, scheduler, early stopping and checkpointing.
- A `diagnose()` function checking inputs, shapes, initial loss vs ln(k), per-layer gradient norms, and single-batch overfitting.
- A custom Dataset with length-grouped batching; measure and report the padding-waste reduction.
- A profiling run showing the bottleneck, plus before/after timings for each optimisation you apply.
Stretch goals
- Plant five bugs (detached branch, wrong label alignment, missing eval mode, LR 100× too high, missing zero_grad) and verify your diagnostic catches each.
- Add torch.compile and report the speedup plus any graph breaks it reports.