Nguyen Le

Optimizing GEMM on Apple's M5 from scratch, Part 1: Basics

A worklog on optimizing GEMM kernel on Apple's M5 from scratch. Part 1: hardware ceilings, the roofline model, shared memory tiling, and thread coarsening.

Status
In progress
AI use
Nearly slop
Tags
system
Last reviewed
47 min read

Introduction

In this post, you and I will iteratively optimize a Metal matrix-multiplication kernel on Apple’s M5 chip. We want to see how fast we can compute C=ABC = AB, where AA, BB, and CC are large matrices stored in single-precision floating-point (fp32) format using GPU. This is a special case of single-precision general matrix multiplication (SGEMM)In full SGEMM, we compute C=αAB+βCC = \alpha AB + \beta C, where α\alpha and β\beta are scalars. Here, we set α=1\alpha=1 and β=0\beta=0.. I’ll use GEMM for short when the context is clear.

The goal of this post is served as a foundational introduction to GEMM on GPU in general and Metal in particular. Although the goal is ambitious but I will try my best. This is the result of a few months of intensive work with my buddies Claude and Codex.

I am not aiming for a state-of-the-art GEMM kernel on Apple hardware but learning how the GPU behaves and why each change makes our kernel faster — or doesn’tWith the help of Codex, we can actually produce a GEMM kernel that is faster than MPS but only on a specific shape. That’s okay and honestly, agent is too good at kernel engineering.. By the end, I hope you’ll be able to analyze a kernel with the roofline model and reason about its performance: data reuse, thread ownership, and what the profiler is actually telling you.

Info

There are already some “GOAT” posts on this topic: [1], [2], and [3]. [4] also explores GEMM optimization on an Apple GPU. All of them inspired this worklog and make great companion reading. They’re the bar I’m aiming for.

Everything in this blog is done on my MacBook Pro with an Apple M5 (10-core GPU, 24 GB of memory)If you don’t have M5, don’t worry, the FLOPs will be smaller but we can still do the same progression to get fast kernel, the only thing we cannot do on older M-chip is the Neural Accelerator (Neural Core) part., macOS 26.7, Xcode 27.0, and MLX 0.32.1, plugged into power. Every kernel computes the same row-majorRow-major is how we store matrices in memory.Row-major versus column-major storage of a matrixFigure from: The Craft of Coding. and non-transposed matrices with M=N=K=4096M=N=K=4096.

Here is where we’re going. Every kernel in this post fits on one chart:

Log-log roofline from 1 to 100 FLOP per byte and 100 to 5000 GFLOP/s with every kernel in the post, linked in the order we wrote them and colored by the limit their counters showed. Device memory, on the slanted 118 GB/s roof: naive at 3.1 FLOP per byte and 376 GFLOP/s, SIMD-group 8×8 at 9.5 and 1133, SIMD-group 32×32 at 16 and 2017, and 32×32 shared at 25 and 2821. Instruction issue, below both roofs: tiled 16×16 at 8.3 and 667, tiled 32×32 at 17 and 683, and 1D coarsening at 27 and 1230. FP32 ALUs, at the flat 4.05 TFLOP/s roof: the TensorOps winner at 53 and 3789. MLX and MPS in FP32, for reference, sit together at about 25 FLOP per byte and 3.4 TFLOP/s, beside the shared SIMD-group kernel.

The whole worklog on one roofline. Higher is faster; further right means more arithmetic for every byte fetched from device memory. The two black lines are the M5’s ceilings: memory bandwidth (slanted) and FP32 compute (flat). The colours show what the GPU profiler said held each kernel back, and the grey squares are Apple’s own libraries. The roofline analysis under kernel 1 explains how to read this chart properly.

We start at the bottom left, with a naive kernel starved by memory, and finish at the top right, about ten times faster and ahead of Metal Performance Shaders (MPS)MPS is a library that provides high-performance matrix multiplication and other linear algebra operations for Metal. We can think this as cuBLAS but for Apple’s GPU instead. Read more about it here. and MLX, on the same plain FP32 units they use.

Before we doing anything, we should ask what the hardware allows: the peak speed we can reach.

M5 GPU Architecture

Note

Apple doesn’t publish much about the M5 GPU architecture, so we have to reverse engineer it from the available information whereas NVIDIA documents its GPUs down to the SM (Apple why?).

From the official source [5], the M5 GPU has 10 cores, a Neural Accelerator inside each core, and 153 GB/s of unified memory shared with the CPU.

The M5 package: CPU cores, Neural Engine and media engines beside a GPU of 10 identical cores in one cluster, each core containing a Neural Accelerator (NA). A front end sits above the cores and a GPU last-level cache below them, then a system-level cache shared with the CPU, then unified memory at 153 GB/s spec and 118 GB/s measured. A zoomed panel shows one core: four schedulers of 32 FP32 lanes each (128 lanes), an on-core cache of about 200 KiB measured that holds registers, threadgroup memory and L1, a Neural Accelerator, and texture and ray-tracing units.

Solid outlines are published by Apple; dashed outlines are inferred from reverse engineering of M1 and M2. Apple does not publish cache sizes beyond the core, so the figure leaves them out. NA = Neural Accelerator.

The GPU is a single cluster of 10 identical cores, each core plays the role of an SM (Streaming Multiprocessor) on an NVIDIA GPU. On M1 and M2, each core has four schedulers, and each scheduler issues one instruction per cycle for a group of 32 threads [6]. That makes 128 FP32 lanes per core. We’ll meet these 32-thread groups, called SIMD groups, in the first kernel.

If the M5 still has 128 lanes per core as M1 and M2, then at its top two clock states, 1.578 and 1.620 GHzApple does not publish GPU clocks. These come from the GPU’s performance states in the device tree (ioreg -p IODeviceTree)., the FP32 peak would be about:

10×128×2×(1.578 to 1.620) GHz≈4.04 to 4.15 TFLOP/s10 \times 128 \times 2 \times (1.578\text{ to }1.620)\ \text{GHz} \approx 4.04\text{ to }4.15\ \text{TFLOP/s}

The number 22 came from the fact that our main operation is multiply-add (FMA), which is two FLOPs. From the calculation above, the peak FLOPs of our M5 chip is 4.04 TFLOP/s (use the lower bound). That is our compute ceiling for ordinary FP32 arithmetic.

For memory, Apple quotes 153 GB/s [5], [?], and our measured kernel that streams a 1 GiB buffer reaches about 118 GB/s, that is our memory ceiling. To be fair, we’ll use the measured value for our peak memory bandwidth.

Against this ceiling, MPS runs at 3.37 TFLOP/s, or 83%. MLX is right beside it. Below are all kernels we’ll build later:

#KernelFunction in the repoGFLOP/svs MPSvs FP32 ceiling
1Naivematmul_naive36510.8%9.0%
2Tiling 16x16matmul_tiling_mnk1665019.3%16.1%
3Tiling 32x32matmul_tiling_mnk3266619.8%16.4%
41D coarseningmatmul_1D_coarsening_mn64k8122236.3%30.2%
5simdgroup 8x8simdgroup_8x8109632.6%27.1%
6simdgroup 32x32simdgroup_32x32168750.1%41.6%
7simdgroup 32x32, threadgroup memorysimdgroup_32x32_shared272280.9%67.2%
8TensorOps 64x64tensorops_64x64303590.2%74.9%
9TensorOps 64x128, synchronized Ktensorops_sync_64x128_k2563686109.5%91.0%
MLX333299.0%82.3%
MetalPerformanceShaders (MPS)3365100%83.1%

This worklog has two parts. This part covers the hardware ceilings, the roofline model, and kernels 1 to 4: naive, shared memory tiling, and thread coarsening. Part 2 covers kernels 5 to 9: SIMD-group matrices and TensorOps. Our best kernel, which uses Neural Accelerator, reaches 91%.

Kernel 1: Naive Implementation

Let’s remember how we do matrix multiplication in undergraduate algorithm classes. To multiply two matrices, we visit each element of CC and take the dot product of the corresponding row of AA and column of BB. In code, that gives us three nested loops like this:

Code
for each row i of C:
for each column j of C:
sum = 0
for k = 0, ..., K-1:
sum += A[i, k] * B[k, j]
C[i, j] = sum

Now look at the outer two loops: they only choose which element of CC to compute. Since different output elements do not depend on one another, we can compute these row-and-column pairs in parallel.

Code
for each thread (i, j) in parallel:
sum = 0
for k = 0, ..., K-1:
sum += A[i, k] * B[k, j]
C[i, j] = sum

To achieve that in GPU programming model, we give each of those computations to a thread.

These threads are executed in parallel by the GPU and each thread has its own data, and will execute the same function so we have SPMD (Single Program Multiple Data) paradigm. Multiple threads form a threadgroup If you know CUDA, a Metal threadgroup plays the role of a CUDA block., and together multiple threadgroups cover a gridWe can map thread to matrix multiplication like this: each thread will compute one element CC, and its data is the row of AA and column of BB corresponding to the element it computes. Then all thread will run the same dot product function along KK dimension   ⟹  \implies same function, different data..

Our naive kernel uses 32×3232\times32 threads per threadgroup, so a 4096×40964096\times4096 output needs a 128×128128\times128 grid of threadgroups:

A 128 by 128 grid of threadgroups with threadgroup (2, 1) highlighted, zoomed into its 32 by 32 threads with thread (29, 3) highlighted, zoomed into one thread. That thread computes column j = 2 times 32 plus 29 = 93 and row i = 1 times 32 plus 3 = 35, so it owns C[35, 93].

Grid, threadgroup, thread. Thread (29,3)(29, 3) of threadgroup (2,1)(2, 1) owns C35,93C_{35,93}. Layout adapted from [3].

Metal gives each thread its group’s position in the grid — through threadgroup_position_in_grid — and its own position within that group — through thread_position_in_threadgroup In CUDA we use <<<gridDim, blockDim>>> to dispatch a kernel and read blockIdx and threadIdx inside it. Metal’s threadgroup_position_in_grid and thread_position_in_threadgroup play the same roles.. The kernel combines them to find the output row ii and column jj it owns. Below is the naive kernel implementation in Metal:

C++
kernel void matmul_naive(
device const float * A [[buffer(0)]],
device const float * B [[buffer(1)]],
device float * C [[buffer(2)]],
uint M, uint N, uint K,
uint2 block_pos [[ threadgroup_position_in_grid ]],
uint2 thread_pos [[ thread_position_in_threadgroup ]],
uint2 threads_per_group [[ threads_per_threadgroup ]]
) {
// x selects the column, y selects the row
uint j = block_pos.x * threads_per_group.x + thread_pos.x; // column
uint i = block_pos.y * threads_per_group.y + thread_pos.y; // row
// check if the thread is within the bounds
if (i < M && j < N) {
float sum = 0.f;
for (uint p = 0; p < K; ++p) {
sum += A[i * K + p] * B[p * N + j];
}
C[i * N + j] = sum;
}
}
Note

When we launch kernel threads, we round the group counts up, ⌈N/32⌉\lceil N/32\rceil across and ⌈M/32⌉\lceil M/32\rceil down, so some threads at the edges may fall outside CC. We check i<Mi<M and j<Nj<N before reading or writing. A valid thread now knows which row of AA and column of BB to multiply.

If you want to know more about how to launch a Metal kernel, you should read Apple’s compute guide in depth, I won’t cover it in this post.

Let’s follow our thread through the whole kernel. We choose thread (29,3)(29, 3) in threadgroup (2,1)(2, 1) as an example. It owns C35,93C_{35,93}, so it walks along row 35 of AA and down column 93 of BB, one element of each per loop step:

Matrices B at the top, A at the bottom left and C at the bottom right, sharing dimension K between A and B. Row i = 35 of A and column j = 93 of B are highlighted and meet at one cell of C inside threadgroup (2, 1). On the right, the thread takes the dot product of A[35, p] and B[p, 93] over 4096 steps and stores the sum back into C. At the bottom, the same loop in row-major addresses: for p from 0 to 4095, sum += A[i K + p] times B[p N + j]; then C[i N + j] = sum. Two loads and one multiply-add per step, 8192 loads per thread.

One thread of the naive kernel. Adapted from [3].

Before we go any further, one choice in the naive kernel deserves a closer look. Go back to the first two lines of the kernel. A thread at position (x,y)(x, y) computes Cy,xC_{y,x}: the horizontal coordinate xx becomes the column jj, and yy becomes the row ii. But why?

  • In memory, the column index varies fastest. CC is row-major, so Ci,jC_{i,j} lives at offset iN+jiN + j. Moving one column to the right moves 4 bytes; moving one row down jumps 4N4N bytes, which is 16 KiB when N=4096N=4096.
  • Among threads, x varies fastest. A threadgroup numbers its threads linearly as x+32yx + 32y. The GPU executes them in SIMD groups of 32 threads that issue each instruction together and it packs consecutive linear indices into the same SIMD group The same concept in CUDA is called warp. In CUDA, we always have a fixed number of threads in a warp, which is 32. The figure is from [1]. Warp Simon But in Apple’s GPU, we cannot assume the same. We need to use use threadExecutionWidth to get the actual warp size. In my M5, it is 32..
A 32 by 32 threadgroup drawn as rows. Row y = 0 is SIMD group 0, row 1 is SIMD group 1, and so on to row 31. Metal numbers threads t = x + 32 y and packs 32 consecutive t into one SIMD group, so SIMD group = y and lane = x. Our thread (29, 3) has t = 125: SIMD group 3, lane 29. Below, the flattened threads 0 to 127 are drawn as four bundles of 32 wavy arrows.

How a 32×3232\times32 threadgroup packs into SIMD groups. With 32 threads per row, each SIMD group is exactly one row of the threadgroup. The figure follows [3]‘s warp figure.

Put the two statements above together. With our mapping, the 32 lanes of a SIMD group share a row ii and hold consecutive columns j0,…,j0+31j_0, \dots, j_0+31. At step pp of the loop they all want the same Ai,pA_{i,p}, and they want Bp,j0,…,Bp,j0+31B_{p,j_0}, \dots, B_{p,j_0+31}, which are 32 neighbouring floats in one 128-byte span.

Our kernel's mapping, thread_pos.x to column. Lanes 0, 1 and 31 of one SIMD group are drawn in green, blue and orange and own consecutive columns of B. At each step p they read across row p of B, 32 neighbouring floats in one 128-byte span, and all read the same element of one row of A. Their outputs form a row of a 32 by 32 tile of C. 343 GFLOP/s.

Our mapping (thread_pos.x → column): what one SIMD group reads at each step pp. Layout adapted from [3].

Flip the mapping and the lanes share a column instead. Then they all want the same Bp,jB_{p,j} but 32 different rows of AA, each 16 KiB from the next.

The flipped mapping, thread_pos.x to row. Lanes 0, 1 and 31 of one SIMD group own consecutive rows of A. At each step p they read down column p of A, 32 addresses each 16 KiB apart, and all read the same element of one column of B. Their outputs form a column of a 32 by 32 tile of C. 206 GFLOP/s.

The flipped mapping (thread_pos.x → row). Both mappings issue the same loads and do the same arithmetic.

Important

On NVIDIA GPUs, merging a warp’s neighbouring addresses into a few wide memory transactions is called global memory coalescing. This concept can be transfered effortlessly to Metal.

From our measurement, the naive kernel reached 365 GFLOP/s, which is 9% of the FP32 ceiling and 11% of MPS. So where does the other 91%91\% go? We will move to another important concept in GPU programming: Roofline Analysis.

Roofline Analysis

To answer that, we need a way to say what a kernel could reach before we measure it. The roofline model [7] gives us one, and it needs only the two ceilings from the setup section. We will follow Part 1 of the JAX Scaling Book [8] closely (Really recommend this book to anyone who wants to be good at inference).

Compute Time and Memory Time

A kernel does two kinds of work: it computes (multiplying and adding numbers as in GEMM), and it moves data between memory and the coresThe memory here can be refered to registers, threadgroup memory (or shared memory in CUDA), devices memory (or global memory in CUDA) and even cache too.. Both take time, and our two ceilings tell us the least time each one could take.

Let’s FF be the number of floating-point operations (FLOPs) that kernel performs and QQ be the number of bytes moved to or from device memoryMetal calls the GPU’s main memory device memory, after the device address space our buffers live in. It plays the role of CUDA’s global memory. For now, “memory” in this section means device memory and its 118 GB/s ceiling. We’ll see soon why that qualifier matters.. Write P=4.04P = 4.04 TFLOP/s for the compute ceiling and β=118\beta = 118 GB/s for the memory bandwidth.

If the GPU did nothing but arithmetic at full speed, the arithmetic would take:

Tcompute=FP.T_\text{compute} = \frac{F}{P}.

If it did nothing but move bytes at full bandwidth, the traffic would take:

Tmemory=Qβ.T_\text{memory} = \frac{Q}{\beta}.

A GPU can do both at once: while some SIMD groups wait for their loads, others keep the arithmetic units busy. At best, the slower of the two hides the faster one completely, so:

T≥max⁡(Tcompute, Tmemory).T \geq \max\left(T_\text{compute},\ T_\text{memory}\right).

Whichever term is larger names the kernel’s bottleneckWith no overlap at all, the two times add up instead, so a real kernel lands somewhere between the max and the sum. So we have this inequality: max⁡(Tcompute,Tmemory)≤T≤Tcompute+Tmemory\max(T_\text{compute}, T_\text{memory}) \leq T \leq T_\text{compute} + T_\text{memory}..

  • If TcomputeT_\text{compute} is larger, the kernel is compute-bound: the arithmetic units are the busy part, and memory keeps up.
  • If TmemoryT_\text{memory} is larger, the kernel is memory-bound: the arithmetic units sit idle, waiting for data.
Example

Let’s use our knowledge to analyze GEMM itself, assuming a “perfect” kernel.

  • At each output element, we need KK multiplications and K−1K-1 additions and we have total M×NM\times N elements (for output matrix CC), so the total number of operations is (2K−1)×M×N=2×M×N×K−M×N(2K - 1) \times M \times N = 2 \times M \times N \times K - M \times N. We can approximate it as 2×M×N×K2 \times M \times N \times K so:
F=2MNK=2⋅40963≈137.4 GFLOP.F = 2MNK = 2 \cdot 4096^3 \approx 137.4\ \text{GFLOP}.
  • For QQ, imagine a perfect kernel that reads AA and BB once and writes CC once. For AA, we need to read total M×KM \times K elements, for BB, we need to read total K×NK \times N elements, and for CC, we need to write total M×NM \times N elements. At 4 bytes per float, it moves:
Q=4(MK+KN+MN) bytes≈201 MB.Q = 4(MK + KN + MN)\ \text{bytes} \approx 201\ \text{MB}.

The arithmetic takes at least 137.4/4.05≈33.9137.4 / 4.05 \approx 33.9 ms, and the traffic takes at least 0.201/118≈1.70.201 / 118 \approx 1.7 ms. The arithmetic floor is twenty times the memory floor, so a well-written GEMM at this size should be compute-bound.

Arithmetic Intensity

Note

We’ll use this concept for every kernel after, so let’s remember this.

If a kernel is compute-bound, then Tcompute>TmemoryT_\text{compute} > T_\text{memory}, or F/P>Q/βF/P > Q/\beta. Then we can rearrange the inequality to:

FQ>Pβ.\frac{F}{Q} > \frac{P}{\beta}.

The left side describes how much arithmetic the kernel does for the bytes it transfers and it also has its own name.

Definition

The arithmetic intensity of a kernel is the number of FLOPs it performs per byte it moves:

I=FQ(FLOP/byte).I = \frac{F}{Q}\quad \text{(FLOP/byte)}.

Unless we name another memory level, QQ counts bytes transferred to or from device memory (global memory in CUDA). When we estimate it by counting buffer loads in the code, we’ll first assume every request reaches device memory. Caches can reduce that traffic, so we’ll check the estimate against measurements later.

On the hardware side, P/βP/\beta is the intensity at which the compute time and memory time are equal. On our M5:

Pβ=4.04 TFLOP/s118 GB/s≈34 FLOP/byte.\frac{P}{\beta} = \frac{4.04\ \text{TFLOP/s}}{118\ \text{GB/s}} \approx 34\ \text{FLOP/byte}.

We can understand it this way: in the time the M5 moves one byte from device memory, it can do about 34 FLOPs.

Important (Intensity of GEMM)

The perfect GEMM has a arithmetic intensity of:

137.4 GFLOP/201 MB≈683 FLOP/byte137.4\ \text{GFLOP} / 201\ \text{MB} \approx 683\ \text{FLOP/byte}

which is nearly 683/34≈20683 / 34 \approx 20 times what M5 actually needs. For example, arithmetic intensity of square N×NN \times N matrices is 2N3/12N2=N/62N^3 / 12N^2 = N/6, so the intensity grows with the matrix size. Therefore, to get the fastest GEMM as we can, we need to raise the intensity near this perfect intensity as much as possible. And that’s maybe wrong, keep increasing arithmetic intensity isn’t the right way, to know why, keep reading and have fun!

Let’s recall the inequality:

T≥max⁡(Tcompute, Tmemory)=max⁡(FP, Qβ)T \geq \max\left(T_\text{compute},\ T_\text{memory}\right) = \max\left(\frac{F}{P},\ \frac{Q}{\beta}\right)

Dividing FF by both sides of the bound on TT, we get:

FT≤Fmax⁡(F/P, Q/β)=min⁡(P, Iβ).\frac{F}{T} \leq \frac{F}{\max\left(F/P,\ Q/\beta\right)} = \min\left(P,\ I\beta\right).

If we plot this bound against II on log-log axes, we will get the shape that names the model: roofline model. It is a slanted line IβI\beta that rises until it meets the flat line PP, this is the ridge point.

  • A kernel left of the ridge sits under the slanted part of the roof. Raising its intensity raises the bandwidth ceiling. If it already reaches that ceiling, it is memory-bound.
  • A kernel right of the ridge sits under the flat part. The FP32 ceiling sets the bound, and more intensity no longer raises it.
Roofline model

An example roofline plot showing two algorithms with different arithmetic intensities (Algo 1 and Algo 2) and their corresponding theoretical peak throughput under different bandwidths (BW1 and BW2). In the red area, an algorithm is bandwidth bound at both bandwidths and is wasting some fraction of the hardware’s peak FLOPs/s. The yellow area is bandwidth-bound only at the lower bandwidth (BW1). The green area is compute-bound at all bandwidths (I use the same example, figure and phrasing from [8]).

The Naive Kernel on the Roofline

Now we go back to the naive kernel’s inner loop (this is where the kernel reads and computes the data):

C++
sum += A[i * K + p] * B[p * N + j];

Each iteration does one multiply-add, 2 FLOPs, and asks for two floats, 8 bytes. If every one of those requests went to device memory, the kernel’s intensity would be:

Inaive=2 FLOP8 bytes=0.25 FLOP/byte.I_\text{naive} = \frac{2\ \text{FLOP}}{8\ \text{bytes}} = 0.25\ \text{FLOP/byte}.

That is only (0.25/683)×100%≈1/2700≈0.036%(0.25 / 683) \times 100\% \approx 1 / 2700 \approx 0.036\% of the perfect kernel’s intensity. With the same arithmetic, naive kernel is spending more than 2700 times on data movement. So “on paper”, our naive kernel is memory-bound.

Put that on the roofline, at 0.25 FLOP/byte, the naive kernel sits far left of the ridge, and its roof is:

roof(naive)=min⁡(4.05 TFLOP/s,0.25×118 GB/s≈30 GFLOP/s)=30 GFLOP/s.\text{roof}(\text{naive}) = \min(4.05\ \text{TFLOP/s}, 0.25 \times 118\ \text{GB/s} \approx 30\ \text{GFLOP/s}) = 30\ \text{GFLOP/s}.

But in our benchmark, we measured 365 GFLOP/s, twelve times higher than that roof! A kernel cannot run above its roof, so one of our inputs must be wrong.

Let’s follow the bytes to find out which one. At 365 GFLOP/s, the kernel finishes its 137.4 GFLOP in about 376 ms. In that time, device memory can deliver at most:

118 GB/s×0.376 s≈44 GB.118\ \text{GB/s} \times 0.376\ \text{s} \approx 44\ \text{GB}.

But the kernel asks for 550 GB from device memory insteadWe need two floats per multiply-add so the total bytes requested is 2MNK×42MNK \times 4 bytes or 2×4×40963≈5502 \times 4 \times 4096^3 \approx 550 GB.. So at least 92% of the loads never reached device memoryEven if we use Apple’s 153 GB/s spec, it only raises the 44 GB to about 58 GB, still barely a tenth of what the kernel asks for. The conclusion doesn’t depend on our measured bandwidth.. It must be the case that almost data has been cached by the GPUThis is an inference from counting loads against measured throughput, not a measured cache hit rate.. So the wrong input was QQ: we counted the bytes the code requests, not the bytes that actually travel from device memory.

Important (Why GPU does the caching for us?)

Recall the access pattern from the coalescing figure. At step pp, a SIMD group reads one 128-byte span of row pp of BB, and the other 31 SIMD groups in its threadgroup read the same span, because they own the same 32 columns. Over the next few steps, the same SIMD group reads Ai,p,Ai,p+1,…A_{i,p}, A_{i,p+1}, \dots, which sit side by side in memory. A cache close to the cores can serve all of those from one trip to device memory.

Our 0.25 FLOP/byte was a no-cache estimate of arithmetic intensity: it assumed every buffer load reached device memory. With the caches absorbing most of those requests, the intensity is at least 137.4 GFLOP/44 GB≈3.1137.4\ \text{GFLOP} / 44\ \text{GB} \approx 3.1 FLOP/byte. We still count at device memory, the only thing that changed is our estimate of QQ.

Log-log roofline of the M5 GPU: a slanted roof at 118 GB/s meets a flat roof at 4.05 TFLOP/s at the ridge, about 34 FLOP per byte. The naive kernel's measured 365 GFLOP/s is plotted twice. Counted at the code's loads, 0.25 FLOP per byte, it sits twelve times above its roof of 30 GFLOP/s, which is impossible. Counted at device memory, at least 3.1 FLOP per byte, it lands on the roof, because caches serve at least 92% of the loads. The perfect GEMM sits at 683 FLOP per byte on the flat roof.

The naive kernel on the M5 roofline, counted two ways. The solid dot uses the bytes the code requests and sits twelve times above its roof. The dashed circle is inferred: it uses the most that device memory could deliver in the measured 376 ms, so its intensity is a lower bound. Both roofs use our measured ceilings.

Profiling the Naive Kernel

Besides analyzing and predicting the kernel performance, we can also ask the GPU. Like Nsight Compute on NVIDIA GPUs, Apple’s Instruments can read the GPU’s performance counters while our kernel runsprobes/profile_kernel.sh in the repository records a Metal System Trace with Instruments’ “Performance Limiters” counter set and averages each counter over our kernel’s GPU time. xctrace has no public option for the counter set, so the script patches Xcode’s template. The counters are sampled for the whole GPU, not per kernel, so keep the machine idle while profiling. The script also times the GPU alone, so its GFLOP/s are a little higher than our tables, which come from the harness and include encoding and submitting the work.. Here is the report for the naive kernel:

CounterValue
GFLOP/s (median of 3 iterations)376
GPU time over all iterations1095.9 ms
Kernel Occupancy97.51 %
Compute SIMD Groups Inflight93.61
Instruction Throughput Limiter61.30 %
Instruction Throughput Utilization20.46 %
ALU Utilization22.76 %
F32 Limiter9.20 %
F32 Utilization9.14 %
Integer and Conditional Limiter36.08 %
Integer and Conditional Utilization27.28 %
Control Flow Limiter19.09 %
L1 Cache Limiter6.81 %
L1 Cache Utilization6.81 %
ThreadGroup L1 Read Accesses0.00 %
Buffer L1 Read Accesses99.88 %
Threadgroup Memory L1 Read Bandwidth0.00 GiB/s
Buffer L1 Read Bandwidth788.22 GiB/s
Buffer L1 Miss Rate18.60 %
Last Level Cache Limiter25.45 %
Last Level Cache Bandwidth196.59 GB/s
GPU Read Bandwidth120.03 GB/s
Register Pressure Influence0.00 %
Register spillsnone

A utilization counter is the share of a unit’s peak work that was actually done and a limiter counts the time the unit tried to work but was stalledThese are Apple’s definitions, which Instruments prints with each counter. For example, “F32 Utilization: Measures the time during which F32 work is executed as a percentage of peak F32 performance”.. For now, we can see three lines:

  • F32 Utilization is 9.1%: the FP32 units do 9% of the work they could, the same 9% of the FP32 ceiling our stopwatch gave.
  • Buffer L1 Miss Rate is 18.6%: the L1 cache inside each core answers about four out of five buffer reads.
  • GPU Read Bandwidth is 120 GB/s: what gets past the GPU’s caches is read from memory outside the GPUApple describes this counter as reads “from a memory external to the GPU (potentially device memory)”, so it may include the system-level cache that the GPU shares with the CPU. at our device-memory ceiling of 118 GB/sAs we mentioned before, 118 GB/s is our estimated from measurements, not the real value and it isn’t different much from the real value (120 GB/s)..

Over one 366 ms run, 120 GB/s is about 44 GB, this is the most requests that device memory can deliver. So the dashed circle in our naive kernel roofline figure is where the naive kernel really sits. The caches absorb over 90% of its loads, and the rest saturate device memory.

Areas of Improvement

Every kernel in the rest of this post performs the same 137.4 GFLOP. What we can change is how many bytes each FLOP costs: we can raise the kernel’s intensity toward the 683 that GEMM allows. The perfect kernel gets there by loading each input value once and reusing it thousands of times from somewhere fast. Every optimization in this post is a way of getting closer to that reuse.

Back to the question the first result raised: can threads working on nearby outputs share input values on purpose instead of hoping the cache does it for them? Our next kernel answers it with tiling.

Kernels 2 and 3: Shared Memory Tiling

Note

Thank Apple for cache optimization but we won’t rely on it. Our next kernel asks for this cache reuse explicitly. Before we change any code, let’s count how much reuse there is to get.

Look again at one 32×3232\times32 threadgroup of the naive kernel. In order to compute that threadgroup’s final output, we need a a block of 32×K32 \times K from AA and a block of K×32K \times 32 from BB. So the threadgroup needs:

32K+32K=2×32×4096≈262K different elements.32K + 32K = 2 \times 32 \times 4096 \approx 262\text{K different elements}.

Now let’s look how our naive kernel acquires these elements. The 32 threads in a row of the threadgroup share the same row ii, so they all walk along the same row of AA. The 32 threads in a column share the same column jj, so they all walk down the same column of BB. Over the whole loop, the threadgroup asks for:

1024 threads×2K loads=1024×8192≈8.4M loads.1024\ \text{threads} \times 2K\ \text{loads} = 1024 \times 8192 \approx 8.4\text{M loads}.

So although we only need 262K different elements, our naive kernel asks for 8.4M loads, that means for each element, it is read 32 times. If the threadgroup has size T×TT \times T, then this is TT times and it is increasing linearly with the threadgroup size.

Our tiled kernel still keeps everything from the naive kernel: the same 32×3232\times32 threadgroup, and each thread still owns one element of CC, so thread (29,3)(29, 3) of threadgroup (2,1)(2, 1) still computes C35,93C_{35,93}. The only thing we change is where the inputs come from so we can control it. Each value is:

  • Loaded from a device buffer once per threadgroup.
  • Then kept on the core.
  • Finally read by all the threads that need it.

To keep values on the core, we need a place that every thread in the threadgroup can write to and read from. Metal calls it threadgroup memoryCUDA calls it shared memory and declares it with __shared__. Metal marks it with the threadgroup address space, the same way device marks our buffers. Besides being a place that all threads in the same block can work with, it is also faster to access than global memory., and we declare it inside the kernel:

C++
threadgroup float A_shmem[32 * 32];

Every thread in the threadgroup sees the same A_shmem, and it lives only as long as the threadgroup does. Threads in other threadgroups can’t see it.

Note

Since M3, Apple stores registers, threadgroup memory, and cached buffer data in the same on-core caches, and assigns that storage to whichever kind a kernel uses [9]. So unlike on NVIDIA GPUs, there is no fixed “shared memory per core” to design around. An experiment from Claude finds that each core keeps about 200 KiB of buffer data close by, and a single threadgroup may allocate at most 32 KiB of threadgroup memory.

Two 32×3232\times32 tiles of float need 2×32×32×4 bytes=82 \times 32 \times 32 \times 4\ \text{bytes} = 8 KiB so it is well under the limit.

But why do we need threadgroup memory? It gives us control: we decide what stays on the core and for how long, instead of hoping the cache decides the same wayIn CUDA, without any help from caching, the pain point of the naive kernel is that it reads the same element many times, and each read is a trip to global memory, which is expensive. So we change the destination of the trip from global memory to the core. Shared memory has lower latency and higher bandwidth than global memory, which makes each trip cheaper. This is the same idea in PMPP [10] and Simon’s blog [1]..

Tiling the Loop over K

Let’s go back to the loops, as we did for the naive kernel. Each thread still computes one dot product of length KK, but now we cut that dot product into phases of TT steps. In each phase, the threadgroup copies a T×TT\times T tile of AA and a T×TT\times T tile of BB into threadgroup memory, and every thread does TT multiply-adds from the tiles:

Code
for each threadgroup (bCol, bRow) in parallel:
for each thread (tCol, tRow) in parallel:
# the element of C this thread owns
i = bRow*T + tRow
j = bCol*T + tCol
sum = 0
# one phase per pair of tiles
for ph = 0, T, 2T, ..., K-T:
# each thread loads one element of A
A_shmem[tRow, tCol] = A[i, ph + tCol]
# and one element of B
B_shmem[tRow, tCol] = B[ph + tRow, j]
wait until the whole threadgroup has loaded
for k = 0, ..., T-1:
# = A[i, ph+k] * B[ph+k, j]
sum += A_shmem[tRow, k] * B_shmem[k, tCol]
wait until the whole threadgroup has finished reading
C[i, j] = sum

Across all phases, ph+kph + k visits every index from 00 to K−1K-1 exactly once, so sum ends up as the same dot product as in the naive kernel. The difference is who loads what. With T×TT\times T threads and two T×TT\times T tiles, each thread loads exactly one element of each tile, and then reads TT elements that other threads loaded.

Top: A times B equals C for threadgroup (2, 1) with 32 by 32 tiles. Its rows 32 to 63 of A and columns 64 to 95 of B are shaded blue. At phase ph = 64, the current 32 by 32 tile of each is shaded red; after each of the 128 phases, pointer A moves one tile right (A += 32) and pointer B one tile down (B += 32 · N), while pointer C stays on the threadgroup's tile of C. Bottom: the two tiles copied into threadgroup memory as A_shmem and B_shmem, one element per thread. Thread (29, 3) reads row 3 of A_shmem and column 29 of B_shmem to accumulate C[35, 93], which is stored after the last phase. Each value is loaded once from device memory and read 32 times from threadgroup memory.

One phase of the tiled kernel for threadgroup (2,1)(2, 1) with T=32T = 32, at ph=64ph = 64. Top: the threadgroup’s rows of AA and columns of BB (blue), this phase’s tiles (red), and where the pointers A, B, and C point (violet). Bottom: the same tiles in threadgroup memory, where thread (29,3)(29, 3) reads row 3 of A_shmem and column 29 of B_shmem for C35,93C_{35,93}. Layout adapted from [1] and [3].

Important (Counting the loads)

Per phase, a threadgroup requests 2T22T^2 floats from the device buffers. There are K/TK/T phases, so a threadgroup requests:

KT×2T2=2TK floats,\frac{K}{T} \times 2T^2 = 2TK\ \text{floats},

TT times fewer than the naive threadgroup’s 2T2K2T^2K. For T=32T = 32, that is 2×32×4096=262,1442 \times 32 \times 4096 = 262{,}144 floats: exactly the number of different floats we counted above, so each one is loaded once.

Across the grid, the MN/T2MN/T^2 threadgroups request:

MNT2×2TK×4 bytes=8MNKT bytes,\frac{MN}{T^2} \times 2TK \times 4\ \text{bytes} = \frac{8MNK}{T}\ \text{bytes},

which is 34.4 GB for T=16T = 16 and 17.2 GB for T=32T = 32, against the naive kernel’s 550 GB. Assuming every request reaches device memory, our intensity estimate becomes:

I=2MNK8MNK/T=T4 FLOP/byte,I = \frac{2MNK}{8MNK/T} = \frac{T}{4}\ \text{FLOP/byte},

4 FLOP/byte for T=16T = 16 and 8 for T=32T = 32. These are no-cache estimates, both still left of the ridge at 34.

Keep that T/4T/4 in mind. Going from 16×1616\times16 to 32×3232\times32 tiles halves the bytes requested from the device buffers. If those requests all reached device memory and its bandwidth still set the time, the 32×3232\times32 kernel should run close to twice as fast. We’ll check that prediction at the end of this section.

Implementation

Note

The template parameters BLOCK_M and BLOCK_N are the tile’s height and width, and matmul_tiling_mnk16 and matmul_tiling_mnk32 in the table set both to 16 and 32. We launch it like the naive kernel: T×TT\times T threads per threadgroup and ⌈N/T⌉×⌈M/T⌉\lceil N/T\rceil \times \lceil M/T\rceil threadgroups.

C++matmul_tiling
template <uint BLOCK_M, uint BLOCK_N>
kernel void matmul_tiling(device const float* A [[buffer(0)]],
device const float* B [[buffer(1)]],
device float* C [[buffer(2)]],
uint M, uint N, uint K,
uint2 block_pos [[ threadgroup_position_in_grid ]],
uint2 thread_pos [[ thread_position_in_threadgroup ]])
{
// one element of A and one of B per thread only works for a square tile
static_assert(BLOCK_M == BLOCK_N, "BLOCK_M must equal BLOCK_N");
uint bRow = block_pos.y;
uint bCol = block_pos.x;
uint tRow = thread_pos.y;
uint tCol = thread_pos.x;
// 1. allocate threadgroup memory and move the pointers
threadgroup float A_shmem[BLOCK_M * BLOCK_N];
threadgroup float B_shmem[BLOCK_M * BLOCK_N];
A += bRow * BLOCK_M * K; // A now starts at row bRow * BLOCK_M
B += bCol * BLOCK_N; // B now starts at column bCol * BLOCK_N
C += bRow * BLOCK_M * N + bCol * BLOCK_N; // C now starts at our tile's top-left corner
float sum = 0.f;
for (uint ph = 0; ph < K; ph += BLOCK_N) {
// 2. collaboratively load one tile of A and one tile of B
A_shmem[tRow * BLOCK_N + tCol] = A[tRow * K + tCol];
B_shmem[tRow * BLOCK_N + tCol] = B[tRow * N + tCol];
threadgroup_barrier(mem_flags::mem_threadgroup); // wait until both tiles are full
// 3. collaboratively compute from the tiles
for (uint k = 0; k < BLOCK_N; ++k) {
sum += A_shmem[tRow * BLOCK_N + k] * B_shmem[k * BLOCK_N + tCol];
}
threadgroup_barrier(mem_flags::mem_threadgroup); // wait until everyone is done reading
// 4. slide both tiles along K
A += BLOCK_N;
B += BLOCK_N * N;
}
// 5. write the result
C[tRow * N + tCol] = sum;
}
Toy problem with 4 by 4 matrices A, B and C split into 2 by 2 tiles. Threadgroup (1, 1) owns rows 2 and 3 of A, columns 2 and 3 of B, and the bottom-right tile of C, shaded blue; its thread (0, 1) owns C[3, 2]. Violet arrows move pointer A by 8 elements from A[0, 0] to A[2, 0], pointer B by 2 from B[0, 0] to B[0, 2], and pointer C by 10 from C[0, 0] to C[2, 2], matching A += bRow · T · K = 8, B += bCol · T = 2, and C += bRow · T · N + bCol · T = 10.

Section 1 on a toy problem, after [3]‘s step-by-step figure: M=N=K=4M = N = K = 4 and T=2T = 2, so the grid has 2×22\times2 threadgroups. We follow threadgroup (1,1)(1, 1) and its thread (0,1)(0, 1), which owns C3,2C_{3,2}; a cell labelled A30 is A3,0A_{3,0}. The violet arrows are the pointer moves: A by 8 elements to A2,0A_{2,0}, B by 2 to B0,2B_{0,2}, and C by 10 to C2,2C_{2,2}.

The same toy problem, sections 2 to 4 stacked. Section 2 at ph = 0: the red tile of A (A20, A21, A30, A31) and the red tile of B (B02, B03, B12, B13) are copied into A_shmem and B_shmem, one element per thread; thread (x, y) copies A_shmem[y, x] and B_shmem[y, x], so thread (0, 1) copies A30 and B12; then threadgroup_barrier waits until both tiles are full. Section 3: thread (0, 1) walks row 1 of A_shmem and column 0 of B_shmem, sum += A_shmem[1, k] · B_shmem[k, 0] for k = 0 and 1, so at ph = 0 the sum is A30 · B02 + A31 · B12; the other three threads read other rows and columns of the same tiles; then threadgroup_barrier waits until everyone is done reading. Section 4: pointer A moves right by BLOCK_N = 2 to A[2, 2] and pointer B moves down by BLOCK_N · N = 8 to B[2, 2]; repeating sections 2 and 3 at ph = 2 adds A32 · B22 + A33 · B32, so C[3, 2] = A30 · B02 + A31 · B12 + A32 · B22 + A33 · B32.

Section 2, at ph=0ph = 0: each thread copies one element of each red tile into threadgroup memory (collaborative loading), so thread (0,1)(0, 1) copies A3,0A_{3,0} and B1,2B_{1,2}. Section 3: it walks row 1 of A_shmem and column 0 of B_shmem, adding A3,0B0,2+A3,1B1,2A_{3,0}B_{0,2} + A_{3,1}B_{1,2} to sum, while the other threads read other rows and columns of the same tiles. Section 4: both tiles slide to ph=2ph = 2, and repeating sections 2 and 3 there completes C3,2C_{3,2}. At full size, the device loads in section 2 are coalesced: each SIMD group is one row of the threadgroup, so its 32 lanes read 32 neighbouring floats of a row of AA and of a row of BB.

Note

What if our matrix size isn’t divisible by tile width? Then some threads will be out of range and we need to handle them specially. This is an exercise for you to try. Here is an hint, out of range threads will load zeros instead so the sum won’t be affected.

Barrier Synchronization

Note

Until now, every thread worked alone. In the naive kernel, a thread read its own row and column, wrote its own output, and never needed anything another thread did. For the tiling kernel, at step kk in section 3, our thread (29,3)(29, 3) reads B_shmem[k, 29], which thread (29,k)(29, k) copied there in section 2. Over its 32 steps, our thread reads values copied by 32 different threads, one from every row of the threadgroup.

And those threads don’t run in lockstep. The 32×32=102432\times32 = 1024 threads of our threadgroup run as 32 SIMD groups, one per row, and the GPU schedules those SIMD groups independently: one can be many instructions ahead of another, and we don’t control the order. If SIMD group 3 (ours) reaches section 3 before SIMD group 20 has written its row of B_shmem, our thread reads whatever was there before: the previous phase’s tile, or garbage in the first phase.

Each thread needs a way to wait for each other. In Metal it is one function call:

C++
threadgroup_barrier(mem_flags::mem_threadgroup);

When a thread calls it, the thread is held at that line until every thread in its threadgroup has reached the same line, and then all of them continueLet’s think we have a group of friends who drive to a mall together that each will shop in a different store, but the car leaves only when everyone is back. Without the barrier, someone gets left at the mall. Below is PMPP’s illustration for threads synchronization.PMPP's barrier synchronization diagram. The mem_flags flag adds a guarantee about memory: every write to threadgroup memory made before the barrier is visible to every thread in the threadgroup after itCUDA’s __syncthreads() does both at once. In Metal, the flag chooses which memory the barrier covers: mem_threadgroup for threadgroup memory, mem_device for device buffers, or mem_none for an execution barrier only. See the Metal Shading Language Specification..

A barrier only waits for the threads of one threadgroup. There is no barrier between threadgroups inside a kernel. That is enough for tiling, because the threads that share a tile all belong to the same threadgroup.

Our kernel calls the barrier twice per phase, and each call protects a different dependence between threads.

  • The barrier after section 2 protects a read-after-write dependence: no thread reads a tile before every thread has written its element into it.
  • The barrier after section 3 protects a write-after-read dependence: no thread overwrites a tile with the next phase’s values before every thread has finished reading the current ones. Without it, a SIMD group that finishes section 3 early would start section 2 of the next phase while a slower SIMD group is still reading.
Timeline of three SIMD groups (20, 3 and 31) of one 32 by 32 threadgroup over one phase, with illustrative timing. Without barriers: SIMD group 3 finishes loading early and, during compute, reads row 20 of B_shmem before SIMD group 20 has finished writing it; then it starts the next load and overwrites the tiles while SIMD group 31 is still computing from them. With both barriers: every SIMD group waits at barrier 1 until the slowest load finishes (tiles are full), computes, and waits at barrier 2 until the slowest compute finishes (everyone is done reading) before the next load.

Three of the 32 SIMD groups in one 32×3232\times32 threadgroup over one phase. Top: without barriers, SIMD group 3 (our thread’s) reads a row of B_shmem that SIMD group 20 hasn’t written yet ①, then starts overwriting the tiles while SIMD group 31 is still reading them ②. Bottom: barrier 1 holds every SIMD group until the tiles are full, and barrier 2 until everyone is done reading.

Example (Removing a barrier)

If we run the kernel at M=N=K=4096M = N = K = 4096 with one barrier removed, five runs each, and compare all 4096×4096≈16.84096 \times 4096 \approx 16.8M outputs against a CPU reference:

Barrier removedWrong outputs, T=16T = 16Wrong outputs, T=32T = 32
First (after loading)99.8%89-90%
Second (after computing)15-16%48%

The result is wrong on every run for both ways, and the number of wrong outputs changes from run to run.

Results

KernelGFLOP/svs naive
Naive3651.00x
Tiling 16x166501.78x
Tiling 32x326661.82x

Tiling actually works! Both tiled kernels are about 1.8x faster than the naive one. But our no-cache intensity estimate suggested that 32×3232\times32 tiles could be nearly twice as fast as 16×1616\times16 ones, since the larger tile halves the requested buffer bytes. In reality, they’re only 2.5% faster. What did we miss?

First, let’s see the profiler results:

Counternaivetiled16tiled32
GFLOP/s376667683
GPU Read Bandwidth (GB/s)120.0380.6339.92
Buffer L1 Read Bandwidth (GiB/s)788.22155.3879.59
L1 Cache Limiter (%)6.818.0610.31
ThreadGroup L1 Read Accesses (%)0.0069.1489.13
Instruction Throughput Limiter (%)61.3080.9780.59
F32 Utilization (%)9.1416.1716.54
Integer and Conditional Utilization (%)27.2839.3462.43
Control Flow Limiter (%)19.0932.9417.76

The memory counters show what tiling bought us. The naive kernel reads about 120 GB/s from outside the GPU, right at our measured bandwidth ceiling. The tiled kernels run faster while reading only 81 and 40 GB/s. Loading each input once per threadgroup has relieved that demand on global memory. But the 32×3232\times32 kernel uses half the bandwidth of the 16×1616\times16 kernel and runs at almost the same speed, we need to know why?

First, let’s check our intensity estimates. The 4 and 8 FLOP/byte we calculated before assumed that every requested byte came from device memory. Even though tiling makes sharing within a threadgroup explicit, the caches can still share inputs between threadgroupsThreadgroups in the same row of the grid read the same rows of AA, and those in the same column read the same columns of BB. A value one threadgroup brought in may still be in the cache when its neighbour asks for it.. The profiler’s 16×1616\times16 run takes about 206 ms, so its global memory reads is:

Q≈81 GB/s×0.206 s≈16.6 GB.Q \approx 81\ \text{GB/s} \times 0.206\ \text{s} \approx 16.6\ \text{GB}.

That is about half the 34.4 GB requested by the kernel. Using this traffic to estimate intensity gives us 137.4/16.6≈8.3137.4 / 16.6 \approx 8.3 FLOP/byte. The same calculation for 32×3232\times32 gives about 8.0 GB of reads and 17 FLOP/byte. Caching changed our estimate of the bytes moved, not what arithmetic intensity means.

At these intensities, the bandwidth roof allows roughly 1 and 2 TFLOP/s. The profiler measures only 667 and 683 GFLOP/s so both kernels sit below their roofs. As we can see, the roofline still helps us see the gap but it doesn’t tell us which part of the kernel prevents us from closing it.

Log-log roofline of the M5 GPU with the naive and both tiled kernels, each at its arithmetic intensity measured at device memory. The naive kernel sits on the slanted 118 GB/s roof at 3.1 FLOP per byte and 376 GFLOP/s. Tiling moves it right: 16×16 tiles to 8.3 FLOP per byte at 667 GFLOP/s, and 32×32 tiles to 17 FLOP per byte at 683 GFLOP/s. Both tiled kernels stop on a flat dashed line at about 675 GFLOP/s, far below the roof; above the 32×32 kernel the roof is 3 times higher.

The naive and tiled kernels on the roofline, with intensity counted at device memory: 137.4 GFLOP divided by the bytes the profiler saw leave the GPU (GPU Read Bandwidth × GPU time). GFLOP/s are from the same profiler runs. The dashed line marks where both tiled kernels stop; it is measured, not derived. Same axes as the naive roofline in kernel 1.

Now, let’s go back to the inner loop:

C++
sum += A_shmem[tRow * BLOCK_N + k] * B_shmem[k * BLOCK_N + tCol];

Each thread reads one value from A_shmem and one from B_shmem, then does one multiply-add. Tiling kernel moved those reads onto the core, but it didn’t remove them. With either tile size, every multiply-add still asks for two values from threadgroup memory, along with the address calculations and loop control. Even though a larger tile lets more threads share each buffer load, each of those threads still has to read the values from the shared tile.

Note (Evidence from profiler)

We can see that L1 Cache Limiter is only 8-10%, while Instruction Throughput Limiter is about 81% for both tile sizes. Meanwhile, the FP32 units do only about 16% of their peak work. This points toward instruction overhead and stalls around the arithmetic, rather than saturated external-memory or L1 bandwidth.

The roofline analysis in kernel 1 asked for reuse “from somewhere fast”. Threadgroup memory gave us that reuse between threads. Now we want reuse within a thread. If one thread computes several outputs that need the same input, it can load that input into a register once and use it in several multiply-adds, without another load for each use. That is the next kernel: thread coarsening.

Kernel 4: 1D Thread Coarsening

Take two outputs in the same column, Ci,jC_{i,j} and Ci+1,jC_{i+1,j}. At step kk, they need different values of AA, but the same value Bk,jB_{k,j}. In the tiled kernel, two threads load that value separately from threadgroup memory. What if one thread computed both outputs? It could load Bk,jB_{k,j} once into a register and use it in both multiply-adds. We would need three loads instead of four for the same arithmetic.

Thread coarsening [10] gives a thread the work of several threads. Here, each thread computes TMT_M outputs stacked in one column:

Ci0,j, Ci0+1,j, …, Ci0+TM−1,j.C_{i_0,j},\ C_{i_0+1,j},\ \dots,\ C_{i_0+T_M-1,j}.

At each step kk, the thread loads Bk,jB_{k,j} once and reads TMT_M different values of AA, one for each output. That gives TM+1T_M + 1 threadgroup-memory loads for TMT_M multiply-adds, or:

TM+1TM=1+1TMloads per multiply-add.\frac{T_M + 1}{T_M} = 1 + \frac{1}{T_M}\quad\text{loads per multiply-add}.

With TM=1T_M = 1, we have the tiled kernel’s two loads per multiply-add. Increasing TMT_M spreads the one load of BB over more outputs. Since those outputs form a strip in one direction, we call this 1D coarsening.

Two outputs in the same column, C[i, j] and C[i+1, j], at one step k. Left, the tiled kernel: thread 1 owns C[i, j] and thread 2 owns C[i+1, j]; each loads its own value of A from column k of A_shmem and both load B[k, j] from row k of B_shmem, so B[k, j] is loaded twice: 4 loads for 2 multiply-adds. Right, the coarsened kernel: one thread owns both outputs, loads B[k, j] once into the register Btmp, and computes sum[0] += A[i, k] · Btmp and sum[1] += A[i+1, k] · Btmp: 3 loads for 2 multiply-adds. With T_M outputs in one column, T_M + 1 loads for T_M multiply-adds; for T_M = 8, 9 loads for 8.

Two outputs in the same column at one step kk. Left: the tiled kernel gives them to two threads, and both load Bk,jB_{k,j} from threadgroup memory. Right: one thread computes both, keeps Bk,jB_{k,j} in a register, and saves a load. With TMT_M outputs per thread, it takes TM+1T_M + 1 loads for TMT_M multiply-adds.

Implementation

Note

Following [1], we take TM=8T_M = 8 and let each threadgroup compute a 64×6464\times64 tile of CC. The tile has 4096 outputs, and each thread owns eight of them, so we need:

64×648=512 threads.\frac{64 \times 64}{8} = 512\ \text{threads}.

That’s why we set BM = BN = 64, BK = 8, and TM = 8. We launch 512 threads per threadgroup and ⌈N/64⌉×⌈M/64⌉\lceil N/64\rceil \times \lceil M/64\rceil threadgroups. As in kernels 2 and 3, I dropped the bounds checks here, try to do it yourself, have fun!

C++matmul_1D_coarsening
template <uint BM, uint BN, uint BK, uint TM>
kernel void matmul_1D_coarsening(device const float* A [[buffer(0)]],
device const float* B [[buffer(1)]],
device float* C [[buffer(2)]],
uint M, uint N, uint K,
uint2 block_pos [[ threadgroup_position_in_grid ]],
uint2 thread_pos [[ thread_position_in_threadgroup ]])
{
uint bRow = block_pos.y;
uint bCol = block_pos.x;
uint tid = thread_pos.x; // 1D threadgroup: tid = 0, ..., BM * BN / TM - 1
// this thread owns rows tRow * TM, ..., tRow * TM + TM - 1 of column tCol
uint tRow = tid / BN;
uint tCol = tid % BN;
// and copies one element of each tile
uint tileRowA = tid / BK, tileColA = tid % BK;
uint tileRowB = tid / BN, tileColB = tid % BN;
// 1. allocate threadgroup memory and move the pointers
threadgroup float A_shmem[BM * BK];
threadgroup float B_shmem[BK * BN];
A += bRow * BM * K;
B += bCol * BN;
C += bRow * BM * N + bCol * BN;
float sum[TM] = {0.f}; // TM results, kept in registers
for (uint ph = 0; ph < K; ph += BK) {
// 2. collaboratively load one tile of A and one tile of B
A_shmem[tileRowA * BK + tileColA] = A[tileRowA * K + tileColA];
B_shmem[tileRowB * BN + tileColB] = B[tileRowB * N + tileColB];
threadgroup_barrier(mem_flags::mem_threadgroup);
// 3. compute TM results from the tiles
for (uint k = 0; k < BK; ++k) {
float Btmp = B_shmem[k * BN + tCol]; // one load, reused TM times
for (uint i = 0; i < TM; ++i) {
sum[i] += A_shmem[(tRow * TM + i) * BK + k] * Btmp;
}
}
threadgroup_barrier(mem_flags::mem_threadgroup);
// 4. slide both tiles along K
A += BK;
B += BK * N;
}
// 5. write the TM results
for (uint i = 0; i < TM; ++i) {
C[(tRow * TM + i) * N + tCol] = sum[i];
}
}

Let’s keep following C35,93C_{35,93}. It lies in threadgroup (1,0)(1, 0), at row 35 and column 29 of that group’s output tile. Row 35 falls in the band covering rows 32 through 39, so tRow = 4 and tCol = 29. Its owner is thread 4×64+29=2854 \times 64 + 29 = 285. That thread computes C32,93C_{32,93} through C39,93C_{39,93}; our C35,93C_{35,93} is its fourth output, accumulated in sum[3].

One phase of the 1D-coarsened kernel for threadgroup (1, 0). A_shmem is a 64 by 8 tile and B_shmem an 8 by 64 tile; their product updates a 64 by 64 tile of C. The C tile is split into 8 bands of 8 rows, one band per thread row tRow = 0 to 7, and 64 columns, one per tCol. Thread 285 has tRow = 4 and tCol = 29, so it owns the 8 by 1 strip at rows 32 to 39, column 29 of the tile: C[32 … 39, 93]. It reads rows 32 to 39 of A_shmem and column 29 of B_shmem. 512 threads, 8 outputs each.

One phase of the 1D kernel for threadgroup (1,0)(1, 0). Thread 285 owns an 8×18\times1 strip of the CC tile, C32,93C_{32,93} to C39,93C_{39,93}, and reads the matching 8 rows of A_shmem and one column of B_shmem. Not to scale: the KK dimension of the tiles is drawn wider than it is. Layout adapted from [3] and [1].

Before thread 285 can touch any of those values, the 512 threads have to fill the two tiles together, just like in kernels 2 and 3. Each thread copies one element of each tile per phase, so you can read the tile shapes straight from the thread count:

Section 2 of the 1D kernel for one phase. The A tile is 64 rows by 8 columns: thread tid copies row tid / 8, column tid % 8, so row 0 comes from threads 0 to 7, row 35 from threads 280 to 287, and row 63 from threads 504 to 511. The B tile is 8 rows by 64 columns: thread tid copies row tid / 64, column tid % 64, so row 0 comes from threads 0 to 63 and row 4 from threads 256 to 319. SIMD group 8, threads 256 to 287, copies rows 32 to 35 of the A tile and the first half of row 4 of the B tile. Thread 285 copies A_shmem[35, 5] and B_shmem[4, 29]. 512 threads with one element of each tile means the 64-row A tile has 512 / 64 = 8 columns, so BK = 8.

Section 2 of the kernel, for one phase: each of the 512 threads copies one element of each tile. Since the AA tile has 64 rows, one element per thread gives it 512/64=8512 / 64 = 8 columns, and that is where BK = 8 comes from. Blue: SIMD group 8, which holds our thread 285; red: the two elements thread 285 copies. Not to scale. Layout adapted from [3]‘s populate-shared-memory step.

Once the barrier says both tiles are full, thread 285 only looks at its own eight rows of A_shmem and its own column of B_shmem. Now we can count what one phase costs it:

Important (Counting the loads)

At each step kk, our thread reads eight values of AA and one of BB from threadgroup memory, then does eight multiply-adds. That is nine loads for eight multiply-adds, instead of the 16 loads eight threads would request in the tiled kernel. Across the eight steps of one phase, the thread requests 72 loads and does 64 multiply-adds. The ratio is still 9/89/8 loads per multiply-add.

Counting the bytes requested by these reads gives a threadgroup-memory intensity of:

Ithreadgroup=2×8 FLOP9×4 bytes=49≈0.44 FLOP/byte,I_\text{threadgroup} = \frac{2 \times 8\ \text{FLOP}}{9 \times 4\ \text{bytes}} = \frac{4}{9} \approx 0.44\ \text{FLOP/byte},

up from the tiled kernel’s 2/8=0.252 / 8 = 0.25. This count tells us how much arithmetic we get for the bytes requested by the reads in our code.

The larger output tile also reduces buffer loads. In one phase, the threadgroup copies 64×8+8×64=102464 \times 8 + 8 \times 64 = 1024 floats (4 KiB) and does 64×64×8=32,76864 \times 64 \times 8 = 32{,}768 multiply-adds. At device memory, assuming every requested byte reaches it, the intensity is:

Idevice=2×32,768 FLOP4,096 bytes=16 FLOP/byte,I_\text{device} = \frac{2 \times 32{,}768\ \text{FLOP}}{4{,}096\ \text{bytes}} = 16\ \text{FLOP/byte},

twice the 32×3232\times32 tiled kernel’s no-cache estimate of 8. Across the grid, the kernel requests 137.4 GFLOP/16≈8.6137.4\ \text{GFLOP} / 16 \approx 8.6 GB from the device buffers. Caches can reduce that traffic further, as they did for kernels 2 and 3.

Keeping BB in a register gives us fewer loads to issue and fewer addresses to calculate for the same arithmetic. That is the overhead the tiled kernels’ profiles pointed us toward. We now need eight accumulators per thread, though, and the smaller BK means more phases and barriers. The load count gives us a reason to try this kernel; the benchmark will tell us whether the changes pay off.

Here is that reuse at work in two steps of thread 285’s inner loop. At each step, one load of B goes into a register and feeds all eight multiply-adds.

Section 3 of the 1D kernel for thread 285, at k = 0 and k = 1. At each step, the thread loads one element of B_shmem, row k and column 29, into the register Btmp, then reads the 8 elements of column k of A_shmem in rows 32 to 39. Each of them times Btmp is added to one of the 8 accumulators sum[0] to sum[7], which hold C[32, 93] to C[39, 93]. Per step: 1 load of B plus 8 loads of A, 9 loads for 8 multiply-adds. In the tiled kernel, 2 loads for 1 multiply-add.

Section 3 of the kernel for thread 285 at k=0k = 0 and k=1k = 1: one load of B_shmem into Btmp, eight loads down column kk of A_shmem, eight multiply-adds into sum. sum[3] accumulates our old C35,93C_{35,93}. After the 8 steps of a phase, each sum[i] has gained 8 terms. Layout adapted from [3]‘s outer-product steps.

Results

KernelGFLOP/svs tiling 32x32
Tiling 32x326661.00x
1D coarsening12221.83x

The 1D kernel reaches 1222 GFLOP/s, 1.83x the 32×3232\times32 tiled kernel’s rate. Giving each thread eight outputs has paid off: we now reach 30% of the FP32 ceiling and 36% of MPS. Let’s check what changed inside the GPU:

Countertiled321D
GFLOP/s6831230
Instruction Throughput Limiter (%)80.5977.96
F32 Utilization (%)16.5430.20
Integer and Conditional Utilization (%)62.4335.40
Threadgroup Memory L1 Read Bandwidth (GiB/s)1312.25435.02
GPU Read Bandwidth (GB/s)39.9245.67
Register spillsnonenone

The FP32 units do about 30% of their peak work, up from 16.5%, matching the increase in throughput. At the same time, integer and conditional utilization falls from 62% to 35%, and threadgroup-memory read bandwidth falls from 1312 to 435 GiB/s. The kernel is doing the same arithmetic faster while putting less demand on those parts of the GPU. That supports the reason why we tried coarsening: less work around each multiply-addThe nine loads per step are a count of read requests in the source code. The L1 bandwidth counter measures hardware traffic, which need not equal four bytes for every such request. Also, this comparison includes the larger output tile, the smaller BK, and the new thread mapping; it does not isolate the effect of reusing BB in a register..

The external read bandwidth actually rises, from about 40 to 46 GB/s. Did we make device-memory traffic worse? Remember that bandwidth is a rate. The profiled kernel finishes in about 112 ms instead of 201 ms, so its total external reads fall from 8.0 GB to:

Q≈45.67 GB/s×0.11175 s≈5.1 GB.Q \approx 45.67\ \text{GB/s} \times 0.11175\ \text{s} \approx 5.1\ \text{GB}.

As in kernels 2 and 3, we use those external reads to estimate device-memory traffic. The intensity is then 137.4/5.1≈27137.4 / 5.1 \approx 27 FLOP/byte, above our no-cache estimate of 16. Its bandwidth roof is about 27×118≈3.227 \times 118 \approx 3.2 TFLOP/s, while the profiler measures 1.23 TFLOP/s. We have raised the intensity and the performance, but there is still a large gap below the roof.

Log-log roofline of the M5 GPU with the naive, both tiled, and the 1D-coarsened kernels at their arithmetic intensity measured at device memory. Naive: 3.1 FLOP per byte, 376 GFLOP/s, on the slanted roof. Tiled 16×16: 8.3 FLOP per byte, 667 GFLOP/s; tiled 32×32: 17 FLOP per byte, 683 GFLOP/s, both on a grey dashed line where the tiled kernels stopped. 1D coarsening: 27 FLOP per byte, 1230 GFLOP/s, 1.8 times higher, just left of the ridge at 34 FLOP per byte; its roof is 2.6 times higher than its performance.

The 1D kernel on the roofline, with the earlier kernels for comparison. As before, intensity is 137.4 GFLOP divided by the bytes the profiler saw leave the GPU, and GFLOP/s are from the same profiler runs. This time the kernel moves up as well as right, but its roof at 27 FLOP/byte is still 2.6× higher than where it runs.

Instruction Throughput Limiter remains high at 78%. It includes both work and stalls, so it does not identify a precise issue-rate limit. The counter still points us toward instruction work and stalls, and the inner loop still has an input it doesn’t reuse within the thread: every multiply-add reads a different value of AA.

Our count 1+1/TM1 + 1/T_M shows the limit of this particular reuse. Increasing TMT_M spreads the load of BB more thinly, but the one load of AA per multiply-add remains. A thread could reuse both inputs by computing a rectangle of outputs instead and that would be 2D coarsening but we will stop coarsening hereLet’s have some fun to try 2D coarsening yourself, 2D coarsening is really interesting! Remember to read [1] for more details.

Our next kernels will move to modern Metal features: simdgroup_matrix, it let the 32 threads of a SIMD group hold 8×88\times8 tiles together and multiply them with one call.

Next

Kernel 4 reaches 1222 GFLOP/s, 36% of MPS, and every multiply-add is still a separate scalar instruction. Part 2 will continue with kernels 5 to 9: SIMD-group matrices, sharing inputs through threadgroup memory, and TensorOps, ending ahead of MPS.

References

  1. [1]
    How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog[HTML]
    Simon Boehm, 2022. Blog post.
  2. [2]
    Outperforming cuBLAS on H100: a Worklog[HTML]
    Pranjal Shankhdhar, 2024. CUDA for Fun.
  3. [3]
    Worklog: optimising GEMM on NVIDIA H100 for cuBLAS-like performance[HTML]
    Hamza Elshafie, 2025. Blog post.
  4. [4]
    Fast Matrix Multiply on an Apple GPU[HTML]
    Zeke Medley, 2025. Percisely.
  5. [5]
    Apple unleashes M5, the next big leap in AI performance for Apple silicon[HTML]
    Apple Inc., 2025. Apple Newsroom.
  6. [6]
    metal-benchmarks: Apple GPU microarchitecture[HTML]
    Philip Turner, 2022. GitHub repository.
  7. [7]
    Roofline: An Insightful Visual Performance Model for Multicore Architectures[DOI]
    Samuel Williams, Andrew Waterman, and David Patterson, 2009. Communications of the ACM, vol. 52, pp. 65--76
  8. [8]
    How to Scale Your Model[HTML]
    Jacob Austin, Sholto Douglas, Roy Frostig, et al., 2025. Google DeepMind.
  9. [9]
    Explore GPU advancements in M3 and A17 Pro[HTML]
    Apple Inc., 2023. Apple Developer Tech Talks.
  10. [10]
    Programming Massively Parallel Processors: A Hands-on Approach
    David B. Kirk and Wen-mei W. Hwu, 2022. Morgan Kaufmann.