the series robot with the pytorch flame as its left eye; for the floating point page its mouth is a number line whose tick spacing doubles

Floating Point

Table of Contents

Run this anywhere:

>>> 0.1 + 0.2
0.30000000000000004
>>> 0.1 + 0.2 == 0.3
False

And in real training runs, this happens:

step 39,998   loss 2.41
step 39,999   loss 2.40
step 40,000   loss nan

Both outputs have the same cause: the way computers store numbers. By the end of this page you will be able to predict both, explain them to someone else, and read a line like "MXFP4, E2M1, block 32, E8M0 scale" part by part. No prior knowledge is assumed. Every measured number on this page comes from a small script you can run, linked where the number appears, measured on an Apple M3 Max with torch 2.11.0 [1].

A fixed set of points

A computer stores a number in a fixed number of bits. Each bit has 2 states, so 32 bits can form 2 x 2 x ... x 2 = 2^32 = 4,294,967,296 different patterns. A number format assigns one number to each pattern. So for a 32-bit machine only those 4,294,967,296 numbers exist; anything else you compute is moved to the nearest one of them, and the distance moved is the rounding error. A format is exactly this: a choice of which numbers get a pattern. This page is about how that choice is made.

First, see the size of the problem. Between 1 and 2 there are infinitely many numbers. Zoom in anywhere and you find more. The line is never empty, at any depth. A format is a finite list of points. So almost no computed number is on the list, and almost every operation ends with a move to the nearest point:

the stretch of line from 1 to 2 with a few of its numbers marked in blue: 1.1, 1.41421..., 1.5, 1.61803..., 1.9999; a zoom into the sliver between 1.41 and 1.42 shows more numbers again, with the note zoom anywhere, forever: never empty; below, the same stretch as an 8-bit format sees it: 8 orange points and nothing else, and every other number must move to one of themthe stretch of line from 1 to 2 with a few of its numbers marked in blue: 1.1, 1.41421..., 1.5, 1.61803..., 1.9999; a zoom into the sliver between 1.41 and 1.42 shows more numbers again, with the note zoom anywhere, forever: never empty; below, the same stretch as an 8-bit format sees it: 8 orange points and nothing else, and every other number must move to one of them
Figure 1. infinitely many numbers on the line, 8 points in the format. every number off the list moves to the nearest point.

The simplest choice is equal spacing: 0, 1, 2, 3, and so on, the same distance 1 between every pair of neighbors. That is what integers are, and for counting it works. Now take two numbers that one real neural network produced in one training step: an activation of 10.5 (a value flowing through the network) and a gradient of 0.0000004 (a value that steers learning). Store both with equally spaced points:

a number line of equally spaced points with 2 orange arrows landing on it, one at 10.5 and one at 0.0000004; below, 2 magnified windows: the window from 10 to 11 shows 10.5 exactly between the 2 points with a snap arrow to 10 and the error drawn as a thick orange segment, 0.5 over 10.5 equals 4.8 percent; the window from 0 to 1 shows 0.0000004 still touching 0 even magnified, the error the whole value, 100 percenta number line of equally spaced points with 2 orange arrows landing on it, one at 10.5 and one at 0.0000004; below, 2 magnified windows: the window from 10 to 11 shows 10.5 exactly between the 2 points with a snap arrow to 10 and the error drawn as a thick orange segment, 0.5 over 10.5 equals 4.8 percent; the window from 0 to 1 shows 0.0000004 still touching 0 even magnified, the error the whole value, 100 percent
Figure 2. equally spaced points meet 2 real numbers. 10.5 keeps a 4.8% error; 0.0000004 becomes 0, a 100% error.

Those 2 numbers are not invented. They come from 1 training step of a small network, and this page keeps returning to that step, so here it is, drawn:

one training step drawn as a real network: 3 columns of neuron squares joined by grey weight edges, a loss box at the right; a blue forward arrow above carries the activations; the edges are the weights, 0.00000125 to 0.3; a dashed orange backward lane below carries 1 gradient per weight, computed from the loss; orange dashed update arrows rise from the lane into the edges; at the bottom, the same step's numbers on one log axis: a blue activations bar from 0.000045 to 10.5 above an orange gradients bar from 0.00000037 to 0.71, the backward bar reaching 2 decades farther leftone training step drawn as a real network: 3 columns of neuron squares joined by grey weight edges, a loss box at the right; a blue forward arrow above carries the activations; the edges are the weights, 0.00000125 to 0.3; a dashed orange backward lane below carries 1 gradient per weight, computed from the loss; orange dashed update arrows rise from the lane into the edges; at the bottom, the same step's numbers on one log axis: a blue activations bar from 0.000045 to 10.5 above an orange gradients bar from 0.00000037 to 0.71, the backward bar reaching 2 decades farther left
Figure 3. one training step of the measured network, drawn as the network. the bottom axis carries the step's 2 flows on one scale: the backward numbers live far lower than the forward ones.

3 kinds of numbers move in that step. Activations are the values flowing forward through the layers. Weights are the numbers the network is learning; multiplying by them is what a layer does. Gradients are computed backward from the loss, 1 for every weight: each one says how much that weight should move. Train the network for a few seconds and measure all 3:

3 rows on one shared axis with decade gridlines: gradients from 0.00000037 to 0.71, weights from 0.00000125 to 0.3, activations from 0.000045 to 10.5; each row shows its full range as a thin line, the middle 99.8 percent as a box, and its median as a thick mark; a bracket over the gradients computes 0.71 over 0.00000037 as 1,900,000, about 2 to the power 20.9; an arrow under the axis computes 10.5 over 0.00000037 as 28,000,000; a warm band on the axis marks where float16's normal points will sit, from 0.0000613 rows on one shared axis with decade gridlines: gradients from 0.00000037 to 0.71, weights from 0.00000125 to 0.3, activations from 0.000045 to 10.5; each row shows its full range as a thin line, the middle 99.8 percent as a box, and its median as a thick mark; a bracket over the gradients computes 0.71 over 0.00000037 as 1,900,000, about 2 to the power 20.9; an arrow under the axis computes 10.5 over 0.00000037 as 28,000,000; a warm band on the axis marks where float16's normal points will sit, from 0.000061
Figure 4. the measured ranges of one small network's numbers in one training step. thin line: full range. box: the middle 99.8%. thick mark: the median.
p1_where_numbers_live.py the proof, ready to read or run
"""Proof: where a real network's numbers live. Train a small MLP for
two seconds, then measure the spans of its weights, activations, and
gradients in one step. The same model needs numbers nine orders of
magnitude apart; a uniform grid cannot serve both ends."""
import torch, torch.nn as nn

torch.manual_seed(0)
net = nn.Sequential(nn.Linear(64, 256), nn.ReLU(),
                    nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1))
opt = torch.optim.Adam(net.parameters(), lr=1e-3)
X = torch.randn(4096, 64); y = (X[:, :3].prod(1, keepdim=True) * 10)
for step in range(300):
    opt.zero_grad()
    loss = ((net(X) - y) ** 2).mean()
    loss.backward(); opt.step()

acts = {}
h = X
tensors = {"weights": torch.cat([p.detach().flatten() for p in net.parameters()]),
           "grads": torch.cat([p.grad.flatten() for p in net.parameters()])}
with torch.no_grad():
    a = net[1](net[0](X))
    tensors["activations"] = net[2](a).flatten()

print(f"final loss {loss.item():.3f}")
for name, t in tensors.items():
    nz = t[t != 0].abs()
    q = torch.quantile(nz, torch.tensor([0.001, 0.5, 0.999]))
    print(f"{name:12s} |min..max| {nz.min():.2e} .. {nz.max():.2e}   "
          f"p0.1/median/p99.9  {q[0]:.2e} / {q[1]:.2e} / {q[2]:.2e}")
span = tensors["grads"].abs()[tensors["grads"] != 0]
import math
print(f"gradient span: {math.log2(span.max() / span.min()):.1f} powers of two "
      f"in one tensor family, this small a net, this early")

print("\ndecade histogram (percent of values per log10 decade; "
      "the drawn silhouettes):")
edges = list(range(-8, 2))
for name, t in tensors.items():
    nz = t[t != 0].abs().log10()
    counts = [(100.0 * ((nz >= a) & (nz < a + 1)).sum() / nz.numel())
              for a in edges]
    row = "  ".join(f"{c:4.1f}" for c in counts)
    print(f"{name:12s} [{edges[0]}..{edges[-1] + 1}]  {row}")
download and run it

Read the two computed lines in the drawing. Why divide? The ratio says how many of the small number fit into the big one. With equal spacing, that is the number of steps between them. The gradients alone run from 0.00000037 to 0.71, and 0.71 / 0.00000037 is about 1,900,000, which is 2^20.9. So if the spacing is fine enough to keep the smallest gradient, the largest one sits 1,900,000 steps away. Across kinds it is worse: 10.5 / 0.00000037 = 28,000,000 steps. A larger model makes both numbers bigger. Equal spacing cannot hold both ends. Set the spacing to 0.0000004 so the smallest gradient survives, and even 2^31 = 2,147,483,648 points in the positive direction reach only 0.0000004 x 2,147,483,648 = 859: the format ends before 1,000. Set the spacing to 1 so that large values fit, and every gradient becomes 0, as the first drawing showed. The spacing itself has to change with the size of the number: points packed close near zero, spread out far from it.

Floating the point

One small tool first: what a dot means in base two, because from here on every number is bits. In decimal, each place is worth a tenth of the place before it: the 7 in 204.75 means seven tenths. Binary uses the same rule with 2 in place of 10. Left of the dot the places are worth 8, 4, 2, 1; right of it they are worth a half, a quarter, an eighth:

2 place value strips: the decimal number 204.75 over its places hundred ten one tenth hundredth, and the binary byte 0010.1100 over its places eight four two one, then one half, one quarter, one eighth, one sixteenth; the bits that are 1 are highlighted and their places sum to 2 plus one half plus one quarter equals 2.75; under each binary place a bar drawn to scale shows its worth halving toward nothing2 place value strips: the decimal number 204.75 over its places hundred ten one tenth hundredth, and the binary byte 0010.1100 over its places eight four two one, then one half, one quarter, one eighth, one sixteenth; the bits that are 1 are highlighted and their places sum to 2 plus one half plus one quarter equals 2.75; under each binary place a bar drawn to scale shows its worth halving toward nothing
Figure 5. the dot in base two. same reading you have done since childhood, with each place worth half the one before instead of a tenth.

Read 0010.1100 with those place values: the 1s sit on 2, on a half and on a quarter, and 2 + 0.5 + 0.25 = 2.75. That is all a string of bits with a dot in it can mean, and you can now read any of them.

One question hides here, and it decides half of this page: which fractions can these places write exactly? The places are halves, quarters, eighths. Any finite sum of them is a fraction whose denominator is a power of 2: 1/2, 3/4, 5/8. Those end cleanly. Now take 1/3, 1/5 or 1/10. Their denominators carry a 3 or a 5. No finite sum of halves, quarters and eighths equals them. Watch both cases run:

two panels build fractions from the halving places, one bit per row, the blue fill showing the sum so far; left, 5/8: add the half, skip the quarter, add the eighth, and the fill reaches the blue target line exactly on the third row, 5/8 equals 0.101, finished in 3 bits; right, 1/3: skip the half, add the quarter, skip the eighth, add the sixteenth, skip the thirty-second, and a gap to the red target line is left after every row, forever: 1/3 equals 0.010101 repeating; below, the rule: a power-of-2 denominator gets hit exactly, a 3 or a 5 never does, and 1/10 carries a 5two panels build fractions from the halving places, one bit per row, the blue fill showing the sum so far; left, 5/8: add the half, skip the quarter, add the eighth, and the fill reaches the blue target line exactly on the third row, 5/8 equals 0.101, finished in 3 bits; right, 1/3: skip the half, add the quarter, skip the eighth, add the sixteenth, skip the thirty-second, and a gap to the red target line is left after every row, forever: 1/3 equals 0.010101 repeating; below, the rule: a power-of-2 denominator gets hit exactly, a 3 or a 5 never does, and 1/10 carries a 5
Figure 6. building fractions from halves, quarters, eighths. 5/8 is hit exactly in 3 bits. 1/3 leaves a gap after every row, forever: its bits repeat. 1/10 carries a 5 and repeats the same way.

Now a first attempt that fails. Take 1 byte, which is 8 bits, and fix the dot in the middle: 4 bits for the whole part, 4 for the fraction:

the same 8 bits with the dot fixed in 3 places, and for each choice a bar on one shared log axis from its smallest step to its ceiling: 0.0156 to 3.98, 0.0625 to 15.94, 0.25 to 63.75; the 3 bars are the same length, 255 steps each, and slide right as the dot moves; 2 dashed orange lines mark 0.0000004, left of every bar, and 10.5, covered only by the lower barsthe same 8 bits with the dot fixed in 3 places, and for each choice a bar on one shared log axis from its smallest step to its ceiling: 0.0156 to 3.98, 0.0625 to 15.94, 0.25 to 63.75; the 3 bars are the same length, 255 steps each, and slide right as the dot moves; 2 dashed orange lines mark 0.0000004, left of every bar, and 10.5, covered only by the lower bars
Figure 7. fixed point: the same 8 bits, 3 dot positions. each choice reaches the same 255 steps; moving the dot slides the reach, never widens it, and no position covers both target numbers.

This is called fixed point, and the drawing shows its problem: the dot's position decides the largest value and the smallest step at the same time, and no single position makes both good. The repair is in the name of this page. Let the dot move, and store where it went.

Where should the machine write down "where the dot went"? You already know the answer, because you already do this on paper. To write a very small or a very large number, you split it into its digits and its size: 0.00000037 becomes 3.7 x 10^-7. The 3.7 says what the digits are; the 10^-7 says how big the number is. 2 separate jobs, written separately:

2 bands: the decimal split the reader knows, 0.00000037 equals 3.7 times 10 to the -7 and 10.5 equals 1.05 times 10 to the 1, with the digits chip in blue and the size chip in orange; the same 2 numbers split in base 2, 2 to the -22 times 1.552 and 2 to the 3 times 1.3125; below, the 3 bit fields with dashed arrows from the chips: the orange exponent takes the size, -22 plus 127 equals 105 goes in, the blue mantissa takes the digits .552 in binary places2 bands: the decimal split the reader knows, 0.00000037 equals 3.7 times 10 to the -7 and 10.5 equals 1.05 times 10 to the 1, with the digits chip in blue and the size chip in orange; the same 2 numbers split in base 2, 2 to the -22 times 1.552 and 2 to the 3 times 1.3125; below, the 3 bit fields with dashed arrows from the chips: the orange exponent takes the size, -22 plus 127 equals 105 goes in, the blue mantissa takes the digits .552 in binary places
Figure 8. the heart of the format: the digits-and-size split you already write, moved to base 2, one bit field per half.

A float makes exactly this split, in base 2. Any positive number is some power of 2 times a number between 1 and 2: 10.5 is 1.3125 x 2^3 (check it: 2^3 = 8, and 1.3125 x 8 = 10.5), and 0.00000037 is about 1.552 x 2^-22. The power of 2 goes into one field, named the exponent. The 1.something goes into the other field, named the mantissa. The 2 words are only names for the 2 halves of the split: the exponent is the number's size, the mantissa is its digits.

What does each field buy on the number line? Draw them [2]:

a number line at true linear scale tiled with doubling intervals from 0.5 to 16, each box twice as wide as the one before, small x2 marks at each border, the interval 8 to 16 in orange with 10.5 landing inside and the exponent's stored numbers -1 to 3 written over the boxes; below, that interval magnified and cut into 8 equal steps numbered step 0 to step 7, the points 10 and 11 in orange with 10.5 landing between them, and rounding picks onea number line at true linear scale tiled with doubling intervals from 0.5 to 16, each box twice as wide as the one before, small x2 marks at each border, the interval 8 to 16 in orange with 10.5 landing inside and the exponent's stored numbers -1 to 3 written over the boxes; below, that interval magnified and cut into 8 equal steps numbered step 0 to step 7, the points 10 and 11 in orange with 10.5 landing between them, and rounding picks one
Figure 9. what each field does. the exponent counts doubling intervals, drawn at true scale so you can see each one really is twice as wide; the mantissa cuts the chosen interval into 2^M equal steps.

Where do the intervals come from? From the split itself. Fix the power at 8. The digits part runs from 1.00 to 1.99. So the values run from 8 to just under 16: every number with power 8 lives between 8 and 16. Fix the power at 16 and you fill 16 to 32. That stretch starts twice as high, so it is twice as wide. That is all the doubling is. Choosing the exponent is choosing the interval. 8 exponent bits count 2^8 = 256 intervals, and because every interval doubles, 256 of them span an enormous distance: that is how 8 bits reach from 1e-38 to 3e38. The mantissa then cuts the chosen interval into equal steps: 3 bits make 2 x 2 x 2 = 8 steps, binary32's 23 bits make 8,388,608. And the growing step we asked for at the start falls out by itself: [4, 8) is twice as wide as [2, 4), both are cut into the same number of steps, so every step in [4, 8) is twice as long. Small numbers get fine steps, large numbers get coarse ones, with nobody managing it.

Now store one number end to end. Move 1: pick the interval. 6.1 = 1.525 x 4, and 1.525 is between 1 and 2, so the interval is [4, 8). Move 2: say where inside. 6.1 sits 2.1 past 4, and 2.1 / 4 = 0.525. That fraction, 0.525, is what the mantissa holds:

two panels: move 1, which interval: a number line from 0 to 8 with the doubling intervals boxed, 6.1's arrow landing in the orange interval 4 to 8, because 6.1 equals 1.525 times 4 and 1.525 is between 1 and 2; move 2, where inside: the interval magnified and cut into 8 equal steps of 0.5 labeled 4 through 8, 6.1's arrow landing between 6 and 6.5 with the nearest step 6 in orange, and a blue bracket measuring 6.1 sits 2.1 past 4, 2.1 over 4 equals 0.525, the fraction the mantissa field stores; with 3 bits it rounds to 6.0, binary32 cuts the same interval into 8,388,608 steps and lands on 6.0999999two panels: move 1, which interval: a number line from 0 to 8 with the doubling intervals boxed, 6.1's arrow landing in the orange interval 4 to 8, because 6.1 equals 1.525 times 4 and 1.525 is between 1 and 2; move 2, where inside: the interval magnified and cut into 8 equal steps of 0.5 labeled 4 through 8, 6.1's arrow landing between 6 and 6.5 with the nearest step 6 in orange, and a blue bracket measuring 6.1 sits 2.1 past 4, 2.1 over 4 equals 0.525, the fraction the mantissa field stores; with 3 bits it rounds to 6.0, binary32 cuts the same interval into 8,388,608 steps and lands on 6.0999999
Figure 10. storing 6.1 is 2 moves: pick the interval, then cut it into equal steps and take the nearest. the fraction 0.525 is what the mantissa holds.

In a real 32-bit float the split gets 1 bit for the sign, 8 for the exponent, 23 for the mantissa. Here is 6.1, every field decoded:

6.1's actual 32 bits drawn in their fields: sign 0, exponent 10000001, mantissa 10000110011001100110011; a dashed ghost cell holding 1. above the mantissa marks the hidden 1, never stored; dashed arrows drop from each field to its decode: 10000001 equals 129, and 129 minus 127 equals 2, the interval 4 to 8, with the bias keeping the field unsigned; the mantissa bits read as 0.525, the position; the parts compose into 1 plus 0.525 times 2 squared equals 6.0999999, the nearest float32 to 6.16.1's actual 32 bits drawn in their fields: sign 0, exponent 10000001, mantissa 10000110011001100110011; a dashed ghost cell holding 1. above the mantissa marks the hidden 1, never stored; dashed arrows drop from each field to its decode: 10000001 equals 129, and 129 minus 127 equals 2, the interval 4 to 8, with the bias keeping the field unsigned; the mantissa bits read as 0.525, the position; the parts compose into 1 plus 0.525 times 2 squared equals 6.0999999, the nearest float32 to 6.1
Figure 11. binary32, decoded by hand: the actual bits of 6.1, the hidden 1 as a ghost cell, and the bias on the exponent's path.

Two details in that card deserve their own sentences, because every format on this page inherits both. First, the decode row says "1 + 0.525", but the mantissa field only stored the .525. The leading 1 costs nothing: every number in a power-of-two interval is 1.something times a power of two, so the format does not store the 1 and gets 24 bits of position for the price of 23. (The subnormals, coming 2 sections from now, are 0.something and are the one exception.) Second, the bias. The exponent field is unsigned: 8 bits, 0 to 255, no minus sign. But interval numbers need minus signs: 0.00000037 lives at interval -22. The fix is one constant, 127, the middle of the range. When the machine stores, it adds 127: the -22 above went in as -22 + 127 = 105. When it reads, it subtracts 127: this card's 129 means 129 - 127 = 2. Same 127, two directions. Every interval number from -126 to 127 now fits in 1 to 254. (Stored 0 and 255 are reserved; they build the edge cases 2 sections ahead.)

two number lines: the interval numbers a format needs, -126 to 127, with -22 and k equals 2 marked in orange; and the 8-bit field's codes, 1 to 254, with 105 and 129 marked; a solid orange arrow crosses down labeled storing: add 127, -22 plus 127 equals 105, and a dashed orange arrow crosses back up labeled reading: subtract 127, 129 minus 127 equals 2; below, why 127: it is the middle of the field's range, so adding it slides every interval number into 1 to 254 with nothing left overtwo number lines: the interval numbers a format needs, -126 to 127, with -22 and k equals 2 marked in orange; and the 8-bit field's codes, 1 to 254, with 105 and 129 marked; a solid orange arrow crosses down labeled storing: add 127, -22 plus 127 equals 105, and a dashed orange arrow crosses back up labeled reading: subtract 127, 129 minus 127 equals 2; below, why 127: it is the middle of the field's range, so adding it slides every interval number into 1 to 254 with nothing left over
Figure 12. the bias, both directions: add 127 when storing, subtract 127 when reading. why 127: it is the middle of what 8 unsigned bits can hold.

The unsigned choice has a quiet payoff. A bigger float always carries a bigger bit pattern, so a chip can compare 2 floats with the integer circuits it already owns [3].

Notice what the last decode row admits: the machine does not store 6.1. It cannot. 6.1 is 61/10, and the 10 carries a 5. The fractions figure showed what a 5 does in base 2: the bits repeat forever. The mantissa keeps 23 of them and cuts the rest. What remains decodes to 6.0999999..., the nearest float32. The gap is the rounding error. The next three sections are about the size and the consequences of that gap. This layout, the bias and the field widths, was standardized in 1985 as IEEE 754 [3], and it is the format your float32 tensors use today, unchanged.

The map

You cannot draw all 4,294,967,296 points of float32. But the same design at 8 bits has only 2^8 = 256 patterns, and its entire positive half fits in one picture. The 8-bit format is called E4M3, and the name is just its recipe: E4 means 4 exponent bits, M3 means 3 mantissa bits. Every format name on this page reads the same way. This is the most important drawing on this page, because every format you will ever meet is this drawing with different counts:

the full positive range of FP8 E4M3 drawn as 16 intervals on one line: a dashed subnormal interval at the far left labeled the ramp to zero, then 15 power of two intervals each holding the same 8 points, a badge counting 127 positive values all drawn, the ramp floor labeled 0.00195, a step chip reading step 32 under the top interval, ending at 448 with a note that there is no infinity and one code means NaN; the interval from 1 to 2 is filled orange and magnified below, its 8 points labeled 1.0 to 1.875 with a step bracket: step 0.125 everywhere in this interval, the next interval 0.25, x2 foreverthe full positive range of FP8 E4M3 drawn as 16 intervals on one line: a dashed subnormal interval at the far left labeled the ramp to zero, then 15 power of two intervals each holding the same 8 points, a badge counting 127 positive values all drawn, the ramp floor labeled 0.00195, a step chip reading step 32 under the top interval, ending at 448 with a note that there is no infinity and one code means NaN; the interval from 1 to 2 is filled orange and magnified below, its 8 points labeled 1.0 to 1.875 with a step bracket: step 0.125 everywhere in this interval, the next interval 0.25, x2 forever
Figure 13. every positive number an FP8 E4M3 can be. each power-of-two interval holds the same 8 points; the step doubles from interval to interval, forever.
p2_e4m3_map.py the proof, ready to read or run
"""Proof: every FP8 E4M3 value, decoded from its bits by hand, and
checked bit-for-bit against torch.float8_e4m3fn. 256 codes: this is
the entire format, and the decoder is fifteen lines."""
import torch

def decode_e4m3(byte):
    s = (byte >> 7) & 1
    e = (byte >> 3) & 0xF
    m = byte & 0x7
    if e == 0xF and m == 0x7:
        return float("nan")            # the single NaN code per sign
    if e == 0:                          # subnormal: no hidden 1
        return (-1)**s * 2**(1 - 7) * (m / 8)
    return (-1)**s * 2**(e - 7) * (1 + m / 8)

agree, values = 0, []
for byte in range(256):
    mine = decode_e4m3(byte)
    ref = torch.tensor([byte], dtype=torch.uint8).view(torch.float8_e4m3fn).float().item()
    ok = (mine != mine and ref != ref) or mine == ref
    agree += ok
    if not ok:
        print(f"DISAGREE byte {byte:08b}: mine {mine}, torch {ref}")
    values.append(mine)
print(f"{agree}/256 bit patterns agree with torch.float8_e4m3fn")

finite = sorted(v for v in values if v == v)
pos = [v for v in finite if v > 0]
print(f"largest value          : {max(finite)}")
print(f"smallest positive      : {min(pos)}")
print(f"positive values        : {len(pos)}")
print(f"step just above 1.0    : {min(v for v in pos if v > 1.0) - 1.0}")
print(f"step just below 448    : {448 - max(v for v in pos if v < 448)}")
download and run it

Read the map slowly; every later section builds on it. Every interval holds exactly 8 points, one per mantissa pattern. Neighbors inside a interval are 1/8 of the interval's starting value apart, so rounding moves a number by at most half of that: 1/16 of its own size, about 6%, and this is the same in every interval, from 0.02 to 400. The step inside [1, 2) is 0.125; the step inside [256, 448] is 32. So the absolute error grows with the number, but the relative error does not. Every float format works this way.

The local step also has a proper name, and you will meet it in every numerics discussion: the ulp, the unit in the last place [4]. "The ulp at x" means the step between neighboring points where x lives. E4M3's ulp at 1.0 is 0.125; its ulp at 300 is 32. One number, the ulp at 1.0, summarizes a format's whole precision, because every other interval's step is that number times a power of two; the field guide later on this page prints it on every format's card.

The proof under the map is 15 lines of Python that decode all 256 bit patterns from first principles, and torch agrees with every single one. The largest value is 448. The smallest positive value is 0.001953125. Divide them: 448 / 0.001953125 = 229,376, about 2^17.8, so the top of this format is about 18 doublings above the bottom. Now compare that with the second drawing: one tensor's gradients spanned 20.9 doublings. The whole format is narrower than one tensor's spread, and that gap runs the entire training half of this page.

The edges of the map

Four places on the map need special handling, and every format must decide all four.

Zero. The position field always means "1.something", so no pattern naturally means zero. The all-zeros pattern is simply assigned the value zero, as a special case. The sign bit still exists, so there is a +0 and a -0, and they compare equal.

The ramp. Just above zero there is trouble. Every normal point is 1.something x a power of 2. The exponent has a lowest power, so there is a smallest normal point: in E4M3 it is 1.0 x 2^-6 = 0.015625. Below it, a naive format has nothing until 0. A cliff. The fix: in the lowest interval only, drop the hidden 1. Read the mantissa as 0.something instead of 1.something. Then 0.000 x 2^-6 is 0 itself, and the other 7 patterns walk up from 0 in the same equal steps. These points are the subnormals. They turn the cliff into a ramp:

2 number lines near zero: on the first, a dashed empty box between 0 and the smallest normal reads no points anywhere in here, and a small result's arrow falls through it to 0; on the second, 7 equal orange steps of 0.00195 fill the gap and the same result's arrow lands on a step2 number lines near zero: on the first, a dashed empty box between 0 and the smallest normal reads no points anywhere in here, and a small result's arrow falls through it to 0; on the second, 7 equal orange steps of 0.00195 fill the gap and the same result's arrow lands on a step
Figure 14. the gap at zero, empty and then filled. gradual underflow means the difference of two unequal numbers always has somewhere to land.

Infinity. When a result overflows the ceiling, float32 returns a dedicated pattern called inf, which then behaves lawfully: anything finite divided by inf is 0, and inf - inf is the next special value. E4M3 made a harder choice: it has no infinity at all. Those patterns were used for one more doubling of range, and overflow stops at 448 instead. Keep that choice in mind; it is the first sign of how little room 8 bits leave.

NaN. Not a Number is the pattern returned when no answer is defensible: 0/0, inf - inf, the square root of -1. It has one deliberately strange law: nan == nan is false, so x != x is the honest test for it. When your loss prints nan, this value is what you are looking at, and it arrived through an overflow or an undefined step somewhere upstream. We will catch it in the act in a few sections.

Rounding

Between any 2 neighboring points of the map lies everything the format cannot say. When a result lands there, one rule decides its fate: go to the nearer point. When it lands exactly halfway, go to the point whose last mantissa bit is 0, so that ties break upward and downward equally often and a long chain of them does not drift. This pair of rules is round-to-nearest, ties-to-even, the default of every machine you will touch [3]:

3 bands: a number between 1.250 and 1.375 with distance brackets 0.7 of the step and 0.3, and an arrow to the nearer point; the tie drawn: 1.1875 exactly between 1.125, mantissa ending 001, odd, and 1.250, ending 010, even, wins, with equal-distance brackets and the arrow to 1.250; and 1.2875 under the 4 rounding modes as 4 aligned arrow rows, nearest, toward 0 and toward minus inf to 1.250, toward plus inf to 1.3753 bands: a number between 1.250 and 1.375 with distance brackets 0.7 of the step and 0.3, and an arrow to the nearer point; the tie drawn: 1.1875 exactly between 1.125, mantissa ending 001, odd, and 1.250, ending 010, even, wins, with equal-distance brackets and the arrow to 1.250; and 1.2875 under the 4 rounding modes as 4 aligned arrow rows, nearest, toward 0 and toward minus inf to 1.250, toward plus inf to 1.375
Figure 15. between two points, the nearer one wins; a tie goes to the even one. the whole error model of numerical computing is this picture.

The worst this rule can do is move a value by half the local step: half an ulp. For float32 the ulp at 1.0 is 2 to the power -23, which is about 1.2e-7 (read e-7 as: move the decimal point 7 places left, so 0.00000012), and that number is called the machine epsilon (torch.finfo(torch.float32).eps prints it). So every operation lands within about 6e-8 of the true result, measured relative to the result's size. Both numbers read straight off the map: find the interval that starts at 1, take its step, halve it. The standard error analysis of floating point is this one bound, applied once per operation [4].

How can hardware apply the rule without computing every bit of the exact answer first? It keeps 3 extra bits, and that is all it ever needs:

the exact product of 2 24-bit numbers drawn as a 48-bit bar cut after the 24 bits the format keeps; the first 2 lost bits drop into orange intervals g and r; dashed lines funnel every later bit into s, 1 if any later bit was 1; guard, round, sticky alone answer every nearest-or-tie question, exactlythe exact product of 2 24-bit numbers drawn as a 48-bit bar cut after the 24 bits the format keeps; the first 2 lost bits drop into orange intervals g and r; dashed lines funnel every later bit into s, 1 if any later bit was 1; guard, round, sticky alone answer every nearest-or-tie question, exactly
Figure 16. guard, round, sticky: the whole rounding rule runs on 3 extra bits, and the result is exactly as if the machine had kept all 48.

This explains the first code block at the top of the page, and you can check every step of it by hand. To write 0.1 in binary you double it, again and again, and each time the whole part of the result is the next bit. 0.1 doubles to 0.2: the first bit is 0. Then 0.4, 0.8: two more 0s. Then 0.8 doubles to 1.6: the first 1, and the 0.6 carries on. 0.6 doubles to 1.2: another 1, and 0.2 carries on. But 0.2 is where the second step started. The process is in a loop, and the bits repeat forever:

the doubling ladder that converts 0.1 to binary: 5 rows each double the fraction and record the whole part as the next bit, giving 0 0 0 1 1; the leftover 0.2 loops back to the second row along an orange arrow; below, the bit string with 3 consecutive 0011 blocks bracketed, the same block forever, and an orange cut mark where the format cuts, bit 53 for float64, and rounds, storing exactly 0.1000000000000000055511151231257827the doubling ladder that converts 0.1 to binary: 5 rows each double the fraction and record the whole part as the next bit, giving 0 0 0 1 1; the leftover 0.2 loops back to the second row along an orange arrow; below, the bit string with 3 consecutive 0011 blocks bracketed, the same block forever, and an orange cut mark where the format cuts, bit 53 for float64, and rounds, storing exactly 0.1000000000000000055511151231257827
Figure 17. 0.1 converted by hand. 5 doublings and the state returns to 0.2: the tail 0011 repeats forever, and no finite mantissa can hold it.

1/3 does the same thing in decimal, and for the same reason: the denominator has a prime factor the base does not. Ten is 2 times 5, so 0.1 is 1/(2 times 5), and base two has no 5. The format cuts the repetition at its mantissa width and stores the nearest representable number, which for float64 is exactly 0.1000000000000000055511151231257827... (proof prints every digit). The same happens to 0.2 and to 0.3, and the sum of the two stored numbers is not the stored number nearest 0.3. Nothing malfunctioned. 3 numbers you typed do not exist, and the printed digits show exactly which nearby numbers were stored instead.

p0_riddles.py the proof, ready to read or run
"""Proof: the two riddles. 0.1 + 0.2 is not 0.3 because 0.1 and 0.2
do not exist in binary; the sum of the two nearest citizens is not
the nearest citizen to 0.3. And at 1e8, float32's local step is 8,
so adding 1 moves nothing."""
from fractions import Fraction
import struct, torch

# the exact value a float64 stores for 0.1
exact = Fraction(struct.unpack("<q", struct.pack("<d", 0.1))[0] & ((1<<52)-1) | (1<<52), 1)
# simpler and fully honest: print the stored values to 30 digits
print(f"stored 0.1  = {0.1:.30f}")
print(f"stored 0.2  = {0.2:.30f}")
print(f"stored sum  = {0.1 + 0.2:.30f}")
print(f"stored 0.3  = {0.3:.30f}")
print(f"0.1 + 0.2 == 0.3 : {0.1 + 0.2 == 0.3}")
print()
t = torch.tensor(1e8, dtype=torch.float32)
print(f"float32: 1e8 + 1 - 1e8 = {((t + 1) - t).item()}")
print(f"the local step at 1e8  = {torch.nextafter(t, torch.tensor(2e8)).item() - t.item()}")
download and run it

One more piece of standard equipment: the fused multiply-add, 1 instruction computing a times b plus c with a single rounding at the end instead of 2. The result is as if the product had been computed exactly and only the final sum were rounded, and on modern chips it costs about as much as 1 multiply [27]. The difference is not small print. Choose a = b = 1 + 2^-27 and c = -(1 + 2^-26): the separate path rounds the product first and returns exactly 0, while the fused path returns the true answer, 2^-54 (proof runs both on this machine). Every tensor core in the training half of this page is a lattice of these fused units, and the sums they accumulate stay in float32 even when the products arrive in 8 bits. Keep that sentence; it is why low-precision training is possible at all.

p21_fma_one_rounding.py the proof, ready to read or run
"""Proof: the fused multiply-add really is one rounding instead of
2, and the difference is visible. Choose a, b and c so that the
exact product a*b carries a tail the separate path must round away:
  a = b = 1 + 2^-27, so a*b = 1 + 2^-26 + 2^-54 exactly
  c = -(1 + 2^-26)
The separate path rounds a*b to 1 + 2^-26 (the 2^-54 tail is under
half an ulp), then adds c and returns exactly 0.0: it reports that
a*b + c is zero when it is not. The fused path computes the exact
product, adds c, rounds once, and returns the true answer, 2^-54.
Both answers are legal for the source line "a*b + c", which is why
a compiler's fusion choice can change a program's bits.
"""
import math
import torch

a = b = 1.0 + 2.0 ** -27
c = -(1.0 + 2.0 ** -26)

separate = a * b + c
if hasattr(math, "fma"):
    fused = math.fma(a, b, c)          # Python 3.13+
    how = "math.fma"
else:
    import ctypes, ctypes.util
    libm = ctypes.CDLL(ctypes.util.find_library("m"))
    libm.fma.restype = ctypes.c_double
    libm.fma.argtypes = [ctypes.c_double] * 3
    fused = libm.fma(a, b, c)          # the C library's fma
    how = "libm fma"

print(f"a = b = 1 + 2^-27, c = -(1 + 2^-26)")
print(f"separate (a*b) + c : {separate}")
print(f"fused fma(a, b, c) : {fused}   (via {how})")
print(f"true answer        : {2.0 ** -54}")
assert separate == 0.0
assert fused == 2.0 ** -54

t = (torch.tensor(a, dtype=torch.float64) *
     torch.tensor(b, dtype=torch.float64) +
     torch.tensor(c, dtype=torch.float64)).item()
print(f"torch eager a*b+c  : {t}   (separate ops: also 0.0)")
print("\nthe separate path says the answer is exactly 0 when it is")
print("not; the fused path keeps it. both are legal readings of the")
print("same source line, which is why fusion decisions (a compiler")
print("flag, an inlined function, torch.compile) can change a")
print("program's last bits without anyone touching the math.")
download and run it

The unit itself is worth one look, because the training half of this page keeps returning to it:

one fused multiply-add unit drawn as a flow: two 8-bit inputs enter a multiply, the exact product with every bit kept enters an add, and the result drops into a float32 accumulator with the note 1 rounding, here only; the accumulator loops back into the add; at the right, a faint 3 by 3 lattice of dashed units, a tensor core is a lattice of these units, side by sideone fused multiply-add unit drawn as a flow: two 8-bit inputs enter a multiply, the exact product with every bit kept enters an add, and the result drops into a float32 accumulator with the note 1 rounding, here only; the accumulator loops back into the add; at the right, a faint 3 by 3 lattice of dashed units, a tensor core is a lattice of these units, side by side
Figure 18. one fused multiply-add unit. the product keeps every bit, the running sum lives in float32, and the single rounding happens at the end. a tensor core is a lattice of these.

Where arithmetic goes wrong

Three rules of ordinary arithmetic stop holding once every result is rounded to a point, and all three cause real bugs.

Absorption. At 100,000,000, which is 1e8, float32's step is 8, so adding 1 offers a move smaller than half the gap, and the sum rounds straight back (proof):

one interval of the float32 map framed between 100000000 and 100000008; a thick orange plus 1 arrow reaches an eighth of the way across, short of the dashed halfway tick at plus 4, and a dashed return path brings the sum back to the left point, short of halfway, the sum rounds back; below, times 1,000,000, the same landing every timeone interval of the float32 map framed between 100000000 and 100000008; a thick orange plus 1 arrow reaches an eighth of the way across, short of the dashed halfway tick at plus 4, and a dashed return path brings the sum back to the left point, short of halfway, the sum rounds back; below, times 1,000,000, the same landing every time
Figure 19. 1e8 + 1 = 1e8, exactly. the addend was real; the local step is 8; there is nowhere closer to land.
p4_absorb_cancel.py the proof, ready to read or run
"""Proof: the two classic accidents. Absorption: below half the
local step, an addend vanishes. Cancellation: subtracting two close
numbers deletes their shared leading digits and promotes the noise."""
import torch
t = torch.tensor(1e8, dtype=torch.float32)
print(f"float32 step at 1e8      : {(torch.nextafter(t, t*2) - t).item()}")
print(f"1e8 + 1                  : {(t + 1).item():.1f}   (absorbed)")
print(f"1e8 + 5                  : {(t + 5).item():.1f}   (rounds up: past half the step)")
a = torch.tensor(1.0000001, dtype=torch.float32)
b = torch.tensor(1.0000000, dtype=torch.float32)
d = (a - b).item()
print(f"\n(1.0000001 - 1.0) in float32 = {d:.10e}")
print(f"true answer                  = 1.0000000e-07")
print(f"relative error               = {abs(d - 1e-7)/1e-7:.1%}")
print("seven leading digits matched and left; only the rounding")
print("noise of the inputs remained.")
download and run it

Cancellation. Subtract two nearly equal numbers and their shared leading digits leave together, promoting whatever rounding noise the inputs carried into the leading digits of the answer. The proof above subtracts 1.0000001 from 1.0 in float32 and gets 1.19e-7 where the truth is 1.0e-7: a 19% error from one subtraction of two almost-exact inputs. The strange part: the subtraction itself commits no error at all. When 2 numbers are within a factor of 2 of each other, their difference is always exactly representable (Sterbenz's lemma [4]; proof checks it on this very case). Cancellation never creates error; it exposes the error the inputs already carried.

Order. Because every addition rounds, (a + b) + c and a + (b + c) are different numbers. Sum 50,000 values forward and backward and the answers disagree (proof); a parallel machine that splits the same sum across cores picks yet another order, and another answer. torch's own .sum() adds pairwise in a tree, which is both faster and about 130 times closer to the true sum than a one-by-one loop (measured: 0.24 error against 0.0018), and compensated summation (Kahan's trick of carrying the rounding error in a second variable) closes most of the rest [4].

p5_sum_order.py the proof, ready to read or run
"""Proof: addition order changes the answer. One million float32
values, three orders, three sums; then the repairs: pairwise
(what torch does), Kahan compensation, and fsum as ground truth."""
import math, torch

torch.manual_seed(0)
x = (torch.randn(1_000_000) * 100).float()
truth = math.fsum(x.double().tolist())

fwd = torch.tensor(0.0)
for c in x.split(100_000):          # sequential in chunks, forward
    for v in c: pass
# a plain python loop over 1e6 floats is slow; do exact fp32 fold in torch
def fold(t):
    s = torch.tensor(0.0, dtype=torch.float32)
    for v in t.split(4096):
        for u in v.tolist():
            s = s + torch.tensor(u, dtype=torch.float32)
    return s.item()

seq_fwd = fold(x[:50_000])          # smaller slice keeps runtime sane
seq_rev = fold(x[:50_000].flip(0))
tsum    = x[:50_000].sum().item()   # torch's pairwise-style reduction
truth50 = math.fsum(x[:50_000].double().tolist())
print(f"ground truth (fsum)       : {truth50:.6f}")
print(f"sequential, forward       : {seq_fwd:.6f}   err {seq_fwd-truth50:+.6f}")
print(f"sequential, reversed      : {seq_rev:.6f}   err {seq_rev-truth50:+.6f}")
print(f"torch .sum() (pairwise)   : {tsum:.6f}   err {tsum-truth50:+.6f}")
print(f"forward == reversed       : {seq_fwd == seq_rev}")
download and run it

One more fact turns this whole section from a list of failures into a toolbox: the rounding error is catchable, exactly. The error of an addition is itself a number the format can hold, and 3 operations recover it whole: s = a + b, z = s - a, e = b - z, and now s + e equals the true sum of a and b with no error at all. Run it on this page's opening example and the 3 operations hand back the exact rounding error of 0.1 + 0.2 (proof checks it against exact rational arithmetic). A fused multiply-add does the same for a product in 1 instruction. Kahan's compensated sum is this trick run in a loop, and the double-word arithmetic that stretches precision in software is this trick kept instead of thrown away [4].

p22_exact_error.py the proof, ready to read or run
"""Proof: the rounding error is catchable, exactly. 3 facts, each
checked against exact rational arithmetic (fractions.Fraction):
1. Fast2Sum: for |a| >= |b|, 3 operations recover the rounding
   error of a + b perfectly: s = a+b; z = s-a; e = b-z. Then
   s + e equals the true sum of the stored a and b, exactly.
   Run on the page's opening example, 0.1 + 0.2.
2. Sterbenz: when x and y are within a factor of 2 of each
   other, x - y is computed exactly: cancellation commits no
   error of its own; it only exposes error the inputs carried.
3. 2MultFMA: 1 fused multiply-add recovers a product's whole
   rounding error: e = fma(a, b, -a*b).
"""
from fractions import Fraction as F
import ctypes, ctypes.util

# ---- 1. Fast2Sum on 0.1 + 0.2
a, b = 0.2, 0.1
s = a + b
z = s - a
e = b - z
print(f"s = a + b        = {s!r}")
print(f"e = (b - (s - a)) = {e!r}")
assert F(a) + F(b) == F(s) + F(e)
print("checked: stored 0.2 + stored 0.1 == s + e, EXACTLY.")
print("the 3 float operations caught the rounding error whole.\n")

# ---- 2. Sterbenz: close subtraction is exact
x, y = 1.0000001, 1.0
d = x - y
assert F(x) - F(y) == F(d)
print(f"x - y = {d!r}")
print("checked: equals the exact difference of the stored inputs.")
print("the 19% error of the cancellation section was already in")
print("the inputs; the subtraction added nothing.\n")

# ---- 3. 2MultFMA: a product's error in one instruction
libm = ctypes.CDLL(ctypes.util.find_library("m"))
libm.fma.restype = ctypes.c_double
libm.fma.argtypes = [ctypes.c_double] * 3
u = v = 1.0 + 2.0 ** -27
p = u * v
e2 = libm.fma(u, v, -p)
assert F(u) * F(v) == F(p) + F(e2)
print(f"p = u * v       = {p!r}")
print(f"e = fma(u,v,-p) = {e2!r}")
print("checked: stored u times stored v == p + e, exactly.")
print("\nKahan's compensated sum is fact 1 run in a loop; the")
print("double-word arithmetic that stretches precision in software")
print("is facts 1 and 3 kept instead of thrown away.")
download and run it

Keep the order fact in mind. It returns at the end of this page as the reason two identical training runs on two GPUs never match to the last bit.

The field guide

The formats in this guide carry nearly all of the world's floating-point arithmetic, and you now own every idea needed to read them. Each gets the same treatment: its card, the bit counts and the numbers that follow from them; its map, where its points land; where it came from; where it runs today; and how it fails. Every number on every card is derived from the 2 bit counts alone, with the formulas you used on binary32, and checked against torch's own tables (proof runs the check). The guide covers the 4 wide formats, the ones that stand alone. The 8-, 6- and 4-bit formats enter later, each at the exact place in the training story that needs it, and a closing sheet gathers the whole family on one drawing.

p16_wide_formats.py the proof, ready to read or run
"""Proof: the field guide's numbers, derived by hand from each
format's two counts (E exponent bits, M mantissa bits), then checked
against torch. Nothing on the cards is typed from memory:
  bias        = 2^(E-1) - 1
  ceiling     = (2 - 2^-M) * 2^bias
  floor       = 2^(1 - bias)          (smallest normal)
  ramp floor  = 2^(1 - bias - M)      (smallest subnormal)
  step at 1   = 2^-M                  (the ulp of 1.0; finfo calls it eps)
  cells       = 2^E - 2               (power-of-two intervals)
  points/cell = 2^M
Also prints the torch defaults the float32 section quotes."""
import math
import torch

FORMATS = [
    ("float64", 11, 52, torch.float64, torch.int64),
    ("float32", 8, 23, torch.float32, torch.int32),
    ("float16", 5, 10, torch.float16, torch.int16),
    ("bfloat16", 8, 7, torch.bfloat16, torch.int16),
]

for name, E, M, dt, it in FORMATS:
    bias = 2 ** (E - 1) - 1
    ceiling = (2 - 2.0 ** -M) * 2.0 ** bias
    floor = 2.0 ** (1 - bias)
    ramp_floor = 2.0 ** (1 - bias - M)
    step_at_1 = 2.0 ** -M
    cells = 2 ** E - 2
    digits = (M + 1) * math.log10(2)

    fi = torch.finfo(dt)
    # smallest subnormal: bit pattern 0...01 viewed as this dtype
    sub = torch.tensor([1], dtype=it).view(dt).double().item()

    assert ceiling == fi.max, (name, ceiling, fi.max)
    assert floor == fi.tiny, (name, floor, fi.tiny)
    assert step_at_1 == fi.eps, (name, step_at_1, fi.eps)
    assert ramp_floor == sub, (name, ramp_floor, sub)

    print(f"{name:9s} E={E:2d} M={M:2d}  bias {bias:4d}   "
          f"ceiling {ceiling:.4g}")
    print(f"          floor {floor:.4g}   ramp floor {ramp_floor:.4g}   "
          f"step at 1 = {step_at_1:.4g}")
    print(f"          {cells} cells of {2**M:,} points; "
          f"about {digits:.1f} decimal digits   "
          f"(torch.finfo agrees on all four numbers)")

# absorption never leaves; it only moves. float64's step at 1e16 is 2.
x = torch.tensor(1e16, dtype=torch.float64)
print(f"\nfloat64: (1e16 + 1) - 1e16 = {((x + 1) - x).item():g}"
      f"   (the step at 1e16 is 2; the 1 is below half of it)")

# the defaults the float32 section quotes, read from this install
print(f"\ntorch {torch.__version__} defaults:")
print(f"  default dtype                  {torch.get_default_dtype()}")
print(f"  float32_matmul_precision       "
      f"{torch.get_float32_matmul_precision()!r}")
print(f"  backends.cuda.matmul.allow_tf32  "
      f"{torch.backends.cuda.matmul.allow_tf32}")
print(f"  backends.cudnn.allow_tf32        "
      f"{torch.backends.cudnn.allow_tf32}")
print(f"  autocast dtype on cpu          "
      f"{torch.get_autocast_dtype('cpu')}")
download and run it

float64

Double binary32's byte count and you get the format the 1985 standard called double precision:

the bit fields of float64 drawn to scale: 1 sign bit, 11 exponent bits in orange, 52 mantissa bits, 8 bytes per number; a card lists bias 1023, ceiling 1.8e308, normal floor 2.23e-308, ramp floor 4.94e-324, step at 1.0 of 2.22e-16, and about 16 decimal digits; brackets at the right tie bias, ceiling and floor to the exponent field, ramp floor to both fields, step and digits to the mantissa fieldthe bit fields of float64 drawn to scale: 1 sign bit, 11 exponent bits in orange, 52 mantissa bits, 8 bytes per number; a card lists bias 1023, ceiling 1.8e308, normal floor 2.23e-308, ramp floor 4.94e-324, step at 1.0 of 2.22e-16, and about 16 decimal digits; brackets at the right tie bias, ceiling and floor to the exponent field, ramp floor to both fields, step and digits to the mantissa field
Figure 20. float64's card. 11 bits of interval, 52 of position; every row follows from those two counts (proof below the guide's intro).

The bias is 1023, so the intervals run from 2.2e-308 up to 1.8e308: 2,046 of them, each holding 4,503,599,627,370,496 points. The ulp at 1.0 is 2.22e-16, which is about 16 decimal digits of position. A map that wide can only be drawn with breaks:

the float64 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 2,046 intervals each holding the same 4,503,599,627,370,496 points, running from 2.23e-308 to 1.8e308 with inf past the ceiling and a step chip hanging from a top interval; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -52 equals 2.22e-16, the ulp at 1.0the float64 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 2,046 intervals each holding the same 4,503,599,627,370,496 points, running from 2.23e-308 to 1.8e308 with inf past the ceiling and a step chip hanging from a top interval; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -52 equals 2.22e-16, the ulp at 1.0
Figure 21. the float64 map: the E4M3 picture with 2,046 intervals instead of 15, and 2^52 points per interval instead of 2^3.

Where it came from: IEEE 754 began as the arithmetic William Kahan designed with Intel for the 8087, the floating-point coprocessor sold beside the 8086; the standards committee turned that chip's arithmetic into everyone's law [3][18]. float64 was its wide grade, sized so ordinary science could chain millions of operations and still trust the leading digits.

Where it runs today: everywhere you did not choose a dtype. A Python float is a float64. NumPy builds float64 arrays unless told otherwise. The digits of 0.1 printed in the rounding section were float64 digits.

Its proof: p16 derives the whole card from E and M and checks every row against torch.finfo. One extra line in it repeats the absorption experiment at this format's scale: in float64, (1e16 + 1) - 1e16 = 0. The ulp at 1e16 is 2, and the added 1 fell under half of it. 16 digits push the cliff out of sight; no digit count removes it.

How it fails in a training loop: float64 rarely gives training a wrong answer. Its problem is cost. 8 bytes per number is 2 times float32 and 4 times bfloat16, spent on digits training cannot use: the sections ahead show training running where the step at 1.0 is 0.0078, so digits 4 through 16 add nothing. On GPUs there is a second cost: most chips carry few float64 units, and NVIDIA's own throughput tables list float64 operations far below float32 on most of its hardware [19]. torch's default is float32, and float64 appears only when you ask (proof prints the default).

float32, and the format hiding inside it

binary32 you have already decoded by hand; the card collects its numbers:

the bit fields of float32 drawn to scale: 1 sign bit, 8 exponent bits in orange, 23 mantissa bits, 4 bytes per number; a card lists bias 127, ceiling 3.4e38, normal floor 1.18e-38, ramp floor 1.4e-45, step at 1.0 of 1.19e-7 highlighted, and about 7 decimal digits; brackets tie each row to the field that sets itthe bit fields of float32 drawn to scale: 1 sign bit, 8 exponent bits in orange, 23 mantissa bits, 4 bytes per number; a card lists bias 127, ceiling 3.4e38, normal floor 1.18e-38, ramp floor 1.4e-45, step at 1.0 of 1.19e-7 highlighted, and about 7 decimal digits; brackets tie each row to the field that sets it
Figure 22. float32's card. the orange row, 1.19e-7 at 1.0, is the machine epsilon from the rounding section.

254 intervals from 1.2e-38 to 3.4e38, 8,388,608 points in each, about 7 digits:

the float32 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 254 intervals each holding the same 8,388,608 points, running from 1.18e-38 to 3.4e38 with inf past the ceiling and a step chip hanging from a top interval; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -23 equals 1.19e-7, the ulp at 1.0the float32 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 254 intervals each holding the same 8,388,608 points, running from 1.18e-38 to 3.4e38 with inf past the ceiling and a step chip hanging from a top interval; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -23 equals 1.19e-7, the ulp at 1.0
Figure 23. the float32 map. the absorption step of 8 at 1e8 is one of these intervals, far to the right of 1.

Where it came from: the same 1985 standard's single precision [3]. Where it runs today: it is the default dtype of torch; torch.tensor(1.0) is a float32 unless you say otherwise (proof prints the default from this install). Even when matmuls run narrow, the master weights of mixed-precision training stay float32 and the tensor cores accumulate in float32, so the training half of this page keeps this format at its center.

Now the format hiding inside it: TensorFloat-32. Since the A100, NVIDIA's tensor cores can take an ordinary float32 matmul, round each factor to 10 mantissa bits, multiply, and accumulate in full float32 [20]. The tensors going in and out are ordinary float32; only the multiply is narrow, reading 19 of the 32 bits (1 + 8 + 10). TF32 is that mode, not a storage type you can give a tensor. NVIDIA measured large speedups at matching accuracy on deep-learning workloads [20]; for code that needs all seven digits it is a silent cut, and that is why torch ships with matmul TF32 off since version 1.12 [21]. On this install, torch.backends.cuda.matmul.allow_tf32 is False, the cudnn convolution flag is True, and torch.set_float32_matmul_precision("high") is the one-line opt-in (proof prints all three). None of this can execute on the Apple machine this page is measured on; the flags are read here, the behavior is cited [20][21].

the bit fields of TF32 drawn to scale: 1 sign bit, 8 exponent bits in orange, 10 mantissa bits, noted as 19 bits inside the tensor core; a card lists bias 127, ceiling 3.4e38, normal floor 1.18e-38, ramp floor 1.15e-41, step at 1.0 of 0.000977 highlighted, and about 3 decimal digits; brackets tie each row to the field that sets itthe bit fields of TF32 drawn to scale: 1 sign bit, 8 exponent bits in orange, 10 mantissa bits, noted as 19 bits inside the tensor core; a card lists bias 127, ceiling 3.4e38, normal floor 1.18e-38, ramp floor 1.15e-41, step at 1.0 of 0.000977 highlighted, and about 3 decimal digits; brackets tie each row to the field that sets it
Figure 24. TF32's card: float32's reach, float16's digits, 19 bits, no storage form. it exists only in the middle of a tensor-core multiply.

How it fails: you have watched it fail all page. The step of 8 at 1e8 is float32's. The 7 digits that turned a subtraction of near-equal inputs into a 19% error are float32's. In training, its failure is price: 4 bytes per number on the memory bus that a later section shows is the bottleneck.

float16

Cut 32 bits in half and something must go. There are two ways to choose, and the choice split the 16-bit world in two:

a fork: float32's 32-bit bar on top, 2 arrows labeled keep the digits and keep the reach diverging to float16, 5 exponent and 10 mantissa bits, and bfloat16, float32's top 16 bits; dashed guides tie bfloat16's exponent field to float32's; below, one shared log axis from 1e-38 to 1e38 carries each choice's reach: float16 a short bar from 6.1e-5 to 65504 with 3 digits, bfloat16 spanning float32's whole reach with 2 digitsa fork: float32's 32-bit bar on top, 2 arrows labeled keep the digits and keep the reach diverging to float16, 5 exponent and 10 mantissa bits, and bfloat16, float32's top 16 bits; dashed guides tie bfloat16's exponent field to float32's; below, one shared log axis from 1e-38 to 1e38 carries each choice's reach: float16 a short bar from 6.1e-5 to 65504 with 3 digits, bfloat16 spanning float32's whole reach with 2 digits
Figure 25. the fork. float16 kept digits and lost reach; bfloat16 kept float32's whole exponent and pays in digits. training mostly chose reach.

float16 is the left fork: keep digits, pay with reach.

the bit fields of float16 drawn to scale: 1 sign bit, 5 exponent bits in orange, 10 mantissa bits, 2 bytes per number; a card lists bias 15, ceiling 65504 highlighted, normal floor 6.1e-5, ramp floor 5.96e-8, step at 1.0 of 0.000977, and about 3 decimal digits; brackets tie each row to the field that sets itthe bit fields of float16 drawn to scale: 1 sign bit, 5 exponent bits in orange, 10 mantissa bits, 2 bytes per number; a card lists bias 15, ceiling 65504 highlighted, normal floor 6.1e-5, ramp floor 5.96e-8, step at 1.0 of 0.000977, and about 3 decimal digits; brackets tie each row to the field that sets it
Figure 26. float16's card. the orange row is the ceiling, 65504: the number to remember about this format.

5 exponent bits give bias 15 and only 30 intervals, from 6.1e-5 to 65504. 10 mantissa bits give 1,024 points per interval and about 3 digits. 30 intervals fit in one drawing, so this is the one wide format whose map you can see whole:

the float16 map drawn whole: a dashed ramp interval and 30 power of two intervals on one unbroken line, each holding the same 1,024 points, from 6.1e-5 to 65504; a red arrow shoots past the ceiling: 60000 times 1.2 lands at inf; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -10 equals 0.000977, the ulp at 1.0the float16 map drawn whole: a dashed ramp interval and 30 power of two intervals on one unbroken line, each holding the same 1,024 points, from 6.1e-5 to 65504; a red arrow shoots past the ceiling: 60000 times 1.2 lands at inf; below, the interval from 1 to 2 magnified showing its first 16 steps, with a bracket at the first step: 2 to the -10 equals 0.000977, the ulp at 1.0
Figure 27. all of float16, no breaks needed: 30 intervals end to end. compare the reach with the maps above it.

Where it came from: film and games, not a numerics committee. NVIDIA and Microsoft made half a type in the Cg shading language in 2002, and Industrial Light & Magic built its OpenEXR film format on the same 1+5+10 layout, in production from 2000 and released as open source in 2003 [22]. IEEE 754 adopted the layout as binary16 in its 2008 revision [3]. The film industry shipped the format years before the standard named it.

Where it runs today: graphics APIs and image pipelines still, inference engines, and mixed-precision training on hardware from before bfloat16 spread. In torch it is the half in .half().

Its proof and its failure are the same three numbers. The ceiling: 60000 times 1.2 is inf in float16 (proof). The digits: 1.001 stores as 1.000977. The floor: normal numbers end at 6.1e-5, and the small half of a gradient histogram lives below that, which is why the loss-scaling section exists. When float16 dies in a run, it dies at one of these three numbers.

p7_sixteen_bits.py the proof, ready to read or run
"""Proof: the 16-bit fork, from finfo, nothing typed from memory.
float16 spent its bits on precision and dies at 65504; bfloat16 kept
float32's exponent and survives, with two decimal digits left."""
import torch

for dt in (torch.float32, torch.float16, torch.bfloat16):
    fi = torch.finfo(dt)
    print(f"{str(dt):16s} max {fi.max:>12.4g}   tiny {fi.tiny:.3g}   "
          f"eps {fi.eps:.3g}")
x = torch.tensor(60000.0)
print(f"\n60000 * 1.2 in float16 : {(x.half() * 1.2).item()}")
print(f"60000 * 1.2 in bfloat16: {(x.bfloat16() * torch.tensor(1.2, dtype=torch.bfloat16)).item()}")
print(f"1.001 in float16       : {torch.tensor(1.001, dtype=torch.float16).item():.6f}")
print(f"1.001 in bfloat16      : {torch.tensor(1.001, dtype=torch.bfloat16).item():.6f}")
download and run it

bfloat16

The right fork: keep reach, pay with digits. Google's brain float keeps all 8 of float32's exponent bits and 7 of mantissa:

the bit fields of bfloat16 drawn to scale: 1 sign bit, 8 exponent bits in orange, 7 mantissa bits, 2 bytes per number; a card lists bias 127, ceiling 3.39e38, normal floor 1.18e-38, ramp floor 9.18e-41, step at 1.0 of 0.00781 highlighted, and about 2 decimal digits; brackets tie each row to the field that sets itthe bit fields of bfloat16 drawn to scale: 1 sign bit, 8 exponent bits in orange, 7 mantissa bits, 2 bytes per number; a card lists bias 127, ceiling 3.39e38, normal floor 1.18e-38, ramp floor 9.18e-41, step at 1.0 of 0.00781 highlighted, and about 2 decimal digits; brackets tie each row to the field that sets it
Figure 28. bfloat16's card: float32's bias and reach, 7 bits of position. the orange row is the step that rounds weight updates away.

The reach is float32's (the ceiling prints 3.39e38 rather than 3.4e38 only because the coarser last step lands the top point lower), the intervals are the same 254 as float32's, and each holds 128 points: about 2 decimal digits.

the bfloat16 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 254 intervals each holding the same 128 points, from 1.18e-38 to 3.39e38 with inf past the ceiling; below, the interval from 1 to 2 magnified with its step bracket, 2 to the -7 equals 0.00781, and under it a solid grey band: float32's 8,388,608 points in the same interval fuse into a bandthe bfloat16 map: a dashed ramp interval, then power of two intervals drawn in 3 groups separated by break marks, 254 intervals each holding the same 128 points, from 1.18e-38 to 3.39e38 with inf past the ceiling; below, the interval from 1 to 2 magnified with its step bracket, 2 to the -7 equals 0.00781, and under it a solid grey band: float32's 8,388,608 points in the same interval fuse into a band
Figure 29. the bfloat16 map next to float32's: the same intervals, 65,536 times fewer points in each. that one comparison is the entire format.

Where it came from: Google built it into the TPU's matrix units, which multiply in bfloat16 and accumulate in float32, and chose the layout so that range would never be the problem and conversion from float32 would be nearly free [23]. The name is Brain Floating Point, after the Google Brain team.

Nearly free is provable, and on this machine it is proven at the bit level: bfloat16 is float32's top half. Take any of the 65,536 possible bfloat16 bit patterns, cast it to float32, and the result is the same 16 bits with 16 zeros appended; proof checks all 65,536, bit for bit. The downward cast is the same move with rounding: keep the top 16 bits, round by the low 16. The proof checks torch's cast against that hand rule on 1,000,010 values (a million random ones plus the edge cases), and they agree on every one. Here is 1.7014 making the trip:

float32:  0 01111111 1011001 1100011101111010
bfloat16: 0 01111111 1011010   (the top half, rounded up by the rest)
stored:   1.703125
p17_bf16_truncation.py the proof, ready to read or run
"""Proof: bfloat16 is the top 16 bits of float32, at the bit level.
Two directions, both exhaustive where exhaustion is possible:
  up:   every one of the 65,536 bfloat16 bit patterns, cast to
        float32, must produce exactly (pattern << 16): the same 16
        bits on top, sixteen zeros appended below.
  down: casting float32 to bfloat16 must equal keeping the top 16
        bits and rounding by the low 16 (nearest, ties to even),
        checked on a million random values plus the edge cases.
"""
import torch

# ---- up: bf16 -> fp32 is "append 16 zero bits", all 65,536 patterns
pats = torch.arange(65536, dtype=torch.int32).to(torch.int16)
as_bf16 = pats.view(torch.bfloat16)
up_bits = as_bf16.float().view(torch.int32)
want = pats.to(torch.int32) << 16
nan_mask = torch.isnan(as_bf16)
ok = (up_bits == want) | nan_mask
print(f"up-cast bit check: {ok.sum().item()} / 65536 patterns match "
      f"(pattern << 16)")
print(f"  of the 65536, {nan_mask.sum().item()} are NaN codes, "
      f"checked separately:")
nan_ok = torch.isnan(as_bf16.float()[nan_mask]).all().item()
print(f"  every NaN pattern up-casts to a float32 NaN: {nan_ok}")
exact = (up_bits == want).sum().item()
print(f"  bit-exact including NaN payloads: {exact} / 65536")

# ---- down: fp32 -> bf16 is "keep the top 16 bits, round by the rest"
def truncate_rne(f32):
    """The hand rule: add the rounding bias, then keep the top half.
    bias = 0x7FFF + (bit 16), the classic nearest-even truncation."""
    u = f32.view(torch.int32)
    bias = 0x7FFF + ((u >> 16) & 1)
    out = ((u + bias) >> 16).to(torch.int16).view(torch.bfloat16)
    return torch.where(torch.isnan(f32), torch.nan, out.float())

torch.manual_seed(0)
x = torch.randn(1_000_000) * torch.logspace(-38, 38, 1_000_000)
edges = torch.tensor([0.0, -0.0, 1.0, 1.0078125, 65504.0, 3.4e38,
                      1e-40, float("inf"), float("-inf"), float("nan")])
x = torch.cat([x, edges])
ours = truncate_rne(x)
torchs = x.bfloat16().float()
agree = (ours == torchs) | (torch.isnan(ours) & torch.isnan(torchs))
print(f"\ndown-cast rule check: {agree.sum().item()} / {len(x)} "
      f"values agree with torch's cast")

# ---- the picture, on one number
v = torch.tensor(1.7014, dtype=torch.float32)
u32 = v.view(torch.int32).item() & 0xFFFFFFFF
b16 = v.bfloat16().view(torch.int16).item() & 0xFFFF
print(f"\n1.7014 in float32 bits : {u32:032b}")
print(f"1.7014 in bfloat16 bits: {b16:016b} (the top half, rounded)")
print(f"bfloat16 value stored  : {v.bfloat16().item():.6f}")
download and run it

Where it runs today: TPUs since their second generation [23], NVIDIA GPUs since the A100 [20], and this machine: torch's CPU autocast picks bfloat16 by default (proof prints it). In large-model training it is the usual compute half of the mixed-precision loop, with FP8 taking over the matmuls on the newest chips (the FP8 section returns to this).

How it fails: 2 digits. 1.001 stores as exactly 1.0 (proof). The step at 1.0 is 0.0078, so a healthy update of 0.001 sits under half a step and rounds away: the disaster that opens the training sections ahead, and the reason master weights exist. bfloat16 did not make training precise. It made training's failures the quiet, repairable kind, and the machinery that repairs them is most of the rest of this page.

The guide pauses here, because at 8 bits and below no format stands alone. Each narrow format arrives later on this page, at the moment the training story needs it, and a closing sheet gathers the whole family in one drawing. First, a payoff: with the wide formats on the table, the small constants in every model's source code finally have their explanation.

The little epsilons, explained

Open any model's source code and small constants appear: an eps=1e-5 in the layer norm, an eps=1e-8 in Adam, a logits - logits.max() in every attention implementation. None of them is superstition; each one is a patch over a place where a formula's intermediate value leaves the map.

The largest number float32 can hold is about 3.4e38, and exp reaches it at 88.73 (measured below). Softmax, the function that turns a model's raw output scores, its logits, into probabilities, applies exp to every logit. Logits above 88.73 exist in every large model, so a naive softmax returns inf, then inf divided by inf, and your loss prints nan. The repair costs nothing: softmax only sees differences, so subtract the maximum first and the largest input becomes 0 (proof):

the exp curve climbing toward float32's dashed ceiling at 3.4e38 and clipping at a red vertical line at z equals 88.7, exp of 88.8 equals inf; a dashed red line marks float16's cliff at z equals 11.09; 3 red ticks near 120 are your logits, past the cliff; an orange arrow slides them to landed ticks at 0 and below; subtract the max, the answer unchanged, softmax only sees differencesthe exp curve climbing toward float32's dashed ceiling at 3.4e38 and clipping at a red vertical line at z equals 88.7, exp of 88.8 equals inf; a dashed red line marks float16's cliff at z equals 11.09; 3 red ticks near 120 are your logits, past the cliff; an orange arrow slides them to landed ticks at 0 and below; subtract the max, the answer unchanged, softmax only sees differences
Figure 30. the cliff is at 88.7 in float32 and at 11.09 in float16. every stable softmax you have ever used is the same one-line slide.
p6_stability.py the proof, ready to read or run
"""Proof: the stability patterns. exp overflows at 88.73 in float32
and 11.09 in float16, so a naive softmax dies on logits your model
produces every day; subtracting the max moves nothing and fixes it
(softmax is shift-invariant)."""
import torch

print(f"float32 exp cliff: exp(88.7)={torch.tensor(88.7).exp().item():.3e}, "
      f"exp(88.8)={torch.tensor(88.8).exp().item()}")
h = torch.tensor([11.0, 11.1], dtype=torch.float16)
print(f"float16 exp cliff: exp(11.0)={h.exp()[0].item()}, exp(11.1)={h.exp()[1].item()}")

logits = torch.tensor([120.0, 119.0, 115.0])
naive = logits.exp() / logits.exp().sum()
stable = (logits - logits.max()).exp() / (logits - logits.max()).exp().sum()
print(f"naive  softmax([120,119,115]) = {naive.tolist()}")
print(f"stable softmax([120,119,115]) = {[round(v,4) for v in stable.tolist()]}")
print(f"torch.softmax agrees with stable: "
      f"{torch.allclose(torch.softmax(logits, 0), stable)}")

# logsumexp, same illness, same cure
print(f"naive  log(sum(exp)) = {logits.exp().sum().log().item()}")
print(f"stable logsumexp     = {torch.logsumexp(logits, 0).item():.4f}")
download and run it

The same pattern explains the rest of the family. logsumexp slides by the max for the same reason. log1p(x) and expm1(x) exist because near zero, 1 + x absorbs x (the absorption figure again, at 1.0 instead of 1e8). The layer-norm epsilon keeps a near-zero variance from turning rsqrt into inf; Adam's epsilon does the same for its denominator; and an attention mask uses a large negative number so that after the slide it underflows to a clean zero instead of poisoning the row with nan. One picture, many patches: keep the intermediate values where the points are.

Why smaller floats

The guide paused at 16 bits. Here is why the family keeps going down anyway.

Training a network is mostly moving numbers, not multiplying them: weights travel from memory to the arithmetic and gradients travel back, billions per step, and the wire is slower than the multiplier. Halve the bits per number and the same wire carries twice the numbers, the same memory holds twice the model, and the tensor cores, built to process the narrower type, double their arithmetic too. Here is the wire, drawn to scale:

the chips and the pipes between them, widths drawn to one bandwidth scale: a CPU and system RAM box joined to the GPU by a thin PCIe 5.0 x16 line, 64 GB per second each way, where the weights cross once at load; the HBM3 box holding the weights and the KV cache, joined to the GPU die and its tensor cores by a wide orange pipe, 3,350 GB per second, 52 times the PCIe pipe; every training step and every generated token pulls the weights through the wide pipethe chips and the pipes between them, widths drawn to one bandwidth scale: a CPU and system RAM box joined to the GPU by a thin PCIe 5.0 x16 line, 64 GB per second each way, where the weights cross once at load; the HBM3 box holding the weights and the KV cache, joined to the GPU die and its tensor cores by a wide orange pipe, 3,350 GB per second, 52 times the PCIe pipe; every training step and every generated token pulls the weights through the wide pipe
Figure 31. the pipes, to one scale (an H100's numbers [30]). the thin line is how the weights arrive; the wide pipe is what every step and every token pays. the byte ladders ahead shrink what crosses it.

The only question is whether the surviving points still cover the numbers the network produces with small enough error. Networks tolerate rounding noise unusually well: they are trained on noisy batches and judged over many outputs, so the answer stays yes far below 32 bits if the format is chosen carefully. The fork figure already showed the first halving's two options, and training mostly picked bfloat16: reach first. The cost is a step of 0.0078 at 1.0, and the next section shows what that step does to learning.

The update that vanished

At 16 bits the map's steps are wide enough to stop training itself. A bfloat16 weight sitting at 1.0 has neighbors 0.0078 away. A healthy update of 0.001 is an arrow one eighth of that step:

a zoomed bfloat16 interval between 1.0000 and 1.0078; an orange update arrow of 0.001 reaches an eighth of the way, short of the dashed halfway mark, and a dashed return path brings it back: x 1,000 updates, still exactly 1.0; below, stochastic rounding splits the same update: a tall grey bar, 87 percent stays, and a thin dashed arrow jumping the whole step, 13 percent to 1.0078; 0.87 times 0 plus 0.13 times 0.0078 equals 0.001, on average the weight still movesa zoomed bfloat16 interval between 1.0000 and 1.0078; an orange update arrow of 0.001 reaches an eighth of the way, short of the dashed halfway mark, and a dashed return path brings it back: x 1,000 updates, still exactly 1.0; below, stochastic rounding splits the same update: a tall grey bar, 87 percent stays, and a thin dashed arrow jumping the whole step, 13 percent to 1.0078; 0.87 times 0 plus 0.13 times 0.0078 equals 0.001, on average the weight still moves
Figure 32. the update that vanished, and the repair. deterministic rounding silently stops training for that weight; stochastic rounding keeps it moving on average.

The proof runs it: 1,000 consecutive updates of 0.001 applied to a bfloat16 weight leave it at exactly 1.0 (proof). This is why every mixed-precision recipe since 2017 keeps a float32 master copy of the weights: the forward and backward passes run narrow, but the optimizer adds the update into a copy whose local step is 65536 times finer, where 0.001 lands easily [5]. The other repair is stochastic rounding, which we will need again at 4 bits: round up with probability proportional to how far you got. In the same proof, the stochastically rounded weight reaches 2.0 alongside the master copy.

p8_lost_update.py the proof, ready to read or run
"""Proof: the update that vanished. In bf16, a weight near 1.0 has a
local step of about 0.0078; an update smaller than half that step
rounds away, and training silently stops for that weight. The fp32
master copy is the fix; stochastic rounding is the other fix."""
import torch

w = torch.tensor(1.0, dtype=torch.bfloat16)
u = 1e-3                                   # lr * grad, a normal size
print(f"bf16 next after 1.0 : {torch.nextafter(torch.tensor(1.0, dtype=torch.bfloat16), torch.tensor(2.0, dtype=torch.bfloat16)).item():.6f}")
print(f"w + {u} in bf16     : {(w + torch.tensor(u, dtype=torch.bfloat16)).item():.6f}  (unchanged: {(w + torch.tensor(u, dtype=torch.bfloat16)) == w})")

# 1000 such updates, three ways
steps = 1000
wb = torch.tensor(1.0, dtype=torch.bfloat16)
for _ in range(steps):
    wb = wb + torch.tensor(u, dtype=torch.bfloat16)
wm = torch.tensor(1.0, dtype=torch.float32)     # master copy
for _ in range(steps):
    wm = wm + u
g = torch.Generator().manual_seed(0)            # stochastic rounding, emulated
ws = torch.tensor(1.0, dtype=torch.float32)
for _ in range(steps):
    hi = torch.nextafter(ws.to(torch.bfloat16), torch.tensor(2.0, dtype=torch.bfloat16)).float()
    lo = ws.to(torch.bfloat16).float()
    x = ws + u
    p = ((x - lo) / (hi - lo)).clamp(0, 1)
    ws = hi if torch.rand((), generator=g) < p else lo
print(f"after {steps} updates of {u}:")
print(f"  bf16 accumulate    : {wb.item():.4f}   (true answer 2.0)")
print(f"  fp32 master        : {wm.item():.4f}")
print(f"  bf16 + stochastic  : {ws.item():.4f}")
download and run it

So the loop that trains every model you use runs like this:

the mixed-precision training loop as 10 stations in a cycle: master weights in float32, cast down to a narrow copy drawn as a 32-bit bar funneling into a 16-bit bar, matmuls with narrow inputs and a float32 accumulator, narrow activations, loss in float32, multiply the loss by S for float16, backward pass with narrow gradients, divide by S with a red diamond asking inf anywhere, yes: skip the step, the Adam moments m and v as two 32-bit chips, and the update landing in float32 before the loop closes; each station carries a bar of its numbers' bits, 1 px per bitthe mixed-precision training loop as 10 stations in a cycle: master weights in float32, cast down to a narrow copy drawn as a 32-bit bar funneling into a 16-bit bar, matmuls with narrow inputs and a float32 accumulator, narrow activations, loss in float32, multiply the loss by S for float16, backward pass with narrow gradients, divide by S with a red diamond asking inf anywhere, yes: skip the step, the Adam moments m and v as two 32-bit chips, and the update landing in float32 before the loop closes; each station carries a bar of its numbers' bits, 1 px per bit
Figure 33. one training step as it actually runs, all 10 stations. narrow bits where the volume is; float32 where errors accumulate; the S dial and the red inf check guard the float16 path.

The products accumulate in float32 inside the tensor cores: that is the fused multiply-add from the rounding section doing its job. Precision goes exactly where the numbers need it and nowhere else. Stations 6 and 8, the S dial and the inf check, are the subject of the next section.

Loss scaling

One group of numbers still fails inside this loop. Gradients are the smallest numbers in training, and float16's floor is high: its subnormals end near 6e-8 and its normal range starts at 6e-5. The left tail of the gradient distribution simply falls below every point of the format:

the measured gradient histogram per decade drawn twice over one log axis: dashed grey as it is, its small tail sitting on the float16 subnormal ramp, and solid orange after multiplying by 1024, 10 doublings right, fully inside float16's normal zone; the axis carries 3 zones: 0 in float16 below 6e-8, the coarse subnormal ramp to 6e-5, and the normal points beyond; bar heights are percent of the measured gradients per decade on a square-root scalethe measured gradient histogram per decade drawn twice over one log axis: dashed grey as it is, its small tail sitting on the float16 subnormal ramp, and solid orange after multiplying by 1024, 10 doublings right, fully inside float16's normal zone; the axis carries 3 zones: 0 in float16 below 6e-8, the coarse subnormal ramp to 6e-5, and the normal points beyond; bar heights are percent of the measured gradients per decade on a square-root scale
Figure 34. the histogram meets the map. multiplying the loss by one number slides every gradient into the band; dividing afterward puts them back.

The rescue is one multiplication. Scale the loss by S before the backward pass and, because differentiation is linear, every gradient arrives multiplied by S; unscale after casting and nothing has changed except which gradients survived. In practice S adapts: overflow detected, halve it and skip the step; a run of clean steps, double it. That is the whole of GradScaler (proof measures the cast; on our toy net only 0.1% of gradients die, and the fraction grows with depth and training time, which is why the recipe exists [5]). bfloat16 mostly retired this machinery: it keeps float32's reach, so its normal floor sits at 1.2e-38 against float16's 6.1e-5, about 34 powers of 10 lower, and the scaler became optional.

p9_loss_scaling.py the proof, ready to read or run
"""Proof: loss scaling, measured. Take real float32 gradients from a
small net, cast them to float16 as mixed precision must, and count
the ones that flush to zero; then scale by 1024 first, cast, unscale.
The cast is where gradients die; the scale is the rescue."""
import torch, torch.nn as nn
torch.manual_seed(0)
net = nn.Sequential(nn.Linear(64, 256), nn.ReLU(),
                    nn.Linear(256, 256), nn.ReLU(), nn.Linear(256, 1))
X = torch.randn(2048, 64); y = X[:, :3].prod(1, keepdim=True)
loss = ((net(X) - y) ** 2).mean()
loss.backward()
g = torch.cat([p.grad.flatten() for p in net.parameters()])
nz = g[g != 0]
plain = (nz.half() == 0).float().mean()
scaled = ((nz * 1024).half().float() / 1024 == 0).float().mean()
print(f"nonzero float32 gradients            : {len(nz)}")
print(f"flushed to zero by the float16 cast  : {plain:.1%}")
print(f"same cast after scaling by 1024      : {scaled:.1%}")
print(f"smallest surviving gradient          : {nz.abs().min().item():.2e}")
print(f"float16's smallest subnormal         : {torch.finfo(torch.float16).smallest_normal * 2**-10:.2e}")
download and run it

FP8

Halve again and there are not enough bits left for one format to serve both directions of training. So at 8 bits the fork appears one more time, and this time both forks ship, as a pair [6]: E4M3 keeps digits, E5M2 keeps reach.

the bit fields of FP8 E4M3 drawn to scale: 1 sign bit, 4 exponent bits in orange, 3 mantissa bits, 1 byte per number; a card lists bias 7, ceiling 448, normal floor 0.0156, ramp floor 0.00195, step at 1.0 of 0.125 highlighted, and about 1 decimal digit; brackets tie each row to the field that sets itthe bit fields of FP8 E4M3 drawn to scale: 1 sign bit, 4 exponent bits in orange, 3 mantissa bits, 1 byte per number; a card lists bias 7, ceiling 448, normal floor 0.0156, ramp floor 0.00195, step at 1.0 of 0.125 highlighted, and about 1 decimal digit; brackets tie each row to the field that sets it
Figure 35. E4M3's card. the map section already drew all 256 of its patterns; this is the same format as a card.

E4M3 you have known since the map section: its 15 intervals are this page's teaching map, and its 256 patterns are decoded in proof. The card records its one non-IEEE choice: no infinity. Those codes were used for one more doubling of range, overflow stops at 448, and a single pattern means NaN.

the bit fields of FP8 E5M2 drawn to scale: 1 sign bit, 5 exponent bits in orange, 2 mantissa bits, 1 byte per number; a card lists bias 15, ceiling 57344 highlighted, normal floor 6.1e-5, ramp floor 1.53e-5, step at 1.0 of 0.25, and about 1 decimal digit; brackets tie each row to the field that sets itthe bit fields of FP8 E5M2 drawn to scale: 1 sign bit, 5 exponent bits in orange, 2 mantissa bits, 1 byte per number; a card lists bias 15, ceiling 57344 highlighted, normal floor 6.1e-5, ramp floor 1.53e-5, step at 1.0 of 0.25, and about 1 decimal digit; brackets tie each row to the field that sets it
Figure 36. E5M2's card: float16's 5 exponent bits kept, 8 of its 10 mantissa bits cut.

E5M2 is float16 with the bottom byte cut, and that is a bit-level fact, provable the same way bfloat16 was: all 256 E5M2 patterns cast to float16 as the same 8 bits with 8 zeros appended (proof checks every one).

the E5M2 map drawn whole: a dashed ramp interval and 30 power of two intervals on one unbroken line, each holding the same 4 points, from 6.1e-5 to 57344 with inf past the ceiling; below, the interval from 1 to 2 showing all 4 of its points with a step bracket, 2 to the -2 equals 0.25, and under it a dense tick row: float16 in the same interval has 1,024 pointsthe E5M2 map drawn whole: a dashed ramp interval and 30 power of two intervals on one unbroken line, each holding the same 4 points, from 6.1e-5 to 57344 with inf past the ceiling; below, the interval from 1 to 2 showing all 4 of its points with a step bracket, 2 to the -2 equals 0.25, and under it a dense tick row: float16 in the same interval has 1,024 points
Figure 37. the E5M2 map: float16's 30 intervals with 4 points in each. reach kept, digits nearly gone.
p20_e5m2_top_byte.py the proof, ready to read or run
"""Proof: FP8 E5M2 is float16's top byte, at the bit level. The
same fact as bfloat16 being float32's top half, one floor down:
E5M2 keeps float16's 5 exponent bits and the top 2 of its 10
mantissa bits. Cast any of the 256 E5M2 bit patterns to float16
and the result is the same 8 bits with 8 zeros appended.
"""
import torch

pats = torch.arange(256, dtype=torch.uint8)
as_e5m2 = pats.view(torch.float8_e5m2)
up_bits = as_e5m2.half().view(torch.int16) & 0xFFFF
want = pats.to(torch.int16) << 8
nan_mask = torch.isnan(as_e5m2)
ok = (up_bits == want) | nan_mask
print(f"up-cast bit check: {ok.sum().item()} / 256 patterns match "
      f"(pattern << 8)")
print(f"  {nan_mask.sum().item()} NaN codes, all up-cast to "
      f"float16 NaN: {torch.isnan(as_e5m2.half()[nan_mask]).all().item()}")
exact = (up_bits == want).sum().item()
print(f"  bit-exact including NaN payloads: {exact} / 256")

v = torch.tensor(1.7014, dtype=torch.float16)
b8 = v.to(torch.float8_e5m2).view(torch.uint8).item()
u16 = v.view(torch.int16).item() & 0xFFFF
print(f"\n1.7014 in float16 bits: {u16:016b}")
print(f"1.7014 in E5M2 bits   : {b8:08b} (the top byte, rounded)")
print(f"E5M2 value stored     : "
      f"{v.to(torch.float8_e5m2).float().item():.4f}")
download and run it

NVIDIA, Arm and Intel proposed the pair for deep learning in 2022 [6], and hardware arrived with the Hopper chips: E4M3 carries the forward pass, E5M2 carries the gradients, whose tails need reach more than digits. Each tensor also carries a float32 scale, chosen from the tensor's recent maximum so that its histogram slides into the band: exactly the loss-scaling picture, applied per tensor. Recipes differ only in whether the maximum is tracked from history (delayed scaling) or measured on the spot:

5 bars show a tensor's largest value step by step from t-4 to t under a bracket labeled the amax history window; a dashed level line touches the tallest bar, the max, and an arrow leads from it to a box reading delayed: reuse the window's max, free; a second orange arrow leads from bar t itself to a box reading current: measure step t itself, 1 extra pass5 bars show a tensor's largest value step by step from t-4 to t under a bracket labeled the amax history window; a dashed level line touches the tallest bar, the max, and an arrow leads from it to a box reading delayed: reuse the window's max, free; a second orange arrow leads from bar t itself to a box reading current: measure step t itself, 1 extra pass
Figure 38. where a tensor's FP8 scale comes from: the loss-scaling dial, automated per tensor.

And not everything is quantized: attention's and the MLP's big matmuls run in FP8, while softmax, the normalizations, the first embedding and the final projection stay wide [11].

Wired into a transformer through NVIDIA's Transformer Engine, the published result is training runs 30 to 40 percent faster at matching loss curves [6], and DeepSeek-V3 trained a 671-billion-parameter model with FP8 matmuls by scaling 128-element tiles instead of whole tensors [7], a step toward what comes next. One caution before the next halving: E4M3 carries about 1.2 decimal digits and E5M2 about 0.9, so at 8 bits no single stored number is trustworthy; only averages over many are.

The outlier problem

Before the next halving, meet the format waiting at the bottom, because its 2 numbers explain everything that follows. 4 bits:

the bit fields of FP4 E2M1 drawn to scale: 1 sign bit, 2 exponent bits in orange, 1 mantissa bit, 4 bits per number; a card lists bias 1, ceiling 6 highlighted, normal floor 1, ramp floor 0.5, step at 1.0 of 0.5, and about 1 decimal digit; brackets tie each row to the field that sets itthe bit fields of FP4 E2M1 drawn to scale: 1 sign bit, 2 exponent bits in orange, 1 mantissa bit, 4 bits per number; a card lists bias 1, ceiling 6 highlighted, normal floor 1, ramp floor 0.5, step at 1.0 of 0.5, and about 1 decimal digit; brackets tie each row to the field that sets it
Figure 39. E2M1's card. 16 codes; the next drawing shows every one of them.
every value of the 4-bit E2M1 float on one line: minus 6 to 6 through minus and plus 0.5, 1, 1.5, 2, 3, 4 and 6, 15 ticks with the zero tick labeled plus and minus 0, the negative half grey and the positive half orange; step brackets under the axis read step 0.5, step 1 and step 2, and an orange bracket spans 0.5 to 6, the whole positive range is 6 over 0.5 equals 12xevery value of the 4-bit E2M1 float on one line: minus 6 to 6 through minus and plus 0.5, 1, 1.5, 2, 3, 4 and 6, 15 ticks with the zero tick labeled plus and minus 0, the negative half grey and the positive half orange; step brackets under the axis read step 0.5, step 1 and step 2, and an orange bracket spans 0.5 to 6, the whole positive range is 6 over 0.5 equals 12x
Figure 40. every value of a 4-bit E2M1 float: 15 numbers plus a second zero. you have now seen all of them.

16 codes, 15 values (0 appears twice, once with each sign), no inf, no NaN, and a total width of 6 / 0.5 = 12x, about 3.6 doublings. The 2023 OCP standard defined E2M1 as the 4-bit element of the block family these sections are building [9], and the newest NVIDIA chips run it at twice the FP8 rate [24]. Alone it is unusable, and even a per-tensor scale cannot save it, because per-tensor scaling has one enemy. Transformer activations grow a few channels whose values run hundreds of times larger than the rest, systematically, past about 6 billion parameters [8]. One scale must now serve the spike and the bell at once:

3 panels each with a bell histogram and one tall orange spike; under each, a proportion bar shows how much of the bell quantizes to 0: all of it for FP4 with one tensor scale, 31 percent for FP4 with a scale per 32, 1 percent for FP8 with one tensor scale; below each, a mini map of where that scale puts the format's points, with a dashed line from the spike pinning the top end3 panels each with a bell histogram and one tall orange spike; under each, a proportion bar shows how much of the bell quantizes to 0: all of it for FP4 with one tensor scale, 31 percent for FP4 with a scale per 32, 1 percent for FP8 with one tensor scale; below each, a mini map of where that scale puts the format's points, with a dashed line from the spike pinning the top end
Figure 41. one spike, three formats, measured. FP8 loses 1%; FP4 with one scale loses the entire bell; blocks are the rescue.

Read the measured numbers. FP8 with a single scale loses 1% of the ordinary values next to a 500x outlier, because a float grid keeps its relative error at any scale until values fall below its smallest positive number, and FP8's smallest is 229,376x under its largest (448 / 0.00195, from the map section). FP4's smallest is only 12x under its largest (6 / 0.5, computed above). One spike takes the whole 12x, and 100% of the ordinary values quantize to zero (proof). At 4 bits, blocks are not an improvement; nothing works without them.

p10_fp4_outlier.py the proof, ready to read or run
"""Proof: why blocks had to be invented. A float grid keeps its
relative precision at any scale, so FP8 with one per-tensor scale
survives an outlier surprisingly well. FP4 does not: its largest
positive value is 6 and its smallest is 0.5, only 12x apart, so one
spike pushes every ordinary value below the smallest, and the
tensor quantizes to zeros. A scale per 32 elements
rescues it."""
import torch

E2M1 = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])

def round_e2m1(v):
    """Round |v| to the nearest E2M1 magnitude, keep the sign."""
    idx = (v.abs().unsqueeze(-1) - E2M1).abs().argmin(-1)
    return E2M1[idx] * v.sign()

def quant(x, block):
    q = torch.empty_like(x)
    for i in range(0, len(x), block):
        b = x[i:i+block]
        s = b.abs().max() / 6.0
        s = s if s > 0 else torch.tensor(1.0)
        q[i:i+block] = round_e2m1(b / s) * s
    return q

torch.manual_seed(0)
x = torch.randn(128) * 0.1
x[40] = 50.0
mask = torch.ones(128, dtype=torch.bool); mask[40] = False

for name, block in (("per-tensor", 128), ("per-block-32", 32)):
    q = quant(x, block)
    zeros = (q[mask] == 0).float().mean()
    rel = ((q - x)[mask].abs() / x[mask].abs()).median()
    print(f"{name:14s} ordinary values quantized to zero: {zeros:.0%}   "
          f"median rel err of the rest: {rel:.1%}")
q = quant(x, 128)
print(f"the outlier itself survives either way: {q[40]:.1f}")
print()
print("the same tensor in FP8 E4M3, one per-tensor scale, for honesty:")
s = x.abs().max() / 448.0
q8 = (x / s).to(torch.float8_e4m3fn).float() * s
print(f"per-tensor FP8    zeros: {(q8[mask]==0).float().mean():.0%}   "
      f"median rel err: {((q8-x)[mask].abs()/x[mask].abs()).median():.1%}")
print("a float grid holds its relative error at any scale, until its")
print("smallest positive value; FP4's smallest (0.5) is only 12x below")
print("its largest (6), and that is the whole reason microscaling exists.")
download and run it

Microscaling

So the scale moved into the data type. The Open Compute Project's MX formats, standardized in 2023 by AMD, Arm, Intel, Meta, Microsoft, NVIDIA and Qualcomm [9], cut every tensor into blocks of 32 and give each block one 8-bit scale:

an MX block: an orange E8M0 scale chip, its own 8 bits drawn all exponent, an interval picker with no position, bracketed over 32 E2M1 element cells, 32 times 4 plus 8 equals 136 bits, 4.25 per number; an E2M1 bit bar drawn beside it for contrast; below, the memory lane: 1 scale byte then 16 element bytes, 2 elements in each, consumed whole by the tensor corean MX block: an orange E8M0 scale chip, its own 8 bits drawn all exponent, an interval picker with no position, bracketed over 32 E2M1 element cells, 32 times 4 plus 8 equals 136 bits, 4.25 per number; an E2M1 bit bar drawn beside it for contrast; below, the memory lane: 1 scale byte then 16 element bytes, 2 elements in each, consumed whole by the tensor core
Figure 42. the MX block. 32 elements, one shared scale, consumed whole by the tensor cores. the scale is an exponent with no mantissa: an interval-picker and nothing else.

Every idea on this page meets here. The E8M0 scale is a float with no mantissa at all, the interval choice from the floating-dot figure with the position deleted, so applying it is exponent addition, free in silicon. The 32-element block is the histogram cut fine enough that an outlier only damages its own block. The accounting is 136 bits per 32 numbers: 4.25 bits each, 136 / 128 = 1.06, so 6% more than raw 4-bit storage. And the block size is a measured compromise: smaller blocks mean less error and more storage spent on scales, and our own sweep across sizes 8, 16, 32, 64, 128 shows both moving (proof). The same chassis carries 8-, 6- and 4-bit elements as MXFP8, MXFP6, MXFP4.

What the tensor core does with 2 of these blocks is 1 more drawing:

a row block of A drawn as an orange scale chip sA over 32 element cells, above a column block of B with scale chip sB; grey arrows pair the elements into a box reading 32 products, summed in float32; the two scale chips feed a box reading sA plus sB, exponents add, whose arrow joins the float32 partial sum, scaled by 1 shifta row block of A drawn as an orange scale chip sA over 32 element cells, above a column block of B with scale chip sB; grey arrows pair the elements into a box reading 32 products, summed in float32; the two scale chips feed a box reading sA plus sB, exponents add, whose arrow joins the float32 partial sum, scaled by 1 shift
Figure 43. 2 MX blocks meet in the tensor core: the 32 element pairs multiply and sum in float32; the 2 scale bytes add as exponents and touch the sum once per block.

The block chassis also changed who does what inside FP8: with a scale for every 32 values, a block rarely spans more than E4M3's 18 doublings, so E4M3, with its 8 points per doubling against E5M2's 4, measured better for the gradients too, and the largest published MXFP8 pretraining run (8 billion parameters, 15 trillion tokens) quantizes every tensor as E4M3 [11].

The 6-bit elements deserve their own cards, because they are the family's open seats: E2M3 keeps digits, E3M2 keeps reach, one more run of the fork.

the bit fields of FP6 E2M3 drawn to scale: 1 sign bit, 2 exponent bits in orange, 3 mantissa bits, 6 bits per number; a card lists bias 1, ceiling 7.5, normal floor 1, ramp floor 0.125, step at 1.0 of 0.125 highlighted, and about 1 decimal digit; brackets tie each row to the field that sets itthe bit fields of FP6 E2M3 drawn to scale: 1 sign bit, 2 exponent bits in orange, 3 mantissa bits, 6 bits per number; a card lists bias 1, ceiling 7.5, normal floor 1, ramp floor 0.125, step at 1.0 of 0.125 highlighted, and about 1 decimal digit; brackets tie each row to the field that sets it
Figure 44. FP6 E2M3's card: 3 mantissa bits, reach of only 5.9 doublings.
the whole FP6 E2M3 map drawn large: a dashed ramp interval and 3 intervals of 8 points each, boundaries at 1, 2 and 4, ceiling 7.5, every point of the orange interval from 1 to 2 labeled, a step bracket reading step 2 to the -3 equals 0.125, the ulp at 1.0, and the note no inf, no NaN, every code is a numberthe whole FP6 E2M3 map drawn large: a dashed ramp interval and 3 intervals of 8 points each, boundaries at 1, 2 and 4, ceiling 7.5, every point of the orange interval from 1 to 2 labeled, a step bracket reading step 2 to the -3 equals 0.125, the ulp at 1.0, and the note no inf, no NaN, every code is a number
Figure 45. all of E2M3: 3 intervals of 8 points, 7.5 down to 0.125.
the bit fields of FP6 E3M2 drawn to scale: 1 sign bit, 3 exponent bits in orange, 2 mantissa bits, 6 bits per number; a card lists bias 3, ceiling 28 highlighted, normal floor 0.25, ramp floor 0.0625, step at 1.0 of 0.25, and about 1 decimal digit; brackets tie each row to the field that sets itthe bit fields of FP6 E3M2 drawn to scale: 1 sign bit, 3 exponent bits in orange, 2 mantissa bits, 6 bits per number; a card lists bias 3, ceiling 28 highlighted, normal floor 0.25, ramp floor 0.0625, step at 1.0 of 0.25, and about 1 decimal digit; brackets tie each row to the field that sets it
Figure 46. FP6 E3M2's card: 1 more exponent bit, reach of 8.8 doublings.
the whole FP6 E3M2 map drawn large: a dashed ramp interval and 7 intervals of 4 points each, boundaries from 0.25 to 16, ceiling 28, every point of the orange interval from 1 to 2 labeled, a step bracket reading step 2 to the -2 equals 0.25, the ulp at 1.0, and the note no inf, no NaN, every code is a numberthe whole FP6 E3M2 map drawn large: a dashed ramp interval and 7 intervals of 4 points each, boundaries from 0.25 to 16, ceiling 28, every point of the orange interval from 1 to 2 labeled, a step bracket reading step 2 to the -2 equals 0.25, the ulp at 1.0, and the note no inf, no NaN, every code is a number
Figure 47. all of E3M2: 7 intervals of 4 points, 28 down to 0.0625.

Their maps are small enough to draw complete: 3 intervals of 8 points, 7 intervals of 4. Blackwell-class chips execute them at full speed as MXFP6 [9][24]; almost no published recipe uses them yet. A format survives by having a job nobody else does. FP8 is safer, FP4 is faster on paper, and FP6 is still looking for its job.

NVIDIA's NVFP4 variant tightens it further: 16-element blocks, a fractional E4M3 scale instead of a power-of-two one, plus one float32 scale per tensor, buying a finer fit for a quarter bit more [10]. The bytes carry the quarter bit, and the deeper difference sits in the scale byte itself:

two byte lanes: MXFP4 as one E8M0 scale byte plus 16 element bytes, 17 bytes for 32 values, 4.25 bits each; NVFP4 as one E4M3 scale byte plus 8 element bytes and a dashed float32 tensor scale chip, 9 bytes for 16 values, 4.5 bits each plus about 0 amortized; below, the two scale bytes drawn bit by bit: E8M0 all exponent, powers of 2 only; E4M3 with 3 mantissa bits, so the scale can be 2.5, not only 2 or 4two byte lanes: MXFP4 as one E8M0 scale byte plus 16 element bytes, 17 bytes for 32 values, 4.25 bits each; NVFP4 as one E4M3 scale byte plus 8 element bytes and a dashed float32 tensor scale chip, 9 bytes for 16 values, 4.5 bits each plus about 0 amortized; below, the two scale bytes drawn bit by bit: E8M0 all exponent, powers of 2 only; E4M3 with 3 mantissa bits, so the scale can be 2.5, not only 2 or 4
Figure 48. the 2 block families as bytes. 4.25 against 4.5 bits per value, and the deeper difference: an E4M3 scale has mantissa bits, so it can be fractional.

Here is one NVFP4 block quantized by hand, every scale shown:

3 lanes of intervals show 8 of an NVFP4 block's 16 values making the round trip; between the first 2 lanes the 2 scales are drawn: the tensor's float32 scale, 448 times 6 over 15.011 equals 179.07, divided by this block's E4M3 scale, 448, together times 0.3997; the values land on E2M1's 16 values, the 2 scales divide back out, and outcome chips read: 0.25 and 0.5 to 0, 3.2 17 percent off, 15.0 exact, it set the scales3 lanes of intervals show 8 of an NVFP4 block's 16 values making the round trip; between the first 2 lanes the 2 scales are drawn: the tensor's float32 scale, 448 times 6 over 15.011 equals 179.07, divided by this block's E4M3 scale, 448, together times 0.3997; the values land on E2M1's 16 values, the 2 scales divide back out, and outcome chips read: 0.25 and 0.5 to 0, 3.2 17 percent off, 15.0 exact, it set the scales
Figure 49. one NVFP4 block, every scale shown. the largest value sets the scales and survives exactly; the smallest fall to 0; the middle carries the percent-level error.

The largest of the 16 values, 15.011, sets the scales and comes back exactly; 0.25 and 0.5 fall below half of the smallest step at that scale and come back as 0; 3.2002 comes back as 3.7528, about 17% off (proof runs every number). A single 4-bit value is coarse. The bet of 4-bit arithmetic is that billions of such errors, kept unbiased, cancel on average.

p19_nvfp4_worked.py the proof, ready to read or run
"""Proof: one NVFP4 block, quantized by hand, every scale shown.
Re-derives the worked 1 x 16 example from NVIDIA's recipe (as
walked through by Radical Numerics): a global FP32 scale brings
the tensor's largest value into range, each 16-value block gets an
FP8 E4M3 scale, and the elements land on the 16 values of E2M1.
"""
import torch

E2M1_MAX = 6.0
E4M3_MAX = 448.0
BLOCK = 16
E2M1_GRID = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])

def rne_e2m1(x):
    """Round each value to the nearest E2M1 value, ties to even."""
    grid = torch.cat([E2M1_GRID, -E2M1_GRID.flip(0)])
    d = (x.unsqueeze(-1) - grid).abs()
    return grid[d.argmin(dim=-1)]

x = torch.tensor([0.0, 0.25, 0.5, 0.75356, 1.251245, 3.2002,
                  4.5032, 15.011, 0.012, -0.312, -5.50055, 10.06,
                  -1.2526, 3.025, 2.5114, 7.0162])

# level 1: one FP32 scale for the whole tensor
global_amax = x.abs().max()
s_enc = (E4M3_MAX * E2M1_MAX) / global_amax
s_dec = 1.0 / s_enc

# level 2: one E4M3 scale for each block of 16
block_amax = x.abs().max()                    # one block here
dec_scale = (block_amax / E2M1_MAX) * s_enc
dec_scale_e4m3 = dec_scale.to(torch.float8_e4m3fn)
enc_scale = 1.0 / (dec_scale_e4m3.float() * s_dec)
print(f"level 1, the tensor's float32 scale: 448 x 6 / "
      f"{global_amax:.4f} = {s_enc:.2f}")
print(f"level 2, this block's E4M3 scale: "
      f"{dec_scale_e4m3.float():g}")
print(f"together: {s_enc:.2f} / {dec_scale_e4m3.float():g} = "
      f"{enc_scale:.4f}, the factor every value is multiplied by")

# quantize: scale, clamp to E2M1's range, round onto its 16 values
scaled = (x * enc_scale).clamp(-E2M1_MAX, E2M1_MAX)
q = rne_e2m1(scaled)

# dequantize: multiply back by both scales
dq = q * dec_scale_e4m3.float() * s_dec

print(f"global amax {global_amax:.4f}   block encode scale "
      f"{enc_scale:.4f}\n")
print(f"{'input':>9s} {'scaled':>8s} {'E2M1':>6s} {'back':>9s}")
for a, b, c, d in zip(x, scaled, q, dq):
    print(f"{a:>9.4f} {b:>8.4f} {c:>6.1f} {d:>9.4f}")

print("\nread the two ends: 15.011 comes back as 15.011 exactly")
print("(it set the scales), 0.25 and 0.5 come back as 0 (they")
print("fell below half of E2M1's smallest step at this scale),")
print("3.2002 comes back as 3.7528: about 17% off. one 4-bit")
print("number is coarse; the training recipe works because these")
print("errors average out over billions of them.")
download and run it

One subtlety earned a hardware rule. The E8M0 scale can only be a power of 2, so a block whose largest value is 5.9 must round its scale one way or the other, and the 2 ways are not close:

a block of 6 intervals all holding 5.9; below, the same E2M1 points drawn under the 2 candidate scales: with the scale rounded down the points reach only 3, and 5.9's arrow clamps back to the last point, 49 percent off on every value; with the scale rounded up the points reach 6, and 5.9 lands beside 6.00, 1.7 percent offa block of 6 intervals all holding 5.9; below, the same E2M1 points drawn under the 2 candidate scales: with the scale rounded down the points reach only 3, and 5.9's arrow clamps back to the last point, 49 percent off on every value; with the scale rounded up the points reach 6, and 5.9 lands beside 6.00, 1.7 percent off
Figure 50. the scale-rounding rule: round the block scale up. the first spec said down, and the difference is a failed training run.

NVIDIA measured the difference as the gap between divergence and parity at trillion-token scale [11]; proof shows the mechanism in 2 lines.

The whole scaling story now fits on one ladder, and every recipe of the last 5 sections is 1 rung of it:

5 rungs from coarse to fine, each with the same 4096 by 4096 weight drawn and its scale chips tied to what they serve by brackets and dashed lines: loss scaling with 1 scale bracketed over the whole backward pass, per tensor for the Hopper FP8 recipes, per 128 by 128 tile for DeepSeek-V3 with 1,024 scales, per block of 32 for the MX formats with 524,288 scales, and per 16 plus 1 global for NVFP4 with 1,048,577 scales; a right column counts the extra bits per value, 0 up to 0.55 rungs from coarse to fine, each with the same 4096 by 4096 weight drawn and its scale chips tied to what they serve by brackets and dashed lines: loss scaling with 1 scale bracketed over the whole backward pass, per tensor for the Hopper FP8 recipes, per 128 by 128 tile for DeepSeek-V3 with 1,024 scales, per block of 32 for the MX formats with 524,288 scales, and per 16 plus 1 global for NVFP4 with 1,048,577 scales; a right column counts the extra bits per value, 0 up to 0.5
Figure 51. the granularity ladder. down it, an outlier damages less; up it, fewer bytes go to scales. every scaling recipe on this page is 1 rung.

Training at 4 bits

The destination format, E2M1, was drawn whole 2 sections back: 15 values, 12x wide. Training inside those 16 codes needs 3 rescues beyond blocks, and each is an idea from earlier on this page pushed one step:

The first is stochastic rounding, the lost-update repair now doing the main work. At 4 bits nearly every update is below half the local step, so deterministic rounding freezes everything; rounding up with probability proportional to progress is unbiased in expectation. Our proof runs 10,000 micro-updates: nearest rounds to a frozen 1.0, stochastic reaches a mean of 3.0113 against a true 3.0, noisy per run and honest on average (proof).

p12_sr_drift.py the proof, ready to read or run
"""Proof: round-to-nearest is biased when every step is smaller than
half the local spacing; stochastic rounding is unbiased in
expectation. The same fact that freezes bf16 weights, isolated."""
import torch
N, u, TRIALS = 10000, 2e-4, 50   # each step ~1/39 of bf16's step at 1.0

def sr_walk(seed):
    g = torch.Generator().manual_seed(seed)
    val = 1.0
    for _ in range(N):
        x = val + u
        lo = torch.tensor(x, dtype=torch.bfloat16)
        lo_f = lo.float().item()
        hi_f = torch.nextafter(lo, torch.tensor(float("inf"), dtype=torch.bfloat16)).float().item()
        if lo_f > x:
            hi_f, lo_f = lo_f, torch.nextafter(lo, torch.tensor(float("-inf"), dtype=torch.bfloat16)).float().item()
        p = 0.0 if hi_f == lo_f else (x - lo_f) / (hi_f - lo_f)
        val = hi_f if torch.rand((), generator=g).item() < p else lo_f
    return val

rne = torch.tensor(1.0, dtype=torch.bfloat16)
for _ in range(N):
    rne = rne + torch.tensor(u, dtype=torch.bfloat16)
finals = torch.tensor([sr_walk(s) for s in range(TRIALS)])
true = 1.0 + N * u
print(f"true value                     : {true:.4f}")
print(f"round-to-nearest, {N} steps  : {rne.item():.4f}  (frozen)")
print(f"stochastic rounding, {TRIALS} runs  : "
      f"mean {finals.mean().item():.4f}, spread +-{finals.std().item():.4f}")
print("unbiased in expectation, noisy per run: that is the trade.")
download and run it

The second is the random Hadamard transform: rotate the tensor by an orthogonal matrix before quantizing and undo it after, which changes no matmul (the rotation cancels) but spreads an outlier's energy across every coordinate:

2 histograms: before, a small bell with 1 dominating orange spike, max over mean 11.8; an arrow labeled rotate by H carrying a small 2 by 2 plus and minus matrix; after, the same energy spread into a wider bell with no spike, max over mean 2.6; max and mean are drawn as level marks on both panels2 histograms: before, a small bell with 1 dominating orange spike, max over mean 11.8; an arrow labeled rotate by H carrying a small 2 by 2 plus and minus matrix; after, the same energy spread into a wider bell with no spike, max over mean 2.6; max and mean are drawn as level marks on both panels
Figure 52. the rotation rescue, measured: max/mean falls from 11.8 to 2.6 and nothing about the mathematics moved.

Our 64-value proof takes the max-to-mean ratio from 11.8 to 2.6 (proof). The third is selective precision: the first and last layers, where quantization error is measured to hurt most, stay in higher precision. With all three plus NVFP4, a 12-billion-parameter model has been pretrained on 10 trillion tokens at nearly the same loss as its FP8 twin [10]; MXFP4 needs more tokens for the same loss [12]. Where each rescue is applied is itself measured engineering [10][24]: stochastic rounding only on the gradients (on the forward pass it adds noise for nothing, so weights and activations round to nearest); the Hadamard rotation only on the inputs of the weight-gradient matmul; and weights are scaled in 16 x 16 squares rather than 1 x 16 rows, for a reason worth drawing:

the same 8 by 8 weight matrix drawn 4 times: blocked along rows for the forward pass; blocked along columns for the transposed backward pass; a clash panel where one red-framed interval sits inside both a row block and a column block, their 2 scale chips pointing at it, 1 weight, 2 blocks, 2 different scales; and 16 by 16 squares, the same both waysthe same 8 by 8 weight matrix drawn 4 times: blocked along rows for the forward pass; blocked along columns for the transposed backward pass; a clash panel where one red-framed interval sits inside both a row block and a column block, their 2 scale chips pointing at it, 1 weight, 2 blocks, 2 different scales; and 16 by 16 squares, the same both ways
Figure 53. the transpose problem. blocks run along the reduction direction, so W and W-transposed disagree; squares are the truce, paid only for the weights.

A row-scaled weight matrix and its column-scaled transpose are 2 different quantized matrices, and the forward and backward passes must see the same weights. The last roughly 15% of layers stay in higher precision, and if a gap to the wide baseline remains at the end, switching the forward pass to higher precision for the final phase of training closes most of it [10][24]. 4-bit training is real and it is young; treat recipes as versioned, not settled.

p13_hadamard.py the proof, ready to read or run
"""Proof: an orthogonal rotation spreads one outlier's energy across
every coordinate, without changing the vector's length or the matmul
it feeds (H is orthogonal). The max shrinks; the format's points get
used again."""
import torch

def hadamard(n):
    H = torch.tensor([[1.0]])
    while H.shape[0] < n:
        H = torch.cat([torch.cat([H, H], 1), torch.cat([H, -H], 1)], 0) / (2 ** 0.5)
    return H

torch.manual_seed(3)
n = 64
x = torch.randn(n) * 0.5
x[7], x[23], x[51] = 8.0, -7.0, 6.0
H = hadamard(n)
y = x @ H
print(f"norm preserved: {torch.allclose(x.norm(), y.norm())}")
for name, v in (("before", x), ("after H", y)):
    print(f"{name:8s} max {v.abs().max():.3f}   mean {v.abs().mean():.3f}   "
          f"max/mean {(v.abs().max()/v.abs().mean()).item():.2f}")
download and run it

The other grids

Inference relaxed one constraint: if only the weights are quantized and arithmetic runs wider, the 16 points need not be a float at all. 3 designs compete on the same bell-shaped distribution of trained weights:

a bell of trained weights above 3 aligned rows of 16 tick marks: INT4 equal spacing, FP4 crowded at zero, NF4 matched to the bell; a warm band marks within 0.25 of zero, holding 3, 5 and 6 points per row; at the right each row's measured mean error is drawn as a bar: 0.163, 0.106, 0.106, with NF4 the best taila bell of trained weights above 3 aligned rows of 16 tick marks: INT4 equal spacing, FP4 crowded at zero, NF4 matched to the bell; a warm band marks within 0.25 of zero, holding 3, 5 and 6 points per row; at the right each row's measured mean error is drawn as a bar: 0.163, 0.106, 0.106, with NF4 the best tail
Figure 54. 16 points, 3 designs, one weight distribution, measured. equal spacing wastes the tails; the float grid crowds zero; the quantile grid follows the weights.

INT4 spaces its points equally and wastes them where weights are rare. FP4 doubles its steps and crowds zero. NF4, from the QLoRA work, places its 16 values at the quantiles of a gaussian, which is approximately what trained weights are, and wins the tail (proof, [13]). Around these grids grew the post-training toolkit: GPTQ chooses each weight's rounding to compensate the error of the previous ones [14]; AWQ rescales channels so the few weights that matter most land on finer points [15]; and the same how-many-bits question now runs through the KV cache and even optimizer states, where 8-bit Adam keeps its moments in blocks with per-block scales [16], the MX idea arriving from the other direction.

p14_grids.py the proof, ready to read or run
"""Proof: three ways to spend sixteen points. INT4's uniform grid,
FP4 E2M1's doubling grid, and NF4's quantile grid (the sixteen
values from the QLoRA paper), each quantizing the same gaussian
weights with one absmax scale."""
import torch
torch.manual_seed(0)
w = torch.randn(100_000)

INT4 = torch.linspace(-7, 7, 15) / 7.0            # symmetric int4
E2M1 = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0]) / 6.0
E2M1 = torch.cat([-E2M1.flip(0), E2M1]).unique()
NF4 = torch.tensor([-1.0, -0.6961928009986877, -0.5250730514526367,
    -0.39491748809814453, -0.28444138169288635, -0.18477343022823334,
    -0.09105003625154495, 0.0, 0.07958029955625534, 0.16093020141124725,
    0.24611230194568634, 0.33791524171829224, 0.44070982933044434,
    0.5626170039176941, 0.7229568362236023, 1.0])  # Dettmers et al. 2023

s = w.abs().max()
for name, grid in (("INT4 uniform", INT4), ("FP4 E2M1", E2M1),
                   ("NF4 quantile", NF4)):
    q = grid[( (w / s).unsqueeze(-1) - grid ).abs().argmin(-1)] * s
    err = (q - w).abs()
    print(f"{name:14s} mean abs err {err.mean():.4f}   "
          f"p99 {err.quantile(0.99):.4f}")
print("\nthe bell wants points where the bell is; NF4 puts them there.")
download and run it

The whole family on one sheet

Every format on this page has now been met. Gather them:

every format on one sheet, one row each for float64, float32, TF32, bfloat16, float16, FP8 E5M2, FP8 E4M3, FP6 E3M2, FP6 E2M3 and FP4 E2M1: the bit fields drawn to one shared scale with exponents orange and mantissas blue, the reach drawn as orange bars in doublings with broken bars for 2098, 277, 264 and 261, and digits as blue bars from 16.0 down to 0.6; the TF32 row is dashed, a read mode; below, the block formats join: MXFP8, MXFP6, MXFP4 and NVFP4, each drawn as its shared scale byte plus its element bits, 8.25 down to 4.25 and 4.5 bits per value, their reach an element bar with dashed arrows, slides per blockevery format on one sheet, one row each for float64, float32, TF32, bfloat16, float16, FP8 E5M2, FP8 E4M3, FP6 E3M2, FP6 E2M3 and FP4 E2M1: the bit fields drawn to one shared scale with exponents orange and mantissas blue, the reach drawn as orange bars in doublings with broken bars for 2098, 277, 264 and 261, and digits as blue bars from 16.0 down to 0.6; the TF32 row is dashed, a read mode; below, the block formats join: MXFP8, MXFP6, MXFP4 and NVFP4, each drawn as its shared scale byte plus its element bits, 8.25 down to 4.25 and 4.5 bits per value, their reach an element bar with dashed arrows, slides per block
Figure 55. the whole family on one sheet, block formats included: orange buys reach, blue buys digits, and a shared scale slides a narrow row anywhere. every scalar number derived from the bit counts and checked against torch where torch carries the format (proof below).
p18_family_sheet.py the proof, ready to read or run
"""Proof: the whole-family sheet, derived from each format's bit
counts and checked against torch wherever torch has the type.
For every format: ceiling, smallest positive value, the number of
doublings between them (log2 of their ratio), step at 1.0, and
decimal digits. kind says how the top exponent codes are used:
  ieee  top exponent code reserved for inf and NaN
  fn    no inf; only the top mantissa pattern of the top code is NaN
  all   no inf and no NaN; every code is a number (FP4 E2M1)
"""
import math
import torch

def family(E, M, bias, kind):
    if kind == "ieee":
        emax = 2 ** E - 2 - bias
        ceiling = (2 - 2.0 ** -M) * 2.0 ** emax
    elif kind == "fn":
        emax = 2 ** E - 1 - bias
        ceiling = (2 - 2.0 ** (1 - M)) * 2.0 ** emax
    else:  # all
        emax = 2 ** E - 1 - bias
        ceiling = (2 - 2.0 ** -M) * 2.0 ** emax
    smallest = 2.0 ** (1 - bias - M)          # smallest subnormal
    return {
        "ceiling": ceiling,
        "smallest": smallest,
        "doublings": math.log2(ceiling) - math.log2(smallest),
        "step1": 2.0 ** -M,
        "digits": (M + 1) * math.log10(2),
    }

ROWS = [
    ("float64", 11, 52, 1023, "ieee", torch.float64),
    ("float32", 8, 23, 127, "ieee", torch.float32),
    ("TF32", 8, 10, 127, "ieee", None),
    ("bfloat16", 8, 7, 127, "ieee", torch.bfloat16),
    ("float16", 5, 10, 15, "ieee", torch.float16),
    ("FP8 E5M2", 5, 2, 15, "ieee", torch.float8_e5m2),
    ("FP8 E4M3", 4, 3, 7, "fn", torch.float8_e4m3fn),
    ("FP6 E3M2", 3, 2, 3, "all", None),
    ("FP6 E2M3", 2, 3, 1, "all", None),
    ("FP4 E2M1", 2, 1, 1, "all", None),
]

print(f"{'format':10s} {'ceiling':>10s} {'smallest':>10s} "
      f"{'doublings':>9s} {'step at 1':>10s} {'digits':>6s}")
for name, E, M, bias, kind, dt in ROWS:
    f = family(E, M, bias, kind)
    print(f"{name:10s} {f['ceiling']:>10.4g} {f['smallest']:>10.3g} "
          f"{f['doublings']:>9.1f} {f['step1']:>10.4g} "
          f"{f['digits']:>6.1f}")
    if dt is not None:
        fi = torch.finfo(dt)
        assert f["ceiling"] == fi.max, (name, f["ceiling"], fi.max)
        assert f["step1"] == fi.eps, (name, f["step1"], fi.eps)
print("\ntorch.finfo agrees on ceiling and step for every format "
      "torch carries\n(float64/32/16, bfloat16, both FP8 types).")
print("FP6 sheets from the same formulas; Blackwell tensor cores "
      "run them,\ntorch has no storage type for them yet.")
download and run it

Read the sheet by columns. The bit bars share one scale: you can see float64 dwarf everything and the narrow rows shrink to almost nothing. The reach bars collapse from float64's 2,098 doublings to E2M1's 3.6; every format from float16 down fits its whole reach on the drawing. The digits column falls from 16 to 0.6. The block rows at the bottom are the repair: the same narrow elements, plus a shared scale whose dashed arrows say what it does, sliding each block's small reach to wherever its values live. And the sheet is the page in one drawing: everything before the field guide taught you what the columns mean, and everything after it was the story of how the narrow rows are made usable.

Where the bytes live

All of it, on one shelf:

4 bars for the weights of a 7,000,000,000-weight model: float32 28 GB, bfloat16 14, FP8 with scales 7.1, MXFP4 with scales 3.7; one stacked bar for what a bf16 mixed-precision training run holds: weights 14, gradients 14, master copy 28, Adam m 28, Adam v 28, together 112 GB, 16 bytes per weight; and a serving panel for a 70B-class chat model with 80 layers and 8 KV heads of 128 dims at 131,072 tokens: weights W4 37 GB plus float16 KV cache 43 GB exactly fills a dashed one-80-GB-GPU line, while FP8 KV at 21.5 GB leaves half the card free; every number derived in the drawing4 bars for the weights of a 7,000,000,000-weight model: float32 28 GB, bfloat16 14, FP8 with scales 7.1, MXFP4 with scales 3.7; one stacked bar for what a bf16 mixed-precision training run holds: weights 14, gradients 14, master copy 28, Adam m 28, Adam v 28, together 112 GB, 16 bytes per weight; and a serving panel for a 70B-class chat model with 80 layers and 8 KV heads of 128 dims at 131,072 tokens: weights W4 37 GB plus float16 KV cache 43 GB exactly fills a dashed one-80-GB-GPU line, while FP8 KV at 21.5 GB leaves half the card free; every number derived in the drawing
Figure 56. 7 billion weights 4 ways, the 112 GB training footprint, and the serving arithmetic: with float16 KV one conversation fills an 80 GB GPU; FP8 KV halves it. the bus carries all of it, every step and every token.

These panels are the honest reason this whole family of formats exists. A 7-billion-parameter model is 28 GB of float32 or 3.7 GB of MXFP4 with its scales; a training run holds 8x its bf16 weights; and in serving, the KV cache decides how many conversations one GPU carries. gpt-oss ships its 120-billion-parameter mixture-of-experts in MXFP4 so it fits a single 80 GB GPU [9].

Serving adds a loop of its own. A chat model generates 1 token at a time, and every token's step reads all the weights and the whole KV cache, the stored attention keys and values of every token so far; serving engines keep that cache in fixed-size pages so many conversations can grow side by side without fragmenting memory [29]. That loop, end to end:

one decode step of a serving engine: a dashed GPU memory region holds all the weights, W4 plus scales, and the KV cache in fixed-size pages drawn as a grid of blue blocks, 1 new entry per token; on the right, the compute loop: attention reads the whole cache so far, MLP matmuls read all the weights, logits in float32, sample 1 token; a black arrow streams all 37 GB of weights and a blue arrow streams the whole cache into the loop on every token, a dashed blue arrow writes 1 new KV entry back, and an orange arrow carries the new token back to the topone decode step of a serving engine: a dashed GPU memory region holds all the weights, W4 plus scales, and the KV cache in fixed-size pages drawn as a grid of blue blocks, 1 new entry per token; on the right, the compute loop: attention reads the whole cache so far, MLP matmuls read all the weights, logits in float32, sample 1 token; a black arrow streams all 37 GB of weights and a blue arrow streams the whole cache into the loop on every token, a dashed blue arrow writes 1 new KV entry back, and an orange arrow carries the new token back to the top
Figure 57. one generated token, end to end. every token streams the full weights and the full cache across the memory bus; halving the bytes halves the time per token [29].

Hardware truth

Throughput follows the same halvings on hardware that speaks the format natively, and only there: on a GPU that must dequantize in software, a narrow format saves memory but not time. That warning is measurable. One published comparison ran the same image-generation model on a GPU with no MXFP8 hardware: the MXFP8 file was smaller, and generation was slower than bfloat16 (112.8 seconds against 96.0), because every block had to be unpacked in software [25]. The same tests ranked output quality FP32, then FP16 and BF16 about equal, then MXFP8, then per-tensor-scaled FP8, then NVFP4, then plain FP8 last [25]: size and quality move together, and a scale at any granularity beats no scale. Who speaks what natively today: NVIDIA's Blackwell chips run MXFP8/6/4 and NVFP4 in their tensor cores [24]; AMD's newest Instinct chips run the MX family [26]; Hopper GPUs and Trainium2 can store MX blocks but unpack them for the math [26]. The same facts, drawn so a spec sheet can be checked against them:

a grid of chips against formats: rows NVIDIA H100, NVIDIA B200, AMD MI355X, Google TPU, AWS Trainium2 and Apple M3; columns float64, TF32, 16-bit, FP8, MX 8 6 4 and NVFP4; filled orange squares mark native tensor-core support, flat grey half-squares mark storage that is unpacked in software; B200 fills every column, H100 and Trainium2 carry half-squares under MXa grid of chips against formats: rows NVIDIA H100, NVIDIA B200, AMD MI355X, Google TPU, AWS Trainium2 and Apple M3; columns float64, TF32, 16-bit, FP8, MX 8 6 4 and NVFP4; filled orange squares mark native tensor-core support, flat grey half-squares mark storage that is unpacked in software; B200 fills every column, H100 and Trainium2 carry half-squares under MX
Figure 58. who speaks what, from this page's sources [20][24][25][26]. filled: runs it in the tensor cores. half: stores it, unpacks in software. empty: these sources make no claim.

And what a native format buys, next to the measured price of one the hardware cannot speak:

horizontal bars of dense matmul rate in multiples of bfloat16: float64 a sliver, far below; TF32 at 0.5x; bfloat16 at 1x; FP8 at 2x, half the bits, twice the rate; FP4 at 4x on Blackwell; below, a red outlined bar at 0.85x for MXFP8 on a GPU with no MX hardware, measured 112.8 seconds against 96.0, slower than bfloat16 because every block is unpacked in softwarehorizontal bars of dense matmul rate in multiples of bfloat16: float64 a sliver, far below; TF32 at 0.5x; bfloat16 at 1x; FP8 at 2x, half the bits, twice the rate; FP4 at 4x on Blackwell; below, a red outlined bar at 0.85x for MXFP8 on a GPU with no MX hardware, measured 112.8 seconds against 96.0, slower than bfloat16 because every block is unpacked in software
Figure 59. each native halving doubles the matmul rate [19][24]; the bottom bar is the measured cost when the hardware cannot speak the format [25].

Read a spec sheet's format row before believing its speedup column.

The contract

Two runs of the same training script, same seeds, same data, on two different GPUs, will not match bit for bit, and now every reason is on this page: sums land in different orders across different core counts; tensor cores round differently than the unfused loop; TF32 trims mantissas silently on one machine and not another. And there is a quieter actor: the compiler. IEEE 754 fixes each instruction's bits, but no language promises which instructions your line becomes. A compiler may legally fuse a * b + c into 1 fused multiply-add (1 rounding) or keep it as 2 instructions (2 roundings), and the choice can flip with an optimization flag or with something as small as inlining a function [27]; the rounding section's proof showed the 2 paths disagreeing. torch.compile is a compiler too: its fused kernels are allowed to round differently than eager mode [28]. And rounding in 2 hops is not rounding once: take an exact value to float64 first and then to float32, and you can land on a different number than going straight to float32, because the first rounding can create a tie the second one breaks the other way (proof builds one). This double rounding is why the old x87 unit, which computed in 80-bit registers and rounded again on every store to memory, made a program's bits depend on register allocation [4]. None of it is a bug; all of it is the map.

p23_double_rounding.py the proof, ready to read or run
"""Proof: rounding twice can differ from rounding once. Take the
exact value 1 + 2^-24 + 2^-60. Rounded straight to float32 it goes
UP to 1 + 2^-23, because it sits just above the halfway point.
Rounded first to float64, the tiny 2^-60 tail is lost and the
float64 result lands exactly ON the halfway point; rounding that
to float32 then goes DOWN to 1.0 by ties-to-even. Two legal
paths, 2 different answers: double rounding, the failure mode of
every system that computes wider first and stores narrower.
"""
from fractions import Fraction as F
import numpy as np

exact = F(1) + F(1, 2 ** 24) + F(1, 2 ** 60)

# path 1: round once, straight to float32 (decided exactly)
lo, hi = F(1), F(1) + F(1, 2 ** 23)          # float32 neighbors
assert lo < exact < hi
once = np.float32(1.0) if (exact - lo) <= (hi - exact) else \
       np.float32(1.0 + 2.0 ** -23)

# path 2: round to float64 first, then to float32
via64 = float(exact)                          # correctly rounded
twice = np.float32(via64)

print(f"exact value          : 1 + 2^-24 + 2^-60")
print(f"rounded once to fp32 : {once!r}")
print(f"fp64 first           : {via64!r}")
print(f"then to fp32         : {twice!r}")
assert once == np.float32(1.0 + 2.0 ** -23)
assert twice == np.float32(1.0)
print("\nthe 2 paths disagree. the old x87 unit computed in 80-bit")
print("registers and rounded again on every spill to memory, so a")
print("program's bits depended on register allocation; SSE and its")
print("one-width successors retired the problem, and this proof is")
print("why narrow-then-narrower casts still deserve suspicion.")
download and run it
The working rules: compare models with tolerances (torch.testing.assert_close, never ==); flip torch.use_deterministic_algorithms(True) when you need repeatability inside one machine and accept the speed cost; and when a loss goes nan, hunt the overflow upstream (an unmasked softmax, an unscaled fp16 gradient, a variance of zero) rather than rerolling the seed. The nan at the top of this page has the same kind of cause as the 0.1 + 0.2: some number moved past the edge of its map.

Where this came from

7 era cards joined by arrows from the 1941 Z3 to 2024 FP4, each carrying a small orange bar of its bits per number drawn to one scale: 22, 32, 16, 16, 8, 4.25 and 4; a dashed card marks 1972 G.711, an 8-bit float in every phone call 50 years before FP8; FP4 carries the note 2025, pretrained at 10T tokens7 era cards joined by arrows from the 1941 Z3 to 2024 FP4, each carrying a small orange bar of its bits per number drawn to one scale: 22, 32, 16, 16, 8, 4.25 and 4; a dashed card marks 1972 G.711, an 8-bit float in every phone call 50 years before FP8; FP4 carries the note 2025, pretrained at 10T tokens
Figure 60. 80 years of the same idea: use fewer bits where the numbers allow it, and move the repair (bias, scale, block) somewhere cheaper.

Konrad Zuse's Z3 computed in 22-bit floating point in 1941, special values included. 44 years later IEEE 754, largely William Kahan's design, ended an era in which every vendor's arithmetic disagreed [3]. The formats since bfloat16 are the same standard's ideas at new bit counts, and the full table of every named format that ever shipped, from the Z3 to the G.711 telephone float of 1972 (an 8-bit float in every phone call for 50 years) to NVFP4, is longer than this page and belongs to a follow-up table of its own. The roads not taken, posits and logarithmic number systems, are worth reading about [17].

Build it yourself

You already did, in the proofs. The 15-line decoder in p2_e4m3_map.py is a complete FP8 implementation that torch agrees with on all 256 patterns; the block quantizer in p11_mxblock.py is MXFP4 minus the packing; the stochastic rounder lives in p12_sr_drift.py. Change E4M3's constants to E5M2's and check yourself against torch again: that is the exercise that makes every format in this family yours.

p11_mxblock.py the proof, ready to read or run
"""Proof: an MX-style block quantizer built from the spec: 32
elements share one power-of-two scale (E8M0), elements are E2M1.
The scale exponent is rounded UP, the choice that keeps the block
maximum representable (the OCP round-down loses it). Sweep block
sizes to see the trade: overhead per element vs error."""
import math, torch

E2M1 = torch.tensor([0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0])

def round_e2m1(v):
    idx = (v.abs().unsqueeze(-1) - E2M1).abs().argmin(-1)
    return E2M1[idx] * v.sign()

def mx_quant(x, block=32):
    q = torch.empty_like(x)
    for i in range(0, len(x), block):
        b = x[i:i+block]
        amax = b.abs().max()
        if amax == 0:
            q[i:i+block] = 0; continue
        e = math.ceil(math.log2(amax / 6.0))     # round UP: amax stays <= 6*2^e
        e = max(-127, min(127, e))
        s = 2.0 ** e
        q[i:i+block] = round_e2m1(b / s) * s
    return q

torch.manual_seed(1)
x = torch.randn(4096)
for block in (8, 16, 32, 64, 128):
    q = mx_quant(x, block)
    rel = ((q - x).abs() / x.abs().clamp_min(1e-9)).median()
    bits = 4 + 8 / block
    print(f"block {block:>3}:  bits/element {bits:.3f}   median rel err {rel:.1%}")

# and the rounding-direction subtlety, demonstrated
b = torch.tensor([5.9] * 32)
e_up = math.ceil(math.log2(5.9 / 6.0)); e_dn = math.floor(math.log2(5.9 / 6.0))
up = round_e2m1(b / 2.0**e_up) * 2.0**e_up
dn = (round_e2m1((b / 2.0**e_dn).clamp(-6, 6)) * 2.0**e_dn)
print(f"\nblock of 5.9s: scale rounded up -> {up[0].item():.2f}, "
      f"rounded down (clamped) -> {dn[0].item():.2f}   (true 5.90)")
download and run it

What you can now say

  • why 0.1 + 0.2 misses, and which 3 numbers were never stored
  • how to convert 0.1 to binary by hand, and why its bits repeat
  • what the exponent and mantissa each buy, what the bias is for, and why the leading 1 is never stored
  • how to read any format's map: points per interval, step per interval, ulp, ceiling, floor, ramp
  • every format's card: where it came from, where it runs, and the number at which it fails
  • the whole family on one sheet: how the bits split between reach and digits, from float64's 2,098 doublings to FP4's 3.6
  • why bfloat16 is float32's top 16 bits, at the bit level
  • what absorption, cancellation and order-dependence do to real code, and which repair fits which
  • every epsilon in a transformer, and the one-line softmax slide
  • why training keeps a float32 master copy, and what loss scaling actually moves
  • why FP8 is a pair, what a block scale is, why E8M0 has no mantissa, and why FP4 cannot live without blocks
  • what stochastic rounding and Hadamard rotations buy at 4 bits
  • why INT4, FP4 and NF4 put their 16 points in different places
  • why two GPUs never agree to the last bit, why even a compiler's fusion choice can change a program's bits, and why neither is a bug

Try it yourself

Predict first, then run, then explain the difference: p0 the two opening questions; p1 your own histogram; p2 all 256 of E4M3; p4 absorption and cancellation; p5 order; p6 the cliff; p7 the fork; p8 the vanished update; p9 the slide; p10 the spike; p11 blocks; p12 stochastic rounding; p13 the rotation; p14 the three grids; p16 the four cards from two numbers; p17 all 65,536 bfloat16 patterns; p18 the family sheet; p19 one NVFP4 block by hand; p20 all 256 E5M2 patterns; p21 1 rounding against 2; p22 the error, caught exactly; p23 rounding twice. On paper: how many doublings separate E5M2's floor from its ceiling, and would the p1 gradients fit without a scale? And before running p16: derive float16's ceiling from E=5, M=10 yourself, the way the binary32 section taught you.

References

[1] Khalilli, proof scripts for this page, measured on an Apple M3 Max, torch 2.11.0, CPU, 2026. Linked in place above; rerun them to check me.

[2] Sanglard, Floating Point Visually Explained, 2017. The interval-and-position way of seeing the fields comes from here. https://fabiensanglard.net/floating_point_visually_explained/

[3] IEEE, 754-2019: Standard for Floating-Point Arithmetic (first edition 1985).

[4] Goldberg, What Every Computer Scientist Should Know About Floating-Point Arithmetic, ACM Computing Surveys, 1991; and Muller et al., Handbook of Floating-Point Arithmetic, 2nd ed., 2018.

[5] Micikevicius et al., Mixed Precision Training, ICLR 2018. https://arxiv.org/abs/1710.03740

[6] Micikevicius et al., FP8 Formats for Deep Learning, 2022. https://arxiv.org/abs/2209.05433

[7] DeepSeek-AI, DeepSeek-V3 Technical Report, 2024. https://arxiv.org/abs/2412.19437

[8] Dettmers et al., LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale, 2022 (the emergent-outlier measurement). https://arxiv.org/abs/2208.07339

[9] Open Compute Project, Microscaling Formats (MX) Specification v1.0, 2023; and Rouhani et al., Microscaling Data Formats for Deep Learning, 2023. https://arxiv.org/abs/2310.10537

[10] NVIDIA, Pretraining Large Language Models with NVFP4, 2025. https://arxiv.org/abs/2509.25149

[11] Mishra et al., Recipes for Pre-training LLMs with MXFP8, 2025 (the scale-rounding result). https://arxiv.org/abs/2506.08027

[12] Tseng et al., Training LLMs with MXFP4, 2025. https://arxiv.org/abs/2502.20586

[13] Dettmers et al., QLoRA: Efficient Finetuning of Quantized LLMs, 2023 (NF4). https://arxiv.org/abs/2305.14314

[14] Frantar et al., GPTQ, 2022. https://arxiv.org/abs/2210.17323

[15] Lin et al., AWQ: Activation-aware Weight Quantization, 2023. https://arxiv.org/abs/2306.00978

[16] Dettmers et al., 8-bit Optimizers via Block-wise Quantization, 2021. https://arxiv.org/abs/2110.02861

[17] Gustafson and Yonemoto, Beating Floating Point at its Own Game: Posit Arithmetic, 2017; logarithmic number systems survey in Muller et al. [4].

[18] Severance, An Interview with the Old Man of Floating-Point (William Kahan on IEEE 754 and the Intel 8087), 1998. https://people.eecs.berkeley.edu/~wkahan/ieee754status/754story.html

[19] NVIDIA, CUDA C++ Programming Guide, the arithmetic instructions throughput table (per-architecture float64 rates). https://docs.nvidia.com/cuda/cuda-c-programming-guide/

[20] Kharya, TensorFloat-32 in the A100 GPU Accelerates AI Training, HPC up to 20x, NVIDIA blog, 2020. https://blogs.nvidia.com/blog/tensorfloat-32-precision-format/

[21] PyTorch documentation, CUDA semantics (the TF32 flags and their defaults since 1.12). https://docs.pytorch.org/docs/stable/notes/cuda.html

[22] Industrial Light & Magic, About OpenEXR (the half type, 2000-2003, and its Cg compatibility); and Bogart, Kainz, Hess, The OpenEXR Image File Format, GPU Gems, 2004. https://openexr.com/en/latest/about.html

[23] Wang and Kanwar, BFloat16: The secret to high performance on Cloud TPUs, Google Cloud blog, 2019. https://cloud.google.com/blog/products/ai-machine-learning/bfloat16-the-secret-to-high-performance-on-cloud-tpus

[24] Ku, Poli et al. (Radical Numerics), NVFP4 pretraining: from theory to implementation, Part 1, 2026. The recipe walk-through this page's NVFP4 worked block follows. https://www.radicalnumerics.ai/blog/nvfp4-part1

[25] Easygoing, Which is Better: FP8_scaled or MXFP8? A Thorough Comparison of Image Generation AI Model Accuracy and Speed, AI Image Journey, 2026. The measured quality ranking and the measured slowdown of MXFP8 on hardware without MX support. https://note.com/ai_image_journey/n/n99d0ed2f1c1d

[26] ZeroEntropy, MXFP4 (concepts), 2026. The per-vendor native-support summary. https://zeroentropy.dev/concepts/mxfp4/

[27] Boehm, Can Function Inlining Affect Floating Point Outputs? Exploring FMA and Other Consistency Issues, 2023. https://siboehm.com/articles/23/Inlining-FMA-FP-consistency

[28] PyTorch documentation, Numerical accuracy. https://docs.pytorch.org/docs/stable/notes/numerical_accuracy.html

[29] Kwon et al., Efficient Memory Management for Large Language Model Serving with PagedAttention (the vLLM paper), 2023. https://arxiv.org/abs/2309.06180

[30] NVIDIA, H100 Tensor Core GPU specifications (HBM3 bandwidth and PCIe generation 5 rates). https://www.nvidia.com/en-us/data-center/h100/

Three good things to read after this page: Goldberg's paper [4] slowly, with the map in hand; the OCP MX specification [9], which is short and readable; and Kahan's interview on the making of IEEE 754 [18], where the standard's designer tells the story himself.