Skip to content

Commit 4f5d548

Browse files
committed
fix(§SO1): smem-fit batched B2 — HPC=2 LAUNCHES on sm_121 (DYX single-tile + fp16 dY band)
MEASURED gb10 sm_121. The HPC=2 batched B2 GEMM twin previously FAILED TO LAUNCH: static=57344+dynamic=66560=123904 B > per-block opt-in cap 101376 B. Two precision- disciplined reductions to chunk_scan_combine_bwd_cuda_prim_gemm_batched ONLY (v1 + §27 prims byte-identical, flag-gated): 1. DYX (HPC,L,L) fp32 band -> single reused (L,L) tile (fuse dseg into dC_diag head-loop; precision-neutral, atomic_adds commute) -> -16384 B at HPC=2. 2. dY result band fp32 -> fp16 (every consumer downcasts to fp16 MMA operand or scales by fp32; dD uses fp32 local) -> -16384 B. 1e-3 gate is the arbiter. Total ~91136 B < cap => HPC=2 LAUNCHES (no InternalError, kernel runs). MEASURED: batched HPC=2 = 1279 ms = 0.708x vs v1-threaded 905 ms => still NO-GO. SASS-confirmed per-64 MMA in serial head loop (tl::mma_sync 16,8,16; M-loop i<2 INSIDE for hh<2). Tall-M offset-relax is a non-starter: 3/4 B2 GEMMs are block- diagonal (distinct per-head operands), so a dense M=HPC*L MMA computes HPC^2 cross- head garbage; only dC_diag shares per-GROUP B/C. Parity: full 8-grad chain overall_pass=true, worst 5.46e-04 < 1e-3 (incl dD fp16-cache, all elements). z3: ALL_POSITIVES_PROVED_AND_NON_VACUOUS. Metal L=32 retile: sizing+z3 done but sub- chunk body unimplemented (RAISES, Apple-GPU numeric deferred). RULE#1: clear cap- named raise (no silent clamp); prod prims byte-identical; batched stays OFF in prod. Core question RESOLVED: gb10 loads OUR source fork (tilelang+tvm from /home/dave/source/tilelang/...), NOT a pip/stale wheel — libtvm_runtime.so mtime 2026-06-06 02:04, carries the cap-named string. Wrong-tilelang hypothesis falsified. See docs/GB10-SMEM-OFFSET-FIX.md (§SO1).
1 parent b9249bb commit 4f5d548

2 files changed

Lines changed: 196 additions & 29 deletions

File tree

cppmega_mlx/nn/_tilelang/mamba3_chunked_backward_core.py

Lines changed: 54 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2370,8 +2370,24 @@ def main(
23702370
# OFFSET-0 head-sized (L,*) tiles REUSED across the serial head loop
23712371
# (the amortization lever = one staging alloc + band-level syncs shared
23722372
# over HPC heads). The per-head dY/DYX results stay in (HPC,...) bands.
2373-
dY = T.alloc_shared((HPC, L, headdim), accum_dtype)
2374-
DYX = T.alloc_shared((HPC, L, L), accum_dtype)
2373+
# §SO1 smem-fit: the dY result band is the fp16 GEMM-operand value (every
2374+
# consumer — dC_off, dchunk_states, dinp, DYX — downcasts dY to fp16 for an
2375+
# MMA operand or multiplies it by an fp32 scalar). Storing the band as fp16
2376+
# (not fp32) halves it (HPC*L*headdim*2 vs *4 => -16384 B at HPC=2,L=P=64),
2377+
# the SECOND smem-fit lever that brings static+dynamic under the sm_121
2378+
# per-block opt-in cap so HPC=2 LAUNCHES. dD uses the fp32 LOCAL dy_v (not
2379+
# this band) so dD is unaffected; the per-grad 1e-3 parity gate is the hard
2380+
# arbiter (RAISES if the fp16 band loses too much — RULE #1, not a silent
2381+
# downgrade: explicit operand dtype + fail-fast gate).
2382+
dY = T.alloc_shared((HPC, L, headdim), dtype)
2383+
# §SO1 smem-fit: DYX is a per-head intermediate consumed (dC_diag + dseg
2384+
# dA-grad) WITHIN one head iteration once the dseg accumulation is fused
2385+
# into the dC_diag head-loop below — so it is a SINGLE reused (L,L) tile
2386+
# (offset-0), NOT an (HPC,L,L) persistent band. This drops (HPC-1)*L*L*4 B
2387+
# of per-CTA SMEM (16384 B at HPC=2,L=64) so static+dynamic fits the
2388+
# sm_121 per-block opt-in cap (101376 B). Precision-NEUTRAL: same fp32
2389+
# DYX_frag values, same fp32 accumulate — just not held across loops.
2390+
DYX = T.alloc_shared((L, L), accum_dtype)
23752391
dY16 = T.alloc_shared((L, headdim), dtype, scope="shared")
23762392
opA = T.alloc_shared((maxLP, maxLP), dtype, scope="shared")
23772393
opB = T.alloc_shared((maxLP, dstate), dtype, scope="shared")
@@ -2414,7 +2430,7 @@ def main(
24142430
x_v = T.Cast(accum_dtype, x[batch_idx, s, head_idx, pp])
24152431
dx[batch_idx, s, head_idx, pp] = d_v * dy_v
24162432
dD_local[0] = dD_local[0] + dy_v * x_v
2417-
dY[hh, ll, pp] = dy_v
2433+
dY[hh, ll, pp] = T.Cast(dtype, dy_v) # §SO1 fp16 band
24182434
T.atomic_add(dD[head_idx], dD_local[0])
24192435
T.sync_threads()
24202436

@@ -2435,11 +2451,18 @@ def main(
24352451
)
24362452
T.sync_threads()
24372453

2438-
# ---- (A) DYX[hh,l,s] = sum_p dY[hh,l,p]*x[hh,s,p] — per-head GEMM ----
2439-
# Offset-0 staging tiles (dY16/opA) reused across the head loop; the
2440-
# staging region + the single trailing sync are shared over HPC heads.
2454+
# ---- (A) DYX build + (B) dC_off + (C) dC_diag + dseg dA-grad, per head ----
2455+
# §SO1 FUSED head-loop: DYX[l,s] = dY@x^T is built into the SINGLE reused
2456+
# (L,L) DYX tile at the TOP of each head iteration, then consumed by the
2457+
# dC_diag masked operand AND the dseg dA-grad accumulation BEFORE the next
2458+
# head overwrites it. This lets DYX be one tile (not an HPC band) — the
2459+
# SMEM-fit lever. The dseg atomic_add into dAcs_acc is commutative with the
2460+
# dC_diag atomic_add already here, so the fusion is order-safe (RULE #1:
2461+
# same math, no fallback). Offset-0 staging tiles (dY16/opA/opB) reused.
24412462
for hh in T.serial(HPC):
24422463
head_idx = hb * HPC + hh
2464+
group_idx = head_idx // heads_per_group
2465+
# (A) DYX[l,s] = sum_p dY[l,p]*x[s,p] -> single reused (L,L) tile
24432466
for lp in T.Parallel(L * headdim):
24442467
ll = lp // headdim
24452468
pp = lp % headdim
@@ -2455,13 +2478,8 @@ def main(
24552478
DYX_frag,
24562479
transpose_B=True,
24572480
)
2458-
T.copy(DYX_frag, DYX[hh, 0:L, 0:L])
2481+
T.copy(DYX_frag, DYX)
24592482
T.sync_threads()
2460-
2461-
# ---- (B) dC_off + (C) dC_diag + dC store, per head ----
2462-
for hh in T.serial(HPC):
2463-
head_idx = hb * HPC + hh
2464-
group_idx = head_idx // heads_per_group
24652483
# (B) dC_off[l,n] = sum_p dY[l,p]*prev_states[p,n] (offset-0 staging)
24662484
for lp in T.Parallel(L * headdim):
24672485
ll = lp // headdim
@@ -2489,7 +2507,7 @@ def main(
24892507
ss = ls % L
24902508
lmat = T.exp2((dacs[hh, ll] - dacs[hh, ss]) * p)
24912509
dt_s = T.Cast(accum_dtype, dt[batch_idx, head_idx, chunk_idx, ss])
2492-
m_val = DYX[hh, ll, ss] * lmat * dt_s
2510+
m_val = DYX[ll, ss] * lmat * dt_s
24932511
opA[ll, ss] = T.Cast(
24942512
dtype, T.if_then_else(ss <= ll, m_val, T.Cast(accum_dtype, 0))
24952513
)
@@ -2521,6 +2539,25 @@ def main(
25212539
c_v = T.Cast(accum_dtype, C[batch_idx, s, group_idx, nn])
25222540
T.atomic_add(dAcs_acc[hh, ll], store_fp32[ll, nn] * c_v * sd)
25232541
T.sync_threads()
2542+
# §SO1 FUSED dseg dA-grad: consume DYX[l,s] for THIS head before the
2543+
# next iteration overwrites the single DYX tile. Same math as the old
2544+
# standalone dseg loop; atomic_add into dAcs_acc is commutative with the
2545+
# dC_diag atomic_add above (RULE #1: identical result, no reordering bug).
2546+
for ls in T.Parallel(L * L):
2547+
ll = ls // L
2548+
ss = ls % L
2549+
cb_v = T.Cast(
2550+
accum_dtype, cb[batch_idx, chunk_idx, group_idx, ll, ss]
2551+
)
2552+
lmat = T.exp2((dacs[hh, ll] - dacs[hh, ss]) * p)
2553+
dt_s = T.Cast(accum_dtype, dt[batch_idx, head_idx, chunk_idx, ss])
2554+
tri = T.if_then_else(
2555+
ss < ll, T.Cast(accum_dtype, 1), T.Cast(accum_dtype, 0)
2556+
)
2557+
dseg = DYX[ll, ss] * cb_v * lmat * dt_s * tri
2558+
T.atomic_add(dAcs_acc[hh, ll], dseg)
2559+
T.atomic_add(dAcs_acc[hh, ss], -dseg)
2560+
T.sync_threads()
25242561

25252562
# ---- (D) dchunk_states[p,n] = sum_l (dY[l,p]*sd[l])*C[l,n], per head ----
25262563
for hh in T.serial(HPC):
@@ -2554,7 +2591,10 @@ def main(
25542591
)
25552592
T.sync_threads()
25562593

2557-
# ---- dinp (3-index, STAYS THREADED) + dseg dA-grad, per head ----
2594+
# ---- dinp (3-index, STAYS THREADED), per head ----
2595+
# §SO1: the dseg dA-grad that USED to be here was fused up into the
2596+
# dC_diag head-loop (so DYX could shrink to one (L,L) tile). dinp reads
2597+
# only dY (still a band) + C, so it stays a separate threaded loop.
25582598
for hh in T.serial(HPC):
25592599
head_idx = hb * HPC + hh
25602600
group_idx = head_idx // heads_per_group
@@ -2578,21 +2618,6 @@ def main(
25782618
dinp[batch_idx, sidx, head_idx, pp, nn] + acc[0]
25792619
)
25802620
T.sync_threads()
2581-
for ls in T.Parallel(L * L):
2582-
ll = ls // L
2583-
ss = ls % L
2584-
cb_v = T.Cast(
2585-
accum_dtype, cb[batch_idx, chunk_idx, group_idx, ll, ss]
2586-
)
2587-
lmat = T.exp2((dacs[hh, ll] - dacs[hh, ss]) * p)
2588-
dt_s = T.Cast(accum_dtype, dt[batch_idx, head_idx, chunk_idx, ss])
2589-
tri = T.if_then_else(
2590-
ss < ll, T.Cast(accum_dtype, 1), T.Cast(accum_dtype, 0)
2591-
)
2592-
dseg = DYX[hh, ll, ss] * cb_v * lmat * dt_s * tri
2593-
T.atomic_add(dAcs_acc[hh, ll], dseg)
2594-
T.atomic_add(dAcs_acc[hh, ss], -dseg)
2595-
T.sync_threads()
25962621

25972622
for hl in T.Parallel(HPC * L):
25982623
hh = hl // L

docs/GB10-SMEM-OFFSET-FIX.md

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
# GB10 SMEM / Offset Fix — §SO1
2+
3+
MEASURED on NVIDIA GB10 DGX-Spark (sm_121, Grace-Blackwell, unified 121 GB),
4+
2026-06-06. All GPU execution gb10-only (Mac SoC-watchdog safety: no local Metal
5+
GPU dispatch this run). Config: bs=1 S=4096 chunk=64 G=8 H=112 P=64 N=64.
6+
7+
## §SO0 — Core question: which tilelang/tvm does gb10 ACTUALLY load?
8+
9+
The user asked: does the gb10 `path_c` env load OUR source fork (with the fix) or a
10+
pip/stale wheel (without it)? **ANSWER: it loads OUR source fork. Confirmed live.**
11+
12+
The cppmega_mlx probes run with:
13+
```
14+
PYTHONPATH=/home/dave/source/cppmega_mlx:/home/dave/source/tilelang:\
15+
/home/dave/source/tilelang/3rdparty/tvm/python:\
16+
/home/dave/source/tilelang/3rdparty/tvm/3rdparty/tvm-ffi/python
17+
TVM_LIBRARY_PATH=/home/dave/source/tilelang/build/lib
18+
/home/dave/cppmega-venv/bin/python ...
19+
```
20+
Resolved live:
21+
- `tvm` -> `/home/dave/source/tilelang/3rdparty/tvm/python/tvm/__init__.py` (OUR fork)
22+
- `tilelang` -> `/home/dave/source/tilelang/tilelang/__init__.py` (OUR fork)
23+
- `libtvm_runtime.so` -> `/home/dave/source/tilelang/build/lib/` (dev root),
24+
mtime **2026-06-06 02:04:25**, and it CARRIES the new RULE#1 cap string
25+
`"... B of shared memory, which exceeds the device per-block opt-in cap of ..."`.
26+
The old opaque `"Failed to set the allowed dynamic shared memory size to"` is
27+
retained as the second-tier raise.
28+
29+
There is NO pip `tilelang`/`tvm`/`apache-tvm` installed in the venv. The "wrong
30+
tilelang" hypothesis is **falsified** — gb10 runs the fixed fork.
31+
32+
## §SO1 — What gb10 was using, the diagnosis, the fix, measured deltas
33+
34+
### The original blocker (reproduced live, HPC=2)
35+
`chunk_scan_combine_bwd_cuda_prim_gemm_batched` at HEADS_PER_CTA=2 (the amortization
36+
lever) FAILED TO LAUNCH. With the rebuilt fork the failure is now a CLEAR named-cap
37+
raise (was an opaque driver error):
38+
```
39+
tvm.error.InternalError: Kernel 'main_kernel' requires static=57344 + dynamic=66560
40+
= 123904 B of shared memory, which exceeds the device per-block opt-in cap of
41+
101376 B (CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK_OPTIN). ... (RULE#1 fail-fast.)
42+
```
43+
**Diagnosis (b) CONFIRMED:** static (57344) + dynamic (66560) = 123904 B overflows the
44+
sm_121 per-block opt-in cap (101376 B live). The request is GENUINELY too big — it is
45+
NOT a missing opt-in (the cuFuncSetAttribute opt-in was always attempted) and NOT a
46+
carveout issue. Moving the 5 static `__shared__` tiles to `scope="shared.dyn"` does
47+
NOT cure it (same total). HPC=4 does not even build for H=112/G=8 (heads_per_group=14;
48+
4 neither divides nor is a multiple of 14 — a clean config raise, not a smem issue).
49+
50+
### The runtime fix (tvm cuda_module.cc:212-246)
51+
RULE#1 static-aware check: query `CU_FUNC_ATTRIBUTE_SHARED_SIZE_BYTES` (static) +
52+
`CU_DEVICE_ATTRIBUTE_MAX_SHARED_MEMORY_PER_BLOCK_OPTIN` (cap) and RAISE naming
53+
static+dynamic+cap BEFORE the opaque driver error. No silent clamp. (Already in the
54+
loaded binary; verified.)
55+
56+
### The smem-fit kernel reductions (§SO1, this run) — make HPC>=2 LAUNCH
57+
Two precision-disciplined reductions to `chunk_scan_combine_bwd_cuda_prim_gemm_batched`
58+
ONLY (v1-threaded + §27 single-tile prims byte-identical — flag-gated, untouched):
59+
60+
1. **DYX band -> single reused (L,L) tile.** DYX is a per-head intermediate consumed by
61+
dC_diag AND the dseg dA-grad. Fusing the dseg accumulation UP into the dC_diag
62+
head-loop lets DYX be one (L,L) tile, not an (HPC,L,L) band. Precision-NEUTRAL (same
63+
fp32 DYX_frag, same fp32 atomic_add; dseg/dC_diag atomic_adds commute). Saves
64+
(HPC-1)*L*L*4 = 16384 B at HPC=2. dynamic 66560 -> 50176, total 107520 B (still over).
65+
66+
2. **dY result band fp32 -> fp16.** Every consumer downcasts dY to an fp16 MMA operand
67+
or multiplies it by an fp32 scalar (dC_off, dchunk, dinp, DYX). dD uses the fp32
68+
LOCAL dy_v (not the band) so dD is unaffected. Saves HPC*L*P*2 = 16384 B at HPC=2.
69+
The per-grad 1e-3 gate is the hard arbiter (RULE#1: explicit operand dtype + fail-
70+
fast gate, NOT a silent downgrade). dynamic 50176 -> 33792, total ~91136 B < cap.
71+
72+
**RESULT: HPC=2 NOW LAUNCHES** (no InternalError; kernel runs; smem ~91 KB < 101376 cap).
73+
74+
### MEASURED deltas (prod shape S=4096 H=112, B2-AB probe)
75+
| variant | ms | vs v1 (905.2 ms) | vs §27 (1231 ms) |
76+
|---------------------------------|---------|------------------|------------------|
77+
| v1_threaded (prod prim) | 905.2 | 1.000x ||
78+
| §27 single-tile GEMM | 1231.1 | 0.735x | 1.000x |
79+
| batched HPC=1 (pre-fit) | 1250.0 | 0.724x | 0.985x |
80+
| **batched HPC=2 (smem-fit, A)** | **1279.3** | **0.708x** | 0.962x |
81+
82+
Small-shape full-chain probe (S=256/512, H=2): batched HPC=2 = 0.671x / 0.727x vs v1;
83+
at S=512 batched (9.15 ms) beats §27 single-tile (13.28 ms) at **1.452x** — the batching
84+
DOES help vs §27, but neither beats v1-threaded.
85+
86+
### (B) Tall-M (offset-relax) — NOT a valid single large MMA here
87+
`gemm_op.py` carries the `allow_first_dim_offset` relax (gated; prod prims never set it,
88+
stay byte-identical) and z3 proves the tall-M offset-band map (bands @ 0/64/128/192
89+
tile-aligned + disjoint; misaligned offset=65 rejected). BUT the four B2 contractions are
90+
**block-diagonal over heads**: DYX (dY@x^T), dC_off (dY@prev_states), dchunk ((decay*dY)^T@C)
91+
have per-head DISTINCT operands on BOTH sides — a dense tall M=HPC*L MMA would compute
92+
HPC^2 cross-head GARBAGE blocks. Only dC_diag/dseg share a per-GROUP operand (B/C), so a
93+
genuine tall-M is valid for AT MOST 1 of 4 GEMMs and only across same-group heads. Since
94+
smem-fit-only is already 0.708x, a tall-M on 1/4 GEMMs cannot flip it to >1.0x. **SASS
95+
confirms the launched HPC=2 kernel is per-64 MMA in a serial head loop** (`tl::mma_sync<
96+
...,16,8,16,...>` with M-loop `i<2` INSIDE `for hh<2` — two M=64 waves per CTA, NOT one
97+
M=128 wave). The tall-M lever is therefore a NON-STARTER for this kernel's algebra.
98+
99+
### Parity (RULE#1 gate, RAISES on miss)
100+
- B2-AB (7 grads, prod shape): worst max|abs| = **2.04e-05** < 1e-3. All pass.
101+
- Full 8-grad chain probe (--b2-gemm-ab, dD fp16-cache, ALL elements): **overall_pass=true**,
102+
worst = 5.46e-04 < 1e-3 (dz/dx/dC/dB/dlog_decay/ddt/dh0/dD). The fp16 dY band + DYX
103+
fusion did NOT break end-to-end parity.
104+
105+
### z3 proof (re-run)
106+
`scratch/proof_b2_batched_driver.py` -> **ALL_POSITIVES_PROVED_AND_NON_VACUOUS**: dchunk
107+
+ tallM_offset_band positives proved; negatives rejected (overlap_band single-writer fail,
108+
transpose_bug counter-witness, tallM_misaligned_offset @65, metal dropped-carry sat).
109+
110+
## §SO1-Metal — L=32 sub-chunk retile (codegen status, NUMERIC DEFERRED)
111+
Lowering the Metal batched prim at sub_chunks=2 (L_sub=32) **RAISES NotImplementedError**:
112+
the sub-chunk-loop kernel BODY (inter-sub-chunk state carry) is GATED pending Apple-GPU
113+
numeric validation (watchdog safety — no Apple GPU exec this run). Only the SIZING +
114+
z3 associativity are done, so MSL/simdgroup-MMA emission **cannot be verified** (no
115+
lowerable kernel). MEASURED sizing (`_metal_subchunk_smem_bytes`, Apple cap 32768 B):
116+
117+
| HPC | sub | L_sub | all_static | store_dynamic | fits 32KB? |
118+
|-----|-----|-------|-----------|---------------|------------|
119+
| 1 | 1 | 64 | 57856 | 41472 | no / no |
120+
| 1 | 2 | 32 | 43264 | **26880** | no / **YES** (store_f32 -> dynamic) |
121+
| 1 | 4 | 16 | 37504 | 21120 | no / YES |
122+
| 2 | 2 | 32 | 49664 | 33280 | no / no (512 B over) |
123+
| 2 | 4 | 16 | 40192 | 23808 | no / YES |
124+
125+
So the L=32 retile fits Apple's 32 KB ONLY at HPC=1 with store_f32 moved to
126+
threadgroup-dynamic (26880 B). The L=32-alone all-static layout never fits (43264 B).
127+
The prior "4.99x usable at prod" is NOT realizable this run: the sub-chunk body is
128+
unimplemented and Apple-GPU numeric is deferred.
129+
130+
## VERDICT — honest NO-GO with WHY
131+
The §SO1 smem-fit fix achieves the stated mechanical goal: **HPC=2 now LAUNCHES** on
132+
sm_121 (66560B/123904B no longer rejected; clear named-cap raise replaces the opaque
133+
error; no silent clamp). All 8 grads pass the 1e-3 gate. BUT the amortization is **still
134+
NO-GO**: batched HPC=2 = 1279 ms = **0.708x vs v1-threaded 905 ms**. ROOT CAUSE: the
135+
gemm_op.py offset-0 staging forces per-head M=64 MMAs in a serial head loop (SASS-
136+
confirmed), and the four contractions are block-diagonal so a genuine tall-M band MMA is
137+
mathematically invalid for 3/4 GEMMs — the staging/sync amortization over HPC heads does
138+
not recover the per-64-tile tensor-core setup cost. v1-threaded remains the prod floor;
139+
the Tri-Dao ~10 ms gap is NOT closed by this rewrite. The batched B2 stays flag-gated and
140+
OFF in prod (prod prims byte-identical), so the prod backward chain / step tok/s are
141+
UNCHANGED from the v1 baseline (447.8 ms chain reference; batched would make the chain
142+
WORSE, so it must not be wired in).

0 commit comments

Comments
 (0)