ml-models / Training systems / Profiling core

Profiling

A training step is slow for one of a few reasons: the GPU is waiting for data, waiting for the Python code that launches its kernels, running out of memory headroom, or simply doing a lot of arithmetic. Profiling is how you tell which, and it starts with measuring time correctly at all, because GPU work is asynchronous. This page times a step with and without synchronization and warmup, ports the profiler's step schedule, splits a trace into self and total time, finds a data-loader stall, traces memory across steps and works out MFU for a real-sized run. Every number is computed and every PyTorch link is pinned to v2.15.0.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Measuring correctly
  5. The PyTorch profiler
  6. The step schedule
  7. Traces and tables
  8. Finding the bottleneck
  9. Memory
  10. Model FLOPs utilization
  11. Worked example
  12. Timing a step
  13. Self and total time
  14. A data-loader stall
  15. Where the peak is
  16. MFU for a 1.3B model
  17. Implementation
  18. Tradeoffs
  19. Check yourself
  20. Connections
  21. Read first
  22. Read next
  23. References

Problem

A training run reports 117.6 ms per step. Is that good? The GPU's spec sheet promises about 989 TFLOP/s; the model does a known number of FLOPs per token. Without a measurement you can trust, you cannot say whether the step is close to the hardware limit or wasting most of it, and without a breakdown you cannot say where the waste is.

Both are easy to get wrong. time.time() around a step measures how long Python took to queue the work, not how long the GPU took to do it. The first steps include one-off costs (CUDA context creation, cuDNN autotuning, allocator growth, compilation) that the average then carries forever. And a profiler that records every step slows the run and produces traces too large to open. Profiling is a set of habits that avoid these traps and point at the bottleneck.

Intuition

The CPU is a dispatcher handing work to the GPU through a queue. As long as the CPU stays ahead, the queue is never empty and the GPU is busy; a timer on the CPU stops long before the GPU finishes. If each piece of work is tiny, the GPU finishes it faster than the CPU can queue the next one, and the GPU idles between kernels no matter how fast it is. If the CPU stalls to fetch the next batch, the queue drains and the GPU idles for the length of the stall.

So the questions to ask a trace are visual: are there gaps in the GPU row, and what is the CPU doing during them? A gap under a data-loader call is an input stall; a stream of tiny kernels with gaps between them is host overhead; a solid GPU row means the step is compute-bound, and only then do kernel-level optimizations (lower precision, fused kernels) pay off.

A GPU-bound step and a host-bound step Top: six large kernels totalling 7,000 microseconds. The CPU finishes queueing them after 30 microseconds, a sliver at the left, while the GPU runs back to back until 7,005 microseconds: GPU utilization 99.9%. A timer without synchronization stops at the CPU's end. Bottom: twenty 2-microsecond kernels, each launched 5 microseconds apart. The GPU finishes each before the next arrives and is busy only 40 of 102 microseconds, 39.2%. GPU-bound: 6 large kernels CPU GPU no-sync timer stops: 30 µs synchronize(): 7,005 µs host-bound: 20 kernels of 2 µs CPU GPU GPU busy 40 of 102 µs (39%): the gaps are launch overhead

Kernel launches (boxes on the CPU row) return immediately; kernels (green) run in queue order. Top: the CPU is done after 30 µs of a 7,005 µs step. Bottom: every kernel finishes before the next launch, so the GPU waits on Python. Each panel has its own time scale.

Mechanics

Measuring correctly

CUDA calls return as soon as the work is queued. The PyTorch notes on asynchronous execution give the two correct ways to time GPU work:

  • Synchronize, then read a host clock. torch.cuda.synchronize() blocks until every queued kernel on the device has finished. Call it before starting the timer and before stopping it. The benchmark utility's own timer() does exactly that: torch.accelerator.synchronize(), then timeit.default_timer().
  • CUDA events. A torch.cuda.Event(enable_timing=True) is a marker placed in the stream by record(); elapsed_time() returns the GPU time between two of them in milliseconds. Events measure the device's own timeline and need a sync only before reading the result, so they disturb the pipeline less.

Setting CUDA_LAUNCH_BLOCKING=1 makes every launch synchronous. It is for debugging (errors are reported at the line that caused them) and never for timing, since it removes the overlap a real run has.

Then discard the first iterations. They pay for context creation, lazy module initialization, cudnn.benchmark autotuning, torch.compile compilation and the caching allocator requesting memory from the driver. Report the median of the rest, which ignores the occasional slow step from a garbage collection or a checkpoint write. torch.utils.benchmark.Timer does all of this: timeit runs max(n/100, 2) warmup calls first, and blocked_autorange picks a block size so each timed block is long enough that timer overhead is negligible, then reports the median per call.

The PyTorch profiler

torch.profiler.profile records every operator call on the CPU and, through NVIDIA's CUPTI via Kineto, every kernel and memory copy on the GPU, with the link between each launch and its kernel. Its main options:

  • activities: ProfilerActivity.CPU and ProfilerActivity.CUDA.
  • record_shapes (input shapes per operator), profile_memory (allocations and frees), with_stack (Python stack per event, expensive) and with_flops (estimated FLOPs for matmuls and convolutions, by formula from the shapes).
  • schedule and on_trace_ready, below.

torch.profiler.record_function("name") adds your own labelled span, so "data", "forward", "backward" and "optimizer" show up as blocks in the trace. The profiler adds its own ProfilerStep#N span around each step.

The step schedule

Recording every step would slow training and fill the disk. torch.profiler.schedule(wait, warmup, active, repeat, skip_first) returns a function from step number to a ProfilerAction: NONE (off), WARMUP (tracing on but results discarded, because starting the tracer has its own overhead), RECORD, and RECORD_AND_SAVE on the last active step, which ends the cycle and calls on_trace_ready. Each call to prof.step() advances the step counter and applies the transition between the old and new action from a table. With repeat=0 the cycle never ends; the function warns when warmup is 0. With no schedule, every step is recorded.

The schedule from this page's example, skip_first=2, wait=1, warmup=1, active=2, repeat=2, ported line for line:

Profiler actions for 14 steps Steps 0 to 13 with their actions: 0 NONE, 1 NONE, 2 NONE, 3 WARMUP, 4 RECORD, 5 RECORD_AND_SAVE, 6 NONE, 7 WARMUP, 8 RECORD, 9 RECORD_AND_SAVE, 10 NONE, 11 NONE, 12 NONE, 13 NONE. Two traces are saved, at steps 5 9 and the profiler is idle after that. 0 – 1 – 2 – 3 W 4 R 5 R+S 6 – 7 W 8 R 9 R+S 10 – 11 – 12 – 13 – – none   W warmup   R record   R+S record and save (on_trace_ready fires)

The first 2 steps are skipped outright, then each cycle of 4 steps waits 1, warms up 1 and records 2. After 2 cycles every step is NONE.

Traces and tables

on_trace_ready receives the profiler at each save. tensorboard_trace_handler(dir) writes one .pt.trace.json per save, named by host, process and time; export_chrome_trace(path) writes one directly. Either file opens in Perfetto (ui.perfetto.dev) or chrome://tracing as a timeline: CPU threads on top, one row per CUDA stream below, with flow arrows from each launch to its kernel. export_stacks writes stacks for a flame graph, and export_memory_timeline plots memory by category.

key_averages() aggregates events by name (optionally by input shape or stack) and its table(sort_by=..., row_limit=...) prints them. Each row has a total time, the span from entry to exit, and a self time, the total minus the time spent in child operators. aten::linear has a large total but almost no self time, because the work is in its child aten::addmm. Sort by self_cpu_time_total to find host overhead, and by self_cuda_time_total (deprecated in favour of self_device_time_total on the event objects) to find the expensive kernels.

Finding the bottleneck

  • Data-loader stalls. In the trace, a long enumerate(DataLoader)#..._next__ span on the CPU with an empty GPU row underneath. Without a profiler, time next(iterator) separately from the step. The fixes are on data loading: more num_workers, pin_memory, prefetching, or cheaper decoding.
  • Host overhead. Many short kernels with gaps between them, and CPU time per step close to the step time. Fixes: larger batches, fewer and bigger operations, fused kernels, torch.compile or CUDA graphs, which replay a recorded sequence of launches with one call.
  • Hidden synchronizations. .item(), .cpu(), print(loss), indexing with a GPU boolean mask, and nonzero all wait for the GPU. In a trace they show as a cudaStreamSynchronize or cudaMemcpyAsync on the CPU that lasts until the queue drains. One per step for logging is fine; one per layer serializes the step.
  • Memory spikes. An out-of-memory error at step 1, or only on some batches. See below.
  • Compute-bound. A solid GPU row. Now kernel time matters: check the top self-time kernels, whether matmuls use tensor cores (bf16, dimensions that are multiples of 8), and the MFU.

Memory

The caching allocator (memory management notes) keeps freed blocks for reuse, so nvidia-smi shows the reserved pool, not what tensors use. memory_allocated() is the bytes held by live tensors now; max_memory_allocated() reads "allocated_bytes.all.peak" from memory_stats(); memory_reserved() and max_memory_reserved() are the same for the pool. reset_peak_memory_stats() restarts the peaks, so you can measure one step at a time, and memory_summary() prints the whole table.

For the question "what was allocated at the peak, and by which line", turn on torch.cuda.memory._record_memory_history(), which records a stack trace for every allocation and free (up to max_entries, keeping the latest), run a few steps, and write the history with _dump_snapshot("snapshot.pickle"). Dragging the file into pytorch.org/memory_viz shows every tensor as a band over time; the tallest stack of bands is the peak. The leading underscore means the API may change between releases.

Model FLOPs utilization

Throughput in tokens per second is hard to compare across models and machines. MFU (introduced with PaLM, Chowdhery et al., 2022) divides the FLOPs the model needs per second by the hardware's peak. For a decoder-only Transformer with N parameters, nlayers layers, width d and context T, training costs about

FLOPs per token ≈ 6N + 12 · nlayers · d · T,   MFU = tokens/s × FLOPs per token ÷ (GPUs × peak FLOP/s)

The 6N is 2N for the forward pass's multiply-adds plus 4N for the backward pass (gradients for activations and weights), as on pretraining; the second term is the attention scores and weighted sums, which grow with context. Hardware FLOPs utilization (HFU) also counts work done twice, such as the forward pass repeated by activation checkpointing, so it is never lower than MFU. MFU is the honest number: a run that recomputes everything can raise HFU without training any faster. Well-tuned large dense runs report roughly 40 to 60% MFU.

ComponentShape/ParamsMeaningSource
torch.cuda.synchronizedeviceBlock the host until queued work finishescuda/__init__.py
torch.cuda.Eventenable_timing=TrueStream marker; elapsed_time in msstreams.py
benchmark.Timerstmt, setup, globalsSynced, warmed-up timing with mediantimer.py
profiler.profileactivities, schedule, on_trace_ready, record_shapes, profile_memory, with_stack, with_flopsCPU and GPU event recorderprofiler.py
profiler.schedulewait, warmup, active, repeat, skip_firstStep → ProfilerActionprofiler.py
tensorboard_trace_handlerdir_nameWrites one trace file per saved cycleprofiler.py
key_averagesgroup_by_input_shape, group_by_stack_nPer-name count, self and total timeprofiler.py
max_memory_allocatedbytesPeak tensor memory since last resetmemory.py
_record_memory_historymax_entriesStack per allocation, for snapshotsmemory.py
Why does the profiler need a WARMUP action at all, if its results are thrown away?

Starting the tracer (CUPTI and Kineto) has a one-off cost, and the first traced step pays it. Tracing a step and discarding it means the recorded steps show the training loop's steady state, not the profiler's own start-up. That is why schedule warns when warmup is 0.

Worked example

Timing a step

A step of 6 kernels (1200, 800, 2400, 600, 1600, 400 µs) launched 5 µs apart, top of the first figure. The first two iterations add 850 ms and 35 ms of one-off work.

iteration01234567
synced (ms)857.0042.017.007.007.007.007.007.00
no sync (ms)850.0335.030.030.030.030.030.030.03

The mean of all 8 synced iterations is 117.63 ms, 17 times the steady 7.005 ms, because of the first iteration. Without synchronization the steady iterations read 0.03 ms: the time to launch, about 234 times too fast. Drop the first two, synchronize, take the median: 7.005 ms. That is also the GPU time, at 99.9% utilization; the host-bound step below it uses only 39.2%.

Self and total time

A profiled step with these CPU spans (µs from the step start), nested by time: the step contains the data-loader call, two aten::linear calls (each containing aten::t and aten::addmm), a ReLU, the loss, two backward nodes and the optimizer. Self time is total time minus the totals of direct children; a stack walk over the spans sorted by start computes it.

namecallsself (µs)total (µs)self %
Optimizer.step#AdamW.step13,7003,70037.8%
enumerate(DataLoader)#_SingleProcessDataLoaderIter.__next__13,0003,00030.6%
autograd::engine::evaluate_function: AddmmBackward021,5501,55015.8%
aten::addmm25705705.8%
ProfilerStep#415109,8005.2%
aten::cross_entropy_loss12602602.7%
aten::t280800.8%
aten::relu170700.7%
aten::linear2607100.6%

Sorted by self time, the optimizer and the data loader top the list, together 68% of the step. Sorted by total time, aten::linear would sit above aten::addmm with 710 µs, yet only 60 µs is its own. The self column sums to the step's 9,800 µs, since every microsecond belongs to exactly one innermost span. A 3.0 ms DataLoader span is the stall to fix first; a for loop over parameters in the optimizer suggests foreach=True or fused=True.

A data-loader stall

Each batch takes 120 ms to load on one worker and 40 ms to train on. With w workers loading in parallel, a batch is ready every 120/w ms. The time the step waits in next(iterator), averaged after the pipeline fills:

workersfirst batch wait (ms)steady wait (ms)wait fractionms per step
212020.033.3%63.3
31200.00.0%45.0
41200.00.0%45.0

With 2 workers the GPU waits 20 ms of every 60, a third of the step, and no kernel optimization can fix that. With 3 workers, batches arrive every 40 ms, as fast as they are consumed, and the wait disappears; a fourth worker buys nothing. The 120 ms first wait is the pipeline filling and is one more reason to skip the first steps when timing.

Where the peak is

A 4-layer model with 1,000 MiB of parameters, trained with Adam, on batches of 4096, 4096, 4096, 8192 tokens. Each layer saves 150 MiB of activations per 1,024 tokens. The backward pass frees each layer's activations after creating its gradient; Adam creates exp_avg and exp_avg_sq lazily at the first optimizer.step(); zero_grad(set_to_none=True) frees the gradients.

Allocated memory over 4 training steps Allocated memory after each of 54 allocation events. Parameters take 1,000 MiB. Step peaks: step 0 with 4,096 tokens, 4,000 MiB; step 1 with 4,096 tokens, 5,650 MiB; step 2 with 4,096 tokens, 5,650 MiB; step 3 with 8,192 tokens, 8,050 MiB. Every step ends at 3,000 MiB, parameters plus Adam state. 0k 2k 4k 6k 8k step 0 step 1 step 2 step 3 peak 8,050 MiB at step 3

Each step climbs through the forward pass, steps down through the backward pass as activations are freed, then drops at zero_grad. Step 0 is lower than step 1 because the Adam state does not exist until its end; the last step is tallest because its batch is twice as long.

steptokenspeak allocated (MiB)allocated at end (MiB)
04,0964,0003,000
14,0965,6503,000
24,0965,6503,000
38,1928,0503,000

Measuring max_memory_allocated() after step 0 gives 4,000 MiB and badly underestimates the 5,650 MiB of every later step: the 2,000 MiB of Adam state is resident through the next forward pass. And a run sized on typical batches fails on the first long one, at 8,050 MiB. Calling reset_peak_memory_stats() before each step and logging the peak catches both; a memory snapshot shows that the long batch's activations, not the parameters, are the tall band. Fixes: cap tokens per batch rather than sequences, activation checkpointing, or gradient accumulation with smaller micro-batches.

MFU for a 1.3B model

A 1.3B-parameter decoder with 24 layers, d = 2048 and context 2,048, trained on 8 GPUs with a 989 TFLOP/s bf16 dense peak. Each step processes 1,048,576 tokens in 2.6 s (measured as above).

quantityvalue
6N (FLOPs per token)7.80 × 10⁹
12 · nlayers · d · T1.21 × 10⁹
FLOPs per token9.01 × 10⁹
tokens per second403,298
achieved FLOP/s per GPU454.1 × 10¹²
MFU45.9%
HFU with full recomputation61.2%

Attention adds 15% on top of 6N at this context, which is why leaving it out understates MFU for long contexts. At 46% the run is in the healthy range; at 15% the next step would be the trace, looking for the stalls above. With full activation recomputation the hardware does a second forward pass (8N instead of 6N, and the attention term likewise grows by a third), so HFU reads 61%, but the run is no faster.

Implementation

Timing a step with events, profiling a few steps on a schedule, measuring the peak memory per step, and computing MFU. train_step and loader stand for your own loop.

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


def time_steps(train_step, batches, warmup=2):
    start = torch.cuda.Event(enable_timing=True)
    end = torch.cuda.Event(enable_timing=True)
    times = []
    for i, batch in enumerate(batches):
        start.record()
        train_step(batch)
        end.record()
        end.synchronize()
        if i >= warmup:
            times.append(start.elapsed_time(end))
    return sorted(times)[len(times) // 2]


def profile_steps(train_step, loader, steps=14):
    with profile(
        activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
        schedule=schedule(skip_first=2, wait=1, warmup=1, active=2, repeat=2),
        on_trace_ready=tensorboard_trace_handler("./traces"),
        record_shapes=True,
        profile_memory=True,
    ) as prof:
        it = iter(loader)
        for _ in range(steps):
            with record_function("data"):
                batch = next(it)
            train_step(batch)
            prof.step()
    print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=15))


def peak_memory_per_step(train_step, batches, snapshot=None):
    if snapshot:
        torch.cuda.memory._record_memory_history(max_entries=100_000)
    peaks = []
    for batch in batches:
        torch.cuda.reset_peak_memory_stats()
        train_step(batch)
        peaks.append(torch.cuda.max_memory_allocated() / 2**20)
    if snapshot:
        torch.cuda.memory._dump_snapshot(snapshot)
        torch.cuda.memory._record_memory_history(enabled=None)
    return peaks


def mfu(params, layers, d_model, context, tokens_per_step, step_s, gpus, peak_flops):
    per_token = 6 * params + 12 * layers * d_model * context
    return tokens_per_step / step_s * per_token / (gpus * peak_flops)

end.synchronize() waits only for that event, not the whole device. For a single function, torch.utils.benchmark.Timer(stmt="f(x)", globals={"f": f, "x": x}).blocked_autorange() does the warmup, synchronization and median for you. Note that peak_memory_per_step reads the peak after each step, which is itself a sync; that is fine for a diagnostic run and is why it is not left on in production.

Tradeoffs

ToolGood forCost
Sync + host clockWhole steps, simplest codeEach sync drains the queue and removes overlap with the next step
CUDA eventsGPU time of a region, low disturbanceOnly covers GPU work on one stream; still needs a sync to read
benchmark.TimerMicrobenchmarks of one op or moduleIsolated from the real loop: no data loading, different cache state
torch.profilerWhere time goes: ops, kernels, gaps, launchesSlows traced steps (more with with_stack and record_shapes); traces are large
Memory stats and snapshotsPeaks per step; which allocation made the peakHistory recording adds a stack trace per allocation; snapshot API is private
MFUOne number to compare runs and hardwareNeeds a FLOP model of the architecture; says nothing about where the loss is
Nsight Systems / ComputeKernel internals: occupancy, memory bandwidth, stallsOutside PyTorch; per-kernel replay is very slow
A step timed with time.time() and no synchronization reads 0.4 ms, but the run makes 3 steps a second. What is going on?

The timer measures only how long Python took to queue the kernels; the GPU runs them afterwards. The queue fills until some call forces a sync (a .item() for logging, a full launch queue, the next batch's copy), and that call absorbs the waiting. The real step time is about 330 ms. Synchronize before stopping the timer, or use CUDA events.

The profiler's GPU rows show many 5 µs kernels with 10 µs gaps between them, and CPU time per step equals the step time. Will a faster GPU help?

No. The step is host-bound: the GPU finishes each kernel and then waits for the next launch. A faster GPU only makes the gaps longer. Reduce the number of launches (bigger batches, fused ops, torch.compile, CUDA graphs) or the Python work between them.

Why is MFU, not HFU, the number to report when comparing two training setups?

MFU counts only the FLOPs the model needs, so it rises only when the same training finishes faster. HFU also counts recomputation, so a setup that recomputes activations can show a higher HFU while taking longer per token.

Check yourself

  • Give two correct ways to time a GPU operation in PyTorch, and say why CUDA_LAUNCH_BLOCKING=1 is not one of them.
  • For schedule(wait=1, warmup=1, active=2, repeat=1), list the action at steps 0 to 5.
  • An operator has a large total time and a small self time. Where is the time?
  • What would a data-loader stall look like in a trace, and what would you try first?
  • Why is peak memory after step 0 lower than after step 1 with Adam?
  • Compute the MFU of a 7B model on 64 GPUs at 989 TFLOP/s peak processing 2M tokens per 9 s step, ignoring attention.

Connections

Read first

Read next

No page builds on this one yet.

Why GPU work is asynchronous and what makes kernels fast is on GPU execution. Profiling usually leads to data loading (stalls), mixed precision (kernel time), checkpointing and gradient accumulation (memory), or distributed training, where communication shows up as NCCL kernels in the trace. The FLOP counts come from pretraining, and serving-side measurements of time to first token and inter-token latency are on latency and throughput. The training loop is what gets profiled, and fixing seeds and nondeterminism before comparing runs is on reproducibility.

References