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 most of a GPU. One thread mapping scatters memory requests. A better mapping makes adjacent lanes touch adjacent values. Tiling then reuses those values on chip, while Tensor Cores change the arithmetic instruction itself.
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'll build one operation, , through five kernels. General matrix-matrix multiplication (GEMM) names this family of products. Matrix has shape , has shape , and has shape . All lab buffers use row-major storage, so neighboring columns occupy neighboring 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.
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.
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:
Large square GEMMs can have enough reuse to become compute-bound, but only if the kernel captures that reuse. The roofline model bounds attainable compute rate by the lower of peak arithmetic throughput and memory bandwidth multiplied by arithmetic intensity.[1] It can't tell us which tile or layout wins. 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. Adjacent lanes therefore change row while holding col fixed in this mapping. At one inner-loop value , those lanes read A[row * K + k] with a stride of and store C[row * N + col] with a stride of . The values are mathematically correct, but a warp's addresses are scattered across memory.
Swap ownership 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}Now adjacent lanes read adjacent B[k * N + col] values and write adjacent outputs. Threads sharing a row request the same address instead of 32 unrelated addresses. NVIDIA's current guidance describes coalescing as combining a warp's accesses into the necessary memory transactions and treats adjacent access as a high-priority pattern.[2]
| Mapping | Adjacent lanes vary | Inner-loop B access | C store | Main waste |
|---|---|---|---|---|
| scalar strided | output row | same column, distant A rows | stride | scattered transactions |
| scalar coalesced | output column | adjacent columns | adjacent columns | repeated global input loads |
Coalescing repairs transaction shape. It doesn't preserve an input tile for reuse across multiple inner products. Shared memory handles that next.
Turn global loads into shared tiles
Pick a output tile. One block loads a tile of and a tile of into shared memory, computes 256 partial dot products, then advances 16 positions along . Every loaded value can feed 16 output columns, and every loaded value can feed 16 output rows.

Two block-wide barriers form correctness boundaries. A first barrier prevents a thread from reading a shared value before another thread writes it. A second prevents a fast thread from overwriting a tile that a slower thread still needs. NVIDIA's GEMM example uses the same shared-memory idea to remove redundant global loads and requires block synchronization when warps consume values written by other warps.[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}Notice how edge handling preserves barrier participation. Each cooperative load checks its own A or B coordinate and writes zero only when that source coordinate is out of range. A thread without a valid output may still load an input needed by its neighbors, and it mustn't return before __syncthreads(). Only the final global store is predicated.
For a complete step, the block performs FLOPs. It loads two 256-element FP32 tiles, or 2048 bytes, before the output store. Ignoring the store until the full loop ends, that step exposes FLOP/B at the global-to-shared boundary. Larger threadblock tiles can raise reuse further, but they also consume more shared memory and registers.
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.
Let each thread keep more than one answer
The shared kernel assigns one accumulator register to each thread. It spends instruction and address overhead for every output while leaving each thread with little independent arithmetic to schedule. Register blocking gives a thread several nearby outputs.
Our next kernel keeps four accumulators per thread. A block computes a output tile. Each thread owns columns tx, tx + 16, tx + 32, and tx + 48 within the 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}One shared value now feeds four thread-local accumulators before the thread moves to the next . The tile is wider, so the block cooperatively loads it in several passes. The downloadable source includes those loads and guards its right edge.
More registers aren't free. A wider per-thread tile may increase instruction-level parallelism and reuse, but it can reduce occupancy or spill registers into local memory. CUTLASS's efficient GEMM description notes that accumulators can consume at least half of a thread's register budget in blocked kernels.[3] Check registers per thread, local-memory traffic, active warps, and runtime together. Occupancy alone isn't the objective.
| Kernel layer | Reuse location | Accumulator owner | New constraint |
|---|---|---|---|
| scalar coalesced | hardware caches | one thread, one output | repeated loads |
| shared | block shared memory | one thread, one output | barriers and shared capacity |
| register | shared memory plus registers | one thread, four outputs | register pressure |
| WMMA | shared or global fragments plus registers | one warp, tile | dtype, layout, alignment, architecture |
Register blocking changes ownership, not the mathematical product. Tensor Core MMA changes the instruction that performs each tile product.
Hand a tile to Tensor Cores
Tensor Cores are specialized matrix multiply-accumulate units. NVIDIA exposed Volta Tensor Cores through the Warp Matrix Multiply Accumulate (WMMA) API in CUDA 9. A full warp cooperates on a fragment whose element-to-lane mapping is intentionally opaque. The original FP16 path multiplies two fragments and accumulates into a fragment, commonly in FP32.[4]
Three rules prevent subtle bugs:
- Every active lane in the warp must execute the WMMA operations coherently.
- Fragment layout and leading dimensions must match the backing memory.
- Edge fragments need padding or a separate predicated path because
load_matrix_syncisn't a per-element guarded load.
For FP16 inputs, load_matrix_sync requires a 32-byte-aligned pointer and a leading dimension divisible by 8 elements. The FP32 output store requires the same pointer alignment and a leading dimension divisible by 4 elements. Padding every dimension to a multiple of 16 satisfies those stride rules, while cudaMalloc and the tile-aligned offsets preserve pointer alignment.[5]
The lab uses row-major FP16 inputs, FP32 accumulation, and one warp per output tile:
1__global__ void gemm_wmma_aligned(const half* A, const half* B, float* C,
2 int M, int N, int K) {
3#if __CUDA_ARCH__ >= 700
4 using namespace nvcuda;
5 int row = blockIdx.y * 16;
6 int col = blockIdx.x * 16;
7
8 wmma::fragment<wmma::accumulator, 16, 16, 16, float> c_frag;
9 wmma::fill_fragment(c_frag, 0.0f);
10
11 for (int k0 = 0; k0 < K; k0 += 16) {
12 wmma::fragment<wmma::matrix_a, 16, 16, 16, half,
13 wmma::row_major> a_frag;
14 wmma::fragment<wmma::matrix_b, 16, 16, 16, half,
15 wmma::row_major> b_frag;
16 wmma::load_matrix_sync(a_frag, A + row * K + k0, K);
17 wmma::load_matrix_sync(b_frag, B + k0 * N + col, N);
18 wmma::mma_sync(c_frag, a_frag, b_frag, c_frag);
19 }
20 wmma::store_matrix_sync(C + row * N + col, c_frag, N,
21 wmma::mem_row_major);
22#endif
23}M isn't used inside the aligned kernel because the host pads all three dimensions to multiples of 16. The runtime launches only complete padded tiles.

Padding is explicit work. For the lab's default , , and , WMMA sees . Logical work remains , while padding raises executed work to . The lab prints both counts because a padded path can appear inefficient when its extra work stays hidden.
Layout is part of the type
A[row * K + k] describes row-major . B[k * N + col] describes row-major . Changing a fragment declaration to column-major without transforming data doesn't request the same matrix through a faster path. It requests different logical elements.
Libraries often name GEMMs with layout pairs such as NN, NT, or TN. Those letters describe whether operands are consumed in their stored or transposed orientation. Always record:
- logical shapes , , and ;
- physical row or column order;
- leading dimensions and batch strides;
- alignment and any padded dimensions;
- input, accumulator, and output dtypes.
An output that looks plausibly random can still be a correct multiplication of the wrong layout.
Correctness needs two references
The FP32 kernels multiply original FP32 inputs. Their CPU reference accumulates those same values in FP64. The WMMA kernel first rounds each input to FP16, so its reference converts the rounded FP16 values back to FP32 and accumulates them in FP64. Comparing WMMA against the unrounded input product would mix input quantization error with accumulation error.
The lab accepts an element when:
Absolute tolerance (atol) protects results near zero. Relative tolerance (rtol) scales with result magnitude. Neither pair is universal. Increase , widen input range, introduce cancellation, change accumulator dtype, or add a fused epilogue, and the error distribution changes.
Use three correctness shapes before timing large squares:
| Shape | Purpose | Failure it exposes |
|---|---|---|
| small odd dimensions | bad edge predicates and padding | |
| several partial tiles | wrong grid math or crop | |
| long reduction | accumulation error and K-tail handling |
Seed input generation, keep values bounded, include zeros and signed values, and fail on NaN or Inf. Then run NVIDIA Compute Sanitizer to catch memory and synchronization defects that numerical comparison may miss.[6]
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 1Run the lab and keep the receipt
Check the installed toolkit and device first. Compile for the GPU you will execute on, not for a favorite architecture copied from another machine:
1nvidia-smi --query-gpu=name,compute_cap --format=csv
2nvcc --version
3
4GPU_ARCH=${GPU_ARCH:-sm_80}
5nvcc -O3 -std=c++17 -lineinfo -arch="$GPU_ARCH" \
6 assets/gemm_lab.cu -o gemm_lab
7./gemm_lab 257 263 251 20The default build target is sm_80 only as an explicit starting choice. Use sm_70 for a Volta WMMA lab, sm_80 for Ampere, or the matching target for a newer device. A binary built only for sm_80 isn't a Hopper-specialized TMA kernel.
Exact latency and throughput are device-specific measurements, so the expected output fixes schema and correctness status without inventing performance values:
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=37879808The program performs five warmups, records CUDA events around repeated launches in one stream, synchronizes the stop event, and reports mean kernel time. For a serious receipt, collect at least median and tail values over multiple process runs. Rotate buffers or state whether warm-cache behavior is intentional.
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.
Nsight Compute can then explain a timing change. Start with a small metric set instead of collecting every counter:
1ncu --set basic --kernel-name regex:gemm_register_blocked --launch-count 1 \
2 ./gemm_lab 512 512 512 2Record requested and actual kernel names, memory sectors, achieved occupancy, register count, shared-memory use, tensor-pipe activity, and replay warnings. NVIDIA's profiler documentation also provides roofline analysis for connecting measured arithmetic intensity to compute and bandwidth ceilings.[7]
Where asynchronous copies begin
The shared and WMMA kernels issue a load, wait, compute, and repeat. A pipelined main loop instead prepares tile while computing tile :
1prime_async_copy(tile[0]);
2
3for (int t = 0; t < k_tiles; ++t) {
4 wait_until_ready(tile[t % stages]);
5 if (t + 1 < k_tiles) {
6 issue_async_copy(tile[(t + 1) % stages]);
7 }
8 mma(accumulator, tile[t % stages]);
9}This snippet expresses ownership and overlap. It isn't a portable implementation: copy instructions, barriers, address spaces, alignment, stage count, and MMA schedule all depend on target architecture.
NVIDIA documents two distinct hardware boundaries:
| Path | First compute capability | Transfer scale | Typical kernel responsibility |
|---|---|---|---|
| synchronous cooperative load | broad CUDA support | threads load scalar or vector values | explicit loads and block barriers |
| LDGSTS asynchronous global-to-shared copy | 8.0+ | small transfers | warp or block pipeline with staged shared tiles |
| Tensor Memory Accelerator (TMA) | 9.0+ | bulk multidimensional transfers | descriptor, transaction barrier, staged consumer |
LDGSTS support starts at compute capability 8.0. TMA starts at 9.0 and adds bulk multidimensional copies.[5] Current CUDA documentation also warns that some high-level cuda::memcpy_async calls fall back to synchronous copies when alignment or size requirements aren't met, while lower-level TMA APIs make violating those requirements undefined behavior.[8]
An 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.
Hopper's warp-group MMA path adds another boundary. WGMMA is collective across four contiguous warps and targets sm_90a; it has its own fence, commit, and wait protocol.[9] Replacing wmma::mma_sync with an instruction name doesn't produce a correct Hopper pipeline.
CUTLASS shows why the chapter stops at this line. Its GEMM main loops double-buffer shared-memory tiles and warp fragments so memory movement overlaps arithmetic, then select threadblock schedules and epilogues around the target shape.[3] Use the CUTLASS profiler to obtain a verified library receipt rather than treating demonstration examples as benchmarks:
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 --verification-enabled=trueCUTLASS kernel availability and command arguments vary by built version and target architecture. Record the repository tag or commit, build configuration, kernel name, and profiler verification result. NVIDIA's current CUTLASS examples page explicitly says examples aren't performance benchmarks and directs measurement work to the profiler.[10]
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 access | unpadded dimensions or bad alignment | print and pointers | pad, align, and launch complete fragments |
| 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, shape is too small, or timing is noisy | 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 now has a stable meaning. Coalescing repairs warp transactions. Shared tiling captures reuse. Register blocking increases per-thread reuse and independent accumulation. WMMA moves a cooperative tile onto Tensor Cores. Async copies and TMA overlap movement with compute, but only after architecture-specific synchronization and layout contracts are correct.