Skip to content

Commit bc2488d

Browse files
committed
perf(engine/metal): gelu-fold wiring — token-identical, routed OFF by receipt (#341 phase 1)
The fused down projections (lthn_gelu_qmv local + lthn_gather_qmv_gelu expert) wire in behind geluFoldEnabled with an engagement counter and a session-level oracle: TestLoadGemma4QuantMoEGeluFoldMatchesChain proves the fold token-identical to the gelu-dispatch + plain-down chain on the QAT-shaped fixture (8-bit local + 4-bit expert downs both engaged) — the verbatim load replication held, no FMA-contraction drift. MEASURED AND ROUTED OFF: the chain evaluates each gelu ONCE; an x-load fold re-evaluates it per row-tile — 352 threadgroups x 2112 precise::tanh on the 26B local down and x8 routes on the expert gather (~6M redundant tanh per layer) — and the real 26B-A4B decode paid 136.4 tok/s fused vs 154.7 chained. geluFoldEnabled defaults false with the receipt in-comment. LESSON (with the router-fusion receipt, now a rule): fold only O(output) work into matvec loads; per-element work consumed by many tiles must stay its own dispatch. Receipts: parity oracle green (opt-in), 26B restored 155.0 tok/s with the lane off, metal 1396 green, repo 10450 green. Co-Authored-By: Virgil <virgil@lethean.io>
1 parent 852a3bd commit bc2488d

5 files changed

Lines changed: 220 additions & 9 deletions

File tree

go/engine/metal/dispatch_sink.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,25 @@ func emitQMV[S dispatchSink](sink S, pso metal.MTLComputePipelineState, wq metal
440440
emitQMVAt(sink, pso, wq, wqOff, scales, scalesOff, biases, biasesOff, x, 0, out, outOff, inDim, outDim)
441441
}
442442

443+
// emitGeluQMV records the gelu-fused down projection (out = dequant(W) ·
444+
// gelu(gate)·up, lthn_gelu_qmv kernel — #341 phase 1) through any sink: wq=0,
445+
// scales=1, biases=2, gate=3, up=4, out=5, K=6, N=7, the same grid as emitQMV.
446+
// The separate gelu-gate-mul dispatch and its dependency hop never encode.
447+
func emitGeluQMV[S dispatchSink](sink S, pso metal.MTLComputePipelineState, wq metal.MTLBuffer, wqOff uint, scales metal.MTLBuffer, scalesOff uint, biases metal.MTLBuffer, biasesOff uint, gate, up, out metal.MTLBuffer, outOff uint, inDim, outDim int) {
448+
sink.setPSO(pso)
449+
sink.setBuf(wq, wqOff, 0)
450+
sink.setBuf(scales, scalesOff, 1)
451+
sink.setBuf(biases, biasesOff, 2)
452+
sink.setBuf(gate, 0, 3)
453+
sink.setBuf(up, 0, 4)
454+
sink.setBuf(out, outOff, 5)
455+
sink.setI32(int32(inDim), 6) // K
456+
sink.setI32(int32(outDim), 7) // N
457+
const bn, bk = 8, 32
458+
nTgp := uint((outDim + bn - 1) / bn)
459+
sink.dispatchThreadgroups(metal.MTLSize{Width: 1, Height: nTgp, Depth: 1}, metal.MTLSize{Width: bk, Height: 2, Depth: 1})
460+
}
461+
443462
// emitQMVAt is emitQMV with the activation vector bound at a byte offset — the batched dense
444463
// forward's rows live at byte offsets inside shared K-row buffers, and a row's quant gate reads
445464
// its input in place.

go/engine/metal/moe_block.go

Lines changed: 93 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,50 @@ func moeArriveZeroBuffer() metal.MTLBuffer {
15551555
// the lane (a silent gate regression reads as zero, not as a perf blur).
15561556
var moeConcurrentBlocks atomic.Int64
15571557

1558+
// geluFoldDispatches counts MoE blocks that ran the gelu-fused down kernels
1559+
// (#341 phase 1) instead of the gelu-dispatch + plain-down chain. Engagement
1560+
// counter for the A/B tests.
1561+
var geluFoldDispatches atomic.Int64
1562+
1563+
type lthnGeluQMVKey struct {
1564+
groupSize, bits int
1565+
}
1566+
1567+
var (
1568+
lthnGeluQMVPSOMu sync.Mutex
1569+
lthnGeluQMVPSOCache = map[lthnGeluQMVKey]metal.MTLComputePipelineState{}
1570+
)
1571+
1572+
// lthnGeluQMVPipeline resolves (and caches, including failures) the local-down
1573+
// variant with the MLP gate fused into its x-load (lthn_gelu_qmv, #341 phase
1574+
// 1). A miss — custom library absent, or a gs/bits pair outside the
1575+
// instantiated set — caches nil so the block falls back to the gelu-dispatch +
1576+
// plain-qmv chain without re-probing.
1577+
func lthnGeluQMVPipeline(groupSize, bits int) (metal.MTLComputePipelineState, bool) {
1578+
key := lthnGeluQMVKey{groupSize: groupSize, bits: bits}
1579+
lthnGeluQMVPSOMu.Lock()
1580+
defer lthnGeluQMVPSOMu.Unlock()
1581+
if pso, ok := lthnGeluQMVPSOCache[key]; ok {
1582+
return pso, pso != nil
1583+
}
1584+
if customLibrary == nil || customLibrary.GetID() == 0 {
1585+
lthnGeluQMVPSOCache[key] = nil
1586+
return nil, false
1587+
}
1588+
fn := customLibrary.NewFunctionWithName(core.Sprintf("lthn_gelu_qmv_bfloat16_t_gs_%d_b_%d", groupSize, bits))
1589+
if fn == nil || fn.GetID() == 0 {
1590+
lthnGeluQMVPSOCache[key] = nil
1591+
return nil, false
1592+
}
1593+
pso, perr := device.NewComputePipelineStateWithFunctionError(fn)
1594+
if perr != nil {
1595+
lthnGeluQMVPSOCache[key] = nil
1596+
return nil, false
1597+
}
1598+
lthnGeluQMVPSOCache[key] = pso
1599+
return pso, true
1600+
}
1601+
15581602
// ffnMegaKernelCompatible is the KERNEL truth: the widths the megakernel is specialised for
15591603
// (4/8-bit byte-aligned codes via ffnMegaPipelineBits, parity-proven at both) with all three
15601604
// projections agreeing so one PSO serves the dispatch.
@@ -1706,6 +1750,20 @@ func encMoEBlockQuantDevice(enc metal.MTLComputeCommandEncoderObject, cb metal.M
17061750
if err != nil {
17071751
return enc, encConc, false, nil
17081752
}
1753+
// gelu fold (#341 phase 1): both down projections read gate/up directly and
1754+
// compute gelu(gate)·up at load — the two gelu dispatches (and the expert
1755+
// gelu's barrier) never encode. Decided ONCE for the whole block so the
1756+
// gated scratch stays coherent; either PSO missing (stale metallib, exotic
1757+
// width) or the lever falls back to the chain unchanged.
1758+
geluFold := false
1759+
var localDownGeluPSO, gatherDownGeluPSO metal.MTLComputePipelineState
1760+
if geluFoldEnabled {
1761+
if p1, ok1 := lthnGeluQMVPipeline(localDownGroupSize, localDownBits); ok1 {
1762+
if p2, ok2 := lthnGatherQMVPipeline(lthnGatherQMVKey{groupSize: expDownGroupSize, bits: expDownBits, expertRows: dModel, batchedX: true, gelu: true}); ok2 {
1763+
localDownGeluPSO, gatherDownGeluPSO, geluFold = p1, p2, true
1764+
}
1765+
}
1766+
}
17091767
rmsPSO, err := pipelineFor(rmsKernelBF16(dModel))
17101768
if err != nil {
17111769
return enc, encConc, false, nil
@@ -1881,8 +1939,22 @@ func encMoEBlockQuantDevice(enc metal.MTLComputeCommandEncoderObject, cb metal.M
18811939
}
18821940
}
18831941
emitGatherDownAll := func() {
1942+
if geluFold {
1943+
// the fused down reads each route's gate/up rows and gelus at load —
1944+
// the expert gelu dispatch never encoded.
1945+
emitLthnGatherQMVGeluRoutes(sink, gatherDownGeluPSO, scratch.expertGateAll, 0, scratch.expertUpAll, 0, expDownPacked.buf, expDownPacked.off, expDownScales.buf, expDownScales.off, expDownBiases.buf, expDownBiases.off, scratch.routeIota, routeIdxBuf, 0, scratch.expertDownAll, 0, dModel, expertDFF, expDownGroupSize, expDownBits, 0, topK)
1946+
return
1947+
}
18841948
emitGatherQMVAllRoutes(sink, gatherExpertDownPSO, downAllMeta, downKeyBatched, scratch.expertGatedAll, 0, expDownPacked.buf, expDownPacked.off, expDownScales.buf, expDownScales.off, expDownBiases.buf, expDownBiases.off, scratch.routeIota, routeIdxBuf, 0, scratch.expertDownAll, 0, dModel, expertDFF, expDownGroupSize, expDownBits, 0, topK)
18851949
}
1950+
emitLocalDown := func() {
1951+
if geluFold {
1952+
geluFoldDispatches.Add(1)
1953+
emitGeluQMV(sink, localDownGeluPSO, localDownPacked.buf, localDownPacked.off, localDownScales.buf, localDownScales.off, localDownBiases.buf, localDownBiases.off, msc.gate, msc.up, scratch.localOut, 0, dFF, dModel)
1954+
return
1955+
}
1956+
emitQ(localDownPSO, localDownPacked, localDownScales, localDownBiases, msc.gated, scratch.localOut, dFF, dModel)
1957+
}
18861958

18871959
// ---- concurrent pass: dispatches overlap; barriers mark the true dependency edges.
18881960
// The router, local-MLP, and expert branches are independent until the combine, so the
@@ -1921,19 +1993,27 @@ func encMoEBlockQuantDevice(enc metal.MTLComputeCommandEncoderObject, cb metal.M
19211993
emitQ(localGatePSO, localGatePacked, localGateScales, localGateBiases, scratch.localIn, msc.gate, dModel, dFF)
19221994
emitQ(localUpPSO, localUpPacked, localUpScales, localUpBiases, scratch.localIn, msc.up, dModel, dFF)
19231995
barrier()
1924-
// stage 3: top-k select ∥ local gelu
1996+
// stage 3: top-k select ∥ local gelu (folded into the local down when the
1997+
// fused kernels are available — the dispatch never encodes)
19251998
if !routerFused {
19261999
routerPlan.emitTopK(sink)
19272000
}
1928-
emitBinary(sink, geluPSO, msc.gate, 0, msc.up, 0, msc.gated, 0, dFF)
2001+
if !geluFold {
2002+
emitBinary(sink, geluPSO, msc.gate, 0, msc.up, 0, msc.gated, 0, dFF)
2003+
}
19292004
barrier()
19302005
// stage 4: expert gate/up gathers (need the top-k indices) ∥ local down
2006+
// (gelu-fused: reads msc.gate/msc.up from stage 2, two barriers upstream)
19312007
emitGatherInAll()
1932-
emitQ(localDownPSO, localDownPacked, localDownScales, localDownBiases, msc.gated, scratch.localOut, dFF, dModel)
1933-
barrier()
1934-
// stage 5: expert gelu
1935-
emitBinary(sink, geluPSO, scratch.expertGateAll, 0, scratch.expertUpAll, 0, scratch.expertGatedAll, 0, topK*expertDFF)
2008+
emitLocalDown()
19362009
barrier()
2010+
// stage 5: expert gelu — folded into the down gather's load when fused
2011+
// (the stage and its barrier disappear; the stage-4 barrier already
2012+
// orders the gate/up slabs ahead of the down gather)
2013+
if !geluFold {
2014+
emitBinary(sink, geluPSO, scratch.expertGateAll, 0, scratch.expertUpAll, 0, scratch.expertGatedAll, 0, topK*expertDFF)
2015+
barrier()
2016+
}
19372017
// stage 6: expert down gather
19382018
emitGatherDownAll()
19392019
barrier()
@@ -1989,14 +2069,18 @@ func encMoEBlockQuantDevice(enc metal.MTLComputeCommandEncoderObject, cb metal.M
19892069
} else {
19902070
emitQ(localGatePSO, localGatePacked, localGateScales, localGateBiases, scratch.localIn, msc.gate, dModel, dFF)
19912071
emitQ(localUpPSO, localUpPacked, localUpScales, localUpBiases, scratch.localIn, msc.up, dModel, dFF)
1992-
emitBinary(sink, geluPSO, msc.gate, 0, msc.up, 0, msc.gated, 0, dFF)
1993-
emitQ(localDownPSO, localDownPacked, localDownScales, localDownBiases, msc.gated, scratch.localOut, dFF, dModel)
2072+
if !geluFold {
2073+
emitBinary(sink, geluPSO, msc.gate, 0, msc.up, 0, msc.gated, 0, dFF)
2074+
}
2075+
emitLocalDown()
19942076
}
19952077
seam("moe.expert")
19962078
emitRMS(hBuf, pre2Buf.buf, scratch.expertIn, pre2Buf.off)
19972079
if allRoutes {
19982080
emitGatherInAll()
1999-
emitBinary(sink, geluPSO, scratch.expertGateAll, 0, scratch.expertUpAll, 0, scratch.expertGatedAll, 0, topK*expertDFF)
2081+
if !geluFold {
2082+
emitBinary(sink, geluPSO, scratch.expertGateAll, 0, scratch.expertUpAll, 0, scratch.expertGatedAll, 0, topK*expertDFF)
2083+
}
20002084
emitGatherDownAll()
20012085
seam("moe.tail")
20022086
// combine the routes: acc = Σ_r w_r · down_r — one fused dispatch (byte-identical

go/engine/metal/moe_session_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -815,3 +815,69 @@ func alignedSafetensorsBlob(t *testing.T, tensors map[string]safetensors.Tensor,
815815
t.Fatal("could not build an aligned safetensors fixture")
816816
return nil
817817
}
818+
819+
// TestLoadGemma4QuantMoEGeluFoldMatchesChain pins the gelu-fused down
820+
// projections (#341 phase 1) to the gelu-dispatch + plain-down chain token for
821+
// token: the fused kernels compute T(gelu(gate)·up) at load with the chain's
822+
// exact expression and rounding (lthn_gelu_qmv_impl.h), so the fold is a
823+
// schedule-only change. The fixture's QAT-shaped overrides put the local down
824+
// at 8-bit and the expert down at 4-bit — both fused widths engage.
825+
func TestLoadGemma4QuantMoEGeluFoldMatchesChain(t *testing.T) {
826+
if os.Getenv(MetallibPathEnv) == "" {
827+
t.Skip("metallib not set")
828+
}
829+
const dModel, nHeads, nKV, headDim, vocab = 64, 2, 1, 64, 32
830+
const dFF, expertDFF, numExperts, topK, numLayers = 128, 64, 4, 2, 2
831+
const maxLen, n = 16, 6
832+
quant := &model.QuantConfig{GroupSize: 64, Bits: 4, Overrides: map[string]model.ModuleQuant{}}
833+
for i := range numLayers {
834+
for _, m := range []string{"mlp.gate_proj", "mlp.up_proj", "mlp.down_proj", "router.proj"} {
835+
quant.Overrides[core.Sprintf("model.layers.%d.%s", i, m)] = model.ModuleQuant{GroupSize: 64, Bits: 8}
836+
}
837+
}
838+
cfg := g4.Config{
839+
HiddenSize: dModel, NumHiddenLayers: numLayers, IntermediateSize: dFF,
840+
NumAttentionHeads: nHeads, NumKeyValueHeads: nKV, HeadDim: headDim, VocabSize: vocab, RMSNormEps: 1e-6,
841+
EnableMoEBlock: true, NumExperts: numExperts, TopKExperts: topK, MoEIntermediateSize: expertDFF,
842+
Quantization: quant,
843+
}
844+
arch, err := cfg.Arch()
845+
if err != nil {
846+
t.Fatalf("Arch: %v", err)
847+
}
848+
ts := moeQuantTensors(t, arch, quant)
849+
prompt := []int32{1, 5, 3}
850+
851+
gen := func(fold bool) []int32 {
852+
t.Helper()
853+
geluFoldEnabled = fold
854+
defer func() { geluFoldEnabled = false }()
855+
lm, aerr := model.Assemble(ts, arch, model.StandardWeightNames())
856+
if aerr != nil {
857+
t.Fatalf("model.Assemble: %v", aerr)
858+
}
859+
g, qerr := loadedToQuant(lm, quant.GroupSize, quant.Bits)
860+
if qerr != nil {
861+
t.Fatalf("loadedToQuant: %v", qerr)
862+
}
863+
sess, serr := NewArchQuantSession(g, arch, maxLen)
864+
if serr != nil {
865+
t.Fatalf("NewArchQuantSession: %v", serr)
866+
}
867+
out, gerr := sess.Generate(prompt, n, -1)
868+
if gerr != nil {
869+
t.Fatalf("Generate: %v", gerr)
870+
}
871+
return out
872+
}
873+
chain := gen(false)
874+
before := geluFoldDispatches.Load()
875+
folded := gen(true)
876+
if geluFoldDispatches.Load() == before {
877+
t.Skip("gelu-fused down kernels unavailable (stale metallib) — the compare would be vacuous")
878+
}
879+
if !idsEqual(folded, chain) {
880+
t.Fatalf("gelu fold diverged from the chain: %v != %v", folded, chain)
881+
}
882+
t.Logf("gelu-fused downs ≡ chain over %d tokens (%v)", len(chain), chain)
883+
}

go/engine/metal/piece_timing.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,16 @@ var (
4848
// lthn_gather_qmv kernel is available (7 buffers + 2 scalars, #280). Test/bench hook
4949
// for lean-vs-MLX byte compares.
5050
leanGatherDisabled bool
51+
// geluFoldEnabled opts the MoE down projections into the gelu-fused kernels
52+
// (gelu(gate)·up computed at the down's x-load — token-identical to the
53+
// chain, see lthn_gelu_qmv_impl.h). OFF by receipt: the chain evaluates each
54+
// gelu ONCE, but an x-load fold re-evaluates it per row-tile — 352
55+
// threadgroups × 2112 precise::tanh for the 26B local down and ×8 routes
56+
// for the expert gather (~6M redundant tanh per layer) — and the real
57+
// 26B-A4B decode measured 136.4 tok/s fused vs 154.7 chained. Fold only
58+
// O(output) work into matvec loads; per-element work consumed by many tiles
59+
// must stay its own dispatch. Capability + parity oracle kept.
60+
geluFoldEnabled bool
5161
// encCarryDisabled forces every concurrent pass to close its encoder and
5262
// reopen a serial one on exit (the pre-#341 shape) instead of carrying the
5363
// open concurrent encoder into the next pass. Test/bench hook for

go/engine/metal/qmv_gather.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,10 @@ var leanGatherDispatches atomic.Int64
185185
type lthnGatherQMVKey struct {
186186
groupSize, bits, expertRows int
187187
fast, batchedX bool
188+
// gelu selects the down-projection variant with the MLP gate fused into its
189+
// x-load (lthn_gather_qmv_gelu — #341 phase 1). Only the qmv_impl form is
190+
// instantiated, so gelu keys always carry fast=false.
191+
gelu bool
188192
}
189193

190194
var (
@@ -211,6 +215,9 @@ func lthnGatherQMVPipeline(key lthnGatherQMVKey) (metal.MTLComputePipelineState,
211215
if key.fast {
212216
variant = "lthn_gather_qmv_fast_"
213217
}
218+
if key.gelu {
219+
variant = "lthn_gather_qmv_gelu_"
220+
}
214221
name := core.Sprintf("%sbfloat16_t_gs_%d_b_%d", variant, key.groupSize, key.bits)
215222
fc := metal.NewMTLFunctionConstantValues()
216223
rows := int32(key.expertRows)
@@ -255,6 +262,31 @@ func emitLthnGatherQMVRoutes(sink encSink, pso metal.MTLComputePipelineState, x
255262
)
256263
}
257264

265+
// emitLthnGatherQMVGeluRoutes is emitLthnGatherQMVRoutes for the gelu-fused
266+
// down variant (#341 phase 1): the kernel reads each route's gate/up activation
267+
// rows (same lhs indexing on both) and computes gelu(gate)·up at load, so the
268+
// expert gelu dispatch and its barrier never encode.
269+
func emitLthnGatherQMVGeluRoutes(sink encSink, pso metal.MTLComputePipelineState, gate metal.MTLBuffer, gateOff uint, up metal.MTLBuffer, upOff uint, wq metal.MTLBuffer, wqOff uint, scales metal.MTLBuffer, scalesOff uint, biases metal.MTLBuffer, biasesOff uint, lhsIdx, rhsIdx metal.MTLBuffer, rhsOff uint, out metal.MTLBuffer, outOff uint, outDim, inDim, groupSize, bits, rowBase, routes int) {
270+
rowPackedBytes := inDim * bits / 8
271+
groups := inDim / groupSize
272+
sink.setPSO(pso)
273+
sink.setBuf(wq, wqOff+uint(rowBase*rowPackedBytes), 0)
274+
sink.setBuf(scales, scalesOff+uint(rowBase*groups*bf16Size), 1)
275+
sink.setBuf(biases, biasesOff+uint(rowBase*groups*bf16Size), 2)
276+
sink.setBuf(gate, gateOff, 3)
277+
sink.setBuf(up, upOff, 4)
278+
sink.setBuf(lhsIdx, 0, 5)
279+
sink.setBuf(rhsIdx, rhsOff, 6)
280+
sink.setBuf(out, outOff, 7)
281+
sink.setI32(int32(inDim), 8)
282+
sink.setI32(int32(outDim), 9)
283+
const bn, bk = 8, 32
284+
sink.dispatchThreadgroups(
285+
metal.MTLSize{Width: 1, Height: uint((outDim + bn - 1) / bn), Depth: uint(routes)},
286+
metal.MTLSize{Width: bk, Height: 2, Depth: 1},
287+
)
288+
}
289+
258290
// emitGatherQMVAllRoutes dispatches one gather_qmv covering EVERY route: grid.z carries the
259291
// routes, rhs_indices supplies each route's expert id (the router's device idxBuf), and the
260292
// x/lhs binding either shares one row (gate/up) or walks the per-route gated rows (down).

0 commit comments

Comments
 (0)