LeetLLM
My PlanLearnGlossaryTracksPracticeBlog
LeetLLM

Your go-to resource for mastering AI & LLM systems.

Product

  • Learn
  • Glossary
  • Tracks
  • Practice
  • Blog
  • RSS

Legal

  • Terms of Service
  • Privacy Policy

© 2026 LeetLLM. All rights reserved.

All Topics
Your Progress
0%

0 of 196 articles completed

🛠️Computing Foundations0/9
Git, Shell, Linux for AIDocker for Reproducible AIPython for AI EngineeringNumPy and Tensor ShapesCUDA for ML TrainingMPS & Metal for ML on MacData Structures for AISQL and Data ModelingAlgorithms for ML Engineers
📊Math & Statistics0/9
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningBayesian Inference from EvidenceStatistics and UncertaintyDistributions and SamplingHypothesis Tests, Intervals, and pass@k
📚Preparation & Prerequisites0/13
Neural Networks from ScratchCNNs from ScratchTraining & BackpropagationSoftmax, Cross-Entropy & OptimizationRNNs, LSTMs, GRUs, and Sequence ModelingAutoencoders and VAEsThe Transformer Architecture End-to-EndLanguage Modeling & Next TokensFrom GPT to Modern LLMsPrompt Engineering FundamentalsCalling LLM APIs in ProductionFirst AI App End-to-EndThe LLM Lifecycle
🧮ML Algorithms & Evaluation0/15
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageStatistical Learning and GeneralizationConformal Prediction and UncertaintyClustering and PCAProbabilistic Generative ModelsCore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingCausal Inference and CounterfactualsPyTorch Training LoopsDataset Pipelines and Data Quality
📦Production ML Systems0/6
Feature Engineering for Production MLBatch and Streaming Feature PipelinesGradient Boosted Trees in ProductionRanking and Recommendation SystemsForecasting and Anomaly DetectionMonitoring Predictive Models
🧪Core LLM Foundations0/8
The Bitter Lesson & ComputeBPE, WordPiece, and SentencePieceStatic to Contextual EmbeddingsPerplexity & Model EvaluationFile Ingestion for AIChunking StrategiesLLM Benchmarks & LimitationsInstruction Tuning & Chat Templates
🧰Applied LLM Engineering0/25
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernancePrivacy-Preserving Machine LearningData Labeling and Human FeedbackEvaluating AI AgentsProduction RAG PipelinesHybrid Search: Dense + SparseReranking and Cross-Encoders for RAGRAG Evaluation for Reliable AnswersLLM-as-a-Judge EvaluationBias & Fairness in LLMsHallucination Detection & MitigationLLM Observability & MonitoringExperiment Tracking with MLflow and W&BPrompt Optimization with DSPyModel Versioning & DeploymentSemantic Caching & Cost OptimizationLLM Cost Engineering & Token EconomicsModel Gateways, Routing, and FallbacksDesign an Automated Support Agent
🎓Portfolio Capstones0/9
Capstone: Delivery ETA PredictionCapstone: Product RankingCapstone: Demand ForecastingCapstone: Image Damage ClassifierCapstone: Production ML PipelineCapstone: Document QACapstone: Eval DashboardCapstone: Fine-Tuned ClassifierCapstone: Reproducible ML Study
🧠Transformer Deep Dives0/8
Sentence Embeddings & Contrastive LossEmbedding Similarity & QuantizationScaled Dot-Product AttentionVision Transformers and Image EncodersPositional Encoding: RoPE & ALiBiLayer Normalization: Pre-LN vs Post-LNMechanistic InterpretabilityDecoding Strategies: Greedy to Nucleus
🧬Advanced Training & Adaptation0/17
Scaling Laws & Compute-Optimal TrainingPre-training Data at ScaleBuild GPT from Scratch LabJAX for PyTorch ResearchersContinued Pretraining for Domain ShiftSynthetic Data PipelinesSupervised Fine-Tuning PipelineMixed Precision TrainingDistributed Training: FSDP & ZeROLoRA & Parameter-Efficient TuningTraining Run OperationsReward Modeling from Preference DataRLHF & DPO AlignmentConstitutional AI & Red TeamingRLVR & Verifiable RewardsKnowledge Distillation for LLMsModel Merging and Weight Interpolation
🤖Advanced Agents & Retrieval0/16
Vector DB Internals: HNSW & IVFAdvanced RAG: HyDE & Self-RAGGraphRAG & Knowledge GraphsRAG Security & Access ControlStructured Output GenerationReAct & Plan-and-ExecuteGuardrails & Safety FiltersCode Generation & SandboxingComputer-Use / GUI / Browser AgentsHuman-in-the-Loop Agent ArchitectureAI Coding Workflow with AgentsAgent Memory & PersistenceAgent Failure & RecoveryRecursive Language Models (RLM)Multi-Agent OrchestrationCapstone: Production Agent
⚡Inference & Production Scale0/31
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceAccelerator Architecture Field GuideCorrect GPU Profiling and BenchmarkingGPU Kernel Performance Engineering LabGPU Parallel Primitives LabGPU Compilers and Kernel LanguagesGEMM From Naive CUDA to Tensor CoresLow-Precision GPU ProgrammingGPU Runtime Execution LabAI-Generated Kernel EngineeringModel Quantization: GPTQ, AWQ & GGUFLocal LLM DeploymentSLM Specialization & Edge DeploymentSpeculative DecodingLong Context Window ManagementMixture of Experts ArchitectureMamba & State Space ModelsReasoning & Test-Time ComputeAdvanced MLOps & DevOps for AIGPU Serving & AutoscalingGPU Failure OperationsDistributed Inference Data PlaneServing Benchmark EngineeringA/B Testing for LLMs
🏗️System Design Capstones0/9
Content Moderation SystemCode Completion SystemMulti-Tenant LLM PlatformLLM-Powered Search EngineVision-Language Models & CLIPMultimodal LLM ArchitectureDiffusion Models: Images & TextReal-Time Voice AI AgentReasoning Agent System Design
🎤AI Lab Interviewing0/4
AI Lab Coding Interview: Python SystemsAI Lab System Design InterviewAI Lab Behavioral InterviewAI Lab Technical Presentation
🔬Project Deep Dives0/17
Deep Dive - vLLMDeep Dive - SkyRLDeep Dive - FlashAttentionDeep Dive - FlashInferDeep Dive - DeepGEMMDeep Dive - NCCLDeep Dive - MegatronDeep Dive - DeepSpeedDeep Dive - RayDeep Dive - MLflowDeep Dive - PyTorchDeep Dive - TransformersDeep Dive - SGLangDeep Dive - slimeDeep Dive - DeepEPDeep Dive - TinkerDeep Dive - Light-PEFT
Back to Topics
LearnInference & Production ScaleLow-Precision GPU Programming
🚀HardInference Optimization

Low-Precision GPU Programming

Build numerically sound FP16, BF16, TF32, FP8, and MXFP8 GPU paths by making scale, accumulation, layout, and kernel contracts explicit.

24 min read
Learning path
Step 150 of 196 in the full curriculum
GEMM From Naive CUDA to Tensor CoresGPU Runtime Execution Lab

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 (O(b2)O(b^2)O(b2)) with mantissa bit width bbb. 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:

  1. Encoding: which exact bit patterns represent normal numbers, subnormals, zeros, infinities, and NaNs?
  2. Scaling architecture: which values share a scale factor, when is that scale measured, and where does it live in memory?
  3. Arithmetic and accumulation: which precision enters the multiplier ALUs, which type accumulates dot products, and where does dequantization happen?
  4. 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 Y=XWY = XWY=XW. Matrix XXX contains two rows of 32 activations. Row 0 holds delicate, small activations between 10−310^{-3}10−3 and 1.01.01.0, typical of a residual stream or normalized state. Row 1 holds an activation spike reaching 500.0500.0500.0, typical of an attention out-projection or Mixture-of-Experts (MoE) routing bottleneck. Vector WWW 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.

FormatStored layoutExponent biasLargest finite valueSmallest positive normalTypical GPU role
FP321 sign, 8 exponent, 23 fraction127about 3.40×10383.40\times10^{38}3.40×1038about 1.18×10−381.18\times10^{-38}1.18×10−38reference math, accumulation, sensitive reductions
TF32FP32 storage (reads 8 exponent, 10 fraction)127about 3.40×10383.40\times10^{38}3.40×1038about 1.18×10−381.18\times10^{-38}1.18×10−38faster single-precision matrix math on NVIDIA Tensor Cores
BF161 sign, 8 exponent, 7 fraction127about 3.39×10383.39\times10^{38}3.39×1038about 1.18×10−381.18\times10^{-38}1.18×10−38broad-range activations, weights, gradients
FP161 sign, 5 exponent, 10 fraction1565,504about 6.10×10−56.10\times10^{-5}6.10×10−5high local precision, narrow dynamic range
FP8 E4M31 sign, 4 exponent, 3 fraction74482−6=0.0156252^{-6} = 0.0156252−6=0.015625forward activations and weights
FP8 E5M21 sign, 5 exponent, 2 fraction1557,3442−14≈6.10×10−52^{-14} \approx 6.10\times10^{-5}2−14≈6.10×10−5backward gradients and wide-range tensors

The Open Compute Project (OCP) 8-bit floating-point specification, known as OFP8, standardizes both FP8 encodings.[1]Reference 1OCP 8-bit Floating Point Specification (OFP8), Revision 1.0https://www.opencompute.org/documents/ocp-8-bit-floating-point-specification-ofp8-revision-1-0-2023-12-01-pdf-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 [−20,20][-20, 20][−20,20]. 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 12.5%12.5\%12.5%. That cuts rounding variance in half compared to E5M2. Because range is limited, OFP8 E4M3 repurposes the extreme exponent pattern 1111 for finite values, reaching 448 with no infinities and only two NaN bit patterns (0x7F and 0xFF).
  • Why E5M2 rules backward gradients: Gradients during training fluctuate wildly across layers, attention heads, and training steps, spanning eight or more orders of magnitude (10−710^{-7}10−7 to 10210^2102). 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 2−16≈1.53×10−52^{-16} \approx 1.53\times10^{-5}2−16≈1.53×10−5. Stochastic gradient descent naturally tolerates coarse 2-bit mantissa precision, giving dynamic range priority over fraction resolution.[2]Reference 2FP8 Formats for Deep Learning.https://arxiv.org/abs/2209.05433

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]Reference 3OCP Microscaling Formats (MX) Specification, Version 1.0https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf
  • 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 50%50\%50% compared to FP8, while doubling Tensor Core math throughput.[4]Reference 4Blackwell Architecture Compatibilityhttps://docs.nvidia.com/cuda/blackwell-compatibility-guide/

Rounding error grows in direct proportion to value magnitude. Around 1.0, E4M3 grid spacing is 2−3=0.1252^{-3} = 0.1252−3=0.125; 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 Y=XWY = XWY=XW, with activation matrix X∈RM×KX \in \mathbb{R}^{M \times K}X∈RM×K and weight matrix W∈RK×NW \in \mathbb{R}^{K \times N}W∈RK×N.

Every element is represented by a positive scale factor and a quantized low-precision code:

Xik≈SX,i⋅X~ik,Wkj≈SW,j⋅W~kjX_{ik} \approx S_{X, i} \cdot \tilde{X}_{ik}, \qquad W_{kj} \approx S_{W, j} \cdot \tilde{W}_{kj}Xik​≈SX,i​⋅X~ik​,Wkj​≈SW,j​⋅W~kj​

Here X~ik\tilde{X}_{ik}X~ik​ and W~kj\tilde{W}_{kj}W~kj​ are low-precision FP8 or FP4 values, while SX,iS_{X, i}SX,i​ and SW,jS_{W, j}SW,j​ are higher-precision (FP32 or E8M0) scale factors. Substitute these into the dot product definition for output element YijY_{ij}Yij​:

Yij=∑k=1KXikWkj=∑k=1K(SX,iX~ik)(SW,jW~kj)Y_{ij} = \sum_{k=1}^K X_{ik} W_{kj} = \sum_{k=1}^K \left(S_{X, i} \tilde{X}_{ik}\right) \left(S_{W, j} \tilde{W}_{kj}\right)Yij​=k=1∑K​Xik​Wkj​=k=1∑K​(SX,i​X~ik​)(SW,j​W~kj​)

Because row scale SX,iS_{X, i}SX,i​ and column scale SW,jS_{W, j}SW,j​ don't depend on the contraction index kkk, both scales factor completely out of the inner sum:

Yij=(SX,i⋅SW,j)∑k=1KX~ikW~kjY_{ij} = \left(S_{X, i} \cdot S_{W, j}\right) \sum_{k=1}^K \tilde{X}_{ik} \tilde{W}_{kj}Yij​=(SX,i​⋅SW,j​)k=1∑K​X~ik​W~kj​

This algebraic identity forms the basis of hardware-accelerated low-precision execution. Work divides into two phases:

  1. Tensor Core Multiply-Accumulate: The inner sum ∑kX~ikW~kj\sum_k \tilde{X}_{ik} \tilde{W}_{kj}∑k​X~ik​W~kj​ executes entirely in fast, low-precision hardware ALUs, accumulating products into wide 32-bit FP32 registers.
  2. Epilogue Dequantization: Dequantization scale (SX,i⋅SW,j)(S_{X, i} \cdot S_{W, j})(SX,i​⋅SW,j​) 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.
Diagram showing 1. High-Precision Inputs X (FP16/BF16), 2. Producer Quantization Compute S, cast to FP8, 3. Memory Layout Packed FP8 codes + Scales, and 4. Tensor Core MMA FP8 inputs → FP32 Accumulator.
1. High-Precision Inputs X (FP16/BF16), 2. Producer Quantization Compute S, cast to FP8, 3. Memory Layout Packed FP8 codes + Scales, and 4. Tensor Core MMA FP8 inputs → FP32 Accumulator.

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 SSS, 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 4×4\times4× 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 KKK (as in 32-element microscaling with block index bbb):

Yij=∑b=1K/32(SX,i,b⋅SW,j,b)∑k∈block bX~ikW~kjY_{ij} = \sum_{b=1}^{K/32} \left(S_{X, i, b} \cdot S_{W, j, b}\right) \sum_{k \in \text{block } b} \tilde{X}_{ik} \tilde{W}_{kj}Yij​=b=1∑K/32​(SX,i,b​⋅SW,j,b​)k∈block b∑​X~ik​W~kj​

The scale product (SX,i,b⋅SW,j,b)(S_{X, i, b} \cdot S_{W, j, b})(SX,i,b​⋅SW,j,b​) 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 (1.f×2e−bias1.f \times 2^{e - \text{bias}}1.f×2e−bias). Subnormal numbers occur when the exponent bits are all zero; the leading bit drops to 0 (0.f×21−bias0.f \times 2^{1 - \text{bias}}0.f×21−bias). This allows values to shrink smoothly toward zero, albeit with progressively fewer significant bits.

In OCP E4M3, the smallest positive normal is 2−6=0.0156252^{-6} = 0.0156252−6=0.015625. Subnormals extend down to 2−9≈0.0019531252^{-9} \approx 0.0019531252−9≈0.001953125. Under standard round-to-nearest-even (RNE):

  • An input of 0.75×2−90.75 \times 2^{-9}0.75×2−9 rounds up to the smallest subnormal 2−92^{-9}2−9.
  • An input of 0.5×2−90.5 \times 2^{-9}0.5×2−9 lands on an exact tie and rounds down to signed zero (+0.0 or -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: ±448\pm 448±448 for E4M3, or ±57,344\pm 57,344±57,344 for E5M2.[1]Reference 1OCP 8-bit Floating Point Specification (OFP8), Revision 1.0https://www.opencompute.org/documents/ocp-8-bit-floating-point-specification-ofp8-revision-1-0-2023-12-01-pdf-1[5]Reference 5FP8 Conversion and Data Movementhttps://docs.nvidia.com/cuda/cuda-math-api/cuda_math_api/group__CUDA__MATH__FP8__MISC.html

Saturation creates an artificial flat cliff. A spike of 500500500 clamps to 448448448; a spike of 50,00050,00050,000 also clamps to 448448448. 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 ΔW=−ηg\Delta W = -\eta gΔW=−ηg. If ∣ΔW∣|\Delta W|∣ΔW∣ is smaller than half of the least significant bit (12LSB\frac{1}{2}\text{LSB}21​LSB) of the weight representation, RNE rounds ΔW\Delta WΔW down to zero:

RNE⁡(W+ΔW)=W\operatorname{RNE}(W + \Delta W) = WRNE(W+ΔW)=W

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:

SR⁡(x)={⌈x⌉with probability x−⌊x⌋⌈x⌉−⌊x⌋⌊x⌋with probability 1−x−⌊x⌋⌈x⌉−⌊x⌋\operatorname{SR}(x) = \begin{cases} \lceil x \rceil & \text{with probability } \frac{x - \lfloor x \rfloor}{\lceil x \rceil - \lfloor x \rfloor} \\ \lfloor x \rfloor & \text{with probability } 1 - \frac{x - \lfloor x \rfloor}{\lceil x \rceil - \lfloor x \rfloor} \end{cases}SR(x)={⌈x⌉⌊x⌋​with probability ⌈x⌉−⌊x⌋x−⌊x⌋​with probability 1−⌈x⌉−⌊x⌋x−⌊x⌋​​

The mathematical expectation of stochastic rounding is strictly unbiased: E[SR⁡(x)]=x\mathbb{E}[\operatorname{SR}(x)] = xE[SR(x)]=x. Even if an update ΔW\Delta WΔW equals just 0.01×LSB0.01 \times \text{LSB}0.01×LSB, it will round up 1%1\%1% 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 K=4096K = 4096K=4096, 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 1024.01024.01024.0, the gap between adjacent representable numbers is 210−10=1.02^{10-10} = 1.0210−10=1.0. Any individual product smaller than 0.50.50.5 added to that running sum rounds down to zero (1024.0+0.4=1024.01024.0 + 0.4 = 1024.01024.0+0.4=1024.0). 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.

fp8-conversion-boundaries.py
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())
Output
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]Reference 6Tensor Attributeshttps://docs.pytorch.org/docs/stable/tensor_attributes.html[7]Reference 7Data Types and Precision Supporthttps://rocm.docs.amd.com/en/latest/reference/precision-support.html

Worked failure: stale delayed scale

Suppose delayed scaling calculates an E4M3 scale factor from a previous iteration's maximum absolute value (amax=10.0\text{amax} = 10.0amax=10.0):

Sold=10.0448≈0.02232S_{\text{old}} = \frac{10.0}{448} \approx 0.02232Sold​=44810.0​≈0.02232

In the current iteration, an activation outlier spikes to 500.0500.0500.0. The scaled magnitude is:

500.0Sold=500.010.0/448=22,400.0\frac{500.0}{S_{\text{old}}} = \frac{500.0}{10.0 / 448} = 22,400.0Sold​500.0​=10.0/448500.0​=22,400.0

Under saturating conversion, 22,400.022,400.022,400.0 clamps directly to the format maximum of 448448448. During consumer dequantization, reconstruction returns:

x^=448⋅Sold=448⋅10.0448=10.0\hat{x} = 448 \cdot S_{\text{old}} = 448 \cdot \frac{10.0}{448} = 10.0x^=448⋅Sold​=448⋅44810.0​=10.0

The reconstructed value is finite, positive, and shows no NaN errors, but its relative error is 98%98\%98%. 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.

GranularityScale count for X∈RM×KX \in \mathbb{R}^{M \times K}X∈RM×KAdvantagesVulnerabilities
Tensor-wide1minimal memory metadata (+4 bytes)a single outlier token compresses all unrelated tokens
Row-wise (per-token)MMMisolates outliers to specific sequence tokensrequires row-major layout and per-row scale loading
Column-wise (per-channel)KKKpreserves channel-specific weight variancecan't be transposed into row-wise layout without requantizing
Block-wise (microscaling)M×(K/32)M \times (K / 32)M×(K/32)localizes dynamic range to 32-element vectorsrequires 32-byte alignment and padding

The host-device latency dilemma

Calculating an online per-tensor scale requires finding amax=max⁡(∣X∣)\text{amax} = \max(|X|)amax=max(∣X∣) across all elements before launching the GEMM:

S=fp8_maxamaxS = \frac{\text{fp8\_max}}{\text{amax}}S=amaxfp8_max​

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 XXX 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]Reference 8Using FP8 and FP4 with Transformer Enginehttps://docs.nvidia.com/deeplearning/transformer-engine/user-guide/examples/fp8_primer.html Instead of measuring the current tensor, it sets the scale factor using an amax history window from prior iterations (t−1,t−2,…t-1, t-2, \dotst−1,t−2,…):

St=fp8_maxmax⁡i∈[1,H]amaxt−iS_t = \frac{\text{fp8\_max}}{\max_{i \in [1, H]} \text{amax}_{t-i}}St​=maxi∈[1,H]​amaxt−i​fp8_max​

While the GEMM executes at step ttt, the kernel computes the actual amaxt\text{amax}_tamaxt​ 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]Reference 9DeepSeek-V3 Technical Report.https://arxiv.org/abs/2412.19437[10]Reference 10DeepGEMM Source Snapshot, July 15 2026https://github.com/deepseek-ai/DeepGEMM/tree/559d79fb6994a58b8a15b4b93bf13ccc16edf247

Instead of lagging behind history, DeepSeek uses fine-grained block-level scaling (1×1281 \times 1281×128 activation tiles and 128×128128 \times 128128×128 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 2−1272^{-127}2−127 up to 21272^{127}2127. 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:

256+832=8.25 bits per element\frac{256 + 8}{32} = 8.25 \text{ bits per element}32256+8​=8.25 bits per element

Let's test our running two-row activation matrix on CPU. Row 0 holds delicate values starting at 0.0010.0010.001. Row 1 holds an activation spike of 500.0500.0500.0.

Predict what happens to 0.0010.0010.001 when it shares one global scale with 500.0500.0500.0, versus when each row gets its own 32-element block scale:

fp8-scale-granularity.py
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()}")
Output
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:

  1. Under tensor-wide scaling (S=1.116071S = 1.116071S=1.116071): The smallest representable non-zero E4M3 step is 1.116071×2−9≈0.002181.116071 \times 2^{-9} \approx 0.002181.116071×2−9≈0.00218. The small activation 0.0010.0010.001 falls below half of this step and rounds straight to zero. The entire row loses nearly 3%3\%3% of its dot-product magnitude because an outlier in an unrelated row set the scale.
  2. Under block-level scaling (S0=2−8=0.00390625S_0 = 2^{-8} = 0.00390625S0​=2−8=0.00390625): The first block's scale shifts the dynamic range downward. Its smallest subnormal step becomes 2−8×2−9=2−17≈7.63×10−62^{-8} \times 2^{-9} = 2^{-17} \approx 7.63\times10^{-6}2−8×2−9=2−17≈7.63×10−6. The small value 0.0010.0010.001 survives accurately as 0.000976560.000976560.00097656 (just 2.3%2.3\%2.3% relative error).
  3. Outlier isolation in Row 1 (S1=21=2.0S_1 = 2^1 = 2.0S1​=21=2.0): Row 1 gets its own scale, so its 500.0500.0500.0 outlier stays confined to its own 32-element chunk without destroying detail in Row 0.
Comparison of tensor-wide scaling versus 32-element block microscaling. Under a single global scale of 1.116071, an outlier of 500 forces small values like 0.001 to round to zero. Under 32-element block scaling, independent E8M0 power-of-two scales protect small row values while isolating the outlier.
Comparing tensor-wide scaling with block microscaling on our two-row activation workload. A global scale lets an outlier in Row 1 squash Row 0's small activations into exact zero. Local block scaling provides an independent exponent per 32 elements, preserving fine features while isolating outlier dynamics.

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 KKK axis share a scale. In column-wise packing, 32 elements along the MMM 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]Reference 11MXFP8https://docs.nvidia.com/deeplearning/transformer-engine/user-guide/features/low_precision_training/mxfp8/mxfp8.html


Hardware support and kernel ABI contracts

Hardware claims must always be tied to specific GPU microarchitectures, compute capabilities, and driver toolkits.

PlatformDocumented support statusBoundary to verify
NVIDIA TF32Ampere (SM 8.0) and newerenabled by default in PyTorch for matrix math; can be toggled via torch.backends.cuda.matmul.allow_tf32
NVIDIA FP8Ada 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 NVFP4Blackwell (SM 10.0, SM 10.3)requires 5th-gen Tensor Cores and 2nd-gen Transformer Engine dual-level scaling
AMD FP8CDNA3 (MI300), CDNA4 (MI350), RDNA4MI300 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:

fp8-production-contract.txt
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 symptomRoot cause boundaryDiagnostic inspectionTargeted fix
Millions of activations equal ±448\pm 448±448Saturation cliffHistogram pre-cast float magnitudes vs format limitsRefresh scale factor or switch from tensor-wide to block scaling
Entire row contains exact zerosOutlier swampingZero rate per row or block before the GEMMSwitch from per-tensor to per-token or block-level scaling
NaNs appear only in non-saturating modeUnhandled overflowRun side-by-side with explicit .clamp(-448, 448)Verify whether caller expects OCP non-saturating NaN or saturating clamp
Numerical drift grows with sequence lengthAccumulator precisionCheck whether Tensor Core is set to FP16 accumulationForce FP32 accumulator in compiler and framework flags
Numerical collapse when porting to AMDOCP vs FNUZ ABI mismatchPrint bitwise hex representation of byte 0x38Explicitly transcode bytes between OCP and FNUZ biases
Output turns to noise after tensor transposeScale orientation violationCompare dot products against fresh column-wise quantizationQuantize 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.

Complete the lesson

Mastery Check

Answer every question, then check your score. Score 75% or higher to mark this lesson complete.

1.An E4M3 GEMM receives an unscaled activation x = 500 with scale s = 1.0 under saturating conversion and FP32 accumulation. Which value enters the Tensor Core multiplier, and why can't FP32 accumulation fix it?

Correct answer: 448 enters the multiplier; saturation discarded the excess magnitude before accumulation.

OFP8 E4M3 has a maximum finite magnitude of 448. Saturating conversion clamps 500 to 448. The wide FP32 accumulator only sums products formed from that reconstructed operand, so it has no information about the discarded magnitude.

2.Why is FP8 E4M3 preferred for forward activations and weights, while E5M2 is preferred for backward gradients?

Correct answer: E4M3 buys an extra fraction bit for normalized activations; E5M2 buys wider dynamic range to prevent gradient underflow.

Forward activations are bounded by normalization layers, so 3 fraction bits minimize quantization noise. Gradients fluctuate across orders of magnitude, so 5 exponent bits prevent vanishing gradient updates.

3.In a matrix multiplication Y = XW with per-token scale S_X and per-channel scale S_W, why can the scale multiplication be factored out of the inner Tensor Core contraction sum?

Correct answer: Neither S_X,i nor S_W,j depends on the inner contraction index k, allowing them to distribute outside the summation.

Because S_X,i depends only on row i and S_W,j depends only on column j, neither varies with contraction index k. They pull outside the sum: Y_ij = (S_X,i * S_W,j) * sum_k (X_fp8 * W_fp8).

4.Why is dequantization implemented inside the consumer GEMM kernel rather than as a standalone pre-pass kernel?

Correct answer: A standalone dequantization pass would write FP32 tensors to global DRAM, multiplying memory bandwidth traffic by 4x.

The primary speedup of low-precision computing comes from reducing DRAM traffic. Materializing FP32 tensors in global memory would negate that benefit. Dequantization is fused into the GEMM register pipeline.

5.What is the gradient stagnation trap in low-precision training, and how does stochastic rounding resolve it?

Correct answer: Deterministic RNE rounds updates smaller than 0.5 LSB to zero, freezing weights; stochastic rounding is unbiased in expectation.

Under round-to-nearest-even, any weight update smaller than half of the least significant bit rounds to zero, freezing parameters. Stochastic rounding rounds up with probability proportional to the residual, ensuring expected updates remain non-zero.

6.Why does a delayed-scaling run show healthy zero-NaN telemetry even after a sudden activation distribution shift degrades model accuracy?

Correct answer: Saturating conversion clamps out-of-range spikes to finite format maximums, creating large errors without generating NaNs.

Under saturating conversion, overflows clamp to 448. The resulting reconstructed numbers are finite, so NaN monitoring checks stay green even though values suffer massive clipping error.

7.An MXFP8 block stores 32 FP8 elements alongside one shared 8-bit E8M0 scale. What is the effective payload per element before container padding?

Correct answer: 8.25 bits per element (256 element bits + 8 scale bits divided by 32).

32 elements of 8 bits each equal 256 bits. Adding 8 bits of E8M0 scale yields 264 bits per 32-element block. Dividing 264 by 32 gives exactly 8.25 bits per value.

8.Why can't a packed row-wise MXFP8 matrix simply be transposed in global memory to serve as a column-wise operand?

Correct answer: Transposing changes group membership; 32 elements contiguous along rows don't share scales along columns.

Microscaling associates a scale with 32 contiguous elements along a specific axis. A matrix transpose reorders elements across different scale blocks, invalidating the shared scale factor.

9.What is the primary difference between OCP E4M3 and AMD CDNA3 FNUZ E4M3 encodings?

Correct answer: OCP has exponent bias 7 and signed zeros; FNUZ has exponent bias 8, no negative zero, and represents NaN at 0x80.

OCP and FNUZ use different exponent biases (7 vs 8) and handle special values differently. FNUZ eliminates signed zeros, using 0x80 as NaN. Raw bytes can't be interchanged between them without explicit transcoding.

9 questions remaining.

Next Step
Continue to GPU Runtime Execution Lab

Low-precision kernels still depend on launch order, stream ownership, and synchronization. Next measure those runtime costs, remove avoidable host gaps, and prove replay remains correct.

PreviousGEMM From Naive CUDA to Tensor Cores
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

OCP 8-bit Floating Point Specification (OFP8), Revision 1.0

Open Compute Project · 2023

https://www.opencompute.org/documents/ocp-8-bit-floating-point-specification-ofp8-revision-1-0-2023-12-01-pdf-1

FP8 Formats for Deep Learning.

Micikevicius, P., et al. · 2022

https://arxiv.org/abs/2209.05433

OCP Microscaling Formats (MX) Specification, Version 1.0

Open Compute Project · 2023

https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf

Blackwell Architecture Compatibility

NVIDIA · 2026

https://docs.nvidia.com/cuda/blackwell-compatibility-guide/

FP8 Conversion and Data Movement

NVIDIA · 2026 · CUDA Math API

https://docs.nvidia.com/cuda/cuda-math-api/cuda_math_api/group__CUDA__MATH__FP8__MISC.html

Tensor Attributes

PyTorch Contributors · 2026 · Official PyTorch Documentation

https://docs.pytorch.org/docs/stable/tensor_attributes.html

Data Types and Precision Support

AMD · 2026 · ROCm Documentation

https://rocm.docs.amd.com/en/latest/reference/precision-support.html

Using FP8 and FP4 with Transformer Engine

NVIDIA · 2026

https://docs.nvidia.com/deeplearning/transformer-engine/user-guide/examples/fp8_primer.html

DeepSeek-V3 Technical Report.

DeepSeek-AI · 2024 · arXiv preprint

https://arxiv.org/abs/2412.19437

DeepGEMM Source Snapshot, July 15 2026

DeepSeek AI and Contributors · 2026

https://github.com/deepseek-ai/DeepGEMM/tree/559d79fb6994a58b8a15b4b93bf13ccc16edf247

MXFP8

NVIDIA · 2026

https://docs.nvidia.com/deeplearning/transformer-engine/user-guide/features/low_precision_training/mxfp8/mxfp8.html

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.