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.
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) | |
|---|---|---|
| Question | Where does the time go in the whole program? | Why is this one kernel as slow as it is? |
| View | A timeline: CPU threads, CUDA API calls, kernels, copies, your own labelled ranges | Hundreds of hardware counters for one kernel launch, grouped into sections with explanations |
| Cost | Low overhead: the program runs at nearly normal speed | High: each kernel is replayed many times to collect all counters |
| Finds | Gaps, launch overhead, synchronizations, copies on the critical path, which kernel dominates | Memory- vs compute-bound, coalescing, bank conflicts, occupancy, stall reasons, the hot source line |
| Chapter 1 regime | Overhead-bound | Compute- 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.
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:
nsys profile and ncu run your program and write a report file (.nsys-rep, .ncu-rep). Both also print text summaries, which is often all you need.\\wsl$\Ubuntu-24.04\home\…. Reports are portable: a report made anywhere opens anywhere.ncu work. Without it you get ERR_NVGPUCTRPERM.Three WSL-specific caveats, all from NVIDIA’s own documentation:
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.
A timeline has one row per thing that can be busy. The ones you will read every day:
cudaLaunchKernel bars are short; cudaDeviceSynchronize and cudaMemcpy bars are as long as the CPU was blocked.nvtxRangePushA("name") / nvtxRangePop(). They are how you find “the part where we do X” in a 30-second trace of a real engine. PyTorch, vLLM and llama.cpp all emit NVTX ranges.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.
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.
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.
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.
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:
profile mode that runs each kernel exactly once.--cache-control all), so every pass starts cold. A kernel that benefits from data left in L2 by the previous kernel looks slower under ncu than in your benchmark.--clock-control boost) locks the GPU to its boost clock; earlier versions locked it to the base clock. Your benchmarks run with whatever clock the card chooses at that moment. --clock-control none leaves the clocks alone.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.
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:
| Section | Answers |
|---|---|
| GPU Speed Of Light Throughput | How close did the kernel get to the compute limit and to the memory limit? The first thing to read. |
| Memory Workload Analysis | Traffic at each level (L1, shared, L2, DRAM), hit rates, sectors per request, shared-memory bank conflicts. |
| Occupancy | Theoretical vs achieved occupancy and which resource limits it (chapter 2’s calculation, done for you). |
| Scheduler Statistics | How often each scheduler had a warp ready to issue, and how often it issued nothing. |
| Warp State Statistics | Stall reasons: how many cycles, on average, warps spent waiting on each kind of thing (section 5). |
| Source Counters | Which 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.
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:
| Metric | Meaning | Seen in |
|---|---|---|
gpu__time_duration.sum | Kernel duration | everything |
dram__throughput.avg.pct_of_peak_sustained_elapsed | DRAM bandwidth used, % of peak | memory-bound kernels |
sm__throughput.avg.pct_of_peak_sustained_elapsed | Busiest SM pipeline, % of peak | compute-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 scattered | chapter 3, the transposes |
l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum (and _st) | Extra shared-memory passes caused by bank conflicts | v2, the unpadded transpose |
l1tex__data_pipe_lsu_wavefronts_mem_shared_op_ld.sum | Shared-memory passes (“wavefronts”) for loads: 1 per request if conflict-free | same |
smsp__average_thread_inst_executed_per_inst_executed.ratio | Average 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.sum | Shared-memory load instructions with at least one lane switched on: the ones that actually send a request | v1 vs v3 |
smsp__inst_executed.sum | Warp instructions executed: the total work, including overhead | every version |
sm__warps_active.avg.pct_of_peak_sustained_active | Achieved occupancy | chapter 2 |
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).
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:
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.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.
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:
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.
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.
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.
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.
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.
__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.
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.
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.
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.
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):
| Kernel | Global ld sectors/request | Global st sectors/request | Shared ld wavefronts | Shared ld bank conflicts |
|---|---|---|---|---|
| copy | 4 | 4 | 0 (no shared memory) | 0 |
| naive | 4 | 32 | 0 | 0 |
| shared, unpadded | 4 | 4 | ≈ 16.8 M (32 per request) | ≈ 16.3 M (31 per request) |
| shared, padded | 4 | 4 | ≈ 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).
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.
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.cuda_gpu_kern_sum. Amdahl decides where effort pays.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.”
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
| Step | What to look at | Prediction to write down first |
|---|---|---|
bench | GB/s per version vs your lab 0 ceiling | the curve from section 6 |
bench (graphs) | µs per kernel for A, B, C | the ranking and the A/C ratio (section 3) |
nsys | the three NVTX ranges in the GUI; cuda_api_sum | where the GPU row has gaps |
ncu-table | which metric moves at each version | section 6, version by version |
ncu-transpose | the four counters of section 7 | the table in section 7 |
ncu (GUI) | v3 vs v4: Speed of Light, Warp State Statistics, Source page | which 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
--clock-control boost default since 2026.1; WSL requirements).--trace, --cuda-graph-trace, nsys stats reports. Nsight Systems 2025.x release notes: the WSL timestamp known issue and its workaround.cub::DeviceReduce.ncu --query-metrics --chip gb205 (Nsight Compute 2026.3).