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 , 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 Limiterstays 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 tile of So we can think each thread will compute elements but it’s not true, we still don’t know how Apple did the dark magic with simdgroup primitives.. At each step along , the group loads an tile from and another from , multiplies them, and adds the product to its output tile. We still walk along ; 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
Let’s reuse our example on to see how what “cooperatively” means. We first divide the output into 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 is entry within it.
At k = 0, the group loads rows 32 through 39 and columns 0 through 7 of , together with rows 0 through 7 and columns 88 through 95 of . Their matrix product adds eight terms to each output. For our chosen element, that update is:
At k = 8, it adds the next eight terms, and so on. After steps, the accumulator contains the full output tile.
Which thread holds entry ? 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
![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.](/_vercel/image?url=_astro%2Fsimdgroup-tile-light.BviXbA3K.png&w=2048&q=100)
The simdgroup_8x8 kernel for threadgroup . Top: the SIMD group’s rows of and columns of (blue), and the two 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 . Not to scale. Layout adapted from
Implementation
We only use this kernel when and are multiples of .
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);}
What one simdgroup_load reads. The pointer marks the tile’s first float; the stride says how far apart its rows are in memory, floats in and floats in .
The main primitive is: simdgroup_multiply_accumulate(next, ta, tb, acc). It computes the matrix update . We keep that result for the next iteration, then store the completed tile into , 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 floats, or 512 requested bytes. It performs 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:
Across the grid, the kernel requests about 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 to , giving up much of the reuse between outputs.

One threadgroup’s work over the same 8 steps of , at the same scale. The bigger output tile uses every loaded value for 64 outputs; the 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 benchmark:
| Kernel | GFLOP/s | Relative to 1D coarsening |
|---|---|---|
| 1D coarsening | 1222 | 1.00x |
SIMD-group 8x8 | 1096 | 0.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 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:
| Counter | 1D | sg8x8 |
|---|---|---|
| GFLOP/s | 1230 | 1133 |
| Instruction Throughput Limiter (%) | 77.96 | 55.58 |
| Integer and Conditional Utilization (%) | 35.40 | 12.20 |
| F32 Utilization (%) | 30.20 | 27.52 |
| GPU Read Bandwidth (GB/s) | 45.67 | 119.09 |
| Last Level Cache Limiter (%) | 10.91 | 83.48 |
| Register spills | none | none |
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 FLOP/byte, the roof is TFLOP/s, and the kernel runs right at it.

The SIMD-group 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 accumulators and reuse a loaded input tile across their matrix products. Let’s give each group a output tile, made of four matrices, and see whether that reuse helps.
Kernel 6: Larger Output Tiles
Each SIMD group now holds four accumulators, covering a output tile. At each step along , it loads two tiles of (a0, a1) and two of (b0, b1). Each input tile feeds two matrix products: a0 updates both top accumulators, a1 both bottom ones, and the two tiles do the same along the columns.
Four SIMD groups cover a threadgroup tile, which gives the kernel its name, simdgroup_32x32. sg selects one of its four quadrants. The harness launches 128 threads per threadgroup and requires to be multiples of 32 and 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.](/_vercel/image?url=_astro%2Fsimdgroup-32x32-light.B0EVWeUE.png&w=2048&q=100)
The simdgroup_32x32 kernel for threadgroup . Left: four SIMD groups split the tile, and each holds four accumulators; is entry 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 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, the kernel and 1D coarsening. In the separate profiler runs:
| Counter | sg8x8 | sg32x32 |
|---|---|---|
| GFLOP/s | 1133 | 2017 |
| GPU Read Bandwidth (GB/s) | 119.09 | 117.75 |
| Register spills | none | none |
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 GB per product. The throughput above uses the median GPU time.. Its measured intensity rises to about 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 tiles; the two above and below load the same 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.

Where the SIMD groups get their 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 tile of and another of into threadgroup memory. The SIMD groups take their fragments from those shared tiles and reuse them in registers as before.
There are now two loops along . The outer loop stages 32 columns of and 32 rows of , 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 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
Per outer iteration, the threadgroup loads bytes from the device buffers and performs 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, the previous kernel and 81% of MPS. The separate profiler runs show:
| Counter | sg32x32 | shared |
|---|---|---|
| GFLOP/s | 2017 | 2821 |
| F32 Utilization (%) | 46.76 | 67.39 |
| Instruction Throughput Limiter (%) | 48.54 | 82.02 |
| Threadgroup Memory L1 Read Bandwidth (GiB/s) | 0.00 | 951.04 |
| GPU Read Bandwidth (GB/s) | 117.75 | 113.40 |
| Register spills | none | none |
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: GB per product.. That gives an intensity of about FLOP/byte and a bandwidth roof of 2.9 TFLOP/s, close to the profiled 2.8 TFLOP/s.

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 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
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
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 has extents , has , and has . 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 and 64 columns of , each across all of , and the matching 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 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 , the 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.](/_vercel/image?url=_astro%2Ftensorops-view-light.DCgJ-J4A.png&w=2048&q=100)
The tensors and slices of matmul_tensorops for the tile that holds . 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 loop independently. If one runs ahead, the groups use inputs from distant parts of and 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
To place those barriers, we need to own the 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
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 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.

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 problem, the saved benchmark runs give:
| Output tile | K handling | GFLOP/s |
|---|---|---|
| MPP’s full K loop | 3035 | |
| MPP’s full K loop | 3182 | |
| MPP’s full K loop | 1048 | |
| Explicit chunks of 128 | 3576 | |
| Explicit chunks of 256 | 3616 | |
| Explicit chunks of 512 | 3616 | |
| Explicit chunks of 1024 | 3606 | |
| Explicit chunks of 256 | 3686 |
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:
That is 16 FLOP/byte for , about 21.3 for , and 32 for . The model ranks 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 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 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:
| Counter | 64x64 | 64x128 | 128x128 | sync64 | sync64x128 |
|---|---|---|---|---|---|
| GFLOP/s | 3243 | 2984 | 1036 | 3785 | 3789 |
| Kernel Occupancy (%) | 16.12 | 18.96 | 16.41 | 21.71 | 18.11 |
| F32 Utilization (%) | 77.34 | 73.67 | 29.10 | 91.87 | 91.94 |
| Last Level Cache Limiter (%) | 55.60 | 35.94 | 58.26 | 41.02 | 37.87 |
| GPU Read Bandwidth (GB/s) | 126.11 | 98.94 | 138.93 | 91.09 | 71.63 |
| Register spills | none | 16 Bytes | none | 144 Bytes | 368 Bytes |
Multiplying each read bandwidth by its kernel time, the tile reads 18.4 GB per product, 3.4× the 5.3 GB of the tile, although the model predicts half as many bytes. It does not spill, and its occupancy is the same 16% as , 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 path reads more, but not why; MPP does not expose how it loads the tile.
The full-K and 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 GB for the winner..

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 output tile, four SIMD groups, and 16 calls covering . It reaches 3.686 TFLOP/s, about 1.095× MPS. On GPU time alone, the synchronized 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 , and 3651 and 3810 for synchronized . The full-K pair even swaps order against the table: 3444 and 3285 for , and 3180 and 3168 for . The table’s 2% gap is within this spread.. The synchronized versions also differ in more than the barrier: they use a static 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
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 on this M5. The synchronized winner requires divisible by 64, by 128, and by 256..
TensorOps also provides access to the M5’s GPU Neural Accelerators
| Counter | winner | mps32 | mlx32 | mps16 |
|---|---|---|---|---|
| GFLOP/s | 3789 | 3385 | 3417 | 15707 |
| F32 Utilization (%) | 91.94 | 83.08 | 82.52 | 0.45 |
| F16 Utilization (%) | 0.02 | 0.09 | 0.03 | 0.04 |
| Neural Accelerator Utilization (%) | 0.00 | 0.00 | 0.00 | 93.38 |
| Threadgroup Memory L1 Read Bandwidth (GiB/s) | 0.00 | 198.72 | 397.45 | 0.00 |
| GPU Read Bandwidth (GB/s) | 71.63 | 128.62 | 139.16 | 105.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 benchmark. TensorOps supplies the matrix implementation; we choose the tile and control progress through . The chunked 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:

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 tile that read 3.4 times the bytes of a smaller one.
There’s plenty we didn’t do. Everything here is one shape, , 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]Metal Shading Language Specification[PDF]Apple Inc., 2026.
- [2]How to Optimize a CUDA Matmul Kernel for cuBLAS-like Performance: a Worklog[HTML]Simon Boehm, 2022. Blog post.
- [3]Worklog: optimising GEMM on NVIDIA H100 for cuBLAS-like performance[HTML]Hamza Elshafie, 2025. Blog post.
- [4]Accelerate your machine learning workloads with the M5 and A19 GPUs[HTML]Apple Inc., 2026. Apple Developer Tech Talks.
- [5]Metal Performance Primitives (MPP) Programming Guide[PDF]Apple Inc., 2026.
![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.](/_vercel/image?url=_astro%2Fsimdgroup-tile-dark.CE0blSqU.png&w=2048&q=100)



![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.](/_vercel/image?url=_astro%2Fsimdgroup-32x32-dark.DffmEs1e.png&w=2048&q=100)



![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.](/_vercel/image?url=_astro%2Ftensorops-view-dark.BByuphV0.png&w=2048&q=100)


