Skip to content

Commit 29c112d

Browse files
committed
feat(path-c): close #10 MXFP4 - real Path C kernel (e2m1 dequant + cooperative matmul2d GEMM)
Tier-3 scaffold #10 (MXFP4 e2m1 OCP MX block-16) was a Python reference codec + planned shader. New mxfp4_matmul_path_c.py: vectorized MLX e2m1 decode (unpack nibbles -> LUT gather x per-block-16 E8M0 scale -> half) then the proven committed Metal-4 cooperative matmul2d GEMM (sparse_mla_grouped_qk_path_c, transpose_B). dequant-at-MLX-boundary (no new codegen intrinsic, no rebuild) -- same strategy as fused_fp8_gemm to ride the numerically-exact half GEMM (no native Apple FP4 ALU; float4_e2m1 has no Metal decode helper). M4: dequant maxdiff 9.7e-4 vs reference codec; GEMM rel 3.1e-4 (32x64x64) .. 3.3e-4 (128^3) -- well under the 5e-2 4-bit bar. 16 passed (+4 new). RULE #1: explicit _coop_tile_for dispatch BEFORE dequant -- sub-tile / non-OCP block / dtype mismatch RAISE with where+what, no silent fallback.
1 parent 1cf1795 commit 29c112d

2 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
# pyright: reportMissingImports=false
2+
"""MXFP4 (e2m1 OCP MX block-16) Path C: real dequant + cooperative GEMM.
3+
4+
Tier-3 Path C scaffold #10. MXFP4 is the OCP MX 4-bit float codec: a
5+
canonical ``e2m1`` 4-bit mantissa (codebook ``±{0,.5,1,1.5,2,3,4,6}``,
6+
sign in the top nibble bit) plus one per-block-16 scale. Storage is
7+
packed ``uint8`` (two nibbles per byte) + a per-block scale tensor.
8+
9+
There is **no native Apple FP4 ALU** and no end-to-end ``float4_e2m1``
10+
TVM dtype usable through the MLX/tvm-ffi ABI (only ``float4_e2m1fn``
11+
exists, and it has no Metal codegen decode helper). So -- exactly like
12+
the dense FP8 matmul2d surface dequants ``e4m3 -> half`` into the
13+
cooperative-input shared buffer before ``T.gemm`` -- MXFP4 dequants the
14+
e2m1 block payload to ``half`` **at the MLX boundary** and feeds the
15+
*already numerically-correct* Metal-4 cooperative-tensor ``matmul2d``
16+
GEMM (``make_sparse_mla_grouped_gemm_matmul2d_kernel``, committed and
17+
verified exact on M4). No new codegen intrinsic is needed; this dodges
18+
the in-kernel ``T.cast`` miscompile the same way ``fused_fp8_gemm`` did.
19+
20+
The dequant here is a **vectorized MLX gather** (LUT index + per-block
21+
scale broadcast), not the per-element Python loop in
22+
``cppmega_mlx.quant.mxfp4_metal`` -- that reference codec stays the
23+
parity oracle; this is the GPU-feedable fast path.
24+
25+
RULE #1 -- no silent fallback. Unsupported shapes/dtypes RAISE with
26+
where + what. A GEMM shape that admits no legal cooperative tile RAISES
27+
(it is NOT silently routed to a degraded path); the OCP block size is
28+
fixed at 16 and any other block size RAISES.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
from typing import Any
34+
35+
import mlx.core as mx
36+
37+
from cppmega_mlx.nn._tilelang import _msl_transform
38+
from cppmega_mlx.quant.mxfp4_metal import (
39+
MXFP4_BLOCK_SIZE,
40+
MXFP4_LUT,
41+
)
42+
43+
44+
__all__ = [
45+
"MXFP4MatmulPathCError",
46+
"mxfp4_dequant_to_half",
47+
"mxfp4_dequant_blockwise_2d",
48+
"mxfp4_matmul_path_c",
49+
"mxfp4_matmul_path_c_status",
50+
]
51+
52+
53+
class MXFP4MatmulPathCError(RuntimeError):
54+
"""Raised when the MXFP4 Path C route cannot run (shape/dtype/Metal)."""
55+
56+
57+
# The 16-entry e2m1 codebook as an MLX fp32 constant, so the nibble->value
58+
# decode is a single vectorized ``mx`` gather instead of a numpy loop.
59+
_MXFP4_LUT_MX = mx.array(MXFP4_LUT.astype("float32"))
60+
61+
62+
def _unpack_nibbles_last_axis(qdata: mx.array, *, n_along_axis: int) -> mx.array:
63+
"""Unpack a packed-nibble ``uint8`` payload into ``uint8`` nibble indices.
64+
65+
``qdata`` packs two e2m1 nibbles per byte (low nibble first), matching
66+
``cppmega_mlx.quant.mxfp4_metal._encode_block``. The last axis of
67+
``qdata`` is ``ceil(n_along_axis / 2)`` bytes; this returns an array
68+
whose last axis is ``n_along_axis`` nibble indices in ``[0, 15]``.
69+
"""
70+
71+
if qdata.dtype != mx.uint8:
72+
raise MXFP4MatmulPathCError(
73+
f"mxfp4 dequant: packed payload must be mx.uint8; got {qdata.dtype}"
74+
)
75+
low = mx.bitwise_and(qdata, mx.array(0x0F, dtype=mx.uint8))
76+
high = mx.bitwise_and(
77+
mx.right_shift(qdata, mx.array(4, dtype=mx.uint8)),
78+
mx.array(0x0F, dtype=mx.uint8),
79+
)
80+
# Interleave low,high along a new trailing axis then flatten: [..., bytes, 2].
81+
interleaved = mx.stack([low, high], axis=-1)
82+
flat = interleaved.reshape(*interleaved.shape[:-2], -1)
83+
if flat.shape[-1] < n_along_axis:
84+
raise MXFP4MatmulPathCError(
85+
f"mxfp4 dequant: packed payload has {flat.shape[-1]} nibbles on the "
86+
f"last axis but {n_along_axis} were requested (truncated payload)"
87+
)
88+
# Trim the padding nibble when n_along_axis is odd.
89+
return flat[..., :n_along_axis]
90+
91+
92+
def mxfp4_dequant_to_half(
93+
nibbles: mx.array,
94+
scales: mx.array,
95+
*,
96+
block_size: int = MXFP4_BLOCK_SIZE,
97+
out_dtype: Any = mx.float16,
98+
) -> mx.array:
99+
"""Decode already-unpacked e2m1 nibble indices + per-block scales -> dtype.
100+
101+
``nibbles`` is an integer array of LUT indices in ``[0, 15]`` whose last
102+
axis is a multiple of ``block_size``. ``scales`` carries one scale per
103+
block; its last axis is ``nibbles.shape[-1] // block_size`` and its
104+
leading axes broadcast against ``nibbles``' leading axes.
105+
"""
106+
107+
if block_size != MXFP4_BLOCK_SIZE:
108+
raise MXFP4MatmulPathCError(
109+
f"mxfp4 dequant: block_size={block_size} unsupported; OCP MX fixes "
110+
f"the block at {MXFP4_BLOCK_SIZE}"
111+
)
112+
n_last = int(nibbles.shape[-1])
113+
if n_last % block_size != 0:
114+
raise MXFP4MatmulPathCError(
115+
f"mxfp4 dequant: last axis {n_last} is not a multiple of the "
116+
f"block size {block_size}"
117+
)
118+
n_blocks = n_last // block_size
119+
if int(scales.shape[-1]) != n_blocks:
120+
raise MXFP4MatmulPathCError(
121+
f"mxfp4 dequant: scales last axis {int(scales.shape[-1])} != "
122+
f"n_blocks {n_blocks} (= {n_last}/{block_size})"
123+
)
124+
# LUT gather: nibble index -> signed codebook magnitude.
125+
idx = nibbles.astype(mx.int32)
126+
decoded = _MXFP4_LUT_MX[idx] # fp32, same shape as nibbles
127+
# Broadcast the per-block scale across the 16 elements of each block.
128+
lead = tuple(decoded.shape[:-1])
129+
decoded_b = decoded.reshape(*lead, n_blocks, block_size)
130+
scale_b = scales.astype(mx.float32).reshape(*lead, n_blocks, 1)
131+
scaled = (decoded_b * scale_b).reshape(*lead, n_last)
132+
return scaled.astype(out_dtype)
133+
134+
135+
def mxfp4_dequant_blockwise_2d(
136+
qdata: mx.array,
137+
scales: mx.array,
138+
*,
139+
rows: int,
140+
cols: int,
141+
block_size: int = MXFP4_BLOCK_SIZE,
142+
out_dtype: Any = mx.float16,
143+
) -> mx.array:
144+
"""Dequant a packed 2D MXFP4 matrix ``(rows, cols)`` to ``out_dtype``.
145+
146+
Blocks tile the **last** axis (the GEMM contraction axis ``K``), so
147+
``cols`` MUST be a multiple of ``block_size``. ``qdata`` is the packed
148+
``uint8`` payload reshaped to ``(rows, cols // 2)`` (two nibbles/byte);
149+
``scales`` is ``(rows, cols // block_size)``. Returns ``(rows, cols)``.
150+
"""
151+
152+
if block_size != MXFP4_BLOCK_SIZE:
153+
raise MXFP4MatmulPathCError(
154+
f"mxfp4 dequant: block_size={block_size} unsupported; OCP MX fixes "
155+
f"the block at {MXFP4_BLOCK_SIZE}"
156+
)
157+
if cols % block_size != 0:
158+
raise MXFP4MatmulPathCError(
159+
f"mxfp4 dequant: contraction dim cols={cols} must be a multiple of "
160+
f"the block size {block_size} (blocks tile the K axis)"
161+
)
162+
bytes_per_row = (cols + 1) // 2
163+
if tuple(qdata.shape) != (rows, bytes_per_row):
164+
raise MXFP4MatmulPathCError(
165+
f"mxfp4 dequant: packed qdata shape {tuple(qdata.shape)} != expected "
166+
f"({rows}, {bytes_per_row}) for a ({rows}, {cols}) matrix"
167+
)
168+
n_blocks = cols // block_size
169+
if tuple(scales.shape) != (rows, n_blocks):
170+
raise MXFP4MatmulPathCError(
171+
f"mxfp4 dequant: scales shape {tuple(scales.shape)} != expected "
172+
f"({rows}, {n_blocks})"
173+
)
174+
nibbles = _unpack_nibbles_last_axis(qdata, n_along_axis=cols)
175+
return mxfp4_dequant_to_half(
176+
nibbles, scales, block_size=block_size, out_dtype=out_dtype
177+
)
178+
179+
180+
def mxfp4_matmul_path_c(
181+
A_qdata: mx.array,
182+
A_scales: mx.array,
183+
B_qdata: mx.array,
184+
B_scales: mx.array,
185+
*,
186+
M: int,
187+
N: int,
188+
K: int,
189+
out: mx.array,
190+
block_size: int = MXFP4_BLOCK_SIZE,
191+
) -> mx.array:
192+
"""MXFP4 ``A(M,K) @ B(N,K).T -> C(M,N)`` through the Path C cooperative GEMM.
193+
194+
``A_qdata``/``B_qdata`` are packed ``uint8`` e2m1 payloads (rows ``M``/``N``,
195+
each row ``ceil(K/2)`` bytes); ``A_scales``/``B_scales`` are the per-block-16
196+
fp32 scales (``(M, K//16)`` / ``(N, K//16)``). ``out`` is the caller-owned
197+
fp32 ``(M, N)`` output.
198+
199+
The e2m1 blocks are dequantized to ``half`` at the MLX boundary and fed to
200+
the proven Metal-4 cooperative-tensor ``matmul2d`` GEMM
201+
(``sparse_mla_grouped_qk_path_c``, ``transpose_B=True``). Shape dispatch is
202+
explicit: ``M%16==0, N%32==0, K%16==0`` and a legal cooperative tile MUST
203+
exist, else RAISE (RULE #1 -- no silent fallback to a degraded path).
204+
"""
205+
206+
if not _msl_transform.can_run_metal():
207+
raise MXFP4MatmulPathCError("mxfp4_matmul_path_c: MLX Metal unavailable")
208+
if block_size != MXFP4_BLOCK_SIZE:
209+
raise MXFP4MatmulPathCError(
210+
f"mxfp4_matmul_path_c: block_size={block_size} unsupported; OCP MX "
211+
f"fixes the block at {MXFP4_BLOCK_SIZE}"
212+
)
213+
if A_qdata.dtype != mx.uint8 or B_qdata.dtype != mx.uint8:
214+
raise MXFP4MatmulPathCError(
215+
"mxfp4_matmul_path_c: packed payloads must be mx.uint8; got "
216+
f"A={A_qdata.dtype} B={B_qdata.dtype}"
217+
)
218+
if tuple(out.shape) != (M, N) or out.dtype != mx.float32:
219+
raise MXFP4MatmulPathCError(
220+
f"mxfp4_matmul_path_c: out must be fp32 shape ({M}, {N}); got "
221+
f"{tuple(out.shape)} {out.dtype}"
222+
)
223+
# Explicit cooperative-tile dispatch BEFORE any dequant work -- a sub-tile
224+
# shape RAISES here, it is never silently routed to a slow scalar path.
225+
from cppmega_mlx.nn._tilelang.fp8_matmul_path_c import _coop_tile_for
226+
227+
coop = _coop_tile_for(int(M), int(N), int(K))
228+
if coop is None:
229+
raise MXFP4MatmulPathCError(
230+
f"mxfp4_matmul_path_c: GEMM shape M={M} N={N} K={K} admits no legal "
231+
"Metal-4 cooperative tile (needs M%16==0, N%32==0, K%16==0). MXFP4 "
232+
"has no native Apple FP4 ALU; sub-tile shapes are NOT served by a "
233+
"degraded path -- dequant via mxfp4_dequant_blockwise_2d and use the "
234+
"pure-MLX reference matmul, or pad to a cooperative-tileable shape."
235+
)
236+
237+
A_half = mxfp4_dequant_blockwise_2d(
238+
A_qdata, A_scales, rows=int(M), cols=int(K),
239+
block_size=block_size, out_dtype=mx.float16,
240+
)
241+
B_half = mxfp4_dequant_blockwise_2d(
242+
B_qdata, B_scales, rows=int(N), cols=int(K),
243+
block_size=block_size, out_dtype=mx.float16,
244+
)
245+
mx.eval(A_half, B_half)
246+
247+
# Reuse the committed, numerically-exact cooperative-tensor matmul2d GEMM
248+
# (transpose_B=True: A(M,K) @ B(N,K).T -> C(M,N)). It owns the same
249+
# _coop_tile_for dispatch and RAISES on sub-tile shapes.
250+
from cppmega_mlx.nn._tilelang.sparse_mla_path_c import (
251+
sparse_mla_grouped_qk_path_c,
252+
)
253+
254+
try:
255+
return sparse_mla_grouped_qk_path_c(A_half, B_half, out)
256+
except Exception as exc:
257+
raise MXFP4MatmulPathCError(
258+
f"mxfp4_matmul_path_c: cooperative matmul2d GEMM failed for "
259+
f"M={M} N={N} K={K} ({type(exc).__name__}: {exc})"
260+
) from exc
261+
262+
263+
def mxfp4_matmul_path_c_status() -> dict[str, Any]:
264+
"""Lightweight availability probe (mirrors the FP8 status surface)."""
265+
266+
if not _msl_transform.can_run_metal():
267+
return {"available": False, "reason": "MLX Metal unavailable"}
268+
try:
269+
import tilelang # noqa: F401
270+
except Exception as exc: # pragma: no cover
271+
return {"available": False, "reason": f"tilelang import failed: {exc}"}
272+
return {
273+
"available": True,
274+
"reason": "MXFP4 e2m1 dequant + cooperative matmul2d GEMM dispatchable",
275+
"codec": "mxfp4_e2m1_v1",
276+
"block_size": MXFP4_BLOCK_SIZE,
277+
"gemm": "sparse_mla_grouped matmul2d (transpose_B=True)",
278+
"needs_codegen_intrinsic": False,
279+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tier-3 Path C #10 regression: MXFP4 e2m1 dequant + cooperative GEMM.
2+
3+
Covers the real Path C surface in
4+
``cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c``:
5+
6+
* vectorized MLX dequant matches the per-row reference codec
7+
* the cooperative-tensor ``matmul2d`` GEMM matches a dequant-then-MLX
8+
matmul reference within the 4-bit tolerance (~5e-2)
9+
* RULE #1: a sub-tile shape RAISES (no silent degraded path)
10+
* RULE #1: a non-OCP block size RAISES
11+
12+
The Metal GEMM tests skip cleanly when MLX Metal is unavailable.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import numpy as np
18+
import mlx.core as mx
19+
import pytest
20+
21+
from cppmega_mlx.quant.mxfp4_metal import (
22+
MXFP4_BLOCK_SIZE,
23+
dequantize_mxfp4_blockwise,
24+
quantize_mxfp4_blockwise,
25+
)
26+
from cppmega_mlx.nn._tilelang import _msl_transform
27+
from cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c import (
28+
MXFP4MatmulPathCError,
29+
mxfp4_dequant_blockwise_2d,
30+
mxfp4_matmul_path_c,
31+
)
32+
33+
34+
_METAL = _msl_transform.can_run_metal()
35+
36+
37+
def _quant_matrix(x_np: np.ndarray):
38+
"""Pack an ``(rows, cols)`` fp32 matrix row-major (blocks tile cols=K)."""
39+
rows, cols = x_np.shape
40+
assert cols % MXFP4_BLOCK_SIZE == 0
41+
qrows, srows = [], []
42+
for r in range(rows):
43+
qd, sc = quantize_mxfp4_blockwise(mx.array(x_np[r]))
44+
qrows.append(np.asarray(qd))
45+
srows.append(np.asarray(sc))
46+
return mx.array(np.stack(qrows)), mx.array(np.stack(srows))
47+
48+
49+
def test_vectorized_dequant_matches_reference_codec():
50+
rng = np.random.default_rng(0)
51+
A = rng.standard_normal((16, 64)).astype(np.float32)
52+
Aq, As = _quant_matrix(A)
53+
got = np.asarray(
54+
mxfp4_dequant_blockwise_2d(Aq, As, rows=16, cols=64, out_dtype=mx.float32)
55+
).astype(np.float32)
56+
ref = np.stack([
57+
np.asarray(dequantize_mxfp4_blockwise(
58+
mx.array(np.asarray(Aq)[r]), mx.array(np.asarray(As)[r]), numel=64))
59+
for r in range(16)
60+
]).astype(np.float32)
61+
assert float(np.max(np.abs(got - ref))) < 1e-3
62+
63+
64+
def test_dequant_bad_block_size_raises():
65+
rng = np.random.default_rng(1)
66+
Aq, As = _quant_matrix(rng.standard_normal((16, 32)).astype(np.float32))
67+
with pytest.raises(MXFP4MatmulPathCError, match="block_size"):
68+
mxfp4_dequant_blockwise_2d(Aq, As, rows=16, cols=32, block_size=8)
69+
70+
71+
@pytest.mark.skipif(not _METAL, reason="MLX Metal backend unavailable")
72+
@pytest.mark.parametrize("shape", [(32, 64, 64), (128, 128, 128)])
73+
def test_matmul2d_gemm_parity(shape):
74+
M, N, K = shape
75+
rng = np.random.default_rng(7)
76+
A = rng.standard_normal((M, K)).astype(np.float32)
77+
B = rng.standard_normal((N, K)).astype(np.float32)
78+
Aq, As = _quant_matrix(A)
79+
Bq, Bs = _quant_matrix(B)
80+
A_dq = mxfp4_dequant_blockwise_2d(Aq, As, rows=M, cols=K, out_dtype=mx.float32)
81+
B_dq = mxfp4_dequant_blockwise_2d(Bq, Bs, rows=N, cols=K, out_dtype=mx.float32)
82+
C_ref = np.asarray(mx.matmul(A_dq, B_dq.T)).astype(np.float32)
83+
84+
out = mx.zeros((M, N), dtype=mx.float32)
85+
C = np.asarray(
86+
mxfp4_matmul_path_c(Aq, As, Bq, Bs, M=M, N=N, K=K, out=out)
87+
).astype(np.float32)
88+
rel = float(np.max(np.abs(C - C_ref)) / (np.max(np.abs(C_ref)) + 1e-6))
89+
assert rel < 5e-2, f"{shape}: rel_maxdiff {rel}"
90+
91+
92+
@pytest.mark.skipif(not _METAL, reason="MLX Metal backend unavailable")
93+
def test_subtile_shape_raises_no_silent_fallback():
94+
"""RULE #1: a shape with no legal cooperative tile RAISES."""
95+
rng = np.random.default_rng(3)
96+
Aq, As = _quant_matrix(rng.standard_normal((16, 32)).astype(np.float32))
97+
Bq, Bs = _quant_matrix(rng.standard_normal((16, 32)).astype(np.float32))
98+
out = mx.zeros((16, 16), dtype=mx.float32)
99+
with pytest.raises(MXFP4MatmulPathCError, match="no legal"):
100+
mxfp4_matmul_path_c(Aq, As, Bq, Bs, M=16, N=16, K=32, out=out)

0 commit comments

Comments
 (0)