Mixed precision with autocast and GradScaler
autocast runs the heavy maths in half-size numbers while keeping the weights in full precision, and GradScaler stops the small numbers from vanishing to zero.
- 7 min read
- 3 reading levels
- Published
Read these first
On this page 5
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
Mixed precision means doing the rough work in small, fast numbers while keeping the final records in big, precise ones.
Think of a shopkeeper during rush hour. For each customer he rounds in his head — quick, small mental numbers. But the account book at the end of the day is written carefully, to the paisa. Rounding the mental maths is fine because the book stays exact.
Deep learning does the same. The millions of multiplications per step run in a compact number format. The model's stored weights stay in the full format.
Why it exists
Numbers in a computer come in sizes. The standard size takes four bytes of space. There is a half-size format that takes two.
Half-size numbers bring two gifts. They take half the memory, so bigger batches fit. And modern NVIDIA GPUs have special hardware — tensor cores, circuits built for half-size maths — that make them dramatically faster.
The catch: half-size numbers cannot hold very small values. During learning, some correction signals are genuinely tiny. Squeezed into the small format they become exactly zero, and learning silently stops. Mixed precision is the trick that takes the gifts while dodging that trap.
How it works
weights stay full-size (the account book)
|
heavy maths runs half-size <- fast, small (autocast)
|
corrections get multiplied up before flowing back
so tiny values survive the trip (the scaler)
|
then divided back down, applied to the full-size weightsThe multiply-up-then-divide-down step is called loss scaling. It is like writing amounts in paise instead of rupees so nothing rounds to zero — then converting back at the end.
A real example you have seen
Practically every large model you have heard of — the GPT family, Stable Diffusion, Llama — was trained this way. Without half-size maths, training bills and times would roughly double. It is not an exotic option; it is the default of the whole industry.
Remember this
- Heavy maths runs in half-size numbers; stored weights stay full-size.
- The scaler multiplies corrections up so tiny values do not vanish to zero.
- You get roughly half the activation memory and a real speed boost on modern GPUs.
What to learn next
- Gradient checkpointing — the other big activation-memory lever.
- Fast attention with scaled_dot_product_attention — kernels that need half precision to shine.
- Gradient clipping — and the unscale-first rule.
Developer — Code and libraries.
Setup
pip install torchRuns on CPU and GPU. On CUDA the half format is float16; on CPU, autocast uses bfloat16. Outputs captured with torch 2.5.1 on an NVIDIA RTX A6000.
See what autocast actually changes
import torch
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
amp_dtype = torch.float16 if device == "cuda" else torch.bfloat16
model = nn.Linear(64, 64).to(device)
x = torch.randn(8, 64, device=device)
with torch.autocast(device_type=device, dtype=amp_dtype):
matmul_out = model(x) # matmuls run in the small dtype
summed = matmul_out.sum() # reductions stay in float32
print("inside autocast, linear output:", matmul_out.dtype)
print("inside autocast, the sum: ", summed.dtype)
print("the weights themselves: ", model.weight.dtype)inside autocast, linear output: torch.float16 inside autocast, the sum: torch.float32 the weights themselves: torch.float32
Three facts in three lines. autocast is a context manager that reroutes each operation to a chosen precision. Matrix multiplies — safe and fast in half precision — get float16. Sums and other error-accumulating reductions stay float32. And the weights are never touched.
The full training pattern
import torch
import torch.nn as nn
device = "cuda" if torch.cuda.is_available() else "cpu"
amp_dtype = torch.float16 if device == "cuda" else torch.bfloat16
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(32, 64), nn.ReLU(), nn.Linear(64, 1)).to(device)
opt = torch.optim.SGD(model.parameters(), lr=0.05)
scaler = torch.amp.GradScaler(device, enabled=(amp_dtype == torch.float16))
x = torch.randn(64, 32, device=device)
y = x.mean(dim=1, keepdim=True)
for step in range(5):
opt.zero_grad(set_to_none=True)
with torch.autocast(device_type=device, dtype=amp_dtype):
loss = nn.functional.mse_loss(model(x), y)
scaler.scale(loss).backward() # multiply the loss up before backward
before = scaler.get_scale()
scaler.step(opt) # unscales gradients, skips step if inf
scaler.update() # adjusts the scale for next time
skipped = " <- overflow, step skipped" if scaler.get_scale() < before else ""
print(f"step {step} loss {loss.item():.4f} scale {scaler.get_scale():.0f}{skipped}")step 0 loss 0.0701 scale 65536 step 1 loss 0.0618 scale 65536 step 2 loss 0.0562 scale 65536 step 3 loss 0.0517 scale 65536 step 4 loss 0.0478 scale 65536
scaler.scale(loss).backward() — the loss is multiplied by the scale (65536 to start). Every gradient inherits that factor, lifting tiny values out of the zero zone. scaler.step(opt) divides the gradients back down, checks them for infinities, and skips the update entirely if any appear. scaler.update() halves the scale after an overflow and slowly grows it during calm stretches. A skipped step is normal early in training — the marker line exists to make it visible.
Only the forward pass goes inside autocast. backward() and opt.step() stay outside — autocast handles their precision automatically.
Common mistakes
Calling .backward() on the unscaled loss. Everything runs, nothing errors, and small gradients quietly die to zero. The model trains worse for no visible reason. The scaler exists for exactly this.
Using float16 without the scaler. Same silent failure. If you want to skip the scaler, use bfloat16 instead — it trades detail for range and cannot underflow this way. On Ampere or newer GPUs, dtype=torch.bfloat16 with no GradScaler is a clean, popular setup.
Clipping gradients while they are still scaled. Clipping at 1.0 when gradients carry a factor of 65536 clips everything. Call scaler.unscale_(opt) first, then clip, then scaler.step(opt).
Putting the whole loop inside autocast. The optimizer update must run in float32. Keep autocast around the forward pass and loss only.
Try it yourself
Change y to x.sum(dim=1, keepdim=True) — a hundred-fold larger target. Run it and watch the overflow marker appear as the scaler backs off. Then switch amp_dtype to torch.bfloat16 (GPU) and see the scaler sit idle.
What to learn next
- Gradient checkpointing — the other big activation-memory lever.
- Fast attention with scaled_dot_product_attention — kernels that need half precision to shine.
- Gradient clipping — and the unscale-first rule.
Researcher — Mathematics and papers.
Format arithmetic
| format | bits | exponent | mantissa | smallest normal | largest |
|---|---|---|---|---|---|
| float32 | 32 | 8 | 23 | $1.2 \times 10^{-38}$ | $3.4 \times 10^{38}$ |
| float16 | 16 | 5 | 10 | $6.1 \times 10^{-5}$ | $65504$ |
| bfloat16 | 16 | 8 | 7 | $1.2 \times 10^{-38}$ | $3.4 \times 10^{38}$ |
float16's five exponent bits are the whole story. Gradient distributions in real training concentrate mass below $6 \times 10^{-5}$; Micikevicius et al. observed that a large fraction of activation-gradient values underflow to zero in float16. Scaling the loss by $S$ shifts the distribution up by $\log_2 S$ binades at zero cost, since backpropagation is linear in the loss. bfloat16 keeps float32's exponent — nothing underflows that would not have anyway — paying with 3 fewer mantissa bits, roughly two decimal digits of precision.
The master-weights requirement
Updates satisfy $w_{t+1} = w_t - \eta g_t$ with $\eta g_t$ often $10^4$ times smaller than $w_t$. In float16, additions where the addend is $2^{-11}$ of the augend round to nothing, freezing training late in convergence. Hence weights, and the Adam moments, stay float32; the memory anatomy fixed cost is unchanged and the savings come from activations.
Hardware
Tensor cores execute mixed $D = A \cdot B + C$ with float16/bfloat16 inputs and float32 accumulation. On an A100: 312 TFLOPS via tensor cores against 19.5 TFLOPS float32 — a 16x ceiling, realised only when shapes align to multiples of 8 and the op is compute-bound. TF32 (a 19-bit truncated mode, on by default for matmuls on Ampere+) recovers ~8x for code that never opts into AMP. Practical gains for full AMP training land at 1.5–3x end to end.
FP8 (E4M3/E5M2) continues the trajectory on Hopper and newer via per-tensor scaling factors — the GradScaler idea generalised and moved into the matmul epilogue (torchao, Transformer Engine).
References
- Micikevicius et al. (2018), Mixed Precision Training, ICLR — loss scaling, master weights, the underflow histograms.
- Kalamkar et al. (2019), A Study of BFLOAT16 for Deep Learning Training — why the exponent matters more than the mantissa.
- NVIDIA, Train With Mixed Precision user guide — the operator classification (allow-list / deny-list) autocast implements.
What to learn next
- Gradient checkpointing — the other big activation-memory lever.
- Fast attention with scaled_dot_product_attention — kernels that need half precision to shine.
- Gradient clipping — and the unscale-first rule.