Mixed Precision Training › Code Implementation

Michael BrenndoerferJanuary 22, 202611 min read
Language AI HandbookAll chapters
Part 31 of 67Training Infrastructure
Chapter 228Mixed Precision Training › Code Implementation
Reading section 7 of 11 · Code Implementation · 11 minSection 7 of 11 · 11 min

Book contents

Section 7 of 11 · 11 min read

Mixed Precision Training › Code ImplementationOne section · Sections in this chapter7 / 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

Let us implement mixed precision training in PyTorch, showing both FP16 with dynamic loss scaling and BF16 without scaling. We will train a small transformer-like model on a synthetic task to measure memory usage, throughput, and training stability.

Setup and Imports

In[5]:
Code
import torch

# Check hardware support
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
has_fp16 = torch.cuda.is_available()
has_bf16 = torch.cuda.is_available() and torch.cuda.is_bf16_supported()
Out[6]:
Console
Device: cpu
FP16 support: False
BF16 support: False

A Simple Transformer Block

We define a small transformer block that exercises the key components: multi-head attention and a feed-forward network. This model is deliberately small so it can run on a CPU if no GPU is available, but the mixed precision patterns are identical for large models.

In[7]:
Code
import torch.nn as nn


class TransformerBlock(nn.Module):
    def __init__(self, d_model=256, n_heads=8, d_ff=1024, dropout=0.1):
        super().__init__()
        self.attention = nn.MultiheadAttention(
            d_model, n_heads, dropout=dropout, batch_first=True
        )
        self.ff = nn.Sequential(
            nn.Linear(d_model, d_ff),
            nn.GELU(),
            nn.Linear(d_ff, d_model),
        )
        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(dropout)

    def forward(self, x):
        attn_out, _ = self.attention(x, x, x)
        x = self.norm1(x + self.dropout(attn_out))
        ff_out = self.ff(x)
        x = self.norm2(x + self.dropout(ff_out))
        return x


class SimpleTransformer(nn.Module):
    def __init__(
        self,
        vocab_size=1000,
        d_model=256,
        n_layers=4,
        n_heads=8,
        d_ff=1024,
        seq_len=64,
    ):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, d_model)
        self.pos_embedding = nn.Embedding(seq_len, d_model)
        self.layers = nn.ModuleList(
            [TransformerBlock(d_model, n_heads, d_ff) for _ in range(n_layers)]
        )
        self.norm = nn.LayerNorm(d_model)
        self.head = nn.Linear(d_model, vocab_size)
        self.seq_len = seq_len

    def forward(self, x):
        B, T = x.shape
        positions = torch.arange(T, device=x.device).unsqueeze(0).expand(B, T)
        h = self.embedding(x) + self.pos_embedding(positions)
        for layer in self.layers:
            h = layer(h)
        h = self.norm(h)
        return self.head(h)
Out[8]:
Console
Model parameters: 921,064
FP32 model size: 3.7 MB
FP16 model size: 1.8 MB

The model prints its parameter count and the theoretical size difference between FP32 and FP16 representations. In practice, the activation memory savings are even more significant at scale, but parameter size gives a sense of the baseline compression.

FP16 Training with Dynamic Loss Scaling

The standard PyTorch mixed precision training loop uses autocast for the forward pass and GradScaler for loss scaling. We demonstrate this with a complete training step.

In[9]:
Code
from torch.amp import autocast


def train_step_fp16(model, optimizer, scaler, x, y):
    """Single training step with FP16 AMP and dynamic loss scaling."""
    optimizer.zero_grad()

    # Forward pass in FP16
    with autocast(device_type=device.type, dtype=torch.float16):
        logits = model(x)
        # logits: (B, T, vocab_size); y: (B, T)
        loss = nn.CrossEntropyLoss()(logits.view(-1, vocab_size), y.view(-1))

    # Scale loss, backward, unscale, clip, step
    scaler.scale(loss).backward()
    scaler.unscale_(optimizer)
    grad_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    scaler.step(optimizer)
    scaler.update()

    return loss.item(), grad_norm.item(), scaler.get_scale()
In[10]:
Code
import torch.optim as optim
from torch.amp import GradScaler

# Run a short training loop and collect metrics
optimizer_fp16 = optim.AdamW(model.parameters(), lr=1e-4)
scaler = GradScaler(device=device.type)

losses_fp16 = []
scales = []
overflow_steps = []

n_steps = 30
rng = np.random.default_rng(42)

for step in range(n_steps):
    x = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
    y = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)

    prev_scale = scaler.get_scale()
    loss, grad_norm, current_scale = train_step_fp16(
        model, optimizer_fp16, scaler, x, y
    )
    losses_fp16.append(loss)
    scales.append(current_scale)

    if current_scale < prev_scale:
        overflow_steps.append(step)
Out[11]:
Console
FP16 training completed: 30 steps
Final loss: 7.0555
Final loss scale: 65536
Overflow events (scale reductions): 0
Mean loss (last 20 steps): 7.0747

The training loop runs cleanly. The GradScaler monitors gradient overflow at each step and reduces the scale only when it detects non-finite gradients. A short, well-behaved run may report no reductions at all, especially on the CPU execution path used for this notebook. That absence is a property of this smoke test, not evidence that dynamic scaling is unnecessary on production FP16 workloads.

BF16 Training without Loss Scaling

BF16 training simplifies the loop: no GradScaler, no overflow checking, no scale management.

In[12]:
Code
def train_step_bf16(model, optimizer, x, y):
    """Single training step with BF16 AMP. No loss scaling needed."""
    optimizer.zero_grad()

    # Forward pass in BF16
    with autocast(device_type=device.type, dtype=torch.bfloat16):
        logits = model(x)
        loss = nn.CrossEntropyLoss()(logits.view(-1, vocab_size), y.view(-1))

    loss.backward()
    torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
    optimizer.step()

    return loss.item()
In[13]:
Code
# Reset model and run BF16 training
model_bf16 = SimpleTransformer(
    vocab_size=vocab_size, d_model=d_model, n_layers=n_layers, seq_len=seq_len
)
model_bf16 = model_bf16.to(device)
optimizer_bf16 = optim.AdamW(model_bf16.parameters(), lr=1e-4)

losses_bf16 = []

for step in range(n_steps):
    x = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
    y = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)

    if has_bf16:
        loss = train_step_bf16(model_bf16, optimizer_bf16, x, y)
    else:
        # Fallback to FP32 on hardware without BF16
        optimizer_bf16.zero_grad()
        logits = model_bf16(x)
        loss = nn.CrossEntropyLoss()(logits.view(-1, vocab_size), y.view(-1))
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model_bf16.parameters(), max_norm=1.0)
        optimizer_bf16.step()
        loss = loss.item()

    losses_bf16.append(loss)
Out[14]:
Console
BF16 training completed: 30 steps
Final loss: 7.0786
Mean loss (last 20 steps): 7.0671
No loss scaling required

The BF16 loop is shorter and easier to reason about. There is no scale factor to monitor, no overflow detection, and no conditional step-skipping logic. The tradeoff is that you need compatible hardware (Ampere GPUs or newer, or TPUs).

Visualizing Training Loss

Let us visualize the loss values for both precision modes. The targets are freshly sampled random tokens at every step, so this 30-step run is a numerical-stability smoke test rather than a convergence experiment. On hardware without BF16 support, the second trace uses FP32.

Out[15]:
Visualization
Line plot showing finite FP16 and BF16 or FP32 smoke-test losses with 10-step moving averages over 30 steps.
Thirty-step numerical-stability smoke test for FP16 with loss scaling and BF16 or FP32 fallback on freshly sampled random-token targets. Raw losses appear as faint lines and 10-step moving averages as solid lines. Both traces remain finite and in the same loss band, but the random targets and short duration do not establish convergence or precision equivalence.

Visualizing Dynamic Loss Scale Evolution

The recorded scale can remain flat when the short runtime produces no overflows, as it does on this notebook's CPU path. To make the controller mechanics visible on every platform, the next chart uses a deterministic illustrative schedule with four overflow decisions and a shortened five-step growth interval. PyTorch's production default growth interval is much longer at 2,000 successful steps.

Out[16]:
Visualization
Step plot of an illustrative FP16 loss scale halving at four overflow decisions and recovering after stable five-step intervals.
Illustrative dynamic loss-scaling controller over 30 steps. Overflow decisions at steps 5, 14, 15, and 24 halve the scale, while five consecutive successful steps double it again. The deliberately shortened growth interval makes both responses visible; PyTorch GradScaler uses a 2,000-step default growth interval.

Measuring the Throughput Benefit

The actual speedup from mixed precision depends on the GPU model, the operation sizes, and the model architecture. Let us measure time per step for FP32 vs FP16 on this model.

In[17]:
Code
import time


def benchmark_step(
    model, use_amp, amp_dtype, use_scaler, n_warmup=3, n_bench=5
):
    """Benchmark a training step and return mean time in milliseconds."""
    optimizer = optim.AdamW(model.parameters(), lr=1e-4)
    scaler = GradScaler(device=device.type) if use_scaler else None

    # Warmup
    for _ in range(n_warmup):
        x = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
        y = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
        optimizer.zero_grad()
        if use_amp:
            with autocast(device_type=device.type, dtype=amp_dtype):
                logits = model(x)
                loss = nn.CrossEntropyLoss()(
                    logits.view(-1, vocab_size), y.view(-1)
                )
            if scaler:
                scaler.scale(loss).backward()
                scaler.unscale_(optimizer)
                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
                scaler.step(optimizer)
                scaler.update()
            else:
                loss.backward()
                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
                optimizer.step()
        else:
            logits = model(x)
            loss = nn.CrossEntropyLoss()(
                logits.view(-1, vocab_size), y.view(-1)
            )
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
        if device.type == "cuda":
            torch.cuda.synchronize()

    # Benchmark
    times = []
    for _ in range(n_bench):
        x = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
        y = torch.randint(0, vocab_size, (batch_size, seq_len), device=device)
        optimizer.zero_grad()
        start = time.perf_counter()
        if use_amp:
            with autocast(device_type=device.type, dtype=amp_dtype):
                logits = model(x)
                loss = nn.CrossEntropyLoss()(
                    logits.view(-1, vocab_size), y.view(-1)
                )
            if scaler:
                scaler.scale(loss).backward()
                scaler.unscale_(optimizer)
                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
                scaler.step(optimizer)
                scaler.update()
            else:
                loss.backward()
                torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
                optimizer.step()
        else:
            logits = model(x)
            loss = nn.CrossEntropyLoss()(
                logits.view(-1, vocab_size), y.view(-1)
            )
            loss.backward()
            torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()
        if device.type == "cuda":
            torch.cuda.synchronize()
        end = time.perf_counter()
        times.append((end - start) * 1000)

    return float(np.mean(times)), float(np.std(times))
In[18]:
Code
# Create fresh models for benchmarking
model_fp32_bench = SimpleTransformer(
    vocab_size=vocab_size, d_model=d_model, n_layers=n_layers, seq_len=seq_len
).to(device)
model_fp16_bench = SimpleTransformer(
    vocab_size=vocab_size, d_model=d_model, n_layers=n_layers, seq_len=seq_len
).to(device)

t_fp32_mean, t_fp32_std = benchmark_step(
    model_fp32_bench, use_amp=False, amp_dtype=None, use_scaler=False
)
t_fp16_mean, t_fp16_std = benchmark_step(
    model_fp16_bench, use_amp=True, amp_dtype=torch.float16, use_scaler=True
)

speedup = t_fp32_mean / t_fp16_mean
Out[19]:
Console
FP32 step time: 21.30 +/- 0.27 ms
FP16 step time: 346.84 +/- 1.37 ms
Speedup (FP16 vs FP32): 0.06x

Note: On CPU, speedup may be < 1x because FP16 tensor cores are GPU-only.
On A100/H100 GPUs, typical speedups are 1.5-3x for transformer workloads.

The speedup on CPU will be small or negative because CPUs do not have dedicated FP16 hardware. On a modern GPU with tensor cores (A100, H100, RTX 30/40 series), the speedup for transformer matrix multiplications is typically 1.5x to 3x, depending on the batch and sequence dimensions. Memory savings are consistent regardless of hardware.

Checking Gradient Dtypes at Runtime

During debugging, it is useful to inspect the dtypes flowing through the model during a mixed precision forward pass.

In[20]:
Code
def inspect_dtypes(model, x, amp_dtype):
    """Run one forward pass and print the dtype of each module's output."""
    dtypes = {}

    def hook(name):
        def _hook(module, input, output):
            if isinstance(output, torch.Tensor):
                dtypes[name] = str(output.dtype)

        return _hook

    hooks = []
    for name, module in model.named_modules():
        if isinstance(module, (nn.Linear, nn.LayerNorm, nn.MultiheadAttention)):
            hooks.append(module.register_forward_hook(hook(name)))

    with autocast(device_type=device.type, dtype=amp_dtype):
        _ = model(x)

    for h in hooks:
        h.remove()

    return dtypes
Out[21]:
Console
Dtype inspection during FP16 autocast:
--------------------------------------------------
  0.norm1                             -> torch.float32
  ff.0                                -> torch.float16
  ff.2                                -> torch.float16
  0.norm2                             -> torch.float32
  1.norm1                             -> torch.float32
  1.norm2                             -> torch.float32
  norm                                -> torch.float32
  head                                -> torch.float16

The dtype inspection confirms that nn.Linear layers (which host matrix multiplications) run in FP16 within the autocast context, while nn.LayerNorm stays in FP32. This is the autocast dispatch table in action. The pattern aligns exactly with the theory: operations sensitive to precision stay in FP32, while compute-heavy projection operations use FP16.

Key Parameters

The key parameters for mixed precision training are:

  • dtype in autocast: torch.float16 for FP16 (requires loss scaling on older hardware), torch.bfloat16 for BF16 (preferred on Ampere GPUs and newer).
  • init_scale in GradScaler: The initial loss scale value. Default is 65536. Lower values reduce early-training overflow at the cost of reduced gradient precision.
  • growth_factor in GradScaler: How much to multiply the scale after a clean stretch. Default is 2.0.
  • backoff_factor in GradScaler: How much to reduce the scale after overflow. Default is 0.5.
  • growth_interval in GradScaler: Number of consecutive steps without overflow before scaling up. Default is 2000. Lower values make the scale more aggressive.
  • max_norm in clip_grad_norm_: The gradient clipping threshold. Apply this after calling scaler.unscale_().