Mixed Precision Training › Loss Scaling

Michael BrenndoerferJanuary 22, 20267 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 228Mixed Precision Training › Loss Scaling
Reading section 4 of 11 · Loss Scaling · 7 minSection 4 of 11 · 7 min

Book contents

Section 4 of 11 · 7 min read

Mixed Precision Training › Loss ScalingOne section · Sections in this chapter4 / 11 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Loss scaling shifts gradient values into a range where FP16 can represent them accurately, then undoes the shift before applying them to the weights.

The Core Idea

Gradient values during training tend to be small, often clustering in the range [10−8,10−2][10^{-8}, 10^{-2}]. FP16 can represent values in this range, but values near the bottom of the range lose most of their significant bits. More critically, underflow to zero is common for the smallest gradients.

The trick is to multiply the loss by a large scalar SS (the scale factor) before the backward pass. By the chain rule, every gradient throughout the network is multiplied by SS. This shift moves all gradient values upward in magnitude, reducing underflow and making them comfortably representable in FP16. Think of it as temporarily stretching the gradient magnitude range so that the values that would have been too small for FP16 are now safely within its representable range.

The chain rule argument is worth spelling out explicitly. If the loss is LL and the scale factor is SS, the scaled loss is L′=S⋅LL' = S \cdot L. The gradient of L′L' with respect to any parameter θ\theta is:

∂L′∂θ=S⋅∂L∂θ\frac{\partial L'}{\partial \theta} = S \cdot \frac{\partial L}{\partial \theta}

So the backward pass on the scaled loss automatically produces gradients that are each multiplied by SS, with no additional code required. The chain rule propagates the scaling factor through the entire computation graph.

Concretely, if the unscaled gradient is gg and the scale factor is SS, then the scaled gradient stored in FP16 is g~=S⋅g\tilde{g} = S \cdot g. After the backward pass, before the optimizer update, we divide by SS:

g=g~Sg = \frac{\tilde{g}}{S}

where:

  • gg: the true gradient that would have been computed in FP32
  • g~\tilde{g}: the scaled gradient stored in FP16
  • SS: the loss scale factor, typically a power of 2 to avoid rounding

The division by SS is done in FP32 (on the master weight gradients), so it does not suffer from precision loss. The whole operation is mathematically a no-op: you multiply by SS before the backward pass and divide by SS after. The only effect is that the intermediate representation in FP16 uses a better portion of the available bit range.

Why use powers of 2 for SS? Because powers of 2 can be divided exactly in floating point arithmetic. Dividing by 2152^{15} is equivalent to subtracting 15 from the FP32 exponent, which introduces zero rounding error. Any other value of SS would introduce a small rounding error in the unscaling step, partially defeating the purpose.

A Numerical Walk-Through

Let us trace through the full loss scaling procedure with concrete numbers to see precisely what changes and what stays the same.

Suppose a gradient value computed during the backward pass, at some layer, is g=0.00000245g = 0.00000245 (approximately 2.45×10−62.45 \times 10^{-6}). In FP32, this is representable with full precision. In FP16, the nearest representable normal value is approximately 2.44×10−62.44 \times 10^{-6}, a small rounding error of about 0.4%. So far, FP16 handles this reasonably well.

Now suppose the gradient update for a weight with value 1.0 is η⋅g=10−4×2.45×10−6=2.45×10−10\eta \cdot g = 10^{-4} \times 2.45 \times 10^{-6} = 2.45 \times 10^{-10}. The ULP for the value 1.0 in FP16 is approximately 9.77×10−49.77 \times 10^{-4}. A gradient update of 2.45×10−102.45 \times 10^{-10} is roughly 4 million times smaller than the ULP. In FP16, the weight simply does not change.

With loss scaling by S=215=32768S = 2^{15} = 32768:

The scaled gradient is g~=S⋅g=32768×2.45×10−6=0.0803\tilde{g} = S \cdot g = 32768 \times 2.45 \times 10^{-6} = 0.0803. This is in the middle of FP16's normal range and carries full precision. The FP16 representation is g~fp16≈0.0803\tilde{g}_{\text{fp16}} \approx 0.0803, negligible rounding.

After the backward pass, the unscaling step divides in FP32: grecovered=0.0803/32768=2.45×10−6g_{\text{recovered}} = 0.0803 / 32768 = 2.45 \times 10^{-6}. This is the original gradient, recovered with essentially no error. The optimizer applies this gradient to the FP32 master weight, and the update proceeds correctly.

The effective change is that the gradient 2.45×10−62.45 \times 10^{-6} was temporarily multiplied by 32768 during the FP16 backward pass to move it from the subnormal/dangerous region of FP16's range into a well-represented middle region, then divided back to restore the original value in FP32 before the optimizer touches it.

Choosing the Scale Factor

The scale factor must be large enough to prevent underflow but small enough to prevent overflow. If SS is too large, the scaled gradients themselves overflow FP16 (exceeding 65504), producing Inf values. If SS is too small, gradients underflow to zero.

Static loss scaling uses a fixed SS, typically around 215=327682^{15} = 32768 or 216=655362^{16} = 65536. This works reasonably well for many models but requires tuning. A scale that works for one architecture may fail for another.

Dynamic loss scaling addresses this by adjusting SS automatically during training. The algorithm is:

  1. Start with a large scale factor (e.g., 2152^{15}).
  2. After each backward pass, check whether any gradient is Inf or NaN.
  3. If overflow is detected: skip the optimizer step (the corrupted gradients would harm the model), reduce SS by a constant factor (e.g., halve it), and continue.
  4. If no overflow is detected: periodically increase SS (e.g., multiply by 2 every 2000 steps, or whenever overflow has not been seen for a stretch).
Dynamic Loss Scaling

Dynamic loss scaling is the standard approach in modern frameworks. PyTorch's torch.amp.GradScaler implements exactly this algorithm. The scale starts high and self-adjusts so that it stays as large as possible without causing overflow, maximizing the precision of gradient representation throughout training.

The adaptive behavior of dynamic loss scaling is important in practice. Early in training, gradients can be large and scale should be lower. As training stabilizes, gradients become smaller and scale can safely increase. A static scale chosen for late-stage training would overflow early; a scale chosen for early training would underflow late.

The skipped steps when overflow is detected are not wasted. The model state is preserved; only the corrupted update is discarded. The scale factor is immediately reduced, which prevents the next step from overflowing as well. In practice, overflow events are rare after the initial warmup period, and the skipped steps represent a tiny fraction of total training compute.

The growth interval (the number of consecutive clean steps before the scale increases) is a subtle but important tuning parameter. The default of 2000 in PyTorch reflects a conservative balance: you want the scale to grow quickly enough to keep gradients well-represented, but not so aggressively that it oscillates between overflow and recovery. If your training shows frequent overflow events throughout, consider reducing the growth factor or increasing the growth interval. If overflow events disappear quickly but you suspect underflow later in training, a shorter growth interval allows the scale to rise faster and capture fine-grained gradient information.

Loss Scaling with Gradient Clipping

Gradient clipping (see the chapter on Gradient Clipping in Part X) caps gradient norms before they are applied to weights, preventing catastrophically large updates. When using loss scaling, gradient clipping must happen on the unscaled gradients. The GradScaler.unscale_() method in PyTorch handles this by dividing the scaled gradients by SS in-place before you call the gradient clip function.

The correct order of operations is:

  1. Scaled backward pass: scaled_loss = scaler.scale(loss); scaled_loss.backward()
  2. Unscale gradients: scaler.unscale_(optimizer)
  3. Clip gradients: torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm)
  4. Optimizer step: scaler.step(optimizer) (internally checks for Inf/NaN and skips if found)
  5. Update scale: scaler.update()

Applying gradient clipping before unscaling would clip the scaled gradients at the wrong threshold, effectively tightening the clip by a factor of SS. If your loss scale is 65536 and your intended clip norm is 1.0, clipping before unscaling would apply a clip of 1.0/65536≈0.0000151.0 / 65536 \approx 0.000015, cutting off nearly all gradient signal.