Skip to content

Commit c7f7931

Browse files
Support affine int8 weight specifications in Arm quantization (pytorch#24337)
Summary: Allow static per-tensor and per-channel affine weight specifications. Retain the channel axis when deriving bias quantization parameters. Serialize identical per-channel convolution weight zero points as the scalar required by TOSA and reject conflicting zero points explicitly. Reviewed By: rascani cc @digantdesai @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani Differential Revision: D128346146 Pull Request resolved: pytorch#24619
1 parent 4654b57 commit c7f7931

5 files changed

Lines changed: 216 additions & 8 deletions

File tree

‎backends/arm/operators/ops_quant_utils.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,15 @@ def add_input_weight_zp_consts(tosa_graph, node, inputs, output_name):
2020
weight_zp = 0
2121
if inputs[1].dtype == ts.DType.INT8:
2222
input_qparams = get_input_qparams(node)
23-
weight_zp = input_qparams[1].zp # type: ignore[assignment]
23+
zero_points = input_qparams[1].zp
24+
if isinstance(zero_points, list):
25+
if not zero_points or any(zp != zero_points[0] for zp in zero_points):
26+
raise ValueError(
27+
"TOSA convolution requires one common weight zero point"
28+
)
29+
weight_zp = zero_points[0]
30+
else:
31+
weight_zp = zero_points
2432

2533
input_zp_name = f"{output_name}_input_zp"
2634
weight_zp_name = f"{output_name}_weight_zp"
@@ -29,7 +37,7 @@ def add_input_weight_zp_consts(tosa_graph, node, inputs, output_name):
2937
[1], inputs[0].dtype, [input_zp], name=input_zp_name
3038
)
3139
weight_zp_tensor = tosa_graph.addConst(
32-
[1], inputs[1].dtype, weight_zp, name=weight_zp_name
40+
[1], inputs[1].dtype, [weight_zp], name=weight_zp_name
3341
)
3442

3543
return input_zp_tensor.name, weight_zp_tensor.name

‎backends/arm/quantizer/quantization_annotator.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ def _adjust_weight_qspec_for_conv_transpose(
129129
if (
130130
node.target != torch.ops.aten.conv_transpose2d.input
131131
or weight_qspec is None
132-
or weight_qspec.qscheme != torch.per_channel_symmetric
132+
or weight_qspec.qscheme
133+
not in (torch.per_channel_symmetric, torch.per_channel_affine)
133134
):
134135
return weight_qspec
135136

‎backends/arm/quantizer/quantization_config.py‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,13 @@ def get_weight_qspec(
232232
) -> Optional[QuantizationSpecBase]:
233233
"""Get the validated weight spec.
234234
235-
Validate that the weight qscheme is supported (per-tensor or
236-
per-channel symmetric) before returning the spec.
235+
Validate that the weight qscheme is supported before returning the spec.
237236
238237
Returns:
239238
Optional[QuantizationSpecBase]: Weight spec, or ``None`` when unset.
240239
241240
Raises:
242-
ValueError: If the qscheme is not a supported symmetric scheme.
241+
ValueError: If the qscheme is not a supported static scheme.
243242
244243
"""
245244
if self.weight is None:
@@ -248,6 +247,8 @@ def get_weight_qspec(
248247
if not hasattr(self.weight, "qscheme") or self.weight.qscheme not in [
249248
torch.per_tensor_symmetric,
250249
torch.per_channel_symmetric,
250+
torch.per_tensor_affine,
251+
torch.per_channel_affine,
251252
]:
252253
raise ValueError(f"Unsupported quantization_spec {self.weight} for weight")
253254
return self.weight
@@ -354,7 +355,10 @@ def _derive_qparams_fn(
354355
)
355356
ch_axis = None
356357
if self.weight is not None:
357-
if qscheme == torch.per_channel_symmetric:
358+
if qscheme in (
359+
torch.per_channel_symmetric,
360+
torch.per_channel_affine,
361+
):
358362
ch_axis = self.weight.ch_axis
359363
if (
360364
node.target == torch.ops.aten.conv_transpose2d.input
@@ -466,7 +470,8 @@ def get_weight_qspec(
466470
node is not None
467471
and weight_qspec is not None
468472
and isinstance(weight_qspec, QuantizationSpec)
469-
and weight_qspec.qscheme == torch.per_channel_symmetric
473+
and weight_qspec.qscheme
474+
in (torch.per_channel_symmetric, torch.per_channel_affine)
470475
and node.target == torch.ops.aten.conv_transpose2d.input
471476
):
472477
# MLETORCH-1853: Fix lazy import when moving files around
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
load("@fbcode_macros//build_defs:build_file_migration.bzl", "fbcode_target")
2+
load("@fbcode_macros//build_defs:python_pytest.bzl", "python_pytest")
3+
load("@fbsource//xplat/executorch/build:runtime_wrapper.bzl", "runtime")
4+
5+
oncall("executorch")
6+
7+
fbcode_target(
8+
_kind = python_pytest,
9+
name = "test_affine_weights",
10+
srcs = ["test_affine_weights.py"],
11+
env = {"TEST_RUNTIME_IS_NOT_OSS": "1" if not runtime.is_oss else "0"},
12+
preload_deps = ["//executorch/kernels/quantized:custom_ops_generated_lib"],
13+
deps = [
14+
"//caffe2:torch",
15+
"//executorch/backends/arm/_passes:passes",
16+
"//executorch/backends/arm/operators:ops",
17+
"//executorch/backends/arm/quantizer:lib",
18+
"//executorch/backends/arm/test:arm_tester" if runtime.is_oss else "//executorch/backends/arm/test/tester/fb:arm_tester_fb",
19+
"//executorch/backends/test/harness:tester",
20+
"//pytorch/ao:torchao",
21+
"fbsource//third-party/pypi/pytest:pytest",
22+
"fbsource//third-party/tosa_tools:serializer",
23+
"fbsource//third-party/tosa_tools:tosa_reference_model",
24+
],
25+
)
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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+
from dataclasses import replace
8+
from unittest.mock import Mock
9+
10+
import pytest
11+
import torch
12+
import tosa_serializer as ts
13+
from executorch.backends.arm._passes.quant_args import QuantArgs
14+
from executorch.backends.arm.operators.ops_quant_utils import add_input_weight_zp_consts
15+
from executorch.backends.arm.quantizer.arm_quantizer import (
16+
get_symmetric_quantization_config,
17+
)
18+
from executorch.backends.arm.quantizer.quantization_config import QuantizationConfig
19+
from executorch.backends.arm.test.tester.test_pipeline import TosaPipelineINT
20+
from executorch.backends.test.harness.stages import StageType
21+
from torchao.quantization.pt2e.quantizer import (
22+
DerivedQuantizationSpec,
23+
QuantizationSpec,
24+
)
25+
26+
27+
def _affine_config(per_channel: bool) -> QuantizationConfig:
28+
config = get_symmetric_quantization_config(is_per_channel=per_channel)
29+
weight = config.weight
30+
assert isinstance(weight, QuantizationSpec)
31+
return replace(
32+
config,
33+
weight=replace(
34+
weight,
35+
qscheme=(
36+
torch.per_channel_affine if per_channel else torch.per_tensor_affine
37+
),
38+
quant_min=-128,
39+
),
40+
)
41+
42+
43+
@pytest.mark.parametrize("per_channel", [False, True])
44+
def test_affine_weight_specs_and_derived_channel_bias(per_channel: bool) -> None:
45+
config = _affine_config(per_channel)
46+
assert config.get_weight_qspec() is config.weight
47+
graph = torch.fx.Graph()
48+
node = graph.call_function(
49+
torch.ops.aten.linear.default,
50+
(
51+
graph.placeholder("input"),
52+
graph.placeholder("weight"),
53+
graph.placeholder("bias"),
54+
),
55+
)
56+
bias = config.get_bias_qspec(node)
57+
assert isinstance(bias, DerivedQuantizationSpec)
58+
assert bias.ch_axis == (0 if per_channel else None)
59+
60+
61+
def test_equal_channel_weight_zero_points_serialize_as_one_scalar() -> None:
62+
graph = torch.fx.Graph()
63+
node = graph.placeholder("conv")
64+
node.meta["input_qparams"] = {
65+
0: QuantArgs(0.1, 0, -127, 127, torch.int8),
66+
1: QuantArgs([0.01, 0.02], [-28, -28], -128, 127, torch.int8, per_channel=True),
67+
}
68+
inputs = [Mock(dtype=ts.DType.INT8), Mock(dtype=ts.DType.INT8)]
69+
serializer = Mock()
70+
input_const, weight_const = Mock(), Mock()
71+
input_const.name, weight_const.name = "input_zp", "weight_zp"
72+
serializer.addConst.side_effect = [input_const, weight_const]
73+
add_input_weight_zp_consts(serializer, node, inputs, "conv")
74+
assert serializer.addConst.call_args_list[1].args == ([1], ts.DType.INT8, [-28])
75+
node.meta["input_qparams"][1] = QuantArgs(
76+
[0.01, 0.02], [-28, -27], -128, 127, torch.int8, per_channel=True
77+
)
78+
with pytest.raises(ValueError, match="common weight zero point"):
79+
add_input_weight_zp_consts(serializer, node, inputs, "conv")
80+
81+
82+
def _affine_model(
83+
operator: str, conflicting_zero_points: bool = False
84+
) -> tuple[torch.nn.Module, tuple[torch.Tensor]]:
85+
model: torch.nn.Linear | torch.nn.Conv2d | torch.nn.ConvTranspose2d
86+
if operator == "linear":
87+
model = torch.nn.Linear(8, 3)
88+
inputs = (torch.linspace(-1, 1, 8).reshape(1, 8),)
89+
else:
90+
model = (
91+
torch.nn.ConvTranspose2d(2, 3, 2)
92+
if operator == "conv_transpose2d"
93+
else torch.nn.Conv2d(2, 3, 2)
94+
)
95+
inputs = (torch.linspace(-1, 1, 72).reshape(1, 2, 6, 6),)
96+
axis = 1 if operator == "conv_transpose2d" else 0
97+
channel_shape = model.weight.movedim(axis, 0).shape
98+
base = torch.linspace(-0.25, 1, model.weight.numel() // 3).reshape(
99+
channel_shape[1:]
100+
)
101+
weights = torch.stack([base * scale for scale in (1, 2, 3)])
102+
if conflicting_zero_points:
103+
weights[0] += 0.25
104+
with torch.no_grad():
105+
model.weight.copy_(weights.movedim(0, axis))
106+
assert model.bias is not None
107+
model.bias.copy_(torch.tensor([0.1, 0.2, 0.3]))
108+
return model.eval(), inputs
109+
110+
111+
def _check_affine_qparams(
112+
pipeline: TosaPipelineINT[tuple[torch.Tensor]], per_channel: bool
113+
) -> None:
114+
graph_module = pipeline.tester.get_artifact(StageType.QUANTIZE)
115+
op = (
116+
torch.ops.quantized_decomposed.dequantize_per_channel.default
117+
if per_channel
118+
else torch.ops.quantized_decomposed.dequantize_per_tensor.default
119+
)
120+
weights = [
121+
node
122+
for node in graph_module.graph.nodes
123+
if node.target == op and node.args[-1] == torch.int8
124+
]
125+
if per_channel:
126+
assert len(weights) == 1
127+
scales = graph_module.get_buffer(str(weights[0].args[1].target))
128+
zero_points = graph_module.get_buffer(str(weights[0].args[2].target))
129+
assert scales.numel() == 3
130+
assert torch.unique(scales).numel() == 3
131+
assert torch.unique(zero_points).numel() == 1
132+
assert zero_points[0] != 0
133+
biases = [
134+
node
135+
for node in graph_module.graph.nodes
136+
if node.target == op and node.args[-1] == torch.int32
137+
]
138+
assert len(biases) == 1
139+
assert biases[0].args[3] == 0
140+
assert graph_module.get_buffer(str(biases[0].args[1].target)).numel() == 3
141+
else:
142+
weight = next(node for node in weights if node.args[0].op == "get_attr")
143+
assert weight.args[2] != 0
144+
145+
146+
@pytest.mark.parametrize("operator", ["linear", "conv2d", "conv_transpose2d"])
147+
@pytest.mark.parametrize("per_channel", [False, True])
148+
def test_affine_weights_tosa_pipeline(operator: str, per_channel: bool) -> None:
149+
model, inputs = _affine_model(operator)
150+
pipeline = TosaPipelineINT[tuple[torch.Tensor]](
151+
model,
152+
inputs,
153+
aten_op=f"torch.ops.aten.{operator}.{'input' if operator == 'conv_transpose2d' else 'default'}",
154+
per_channel_quantization=per_channel,
155+
)
156+
pipeline.quantizer.set_global(_affine_config(per_channel))
157+
pipeline.add_stage_after("quantize", _check_affine_qparams, pipeline, per_channel)
158+
pipeline.run()
159+
assert pipeline.has_stage("run_method_and_compare_outputs")
160+
161+
162+
def test_conflicting_affine_weight_zero_points_rejected_by_pipeline() -> None:
163+
model, inputs = _affine_model("conv2d", conflicting_zero_points=True)
164+
pipeline = TosaPipelineINT[tuple[torch.Tensor]](
165+
model, inputs, aten_op="torch.ops.aten.conv2d.default"
166+
)
167+
pipeline.quantizer.set_global(_affine_config(True))
168+
with pytest.raises(ValueError, match="common weight zero point"):
169+
pipeline.run()

0 commit comments

Comments
 (0)