Part I — The GPU · Chapter 5

Profiling with Nsight: reductions and transposes

~55 min read2 interactive widgets4 plateslab 4 · ~60 min on your GPU

The chapter in one paragraph

Chapters 1–4 taught you what can limit a kernel. This one teaches you to find out what does, with evidence instead of guesses. Two tools split the job. Nsight Systems draws a timeline of the whole program: which kernels ran, when, and where the GPU sat idle waiting for the CPU. Nsight Compute takes one kernel apart: how close it came to each hardware limit, how coalesced its accesses were, how many bank conflicts it caused, and, through stall reasons, what its warps were waiting for. The worked subject is the parallel reduction, the classic sum of an array, optimized in six steps where each step moves one metric you can watch. Then chapter 3’s transposes go under the microscope, and the chapter ends with a workflow you will use for the rest of the course: timeline first, then one kernel, then one hypothesis, one change, and a re-measurement.

In this chapter

  1. Two tools, two questions
  2. Profiling under WSL2
  3. Nsight Systems: reading a timeline
  4. Nsight Compute: one kernel under the microscope
  5. Stall reasons: what the warps were waiting for
  6. The reduction, six versions
  7. The transposes, under ncu
  8. The workflow
  9. Lab 4: profile it yourself
  10. Check your understanding

1. Two tools, two questions

Everyone who starts optimizing GPU code goes through the same phase: change something, rerun the benchmark, stare at a number that moved by 3% in the wrong direction, and have no idea why. The benchmark tells you that something is slow. It can never tell you why. For that you need a profiler, and NVIDIA ships two, because there are two different questions:

Nsight Systems (nsys)Nsight Compute (ncu)
QuestionWhere does the time go in the whole program?Why is this one kernel as slow as it is?
ViewA timeline: CPU threads, CUDA API calls, kernels, copies, your own labelled rangesHundreds of hardware counters for one kernel launch, grouped into sections with explanations
CostLow overhead: the program runs at nearly normal speedHigh: each kernel is replayed many times to collect all counters
FindsGaps, launch overhead, synchronizations, copies on the critical path, which kernel dominatesMemory- vs compute-bound, coalescing, bank conflicts, occupancy, stall reasons, the hot source line
Chapter 1 regimeOverhead-boundCompute- and memory-bound

The order matters: always start with the timeline. Optimizing a kernel that takes 5% of the run, while the GPU idles 40% of the time between kernels, is the most common way to waste a week in this field. Nsight Systems tells you whether any kernel is worth opening; Nsight Compute tells you what is wrong with it.

2. Profiling under WSL2

Both tools are part of the CUDA toolkit you installed in chapter 1, and both work inside WSL2. NVIDIA supports profiling there on Windows 11 with driver 525 or newer (Windows 10 needs build 19044 and driver 545). The pieces fit together like this:

Three WSL-specific caveats, all from NVIDIA’s own documentation:

The desktop shares your GPU

Under WSL2 your RTX 5070 is also drawing the Windows desktop. A browser playing video or a game in the background steals memory bandwidth and SM time, and shows up in your numbers as noise. Close them before profiling, just as for benchmarks.

3. Nsight Systems: reading a timeline

A timeline has one row per thing that can be busy. The ones you will read every day:

What you look for is almost always the same thing: white space in the GPU row. Every gap is time the GPU could have worked and did not, and the rows above tell you why. Lab 4’s graphs.cu builds the three most common situations on purpose: 1,000 tiny kernels (4,096 floats each, a couple of microseconds of GPU work) launched three different ways.

Your move: rank A, B and C by time per kernel, and guess the ratio between A and C.

A > B > C, and the gap between A and C is large: often an order of magnitude. A pays, per kernel, the launch path through the driver, the GPU’s own startup and completion, and the trip back to wake up the waiting CPU thread. B hides the waiting but not the submission: the CPU still spends a few microseconds per cudaLaunchKernel, and a 2 µs kernel finishes before the next one arrives. C submits once, so what remains is the GPU’s own per-kernel cost. Under WSL2 every submission crosses from Linux into the Windows driver, which makes A and B more expensive than on native Linux and graphs more valuable. Lab 4 prints all three; the exact numbers depend on your CPU and driver.

CUDA graphs

A CUDA graph is a recording of GPU work that can be replayed with one call. You make one by capturing a stream: between cudaStreamBeginCapture and cudaStreamEndCapture, launches are recorded instead of executed. cudaGraphInstantiate then validates and prepares the whole sequence once (the expensive step), and cudaGraphLaunch replays it as often as you like:

cudaStreamBeginCapture(st, cudaStreamCaptureModeGlobal);
for (int k = 0; k < 1000; ++k) bump<<<16, 256, 0, st>>>(d, 4096);   // recorded, not run
cudaStreamEndCapture(st, &graph);
cudaGraphInstantiate(&exec, graph, 0);   // once
cudaGraphLaunch(exec, st);                // 1,000 kernels, one call

The catch is that a graph replays exactly what was recorded: same kernels, same grid sizes, same pointers. That fits workloads that repeat the same shape over and over, which is precisely LLM decode: every step runs the same few hundred kernels on the same buffers. This is why inference engines capture graphs for decode (vLLM captures one per batch size it expects; llama.cpp uses them on NVIDIA GPUs), and it is how your spine project will fix the overhead problem that chapter 1 predicted for GPT-2 small.

From picture to numbers

The GUI is for looking; nsys stats is for numbers. make nsys in lab 4 prints three tables from the report: nvtx_sum (time per labelled range), cuda_api_sum (how many times each API call happened and its average CPU time: 2,000 cudaStreamSynchronize calls in A is a smell you can see without opening a timeline), and cuda_gpu_kern_sum (total GPU time per kernel name, the first place to look in a real program).

One flag deserves a note: by default Nsight Systems draws a whole graph as a single bar. The lab passes --cuda-graph-trace=node so that you can see the individual kernels inside it.

4. Nsight Compute: one kernel under the microscope

How it works, and why its times differ from yours

A GPU can only count a limited number of events at once, but a full report needs thousands of metrics. So Nsight Compute replays each profiled kernel: it saves the memory the kernel touches, runs it, restores the memory, runs it again with a different set of counters, and repeats, dozens of times for the full set. Three consequences:

So use ncu’s duration to compare kernels with each other inside one report, and your lab benchmark (or Nsight Systems, which neither locks clocks nor flushes caches) for real-world time.

Sets and sections

Results are organized in sections, and sections are grouped in sets. Without flags you get the basic set (Speed of Light, Launch Statistics, Occupancy, Workload Distribution): cheap, and enough to classify a kernel. --set full collects everything, including the sections that explain why:

SectionAnswers
GPU Speed Of Light ThroughputHow close did the kernel get to the compute limit and to the memory limit? The first thing to read.
Memory Workload AnalysisTraffic at each level (L1, shared, L2, DRAM), hit rates, sectors per request, shared-memory bank conflicts.
OccupancyTheoretical vs achieved occupancy and which resource limits it (chapter 2’s calculation, done for you).
Scheduler StatisticsHow often each scheduler had a warp ready to issue, and how often it issued nothing.
Warp State StatisticsStall reasons: how many cycles, on average, warps spent waiting on each kind of thing (section 5).
Source CountersWhich source lines and SASS instructions the stalls and memory traffic belong to. Needs -lineinfo at compile time.

Every section ends with rules: short diagnoses such as “uncoalesced shared accesses” or “this kernel exhibits low compute throughput and memory bandwidth utilization,” each with an estimated speedup. They are good hints and not always right: treat them as hypotheses to check, not verdicts.

Speed of Light: the one panel to read first

Metric names, decoded

Every number in a report is a named metric, and the names follow a grammar. Once you can read them you can request exactly what you want with --metrics, as lab 4’s make ncu-table does:

dram__throughput.avg.pct_of_peak_sustained_elapsed
└┬─┘  └───┬────┘ └┬┘ └──────────────┬─────────────┘
 unit   counter  rollup           suffix
(DRAM)  (what)   (over all DRAM   (as % of the peak rate,
                  channels)        over the kernel's run)

Units you will meet: gpu (whole chip), sm and smsp (an SM, and one of its four sub-partitions: the quadrants of chapter 2), l1tex (L1 and shared memory), lts (L2 slices), dram. The rollup says how per-unit values combine (.sum, .avg, .max), and ratios end in .ratio. The ones worth knowing by heart:

MetricMeaningSeen in
gpu__time_duration.sumKernel durationeverything
dram__throughput.avg.pct_of_peak_sustained_elapsedDRAM bandwidth used, % of peakmemory-bound kernels
sm__throughput.avg.pct_of_peak_sustained_elapsedBusiest SM pipeline, % of peakcompute-bound kernels
l1tex__average_t_sectors_per_request_pipe_lsu_mem_global_op_ld.ratio (and _st)32-byte sectors per warp-wide global load (store): 4 means perfectly coalesced floats, 32 means scatteredchapter 3, the transposes
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum (and _st)Extra shared-memory passes caused by bank conflictsv2, the unpadded transpose
l1tex__data_pipe_lsu_wavefronts_mem_shared_op_ld.sumShared-memory passes (“wavefronts”) for loads: 1 per request if conflict-freesame
smsp__average_thread_inst_executed_per_inst_executed.ratioAverage active threads per warp instruction: below 32 means divergent branches (predicated-off lanes still count as active)chapter 2’s divergence lab
smsp__inst_executed_op_shared_ld_pred_on_any.sumShared-memory load instructions with at least one lane switched on: the ones that actually send a requestv1 vs v3
smsp__inst_executed.sumWarp instructions executed: the total work, including overheadevery version
sm__warps_active.avg.pct_of_peak_sustained_activeAchieved occupancychapter 2
Names drift between versions

NVIDIA occasionally renames metrics (the older smsp__thread_inst_executed_per_inst_executed is now deprecated in favour of the average_ form above). If ncu says a metric does not exist, ncu --query-metrics --chip gb205 lists what your version calls things. The names in this chapter were checked against Nsight Compute 2026.3 for your GPU’s chip (GB205).

5. Stall reasons: what the warps were waiting for

Chapter 2 described the scheduler: every cycle, each quadrant picks one ready warp and issues its next instruction. Nsight Compute watches this happen. For every warp that could not issue, it records why, and reports the result as an average number of warps stalled on each reason per issued instruction. That table is the most direct answer a profiler can give to “why is this slow?”: it names the thing everyone is waiting for.

Here is that bookkeeping as a machine: the SM’s four quadrants of warps, a sampler that taps one warp at a fixed interval of cycles, and one counter per state. Run it on each kernel and watch which counter pulls ahead.

Two rules make stall reasons useful instead of confusing:

  1. Read them together with Speed of Light. A kernel at 90% of DRAM bandwidth should be dominated by long_scoreboard (waiting for memory): that is what being memory-bound looks like, and there is nothing to fix. The same stall with DRAM at 30% means the kernel is waiting on latency it fails to hide, and you have work to do.
  2. Stalls are charged to the instruction that waits, not the one that caused the wait. A slow load shows up on the first instruction that uses the loaded value. On the Source page, look a few lines up from the hot line.
Your move: a kernel shows compute at 15%, memory at 25%, occupancy 100%, and its top stall is barrier. What is going on, and what would you try?

Neither resource is busy and the warps are mostly waiting for each other at __syncthreads(). Typical cause: many barriers with very little work between them, which is exactly the shape of a tree reduction where each level adds one number per thread and then synchronizes. Remedies: do more work between barriers (each thread sums several elements first: coarsening), and replace the last barrier-separated levels with warp shuffles, which need no barrier at all. That is versions 4 and 5 below. High occupancy does not help here: more warps just means more warps waiting at the same barrier.

6. The reduction, six versions

A reduction turns an array into one value: a sum, a maximum, a norm. It is everywhere in ML (softmax needs a max and a sum per row, layernorm a mean and a variance, every loss is a sum), and it is the standard exercise for learning to read a profiler, because the naive GPU version is slow for reasons that are all visible in counters.

Sequentially it is one loop. In parallel it is a tree: add pairs, then pairs of pairs, and so on. N values take N−1 additions, the same as the loop, but in log2N steps instead of N. Rearranging the additions this way is only allowed because addition is associative and commutative. For integers that is exactly true. For floats it is almost true: each ordering rounds differently, so a GPU sum and a CPU sum of the same floats differ in the last bits, and two runs of a kernel that finishes with atomics can differ from each other. Lab 4’s test checks floats against a double-precision reference with a tolerance for this reason, and prints one surprise: a plain sequential float loop is the least accurate of all at large sizes, because once the running sum is big, every small addend loses most of its bits. Trees add numbers of similar size.

All six versions below reduce each block’s share in shared memory and finish with one atomicAdd per block into the final result (blocks cannot wait for each other, chapter 2). Each version changes one thing, and for each I state what the profiler should show. These are predictions from the mechanisms and from the compiled SASS, not measurements: make ncu-table in lab 4 prints the real values for all six on your card.

The first three are the textbook sequence (PMPP chapter 10, and Mark Harris’s famous 2007 slides). They were designed to teach divergence, and on 2007 hardware each step was worth a factor of two. The profiler is about to tell a more interesting story, so look at what the compiler does with v1 first. Because the block size is a compile-time constant, the loop is fully unrolled, every % becomes a bit mask, and the if becomes predication, not a branch (chapter 2): every warp issues every level’s instructions, with some lanes switched off:

LOP3.LUT P0, RZ, R2, 0x1, RZ, 0xc0, !PT   // P0 = (tid & 1) != 0: the "% 2", as a mask
BAR.SYNC.DEFER_BLOCKING 0x0               // __syncthreads()
@!P0 LDS R4, [R3+0x4]                     // s[tid + 1], only lanes with P0 false
@!P0 LDS R5, [R3]                         // s[tid]
@!P0 STS [R3], R4                         // (FADD omitted)
LOP3.LUT P0, RZ, R2, 0x7, RZ, 0xc0, !PT   // next level's mask, 0x3, 0x7, 0xf ...

v2 and v3 compile to the same shape: 88 instructions each, identical in count. So the three versions issue the same number of warp instructions, and the classic “v1 wastes issue slots on divergent warps” argument does not apply to this code. What still differs is which lanes are switched on, and that decides how much shared-memory traffic each level causes. A warp whose lanes are all off sends no request; a warp with even one active lane sends one, and a request with bank conflicts takes several passes (“wavefronts”). Step through it:

v1: interleaved addressing

for (unsigned stride = 1; stride < 256; stride *= 2) {
    if (tid % (2 * stride) == 0) s[tid] += s[tid + stride];
    __syncthreads();
}

The direct translation of the tree. The adding threads are the multiples of 2×stride, spread across the whole block, so for the first five levels every one of the eight warps has at least one active lane and sends its own nearly empty shared-memory request. Per block: 94 shared-load requests over the whole tree, each one a single pass (no conflicts: the active lanes of a warp sit on different banks). Expected evidence: smsp__inst_executed_op_shared_ld_pred_on_any.sum (shared loads with at least one lane on) about 4× higher than v3’s, and zero bank conflicts. Stall reasons: barrier dominates, as in every version with eight barriers per 256 values.

v2: compact thread index

unsigned idx = 2 * stride * tid;
if (idx < 256) s[idx] += s[idx + stride];

Same pairs, reassigned: thread t does the work thread 2×stride×t did. Now the working threads are 0, 1, 2… packed together, so only 4, then 2, then 1 warp has lanes on, and shared-load requests drop from 94 to 24 per block. But a new counter lights up: thread t touches word 2×stride×t, and neighbours that far apart share banks: 2-way at stride 1, up to 8-way at the deeper levels (red in the stepper). Each request now takes several passes, and the total comes back to 94 passes per block, the same as v1 for a completely different reason. Expected evidence: l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum jumps from zero to about 70 per block, while shared-memory wavefronts stay where v1 had them. Fewer requests, same work for the banks: a textbook “improvement” with a flaw that only the profiler shows.

v3: sequential addressing

for (unsigned stride = 128; stride > 0; stride >>= 1) {
    if (tid < stride) s[tid] += s[tid + stride];
    __syncthreads();
}

Fold the upper half onto the lower half. Active threads are packed (few warps send requests) and thread t touches words t and t+stride (consecutive, no conflicts): 24 requests and 24 passes per block, the minimum for this tree. What remains is structural, and identical in all three versions: every 256 input values pay for 8 barriers and 88 instructions per warp, for just 1 KB of data loaded per block. So the honest prediction for the benchmark is that v1, v2 and v3 land close together, v3 slightly ahead, nothing like the 2× steps of 2007. If your numbers show that, the lesson is the chapter’s whole point: optimization folklore is a hypothesis, and the SASS and the counters decide.

Your move: v3 is clean: minimal requests, no conflicts. Yet it will not reach your DRAM ceiling. Which Speed of Light bar and which stall reason do you expect, and what is the fix?

Memory throughput clearly below the practical ceiling you measured in lab 0, compute throughput low too, and barrier high among the stalls: the kernel is not waiting on DRAM, it is spending its time on tree overhead between tiny loads. Each block loads just one float per thread and then runs eight levels of synchronization. The fix is to give every thread more to load before the tree starts: thread coarsening. It also puts several loads in flight per thread, which chapter 3 showed is what keeps the memory pipe full.

v4: coarsening

T sum = 0;                                            // each thread: 8 values, in a register
for (int k = 0; k < 8; ++k) {
    size_t idx = blockIdx.x * 256 * 8 + tid + k * 256;  // coalesced at every k
    if (idx < n) sum += in[idx];
}
s[tid] = sum;  __syncthreads();  /* ...then v3's tree */

Each thread first adds 8 values privately: no barrier, no shared memory, every thread busy, and 8 independent loads in flight. The block covers 2,048 values, so there are 8× fewer blocks and 8× fewer trees, barriers and atomics per byte. Expected evidence: DRAM throughput jumps toward the ceiling, barrier stalls shrink, long_scoreboard (waiting for DRAM) becomes the dominant stall, and total instructions drop sharply. This is usually the biggest single step in the sequence.

v5: warp shuffles for the last five levels

__device__ float warp_sum(float v) {
    for (int offset = 16; offset > 0; offset >>= 1)
        v += __shfl_down_sync(0xffffffff, v, offset);   // lane i reads lane i+offset's v
    return v;                                           // lane 0 holds the warp's sum
}

Once 32 or fewer values remain, they all live in one warp, and the lanes of a warp can exchange registers directly with __shfl_down_sync: no shared memory, no barrier. (The mask argument is chapter 2’s Volta lesson: it states which lanes take part instead of assuming lockstep.) v5 runs the shared-memory tree down to 64 values, then lets warp 0 finish with five shuffles. Five of eight barriers disappear, and so do five rounds of shared-memory loads and stores. Expected evidence: fewer shared-memory wavefronts and fewer barrier stalls, and probably almost no change in time, because v4 was already close to the DRAM ceiling. That is chapter 4’s lesson seen from the profiler: once you are on the roofline, removing work you do not wait for buys nothing. It would matter if the data were in L2, or if this reduction were fused into a kernel with other work, as it will be in Part II’s softmax.

v6: one wave, wide loads

The modern shape. Launch only as many blocks as fit on the GPU at once (48 SMs × 6 blocks of 256 threads = 288), let each thread walk the whole array with a grid-stride loop reading 16 bytes per load (float4), then reduce with shuffles within each warp, one shared-memory slot per warp, and shuffles again. 288 atomics in total instead of 32,768. It should sit at the ceiling, with long_scoreboard the main stall and the fewest instructions of any version. It is also the shape of reduction you will write inside fused kernels later.

The ceiling: CUB

CUB, NVIDIA’s library of parallel primitives (part of CCCL, shipped with the toolkit), has cub::DeviceReduce::Sum, tuned per architecture. The lab runs it as the reference, and ncu shows you how it does it: typically two kernels, one that reduces tiles to partial sums and a single-block kernel that finishes. In production you call the library; you write your own when the reduction must live inside another kernel.

Your move: before running make bench, sketch the curve: GB/s for v1…v6 and CUB, relative to your lab 0 ceiling.

The shape to expect, not exact numbers: v1, v2 and v3 close together and well short of the ceiling (same instructions and barriers per 1 KB; only their shared-memory traffic differs), v3 at most modestly ahead; a big jump at v4 as coarsening puts more bytes in flight and cuts the tree overhead eightfold; v5, v6 and CUB clustered just under your lab 0 practical ceiling, within a few percent of each other. If v5 and v6 are no faster than v4, that is not a failed optimization: it is the roofline. The profiler will still show them doing less work, and that difference will matter the day this code is fused with something else.

7. The transposes, under ncu

Chapter 3 predicted why the three transposes differ: uncoalesced writes in the naive one, a 32-way bank conflict in the unpadded tile, neither in the padded one. make ncu-transpose collects exactly the counters that test those claims, on a 4096×4096 matrix, each kernel launched once. Here is what the mechanisms predict (numbers per warp-wide request, from the sector and bank rules):

KernelGlobal ld sectors/requestGlobal st sectors/requestShared ld wavefrontsShared ld bank conflicts
copy440 (no shared memory)0
naive43200
shared, unpadded44≈ 16.8 M (32 per request)≈ 16.3 M (31 per request)
shared, padded44≈ 0.52 M (1 per request)≈ 0

Where the big numbers come from: 4096×4096 = 16.8 M floats are each read once out of the tile, 32 per warp-wide request, so there are 524,288 shared-memory load requests. Conflict-free, each is one wavefront (one pass through the banks). With 32 lanes on one bank, each needs 32 passes: 31 of them are counted as conflicts. The naive store needs 32 sectors per request because each lane writes a different row of the output, 16 KB from its neighbour (chapter 3’s scattered pattern).

Your move: the unpadded kernel’s DRAM traffic is already optimal (4 sectors per request both ways). Which Speed of Light bar and which stall reasons would betray its problem?

The memory bar would be high but it would be the L1/shared row driving it, not DRAM: the shared-memory pipeline is doing 32 passes per request. DRAM throughput stays below what the copy achieves. Among stalls, mio_throttle and short_scoreboard: warps waiting for a shared-memory queue that is clogged with serialized passes. This is why Speed of Light always has a breakdown: “memory-bound” is not one thing, and the fix depends on which memory.

Read your real numbers against this table. Small deviations are normal (a few requests at tile edges, the counters’ exact definitions). Large ones mean a claim in chapter 3 was wrong for your card, which would be worth knowing, and worth telling me.

8. The workflow

  1. Timeline first. Run the real workload under nsys. Is the GPU busy? If there are gaps, you are overhead-bound (chapter 1), and no kernel work will help until they are gone: fuse, batch, use graphs, move CPU work off the critical path.
  2. Pick the kernel with the largest total time in cuda_gpu_kern_sum. Amdahl decides where effort pays.
  3. Classify it with Speed of Light and chapter 4’s roofline: compute-bound, memory-bound (which memory?), or neither.
  4. Explain it: stall reasons, Memory Workload Analysis, the Source page. Find the line and the mechanism.
  5. Write down one hypothesis that predicts a number: “padding removes the conflicts, so shared wavefronts per request drop from 32 to 1 and time drops by about X.”
  6. Change one thing.
  7. Re-measure both: the benchmark (did it get faster?) and the metric (did it change for the reason you thought?). A speedup for the wrong reason is a coincidence you cannot build on.

And keep a log: kernel version, change, hypothesis, benchmark result, key metrics. It takes a minute per iteration, it is the raw material of the write-ups that will become your portfolio, and it is exactly what an interviewer means by “walk me through how you optimized something.”

9. Lab 4: profile it yourself

Lab 4Reductions, launch gaps, transposes under ncu~60 min · mlsys/labs/lab04-profiling/

Two programs: reduce (v1…v6 and CUB) and graphs (the launch-gap demo). Before profiling, make sure the Windows performance-counter switch is on (section 2).

cd ~/unibo-lessons/mlsys/labs/lab04-profiling
make test          # every reduction on ints (exact) and floats (tolerance); graph demo
make bench         # GB/s of v1..v6 and CUB; microseconds per kernel, three ways
make nsys          # timeline of the launch-gap demo + nvtx / API / kernel tables
make ncu-table     # this chapter's key metrics for every reduction, as text
make ncu-transpose # lab 2's transposes: sectors/request, wavefronts, conflicts
make ncu           # full report of every reduction: open it in the Windows GUI
make ncu KERNEL=reduce_v3   # ...or just one version
StepWhat to look atPrediction to write down first
benchGB/s per version vs your lab 0 ceilingthe curve from section 6
bench (graphs)µs per kernel for A, B, Cthe ranking and the A/C ratio (section 3)
nsysthe three NVTX ranges in the GUI; cuda_api_sumwhere the GPU row has gaps
ncu-tablewhich metric moves at each versionsection 6, version by version
ncu-transposethe four counters of section 7the table in section 7
ncu (GUI)v3 vs v4: Speed of Light, Warp State Statistics, Source pagewhich stall dominates each

The reports land in reports/; open them in the Windows GUIs through \\wsl$\. In the Nsight Compute GUI, select two results and use Add Baseline on the first: every metric of the second is then shown as a difference. That is the fastest way to see what one change did. Paste back the PASTE: lines and the text output of make ncu-table and make ncu-transpose.

Stretch

Make v5 fully warp-based: every warp reduces its 32 values with warp_sum, lane 0 of each warp writes one value to an 8-entry shared array, one barrier, and warp 0 reduces those 8. That is one barrier per block instead of three. Predict what changes in the profiler and what does not change in the benchmark, then check both. Second stretch: capture the whole reduction benchmark loop (memset + kernel) in a CUDA graph and see whether the graph matters for a kernel this long.

Check your understanding

You have a slow program. Which tool first, and what are you looking for?

Nsight Systems, on the real workload. You look for gaps in the GPU row (overhead-bound time, where no kernel optimization helps) and for the kernels with the largest total time, which are the only ones worth opening in Nsight Compute.

Why can a kernel’s duration under ncu differ from your benchmark, and which one do you trust for what?

ncu locks the clocks (to boost by default since 2026.1, to base in older versions), flushes the caches before each replay pass, and serializes kernels. Your benchmark runs at whatever clock the card picks, with warm caches. Use ncu’s durations to compare kernels within one report, and benchmarks or Nsight Systems for real-world time.

Speed of Light shows compute 20% and memory 88%. What do you do next?

Check which memory unit is at 88%. If it is DRAM and that is near your practical ceiling, the kernel is at its roofline: the only way forward is to move fewer bytes (fusion, lower precision, reuse), not to tune the kernel. If it is L1/shared, look for bank conflicts or excessive shared-memory traffic.

A memory-bound kernel at 90% of DRAM bandwidth has long_scoreboard as its top stall. Problem?

No. Waiting for DRAM is exactly what a kernel limited by DRAM does. long_scoreboard is only a problem when DRAM throughput is low: then warps are waiting on latency that is not being hidden, and the fix is more bytes in flight or better access patterns.

v1, v2 and v3 perform the same additions and compile to the same number of instructions. What differs between them in the profiler, and why?

Shared-memory traffic. v1’s adding lanes are spread over all warps, so many warps send nearly empty shared-load requests (about 4× more requests than v3, smsp__inst_executed_op_shared_ld_pred_on_any). v2 packs the lanes (few requests) but accesses words 2×stride apart, so its requests hit bank conflicts (l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld rises from zero) and need as many passes in total as v1. v3 has both few requests and no conflicts. Warp instructions executed stay the same in all three, because the compiler unrolled the loop and predicated the if.

Why is thread coarsening (v4) usually the biggest single improvement in the sequence?

v1–v3 pay a full tree (8 barriers, 8 rounds of shared-memory traffic, mostly idle threads) for every 1 KB of data, and each thread has only one load in flight. Coarsening sums 8 values per thread in a register first: 8× fewer trees and barriers per byte, and 8 independent loads in flight per thread, which is what keeps the DRAM pipe full.

v5 removes five barriers per block, but the benchmark barely moves. Why, and was it pointless?

v4 was already near the DRAM ceiling: time is set by bytes moved, and v5 moves the same bytes. The profiler still shows fewer barriers and shared-memory operations. That saved work pays off when the data comes from L2 or when the reduction is fused into a kernel that has other work to do.

What does a CUDA graph remove, and why is it a good fit for LLM decode?

It removes the per-launch CPU and driver cost: a recorded sequence of kernels is submitted with one call and runs back to back. It requires the same kernels, shapes and buffers every time, which is what a decode step is: the same few hundred kernels for every token. Engines capture a graph per batch size.

In the unpadded transpose, how many shared-memory load wavefronts per request do you expect, and how many bank conflicts?

32 wavefronts per request, of which 31 are counted as bank conflicts: all 32 lanes of the warp read the same bank (a column of a 32-word-wide tile). With one word of padding: 1 wavefront, no conflicts.

Why does a float reduction on the GPU give a slightly different answer from a CPU loop, and why is the GPU often closer to the truth?

Float addition is not associative, so a different order of additions rounds differently, and atomics make the order vary between runs. A tree adds values of similar magnitude, while a sequential loop keeps adding small values to a growing large sum, losing more of each addend’s bits. So the tree is usually more accurate.

Sources and further reading