Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
Four lines change, the timer drops, and the kernel looks faster. Then one output check finds stale values. The optimization didn't win anything. It made the program wrong more quickly.
This lab turns that failure into a repeatable engineering habit. You'll keep one square matrix, one data type, one launch geometry, and one timing harness while changing only the kernel's memory path. Every candidate must pass exact output checks and CUDA correctness tools before its timing counts.
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. If input element lives at input[r * N + c], correct output satisfies:
No floating-point arithmetic changes a value. A correct result must therefore match bit for bit, so max_abs_error should be 0. The default matrix gives every location a distinct integer-valued float, which keeps a misplaced element from hiding behind repeated test data. That exact contract is stronger than a loose tolerance and simpler than checking only a checksum.
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.
Every iteration follows the same loop:

Record hypothesis before editing. Otherwise every surprising counter becomes a story invented after result.
Build reproducible lab
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 transpose lab source as transpose_lab.cu. Also save recovery record template for deliberate failure later.
First create one artifact directory and capture environment. These commands produce text files you can compare without relying on terminal scrollback:
1mkdir -p artifacts/run-01
2nvidia-smi -L | tee artifacts/run-01/gpu.txt
3nvidia-smi | tee artifacts/run-01/nvidia-smi.txt
4nvcc --version | tee artifacts/run-01/nvcc.txt
5ncu --version | tee artifacts/run-01/ncu.txt
6compute-sanitizer --version | tee artifacts/run-01/compute-sanitizer.txt
7sha256sum transpose_lab.cu | tee artifacts/run-01/source.sha256Compile optimized code with line information for profiler and sanitizer attribution. -Xptxas=-v also prints per-kernel register and static shared-memory use:
1nvcc -O3 -lineinfo -Xptxas=-v \
2 -o transpose_lab transpose_lab.cu \
3 2>&1 | tee artifacts/run-01/build.txtExpected build evidence is structural, not one universal register count:
- compiler names each instantiated kernel;
transpose_tiled<0>reserves bytes of static shared memory;transpose_tiled<1>reserves bytes;- register count is recorded instead of assumed;
- no compilation error is present.
Run fixed matrix for 100 measured launches per normal variant:
1./transpose_lab all 4096 100 \
2 | tee artifacts/run-01/benchmark.csvHarness prints CSV columns variant, correct, mismatches, max_abs_error, average_ms, and effective_gb_s. Device and launch shape go to standard error, so tee keeps CSV machine-readable while terminal still names hardware. Pass conditions are:
| Field | Required evidence |
|---|---|
correct | true for copy, naive, tiled, and padded |
mismatches | 0 |
max_abs_error | 0 |
average_ms | positive CUDA-event time 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, temperature, competing work, or startup state. Repeat separate processes under 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 | tail -n 1
11 done
12 done
13} | tee artifacts/run-01/repeated-benchmark.csvCompare distributions, not only best row. If one candidate wins once and overlaps baseline in later runs, evidence doesn't support keeping it.
Read naive memory path
CUDA coalesces a warp's global-memory requests into transactions that cover requested addresses. Adjacent float32 words need fewer transactions than words spread across matrix rows.[1] The naive kernel reads input with good spatial order, then loses that order on output.
Here is complete indexing logic from transpose_naive. A block has 32 threads in x and 8 in y; each thread handles four rows separated by kBlockRows:
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}Within one warp, threadIdx.x runs from 0 through 31 while threadIdx.y stays fixed. Input indexes therefore differ by one float. Output indexes differ by floats because x became output row. Correct index mapping creates inefficient stores.
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. Harness reports:
Effective bandwidth isn't physical DRAM traffic. Cache hits, excess transactions, error-correction traffic, and architecture-specific paths can make hardware traffic differ. Use it as a stable workload-normalized rate for comparing same operation on 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 memory hierarchy. Occupancy reports active-warps capacity and its resource limiters.[2]
Collect one naive launch. Full set may replay kernel several times to gather counters, so don't use profiler run as timing result:
1ncu --set full \
2 --kernel-name-base function \
3 --kernel-name transpose_naive \
4 --launch-count 1 \
5 --force-overwrite \
6 -o artifacts/run-01/naive \
7 ./transpose_lab naive 4096 1
8
9ncu --import artifacts/run-01/naive.ncu-rep --page details \
10 > artifacts/run-01/naive.txtRead 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.
- Inspect global load and store requests, sectors, and reported access inefficiencies.
- Check static shared memory is zero for naive variant.
- Read occupancy limiter, but don't optimize it unless evidence connects limiter to latency or throughput.
Roofline reasoning predicts a memory-bound transpose because arithmetic intensity is near zero. Profiler still matters: it distinguishes useful memory traffic from inefficient transactions and verifies that observed kernel is one you meant to measure. Roofline is a ceiling model, not a promise that every low-intensity kernel reaches peak bandwidth.[3]
Write one bound statement before editing:
1Workload: float32 transpose, N=4096, block=32x8
2Correctness: exact transpose, 0 mismatches
3Observed limiter: [fill from report]
4Source evidence: [section, counter, and source line]
5Hypothesis: make output stores adjacent by staging a tile in shared memoryIf report doesn't support memory-path hypothesis, stop. A shared-memory rewrite would be guesswork.
Iteration one: coalesce both global directions
Shared memory is on-chip storage shared by threads in one block. Tiled transpose first copies input tile into shared memory with adjacent global reads. After block-wide barrier, threads read tile with swapped indexes and write adjacent output words. CUDA's official guidance uses shared memory for this exact reordering pattern.[4]
The core kernel below accepts Padding as 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 for (int offset = 0; offset < kTile; offset += kBlockRows) {
9 if (input_x < n && input_y + offset < n) {
10 tile[threadIdx.y + offset][threadIdx.x] =
11 input[static_cast<size_t>(input_y + offset) * n + input_x];
12 }
13 }
14
15 __syncthreads();
16
17 const int output_x = blockIdx.y * kTile + threadIdx.x;
18 const int output_y = blockIdx.x * kTile + threadIdx.y;
19
20 for (int offset = 0; offset < kTile; offset += kBlockRows) {
21 if (output_x < n && output_y + offset < n) {
22 output[static_cast<size_t>(output_y + offset) * n + output_x] =
23 tile[threadIdx.x][threadIdx.y + offset];
24 }
25 }
26}Barrier is a correctness boundary. Every thread must finish writes into tile before any thread reads values written by another warp. Stream order doesn't provide that block-internal guarantee.
Run correctness and event timing first, then collect 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-count 1 \
8 --force-overwrite \
9 -o artifacts/run-01/tiled \
10 ./transpose_lab tiled 4096 1Expected evidence has two parts:
- global reads and writes now use adjacent lane addresses;
- shared-memory column read maps many lanes onto same bank.
Shared memory has 32 banks for the access model used here, and successive 32-bit words map to successive banks. Different words requested from same bank in one warp are serialized, except supported broadcast case where threads request same word.[1]
For unpadded tile, column element for lane has word offset . Its bank is:
All 32 lanes address different words in bank . Global traffic improved, but shared-memory conflict is now visible.

Profiler versions and architectures can expose bank evidence with different metric names. Use report's Shared Memory tables and source correlation first. If you need raw name for scripted collection, query installed tool instead of copying a metric from another GPU:
1ncu --query-metrics | rg 'bank_conflicts|shared.*conflict' \
2 | tee artifacts/run-01/bank-metrics.txtRecord metric name, value, and source line. A screenshot without workload identity or source version isn't enough.
Iteration two: pad shared tile
Candidate change is one token in tile width:
1__shared__ float tile[32][33];Column element for lane now has word offset :
Lanes 0 through 31 map to 32 distinct banks. Padding doesn't change matrix shape, output, or logical global bytes. It changes address stride inside shared memory. NVIDIA's best-practices transpose example uses same extra column to remove bank conflict.[4]
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 . Lanes 0 through 15 reach 16 distinct banks, then lanes 16 through 31 repeat those banks. Each bank in that set receives two different words, leaving a two-way conflict. A 33-word row works because its stride is one modulo 32.
Check 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-count 1 \
8 --force-overwrite \
9 -o artifacts/run-01/padded \
10 ./transpose_lab padded 4096 1Compare same 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 “padding is X times faster” without your hardware, software, shape, precision, timing method, baseline, and correctness record. Architecture and toolchain decide actual size of benefit, so a universal speedup would be false.
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.
Occupancy is constraint, not score
Occupancy is ratio of active warps on a streaming multiprocessor (SM) to 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.[4][2]
Lab block has 32 x 8 = 256 threads, or eight 32-thread warps. Resource comparison stays concrete:
| 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 |
Small 128-byte increase may leave occupancy unchanged, or cross allocation boundary on some architecture or a larger tile design. Read actual report. Don't infer resident blocks from source alone.
Use these diagnoses:
| 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 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 padded variant when it removes measured conflict and improves repeat timing without correctness or resource regression. Revert when counter improves but end-to-end kernel timing doesn't. Counter movement isn't product outcome by itself.
Deliberately remove barrier
Performance edits often break synchronization before they break memory bounds. Variant broken uses padded tile but omits __syncthreads() between block writes and reads. Some warps can read locations before producer warp writes them.
Run smaller matrix under tools. NVIDIA recommends memcheck before racecheck because racecheck doesn't check invalid memory addresses.[5]
1compute-sanitizer --tool memcheck \
2 ./transpose_lab broken 1024 1 \
3 2>&1 | tee artifacts/run-01/broken-memcheck.txt
4
5compute-sanitizer --tool racecheck \
6 ./transpose_lab broken 1024 1 \
7 2>&1 | tee artifacts/run-01/broken-racecheck.txt
8
9compute-sanitizer --tool synccheck \
10 ./transpose_lab broken 1024 1 \
11 2>&1 | tee artifacts/run-01/broken-synccheck.txtExpected diagnostic split:
memcheckmay report no illegal address because every index stays in allocation bounds;racecheckshould report shared-memory read-after-write hazards around missing barrier;synccheckmay report no misuse because kernel omitted barrier instead of executing divergent or invalid barrier;- output checker may fail, but accidental correct output on one run doesn't clear race.
That last case is important. Scheduler timing can hide data race during ordinary run. Dynamic race evidence invalidates kernel even when sampled output happens to match.
Recover by restoring block-wide barrier, then rerun exact output, memcheck, and racecheck on padded variant:
1compute-sanitizer --tool memcheck \
2 ./transpose_lab padded 1024 1 \
3 2>&1 | tee artifacts/run-01/recovered-memcheck.txt
4
5compute-sanitizer --tool racecheck \
6 ./transpose_lab padded 1024 1 \
7 2>&1 | tee artifacts/run-01/recovered-racecheck.txt
8
9./transpose_lab padded 4096 100 \
10 | tee artifacts/run-01/recovered-benchmark.csvRecovery record must connect symptom to invariant:
| 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 |
| proof | exact transpose, memcheck clean, racecheck clean |
| 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 raw reports. That artifact is stronger than “added synchronization” because reviewer can trace failure, repair, and post-recovery cost.
Decide between transpose, reduction, and scan
Shared-memory tile is right for layout reordering. It isn't universal answer for every memory-bound kernel. Ask what output dependency requires before choosing 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 |
| scan | emit prefix aggregate for every input position | each output depends on preceding range | prefix offsets, stream compaction positions, cumulative token counts |
Consider moving 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 few scalars. A scan would do extra work by producing prefix result for every comparison.
Now consider compacting indexes of mismatched elements. Each failing element needs destination position based on number of failures before it. That is prefix dependency, so scan computes offsets. A reduction can count total failures but can't assign each failure unique compacted slot.
Decision rule stays 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 dependency graph picks algorithm. Don't start by copying transpose tile into reduction or scan kernel.
Close evidence ledger
Complete comparison only after all variants share workload and validity contract:
| 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 “looks right.” Keep raw CSV, .ncu-rep files, sanitizer logs, compiler resource output, environment files, source hash, and filled recovery record.
You have 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 cleared accepted kernel;
- reported performance includes conditions and distribution, not unsupported headline speedup.