Theoretical FLOPS are easy; what your kernel actually achieves is what matters. Walk through every NVIDIA profiling tool — Nsight Systems for the timeline, Nsight Compute for the kernel, NVTX for annotations, CUPTI for programmatic capture, plus dcgmi/nvbandwidth/nvitop — and the workflow that turns "it's slow" into "fix line 42".
A practical walkthrough of NVIDIA's profiling stack. Each tool answers a different question; the skill is knowing which one to reach for first.
Profiling is not one tool, it's a stack. A different question is asked at every level — and a different tool answers it. Picking the wrong tool wastes hours: nobody finds a bank conflict in nvidia-smi, and nobody finds a flaky power supply in Nsight Compute.
dcgmi, dcgm-exporter feeding Prometheus. XID errors, ECC counts, throttling reasons, power, link state. Always-on; alerts fire long before users complain.ncu) measures occupancy, achieved bandwidth, achieved FLOPS, warp stall reasons, register and shared-memory usage, source-correlated SASS.cuobjdump and nvdisasm on the cubin, plus --ptxas-options=-v at compile. The bedrock when nothing higher up explains the behaviour."Start broad, narrow only when needed." A timeline (Nsight Systems) catches 80% of perf bugs because most of them are systemic: dataloader stalls, missing NVTX context, wrong stream, a sync that didn't need to be there. Don't open Nsight Compute until you know which kernel matters — otherwise you're profiling the wrong thing very precisely.
NVTX (NVIDIA Tools Extension) is the bridge between your code's mental model and what the profiler sees. It is just two things: range markers (push/pop) and category labels. The cost at runtime is essentially zero when no profiler is attached — ship NVTX in production code without guilt.
Without NVTX, Nsight Systems shows a sea of unnamed CUDA calls: cudaMemcpyAsync, cudaLaunchKernel, cudaStreamSynchronize, repeated thousands of times. You can't tell forward from backward, attention from MLP, or one transformer block from the next.
With NVTX ranges, the timeline shows your model's structure: bands labelled forward, attention.q_proj, backward, optimizer.step. Now patterns and gaps mean something.
You don't always need to add ranges yourself:
torch.cuda.nvtx.range_push wraps autograd, optimisers, and AMP regions emit automatically when torch.profiler is active.tf.profiler.experimental hooks emit NVTX.Custom ranges are still worth it for your training step, dataloader phases, and any host-side preprocessing.
import torch
import torch.cuda.nvtx as nvtx
def train_step(model, batch, optimizer, criterion):
nvtx.range_push("step")
nvtx.range_push("forward")
logits = model(batch["input_ids"])
loss = criterion(logits, batch["labels"])
nvtx.range_pop() # /forward
nvtx.range_push("backward")
loss.backward()
nvtx.range_pop() # /backward
nvtx.range_push("optim")
optimizer.step()
optimizer.zero_grad(set_to_none=True)
nvtx.range_pop() # /optim
nvtx.range_pop() # /step
return loss.item()
#include <nvtx3/nvToolsExt.h>
void run_block(int layer_id) {
char name[64];
snprintf(name, sizeof(name), "block.%d", layer_id);
nvtxRangePushA(name);
nvtxRangePushA("attn");
launch_attention_kernel(...);
nvtxRangePop(); // /attn
nvtxRangePushA("mlp");
launch_mlp_kernel(...);
nvtxRangePop(); // /mlp
nvtxRangePop(); // /block.N
}
Annotate once, at the boundaries that matter to you (forward / backward / dataloader / optimizer / collective). Resist annotating every kernel — the profiler already shows those. Treat NVTX like a coarse log: it should narrate the program, not transcribe it.
Nsight Systems (nsys) is the system-wide profiler. One capture covers every CPU thread, every CUDA stream, every kernel launch, every NCCL collective, OS scheduling, and your NVTX ranges — correlated on a single time axis. It is the first tool to reach for when something is "slow".
# Single rank, full coverage:
nsys profile \
-t cuda,nvtx,osrt,cudnn,cublas,nccl \
--capture-range=cudaProfilerApi \
--capture-range-end=stop \
-o train_run \
python train.py
# Then open in Nsight Systems UI:
nsys-ui train_run.nsys-rep
# Or generate a CLI summary without the UI:
nsys stats train_run.nsys-rep | head -60
cuda — every CUDA Runtime & Driver API call: launches, copies, synchronisations, stream and event ops. Mandatory.nvtx — user and library NVTX ranges. Pair with the cuda rows on the timeline to read intent.osrt — OS runtime: file IO, mutexes, sleep, futex. Reveals dataloader stalls and Python GIL contention.cudnn / cublas — library-level NVTX from the maths kernels themselves.nccl — collective ops with size and rank metadata. Essential for distributed.Empty rows on the GPU streams = the host is the bottleneck. Common causes: synchronous Python preprocessing, blocking .item() / .cpu() calls, dataloader worker starvation, torch.save mid-training.
Wide kernel bars on the GPU stream are good (work happening) but their content matters. Hover for kernel name and duration. If one kernel dominates, that's your Nsight Compute target.
Tall thin bars on the API row marked cudaStreamSynchronize / cudaDeviceSynchronize indicate stream serialisation. Often introduced by debug prints, metric scraping, or ill-placed .item().
If NCCL collectives appear on the same stream as compute, they serialise. The fix is a dedicated comm stream so all-reduce overlaps with the next forward.
Nsight Systems is sampling-based and cheap — expect < 5% overhead, often closer to 1%. Safe to run on full-size workloads. Nsight Compute, in contrast, serialises every kernel for measurement and slows the program by 10×+; never confuse the two.
What does a "good" timeline look like, vs a "bad" one? The difference is rarely subtle once you know what to look for.
nvidia-smi tellsnvidia-smi's "GPU utilisation" is the percentage of time any kernel was running — even one warp, one SM. A 70 GB H100 doing 2% of useful work can show 100% utilisation. Only kernel-level analysis (Nsight Compute) tells you whether the silicon is actually loaded. Treat nvidia-smi -l as a "is it on?" check, not a perf metric.
Nsight Compute (ncu) is the kernel-level microscope. Where Nsight Systems gives you the timeline, ncu gives you the inside of one bar on that timeline: which exact PerfMon counters fired, how many warps stalled and on what, what fraction of theoretical bandwidth and FLOPS you actually achieved.
# Profile every kernel with the full metric set, write report to file:
ncu --set full -o kernel_full python repro.py
# Most kernels you don't care about. Filter by name:
ncu --set full -k "matmul|attention" -o focused python repro.py
# Only the first occurrence of each kernel:
ncu --set full --launch-skip 0 --launch-count 1 -o once python repro.py
# Source-correlated SASS view (huge for finding the line that stalls):
ncu --set full --import-source yes -o with_src python repro.py
# Then open in the UI:
ncu-ui kernel_full.ncu-rep
ncu reports per kernel| Section | What it tells you |
|---|---|
| GPU Speed of Light | Achieved % of peak compute and memory bandwidth. The headline number. |
| Compute Workload Analysis | Issued vs executed instructions; pipe utilisation (FMA, ALU, Tensor, FP64, etc.). |
| Memory Workload Analysis | L1/L2 hit rates, bytes from HBM, sector loads, shared-memory bank conflicts. |
| Scheduler Statistics | Warps per scheduler, eligible warps, issue slots used — the occupancy story. |
| Warp State (stall reasons) | Per-warp stall breakdown: memory dep, exec dep, IMC throttle, MIO throttle, sync barrier, etc. |
| Source / SASS | Per-line metric overlay on PTX/SASS. Find which line dominates stalls. |
| Roofline | Plot kernel on the FLOP/byte vs FLOPS chart. Are you memory- or compute-bound? |
ncu is slowTo gather the full metric set, Nsight Compute serialises kernels and replays each one many times to harvest different counter groups. A kernel that runs in 0.5 ms can take 1–2 seconds inside ncu. Never run it on full-scale training — build a small repro: a single forward pass with batch=1, one transformer block, ten tokens. Same kernel pattern, profileable in seconds.
The roofline plot is the single best mental model for "is this kernel slow because of memory or because of compute?". It pins the kernel on a chart with two ceilings: the memory bandwidth ceiling (left, sloped) and the compute ceiling (right, flat). Whichever is closer wins; you cannot go above either.
You're compute-bound territory by AI but achieving the slope-roof ceiling = your tile size is too small or you're hitting cache misses. Increase tile, use TMA, fix shared-memory layout.
Impossible: softmax is fundamentally memory-bound. If ncu says compute is the limit, the kernel is doing redundant work — bad reduction pattern, missing online-softmax fusion, recomputing exponentials.
Bandwidth left on the table. Common cause: launching at small grid size, kernel-launch overhead dominates. Fuse with neighbours, increase work per launch.
The good case — you're hitting peak tensor-core throughput. Move on; spend optimisation budget on the next slowest kernel.
The Warp State section of ncu reports the average reason a warp was not eligible to issue. Each reason maps to a class of fix. Knowing the table by heart is what separates "the kernel is slow" from "the kernel needs cp.async with a deeper pipeline".
| Stall reason | What it means | Typical fix |
|---|---|---|
| Memory dependency (LG/LD) | Warp waiting on a global / shared load. Long-latency HBM read in flight. | Increase tile size; use TMA (Hopper+) or cp.async (Ampere+) for async copies; deeper software pipeline. |
| Execution dependency | Pipeline backed up — result of one instruction needed by the next, no other warp ready. | Reduce registers per thread to raise occupancy; simplify ILP; let more warps fly in parallel. |
| IMC miss / throttle | Immediate-constant cache miss — warp waiting on a load from constant memory (__constant__, kernel parameters, immediate values). |
Reduce constant-memory footprint hit per warp; avoid large jumps that defeat the constant cache; promote frequently-read values to registers. |
| Math pipe throttle | Issue pipe saturated — back-to-back instructions targeting the same pipe (e.g. solid FFMA inner loops on the FMA pipe). | Spread instruction mix; interleave loads with compute; raise warps-per-SM so other warps can fill issue slots. |
| MIO throttle | Memory-IO unit overloaded — usually shared-memory bank conflicts. | Pad shared arrays (+1 trick); swizzle layout; use vectorised loads (float4). |
| Sync / barrier | Warps waiting at __syncthreads(). |
Reduce barriers; use __syncwarp when block-wide isn't needed; replace with async copies. |
| Tex throttle | Texture / read-only cache pipeline saturated. | Reduce __ldg reuse; rebalance between L1 and read-only path. |
| Branch divergence | Threads in a warp take different paths — serial execution. | Restructure conditionals; sort or bucket inputs; use predication. |
| PCIe transfer (host) | Visible in Nsight Systems, not ncu — slow H2D copies dominate the timeline. |
Use pinned memory + cudaMemcpyAsync; overlap with compute on a separate stream; consider GPUDirect Storage if from disk. |
| Register spill | --ptxas-options=-v shows local-memory bytes > 0; spills hit slow LMEM (cached in L1). |
Reduce live-range pressure; __launch_bounds__; smaller tiles; refactor to reuse registers. |
Read the top-line "Speed of Light" first — if it says 80% of peak, stop optimising. If it's 20%, look at the dominant stall reason; that single number narrows the search to one of the rows above. Don't try to fix everything at once: re-profile after every change, or you'll improve one stall and silently regress another.
One rank tells you about that rank. Distributed problems — stragglers, ring imbalance, cross-node latency — only show up when you correlate timelines across ranks. Nsight Systems has first-class support for this.
# torchrun: each rank gets its own .nsys-rep, named by RANK env var:
nsys profile -t cuda,nvtx,nccl -o "out_rank%q{RANK}" \
torchrun --nproc_per_node=8 --nnodes=2 train.py
# SLURM srun: same idea with SLURM_PROCID:
srun --ntasks=16 nsys profile -t cuda,nvtx,nccl \
-o "out_%q{SLURM_PROCID}" python train.py
# Diagnostic env vars to capture alongside the trace:
export NCCL_DEBUG=INFO # protocol, ring, algo, channel choice
export NCCL_DEBUG_SUBSYS=COLL,ENV # filter what NCCL prints
export NCCL_TOPO_DUMP_FILE=topo.xml # NCCL's view of the fabric — gold for "why slow ring"
Nsight Systems' UI lets you load several .nsys-rep files together: File → Open Multiple. The tool aligns them on a common time axis (using NCCL collective endpoints as anchors). What you can then see at a glance:
all-reduce bar is consistently longer than its peers; that rank started the collective late, the others were waiting.Per-rank logs print the chosen algorithm (Tree vs Ring), protocol (Simple / LL / LL128), and channel count. A sudden algorithm change between runs explains otherwise mysterious throughput regressions.
NCCL's belief about the fabric — PCIe topology, NVLink graph, NIC affinity. If NCCL thinks two GPUs are SYS-connected when in fact they share NVLink, you'll see catastrophic perf. Diff this file against nvidia-smi topo -m.
1–5% per rank, but report files can balloon (gigabytes for a 30s capture across 64 ranks). Use --capture-range=cudaProfilerApi to bracket exactly the steps you want, not the whole run.
For very large clusters, profile a subset of ranks — rank 0 + one per node usually captures the structure without 1024× the data. Add --sample=cpu for backtraces on the host side.
CUPTI (CUDA Profiling Tools Interface) is the C API behind every NVIDIA profiler. It surfaces every CUDA event — kernel launches, memcpys, sync ops, stream activity — plus the GPU PerfMon counters used by Nsight Compute. You won't write CUPTI directly often; you almost certainly already use it through torch.profiler and friends.
ncu uses; serialises kernels, much higher overhead.torch.profiler — emits Chrome trace JSON#include <cupti.h>
#include <stdio.h>
static void CUPTIAPI on_buffer_request(uint8_t **buf, size_t *sz, size_t *maxn) {
*sz = 8 * 1024 * 1024;
*buf = (uint8_t*)aligned_alloc(8, *sz);
*maxn = 0;
}
static void CUPTIAPI on_buffer_complete(CUcontext ctx, uint32_t streamId,
uint8_t *buf, size_t sz, size_t valid) {
CUpti_Activity *rec = NULL;
while (cuptiActivityGetNextRecord(buf, valid, &rec) == CUPTI_SUCCESS) {
if (rec->kind == CUPTI_ACTIVITY_KIND_KERNEL ||
rec->kind == CUPTI_ACTIVITY_KIND_CONCURRENT_KERNEL) {
CUpti_ActivityKernel9 *k = (CUpti_ActivityKernel9*)rec;
printf("%-40s %.3f ms grid=(%u,%u,%u) regs=%u\n",
k->name,
(k->end - k->start) / 1.0e6,
k->gridX, k->gridY, k->gridZ,
k->registersPerThread);
}
}
free(buf);
}
int main(void) {
cuptiActivityRegisterCallbacks(on_buffer_request, on_buffer_complete);
cuptiActivityEnable(CUPTI_ACTIVITY_KIND_CONCURRENT_KERNEL);
cuptiActivityEnable(CUPTI_ACTIVITY_KIND_MEMCPY);
/* ... run your CUDA program here ... */
cuptiActivityFlushAll(1);
return 0;
}
Custom in-house: an autotuner that picks tile size by re-measuring kernel time, a continuous metrics daemon that feeds Prometheus per-kernel, a CI test that asserts no kernel regressed by >5%. Most engineers should reach for torch.profiler first — it gives 90% of the value with one decorator.
Nsight is for development. In production you don't want to attach a profiler — you want continuous, low-overhead telemetry that alerts when something drifts. That's where DCGM, nvbandwidth, nvitop, and the stress testers live.
| Tool | What it does | When you use it |
|---|---|---|
nvidia-smi |
Lightweight probe: power, temp, mem, processes, ECC counts. Reads NVML. | Quick "is it on?" check; ad-hoc one-shot during incident. |
nvitop |
TUI dashboard, multi-GPU + multi-process, sortable, kill-process bindings. | Day-to-day "what's running where" on a shared workstation. Strictly better than watch -n1 nvidia-smi. |
dcgmi (DCGM) |
NVIDIA's datacenter GPU manager. CLI for health checks, stress tests, group ops, MIG ops. | Pre-flight on a node: dcgmi diag -r 3 runs a 5-min battery of tests. |
dcgm-exporter |
Prometheus exporter for DCGM. Per-GPU metrics with labels. | Always-on production observability. Pair with Grafana dashboards. |
nvbandwidth |
Measures real H2D, D2H, D2D, P2P bandwidth across all GPU pairs. NVIDIA-supported successor to bandwidthTest. |
"Why is GPU 2 slower at all-reduce?" — nvbandwidth exposes the bad lane / cable / port. |
nv-gpu-burn / gpu-fryer |
Stress testers — pure compute loops with optional matrix-error checking. | Burn-in before production; reproducing thermal throttling. |
cuobjdump / nvdisasm |
Disassemble a cubin: list kernels, dump SASS, dump PTX, show resource usage per kernel. | Final ground-truth: "what did the compiler actually emit?" |
nvidia-smi doesn't# On every GPU node: dcgm-exporter as a sidecar / DaemonSet
docker run -d --name dcgm-exporter --gpus all --cap-add SYS_ADMIN \
-p 9400:9400 nvcr.io/nvidia/k8s/dcgm-exporter:latest
# Prometheus scrapes :9400/metrics every 15s
# Grafana dashboard 12239 (DCGM Exporter) is a sane default
# Pre-flight: full diagnostic before reintroducing a node
sudo dcgmi diag -r 3 # level 3 = ~5 min, full battery
nvbandwidth -t host_to_device_memcpy_ce
nvbandwidth -t device_to_device_memcpy_read_ce
The metrics that actually predict outages: rising ECC SBE rate (silicon ageing), any DBE (replace the card), XID 13/31/43/45/63 (page faults / MMU failures), throttling reason = HW Slowdown (PSU sag), NVLink replay rate > 0. Don't alert on GPU utilisation — remember, nvidia-smi lies.
Pick the question you actually have. The planner picks the tool, the exact command, what to look for, and the next tool to reach for if the first one doesn't surface the answer.
Start at the level of the question (fleet, rank, kernel), reach for the right tool, capture once with NVTX already in the code, read the highest-priority signal first (top-line throughput → dominant stall reason → source line). Profilers don't fix bugs; they tell you where the bug is. Once you know that, the fix is usually three lines.