cudatraining · lab notes

LESSON 10 · 2026.04.20 · L4

Nine kernels under the knife — what was hiding behind the numbers

Beyond "fast/slow," reaching "why that number." Turning three claims from lessons 1–9 into numbers with nsys timelines and ncu stall counters.

GPU · L4 · sm_89 tools · nsys 2025.1 · ncu 2025.2 phases · 3

Through lessons 1–9 I wrote ten CUDA / Triton kernels. I benched each and logged numbers like "v4 is 200× faster than v1" and "ours runs at 78 % of SDPA."

But there was a lot I had moved past without actually knowing why the number was what it was:

This essay is a record of tearing those kernels apart with nsys (timeline profiler) and ncu (per-kernel metric profiler) — without changing a single line of kernel code. Doing this first paid back more per hour than writing another kernel.

Two tools, two viewpoints

toolwhat it showsoverheadthe question it answers
nsys (Nsight Systems)Time-axis event timeline (CUDA API, kernel, memcpy, stream sync)~1–5 %"where on the time axis is something waiting?"
ncu (Nsight Compute)Per-kernel internal HW counters (stall reason, tensor pipe, memory SOL)10–30 × real time (replay)"what is a warp doing every cycle?"

nsys looks at the "critical-path balance between kernel and transfer." ncu looks at "whether warps inside a kernel are idle, computing, or waiting." They don't overlap.

Phase 1 · nsys — pageable is especially slow on D2H

Question: Lesson 04 said pinned memory is faster than pageable. How much, in which direction?

Experiment: Run bin/vector_add --n 16M --iterations 5 twice — once with --pageable, once with --pinned — under nsys, then pull .nsys-rep locally and inspect in GUI + CLI stats.

directionpageable GB/spinned GB/sspeedup
H2D (134 MB)4.7712.352.59×
D2H (67 MB)1.3313.199.91×

The surprise: pageable H2D at 4.77 GB/s but pageable D2H at 1.33 GB/s — same PCIe, and yet 3.6× slower. The timeline makes the reason obvious:

L4's PCIe Gen4 x16 effective BW ≈ 26 GB/s. Pinned reaches ~50 % of that. Pageable D2H sits at 5 % — a structural tax.

And the kernel time doesn't move (pageable 0.834 ms, pinned 0.836 ms). Pinning touches only the transfer path, not on-device execution — an obvious fact, now confirmed with numbers.
One-line takeaway

Don't remember pageable → pinned as the vague "transfer gets 2× faster." Remember it as "D2H gets 10× faster." That's where the user-visible latency drop actually comes from.

Phase 2 · ncu — atomic's "slowness" is not occupancy, it's lg_throttle

Question: Lesson 02 reduction v1 (atomicAdd per thread) is hundreds of times slower than v4 (warp shuffle + 1 atomic per block). Fine — but what specific HW counter exposes that slowness?

Experiment: Run bin/reduction --n 4M --version {1,4} each under ncu --set detailed --launch-skip 20 --launch-count 1 -k "regex:reduce_v{1,4}_", then compare stall distribution + SOL metrics.

metricv1 (atomic per thread)v4 (shuffle + block atomic)
Elapsed cycles12,085,43555,229 (218× fewer)
DRAM throughput0.46 %88.2 % (192× higher)
L2 hit rate88.74 % (!)0.95 %
Achieved occupancy91.17 %91.89 % (essentially the same)
Dominant stalllg_throttle 31.1 %long_scoreboard 84.6 %

Three surprises:

  1. Occupancy is the same. Both sit at 91–92 %. Intuitively you'd think "v1's warps can't launch because atomic blocks them." In fact the warps do launch — and then sit there waiting. That doesn't register in occupancy.
  2. DRAM is empty. v1's DRAM is 0.46 %. This kernel is not memory-bound.
  3. But L2 hit is 88.74 % — absurdly high. All threads touch the same 4-byte accumulator, so that cache line gets pinned in L2 and keeps hitting. But lots of hits isn't speed — every SM fighting over one line creates serialization.

That shows up as lg_throttle 31.1 % — "local/global memory throttle," a signal that the LSU (load/store unit) is getting back-pressured on the atomic path. In v4, lg_throttle goes to 0 %, the dominant stall flips to long_scoreboard (normal DRAM load wait), DRAM fills to 88 %, and the kernel takes the healthy shape of a memory-bound kernel.

Lesson

Remember "atomic is slow" at this resolution: "atomic creates L2 cache-line serialization, which shows up on the counter as lg_throttle, and meanwhile DRAM sits empty." Only with those three sentences together is "why it's slow" actually explained.

Phase 3 · ncu-tracing the 20 % gap between ours and SDPA

Question: Lesson 09's 4-D causal FA in Triton hit 78–90 % of F.scaled_dot_product_attention. Where is the 22 %?

Experiment: B=1 H=32 N=2048 d=128 causal fp16 (LLaMA-7B mid-range, where the gap was biggest). Profile each under ncu and compare metrics.

First finding — SDPA's backend wasn't cuDNN. Kernel name:

void flash_fwd_kernel<Flash_fwd_kernel_traits<128, 64, 64, 4, 0, 0, half_t, ...>>(Flash_fwd_params)

That's Tri Dao's Flash Attention 2 CUDA implementation — PyTorch 2.11 ships it and dispatches to it on L4 + fp16 + causal. Not cuDNN. So we're actually comparing Triton FA to a seasoned CUDA implementation of the same algorithm.

metricours (Triton)SDPA (FA-2 CUDA)ratio
Elapsed cycles1,565,141827,3281.89×
Compute (SM) throughput39.3 %72.1 %1.84×
Tensor pipe utilization44.6 %78.8 %1.77×
DRAM throughput10.6 %20.3 %1.92×
Registers per thread255 (verge of spilling)1840.72×
Achieved occupancy8.3 %16.2 %1.95×

Stall distribution:

stall reasonoursSDPA
total samples78,14442,886
wait (MMA output dep)38.6 %19.0 %
selected (issued)21.7 %13.6 %
math_pipe_throttle (tensor saturation)19.4 %41.5 %
short_scoreboard (reg dep)14.9 %2.2 %

Four places the 20 % gap lives

  1. Register pressure → occupancy halved. Autotune picked BLOCK_M=128, pushing registers to 255 (literally the max, on the verge of spill). Resident warps on the SM get halved. SDPA uses BLOCK_M=64, 184 regs/thread, and keeps 2× the warps alive. Occupancy 8.3 % vs 16.2 %.
  2. MMA dependency chain (wait 38.6 %). We consume the output accumulator too close to a tl.dot. num_stages is low, so the consumer waits on the producer MMA. SDPA's wait is only 19 %.
  3. Register dependency (short_scoreboard 14.9 % vs SDPA 2.2 %). A follow-on effect of #1 — with the register file stuffed, producer-consumer often reference the same physical register.
  4. SDPA is already sitting at a "good" bottleneck. math_pipe_throttle 41.5 % — tensor core saturated. That's a better signal than wait: it means they're in the "you'd need more FLOPs to go faster" regime. We don't reach it.

Three lessons from this session

(a) Occupancy is not throughput

Common thread across Phase 2 and Phase 3: had I only looked at occupancy, I'd have made the wrong diagnosis.

Occupancy caps "how many warps can live." What those warps are doing is separate, and you need DRAM / compute / tensor-pipe SOL % + stall distribution to see it.

(b) A ncu stall distribution is a kernel's fingerprint

Only once this distribution is visible does "what to fix" become clear. Without ncu, you can't make that judgement.

(c) "Bigger tile is faster" is an unreliable intuition

In Phase 3, autotune picked BLOCK_M=128, but that wasn't optimal for this shape on L4. Register pressure drained the warp pool. Small tiles (fewer registers → more resident warps, shorter K/V reuse cycle makes software pipelining easier) vs big tiles (each block reads once and computes more → higher arithmetic intensity) — you don't know without measuring. And autotune picking best-by-wall-time doesn't guarantee that best is actually using the HW fully. Confirm with ncu.

The practical tool chain I took home

  1. Run the kernel, measure wall time.
  2. nsys timeline → check kernel vs transfer critical path.
  3. ncu --set detailed → check SOL % for DRAM / Compute / Tensor pipe.
  4. Read stall reason distribution. What's dominant?
  5. Prescribe based on dominant stall (see table above).

"Before bragging about the speedup," log at least the DRAM % and the dominant stall. Conversely, no more publishing numbers from a "I don't know why it's fast/slow" state.

Closing — what this session is saying

These three interpretations are impossible without profiling tools. And with them, the next iteration's "what to change" actually makes sense. A session with zero new kernels — but one that moved forward the starting point of every kernel-tuning session after this.

Appendix — reproduction commands
# Phase 1 — nsys timeline diff (pinned vs pageable)
./scripts/gcp_run_lesson10_phase1.sh <PROJECT_ID> us-west1-b cuda-l4-dev-lesson10

# Phase 2 — ncu reduction v1 vs v4
./scripts/gcp_run_lesson10_phase2.sh <PROJECT_ID> us-west1-b cuda-l4-dev-lesson10

# Phase 3 — ncu ours vs SDPA
./scripts/gcp_run_lesson10_phase3.sh <PROJECT_ID> us-west1-b cuda-l4-dev-lesson10

On the GCP DL image, wrap ncu with sudo -E env PATH=$PATH ncu ... to get perf-counter access.