TAI BUI
Back to curriculum
CapstonePython

Gradient Clipping and Mixed Precision

One bad batch can spike the gradient norm by three orders of magnitude, and FP16 adds an overflow risk on the loss side. Clip the global L2 norm, wrap the step in autocast and GradScaler, and skip cleanly on NaN or Inf while logging the scaling factor for forensics.

TIME: ~90 minutesPREREQUISITES: Phase 19 lessons 30-37

Learning Objectives

  • Compute the global L2 norm over all parameter gradients and clip in place when it exceeds a configured threshold.
  • Wrap a training step in autocast plus a GradScaler so FP16 forward and backward passes survive overflow.
  • Detect NaN and Inf in the loss or gradient, skip the optimizer step, and log the skip.
  • Report the GradScaler's scaling factor every step so a long sequence of skips is visible immediately.

The Problem

A training run that ran clean yesterday produces a loss curve that goes vertical at step 8,217. The culprit is a single batch with a gradient norm of 4,200, twenty times the previous peak. Without clipping, the optimizer applies a step that resets everything the model learned over the previous hour. With a global L2 clip at norm 1.0, that same batch contributes a unit-norm update, the loss stays on its trend line, and the run survives.

Mixed-precision training raises throughput by 2-3x by computing the forward pass and most of the backward pass in FP16. The cost is FP16's narrow exponent range. A gradient that overflows in FP16 evaluates to Inf, which propagates through later layers as NaN, which sets every weight to NaN at the next optimizer step. PyTorch's GradScaler handles this by multiplying the loss by a large scaling factor before the backward pass and dividing the gradients by the same factor before the optimizer step. If any gradient is Inf or NaN at unscale time, the scaler skips the step and halves the factor. If the previous N steps were clean, it doubles the factor. Over a run, the factor settles at the highest value the FP16 range allows.

The build problem is wiring the two together. Clip before unscale and the threshold applies to scaled gradients. Clip after unscale and the order of GradScaler calls matters. The right order is scaler.scale(loss).backward(), then scaler.unscale_(optimizer), then the clip (clip_global_l2_norm in this lesson, clip_grad_norm_ in stock PyTorch), then scaler.step(optimizer), then scaler.update(). Any other order gives a loop that is silently broken.

Warm-up0/3
1. An optimizer applies a gradient roughly 20 times larger than any it has seen so far. What is the most likely effect on the weights?Warm-up
2. In standard IEEE floating-point arithmetic, what does Inf minus Inf evaluate to?Warm-up
3. What is the Euclidean (L2) norm of the vector [5, 12]?Warm-up
Check: The Problem0/5
1. Why can half-precision (FP16) training produce Inf values that FP32 training would not?Explain
2. What is the correct order of calls in one mixed-precision training step with clipping?Explain
3. What happens when the GradScaler finds an Inf or NaN gradient at unscale time?Explain
4. A run's loss curve goes vertical at step 8,217. The culprit batch had a gradient norm of 4,200, twenty times the previous peak. Which missing ingredient explains the damage?Analyze
5. On CUDA, grad_l2_pre_clip reads thousands of times larger than in earlier runs and post_clip is pinned at 1.0 on every step. Which wiring bug fits?Analyze

The Concept

Mermaid diagram

Global L2 norm

The global L2 norm is the Euclidean norm of the concatenated gradient vector across all parameters, not a per-parameter norm. PyTorch ships it as torch.nn.utils.clip_grad_norm_(parameters, max_norm). This lesson computes the norm by hand instead (compute_global_l2_norm) and scales the gradients in place by max_norm / (pre_clip + 1e-12) when the norm exceeds max_norm. clip_global_l2_norm returns (pre_clip, post_clip), so you can log both the natural and the clipped value. You need both to diagnose "we are clipping at every step."

autocast and GradScaler

torch.amp.autocast(device_type) is a context manager that runs eligible operations (most matmul-class ones) in FP16. torch.amp.GradScaler(device_type) is the helper that scales the loss before backward and inverse-scales the gradients before the optimizer step. They are designed as a pair, so a GPU loop uses both.

This lesson uses CPU autocast because that is what runs in CI. The same pattern transfers verbatim to CUDA by changing device_type="cpu" to device_type="cuda". On CPU the GradScaler is a stub, since CPU autocast already operates in BF16 by default and does not need loss scaling. In this lesson's code the scaler is only enabled when device_type is "cuda". The call sites stay in anyway, so the wiring is identical to the GPU loop.

NaN and Inf detection

Detection happens in two places. First, the loss is checked with torch.isfinite before backward. An Inf or NaN loss does not produce useful gradients, so the step is skipped without entering the optimizer. Second, after scaler.unscale_(optimizer), has_non_finite_grad(...) scans the unscaled gradients and treats any Inf or NaN as a skip. Between them the two checks cover the forward-pass and backward-pass failure modes.

Scaling factor diagnostics

The scaling factor is the GradScaler's internal state. Each step, read scaler.get_scale() and log it next to the learning rate and gradient norm. A healthy run shows the factor climbing in powers of two until it saturates near 2^17 or 2^18. A misbehaving run shows it oscillating between high and low values, which means the gradients are sometimes in range and sometimes not. Without the log, you cannot see any of this. On CPU the scaler is disabled, so the logged value stays constant.

Check: The Concept0/15
1. What is the global L2 norm of a model's gradients?Define
2. What does an autocast block do?Define
3. What does a GradScaler do around the backward pass?Define
4. In this training loop, what is a skip?Define
5. What is the scaling factor in a GradScaler?Define
6. What does clip_global_l2_norm return, and why does that matter?Define
7. Why does clip_global_l2_norm multiply every gradient by the same factor, max_norm / pre_clip?Explain
8. Why is the loss finiteness check needed even with a GradScaler?Explain
9. After scaler.unscale_(optimizer), what does has_non_finite_grad check for?Explain
10. Which failure can only the post-unscale gradient scan catch, not the loss check?Explain
11. Why can the same autocast and GradScaler code run on CPU in CI and later on a GPU?Explain
12. Why switch from FP16 to BF16 autocast when overflow-driven skips are frequent?Explain
13. Your log shows grad_l2_pre_clip between 3 and 6 on every step, while grad_l2_post_clip is always 1.0. What does this suggest?Analyze
14. A CPU run's log shows an identical scaler_scale on every row, including the injected-Inf SKIP row. Why?Analyze
15. CI has only CPUs, but the same loop must train on GPUs later with no code branches. Which design fits?Design

Build It

code/main.py implements:

  • clip_global_l2_norm: computes the global L2 norm by hand (compute_global_l2_norm), scales gradients in place when the norm exceeds max_norm, and returns (pre_clip, post_clip). It returns early, leaving gradients untouched, when the norm is non-finite or already within max_norm.
  • has_non_finite_grad: a helper that scans gradients for NaN and Inf.
  • AmpTrainState: wraps a model, an AdamW optimizer, a GradScaler, and an autocast device. Its step(inputs, targets) runs the full clipping, scaling, and skip-on-NaN pipeline.
  • StepLog and SkipLog: structured per-step records.
  • A demo that trains a small Linear(16, 32) / GELU / Linear(32, 4) model with AdamW and MSE loss for 20 steps, injects an Inf into the gradient on step 5 to exercise the skip path, and prints the resulting log.

Run it:

bash
python3 code/main.py

The script exits zero and prints a per-step log with each row tagged STEP or SKIP. At least one row is a SKIP.

Check: Build It0/8
1. A parameter's gradient is [6.0, 8.0]. After clip_global_l2_norm with max_norm=1.0, what are the returned pair and the gradient?Apply
2. A parameter's gradient is [0.3, 0.4] and max_norm is 1.0. What does clip_global_l2_norm do?Apply
3. Two parameters have gradients [3.0, 0.0] and [4.0]. With max_norm=2.5, what are the gradients afterwards?Apply
4. Gradients are all non-finite, so the norm computes to Inf. What does clip_global_l2_norm(params, 1.0) do?Apply
5. A caller passes max_norm=0.0 to clip_global_l2_norm. What happens?Apply
6. In the 20-step demo, an Inf is injected into a gradient at step 5 on CPU. What does the step 5 row show, and what follows?Apply
7. In a test, the loss function is replaced with one that returns preds.sum() * inf. What does step() return?Apply
8. The demo trains 20 steps with one skip at step 5 and prints rolling_skip_rate(log, window=10)[-1]. What is printed?Apply

Production Patterns

Four patterns turn the loop into a production training step.

Skip counter as an alert, not a log line. A handful of skipped steps per run is healthy. Hundreds of skips per epoch should raise a hard alert: the model is in a regime FP16 cannot hold, and the loop is failing silently. The lesson tracks a 1,000-step rolling skip rate, and in production you would page on a rate above 5 percent.

Clip threshold lives in the config. max_norm = 1.0 is the modern default for language-model training. Sweep it on a small model first. Larger thresholds let the model recover from genuinely difficult batches, while smaller thresholds bound the worst case at the cost of a noisier loss curve. Put the threshold in the same YAML or JSON config as the schedule from lesson 44.

Norm log goes to a CSV with the schedule. The columns are step, lr, grad_l2_pre_clip, grad_l2_post_clip, loss, skipped, skip_reason, scaler_scale. A reviewer who opens the file sees the schedule, the gradient story, the scaling factor, and the skip outcome with its reason in one row. Splitting the columns across files is a recipe for misaligned analyses.

scaler.update() runs on clean steps and on non-finite-gradient skips. On a clean step the scaler reads its no-inf counter, increments it, and possibly doubles the factor. On a non-finite-gradient skip it halves the factor and resets the counter. Forgetting update() on that path produces the bug where the scaling factor never changes. A non-finite-loss skip is the exception: scaler.scale(loss).backward() never ran, so calling update() would break GradScaler's call ordering, and the loop deliberately leaves it out.

Check: Production Patterns0/10
1. Why does the lesson put the schedule, both norms, the loss, the scale and the skip outcome in one CSV row?Explain
2. What is the production signal in the rolling skip rate?Explain
3. Why does the loop read scaler.get_scale() and log it every step?Explain
4. A log has skip flags [True, True, False, False, False] and rolling_skip_rate uses window=2. What is the rate at index 2?Apply
5. On CUDA, repeated Inf-gradient skips appear in the log, but scaler_scale never changes. What is the most likely bug?Analyze
6. A teammate adds scaler.update() to the non-finite-loss skip path for consistency. What goes wrong?Analyze
7. A run logs 400 skips in one epoch, yet the loss curve looks smooth. How should you read this?Analyze
8. A run logs scaler_scale bouncing between 65536 and 16384, with a cluster of non_finite_grad skip rows. What is the best first interpretation?Analyze
9. You are setting max_norm for a new language-model run. Which plan fits?Design
10. A post-mortem must tell whether each skip came from the loss or the gradients, and whether the scale was oscillating. Which logging design fits?Design

Use It

Production patterns:

  • Autocast device matches optimizer device. Use torch.amp.autocast(device_type="cuda") for GPU training and torch.amp.autocast(device_type="cpu") for CPU. A mismatch produces a silent type error: the loss curve looks fine while the model is not learning.
  • Loss check before backward. torch.isfinite(loss).all() is one tensor reduction. The cost is negligible, and on a NaN loss it saves an entire training step. Run it on every step.
  • set_to_none=True in zero_grad. This sets gradients to None instead of zero, which lets the optimizer skip computation for unaffected parameter groups. It is a free throughput improvement and shrinks the bug surface slightly.
Check: Use It0/2
1. What does passing set_to_none=True to zero_grad change?Explain
2. What is the risk if the autocast device_type does not match the device the model runs on?Explain

Ship It

On a real project, outputs/skill-clip-amp.md would describe which clip threshold and autocast device the training step uses, where the per-step CSV lives in version control, and what the production skip-rate alert threshold is. This lesson ships the engine.

Exercises

  1. Replace the synthetic Inf injection with a real loss spike (multiply one batch's target by 1e8) and verify the skip path triggers.
  2. Add a --bf16 mode that switches autocast to BF16 instead of FP16. BF16 has a wider exponent range than FP16 and rarely needs loss scaling, so overflow-driven skips should largely disappear. The injected Inf still triggers its skip, so compare the skip counts with that step excluded.
  3. Add a unit test that the gradient-clip wrapper returns the pre-clip and post-clip norm correctly when no clipping occurs.
  4. Add a rolling-window skip-rate computation and a CLI flag that fails the run if the rate exceeds a configured threshold for 100 consecutive steps.
  5. Wire the loop to write the canonical CSV (step, lr, grad_l2_pre_clip, grad_l2_post_clip, loss, skipped, skip_reason, scaler_scale) and confirm the file survives a Ctrl-C by flushing after every row.

Key Terms

TermWhat people sayWhat it actually means
Global L2 norm"Clip target"Euclidean norm of the concatenated gradient vector across all trainable parameters
autocast"Mixed precision"Selective FP16 (or BF16) execution of eligible operations inside a with block
GradScaler"Loss scaler"Helper that multiplies the loss before backward and inverse-scales gradients before the optimizer step
Skip"Bad step"An optimizer step refused because the gradient or loss was non-finite; the scaler halves the factor after a non-finite-gradient skip
Scaling factor"Scaler state"The GradScaler's current multiplier; doubles after clean stretches and halves on a non-finite-gradient skip (a non-finite-loss skip leaves it unchanged, and on CPU the scaler is disabled)
Author's Note

My bet is that call order is the part to get right first. Any other order gives a silently broken loop, while max_norm is a value I'd sweep on a small model afterward. I'd keep skip_reason in the CSV so a skip row says which check fired, the loss check or the gradient check. And when a run looks off, I'd read the scaler_scale column first: an oscillating factor means the gradients are sometimes in FP16 range and sometimes not.

Further Reading

Code