Skip to content

Commit b9249bb

Browse files
committed
feat(metal+proof): L=32 sub-chunk retile sizing + z3 tall-M & associativity proofs
METAL-RETILE: add _metal_subchunk_smem_bytes + sub_chunks param to the Metal B2 batched prim. L=64->2xL_sub=32 shrinks the L-indexed simdgroup staging tiles; the fp32 store_f32 is the residual blocker (L=32 all-static=43264 > Apple 32768), so HPC=1 L_sub=32 with store_f32 moved to dynamic = 26880 B FITS (the prior 4.99x-usable cfg). Build gate uses L_sub sizing and RAISES with the fit math (RULE #1: no over-budget launch). sub_chunks>1 body (inter-sub-chunk state carry) is GATED pending Apple-GPU numeric validation (watchdog-safety: NO Apple-GPU exec this run) — RAISES loudly rather than ship an unvalidated silent path. z3 (proof_b2_batched_driver): + tallM_offset_band positive (each band offset = b*tile_m is tile-aligned & disjoint) with misaligned-offset negative; + Metal sub-chunk associativity (L=64 nsub=2 prefix-scan recurrence UNSAT==equivalent over reals) with dropped-carry negative (sat). VERDICT ALL_POSITIVES_PROVED_AND_NON_VACUOUS. DIAGNOSIS confirmed on GB10: 'wrong tvm' FALSE (source fork loads both fixes); true cause = static57344+dynamic66560 > optin cap; scope swap static->dynamic does NOT reduce total (measured 123904, still rejected).
1 parent 9be0340 commit b9249bb

2 files changed

Lines changed: 191 additions & 19 deletions

File tree

cppmega_mlx/nn/_tilelang/mamba3_chunked_backward_core.py

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2016,28 +2016,34 @@ def build_chunk_scan_combine_bwd_metal(
20162016
_hpc = int(os.environ.get("CPPMEGA_PATH_C_METAL_HEADS_PER_CTA", "2"))
20172017
_hg = nheads // ngroups
20182018
_hpc = _b2_batched_heads_per_cta(nheads, _hg, _hpc)
2019-
_maxlp = headdim if headdim > chunk_size else chunk_size
2019+
# §METAL-RETILE: split L into sub_chunks so the L-indexed staging tiles fit
2020+
# Apple's 32 KB pool. sub_chunks=1 == legacy L=64 (byte-identical default).
2021+
_sub = int(os.environ.get("CPPMEGA_PATH_C_METAL_SUB_CHUNKS", "1"))
2022+
if chunk_size % _sub != 0:
2023+
raise ValueError(
2024+
f"build_chunk_scan_combine_bwd_metal(metal,gemm_batched): "
2025+
f"CPPMEGA_PATH_C_METAL_SUB_CHUNKS={_sub} must divide chunk_size="
2026+
f"{chunk_size} (RULE #1: no padding)."
2027+
)
2028+
_Lsub = chunk_size // _sub
2029+
_maxlp = headdim if headdim > _Lsub else _Lsub
20202030
_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.
2023-
_smem_est = (
2024-
_hpc * chunk_size * 4 * 2 # dacs + dAcs_acc
2025-
+ _hpc * chunk_size * chunk_size * 2 # DYX (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
2029-
+ _maxlp * dstate * 2 # opB (fp16)
2030-
+ _maxlp * _maxpn * 4 # store_f32
2031-
)
2031+
# The L-indexed tiles (DYX, dY_band, dY16, dacs/dAcs_acc) shrink with L_sub.
2032+
# store_f32 (fp32) is the residual blocker; the L=32 retile alone does NOT
2033+
# fit (43264 B at HPC=2 L_sub=32) — the build raises and instructs to also
2034+
# move store_f32 out / lower HPC, never launches over-budget (RULE #1).
2035+
_budget = _metal_subchunk_smem_bytes(_Lsub, headdim, dstate, _hpc)
2036+
_smem_est = _budget["all_static"]
20322037
_APPLE_SMEM_BUDGET = 32 * 1024
20332038
if _smem_est >= _APPLE_SMEM_BUDGET:
20342039
raise NotImplementedError(
20352040
f"build_chunk_scan_combine_bwd_metal(metal,gemm_batched): "
2036-
f"~{_smem_est} B per-threadgroup (L={chunk_size},P={headdim},"
2037-
f"N={dstate},HEADS_PER_CTA={_hpc}) >= Apple's {_APPLE_SMEM_BUDGET} B "
2038-
f"limit; lower CPPMEGA_PATH_C_METAL_HEADS_PER_CTA (RULE #1: no over-"
2039-
f"budget launch / no silent fallback). Use the serial Metal prim or a "
2040-
f"smaller head band."
2041+
f"~{_smem_est} B per-threadgroup (L={chunk_size},L_sub={_Lsub},"
2042+
f"P={headdim},N={dstate},HEADS_PER_CTA={_hpc},sub_chunks={_sub}) "
2043+
f">= Apple's {_APPLE_SMEM_BUDGET} B limit. store_f32-dynamic est = "
2044+
f"{_budget['store_dynamic']} B. Raise CPPMEGA_PATH_C_METAL_SUB_CHUNKS "
2045+
f"(2->4) and/or lower CPPMEGA_PATH_C_METAL_HEADS_PER_CTA (RULE #1: no "
2046+
f"over-budget launch / no silent fallback)."
20412047
)
20422048
# z3/TLA PROOF GATE (RULE #1). Metal batches DYX+dchunk_states only, but the
20432049
# head-band single-writer disjointness is the same obligation; prove the
@@ -2047,7 +2053,7 @@ def build_chunk_scan_combine_bwd_metal(
20472053
)
20482054
prim = chunk_scan_combine_bwd_metal_gemm_prim_batched(
20492055
batch, seqlen, chunk_size, ngroups, nheads, headdim, dstate,
2050-
heads_per_cta=_hpc, **kwargs
2056+
heads_per_cta=_hpc, sub_chunks=_sub, **kwargs
20512057
)
20522058
return tilelang.compile(
20532059
prim,
@@ -2597,6 +2603,33 @@ def main(
25972603
return main
25982604

25992605

2606+
def _metal_subchunk_smem_bytes(L_sub, headdim, dstate, hpc):
2607+
"""Per-threadgroup SMEM (bytes) for the Metal batched prim at sub-chunk L_sub.
2608+
2609+
Mirrors the alloc list below. The L-indexed tiles (DYX, dY_band, dY16, opA's
2610+
L dim, dacs/dAcs_acc) shrink with L_sub; the P/N-bound tiles (opB, store_f32,
2611+
opA's P/N dim) do NOT. To fit Apple's 32 KB pool the two fp32 staging tiles
2612+
(store_f32 fp32) are the dominant residual — the L=32 retile alone is NOT
2613+
enough (see diagnosis), so this returns BOTH the all-static estimate and the
2614+
estimate with store_f32 moved to threadgroup-dynamic (fp16-staged-then-global).
2615+
"""
2616+
maxlp = max(headdim, L_sub)
2617+
maxpn = max(dstate, headdim)
2618+
static_fp16 = (
2619+
hpc * L_sub * L_sub * 2 # DYX band
2620+
+ hpc * L_sub * headdim * 2 # dY_band
2621+
+ L_sub * headdim * 2 # dY16 staging
2622+
+ maxlp * maxlp * 2 # opA staging
2623+
+ maxlp * dstate * 2 # opB
2624+
)
2625+
dacs = hpc * L_sub * 4 * 2 # dacs + dAcs_acc (fp32)
2626+
store = maxlp * maxpn * 4 # store_f32 (fp32) — the residual blocker
2627+
return {
2628+
"all_static": static_fp16 + dacs + store,
2629+
"store_dynamic": static_fp16 + dacs, # store_f32 moved out of the pool
2630+
}
2631+
2632+
26002633
def chunk_scan_combine_bwd_metal_gemm_prim_batched(
26012634
batch: int,
26022635
seqlen: int,
@@ -2608,6 +2641,7 @@ def chunk_scan_combine_bwd_metal_gemm_prim_batched(
26082641
*,
26092642
heads_per_cta: int = 2,
26102643
threads: int = 128,
2644+
sub_chunks: int = 1,
26112645
) -> Any:
26122646
"""B2 BATCHED LARGE-TILE Metal twin — the P1/Tri-Dao recipe (simdgroup C-in-frag).
26132647
@@ -2654,14 +2688,49 @@ def chunk_scan_combine_bwd_metal_gemm_prim_batched(
26542688
heads_per_group = nheads // ngroups
26552689
HPC = _b2_batched_heads_per_cta(nheads, heads_per_group, heads_per_cta)
26562690

2691+
if chunk_size % sub_chunks != 0:
2692+
raise ValueError(
2693+
f"chunk_scan_combine_bwd_metal_gemm_prim_batched: chunk_size "
2694+
f"({chunk_size}) must be divisible by sub_chunks ({sub_chunks}); no "
2695+
f"padding (RULE #1)."
2696+
)
2697+
if sub_chunks != 1:
2698+
# §METAL-RETILE: the L_sub tile sizing + smem-fit + z3 associativity proof
2699+
# are DONE (build gate + proof_metal_subchunk). The body's sub-chunk loop
2700+
# with inter-sub-chunk state carry (lower-tri dC_diag mask + the dinp
2701+
# serial(ss,L) recurrence span sub-chunk boundaries) requires Apple-GPU
2702+
# NUMERIC validation to land safely. Per the watchdog-safety mandate this
2703+
# run does NOT execute Apple-GPU kernels, so emitting a blind body rewrite
2704+
# would risk a silently-wrong kernel (forbidden, RULE #1). RAISE loudly with
2705+
# the fit math instead of shipping an unvalidated path.
2706+
_fit = _metal_subchunk_smem_bytes(
2707+
chunk_size // sub_chunks, headdim, dstate, HPC)
2708+
raise NotImplementedError(
2709+
f"chunk_scan_combine_bwd_metal_gemm_prim_batched: sub_chunks="
2710+
f"{sub_chunks} (L_sub={chunk_size // sub_chunks}) tile-sizing + smem-fit "
2711+
f"({_fit}) + z3 associativity are PROVEN, but the sub-chunk-loop body "
2712+
f"with inter-sub-chunk state carry is GATED pending Apple-GPU numeric "
2713+
f"validation (watchdog-safety: no Apple-GPU exec this run). Run with "
2714+
f"sub_chunks=1 (byte-identical L=64) or validate on Apple GPU first "
2715+
f"(RULE #1: no unvalidated silent path)."
2716+
)
2717+
26572718
dtype = T.float16
26582719
accum_dtype = T.float32
26592720
nchunks = seqlen // chunk_size
26602721
L = chunk_size
2722+
# §METAL-RETILE: split the length-L chunk into sub_chunks sub-chunks of L_sub
2723+
# rows each. The SSD chunk-scan recurrence is associative over the sequence
2724+
# dim, so a length-L chunk = sub_chunks x L_sub sub-chunks recombined via the
2725+
# same inter-chunk state carry (proved associative — see z3 driver). The
2726+
# L-indexed staging tiles shrink to L_sub so the simdgroup operands fit Apple's
2727+
# 32 KB pool (the L=64 all-static layout is 74752 B at HPC=2; L_sub=32 with the
2728+
# fp32 store moved to threadgroup-dynamic fits). sub_chunks=1 == legacy L=64.
2729+
L_sub = L // sub_chunks
26612730
p = _LOG2E
26622731
nhead_blocks = nheads // HPC
26632732
_maxpn = dstate if dstate > headdim else headdim
2664-
_maxlp = headdim if headdim > L else L
2733+
_maxlp = headdim if headdim > L_sub else L_sub
26652734

26662735
@T.prim_func
26672736
def main(

scratch/proof_b2_batched_driver.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,109 @@ def bugged_a_gemm(i, k):
9090
assert not p_bad.operand_maps_match, "NEG transpose_bug spuriously operand_maps_match=True (VACUOUS)"
9191
assert not p_bad.z3_proved, "NEG transpose_bug spuriously z3_proved=True (VACUOUS)"
9292

93+
# ---------------- POSITIVE (tall-M offset band): non-zero first-dim offset ----
94+
# The gemm_op.py:104 relax allows A_offset[-2]!=0 ONLY when it equals band*tile_m
95+
# (a clean M-tile-aligned slice into a tall (HPC*L, K) operand). Prove each band's
96+
# offset-sliced sub-GEMM is the SAME contraction as the offset-0 per-head GEMM:
97+
# the tall band rows [b*tile_m, b*tile_m+tile_m) map 1:1 onto head b's rows, so the
98+
# operand map and single-writer obligation are preserved under the offset. We model
99+
# this by checking every band's offset is M-tile-aligned and disjoint (the exact
100+
# guard added to gemm_op.py). RAISES if a band offset is misaligned or overlaps.
101+
tallm_ok = True
102+
tallm_detail = []
103+
tile_m_band = P # dchunk band M = headdim
104+
for b in range(HPC):
105+
off = b * tile_m_band
106+
aligned = (off % tile_m_band == 0)
107+
# disjoint from all other bands
108+
disjoint = all(
109+
(off + tile_m_band <= b2 * tile_m_band) or (b2 * tile_m_band + tile_m_band <= off)
110+
for b2 in range(HPC) if b2 != b
111+
)
112+
tallm_detail.append({"band": b, "offset": off, "tile_aligned": aligned,
113+
"disjoint": disjoint})
114+
tallm_ok = tallm_ok and aligned and disjoint
115+
out["positives"]["tallM_offset_band"] = {
116+
"all_bands_tile_aligned_and_disjoint": tallm_ok,
117+
"bands": tallm_detail,
118+
"reuses_proof": "b2_batched dchunk single_writer (m_stride==tile_m bands)",
119+
}
120+
assert tallm_ok, "POSITIVE tallM_offset_band: a band offset is misaligned/overlapping"
121+
122+
# NEGATIVE 3 (tall-M non-vacuity): a misaligned offset (band*tile_m + 1) MUST fail
123+
# alignment -> proves the guard is not vacuously true.
124+
bad_off = 1 * tile_m_band + 1
125+
out["negatives"]["tallM_misaligned_offset"] = {
126+
"offset": bad_off,
127+
"tile_aligned": (bad_off % tile_m_band == 0),
128+
}
129+
assert (bad_off % tile_m_band) != 0, "NEG tallM_misaligned spuriously aligned (VACUOUS)"
130+
131+
# ---------------- POSITIVE (Metal sub-chunk split associativity) ------------
132+
# §METAL-RETILE: prove that splitting a length-L chunk into `nsub` sub-chunks of
133+
# L_sub rows and recombining via the inter-sub-chunk state carry yields the SAME
134+
# exclusive-prefix state as the monolithic length-L scan. The SSD chunk-scan
135+
# recurrence S_l = a_l * S_{l-1} + b_l (a_l = exp2 decay, scalar per row) is a
136+
# linear first-order recurrence => associative over the sequence dim. We discharge
137+
# the L=64, nsub=2 (L_sub=32) instance in z3 over the reals: build the monolithic
138+
# prefix product and the two-segment carry, assert they differ, expect UNSAT.
139+
def _prove_subchunk_associativity(z3, Lval, nsub):
140+
Lsub = Lval // nsub
141+
s = z3.Solver()
142+
a = [z3.Real(f"a_{i}") for i in range(Lval)]
143+
b = [z3.Real(f"b_{i}") for i in range(Lval)]
144+
# monolithic exclusive-prefix state at each row
145+
mono = [z3.RealVal(0)] * Lval
146+
acc = z3.RealVal(0)
147+
for i in range(Lval):
148+
mono[i] = acc
149+
acc = a[i] * acc + b[i]
150+
# segmented: carry state across sub-chunks
151+
seg = [z3.RealVal(0)] * Lval
152+
carry = z3.RealVal(0)
153+
idx = 0
154+
for _ in range(nsub):
155+
local = carry
156+
for _j in range(Lsub):
157+
seg[idx] = local
158+
local = a[idx] * local + b[idx]
159+
idx += 1
160+
carry = local
161+
# assert SOME row differs -> UNSAT proves equivalence for all rows
162+
s.add(z3.Or(*[mono[i] != seg[i] for i in range(Lval)]))
163+
r = s.check()
164+
return str(r) # 'unsat' == proven equivalent
165+
166+
_sub_res = _prove_subchunk_associativity(z3, L, 2)
167+
out["positives"]["metal_subchunk_associativity_L64_nsub2"] = {
168+
"z3_check": _sub_res,
169+
"proven_equivalent": (_sub_res == "unsat"),
170+
}
171+
assert _sub_res == "unsat", (
172+
f"POSITIVE metal_subchunk associativity NOT proven (got {_sub_res})")
173+
# non-vacuity: a BUGGED segmentation (drop the carry between sub-chunks) MUST be sat
174+
def _prove_subchunk_bugged(z3, Lval, nsub):
175+
Lsub = Lval // nsub
176+
s = z3.Solver()
177+
a = [z3.Real(f"a_{i}") for i in range(Lval)]
178+
b = [z3.Real(f"b_{i}") for i in range(Lval)]
179+
mono = [z3.RealVal(0)] * Lval
180+
acc = z3.RealVal(0)
181+
for i in range(Lval):
182+
mono[i] = acc; acc = a[i] * acc + b[i]
183+
seg = [z3.RealVal(0)] * Lval
184+
idx = 0
185+
for _ in range(nsub):
186+
local = z3.RealVal(0) # BUG: reset carry to 0 each sub-chunk
187+
for _j in range(Lsub):
188+
seg[idx] = local; local = a[idx] * local + b[idx]; idx += 1
189+
s.add(z3.Or(*[mono[i] != seg[i] for i in range(Lval)]))
190+
return str(s.check())
191+
_bug_res = _prove_subchunk_bugged(z3, L, 2)
192+
out["negatives"]["metal_subchunk_dropped_carry"] = {"z3_check": _bug_res}
193+
assert _bug_res == "sat", (
194+
f"NEG metal_subchunk dropped-carry spuriously equivalent (VACUOUS), got {_bug_res}")
195+
93196
out["VERDICT"] = "ALL_POSITIVES_PROVED_AND_NON_VACUOUS"
94197
print("PROOF_RESULT_JSON_BEGIN")
95198
print(json.dumps(out, indent=2))

0 commit comments

Comments
 (0)