GPUs & accelerators, section 2 of 4, 4h

Getting the most from the hardware

By the end: You can identify whether a workload is compute-bound or memory-bound and act on it.

  • Compute-bound versus memory-bound
  • Batch size and utilisation
  • Kernel fusion, conceptually
  • Multi-GPU basics

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

You have a rough model of the hardware; this is about getting work out of it. The first question for any slow workload is whether the processors are the limit or moving data is, because the fixes point in opposite directions. The large matrix multiplications in training are usually compute-bound. Generating one token at a time is memory-bound, which is why batching requests together is the single largest lever on inference cost there is. This section covers making that diagnosis and what follows from each answer.

Two kinds of bottleneck#

A workload is compute-bound when the processors are the limit, and memory-bound when moving data is. Large matrix multiplications in training are usually compute-bound. Generating one token at a time in inference is usually memory-bound, since each step reads all the weights to do relatively little arithmetic.

Training, large batches     compute-bound   more FLOPs help
Inference, one token        memory-bound    more bandwidth helps
                                            batching helps most

You do not have to guess which one you have. Count the arithmetic and count the bytes, divide, and compare with the hardware's own ratio of peak arithmetic to peak bandwidth. Everything below the hardware's number is memory-bound.

operation                              FLOPs        bytes moved    intensity

add two n x n bf16 tensors             n^2          6 n^2          0.17
multiply two n x n bf16 matrices       2 n^3        6 n^2          n / 3
  n = 512                                                            171
  n = 4096                                                         1,365

A GPU with roughly 300 TFLOP/s and 2 TB/s balances at about 150 FLOP/byte.
Below that line, more arithmetic units buy you nothing at all.
The matrix multiply's intensity grows with n while the add's is fixed, which is the whole story: big matrix multiplies are the only common operation comfortably on the compute side of the line. Everything else — activations, normalisations, adding a residual, casting a dtype — is memory-bound.

The size of the gap is easy to underestimate until you measure it. There is no GPU here, so this is the same experiment on this machine's CPU, where the ratio is different but the physics is identical.

matmul  1024x1024    1,194 GFLOP/s     intensity 171 FLOP/byte
matmul  2048x2048      917 GFLOP/s     intensity 341 FLOP/byte
matmul  4096x4096      769 GFLOP/s     intensity 683 FLOP/byte
add     2048x2048        4.2 GFLOP/s   intensity 0.08          50.3 GB/s
add     4096x4096        4.0 GFLOP/s   intensity 0.08          48.3 GB/s
add     8192x8192        4.0 GFLOP/s   intensity 0.08          48.3 GB/s
Measured with torch on this machine's CPU, not a GPU. Note the last column: the add runs at the same 48 GB/s whatever its size, because that is the memory bandwidth and the addition itself is free by comparison. Two hundred times the arithmetic rate separates the two regimes, on the same silicon, on the same day.

Applying this to a language model gives the result people find counterintuitive. Generating one token for one sequence must read every weight in the model — 14 GB for a 7B model in bf16 — and does about two arithmetic operations per weight. Two FLOPs per two bytes is an intensity of 1, two orders of magnitude below the hardware's balance point, so the GPU's arithmetic units are idle more than 99 per cent of the time and generation speed is decided purely by how fast weights can be read.

Batch size fills the machine#

Small batches leave most of the GPU idle. Larger batches keep the cores busy, up to the limit of memory. For inference, batching many requests together is what turns a memory-bound workload into an efficient one.

The mechanism is worth spelling out because it explains why the effect is so large. The weights are read once per step regardless of how many sequences are in the batch. One sequence does one token's worth of arithmetic per weight read; thirty-two sequences do thirty-two tokens' worth for the same read. The arithmetic intensity rises in direct proportion to the batch size, and until it crosses the hardware's balance point the step takes very nearly the same wall-clock time.

batch   weight reads   tokens produced   intensity    time per step   tokens/s
   1      14 GB              1              1x            same           1x
   4      14 GB              4              4x            same           4x
  16      14 GB             16             16x            about same    ~16x
  64      14 GB             64             64x            rising         <64x
 256      14 GB            256            256x            compute-bound  flattens
An illustration of the shape, not measured output — there is no GPU on this machine. The important claim is qualitative and reliable: throughput rises nearly linearly with batch size while the workload is memory-bound, then flattens once it is not. The knee is what you are looking for, and the only way to find it on your hardware is to sweep it.

Two costs push back. Latency for an individual request goes up, because a request waits for the batch to form and then shares the step with everyone else, so throughput and time-to-first-token trade against each other directly. And memory goes up: each sequence in the batch carries its own KV cache, which for a 7B model at 8,192 tokens of context is about 4 GiB per sequence with full multi-head attention. Sixteen concurrent sequences at that context need 64 GiB of cache against 12.6 GiB of weights, so the cache, not the model, is what fills the card.

The KV cache is why grouped-query attention exists

Sharing key and value heads across query heads cuts the cache in direct proportion. The same 7B shape at 8,192 tokens and 16 concurrent sequences needs 64 GiB with 32 key-value heads, 16 GiB with 8, and 2 GiB with 1. That is the difference between serving 16 users on a card and serving 200, and it costs a little quality. It is the single highest-leverage architectural decision for serving cost.

Fusion#

Each separate operation reads its input from memory and writes its output back. Fusing several operations into one kernel keeps intermediate results on the chip, avoiding round trips. This is much of what compilers and optimised attention implementations do.

Count the traffic on a sequence you write every day. A linear layer followed by a bias add, a GELU and a dropout is four operations on the same tensor. Unfused, the intermediate result is written to memory and read back three times — six tensor-sized transfers that exist only because the operations were separate kernels. Fused, it is written once. The arithmetic is identical; roughly five-sixths of the memory traffic is gone, and since the sequence was memory-bound, that is very close to a five-fold speedup on that part of the network.

In practice you get this by asking rather than by writing kernels. torch.compile fuses elementwise chains automatically, and the attention implementations shipped with modern frameworks are already fused. The engineering skill is recognising which parts of your model are a chain of small elementwise operations, because those are the parts where a compiler has something to work with, and knowing that it will do nothing for a model that is already one large matrix multiplication.

Optimised attention is a memory trick

Fast attention implementations get their speed mainly by avoiding writing the large attention matrix to memory, computing it in tiles that stay on-chip. The arithmetic is the same; the memory traffic is what changed. This also explains why the saving grows with sequence length: the attention matrix is the square of the sequence length, so at 8,192 tokens it is the largest tensor in the model by a wide margin, and never materialising it is worth more than any arithmetic saving could be.

When one GPU is not enough#

Before reaching for more hardware, exhaust the cheap moves, because they are usually enough and they are always faster to try. Mixed precision halves the activation memory and roughly doubles matrix multiply throughput. Gradient accumulation gets you a large effective batch with small resident activations. Activation checkpointing trades about a third more compute for a large memory saving. Each is one line and none of them requires a second device.

When you do add devices, the first thing to know is that the link between them is a hierarchy and it decides what is worth doing. Cards inside one machine talk over a fast direct interconnect at hundreds of gigabytes a second. Machines talk over a network, which is one or two orders of magnitude slower. A parallelism strategy that is fine inside a box can be unusable across boxes, and that boundary, not the number of GPUs, is the thing to design around.

What to skip: writing kernels. The skill that pays here is diagnosis — deciding which side of the roofline you are on, and choosing between a larger batch, a fused implementation, lower precision or more devices. Reading a kernel occasionally is useful; writing one is a specialisation very few roles ask for.

Where people go wrong

Buying a faster GPU for a memory-bound workload. If bandwidth is the limit, more compute helps very little.

If this comes up in an interview

Is LLM inference compute-bound or memory-bound?

Generating one token at a time is typically memory-bound, since each step reads all the weights to do comparatively little arithmetic. Batching many requests together is the main way to make it efficient.

What does kernel fusion do?

Combines several operations into one kernel so intermediate results stay on-chip instead of being written to and read from memory between steps. The saving is in memory traffic rather than arithmetic.

Build this: Measure inference throughput at batch sizes 1, 8 and 32 and plot tokens per second. The shape of the curve shows the memory-bound regime.

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