Skip to content

Mixed Precision Training (AMP)

Overview

Automatic Mixed Precision (AMP) uses lower precision (float16/bfloat16) for most computations while keeping critical operations in float32. Result: 2-3x speedup, 50% memory reduction, virtually no accuracy loss.

  • Frameworks: PyTorch AMP, TensorFlow mixed precision
  • Key Innovation: Selectively use lower precision without breaking training
  • Speedup: 1.5-3x faster training (hardware-dependent)
  • Memory: 40-60% reduction in GPU memory usage
  • Adoption: Standard in modern training (no longer optional!)

-

The Problem: float32 is Wasteful

Precision Types

Data Type: Size| Precision| Usage
──────────────────────────────────────
float32: 4B| 7 decimals| Full precision
float16: 2B| 4 decimals| Half precision
bfloat16: 2B| 3 decimals| Half precision (less underflow)
float8: 1B| 2 decimals| Extreme compression (emerging)

float32 breakdown:
 - Sign: 1 bit
 - Exponent: 8 bits
 - Mantissa: 23 bits
 - Total: 32 bits = 4 bytes

float16 breakdown:
 - Sign: 1 bit
 - Exponent: 5 bits
 - Mantissa: 10 bits
 - Total: 16 bits = 2 bytes (50% smaller!)

Range:
 - float32: 10^-38 to 10^38
 - float16: 10^-4 to 10^4 (limited for deep networks!)
 - bfloat16: 10^-38 to 10^38 (matches float32 range)
 - Problem: float16 range too small for training!

Why float32 is Overkill

Observation: Not all operations need full precision

Operation Precision Needed Current Use
─────────────────────────────────────────────────
Matrix multiply float16 OK float32 (wasteful!)
Dot products float16/32 float32
Loss computation float32 (stable) float32 (correct)
Gradient updates float32 float32 (necessary)
Softmax float32 float32 (could be relaxed)

Memory for 70B model:
 - In float32: 70B × 4 bytes = 280GB
 - In float16: 70B × 2 bytes = 140GB
 - Savings: 50%!

Speed improvement:
 - GPUs optimized for float16 matrix ops
 - Tensor cores: Can do 2× float16 ops per float32 op
 - A100: 312 TFLOPS float32 vs 625 TFLOPS float16!
 - Speedup: 2-3x possible!

Trade-off:
 - Loss precision in matrix multiplies: OK (hardware does this anyway)
 - But: Need to be careful about numerical stability
 - Solution: AMP (automatic mixed precision)

How Automatic Mixed Precision Works

The Strategy

Goal: Use float16 where possible, float32 where needed

Strategy:
1. Matrix multiplies → float16 (fast, hardware optimized)
2. Reductions (softmax, reduce_sum) → float32 (numerical stability)
3. Loss computation → float32 (stability for backward)
4. Gradient computation → float32 (accurate gradients)
5. Parameter updates → float32 (small changes matter)

Problem: float16 range too small for gradients!
Solution: Master copy in float32, compute in float16

Automatic Mixed Precision Flow

Forward Pass:
- ┌─────────────────────┐
 - Input (float32) │
 - ┬──────────────┘
 ↓
- ┌──────────────────────┐
 - Cast to float16 │
 - ┤
 - Linear + GELU │ (float16 computation, fast!)
 - ┤
 - Linear + Softmax │ (float16 + stable reduction)
 - ┤
 - Cast back to float32 │
 - ┬───────────────┘
 ↓
- ┌──────────────────────┐
 - Output (float32) │
 - ┬───────────────┘
 ↓
 Loss (float32)

Backward Pass:
- ┌──────────────────────┐
 - Gradients (float32) │ (computed in float32 for accuracy)
 - ┤
 - Loss scaling applied │ (prevent gradient underflow)
 - ┤
 - Backward through │ (float32 computation)
 - float16 ops │
 - ┘

-

Implementation

PyTorch Automatic Mixed Precision

import torch
from torch.cuda.amp import autocast, GradScaler

# Setup
model = model.to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4)
scaler = GradScaler() # Prevents gradient underflow

# Training loop
for epoch in range(num_epochs):
 for batch in dataloader:
 x, y = batch
 x, y = x.to(device), y.to(device)

 # Forward pass with autocast
 with autocast():
 output = model(x)
 loss = criterion(output, y)

 # Backward with scaling
 scaler.scale(loss).backward()
 scaler.unscale_(optimizer)
 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

 # Optimizer step with scaler
 scaler.step(optimizer)
 scaler.update()
 optimizer.zero_grad()

# Key operations:
# autocast()
# GradScaler

Loss Scaling Explained

Problem: Gradient underflow in float16

float16 range: [6×10^-5, 6×10^4]

Typical gradient: 1e-5
 - In float32: 1e-5 (small but representable)
 - In float16: UNDERFLOWS to 0! (below 6e-5 minimum)
 - Result: No gradient signal! Training fails!

Solution: Loss scaling (multiply loss by large factor)

Loss: 0.5
Scaled loss: 0.5 × 1024 = 512

Gradients: 1e-5 × 1024 = 1.024e-2
 - In float16: 0.01 is representable!
 - Backward computes: 0.01 (in float16)
 - Unscale: 0.01 / 1024 = 1e-5 (back to original)
 - Optimizer sees: 1e-5 (correct gradient!)

Dynamic scaling:
 - If overflow: Reduce scaling factor (2x smaller)
 - If no overflow: Try increasing scaling factor
 - PyTorch GradScaler does this automatically!

Manual Control (if needed)

class MixedPrecisionTrainer:
 def __init__(self, model, optimizer):
 self.model = model
 self.optimizer = optimizer
 self.scaler = GradScaler()

 # Manual loss scale control
 self.loss_scale = 1024.0
 self.max_loss_scale = 65536.0
 self.min_loss_scale = 1.0

 def train_step(self, batch):
 x, y = batch

 # Forward with autocast (float16 for ops)
 with autocast():
 output = self.model(x)
 loss = criterion(output, y)

 # Scale loss to prevent gradient underflow
 scaled_loss = loss * self.loss_scale

 # Backward
 self.optimizer.zero_grad()
 scaled_loss.backward()

 # Check for overflow (NaN or Inf gradients)
 if torch.isnan(scaled_loss) or torch.isinf(scaled_loss):
 print("Overflow detected! Reducing loss scale")
 self.loss_scale = max(self.loss_scale / 2, self.min_loss_scale)
 self.optimizer.zero_grad()
 return

 # Unscale gradients
 for param in self.model.parameters():
 if param.grad is not None:
 param.grad.data /= self.loss_scale

 # Clip gradients (after unscaling!)
 torch.nn.utils.clip_grad_norm_(self.model.parameters(), max_norm=1.0)

 # Optimizer step
 self.optimizer.step()

 # Increase loss scale if training stable
 self.loss_scale = min(self.loss_scale * 1.01, self.max_loss_scale)

-

Memory and Speed Analysis

Memory Breakdown

Model: Llama 7B, Batch size: 1, Sequence length: 4096

Without AMP (float32):
 - Model weights: 7B × 4 = 28GB
 - Optimizer states (Adam): 2 × 28 = 56GB
 - Activations: ~16GB
 - Gradients: 28GB
 - Total: ~128GB (doesn't fit on A100)

With AMP (float16 compute + float32 master):
 - Model weights (float32 master): 28GB
 - Model weights (float16 copy): 14GB
 - Optimizer states (float32): 56GB
 - Activations (float16): ~8GB
 - Gradients (float32): 28GB
 - Total: ~134GB (still doesn't fit!)

Wait... that's more memory! Why?

Issue: Need to maintain both float32 (master) and float16 (compute)
Solution: Use FSDP + AMP + Gradient checkpointing together

With FSDP (8 GPUs) + AMP + Checkpointing:
 - Per GPU weights: 28GB / 8 = 3.5GB
 - Per GPU optimizer: 56GB / 8 = 7GB
 - Per GPU activations: 2GB (checkpointed)
 - Per GPU gradients: 3.5GB / 8 = 0.4GB
 - Per GPU total: ~12GB (fits on A100!)

Techniques combined:
 - AMP: Float16 activations (50% memory)
 - Checkpointing: Recompute activations (90% reduction)
 - FSDP: Shard parameters (8x reduction)
 - Combined: 8 × 2 × 8 = 128x memory reduction!

Speed Improvement

Hardware: A100 GPU

Float32 Performance:
 - Dense matrix multiply: 312 TFLOPS
 - Actual throughput: 300 TFLOPS (realistic)

Float16 Performance:
 - Dense matrix multiply: 625 TFLOPS (2× float32)
 - With tensor cores: up to 1248 TFLOPS
 - Actual throughput: 600-800 TFLOPS (realistic)

Training throughput (iterations/sec):

Model: Llama 7B, Batch: 1, Seq: 4096
 - float32: 0.5 iter/sec
 - float16 AMP: 1.2 iter/sec
 - Speedup: 2.4x!

Model: Llama 70B, Batch: 1, Seq: 4096
 - float32: 0.05 iter/sec (very slow)
 - float16 AMP: 0.1 iter/sec
 - With FSDP: 0.3 iter/sec (6x total)
 - Speedup: 6x!

Wall-clock training time:
 - Without AMP: 48 hours to train 1 epoch
 - With AMP: 20 hours (2.4x faster)
 - Saves: 28 hours per epoch!

Practical Considerations

bfloat16 vs float16

Comparison:

 float16 bfloat16
───────────────────────────────────
Exponent bits 5 8
Mantissa bits 10 7
Memory 2B 2B
Range 10^-4-10^4 10^-38-10^38
Precision High Medium
Stability Underflow Stable
Hardware Wide A100+, newer

Recommendation:
 - GPUs with bfloat16: Use bfloat16 (more stable)
 - Older GPUs: Use float16 with careful loss scaling
 - Default modern: bfloat16 is becoming standard

Convergence Impact

Study: Does AMP hurt convergence?

Result: Negligible impact!

Accuracy difference (AMP vs full float32):
 - BERT: <0.1% accuracy loss
 - GPT-2: <0.2% accuracy loss
 - Vision models: <0.5% accuracy loss
 - Conclusion: AMP converges nearly identically

Convergence speed:
 - AMP may converge slightly faster (due to noise)
 - Or slightly slower (depends on loss scale tuning)
 - Typical: Within noise margins

When to Use AMP

Always Use AMP When

Training transformer models (default)
GPU has tensor cores (A100, H100, V100+)
Want 2-3x speedup for free
Need to reduce memory usage

Avoid AMP When

Doing numerical research (need full precision)
Model has custom float32-specific operations
Loss landscape is extremely unstable
 (rare, but possible with certain architectures)

Key Takeaways

2-3x speedup with virtually no accuracy loss 40-60% memory reduction through lower precision Automatic: PyTorch handles precision decisions Loss scaling prevents gradient underflow Industry standard: Should be default for training

-