Skip to content

Commit a6d812a

Browse files
authored
MLX Q5K/Q6K upgrades (#20827)
This PR adds an MLX-native repack lowering path for GGUF Q5_K and Q6_K weights, mirroring the existing Q4_K path: at export time the raw GGUF blob is unpacked and repacked into MLX affine qparams (bits=5 / bits=6) consumed by MLX's native QuantizedMatmul/quantized-gather kernels, gated by ET_MLX_EMIT_DIRECT_GGUF (default = native, falling back to the fused Metal kernels otherwise). It also introduces a lossless group-size upgrade in to_intx_unpacked_to_int8_tensor (_max_uniform_group_size) that merges adjacent sub-blocks with identical scale/min into the largest MLX-supported group size (32→64→128) when bit-exact, which the repack paths request via max_group_size=128; because Q6_K's native group size (16) isn't MLX-compatible, its native path only activates when this merge reaches ≥32 and otherwise transparently falls back to fused kernels. Q6_K's symmetric zero-point lets biases be computed in the init chain via emit_quantized_biases instead of being serialized, and a FakeTensor guard keeps the data-dependent merge from tripping the partitioner's support check. Coverage includes new unit tests for the merge logic in test_gguf.py and native/fused + group-merged op tests across Q4_K/Q5_K/Q6_K in test_linear.py and test_embedding.py.
1 parent 50529ee commit a6d812a

23 files changed

Lines changed: 1048 additions & 62 deletions

backends/mlx/builder/op_helpers.py

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@
2424
# computing biases = -scales * 2^(bits-1) during the init chain.
2525
QUANTIZED_SERIALIZE_BIASES = False
2626

27+
# Row-chunk size (in elements) for the int64 bit-packer in ``to_mlx_qparams``.
28+
# Packing widths that don't divide 32 (5/6-bit) needs int64 scratch; chunking the
29+
# rows bounds that scratch to ~this many elements instead of the whole weight
30+
# (a full lm_head would otherwise be tens of GB of int64 -> OOM).
31+
_PACK_CHUNK_ELEMS = 1 << 24 # ~16M int64 elements (~128 MB per scratch tensor)
32+
2733

2834
def get_aten_target(target):
2935
"""
@@ -555,27 +561,43 @@ def to_mlx_qparams(
555561
Q = q.contiguous().view(torch.uint32).reshape(rows, -1)
556562
else:
557563
# Contiguous LSB-first bit-packing for widths that don't divide 32
558-
# (e.g. 6-bit), matching MLX's affine pack_and_quantize (ops.cpp).
564+
# (e.g. 5/6-bit), matching MLX's affine pack_and_quantize (ops.cpp).
565+
#
566+
# We scatter each column's value directly into its uint32 word(s) rather
567+
# than expanding to a per-bit stream. Column j occupies global bits
568+
# [j*bits, (j+1)*bits): word j*bits//32 at shift j*bits%32, plus a carry
569+
# into the next word when it straddles the boundary (at most one carry
570+
# since bits <= 32). Column bit-ranges within a word are disjoint, so
571+
# index_add_ (sum) is equivalent to OR.
559572
#
560-
# We scatter each column's value directly into its uint32 word(s)
561-
# rather than expanding to a per-bit stream, which would materialize an
562-
# int64 (rows, cols, bits) tensor (tens of GB for lm_head -> OOM).
563-
# Column j occupies global bits [j*bits, (j+1)*bits): word j*bits//32 at
564-
# shift j*bits%32, plus a carry into the next word when it straddles the
565-
# boundary (at most one carry since bits <= 32). Column bit-ranges within
566-
# a word are disjoint, so index_add_ (sum) is equivalent to OR.
573+
# The scatter needs int64 (a value shifted by up to 31 overflows int32),
574+
# so we pack the rows in chunks to bound the peak int64 working set --
575+
# packing a full lm_head in one shot would otherwise materialize a
576+
# multi-GB int64 tensor.
567577
n_words = cols * bits // 32
568-
q = qdata.to(torch.int64) + offset # 0 .. 2**bits-1
569578
pos = torch.arange(cols, dtype=torch.int64) * bits
570579
word = pos // 32
571580
shift = pos % 32
572-
packed = torch.zeros(rows, n_words, dtype=torch.int64)
573-
packed.index_add_(1, word, q << shift) # low bits (+ overflow)
574581
straddle = (shift + bits) > 32
575-
if bool(straddle.any()):
576-
carry = torch.where(straddle, q >> (32 - shift), torch.zeros_like(q))
577-
packed.index_add_(1, (word + 1).clamp(max=n_words - 1), carry)
578-
Q = (packed & 0xFFFFFFFF).to(torch.int32).contiguous().view(torch.uint32)
582+
has_straddle = bool(straddle.any())
583+
word_carry = (word + 1).clamp(max=n_words - 1) if has_straddle else None
584+
585+
rows_per_chunk = max(1, _PACK_CHUNK_ELEMS // cols)
586+
chunks = []
587+
for r0 in range(0, rows, rows_per_chunk):
588+
q = qdata[r0 : r0 + rows_per_chunk].to(torch.int64) + offset
589+
packed = torch.zeros(q.shape[0], n_words, dtype=torch.int64)
590+
packed.index_add_(1, word, q << shift) # low bits (+ overflow)
591+
if has_straddle:
592+
carry = torch.where(straddle, q >> (32 - shift), torch.zeros_like(q))
593+
packed.index_add_(1, word_carry, carry)
594+
del carry
595+
del q
596+
chunks.append((packed & 0xFFFFFFFF).to(torch.int32))
597+
del packed
598+
packed_i32 = chunks[0] if len(chunks) == 1 else torch.cat(chunks, dim=0)
599+
del chunks
600+
Q = packed_i32.contiguous().view(torch.uint32)
579601

580602
if compute_biases:
581603
B = -scale * (zero_point.to(scale.dtype) + offset)

backends/mlx/custom_kernel_ops/gguf/patterns.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@
1717
These handlers match that ``dequantize_gguf -> linear/embedding`` subgraph and
1818
lower it without materializing the dequantized weight:
1919
20-
* **Q6_K** -> fused custom Metal kernels in :mod:`.q6k`.
21-
* **Q5_K** -> fused custom Metal kernels in :mod:`.q5k`.
22-
* **Q4_K** -> fused custom Metal kernels in :mod:`.q4k` (default), or the legacy
23-
MLX-native repack path when ``ET_MLX_EMIT_DIRECT_GGUF=0``.
20+
* **Q4_K** / **Q5_K** -> MLX-native repack path by default; the fused custom
21+
Metal kernels in :mod:`.q4k` / :mod:`.q5k` when ``ET_MLX_EMIT_DIRECT_GGUF=1``.
22+
* **Q6_K** -> MLX-native repack path when its sub-blocks merge to an
23+
MLX-supported group size (>= 32), else the fused kernels in :mod:`.q6k`;
24+
``ET_MLX_EMIT_DIRECT_GGUF=1`` forces fused.
2425
2526
All cover linear and embedding.
2627
@@ -43,7 +44,8 @@
4344
from torch.export.exported_program import ExportedProgram
4445
from torch.fx.node import Node
4546

46-
# Quant types each pattern can lower (both via fused custom Metal kernels).
47+
# Quant types each pattern can lower (MLX-native repack by default, or fused
48+
# custom Metal kernels via ``ET_MLX_EMIT_DIRECT_GGUF=1``).
4749
_LINEAR_TYPES = {"q4_k", "q5_k", "q6_k"}
4850
_EMBEDDING_TYPES = {"q4_k", "q5_k", "q6_k"}
4951

backends/mlx/custom_kernel_ops/gguf/q4k/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
``custom_kernel_ops.gguf.patterns``); they are not imported here to keep the
1313
package import light.
1414
15-
By default the legacy export-time repack path
15+
By default the export-time repack path
1616
(:mod:`.linear_mlx_native` / :mod:`.embedding_mlx_native`) is used. Set
1717
``ET_MLX_EMIT_DIRECT_GGUF=1`` to emit the fused Metal kernels that read raw
1818
GGUF bytes instead.
@@ -26,7 +26,7 @@
2626
def emit_direct_gguf() -> bool:
2727
"""Return True to emit fused kernels that read raw GGUF bytes.
2828
29-
Defaults to False (the legacy MLX-native repack path); set
29+
Defaults to False (the MLX-native repack path); set
3030
``ET_MLX_EMIT_DIRECT_GGUF=1`` to enable the fused kernels.
3131
"""
3232
return os.environ.get("ET_MLX_EMIT_DIRECT_GGUF", "0") != "0"

backends/mlx/custom_kernel_ops/gguf/q4k/embedding.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ def emit_embedding(
134134
indices_node: Node,
135135
output_dtype: torch.dtype,
136136
) -> Slot:
137-
"""Dispatch to fused Metal gather or the legacy MLX-native repack path."""
137+
"""Dispatch to fused Metal gather or the MLX-native repack path."""
138138
from executorch.backends.mlx.custom_kernel_ops.gguf.q4k import emit_direct_gguf
139139

140140
if emit_direct_gguf():

backends/mlx/custom_kernel_ops/gguf/q4k/linear.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ def emit_linear(
487487
weight_node: Node,
488488
bias_node: Optional[Node],
489489
) -> Slot:
490-
"""Dispatch to fused Metal kernels or the legacy MLX-native repack path."""
490+
"""Dispatch to fused Metal kernels or the MLX-native repack path."""
491491
from executorch.backends.mlx.custom_kernel_ops.gguf.q4k import emit_direct_gguf
492492

493493
if emit_direct_gguf():

backends/mlx/custom_kernel_ops/gguf/q4k/linear_mlx_native.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99
"""GGUF **Q4_K** linear lowering via MLX's native 4-bit quantized matmul.
1010
1111
Lowers a ``dequantize_gguf -> linear`` pattern to a ``QuantizedMatmulNode``
12-
(mode "affine", group_size 32); the GGUF blob is repacked into MLX qparams at
13-
export time (see :mod:`.repack_mlx`).
12+
(mode "affine"); the GGUF blob is repacked into MLX qparams at export time (see
13+
:mod:`.repack_mlx`). The group size is 32 by default, or the merged 64/128 when
14+
adjacent sub-blocks are identical.
1415
"""
1516

1617
from __future__ import annotations

backends/mlx/custom_kernel_ops/gguf/q4k/repack_mlx.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# LICENSE file in the root directory of this source tree.
77
#
88

9-
"""Q4_K -> MLX qparam repack for the legacy MLX-native lowering path.
9+
"""Q4_K -> MLX qparam repack for the MLX-native lowering path.
1010
1111
Used when ``ET_MLX_EMIT_DIRECT_GGUF=0``: the GGUF blob is unpacked and repacked
1212
into MLX affine 4-bit qparams at export time instead of being consumed directly
@@ -28,16 +28,23 @@
2828
def repack_mlx(P: MLXProgramBuilder, weight_node: Node) -> Tuple[Slot, Slot, Slot, int]:
2929
"""Unpack a raw Q4_K blob and repack into MLX qparam constants.
3030
31+
Adjacent sub-blocks with identical scale/min are merged into a larger group
32+
size (up to 128) when lossless, so ``group_size`` may be 32, 64, or 128.
3133
Returns ``(packed_slot, scales_slot, biases_slot, group_size)``.
3234
"""
3335
from executorch.extension.llm.export.gguf import ExportableGGUFTensor
3436

3537
weight_target, raw = P.get_placeholder_target_and_tensor(weight_node)
36-
intx = ExportableGGUFTensor.from_raw(raw, "q4_k").to_intx_unpacked_to_int8_tensor()
38+
intx = ExportableGGUFTensor.from_raw(raw, "q4_k").to_intx_unpacked_to_int8_tensor(
39+
max_group_size=128
40+
)
3741
group_size = int(intx.block_size[-1])
38-
packed, biases = to_mlx_qparams(intx.qdata, intx.scale, intx.zero_point, _BITS)
42+
qdata, scale, zero_point = intx.qdata, intx.scale, intx.zero_point
43+
del intx # drop the tensor-subclass wrapper; keep only the fields we need
44+
packed, biases = to_mlx_qparams(qdata, scale, zero_point, _BITS)
45+
del qdata # the (N, K) int8 is no longer needed once packed
3946

4047
packed_slot = P.make_or_get_constant(f"{weight_target}_q4k_packed", packed)
41-
scales_slot = P.make_or_get_constant(f"{weight_target}_q4k_scales", intx.scale)
48+
scales_slot = P.make_or_get_constant(f"{weight_target}_q4k_scales", scale)
4249
biases_slot = P.make_or_get_constant(f"{weight_target}_q4k_biases", biases)
4350
return packed_slot, scales_slot, biases_slot, group_size

backends/mlx/custom_kernel_ops/gguf/q5k/__init__.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,32 @@
66
# LICENSE file in the root directory of this source tree.
77
#
88

9-
"""GGUF Q5_K format implementation (fused custom Metal kernels).
9+
"""GGUF Q5_K format lowering for the MLX backend.
1010
1111
Re-exports the lightweight constants/header from :mod:`.common` so they can be
1212
imported without pulling in the MLX builder. The ``emit_*`` lowerings live in
1313
:mod:`.linear` / :mod:`.embedding` (called by ``custom_kernel_ops.gguf.patterns``)
1414
and are not imported here.
15+
16+
By default the export-time repack path
17+
(:mod:`.linear_mlx_native` / :mod:`.embedding_mlx_native`) is used. Set
18+
``ET_MLX_EMIT_DIRECT_GGUF=1`` to emit the fused Metal kernels that read raw
19+
GGUF bytes instead.
1520
"""
1621

22+
import os
23+
1724
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.common import ( # noqa: F401
1825
_Q5K_HEADER,
1926
Q5K_BLOCK_BYTES,
2027
QK_K,
2128
)
29+
30+
31+
def emit_direct_gguf() -> bool:
32+
"""Return True to emit fused kernels that read raw GGUF bytes.
33+
34+
Defaults to False (the MLX-native repack path); set
35+
``ET_MLX_EMIT_DIRECT_GGUF=1`` to enable the fused kernels.
36+
"""
37+
return os.environ.get("ET_MLX_EMIT_DIRECT_GGUF", "0") != "0"

backends/mlx/custom_kernel_ops/gguf/q5k/embedding.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"""
5656

5757

58-
def emit_embedding(
58+
def _emit_embedding_fused(
5959
P: MLXProgramBuilder,
6060
head: Node,
6161
weight_node: Node,
@@ -122,3 +122,23 @@ def emit_embedding(
122122
)
123123

124124
return out
125+
126+
127+
def emit_embedding(
128+
P: MLXProgramBuilder,
129+
head: Node,
130+
weight_node: Node,
131+
indices_node: Node,
132+
output_dtype: torch.dtype,
133+
) -> Slot:
134+
"""Dispatch to fused Metal gather or the MLX-native repack path."""
135+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k import emit_direct_gguf
136+
137+
if emit_direct_gguf():
138+
return _emit_embedding_fused(P, head, weight_node, indices_node, output_dtype)
139+
140+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.embedding_mlx_native import (
141+
emit_embedding as emit_embedding_mlx_native,
142+
)
143+
144+
return emit_embedding_mlx_native(P, head, weight_node, indices_node, output_dtype)
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#
2+
# Copyright (c) Meta Platforms, Inc. and affiliates.
3+
# All rights reserved.
4+
#
5+
# This source code is licensed under the BSD-style license found in the
6+
# LICENSE file in the root directory of this source tree.
7+
#
8+
9+
"""GGUF **Q5_K** embedding lowering via MLX's native 5-bit quantized gather.
10+
11+
Lowers a ``dequantize_gguf -> embedding`` pattern to a quantized gather: gather
12+
the packed quants / scales / biases by index, then dequantize the gathered rows
13+
(``DequantizeNode``, mode "affine"). The GGUF blob is repacked into MLX qparams
14+
at export time (see :mod:`.repack_mlx`).
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from executorch.backends.mlx.builder.op_helpers import emit_quantized_gather
20+
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
21+
from executorch.backends.mlx.builder.slot_manager import Slot
22+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.repack_mlx import (
23+
_BITS,
24+
repack_mlx,
25+
)
26+
from torch.fx.node import Node
27+
28+
29+
def emit_embedding(
30+
P: MLXProgramBuilder,
31+
head: Node,
32+
weight_node: Node,
33+
indices_node: Node,
34+
output_dtype,
35+
) -> Slot:
36+
"""Lower a Q5_K ``dequantize_gguf -> embedding`` pattern to a quantized gather.
37+
38+
Gathers the packed quants / scales / biases by index, then dequantizes the
39+
gathered rows (MLX affine 5-bit) -- the same shape as MLX's generic quantized
40+
embedding.
41+
"""
42+
w_slot, scales_slot, biases_slot, group_size = repack_mlx(P, weight_node)
43+
(indices_slot,) = P.slot_map([indices_node])
44+
45+
out = P.make_or_get_slot(head)
46+
emit_quantized_gather(
47+
P,
48+
out,
49+
indices_slot,
50+
w_slot,
51+
scales_slot,
52+
biases_slot,
53+
group_size=group_size,
54+
bits=_BITS,
55+
mode="affine",
56+
out_dtype=output_dtype,
57+
)
58+
return out

0 commit comments

Comments
 (0)