Optimizing GEMM in HIP#
Matrix multiplication is one of the most fundamental GPU workloads. It underlies the compute-intensive layers of deep neural networks - fully connected layers, convolutional layers expressed as implicit GEMMs, and attention mechanisms - and is central to scientific computing, computer vision, and recommendation systems. GPUs are heavily optimized for matrix multiplication, and understanding how to write an efficient GEMM kernel is an effective way to learn how GPU hardware resources interact.
This tutorial walks through seven progressive optimization steps applied to a general-purpose single-precision (FP32) matrix multiplication kernel (General Matrix Multiply, or GEMM: \(\pmb{C} = \pmb{A} \times \pmb{B}\)). Each step builds on the previous one, introducing a specific technique and explaining how to measure its effect with the ROCm performance analysis stack.
The complete source files for all steps are available at:
Note
All examples target \(4096 \times 4096\) matrices in FP32 row-major
layout and are validated against the identity-matrix test
(\(B = I \implies C = A\)). They compile with
amdclang++ -O3 -std=c++17 and run on any ROCm-supported GPU architecture.
Prerequisites#
Before starting this tutorial, ensure the following are in place.
ROCm installed and
amdclang++available onPATH.Familiarity with the HIP execution model (grids, blocks, wavefronts) and its mapping to AMD GPU hardware (dispatches, workgroups, wavefronts).
Application tracing and profiling using rocprofv3 installed for performance analysis.
Tip
Hardware performance counter availability varies by ROCm version, Linux
kernel version, and system permissions. A counter that appears in
rocprofv3 --list-avail might still return only zero values on a given
system. If that happens, the counter is unavailable. Kernel duration
(End_Timestamp - Start_Timestamp from rocprofv3 --kernel-trace) is
always available and provides a reliable baseline across all steps and
architectures.
GEMM fundamentals#
A GEMM multiplies an \(M \times K\) matrix \(\pmb{A}\) by a \(K \times N\) matrix \(\pmb{B}\) to produce an \(M \times N\) output matrix \(\pmb{C}\). Each element \(C_{ij}\) is the inner product of the i-th row of \(\pmb{A}\) and the j-th column of \(\pmb{B}\):
A CPU implementation applies three nested loops over m, n, and k, performing one multiply-accumulate per iteration - 2 × M × N × K scalar operations in total.
A GPU implementation is embarrassingly parallel across the output elements. A HIP kernel assigns one thread (or a small tile of threads) to each output element, eliminating the m and n loops entirely and leaving only the k reduction loop inside each thread. With \(M \times N\) output elements and a modern AMD GPU fielding tens of thousands of concurrent threads, the full output matrix can be computed in a single dispatch - provided data can be supplied fast enough to keep the Compute Units (CUs) busy.
Background: the GEMM arithmetic intensity#
For an \(M \times K \times N\) GEMM the arithmetic intensity - floating-point operations per byte of DRAM traffic - is:
For \(M = N = K = n\) this simplifies to \(\frac{n}{6}\). With \(n = 4096\) that gives roughly 683 FLOPs/byte, far above the roofline ridge point of any current AMD GPU. GEMM is therefore compute-bound in principle - but only if data is supplied fast enough to keep the compute units busy. The naive kernel falls well below the roofline because it is memory-bound in practice: global memory latency stalls dominate (see Roofline model for background on roofline analysis and performance bottlenecks).
The optimization steps that follow progressively close the gap between actual and theoretical throughput by improving data reuse and instruction-level efficiency.
Step 1: Naive kernel#
The naive kernel assigns one thread per output element. Each thread reads a full row of \(\pmb{A}\) and a full column of \(\pmb{B}\) directly from global memory.
While this maps naturally onto the GPU’s parallel execution model, it produces
severe cache thrashing. Consider that any element \(A[i][k]\) is needed
by all N threads that compute a different output column in the same row, and
\(B[k][j]\) is needed by all M threads that compute a different output
row in the same column. With tens of thousands of threads in flight
simultaneously, the working set far exceeds the L2 cache, so data that should
be reused is evicted before the next thread requests it. The result is that
total DRAM traffic is a large multiple of the minimum required bandwidth
(sizeof(float) * (M*K + K*N + M*N)), and the kernel is firmly
memory-bound despite GEMM’s high arithmetic intensity in principle.
1__global__ void matrix_multiply_naive(const float* __restrict__ A,
2 const float* __restrict__ B,
3 float* __restrict__ C,
4 int m,
5 int n,
6 int k)
7{
8 const int col = blockIdx.x * blockDim.x + threadIdx.x;
9 const int row = blockIdx.y * blockDim.y + threadIdx.y;
10
11 if(row < m && col < n)
12 {
13 float sum = 0.0f;
14 for(int i = 0; i < k; ++i)
15 {
16 sum += A[row * k + i] * B[i * n + col];
17 }
18 C[row * n + col] = sum;
19 }
20}
Launch configuration:
1const dim3 block(BLOCK_SIZE, BLOCK_SIZE);
2const dim3 grid((N + BLOCK_SIZE - 1) / BLOCK_SIZE,
3 (M + BLOCK_SIZE - 1) / BLOCK_SIZE);
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_naive.hip -o mm_naive
./mm_naive
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_naive
Kernel duration is End_Timestamp - Start_Timestamp (both in nanoseconds).
The trace also captures VGPR_Count and Scratch_Size for each dispatch.
The --kernel-trace CSV is sufficient to establish the baseline: the naive
kernel’s End_Timestamp - Start_Timestamp will be the slowest of all steps
because every global memory access is a cache miss.
Step 2: LDS tiling#
The root cause of the naive kernel’s cache thrashing is that all threads share a single transparent L2 cache with no way to guarantee that a loaded value stays resident until every thread that needs it has read it. AMD GPUs expose Local Data Share (LDS) - a low-latency, high-bandwidth on-chip memory that is explicitly managed by the programmer, functioning as a programmable L1 cache. Unlike CPU hardware caches, data placed in LDS stays there until the kernel explicitly overwrites or discards it.
The key insight is that every element of \(\pmb{A}\) is used by \(N\)
threads (one per output column) and every element of \(\pmb{B}\) is used by
\(M\) threads. Caching a TILE_SIZE * TILE_SIZE strip of \(\pmb{A}\)
and \(\pmb{B}\) in LDS lets all TILE_SIZE² threads in a block reuse
that data without touching global memory again.
1constexpr int TILE_SIZE = 16;
Shared memory allocation:
1// Allocate two LDS tiles: one strip of A (row-major) and one of B (row-major).
2// Each tile holds TILE_SIZE x TILE_SIZE float elements = 1 KiB.
3__shared__ float tile_a[TILE_SIZE][TILE_SIZE];
4__shared__ float tile_b[TILE_SIZE][TILE_SIZE];
Load phase (cooperative, one element per thread):
1// Cooperative load: thread (tx, ty) loads one element of each tile.
2//
3// tile_a ← A[row][t*TILE_SIZE + tx]
4// Row comes from the block's output row; column strides across K.
5const int a_col = t * TILE_SIZE + tx;
6tile_a[ty][tx] = (row < m && a_col < k) ? A[row * k + a_col] : 0.0f;
7
8// tile_b ← B[t*TILE_SIZE + ty][col]
9// Row strides across K; column comes from the block's output column.
10const int b_row = t * TILE_SIZE + ty;
11tile_b[ty][tx] = (b_row < k && col < n) ? B[b_row * n + col] : 0.0f;
12
13// Ensure all threads have written their element before any thread reads.
14__syncthreads();
Compute phase (inner product from LDS):
1// Accumulate the partial dot product for this tile from LDS.
2// Both tile_a and tile_b reside in LDS; no global memory is touched here.
3for(int i = 0; i < TILE_SIZE; ++i)
4{
5 sum += tile_a[ty][i] * tile_b[i][tx];
6}
7
8// Ensure all threads have finished reading before the next iteration
9// overwrites the tiles.
10__syncthreads();
LDS bank conflict analysis#
Moving data into LDS is only half the battle - how threads access that data determines whether the LDS delivers its full bandwidth. LDS is divided into independently addressable banks. When threads in the same cycle access different addresses that map to the same bank, the hardware must serialize those accesses. This is called a bank conflict, and it directly reduces the effective LDS bandwidth by the degree of the conflict (a k-way conflict takes k cycles instead of one).
Bank mapping is straightforward: consecutive 4-byte words are assigned to
consecutive banks in round-robin order. For a 32-bank LDS, word at byte
address a maps to bank (a / 4) % 32. Two threads accessing the
same address are not in conflict - the hardware broadcasts the value to
both.
The number of LDS banks varies across AMD GPU architectures:
Architecture |
Banks |
Entries per bank (4 bytes each) |
|---|---|---|
CDNA |
32 |
512 |
CDNA2 |
32 |
512 |
CDNA3 |
32 |
512 |
CDNA4 |
64 |
640 |
RDNA2 |
64 |
512 |
RDNA3 |
64 |
512 |
RDNA3.5 |
64 |
512 |
RDNA4 |
64 |
512 |
Note
On RDNA GPUs the 64 banks are sub-divided into two sets of 32 banks, each
affiliated with a pair of SIMD32 units within the WGP. A wavefront
executes on one SIMD32 and maps its accesses to the affiliated 32-bank set.
In CU mode (-mcumode), a workgroup runs on a single CU and accesses one
set of 32 banks; in WGP mode (the default) the workgroup spans the full
WGP.
For this kernel’s compute phase - an inner product over the K-strip:
sum += tile_a[ty][i] * tile_b[i][tx];
the access pattern with
floatdata is **inherently conflict-free
regardless of tile size**:
tile_a[ty][i]: all threads in a wavefront that share the sametyread the same address. The hardware broadcasts the value - no conflict.tile_b[i][tx]: each thread has a uniquetx, and because eachfloatis exactly 4 bytes (= one bank slot), consecutivetxvalues always map to consecutive banks. No two threads in the same cycle can hit the same bank at a different address, regardless of how largeTILE_SIZEis. Theiloop iterates sequentially within each thread, so accesses to different rows oftile_bare never concurrent.
In other words, with FP32 data and the simple inner-product pattern of this step, bank conflicts are a non-issue. The bank mechanism is worth understanding now, however, because it becomes a real concern later.
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_lds.hip -o mm_lds
./mm_lds
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_lds
Compare End_Timestamp - Start_Timestamp against the Step 1 baseline. To
measure L2-to-High Bandwidth Memory (HBM) read traffic on CDNA GPUs:
# CDNA only
rocprofv3 --pmc TCP_TCC_READ_REQ_sum --output-format csv -- ./mm_lds
What to observe after this step#
Compare the following counters against the Step 1 naive kernel baseline.
Counter |
What to look for |
|---|---|
Kernel duration (kernel-trace CSV) |
|
|
CDNA only: reduction proportional to |
|
Should remain at or near zero for |
Step 3: Register tiling#
In the LDS kernel each thread computes exactly one output element, reading
TILE_SIZE values from tile_a and TILE_SIZE values from tile_b
for every K-strip. If instead each thread computes a
THREAD_TILE_M × THREAD_TILE_N sub-tile in registers, it amortizes the LDS
load cost across THREAD_TILE_M × THREAD_TILE_N outputs.
The outer product of a length-THREAD_TILE_M column fragment of A and a
length-THREAD_TILE_N row fragment of B produces a full
THREAD_TILE_M × THREAD_TILE_N block of C contributions using only
THREAD_TILE_M + THREAD_TILE_N LDS reads instead of
THREAD_TILE_M × THREAD_TILE_N.
Tile parameters:
1constexpr int BLOCK_TILE_M = 128; // Rows of C computed by one block
2constexpr int BLOCK_TILE_N = 128; // Columns of C computed by one block
3constexpr int K_TILE_SIZE = 16; // K-strip width loaded into LDS each iteration
4constexpr int THREAD_TILE_M = 8; // Rows of C computed by one thread
5constexpr int THREAD_TILE_N = 8; // Columns of C computed by one thread
Transposed tile_b layout:
tile_b is stored transposed in LDS as tile_b_T[BLOCK_TILE_N][K_TILE_SIZE]
(column-major for B). This layout choice is motivated by two concerns:
Stride-1 reads during the outer-product compute phase: In the outer-product pattern, each thread loads a fragment of
THREAD_TILE_Nconsecutive elements fromtile_b_Talong thekidimension. Because the inner dimension oftile_b_TisK_TILE_SIZE, consecutivekivalues are adjacent in memory - stride-1 access. Without transposition (tile_b[K_TILE_SIZE][BLOCK_TILE_N]), the fragment load would stride across the largeBLOCK_TILE_Ndimension, producing scattered LDS reads.Bank-conflict safety for sub-4-byte types: In Step 2’s simple inner-product loop, each
floatoccupies exactly one 4-byte LDS bank slot, so bank conflicts cannot arise regardless of tile dimensions. This property does not hold for smaller data types. When architecture-specific builtins introduce half-precision (FP16, 2 bytes) or quarter-precision (FP8, 1 byte) data in follow-up sections, multiple elements pack into a single 4-byte bank slot. If the row stride oftile_bequals or is a multiple of the bank count, different threads can address different sub-word elements within the same bank slot, producing conflicts that are impossible with FP32. The transposed layout decouples the fragment access stride (K_TILE_SIZE) from the tile’s outer dimension (BLOCK_TILE_N), avoiding this class of conflict regardless of element size or bank count.
LDS allocation:
1// tile_a: row-major strip of A, shape [BLOCK_TILE_M][K_TILE_SIZE]
2__shared__ float tile_a[BLOCK_TILE_M][K_TILE_SIZE];
3// tile_b_T: column-major strip of B (transposed in LDS), shape [BLOCK_TILE_N][K_TILE_SIZE]
4// Storing transposed eliminates the bank-conflict risk that arises when
5// TILE_SIZE equals the LDS bank count, and produces stride-1 reads during
6// both the load phase and the compute phase.
7__shared__ float tile_b_T[BLOCK_TILE_N][K_TILE_SIZE];
Cooperative tile load:
1// --- Cooperative load of tile_a (BLOCK_TILE_M x K_TILE_SIZE) ---
2// Each thread loads one or more elements by striding through the flat
3// tile index space with step = num_threads.
4{
5 const int tile_elems = BLOCK_TILE_M * K_TILE_SIZE;
6 for(int idx = tid; idx < tile_elems; idx += num_threads)
7 {
8 const int tile_row = idx / K_TILE_SIZE;
9 const int tile_col = idx % K_TILE_SIZE;
10 const int global_row = block_row_start + tile_row;
11 const int global_col = t * K_TILE_SIZE + tile_col;
12 tile_a[tile_row][tile_col] =
13 (global_row < m && global_col < k)
14 ? A[global_row * k + global_col]
15 : 0.0f;
16 }
17}
18
19// --- Cooperative load of tile_b_T (BLOCK_TILE_N x K_TILE_SIZE, transposed) ---
20// We load B[b_row][b_col] into tile_b_T[b_col - block_col_start][b_row - t*K].
21// Equivalently: thread loads the element at (col_within_tile, k_within_tile)
22// of the transposed tile, reading B in row-major order.
23{
24 const int tile_elems = BLOCK_TILE_N * K_TILE_SIZE;
25 for(int idx = tid; idx < tile_elems; idx += num_threads)
26 {
27 const int tile_col_idx = idx / K_TILE_SIZE; // column within block tile
28 const int tile_k_idx = idx % K_TILE_SIZE; // k index within strip
29 const int global_row = t * K_TILE_SIZE + tile_k_idx;
30 const int global_col = block_col_start + tile_col_idx;
31 tile_b_T[tile_col_idx][tile_k_idx] =
32 (global_row < k && global_col < n)
33 ? B[global_row * n + global_col]
34 : 0.0f;
35 }
36}
37
38__syncthreads();
Outer-product accumulation:
1// Outer-product accumulation entirely in registers.
2// For each k-index within the strip:
3// - Load a column fragment of tile_a into registers (a_frag).
4// - Load a row fragment of tile_b_T into registers (b_frag).
5// - Accumulate the TM x TN outer product into acc.
6for(int ki = 0; ki < K_TILE_SIZE; ++ki)
7{
8 float a_frag[THREAD_TILE_M];
9 #pragma unroll
10 for(int i = 0; i < THREAD_TILE_M; ++i)
11 {
12 a_frag[i] = tile_a[thread_row + i][ki];
13 }
14
15 float b_frag[THREAD_TILE_N];
16 #pragma unroll
17 for(int j = 0; j < THREAD_TILE_N; ++j)
18 {
19 b_frag[j] = tile_b_T[thread_col + j][ki];
20 }
21
22 #pragma unroll
23 for(int i = 0; i < THREAD_TILE_M; ++i)
24 {
25 #pragma unroll
26 for(int j = 0; j < THREAD_TILE_N; ++j)
27 {
28 acc[i][j] += a_frag[i] * b_frag[j];
29 }
30 }
31}
32
33__syncthreads();
Write-back:
1// Write the register accumulator to global memory.
2// Each store is a coalesced write: threads with consecutive tx values write
3// to consecutive columns (thread_col strides by THREAD_TILE_N = 8, but the
4// inner j loop produces 8 consecutive output columns per thread, so adjacent
5// threads in x cover adjacent 8-wide strips → effectively coalesced).
6#pragma unroll
7for(int i = 0; i < THREAD_TILE_M; ++i)
8{
9 #pragma unroll
10 for(int j = 0; j < THREAD_TILE_N; ++j)
11 {
12 const int out_row = block_row_start + thread_row + i;
13 const int out_col = block_col_start + thread_col + j;
14 if(out_row < m && out_col < n)
15 {
16 C[out_row * n + out_col] = acc[i][j];
17 }
18 }
19}
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_register_tiling.hip -o mm_register_tiling
./mm_register_tiling
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_register_tiling
For LDS and arithmetic instruction counts:
rocprofv3 --pmc SQ_INSTS_LDS --output-format csv -- ./mm_register_tiling
What to observe#
Compare the following counters against the LDS tiling kernel from Step 2.
Counter |
What to look for |
|---|---|
|
Increase in VALU instructions per wave (more Fused Multiply-Accumulate (FMA) operations per LDS read). |
|
Reduction in LDS instructions per wave ( |
|
Reduction in LDS stall cycles (register reuse hides LDS latency). |
Tile parameter tuning guidance#
The tile dimensions (BLOCK_TILE_M, BLOCK_TILE_N, K_TILE_SIZE,
THREAD_TILE_M, THREAD_TILE_N) are not one-size-fits-all. Optimal
values depend on several interacting constraints:
- Matrix dimensions
Tile sizes should evenly divide the matrix dimensions to avoid boundary handling overhead. Padding the matrices to a multiple of the tile size is common in production GEMM libraries.
- LDS capacity
Each workgroup allocates
BLOCK_TILE_M × K_TILE_SIZEandK_TILE_SIZE × BLOCK_TILE_Nfloats in LDS. LDS capacity varies across AMD GPU architectures (typically 64–160 KiB per compute unit). Exceeding the LDS budget reduces occupancy by limiting how many workgroups can be resident simultaneously.- Register file
Each thread holds a
THREAD_TILE_M × THREAD_TILE_Naccumulator array plus fragment temporaries. Larger thread tiles increase arithmetic intensity but consume more Vector General-Purpose Registers (VGPRs), reducing occupancy. Step 6 addresses this tradeoff directly.- Architecture-to-architecture variation
LDS bank counts, wavefront widths, VGPR file size, and L1/L2 cache line sizes differ across CDNA and RDNA families. Tile sizes that are optimal on a CDNA3 GPU might not be optimal on an RDNA4 GPU. Use
rocprofv3 --pmcto measure LDS efficiency, VGPR usage, and occupancy on each target, and re-tune accordingly.
Step 4: Double buffering#
Every iteration of the K-strip loop stalls at __syncthreads() waiting for
LDS tile loads to complete before compute can begin. Software double buffering
hides this latency by maintaining two pairs of LDS buffers - one pair for
A tiles and one for B tiles - each with a ping and a pong slot.
The next tile is loaded into the background slot while the current slot is
being consumed.
Both buffering strategies are hidden behind a TilePolicy interface so that
the kernel body is identical regardless of the chosen approach.
Policy interface overview:
Method |
Responsibility |
|---|---|
|
Load tile 0 into buffer 0 and synchronize (double-buffer only; no-op for single). |
|
Issue the load for the next tile into the background buffer. |
|
Synchronize before compute (single-buffer: |
|
Synchronize after compute (both: |
|
Return which buffer to read for the current iteration. |
Single-buffer policy (baseline - same logic as Step 3):
1struct SingleBufferTilePolicy
2{
3 static constexpr int num_buffers = 1;
4
5 // LDS holds one pair of tiles.
6 struct SharedStorage
7 {
8 float tile_a[BLOCK_TILE_M][K_TILE_SIZE];
9 float tile_b_T[BLOCK_TILE_N][K_TILE_SIZE];
10 };
11
12 // No preloading: the first prefetch() call loads tile 0.
13 static __device__ void prologue(SharedStorage& /*smem*/,
14 const float* /*A*/,
15 const float* /*B*/,
16 int /*block_row_start*/,
17 int /*block_col_start*/,
18 int /*m*/,
19 int /*n*/,
20 int /*k*/,
21 int /*tid*/,
22 int /*num_threads*/,
23 int /*num_tiles*/)
24 {}
25
26 // Load tile `tile_idx - 1` (the CURRENT tile t) into the single buffer.
27 // The kernel calls prefetch(t + 1), so tile_idx = t + 1. We load
28 // tile t = tile_idx - 1 here; acquire() then __syncthreads() to ensure
29 // it is visible before compute begins.
30 static __device__ void prefetch(SharedStorage& smem,
31 const float* A,
32 const float* B,
33 int tile_idx,
34 int block_row_start,
35 int block_col_start,
36 int m,
37 int n,
38 int k,
39 int tid,
40 int num_threads,
41 int /*num_tiles*/)
42 {
43 // tile_idx = t + 1, so the tile to load is t = tile_idx - 1.
44 // The loop runs t in [0, num_tiles), so tile_idx in [1, num_tiles],
45 // and load_idx in [0, num_tiles - 1] - always in range.
46 const int load_idx = tile_idx - 1;
47
48 const int tile_elems_a = BLOCK_TILE_M * K_TILE_SIZE;
49 for(int idx = tid; idx < tile_elems_a; idx += num_threads)
50 {
51 const int tile_row = idx / K_TILE_SIZE;
52 const int tile_col = idx % K_TILE_SIZE;
53 const int global_row = block_row_start + tile_row;
54 const int global_col = load_idx * K_TILE_SIZE + tile_col;
55 smem.tile_a[tile_row][tile_col] =
56 (global_row < m && global_col < k)
57 ? A[global_row * k + global_col]
58 : 0.0f;
59 }
60
61 const int tile_elems_b = BLOCK_TILE_N * K_TILE_SIZE;
62 for(int idx = tid; idx < tile_elems_b; idx += num_threads)
63 {
64 const int tile_col_idx = idx / K_TILE_SIZE;
65 const int tile_k_idx = idx % K_TILE_SIZE;
66 const int global_row = load_idx * K_TILE_SIZE + tile_k_idx;
67 const int global_col = block_col_start + tile_col_idx;
68 smem.tile_b_T[tile_col_idx][tile_k_idx] =
69 (global_row < k && global_col < n)
70 ? B[global_row * n + global_col]
71 : 0.0f;
72 }
73 }
74
75 // Wait for the prefetch just issued to complete.
76 static __device__ void acquire(int /*tile_idx*/)
77 {
78 __syncthreads();
79 }
80
81 // Protect the buffer: no thread may start the next prefetch until all
82 // threads have finished reading the current tile.
83 static __device__ void release(int /*tile_idx*/)
84 {
85 __syncthreads();
86 }
87
88 // Always return buffer index 0.
89 static __device__ int buf_idx(int /*tile_idx*/)
90 {
91 return 0;
92 }
93
94 static __device__ const float* get_tile_a(const SharedStorage& smem, int /*buf*/)
95 {
96 return &smem.tile_a[0][0];
97 }
98
99 static __device__ const float* get_tile_b_T(const SharedStorage& smem, int /*buf*/)
100 {
101 return &smem.tile_b_T[0][0];
102 }
103};
Software double-buffer policy:
1struct SoftwareDoubleBufferTilePolicy
2{
3 static constexpr int num_buffers = 2;
4
5 // LDS holds TWO pairs of tiles, indexed by [buf][...].
6 struct SharedStorage
7 {
8 float tile_a[2][BLOCK_TILE_M][K_TILE_SIZE];
9 float tile_b_T[2][BLOCK_TILE_N][K_TILE_SIZE];
10 };
11
12 // Load tile 0 into buf[0] and synchronise so that the first iteration
13 // can call acquire() as a no-op.
14 static __device__ void prologue(SharedStorage& smem,
15 const float* A,
16 const float* B,
17 int block_row_start,
18 int block_col_start,
19 int m,
20 int n,
21 int k,
22 int tid,
23 int num_threads,
24 int /*num_tiles*/)
25 {
26 const int tile_elems_a = BLOCK_TILE_M * K_TILE_SIZE;
27 for(int idx = tid; idx < tile_elems_a; idx += num_threads)
28 {
29 const int tile_row = idx / K_TILE_SIZE;
30 const int tile_col = idx % K_TILE_SIZE;
31 const int global_row = block_row_start + tile_row;
32 const int global_col = tile_col; // tile 0: K offset = 0
33 smem.tile_a[0][tile_row][tile_col] =
34 (global_row < m && global_col < k)
35 ? A[global_row * k + global_col]
36 : 0.0f;
37 }
38
39 const int tile_elems_b = BLOCK_TILE_N * K_TILE_SIZE;
40 for(int idx = tid; idx < tile_elems_b; idx += num_threads)
41 {
42 const int tile_col_idx = idx / K_TILE_SIZE;
43 const int tile_k_idx = idx % K_TILE_SIZE;
44 const int global_row = tile_k_idx; // tile 0: K offset = 0
45 const int global_col = block_col_start + tile_col_idx;
46 smem.tile_b_T[0][tile_col_idx][tile_k_idx] =
47 (global_row < k && global_col < n)
48 ? B[global_row * n + global_col]
49 : 0.0f;
50 }
51
52 // Ensure tile 0 is fully in LDS before the main loop begins.
53 __syncthreads();
54 }
55
56 // Issue the load for tile `tile_idx` into the alternate buffer.
57 // This load runs concurrently with the compute on the current buffer.
58 static __device__ void prefetch(SharedStorage& smem,
59 const float* A,
60 const float* B,
61 int tile_idx,
62 int block_row_start,
63 int block_col_start,
64 int m,
65 int n,
66 int k,
67 int tid,
68 int num_threads,
69 int num_tiles)
70 {
71 if(tile_idx >= num_tiles)
72 {
73 return;
74 }
75
76 // Write into the buffer that is NOT currently being read.
77 const int dst_buf = tile_idx % 2;
78
79 const int tile_elems_a = BLOCK_TILE_M * K_TILE_SIZE;
80 for(int idx = tid; idx < tile_elems_a; idx += num_threads)
81 {
82 const int tile_row = idx / K_TILE_SIZE;
83 const int tile_col = idx % K_TILE_SIZE;
84 const int global_row = block_row_start + tile_row;
85 const int global_col = tile_idx * K_TILE_SIZE + tile_col;
86 smem.tile_a[dst_buf][tile_row][tile_col] =
87 (global_row < m && global_col < k)
88 ? A[global_row * k + global_col]
89 : 0.0f;
90 }
91
92 const int tile_elems_b = BLOCK_TILE_N * K_TILE_SIZE;
93 for(int idx = tid; idx < tile_elems_b; idx += num_threads)
94 {
95 const int tile_col_idx = idx / K_TILE_SIZE;
96 const int tile_k_idx = idx % K_TILE_SIZE;
97 const int global_row = tile_idx * K_TILE_SIZE + tile_k_idx;
98 const int global_col = block_col_start + tile_col_idx;
99 smem.tile_b_T[dst_buf][tile_col_idx][tile_k_idx] =
100 (global_row < k && global_col < n)
101 ? B[global_row * n + global_col]
102 : 0.0f;
103 }
104 // No sync here: the load runs concurrently with compute on the other buffer.
105 }
106
107 // The current buffer is already filled (by prologue or the previous release).
108 // No synchronisation is needed here.
109 static __device__ void acquire(int /*tile_idx*/) {}
110
111 // Single __syncthreads() serves dual purpose:
112 // - Ensures every thread has finished reading buf[tile_idx % 2]
113 // (so the next prefetch may safely overwrite it).
114 // - Ensures the prefetch issued at the top of this iteration
115 // (loading into buf[(tile_idx+1) % 2]) has completed.
116 static __device__ void release(int /*tile_idx*/)
117 {
118 __syncthreads();
119 }
120
121 static __device__ int buf_idx(int tile_idx)
122 {
123 return tile_idx % 2;
124 }
125
126 static __device__ const float* get_tile_a(const SharedStorage& smem, int buf)
127 {
128 return &smem.tile_a[buf][0][0];
129 }
130
131 static __device__ const float* get_tile_b_T(const SharedStorage& smem, int buf)
132 {
133 return &smem.tile_b_T[buf][0][0];
134 }
135};
Compile-time policy validation:
1template<typename TilePolicy>
2struct TilePolicyTraits
3{
4 static_assert(TilePolicy::num_buffers == 1 || TilePolicy::num_buffers == 2,
5 "TilePolicy::num_buffers must be 1 (single buffer) or 2 (double buffer)");
6
7 // SharedStorage must be a non-empty, trivially-destructible aggregate
8 // that the compiler can place in __shared__ memory.
9 static_assert(sizeof(typename TilePolicy::SharedStorage) > 0,
10 "TilePolicy::SharedStorage must have non-zero size");
11 static_assert(std::is_trivially_destructible_v<typename TilePolicy::SharedStorage>,
12 "TilePolicy::SharedStorage must be trivially destructible "
13 "(required for __shared__ variables)");
14};
Unified kernel template:
1template<typename TilePolicy>
2__global__ void matrix_multiply_buffered(const float* __restrict__ A,
3 const float* __restrict__ B,
4 float* __restrict__ C,
5 int m,
6 int n,
7 int k)
8{
9 // Instantiate the trait checker; all static_asserts fire at compile time.
10 (void)TilePolicyTraits<TilePolicy>{};
11
12 __shared__ typename TilePolicy::SharedStorage smem;
13
14 const int tx = threadIdx.x;
15 const int ty = threadIdx.y;
16 const int tid = ty * blockDim.x + tx;
17 const int num_threads = blockDim.x * blockDim.y;
18
19 const int thread_row = ty * THREAD_TILE_M;
20 const int thread_col = tx * THREAD_TILE_N;
21 const int block_row_start = blockIdx.y * BLOCK_TILE_M;
22 const int block_col_start = blockIdx.x * BLOCK_TILE_N;
23
24 float acc[THREAD_TILE_M][THREAD_TILE_N] = {};
25
26 const int num_tiles = (k + K_TILE_SIZE - 1) / K_TILE_SIZE;
27
28 // [Sphinx prologue start]
29 // Prologue: SingleBuffer → no-op; DoubleBuffer → preload tile 0 + sync.
30 TilePolicy::prologue(smem, A, B, block_row_start, block_col_start,
31 m, n, k, tid, num_threads, num_tiles);
32 // [Sphinx prologue end]
33
34 for(int t = 0; t < num_tiles; ++t)
35 {
36 // [Sphinx prefetch acquire start]
37 // Issue the load for the NEXT tile into the alternate buffer (or the
38 // single buffer for SingleBufferTilePolicy).
39 TilePolicy::prefetch(smem, A, B, t + 1, block_row_start, block_col_start,
40 m, n, k, tid, num_threads, num_tiles);
41
42 // Ensure the CURRENT tile (tile t) is fully in LDS before reading it.
43 // SingleBuffer: __syncthreads() after the load just issued above.
44 // DoubleBuffer: no-op (tile t was already in LDS from the previous release).
45 TilePolicy::acquire(t);
46 // [Sphinx prefetch acquire end]
47
48 // Outer-product accumulation from LDS (register tiling, same as Step 3).
49 const float* ta = TilePolicy::get_tile_a(smem, TilePolicy::buf_idx(t));
50 const float* tb_T = TilePolicy::get_tile_b_T(smem, TilePolicy::buf_idx(t));
51
52 for(int ki = 0; ki < K_TILE_SIZE; ++ki)
53 {
54 float a_frag[THREAD_TILE_M];
55 #pragma unroll
56 for(int i = 0; i < THREAD_TILE_M; ++i)
57 {
58 // tile_a is laid out as [BLOCK_TILE_M][K_TILE_SIZE]
59 a_frag[i] = ta[(thread_row + i) * K_TILE_SIZE + ki];
60 }
61
62 float b_frag[THREAD_TILE_N];
63 #pragma unroll
64 for(int j = 0; j < THREAD_TILE_N; ++j)
65 {
66 // tile_b_T is laid out as [BLOCK_TILE_N][K_TILE_SIZE]
67 b_frag[j] = tb_T[(thread_col + j) * K_TILE_SIZE + ki];
68 }
69
70 #pragma unroll
71 for(int i = 0; i < THREAD_TILE_M; ++i)
72 {
73 #pragma unroll
74 for(int j = 0; j < THREAD_TILE_N; ++j)
75 {
76 acc[i][j] += a_frag[i] * b_frag[j];
77 }
78 }
79 }
80
81 // [Sphinx release start]
82 // SingleBuffer: second __syncthreads() – protects LDS before prefetch
83 // overwrites it in the next iteration.
84 // DoubleBuffer: single __syncthreads() – ensures:
85 // (a) compute has finished reading buf[t%2], so the next prefetch
86 // may safely write into it.
87 // (b) the prefetch issued above (for tile t+1 into buf[(t+1)%2])
88 // has completed before the next iteration's acquire() returns.
89 TilePolicy::release(t);
90 // [Sphinx release end]
91 }
92
93 // Write accumulator to global C
94 #pragma unroll
95 for(int i = 0; i < THREAD_TILE_M; ++i)
96 {
97 #pragma unroll
98 for(int j = 0; j < THREAD_TILE_N; ++j)
99 {
100 const int out_row = block_row_start + thread_row + i;
101 const int out_col = block_col_start + thread_col + j;
102 if(out_row < m && out_col < n)
103 {
104 C[out_row * n + out_col] = acc[i][j];
105 }
106 }
107 }
108}
Note
The LDS footprint doubles with software double buffering: two copies of the
A and B tile buffers are needed instead of one. For the parameters in this
example
(BLOCK_TILE_M = BLOCK_TILE_N = 128, K_TILE_SIZE = 16):
Single-buffer LDS: 128 × 16 × 4 × 2 = 16 KiB
Double-buffer LDS: 16 KiB × 2 = 32 KiB
This is within the 64–160 KiB LDS budget on all supported architectures, but
leaves less headroom for occupancy. Use rocprofv3 --pmc MeanOccupancyPerCU
to verify occupancy does not drop when switching from single- to double-buffered
policy.
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_double_buffer.hip -o mm_double_buffer
./mm_double_buffer
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_double_buffer
To measure LDS stall cycles (RDNA3 and all CDNA):
# RDNA3 and all CDNA
rocprofv3 --pmc SQ_WAIT_INST_LDS --output-format csv -- ./mm_double_buffer
What to observe#
Compare the following counters against the register tiling kernel from Step 3.
Counter |
What to look for |
|---|---|
|
Reduction in LDS stall cycles (load latency hidden by prefetch). |
Kernel duration (kernel-trace CSV) |
Should remain similar to Step 3 (prefetch hides latency but does not reduce total data fetched). |
|
CDNA only: roughly constant vs Step 3 (same number of L2-to-HBM reads; only latency is hidden, not traffic). |
Step 5: Vectorized loads#
Each global memory load instruction in the tile-loading loop fetches one
float per thread. Replacing it with a float2 or float4 load
fetches 2 or 4 float values per instruction - the same total data moves
through the cache hierarchy, but in fewer instructions. This reduces pressure
on the VMEM instruction-issue pipeline and can improve overall throughput when
instruction issue is the bottleneck rather than memory bandwidth.
To put this in context, consider how a single coalesced scalar load of a
float maps to cache lines on each architecture family:
Architecture |
Wavefront width |
L1/L0 cache line size |
Cache lines per coalesced scalar load |
|---|---|---|---|
CDNA |
64 lanes |
64 bytes |
64 × 4 B / 64 B = 4 |
CDNA2 |
64 lanes |
64 bytes |
64 × 4 B / 64 B = 4 |
CDNA3 |
64 lanes |
128 bytes |
64 × 4 B / 128 B = 2 |
CDNA4 |
64 lanes |
128 bytes |
64 × 4 B / 128 B = 2 |
RDNA2 |
32 lanes |
128 bytes |
32 × 4 B / 128 B = 1 |
RDNA3 |
32 lanes |
128 bytes |
32 × 4 B / 128 B = 1 |
RDNA3.5 |
32 lanes |
128 bytes |
32 × 4 B / 128 B = 1 |
RDNA4 |
32 lanes |
128 bytes |
32 × 4 B / 128 B = 1 |
On RDNA GPUs, a coalesced scalar float load already fills exactly one cache
line - wider vector loads do not reduce cache traffic. On CDNA and CDNA2 a
scalar load spans 4 cache lines. On CDNA3 and CDNA4, it spans 2. In all cases,
vector loads do not change the number of cache lines accessed; they reduce
the number of instructions the wavefront must issue to move the same
amount of data.
Two vector widths are shown alongside the scalar baseline:
Width |
HIP type |
Alignment |
Instruction |
|---|---|---|---|
1 |
|
4 bytes |
|
2 |
|
8 bytes |
|
4 |
|
16 bytes |
|
Vector type helper:
1template<int Width>
2struct VectorType;
3
4template<>
5struct VectorType<1>
6{
7 using type = float;
8 static constexpr int width = 1;
9 static constexpr int byte_align = alignof(float); // 4 bytes
10};
11
12template<>
13struct VectorType<2>
14{
15 using type = float2;
16 static constexpr int width = 2;
17 static constexpr int byte_align = 8; // float2 must be 8-byte aligned
18};
19
20template<>
21struct VectorType<4>
22{
23 using type = float4;
24 static constexpr int width = 4;
25 static constexpr int byte_align = 16; // float4 must be 16-byte aligned
26};
Vectorized load function:
1template<int Width>
2__device__ void vectorized_load(float* __restrict__ dst,
3 const float* __restrict__ src,
4 int tile_elems,
5 int tid,
6 int num_threads)
7{
8 using Vec = typename VectorType<Width>::type;
9
10 static_assert(sizeof(Vec) == Width * sizeof(float),
11 "Vector type size mismatch");
12
13 // Number of complete vector elements in the tile.
14 // The static_asserts in the kernel guarantee tile_elems % Width == 0.
15 const int vec_elems = tile_elems / Width;
16
17 const Vec* src_vec = reinterpret_cast<const Vec*>(src);
18 Vec* dst_vec = reinterpret_cast<Vec*>(dst);
19
20 for(int idx = tid; idx < vec_elems; idx += num_threads)
21 {
22 dst_vec[idx] = src_vec[idx];
23 }
24}
Alignment requirements:
The reinterpret_cast in vectorized_load is only safe when the source
pointer is aligned to sizeof(Vec) bytes. Two alignment guarantees apply:
hipMallocreturns a pointer aligned to at least 256 bytes, satisfyingfloat4(16 bytes) for the base of any matrix.Each row of A begins at offset
row × K × sizeof(float). Forfloat4loads the row lengthKmust be a multiple of 4. A compile-timestatic_assertenforces this for the tile parameters.
A runtime check is included in the example to catch misaligned user-supplied pointers:
1void check_alignment(const void* ptr, int required_bytes, const char* name)
2{
3 const uintptr_t addr = reinterpret_cast<uintptr_t>(ptr);
4 if(addr % static_cast<uintptr_t>(required_bytes) != 0)
5 {
6 std::cerr << "WARNING: " << name << " pointer " << ptr
7 << " is not " << required_bytes << "-byte aligned. "
8 << "Vectorised loads of width " << required_bytes
9 << " bytes will produce undefined behaviour.\n";
10 }
11}
Note
Only the A-tile load is vectorized because its elements are contiguous in global memory (row-major, stride 1). The B-tile is loaded column-by-column (stride N in global memory), which is not amenable to simple vector loads. Architecture-specific builtins (MFMA and WMMA), covered in follow-up sections, address this asymmetry.
Vectorized loads and smaller data types#
For FP32, vectorized loads are a moderate optimization: they reduce instruction count, but a scalar load already fills cache lines well (see table above). The picture changes significantly for smaller data types. With FP16 (2 bytes) or FP8 (1 byte), a scalar load per thread no longer fills a full cache line:
Element type |
Size |
RDNA scalar load (32 lanes) |
Cache line fill (128 B line) |
|---|---|---|---|
FP32 |
4 bytes |
32 × 4 = 128 B |
100% (1 full line) |
FP16 |
2 bytes |
32 × 2 = 64 B |
50% (half a line wasted) |
FP8 |
1 byte |
32 × 1 = 32 B |
25% (three quarters wasted) |
The wasted portion of each cache line is fetched from DRAM but never used - this is pure bandwidth overhead. A 2-wide vector load for FP16 or a 4-wide vector load for FP8 restores the full cache line fill. On CDNA3/CDNA4 (128 B cache line, 64-wide wavefronts), FP8 scalar loads similarly fill only half a line.
This is why the TilePolicy parameterizes the vector load width: the
optimal width depends on both the element type and the target architecture.
For the FP32 case in this tutorial, the benefit is modest, but for the
low-precision builtins introduced in follow-up sections, vectorized loads
become essential to avoid wasting memory bandwidth.
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_vectorized.hip -o mm_vectorized
./mm_vectorized
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_vectorized
To compare VMEM instruction cycles across scalar and vector variants:
# RDNA (combined VMEM counter)
rocprofv3 --pmc SQ_INST_CYCLES_VMEM --output-format csv -- ./mm_vectorized
# CDNA (read counter only; GEMM tile loads are reads)
rocprofv3 --pmc SQ_INST_CYCLES_VMEM_RD --output-format csv -- ./mm_vectorized
What to observe#
Compare the following counters against the double-buffered kernel from Step 4.
Counter |
What to look for |
|---|---|
|
Reduction in VMEM instruction cycles (fewer instructions for the same
total data). CDNA GPUs split this into |
|
CDNA only: total L2 cache accesses should remain roughly constant (cache-line traffic does not change – only the instruction count does). |
Step 6: Register pressure and occupancy#
GPU occupancy - the ratio of active wavefronts to the hardware maximum - is set by
the most constrained resource. For register-tiled GEMM kernels that resource
is typically the VGPR file: each thread holds
THREAD_TILE_M × THREAD_TILE_N accumulator registers plus fragment arrays,
and the compiler might allocate additional temporaries.
Higher register usage means fewer concurrent wavefronts per CU, which reduces the GPU’s ability to hide memory latency through wavefront switching. Conversely, forcibly reducing register usage can increase register spilling to scratch memory (a slow VGPR-to-DRAM path), degrading performance.
Three kernel variants illustrate the tradeoff:
No annotation (compiler decides freely):
1__global__ void matrix_multiply_no_hint(const float* __restrict__ A,
2 const float* __restrict__ B,
3 float* __restrict__ C,
4 int m,
5 int n,
6 int k)
7{
8 __shared__ float tile_a[BLOCK_TILE_M][K_TILE_SIZE];
9 __shared__ float tile_b_T[BLOCK_TILE_N][K_TILE_SIZE];
10 gemm_body(A, B, C, m, n, k, tile_a, tile_b_T);
11}
``__launch_bounds__``:
1constexpr int MIN_WAVES_PER_EU = 2; // tune this with rocprofv3
2
3__global__
4__launch_bounds__(BLOCK_THREADS, MIN_WAVES_PER_EU)
5void matrix_multiply_launch_bounds(const float* __restrict__ A,
6 const float* __restrict__ B,
7 float* __restrict__ C,
8 int m,
9 int n,
10 int k)
11{
12 __shared__ float tile_a[BLOCK_TILE_M][K_TILE_SIZE];
13 __shared__ float tile_b_T[BLOCK_TILE_N][K_TILE_SIZE];
14 gemm_body(A, B, C, m, n, k, tile_a, tile_b_T);
15}
The two-argument form __launch_bounds__(max_threads, min_waves_per_eu)
tells the compiler to allocate VGPRs such that at least min_waves_per_eu
wavefronts can be resident per EU simultaneously, given
max_threads / warpSize wavefronts per block.
``[[clang::amdgpu_waves_per_eu]]``:
1// tune WAVES_PER_EU_MIN and WAVES_PER_EU_MAX with rocprofv3 --kernel-trace
2// (compare VGPR_Count across variants).
3constexpr int WAVES_PER_EU_MIN = 2;
4constexpr int WAVES_PER_EU_MAX = 4;
5
6__global__ void matrix_multiply_waves_per_eu
7[[clang::amdgpu_waves_per_eu(WAVES_PER_EU_MIN, WAVES_PER_EU_MAX)]]
8(const float* __restrict__ A,
9 const float* __restrict__ B,
10 float* __restrict__ C,
11 int m,
12 int n,
13 int k)
14{
15 __shared__ float tile_a[BLOCK_TILE_M][K_TILE_SIZE];
16 __shared__ float tile_b_T[BLOCK_TILE_N][K_TILE_SIZE];
17 gemm_body(A, B, C, m, n, k, tile_a, tile_b_T);
18}
This Clang attribute directly instructs the backend to target a wavefront
occupancy in the range [min, max] per EU.
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_launch_bounds.hip -o mm_launch_bounds
./mm_launch_bounds
Finding optimal values with rocprofv3#
Collect static kernel metadata for all three variants:
rocprofv3 --kernel-trace --output-format csv -- ./mm_launch_bounds
In the resulting CSV, compare the
VGPR_CountandScratch_Sizecolumns across the three kernels.Collect dynamic occupancy counters:
rocprofv3 --pmc SQ_WAVES_sum MeanOccupancyPerCU SQ_WAIT_INST_LDS --output-format csv -- ./mm_launch_bounds # if SQ_WAIT_INST_LDS not available rocprofv3 --pmc SQ_WAVES_sum MeanOccupancyPerCU --output-format csv -- ./mm_launch_bounds # if MeanOccupancyPerCU and SQ_WAVES_sum not available rocprofv3 --pmc SQ_LEVEL_WAVES SQ_WAIT_INST_LDS --output-format csv -- ./mm_launch_bounds
Open both CSVs and note the values of:
VGPR_Count (vector registers allocated per thread, from the kernel-trace CSV)
Scratch_Size (>0 means VGPRs are spilling to DRAM - avoid this)
MeanOccupancyPerCU or SQ_LEVEL_WAVES: mean active wavefronts per CU, from the PMC CSV
Calculate the theoretical maximum occupancy from
VGPR_Countusing the formula for your architecture (available in the AMD ISA reference).If the compiler allocated more VGPRs than necessary and occupancy is below the target, add
__launch_bounds__with amin_waves_per_euthat reflects the desired occupancy.Re-profile and re-check
Scratch_Size- if it increases significantly, the compiler was forced to spill and the constraint is too aggressive.
Note
The numeric values MIN_WAVES_PER_EU = 2 and WAVES_PER_EU_MAX = 4
in the example are illustrative. Optimal values depend on the target GPU
and the exact kernel register usage shown in the rocprofv3 --kernel-trace CSV.
Always re-profile with rocprofv3 after applying the annotation to
confirm that occupancy improves without introducing scratch-memory spilling.
What to observe#
Compare the following counters across the three kernel variants (no annotation,
__launch_bounds__, and [[clang::amdgpu_waves_per_eu]]).
Counter / column |
What to look for |
|---|---|
|
From |
|
From |
|
From |
|
LDS stall cycles (fall when more wavefronts are resident). |
Step 7: Generic kernel#
By this point the kernel is already a strong general-purpose implementation: it uses LDS tiling to exploit data reuse, register tiling to maximize arithmetic intensity, software double buffering to hide load latency, and vectorized loads to reduce memory transaction overhead. For many workloads this is sufficient.
Squeezing out the last few percent of throughput, however, requires architecture-specific matrix-multiply instructions: Matrix Fused Multiply-Accumulate (MFMA) on CDNA GPUs and Wave Matrix Multiply-Accumulate (WMMA) on RDNA3 and RDNA4. These instructions perform a small matrix multiply directly in hardware and deliver substantially higher FLOP/s than an equivalent sequence of scalar FMAs.
Steps 1–6 produced a well-optimized scalar GEMM kernel, but repeating the same work for each architecture-specific instruction set - MFMA on CDNA, WMMA on RDNA3, the relaxed WMMA variant on RDNA4 - would mean maintaining several near-identical copies of the kernel with only the inner computation swapped out. Any future improvement (a new tiling strategy, a wider vector load, a different buffering depth) would have to be applied to every copy independently.
The goal of this step is to factor the kernel so that the optimized orchestration is written once and architecture-specific builtins can be dropped in later as a policy, without touching any kernel code.
The insight from the preceding steps is that all the work decomposes into exactly two independent concerns:
Data movement (
TilePolicy) - tile shape, LDS layout, vector load width, and buffering strategy. These optimizations are identical regardless of which instruction is used to compute the output; afloat4load into a double-buffered LDS tile is just as beneficial whether the inner loop uses scalar FMAs or MFMA.Arithmetic (
ComputePolicy) - how a thread’s register fragment is loaded from LDS and how the output accumulator is updated. This is the only part that differs between scalar code and architecture-specific builtins.
Following the principle of lifting an algorithm into its most general form,
these two responsibilities are encapsulated in two orthogonal policy classes:
TilePolicy and ComputePolicy. A single kernel template
matrix_multiply_generic<TilePolicy, ComputePolicy> orchestrates the common
control flow while delegating all architecture-specific details to the policies.
The kernel presented here uses ScalarFMAPolicy as the ComputePolicy.
It already incorporates all the optimizations from the preceding steps: LDS
tiling, register tiling, software double buffering, and vectorized loads. When
an MFMA or WMMA ComputePolicy is provided in one of the
architecture-specific builtins chapters, the same data-movement infrastructure
and the same kernel orchestration are reused unchanged - only the inner arithmetic
changes.
Policy interfaces#
Both interfaces are documented in full in matrix_multiply_generic.hip.
The key design decisions are:
TilePolicy interface summary:
Requirement |
Rationale |
|---|---|
|
Governs LDS layout (one or two buffer pairs) and sync strategy. |
|
Tile shape needed by the kernel to compute grid dimensions. |
|
Placed in |
|
Data movement hooks; see below. |
ComputePolicy interface summary:
Requirement |
Rationale |
|---|---|
|
Output sub-tile per thread; kernel derives block dimensions from these. |
|
Unique output lanes per wavefront. |
|
Architecture-aware thread-to-output mapping. |
|
Element types of the register fragments (for example, |
|
Number of k-indices consumed per |
|
Fragment load, multiply-accumulate, and write-back. |
Data type scope: policy coverage#
elem_a and elem_b parameterize the register fragment type and are
already fully wired through the kernel loop. A future ComputePolicy can
set elem_a = __half and the float-to-half conversion happens entirely
inside load_a / load_b - the kernel body is untouched.
However, two things are not yet parameterized and are hardcoded to
float in this file:
The LDS storage type -
SharedStoragein everyTilePolicyholdsfloatarrays, and the cooperative load helpers writefloatinto LDS.The global memory pointer type - the kernel signature takes
const float* A,const float* B,float* C.
This means two distinct cases arise when introducing builtins in follow-up sections:
Case |
Example |
What needs to change |
|---|---|---|
FP32 in memory, low-precision fragments |
Global |
Only |
Low-precision in memory |
Global |
|
The global memory and LDS remain float throughout this tutorial, so only
Case 1 applies. The DirectLoadTilePolicy
below is a Case 1 example at the TilePolicy
level; a ComputePolicy that converts to FP16 in its load_a /
load_b would be a Case 1 example at the compute level. Case 2 is left
as an extension for architecture-specific follow-up sections that operate on
native FP16 or FP8 input matrices.
Compile-time validation#
Both policy interfaces are validated with C++17 static_assert traits:
TilePolicy traits:
1template<typename TilePolicy>
2struct TilePolicyTraits
3{
4 // num_buffers must be 1 or 2
5 static_assert(TilePolicy::num_buffers == 1 || TilePolicy::num_buffers == 2,
6 "TilePolicy::num_buffers must be 1 (single) or 2 (double-buffer)");
7
8 // Tile dimensions must be positive multiples of k_tile_size
9 static_assert(TilePolicy::block_tile_m > 0,
10 "TilePolicy::block_tile_m must be a positive integer");
11 static_assert(TilePolicy::block_tile_n > 0,
12 "TilePolicy::block_tile_n must be a positive integer");
13 static_assert(TilePolicy::k_tile_size > 0,
14 "TilePolicy::k_tile_size must be a positive integer");
15
16 // SharedStorage must be trivially destructible (no LDS destructor call)
17 static_assert(std::is_trivially_destructible_v<typename TilePolicy::SharedStorage>,
18 "TilePolicy::SharedStorage must be trivially destructible");
19
20 // SharedStorage must actually occupy LDS
21 static_assert(sizeof(typename TilePolicy::SharedStorage) > 0,
22 "TilePolicy::SharedStorage must have non-zero size");
23};
ComputePolicy traits:
1template<typename ComputePolicy>
2struct ComputePolicyTraits
3{
4 static_assert(ComputePolicy::thread_tile_m > 0,
5 "ComputePolicy::thread_tile_m must be a positive integer");
6 static_assert(ComputePolicy::thread_tile_n > 0,
7 "ComputePolicy::thread_tile_n must be a positive integer");
8 static_assert(ComputePolicy::effective_lanes > 0,
9 "ComputePolicy::effective_lanes must be a positive integer");
10 static_assert(ComputePolicy::k_step > 0,
11 "ComputePolicy::k_step must be a positive integer");
12
13 // Accumulator must be trivially constructible (no runtime init in registers)
14 static_assert(std::is_trivially_constructible_v<typename ComputePolicy::Accumulator>,
15 "ComputePolicy::Accumulator must be trivially constructible");
16
17 // Fragment element types must have a well-defined size (covers both
18 // scalar arithmetic types like float and vector types like half2).
19 static_assert(sizeof(typename ComputePolicy::elem_a) > 0,
20 "ComputePolicy::elem_a must have non-zero size");
21 static_assert(sizeof(typename ComputePolicy::elem_b) > 0,
22 "ComputePolicy::elem_b must have non-zero size");
23};
C++17 versus C++20: concept syntax#
The static_assert approach above is portable to C++17 but requires the
traits struct to be instantiated explicitly, and error messages appear at the
trait instantiation site. C++20 requires clauses provide a more ergonomic
alternative:
1template<typename TilePolicy>
2concept TilePolicyConcept = requires
3{
4 requires TilePolicy::num_buffers == 1 || TilePolicy::num_buffers == 2;
5 requires TilePolicy::block_tile_m > 0;
6 requires TilePolicy::block_tile_n > 0;
7 requires TilePolicy::k_tile_size > 0;
8 requires std::is_trivially_destructible_v<typename TilePolicy::SharedStorage>;
9 requires sizeof(typename TilePolicy::SharedStorage) > 0;
10};
11
12// The kernel declaration then becomes self-documenting:
13template<TilePolicyConcept TilePolicy, typename ComputePolicy>
14__global__ void matrix_multiply_generic(/* … */);
15//
16// Constraint violations are reported at the point of the template
17// instantiation with a clear "constraint not satisfied" diagnostic rather than
18// a cryptic static_assert failure inside a trait struct.
With C++20, the constraint is expressed directly in the function signature and violations are reported at the point of the invalid template instantiation with a clear “constraint not satisfied” diagnostic.
Concrete policies#
ScalarFMAPolicy - portable scalar FP32 outer-product (no builtins):
1template<int ThreadTileM_, int ThreadTileN_, int BlockDimX_>
2struct ScalarFMAPolicy
3{
4 static constexpr int thread_tile_m = ThreadTileM_;
5 static constexpr int thread_tile_n = ThreadTileN_;
6 // All lanes in a wavefront compute unique output elements (no mirroring).
7 static constexpr int effective_lanes = 64; // AMD wavefront size
8 static constexpr int k_step = 1; // one scalar FMA per ki iteration
9
10 using elem_a = float;
11 using elem_b = float;
12
13 struct Accumulator
14 {
15 float data[ThreadTileM_][ThreadTileN_];
16 };
17
18 __device__ static void zero(Accumulator& acc)
19 {
20 #pragma unroll
21 for(int i = 0; i < ThreadTileM_; ++i)
22 #pragma unroll
23 for(int j = 0; j < ThreadTileN_; ++j)
24 acc.data[i][j] = 0.0f;
25 }
26
27 // Map thread id to the top-left corner of its output sub-tile.
28 // lane_id is accepted but unused (interface forward-compatibility).
29 __device__ static void thread_tile_offset(int tid,
30 int /*lane_id*/,
31 int* thread_row,
32 int* thread_col)
33 {
34 const int tx = tid % BlockDimX_;
35 const int ty = tid / BlockDimX_;
36 *thread_row = ty * ThreadTileM_;
37 *thread_col = tx * ThreadTileN_;
38 }
39
40 // Load a column fragment of tile_a into registers.
41 // tile_a is stored row-major: tile_a[row][ki] = ptr[row * k_tile_size + ki]
42 __device__ static void load_a(const float* tile_a_ptr,
43 int tile_a_row,
44 int ki,
45 int k_tile_size,
46 int /*lane_id*/,
47 elem_a (&frag)[ThreadTileM_])
48 {
49 #pragma unroll
50 for(int i = 0; i < ThreadTileM_; ++i)
51 frag[i] = tile_a_ptr[(tile_a_row + i) * k_tile_size + ki];
52 }
53
54 // Load a row fragment of tile_b_T into registers.
55 // tile_b_T is stored column-major: tile_b_T[col][ki] = ptr[col * k_tile_size + ki]
56 __device__ static void load_b(const float* tile_b_T_ptr,
57 int tile_b_col,
58 int ki,
59 int k_tile_size,
60 int /*lane_id*/,
61 elem_b (&frag)[ThreadTileN_])
62 {
63 #pragma unroll
64 for(int j = 0; j < ThreadTileN_; ++j)
65 frag[j] = tile_b_T_ptr[(tile_b_col + j) * k_tile_size + ki];
66 }
67
68 // Outer-product FMA: acc += a_frag ⊗ b_frag
69 __device__ static void mma(Accumulator& acc,
70 const elem_a (&a_frag)[ThreadTileM_],
71 const elem_b (&b_frag)[ThreadTileN_])
72 {
73 #pragma unroll
74 for(int i = 0; i < ThreadTileM_; ++i)
75 #pragma unroll
76 for(int j = 0; j < ThreadTileN_; ++j)
77 acc.data[i][j] += a_frag[i] * b_frag[j];
78 }
79
80 // Write accumulator to global memory.
81 // lane_id accepted but unused; all lanes write their unique sub-tile.
82 __device__ static void store_c(const Accumulator& acc,
83 float* C,
84 int out_row_base,
85 int out_col_base,
86 int m,
87 int n,
88 int /*lane_id*/)
89 {
90 #pragma unroll
91 for(int i = 0; i < ThreadTileM_; ++i)
92 #pragma unroll
93 for(int j = 0; j < ThreadTileN_; ++j)
94 {
95 const int r = out_row_base + i;
96 const int c = out_col_base + j;
97 if(r < m && c < n)
98 C[r * n + c] = acc.data[i][j];
99 }
100 }
101};
SingleBufferTilePolicy - single LDS buffer pair, equivalent to Step 3:
1template<int BlockTileM_, int BlockTileN_, int KTileSize_>
2struct SingleBufferTilePolicy
3{
4 static constexpr int num_buffers = 1;
5 static constexpr int block_tile_m = BlockTileM_;
6 static constexpr int block_tile_n = BlockTileN_;
7 static constexpr int k_tile_size = KTileSize_;
8
9 struct SharedStorage
10 {
11 float tile_a[BlockTileM_][KTileSize_];
12 float tile_b_T[BlockTileN_][KTileSize_];
13 };
14
15 __device__ static void prologue(SharedStorage& /*smem*/,
16 const float* /*A*/, const float* /*B*/,
17 int /*block_row_start*/, int /*block_col_start*/,
18 int /*m*/, int /*n*/, int /*k*/,
19 int /*tid*/, int /*num_threads*/)
20 {
21 // No prologue needed for single-buffer; tile is loaded at the top of
22 // each loop iteration in prefetch().
23 }
24
25 __device__ static void prefetch(SharedStorage& smem,
26 const float* A, const float* B,
27 int t_next,
28 int block_row_start, int block_col_start,
29 int m, int n, int k,
30 int tid, int num_threads)
31 {
32 // For single-buffer we load the *current* tile (t_next is actually t
33 // from the caller's perspective - see kernel loop).
34 // Rename for clarity: t_next here is the index we want to load.
35 const int t = t_next;
36 cooperative_load_tile_a(&smem.tile_a[0][0], A, block_row_start,
37 block_tile_m, t, k_tile_size, m, k, tid, num_threads);
38 cooperative_load_tile_b_T(&smem.tile_b_T[0][0], B, block_col_start,
39 block_tile_n, t, k_tile_size, k, n, tid, num_threads);
40 }
41
42 // After prefetch: wait for the load to complete before compute.
43 __device__ static void acquire(int /*t*/) { __syncthreads(); }
44
45 // After compute: wait for all threads to finish reading before the next
46 // iteration overwrites the tile.
47 __device__ static void release(int /*t*/) { __syncthreads(); }
48
49 __device__ static int buf_idx(int /*t*/) { return 0; }
50
51 __device__ static float* get_tile_a(SharedStorage& smem, int /*buf*/)
52 {
53 return &smem.tile_a[0][0];
54 }
55 __device__ static float* get_tile_b_T(SharedStorage& smem, int /*buf*/)
56 {
57 return &smem.tile_b_T[0][0];
58 }
59};
SoftwareDoubleBufferTilePolicy - ping-pong LDS buffers, equivalent to Step 4:
1template<int BlockTileM_, int BlockTileN_, int KTileSize_>
2struct SoftwareDoubleBufferTilePolicy
3{
4 static constexpr int num_buffers = 2;
5 static constexpr int block_tile_m = BlockTileM_;
6 static constexpr int block_tile_n = BlockTileN_;
7 static constexpr int k_tile_size = KTileSize_;
8
9 struct SharedStorage
10 {
11 float tile_a[2][BlockTileM_][KTileSize_];
12 float tile_b_T[2][BlockTileN_][KTileSize_];
13 };
14
15 // Prologue: load tile 0 into buffer 0 and synchronise so compute can
16 // begin immediately at the start of the first iteration.
17 __device__ static void prologue(SharedStorage& smem,
18 const float* A, const float* B,
19 int block_row_start, int block_col_start,
20 int m, int n, int k,
21 int tid, int num_threads)
22 {
23 cooperative_load_tile_a(&smem.tile_a[0][0][0], A, block_row_start,
24 block_tile_m, 0, k_tile_size, m, k, tid, num_threads);
25 cooperative_load_tile_b_T(&smem.tile_b_T[0][0][0], B, block_col_start,
26 block_tile_n, 0, k_tile_size, k, n, tid, num_threads);
27 __syncthreads(); // tile 0 visible to all threads before loop entry
28 }
29
30 // Prefetch tile t_next into the background buffer (1 - current) while
31 // the current buffer is in use for compute. No sync here - the sync that
32 // ends the previous compute phase (release) also covers this load because
33 // the background buffer is not touched until two iterations later.
34 __device__ static void prefetch(SharedStorage& smem,
35 const float* A, const float* B,
36 int t_next,
37 int block_row_start, int block_col_start,
38 int m, int n, int k,
39 int tid, int num_threads)
40 {
41 if(t_next * k_tile_size < k) // guard: do not load beyond K
42 {
43 const int buf = t_next % 2; // background buffer index
44 cooperative_load_tile_a(&smem.tile_a[buf][0][0], A, block_row_start,
45 block_tile_m, t_next, k_tile_size, m, k, tid, num_threads);
46 cooperative_load_tile_b_T(&smem.tile_b_T[buf][0][0], B, block_col_start,
47 block_tile_n, t_next, k_tile_size, k, n, tid, num_threads);
48 }
49 }
50
51 // acquire: the prologue already placed a barrier; nothing needed here.
52 // The previous iteration's release() synced both the completed compute
53 // and the in-flight prefetch into the background buffer.
54 __device__ static void acquire(int /*t*/) {}
55
56 // release: one __syncthreads covers two things:
57 // (a) all threads finished computing from the current buffer, and
58 // (b) all threads finished writing the prefetched tile into the
59 // background buffer (the load issued in prefetch() above).
60 // Both must be visible before the next iteration swaps buffers.
61 __device__ static void release(int /*t*/) { __syncthreads(); }
62
63 __device__ static int buf_idx(int t) { return t % 2; }
64
65 __device__ static float* get_tile_a(SharedStorage& smem, int buf)
66 {
67 return &smem.tile_a[buf][0][0];
68 }
69 __device__ static float* get_tile_b_T(SharedStorage& smem, int buf)
70 {
71 return &smem.tile_b_T[buf][0][0];
72 }
73};
Generic kernel template#
1template<typename TilePolicy, typename ComputePolicy>
2__global__ void
3 __launch_bounds__(TilePolicy::block_tile_m / ComputePolicy::thread_tile_m
4 * TilePolicy::block_tile_n / ComputePolicy::thread_tile_n)
5 matrix_multiply_generic(const float* __restrict__ A,
6 const float* __restrict__ B,
7 float* __restrict__ C,
8 int m,
9 int n,
10 int k)
11{
12 // Instantiate TilePolicyTraits/ComputePolicyTraits to trigger static_assert
13 // checks at the point where this kernel is instantiated.
14 (void)TilePolicyTraits<TilePolicy>{};
15 (void)ComputePolicyTraits<ComputePolicy>{};
16
17 // [Sphinx gemm kernel smem start]
18 __shared__ typename TilePolicy::SharedStorage smem;
19 // [Sphinx gemm kernel smem end]
20
21 const int tid = threadIdx.y * blockDim.x + threadIdx.x;
22 const int lane_id = tid % 64; // wavefront lane index (AMD: 64-wide)
23 const int num_threads = blockDim.x * blockDim.y;
24
25 // Top-left corner of the block tile in global C
26 const int block_row_start = blockIdx.y * TilePolicy::block_tile_m;
27 const int block_col_start = blockIdx.x * TilePolicy::block_tile_n;
28
29 // Thread's output sub-tile offset within the block tile
30 int thread_row = 0, thread_col = 0;
31 ComputePolicy::thread_tile_offset(tid, lane_id, &thread_row, &thread_col);
32
33 // Initialise accumulator to zero
34 typename ComputePolicy::Accumulator acc;
35 ComputePolicy::zero(acc);
36
37 const int num_tiles = (k + TilePolicy::k_tile_size - 1) / TilePolicy::k_tile_size;
38
39 // [Sphinx gemm kernel prologue start]
40 // Double-buffer prologue: load tile 0 for policies that support it.
41 // For SingleBufferTilePolicy this is a no-op.
42 TilePolicy::prologue(smem, A, B,
43 block_row_start, block_col_start,
44 m, n, k, tid, num_threads);
45 // [Sphinx gemm kernel prologue end]
46
47 // [Sphinx gemm kernel loop start]
48 for(int t = 0; t < num_tiles; ++t)
49 {
50 // --- Prefetch next tile (or load current tile for single-buffer) ---
51 // For SoftwareDoubleBufferTilePolicy: issues the load for tile (t+1)
52 // into the background buffer while we compute from the foreground.
53 // For SingleBufferTilePolicy: loads tile t (t_next == t here).
54 TilePolicy::prefetch(smem, A, B, t + (TilePolicy::num_buffers == 1 ? 0 : 1),
55 block_row_start, block_col_start,
56 m, n, k, tid, num_threads);
57
58 // --- Acquire: synchronise before compute ---
59 // Single-buffer: __syncthreads() after load
60 // Double-buffer: no-op (previous release() already synced)
61 TilePolicy::acquire(t);
62
63 // --- Compute: outer-product accumulation from the active buffer ---
64 const int buf = TilePolicy::buf_idx(t);
65 const float* tile_a_ptr = TilePolicy::get_tile_a(smem, buf);
66 const float* tile_b_T_ptr = TilePolicy::get_tile_b_T(smem, buf);
67
68 // k_step: number of k-indices consumed per mma() call.
69 // ScalarFMAPolicy uses k_step=1 (one FMA per call); intrinsic-based
70 // policies can use higher values (e.g. k_step=2 for dot-product ops).
71 static_assert(TilePolicy::k_tile_size % ComputePolicy::k_step == 0,
72 "k_tile_size must be divisible by ComputePolicy::k_step");
73
74 for(int ki = 0; ki < TilePolicy::k_tile_size; ki += ComputePolicy::k_step)
75 {
76 typename ComputePolicy::elem_a a_frag[ComputePolicy::thread_tile_m];
77 typename ComputePolicy::elem_b b_frag[ComputePolicy::thread_tile_n];
78
79 ComputePolicy::load_a(tile_a_ptr, thread_row, ki,
80 TilePolicy::k_tile_size, lane_id, a_frag);
81 ComputePolicy::load_b(tile_b_T_ptr, thread_col, ki,
82 TilePolicy::k_tile_size, lane_id, b_frag);
83 ComputePolicy::mma(acc, a_frag, b_frag);
84 }
85
86 // --- Release: synchronise after compute ---
87 // Single-buffer: __syncthreads() before next iteration overwrites tile
88 // Double-buffer: __syncthreads() covers both compute-done and prefetch-done
89 TilePolicy::release(t);
90 }
91 // [Sphinx gemm kernel loop end]
92
93 // [Sphinx gemm kernel store start]
94 // Write the accumulator to global memory.
95 ComputePolicy::store_c(acc, C,
96 block_row_start + thread_row,
97 block_col_start + thread_col,
98 m, n, lane_id);
99 // [Sphinx gemm kernel store end]
100}
Architecture dispatch#
A compile-time dispatch block selects the appropriate ComputePolicy based
on the target Instruction Set Architecture (ISA). The architecture-specific
MFMA and WMMA policies are stubs
to be filled by follow-up architecture-specific sections:
1//
2// Selecting a ComputePolicy at compile time based on device ISA:
3//
4// gfx90a / gfx942 → MFMAPolicy<float,float> (CDNA2/3)
5// gfx1100 / gfx1101 → WMMAPolicy<__half,float> (RDNA3)
6// gfx1200 / gfx1201 → RDNA4WMMAPolicy (relaxed lane rules)
7// fallback (all others) → ScalarFMAPolicy
8//
9// Usage pattern (not yet compiled; shown for documentation):
10//
11// #if defined(__gfx90a__) || defined(__gfx942__)
12// using DefaultComputePolicy = MFMAPolicy</* … */>;
13// #elif defined(__gfx1100__) || defined(__gfx1101__) || defined(__gfx1102__)
14// using DefaultComputePolicy = WMMAPolicy</* … */>;
15// #elif defined(__gfx1200__) || defined(__gfx1201__)
16// using DefaultComputePolicy = RDNA4WMMAPolicy</* … */>;
17// #else
18// using DefaultComputePolicy = ScalarFMAPolicy<8, 8, 16>;
19// #endif
20//
21// The kernel template (GemmKernel) is identical for all policies; only the
22// type alias changes.
Policy aliases used in this example:
1// Single-buffer variant (baseline – equivalent to register-tiled step 3)
2using SingleBufPolicy = SingleBufferTilePolicy<BLOCK_TILE_M, BLOCK_TILE_N, K_TILE_SIZE>;
3
4// Double-buffer variant (software ping-pong, same tile/compute parameters)
5using DoubleBufPolicy = SoftwareDoubleBufferTilePolicy<BLOCK_TILE_M, BLOCK_TILE_N, K_TILE_SIZE>;
6
7// Portable scalar outer-product compute policy
8using ScalarPolicy = ScalarFMAPolicy<THREAD_TILE_M, THREAD_TILE_N, BLOCK_DIM_X>;
9
10// Direct global→LDS tile policy (CDNA3/CDNA4, bypasses VGPRs during tile load)
11#if defined(__gfx942__) || defined(__gfx950__)
12using DirectLoadPolicy = DirectLoadTilePolicy<BLOCK_TILE_M, BLOCK_TILE_N, K_TILE_SIZE>;
13#endif
Compile and run:
amdclang++ -O3 -std=c++17 matrix_multiply_generic.hip -o mm_generic
./mm_generic
The program launches three variants:
GemmKernel<SingleBufPolicy, ScalarPolicy>- single-buffer, scalar FP32GemmKernel<DoubleBufPolicy, ScalarPolicy>- double-buffer, scalar FP32GemmKernel<DirectLoadPolicy, ScalarPolicy>- direct global-to-LDS loads (CDNA3 and CDNA4 only)
The first two differ only in their TilePolicy; the third replaces the
standard cooperative load with a hardware-specific builtin. All three share
the same kernel template and ComputePolicy.
Profile wall-clock time with rocprofv3:
rocprofv3 --kernel-trace --output-format csv -- ./mm_generic
The --kernel-trace CSV shows VGPR_Count and LDS_Block_Size for each
of the three dispatched kernels. Compare these columns across the
SingleBufPolicy, DoubleBufPolicy, and DirectLoadPolicy variants to
confirm the expected differences in register and LDS usage.
Builtin drop-in: DirectLoadTilePolicy#
The DirectLoadTilePolicy demonstrates how an architecture-specific
builtin can be used as a drop-in TilePolicy without touching the kernel
template or the ComputePolicy.
On CDNA3 and CDNA4, the __builtin_amdgcn_global_load_lds intrinsic
transfers data from global memory directly into LDS without staging in vector
registers:
// Gather 64 floats from global memory into 64 contiguous LDS locations.
// Each lane provides its own global source address (per-lane VADDR).
// The hardware writes lane k's value to dst_chunk + k * sizeof(float).
__builtin_amdgcn_global_load_lds(
src_lane, // per-lane global address (VADDR)
dst_chunk, // wave-uniform LDS base (-> M0)
4, // size per lane in bytes (immediate)
0, // offset (immediate)
0); // cache policy (immediate)
There are three distinct benefits:
Instruction count reduction - each
global_load_lds_dwordreplaces aglobal_load_dwordinto a VGPR followed by ads_write_b32from that VGPR into LDS. A single instruction does the work of two, halving the total instruction count for the tile-load phase. For the tile parameters in this example (BLOCK_TILE_M = 128,K_TILE_SIZE = 16, 4 wavefronts) each wavefront issues 32global_load_lds_dwordinstructions instead of 32global_load_dword+ 32ds_write_b32= 64 instructions. This directly frees instruction-issue bandwidth for the FMA compute phase.VGPR file bandwidth - the VGPR file is no longer used as a staging area for tile data during the load phase. Its read/write bandwidth is fully available to the outer-product FMA loop, reducing contention between the load and compute phases.
VGPR count - the loaded data never occupies vector registers. Fewer VGPRs allocated means more wavefronts can be resident per CU simultaneously (see Step 6), which improves the hardware’s ability to hide memory latency through wavefront switching.
On throughput-sensitive workloads the first effect dominates: if the scalar tile-load sequence is instruction-issue-bound, halving its instruction count can produce speedups larger than VGPR savings alone would suggest.
At the ISA level, global_load_lds_dword is a wavefront-wide gather:
VADDR(a vector register) provides each lane’s global source address - the global pointer need not be wave-uniform.M0(a scalar register) provides the LDS base address - wave-uniform.The LDS write destination is implicitly offset per lane: lane k writes to
M0 + offset + k * 4(forsize <= 4), orM0 + offset + k * 16(forsize > 4).
A single global_load_lds_dword instruction therefore gathers 64 floats
(256 bytes) from 64 potentially different global addresses into 64 contiguous
LDS locations - all without touching VGPRs.
The DirectLoadTilePolicy treats the tile as a flat array of elements and
processes it in chunks of 64 (one wavefront width per instruction). For
BLOCK_TILE_M = 128, K_TILE_SIZE = 16: 2048 elements / 64 per
instruction = 32 chunks. With 4 wavefronts: 8 instructions per wavefront
to fill the entire tile.
1template<int BlockTileM_, int BlockTileN_, int KTileSize_>
2struct DirectLoadTilePolicy
3{
4 static constexpr int num_buffers = 1;
5 static constexpr int block_tile_m = BlockTileM_;
6 static constexpr int block_tile_n = BlockTileN_;
7 static constexpr int k_tile_size = KTileSize_;
8
9 static_assert((BlockTileM_ * KTileSize_) % 64 == 0,
10 "tile_a element count must be a multiple of 64 (wavefront width)");
11 static_assert((BlockTileN_ * KTileSize_) % 64 == 0,
12 "tile_b_T element count must be a multiple of 64 (wavefront width)");
13
14 struct SharedStorage
15 {
16 float tile_a[BlockTileM_][KTileSize_];
17 float tile_b_T[BlockTileN_][KTileSize_];
18 };
19
20 // Helper: gather-load a flat tile from global memory to LDS.
21 //
22 // Elements are processed in chunks of 64 (one per wavefront-wide
23 // instruction). Each lane computes its own global source address;
24 // the hardware writes lane k's value to M0 + k * sizeof(float).
25 //
26 // Parameters:
27 // lds_dest – base of the flat LDS tile array
28 // global_src – base of the global matrix
29 // num_rows/cols – tile dimensions (LDS layout is [num_rows][num_cols])
30 // global_row/col – top-left corner of this tile in the global matrix
31 // global_stride – row stride of the global matrix
32 // transpose – if true, swap row/col in the global read pattern
33 // (used for tile_b_T: LDS[col][ki], global B[ki][col])
34 __device__ static void direct_load_flat(float* lds_dest,
35 const float* global_src,
36 int num_rows,
37 int num_cols,
38 int global_row_start,
39 int global_col_start,
40 int global_stride,
41 bool transpose,
42 int tid,
43 int num_threads)
44 {
45 constexpr int warp_size = 64;
46 const int wave_id = tid / warp_size;
47 const int lane = tid % warp_size;
48 const int num_waves = num_threads / warp_size;
49 const int total = num_rows * num_cols;
50 const int total_chunks = total / warp_size;
51
52 for(int chunk = wave_id; chunk < total_chunks; chunk += num_waves)
53 {
54 const int chunk_start = chunk * warp_size;
55
56 // --- Per-lane global source address ---
57 // Flat index of the element this lane handles.
58 const int flat_idx = chunk_start + lane;
59 const int lr = flat_idx / num_cols; // row in LDS tile
60 const int lc = flat_idx % num_cols; // col in LDS tile
61
62 // Map to global coordinates (swap row/col for transposed tiles).
63 const int gr = global_row_start + (transpose ? lc : lr);
64 const int gc = global_col_start + (transpose ? lr : lc);
65
66 // Each lane computes its own global address (VADDR, per-lane).
67 const float* src_lane = &global_src[gr * global_stride + gc];
68
69 // --- Wave-uniform LDS destination ---
70 // M0 points to the start of this 64-element chunk.
71 // The hardware adds lane * sizeof(float) implicitly, so
72 // lane k's value lands at lds_dest[chunk_start + k].
73 float* dst_chunk = &lds_dest[chunk_start];
74
75 __builtin_amdgcn_global_load_lds(
76 const_cast<float*>(src_lane), // per-lane global address
77 dst_chunk, // wave-uniform LDS base (→ M0)
78 4, // size: 4 bytes per lane
79 0, // offset: immediate, 0
80 0); // aux: wave scope, temporal
81 }
82 }
83
84 __device__ static void prologue(SharedStorage& /*smem*/,
85 const float* /*A*/, const float* /*B*/,
86 int /*block_row_start*/, int /*block_col_start*/,
87 int /*m*/, int /*n*/, int /*k*/,
88 int /*tid*/, int /*num_threads*/)
89 {
90 }
91
92 __device__ static void prefetch(SharedStorage& smem,
93 const float* A,
94 const float* B,
95 int t_next,
96 int block_row_start, int block_col_start,
97 int m, int n, int k,
98 int tid, int num_threads)
99 {
100 const int t = t_next; // single-buffer: t_next == t
101
102 // tile_a: row-major gather [block_tile_m × k_tile_size]
103 // LDS flat layout matches global row-major order, so transpose=false.
104 direct_load_flat(&smem.tile_a[0][0], A,
105 block_tile_m, k_tile_size,
106 block_row_start, t * k_tile_size,
107 k,
108 /*transpose=*/false,
109 tid, num_threads);
110
111 // tile_b_T: transposed gather [block_tile_n × k_tile_size]
112 // LDS layout: tile_b_T[col_within_tile][k_idx]
113 // Global read: B[k_idx_global][col_global]
114 // transpose=true swaps lr↔lc in the global coordinate mapping.
115 direct_load_flat(&smem.tile_b_T[0][0], B,
116 block_tile_n, k_tile_size,
117 t * k_tile_size, block_col_start,
118 n,
119 /*transpose=*/true,
120 tid, num_threads);
121 }
122
123 __device__ static void acquire(int /*t*/) { __syncthreads(); }
124 __device__ static void release(int /*t*/) { __syncthreads(); }
125 __device__ static int buf_idx(int /*t*/) { return 0; }
126
127 __device__ static float* get_tile_a(SharedStorage& smem, int /*buf*/)
128 {
129 return &smem.tile_a[0][0];
130 }
131 __device__ static float* get_tile_b_T(SharedStorage& smem, int /*buf*/)
132 {
133 return &smem.tile_b_T[0][0];
134 }
135};
Note
Because the arithmetic is unchanged (full FP32 scalar outer-product), the
DirectLoadPolicy variant produces results identical to the other two.
The only difference is the data path during the tile load phase.
For the full builtin reference - signatures, parameter tables, address calculation formulas, and cache policy encoding - see Global-to-LDS builtins for AMD GPUs.
What to observe:
Counter |
What to look for |
|---|---|
Kernel duration (kernel-trace CSV) |
Should be similar to or better than |
|
From |
|
From |
Further reading#
The following resources provide deeper coverage of the tools and hardware referenced in this tutorial.
rocprofv3 documentation - detailed guide to timeline and counter profiling.
AMD GPU architecture guides (ISA references) - VGPR budgets, LDS bank geometry, and wavefront scheduling details for each architecture family.