Quantised LLM Inference

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.

On this page 10
  1. The short answer
  2. The analogy you have already lived
  3. Why it exists
  4. How the bits are spent
  5. The story bf16 won
  6. Integers are a different beast
  7. What is honestly hard here
  8. Where you have already seen this
  9. Remember this
  10. What to learn next

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, finer

Look 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 numbers

The 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

bash
pip install torch

Runs on a CPU instantly.

Every format, measured

formats.py
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}")
Output
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

FormatBitsWhere
fp3232master weights, optimiser state
bf1616training compute, most inference
fp1616older inference stacks, needs loss scaling to train
fp8 e4m3 / e5m28Hopper and later, natively
int88LLM.int8(), SmoothQuant, mature everywhere
int44GPTQ, AWQ, bitsandbytes NF4
NF44QLoRA — non-uniform levels fitted to a normal distribution
MXFP4432-value blocks, one 8-bit power-of-two scale
NVFP4416-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$
fp328233.4e381.2e-7
bf16873.4e387.8e-3
fp16510655049.8e-4
fp8 e4m3434480.125
fp8 e5m252573440.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:

BlockScale typeEffective bits
MXFP4 (OCP Microscaling)32E8M0, a power of two4.25
NVFP4 (NVIDIA)16FP8 E4M3, plus a per-tensor FP32 scale4.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

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.

What to learn next

These follow on from what you just read.

  • Quantised LLM Inference

    GPTQ

    GPTQ quantises a layer one column at a time and pushes each rounding error into the weights it has not reached yet, so the layer's output stays close to the original.

  • Looking Inside a Trained Model

    The logit lens

    The logit lens reads out a model's current best guess at every intermediate layer, showing a prediction sharpen gradually rather than appear all at once.

  • Looking Inside a Trained Model

    Induction heads

    Induction heads let a model complete a repeated pattern it has only seen once before in the same conversation, the mechanism behind in-context learning.