hands-on labon a real NVIDIA GPU

Profile PyTorch Training with the Built-in Profiler

profiler, read the op-level table, inspect the Chrome/Perfetto timeline, and decide when to reach for Nsight Systems instead.

time
35 min
checked steps
4
level
Intermediate
setup
None
NCA-AIIONCP-GENLNCP-ADSNCA-GENLNCA-GENMPart of the AI Infrastructure Engineer Course

$29.99a month with Pro

Pro opens this lab and every other one. Cancel any time.

Lab cockpit35 min · 4 stepsSession running
3 / 4 steps passingExport a Chrome trace for visual analysis · step 4 of 4
lab.ipynb

Step 4 — Export a Chrome trace for visual analysis

Tables are useful for the summary. Timelines are where bottlenecks reveal themselves visually. A well-structured profile shows CUDA streams as lanes, kernels as colored bars, CPU threads interleaved. You can see at a glance:

[ ]:
chrome_trace_path = '/tmp/pytorch_trace.json'prof.export_chrome_trace(chrome_trace_path) import os, jsonsize_kb = os.path.getsize(chrome_trace_path) / 1024with open(chrome_trace_path) as f:    trace = json.load(f)events = trace.get('traceEvents') if isinstance(trace, dict) else traceevent_types = {}for e in events:    cat = e.get('cat', 'unknown')    event_types[cat] = event_types.get(cat, 0) + 1 print(f'Chrome trace written: {chrome_trace_path} ({size_kb:.1f} KB, {len(events)} events)')print(f'\nEvent categories (what\'s in the trace):')for cat, count in sorted(event_types.items(), key=lambda x: -x[1])[:8]:    print(f'  {cat:<25s} {count:>6d} events')print(f'\nOpen at chrome://tracing or ui.perfetto.dev to explore the timeline.')
[ ]:
lab.check(4)
step 4 of 4, replayed: Export a Chrome trace for visual analysis. The code, the output and the check are the lab's own.

4 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Profile a training step, extract top ops

    read this step below ↓

  2. 2

    The canonical key_averages().table() summary

  3. 3

    Memory profiling: which op allocates the most VRAM?

  4. 4

    Export a Chrome trace for visual analysis

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Profile a training step, extract top ops

Nsight Systems (Lab I-3) gives you the system view — CUDA API calls, CPU threads, NVTX ranges, kernel launches. It's thorough but heavy, and you need to drop to shell + parse CSVs.

PyTorch Profiler is the lighter cousin. Five lines of Python give you per-operator timings, memory allocations, and Chrome trace exports — all inside your Python script. It's the go-to tool for model-level optimization: "which torch op is eating my time?"

Both matter. They're complementary:

QuestionBest tool
"Is the GPU idle or busy right now?"Nsight Systems + timeline
"Which op in my model is slowest?"PyTorch Profiler + key_averages
"How much memory does each layer allocate?"PyTorch Profiler with profile_memory=True
"What's my GPU doing at microsecond granularity?"Nsight Systems or Nsight Compute
"Can I use this in CI?"PyTorch Profiler (scriptable, parseable)

References

What you'll do

  1. Wrap a training step with torch.profiler.profile(...); extract top ops
  2. Pretty-print the key_averages() table; read the columns correctly
  3. Add memory profiling; find the biggest allocators
  4. Export a Chrome trace; inspect it to see the scheduler gaps

The minimum useful profile:

with torch.profiler.profile(activities=[CPU, CUDA]) as prof:
    for _ in range(5):
        training_step()
prof.key_averages().table(sort_by='cuda_time_total', row_limit=20)

Two gotchas:

  • Run a few warmup steps outside the profiler — the first CUDA launch compiles kernels, skewing the numbers.
  • torch.cuda.synchronize() at the end — without it, some async kernels may still be in flight and not counted.

The profiler's three timing columns often confuse first-time users:

ColumnWhat it measures
CPU timeTime the CPU thread spent inside this op (dispatching, arg binding)
CUDA timeTime GPU kernels launched by this op spent running
Self CUDA timeCUDA time excluding child ops (useful for ops like Sequential that compose others)

Optimize by self CUDA time for op-level work, CUDA time for whole-module work.

import torch
import torch.nn as nn
from torch.profiler import profile, ProfilerActivity, record_function

device = torch.device('cuda')
torch.manual_seed(0)

# A small but realistic training setup
model = nn.Sequential(
    nn.Conv2d(3, 64, 7, stride=2, padding=3), nn.BatchNorm2d(64), nn.ReLU(),
    nn.Conv2d(64, 128, 3, stride=2, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
    nn.Conv2d(128, 256, 3, stride=2, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
    nn.AdaptiveAvgPool2d(1), nn.Flatten(),
    nn.Linear(256, 1000),
).to(device)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)
loss_fn = nn.CrossEntropyLoss()

def training_step():
    x = torch.randn(64, 3, 224, 224, device=device)
    y = torch.randint(0, 1000, (64,), device=device)
    out = model(x)
    loss = loss_fn(out, y)
    optimizer.zero_grad(set_to_none=True)
    loss.backward()
    optimizer.step()

# Warmup OUTSIDE the profiler (first CUDA launches compile kernels)
for _ in range(3):
    training_step()
torch.cuda.synchronize()
print('warmed up')
# Profile 5 steps
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
    for _ in range(5):
        with record_function('training_step'):
            training_step()
    torch.cuda.synchronize()

# Extract top ops
top_ops = []
for evt in prof.key_averages():
    top_ops.append({
        'name': evt.key,
        'cpu_time_us': evt.cpu_time_total,
        'cuda_time_us': evt.device_time_total if hasattr(evt, 'device_time_total') else evt.cuda_time_total,
        'self_cuda_time_us': evt.self_device_time_total if hasattr(evt, 'self_device_time_total') else evt.self_cuda_time_total,
        'count': evt.count,
    })
top_ops.sort(key=lambda op: -op['cuda_time_us'])
top_ops = top_ops[:15]

print(f'Captured {len(top_ops)} top ops (by CUDA time):\n')
print(f'{"op":<40s} {"CPU (ms)":>10s} {"CUDA (ms)":>10s} {"count":>6s}')
for op in top_ops[:12]:
    print(f'{op["name"][:40]:<40s} {op["cpu_time_us"]/1000:>10.2f} {op["cuda_time_us"]/1000:>10.2f} {op["count"]:>6d}')

what you get

  • A JupyterLab notebook on a real NVIDIA GPU
  • A check at the end of every step
  • Hints and the full solution
  • Nothing to install or configure

before you start

  • Comfortable writing a PyTorch training loop (forward, loss, backward, optimizer step)
  • Basic understanding of CPU vs GPU execution and why `torch.cuda.synchronize()` matters
  • Familiarity with nn.Module / CNNs (the demo uses a small conv net)

Exam domains covered

GPU Acceleration & Distributed TrainingEvaluation, Monitoring & Safety

Part of the GPU and Kubernetes track

Lab 15 of 20. GPU scheduling on your own Kubernetes cluster, the GPU Operator, CUDA kernels and profiling.

Questions about this lab

Do I need TensorBoard to use the PyTorch Profiler?

No. The native torch.profiler.profile context manager plus key_averages().table() gives you the full op-level summary directly in your notebook, and export_chrome_trace writes a JSON file that opens in any Chromium browser at chrome://tracing or in Perfetto. TensorBoard adds a nicer web UI on top of the same data via the torch-tb-profiler plugin, but it's optional. This lab uses the native API and Perfetto so you can read a trace without any extra install.

Why do I need warmup and torch.cuda.synchronize() before timing?

CUDA kernel launches are asynchronous, calling model(x) returns immediately while the GPU is still crunching. Without synchronize() your timer captures the launch, not the work. Worse, the first few iterations hit lazy kernel compilation, cuDNN autotuner probing, and allocator warmup; those costs contaminate the profile. The Step 1 hint makes both explicit: run a few training steps outside the profiler, then enter the with profile(...) block, then sync right before exit.

What does profile_memory=True actually record?

Per-op peak memory allocation on each device. In Step 3 you read evt.device_memory_usage (or cuda_memory_usage on older torch) for each EventAggregator and convert to MiB, the top entries are usually activation storage for convolution/matmul ops, not weights. That's how you find the ops to target with activation checkpointing or a lower-precision recompute. record_shapes=True adds tensor dimensions so you can see which layer width is the culprit when the same op name appears multiple times.

How do I read a Chrome trace in Perfetto?

Open ui.perfetto.dev in your browser, drag your exported JSON onto the page, and you'll see CUDA streams, CPU threads, and NVTX markers on a unified timeline. The important shapes: long solid CUDA-stream blocks (kernels running), gaps in the CUDA stream with CPU-thread activity (launch-bound), and cudaMemcpy regions that don't overlap with kernels (H2D stall). Perfetto is the same engine Chrome DevTools uses, if you've read a flame chart before, it'll feel familiar.

When does PyTorch Profiler stop being enough?

When your bottleneck is below the torch-op layer. If a single aten::addmm is hot and you need to know why, warp-level instruction mix, Tensor Core utilization, SM occupancy, L2 cache hit rate, you drop into Nsight Compute. For system-level view across multiple processes, CUDA graphs, and the full CUDA API, you use Nsight Systems. PyTorch Profiler lives in between: model-level diagnosis and memory accounting. Great first tool, rarely the last.

What does the lab grade on each step?

Step 1 checks top_ops has ≥5 entries, each with name, cpu_time_us, cuda_time_us, and that the top op name contains a real torch/autograd marker (aten::, cudnn::, Optimizer, gemm, etc.). Step 2 verifies table_str is >200 chars and contains the canonical columns. Step 3 validates mem_ops has ≥3 ops with real cuda_mem_mib values and at least one above 1 MiB. Step 4 confirms the trace file exists, is valid JSON, and has >50 trace events.

What you'll profile in this PyTorch Profiler lab

PyTorch Profiler is the five-lines-of-code tool that tells you which op is eating your training budget, aten::addmm, cudnn::conv2d, or the Optimizer.step you forgot was expensive. It's the first profiler every PyTorch engineer should learn and the one you'll reach for 80% of the time. You'll walk away with four concrete artifacts from a real training loop: a top_ops ranking by CUDA time, the canonical key_averages().table() string you paste into GitHub issues, a per-op memory allocation breakdown in MiB, and a Chrome trace JSON you open in Perfetto to see CUDA streams and CPU threads on a unified timeline. Plus the judgment to know when to graduate from this tool to Nsight Systems (for system-level view) or Nsight Compute (for SM-level kernel internals). About 35 minutes on a live NVIDIA GPU pod, PyTorch, CUDA, and the profiler extensions are ready; Perfetto runs in your browser at ui.perfetto.dev.

The technical substance is torch.profiler.profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) plus three critical switches: profile_memory=True (lights up per-op device_memory_usage, which surfaces activation-storage hot spots that weight checkpointing or lower-precision recompute can actually fix), record_shapes=True (tensor dims so you can tell which conv layer is the culprit when the same op name shows up ten times), and export_chrome_trace (writes Chrome Trace Event Format JSON). The non-negotiable: warmup outside the profiled region and torch.cuda.synchronize() before exiting the context. Without it, async kernel launches + lazy cuDNN autotuning + allocator warmup contaminate every number. The trap engineers fall into: aggregated tables hide scheduler gaps, implicit syncs, and H2D serialization, those pathologies only show up on the Perfetto timeline. Tables answer 'which op is hot,' timelines answer 'why is the GPU idle between ops.'

The hand-off rule: when a single aten::addmm is hot and you need warp-level instruction mix, Tensor Core utilization, SM occupancy, or L2 hit rate, you drop into Nsight Compute. For cross-process, CUDA-graph, full-CUDA-API visibility, you use Nsight Systems. PyTorch Profiler lives squarely in between: model-level diagnosis, memory accounting, and a good-enough timeline for 80% of what you'll debug. Knowing which tool to reach for is half the skill.

Prereqs: a PyTorch training loop (forward/loss/backward/optimizer), nn.Module familiarity, a mental model of why torch.cuda.synchronize() matters. Preinstalled: PyTorch, CUDA, profiler extensions, Perfetto link. Grading validates real artifacts: top_ops has ≥5 entries with recognizable torch/autograd markers (aten::, cudnn::, Optimizer), table_str contains the canonical columns, mem_ops shows at least one op above 1 MiB, and your Chrome trace is valid JSON with >50 events.