TAI BUI
Back to curriculum
BuildPython

Debugging and Profiling AI Code

The worst AI bugs don't crash. They train silently on garbage for eight hours, burn your GPU budget, and hand you a beautiful loss curve on the way out.

TIME: ~60 minutesPREREQUISITES: Phase 0, Lesson 01; basic PyTorch familiarity

Learning Objectives

  • Use conditional `breakpoint()` and `debug_print` to inspect tensor shapes, dtypes, and NaN values mid-training
  • Profile training loops with `cProfile`, `line_profiler`, and `tracemalloc` to find bottlenecks
  • Recognize the four bugs that show up in almost every training run: shape mismatches, NaN loss, data leakage, and wrong-device tensors
  • Set up TensorBoard to visualize loss curves, weight histograms, and gradient distributions

The Problem

By this point in Phase 0 you've got a machine, a compute setup, a shell you're comfortable in, an editor, and access to the APIs and data you need. That's the whole workshop, wired up. This lesson is the last piece: what happens when something in that workshop is actually broken.

One honest note before we go further: everything from here on assumes you've already trained a model, or at least watched a training loop run and seen gradients update. Lessons 1 through 6 assumed nothing. This one assumes real PyTorch familiarity, because it's the last lesson in the phase and the next real use for it is Phase 2 and 3, where you'll be neck-deep in tensors every day. If you haven't trained anything yet, that's fine. Skim this lesson for the shape of it now, then come back once you have a training loop of your own to point these tools at. The concepts will land much harder once you've felt the problem they solve.

And AI code breaks in a way regular code doesn't. A web app crashes with a stack trace, you get a line number, you fix it. A misconfigured training loop doesn't do you that favor. It runs for eight hours, burns $200 in GPU time, and hands back a model that has learned to predict the mean of every input. No exception. No red text. The loss curve even looks fine if you don't know what you're looking at.

The bug was never going to throw. It was a tensor sitting on the wrong device, a .detach() you forgot to call, or labels that leaked into the features three preprocessing steps ago. None of that raises an error. All of it wastes your afternoon and your compute budget.

So you need a different set of tools than the ones you'd reach for debugging a REST API. Not because Python debugging changes, but because the failure mode changes: from "it crashed" to "it lied to you convincingly."

Warm-up0/3
1. In a PyTorch training step, what does loss.backward() do?Warm-up
A tiny PyTorch training step with one weight w=1.5 and loss (w*2-1)**2 = 4.0. The line loss.backward() is highlighted, followed by optimizer.step(). The printed state before it is 1.5, 4.0 and None for w.grad.
2. Why do you hold out a test set when training a model?Warm-up
A bar of 1,000 labelled examples split into an 800-example train set and a 200-example test set, followed by three steps: split once before training, train on 800 for 10 epochs, and 200 held out, never used in step 2.
3. What does the learning rate control in gradient descent?Warm-up
Parabola loss = w squared with the point w=4.0 marked and an arrow to w=3.2, beside the update arithmetic grad = 8.0, lr = 0.1, w_new = 4.0 - 0.1 * 8.0 = 3.2.
Check: The Problem0/1
1. What makes debugging AI/ML code different from debugging a typical web application?Explain
Two terminals. First, a small web handler run on an empty list ends in a real ZeroDivisionError traceback with a line number. Second, a real training script prints epoch 1, 25 and 50 losses and then saved model.pt.

The Concept

I think about AI debugging as three levels, and almost everyone starts at the wrong one.

Mermaid diagram

Most people's first instinct when a model isn't learning is to open TensorBoard and stare at curves, which is level 3. But in my experience 80% of AI bugs live at levels 1 and 2: a print statement that shows a tensor has the wrong shape, or a breakpoint() that catches a NaN two lines before it would otherwise have propagated silently through the rest of the run. TensorBoard tells you training is unhealthy. It rarely tells you why. Levels 1 and 2 do.

Check: The Concept0/1
1. Why should print statements and breakpoints come before opening TensorBoard when a model is not learning?Explain
A TensorBoard-style loss curve from a real 300-step run that stays flat near 2.3, beside the lesson's three levels: 3 training dynamics, 2 tensor operations, 1 standard Python.

Build It

Part 1: Print debugging (yes, it still works)

Print debugging gets a bad reputation it hasn't earned. For tensor code specifically, one well-placed print beats stepping through a debugger, because you need to see shape, dtype, device, and value range all at once, not one variable at a time.

python
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()}")

Here's the kind of bug it actually catches. Say you move your model to GPU but forget to move a freshly-loaded batch of labels along with it. Nothing crashes right away; PyTorch either raises later, deep in the loss function, or in older code paths silently does the wrong thing. Call debug_print("labels", labels) right before you compute the loss and the line reads device=cpu while everything else says device=cuda:0. That single line tells you exactly what's wrong before the loss function gets a chance to confuse you with an unrelated error.

Drop this after every operation you don't fully trust. Find the bug, delete the prints. That's the whole workflow, and there's no shame in it.

Part 2: pdb and breakpoint()

The stdlib debugger is more useful for AI work than it gets credit for. But drop breakpoint() into a training loop unconditionally and you'll regret it. Stopping at every single step of a 10,000-step run isn't debugging, it's a loop you'll kill out of frustration before you find anything. The fix is a guard that only fires when something's actually gone wrong.

python
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()

That if guard is what makes this a conditional breakpoint: you drop into the interactive prompt only when the loss looks broken, not on every step. Once you're dropped in, a handful of commands cover most of what you need:

  • p outputs.shape: check shapes
  • p loss.item(): see the loss value
  • p torch.isnan(outputs).sum(): count NaNs
  • p model.fc1.weight.grad: check gradients
  • c to continue, q to quit

Part 3: Logging, once print outgrows itself

When your debugging session needs to survive past the terminal you're staring at, switch from print to logging.

python
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 buys you timestamps, severity levels, and a file that survives after your terminal scrolls or the SSH session drops. When a run dies at 3 AM, you want training.log, not a scrollback buffer you already lost.

Part 4: Timing code sections

You can't optimize what you haven't measured. A small context-manager timer tells you exactly where the seconds are going.

python
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()

The most common finding when people run this for the first time: data loading eats 60% of the wall clock. The fix is num_workers > 0 on your DataLoader, not a bigger GPU. I've seen people spend money solving a problem a keyword argument would have fixed for free.

Part 5: cProfile and line_profiler

When a manual Timer isn't granular enough:

bash
python -m cProfile -s cumtime train.py

That gives you every function call sorted by cumulative time. When you need to go line-by-line instead of function-by-function:

bash
pip install line_profiler
python
@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

When you need to know which line is eating RAM, not just how much a process holds in total, tracemalloc walks every allocation back to the line that made it.

python
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

For a running total instead of a single snapshot, memory_profiler reports the memory cost of each line as the function executes.

bash
pip install memory_profiler
python
from memory_profiler import profile
 
@profile
def load_data():
    raw = read_csv("data.csv")       # watch memory jump here
    processed = preprocess(raw)       # and here
    return processed

Run it with python -m memory_profiler your_script.py for a line-by-line memory report.

GPU memory with PyTorch

Neither of the above sees GPU memory, so check that directly through PyTorch's own allocator.

python
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 an OOM (the GPU couldn't find room for the tensor you tried to allocate), work down this list in order:

  1. Reduce batch size. It's always the first thing to try
  2. torch.cuda.empty_cache() to free cached memory
  3. del tensor followed by torch.cuda.empty_cache() for large intermediates
  4. Mixed precision (torch.cuda.amp), doing most of the math in a lower-precision format, to roughly halve memory usage
  5. Gradient checkpointing, trading extra recomputation for lower peak memory by not storing every intermediate activation, for very deep models

Part 7: The bugs you'll actually hit

Shape mismatch

This is the most common bug there is. A tensor comes in shaped [batch, features] when the model is expecting [batch, channels, height, width]. Instead of guessing where the mismatch happens, hook every module and print the transformation:

python
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 one sample batch, before you spend more than five minutes on a training run. It maps every shape transformation in the model in one shot.

NaN loss

NaN loss means something exploded, and it's almost always one of: learning rate too high, division by zero in a custom loss, log of zero or a negative number, or exploding gradients in an RNN. The log-of-zero case shows up more than people expect because loss functions like cross-entropy take the log of a predicted probability. If that probability rounds down to exactly zero, its log is negative infinity, and negative infinity flowing backward through the network is one of the most common routes to NaN.

python
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

If your model just hit 99% accuracy on the test set, that's not a win — that's a bug you haven't found yet.

python
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 False

Check for temporal leakage too, since future rows can end up predicting the past. Sort by timestamp before you split, every time.

Wrong device

Mixing CPU and GPU tensors usually throws a clear runtime error. The sneakier version is when one tensor silently stays on CPU while everything else runs on GPU — no crash, just a training run that's mysteriously slow.

python
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, once the small stuff is ruled out

Once you've ruled out the level 1 and 2 bugs above, TensorBoard earns its keep, because it shows you what's happening inside training over time, not just at one step.

bash
pip install tensorboard
python
from 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:

bash
tensorboard --logdir=runs

What the curves are telling you:

  • Loss not decreasing: learning rate too low, or an architecture problem
  • Loss oscillating wildly: learning rate too high
  • Loss goes to NaN: numerical instability, see the NaN section above
  • Train loss down, val loss up: overfitting
  • Weight histograms collapsing to zero: vanishing gradients
  • Gradient histograms exploding: you need gradient clipping

Take the overfitting row as a worked example, since it's the one people misread most often. Plot both curves and train loss keeps falling in a smooth line for the whole run. Val loss follows it down at first, flattens out around the middle of training, then turns and starts climbing while train loss is still dropping. That specific shape, not just "val loss is higher than train loss", is what overfitting looks like: the model is still finding ways to reduce error on examples it has already memorized, and those tricks stop transferring to examples it hasn't seen. The moment val loss turns upward is your signal to stop training, well before train loss tells you anything is wrong.

Part 9: The VS Code debugger

For interactive work, wire up a launch.json:

json
{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Debug Training",
            "type": "debugpy",
            "request": "launch",
            "program": "${file}",
            "console": "integratedTerminal",
            "justMyCode": false
        }
    ]
}

Set breakpoints in the gutter, inspect tensor properties in the Variables pane, and run arbitrary expressions in the Debug Console mid-execution. This is where I reach for stepping through a data preprocessing pipeline transformation by transformation, rather than breakpoint() in the terminal.

Check: Build It0/39
1. What is NaN when it shows up in a training loss?Define
A Python prompt where a tensor [1.0, nan, 3.0] is created, printed, and checked with isnan(), with real outputs. Below, a training log whose loss reads nan at step 500.
The log lines are illustrative.
2. What does an OOM error mean on a GPU?Define
Terminal from python train.py: step 0 prints a loss, then a traceback ends with torch.OutOfMemoryError: CUDA out of memory, Tried to allocate 2.00 GiB, GPU 0 has a total capacity of 15.77 GiB.
Typed, not captured: no GPU was used. The message text follows PyTorch's CUDA OOM template.
3. What is mixed precision?Define
An autocast block on CPU with bfloat16 that computes h = lin(x) and a cross_entropy loss, with the dtypes of h and loss shown as question marks. A table lists measured value sizes: float64 8 bytes, float32 4, float16 2, bfloat16 2.
4. What is gradient checkpointing?Define
Two code cards side by side: a plain four-block forward pass, and the same four blocks each wrapped in checkpoint(block, h) from torch.utils.checkpoint.
5. What does register_forward_hook let check_shapes do?Define
Excerpt of check_shapes: a three-layer Sequential model and the loop for name, module in model.named_modules() that calls module.register_forward_hook(make_hook(name)), highlighted. Below, the real named_modules names: empty string, '0', '1', '2'.
6. What does tracemalloc tell you that a process-wide memory total does not?Define
A six-line script load.py that builds three Python lists, then prints one process-wide memory figure; a real run printed 38.4 MiB.
7. What is a vanishing gradient?Define
Bar chart on a log scale of the gradient norm of each of 13 Linear layers in a 12-block sigmoid network after one real backward pass, with layer 1 the first and layer 13 the last.
Measured with PyTorch, seed 0, 13 Linear layers of width 32.
8. What is an exploding gradient?Define
Table from a real SGD run with learning rate 1.0 on a linear model: for selected steps the loss, the largest gradient magnitude and the largest weight magnitude, each growing overall from step 0 to step 11.
9. What is data leakage?Define
Two small tables for a house-price set: train.csv with ids 1042 to 1046 and test.csv with ids 1047, 1044 and 1048, where the row with id 1044 and identical values appears in both and is outlined.
10. What is a conditional breakpoint in the lesson's sense?Define
The training_step function with the line if loss.item() > 100 or torch.isnan(loss): breakpoint() inside it, and a real terminal run of four steps in which the loop prints steps 0 to 2, drops into a (Pdb) prompt at step 2 with loss 250.0, and continues to step 3.
11. What does a profiler measure?Define
Terminal with a real cProfile run of a script with load_data, augment and model_step, listing ncalls, tottime and cumtime per function, then a tracemalloc line reporting current and peak memory.
12. What does the debug_print utility report about a tensor in one line?Define
Code calling print(logits) and then debug_print('logits', logits), with the highlighted debug_print call. The terminal shows the real output of plain print on a 2 by 4 tensor containing one nan, and a question mark where the debug_print line would be.
13. What does a VS Code debugpy launch configuration let you do?Define
A .vscode/launch.json file with one configuration named Debug Training, type debugpy, request launch, program ${file}, console integratedTerminal and justMyCode false, plus the Run and Debug dropdown showing Debug Training next to a green play button.
14. Why switch from print statements to Python's logging module partway through a debugging session?Explain
Two terminals: a print-based training run over ssh that prints two step lines and then drops with client_loop: send disconnect: Broken pipe, and a logging-based version whose real formatted line carries a timestamp and [INFO] level: 2026-10-07 02:14:09,512 [INFO] step 100  loss 1.9200.
15. Why guard breakpoint() with an if condition instead of dropping it unconditionally into a training loop?Explain
A 10,000-step loop with an unguarded breakpoint() and a real pdb session that stops, prints step 0, continues, stops again at step 1, and again at step 2.
16. What is the difference between cProfile and line_profiler?Explain
Two real profiler outputs for the same training step with load_batch, forward and backward: tool A lists ncalls, tottime and cumtime per function; tool B lists hits and percent time per source line of train_step.
17. What is the difference between tracemalloc and memory_profiler?Explain
Two real outputs for one load_data function that builds two lists of one million elements. Tool A, a tracemalloc snapshot, prints a file:line with size, count and average. Tool B, memory_profiler, prints a table of lines with memory usage and increment.
18. Neither tracemalloc nor memory_profiler can see GPU memory. How do you check it instead?Explain
Two boxes, RAM for the Python heap and GPU VRAM holding a 1.0 GiB tensor created with device cuda. tracemalloc and memory_profiler point at RAM, and an unlabelled box with a question mark points at VRAM.
19. What is the most common finding when you time the sections of a training loop?Explain
Code wrapping a data-loading line, a forward pass and a backward pass each in a Timer context manager, followed by terminal output lines for the three sections with unknown times shown as question marks.
20. Why does log of zero so often lead to NaN loss with cross-entropy?Explain
Hand-written cross-entropy: softmax of logits [200.0, 0.0] printed as tensor([1., 0.]), then the highlighted line loss = -torch.log(p[1]) for target class 1, then loss.backward().
21. Beyond checking for overlapping IDs, what other kind of data leakage should you check for?Explain
A date-ordered timeline of 12 monthly rows with unique ids, a random split that colours 8 as train and 4 as test and mixes them along the timeline, and a terminal line showing the train and test id sets share no ids.
22. What does logging weights and gradients with writer.add_histogram let you track in TensorBoard?Explain
The lesson's logging loop calling writer.add_histogram for weights and gradients every 100 steps, and the eight tags it creates for a 784-128-10 model: weights/ and grads/ for 0.weight, 0.bias, 2.weight and 2.bias.
23. In training_step, loss.item() is 250.0 and torch.isnan(loss) is False. What happens?Apply
The conditional breakpoint code with condition loss.item() > 100 or torch.isnan(loss), followed by the step's values: loss.item() is 250.0 and torch.isnan(loss) is tensor(False).
24. A Timer wraps a block and prints '[forward pass] 0.0412s'. Where is the elapsed time computed?Apply
The lesson's Timer class with __init__, __enter__ and __exit__ methods and its print line, followed by a with Timer('forward pass') block and the printed line [forward pass] 0.0412s.
25. You decorated a function with @profile and want line-by-line timing. Which command runs it?Apply
A train.py file with a function train_step decorated with @profile, and a terminal where pip install line_profiler succeeded and ls shows train.py.
26. debug_print("labels", labels) prints device=cpu, while the other tensors say device=cuda:0. What does this tell you?Apply
Terminal output of three debug_print calls: inputs and outputs report device=cuda:0 while labels reports device=cpu, each with shape, dtype, min, max, mean and has_nan.
Values come from a real CPU run of debug_print. The device text is set to cuda:0 for inputs and outputs.
27. check_devices(model, x, y) prints 'WARNING: tensor 1 on cpu, model on cuda:0'. Which argument is on the wrong device?Apply
The lesson's check_devices function, then a call check_devices(model, x, y) that prints Model device: cuda:0 and the warning tensor 1 on cpu, model on cuda:0.
28. With train ids {1, 2, 3, 4} and test ids {4, 5}, what does check_data_leakage print?Apply
Two small tables, train_set with ids 1 to 4 and test_set with ids 4 and 5, the lesson's check_data_leakage function, and the call check_data_leakage(train_set, test_set) with no output yet.
29. A very deep network hits OOM even after you reduced batch size and cleared the cache. Which two techniques come next?Apply
An OOM error from a very deep network, with a checklist: reduce the batch size done, torch.cuda.empty_cache() done, next step unknown.
Typed, not captured: the error line follows PyTorch's CUDA OOM template.
30. In TensorBoard, a loss curve that oscillates wildly most likely means what?Apply
A TensorBoard-style loss/train curve from a real 300-step run that swings repeatedly between near zero and about 200 without diverging or settling.
Real run: one weight, loss = 5 * w**2, SGD with lr 0.39 and momentum 0.99.
31. The gradient histograms in TensorBoard are exploding. What should you add?Apply
Four TensorBoard-style histograms of the gradients of the first layer weights from a real run at steps 0, 6, 8 and 9, each labelled with its x-axis range: plus or minus 0.326, 21.8, 1.49e+05 and 8.98e+13.
32. Train loss keeps falling smoothly while val loss dips, flattens, then climbs. When should you stop training?Apply
Real loss curves for 1,200 epochs: the train loss falls from about 0.16 to 0.03, while the val loss falls to about 0.14, stays level, then rises to about 0.21 by the last epoch.
33. Your training run never crashes but is mysteriously slow. What can check_devices help you catch?Analyze
A training log that runs to step 5,000 without errors and with the loss falling, beside a note: no traceback, no warning, loss goes down, but the run is mysteriously slow.
Illustrative log; the loss values are made up.
34. Your model reaches 99% accuracy on the test set. Which bug should you suspect first?Analyze
A terminal run of evaluate.py printing test accuracy: 0.9900, and a nearly full bar marked 99 percent, as reported by the evaluation script.
Illustrative: only the 99% figure comes from the question.
35. Val accuracy is 99.5% on a time-ordered dataset split at random, and check_data_leakage returns False. What should you suspect?Analyze
A date-ordered row of 20 daily readings coloured by a random split into 16 train and 4 validation rows, mixed along the timeline, with a terminal showing val accuracy 99.5% and check_data_leakage(train, val) returning False.
36. A deep model fails with a shape error, and you cannot tell which layer received the wrong shape. What is the most efficient first step?Analyze
A Sequential model of two conv blocks, flatten and a final Linear, called on an 8 by 1 by 28 by 28 batch, and its real traceback: frames in container.py and linear.py ending in RuntimeError: mat1 and mat2 shapes cannot be multiplied (8x1568 and 6272x10). No layer name appears.
37. You see NaN loss at step 500. Which approach helps you find the root cause?Analyze
A real training log printing the loss every 100 steps, finite from step 100 to 400 and then loss nan at step 500.
38. A run hits OOM and the engineer enables gradient checkpointing on a small network before anything else. What is wrong with that order?Analyze
An out-of-memory error, a small network of three Linear layers with 530,442 parameters (2.0 MiB in fp32), and a diff that wraps block(h) in checkpoint(block, h) as the engineer's first change.
Typed, not captured: the error line follows PyTorch's CUDA OOM template.
39. A rare NaN may appear around step 40,000 of an overnight run over SSH that you cannot watch. Which design fits?Design
A terminal with ssh gpu-box and python train.py running overnight, above a timeline from launch in the evening through a possible rare NaN around step 40,000 to reading the results in the morning, annotated nobody at the keyboard and connected over SSH.

Use It

Here's the order I actually run through, and it catches most AI bugs before they cost you anything:

  1. Before training: run check_shapes on a sample batch. Confirm the dimensions match what you think they are, before you've spent a single GPU-second.
  2. First 10 steps: debug_print on loss, outputs, and gradients. Nothing should be NaN, and the ranges should look sane.
  3. During training: log loss, learning rate, and gradient norms. Let TensorBoard do the visualizing.
  4. When something breaks: breakpoint() right at the failure point. Inspect tensors interactively instead of guessing.
  5. For performance: time data loading against forward and backward passes. Only profile memory once you're actually near OOM.

Notice the order: cheap, fast checks first, expensive visual tools last. That's the whole point of the three levels from the Concept section.

Check: Use It0/2
1. You are about to launch a long training run. What is the first check in a sensible order?Apply
A terminal with the command python train.py about to launch, next to the lesson's example cost of a run that goes wrong unwatched: 8 hours of GPU time and about 200 dollars.
Illustrative setup; the cost figures are the lesson's example.
2. You inherit a never-debugged training script and have a short window before a GPU run. Which plan fits best?Design
A card listing an inherited train.py marked never debugged, beside an unscaled timeline with a short window between now and the GPU run.
Illustrative: the window is not to scale.

Ship It

Run the debugging toolkit:

bash
python code/debug_tools.py

See outputs/prompt-debug-ai-code.md for a prompt you can hand to an LLM to help triage AI-specific bugs (NaN loss, shape errors, silent training failures, OOM) instead of starting from a blank terminal every time.

Author's Note

This is the last lesson in the phase, and I think it's the one people skip the fastest. Everyone wants to get to the model. But every hour I've lost to AI code, I've lost to a silent bug, never a loud one. Silent bugs are the ones that don't respect your setup: the fancy compute, the clean shell, the well-configured editor from the earlier lessons in this phase don't save you from a tensor quietly sitting on the wrong device. The tools here are cheap and unglamorous — a print statement, a conditional breakpoint, a five-line shape hook — and that's exactly why they catch 80% of what actually goes wrong before you ever need to open TensorBoard.

Exercises

  1. Run debug_tools.py and 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.
  2. Profile a training loop with cProfile and identify the slowest function.
  3. Use tracemalloc to find which line in your data loading pipeline allocates the most memory.
  4. Set up TensorBoard for a simple training run and identify whether the model is overfitting.
  5. Use breakpoint() inside a training loop. Practice inspecting tensor shapes, devices, and gradient values from the debugger prompt.

Key Terms

TermWhat people sayWhat it actually means
NaN"Not a number"An invalid floating-point result that poisons every downstream computation
OOM"Out of memory"Not enough free VRAM for the tensor you tried to allocate
Mixed precision"Half precision"Most math in fp16/bf16 for speed and memory, fp32 only where precision matters
Gradient checkpointing"The recompute trick"Recompute activations during the backward pass instead of storing all of them
Forward hook"A callback"A function PyTorch fires after a module's forward pass, no code changes needed
tracemalloc"Memory tracer"Stdlib module that tracks which line allocated each block of memory
Vanishing gradient"Gradients disappear"Gradients shrink toward zero across many layers, so early layers barely update
Exploding gradient"Gradients blow up"Gradients grow uncontrollably during backprop, sending weights to huge or NaN values
Data leakage"Cheating"Test-set information sneaks into training and inflates accuracy that won't hold in production
Conditional breakpoint"Smart breakpoint"A breakpoint() guarded by an if, firing only when something's actually wrong

Code

Ships With