|
| 1 | +"""NVFP4 (e2m1 + block-scale) training-dtype route for m04_train_step. |
| 2 | +
|
| 3 | +NVFP4 is the native Blackwell (sm_120/sm_121, e.g. gb10) 4-bit *training* format: |
| 4 | +e2m1 elements with a 2D block scaling (NVIDIA's recipe uses E4M3 block scales over |
| 5 | +16-element blocks; the OCP MX e2m1 codec this repo already ships uses fp32 block |
| 6 | +scales over 16-element blocks -- the same block geometry). The goal of NVFP4 |
| 7 | +training is 2x operand memory + throughput over MXFP8. |
| 8 | +
|
| 9 | +This module wires the *route* (mirroring fp8_path_c): a carrier dtype, a route |
| 10 | +predicate, a precision-route payload, and -- crucially under RULE #1 -- a |
| 11 | +FAIL-LOUD gate that RAISES with exactly which training ops lack an NVFP4 kernel |
| 12 | +instead of silently downcasting to bf16. |
| 13 | +
|
| 14 | +What IS nvfp4 today (M4-verified, committed): |
| 15 | + * forward GEMM operands: quantize bf16 -> e2m1 + per-block-16 scale via the |
| 16 | + ``cppmega_mlx.quant.mxfp4_metal`` codec, multiply via the proven |
| 17 | + ``cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c`` cooperative-tensor GEMM |
| 18 | + (commit 29c112d). :func:`nvfp4_gemm_smoke` exercises exactly this. |
| 19 | +
|
| 20 | +What is NOT nvfp4 yet (no kernel -> the route RAISES, it does not bf16-fallback): |
| 21 | + * backward / VJP for the e2m1 GEMM (no nvfp4 grad kernel), |
| 22 | + * optimizer state / update in nvfp4 (AdamW moments stay fp32 only), |
| 23 | + * every non-GEMM op in the HybridTinyLM graph (RMSNorm, SwiGLU, softmax, |
| 24 | + Sparse-MLA attention scores, Mamba3 selective scan, M2RNN recurrence, |
| 25 | + embedding / LM-head, residual adds) -- none have an nvfp4 kernel. |
| 26 | +
|
| 27 | +Because a real training step needs all of the above, this route delivers the |
| 28 | +honest partial: ``--dtype nvfp4`` is accepted (no argparse rejection), the |
| 29 | +nvfp4 forward-GEMM operand path is real and smoke-tested, and the training step |
| 30 | +RAISES :class:`Nvfp4TrainingRouteUnavailable` naming the precise missing |
| 31 | +kernels. No silent bf16 downcast anywhere. |
| 32 | +""" |
| 33 | + |
| 34 | +from __future__ import annotations |
| 35 | + |
| 36 | +import argparse |
| 37 | +from typing import Any |
| 38 | + |
| 39 | +import mlx.core as mx |
| 40 | + |
| 41 | +NVFP4_DTYPE = "nvfp4" |
| 42 | + |
| 43 | +# NVFP4 carries parameters/activations in bf16 exactly like the fp8_path_* routes |
| 44 | +# until the model graph owns native nvfp4 producers; the 4-bit e2m1 payloads are |
| 45 | +# materialized per-op at the GEMM boundary, not as the carrier dtype. |
| 46 | +NVFP4_CARRIER_DTYPE = "bfloat16" |
| 47 | + |
| 48 | +# OCP-MX / NVFP4 block geometry: 16 elements per block, one scale per block. |
| 49 | +NVFP4_BLOCK_SIZE = 16 |
| 50 | +NVFP4_ELEMENT_FORMAT = "e2m1" |
| 51 | +NVFP4_BLOCK_SCALE_FORMAT = "fp32_block_scale_v1" # repo codec; NVIDIA uses E4M3 |
| 52 | + |
| 53 | +NVFP4_ROUTE_KIND = "nvfp4" |
| 54 | +NVFP4_E2E_TRAINING_BLOCKER_TYPE = "nvfp4_training_route_unavailable" |
| 55 | + |
| 56 | +# The forward GEMM operand path that IS nvfp4 today. NOTE: the GEMM kernel |
| 57 | +# (mxfp4_matmul_path_c) is a Metal/Apple-M4 cooperative-tensor kernel -- it |
| 58 | +# RAISES "MLX Metal unavailable" on CUDA/Blackwell (gb10) rather than falling |
| 59 | +# back, so on gb10 even this op needs a CUDA/Blackwell nvfp4 GEMM kernel (listed |
| 60 | +# in NVFP4_UNSUPPORTED_TRAINING_OPS as ``cuda_blackwell_nvfp4_gemm``). The |
| 61 | +# operand codec (e2m1 + block scale) is backend-agnostic and runs anywhere. |
| 62 | +NVFP4_SUPPORTED_OPS = ("forward_gemm_operands_e2m1_block_scale_metal_m4",) |
| 63 | + |
| 64 | +# Ops that have NO nvfp4 kernel. The training step RAISES naming these rather |
| 65 | +# than silently running them in bf16 (RULE #1: no silent downcast). |
| 66 | +NVFP4_UNSUPPORTED_TRAINING_OPS = ( |
| 67 | + "cuda_blackwell_nvfp4_gemm", # forward GEMM kernel exists for Metal/M4 only |
| 68 | + "gemm_backward_vjp", # no nvfp4 grad kernel for the e2m1 GEMM |
| 69 | + "optimizer_state_and_update", # AdamW moments are fp32-only; no nvfp4 state |
| 70 | + "rmsnorm", |
| 71 | + "swiglu_ffn", |
| 72 | + "softmax", |
| 73 | + "sparse_mla_attention", # nvfp4 attention scores/PV: no kernel |
| 74 | + "mamba3_selective_scan", |
| 75 | + "m2rnn_recurrence", |
| 76 | + "embedding_and_lm_head", |
| 77 | + "residual_add", |
| 78 | +) |
| 79 | + |
| 80 | + |
| 81 | +class Nvfp4TrainingRouteUnavailable(RuntimeError): |
| 82 | + """Raised when --dtype nvfp4 reaches a training op that has no nvfp4 kernel. |
| 83 | +
|
| 84 | + Carries the precise op list so the m04 receipt reports an actionable next |
| 85 | + blocker instead of an argparse rejection or a silent bf16 downcast. |
| 86 | + """ |
| 87 | + |
| 88 | + |
| 89 | +def nvfp4_dtype_requested( |
| 90 | + args: "argparse.Namespace | Any", |
| 91 | +) -> bool: |
| 92 | + """Return whether the CLI/config dtype names the NVFP4 route.""" |
| 93 | + |
| 94 | + return str(getattr(args, "dtype", "")).strip().lower() == NVFP4_DTYPE |
| 95 | + |
| 96 | + |
| 97 | +# The NVFP4 route has no AUTO long-seq gate of its own (unlike fp8_path_c, whose |
| 98 | +# Path C arenas blow up at long seq). nvfp4_route_requested == dtype_requested. |
| 99 | +nvfp4_route_requested = nvfp4_dtype_requested |
| 100 | + |
| 101 | + |
| 102 | +def nvfp4_gemm_smoke( |
| 103 | + *, |
| 104 | + M: int = 16, |
| 105 | + N: int = 32, |
| 106 | + K: int = 64, |
| 107 | + seed: int = 0, |
| 108 | +) -> dict[str, Any]: |
| 109 | + """Exercise the one op that IS nvfp4: forward e2m1 + block-scale GEMM. |
| 110 | +
|
| 111 | + Quantizes two bf16 operands to e2m1 + per-block-16 scale with the committed |
| 112 | + mxfp4 codec, multiplies them through the M4-verified cooperative-tensor |
| 113 | + ``mxfp4_matmul_path_c`` GEMM, and returns finite-ness + the relative error |
| 114 | + vs the bf16 reference. Shapes default to the minimal cooperative tile |
| 115 | + (M%16==0, N%32==0, K%16==0). RAISES (never falls back) if Metal/kernel is |
| 116 | + unavailable or a shape admits no cooperative tile. |
| 117 | + """ |
| 118 | + |
| 119 | + from cppmega_mlx.quant.mxfp4_metal import quantize_mxfp4_blockwise |
| 120 | + from cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c import ( |
| 121 | + MXFP4_BLOCK_SIZE, |
| 122 | + mxfp4_matmul_path_c, |
| 123 | + ) |
| 124 | + |
| 125 | + if K % NVFP4_BLOCK_SIZE != 0: |
| 126 | + raise Nvfp4TrainingRouteUnavailable( |
| 127 | + f"nvfp4_gemm_smoke: K={K} must be a multiple of the block size " |
| 128 | + f"{NVFP4_BLOCK_SIZE}" |
| 129 | + ) |
| 130 | + |
| 131 | + mx.random.seed(seed) |
| 132 | + a = mx.random.normal((M, K)).astype(mx.bfloat16) |
| 133 | + b = mx.random.normal((N, K)).astype(mx.bfloat16) |
| 134 | + |
| 135 | + def _quant_rows(mat: mx.array, rows: int, cols: int) -> tuple[mx.array, mx.array]: |
| 136 | + # Per-row e2m1 + block-scale quantization, packed to (rows, cols//2) |
| 137 | + # uint8 nibbles and (rows, cols//16) fp32 scales -- the layout the |
| 138 | + # mxfp4_matmul_path_c kernel consumes. |
| 139 | + q_rows = [] |
| 140 | + s_rows = [] |
| 141 | + bytes_per_row = (cols + 1) // 2 |
| 142 | + n_blocks = cols // NVFP4_BLOCK_SIZE |
| 143 | + for r in range(rows): |
| 144 | + qd, sc = quantize_mxfp4_blockwise( |
| 145 | + mat[r], block_size=MXFP4_BLOCK_SIZE |
| 146 | + ) |
| 147 | + if int(qd.shape[0]) != bytes_per_row: |
| 148 | + raise Nvfp4TrainingRouteUnavailable( |
| 149 | + f"nvfp4_gemm_smoke: packed row len {int(qd.shape[0])} != " |
| 150 | + f"{bytes_per_row} for cols={cols}" |
| 151 | + ) |
| 152 | + if int(sc.shape[0]) != n_blocks: |
| 153 | + raise Nvfp4TrainingRouteUnavailable( |
| 154 | + f"nvfp4_gemm_smoke: scale row len {int(sc.shape[0])} != " |
| 155 | + f"{n_blocks} blocks" |
| 156 | + ) |
| 157 | + q_rows.append(qd.reshape(1, bytes_per_row)) |
| 158 | + s_rows.append(sc.reshape(1, n_blocks)) |
| 159 | + return mx.concatenate(q_rows, axis=0), mx.concatenate(s_rows, axis=0) |
| 160 | + |
| 161 | + a_q, a_s = _quant_rows(a, M, K) |
| 162 | + b_q, b_s = _quant_rows(b, N, K) |
| 163 | + out = mx.zeros((M, N), dtype=mx.float32) |
| 164 | + |
| 165 | + out = mxfp4_matmul_path_c( |
| 166 | + a_q, a_s, b_q, b_s, M=M, N=N, K=K, out=out, |
| 167 | + block_size=MXFP4_BLOCK_SIZE, |
| 168 | + ) |
| 169 | + mx.eval(out) |
| 170 | + |
| 171 | + ref = (a.astype(mx.float32) @ b.astype(mx.float32).T) |
| 172 | + mx.eval(ref) |
| 173 | + all_finite = bool(mx.all(mx.isfinite(out))) |
| 174 | + diff = (out - ref).reshape(-1) |
| 175 | + denom = float(mx.sqrt(mx.mean(ref.reshape(-1) * ref.reshape(-1)))) + 1e-12 |
| 176 | + rel_rmse = float(mx.sqrt(mx.mean(diff * diff))) / denom |
| 177 | + |
| 178 | + return { |
| 179 | + "op": NVFP4_SUPPORTED_OPS[0], |
| 180 | + "M": M, |
| 181 | + "N": N, |
| 182 | + "K": K, |
| 183 | + "block_size": NVFP4_BLOCK_SIZE, |
| 184 | + "element_format": NVFP4_ELEMENT_FORMAT, |
| 185 | + "block_scale_format": NVFP4_BLOCK_SCALE_FORMAT, |
| 186 | + "all_finite": all_finite, |
| 187 | + "rel_rmse_vs_bf16": rel_rmse, |
| 188 | + "kernel": "cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c.mxfp4_matmul_path_c", |
| 189 | + "codec": "cppmega_mlx.quant.mxfp4_metal.quantize_mxfp4_blockwise", |
| 190 | + } |
| 191 | + |
| 192 | + |
| 193 | +def nvfp4_training_route_payload( |
| 194 | + args: "argparse.Namespace | Any", |
| 195 | +) -> dict[str, Any]: |
| 196 | + """precision_route payload for the NVFP4 route (mirrors fp8_path_c shape).""" |
| 197 | + |
| 198 | + requested = nvfp4_route_requested(args) |
| 199 | + return { |
| 200 | + "requested": NVFP4_DTYPE, |
| 201 | + "kind": NVFP4_ROUTE_KIND, |
| 202 | + "status": NVFP4_E2E_TRAINING_BLOCKER_TYPE, |
| 203 | + "blocker_type": NVFP4_E2E_TRAINING_BLOCKER_TYPE if requested else None, |
| 204 | + "carrier_dtype": NVFP4_CARRIER_DTYPE, |
| 205 | + "element_format": NVFP4_ELEMENT_FORMAT, |
| 206 | + "block_size": NVFP4_BLOCK_SIZE, |
| 207 | + "block_scale_format": NVFP4_BLOCK_SCALE_FORMAT, |
| 208 | + "nvfp4_supported_ops": list(NVFP4_SUPPORTED_OPS), |
| 209 | + "nvfp4_unsupported_training_ops": list(NVFP4_UNSUPPORTED_TRAINING_OPS), |
| 210 | + "forward_gemm_kernel": ( |
| 211 | + "cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c.mxfp4_matmul_path_c" |
| 212 | + ), |
| 213 | + "operand_codec": ( |
| 214 | + "cppmega_mlx.quant.mxfp4_metal.quantize_mxfp4_blockwise" |
| 215 | + ), |
| 216 | + "full_end_to_end_training_available": False, |
| 217 | + "large_tensor_staging_allowed": False, |
| 218 | + "hidden_wrapper_quantization_allowed": False, |
| 219 | + "kernel_boundary_quantization_allowed": False, |
| 220 | + } |
| 221 | + |
| 222 | + |
| 223 | +def nvfp4_training_route_unavailable_reason() -> str: |
| 224 | + """The precise, actionable RAISE message for the NVFP4 training step.""" |
| 225 | + |
| 226 | + ops = ", ".join(NVFP4_UNSUPPORTED_TRAINING_OPS) |
| 227 | + return ( |
| 228 | + "nvfp4 is wired as a training dtype route (carrier bf16, e2m1 + " |
| 229 | + f"per-block-{NVFP4_BLOCK_SIZE} scale operands) and the forward-GEMM " |
| 230 | + "operand path is real and M4-verified " |
| 231 | + "(cppmega_mlx.nn._tilelang.mxfp4_matmul_path_c). A full training step " |
| 232 | + "additionally needs nvfp4 kernels that DO NOT EXIST yet: " |
| 233 | + f"{ops}. Rather than silently downcast these ops to bf16 (forbidden), " |
| 234 | + "the nvfp4 route fails loud here. Next blocker: implement the nvfp4 " |
| 235 | + "GEMM backward/VJP, then non-GEMM ops, then optimizer-state support; " |
| 236 | + "run --dtype fp8_path_c or --dtype bfloat16 for a full training step " |
| 237 | + "today." |
| 238 | + ) |
| 239 | + |
| 240 | + |
| 241 | +def raise_if_nvfp4_training_unsupported( |
| 242 | + args: "argparse.Namespace | Any", |
| 243 | +) -> None: |
| 244 | + """RAISE the precise nvfp4 training blocker if --dtype nvfp4 is requested. |
| 245 | +
|
| 246 | + Call this on the training critical path BEFORE any op would otherwise run |
| 247 | + in bf16. This is the RULE #1 fail-loud guard: it never returns a degraded |
| 248 | + bf16 path for nvfp4; it raises with WHERE (this guard) + WHAT (the missing |
| 249 | + nvfp4 training kernels). |
| 250 | + """ |
| 251 | + |
| 252 | + if not nvfp4_route_requested(args): |
| 253 | + return |
| 254 | + raise Nvfp4TrainingRouteUnavailable(nvfp4_training_route_unavailable_reason()) |
0 commit comments