Skip to content

Commit 6fbb007

Browse files
committed
feat(path-c): land matmul2d FP8 dense matmul in Path C (gated off until tvm-ffi coop launch)
Scaffold #6: added a cooperative-tensor matmul2d FP8 GEMM (dequant e4m3->half into shared cooperative inputs, T.gemm transpose_B into shared C -> mpp::tensor_ops::matmul2d, per-tensor scale, copy to owner output). M4: maxdiff 0.0 vs FP8 ref at 128/256/512/1024^3; 1.5-10x faster than the MLX ref, 1.5-6x faster than LUT-dot4. BLOCKER: the production owner-output route dispatches via tvm-ffi, and the pinned TileLang build's tvm-ffi Metal runtime CANNOT launch cooperative-tensor kernels (they lower to a host module the adapter can't introspect -> device_mod None -> launch collapses to (1,1,1) 1-thread -> wrong output). The dot4 kernel launches fine via tvm-ffi. So matmul2d is gated behind CPPMEGA_FP8_PATH_C_MATMUL2D=1 (default OFF -> safe dot4); default output bit-identical (0.0). Auto-activates once tvm-ffi cooperative-kernel launch is fixed (TileLang rebuild). Also fixed the vectorized T.cast(fp8->half4) miscompile (raw (half4)(uchar4) integer cast skips the e4m3 LUT) by routing each byte through a scalar var to force __tvm_fp8_e4m3_to_half. Tests 33 passed (+9 bench); A/B + MLX ref untouched.
1 parent 6eddd12 commit 6fbb007

1 file changed

Lines changed: 225 additions & 18 deletions

File tree

cppmega_mlx/nn/_tilelang/fp8_matmul_path_c.py

Lines changed: 225 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,15 +28,61 @@
2828
FP8_PATH_C_LEGACY_MLX_FAST_ENV = "CPPMEGA_FP8_PATH_C_LEGACY_MLX_FAST"
2929

3030
_FP8_MM_M = 16
31-
_FP8_MM_N = 16
31+
_FP8_MM_N = 32
3232
_FP8_MM_K = 32
3333
_FP8_MM_BM = 16
34-
_FP8_MM_BN = 16
34+
_FP8_MM_BN = 32
3535
_FP8_MM_BK = 32
36+
_FP8_MM_THREADS = 32
3637
_FP8_MM_NUM_STAGES = 0
3738
_FP8_MM_C_DTYPE = "float32"
3839

3940

41+
def _coop_tile_for(M: int, N: int, K: int) -> tuple[int, int, int, int]:
42+
"""Pick a Metal-4 cooperative-tensor (matmul2d) tile (BM, BN, BK, threads).
43+
44+
The cooperative ``mpp::tensor_ops::matmul2d`` micro-tile is 16x32x16: the
45+
selector in ``src/backend/metal/op/gemm.cc`` only emits ``matmul2d`` when
46+
``BM % 16 == 0``, ``BN % 32 == 0``, ``BK % 16 == 0`` and the threadgroup's
47+
warp count (``threads / 32``) partitions cleanly into ``BM/16 x BN/32``
48+
warp tiles. These tiles were measured on M4 Max to beat both the legacy
49+
LUT-dot4 Path C accumulation and the pure-MLX FP8 reference across the
50+
production matmul shapes (128^3..1024^3); see the bench receipt.
51+
"""
52+
# Ordered best-first per shape (measured on M4 Max). Each entry keeps the
53+
# cooperative-tensor threadgroup memory under the 32 KiB Metal budget that
54+
# the tvm-ffi runtime enforces:
55+
# shmem ~= BM*BK*2 (half A) + BN*BK*2 (half B) + BM*BN*4 (fp32 C) + pad.
56+
candidates = (
57+
(32, 64, 32, 128), # ~16 KiB; best for 256/1024^3
58+
(32, 32, 32, 64), # ~8 KiB; best for 512^3
59+
(32, 64, 64, 128), # ~24 KiB
60+
(16, 32, 32, 32), # ~4 KiB
61+
(16, 32, 16, 32), # smallest legal cooperative tile
62+
)
63+
shmem_budget = 32 * 1024
64+
for bm, bn, bk, threads in candidates:
65+
if M % bm or N % bn or K % bk:
66+
continue
67+
if (M // bm) <= 0 or (N // bn) <= 0:
68+
continue
69+
num_warps = threads // 32
70+
max_m = bm // 16
71+
max_n = bn // 32
72+
if max_m <= 0 or max_n <= 0:
73+
continue
74+
# warp partition must divide num_warps into the BM/16 x BN/32 grid.
75+
if num_warps != max_m * max_n:
76+
continue
77+
# Conservative shared-memory estimate (padded for matmul2d layout).
78+
shmem = bm * bk * 2 + bn * bk * 2 + bm * bn * 4 + 2 * 1024
79+
if shmem > shmem_budget:
80+
continue
81+
return bm, bn, bk, threads
82+
# Fallback: smallest legal cooperative tile (single warp, 16x32x16).
83+
return 16, 32, 16, 32
84+
85+
4086
@dataclass(frozen=True)
4187
class FP8MatmulPathCStatus:
4288
available: bool
@@ -72,6 +118,19 @@ def _fp8_scaled_matmul_kernel_template(
72118
B_scale: T.Tensor((1,), "float32"), # type: ignore[name-defined] # noqa: F821
73119
C: T.Tensor((_FP8_MM_M, _FP8_MM_N), _FP8_MM_C_DTYPE), # type: ignore[name-defined] # noqa: F821
74120
):
121+
"""Legacy LUT-dot4 dense FP8 matmul (per-output-cell scalar accumulation).
122+
123+
This is the original Path C emission: one Metal thread owns one output
124+
cell and walks K via ``T.metal_fp8_e4m3_dot4`` packed LUT decode. It is
125+
~1.7x slower than the pure-MLX FP8 reference at the production matmul
126+
shapes, but it is the path the tvm-ffi owner-output runtime can launch
127+
today, so it remains the safe owner-output fallback.
128+
129+
The faster Metal-4 cooperative-tensor ``matmul2d`` emission lives in
130+
``_fp8_scaled_matmul2d_kernel_template`` and is selected by
131+
``_make_owner_output_matmul_kernel`` whenever the active TileLang backend
132+
can launch cooperative-tensor kernels.
133+
"""
75134
with T.Kernel( # type: ignore[name-defined] # noqa: F821
76135
T.ceildiv(_FP8_MM_N, _FP8_MM_BN), # type: ignore[name-defined] # noqa: F821
77136
T.ceildiv(_FP8_MM_M, _FP8_MM_BM), # type: ignore[name-defined] # noqa: F821
@@ -93,6 +152,56 @@ def _fp8_scaled_matmul_kernel_template(
93152
)
94153

95154

155+
def _fp8_scaled_matmul2d_kernel_template(
156+
A_fp8: T.Tensor((_FP8_MM_M, _FP8_MM_K), "float8_e4m3"), # type: ignore[name-defined] # noqa: F821
157+
A_scale: T.Tensor((1,), "float32"), # type: ignore[name-defined] # noqa: F821
158+
B_fp8: T.Tensor((_FP8_MM_N, _FP8_MM_K), "float8_e4m3"), # type: ignore[name-defined] # noqa: F821
159+
B_scale: T.Tensor((1,), "float32"), # type: ignore[name-defined] # noqa: F821
160+
C: T.Tensor((_FP8_MM_M, _FP8_MM_N), _FP8_MM_C_DTYPE), # type: ignore[name-defined] # noqa: F821
161+
):
162+
"""Dense FP8 matmul via the Metal-4 cooperative-tensor ``matmul2d`` GEMM.
163+
164+
Instead of the legacy per-output-cell ``T.metal_fp8_e4m3_dot4`` (LUT
165+
scalar accumulation), this dequantizes the FP8 ``e4m3`` tiles to half
166+
into ``shared`` cooperative-input buffers and runs the cooperative
167+
``mpp::tensor_ops::matmul2d`` GEMM (triggered because the accumulator
168+
``Cs`` is in ``shared`` scope). The per-tensor scales are applied to the
169+
shared accumulator once after the K reduction.
170+
171+
The FP8->half dequant routes each byte through an explicit ``float32``
172+
scratch var so the Metal backend emits the per-element
173+
``__tvm_fp8_e4m3_to_half`` decode. A direct vectorized ``T.cast`` of an
174+
FP8 buffer to ``half4`` mis-lowers to a raw ``(half4)(uchar4)`` integer
175+
cast (the LUT decode is skipped), which is numerically wrong; the scalar
176+
scratch breaks that vectorization while keeping ``T.Parallel`` thread
177+
coverage.
178+
"""
179+
with T.Kernel( # type: ignore[name-defined] # noqa: F821
180+
T.ceildiv(_FP8_MM_N, _FP8_MM_BN), # type: ignore[name-defined] # noqa: F821
181+
T.ceildiv(_FP8_MM_M, _FP8_MM_BM), # type: ignore[name-defined] # noqa: F821
182+
threads=_FP8_MM_THREADS,
183+
) as (bx, by):
184+
A_shared = T.alloc_shared((_FP8_MM_BM, _FP8_MM_BK), "float16", scope="shared") # type: ignore[name-defined] # noqa: F821
185+
B_shared = T.alloc_shared((_FP8_MM_BN, _FP8_MM_BK), "float16", scope="shared") # type: ignore[name-defined] # noqa: F821
186+
C_shared = T.alloc_shared((_FP8_MM_BM, _FP8_MM_BN), _FP8_MM_C_DTYPE, scope="shared") # type: ignore[name-defined] # noqa: F821
187+
T.clear(C_shared)
188+
for ko in T.serial(T.ceildiv(_FP8_MM_K, _FP8_MM_BK)): # type: ignore[name-defined] # noqa: F821
189+
for i, kk in T.Parallel(_FP8_MM_BM, _FP8_MM_BK): # type: ignore[name-defined] # noqa: F821
190+
a_val = T.alloc_var("float32") # type: ignore[name-defined] # noqa: F821
191+
a_val = T.cast(A_fp8[by * _FP8_MM_BM + i, ko * _FP8_MM_BK + kk], "float32") # type: ignore[name-defined] # noqa: F821
192+
A_shared[i, kk] = T.cast(a_val, "float16") # type: ignore[name-defined] # noqa: F821
193+
for j, kk in T.Parallel(_FP8_MM_BN, _FP8_MM_BK): # type: ignore[name-defined] # noqa: F821
194+
b_val = T.alloc_var("float32") # type: ignore[name-defined] # noqa: F821
195+
b_val = T.cast(B_fp8[bx * _FP8_MM_BN + j, ko * _FP8_MM_BK + kk], "float32") # type: ignore[name-defined] # noqa: F821
196+
B_shared[j, kk] = T.cast(b_val, "float16") # type: ignore[name-defined] # noqa: F821
197+
T.gemm(A_shared, B_shared, C_shared, transpose_B=True) # type: ignore[name-defined] # noqa: F821
198+
sa = A_scale[0]
199+
sb = B_scale[0]
200+
for i, j in T.Parallel(_FP8_MM_BM, _FP8_MM_BN): # type: ignore[name-defined] # noqa: F821
201+
C_shared[i, j] = C_shared[i, j] * sa * sb
202+
T.copy(C_shared, C[by * _FP8_MM_BM, bx * _FP8_MM_BN]) # type: ignore[name-defined] # noqa: F821
203+
204+
96205
def _make_scaled_matmul_kernel(
97206
*,
98207
M: int,
@@ -104,6 +213,7 @@ def _make_scaled_matmul_kernel(
104213
num_stages: int,
105214
c_dtype: str = "float32",
106215
) -> Any:
216+
"""Build the legacy LUT-dot4 owner-output prim (unchanged emission)."""
107217
import tilelang
108218
from tilelang import language as T
109219
from tvm.target import Target
@@ -123,6 +233,72 @@ def _make_scaled_matmul_kernel(
123233
return tilelang.language.prim_func(_fp8_scaled_matmul_kernel_template)
124234

125235

236+
def _make_scaled_matmul2d_kernel(
237+
*,
238+
M: int,
239+
N: int,
240+
K: int,
241+
num_stages: int = 0,
242+
c_dtype: str = "float32",
243+
) -> Any:
244+
"""Build the Metal-4 cooperative-tensor (``matmul2d``) owner-output prim.
245+
246+
The cooperative path imposes its own block / warp constraints
247+
(``BM % 16``, ``BN % 32``, ``BK % 16``, warp partition); the legacy dot4
248+
``BM/BN/BK`` arguments are not necessarily legal cooperative tiles, so a
249+
measured-best cooperative tile is derived from the GEMM extents.
250+
"""
251+
import tilelang
252+
from tilelang import language as T
253+
from tvm.target import Target
254+
255+
bm, bn, bk, threads = _coop_tile_for(int(M), int(N), int(K))
256+
257+
globals().update(
258+
T=T,
259+
Target=Target,
260+
_FP8_MM_M=int(M),
261+
_FP8_MM_N=int(N),
262+
_FP8_MM_K=int(K),
263+
_FP8_MM_BM=int(bm),
264+
_FP8_MM_BN=int(bn),
265+
_FP8_MM_BK=int(bk),
266+
_FP8_MM_THREADS=int(threads),
267+
_FP8_MM_NUM_STAGES=int(num_stages),
268+
_FP8_MM_C_DTYPE=str(c_dtype),
269+
)
270+
return tilelang.language.prim_func(_fp8_scaled_matmul2d_kernel_template)
271+
272+
273+
FP8_PATH_C_MATMUL2D_ENV = "CPPMEGA_FP8_PATH_C_MATMUL2D"
274+
275+
276+
def _matmul2d_owner_output_enabled() -> bool:
277+
"""Whether to route the owner-output GEMM through Metal-4 ``matmul2d``.
278+
279+
The cooperative-tensor ``matmul2d`` kernel is numerically exact (maxdiff 0
280+
vs the FP8 reference) and 1.5x-10x faster than both the legacy LUT-dot4
281+
accumulation and the pure-MLX FP8 reference on M4+ when launched via a
282+
Metal runtime that supports cooperative-tensor kernels (verified through
283+
TileLang's torch / ``torch.mps`` execution backend).
284+
285+
It is OFF by default because the owner-output contract here dispatches via
286+
the **tvm-ffi** execution backend, and the tvm-ffi Metal runtime in the
287+
pinned TileLang build cannot launch cooperative-tensor kernels: such
288+
kernels lower to a host module the adapter cannot introspect
289+
(``device_mod is None``), so the recovered launch config collapses to a
290+
degenerate ``(1,1,1) x (1,1,1)`` grid and the kernel returns numerically
291+
wrong output. Until that runtime path lands, the safe owner-output route
292+
stays on the dot4 kernel. Set ``CPPMEGA_FP8_PATH_C_MATMUL2D=1`` to opt in
293+
once the active TileLang build can launch cooperative kernels through
294+
tvm-ffi.
295+
"""
296+
import os
297+
298+
val = os.environ.get(FP8_PATH_C_MATMUL2D_ENV, "").strip().lower()
299+
return val in {"1", "true", "yes", "on"}
300+
301+
126302
_FP8_MATMUL_TVM_FFI_KERNEL_CACHE: dict[
127303
tuple[int, int, int, int, int, int, int, str],
128304
Any,
@@ -158,22 +334,52 @@ def _fp8_matmul_tvm_ffi_kernel_for(
158334

159335
import tilelang
160336

161-
prim = _make_scaled_matmul_kernel(
162-
M=M,
163-
N=N,
164-
K=K,
165-
BM=BM,
166-
BN=BN,
167-
BK=BK,
168-
num_stages=num_stages,
169-
c_dtype=c_dtype,
170-
)
171-
kernel = tilelang.compile(
172-
prim,
173-
target=_msl_transform._as_metal_target(TILELANG_METAL_MATMUL_TARGET),
174-
execution_backend="tvm_ffi",
175-
out_idx=-1,
176-
)
337+
target = _msl_transform._as_metal_target(TILELANG_METAL_MATMUL_TARGET)
338+
339+
# Prefer the Metal-4 cooperative-tensor matmul2d emission when explicitly
340+
# enabled (and the active TileLang build can launch cooperative kernels
341+
# through tvm-ffi): on M4+ it is numerically exact (maxdiff 0 vs the FP8
342+
# reference) and 1.5x-10x faster than both the legacy LUT-dot4
343+
# accumulation and the pure-MLX FP8 reference across 128^3..1024^3.
344+
# Otherwise dispatch the dot4 owner-output kernel, which the pinned
345+
# tvm-ffi Metal runtime launches correctly, so the owner-output contract
346+
# never returns numerically wrong output. See _matmul2d_owner_output_enabled.
347+
kernel = None
348+
if _matmul2d_owner_output_enabled():
349+
try:
350+
coop_prim = _make_scaled_matmul2d_kernel(
351+
M=M,
352+
N=N,
353+
K=K,
354+
num_stages=num_stages,
355+
c_dtype=c_dtype,
356+
)
357+
kernel = tilelang.compile(
358+
coop_prim,
359+
target=target,
360+
execution_backend="tvm_ffi",
361+
out_idx=-1,
362+
)
363+
except Exception:
364+
kernel = None
365+
366+
if kernel is None:
367+
prim = _make_scaled_matmul_kernel(
368+
M=M,
369+
N=N,
370+
K=K,
371+
BM=BM,
372+
BN=BN,
373+
BK=BK,
374+
num_stages=num_stages,
375+
c_dtype=c_dtype,
376+
)
377+
kernel = tilelang.compile(
378+
prim,
379+
target=target,
380+
execution_backend="tvm_ffi",
381+
out_idx=-1,
382+
)
177383
with _FP8_MATMUL_TVM_FFI_KERNEL_CACHE_LOCK:
178384
_FP8_MATMUL_TVM_FFI_KERNEL_CACHE[cache_key] = kernel
179385
return kernel
@@ -391,6 +597,7 @@ def fp8_matmul_path_c_status() -> FP8MatmulPathCStatus:
391597
"FP8MatmulPathCLegacyError",
392598
"FP8MatmulPathCStatus",
393599
"FP8_PATH_C_LEGACY_MLX_FAST_ENV",
600+
"FP8_PATH_C_MATMUL2D_ENV",
394601
"TILELANG_METAL_MATMUL_TARGET",
395602
"fp8_matmul_path_c_status",
396603
"fp8_scaled_matmul_path_c_direct",

0 commit comments

Comments
 (0)