Floating Point Formats
Every number format splits its bits between how large a value it can hold and how finely it can tell nearby ones apart, and training cares far more about range than fine detail.
Why Does This Exist?
Two 16-bit formats use the same memory and bandwidth, yet one trains a large model reliably while the other needs an extra mechanism to avoid producing not-a-number.
The difference is the bit split. Float16 spends 5 bits on the exponent and 10 on the mantissa. Bfloat16 spends 8 and 7. That single choice decides whether small gradients survive and whether large activations overflow, which explains why loss scaling exists and why bfloat16 became the default where hardware supports it.
This page covers the arithmetic that does not change: range, precision, and the two failures. Which accelerator supports which format changes yearly, so check current hardware docs for support. How rounding interacts once values are stored is in floating point arithmetic, and the failure catalogue is in numerical stability.
The one idea you need first
A float is sign, exponent, and mantissa: . Exponent width sets range, the largest and smallest magnitudes. Mantissa width sets precision, how many significant bits each value carries. Range and precision trade directly because the bit budget is fixed.
Think of It Like This
Scientific notation with fixed boxes
You must write every number as with a fixed number of boxes, some for digits and some for the exponent.
Spend boxes on the exponent and you can write and , but nearby values collapse together. Spend them on digits and you separate 1.00001 from 1.00002 beautifully, yet no longer fits and overflows to infinity.
Gradients in a deep network span an enormous range, since a chain-rule product over many layers can land anywhere from to , while their job is mostly direction, not the sixth digit. So spend the boxes on the exponent. That is exactly what bfloat16 does, and why it works where float16 struggles.
How It Actually Works
The formats that matter, with the two numbers that matter about each:
- Float32, 8 exponent and 23 mantissa. Max , epsilon or about 7 decimal digits. The baseline.
- Float16, 5 exponent and 10 mantissa. Max 65,504, epsilon or about 3 digits. Smallest normal value .
- Bfloat16, 8 exponent and 7 mantissa. Max , identical range to float32, epsilon or about 2 digits.
- TF32, 8 exponent and 10 mantissa. Float32 range with reduced precision, used inside matmul units while tensors stay float32.
- FP8, two variants. E4M3 with 4 exponent and 3 mantissa and max 448 favours precision for forward activations. E5M2 with 5 exponent and 2 mantissa and max 57,344 favours range for gradients.
Float16 and bfloat16 are the same size and differ only in the 5/10 versus 8/7 split. That is the whole design decision.
Why range beats precision for gradients
Overflow is loud. Past the maximum comes infinity, then not-a-number, and the run dies. Float16 tops out at 65,504, which is genuinely low: a chain-rule product over 30 layers averaging 1.5 per layer reaches about , straight past it.
Underflow is quiet and worse. A gradient of sits far below float16 normal range, so it rounds to zero with no warning. Those parameters simply stop learning, and the plateau looks architectural. Bfloat16 shares float32 exponent width, so neither happens. It pays with about 2 digits of precision, which gradients tolerate because they indicate a direction.
Mixed precision is not casting the model. It keeps a float32 master copy of weights, scales loss so small gradients land inside narrow range, accumulates in float32 inside kernels, and keeps normalisation and softmax wider. Float16 needs loss scaling while bfloat16 usually does not, which is the clearest practical consequence of the range gap.
Worked example
Take and , both of which real gradient distributions contain.
Float16 smallest normal is . Since lies below even the subnormal floor near , it rounds to exactly zero, so that parameter receives no update. Bfloat16 smallest normal is about , so is comfortably representable.
At the top end, , so float16 overflows to infinity and the run ends one not-a-number later. Bfloat16 tops out at , so it is unaffected. Loss scaling by lifts to , now inside float16 window, which is what the scaler buys.
The precision cost for contrast: bfloat16 epsilon is , so a update to a weight of 1.0 vanishes without a float32 master copy. Small updates discarded silently stall training at worse loss for reasons no learning-rate sweep fixes.
Code
The point is the check: confirm range behaviour with standard-library packing only, since exponent width is the claim.
import struct
def to_f16(x: float) -> float: return struct.unpack(">e", struct.pack(">e", x))[0]
def add_f16(a: float, b: float) -> float: return to_f16(to_f16(a) + to_f16(b))
print(to_f16(1e-8), to_f16(1e5 + 1e5))# -> 0.0 infprint(to_f16(1e-8 * 32768))# -> 0.0003275871276855469print(add_f16(1.0, 1e-4))# -> 1.0Tiny rounds to zero in float16 while scaled by it survives at , and still returns 1.0. Those three lines are the range argument and the master-weight argument as executable checks.
Watch Out For
Switching to float16 without loss scaling and blaming the model
Calling half precision directly appears to work, then training diverges or plateaus for reasons that look architectural. Underflow to zero means some parameters stop updating while loss still falls on the rest, so the model trains to a worse optimum with no error. Overflow means infinity, then not-a-number, and the run dies at an arbitrary step.
Symptom: silent plateau or sudden not-a-number with no trigger. Fix: use the framework mixed-precision path with master weights and a scaler instead of casting by hand, log the scale value since repeated back-off means batches are being skipped, and watch the gradient norm since it drifts toward the ceiling first. Where hardware supports bfloat16, prefer it.
Comparing narrow-format runs without controlling seed variance
Reduced precision often shows up as wider spread across seeds rather than a worse mean, and a single run of each cannot see that. Rounding noise perturbs the trajectory on a non-convex surface, so different runs land in different places even when average quality is unchanged.
Symptom: one float16 run beats one float32 run, then the next loses. Fix: compare at least three seeds per configuration as mean and standard deviation, keep evaluation in the wider format, and treat any change smaller than seed spread as unmeasured.
The Quick Version
- Exponent width sets range while mantissa width sets precision.
- Float32 reaches with epsilon ; float16 tops out at 65,504.
- Bfloat16 keeps float32 range with an 8-bit exponent and pays with about 2 digits of precision.
- Float16 zeroes and overflows ; bfloat16 holds both.
- Float16 needs loss scaling while bfloat16 usually does not.
- Mixed precision means master weights, scaling, wide accumulation, and selective wider layers.
- Without master weights a update to 1.0 vanishes; compare narrow formats across seeds.