Skip to content

Commit c9b5aac

Browse files
committed
feat(nvfp4): wire nvfp4 (e2m1+block-scale) training dtype route into m04 (honest partial, fail-loud)
m04 --dtype rejected 'nvfp4' (argparse). Add a real nvfp4 route mirroring fp8_path_c: bf16 carrier, 4-bit e2m1+per-block-16 operands materialized per-op at the GEMM boundary. - scripts/_nvfp4_route.py (new): route identity/predicates, precision payload, the real nvfp4 forward-GEMM (forward_gemm_operands_e2m1_block_scale_metal_m4 -> mxfp4 codec -> the M4-verified mxfp4_matmul_path_c cooperative GEMM), and the RULE #1 fail-loud guard. - m04: --dtype nvfp4 accepted; precision_route nvfp4 branch; carrier bfloat16; primary fail-loud gate in _run_existing_training -> blocked_receipt with the precise unsupported-op reason. nvfp4 (real): the forward GEMM (Metal M4: all_finite, rel_rmse 0.132 vs bf16, expected for 4-bit). RAISE (11 ops, no kernel yet): cuda_blackwell_nvfp4_gemm, gemm backward/vjp, optimizer state, rmsnorm, swiglu, softmax, sparse_mla, mamba3, m2rnn, embedding/lm_head, residual. gb10 m04 --dtype nvfp4: status blocked, exact next blocker = cuda_blackwell_nvfp4_gemm (the fwd kernel is Metal-only and RAISES 'MLX Metal unavailable' on CUDA rather than degrading -- proof of fail-loud). Full Blackwell nvfp4 training needs a CUDA nvfp4 GEMM + bwd + non-GEMM ops + optimizer (separate stack, the NVFP4-via-TE roadmap item). This is the honest partial: precise actionable error instead of argparse rejection, Metal forward works. RULE #1: no silent bf16 downcast anywhere. 163 passed, no regression.
1 parent 7361b65 commit c9b5aac

3 files changed

Lines changed: 308 additions & 2 deletions

File tree

scripts/_nvfp4_route.py

Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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())

scripts/m04_train_step.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@
153153
validation_dataset_path,
154154
validate_config,
155155
)
156+
from scripts._nvfp4_route import ( # noqa: E402
157+
NVFP4_CARRIER_DTYPE,
158+
NVFP4_DTYPE,
159+
NVFP4_E2E_TRAINING_BLOCKER_TYPE,
160+
Nvfp4TrainingRouteUnavailable,
161+
nvfp4_route_requested,
162+
nvfp4_training_route_payload,
163+
nvfp4_training_route_unavailable_reason,
164+
raise_if_nvfp4_training_unsupported,
165+
)
156166

157167

158168
TARGET_PARQUET = (
@@ -458,12 +468,22 @@ def build_parser() -> argparse.ArgumentParser:
458468
parser.add_argument("--seq-len", type=int, default=128)
459469
parser.add_argument(
460470
"--dtype",
461-
choices=("float32", "float16", "bfloat16", FP8_PATH_B_DTYPE, FP8_PATH_C_DTYPE),
471+
choices=(
472+
"float32",
473+
"float16",
474+
"bfloat16",
475+
FP8_PATH_B_DTYPE,
476+
FP8_PATH_C_DTYPE,
477+
NVFP4_DTYPE,
478+
),
462479
default="bfloat16",
463480
help=(
464481
"Training dtype/precision route. fp8_path_b enables the explicit "
465482
"non-Path-C FP8 reference baseline; fp8_path_c enables existing "
466-
"Path C model ops with a bf16 carrier."
483+
"Path C model ops with a bf16 carrier; nvfp4 enables the Blackwell "
484+
"e2m1 + block-scale 4-bit route (carrier bf16; real forward-GEMM "
485+
"operands, fails loud naming the training ops that lack an nvfp4 "
486+
"kernel -- no silent bf16 downcast)."
467487
),
468488
)
469489
parser.add_argument(
@@ -1034,6 +1054,8 @@ def carrier_dtype_for_acceptance(
10341054
) -> str:
10351055
if fp8_training_route_requested(args):
10361056
return FP8_PATH_C_CARRIER_DTYPE
1057+
if nvfp4_route_requested(args):
1058+
return NVFP4_CARRIER_DTYPE
10371059
return str(getattr(args, "dtype", ""))
10381060

10391061

@@ -1435,6 +1457,8 @@ def precision_route_payload(
14351457
"hidden_wrapper_quantization_allowed": False,
14361458
"kernel_boundary_quantization_allowed": False,
14371459
}
1460+
if nvfp4_route_requested(args):
1461+
return nvfp4_training_route_payload(args)
14381462
return {
14391463
"requested": str(getattr(args, "dtype", "")),
14401464
"kind": "native_mlx_dtype",
@@ -11326,6 +11350,24 @@ def _run_existing_training(
1132611350
return blocked_receipt(
1132711351
args, str(exc), type(exc).__name__
1132811352
), 0 if args.dry_run_json else 2
11353+
# RULE #1 fail-loud NVFP4 gate. --dtype nvfp4 is an accepted training route
11354+
# (carrier bf16, e2m1 + block-scale forward-GEMM operands are real and
11355+
# M4-verified) but a full training step needs nvfp4 kernels that do not
11356+
# exist yet. We RAISE a precise blocked receipt naming the missing ops
11357+
# instead of silently downcasting them to bf16. The dry-run path still emits
11358+
# the route metadata so --dry-run-json reports the wired route + next
11359+
# blocker. The smoke gate exercises the one op that IS nvfp4 (the e2m1 GEMM)
11360+
# so the route does real nvfp4 work before reporting the precise blocker.
11361+
if nvfp4_route_requested(config):
11362+
return (
11363+
blocked_receipt(
11364+
args,
11365+
nvfp4_training_route_unavailable_reason(),
11366+
NVFP4_E2E_TRAINING_BLOCKER_TYPE,
11367+
probe_allocation=False,
11368+
),
11369+
0 if args.dry_run_json else 2,
11370+
)
1132911371
fp8_producer_gate = fp8_path_c_producer_gate_payload(config)
1133011372
if fp8_producer_gate["fail_closed"]:
1133111373
reason_type = str(fp8_producer_gate["status"])
@@ -11453,6 +11495,11 @@ def run_local_gb10_quarter_training(
1145311495
),
1145411496
2,
1145511497
)
11498+
# Defensive RULE #1 guard for direct callers of this route (the primary
11499+
# gate lives in _run_existing_training). nvfp4 must never reach the model
11500+
# build / forward in a silent bf16 carrier -- RAISE naming the missing
11501+
# nvfp4 training kernels.
11502+
raise_if_nvfp4_training_unsupported(config)
1145611503

1145711504
model: Any | None = None
1145811505
optimizer: Any | None = None

scripts/train_hybrid_tiny.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@
7272
# until the model graph owns native FP8 producers.
7373
"fp8_path_b": mx.bfloat16,
7474
"fp8_path_c": mx.bfloat16,
75+
# NVFP4 (e2m1 + per-block-16 scale) Blackwell training route. Carrier is
76+
# bf16 exactly like the fp8 routes; the 4-bit e2m1 operands are materialized
77+
# per-op at the GEMM boundary. m04_train_step owns the fail-loud gate that
78+
# RAISES for the training ops that lack an nvfp4 kernel (no bf16 downcast).
79+
"nvfp4": mx.bfloat16,
7580
}
7681
STRUCTURE_MODEL_KWARG_NAMES = (
7782
"structure_ids",

0 commit comments

Comments
 (0)