|
| 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 | + } |
0 commit comments