Skip to content

Commit 28c1364

Browse files
committed
docs(§M1): Apple-Metal GEMM rewrite + auto-GEMMify pass + z3 proof — MEASURED
Track A F0 serial->T.gemm(simdgroup) MEASURED on the Apple GPU (M4 Max): cb bit-exact (0.0), summary_states 2.1e-6 vs serial (gate 5e-4), 8.20x (0.1574ms->0.0192ms). Codegen emits 2x simdgroup_multiply_accumulate + 128x make_filled_simdgroup (real tensor-op matmul); serial prim has 0 of each. B2/B0 GEMM flag RAISES (over Apple 32KB threadgroup / not a matmul); serial stays the path. Track B AutoGemmifyReductions auto-detects+infers-transpose+z3-proves the serial-reduction contraction from raw TIR (14/14 tests); in-place T.gemm splice honestly DEFERRED to the builder (_emit_gemm_for_match->None). Track C z3 prover: 13/13, correct rewrites proved, wrong rewrites (transpose, dropped k-tile, overlapping tiles, mask off-by-one, scale off-by-one) REFUSED with a counter-witness (non-vacuous). No C++ rebuild; live build untouched.
1 parent 949042b commit 28c1364

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Apple-Metal GEMM rewrite + auto-GEMMify pass + built-in z3 proof
2+
3+
§M1 — three tracks, MEASURED on this Apple-Silicon Mac (M4 Max, macOS arm64,
4+
MLX 0.31.1 metal=True, tilelang built+loadable from
5+
`/Volumes/external/sources/tilelang/build`). This work ran ENTIRELY LOCALLY on
6+
the Apple GPU — no gb10, no extrapolation for Track A timings.
7+
8+
Environment of record: `/Volumes/external/sources/cppmega.mlx/.venv/bin/python`
9+
(CPython 3.13.12), z3 4.15.4, tilelang `0.1.9+git5b30bb10`.
10+
11+
---
12+
13+
## Track A — Metal-GEMM rewrite of F0/B2/B0 Metal prims (hand A)
14+
15+
Flag: `CPPMEGA_PATH_C_METAL_GEMM`. The serial Metal prim is kept BYTE-IDENTICAL
16+
as the parity reference (flag OFF). RULE #1: when the flag is ON, GEMM is the ONE
17+
path; a tile/divisibility/compile failure RAISES (no silent serial fallback).
18+
19+
### F0 (`chunk_precompute_fwd`) — FULLY GEMM-ified, MEASURED on the Apple GPU
20+
21+
The two head-independent serial reductions become `T.gemm`:
22+
1. `cb = C @ B^T` (transpose_B)
23+
2. `summary_states = (decay·dt-weighted x)^T @ B` (transpose_A)
24+
25+
MEASURED (`scratch/probe_f0b2b0_metal_gemm_parity.py`, dims B=1 S=128 chunk=64
26+
G=1 H=2 P=N=64, fp16 inputs, mps device, 50-iter timing, gate 5e-4):
27+
28+
| output | max\|abs diff\| serial vs gemm | nan |
29+
|------------------|-------------------------------:|-----|
30+
| cb | 0.000e+00 (bit-exact) | no |
31+
| dA_cumsum | 0.000e+00 (bit-exact) | no |
32+
| summary_states | 2.128e-06 | no |
33+
34+
- serial = **0.1574 ms**, gemm = **0.0192 ms****8.20x faster**, worst diff
35+
2.128e-06 ≪ 5e-4 gate. Parity checked over EVERY output element (no subset).
36+
37+
### Codegen is REAL tensor-op matmul, not serial relabeled
38+
39+
`get_kernel_source()` on the F0 GEMM prim (flag ON):
40+
- `simdgroup_multiply_accumulate(...)` × **2** — the two T.gemm calls:
41+
`simdgroup_multiply_accumulate(cb_frag[...], A_local[...], B_local[...], cb_frag[...])`
42+
and `simdgroup_multiply_accumulate(states_frag[...], ...)`.
43+
- `make_filled_simdgroup` × 128 (the register fragment accumulators),
44+
`simdgroup` token × 206.
45+
46+
The SERIAL F0 prim (flag OFF): `simdgroup_multiply` × **0**,
47+
`make_filled_simdgroup` × **0** (its 34 `simdgroup` tokens are `simdgroup_barrier`
48+
sync only). So the GEMM prim emits Apple's `metal.simdgroup` cooperative tensor
49+
matmul (the matmul2d lowering target on M1-M4 where the register-fragment route
50+
is forced), and the serial prim does scalar loops. The two are genuinely
51+
different code, not a relabel.
52+
53+
> Metal vs the CUDA twin: C accumulators are register FRAGMENTS, which forces the
54+
> stable 8x8 `metal.simdgroup` path (shared-C with N≥32/K%16==0 would route to
55+
> the M5-only `metal.cooperative_tensor` path that fails on M1-M4). fp16 operands
56+
> / fp32 accum; summary_states stored fp32; dacs reloaded from fp16 dA_cumsum to
57+
> fit Apple's hard 32 KB threadgroup limit.
58+
59+
### B2 / B0 — honest RAISE (RULE #1), serial stays the working path
60+
61+
With the flag ON, `build_chunk_scan_combine_bwd_metal` (B2) and
62+
`build_chunk_precompute_bwd_metal` (B0) RAISE a clear `NotImplementedError`
63+
(verified by the probe). With the flag OFF, both serial prims compile to a
64+
`JITKernel` and run. Reasons (honest scope, not a bug):
65+
- B2's 4-contraction fragment-staging set exceeds Apple's 32 KB threadgroup
66+
limit (the gb10 CUDA twin needs 72.5 KB).
67+
- B0 is a reverse-cumsum scan + per-l atomic scatters, not a 2D matmul.
68+
69+
The `chunk_scan_combine_bwd_metal_gemm_prim` (DYX + dchunk_states, two clean
70+
GEMMs) is defined for reference / larger-budget backends.
71+
72+
**Track A GO/NO-GO: GO for F0** (8.20x, bit-exact cb + 2.1e-6 summary, real
73+
simdgroup matmul). **NO-GO (honest) for B2/B0 on Apple** — over Apple's 32 KB
74+
threadgroup budget / not a matmul; the serial prim is the path and the GEMM flag
75+
RAISES rather than launching wrong.
76+
77+
---
78+
79+
## Track B — automatic compiler pass `AutoGemmifyReductions`
80+
81+
`/Volumes/external/sources/tilelang/tilelang/transform/auto_gemmify_reductions.py`
82+
— a Python-registered `tvm` `prim_func_pass` (NO C++ rebuild; the live
83+
`build/` is untouched). Default OFF; opt-in via env
84+
`TILELANG_ENABLE_AUTO_GEMMIFY` or PassConfig key `tl.auto_gemmify_reductions`.
85+
86+
### What it DOES auto-detect + auto-prove (no hand rewrite)
87+
88+
Given a raw `@T.prim_func` written in the canonical serial-reduction shape
89+
```
90+
for ls in range(M*N):
91+
i = ls // N ; j = ls % N
92+
acc = local[1]; acc[0] = 0
93+
for k in range(K):
94+
acc[0] = acc[0] + A[..i..k..] * B[..k..j..]
95+
out[..i..j..] = acc[0]
96+
```
97+
the pass, WITHOUT any annotation:
98+
1. structurally recognizes the contraction,
99+
2. INFERS transpose flags from the actual TIR index positions
100+
(cb → transpose_A=False/transpose_B=True; summary → transpose_A=True), and
101+
3. runs the built-in z3 prover (Track C machinery) which proves algebraic
102+
equivalence + race-freedom NON-vacuously, then stamps `tl.auto_gemmify_proved`.
103+
104+
Demonstrated end-to-end on the F0 `cb` serial kernel (`match_contraction` +
105+
`prove_contraction` + `rewrite_contractions`):
106+
`MATCH M,N,K=32x32x16 transpose_A=False transpose_B=True; z3 algebra_proved=True
107+
race_proved=True PROVED=True`.
108+
109+
Test suite: **14/14 PASS**
110+
(`testing/python/transform/test_auto_gemmify_reductions.py`) — including the
111+
correct cb/summary/scaled matches, and the DECLINE cases (k-dependent scale,
112+
3-operand product, plain 1D reduction, wrong-transpose z3 counterexample,
113+
z3-disabled env).
114+
115+
### The honest limit (prototype scope)
116+
117+
`_emit_gemm_for_match` returns `None` — the pass does NOT splice a `T.gemm`
118+
tile-op into the raw TIR in-place. Synthesizing the exact shared-buffer staging
119+
+ `tl.region` operands that `LayoutInference` + the Metal `gemm.cc` selector
120+
demand from raw TIR is the brittle part flagged as risk #1 in the design; rather
121+
than fabricate shared allocations that would make `LowerTileOp` raise, the
122+
in-place splice is DEFERRED to the frontend builder path (the hand A F0 prim).
123+
So on the test kernel: `rewritten=0`, `decline_reason=gemm_splice_deferred_to_builder`,
124+
`gemm calls after=0`. The detector + z3 prover (the load-bearing, demonstrable
125+
machinery) run and prove regardless.
126+
127+
**Track B GO/NO-GO: GO as a detector+prover prototype** — it AUTO-detects the
128+
serial-reduction-is-a-GEMM pattern from raw TIR and AUTO-proves the rewrite,
129+
with transpose inference, with no hand annotation. **NO-GO for fully-automatic
130+
in-place T.gemm emission** — that final IR splice is honestly deferred to the
131+
builder, so the auto-pass recognizes+proves the rewrite but does not yet emit it
132+
itself. This is a working PROTOTYPE, scoped honestly, not a production pass.
133+
134+
---
135+
136+
## Track C — built-in z3 proof of the rewrite
137+
138+
`/Volumes/external/sources/cppmega.mlx/cppmega_mlx/nn/_tilelang/_gemm_rewrite_proof.py`
139+
— mirrors `MetalReductionSyncPlan` / `mamba3_path_c` provers. Each obligation
140+
checks the NEGATION of a property for `unsat` (proved) / `sat` (concrete
141+
counter-witness) / `unknown` (fail-closed). `z3_proved` is True iff EVERY
142+
applicable obligation discharges to `unsat`.
143+
144+
Obligations: operand-map match (no accidental transpose), tiling injectivity
145+
over k, k-coverage/surjectivity, mask-predicate equivalence, scale-fold
146+
equivalence (uninterpreted base fn), single-writer output disjointness.
147+
148+
This is the built-in gate for Track B: the auto-pass calls
149+
`prove_contraction` / `require_gemm_rewrite_proof`; an unprovable contraction
150+
RAISES `GemmRewriteNotProven` (forced mode) or keeps the serial path
151+
(detect-and-prefer mode). NO rewrite without a passing z3 proof (RULE #1).
152+
153+
### Proof results — MEASURED (`tests/test_gemm_rewrite_proof.py`, 13/13 PASS)
154+
155+
CORRECT rewrites PROVE:
156+
- F0 `cb=C@B^T` → z3_proved=True (operand maps match, tiling injective, k
157+
covered, single writer).
158+
- B2 `dinp` lower-tri causal mask → z3_proved=True (mask_equiv=True).
159+
- F0 `summary` k-independent scale fold → z3_proved=True (scale_equiv=True).
160+
161+
WRONG rewrites are REFUSED with a concrete COUNTER-WITNESS (the NON-VACUITY
162+
proof — a tautological encoding would pass these too):
163+
- transposed A operand (`k*M+i` vs `i*K+k`) → operand_maps_match=False.
164+
- dropped last k-tile (k_steps=3 covers [0,48) of 64) → k_covered=False.
165+
- overlapping output tiles (tile_m=20, stride=16 → rows 16..19 double-owned) →
166+
single_writer=False.
167+
- strict `i>k` vs inclusive `i>=k` causal mask (drops the diagonal) →
168+
mask_equiv=False.
169+
- `scale(k+1)` vs `scale(k)` off-by-one decay fold → scale_equiv=False.
170+
171+
Track B's own z3 (`prove_contraction`) is also non-vacuous: flipping a transpose
172+
flag yields a z3 algebra counterexample (`z3_algebra_proved=False`,
173+
`decline_reason=z3_algebra_counterexample`) even on a square M==K==N kernel, and
174+
disabling z3 (`TILELANG_DISABLE_Z3`) makes every candidate DECLINE
175+
(`decline_reason=z3_disabled`) — it never rewrites without a proof.
176+
177+
**Track C GO/NO-GO: GO** — the proof runs, proves correct rewrites, and FAILS on
178+
deliberately-broken rewrites with a counter-witness. It proves SYMBOLIC/
179+
structural equivalence + race-freedom (NOT fp bit-equivalence — that is the
180+
numeric parity test in Track A). Non-vacuity is locked in by the wrong-rewrite
181+
tests.
182+
183+
---
184+
185+
## Honest attribution / scope summary
186+
187+
| Track | Claim | Status |
188+
|-------|-------|--------|
189+
| A F0 | serial→T.gemm(simdgroup), measured on Apple GPU | **REAL** — 8.20x, bit-exact cb, 2.1e-6 summary, real `simdgroup_multiply_accumulate` codegen |
190+
| A B2/B0 | GEMM on Apple | honest **RAISE** (over 32 KB threadgroup / not a matmul); serial is the path |
191+
| B | auto-detect + auto-prove serial-reduction contraction from raw TIR | **REAL** prototype — detects + infers transpose + z3-proves, 14/14 tests |
192+
| B | auto in-place T.gemm emission | **DEFERRED**`_emit_gemm_for_match` returns None; splice deferred to builder |
193+
| C | z3 equivalence + race-free proof, non-vacuous, built into the pass | **REAL** — 13/13 tests, wrong rewrites refused with counter-witness |
194+
195+
All Track A timings are MEASURED on this Apple GPU (no extrapolation). Track B/C
196+
are tested on this machine with the live tilelang build + z3 4.15.4. No C++
197+
rebuild; the live `/Volumes/external/sources/tilelang/build` is untouched.

0 commit comments

Comments
 (0)