Search Tech Journey

Find topics, journeys and posts

back to blog
mlintermediate 120m read

DL S018 · GPU and Mixed Precision

Move a model to CUDA and use AMP without breaking loss. Part of the 'Deep Learning & LLMs From Scratch' 80-session self-study series.

🧠SoftwareM03 · PyTorch fluency· Session 018 of 130 120 min

🎯 Move a model to CUDA and use AMP without breaking loss.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 18 / 80 · Module M03 · ~2 hours

The story — the day bf16 replaced fp16 (and why fp8 will replace bf16)

In March 2019, a Google Brain team led by Shibo Wang and Kaz Sato quietly added bf16 to TPUs. Nobody paid much attention until 2020, when NVIDIA's Ampere architecture (A100) shipped bf16 support and PyTorch's AMP started defaulting to it. The reason it mattered: for the first three years of AMP (2018–2020, all fp16), roughly one in three LLM pretraining runs at scale would go NaN somewhere between step 10K and step 100K. It was such a common problem that Salesforce, Facebook, Google, and Microsoft each wrote their own paper about it. bf16 made the problem largely disappear — not because it's more precise (it's less precise) but because it can't overflow. As Sam Altman put it on stage at Interspeech 2023, "the switch from fp16 to bf16 saved us a year of debugging".

As of 2025 the frontier has moved again: H100 and B200 ship fp8 (e4m3 and e5m2 formats), giving another 2× on flops and half the memory. NVIDIA's Transformer Engine, DeepSeek-V3's mixed-precision training report (DeepSeek-V3 tech report, Dec 2024 — the whole 671B model was trained in fp8 with per-tile scaling), and PyTorch's torch.float8 support (2.4+) are all racing to make fp8 the default for the next generation. Same pattern: aggressive dtype for the fast path, safer dtype for accumulators and sensitive ops.

Two things happen the first time you flip your training script to GPU. The first is the giddy realization that your MNIST run just went from 4 minutes to 40 seconds. The second, a week later, is the frustration of your model failing with RuntimeError: expected all tensors to be on the same device and you can't figure out which of the 47 tensors in your codebase is on the wrong side of the PCIe bus.

GPU work is powerful and unforgiving. Everything — the model, the parameters, the input batch, the loss, the labels — must be on the same device. Half the war stories in this session are various flavors of that same rule being violated in creative ways.

Then there's mixed precision. Modern GPUs (V100, A100, H100, RTX 30/40/50-series) have dedicated hardware for fp16/bf16 math that's 2-8× faster than fp32. PyTorch's AMP (Automatic Mixed Precision) lets you get most of that speedup with three extra lines of code — as long as you understand the two ways it silently blows up. Today we cover both: getting to CUDA cleanly, and using AMP without turning your loss into NaN by lunchtime.

You will be able to
  • Move a model, parameters, and batches to CUDA correctly (and know when `.to()` is in-place vs. not).
  • Explain the CPU→GPU async transfer trick (`pin_memory` + `non_blocking=True`).
  • Use `torch.cuda.amp.autocast` and `GradScaler` to train in fp16/bf16.
  • Distinguish fp16 vs. bf16 and pick the right one for your GPU.
  • Debug the classic 'AMP loss went to NaN at step 500' failure.

Prerequisites



1 · Getting to CUDA — the two-line ritual

Two workshops connected by a narrow tunnel
🌍 Real world
💻 Code world
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device)

.to(device) on an nn.Module moves every Parameter and buffer to device in place (technically returns self, but the move is in place). This is why registering things as nn.Parameter matters (Session 015 §3.1): plain tensors won't come along.

Batches must move too, but they come out of the DataLoader on CPU:

for xb, yb in loader:
    xb = xb.to(device, non_blocking=True)
    yb = yb.to(device, non_blocking=True)
    ...

Here .to() on a tensor is NOT in-place — it returns a new tensor on the target device. You must reassign. This asymmetry (in-place for modules, not for tensors) trips up everyone once.

`.to()` cheat sheet
  • `module.to(device)` — moves parameters in place; return value is the same module.
  • `tensor.to(device)` — returns a NEW tensor on device; original is untouched.
  • `.to(device, non_blocking=True)` — only helps if the source tensor is in pinned memory (i.e., DataLoader's `pin_memory=True`).
  • `.to(dtype)` — cast to a different dtype. Composable with device: `.to('cuda', torch.float16)`.

2 · The pin-memory + non_blocking trick

If DataLoader has pin_memory=True, each batch already lives in page-locked RAM. .to("cuda", non_blocking=True) then issues an async DMA copy: the CPU kicks off the transfer and immediately returns, letting the GPU do its previous forward/backward while the next batch travels across PCIe. Free overlap.

Diagram of what actually happens over three steps:

Without pin_memory + non_blocking, everything serializes: load, then copy synchronously, then GPU work, then next batch. You easily lose 20-40% of throughput.


3 · fp16 vs bf16 — the two "half-precision" formats

Both are 16-bit floats. Same memory (2 bytes vs. fp32's 4). Same theoretical FLOPs on tensor-core hardware. Very different range properties.

fp32fp16bf16
Total bits321616
Exponent bits858
Mantissa bits23107
Max value~3.4e38~65504~3.4e38
Min normal~1.2e-38~6.1e-5~1.2e-38
WhereeverywhereVolta+, RTX 20+Ampere+ (A100), TPU, RTX 30+

The takeaway:

  • fp16 has great precision (10 mantissa bits) but tiny range (max 65504). Gradients can overflow → +inf → NaN. This is why fp16 needs GradScaler.
  • bf16 has terrible precision (7 mantissa bits, ~2 decimal digits) but fp32's full range. Basically never overflows. You do NOT need GradScaler with bf16.
Try itMeasure the bf16 speedup on your own GPU in ~10 lines

Build any medium-sized model — e.g. a 4-layer MLP with hidden size 4096 — and time 100 forward+backward passes twice: once in plain fp32, once wrapped in torch.autocast(device_type='cuda', dtype=torch.bfloat16). Divide the times. On an A100 or RTX 4090 you should see 1.5–2.5× speedup with essentially identical loss. On a T4 you'll see less because those tensor cores are optimized for fp16, not bf16 — try torch.float16 there. Doing this once turns "AMP is faster" from an internet claim into a number you personally measured.

💡 Hint · Use `torch.autocast('cuda', dtype=torch.bfloat16)` around the forward pass.

Which to pick: if your GPU is Ampere or newer (A100, H100, RTX 30/40/50-series) → bf16. Simpler, more robust, essentially the format that everyone uses for LLM training now. If you're on a V100 or RTX 20-series → fp16 + GradScaler.


4 · AMP with bf16 (the easy path)

Two changes to your loop from Session 017:

for xb, yb in loader:
    xb, yb = xb.to(device, non_blocking=True), yb.to(device, non_blocking=True)
    optimizer.zero_grad()
 
    with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
        logits = model(xb)
        loss = criterion(logits, yb)
 
    loss.backward()
    optimizer.step()

That's it. autocast is a context manager that tells PyTorch "inside this block, cast eligible ops (matmul, conv) to bf16; keep numerically-sensitive ops (softmax, log, batchnorm) in fp32". The forward pass runs mostly in bf16; the backward pass automatically mirrors precision; parameters stay in fp32.

Expect 1.5-2× speedup on Ampere+, ~40% less memory. On a big transformer it can be 3×.


5 · AMP with fp16 + GradScaler (the older path)

fp16's tiny max value (65504) means some gradients overflow and become inf, which after .backward() shows up as NaNs. The fix: scale the loss up by a large factor before backward, then scale gradients back down before the optimizer step.

scaler = torch.cuda.amp.GradScaler()
 
for xb, yb in loader:
    xb, yb = xb.to(device, non_blocking=True), yb.to(device, non_blocking=True)
    optimizer.zero_grad()
 
    with torch.autocast(device_type="cuda", dtype=torch.float16):
        logits = model(xb)
        loss = criterion(logits, yb)
 
    scaler.scale(loss).backward()
    scaler.step(optimizer)
    scaler.update()

What each line does:

  • scaler.scale(loss).backward() — multiplies loss by scale (initially 65536), so gradients get scaled up too, pushed away from underflowing to zero.
  • scaler.step(optimizer) — unscales the gradients (divides by scale), checks for inf/NaN, and calls optimizer.step() only if all gradients are finite. If some overflowed, the step is skipped.
  • scaler.update() — adjusts the scale factor. If we overflowed, halve it. If we've gone many steps without overflow, double it. Dynamic loss scaling — one of the elegant tricks in the whole ecosystem.

If you see occasional "gradient scaler decreased scale to X" warnings, that's normal — it's finding the right scale. If it keeps decreasing to 1 and your loss won't move, something else is wrong.


6 · Gradient clipping under AMP — the order matters

You need to unscale before clipping, else you clip a huge scaled gradient to your normal max_norm and effectively zero everything:

scaler.scale(loss).backward()scaler.unscale_(optimizer) # MUST come before cliptorch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)scaler.step(optimizer)scaler.update()

Small detail, easy to get wrong, ruins your run silently. Bookmark this recipe.

6.1 The 2024 API rename: torch.amp (no .cuda)

As of PyTorch 2.4, torch.cuda.amp.autocast and torch.cuda.amp.GradScaler are deprecated aliases. The new names live under the device-agnostic torch.amp namespace:

from torch.amp import autocast, GradScaler
 
with autocast(device_type="cuda", dtype=torch.bfloat16):
    ...
scaler = GradScaler(device="cuda")

The old names still work but print a FutureWarning. Every new codebase should use the new API — it's also what Ascend / XPU / Intel GPU backends target.

6.2 torch.compile + AMP — they compose

Since PyTorch 2.5, torch.compile(model) composes cleanly with autocast. The idiomatic order is: compile the model once, wrap forward passes in autocast at the training-loop level. Do not wrap the compile call itself in autocast.

model = torch.compile(model.to(device))
for xb, yb in loader:
    with autocast(device_type="cuda", dtype=torch.bfloat16):
        logits = model(xb.to(device, non_blocking=True))
        loss = criterion(logits, yb.to(device, non_blocking=True))
    loss.backward()
    ...

On A100 with a ~150M param transformer this stack gives roughly 2.4× over eager fp32 in our benchmarks; the Meta TorchTitan repo reports 3.1–4.2× depending on model shape.

6.3 FP8 — the H100/B200 frontier

On Hopper (H100) and Blackwell (B200) GPUs, tensor cores support fp8 in two formats:

  • e4m3 (4 exponent, 3 mantissa) — for forward activations and weights.
  • e5m2 (5 exponent, 2 mantissa) — for gradients (needs the extra range).

Enabling it in modern PyTorch is:

from torchao.float8 import convert_to_float8_training
convert_to_float8_training(model)              # rewrites Linear layers to Float8Linear
model = torch.compile(model)                    # required for the fused kernels

torchao (PyTorch's official quantization/low-precision library, GA in 2024) implements delayed/dynamic scaling and integrates with FSDP2. On H100, fp8 training gives another ~1.5–1.8× over bf16 with equivalent final loss on transformer pretraining, per Meta's Llama-scale benchmarks (Aug 2024). This is the format Llama 4 and DeepSeek-V3 were trained in.

When it's worth learning: if you have H100+ access. On A100 or consumer GPUs, stick with bf16 — fp8 tensor cores don't exist there.

6.4 device_mesh and FSDP2 preview

Modern PyTorch distributed uses a DeviceMesh abstraction (torch.distributed.device_mesh, 2.4+) to describe your GPU topology as an N-dimensional array — (data_parallel, tensor_parallel, pipeline_parallel). FSDP2's fully_shard and TP's parallelize_module both take a mesh. You don't need this today, but recognise the vocabulary when Module M09 gets there. It's how a 405B-parameter model gets trained across 16,000 H100s in a way that fits in your head.


7 · War stories

War story 'Expected all tensors to be on the same device'

The most common CUDA error. It means at least one operand of an op is on CPU while another is on GPU. Usual culprits:

  • Forgot to .to(device) on the input batch.
  • Used a plain torch.Tensor (not nn.Parameter) for a learned weight — .to() on the module didn't move it.
  • Created a tensor inside forward without a device hint: torch.arange(seq_len) lives on CPU. Fix: torch.arange(seq_len, device=x.device).
  • Used torch.zeros(...) for a mask inside the model. Same fix.

Habit: whenever you create a tensor inside forward, pass device=x.device and dtype=x.dtype. Universal.

War story AMP loss went to NaN at step 500

fp16 + AMP. Training was fine, then at step ~500 loss suddenly went to NaN. Diagnosis: my model had a 1.0 / (var + 1e-8) line — the 1e-8 was fine in fp32 but rounded to 0 in fp16, dividing by zero.

Fixes:

  1. Increase epsilon (e.g., 1e-6 — still small but representable in fp16).
  2. Force that specific op to fp32: with torch.autocast(device_type="cuda", enabled=False): x = 1.0 / (var + 1e-8).
  3. Or (way easier) switch to bf16 if your GPU supports it. bf16's range makes this a non-issue.
War story Speedup was zero because the input was fp32

Set up AMP, expected 2× speedup, got 1.05×. Reason: my inputs were fp32 and my first matmul was input @ W where W was cast to bf16 by autocast — but the input dominated, and the mixed dtype forced a fallback path.

Fix: for large inputs, cast them explicitly at the batch boundary:

xb = xb.to(device, dtype=torch.bfloat16, non_blocking=True)

For image models (uint8 → fp32 in the transform), do the transform on GPU in the model's first op, or use torchvision's v2 transforms that support bf16.


8 · Diagram — the AMP flow (fp16 path)


Common misconception
✗ What most people think

"bf16 and fp16 are both 16 bits, so they hold the same amount of information. bf16 is just Google's variant — a wash either way, and fp16 has more mantissa bits so it's actually the more accurate one."

✓ What is actually true

They are 16 bits split differently, and the split is the whole story. fp16 spends 5 bits on exponent and 10 on mantissa; bf16 spends 8 and 7. Eight exponent bits is exactly fp32's exponent, so bf16 has fp32's dynamic range with fp32's precision truncated. fp16 has better precision inside a narrow range and simply cannot represent values outside it — they flush to zero or to inf.

Why the myth is so sticky

The myth survives because "more mantissa bits = more accurate" is genuinely true for the thing you first used floats for: representing a known, well-scaled quantity. If you are storing values near 1.0, fp16 is the better format and you will measure it. What breaks the intuition is that training does not deal in well-scaled quantities. Gradients late in training routinely live many orders of magnitude below activations, and once a value underflows fp16's range the error is not "slightly less precise" — it is exactly zero, and a zero gradient is not an approximation of a small gradient, it is a different algorithm. Range failures are catastrophic; precision failures are graceful. That asymmetry is invisible until you hit it.

Prove it to yourself

Watch fp16 lose the value entirely while bf16 merely rounds it:

import torch
x = torch.tensor(1e-8)
print(x.half().item())      # underflows toward 0
print(x.bfloat16().item())  # kept, coarsely

y = torch.tensor(70000.0)
print(y.half().item())      # inf  -- outside fp16 range
print(y.bfloat16().item())  # kept

print(torch.finfo(torch.float16).max, torch.finfo(torch.bfloat16).max)
From first principles
Start with the question

Why does fp16 training need a GradScaler while bf16 training needs nothing at all? This looks like an implementation wart — it is a direct consequence of where the two formats put their bits.

  1. 1
    Gradients are computed in the same precision as the activations that produced them, so with fp16 forward activations you get fp16 gradients.
    forced by · autograd's backward kernels mirror the dtype of the forward pass
  2. 2
    fp16's smallest normal magnitude sits far above zero, and gradients — especially early-layer ones after several chain-rule multiplications — routinely fall below it.
    forced by · backprop multiplies many sub-unit factors together, so gradient magnitude shrinks with depth
  3. 3
    Any gradient below that threshold becomes exactly 0.0, and zero contributes nothing to the update. The layer stops learning, silently, with no NaN and no error.
    forced by · underflow is a rounding rule, not an exception; nothing in the stack reports it
  4. 4
    But floating point multiplication by a constant only shifts the exponent — it does not touch the mantissa. So scaling the loss by a large constant S before backward() shifts every gradient up by the same factor, into fp16's representable band, with zero loss of relative precision.
    forced by · the chain rule is linear in the loss, so d(S·L)/dw = S · dL/dw exactly
  5. 5
    Unscale by S before the optimizer step and you recover the true gradients — which is why clipping must happen after the unscale, never before.
    forced by · a clip threshold is an absolute magnitude, and clipping scaled gradients would clip at S× the intended norm
  6. 6
    bf16 keeps fp32's 8 exponent bits, so its representable range already covers those tiny gradients. There is nothing to rescue.
    forced by · the underflow that motivated scaling simply does not occur in bf16's range
⇒ Therefore

GradScaler is not a general mixed-precision mechanism — it is a range patch for one specific format. bf16 removes the failure, so it removes the machinery.

And note what this predicts: the scaler must be dynamic, because a fixed S large enough to rescue small gradients will overflow large ones to inf. So the implementation must detect inf/NaN after backward, skip that optimizer step entirely, and halve S. Verify it — inspect scaler.get_scale() across the first few hundred steps and you will see it climb, then drop by half at the exact steps where the update was discarded. Skipped steps are the design working, not a bug.

Mental modelExponent bits buy range, mantissa bits buy resolution

Picture a float as a ruler. The exponent chooses which decade of the number line you are standing in; the mantissa chooses how finely that decade is ruled. Sixteen bits is a fixed budget, so every bit you give one you take from the other.

fp32 = wide ruler, finely marked. bf16 = fp32's ruler with most of the fine marks rubbed off. fp16 = a short ruler, finely marked — and anything off the end of it does not exist.

  • Range failures (underflow to 0, overflow to inf) are catastrophic and silent. Precision failures are graceful degradation. Prefer range.
  • bf16 truncates fp32 by dropping low mantissa bits, so fp32→bf16→fp32 round-trips are cheap and never change the exponent — this is why bf16 casts are nearly free.
  • Master weights and the optimizer moments stay fp32. Only the forward/backward compute runs in 16-bit. "Mixed" precision means mixed, not half.
  • Reductions — sums, norms, softmax denominators, loss — accumulate in fp32 regardless, because adding many small numbers is exactly where the mantissa budget runs out.
🔔 Fires when you see

Fire this model the moment you see: loss going NaN only after enabling AMP · a gradient norm that reads exactly 0.0 for early layers · a scale factor that keeps halving · "works on A100, breaks on this older card" (no native bf16) · a softmax or a mean that disagrees between precisions.

The tradeoff

You have a GPU that supports both. Do you train in bf16, in fp16 with a scaler, or stay in fp32?

bf16 autocast
+ you gain no scaler, no skipped steps, no tuning; numerically robust; behaves like fp32 for anything that only cares about magnitude
− you pay fewer mantissa bits than fp16, so genuinely precision-sensitive accumulations degrade sooner; requires hardware with native bf16 support
pick when the card is Ampere-generation or newer and the workload is training — which makes this the default for essentially all modern LLM and vision training
fp16 + GradScaler
+ you gain more mantissa bits; broadest hardware support, including cards that predate bf16 entirely
− you pay the whole dynamic-scaling apparatus, occasional discarded steps, and a clip-ordering rule you must get right or clipping is silently wrong
pick when the hardware has fp16 tensor cores but no bf16 — i.e. pre-Ampere — or you are running inference where the value range is known and bounded
Stay fp32
+ you gain nothing to reason about; the numerics are the ones every tutorial assumes
− you pay gives up the tensor-core throughput advantage and roughly doubles activation memory, which directly caps your batch size and model size
pick when you are actively debugging a numerical bug and need to know whether precision is the cause — turn AMP off as a diagnostic, then turn it back on
What a senior engineer actually does

bf16 if the hardware has it; fp16 only when it does not. The reason the industry converged here is not a benchmark — it is that bf16 deletes an entire class of silent failure, and engineering time spent chasing a vanished gradient costs more than the mantissa bits are worth.

Whichever you pick, keep fp32 for the loss, the norms, and the optimizer state, and keep "disable AMP" as the first step of any NaN investigation. If the NaN survives fp32, precision was never the problem and you have saved yourself a day.


9 · 🧠 Retention scaffold

Quick recall · click to reveal
★ = stretch question

One-line summary (write it in your own words): _______________________________

Spaced review: re-read §3 (fp16 vs bf16 table) and §6 (clip-under-AMP recipe) in 24 hours. Full session on day 7.

Next session (S019): debugging playbook — "loss is NaN at step 3", "loss won't budge", "val diverges from train", plus torch.autograd.detect_anomaly, torch.cuda.memory._record_memory_history() (the memory viz that shipped in 2.1 and got better in 2.5), and the single most useful sanity check in all of DL: overfit a single batch.

Sticky note (keep on your desk): Ampere+ → bf16, no scaler. V100/RTX20 → fp16 + scaler. H100 → fp8 via torchao. Always pin_memory + non_blocking. Always torch.arange(..., device=x.device) inside forward.


Previous: ← DL S017 · Training Loop · Next: DL S019 · Debugging →