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 on PATH.

  • 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}\):

Three labeled matrices: A (M×K) with row i highlighted and dimension arrows, B (K×N) with column j highlighted and dimension arrows, and C (M×N) with element c_ij highlighted showing the result of their inner product

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:

\[I = \frac{2 \cdot M \cdot N \cdot K}{\text{sizeof}(\text{float}) \cdot (M \cdot K + K \cdot N + M \cdot N)}\]

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 float data is **inherently conflict-free

regardless of tile size**:

  • tile_a[ty][i]: all threads in a wavefront that share the same ty read the same address. The hardware broadcasts the value - no conflict.

  • tile_b[i][tx]: each thread has a unique tx, and because each float is exactly 4 bytes (= one bank slot), consecutive tx values always map to consecutive banks. No two threads in the same cycle can hit the same bank at a different address, regardless of how large TILE_SIZE is. The i loop iterates sequentially within each thread, so accesses to different rows of tile_b are 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)

End_Timestamp - Start_Timestamp should drop significantly versus the naive kernel, confirming that LDS data reuse is reducing global memory traffic.

TCP_TCC_READ_REQ_sum (CDNA)

CDNA only: reduction proportional to TILE_SIZE confirms fewer L2-to-HBM read requests.

LDSBankConflict

Should remain at or near zero for TILE_SIZE=16 with FP32 data (confirms no bank conflicts).

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:

  1. Stride-1 reads during the outer-product compute phase: In the outer-product pattern, each thread loads a fragment of THREAD_TILE_N consecutive elements from tile_b_T along the ki dimension. Because the inner dimension of tile_b_T is K_TILE_SIZE, consecutive ki values are adjacent in memory - stride-1 access. Without transposition (tile_b[K_TILE_SIZE][BLOCK_TILE_N]), the fragment load would stride across the large BLOCK_TILE_N dimension, producing scattered LDS reads.

  2. Bank-conflict safety for sub-4-byte types: In Step 2’s simple inner-product loop, each float occupies 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 of tile_b equals 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

VALUInsts (RDNA, CDNA, CDNA2) / SQ_INSTS_VALU (CDNA3, CDNA4)

Increase in VALU instructions per wave (more Fused Multiply-Accumulate (FMA) operations per LDS read).

SQ_INSTS_LDS

Reduction in LDS instructions per wave (THREAD_TILE_M + THREAD_TILE_N instead of 2 × THREAD_TILE_M × THREAD_TILE_N).

SQ_WAIT_INST_LDS

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_SIZE and K_TILE_SIZE × BLOCK_TILE_N floats 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_N accumulator 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 --pmc to 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

prologue

Load tile 0 into buffer 0 and synchronize (double-buffer only; no-op for single).

prefetch

Issue the load for the next tile into the background buffer.

acquire

Synchronize before compute (single-buffer: __syncthreads(); double: no-op).

release

Synchronize after compute (both: __syncthreads()).

buf_idx

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

SQ_WAIT_INST_LDS

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).

TCP_TCC_READ_REQ_sum

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

float

4 bytes

buffer_load_dword (one 4 B element per thread)

2

float2

8 bytes

buffer_load_dwordx2 (two 4 B elements per thread)

4

float4

16 bytes

buffer_load_dwordx4 (four 4 B elements per thread)

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:

  1. hipMalloc returns a pointer aligned to at least 256 bytes, satisfying float4 (16 bytes) for the base of any matrix.

  2. Each row of A begins at offset row × K × sizeof(float). For float4 loads the row length K must be a multiple of 4. A compile-time static_assert enforces 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

SQ_INST_CYCLES_VMEM (all RDNA GPUs) / SQ_INST_CYCLES_VMEM_RD (all CDNA)

Reduction in VMEM instruction cycles (fewer instructions for the same total data). CDNA GPUs split this into SQ_INST_CYCLES_VMEM_RD (reads) and SQ_INST_CYCLES_VMEM_WR (writes); use the read counter for GEMM tile loads.

TCP_TOTAL_CACHE_ACCESSES

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#

  1. Collect static kernel metadata for all three variants:

    rocprofv3 --kernel-trace --output-format csv -- ./mm_launch_bounds
    

    In the resulting CSV, compare the VGPR_Count and Scratch_Size columns across the three kernels.

  2. 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
    
  3. 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

  4. Calculate the theoretical maximum occupancy from VGPR_Count using the formula for your architecture (available in the AMD ISA reference).

  5. If the compiler allocated more VGPRs than necessary and occupancy is below the target, add __launch_bounds__ with a min_waves_per_eu that reflects the desired occupancy.

  6. 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

VGPR_Count (kernel-trace CSV)

From --kernel-trace CSV: should decrease after adding the annotation.

Scratch_Size (kernel-trace CSV)

From --kernel-trace CSV: must remain zero (non-zero means register spilling to DRAM).

MeanOccupancyPerCU + SQ_WAVES_sum / SQ_LEVEL_WAVES

From --pmc CSV: mean active wavefronts per CU; should rise with fewer VGPRs.

SQ_WAIT_INST_LDS

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:

  1. 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; a float4 load into a double-buffered LDS tile is just as beneficial whether the inner loop uses scalar FMAs or MFMA.

  2. 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

num_buffers = 1 | 2

Governs LDS layout (one or two buffer pairs) and sync strategy.

block_tile_m, block_tile_n, k_tile_size

Tile shape needed by the kernel to compute grid dimensions.

SharedStorage (trivially destructible)

Placed in __shared__; must not require a destructor call.

prologue, prefetch, acquire, release

Data movement hooks; see below.

ComputePolicy interface summary:

Requirement

Rationale

thread_tile_m, thread_tile_n

Output sub-tile per thread; kernel derives block dimensions from these.

effective_lanes

Unique output lanes per wavefront.

thread_tile_offset, tid, lane_id, *row, *col

Architecture-aware thread-to-output mapping.

elem_a, elem_b

Element types of the register fragments (for example, float or __half); the kernel loop is fully templated on these.

k_step

Number of k-indices consumed per mma() call (1 for scalar FMA; higher values for builtins that process multiple k-indices per call); the kernel loop advances ki by this amount.

load_a, load_b, mma, store_c

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 - SharedStorage in every TilePolicy holds float arrays, and the cooperative load helpers write float into 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 float → LDS float → __half fragment for MFMA

Only ComputePolicy::load_a and load_b (conversion in registers). TilePolicy and the kernel signature are unchanged.

Low-precision in memory

Global __half → LDS __half → __half fragment

TilePolicy needs an InputT template parameter so SharedStorage and the cooperative load helpers use InputT instead of float. The kernel signature changes from const float* to const InputT*.

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:

  1. GemmKernel<SingleBufPolicy, ScalarPolicy> - single-buffer, scalar FP32

  2. GemmKernel<DoubleBufPolicy, ScalarPolicy> - double-buffer, scalar FP32

  3. GemmKernel<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:

  1. Instruction count reduction - each global_load_lds_dword replaces a global_load_dword into a VGPR followed by a ds_write_b32 from 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 32 global_load_lds_dword instructions instead of 32 global_load_dword + 32 ds_write_b32 = 64 instructions. This directly frees instruction-issue bandwidth for the FMA compute phase.

  2. 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.

  3. 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 (for size <= 4), or M0 + offset + k * 16 (for size > 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 SingleBufPolicy (same tile parameters mean same DRAM traffic).

VGPR_Count (kernel-trace CSV)

From --kernel-trace CSV: should be lower for DirectLoadPolicy than for SingleBufPolicy (tile data bypasses VGPRs during load).

LDS_Block_Size (kernel-trace CSV)

From --kernel-trace CSV: same as SingleBufPolicy (both use one buffer pair).

Further reading#

The following resources provide deeper coverage of the tools and hardware referenced in this tutorial.