system_ml / 23 · profiling & MFU lesson 23 / 26

Profiling, MFU & finding the bottleneck

The whole series taught you to predict where the bottleneck lives. This lesson is how you measure it on a real run and score the prediction. MFU is the single number; the trace is how you find which named cause — each from an earlier lesson — is eating the gap.

The one number, and what it really asks

Every previous lesson ended with a hammer: FSDP for the memory wall, overlap for the comm wall, fusion for memory-bound kernels, CUDA graphs for launch overhead. You now have a toolbox and a habit of guessing which tool a given run needs. This lesson closes the loop. There is exactly one scalar that scores the whole run, and it has a name: Model FLOPs Utilization — MFU.

The definition starts from the same accounting we did in lesson 01. A transformer step does, for the useful forward+backward math, about 6 · N FLOPs per token, where N is the parameter count (2 for forward, 4 for backward — the 6ND rule). So the useful work per training step is:

model_FLOPs_per_step  ≈  6 · N · (B · T)

where B · T is the number of tokens processed in one optimizer step (global batch × sequence length, summed across all data-parallel replicas). Divide that useful work by what the hardware could have done in the wall-clock time the step actually took, across every GPU you paid for:

MFU  =  (6 · N · tokens_per_step) / (step_time · peak_FLOPS · num_gpus)

That denominator is the honest one. peak_FLOPS is the chip's datasheet number — for an H100 SXM, 989 TFLOP/s dense bf16 (the marketing "1979 TFLOP/s" is the 2:4-sparse figure; never use it for MFU). Multiply by num_gpus and by the measured step_time and you have the total FLOP-budget the cluster had available during that step. MFU is the fraction of that budget that went to math your model actually needed.

Worked example · 70B, the number to memorize
Llama-70B, global batch 1024 sequences of 4096 tokens, on 512 H100s, measured step time 8.0 s. Tokens/step = 1024 × 4096 = 4.19M. Useful FLOPs = 6 × 70e9 × 4.19M = 1.76 × 10¹⁸. Available budget = 8.0 s × 989e12 × 512 = 4.05 × 10¹⁸. MFU = 1.76 / 4.05 = 43.5%. That is a perfectly healthy large-scale training run. If someone tells you their 70B run is at 12% MFU, something on the list below is broken, and the trace will say which.

Three rules of thumb worth burning in: 35–45% MFU is typical for well-tuned large-model training, >50% is very good (frontier labs publish numbers in the 40–55% range), and 100% is physically impossible. Why impossible? Because the 6ND count ignores everything that isn't a big matmul — attention softmax, LayerNorm, the optimizer step, embeddings, the LM head — and those run at well below peak because they are memory-bound (next section). Even a perfect, zero-overhead, zero-communication run spends real time in kernels the tensor cores can't keep busy. MFU is asymptotic to something like 60–70%, not 100%, on a transformer.

It helps to see where the FLOPs actually go. In the 6ND accounting, the dominant terms are the four big GEMMs per layer — the QKV projection, the attention output projection, and the two MLP matmuls — and these are exactly the kernels that sit comfortably above the roofline ridge and run near peak. But a real step also pays for things the 6ND count politely ignores: the softmax inside attention, two LayerNorms per layer, the residual adds, the SwiGLU/GELU nonlinearity, dropout, the embedding lookup, the final LM-head projection over a 100k+ vocabulary, and the optimizer update over every parameter. Individually each is cheap in FLOPs; collectively they are a meaningful slice of wall-clock precisely because they run at single-digit-percent of peak. That is the structural reason MFU tops out well below 1.0: the denominator counts time the numerator's 6ND formula refuses to credit.

Make the ceiling concrete by budgeting the wall-clock of one clean step — no exposed comm beyond what's unavoidable, no data stall, just the irreducible mix. Account for where the seconds go, not the FLOPs:

Summing the contributions: 0.69 + 0.015 + 0 ≈ 0.70. That is the ~60–70% ceiling, derived: a transformer step is ~three-quarters near-peak GEMM and ~one-quarter time the tensor cores can't be kept busy, so even the perfect run asymptotes to ~0.7, not 1.0. The denominator counts all the wall-clock; the numerator credits only the GEMM-shaped fraction of it.

One more subtlety on the numerator. The strict 6ND count is the matmul-only approximation; some teams add the attention score-and-context FLOPs (the QKᵀ and ·V products, which scale with rather than T) into "useful" work, which nudges the reported MFU up at long sequence lengths. Both conventions are defensible — what is not defensible is comparing your MFU against someone else's without knowing which convention each of you used. When you read "we hit 52% MFU" in a paper, the first question is always "6ND, or 6ND plus attention?" The widget below uses the clean 6ND convention so the numbers are comparable across model sizes.

MFU vs HFU — the recompute distinction

There is a second utilization number, and confusing the two is the most common profiling mistake. HFU — Hardware FLOPs Utilization — counts every FLOP the hardware executed, including the ones you did twice on purpose.

Recall lesson 10: activation checkpointing throws away activations in the forward pass and recomputes them during backward to save memory. That recompute is real forward work the GPU does a second time — roughly +33% more FLOPs (one extra forward on top of the fwd+bwd, so 6ND becomes ~8ND of executed work). Those extra FLOPs are hardware work but not useful model work — the gradient would be identical without them; you only pay them to fit in memory.

HFU  =  (6 · N · tokens + recompute_FLOPs) / (step_time · peak_FLOPS · num_gpus)  ≥  MFU

So with full activation recompute, HFU ≈ 1.33 × MFU. The practical reading:

Why this trips people
A team turns on full activation checkpointing to fit a longer sequence, MFU drops from 45% to 38%, and someone files a "regression." It isn't one. HFU went up (more tensor-core work), MFU went down (more of that work is recompute, not new model progress), and the run fits where it didn't before. The right lever — selective recompute (lesson 10) — recomputes only the cheap-to-store, expensive-to-recompute tensors and recovers most of the MFU.

The roofline — the per-kernel lens

MFU scores the whole run. To understand why a kernel is slow you drop to the roofline from lesson 01. Every kernel is bound by one of two ceilings: how fast you stream bytes from HBM, or how fast the tensor cores chew FLOPs already in SRAM. The crossover is the ridge point — the arithmetic intensity (FLOPs per byte) at which the two ceilings meet:

ridge_point  =  peak_FLOPS / HBM_BW  =  989 × 10¹² / 3.35 × 10¹²  ≈  295 FLOP/byte

That number — ~295 FLOP/byte for an H100 (989 TFLOP/s over 3.35 TB/s of HBM3) — is the line that decides everything. A kernel whose arithmetic intensity sits above 295 is compute-bound: it can in principle reach peak FLOP/s. A kernel below 295 is memory-bound: it will finish reading its bytes before the tensor cores are busy, and no amount of faster math helps.

Work one kernel explicitly to make the ridge concrete. Take an MLP matmul [M, K] × [K, n] with M = B·T rows, K = d contraction, n = 4d output. The FLOPs are 2·M·K·n. The bytes moved (bf16, 2 B/elem) are roughly the two inputs plus the output: 2·(M·K + K·n + M·n). The arithmetic intensity is the ratio, and for a square-ish problem it grows with M — i.e. with how many tokens you push through at once. Plug in real numbers with K = d = 8192, n = 4d = 32768. The intensity is 2MKn / [2(MK + Kn + Mn)] = MKn / (MK + Kn + Mn).

M = 1:   (1·8192·32768) / (8192 + 268·10⁶ + 32768)  ≈  2.68·10⁸ / 2.68·10⁸  ≈  1.0 FLOP/byte

At M = 1 (single-token decode) the matmul is a matrix-vector product: the K·n weight term (~268M elements) swamps the denominator, so you read the entire weight (megabytes) to do 2·K·n FLOPs — intensity ≈ 1, ~300× below the ridge, deep on the orange slope. Now push the batch to a training tile:

M = 4096:   (4096·8192·32768) / (4096·8192 + 8192·32768 + 4096·32768)  ≈  1.10·10¹² / 4.36·10⁸  ≈  2520 FLOP/byte

The same weight is read once but reused across 4096 rows, the M·K and M·n activation terms grow while the dominant K·n weight term stays fixed, and intensity vaults to ~2500 — an order of magnitude above the 295 ridge, firmly compute-bound. Between M=1 and M=4096 the kernel crosses the ridge: that is the whole story in two substitutions. This single ratio — reuse of the weight across the batch dimension — is why training prefers big tiles and why decode is doomed to be memory-bound (the entire content of lesson 14 and the motivation for continuous batching).

The number to keep in your head
Ridge ≈ 295 FLOP/byte on an H100. If a kernel's arithmetic intensity is below that, faster tensor cores do nothing for it — you are gated on HBM, and your only levers are (a) move fewer bytes (fusion, lesson 19; quantization, lesson 17) or (b) raise reuse (bigger batch/tile). If it's above 295, you're gated on FLOPs, and the lever is better tiling so the SMs stay full. Profilers like Nsight Compute will tell you a kernel's achieved intensity directly; this number tells you which side of the ridge it lives on.
arithmetic intensity (FLOPs / byte) achievable FLOPs/s ridge ≈ 295 FLOP/byte memory-bound FLOP/s = HBM_BW × intensity compute-bound FLOP/s = peak decode matmul (batch 1) ~2 FLOP/byte LayerNorm / softmax / GELU training GEMM / prefill ~300+ FLOP/byte

Two consequences you will see in every trace:

Why MFU < 100% — the subtraction list

MFU is a budget, and the gap from 100% (really, from the ~60–70% ceiling) is a sum of named subtractions. Each one is a lesson you already read; the trace tells you which subtraction is large right now:

SubtractionWhat it isFix & lesson
Pipeline bubbleStages idle at fill/drain of each pipeline scheduleInterleaved/zero-bubble schedules — lesson 07
Exposed communicationTP AllReduce or DP/FSDP collective on the critical path, not hidden behind computeOverlap, bigger buckets, fewer cross-node collectives — lesson 13
Data starvationGPU waits because the input pipeline didn't deliver the next batchMore workers, prefetch — lesson 25
Launch / Python overheadCPU can't dispatch kernels fast enough; GPU idles between tiny opstorch.compile (21), CUDA graphs (22); root cause is the dispatcher (16)
Memory-bound small kernelsElementwise / norm kernels running far below the ridgeFusion — lesson 19
Suboptimal matmul tilingGEMM shapes that don't tile cleanly onto the SMs (bad M/N/K, wave quantization)Better shapes, autotuned kernels — lessons 19/20
Recompute (HFU>MFU)Activation recomputation — real FLOPs, no model progressSelective recompute — lesson 10

The mental model: MFU is the scoreboard that tells you how much total headroom remains. The trace tells you which row above is currently the big one. You don't optimize the list top-to-bottom; you profile, find the dominant gap, fix that, re-measure, and repeat. Optimizing a subtraction that's already small is wasted work.

The profiler — what a trace actually is

Two tools cover almost everything. torch.profiler emits a Chrome trace (open it in chrome://tracing or Perfetto) and gives you per-operator CPU and CUDA timings with autograd attribution. Nsight Systems (nsys) gives a lower-level system timeline — NVTX ranges, NCCL kernels, CUDA API calls, even the PCIe/NVLink traffic — and is the tool of choice when you suspect communication or driver-level stalls.

The minimal torch.profiler incantation, which you should keep in muscle memory:

import torch
from torch.profiler import profile, ProfilerActivity, schedule

sched = schedule(wait=1, warmup=1, active=3, repeat=1)  # skip warmup steps

with profile(
        activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
        schedule=sched,
        on_trace_ready=torch.profiler.tensorboard_trace_handler("./tb"),
        record_shapes=True, with_stack=True) as prof:
    for step, batch in enumerate(loader):
        train_step(batch)
        prof.step()                      # advances the schedule

# prints the kernels eating the most GPU time:
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))

Three details in that snippet are load-bearing and routinely gotten wrong. First, the schedule with a warmup phase: the first few steps of any run pay one-time costs — CUDA context creation, the caching allocator growing its pools (lesson 18), cuDNN/cuBLAS algorithm autotuning, and (if you use it) torch.compile's first-call compilation (lesson 21). Profiling those steps gives you a trace dominated by costs you'll never pay again; always skip them. Second, record_shapes and with_stack are not free — stack capture in particular adds real overhead and can distort the very timings you're measuring, so turn them on only when you specifically need to attribute a kernel back to a Python line. Third, CUDA is asynchronous: the CPU launches a kernel and moves on, so a naive CPU-side timer measures launch time, not execution time. The profiler's CUDA activity lane records the real on-device duration; if you ever hand-roll timing, you must torch.cuda.synchronize() or use CUDA events, or your numbers are fiction.

The key_averages().table() print is the fast first read before you even open the visual trace. Sorted by cuda_time_total, the top rows tell you where the GPU spends its seconds: if the top entries are ampere_sgemm/cutlass GEMM kernels, your time is going to matmuls (healthy — you're compute-bound on the right thing). If the top entries are elementwise, vectorized_, reduce, or a long tail of tiny named ops, you're memory-bound and fusion is the lever. If a nccl kernel (e.g. ncclAllReduce) is near the top and the run isn't overlapping it, communication is exposed. The table answers "what" in five seconds; the visual trace answers "why is the GPU idle," which the table cannot show because idle time has no kernel to attribute.

Whatever the tool, a trace is the same picture: time on the x-axis, and a set of horizontal lanes stacked vertically. The four lanes that matter:

The MFU calculator

This is the scoreboard. Set your run's parameters and read the MFU live. The lower stacked bar lets you attribute the gap from peak to named causes (bubble, exposed comm, data stall) — drag them until the bar's leftover "unexplained" slice shrinks to zero, which is the same accounting you do when you read a trace. The KPIs report achieved TFLOP/s per GPU, MFU %, and how much headroom remains to the 50% "very good" mark.

MFU calculator · score the run, attribute the gap
MFU = 6·N·tokens_per_step / (step_time · peak · num_gpus). tokens_per_step = micro-batch × seq × data-parallel. The attribution sliders carve the measured gap (peak − MFU) into named causes; "unexplained" is whatever you haven't accounted for yet.
tokens / step
achieved TFLOP/s/GPU
MFU
headroom to 50%

Play with it: bump the micro-batch from 1 and watch MFU climb (you're walking up the roofline slope). Push the step time up by 2× and watch it halve — that's what an unhidden AllReduce or a starving dataloader does to the same useful work. The headroom KPI is the prize: it's the number a quarter of optimization work is chasing.

Reading a trace — the diagnostic decision tree

This is the heart of the lesson. You have the scoreboard (MFU is low); now you open the trace and ask one question repeatedly: while the GPU compute stream is idle, what is busy? The answer is the signature, and each signature has exactly one first response:

Trace signatureDiagnosisGo fix it in…
GPU compute idle while the CPU lane is busy launching kernels (lots of tiny kernels, fat gaps between them)Launch / CPU-bound — framework overhead exceeds kernel timeCUDA graphs (22), torch.compile (21); cause is the dispatcher (16)
GPU compute idle while a NCCL kernel runs on the comm streamExposed communication — the collective is on the critical pathOverlap, bigger buckets, fewer cross-node collectives (13)
GPU idle and the CPU is blocked (waiting on a next(loader) / H2D copy), comm idle tooData starvation — the input pipeline is too slowMore workers, prefetch, faster decode (25)
GPU compute lane fully packed, no gaps — but MFU still lowMemory-bound or tiny matmuls — kernels run below the ridgeFusion (19), larger batch, better tiling
Regular, periodic gaps with the same shape each iteration's edgesPipeline bubble — fill/drain of the scheduleInterleaved / zero-bubble schedule (07)

Walk the five rows, because the distinctions are exactly what's hard the first time you stare at a real trace:

The trace reader

This is the payoff. Pick a scenario; the canvas draws a synthetic three-lane trace (CPU/launch, GPU-compute, GPU-comm) with that scenario's characteristic block-and-gap signature. It computes an illustrative utilization and prints the diagnosis plus the lesson number to go fix it. Compare "healthy" against the four failure modes — the goal is to make the signatures recognizable on sight, so that when you open a real Perfetto trace your eye already knows what it's looking for.

Trace reader · pick a scenario, read the signature
Three lanes: CPU launch (top), GPU compute (middle), GPU comm (bottom). Blue = compute kernel, orange = comm/NCCL, gray = CPU dispatch, red hatching = GPU idle. The diagnosis below names the cause and the lesson that fixes it.
scenario
GPU compute busy
illustrative MFU
go fix in
diagnosis

Notice how different the five pictures are. Healthy: compute packed, comm tucked underneath it, CPU launching ahead. Launch-bound: tiny compute blocks marooned in a sea of red idle while the CPU lane churns. Comm-bound: a fat orange NCCL block on the comm lane with the compute lane idle right above it. Data-starved: everything idle, including comm, while the CPU blocks on the loader. Bubble: the regular, periodic gaps. Once these shapes are in your head, diagnosing a real trace is pattern-matching.

Five ways to compute MFU wrong

Because MFU is the number you report, budget against, and compare to other teams, it is worth knowing how it gets miscalculated — every one of these produces a number that looks plausible and is wrong:

The workflow, in one breath

  1. Measure MFU. One number. If it's 40%+, you're probably fine — go do something else. If it's low, continue.
  2. Open a trace (torch.profiler for op-level, nsys for system-level). Find the largest gap on the GPU compute stream.
  3. Read the signature — what's busy while compute is idle — and match it to the decision tree. That names the cause and the lesson.
  4. Apply that lesson's fix. Just the one for the dominant gap.
  5. Re-measure MFU. Did it move? If the dominant gap shifted to a new signature, repeat from step 2.

The discipline that matters: never optimize before profiling. Half of "obvious" optimizations target a subtraction that was already small, and you can't know which subtraction is large without the trace. The series gave you the hammers; this lesson is the X-ray that tells you which nail to hit.

Takeaway
MFU is the single scalar that scores a whole run: useful 6ND FLOPs over the FLOP-budget the cluster had. 35–45% is healthy, >50% is excellent, 100% is impossible. HFU additionally counts recompute, so HFU > MFU whenever you checkpoint activations. When MFU is low, the profiler trace tells you which named gap is eating it — and every signature points at a specific earlier lesson's fix: launch-bound → CUDA graphs/compile (21/22), exposed comm → overlap (13), data starvation → the pipeline (25), memory-bound kernels → fusion (19), periodic gaps → the pipeline bubble (07). The next lesson (24, numerical stability) is about a different kind of failure entirely — not a slow run, but a divergent one: the loss spikes and the whole training melts down, and no amount of MFU saves you.