Notes From Simon Boehm’s CUDA Matmul Blog

August 25, 2026

Notes From Simon Boehm’s CUDA Matmul Blog

These are my notes from reading Simon Boehm’s How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance. I am going through it one kernel at a time and trying to understand not only what changes, but why each change makes the GPU faster.

We are going to optimize SGEMM: single-precision general matrix multiplication.

For , , and , the literal arithmetic in the displayed kernel is approximately:

The comes from the final operation. In normal GEMM benchmarks we ignore this comparatively small part and count the workload as FLOPs.

Now let us calculate the minimum amount of data that must move. Assume square matrices with width . We must read FP32 values from A, read from B, read the old values of C, and write the new values of C. That is four matrix-sized transfers, and every FP32 value occupies 4 bytes:

For :

This is the minimum data volume, not memory bandwidth. Data volume tells us how many bytes must move. Bandwidth tells us how many bytes the GPU can move per second.

That amount of mathematical work is fixed. If two kernels compute the same SGEMM, we cannot magically remove the required FLOPs. What we can optimize is how quickly the GPU performs them.

So the goal of every optimization in these notes is simple: perform the same useful FLOPs in less time.

Is The Kernel Memory-Bound Or Compute-Bound?

For the square SGEMM, the ideal workload is:

  • approximately GFLOPs of useful arithmetic;
  • at least MB of global-memory traffic, assuming perfect reuse.

Assume the GPU provides:

  • FP32 compute throughput: TFLOP/s;
  • global-memory bandwidth: GB/s.

If arithmetic were the only limitation, the fastest possible compute time would be:

If memory movement were the only limitation, the fastest possible transfer time would be:

The kernel cannot finish before both the arithmetic and the required memory movement finish. In the ideal case they can overlap, so the lower bound is controlled by the slower one:

The compute work takes about as long as the minimum memory movement. Therefore an ideally reused implementation of this SGEMM is compute-bound.

The reuse assumption is everything. The MB calculation assumes A and B are fetched from global memory once and then reused on-chip. A naive kernel repeatedly requests the same values for different outputs, so its memory traffic can be vastly larger. The mathematical operation may be ideally compute-bound while a bad implementation of it is still memory-bound.

What About Tensor Cores?

The TFLOP/s roof above refers to ordinary FP32 arithmetic. Tensor cores are separate matrix-multiply hardware and can provide much higher throughput when we use their supported matrix instructions, data types, and layouts.

The kernels in this article perform scalar FP32 fused multiply-add instructions on the regular FP32 pipelines. They do not issue tensor-core MMA instructions. Therefore using a tensor-core peak in this calculation would give us the wrong roof for the code we are actually optimizing. Warp tiling later in the article organizes work in a tensor-core-like hierarchy, but it still does not turn these kernels into tensor-core kernels.

Naive Implementation

The naive implementation assigns one thread to each output element of C. A thread that owns must calculate a length- dot product, so it reads the complete required row of A and the complete required column of B. It also reads the previous value of C for the term.

Therefore each output thread logically reads:

There are output elements, and therefore output threads. The total logical read demand is:

For the article’s square case, :

Including the final write of every C element gives approximately GB of logical memory operations. The ideal perfectly reused implementation needed only MB, so the naive demand is roughly the ideal minimum.

This is a logical request count, not measured DRAM traffic. Caches, warp-wide reuse, broadcasts, coalescing, and transaction overfetch change how many bytes physically cross global memory.

The FLOP count has not changed. The naive kernel and every optimized kernel still perform the same useful FLOPs. We are not removing matrix-multiplication work. We are removing redundant data movement so the GPU can finish those same FLOPs in less time and therefore achieve a higher FLOP/s rate.