Nguyen Le

Optimizing GEMM on Apple's M5 from scratch, Part 2: Outperforming MPS's GEMM (in big 2026)

A worklog on optimizing GEMM kernel on Apple's M5 from scratch. Part 2: SIMD-group matrices, threadgroup sharing, and TensorOps, ending ahead of MPS.

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

Recap of Part 1

This is the second part of my worklog on writing an FP32 GEMM kernel for Apple’s M5 GPU. Part 1 has introduced us to the hardware, the roofline model, and the GPU-counter method, and built kernels 1 to 4. We still use the same setup: row-major, non-transposed FP32 matrices with M=N=K=4096M=N=K=4096, on a MacBook Pro with an M5 (10-core GPU).

Some numbers from Part 1 that we need to remember:

  • The FP32 compute ceiling is 4.04 TFLOP/s, the measured memory bandwidth is 118 GB/s, and the ridge point is about 34 FLOP/byte.
  • Measured intensity uses the bytes the profiler sees leave the GPU: GPU Read Bandwidth × GPU time (method).
  • MPS reaches 3365 GFLOP/s and MLX 3332 GFLOP/s on this problem.
  • Kernel 4, 1D thread coarsening, reaches 1222 GFLOP/s, 36% of MPS. Its Instruction Throughput Limiter stays at 78%, and every multiply-add is still a separate scalar instruction.

Kernel 5: SIMD-Group Matrix Multiplication

Thread coarsening let one thread reuse an input across several outputs but we still compute those outputs with “raw” scalar multiply-adds though. To increase arithmetic, Metal also gives us a matrix multiply-accumulate primitve that the threads of a SIMD group execute together.

This time, each SIMD group will compute an 8×88 \times 8 tile of CCSo we can think each thread will compute 44 elements but it’s not true, we still don’t know how Apple did the dark magic with simdgroup primitives.. At each step along KK, the group loads an 8×88\times8 tile from AA and another from BB, multiplies them, and adds the product to its output tile. We still walk along KK; each step now adds a small matrix product.

One SIMD Group per Output Tile

Our earlier kernels already ran in SIMD groups. Each thread held its own accumulator and computed its own outputs. With simdgroup_float8x8, the 32 threads hold one logical matrix together. The loads, multiply-accumulates, and stores all operate on that matrix cooperatively [1].

Let’s reuse our example on C[35,93]C[35, 93] to see how what “cooperatively” means. We first divide the output into 8×88\times8 tiles, so it belongs to the tile starting at row 32 and column 88. That tile covers rows 32 through 39 and columns 88 through 95. Its SIMD group holds an accumulator for all 64 outputs, and C[35,93]C[35,93] is entry (3,5)(3,5) within it.

At k = 0, the group loads rows 32 through 39 and columns 0 through 7 of AA, together with rows 0 through 7 and columns 88 through 95 of BB. Their matrix product adds eight terms to each output. For our chosen element, that update is:

acc[3,5]+=∑t=07A[35,t] B[t,93].\text{acc}[3,5] \mathrel{+}= \sum_{t=0}^{7} A[35,t]\,B[t,93].

At k = 8, it adds the next eight terms, and so on. After 4096/8=5124096/8 = 512 steps, the accumulator contains the full output tile.

Which thread holds entry (3,5)(3,5)? Metal leaves the mapping from matrix elements to threads unspecified. We can identify the entry in the logical matrix, but the API does not give us a portable lane number for it. We work with the tile through the collective operations insteadThe Metal Shading Language specification describes the matrix types in section 2.4 and their operations in section 6.8 [1]. All threads in the SIMD group must execute these operations under uniform control flow: we cannot put a matrix load or multiply inside a branch that only some of the threads take..

One SIMD group of the simdgroup_8x8 kernel. C is divided into 8 by 8 tiles; threadgroup (11, 4) owns the tile at rows 32 to 39 and columns 88 to 95. It walks rows 32 to 39 of A and columns 88 to 95 of B in 512 steps of 8 along K: at each step it loads ta = A[32 … 39, k … k+7] and tb = B[k … k+7, 88 … 95] and computes acc = ta · tb + acc. In the zoom, row 3 of ta and column 5 of tb meet at acc[3, 5], which is C[35, 93]. The 64 entries of acc are spread over the 32 lanes in a layout Metal does not specify. One call from all 32 lanes does 8 × 8 × 8 = 512 multiply-adds per step.

The simdgroup_8x8 kernel for threadgroup (11,4)(11, 4). Top: the SIMD group’s rows of AA and columns of BB (blue), and the two 8×88\times8 tiles it loads at one step (red). Bottom: that step inside the group’s registers, where row 3 of ta and column 5 of tb update acc[3, 5], our C[35,93]C[35,93]. Not to scale. Layout adapted from [2] and [3].

Implementation

We only use this kernel when M,NM, N and KK are multiples of 88.

C++kernels/simdgroup.metal — simdgroup_8x8
#include <metal_stdlib>
#include <metal_simdgroup_matrix>
using namespace metal;
kernel void simdgroup_8x8(device const float* a [[buffer(0)]],
device const float* b [[buffer(1)]],
device float* c [[buffer(2)]],
uint M, uint N, uint K,
uint2 group [[threadgroup_position_in_grid]]) {
const uint row = group.y * 8;
const uint col = group.x * 8;
simdgroup_float8x8 acc = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
for (uint k = 0; k < K; k += 8) {
simdgroup_float8x8 ta, tb, next;
simdgroup_load(ta, a + row * K + k, K);
simdgroup_load(tb, b + k * N + col, N);
simdgroup_multiply_accumulate(next, ta, tb, acc);
acc = next;
}
simdgroup_store(acc, c + row * N + col, N);
}
The 8 by 8 tile ta inside the row-major matrix A, at rows 32 to 39 and columns k to k+7. In memory, A is stored one row after another, so the tile is 8 runs of 8 neighbouring floats, 32 bytes each, with 4096 floats between the start of one run and the next. simdgroup_load(ta, a + row * K + k, K) takes a pointer to the tile's first float and the row stride in floats; simdgroup_load(tb, b + k * N + col, N) does the same for B with stride N.

What one simdgroup_load reads. The pointer marks the tile’s first float; the stride says how far apart its rows are in memory, KK floats in AA and NN floats in BB.

The main primitive is: simdgroup_multiply_accumulate(next, ta, tb, acc). It computes the matrix update next=ta∗tb+acc\text{next} = \text{ta} * \text{tb} + \text{acc}. We keep that result for the next iteration, then store the completed tile into CC, whose row stride is also N.

There are no need for threadgroup memory here. The group loads directly from the device buffers, keeps the matrices in registers, and stores its result. We also need no threadgroup barrier: there is no shared-memory data being passed between SIMD groups.

Counting the Loads

How much reuse does this small tile give us? Each step loads 2×82=1282\times8^2 = 128 floats, or 512 requested bytes. It performs 83=5128^3 = 512 multiply-adds, or 1024 FLOPs. Ignoring the final output store, which happens once after all 512 steps, our no-cache estimate of arithmetic intensity is:

Ino-cache=2×832×82×4=2 FLOP/byte.I_{\text{no-cache}} = \frac{2\times8^3}{2\times8^2\times4} = 2\ \text{FLOP/byte}.

Across the grid, the kernel requests about 137.4 GFLOP/2=68.7137.4\ \text{GFLOP}/2 = 68.7 GB from the device buffers. That is eight times the 1D kernel’s 8.6 GB. We changed the way we express the arithmetic, but also shrank the output tile from 64×6464\times64 to 8×88\times8, giving up much of the reuse between outputs.

One threadgroup's work for 8 steps of k, drawn at the same scale. Left, 1D coarsening: a 64 by 64 tile of C, with 64 by 8 of A and 8 by 64 of B; it loads 1024 floats and does 32,768 multiply-adds, 16 FLOP per byte, and each loaded value feeds 64 outputs. Right, SIMD-group 8 by 8: an 8 by 8 tile of C, with 8 by 8 of A and 8 by 8 of B; it loads 128 floats and does 512 multiply-adds, 2 FLOP per byte, and each loaded value feeds 8 outputs.

One threadgroup’s work over the same 8 steps of kk, at the same scale. The bigger output tile uses every loaded value for 64 outputs; the 8×88\times8 tile, for 8. That factor of 8 is the drop from 16 to 2 FLOP/byte.

As before, these are requested bytes assuming every load reaches device memory. Neighbouring groups load overlapping inputs, so caches can reduce the actual traffic. The estimate tells us how much reuse the kernel explicitly arranges; it does not place the measured kernel on the roofline.

Results

For the same M=N=K=4096M=N=K=4096 benchmark:

KernelGFLOP/sRelative to 1D coarsening
1D coarsening12221.00x
SIMD-group 8x810960.90x

Our first SIMD-group matrix kernel reaches about 10% less throughput than 1D coarsening. We now have collective matrix arithmetic, but much less reuse in the kernel.

Can we blame device memory? With the no-cache estimate, its bandwidth roof would be only 2×118=2362\times118 = 236 GFLOP/s. The kernel runs well above that, so the 68.7 GB count clearly overstates the traffic reaching device memory. We need this kernel’s GPU counters to measure that traffic and identify what limits it; the timing alone does not settle either question.

So let’s measure it:

Counter1Dsg8x8
GFLOP/s12301133
Instruction Throughput Limiter (%)77.9655.58
Integer and Conditional Utilization (%)35.4012.20
F32 Utilization (%)30.2027.52
GPU Read Bandwidth (GB/s)45.67119.09
Last Level Cache Limiter (%)10.9183.48
Register spillsnonenone

The integer pipeline falls from 35% to 12% busy: the matrix calls did remove most of the bookkeeping around each multiply-add. Now look at the memory rows. The kernel reads 119 GB/s from outside the GPU, our device-memory ceiling, and Last Level Cache Limiter is at 83%. Over its 121 ms, that is 14.5 GB, about a fifth of the 68.7 GB it requests, so the caches still help. At 137.4/14.5≈9.5137.4 / 14.5 \approx 9.5 FLOP/byte, the roof is 9.5×118≈1.129.5 \times 118 \approx 1.12 TFLOP/s, and the kernel runs right at it.

Log-log roofline of the M5 GPU with all kernels so far at their arithmetic intensity measured at device memory. Naive 3.1 FLOP per byte at 376 GFLOP/s, tiled 16×16 8.3 at 667, tiled 32×32 17 at 683, 1D coarsening 27 at 1230, and SIMD-group 8×8 9.5 at 1133. The SIMD-group 8×8 kernel sits on the slanted 118 GB/s roof, back from where 1D coarsening was.

The SIMD-group 8×88\times8 kernel on the roofline. Intensity is counted from the bytes the profiler saw leave the GPU, and GFLOP/s are from the same profiler runs. It moves left of the 1D kernel and lands on the slanted roof: device memory sets its pace.

The count does suggest a next step we can test. In the 1D kernel, one thread reused an input by holding several outputs. Here, a SIMD group can do the same with whole tiles: hold several 8×88\times8 accumulators and reuse a loaded input tile across their matrix products. Let’s give each group a 16×1616\times16 output tile, made of four 8×88\times8 matrices, and see whether that reuse helps.

Kernel 6: Larger Output Tiles

Each SIMD group now holds four 8×88\times8 accumulators, covering a 16×1616\times16 output tile. At each step along KK, it loads two tiles of AA (a0, a1) and two of BB (b0, b1). Each input tile feeds two matrix products: a0 updates both top accumulators, a1 both bottom ones, and the two BB tiles do the same along the columns.

Four SIMD groups cover a 32×3232\times32 threadgroup tile, which gives the kernel its name, simdgroup_32x32. sg selects one of its four 16×1616\times16 quadrants. The harness launches 128 threads per threadgroup and requires M,NM,N to be multiples of 32 and KK a multiple of 8.

Left: the 32 by 32 output tile of threadgroup (2, 1), split into four 16 by 16 quadrants, one per SIMD group 0 to 3, each split into four 8 by 8 accumulators c00, c01, c10, c11. Our C[35, 93] is entry (3, 5) of c01 in SIMD group 1, whose quadrant starts at row 32 and column 80. Right: one step of SIMD group 1. It loads a0 and a1 from rows 32 to 47 of A and b0 and b1 from columns 80 to 95 of B, and computes c00 += a0 · b0, c01 += a0 · b1, c10 += a1 · b0, c11 += a1 · b1. Row 3 of a0 and column 5 of b1 meet at entry (3, 5) of c01. Four tile loads feed four matrix products, where the 8 by 8 kernel needed two loads for one.

The simdgroup_32x32 kernel for threadgroup (2,1)(2, 1). Left: four SIMD groups split the 32×3232\times32 tile, and each holds four 8×88\times8 accumulators; C[35,93]C[35,93] is entry (3,5)(3,5) of SIMD group 1’s c01. Right: one step of SIMD group 1, where each loaded tile feeds two matrix products.

C++kernels/simdgroup.metal — simdgroup_32x32
kernel void simdgroup_32x32(device const float* a [[buffer(0)]],
device const float* b [[buffer(1)]],
device float* c [[buffer(2)]],
constant GemmShape& p [[buffer(3)]],
uint2 group [[threadgroup_position_in_grid]],
uint sg [[simdgroup_index_in_threadgroup]]) {
const uint row = group.y * 32 + (sg / 2) * 16;
const uint col = group.x * 32 + (sg % 2) * 16;
simdgroup_float8x8 c00 = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
simdgroup_float8x8 c01 = c00, c10 = c00, c11 = c00;
for (uint k = 0; k < p.K; k += 8) {
simdgroup_float8x8 a0, a1, b0, b1, next;
simdgroup_load(a0, a + row * p.K + k, p.K);
simdgroup_load(a1, a + (row + 8) * p.K + k, p.K);
simdgroup_load(b0, b + k * p.N + col, p.N);
simdgroup_load(b1, b + k * p.N + col + 8, p.N);
simdgroup_multiply_accumulate(next, a0, b0, c00);
c00 = next;
simdgroup_multiply_accumulate(next, a0, b1, c01);
c01 = next;
simdgroup_multiply_accumulate(next, a1, b0, c10);
c10 = next;
simdgroup_multiply_accumulate(next, a1, b1, c11);
c11 = next;
}
simdgroup_store(c00, c + row * p.N + col, p.N);
simdgroup_store(c01, c + row * p.N + col + 8, p.N);
simdgroup_store(c10, c + (row + 8) * p.N + col, p.N);
simdgroup_store(c11, c + (row + 8) * p.N + col + 8, p.N);
}

We now do four matrix multiply-accumulates for four input loads; the 8×88\times8 kernel did one for two loads. Per SIMD group, that is 4096 FLOPs for 1024 requested bytes, so the no-cache intensity doubles to 4 FLOP/byte. Across the grid, the requested reads fall from 68.7 to 34.4 GB, again ignoring the final output stores.

The benchmark reaches 1687 GFLOP/s, 1.54×1.54\times the 8×88\times8 kernel and 1.38×1.38\times 1D coarsening. In the separate profiler runs:

Countersg8x8sg32x32
GFLOP/s11332017
GPU Read Bandwidth (GB/s)119.09117.75
Register spillsnonenone

The read rate still reaches our device-memory ceiling, but external reads fall to about 8.4 GB per matrix product, down from 14.5 GBThe counters cover three timed dispatches over 214 ms, so the average is 117.75 GB/s×0.214 s/3≈8.4117.75\ \text{GB/s}\times0.214\ \text{s}/3 \approx 8.4 GB per product. The throughput above uses the median GPU time.. Its measured intensity rises to about 137.4/8.4≈16137.4/8.4 \approx 16 FLOP/byte, putting its bandwidth roof near 1.9 TFLOP/s, close to the profiled 2 TFLOP/s. Reuse has reduced the traffic, and device memory still sets the pace.

There is more reuse available within the threadgroup. The two SIMD groups side by side load the same AA tiles; the two above and below load the same BB tiles. Caches can answer some of those repeated requests. Our next kernel will arrange that sharing explicitly: load each input tile once into threadgroup memory, then let the SIMD groups read it from there.

Left, simdgroup_32x32: in device memory, A rows 32 to 47 are loaded by SIMD groups 0 and 1, and A rows 48 to 63 by SIMD groups 2 and 3, so per 32 steps of k, 2 × 1024 floats of A are requested from device memory, each one twice. Right, simdgroup_32x32_shared: A rows 32 to 63 are copied once, by all 128 threads, into As in threadgroup memory, and all four SIMD groups read their fragments from As, so per 32 steps of k, 1024 floats of A are requested once. B works the same way by columns: SIMD groups 0 and 2 share columns 64 to 79.

Where the SIMD groups get their AA fragments. Left: each group asks device memory for its own, so every fragment is requested twice. Right: the threadgroup copies the block once into threadgroup memory, and the groups share it. The counts are requests; caches may already merge some of the duplicates on the left.

Kernel 7: Sharing Inputs via Threadgroup Memory

We keep the same output layout: four SIMD groups, each holding four accumulators. Now the 128 threads first cooperate to load a 32×3232\times32 tile of AA and another of BB into threadgroup memory. The SIMD groups take their 8×88\times8 fragments from those shared tiles and reuse them in registers as before.

There are now two loops along KK. The outer loop stages 32 columns of AA and 32 rows of BB, starting at kb. The inner loop consumes that data in four steps, at local k = 0, 8, 16, 24. The accumulators stay alive across both loops.

C++kernels/simdgroup.metal — simdgroup_32x32_shared
kernel void simdgroup_32x32_shared(device const float* a [[buffer(0)]],
device const float* b [[buffer(1)]],
device float* c [[buffer(2)]],
constant GemmShape& p [[buffer(3)]],
uint2 group [[threadgroup_position_in_grid]],
uint sg [[simdgroup_index_in_threadgroup]],
uint tid [[thread_index_in_threadgroup]]) {
threadgroup float As[32 * 33];
threadgroup float Bs[32 * 33];
const uint block_row = group.y * 32;
const uint block_col = group.x * 32;
const uint subrow = (sg / 2) * 16;
const uint subcol = (sg % 2) * 16;
simdgroup_float8x8 c00 = make_filled_simdgroup_matrix<float, 8, 8>(0.0f);
simdgroup_float8x8 c01 = c00, c10 = c00, c11 = c00;
for (uint kb = 0; kb < p.K; kb += 32) {
for (uint i = tid; i < 32 * 32; i += 128) {
const uint r = i / 32;
const uint col = i % 32;
As[r * 33 + col] = a[(block_row + r) * p.K + kb + col];
Bs[r * 33 + col] = b[(kb + r) * p.N + block_col + col];
}
threadgroup_barrier(mem_flags::mem_threadgroup);
for (uint k = 0; k < 32; k += 8) {
simdgroup_float8x8 a0, a1, b0, b1, next;
simdgroup_load(a0, As + subrow * 33 + k, 33);
simdgroup_load(a1, As + (subrow + 8) * 33 + k, 33);
simdgroup_load(b0, Bs + k * 33 + subcol, 33);
simdgroup_load(b1, Bs + k * 33 + subcol + 8, 33);
simdgroup_multiply_accumulate(next, a0, b0, c00);
c00 = next;
simdgroup_multiply_accumulate(next, a0, b1, c01);
c01 = next;
simdgroup_multiply_accumulate(next, a1, b0, c10);
c10 = next;
simdgroup_multiply_accumulate(next, a1, b1, c11);
c11 = next;
}
threadgroup_barrier(mem_flags::mem_threadgroup);
}
const uint row = block_row + subrow;
const uint col = block_col + subcol;
simdgroup_store(c00, c + row * p.N + col, p.N);
simdgroup_store(c01, c + row * p.N + col + 8, p.N);
simdgroup_store(c10, c + (row + 8) * p.N + col, p.N);
simdgroup_store(c11, c + (row + 8) * p.N + col + 8, p.N);
}
One outer iteration of simdgroup_32x32_shared. 1, copy: 128 threads fill As and Bs, each 32 rows by 33 floats with one padding float per row. Thread tid copies i = tid, tid + 128, and so on, into row i / 32 and column i % 32, so SIMD group s fills rows s, s + 4, up to s + 28; SIMD group 0 fills rows 0, 4, …, 28. A threadgroup barrier waits until As and Bs are full. 2, compute: for k = 0, 8, 16, 24, every SIMD group loads its fragments from As and Bs with stride 33; at k = 8, SIMD group 1 reads a0 and a1 from columns 8 to 15 of As and b0 and b1 from rows 8 to 15, columns 16 to 31, of Bs, then updates c00 to c11. A second barrier waits until everyone is done reading before the next kb.

One outer iteration of the shared kernel: the cooperative copy (blue: the rows SIMD group 0 fills; grey: the padding column), the first barrier, one inner step for SIMD group 1, and the second barrier.

tid runs from 0 to 127. The loading loop assigns each thread eight positions in each input tile, spaced 128 elements apart. Together they cover all 1024 positions exactly once. Within a SIMD group, neighbouring threads read 32 consecutive floats from each device buffer, so the cooperative copy keeps those reads contiguous.

Why 32 * 33? Each shared row has 32 values followed by one unused slot. Its stride is therefore 33 floats, or 132 bytes, and the simdgroup_load calls must use that stride. The two arrays occupy 8.25 KiB togetherThe padding shifts successive rows’ alignment by one float compared with a stride of 32. It is intended to change bank alignment, as the kernel’s source comment says. We have not benchmarked an unpadded version on the M5, so these results do not establish whether the padding improves performance..

The first barrier waits for all 128 threads to finish the copy before any SIMD group reads the shared tiles. The second waits for all groups to finish reading before the next outer iteration overwrites them. A group that finishes early must wait for the others; its matrix operations only coordinate the threads within that SIMD groupAs in kernels 2 and 3, mem_flags::mem_threadgroup orders the threadgroup-memory accesses across the barrier. All threads reach both barriers on every outer iteration [1]. This kernel requires M,N,KM,N,K to be multiples of 32, so every staged tile is complete..

Per outer iteration, the threadgroup loads 2×322×4=81922\times32^2\times4 = 8192 bytes from the device buffers and performs 2×323=65,5362\times32^3 = 65{,}536 FLOPs. The no-cache intensity is now 8 FLOP/byte, and requested device reads fall from 34.4 to 17.2 GB. The SIMD groups still load their fragments separately, but those repeated loads now come from threadgroup memory.

The benchmark reaches 2722 GFLOP/s, 1.61×1.61\times the previous kernel and 81% of MPS. The separate profiler runs show:

Countersg32x32shared
GFLOP/s20172821
F32 Utilization (%)46.7667.39
Instruction Throughput Limiter (%)48.5482.02
Threadgroup Memory L1 Read Bandwidth (GiB/s)0.00951.04
GPU Read Bandwidth (GB/s)117.75113.40
Register spillsnonenone

The FP32 units are busier, and the new threadgroup-memory reads are visible. External reads fall again, from 8.4 to about 5.6 GB per matrix productThe shared kernel’s counters cover three timed dispatches over 148.3 ms: 113.40 GB/s×0.1483 s/3≈5.6113.40\ \text{GB/s}\times0.1483\ \text{s}/3 \approx 5.6 GB per product.. That gives an intensity of about 137.4/5.6≈25137.4/5.6 \approx 25 FLOP/byte and a bandwidth roof of 2.9 TFLOP/s, close to the profiled 2.8 TFLOP/s.

Log-log roofline with the 1D-coarsened kernel and the three SIMD-group kernels at their arithmetic intensity measured at device memory: 1D coarsening at 27 FLOP per byte and 1230 GFLOP/s, below the roof; 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, all three on the slanted 118 GB/s roof and climbing it as reuse grows.

The SIMD-group kernels on the roofline, with the 1D kernel for reference. Each step of reuse moves the kernel up the memory roof: 9.5, 16, and 25 FLOP/byte, with throughput rising in step. Intensities are from the profiler’s external reads, as in the text.

Instruction Throughput Limiter rises to 82%, but it includes stalls as well as work. With external bandwidth still near its ceiling, that number alone does not establish an instruction-issue bottleneck. We have reduced the traffic enough to run much faster while adding the copies and barriers needed to share inputs explicitly.

Kernel 8: TensorOps

Kernel 7 still needs manual work for every 8×88\times8 tile:the input copies, two barriers per step and dot product. A larger output tile would need more accumulators and more calls. But fortunately, with new Metal 4 from Apple (introduced with M5 in WWDC25 [4]), we can let Metal Performance Primitives (MPP) to do “dark magic” with the whole threadgroup tile instead.

MPP’s matmul2d TensorOp is new API for our kernel. We choose the output tile and the SIMD groups that work on it, then MPP will supply the loads and the matrix arithmetic [1]. Kernel 8 passes views of the device buffers directly to it, without threadgroup-memory staging. Apple’s programming guide recommends this on Apple silicon, where the caches can provide the reuse [5].

Tensors and Slices

The buffers hold the same row-major FP32 matrices. A tensor adds their dimensions and strides. Metal lists dimensions from the innermost outward, so AA has extents (K,M)(K,M), BB has (N,K)(N,K), and CC has (N,M)(N,M). This describes the existing layout; nothing is transposed or copied.

Here is the full-tile path of matmul_tensorops, with TM = TN = 64 and SG = 4. The file includes <metal_tensor> and <MetalPerformancePrimitives/MetalPerformancePrimitives.h> and uses the mpp::tensor_ops namespace:

C++kernels/tensorops.metal — full-tile path
auto A = tensor<device float, dextents<int, 2>, tensor_inline>(
a, dextents<int, 2>((int)p.K, (int)p.M));
auto B = tensor<device float, dextents<int, 2>, tensor_inline>(
b, dextents<int, 2>((int)p.N, (int)p.K));
auto C = tensor<device float, dextents<int, 2>, tensor_inline>(
c, dextents<int, 2>((int)p.N, (int)p.M));
constexpr auto desc = matmul2d_descriptor(TM, TN, dynamic_length_v<int>);
matmul2d<desc, execution_simdgroups<SG>> op;
const int row = (int)group.y * TM;
const int col = (int)group.x * TN;
auto ta = A.slice<dynamic_extent, TM>(0, row);
auto tb = B.slice<TN, dynamic_extent>(col, 0);
auto tc = C.slice<TN, TM>(col, row);
op.run(ta, tb, tc);

The slices select 64 rows of AA and 64 columns of BB, each across all of KK, and the matching 64×6464\times64 output tile. Slices are also views: constructing ta loads nothing. op.run performs the loads and writes the product to tc. The descriptor’s default mode is multiply, so tc does not need to be initialized.

dynamic_length_v<int> lets MPP traverse the full KK dimension, and execution_simdgroups<4> makes all 128 threads participate in the call. The fixed slice extents give the compiler the full tile sizes; the source has a separate path with dynamic extents for partial edge tilesThe tensor coordinates put columns first, so the output view is C.slice<TN, TM>(col, row). The descriptor uses the mathematical order (M, N, K). For our C[35,93]C[35,93], the 64×6464\times64 tile begins at row 0 and column 64..

The three FP32 matrices as Metal tensors. A has extents (K, M): extent 0 is K, contiguous, and extent 1 is the M rows. B has extents (N, K) and C has extents (N, M). ta = A.slice<dynamic_extent, 64>(0, row) selects rows 0 to 63 across all of K; tb = B.slice<64, dynamic_extent>(col, 0) selects columns 64 to 127 across all of K; tc = C.slice<64, 64>(col, row), with row = 0 and col = 64, is the output tile that contains C[35, 93]. Below, op.run(ta, tb, tc): one call from 4 SIMD groups (128 threads) computes the whole 64 × 64 tile. We choose the output tile, how many SIMD groups share it, and how K is walked, whole or in chunks; MPP chooses how the inputs are loaded, how the tile is split across lanes, and which matrix instructions run. A tensor adds shape and strides to the same buffer; a slice is a view, not a copy.

The tensors and slices of matmul_tensorops for the tile that holds C[35,93]C[35,93]. Red: the input views; blue: the output view. Making a slice copies nothing; op.run does the loads.

Kernel 8 reaches 3035 GFLOP/s, up from 2722 for kernel 7 and about 90% of MPS. MPP controls everything inside the call; kernel 9 changes what happens around it.

Kernel 9: Synchronizing SIMD Groups Along K

In kernel 8, the four SIMD groups move through MPP’s internal KK loop independently. If one runs ahead, the groups use inputs from distant parts of AA and BB at the same time, which enlarges the active input set and makes cache reuse harder. Apple recommends periodically synchronizing the groups with a threadgroup barrier [5].

To place those barriers, we need to own the KK loop. In matmul_tensorops_sync, each call handles one BK-wide chunk, and a cooperative tensor holds the output between calls: MPP partitions its elements across the participating threads in thread memory and chooses their layout [1].

After constructing the same buffer views, the kernel runs:

C++kernels/tensorops_sync.metal — accumulator and K loop
const int row = (int)group.y * TM;
const int col = (int)group.x * TN;
constexpr auto desc = matmul2d_descriptor(
TM, TN, BK, false, false, false,
matmul2d_descriptor::mode::multiply_accumulate);
matmul2d<desc, execution_simdgroups<4>> op;
auto first_a = A.slice<BK, TM>(0, row);
auto first_b = B.slice<TN, BK>(col, 0);
auto acc = op.template get_destination_cooperative_tensor<
decltype(first_a), decltype(first_b), float>();
for (uint16_t i = 0; i < acc.get_capacity(); ++i) {
if (acc.is_valid_element(i)) acc[i] = 0.0f;
}
for (int k = 0; k < (int)p.K; k += BK) {
threadgroup_barrier(mem_flags::mem_none);
auto ta = A.slice<BK, TM>(k, row);
auto tb = B.slice<TN, BK>(col, k);
op.run(ta, tb, acc);
}
auto tc = C.slice<TN, TM>(col, row);
acc.store(tc);

Each thread zeroes the valid elements in its own part of acc: get_capacity() counts its storage slots, and is_valid_element skips slots that hold no tensor element. Every call then adds a partial product into the same accumulator, and we store it to CC once, after the last chunk, so the chunks cost no round trips through the output buffer.

The barrier uses mem_none: every thread must reach the chunk boundary before any continues, but no memory fence is added. Unlike kernel 7, there is no shared tile to protect from being overwritten; the barrier only keeps the groups in step while they read the input buffers. The groups can still drift within a chunk, so BK sets how often they synchronize.

Top: tensorops_sync_64x128_k256 makes 16 calls along K. The loop: acc = 0; for k = 0, 256, up to 3840: threadgroup_barrier(mem_none), then op.run on A's rows for k to k+255 and B's k to k+255 for the tile's columns, accumulating into acc; finally acc.store to the C tile, once. A 64-row band of A and a 128-column band of B are each cut into 16 chunks of 256 along K, with one chunk highlighted in red. acc is a 64 × 128 cooperative tensor spread across the 128 threads and written to C once, after the last chunk. Bottom, illustrative: with no barriers, the four SIMD groups drift to different positions along K, so the part of A and B in use at the same time spans several chunks; with a barrier every 256, all four groups stay inside one chunk. BK does not change how many bytes the tile needs, only how many are in use at once.

The synchronized kernel. Top: the 16 chunk calls and the accumulator that stays with the threads until the end. Bottom: what the barrier is for. The positions are a sketch, not a measurement; MPP does not tell us how it splits the tile across the groups.

Choosing the Tile and Chunk Size

This leaves two parameters: the output tile and BK. For the same 409634096^3 problem, the saved benchmark runs give:

Output tileK handlingGFLOP/s
64×6464\times64MPP’s full K loop3035
64×12864\times128MPP’s full K loop3182
128×128128\times128MPP’s full K loop1048
64×6464\times64Explicit chunks of 1283576
64×6464\times64Explicit chunks of 2563616
64×6464\times64Explicit chunks of 5123616
64×6464\times64Explicit chunks of 10243606
64×12864\times128Explicit chunks of 2563686

Larger output tiles reuse each input more. If each threadgroup loads every input it needs once per chunk, every load reaches device memory, and we ignore the final output store, the tile model gives:

Itile model=2TMTNBK4BK(TM+TN)=TMTN2(TM+TN).I_{\text{tile model}} = \frac{2T_M T_N B_K}{4B_K(T_M+T_N)} = \frac{T_M T_N}{2(T_M+T_N)}.

That is 16 FLOP/byte for 64×6464\times64, about 21.3 for 64×12864\times128, and 32 for 128×128128\times128. The model ranks 128×128128\times128 first, yet it runs about three times slower than the other two, so reuse alone does not determine the best tile.

BK cancels out of the formula. Splitting KK does not change the input bytes the tile model needs, only when the groups request them and how much data they use at once. Smaller chunks synchronize more often; larger chunks give the groups more room to drift. The 64×6464\times64 results are nearly identical across 256, 512, and 1024, so 256 is a reasonable choice here, not a universal optimum.

The model does not tell us what MPP actually reads, so we profile the kernels as before:

Counter64x6464x128128x128sync64sync64x128
GFLOP/s32432984103637853789
Kernel Occupancy (%)16.1218.9616.4121.7118.11
F32 Utilization (%)77.3473.6729.1091.8791.94
Last Level Cache Limiter (%)55.6035.9458.2641.0237.87
GPU Read Bandwidth (GB/s)126.1198.94138.9391.0971.63
Register spillsnone16 Bytesnone144 Bytes368 Bytes

Multiplying each read bandwidth by its kernel time, the 128×128128\times128 tile reads 18.4 GB per product, 3.4× the 5.3 GB of the 64×6464\times64 tile, although the model predicts half as many bytes. It does not spill, and its occupancy is the same 16% as 64×6464\times64, so the loss comes from traffic rather than registers or occupancy. At 7.5 FLOP/byte it sits on the memory roof, with F32 utilization at 29%. The counters show that MPP’s 128×128128\times128 path reads more, but not why; MPP does not expose how it loads the tile.

The full-K 64×6464\times64 and 64×12864\times128 kernels read less than the model predicts: 26 and 30 FLOP/byte against 16 and 21. Part of the re-reads is served before device memory, most likely by the GPU’s caches, which is consistent with Apple’s guide. The synchronized kernels read 3.3 and 2.6 GB, 38% and 43% less than their full-K versions, which places both to the right of the ridge. F32 Utilization reaches 92%: for the first time in this post, the FP32 units are the limiting resourceBoth synchronized kernels spill a few registers, 144 and 368 bytes, and are still the fastest kernels here. Bytes per product are GPU Read Bandwidth × median GPU time, for example 71.63 GB/s×36.28 ms≈2.671.63\ \text{GB/s}\times36.28\ \text{ms}\approx2.6 GB for the winner..

Zoomed log-log roofline, 3 to 100 FLOP per byte and 500 to 5000 GFLOP/s, with the 118 GB/s roof, the 4.05 TFLOP/s FP32 roof, the ridge at about 34 FLOP per byte, and a dashed line where MPS and MLX sit, about 3.4 TFLOP/s. Points at measured intensity: SIMD-group 32×32 shared at 25 FLOP per byte and 2821 GFLOP/s; TensorOps 64×64 at 26 and 3243; 64×128 at 30 and 2984; 128×128 at 7.5 and 1036, reading 3.4 times the bytes of 64×64; 64×64 with chunks of 256 at 42 and 3785; 64×128 with chunks of 256 at 53 and 3789. Arrows show that splitting K into chunks of 256 with a barrier per chunk reads 38 to 43 percent fewer bytes and moves both tiles past the ridge, just under the FP32 roof.

The TensorOps kernels on a zoomed roofline, from the profiler runs. Several dots sit slightly above the 118 GB/s roof: the counter reported up to 139 GB/s, more than our streaming probe reached, so it likely also counts reads served by the system-level cache. Read the x positions as close estimates, not exact values.

In the benchmark, the fastest combination is tensorops_sync_64x128_k256: a 64×12864\times128 output tile, four SIMD groups, and 16 calls covering K=4096K=4096. It reaches 3.686 TFLOP/s, about 1.095× MPS. On GPU time alone, the synchronized 64×6464\times64 kernel is equally fast; the two trade places from run to runTwo re-timings without counters (run_kernel, 15 iterations each, GPU time): 3808 and 3682 GFLOP/s for synchronized 64×6464\times64, and 3651 and 3810 for synchronized 64×12864\times128. The full-K pair even swaps order against the table: 3444 and 3285 for 64×6464\times64, and 3180 and 3168 for 64×12864\times128. The table’s 2% gap is within this spread.. The synchronized versions also differ in more than the barrier: they use a static KK in the descriptor and a cooperative destination, so these timings do not isolate the barrier’s contribution.

Precision, Timing, and the Neural Accelerators

Two caveats apply to this result. First, precision. Both TensorOps versions keep FP32 buffers and leave relaxed_precision false: by default in the first descriptor and explicitly in the second. This disables the option to truncate FP32 mantissas before multiplication [1]. The benchmark checks agreement with MPS using ∣C−CMPS∣≤10−3+10−3∣CMPS∣|C-C_{\text{MPS}}|\leq10^{-3}+10^{-3}|C_{\text{MPS}}|; passing that check is a comparison with MPS, not with an exact reference.

Second, timing. The table averages the median throughput from the three saved claude_full_0925_run1/2/3.csv runs. Those timings cover complete host calls after warming and checking the kernels, including command encoding, submission, and waiting. MPS and our shaders reuse allocated buffersThese are the benchmark timings used in the opening ladder, rather than the GPU-only timings from the SIMD-group profiler. The comparison is for packed, non-transposed FP32 GEMM at M=N=K=4096M=N=K=4096 on this M5. The synchronized winner requires MM divisible by 64, NN by 128, and KK by 256..

TensorOps also provides access to the M5’s GPU Neural Accelerators [5], which raises the question of whether our FP32 kernels use them. Neither the API name nor the timings answer it: the winner’s 91% of our 4.04 TFLOP/s estimate is a comparison with the ordinary FP32 peak, not a measurement. I recorded a trace with the accelerator counters and added two controls, MLX’s FP32 matmul and MPS on FP16 matrices, which should use the accelerators:

Counterwinnermps32mlx32mps16
GFLOP/s37893385341715707
F32 Utilization (%)91.9483.0882.520.45
F16 Utilization (%)0.020.090.030.04
Neural Accelerator Utilization (%)0.000.000.0093.38
Threadgroup Memory L1 Read Bandwidth (GiB/s)0.00198.72397.450.00
GPU Read Bandwidth (GB/s)71.63128.62139.16105.97

They do not. Our FP32 winner, MPS, and MLX all show 0% Neural Accelerator utilization; their FLOPs run on the ordinary FP32 ALUs. The FP16 control confirms that the counter works: MPS keeps the accelerators 93% busy and reaches 15.7 TFLOP/s, almost four times our FP32 roof. The winner therefore beats MPS and MLX on the same FP32 units. It keeps them 92% busy against 83%, reads about half the bytes, and reads nothing from threadgroup memory, while both libraries stage through itMLX doesn’t label its command buffers, so for MLX gpu_counters.py --min-ms 20 --process python selects the 13 command buffers with at least 20 ms of GPU time: 3 warm-up and 10 timed products. The GPU time-slices each buffer with other processes, so one buffer shows up as several intervals in the trace..

We finish at 3.686 TFLOP/s, ahead of MPS on this 409634096^3 benchmark. TensorOps supplies the matrix implementation; we choose the tile and control progress through KK. The chunked KK loop leaves the tile model’s arithmetic intensity unchanged but reduces the bytes that actually reach memory.

Conclusion

Let’s end where we started. Here is the chart from the introduction of part 1 again; this time, you can read every dot on it:

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 same roofline, read with everything we’ve learned. The naive kernel sits on the memory roof at 3 FLOP/byte. Tiling and coarsening raise the intensity, but instruction issue keeps them below both roofs. The SIMD-group kernels climb the memory roof as their reuse grows. Only the synchronized TensorOps kernel crosses the ridge, where the FP32 units become the limit. MLX and MPS sit beside our shared SIMD-group kernel and read about twice the winner’s bytes.

If you take one thing from this post, take the loop we ran for every kernel: count the FLOPs and the bytes, put the kernel on the roofline, ask the profiler which limit it actually hit, change one thing, and measure again. Every big jump came out of that loop, and so did every surprise: tiling that took the pressure off device memory and then ran into an instruction wall, matrix instructions that came out slower than plain coarsening, and a 128×128128\times128 tile that read 3.4 times the bytes of a smaller one.

There’s plenty we didn’t do. Everything here is one shape, 409634096^3, packed and non-transposed, in FP32; other shapes would need other tiles and, for the synchronized kernel, edge handling. And the Neural Accelerators sat idle in every FP32 kernel, Apple’s included. MPP has a relaxed_precision flag that we left off; whether turning it on lets FP32 work reach the accelerators is an experiment I haven’t run yet. If you try it, I’d love to hear what you find.

Finally, thank you. This worklog turned out really long, and if you’ve read all the way down here, you gave it hours you could have spent on anything else. From the bottom of our hearts (mine, and whatever Claude and Codex have instead), thank you for reading. If you find a mistake or a faster kernel, the code is on GitHub: open an issue, and let’s make it faster together.

References

  1. [1]
    Metal Shading Language Specification[PDF]
    Apple Inc., 2026.
  2. [2]
    How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog[HTML]
    Simon Boehm, 2022. Blog post.
  3. [3]
    Worklog: optimising GEMM on NVIDIA H100 for cuBLAS-like performance[HTML]
    Hamza Elshafie, 2025. Blog post.
  4. [4]
    Accelerate your machine learning workloads with the M5 and A19 GPUs[HTML]
    Apple Inc., 2026. Apple Developer Tech Talks.
  5. [5]
    Metal Performance Primitives (MPP) Programming Guide[PDF]
    Apple Inc., 2026.