ml-models / Training systems / Precision and mixed precision core

Precision and mixed precision

How the floating-point formats used in deep learning (fp32, tf32, fp16, bf16 and the two fp8 formats) split their bits between range and precision, which operations PyTorch's autocast runs in which format, why fp16 training needs loss scaling and bf16 does not, and why the weights themselves stay in fp32.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. How a float is read
  5. The formats
  6. Range versus precision
  7. autocast and its op lists
  8. Loss scaling
  9. Master weights and fp32 accumulation
  10. fp8
  11. The PyTorch API
  12. Worked example
  13. Implementation
  14. Tradeoffs
  15. Connections
  16. Read first
  17. Read next
  18. References

Problem

Matrix units on modern GPUs run several times faster on 16-bit and 8-bit inputs than on 32-bit ones, and half the bytes per value means half the memory traffic for every memory-bound operation (see how a GPU runs a model). Training entirely in a 16-bit format, though, fails in specific ways: small gradients round to zero, large activations overflow to infinity, and a weight update smaller than the gap between two neighbouring 16-bit numbers is lost entirely. Mixed precision keeps the speed by running the matmul-heavy parts in a low-precision format while keeping the parts that need range or accumulation in fp32. Doing that well needs a clear picture of what each format can and cannot represent.

Intuition

A floating-point number is scientific notation in base 2: a sign, an exponent that says where the binary point goes, and a mantissa that holds the significant digits. A fixed bit budget is split between the two. Exponent bits buy range, how large and how small a number can get before it overflows to infinity or underflows to zero. Mantissa bits buy precision, how many significant digits survive rounding at any scale. fp16 and bf16 both use 16 bits; fp16 spends them on precision (10 mantissa bits), bf16 on range (8 exponent bits, the same as fp32). That one choice decides almost everything else on this page.

Bit layouts of six floating-point formats, drawn to the same scale per bit Each format is a row of sign, exponent and mantissa fields. fp32: 1 sign, 8 exponent, 23 mantissa bits. tf32: 1, 8 and 10, a 19-bit format used inside matrix units on fp32 data. fp16: 1, 5 and 10. bf16: 1, 8 and 7, the top half of an fp32. fp8 e4m3: 1, 4 and 3. fp8 e5m2: 1, 5 and 2. fp32, tf32 and bf16 share the 8-bit exponent and so the same range; fp16 and e5m2 share the 5-bit exponent. sign exponent (range) mantissa (precision) fp32 1 · 8 · 23 8 23 tf32 1 · 8 · 10 8 10 13 bits of the fp32 ignored fp16 1 · 5 · 10 5 10 bf16 1 · 8 · 7 8 7 fp8 e4m3 1 · 4 · 3 4 3 fp8 e5m2 1 · 5 · 2 5 2

Each square is one bit at the same width in every row. bf16 is fp32 with the low 16 mantissa bits dropped, so converting between them is a shift and a rounding step; tf32 is not a storage format at all, but the precision matrix units use when they are allowed to round fp32 inputs.

Mechanics

How a float is read

With e exponent bits and m mantissa bits, the stored exponent field is offset by a bias of 2e−1 − 1 so that both tiny and huge numbers are representable. A normal number is read as

value = (−1)sign × 1.mantissa2 × 2field − bias     spacing just above 1 = 2−m (machine epsilon)

The all-ones exponent field is reserved for infinity and NaN in the IEEE formats, and the all-zeros field for zero and subnormal numbers, which drop the implicit leading 1 to fill the gap between the smallest normal number and zero with evenly spaced, less precise values. Between any two powers of two there are exactly 2m representable numbers, so the relative spacing stays near 2−m everywhere in the normal range while the absolute spacing doubles with each power of two. Converting a number into a format rounds it to the nearest representable value, ties to the one with an even last bit (round-to-nearest-even).

The formats

FormatBits (s · e · m)LargestSmallest normalSmallest subnormalEpsilon≈ Decimal digits
fp321 · 8 · 233.40282e+381.18e-381.40e-451.19e-077.2
tf321 · 8 · 103.40116e+381.18e-381.15e-419.77e-043.3
fp161 · 5 · 10655046.10e-055.96e-089.77e-043.3
bf161 · 8 · 73.38953e+381.18e-389.18e-417.81e-032.4
fp8 e4m31 · 4 · 34481.56e-021.95e-031.25e-011.2
fp8 e5m21 · 5 · 2573446.10e-051.53e-052.50e-010.9
  • fp32 (torch.float32): the default. About 7 decimal digits, range from about 10−38 to 1038.
  • tf32: fp32's exponent with fp16's mantissa. NVIDIA matrix units since the Ampere generation can multiply fp32 inputs at this precision while still accumulating and storing fp32. PyTorch controls it with torch.set_float32_matmul_precision: "highest" (the default) keeps full fp32; "high" allows tf32 or a two-bf16 split; "medium" allows bf16.
  • fp16 (torch.float16, IEEE half): 3 digits of precision but a range that stops at 65504 and, for normal numbers, 6.10e-05.
  • bf16 (torch.bfloat16, "brain float"): fp32's range with 2 to 3 digits of precision. PyTorch converts fp32 to bf16 with round-to-nearest-even on the top 16 bits.
  • fp8 e4m3 (torch.float8_e4m3fn): more precision, less range. The "fn" means finite: it has no infinities, gives the all-ones exponent back to ordinary numbers (keeping only S.1111.111 as NaN), and so reaches 448 instead of 240. PyTorch's conversion saturates: anything at or past the overflow threshold becomes ±448.
  • fp8 e5m2 (torch.float8_e5m2): fp16's exponent with 2 mantissa bits, IEEE-like with infinities; overflow rounds to infinity.

Range versus precision

The two failure modes are different. A range failure turns a number into ±infinity (overflow) or 0 (underflow), which is catastrophic: one infinity in a gradient poisons the whole update. A precision failure perturbs a number by up to half a spacing, which is usually harmless noise in a neural network, except in one place: adding a small number to a large one. If the small number is less than half the spacing at the large one, the sum rounds back to the large number exactly. That is what happens to a weight update in a low-precision weight, and it is why the weights themselves stay fp32 (master weights, below). Training has values of very different scales in different places: activations and weights near 1, gradients that can be many orders of magnitude smaller, and sums of thousands of terms inside every matmul. Each needs a different trade.

The representable range of each format on a log scale, with five gradients before and after loss scaling A horizontal log-2 axis from 2 to the minus 40 up to 2 to the 20. fp32 and bf16 cover the whole axis and beyond. fp16 covers 2 to the minus 24 (subnormal, normal from 2 to the minus 14) up to 65504. fp8 e5m2 covers 2 to the minus 16 up to 57344 and fp8 e4m3 covers 2 to the minus 9 up to 448. Below, five example gradients from 3e-02 down to 1e-10: the two smallest fall below fp16's smallest subnormal and round to zero. Multiplying them all by 2 to the 16 shifts every point 16 powers of two to the right, where all five fit inside fp16's range. fp32 from 2−149 to 2128 bf16 from 2−133 to 2128 fp16 65504 fp8 e5m2 57344 fp8 e4m3 448 gradients × 216 rounds to 0 in fp16 2−40 2−30 2−20 2−10 20 210 220 magnitude (log scale); dashed lines mark fp16's smallest subnormal and largest value

Solid bars are the normal range, dashed bars the subnormals. The hollow dots are gradients fp16 cannot hold; loss scaling moves every gradient right by the same factor, and the whole set fits.

autocast and its op lists

torch.autocast(device_type, dtype=None, enabled=True, cache_enabled=None) is a context manager that changes which dtype operations run in, without changing the model. Inside it, each eligible op is intercepted at PyTorch's dispatcher (the Autocast dispatch key) and its floating-point CUDA inputs are cast according to a per-op cast policy. The default dtype is torch.float16 for CUDA and torch.bfloat16 for CPU. fp64 tensors are never cast, and ops on no list fall through and run in whatever dtype their inputs already have. The CUDA registration in autocast_mode.cpp sorts ops into five policies:

PolicyWhat it doesOps on the CUDA list (from the pinned source)
lower_precision_fpCast every input to the autocast dtype and run there.mm, bmm, matmul, addmm, baddbmm, linear, einsum, the conv* and conv_transpose* family, prelu, the LSTM/GRU/RNN cells, scaled_dot_product_attention, _scaled_dot_product_flash_attention.
fp32Cast every input to fp32 and run there.exp, log, log1p, pow, reciprocal, rsqrt, softplus, layer_norm, rms_norm, group_norm, logsumexp, cosine_similarity, cdist, the losses (nll_loss, mse_loss, l1_loss, kl_div, binary_cross_entropy_with_logits and others), the upsample_* ops.
fp32_set_opt_dtypeRun in fp32 by setting the op's optional output dtype, unless the caller set one.softmax, log_softmax, sum, prod, cumsum, cumprod, linalg_vector_norm, linalg_matrix_norm.
fp32_append_dtypeRedispatch to the overload that takes a dtype, passing fp32.norm (two overloads).
promoteRun in the widest dtype among the inputs.addcdiv, addcmul, atan2, bilinear, cross, dot, vdot, grid_sampler, index_put, tensordot, scatter_add.

The pattern follows the formats. Matmuls and convolutions are compute-bound, run on matrix units that are fastest in 16 bits, and accumulate their dot products in fp32 internally, so they go low. Exponentials, logs, normalizations, reductions and losses can overflow or need to sum many terms accurately, so they go to fp32. One op is banned outright: binary_cross_entropy raises an error under CUDA autocast, because it usually follows a sigmoid whose output loses too much precision in fp16; the error points to binary_cross_entropy_with_logits, which fuses the two and is on the fp32 list.

autocast also keeps a weight cast cache: the first time an fp32 leaf tensor that requires grad (a parameter) is cast to the low dtype inside an autocast region, the cast copy is kept and reused by later ops in the same region, so a weight used by several matmuls is converted once per forward pass. The cache is cleared when the outermost autocast region exits. Its documentation also says to wrap only the forward pass and the loss, not backward(): backward ops run in the same dtype autocast chose for their forward ops anyway.

Loss scaling

In fp16, the gradients of a deep network often include values below 5.96e-08, fp16's smallest subnormal, which round to zero, and many more in the subnormal range where they keep only a few bits. Loss scaling multiplies the loss by a factor S before backward(). By the chain rule every gradient is multiplied by S too, which shifts the whole gradient distribution up into fp16's range (the second diagram). Before the optimizer uses them, the gradients are divided by S again, in fp32, where the small values are representable.

A fixed S is hard to choose: too small and gradients still underflow, too large and some overflow to infinity. GradScaler picks it dynamically. It starts at init_scale, by default 216 = 65,536. Each iteration scaler.step(optimizer) unscales the gradients and checks them for infs and NaNs: if any are found, it skips optimizer.step() so the weights are untouched, and update() multiplies the scale by backoff_factor (0.5). After growth_interval (2000) consecutive iterations without infs it multiplies the scale by growth_factor (2.0). The scale settles near the largest value that does not overflow, and skipped steps become rare. unscale_ and update keep the inf flags and the scale on the GPU without a CPU-GPU sync; step reads the flags back with .item() to decide whether to call the optimizer.

bf16 needs none of this. Its exponent is fp32's, so every magnitude down to fp32's smallest normal number, 1.18e-38, is representable in bf16, far below where gradients live. What bf16 gives up is precision, and scaling cannot add mantissa bits. Its documentation for update() notes the converse risk: bf16-pretrained models are often incompatible with fp16 training, because their activations use a range fp16 does not have.

GradScaler's scale over twelve steps with a growth interval of 3 The largest gradient is 0.6 every step. The scale starts at 2 to the 16. After three clean steps it doubles to 2 to the 17, at which 0.6 times the scale exceeds fp16's maximum of 65504, so step 4 overflows, is skipped, and the scale halves back to 2 to the 16. The pattern repeats: 3 of 12 steps skipped. overflow above ≈ 109,173 1 2 3 4 skip 5 6 7 8 skip 9 10 11 12 skip 216 217 step

Bar height is the scale used at each step: green steps run the optimizer, amber steps overflow and are skipped. The dashed line is the largest scale at which 0.6 × scale still fits under fp16's 65504. With the default growth_interval of 2000 the same probe costs one skipped step in 2001 instead of one in 4.

Master weights and fp32 accumulation

Micikevicius et al. (2018) named the three techniques that made fp16 training match fp32 accuracy: loss scaling, an fp32 master copy of the weights, and accumulating fp16 products into fp32. The master copy exists for the precision failure above: an update lr × g is often far smaller than the weight it is added to, and in fp16 the spacing near 1.0 is 9.77e-04. Any update below half of that vanishes. Keeping the weights and the optimizer's state in fp32, applying the update there, and using a low-precision copy only for the forward and backward matmuls keeps every small update.

In PyTorch's autocast recipe this is automatic: the parameters stay fp32 (model.half() is exactly what the autocast documentation says not to do), autocast's weight cache provides the fp16 or bf16 copy for each forward pass, and the optimizer updates the fp32 parameters. The third technique lives inside the matmul kernels, which accumulate in fp32 even when their inputs are 16-bit. Pure-bf16 training, with parameters stored in bf16, saves the master copy's memory but has to deal with lost updates some other way (stochastic rounding or compensated summation), and is a separate recipe from autocast.

fp8

fp8 doubles matrix-unit throughput again on hardware that supports it, but with only 3 or 2 mantissa bits and a narrow range, a single global scale is not enough. fp8 training recipes keep a scale per tensor (or per block of a tensor), computed from recent maximum absolute values, and use e4m3 where precision matters (weights and activations in the forward pass) and e5m2 where range matters (gradients in the backward pass), as the FP8 format paper proposes. PyTorch provides the dtypes and conversions; the scaled matmuls and their recipes live in separate libraries and are beyond this page.

The PyTorch API

ComponentShape/ParamsMeaningSource
torch.autocast device_type, dtype=None, enabled=True, cache_enabled=None Context manager or decorator that runs eligible ops in a lower-precision dtype; default float16 on CUDA, bfloat16 on CPU. autocast_mode.py 52-307
CastPolicy lower_precision_fp, fp32, fp32_set_opt_dtype, fp32_append_dtype, promote The five ways an autocast wrapper casts an op's inputs. autocast_mode.h 416-438
Op lists AT_FORALL_LOWER_PRECISION_FP, AT_FORALL_FP32, AT_FORALL_FP32_SET_OPT_DTYPE, AT_FORALL_PROMOTE Which op gets which policy; the CUDA registration also bans binary_cross_entropy. autocast_mode.h 819-956, autocast_mode.cpp 181-222
cached_cast (to_type, arg, device_type) Casts one input; caches low-precision casts of fp32 leaf tensors that require grad (the weights). autocast_mode.cpp 122-157
torch.amp.GradScaler device="cuda", init_scale=2.**16, growth_factor=2.0, backoff_factor=0.5, growth_interval=2000, enabled=True Dynamic loss scaling for fp16. grad_scaler.py 53-159
scale, unscale_ (outputs), (optimizer) Multiply the loss by the scale; divide an optimizer's gradients by it and record infs, before clipping. grad_scaler.py 193-374
step, update (optimizer, *args, **kwargs), (new_scale=None) Run optimizer.step() unless infs were found; back off or grow the scale. grad_scaler.py 375-559
torch.set_float32_matmul_precision "highest" (default), "high", "medium" Lets fp32 matmuls use tf32 or bf16 internally; outputs stay fp32. torch/__init__.py 2008-2077
bf16 conversion round_to_nearest_even(float) → uint16 fp32 to bf16: add a rounding bias to the fp32 bits and keep the top 16. BFloat16.h 100-114
fp8 conversions fp8e4m3fn_from_fp32_value, fp8e5m2_from_fp32_value fp32 to fp8: e4m3fn saturates overflow to ±448, e5m2 overflows to ±inf. Float8_e4m3fn.h 171-243, Float8_e5m2.h 77-144

Worked example

One number in every format. Rounding to nearest even, computed from each format's bit layout:

Format1/3Bits of 1/3 (s | e | m)70,0001e-08
fp320.3333333430 | 01111101 | 01010101010101010101011700001e-08
tf320.3332519530 | 01111101 | 0101010101700169.997e-09
fp160.3332519530 | 01101 | 0101010101inf0
bf160.3339843750 | 01111101 | 0101011701441.001e-08
fp8 e4m30.343750 | 0101 | 0114480
fp8 e5m20.31250 | 01101 | 01inf0

fp16 holds 1/3 to 2.4e-04 relative error, better than bf16's 2.0e-03, but 70,000 overflows it to infinity and 1e-08 underflows to zero. bf16 keeps both, coarsely. e4m3 saturates 70,000 to 448, as PyTorch's conversion does; e5m2 overflows to infinity.

Loss scaling five gradients. Each gradient rounded to fp16 directly, and after scaling by S = 216 and dividing back in fp32:

Gradientfp16Error× S in fp16÷ SErrorbf16, unscaled error
3e-020.030.00%19660.0299990.00%0.10%
4e-053.999e-050.01%2.62113.9995e-050.01%0.14%
2e-071.788e-0710.59%0.0131072e-070.00%0.12%
5e-090100.00%0.000327594.9986e-090.03%0.12%
1e-100100.00%6.5565e-061.0004e-100.04%0.04%

Unscaled, 2e-07 lands among the subnormals with an error of 11% and the two smallest gradients become exactly 0. Scaled, every value keeps three to four significant digits. bf16 holds all five unscaled, each within its usual precision of about 0.39%.

GradScaler's schedule. The third diagram, with the largest gradient 0.6 every step and growth_interval=3 to keep it short:

  • Steps 1-3 at 216: 0.6 × 65,536 = 39,322, below 65504. After three clean steps the scale doubles.
  • Step 4 at 217: 0.6 × 131,072 = 78,643 overflows to infinity. The step is skipped, the weights are unchanged, and the scale halves.
  • 3 of 12 steps are skipped; the final scale is 216. With the default interval of 2000 the scale probes upward once every 2001 steps and nearly every step trains.

Lost updates. Add an update of 1e-04 to a weight of 1.0, 100 times, rounding after each add:

  • fp16: the spacing at 1.0 is 2−10 = 9.77e-04, so 1 + 1e-04 rounds back to 1. After 100 steps the weight is 1.
  • bf16: the spacing is 2−7; the weight is still 1.
  • fp32 master weight: 1.0100017, the intended 1.01 up to fp32 rounding. Cast to fp16 for the next forward pass it is 1.0097656: the forward pass sees the change even though no single update was representable in fp16.

Implementation

import torch

model = MyModel().cuda()                      # parameters stay float32: the master weights
optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4)

# float16: autocast plus dynamic loss scaling.
scaler = torch.amp.GradScaler("cuda")
for x, y in loader:
    optimizer.zero_grad(set_to_none=True)
    with torch.autocast("cuda", dtype=torch.float16):
        loss = loss_fn(model(x), y)           # matmuls in fp16, softmax/norms/loss in fp32
    scaler.scale(loss).backward()             # outside autocast
    scaler.unscale_(optimizer)                # so clipping sees true gradient norms
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(optimizer)                    # skipped if any gradient is inf/NaN
    scaler.update()

# bfloat16: the same autocast, no scaler.
for x, y in loader:
    optimizer.zero_grad(set_to_none=True)
    with torch.autocast("cuda", dtype=torch.bfloat16):
        loss = loss_fn(model(x), y)
    loss.backward()
    optimizer.step()

# Let plain float32 matmuls use TF32 on GPUs that support it.
torch.set_float32_matmul_precision("high")

torch.finfo(torch.float16).max                # 65504.0
torch.finfo(torch.bfloat16).eps               # 0.0078125

Save scaler.state_dict() with the model and optimizer so a resumed fp16 run does not restart its scale from 216. Inside an autocast region an op's output dtype follows its policy, so a tensor coming out of a matmul is fp16 and one coming out of a softmax is fp32. Mixing an autocast output with an fp32 tensor in an op outside autocast needs an explicit .float().

Tradeoffs

  • fp16 versus bf16. fp16 has 3 more mantissa bits and needs loss scaling, occasional skipped steps and care with large activations. bf16 needs none of that and is the usual choice where the hardware supports it, at the cost of noisier values.
  • Speed versus where it comes from. Matrix-unit throughput gains apply only to the compute-bound ops autocast lowers. Memory-bound ops gain only from halved bytes, and ops on the fp32 list gain nothing, so the end-to-end speedup is smaller than the matmul speedup.
  • Memory. Activations saved for backward are halved, but fp32 master weights and fp32 optimizer state stay, plus the cached low-precision weight copies. The savings are mostly in activations.
  • tf32. A free speedup for fp32 code with fp16-level precision inside matmuls. That is fine for training most networks and wrong for code that needs full fp32 matmuls, which is why PyTorch's default is "highest".
  • fp8. Another 2× in matmul throughput, but per-tensor scaling, saturation and format choice per tensor role add engineering that 16-bit training avoids.
A bf16 run and an fp16 run of the same model: which one needs GradScaler, and why does the other not?

The fp16 run. fp16's smallest subnormal is 5.96e-08, so small gradients underflow to zero unless the loss is scaled up first. bf16 has fp32's 8-bit exponent and the same normal range, down to 1.18e-38, so realistic gradients do not underflow; scaling would not add the precision bf16 lacks.

Why does autocast run softmax and layer_norm in fp32 but linear in fp16?

Linear layers are matmuls: compute-bound, fast on 16-bit matrix units, and accumulated in fp32 inside the kernel. Softmax exponentiates (easy to overflow at fp16's maximum of 65504) and both it and layer norm sum over a whole row, where low precision loses accuracy.

With learning rate × gradient = 1e-04 and weights near 1, why can't the weights simply be stored in fp16?

The spacing between fp16 numbers near 1 is 9.77e-04. Any update smaller than half of it rounds away, so the weight never moves (1 after 100 steps). An fp32 master copy accumulates the updates (1.01).

Connections

Read first

Read next

References