Skip to content

Commit 10d3009

Browse files
Fix quantized Conv1d same padding with even kernels in XNNPACK (#20734)
Fixes #20558. Related to #20553. ## Summary Quantized `nn.Conv1d(..., padding="same")` with an even kernel exports with asymmetric padding (unequal left/right amounts), which cannot be folded into the convolution's symmetric padding field. The original pad-folding approach in this PR was replaced with the explicit-PAD approach from #20553: - **InsertPadQDQPass** — inserts implicit quantize/dequantize pairs after constant_pad_nd nodes in quantized contexts so they serialize as quantized static pads. Refactored with additional guard checks (pad_value, pad_amounts, negative amounts) and correct idempotency. - **ConvolutionConfig._get_act_deps** — pulls zero-valued constant_pad_nd nodes (and their QDQ chain if InsertPadQDQPass already ran) into the convolution's partition for both 1D and 2D convs. The merged method replaces separate 1D and 2D implementations that existed on this branch. - **Regression test** — quantized Conv1d even-kernel same-padding covering both symmetric and asymmetric pad cases, validated by numerical comparison. - Removed unrelated __init__.py re-ordering and no-op conv1d_unsqueeze_pass changes. With these changes, an even-kernel `padding="same"` conv1d graph: ``` dequant -> constant_pad_nd -> convolution ``` becomes (after XNNPACK preprocessing and partitioning): ``` [dequant -> pad -> q -> dq -> conv] (single XNNPACK delegate) ``` ## Test plan ``` python -m pytest backends/xnnpack/test/ops/test_conv1d.py -q # 7 passed (1 new regression test with 4 subTests) python -m pytest backends/xnnpack/test/ops/test_conv2d.py -q # 32 passed python -m pytest backends/xnnpack/test/passes/test_insert_pad_qdq.py -q # 3 passed python -m pytest backends/xnnpack/test/ops/test_static_constant_pad.py -q # 8 passed lintrunner -a # No lint issues ``` cc @GregoryComer @digantdesai @cbilgin @JakeStevens @freddan80 @per @zingo @oscarandersson8218 @mansnils @Sebastian-Larsson @robell @rascani --------- Co-authored-by: Jacob Stevens <stevens.jacob1492@gmail.com>
1 parent ab4271f commit 10d3009

3 files changed

Lines changed: 141 additions & 80 deletions

File tree

backends/xnnpack/_passes/insert_pad_qdq.py

Lines changed: 60 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -4,81 +4,85 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
from typing import cast, List
8+
79
import torch
810
from executorch.backends.xnnpack._passes.xnnpack_pass import XNNPACKPass
9-
from executorch.backends.xnnpack.utils.quant_utils import is_quant, tag_as_implicit_q_dq
11+
from executorch.backends.xnnpack.utils.quant_utils import (
12+
is_dequant,
13+
is_quant,
14+
tag_as_implicit_q_dq,
15+
)
1016
from executorch.exir.dialects._ops import ops as exir_ops
1117
from executorch.exir.pass_base import PassResult
1218

1319

1420
class InsertPadQDQPass(XNNPACKPass):
1521
"""
16-
Completes the quantization of a constant_pad_nd that sits inside a quantized
17-
region but was left with an fp32 output.
18-
19-
An even-kernel 'same'-padding conv decomposes (after quantization) into
20-
dequant -> constant_pad_nd -> convolution. Because the pad is introduced by
21-
to_edge decomposition -- after the quantizer has run -- it is never annotated,
22-
so no quantize follows it and its output would serialize as fp32. The
23-
downstream conv would then reject its (now fp32) activation.
24-
25-
A zero-valued pad preserves quantization, so we insert an implicit
26-
quantize -> dequantize pair after the pad, reusing the feeding dequant's
27-
params. The pad then delegates as a normal quantized XNNStaticConstantPad and
28-
the conv sees a proper dequantized activation. The pad node itself is left in
29-
place, so all graph shapes stay consistent through later retracing passes.
22+
Inserts implicit quantize/dequantize pairs after constant_pad_nd nodes
23+
that sit in a quantized context (input is a dequantize node), so the pad
24+
can be serialized as a quantized static pad op.
25+
26+
Skips pads whose output is already quantized (idempotent).
27+
28+
Without this pass, a zero-valued constant_pad_nd between a dequantize and
29+
a convolution would serialize as fp32 while the conv expects quantized
30+
activation, causing a mismatch.
3031
"""
3132

32-
def _insert_qdq_after(self, graph, pad, q_params):
33-
with graph.inserting_after(pad):
34-
q = graph.create_node(
35-
"call_function",
36-
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
37-
args=(),
38-
)
39-
q.meta = pad.meta.copy()
40-
tag_as_implicit_q_dq(q)
41-
with graph.inserting_after(q):
42-
dq = graph.create_node(
43-
"call_function",
44-
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
45-
args=(q,) + q_params,
46-
)
47-
dq.meta = q.meta.copy()
48-
tag_as_implicit_q_dq(dq)
49-
pad.replace_all_uses_with(dq)
50-
# Set last so replace_all_uses_with above does not rewrite the quantize's
51-
# own input.
52-
q.args = (pad,) + q_params
53-
54-
def call(self, graph_module: torch.fx.GraphModule):
33+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
5534
graph = graph_module.graph
56-
for pad in list(graph.nodes):
35+
modified = False
36+
for node in list(graph.nodes):
5737
if (
58-
pad.op != "call_function"
59-
or pad.target != exir_ops.edge.aten.constant_pad_nd.default
38+
node.op != "call_function"
39+
or node.target != exir_ops.edge.aten.constant_pad_nd.default
6040
):
6141
continue
6242

63-
# Only per-tensor static activations are handled: _insert_qdq_after
64-
# builds quantize_per_tensor.default, so the feeding dequant must have
65-
# the matching per-tensor signature (a per-channel/per-token/affine
66-
# dequant would supply mismatched args).
67-
dq = pad.args[0]
68-
if (
69-
not isinstance(dq, torch.fx.Node)
70-
or dq.target
71-
!= exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default
43+
pad_input = node.args[0]
44+
if not (
45+
isinstance(pad_input, torch.fx.Node)
46+
and is_dequant(pad_input)
47+
and pad_input.target
48+
== exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default
7249
):
7350
continue
7451

75-
# Skip if the pad's output is already quantized. Requiring *no* user to
76-
# be a quantize (rather than merely "not all") avoids double-quantizing
77-
# pre-existing quant consumers when the pad has mixed users.
78-
if not pad.users or any(is_quant(user) for user in pad.users):
52+
pad_value = cast(float, node.args[2]) if len(node.args) > 2 else 0.0
53+
if pad_value != 0.0:
54+
continue
55+
56+
pad_amounts = cast(List[int], node.args[1])
57+
if any(p < 0 for p in pad_amounts):
7958
continue
8059

81-
self._insert_qdq_after(graph, pad, tuple(dq.args[1:]))
60+
if any(is_quant(user) for user in node.users):
61+
continue
62+
63+
q_params = pad_input.args[1:]
64+
65+
with graph.inserting_after(node):
66+
q = graph.create_node(
67+
"call_function",
68+
exir_ops.edge.quantized_decomposed.quantize_per_tensor.default,
69+
args=(node,) + q_params,
70+
)
71+
q.meta = node.meta.copy()
72+
tag_as_implicit_q_dq(q)
73+
74+
with graph.inserting_after(q):
75+
dq = graph.create_node(
76+
"call_function",
77+
exir_ops.edge.quantized_decomposed.dequantize_per_tensor.default,
78+
args=(q,) + q_params,
79+
)
80+
dq.meta = q.meta.copy()
81+
tag_as_implicit_q_dq(dq)
82+
83+
node.replace_all_uses_with(dq)
84+
q.args = (node,) + q_params
85+
modified = True
8286

8387
graph_module.recompile()
84-
return PassResult(graph_module, True)
88+
return PassResult(graph_module, modified)

backends/xnnpack/partition/config/gemm_configs.py

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -397,44 +397,48 @@ def check_constraints(self, node: torch.fx.Node, ep: ExportedProgram) -> bool:
397397
return False
398398
return True
399399

400-
def supported_precision_types(self):
401-
return [
402-
ConfigPrecisionType.FP32,
403-
ConfigPrecisionType.STATIC_QUANT,
404-
ConfigPrecisionType.DYNAMIC_QUANT,
405-
]
406-
407400
def _get_act_deps(
408401
self, node: torch.fx.Node, ep: ExportedProgram, precision: ConfigPrecisionType
409402
) -> Tuple[bool, List[torch.fx.Node]]:
410-
# An even-kernel 'same'-padding conv decomposes into
411-
# dequant -> constant_pad_nd -> convolution. Pull the zero-valued spatial
412-
# pad into this conv's partition so it delegates as a quantized
413-
# XNNStaticConstantPad alongside the conv (InsertPadQDQPass completes the
414-
# pad's quantization); otherwise the pad is orphaned and the conv is left
415-
# with an unquantized activation.
416-
# Only supporting 2D, non-transposed convs
417-
is_transpose = node.args[6]
418-
is_2d_conv = len(cast(List[int], node.args[4])) == 2
419403
act_input = get_input_node(node, self.act_idx)
404+
is_transpose = node.args[6]
420405
if (
421406
precision != ConfigPrecisionType.FP32
422407
and not is_transpose
423-
and is_2d_conv
408+
and is_node(act_input)
424409
and act_input.target == exir_ops.edge.aten.constant_pad_nd.default
425410
and len(act_input.users) == 1
426-
and is_dequant(get_input_node(act_input, 0))
427411
):
428-
pad_value = act_input.args[2] if len(act_input.args) > 2 else 0
412+
conv_padding = cast(List[int], node.args[4])
413+
is_1d = len(conv_padding) == 1
414+
is_2d = len(conv_padding) == 2
415+
416+
pad_value = (
417+
cast(float, act_input.args[2]) if len(act_input.args) > 2 else 0.0
418+
)
429419
pad_amounts = cast(List[int], act_input.args[1])
430-
spatial_only = len(pad_amounts) <= 4 or all(a == 0 for a in pad_amounts[4:])
431-
if pad_value == 0 and spatial_only:
420+
spatial_only = (
421+
is_1d
422+
and (len(pad_amounts) <= 2 or all(a == 0 for a in pad_amounts[2:]))
423+
) or (
424+
is_2d
425+
and (len(pad_amounts) <= 4 or all(a == 0 for a in pad_amounts[4:]))
426+
)
427+
428+
if pad_value == 0.0 and all(a >= 0 for a in pad_amounts) and spatial_only:
432429
valid, deps = super()._get_act_deps(act_input, ep, precision)
433430
if valid:
434431
return (True, [act_input, *deps])
435432

436433
return super()._get_act_deps(node, ep, precision)
437434

435+
def supported_precision_types(self):
436+
return [
437+
ConfigPrecisionType.FP32,
438+
ConfigPrecisionType.STATIC_QUANT,
439+
ConfigPrecisionType.DYNAMIC_QUANT,
440+
]
441+
438442

439443
class AddmmConfig(GEMMConfig):
440444
"""

backends/xnnpack/test/ops/test_conv1d.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,24 @@ def forward(self, x):
9090
z = torch.add(y, z)
9191
return z
9292

93+
class Conv1dSamePadding(torch.nn.Module):
94+
def __init__(self, kernel_size: int, dilation: int = 1):
95+
super().__init__()
96+
self.conv1d = torch.nn.Conv1d(
97+
in_channels=2,
98+
out_channels=4,
99+
kernel_size=kernel_size,
100+
dilation=dilation,
101+
padding="same",
102+
bias=True,
103+
)
104+
105+
def forward(self, x):
106+
return self.conv1d(x)
107+
108+
def _get_calibration_samples(self, inputs):
109+
return [tuple(torch.randn_like(inputs[i]) for i in range(len(inputs)))]
110+
93111
def _test_conv1d(
94112
self,
95113
module,
@@ -102,9 +120,7 @@ def _test_conv1d(
102120
skip_to_executorch=False,
103121
):
104122
calibration_samples = (
105-
[tuple(torch.randn_like(inputs[i]) for i in range(len(inputs)))]
106-
if quantized
107-
else None
123+
self._get_calibration_samples(inputs) if quantized else None
108124
)
109125

110126
tester = (
@@ -160,6 +176,43 @@ def test_qs8_conv1d(self):
160176
self.Conv1d(), inputs, 1, quantized=True, dynamic_shape=dynamic_shapes
161177
)
162178

179+
def test_qs8_conv1d_even_kernel_same_padding(self):
180+
inputs = (torch.randn(1, 2, 16),)
181+
configs = [
182+
(2, 1),
183+
(3, 1),
184+
(4, 1),
185+
(4, 2),
186+
]
187+
for kernel_size, dilation in configs:
188+
with self.subTest(kernel_size=kernel_size, dilation=dilation):
189+
(
190+
Tester(
191+
self.Conv1dSamePadding(
192+
kernel_size=kernel_size, dilation=dilation
193+
),
194+
inputs,
195+
)
196+
.quantize(
197+
Quantize(
198+
calibration_samples=self._get_calibration_samples(inputs)
199+
)
200+
)
201+
.export()
202+
.check_count({"torch.ops.aten.conv1d.padding": 1})
203+
.to_edge_transform_and_lower()
204+
.check_not(
205+
[
206+
"executorch_exir_dialects_edge__ops_aten_convolution_default",
207+
"executorch_exir_dialects_edge__ops_aten_constant_pad_nd_default",
208+
]
209+
)
210+
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
211+
.to_executorch()
212+
.serialize()
213+
.run_method_and_compare_outputs(num_runs=10, atol=0.04, rtol=0.02)
214+
)
215+
163216
def test_qs8_conv1d_batchnorm_seq(self):
164217
inputs = (torch.randn(2, 2, 4),)
165218
dynamic_shapes = ({0: torch.export.Dim("batch", min=2, max=10)},)

0 commit comments

Comments
 (0)