|
| 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