Reading an Nsight Compute report
ncu may return no metrics when the driver limits counter access. It reports
ERR_NVGPUCTRPERM, in full: The user running <tool_name/application_name> does not have permission to access NVIDIA GPU Performance Counters or the Hardware Event System on the target device.
When the NVIDIA driver has RmProfilingAdminOnly: 1, only an administrator
can use performance counters. On a machine you administer, run sudo ncu or
change the driver setting.
This page ships two Nsight Compute reports and
their text exports at content/profiles/nsight-compute/. They capture day
16's naive and tiled matmul on a Tesla T4.
You can complete the lesson by reading those files. The CUDA 12.6 privileged
capture and the CUDA 13 ordinary-user permission failure are both listed in
evidence.
The three numbers, and the order they answer in
A full report has a dozen sections and several hundred metrics. Three of them decide what you do next, and the rest are for after you know which question you are asking.
One: speed of light. The GPU Speed Of Light Throughput section appears first. Start with two rows.
Compute (SM) Throughput is what fraction of the arithmetic pipelines' peak
rate the kernel reached. Memory Throughput is the same for the busiest
memory unit. A kernel high on the first and low on the second is
compute-bound; the other way round is
memory-bound; low on both means it is stalled on
latency and neither ceiling is the problem, which is the case
warp stall reasons exist to explain.
The section also prints DRAM Throughput and Duration as separate rows, and the
distinction between Memory Throughput and DRAM Throughput is the single
most misread thing in this report. Older Nsight Compute labels those two
headline rows Compute (SM) [%] and Memory [%], so a report captured a few
years ago reads differently from the one shipped here; a 2022 transcript
carrying the old spellings is at
https://stackoverflow.com/questions/74245226/nsight-compute-not-showing-achieved-occupancy-in-the-metrics
(checked 2026-08-30).
Two: the memory chart. Memory Workload Analysis is where the number
turns into a cause. In the graphical report it is a diagram of boxes and
arrows running from the kernel through the
L1 and L2 caches out to
global memory, each arrow labelled with the traffic
that crossed it. On the command line the same numbers arrive as rows:
Memory Throughput [Gbyte/s], L1/TEX Hit Rate, L2 Hit Rate.
The two counters that matter most here are the ones
day 11 already used,
l1tex__t_requests_pipe_lsu_mem_global_op_ld.sum for how many warp-wide load
instructions issued and l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum for
how many 32-byte sectors those instructions
pulled. Their ratio is sectors per
transaction. Compare that ratio and the total
sector count across the two kernels.
Three: achieved occupancy. The Occupancy section prints Theoretical Occupancy and Achieved Occupancy next to each other, plus the four block
limits (Block Limit SM, Block Limit Registers, Block Limit Shared Mem,
Block Limit Warps) that produced the theoretical figure. Theoretical
occupancy is arithmetic on the launch configuration and
you can get it before the kernel runs; achieved occupancy is the average
number of resident warps over the kernel's life and needs a
run.
When they differ, the launch could have filled the machine and the workload did not, and barriers, an uneven tail and one block taking longer than its neighbours are the usual reasons. Read it third, because it explains a gap rather than naming a bound.
Diagram: where a load instruction goes. Three horizontal bands. Each has a warp on the left, then a box for shared memory, a box for L1, a box for L2 and a box for DRAM to the right, with arrows carrying the traffic between them. Band 1, the naive kernel: every arrow from the warp runs past shared memory into L1 and on to L2. Caption "1,024 global loads per thread, two sectors each, 16,777,216 sectors across the grid." Band 2, the tiled kernel's global half: a thin bundle of arrows on the same path. Caption "64 global loads per thread, four sectors each, 2,097,152 sectors across the grid." Band 3, the tiled kernel's shared half: a thick bundle of arrows that stops at the shared memory box and never reaches L1 or L2. Caption "1,024 shared loads per thread, and not one of them fetches a sector." Alt text: "Tiling moves load instructions rather than removing them. The naive kernel issues 1,024 global loads per thread. The tiled kernel issues 64 global loads and 1,024 shared ones, and a shared load fetches no sector."
Why the naive matmul is not a coalescing problem
After day 11 the reflex on seeing a slow kernel is to suspect the access pattern, and here the reflex is wrong. Both matmuls are already coalesced.
The block is 16 by 16 with threadIdx.x fastest, so a warp of 32 lanes is two
rows of sixteen consecutive columns. Look at what that does to the naive
kernel's two loads:
acc += a[row * k + p] * b[p * n + col];
a[row * k + p] depends only on row, so the warp asks for two addresses,
one per half, and they land in two sectors. b[p * n + col] depends only on
col, so the warp asks for sixteen consecutive floats, which is 64 bytes
starting on a 64-byte boundary, and that is two sectors as well. Two sectors
per request is close to ideal.
Day 11's worst case was thirty-two.
The tiled kernel's global loads are worse on that ratio, four sectors per request rather than two, because each of the warp's two rows now reads a run of sixteen instead of sharing one. It is still eight times cheaper overall, because it issues sixteen times fewer of them.
The ratio alone does not show the cost. The naive kernel has the better sectors-per-request figure and moves eight times the sectors, and no amount of fixing the access pattern would have found that, because there was nothing wrong with the access pattern.
Capturing a report someone else can read
Full program in
code/day42-ncu/matmul_profile.cu.
The kernels are day 16's, unchanged. Four rules make the capture reproducible.
One size, and it divides the tile. 512 by 512 by 512 with a 16-wide tile means no bounds guard ever fires, so the report describes the steady-state shape rather than an edge case. Day 16 owns the ragged sizes and the guards they need. 512 cubed is also day 30's roofline row, which is what lets this report answer a question that day left open.
A fixed launch order, so the capture command can name a launch. Each
kernel launches fourteen times here: one correctness launch, three warm-ups,
ten timed. The reports profile the first timed launch, which is why the
capture passes --launch-skip 4.
That option counts only launches matching the kernel filter, so with one kernel named per run the four skipped are that kernel's own. The program prints the command with the number filled in from its own constants, so the README and the code cannot drift apart about which launch was captured.
The prediction is printed before the counters are read. These constants say what the memory rows should hold, and the derivation is in the comment above them:
constexpr size_t kSectorBytes = 32;
constexpr size_t kBlocks = (kDim / kTileDim) * (kDim / kTileDim);
constexpr size_t kWarps = kBlocks * ((kTileDim * kTileDim) / kWarpSize);
constexpr size_t kNaiveRequests = kWarps * 2 * kDim;
constexpr size_t kTiledRequests = kWarps * 2 * (kDim / kTileDim);
constexpr size_t kNaiveSectorsPerRequest = 2;
constexpr size_t kTiledSectorsPerRequest = 4;
constexpr size_t kNaiveSectors = kNaiveRequests * kNaiveSectorsPerRequest;
constexpr size_t kTiledSectors = kTiledRequests * kTiledSectorsPerRequest;
constexpr size_t kNaiveGlobalLoads = 2 * kDim;
constexpr size_t kTiledGlobalLoads = 2 * (kDim / kTileDim);
constexpr size_t kTiledSharedLoads = 2 * kTileDim * (kDim / kTileDim);
// A, B and C read or written once each. This is the floor on DRAM traffic,
// not a prediction of it: both kernels re-read A and B many times and the
// caches decide how much of that reaches memory.
constexpr size_t kCompulsoryBytes = 3 * kDim * kDim * sizeof(float);
The last three lines support this claim: tiling does not cut how many load instructions a thread issues. It moves them. Two loads per k step is two loads per k step either way.
The naive kernel issues 1,024 global loads per thread, while the tiled kernel
issues 64 global and 1,024
shared ones. The tiled kernel issues more load
instructions and is faster, because a shared-memory load fetches no sector
from L2. A static_assert ties the tiled kernel's shared-load count to the
naive kernel's global-load count, counted two different ways, because this
page's whole reading of the memory chart rests on them being equal.
Timings come from a plain run, counters from a profiled one. Nsight
Compute serializes the kernel it is collecting and replays it once per pass,
so a cudaEvent figure captured under ncu measures the profiler. Never take
both from the same run.
Note. On Turing the L1 data cache and shared memory are one 96 KB unit per SM, carved either 64 KB shared and 32 KB L1 or the other way round (https://docs.nvidia.com/cuda/turing-tuning-guide/index.html , checked 2026-08-30). The tiled kernel's shared reads therefore land in the same hardware its global reads left. Whether
L1/TEX Cache Throughputfalls, holds or rises is not obvious from the source, and this capture settles it.
Results
Measured. Tesla T4, driver 595.84, CUDA 12.6 (V12.6.85), profiled with
Nsight Compute 2024.3.2 under sudo, --clock-control base. Captured
2026-09-01 on the project's verification node; the transcript is in the
page's evidence file and both reports ship with their text exports at
content/profiles/nsight-compute/.
CUDA 13.0 (V13.0.88) re-verification on 2026-09-02, with driver 580.173.02
on the same Tesla T4, passed both kernels against the CPU reference. The
ordinary-user ncu attempt was blocked by ERR_NVGPUCTRPERM with
RmProfilingAdminOnly: 1, while the new Nsight Systems capture completed.
The counter table below remains the CUDA 12.6 privileged capture; no CUDA 13
counter values are claimed.
Rows and metric names are spelled as the report spells them, so a reader can search for either. The one derived line says so.
| Report row or metric | naive | tiled |
|---|---|---|
Compute (SM) Throughput |
60.78% | 72.67% |
Memory Throughput |
60.78% | 72.67% |
DRAM Throughput |
1.25% | 1.95% |
Duration |
1.18 ms | 744.64 us |
l1tex__t_requests_pipe_lsu_mem_global_op_ld.sum |
8,388,608 | 524,288 |
l1tex__t_sectors_pipe_lsu_mem_global_op_ld.sum |
16,777,077 | 2,083,878 |
| sectors per request, derived | 2.0 | 4.0 |
dram__bytes_read.sum + dram__bytes_write.sum |
4,741,440 B | 4,641,024 B |
L1/TEX Hit Rate |
87.58% | 5.13% |
L2 Hit Rate |
97.93% | 97.05% |
Theoretical Occupancy |
100% | 100% |
Achieved Occupancy |
95.29% | 94.94% |
The four predictions, against the card:
The request counts matched the derivation exactly and the sector counts came within 0.7 percent. 8,388,608 and 524,288 requests, to the digit. The sector counters read 16,777,077 against a derived 16,777,216 and 2,083,878 against 2,097,152, both a hair under, which is the counters seeing a handful of sectors served before they started counting rather than an error in the geometry. Sectors per request lands on 2.0 and 4.0 as derived.
Neither kernel is anywhere near DRAM bound, and day 30's flaw is
settled. Total DRAM traffic is 4.74 MB for the naive kernel and 4.64 MB
for the tiled one, within 1.6x of the 3,145,728-byte compulsory floor and
about 220 times below the 1,074.8 MB day 30 charged the naive kernel when
it computed bytes by hand. The row that read over a thousand percent of
the copy ceiling was counting reads the caches served: the report says
so directly, with DRAM Throughput under 2 percent for both kernels
while Memory Throughput sits over 60.
The memory chart separates the kernels and the duration does not.
Duration differs by 1.59x, sectors moved by 8.05x. A reader handed only
the times could not say why; a reader handed the sector counts can. The
L1/TEX Hit Rate row is the same story from the other side: the naive
kernel hits L1 on 87.58 percent of its sectors because a warp's row
re-reads are cache-resident, and the tiled kernel's global loads hit
L1 on 5.13 percent because shared memory already absorbed the reuse and
what is left is compulsory.
Achieved occupancy sits below theoretical for both, and lower for the tiled kernel by less than half a point. 95.29 against 94.94 percent. The direction held, the margin is nothing: two barriers per tile step cost this kernel almost no residency at this shape, and the prediction's reasoning bought more than the card charged.
Do not memorise the percentages. The useful parts across GPUs are the order you read the sections in and the fact that two of these rows answer different questions with the same word in them.
Run it yourself
Reading the shipped reports needs nothing: ncu --import works without
counters, because reading a report is not profiling. The published copies
live at content/profiles/nsight-compute/; the verification run captures
them into code/day42-ncu/profile/ and the build copies them over.
Capturing them needs performance-counter permission. On a system you
administer, use sudo ncu or the persistent driver setting in the README.
If you cannot collect counters, read the shipped .ncu-rep files. The
.nsys-rep in the same directory records an Nsight
Systems trace that does not need those counters.
See /setup/learn-cuda-without-a-gpu
if you do not have a GPU.
Exercise
Four questions, all answerable from this page and the shipped reports.
Time: 25 to 40 minutes. Submit: four answers, each naming the report row or the counter that settles it.
Check: the quiz in content/quizzes/day42.toml, marked in the browser,
with an explanation on every option. The four questions above have their
answers in the reveal below, and each one points at something you can look up
in profile/day42-naive.details.txt or profile/day42-tiled.details.txt
rather than at a feeling about how the hardware behaves. An answer that names
no row and no counter is not finished.
- Read the naive kernel's
Memory Throughputrow and itsDRAM Throughputrow. Day 30 called the same kernel bandwidth-bound. Are those two rows and day 30 talking about the same bandwidth? - The tiled kernel issues more load instructions per thread than the naive one and runs faster. Which row of the memory chart explains that, and which row cannot?
- Suppose the two kernels report the same
Theoretical Occupancyand differentAchieved Occupancy. Name two things on this page that could cause that gap and one that could not. - You are an ordinary user on a system where
RmProfilingAdminOnlyis 1. Which of the shipped captures can you make, and which is blocked?
Hint 1
Three questions ask you to distinguish two similar metrics. Identify which metric measures the hardware unit in question.
Hint 2
For question 2, count load instructions per thread for each kernel and then count sectors. The two counts move in opposite directions.
Solution
1. No. Memory Throughput in the speed-of-light section is the busiest
memory unit as a fraction of its own peak, and on this kernel that unit is
L1TEX, not DRAM. DRAM Throughput is a separate row and it is the one that
means what day 30 meant.
The obvious wrong answer is that both say "memory-bound", which is true of the word and false of the bottleneck.
Fixing DRAM traffic on a kernel that is saturating L1 buys nothing.
2. The sector count explains it; the request count cannot. The naive kernel issues 1,024 global loads per thread and the tiled kernel 1,088 loads in total, 64 global and 1,024 shared. Counting instructions, the tiled kernel does more work.
Counting sectors pulled through L1TEX from L2, it does an eighth as much, because a shared-memory load fetches no sector. The obvious wrong answer is "it issues sixteen times fewer loads", which is true only of the global ones and skips where the other 1,024 went.
3. Barriers and an uneven tail could; registers and shared memory could
not. The tiled kernel waits at two __syncthreads() per tile step, and the
last wave of blocks leaves part of the machine idle.
Registers and shared memory are exactly what Block Limit Registers and
Block Limit Shared Mem already priced into the theoretical figure, so if theoretical occupancy is
equal they are not what separated the achieved figures. The obvious wrong
answer is "the tiled kernel uses shared memory, so fewer blocks fit", which
the block-limit rows refute in the same section.
4. The .nsys-rep, not the .ncu-rep files. Nsight Systems tracing does
not need these performance counters, while Nsight Compute does. With
RmProfilingAdminOnly set to 1, an ordinary user gets ERR_NVGPUCTRPERM.
The report gives you sectors, hit rates, and occupancy. Your model of the kernel explains those values. Here, knowing that a warp covers two rows of sixteen columns tells you what to change.
Pitfalls
ncu prints no metrics and one error. The string is ERR_NVGPUCTRPERM,
in full: The user running <tool_name/application_name> does not have permission to access NVIDIA GPU Performance Counters or the Hardware Event System on the target device. Performance counters are admin-only under the
stock driver default RmProfilingAdminOnly: 1. Use sudo ncu where you have
root, set NVreg_RestrictProfilingToAdminUsers=0 where you own the machine,
and read the shipped report where you have neither.
You timed the kernel under the profiler. Nsight Compute replays the kernel
once per collection pass, so every clock in the program is measuring the
profiler. Run bare for times, run under ncu for counters, and say on the page
which run each number came from.
You read Memory Throughput as DRAM bandwidth. It is the busiest memory
unit against its own peak, and DRAM Throughput is the row you wanted. A
kernel can sit near the top of the first and near the bottom of the second,
which is exactly what a cache-resident working set looks like.
You profiled the first launch of a kernel. Lazy module loading is the default from CUDA 12.2 on Linux, so the first launch of each kernel pays its own load, and the report of that launch describes a cost you will never pay again. Skip past the warm-ups. Day 9 makes the same point about a timer.
You compared two reports captured at different clocks. --clock-control base pins the GPU clocks for the collection, and without it a card that was
warm for one capture and cold for the other gives you two rooflines rather
than one. Both captures here pass it.
You optimised for the occupancy row because the report prints it. Occupancy buys latency hiding and nothing else, and past the point where the latency is covered the registers it costs buy less. Day 45 tunes one kernel two ways to equal speed at different occupancies.
Go deeper
- Nsight Compute Profiling Guide, "Sections and Rules" and "Metrics Structure", for what each section collects and how a metric name is built: https://docs.nvidia.com/nsight-compute/ProfilingGuide/index.html (checked 2026-08-30)
- Nsight Compute CLI reference, for
--set,--kernel-name,--launch-skip,--launch-count,--clock-control,--exportand--import: https://docs.nvidia.com/nsight-compute/NsightComputeCli/index.html (checked 2026-08-30) - NVIDIA on
ERR_NVGPUCTRPERM, with the full string and the driver versions that introduced the restriction: https://developer.nvidia.com/nvidia-development-tools-solutions-err_nvgpuctrperm-permission-issue-performance-counters (checked 2026-08-30) - Bob Crovella, "Using Nsight Compute to Inspect your Kernels", which is where
the two
l1tex__t_*metric names on this page come from: https://developer.nvidia.com/blog/using-nsight-compute-to-inspect-your-kernels/ (checked 2026-08-30) - Programming Massively Parallel Processors, 4th edition, chapter 5, on the tiled matmul this report is about: https://shop.elsevier.com/books/programming-massively-parallel-processors/hwu/978-0-323-91231-0
Next
Day 43 changes this kernel in three steps. It grades each step with report ratios rather than absolute times, which vary by GPU.
Day 45 studies the occupancy row. Day 49 rebuilds day
30's roofline from dram__bytes.sum instead of a byte estimate, then computes
memory bandwidth and arithmetic
intensity from measured traffic.