fp32, bf16, fp8 and int4
Every weight and activation is stored in some number format, and the choice between them is a trade between range, precision and memory.
- 15 min read
- 3 reading levels
- Published
On this page 10
One lesson, three depths. Pick the one that fits you today — you can switch any time.
Beginner — No maths. Plain English.
The short answer
Every number in a model is stored using a fixed number of bits. Fewer bits means less memory and less accuracy.
The analogy you have already lived
Think about the different scales you have used to weigh things. The kitchen scale reads to the gram, up to five kilograms. The luggage scale at the airport reads to a hundred grams, up to thirty kilograms.
Neither is better. They cover different ranges with different fineness, and both are limited by the number of digits on the display.
A number format is a display with a fixed number of digits. You choose how to spend them.
Why it exists
A model with seven billion numbers stored in the standard format needs 28 gigabytes. Halve the bits per number and it needs 14. Quarter them and it needs 7.
That is not only about disk space. Reading those numbers off the memory chip is what makes generation slow, so fewer bits is directly faster.
How the bits are spent
A floating-point number splits its bits into two jobs.
Exponent bits decide how big or small a number it can represent. This is the range.
Mantissa bits decide how finely it can distinguish nearby numbers. This is the precision.
float32 1 sign + 8 exponent + 23 mantissa huge range, very fine
bfloat16 1 sign + 8 exponent + 7 mantissa same range, coarse
float16 1 sign + 5 exponent + 10 mantissa smaller range, finerLook at bfloat16 and float16. Same size. Completely different personality.
The story bf16 won
Training used to use float16 and kept breaking. Numbers grew past what five exponent bits could hold and became infinity. Tiny gradients shrank below the smallest representable value and became zero.
bfloat16 keeps float32's full range and gives up precision instead. Training stopped breaking. It is now the default almost everywhere.
The measurement below shows both failures in two lines of output.
Integers are a different beast
An integer format has no exponent at all. To store a decimal like 0.0037, you keep a whole number and a separate scale, and multiply them back together.
values 0.0037 -0.0091 0.0052
scale 0.0001
stored as 37 -91 52 <- small whole numbersThe catch is that one scale is shared by a group. Suppose one number in that group is enormous. The scale must stretch to cover it, and every small number gets crushed.
So the group has to be small. In the measurement below, shrinking the group from four thousand numbers to sixteen cuts the error tenfold.
What is honestly hard here
There are more formats than there should be, and they are not interchangeable across hardware.
A 4-bit format designed for one company's chip runs slowly or not at all on another's. Support arrives in libraries months after the chips ship.
Check what your hardware and your serving library actually support before choosing. The best format on paper is useless if nothing runs it.
Where you have already seen this
- Kitchen scales against luggage scales.
- Photo files at different quality settings.
- A ruler marked in millimetres against a measuring tape marked in centimetres.
Remember this
- Bits split between range (how big) and precision (how fine).
- bfloat16 trades precision for range, and that is why training uses it.
- Integer formats need a shared scale, and the group sharing it must be small.
What to learn next
- GPTQ — quantising weights while correcting the error as you go.
- AWQ — deciding which weights matter before you round them.
- Mixed precision training — the training side of the same choices.
Developer — Code and libraries.
Setup
pip install torchRuns on a CPU instantly.
Every format, measured
import torch
print(f"{'format':<14} {'bits':>5} {'largest':>12} {'smallest step':>14} {'note'}")
rows = [("float32", torch.float32, "the training default"),
("bfloat16", torch.bfloat16, "same range as fp32, less precision"),
("float16", torch.float16, "more precision, much less range"),
("float8_e4m3fn", torch.float8_e4m3fn, "4 exponent bits, 3 mantissa"),
("float8_e5m2", torch.float8_e5m2, "5 exponent bits, 2 mantissa")]
for name, dt, note in rows:
fi = torch.finfo(dt)
print(f"{name:<14} {fi.bits:>5} {fi.max:>12.4g} {fi.eps:>14.4g} {note}")
for name, dt, note in [("int8", torch.int8, "needs a scale to mean anything"),
("int4 (packed)", None, "no native dtype; two per byte")]:
if dt is None:
print(f"{name:<14} {4:>5} {7:>12} {'-':>14} {note}")
else:
ii = torch.iinfo(dt)
print(f"{name:<14} {ii.bits:>5} {ii.max:>12} {'1':>14} {note}")
# Why bf16 replaced fp16 for training: range, not precision.
print("\nan activation of 100000 (large but not absurd) stored in each format:")
x = torch.tensor([1e5])
for name, dt in [("float32", torch.float32), ("bfloat16", torch.bfloat16),
("float16", torch.float16)]:
print(f" {name:<10} {x.to(dt).float().item():>12.6g}")
print("\nand a gradient of 1e-8:")
x = torch.tensor([1e-8])
for name, dt in [("float32", torch.float32), ("bfloat16", torch.bfloat16),
("float16", torch.float16)]:
print(f" {name:<10} {x.to(dt).float().item():>12.6g}")
# Block scaling: what makes 4-bit usable at all.
torch.manual_seed(0)
w = torch.randn(4096) * 0.02
w[7] = 0.9 # one outlier, as real weights have
def quant_int(x, bits, block):
q = x.clone().reshape(-1, block)
scale = q.abs().amax(1, keepdim=True) / (2 ** (bits - 1) - 1)
return ((q / scale).round().clamp(-(2 ** (bits - 1)), 2 ** (bits - 1) - 1)
* scale).reshape(-1)
def err(a, b):
return (a - b).pow(2).mean().sqrt().item() / a.pow(2).mean().sqrt().item()
print("\nint4 quantisation error, by block size (relative RMS error)")
for block in (4096, 256, 64, 32, 16):
overhead = 16 / block # one fp16 scale per block
print(f" block {block:>5}: error {err(w, quant_int(w, 4, block)):.4f}"
f" bits per weight {4 + overhead:.2f}")
print(f" int8, block 4096: error {err(w, quant_int(w, 8, 4096)):.4f}")format bits largest smallest step note float32 32 3.403e+38 1.192e-07 the training default bfloat16 16 3.39e+38 0.007812 same range as fp32, less precision float16 16 6.55e+04 0.0009766 more precision, much less range float8_e4m3fn 8 448 0.125 4 exponent bits, 3 mantissa float8_e5m2 8 5.734e+04 0.25 5 exponent bits, 2 mantissa int8 8 127 1 needs a scale to mean anything int4 (packed) 4 7 - no native dtype; two per byte an activation of 100000 (large but not absurd) stored in each format: float32 100000 bfloat16 99840 float16 inf and a gradient of 1e-8: float32 1e-08 bfloat16 1.00117e-08 float16 0 int4 quantisation error, by block size (relative RMS error) block 4096: error 0.8112 bits per weight 4.00 block 256: error 0.2171 bits per weight 4.06 block 64: error 0.1359 bits per weight 4.25 block 32: error 0.1043 bits per weight 4.50 block 16: error 0.0800 bits per weight 5.00 int8, block 4096: error 0.0835
Written against PyTorch 2.5.1, CPU. Everything except the seeded random weights is a property of the formats, so it is identical on any machine.
Read the two overflow lines first
float16 turned 100,000 into inf. Its maximum is 65,504. An attention logit or an activation of that size is uncommon but not rare, and one inf propagates into nan and kills a training run. bfloat16 stored it as 99,840 — coarse, and finite, which is the only property that matters here.
float16 turned 1e-8 into exactly zero. A gradient that small underflows. Under fp16 training this is why loss scaling exists: multiply the loss by a large constant so gradients land inside the representable range, and divide it out later. bf16 needs no such machinery, and that is the entire reason it won.
bfloat16 has an eps of 0.0078 against float16's 0.00098. bf16 really is eight times coarser. For training, range beat precision. That is an empirical finding about how neural networks behave, not a theorem.
The fp8 pair is the same trade at half the size. e4m3 has range ±448 with finer steps; e5m2 reaches 57,344 with coarser ones. Weights and activations use e4m3; gradients, which have a wider dynamic range, use e5m2.
Now the block-size table, which is the whole of low-bit quantisation
One scale for 4,096 weights gives 0.81 relative error — useless. One outlier at 0.9 among weights of typical size 0.02 forces the scale to cover 0.9, so every ordinary weight rounds to 0 or 1 step. This is not a contrived case: real weight matrices have outliers.
Shrinking the block to 32 cuts the error eightfold, to 0.10. The outlier is now confined to one block of 32 and only damages that block.
Count the real cost. An fp16 scale per block of 32 is 0.5 extra bits per weight, so "4-bit" is really 4.5. At block 16 it is 5.0. Compare like with like: int4 at block 16 (5.0 bits, error 0.080) is barely better than int8 at block 4096 (8.0 bits, error 0.084) — the extra 3 bits bought almost nothing here, because the per-tensor int8 scale already handled the outlier well enough at 8-bit resolution.
That comparison is the argument for the smarter methods in the next two lessons. Plain rounding at 4 bits needs so much scale overhead that its advantage over int8 nearly vanishes.
The formats you will meet in the wild
| Format | Bits | Where |
|---|---|---|
| fp32 | 32 | master weights, optimiser state |
| bf16 | 16 | training compute, most inference |
| fp16 | 16 | older inference stacks, needs loss scaling to train |
| fp8 e4m3 / e5m2 | 8 | Hopper and later, natively |
| int8 | 8 | LLM.int8(), SmoothQuant, mature everywhere |
| int4 | 4 | GPTQ, AWQ, bitsandbytes NF4 |
| NF4 | 4 | QLoRA — non-uniform levels fitted to a normal distribution |
| MXFP4 | 4 | 32-value blocks, one 8-bit power-of-two scale |
| NVFP4 | 4 | 16-value blocks, one fp8 scale, plus a per-tensor fp32 scale |
MXFP4 and NVFP4 differ in exactly the way the block-size table predicts: NVFP4's 16-value blocks track local outliers more tightly than MXFP4's 32-value blocks, and its fp8 scale is finer than MXFP4's power-of-two one. It costs more overhead per value and gives lower error. Blackwell tensor cores support both natively; earlier hardware emulates them, which is slower than bf16.
Common mistakes
Reporting "4-bit" without the scale overhead. Group size 32 with an fp16 scale is 4.5 bits per weight. At group 16 it is 5. Always state the group size.
Using fp16 on modern hardware out of habit. bf16 is safer and equally fast on anything from Ampere onward.
Assuming a format is supported because a library accepts it. PyTorch will happily create an float8_e4m3fn tensor on a CPU that has no fp8 arithmetic, then emulate every operation in fp32. Check whether your kernels support it.
Quantising everything. Embeddings, layer norms, and the output head are disproportionately sensitive. Nearly every practical recipe leaves them in 16 bits.
Mixing formats without checking accumulation. An fp8 matmul that accumulates in fp8 is far worse than one accumulating in fp32. The accumulator dtype is rarely printed and often matters more than the storage dtype.
Try it yourself
Remove the w[7] = 0.9 line and re-run the block-size table. Without the outlier, block 4096 improves enormously and the advantage of small blocks nearly vanishes. Outliers are the entire reason low-bit quantisation is difficult.
What to learn next
- GPTQ — quantising weights while correcting the error as you go.
- AWQ — deciding which weights matter before you round them.
- Mixed precision training — the training side of the same choices.
Researcher — Mathematics and papers.
Anatomy
An IEEE-style float with sign $s$, exponent field $e$ and mantissa field $m$ (with $p$ mantissa bits and bias $B$) represents, for normal numbers:
$$ x = (-1)^s \cdot 2^{e - B} \cdot \left(1 + \frac{m}{2^{p}}\right) $$
Two derived quantities decide everything:
- Dynamic range $\approx 2^{2^{n_e}}$, set by the exponent width $n_e$.
- Relative precision $\epsilon = 2^{-p}$, set by the mantissa width.
| Format | $n_e$ | $p$ | Max | $\epsilon$ |
|---|---|---|---|---|
| fp32 | 8 | 23 | 3.4e38 | 1.2e-7 |
| bf16 | 8 | 7 | 3.4e38 | 7.8e-3 |
| fp16 | 5 | 10 | 65504 | 9.8e-4 |
| fp8 e4m3 | 4 | 3 | 448 | 0.125 |
| fp8 e5m2 | 5 | 2 | 57344 | 0.25 |
bf16 is fp32 truncated to 16 bits, which is why conversion is a shift and why an fp32 accumulator costs nothing to feed. e4m3fn is "finite": it has no infinities, reclaiming those encodings for values, which is why its max is 448 rather than 240.
Micikevicius et al., 2022 (FP8 Formats for Deep Learning, NVIDIA/Arm/Intel) standardised the e4m3/e5m2 pair and the convention of e4m3 for forward tensors and e5m2 for gradients.
Integer quantisation
Affine quantisation of a group $G$:
$$ q = \mathrm{clamp}!\left(\left\lfloor \frac{x - z}{s} \right\rceil, q_{\min}, q_{\max}\right), \qquad \hat x = s q + z $$
with scale $s$ and zero point $z$. Symmetric quantisation sets $z = 0$ and $s = \max_{G}|x| / q_{\max}$, which is what the code uses and what most weight quantisers do.
Under a uniform-error model, quantisation noise has variance $s^2/12$, so error scales linearly in the group's dynamic range. With a heavy-tailed weight distribution, $\max_G |x|$ is dominated by the largest element in the group, and error scales with that outlier — which is precisely the 0.81 in the first row of the table.
Effective bits per value, including the scale:
$$ b_{\text{eff}} = b + \frac{b_{\text{scale}}}{g} $$
At $b=4$, $b_{\text{scale}}=16$, $g=32$: 4.5 bits. Papers that quote 4 and use $g=64$ are quoting 4.25.
Non-uniform levels
Uniform integer levels are a poor match for a bell-shaped weight distribution. NF4 (Dettmers et al., 2023, QLoRA) is information-theoretically optimal for normally distributed data: the 16 levels are the quantiles of a standard normal, so each level receives equal probability mass. Combined with double quantisation — quantising the block scales themselves, saving about 0.37 bits per parameter — QLoRA fine-tunes a 65B model on one 48 GB GPU with no measurable quality loss against 16-bit LoRA.
The 4-bit block formats
Both encode values as E2M1 (1 sign, 2 exponent, 1 mantissa bits), and differ in the scaling structure:
| Block | Scale type | Effective bits | |
|---|---|---|---|
| MXFP4 (OCP Microscaling) | 32 | E8M0, a power of two | 4.25 |
| NVFP4 (NVIDIA) | 16 | FP8 E4M3, plus a per-tensor FP32 scale | 4.5 |
NVFP4's advantages follow directly from the block-size analysis: smaller blocks track local dynamic range more closely, and an E4M3 scale is finer than a power-of-two one. The cost is 0.25 more bits per value and a two-level scaling hierarchy. Both are native on Blackwell fifth-generation tensor cores; on older hardware they are emulated.
NVIDIA, 2025 (Pretraining Large Language Models with NVFP4) report pretraining a 12B model on 10T tokens in NVFP4 with loss and downstream accuracy comparable to an FP8 baseline, which is a stronger claim than post-training quantisation and was far from a foregone conclusion.
Where the difficulty lives: activation outliers
Weights are approximately Gaussian and quantise well. Activations do not.
Dettmers et al., 2022 (LLM.int8()) found that beyond roughly 6.7B parameters, emergent outlier features appear in a small number of fixed hidden dimensions, with magnitudes up to 20× the rest, and that zeroing them collapses attention and perplexity. Their solution is a mixed-precision decomposition: the outlier dimensions (about 0.1% of them) stay in fp16, everything else goes to int8, with no quality loss at any tested scale.
Xiao et al., 2023 (SmoothQuant) instead migrate the difficulty: since $Y = (X\,\mathrm{diag}(s)^{-1})\cdot(\mathrm{diag}(s)\,W)$ is mathematically identical, choosing $s_j = \max|X_j|^\alpha / \max|W_j|^{1-\alpha}$ (with $\alpha \approx 0.5$) divides the burden between activations and weights, making both quantisable to int8 with plain kernels. This same idea, applied to weights only, becomes AWQ.
What to quantise, and what not to
Consistent findings across the literature:
- Embeddings and the output head are disproportionately sensitive; almost all recipes keep them at 16 bits.
- Normalisation parameters are tiny in count and sensitive; keep them.
- The first and last transformer blocks are more sensitive than the middle.
- Attention is more sensitive than the MLP, so mixed-precision schemes give attention more bits.
Papers and specifications
- Micikevicius et al., Mixed Precision Training, ICLR 2018 — arxiv.org/abs/1710.03740
- Kalamkar et al., A Study of BFLOAT16 for Deep Learning Training, 2019 — arxiv.org/abs/1905.12322
- Dettmers et al., LLM.int8(), NeurIPS 2022 — arxiv.org/abs/2208.07339
- Micikevicius et al., FP8 Formats for Deep Learning, 2022 — arxiv.org/abs/2209.05433
- Xiao et al., SmoothQuant, ICML 2023 — arxiv.org/abs/2211.10438
- Dettmers et al., QLoRA, NeurIPS 2023 — arxiv.org/abs/2305.14314 (NF4)
- Open Compute Project, OCP Microscaling Formats (MX) Specification v1.0, 2023.
- NVIDIA, Pretraining Large Language Models with NVFP4, 2025 — arxiv.org/abs/2509.25149
What to learn next
- GPTQ — quantising weights while correcting the error as you go.
- AWQ — deciding which weights matter before you round them.
- Mixed precision training — the training side of the same choices.