metalworkingGitHub
/kernels/gemm-double-buffered

Case Study: The Double-Buffered GEMM

sync_copy_db.metal is the tiled GEMM with double buffering added, and the cleanest measured demonstration in this glossary that there is no single fastest kernel.

The delta from its sibling is exactly three structural changes: two buffer sets, a prologue prefetch, one barrier per iteration instead of two.

  threadgroup float A_tg[2][BM * BK];
  threadgroup float B_tg[2][BK * BN];
  ...
  // Prologue: load tile 0 into buffer 0.
  load_tile<BM, BK, NTHREADS>(A + tg_row * k, k, A_tg[0], tid);
  load_tile<BK, BN, NTHREADS>(B + tg_col,     m, B_tg[0], tid);

  for (uint l = 0; l < k_tiles; l++) {
    threadgroup_barrier(mem_flags::mem_threadgroup);
    ushort nxt = ushort(1) - cur;
    if (l + 1 < k_tiles) {
      load_tile<BM, BK, NTHREADS>(A + tg_row * k + k_off, k, A_tg[nxt], tid);
      load_tile<BK, BN, NTHREADS>(B + k_off * m + tg_col, m, B_tg[nxt], tid);
    }
    /* compute on A_tg[cur] / B_tg[cur] */
    cur = nxt;
  }

sync_copy_db.metal:87-126, abridged

The single barrier is doing double duty: "the prefetch landed" and "everyone's done with the buffer we're about to overwrite" are the same condition when the buffers alternate. The kernel's own header comment is honest about the mechanism: without the dead async-DMA primitive this is not true overlap. It's ILP-based latency hiding, the same threads issuing independent loads and math for the scheduler to interleave.

Three Metal GEMM kernels benchmarked across matrix sizes, showing the fastest one changes with the problem size

The payoff table (author's numbers, M5 Max, fp32, best of 3×5, from the repo README):

Size sync_copy sync_copy_db MPS
1024² 3.3 TF 10.7 TF 2.9 TF
2048² 7.7 TF 9.0 TF 5.2 TF
4096² 13.5 TF 13.1 TF 11.7 TF
8192² 13.0 TF 12.7 TF 13.5 TF

Three regimes, three winners. At 1024², launch and DRAM-fetch latency dominate and the prefetch pipeline hides them: 3× over both alternatives, and note this is the regime LLM decode lives in. At 4096², the simpler kernel wins because its smaller loop unrolls better; compiler quality beats algorithm. At 8192², everything hits the bandwidth wall and MPS converges with the hand-written kernels.

The README's "things that did not help" section is required reading as methodology: offline -O3 -ffast-math compilation (bit-identical to runtime), bigger simdgroup tiles (10× slower from spills), SW=3 threadgroups. Negative results, measured and published: the norm this glossary's war stories wish the whole ecosystem followed.