Skip to content

Commit e16c048

Browse files
authored
Support FP16 GGUF export (#20968) (#20968)
Summary: Adds fp16 support to the MLX GGUF export path. Previously activations, the KV cache, and unquantized weights were always bf16; this adds an `--activation-dtype` option (bfloat16 | float16 | float) so a model can be exported for fp16 (or fp32) compute. Key changes: - The export threads an activation dtype through the pipeline: unquantized weights, buffers, and the KV cache are converted to the chosen dtype, and the dtype is recorded in the .pte. - Quantized-matmul scales now follow the activation dtype (`scale_dtype`), so MLX does not promote to float32 (bf16 scales against fp16 activations previously forced float32 and broke kernel compilation). Plumbed through the q4k/q5k/q6k MLX repack paths; CUDA and other callers pass bfloat16 explicitly. - `iter_gguf` now returns F16 tensors in their native fp16 dtype instead of silently casting to bf16; call sites that require bf16 cast explicitly. bf16 remains the default, so existing exports are unchanged. Reviewed By: Gasoonjia Differential Revision: D111968227
1 parent 4558fdc commit e16c048

18 files changed

Lines changed: 146 additions & 48 deletions

backends/cuda/dp4a_planar_int5_tensor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,9 @@ def from_exportable_gguf(cls, gt) -> "CudaDp4aPlanarInt5Tensor":
286286
"CudaDp4aPlanarInt5Tensor.from_exportable_gguf requires a q5_k "
287287
f"ExportableGGUFTensor, got {gt.ggml_type!r}"
288288
)
289-
return cls._from_intx_int8(gt.to_intx_unpacked_to_int8_tensor())
289+
return cls._from_intx_int8(
290+
gt.to_intx_unpacked_to_int8_tensor(scale_dtype=torch.bfloat16)
291+
)
290292

291293
def dequantize(self, output_dtype: Optional[torch.dtype] = None) -> torch.Tensor:
292294
"""Dequantize to a dense tensor (asymmetric: ``w = scale * (u - zero)``).

backends/cuda/dp4a_planar_int6_tensor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,9 @@ def from_exportable_gguf(cls, gt) -> "CudaDp4aPlanarInt6Tensor":
278278
"CudaDp4aPlanarInt6Tensor.from_exportable_gguf requires a q6_k "
279279
f"ExportableGGUFTensor, got {gt.ggml_type!r}"
280280
)
281-
return cls._from_intx_int8(gt.to_intx_unpacked_to_int8_tensor())
281+
return cls._from_intx_int8(
282+
gt.to_intx_unpacked_to_int8_tensor(scale_dtype=torch.bfloat16)
283+
)
282284

283285
def dequantize(self, output_dtype: Optional[torch.dtype] = None) -> torch.Tensor:
284286
"""Dequantize to a dense tensor (symmetric: ``w = q * scale``).

backends/cuda/tests/test_int6_dispatch.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def test_from_exportable_gguf(self):
258258
# (scale = code * step[:, g // (256 // gs)]); the GGUF sub-scales are
259259
# constant within this single super-block, so the int8 re-encoding is
260260
# exact here.
261-
intx = gt.to_intx_unpacked_to_int8_tensor()
261+
intx = gt.to_intx_unpacked_to_int8_tensor(scale_dtype=torch.bfloat16)
262262
q_rt = unpack_int6(t.ql, t.qh, N, nb * 256).to(torch.int8)
263263
self.assertTrue(torch.equal(q_rt, intx.qdata))
264264
n_groups = intx.scale.shape[1]

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def emit_embedding(
3939
gathered rows (MLX affine 4-bit) -- the same shape as MLX's generic quantized
4040
embedding.
4141
"""
42-
w_slot, scales_slot, biases_slot, group_size = repack_mlx(P, weight_node)
42+
w_slot, scales_slot, biases_slot, group_size = repack_mlx(
43+
P, weight_node, scale_dtype=output_dtype
44+
)
4345
(indices_slot,) = P.slot_map([indices_node])
4446

4547
out = P.make_or_get_slot(head)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ def emit_linear(
4646
node. The blob is repacked into MLX qparams at export time, so only the
4747
MLX-format constants are serialized.
4848
"""
49-
w_slot, scales_slot, biases_slot, group_size = repack_mlx(P, weight_node)
49+
w_slot, scales_slot, biases_slot, group_size = repack_mlx(
50+
P, weight_node, scale_dtype=x_node.meta["val"].dtype
51+
)
5052
x_slot, bias_slot = P.slot_map([x_node, bias_node])
5153

5254
out = P.make_or_get_slot(head)

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
from __future__ import annotations
1717

18-
from typing import Tuple
18+
from typing import Optional, Tuple
19+
20+
import torch
1921

2022
from executorch.backends.mlx.builder.op_helpers import to_mlx_qparams
2123
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
@@ -25,18 +27,26 @@
2527
_BITS = 4
2628

2729

28-
def repack_mlx(P: MLXProgramBuilder, weight_node: Node) -> Tuple[Slot, Slot, Slot, int]:
30+
def repack_mlx(
31+
P: MLXProgramBuilder,
32+
weight_node: Node,
33+
scale_dtype: Optional[torch.dtype] = None,
34+
) -> Tuple[Slot, Slot, Slot, int]:
2935
"""Unpack a raw Q4_K blob and repack into MLX qparam constants.
3036
3137
Adjacent sub-blocks with identical scale/min are merged into a larger group
3238
size (up to 128) when lossless, so ``group_size`` may be 32, 64, or 128.
3339
Returns ``(packed_slot, scales_slot, biases_slot, group_size)``.
40+
41+
``scale_dtype`` sets the dtype of the emitted scales/biases constants; pass
42+
the activation dtype so MLX ``quantized_matmul`` does not promote (a bf16
43+
activation with f16 scales, or vice versa, promotes to float32).
3444
"""
3545
from executorch.extension.llm.export.gguf import ExportableGGUFTensor
3646

3747
weight_target, raw = P.get_placeholder_target_and_tensor(weight_node)
3848
intx = ExportableGGUFTensor.from_raw(raw, "q4_k").to_intx_unpacked_to_int8_tensor(
39-
max_group_size=128
49+
max_group_size=128, scale_dtype=scale_dtype
4050
)
4151
group_size = int(intx.block_size[-1])
4252
qdata, scale, zero_point = intx.qdata, intx.scale, intx.zero_point

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ def emit_embedding(
3939
gathered rows (MLX affine 5-bit) -- the same shape as MLX's generic quantized
4040
embedding.
4141
"""
42-
w_slot, scales_slot, biases_slot, group_size = repack_mlx(P, weight_node)
42+
w_slot, scales_slot, biases_slot, group_size = repack_mlx(
43+
P, weight_node, scale_dtype=output_dtype
44+
)
4345
(indices_slot,) = P.slot_map([indices_node])
4446

4547
out = P.make_or_get_slot(head)

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,9 @@ def emit_linear(
4646
node. The blob is repacked into MLX qparams at export time, so only the
4747
MLX-format constants are serialized.
4848
"""
49-
w_slot, scales_slot, biases_slot, group_size = repack_mlx(P, weight_node)
49+
w_slot, scales_slot, biases_slot, group_size = repack_mlx(
50+
P, weight_node, scale_dtype=x_node.meta["val"].dtype
51+
)
5052
x_slot, bias_slot = P.slot_map([x_node, bias_node])
5153

5254
out = P.make_or_get_slot(head)

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

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@
1515

1616
from __future__ import annotations
1717

18-
from typing import Tuple
18+
from typing import Optional, Tuple
19+
20+
import torch
1921

2022
from executorch.backends.mlx.builder.op_helpers import to_mlx_qparams
2123
from executorch.backends.mlx.builder.program_builder import MLXProgramBuilder
@@ -25,18 +27,26 @@
2527
_BITS = 5
2628

2729

28-
def repack_mlx(P: MLXProgramBuilder, weight_node: Node) -> Tuple[Slot, Slot, Slot, int]:
30+
def repack_mlx(
31+
P: MLXProgramBuilder,
32+
weight_node: Node,
33+
scale_dtype: Optional[torch.dtype] = None,
34+
) -> Tuple[Slot, Slot, Slot, int]:
2935
"""Unpack a raw Q5_K blob and repack into MLX qparam constants.
3036
3137
Adjacent sub-blocks with identical scale/min are merged into a larger group
3238
size (up to 128) when lossless, so ``group_size`` may be 32, 64, or 128.
3339
Returns ``(packed_slot, scales_slot, biases_slot, group_size)``.
40+
41+
``scale_dtype`` sets the dtype of the emitted scales/biases constants; pass
42+
the activation dtype so MLX ``quantized_matmul`` does not promote (a bf16
43+
activation with f16 scales, or vice versa, promotes to float32).
3444
"""
3545
from executorch.extension.llm.export.gguf import ExportableGGUFTensor
3646

3747
weight_target, raw = P.get_placeholder_target_and_tensor(weight_node)
3848
intx = ExportableGGUFTensor.from_raw(raw, "q5_k").to_intx_unpacked_to_int8_tensor(
39-
max_group_size=128
49+
max_group_size=128, scale_dtype=scale_dtype
4050
)
4151
group_size = int(intx.block_size[-1])
4252
qdata, scale, zero_point = intx.qdata, intx.scale, intx.zero_point

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ def emit_embedding(
4242
Returns the output slot, or ``None`` when the weight does not merge to an
4343
MLX-supported group size (the caller should fall back to the fused gather).
4444
"""
45-
repacked = repack_mlx(P, weight_node)
45+
repacked = repack_mlx(P, weight_node, scale_dtype=output_dtype)
4646
if repacked is None:
4747
return None
4848
w_slot, scales_slot, biases_slot, group_size = repacked

0 commit comments

Comments
 (0)