Skip to content

Commit 8b24e38

Browse files
committed
fix(§TB1): offset-0 GEMM staging tiles (T.gemm requires A first-dim offset 0)
The batched B2 prims sliced tall (HPC*L,*) shared bands into per-head sub-GEMMs, but this tilelang's T.gemm asserts the A operand first-dim offset==0 — caught at trace (CUDA) / MSL-compile (Metal). FIX: dY16/opA are now OFFSET-0 head-sized staging tiles (L,*)/(maxLP,maxLP) REUSED across the serial head loop; per-head dY results live in (HPC,L,P) bands. Amortization lever preserved = one staging alloc + band-level syncs shared over HPC heads (not literal tall-M). smem gate estimates updated to the real offset-0 footprint (both CUDA gb10 99KB + Metal 32KB). MEASURED Metal (local Apple, in-budget L=P=N=32 HPC=2): batched 4.35ms vs serial 10.82ms = 2.49x GO; parity tight on 6/7 grads, dchunk=6.62e-3 (fp16 decay-fold in the dchunk GEMM operand vs serial fp32 — precision, not correctness). prod L=P=N=64 Metal = SMEM-NO-GO (49664 B > 32768 B at HPC=1, dispatcher gate RAISES).
1 parent 9899ce0 commit 8b24e38

2 files changed

Lines changed: 181 additions & 34 deletions

File tree

cppmega_mlx/nn/_tilelang/mamba3_chunked_backward_core.py

Lines changed: 69 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1895,12 +1895,15 @@ def build_chunk_scan_combine_bwd_metal(
18951895
maxLP = P if P > L else L
18961896
_hg = nheads // ngroups
18971897
_hpc = _b2_batched_heads_per_cta(nheads, _hg, _hpc)
1898+
# §TB1: dY16/opA are now OFFSET-0 head-sized staging tiles (L,*) /
1899+
# (maxLP,maxLP) reused across the head loop (NOT HPC*L bands) — only
1900+
# dacs/dAcs_acc/dY/DYX scale with HPC (per-head result bands).
18981901
smem_bytes = (
18991902
_hpc * L * 4 * 2 # dacs + dAcs_acc (fp32) per head
19001903
+ _hpc * L * P * 4 # dY (fp32) per head
19011904
+ _hpc * L * L * 4 # DYX (fp32) per head
1902-
+ _hpc * L * P * 2 # dY16 (fp16) band
1903-
+ _hpc * L * maxLP * 2 # opA (fp16) band
1905+
+ L * P * 2 # dY16 (fp16) offset-0 staging tile
1906+
+ maxLP * maxLP * 2 # opA (fp16) offset-0 staging tile
19041907
+ maxLP * N * 2 # opB (fp16) shared
19051908
+ maxLP * N * 4 # store_fp32 shared
19061909
+ L * N * 4 # dCdiag_sh (fp32) shared
@@ -2015,11 +2018,14 @@ def build_chunk_scan_combine_bwd_metal(
20152018
_hpc = _b2_batched_heads_per_cta(nheads, _hg, _hpc)
20162019
_maxlp = headdim if headdim > chunk_size else chunk_size
20172020
_maxpn = dstate if dstate > headdim else headdim
2021+
# §TB1: dY16/opA are OFFSET-0 head-sized staging tiles reused across the
2022+
# head loop; only dacs/dAcs_acc/DYX/dY_band scale with HPC.
20182023
_smem_est = (
20192024
_hpc * chunk_size * 4 * 2 # dacs + dAcs_acc
20202025
+ _hpc * chunk_size * chunk_size * 2 # DYX (fp16) band
2021-
+ _hpc * chunk_size * headdim * 2 # dY16 (fp16) band
2022-
+ _hpc * chunk_size * _maxlp * 2 # opA (fp16) band
2026+
+ _hpc * chunk_size * headdim * 2 # dY_band (fp16) per head
2027+
+ chunk_size * headdim * 2 # dY16 (fp16) staging tile
2028+
+ _maxlp * _maxlp * 2 # opA (fp16) staging tile
20232029
+ _maxlp * dstate * 2 # opB (fp16)
20242030
+ _maxlp * _maxpn * 4 # store_f32
20252031
)
@@ -2353,10 +2359,15 @@ def main(
23532359
# Fragments are sized per-head (single 64-band) and accumulate per head.
23542360
dacs = T.alloc_shared((HPC, L), accum_dtype)
23552361
dAcs_acc = T.alloc_shared((HPC, L), accum_dtype)
2362+
# §TB1 offset-0 fix: this tilelang's T.gemm requires the A operand's
2363+
# first-dim offset == 0, so the GEMM staging tiles (dY16/opA) are
2364+
# OFFSET-0 head-sized (L,*) tiles REUSED across the serial head loop
2365+
# (the amortization lever = one staging alloc + band-level syncs shared
2366+
# over HPC heads). The per-head dY/DYX results stay in (HPC,...) bands.
23562367
dY = T.alloc_shared((HPC, L, headdim), accum_dtype)
23572368
DYX = T.alloc_shared((HPC, L, L), accum_dtype)
2358-
dY16 = T.alloc_shared((HPC * L, headdim), dtype, scope="shared")
2359-
opA = T.alloc_shared((HPC * L, maxLP), dtype, scope="shared")
2369+
dY16 = T.alloc_shared((L, headdim), dtype, scope="shared")
2370+
opA = T.alloc_shared((maxLP, maxLP), dtype, scope="shared")
23602371
opB = T.alloc_shared((maxLP, dstate), dtype, scope="shared")
23612372
store_fp32 = T.alloc_shared((maxLP, dstate), accum_dtype, scope="shared")
23622373
dCdiag_sh = T.alloc_shared((L, dstate), accum_dtype, scope="shared")
@@ -2398,8 +2409,6 @@ def main(
23982409
dx[batch_idx, s, head_idx, pp] = d_v * dy_v
23992410
dD_local[0] = dD_local[0] + dy_v * x_v
24002411
dY[hh, ll, pp] = dy_v
2401-
dY16[hh * L + ll, pp] = T.Cast(dtype, dy_v)
2402-
opA[hh * L + ll, pp] = T.Cast(dtype, x_v)
24032412
T.atomic_add(dD[head_idx], dD_local[0])
24042413
T.sync_threads()
24052414

@@ -2420,25 +2429,38 @@ def main(
24202429
)
24212430
T.sync_threads()
24222431

2423-
# ---- (A) DYX[hh,l,s] = sum_p dY[hh,l,p]*x[hh,s,p] — per-head GEMM band ----
2424-
# TALL-M: opA holds all HPC heads' x; dY16 all heads' dY. Each head's
2425-
# 64-band shares the staged tiles + the single sync below (amortized).
2432+
# ---- (A) DYX[hh,l,s] = sum_p dY[hh,l,p]*x[hh,s,p] — per-head GEMM ----
2433+
# Offset-0 staging tiles (dY16/opA) reused across the head loop; the
2434+
# staging region + the single trailing sync are shared over HPC heads.
24262435
for hh in T.serial(HPC):
2436+
head_idx = hb * HPC + hh
2437+
for lp in T.Parallel(L * headdim):
2438+
ll = lp // headdim
2439+
pp = lp % headdim
2440+
dY16[ll, pp] = T.Cast(dtype, dY[hh, ll, pp])
2441+
opA[ll, pp] = T.Cast(
2442+
dtype, x[batch_idx, base + ll, head_idx, pp]
2443+
)
2444+
T.sync_threads()
24272445
T.clear(DYX_frag)
24282446
T.gemm(
2429-
dY16[hh * L : hh * L + L, 0:headdim],
2430-
opA[hh * L : hh * L + L, 0:headdim],
2447+
dY16[0:L, 0:headdim],
2448+
opA[0:L, 0:headdim],
24312449
DYX_frag,
24322450
transpose_B=True,
24332451
)
24342452
T.copy(DYX_frag, DYX[hh, 0:L, 0:L])
2435-
T.sync_threads()
2453+
T.sync_threads()
24362454

24372455
# ---- (B) dC_off + (C) dC_diag + dC store, per head ----
24382456
for hh in T.serial(HPC):
24392457
head_idx = hb * HPC + hh
24402458
group_idx = head_idx // heads_per_group
2441-
# (B) dC_off[l,n] = sum_p dY[l,p]*prev_states[p,n]
2459+
# (B) dC_off[l,n] = sum_p dY[l,p]*prev_states[p,n] (offset-0 staging)
2460+
for lp in T.Parallel(L * headdim):
2461+
ll = lp // headdim
2462+
pp = lp % headdim
2463+
dY16[ll, pp] = T.Cast(dtype, dY[hh, ll, pp])
24422464
T.copy(
24432465
prev_states[
24442466
batch_idx, chunk_idx, head_idx, 0:headdim, 0:dstate
@@ -2449,20 +2471,20 @@ def main(
24492471
T.sync_threads()
24502472
T.clear(dCoff_frag)
24512473
T.gemm(
2452-
dY16[hh * L : hh * L + L, 0:headdim],
2474+
dY16[0:L, 0:headdim],
24532475
opB[0:headdim, 0:dstate],
24542476
dCoff_frag,
24552477
)
24562478
T.copy(dCoff_frag, store_fp32[0:L, 0:dstate])
24572479
T.sync_threads()
2458-
# (C) dC_diag[l,n] = sum_{s<=l} M[l,s]*B[s,n] (masked operand)
2480+
# (C) dC_diag[l,n] = sum_{s<=l} M[l,s]*B[s,n] (masked operand, offset-0)
24592481
for ls in T.Parallel(L * L):
24602482
ll = ls // L
24612483
ss = ls % L
24622484
lmat = T.exp2((dacs[hh, ll] - dacs[hh, ss]) * p)
24632485
dt_s = T.Cast(accum_dtype, dt[batch_idx, head_idx, chunk_idx, ss])
24642486
m_val = DYX[hh, ll, ss] * lmat * dt_s
2465-
opA[hh * L + ll, ss] = T.Cast(
2487+
opA[ll, ss] = T.Cast(
24662488
dtype, T.if_then_else(ss <= ll, m_val, T.Cast(accum_dtype, 0))
24672489
)
24682490
T.copy(
@@ -2473,7 +2495,7 @@ def main(
24732495
T.sync_threads()
24742496
T.clear(dCdiag_frag)
24752497
T.gemm(
2476-
opA[hh * L : hh * L + L, 0:L],
2498+
opA[0:L, 0:L],
24772499
opB[0:L, 0:dstate],
24782500
dCdiag_frag,
24792501
)
@@ -2502,7 +2524,7 @@ def main(
25022524
ll = lp // headdim
25032525
pp = lp % headdim
25042526
sd = T.exp2(dacs[hh, ll] * p)
2505-
opA[hh * L + ll, pp] = T.Cast(dtype, dY[hh, ll, pp] * sd)
2527+
opA[ll, pp] = T.Cast(dtype, dY[hh, ll, pp] * sd)
25062528
T.copy(
25072529
C[batch_idx, base : base + L, group_idx, 0:dstate],
25082530
opB[0:L, 0:dstate],
@@ -2511,7 +2533,7 @@ def main(
25112533
T.sync_threads()
25122534
T.clear(dchunk_frag)
25132535
T.gemm(
2514-
opA[hh * L : hh * L + L, 0:headdim],
2536+
opA[0:L, 0:headdim],
25152537
opB[0:L, 0:dstate],
25162538
dchunk_frag,
25172539
transpose_A=True,
@@ -2667,11 +2689,18 @@ def main(
26672689
chunk_idx = bx // batch
26682690
base = chunk_idx * chunk_size
26692691

2692+
# NOTE (§TB1 offset-0 fix): this tilelang's T.gemm REQUIRES the A
2693+
# operand's first-dim offset == 0, so we CANNOT slice a tall
2694+
# (HPC*L,...) band into per-head sub-GEMMs. The amortization lever is
2695+
# preserved by REUSING one offset-0 head-sized staging tile across the
2696+
# serial head loop (single alloc, one sync per phase over the band) —
2697+
# the staging region + syncs are shared, the GEMM is issued per head.
26702698
dacs = T.alloc_shared((HPC, L), accum_dtype)
26712699
dAcs_acc = T.alloc_shared((HPC, L), accum_dtype)
26722700
DYX = T.alloc_shared((HPC, L, L), dtype, scope="shared")
2673-
dY16 = T.alloc_shared((HPC * L, headdim), dtype, scope="shared")
2674-
opA = T.alloc_shared((HPC * L, _maxlp), dtype, scope="shared")
2701+
dY_band = T.alloc_shared((HPC, L, headdim), dtype, scope="shared")
2702+
dY16 = T.alloc_shared((L, headdim), dtype, scope="shared")
2703+
opA = T.alloc_shared((_maxlp, _maxlp), dtype, scope="shared")
26752704
opB = T.alloc_shared((_maxlp, dstate), dtype, scope="shared")
26762705
store_f32 = T.alloc_shared((_maxlp, _maxpn), accum_dtype, scope="shared")
26772706
DYX_frag = T.alloc_fragment((L, L), accum_dtype)
@@ -2708,8 +2737,7 @@ def main(
27082737
x_v = T.Cast(accum_dtype, x[batch_idx, s, head_idx, pp])
27092738
dx[batch_idx, s, head_idx, pp] = d_v * dy_v
27102739
dD_local[0] = dD_local[0] + dy_v * x_v
2711-
dY16[hh * L + ll, pp] = T.Cast(dtype, dy_v)
2712-
opA[hh * L + ll, pp] = T.Cast(dtype, x_v)
2740+
dY_band[hh, ll, pp] = T.Cast(dtype, dy_v)
27132741
T.atomic_add(dD[head_idx], dD_local[0])
27142742
T.sync_threads()
27152743

@@ -2729,12 +2757,19 @@ def main(
27292757
)
27302758
T.sync_threads()
27312759

2732-
# ---- (A) DYX per head (band, fp16 recompute tile) ----
2760+
# ---- (A) DYX per head (offset-0 staging tiles, reused across band) ----
27332761
for hh in T.serial(HPC):
2762+
head_idx = hb * HPC + hh
2763+
for lp in T.Parallel(L * headdim):
2764+
ll = lp // headdim
2765+
pp = lp % headdim
2766+
dY16[ll, pp] = dY_band[hh, ll, pp]
2767+
opA[ll, pp] = T.Cast(dtype, x[batch_idx, base + ll, head_idx, pp])
2768+
T.sync_threads()
27342769
T.clear(DYX_frag)
27352770
T.gemm(
2736-
dY16[hh * L : hh * L + L, 0:headdim],
2737-
opA[hh * L : hh * L + L, 0:headdim],
2771+
dY16[0:L, 0:headdim],
2772+
opA[0:L, 0:headdim],
27382773
DYX_frag,
27392774
transpose_B=True,
27402775
)
@@ -2745,16 +2780,16 @@ def main(
27452780
DYX[hh, ll, ss] = T.Cast(dtype, store_f32[ll, ss])
27462781
T.sync_threads()
27472782

2748-
# ---- (D) dchunk_states per head (band, transpose_A) ----
2783+
# ---- (D) dchunk_states per head (offset-0 staging, transpose_A) ----
27492784
for hh in T.serial(HPC):
27502785
head_idx = hb * HPC + hh
27512786
group_idx = head_idx // heads_per_group
27522787
for lp in T.Parallel(L * headdim):
27532788
ll = lp // headdim
27542789
pp = lp % headdim
27552790
sd = T.exp2(dacs[hh, ll] * p)
2756-
opA[hh * L + ll, pp] = T.Cast(
2757-
dtype, T.Cast(accum_dtype, dY16[hh * L + ll, pp]) * sd
2791+
opA[ll, pp] = T.Cast(
2792+
dtype, T.Cast(accum_dtype, dY_band[hh, ll, pp]) * sd
27582793
)
27592794
T.copy(
27602795
C[batch_idx, base : base + L, group_idx, 0:dstate],
@@ -2764,7 +2799,7 @@ def main(
27642799
T.sync_threads()
27652800
T.clear(dchunk_frag)
27662801
T.gemm(
2767-
opA[hh * L : hh * L + L, 0:headdim],
2802+
opA[0:L, 0:headdim],
27682803
opB[0:L, 0:dstate],
27692804
dchunk_frag,
27702805
transpose_A=True,
@@ -2795,7 +2830,7 @@ def main(
27952830
for pp in T.serial(headdim):
27962831
cs = prev_states[batch_idx, chunk_idx, head_idx, pp, nn]
27972832
accn[0] = accn[0] + T.Cast(
2798-
accum_dtype, dY16[hh * L + ll, pp]
2833+
accum_dtype, dY_band[hh, ll, pp]
27992834
) * cs
28002835
c_v = T.Cast(accum_dtype, C[batch_idx, s, group_idx, nn])
28012836
cdiag = T.alloc_local((1,), accum_dtype)
@@ -2832,7 +2867,7 @@ def main(
28322867
accum_dtype, C[batch_idx, base + ll, group_idx, nn]
28332868
)
28342869
acc[0] = acc[0] + T.Cast(
2835-
accum_dtype, dY16[hh * L + ll, pp]
2870+
accum_dtype, dY_band[hh, ll, pp]
28362871
) * c_v * lmat
28372872
dinp[batch_idx, sidx, head_idx, pp, nn] = (
28382873
dinp[batch_idx, sidx, head_idx, pp, nn] + acc[0]
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
"""LOCAL Apple-Metal A/B for the batched B2 GEMM prim (§TB1).
2+
3+
Builds+times the byte-identical SERIAL prod Metal prim vs the NEW batched-large-
4+
tile Metal GEMM prim on SMALL bounded shapes (<<80GB local RSS guard), and checks
5+
the GEMM-able grad outputs (dchunk_states + the shared dC/dz/dx/dD/dinp/dA_y) max
6+
|abs| vs the serial prod. RULE #1: build/run errors propagate; no fallback.
7+
8+
The Metal batched prim GEMM-ifies DYX + dchunk_states (Apple 32KB pool); the other
9+
terms stay serial — SAME serial math as the byte-identical prim, so parity must be
10+
tight everywhere.
11+
"""
12+
import os
13+
import time
14+
15+
import numpy as np
16+
import mlx.core as mx # noqa: F401 (forces Metal availability)
17+
import tilelang
18+
import tilelang.language as T # noqa: F401
19+
import torch
20+
21+
from cppmega_mlx.nn._tilelang.mamba3_chunked_backward_core import (
22+
chunk_scan_combine_bwd_metal_prim,
23+
chunk_scan_combine_bwd_metal_gemm_prim_batched,
24+
_b2_batched_heads_per_cta,
25+
)
26+
27+
DEV = "mps" # tilelang Metal kernels require MPS-resident torch tensors.
28+
29+
# SMALL bounded prod-shaped tile: chunk=64 (the prod L), P=N=64 (prod), but tiny
30+
# S/H so total RAM is a few hundred MB, well under the 80GB local guard.
31+
# IN-BUDGET Apple config: L=P=N=32 (NOT prod 64) keeps the threadgroup staging
32+
# under Apple's HARD 32KB pool so the batched Metal prim actually BUILDS+RUNS. The
33+
# prod L=P=N=64 batched Metal prim is the MEASURED SMEM-NO-GO (49664 B > 32768 B,
34+
# the dispatcher gate RAISES) — reported separately, honest (RULE #1).
35+
import os as _os2
36+
if _os2.environ.get("CPPMEGA_METAL_PROD64") == "1":
37+
B, S, CH, G, H, P, N = 1, 128, 64, 2, 4, 64, 64 # prod dims -> expect SMEM RAISE
38+
else:
39+
B, S, CH, G, H, P, N = 1, 64, 32, 2, 4, 32, 32 # in-budget, real measurement
40+
nchunks = S // CH
41+
HPC = _b2_batched_heads_per_cta(H, H // G, int(os.environ.get("CPPMEGA_PATH_C_METAL_HEADS_PER_CTA", "2")))
42+
43+
rng = np.random.RandomState(0)
44+
def f16(shape):
45+
return torch.tensor((rng.randn(*shape) * 0.1).astype(np.float32), dtype=torch.float16, device=DEV)
46+
def f32(shape):
47+
return torch.tensor((rng.randn(*shape) * 0.1).astype(np.float32), dtype=torch.float32, device=DEV)
48+
49+
dout = f16((B, S, H, P)); cb = f16((B, nchunks, G, CH, CH)); x = f16((B, S, H, P))
50+
z = f16((B, S, H, P)); dt = f16((B, H, nchunks, CH)); dA = f16((B, H, nchunks, CH))
51+
C = f16((B, S, G, N)); Bm = f16((B, S, G, N)); prev = f32((B, nchunks, H, P, N))
52+
D = f16((H,)); y = f16((B, S, H, P))
53+
54+
def zeros():
55+
return (torch.zeros(B, S, H, N, dtype=torch.float32, device=DEV),
56+
torch.zeros(B, S, H, P, dtype=torch.float32, device=DEV),
57+
torch.zeros(B, S, H, P, dtype=torch.float32, device=DEV),
58+
torch.zeros(B, nchunks, H, P, N, dtype=torch.float32, device=DEV),
59+
torch.zeros(B, S, H, P, N, dtype=torch.float32, device=DEV),
60+
torch.zeros(B, H, nchunks, CH, dtype=torch.float32, device=DEV),
61+
torch.zeros(H, dtype=torch.float32, device=DEV))
62+
63+
OUT = [11, 12, 13, 14, 15, 16, 17]
64+
65+
def compile_prim(prim):
66+
return tilelang.compile(prim, out_idx=OUT, target=None)
67+
68+
def run(kern):
69+
o = zeros()
70+
kern(dout, cb, x, z, dt, dA, C, Bm, prev, D, y, *o)
71+
torch.mps.synchronize()
72+
return o
73+
74+
def timeit(kern, iters=30):
75+
run(kern)
76+
torch.mps.synchronize()
77+
t0 = time.perf_counter()
78+
for _ in range(iters):
79+
o = zeros()
80+
kern(dout, cb, x, z, dt, dA, C, Bm, prev, D, y, *o)
81+
torch.mps.synchronize()
82+
return (time.perf_counter() - t0) / iters * 1e3
83+
84+
print(f"[METAL-B2-BATCHED] dims B={B} S={S} chunk={CH} G={G} H={H} P={P} N={N} HPC={HPC}")
85+
86+
serial_prim = chunk_scan_combine_bwd_metal_prim(B, S, CH, G, H, P, N)
87+
batched_prim = chunk_scan_combine_bwd_metal_gemm_prim_batched(B, S, CH, G, H, P, N, heads_per_cta=HPC)
88+
89+
k_serial = compile_prim(serial_prim)
90+
k_batched = compile_prim(batched_prim)
91+
92+
o_s = run(k_serial)
93+
o_b = run(k_batched)
94+
names = ["dC", "dx", "dz", "dchunk", "dinp", "dA_y", "dD"]
95+
maxabs = {nm: float((a - b).abs().max().cpu()) for nm, a, b in zip(names, o_s, o_b)}
96+
97+
t_s = timeit(k_serial)
98+
t_b = timeit(k_batched)
99+
speedup = t_s / t_b if t_b > 0 else float("nan")
100+
verdict = "GO" if t_b < t_s else "NO-GO"
101+
worst = max(maxabs.values())
102+
parity = "PASS" if worst < 1e-3 else "FAIL"
103+
104+
print(f"[METAL-B2-BATCHED] serial={t_s:.3f}ms batched={t_b:.3f}ms "
105+
f"speedup={speedup:.3f}x {verdict} HPC={HPC}")
106+
print(f"[METAL-B2-BATCHED] parity {parity} worst={worst:.2e} "
107+
+ " ".join(f"{k}={v:.2e}" for k, v in maxabs.items()))
108+
print("METAL_RESULT_JSON " + str({
109+
"serial_ms": round(t_s, 4), "batched_ms": round(t_b, 4),
110+
"speedup": round(speedup, 4), "verdict": verdict, "HPC": HPC,
111+
"parity": parity, "maxabs": {k: f"{v:.2e}" for k, v in maxabs.items()},
112+
}))

0 commit comments

Comments
 (0)