Nsight profiling (and torch.profiler fallback)#
Track 07 - GPU · Notebook 07 · Runtime: ≈5 min on CPU or GPU
Prerequisites:
07_gpu/01(GPU architecture tour).Hardware: local GPU or Colab Pro for Nsight. Without Nsight the notebook falls back to
torch.profilerwhich captures kernel timings and GPU/CPU breakdowns but not warp occupancy or memory-throughput per kernel.References:
What#
You’ve measured end-to-end latency in earlier notebooks. This one answers the question where in the forward pass is the time going? Two tools:
Nsight Systems - captures every CUDA kernel launch, NVTX range, and memory transfer into a timeline. Run the Python script under
nsys profileto produce a.qdrep/.nsys-repfile and open it in the Nsight GUI.torch.profiler - Python-level profiler that records CPU + CUDA events. Does not need Nsight installed; works on Colab.
This notebook runs a small forward pass under torch.profiler, sums time per operator, and identifies the top-5 kernels. A commented Nsight invocation shows how to reproduce at the command line.
from llm_systems_cookbook.nb import bootstrap
import time
import torch
import torch.nn as nn
from torch.profiler import ProfilerActivity, profile
s = bootstrap("07_gpu_07_nsight_profiling")
A small attention block to profile#
One transformer block with self-attention and an MLP. Big enough that the individual ops show up distinctly in the profiler.
class Block(nn.Module):
def __init__(self, d: int = 512, n_head: int = 8, ff: int = 2048) -> None:
super().__init__()
self.ln1 = nn.LayerNorm(d)
self.attn = nn.MultiheadAttention(d, n_head, batch_first=True)
self.ln2 = nn.LayerNorm(d)
self.ff = nn.Sequential(nn.Linear(d, ff), nn.GELU(), nn.Linear(ff, d))
def forward(self, x: torch.Tensor) -> torch.Tensor:
h = self.ln1(x)
a, _ = self.attn(h, h, h, need_weights=False)
x = x + a
return x + self.ff(self.ln2(x))
torch.manual_seed(0)
block = Block().to(DEVICE).eval()
x = torch.randn(4, 256, 512, device=DEVICE)
# Warmup.
with torch.inference_mode():
for _ in range(3):
block(x)
if IS_CUDA:
torch.cuda.synchronize()
Profile one forward pass#
torch.profiler.profile captures CPU-side Python ops and CUDA kernel
launches. We aggregate by cuda_time_total (or cpu_time_total on CPU)
and print the top operators.
activities = [ProfilerActivity.CPU]
if IS_CUDA:
activities.append(ProfilerActivity.CUDA)
with torch.inference_mode(), profile(activities=activities, record_shapes=False) as prof:
for _ in range(10):
block(x)
if IS_CUDA:
torch.cuda.synchronize()
sort_key = "cuda_time_total" if IS_CUDA else "cpu_time_total"
top_events = sorted(
prof.key_averages(),
key=lambda e: getattr(e, sort_key),
reverse=True,
)[:5]
print(f"top operators by {sort_key}:")
for e in top_events:
self_time_us = getattr(e, sort_key)
print(f" {e.key:<50} {self_time_us/1e3:>8.2f} ms calls={e.count}")
total_time_us = sum(getattr(e, sort_key) for e in prof.key_averages())
print(f"\ntotal op time = {total_time_us/1e3:.1f} ms over 10 forwards")
s.check(
"profile_captured_events",
lambda: len(prof.key_averages()) > 0,
msg=f"n events = {len(prof.key_averages())}",
)
s.check(
"top_event_consumes_measurable_time",
lambda: getattr(top_events[0], sort_key) > 0,
msg=f"top op {top_events[0].key} time = {getattr(top_events[0], sort_key)} us",
)
s.check(
"total_time_accumulates",
lambda: total_time_us > 0,
msg=f"total = {total_time_us} us",
)
Where the time goes#
The table above lists ops by wall time; a horizontal bar of the top entries makes the shape of the distribution obvious. A few ops usually dominate - that’s where any optimisation effort should start. Everything else is noise until the top bar comes down.
import matplotlib.pyplot as plt
if len(top_events) > 0 and total_time_us > 0:
names = [e.key if len(e.key) <= 36 else e.key[:33] + "..." for e in top_events]
times_ms = [getattr(e, sort_key) / 1e3 for e in top_events]
frac = [t * 1e3 / total_time_us for t in times_ms]
fig, ax = plt.subplots(figsize=(7, 3.4))
y = list(range(len(names)))[::-1]
bars = ax.barh(y, times_ms, color="tab:blue")
ax.set_yticks(y)
ax.set_yticklabels(names)
ax.set_xlabel(f"time (ms, {sort_key})")
ax.set_title(f"top-{len(names)} ops ({sum(frac):.0%} of {total_time_us/1e3:.1f} ms total)")
for bar, f in zip(bars, frac):
ax.text(bar.get_width(), bar.get_y() + bar.get_height() / 2,
f" {f:.0%}", va="center", fontsize=9)
ax.grid(True, axis="x", alpha=0.3)
fig.tight_layout()
plt.show()
else:
print("skipped - no profiler events captured.")
Running Nsight from the shell#
When you want kernel-level detail that torch.profiler can’t give you (warp occupancy, memory throughput per kernel, stream dependencies), use Nsight Systems outside the notebook:
# Capture a 10-second window of a training or inference run.
nsys profile \
--trace=cuda,nvtx,osrt \
--output=my_trace \
--force-overwrite=true \
python my_script.py
The resulting my_trace.nsys-rep opens in the Nsight Systems GUI.
For kernel-level instruction-mix and memory-throughput analysis (one specific kernel at a time) use Nsight Compute:
ncu --target-processes application-only \
--set full -k my_kernel \
-o my_kernel_report \
python my_script.py
Add NVTX ranges in Python via torch.cuda.nvtx.range_push("name") /
range_pop() so Nsight labels phases of your code distinctly.
# Demonstrate NVTX range annotations; they are no-ops on CPU.
if IS_CUDA:
with torch.inference_mode():
torch.cuda.nvtx.range_push("custom/forward")
for _ in range(5):
block(x)
torch.cuda.nvtx.range_pop()
torch.cuda.synchronize()
print("NVTX ranges would appear in Nsight Systems under 'custom/forward'")
s.check("nvtx_ranges_recorded", lambda: True, msg="NVTX calls completed")
else:
s.skip("nvtx_ranges_recorded", "no CUDA (NVTX is a CUDA-only API)")
Exercises#
Compare SDPA backends. Wrap each of
sdpa_kernel([MATH]),sdpa_kernel([EFFICIENT_ATTENTION]),sdpa_kernel([FLASH_ATTENTION])in an NVTX range and see which kernels each backend dispatches in the Nsight timeline.Find the graph break. Add a graph break (e.g.,
torch._dynamo.graph_break()) inside atorch.compile-d function and profile. Look for the gap in the GPU timeline where the graph-break Python code runs.Kernel-level memory throughput. Run Nsight Compute on your custom Triton kernel from
07_gpu/03. The report’s “DRAM throughput %” tells you how close to peak bandwidth you’re getting.
References#
NVIDIA DLI Accelerated Computing with CUDA and Nsight for the canonical workflow.
Horace He 2022 (Making Deep Learning Go Brrrr) for the connection between profiler output and the roofline mental model.
s.summary()
s.save()