Skip to content

Commit da0b67c

Browse files
committed
perf(metal): recorded ICB fans global-layer SDPA over the 2-pass pair — deep decode un-collapsed
The arch ICB recorded MLX's single-pass sdpa_vector once at session open: one threadgroup per head serially streaming the WHOLE cache. The re-encode lane's sdpa2PassMinKV switch cannot happen inside a recorded command buffer, so decode at depth collapsed far below bandwidth physics (E2B @52k: 44 tok/s measured vs ~115 expected from the ~6% attention-read overhead; E4B worse — more globals). GLOBAL layers now record the 2-pass pair: sdpa_vector_2pass_1 (blocks baked as function constant 26, supportIndirectCommandBuffers; N bound via the rebindable nGlobalBuf; K/V at binds 1/2 exactly as the single-pass op so the replay's rebind indices are untouched) + the 2pass_2 merge. Sliding layers keep single-pass — their window bounds n below the knee. blocks = sdpa2PassBlocks(maxLen), fixed at record time and safe at ANY smaller n: a block whose strided key walk starts past N writes finite_min/0 partials the merge zeroes. Gated on maxLen >= 1024 so short sessions — and every synthetic byte-parity fixture — keep the pure single-pass layout. Shared per-replay partials/sums/maxs + scalarI32(blocks) are registered resident (an ICB op reading a non-resident buffer is undefined — the #343 class). New plumbing: emitSDPA2Pass1NAt (N-from-buffer variant of the shared emitter), sdpaVector2Pass1PipelineICB / sdpaVector2Pass2PipelineICB. Receipts (E2B 4bit, tg128 temp0 think, quiet GPU): 6.6K 126.4 -> 149.7, 26K 71.0 -> 136.7, 52K 44.0 -> 114.4 (the physics prediction), 97K 25.7 -> 87.7 (+241%). Zero-depth unregressed: E2B 175.2, 26B MoE 146.0. Deep-session (131072) tokens byte-identical to the single-pass receipt and deterministic across reps; ICB parity suites green; full engine/metal suite 1436 green. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 582409b commit da0b67c

3 files changed

Lines changed: 159 additions & 7 deletions

File tree

go/engine/metal/decode_forward_arch_icb.go

Lines changed: 71 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,6 +1210,43 @@ func recordArchICB(
12101210
sdpaPSOByHd[hd] = pso
12111211
}
12121212
}
1213+
// Deep-decode: GLOBAL layers record the 2-pass SDPA pair instead of the single-pass
1214+
// kernel. The single-pass sdpa_vector runs ONE threadgroup per head over the whole
1215+
// cache — recorded once, it can never re-parallelise as the KV grows, which collapsed
1216+
// deep decode far below bandwidth physics (E2B @52K: 44 tok/s measured vs ~115
1217+
// expected). blocks is fixed at record time from maxLen and is safe at ANY smaller n:
1218+
// a block whose strided key walk starts past N writes finite_min/0 partials that the
1219+
// pass-2 merge zeroes. Sliding layers stay single-pass — their window bounds n below
1220+
// the 2-pass knee. Sessions too short to ever cross the knee keep the pure
1221+
// single-pass layout (and the existing byte-parity fixtures with it).
1222+
sdpa2PassICBBlocks := 0
1223+
if maxLen >= sdpa2PassMinKV {
1224+
sdpa2PassICBBlocks = int(sdpa2PassBlocks(maxLen))
1225+
}
1226+
sdpa2Pass1PSOByHd := make(map[int]metal.MTLComputePipelineState)
1227+
sdpa2Pass2PSOByHd := make(map[int]metal.MTLComputePipelineState)
1228+
nGlobal2Pass := 0
1229+
if sdpa2PassICBBlocks > 0 {
1230+
for li := range nLayers {
1231+
if specs[li].Attention != model.GlobalAttention {
1232+
continue
1233+
}
1234+
nGlobal2Pass++
1235+
hd := hdOf(li)
1236+
if _, ok := sdpa2Pass1PSOByHd[hd]; !ok {
1237+
p1, e := sdpaVector2Pass1PipelineICB(hd, int32(sdpa2PassICBBlocks))
1238+
if e != nil {
1239+
return nil, e
1240+
}
1241+
p2, e2 := sdpaVector2Pass2PipelineICB(hd)
1242+
if e2 != nil {
1243+
return nil, e2
1244+
}
1245+
sdpa2Pass1PSOByHd[hd] = p1
1246+
sdpa2Pass2PSOByHd[hd] = p2
1247+
}
1248+
}
1249+
}
12131250
addPSO, err := pipelineForICB("vv_Addbfloat16")
12141251
if err != nil {
12151252
return nil, err
@@ -1411,6 +1448,20 @@ func recordArchICB(
14111448
offBuf, nGlobalBuf, nSlidingBuf, epsBuf, axisBuf, wsBuf,
14121449
ropeScaleB, ropeBaseB, ropeLocalBaseB, freqStride1B, sdpaScaleB, addModelB,
14131450
}
1451+
// 2-pass SDPA intermediates for the GLOBAL layers (shared across layers — the replay's
1452+
// dependency barriers already serialise each layer's attention on the shared scratch,
1453+
// exactly as the single-row attn scratch). f32 per the kernel ABI; sized at the widest
1454+
// head dim. Owned by the replay via residentRes for the session's lifetime.
1455+
var p2Partials, p2Sums, p2Maxs metal.MTLBuffer
1456+
if nGlobal2Pass > 0 {
1457+
p2Partials = device.NewBufferWithLengthOptions(uint(nHeads*sdpa2PassICBBlocks*maxHd*4), metal.MTLResourceStorageModeShared)
1458+
p2Sums = device.NewBufferWithLengthOptions(uint(nHeads*sdpa2PassICBBlocks*4), metal.MTLResourceStorageModeShared)
1459+
p2Maxs = device.NewBufferWithLengthOptions(uint(nHeads*sdpa2PassICBBlocks*4), metal.MTLResourceStorageModeShared)
1460+
// pass-2 binds blocks via the memoised scalar — a value no other op declares, so
1461+
// register it resident explicitly (an ICB op reading a non-resident buffer is
1462+
// undefined; the strides/scale/N binds all reuse scalars already listed above).
1463+
resident = append(resident, p2Partials, p2Sums, p2Maxs, scalarI32(int32(sdpa2PassICBBlocks)))
1464+
}
14141465
if !hasFusedGELU {
14151466
resident = append(resident, x2, x3, x3s, inner, scaled, tnh, onePlus, halfG, gelu, c044, c079, c1c, c05)
14161467
}
@@ -1538,7 +1589,8 @@ func recordArchICB(
15381589
opsPerLayer--
15391590
}
15401591
}
1541-
total := opsPerLayer * nLayers
1592+
// GLOBAL layers' 2-pass SDPA is pass-1 + pass-2 where the single-pass was one op.
1593+
total := opsPerLayer*nLayers + nGlobal2Pass
15421594
icbDesc := metal.NewMTLIndirectCommandBufferDescriptor()
15431595
icbDesc.SetCommandTypes(metal.MTLIndirectCommandTypeConcurrentDispatch)
15441596
icbDesc.SetInheritBuffers(false)
@@ -1743,9 +1795,23 @@ func recordArchICB(
17431795
// gqaOf/sdpaStrideOf/sdpaScaleB hold. attendK read offset rebound/token if sliding.
17441796
hd, kv := hdOf(li), kvOf(li)
17451797
kvd := int64(kv * hd)
1746-
emitSDPA(fastICBSink{emit()}, sdpaPSOByHd[hd], qr, attendK, attendV, attn, 0, nBufForLayer,
1747-
nHeads, kv, 0, int64(hd), kvd, int64(hd), kvd, scale)
1748-
sdpaIdx[li] = opIdx - 1
1798+
if sdpa2PassICBBlocks > 0 && specs[li].Attention == model.GlobalAttention {
1799+
// GLOBAL layer deep-decode: the 2-pass pair fans the growing-cache reduction over
1800+
// blocks threadgroups (pass 1) and merges the partials (pass 2) — the recorded
1801+
// replacement for the single-pass kernel that serialised the whole cache on one
1802+
// threadgroup per head. N binds the same rebindable nGlobalBuf; K/V bind at slots
1803+
// 1/2 exactly as the single-pass op, so the replay's rebind indices are unchanged.
1804+
emitSDPA2Pass1NAt(fastICBSink{emit()}, sdpa2Pass1PSOByHd[hd], qr, 0, attendK, attendV,
1805+
p2Partials, p2Sums, p2Maxs, 0, nBufForLayer, 1, nHeads, kv, 0, sdpa2PassICBBlocks,
1806+
int64(hd), kvd, int64(hd), kvd, scale)
1807+
sdpaIdx[li] = opIdx - 1
1808+
emitSDPA2Pass2(fastICBSink{emit()}, sdpa2Pass2PSOByHd[hd], p2Partials, p2Sums, p2Maxs,
1809+
attn, 1, nHeads, sdpa2PassICBBlocks)
1810+
} else {
1811+
emitSDPA(fastICBSink{emit()}, sdpaPSOByHd[hd], qr, attendK, attendV, attn, 0, nBufForLayer,
1812+
nHeads, kv, 0, int64(hd), kvd, int64(hd), kvd, scale)
1813+
sdpaIdx[li] = opIdx - 1
1814+
}
17491815
recordProj(li, emit(), attn, attnOut, 0, projO)
17501816
if hasPA && useFusedResRMS { // fused: hBuf = inBuf + rms(Wo·attn) — one op, one fewer barrier
17511817
setRMSResidual(emit(), attnOut, postAttnBufs[li], inBuf, hBuf)
@@ -1846,7 +1912,7 @@ func recordArchICB(
18461912
// the recorded layout diverged from opsPerLayer·nLayers — a recorder bug, not a numeric
18471913
// drift; fail loud rather than replay a misaligned ICB.
18481914
if opIdx != total {
1849-
coreErr = core.NewError(core.Sprintf("native.decodeForwardArchICBCore: recorded %d ops, expected %d (opsPerLayer=%d × %d layers) — heterogeneous layout misaligned", opIdx, total, opsPerLayer, nLayers))
1915+
coreErr = core.NewError(core.Sprintf("native.decodeForwardArchICBCore: recorded %d ops, expected %d (opsPerLayer=%d × %d layers + %d global 2-pass) — heterogeneous layout misaligned", opIdx, total, opsPerLayer, nLayers, nGlobal2Pass))
18501916
return
18511917
}
18521918

go/engine/metal/dispatch_sink.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -385,20 +385,34 @@ func emitSDPAAt[S dispatchSink](sink S, pso metal.MTLComputePipelineState, q met
385385
// emitSDPA2Pass1 records the first long-context SDPA pass. It writes one partial
386386
// weighted-V sum plus online-softmax sum/max per (batch, kv-head, block).
387387
func emitSDPA2Pass1[S dispatchSink](sink S, pso metal.MTLComputePipelineState, q, k, v, partials, sums, maxs metal.MTLBuffer, kvByteOff uint, batch, nHeads, nKVHeads, n, blocks int, kHeadStride, kSeqStride, vHeadStride, vSeqStride int64, scale float32) {
388-
emitSDPA2Pass1At(sink, pso, q, 0, k, v, partials, sums, maxs, kvByteOff, batch, nHeads, nKVHeads, n, blocks, kHeadStride, kSeqStride, vHeadStride, vSeqStride, scale)
388+
emitSDPA2Pass1NAt(sink, pso, q, 0, k, v, partials, sums, maxs, kvByteOff, nil, batch, nHeads, nKVHeads, n, blocks, kHeadStride, kSeqStride, vHeadStride, vSeqStride, scale)
389389
}
390390

391391
// emitSDPA2Pass1At is emitSDPA2Pass1 with the query bound at a byte offset (the attention fold's
392392
// slab rows). The partials/sums/maxs stay whole-buffer — they are per-dispatch scratch.
393393
func emitSDPA2Pass1At[S dispatchSink](sink S, pso metal.MTLComputePipelineState, q metal.MTLBuffer, qOff uint, k, v, partials, sums, maxs metal.MTLBuffer, kvByteOff uint, batch, nHeads, nKVHeads, n, blocks int, kHeadStride, kSeqStride, vHeadStride, vSeqStride int64, scale float32) {
394+
emitSDPA2Pass1NAt(sink, pso, q, qOff, k, v, partials, sums, maxs, kvByteOff, nil, batch, nHeads, nKVHeads, n, blocks, kHeadStride, kSeqStride, vHeadStride, vSeqStride, scale)
395+
}
396+
397+
// emitSDPA2Pass1NAt is emitSDPA2Pass1At with the attended length optionally taken from a
398+
// BUFFER: nBuf != nil binds it at index 7 (the recorded arch ICB rebinds that buffer per
399+
// token — N is the one per-token-varying scalar, exactly as emitSDPAAt's nBuf); nBuf == nil
400+
// inlines n (the live paths know the length). blocks stays baked in the PSO + grid, which is
401+
// safe at ANY n: a block whose strided key walk starts past N contributes finite_min/0
402+
// partials that the pass-2 merge zeroes out.
403+
func emitSDPA2Pass1NAt[S dispatchSink](sink S, pso metal.MTLComputePipelineState, q metal.MTLBuffer, qOff uint, k, v, partials, sums, maxs metal.MTLBuffer, kvByteOff uint, nBuf metal.MTLBuffer, batch, nHeads, nKVHeads, n, blocks int, kHeadStride, kSeqStride, vHeadStride, vSeqStride int64, scale float32) {
394404
sink.setPSO(pso)
395405
sink.setBuf(q, qOff, 0)
396406
sink.setBuf(k, kvByteOff, 1)
397407
sink.setBuf(v, kvByteOff, 2)
398408
sink.setBuf(partials, 0, 3)
399409
sink.setBuf(sums, 0, 4)
400410
sink.setBuf(maxs, 0, 5)
401-
sink.setI32(int32(n), 7)
411+
if nBuf != nil {
412+
sink.setBuf(nBuf, 0, 7) // ICB: the N buffer, rebound per token at replay
413+
} else {
414+
sink.setI32(int32(n), 7)
415+
}
402416
sink.setI64(kHeadStride, 8)
403417
sink.setI64(kSeqStride, 9)
404418
sink.setI64(vHeadStride, 10)

go/engine/metal/icb.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,78 @@ func sdpaVectorPipelineICB(name string) (metal.MTLComputePipelineState, error) {
463463
return pso, nil
464464
}
465465

466+
// sdpaVector2Pass1PipelineICB builds the ICB-recordable sdpa_vector_2pass_1 — the same
467+
// function constants as the live builder (20-25 false: decode-time no mask/transpose/
468+
// causal/sinks; 26 = blocks, baked because the kernel strides its key walk and indexes
469+
// the partials by it) with supportIndirectCommandBuffers set. The recorded arch ICB
470+
// replays this pair for GLOBAL layers so deep decode keeps the fanned-out reduction the
471+
// single-pass kernel cannot give (one threadgroup per head serialising the whole cache
472+
// — the deep-decode collapse).
473+
func sdpaVector2Pass1PipelineICB(headDim int, blocks int32) (metal.MTLComputePipelineState, error) {
474+
key := core.Sprintf("sdpa_vector_2pass_1_bfloat16_t_%d_%d|b%d|icb", headDim, headDim, blocks)
475+
icbPSOMu.Lock()
476+
defer icbPSOMu.Unlock()
477+
if pso, ok := icbPSOCache[key]; ok {
478+
return pso, nil
479+
}
480+
if library == nil || library.GetID() == 0 {
481+
return nil, core.NewError("native.sdpaVector2Pass1PipelineICB: library unavailable")
482+
}
483+
fc := metal.NewMTLFunctionConstantValues()
484+
off := uint8(0)
485+
for _, idx := range []uint{20, 21, 22, 23, 24, 25} {
486+
fc.SetConstantValueTypeAtIndex(unsafe.Pointer(&off), metal.MTLDataTypeBool, idx)
487+
}
488+
blk := blocks
489+
fc.SetConstantValueTypeAtIndex(unsafe.Pointer(&blk), metal.MTLDataTypeInt, 26)
490+
name := core.Sprintf("sdpa_vector_2pass_1_bfloat16_t_%d_%d", headDim, headDim)
491+
fn, err := library.NewFunctionWithNameConstantValuesError(name, fc)
492+
if err != nil {
493+
return nil, core.E("native.sdpaVector2Pass1PipelineICB", name, err)
494+
}
495+
if fn == nil || fn.GetID() == 0 {
496+
return nil, core.NewError("native.sdpaVector2Pass1PipelineICB: kernel " + name + " not found")
497+
}
498+
desc := metal.NewMTLComputePipelineDescriptor()
499+
desc.SetComputeFunction(fn)
500+
desc.SetSupportIndirectCommandBuffers(true)
501+
pso, err := device.NewComputePipelineStateWithDescriptorOptionsReflectionError(desc, 0, nil)
502+
if err != nil {
503+
return nil, core.E("native.sdpaVector2Pass1PipelineICB", "pipeline "+name, err)
504+
}
505+
icbPSOCache[key] = pso
506+
return pso, nil
507+
}
508+
509+
// sdpaVector2Pass2PipelineICB builds the ICB-recordable sdpa_vector_2pass_2 combine
510+
// kernel (plain function, no constants — blocks arrives inlined at bind 4) with
511+
// supportIndirectCommandBuffers set.
512+
func sdpaVector2Pass2PipelineICB(headDim int) (metal.MTLComputePipelineState, error) {
513+
name := core.Sprintf("sdpa_vector_2pass_2_bfloat16_t_%d", headDim)
514+
key := name + "|icb"
515+
icbPSOMu.Lock()
516+
defer icbPSOMu.Unlock()
517+
if pso, ok := icbPSOCache[key]; ok {
518+
return pso, nil
519+
}
520+
if library == nil || library.GetID() == 0 {
521+
return nil, core.NewError("native.sdpaVector2Pass2PipelineICB: library unavailable")
522+
}
523+
fn := library.NewFunctionWithName(name)
524+
if fn == nil || fn.GetID() == 0 {
525+
return nil, core.NewError("native.sdpaVector2Pass2PipelineICB: kernel " + name + " not found")
526+
}
527+
desc := metal.NewMTLComputePipelineDescriptor()
528+
desc.SetComputeFunction(fn)
529+
desc.SetSupportIndirectCommandBuffers(true)
530+
pso, err := device.NewComputePipelineStateWithDescriptorOptionsReflectionError(desc, 0, nil)
531+
if err != nil {
532+
return nil, core.E("native.sdpaVector2Pass2PipelineICB", "pipeline "+name, err)
533+
}
534+
icbPSOCache[key] = pso
535+
return pso, nil
536+
}
537+
466538
func sdpaVectorPipelineICBForHeadDim(headDim int) (metal.MTLComputePipelineState, error) {
467539
icbPSOMu.Lock()
468540
if pso, ok := sdpaVectorICBHeadDimPSOCache[headDim]; ok {

0 commit comments

Comments
 (0)