Every optimisation is a hypothesis
Someone tells you the training run is slow. You have a list of things that make training runs faster: bigger batches, fused kernels, mixed precision, gradient accumulation, a better data loader, torch.compile, CUDA graphs, a faster interconnect. Every one of those is a real technique that has produced real speedups on real workloads. And on any particular run, most of them will do nothing at all.
They do nothing because a technique only helps if it relieves the resource that the machine is currently waiting on. Fusing elementwise kernels is a bandwidth optimisation; if you are waiting on the network it changes nothing. Overlapping communication is a scheduling optimisation; if your GPUs are idle because a Python worker is decoding JPEGs, overlapping the all-reduce with a GPU that is already idle is free and worthless. A speedup is not a property of a technique. It is a property of the match between a technique and a bottleneck.
So the sentence to hold onto for the rest of this article is:
Every optimisation is a hypothesis about the bottleneck. Profiling exists to test that hypothesis before you spend a week on it.
The seven articles before this one built a model of the machine: what work a layer implies, what tensors it materialises, where those tensors live, how many bytes move across which boundary, and who has to talk to whom when the model no longer fits on one device. This article closes the loop. It is about walking up to a running system that is slower than you expected and finding out, from evidence rather than folklore, which of those costs is the one you are actually paying.
The method has three moves, and they are the same three moves whether the workload is a 7B vision-language model on 64 H100s or a single-GPU inference server. Look at time: build a timeline and find where the scarce resource is not doing useful work. Name the resource: measure enough to say which of eleven things is saturated. Predict, then verify: state what the fix should buy you in units of time, apply it, and check whether the number moved. If it did not, your hypothesis was wrong, and that is information — as long as you were measuring.
Four kinds of callout run through this article. Profiling is a field where the confident wrong answer is the expensive one, so the callouts carry most of the warnings.
A subtlety that changes how a measurement should be read — a counter that means something narrower than its name, or a verdict that only holds at one scale.
A conclusion the evidence invites but does not support. Each one is a mistake common enough to have cost somebody a week.
A number that is vendor-quoted, workload-dependent or estimated rather than derived. Where a decision turns on one, measure it on your own hardware.
A worked example, a command you can paste, or a practical suggestion. Skippable without losing the argument.
The timeline is the primitive
Almost every performance question is really a question about time, and time in a modern accelerator system is not one line but several. There is a CPU thread running Python and enqueuing work. There is a copy engine moving bytes over PCIe or NVLink. There are one or more compute streams executing kernels. There is a NIC moving gradients or activations between hosts. There is a storage path pulling shards off a network filesystem. These run concurrently, they depend on each other, and the thing you call "step time" is the shape their overlap happens to take.
So before any counter, any roofline, any tool: draw the lanes. The first diagnostic question in all of systems performance is not "how fast is my GPU" but:
Is the scarce resource doing useful work continuously, and if not, which lane is it waiting on?
The figure below is one training step of a mid-sized vision-language model, drawn as five lanes. It is a deliberately unhealthy step, because healthy steps teach you nothing. Step it through: watch the playhead sweep, then look at what the GPU lane is doing while the other lanes are busy.
Three things in that figure are worth naming, because they generalise to every workload you will ever profile.
First, the gaps are the measurement. The step took 120 ms and the GPU did useful work for 64 of them. Nothing about the kernels was slow. If you had spent the week making the forward pass 20% faster you would have bought 10 ms out of 120, an 8% speedup, while a 22 ms hole sat untouched. The distribution of idle time is the priority list.
Second, the cause of a gap is never inside the gap. A hole in the GPU lane contains, by definition, no information about itself. You find the cause by looking at what the other lanes were doing during it, and then at what those lanes were waiting on. Here the GPU waited on a copy, the copy waited on a pinned buffer, and the buffer waited on a worker process that was still decoding. Three lanes up, the real problem is a CPU one.
Third, dependency, not duration, decides what matters. The all-reduce lane is busy for 44 ms but only 16 of those cost you anything, because 28 ms of it sits underneath backward work that had to happen anyway. Comparing "time spent in NCCL" against "time spent in GEMMs" is meaningless. The only communication number worth reporting is exposed communication: the part that no computation was available to hide.
Tracing every kernel launch adds CPU work to the lane that is often already the problem. A run that is launch-bound will look worse under a profiler, and a run that is comfortably GPU-bound will barely notice. Always sanity-check the profiled step time against the unprofiled one from your normal logs; if tracing inflated the step by 30%, treat the CPU lane in that trace as an upper bound rather than a measurement. Nsight Systems at default settings is usually cheap; per-kernel replay in Nsight Compute is emphatically not, and can slow a kernel by orders of magnitude by design.
For PyTorch, the built-in profiler with
record_shapes and a stack of a dozen steps is enough to produce a Chrome trace you can open in chrome://tracing or Perfetto. For anything involving multiple processes, copy engines or the network, use Nsight Systems instead, because it sees lanes the framework cannot:
nsys profile -t cuda,nvtx,osrt,cudnn,cublas \
--capture-range=cudaProfilerApi \
-o step_trace python train.py
# wrap the steps you care about, or you will capture the whole warmup
with torch.profiler.profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=torch.profiler.schedule(wait=5, warmup=3, active=6),
on_trace_ready=torch.profiler.tensorboard_trace_handler("./tb"),
record_shapes=True, profile_memory=True, with_stack=True): ...
Annotate your own phases with torch.cuda.nvtx.range_push("vit_forward"). Ten minutes of NVTX ranges will save you an hour of squinting at a wall of unnamed kernels.Eleven ways to be slow
"The model is slow" is not a diagnosis, and neither is "we are GPU bound". To make progress you need a vocabulary with enough resolution that each entry implies a different fix. Here is the full list. Everything in the rest of this article is machinery for deciding which row you are in.
| Bottleneck | Signature on the timeline | What confirms it | What actually helps |
|---|---|---|---|
| Compute throughput | Dense kernels back to back, no gaps | High tensor-core pipe activity, MFU near your realistic ceiling | Fewer FLOPs, better kernels, lower precision |
| Memory bandwidth | Dense kernels, but the fast ones are elementwise | DRAM throughput near peak, low tensor-core activity | Fusion, reuse, recompute, smaller dtypes |
| Memory capacity | Small batch, recompute everywhere, or an OOM | Peak allocated near HBM size, high recompute fraction | Sharding, offload, checkpointing, allocator tuning |
| Kernel latency | Thousands of short kernels with small gaps | Mean kernel duration < ~20 µs, low occupancy | Fusion, CUDA graphs, bigger tiles, batching |
| CPU launch overhead | GPU lane sparse; CPU lane saturated | Sum of kernel time < wall time, CPU thread at 100% | torch.compile, graphs, fewer python ops |
| Input pipeline | Periodic GPU holes aligned with batch boundaries | Prefetch queue depth hits zero | More workers, decode on GPU, better format |
| Communication bandwidth | Long collectives that scale with message size | Achieved bus bandwidth near link peak | Fewer bytes, lower precision, better topology |
| Communication latency | Many tiny collectives; duration independent of size | Collective time ≈ constant as bytes shrink | Bucketing, fusion, fewer sync points |
| Synchronisation | Ranks arrive at a collective at different times | Wait time before the transfer, per rank | Remove the sync, or move it off the hot path |
| Load imbalance | One rank consistently later than the rest | Per-rank compute time spread | Rebalance the shard, not the network |
| Storage / I/O | First epoch slow, later epochs fine | Read throughput, page-cache hit rate | Sharding, sequential layout, local cache |
A real workload usually has three of these at once, in different proportions on different ranks. What makes the taxonomy usable is not that exactly one row is true, but that at any moment one row dominates the critical path — and when you fix it, a different row takes over. The whole discipline is: identify the current one, fix it, re-measure, repeat, and know when to stop.
What GPU utilisation actually counts
The first number everyone reaches for is the one nvidia-smi prints, and it is the most misleading number in the stack. It is not a measure of how much of the GPU you are using. It is the fraction of sampled time intervals during which at least one kernel was resident on the device. One thread block, occupying one of 132 streaming multiprocessors, doing an elementwise copy of four thousand floats, will report the same 100% as a perfectly tuned GEMM saturating every tensor core in the machine.
This is not a bug; it is what the counter was designed for, back when a GPU was a graphics device and "is anyone using this card" was the question. But it means the number can only ever falsify: 40% utilisation proves you have a gap problem, while 100% proves nothing at all. The figure below runs three completely different kernels through the same instrument.
The three kernels differ in every way that matters and agree on the only number most dashboards show. Kernel A is latency bound: it occupies eight SMs for nine microseconds, and the interesting fact about it is not its duration but that the model launches nine hundred like it per step. Kernel B is bandwidth bound: every SM is busy, most warps are resident, and almost all of them are stalled waiting on DRAM. Kernel C is compute bound with a third of the occupancy of B, which is fine, because each of its warps is issuing tensor-core instructions that do 512 multiply-accumulates apiece.
Occupancy is the ratio of resident warps to the hardware maximum, and it exists to give the scheduler something to switch to while a warp waits. It is a latency-hiding resource, not a performance target. Well-tuned GEMM kernels routinely run at 25–35% occupancy because each thread holds a large register-resident accumulator tile, and trading those registers for more warps would make them slower. Raise occupancy when the profiler says warps are stalling with nothing else to issue; otherwise leave it alone.
Model FLOPs Utilisation compares the useful FLOPs your model definition implies against the vendor's dense peak — for an H100 SXM, 989 TFLOP/s in bf16 with the sparsity claim removed. It is a deliberately harsh denominator: it charges you for recomputation you chose to do, and it uses a clock the card cannot sustain under real power and thermal limits. Treat 35–55% as a good large-model training number rather than a failure to reach 100%, and care about the trend across your own changes far more than the absolute value.
What we want to measure, before any tool
Tools change every eighteen months. What you want out of them does not. If you derive the measurement list from the machine model rather than from a tool's menu, you will know what to look for in whatever the next profiler is called.
The machine has a small number of resources that can be the scarce one, and each has exactly two properties worth knowing: how much of it you used and how fast it could have gone. Every diagnosis is a ratio between those two.
| Resource | What you count | What you divide by | The ratio is called |
|---|---|---|---|
| Arithmetic | Useful FLOPs from the model definition | Elapsed time × dense peak | MFU |
| Tensor cores | Cycles the tensor pipe was active | Total active cycles | Pipe utilisation |
| HBM | Bytes read + written at DRAM | Elapsed time × HBM bandwidth | Bandwidth utilisation |
| Cache | L2 / L1 hits | Total requests | Hit rate |
| Execution | Cycles an issue slot was used | Cycles a warp was resident | Issue efficiency |
| Scheduler | Resident warps | Hardware maximum | Achieved occupancy |
| Network | Bytes moved by a collective | Collective duration × link peak | Bus bandwidth |
| Time itself | Sum of kernel durations | Wall-clock step time | GPU busy fraction |
Notice the last row. It needs no special tooling, it costs nothing, and it is the single most informative number you can compute early: add up every kernel's duration in a step and divide by the step's wall time. If that is 0.55, no kernel optimisation in the world can buy you more than a 1.8× speedup, and you should go look for the gaps. If it is 0.95, gaps are not your problem and you can start reading counters.
"DRAM throughput 89%" is a count over a duration. It becomes a verdict only when you compare it against a ceiling and against the alternative explanations. The same 89% means "bandwidth bound, go fuse" for an elementwise chain and "something is wrong with your tiling" for a GEMM that should have been reusing its operands from shared memory. The number never carries its own interpretation; the model of the workload does.
Three magnifications, three tools
Profilers are usually presented as a list of products. It is more useful to think of them as three magnifications of one microscope. You start at the scale of the training step, and you only descend when the level above has told you where to point.
Scale 1 — the framework profiler
PyTorch's profiler knows things no external tool does: which Python operator issued a kernel, what the input shapes were, which module in your nn.Module tree it belongs to, and how much memory the allocator handed out during it. That provenance is why it is the right first stop. Sort by self CUDA time, look at the top ten rows, and ask whether the ranking matches your mental model of the network. If a layer you thought was free is in the top three, you have learned something before opening a single counter.
Its limitation is that it is a framework's view of the world. It sums operator durations well and reasons about the space between them poorly: copy engines, other processes, NCCL internals and CPU-side stalls that happen outside an operator are all either invisible or approximate.
Scale 2 — Nsight Systems
This is the tool for everything that is not a kernel. It samples the OS scheduler, traces CUDA API calls separately from kernel execution, shows the copy engines as their own lanes, unrolls NCCL, and — critically — draws all of it on one shared clock across processes. The step timeline in Figure 01 is exactly what it produces. Use it whenever the question is "why is there a hole", "who is waiting for whom", or "is this overlapping like I think it is".
The habit worth building: annotate your code with NVTX ranges that match how you think about the model, so the trace reads as vit_forward → connector → llm_forward → loss → backward → optimizer rather than as forty thousand anonymous kernels.
Scale 3 — Nsight Compute
Only descend here when scales 1 and 2 have already told you that a specific kernel is worth a specific fraction of the step. Nsight Compute replays a single kernel many times with different counters enabled, which is why it is slow and why it is definitive. It answers precisely one class of question: given that this kernel is running, what inside the SM is preventing it from going faster. Warp stall reasons, memory-hierarchy throughput at every level, tensor-core pipe activity, and a per-kernel roofline all come from here.
1. Sum kernel time ÷ wall time. If it is low, stop and go find gaps. 2. Rank operators by self CUDA time and check the ranking against the model. 3. Take one
nsys trace of five steady-state steps and look at lane occupancy, not at kernels. 4. Only then pick the single worst kernel and open ncu on it. Most performance work never reaches step 4, and the teams that start at step 4 are the ones who spend a week tuning a kernel that was 3% of the step.Three numbers into a verdict
Earlier in this series, Why Fast GPUs Still Wait for Memory derived the roofline from first principles. Here we only need its operational form, which is a procedure rather than a picture. Measure three things about a kernel or a phase: the useful FLOPs it performs, the bytes it moves at the DRAM boundary, and how long it took. From those, arithmetic intensity is FLOPs ÷ bytes, achieved performance is FLOPs ÷ time, and the ceiling for that intensity is the smaller of the machine's compute peak and its bandwidth times that intensity.
Then read off which of three situations you are in. On the sloped roof: you are bandwidth bound, and the only thing that helps is moving fewer bytes. On the flat roof: you are compute bound, and the only thing that helps is fewer or cheaper FLOPs. Well below both roofs: you are neither, which is the most common and most misdiagnosed case, and it means the kernel is limited by latency, occupancy, launch overhead or something else that stops the machine from ever reaching a throughput limit at all.
That third case matters because it is where most unoptimised ML code actually lives, and because both instincts it triggers — "reduce FLOPs" and "reduce bytes" — are wrong there. Watch a real optimisation walk across the plot.
A kernel at 12% of its roof is not automatically worth fixing. Multiply by its share of the step first. Tripling the throughput of something that is 4% of your time buys 2.7%; halving the exposed all-reduce that is 18% buys 9%. The roofline tells you whether a kernel can go faster and what kind of change would do it. Only the timeline tells you whether you should care.
"Bytes moved" means bytes crossing the boundary you named. A fused kernel that reads from L2 moves DRAM bytes only on the miss, so its measured intensity depends on cache behaviour and therefore on the shapes around it. Use the profiler's DRAM counters rather than analytic byte counts when the two disagree, and never mix an analytic FLOP count with a measured byte count without saying so — the ratio will be wrong in a direction you cannot predict.
The diagnosis tree
Everything so far collapses into one decision procedure. It starts with the cheapest measurement in the stack and only asks for expensive ones when the cheap one has narrowed the field. Walk it below; each leaf names the evidence that confirms it, because a verdict you cannot confirm is a guess with better vocabulary.
Lab 01 · walk the tree
Answer with what you measured, not with what you suspect.
start
The input pipeline
Input bottlenecks are the most common cause of a disappointing training run and the least glamorous to fix, which is roughly why they survive so long. The structure is always the same: a producer, a bounded queue, and a consumer that is far more expensive per unit than the producer and therefore far more expensive to starve.
The producer is a pool of worker processes doing decode, augmentation, tokenisation and collation on CPU cores. The queue is the prefetch buffer — num_workers × prefetch_factor batches deep. The consumer is the GPU. Since queue depth is bounded, the steady-state rule is brutally simple: if the producer's average time per batch exceeds the consumer's, the queue empties and stays empty, and every step pays the difference. Depth buys you tolerance for jitter, never for a throughput deficit.
Configuration A loses 16 ms per 96, or 17% of the run, and it loses it forever — the queue never recovers, because recovery requires the producer to be faster than the consumer for a while and it never is. Configuration B doubles the workers and the queue saturates at its cap, which is what a healthy pipeline looks like: depth pinned near the top, with the occasional dip when a slow shard arrives. Configuration C moves decode to the GPU and makes the copy asynchronous, which does not make training faster at all — the GPU was already fully fed — but it frees eight CPU cores you can now spend elsewhere.
You rarely need a profiler for this. Log the prefetch queue depth every step, or simply time
next(iter) separately from the rest of the step. A depth histogram concentrated at the cap means the input pipeline is not your problem, no matter how slow the loader feels; a histogram with mass at zero means it is, no matter how many workers you have already thrown at it. Everything else — which format, how many workers, whether to decode on GPU — is a question about how to move that histogram.Epoch one reads cold from a network filesystem and looks catastrophic; epoch two reads from the page cache and looks perfect. If your dataset fits in host RAM you will measure a pipeline that does not exist at real scale, and if it does not fit you will measure a storage system under a cold-start pattern you will never see again. Always profile in steady state, several hundred steps in, and say which regime you profiled.
Distributed training
Once there is more than one GPU, a new class of bottleneck appears that has no analogue on a single device: waiting for someone else. It is also the class most often misdiagnosed, because the profiler faithfully reports where the time was spent — inside a collective — and the cause is usually somewhere else entirely.
Start from the dependency structure. Gradients become available progressively during the backward pass, in reverse layer order. A collective on the last layer's gradients can therefore start while the rest of the backward pass is still running, which is what gradient bucketing exists to exploit. The consequence is that only the tail of communication is ever exposed — unless something breaks the overlap.
Scenario three is the one worth internalising. Every rank's profile shows a long, slow all-reduce. The bus bandwidth during the transfer is at link peak. The network is innocent: three ranks reached the collective and then sat inside it, doing nothing, until the fourth arrived. If you respond by tuning NCCL you will change nothing, because the eighteen milliseconds you are chasing were spent on rank 2's forward pass — perhaps because its shard of the batch had more images, or longer sequences, or one unlucky sample with a very high resolution.
Scenario four is the measurement that separates them, and it is worth building into your harness permanently: put an explicit barrier immediately before the collective and time both halves. Time in the barrier is arrival skew, which is a load-balancing problem. Time in the collective after the barrier is transfer, which is a network problem. Without that split, the two are indistinguishable in every profile you will ever take.
Log each rank's compute time per step and print the max-minus-min across ranks. If that spread is a few percent, imbalance is not your problem. If it is 15%, you have found your bottleneck and no amount of communication tuning will touch it. On multimodal workloads this number is almost never small by default, because samples differ in cost by an order of magnitude and a naive sampler distributes that cost randomly rather than evenly.
Halve the message size. If the collective takes about half as long, you are bandwidth bound and the fix is fewer bytes. If it takes almost exactly as long, you are latency bound and the fix is fewer messages — larger buckets, fused collectives, fewer synchronisation points. This one experiment costs five minutes and rules out half the taxonomy.
Memory diagnosis
Memory is the bottleneck that does not slow you down; it stops you, or it forces a configuration that slows you down elsewhere. A batch size chosen because 33 would OOM is a memory bottleneck wearing a throughput costume, and so is every activation checkpoint you did not want.
Three numbers, and the difference between them is the whole subject. Allocated is what your tensors currently hold. Reserved is what the caching allocator has taken from the driver and will not give back — it keeps freed blocks in its own pools so the next allocation does not need an expensive, synchronising cudaMalloc. Peak is the high-water mark of allocated within a window, and it is the number that decides whether you fit. Reserved minus allocated is cached-but-idle memory, which is normally healthy and occasionally fatal.
The failure mode in that figure — an out-of-memory error while gigabytes sit free — confuses people the first time and is entirely mundane once you see the shape. The allocator hands out blocks carved from larger segments. Freeing a tensor returns its block to the pool, not to the driver. If your step allocates a different mix of shapes each iteration, those pools fill with fragments that are individually too small for the next large request. Variable sequence lengths, variable image resolutions and dynamic batch composition are the usual culprits, which is precisely why multimodal training hits this more than plain language modelling.
Every PyTorch OOM tells you four numbers: what was requested, total capacity, how much is free, and how much is reserved-but-unallocated. If free is small, you have a capacity problem and you need to shard, checkpoint or shrink. If free is large and reserved-but-unallocated is also large, you have a fragmentation problem and the fix is allocator configuration or shape stability. Those are different problems with different fixes, and the message distinguishes them for you. For anything harder,
torch.cuda.memory._record_memory_history() followed by a snapshot dump gives you a per-allocation timeline with stack traces.Freeing memory is only worth doing if you spend it. If you halve activation memory and keep the same batch size, you have bought nothing but comfort. The chain has to close: less memory → larger batch or fewer shards or less recomputation → measurably fewer seconds per step. Always state the second half of that sentence before starting the work.
Inference diagnosis
Serving has its own vocabulary because it has its own objective. Training optimises one number, throughput. Serving optimises three that trade against each other: time to first token, time per output token, and total throughput. A configuration that maximises the third invariably damages the first, which is why "our GPUs are 95% busy" is a much weaker statement in serving than it sounds.
The structural reason is that a decoder does two very different jobs. Prefill processes the whole prompt in parallel: a big, compute-bound GEMM that saturates the tensor cores. Decode produces one token per request per iteration: a small, bandwidth-bound pass whose cost is dominated by reading the weights and the KV cache out of HBM. They compete for the same device, and how you schedule that competition is your latency profile.
Map the symptom to the mechanism before touching a knob:
| Symptom | Candidate causes, in the order worth checking |
|---|---|
| Bad TTFT | Queueing delay before the request is admitted; prefill competing with a full decode batch; a genuinely long prompt; admission policy that waits to form a batch; cold cache or model load on a scaled-up replica. |
| Bad TPOT | Decode is bandwidth bound and the batch is too small to amortise the weight read; KV cache reads growing with context; prefill of other requests stealing iterations; tensor-parallel communication on every layer; CPU-side scheduling between iterations. |
| Low throughput | Batches never fill because of admission or memory limits; KV cache fragmentation capping concurrency; per-iteration launch overhead; a scheduler that cannot mix prefill and decode; padding waste from ragged batches. |
In a decode step you read the entire weight matrix to produce one token per sequence. Two sequences read the same weights once. That is why increasing the decode batch improves throughput almost linearly at first with barely any latency cost — you are amortising a fixed byte movement over more work. The regime ends when the KV cache reads, which scale with batch and context, start to dominate the weight reads. At that point TPOT climbs with batch size and the free lunch is over. Knowing which side of that transition you are on is the single most useful thing to measure in a serving system.
Prefill-heavy and decode-heavy requests have different cost structures, so a mean latency over mixed traffic describes nobody. Report TTFT and TPOT separately, at p50 and p99, bucketed by prompt length. A p99 TTFT regression caused entirely by 8k-token prompts is invisible in an average and obvious in a bucketed histogram.
Overlap, and what a step really costs
The resource model is now nearly complete, and the last piece is the one that makes all the arithmetic non-obvious. Compute, communication and data loading use different hardware. If they were perfectly independent, a step would cost the maximum of the three rather than the sum. If they were perfectly dependent, it would cost the sum. Real systems sit somewhere between, and where exactly they sit is decided by dependencies you can usually change.
So the useful decomposition of a step is not "how much time did each resource use" but:
step = critical-path compute + the part of everything else that had nothing to hide under.
That second term is the only one worth optimising, and it explains several results that otherwise look paradoxical. Making the backward pass faster can make a step less efficient, because there is now less computation available to hide the same collectives under. Adding gradient accumulation reduces communication per sample but also reduces the compute available to overlap it. Prefetching further ahead helps nothing if the producer is fundamentally slower than the consumer. Play with the arithmetic below until those three sentences feel obvious.
Lab 02 · exposed time
Four resources, one step. Move the overlap sliders to see how much of each cost you actually pay.
Two things run concurrently when nothing forces an order between them. Copy engines and compute streams are separate hardware, but a synchronous
.cpu() in the middle of your step imposes an order the hardware never asked for. Most "we cannot overlap this" problems are really "we inserted a dependency we did not need": a metric read too early, a shape computed on the host, an assertion on a tensor value, a logging call inside the loop.Case study: whack-a-mole
Here is the whole method applied to one run, and the point of it is the shape of the sequence rather than any individual fix. Each intervention is chosen because a measurement identified it, each is verified by re-measuring, and each one promotes a different resource to the top of the list.
The run went from 380 ms to 166 ms per step, a 2.29× speedup, and not one of the four moves was chosen from a list of best practices. Each was chosen because a measurement pointed at it. Note also what did not happen: nobody wrote a custom kernel, nobody changed the model, and the total time spent was days rather than weeks — because the expensive tools were only ever pointed at things the cheap tools had already implicated.
The last stage is the one people skip. After three fixes, the step is 90% dense GEMM running at a healthy fraction of the machine's peak. There is still headroom, in the sense that 61% is not 100%. But the remaining gains require changing the algorithm, the numerics or the parallelism strategy — and each of those carries correctness risk that the previous four fixes did not. Knowing that you have arrived at that boundary is as much a part of the discipline as finding the first bottleneck.
Every measurement in this case study should be taken at the scale you intend to run at. Larger micro-batches reduce launch overhead and improve MFU on a single GPU, while increasing activation memory and, under sharding, the bytes each collective must move. A change that is a clear win at one node can be neutral at eight and negative at sixty-four. If you cannot profile at full scale, profile at two scales and extrapolate the trend rather than the number.
Ten questions
This is the last article in the series, so it should end with the thing that survives after the specific tools have been replaced. Every question the previous seven articles asked can be recovered from ten, asked in this order, of any machine learning system you are handed:
- What computation must happen? Not what the code does — what the model definition implies. FLOPs by layer, from shapes.
- What tensors exist? Parameters, gradients, optimiser state, activations, caches. Their sizes are arithmetic, not mystery.
- Where do they live? Registers, shared memory, L2, HBM, host RAM, another device, disk. Each has a different price.
- How many bytes move? Per operator and per step, at the boundary you name.
- Across which boundary? The same gigabyte costs microseconds over NVLink and milliseconds over Ethernet.
- Who communicates with whom? Which ranks, which collective, how many times per step, and at what point in the dependency graph.
- What depends on what? The critical path is the only path whose length is your step time.
- What can overlap? Anything with no dependency between it and something else — which is more than your current code allows.
- What resource is saturated? Measured against its ceiling, not against your expectations.
- If we remove that bottleneck, what becomes the next one? Ask before you start, so you know what the fix is worth.
Question ten deserves the last word, because it is the one that turns a technique into a discipline. Amdahl's arithmetic is unforgiving: a component that is 30% of your step time can never buy you more than 1.43×, no matter how brilliantly you eliminate it. Knowing the answer to question ten before you begin tells you whether the week is worth spending, and knowing it afterwards tells you when to stop.
Performance in machine learning systems is not about maximising GPU utilisation in isolation. It is about orchestrating compute, memory, communication, I/O and synchronisation so that the scarce resource stays useful while everything else stays out of its way — and then, when it finally is, having the discipline to stop.
References
Grouped by topic. Every entry links to its primary source.
Timelines and tools
- torch.profiler · PyTorch — schedules, trace handlers, shape and memory recording.
- Profiling PyTorch with TensorBoard · PyTorch tutorials — the practical loop for reading operator tables and traces.
- Holistic Trace Analysis · Meta — automated idle-time breakdown, kernel classification and communication–computation overlap from PyTorch traces.
- Nsight Systems · NVIDIA — the system-wide timeline: OS threads, CUDA API, copy engines, NCCL, NVTX.
- Nsight Compute Profiling Guide · NVIDIA — counter definitions, warp stall reasons, the per-kernel roofline section.
- NVML device queries · NVIDIA — the definition behind
utilization.gpu: the fraction of sample intervals in which at least one kernel was executing.
Bounds, counters and the roofline
- Roofline: An Insightful Visual Performance Model for Multicore Architectures · Williams, Waterman & Patterson, CACM 2009 — the original model.
- Making Deep Learning Go Brrrr From First Principles · Horace He — compute, bandwidth and overhead as the three regimes of a deep learning workload.
- PaLM: Scaling Language Modeling with Pathways · Chowdhery et al., 2022 — the definition of Model FLOPs Utilisation and why it is reported instead of hardware utilisation.
- Efficient Large-Scale Language Model Training on GPU Clusters Using Megatron-LM · Narayanan et al., 2021 — end-to-end accounting of compute, communication and bubbles at scale.
- NVIDIA H100 Tensor Core GPU · NVIDIA — the peak numbers used throughout this article, and the fine print about dense versus sparse.
Kernels, fusion and launch overhead
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness · Dao et al., 2022 — the canonical demonstration that a "compute" kernel was a data-movement problem.
- Accelerating PyTorch with CUDA Graphs · PyTorch — replaying a captured launch sequence when the CPU is the bottleneck.
- torch.compile · PyTorch — graph capture, fusion and the reduction in dispatch that follows.
- Matrix Multiplication Background User's Guide · NVIDIA — tile and wave quantisation, and why a GEMM's shape decides its efficiency.
Input pipelines
- torch.utils.data · PyTorch — workers, prefetch factor, pinned memory and the mechanics of the queue.
- NVIDIA DALI · NVIDIA — moving decode and augmentation onto the GPU when the CPU is the limit.
- WebDataset · sequential sharded formats for datasets that live behind a network filesystem.
Distributed training
- NCCL User Guide · NVIDIA — collective algorithms, topology detection and the environment variables worth knowing.
- PyTorch Distributed: Experiences on Accelerating Data Parallel Training · Li et al., 2020 — gradient bucketing and computation–communication overlap, described by the people who built it.
- nccl-tests · NVIDIA — the measurement that tells you what your fabric can actually do, so you know what to compare against.
- PyTorch FSDP: Experiences on Scaling Fully Sharded Data Parallel · Zhao et al., 2023 — prefetch, resharding and where the bubbles come from.
Memory
- CUDA semantics — memory management · PyTorch — the caching allocator, reserved versus allocated, and
PYTORCH_CUDA_ALLOC_CONF. - Understanding GPU Memory: Visualizing All Allocations Over Time · PyTorch — the memory snapshot tool and how to read a fragmentation timeline.
- Training Deep Nets with Sublinear Memory Cost · Chen et al., 2016 — the original memory-for-compute trade.
Inference and serving
- Orca: A Distributed Serving System for Transformer-Based Generative Models · Yu et al., OSDI 2022 — iteration-level (continuous) batching.
- Efficient Memory Management for Large Language Model Serving with PagedAttention · Kwon et al., SOSP 2023 — KV-cache fragmentation as the limit on concurrency.
- Taming Throughput-Latency Tradeoff in LLM Inference with Sarathi-Serve · Agrawal et al., OSDI 2024 — chunked prefill and stall-free scheduling, the mechanism in Figure 08.
- MLPerf Inference · MLCommons — how the community defines latency-bounded throughput, and why SLOs come before numbers.