Skip to content

Commit 42fcf94

Browse files
authored
support MXFP8 (#21146)
Differential Revision: D113113318 Pull Request resolved: #21146
1 parent 17c17e4 commit 42fcf94

9 files changed

Lines changed: 681 additions & 12 deletions

File tree

backends/mlx/builder/op_helpers.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,35 @@ def parse_dequant_nvfp4_node(
656656
return qdata, scale, per_tensor_scale, output_dtype
657657

658658

659+
def parse_dequant_mx_node(
660+
node: Node,
661+
) -> Optional[Tuple[Node, Node, torch.dtype, int, torch.dtype]]:
662+
"""Parse a torchao.dequantize_mx node.
663+
664+
Returns (qdata, scale, elem_dtype, block_size, output_dtype) or None if not a
665+
dequantize_mx node or the custom op is not registered. MX has no per-tensor
666+
scale and no affine zero-point; the block scale is always E8M0.
667+
"""
668+
target = get_aten_target(node.target)
669+
try:
670+
import executorch.extension.llm.export.mx # noqa: F401
671+
except ImportError:
672+
return None
673+
674+
if target is not torch.ops.torchao.dequantize_mx.default:
675+
return None
676+
677+
qdata, scale, elem_dtype, block_size = node.args[0:4]
678+
679+
output_dtype = torch.float32
680+
if len(node.args) > 4:
681+
output_dtype = node.args[4]
682+
elif "output_dtype" in node.kwargs:
683+
output_dtype = node.kwargs["output_dtype"]
684+
685+
return qdata, scale, elem_dtype, int(block_size), output_dtype
686+
687+
659688
def parse_dequant_int4_node(
660689
node: Node,
661690
) -> Optional[Tuple[Node, Node, Node, int, Optional[torch.dtype]]]:

backends/mlx/builder/program_builder.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,23 @@ def _process_nodes(self) -> None: # noqa C901
567567
if n.op in ("placeholder", "output"):
568568
dtype_error = _check_dtype(n)
569569
if dtype_error is not None:
570+
# A placeholder consumed entirely by pattern handlers (which
571+
# repack it into new constants -- e.g. an MX fp8/e8m0 weight
572+
# reinterpreted to uint32/uint8) is never emitted as an MLX
573+
# tensor, so its own (unserializable) dtype is irrelevant. Its
574+
# users are the pattern's head/body nodes, which at this point
575+
# have a handler assigned (by _apply_patterns) but are not yet
576+
# marked handled, so check for a claimed handler too.
577+
if (
578+
n.op == "placeholder"
579+
and n.users
580+
and all(
581+
self._is_handled(u) or self.node_info[u].handler is not None
582+
for u in n.users
583+
)
584+
):
585+
self._mark_supported(n)
586+
continue
570587
self._mark_unsupported(n, f"{n.op} {dtype_error}")
571588
continue
572589
self._mark_supported(n)

backends/mlx/patterns.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
emit_quantized_gather,
2424
emit_stop_position,
2525
parse_dequant_int4_node,
26+
parse_dequant_mx_node,
2627
parse_dequant_node,
2728
parse_dequant_nvfp4_node,
2829
regroup_affine_scales,
@@ -1166,6 +1167,196 @@ def __call__(self, P, n):
11661167
return out
11671168

11681169

1170+
def _mx_mlx_mode_bits(elem_dtype: torch.dtype):
1171+
"""Map an MX element dtype to the MLX (mode, bits). Rejects unsupported formats.
1172+
1173+
MLX's block-scaled kernels only implement E4M3 elements (mxfp8). Other MX
1174+
element encodings (e5m2, fp4/fp6, ...) are rejected rather than silently
1175+
mis-lowered.
1176+
"""
1177+
if elem_dtype == torch.float8_e4m3fn:
1178+
return "mxfp8", 8
1179+
raise ValueError(
1180+
f"MLX backend does not support MX element dtype {elem_dtype}; "
1181+
"only float8_e4m3fn (mxfp8) is supported."
1182+
)
1183+
1184+
1185+
def _mx_pack_for_mlx(P, qdata_node, scale_node):
1186+
"""Reinterpret ExportableMXTensor's native FP8/E8M0 storage to MLX layout.
1187+
1188+
ExportableMXTensor stores ``qdata`` as native FP8 (one value per byte) and
1189+
``scale`` as ``float8_e8m0fnu``; MLX's kernel wants ``qdata`` as uint32
1190+
(4 FP8 codes per word) and ``scale`` as uint8. Both conversions are pure bit
1191+
reinterpretations (``view``), registered as MLX constants.
1192+
"""
1193+
qdata_target, qdata = P.get_placeholder_target_and_tensor(qdata_node)
1194+
scale_target, scale = P.get_placeholder_target_and_tensor(scale_node)
1195+
w = qdata.contiguous().view(torch.uint8).view(torch.uint32)
1196+
sc = scale.contiguous().view(torch.uint8)
1197+
w_slot = P.make_or_get_constant(f"{qdata_target}_mx_packed", w)
1198+
scale_slot = P.make_or_get_constant(f"{scale_target}_mx_u8", sc)
1199+
return w_slot, scale_slot
1200+
1201+
1202+
@REGISTRY.register_pattern(name="MX_QUANTIZED_LINEAR")
1203+
class MXQuantizedLinearHandler(PatternHandler):
1204+
"""Fuse dequantize_mx + linear into QuantizedMatmulNode for the MX format.
1205+
1206+
Matches:
1207+
linear(x, dequantize_mx(qdata, scale, elem_dtype, block_size), bias)
1208+
1209+
Emits:
1210+
QuantizedMatmulNode [→ AddNode(bias)] [→ AsTypeNode]
1211+
1212+
The element dtype is mapped to the MLX ``mode``/``bits`` (rejecting formats
1213+
MLX can't lower). MX has no per-tensor scale, so no MultiplyNode is emitted.
1214+
"""
1215+
1216+
def __init__(self, head, body, qdata, scale, elem_dtype, block_size, output_dtype):
1217+
super().__init__(head, body)
1218+
self.qdata = qdata
1219+
self.scale = scale
1220+
self.elem_dtype = elem_dtype
1221+
self.block_size = block_size
1222+
self.output_dtype = output_dtype
1223+
1224+
@classmethod
1225+
def maybe_create(cls, ep, head):
1226+
if not match_target(head, torch.ops.aten.linear.default):
1227+
return None
1228+
x, dequant = head.args[0:2]
1229+
if not isinstance(dequant, Node):
1230+
return None
1231+
if not has_single_user(dequant):
1232+
return None
1233+
parsed = parse_dequant_mx_node(dequant)
1234+
if parsed is None:
1235+
return None
1236+
qdata, scale, elem_dtype, block_size, output_dtype = parsed
1237+
return cls(head, [dequant], qdata, scale, elem_dtype, block_size, output_dtype)
1238+
1239+
def __call__(self, P, n):
1240+
assert n == self.head
1241+
1242+
x_node, w_node = n.args[0:2]
1243+
b_node = n.args[2] if len(n.args) > 2 else None
1244+
1245+
mode, bits = _mx_mlx_mode_bits(self.elem_dtype)
1246+
needs_cast = x_node.meta["val"].dtype != self.output_dtype
1247+
has_bias = b_node is not None
1248+
1249+
w, scales = _mx_pack_for_mlx(P, self.qdata, self.scale)
1250+
x, bias = P.slot_map([x_node, b_node])
1251+
1252+
out = P.make_or_get_slot(n)
1253+
P.emit(
1254+
QuantizedMatmulNode(
1255+
x=P.slot_to_tid(x),
1256+
w=P.slot_to_tid(w),
1257+
scales=P.slot_to_tid(scales),
1258+
out=P.slot_to_tid(out),
1259+
biases=None,
1260+
group_size=self.block_size,
1261+
bits=bits,
1262+
mode=mode,
1263+
transpose=True,
1264+
)
1265+
)
1266+
1267+
if has_bias:
1268+
P.emit(
1269+
AddNode(
1270+
a=P.slot_to_tid(out),
1271+
b=P.slot_to_tid(bias),
1272+
out=P.slot_to_tid(out),
1273+
)
1274+
)
1275+
1276+
if needs_cast:
1277+
P.emit(
1278+
AsTypeNode(
1279+
x=P.slot_to_tid(out),
1280+
out=P.slot_to_tid(out),
1281+
scalar_type=torch_dtype_to_scalar_type(self.output_dtype),
1282+
)
1283+
)
1284+
1285+
return out
1286+
1287+
1288+
@REGISTRY.register_pattern(name="MX_QUANTIZED_EMBEDDING")
1289+
class MXQuantizedEmbeddingHandler(PatternHandler):
1290+
"""Fuse dequantize_mx + embedding into gather + DequantizeNode for the MX format.
1291+
1292+
Matches:
1293+
embedding(dequantize_mx(qdata, scale, elem_dtype, block_size), indices)
1294+
1295+
Emits:
1296+
TakeNode(qdata) → TakeNode(scales) → DequantizeNode [→ AsTypeNode]
1297+
"""
1298+
1299+
def __init__(self, head, body, qdata, scale, elem_dtype, block_size, output_dtype):
1300+
super().__init__(head, body)
1301+
self.qdata = qdata
1302+
self.scale = scale
1303+
self.elem_dtype = elem_dtype
1304+
self.block_size = block_size
1305+
self.output_dtype = output_dtype
1306+
1307+
@classmethod
1308+
def maybe_create(cls, ep, head):
1309+
if not match_target(head, torch.ops.aten.embedding.default):
1310+
return None
1311+
1312+
w, x = head.args[0:2]
1313+
if not isinstance(w, Node):
1314+
return None
1315+
if not has_single_user(w):
1316+
return None
1317+
parsed = parse_dequant_mx_node(w)
1318+
if parsed is None:
1319+
return None
1320+
qdata, scale, elem_dtype, block_size, output_dtype = parsed
1321+
return cls(head, [w], qdata, scale, elem_dtype, block_size, output_dtype)
1322+
1323+
def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot:
1324+
assert n == self.head
1325+
w_node, x_node = n.args[0:2]
1326+
1327+
mode, bits = _mx_mlx_mode_bits(self.elem_dtype)
1328+
x_dtype = x_node.meta["val"].dtype
1329+
needs_cast = self.output_dtype != x_dtype
1330+
1331+
qdata_slot, scales_slot = _mx_pack_for_mlx(P, self.qdata, self.scale)
1332+
(x,) = P.slot_map([x_node])
1333+
1334+
out = P.make_or_get_slot(n)
1335+
emit_quantized_gather(
1336+
P,
1337+
out,
1338+
x,
1339+
qdata_slot,
1340+
scales_slot,
1341+
None,
1342+
group_size=self.block_size,
1343+
bits=bits,
1344+
mode=mode,
1345+
out_dtype=self.output_dtype,
1346+
)
1347+
1348+
if needs_cast:
1349+
P.emit(
1350+
AsTypeNode(
1351+
x=P.slot_to_tid(out),
1352+
out=P.slot_to_tid(out),
1353+
scalar_type=torch_dtype_to_scalar_type(self.output_dtype),
1354+
)
1355+
)
1356+
1357+
return out
1358+
1359+
11691360
@REGISTRY.register_pattern(name="INT4_QUANTIZED_LINEAR")
11701361
class Int4QuantizedLinearHandler(PatternHandler):
11711362
"""Fuse dequantize_int4_tensor + linear into QuantizedMatmulNode(mode="affine").

backends/mlx/test/test_ops.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7556,6 +7556,84 @@ def create_inputs(self) -> Tuple[torch.Tensor, ...]:
75567556
return (x,)
75577557

75587558

7559+
class MXFP8QuantizedLinearModel(nn.Module):
7560+
"""Simple linear layer that will be quantized with MXFP8."""
7561+
7562+
def __init__(
7563+
self, in_features: int = 64, out_features: int = 128, bias: bool = True
7564+
):
7565+
super().__init__()
7566+
self.linear = nn.Linear(in_features, out_features, bias=bias)
7567+
7568+
def forward(self, x: torch.Tensor) -> torch.Tensor:
7569+
return self.linear(x)
7570+
7571+
7572+
@register_test
7573+
class MXFP8QuantizedLinearTest(OpTestCase):
7574+
"""Test case for MXFP8 quantized nn.Linear (mode="mxfp8", group_size 32)."""
7575+
7576+
name = "mxfp8_quantized_linear"
7577+
rtol = 0.1
7578+
atol = 0.1
7579+
7580+
def __init__(
7581+
self,
7582+
in_features: int = 64,
7583+
out_features: int = 128,
7584+
batch_size: int = 2,
7585+
seq_len: int = 16,
7586+
bias: bool = True,
7587+
dtype: torch.dtype = torch.float32,
7588+
):
7589+
self.in_features = in_features
7590+
self.out_features = out_features
7591+
self.batch_size = batch_size
7592+
self.seq_len = seq_len
7593+
self.bias = bias
7594+
self.dtype = dtype
7595+
7596+
parts = ["mxfp8_quantized_linear"]
7597+
if not bias:
7598+
parts.append("no_bias")
7599+
if dtype != torch.float32:
7600+
parts.append(str(dtype).split(".")[-1])
7601+
self.name = "_".join(parts)
7602+
7603+
@classmethod
7604+
def get_test_configs(cls) -> List["MXFP8QuantizedLinearTest"]:
7605+
return [
7606+
cls(),
7607+
cls(bias=False),
7608+
cls(dtype=torch.bfloat16),
7609+
cls(bias=False, dtype=torch.bfloat16),
7610+
]
7611+
7612+
def get_edge_compile_config(self):
7613+
from executorch.exir import EdgeCompileConfig
7614+
7615+
return EdgeCompileConfig(_check_ir_validity=False)
7616+
7617+
def create_model(self) -> nn.Module:
7618+
model = MXFP8QuantizedLinearModel(
7619+
self.in_features, self.out_features, bias=self.bias
7620+
)
7621+
model = model.to(self.dtype)
7622+
7623+
from executorch.extension.llm.export.mx import ExportableMXConfig
7624+
from torchao.quantization import quantize_
7625+
7626+
quantize_(model, ExportableMXConfig())
7627+
7628+
return model
7629+
7630+
def create_inputs(self) -> Tuple[torch.Tensor, ...]:
7631+
x = torch.randn(
7632+
self.batch_size, self.seq_len, self.in_features, dtype=self.dtype
7633+
)
7634+
return (x,)
7635+
7636+
75597637
def _make_int4_quantized_weight(weight: torch.Tensor, group_size: int) -> torch.Tensor:
75607638
"""Groupwise affine 4-bit quantize a ``(N, K)`` weight into an
75617639
``ExportableInt4Tensor`` (torchao ``Int4Tensor`` packed layout)."""

0 commit comments

Comments
 (0)