Chapter 3 · Kernel optimization
Matrix multiplication
3.2

Matrix multiplication

A naive matrix-multiply kernel assigns one output element to one thread: each thread walks the corresponding row of A and column of B and accumulates a dot product straight out of global memory. On an RTX A6000 multiplying two 4092×4092 float32 matrices, that kernel manages about 309 GFLOP/s, roughly 1.3% of what cuBLAS reaches on the same GPU and the same problem. The gap is not arithmetic: neighboring threads in a warp end up reading rows of A that sit nowhere near each other in memory, so almost none of those loads can be combined into a single wide transaction.

The worklog’s first kernel is the whole algorithm in fifteen lines, launched with one thread per entry of C:

sgemm_naive, from How to Optimize a CUDA Matmul Kernel
__global__ void sgemm_naive(int M, int N, int K, float alpha, const float *A,
                            const float *B, float beta, float *C) {
  // compute position in C that this thread is responsible for
  const uint x = blockIdx.x * blockDim.x + threadIdx.x;
  const uint y = blockIdx.y * blockDim.y + threadIdx.y;

  if (x < M && y < N) {
    float tmp = 0.0;
    for (int i = 0; i < K; ++i) {
      tmp += A[x * K + i] * B[i * N + y];
    }
    C[x * N + y] = alpha * tmp + beta * C[x * N + y];
  }
}

Reassigning which thread owns which output element so that threads in a warp read consecutive addresses, letting the hardware coalesce those reads, already lifts throughput to about 1986.5 GFLOP/s with no other change. The next step is : the kernel stages a block-sized tile of A and a tile of B into shared memory once, and every thread in the block reads back out of that on-chip copy instead of returning to global memory for each partial product, which pushes throughput to about 2980.3 GFLOP/s.

The heart of that shared-memory kernel is the loop every later version elaborates: stage a tile of A and a tile of B, synchronize, accumulate, synchronize, advance to the next tile along the reduction dimension:

Shared-memory tiling, from How to Optimize a CUDA Matmul Kernel
// advance pointers to the starting positions
A += cRow * BLOCKSIZE * K;
B += cCol * BLOCKSIZE;
C += cRow * BLOCKSIZE * N + cCol * BLOCKSIZE;

float tmp = 0.0;
for (int bkIdx = 0; bkIdx < K; bkIdx += BLOCKSIZE) {
  As[threadRow * BLOCKSIZE + threadCol] = A[threadRow * K + threadCol];
  Bs[threadRow * BLOCKSIZE + threadCol] = B[threadRow * N + threadCol];

  // block threads in this block until cache is fully populated
  __syncthreads();

  // advance pointers onto next chunk
  A += BLOCKSIZE;
  B += BLOCKSIZE * N;

  // execute the dotproduct on the currently cached block
  for (int dotIdx = 0; dotIdx < BLOCKSIZE; ++dotIdx) {
    tmp += As[threadRow * BLOCKSIZE + dotIdx] *
            Bs[dotIdx * BLOCKSIZE + threadCol];
  }
  // need to sync again at the end, to avoid faster threads
  // fetching the next block into the cache before slower threads are done
  __syncthreads();
}
C[threadRow * N + threadCol] =
    alpha * tmp + beta * C[threadRow * N + threadCol];

The two barriers are the cooperation cost the transpose exercise introduced, now guarding both directions: the first keeps any thread from computing against a tile another thread has not finished staging, and the second keeps a fast thread from overwriting the tile with the next chunk while a slower one is still reading the current chunk. At a block size of 32 the two tiles occupy 8KB of the 48KB of shared memory a block can address on this GPU.

Even with tiling, each thread is still computing exactly one output element, so most instructions in the inner loop are shared-memory loads rather than the fused multiply-adds actually doing the work; a profiler shows warps repeatedly stalling in the “Stall MIO Throttle” state, waiting on the memory pipe rather than on arithmetic. fixes that ratio directly: giving each thread a small 1D tile of outputs held in registers moves throughput to 8474.7 GFLOP/s, and a 2D tile of outputs per thread reaches 15971.7 GFLOP/s, 68.7% of cuBLAS. Vectorized memory instructions, autotuned tile sizes, and tiling at the warp level close most of what remains, reaching 21779.3 GFLOP/s, 93.7% of cuBLAS, without a tensor core in sight.

309.0GFLOP/s
Naive kernel (1.3% of cuBLAS)
21779.3GFLOP/s
Warptiled kernel (93.7% of cuBLAS)
23249.6GFLOP/s
cuBLAS (same GPU, same problem)

None of these kernels is limited by in the way a first guess might suggest: the shared-memory kernel above fits only one block per SM and still reaches 66% occupancy, and pushing that number higher would not by itself close the remaining gap to cuBLAS. What separates a kernel like this from a vendor library is mostly the same tiling idea applied recursively, at the block, warp, and instruction level. Libraries such as CUTLASS describe those nested tiles as compositions of layouts, a shape paired with a stride, and talk about a matrix being “K-major” when it is stride-1 along the reduction dimension, rather than relying on the row-major and column-major vocabulary borrowed from BLAS.

The case against occupancy-first reasoning is older than any GPU in this chapter. In 2008, Volkov and Demmel benchmarked dense linear algebra across four NVIDIA GPUs and built an SGEMM that sustained 58 to 60% of each chip’s peak where NVIDIA’s own CUBLAS 1.1 sustained 36 to 44%, and the design contradicted the official guidance of the era point by point: instead of many threads, shared memory as the primary storage, and long vectors, their kernel kept each 64×16 output block of C entirely in registers, staged only B’s block through shared memory, and ran short 64-element vector threads.

The paper’s method is what survived: measure, then reason. Varying the thread count showed the code reaching 32, 49, 58, and 59% of peak at one to four threads per core, and on the GTX280 those four threads correspond to 25% occupancy, from which the authors conclude that one should not over-optimize for occupancy, though extremely low occupancy can also hurt. Cycle counting on the disassembled binaries located the real bound in instruction throughput: CUBLAS ran twice as many warps yet was 1.6× slower, because keeping both input blocks in shared memory forced an extra register move for every two multiply-adds, diluting the multiply-add share of its inner loop to 56% of instructions against 82% in theirs.

Where a matmul tile lives. One thread block computes one tile of C. The band of A rows and B columns it needs is staged tile by tile into shared memory, and each thread accumulates a small register tile of outputs. Shaded regions mark the data for the highlighted C tile.Illustrative numbers

Asynchronous copies and double buffering#

Look again at the two barriers in the shared-memory kernel above. The second one exists only because there is one buffer: no thread may begin staging the next tile until every thread has finished reading the current one. That makes the mainloop strictly alternate between a load phase in which the math units have nothing to do and a compute phase in which the memory pipe has nothing to do, and the escape from an alternation like that is to hold two of whatever is contended. CUTLASS calls the technique and implements it by double buffering at two scopes: threadblock-scoped shared-memory tiles, where two tiles are allocated and one loads data for the current matrix operation while the other buffers data from global memory for the next mainloop iteration, and warp-scoped matrix fragments, where two fragments are allocated in registers and one is passed to the CUDA and tensor cores while the other receives shared-memory fetch returns for the next warp-level operation.

The reason a GEMM kernel has to arrange this by hand connects back to the occupancy argument above. CUTLASS’s own explanation is that the blocked structure demands a large storage allocation in each thread’s registers, with the accumulator elements alone typically occupying at least half of a thread’s register budget, so occupancy is relatively low compared to other classes of GPU workload, and that limits the GPU’s ability to hide memory latency and other stalls by switching to another resident warp. There are fewer warps to switch to, so the latency has to be hidden inside the thread instead.

The other half of the technique is the route the bytes take. Prior to cuda::memcpy_async, copying from global to shared memory was a two-step process: the thread block copied data from global memory into registers and then from registers into shared memory, which NVIDIA describes as a long journey through the memory hierarchy. Ampere added a direct path. With an the block no longer stages data through registers, which frees it from the task of moving data and frees those registers to be used by computations; because the copy uses no registers, register pressure falls and occupancy improves, and the copy is emitted without depending on the compiler’s dynamic loop unrolling.

The instruction underneath is LDGSTS, available from compute capability 8.0. It copies 4, 8, or 16 bytes: at 4 or 8 bytes the data is also cached in L1, while the 16-byte form enables an L1 bypass mode that leaves the cache unpolluted. Global to shared is the only direction it supports, and best performance wants both the shared and the global address aligned to 128 bytes. Completion is signalled through a shared-memory barrier or a pipeline, and the default is narrower than it looks: each thread waits only for its own copies, so a block whose threads prefetch data that other threads will read still needs a __syncthreads() after the completion wait. Substituting the asynchronous copy into a staging loop is otherwise mechanical:

cooperative_groups::memcpy_async, from Controlling Data Movement to Boost Performance on the NVIDIA Ampere Architecture
#include <cooperative_groups.h>
#include <cooperative_groups/memcpy_async.h>

template <typename T>
__global__ void example_kernel(T * global1, T * global2, size_t subset_count)
{
    extern __shared__ T shared[];
    auto group = cooperative_groups::this_thread_block();

    for (size_t subset = 0; subset < subset_count; ++subset) {
        cooperative_groups::memcpy_async(group, shared,
                                         &global1[subset * group.size()], sizeof(T) * group.size());
        cooperative_groups::memcpy_async(group, shared + group.size(),
                                         &global2[subset * group.size()], sizeof(T) * group.size());

        cooperative_groups::wait(group); // Wait for all copies to complete

        compute(shared);

        group.sync();
    }
}

That version is a drop-in replacement: cooperative_groups::memcpy_async stands in for the assignment and cooperative_groups::wait stands in for the barrier that used to guard it, and the loop keeps the shape the matmul mainloop has, copy the subset, wait, compute, synchronize. What it does not do yet is overlap anything. It shortens the route the bytes take and hands the registers back, but the block still waits for the copy it just issued before it computes on the result.

Overlap needs stages. Because asynchronous data movement lets multiple batches be in flight at the same time, a block can pipeline its way through a large structure by submitting N stages of asynchronous copies, waiting for the oldest stage to complete, computing with that batch of shared memory, and submitting a new stage before waiting on the next oldest. At N of 2 that is exactly double buffering: stage k+1 is being fetched while stage k is being computed. cuda::pipeline names the four points where the handoff has to be arranged:

Two-stage pipeline, from Controlling Data Movement to Boost Performance on the NVIDIA Ampere Architecture
template<int block_dim, int num_stages>
__global__ void in_between_pipe_thread(int* dest, int const* src, size_t size) {
    // Read blockDim.x integers per pipeline stage
    __shared__ int smem[num_stages][block_dim];

    // Grid stride loop:
    int offset = blockIdx.x * blockDim.x;
    size_t stride = gridDim.x * blockDim.x;

    // No pipeline::shared_state needed
    cuda::pipeline<cuda::thread_scope_thread> pipe = cuda::make_pipeline();

    // Load all pipeline stages.
    for (int stage = 0; stage < num_stages; ++stage) {
        pipe.producer_acquire();
        size_t idx = offset + stage * stride + threadIdx.x;
        if (idx < size) {
            cuda::memcpy_async(&smem[stage][threadIdx.x], &src[idx], sizeof(int), pipe);
        }
        pipe.producer_commit();
    }

    // At this point, there are `num_stages` commited into the pipeline. This is a loop.
    // invariant that is upheld throughout the loop.
    int stage = 0;
    for (size_t block_idx = offset; block_idx < size; block_idx += stride) {
        // Wait for the first stage to have completed loading, or equivalently: wait until
        // at most `num_stages - 1` stages are still loading.
        cuda::pipeline_consumer_wait_prior<num_stages - 1>(pipe);

        // __syncthreads is necessary if other threads want to read this thread's loaded data.
        __syncthreads();

        // ... compute on smem[stage][..] ...

        // __syncthreads is necessary if other threads are reading data that this thread
        // is about to overwrite below.
        __syncthreads();

        // Release the consumed stage.
        pipe.consumer_release();

        // Pre-load data for `num_stages` into the future.
        pipe.producer_acquire();
        // To ensure that the number of commited stages into the pipeline remains constant,
        // producer_acquire and producer_commit are called even if the load is out-of-bounds.
        size_t idx = block_idx + num_stages * stride + threadIdx.x;
        if (idx < size) {
            cuda::memcpy_async(&smem[stage][threadIdx.x], &src[idx], sizeof(int), pipe);
        }
        pipe.producer_commit();

        stage = (stage + 1) % num_stages;
    }
}

producer_acquire claims a stage before the copy is issued and producer_commit publishes it; pipeline_consumer_wait_prior with a template argument of num_stages - 1 waits until at most that many stages are still loading, which is the same statement as waiting for the oldest; and consumer_release hands the stage back. The stage index advancing modulo num_stages is what turns the shared-memory allocation into a circular buffer. The loop invariant the comments name is the part worth copying into a kernel of your own: exactly num_stages stages stay committed at the top of every iteration, which is why the acquire and commit pair still runs when the load it guards is out of bounds and copies nothing.

CUTLASS generalizes those four calls into its Pipeline classes, where producer_acquire blocks until a consumer releases the stage it wants, producer_commit notifies waiting consumers without blocking, consumer_wait blocks until the producers have committed a stage, and consumer_release frees the stage again. Its stated reason for having the abstraction is worth weighing before hand-rolling one: a persistent GEMM manages dozens of different kinds of asynchronously executing operations synchronizing through multiple barriers organized as a circular list, complexity CUTLASS calls too much for human programmers to manage by hand. It is also the vocabulary the next section needs. Hopper’s warp specialization is this pipeline with the two roles handed to different warps rather than to different phases of one loop: a producer warp group waits for a shared-memory buffer to be signalled empty and issues a TMA load into it, the TMA updates that stage’s barrier on completion, and a consumer warp group waits on the barrier, runs the tensor-core matmuls, and releases the buffer for the next load.