Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A GPU operation gets faster after you switch it to a smaller number format. Then one request returns a plausible vector with a 98% error. Nothing crashes. Every value is finite. The narrow input format clipped a spike before the wider accumulator ever saw it.
The previous GEMM lesson tuned a general matrix multiplication (GEMM) while keeping operand, accumulator, and output types separate. The mixed-precision training lesson showed how scaling keeps gradients inside a 16-bit format's range. Here, those ideas become an explicit numeric contract for each tensor and kernel.
That incident separates low-precision programming from changing a dtype argument. A production path needs four compatible contracts:
- Encoding: which bit patterns represent finite values, zeros, infinities, and NaNs?
- Scaling: which values share a scale, when is it measured, and how is it stored?
- Arithmetic: which format enters the multiplier, accumulates partial sums, and stores the epilogue?
- Kernel application binary interface (ABI): which layouts, alignments, architecture targets, and scale orientations does the implementation accept?
The running operation is a small . has two rows of 32 activations. One row stays near to ; the other reaches . is a 32-element vector. We'll compare 8-bit floating-point (FP8) inputs with a 32-bit floating-point (FP32) accumulator. This setup is small enough to audit every conversion but has the same failure mode as a large projection: one global scale lets the large row erase detail in the small row.
A tensor-core kernel accepts FP8 inputs and accumulates into FP32. Does FP32 accumulation guarantee a correct answer?
Answer
No. FP32 accumulation preserves the products that reach it, but it can't recover values already rounded to zero or clipped during FP8 conversion. Input scaling and conversion semantics remain part of correctness.
Read formats as range plus spacing
A format name says how many exponent and fraction bits are available. More exponent bits buy dynamic range. More fraction bits buy precision near a fixed magnitude. The table compares FP32, NVIDIA TensorFloat-32 (TF32), bfloat16 (BF16), IEEE half precision (FP16), and two FP8 encodings.
| Format | Stored layout | Largest finite magnitude | Smallest positive normal | Typical GPU role |
|---|---|---|---|---|
| FP32 | 1 sign, 8 exponent, 23 fraction | about | about | reference math, accumulation, sensitive reductions |
| TF32 | FP32 storage; multiplier reads 8 exponent and 10 fraction bits | FP32 range | FP32 normal range | faster FP32 matrix math on supported NVIDIA tensor cores |
| BF16 | 1 sign, 8 exponent, 7 fraction | about | about | broad-range activations, weights, gradients |
| FP16 | 1 sign, 5 exponent, 10 fraction | 65,504 | about | higher local precision, narrower range |
| FP8 E4M3 | 1 sign, 4 exponent, 3 fraction | 448 | forward tensors when precision matters more than range | |
| FP8 E5M2 | 1 sign, 5 exponent, 2 fraction | 57,344 | gradients or tensors needing more range |
The Open Compute Project (OCP) 8-bit floating-point specification, shortened to OFP8, defines these two encodings. OFP8 E4M3 reaches subnormals down to and uses its extreme exponent patterns for finite numbers, leaving two NaN encodings and no infinity. OFP8 E5M2 reaches subnormals down to and retains IEEE-style infinities and NaNs.[1]
Two comparisons prevent common design mistakes:
- FP16 versus BF16: both store 16 bits. FP16 has three more fraction bits, while BF16 inherits FP32's eight exponent bits. FP16 resolves nearby values more finely; BF16 survives much larger and smaller magnitudes.
- TF32 versus a tensor dtype: TF32 is an NVIDIA compute mode for FP32 matrix operations, not an 11-bit storage type. FP32 tensors enter a tensor-core multiplier with reduced fraction precision, while products normally accumulate in FP32. Current PyTorch documentation exposes explicit TF32 precision controls and notes that defaults differ between matrix multiplication and convolution.[2]
Roundoff grows with magnitude. Around 1, E4M3 spacing is ; around 256, its spacing is 32. Scaling moves values into a useful part of that nonuniform grid.
Follow one value through the kernel
For each operand, use scale and encode
where is the low-precision format. The GEMM then computes products from reconstructed values and accumulates them in the declared accumulator type:
The output cast is another rounding boundary. A BF16 epilogue can hide improvement gained from an FP32 accumulator if the next operator only receives BF16.

Treat every arrow as owned state. If a framework exports QX but omits SX, another runtime sees codes without their units. If it keeps a rowwise scale but launches a kernel expecting tensorwise scale, the bytes are valid and the numbers are wrong.
Why is an FP8 tensor plus its scale closer to a typed value than the FP8 bytes alone?
Answer
The FP8 code q represents x divided by s. Reconstructing the application value requires x_hat = s times q, so scale, granularity, and axis are part of the tensor's numeric meaning.
Conversion has three visible failure modes
Saturation handles values beyond the finite range. Under OFP8 saturating conversion, 500 cast directly to E4M3 becomes 448. Under non-saturating conversion, an E4M3 overflow becomes NaN, while E5M2 overflow becomes infinity. CUDA's FP8 conversion API exposes saturation modes and round-to-nearest-even behavior.[1][3]
Underflow handles values below the smallest subnormal. OFP8 conversion rounds them to signed zero. Values between the smallest normal and smallest subnormal can survive as subnormals, but with reduced significant precision. Hardware may flush some denormals. PyTorch documents a specific ROCm caveat: on MI200-class products, FP16 and BF16 V_DOT2 and matrix fused multiply-add instructions flush input and output denormals; its other supported AMD GPUs don't have this behavior.[1][2]
Rounding chooses a nearby representable value. OFP8 conversion requires round-to-nearest, ties-to-even. The rule is deterministic, but repeated casts can still bias a computation when values repeatedly land on one side of a grid boundary.[1]
Clipping telemetry and NaN checks observe different failures. Saturation can produce a finite but badly wrong result. A health check that only counts NaNs misses it.
Worked failure: stale scale, finite answer
Suppose delayed E4M3 scaling uses yesterday's amax of 10:
Today, a value spikes to 500. Its scaled magnitude is 22,400, so saturating conversion clamps it to 448. Reconstruction returns
The value is finite and has the right sign, but its relative error is 98%. FP32 accumulation faithfully sums the wrong product.
Scale granularity sets who competes for range
| Granularity | Scale count for | Benefit | Cost or risk |
|---|---|---|---|
| Tensorwise | 1 | minimal metadata and simple kernels | one outlier controls every value |
| Rowwise | separates activation rows or tokens | axis-specific layout and scale loads | |
| Columnwise | useful when contraction or output channels differ | isn't interchangeable with rowwise packing | |
| Groupwise | roughly | localizes outliers within groups of size | more metadata and indexing work |
| MXFP8 blockwise | one E8M0 scale per 32 elements | standardized local power-of-two scale | block alignment, padding, and orientation constraints |
The best granularity follows the distribution and access pattern. Finer scaling reduces competition between outliers and small values, but kernels must fetch and apply more metadata. A theoretically accurate grouping can lose overall throughput if it breaks coalesced access or lacks a fused kernel.
Current scaling and delayed scaling
An amax is the largest absolute value in a scale group. That measurement is an observation, not a scale by itself.
Current scaling measures the tensor being converted, derives a scale, then casts it. NVIDIA Transformer Engine describes one FP32 scale per tensor for its current-scaling FP8 recipe. The extra amax pass means the input is read twice: once to reduce amax and again to scale and cast.[4]
Delayed scaling chooses today's scale from prior amax values, so a fused kernel can cast today's input while collecting an amax for a future step. An amax history may choose its maximum or its most recent sample. The saved read improves execution, but a sudden spike can clip under a stale scale. A stale historical maximum can cause the opposite problem: no clipping, yet common values occupy too little of the FP8 grid.[4]
Use the recipe name together with its state:
1fp8_contract = {
2 format: E4M3,
3 granularity: tensorwise,
4 scale_rule: current,
5 rounding: nearest_even,
6 overflow: saturate,
7 accumulator: FP32,
8 output: BF16
9}Transformer Engine's common hybrid recipe uses E4M3 for forward activations and weights, then E5M2 for gradients because gradients need more range. That convention is a recipe choice, not a property that forces every inference tensor into E4M3.[4][5]
A delayed-scaling run has no NaNs, but clip count jumps after a distribution shift. Which state should be inspected first?
Answer
Inspect the scale derived from amax history against the current amax. A stale scale can saturate new spikes into finite maximum values, so NaN count stays zero while error grows.
MXFP8 makes scale ownership part of the format
The OCP Microscaling (MX) specification describes an MX tensor as element values , a shared scale type , and block size . Microscaling FP8 (MXFP8) uses 32 E4M3 or E5M2 elements with one E8M0 scale per block. E8M0 stores a power-of-two exponent, so scale multiplication can be implemented as exponent adjustment.[6]
One 32-value block contains 256 element bits plus 8 scale bits, an effective payload of bits per value before padding or container overhead. Other standardized MX members trade more precision for density, including MXFP6, MXFP4, and MXINT8.[6]
MX changes the interface, not the need for a kernel contract:
- The contraction dimension is typically padded to a multiple of 32.
- Scale and element bytes need their specified physical layout.
- Rowwise and columnwise quantizations of a matrix are different numeric objects. NVIDIA's MXFP8 documentation warns that a packed rowwise tensor can't be transposed to obtain the columnwise representation; both orientations must be quantized independently from higher precision data.[4]
- The OCP general dot-product definition says its result should be FP32, while internal precision and operation order remain implementation-defined. Bitwise equality across kernels isn't implied.[6]
Run the two-row experiment
This exercise uses PyTorch's OCP E4M3 dtype. Tensorwise scaling uses the exact amax ratio. The MXFP8-like path uses one conservative power-of-two scale per 32-value row so every block maximum fits inside E4M3. OCP permits multiple scale-selection algorithms, so the exercise demonstrates granularity rather than claiming one universal MX encoder.
Predict what happens to 0.001 when it shares one scale with 500, then run the code.
1import math
2import torch
3
4torch.set_printoptions(precision=6, sci_mode=False)
5
6pattern_small = [0.001, 0.03, 0.117, 0.5, 1.0, -0.25, 0.0625, -0.015625]
7pattern_large = [4.0, 16.0, 64.0, 500.0, -2.0, -8.0, -32.0, -250.0]
8weight_pattern = [0.5, -0.25, 0.125, -0.0625, 0.03125, -0.5, 0.25, -0.125]
9
10x = torch.tensor([pattern_small * 4, pattern_large * 4], dtype=torch.float32)
11w = torch.tensor(weight_pattern * 4, dtype=torch.float32).reshape(32, 1)
12fp8_max = torch.finfo(torch.float8_e4m3fn).max
13
14def roundtrip(values: torch.Tensor, scale: torch.Tensor) -> torch.Tensor:
15 return (values / scale).to(torch.float8_e4m3fn).float() * scale
16
17tensor_scale_x = x.abs().max() / fp8_max
18tensor_scale_w = w.abs().max() / fp8_max
19x_tensor = roundtrip(x, tensor_scale_x)
20w_tensor = roundtrip(w, tensor_scale_w)
21
22def conservative_power_of_two_scale(block: torch.Tensor) -> float:
23 ratio = block.abs().max().item() / fp8_max
24 return 2.0 ** math.ceil(math.log2(ratio))
25
26mx_scales_x = torch.tensor(
27 [conservative_power_of_two_scale(row) for row in x], dtype=torch.float32
28).reshape(2, 1)
29mx_scale_w = torch.tensor(conservative_power_of_two_scale(w), dtype=torch.float32)
30x_mx = roundtrip(x, mx_scales_x)
31w_mx = roundtrip(w, mx_scale_w)
32
33reference_y = (x @ w).squeeze()
34tensor_y = (x_tensor @ w_tensor).squeeze()
35mx_y = (x_mx @ w_mx).squeeze()
36
37print(f"tensor_scale_x={tensor_scale_x.item():.6f}")
38print(f"mxfp8_scales_x={mx_scales_x.squeeze().tolist()}")
39print(
40 "x[0,0]: "
41 f"reference={x[0, 0].item():.8f} "
42 f"tensor_fp8={x_tensor[0, 0].item():.8f} "
43 f"mxfp8={x_mx[0, 0].item():.8f}"
44)
45print(
46 "x[1,3]: "
47 f"reference={x[1, 3].item():.8f} "
48 f"tensor_fp8={x_tensor[1, 3].item():.8f} "
49 f"mxfp8={x_mx[1, 3].item():.8f}"
50)
51print(f"reference_y=[{reference_y[0]:.6f}, {reference_y[1]:.6f}]")
52print(f"tensor_fp8_y=[{tensor_y[0]:.6f}, {tensor_y[1]:.6f}]")
53print(f"mxfp8_y=[{mx_y[0]:.6f}, {mx_y[1]:.6f}]")
54print(f"tensor_abs_error={(tensor_y - reference_y).abs().tolist()}")
55print(f"mxfp8_abs_error={(mx_y - reference_y).abs().tolist()}")1tensor_scale_x=1.116071
2mxfp8_scales_x=[0.00390625, 2.0]
3x[0,0]: reference=0.00100000 tensor_fp8=0.00000000 mxfp8=0.00097656
4x[1,3]: reference=500.00000000 tensor_fp8=500.00000000 mxfp8=512.00000000
5reference_y=[0.600812, 7.750000]
6tensor_fp8_y=[0.583104, 7.568359]
7mxfp8_y=[0.601562, 7.750000]
8tensor_abs_error=[0.017708778381347656, 0.181640625]
9mxfp8_abs_error=[0.0007500052452087402, 0.0]The tensorwise scale is about 1.116, so the smallest nonzero reconstructed E4M3 value is . The first activation, 0.001, falls below half that first step and rounds to zero. Its row loses almost 3% in the final dot product.
The first block's power-of-two scale is . Its smallest subnormal reconstructs as , so 0.001 survives as 0.00097656. The large row gets its own scale of 2.0, but its 500 still rounds to 512. The second dot product happens to be exact because positive and negative rounding errors cancel for this weight vector, not because block scaling preserves every operand.

The result doesn't prove MXFP8 always wins. It isolates one mechanism. On a distribution with uniform magnitude, per-block metadata may add traffic without enough accuracy benefit. Measure error and kernel throughput together.
Accumulation is a separate precision decision
A multiply format and an accumulator format solve different problems. FP8 or FP16 operands reduce storage and can expose higher-throughput tensor-core paths. FP32 accumulation reduces loss while many products are added.
FP32 accumulation still has limits:
- Products are formed from already rounded inputs.
- Some libraries permit reduced-precision reductions for FP16 or BF16 GEMMs on supported hardware. PyTorch exposes flags to disable those paths when numerical error matters more than speed.[2]
- Split-K or parallel reductions change addition order. Floating-point addition isn't associative, so two correct kernels may differ in low bits.
- Bias, activation, residual addition, and output cast can each introduce another precision boundary.
Record a full signature such as E4M3 x E4M3 -> FP32 accumulate -> BF16 output. Saying "FP8 GEMM" leaves the most important correctness choices unstated.
Hardware support is a dated claim
The table below is a documentation snapshot verified 2026-08-29. Architecture capability, installed toolkit, framework release, and chosen kernel all have to agree.
| Ecosystem | Documented architecture support in this snapshot | Boundary to keep explicit |
|---|---|---|
| NVIDIA TF32 | Ampere and later in current PyTorch guidance | enabled state and library heuristic affect whether a specific operation uses TF32 |
| NVIDIA FP8 | CUDA capability tables list FP8 from compute capability 8.9; Transformer Engine documents FP8 on Ada, Hopper, and Blackwell | a dtype object or device capability doesn't promise every operator has an FP8 kernel |
| NVIDIA MXFP8 | Transformer Engine documents native MXFP8 on Blackwell SM 10.0 and 10.3 | block layout, dual orientation, and recipe support are kernel-specific |
| AMD FP8 | ROCm lists matrix-core FP8 on CDNA3, CDNA4, and RDNA4 families | MI300-class CDNA3 paths use FNUZ; MI350-class CDNA4 paths use OCP FP8; RDNA4 and library support need path-specific checks |
CUDA's current programming guide supplies the NVIDIA capability table.[7] AMD's current precision matrix names example products and separates language types, matrix-core support, and library support.[8]
Interoperability needs particular care on AMD. FNUZ E4M3 and E5M2 encodings differ from OCP variants in special values and signed-zero behavior. Identical raw bytes can mean different numbers. PyTorch exposes OCP-style and FNUZ dtype names, plus E8M0 and packed low-bit types, but its own dtype documentation warns that shell dtype availability doesn't imply broad operator support.[9]
Write the kernel contract before choosing the kernel
Use a contract review that another engineer could implement without guessing:
| Field | Example decision | Failure when omitted |
|---|---|---|
| Logical shapes | , , | wrong padding or tail handling |
| Element encodings | OCP E4M3 for both operands | OCP/FNUZ byte mismatch |
| Scale semantics | E8M0, one per 32 contiguous values | wrong axis or stale metadata |
| Operand layouts | row-major , kernel-packed | valid allocation, scrambled values |
| Scale layouts | rowwise , columnwise | transposed scale association |
| Accumulator | FP32 | unexplained reduction drift |
| Epilogue | FP32 bias, GELU, BF16 store | hidden output rounding |
| Overflow and rounding | saturate, nearest-even | NaN versus clamp disagreement |
| Alignment | pointer, stride, and tile constraints | fallback, fault, or slow path |
| Architecture | compiled target and minimum runtime | unsupported instruction or silent fallback |
Treat framework, compiler, and kernel boundaries as serialization boundaries. Pass dtype tags, scales, orientation, padding, and accumulator mode together. Reject ambiguous inputs at dispatch rather than inferring them from tensor shape.
A framework exposes float8_e4m3fn, and the target GPU advertises FP8 instructions. Is that enough to dispatch an arbitrary FP8 operator?
Answer
No. Dtype registration and instruction support are necessary but not sufficient. The framework still needs a kernel for the exact operation, layout, scale recipe, accumulator, architecture, and runtime version.
Diagnose the numeric boundary
Start from the first boundary where the low-precision path diverges from an FP32 reference.
| Symptom | Likely boundary | Targeted probe | Typical correction |
|---|---|---|---|
| Raw FP8 codes pinned at ±448 | E4M3 conversion | clip count and pre-cast amax | refresh scale or use finer granularity |
| Many exact zeros in one row | scale grouping or underflow | zero rate by row/block before GEMM | rowwise or blockwise scale |
| NaNs appear only with non-saturating cast | overflow policy | compare saturating and non-saturating conversions | specify policy and fix range |
| Error starts after reduction | accumulator or split-K order | store FP32 partials and compare reduction modes | wider reduction or stable algorithm |
| Correct on one vendor, wrong on another | encoding ABI | decode known byte patterns, especially zero/NaN | convert OCP and FNUZ explicitly |
| Correct values, disappointing speed | layout or kernel dispatch | profiler kernel name, bytes moved, fallback log | pack expected layout or choose supported recipe |
| MX result breaks after transpose | scale orientation | compare fresh columnwise quantization | quantize each required orientation independently |
A useful incident capture includes input histograms, amax per scale group, selected scales, clip and zero counts, accumulator mode, output error against FP32, actual kernel symbol, and device/runtime versions. A single aggregate relative error hides where the contract failed.
Ship low precision as a measured contract
Low-precision GPU code is safe when every lossy boundary is named and observed:
- Pick format from range and spacing, not bit width alone.
- Make scale value, granularity, axis, update rule, and history explicit.
- Count saturation and underflow even when outputs remain finite.
- Separate operand encoding from accumulator and epilogue precision.
- Version hardware claims and verify actual kernel dispatch.
- Compare accuracy and throughput with the same shapes, layouts, and workload distribution used in serving.