Deep Learning · Module 23 of 26 · 8 concepts · about 18 min

GPU & Efficient Training

Using accelerator memory and throughput deliberately rather than accidentally.

At work: Training models faster and cheaper on GPUs, where compute is often the largest line in an AI team's budget.

Devices, mixed precision, batching and gradient accumulation

What it is

GPU training moves both model parameters and each batch's tensors to the same accelerator device. Mixed precision uses lower-precision arithmetic where it is safe and maintains critical values or scaling in higher precision, often reducing memory use and increasing throughput on modern hardware. Gradient accumulation sums appropriately scaled gradients across several micro-batches before one optimiser step, emulating a larger effective batch when activations would not fit together in memory. Data loading, host-to-device transfer, sequence padding, and activation checkpointing can dominate performance as much as the model's arithmetic.

Why it matters

Performance is often limited by input loading, memory bandwidth, or activation storage rather than raw FLOPs. Measuring those bottlenecks is more useful than guessing: profile step time, accelerator utilisation, memory peaks, and data-loader wait time before changing model design. Efficiency work must preserve numerical and training semantics; a faster run with a changed effective batch or unstable reduced precision is not a fair comparison.

In the real world: Techniques that cut training time and cloud GPU bills.

Visual example

Deep learning

Several small micro-batches can contribute to one larger effective update.

Gradient accumulation

  1. micro-batch 1forward → loss / N → backward
  2. micro-batch 2forward → loss / N → backward
  3. … N micro-batchesgradients accumulate
  4. one optimizer.step()apply one effective large-batch update

Clear gradients only before the accumulation cycle, not between its micro-batches.

import torch

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)

torch.cuda.reset_peak_memory_stats()
train_one_epoch(model, loader, optimizer, device)
peak_gb = torch.cuda.max_memory_allocated() / 1e9
print(f'peak allocated: {peak_gb:.2f} GB')

Calling optimizer.step() for each accumulation micro-batch. That changes the effective batch and update count; divide the loss, call backward per micro-batch, then step only at the accumulation boundary.

Why a GPU is faster for deep learning

What it is

A CPU has a few dozen powerful cores optimised for low-latency execution of branchy, dependent instructions. A GPU has thousands of simple cores and very wide memory bandwidth, optimised for throughput on the same operation applied to many independent elements. Deep learning is almost entirely the second shape: a matrix multiply is millions of independent multiply-accumulates with no branches, and an activation is the same scalar function applied element by element.

Why it matters

This is the NumPy vectorisation lesson taken to hardware. There, the win came from replacing a Python loop with one compiled operation over a whole array; here, the same restructuring lets thousands of cores work at once. It also explains the shape of every optimisation in this module. A GPU is fast at *bulk uniform arithmetic*, so anything that breaks uniformity — a Python loop over batch elements, per-example branching, tiny kernels, a synchronising .item() inside the inner loop — destroys the advantage. And because bandwidth is finite, many deep-learning operations are memory-bound rather than compute-bound: they are waiting for numbers to arrive, not for arithmetic to finish, which is why lower precision speeds things up even when it does not reduce the operation count.

In the real world: A training job that takes a week on CPUs can take hours on a GPU.

import torch
import time

def timed(fn, *args, warmup=3, iters=20):
    for _ in range(warmup):
        fn(*args)
    torch.cuda.synchronize()          # GPU calls are async; you must sync to time them
    start = time.perf_counter()
    for _ in range(iters):
        fn(*args)
    torch.cuda.synchronize()
    return (time.perf_counter() - start) / iters

a_cpu = torch.randn(4096, 4096)
a_gpu = a_cpu.cuda()

timed(torch.matmul, a_cpu, a_cpu)     # ~0.35 s
timed(torch.matmul, a_gpu, a_gpu)     # ~0.004 s - roughly 90x

Timing GPU code with time.perf_counter() and no torch.cuda.synchronize(). CUDA calls return before the work finishes, so you measure how long it took to *queue* the operation — often reporting an impossible speedup of thousands of times.

Mixed-precision training

What it is

Mixed precision runs most of the forward and backward pass in 16-bit floating point while keeping a 32-bit master copy of the weights that the optimiser actually updates. With float16 the exponent range is narrow enough that small gradients underflow to zero, so a loss scaler multiplies the loss by a large factor before backward() and divides the gradients afterwards. bfloat16 has the same exponent range as float32 and fewer mantissa bits, so it needs no loss scaling — which is why it has become the default on hardware that supports it.

Why it matters

It buys two things at once. Memory: activations and gradients halve, which is usually the binding constraint. Speed: modern accelerators have dedicated tensor cores that do 16-bit matrix multiplies several times faster than 32-bit ones, and halving the bytes moved also relieves the memory-bandwidth limit. Accuracy holds up because the parts of training that are numerically delicate — accumulating a weight update, reducing a sum, normalising — are kept in float32 by the autocast rules, while the parts that are robust to a few lost mantissa bits, chiefly the matrix multiplies, are not. The win is close to free, which is why mixed precision is a default rather than a tuning option.

In the real world: Using 16-bit numbers can nearly halve training time and memory with no loss in quality.

Visual example

autocast

Which parts of training run in which precision — and why the delicate ones stay in fp32.

Where the speed comes from

(most of the FLOPs, on dedicated hardware)

Where the accuracy is protected

(autocast keeps these in fp32 for you)

Precision by operation
precisionwhy
matmul / convbf16 / fp16tensor cores; robust to lost mantissa bits
softmax / layernormfp32exponentials and variances overflow easily
loss reductionfp32summing many small numbers needs the mantissa
weight updatefp32 master copyan update smaller than 1 ulp would vanish

torch.float16

5-bit exponent - needs a GradScaler

torch.bfloat16

8-bit exponent, same range as fp32 - no scaler needed

Half the memory and several times the matmul throughput, for a bounded numerical cost

fp16 versus bf16, in one line each

fp16: 1-5-10 bits: more precision, range only to ~65504, so gradients underflow

bf16: 1-8-7 bits: fp32's range, coarser steps, no loss scaling required

fp32: 1-8-23 bits: the master weights and every reduction

Why this matters

Mixed precision is often described as 'training in fp16', which makes it sound risky. It is not — it is training in fp16 *except* for the four or five operation classes where fp16 would actually break, and autocast knows which those are.

Common misconception

Wrong idea: lower precision trades accuracy for speed.

Correct idea: it trades mantissa bits in the robust operations for speed. The operations that need precision are excluded automatically, which is why final accuracy is typically unchanged.

import torch

scaler = torch.amp.GradScaler('cuda')

for x, y in loader:
    x, y = x.to(device), y.to(device)
    optimizer.zero_grad(set_to_none=True)

    with torch.autocast('cuda', dtype=torch.bfloat16):
        loss = loss_fn(model(x), y)        # matmuls in bf16, reductions in fp32

    scaler.scale(loss).backward()          # scale up so small grads survive fp16
    scaler.unscale_(optimizer)             # back to true scale before clipping
    torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
    scaler.step(optimizer)                 # skips the step if any grad is inf/nan
    scaler.update()

Clipping gradients without calling scaler.unscale_(optimizer) first. The gradients are still multiplied by the loss scale, so a max_norm of 1.0 is effectively clipping at 65536 and does nothing at all.

Gradient accumulation and effective batch size

What it is

Gradient accumulation decouples the batch size the optimiser sees from the batch size that fits in memory. Run N micro-batches, dividing each loss by N and calling backward() on each so gradients sum in .grad, then call optimizer.step() once. The resulting update is mathematically the same as one step on a batch N times larger, because the mean of means over equal-sized micro-batches is the mean over the whole batch.

Why it matters

It matters because batch size is not a free parameter. Large-batch training needs a matched learning rate, interacts with the warmup schedule, and changes the gradient noise that some models depend on; a result reported at batch 512 cannot be reproduced at batch 32 by changing nothing else. Accumulation is what lets you keep the *training recipe* fixed while adapting to whatever memory you actually have — the same run on one 24 GB GPU and on eight 80 GB ones, if the effective batch matches. The caveats are that it costs proportional time (you do all the same work, just serially) and that the division must be exact: unequal final micro-batches weight their examples differently unless you normalise by token or sample count instead of by N.

In the real world: Training with large-batch settings on a smaller, cheaper GPU.

import torch

ACCUM = 8                     # effective batch = micro_batch * 8

optimizer.zero_grad(set_to_none=True)
for step, (x, y) in enumerate(loader, start=1):
    x, y = x.to(device), y.to(device)
    with torch.autocast('cuda', dtype=torch.bfloat16):
        loss = loss_fn(model(x), y) / ACCUM     # divide, so grads average
    loss.backward()                              # accumulates into .grad

    if step % ACCUM == 0:                        # only now is the batch complete
        torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
        optimizer.step()
        scheduler.step()                         # one scheduler step per UPDATE
        optimizer.zero_grad(set_to_none=True)

Stepping the learning-rate scheduler once per micro-batch. The schedule then runs N times too fast, so warmup finishes almost immediately and the decay has completed long before training has — an easy mistake to miss because the loss still goes down.

Data parallelism versus model parallelism

What it is

Data parallelism replicates the whole model on every GPU, gives each a different slice of the batch, and averages the gradients across devices before the update — so every replica stays identical. Model parallelism instead splits the *model*: tensor parallelism shards individual weight matrices across devices, and pipeline parallelism assigns different layers to different devices and streams micro-batches through them. Fully-sharded approaches combine the ideas by sharding parameters, gradients and optimiser state across the data-parallel group and gathering each layer's weights only while it is in use.

Why it matters

The choice is forced by a single question: does one copy of the model, its gradients and its optimiser state fit on one device? If yes, use data parallelism — it is simpler, it scales nearly linearly, and the only communication is one gradient all-reduce per step. If no, you have no option but to split the model, and you accept the cost: tensor parallelism communicates *within* every layer, so it needs very fast interconnect, and pipeline parallelism leaves devices idle at the start and end of each batch ('bubbles') unless micro-batching keeps the pipe full. Reaching for model parallelism before you need it buys complexity and a slower run.

In the real world: Splitting training across 8 GPUs to finish in a day instead of a week.

Visual example

Scaling out

One question decides it: does a single copy of model, gradients and optimiser state fit on one device?

Always try this first

(simplest, near-linear scaling)

Needs NVLink-class links

(communicates inside every layer)

What gets split, and what it costs
splitcommunicationuse when
data parallelthe batch1 all-reduce per stepthe model fits on one GPU
tensor paralleleach weight matrixtwice per layerone layer is too big
pipeline parallelthe layer stackactivations at each boundarymany layers, slow interconnect
fully shardedparams + grads + optimisergather per layer, per stepthe model fits but its state does not

DistributedDataParallel

one gradient all-reduce, overlapped with backward

FSDP / ZeRO-3

the usual answer for a large model on modest GPUs

Model parallelism is a consequence of not fitting, never an upgrade

Why this matters

People often assume model parallelism is the more advanced, therefore better, option. It is strictly more expensive per unit of work; you adopt it only when data parallelism has become impossible.

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

dist.init_process_group('nccl')
rank = dist.get_rank()
torch.cuda.set_device(rank)

model = DDP(model.to(rank), device_ids=[rank])
sampler = DistributedSampler(dataset)             # each rank gets a disjoint slice
loader = torch.utils.data.DataLoader(dataset, batch_size=32, sampler=sampler)

for epoch in range(epochs):
    sampler.set_epoch(epoch)                      # or every rank reuses one order
    train_one_epoch(model, loader, optimizer, rank)

# Effective batch = 32 * world_size. The learning rate usually has to follow.

Forgetting sampler.set_epoch(epoch). Without it the DistributedSampler reshuffles identically every epoch, so the whole run sees the same batch composition repeatedly and the shuffling you configured does nothing.

The GPU memory budget

What it is

Training memory has four components and they scale differently. Parameters are fixed by the model. Gradients are one value per trainable parameter. Optimiser state is the largest surprise: AdamW keeps two float32 moment estimates per parameter, so it costs roughly twice the model in fp32. Activations are everything the forward pass stored for the backward pass, and unlike the first three they scale with *batch size and sequence length* — which is why the same model trains fine at batch 8 and dies at batch 16.

Why it matters

This breakdown is the most practically valuable table in the module, because it tells you which lever to pull. If activations dominate, reduce batch size, shorten sequences, or use gradient checkpointing — changing the optimiser will not help. If optimiser state dominates, switch to 8-bit Adam or shard it with ZeRO/FSDP — reducing batch size will not help. Without the breakdown, out-of-memory debugging is guesswork; with it, the fix is usually obvious from which row is biggest.

In the real world: Planning whether a model will fit on the GPUs the team can afford.

Visual example

7B model, batch 8, seq 2048

Four components, two scaling laws. Only the last row grows with batch size — which is why it causes most OOMs.

Bigger than the model

(two fp32 moments per parameter)

The one that moves

(everything else is constant per step)

LoRA r=8

kills rows 2 and 3 together: 70 GB -> 0.2 GB

Where 80 GB goes
sizescales withlever
parameters14 GBmodel sizesmaller model, or quantise
gradients14 GBtrainable paramsfreeze layers, or LoRA
AdamW state56 GBtrainable params8-bit Adam, or FSDP shard
activations19 GBbatch x seq x depthsmaller batch, or checkpointing

checkpointing

cuts row 4 by ~70% for ~30% more compute

103 GB on an 80 GB card. The fix depends entirely on which row you attack

The arithmetic

parameters: 7e9 x 2 bytes (bf16) = 14 GB

gradients: 7e9 x 2 bytes = 14 GB

AdamW: 7e9 x (4 + 4) bytes of fp32 moments = 56 GB

activations: ~8 x 2048 x 4096 x 32 layers x ~16 tensors x 2 bytes = 19 GB

Total 103 GB. Freezing most layers removes 62 of them, in the two rows that depend on trainable count

Why this matters

Almost every 'my model does not fit' question is answerable from this table. The instinct is to reduce batch size, which only touches the smallest of the four rows in this particular configuration.

Common misconception

Wrong idea: a 7B model needs about 14 GB to train, since that is its size in bf16.

Correct idea: it needs seven times that. Gradients, optimiser moments and stored activations all dwarf the weights, and three of the four grow with what you choose rather than with the model.

import torch

def memory_budget(model, batch, seq, d_model, n_layers, bytes_per_elem=2):
    params = sum(p.numel() for p in model.parameters())
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return {
        'parameters_gb': params * 2 / 1e9,                  # bf16 weights
        'gradients_gb': trainable * 2 / 1e9,
        'adamw_state_gb': trainable * 8 / 1e9,              # two fp32 moments
        'activations_gb': batch * seq * d_model * n_layers * 16 * bytes_per_elem / 1e9,
    }

budget = memory_budget(model, batch=8, seq=2048, d_model=4096, n_layers=32)
print(budget, 'total:', sum(budget.values()))

print(torch.cuda.memory_summary())        # the ground truth, when in doubt

Reading nvidia-smi and concluding the GPU is nearly full. PyTorch's caching allocator holds freed blocks for reuse, so reserved memory stays high; torch.cuda.memory_allocated() is what your tensors actually occupy.

Gradient checkpointing

What it is

Normally the forward pass stores every intermediate activation because the backward pass needs them to compute local derivatives. Gradient checkpointing stores only a few — the boundaries between segments of the network — and discards the rest. During the backward pass, each segment's activations are recomputed from its stored input just before they are needed, then thrown away again. It is an explicit trade of compute for memory.

Why it matters

The trade is unusually favourable, which is why it is a standard tool rather than a last resort. Storing every activation costs memory proportional to depth; storing only segment boundaries costs roughly the square root of that, while the extra compute is one additional forward pass, about 30% more time. For a deep network that is a 60 to 70% cut in activation memory for a third more time — and if the alternative is not being able to train at all, or training at a batch size so small the gradient is too noisy, that is an easy trade. It is also composable: checkpointing plus mixed precision plus accumulation are three independent levers on three different terms of the budget.

In the real world: Trading a bit of extra compute to fit a bigger model on the same GPU.

Visual example

Memory vs time

Three levers, three different terms of the budget. The combination is what fits a 103 GB run onto an 80 GB card.

fp32 baselinemixed precision+ gradient checkpointing+ 8-bit optimizer+ LoRA r=8
11.21.41.61.822.2020406080100120relative step timepeak memory (GB)80 GB cardstill does not fitfits, 35% slowerfits comfortably

What each lever actually reduces

mixed precision: halves activations and gradients; weights stay resident

checkpointing: cuts stored activations ~70%, costs one extra forward pass

8-bit optimizer: 56 GB of AdamW moments becomes 14 GB

LoRA: removes gradients and optimiser state for the frozen 99.6%

Reading the y-axis alone would suggest LoRA is all you need; the x-axis is why the others are still used for full fine-tunes

Why this matters

The 80 GB reference line is the only thing that matters in practice: below it the run happens, above it the run does not. Everything else is how much time you pay to get under the line.

Each technique attacks a different row of the memory budget, so their savings compose rather than overlap.

import torch
import torch.nn as nn
from torch.utils.checkpoint import checkpoint

class CheckpointedStack(nn.Module):
    def __init__(self, blocks):
        super().__init__()
        self.blocks = nn.ModuleList(blocks)

    def forward(self, x):
        for block in self.blocks:
            if self.training:
                # Store only x; recompute block's internals during backward.
                x = checkpoint(block, x, use_reentrant=False)
            else:
                x = block(x)
        return x

# Many libraries expose this directly:
# model.gradient_checkpointing_enable()

Leaving checkpointing on for inference. There is no backward pass, so nothing is saved and the recomputation is pure overhead — guard it with self.training or disable it before serving.

Debugging CUDA out of memory

What it is

torch.cuda.OutOfMemoryError reports how much was requested, how much is free, and how much PyTorch has reserved. Read all three: a request for 2 MB failing with 8 GB reserved and 50 MB free means fragmentation, not a genuine shortage. The error also names the moment of failure, which is informative — failing in the backward pass points at activations, failing at optimizer.step() on the first update points at optimiser state being allocated lazily.

Why it matters

The reason to have a checklist is that the instinctive fix — halve the batch size — often does not work, because the batch was never the biggest term. Work down the list instead: is something accumulating across steps (a loss tensor in a list, a metric holding a graph, a missing zero_grad)? Is the validation loop missing no_grad, so it retains activations at inference batch sizes? Did memory grow monotonically over epochs, which means a leak rather than a budget? Only then reduce batch size, and if that is not enough turn to the structural levers: checkpointing, an 8-bit optimiser, sharding, or LoRA.

In the real world: The most common training crash, and the quick fixes that avoid renting bigger GPUs.

Visual example

OOM checklist

Where it failed tells you which term of the budget overflowed. Check in this order.

Check this first

(a leak looks like a budget problem for one epoch)

Not a memory problem at all

(8 GB free and a 2 MB request failing)

Symptom to cause
likely causefirst thing to try
grows each stepa retained graph or metric listappend loss.item(), not loss
fails in backwardstored activations too largecheckpointing, or smaller batch
fails at step()optimiser state allocated lazily8-bit Adam, or shard it
tiny alloc failsfragmentation, not shortageexpandable_segments:True

Halving the batch size is item five, not item one

Common misconception

Wrong idea: out of memory means the model is too big for the GPU.

Correct idea: it means one of four terms overflowed, and the message says which. A leak, a missing no_grad, and genuine fragmentation all report the same error and need completely different fixes.

import gc
import torch

# 1. What does PyTorch think it is holding?
print(torch.cuda.memory_allocated() / 1e9, 'GB in live tensors')
print(torch.cuda.memory_reserved() / 1e9, 'GB reserved by the allocator')
print(torch.cuda.max_memory_allocated() / 1e9, 'GB peak')

# 2. Is anything retaining a graph across steps?
for obj in gc.get_objects():
    if torch.is_tensor(obj) and obj.is_cuda and obj.grad_fn is not None:
        print('live graph tensor:', tuple(obj.shape))

# 3. Fragmentation, not shortage? Let the allocator use expandable segments.
#    PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True

# 4. Confirm the leak shape: peak should be flat across epochs, not rising.
torch.cuda.reset_peak_memory_stats()

Calling torch.cuda.empty_cache() in the training loop to 'free memory'. It returns cached blocks to the driver without freeing a single live tensor, so it fixes nothing and makes every subsequent allocation slower.