Skip to content

Commit e11953e

Browse files
authored
Q4k/Q6k improvements (#20643)
Small quant improvements: * Parallelizes writes in Q4k/Q6k kernels. * Adds support for MLX 6-bit kernel
1 parent 6f8a889 commit e11953e

4 files changed

Lines changed: 52 additions & 39 deletions

File tree

backends/mlx/builder/op_helpers.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,8 @@ def emit_lifted_constant(P: "MLXProgramBuilder", value, dtype: torch.dtype) -> S
157157

158158
if isinstance(value, (int, float, bool)):
159159
return P.make_or_get_constant(
160-
f"_scalar_{value}", torch.tensor(value, dtype=dtype) # 0-D
160+
f"_scalar_{value}",
161+
torch.tensor(value, dtype=dtype), # 0-D
161162
)
162163

163164
from executorch.backends.mlx.serialization.mlx_graph_schema import FullNode
@@ -533,11 +534,10 @@ def to_mlx_qparams(
533534
assert qdata.dtype == torch.int8
534535
offset = 2 ** (bits - 1)
535536

536-
# Pack data tightly into uint32
537-
assert 32 % bits == 0
538-
vals_per_uint32 = 32 // bits
539-
assert qdata.shape[1] % vals_per_uint32 == 0
537+
# Pack data into a contiguous uint32 bitstream. cols*bits must be a
538+
# multiple of 32 (holds since in_features is a multiple of group_size>=32).
540539
rows, cols = qdata.shape
540+
assert (cols * bits) % 32 == 0
541541

542542
if bits == 4:
543543
# 4-bit: view(uint8) + wrapping add + pack 2 nibbles per byte → view as uint32
@@ -554,14 +554,18 @@ def to_mlx_qparams(
554554
q = qdata.view(torch.uint8) + offset
555555
Q = q.contiguous().view(torch.uint32).reshape(rows, -1)
556556
else:
557-
# General fallback for other bit widths
558-
Q = (qdata.to(torch.int32) + offset).reshape(-1, vals_per_uint32)
559-
shifts = torch.arange(0, 32, bits, dtype=torch.int32)
560-
shifted = Q << shifts
561-
packed = shifted[:, 0]
562-
for i in range(1, vals_per_uint32):
563-
packed = packed | shifted[:, i]
564-
Q = packed.view(torch.uint32).reshape(rows, -1)
557+
# Contiguous bit-packing for widths that don't divide 32 (e.g. 6-bit),
558+
# matching MLX's affine pack_and_quantize (ops.cpp): each value
559+
# contributes `bits` LSB-first bits to a continuous stream that is
560+
# chunked into uint32 words (also LSB-first), straddling word
561+
# boundaries -> (rows, cols*bits//32) uint32.
562+
q = qdata.to(torch.int32) + offset # 0 .. 2**bits-1
563+
bit_ids = torch.arange(bits, dtype=torch.int32)
564+
stream = (q.unsqueeze(-1) >> bit_ids) & 1 # (rows, cols, bits) LSB-first
565+
stream = stream.reshape(rows, -1, 32) # (rows, n_words, 32)
566+
word_shifts = torch.arange(32, dtype=torch.int64)
567+
packed = (stream.to(torch.int64) << word_shifts).sum(-1) # (rows, n_words)
568+
Q = packed.to(torch.int32).contiguous().view(torch.uint32)
565569

566570
if compute_biases:
567571
B = -scale * (zero_point.to(scale.dtype) + offset)
@@ -654,10 +658,11 @@ def parse_dequant_node(
654658
if group_size not in [16, 32, 64, 128]:
655659
return None
656660

657-
# TODO: MLX supports 3, 5, and 7, but we need to figure out the
658-
# packing story in to_mlx_qparams to use them
661+
# MLX supports 2,3,4,5,6,8-bit affine quantization. to_mlx_qparams packs
662+
# 2/4/8 via fast paths and other widths (e.g. 6) via a general contiguous
663+
# bit-packer, so enable 6 here too.
659664
bits = (qmax - qmin + 1).bit_length() - 1
660-
if bits not in [2, 4, 8]:
665+
if bits not in [2, 4, 6, 8]:
661666
return None
662667
return qdata, scale, zero_point, group_size, bits, out_dtype, quantized_dim
663668

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

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,8 +152,8 @@ def _q4k_matmul_source(has_bias: bool) -> str:
152152
constexpr short NL0 = NK / 16; // = 2 — dequant iterations per thread for weight
153153
constexpr short NL1 = NK / 8; // = 4 — load iterations per thread for activation
154154
155-
threadgroup half sa[4096]; // NR0 * NK storage (strided by 64)
156-
threadgroup half sb[4096]; // NR1 * NK storage (strided by 64)
155+
threadgroup half sa[4096]; // NR0 * NK weight tile; reused as NR1*NR0 float output staging (8 KB)
156+
threadgroup half sb[1024]; // NR1 * NK activation tile (strided by 64): 16 ib slots * 64
157157
158158
const ushort tid = thread_index_in_threadgroup; // 0..127
159159
const ushort sgitg = simdgroup_index_in_threadgroup; // 0..3
@@ -252,7 +252,9 @@ def _q4k_matmul_source(has_bias: bool) -> str:
252252
}}
253253
}}
254254
255-
// --- Write results: always via threadgroup memory for float→OutT cast ---
255+
// --- Write results: stage the output tile in threadgroup memory, then
256+
// drain it to device. Staging is required for the float->OutT cast and the
257+
// optional bias add.
256258
// Barrier needed: sa was used for weight tiles during the K-loop and is now
257259
// reused as float staging for the output. Without this barrier, a fast
258260
// simdgroup could start writing mc[] into sa while a slower one is still
@@ -268,14 +270,15 @@ def _q4k_matmul_source(has_bias: bool) -> str:
268270
}}
269271
threadgroup_barrier(mem_flags::mem_threadgroup);
270272
271-
if (sgitg == 0) {{
272-
for (int j = tid; j < nr1; j += NR1) {{
273-
device OutT * D = out + (uint)(r1 + j) * (uint)N + r0;
274-
threadgroup float * Cp = ((threadgroup float *) sa) + j * NR0;
275-
for (int i = 0; i < nr0; ++i) {{
276-
float v = Cp[i];
277-
D[i] = (OutT)(v {bias_add});
278-
}}
273+
// Drain all NR1*NR0 tile elements across the threadgroup's 128 threads.
274+
// NR0/NR1 are compile-time constants, so idx / NR0 and idx % NR0 fold
275+
// to a shift/mask.
276+
for (int idx = tid; idx < NR1 * NR0; idx += 128) {{
277+
const int j = idx / NR0;
278+
const int i = idx % NR0;
279+
if (j < nr1 && i < nr0) {{
280+
const float v = ((threadgroup float *) sa)[j * NR0 + i];
281+
out[(uint)(r1 + j) * (uint)N + (r0 + i)] = (OutT)(v {bias_add});
279282
}}
280283
}}
281284
}}

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ def _q6k_matmul_source(has_bias: bool) -> str:
162162
constexpr short NL0 = NK / 16; // = 2 — dequant iterations per thread for weight
163163
constexpr short NL1 = NK / 8; // = 4 — load iterations per thread for activation
164164
165-
threadgroup half sa[4096]; // NR0 * NK storage (strided by 64)
166-
threadgroup half sb[4096]; // NR1 * NK storage (strided by 64)
165+
threadgroup half sa[4096]; // NR0 * NK weight tile; reused as NR1*NR0 float output staging (8 KB)
166+
threadgroup half sb[1024]; // NR1 * NK activation tile (strided by 64): 16 ib slots * 64
167167
168168
const ushort tid = thread_index_in_threadgroup; // 0..127
169169
const ushort sgitg = simdgroup_index_in_threadgroup; // 0..3
@@ -262,7 +262,9 @@ def _q6k_matmul_source(has_bias: bool) -> str:
262262
}}
263263
}}
264264
265-
// --- Write results: always via threadgroup memory for float→InT cast ---
265+
// --- Write results: stage the output tile in threadgroup memory, then
266+
// drain it to device. Staging is required for the float->InT cast and the
267+
// optional bias add.
266268
// Barrier needed: sa was used for weight tiles during the K-loop and is now
267269
// reused as float staging for the output. Without this barrier, a fast
268270
// simdgroup could start writing mc[] into sa while a slower one is still
@@ -278,22 +280,23 @@ def _q6k_matmul_source(has_bias: bool) -> str:
278280
}}
279281
threadgroup_barrier(mem_flags::mem_threadgroup);
280282
281-
if (sgitg == 0) {{
282-
for (int j = tid; j < nr1; j += NR1) {{
283-
device InT * D = out + (uint)(r1 + j) * (uint)N + r0;
284-
threadgroup float * Cp = ((threadgroup float *) sa) + j * NR0;
285-
for (int i = 0; i < nr0; ++i) {{
286-
float v = Cp[i];
287-
D[i] = (InT)(v {bias_add});
288-
}}
283+
// Drain all NR1*NR0 tile elements across the threadgroup's 128 threads.
284+
// NR0/NR1 are compile-time constants, so idx / NR0 and idx % NR0 fold
285+
// to a shift/mask.
286+
for (int idx = tid; idx < NR1 * NR0; idx += 128) {{
287+
const int j = idx / NR0;
288+
const int i = idx % NR0;
289+
if (j < nr1 && i < nr0) {{
290+
const float v = ((threadgroup float *) sa)[j * NR0 + i];
291+
out[(uint)(r1 + j) * (uint)N + (r0 + i)] = (InT)(v {bias_add});
289292
}}
290293
}}
291294
}}
292295
"""
293296

294297

295298
# Number of simdgroups per threadgroup for the mat-vec kernel.
296-
_Q6K_MV_NSG = 4
299+
_Q6K_MV_NSG = 2
297300
# Tile sizes for the mat-mat kernel (from llama.cpp kernel_mul_mm).
298301
_Q6K_MM_NR0 = 64 # weight/output rows (N dim) per threadgroup
299302
_Q6K_MM_NR1 = 32 # activation rows (M dim) per threadgroup

backends/mlx/test/test_ops.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6303,6 +6303,8 @@ def get_test_configs(cls) -> List["QuantizedLinearTest"]:
63036303
cls(group_size=128),
63046304
cls(qdtype=torch.int2),
63056305
cls(qdtype=torch.int8),
6306+
cls(qdtype=torch.int6),
6307+
cls(qdtype=torch.int6, group_size=128),
63066308
# group_size=16: exercises the non-fused dequantize+matmul path
63076309
# (requires ET_MLX_ALLOW_NON_FUSED_QUANTIZED_OPS=1).
63086310
cls(qdtype=torch.int8, group_size=16),

0 commit comments

Comments
 (0)