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 177 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/8
Gradients and BackpropVectors, Matrices & TensorsLinear Algebra for MLAdam, Momentum, SchedulersProbability for Machine LearningStatistics 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/11
Linear Regression from ScratchLogistic Regression and MetricsDecision Trees, Forests, and BoostingReinforcement Learning BasicsValidation and LeakageClustering and PCACore Retrieval AlgorithmsDecoding AlgorithmsExperiment Design and A/B TestingPyTorch 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/24
Dimensionality Reduction for EmbeddingsCoT, ToT & Self-Consistency PromptingFunction Calling & Tool UseMCP & Tool Protocol StandardsContext EngineeringPrompt Injection DefenseResponsible AI GovernanceData 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/16
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 TuningReward 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/19
Inference: TTFT, TPS & KV CacheMulti-Query & Grouped-Query AttentionKV Cache & PagedAttentionPrefix Caching and Prompt CachingFlashAttention & Memory EfficiencyContinuous Batching & SchedulingScaling LLM InferenceModel Parallelism for LLM InferenceModel 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 & AutoscalingA/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 & Test-Time Compute
🎤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
LearnAdvanced Training & AdaptationMixed Precision Training
⚡HardFine-Tuning & Training

Mixed Precision Training

Measure how FP16 and BF16 affect training range, update precision, memory, and release evidence before enabling faster low-precision compute.

22 min read
Learning path
Step 104 of 177 in the full curriculum
Supervised Fine-Tuning PipelineDistributed Training: FSDP & ZeRO

Personalize this lesson

Adapt explanations and teaching visuals to your background and preferred voice.

A training-speed change is an experiment, not an automatic improvement. A faster run isn't better if it corrupts updates, overflows, or lowers the declared held-out metric.

Suppose the deploy assistant now needs a small trace-evidence classifier that recognizes whether an incident summary is supported by logs. You want to fine-tune it faster, so the proposed run changes its precision policy from FP32 to FP16 or BF16. Build the run evidence you would need before choosing that policy.

Two log-scale comparisons of FP32, FP16, and BF16: FP32 and BF16 span roughly 1.2e-38 to 3.4e38, while FP16 has a 6.1e-5 normal floor, a roughly 6.0e-8 subnormal floor, and a 6.6e4 maximum, so a 1.2e-8 gradient underflows and a 1e5 activation overflows only in FP16; near 1.0, a 1e-4 update survives FP32 spacing but rounds away in both FP16 and BF16, motivating low-precision compute with FP32 update storage.
The upper plot separates representable magnitude from the lower plot's spacing near 1.0. BF16 protects the two range examples, but only FP32 preserves the small parameter update.

Make precision an explicit experiment parameter

Mixed precision training runs selected expensive operations in a compact floating-point format while retaining higher precision where training is fragile. Current PyTorch AMP examples create the model and optimizer in default precision, then let autocast choose an operation-specific dtype inside the forward region.[1]Reference 1Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

Start as you would in an experiment tracker: state the artifact under test and its guardrails before measuring candidates.

precision-contract.py
1from dataclasses import dataclass 2import torch 3 4@dataclass(frozen=True) 5class PrecisionContract: 6 experiment: str 7 artifact: str 8 baseline: str 9 candidates: tuple[str, ...] 10 data_fingerprint: str 11 seed_policy: str 12 eval_suite: str 13 hardware_profile: str 14 required_supported_evidence_f1: float 15 permitted_nonfinite_steps: int 16 17contract = PrecisionContract( 18 experiment="trace-evidence-encoder-precision", 19 artifact="trace-evidence-classifier-v2", 20 baseline="fp32", 21 candidates=("fp16_unscaled", "fp16_scaled", "bf16"), 22 data_fingerprint="trace-events@sha256:fixture-7", 23 seed_policy="seeds=11,17,23", 24 eval_suite="supported-evidence@sha256:suite-4", 25 hardware_profile="a100-80gb-single-gpu", 26 required_supported_evidence_f1=0.92, 27 permitted_nonfinite_steps=0, 28) 29 30print(f"experiment={contract.experiment}") 31print(f"artifact={contract.artifact}") 32print(f"baseline={contract.baseline}") 33print(f"candidates={','.join(contract.candidates)}") 34print(f"data_fingerprint={contract.data_fingerprint}") 35print(f"seed_policy={contract.seed_policy}") 36print(f"eval_suite={contract.eval_suite}") 37print(f"hardware_profile={contract.hardware_profile}") 38print(f"metric_gate=supported_evidence_f1>={contract.required_supported_evidence_f1:.2f}") 39print(f"nonfinite_steps_gate={contract.permitted_nonfinite_steps}")
Output
1experiment=trace-evidence-encoder-precision 2artifact=trace-evidence-classifier-v2 3baseline=fp32 4candidates=fp16_unscaled,fp16_scaled,bf16 5data_fingerprint=trace-events@sha256:fixture-7 6seed_policy=seeds=11,17,23 7eval_suite=supported-evidence@sha256:suite-4 8hardware_profile=a100-80gb-single-gpu 9metric_gate=supported_evidence_f1>=0.92 10nonfinite_steps_gate=0

The job isn't to declare BF16 good or FP16 bad in the abstract. It's to understand what each format can lose, then measure the acceptable candidates under the same validation and hardware conditions.

A floating-point number has two limits

A floating-point value is similar to scientific notation: a sign, a scale, and significant digits. Its exponent controls range, meaning how tiny or large a magnitude it can represent. Its fraction (often called the mantissa in training discussions) controls resolution, meaning how close two neighboring values can be.

FormatBitsExponent bitsFraction bitsMain training consequence
FP3232823Wide range and fine update resolution, with higher storage cost
FP1616510Compact, but small gradients can underflow and large values can overflow
BF161687Compact with FP32-like range, but coarser nearby resolution

The important correction is easy to miss: BF16 improves range relative to FP16; it doesn't improve nearby resolution. BF16 has fewer fraction bits than FP16. That's why BF16 compute still normally updates FP32 parameters.

PyTorch exposes the exact format limits with torch.finfo. Run this on CPU; no accelerator is required to inspect the number system.

format-limits.py
1formats = ( 2 ("FP32", torch.float32), 3 ("FP16", torch.float16), 4 ("BF16", torch.bfloat16), 5) 6 7print("format epsilon_at_1 smallest_normal largest_finite") 8for label, dtype in formats: 9 info = torch.finfo(dtype) 10 print(f"{label:<6} {info.eps:>12.1e} {info.tiny:>15.1e} {info.max:>14.1e}") 11 12print(f"bf16_min_normal_matches_fp32={torch.finfo(torch.bfloat16).tiny == torch.finfo(torch.float32).tiny}") 13print(f"bf16_resolution_coarser_than_fp16={torch.finfo(torch.bfloat16).eps > torch.finfo(torch.float16).eps}")
Output
1format epsilon_at_1 smallest_normal largest_finite 2FP32 1.2e-07 1.2e-38 3.4e+38 3FP16 9.8e-04 6.1e-05 6.6e+04 4BF16 7.8e-03 1.2e-38 3.4e+38 5bf16_min_normal_matches_fp32=True 6bf16_resolution_coarser_than_fp16=True

epsilon_at_1 is the spacing between 1.0 and the next representable value near it. smallest_normal and largest_finite describe range. FP16 gives finer resolution than BF16 near 1.0, but far less range.

If BF16 has FP32-like range, why don't we store model updates only in BF16?

Answer

Range and resolution are different. BF16 can represent very small magnitudes, but its fraction has only 7 bits, so tiny changes to a weight near 1.0 can still round away. FP32 update state preserves those accumulated changes.

Tiny updates need an FP32 home

Assume the classifier has a weight at 1.0. One optimizer step wants to subtract 0.0001. This is much larger than the smallest BF16 magnitude, but smaller than the spacing between BF16 values near 1.0.

tiny-parameter-update.py
1weight = torch.tensor([1.0], dtype=torch.float32) 2update = torch.tensor([1.0e-4], dtype=torch.float32) 3 4for label, dtype in formats: 5 before = weight.to(dtype) 6 after = before - update.to(dtype) 7 changed = bool(after.item() != before.item()) 8 print(f"{label}: stored_after_step={after.item():.8f}, update_survived={changed}") 9 10print("lesson=BF16 protects range; FP32 protects small accumulated updates")
Output
1FP32: stored_after_step=0.99989998, update_survived=True 2FP16: stored_after_step=1.00000000, update_survived=False 3BF16: stored_after_step=1.00000000, update_survived=False 4lesson=BF16 protects range; FP32 protects small accumulated updates

Both 16-bit parameter values lose this update. The original mixed-precision recipe used an FP32 master copy of the weights so small updates accumulate instead of disappearing.[2]Reference 2Mixed Precision Training.https://arxiv.org/abs/1710.03740 Current PyTorch AMP gets the same protection in its ordinary pattern by creating parameters in default precision and autocasting eligible forward operations rather than converting parameter storage before the optimizer step.[1]Reference 1Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

Range decides whether gradients exist at all

Resolution is one issue. Range is another. Compare an extremely small gradient and a very large activation-like value when stored in FP16 and BF16.

gradient-range.py
1values = torch.tensor([1.2e-8, 1.0e5], dtype=torch.float32) 2 3for label, dtype in (("FP16", torch.float16), ("BF16", torch.bfloat16)): 4 cast = values.to(dtype) 5 print( 6 f"{label}: small={cast[0].item():.2e}, " 7 f"large={cast[1].item():.2e}, " 8 f"all_finite={bool(torch.isfinite(cast).all())}" 9 ) 10 11print("fp16_loses_small_and_large=True") 12print("bf16_keeps_range_in_this_example=True")
Output
1FP16: small=0.00e+00, large=inf, all_finite=False 2BF16: small=1.20e-08, large=9.98e+04, all_finite=True 3fp16_loses_small_and_large=True 4bf16_keeps_range_in_this_example=True

FP16's smallest positive normal number is roughly 6.1×10−56.1 \times 10^{-5}6.1×10−5, and its subnormal floor is about 6.0×10−86.0 \times 10^{-8}6.0×10−8. A true gradient of 1.2×10−81.2 \times 10^{-8}1.2×10−8 becomes zero in FP16. At the other end, 100000 is beyond FP16's largest finite value of 65504, so it becomes Inf.

BF16 keeps the 8-bit exponent width of FP32, giving it a similar range and making these two magnitudes representable, although rounded. The BF16 training study documents that wider range as its main stability advantage over FP16.[3]Reference 3A Study of BFLOAT16 for Deep Learning Training.https://arxiv.org/abs/1905.12322

FP16 uses loss scaling to rescue small gradients

For FP16, loss scaling moves gradient magnitudes into a representable interval during backpropagation. Multiply loss by a scale SSS; the chain rule multiplies each gradient by SSS too. After backward, divide gradients by SSS in FP32 before applying the optimizer step. The intended update has not changed.

For a true gradient of 1.2×10−81.2 \times 10^{-8}1.2×10−8:

OperationValueFP16 outcome
Cast unscaled gradient1.2×10−81.2 \times 10^{-8}1.2×10−8Rounds to zero
Multiply by S=1024S=1024S=1024 during backward1.23×10−51.23 \times 10^{-5}1.23×10−5Representable
Convert to FP32 and divide by SSSapproximately 1.2×10−81.2 \times 10^{-8}1.2×10−8Ready for FP32 update
loss-scaling-rescue.py
1true_grad = torch.tensor([1.2e-8], dtype=torch.float32) 2scale = 1024.0 3 4plain_fp16 = true_grad.to(torch.float16) 5scaled_fp16 = (true_grad * scale).to(torch.float16) 6recovered_fp32 = scaled_fp16.to(torch.float32) / scale 7 8print(f"plain_underflowed={plain_fp16.item() == 0.0}") 9print(f"scaled_visible={scaled_fp16.item() > 0.0}") 10print(f"recovered_grad={recovered_fp32.item():.2e}") 11print(f"recovery_relative_error={abs(recovered_fp32.item() - true_grad.item()) / true_grad.item():.3%}")
Output
1plain_underflowed=True 2scaled_visible=True 3recovered_grad=1.20e-08 4recovery_relative_error=0.077%
Log-magnitude loss-scaling plot where multiplying gradients by 1024 shifts every value 3.01 decades: a 1.2e-8 tiny gradient moves into FP16 range at 1.23e-5, a 2e-2 quiet gradient remains finite at 20.5, and a 1e2 spike becomes 1.024e5 above FP16 maximum 65504; the finite batch is unscaled and applied at scale 1024, while the overflowed batch is skipped and backs off to scale 512.
The same scale rescues the tiny gradient and overflows the spike. Dynamic scaling applies only the finite, unscaled batch and halves the next scale after the rejected batch.

Scaling too far causes overflow

A fixed scale that saves the smallest gradient may overflow a larger gradient in the same step. Dynamic scaling therefore has two outcomes: apply a finite, descaled update, or skip an overflowed step and reduce the scale.

overflow-backoff.py
1def scaled_step_status(gradients: torch.Tensor, scale: float) -> tuple[str, float]: 2 scaled = (gradients * scale).to(torch.float16) 3 if not bool(torch.isfinite(scaled).all()): 4 return "SKIP_OVERFLOW", scale / 2 5 return "APPLY_DESCALED_UPDATE", scale 6 7quiet_step = torch.tensor([1.2e-8, 2.0e-2], dtype=torch.float32) 8spiky_step = torch.tensor([1.2e-8, 1.0e2], dtype=torch.float32) 9 10quiet_status, quiet_next_scale = scaled_step_status(quiet_step, 1024.0) 11spiky_status, spiky_next_scale = scaled_step_status(spiky_step, 1024.0) 12 13print(f"quiet_step={quiet_status}, next_scale={quiet_next_scale:.0f}") 14print(f"spiky_step={spiky_status}, next_scale={spiky_next_scale:.0f}") 15print("invariant=never_apply_nonfinite_gradients")
Output
1quiet_step=APPLY_DESCALED_UPDATE, next_scale=1024 2spiky_step=SKIP_OVERFLOW, next_scale=512 3invariant=never_apply_nonfinite_gradients

In current PyTorch, torch.amp.GradScaler performs this scale, unscale, finite-check, skip, and update control flow for FP16 training. PyTorch also documents that if you inspect or clip gradients, you must call scaler.unscale_(optimizer) before clipping so thresholds apply to true gradient magnitudes.[1]Reference 1Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

Loss scaling isn't a general extension of FP16 range. It rescues small backward gradients that would underflow, but it can't make a forward activation above 65504 representable. PyTorch also warns that GradScaler may reduce its scale below 1 for overflow-prone models, so don't assume the scale always grows or stays above 1.[1]Reference 1Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

An FP16 step overflows after backward. Should the optimizer apply the finite gradients and drop only the non-finite tensors?

Answer

No. Skip the whole optimizer step, reduce the loss scale, and retry on a later batch. Applying a partial update changes the optimizer contract; clipping must also happen only after gradients are unscaled to their true magnitudes.

Compute low, update high

Loss scaling protects FP16 gradients from range failure. It doesn't make 16-bit parameter storage appropriate for tiny updates. Preserve the FP32 update path separately.

fp32-update-path.py
1step = torch.tensor([1.0e-4], dtype=torch.float32) 2fp16_parameter = torch.tensor([1.0], dtype=torch.float16) 3fp32_parameter = torch.tensor([1.0], dtype=torch.float32) 4 5fp16_after = fp16_parameter - step.to(torch.float16) 6fp32_after = fp32_parameter - step 7 8print(f"fp16_parameter_changed={fp16_after.item() != fp16_parameter.item()}") 9print(f"fp32_parameter_changed={fp32_after.item() != fp32_parameter.item()}") 10print(f"fp32_after={fp32_after.item():.8f}") 11print("policy=low_precision_compute_with_fp32_update_state")
Output
1fp16_parameter_changed=False 2fp32_parameter_changed=True 3fp32_after=0.99989998 4policy=low_precision_compute_with_fp32_update_state

The original paper describes copying FP32 master weights into a low-precision compute copy.[2]Reference 2Mixed Precision Training.https://arxiv.org/abs/1710.03740 With ordinary AMP, PyTorch parameters remain FP32, autocast selects lower precision for eligible compute, and the optimizer updates the FP32 parameters directly.[1]Reference 1Automatic Mixed Precision package - torch.amphttps://docs.pytorch.org/docs/stable/amp.html

This is the CUDA shape you would use for a real fine-tuning run. It isn't marked executable here because it needs an accelerator and a model workload:

cuda-amp-training-shape.py
1dtype = torch.bfloat16 # compare against torch.float16 in a controlled run 2use_scaler = dtype == torch.float16 3scaler = torch.amp.GradScaler("cuda", enabled=use_scaler) 4 5for batch, target in dataloader: 6 optimizer.zero_grad(set_to_none=True) 7 with torch.autocast(device_type="cuda", dtype=dtype): 8 logits = model(batch.cuda()) 9 loss = criterion(logits, target.cuda()) 10 11 if scaler.is_enabled(): 12 scaler.scale(loss).backward() 13 scaler.unscale_(optimizer) 14 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) 15 scaler.step(optimizer) 16 scaler.update() 17 else: 18 loss.backward() 19 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) 20 optimizer.step()

For BF16, skipping GradScaler is a common policy because BF16's exponent range avoids the FP16 failure that loss scaling targets. It isn't a guarantee that every BF16 training job is stable. Bad data, unstable losses, overly large learning rates, or sensitive kernels can still produce non-finite values.

GradScaler and gradient accumulation share one optimizer-step boundary

The sketch above assumes one micro-batch per optimizer step. Real SFT and distributed jobs often use gradient accumulation: several micro-batches contribute to gradients before a single optimizer.step. The scaler contract still keys off that optimizer step, not each micro-batch.

CallOnce per micro-batchOnce per optimizer step
scaler.scale(micro_loss).backward()YesNo
scaler.unscale_(optimizer)NoYes (before clip)
clip gradientsNoYes
scaler.step(optimizer)NoYes
scaler.update()NoYes

If you step/update every micro-batch, the effective batch size collapses and the scale adapts on incomplete gradients. If you unscale before the accumulation window finishes, later micro-batches write into already-unscaled grad buffers. Count skipped non-finite optimizer steps in telemetry; a silent skip storm is a failed precision experiment even when the run "completes."

When loss is averaged over the accumulation window, keep the usual loss / accum_steps scaling and let GradScaler multiply that micro-loss. Don't double-count accumulation by also dividing the GradScaler scale by hand.

Memory savings need accounting, not slogans

When autocast runs an eligible operation in BF16 or FP16, its saved low-precision activation values use two bytes rather than four. Other operations stay in or return FP32 for numerical safety. Total training memory doesn't necessarily halve because FP32 parameters and optimizer moments may remain unchanged.

The next accounting exercise uses a deliberately small inventory: 100 million parameters, their gradients, two Adam moment buffers, and 800 million stored activation values. To make the arithmetic visible, assume those inventoried activations were saved in low precision for the AMP candidate. A real profile may retain FP32 values for some operations. This is a budget calculation, not a measured GPU profile.

memory-budget.py
1def gib(values: int, bytes_per_value: int) -> float: 2 return values * bytes_per_value / (1024 ** 3) 3 4parameter_values = 100_000_000 5activation_values = 800_000_000 6 7fp32_budget = { 8 "parameters": gib(parameter_values, 4), 9 "gradients": gib(parameter_values, 4), 10 "adam_moments": gib(parameter_values * 2, 4), 11 "activations": gib(activation_values, 4), 12} 13amp_budget = { 14 **{name: value for name, value in fp32_budget.items() if name != "activations"}, 15 "activations": gib(activation_values, 2), 16} 17 18print(f"fp32_total_gib={sum(fp32_budget.values()):.2f}") 19print(f"amp_total_gib={sum(amp_budget.values()):.2f}") 20print(f"activation_saving_gib={fp32_budget['activations'] - amp_budget['activations']:.2f}") 21print(f"total_reduction={(1 - sum(amp_budget.values()) / sum(fp32_budget.values())):.1%}") 22print("lesson=half_size_activations_do_not_imply_half_total_memory")
Output
1fp32_total_gib=4.47 2amp_total_gib=2.98 3activation_saving_gib=1.49 4total_reduction=33.3% 5lesson=half_size_activations_do_not_imply_half_total_memory

Saved activations use two bytes instead of four, yet total measured memory falls by only one third. Is mixed precision broken?

Answer

No. Parameters, gradients, and Adam moments may still use FP32 storage, so only one part of the inventory halved. Profile each component before deciding whether activation casting, sharding, checkpointing, or a smaller batch targets the real bottleneck.

Component memory accounting for 100 million parameters, 100 million gradients, two Adam moment buffers, and 800 million saved activations: FP32 totals 4.47 GiB, while an AMP candidate totals 2.98 GiB because only activations halve from 2.98 to 1.49 GiB, a 33.3 percent reduction; separate 100-million-gradient communication bars show 0.37 GiB for FP32 reduction and 0.19 GiB for BF16 reduction, a 50 percent payload reduction, emphasizing that compute BF16, update FP32, and reduction dtype are independent run fields.
Halving activation bytes reduces this full inventory by one third, not one half. The separate payload bars show why reduce_dtype must be measured independently from compute dtype.

For large models, sharding methods such as ZeRO and Fully Sharded Data Parallel (FSDP) address parameters, gradients, and optimizer-state memory that activation casting alone doesn't remove.[4]Reference 4ZeRO: Memory Optimizations Toward Training Trillion Parameter Models.https://arxiv.org/abs/1910.02054[5]Reference 5PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel.https://arxiv.org/abs/2304.11277

Distributed jobs add a communication dtype

When workers exchange gradients, the network payload has its own precision policy. Current PyTorch FSDP MixedPrecision configuration exposes param_dtype for forward and backward computation and reduce_dtype for gradient reduction; the two fields may differ.[6]Reference 6FullyShardedDataParallelhttps://docs.pytorch.org/docs/stable/fsdp.html

communication-budget.py
1gradient_values = 100_000_000 2fp32_reduce_gib = gib(gradient_values, 4) 3bf16_reduce_gib = gib(gradient_values, 2) 4 5print(f"gradient_payload_fp32_gib={fp32_reduce_gib:.2f}") 6print(f"gradient_payload_bf16_gib={bf16_reduce_gib:.2f}") 7print(f"payload_reduction={(1 - bf16_reduce_gib / fp32_reduce_gib):.0%}") 8print("warning=compute_dtype_does_not_prove_reduce_dtype")
Output
1gradient_payload_fp32_gib=0.37 2gradient_payload_bf16_gib=0.19 3payload_reduction=50% 4warning=compute_dtype_does_not_prove_reduce_dtype

A job can use BF16 for matrix computations and still communicate FP32 gradient payloads. Therefore a trustworthy run record separates compute_dtype, update_storage_dtype, and reduce_dtype rather than logging a single mixed_precision=true flag.

Decide from runs, not format preference

RL can expose a BF16 train-inference mismatch

BF16's wide exponent range makes it a strong default candidate for many training jobs, but range isn't the only concern. Reinforcement-learning fine-tuning compares or updates a training policy using trajectories generated by an inference policy. Different kernels, batching shapes, or execution paths can produce nearby FP32 logits before casting. BF16's coarse spacing may round those nearby values to different sides of a representable boundary, so rollout probabilities no longer match the probabilities used during training.

Qi et al. report this failure mode in LLM reinforcement-learning fine-tuning and find that using FP16 consistently reduced the train-inference mismatch and improved optimization in their tested tasks, algorithms, and frameworks.[7]Reference 7Defeating the Training-Inference Mismatch via FP16https://arxiv.org/abs/2510.26788 That's evidence for an RL-specific candidate, not a universal reversal of the BF16 guidance above. FP16 still needs loss scaling for small gradients, finite forward values, and the same controlled quality checks as every precision policy.

The toy below doesn't reproduce a full RL run. It isolates the rounding boundary. Two execution paths produce first logits 1.0038 and 1.0040. FP16 rounds both to the same value, while BF16 rounds one down and the other up, creating a probability gap.

compare-policy-rounding.py
1import torch 2 3training_logits = torch.tensor([1.0038, 1.0], dtype=torch.float32) 4rollout_logits = torch.tensor([1.0040, 1.0], dtype=torch.float32) 5 6def policy_gap(dtype: torch.dtype) -> tuple[list[float], list[float], float]: 7 training_cast = training_logits.to(dtype).to(torch.float32) 8 rollout_cast = rollout_logits.to(dtype).to(torch.float32) 9 training_probability = torch.softmax(training_cast, dim=0)[0] 10 rollout_probability = torch.softmax(rollout_cast, dim=0)[0] 11 gap = abs(training_probability.item() - rollout_probability.item()) 12 return training_cast.tolist(), rollout_cast.tolist(), gap 13 14for label, dtype in (("FP16", torch.float16), ("BF16", torch.bfloat16)): 15 training_cast, rollout_cast, gap = policy_gap(dtype) 16 print( 17 f"{label}: training={training_cast[0]:.7f}, " 18 f"rollout={rollout_cast[0]:.7f}, probability_gap={gap:.7f}" 19 ) 20 21assert policy_gap(torch.float16)[2] == 0.0 22assert policy_gap(torch.bfloat16)[2] > 0.0
Output
1FP16: training=1.0039062, rollout=1.0039062, probability_gap=0.0000000 2BF16: training=1.0000000, rollout=1.0078125, probability_gap=0.0019531

An RL precision contract should therefore add a direct train-versus-rollout comparison: record logits or log probabilities for matched prompts, measure divergence under the actual training and generation stacks, and check reward and held-out quality across seeds. Promote scaled FP16 over BF16 only when it closes that measured mismatch without introducing overflow or quality regressions.

The final cell brings the lesson back to experiment tracking. The numbers below are illustrative recorded outcomes, not benchmark claims. They show the review rule you should apply after running the same classifier, data fingerprint, seed policy, held-out supported_evidence_f1 evaluation, and target-GPU profile for every precision configuration. One extra BF16 run changes hardware on purpose so the comparison filter has something to reject.

precision-run-decision.py
1@dataclass(frozen=True) 2class PrecisionRun: 3 run_id: str 4 policy: str 5 artifact: str 6 data_fingerprint: str 7 seed_policy: str 8 eval_suite: str 9 hardware_profile: str 10 supported_evidence_f1: float 11 nonfinite_steps: int 12 peak_memory_gib: float 13 examples_per_second: int 14 evidence: str 15 16def fixture_run( 17 run_id: str, 18 policy: str, 19 supported_evidence_f1: float, 20 nonfinite_steps: int, 21 peak_memory_gib: float, 22 examples_per_second: int, 23 *, 24 hardware_profile: str = contract.hardware_profile, 25) -> PrecisionRun: 26 return PrecisionRun( 27 run_id=run_id, 28 policy=policy, 29 artifact=contract.artifact, 30 data_fingerprint=contract.data_fingerprint, 31 seed_policy=contract.seed_policy, 32 eval_suite=contract.eval_suite, 33 hardware_profile=hardware_profile, 34 supported_evidence_f1=supported_evidence_f1, 35 nonfinite_steps=nonfinite_steps, 36 peak_memory_gib=peak_memory_gib, 37 examples_per_second=examples_per_second, 38 evidence="illustrative_fixture", 39 ) 40 41runs = ( 42 fixture_run("run_fp32", "fp32", 0.93, 0, 4.47, 800), 43 fixture_run("run_fp16_plain", "fp16_unscaled", 0.88, 3, 2.98, 1240), 44 fixture_run("run_fp16_scaled", "fp16_scaled", 0.93, 0, 2.98, 1190), 45 fixture_run("run_bf16", "bf16", 0.93, 0, 2.98, 1310), 46 fixture_run("run_bf16_other_hardware", "bf16", 0.93, 0, 2.98, 1770, hardware_profile="h100-80gb-single-gpu"), 47) 48 49def comparable_to_contract(run: PrecisionRun) -> bool: 50 return ( 51 run.artifact == contract.artifact 52 and run.data_fingerprint == contract.data_fingerprint 53 and run.seed_policy == contract.seed_policy 54 and run.eval_suite == contract.eval_suite 55 and run.hardware_profile == contract.hardware_profile 56 ) 57 58def passes_gates(run: PrecisionRun) -> bool: 59 return ( 60 run.supported_evidence_f1 >= contract.required_supported_evidence_f1 61 and run.nonfinite_steps <= contract.permitted_nonfinite_steps 62 ) 63 64comparable_candidates = [run for run in runs if run.policy != contract.baseline and comparable_to_contract(run)] 65gate_eligible = [run.run_id for run in comparable_candidates if passes_gates(run)] 66rejected_gates = [run.run_id for run in comparable_candidates if not passes_gates(run)] 67excluded_noncomparable = [run.run_id for run in runs if not comparable_to_contract(run)] 68 69print(f"gate_eligible_runs={','.join(gate_eligible)}") 70print(f"rejected_gate_runs={','.join(rejected_gates)}") 71print(f"excluded_noncomparable_runs={','.join(excluded_noncomparable)}") 72print("decision=BLOCKED_FIXTURE_ONLY_RUN_MEASURED_PROFILE") 73print("next_metrics=examples_per_second,peak_memory,supported_evidence_f1,nonfinite_steps")
Output
1gate_eligible_runs=run_fp16_scaled,run_bf16 2rejected_gate_runs=run_fp16_plain 3excluded_noncomparable_runs=run_bf16_other_hardware 4decision=BLOCKED_FIXTURE_ONLY_RUN_MEASURED_PROFILE 5next_metrics=examples_per_second,peak_memory,supported_evidence_f1,nonfinite_steps

The right result isn't "BF16 wins because it's modern." Both scaled FP16 and BF16 pass this small fixture, and both require real measurements under the declared contract. The faster run_bf16_other_hardware value can't rank against them because its accelerator changed. BF16 is often simpler to operate because it commonly avoids loss scaling, but only a comparable controlled run can justify promotion.

FP8 is a later optimization, not a default answer

FP8 reduces compute storage again, but its reduced range and resolution require managed scaling recipes. FP8 isn't one layout: the FP8 formats paper specifies complementary E4M3 and E5M2 encodings for deep-learning workloads. NVIDIA Transformer Engine 2.16.0 documents a hybrid recipe that uses E4M3 during the forward pass and E5M2 during the backward pass, plus delayed, current, and block-scaling recipes for supported accelerators.[8]Reference 8FP8 Formats for Deep Learning.https://arxiv.org/abs/2209.05433[9]Reference 9Using FP8 and FP4 with Transformer Enginehttps://docs.nvidia.com/deeplearning/transformer-engine/user-guide/examples/fp8_primer.html

That's enough orientation here. Don't add FP8 to a training proposal until BF16 or scaled FP16 is measured, quality checks exist, and the team can operate the scaling policy. Precision work should reduce measured cost without creating unexplained convergence risk.

Common pitfalls

BF16 is mistaken for an FP32 optimizer replacement

  • Symptom: A BF16-only parameter update stops improving loss even though gradients are finite.
  • Cause: Wide range was confused with fine resolution near current weights.
  • Fix: Keep FP32 update state under ordinary AMP and log the storage policy.

FP16 silently loses gradients

  • Symptom: Training appears stable but supported_evidence_f1 lags the FP32 baseline.
  • Cause: Small unscaled FP16 gradients underflow to zero.
  • Fix: Use GradScaler for FP16, track non-finite or skipped steps, and compare the declared held-out metric against the same baseline.

Gradient clipping sees scaled values

  • Symptom: Clipping behaves erratically or training diverges under FP16 AMP.
  • Cause: The run clips gradients before scaler.unscale_(optimizer).
  • Fix: Unscale first, then clip, then let the scaler perform or skip the optimizer step.

Memory claims omit optimizer state

  • Symptom: "Half-memory" planning fails when the job is scheduled.
  • Cause: Only activation dtype changed while FP32 parameters and Adam moments remain large.
  • Fix: Log a component-level memory profile or accounting budget, not a dtype slogan.

Distributed bandwidth remains high

  • Symptom: BF16 compute is enabled, but cross-worker traffic is still a bottleneck.
  • Cause: Reduction payloads remain FP32.
  • Fix: Inspect and record reduce_dtype separately, then measure held-out metric and communication changes before promotion.
Complete the lesson

Mastery Check

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

1.A model weight is stored directly in BF16 at value 1.0, and an optimizer step tries to subtract 1.0e-4 from it. What should you expect, and why does mixed precision keep an FP32 update path?

Correct answer: The BF16 value can remain 1.0 because 1.0e-4 is smaller than BF16 spacing near 1.0; FP32 update state preserves small accumulated changes.

Exponent range determines which standalone magnitudes BF16 can represent, but its 7 fraction bits determine spacing near the current weight. The gap around 1.0 is larger than 1.0e-4, so the subtraction can round back to 1.0. FP32 update state provides enough resolution for such changes to accumulate.

2.You cast two FP32 values, 1.2e-8 and 1.0e5, to FP16 and to BF16. Which outcome matches the formats' ranges?

Correct answer: FP16 turns the tiny value into 0 and the large value into Inf; BF16 keeps both values finite, with rounding.

This is a range question rather than a nearby-resolution question. FP16 cannot represent 1.2e-8 or 1.0e5, so the values become zero and infinity. BF16 retains FP32-like exponent range and keeps both magnitudes finite, although its limited fraction bits cause rounding.

3.An FP16 run overflows on a forward activation of 1.0e5 before the loss is formed. The team enables a larger GradScaler loss scale. What should they expect?

Correct answer: The forward overflow can remain; loss scaling rescues small backward gradients but does not extend FP16's forward range.

GradScaler multiplies the loss so small backward gradients are formed at representable FP16 magnitudes, then unscales them before the update. It does not change FP16's largest finite value or repair an activation that already overflowed during the forward pass.

4.An FP16 AMP run uses loss scale 1024 and gradient clipping. In one step, scaled gradients may include a tiny true gradient such as 1.2e-8 and a spiky true gradient such as 1.0e2. Which control flow preserves the intended optimizer update?

Correct answer: Scale the loss, run backward, call scaler.unscale_(optimizer), clip the unscaled gradients, then let scaler.step skip non-finite updates and scaler.update adjust the scale.

Scaling makes the tiny gradient visible, but the spiky gradient can overflow after multiplication. Unscaling before clipping restores the true gradient units, so the clipping threshold has its intended meaning. If any scaled gradient is non-finite, the update must be skipped and the dynamic scale adjusted rather than applying corrupted values.

5.A memory budget contains 100 million parameters, 100 million gradients, two 100 million-value Adam moment buffers, and 800 million saved activation values. FP32 uses 4 bytes per value. An AMP candidate leaves parameters, gradients, and Adam moments in FP32 but saves these activations in 2-byte low precision. What reduction should this inventory claim?

Correct answer: About 33%, from 4.47 GiB to 2.98 GiB, because only the inventoried activations are halved.

The FP32 inventory contains 1.2 billion values at four bytes each, or about 4.47 GiB. Casting only the 800 million activations to two bytes saves about 1.49 GiB, leaving about 2.98 GiB. Unchanged FP32 parameters, gradients, and Adam moments prevent a 50% total reduction.

6.A distributed run uses BF16 for eligible matrix computations and all-reduces 100 million gradient values. What must the run record before claiming reduced communication bandwidth?

Correct answer: It must record compute_dtype, FP32 update storage, and reduce_dtype separately; a BF16 reduce payload is about 0.19 GiB versus 0.37 GiB for FP32, but only if reduction actually uses BF16.

Computation and gradient communication have independent dtype policies. BF16 matrix operations do not prove that all-reduce payloads use BF16. For 100 million values, changing the actual reduction dtype from four-byte FP32 to two-byte BF16 halves the payload, but the run record must establish that configuration before making the claim.

7.A precision contract requires the same artifact, data, seeds, eval suite, and A100 hardware, with supported_evidence_f1 >= 0.92 and nonfinite_steps <= 0. Candidate runs report: fp16_unscaled on A100 has F1 0.88 and 3 nonfinite steps; fp16_scaled on A100 has F1 0.93 and 0 nonfinite steps; bf16 on A100 has F1 0.93 and 0 nonfinite steps; bf16 on H100 has F1 0.93, 0 nonfinite steps, and the highest throughput. Which decision follows?

Correct answer: Only the A100 fp16_scaled and A100 bf16 runs are gate eligible; fp16_unscaled fails the gates and the H100 run is excluded from the A100 precision ranking.

Eligibility requires two filters. Runs must first match the artifact, data, seeds, evaluation suite, and hardware in the comparison contract. Comparable runs must then satisfy both the declared held-out metric and non-finite-step gates. Higher throughput on different hardware cannot rank a precision policy in the A100 experiment.

8.During RL fine-tuning, matched prompts produce nearby FP32 logits in the training and rollout stacks. BF16 rounds the two first logits to 1.0000000 and 1.0078125, while FP16 rounds both to 1.0039062. What experiment is justified?

Correct answer: Compare scaled FP16 with BF16 on matched train-rollout probability divergence, non-finite steps, reward, and held-out quality under the same hardware and seeds.

The example exposes a plausible BF16 rounding mismatch, not a universal format winner. Scaled FP16 may preserve closer policy probabilities because it has more fraction bits, but it still has narrower range and can underflow or overflow. A controlled RL comparison must measure both policy consistency and optimization quality before changing the precision policy.

8 questions remaining.

Next Step
Continue to Distributed Training: FSDP & ZeRO

You can now choose FP16, BF16, and update-state precision from measured stability and memory evidence. Distributed training keeps those dtype decisions explicit while model states, gradients, and communication are split across workers.

PreviousSupervised Fine-Tuning Pipeline
Share this article
XFacebookLinkedInBlueskyRedditHacker NewsEmail
References

Automatic Mixed Precision package - torch.amp

PyTorch Contributors · 2026

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

Mixed Precision Training.

Micikevicius, P., et al. · 2018

https://arxiv.org/abs/1710.03740

A Study of BFLOAT16 for Deep Learning Training.

Kalamkar, D., et al. · 2019

https://arxiv.org/abs/1905.12322

ZeRO: Memory Optimizations Toward Training Trillion Parameter Models.

Rajbhandari, S., et al. · 2020 · SC 2020

https://arxiv.org/abs/1910.02054

PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel.

Zhao, Y., et al. · 2023 · VLDB 2023

https://arxiv.org/abs/2304.11277

FullyShardedDataParallel

PyTorch Contributors · 2026

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

Defeating the Training-Inference Mismatch via FP16

Qi, P., Liu, Z., Zhou, X., Pang, T., Du, C., Lee, W. S., & Lin, M. · 2025

https://arxiv.org/abs/2510.26788

FP8 Formats for Deep Learning.

Micikevicius, P., et al. · 2022

https://arxiv.org/abs/2209.05433

Using FP8 and FP4 with Transformer Engine

NVIDIA · 2026

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

Discussion

Questions and insights from fellow learners.

Discussion loads when you reach this section.