The Hitchhiker's Guide — Phase 07: Profiling & Performance Engineering
"Measure don't guess. Guess don't ship." — Performance engineering maxim
Section 1: The Roofline Model
The Core Idea
Every op on a GPU (or NPU) is limited by one of two things:
- Compute throughput: how fast the ALUs can multiply (FLOPS/s)
- Memory bandwidth: how fast data moves from DRAM to the ALUs (bytes/s)
The roofline model formalizes this:
$$\text{Performance (FLOPS/s)} = \min\left(\text{Peak FLOPS/s},\ \text{Peak BW (bytes/s)} \times I\right)$$
where $I$ = arithmetic intensity = FLOPS / bytes transferred.
The "roofline" is the shape formed by this function: a sloped line (memory-bound region) that flattens at the peak FLOPS ceiling (compute-bound region).
Performance
(TFLOPS/s)
│
│ __________ Peak FLOPS (A100: 312 TFLOPS FP16)
│ /
│ / ← compute-bound ops (slope = Peak BW)
│ /
│ / ← memory-bound ops (below the roofline)
│ /
└──────────────────────────────────── Arithmetic Intensity (FLOPS/byte)
L1 L2 L3 DRAM (crossover point: Peak FLOPS / Peak BW)
Crossover point (arithmetic intensity where the boundary is): $$I_{\text{crossover}} = \frac{\text{Peak FLOPS}}{\text{Peak BW}}$$
For A100: $312 \times 10^{12} / 2 \times 10^{12} = 156$ FLOPS/byte
Any op with $I < 156$ FLOPS/byte is memory-bound on A100.
Arithmetic Intensity of Common Ops
| Operation | FLOPS | Bytes Read | Bytes Written | AI (FLOPS/byte) | Bound |
|---|---|---|---|---|---|
y = x + b (elementwise add, FP32) | $N$ | $4N + 4N$ | $4N$ | $1/12 \approx 0.08$ | Memory |
y = ReLU(x) (FP32) | $N$ | $4N$ | $4N$ | $1/8 = 0.125$ | Memory |
LayerNorm (FP32) | $8N$ | $4N$ | $4N$ | $1$ | Memory |
GEMM ($M \times K \times N$, FP32) | $2MKN$ | $4(MK + KN)$ | $4MN$ | $\frac{MKN}{2(MK+KN+MN)} \approx K/4$ for large | Compute |
Attention ($n$ tokens, $d$ dim) | $4n^2d$ | $8nd + n^2$ | $4nd$ | $\frac{4n^2d}{8nd+n^2+4nd} \approx n/2$ | Compute (large $n$), Memory (small $n$) |
Conv2D ($K \times K$ kernel, $C_{in}$, $C_{out}$) | $2K^2 C_{in} C_{out} HW$ | ... | ... | $K^2 C_{in} / 2$ for large batch | Compute |
Key insight: a matmul with $K=4096$ has AI ≈ 1024 FLOPS/byte — firmly compute-bound. A matmul in decode-time attention (batch=1, $K$ is small) may be only AI = 2 FLOPS/byte — firmly memory-bound.
The Decode-Time Attention Problem
During LLM generation (decode step), batch_size=1, seq_len grows with each token. The attention operation:
- Reads K, V caches: $8 n d$ bytes (grows with sequence length)
- Compute: $4nd$ FLOPS
AI = $4nd / 8nd = 0.5$ FLOPS/byte — deeply memory-bound regardless of hardware.
This is why decode-time LLM performance scales with memory bandwidth, not FLOPS. A100 has 2 TB/s HBM bandwidth; Snapdragon 8 Gen 3 has 77 GB/s DRAM → A100 is 26× better on LLM decode, even though the TOPS ratio is much larger.
Section 2: Profiling Tools — When to Use Which
Tool Selection Guide
Question: "Why is my model slow?"
│
┌───────┴───────┐
"Which ops?" "Why is op X slow?"
│ │
torch.profiler Nsight Compute (ncu)
(Python-level) (kernel-level)
│
"Is the GPU busy?"
│
Nsight Systems (nsys)
(system-level timeline)
torch.profiler — Python-Level Profiling
import torch
from torch.profiler import profile, record_function, ProfilerActivity
model = MyModel().cuda()
x = torch.randn(32, 512).cuda()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
with_flops=True,
with_stack=True,
) as prof:
with record_function("model_inference"):
model(x)
# Print top ops by CUDA time
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))
# Export Chrome trace (open in chrome://tracing)
prof.export_chrome_trace("trace.json")
# With scheduler for training loops
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=torch.profiler.schedule(wait=2, warmup=2, active=3, repeat=1),
on_trace_ready=torch.profiler.tensorboard_trace_handler('./log/resnet18'),
) as prof:
for step, (x, y) in enumerate(dataloader):
loss = model(x).mean()
loss.backward()
optimizer.step()
prof.step() # must call step() at end of each iteration
Nsight Systems (nsys) — System Timeline
# Profile a Python training script
nsys profile \
--trace=cuda,nvtx,osrt,cudnn,cublas \
--output=profile \
python train.py
# Open the output
nsys-ui profile.nsys-rep # opens GUI
In the nsys timeline, look for:
- CPU-GPU sync points (CPU waiting for GPU to finish — gaps in GPU timeline)
- Memory transfer (H2D/D2H) — often the hidden bottleneck
- CUDA launch gaps — if GPU timeline has gaps, it's CPU-limited (not submitting kernels fast enough)
- SM occupancy — visible in NVTX rows; low occupancy = underutilized GPU
Nsight Compute (ncu) — Per-Kernel Analysis
# Profile specific kernel
ncu \
--metrics l1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum,\
l1tex__t_bytes_pipe_lsu_mem_global_op_st.sum,\
sm__sass_thread_inst_executed_op_ffma_pred_on.sum \
--target-processes all \
python -c "import torch; torch.nn.functional.softmax(torch.randn(1024, 1024).cuda(), dim=-1)"
# Full report (slow but comprehensive)
ncu --set full python train.py 2>&1 | head -100
Key ncu metrics:
sm__throughput.avg.pct_of_peak_sustained_elapsed: % of peak SM utilizationl1tex__t_bytes_pipe_lsu_mem_global_op_ld.sum: bytes loaded from L1 cachedram__bytes_read.sum: bytes read from DRAM (global memory)smsp__warp_issue_stalled_long_scoreboard_per_warp_active.pct: % time stalled waiting for memory — high = memory-bound kernel
torch.utils.benchmark — Reliable Timing
import torch.utils.benchmark as benchmark
# Timer: handles warmup automatically
t = benchmark.Timer(
stmt='model(x)',
setup='import torch; model = MyModel().cuda(); x = torch.randn(32, 512).cuda()',
num_threads=1,
)
# Measure
result = t.timeit(100) # 100 repetitions
print(result)
# <torch.utils.benchmark.utils.common.Measurement object at 0x...>
# model(x)
# 2.47 ms
# 1 measurement, 100 runs , 1 thread
# Compare two implementations
results = benchmark.Compare([
benchmark.Timer(stmt='fast_model(x)', setup=fast_setup).timeit(100),
benchmark.Timer(stmt='slow_model(x)', setup=slow_setup).timeit(100),
])
results.print()
Why torch.utils.benchmark over time.time():
- Handles warmup automatically (GPU JIT compilation, caching)
- Reports statistics (mean, median, IQR)
- Handles CUDA synchronization correctly (calls
torch.cuda.synchronize()before timing) - Uses
perf_counter_nsfor high-resolution timing
Section 3: CUDA Occupancy
Occupancy = (active warps on SM) / (max theoretical warps on SM)
Occupancy is limited by the most constraining resource per SM:
- Register file: each SM has 65,536 registers; if a kernel uses 64 registers per thread and block has 256 threads: 64 × 256 = 16,384 registers per block; SM can run 4 blocks = 1024 threads = 32 warps (100% occupancy on A100 which has 64 max warps)
- Shared memory: SM has 192 KB; if kernel uses 48 KB per block: SM can run 4 blocks
- Block size: launch config must match hardware; e.g., A100 has max 1024 threads/block and max 32 blocks/SM
Calculate occupancy:
# Using PyTorch's occupancy calculator
from torch.cuda.amp import autocast
@torch.jit.script
def my_kernel(x: torch.Tensor) -> torch.Tensor:
return x.relu()
# Get theoretical occupancy
import pycuda.compiler as cuda
# Or use ncu --metrics sm__occupancy_avg...
Low occupancy is not always bad: a kernel with 25% occupancy but 100% FLOP utilization is fine. Occupancy matters when the kernel is latency-bound (lots of memory waits) — higher occupancy allows the scheduler to hide latency by switching to other warps.
Section 4: Memory Coalescing
A CUDA warp (32 threads) executes the same instruction simultaneously. When all 32 threads access a 128-byte-aligned, contiguous segment of memory, this is a coalesced access — one DRAM transaction for 32 threads.
Coalesced (efficient):
# Each thread accesses array[thread_id] — contiguous, aligned
# 32 threads → 1 memory transaction (128 bytes)
output[idx] = input[idx] # good: sequential access pattern
Non-coalesced (inefficient):
# Each thread accesses array[thread_id * stride] — strided
# 32 threads → 32 memory transactions (one per thread)
output[idx] = input[idx * 64] # bad: stride-64 access, each in different cache line
Real example — transposed access:
# Matrix stored row-major: A[row][col]
# Kernel: thread i reads column i of row j — sequential → coalesced ✅
# Kernel: thread i reads row i of column j — non-sequential → non-coalesced ❌
# Fix: use shared memory tiling
# Load a TILE×TILE block into shared memory (coalesced)
# Operate on shared memory (any pattern, fast)
Section 5: The Five Common GPU Performance Bugs
These are the bugs you will find and fix in Lab 03:
Bug 1: Missing KV Cache (2–4× slowdown in generation)
- Symptom: generation time grows O(n²) with sequence length
- Fix: cache K, V in
past_key_values; compute attention only for new tokens
Bug 2: Non-Contiguous Tensors (10–30% overhead)
- Symptom:
tensor.is_contiguous()returnsFalse; copies inserted automatically - Fix: insert
.contiguous()before ops that require contiguous memory, or refactor the transpose/slice that created the non-contiguous tensor
Bug 3: CPU-GPU Synchronization in Loop (100–1000× slowdown)
- Symptom:
tensor.item(),.numpy(),print(tensor)inside generate loop - Fix: remove synchronizations from the hot path; accumulate and print after generation completes
Bug 4: FP32 Ops in FP16 Model (2–4× slowdown)
- Symptom: in the profile, LayerNorm/Softmax runs in FP32 despite model in FP16
- Fix: use
torch.autocast(device_type='cuda', dtype=torch.float16)or explicitly cast ops to FP16; or usetorch.nn.LayerNorm(..., dtype=torch.float16)
Bug 5: Sequential Generation (Nx slowdown for batch=N)
- Symptom: calling
model.generate()N times in a loop instead of batching - Fix: pad to equal length, pass batch to
model.generate(input_ids=batch); if variable length, use continuous batching
Section 6: Interview Pitfalls
| Question | Wrong Answer | Right Answer |
|---|---|---|
| "What is the roofline model?" | "A performance graph" | "A model that predicts achievable performance as min(peak_FLOPS, peak_BW × AI) where AI = FLOPS/bytes; ops above the roofline are impossible; ops below are suboptimal; categorizes bottleneck as compute-bound vs memory-bound" |
| "How do you measure GPU memory bandwidth?" | "Check the spec sheet" | "Empirically: time a large element-wise copy (no compute); bandwidth = bytes / time. A100 spec is 2 TB/s but measured is ~1.9 TB/s. Always measure — thermal throttling, ECC, and other factors reduce practical bandwidth." |
| "What is CUDA occupancy and why does it matter?" | "How busy the GPU is" | "Ratio of active warps to maximum warps per SM. Matters for latency-hiding: a memory-bound kernel benefits from high occupancy (scheduler switches to other warps during memory waits). Low occupancy from register pressure can limit throughput even if individual threads are doing useful work." |
| "nsys vs ncu — when do you use each?" | "They're the same" | "nsys = system timeline; shows CPU-GPU interaction, gaps, synchronizations, transfers — use to find structural problems. ncu = per-kernel metrics; shows FLOP utilization, memory bandwidth, warp stalls — use once you've identified which kernel is slow." |
| "How do you benchmark a PyTorch function reliably?" | "import time; t = time.time()" | "Use torch.utils.benchmark.Timer; it handles CUDA synchronization, warmup iterations, and reports statistical summaries. Raw time.time() misses GPU async execution and JIT warm-up." |
Section 7: Resources
- Roofline Model paper — Samuel Williams et al., "Roofline: An Insightful Visual Performance Model for Floating-Point Programs and Multiprocessor Architectures" (CACM 2009)
- NVIDIA Nsight Compute documentation — https://docs.nvidia.com/nsight-compute/
- NVIDIA Nsight Systems documentation — https://docs.nvidia.com/nsight-systems/
- torch.profiler tutorial — https://pytorch.org/tutorials/recipes/recipes/profiler_recipe.html
- torch.utils.benchmark documentation — https://pytorch.org/docs/stable/benchmark_utils.html
- "CUDA C++ Best Practices Guide" — NVIDIA (memory coalescing, occupancy, shared memory)
- "GPU Performance Background" — Tim Dettmers blog: https://timdettmers.com/2020/09/07/which-gpu-for-deep-learning/
- MLPerf inference benchmarks — https://mlcommons.org/benchmarks/inference/ — real performance numbers on real hardware