diff --git a/backends/mlx/builder/op_helpers.py b/backends/mlx/builder/op_helpers.py index 669f328716e..debe266d8ad 100644 --- a/backends/mlx/builder/op_helpers.py +++ b/backends/mlx/builder/op_helpers.py @@ -519,6 +519,7 @@ def to_mlx_qparams( zero_point: torch.Tensor, bits: int, compute_biases: bool = True, + prepacked: bool = False, ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: """ Convert TorchAO quantization params to MLX format. @@ -536,10 +537,31 @@ def to_mlx_qparams( Returns (Q, None) in this case. This is valid when zero_point is all zeros, as the C++ runtime will compute biases = -scales * 2^(bits-1). + prepacked: If True, ``qdata`` is already nibble-packed uint8 holding the + unsigned values ``q + offset`` (two 4-bit values per byte, + even index -> low nibble) — the exact layout this function + produces for ``bits == 4``. It is reinterpreted as uint32 + directly (``view``), skipping the int8 -> uint32 repack. + Only supported for ``bits == 4``. """ - assert qdata.dtype == torch.int8 offset = 2 ** (bits - 1) + if prepacked: + assert bits == 4, "prepacked to_mlx_qparams only supports 4-bit" + assert ( + qdata.dtype == torch.uint8 + ), f"prepacked qdata must be uint8, got {qdata.dtype}" + # (rows, cols//2) uint8 -> (rows, cols//8) uint32. cols//2 must be a + # multiple of 4, i.e. in_features a multiple of 8 (holds: gs >= 32). + assert qdata.shape[-1] % 4 == 0, "packed cols must be a multiple of 4" + Q = qdata.contiguous().view(torch.uint32) + if compute_biases: + B = -scale * (zero_point.to(scale.dtype) + offset) + return Q, B + return Q, None + + assert qdata.dtype == torch.int8 + # Pack data into a contiguous uint32 bitstream. cols*bits must be a # multiple of 32 (holds since in_features is a multiple of group_size>=32). rows, cols = qdata.shape @@ -640,7 +662,9 @@ def parse_dequant_int4_node( """Parse a torchao.dequantize_int4_tensor node. Returns (qdata, scale, zero_point, group_size, output_dtype) or None if not a - dequantize_int4_tensor node or the custom op is not registered. + dequantize_int4_tensor node or the custom op is not registered. ``group_size`` + is the MLX-legal group_size (16/32/64/128); coarser int4 groups (e.g. 256) + are regrouped to it via ``regroup_affine_scales`` at emit time. """ target = get_aten_target(node.target) try: @@ -652,6 +676,10 @@ def parse_dequant_int4_node( return None qdata, scale, zero_point, group_size = node.args[0:4] + mlx_group_size = mlx_affine_group_size(int(group_size)) + if mlx_group_size is None: + return None + group_size = mlx_group_size output_dtype = None if len(node.args) > 4: @@ -662,6 +690,60 @@ def parse_dequant_int4_node( return qdata, scale, zero_point, group_size, output_dtype +_MLX_AFFINE_GROUP_SIZES = (128, 64, 32, 16) + + +def mlx_affine_group_size(group_size: int) -> Optional[int]: + """Largest MLX-supported group_size (128/64/32/16) that divides ``group_size``. + + MLX's affine quantized kernels only support group_size in {16, 32, 64, 128}. + torchao may quantize with a coarser or per-axis group_size (e.g. 256 or a + full row of 5376). A coarse group is a stack of finer groups that share one + scale, so any legal divisor is exact after repeating scale/zero_point (see + ``regroup_affine_scales``). Returns ``group_size`` unchanged when already + legal, the coarsest legal divisor otherwise, or ``None`` when none divides. + """ + if group_size in _MLX_AFFINE_GROUP_SIZES: + return group_size + for candidate in _MLX_AFFINE_GROUP_SIZES: + if group_size % candidate == 0: + return candidate + return None + + +def regroup_affine_scales( + scale: torch.Tensor, + zero_point: torch.Tensor, + in_features: int, + target_group_size: int, +) -> Tuple[torch.Tensor, torch.Tensor, bool]: + """Repeat per-group scale/zero_point so the effective group_size becomes + ``target_group_size`` (an MLX-legal size from ``mlx_affine_group_size``). + + ``scale``/``zero_point`` are shaped ``[..., in_features // old_group_size]``. + A coarse group shares one scale across ``old_group_size // target_group_size`` + finer groups, so repeat-interleaving along the last axis is numerically exact. + + Returns ``(scale, zero_point, changed)``; a no-op (``changed=False``) when the + group_size is already ``target_group_size``. + """ + old_groups = scale.shape[-1] + old_group_size = in_features // old_groups + assert ( + old_group_size >= target_group_size and old_group_size % target_group_size == 0 + ), ( + f"cannot regroup: weight group_size={old_group_size} is finer than, or " + f"not a multiple of, target_group_size={target_group_size} — " + f"repeat-interleave can only refine groups" + ) + repeat = old_group_size // target_group_size + if repeat == 1: + return scale, zero_point, False + scale = scale.repeat_interleave(repeat, dim=-1) + zero_point = zero_point.repeat_interleave(repeat, dim=-1) + return scale, zero_point, True + + def parse_dequant_node( node: Node, ) -> Optional[Tuple[Node, Node, Node, int, int, Optional[torch.dtype], int]]: @@ -673,7 +755,9 @@ def parse_dequant_node( - Conv2d weights (4D): block_size=[1, 32, 1, 1] → quantized_dim=1 Returns (qdata, scale, zero_point, group_size, bits, out_dtype, quantized_dim) - or None if unsupported. + or None if unsupported. ``group_size`` is the MLX-legal group_size (16/32/64/ + 128); when the weight uses a coarser group, callers must regroup scale/ + zero_point to it via ``regroup_affine_scales`` before packing. """ qdata, block_size, scale, zero_point, dtype, qmin, qmax = node.args[0:7] out_dtype = ( @@ -687,8 +771,13 @@ def parse_dequant_node( if len(non_one) != 1: return None quantized_dim, group_size = non_one[0] - if group_size not in [16, 32, 64, 128]: + # MLX kernels only support group_size in {16,32,64,128}. Coarser/per-axis + # groups are lowered by repeating scale/zero_point to the coarsest legal + # divisor at emit time (regroup_affine_scales); reject if none divides. + mlx_group_size = mlx_affine_group_size(group_size) + if mlx_group_size is None: return None + group_size = mlx_group_size # MLX supports 2,3,4,5,6,8-bit affine quantization. to_mlx_qparams packs # 2/4/8 via fast paths and other widths (e.g. 5, 6) via a general diff --git a/backends/mlx/custom_ops.py b/backends/mlx/custom_ops.py index 5605b59c543..3f268369d5e 100644 --- a/backends/mlx/custom_ops.py +++ b/backends/mlx/custom_ops.py @@ -354,6 +354,17 @@ def gather_qmm( s_sel = scales b_sel = biases + subbyte_packed = w_sel.dtype == torch.uint8 + if subbyte_packed: + assert ( + bits == 4 + ), "Subbyte packing qdata (uint8) is only supported for bits=4 now." + + offset = 2 ** (bits - 1) + lo = (w_sel & 0x0F).to(torch.int16) + hi = ((w_sel >> 4) & 0x0F).to(torch.int16) + w_sel = torch.stack([lo, hi], dim=-1).reshape(*w_sel.shape[:-1], -1) - offset + # Dequantize w_float = w_sel.to(x.dtype) s_expanded = s_sel.repeat_interleave(group_size, dim=-1) diff --git a/backends/mlx/llm/switch.py b/backends/mlx/llm/switch.py index 28d408cbd71..69fcb6eeee7 100644 --- a/backends/mlx/llm/switch.py +++ b/backends/mlx/llm/switch.py @@ -93,6 +93,10 @@ def pack(self): - Quantized: extracts inner tensors (qdata, scale, zero_point), stacks into [E, out, in_packed] buffers. Weight layout matches mlx::gather_qmm's expectations (transpose=True handles transposition). + 4-bit qdata is nibble-packed to uint8 [E, out, in//2] (two values per + byte, value = signed q + 8) so it stays packed end-to-end — the + gather_qmm lowering reinterprets it as uint32 directly. 8-bit qdata + keeps the int8 [E, out, in] layout. - Unquantized: stacks weight.data into [E, out, in], then pretransposes to [E, in, out] so gather_mm receives the correct layout directly (no runtime transpose needed). @@ -100,24 +104,60 @@ def pack(self): if self._packed: return + from executorch.extension.llm.export.int4 import ExportableInt4Tensor + from torchao.quantization import IntxUnpackedToInt8Tensor + w0 = self.experts[0].weight self._is_quantized = hasattr(w0, "qdata") - if self._is_quantized: - _, metadata = w0.__tensor_flatten__() - self.group_size = metadata["block_size"][-1] - + if isinstance(w0, ExportableInt4Tensor): + # Per-expert ExportableInt4Tensor: qdata is already nibble-packed + # uint8 (out, in//2); scale/zero_point are (in//gs, out) unsigned. + # Stack into the gather layout the gather_qmm handler expects + # (packed uint8 qdata + signed zero_point). + self.group_size = w0.group_size self.register_buffer( - "qdata", - torch.stack([e.weight.qdata for e in self.experts]), + "qdata", torch.stack([e.weight.qdata for e in self.experts]) ) self.register_buffer( "scale", - torch.stack([e.weight.scale for e in self.experts]), + torch.stack([e.weight.scale.t().contiguous() for e in self.experts]), ) self.register_buffer( "zero_point", - torch.stack([e.weight.zero_point for e in self.experts]), + torch.stack( + [ + (e.weight.zero_point.to(torch.int16) - 8) + .t() + .contiguous() + .to(torch.int8) + for e in self.experts + ] + ), + ) + elif isinstance(w0, IntxUnpackedToInt8Tensor): + _, metadata = w0.__tensor_flatten__() + self.group_size = metadata["block_size"][-1] + + qdata = torch.stack([e.weight.qdata for e in self.experts]) + scale = torch.stack([e.weight.scale for e in self.experts]) + zero_point = torch.stack([e.weight.zero_point for e in self.experts]) + + # Nibble-pack 4-bit qdata into uint8 [E, out, in//2] so experts stay + # packed through export (half the storage; gather_qmm views as uint32). + if metadata.get("target_dtype") == torch.int4: + E, out, in_features = qdata.shape + qu = (qdata.to(torch.int16) + 8).to(torch.uint8) + qu = qu.reshape(E, out, in_features // 2, 2) + qdata = (qu[..., 0] | (qu[..., 1] << 4)).contiguous() + + self.register_buffer("qdata", qdata) + self.register_buffer("scale", scale) + self.register_buffer("zero_point", zero_point) + elif self._is_quantized: + raise TypeError( + f"Unsupported quantized expert weight type: {type(w0).__name__}; " + "expected ExportableInt4Tensor or IntxUnpackedToInt8Tensor" ) else: # Stack [E, out, in] then pretranspose to [E, in, out] diff --git a/backends/mlx/ops.py b/backends/mlx/ops.py index 39167445c37..002cda892f3 100644 --- a/backends/mlx/ops.py +++ b/backends/mlx/ops.py @@ -25,6 +25,7 @@ emit_quantized_biases, emit_shape, parse_dequant_node, + regroup_affine_scales, to_mlx_qparams, torch_dtype_to_scalar_type, ) @@ -1849,7 +1850,12 @@ def _gather_qmm_handler(P: MLXProgramBuilder, n: Node) -> Slot: if biases_node is not None: zp_target, zp_data = P.get_placeholder_target_and_tensor(biases_node) - # Reshape 3D [E, out, in] to 2D for to_mlx_qparams, then reshape back + # Reshape 3D [E, out, in] to 2D for to_mlx_qparams, then reshape back. + # Packed int4 experts store qdata as uint8 [E, out, in//2] (two 4-bit values + # per byte); unpacked experts store int8 [E, out, in]. Detect via dtype and + # let to_mlx_qparams take the prepacked (view -> uint32) fast path so packed + # weights never expand to int8 during lowering. + prepacked = w_data.dtype == torch.uint8 orig_shape = w_data.shape E, out_dim = orig_shape[0], orig_shape[1] w_2d = w_data.reshape(E * out_dim, -1) @@ -1860,7 +1866,7 @@ def _gather_qmm_handler(P: MLXProgramBuilder, n: Node) -> Slot: else torch.zeros_like(s_2d, dtype=torch.int8) ) - Q, B = to_mlx_qparams(w_2d, s_2d, zp_2d, bits) + Q, B = to_mlx_qparams(w_2d, s_2d, zp_2d, bits, prepacked=prepacked) Q = Q.reshape(E, out_dim, -1) B = B.reshape(E, out_dim, -1) @@ -4437,6 +4443,12 @@ def _dequantize_affine_handler(P: MLXProgramBuilder, n: Node) -> Slot: scale_2d = scale.reshape(-1, scale.shape[-1]) zero_point_2d = zero_point.reshape(-1, zero_point.shape[-1]) + # torchao may quantize with a coarser group_size than MLX supports; repeat + # scale/zero_point to the MLX-legal group_size (returned by parse_dequant_node). + scale_2d, zero_point_2d, _ = regroup_affine_scales( + scale_2d, zero_point_2d, qdata_2d.shape[-1], group_size + ) + Q, B = to_mlx_qparams(qdata_2d, scale_2d, zero_point_2d, bits) leading_dims = permuted_shape[:-1] diff --git a/backends/mlx/patterns.py b/backends/mlx/patterns.py index dcc4f4d7d30..6731210209a 100644 --- a/backends/mlx/patterns.py +++ b/backends/mlx/patterns.py @@ -15,7 +15,6 @@ from __future__ import annotations -import os from typing import Any, List, Optional, Tuple import torch @@ -26,6 +25,7 @@ parse_dequant_int4_node, parse_dequant_node, parse_dequant_nvfp4_node, + regroup_affine_scales, to_mlx_qparams, torch_dtype_to_scalar_type, ) @@ -40,10 +40,8 @@ ) from executorch.backends.mlx.serialization.mlx_graph_schema import ( AddIntNode, - AddmmNode, AddNode, AsTypeNode, - DequantizeNode, IndexCopyNode, IntOrVid, ModIntNode, @@ -54,7 +52,6 @@ SliceUpdateNode, SubtractIntNode, SymSizeNode, - TransposeNode, ) from torch.export.exported_program import ExportedProgram from torch.fx.node import Node @@ -874,6 +871,11 @@ def maybe_create( if parsed is None: return None qdata, scale, zero_point, group_size, bits, out_dtype, _quantized_dim = parsed + # MLX's fused quantized_matmul Metal kernels only exist for group_size + # in {32, 64, 128}. group_size=16 can't be regrouped up (regrouping only + # splits groups finer), so it isn't lowerable as a fused quantized linear. + if group_size < 32: + return None out_dtype = x.meta["val"].dtype if out_dtype is None else out_dtype head = linear_node @@ -889,18 +891,6 @@ def maybe_create( out_dtype=out_dtype, ) - # MLX's quantized_matmul Metal kernels are only instantiated for - # group_size in {32, 64, 128}. For smaller group sizes (e.g. GGUF - # Q6_K with group_size=16), emit DequantizeNode + matmul instead. - # Weights stay packed in the .pte file; dequantized on-device. - # This non-fused path is significantly slower and must be opted in - # via ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS=1. - _MIN_FUSED_GROUP_SIZE = 32 - - @staticmethod - def _allow_non_fused() -> bool: - return os.environ.get("ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS", "0") == "1" - def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: assert n == self.head @@ -911,9 +901,19 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: zero_point_target, zero_point = P.get_placeholder_target_and_tensor( self.zero_point ) - _, scale = P.get_placeholder_target_and_tensor(self.scale) + scale_target, scale = P.get_placeholder_target_and_tensor(self.scale) + + # torchao may quantize with a coarser group_size than MLX supports; + # repeat scale/zero_point to the MLX-legal group_size (self.group_size). + scale, zero_point, regrouped = regroup_affine_scales( + scale, zero_point, qdata.shape[-1], self.group_size + ) - x_slot, scale_slot, b_slot = P.slot_map([x_node, self.scale, b_node]) + x_slot, b_slot = P.slot_map([x_node, b_node]) + if regrouped: + scale_slot = P.make_or_get_constant(f"{scale_target}_regrouped", scale) + else: + (scale_slot,) = P.slot_map([self.scale]) Q, B = to_mlx_qparams(qdata, scale, zero_point, self.bits) w = P.make_or_get_constant(f"{qdata_target}_to_packed", Q) @@ -926,59 +926,19 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: x_dtype = x_node.meta["val"].dtype needs_cast = self.out_dtype != x_dtype - if self.group_size >= self._MIN_FUSED_GROUP_SIZE: - P.emit( - QuantizedMatmulNode( - x=P.slot_to_tid(x_slot), - w=P.slot_to_tid(w), - scales=P.slot_to_tid(scale_slot), - out=P.slot_to_tid(out), - biases=P.slot_to_tid(biases), - group_size=self.group_size, - bits=self.bits, - mode="affine", - transpose=True, - ) - ) - else: - if not self._allow_non_fused(): - raise ValueError( - f"Quantized linear with group_size={self.group_size} requires " - f"the non-fused dequantize+matmul path, which is significantly " - f"slower than the fused QuantizedMatmulNode (group_size >= 32). " - f"Set ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS=1 to allow this." - ) - out_scalar_type = torch_dtype_to_scalar_type(self.out_dtype) - _, w_deq = P.make_tmp_slot() - P.emit( - DequantizeNode( - w=P.slot_to_tid(w), - scales=P.slot_to_tid(scale_slot), - out=P.slot_to_tid(w_deq), - biases=P.slot_to_tid(biases), - group_size=self.group_size, - bits=self.bits, - mode="affine", - dtype=out_scalar_type, - ) - ) - _, w_t = P.make_tmp_slot() - P.emit( - TransposeNode( - x=P.slot_to_tid(w_deq), - out=P.slot_to_tid(w_t), - perm=[1, 0], - ) - ) - P.emit( - AddmmNode( - mat1=P.slot_to_tid(x_slot), - mat2=P.slot_to_tid(w_t), - out=P.slot_to_tid(out), - ) + P.emit( + QuantizedMatmulNode( + x=P.slot_to_tid(x_slot), + w=P.slot_to_tid(w), + scales=P.slot_to_tid(scale_slot), + out=P.slot_to_tid(out), + biases=P.slot_to_tid(biases), + group_size=self.group_size, + bits=self.bits, + mode="affine", + transpose=True, ) - # DequantizeNode already produces the correct dtype. - needs_cast = False + ) if has_bias: P.emit( @@ -1069,12 +1029,22 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: zero_point_target, zero_point = P.get_placeholder_target_and_tensor( self.zero_point ) - _, scale = P.get_placeholder_target_and_tensor(self.scale) + scale_target, scale = P.get_placeholder_target_and_tensor(self.scale) + + # torchao may quantize with a coarser group_size than MLX supports; + # repeat scale/zero_point to the MLX-legal group_size (self.group_size). + scale, zero_point, regrouped = regroup_affine_scales( + scale, zero_point, qdata.shape[-1], self.group_size + ) Q, B = to_mlx_qparams(qdata, scale, zero_point, self.bits) w = P.make_or_get_constant(f"{qdata_target}_to_packed", Q) - indices_slot, scale_slot = P.slot_map([indices_node, self.scale]) + if regrouped: + (indices_slot,) = P.slot_map([indices_node]) + scale_slot = P.make_or_get_constant(f"{scale_target}_regrouped", scale) + else: + indices_slot, scale_slot = P.slot_map([indices_node, self.scale]) biases = emit_quantized_biases( P, zero_point_target, scale, zero_point, self.bits, B, scale_slot ) @@ -1216,12 +1186,6 @@ def __init__(self, head, body, qdata, scale, zero_point, group_size, out_dtype): self.group_size = group_size self.out_dtype = out_dtype - _MIN_FUSED_GROUP_SIZE = 32 - - @staticmethod - def _allow_non_fused() -> bool: - return os.environ.get("ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS", "0") == "1" - @classmethod def maybe_create(cls, ep, head): if not match_target(head, torch.ops.aten.linear.default): @@ -1235,6 +1199,10 @@ def maybe_create(cls, ep, head): if parsed is None: return None qdata, scale, zero_point, group_size, out_dtype = parsed + # MLX's fused quantized_matmul kernels only exist for group_size in + # {32, 64, 128}; smaller groups aren't lowerable as a fused linear. + if group_size < 32: + return None return cls(head, [dequant], qdata, scale, zero_point, group_size, out_dtype) def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: @@ -1247,6 +1215,11 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: _, scale = P.get_placeholder_target_and_tensor(self.scale) q, scale_nk, zp = _unpack_int4_to_intx_fields(qdata_packed, scale, zero_point) + # int4 may use a coarser group_size than MLX supports (e.g. 256); repeat + # scale/zero_point to the MLX-legal group_size (self.group_size). + scale_nk, zp, _ = regroup_affine_scales( + scale_nk, zp, q.shape[-1], self.group_size + ) Q, B = to_mlx_qparams(q, scale_nk, zp, 4) w = P.make_or_get_constant(f"{qdata_target}_int4_to_packed", Q) @@ -1259,12 +1232,6 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: ) needs_cast = out_dtype != x_node.meta["val"].dtype - if self.group_size < self._MIN_FUSED_GROUP_SIZE and not self._allow_non_fused(): - raise ValueError( - f"Int4 quantized linear with group_size={self.group_size} requires " - f"the non-fused path; set ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS=1." - ) - out = P.make_or_get_slot(n) P.emit( QuantizedMatmulNode( @@ -1342,6 +1309,11 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot: _, scale = P.get_placeholder_target_and_tensor(self.scale) q, scale_nk, zp = _unpack_int4_to_intx_fields(qdata_packed, scale, zero_point) + # int4 may use a coarser group_size than MLX supports (e.g. 256); repeat + # scale/zero_point to the MLX-legal group_size (self.group_size). + scale_nk, zp, _ = regroup_affine_scales( + scale_nk, zp, q.shape[-1], self.group_size + ) Q, B = to_mlx_qparams(q, scale_nk, zp, 4) w = P.make_or_get_constant(f"{qdata_target}_int4_to_packed", Q) diff --git a/backends/mlx/test/test_ops.py b/backends/mlx/test/test_ops.py index 66db9ead8b1..a6cfc98d2f3 100644 --- a/backends/mlx/test/test_ops.py +++ b/backends/mlx/test/test_ops.py @@ -24,7 +24,6 @@ See README.md in this directory for full documentation. """ -import os from typing import Callable, Dict, List, Optional, Tuple import executorch.exir as exir @@ -6321,21 +6320,8 @@ def get_test_configs(cls) -> List["QuantizedLinearTest"]: cls(qdtype=torch.int8), cls(qdtype=torch.int6), cls(qdtype=torch.int6, group_size=128), - # group_size=16: exercises the non-fused dequantize+matmul path - # (requires ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS=1). - cls(qdtype=torch.int8, group_size=16), - cls(qdtype=torch.int4, group_size=16), - cls(qdtype=torch.int8, group_size=16, bias=False), ] - def generate_test_files(self, verbose=False): - if self.group_size < 32: - os.environ["ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS"] = "1" - try: - return super().generate_test_files(verbose=verbose) - finally: - os.environ.pop("ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS", None) - def create_model(self) -> nn.Module: model = LinearModel(self.in_features, self.out_features, bias=self.bias) model = model.to(self.dtype) @@ -6694,6 +6680,7 @@ def __init__( in_features: int, out_features: int, group_size: int = 32, + packed: bool = False, ): super().__init__() self.out_features = out_features @@ -6713,10 +6700,14 @@ def __init__( quantize_model_(wrapper, qlinear_config="4w", qlinear_group_size=group_size) # Extract and stack quantized inner tensors - self.register_buffer( - "qdata", - torch.stack([e.weight.qdata for e in experts]), - ) + qdata = torch.stack([e.weight.qdata for e in experts]) # int8 [E, out, in] + if packed: + # Nibble-pack to uint8 [E, out, in//2] (value = signed q + 8), the + # end-to-end packed layout produced by SwitchLinear.pack(). + qu = (qdata.to(torch.int16) + 8).to(torch.uint8) + qu = qu.reshape(num_experts, out_features, in_features // 2, 2) + qdata = (qu[..., 0] | (qu[..., 1] << 4)).contiguous() + self.register_buffer("qdata", qdata) self.register_buffer( "scale", torch.stack([e.weight.scale for e in experts]), @@ -6757,6 +6748,7 @@ def __init__( batch_size: int = 2, group_size: int = 32, dtype: torch.dtype = torch.float32, + packed: bool = False, ): self.num_experts = num_experts self.in_features = in_features @@ -6764,6 +6756,7 @@ def __init__( self.batch_size = batch_size self.group_size = group_size self.dtype = dtype + self.packed = packed parts = [ "gather_qmm", @@ -6772,6 +6765,8 @@ def __init__( f"o{out_features}", f"g{group_size}", ] + if packed: + parts.append("packed") if dtype != torch.float32: parts.append(str(dtype).split(".")[-1]) self.name = "_".join(parts) @@ -6783,6 +6778,10 @@ def get_test_configs(cls) -> List["GatherQmmTest"]: cls(num_experts=8, in_features=128, out_features=256), cls(dtype=torch.bfloat16), cls(batch_size=1), + # Packed int4 experts (uint8 nibble-packed) — exercises the + # to_mlx_qparams prepacked (view -> uint32) lowering path. + cls(packed=True), + cls(packed=True, dtype=torch.bfloat16), ] def get_edge_compile_config(self): @@ -6796,6 +6795,7 @@ def create_model(self) -> nn.Module: self.in_features, self.out_features, self.group_size, + packed=self.packed, ) return model.to(self.dtype) diff --git a/backends/mlx/test/test_quant_regroup.py b/backends/mlx/test/test_quant_regroup.py new file mode 100644 index 00000000000..c8546b0de05 --- /dev/null +++ b/backends/mlx/test/test_quant_regroup.py @@ -0,0 +1,70 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# All rights reserved. +# +# This source code is licensed under the BSD-style license found in the +# LICENSE file in the root directory of this source tree. + +"""Unit tests for MLX affine group_size regrouping (op_helpers). No hardware needed.""" + +import unittest + +import torch +from executorch.backends.mlx.builder.op_helpers import ( + mlx_affine_group_size, + regroup_affine_scales, +) + + +class TestMlxAffineGroupSize(unittest.TestCase): + def test_supported_passthrough(self): + for gs in (16, 32, 64, 128): + self.assertEqual(mlx_affine_group_size(gs), gs) + + def test_coarse_maps_to_largest_divisor(self): + self.assertEqual(mlx_affine_group_size(256), 128) # int4 common case + self.assertEqual(mlx_affine_group_size(5376), 128) # per-axis lm_head + self.assertEqual(mlx_affine_group_size(48), 16) # only 16 divides + + def test_no_legal_divisor_returns_none(self): + self.assertIsNone(mlx_affine_group_size(5)) + self.assertIsNone(mlx_affine_group_size(24)) # 24%16!=0, 24%32.. none + + +class TestRegroupAffineScales(unittest.TestCase): + def test_noop_when_already_target(self): + scale = torch.randn(64, 8) # K=256, gs=32 + zp = torch.zeros(64, 8, dtype=torch.int8) + s2, z2, changed = regroup_affine_scales(scale, zp, 256, 32) + self.assertFalse(changed) + self.assertIs(s2, scale) + self.assertIs(z2, zp) + + def test_per_axis_regroups_shape(self): + scale = torch.randn(64, 1) # per-axis, K=256 -> gs=256 + zp = torch.zeros(64, 1, dtype=torch.int8) + s2, z2, changed = regroup_affine_scales(scale, zp, 256, 128) + self.assertTrue(changed) + self.assertEqual(s2.shape, (64, 2)) + self.assertEqual(z2.shape, (64, 2)) + + def test_regroup_preserves_dequant(self): + # A coarse group is a stack of finer groups sharing one scale, so + # regrouping must not change the dequantized values. + torch.manual_seed(0) + N, K = 4, 256 + qdata = torch.randint(-8, 7, (N, K), dtype=torch.int8) + scale = torch.rand(N, 1) + 0.1 # per-axis (group_size = K) + zp = torch.randint(-4, 4, (N, 1), dtype=torch.int8) + + coarse = (qdata.float() - zp.float()) * scale # one scale per row + + s2, z2, changed = regroup_affine_scales(scale, zp, K, 128) + self.assertTrue(changed) + fine = (qdata.float() - z2.repeat_interleave(128, -1).float()) * ( + s2.repeat_interleave(128, -1) + ) + self.assertTrue(torch.allclose(coarse, fine)) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/models/gemma4_31b/quant/pack_mlx.py b/examples/models/gemma4_31b/quant/pack_mlx.py index 22f525accd2..45cea5b2297 100644 --- a/examples/models/gemma4_31b/quant/pack_mlx.py +++ b/examples/models/gemma4_31b/quant/pack_mlx.py @@ -9,8 +9,10 @@ ``Int4Tensor`` weights are wrapped as ``ExportableInt4Tensor`` so they export to ``dequantize_int4_tensor -> linear/embedding`` (matched by MLX's Int4 handlers). ``IntxUnpackedToInt8Tensor`` (e.g. int8 / Q6_K) already exports to -``dequantize_affine -> linear`` and is assigned directly, regrouped to an -MLX-compatible group size when needed. +``dequantize_affine -> linear`` and is assigned directly. Coarse/per-axis group +sizes are regrouped to an MLX-legal size (16/32/64/128) inside the MLX pattern +handlers at export time (``regroup_affine_scales``), so no pack-time regroup is +needed here. The backend-agnostic ``pack_model`` dispatcher lives in ``pack.py``. """ @@ -22,59 +24,6 @@ from .pack import ModulePackerFn, pack_model # noqa: F401 -_MLX_SUPPORTED_GROUP_SIZES = (128, 64, 32, 16) - - -# --------------------------------------------------------------------------- -# Embedding group_size regrouping - - -def _mlx_group_size(gs: int, K: int) -> int: - """Find an MLX-compatible group_size for the given weight group_size. - - If ``gs`` is already in {32, 64, 128}, return it. Otherwise find the - largest supported group_size that divides ``gs`` so per-axis scales can - be repeated to fill finer groups. - """ - if gs in _MLX_SUPPORTED_GROUP_SIZES: - return gs - for candidate in _MLX_SUPPORTED_GROUP_SIZES: - if gs % candidate == 0 and K % candidate == 0: - return candidate - raise ValueError( - f"MLX requires group_size in {set(_MLX_SUPPORTED_GROUP_SIZES)} " - f"(or a multiple thereof), got {gs}" - ) - - -def _regroup_intx(w: torch.Tensor, new_gs: int) -> torch.Tensor: - """Regroup an ``IntxUnpackedToInt8Tensor`` to a finer group_size.""" - from torchao.quantization import IntxUnpackedToInt8Tensor - - old_gs = w.block_size[-1] - if old_gs % new_gs != 0: - raise ValueError( - f"new group_size {new_gs} must evenly divide old group_size {old_gs}" - ) - repeat_factor = old_gs // new_gs - N = w.qdata.shape[0] - n_groups = w.qdata.shape[-1] // new_gs - - scale = w.scale.repeat_interleave(repeat_factor, dim=-1).reshape(N, n_groups) - zero_point = w.zero_point.repeat_interleave(repeat_factor, dim=-1).reshape( - N, n_groups - ) - - return IntxUnpackedToInt8Tensor( - qdata=w.qdata, - scale=scale, - zero_point=zero_point, - target_dtype=w.target_dtype, - block_size=(1, new_gs), - dtype=w.dtype, - activation_quantization=w.activation_quantization, - ) - # --------------------------------------------------------------------------- # Per-module packer @@ -85,13 +34,10 @@ def pack_for_mlx(module: nn.Module, weights: dict[str, torch.Tensor]) -> None: ``Int4Tensor`` is wrapped as ``ExportableInt4Tensor`` (exports to ``dequantize_int4_tensor → linear/embedding``). ``IntxUnpackedToInt8Tensor`` - is assigned directly, regrouped to a compatible group_size when needed (e.g. - per-axis group_size=5376 → 128) since MLX accepts group_size in - {16, 32, 64, 128}. Group sizes ≥ 32 use the fused ``QuantizedMatmulNode``; - group_size=16 (e.g. GGUF Q6_K) falls back to ``DequantizeNode`` + matmul. + is assigned directly; coarse/per-axis group sizes are regrouped to an + MLX-legal size in the MLX pattern handlers at export time. """ from executorch.extension.llm.export.int4 import ExportableInt4Tensor - from torchao.quantization import IntxUnpackedToInt8Tensor from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor w = weights["weight"] @@ -99,12 +45,6 @@ def pack_for_mlx(module: nn.Module, weights: dict[str, torch.Tensor]) -> None: # Int4 group is MLX-native (32); wrap so it exports to # dequantize_int4_tensor -> linear/embedding. w = ExportableInt4Tensor.from_int4_tensor(w) - elif isinstance(w, IntxUnpackedToInt8Tensor): - gs = w.block_size[-1] - K = w.qdata.shape[-1] - target_gs = _mlx_group_size(gs, K) - if target_gs != gs: - w = _regroup_intx(w, target_gs) module.weight = nn.Parameter(w, requires_grad=False) diff --git a/examples/models/gemma4_31b/quant/tests/test_pack_mlx.py b/examples/models/gemma4_31b/quant/tests/test_pack_mlx.py index 4ff2c4149cf..2861a7ffaec 100644 --- a/examples/models/gemma4_31b/quant/tests/test_pack_mlx.py +++ b/examples/models/gemma4_31b/quant/tests/test_pack_mlx.py @@ -13,7 +13,6 @@ from executorch.examples.models.gemma4_31b.quant.pack import pack_model from executorch.examples.models.gemma4_31b.quant.pack_mlx import ( - _mlx_group_size, DEFAULT_MLX_PACKERS, pack_for_mlx, ) @@ -49,8 +48,12 @@ def test_int8_passes_through(self): self.assertIsInstance(module.weight.data, IntxUnpackedToInt8Tensor) self.assertEqual(module.weight.shape, torch.Size([64, 128])) - def test_regroup_preserves_dequant(self): - """Linear with non-standard group_size regroups and dequantizes correctly.""" + def test_int8_coarse_passes_through(self): + """Linear with a coarse group_size passes through unchanged. + + Regrouping to an MLX-legal group_size now happens in the MLX pattern + handlers at export time, so the packer leaves block_size untouched. + """ torch.manual_seed(0) weight = torch.randn(64, 256, dtype=torch.bfloat16) config = QuantConfig(bits=8, group_size=256, symmetric=True, method="min_max") @@ -60,7 +63,7 @@ def test_regroup_preserves_dequant(self): module = nn.Linear(256, 64, bias=False) pack_for_mlx(module, {"weight": w}) - self.assertEqual(module.weight.data.block_size, (1, 128)) + self.assertEqual(module.weight.data.block_size, (1, 256)) after = dequantize_weight(module.weight.data, torch.float32) self.assertTrue( torch.allclose(before, after, atol=1e-5), @@ -68,22 +71,6 @@ def test_regroup_preserves_dequant(self): ) -class TestMlxGroupSize(unittest.TestCase): - def test_passthrough(self): - for gs in (16, 32, 64, 128): - self.assertEqual(_mlx_group_size(gs, 256), gs) - - def test_regroup_5376(self): - self.assertEqual(_mlx_group_size(5376, 5376), 128) - - def test_regroup_256(self): - self.assertEqual(_mlx_group_size(256, 256), 128) - - def test_rejects_indivisible(self): - with self.assertRaises(ValueError): - _mlx_group_size(7, 7) - - class TestPackLinearGroupSize16(unittest.TestCase): """Packing group_size=16 weights (GGUF Q6_K) preserves semantics.""" @@ -134,13 +121,14 @@ def test_compatible_passes_through(self): pack_for_mlx(module, {"weight": w}) self.assertEqual(module.weight.shape, torch.Size([100, 64])) - def test_per_axis_regroups(self): + def test_per_axis_passes_through(self): module = nn.Embedding(50, 256) config = QuantConfig(bits=8, group_size=256, symmetric=True, method="min_max") w = quantize_weight(torch.randn(50, 256, dtype=torch.bfloat16), config) pack_for_mlx(module, {"weight": w}) self.assertEqual(module.weight.shape, torch.Size([50, 256])) - self.assertEqual(module.weight.data.block_size, (1, 128)) + # Regrouping happens in the MLX handlers at export time, not at pack time. + self.assertEqual(module.weight.data.block_size, (1, 256)) def test_int4_wraps_exportable(self): from executorch.extension.llm.export.int4 import ExportableInt4Tensor diff --git a/examples/models/qwen3_5_moe/README.md b/examples/models/qwen3_5_moe/README.md index 77f53aefcc6..85c33911d1a 100644 --- a/examples/models/qwen3_5_moe/README.md +++ b/examples/models/qwen3_5_moe/README.md @@ -249,6 +249,37 @@ python export.py \ --output-dir ./qwen35_moe_mlx ``` +### Prequantized Export (MLX) + +The examples above re-quantize on every export. To quantize once and reuse the +result, build a self-contained bundle with `quantize_and_save.py --backend mlx`, +then export from it with `--prequantized`: + +```bash +# Step 1: Quantize once and save an MLX bundle +python quantize_and_save.py \ + --model-dir ~/models/Qwen3.5-35B-A3B \ + --backend mlx \ + --qlinear 4w \ + --qlinear-group-size 64 \ + --output qwen35_moe_mlx_int4 + +# Step 2: Export from the bundle (fast, no --model-dir needed) +python export.py \ + --backend mlx \ + --prequantized qwen35_moe_mlx_int4 \ + --output-dir ./qwen35_moe_mlx +``` + +The bundle contains `model.safetensors` (torchao tensor subclasses), `config.json`, +and tokenizer files, and can be shared via HuggingFace Hub. + +`--backend mlx` quantizes and saves one decoder layer at a time, so peak memory +stays at roughly one bf16 layer instead of the full model — useful on Apple +Silicon where unified memory is limited. `--qlinear-group-size` defaults to 64 +for MLX (32 for CUDA); `--hqq` is ignored (the MLX configs use torchao's +`hqq_scale_only` qparams internally). + ### MLX Options | Flag | Default | Description | diff --git a/examples/models/qwen3_5_moe/export.py b/examples/models/qwen3_5_moe/export.py index d2c3914c6a2..385b386204c 100644 --- a/examples/models/qwen3_5_moe/export.py +++ b/examples/models/qwen3_5_moe/export.py @@ -176,8 +176,9 @@ def load_and_quantize(args): # noqa: C901 if backend == "mlx": if args.prequantized: - raise ValueError( - "MLX backend does not support custom prequantized weights. Use a prequantized torchao checkpoint instead." + return load_prequantized_model_mlx( + args.prequantized, + use_splitk_decode=use_splitk, ) _prepare_and_quantize_mlx(model, config, args) @@ -299,6 +300,110 @@ def load_prequantized_model(prequantized_dir, max_seq_len=4096, use_splitk_decod return model, config +def load_prequantized_model_mlx(prequantized_dir, use_splitk_decode=True): + """Load an MLX-format prequantized safetensors bundle into a model. + + The bundle (from quantize_and_save.py --backend mlx) stores experts already + packed into stacked gather buffers and dense/lm_head weights as + ``ExportableInt4Tensor`` (embedding as ``IntxUnpackedToInt8Tensor``). Loading: + + 1. Build the model on meta and re-apply mlx_source_transformations so the + module tree, swapped forwards, and MLX KV cache match the bundle. + 2. Convert each SwitchLinear to its packed buffer layout (experts are + already stacked/packed in the bundle) and assign the rest via + load_state_dict. + + max_seq_len is derived from a saved KV cache buffer so the rebuilt cache + geometry and export dynamic-shape bounds always match the bundle. + """ + from executorch.backends.mlx.llm.switch import pack_all_switch_linears + from executorch.examples.models.qwen3_5_moe.mlx_source_transformations import ( + mlx_source_transformations, + ) + from executorch.examples.models.qwen3_5_moe.quantize_and_save import ( + load_quantized_state_dict, + ) + + config_path = os.path.join(prequantized_dir, "config.json") + safetensors_path = os.path.join(prequantized_dir, "model.safetensors") + + print(f"Loading prequantized MLX weights from {safetensors_path}...") + state_dict = load_quantized_state_dict(safetensors_path) + + # Int4Tensor -> ExportableInt4Tensor so dense linears export via + # dequantize_int4_tensor and experts pack via pack_all_switch_linears. + # Coarse/per-axis int8 (e.g. the embedding at group_size=hidden) keeps its + # group_size; the MLX pattern handlers regroup scale/zero_point to an + # MLX-legal size at export time (regroup_affine_scales). + from executorch.extension.llm.export.int4 import ExportableInt4Tensor + from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor + + for key, val in list(state_dict.items()): + if isinstance(val, Int4Tensor): + state_dict[key] = ExportableInt4Tensor.from_int4_tensor(val) + + # Derive max_seq_len from a saved KV cache buffer ([1, H, max_seq_len, D]). + max_seq_len = None + for key, val in state_dict.items(): + if key.endswith(".kv_cache.k_cache"): + max_seq_len = val.shape[2] + break + if max_seq_len is None: + raise RuntimeError( + "Prequantized MLX bundle has no KV cache buffer; cannot infer " + "max_seq_len (is this a CUDA bundle? use --backend cuda)." + ) + + config = Qwen35MoEConfig.from_hf_config(config_path) + config.max_seq_len = max_seq_len + config.use_splitk_decode = use_splitk_decode + + print("Building model on meta device...") + with torch.device("meta"): + model = Qwen35MoE(config) + # Reproduce the source-transformed structure (per-expert SwitchLinear + # nn.Linears, swapped forwards, MLX KV cache) the bundle was saved in. + mlx_source_transformations( + model, + model_dtype=torch.bfloat16, + config=config, + sort_experts=True, + fuse_gate_up=False, + ) + + missing, unexpected = model.load_state_dict(state_dict, strict=False, assign=True) + del state_dict + + runtime_prefixes = (".mask", ".inv_freq", ".cache_positions") + expected_missing = {k for k in missing if any(p in k for p in runtime_prefixes)} + weight_missing = set(missing) - expected_missing + if weight_missing: + raise RuntimeError( + f"Prequantized MLX checkpoint is missing {len(weight_missing)} weight " + f"keys (model/checkpoint version mismatch?): {sorted(weight_missing)[:10]}" + ) + if unexpected: + raise RuntimeError( + f"Prequantized MLX checkpoint has {len(unexpected)} unexpected keys " + f"(model/checkpoint version mismatch?): {sorted(unexpected)[:10]}" + ) + + # Stack per-expert ExportableInt4Tensors into mlx::gather_qmm buffers. + pack_all_switch_linears(model) + + # assign=True wraps assigned tensors as Parameter(requires_grad=True), which + # breaks unwrap_tensor_subclass_parameters on int-dtype quantized inners. + for p in model.parameters(): + p.requires_grad_(False) + model.eval() + + print( + f"Model: {config.num_hidden_layers} layers, {config.hidden_size}d, " + f"{config.num_experts} experts top-{config.num_experts_per_tok}" + ) + return model, config + + def _quantize_experts_int4(model, config, group_size=32, use_hqq=False): """Quantize expert weights to packed INT4 for the fused MoE kernel. @@ -1255,8 +1360,6 @@ def main(): # noqa: C901 torch.cuda.reset_peak_memory_stats(0) if args.backend == "mlx": - if args.prequantized: - parser.error("--prequantized is not supported with --backend mlx") if args.turboquant: parser.error("--turboquant is not supported with --backend mlx") diff --git a/examples/models/qwen3_5_moe/quantize_and_save.py b/examples/models/qwen3_5_moe/quantize_and_save.py index 3f58ce969df..3a64fac8251 100644 --- a/examples/models/qwen3_5_moe/quantize_and_save.py +++ b/examples/models/qwen3_5_moe/quantize_and_save.py @@ -4,6 +4,14 @@ re-quantizing via --prequantized. The output directory contains everything needed to load the model — no reference to the original HF checkpoint required. +Two backends are supported, producing bundles for the matching export backend: + --backend cuda (default): packed-INT4 experts for the fused_moe Triton kernel + plus tile_packed_to_4d dense layers. Loaded by export.py --backend cuda. + --backend mlx: applies the MLX source transforms (SwitchMLP experts, MLX KV + cache, etc.), quantizes via torchao, and packs SwitchLinear weights. Loaded + by export.py --backend mlx. Note: --hqq has no effect here; the MLX 4w/8w + configs always use torchao's hqq_scale_only qparams internally. + Output: output_dir/ model.safetensors # quantized weights (with reconstruction metadata in header) @@ -16,6 +24,7 @@ Usage: python quantize_and_save.py --model-dir /path/to/Qwen3.5-MoE-A3B --qlinear 4w python quantize_and_save.py --model-dir /path/to/model --qlinear 4w --hqq + python quantize_and_save.py --model-dir /path/to/model --backend mlx --qlinear 4w """ import argparse @@ -33,110 +42,132 @@ # --------------------------------------------------------------------------- # Safetensors roundtrip for quantized models # -# Tensor subclasses (Int4TilePackedTo4dTensor, IntxUnpackedToInt8Tensor) can't -# be stored directly in safetensors. We flatten them into plain inner tensors -# with .__ suffixes and store reconstruction metadata (class name, -# block_size, shape, dtypes) in the safetensors header under "quantization". +# Tensor subclasses (Int4Tensor, Int4TilePackedTo4dTensor, +# IntxUnpackedToInt8Tensor) can't be stored directly in safetensors, so we use +# torchao's flatten/unflatten helpers. They deconstruct each subclass into its +# plain inner tensors plus JSON reconstruction metadata in the safetensors +# header. This matches the bundle format used by gemma4's quantize_and_save. +# +# Saving always uses the modern (torchao) format. Loading auto-detects the +# format from the safetensors header and falls back to a legacy reader for +# bundles written by the previous hand-rolled serializer. The legacy format was +# only ever produced by the CUDA path (MLX prequant is new), so the legacy +# branch exists purely for CUDA back-compat and can be dropped once all such +# bundles are regenerated. # --------------------------------------------------------------------------- -# Registry of tensor subclass types we know how to reconstruct. -_SUBCLASS_REGISTRY = {} - -def _register_subclass(cls): - _SUBCLASS_REGISTRY[cls.__qualname__] = cls +def save_quantized_tensors(items, safetensors_path): + """Flatten an iterable of ``(key, tensor)`` and write a safetensors bundle. - -def _init_subclass_registry(): - """Lazily populate the registry on first use.""" - if _SUBCLASS_REGISTRY: - return - from torchao.quantization import IntxUnpackedToInt8Tensor - from torchao.quantization.quantize_.workflows.int4.int4_tile_packed_to_4d_tensor import ( - Int4TilePackedTo4dTensor, + Tensor subclasses are flattened via torchao's ``flatten_tensor_state_dict``. + ``nn.Parameter`` values are unwrapped to their tensor data (bare subclasses + are left as-is). Duplicate keys and meta / None tensors are skipped. + """ + from torchao.prototype.safetensors.safetensors_support import ( + flatten_tensor_state_dict, ) - _register_subclass(Int4TilePackedTo4dTensor) - _register_subclass(IntxUnpackedToInt8Tensor) + state_dict = {} + for key, val in items: + # First-wins dedup: tied weights (e.g. lm_head / embed_tokens) appear + # twice; params are iterated before buffers, so keep the first. + if key in state_dict: + continue + if val is None or val.device.type == "meta": + continue + if isinstance(val, torch.nn.Parameter): + val = val.data + state_dict[key] = val + + tensors_data, metadata = flatten_tensor_state_dict(state_dict) + save_file(tensors_data, safetensors_path, metadata=metadata) + return len(state_dict) def save_quantized_model(model, safetensors_path): """Save a quantized model to safetensors with subclass reconstruction metadata. Iterates named_parameters and named_buffers directly (no state_dict copy) - to avoid doubling peak memory. Tensor subclasses are flattened into plain - inner tensors with .__ suffixes. + to avoid doubling peak memory. """ - tensors = {} - subclass_meta = {} - - seen = set() - for key, val in list(model.named_parameters()) + list(model.named_buffers()): - if key in seen: - continue - seen.add(key) - - if val.device.type == "meta": - continue - - if hasattr(val, "__tensor_flatten__"): - inner_names, attrs = val.__tensor_flatten__() - meta = {"_type": type(val).__qualname__} - for attr_name, attr_val in attrs.items(): - if isinstance(attr_val, torch.Size): - meta[attr_name] = list(attr_val) - elif isinstance(attr_val, torch.dtype): - meta[attr_name] = str(attr_val) - else: - meta[attr_name] = attr_val - subclass_meta[key] = meta - - for name in inner_names: - inner_tensor = getattr(val, name) - tensors[f"{key}.__{name}"] = inner_tensor.contiguous() - else: - tensors[key] = val.data.contiguous() - - header_metadata = {} - if subclass_meta: - header_metadata["quantization"] = json.dumps(subclass_meta) - - save_file(tensors, safetensors_path, metadata=header_metadata) - return len(tensors) + items = list(model.named_parameters()) + list(model.named_buffers()) + return save_quantized_tensors(items, safetensors_path) def load_quantized_state_dict(safetensors_path): """Load a quantized state dict from safetensors, reconstructing tensor subclasses. + Auto-detects the bundle format from the safetensors header: modern bundles + (torchao) carry a ``tensor_names`` key, legacy bundles carry ``quantization``. Returns a state dict with plain tensors and reconstructed tensor subclasses ready for model.load_state_dict(state_dict, strict=False, assign=True). """ from safetensors import safe_open - _init_subclass_registry() - with safe_open(safetensors_path, framework="pt", device="cpu") as f: - header_metadata = f.metadata() + metadata = f.metadata() or {} flat_tensors = {key: f.get_tensor(key) for key in f.keys()} + if "tensor_names" in metadata: + from torchao.prototype.safetensors.safetensors_support import ( + unflatten_tensor_state_dict, + ) + + state_dict, _ = unflatten_tensor_state_dict(flat_tensors, metadata) + return state_dict + + if "quantization" in metadata: + return _load_legacy_state_dict(flat_tensors, metadata) + + raise ValueError(f"Unrecognized quantized bundle format: {safetensors_path}") + + +# Legacy reader (CUDA back-compat) ------------------------------------------ +# Reconstructs bundles written by the previous hand-rolled serializer, which +# stored inner tensors as ``{key}.__{name}`` and reconstruction metadata as JSON +# under the ``quantization`` header key. Only the reader is kept; new bundles +# are always written in the modern torchao format above. +_LEGACY_SUBCLASS_REGISTRY = {} + + +def _register_legacy_subclass(cls): + _LEGACY_SUBCLASS_REGISTRY[cls.__qualname__] = cls + + +def _init_legacy_subclass_registry(): + if _LEGACY_SUBCLASS_REGISTRY: + return + from torchao.quantization import IntxUnpackedToInt8Tensor + from torchao.quantization.quantize_.workflows.int4.int4_tensor import Int4Tensor + from torchao.quantization.quantize_.workflows.int4.int4_tile_packed_to_4d_tensor import ( + Int4TilePackedTo4dTensor, + ) + + _register_legacy_subclass(Int4TilePackedTo4dTensor) + _register_legacy_subclass(IntxUnpackedToInt8Tensor) + _register_legacy_subclass(Int4Tensor) + + +def _load_legacy_state_dict(flat_tensors, header_metadata): + """Reconstruct a state dict from a legacy (pre-torchao) safetensors bundle.""" + _init_legacy_subclass_registry() quantization_meta = json.loads(header_metadata.get("quantization", "{}")) state_dict = {} reconstructed_keys = set() for key, meta in quantization_meta.items(): - cls = _SUBCLASS_REGISTRY[meta["_type"]] + cls = _LEGACY_SUBCLASS_REGISTRY[meta["_type"]] - # Collect inner tensors tensor_data = {} prefix = f"{key}.__" for flat_key in list(flat_tensors.keys()): if flat_key.startswith(prefix): - inner_name = flat_key[len(prefix) :] - tensor_data[inner_name] = flat_tensors[flat_key] + tensor_data[flat_key[len(prefix) :]] = flat_tensors[flat_key] reconstructed_keys.add(flat_key) - # Restore Python types from JSON (lists stay as lists, not tuples, - # because Int4TilePackedTo4dTensor expects block_size as a list) + # Restore Python types from JSON: lists stay lists (block_size), shape + # becomes torch.Size, and "torch.*" strings become dtypes. attrs = {} for attr_name, attr_val in meta.items(): if attr_name == "_type": @@ -148,10 +179,9 @@ def load_quantized_state_dict(safetensors_path): else: attrs[attr_name] = attr_val - # outer_size and outer_stride are unused by TorchAOBaseTensor.__tensor_unflatten__ + # outer_size / outer_stride are unused by TorchAOBaseTensor.__tensor_unflatten__ state_dict[key] = cls.__tensor_unflatten__(tensor_data, attrs, None, None) - # Add plain tensors for key, tensor in flat_tensors.items(): if key not in reconstructed_keys: state_dict[key] = tensor @@ -159,6 +189,233 @@ def load_quantized_state_dict(safetensors_path): return state_dict +# --------------------------------------------------------------------------- +# Streaming MLX quantization +# +# Quantizes one decoder layer at a time so peak memory stays at ~one bf16 layer +# instead of the whole model. Each layer's weights are read lazily from the +# checkpoint shards, run through the exact same MLX pipeline used for a +# full-model export (source transforms -> torchao quant -> pack), then the bf16 +# is released and only the (much smaller) quantized tensors are kept. The bundle +# is byte-compatible with the full-model path and loads via +# export.load_prequantized_model_mlx. +# --------------------------------------------------------------------------- + + +def _open_checkpoint_shards(model_dir): + """Return (weight_map, get_handle) for lazy per-tensor checkpoint access. + + weight_map maps checkpoint key -> shard filename. get_handle(shard) returns + a cached safetensors handle (mmap; get_tensor copies only the requested + tensor, so only one tensor is materialized at a time). + """ + from safetensors import safe_open + + index_path = os.path.join(model_dir, "model.safetensors.index.json") + if os.path.exists(index_path): + with open(index_path) as f: + weight_map = json.load(f)["weight_map"] + else: + single = os.path.join(model_dir, "model.safetensors") + if not os.path.exists(single): + raise FileNotFoundError(f"No safetensors checkpoint in {model_dir}") + with safe_open(single, framework="pt", device="cpu") as f: + weight_map = {k: "model.safetensors" for k in f.keys()} + + handles = {} + + def get_handle(shard): + if shard not in handles: + handles[shard] = safe_open( + os.path.join(model_dir, shard), framework="pt", device="cpu" + ) + return handles[shard] + + return weight_map, get_handle + + +def _load_remapped_subset(weight_map, get_handle, config, predicate): + """Load + remap the checkpoint keys whose normalized name matches predicate. + + Returns a state dict in export-model key space (qkv / gate_up / experts + fused), the same structure Qwen35MoE.from_hf_checkpoint produces. + """ + from executorch.examples.models.qwen3_5_moe.model import ( + _fuse_projection_weights, + _process_checkpoint_key, + ) + + sd = {} + expert_weights = {} + for ckpt_key, shard in weight_map.items(): + norm_key = ckpt_key.replace("model.language_model.", "model.", 1) + if not predicate(norm_key): + continue + tensor = get_handle(shard).get_tensor(ckpt_key) + _process_checkpoint_key(ckpt_key, tensor, sd, expert_weights) + + # Stack per-expert weights (alternative checkpoint format) into [E, N, K]. + if expert_weights: + for layer_idx in range(config.num_hidden_layers): + gate = [ + expert_weights.get((layer_idx, "gate", e)) + for e in range(config.num_experts) + ] + up = [ + expert_weights.get((layer_idx, "up", e)) + for e in range(config.num_experts) + ] + down = [ + expert_weights.get((layer_idx, "down", e)) + for e in range(config.num_experts) + ] + if gate[0] is not None: + sd[f"layers.{layer_idx}.mlp.experts.w1_weight"] = torch.cat( + [torch.stack(gate), torch.stack(up)], dim=1 + ) + if down[0] is not None: + sd[f"layers.{layer_idx}.mlp.experts.w2_weight"] = torch.stack( + down, dim=0 + ) + + _fuse_projection_weights(sd, config) + return sd + + +def _materialize_meta_buffers(module): + """Replace meta buffers with CPU zeros (mirrors the full export path).""" + for fqn, buf in list(module.named_buffers()): + if buf.device.type == "meta": + parts = fqn.rsplit(".", 1) + parent = module.get_submodule(parts[0]) if len(parts) > 1 else module + parent.register_buffer( + parts[-1], torch.zeros(buf.shape, dtype=buf.dtype, device="cpu") + ) + + +def _mlx_quant_recipe(config, group_size): + """gemma4-style recipe for Qwen 3.5 MoE (applied to source-transformed model). + + int4 min/max for all linear weights, int8 per-axis for the embedding, and + everything else (RMSNorm weights, conv1d, biases, A_log, _rms_weight, ...) + left unquantized. Matched with re.fullmatch. Uses min/max (single-pass) — + HQQ's iterative scale search is far too slow across per-expert linears. + """ + from executorch.examples.models.gemma4_31b.quant import ( + QuantConfig, + QuantRecipe, + QuantRule, + ) + + int4 = QuantConfig(bits=4, group_size=group_size, symmetric=False, method="min_max") + int8 = QuantConfig( + bits=8, group_size=config.hidden_size, symmetric=True, method="min_max" + ) + + return QuantRecipe( + rules=[ + QuantRule(r".*embed_tokens\.weight", int8), + QuantRule(r".*(ln_1|ln_2|q_norm|k_norm|norm)\.weight", None), + QuantRule(r".*conv1d\.weight", None), + QuantRule(r".*\.weight", int4), + ] + ) + + +def stream_quantize_and_save_mlx(model_dir, config, args, safetensors_path): + """Quantize + save an MLX bundle one decoder layer at a time (low memory). + + Uses gemma4's ``quantize_model`` to produce ``Int4Tensor`` / + ``IntxUnpackedToInt8Tensor`` subclasses. Packing is deferred to load + (``pack_all_switch_linears``). Peak memory is ~one bf16 decoder layer. + """ + import torch.nn as nn + + from executorch.examples.models.gemma4_31b.quant import quantize_model + from executorch.examples.models.qwen3_5_moe.mlx_source_transformations import ( + mlx_source_transformations, + ) + from executorch.examples.models.qwen3_5_moe.model import Block, GemmaRMSNorm + + recipe = _mlx_quant_recipe(config, args.qlinear_group_size) + weight_map, get_handle = _open_checkpoint_shards(model_dir) + accum = [] # (key, tensor) + + # --- Decoder layers, one at a time --- + for i in range(config.num_hidden_layers): + layer_sd = _load_remapped_subset( + weight_map, + get_handle, + config, + lambda n, i=i: n.startswith(f"model.layers.{i}."), + ) + prefix = f"layers.{i}." + block_sd = { + k[len(prefix) :]: v for k, v in layer_sd.items() if k.startswith(prefix) + } + del layer_sd + + with torch.device("meta"): + block = Block(config, layer_idx=i) + block.load_state_dict(block_sd, strict=False, assign=True) + del block_sd + block = block.to(torch.bfloat16) + _materialize_meta_buffers(block) + block.eval() + + # Source-transform (experts -> per-expert nn.Linear), then quantize the + # nn.Linears via the recipe. Packing happens on load. + mlx_source_transformations( + block, + model_dtype=torch.bfloat16, + config=config, + sort_experts=True, + fuse_gate_up=False, + ) + for name, val in quantize_model(block, recipe).items(): + accum.append((prefix + name, val)) + del block + print( + f" Streamed layer {i + 1}/{config.num_hidden_layers}", end="\r", flush=True + ) + print() + + # --- Top-level: embed_tokens (int8), norm (RMS), lm_head (int4) --- + top_sd = _load_remapped_subset( + weight_map, + get_handle, + config, + lambda n: n in ("model.embed_tokens.weight", "model.norm.weight") + or n == "lm_head.weight", + ) + embed_w = top_sd["embed_tokens.weight"].to(torch.bfloat16) + norm_w = top_sd["norm.weight"].to(torch.bfloat16) + lm_w = top_sd.get("lm_head.weight") + lm_w = embed_w.clone() if lm_w is None else lm_w.to(torch.bfloat16) + del top_sd + + class _Top(nn.Module): + def __init__(self): + super().__init__() + self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size) + self.norm = GemmaRMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False) + + top = _Top().to(torch.bfloat16) + with torch.no_grad(): + top.embed_tokens.weight.copy_(embed_w) + top.norm.weight.copy_(norm_w) + top.lm_head.weight.copy_(lm_w) + # Matches _swap_rms_norm: precompute (1 + weight) used by F.rms_norm. + top.norm._rms_weight = nn.Parameter(1.0 + top.norm.weight.data) + + for name, val in quantize_model(top, recipe).items(): + accum.append((name, val)) + + print("Writing quantized weights...") + return save_quantized_tensors(accum, safetensors_path) + + # --------------------------------------------------------------------------- # CLI # --------------------------------------------------------------------------- @@ -176,6 +433,12 @@ def main(): default="qwen35_moe_quantized", help="Output directory (default: qwen35_moe_quantized/)", ) + parser.add_argument( + "--backend", + default="cuda", + choices=["cuda", "mlx"], + help="Target backend for the bundle: cuda (default) or mlx.", + ) parser.add_argument("--max-seq-len", type=int, default=4096, help="KV cache length") parser.add_argument( "--qlinear", @@ -186,42 +449,79 @@ def main(): parser.add_argument( "--qlinear-group-size", type=int, - default=32, - help="Group size for linear quantization.", + default=None, + help="Group size for linear quantization " + "(default: 64 for mlx per README recommendation, 32 for cuda).", ) parser.add_argument( "--qembedding", default=None, choices=["8w"], help="Quantize embedding layers." ) + parser.add_argument( + "--qembedding-group-size", + type=int, + default=None, + help="Group size for embedding quantization (mlx backend).", + ) parser.add_argument( "--hqq", action="store_true", - help="Use HQQ scale-only optimization for expert quantization.", + help="Use HQQ scale-only optimization for expert quantization " + "(cuda backend only; ignored for mlx).", ) args = parser.parse_args() if not args.qlinear and not args.qembedding: parser.error("At least one of --qlinear or --qembedding is required.") - # Load model - print("Loading model...") - model, config = Qwen35MoE.from_hf_checkpoint( - args.model_dir, max_seq_len=args.max_seq_len - ) - model.eval() - print( - f"Model: {config.num_hidden_layers} layers, {config.hidden_size}d, " - f"{config.num_experts} experts top-{config.num_experts_per_tok}" - ) - - # Quantize (includes expert INT4 + linear + embedding quantization) - _quantize(model, config, args) + # Resolve the linear group size: the README recommends 64 for MLX, while + # CUDA uses the 32 default. An explicit --qlinear-group-size overrides this. + if args.qlinear_group_size is None: + args.qlinear_group_size = 64 if args.backend == "mlx" else 32 + print( + f"Using default --qlinear-group-size {args.qlinear_group_size} " + f"for --backend {args.backend}." + ) + + if args.backend == "mlx" and args.hqq: + print( + "Note: --hqq is ignored for --backend mlx " + "(MLX quant configs use hqq_scale_only qparams internally)." + ) - # Save bundle os.makedirs(args.output, exist_ok=True) - safetensors_path = os.path.join(args.output, "model.safetensors") - print("Saving quantized weights...") - n_tensors = save_quantized_model(model, safetensors_path) + + if args.backend == "mlx": + # Stream layer-by-layer so peak memory is ~one bf16 layer, not the whole + # model. Reads config only; weights are pulled lazily from the shards. + from executorch.examples.models.qwen3_5_moe.model import Qwen35MoEConfig + + config = Qwen35MoEConfig.from_hf_config( + os.path.join(args.model_dir, "config.json") + ) + config.max_seq_len = args.max_seq_len + print( + f"Model: {config.num_hidden_layers} layers, {config.hidden_size}d, " + f"{config.num_experts} experts top-{config.num_experts_per_tok}" + ) + print("Streaming + quantizing one layer at a time...") + n_tensors = stream_quantize_and_save_mlx( + args.model_dir, config, args, safetensors_path + ) + else: + # CUDA: load the full model, then quantize. + print("Loading model...") + model, config = Qwen35MoE.from_hf_checkpoint( + args.model_dir, max_seq_len=args.max_seq_len + ) + model.eval() + print( + f"Model: {config.num_hidden_layers} layers, {config.hidden_size}d, " + f"{config.num_experts} experts top-{config.num_experts_per_tok}" + ) + _quantize(model, config, args) + print("Saving quantized weights...") + n_tensors = save_quantized_model(model, safetensors_path) # Copy config and tokenizer from source for filename in [ @@ -237,7 +537,13 @@ def main(): size_mb = os.path.getsize(safetensors_path) / (1024 * 1024) print(f"Saved {n_tensors} tensors ({size_mb:.1f} MB) to {args.output}/") - print(f"Done. Use with: python export.py --prequantized {args.output}") + if args.backend == "mlx": + print( + f"Done. Use with: python export.py --backend mlx " + f"--prequantized {args.output}" + ) + else: + print(f"Done. Use with: python export.py --prequantized {args.output}") if __name__ == "__main__": diff --git a/examples/models/qwen3_5_moe/test_quantize_roundtrip.py b/examples/models/qwen3_5_moe/test_quantize_roundtrip.py index db77a9c01dd..d4ff64bdb6f 100644 --- a/examples/models/qwen3_5_moe/test_quantize_roundtrip.py +++ b/examples/models/qwen3_5_moe/test_quantize_roundtrip.py @@ -208,5 +208,111 @@ def test_load_rejects_corrupted_checkpoint(self): load_prequantized_model(tmpdir, max_seq_len=TINY_CONFIG.max_seq_len) +class TestSerializerFormatDispatch(unittest.TestCase): + """CPU-only tests for load_quantized_state_dict format auto-detection. + + Unlike the roundtrip tests above (which need CUDA for + Int4TilePackedTo4dTensor), these use a min/max Int4Tensor that quantizes on + CPU, so they exercise the modern/legacy/unknown dispatch without a GPU. + """ + + @staticmethod + def _cpu_subclass(): + from executorch.examples.models.gemma4_31b.quant import ( + QuantConfig, + quantize_model, + QuantRecipe, + QuantRule, + ) + + model = torch.nn.Sequential(torch.nn.Linear(64, 32, bias=False)) + model.eval().to(torch.bfloat16) + recipe = QuantRecipe( + rules=[ + QuantRule( + r".*\.weight", + QuantConfig( + bits=4, group_size=32, symmetric=False, method="min_max" + ), + ) + ] + ) + return "0.weight", next(iter(quantize_model(model, recipe).values())) + + def _assert_subclass_equal(self, a, b): + names_a, attrs_a = a.__tensor_flatten__() + names_b, attrs_b = b.__tensor_flatten__() + self.assertIs(type(a), type(b)) + self.assertEqual(names_a, names_b) + for name in names_a: + self.assertTrue(torch.equal(getattr(a, name), getattr(b, name))) + self.assertEqual(attrs_a, attrs_b) + + def test_modern_roundtrip(self): + """Modern (torchao) bundle: save -> load reconstructs the subclass.""" + from executorch.examples.models.qwen3_5_moe.quantize_and_save import ( + load_quantized_state_dict, + save_quantized_tensors, + ) + + key, qt = self._cpu_subclass() + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "model.safetensors") + save_quantized_tensors([(key, qt)], path) + out = load_quantized_state_dict(path) + self._assert_subclass_equal(qt, out[key]) + + def test_legacy_roundtrip(self): + """Legacy (pre-torchao) CUDA-format bundle still loads via fallback. + + Fabricates the old on-disk layout (inner tensors as ``{key}.__{name}`` + plus a ``quantization`` JSON header) and checks the subclass round-trips + and plain tensors pass through. + """ + from executorch.examples.models.qwen3_5_moe.quantize_and_save import ( + load_quantized_state_dict, + ) + from safetensors.torch import save_file + + def to_json_attr(val): + if isinstance(val, torch.Size): + return list(val) + if isinstance(val, torch.dtype): + return "torch." + str(val).split(".")[-1] + return val + + key, qt = self._cpu_subclass() + names, attrs = qt.__tensor_flatten__() + flat = {f"{key}.__{n}": getattr(qt, n) for n in names} + flat["norm.weight"] = torch.ones(4, dtype=torch.bfloat16) + header = { + key: { + "_type": type(qt).__qualname__, + **{k: to_json_attr(v) for k, v in attrs.items()}, + } + } + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "model.safetensors") + save_file(flat, path, metadata={"quantization": json.dumps(header)}) + out = load_quantized_state_dict(path) + + self._assert_subclass_equal(qt, out[key]) + self.assertTrue(torch.equal(out["norm.weight"], flat["norm.weight"])) + + def test_unknown_format_raises(self): + """A bundle with neither header key raises a clear ValueError.""" + from executorch.examples.models.qwen3_5_moe.quantize_and_save import ( + load_quantized_state_dict, + ) + from safetensors.torch import save_file + + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "model.safetensors") + save_file({"x": torch.ones(2)}, path, metadata={"foo": "bar"}) + with self.assertRaises(ValueError): + load_quantized_state_dict(path) + + if __name__ == "__main__": unittest.main()