Skip to content

Commit 44539fd

Browse files
authored
[MLX] Add quant regrouping to MLX lowering flow, and update quantize_and_save in Qwen to support MLX path (#21023)
For the MLX repacking and quantize_and_save work, we removed the redundant hand-rolled MLX regrouping/packing code in both gemma4 and qwen3.5 (deleting the old pack_mlx/export.py regroup paths and their tests). We also added MLX path in qwen's quantize_and_save.py
1 parent 8077bb6 commit 44539fd

13 files changed

Lines changed: 965 additions & 297 deletions

File tree

backends/mlx/builder/op_helpers.py

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ def to_mlx_qparams(
519519
zero_point: torch.Tensor,
520520
bits: int,
521521
compute_biases: bool = True,
522+
prepacked: bool = False,
522523
) -> Tuple[torch.Tensor, Optional[torch.Tensor]]:
523524
"""
524525
Convert TorchAO quantization params to MLX format.
@@ -536,10 +537,31 @@ def to_mlx_qparams(
536537
Returns (Q, None) in this case. This is valid when
537538
zero_point is all zeros, as the C++ runtime will compute
538539
biases = -scales * 2^(bits-1).
540+
prepacked: If True, ``qdata`` is already nibble-packed uint8 holding the
541+
unsigned values ``q + offset`` (two 4-bit values per byte,
542+
even index -> low nibble) — the exact layout this function
543+
produces for ``bits == 4``. It is reinterpreted as uint32
544+
directly (``view``), skipping the int8 -> uint32 repack.
545+
Only supported for ``bits == 4``.
539546
"""
540-
assert qdata.dtype == torch.int8
541547
offset = 2 ** (bits - 1)
542548

549+
if prepacked:
550+
assert bits == 4, "prepacked to_mlx_qparams only supports 4-bit"
551+
assert (
552+
qdata.dtype == torch.uint8
553+
), f"prepacked qdata must be uint8, got {qdata.dtype}"
554+
# (rows, cols//2) uint8 -> (rows, cols//8) uint32. cols//2 must be a
555+
# multiple of 4, i.e. in_features a multiple of 8 (holds: gs >= 32).
556+
assert qdata.shape[-1] % 4 == 0, "packed cols must be a multiple of 4"
557+
Q = qdata.contiguous().view(torch.uint32)
558+
if compute_biases:
559+
B = -scale * (zero_point.to(scale.dtype) + offset)
560+
return Q, B
561+
return Q, None
562+
563+
assert qdata.dtype == torch.int8
564+
543565
# Pack data into a contiguous uint32 bitstream. cols*bits must be a
544566
# multiple of 32 (holds since in_features is a multiple of group_size>=32).
545567
rows, cols = qdata.shape
@@ -640,7 +662,9 @@ def parse_dequant_int4_node(
640662
"""Parse a torchao.dequantize_int4_tensor node.
641663
642664
Returns (qdata, scale, zero_point, group_size, output_dtype) or None if not a
643-
dequantize_int4_tensor node or the custom op is not registered.
665+
dequantize_int4_tensor node or the custom op is not registered. ``group_size``
666+
is the MLX-legal group_size (16/32/64/128); coarser int4 groups (e.g. 256)
667+
are regrouped to it via ``regroup_affine_scales`` at emit time.
644668
"""
645669
target = get_aten_target(node.target)
646670
try:
@@ -652,6 +676,10 @@ def parse_dequant_int4_node(
652676
return None
653677

654678
qdata, scale, zero_point, group_size = node.args[0:4]
679+
mlx_group_size = mlx_affine_group_size(int(group_size))
680+
if mlx_group_size is None:
681+
return None
682+
group_size = mlx_group_size
655683

656684
output_dtype = None
657685
if len(node.args) > 4:
@@ -662,6 +690,60 @@ def parse_dequant_int4_node(
662690
return qdata, scale, zero_point, group_size, output_dtype
663691

664692

693+
_MLX_AFFINE_GROUP_SIZES = (128, 64, 32, 16)
694+
695+
696+
def mlx_affine_group_size(group_size: int) -> Optional[int]:
697+
"""Largest MLX-supported group_size (128/64/32/16) that divides ``group_size``.
698+
699+
MLX's affine quantized kernels only support group_size in {16, 32, 64, 128}.
700+
torchao may quantize with a coarser or per-axis group_size (e.g. 256 or a
701+
full row of 5376). A coarse group is a stack of finer groups that share one
702+
scale, so any legal divisor is exact after repeating scale/zero_point (see
703+
``regroup_affine_scales``). Returns ``group_size`` unchanged when already
704+
legal, the coarsest legal divisor otherwise, or ``None`` when none divides.
705+
"""
706+
if group_size in _MLX_AFFINE_GROUP_SIZES:
707+
return group_size
708+
for candidate in _MLX_AFFINE_GROUP_SIZES:
709+
if group_size % candidate == 0:
710+
return candidate
711+
return None
712+
713+
714+
def regroup_affine_scales(
715+
scale: torch.Tensor,
716+
zero_point: torch.Tensor,
717+
in_features: int,
718+
target_group_size: int,
719+
) -> Tuple[torch.Tensor, torch.Tensor, bool]:
720+
"""Repeat per-group scale/zero_point so the effective group_size becomes
721+
``target_group_size`` (an MLX-legal size from ``mlx_affine_group_size``).
722+
723+
``scale``/``zero_point`` are shaped ``[..., in_features // old_group_size]``.
724+
A coarse group shares one scale across ``old_group_size // target_group_size``
725+
finer groups, so repeat-interleaving along the last axis is numerically exact.
726+
727+
Returns ``(scale, zero_point, changed)``; a no-op (``changed=False``) when the
728+
group_size is already ``target_group_size``.
729+
"""
730+
old_groups = scale.shape[-1]
731+
old_group_size = in_features // old_groups
732+
assert (
733+
old_group_size >= target_group_size and old_group_size % target_group_size == 0
734+
), (
735+
f"cannot regroup: weight group_size={old_group_size} is finer than, or "
736+
f"not a multiple of, target_group_size={target_group_size} — "
737+
f"repeat-interleave can only refine groups"
738+
)
739+
repeat = old_group_size // target_group_size
740+
if repeat == 1:
741+
return scale, zero_point, False
742+
scale = scale.repeat_interleave(repeat, dim=-1)
743+
zero_point = zero_point.repeat_interleave(repeat, dim=-1)
744+
return scale, zero_point, True
745+
746+
665747
def parse_dequant_node(
666748
node: Node,
667749
) -> Optional[Tuple[Node, Node, Node, int, int, Optional[torch.dtype], int]]:
@@ -673,7 +755,9 @@ def parse_dequant_node(
673755
- Conv2d weights (4D): block_size=[1, 32, 1, 1] → quantized_dim=1
674756
675757
Returns (qdata, scale, zero_point, group_size, bits, out_dtype, quantized_dim)
676-
or None if unsupported.
758+
or None if unsupported. ``group_size`` is the MLX-legal group_size (16/32/64/
759+
128); when the weight uses a coarser group, callers must regroup scale/
760+
zero_point to it via ``regroup_affine_scales`` before packing.
677761
"""
678762
qdata, block_size, scale, zero_point, dtype, qmin, qmax = node.args[0:7]
679763
out_dtype = (
@@ -687,8 +771,13 @@ def parse_dequant_node(
687771
if len(non_one) != 1:
688772
return None
689773
quantized_dim, group_size = non_one[0]
690-
if group_size not in [16, 32, 64, 128]:
774+
# MLX kernels only support group_size in {16,32,64,128}. Coarser/per-axis
775+
# groups are lowered by repeating scale/zero_point to the coarsest legal
776+
# divisor at emit time (regroup_affine_scales); reject if none divides.
777+
mlx_group_size = mlx_affine_group_size(group_size)
778+
if mlx_group_size is None:
691779
return None
780+
group_size = mlx_group_size
692781

693782
# MLX supports 2,3,4,5,6,8-bit affine quantization. to_mlx_qparams packs
694783
# 2/4/8 via fast paths and other widths (e.g. 5, 6) via a general

backends/mlx/custom_ops.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,17 @@ def gather_qmm(
354354
s_sel = scales
355355
b_sel = biases
356356

357+
subbyte_packed = w_sel.dtype == torch.uint8
358+
if subbyte_packed:
359+
assert (
360+
bits == 4
361+
), "Subbyte packing qdata (uint8) is only supported for bits=4 now."
362+
363+
offset = 2 ** (bits - 1)
364+
lo = (w_sel & 0x0F).to(torch.int16)
365+
hi = ((w_sel >> 4) & 0x0F).to(torch.int16)
366+
w_sel = torch.stack([lo, hi], dim=-1).reshape(*w_sel.shape[:-1], -1) - offset
367+
357368
# Dequantize
358369
w_float = w_sel.to(x.dtype)
359370
s_expanded = s_sel.repeat_interleave(group_size, dim=-1)

backends/mlx/llm/switch.py

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -93,31 +93,71 @@ def pack(self):
9393
- Quantized: extracts inner tensors (qdata, scale, zero_point),
9494
stacks into [E, out, in_packed] buffers. Weight layout matches
9595
mlx::gather_qmm's expectations (transpose=True handles transposition).
96+
4-bit qdata is nibble-packed to uint8 [E, out, in//2] (two values per
97+
byte, value = signed q + 8) so it stays packed end-to-end — the
98+
gather_qmm lowering reinterprets it as uint32 directly. 8-bit qdata
99+
keeps the int8 [E, out, in] layout.
96100
- Unquantized: stacks weight.data into [E, out, in], then pretransposes
97101
to [E, in, out] so gather_mm receives the correct layout directly
98102
(no runtime transpose needed).
99103
"""
100104
if self._packed:
101105
return
102106

107+
from executorch.extension.llm.export.int4 import ExportableInt4Tensor
108+
from torchao.quantization import IntxUnpackedToInt8Tensor
109+
103110
w0 = self.experts[0].weight
104111
self._is_quantized = hasattr(w0, "qdata")
105112

106-
if self._is_quantized:
107-
_, metadata = w0.__tensor_flatten__()
108-
self.group_size = metadata["block_size"][-1]
109-
113+
if isinstance(w0, ExportableInt4Tensor):
114+
# Per-expert ExportableInt4Tensor: qdata is already nibble-packed
115+
# uint8 (out, in//2); scale/zero_point are (in//gs, out) unsigned.
116+
# Stack into the gather layout the gather_qmm handler expects
117+
# (packed uint8 qdata + signed zero_point).
118+
self.group_size = w0.group_size
110119
self.register_buffer(
111-
"qdata",
112-
torch.stack([e.weight.qdata for e in self.experts]),
120+
"qdata", torch.stack([e.weight.qdata for e in self.experts])
113121
)
114122
self.register_buffer(
115123
"scale",
116-
torch.stack([e.weight.scale for e in self.experts]),
124+
torch.stack([e.weight.scale.t().contiguous() for e in self.experts]),
117125
)
118126
self.register_buffer(
119127
"zero_point",
120-
torch.stack([e.weight.zero_point for e in self.experts]),
128+
torch.stack(
129+
[
130+
(e.weight.zero_point.to(torch.int16) - 8)
131+
.t()
132+
.contiguous()
133+
.to(torch.int8)
134+
for e in self.experts
135+
]
136+
),
137+
)
138+
elif isinstance(w0, IntxUnpackedToInt8Tensor):
139+
_, metadata = w0.__tensor_flatten__()
140+
self.group_size = metadata["block_size"][-1]
141+
142+
qdata = torch.stack([e.weight.qdata for e in self.experts])
143+
scale = torch.stack([e.weight.scale for e in self.experts])
144+
zero_point = torch.stack([e.weight.zero_point for e in self.experts])
145+
146+
# Nibble-pack 4-bit qdata into uint8 [E, out, in//2] so experts stay
147+
# packed through export (half the storage; gather_qmm views as uint32).
148+
if metadata.get("target_dtype") == torch.int4:
149+
E, out, in_features = qdata.shape
150+
qu = (qdata.to(torch.int16) + 8).to(torch.uint8)
151+
qu = qu.reshape(E, out, in_features // 2, 2)
152+
qdata = (qu[..., 0] | (qu[..., 1] << 4)).contiguous()
153+
154+
self.register_buffer("qdata", qdata)
155+
self.register_buffer("scale", scale)
156+
self.register_buffer("zero_point", zero_point)
157+
elif self._is_quantized:
158+
raise TypeError(
159+
f"Unsupported quantized expert weight type: {type(w0).__name__}; "
160+
"expected ExportableInt4Tensor or IntxUnpackedToInt8Tensor"
121161
)
122162
else:
123163
# Stack [E, out, in] then pretranspose to [E, in, out]

backends/mlx/ops.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
emit_quantized_biases,
2626
emit_shape,
2727
parse_dequant_node,
28+
regroup_affine_scales,
2829
to_mlx_qparams,
2930
torch_dtype_to_scalar_type,
3031
)
@@ -1849,7 +1850,12 @@ def _gather_qmm_handler(P: MLXProgramBuilder, n: Node) -> Slot:
18491850
if biases_node is not None:
18501851
zp_target, zp_data = P.get_placeholder_target_and_tensor(biases_node)
18511852

1852-
# Reshape 3D [E, out, in] to 2D for to_mlx_qparams, then reshape back
1853+
# Reshape 3D [E, out, in] to 2D for to_mlx_qparams, then reshape back.
1854+
# Packed int4 experts store qdata as uint8 [E, out, in//2] (two 4-bit values
1855+
# per byte); unpacked experts store int8 [E, out, in]. Detect via dtype and
1856+
# let to_mlx_qparams take the prepacked (view -> uint32) fast path so packed
1857+
# weights never expand to int8 during lowering.
1858+
prepacked = w_data.dtype == torch.uint8
18531859
orig_shape = w_data.shape
18541860
E, out_dim = orig_shape[0], orig_shape[1]
18551861
w_2d = w_data.reshape(E * out_dim, -1)
@@ -1860,7 +1866,7 @@ def _gather_qmm_handler(P: MLXProgramBuilder, n: Node) -> Slot:
18601866
else torch.zeros_like(s_2d, dtype=torch.int8)
18611867
)
18621868

1863-
Q, B = to_mlx_qparams(w_2d, s_2d, zp_2d, bits)
1869+
Q, B = to_mlx_qparams(w_2d, s_2d, zp_2d, bits, prepacked=prepacked)
18641870
Q = Q.reshape(E, out_dim, -1)
18651871
B = B.reshape(E, out_dim, -1)
18661872

@@ -4437,6 +4443,12 @@ def _dequantize_affine_handler(P: MLXProgramBuilder, n: Node) -> Slot:
44374443
scale_2d = scale.reshape(-1, scale.shape[-1])
44384444
zero_point_2d = zero_point.reshape(-1, zero_point.shape[-1])
44394445

4446+
# torchao may quantize with a coarser group_size than MLX supports; repeat
4447+
# scale/zero_point to the MLX-legal group_size (returned by parse_dequant_node).
4448+
scale_2d, zero_point_2d, _ = regroup_affine_scales(
4449+
scale_2d, zero_point_2d, qdata_2d.shape[-1], group_size
4450+
)
4451+
44404452
Q, B = to_mlx_qparams(qdata_2d, scale_2d, zero_point_2d, bits)
44414453

44424454
leading_dims = permuted_shape[:-1]

0 commit comments

Comments
 (0)