Skip to content

Commit 0e0db95

Browse files
authored
Arm backend: Reject unsupported transpose convolutions (#20763)
Reject unsupported transpose convolution cases during TOSA partitioning instead of letting them reach backend lowering. Grouped transpose convolutions with per-channel weight quantization can appear before qparam metadata is folded, with the per-channel weight visible only through a dequantize_per_channel weight input. Detect and reject that form as well. Also reject dilated transpose convolutions for both edge convolution and aten conv_transpose2d forms. This avoids _ExportedProgramGraphPassAdapter failures in the backend test suite for unsupported Arm lowering cases. Signed-off-by: Zingo Andersen <Zingo.Andersen@arm.com>
1 parent 6018283 commit 0e0db95

2 files changed

Lines changed: 259 additions & 11 deletions

File tree

backends/arm/operator_support/convolution_support.py

Lines changed: 41 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,10 @@
3131
class ConvolutionSupported(SupportedTOSAOperatorCheck):
3232
"""Provide TOSA support check for convolutions."""
3333

34-
targets = [exir_ops.edge.aten.convolution.default]
34+
targets = [
35+
exir_ops.edge.aten.convolution.default,
36+
torch.ops.aten.conv_transpose2d.input,
37+
]
3538

3639
def is_node_tosa_supported(
3740
self, node: fx.Node, tosa_spec: TosaSpecification
@@ -42,9 +45,7 @@ def is_node_tosa_supported(
4245
padding. Apply additional hardware-specific constraints for U55.
4346
4447
"""
45-
transposed = cast(bool, node.args[6])
46-
output_padding = cast(list[int], node.args[7])
47-
groups = cast(int, node.args[8])
48+
transposed, output_padding, groups = self._get_conv_params(node)
4849

4950
if transposed:
5051
if not self._check_transposed_support(node, groups, output_padding):
@@ -67,6 +68,13 @@ def _get_weight_qargs(self, node: fx.Node) -> QuantArgs | None:
6768
return input_qparams[1]
6869
return None
6970

71+
def _has_per_channel_weight(self, node: fx.Node) -> bool:
72+
weight_node = cast(fx.Node, node.args[1])
73+
return weight_node.target in (
74+
torch.ops.quantized_decomposed.dequantize_per_channel.default,
75+
exir_ops.edge.quantized_decomposed.dequantize_per_channel.default,
76+
)
77+
7078
def _check_output_padding(self, output_padding: list[int], node: fx.Node) -> bool:
7179
for output_pad in output_padding:
7280
if output_pad != 0:
@@ -77,24 +85,46 @@ def _check_output_padding(self, output_padding: list[int], node: fx.Node) -> boo
7785
return False
7886
return True
7987

88+
def _get_conv_params(self, node: fx.Node) -> tuple[bool, list[int], int]:
89+
"""Return transposed, output_padding, and groups for supported conv
90+
targets.
91+
"""
92+
93+
if node.target == torch.ops.aten.conv_transpose2d.input:
94+
return True, cast(list[int], node.args[5]), cast(int, node.args[6])
95+
return (
96+
cast(bool, node.args[6]),
97+
cast(list[int], node.args[7]),
98+
cast(int, node.args[8]),
99+
)
100+
101+
def _get_transposed_conv_dilation(self, node: fx.Node) -> list[int]:
102+
if node.target == torch.ops.aten.conv_transpose2d.input:
103+
return cast(list[int], node.args[7])
104+
return cast(list[int], node.args[5])
105+
80106
def _check_transposed_support(
81107
self, node: fx.Node, groups: int, output_padding: list[int]
82108
) -> bool:
109+
dilation = expand_around_channel(self._get_transposed_conv_dilation(node), 2)
110+
if any(d != 1 for d in dilation):
111+
self.reporter.report_reject(
112+
node, "Transpose convolutions with dilation are not supported."
113+
)
114+
return False
115+
83116
if groups != 1:
84117
weight_qargs = self._get_weight_qargs(node)
85-
if isinstance(weight_qargs, QuantArgs) and weight_qargs.per_channel:
118+
has_per_channel_qargs = (
119+
isinstance(weight_qargs, QuantArgs) and weight_qargs.per_channel
120+
)
121+
if has_per_channel_qargs or self._has_per_channel_weight(node):
86122
self.reporter.report_reject(
87123
node,
88124
"Grouped transpose convolutions with per-channel weight "
89125
"quantization are not supported.",
90126
)
91127
return False
92-
dilation = expand_around_channel(cast(list[int], node.args[5]), 2)
93-
if any(d != 1 for d in dilation):
94-
self.reporter.report_reject(
95-
node, "Transpose convolutions with dilation are not supported."
96-
)
97-
return False
98128

99129
pad = expand_around_channel(cast(list[int], node.args[4]), 2)
100130
out_pad = expand_around_channel(output_padding, 2)
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
import torch
7+
from executorch.backends.arm._passes.quant_args import QuantArgs
8+
from executorch.backends.arm.operator_support.convolution_support import (
9+
ConvolutionSupported,
10+
)
11+
from executorch.backends.arm.tosa import TosaSpecification
12+
from executorch.exir.backend.utils import WhyNoPartitionReporter
13+
from executorch.exir.dialects._ops import ops as exir_ops
14+
from torch._subclasses.fake_tensor import FakeTensorMode
15+
16+
# These tests exercise the operator-support predicate directly. Building small
17+
# FX nodes keeps unsupported cases from reaching the lowering pipeline.
18+
#
19+
# input ─┐
20+
# ├─ conv_transpose2d / transposed convolution ──► reject early
21+
# weight ┘ │
22+
# ├─ dilation != 1
23+
# └─ grouped + per-channel weight qparams
24+
#
25+
# If these cases are incorrectly marked supported, later rewrite/decomposition
26+
# passes fail with opaque _ExportedProgramGraphPassAdapter errors instead of a
27+
# clear partition rejection.
28+
29+
30+
def _fake_tensor(shape: tuple[int, ...]) -> torch.Tensor:
31+
with FakeTensorMode() as mode:
32+
return mode.from_tensor(torch.empty(shape))
33+
34+
35+
def _make_conv_node(
36+
target,
37+
args,
38+
input_shape=(1, 3, 8, 8),
39+
weight_shape=(3, 3, 3, 3),
40+
per_channel_weight=False,
41+
):
42+
graph = torch.fx.Graph()
43+
input_node = graph.placeholder("input")
44+
input_node.meta["val"] = _fake_tensor(input_shape)
45+
weight_node = graph.placeholder("weight")
46+
weight_node.meta["val"] = _fake_tensor(weight_shape)
47+
if per_channel_weight:
48+
scale_node = graph.placeholder("scale")
49+
scale_node.meta["val"] = _fake_tensor((weight_shape[0],))
50+
zero_point_node = graph.placeholder("zero_point")
51+
zero_point_node.meta["val"] = _fake_tensor((weight_shape[0],))
52+
weight_node = graph.call_function(
53+
torch.ops.quantized_decomposed.dequantize_per_channel.default,
54+
(weight_node, scale_node, zero_point_node, 0, -127, 127, torch.int8),
55+
)
56+
weight_node.meta["val"] = _fake_tensor(weight_shape)
57+
bias_node = graph.placeholder("bias")
58+
bias_node.meta["val"] = _fake_tensor((weight_shape[1],))
59+
node_args = {
60+
"input": input_node,
61+
"weight": weight_node,
62+
"bias": bias_node,
63+
}
64+
resolved_args = tuple(
65+
node_args[arg] if isinstance(arg, str) else arg for arg in args
66+
)
67+
node = graph.call_function(target, resolved_args)
68+
node.meta["val"] = _fake_tensor((1, weight_shape[1], 8, 8))
69+
return node
70+
71+
72+
def _checker() -> ConvolutionSupported:
73+
return ConvolutionSupported(
74+
TosaSpecification.create_from_string("TOSA-1.0+FP"),
75+
WhyNoPartitionReporter(),
76+
)
77+
78+
79+
def test_rejects_edge_transpose_convolution_with_dilation() -> None:
80+
# edge.aten.convolution.default represents transposed convolution via the
81+
# boolean transposed argument. Dilation is rejected here because
82+
# RewriteConvPass cannot lower dilated TRANSPOSE_CONV2D.
83+
node = _make_conv_node(
84+
exir_ops.edge.aten.convolution.default,
85+
(
86+
"input",
87+
"weight",
88+
"bias",
89+
[1, 1],
90+
[0, 0],
91+
[2, 1],
92+
True,
93+
[0, 0],
94+
1,
95+
),
96+
)
97+
98+
assert not _checker().is_node_supported({}, node)
99+
100+
101+
def test_rejects_aten_conv_transpose2d_with_dilation() -> None:
102+
# torch.export may also preserve the explicit aten.conv_transpose2d.input
103+
# overload. It uses a different argument layout from edge.aten.convolution,
104+
# so cover it separately to keep the support-check indexing correct.
105+
node = _make_conv_node(
106+
torch.ops.aten.conv_transpose2d.input,
107+
(
108+
"input",
109+
"weight",
110+
"bias",
111+
[1, 1],
112+
[0, 0],
113+
[0, 0],
114+
1,
115+
[2, 1],
116+
),
117+
)
118+
119+
assert not _checker().is_node_supported({}, node)
120+
121+
122+
def test_rejects_grouped_aten_conv_transpose2d_with_per_channel_weights() -> None:
123+
# Grouped transpose convolution is decomposed into per-group convolutions.
124+
# Per-channel weight qparams on aten.conv_transpose2d.input do not align with
125+
# that decomposition, so the node must be rejected before decomposition.
126+
node = _make_conv_node(
127+
torch.ops.aten.conv_transpose2d.input,
128+
(
129+
"input",
130+
"weight",
131+
"bias",
132+
[1, 1],
133+
[0, 0],
134+
[0, 0],
135+
3,
136+
[1, 1],
137+
),
138+
)
139+
node.meta["input_qparams"] = {
140+
1: QuantArgs(
141+
[1.0, 1.0, 1.0], [0, 0, 0], -127, 127, torch.int8, per_channel=True
142+
)
143+
}
144+
145+
assert not _checker().is_node_supported({}, node)
146+
147+
148+
def test_rejects_grouped_edge_transpose_convolution_with_per_channel_dq_weight() -> (
149+
None
150+
):
151+
# The flow-suite partitioner sees converted quantized models before
152+
# FoldAndAnnotateQParamsPass adds input_qparams. At that point per-channel
153+
# weight quantization is visible through the dequantize_per_channel weight
154+
# input, so reject from that graph shape too.
155+
node = _make_conv_node(
156+
exir_ops.edge.aten.convolution.default,
157+
(
158+
"input",
159+
"weight",
160+
"bias",
161+
[1, 1],
162+
[0, 0],
163+
[1, 1],
164+
True,
165+
[0, 0],
166+
3,
167+
),
168+
per_channel_weight=True,
169+
)
170+
171+
assert not _checker().is_node_supported({}, node)
172+
173+
174+
def test_rejects_grouped_edge_transpose_convolution_with_per_channel_weights() -> None:
175+
# The same grouped/per-channel restriction applies to the edge dialect form
176+
# of transposed convolution. This covers the path where export/decomposition
177+
# has normalized conv_transpose2d into edge.aten.convolution.default.
178+
node = _make_conv_node(
179+
exir_ops.edge.aten.convolution.default,
180+
(
181+
"input",
182+
"weight",
183+
"bias",
184+
[1, 1],
185+
[0, 0],
186+
[1, 1],
187+
True,
188+
[0, 0],
189+
3,
190+
),
191+
)
192+
node.meta["input_qparams"] = {
193+
1: QuantArgs(
194+
[1.0, 1.0, 1.0], [0, 0, 0], -127, 127, torch.int8, per_channel=True
195+
)
196+
}
197+
198+
assert not _checker().is_node_supported({}, node)
199+
200+
201+
def test_accepts_aten_conv_transpose2d_without_unsupported_options() -> None:
202+
# A plain 2D transpose convolution with groups=1 and dilation=1 is still
203+
# supported. This guards against the rejection checks becoming too broad.
204+
node = _make_conv_node(
205+
torch.ops.aten.conv_transpose2d.input,
206+
(
207+
"input",
208+
"weight",
209+
"bias",
210+
[1, 1],
211+
[0, 0],
212+
[0, 0],
213+
1,
214+
[1, 1],
215+
),
216+
)
217+
218+
assert _checker().is_node_supported({}, node)

0 commit comments

Comments
 (0)