Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A value of 500 goes into an FP8 cast and comes back as 10. No exception fires, no infinity gets written, and no NaN flag lights up. The scale chosen for an earlier batch made the new spike too large to encode, so the converter quietly clamped it to the format ceiling. The consumer GPU kernel then accumulates that corrupted input in 32-bit floating point without recovering a single bit of lost information.
Hardware multipliers explain why everyone wants smaller types: multiplier silicon area and dynamic power scale quadratically () with mantissa bit width . Dropping from 32-bit IEEE floats to 16-bit, 8-bit, or 4-bit numbers quadruples raw matrix throughput (TFLOPS) on Tensor Cores while cutting memory traffic across high-bandwidth memory (HBM) and SRAM in half.
Shrinking bits isn't as simple as flipping a dtype argument in PyTorch. Doing low-precision math safely demands four explicit contracts:
- Encoding: which exact bit patterns represent normal numbers, subnormals, zeros, infinities, and NaNs?
- Scaling architecture: which values share a scale factor, when is that scale measured, and where does it live in memory?
- Arithmetic and accumulation: which precision enters the multiplier ALUs, which type accumulates dot products, and where does dequantization happen?
- Kernel application binary interface (ABI): which layouts, byte alignments, architecture targets, and swizzle patterns does the hardware instruction require?
Our running operation throughout this chapter is a compact projection . Matrix contains two rows of 32 activations. Row 0 holds delicate, small activations between and , typical of a residual stream or normalized state. Row 1 holds an activation spike reaching , typical of an attention out-projection or Mixture-of-Experts (MoE) routing bottleneck. Vector is a 32-element weight column. We'll trace these numbers through every stage of low-precision execution to see where numeric contracts hold and where they break.
A tensor-core kernel accepts FP8 inputs and accumulates into FP32 registers. Does FP32 accumulation guarantee a correct answer?
Answer
No. FP32 accumulation preserves products that reach the accumulator, but it can't recover information already rounded to zero or clipped during input conversion. Input scaling and conversion semantics remain part of numerical correctness.
Format taxonomy: range, spacing, and bitfields
A floating-point format name reveals how many exponent and fraction bits it provides. Exponent bits determine dynamic range: the distance between the smallest subnormal and the largest finite number. Fraction bits (the mantissa) determine precision: the spacing between representable numbers within any given binary octave.
| Format | Stored layout | Exponent bias | Largest finite value | Smallest positive normal | Typical GPU role |
|---|---|---|---|---|---|
| FP32 | 1 sign, 8 exponent, 23 fraction | 127 | about | about | reference math, accumulation, sensitive reductions |
| TF32 | FP32 storage (reads 8 exponent, 10 fraction) | 127 | about | about | faster single-precision matrix math on NVIDIA Tensor Cores |
| BF16 | 1 sign, 8 exponent, 7 fraction | 127 | about | about | broad-range activations, weights, gradients |
| FP16 | 1 sign, 5 exponent, 10 fraction | 15 | 65,504 | about | high local precision, narrow dynamic range |
| FP8 E4M3 | 1 sign, 4 exponent, 3 fraction | 7 | 448 | forward activations and weights | |
| FP8 E5M2 | 1 sign, 5 exponent, 2 fraction | 15 | 57,344 | backward gradients and wide-range tensors |
The Open Compute Project (OCP) 8-bit floating-point specification, known as OFP8, standardizes both FP8 encodings.[1] Each format targets a distinct numerical regime:
- Why E4M3 rules forward activations and weights: Forward activations in transformer architectures are normalized by LayerNorm or RMSNorm. Values generally stay inside a bounded range like . Huge exponent headroom is wasted here; fraction bits are what keep quantization noise low. E4M3 provides 3 fraction bits (8 representable significands per power-of-two octave), giving a relative resolution of . That cuts rounding variance in half compared to E5M2. Because range is limited, OFP8 E4M3 repurposes the extreme exponent pattern
1111for finite values, reaching 448 with no infinities and only two NaN bit patterns (0x7Fand0xFF). - Why E5M2 rules backward gradients: Gradients during training fluctuate wildly across layers, attention heads, and training steps, spanning eight or more orders of magnitude ( to ). If gradients run into an underflow wall, weight updates vanish into exact zeros. E5M2 provides 5 exponent bits, preserving a dynamic range up to 57,344 and down to subnormals of . Stochastic gradient descent naturally tolerates coarse 2-bit mantissa precision, giving dynamic range priority over fraction resolution.[2]
Beyond 8-bit floats sits the sub-8-bit frontier:
- OCP Microscaling (MX) formats: Instead of giving every number its own large exponent, a block of elements (typically 32 contiguous values) shares a single 8-bit scale factor (
E8M0). Individual elements can then shrink to MXFP8 (E4M3 or E5M2), MXFP6 (E3M2 with max finite 28, or E2M3 with max finite 7.5), or MXFP4 (E2M1 with max finite 6).[3] - NVIDIA Blackwell NVFP4: Blackwell architectures introduce 5th-generation Tensor Cores that execute FP4 (E2M1) matrix math natively. NVFP4 uses dual-level microscaling: a 16-element or 32-element vector shares an FP8 / E8M0 microscopic scale factor, which is in turn multiplied by a per-tensor macro scale. This cuts memory footprint for weights and the KV cache by compared to FP8, while doubling Tensor Core math throughput.[4]
Rounding error grows in direct proportion to value magnitude. Around 1.0, E4M3 grid spacing is ; around 256, spacing balloons to 32. Scaling moves application values into the sweetest part of that non-uniform grid.
Scale ownership and the GEMM dataflow
Let's derive the exact mathematics of how scale factors travel through a matrix multiplication. Consider a standard GEMM , with activation matrix and weight matrix .
Every element is represented by a positive scale factor and a quantized low-precision code:
Here and are low-precision FP8 or FP4 values, while and are higher-precision (FP32 or E8M0) scale factors. Substitute these into the dot product definition for output element :
Because row scale and column scale don't depend on the contraction index , both scales factor completely out of the inner sum:
This algebraic identity forms the basis of hardware-accelerated low-precision execution. Work divides into two phases:
- Tensor Core Multiply-Accumulate: The inner sum executes entirely in fast, low-precision hardware ALUs, accumulating products into wide 32-bit FP32 registers.
- Epilogue Dequantization: Dequantization scale is applied in the kernel's epilogue stage right before writing the final result back to global memory or piping it into a fused bias and activation function.

This raises a critical architecture question: who owns quantization and dequantization?
- Quantization is owned by the PRODUCER: The kernel generating the tensor (such as RMSNorm, SiLU, or the prior layer's GEMM epilogue) measures range, computes scale factor , casts values into low-precision codes, and packs both codes and scales into global memory or shared memory (SRAM).
- Dequantization is NEVER a standalone memory-to-memory kernel: Writing an FP32 dequantized tensor back out to DRAM would waste the memory bandwidth and completely erase the speedup of low precision. Dequantization is owned by the CONSUMER GEMM, fused directly into register ALUs during accumulation or epilogue store.
If scaling happens block-wise along the contraction axis (as in 32-element microscaling with block index ):
The scale product stays inside the outer reduction loop. The Tensor Core accumulates 32-element partial dot products in FP32, multiplies by the block scale product, and sums across blocks.
Numerical hazards: subnormals, saturation, and rounding
Running low-precision code exposes three distinct numerical pitfalls. Understanding how they manifest keeps models from silently losing fidelity.
Subnormals and flush-to-zero (FTZ)
Normal floating-point numbers have an implicit leading 1 (). Subnormal numbers occur when the exponent bits are all zero; the leading bit drops to 0 (). This allows values to shrink smoothly toward zero, albeit with progressively fewer significant bits.
In OCP E4M3, the smallest positive normal is . Subnormals extend down to . Under standard round-to-nearest-even (RNE):
- An input of rounds up to the smallest subnormal .
- An input of lands on an exact tie and rounds down to signed zero (
+0.0or-0.0).
Hardware can introduce another trap: flush-to-zero (FTZ) mode. Some fast matrix instructions or compiler flags flush all subnormals to zero in a single cycle to avoid multi-cycle normalization penalties. If a deep model's residual signals or attention logits drift into the subnormal band, FTZ wipes them out, causing sudden gradient extinction.
Saturation cliffs
What happens when an unscaled or poorly scaled value exceeds the maximum representable magnitude? Under non-saturating conversion, an out-of-range E4M3 value turns into NaN, while an E5M2 value becomes infinity.
Under saturating conversion (the default for production FP8 paths), out-of-range values get clamped to the format limit: for E4M3, or for E5M2.[1][5]
Saturation creates an artificial flat cliff. A spike of clamps to ; a spike of also clamps to . This destroys relative magnitudes, and in backward passes, the derivative across a clamped plateau is zero, killing parameter updates. Worst of all, saturation produces valid, finite numbers. A production health check that only looks for NaNs and infinities will see clean telemetry while model generations degrade into nonsense.
Rounding: RNE versus stochastic rounding
Standard IEEE and OCP conversion mandates round-to-nearest, ties-to-even (RNE). RNE is deterministic, but it introduces the gradient stagnation trap during low-precision training.
Suppose an optimizer calculates a weight update . If is smaller than half of the least significant bit () of the weight representation, RNE rounds down to zero:
The weight never updates. Run training for 100,000 steps, and the weight remains frozen in place.
Stochastic rounding breaks this trap by rounding up or down probabilistically based on the distance to the adjacent representable points:
The mathematical expectation of stochastic rounding is strictly unbiased: . Even if an update equals just , it will round up of the time. Over thousands of iterations, small updates integrate into real weight changes, making training in low precision numerically viable without keeping separate FP32 master copies.
The mandatory FP32 accumulator contract
Never accumulate low-precision products in a narrow format. Consider an inner dimension of , common in modern LLMs. A single output activation is the sum of 4096 individual products.
If you accumulate in FP16, you hit the swamping boundary quickly. In FP16, once a running partial sum reaches , the gap between adjacent representable numbers is . Any individual product smaller than added to that running sum rounds down to zero (). By the middle of the dot product, the accumulator becomes completely deaf to incoming numbers. Accumulating in FP8 is catastrophic.
Hardware Tensor Cores enforce the Accumulator Contract: inputs are consumed in low precision (FP8, FP16, BF16), multiplication occurs at full or expanded precision, accumulation runs in dedicated 32-bit FP32 registers, and downcasting happens only during the final epilogue store.
Hands-on: auditing FP8 conversion boundaries
Let's test subnormal rounding, saturation clamping, and byte-level encoding differences using PyTorch on CPU.
In this script, notice how raw conversion behaves on CPU compared to explicit saturating conversion, and observe how identical raw bytes represent completely different numbers across OCP and FNUZ specifications.
1import torch
2
3print("PyTorch:", torch.__version__, "device: cpu")
4d = 2.0 ** -9
5values = torch.tensor([0.5 * d, 0.75 * d, -0.5 * d, 1.0625, 1.1875, 500.0])
6
7# Plain cast on CPU: out-of-range values produce NaN in non-saturating paths
8raw = values.to(torch.float8_e4m3fn).float()
9
10# Saturating conversion: clamps finite values to the E4M3 ceiling of 448
11saturated = values.clamp(-448.0, 448.0).to(torch.float8_e4m3fn).float()
12
13print("plain cast:", raw.tolist())
14print("saturating finite inputs:", saturated.tolist())
15print("negative half-step keeps sign:", bool(torch.signbit(raw[2])))
16
17assert raw[:5].tolist() == [0.0, d, -0.0, 1.0, 1.25]
18assert saturated[-1] == 448.0
19
20# The exact same byte pattern means different values across specs
21codes = torch.tensor([0x38, 0x80], dtype=torch.uint8)
22print("OCP E4M3 bytes:", codes.view(torch.float8_e4m3fn).float().tolist())
23print("FNUZ E4M3 bytes:", codes.view(torch.float8_e4m3fnuz).float().tolist())1PyTorch: 2.8.0 device: cpu
2plain cast: [0.0, 0.001953125, -0.0, 1.0, 1.25, nan]
3saturating finite inputs: [0.0, 0.001953125, -0.0, 1.0, 1.25, 448.0]
4negative half-step keeps sign: True
5OCP E4M3 bytes: [1.0, -0.0]
6FNUZ E4M3 bytes: [0.5, nan]Look at the byte decoding at the end: byte 0x38 decodes to 1.0 in OCP E4M3, but becomes 0.5 in AMD's FNUZ E4M3 because their exponent biases differ (7 versus 8). Byte 0x80 represents negative zero in OCP E4M3, but represents NaN in FNUZ because FNUZ drops signed zeros. Feeding an OCP-encoded buffer into a kernel expecting FNUZ silently alters standard numeric values throughout your model.[6][7]
Worked failure: stale delayed scale
Suppose delayed scaling calculates an E4M3 scale factor from a previous iteration's maximum absolute value ():
In the current iteration, an activation outlier spikes to . The scaled magnitude is:
Under saturating conversion, clamps directly to the format maximum of . During consumer dequantization, reconstruction returns:
The reconstructed value is finite, positive, and shows no NaN errors, but its relative error is . The FP32 accumulator faithfully adds the corrupted product into the running sum, masking the numerical collapse from monitoring systems.
Scaling architectures: granularity and delayed scaling
The granularity of your scale factor sets which values compete for the same dynamic range exponent.
| Granularity | Scale count for | Advantages | Vulnerabilities |
|---|---|---|---|
| Tensor-wide | 1 | minimal memory metadata (+4 bytes) | a single outlier token compresses all unrelated tokens |
| Row-wise (per-token) | isolates outliers to specific sequence tokens | requires row-major layout and per-row scale loading | |
| Column-wise (per-channel) | preserves channel-specific weight variance | can't be transposed into row-wise layout without requantizing | |
| Block-wise (microscaling) | localizes dynamic range to 32-element vectors | requires 32-byte alignment and padding |
The host-device latency dilemma
Calculating an online per-tensor scale requires finding across all elements before launching the GEMM:
If a framework calculates this scale on the host CPU, the GPU must write out the maximum, synchronize the CUDA stream, and copy the scalar over PCIe. That introduces a 10 to 50 microsecond pipeline bubble on every single layer.
If the GPU calculates the scale asynchronously using a separate reduction kernel, it avoids CPU synchronization, but it still pays a steep memory bandwidth penalty: the entire tensor must be read from global memory once to compute amax, and read again by the GEMM kernel.
Delayed scaling in Transformer Engine
NVIDIA Transformer Engine bypasses the reduction pass through delayed scaling.[8] Instead of measuring the current tensor, it sets the scale factor using an amax history window from prior iterations ():
While the GEMM executes at step , the kernel computes the actual as a side product in register memory and writes it out for future steps. This eliminates both the host synchronization bubble and the extra DRAM read pass.
The risk is scale lag. If an activation spike hits after a distribution shift, the historical scale is too small, causing massive saturation clipping. If the historical maximum was an unusual outlier, the scale remains too large, pushing typical activations down toward the subnormal floor.
Fused online scaling in DeepSeek-V3 and DeepGEMM
Modern open models take a different path. DeepSeek-V3 and its underlying DeepGEMM library reject delayed scaling entirely.[9][10]
Instead of lagging behind history, DeepSeek uses fine-grained block-level scaling ( activation tiles and weight tiles). The scale computation is fused directly into the Tensor Memory Accelerator (TMA) load and warp-specialized GEMM pipeline. Scales are evaluated in registers and shared memory as data arrives from global memory, completely avoiding both host synchronization and delayed scale clipping.
Microscaling: the two-row experiment
In OCP Microscaling, every 32-element vector shares an 8-bit scale factor (E8M0). E8M0 stores a pure power-of-two exponent with a bias of 127, encoding scales from up to . Multiplying by an E8M0 scale in hardware requires zero mantissa multiplication ALUs: it's a simple integer addition to the exponent field!
A 32-value block holds 256 bits of element data plus 8 bits of shared scale, giving an effective payload of:
Let's test our running two-row activation matrix on CPU. Row 0 holds delicate values starting at . Row 1 holds an activation spike of .
Predict what happens to when it shares one global scale with , versus when each row gets its own 32-element block scale:
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 if not torch.isfinite(values).all() or not torch.isfinite(scale).all() or (scale <= 0).any():
16 raise ValueError("finite values and positive finite scales required")
17 scaled = values / scale
18 return scaled.clamp(-fp8_max, fp8_max).to(torch.float8_e4m3fn).float() * scale
19
20# 1. Tensor-wide scaling: single scale derived from global maximum
21tensor_scale_x = x.abs().max() / fp8_max
22tensor_scale_w = w.abs().max() / fp8_max
23x_tensor = roundtrip(x, tensor_scale_x)
24w_tensor = roundtrip(w, tensor_scale_w)
25
26# 2. Block-level scaling: conservative power-of-two scale per 32-value row
27def conservative_power_of_two_scale(block: torch.Tensor) -> float:
28 ratio = block.abs().max().item() / fp8_max
29 if ratio == 0:
30 return 1.0
31 exponent = max(-127, math.ceil(math.log2(ratio)))
32 return 2.0 ** exponent
33
34mx_scales_x = torch.tensor(
35 [conservative_power_of_two_scale(row) for row in x], dtype=torch.float32
36).reshape(2, 1)
37mx_scale_w = torch.tensor(conservative_power_of_two_scale(w), dtype=torch.float32)
38
39x_mx = roundtrip(x, mx_scales_x)
40w_mx = roundtrip(w, mx_scale_w)
41
42reference_y = (x @ w).squeeze()
43tensor_y = (x_tensor @ w_tensor).squeeze()
44mx_y = (x_mx @ w_mx).squeeze()
45
46print(f"tensor_scale_x={tensor_scale_x.item():.6f}")
47print(f"mxfp8_scales_x={mx_scales_x.squeeze().tolist()}")
48print(f"x[0,0]: ref={x[0, 0].item():.8f} tensor_fp8={x_tensor[0, 0].item():.8f} mxfp8={x_mx[0, 0].item():.8f}")
49print(f"x[1,3]: ref={x[1, 3].item():.8f} tensor_fp8={x_tensor[1, 3].item():.8f} mxfp8={x_mx[1, 3].item():.8f}")
50print(f"reference_y=[{reference_y[0]:.6f}, {reference_y[1]:.6f}]")
51print(f"tensor_fp8_y=[{tensor_y[0]:.6f}, {tensor_y[1]:.6f}]")
52print(f"mxfp8_y=[{mx_y[0]:.6f}, {mx_y[1]:.6f}]")
53print(f"tensor_abs_error={(tensor_y - reference_y).abs().tolist()}")
54print(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]: ref=0.00100000 tensor_fp8=0.00000000 mxfp8=0.00097656
4x[1,3]: ref=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]Follow the numbers closely:
- Under tensor-wide scaling (): The smallest representable non-zero E4M3 step is . The small activation falls below half of this step and rounds straight to zero. The entire row loses nearly of its dot-product magnitude because an outlier in an unrelated row set the scale.
- Under block-level scaling (): The first block's scale shifts the dynamic range downward. Its smallest subnormal step becomes . The small value survives accurately as (just relative error).
- Outlier isolation in Row 1 (): Row 1 gets its own scale, so its outlier stays confined to its own 32-element chunk without destroying detail in Row 0.

Notice an essential detail in the microscaling specification: a packed row-wise MXFP8 matrix can't simply be transposed in memory to serve as a column-wise operand. In row-wise packing, every 32 elements along the axis share a scale. In column-wise packing, 32 elements along the axis share a scale. Transposing the byte buffer groups completely different numbers together under the same scale factor. Both orientations must be quantized independently from higher-precision data.[11]
Hardware support and kernel ABI contracts
Hardware claims must always be tied to specific GPU microarchitectures, compute capabilities, and driver toolkits.
| Platform | Documented support status | Boundary to verify |
|---|---|---|
| NVIDIA TF32 | Ampere (SM 8.0) and newer | enabled by default in PyTorch for matrix math; can be toggled via torch.backends.cuda.matmul.allow_tf32 |
| NVIDIA FP8 | Ada Lovelace (SM 8.9), Hopper (SM 9.0), Blackwell (SM 10.0) | hardware instructions exist, but specific operator support depends on Transformer Engine or CUTLASS recipes |
| NVIDIA NVFP4 | Blackwell (SM 10.0, SM 10.3) | requires 5th-gen Tensor Cores and 2nd-gen Transformer Engine dual-level scaling |
| AMD FP8 | CDNA3 (MI300), CDNA4 (MI350), RDNA4 | MI300 uses FNUZ encodings; MI350 paths use OCP encodings; raw byte interchange between them is invalid |
Before launching a low-precision kernel in production, write out its complete numeric contract:
1kernel_contract = {
2 operation: GEMM_Y_equals_XW,
3 operand_x: { dtype: OCP_E4M3, layout: row_major, scale_granularity: per_token_1x32, scale_dtype: E8M0 },
4 operand_w: { dtype: OCP_E4M3, layout: col_major_packed, scale_granularity: per_channel_32x1, scale_dtype: E8M0 },
5 accumulator: FP32,
6 epilogue: { dequant_scale: fused_product, bias: FP32, activation: SiLU, output_dtype: BF16 },
7 rounding_mode: round_nearest_even,
8 overflow_policy: saturate,
9 alignment: 16_byte_aligned
10}Treat framework, compiler, and kernel boundaries as strict serialization interfaces. Passing dtype tags, scales, orientation, padding, and accumulator modes together prevents silent numerical drift.
Troubleshooting numerical failures
When low-precision outputs diverge from an FP32 reference run, use this diagnostic roadmap:
| Observed symptom | Root cause boundary | Diagnostic inspection | Targeted fix |
|---|---|---|---|
| Millions of activations equal | Saturation cliff | Histogram pre-cast float magnitudes vs format limits | Refresh scale factor or switch from tensor-wide to block scaling |
| Entire row contains exact zeros | Outlier swamping | Zero rate per row or block before the GEMM | Switch from per-tensor to per-token or block-level scaling |
| NaNs appear only in non-saturating mode | Unhandled overflow | Run side-by-side with explicit .clamp(-448, 448) | Verify whether caller expects OCP non-saturating NaN or saturating clamp |
| Numerical drift grows with sequence length | Accumulator precision | Check whether Tensor Core is set to FP16 accumulation | Force FP32 accumulator in compiler and framework flags |
| Numerical collapse when porting to AMD | OCP vs FNUZ ABI mismatch | Print bitwise hex representation of byte 0x38 | Explicitly transcode bytes between OCP and FNUZ biases |
| Output turns to noise after tensor transpose | Scale orientation violation | Compare dot products against fresh column-wise quantization | Quantize row-major and column-major representations independently |
A reliable incident trace must capture input histograms, amax per scale group, selected scale factors, clip counts, zero counts, accumulator modes, and the profiler-confirmed kernel symbol. Checking only the final output relative error hides where the contract failed.