Module 11 of 12
Chapter 10 • Production & Acceleration

GPU Acceleration, Mixed Precision & torch.compile

Modern deep learning workloads are constrained by memory bandwidth and hardware FLOP utilization. By leveraging Automatic Mixed Precision (AMP) and the PyTorch 2.0+ torch.compile()compiler stack, engineers can double training throughput and halve VRAM consumption without sacrificing model accuracy.

1. Precision Formats: FP32 vs FP16 vs BF16

Standard deep learning computes in 32-bit floating point (FP32). Modern NVIDIA Tensor Cores (Ampere, Hopper, Blackwell) process 16-bit representations at up to 4x higher mathematical throughput:

FormatSign BitExponent Bits (Dynamic Range)Mantissa Bits (Precision)Underflow Risk & Requirement
FP32 (Single)1 bit8 bits (10-38 to 1038)23 bits (High precision)Zero underflow risk; consumes 4 bytes per parameter.
FP16 (Half)1 bit5 bits (10-5 to 65,504)10 bitsHigh underflow risk! Small gradients flush to 0.0. Requires GradScaler.
BF16 (Bfloat16)1 bit8 bits (10-38 to 1038)7 bitsZero underflow risk! Dynamic range matches FP32. No GradScaler required.

2. Automatic Mixed Precision with torch.amp & GradScaler

Automatic Mixed Precision evaluates compute-intensive layers (matrix multiplications, convolutions) in 16-bit while keeping numerically sensitive reductions (Softmax, LayerNorm, loss aggregations) in FP32. When using FP16, GradScaler multiplies loss by a scale factor to prevent gradient underflow:

import torch
import torch.nn as nn
from torch.amp import autocast, GradScaler

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = MyNeuralNet().to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
criterion = nn.CrossEntropyLoss()

# Initialize FP16 gradient scaler
scaler = GradScaler('cuda')

for inputs, targets in dataloader:
    inputs, targets = inputs.to(device), targets.to(device)
    optimizer.zero_grad(set_to_none=True)

    # 1. Forward pass under autocast context
    # (Automatically casts Conv/Linear layers to FP16/BF16)
    with autocast(device_type="cuda", dtype=torch.float16):
        outputs = model(inputs)
        loss = criterion(outputs, targets)

    # 2. Scaled backward pass
    # Multiplies loss by scale factor so gradients don't flush to zero
    scaler.scale(loss).backward()

    # 3. Unscales gradients and invokes optimizer.step()
    # (Skips step if NaN/Inf gradients are detected and reduces scale factor)
    scaler.step(optimizer)

    # 4. Update scale factor for next iteration
    scaler.update()

3. Next-Gen JIT Compilation: torch.compile()

Traditionally, PyTorch runs in eager mode: each line of Python sends an individual CUDA kernel launch across the driver. Introduced in PyTorch 2.0, torch.compile() captures the computational graph usingTorchDynamo and compiles it into fused Triton CUDA kernels with TorchInductor:

# Compiling your model with one line of code:
compiled_model = torch.compile(
    model,
    mode="reduce-overhead",  # Best for latency & small batches using CUDA graphs
    fullgraph=False          # Falls back cleanly to Python eager mode on unsupported ops
)

# Benchmark inference / training
with torch.inference_mode():
    y = compiled_model(sample_tensor)
1. Kernel Fusion

Merges multiple sequential elementwise ops (e.g. Bias Add + GELU + Dropout) into a single GPU kernel, eliminating round-trip HBM memory reads.

2. CUDA Graphs

Records the entire GPU execution sequence upfront and replays it in hardware, reducing CPU driver launch overhead to zero.

3. Native Triton Kernels

Generates optimized Triton GPU machine code customized specifically for your exact input tensor shapes and GPU architecture.

4. Multi-GPU Scaling: Why DDP Replaces DataParallel

Do NOT use torch.nn.DataParallel

nn.DataParallel is single-process multi-threaded. Due to Python's Global Interpreter Lock (GIL), GPU 0 becomes a severe bottleneck managing all parameter broadcasts and gradient reductions, typically achieving only 50-60% scaling efficiency.

Instead, use DistributedDataParallel (DDP). DDP spawns one independent OS process per GPU, completely bypassing the Python GIL and performing all-reduce gradient synchronizations via high-speed NCCL ring interconnects:

# Multi-GPU Launch Command (e.g., 4 GPUs on 1 node):
# torchrun --nproc_per_node=4 train_ddp.py

import torch
import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP
from torch.utils.data.distributed import DistributedSampler

def main_ddp():
    # Initialize process group via NCCL backend
    dist.init_process_group(backend="nccl")
    local_rank = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local_rank)

    # Wrap model in DDP
    model = MyModel().to(local_rank)
    model = DDP(model, device_ids=[local_rank])

    # DistributedSampler ensures each GPU receives non-overlapping data slices
    sampler = DistributedSampler(dataset, shuffle=True)
    loader = DataLoader(dataset, batch_size=32, sampler=sampler)

    for epoch in range(10):
        sampler.set_epoch(epoch)  # Critical for proper multi-GPU shuffling
        # ... standard training loop ...
Next Chapter

Transfer Learning & Fine-Tuning

Continue to the next hands-on deep learning guide in this PyTorch series.

Continue Learning →

Explore Related Tools

Hand-picked utilities and calculators related to this tool.

Related Tools