Debugging and Profiling
The worst AI bugs don't crash. They train silently on garbage and report a beautiful loss curve.
Prerequisites: Lesson 1 (Dev Environment), basic PyTorch familiarity
Video coming soon
An AI-narrated walkthrough for this module is on the way. Everything you need is in the lesson below.
#Learning Objectives
- Use conditional
breakpoint()anddebug_printto inspect tensor shapes, dtypes, and NaN values mid-training - Profile training loops with
cProfile,line_profiler, andtracemallocto find bottlenecks - Detect common AI bugs: shape mismatches, NaN loss, data leakage, and wrong-device tensors
- Set up TensorBoard to visualize loss curves, weight histograms, and gradient distributions
#The Problem
AI code fails differently than regular code. A web app crashes with a stack trace. A misconfigured training loop runs for 8 hours, burns $200 in GPU time, and produces a model that predicts the mean of every input. The code never errored. The bug was a tensor on the wrong device, a forgotten .detach(), or labels leaking into features.
You need debugging tools that catch these silent failures before they waste your time and compute.
#The Concept
AI debugging operates at three levels:
graph TD
L3["3. Training Dynamics<br/>Loss curves, gradient norms, activations"] --> L2
L2["2. Tensor Operations<br/>Shapes, dtypes, devices, NaN/Inf values"] --> L1
L1["1. Standard Python<br/>Breakpoints, logging, profiling, memory"]
Most people jump straight to level 3 (staring at TensorBoard). But 80% of AI bugs live at levels 1 and 2.
#Build It
#Part 1: Print Debugging (Yes, It Works)
Print debugging gets dismissed. It shouldn't. For tensor code, a targeted print statement beats stepping through a debugger because you need to see shapes, dtypes, and value ranges all at once.
def debug_print(name, tensor):
print(f"{name}: shape={tensor.shape}, dtype={tensor.dtype}, "
f"device={tensor.device}, "
f"min={tensor.min().item():.4f}, max={tensor.max().item():.4f}, "
f"mean={tensor.mean().item():.4f}, "
f"has_nan={tensor.isnan().any().item()}")Call this after every suspicious operation. When the bug is found, remove the prints. Simple.
#Part 2: Python Debugger (pdb and breakpoint)
The built-in debugger is underrated for AI work. Drop breakpoint() into your training loop and inspect tensors interactively.
def training_step(model, batch, criterion, optimizer):
inputs, labels = batch
outputs = model(inputs)
loss = criterion(outputs, labels)
if loss.item() > 100 or torch.isnan(loss):
breakpoint()
loss.backward()
optimizer.step()When the debugger drops you in, useful commands:
p outputs.shapeto check shapesp loss.item()to see the loss valuep torch.isnan(outputs).sum()to count NaNsp model.fc1.weight.gradto check gradientscto continue,qto quit
This is conditional debugging. You only stop when something looks wrong. For a 10,000-step training run, that matters.
#Part 3: Python Logging
Replace print statements with logging when your debugging goes beyond a quick check.
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[
logging.FileHandler("training.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
logger.info("Starting training: lr=%.4f, batch_size=%d", lr, batch_size)
logger.warning("Loss spike detected: %.4f at step %d", loss.item(), step)
logger.error("NaN loss at step %d, stopping", step)Logging gives you timestamps, severity levels, and file output. When a training run fails at 3 AM, you want a log file, not terminal output that scrolled off screen.
#Part 4: Timing Code Sections
Knowing where time goes is the first step to optimization.
import time
class Timer:
def __init__(self, name=""):
self.name = name
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *args):
elapsed = time.perf_counter() - self.start
print(f"[{self.name}] {elapsed:.4f}s")
with Timer("data loading"):
batch = next(dataloader_iter)
with Timer("forward pass"):
outputs = model(batch)
with Timer("backward pass"):
loss.backward()Common finding: data loading takes 60% of training time. The fix is num_workers > 0 in your DataLoader, not a faster GPU.
#Part 5: cProfile and line_profiler
When you need more than manual timers:
python -m cProfile -s cumtime train.pyThis shows every function call sorted by cumulative time. For line-by-line profiling:
pip install line_profiler@profile
def train_step(model, data, target):
output = model(data)
loss = F.cross_entropy(output, target)
loss.backward()
return loss
# Run with: kernprof -l -v train.py#Part 6: Memory Profiling
#CPU Memory with tracemalloc
import tracemalloc
tracemalloc.start()
# your code here
model = build_model()
data = load_dataset()
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
for stat in top_stats[:10]:
print(stat)#CPU Memory with memory_profiler
pip install memory_profilerfrom memory_profiler import profile
@profile
def load_data():
raw = read_csv("data.csv") # watch memory jump here
processed = preprocess(raw) # and here
return processedRun with python -m memory_profiler your_script.py to see line-by-line memory usage.
#GPU Memory with PyTorch
import torch
if torch.cuda.is_available():
print(torch.cuda.memory_summary())
print(f"Allocated: {torch.cuda.memory_allocated() / 1e9:.2f} GB")
print(f"Cached: {torch.cuda.memory_reserved() / 1e9:.2f} GB")When you hit OOM (Out of Memory):
- Reduce batch size (first thing to try, always)
- Use
torch.cuda.empty_cache()to free cached memory - Use
del tensorfollowed bytorch.cuda.empty_cache()for large intermediates - Use mixed precision (
torch.cuda.amp) to halve memory usage - Use gradient checkpointing for very deep models
#Part 7: Common AI Bugs and How to Catch Them
#Shape Mismatch
The most frequent bug. A tensor has shape [batch, features] when the model expects [batch, channels, height, width].
def check_shapes(model, sample_input):
print(f"Input: {sample_input.shape}")
hooks = []
def make_hook(name):
def hook(module, inp, out):
in_shape = inp[0].shape if isinstance(inp, tuple) else inp.shape
out_shape = out.shape if hasattr(out, "shape") else type(out)
print(f" {name}: {in_shape} -> {out_shape}")
return hook
for name, module in model.named_modules():
hooks.append(module.register_forward_hook(make_hook(name)))
with torch.no_grad():
model(sample_input)
for h in hooks:
h.remove()Run this once with a sample batch. It maps every shape transformation in your model.
#NaN Loss
NaN loss means something exploded. Common causes:
- Learning rate too high
- Division by zero in custom loss
- Log of zero or negative number
- Exploding gradients in RNNs
def detect_nan(model, loss, step):
if torch.isnan(loss):
print(f"NaN loss at step {step}")
for name, param in model.named_parameters():
if param.grad is not None:
if torch.isnan(param.grad).any():
print(f" NaN gradient in {name}")
if torch.isinf(param.grad).any():
print(f" Inf gradient in {name}")
return True
return False#Data Leakage
Your model gets 99% accuracy on the test set. Sounds great. It's a bug.
def check_data_leakage(train_set, test_set, id_column="id"):
train_ids = set(train_set[id_column].tolist())
test_ids = set(test_set[id_column].tolist())
overlap = train_ids & test_ids
if overlap:
print(f"DATA LEAKAGE: {len(overlap)} samples in both train and test")
return True
return FalseAlso check for temporal leakage: using future data to predict the past. Sort by timestamp before splitting.
#Wrong Device
Tensors on different devices (CPU vs GPU) cause runtime errors. But sometimes a tensor silently stays on CPU while everything else is on GPU, and training just runs slowly.
def check_devices(model, *tensors):
model_device = next(model.parameters()).device
print(f"Model device: {model_device}")
for i, t in enumerate(tensors):
if t.device != model_device:
print(f" WARNING: tensor {i} on {t.device}, model on {model_device}")#Part 8: TensorBoard Basics
TensorBoard shows you what's happening inside training over time.
pip install tensorboardfrom torch.utils.tensorboard import SummaryWriter
writer = SummaryWriter("runs/experiment_1")
for step in range(num_steps):
loss = train_step(model, batch)
writer.add_scalar("loss/train", loss.item(), step)
writer.add_scalar("lr", optimizer.param_groups[0]["lr"], step)
if step % 100 == 0:
for name, param in model.named_parameters():
writer.add_histogram(f"weights/{name}", param, step)
if param.grad is not None:
writer.add_histogram(f"grads/{name}", param.grad, step)
writer.close()Launch it:
tensorboard --logdir=runsWhat to look for:
- Loss not decreasing: Learning rate too low, or model architecture issue
- Loss oscillating wildly: Learning rate too high
- Loss goes to NaN: Numerical instability (see NaN section above)
- Train loss decreasing, val loss increasing: Overfitting
- Weight histograms collapsing to zero: Vanishing gradients
- Gradient histograms exploding: Need gradient clipping
#Part 9: VS Code Debugger
For interactive debugging, configure VS Code with a launch.json:
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Training",
"type": "debugpy",
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
"justMyCode": false
}
]
}Set breakpoints by clicking the gutter. Use the Variables pane to inspect tensor properties. The Debug Console lets you run arbitrary Python expressions mid-execution.
Useful for stepping through data preprocessing pipelines where you want to see each transformation.
#Use It
Here's the debugging workflow that catches most AI bugs:
- Before training: Run
check_shapeswith a sample batch. Verify input and output dimensions match expectations. - First 10 steps: Use
debug_printon loss, outputs, and gradients. Confirm nothing is NaN and values are in reasonable ranges. - During training: Log loss, learning rate, and gradient norms. Use TensorBoard for visualization.
- When something breaks: Drop
breakpoint()at the failure point. Inspect tensors interactively. - For performance: Time your data loading vs forward vs backward pass. Profile memory if you're near OOM.
#Ship It
Run the debugging toolkit script:
python phases/00-setup-and-tooling/12-debugging-and-profiling/code/debug_tools.pySee outputs/prompt-debug-ai-code.md for a prompt that helps diagnose AI-specific bugs.
#Exercises
- Run
debug_tools.pyand read through each section's output. Modify the dummy model to introduce a NaN (hint: divide by zero in the forward pass) and watch the detector catch it. - Profile a training loop with
cProfileand identify the slowest function. - Use
tracemallocto find which line in your data loading pipeline allocates the most memory. - Set up TensorBoard for a simple training run and identify whether the model is overfitting.
- Use
breakpoint()inside a training loop. Practice inspecting tensor shapes, devices, and gradient values from the debugger prompt.
Run this lesson in the Code Lab
A real JupyterLab, with this lesson's code files (and a ready-to-run notebook). The Quick Lab opens instantly in your browser. The Cloud Lab adds a terminal, saved files and the full toolchain.
import sys
import time
import tracemalloc
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
try:
import torch
import torch.nn as nn
HAS_TORCH = True
except ImportError:
HAS_TORCH = False
def debug_print(name, tensor):
print(f" {name}: shape={tensor.shape}, dtype={tensor.dtype}, "
f"device={tensor.device}, "
f"min={tensor.min().item():.4f}, max={tensor.max().item():.4f}, "
f"mean={tensor.mean().item():.4f}, "
f"has_nan={tensor.isnan().any().item()}")
class Timer:
def __init__(self, name=""):
self.name = name
self.elapsed = 0.0
def __enter__(self):
self.start = time.perf_counter()
return self
def __exit__(self, *args):
self.elapsed = time.perf_counter() - self.start
print(f" [{self.name}] {self.elapsed:.4f}s")
def check_shapes(model, sample_input):
print(f" Input: {sample_input.shape}")
hooks = []
def make_hook(name):
def hook(module, inp, out):
in_shape = inp[0].shape if isinstance(inp, tuple) else inp.shape
out_shape = out.shape if hasattr(out, "shape") else type(out).__name__
print(f" {name}: {in_shape} -> {out_shape}")
return hook
for name, module in model.named_modules():
if name:
hooks.append(module.register_forward_hook(make_hook(name)))
with torch.no_grad():
model(sample_input)
for h in hooks:
h.remove()
def detect_nan(model, loss, step):
if torch.isnan(loss):
print(f" NaN loss detected at step {step}")
for name, param in model.named_parameters():
if param.grad is not None:
if torch.isnan(param.grad).any():
print(f" NaN gradient in {name}")
if torch.isinf(param.grad).any():
print(f" Inf gradient in {name}")
return True
return False
def check_devices(model, *tensors):
model_device = next(model.parameters()).device
print(f" Model device: {model_device}")
for i, t in enumerate(tensors):
status = "OK" if t.device == model_device else "MISMATCH"
print(f" Tensor {i}: {t.device} [{status}]")
def check_gradient_health(model):
total_norm = 0.0
for name, param in model.named_parameters():
if param.grad is not None:
grad_norm = param.grad.data.norm(2).item()
total_norm += grad_norm ** 2
if grad_norm > 100:
print(f" WARNING: large gradient in {name}: {grad_norm:.2f}")
if grad_norm == 0:
print(f" WARNING: zero gradient in {name}")
total_norm = total_norm ** 0.5
print(f" Total gradient norm: {total_norm:.4f}")
return total_norm
def demo_print_debugging():
print("\n--- 1. Print Debugging for Tensors ---")
x = torch.randn(32, 784)
debug_print("input batch", x)
w = torch.randn(784, 128)
out = x @ w
debug_print("after matmul", out)
with_nan = out.clone()
with_nan[0, 0] = float("nan")
debug_print("with injected NaN", with_nan)
def demo_timing():
print("\n--- 2. Timing Code Sections ---")
with Timer("matrix multiply 1000x1000"):
a = torch.randn(1000, 1000)
b = torch.randn(1000, 1000)
_ = a @ b
with Timer("matrix multiply 5000x5000"):
a = torch.randn(5000, 5000)
b = torch.randn(5000, 5000)
_ = a @ b
def demo_memory_tracking():
print("\n--- 3. Memory Tracking (tracemalloc) ---")
tracemalloc.start()
data = [torch.randn(100, 100) for _ in range(100)]
more_data = torch.randn(1000, 1000)
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics("lineno")
print(" Top 5 memory allocations:")
for stat in top_stats[:5]:
print(f" {stat}")
del data, more_data
tracemalloc.stop()
def demo_shape_checking():
print("\n--- 4. Shape Checking Through Model ---")
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 64),
nn.ReLU(),
nn.Linear(64, 10),
)
sample = torch.randn(4, 784)
check_shapes(model, sample)
def demo_nan_detection():
print("\n--- 5. NaN Detection ---")
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10),
)
x = torch.randn(4, 784)
target = torch.randint(0, 10, (4,))
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
optimizer.zero_grad()
output = model(x)
loss = criterion(output, target)
loss.backward()
print(f" Normal loss: {loss.item():.4f}")
nan_found = detect_nan(model, loss, step=0)
print(f" NaN detected: {nan_found}")
fake_nan_loss = torch.tensor(float("nan"))
print(f" Simulated NaN loss: {fake_nan_loss.item()}")
nan_found = detect_nan(model, fake_nan_loss, step=99)
print(f" NaN detected: {nan_found}")
def demo_device_checking():
print("\n--- 6. Device Checking ---")
model = nn.Linear(10, 5)
t1 = torch.randn(4, 10)
t2 = torch.randn(4, 10)
check_devices(model, t1, t2)
if torch.cuda.is_available():
model_gpu = model.cuda()
t_cpu = torch.randn(4, 10)
t_gpu = torch.randn(4, 10).cuda()
print(" With mixed devices:")
check_devices(model_gpu, t_cpu, t_gpu)
def demo_gradient_health():
print("\n--- 7. Gradient Health Check ---")
model = nn.Sequential(
nn.Linear(784, 256),
nn.ReLU(),
nn.Linear(256, 10),
)
x = torch.randn(4, 784)
target = torch.randint(0, 10, (4,))
criterion = nn.CrossEntropyLoss()
output = model(x)
loss = criterion(output, target)
loss.backward()
check_gradient_health(model)
def demo_gpu_memory():
print("\n--- 8. GPU Memory Summary ---")
if not torch.cuda.is_available():
print(" No GPU available. Skipping GPU memory demo.")
print(" On a GPU machine, torch.cuda.memory_summary() shows:")
print(" - Allocated memory per block size")
print(" - Cached (reserved) memory")
print(" - Peak memory usage")
return
print(f" GPU: {torch.cuda.get_device_name(0)}")
print(f" Allocated: {torch.cuda.memory_allocated() / 1e6:.1f} MB")
print(f" Cached: {torch.cuda.memory_reserved() / 1e6:.1f} MB")
large_tensor = torch.randn(10000, 10000, device="cuda")
print(f" After 10k x 10k tensor:")
print(f" Allocated: {torch.cuda.memory_allocated() / 1e6:.1f} MB")
del large_tensor
torch.cuda.empty_cache()
print(f" After cleanup:")
print(f" Allocated: {torch.cuda.memory_allocated() / 1e6:.1f} MB")
def demo_logging():
print("\n--- 9. Structured Logging ---")
logger.info("Training started: lr=0.001, batch_size=32, epochs=10")
logger.info("Step 100: loss=2.3026, accuracy=0.10")
logger.warning("Loss spike detected: 15.7 at step 450")
logger.info("Step 1000: loss=0.4512, accuracy=0.87")
logger.info("Training complete: best_loss=0.3201")
def demo_conditional_breakpoint():
print("\n--- 10. Conditional Breakpoint Pattern ---")
print(" In real code, use this pattern:")
print()
print(" for step in range(num_steps):")
print(" loss = train_step(model, batch)")
print(" if loss.item() > 10 or torch.isnan(loss):")
print(" breakpoint() # drops into pdb")
print()
print(" Useful pdb commands once inside:")
print(" p tensor.shape # print shape")
print(" p tensor.device # check device")
print(" p tensor.grad # inspect gradients")
print(" p tensor.isnan().sum() # count NaNs")
print(" c # continue execution")
print(" q # quit debugger")
def main():
print("=" * 60)
print(" AI Debugging and Profiling Toolkit")
print(" Phase 0, Lesson 12")
print("=" * 60)
if not HAS_TORCH:
print("\nPyTorch not installed. Install with:")
print(" uv pip install torch")
print("\nRunning non-PyTorch demos only...\n")
demo_memory_tracking()
demo_logging()
return 1
demo_print_debugging()
demo_timing()
demo_memory_tracking()
demo_shape_checking()
demo_nan_detection()
demo_device_checking()
demo_gradient_health()
demo_gpu_memory()
demo_logging()
demo_conditional_breakpoint()
print("\n" + "=" * 60)
print(" All demos complete.")
print(" Next: introduce bugs intentionally and practice catching them.")
print("=" * 60 + "\n")
return 0
if __name__ == "__main__":
sys.exit(main())
5 questions on this lesson
Answer each one and see straight away whether you were right and why. Your first answer to a question counts. You are not signed in, so this is practice and nothing is saved.
Reusable outputs from this lesson. Copy them into your own work.
---
name: prompt-debug-ai-code
description: Diagnose AI-specific bugs including NaN loss, shape errors, training failures, and OOM
phase: 0
lesson: 12
---
You are an AI/ML debugging specialist. The user is training or running a machine learning model and has hit a bug. Your job is to diagnose the root cause and provide the exact fix.
When the user describes a problem, follow this process:
1. Classify the bug into one of these categories:
- **NaN/Inf loss**: numerical instability during training
- **Shape mismatch**: tensor dimension errors
- **Training not converging**: loss not decreasing or stuck
- **OOM (Out of Memory)**: GPU or CPU memory exhaustion
- **Data issue**: leakage, wrong preprocessing, corrupted inputs
- **Device mismatch**: tensors on different devices
- **Silent failure**: code runs but model learns nothing
2. Ask for the specific diagnostic output based on the category:
For **NaN loss**, ask the user to run:
```python
for name, param in model.named_parameters():
if param.grad is not None:
print(f"{name}: grad_norm={param.grad.norm():.4f}, "
f"has_nan={param.grad.isnan().any()}, "
f"has_inf={param.grad.isinf().any()}")
```
For **shape mismatch**, ask for:
```python
print(f"Input shape: {x.shape}")
print(f"Expected: {model.fc1.in_features}")
print(f"Output shape: {model(x).shape}")
print(f"Target shape: {target.shape}")
```
For **training not converging**, ask for:
- Learning rate value
- Loss values at steps 0, 10, 100, 1000
- Whether data is shuffled
- Whether gradients are being zeroed each step
For **OOM**, ask for:
```python
print(f"Batch size: {batch_size}")
print(f"Model params: {sum(p.numel() for p in model.parameters()):,}")
print(f"GPU memory: {torch.cuda.memory_allocated()/1e9:.2f} GB / "
f"{torch.cuda.get_device_properties(0).total_memory/1e9:.2f} GB")
```
3. Provide the fix. Be specific. Not "try reducing the learning rate" but "change lr from 0.1 to 0.001" or "add torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) before optimizer.step()".
Common root causes and their fixes:
- **NaN after a few steps**: Learning rate too high. Reduce by 10x. Add gradient clipping.
- **NaN immediately**: Log of zero or negative number in loss. Add epsilon: `torch.log(x + 1e-8)`.
- **NaN in specific layer**: Check for division by zero. BatchNorm with batch_size=1 will NaN.
- **Loss stuck at ln(num_classes)**: Model predicting uniform distribution. Check that gradients flow (no accidental `.detach()` or `with torch.no_grad()` around the forward pass).
- **Loss stuck at high value**: Wrong loss function for the task. CrossEntropyLoss expects raw logits, not softmax output.
- **Loss decreasing then exploding**: Learning rate too high for later training. Use a learning rate scheduler.
- **Perfect training accuracy, bad test accuracy**: Overfitting. Add dropout, reduce model size, add data augmentation, or get more data.
- **99% test accuracy on first epoch**: Data leakage. Labels are in the features, or train/test sets overlap.
- **OOM during forward pass**: Batch size too large or model too big. Halve the batch size. Use mixed precision with `torch.cuda.amp.autocast()`.
- **OOM during backward pass**: Gradient accumulation without clearing. Call `optimizer.zero_grad()` each step.
- **RuntimeError about device**: Move all tensors to the same device. Use `model.to(device)` and `tensor.to(device)` consistently.
- **Slow training, GPU utilization low**: Data loading is the bottleneck. Set `num_workers=4` (or higher) in DataLoader. Use `pin_memory=True`.
Always end with a verification step the user can run to confirm the fix worked.
The text and code of this lesson are based on AI Engineering from Scratch by Rohit Ghumare, MIT licensed.