Skip to content

Commit 11cd1b8

Browse files
authored
Serialize GGUF weights as packed constants (pytorch#22856)
Summary: Keep GGUF-quantized weights in their packed block form through lowering and serialization, instead of materializing a dequantized float weight inside the delegate. - The partitioner claims torchao::dequantize_gguf (importing executorch.extension.llm.export.gguf to register it), so a GGUF weight dequantize stays inside the delegate rather than forcing a partition boundary. - graph_serialize._fold_gguf_dequant folds the dequantize at write time, the way XNNPACK folds a weight dequantize. Graph topology is not rewritten; instead each dequantize_gguf node is tagged to skip serialization, its packed weight placeholder is tagged with the logical shape and codec, and references to the dequantize output are redirected to the packed weight. The consuming linear/embedding is therefore emitted directly over the packed constant. - The packed constant serializes as TensorMeta(dtype=BYTE, sizes=<logical shape>, quant=QuantSpec(scheme=PackedQuant(codec="gguf:<qtype>"))). The raw GGUF block bytes ship as-is, and the runtime dequantizes inside the matmul/gather. Reviewed By: digantdesai Differential Revision: D111753294 Pull Request resolved: pytorch#22856
1 parent 798c351 commit 11cd1b8

5 files changed

Lines changed: 183 additions & 2 deletions

File tree

‎backends/native/BUCK‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ fbcode_target(
4242
"//executorch/exir/backend:utils",
4343
"//executorch/exir/passes:cse_pass",
4444
"//executorch/exir/passes:lib",
45+
"//executorch/extension/llm/export:gguf",
4546
],
4647
)

‎backends/native/partitioner.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from typing import Callable, final, List, Mapping, Optional, Tuple
1717

18+
# Registers torch.ops.torchao.dequantize_gguf, referenced in _SUPPORTED_NON_CORE_OPS.
19+
import executorch.extension.llm.export.gguf # noqa: F401
1820
import torch
1921
from executorch.backends.native.passes import backend_inplace_aten_variants
2022

@@ -38,6 +40,9 @@
3840
torch.ops.aten.linear.default,
3941
torch.ops.aten.addmm.default,
4042
torch.ops.aten.scaled_dot_product_attention.default,
43+
# GGUF weight dequantize stays in the delegate; the serializer folds it into a
44+
# PackedQuant weight on the consuming op.
45+
torch.ops.torchao.dequantize_gguf.default,
4146
torch.ops.aten.rms_norm.default,
4247
]
4348

‎backends/native/serialization/graph_serialize.py‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@
6666
OutputKind,
6767
OutputSpec,
6868
OutputValueKind,
69+
PackedQuant,
6970
Program,
71+
QuantSpec,
7072
ScalarType,
7173
ScalarTypeArg,
7274
StringArg,
@@ -229,6 +231,17 @@ def _tensor_meta(t: torch.Tensor) -> TensorMeta:
229231
)
230232

231233

234+
def _packed_tensor_meta(codec: str, sizes: tuple[int, ...]) -> TensorMeta:
235+
"""TensorMeta for a codec-packed weight: logical shape with BYTE dtype, the
236+
packing described by a PackedQuant scheme. The raw block bytes ship as data.
237+
"""
238+
return TensorMeta(
239+
dtype=ScalarType.BYTE,
240+
sizes=[_dim(int(s)) for s in sizes],
241+
quant=QuantSpec(scheme=PackedQuant(codec=codec)),
242+
)
243+
244+
232245
# ---------------------------------------------------------------------------
233246
# Argument dispatch.
234247
# ---------------------------------------------------------------------------
@@ -353,6 +366,11 @@ def _node_to_arg_value(
353366
v: torch.fx.Node,
354367
subgraph_map: "dict[str, torch.fx.GraphModule] | None",
355368
) -> ArgumentValue:
369+
# A GGUF weight consumed through dequantize_gguf serializes as the raw packed
370+
# constant (see _fold_gguf_dequant), so redirect the reference to it.
371+
redirect = v.meta.get("native_serialize_as")
372+
if redirect is not None:
373+
return TensorArg(name=redirect)
356374
# A get_attr node that resolves to a submodule GraphModule is a
357375
# higher-order-op subgraph (torch.cond branch, map body, ...). Inline it
358376
# as a GraphArg rather than referencing it like a tensor value.
@@ -758,6 +776,11 @@ def _collect_fx_nodes(
758776
):
759777
continue
760778

779+
# Folded GGUF dequantize nodes are not emitted; the consuming op references
780+
# the packed weight directly (see _fold_gguf_dequant).
781+
if fx_node.meta.get("native_skip_serialize"):
782+
continue
783+
761784
kind = _op_kind(fx_node.op)
762785
target = None
763786
inputs: list[NamedArgument] = []
@@ -839,10 +862,12 @@ def _extract_constants_and_mutable_buffers(
839862
state_dict: dict[str, object],
840863
constants: dict[str, object] | None,
841864
mutated_fqns: set[str],
865+
packed_quant: dict[str, dict[str, object]] | None = None,
842866
) -> tuple[list[NamedTensorRef], dict[str, torch.Tensor], list[MutableBufferSpec]]:
843867
constant_refs: list[NamedTensorRef] = []
844868
constant_data: dict[str, torch.Tensor] = {}
845869
mutable_buffers: list[MutableBufferSpec] = []
870+
packed_quant = packed_quant or {}
846871

847872
for ispec in getattr(graph_signature, "input_specs", []) or []:
848873
if ispec.kind not in _INPUT_KIND_MAP:
@@ -862,11 +887,17 @@ def _extract_constants_and_mutable_buffers(
862887
if not isinstance(tensor, torch.Tensor):
863888
continue
864889
tensor = tensor.contiguous()
890+
pq = packed_quant.get(name)
891+
meta = (
892+
_packed_tensor_meta(pq["codec"], pq["sizes"])
893+
if pq is not None
894+
else _tensor_meta(tensor)
895+
)
865896
constant_refs.append(
866897
NamedTensorRef(
867898
name=name,
868899
data_key=target_fqn,
869-
meta=_tensor_meta(tensor),
900+
meta=meta,
870901
kind=_INPUT_KIND_MAP[ispec.kind],
871902
mutated=target_fqn in mutated_fqns,
872903
)
@@ -916,9 +947,14 @@ def _build_method_graph(
916947
]:
917948
_validate_tensor_user_inputs(graph_signature)
918949
output_specs, mutated_fqns = _build_output_specs(output_names, graph_signature)
950+
packed_quant = {
951+
n.name: n.meta["native_packed_quant"]
952+
for n in graph_module.graph.nodes
953+
if n.op == "placeholder" and "native_packed_quant" in n.meta
954+
}
919955
constant_refs, constant_data, mutable_buffers = (
920956
_extract_constants_and_mutable_buffers(
921-
graph_signature, state_dict, constants, mutated_fqns
957+
graph_signature, state_dict, constants, mutated_fqns, packed_quant
922958
)
923959
)
924960
constant_names = {c.name for c in constant_refs}
@@ -936,6 +972,30 @@ def _build_method_graph(
936972
return graph, constant_refs, output_specs, mutable_buffers, constant_data
937973

938974

975+
def _fold_gguf_dequant(graph_module: torch.fx.GraphModule) -> None:
976+
"""Tag GGUF dequantize nodes so serialization emits the consuming op over the
977+
packed weight, the way XNNPACK folds a weight dequantize at write time.
978+
979+
The graph is left unchanged: each dequantize_gguf node is marked to skip, its
980+
packed weight arg is tagged with a PackedQuant scheme (logical shape, codec),
981+
and references to the dequantize output are redirected to the packed weight.
982+
"""
983+
for node in graph_module.graph.nodes:
984+
op = _resolve_op_overload(node.target) if node.op == "call_function" else None
985+
if op is None or op.name() != "torchao::dequantize_gguf" or len(node.args) < 2:
986+
continue
987+
weight = node.args[0]
988+
val = node.meta.get("val")
989+
if not isinstance(weight, torch.fx.Node) or not isinstance(val, torch.Tensor):
990+
continue
991+
weight.meta["native_packed_quant"] = {
992+
"codec": f"gguf:{node.args[1]}",
993+
"sizes": tuple(int(s) for s in val.shape),
994+
}
995+
node.meta["native_serialize_as"] = weight.name
996+
node.meta["native_skip_serialize"] = True
997+
998+
939999
def _build_graph_body(
9401000
graph_module: torch.fx.GraphModule,
9411001
graph_signature: object | None,
@@ -948,6 +1008,7 @@ def _build_graph_body(
9481008
list[MutableBufferSpec],
9491009
dict[str, torch.Tensor],
9501010
]:
1011+
_fold_gguf_dequant(graph_module)
9511012
subgraph_map = _subgraph_map(graph_module)
9521013
nodes, val_by_name, output_names = _collect_fx_nodes(graph_module, subgraph_map)
9531014

‎backends/native/test/BUCK‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ fbcode_target(
2222
"//caffe2:torch",
2323
"//executorch/backends/native:lib",
2424
"//executorch/exir:lib",
25+
"//executorch/extension/llm/export:gguf",
2526
],
2627
)
2728

@@ -45,6 +46,20 @@ fbcode_target(
4546
"//caffe2:torch",
4647
"//executorch/backends/native:lib",
4748
"//executorch/exir:lib",
49+
"//executorch/extension/llm/export:gguf",
50+
],
51+
)
52+
53+
fbcode_target(
54+
_kind = runtime.python_test,
55+
name = "test_quant",
56+
srcs = ["test_quant.py"],
57+
deps = [
58+
"//caffe2:torch",
59+
"//executorch/backends/native:lib",
60+
"//executorch/backends/native/test:utils",
61+
"//executorch/exir:lib",
62+
"//executorch/extension/llm/export:gguf",
4863
],
4964
)
5065

@@ -56,5 +71,6 @@ fbcode_target(
5671
"//caffe2:torch",
5772
"//executorch/backends/native:lib",
5873
"//executorch/exir:lib",
74+
"//executorch/extension/llm/export:gguf",
5975
],
6076
)

‎backends/native/test/test_quant.py‎

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
import unittest
8+
9+
import torch
10+
import torch.nn as nn
11+
12+
from executorch.backends.native.serialization import deserialize_program
13+
from executorch.backends.native.serialization.schema import PackedQuant, ScalarType
14+
from executorch.backends.native.test.utils import (
15+
_call_function_targets,
16+
_get_delegate_blob,
17+
_lower,
18+
)
19+
from executorch.extension.llm.export.gguf import ExportableGGUFTensor
20+
21+
22+
class FuseGGUFPassTest(unittest.TestCase):
23+
def _packed_constants(self, program):
24+
return [
25+
c
26+
for c in (program.methods[0].constants or [])
27+
if c.meta.quant is not None and isinstance(c.meta.quant.scheme, PackedQuant)
28+
]
29+
30+
def test_gguf_linear_serializes_as_packed_linear(self):
31+
# A GGUF-quantized linear serializes as a plain linear over a weight
32+
# constant tagged with a PackedQuant codec, dropping the dequantize.
33+
n, k = 8, 256 # k must be a multiple of QK_K (256); q4_k block = 144 bytes
34+
blob = torch.randint(0, 256, (n, (k // 256) * 144), dtype=torch.uint8)
35+
lin = nn.Linear(k, n, bias=False)
36+
lin.weight = nn.Parameter(
37+
ExportableGGUFTensor.from_raw(blob, "q4_k", torch.float32),
38+
requires_grad=False,
39+
)
40+
41+
program = deserialize_program(
42+
_get_delegate_blob(_lower(lin, (torch.randn(2, k),)))
43+
)
44+
targets = _call_function_targets(program.methods[0].graph)
45+
self.assertTrue(any(t and "linear" in t for t in targets))
46+
self.assertFalse(any(t and "dequantize_gguf" in t for t in targets))
47+
48+
packed = self._packed_constants(program)
49+
self.assertEqual(len(packed), 1)
50+
self.assertEqual(packed[0].meta.quant.scheme.codec, "gguf:q4_k")
51+
self.assertEqual(packed[0].meta.dtype, ScalarType.BYTE)
52+
53+
def test_activation_dtype_in_metadata(self):
54+
# The activation compute dtype must round-trip into the serialized
55+
# TensorMeta of every value in the graph (the packed weight stays BYTE).
56+
n, k = 8, 256 # k multiple of QK_K (256); q4_k block = 144 bytes
57+
blob = torch.randint(0, 256, (n, (k // 256) * 144), dtype=torch.uint8)
58+
for dtype, expected in (
59+
(torch.float32, ScalarType.FLOAT),
60+
(torch.float16, ScalarType.HALF),
61+
(torch.bfloat16, ScalarType.BFLOAT16),
62+
):
63+
with self.subTest(dtype=dtype):
64+
lin = nn.Linear(k, n, bias=False)
65+
lin.weight = nn.Parameter(
66+
ExportableGGUFTensor.from_raw(blob, "q4_k", dtype),
67+
requires_grad=False,
68+
)
69+
program = deserialize_program(
70+
_get_delegate_blob(_lower(lin, (torch.randn(2, k, dtype=dtype),)))
71+
)
72+
metas = [
73+
tv.meta for tv in (program.methods[0].graph.tensor_values or [])
74+
]
75+
self.assertTrue(metas, "expected serialized tensor values")
76+
for meta in metas:
77+
self.assertEqual(meta.dtype, expected)
78+
79+
def test_gguf_embedding_serializes_as_packed_embedding(self):
80+
# The embedding counterpart: plain embedding over a PackedQuant weight.
81+
num_emb, k = 16, 256 # k multiple of QK_K (256); q4_k block = 144 bytes
82+
blob = torch.randint(0, 256, (num_emb, (k // 256) * 144), dtype=torch.uint8)
83+
emb = nn.Embedding(num_emb, k)
84+
emb.weight = nn.Parameter(
85+
ExportableGGUFTensor.from_raw(blob, "q4_k", torch.float32),
86+
requires_grad=False,
87+
)
88+
89+
program = deserialize_program(
90+
_get_delegate_blob(_lower(emb, (torch.randint(0, num_emb, (2, 3)),)))
91+
)
92+
targets = _call_function_targets(program.methods[0].graph)
93+
self.assertTrue(any(t and "embedding" in t for t in targets))
94+
self.assertFalse(any(t and "dequantize_gguf" in t for t in targets))
95+
96+
packed = self._packed_constants(program)
97+
self.assertEqual(len(packed), 1)
98+
self.assertEqual(packed[0].meta.quant.scheme.codec, "gguf:q4_k")

0 commit comments

Comments
 (0)