The GPU-hour that runs out
Say an Indian NLP startup gets an allocation of GPU-hours on a shared compute grid — the kind of subsidized cluster access schemes like the IndiaAI Mission exist to provide for exactly this reason — to train a Hindi-Tamil speech transcription transformer. The allocation is fixed: a set number of GPU-hours per month, on rented A100-class cards shared with other teams. Two constraints bite at once. First, the model in full 32-bit precision barely fits in the card's memory alongside its optimizer state, so the team is stuck with a small batch size, which makes gradient estimates noisy and training slow to converge. Second, every hour of training against that quota is an hour that cannot be used for the next experiment. Mixed-precision training addresses both constraints with one change to the training loop: it does not touch the model architecture, the data, or the optimizer's update rule — it only changes the numeric format that weights, activations, and gradients are stored and multiplied in during the forward and backward passes. That single change buys a large chunk of GPU memory back (fitting a bigger batch on the same card) and a large chunk of raw multiply-accumulate throughput back (specialized hardware units run half-precision matrix multiplies far faster than full-precision ones). Understanding exactly why requires going down to the bit layout of a floating-point number.
What a float actually is: sign, exponent, mantissa
A 32-bit float (FP32, the default in almost all deep learning frameworks until you opt out of it) packs a real number into three fields: 1 sign bit, 8 exponent bits, and 23 mantissa (fraction) bits. The value is reconstructed as sign × 1.mantissa × 2^(exponent − 127), where 127 is the exponent bias. The exponent field controls range — how large or small a number the format can represent at all — and the mantissa field controls precision — how many significant digits it can represent within that range.
FP16 (IEEE half precision) squeezes the same idea into 16 bits: 1 sign, 5 exponent, 10 mantissa, bias 15. Fewer exponent bits means a drastically smaller representable range; fewer mantissa bits means coarser precision. Concretely, FP16's smallest normal (non-subnormal) magnitude is 2⁻¹⁴ ≈ 6.104 × 10⁻⁵, and going below that into subnormal numbers, the smallest representable nonzero magnitude at all is 2⁻²⁴ ≈ 5.96 × 10⁻⁸. Anything smaller than that rounds to exact zero. Compare that to FP32's smallest normal magnitude of roughly 1.18 × 10⁻³⁸ — thirty orders of magnitude more headroom. This gap is the entire reason mixed-precision training needs a safety mechanism, which the next two sections build up to.
BF16 (bfloat16) is a third format worth knowing before going further: also 16 bits, but split as 1 sign, 8 exponent, 7 mantissa — the same exponent width as FP32, at the cost of only 7 mantissa bits instead of FP16's 10. It trades precision for range instead of trading both away.
Why FP16 is actually faster, not just smaller
Halving the number of bytes per number matters for memory bandwidth — moving half as many bytes between GPU memory and the compute cores in the same amount of time is itself a speedup — but the larger effect is that modern NVIDIA GPUs (from Volta onward) contain dedicated matrix-multiply hardware called Tensor Cores that only activate their full throughput when operands arrive in a reduced-precision format like FP16 or BF16. NVIDIA's published specifications for the Volta-generation V100, for example, list roughly 15.7 TFLOPS of FP32 throughput on the ordinary CUDA cores versus roughly 125 TFLOPS on the Tensor Cores running FP16 matrix multiplies with FP32 accumulation — close to an order of magnitude higher. A training step in a transformer is dominated by matrix multiplications (the query/key/value projections, the feed-forward layers), so routing those matmuls through Tensor Cores in FP16 is where the wall-clock speedup actually comes from, not merely from smaller numbers taking less time to add.
Note the phrase "FP32 accumulation" above — it is not incidental. A Tensor Core takes FP16 inputs but sums the products of a matrix multiply-accumulate in FP32 internally, only rounding back down to FP16 (or leaving it in FP32) at the end. This matters because a single output element of a matmul is a sum over potentially thousands of products; accumulating that many terms in FP16's coarse precision would compound rounding error badly, while accumulating in FP32 keeps the running sum accurate regardless of how coarse the individual FP16 inputs were.
The catch: gradients quietly vanish to zero
If you naively cast a model's weights, activations, and gradients to FP16 and train as usual, loss curves typically go flat or diverge. The reason is the range gap from the section above: gradient magnitudes late in training, or gradients in deep layers with vanishing-gradient effects, routinely fall below FP16's representable floor. Once a value is smaller in magnitude than 2⁻²⁴ ≈ 5.96 × 10⁻⁸, casting it to FP16 does not round it — it flushes it to exactly 0.0, and that parameter simply stops learning for that step, silently and without any error.
The following trace makes this exact, using values that are clean powers of two so every step is exact — no floating-point rounding ambiguity hides what is happening:
import numpy as np
# A gradient that is tiny relative to fp16's representable range
grad = 2.0 ** -30 # true value ≈ 9.313225746e-10
print(np.float16(grad)) # 0.0 -- flushed to zero, gradient lost
# Loss scaling: multiply the loss (and hence every gradient) by S
S = 2.0 ** 18 # S = 262144
scaled_grad = grad * S # 2^-30 * 2^18 = 2^-12 = 0.000244140625
print(np.float16(scaled_grad)) # 0.000244140625 -- exactly representable
# Unscale in fp32 before the optimizer touches the weights
recovered = np.float32(np.float16(scaled_grad)) / S
print(recovered) # 9.313226e-10 -- original value recovered
Trace it: grad = 2⁻³⁰ is far below FP16's subnormal floor of 2⁻²⁴, so np.float16(grad) rounds it to exactly 0.0 — the gradient is destroyed before the optimizer ever sees it. Multiplying by S = 2¹⁸ shifts it up to 2⁻¹², which is comfortably above FP16's minimum normal of 2⁻¹⁴ and, being an exact power of two, is representable with zero rounding error. After the backward pass computes gradients in this scaled space, dividing by the same S in FP32 recovers the original value exactly: 2⁻¹² / 2¹⁸ = 2⁻³⁰, matching the input to the last printed digit. Nothing about the model or the math changed — only the exponent that the value occupied during the FP16-precision segment of the computation.
Loss scaling, and why it has to be dynamic in practice
Multiplying the scalar loss by a constant S before calling backward() scales every gradient in the computational graph by the same S, by the chain rule — it costs one multiply and shifts the entire gradient distribution up into FP16's representable range. The optimizer step then divides the FP16 gradients by S back in FP32 before applying the update, exactly as traced above. The one thing this scheme has to guard against is picking S too large: an already-large gradient multiplied by a big S can overflow FP16's maximum representable magnitude (65,504) and become inf or nan instead of underflowing to zero — the opposite failure mode. Standard implementations (PyTorch's torch.cuda.amp.GradScaler among them) handle this with dynamic loss scaling: start with a large S, and after every backward pass, check whether any gradient came back as inf or nan. If so, discard that step's update entirely and halve S before retrying. If many consecutive steps pass cleanly, periodically double S again to keep testing whether more headroom is safe. This turns loss scaling from a manually-tuned hyperparameter into a self-correcting mechanism that finds the largest safe scale automatically.
BF16: sidestepping the range problem instead of patching it
Recall bfloat16 keeps FP32's 8-bit exponent field, so its dynamic range matches FP32's — a minimum normal magnitude around 1.18 × 10⁻³⁸ instead of FP16's 6.10 × 10⁻⁵. A gradient of 10⁻⁸ or 10⁻¹⁵ that would flush to zero in FP16 stays a nonzero, correctly-exponentiated value in BF16; only its mantissa gets truncated to 7 bits, giving roughly 2⁻⁸ ≈ 0.4% relative rounding error per value, compared with FP16's roughly 2⁻¹¹ ≈ 0.05%. This is exactly why BF16-trained models, common on TPUs and on Ampere-generation-and-later NVIDIA GPUs, typically skip loss scaling altogether: the failure mode that loss scaling exists to prevent — exponent underflow — mostly does not occur in BF16, because the exponent field was never the constrained resource. The cost paid instead is coarser precision on every individual value, which in practice tends to matter less for training stability than a hard flush-to-zero, since gradient descent is fairly tolerant of a little extra per-step noise but not of a gradient that silently vanishes.
The mixed-precision training loop, end to end
Putting loss scaling together with the FP16 compute path gives the full loop used by essentially every modern mixed-precision training framework. The load-bearing design choice, visible in the diagram below, is that an FP32 copy of the weights — the "master weights" — persists across steps and is the only copy the optimizer ever updates; the FP16 copy used for the fast forward/backward pass is re-derived from it at the start of every step, never the other way around.
Where the memory savings actually come from
Common misconception: "mixed-precision training halves the model's memory footprint." This is not generally true for the weight-and-optimizer-state memory, and the arithmetic below shows exactly why, using the Adam optimizer (the default for transformer training, which keeps two extra FP32 state tensors per parameter — a first-moment estimate m and second-moment estimate v).
| Component | FP32-only training | Mixed precision (FP32 master + FP16 compute) |
|---|---|---|
| Weights | 4 bytes/param (FP32) | 4 bytes/param (FP32 master) + 2 bytes/param (FP16 working copy) |
| Gradients | 4 bytes/param (FP32) | 2 bytes/param (FP16) |
| Adam momentum (m) | 4 bytes/param (FP32) | 4 bytes/param (FP32) |
| Adam variance (v) | 4 bytes/param (FP32) | 4 bytes/param (FP32) |
| Total per parameter | 16 bytes | 16 bytes |
| Total for a 125M-parameter model | 16 × 125,000,000 = 2,000,000,000 bytes = 2.00 GB | 16 × 125,000,000 = 2,000,000,000 bytes = 2.00 GB |
The totals are identical, because the FP32 master copy and the two FP32 Adam state tensors are still full-size — the FP16 working copy of the weights is pure addition on top of that, not a replacement. This is a well-documented finding (it is the starting point of Microsoft's ZeRO memory-optimization work on large-scale training) and it directly contradicts the "mixed precision = half the memory" shorthand that circulates in casual explanations of the technique.
The real memory win is in activations — the intermediate tensors produced during the forward pass and cached for use in the backward pass, which in mixed-precision training are stored in FP16 rather than FP32. Take one such tensor from a BERT-base-scale transformer layer: batch size 32, sequence length 512, hidden dimension 768. That tensor holds 32 × 512 × 768 = 12,582,912 elements. In FP32 that is 4 × 12,582,912 = 50,331,648 bytes = exactly 48 MiB; in FP16 it is exactly 24 MiB. A transformer layer caches many such tensors — the outputs of each linear projection, each attention score matrix, each normalization step — across every layer of the network, and it is this activation memory, not the weight-and-optimizer memory, that dominates the footprint of training a large batch on a long sequence. Halving every one of those tensors is what actually frees up the headroom to run a bigger batch on the same GPU — which is precisely what raises the trained model's throughput within a fixed GPU-hour budget, alongside the raw Tensor Core speedup from the earlier section.
Active recall
Attempt each question before reading its answer.
- A model uses BF16 instead of FP16 for its compute path. Why can it typically skip loss scaling?
- Is a gradient of 3 × 10⁻⁵ representable as an FP16 normal number, or does it fall into subnormal territory? Show the comparison.
- Using the byte ledger above, compute total weight-and-optimizer memory (Adam) for a 500-million-parameter model in (a) full FP32 and (b) mixed precision with an FP32 master copy. What do the two numbers tell you about where mixed precision's real advantage lies?
- Trace:
grad = 2⁻²⁰, scaleS = 2⁶. IsSlarge enough to lift the scaled gradient into FP16's normal range? Show the arithmetic. - Why must the FP32 master weight copy persist across optimizer steps, instead of reconstructing an FP32 weight from the FP16 copy fresh at each step?
- Tensor Cores accept FP16 input operands but accumulate a matrix multiply's running sum in FP32. Why does that design choice matter?
Answers
1. BF16 keeps FP32's 8-bit exponent field (bias 127), so its dynamic range matches FP32's — roughly 1.18 × 10⁻³⁸ up to 3.4 × 10³⁸ — rather than FP16's much narrower 5-bit-exponent range. A gradient that would underflow to exact zero in FP16 stays a representable (if less precise) nonzero value in BF16, so the failure mode loss scaling exists to prevent mostly does not arise.
2. FP16's minimum normal magnitude is 2⁻¹⁴ ≈ 6.104 × 10⁻⁵. Since 3 × 10⁻⁵ is smaller than that, it is not representable as a normal FP16 number — it falls into subnormal range (down to a floor of 2⁻²⁴ ≈ 5.96 × 10⁻⁸), meaning it is still nonzero but has fewer effective significant bits than a normal FP16 value.
3. Full FP32: 16 bytes/param × 500,000,000 = 8,000,000,000 bytes = 8.00 GB. Mixed precision with FP32 master: also 16 bytes/param × 500,000,000 = 8.00 GB — identical. This shows mixed precision's memory advantage does not come from the weight/optimizer allocation at all; it comes from halving activation memory (FP16 forward-pass tensors) and from the resulting ability to raise batch size within a fixed GPU budget, combined with the separate Tensor Core throughput speedup.
4. Scaled value = 2⁻²⁰ × 2⁶ = 2⁻¹⁴ = 0.00006103515625, which is exactly FP16's minimum normal magnitude — the boundary case, just barely inside normal range. S = 2⁶ is the smallest power-of-two scale that succeeds here; anything smaller would leave the gradient in subnormal territory or push it toward underflow.
5. Individual weight updates (learning rate × gradient) are frequently far smaller in magnitude than the weight itself. FP16's roughly 10-bit mantissa (about 2⁻¹¹ relative precision) would round many of these small updates to exactly zero when added directly to an FP16-only weight, silently stalling learning — especially late in training as gradients shrink. An FP32 accumulator lets those small updates add up correctly over many steps before the value is ever rounded down to FP16 for the next forward pass.
6. A single output element of a matrix multiply sums potentially thousands of products. Even if every individual product is computed from FP16 inputs, accumulating that many terms in FP16 precision would compound rounding error badly over the length of the sum. Accumulating in FP32 keeps the running total accurate regardless of the input rounding, so the speed and memory benefit of FP16 inputs is captured without paying FP16's precision cost on the long summation.
Think About It
Think about this: How would you explain mixed-precision training: speed and memory efficiency to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.
Key Takeaways — Summary and Recap
Let us recap what we covered: the core ideas behind mixed-precision training: speed and memory efficiency, how they connect to real-world applications, and why they matter for your journey in computer science. Remember these key points as you move forward. For competitive exam preparation (CBSE, JEE, BITSAT), focus on understanding the WHY behind each concept, not just the WHAT.