Skip to content

Commit f185eec

Browse files
committed
feat(§27): B2 tensor-core T.gemm backward MEASURED on gb10 — REAL HMMA + parity PASS but 0.749x THROUGHPUT NO-GO
The new chunk_scan_combine_bwd_cuda_prim_gemm re-expresses B2's four GEMM-able contractions (DYX/dC_off/dC_diag/dchunk_states) as T.gemm, mirroring the §26 F0 14.5x template. MEASURED at prod cfg (S=4096 c=64 g=8 H=112 P=64 N=64 bs1): REAL TENSOR CORE: 128 mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32 + 66 ldmatrix in the compiled .so (the tl::mma_sync<kFloat16,kFloat16,kFloat32,16,8,16> CUTLASS path, byte-for-byte the F0 codegen form) — NOT threaded-serial relabeled. PARITY: ALL 8 grads pass the 1e-3 gate via the env-selected GEMM prim — dz=1.73e-4 dx=8.10e-4 dC=8.49e-5 dB=1.25e-5 dlog_decay=9.77e-4 ddt=1.53e-4 dh0=2.34e-4 dD=2.67e-5 (honest fp16-cache gold); WORST 9.77e-4 < 1e-3. GEMM-vs-v1 math-equivalent (dC 6.2e-5 / dchunk 4.8e-5 / dA_y 3.2e-5). THROUGHPUT: in-process A/B = 0.749x — the GEMM prim is SLOWER than the v1 threaded-serial prim (v1=1027.5ms gemm=1371.5ms same box). B2's four collapsed contractions are tiny single-64-tile m16n8k16 GEMMs whose ldmatrix/smem-staging + four sync barriers cost MORE than v1's re-gridded serial reductions; the true B2 hotspot (3-index s-dependent dinp) stays threaded in BOTH. The §26 F0 lever does NOT transfer to B2 -> NO-GO. B2 stays the v1 prim (byte-identical, gated OFF behind CPPMEGA_PATH_C_B2_GEMM). Step tok/s UNCHANGED at §17 ≈907/≈298 (the GEMM chain ~559.9ms would REGRESS vs v1 447.8ms). B0 NOT converted (same single-tile regression predicted). FIX to make it compile (RULE #1, the-one-path RAISES): a frag->shared spill dCdiag_sh (+16KB -> 88.5KB<99KB) + thread-strided dC consume loop clear a float32x{2,4}-vs-float32 Simplify Bind ICHECK (the all-fp32 dC store auto-vectorized while its loop-variant atomic_add could not ride the vector lane). docs/RELAX-GRAPH-VS-MEGATRON.md: §27 added (TL;DR + Verdict), measured B2 A/B, all-8-grad parity, box-state note (this box ran ~3x slower uniformly; §17 canonical NOT revised, only the box-invariant 0.749x ratio).
1 parent 2c515ff commit f185eec

2 files changed

Lines changed: 70 additions & 14 deletions

File tree

cppmega_mlx/nn/_tilelang/mamba3_chunked_backward_core.py

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,7 +1189,9 @@ def main(
11891189
group_idx = head_idx // heads_per_group
11901190
base = chunk_idx * chunk_size
11911191

1192-
# SMEM-MINIMAL tiling (<99 KB gb10 budget at prod L=P=N=64 -> 72.5 KB).
1192+
# SMEM-MINIMAL tiling (<99 KB gb10 budget at prod L=P=N=64 -> 88.5 KB
1193+
# incl. dCdiag_sh; the fragment->shared spill that fixes the Simplify
1194+
# float32x{2,4}-vs-float32 Bind ICHECK).
11931195
# The fp16 GEMM operands are REUSED across the four GEMM phases (each
11941196
# phase is separated by T.sync_threads() and the operand tile is written
11951197
# FRESH before its gemm, so the reuse is race-free). Sized by the MAX of
@@ -1211,6 +1213,7 @@ def main(
12111213
opA = T.alloc_shared((L, maxLP), dtype, scope="shared") # XT16 | M16 | dY_sd16
12121214
opB = T.alloc_shared((maxLP, dstate), dtype, scope="shared") # ps16 | B16 | C16
12131215
store_fp32 = T.alloc_shared((maxLP, dstate), accum_dtype, scope="shared") # dCoff_sh | dchunk_store
1216+
dCdiag_sh = T.alloc_shared((L, dstate), accum_dtype, scope="shared") # dC_diag fp32 (frag->shared so the elementwise dC consume reads SHARED, not a register fragment — a flattened T.Parallel index over a fragment auto-vectorizes to float32x2 in Simplify; F0 reads fragments only via whole-tile T.copy)
12141217
DYX_frag = T.alloc_fragment((L, L), accum_dtype) # (A) C accum
12151218
dCoff_frag = T.alloc_fragment((L, dstate), accum_dtype) # (B) C accum
12161219
dCdiag_frag = T.alloc_fragment((L, dstate), accum_dtype) # (C) C accum
@@ -1314,21 +1317,30 @@ def main(
13141317
T.sync_threads()
13151318
T.clear(dCdiag_frag)
13161319
T.gemm(opA[0:L, 0:L], opB[0:L, 0:dstate], dCdiag_frag) # sum_s M[l,s]*B[s,n]
1320+
T.copy(dCdiag_frag, dCdiag_sh) # fragment -> shared (whole-tile, F0 pattern)
13171321
T.sync_threads()
13181322

13191323
# dC[l,n] = dCoff*sd[l] + dCdiag ; dstate_decay dA grad from un-scaled
13201324
# dC_off (store_fp32, the §A risk-3 segsum-VJP term:
13211325
# dAcs_acc[l] += sum_n dCoff*C*sd).
1322-
for ln in T.Parallel(L * dstate):
1323-
ll = ln // dstate
1324-
nn = ln % dstate
1325-
s = base + ll
1326-
sd = T.exp2(dacs[ll] * p)
1327-
dC[batch_idx, s, head_idx, nn] = (
1328-
store_fp32[ll, nn] * sd + dCdiag_frag[ll, nn]
1329-
)
1330-
c_v = T.Cast(accum_dtype, C[batch_idx, s, group_idx, nn])
1331-
T.atomic_add(dAcs_acc[ll], store_fp32[ll, nn] * c_v * sd)
1326+
# THREAD-STRIDED serial (NOT T.Parallel): a flat T.Parallel(L*dstate)
1327+
# over the all-fp32 contiguous dC store auto-vectorizes to float32x4 in
1328+
# Simplify, but the loop-variant atomic_add to dAcs_acc[ll] cannot ride
1329+
# that vector lane -> the float32x4-vs-float32 Bind ICHECK. The same
1330+
# thread-strided form the dinp/dinp-zero loops use is not vectorized.
1331+
for ln0 in T.serial(0, L * dstate, threads):
1332+
lane = T.get_thread_binding(0)
1333+
ln = ln0 + lane
1334+
if ln < L * dstate:
1335+
ll = ln // dstate
1336+
nn = ln % dstate
1337+
s = base + ll
1338+
sd = T.exp2(dacs[ll] * p)
1339+
dC[batch_idx, s, head_idx, nn] = (
1340+
store_fp32[ll, nn] * sd + dCdiag_sh[ll, nn]
1341+
)
1342+
c_v = T.Cast(accum_dtype, C[batch_idx, s, group_idx, nn])
1343+
T.atomic_add(dAcs_acc[ll], store_fp32[ll, nn] * c_v * sd)
13321344
T.sync_threads()
13331345

13341346
# ---- (D) dchunk_states[p,n] = sum_l (dY[l,p]*sd[l])*C[l,n] via T.gemm ----
@@ -1500,7 +1512,8 @@ def build_chunk_scan_combine_bwd_metal(
15001512
# + dY16[L,P]fp16 + opA[L,max(L,P)]fp16 + opB[max(L,P),N]fp16
15011513
# + store_fp32[max(L,P),N]fp32. (DYX_frag/dCoff_frag/dCdiag_frag/
15021514
# dchunk_frag live in REGISTERS — fragments, not smem.) XT(fp32) is
1503-
# DROPPED (XT16 written directly). At prod L=P=N=64 -> 72.5 KB.
1515+
# DROPPED (XT16 written directly). dCdiag_sh[L,N]fp32 added (frag->shared
1516+
# spill, the float32x{2,4} Simplify-Bind fix). At prod L=P=N=64 -> 88.5 KB.
15041517
L = chunk_size
15051518
P = headdim
15061519
N = dstate
@@ -1513,6 +1526,10 @@ def build_chunk_scan_combine_bwd_metal(
15131526
+ L * maxLP * 2 # opA (fp16; XT16|M16|dY_sd16)
15141527
+ maxLP * N * 2 # opB (fp16; ps16|B16|C16)
15151528
+ maxLP * N * 4 # store_fp32 (dCoff_sh|dchunk_store)
1529+
+ L * N * 4 # dCdiag_sh (fp32; frag->shared, so the
1530+
# elementwise dC consume reads SHARED not
1531+
# a register fragment — avoids the
1532+
# float32x2-vs-float32 Simplify Bind error)
15161533
)
15171534
_GB10_SMEM_BUDGET = 99 * 1024
15181535
if smem_bytes >= _GB10_SMEM_BUDGET:

docs/RELAX-GRAPH-VS-MEGATRON.md

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
# Relax graph-path train_step vs Megatron — measured tok/s + peak memory (gb10 CUDA)
22

3-
**Status: MEASURED, 2026-06-04, gb10 (Grace-Blackwell aarch64, `tvm.cuda(0)`). Latest: §18 —
3+
**Status: MEASURED, 2026-06-05, gb10 (Grace-Blackwell aarch64, `tvm.cuda(0)`). Latest: §27 —
4+
the B2 backward tensor-core T.gemm rewrite is REAL but a THROUGHPUT NO-GO. The new prim
5+
`chunk_scan_combine_bwd_cuda_prim_gemm` re-expresses B2's four GEMM-able contractions
6+
(DYX / dC_off / dC_diag / dchunk_states) as `T.gemm` mirroring the §26 F0 14.5× template.
7+
It emits **REAL sm_120 tensor-core code — 128 `mma.sync.aligned.m16n8k16.row.col.f32.f16.f16.f32`
8+
+ 66 `ldmatrix` in the compiled `.so` (the `tl::mma_sync<kFloat16,kFloat16,kFloat32,16,8,16>`
9+
CUTLASS path, byte-for-byte the F0 codegen form), NOT threaded-serial relabeled** — and ALL 8
10+
model-facing grads PASS the 1e-3 gate through the GEMM prim (dz=1.73e-4, dx=8.10e-4, dC=8.49e-5,
11+
dB=1.25e-5, dlog_decay=9.77e-4, ddt=1.53e-4, dh0=2.34e-4, dD=2.67e-5 via the honest fp16-cache
12+
gold; WORST 9.77e-4 < 1e-3; GEMM-vs-v1 deltas dC 6.2e-5 / dchunk 4.8e-5 / dA_y 3.2e-5,
13+
math-equivalent). **BUT the in-process A/B is 0.749× — the GEMM prim is SLOWER than the v1
14+
threaded-serial prim (v1=1027.5 ms, gemm=1371.5 ms, same box, same inputs) → NO-GO.** Unlike F0
15+
(whose serial-scalar contraction was the entire 24.57 ms hotspot), B2's dominant cost is NOT the
16+
four collapsed contractions — they are tiny single-64-tile m16n8k16 GEMMs (M=N=K=64, one MMA
17+
tile each) whose ldmatrix/smem-staging + four sync barriers cost MORE than the v1 re-gridded
18+
serial reductions, while the actual B2 hotspot (the 3-index s-dependent `dinp` contraction +
19+
the gold-side work) stays threaded in BOTH prims. So GEMM-izing B2 does NOT transfer the F0 win;
20+
the §26 lever was MIS-LOCALIZED for B2. **B2 stays the v1 prim** (byte-identical, env-gated off);
21+
the GEMM prim is committed behind `CPPMEGA_PATH_C_B2_GEMM` as a measured NO-GO (RULE #1: the
22+
selected path is the ONE path and RAISES on failure; it required a frag→shared spill `dCdiag_sh`
23+
to clear a `float32x{2,4}`-vs-`float32` Simplify Bind ICHECK — the all-fp32 `dC` consume loop
24+
auto-vectorized while its loop-variant `atomic_add` could not ride the vector lane; fixed by
25+
thread-striding that loop and spilling the dC_diag fragment to shared, +16 KB → 88.5 KB < 99 KB).
26+
B0 was NOT converted (B2 NO-GO predicts the same single-tile-GEMM regression). **The §17 ≈907/≈298
27+
tok/s GO is UNCHANGED — the backward chain stays v1 447.8 ms canonical (the GEMM chain would be
28+
~559.9 ms, a REGRESSION).** This box ran ~3× slower than §17 uniformly (box-state effect, the v1
29+
B2=1358.7 / B0=272.6 / B1=5.28 ms here vs §17's 334.6 / 110.8 / 2.41 — NOT a code regression;
30+
the canonical §17 numbers are NOT revised, only the box-invariant 0.749× ratio is). Prior: §18 —
431
the THREE named Megatron-gap levers (4× batch / fp8 activations / B2 v2 dstate-split) RE-MEASURED:
532
ALL THREE land as NO-GO-for-throughput, the §17 ≈907/≈298 tok/s GO stands unchanged. (1) bs4
633
FORWARD is MEASURED + parity-PASS (chain 7.231→≈29.59 ms, 4.09× — exactly 4× grid, batch reaches the
@@ -42,7 +69,7 @@ region that cannot run RAISES (the profiler and the e2e runner both fail-closed)
4269
| tokens/step | 4,096 (seq×batch=4096×1) | 16,384 (bs=4×seq=4096) |
4370
| **peak memory** | **6.400 GB planned 8L / 12.998 GB planned 28L device-peak** (Korthikanti launch-cache §14; the gridded SSD scan §15 reuses the same banks → peak UNCHANGED; was 4.682/8.787 GB pre-§14 — the launch lever trades +1.7/+4.2 GB for the launch reduction, still 2x under Megatron) | **~26 GB** |
4471
| **fits Megatron-class memory** | **YES — 13.0 GB planned 28L < 26 GB (2x under)** | 26 GB |
45-
| **tok/s** | **§17 (gridded fwd + RE-GRIDDED gridded backward, MEASURED B2 substituted): ≈907 tok/s @8L / ≈298 @28L (gap ≈3.75×/≈11.4×) — B2 re-gridded 2484→334.6 ms (7.42×), chain 447.8 ms < 456 ms numpy bwd, ALL 8 grads pass incl dD 2.48e-5. §18 RE-MEASURED the 3 gap levers: bs4 fwd MEASURED+PASS but tok/s batch-invariant (saturated kernels, gap unchanged ≈3.75×/≈11.4×), bs4 bwd memory-NO-GO (gold harness OOM), fp8 UNRUNNABLE on sm_121 (byte-halving 2.0× MEASURED, speed UNMEASURED), B2 v2 NO-GO (0.997×/1.001×) — §17 headline holds. §21 RE-MEASURED the fp8 levers on the unblocked sm_121: our OWN native fp8-input e4m3 MMA is now VERIFIED real (kFloat8_e4m3 m16n8k32, 0 fp16) and 2.2–3.5× faster than the old fp16-decode but still 0.69–0.95× bf16 (untuned tile → speed NO-GO); TE standalone fp8 GEMM stays the only ~1.7× win; wiring TE fp8 into the e2e step is a memory-NO-GO (fp8 backward OOMs at the MLX↔torch cudaMallocAsync bridge, reproduced to seq=512) — so §17's ≈907/≈298 stays the canonical step number, fp8 e2e tok/s UNMEASURED. Prior: ≈894/≈293 @ §15 (gridded scan + numpy bwd, EXTRAPOLATED); 45.44 @8L MEASURED (§14); 29.82 @ §13, 25.2 pre-§13.** | **3399 tok/s** |
72+
| **tok/s** | **§27 (B2 tensor-core T.gemm backward — REAL HMMA, parity PASS, but THROUGHPUT NO-GO): the new `chunk_scan_combine_bwd_cuda_prim_gemm` emits 128 real `mma.sync.m16n8k16` + 66 `ldmatrix` (F0 codegen form, SASS-verified) and ALL 8 grads pass the 1e-3 gate (WORST dlog_decay 9.77e-4; dD 2.67e-5 honest fp16-cache gold), but the in-process A/B is **0.749× — SLOWER than v1** (v1=1027.5 ms vs gemm=1371.5 ms same box) → B2 stays v1, gated off behind `CPPMEGA_PATH_C_B2_GEMM`. B2's four collapsed contractions are tiny single-64-tile GEMMs whose staging+sync cost exceeds the v1 serial reductions; the real B2 hotspot (3-index dinp) stays threaded in both. The §26 F0 lever does NOT transfer to B2. tok/s UNCHANGED from §17 (the GEMM chain ~559.9 ms would REGRESS vs v1 447.8 ms). §17 (gridded fwd + RE-GRIDDED gridded backward, MEASURED B2 substituted): ≈907 tok/s @8L / ≈298 @28L (gap ≈3.75×/≈11.4×) — B2 re-gridded 2484→334.6 ms (7.42×), chain 447.8 ms < 456 ms numpy bwd, ALL 8 grads pass incl dD 2.48e-5. §18 RE-MEASURED the 3 gap levers: bs4 fwd MEASURED+PASS but tok/s batch-invariant (saturated kernels, gap unchanged ≈3.75×/≈11.4×), bs4 bwd memory-NO-GO (gold harness OOM), fp8 UNRUNNABLE on sm_121 (byte-halving 2.0× MEASURED, speed UNMEASURED), B2 v2 NO-GO (0.997×/1.001×) — §17 headline holds. §21 RE-MEASURED the fp8 levers on the unblocked sm_121: our OWN native fp8-input e4m3 MMA is now VERIFIED real (kFloat8_e4m3 m16n8k32, 0 fp16) and 2.2–3.5× faster than the old fp16-decode but still 0.69–0.95× bf16 (untuned tile → speed NO-GO); TE standalone fp8 GEMM stays the only ~1.7× win; wiring TE fp8 into the e2e step is a memory-NO-GO (fp8 backward OOMs at the MLX↔torch cudaMallocAsync bridge, reproduced to seq=512) — so §17's ≈907/≈298 stays the canonical step number, fp8 e2e tok/s UNMEASURED. Prior: ≈894/≈293 @ §15 (gridded scan + numpy bwd, EXTRAPOLATED); 45.44 @8L MEASURED (§14); 29.82 @ §13, 25.2 pre-§13.** | **3399 tok/s** |
4673
| tok/s ratio vs Megatron | **≈0.27x (8L) / ≈0.088x (28L) with §17 (gridded fwd + gridded bwd) — i.e. ≈3.75x–11.4x slower** (was 75x–289x @ §14, 114x–654x @ §13) | 1.0x |
4774
| what runs the compute | **REAL tilelang path_c-CUDA MR kernel, device-resident fwd (§13) + Korthikanti recompute cache (§14) + GRIDDED CUDA SSD chunked scan replacing the serial MR mamba recurrence (§15, F2 0.980 ms vs serial 6.56 s)** + abstract numpy bwd/adam/loss (lever 4, now the dominant remaining term) | tuned fused-FP8-CUDA kernels + selective recompute |
4875

@@ -1511,6 +1538,18 @@ between, gb10 left idle (0% util, >115 GB free).
15111538

15121539
## 7. Verdict
15131540

1541+
**§27 (B2 tensor-core backward — NO-GO):** The B2 backward GEMM rewrite is a real, parity-correct
1542+
tensor-core kernel (128 `mma.sync.m16n8k16` + 66 `ldmatrix`, SASS-verified, ALL 8 grads pass the
1543+
1e-3 gate incl. dD 2.67e-5 honest fp16-cache) but it is **0.749× — slower than the v1 threaded-serial
1544+
prim** (v1 1027.5 ms vs gemm 1371.5 ms, same box/inputs, in-process A/B). B2's four collapsed
1545+
contractions are tiny single-64-tile m16n8k16 GEMMs whose ldmatrix/smem-staging + four sync barriers
1546+
cost MORE than v1's re-gridded serial reductions, while the true B2 hotspot (the 3-index s-dependent
1547+
`dinp` contraction) stays threaded in BOTH prims — so the §26 F0 14.5× lever does NOT transfer. **The
1548+
backward chain stays v1 (447.8 ms canonical); the GEMM chain would REGRESS to ~559.9 ms. Step tok/s
1549+
UNCHANGED at §17's ≈907 @8L / ≈298 @28L (gap ≈3.75×/≈11.4× vs Megatron 3399).** The GEMM prim is
1550+
committed behind `CPPMEGA_PATH_C_B2_GEMM` (default OFF, RULE #1 the-one-path semantics, RAISES on
1551+
failure) as a documented measured NO-GO; B0 was not converted (same single-tile regression predicted).
1552+
15141553
The Relax graph-path train_step **fits Megatron-class memory (12.998 GB planned 28L
15151554
device-peak vs Megatron 26 GB, optimizer in-place) and runs the full 28-layer 1.8B step the
15161555
eager path OOMs on** — the memory-first goal is MET on device. THREE throughput levers have now

0 commit comments

Comments
 (0)