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 identical 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. Now we turn those mechanics into an uncompromising measurement discipline built around five verification gates:
- Gate 1: Reference Verification. Prove numerical output matches an independent double-precision CPU reference within declared tolerances (), testing both power-of-two and arbitrary unaligned lengths.
- Gate 2: Compute Sanitizer. Verify runtime memory safety and synchronization integrity (
memcheck,racecheck,initcheck,synccheck), ensuring illegal memory accesses don't hide behind lucky allocations. - Gate 3: Clean Synchronized Timing. Measure steady-state GPU elapsed time using stream-bound CUDA events, warmups, and explicit L2 cache invalidation, isolating clean runs from profiler overhead.
- Gate 4: Hardware Roofline Bound. Compare measured memory traffic and compute throughput against the theoretical hardware ceilings, verifying the kernel doesn't violate physical bandwidth limits.
- Gate 5: Reproducible Receipt. Capture hardware state, GPU UUID, clocks, compiler flags, and raw sample hashes in an immutable record so any peer can reproduce the result.
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 a candidate replaces an existing triad_f32 implementation. Before opening a profiler, write the claim so another engineer could disprove it. The starter supplies a baseline and a deliberately incorrect variant, not two valid implementations with a measured speedup.
| 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 leaves 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.
Gate 1: Reference verification and numerical tolerance
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 to float, and compare every element. A fused multiply-add (FMA) rounds the multiplication and addition together rather than rounding their intermediate product. The CPU reference rounds in double and then converts to float; the GPU's fmaf rounds directly to float. Higher precision helps here, but two-stage rounding isn't a universal bitwise oracle.[1]
A checksum is too weak: two wrong values can cancel, and a single out-of-bounds write may miss sampled positions. Initialize output to a recognizable poison value before checking, so a skipped store can't inherit a previous correct result. Allocate each test's logical length, not a larger buffer that hides its tail overrun.
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 |
This CPU-only indexing exercise isolates the allocation trap. Python bounds checks stand in for the illegal access that a CUDA sanitizer should report; they don't simulate GPU execution. Predict why the same wrong guard behaves differently with spare allocation space.
1import math
2
3def write_indexes(n, allocated, inclusive=False):
4 output = [math.nan] * allocated
5 launched = ((n + 255) // 256) * 256
6 for i in range(launched):
7 if i < n or (inclusive and i == n):
8 output[i] = float(i) # Python raises on an allocation overrun.
9 return output
10
11n = 257
12correct = write_indexes(n, n)
13oversized = write_indexes(n, 512, inclusive=True)
14print("logical output matches:", oversized[:n] == correct)
15print("illegal extra store:", oversized[n])
16try:
17 write_indexes(n, n, inclusive=True)
18except IndexError:
19 print("exact allocation: tail overrun caught")
20print("empty input:", write_indexes(0, 0))1logical output matches: True
2illegal extra store: 257.0
3exact allocation: tail overrun caught
4empty input: []Gate 2: Compute Sanitizer and runtime memory safety
Reference comparison catches wrong values that reach output. Runtime tools catch illegal execution that happens to leave checked values unchanged. NVIDIA Compute Sanitizer provides four specialized checkers:[3]
memcheck: Detects out-of-bounds global, shared, and local memory accesses, misaligned memory instructions, and illegal device pointers.racecheck: Detects shared-memory data hazards (RAW, WAR, WAW hazards where threads in a block access the same shared memory location without synchronization).initcheck: Flags reads of uninitialized global memory, catching kernels that assume memory allocations come pre-zeroed.synccheck: Catches illegal barrier synchronization, such as calling__syncthreads()inside divergent branches where not all warps arrive.
Run memcheck first. NVIDIA specifically recommends running memcheck before tools such as racecheck and synccheck, because race checking doesn't validate underlying pointer validity.[3] Always configure --padding 32 to detect memory overruns that would otherwise land safely inside allocator alignment padding, and pass --error-exitcode 99 so automated test runners fail immediately when errors occur.

This sequence prevents an embarrassing trap. A profiler can report record-breaking memory throughput for a kernel that reads past buffer bounds. High hardware utilization never upgrades undefined behavior into a valid optimization.
Profiler scope: Nsight Systems versus Nsight Compute
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 supplies | What it can't establish alone |
|---|---|---|---|
| Why is the 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 system-level bottlenecks
Open a system trace before assuming the hottest-looking kernel owns application delay. Nsight Systems attaches lightweight runtime hooks (CUPTI and OS scheduler tracing) with low overhead (typically 1% to 5%). Look for large host-to-device transfers, CPU preparation gaps, stream serialization, blocking scalar reads, and GPU idle bubbles. 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 reveals that pattern immediately. It's correct for the isolated kernel-latency question, but it would distort a throughput experiment that expects multiple kernels or requests in flight. Scope determines whether synchronization is a measurement boundary or an application bottleneck.
Nsight Compute diagnoses kernel microarchitecture
Once triad_f32 is a known hotspot on the critical path, collect kernel metrics. Nsight Compute hooks directly into hardware performance monitoring units (PMUs) on the streaming multiprocessors. Its report sections cover launch configuration, memory workload analysis, GPU Speed of Light, and Roofline analysis.[5]
Its baseline feature compares two kernel runs side-by-side. However, microarchitectural profiling changes the physical execution conditions.
Profiler distortions: multi-pass replay and counter perturbation
Hardware performance counters on GPU streaming multiprocessors are physically limited. An SM can't record hundreds of architectural counters in a single execution pass. To assemble a comprehensive report across instructions, cache hierarchies, and pipeline stalls, Nsight Compute uses multi-pass kernel replay.
During replay, the profiler saves the GPU memory state and re-executes the exact same kernel dozens of times under different hardware counter configurations. By default, Nsight Compute also enforces --cache-control all, explicitly flushing L1 and L2 caches before every pass to ensure deterministic counter collection.
This mechanism introduces massive execution distortion:
- A 15-microsecond kernel can take hundreds of milliseconds to execute under profiling.
- Cache lines that would remain resident in a tight loop get evicted before each replay pass.
- Warp scheduling interleaving and memory controller queues get serialized to capture fine-grained stalls.
Never quote elapsed time from an instrumented profiler report as a clean benchmark. Profilers explain why code behaves as it does; clean, un-instrumented benchmark runs measure how fast it actually finishes.
Sanitizers run outside timing
Compute Sanitizer instruments memory instructions and barrier synchronizations, adding substantial overhead and serializing concurrent warps. A clean sanitizer report is correctness evidence, not a latency sample. Run sanitizers to verify safety, then close them completely before taking benchmark measurements.
⚠️ Common mistake: Running every tool simultaneously produces an expensive, distorted execution whose timing answers no valid question. Keep correctness verification, microarchitectural diagnosis, and clean benchmark timing as separate, linked runs.
Gate 3: Synchronized timing and benchmark state control
“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 device interval | One kernel on resident buffers, plus any gaps inside event boundaries | 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 |
Host versus device asynchrony: timing streams, not launch queues
CUDA kernel launches (<<<grid, block, shared, stream>>> or cudaLaunchKernel) are non-blocking asynchronous calls. When host code launches a kernel, the CPU constructs a launch descriptor packet, writes it into the driver's ring buffer (push-buffer in pinned memory), and returns control to the CPU thread immediately. This handoff typically takes 2 to 5 microseconds.
If an engineer wraps a host timer (std::chrono::high_resolution_clock or Python's time.perf_counter()) around a kernel launch without stream synchronization, the timer stops the instant the CPU finishes enqueuing the packet. The GPU hasn't even scheduled the blocks on SMs! The resulting "speedup" is merely a measurement of how fast the CPU pushed a descriptor into a queue.
CUDA events solve this by placing timestamp markers into the execution stream:
1cudaEvent_t start, stop;
2CUDA_CHECK(cudaEventCreate(&start));
3CUDA_CHECK(cudaEventCreate(&stop));
4
5CUDA_CHECK(cudaEventRecord(start, stream));
6launch_triad<<<blocks, threads, 0, stream>>>(...);
7CUDA_CHECK(cudaEventRecord(stop, stream));
8
9CUDA_CHECK(cudaEventSynchronize(stop));
10float elapsed_ms = 0.0f;
11CUDA_CHECK(cudaEventElapsedTime(&elapsed_ms, start, stop));Here is what happens in hardware:
cudaEventRecord(start, stream)writes an event marker into the stream FIFO.- When the GPU execution engine reaches that marker, it records the hardware clock timestamp into the event object.
- The kernel executes on the SMs.
- When the kernel completes all thread blocks, the GPU reaches
cudaEventRecord(stop, stream)and records the stop timestamp. cudaEventSynchronize(stop)halts the host thread until the GPU signals that the stop event has retired.cudaEventElapsedTime(&elapsed_ms, start, stop)calculates the true elapsed time directly from the hardware timestamps.
Coarse device synchronization (cudaDeviceSynchronize()) stalls every stream on the GPU, draining concurrent queues and killing pipeline overlap. Stream-specific event synchronization cleanly measures the targeted boundary without penalizing independent streams.

For tiny sub-microsecond kernels, time a batch of launches and divide by iteration count to amortize event overhead, keeping the batch size identical across comparisons. CUDA events provide roughly 0.5-microsecond resolution; zero or sub-microsecond measurements signal an insufficient measurement window rather than infinite speed.[6]
L2 cache invalidation: preventing residency bias
Modern GPU cache hierarchies feature substantial L2 caches:
- NVIDIA A100: 40 MB L2 cache
- NVIDIA H100: 50 MB L2 cache
- NVIDIA RTX 4090: 72 MB L2 cache
- NVIDIA B200: 60 MB L2 cache
Now consider the triad working set for single-precision floats. Each buffer (, , ) consumes MB. Three buffers require MB total.
That 12 MB working set fits entirely inside the GPU's L2 cache!
In a naive benchmark loop that repeatedly launches on the same buffer addresses:
- Launch 1 fetches data from HBM3 (yielding ~2.0 to 3.35 TB/s).
- Launches 2 through 200 find every single cache line already resident in L2!
- L2 cache delivers up to 12 TB/s of aggregate bandwidth on an H100.
- The benchmark prints an elapsed time of ~3 microseconds, implying an impossible GB/s of bandwidth.
An engineer rejoices: "Our kernel hit 4 TB/s on H100!" In reality, the kernel never touched high-bandwidth memory. It merely benchmarked L2 cache latency.
In production LLM serving or training pipelines, incoming user prompts and fresh activations stream from DRAM. They don't arrive pre-warmed in L2 cache. Benchmarking an un-rotated small buffer creates an artificial residency bias that vanishes in production.
Two techniques prevent L2 residency bias:
- Buffer Rotation: Allocate an array of distinct buffer triplets such that total memory footprint exceeds the L2 cache capacity (e.g. ). Cycle through
slot = i % slotson every iteration, forcing every launch to fetch from DRAM. - Cache Invalidation Sweep: If memory constraints prevent allocating multiple buffers, launch an explicit cache-clearing sweep kernel over a separate dummy buffer sized to L2 capacity between timed runs, evicting all cached lines back to DRAM.

Warm until mechanism is steady
Early iterations absorb CUDA context initialization, memory allocator pool growth, library loading, just-in-time compilation, page table mapping, and GPU clock frequency ramping.
Warmup iterations aren't arbitrary. Monitor latency, clocks, and temperature until they stabilize, then freeze the warmup count. Never discard slow samples that occur later in the run: thermal or power throttling during sustained execution is a physical hardware constraint, not an outlier.
Record clocks, power, and thermal state
GPU clocks dynamically adjust based on thermal headroom, power limits, and concurrent workloads. A candidate kernel measured after the card has heated up can appear slower even when its instruction stream is superior. NVIDIA System Management Interface (nvidia-smi) provides scriptable state queries:[7]
1nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csvOn dedicated hardware with administrator privileges, lock clocks during benchmarking to eliminate frequency jitter:
1sudo nvidia-smi --lock-gpu-clocks=1980,1980On shared cloud VMs where clock locking is restricted, query and log the actual observed clocks before and after every benchmark round. Ensure both baseline and candidate run within the same clock and thermal envelope.
Preserve the distribution with alternating paired rounds (ABBA)
Reporting only the minimum execution time rewards random noise. Reporting a single mean obscures bimodal behavior and tail latency. Preserve raw samples, reporting sample count, median, p10, p90, and the p10-to-p90 spread.
To prevent thermal drift or background host activity from favoring one implementation, alternate rounds in an A, B, B, A sequence. Within each round , compute the paired speedup:
Then evaluate the distribution of round-level speedups. If a candidate wins round 1 () but loses round 2 (), pooling all samples into a single average hides that the candidate regressed once the GPU reached thermal equilibrium.
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.
Gate 4: Hardware Roofline and Speed-of-Light bounds
The Roofline model bounds attainable performance by the smaller of the compute ceiling and the bandwidth ceiling scaled by arithmetic intensity:[8]
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. Compare byte counts and bandwidth at the same memory level: an L2 hit is still an input load, but not an HBM read. More than 12 HBM bytes per element can indicate transaction inefficiency or redundant traffic; fewer can indicate cache reuse or another traffic-reducing mechanism. Under a genuinely streaming regime, approaching both the useful-byte floor and the bandwidth roof leaves little for shared-memory staging to remove.
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, test this hypothesis:
- 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.
If the measurements don't support that sequence, revise the hypothesis before changing the kernel.
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.
Run the triad investigation: five gates in code
The CUDA lab requires an NVIDIA GPU, a compatible driver/toolkit, and the profiling tools named below. It wasn't compiled or run on CUDA during this review; the displayed output is an acceptance shape, not a hardware result. CPU-only checks can validate the arithmetic, host helper functions, and receipt parser, but can't establish device correctness or speed.
The file includes buggy and fixed kernels, deterministic inputs, a CPU reference, event timing, same-buffer and buffer-rotated modes, and raw-sample CSV output. Read it in three parts: kernels and checking helpers, setup and test modes, then the timed loop. --profile launches one fixed kernel as an isolated diagnostic, not a warmed benchmark.
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 <limits>
11#include <stdexcept>
12#include <string>
13#include <utility>
14#include <vector>
15
16static void cuda_check(cudaError_t status, const char* call) {
17 if (status != cudaSuccess) {
18 std::cerr << call << ": " << cudaGetErrorString(status) << "\n";
19 std::exit(1);
20 }
21}
22
23#define CUDA_CHECK(call) cuda_check((call), #call)
24
25__global__ void triad_buggy(
26 const float* x,
27 const float* z,
28 float* y,
29 std::size_t n,
30 float alpha
31) {
32 const std::size_t i = static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
33 if (i <= n) {
34 y[i] = fmaf(alpha, x[i], z[i]);
35 }
36}
37
38__global__ void triad_f32(
39 const float* x,
40 const float* z,
41 float* y,
42 std::size_t n,
43 float alpha
44) {
45 const std::size_t i = static_cast<std::size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
46 if (i < n) {
47 y[i] = fmaf(alpha, x[i], z[i]);
48 }
49}
50
51static void launch_triad(
52 bool buggy,
53 const float* x,
54 const float* z,
55 float* y,
56 std::size_t n,
57 float alpha,
58 cudaStream_t stream = nullptr
59) {
60 if (n == 0) {
61 return;
62 }
63 constexpr int threads = 256;
64 const int blocks = static_cast<int>((n + threads - 1) / threads);
65 if (buggy) {
66 triad_buggy<<<blocks, threads, 0, stream>>>(x, z, y, n, alpha);
67 } else {
68 triad_f32<<<blocks, threads, 0, stream>>>(x, z, y, n, alpha);
69 }
70 CUDA_CHECK(cudaGetLastError());
71}
72
73struct Verification {
74 std::size_t failures;
75 double max_abs;
76 double max_rel;
77};
78
79static Verification compare_outputs(
80 const std::vector<float>& x,
81 const std::vector<float>& z,
82 const std::vector<float>& actual,
83 std::size_t n,
84 float alpha
85) {
86 if (x.size() < n || z.size() < n || actual.size() != n || !std::isfinite(alpha)) {
87 throw std::invalid_argument("invalid reference inputs or output length");
88 }
89
90 constexpr double atol = 2e-6;
91 constexpr double rtol = 2e-5;
92 Verification result{0, 0.0, 0.0};
93
94 for (std::size_t i = 0; i < n; ++i) {
95 const double reference64 = std::fma(
96 static_cast<double>(alpha),
97 static_cast<double>(x[i]),
98 static_cast<double>(z[i])
99 );
100 const float reference = static_cast<float>(reference64);
101 if (!std::isfinite(reference) || !std::isfinite(actual[i])) {
102 ++result.failures;
103 result.max_abs = result.max_rel = std::numeric_limits<double>::infinity();
104 continue;
105 }
106 const double abs_error = std::abs(
107 static_cast<double>(actual[i]) - static_cast<double>(reference)
108 );
109 const double rel_error = abs_error / std::max(std::abs(static_cast<double>(reference)), 1e-30);
110 const double allowed = atol + rtol * std::abs(static_cast<double>(reference));
111
112 result.max_abs = std::max(result.max_abs, abs_error);
113 result.max_rel = std::max(result.max_rel, rel_error);
114 if (abs_error > allowed) {
115 ++result.failures;
116 }
117 }
118 return result;
119}
120
121static Verification verify(
122 const std::vector<float>& x, const std::vector<float>& z,
123 float* device_y, std::size_t n, float alpha
124) {
125 std::vector<float> actual(n);
126 if (n != 0) {
127 CUDA_CHECK(cudaMemcpy(actual.data(), device_y, n * sizeof(float), cudaMemcpyDeviceToHost));
128 }
129 return compare_outputs(x, z, actual, n, alpha);
130}
131
132static void fill_inputs(
133 std::vector<float>& x,
134 std::vector<float>& z,
135 unsigned int seed,
136 float alpha
137) {
138 for (std::size_t i = 0; i < x.size(); ++i) {
139 const int x_code = static_cast<int>((i * 29 + seed * 17) % 251) - 125;
140 const int z_code = static_cast<int>((i * 31 + seed * 13) % 239) - 119;
141 x[i] = static_cast<float>(x_code) / 63.0f;
142 z[i] = static_cast<float>(z_code) / 71.0f;
143 if (i % 4096 == 0) {
144 x[i] = 0.0f;
145 z[i] = 0.0f;
146 } else if (i % 4096 == 1) {
147 x[i] = 1.0f;
148 z[i] = -alpha;
149 } else if (i % 4096 == 2) {
150 x[i] = 1e10f;
151 z[i] = -1e10f;
152 }
153 }
154}
155
156static double percentile(const std::vector<float>& sorted, double q) {
157 if (sorted.empty() || !std::isfinite(q) || q < 0.0 || q > 1.0 ||
158 !std::is_sorted(sorted.begin(), sorted.end()) ||
159 std::any_of(sorted.begin(), sorted.end(), [](float v) { return !std::isfinite(v) || v <= 0; })) {
160 throw std::invalid_argument("expected sorted, finite positive samples and q in [0, 1]");
161 }
162 const double position = q * (sorted.size() - 1);
163 const auto lower = static_cast<std::size_t>(std::floor(position));
164 const auto upper = static_cast<std::size_t>(std::ceil(position));
165 return sorted[lower] + (position - lower) * (static_cast<double>(sorted[upper]) - sorted[lower]);
166}
167
168static std::string cuda_version(int encoded) {
169 return std::to_string(encoded / 1000) + "." + std::to_string((encoded % 1000) / 10);
170}
171
172int main(int argc, char** argv) {
173 const std::string mode = argc > 1 ? argv[1] : "--check";
174 const bool bench_hot = mode == "--bench-hot";
175 const bool bench_rotate = mode == "--bench-rotate";
176 const bool benchmark = bench_hot || bench_rotate;
177
178 if (
179 argc > 3 ||
180 (
181 mode != "--check" &&
182 mode != "--buggy" &&
183 mode != "--profile" &&
184 !benchmark
185 )
186 ) {
187 std::cerr << "usage: ./triad_bench [--check|--buggy|--profile|--bench-hot|--bench-rotate] [samples.csv]\n";
188 return 2;
189 }
190 if (argc == 3 && !benchmark) {
191 std::cerr << "a sample CSV path is valid only for benchmark modes\n";
192 return 2;
193 }
194
195 constexpr std::size_t n = 1'000'003;
196 constexpr float alpha = 1.25f;
197 const std::size_t bytes = n * sizeof(float);
198
199 cudaDeviceProp properties{};
200 CUDA_CHECK(cudaGetDeviceProperties(&properties, 0));
201 int runtime_version = 0;
202 int driver_api_version = 0;
203 CUDA_CHECK(cudaRuntimeGetVersion(&runtime_version));
204 CUDA_CHECK(cudaDriverGetVersion(&driver_api_version));
205 std::cout << "device=" << properties.name
206 << " compute_capability=" << properties.major << "." << properties.minor
207 << " l2_bytes=" << properties.l2CacheSize
208 << " cuda_runtime=" << cuda_version(runtime_version)
209 << " cuda_driver_api=" << cuda_version(driver_api_version) << "\n";
210
211 std::vector<float> host_x(n);
212 std::vector<float> host_z(n);
213 fill_inputs(host_x, host_z, 7, alpha);
214
215 int slots = 1;
216 if (bench_rotate) {
217 const std::size_t slot_bytes = 3 * bytes;
218 const std::size_t target_bytes = std::max(
219 8 * slot_bytes,
220 2 * static_cast<std::size_t>(properties.l2CacheSize) + slot_bytes
221 );
222 const std::size_t desired_slots = (target_bytes + slot_bytes - 1) / slot_bytes;
223
224 std::size_t free_bytes = 0;
225 std::size_t total_bytes = 0;
226 CUDA_CHECK(cudaMemGetInfo(&free_bytes, &total_bytes));
227 const std::size_t affordable_slots = std::max<std::size_t>(1, free_bytes / (4 * slot_bytes));
228 if (desired_slots > affordable_slots) {
229 std::cerr << "buffer rotation needs " << desired_slots * slot_bytes
230 << " bytes but conservative allocation budget is "
231 << affordable_slots * slot_bytes << " bytes\n";
232 return 1;
233 }
234 slots = static_cast<int>(desired_slots);
235 std::cout << "rotation_target_bytes=" << target_bytes
236 << " rotation_working_set_bytes=" << slots * slot_bytes
237 << " free_bytes=" << free_bytes
238 << " total_bytes=" << total_bytes << "\n";
239 }
240
241 std::vector<float*> xs(slots, nullptr);
242 std::vector<float*> zs(slots, nullptr);
243 std::vector<float*> ys(slots, nullptr);
244
245 for (int slot = 0; slot < slots; ++slot) {
246 fill_inputs(host_x, host_z, 7 + static_cast<unsigned int>(slot), alpha);
247 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&xs[slot]), bytes));
248 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&zs[slot]), bytes));
249 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&ys[slot]), bytes));
250 CUDA_CHECK(cudaMemcpy(xs[slot], host_x.data(), bytes, cudaMemcpyHostToDevice));
251 CUDA_CHECK(cudaMemcpy(zs[slot], host_z.data(), bytes, cudaMemcpyHostToDevice));
252 CUDA_CHECK(cudaMemset(ys[slot], 0xff, bytes)); // NaN poison catches omitted stores.
253 }
254 fill_inputs(host_x, host_z, 7, alpha); // Reference for slot zero.
255
256 if (mode == "--buggy") {
257 launch_triad(true, xs[0], zs[0], ys[0], n, alpha);
258 CUDA_CHECK(cudaDeviceSynchronize());
259 std::cout << "buggy launch completed; inspect it under Compute Sanitizer\n";
260 } else if (mode == "--profile") {
261 launch_triad(false, xs[0], zs[0], ys[0], n, alpha);
262 CUDA_CHECK(cudaDeviceSynchronize());
263 std::cout << "profile launch complete n=" << n << "\n";
264 } else if (mode == "--check") {
265 const std::vector<std::pair<std::size_t, unsigned int>> cases = {
266 {0, 7}, {1, 7}, {255, 7}, {256, 7}, {257, 7},
267 {n, 7}, {n, 19}, {n, 101},
268 };
269 for (const auto& [test_n, seed] : cases) {
270 fill_inputs(host_x, host_z, seed, alpha);
271 float *test_x = nullptr, *test_z = nullptr, *test_y = nullptr;
272 if (test_n != 0) {
273 const auto test_bytes = test_n * sizeof(float);
274 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&test_x), test_bytes));
275 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&test_z), test_bytes));
276 CUDA_CHECK(cudaMalloc(reinterpret_cast<void**>(&test_y), test_bytes));
277 CUDA_CHECK(cudaMemcpy(test_x, host_x.data(), test_bytes, cudaMemcpyHostToDevice));
278 CUDA_CHECK(cudaMemcpy(test_z, host_z.data(), test_bytes, cudaMemcpyHostToDevice));
279 CUDA_CHECK(cudaMemset(test_y, 0xff, test_bytes));
280 }
281 launch_triad(false, test_x, test_z, test_y, test_n, alpha);
282 CUDA_CHECK(cudaDeviceSynchronize());
283 const Verification correctness = verify(host_x, host_z, test_y, test_n, alpha);
284 std::cout << std::scientific
285 << "seed=" << seed
286 << " n=" << test_n
287 << " correctness=" << (correctness.failures == 0 ? "PASS" : "FAIL")
288 << " failures=" << correctness.failures
289 << " max_abs=" << correctness.max_abs
290 << " max_rel=" << correctness.max_rel << "\n";
291 if (test_n != 0) {
292 CUDA_CHECK(cudaFree(test_x));
293 CUDA_CHECK(cudaFree(test_z));
294 CUDA_CHECK(cudaFree(test_y));
295 }
296 if (correctness.failures != 0) {
297 return 1;
298 }
299 }
300 } else {
301 launch_triad(false, xs[0], zs[0], ys[0], n, alpha);
302 CUDA_CHECK(cudaDeviceSynchronize());
303 const Verification correctness = verify(host_x, host_z, ys[0], n, alpha);
304 std::cout << std::scientific
305 << "correctness=" << (correctness.failures == 0 ? "PASS" : "FAIL")
306 << " failures=" << correctness.failures
307 << " max_abs=" << correctness.max_abs
308 << " max_rel=" << correctness.max_rel << "\n";
309
310 if (correctness.failures != 0) {
311 return 1;
312 }
313
314 const int warmup = std::max(20, 2 * slots);
315 constexpr int samples = 200;
316 for (int i = 0; i < warmup; ++i) {
317 const int slot = i % slots;
318 launch_triad(false, xs[slot], zs[slot], ys[slot], n, alpha);
319 }
320 CUDA_CHECK(cudaDeviceSynchronize());
321
322 cudaEvent_t start{};
323 cudaEvent_t stop{};
324 CUDA_CHECK(cudaEventCreate(&start));
325 CUDA_CHECK(cudaEventCreate(&stop));
326 std::vector<float> sample_ms;
327 sample_ms.reserve(samples);
328
329 for (int i = 0; i < samples; ++i) {
330 const int slot = i % slots;
331 CUDA_CHECK(cudaEventRecord(start));
332 launch_triad(false, xs[slot], zs[slot], ys[slot], n, alpha);
333 CUDA_CHECK(cudaEventRecord(stop));
334 CUDA_CHECK(cudaEventSynchronize(stop));
335 float elapsed_ms = 0.0f;
336 CUDA_CHECK(cudaEventElapsedTime(&elapsed_ms, start, stop));
337 if (!std::isfinite(elapsed_ms) || elapsed_ms <= 0.0f) {
338 std::cerr << "invalid timing sample; increase the measurement window\n";
339 return 1;
340 }
341 sample_ms.push_back(elapsed_ms);
342 }
343
344 // Check every rotated slot after timing, outside all event intervals.
345 for (int slot = 0; slot < slots; ++slot) {
346 fill_inputs(host_x, host_z, 7 + static_cast<unsigned int>(slot), alpha);
347 const auto checked = verify(host_x, host_z, ys[slot], n, alpha);
348 if (checked.failures != 0) {
349 std::cerr << "post-timing verification failed for slot " << slot << "\n";
350 return 1;
351 }
352 }
353 std::cout << "post_timing_slots_checked=" << slots << "\n";
354
355 const std::string sample_path = argc == 3
356 ? argv[2]
357 : (bench_hot ? "samples-hot.csv" : "samples-rotate.csv");
358 std::ofstream sample_file(sample_path);
359 if (!sample_file) {
360 std::cerr << "could not open sample CSV: " << sample_path << "\n";
361 return 1;
362 }
363 sample_file << "sample,slot,elapsed_us\n" << std::fixed << std::setprecision(6);
364 for (int i = 0; i < samples; ++i) {
365 sample_file << i << "," << i % slots << "," << sample_ms[i] * 1000.0 << "\n";
366 }
367 sample_file.close();
368 if (!sample_file) {
369 std::cerr << "failed to persist complete sample CSV\n";
370 return 1;
371 }
372
373 std::vector<float> sorted_ms = sample_ms;
374 std::sort(sorted_ms.begin(), sorted_ms.end());
375 const double p10_ms = percentile(sorted_ms, 0.10);
376 const double median_ms = percentile(sorted_ms, 0.50);
377 const double p90_ms = percentile(sorted_ms, 0.90);
378 const double useful_gb_s = (3.0 * bytes) / (median_ms / 1000.0) / 1e9;
379
380 std::cout << std::fixed << std::setprecision(3)
381 << "regime=" << (bench_hot ? "same-buffer" : "buffer-rotated")
382 << " slots=" << slots
383 << " warmup=" << warmup
384 << " samples=" << samples
385 << " raw_samples=" << sample_path << "\n"
386 << "p10_us=" << p10_ms * 1000.0
387 << " median_us=" << median_ms * 1000.0
388 << " p90_us=" << p90_ms * 1000.0 << "\n"
389 << "useful_gb_s=" << useful_gb_s
390 << " (12 algorithmic bytes per element)\n";
391
392 CUDA_CHECK(cudaEventDestroy(start));
393 CUDA_CHECK(cudaEventDestroy(stop));
394 }
395
396 for (int slot = 0; slot < slots; ++slot) {
397 CUDA_CHECK(cudaFree(xs[slot]));
398 CUDA_CHECK(cudaFree(zs[slot]));
399 CUDA_CHECK(cudaFree(ys[slot]));
400 }
401 return 0;
402}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
2set -o pipefail
3nvcc -O3 -lineinfo -std=c++17 triad_bench.cu -o triad_bench && \
4 ./triad_bench --check | tee receipts/correctness.txtA correct run prints the target device and a complete elementwise check. The output below describes the required shape, not a recorded CUDA run. Inspect the actual error values rather than requiring the zeros shown here. This fixed-size starter isn't a general allocator or launch planner; larger shapes need device-limit and integer-overflow checks too.
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 --padding 32 ./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. Padding improves detection when adjacent allocations might otherwise hide an overrun. Without the sanitizer, the launch may appear to complete. Use the fixed mode with i < n, then require a clean report.[3]
1set -o pipefail
2compute-sanitizer --error-exitcode 99 --tool memcheck --padding 32 ./triad_bench --check 2>&1 \
3 | tee receipts/memcheck-fixed.txt1ERROR SUMMARY: 0 errorsRun initcheck when a candidate changes which global data is initialized or read; it doesn't require shared memory. Add racecheck for shared-memory communication and synccheck for synchronization changes. None replaces memcheck or numerical comparison.
Capture the system timeline next. Timings printed while the 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 full trace includes allocations, setup copies, the initial correctness readback, warmup, 200 timed launches, and final per-slot verification copies. Only the event-bounded sample region claims to exclude copies. Inside it, expect one launch and one event wait per sample; investigate long host gaps rather than mistaking setup or verification for timed work.
List the installed sections before collecting a kernel profile. Section sets vary by installation; don't assume a set named roofline exists. The command names four standard sections and makes cache flushing explicit. --clock-control none leaves existing clock policy unchanged.[9]
1ncu --list-sets
2ncu --list-sections
3ncu \
4 --section LaunchStats \
5 --section SpeedOfLight \
6 --section MemoryWorkloadAnalysis \
7 --section SpeedOfLight_RooflineChart \
8 --cache-control all \
9 --clock-control none \
10 --launch-count 1 \
11 --export receipts/triad_compute \
12 ./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 the achieved point, device-memory bytes, cache behavior, and SpeedOfLight sections together. Compare measured traffic per element with the 12-byte algorithmic count. Useful bandwidth above the device's HBM specification can reflect data served by cache, rather than impossible hardware performance.
This isolated, cache-flushed profile doesn't establish the residency of the clean same-buffer benchmark. Nsight Compute normally flushes caches between replay passes and may control clocks; replay mode also changes execution. For a cache-primed application, a separately designed experiment can use application replay with --cache-control none so its setup is repeated for each pass. Record these choices and never mix a cold profile's byte count with a hot run's elapsed time in one Roofline point.[5]
Finally collect clean timing outside tools:
1set -o pipefail
2nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csv \
3 | tee receipts/gpu-state-before.csv
4
5./triad_bench --bench-hot receipts/bench-hot-samples.csv | tee receipts/bench-hot.txt
6./triad_bench --bench-rotate receipts/bench-rotate-samples.csv | tee receipts/bench-rotate.txt
7
8nvidia-smi --query-gpu=timestamp,uuid,name,driver_version,pstate,clocks.sm,clocks.mem,temperature.gpu,power.draw,power.limit --format=csv \
9 | 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.
The post-timing check validates each slot's final output, not every intermediate launch. Keep adversarial correctness runs separate so an intermittent omitted store can't hide behind a previous correct value. Record nvcc, nsys, ncu, and compute-sanitizer versions alongside the exact commands; profiler section availability and supported metrics vary by tool and GPU.
--bench-hot is a convenient flag name, not evidence of cache residency: the program reports it as same-buffer. Percentiles use linear interpolation at index , so an even-count median averages the middle pair. Samples remain in acquisition order in the CSV. Match the selected CUDA device to its UUID, especially when CUDA_VISIBLE_DEVICES remaps ordinals; a GPU name alone isn't enough on a multi-GPU host.
Don't compare a hot baseline with a rotated candidate. Run both versions in the 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.
Gate 5: Reproducible receipts and machine-readable evidence
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 "binary_sha256": "<candidate binary SHA-256>",
8 "compiler": "nvcc <version>",
9 "flags": ["-O3", "-lineinfo", "-std=c++17"]
10 },
11 "baseline_source": {
12 "commit": "<baseline full commit SHA>",
13 "file_sha256": "<baseline source SHA-256>",
14 "binary_sha256": "<baseline binary SHA-256>"
15 },
16 "hardware": {
17 "gpu_uuid": "<GPU UUID>",
18 "gpu_name": "<exact model>",
19 "compute_capability": "<major.minor>",
20 "driver": "<driver version>",
21 "cuda_runtime": "<runtime version>",
22 "power_limit_w": "<watts>",
23 "clock_policy": "observed or locked",
24 "isolation": "<exclusive host, MIG slice, or competing-work policy>"
25 },
26 "workload": {
27 "operation": "y[i] = fmaf(1.25f, x[i], z[i])",
28 "dtype": "float32",
29 "n": 1000003,
30 "threads_per_block": 256,
31 "cache_regime": "buffer-rotated",
32 "rotation_slots": "<measured run value>"
33 },
34 "correctness": {
35 "reference": "CPU double FMA, one cast to float",
36 "atol": 0.000002,
37 "rtol": 0.00002,
38 "failures": "<measured count>",
39 "memcheck_errors": "<parsed count>",
40 "sanitizer_exit_status": "<actual exit status>",
41 "sanitizer_version": "<version>"
42 },
43 "measurement": {
44 "timer": "CUDA events in kernel stream",
45 "warmup": "<count and stability rule>",
46 "samples": 200,
47 "rounds": "<count>",
48 "order": "ABBA within each round",
49 "raw_samples": ["<round/block-specific baseline CSVs>", "<round/block-specific candidate CSVs>"],
50 "raw_sample_sha256": "<digest for each artifact>",
51 "quantiles": "linear interpolation at q*(N-1)",
52 "profiler_attached": false
53 },
54 "artifacts": {
55 "systems": "triad_systems.nsys-rep",
56 "compute": "triad_compute.ncu-rep",
57 "sanitizer": "memcheck-fixed.txt",
58 "gpu_state_before": "gpu-state-before.csv",
59 "gpu_state_after": "gpu-state-after.csv"
60 },
61 "result": {
62 "baseline_median_us": "<value>",
63 "candidate_median_us": "<value>",
64 "round_speedup_median": "<value>",
65 "round_speedup_interval": "<method and bounds>",
66 "end_to_end_effect": "<measured separately or explicitly out of scope>"
67 }
68}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 Python fixture below reads real temporary CSV files containing synthetic timings. It checks saved-byte hashes, sample order, positive finite durations, and matching comparison conditions before computing one round's ratio. It deliberately doesn't print “claim ready”: artifact integrity and a favorable ratio don't prove correctness, representative hardware conditions, or a repeatable improvement.
1import csv
2import hashlib
3import io
4import math
5from pathlib import Path
6from statistics import median
7from tempfile import TemporaryDirectory
8
9def read_samples(path, expected_sha256, slots):
10 if type(slots) is not int or slots < 1:
11 raise ValueError("positive slot count required")
12 data = Path(path).read_bytes()
13 if hashlib.sha256(data).hexdigest() != expected_sha256:
14 raise ValueError("sample artifact changed")
15 rows = csv.DictReader(io.StringIO(data.decode("utf-8")))
16 if rows.fieldnames != ["sample", "slot", "elapsed_us"]:
17 raise ValueError("unexpected CSV schema")
18 samples = []
19 for index, row in enumerate(rows):
20 if None in row or any(value is None for value in row.values()):
21 raise ValueError("malformed row")
22 if int(row["sample"]) != index or int(row["slot"]) != index % slots:
23 raise ValueError("sample sequence or rotation mismatch")
24 elapsed = float(row["elapsed_us"])
25 if not math.isfinite(elapsed) or elapsed <= 0:
26 raise ValueError("duration must be finite and positive")
27 samples.append(elapsed)
28 if len(samples) < 2:
29 raise ValueError("at least two samples required")
30 return samples
31
32def compare_pair(baseline, candidate):
33 required = {"n", "dtype", "cache_regime", "slots", "gpu_uuid", "timer"}
34 if set(baseline["conditions"]) != required or baseline["conditions"] != candidate["conditions"]:
35 raise ValueError("comparison conditions differ or are incomplete")
36 a, b = [read_samples(r["path"], r["sha256"], r["conditions"]["slots"])
37 for r in (baseline, candidate)]
38 if len(a) != len(b):
39 raise ValueError("different sample counts")
40 a_median, b_median = median(a), median(b)
41 ratio = a_median / b_median
42 if not all(math.isfinite(value) and value > 0 for value in (a_median, b_median, ratio)):
43 raise ValueError("invalid derived statistic")
44 return a_median, b_median, ratio
45
46conditions = {"n": 1_000_003, "dtype": "float32", "cache_regime": "buffer-rotated",
47 "slots": 2, "gpu_uuid": "fixture-only", "timer": "synthetic"}
48with TemporaryDirectory() as directory:
49 records = []
50 for name, values in (("baseline", [102, 101, 104, 100, 103]),
51 ("candidate", [82, 81, 83, 80, 82])):
52 path = Path(directory) / f"{name}.csv"
53 text = "sample,slot,elapsed_us\n" + "".join(
54 f"{i},{i % 2},{value}\n" for i, value in enumerate(values))
55 path.write_text(text, encoding="utf-8")
56 records.append({"path": path, "sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
57 "conditions": conditions.copy()})
58 a, b, ratio = compare_pair(*records)
59 print(f"synthetic medians: {a:g} us and {b:g} us; ratio {ratio:.2f}x")
60 print("timing direction:", "improvement" if ratio > 1 else "no improvement")
61 records[1]["path"].write_text("truncated", encoding="utf-8")
62 try:
63 compare_pair(*records)
64 except ValueError as error:
65 print("after artifact change:", error)1synthetic medians: 102 us and 82 us; ratio 1.24x
2timing direction: improvement
3after artifact change: sample artifact changedA hash binds a receipt to bytes; it doesn't prove those bytes came from a GPU. Collect hardware state, source identity, correctness logs, sanitizer exit status, and timing artifacts through a trusted harness. This small parser checks only timing-file integrity and declared conditions, not that entire collection process. Run it per matched round, then examine the round-level distribution.
The five verification gates in production review
Before submitting or merging any GPU optimization patch, run through the complete verification sequence:
- Gate 1: Reference Verification. Test numerical equivalence against a higher-precision reference on power-of-two, prime, and unaligned lengths () with explicit poison patterns.
- Gate 2: Compute Sanitizer. Run
compute-sanitizer --tool memcheck --padding 32 --error-exitcode 99to prove that no out-of-bounds reads or writes occur on any exercised path. - Gate 3: Synchronized Timing. Time completed GPU execution via stream-bound CUDA events (
cudaEventRecord), warm the card past initial JIT and driver overhead, invalidate L2 cache lines across iterations, and run alternating ABBA rounds. - Gate 4: Hardware Roofline Bound. Calculate arithmetic intensity and verify that measured memory bandwidth doesn't violate physical HBM limits. If bandwidth looks impossibly high, check for unevicted L2 cache hits.
- Gate 5: Reproducible Receipt. Persist the git commit SHA, binary hash, GPU UUID, driver version, clocks, power limits, raw sample CSVs, and sanitizer output in a structured
receipt.json.
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.
Follow-up questions
Why can an i <= n kernel pass an elementwise check for n=257 when its allocation has 512 elements?
Answer
It writes all 257 logical outputs correctly and also writes element 257. A comparison restricted to the logical output misses that extra store, while a memory checker sees an allocation-valid access. Use exact-sized allocations, output poisoning, and sanitizer padding to expose different parts of the failure.
A warm same-buffer run is fast, but a cache-flushed Nsight Compute report shows high HBM traffic. Can you divide that report's bytes by the warm event duration?
Answer
No. The numerator and denominator describe different cache regimes. Use traffic and duration from a consistent profiling experiment for its Roofline point, and report the clean benchmark separately. Record replay, cache, and clock controls so the difference is interpretable.