Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
You change four lines of CUDA, recompile, and watch the kernel runtime drop by 40%. It feels like an instant win until your verification pass flags thousands of stale NaN values across the output matrix. The optimization didn't win anything; it just computed garbage at breakneck speed.
Real GPU performance engineering isn't about guessing clever tricks or chasing microbenchmarks in isolation. It's a disciplined, evidence-driven loop: form a concrete hypothesis about hardware bottlenecks, implement a single causal code modification, prove bit-exact correctness against edge-case matrices, and verify that Nsight Compute counters move for the exact mechanical reason you predicted.
In this lab, you'll optimize an out-of-place single-precision () matrix transpose kernel. Transpose is the canonical proving ground for mastering GPU memory hierarchy because it performs zero arithmetic beyond indexing: exactly one read and one write per element. Every nanosecond spent in the kernel reflects global memory bus transactions, L1/L2 cache line utilization, shared memory bank conflicts, and instruction-level latency hiding.
Three prerequisites supply the vocabulary. CUDA for ML Training established grids, blocks, warps, asynchronous launches, and memory hierarchy. Accelerator Architecture Field Guide connected arithmetic intensity and resource ceilings to workload bounds. GPU Profiling, Correctness, and Benchmarking separated host timelines, kernel counters, and correctness evidence. Here those ideas control one kernel from first measurement through recovery.
Keep one experiment stable
The workload transposes an row-major float32 matrix into a separate output allocation. This is an out-of-place transpose; passing overlapping input and output would violate the contract. If lives at input[r * N + c], the output satisfies:
No floating-point arithmetic changes a value. The checker compares each float's 32-bit representation via bitwise equality, not a tolerance or checksum. It also reports maximum absolute error as a diagnostic; the mismatch count decides correctness. Positive and negative zero differ in bits even though their absolute difference is zero.
The harness limits N to 4096. In IEEE 754 single-precision float32, the significand has 24 bits of precision (23 stored bits plus 1 implicit leading bit). Integers from 0 through have exact, unique representations. For , the matrix contains exactly elements, meaning every single element receives a unique float value that avoids rounding ambiguity. Before each standalone validation launch, the harness fills the output with NaN bit patterns (0xff). A skipped store fails immediately instead of inheriting a correct value from a previous variant.
The supplied harness contains five variants:
| Variant | Purpose | Included in normal benchmark? |
|---|---|---|
copy | reads and writes every element without transposing, giving a same-harness memory-movement reference | yes |
naive | reads adjacent input words but writes the transpose with stride | yes |
tiled | stages a tile in shared memory so global reads and writes are adjacent | yes |
padded | changes shared tile to so column reads spread across banks | yes |
broken | removes required block barrier to create a shared-memory race | no, run deliberately |
copy isn't a transpose baseline. It performs a different operation with the same logical read and write volume. Treat it as context for the memory path, not as a denominator for a claimed transpose speedup.
Write the hypothesis before editing, reject incorrect candidates, then compare repeated timings under identical conditions. A surprising counter should change the next experiment, not become a story invented after the result.
Build the lab on an NVIDIA GPU
You need an NVIDIA GPU, a CUDA Toolkit with nvcc and Compute Sanitizer, and Nsight Compute's ncu command. Tool versions and GPU identity belong beside every result because compiler decisions, architecture, clocks, and profiler metric availability can change evidence.
Save the CUDA source and its host-checking header in the same directory. Save the recovery template for the deliberate failure later. The shell commands below target Bash on a CUDA-equipped machine. A CPU or Apple GPU can't execute these CUDA kernels; the later Python exercise checks indexing only.
Create an artifact directory and capture the environment. Keep pipefail enabled so a compiler or CUDA failure isn't hidden by a successful tee:
1mkdir -p artifacts/run-01
2set -euo pipefail
3nvidia-smi -L | tee artifacts/run-01/gpu.txt
4nvidia-smi | tee artifacts/run-01/nvidia-smi.txt
5nvcc --version | tee artifacts/run-01/nvcc.txt
6ncu --version | tee artifacts/run-01/ncu.txt
7compute-sanitizer --version | tee artifacts/run-01/compute-sanitizer.txt
8sha256sum transpose_lab.cu transpose_checks.hpp | tee artifacts/run-01/source.sha256Compile optimized C++17 code with line information for attribution. -Xptxas=-v reports kernel resource use, including registers per thread, static shared memory per block, and local memory spills. -arch=native targets the GPU visible during compilation; this binary isn't a portable multi-architecture artifact. For a separate build host, select an explicit architecture supported by that Toolkit and the target GPU.[1]
1nvcc -std=c++17 -O3 -arch=native -lineinfo -Xptxas=-v \
2 -o transpose_lab transpose_lab.cu \
3 2>&1 | tee artifacts/run-01/build.txtInspect the build log rather than guessing resource consumption:
- the compiler names each instantiated kernel;
transpose_tiled<0>reserves bytes of static shared memory;transpose_tiled<1>reserves bytes;- register count is recorded directly from
ptxas; - verify that
spill storesandspill loadsare both zero; - no compilation error is present.
First exercise boundary shapes, including tiles that aren't full. Each command must exit zero:
1for n in 1 7 31 32 33 65 257; do
2 ./transpose_lab all "$n" 1 > "artifacts/run-01/check-${n}.csv"
3doneThen run the fixed performance case for 100 measured launches per normal variant:
1./transpose_lab all 4096 100 \
2 | tee artifacts/run-01/benchmark.csvThe harness prints CSV columns variant, correct, mismatches, max_abs_error, average_ms, and effective_gb_s. Device and launch shape go to standard error. A fresh-buffer check runs before warmup and another after timing. Failure suppresses performance fields and returns exit code 2. The known-racy broken variant always returns 2 and is never timed, even if its sampled output matches. Runtime or argument errors return 1.
Pass conditions for the four normal variants are:
| Field | Required evidence |
|---|---|
correct | true for copy, naive, tiled, and padded |
mismatches | 0 |
max_abs_error | 0 |
average_ms | positive average CUDA-event interval after five warmup launches |
effective_gb_s | positive logical read-plus-write rate, interpreted only with profiler evidence |
Don't rank variants yet. A single process run can be affected by clocks, device temperature, competing work, or startup state. Repeat separate processes under the same conditions, alternate order by round, and preserve every row:
1{
2 echo 'variant,correct,mismatches,max_abs_error,average_ms,effective_gb_s'
3 for round in 1 2 3 4 5; do
4 if (( round % 2 == 1 )); then
5 order=(naive tiled padded)
6 else
7 order=(padded tiled naive)
8 fi
9 for variant in "${order[@]}"; do
10 ./transpose_lab "$variant" 4096 100 2>> artifacts/run-01/repeated-launches.txt | tail -n 1
11 done
12 done
13} | tee artifacts/run-01/repeated-benchmark.csvThese are repeated-buffer, warm-cache measurements, not streaming cold-DRAM measurements. The event interval excludes allocation, host transfers, and validation, but includes any device idle gaps between host-submitted launches. For tiny matrices, launch overhead can dominate. Profiler replay and cache-control settings can produce different cache conditions, so don't equate its counters with the unprofiled timing run automatically.
Compare repeated samples, not only the best row. Overlapping samples don't by themselves prove equality; report the variation and collect enough evidence to resolve a distinct difference. Don't claim a win from one favorable sample.
Read the naive memory path
CUDA coalesces a warp's global-memory requests into cache-line transactions that cover requested addresses. Modern NVIDIA architectures route global loads and stores through the L1/Texture cache and L2 cache down to DRAM channels. Hardware organizes memory traffic into 128-byte cache lines, which are subdivided into four 32-byte sectors.[2] When threads in a warp access consecutive words within an aligned 128-byte segment, the hardware services all 32 threads with a single 128-byte cache request.
The naive kernel reads input with ideal spatial order, then scatters that order on output. Here is the indexing logic from transpose_naive:
1__global__ void transpose_naive(float* output, const float* input, int n) {
2 const int x = blockIdx.x * kTile + threadIdx.x;
3 const int y = blockIdx.y * kTile + threadIdx.y;
4
5 for (int offset = 0; offset < kTile; offset += kBlockRows) {
6 if (x < n && y + offset < n) {
7 const size_t input_index = static_cast<size_t>(y + offset) * n + x;
8 const size_t output_index = static_cast<size_t>(x) * n + y + offset;
9 output[output_index] = input[input_index];
10 }
11 }
12}Look closely at what happens across a single warp (32 threads where threadIdx.y is constant and threadIdx.x runs from 0 through 31):
- Input read:
input_index = (y + offset) * n + x. Becausex = blockIdx.x * 32 + threadIdx.x, consecutive threads access consecutive float32 values in rowy + offset. The 32 threads request contiguous bytes. The memory subsystem serves all 32 lanes with a single 128-byte transaction (four adjacent 32-byte sectors). Coalescing efficiency is 100%. - Output store:
output_index = x * n + (y + offset). Herexmultiplies the stride . Lane 0 writes to row 0, Lane 1 writes to row 1, Lane 2 writes to row 2, and Lane 31 writes to row 31. For , consecutive lanes write to addresses spaced by bytes (). - The transaction explosion: Because each thread's 4-byte float lands in a completely different 32-byte sector and 128-byte cache line, the memory controller can't coalesce them. It issues 32 separate 32-byte sector transactions to write just 128 bytes of payload.
That means transferring 1024 bytes across the memory bus to commit 128 bytes of data, wasting 87.5% of requested transfer volume. The write pipeline chokes on memory controller queues, and kernel throughput collapses.
Transpose does almost no useful floating-point arithmetic. Its logical data volume per launch is one read and one write for every element:
For , that's bytes, or 128 MiB. The harness reports:
Effective bandwidth isn't physical DRAM traffic. Cache hits, excess transactions, error-correction traffic, and architecture-specific paths make hardware traffic differ. Use it as a workload-normalized rate for comparing the same operation on the same setup.
A transpose averages ms. What effective rate should the ledger record, and what hardware claim remains unsupported?
Answer
The logical volume is bytes. Dividing by seconds and then by gives about GB/s. That is a workload-normalized effective rate. It doesn't establish physical DRAM traffic or percentage of peak bandwidth without profiler counters and the matching hardware conditions.
Profile before editing
Nsight Compute groups kernel evidence into named sections. LaunchStats reports grid, block, registers, and shared memory. SpeedOfLight compares compute and memory resource throughput with device ceilings. MemoryWorkloadAnalysis traces traffic through the memory hierarchy. Occupancy reports active-warps capacity and its resource limiters.[3]
Collect the first measured naive launch, skipping its one validation launch and five warmups. The full section set can require replay, so use the unprofiled harness for timing. CLI filters and launch selection are documented separately from the profiling metrics.[4]
1ncu --set full \
2 --kernel-name-base function \
3 --kernel-name transpose_naive \
4 --launch-skip 6 \
5 --launch-count 1 \
6 --force-overwrite \
7 -o artifacts/run-01/naive \
8 ./transpose_lab naive 4096 1
9
10ncu --import artifacts/run-01/naive.ncu-rep --page details \
11 > artifacts/run-01/naive.txtRead the report in this order:
- Confirm kernel name, matrix size, block
32 x 8, and one profiled launch. - Check whether memory throughput is closer to its ceiling than compute throughput (
dram__throughput.avg.pct_of_peak_sustained_elapsed). - Inspect global load and store sector efficiency (
smsp__sass_average_data_bytes_per_sector_mem_global_op_st.pct). Intranspose_naive, store efficiency plunges near 12.5% because only 4 bytes are utilized per 32-byte sector. - Check static shared memory is zero for the naive variant.
- Read the occupancy limiter, but don't optimize it unless evidence connects the limiter to latency or throughput.
Low arithmetic intensity makes the memory path the primary suspect, not a confirmed bottleneck. Small launches can instead be dominated by overhead, and even large kernels can hit memory-instruction throughput before DRAM bandwidth. Roofline is a ceiling model, not a promise that every low-intensity kernel reaches peak bandwidth.[5] Use the profiler to identify which resource limits this launch.
Write one bound statement before editing:
1Workload: float32 transpose, N=4096, block=32x8
2Correctness: exact transpose, 0 mismatches
3Observed limiter: global memory store sector inefficiency (32 separate sector transactions per warp)
4Source evidence: SpeedOfLight memory bound; MemoryWorkloadAnalysis store sector efficiency ~12.5%
5Hypothesis: make output stores adjacent by staging a tile in shared memoryIf the report doesn't support a memory-path limit, revise the hypothesis before crediting a shared-memory rewrite with a speedup.
Coalesce both global directions
Shared memory is on-chip SRAM allocated per Streaming Multiprocessor (SM). It provides low latency and terabytes per second of aggregate bandwidth. The tiled transpose loads an input tile into shared memory with adjacent global reads. After a block barrier, threads read the tile with swapped indexes and write adjacent output words. Official CUDA guidance uses shared memory for this reordering pattern.[6]
The core kernel below accepts Padding as a compile-time tile-width change. Variant tiled instantiates Padding = 0:
1template <int Padding>
2__global__ void transpose_tiled(float* output, const float* input, int n) {
3 __shared__ float tile[kTile][kTile + Padding];
4
5 const int input_x = blockIdx.x * kTile + threadIdx.x;
6 const int input_y = blockIdx.y * kTile + threadIdx.y;
7
8 #pragma unroll
9 for (int offset = 0; offset < kTile; offset += kBlockRows) {
10 if (input_x < n && input_y + offset < n) {
11 tile[threadIdx.y + offset][threadIdx.x] =
12 input[static_cast<size_t>(input_y + offset) * n + input_x];
13 }
14 }
15
16 __syncthreads();
17
18 const int output_x = blockIdx.y * kTile + threadIdx.x;
19 const int output_y = blockIdx.x * kTile + threadIdx.y;
20
21 #pragma unroll
22 for (int offset = 0; offset < kTile; offset += kBlockRows) {
23 if (output_x < n && output_y + offset < n) {
24 output[static_cast<size_t>(output_y + offset) * n + output_x] =
25 tile[threadIdx.x][threadIdx.y + offset];
26 }
27 }
28}Notice the symmetry in coordinates:
- During input loading, thread
threadIdx.xreads global indexinput_y * n + input_xand writes totile[threadIdx.y + offset][threadIdx.x]. Becauseinput_xadvances withthreadIdx.x, global reads are 100% coalesced. __syncthreads()guarantees all 256 threads in the thread block finish writing totilebefore any thread reads from it.- During output storing, thread
threadIdx.xwrites to global indexoutput_y * n + output_x. Becauseoutput_x = blockIdx.y * kTile + threadIdx.x,output_xadvances withthreadIdx.x! The global store is now 100% coalesced into a single 128-byte cache transaction.
Keep the barrier outside bounds checks so every thread in the block reaches it, including threads processing edge tiles.
Check the edge-tile mapping without a GPU
For N=33, most threads in the last tile are out of bounds. Does an admitted output read still have a producer? Its shared coordinate is (threadIdx.x, threadIdx.y + offset). The corresponding input coordinate is (blockIdx.y * 32 + threadIdx.x, blockIdx.x * 32 + threadIdx.y + offset), exactly the transpose of the admitted output coordinate. The two bounds conditions agree.
This CPU model enumerates those coordinates, requires every shared read to have a producer, and checks that every output location is written exactly once. It also checks the bank arithmetic. It doesn't execute CUDA, model warp scheduling, or measure a memory transaction.
1from collections import Counter
2
3def check_mapping(n):
4 output = {}
5 for by in range((n + 31) // 32):
6 for bx in range((n + 31) // 32):
7 tile = {}
8 for ty in range(8):
9 for tx in range(32):
10 for offset in range(0, 32, 8):
11 row, col = by * 32 + ty + offset, bx * 32 + tx
12 if row < n and col < n:
13 tile[ty + offset, tx] = (row, col)
14 # All producer writes precede consumer reads in this model.
15 for ty in range(8):
16 for tx in range(32):
17 for offset in range(0, 32, 8):
18 row, col = bx * 32 + ty + offset, by * 32 + tx
19 if row < n and col < n:
20 assert (row, col) not in output
21 output[row, col] = tile[tx, ty + offset]
22 assert len(output) == n * n
23 assert all(value == (col, row) for (row, col), value in output.items())
24
25for n in (1, 7, 31, 32, 33, 65, 257):
26 check_mapping(n)
27print("seven square shapes: index coverage and transpose mapping pass")
28for stride in (32, 33, 34):
29 banks = Counter((lane * stride) % 32 for lane in range(32))
30 print(f"stride {stride}: {len(banks)} banks, {max(banks.values())} words per bank")
31assert 2 * 4096**2 * 4 == 134_217_728
32print(f"example effective GB/s: {134_217_728 / 0.000250 / 1e9:.1f}")1seven square shapes: index coverage and transpose mapping pass
2stride 32: 1 banks, 32 words per bank
3stride 33: 32 banks, 1 words per bank
4stride 34: 16 banks, 2 words per bank
5example effective GB/s: 536.9Run correctness and event timing first, then collect the profiler report:
1./transpose_lab tiled 4096 100 \
2 | tee artifacts/run-01/tiled-benchmark.csv
3
4ncu --set full \
5 --kernel-name-base function \
6 --kernel-name 'regex:transpose_tiled' \
7 --launch-skip 6 \
8 --launch-count 1 \
9 --force-overwrite \
10 -o artifacts/run-01/tiled \
11 ./transpose_lab tiled 4096 1Expected evidence shows two distinct shifts:
- global reads and writes now use adjacent lane addresses, curing global store serialization;
- the shared-memory column read triggers a severe bank conflict.
Shared memory is split into 32 hardware banks, each 4 bytes wide (32 bits). Successive 32-bit words map cyclically to successive banks: . When threads in a warp access distinct banks, requests complete in parallel in a single clock cycle. If multiple threads request different words from the same bank, the hardware serializes access across multiple cycles.
In the unpadded tile tile[32][32], row , column sits at 1D float offset . When reading down column , thread lane reads row :
Every single lane requests a distinct word from Bank . The hardware serializes the request into a 32-way bank conflict, taking 32 cycles instead of 1 cycle. The strided access penalty moved from global DRAM into on-chip shared memory.

Query the installed Nsight Compute metrics to inspect shared memory conflicts:
1ncu --query-metrics | rg 'bank_conflicts|shared.*conflict' \
2 | tee artifacts/run-01/bank-metrics.txtIn the detailed Nsight report for transpose_tiled<0>, look for l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum. The counter will report substantial shared load bank conflicts attributed directly to the line reading tile[threadIdx.x][threadIdx.y + offset].
Change the shared-memory stride
The next candidate changes only the tile declaration width:
1__shared__ float tile[32][33];By adding a single padding float to each row, the row stride increases from 32 to 33. The column element for lane at column now sits at word offset :
Because ranges from 0 through 31, generates a complete cyclic permutation of the integers 0 through 31. Lane 0 accesses Bank , Lane 1 accesses Bank , and Lane 31 accesses Bank .
Every lane hits a unique bank. The 32-way bank conflict vanishes completely, allowing all 32 lanes to complete their shared memory read in a single clock cycle. Padding doesn't alter matrix shape, output buffers, or logical global traffic. It changes address stride inside on-chip SRAM.[6]
Would float tile[32][34] remove the column conflict as completely as float tile[32][33]? Derive the bank pattern before answering.
Answer
No. A 34-word row gives . Because , lanes 0 through 15 reach 16 distinct banks, then lanes 16 through 31 repeat those exact same banks. Each bank receives two different words, leaving a two-way conflict. A 33-word row works because , so its stride is one modulo 32.
Check the padded result before timing or profiling it:
1./transpose_lab padded 4096 100 \
2 | tee artifacts/run-01/padded-benchmark.csv
3
4ncu --set full \
5 --kernel-name-base function \
6 --kernel-name 'regex:transpose_tiled' \
7 --launch-skip 6 \
8 --launch-count 1 \
9 --force-overwrite \
10 -o artifacts/run-01/padded \
11 ./transpose_lab padded 4096 1Compare identical evidence fields across tiled and padded:
| Evidence | tiled expectation | padded expectation | Decision use |
|---|---|---|---|
| exact transpose | 0 mismatches | 0 mismatches | reject either candidate that fails |
| logical global bytes | same | same | keeps rate comparable |
| global access order | adjacent reads and writes | adjacent reads and writes | confirms padding didn't regress coalescing |
| shared bank conflicts | column conflict present | conflict removed for shown mapping | tests causal hypothesis |
| static shared memory | 4096 bytes | 4224 bytes | feeds resource-limit check |
| event time | measured distribution | measured distribution | keep change only if repeat evidence improves |
Don't write that padding is faster without citing your hardware, software, shape, precision, timing method, baseline, and correctness record. Architecture and toolchain decide the size of the benefit, so a universal speedup claim would be unfounded.
A candidate is 6% faster, but its edit both pads the tile and changes the block from 32 x 8 to 32 x 16. What can the evidence ledger conclude, and how should the experiment recover?
Answer
The run shows that the combined candidate differs, but it can't attribute the change to padding or launch geometry. Restore 32 x 8 and measure padding alone against the fixed baseline. Then hold tile width fixed and test 32 x 16 separately. Each branch still needs the same exact-output, profiler, sanitizer, and repeated-timing gates.
Loop unrolling and instruction-level parallelism
Hardware needs work in flight to hide memory latency. On modern GPUs, fetching a line from off-chip DRAM takes between 200 and 800 clock cycles. There are two primary mechanisms to tolerate that latency: warp-level parallelism (having enough active warps on the SM to switch between) and instruction-level parallelism (ILP, having multiple independent instructions in flight within the same thread).
In transpose_tiled, each block contains threads, but the tile holds floats. Each thread processes four rows separated by kBlockRows = 8:
1#pragma unroll
2for (int offset = 0; offset < kTile; offset += kBlockRows) {
3 if (input_x < n && input_y + offset < n) {
4 tile[threadIdx.y + offset][threadIdx.x] =
5 input[static_cast<size_t>(input_y + offset) * n + input_x];
6 }
7}The #pragma unroll directive instructs the compiler to unroll all four iterations into straight-line SASS instructions. This transformation delivers two distinct benefits:
- Eliminating loop overhead: It removes induction variable increments, compare instructions, and branch jumps.
- Exposing independent loads to the scheduler: The compiler issues four independent global load instructions (
LDG.E) back-to-back before any dependent write to shared memory. The memory pipeline dispatches these four memory requests concurrently, overlapping their latency windows.
Unrolling increases register pressure. Each in-flight memory operation requires dedicated registers to hold target addresses and loaded values. Inspect the compiler resource output from nvcc -Xptxas=-v:
1ptxas info : Compiling entry function '_Z15transpose_tiledILi1EEvPfPKfi' for 'sm_89'
2ptxas info : Used 20 registers, 4224 bytes smem, 0 bytes spill stores, 0 bytes spill loadsIf register usage climbs too high (for example, above 32 or 48 registers per thread depending on architecture), the GPU can't fit as many concurrent warps on the SM. If registers exceed the allocation limit, the compiler spills variables to local memory (spill stores and spill loads). Local memory resides in off-chip DRAM, introducing cache thrashing and destroying throughput. Always verify that spill stores and loads remain zero.
Branch divergence is minimal in this kernel. For interior tiles where and , every thread in the warp evaluates the bounds check to true. On boundary tiles ( or ), threads beyond matrix limits take the false branch. Modern architectures with Independent Thread Scheduling execute divergent branches sequentially using thread masks, so keeping work outside bounds guards ensures maximum SIMT lane utilization.
Occupancy is a constraint, not a score
Occupancy is the ratio of active warps on a Streaming Multiprocessor (SM) to the hardware maximum. More resident warps can hide latency, but highest occupancy doesn't automatically deliver highest performance. Registers, shared memory, threads per block, and barriers can limit resident blocks, while forcing resource use down can introduce spills or extra instructions.[6][3]
The lab block uses 32 x 8 = 256 threads, or eight 32-thread warps. The concrete resource comparison across variants shows:
| Resource | naive | tiled | padded | What to inspect |
|---|---|---|---|---|
| threads per block | 256 | 256 | 256 | LaunchStats |
| static shared memory per block | 0 B | 4096 B | 4224 B | compiler output and LaunchStats |
| added shared memory from padding | 0 B | 0 B | 128 B | arithmetic plus report |
| registers per thread | compiler-specific | compiler-specific | compiler-specific | -Xptxas=-v and LaunchStats |
| resident blocks or warps | device-specific | device-specific | device-specific | Occupancy limiter |
The 128-byte increase from padding may leave occupancy unchanged or cross an allocation boundary for a particular resource configuration. Read the report. Don't infer resident blocks from source alone.
Use these diagnoses when evaluating profiler evidence:
| Profiler evidence | Likely constraint | Next experiment |
|---|---|---|
| low occupancy limited by static shared memory | too much per-block tile storage | reduce tile footprint or test smaller blocks, one change at a time |
| low occupancy limited by registers plus local-memory traffic | register pressure and spilling | reduce live state or revisit unrolling, then check instruction count |
| adequate occupancy but high memory throughput | workload remains bandwidth-bound | reduce transactions or bytes, not chase 100% occupancy |
| many waves but long barrier stalls | synchronization or imbalance | inspect work per warp and barrier placement |
| too few blocks to fill SMs | grid lacks parallel work | test larger workload or different decomposition |
Keep the padded variant when it removes the measured conflict and improves repeat timing without correctness or resource regression. Revert when the counter improves but end-to-end kernel timing doesn't. Counter movement isn't a product outcome by itself.
Deliberately remove the barrier
Performance edits often break synchronization before they break memory bounds. Variant broken uses the padded tile but omits __syncthreads() between shared writes and shared reads. Some warps read shared memory locations before producer warps finish writing them.
Run a smaller matrix under the tools. Compute Sanitizer documentation recommends running memcheck before racecheck because racecheck doesn't check invalid memory addresses.[7] The harness intentionally exits 2 for broken; capture that expected application failure without aborting the diagnostic sequence. Keep the full logs: --error-exitcode 99 applies to detected sanitizer errors when the application itself would otherwise succeed. A nonzero exit alone isn't evidence of a race: tool startup failures and application errors also fail the command. Identify the reported shared-memory hazard in the racecheck log.
1for tool in memcheck racecheck synccheck; do
2 if compute-sanitizer --tool "$tool" --error-exitcode 99 \
3 ./transpose_lab broken 33 1 \
4 > "artifacts/run-01/broken-${tool}.txt" 2>&1; then
5 echo "unexpected success for known-broken variant" >&2
6 exit 1
7 else
8 status=$?
9 echo "${tool} exit=${status}" | tee -a artifacts/run-01/broken-exits.txt
10 fi
11doneExpected diagnostic split:
memcheckreports no illegal addresses because every index stays within allocation bounds;racecheckreports shared-memory read-after-write (RAW) hazards around the missing barrier;synccheckreports no misuse because the kernel omitted a barrier instead of executing an invalid barrier;- output checker may fail, but an accidental correct output on one run doesn't clear the race.
That last point is critical. Scheduler timing can hide data races during an ordinary run. Dynamic race evidence invalidates a kernel even when sampled output happens to match.
Recover by restoring the block barrier. Validate every candidate you intend to keep, including a partial tile and a full-tile case. These checks run outside the performance measurement:
1for variant in copy naive tiled padded; do
2 for n in 33 1024; do
3 for tool in memcheck racecheck synccheck; do
4 compute-sanitizer --tool "$tool" --error-exitcode 99 \
5 ./transpose_lab "$variant" "$n" 1 \
6 > "artifacts/run-01/${variant}-${n}-${tool}.txt" 2>&1
7 done
8 done
9done
10
11./transpose_lab padded 4096 100 \
12 | tee artifacts/run-01/recovered-benchmark.csvThe recovery record connects the symptom to the ordering requirement:
| Field | Minimum content |
|---|---|
| failure | missing block barrier between shared writes and cross-warp reads |
| detection | racecheck hazard plus output result, whether pass or fail |
| repair | restored __syncthreads() reached by every thread in block |
| validation | exact transpose, memcheck and racecheck results for the recorded cases |
| performance | same event-timed shape and iteration count after repair |
| provenance | GPU, toolkit, source hash, compiler resource output, report paths |
Fill recovery-template.md and keep it with the raw reports. A reviewer should be able to trace the failure, repair, tested shapes, and post-recovery cost. Clean dynamic checks cover those executions, not every possible input or schedule.
Decide between transpose, reduction, and scan
Shared-memory tiling works well for spatial reordering. It isn't a universal answer for every memory-bound kernel. Ask what output dependency requires before choosing a primitive:
| Primitive | Output contract | Neighbor interaction | Typical use |
|---|---|---|---|
| transpose | preserve every value, change two-dimensional index order | threads exchange tile positions | switch row-major and column-major access orientation |
| reduction | combine many values into fewer values with associative operator | partial aggregates merge | sum loss, maximum error, count mismatches, row sum [8] |
| scan | emit prefix aggregate for every input position | each output depends on preceding range | prefix offsets, stream compaction positions, cumulative token counts |
Consider moving the correctness check from CPU back to GPU. If you need only total mismatch count and maximum absolute error, use reductions. Both outputs collapse comparisons into one or two scalars. A scan would do extra work by producing a prefix result for every comparison.
Now consider compacting indexes of mismatched elements. Each failing element needs a destination position based on the number of failures before it. That is a prefix dependency, so a scan computes offsets. A reduction can count total failures but can't assign each failure a unique compacted slot.
The decision rule remains mechanical:
- Same number of values, different layout: transpose.
- Fewer summary values: reduction.
- Same number of prefix states or compaction offsets: scan.
Tiling can appear inside all three implementations, but the dependency graph picks the algorithm. Don't start by copying a transpose tile into a reduction or scan kernel.
Report the result you measured
Complete the comparison only after the variants share the same workload and validity contract. The entries below are hypotheses and required observations, not supplied benchmark results:
| Run | Hypothesis | One code change | Correctness | Profiler evidence | Event timing | Verdict |
|---|---|---|---|---|---|---|
| naive | direct transpose wastes global-store transactions | none | exact | memory-bound, strided store source line | five-run distribution | baseline |
| tiled | shared tile coalesces output stores | add tile and block barrier | exact | global access improves; bank conflict appears | five-run distribution | keep for next iteration or reject |
| padded | extra column changes bank mapping | tile width 32 to 33 | exact plus sanitizer | bank conflict falls; resource use recorded | five-run distribution | keep only with repeat benefit |
| broken | barrier is required for cross-warp tile reuse | remove barrier | unreliable | racecheck hazard | timing invalid | reject and recover |
One row isn't complete if its correctness cell says only that it looks right. Keep raw CSV, .ncu-rep files, sanitizer logs, compiler resource output, environment files, source hash, and the filled recovery record.
You've finished when you can defend all six claims:
- workload and timing method stayed fixed;
- baseline bound came from profiler evidence;
- each candidate changed one causal mechanism;
- output check matched transpose exactly;
- sanitizer results are clean for the accepted kernels and tested shapes;
- reported performance includes conditions and distribution, not unsupported headline speedup.