Kernel Profiling & Roofline#
ppbcc profile runs a profiler over a set of benchmark executables, one
after another, consolidates the hardware counters into one tidy CSV, and draws a
roofline model from them. --profiler selects the backend:
ncu is the default and needs no instrumentation: it attaches from the
outside and replays every launch it sees. likwid reads the counters from
inside the application, around the regions marked in the benchmark source, which
keeps several kernels of one call together in a single row. All backends write
the same columns, so the CSV and the plot are identical in shape, and
--from-csv merges the tables of several of them into one roofline.
Note
Nsight Compute and LIKWID both read their counters through CUPTI, which
needs a current CUDA context. Executables whose paradigm builds its own
(*_ocl, *_boost, *_vulkan, *_slang_vulkan) or that never
reach the GPU (*_cpp) produce no report; they are reported as such and
skipped. ppbcc.profiling.nsys is the backend for those.
Building the executables for profiling#
A profiler replays every kernel launch it sees, so a benchmark that sweeps a
range of problem sizes and repeats each of them takes hours under ncu and
produces thousands of near-identical kernels. The
performance-portability-benchmark
repository therefore has a dedicated build switch:
cmake --preset cuda-llvm-profiling # = cuda-llvm plus -DPPB_PROFILING=ON
cmake --build build-cuda-llvm-profiling -j
PPB_PROFILING=ON reduces every executable to a single input running a
single iteration and repetition, so profiling one binary takes about a
second and every implementation is profiled on the same input:
Benchmark problem |
Input under PPB_PROFILING |
|---|---|
Vector addition |
\(10^8\) elements |
Matrix multiplication |
\(4096 \times 4096\) |
N-body simulation |
\(10^5\) particles |
Polyhedral gravity model |
|
It is the largest input except where that would defeat the profiler: one Nsight Compute replay pass at \(16384^2\) has to snapshot several GB around every launch, and Eros’ polyhedral kernels are only a few microseconds long — too short for a counter- or sample-based tool to resolve.
PPB_PROFILING=ON also switches on PPB_ENABLE_NVTX, which names the
kernels (see Regions below), and compiles the targets with line tables so a
profiler can map an instruction back to a source line. The optimization flags
are untouched: a roofline measured from a differently optimized binary
describes a different program.
Profiling#
As with Benchmarking, start with --dry-run:
ppbcc profile -b build-cuda-llvm-profiling -p src -r "polyhedral_.*" --dry-run
Drop it to profile. Each run writes <report-dir>/<executable>.ncu-rep next
to the Google-Benchmark report <report-dir>/<executable>.json — the profiler
does not know which paradigm a kernel was written in, so the JSON report
supplies the paradigm and the precision:
ppbcc profile -b build-cuda-llvm-profiling -p src -r "polyhedral_.*" \
-d profiling -H "NVIDIA RTX5080" -o Profiling_NVIDIA_RTX5080 --roofline
Existing reports are reused unless --force is given, and --skip-profile
runs nothing at all and only re-parses the reports already in --report-dir.
That splits nicely into a collection pass per toolchain (--no-csv, writing
into a shared --report-dir) and one consolidation pass over all of them.
Note
Google Benchmark re-executes its process to switch off address space
randomization, and Nsight Compute hangs on some binaries
(matMul_kokkos, matMul_omp) when it follows that exec. The
ncu backend therefore starts every run through setarch <arch> -R,
so the re-exec never happens. setarch ships with util-linux; where it
is missing, the run keeps the re-exec and a warning is logged.
-O aslr=on opts out.
What is measured#
The collected metric set follows Nvidia’s own roofline recipe:
Work is counted from executed SASS instructions, per precision: \(\mathrm{FLOP} = \mathrm{add} + \mathrm{mul} + 2 \cdot \mathrm{fma}\). All three precisions are always reported (
FLOP FP32,FLOP FP64,FLOP FP16);--precisionselects which one the roofline uses, withautofollowing the precision the binary was built with.Traffic is read at a selectable level of the hierarchy —
--memory-levelpicksdram(default),l2orl1.Both ceilings are measured, not looked up. Every
.peak_sustainedmetric is a per-cycle rate; multiplied with the clock the corresponding unit actually ran at during the kernel it becomes an absolute rate. The highest value observed across all profiled kernels becomes the roof.
The LIKWID backend#
LIKWID reads the counters through its NvMarker API, from regions that the benchmark source opens itself. Build the benchmark with the markers compiled in:
cmake --preset cuda-llvm-profiling -B build-likwid \
-DPPB_ENABLE_LIKWID=ON -DLIKWID_ROOT="$LIKWID_PREFIX"
cmake --build build-likwid -j
Then point ppbcc profile at it. LIKWID exposes no peak_sustained
counters, so the two ceilings have to be supplied; they are a property of the
hardware, and an ncu run on the same machine measures them:
ppbcc profile --profiler likwid -b build-likwid -p src -r "polyhedral_.*" \
-O lib="$LIKWID_PREFIX/lib" -d ../profiling-likwid \
--peak-performance 5.74e13 --peak-bandwidth 9.59e11 \
-H "NVIDIA RTX5080" --roofline
Three properties of LIKWID 5.5.1 are worth knowing.
The likwid-perfctr wrapper is bypassed. It programs the counters from a
separate process, which fails on Blackwell with
CUPTI_ERROR_INVALID_PARAMETER regardless of the CUDA version. ppbcc
therefore sets LIKWID_NVMON_GPUS, LIKWID_NVMON_EVENTS and
LIKWID_NVMON_FILEPATH and runs the binary directly, so the instrumented
process programs its own counters.
Every executable runs twice. The SMSP counters (floating-point instructions)
and the DRAM counter cannot be programmed in the same pass, so the events are
split into the two groups of
EVENT_GROUPS and merged afterwards.
The two runs do not cost the same. Programming three SMSP counters perturbs a
kernel far more than a single DRAM counter – on the 163842 matrix
multiplication the same region takes about 36s in the first group and 3.6s
in the second. The counters themselves are unaffected by that overhead, but the
region time is, so the reported duration is the smallest of the two, which is
the least perturbed measurement of the kernel. Run at -v to see the spread.
The counters are corrected by a factor of two. On this hardware LIKWID reports
every Nvidia counter at exactly half its true value. The factor was established
against kernels with an analytically known instruction count: for FADD,
FMUL, FFMA and DRAM_BYTES_SUM alike, ncu reproduces the analytic
value exactly and LIKWID returns half of it, independently of the counter domain
and of how many launches a region contains.
COUNTER_SCALE compensates for it. Note that
arithmetic intensity is a ratio of two equally scaled counters and is therefore
unaffected either way; only the absolute rates need the correction.
Warning
NvMarker needs a current CUDA context, so it sees the same paradigms Nsight Compute does. OpenCL and Vulkan build their own contexts and produce no marker file. The markers around those kernels are still compiled in, so the gap shows up as an explicit warning rather than silently.
The Nsight Systems backend#
--gpu-metrics-devices programs the GPU’s performance monitors in time-based
sampling mode: device-wide, no context filter, no kernel boundaries. That is
what makes it the only backend here that sees OpenCL and Vulkan. What it gives
up is attribution — a sample knows when, not which kernel — and the
benchmark buys that back: --trace=nvtx records the marked regions with the
same clock as the samples, so a window is named by the region it falls in and
the stretches where compute warps are in flight inside it are the kernel.
ppbcc profile --profiler nsys -b build-cuda-llvm-profiling -p src \
-r "matMul_ocl$" "matMul_vulkan$" -d ../profiling-nsys \
-O iterations=20 --peak-performance 5.74e13 --peak-bandwidth 9.592e11 \
--analytic-flop "matMul_.*=137438953472" -H "NVIDIA RTX5080"
Three options carry the method.
--analytic-flopThe metric sets available on consumer hardware carry pipe utilisations, not instruction counts, so the FLOP count has to come from the work model. It is not a guess where it matters: Nsight Compute counts exactly \(2 \cdot M \cdot N \cdot K\) for every CUDA-backed matrix multiplication.
-O iterationsA lazily initialising backend compiles pipelines and fills buffers on its first call, and the sampler cannot tell that from a kernel launch. Every binary is therefore profiled twice, at 1 and at N iterations; the difference over N-1 iterations cancels the setup both runs paid.
-O activity-ratioSome runtimes move buffers with a compute shader rather than a copy engine, which shows up as compute-active windows that are not the kernel — and, because the marked region encloses everything the benchmark times, inside the same region. A window below this fraction of the busiest one’s occupancy in that region is dropped.
--peak-bandwidth is required: the sampler reports DRAM traffic as a
percentage of peak, so the peak is what turns it into bytes. Both ceilings are
properties of the hardware, and an ncu run on the same machine measures them.
The Nsight Graphics backend#
GPU Trace reads the same performance monitors as Nsight Compute — as counter
sums rather than samples — and it is the one Nvidia tool that does so for a
Vulkan workload, which makes it the independent check on the sampled numbers.
Its unit of attribution is the trace: with no swapchain there are no frames,
and the per-regime table stays empty. The region is bounded by submit index
instead, and -O submit=auto traces the first -O probes submissions and
keeps the one with the most compute cycles. A Vulkan submission carries no NVTX
range, so this is the one backend whose rows are unnamed.
Warning
auto picks the busiest submission, which is the wrong one whenever the
first call also does the lazy initialisation — it is then longer than the
steady-state dispatch. Pass -O submit=<index> explicitly in that case.
Regions#
A profiler names a kernel whatever the compiler called it, which for several
paradigms is nothing useful: AdaptiveCpp launches four kernels all called
__acpp_sscp_kernel, Kokkos eleven called
Kokkos::cuda_parallel_launch_local_memory. The benchmark therefore names the
work itself. src/common/Marker.h brackets each phase with an NVTX range —
matmul for the matrix multiplication, init and evaluate for the
polyhedral gravity — and every backend reports which range a row belongs to in
the Region column. -DPPB_PROFILING=ON turns that on.
Everything outside those ranges is the runtime setting itself up: Kokkos’
architecture query, desul’s lock arrays, a framework’s buffer staging. Those
launches say nothing about the algorithm, so they are dropped before the CSV is
written; --all-kernels keeps them. An executable with no range at all is
kept unchanged, with a warning, since that means the binary was built without
PPB_ENABLE_NVTX rather than that its kernels are uninteresting.
--region restricts the plot to some of them; the CSV always keeps every
region:
# The polyhedral kernel without its one-off initialization.
ppbcc profile ... --roofline --region evaluate
Aggregation#
One region may launch several kernels. --aggregate decides what a point is:
sum(default)One point per implementation and region: work, traffic and kernel time of every launch in it are added up.
dominantOne point per implementation and region, its longest-running kernel only.
noneOne point per kernel launch.
Output#
The CSV holds one row per profiled kernel launch: the identifying columns
(problem, paradigm, precision, hardware, executable, region, kernel, grid/block
size),
the derived roofline quantities (duration, FLOP, traffic, arithmetic intensity,
performance, and both ceilings), and behind them every raw ncu metric.
Every quantity carries its unit in the header, e.g. Arithmetic Intensity
[FLOP/Byte] or dram__bytes.sum.peak_sustained [Byte/cycle]; --from-csv
reads files with and without units.
--skip-profile does not need the ncu CLI: without one on the PATH,
the reports are read through Nsight Compute’s ncu_report Python module,
which the macOS host application ships as well.
The roofline chart uses the same paradigm colors as P3 Analysis, so a point can be matched to its paradigm across both figures.