Skip to content

Commit 6574440

Browse files
committed
perf(metal): deep-prompt global-layer SDPA rides a steel GEMM composition
The multiQ vector kernel re-reads the whole K/V stream once per query row. At depth that traffic outgrows the SLC and the measured per-key cost ramps 0.032µs → 0.065µs (the #345 knee); even its floor runs the prompt n² at a small fraction of the machine's matmul rate — mlx-lm's gemma4 prompt attention (the same GEMM chain below, headDim 256 exceeding MLX's fused steel_attention) prefilled 63K tokens 11× faster. Past sdpaPromptGEMMMinKV (4096) a global layer's chunk SDPA now runs as three dispatches per GQA group: S = Q @ Kᵀ (steel nt, batched over the group's q-heads on grid.z with scalar batch strides, B stride 0 sharing the group's K), an in-place causal row softmax (new lthn_softmax_causal_rows_bf16 — per-row valid-prefix cap identical to the multiQ kernel's, masked tail written zero for the following GEMM), and O = P @ V (steel nn, batched). K/V are read once per kv head instead of once per query row. Sliding layers and short prompts keep their existing kernels; the S slabs double-buffer across kv heads and track their OWN capacities (the #343 lesson). Token-identity tier — S stores bf16 between the GEMMs, the same boundary the fold's qmm and ≥32-row steel projections already trade at; pinned by the per-row cosine closeness test vs the multiQ kernel. Receipts (gemma-4-e2b-4bit, ctx 131072, temp 0, quiet GPU): 63K prefill 95.8s → 29.4s (3.3×) · needle HIT · byte-deterministic 97K prefill 276.0s → 54.7s (5.0×) · needle HIT · decode after 87.9 tok/s (= the #344 receipt, decode untouched) per-key cost flat 0.0107µs at every depth — the SLC-decay ramp is gone 26B-A4B 6.6K: 8.7s → 7.2s, needle HIT (quant/MoE arch rides the same lane) engine/metal suite 1439 green · module suite 9057 green Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 0007b21 commit 6574440

5 files changed

Lines changed: 482 additions & 14 deletions

File tree

go/engine/metal/decode_batched_session.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,13 @@ type denseBatchScratch struct {
8282
layerVStage []metal.MTLBuffer
8383
layerStageRowCap int
8484
layerStageKVCap int
85+
// prompt-attention GEMM score slabs (sdpa_prompt_gemm.go): two K × nCap buffers
86+
// double-buffered across heads so head h+1's wide QKᵀ GEMM overlaps head h's skinny
87+
// P@V GEMM instead of draining the GPU behind its 32 output tiles.
88+
sdpaS0 metal.MTLBuffer
89+
sdpaS1 metal.MTLBuffer
90+
sdpaSRowCap int // sdpaPromptS's OWN capacities — never another fold's (the attnRowCap lesson)
91+
sdpaSNCap int
8592
// moeBatch holds the K-row MoE fold slabs (moe_batch.go) — nil until an MoE chunk runs.
8693
moeBatch *moeBatchScratch
8794
}
@@ -139,6 +146,23 @@ func (s *denseBatchScratch) layerStage(li, layers, k, kvDimMax int) (kSt, vSt me
139146
return s.layerKStage[li], s.layerVStage[li]
140147
}
141148

149+
// sdpaPromptS returns the two prompt-attention score slabs (kRows × nCap bf16 each) for the
150+
// GEMM SDPA composition, (re)allocating when the chunk row count grows or the attended-length
151+
// cap rises. nCap is the session's maxLen so the allocation happens ONCE per session instead
152+
// of every deepening chunk. Growth is tracked by sdpaPromptS's OWN capacity fields — never
153+
// another fold's (the attnRowCap lesson: capacity consumed outside its owner left slabs short
154+
// at the wide tail-absorbed chunk).
155+
func (s *denseBatchScratch) sdpaPromptS(kRows, nCap int) (s0, s1 metal.MTLBuffer) {
156+
if s.sdpaS0 == nil || s.sdpaSRowCap < kRows || s.sdpaSNCap < nCap {
157+
rows := max(kRows, s.sdpaSRowCap)
158+
cols := max(nCap, s.sdpaSNCap)
159+
s.sdpaS0 = scratchBF16(rows * cols)
160+
s.sdpaS1 = scratchBF16(rows * cols)
161+
s.sdpaSRowCap, s.sdpaSNCap = rows, cols
162+
}
163+
return s.sdpaS0, s.sdpaS1
164+
}
165+
142166
func (s *denseBatchScratch) Close() {
143167
if s == nil {
144168
return
@@ -890,6 +914,15 @@ func (s *archDecodeState) stepTokensBatchedDenseResultWithInputViewsPLE(embs [][
890914
// (the per-row oracle would have routed 2-pass there), same tier as the fold's qmm.
891915
useMultiQ := !sdpaMultiQDisabledForTest && (slideW == 0 || basePos+K <= slideW) &&
892916
(basePos+K < sdpa2PassMinKV || K >= steelGEMMMinRows) && gpuHasSDPAMultiQ(lhd)
917+
// Deep-prompt global layers route the SDPA to the steel GEMM composition
918+
// (sdpa_prompt_gemm.go): K/V read once per HEAD instead of once per query row,
919+
// so the traffic no longer scales with the row count and the multiQ kernel's
920+
// deep-context SLC-decay ramp never engages. Same emission seam as multiQ —
921+
// the per-row loop still runs its staged/rope tail, only the SDPA dispatches
922+
// differ. Token-identity tier (S stores bf16 between the GEMMs), the same
923+
// boundary the fold's qmm and ≥32-row steel projections already trade at.
924+
useGEMMSDPA := useMultiQ && slideW == 0 && basePos+K >= sdpaPromptGEMMMinKV &&
925+
K <= sdpaPromptGEMMMaxRows && !sdpaPromptGEMMDisabledForTest && gpuHasPromptSDPAGEMM()
893926
if batchedRope {
894927
if err = encQKNormRopeRows(enc, qSlab, s.lb[li].qNorm.buf, qSlab, 0, s.lb[li].qNorm.off, 0, qDim, qDim, offBuf[0], layerRopeFreqs, K, s.nHeads, lhd, rotDim, rbase, s.scale, s.eps); err != nil {
895928
endEncodingFast(enc)
@@ -980,6 +1013,13 @@ func (s *archDecodeState) stepTokensBatchedDenseResultWithInputViewsPLE(embs [][
9801013
stagedDeferred[li] = true
9811014
pendingLandings = append(pendingLandings, ringLanding{li: li, kvDim: kvDim, slideW: slideW})
9821015
}
1016+
} else if useGEMMSDPA {
1017+
sScore0, sScore1 := s.denseBatch.sdpaPromptS((s.nHeads/lkv)*K, s.maxLen)
1018+
if err = encSDPAPromptGEMM(enc, qSlab, ownerK, ownerV, attnSlab, sScore0, sScore1,
1019+
s.nHeads, lkv, lhd, K, basePos+K, qDim, kvDim, s.scale); err != nil {
1020+
endEncodingFast(enc)
1021+
return nil, false, err
1022+
}
9831023
} else if useMultiQ {
9841024
if err = encSDPAMultiQCausal(enc, qSlab, ownerK, ownerV, attnSlab, s.nHeads, lkv, lhd, K, basePos+K,
9851025
int64(lhd), int64(kvDim), int64(lhd), int64(kvDim), s.scale); err != nil {

go/engine/metal/gemm_steel.go

Lines changed: 49 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type steelGEMMParams struct {
5151
_ int32 // trailing pad to the struct's 8-byte alignment
5252
}
5353

54-
type steelGEMMKey struct{ alignM, alignN, alignK bool }
54+
type steelGEMMKey struct{ transB, alignM, alignN, alignK bool }
5555

5656
var (
5757
steelGEMMMu sync.Mutex
@@ -72,21 +72,32 @@ const (
7272
// each combo is its own PSO. has_batch/use_out_source/do_axpby are baked false — the batched pass
7373
// runs plain single-batch D = A @ Bᵀ.
7474
func steelGEMMPipeline(alignM, alignN, alignK bool) (metal.MTLComputePipelineState, bool) {
75+
return steelGEMMPipelineTrans(true, alignM, alignN, alignK)
76+
}
77+
78+
// steelGEMMPipelineTrans is steelGEMMPipeline with the B-transpose selectable: transB=true loads
79+
// the nt kernel (B read transposed — weights, K caches), false the nn kernel (B read as-is — the
80+
// prompt attention's P @ V). Same function-constant layout either way.
81+
func steelGEMMPipelineTrans(transB, alignM, alignN, alignK bool) (metal.MTLComputePipelineState, bool) {
7582
steelGEMMMu.Lock()
7683
defer steelGEMMMu.Unlock()
7784
if steelGEMMBroken {
7885
return nil, false
7986
}
80-
key := steelGEMMKey{alignM: alignM, alignN: alignN, alignK: alignK}
87+
key := steelGEMMKey{transB: transB, alignM: alignM, alignN: alignN, alignK: alignK}
8188
if pso, ok := steelGEMMPSOCache[key]; ok {
8289
return pso, true
8390
}
8491
if library == nil || library.GetID() == 0 {
8592
steelGEMMBroken = true
8693
return nil, false
8794
}
88-
name := core.Sprintf("steel_gemm_fused_nt_bfloat16_bfloat16_bm%d_bn%d_bk%d_wm%d_wn%d",
89-
steelGEMMBM, steelGEMMBN, steelGEMMBK, steelGEMMWM, steelGEMMWN)
95+
variant := "nn"
96+
if transB {
97+
variant = "nt"
98+
}
99+
name := core.Sprintf("steel_gemm_fused_%s_bfloat16_bfloat16_bm%d_bn%d_bk%d_wm%d_wn%d",
100+
variant, steelGEMMBM, steelGEMMBN, steelGEMMBK, steelGEMMWM, steelGEMMWN)
90101
fc := metal.NewMTLFunctionConstantValues()
91102
off := uint8(0)
92103
for _, idx := range []uint{10, 100, 110} { // has_batch, use_out_source, do_axpby
@@ -116,38 +127,62 @@ func steelGEMMPipeline(alignM, alignN, alignK bool) (metal.MTLComputePipelineSta
116127
// (ldd = outDim). Reports false when the steel pipeline is unavailable (the caller keeps the
117128
// batched gemv).
118129
func encGemmBF16NT(enc metal.MTLComputeCommandEncoder, mat, vec, out metal.MTLBuffer, matOff, vecOff, outOff uint, outDim, inDim, rows int) bool {
119-
pso, ok := steelGEMMPipeline(rows%steelGEMMBM == 0, outDim%steelGEMMBN == 0, inDim%steelGEMMBK == 0)
130+
return encGemmBF16Strided(enc, true, vec, mat, out, vecOff, matOff, outOff,
131+
rows, outDim, inDim, inDim, inDim, outDim)
132+
}
133+
134+
// encGemmBF16Strided encodes D[m × n] = A[m × k] @ B (nt: B[n × k] read transposed; nn: B[k × n]
135+
// read as-is) as ONE steel GEMM with explicit leading dimensions — the general form behind
136+
// encGemmBF16NT. lda/ldb/ldd are element strides between consecutive rows of A/B/D, so a caller
137+
// can address one head's columns inside a wider slab (the prompt attention's Q/K/V/O views).
138+
// Reports false when the steel pipeline is unavailable (the caller keeps its fallback path).
139+
func encGemmBF16Strided(enc metal.MTLComputeCommandEncoder, transB bool, a, b, d metal.MTLBuffer,
140+
aOff, bOff, dOff uint, m, n, k, lda, ldb, ldd int) bool {
141+
return encGemmBF16StridedBatch(enc, transB, a, b, d, aOff, bOff, dOff, m, n, k, lda, ldb, ldd, 1, 0, 0, 0)
142+
}
143+
144+
// encGemmBF16StridedBatch is encGemmBF16Strided with a uniform-stride batch on grid depth:
145+
// batch problems at element strides batchA/batchB/batchD (stride 0 broadcasts an operand —
146+
// the GQA group's shared K/V). The steel fused kernel applies the scalar batch strides off
147+
// tid.z even with has_batch baked false, so this rides the SAME PSO as the single dispatch.
148+
func encGemmBF16StridedBatch(enc metal.MTLComputeCommandEncoder, transB bool, a, b, d metal.MTLBuffer,
149+
aOff, bOff, dOff uint, m, n, k, lda, ldb, ldd, batch int, batchA, batchB, batchD int64) bool {
150+
pso, ok := steelGEMMPipelineTrans(transB, m%steelGEMMBM == 0, n%steelGEMMBN == 0, k%steelGEMMBK == 0)
120151
if !ok {
121152
return false
122153
}
123154
if pieceTimingOn {
124155
steelGEMMDispatchesForTest++
125156
}
126-
tilesM := (rows + steelGEMMBM - 1) / steelGEMMBM
127-
tilesN := (outDim + steelGEMMBN - 1) / steelGEMMBN
157+
tilesM := (m + steelGEMMBM - 1) / steelGEMMBM
158+
tilesN := (n + steelGEMMBN - 1) / steelGEMMBN
128159
// threadblock swizzle (mlx matmul.cpp): interleave the tile walk so neighbouring threadgroups
129160
// share B panels in L2 — 0 for short grids, 2 on this device class for tall ones.
130161
swizzle := 0
131162
if tilesM > 3 {
132163
swizzle = 2
133164
}
134165
params := steelGEMMParams{
135-
M: int32(rows), N: int32(outDim), K: int32(inDim),
136-
LDA: int32(inDim), LDB: int32(inDim), LDD: int32(outDim),
166+
M: int32(m), N: int32(n), K: int32(k),
167+
LDA: int32(lda), LDB: int32(ldb), LDD: int32(ldd),
137168
TilesN: int32(tilesN), TilesM: int32(tilesM),
138-
SwizzleLog: int32(swizzle), GemmKIterationsAligned: int32(inDim / steelGEMMBK), BatchNDim: 1,
169+
BatchStrideA: batchA, BatchStrideB: batchB, BatchStrideD: batchD,
170+
SwizzleLog: int32(swizzle), GemmKIterationsAligned: int32(k / steelGEMMBK), BatchNDim: 1,
139171
}
140172
sink := encSink{enc}
141173
sink.setPSO(pso)
142-
sink.setBuf(vec, vecOff, 0) // A: the activation rows
143-
sink.setBuf(mat, matOff, 1) // B: the weight, read transposed (nt)
144-
sink.setBuf(out, outOff, 3) // D
174+
sink.setBuf(a, aOff, 0)
175+
sink.setBuf(b, bOff, 1)
176+
sink.setBuf(d, dOff, 3)
145177
setBytes(enc, unsafe.Pointer(&params), uint(unsafe.Sizeof(params)), 4)
146178
tile := 1 << swizzle
147179
gridX := tilesN * tile
148180
gridY := (tilesM + tile - 1) / tile
181+
if batch < 1 {
182+
batch = 1
183+
}
149184
sink.dispatchThreadgroups(
150-
metal.MTLSize{Width: uint(gridX), Height: uint(gridY), Depth: 1},
185+
metal.MTLSize{Width: uint(gridX), Height: uint(gridY), Depth: uint(batch)},
151186
metal.MTLSize{Width: 32, Height: steelGEMMWN, Depth: steelGEMMWM},
152187
)
153188
return true
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
// SPDX-Licence-Identifier: EUPL-1.2
2+
3+
#include <metal_stdlib>
4+
using namespace metal;
5+
6+
// lthn_softmax_causal_rows_bf16 — in-place causal row softmax over the prompt attention's
7+
// score slab S[rows × N] (bf16, rows packed at ldd = N). One threadgroup per slab row. The
8+
// grid may stack several heads' K-row score blocks ([head][K][N], the batched-GEMM layout),
9+
// so the query index within the chunk is row % K.
10+
//
11+
// Query s attends keys [0 .. N-K+s] — the same per-row cap the multiQ vector kernel applies
12+
// (query s uses key i iff i <= N - K + s), expressed as a valid-prefix length so causality
13+
// needs no mask storage. Entries at or beyond the cap are written 0.0 so the following
14+
// P @ V steel GEMM reads zero weight from the masked tail.
15+
//
16+
// Maths is f32 throughout (scores load bf16 → f32, scale applied at read — equivalent to the
17+
// vector kernel scaling q before the dot). Three sweeps: max → exp+sum (exp stored bf16
18+
// in place) → normalise. The stored-P double rounding (unnormalised exp then normalise) is
19+
// bounded by bf16's 2⁻⁸ relative step — the same tier as the score slab itself.
20+
kernel void lthn_softmax_causal_rows_bf16(
21+
device bfloat* S [[buffer(0)]],
22+
const constant int& N [[buffer(1)]],
23+
const constant int& K [[buffer(2)]],
24+
const constant float& scale [[buffer(3)]],
25+
uint row [[threadgroup_position_in_grid]],
26+
uint lid [[thread_position_in_threadgroup]],
27+
uint lsize [[threads_per_threadgroup]],
28+
uint simd_gid [[simdgroup_index_in_threadgroup]],
29+
uint simd_lid [[thread_index_in_simdgroup]]) {
30+
device bfloat* rowPtr = S + size_t(row) * size_t(N);
31+
const int s = int(row) % K; // query index within the chunk (grid may stack heads)
32+
const int valid = N - K + s + 1; // keys [0, valid)
33+
const int simds = int(lsize) / 32;
34+
35+
threadgroup float tgRed[32];
36+
threadgroup float tgOut[2];
37+
38+
// sweep 1: row max over the valid prefix (scaled scores)
39+
float m = -MAXFLOAT;
40+
for (int i = int(lid); i < valid; i += int(lsize)) {
41+
m = max(m, float(rowPtr[i]) * scale);
42+
}
43+
m = simd_max(m);
44+
if (simd_lid == 0) {
45+
tgRed[simd_gid] = m;
46+
}
47+
threadgroup_barrier(mem_flags::mem_threadgroup);
48+
m = (int(lid) < simds) ? tgRed[lid] : -MAXFLOAT;
49+
m = simd_max(m);
50+
if (lid == 0) {
51+
tgOut[0] = m;
52+
}
53+
threadgroup_barrier(mem_flags::mem_threadgroup);
54+
m = tgOut[0];
55+
threadgroup_barrier(mem_flags::mem_threadgroup);
56+
57+
// sweep 2: exponentiate in place + accumulate the sum; zero the masked tail
58+
float sum = 0.0f;
59+
for (int i = int(lid); i < N; i += int(lsize)) {
60+
if (i < valid) {
61+
float p = fast::exp(float(rowPtr[i]) * scale - m);
62+
rowPtr[i] = bfloat(p);
63+
sum += p;
64+
} else {
65+
rowPtr[i] = bfloat(0.0f);
66+
}
67+
}
68+
sum = simd_sum(sum);
69+
if (simd_lid == 0) {
70+
tgRed[simd_gid] = sum;
71+
}
72+
threadgroup_barrier(mem_flags::mem_threadgroup);
73+
sum = (int(lid) < simds) ? tgRed[lid] : 0.0f;
74+
sum = simd_sum(sum);
75+
if (lid == 0) {
76+
tgOut[1] = sum;
77+
}
78+
threadgroup_barrier(mem_flags::mem_threadgroup);
79+
sum = tgOut[1];
80+
81+
// sweep 3: normalise the valid prefix
82+
const float inv = sum > 0.0f ? (1.0f / sum) : 0.0f;
83+
for (int i = int(lid); i < valid; i += int(lsize)) {
84+
rowPtr[i] = bfloat(float(rowPtr[i]) * inv);
85+
}
86+
}

0 commit comments

Comments
 (0)