Skip to content

Commit a931a27

Browse files
Arm backend: preserve Q/DQ for partially quant ops (#21589)
The Q/DQ folding pass incorrectly removes quantization boundaries when only one of an operator's peer tensor inputs is quantized. This can make later lowering treat quantized and floating-point inputs as compatible quantized values. Preserve add and sub by default. For mixed TOSA profiles and VGF, use an explicit operator allowlist while excluding index operands. Signed-off-by: Sangwon Ha <sangwon.ha@arm.com>
1 parent 2afdd93 commit a931a27

3 files changed

Lines changed: 237 additions & 16 deletions

File tree

backends/arm/_passes/arm_pass_manager.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -508,7 +508,13 @@ def _tosa_pipeline(
508508
# Fold Q/DQ nodes, insert INT8/INT32 rescales, decompose quantization nodes.
509509
self.add_passes(
510510
[
511-
FoldAndAnnotateQParamsPass(exported_program),
511+
FoldAndAnnotateQParamsPass(
512+
exported_program,
513+
preserve_partial_binary_tensor_qdq=(
514+
self.tosa_spec.support_float()
515+
or self.compile_spec._get_output_format() == "vgf"
516+
),
517+
),
512518
# Both hardtanh and relu are normalized to clamp by
513519
# ConvertToClampPass; after q/dq folding above, adjacent clamps
514520
# (e.g. from HardTanh+ReLU) are directly connected and can be

backends/arm/_passes/fold_qdq_with_annotated_qparams_pass.py

Lines changed: 87 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import copy
88

9-
from typing import cast, Optional, Set, Type
9+
from typing import cast, ClassVar, Optional, Set, Type
1010

1111
import torch
1212
from executorch.backends.arm._passes import ArmPass
@@ -127,12 +127,36 @@ class FoldAndAnnotateQParamsPass(ArmPass):
127127
InsertTableOpsPass,
128128
RemoveNoopPass,
129129
}
130+
_default_partial_binary_qdq_targets: ClassVar[tuple[object, ...]] = (
131+
exir_ops.edge.aten.add.Tensor,
132+
exir_ops.edge.aten.sub.Tensor,
133+
)
134+
_mixed_profile_partial_binary_qdq_targets: ClassVar[tuple[object, ...]] = (
135+
*_default_partial_binary_qdq_targets,
136+
exir_ops.edge.aten.mul.Tensor,
137+
exir_ops.edge.aten.div.Tensor,
138+
exir_ops.edge.aten.minimum.default,
139+
exir_ops.edge.aten.maximum.default,
140+
exir_ops.edge.aten.mm.default,
141+
exir_ops.edge.aten.bmm.default,
142+
exir_ops.edge.aten.eq.Tensor,
143+
exir_ops.edge.aten.ge.Tensor,
144+
exir_ops.edge.aten.gt.Tensor,
145+
exir_ops.edge.aten.le.Tensor,
146+
exir_ops.edge.aten.lt.Tensor,
147+
exir_ops.edge.aten.grid_sampler_2d.default,
148+
)
130149

131150
def __init__(
132-
self, exported_program: Optional[ExportedProgram] = None, *args, **kwargs
151+
self,
152+
exported_program: Optional[ExportedProgram] = None,
153+
*args,
154+
preserve_partial_binary_tensor_qdq: bool = False,
155+
**kwargs,
133156
) -> None:
134157
super().__init__(*args, **kwargs)
135158
self.exported_program = exported_program
159+
self.preserve_partial_binary_tensor_qdq = preserve_partial_binary_tensor_qdq
136160

137161
def _extract_input_params(
138162
self, arg_list: list[Node]
@@ -177,9 +201,13 @@ def _annotate_input_params(
177201
index: int,
178202
input_qparams: QuantArgs,
179203
nodes_to_remove: set[Node],
204+
remove_qdq: bool = True,
180205
) -> None:
181206
node.meta["input_qparams"][index] = input_qparams
182207

208+
if not remove_qdq:
209+
return
210+
183211
for dq in nodes_to_remove:
184212
if dq.target not in DQ_OPS:
185213
raise RuntimeError(f"Expected one of {DQ_OPS} dq_op, got {dq.target}")
@@ -201,6 +229,35 @@ def fold_and_annotate_arg(
201229
graph_module, node, i, input_qparams, nodes_to_remove
202230
)
203231

232+
def _extract_arg_input_params(
233+
self, arg: object
234+
) -> tuple[Optional[QuantArgs], set[Node]]:
235+
if isinstance(arg, (list, tuple)):
236+
return self._extract_input_params(list(arg))
237+
if isinstance(arg, Node):
238+
return self._extract_input_params([arg])
239+
return None, set()
240+
241+
def _has_partial_binary_tensor_qdq_inputs(
242+
self, node: Node, input_qparams: dict[int, QuantArgs]
243+
) -> bool:
244+
targets = (
245+
self._mixed_profile_partial_binary_qdq_targets
246+
if self.preserve_partial_binary_tensor_qdq
247+
else self._default_partial_binary_qdq_targets
248+
)
249+
if node.target not in targets:
250+
return False
251+
252+
lhs_idx, rhs_idx = 0, 1
253+
if lhs_idx >= len(node.args) or rhs_idx >= len(node.args):
254+
return False
255+
if not isinstance(node.args[lhs_idx], Node) or not isinstance(
256+
node.args[rhs_idx], Node
257+
):
258+
return False
259+
return (lhs_idx in input_qparams) != (rhs_idx in input_qparams)
260+
204261
def _handle_control_flow_node(self, node: Node, graph_module: GraphModule):
205262
"""Fold outmost quant nodes inside submodule.
206263
@@ -327,12 +384,12 @@ def _correct_output_dtype(node: torch.fx.Node):
327384
def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
328385

329386
# Loop over the graph nodes and find any node in the 'targeted_ops' list.
330-
modified = False
387+
graph_modified = False
388+
metadata_modified = False
331389
for n in graph_module.graph.nodes:
332390
n = cast(Node, n)
333391
if not FoldAndAnnotateQParamsPass.is_foldable(n):
334392
continue
335-
modified = True
336393

337394
# Make sure we haven't already set qparams meta information on the node
338395
if "input_qparams" in n.meta:
@@ -346,17 +403,32 @@ def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
346403
"output_qparams should not have been set at this point"
347404
)
348405

406+
input_qparams: dict[int, QuantArgs] = {}
407+
input_nodes_to_remove: dict[int, set[Node]] = {}
408+
for i, arg in enumerate(n.args):
409+
qparams, nodes_to_remove = self._extract_arg_input_params(arg)
410+
if qparams is not None:
411+
input_qparams[i] = qparams
412+
input_nodes_to_remove[i] = nodes_to_remove
413+
414+
preserve_qdq = self._has_partial_binary_tensor_qdq_inputs(n, input_qparams)
415+
graph_modified = graph_modified or not preserve_qdq
416+
metadata_modified = True
417+
349418
# for the inputs and outputs search the graph for quantization info and
350419
# store the information in a dict with order of the _tensor_ inputs as key,
351420
# ignoring any other arguments to the target node.
352421
n.meta["input_qparams"] = {}
353422
n.meta["output_qparams"] = {}
354-
for i, arg in enumerate(n.args):
355-
if isinstance(arg, (list, tuple)):
356-
self.fold_and_annotate_arg(graph_module, n, arg, i) # type: ignore
357-
358-
elif isinstance(arg, Node):
359-
self.fold_and_annotate_arg(graph_module, n, [arg], i)
423+
for i, qparams in input_qparams.items():
424+
self._annotate_input_params(
425+
graph_module,
426+
n,
427+
i,
428+
qparams,
429+
input_nodes_to_remove[i],
430+
remove_qdq=not preserve_qdq,
431+
)
360432

361433
# Copy the users, since we are modifying it.
362434
users_copy = copy.copy(n.users)
@@ -369,8 +441,9 @@ def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
369441
user.target, user.args
370442
)
371443

372-
user.replace_all_uses_with(n)
373-
graph_module.graph.erase_node(user)
444+
if not preserve_qdq:
445+
user.replace_all_uses_with(n)
446+
graph_module.graph.erase_node(user)
374447

375448
# Some op(s) contain a "dtype" key in their node kwargs. Set this
376449
# to the type of output qparams.
@@ -383,10 +456,10 @@ def call(self, graph_module: GraphModule) -> PassResult: # noqa: C901
383456
self._handle_control_flow_node(n, graph_module)
384457

385458
# retrace the graph to update the fake tensor types
386-
if modified:
459+
if graph_modified:
387460
graph_module = super().call(graph_module).graph_module
388461

389-
return PassResult(graph_module, modified)
462+
return PassResult(graph_module, metadata_modified or graph_modified)
390463

391464

392465
class QuantizeClampArgumentsPass(ArmPass):

backends/arm/test/passes/test_fold_qdq_pass.py

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,35 @@
33
# This source code is licensed under the BSD-style license found in the
44
# LICENSE file in the root directory of this source tree.
55

6-
from typing import ClassVar, Dict, Tuple
6+
from typing import Callable, ClassVar, Dict, Tuple
77

8+
import pytest
89
import torch
910
from executorch.backends.arm._passes import FoldAndAnnotateQParamsPass
11+
from executorch.backends.arm.common.annotation_meta import ArmAnnotationInfo
1012
from executorch.backends.arm.test import common
1113
from executorch.backends.arm.test.tester.test_pipeline import PassPipeline
14+
from executorch.exir.dialects._ops import ops as exir_ops
1215

1316

1417
input_t = Tuple[torch.Tensor, torch.Tensor] # Input x, y
1518

19+
_MIXED_PROFILE_PARTIAL_BINARY_TENSOR_TARGETS = (
20+
exir_ops.edge.aten.add.Tensor,
21+
exir_ops.edge.aten.sub.Tensor,
22+
exir_ops.edge.aten.mul.Tensor,
23+
exir_ops.edge.aten.div.Tensor,
24+
exir_ops.edge.aten.minimum.default,
25+
exir_ops.edge.aten.maximum.default,
26+
exir_ops.edge.aten.mm.default,
27+
exir_ops.edge.aten.bmm.default,
28+
exir_ops.edge.aten.eq.Tensor,
29+
exir_ops.edge.aten.ge.Tensor,
30+
exir_ops.edge.aten.gt.Tensor,
31+
exir_ops.edge.aten.le.Tensor,
32+
exir_ops.edge.aten.lt.Tensor,
33+
)
34+
1635

1736
class SimpleQuantizeModel(torch.nn.Module):
1837
test_data: ClassVar[Dict[str, input_t]] = {
@@ -49,3 +68,126 @@ def test_fold_and_annotate_q_params_tosa_INT(test_data: input_t) -> None:
4968
)
5069
pipeline.pop_stage(-1) # Do not compare output
5170
pipeline.run()
71+
72+
73+
@pytest.mark.parametrize(
74+
"binary_target",
75+
(exir_ops.edge.aten.add.Tensor, exir_ops.edge.aten.sub.Tensor),
76+
)
77+
def test_fold_qdq_preserves_default_partial_binary_qdq(
78+
binary_target: Callable[..., object],
79+
) -> None:
80+
_check_fold_qdq_preserves_partial_binary_qdq(binary_target)
81+
82+
83+
@pytest.mark.parametrize(
84+
"binary_target",
85+
_MIXED_PROFILE_PARTIAL_BINARY_TENSOR_TARGETS,
86+
)
87+
def test_fold_qdq_preserves_mixed_profile_partial_binary_tensor_qdq(
88+
binary_target: Callable[..., object],
89+
) -> None:
90+
_check_fold_qdq_preserves_partial_binary_qdq(
91+
binary_target, preserve_partial_binary_tensor_qdq=True
92+
)
93+
94+
95+
def test_fold_qdq_preserves_mixed_profile_partial_grid_sampler_qdq() -> None:
96+
_check_fold_qdq_preserves_partial_binary_qdq(
97+
exir_ops.edge.aten.grid_sampler_2d.default,
98+
preserve_partial_binary_tensor_qdq=True,
99+
extra_args=(0, 0, False),
100+
)
101+
102+
103+
def test_fold_qdq_mixed_profile_allowlist_has_test_coverage() -> None:
104+
tested_targets = {
105+
*_MIXED_PROFILE_PARTIAL_BINARY_TENSOR_TARGETS,
106+
exir_ops.edge.aten.grid_sampler_2d.default,
107+
}
108+
109+
assert tested_targets == set(
110+
FoldAndAnnotateQParamsPass._mixed_profile_partial_binary_qdq_targets # noqa: SLF001
111+
)
112+
113+
114+
def test_fold_qdq_folds_default_partial_mul_qdq() -> None:
115+
_, mul, _, _ = _partial_binary_qdq_graph(exir_ops.edge.aten.mul.Tensor)
116+
117+
assert not FoldAndAnnotateQParamsPass()._has_partial_binary_tensor_qdq_inputs( # noqa: SLF001
118+
mul, {0: object()} # type: ignore[dict-item]
119+
)
120+
121+
122+
@pytest.mark.parametrize(
123+
"target",
124+
(
125+
exir_ops.edge.aten.index_select.default,
126+
exir_ops.edge.aten.gather.default,
127+
),
128+
)
129+
def test_fold_qdq_does_not_treat_index_as_binary_operand(
130+
target: Callable[..., object],
131+
) -> None:
132+
graph = torch.fx.Graph()
133+
x = graph.placeholder("x")
134+
index = graph.placeholder("index")
135+
node = graph.call_function(target, (x, 0, index))
136+
137+
assert not FoldAndAnnotateQParamsPass(
138+
preserve_partial_binary_tensor_qdq=True
139+
)._has_partial_binary_tensor_qdq_inputs( # noqa: SLF001
140+
node, {0: object()} # type: ignore[dict-item]
141+
)
142+
143+
144+
def _check_fold_qdq_preserves_partial_binary_qdq(
145+
binary_target: Callable[..., object],
146+
preserve_partial_binary_tensor_qdq: bool = False,
147+
extra_args: tuple[int | bool, ...] = (),
148+
) -> None:
149+
graph_module, add, _, y = _partial_binary_qdq_graph(binary_target, extra_args)
150+
x_dq = add.args[0]
151+
add_q = next(iter(add.users))
152+
153+
FoldAndAnnotateQParamsPass(
154+
preserve_partial_binary_tensor_qdq=preserve_partial_binary_tensor_qdq
155+
)(graph_module)
156+
157+
assert set(add.meta["input_qparams"]) == {0}
158+
assert set(add.meta["output_qparams"]) == {0}
159+
assert add.args == (x_dq, y, *extra_args)
160+
assert add_q in add.users
161+
162+
163+
def _partial_binary_qdq_graph(
164+
binary_target: Callable[..., object],
165+
extra_args: tuple[int | bool, ...] = (),
166+
) -> tuple[torch.fx.GraphModule, torch.fx.Node, torch.fx.Node, torch.fx.Node]:
167+
graph = torch.fx.Graph()
168+
x = graph.placeholder("x")
169+
y = graph.placeholder("y")
170+
x_q = graph.call_function(
171+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
172+
(x, 0.5, 0, -128, 127, torch.int8),
173+
)
174+
x_dq = graph.call_function(
175+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
176+
(x_q, 0.5, 0, -128, 127, torch.int8),
177+
)
178+
add = graph.call_function(binary_target, (x_dq, y, *extra_args))
179+
add.meta["custom"] = {
180+
ArmAnnotationInfo.CUSTOM_META_KEY: ArmAnnotationInfo(quantized=True)
181+
}
182+
add_q = graph.call_function(
183+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
184+
(add, 0.5, 0, -128, 127, torch.int8),
185+
)
186+
out = graph.call_function(
187+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
188+
(add_q, 0.5, 0, -128, 127, torch.int8),
189+
)
190+
graph.output(out)
191+
graph_module = torch.fx.GraphModule(torch.nn.Module(), graph)
192+
193+
return graph_module, add, x_q, y

0 commit comments

Comments
 (0)