Numerics discipline for low-precision inference¶
Most “my kernel is broken” bugs are actually numerics bugs. The kernel runs, produces plausible-looking numbers, and quietly diverges from the reference at long sequence lengths or specific input distributions. This document is the checklist that prevents 90% of those bugs.
The dtype landscape¶
Dtype |
Bits |
Range |
Precision |
Where it’s used |
|---|---|---|---|---|
FP32 |
32 |
~1e-38 to 3.4e38 |
7 decimal digits |
Accumulators, softmax state, master weights |
TF32 |
19 |
FP32 range |
10-bit mantissa |
Ampere+ tensor-core input (implicit) |
FP16 |
16 |
~6e-5 to 65k |
3-4 decimal digits |
Legacy training; still common at inference |
BF16 |
16 |
FP32 range |
2-3 decimal digits |
Modern default for LLMs. Same exponent as fp32 |
FP8 (E4M3) |
8 |
~2e-4 to 448 |
~2 digits |
H100+ tensor cores, weights + activations |
FP8 (E5M2) |
8 |
~6e-5 to 57344 |
~1 digit |
Gradients (wider range, less precision) |
NVFP4 |
4 |
~2e-4 to 6 |
tiny |
Blackwell weights (block scale 16 + FP32 second-level) |
MXFP4 |
4 |
~2e-4 to 6 |
tiny |
OCP standard, block scale 32 |
INT8 |
8 |
-128 to 127 |
linear |
Weight-only quant, activations in some setups |
INT4 |
4 |
-8 to 7 (or 0-15) |
linear |
Weight-only quant (GPTQ, AWQ, Marlin) |
The one rule that solves half of numerics bugs¶
Accumulate in FP32. Always. Inputs bf16, outputs bf16, accumulator fp32.
Tensor cores physically implement this: bf16×bf16 → fp32 accumulator, then cast back to bf16 on write. In Triton, tl.dot(a, b) defaults to fp32 accumulator when a and b are bf16. Don’t override this.
Corollaries:
Softmax state (
m,l) in fp32.Running output
oin fp32 until the final store.Any
sum/mean/varreduction in fp32, even if inputs are bf16.LayerNorm/RMSNorm reduction in fp32.
Only the final tensor written to HBM should be the target dtype.
Tolerances — what “close enough” means¶
Operation |
Dtype pair |
atol |
rtol |
|---|---|---|---|
GEMM (single) |
bf16 in, bf16 out, fp32 acc |
1e-2 |
1e-2 |
GEMM chain (attention) |
bf16 in, bf16 out |
5e-3 |
5e-3 |
RMSNorm |
bf16 in, bf16 out |
1e-3 |
1e-3 |
Softmax |
bf16 in, bf16 out |
1e-3 |
1e-3 |
Attention forward |
bf16 in, bf16 out |
1e-2 |
1e-2 |
Attention forward |
fp8 in, bf16 out |
5e-2 |
5e-2 |
FP32 references |
fp32 |
1e-5 |
1e-5 |
These are guidelines, not absolutes. Longer sequences and larger tensors accumulate more rounding error. If you’re inside these bounds you probably have no numerics bug. If you’re outside them, you almost certainly do.
PyTorch verification pattern¶
import torch
def verify(ours, ref, atol=1e-2, rtol=1e-2, name="kernel"):
diff = (ours.float() - ref.float()).abs()
max_abs = diff.max().item()
max_rel = (diff / (ref.float().abs() + 1e-6)).max().item()
print(f"[{name}] max_abs={max_abs:.4e} max_rel={max_rel:.4e}")
torch.testing.assert_close(ours, ref, atol=atol, rtol=rtol)
Always print max_abs and max_rel even when the test passes — they’re your regression signal.
Failure smells — what NaN patterns tell you¶
Symptom |
Likely cause |
|---|---|
All-NaN output |
|
First row NaN, rest OK |
Fully-masked first row in causal attention (esp. sliding window at pos 0) |
NaN appears at N > 4k, fine at N < 2k |
fp32 accumulator missing; bf16 overflow in |
Output all zeros |
Wrong scale applied; mask value 0 instead of -inf; softmax denominator = inf |
Correct at atol=1e-1 but not 1e-2 |
Softmax scale ordering (post-multiply instead of pre-multiply Q); loss of a ULP |
Correct on H100 but wrong on A100 |
TF32 vs FP32 mismatch: A100’s tensor cores use TF32 by default for fp32 matmul; force fp32 or match TF32 explicitly |
Correct with |
Mask direction wrong or mask value = 0 |
Nondeterministic (differs run-to-run) |
Some reduction in bf16 accumulator (bf16 add is non-associative and warp scheduling is nondeterministic) |
The FP8 rules (Hopper/Blackwell)¶
FP8 is the first dtype where naive casts give bad results. Key rules:
Use E4M3 for weights & activations, E5M2 for gradients (which you don’t care about at inference). E4M3 has more precision at the expense of range; use it where clipping isn’t a problem.
Always block-quantize. Global scale × global tensor loses too much accuracy. Per-tile or per-channel scaling is the norm. FP8 activations in FA3 use block quantization along the sequence dim.
Delayed scaling for activations (during training) — use a moving max. For inference, static per-tensor scale calibration works.
Incoherent processing / Hadamard rotation (from FA3): pre-rotate Q, K, V by a random orthogonal transform. Spreads outliers, reducing quant error 2–3×. See FA3 §3.
Accumulator stays fp32. FP8 tensor cores emit fp32; you cast to bf16 or fp16 on the final write.
FP8 tolerance: atol=5e-2, rtol=5e-2 is typical. If you can hold 1e-2 you’re doing something impressive.
The FP4 rules (Blackwell)¶
FP4 (NVFP4 or MXFP4) is currently weight-only. Activations stay bf16 / fp8.
NVFP4: block size 16, per-block E4M3 scale, plus per-tensor FP32 scale. Tighter block → better accuracy.
MXFP4: block size 32, OCP standard. More portable but coarser.
Dequantize on-chip. FP4 weights are loaded from HBM as packed uint8 (2 weights per byte), dequantized in registers with the per-block scale, consumed directly by tensor cores.
Model accuracy loss should be < 1% on standard LM eval tasks if calibration is done properly. If you’re losing > 3%, your calibration set is bad or quantization is applied to layers that shouldn’t be quantized (e.g., LM head, layernorm weights).
Reference: NVIDIA NVFP4 blog: https://developer.nvidia.com/blog/introducing-nvfp4-for-efficient-and-accurate-low-precision-inference
Determinism trade-off¶
BF16 add is non-associative: (a + b) + c ≠ a + (b + c) at the last bit. Combined with nondeterministic warp scheduling, kernel outputs can differ by 1–2 ULP run-to-run.
When determinism matters (regression tests, reproducibility research):
Set
torch.use_deterministic_algorithms(True)Force
CUBLAS_WORKSPACE_CONFIG=:4096:8Accept the (usually small) perf hit
When it doesn’t (production serving): allow nondeterminism. The variance is smaller than the model’s inherent noise.
The 5 numerics debugging tactics that actually work¶
Bisect on shape. If broken at N=8192, works at N=512, the bug scales with N. Almost certainly accumulator precision.
Bisect on input. Replace random inputs with all-ones. If bug persists, it’s algorithmic. If bug vanishes, it’s input-distribution-sensitive (outliers, extreme values).
Print intermediate tensors. In Triton use
tl.static_print(compile-time) orTRITON_INTERPRET=1(runtime) to get printable state inside kernels.Reference in fp32. Compute the reference in fp32, cast to bf16 for comparison. Isolates “my kernel is wrong” from “my kernel is fine but bf16 rounds differently.”
Turn off masking. If bug vanishes without the causal mask, mask value or comparison direction is wrong.
Standard test harness for a new kernel¶
def standard_kernel_test(ours_fn, ref_fn, shapes, dtype=torch.bfloat16):
for shape in shapes:
for seed in [0, 1, 42]:
torch.manual_seed(seed)
args = [torch.randn(*s, dtype=dtype, device="cuda") for s in shape]
ours = ours_fn(*args)
ref = ref_fn(*args)
verify(ours, ref)
# Adversarial
args = [torch.full(shapes[0][0], 1e4, dtype=dtype, device="cuda")]
ours = ours_fn(*args); ref = ref_fn(*args)
assert not torch.isnan(ours).any(), "NaN under extreme inputs"
# Edge: all-zero
args = [torch.zeros(*shapes[0][0], dtype=dtype, device="cuda")]
ours = ours_fn(*args); ref = ref_fn(*args)
verify(ours, ref)
Every new kernel gets this treatment. It takes 5 minutes to write once and saves days of debugging.
References¶
FP8 formats: https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_71> (Micikevicius et al., NVIDIA/Arm/Intel FP8 spec)
NVFP4 blog: https://developer.nvidia.com/blog/introducing-nvfp4-for-efficient-and-accurate-low-precision-inference
FA3 (§3 covers FP8 attention with incoherent processing): https://arxiv.org/abs/<phone_number_or_numberic_id_or_random_id_72>
Horace He on numerics: https://horace.io/brrr_intro.html
PyTorch determinism docs: https://pytorch.org/docs/stable/notes/randomness.html