Personalize this lesson
Adapt explanations and teaching visuals to your background and preferred voice.
A matrix multiplication can return every right number and still waste 97% of a GPU. When you write the textbook three-loop dot product in naive CUDA, each thread spends its entire existence waiting on High Bandwidth Memory (HBM). Swapping thread coordinates fixes memory coalescing, packing scattered bytes into wide bus transactions. Tiling inputs into shared memory raises arithmetic intensity by orders of magnitude, amortizing DRAM fetches across hundreds of threads. 2D register blocking then insulates shared memory itself, feeding multiple accumulators per thread from register files. Finally, Tensor Cores replace scalar arithmetic instructions with warp-synchronous matrix multiply-accumulate operations, shifting compute ceilings into hundreds of TFLOP/s.
The previous GPU compiler lesson separated source language, generated code, and target architecture. In the kernel engineering lab, memory access, occupancy, and synchronization became tuning decisions. Finally, the profiling lesson required correctness before speed. Keep all three contracts active here.
We build one operation, , through five successive kernel stages. This represents the , case of general matrix-matrix multiplication (GEMM), , where we don't read the old output buffer. Matrix has shape , has shape , and has shape . All lab buffers use row-major storage, so neighboring columns occupy neighboring memory addresses.
The complete CUDA source is available as gemm_lab.cu. Its kernels are intentionally educational. A production library has more schedules, layout transforms, epilogues, and hardware-specific paths than one chapter should hide inside a code listing.
You'll trace warp addresses, eliminate shared-memory bank conflicts with padding and swizzling, explain both shared-memory barriers, check a padded WMMA layout, and produce a correctness-gated measurement receipt. The host C++ checks and CPU index model run on any machine without a GPU. Compiling CUDA, executing kernels, running sanitizers, and collecting hardware timings remain required on your target device.
Compute one output before launching a warp
Start with matrices small enough to multiply on paper:
is and is , so must be . Entry takes row 1 of and column 0 of :
Applying the same dot product to each output gives:
GEMM performs multiplications and about the same number of additions. Performance tools conventionally count floating-point operations (FLOPs), including one multiply and one add for each inner-loop step. Our tiny product therefore counts FLOPs.
In naive scalar code, each thread computes a single output . For each step along , the thread fetches one float from (4 bytes) and one float from (4 bytes), performing one fused multiply-add (2 FLOPs). That yields an arithmetic intensity of:
Consider an NVIDIA A100 GPU with 2,039 GB/s HBM2e memory bandwidth and 19.5 TFLOP/s peak FP32 CUDA core throughput. At 0.25 FLOP/B, attainable performance caps out at:
That's just 2.6% of the GPU's 19.5 TFLOP/s compute capability. Over 97% of the arithmetic hardware sits idle, starved for bytes.
The script below reproduces the hand calculation and compares two traffic models. Its first model assumes every output reloads two input values for every . An optimistic second model counts each input and output element once, giving a lower bound for a cold, standalone operation rather than a claim about a real cache.
1A = [[1.0, 2.0, 0.0], [-1.0, 3.0, 2.0]]
2B = [[2.0, -1.0], [1.0, 4.0], [3.0, 0.0]]
3
4M, K, N = len(A), len(B), len(B[0])
5C = [
6 [sum(A[row][k] * B[k][col] for k in range(K)) for col in range(N)]
7 for row in range(M)
8]
9
10flops = 2 * M * N * K
11scalar_bytes = 4 * M * N * (2 * K + 1)
12unique_bytes = 4 * (M * K + K * N + M * N)
13
14print(f"C={C}")
15print(f"flops={flops}")
16print(f"scalar_load_model={flops / scalar_bytes:.3f} FLOP/B")
17print(f"perfect_reuse_bound={flops / unique_bytes:.3f} FLOP/B")1C=[[4.0, 7.0], [7.0, 13.0]]
2flops=24
3scalar_load_model=0.214 FLOP/B
4perfect_reuse_bound=0.375 FLOP/BThose intensities are small because the matrices are small. For a square FP32 GEMM with , the unique-byte model becomes:
When , FLOP/B. Large square GEMMs contain abundant reuse to become compute-bound, but only if the kernel captures that reuse on chip. The roofline model bounds attainable compute rate by the lower of peak arithmetic throughput and memory bandwidth multiplied by arithmetic intensity.[1] It tells us whether reducing bytes can still move the ceiling.
Why is 0.375 FLOP/B an optimistic bound for the tiny product rather than a measured intensity?
Answer
It counts every A, B, and C element exactly once. A real kernel may fetch cache lines it only partly uses, reload evicted data, or move padding and metadata. A warm cache can also reduce DRAM traffic for one launch, so measure device traffic and record cache conditions.
A correct thread can still request the wrong addresses
The first CUDA kernel assigns one thread to one output. Each thread runs the same -step dot product:
1__global__ void gemm_scalar_strided(const float* A, const float* B, float* C,
2 int M, int N, int K) {
3 int row = blockIdx.x * blockDim.x + threadIdx.x;
4 int col = blockIdx.y * blockDim.y + threadIdx.y;
5 if (row >= M || col >= N) return;
6
7 float acc = 0.0f;
8 for (int k = 0; k < K; ++k) {
9 acc += A[row * K + k] * B[k * N + col];
10 }
11 C[row * N + col] = acc;
12}CUDA linearizes threadIdx.x first across warp lanes 0 through 31. In this strided kernel with a block, lanes 0 through 15 own rows 0 through 15 of column 0, while lanes 16 through 31 own rows 0 through 15 of column 1. Because is stored row-major, row begins elements ( bytes) after row .
When floats (256 bytes), lane 0 requests byte 0, lane 1 requests byte 256, and lane 2 requests byte 512. A hardware DRAM cache sector is 32 bytes wide (holding 8 contiguous FP32 floats). Because adjacent lanes touch addresses 256 bytes apart, each lane's request lands in a completely separate 32-byte sector. Lanes 0 through 15 force 16 distinct sector transactions for a single instruction. Stores to suffer the exact same -stride penalty. The numbers are mathematically correct, but memory hardware chokes on scattered transactions.
Swap index assignments so threadIdx.x selects col:
1__global__ void gemm_scalar_coalesced(const float* A, const float* B, float* C,
2 int M, int N, int K) {
3 int row = blockIdx.y * blockDim.y + threadIdx.y;
4 int col = blockIdx.x * blockDim.x + threadIdx.x;
5 if (row >= M || col >= N) return;
6
7 float acc = 0.0f;
8 for (int k = 0; k < K; ++k) {
9 acc += A[row * K + k] * B[k * N + col];
10 }
11 C[row * N + col] = acc;
12}With the coalesced kernel's block, a full warp of 32 lanes owns one row and 32 consecutive columns. For matrix , every thread in the warp reads A[row * K + k]. All 32 lanes request the exact same address, which the SM services with a single 32-byte sector broadcast. For matrix , lanes 0 through 31 request 32 consecutive columns ( bytes), fitting neatly into 4 contiguous 32-byte sectors. Stores to similarly coalesce into 4 contiguous sectors.[2]
| Mapping | Adjacent lanes vary | Inner-loop A access | Inner-loop B access | C store | First warp sectors (k=0) |
|---|---|---|---|---|---|
| scalar strided | row within each 16-lane half | stride (16 sectors) | two columns per warp (1 sector) | stride (16 sectors) | 33 sectors |
| scalar coalesced | output column | shared row (1 broadcast sector) | contiguous 32 floats (4 sectors) | contiguous 32 floats (4 sectors) | 9 sectors |
Coalescing cuts bus transactions by 73% for the first warp iteration. Yet its arithmetic intensity remains stuck at 0.25 FLOP/B. Packing memory requests tightly prevents bus transaction replays, but threads still reload inputs from global memory on every step. Shared memory tackles data reuse next.

Turn global loads into shared tiles
Pick a output tile. One threadblock loads a tile of and a tile of into fast, on-chip shared memory, computes 256 partial dot products, then advances 16 positions along . Every loaded value feeds 16 output columns, and every loaded value feeds 16 output rows.

Two block-wide barriers form correctness boundaries:
- Read-After-Write (RAW) barrier: Prevents any thread from reading a shared tile before all threads finish loading it from global memory.
- Write-After-Read (WAR) barrier: Prevents fast threads from looping around and overwriting shared memory before slower threads finish consuming the current tile.
NVIDIA's classic GEMM pattern relies on this cooperative staging to slash global DRAM transactions.[2]
1constexpr int TILE = 16;
2
3__global__ void gemm_shared(const float* A, const float* B, float* C,
4 int M, int N, int K) {
5 __shared__ float As[TILE][TILE];
6 __shared__ float Bs[TILE][TILE];
7
8 int row = blockIdx.y * TILE + threadIdx.y;
9 int col = blockIdx.x * TILE + threadIdx.x;
10 float acc = 0.0f;
11
12 for (int k0 = 0; k0 < K; k0 += TILE) {
13 int ak = k0 + threadIdx.x;
14 int bk = k0 + threadIdx.y;
15 As[threadIdx.y][threadIdx.x] =
16 (row < M && ak < K) ? A[row * K + ak] : 0.0f;
17 Bs[threadIdx.y][threadIdx.x] =
18 (bk < K && col < N) ? B[bk * N + col] : 0.0f;
19 __syncthreads();
20
21#pragma unroll
22 for (int k = 0; k < TILE; ++k) {
23 acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];
24 }
25 __syncthreads();
26 }
27
28 if (row < M && col < N) C[row * N + col] = acc;
29}Edge handling preserves barrier participation. Each cooperative load checks its own coordinate and writes zero when out of range. A thread outside or still loads an input needed by its valid neighbors and must participate in __syncthreads(). Early exits before barriers cause undefined behavior and GPU hangs. Only the final global store to is predicated.
For each tile step, the block performs FLOPs. It loads two 256-element FP32 tiles ( bytes) from global memory. Arithmetic intensity at the global-to-shared boundary jumps to:
This represents a reduction in global memory traffic compared to scalar code. On our A100, 4.0 FLOP/B raises the bandwidth ceiling from 0.51 TFLOP/s to TFLOP/s.
An edge thread has row >= M. Why must it still reach both barriers inside the K loop?
Answer
Other threads in the block may need its B load and its barrier participation. The edge thread still performs both predicated loads, zero-fills only invalid source coordinates, reaches each block-wide barrier, and skips its final out-of-range store.
Eliminate shared-memory bank conflicts with padding and swizzling
Shared memory isn't an unconstrained cache. On modern NVIDIA architectures, shared memory is divided into 32 independent memory banks, interleaved cyclically in 4-byte (32-bit) words:
Each bank can service only one 32-bit word per clock cycle. When multiple threads in a warp access different words within the same bank simultaneously, the hardware serializes the requests. A 16-way or 32-way bank conflict stalls the execution pipeline for 16 or 32 consecutive clock cycles.
Examine the inner loop access pattern in gemm_shared:
1acc += As[threadIdx.y][k] * Bs[k][threadIdx.x];For , adjacent threads in a warp have adjacent threadIdx.x values. At fixed , lane reads column , accessing bank . Across all 32 lanes, requests distribute across banks without conflicts.
For , thread reads As[threadIdx.y][k]. Notice that threads with different share the same column . In a tile where stride is 32 floats:
Every thread in the warp hits the exact same bank with different row addresses. That triggers a catastrophic 32-way bank conflict, destroying shared-memory throughput.
Two techniques eliminate this serialization:
1. Stride padding
Add a padding float to the shared memory array declaration:
1constexpr int PAD = 1;
2__shared__ float As[TILE][TILE + PAD]; // Stride becomes 33 floatsWith stride 33, the bank calculation becomes:
Because , each consecutive row shifts the bank assignment by 1. All 32 threads land on 32 distinct banks. Bank conflicts drop to zero.
2. Bitwise XOR swizzling
While padding is simple, it wastes shared-memory capacity and breaks the 16-byte alignment required for 128-bit vector loads (float4 or uint4). Production kernels in libraries like CUTLASS use bitwise XOR swizzling instead.[3]
Swizzling permutes the column index by XORing it with bits from the row index:
1int swizzled_col = col ^ ((row / 4) % 8);By XORing row bits into column bits, each row's columns map to a distinct permutation of banks. No memory is wasted on padding, 128-bit memory alignment is preserved, and bank conflicts vanish.
| Technique | Memory overhead | 128-bit vector alignment | Conflict elimination |
|---|---|---|---|
| None (natural 2D array) | 0% | Preserved | Severe 16-way or 32-way serialization |
Stride padding (+1 float) | capacity waste | Broken (non-power-of-two stride) | Completely eliminated |
| Bitwise XOR swizzling | 0% capacity waste | Preserved (powers of two aligned) | Completely eliminated |
Let each thread keep more than one answer
In gemm_shared, each thread computes a single output value. For every multiply-accumulate step, the thread reads one float from As and one float from Bs: 8 bytes read from shared memory for 2 FLOPs. That gives an internal shared-memory arithmetic intensity of just 0.25 FLOP/B. The SM's shared-memory load pipelines and register ports become saturated.
Register blocking gives each thread a 2D tile of outputs held in local registers. In our lab kernel, each thread computes 4 horizontal outputs ( tile):
1float acc[4] = {0.0f, 0.0f, 0.0f, 0.0f};
2
3#pragma unroll
4for (int k = 0; k < TILE; ++k) {
5 float a = As[threadIdx.y][k];
6#pragma unroll
7 for (int j = 0; j < 4; ++j) {
8 acc[j] += a * Bs[k][threadIdx.x + j * TILE];
9 }
10}A single float loaded from As into register a now feeds 4 independent multiply-adds. In an register tile (), a thread loads 8 values from and 8 values from (16 floats = 64 bytes), then executes FMAs (128 FLOPs):
That represents an reduction in shared-memory load traffic. Registers insulate shared memory just as shared memory insulates global DRAM.
More registers aren't free. Each thread must allocate accumulator registers plus temporary storage for inputs. An SM holds 65,536 32-bit registers. If each thread consumes 64 registers, an SM can host at most active threads (capping occupancy at 50% on Ampere). If register allocations spill into local memory (backed by DRAM), high latencies and cache thrashing erase all benefits.[3] Tuning register tile sizes balances arithmetic reuse against warp occupancy.
| Kernel layer | Primary reuse location | Accumulator owner | Added constraint |
|---|---|---|---|
| scalar coalesced | Hardware L1/L2 caches | One thread, one output | Repeated global loads |
| shared | Threadblock shared memory | One thread, one output | Barriers, shared capacity, bank conflicts |
| register | Shared memory plus registers | One thread, four outputs | Register pressure and occupancy limits |
| Tensor Core WMMA | Fragment registers / MMA pipes | One warp, tile | Precision, alignment, and fragment layout |
Hand a tile to Tensor Cores
Tensor Cores are specialized execution units designed for matrix multiply-accumulate operations. NVIDIA introduced Volta Tensor Cores through the Warp Matrix Multiply Accumulate (WMMA) API in CUDA 9. Rather than having individual threads execute scalar FMAs, all 32 lanes in a warp cooperate to multiply fragments of matrices:
For FP16 inputs, a standard fragment represents a matrix tile, accumulating into FP32 accumulators.[4]
Three rules prevent bugs when programming WMMA:
- Full warp cooperation: All 32 lanes must participate with matching matrix shapes and types. A diverged or partially active warp produces undefined results.
- Strict pointer and stride alignment: For FP16 inputs,
load_matrix_syncrequires a 32-byte-aligned base pointer and a leading dimension divisible by 8 elements (16 bytes). The FP32 output store requires 32-byte pointer alignment and a leading dimension divisible by 4 elements (16 bytes). - Padded fragment boundaries: Tensor Core instructions can't load partial fragments. Edges must be padded to multiples of 16 in all three dimensions (), zero-filled, and cropped after computation.[5]
The lab kernel uses row-major FP16 inputs, FP32 accumulation, and one warp per tile:
1__global__ void gemm_wmma_aligned(const half* A, const half* B, float* C,
2 int M, int N, int K) {
3#if defined(__CUDA_ARCH__) && __CUDA_ARCH__ < 700
4 asm("trap;"); // Never silently execute an empty unsupported kernel.
5#else
6 using namespace nvcuda;
7 int row = blockIdx.y * 16;
8 int col = blockIdx.x * 16;
9
10 wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;
11 wmma::fill_fragment(c_frag, 0.0f);
12
13 for (int k0 = 0; k0 < K; k0 += 16) {
14 wmma::fragment<wmma::matrix_a, 16, 16, 16, half,
15 wmma::row_major> a_frag;
16 wmma::fragment<wmma::matrix_b, 16, 16, 16, half,
17 wmma::row_major> b_frag;
18 wmma::load_matrix_sync(a_frag, A + row * K + k0, K);
19 wmma::load_matrix_sync(b_frag, B + k0 * N + col, N);
20 wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);
21 }
22 wmma::store_matrix_sync(C + row * N + col, c_frag, N,
23 wmma::mem_row_major);
24#endif
25}Because host setup pads all dimensions to multiples of 16 (), the grid launches only complete tiles.

Padding adds explicit arithmetic overhead. For default shapes , WMMA executes on :
Reported TFLOP/s must always use logical operations (), measuring useful work completed rather than padded instruction waste.
Layout is part of the type
A[row * K + k] assumes row-major storage. B[k * N + col] assumes row-major storage. Changing a fragment layout tag from wmma::row_major to wmma::col_major without physically transposing memory doesn't create a faster kernel; it computes a completely wrong matrix product.
Always specify and verify:
- Logical shapes , , ;
- Storage order (row-major or column-major);
- Leading dimensions and memory strides;
- Memory alignment and padding multiples;
- Input, accumulator, and output data types.
Why mixed precision demands FP32 accumulation
Tensor Cores achieve extreme throughput by multiplying reduced-precision inputs (FP16 or BF16) and accumulating sums in full FP32. Understanding why FP16 accumulation fails reveals the numerical foundation of deep learning systems.
Consider the IEEE 754 half-precision (FP16) format:
- 1 sign bit, 5 exponent bits, 10 mantissa bits;
- Machine epsilon ;
- Dynamic range: minimum subnormal , maximum finite value .
In a transformer model with hidden dimension , calculating a single dot product sums 4,096 products. If inputs are normalized with variance , partial sums quickly grow toward or .
When an FP16 accumulator reaches (), its least significant mantissa bit represents . Any subsequent product smaller than 0.5 completely disappears when added (swamping)! Over a 4,096-step reduction, hundreds of small gradients or activations round to zero, causing severe gradient underflow and network divergence.
In contrast, single precision (FP32) provides:
- 8 exponent bits, 23 mantissa bits;
- Machine epsilon ;
- Dynamic range up to .
Even when the running sum reaches , the FP32 resolution step is , easily preserving tiny numerical updates across long reductions.

The two-reference testing protocol
Validating a mixed-precision kernel requires two distinct CPU references:
- Arithmetic correctness reference: Convert FP32 inputs to FP16, convert them back to FP32, and accumulate in FP64. This reference isolates kernel arithmetic and accumulation fidelity from input quantization loss.
- Application fidelity reference: Accumulate original unrounded FP32 inputs in FP64. This measures total end-to-end numerical change introduced by quantizing inputs to FP16.
We check kernel correctness against Reference 1 using absolute and relative error bounds:
For FP16 WMMA with FP32 accumulation, the lab enforces and . Absolute tolerance handles values near zero, while relative tolerance scales with large outputs.
The hardware roofline across kernel stages
We can now place every kernel stage onto the hardware roofline to understand how memory hierarchy optimizations shift the performance ceiling.
On an NVIDIA A100 SXM4 GPU:
- HBM2e bandwidth: GB/s.
- Peak FP32 CUDA core throughput: TFLOP/s.
- Peak FP16 Tensor Core throughput: TFLOP/s.
The roofline knee defines the minimum arithmetic intensity required to hit peak compute:
| Kernel Stage | Arithmetic Intensity () | A100 Ceiling | Hardware Bottleneck |
|---|---|---|---|
| Naive FP32 | 0.25 FLOP/B | 0.51 TFLOP/s | Extreme HBM starvation (2.6% of FP32 peak) |
| Coalesced FP32 | 0.25 FLOP/B | 0.51 TFLOP/s | Bus coalesced, but identical HBM traffic |
| Shared Tiling () | 4.0 FLOP/B | 8.16 TFLOP/s | HBM bound; SMEM bandwidth and barrier overhead |
| Shared Tiling () | 8.0 FLOP/B | 16.3 TFLOP/s | Approaching FP32 CUDA core ceiling |
| 2D Register Blocked () | 32.0 FLOP/B | 19.5 TFLOP/s | FP32 Compute Bound! Saturates CUDA cores |
| Naive WMMA () | 4.0 FLOP/B | 8.16 TFLOP/s | Severely Memory Bound! (2.6% of 312 TFLOP/s) |
| Tuned Production GEMM (CUTLASS) | FLOP/B | 312 TFLOP/s | Tensor Core Bound! Multi-stage async pipeline |
Notice the critical insight: writing a naive WMMA kernel without large shared-memory tiles and multi-stage pipelining leaves Tensor Cores 97% starved for data! Reaching 312 TFLOP/s requires clearing the 153 FLOP/B roofline knee, which demands large threadblock tiles (), deep register blocking, and asynchronous prefetching.
Run the lab and keep the receipt
Save the download as assets/gemm_lab.cu relative to your working directory. Check the installed toolkit and device first. Set GPU_ARCH to an architecture supported by both the device and compiler, such as sm_80 on an Ampere GPU:
1nvidia-smi --query-gpu=name,compute_cap --format=csv
2nvcc --version
3nvcc --list-gpu-code
4
5: "${GPU_ARCH:?Set GPU_ARCH to your device's supported target, such as sm_80}"
6nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \
7 assets/gemm_lab.cu -o gemm_lab
8./gemm_lab 257 263 251 20WMMA hardware support began at compute capability 7.0, but toolkit support is a separate constraint. CUDA 13.0 removed offline compilation for architectures below 7.5; a Volta sm_70 exercise needs a compatible CUDA 12.x toolchain. A binary targeting sm_80 doesn't become a Hopper-specialized TMA kernel merely by running on newer hardware.[6]
The CLI rejects nonpositive or malformed dimensions, padded buffers exceeding signed 32-bit index ranges, and unsupported grid sizes. Those checks don't guarantee enough device memory. The host FP64 reference also costs : start with small correctness shapes before attempting large matrices.
Exact latency and throughput require device measurements. This is the output schema when every kernel passes:
1device=<GPU name> cc=<major.minor> shape=257x263x251 iterations=20
2scalar_strided PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured>
3scalar_coalesced PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured>
4shared_16x16 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured>
5register_16x64 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured>
6wmma_f16_f32 PASS max_abs=<measured> max_rel=<measured> mean_ms=<measured> tflop_s=<measured>
7wmma_padding=272x272x256 logical_flops=33930682 executed_flops=37879808After correctness, the program performs five warmups, records CUDA events around repeated launches in one stream, synchronizes the stop event, and reports the mean event interval per launch. It excludes allocation, transfers, FP16 conversion, padding, reference computation, and cropping. Short kernels can include stream idle gaps between host launches. Reusing the same buffers also favors warm-cache behavior. For a serious receipt, repeat whole runs and report the distribution of their means.
Use a structured row so someone else can reproduce the comparison:
1{
2 "hardware": {"gpu": "record nvidia-smi name", "compute_capability": "record it"},
3 "software": {"driver": "record it", "cuda": "record nvcc version", "git_commit": "record it"},
4 "workload": {"M": 257, "N": 263, "K": 251, "warmups": 5, "iterations": 20},
5 "path": {"kernel": "register_16x64", "input": "fp32", "accumulator": "fp32", "layout": "row-row-row"},
6 "timing": {"method": "CUDA events in one stream", "cache_state": "warm"},
7 "correctness": {"reference": "CPU fp64 on fp32 inputs", "atol": 0.0002, "rtol": 0.0002},
8 "result": {"status": "fill after run", "mean_ms": "fill after run", "tflop_s": "fill after run"}
9}Don't write 4.2x faster unless the receipt names both kernels, same shape, same device state, same timing method, same precision contract, and equivalent correctness. Compare against a tuned library such as cuBLAS or a CUTLASS profiler kernel before calling a handwritten kernel competitive.
The lab's FP16 WMMA row and FP32 scalar rows have different input precision. Their timing ratio alone isn't a same-precision optimization result. Use rounded inputs and an explicitly matched library compute mode for a controlled comparison.
Run Compute Sanitizer to verify memory and race safety:
1compute-sanitizer --tool memcheck ./gemm_lab 31 29 37 1
2compute-sanitizer --tool racecheck ./gemm_lab 31 29 37 1
3compute-sanitizer --tool synccheck ./gemm_lab 31 29 37 1Nsight Compute can then explain timing changes. Start with a small metric set:
1ncu --set basic --kernel-name regex:gemm_register_blocked --launch-count 1 \
2 ./gemm_lab 512 512 512 2This command captures the first matching launch. The basic set is a starting capture. Add sections for memory sectors, occupancy, registers, shared memory, and tensor-pipe activity as needed.[7]
Check the index model without a GPU
Download gemm_index_check.py alongside the CUDA file. It executes the two scalar ownership maps, cooperative shared loads, four-output register layout, and zero-padding/crop algebra on six integer fixtures, including and . It assumes both block barriers work; it can't detect real scheduling races, floating-point error, or WMMA instruction behavior.
1import runpy
2
3runpy.run_path("assets/gemm_index_check.py", run_name="__main__")1PASS 6 shapes: scalar ownership, shared/register indices, padding/crop
2first-warp sectors (A, B, C): strided=(16, 1, 16), coalesced=(1, 4, 4)
3CPU integer fixtures only; no CUDA, floating-point, barrier, or speed validation.The CUDA download also exposes a host-only build that checks its actual argument parser, index-overflow guards, reference multiplication, NaN rejection, and tolerance comparison. It excludes every CUDA declaration:
1clang++ -x c++ -std=c++17 -Wall -Wextra -Werror -DGEMM_HOST_CHECK \
2 assets/gemm_lab.cu -o gemm_host_check
3./gemm_host_checkWhere asynchronous copies and TMA take over
The shared and WMMA kernels issue a load, wait, compute, and repeat. A pipelined main loop instead prepares tile while computing tile :
1acquire_empty(stage[0])
2issue_copy(input_tile=0, destination=stage[0])
3
4for t in 0 .. k_tiles-1:
5 current = stage[t % 2]
6 wait_copy_complete_for_all_consumers(current)
7 if t + 1 < k_tiles:
8 next = stage[(t + 1) % 2]
9 acquire_empty(next)
10 issue_copy(input_tile=t+1, destination=next)
11 accumulate(accumulator, current)
12 wait_all_consumers_finished(current)
13 release_empty(current)The two slots must not alias. Copy completion makes a tile readable; consumer completion makes its storage reusable. An asynchronous MMA can still read shared memory after issue, so releasing a stage at instruction issue is unsafe.
NVIDIA documents three distinct hardware boundaries:
| Hardware Mechanism | First Architecture | Data Movement Path | Kernel Programming Model |
|---|---|---|---|
| Synchronous cooperative load | Pre-Ampere | GMEM Regs SMEM | Explicit thread loads and __syncthreads() |
cp.async (LDGSTS) | Ampere (CC 8.0) | GMEM SMEM directly | Bypasses register file; async pipeline tokens |
| Tensor Memory Accelerator (TMA) | Hopper (CC 9.0) | Multi-D GMEM SMEM | Hardware descriptor; transaction barriers |
In pre-Ampere code, moving data from global DRAM into shared memory required two instructions per 16 bytes: LDG (load from global to register) and STS (store from register to shared memory). This consumed register file bandwidth and SM issue slots just to route bytes into shared memory.
Ampere introduced cp.async (hardware instruction LDGSTS), copying data directly from global memory into shared memory without touching registers. This frees registers for accumulators and lets data movement proceed in the background while math units execute compute instructions.
Hopper introduces two further leaps:
- Warpgroup MMA (
wgmma): Four warps (128 threads) execute matrix multiply-accumulate cooperatively. Unlike Volta and Ampere WMMA, which required loading input matrices from shared memory into thread registers (ldmatrix),wgmmareads matrix inputs directly from shared memory. This cuts register allocation in half. - Tensor Memory Accelerator (TMA): A dedicated hardware unit that copies multidimensional tensor tiles between global memory and shared memory asynchronously. A single thread issues a TMA descriptor instruction; hardware handles address calculations, strides, and out-of-bounds boundary clipping automatically.[5]
CUTLASS 3.x and its CuTe template engine wrap these hardware primitives into composable C++ layouts (Tensor, Layout, TiledCopy, TiledMMA), automating multi-stage pipelining and warp specialization.[8] Use the CUTLASS profiler for verified library receipts:
1./tools/profiler/cutlass_profiler \
2 --operation=Gemm --m=2048 --n=2048 --k=2048 \
3 --A=f16:row --B=f16:row --C=f32:row \
4 --accum=f32 --alpha=1 --beta=0 --verification-enabled=trueAn async-copy kernel returns correct values but matches the synchronous kernel's timing. Why isn't the API name proof that transfer overlapped arithmetic?
Answer
A high-level copy can fall back to a synchronous path when its alignment or size contract isn't met. Even with an asynchronous instruction, too little independent computation, too few pipeline stages, or waits in the wrong place can remove overlap. Inspect the emitted instruction path and stall metrics before changing the stage count.
Diagnose the first bad signal
| Symptom | Likely cause | First check | Repair |
|---|---|---|---|
| Wrong values only on bottom or right edge | Missing predicate or crop | Run | Zero-fill input tails and guard output stores |
| Hang in shared kernel | Some threads skipped a barrier | Run synccheck on an edge shape | Keep every block thread in tile barriers |
| Transposed-looking output | Physical layout disagrees with fragment layout | Check indices and leading dimensions | Make layout contract explicit or transform data |
| WMMA illegal memory access | Unpadded dimensions or bad alignment | Print and pointers | Pad, align, and launch complete fragments |
| Shared kernel throughput collapses | 32-way shared memory bank conflict | Check row stride divisibility by 32 | Add +1 stride padding or apply XOR swizzling |
| WMMA passes small K but drifts at large K | Tolerance ignores reduction length | Compare rounded-input reference across K sweep | Set evidence-based tolerance or stronger accumulation |
| Coalesced kernel isn't faster | Cache already hid loads or shape is too small | Inspect sectors and repeat distribution | Keep evidence; don't assume mapping change must win |
| Register kernel slows down | Spills or lower useful occupancy | Inspect registers and local-memory traffic | Reduce per-thread tile or retune block shape |
| No tensor-pipe activity | Binary, dtype, layout, or path missed Tensor Cores | Record architecture and instruction metrics | Compile correct target and verify selected kernel |
| Async version matches synchronous timing | Copy didn't overlap or fell back | Inspect instruction path and stall reasons | Meet alignment contract and pipeline enough work |
Every row starts from a symptom, not a favorite optimization. If correctness fails, stop timing. If timing moves without the expected counter change, revisit the hypothesis.
Keep the artifact honest
A complete lab submission contains:
- Source and exact compile command;
- Device, driver, toolkit, and target architecture;
- Odd, tiled, and long- correctness shapes;
- Reference dtype plus
atolandrtol; - Warmup, timing, synchronization, and cache-state method;
- Logical and padded operation counts;
- One profiler capture tied to a named kernel;
- A cuBLAS or CUTLASS baseline with the same input and output contract;
- Conclusions limited to measured shapes.
The progression has a stable meaning: coalescing repairs warp transactions; shared tiling captures DRAM reuse; XOR swizzling eliminates bank conflicts; register blocking increases per-thread reuse and independent accumulation; WMMA moves cooperative tiles onto Tensor Cores; and asynchronous copies with TMA overlap data movement with compute.