I know what you’re thinking. A fp32 GEMM in 2026? For Ampere??

Yes, SGEMM is rarely ever used these days in the ML space given the massive compute throughput gap between CUDA and tensor cores. For reference, an A100 PCIe’s (now almost 6 years old) peak theoretical performance for bf16 is 312 TFLOPs/sec. and 156 TFLOPs/sec. for tf32, while fp32 sits at a “measly” 19.5 TFLOPs/sec. With mixed-precision training and quantized inference being the norm, GEMM itself is practically never done in fp32 today.

Despite all of these (good) reasons, SGEMM still acts as a great learning experience for squeezing the most performance out of GPUs (without involving tensor cores, of course). In this blogpost, I will briefly go over the structure of GEMM and iteratively work through how I optimized a kernel using NVIDIA’s CUTLASS CuTe library to match/exceed cuBLAS on certain problem shapes on SM86 using my personal RTX 3070 Mobile card.

Disclaimer: this post will be for those relatively new to kernel programming, but with a basic understanding of general GPU concepts.

a brief intro on GEMMs

Large and square-ish GEMM (general matrix-multiply) routines are embarrassingly parallel and compute-bound. GPUs being massively parallel SIMT processors, they are the ideal hardware to run such algorithms on quickly.

So the goal is straightforward: keep compute units busy by making sure data is always there when they need it. This pushes us towards using faster levels of the CUDA memory hierarchy. This includes the non-programmable L1 and L2 caches, and programmable shared memory (SMEM) and registerfile. These on-chip memory regions are much smaller in size, but have far higher throughput than DRAM. A more detailed discussion of these memory levels is available in the CUDA docs .

The basic hardware unit of NVIDIA GPUs is the streaming multiprocessor (SM), which contains shared memory, the registerfile, ALUs, LSUs, warp schedulers, and more. Warp schedulers issue instructions for warps to execute in lockstep (barring warp divergence), and can swap executing warps extremely quickly when some are in a blocked state, enabling latency hiding.

To exploit this memory hierarchy and the large data reuse inherent to GEMM, most large routines follow a similar pattern: tile and move $A, B$ into SMEM and loop along the $K$-axis for each thread/warp to accumulate its own small outer product tile within the SMEM tile. See the basic pseudocode below. With the introduction of tensor cores, the motivation for tiling became stronger, as these specialized hardware units execute warp-wide mini-GEMMs on specific tile shapes. Newer microarchitectures like Hopper (SM90) and Blackwell (SM100+) also have WGMMA, TMA, and TMEM, which are asynchronous hardware units that can accelerate such routines greatly.

C++
// SMEM tiles
__shared__ float A_tile[block_M * block_K];
__shared__ float B_tile[block_K * block_N];

// chunking along K-axis
const int tiles = CEIL_DIV(K, block_K);

// thread-local accumulator in registerfile
float C_accum[thread_M * thread_N] = {0.0};
// outer-product operands
float A_register[thread_M] = {0.0};
float B_register[thread_N] = {0.0};

for (int tile = 0; tile < tiles; ++tile) {
    for (int load_tile = 0; load_tile < A_load_tiles; ++load_tile) {
        // ...LDGSTS A SMEM tile
    }
    for (int load_tile = 0; load_tile < B_load_tiles; ++load_tile) {
        // ...LDGSTS B SMEM tile
    }
    __syncthreads();
    for (int k = 0; k < block_K; ++k) {
        for (int m = 0; m < thread_M; ++m) {
            // ...LDS A outer product operand
        }
        for (int n = 0; n < thread_N; ++n) {
            // ...LDS B outer product operand
        }
        for (int m = 0; m < thread_M; ++m) {
            for (int n = 0; n < thread_N; ++n) {
                // ...accumulate outer products
            }
        }
    }
    __syncthreads();
}
for (int m = 0; m < thread_M; ++m) {
    for (int n = 0; n < thread_N; ++n) {
        // ...STG accumulated registerfile
    }
}

// LDG = read from DRAM, STG = write to DRAM
// LDS = read from SMEM, STS = write to SMEM
Click to expand and view more

Unfortunately for us, Ampere does not have such features. The closest equivalent is the hardware-backed cp.async family of instructions, which allow a warp to asynchronously transfer memory from DRAM to SMEM (one-way), bypassing the registerfile without blocking. Note that cp.async requires every thread in a block to cooperatively load a tile into SMEM, unlike TMA. Still, this instruction will come in handy, as we can asynchronously load the next tile of data into a circular SMEM buffer while the current tile is being processed to keep compute units busy.

We use a similar pipelining technique when moving data to the registerfile from SMEM. We allocate 2 “$K$-slices” worth of operand data (or as we will call them — blocks) from $A, B$ and have each register-backed buffer swap between compute and reading the next blocks from SMEM (more in-depth discussion later). This diagram from NVIDIA outlines the approach well:

pipeline diagram

host-side setup

With all that being said, let’s setup the kernel! We’ll hand off most of the indexing calculations to CuTe to deal with at compile-time, so that we can save some of our precious registers. We’ll start with the necessary setup on host-side. This will be a nn kernel, where both inputs are not transposed and expected to be in column-major format.

C++
void nn(int m, int n, int k, 
        float alpha, const float* A, int ldA,
        const float* B, int ldB, float beta,
        float* C, int ldC, cudaStream_t stream = 0) {
    using namespace cute;

    auto cta_shape = make_shape(Int<128>{}, Int<128>{}, Int<32>{});
    auto stride_A = make_stride(Int<1>{}, ldA);
    auto stride_B = make_stride(ldB, Int<1>{});
    auto stride_C = make_stride(Int<1>{}, ldC);

    constexpr int n_pipes = 3;
    auto sA_layout = make_layout(make_shape(select<0>(cta_shape), select<2>(cta_shape), Int<n_pipes>{}));
    auto sB_layout = make_layout(
        make_shape(size<1>(cta_shape), size<2>(cta_shape), Int<n_pipes>{}),
        make_stride(size<2>(cta_shape), Int<1>{}, size<1>(cta_shape) * size<2>(cta_shape))
    );
    constexpr uint smem_size = (cosize_v<decltype(sA_layout)> + cosize_v<decltype(sB_layout)>) * sizeof(float);
    ...
}
Click to expand and view more

We define the SMEM tiling shapes for $A, B$ to be $128 \times 32$, and the strides for their entire tensors in DRAM to be column-major. The DRAM and SMEM $B$ tile shapes are $(N, K)$ rather than the usual $(K, N)$ to consistently keep the reduction mode $K$ on the right, like $A$, which is $(M, K)$. For the SMEM tiles, we’ll use a 3-stage circular buffer for both. To use the widest granularity for reads/writes (i.e. 128-bits), both SMEM tiles should also be in column-major. Wider reads and writes lower instruction count and generally improve performance.

cp.async can move memory at 4-, 8-, and 16-byte granularities. Furthermore, reads from DRAM AND writes to SMEM must strictly be to contiguous chunks of memory.

smem layouts
We can visualize the two SMEM tile layouts below. I’ve attempted to show the stride-1 direction with the arrows for each layout. That is, they represent how the tensor’s physical linear memory is arranged with sA_layout and sB_layout.

We sum up the cosizes of each SMEM layout to retrieve the number of bytes of SMEM required for the kernel. We need to dynamically allocate SMEM in our kernel, as CUDA caps static allocation at 48 kilobytes across all microarchitectures (we need $128 \times 32 \times 3 \times 2 \times 4 = 98304$ bytes).

A side note: Cosize represents the size of the codomain of a layout. When a layout is compact and bijective, the size is equivalent to the cosize. However, there may be times where this is not the case:

For us, the SMEM layouts are compact and bijective. We still use cosize though to enforce that they require the minimum size of the codomains to back their layouts in physical memory, independent of the tensors’ logical size.

C++
auto copy_A = make_tiled_copy(
    Copy_Atom<Copy_Traits<SM80_CP_ASYNC_CACHEGLOBAL<uint128_t>>, float>{},
    make_layout(make_shape(Int<32>{}, Int<8>{})),
    make_layout(make_shape(Int<4>{}, Int<1>{}))
);
auto copy_B = make_tiled_copy(
    Copy_Atom<Copy_Traits<SM80_CP_ASYNC_CACHEGLOBAL<uint128_t>>, float>{},
    make_layout(make_shape(Int<32>{}, Int<8>{}), LayoutRight{}),
    make_layout(make_shape(Int<1>{}, Int<4>{}), LayoutRight{})
);
auto mma = make_tiled_mma(
    MMA_Atom<UniversalFMA<float>>{},
    Layout<Shape<_16,_16>>{}
);
auto kernel = ampere_sgemm_128x32_3stage<decltype(stride_A), decltype(stride_B), decltype(stride_C),
                                         decltype(sA_layout), decltype(sB_layout), decltype(cta_shape),
                                         decltype(copy_A), decltype(copy_B), decltype(mma)>;
cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, smem_size);
cudaFuncSetAttribute(kernel, cudaFuncAttributePreferredSharedMemoryCarveout, 100);

dim3 block_dim(size(mma));
dim3 grid_dim(size(ceil_div(m, select<0>(cta_shape))), size(ceil_div(n, select<1>(cta_shape))));
kernel<<<grid_dim, block_dim, smem_size, nullptr>>>(
    m, n, k, alpha, beta, A, stride_A, B, stride_B, C, stride_C, cta_shape, sA_layout, sB_layout, copy_A, copy_B, mma
);
Click to expand and view more

Setting up the tiled MMA and copy objects, there’s a bit of boilerplate code needed.

make_tiled_copy allows us to arrange a group of threads with a certain layout and assign a group of elements for each thread to be responsible for. We wrap a CopyTraits object to use the SM80 cp.async.cg.shared.global.L2::128B instruction with a read granularity of 128-bits = 16-bytes. The CopyAtom then takes this CopyTraits and the actual dtype to be used.

The layout of the threads for $A$ is $(32 \times 8)$ in column-major, with each thread responsible for 4 column-major elements, whereas for $B$ both are in row-major arrangement. It is very important to write correct layouts for any problem to ensure read-write access patterns that we actually intended (CuTe will also statically fail if given an impossible access pattern).

A good way to start writing these is to write out the ideal case, or what we’d expect the tiling to be. To coalesce 128-byte DRAM reads and use thread-local 16-byte loads, there should be a stride of 1 between each thread’s 4 fp32s and stride of 4 between threads.

a partition
Visualizing the layout for $A$ (not to scale, some values omitted)

Above is an ideal partitioning of the destination tensor. We can see some values mapped to Thread 0 in pink. The green represents the first 4 values mapped to Thread 1. Thread 2’s values would be right below, and so on, tiling in column-major order. The blue represents the actual stride required in physical linear memory to jump to the next value. The stride-1 between each value within the 4-value blocks allow us to use the wide 16-byte instructions. This layout also maximally coalesces GMEM (global memory) transactions.

Just to verify, we can print copy_A.tidfrg_D(dst_tensor):

$$ (256,(4,1),(1,4,3)):(4,(1,0),(0,1024,4096)) $$

The comments in CuTe source code for tidfrg_S and tidfrg_D explain what these layouts represent:

C++
// Tile a tensor or a layout from shape
//   (M,N,...)
// to shape
//   (Thr,(FrgV,FrgX),(RestM,RestN,...))
// where
//   Thr:   The logical threads within the tiled copy.
//   FrgV:  The values local to a COPY_ATOM Dst.
//   FrgX:  The values tiled across COPY_ATOMs Dst.
//   RestM: The values tiled in M.
//   RestN: The values tiled in N.
auto tidfrg_D(DTensor&& dtensor) {
    ...
}
Click to expand and view more

Corresponding each term to what we saw, we see that this tiled copy uses 256 threads, where each thread is mapped to 4 values local to a copy atom (16-byte cp.async atom = 4× fp32), 4 values tiled across the K-mode, and 3 values tiled across the shared memory’s 3 stages, matching our earlier visualization. Internally, partition_X uses the layouts from tidfrg by indexing the Thr-mode with the thread index in the actual kernel.

Lastly, we set up a TiledMMA object, which assigns threads to values for matrix-multiply accumulation (MMA). Similar to TiledCopy, it takes an MMA_Trait wrapping an MMA_Atom, which defines the smallest “unit” of computation to be tiled. This allows for easy partitioning of the operand $A$ and $B$ matrices and register allocation for the product $C$ fragment. Since our operands are in fp32, which don’t have tensor core support, we use the UniversalFMA<float> atom here. But, for example, if our operands were to be in half-precision, we’d want to use an atom like SM80_16x8x16_F16F16F16F16_TN that wraps a tensor core (TC) instruction.

Each of these atoms intrinsically involve a different number of threads. For FMA, it’s just 1 thread performing a scalar computation. Ampere TC instructions are at warp-level; they require a warp to cooperatively compute a small matrix product, while Hopper TCs involve a warpgroup (4 warps) and so on. We’ll focus on the simple FMA case.

Suppose that after we’ve done some tuning, we decide to use 256 threads in our kernel. Of course, writing a kernel for first time, you wouldn’t know the optimal number of threads, but 128 or 256 threads tend to be good starting points for GEMMs. With each thread computing 1 scalar fragment of $C$, a threadblock of 256 threads can independently compute 256 $C$ values per atom. For balanced data reuse, we’ll arrange the atom’s 256 values to compute a $(16 \times 16)$ fragment of $C$.

c-mma
print_latex helps us visualize the atom. Each thread computes 1 scalar product and accumulates to its 1 fragment register.

You may be asking: “But I thought our setup was for each threadblock to accumulate a $(128 \times 128)$ tile of $C$?” TiledMMA exposes similar methods to TiledCopy, where we can partition an arbitrarily-sized input tensor according to this smaller atom layout. In other words, it replicates the atom across given matrices. Partitioning a $(128 \times 128)$ tile of $C$ thus results in an $(8 \times 8)$ tile of atoms. There is also thrfrg, which is the MMA equivalent to the tidfrg from TiledCopy we saw earlier. Printing thrfrg_C(gC).shape:

$$ ((1, (16, 16)), (1, (8, 8))) $$

And again, looking at the source code comments:

C++
// Tile a tensor or a layout from shape
//   (M,N,...)
// to shape
//   ((ThrV,(ThrM,ThrN)),(FrgV,(RestM,RestN,...)))
// where
//   ThrV:  The threads local to an MMA. layout<0>(ThrLayoutVMNK): ThrV -> thread_idx
//   ThrM:  The threads tiled in M.      layout<1>(ThrLayoutVMNK): ThrM -> thread_idx
//   ThrN:  The threads tiled in N.      layout<2>(ThrLayoutVMNK): ThrN -> thread_idx
//   FrgV:  The values local to an MMA.
//   RestM: The values tiled in M.
//   RestN: The values tiled in N.
auto thrfrg_C(CTensor&& ctensor) const {
    ...
}
Click to expand and view more

We see that there is exactly 1 thread and 1 value local to the MMA atom, and 16 threads tiled in the M- and N-modes. To cover the entire threadblock tile, there then must be 8 values tiled in the M- and N-modes too, meaning the $C$ fragment alone requires 64× 32-bit registers per thread. That is, each thread is computing an $(8 \times 8)$ outer product. Let’s look at thrfrg_A now:

$$((1,(16,1)),(1,(8,32,3)))$$

Notice here how no threads are tiled in the K-mode. Instead, each thread owns all values in its K-mode, as it requires an accumulation over this mode. Now, we can move onto the block/thread-local setup within the actual kernel.

device-side setup

C++
// cta_shape is (Tile M, Tile N, Tile K) = (128, 128, 32)
auto mA = make_tensor(make_gmem_ptr(A), make_layout(make_shape(m, k), stride_A));
auto mB = make_tensor(make_gmem_ptr(B), make_layout(make_shape(n, k), stride_B));
auto mC = make_tensor(make_gmem_ptr(C), make_layout(make_shape(m, n), stride_C));

auto coord = make_coord(blockIdx.y, blockIdx.x, _);
auto gA = local_tile(mA, cta_shape, coord, Step<_1, X, _1>{});
auto gB = local_tile(mB, cta_shape, coord, Step<X, _1, _1>{});
auto gC = local_tile(mC, cta_shape, coord, Step<_1, _1, X>{});
Click to expand and view more

This code is boilerplate for GEMMs. We first create the dynamically-shaped global memory tensors. To “assign” each $(M, N)$ tile coord to threadblocks, we create a Coord object based on block index, and use the local_tile API. Internally, this function is a wrapper on top of zipped_divide that take Coord and Step objects, which specify which tile coord we want to slice out and which modes we want to keep/discard, respectively. Notice the X in the Step object, which means that the corresponding mode in the cta_shape divisor is dropped. Since it’s of shape $(\text{Tile M, Tile N, Tile K})$, we specify the X at mode-1 for $A$ and mode-0 for $B$.

For example, for gA, this means we index mode-0 of the “quotient” tensor of shape $\left(\frac{M}{\text{Tile M}}, \frac{K}{\text{Tile K}}\right)$ by blockIdx.y and keep all of mode-1, since each block does a reduction along the K-mode. Thus, we set Coord to (blockIdx.y, blockIdx.x, _)in CuTe syntax, an underscore indicates we keep all of that mode. This results in gA and gB of shape $(128, K)$.

threadblock-partitioning
A visual of threadblock partitioning (not to scale)

C++
extern __shared__ float smem_buffer[];
auto sA = make_tensor(make_smem_ptr(&smem_buffer[0]), sA_layout);
auto sB = make_tensor(make_smem_ptr(&smem_buffer[cosize_v<sALayout>]), sB_layout);

auto tA = copy_A.get_thread_slice(threadIdx.x);
auto tAgA = tA.partition_S(gA);
auto tAsA = tA.partition_D(sA);

auto tB = copy_B.get_thread_slice(threadIdx.x);
auto tBgB = tB.partition_S(gB);
auto tBsB = tB.partition_D(sB);
Click to expand and view more

We then setup the shared memory tensors from a single buffer, and partition the source and destination tensors for each thread based on the TiledCopy objects we discussed earlier. Printing tAsA.shape from any thread gives us $(4, 1), 1, 4, 3$. We can see that this is exactly what we saw from tidfrg_D, but without mode-0, since we’ve now sliced that tensor to get the thread-local partition. Similarly, tAgA.shape prints $(4, 1), 1, 4, 32$ when $K = 1024$, as $\frac{1024}{\text{Tile K}} = 32$ (mode-3 represents values tiled across $K$).

C++
using blockPipes = _2;  // num. of block buffers
...
auto tC = tiled_mma.get_thread_slice(threadIdx.x);
auto tCsA = tC.partition_A(sA);
auto tCsB = tC.partition_B(sB);
auto tCgC = tC.partition_C(gC);
auto tCrA = make_fragment_like(composition(tCsA(_,_,_,0), make_shape(_,_,blockPipes{})));
auto tCrB = make_fragment_like(composition(tCsB(_,_,_,0), make_shape(_,_,blockPipes{})));
auto tCrC = make_fragment_like(tCgC);
fill(tCrC, 0.0);
Click to expand and view more

Last component for the setup is now setting up the thread-local MMA registers. TiledMMA already gave us a thread-value mapping for $A$, $B$, and $C$, so we just need to slice out each thread-local view and partition the input tensors. Notice that gC is referring to the global memory pointer for $C$, since we’re not tiling it into SMEM, only $A$ and $B$.

You may be asking what this is doing: composition(tCsA(_,_,_,0), make_shape(_,_,blockPipes{})). Recall the pipelining diagram from before, and how each thread owns all of its K-mode. Ideally, we want each thread to begin its load for the next “slice” of its K-mode from SMEM while doing compute on this current slice to save some cycles. The cost though is that we require more registers to eliminate the potential scoreboard stall, hence why we set blockPipes to 2.

Recall from our TiledMMA discussion earlier that thrfrg_A mapped 8 values in the M-mode and all 32 values in the K-mode to each thread. The motivation of the composition(...) is to only have blockPipes values in the K-mode physically allocated in registers, since allocating the entire K-mode may cause register spilling (8 × 32 = 256 registers for 1 operand alone!). Recall register spilling occurs when each thread requires over a certain number of registers (depends on occupancy) and causes the compiler to store registers in local memory (physically backed by slow DRAM) instead, hurting performance.

tCsA(_,_,_,0) globs all the values local to the MMA Atom (1), values tiled in the M-mode (8), and tiled in the K-mode (32). The 0 in the last mode specifies that we just want the values local to one SMEM pipe. We then compose (roughly analagous to functional composition) this sub-tensor with a shape that is identity for values local to the MMA Atom and tiled in the M-mode, but restricts the K-mode values to blockPipes.

mainloop

We’ll now implement the mainloop, where each threadblock loops over its K-tiles and accumulates its individual matrix product. We begin with a prefetch to avoid some extra conditional statements inside the loop.

C++
uint gmem_tile_idx = 0;
uint gmem_tiles = size<2>(gA);
constexpr uint smem_pipes = size<2>(sA);

// prefetch for first (smem_pipes - 1) pipes
CUTE_UNROLL
for (uint i = 0; i < smem_pipes - 1; ++i) {
    copy(copy_A, tAgA(_,_,_,gmem_tile_idx), tAsA(_,_,_,i));
    copy(copy_B, tBgB(_,_,_,gmem_tile_idx), tBsB(_,_,_,i));
    cp_async_fence();
    --gmem_tiles;
    if (gmem_tiles) {
        ++gmem_tile_idx;
    }
}
Click to expand and view more

We initialize some variables for tracking:

In the prefetch loop, we fire off the loads for the first 2 out of 3 pipes. For each copy call, we glob the values local to a copy atom, values tiled across Tile M, and values tiled across Tile K by placing an underscore at those modes. We then copy this K-tile at position gmem_tile_idx to the corresponding pipe. Lastly, we decrement the number of K-tiles left to load, and only increment the K-tile position if there are more left. Otherwise, the next iteration simply loads the same global K-tile into the next pipe to prevent a segfault.

And for the last setup bits before the actual loop body, we do a prefetch for the first register block from SMEM. Note that we need to wait until just the first tile is loaded using cp_async_wait<1> and sync the threadblock for visibility.

C++
int block_pipe = 0;
cp_async_wait<smem_pipes - 2>();
__syncthreads();
// prefetch rmem_block = 0
copy(tCsA(_,_,0,0), tCrA(_,_,block_pipe));  // (M, K, pipe)
copy(tCsB(_,_,0,0), tCrB(_,_,block_pipe));  // (N, K, pipe)

uint pipe_read = 0;
uint pipe_write = smem_pipes - 1;
constexpr uint rmem_blocks = size<2>(tCsA);
const uint k_iters = gmem_tiles + (smem_pipes - 1);
Click to expand and view more

Finally, we set some variables for tracking the pipeline stages. pipe_read and pipe_write correspond to the SMEM pipe to read from and write to, respectively. rmem_blocks is the number of values tiled across the K-mode of the SMEM tile (32) and k_iters tracks the number of GMEM tiles left after the prefetch and to drain the prefetched SMEM pipes.

mainloop1
The dotted blue sector represents the 3 SMEM pipes. We start loads for the next 2 tiles in advance, and wait for the current tile load to complete on each iter. of the mainloop.

C++
for (uint iter = 0;  iter < k_iters; ++iter) {
    if (iter < gmem_tiles) {
        copy(copy_A, tAgA(_,_,_,gmem_tile_idx), tAsA(_,_,_,pipe_write));    
        copy(copy_B, tBgB(_,_,_,gmem_tile_idx), tBsB(_,_,_,pipe_write));
    }
    cp_async_fence();

    CUTE_UNROLL
    for (uint block = 0; block < rmem_blocks - 1; ++block) {
        copy(tCsA(_,_,block+1,pipe_read), tCrA(_,_,block_pipe^1));  // TODO: call before gemm to interleave?
        gemm(tiled_mma, tCrA(_,_,block_pipe), tCrB(_,_,block_pipe), tCrC);
        copy(tCsB(_,_,block+1,pipe_read), tCrB(_,_,block_pipe^1));
        block_pipe ^= 1;
    }
    gemm(tiled_mma, tCrA(_,_,block_pipe), tCrB(_,_,block_pipe), tCrC);
    block_pipe ^= 1;
    ...
Click to expand and view more

We begin the loop body by checking if there’s another K-tile still left to process. If so, we fire off the async copy for that GMEM → SMEM load, and group it with the fence, which will go into our last (3rd) SMEM pipe. Note that the fence is placed outside the if-statement, which creates empty copy groups for tail iterations where there are no new GMEM tiles left. This way, we ensure these tail iterations still correctly wait on their loads.

Some more notable things here:

C++
    ...
    pipe_write = pipe_read;
    pipe_read = (pipe_read + 1) % smem_pipes;

    cp_async_wait<smem_pipes - 2>();
    __syncthreads();

    if (iter != k_iters - 1) {
        copy(tCsA(_,_,0,pipe_read), tCrA(_,_,block_pipe));
        copy(tCsB(_,_,0,pipe_read), tCrB(_,_,block_pipe));
    }
    ++gmem_tile_idx;
}
Click to expand and view more

We then swap SMEM pipes in a “circular” fashion. Since we have processed the tile in the read pipe, we can safely overwrite it by setting it as the write pipe. The read pipe should then be set to the next tile. We modulo the incremented read pipe by the number of pipes (3) to “rotate” it back to the first pipe when it exceeds the max pipe index.

After waiting on the arrival of solely the next tile and ensuring visibility, we preload the first register fragment of the next iteration from SMEM (as long as this is not the last tile). Finally, we update the GMEM tile index for the next iteration.

epilogue

We’ll keep the epilogue pretty simple here with the standard axpby epilogue of formal GEMM implementations in BLAS libraries. Mathematically…

$$C_{\text{out}} = \alpha(AB) + \beta(C_{\text{in}})$$

…where $\alpha, \beta$ are scalars. Note that swapping in different epilogues is relatively simple. We can do this in one line of code with CuTe’s axpby API :

C++
axpby(alpha, tCrC, beta, tCgC);
Click to expand and view more

The function reads in each value of tCgC, scales it by beta, adds it to the corresponding value of tCrC scaled by alpha, and writes it back to tCgC. You may be rightly questioning the performance of this, considering every value of each thread’s 8 × 8 fragment is strided, causing narrow 32-bit reads and writes. We’ll explore the optimization for this and other issues right below.

optimizations

After verifying the kernel’s correctness, the next step is to profile and optimize performance. NVIDIA’s nsight-compute application provides key metrics and suggestions by profiling kernels, helping us quickly narrow down on bottlenecks. We can launch it via terminal:

BASH
ncu --set full --open-in-ui ./my_kernel <args...>
Click to expand and view more

swizzles & shared memory

Upon profiling, ncu informs us of a problem about our use of shared memory:

profile
While 2% of all wavefronts being excessive indicates this problem isn’t really a bottleneck, solving this issue can still teach us how to use shared memory efficiently. What “excessive wavefronts” is referring to are shared memory bank conflicts.

Recall from our tiled MMA layout that each group of 16 consecutive threads share the same “N-row”. Visualizing the $B$ read pattern:

smem_opt1
As always — not to scale

For each iteration across $K$, we observe that threads 0 through 15 will conflict with threads 16 through 31: a 2-way conflict.

Let’s talk solutions now. There are 2 common ways to solve conflicts:

An example swizzle for $B$ would be to swap the data in B0 and B1 in the second row. This way, data required for the first K-iteration can be serviced by both banks at once, rather than being serialized by one bank. We would repeat this pattern by swapping B2 with B3 for the second K-iteration, and so on. Typically, XOR is used in some way for remapping the SMEM addresses since it’s bijective (that is, $(x \oplus c) \oplus c = x$) and cheap. Recall its truth table:

xor_truth

Applying this to the example, we can XOR addresses 32 and 33 with a fixed constant of 1, effectively swapping their addresses. Before this though, since we need to repeat this pattern every 2 rows for other warps, we first modulo the address by 64. To then leave the first of the 2 rows identity, we right-shift the modulo’d address by 5 (division by 32), and only then do we apply the XOR. All together: swizzled = addr ^ (addr % 64 >> 5).

We’ll now take a look at the slightly more complex CUTLASS API. For reference, the implementation is here . The docstring sheds some light:

C++
// A generic Swizzle functor
/* 0bxxxxxxxxxxxxxxxYYYxxxxxxxZZZxxxx
 *                               ^--^ MBase is the number of least-sig bits to keep constant
 *                  ^-^       ^-^     BBits is the number of bits in the mask
 *                    ^---------^     SShift is the distance to shift the YYY mask
 *                                       (pos shifts YYY to the right, neg shifts YYY to the left)
 *
 * e.g. Given
 * 0bxxxxxxxxxxxxxxxxYYxxxxxxxxxZZxxx
 * the result is
 * 0bxxxxxxxxxxxxxxxxYYxxxxxxxxxAAxxx where AA = ZZ xor YY
 */
template <int BBits, int MBase, int SShift = BBits>
struct Swizzle { ... }
Click to expand and view more

Don’t worry if you don’t understand this at a first glance. What this API does is it effectively formalizes what we discussed in the previous example for more general power-of-2 swizzles in binary. I won’t delve into the implementation details (see the swizzling portion of Aleksa Gordic’s blog ), but rather what you as the user need to know to start using it.

The 3 parameters are used as follows: A bitmask of size BBits of 1s starting at bit SShift + MBase (zero-indexed) is bitwise ANDed with the input address. The resulting mask is then right- or left-shifted by SShift bits, depending on its sign, and XORed with the input address.

A couple things to note: The swizzle…

Applying this to our example again, we need to check if the 5th bit of the input (corresponding to 32) is 1 by using a bitmask with a width of just 1 and shifting it down to the appropriate bit to XOR with. We know at this point that BBits = 1 and SShift + MBase = 5, since addresses below 32 should remain identity.

Now, you might be saying that MBase should be 0, since we XORed addresses by 1 earlier. This would be correct if we were solely considering the read side. However, recall that we used 128-bit copies in the GMEM → SMEM write side, which thus requires an alignment of 4 floats. Swizzling at a granularity of 1 address would break this alignment requirement. Instead, we need to swizzle at a minimum granularity of 4 address indices, meaning we should have MBase = 2 and SShift = 3. Finally, we have the new $B$ SMEM layout to be:

C++
auto sB_layout_swizzled = composition(Swizzle<1,2,3>{}, sB_layout);

...

auto kernel = ampere_sgemm_128x32_3stage<decltype(stride_A), decltype(stride_B), decltype(stride_C),
                                         decltype(sA_layout),
                                         decltype(sB_layout_swizzled),
                                         decltype(cta_shape),
                                         decltype(copy_A), decltype(copy_B), decltype(mma)>;
Click to expand and view more

wide epilogue instructions

This is not an optimization that was informed by profiling, but still worth chasing after. As mentioned earlier, the scattered register fragment per thread prevents us from using wide 128-bit global memory instructions. Recall that $C$ is column-major and has a stride of 1 along its M-mode. We should thus pack 4 values along the M-mode. Visualizing this change:

epilogue_opt1
Green squares are not to scale. Each one represents 1 value.

Changing the positions of $C$ that each thread computes requires a change in the thread-value mappings from TiledMMA. Thankfully, CuTe exposes a parameter in make_tiled_mma that lets us rearrange the mapping for situations like this.

C++
// @tparam MMA_Atom The MMA_Atom to use in the TiledMMA
// @tparam AtomLayoutMNK The MNK-tiling of the Atom to be performed.
// @tparam PermuationsMNK Permutations to apply to each MNK-mode before tiling for the Atom.
template <class MMA_Atom,
          class AtomLayoutMNK,
          class PermutationMNK = Tile<Underscore,Underscore,Underscore>>
struct TiledMMA : MMA_Atom { ... }
Click to expand and view more

The two parameters following the MMA atom are…

In summary, these parameters control the arrangement of backing hardware atoms, the logical tiling of atoms to larger tensors, and the arrangement of values from these replicated tiles.

Let’s take a look at the code change:

C++
auto mma = make_tiled_mma(
    MMA_Atom<UniversalFMA<float>>{},
    Layout<Shape<_16,_16>>{},
    Tile<
        Layout<Shape<_16,_4,_2>, Stride<_4,_1,_64>>,
        _128,
        _32
    >{}
);
Click to expand and view more

Taking a look at the 3rd argument, we see a Tile object, with three template args. corresponding to the MNK-modes respectively. The static integer arguments for the N- and K-modes indicate that we do not wish to apply a permutation to those modes. Instead, they represent identity functions. The actual values for N and K represent that we are logically extending the tiled MMA to the threadblock tile, which had $M = 128$, $N = 128$, and $K = 32$. Note that the size of the layout for the M-mode is also 128, but we are applying a permutation, so it’s not just an integer.

We can technically leave the N- and K-modes out since they’re just identity functions, and the tiled MMA will take care of logically extending to larger tiles on a partition without permutations, but we leave them here for clarity.

The layout for the M-mode can be read as a gather/scatter pattern. Given some an unpermuted M-mode index $x$, we apply the layout $f$ to get a new permuted index $y$. Recall that layouts are colexicographic, meaning that the left-most coordinate changes the “fastest”.

Looking at our layout: Layout<Shape<_16,_4,_2>, Stride<_4,_1,_64>>, it:

Great! Now, we need to tweak our epilogue to take advantage of the contiguous values. We allocate a temporary register buffer to hold 1 “column” of the 8 × 8 fragment at a time (could use more here), copy in the corresponding column from $C_{\text{in}}$ with 2× 128-bit instructions, apply axpby, and copy it out to $C_{\text{out}}$ with 2× 128-bit instructions.

C++
auto tCgC_fragment = make_fragment_like(tCrC(_,_,0));  // (1,8) : (0,1)

for (uint col = 0; col < size<2>(tCgC); ++col) {
    copy(AutoVectorizingCopy{}, tCgC(_,_,col), tCgC_fragment);
    axpby(alpha, tCrC(_,_,col), beta, tCgC_fragment);
    copy(AutoVectorizingCopy{}, tCgC_fragment, tCgC(_,_,col));
}
Click to expand and view more

Lastly, note the use of AutoVectorizingCopy as the first argument. This allows copy to automatically dispatch to an instruction of the widest common alignment between the source and destination tensors.

conclusion

In this article, we’ve taken a deceivingly simple single-precision GEMM to a SOTA implementation on an older Ampere GPU, learning many CUTLASS CuTe concepts along the way. The more general techniques and optimizations we’ve touched on also apply to production kernels in deep learning libraries. The full code for this kernel is available here .

If you’ve made it this far, thank you for your attention; I really hope you were able to pick up a thing or two and gained an appreciation for how deep the kernel optimization rabbithole can get.

Copyright Notice

Author: Daniel Park

Link: https://parxd.github.io/posts/cute-ly-writing-an-sm86-sgemm/

License: CC BY-NC-SA 4.0

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. Please attribute the source, use non-commercially, and maintain the same license.

Start searching

Enter keywords to search articles

↑↓
ESC
⌘K Shortcut