GPUs & accelerators, section 1 of 4, 5h

What the hardware is doing

By the end: You can reason about why something is slow and whether it will fit.

  • Why GPUs suit this work
  • Memory and bandwidth
  • Mixed precision
  • Reading nvidia-smi honestly

New to gpus & accelerators? A CPU is built to do one thing after another very quickly; a GPU is built to do thousands of simple, identical things at the same moment, and that is the whole of the difference. Neural networks happen to be exactly that shape — the same multiply-and-add repeated across millions of numbers — which is why they run on GPUs. The number that matters most is VRAM, the memory on the card itself, because a model has to fit inside it to run at all. Why it matters

Everything above this layer runs on a GPU, and a rough model of what one is doing answers the two questions you will be asked constantly: why is this slow, and will it fit. This section covers why matrix maths suits thousands of simple cores, why memory and the bandwidth to it usually bind before arithmetic does, what mixed precision buys and what it costs, and how to read nvidia-smi without being misled — high utilisation can mean the device is busy doing very little.

Many small operations at once#

A CPU has a few very capable cores. A GPU has thousands of simple ones. Matrix multiplication is thousands of independent multiply-and-add operations, which is exactly the shape a GPU is built for.

It is worth being specific about what 'capable' and 'simple' mean, because the difference is not that GPU cores are slower. A CPU core spends most of its transistors on making a single sequence of instructions finish sooner: predicting which branch will be taken, reordering instructions, and holding several megabytes of cache per core so it rarely waits for memory. All of that is machinery for code where the next step depends on the last one. A GPU spends those transistors on more arithmetic units instead, and handles waiting by keeping thousands of threads in flight so there is always one ready to run while others wait.

That bargain only pays when the work is thousands of identical independent operations with no branching. Multiplying two matrices is exactly that — every output element is an independent dot product of a row and a column — which is why the same hardware serves graphics and neural networks. Give a GPU a loop with a data-dependent branch in it and it will lose to a CPU comfortably.

Two consequences follow immediately and explain a lot of otherwise mysterious behaviour. Anything you write as a Python loop over items is the wrong shape and will be slow no matter what device it runs on, because it is a sequence of tiny operations rather than one large one. And a small operation on a GPU costs roughly the same as a medium one, because the fixed cost of launching the work dominates — which is why a batch of 1 and a batch of 8 often take almost the same time.

Memory is usually the constraint#

  • VRAM decides whether a model fits at all. Exceeding it fails immediately rather than degrading.
  • Bandwidth decides speed for large models, because generation is dominated by reading weights.
  • Moving data between host and device is slow. Do it once per batch, never per item.

The second point is the one people find surprising, and it has a simple form. Every operation has an arithmetic intensity: the number of floating-point operations it performs divided by the number of bytes it has to move to and from memory. A chip has a ratio too — its peak arithmetic rate divided by its memory bandwidth. If your operation's intensity is below the chip's ratio, you are memory-bound and buying more arithmetic buys you nothing.

The gap between the two regimes is much larger than intuition suggests. There is no GPU on the machine these words were written on, so here it is measured on its CPU, where the same physics applies with smaller numbers.

matmul  1024x1024     1,194 GFLOP/s      intensity  171 FLOP/byte
matmul  4096x4096       769 GFLOP/s      intensity  683 FLOP/byte
add     4096x4096         4.0 GFLOP/s    intensity 0.08 FLOP/byte    48 GB/s
add     8192x8192         4.0 GFLOP/s    intensity 0.08 FLOP/byte    48 GB/s
Measured with torch on this machine's CPU, not a GPU; the numbers are this laptop's, the shape is universal. Adding two matrices runs at a two-hundredth of the rate of multiplying them, and it is not because addition is hard. The add reads 12 bytes and does 1 arithmetic operation, so it spends all its time waiting for memory — note that it saturates at about 48 GB/s regardless of size, which is simply this machine's memory bandwidth. A data-centre GPU balances at roughly 150 FLOPs per byte, so the same split applies with the boundary in the same place.

This single ratio explains most of what you will be asked about performance. Training with a large batch does big matrix multiplications and is compute-bound. Generating one token at a time reads every weight in the model to do a handful of arithmetic per weight, so it is heavily memory-bound, which is why token generation speed tracks memory bandwidth rather than FLOPs, and why batching many requests together is the largest single lever on inference cost. And it is why fusing operations helps: the arithmetic is unchanged, the round trips to memory are what disappeared.

What is actually in the memory during training#

Ask someone how much memory it takes to train a model and they will usually answer with the parameter count times a couple of bytes. That answer is wrong by roughly a factor of eight, and knowing why is the difference between planning a run and being surprised by it. There are four things in memory, and the weights are the smallest of them.

import torch
import torch.nn as nn

model = nn.Sequential(nn.Linear(1024, 4096), nn.GELU(), nn.Linear(4096, 1024))
opt = torch.optim.AdamW(model.parameters(), lr=1e-4)
model(torch.randn(8, 1024)).pow(2).mean().backward()
opt.step()                                   # the state exists only after a step

n = sum(p.numel() for p in model.parameters())
params = sum(p.numel() * p.element_size() for p in model.parameters())
grads = sum(p.grad.numel() * p.grad.element_size() for p in model.parameters())
state = sum(v.numel() * v.element_size()
            for s in opt.state.values() for v in s.values() if torch.is_tensor(v))
for label, b in [("parameters", params), ("gradients", grads), ("Adam state", state)]:
    print(f"{label:12} {b / 2**20:8.1f} MiB   {b / n:5.1f} bytes per parameter")

# parameters       32.0 MiB     4.0 bytes per parameter
# gradients        32.0 MiB     4.0 bytes per parameter
# Adam state       64.0 MiB     8.0 bytes per parameter
#                                16.0 in total, and the model is 4 of them
Measured, not estimated, and it holds for any model: the same script on a 162,643,968-parameter transformer gives 620.4, 620.4 and 1,240.9 MiB, the same 16 bytes per parameter. AdamW keeps two tensors per parameter — a running average of the gradient and of its square — so the optimiser alone is twice the size of the model. Note that the state does not exist until the first opt.step(), which is why a run that survived one step can still run out of memory on the second.

In mixed precision the bookkeeping changes but the total does not, which catches people out. Weights and gradients drop to 2 bytes each, but a 32-bit master copy of the weights has to be kept for the optimiser to update accurately, and Adam's two moments stay in 32-bit. Two plus two plus four plus four plus four is still sixteen bytes per parameter. Mixed precision buys speed and cheaper activations; it does not shrink the optimiser.

A 6.74B-parameter model (llama-7B shape), mixed precision, AdamW:

  bf16 weights          2 B/param     12.6 GiB
  bf16 gradients        2 B/param     12.6 GiB
  fp32 master weights   4 B/param     25.1 GiB
  Adam m                4 B/param     25.1 GiB
  Adam v                4 B/param     25.1 GiB
                       --------------------------
  total                16 B/param    100.4 GiB     before a single activation

  optimiser's share: 75 per cent.
  inference on the same model: 12.6 GiB.
Computed from a parameter count taken by building one real transformer block in torch and multiplying by the layer count: 202,383,360 per block x 32, plus embeddings and head, is 6,738,415,616. The point of the table is the last two lines. The thing everyone quotes, the weights, is an eighth of the problem, and training the model needs eight times the memory of serving it.

The fourth item is activations: everything the forward pass computed that the backward pass needs in order to differentiate. Unlike the other three it does not scale with the parameter count at all — it scales with batch size and sequence length, which means it is the one you control.

Measured by summing every layer output in a 162M transformer, fp32:

  batch 1, 256 tokens      150.3 MiB
  batch 2, 256 tokens      299.9 MiB
  batch 4, 256 tokens      599.1 MiB
  batch 8, 256 tokens    1,197.4 MiB      -> 149.7 MiB per sample, flat
Exactly linear in batch size, which is what makes batch size the memory dial. For the 6.74B model above, the same measurement gives about 166 KiB per token per block in fp32, so 32 blocks at bf16 over a 4,096-token sequence is roughly 10.4 GiB of activations for a single sequence.

So the batch size you can use is decided by what is left after the fixed 16 bytes per parameter, and the three ways out are all forms of the same trade. Activation checkpointing stores only some activations and recomputes the rest in the backward pass, buying a large memory saving for roughly a third more compute. Gradient accumulation runs several small batches and steps once, so the effective batch is large while the resident activations are small. Sharding the optimiser state across devices attacks the other 12 bytes. Reach for them in that order.

Out of memory on step two, not step one

Adam's state is allocated during the first opt.step(), and PyTorch's caching allocator will have already filled memory with activation buffers. A script that trains one batch successfully and dies on the second has almost always hit this, and the fix is not a smaller batch by trial and error but the arithmetic above, done before launching.

Mixed precision#

Using 16-bit numbers for most operations and 32-bit where precision matters roughly halves memory and can double throughput, with essentially no quality loss. It is close to free and frequently left switched off.

There are two 16-bit formats and they are not interchangeable. Both use 16 bits; they spend them differently, one on range and the other on precision, and which one you have decides whether you need loss scaling.

import torch

for dt in (torch.float32, torch.float16, torch.bfloat16):
    i = torch.finfo(dt)
    print(f"{str(dt):16} max {i.max:.2e}  smallest normal {i.tiny:.2e}  eps {i.eps:.2e}")

# torch.float32    max 3.40e+38  smallest normal 1.18e-38  eps 1.19e-07
# torch.float16    max 6.55e+04  smallest normal 6.10e-05  eps 9.77e-04
# torch.bfloat16   max 3.39e+38  smallest normal 1.18e-38  eps 7.81e-03

g = torch.tensor([2.0e-8, 1.0e-5, 3.0e-3])          # plausible gradient magnitudes
show = lambda t: [f"{v:.2e}" for v in t.tolist()]
print(show(g.half().float()))
print(show(g.bfloat16().float()))
print(show((g * 1024).half().float() / 1024))       # the same in fp16, loss-scaled

# ['0.00e+00', '1.00e-05', '3.00e-03']   fp16: the first gradient became zero
# ['2.00e-08', '1.00e-05', '3.01e-03']   bf16: kept, with range to spare
# ['2.00e-08', '1.00e-05', '3.00e-03']   fp16 scaled by 1024 first: kept

print(float(torch.full((65536,), 1.0, dtype=torch.float16).sum(dtype=torch.float16)))
# inf  -- fp16 cannot represent 65536; bfloat16 and float32 both return 65536.0
bfloat16 has float32's exponent and eight fewer mantissa bits, so it has the same range and much less precision. float16 has the precision and a range that stops at 65,504 at the top and underflows to zero at 6e-8 at the bottom. Those are the two failures: a gradient that silently becomes zero, and a sum that becomes infinity. Both are why reductions and optimiser state stay in 32-bit even when the matrix multiplies do not.

The rule follows directly. On hardware with bfloat16 support, use it and do not use a gradient scaler — there is nothing to scale for. On older hardware you have float16, and you need the scaler: multiply the loss by a large constant so small gradients land inside the representable range, then divide it back out before the optimiser step. PyTorch's GradScaler does that and also skips any step whose gradients came back as infinity, adjusting the constant as it goes.

Precision loss is not where you expect

bfloat16's eight mantissa bits mean 1.0 plus 0.001 evaluates to exactly 1.0. That is why the optimiser keeps a 32-bit master copy of the weights: a typical update is many orders of magnitude smaller than the weight it is applied to, and adding it in bfloat16 would discard it entirely. The forward pass tolerates low precision; the accumulation of small updates does not.

# Needs a CUDA device. model, opt, loss_fn, batch and target are yours.

# On modern hardware, with bfloat16: no scaler needed.
with torch.autocast("cuda", dtype=torch.bfloat16):
    loss = loss_fn(model(batch), target)
loss.backward()
opt.step()
opt.zero_grad()

# On older hardware, with float16: the scaler is required.
scaler = torch.amp.GradScaler()

with torch.autocast("cuda", dtype=torch.float16):
    loss = loss_fn(model(batch), target)

scaler.scale(loss).backward()      # multiply the loss so gradients stay in range
scaler.step(opt)                   # unscale, then skip the step if any gradient is inf
scaler.update()                    # adjust the scale for next time
opt.zero_grad()
Note what autocast does not cover: it changes the dtype of operations inside the block, not the dtype of your parameters. The weights stay in float32, which is the master copy the note above is about. Also note the loss stays outside autocast in some codebases and inside in others; either works, but the backward pass must be outside, which is why the indentation matters.

Utilisation is not the whole story#

High utilisation does not mean efficient

nvidia-smi shows the fraction of time a kernel was running, not whether the hardware was used well. A GPU can report 100 percent while doing something badly. Low utilisation is a clear signal, usually data loading. High utilisation needs a profiler to interpret.

nvidia-smi                       # memory used, utilisation, temperature
watch -n 1 nvidia-smi            # live
nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu,power.draw \
           --format=csv -l 1     # just the four numbers, once a second

Four numbers are worth reading, and the useful skill is knowing what each one rules out rather than what it proves. Memory used against memory total tells you how much headroom the batch size has. Utilisation tells you what fraction of the last sampling interval had any kernel running. Power draw is the honest proxy for whether the arithmetic units are busy, because a GPU doing real matrix multiplication draws close to its rated power and one shuffling memory around does not. Temperature matters only when the card is throttling.

utilisation   power      what it means                      where to look

  low         low      the GPU is waiting                 data loading, or Python
                       for work                           between kernel launches
  high        low      kernels run constantly but         many tiny operations,
                       do little arithmetic               or a memory-bound workload
  high        high     the hardware is genuinely busy     the model, the batch size,
                                                          or accept it and scale out
  any        at cap    thermal or power throttling        cooling, or the power limit
An illustration of how to read the two together, not captured output — there is no GPU on the machine these words were written on. The middle row is the one that wastes people's time: utilisation reads 95 per cent and the run is still slow, because utilisation measures whether a kernel was running, not whether it was doing anything worth doing.

So the diagnostic order is: check memory first, since it decides what is possible at all; then check utilisation, since a low number is unambiguous and almost always means data loading; and only then reach for a profiler, because a high utilisation number tells you nothing on its own.

How deep to go#

For engineering, this level is sufficient: what fits, what makes it slow, and how to see it. Writing CUDA kernels is a specialisation that very few roles require, and it is a poor place to start.

What is worth memorising is short: sixteen bytes per parameter to train with Adam, two bytes to serve; activations scale with batch and sequence and nothing else; a chip balances at roughly a hundred and fifty FLOPs per byte, so anything below that is memory-bound; and bfloat16 has range while float16 has precision. What can be looked up on the day: the datasheet numbers for any particular card, the CUDA and driver version matrix, the flag names for any given framework's mixed precision, and everything about writing kernels.

Where people go wrong

Reading nvidia-smi utilisation as efficiency. It measures whether a kernel was running, not whether the work was done well, so 100 percent can still be slow.

If this comes up in an interview

Why are GPUs used for deep learning?

The core operation is matrix multiplication, which is thousands of independent multiply-accumulate operations. A GPU has thousands of simple cores suited to exactly that, while a CPU has a few complex ones.

What is mixed precision training?

Using 16-bit for most operations and 32-bit where precision matters, with loss scaling to stop small gradients underflowing. It roughly halves memory and can double throughput at essentially no quality cost.

Your GPU shows 30 percent utilisation. What is likely wrong?

The GPU is waiting, almost always for data. Raise data loader workers, enable pinned memory, and check for per-item preprocessing that should be precomputed or moved onto the device.

Build this: Train a model and measure throughput, then enable mixed precision and measure again. Then increase data loader workers and measure a third time.

This is one section of GPUs & accelerators, 16 hours across 4 sections. Read it in full to get the whole topic in order.