Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A patch arrives with one exciting line: triad_f32 is 1.8x faster. It passed on one input, and a host timer printed a smaller number. On another run, the advantage shrinks. On a length that isn't divisible by 256, one output near the end changes.
That isn't a performance result yet. A trustworthy claim must show that both kernels computed the same operation, timed completed GPU work under the same conditions, and improved the scope users care about. Profilers explain where time went. They don't repair a broken comparison.
The Accelerator Architecture Field Guide established streaming multiprocessors, high-bandwidth memory, caches, and hardware ceilings. CUDA for ML Training established grids, blocks, asynchronous launches, and event timing. Keep those mechanics. Now turn them into a measurement discipline.
One small kernel will carry the investigation. For every index , it computes a floating-point triad:
It looks too small to fail. That makes it useful: if the evidence is weak here, a fused attention or quantization kernel will hide the same mistakes behind far more code.
Turn “faster” into a testable claim
Suppose the candidate triad_f32 replaces a scalar baseline. Before opening a profiler, write the claim so another engineer could disprove it.
| Claim field | Concrete contract for the running kernel |
|---|---|
| Semantics | For each valid index, return fmaf(alpha, x[i], z[i]) within declared tolerance |
| Workload | float32, , deterministic bounded inputs |
| Scope | One completed kernel launch, excluding allocation and host-to-device copies |
| Cache regime | Report cache-hot and buffer-rotated measurements separately |
| Baseline | Same compiler flags, device, inputs, output check, and timing method; record each version's launch shape |
| Environment | GPU UUID, driver, CUDA runtime, source revision, clocks, power limit, and temperature |
| Decision | Keep candidate only if correctness gates pass and measured scope improves |
The odd length is intentional. With 256 threads per block, the last block contains threads whose indexes exceed . A boundary guard must reject them. Testing only lengths divisible by block size can leave an off-by-one bug invisible.
The arithmetic also supplies an early hypothesis. One triad element performs one multiply and one add, counted as two floating-point operations (FLOPs). Its algorithmic minimum traffic is two float32 loads and one float32 store, or 12 bytes:
That low arithmetic intensity suggests a bandwidth-sensitive kernel. It doesn't prove measured device-memory traffic equals 12 bytes per element, nor that high-bandwidth memory (HBM) is the current limit. Caches, transaction efficiency, launch overhead, and extra instructions can all move the measured point.
💡 Key insight: A performance hypothesis comes from operation and byte counts. A performance claim comes from a correctness-checked measurement on named hardware.
A cache-hot run processes 1,000,003 elements in 8.0 microseconds. Using the 12-byte algorithmic floor, what useful bandwidth does that imply, and why doesn't it prove HBM delivered that rate?
Answer
The calculation is , or about GB/s. That number counts algorithmic input and output bytes. It isn't a device-memory traffic counter, so cache hits can make useful bandwidth exceed the GPU's HBM specification without violating a hardware limit.
Prove output before studying speed
A trusted reference should be simpler than the candidate and independent enough to catch its mistakes. For triad, compute on the CPU in double, cast once to float, and compare every element. Both paths use a fused multiply-add (FMA), which rounds multiplication plus addition once rather than after each operation.[1] A checksum is too weak: two wrong values can cancel, and a single out-of-bounds write may miss sampled positions.
Floating-point equality needs a numerical contract. PyTorch's allclose rule is a useful statement of it:[2]
Here is the candidate output and is the reference. Relative tolerance scales with nonzero reference magnitude. Absolute tolerance protects values near zero, where relative error alone can explode. Neither constant is universal. Choose them from dtype, operation count, expected rounding, input range, and downstream quality requirements, then freeze them before comparing candidates.
For bounded float32 inputs and one fused multiply-add, this lab uses atol = 2e-6 and rtol = 2e-5. Those are acceptance settings for this workload, not recommended defaults for arbitrary kernels. A reduction over thousands of values needs different analysis because accumulation order changes rounding.
Exercise the contract across shapes and values that expose distinct failure modes:
| Test | Why it exists | Failure signature |
|---|---|---|
| and | empty and smallest legal work | launch or guard assumption |
| both sides of one block boundary | last-block indexing bug | |
| realistic rounded-up grid | rare tail corruption | |
| zeros and cancellation | reference values near zero | missing absolute tolerance |
| largest lab magnitude, | large-value rounding and cancellation | absolute- or relative-error spike |
| repeated seeds | varied values with reproducible inputs | input-sensitive mismatch |
Reference comparison catches wrong values that reach output. Runtime tools catch illegal execution that happens to leave checked values unchanged. NVIDIA Compute Sanitizer is a functional correctness suite: memcheck detects out-of-bounds and misaligned memory accesses, racecheck detects shared-memory hazards, initcheck detects uninitialized device-memory reads, and synccheck detects invalid synchronization use.[3]
Run memcheck first. NVIDIA specifically recommends that order before tools such as racecheck and synccheck, because those tools don't replace memory-access checking.[3]

The order prevents a familiar trap. A profiler can report excellent memory throughput for a kernel that reads one element past the buffer. High utilization never upgrades undefined behavior into a valid optimization.
Choose instrument by question
Start wide enough to see ownership of delay, then zoom into one proven hotspot. NVIDIA separates those scopes across Nsight Systems and Nsight Compute.[4][5]
| Question | Instrument | Evidence it can supply | What it can't establish alone |
|---|---|---|---|
| Why is application waiting? | Nsight Systems | CPU scheduling, CUDA API calls, transfers, kernels, streams, synchronization, idle gaps | exact instruction or memory limit inside one kernel |
| Why is this kernel slow? | Nsight Compute | launch resources, SpeedOfLight sections, cache and device-memory traffic, warp stalls, Roofline point | user-visible end-to-end latency or output correctness |
| Is CUDA execution legal? | Compute Sanitizer | invalid access, shared-memory hazard, uninitialized read, invalid barrier use | representative performance under normal execution |
| How long did completed device work take? | CUDA events or a synchronizing benchmark harness | device elapsed time for declared event boundaries | cause of the elapsed time |
Nsight Systems finds scope
Open a system trace before assuming the hottest-looking kernel owns the application delay. Look for large host-to-device copies, CPU input gaps, many tiny launches, stream serialization, blocking scalar reads, and idle GPU regions. A long kernel is actionable only if it lies on the critical path for the claim.
For this lab, event synchronization after every sample intentionally serializes launches. Nsight Systems should reveal that pattern. It's correct for the isolated kernel-latency question, but it would distort a throughput experiment that expects several kernels or requests in flight. Scope determines whether synchronization is a measurement boundary or a workload bug.
Nsight Compute explains one kernel
Once triad_f32 is a known hotspot, collect kernel metrics. Nsight Compute's report sections cover launch configuration, memory workload, GPU Speed of Light, and Roofline analysis. Its baseline feature can compare reports, but metric collection and kernel replay add overhead.[5]
Never quote elapsed time from a heavily instrumented profiler run as a clean benchmark. Profile to explain. Rerun outside the profiler to measure.
Sanitizers run outside timing
Compute Sanitizer changes execution cost and may serialize behavior. A clean sanitizer report is correctness evidence, not latency sample. Preserve its command, tool version, exit status, and report alongside performance artifacts.
⚠️ Common mistake: Running every tool at once produces one expensive, perturbed execution whose timing answers no clean question. Keep correctness, diagnosis, and measurement as linked but separate runs.
Read Roofline and SpeedOfLight without guessing
The Roofline model bounds attainable performance by the smaller of the compute ceiling and the bandwidth ceiling scaled by arithmetic intensity:[6]
Nsight Compute plots the achieved point against precision- and device-specific ceilings. The sloped region represents the memory-bandwidth bound; the flat region represents the peak-compute bound. The ridge is their intersection.[5]
For triad, algorithmic intensity begins near 0.167 FLOPs/byte. If the profiler reports substantially more than 12 bytes of device-memory traffic per element, the measured intensity is even lower than the algorithmic estimate. That gap points toward transaction inefficiency or redundant traffic. If measured traffic stays near the compulsory minimum and the point approaches the bandwidth roof, adding shared memory probably can't remove two input reads and one output write. It may add instructions instead.
Read several sections together:
| Nsight Compute evidence | Question answered | Sound next move |
|---|---|---|
LaunchStats | Did grid, block, registers, or shared memory constrain launch? | change launch resources only when metric identifies pressure |
SpeedOfLight | Which compute or memory paths approach throughput ceiling? | inspect saturated path and corroborating stalls |
MemoryWorkloadAnalysis | How much traffic reaches cache levels and device memory? | compare measured bytes with algorithmic minimum |
| Roofline chart | Does achieved point sit in bandwidth or compute region? | target bytes/reuse on slope; target math pipeline near flat roof |
One percentage isn't diagnosis. Low compute utilization can mean memory limit, too little parallel work, launch overhead, dependencies, or host starvation. High device-memory utilization can coexist with wasteful transactions. Roofline locates achieved point; system trace and detailed counters explain why it landed there.
For streaming triad, likely sequence is:
- Reference and sanitizer pass.
- Nsight Systems confirms kernel dominates selected scope rather than copies or host gaps.
- Nsight Compute shows low arithmetic intensity and strong device-memory pressure.
- Measured bytes per element are compared with 12-byte algorithmic floor.
- Optimization targets excess traffic or access order. Shared memory is rejected when it can't remove compulsory traffic.
That conclusion may sound uneventful. Avoiding a useless rewrite saves engineering time and keeps risk out of the hot path.
Nsight Compute places triad_f32 near the sloped bandwidth roof, but Nsight Systems shows that the GPU is idle for most of the request while the CPU prepares inputs. What can you conclude, and which scope should you optimize first?
Answer
You can conclude that the isolated kernel is probably bandwidth-limited under the profiled workload. You can't conclude that kernel tuning will materially improve request latency. The system trace identifies CPU preparation on the request's critical path, so measure and repair that wider bottleneck before spending effort on a kernel already near its roof.
Design benchmark around real regime
“Kernel time” still leaves choices. State whether the product reuses the same resident data, streams new buffers, overlaps launches, or includes transfers. Different scopes can all be legitimate, but they can't share one unlabeled number.
| Measurement question | Include | Exclude | Timing method |
|---|---|---|---|
| isolated launch latency | one kernel on resident buffers | allocation and copies | CUDA events around launch |
| steady pipeline throughput | representative sequence with normal overlap | one-time setup | events around batch or end-to-end synchronized wall clock |
| first request latency | compile, lazy initialization, allocation, and transfers that user experiences | unrelated process startup | synchronized end-to-end clock |
| cache-sensitive stream | rotated working set sized beyond relevant cache | accidental same-buffer reuse | events plus cache counters |
Synchronize boundaries, not every operation
CUDA launches are asynchronous with respect to the host. A CPU timer can stop after enqueue while the device still works. CUDA events record progress in a stream and can measure elapsed device time after the end event completes.[7]
For one launch:
- Finish unrelated setup before start event.
- Record start event in same stream as kernel.
- Launch kernel.
- Record end event.
- Wait for end event, then read elapsed time.
A device-wide cudaDeviceSynchronize() inside the timed region adds a boundary that the production path may not contain. Use an event dependency when it matches the question. For tiny kernels, time a batch of launches and divide when event overhead is large relative to the work, while preserving the same batch size for the baseline and candidate.
PyTorch's benchmark utilities address accelerator synchronization and warmup for similar reasons.[8] A harness is helpful, but its defaults still need to match intended scope.
Warm until mechanism is steady
Early iterations can include CUDA context creation, allocator growth, library initialization, just-in-time compilation, page mapping, cache fill, and clock ramp. Warmup count isn't magic. Watch latency, clocks, temperature, and selected algorithm until they stabilize, then record count and stopping rule.
Don't silently discard inconvenient later samples. Thermal or power throttling after warmup is part of the environment, not an outlier by definition.
Name cache policy
Reusing one buffer can make data cache-resident. Rotating across a working set larger than the relevant cache better represents streaming data. Neither regime is universally honest.

The plotted values are schematic, not benchmark results. In an actual run, the total rotated footprint should exceed the target cache by a deliberate margin, and Nsight Compute cache metrics should confirm the assumed regime. Allocation size alone can't prove every access missed the cache.
Record clocks, power, and thermal state
GPU clocks respond to idle state, temperature, power limit, and other work. A candidate measured after card heats up can look slower even when its code is better. NVIDIA System Management Interface (nvidia-smi) exposes scriptable queries, supported clock controls, and power-limit state.[9]
Capture the state before and after each comparison:
1nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csvOn isolated, administrator-controlled hardware, supported clock locking can reduce variance. It requires privileges and differs by GPU. Users on a shared fleet should record observed clocks and power instead of changing machine policy. Either way, the baseline and candidate need the same power limit, thermal envelope, and competing-work policy.
Preserve distribution
Reporting only the minimum rewards noise. A single mean hides tails. Keep raw samples, then report at least the sample count, median, p10, p90, and p10-to-p90 spread. Compare the baseline and candidate in alternating rounds such as A, B, B, A so gradual clock or thermal drift doesn't always favor the same version.
Within each controlled round , compute speedup from like-for-like medians:
Then summarize the distribution of round-level speedups. State the number of rounds and a spread or interval instead of treating 200 adjacent launches on one thermal trajectory as 200 independent comparisons. Also report absolute time. A 2x speedup from 2 microseconds to 1 microsecond may disappear in an application dominated by launch, copies, or queueing.
Two controlled rounds produce baseline and candidate medians of (12, 10) and (9, 10) microseconds. What are the round speedups, and why is one pooled speedup inadequate?
Answer
The round speedups are and . The candidate wins one round and loses the other, so pooling samples into one number would hide a reversal that may come from drift or an unstable effect. Report both round-level results, inspect clocks and temperature, and collect more alternating rounds before making a speed claim.
Run the triad investigation
The lab uses one complete CUDA file. It includes buggy and fixed kernels, deterministic seeded inputs, a CPU reference, a tolerance check, event timing, cache-hot and buffer-rotated modes, and ordered raw-sample CSV output. --profile launches one fixed kernel so Nsight Compute doesn't collect hundreds of samples.
1#include <cuda_runtime.h>
2
3#include <algorithm>
4#include <cstddef>
5#include <cmath>
6#include <cstdlib>
7#include <fstream>
8#include <iomanip>
9#include <iostream>
10#include <string>
11#include <utility>
12#include <vector>
13
14static void cuda_check(cudaError_t status, const char* call) {
15 if (status != cudaSuccess) {
16 std::cerr << call << ": " << cudaGetErrorString(status) << "\n";
17 std::exit(1);
18 }
19}
20
21#define CUDA_CHECK(call) cuda_check((call), #call)
22
23__global__ void triad_buggy(
24 const float* x,
25 const float* z,
26 float* y,
27 std::size_t n,
28 float alpha
29) {
30 const std::size_t i = blockIdx.x * blockDim.x + threadIdx.x;
31 if (i <= n) {
32 y[i] = fmaf(alpha, x[i], z[i]);
33 }
34}
35
36__global__ void triad_f32(
37 const float* x,
38 const float* z,
39 float* y,
40 std::size_t n,
41 float alpha
42) {
43 const std::size_t i = blockIdx.x * blockDim.x + threadIdx.x;
44 if (i < n) {
45 y[i] = fmaf(alpha, x[i], z[i]);
46 }
47}
48
49static void launch_triad(
50 bool buggy,
51 const float* x,
52 const float* z,
53 float* y,
54 std::size_t n,
55 float alpha,
56 cudaStream_t stream = nullptr
57) {
58 if (n == 0) {
59 return;
60 }
61 constexpr int threads = 256;
62 const int blocks = static_cast<int>((n + threads - 1) / threads);
63 if (buggy) {
64 triad_buggy<<<blocks, threads, 0, stream>>>(x, z, y, n, alpha);
65 } else {
66 triad_f32<<<blocks, threads, 0, stream>>>(x, z, y, n, alpha);
67 }
68 CUDA_CHECK(cudaGetLastError());
69}
70
71struct Verification {
72 std::size_t failures;
73 double max_abs;
74 double max_rel;
75};
76
77static Verification verify(
78 const std::vector<float>& x,
79 const std::vector<float>& z,
80 float* device_y,
81 std::size_t n,
82 float alpha
83) {
84 if (n == 0) {
85 return {0, 0.0, 0.0};
86 }
87
88 std::vector<float> actual(n);
89 CUDA_CHECK(cudaMemcpy(
90 actual.data(),
91 device_y,
92 actual.size() * sizeof(float),
93 cudaMemcpyDeviceToHost
94 ));
95
96 constexpr double atol = 2e-6;
97 constexpr double rtol = 2e-5;
98 Verification result{0, 0.0, 0.0};
99
100 for (std::size_t i = 0; i < n; ++i) {
101 const double reference64 = std::fma(
102 static_cast<double>(alpha),
103 static_cast<double>(x[i]),
104 static_cast<double>(z[i])
105 );
106 const float reference = static_cast<float>(reference64);
107 const double abs_error = std::abs(
108 static_cast<double>(actual[i]) - static_cast<double>(reference)
109 );
110 const double rel_error = abs_error / std::max(std::abs(static_cast<double>(reference)), 1e-30);
111 const double allowed = atol + rtol * std::abs(static_cast<double>(reference));
112
113 result.max_abs = std::max(result.max_abs, abs_error);
114 result.max_rel = std::max(result.max_rel, rel_error);
115 if (!std::isfinite(actual[i]) || abs_error > allowed) {
116 ++result.failures;
117 }
118 }
119 return result;
120}
121
122static void fill_inputs(
123 std::vector<float>& x,
124 std::vector<float>& z,
125 unsigned int seed,
126 float alpha
127) {
128 for (std::size_t i = 0; i < x.size(); ++i) {
129 const int x_code = static_cast<int>((i * 29 + seed * 17) % 251) - 125;
130 const int z_code = static_cast<int>((i * 31 + seed * 13) % 239) - 119;
131 x[i] = static_cast<float>(x_code) / 63.0f;
132 z[i] = static_cast<float>(z_code) / 71.0f;
133 if (i % 4096 == 0) {
134 x[i] = 0.0f;
135 z[i] = 0.0f;
136 } else if (i % 4096 == 1) {
137 x[i] = 1.0f;
138 z[i] = -alpha;
139 } else if (i % 4096 == 2) {
140 x[i] = 1e10f;
141 z[i] = -1e10f;
142 }
143 }
144}
145
146static double percentile(const std::vector<float>& sorted, double q) {
147 const auto index = static_cast<std::size_t>(q * (sorted.size() - 1));
148 return sorted[index];
149}
150
151static std::string cuda_version(int encoded) {
152 return std::to_string(encoded / 1000) + "." + std::to_string((encoded % 1000) / 10);
153}
154
155int main(int argc, char** argv) {
156 const std::string mode = argc > 1 ? argv[1] : "--check";
157 const bool bench_hot = mode == "--bench-hot";
158 const bool bench_rotate = mode == "--bench-rotate";
159 const bool benchmark = bench_hot || bench_rotate;
160
161 if (
162 argc > 3 ||
163 (
164 mode != "--check" &&
165 mode != "--buggy" &&
166 mode != "--profile" &&
167 !benchmark
168 )
169 ) {
170 std::cerr << "usage: ./triad_bench [--check|--buggy|--profile|--bench-hot|--bench-rotate] [samples.csv]\n";
171 return 2;
172 }
173 if (argc == 3 && !benchmark) {
174 std::cerr << "a sample CSV path is valid only for benchmark modes\n";
175 return 2;
176 }
177
178 constexpr std::size_t n = 1'000'003;
179 constexpr float alpha = 1.25f;
180 const std::size_t bytes = n * sizeof(float);
181
182 cudaDeviceProp properties{};
183 CUDA_CHECK(cudaGetDeviceProperties(&properties, 0));
184 int runtime_version = 0;
185 int driver_api_version = 0;
186 CUDA_CHECK(cudaRuntimeGetVersion(&runtime_version));
187 CUDA_CHECK(cudaDriverGetVersion(&driver_api_version));
188 std::cout << "device=" << properties.name
189 << " compute_capability=" << properties.major << "." << properties.minor
190 << " l2_bytes=" << properties.l2CacheSize
191 << " cuda_runtime=" << cuda_version(runtime_version)
192 << " cuda_driver_api=" << cuda_version(driver_api_version) << "\n";
193
194 std::vector<float> host_x(n);
195 std::vector<float> host_z(n);
196 fill_inputs(host_x, host_z, 7, alpha);
197
198 int slots = 1;
199 if (bench_rotate) {
200 const std::size_t slot_bytes = 3 * bytes;
201 const std::size_t target_bytes = std::max(
202 8 * slot_bytes,
203 2 * static_cast<std::size_t>(properties.l2CacheSize) + slot_bytes
204 );
205 const std::size_t desired_slots = (target_bytes + slot_bytes - 1) / slot_bytes;
206
207 std::size_t free_bytes = 0;
208 std::size_t total_bytes = 0;
209 CUDA_CHECK(cudaMemGetInfo(&free_bytes, &total_bytes));
210 const std::size_t affordable_slots = std::max<std::size_t>(1, free_bytes / (4 * slot_bytes));
211 if (desired_slots > affordable_slots) {
212 std::cerr << "buffer rotation needs " << desired_slots * slot_bytes
213 << " bytes but conservative allocation budget is "
214 << affordable_slots * slot_bytes << " bytes\n";
215 return 1;
216 }
217 slots = static_cast<int>(desired_slots);
218 std::cout << "rotation_target_bytes=" << target_bytes
219 << " rotation_working_set_bytes=" << slots * slot_bytes
220 << " free_bytes=" << free_bytes
221 << " total_bytes=" << total_bytes << "\n";
222 }
223
224 std::vector<float*> xs(slots, nullptr);
225 std::vector<float*> zs(slots, nullptr);
226 std::vector<float*> ys(slots, nullptr);
227
228 for (int slot = 0; slot < slots; ++slot) {
229 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&xs[slot]), bytes));
230 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&zs[slot]), bytes));
231 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&ys[slot]), bytes));
232 CUDA_CHECK(cudaMemcpy(xs[slot], host_x.data(), bytes, cudaMemcpyHostToDevice));
233 CUDA_CHECK(cudaMemcpy(zs[slot], host_z.data(), bytes, cudaMemcpyHostToDevice));
234 }
235
236 if (mode == "--buggy") {
237 launch_triad(true, xs[0], zs[0], ys[0], n, alpha);
238 CUDA_CHECK(cudaDeviceSynchronize());
239 std::cout << "buggy launch completed; inspect it under Compute Sanitizer\n";
240 } else if (mode == "--profile") {
241 launch_triad(false, xs[0], zs[0], ys[0], n, alpha);
242 CUDA_CHECK(cudaDeviceSynchronize());
243 std::cout << "profile launch complete n=" << n << "\n";
244 } else if (mode == "--check") {
245 const std::vector<std::pair<std::size_t, unsigned int>> cases = {
246 {0, 7}, {1, 7}, {255, 7}, {256, 7}, {257, 7},
247 {n, 7}, {n, 19}, {n, 101},
248 };
249 for (const auto& [test_n, seed] : cases) {
250 fill_inputs(host_x, host_z, seed, alpha);
251 CUDA_CHECK(cudaMemcpy(xs[0], host_x.data(), bytes, cudaMemcpyHostToDevice));
252 CUDA_CHECK(cudaMemcpy(zs[0], host_z.data(), bytes, cudaMemcpyHostToDevice));
253 launch_triad(false, xs[0], zs[0], ys[0], test_n, alpha);
254 CUDA_CHECK(cudaDeviceSynchronize());
255 const Verification correctness = verify(host_x, host_z, ys[0], test_n, alpha);
256 std::cout << std::scientific
257 << "seed=" << seed
258 << " n=" << test_n
259 << " correctness=" << (correctness.failures == 0 ? "PASS" : "FAIL")
260 << " failures=" << correctness.failures
261 << " max_abs=" << correctness.max_abs
262 << " max_rel=" << correctness.max_rel << "\n";
263 if (correctness.failures != 0) {
264 return 1;
265 }
266 }
267 } else {
268 launch_triad(false, xs[0], zs[0], ys[0], n, alpha);
269 CUDA_CHECK(cudaDeviceSynchronize());
270 const Verification correctness = verify(host_x, host_z, ys[0], n, alpha);
271 std::cout << std::scientific
272 << "correctness=" << (correctness.failures == 0 ? "PASS" : "FAIL")
273 << " failures=" << correctness.failures
274 << " max_abs=" << correctness.max_abs
275 << " max_rel=" << correctness.max_rel << "\n";
276
277 if (correctness.failures != 0) {
278 return 1;
279 }
280
281 const int warmup = std::max(20, 2 * slots);
282 constexpr int samples = 200;
283 for (int i = 0; i < warmup; ++i) {
284 const int slot = i % slots;
285 launch_triad(false, xs[slot], zs[slot], ys[slot], n, alpha);
286 }
287 CUDA_CHECK(cudaDeviceSynchronize());
288
289 cudaEvent_t start{};
290 cudaEvent_t stop{};
291 CUDA_CHECK(cudaEventCreate(&start));
292 CUDA_CHECK(cudaEventCreate(&stop));
293 std::vector<float> sample_ms;
294 sample_ms.reserve(samples);
295
296 for (int i = 0; i < samples; ++i) {
297 const int slot = i % slots;
298 CUDA_CHECK(cudaEventRecord(start));
299 launch_triad(false, xs[slot], zs[slot], ys[slot], n, alpha);
300 CUDA_CHECK(cudaEventRecord(stop));
301 CUDA_CHECK(cudaEventSynchronize(stop));
302 float elapsed_ms = 0.0f;
303 CUDA_CHECK(cudaEventElapsedTime(&elapsed_ms, start, stop));
304 sample_ms.push_back(elapsed_ms);
305 }
306
307 const std::string sample_path = argc == 3
308 ? argv[2]
309 : (bench_hot ? "samples-hot.csv" : "samples-rotate.csv");
310 std::ofstream sample_file(sample_path);
311 if (!sample_file) {
312 std::cerr << "could not open sample CSV: " << sample_path << "\n";
313 return 1;
314 }
315 sample_file << "sample,slot,elapsed_us\n" << std::fixed << std::setprecision(6);
316 for (int i = 0; i < samples; ++i) {
317 sample_file << i << "," << i % slots << "," << sample_ms[i] * 1000.0 << "\n";
318 }
319
320 std::vector<float> sorted_ms = sample_ms;
321 std::sort(sorted_ms.begin(), sorted_ms.end());
322 const double p10_ms = percentile(sorted_ms, 0.10);
323 const double median_ms = percentile(sorted_ms, 0.50);
324 const double p90_ms = percentile(sorted_ms, 0.90);
325 const double useful_gb_s = (3.0 * bytes) / (median_ms / 1000.0) / 1e9;
326
327 std::cout << std::fixed << std::setprecision(3)
328 << "regime=" << (bench_hot ? "cache-hot" : "buffer-rotated")
329 << " slots=" << slots
330 << " warmup=" << warmup
331 << " samples=" << samples
332 << " raw_samples=" << sample_path << "\n"
333 << "p10_us=" << p10_ms * 1000.0
334 << " median_us=" << median_ms * 1000.0
335 << " p90_us=" << p90_ms * 1000.0 << "\n"
336 << "useful_gb_s=" << useful_gb_s
337 << " (12 algorithmic bytes per element)\n";
338
339 CUDA_CHECK(cudaEventDestroy(start));
340 CUDA_CHECK(cudaEventDestroy(stop));
341 }
342
343 for (int slot = 0; slot < slots; ++slot) {
344 CUDA_CHECK(cudaFree(xs[slot]));
345 CUDA_CHECK(cudaFree(zs[slot]));
346 CUDA_CHECK(cudaFree(ys[slot]));
347 }
348 return 0;
349}Compile with optimization and line information. Line information lets sanitizer and profiler reports point back to source without using debug build as performance binary:
1mkdir -p receipts
2nvcc -O3 -lineinfo -std=c++17 triad_bench.cu -o triad_bench
3./triad_bench --check | tee receipts/correctness.txtA correct run prints the target device and a complete elementwise check. The exact device name varies. These bounded inputs should normally agree bit for bit with the chosen fused reference, while the tolerance gate still records the acceptance contract:
1device=<your GPU> compute_capability=<major.minor> l2_bytes=<bytes> cuda_runtime=<major.minor> cuda_driver_api=<major.minor>
2seed=7 n=0 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
3seed=7 n=1 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
4seed=7 n=255 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
5seed=7 n=256 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
6seed=7 n=257 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
7seed=7 n=1000003 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
8seed=19 n=1000003 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00
9seed=101 n=1000003 correctness=PASS failures=0 max_abs=0.000000e+00 max_rel=0.000000e+00Now run the deliberately wrong i <= n guard under memcheck. --error-exitcode 99 makes a detected sanitizer error fail automation even when the target process exits cleanly, and pipefail keeps tee from hiding that status:
1set -o pipefail
2compute-sanitizer --error-exitcode 99 --tool memcheck ./triad_bench --buggy 2>&1 \
3 | tee receipts/memcheck-buggy.txtBecause the grid rounds up and , a thread with i == n executes. The report should identify an invalid global access in triad_buggy. Without the sanitizer, the launch may appear to complete. Replace the guard with i < n by using fixed mode, then require a clean report:
1set -o pipefail
2compute-sanitizer --error-exitcode 99 --tool memcheck ./triad_bench --check 2>&1 \
3 | tee receipts/memcheck-fixed.txt1ERROR SUMMARY: 0 errorsIf next candidate adds shared memory or barriers, extend gate with racecheck, initcheck, and synccheck. Current triad has neither shared-memory communication nor block barriers, so those tools shouldn't be added as ceremony.
Capture system timeline next. Timings printed while profiler is attached are diagnostic only:
1nsys profile \
2 --trace=cuda,osrt \
3 --sample=none \
4 --output=receipts/triad_systems \
5 ./triad_bench --bench-rotate receipts/nsys-diagnostic-samples.csvThe trace should show warmup launches followed by one launch and one event wait per sample. Unexpected copies or long host gaps would contradict the isolated resident-buffer claim.
Collect one kernel profile with Roofline set:
1ncu \
2 --set roofline \
3 --launch-count 1 \
4 --export receipts/triad_compute \
5 ./triad_bench --profileOn a locked-down host, ncu may return ERR_NVGPUCTRPERM. Counter-access policy blocked metric collection; the kernel didn't fail. Use an approved profiling host or ask an administrator to enable performance counters rather than changing privilege policy on a shared machine.[5]
Read achieved point, device-memory bytes, cache behavior, and SpeedOfLight sections together. Compare measured traffic per element with 12-byte algorithmic floor. A cache-hot run may report useful bandwidth above device HBM specification because useful-byte calculation counts algorithmic bytes while data is served by cache. That's evidence of different regime, not impossible hardware.
Finally collect clean timing outside tools:
1nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csv \
2 | tee receipts/gpu-state-before.csv
3
4./triad_bench --bench-hot receipts/bench-hot-samples.csv | tee receipts/bench-hot.txt
5./triad_bench --bench-rotate receipts/bench-rotate-samples.csv | tee receipts/bench-rotate.txt
6
7nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csv \
8 | tee receipts/gpu-state-after.csvThe starter warms for at least 20 launches and enough launches to touch every rotation slot twice. Treat that as an initial policy, not proof of steady state. Increase it if latency, clocks, temperature, or cache counters haven't stabilized on the target machine.
Don't compare hot baseline with rotated candidate. Run both versions in same regime and alternate order across rounds.
Diagnose contradictory evidence
GPU investigations often return signals that look inconsistent. Resolve by asking which scope each signal describes.
| Observation | Tempting conclusion | Better diagnosis | Next check |
|---|---|---|---|
| host timer is tiny | kernel is fast | timer stopped after enqueue | CUDA events or synchronized boundary |
| sanitizer fails but sampled outputs match | error is harmless | illegal access missed sampled state | reject candidate and repair bounds |
nvidia-smi utilization is high | kernel is efficient | GPU stayed busy doing some work | Nsight Systems critical path, then kernel counters |
| SpeedOfLight memory percentage is high | no optimization remains | one memory path is busy, perhaps wastefully | bytes per element and transaction efficiency |
| cache-hot timing wins | candidate is universally faster | benchmark changed residency regime | same-buffer versus rotated A/B under same policy |
| profiler duration is slower | optimization regressed | instrumentation or replay perturbed execution | clean rerun outside profiler |
| median improves while p90 worsens | clear win | distribution changed or throttling emerged | raw samples, clocks, power, temperature, paired order |
| kernel improves but request latency doesn't | profiler is wrong | optimized scope isn't end-to-end bottleneck | return to Nsight Systems and critical path |
Failure diagnosis should end with one controlled experiment. Changing block size, vector width, memory layout, and dtype together destroys attribution even if final number improves.
Preserve evidence receipt
A chat message saying “A is faster” isn't reproducible. Save a machine-readable receipt plus raw artifacts. At minimum, another engineer should know exactly what ran, where it ran, what passed, and which files support the claim.
1{
2 "claim": "candidate lowers buffer-rotated triad_f32 kernel latency versus baseline",
3 "source": {
4 "repository": "<repository URL>",
5 "commit": "<full commit SHA>",
6 "file_sha256": "<triad_bench.cu SHA-256>",
7 "compiler": "nvcc <version>",
8 "flags": ["-O3", "-lineinfo", "-std=c++17"]
9 },
10 "hardware": {
11 "gpu_uuid": "<GPU UUID>",
12 "gpu_name": "<exact model>",
13 "compute_capability": "<major.minor>",
14 "driver": "<driver version>",
15 "cuda_runtime": "<runtime version>",
16 "power_limit_w": "<watts>",
17 "clock_policy": "observed or locked",
18 "isolation": "<exclusive host, MIG slice, or competing-work policy>"
19 },
20 "workload": {
21 "operation": "y[i] = fmaf(1.25f, x[i], z[i])",
22 "dtype": "float32",
23 "n": 1000003,
24 "threads_per_block": 256,
25 "cache_regime": "buffer-rotated",
26 "rotation_slots": "<measured run value>"
27 },
28 "correctness": {
29 "reference": "CPU double FMA, one cast to float",
30 "atol": 0.000002,
31 "rtol": 0.00002,
32 "failures": 0,
33 "memcheck_errors": 0
34 },
35 "measurement": {
36 "timer": "CUDA events in kernel stream",
37 "warmup": "<count and stability rule>",
38 "samples": 200,
39 "rounds": "<count>",
40 "order": "ABBA within each round",
41 "raw_samples": ["baseline-rotate.csv", "candidate-rotate.csv"]
42 },
43 "artifacts": {
44 "systems": "triad_systems.nsys-rep",
45 "compute": "triad_compute.ncu-rep",
46 "sanitizer": "memcheck-fixed.txt",
47 "gpu_state_before": "gpu-state-before.csv",
48 "gpu_state_after": "gpu-state-after.csv"
49 },
50 "result": {
51 "baseline_median_us": "<value>",
52 "candidate_median_us": "<value>",
53 "round_speedup_median": "<value>",
54 "round_speedup_interval": "<method and bounds>",
55 "end_to_end_effect": "<measured separately or explicitly out of scope>"
56 }
57}Store reports with the source revision, not in a personal profiler workspace that disappears. Hash the source and raw sample files. If the workload contains proprietary input, archive the deterministic generator, shape distribution, and a redacted data fingerprint so the receipt remains useful without leaking data.
The small Python fixture below applies a release gate to one receipt. Its numbers are labeled fixture data; they aren't GPU measurements.
1from statistics import median
2
3receipt_fixture = {
4 "baseline_us": [102.0, 101.0, 104.0, 100.0, 103.0],
5 "candidate_us": [82.0, 81.0, 83.0, 80.0, 82.0],
6 "correctness_failures": 0,
7 "sanitizer_errors": 0,
8 "same_workload": True,
9 "same_environment": True,
10 "raw_samples_saved": True,
11}
12
13baseline = median(receipt_fixture["baseline_us"])
14candidate = median(receipt_fixture["candidate_us"])
15speedup = baseline / candidate
16gates = [
17 receipt_fixture["correctness_failures"] == 0,
18 receipt_fixture["sanitizer_errors"] == 0,
19 receipt_fixture["same_workload"],
20 receipt_fixture["same_environment"],
21 receipt_fixture["raw_samples_saved"],
22]
23
24print("fixture baseline median:", baseline, "us")
25print("fixture candidate median:", candidate, "us")
26print(f"fixture speedup: {speedup:.2f}x")
27print("claim ready:", all(gates))1fixture baseline median: 102.0 us
2fixture candidate median: 82.0 us
3fixture speedup: 1.24x
4claim ready: TrueFlip any gate to False. The speedup remains numerically the same, but the claim becomes unpublishable. That's intended behavior.
A defensible optimization loop
Keep the loop narrow:
- State claim. Name workload, baseline, scope, correctness, and environment.
- Prove semantics. Run reference comparison across adversarial shapes and values.
- Check execution. Require relevant Compute Sanitizer tools to pass.
- Find scope. Use Nsight Systems to locate delay on application critical path.
- Explain kernel. Use Nsight Compute and Roofline only for confirmed hotspot.
- Change one mechanism. Target measured traffic, launch cost, occupancy constraint, or math pipeline.
- Rerun clean. Warm, control cache regime, record clocks and power, preserve raw samples.
- Archive receipt. Link correctness, reports, environment, and result to exact source revision.
Optimization earns trust when the faster candidate remains the same program for approved inputs and the evidence survives another engineer's rerun. Kernel engineering starts after that foundation, not before it.
Mastery check
Evaluation rubric
- Foundational: Separate reference checking, sanitizer evidence, profiler diagnosis, and clean timing into distinct runs.
- Intermediate: Compute useful bandwidth from declared algorithmic bytes while distinguishing it from measured HBM traffic.
- Advanced: Design alternating benchmark rounds, interpret system and kernel profiles at their proper scopes, and defend a speedup from a reproducible evidence receipt.
Common pitfalls
- Treating a clean sampled output as proof that an illegal memory access is harmless.
- Comparing a cache-hot candidate with a buffer-rotated baseline.
- Publishing profiler duration as clean latency or pooling samples across drifting rounds.