Skip to content

Commit 420a8ab

Browse files
authored
Delegate even-kernel 'same'-padding convs via a quantized static pad (#20553)
Differential Revision: D109871964 Pull Request resolved: #20553
1 parent 9c7fba0 commit 420a8ab

5 files changed

Lines changed: 311 additions & 0 deletions

File tree

backends/xnnpack/_passes/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from executorch.backends.xnnpack._passes.decompose_cat import DecomposeConcatenate
2828
from executorch.backends.xnnpack._passes.fuse_activation_pass import FuseActivationPass
2929
from executorch.backends.xnnpack._passes.fuse_batch_norm import FuseBatchNormPass
30+
from executorch.backends.xnnpack._passes.insert_pad_qdq import InsertPadQDQPass
3031
from executorch.backends.xnnpack._passes.prelu_reshape_pass import PReLUReshapePass
3132
from executorch.backends.xnnpack._passes.propagate_custom_meta_pass import (
3233
PropagateCustomMetaPass,
@@ -81,6 +82,7 @@ def __init__(
8182
FuseActivationPass,
8283
DecomposeConcatenate,
8384
RemoveGetItemPass,
85+
InsertPadQDQPass,
8486
Conv1dUnsqueezePass,
8587
PReLUReshapePass,
8688
ChannelsLastTaggedReshapePass,
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 torch
8+
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
10+
from executorch.exir.dialects._ops import ops as exir_ops
11+
from executorch.exir.pass_base import PassResult
12+
13+
14+
class InsertPadQDQPass(XNNPACKPass):
15+
"""
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.
30+
"""
31+
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):
55+
graph = graph_module.graph
56+
for pad in list(graph.nodes):
57+
if (
58+
pad.op != "call_function"
59+
or pad.target != exir_ops.edge.aten.constant_pad_nd.default
60+
):
61+
continue
62+
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
72+
):
73+
continue
74+
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):
79+
continue
80+
81+
self._insert_qdq_after(graph, pad, tuple(dq.args[1:]))
82+
83+
graph_module.recompile()
84+
return PassResult(graph_module, True)

backends/xnnpack/partition/config/gemm_configs.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
format_target_name,
3939
)
4040
from executorch.exir.backend.utils import WhyNoPartition
41+
from executorch.exir.dialects._ops import ops as exir_ops
4142
from torch.export import ExportedProgram
4243
from torch.fx.passes.utils.source_matcher_utils import (
4344
get_source_partitions,
@@ -403,6 +404,37 @@ def supported_precision_types(self):
403404
ConfigPrecisionType.DYNAMIC_QUANT,
404405
]
405406

407+
def _get_act_deps(
408+
self, node: torch.fx.Node, ep: ExportedProgram, precision: ConfigPrecisionType
409+
) -> 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
419+
act_input = get_input_node(node, self.act_idx)
420+
if (
421+
precision != ConfigPrecisionType.FP32
422+
and not is_transpose
423+
and is_2d_conv
424+
and act_input.target == exir_ops.edge.aten.constant_pad_nd.default
425+
and len(act_input.users) == 1
426+
and is_dequant(get_input_node(act_input, 0))
427+
):
428+
pad_value = act_input.args[2] if len(act_input.args) > 2 else 0
429+
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:
432+
valid, deps = super()._get_act_deps(act_input, ep, precision)
433+
if valid:
434+
return (True, [act_input, *deps])
435+
436+
return super()._get_act_deps(node, ep, precision)
437+
406438

407439
class AddmmConfig(GEMMConfig):
408440
"""

backends/xnnpack/test/ops/test_conv2d.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,126 @@ def test_qs8_conv2d_per_channel(self) -> None:
310310
quant_config=get_symmetric_quantization_config(is_per_channel=True),
311311
)
312312

313+
def test_qs8_conv2d_even_kernel_same_padding(self) -> None:
314+
# An even-kernel 'same'-padding conv decomposes into
315+
# dequant -> constant_pad_nd -> convolution. The pad and conv are pulled
316+
# into one partition and both delegate (the pad as a quantized
317+
# XNNStaticConstantPad), so neither survives in the top-level graph.
318+
for has_bias in (True, False):
319+
m = Conv2d(
320+
in_channels=2,
321+
out_channels=4,
322+
kernel_size=(4, 4),
323+
stride=(1, 1),
324+
padding="same",
325+
bias=has_bias,
326+
)
327+
tester = Tester(m.eval(), m.get_inputs())
328+
tester.quantize(
329+
Quantize(quantization_config=get_symmetric_quantization_config())
330+
)
331+
(
332+
tester.export()
333+
.check_count({"torch.ops.aten.conv2d": 1})
334+
.to_edge_transform_and_lower()
335+
.check_not(
336+
[
337+
"executorch_exir_dialects_edge__ops_aten_convolution_default",
338+
"executorch_exir_dialects_edge__ops_aten_constant_pad_nd_default",
339+
]
340+
)
341+
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
342+
.to_executorch()
343+
.serialize()
344+
.run_method_and_compare_outputs(qtol=2)
345+
)
346+
347+
def test_qs8_conv2d_depthwise_even_kernel_same_padding(self) -> None:
348+
# Depthwise is a standard (non-transposed) 2D conv, so its even-'same' pad
349+
# is delegated alongside the conv too. Assert it still fully delegates and is
350+
# numerically correct.
351+
m = Conv2d(
352+
in_channels=4,
353+
out_channels=4,
354+
groups=4,
355+
kernel_size=(4, 4),
356+
stride=(1, 1),
357+
padding="same",
358+
)
359+
tester = Tester(m.eval(), m.get_inputs())
360+
tester.quantize(
361+
Quantize(quantization_config=get_symmetric_quantization_config())
362+
)
363+
(
364+
tester.export()
365+
.check_count({"torch.ops.aten.conv2d": 1})
366+
.to_edge_transform_and_lower()
367+
.check_not(
368+
[
369+
"executorch_exir_dialects_edge__ops_aten_convolution_default",
370+
"executorch_exir_dialects_edge__ops_aten_constant_pad_nd_default",
371+
]
372+
)
373+
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
374+
.to_executorch()
375+
.serialize()
376+
.run_method_and_compare_outputs(qtol=2)
377+
)
378+
379+
class EvenSamePadFlatten(torch.nn.Module):
380+
# Reproduces the failure where an even-kernel 'same' conv is followed by a
381+
# shape-dependent op. The flatten bakes a fixed view over the conv's padded
382+
# output extent; if the pad's spatial contribution is dropped from the graph
383+
# the conv output shrinks and the view becomes invalid.
384+
def __init__(self):
385+
super().__init__()
386+
self.conv = torch.nn.Conv2d(1, 16, (2, 1), padding="same")
387+
388+
def forward(self, x):
389+
out = torch.reshape(x, (1, 1, 256, 1))
390+
out = self.conv(out)
391+
return torch.flatten(out, 1)
392+
393+
def get_inputs(self):
394+
return (torch.randn(1, 1, 256, 1),)
395+
396+
def test_fp32_even_kernel_same_padding_flatten(self) -> None:
397+
m = self.EvenSamePadFlatten().eval()
398+
(
399+
Tester(m, m.get_inputs())
400+
.export()
401+
.to_edge_transform_and_lower()
402+
.check_not(
403+
[
404+
"executorch_exir_dialects_edge__ops_aten_convolution_default",
405+
"executorch_exir_dialects_edge__ops_aten_constant_pad_nd_default",
406+
]
407+
)
408+
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
409+
.to_executorch()
410+
.serialize()
411+
.run_method_and_compare_outputs()
412+
)
413+
414+
def test_qs8_even_kernel_same_padding_flatten(self) -> None:
415+
m = self.EvenSamePadFlatten().eval()
416+
(
417+
Tester(m, m.get_inputs())
418+
.quantize(Quantize(quantization_config=get_symmetric_quantization_config()))
419+
.export()
420+
.to_edge_transform_and_lower()
421+
.check_not(
422+
[
423+
"executorch_exir_dialects_edge__ops_aten_convolution_default",
424+
"executorch_exir_dialects_edge__ops_aten_constant_pad_nd_default",
425+
]
426+
)
427+
.check_count({"torch.ops.higher_order.executorch_call_delegate": 1})
428+
.to_executorch()
429+
.serialize()
430+
.run_method_and_compare_outputs(qtol=2)
431+
)
432+
313433
def test_fp32_conv2d_seq(self) -> None:
314434
for transpose in (True, False):
315435
self._test(Conv2dSeq(transpose=transpose), conv_count=2)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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+
from executorch.backends.test.harness.stages import StageType
11+
from executorch.backends.xnnpack._passes.insert_pad_qdq import InsertPadQDQPass
12+
from executorch.backends.xnnpack.quantizer.xnnpack_quantizer import (
13+
get_symmetric_quantization_config,
14+
)
15+
from executorch.backends.xnnpack.test.tester import Quantize, RunPasses, Tester
16+
from executorch.exir.dialects._ops import ops as exir_ops
17+
18+
19+
class TestInsertPadQDQ(unittest.TestCase):
20+
Q = exir_ops.edge.quantized_decomposed.quantize_per_tensor.default
21+
PAD = exir_ops.edge.aten.constant_pad_nd.default
22+
23+
class PadConv(torch.nn.Module):
24+
# Even kernel + 'same' padding decomposes into constant_pad_nd -> conv.
25+
def __init__(self):
26+
super().__init__()
27+
self.conv = torch.nn.Conv2d(2, 2, (2, 2), padding="same")
28+
29+
def forward(self, x):
30+
return self.conv(x)
31+
32+
def _run(self, passes, quantize):
33+
tester = Tester(self.PadConv().eval(), (torch.randn(1, 2, 8, 8),))
34+
if quantize:
35+
tester.quantize(
36+
Quantize(quantization_config=get_symmetric_quantization_config())
37+
)
38+
artifact = (
39+
tester.export()
40+
.to_edge()
41+
.run_passes(RunPasses(passes))
42+
.get_artifact(StageType.RUN_PASSES)
43+
)
44+
return artifact.exported_program().graph_module.graph
45+
46+
def _pad(self, graph):
47+
return next(n for n in graph.nodes if n.target == self.PAD)
48+
49+
def _num_quant(self, graph):
50+
return sum(1 for n in graph.nodes if n.target == self.Q)
51+
52+
def test_inserts_qdq_after_quantized_pad(self):
53+
# dequant -> pad -> conv: the pass quantizes the pad's output, so the pad
54+
# is now consumed by an inserted quantize.
55+
graph = self._run([InsertPadQDQPass], quantize=True)
56+
pad = self._pad(graph)
57+
self.assertTrue(all(user.target == self.Q for user in pad.users))
58+
59+
def test_idempotent_skips_already_quantized_pad(self):
60+
# A second run must see the pad's user is already a quantize and skip it,
61+
# so no extra quantize is inserted.
62+
once = self._num_quant(self._run([InsertPadQDQPass], quantize=True))
63+
twice = self._num_quant(
64+
self._run([InsertPadQDQPass, InsertPadQDQPass], quantize=True)
65+
)
66+
self.assertEqual(once, twice)
67+
68+
def test_noop_for_fp32_pad(self):
69+
# The pad is not fed by a dequant, so the pass leaves the graph untouched.
70+
graph = self._run([InsertPadQDQPass], quantize=False)
71+
self.assertEqual(self._num_quant(graph), 0)
72+
pad = self._pad(graph)
73+
self.assertFalse(any(user.target == self.Q for user in pad.users))

0 commit comments

Comments
 (0)