Skip to content

Commit f5bb7c2

Browse files
JaynouOliverclaude
andcommitted
[MLX] Add Q5_K quantization support
Add fused Q5_K Metal kernels (linear + embedding) for the MLX backend, matching the existing Q6_K (#20004) and Q4_K (#20172) support, and register Q5_K on the export side so it lowers through the MLX pattern handlers. Q5_K combines Q4_K's affine super-block (d/dmin + 6-bit packed scales/mins unpacked via get_scale_min_k4) with a Q6_K-style high-bit array: each weight is a 5-bit code whose low 4 bits come from qs and whose 5th bit comes from qh. The kernels read the raw block_q5_K directly (no export-time repack). Changes: - extension/llm/export/gguf.py: register GGML_Q5_K = 13, _Q5_K_BLOCK_BYTES = 176, and add "q5_k" to the id / block-bytes maps. - backends/mlx/custom_kernel_ops/gguf/q5k/{common,linear,embedding,__init__}.py: block_q5_K struct + per-element (embedding) and vectorized half4x4 (matmul) dequant helpers, mat-vec (decode) / mat-mat (prefill) / dynamic-M IfNode linear, and a per-element gather embedding. Ported from llama.cpp. - backends/mlx/custom_kernel_ops/gguf/patterns.py: wire q5_k into the linear / embedding handlers and the supported-type sets. - tests: add make_q5_k_blob + q5_k configs to test_linear.py / test_embedding.py, and Q5_K coverage to extension/llm/export/test/test_gguf.py. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 20944fd commit f5bb7c2

9 files changed

Lines changed: 914 additions & 28 deletions

File tree

backends/mlx/custom_kernel_ops/gguf/patterns.py

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,11 @@
1818
lower it without materializing the dequantized weight:
1919
2020
* **Q6_K** -> fused custom Metal kernels in :mod:`.q6k`.
21+
* **Q5_K** -> fused custom Metal kernels in :mod:`.q5k`.
2122
* **Q4_K** -> fused custom Metal kernels in :mod:`.q4k` (default), or the legacy
2223
MLX-native repack path when ``ET_MLX_EMIT_DIRECT_GGUF=0``.
2324
24-
Both cover linear and embedding.
25+
All cover linear and embedding.
2526
2627
Other quant types are left unmatched (the caller is expected to convert them to a
2728
torchao ``Int4Tensor`` / ``IntxUnpackedToInt8Tensor`` first).
@@ -43,8 +44,8 @@
4344
from torch.fx.node import Node
4445

4546
# Quant types each pattern can lower (both via fused custom Metal kernels).
46-
_LINEAR_TYPES = {"q4_k", "q6_k"}
47-
_EMBEDDING_TYPES = {"q4_k", "q6_k"}
47+
_LINEAR_TYPES = {"q4_k", "q5_k", "q6_k"}
48+
_EMBEDDING_TYPES = {"q4_k", "q5_k", "q6_k"}
4849

4950

5051
def parse_dequantize_gguf_node(
@@ -78,8 +79,8 @@ class GGUFQuantizedLinearHandler(PatternHandler):
7879
"""Lower ``dequantize_gguf + linear`` to a fused quantized matmul.
7980
8081
Matches ``linear(x, dequantize_gguf(weight, ggml_type, out_dtype), bias)``
81-
and dispatches on ``ggml_type``: Q6_K / Q4_K -> custom Metal kernels in
82-
:mod:`.q6k` / :mod:`.q4k`.
82+
and dispatches on ``ggml_type``: Q6_K / Q5_K / Q4_K -> custom Metal kernels in
83+
:mod:`.q6k` / :mod:`.q5k` / :mod:`.q4k`.
8384
"""
8485

8586
def __init__(self, head, body, weight, ggml_type, output_dtype):
@@ -113,6 +114,10 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot:
113114
from executorch.backends.mlx.custom_kernel_ops.gguf.q6k.linear import (
114115
emit_linear,
115116
)
117+
elif self.ggml_type == "q5_k":
118+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.linear import (
119+
emit_linear,
120+
)
116121
else: # q4_k
117122
from executorch.backends.mlx.custom_kernel_ops.gguf.q4k.linear import (
118123
emit_linear,
@@ -125,8 +130,8 @@ class GGUFQuantizedEmbeddingHandler(PatternHandler):
125130
"""Lower ``dequantize_gguf + embedding`` to a quantized gather.
126131
127132
Matches ``embedding(dequantize_gguf(weight, ggml_type, out_dtype), indices)``
128-
and dispatches on ``ggml_type``: Q6_K / Q4_K -> custom Metal gather kernels
129-
in :mod:`.q6k` / :mod:`.q4k`.
133+
and dispatches on ``ggml_type``: Q6_K / Q5_K / Q4_K -> custom Metal gather
134+
kernels in :mod:`.q6k` / :mod:`.q5k` / :mod:`.q4k`.
130135
"""
131136

132137
def __init__(self, head, body, weight, ggml_type, output_dtype):
@@ -159,6 +164,10 @@ def __call__(self, P: MLXProgramBuilder, n: Node) -> Slot:
159164
from executorch.backends.mlx.custom_kernel_ops.gguf.q6k.embedding import (
160165
emit_embedding,
161166
)
167+
elif self.ggml_type == "q5_k":
168+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.embedding import (
169+
emit_embedding,
170+
)
162171
else: # q4_k
163172
from executorch.backends.mlx.custom_kernel_ops.gguf.q4k.embedding import (
164173
emit_embedding,
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 format implementation (fused custom Metal kernels).
10+
11+
Re-exports the lightweight constants/header from :mod:`.common` so they can be
12+
imported without pulling in the MLX builder. The ``emit_*`` lowerings live in
13+
:mod:`.linear` / :mod:`.embedding` (called by ``custom_kernel_ops.gguf.patterns``)
14+
and are not imported here.
15+
"""
16+
17+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.common import ( # noqa: F401
18+
_Q5K_HEADER,
19+
Q5K_BLOCK_BYTES,
20+
QK_K,
21+
)
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
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+
"""Shared GGUF **Q5_K** primitives for the MLX backend.
10+
11+
This module holds the pieces common to every Q5_K kernel (linear matmul/matvec
12+
and the embedding gather), so format-specific op modules import from here:
13+
14+
* ``QK_K`` / ``K_SCALE_SIZE`` / ``Q5K_BLOCK_BYTES`` and the per-super-block byte
15+
layout constants.
16+
* ``_Q5K_HEADER`` -- the Metal header (the ``block_q5_K`` struct plus the
17+
per-element and vectorized dequant helpers) shared by all Q5_K Metal kernels.
18+
19+
Q5_K layout (per 256-element super-block, 176 bytes, see llama.cpp
20+
``block_q5_K`` in ``ggml-common.h``)::
21+
22+
half d # super-block scale for the quantized scales
23+
half dmin # super-block scale for the quantized mins
24+
uint8 scales[12] # 6-bit packed sub-block scales + mins (as Q4_K)
25+
uint8 qh[QK_K/8 = 32] # quants, high bit (the 5th bit)
26+
uint8 qs[QK_K/2 = 128] # quants, low 4 bits
27+
28+
Q5_K combines Q4_K's affine super-block (``d``/``dmin`` with 6-bit packed
29+
scales/mins unpacked via ``get_scale_min_k4``) with a Q6_K-style high-bit array:
30+
each weight is a 5-bit code ``q`` (0..31) whose low 4 bits come from ``qs`` and
31+
whose 5th bit comes from ``qh``. The dequantized value for code ``q`` in
32+
sub-block ``s`` is ``d * scale[s] * q - dmin * min[s]`` (affine, same shape as
33+
Q4_K but with ``q`` in 0..31).
34+
35+
Attribution
36+
-----------
37+
The Q5_K block layout and the Metal dequant helpers in ``_Q5K_HEADER`` follow
38+
llama.cpp (``ggml-common.h`` / ``ggml-metal.metal``: ``block_q5_K``,
39+
``dequantize_q5_K``, ``dequantize_row_q5_K``, ``get_scale_min_k4``), which is
40+
MIT-licensed (Copyright (c) 2023-2024 The ggml authors).
41+
"""
42+
43+
from __future__ import annotations
44+
45+
# ---------------------------------------------------------------------------
46+
# Q5_K constants
47+
# ---------------------------------------------------------------------------
48+
49+
QK_K = 256
50+
K_SCALE_SIZE = 12
51+
_Q5K_D_BYTES = 2
52+
_Q5K_DMIN_BYTES = 2
53+
_Q5K_SCALES_BYTES = K_SCALE_SIZE
54+
_Q5K_QH_BYTES = QK_K // 8 # 32
55+
_Q5K_QS_BYTES = QK_K // 2 # 128
56+
Q5K_BLOCK_BYTES = (
57+
_Q5K_D_BYTES + _Q5K_DMIN_BYTES + _Q5K_SCALES_BYTES + _Q5K_QH_BYTES + _Q5K_QS_BYTES
58+
) # 176
59+
60+
61+
# ---------------------------------------------------------------------------
62+
# Shared Metal header
63+
# ---------------------------------------------------------------------------
64+
65+
# Ported from llama.cpp ggml-common.h (block_q5_K, get_scale_min_k4) and
66+
# ggml-metal.metal (dequantize_q5_K). Struct field order matches GGUF bytes:
67+
# d(0:2), dmin(2:4), scales(4:16), qh(16:48), qs(48:176).
68+
_Q5K_HEADER = """
69+
#include <metal_simdgroup>
70+
#include <metal_simdgroup_matrix>
71+
using namespace metal;
72+
73+
#define QK_K 256
74+
#define K_SCALE_SIZE 12
75+
76+
typedef struct {
77+
half d; // super-block scale for quantized scales
78+
half dmin; // super-block scale for quantized mins
79+
uint8_t scales[K_SCALE_SIZE]; // 6-bit packed scales + mins (same as Q4_K)
80+
uint8_t qh[QK_K/8]; // quants, high bit (the 5th bit)
81+
uint8_t qs[QK_K/2]; // quants, low 4 bits
82+
} block_q5_K; // 176 bytes
83+
84+
// Unpack 6-bit scale and min for sub-block index j (0..7).
85+
// Ported from llama.cpp get_scale_min_k4 (ggml-quants.c). Identical to Q4_K.
86+
inline void get_scale_min_k4(int j, device const uint8_t * q,
87+
thread uint8_t & sc, thread uint8_t & m) {
88+
if (j < 4) {
89+
sc = q[j] & 63;
90+
m = q[j + 4] & 63;
91+
} else {
92+
sc = (q[j + 4] & 0xF) | ((q[j - 4] >> 6) << 4);
93+
m = (q[j + 4] >> 4) | ((q[j] >> 6) << 4);
94+
}
95+
}
96+
97+
// Metal variant used by dequantize_q5_K_16 (matches ggml-metal.metal). Same as Q4_K.
98+
inline uchar2 get_scale_min_k4_just2(int j, int k, device const uint8_t * q) {
99+
return j < 4
100+
? uchar2{uint8_t(q[j + 0 + k] & 63), uint8_t(q[j + 4 + k] & 63)}
101+
: uchar2{
102+
uint8_t((q[j + 4 + k] & 0xF) | ((q[j - 4 + k] >> 6) << 4)),
103+
uint8_t((q[j + 4 + k] >> 4) | ((q[j + 0 + k] >> 6) << 4))};
104+
}
105+
106+
// Dequantize a single element at within-block position p (0..255).
107+
// Mirrors dequantize_row_q5_K (ggml-quants.c): 64-element chunks, 32 lows then
108+
// 32 highs per chunk; the 5th bit comes from qh at bit (2*chunk + half).
109+
inline float dequant_q5k_elem(device const block_q5_K * blk, int p) {
110+
const int c = p >> 6; // 0..3 (64-element chunk)
111+
const int within = p & 63; // 0..63 within chunk
112+
const int l = within & 31; // 0..31 (also the qh byte index)
113+
const int half = within >> 5; // 0 low nibble, 1 high nibble
114+
const int sub = 2 * c + half; // sub-block 0..7 (also the qh bit index)
115+
116+
const uint8_t byte = blk->qs[c * 32 + l];
117+
const int nib = (half == 0) ? (byte & 0xF) : (byte >> 4);
118+
const int hibit = (blk->qh[l] >> sub) & 1;
119+
const int q = nib + (hibit << 4); // 0..31
120+
121+
uint8_t sc, mn;
122+
get_scale_min_k4(sub, blk->scales, sc, mn);
123+
124+
const float d = (float) blk->d;
125+
const float dm = (float) blk->dmin;
126+
return d * (float) sc * (float) q - dm * (float) mn;
127+
}
128+
129+
// Vectorized Q5_K dequantize: decodes 16 values into half4x4.
130+
// Ported from llama.cpp dequantize_q5_K (ggml-metal.metal): Q4_K's vectorized
131+
// decode plus the Q6_K-style 5th bit. mat-mat uses NL=16 (QK_K/16); for sub-block
132+
// `il` the 16 outputs map to block positions [il*16 : il*16+16].
133+
inline void dequantize_q5_K_16(device const block_q5_K * xb, short il,
134+
thread half4x4 & reg) {
135+
device const uint8_t * q = xb->qs;
136+
device const uint8_t * qh = xb->qh;
137+
138+
short is = (il / 4) * 2;
139+
q = q + (il / 4) * 32 + 16 * (il & 1);
140+
qh = qh + 16 * (il & 1);
141+
const uint8_t ul = 1 << (il / 2); // 5th-bit mask (uses original il)
142+
il = il & 3;
143+
144+
const uchar2 sc = get_scale_min_k4_just2(is, il / 2, xb->scales);
145+
const float d = il < 2 ? (float) xb->d : (float) xb->d / 16.f;
146+
const float dm = (float) xb->dmin;
147+
const float dl = d * (float) sc[0];
148+
const float ml = dm * (float) sc[1];
149+
150+
const ushort mask = il < 2 ? 0x0F : 0xF0;
151+
// Low nibble (mask 0x0F): the 5th bit adds 16. High nibble (mask 0xF0) is the
152+
// value pre-shifted by 4 with d/16, so the 5th bit adds 16*16 = 256.
153+
const float qh_val = il < 2 ? 16.f : 256.f;
154+
for (int i = 0; i < 16; ++i) {
155+
const float v = (float)(q[i] & mask) + ((qh[i] & ul) ? qh_val : 0.f);
156+
reg[i / 4][i % 4] = (half)(dl * v - ml);
157+
}
158+
}
159+
"""
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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 for the MLX GGUF pattern handler.
10+
11+
A custom gather Metal kernel is needed because MLX's affine dequantize has no
12+
group_size=32 kernel that reads the raw ``block_q5_K`` (split low-bit/high-bit)
13+
layout, so a Q5_K embedding can't use the generic quantized-embedding path.
14+
Mirrors :mod:`..q6k.embedding`, dequantizing one element per thread via
15+
``dequant_q5k_elem``.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import torch
21+
from executorch.backends.mlx.builder.op_helpers import (
22+
emit_product,
23+
emit_shape,
24+
torch_dtype_to_scalar_type,
25+
)
26+
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
27+
from executorch.backends.mlx.builder.slot_manager import Slot
28+
from executorch.backends.mlx.custom_kernel_ops.gguf.q5k.common import (
29+
_Q5K_HEADER,
30+
Q5K_BLOCK_BYTES,
31+
QK_K,
32+
)
33+
from executorch.backends.mlx.serialization.mlx_graph_schema import (
34+
IntOrVid,
35+
MetalKernelNode,
36+
)
37+
from torch.fx.node import Node
38+
39+
40+
# ---------------------------------------------------------------------------
41+
# Metal kernel source
42+
# ---------------------------------------------------------------------------
43+
44+
45+
# One thread per output element. grid = (K, num_idx, 1): x picks the feature j,
46+
# y picks the gathered row; each thread dequantizes a single Q5_K element.
47+
_Q5K_EMBED_SOURCE = """
48+
const uint j = thread_position_in_grid.x; // 0..K-1
49+
const uint r = thread_position_in_grid.y; // gathered row
50+
const int row = (int) indices[r];
51+
const int nb = K / QK_K;
52+
device const block_q5_K * blk =
53+
((device const block_q5_K *) weight) + (uint)row * nb + (j / QK_K);
54+
out[r * (uint)K + j] = (OutT) dequant_q5k_elem(blk, j % QK_K);
55+
"""
56+
57+
58+
def emit_embedding(
59+
P: MLXProgramBuilder,
60+
head: Node,
61+
weight_node: Node,
62+
indices_node: Node,
63+
output_dtype: torch.dtype,
64+
) -> Slot:
65+
"""Lower a Q5_K ``dequantize_gguf`` -> ``embedding`` pattern to a fused gather.
66+
67+
``weight_node`` is the raw GGUF blob (the dequantize op's weight input) and
68+
``head`` is the ``aten.embedding`` node that owns the output slot.
69+
"""
70+
weight_slot, indices_slot = P.slot_map([weight_node, indices_node])
71+
72+
weight_meta = weight_node.meta["val"]
73+
if weight_meta.dim() != 2:
74+
raise NotImplementedError(
75+
f"gguf q5k embedding: weight must be 2-D (vocab, row_bytes); got "
76+
f"shape {tuple(weight_meta.shape)}"
77+
)
78+
row_bytes = weight_meta.shape[1]
79+
if not isinstance(row_bytes, int):
80+
raise NotImplementedError(
81+
"gguf q5k embedding: weight shape must be statically known"
82+
)
83+
if row_bytes % Q5K_BLOCK_BYTES != 0:
84+
raise ValueError(
85+
f"gguf q5k embedding: weight row bytes {row_bytes} must be a "
86+
f"multiple of {Q5K_BLOCK_BYTES}"
87+
)
88+
K = (row_bytes // Q5K_BLOCK_BYTES) * QK_K
89+
90+
out_dtype_int = torch_dtype_to_scalar_type(output_dtype)
91+
92+
out = P.make_or_get_slot(head)
93+
leading = emit_shape(P, indices_node, indices_slot, end_dim=None)
94+
num_idx_iov = emit_product(P, leading)
95+
out_shape_flat = leading + [IntOrVid.from_literal(K)]
96+
97+
# threadgroup.x must divide grid.x (= K, a multiple of 256).
98+
tg_x = 256 if K % 256 == 0 else K
99+
100+
P.emit(
101+
MetalKernelNode(
102+
name="gguf_q5k_embedding",
103+
source=_Q5K_EMBED_SOURCE,
104+
header=_Q5K_HEADER,
105+
inputs=[P.slot_to_tid(weight_slot), P.slot_to_tid(indices_slot)],
106+
outputs=[P.slot_to_tid(out)],
107+
grid=[IntOrVid.from_literal(K), num_idx_iov, IntOrVid.from_literal(1)],
108+
threadgroup=[
109+
IntOrVid.from_literal(tg_x),
110+
IntOrVid.from_literal(1),
111+
IntOrVid.from_literal(1),
112+
],
113+
input_names=["weight", "indices"],
114+
output_names=["out"],
115+
output_shapes_flat=out_shape_flat,
116+
output_shape_lengths=[len(out_shape_flat)],
117+
output_dtypes=[out_dtype_int],
118+
template_arg_names=["OutT", "K"],
119+
template_arg_kinds=[2, 0], # dtype, int
120+
template_arg_values=[out_dtype_int, K],
121+
)
122+
)
123+
124+
return out

0 commit comments

Comments
 (0)