From 5905ee9847110ce35f5c08a85a91977e1ae59dd9 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Fri, 30 Oct 2026 07:46:49 -0700 Subject: [PATCH 01/23] Keep ops with more than 6 dimensions out of the XNNPACK delegate XNNPACK supports tensors with at most 6 dimensions. The XNNPACK partitioner did not check this, so it could delegate an op whose input or output has more dimensions. Lowering then succeeds, but loading the program fails with: Failed to define tensor 0 with code: xnn_status_unsupported_parameter Some vision models hit this in a "patchify" step, which reshapes an image into 8 dimensions, permutes it, and reshapes it into a sequence of patches: y = x.reshape(2, 6, 8, 2, 16, 8, 2, 16).permute(0, 2, 5, 3, 6, 1, 4, 7) y = y.reshape(2, 64, -1) * 2 This change checks the number of dimensions in the XNNPACK partitioner: - The common constraint check that op configs run. An op with an input or output above XNN_MAX_TENSOR_DIMS is not delegated, and an op such as linear is not kept whole for XNNPACK, so it gets decomposed. - The source partition check for a decomposed linear. Its matrix multiply pulls the reshapes around it into its partition, even when their own constraint check rejects them. If any of them touches a tensor above the limit, the matrix multiply is not delegated either. The check runs before the linear's quantize and dequantize nodes are marked to be folded into the matrix multiply, so a rejected linear leaves them as separate ops. In the example above, the reshapes and the permute then run outside XNNPACK, and the multiply is still delegated. Still not covered: a linear on such an input, quantized with torchao's affine quantize and dequantize ops. Those ops are kept whole for XNNPACK and have no portable kernels, so such a model now fails when it is converted to an ExecuTorch program, instead of when it is loaded. The quantized test needs the quantized kernels, so the test target now preloads them. Tested with new permute tests that lower a linear on an 8-D input followed by an 8-D permute, in float and with static quantization. They check that no delegate holds a tensor with more than 6 dims, and compare the program output with the reference. The tests fail without the change, and they also fail when the common check or the matrix multiply check is removed. --- .../xnnpack/partition/config/gemm_configs.py | 2 + .../partition/config/xnnpack_config.py | 15 +++++++ backends/xnnpack/test/BUCK | 1 + backends/xnnpack/test/ops/test_permute.py | 43 +++++++++++++++++++ 4 files changed, 61 insertions(+) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 239a424965b..c840332bbc1 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -562,6 +562,8 @@ def find_partition_args(input_node): not in (src_partition.nodes + src_partition.input_nodes) # there can only be a single output node in partition or len(src_partition.output_nodes) != 1 + # this partition takes these nodes even if their configs reject them + or not all(self._check_tensor_ranks(n) for n in src_partition.nodes) ): why(node, "invalid source partition") return (False, []) diff --git a/backends/xnnpack/partition/config/xnnpack_config.py b/backends/xnnpack/partition/config/xnnpack_config.py index e0a575e000a..0285fc876b6 100644 --- a/backends/xnnpack/partition/config/xnnpack_config.py +++ b/backends/xnnpack/partition/config/xnnpack_config.py @@ -15,6 +15,7 @@ is_qparam, is_quant, ) +from executorch.backends.xnnpack.utils.xnnpack_constants import XNN_MAX_TENSOR_DIMS from executorch.exir.backend.canonical_partitioners.config_partitioner import ( format_target_name, PartitionerConfig, @@ -152,6 +153,20 @@ def check_common_constraints( why(node, reason="invalid dtype") return False + return self._check_tensor_ranks(node) + + def _check_tensor_ranks(self, node: torch.fx.Node) -> bool: + vals = [arg.meta.get("val") for arg in node.all_input_nodes] + node_val = node.meta.get("val") + vals.extend(node_val if isinstance(node_val, (list, tuple)) else [node_val]) + for val in vals: + if isinstance(val, torch.Tensor) and val.dim() > XNN_MAX_TENSOR_DIMS: + why( + node, + reason=f"XNNPACK supports at most {XNN_MAX_TENSOR_DIMS} dims, got {val.dim()}", + ) + return False + return True def _check_inputs_are_valid_dtypes(self, node, valid_dtypes): diff --git a/backends/xnnpack/test/BUCK b/backends/xnnpack/test/BUCK index 0eb2af1bf9b..3e28be745d0 100644 --- a/backends/xnnpack/test/BUCK +++ b/backends/xnnpack/test/BUCK @@ -96,6 +96,7 @@ fbcode_target(_kind = runtime.python_test, "test_xnnpack_utils.py", ], supports_static_listing = False, + preload_deps = ["//executorch/kernels/quantized:aot_lib"], deps = [ "fbsource//third-party/pypi/parameterized:parameterized", "//executorch/backends/test/harness:tester", diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index e0171f2178f..d17618fd992 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -8,6 +8,14 @@ import torch from executorch.backends.xnnpack.test.tester import Tester +from executorch.exir.lowered_backend_module import get_lowered_backend_modules + +# Buck preloads the quantized kernels instead of shipping this module. +try: + import executorch.extension.pybindings.portable_lib # noqa[F401] + import executorch.kernels.quantized # noqa[F401] +except ImportError: + pass class TestPermute(unittest.TestCase): @@ -83,6 +91,41 @@ def test_fp32_permute_copy(self): .run_method_and_compare_outputs() ) + class LinearPermute8d(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(4, 4) + + def forward(self, x): + y = self.linear(x.reshape(2, 2, 2, 2, 2, 2, 2, 4)) + return y.permute(0, 2, 5, 3, 6, 1, 4, 7).reshape(2, 64, -1) * 2 + + def _test_permute_above_max_rank(self, quantize): + # XNNPACK allows at most 6 dims, and a delegate that holds a bigger tensor + # fails to load. The linear also pulls in its 8-D views as dependencies. + tester = Tester(self.LinearPermute8d().eval(), (torch.randn(2, 64, 4),)) + if quantize: + tester.quantize() + tester.export().to_edge_transform_and_lower() + + lowered_modules = get_lowered_backend_modules( + tester.get_artifact().exported_program().graph_module + ) + self.assertGreater(len(lowered_modules), 0) + for lowered_module in lowered_modules: + for node in lowered_module.original_module.graph.nodes: + val = node.meta.get("val") + if isinstance(val, torch.Tensor): + self.assertLessEqual(val.dim(), 6, node.format_node()) + + tester.to_executorch().serialize().run_method_and_compare_outputs(qtol=1) + + def test_fp32_permute_above_max_rank(self): + self._test_permute_above_max_rank(quantize=False) + + def test_qs8_permute_above_max_rank(self): + self._test_permute_above_max_rank(quantize=True) + def test_qs8_permute(self): inputs = (torch.randn(1, 1, 4, 4),) ( From a2c17ef7075ba134cdfdd6dae30e3f8b87f485c6 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:11:25 -0700 Subject: [PATCH 02/23] Fix zeros from quantized convs and linears that XNNPACK leaves out The XNNPACK partitioner marks the quantize and dequantize nodes around a quantized conv or linear as implicit, which means folded into that op. The delegate then does not emit them as separate ops. The marks were set while the partitioner was still checking the op, so they stayed even when it then rejected the op. The quantize and dequantize pairs around a rejected op then went into delegates of their own, and those delegates skipped the marked nodes. One delegate had no output, and the next one read a value that nothing wrote. The program returned zeros instead of failing. For example: - A statically quantized Conv3d or ConvTranspose3d. XNNPACK has no 3-D convolution, so the partitioner rejects it, and the output was all zeros. - A quantized Conv2d or Linear whose output is not quantized, for example with a quantization config without an output activation. Now GEMMConfig marks the nodes in get_partition, once a partition is taken. A rejected op leaves them as separate ops, and their delegates convert the values as they should. Tested with a new conv test: a Conv2d with a quantized input and weight and a float output. Without this change its output does not match the reference. --- .../xnnpack/partition/config/gemm_configs.py | 17 ++++++++++----- backends/xnnpack/test/ops/test_conv2d.py | 21 +++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index c840332bbc1..95fccc131c9 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -83,6 +83,18 @@ def get_node_and_deps( return partition + def get_partition( + self, node: torch.fx.Node, ep: ExportedProgram + ) -> List[torch.fx.Node]: + partition = super().get_partition(node, ep) + # Tag q/dq nodes as implicit only once the partition is taken. A rejected + # node keeps them as separate ops. + for n in partition: + if is_dequant(n) or is_quant(n): + tag_as_implicit_q_dq(n) + + return partition + def get_original_aten(self) -> Optional[torch._ops.OpOverload]: return None @@ -139,11 +151,6 @@ def get_deps( valid_deps = valid_bias and valid_weight and valid_act and valid_output deps = list(chain(bias_deps, weight_deps, act_deps, output_deps)) - # Tag q/dq nodes as implicit q/dq nodes - for dep in deps: - if is_dequant(dep) or is_quant(dep): - tag_as_implicit_q_dq(dep) - return valid_deps, deps def _get_weight_deps( diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 0db84852422..7164547c379 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -6,6 +6,7 @@ import itertools import unittest +from dataclasses import replace from typing import Optional import torch @@ -375,6 +376,26 @@ def test_qs8_conv2d_per_channel(self) -> None: quant_config=get_symmetric_quantization_config(is_per_channel=True), ) + def test_qs8_conv2d_float_output(self) -> None: + # XNNPACK leaves out a quantized conv whose output is not quantized. The + # quantize and dequantize of its input then run in a delegate of their own. + quant_config = replace( + get_symmetric_quantization_config(is_per_channel=True), + output_activation=None, + ) + m = Conv2d() + ( + Tester(m.eval(), m.get_inputs()) + .quantize(Quantize(quantization_config=quant_config)) + .export() + .to_edge_transform_and_lower() + .check(["executorch_exir_dialects_edge__ops_aten_convolution_default"]) + .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + def test_qs8_conv2d_even_kernel_same_padding(self) -> None: # An even-kernel 'same'-padding conv decomposes into # dequant -> constant_pad_nd -> convolution. The pad and conv are pulled From f6c588f784d23e948cd0731cb0249c193662222d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:16:38 -0700 Subject: [PATCH 03/23] Check every node an XNNPACK partition takes against the 6 dim limit The dims check for a decomposed linear looked at every node of its source partition. The nodes the partition takes can differ from that. With force_non_static_weights_for_f32_linear the partition keeps only the deps inside the source partition, so a 2-D matrix multiply was rejected because of 8-D views it would never hold. Now XNNPartitionerConfig.get_partition checks every node it returns, and returns an empty partition if any of them has more than 6 dims. This covers every config, not only linear. The check of the op itself stays in check_common_constraints, because the partitioner uses it to decide which aten ops to keep from being decomposed. In its default mode, a decomposed float linear now also keeps its 2-D matrix multiply on XNNPACK. Its source partition leaves out only the views above the limit, instead of the whole partition being rejected. A quantized linear on such an input still runs outside XNNPACK, because its quantized activation has more than 6 dims. Tested with stronger permute tests that check which ops are delegated. A 6-D permute is delegated and a 7-D one is not. A float linear on an 8-D input keeps its matrix multiply in XNNPACK, with and without force_non_static_weights_for_f32_linear, while its views and the 8-D permute stay outside. Its static and dynamic quantized variants keep the matrix multiply outside. Each of these breaks at least one test: removing the get_partition check, keeping the views in the default mode, putting back the old source partition check, or lowering the limit to 5 dims. --- .../xnnpack/partition/config/gemm_configs.py | 9 +- .../partition/config/xnnpack_config.py | 4 + backends/xnnpack/test/ops/test_permute.py | 84 ++++++++++++++----- 3 files changed, 72 insertions(+), 25 deletions(-) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 95fccc131c9..2f67a4b8dce 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -569,8 +569,6 @@ def find_partition_args(input_node): not in (src_partition.nodes + src_partition.input_nodes) # there can only be a single output node in partition or len(src_partition.output_nodes) != 1 - # this partition takes these nodes even if their configs reject them - or not all(self._check_tensor_ranks(n) for n in src_partition.nodes) ): why(node, "invalid source partition") return (False, []) @@ -585,11 +583,14 @@ def find_partition_args(input_node): node.users = old_users # When using force_non_static_weights_for_f32_linear, we want to get_deps to overwrite the source partition nodes. - # Else we want to be greedy. + # Else we want to be greedy, but leave out views XNNPACK cannot hold. ret_deps = ( list(set(deps) & set(src_partition.nodes)) if self.force_non_static_weights_for_f32_linear - else list(set(deps) | set(src_partition.nodes)) + else list( + set(deps) + | {n for n in src_partition.nodes if self._check_tensor_ranks(n)} + ) ) return valid_deps, ret_deps diff --git a/backends/xnnpack/partition/config/xnnpack_config.py b/backends/xnnpack/partition/config/xnnpack_config.py index 0285fc876b6..1e3ebcb3eea 100644 --- a/backends/xnnpack/partition/config/xnnpack_config.py +++ b/backends/xnnpack/partition/config/xnnpack_config.py @@ -69,6 +69,10 @@ def get_partition( with the partitioner config which returned them """ partitioned_nodes = self.get_node_and_deps(node, ep) + # A partition can take nodes that their own configs reject. + if not all(self._check_tensor_ranks(n) for n in partitioned_nodes): + return [] + # label partitioned nodes with the name of the partitioner config for node in partitioned_nodes: if "xnn_partitioner_config" in node.meta: diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index d17618fd992..b7c29baa00e 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -7,8 +7,15 @@ import unittest import torch -from executorch.backends.xnnpack.test.tester import Tester -from executorch.exir.lowered_backend_module import get_lowered_backend_modules +from executorch.backends.xnnpack.partition.xnnpack_partitioner import XnnpackPartitioner +from executorch.backends.xnnpack.quantizer.xnnpack_quantizer import ( + get_symmetric_quantization_config, +) +from executorch.backends.xnnpack.test.tester import ( + Quantize, + Tester, + ToEdgeTransformAndLower, +) # Buck preloads the quantized kernels instead of shipping this module. try: @@ -100,31 +107,66 @@ def forward(self, x): y = self.linear(x.reshape(2, 2, 2, 2, 2, 2, 2, 4)) return y.permute(0, 2, 5, 3, 6, 1, 4, 7).reshape(2, 64, -1) * 2 - def _test_permute_above_max_rank(self, quantize): - # XNNPACK allows at most 6 dims, and a delegate that holds a bigger tensor - # fails to load. The linear also pulls in its 8-D views as dependencies. - tester = Tester(self.LinearPermute8d().eval(), (torch.randn(2, 64, 4),)) - if quantize: - tester.quantize() - tester.export().to_edge_transform_and_lower() + def test_fp32_permute_max_rank(self): + # XNNPACK allows at most 6 dims, so a 6-D permute is delegated and a 7-D + # one is not. + delegate = "torch.ops.higher_order.executorch_call_delegate" + permute = "executorch_exir_dialects_edge__ops_aten_permute_copy_default" + for rank, delegates in ((6, 1), (7, 0)): + dims = list(reversed(range(rank))) + ( + Tester(self.Permute(dims), (torch.randn([2] * rank),)) + .export() + .to_edge_transform_and_lower() + .check_count({delegate: delegates, permute: 1 - delegates}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) - lowered_modules = get_lowered_backend_modules( - tester.get_artifact().exported_program().graph_module + def _test_permute_above_max_rank(self, quant_config=None, partitioner=None): + # A delegate that holds a tensor above 6 dims fails to load, so the 8-D + # views and permute stay outside and the mul is delegated. The linear's + # 2-D matmul is delegated too, unless its activation is quantized in 8-D. + tester = Tester(self.LinearPermute8d().eval(), (torch.randn(2, 64, 4),)) + if quant_config is not None: + tester.quantize(Quantize(quantization_config=quant_config)) + tester.export().to_edge_transform_and_lower( + ToEdgeTransformAndLower([partitioner]) if partitioner else None + ) + addmm = ["executorch_exir_dialects_edge__ops_aten_addmm_default"] + if quant_config is None: + tester.check_not(addmm) + else: + tester.check(addmm) + ( + tester.check_count( + { + "torch.ops.higher_order.executorch_call_delegate": 2, + "executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1, + } + ) + .check_not(["executorch_exir_dialects_edge__ops_aten_mul_Tensor"]) + .to_executorch() + .serialize() + .run_method_and_compare_outputs(qtol=1) ) - self.assertGreater(len(lowered_modules), 0) - for lowered_module in lowered_modules: - for node in lowered_module.original_module.graph.nodes: - val = node.meta.get("val") - if isinstance(val, torch.Tensor): - self.assertLessEqual(val.dim(), 6, node.format_node()) - - tester.to_executorch().serialize().run_method_and_compare_outputs(qtol=1) def test_fp32_permute_above_max_rank(self): - self._test_permute_above_max_rank(quantize=False) + for force_non_static_weights in (False, True): + self._test_permute_above_max_rank( + partitioner=XnnpackPartitioner( + force_non_static_weights_for_f32_linear=force_non_static_weights + ) + ) def test_qs8_permute_above_max_rank(self): - self._test_permute_above_max_rank(quantize=True) + self._test_permute_above_max_rank(get_symmetric_quantization_config()) + + def test_qd8_permute_above_max_rank(self): + self._test_permute_above_max_rank( + get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True) + ) def test_qs8_permute(self): inputs = (torch.randn(1, 1, 4, 4),) From 174702be8ca4607a1ca814811fe4f0553827a42a Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 11:17:09 -0700 Subject: [PATCH 04/23] Leave 3-D convolutions in float in the XNNPACK quantizer XNNPACK has no 3-D convolution, so the XNNPACK partitioner never delegates Conv3d or ConvTranspose3d. The XNNPACK quantizer still quantized them for static quantization. That only added quantize and dequantize steps around a float convolution that runs on the CPU kernels anyway. Now the conv and conv + relu annotators skip convolutions whose weight has more than 4 dims, the same way the conv annotator already skips the convolutions that XNNPACK cannot run with dynamic quantization. The partitioner tells the convolution dims apart by the weight rank too. Tested with a new conv test that quantizes a Conv3d with a relu followed by a ConvTranspose3d. It finds quantize and dequantize ops in the graph without this change, and none with it. --- .../xnnpack/quantizer/xnnpack_quantizer_utils.py | 10 +++++++++- backends/xnnpack/test/ops/test_conv2d.py | 13 +++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py index 8868d742b7d..c5f9ea0e839 100644 --- a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py +++ b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py @@ -240,6 +240,13 @@ def _annotate_linear_relu( return annotated_partitions +def _is_1d_or_2d_conv(weight: Node) -> bool: + # XNNPACK runs no other convs, and the weight rank is the only reliable + # indicator of the conv dimensionality. + weight_shape = getattr(weight.meta.get("val"), "shape", None) + return weight_shape is None or len(weight_shape) <= 4 + + def _do_annotate_conv( gm: torch.fx.GraphModule, quantization_config: Optional[QuantizationConfig], @@ -277,6 +284,7 @@ def _do_annotate_conv( # skip if transposed conv has more than 1 group skip = skip or (is_conv_transpose and num_groups != 1) + skip = skip or not _is_1d_or_2d_conv(weight) if is_conv_transpose: # transposed convs per output channel quantization @@ -371,7 +379,7 @@ def _do_annotate_conv_relu( if _is_annotated(partition): continue - if is_conv_transpose and groups != 1: + if (is_conv_transpose and groups != 1) or not _is_1d_or_2d_conv(weight): continue if filter_fn and not filter_fn(conv_node): diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 7164547c379..5b9edaf10a0 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -356,6 +356,19 @@ def test_fp32_conv3d_single_element_stride_doesnt_partition(self) -> None: .run_method_and_compare_outputs() ) + def test_qs8_conv3d_not_quantized(self) -> None: + # XNNPACK has no conv3d support, so its quantizer leaves 3d convs in float, + # with or without a fused relu. + m = torch.nn.Sequential( + torch.nn.Conv3d(2, 2, 2), torch.nn.ReLU(), torch.nn.ConvTranspose3d(2, 2, 2) + ) + ( + Tester(m.eval(), (torch.randn(1, 2, 4, 4, 4),)) + .quantize() + .export() + .check_not(["torch.ops.quantized_decomposed"]) + ) + def test_fp32_conv2d_permute(self) -> None: for transpose in (True, False): for perm_order in list(itertools.permutations([0, 1, 2, 3])): From 978bdfa1bfe4153616b1919b0da6232b25cf82e7 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 13:52:00 -0700 Subject: [PATCH 05/23] Tag q/dq nodes of other XNNPACK ops only once their partition is taken The generic XNNPACK op configs, such as add or permute, mark the quantize and dequantize nodes around a quantized op as implicit, which means folded into the op, in get_node_and_deps. get_partition can still reject the partition after that with its dims check. That cannot happen for these ops today, because their quantize and dequantize nodes have the same shapes as the op, which check_common_constraints already checked. But any later check in get_partition would bring back the wrong outputs that a rejected quantized conv or linear used to give. Now these configs mark the nodes in get_partition, once the partition is taken, as the conv and linear configs do. The marked nodes are the same as before. Checked by forcing get_partition to reject the partition of a quantized add: the program matches the reference with this change, and is wrong without it. --- .../partition/config/generic_node_configs.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/backends/xnnpack/partition/config/generic_node_configs.py b/backends/xnnpack/partition/config/generic_node_configs.py index 3c87e47c656..66fa3c2cbc8 100644 --- a/backends/xnnpack/partition/config/generic_node_configs.py +++ b/backends/xnnpack/partition/config/generic_node_configs.py @@ -165,19 +165,22 @@ def get_node_and_deps( # Expected node --> fused_act (optional) --> dequant return deps - # Tag input nodes (dq nodes) as implicit q/dq nodes - for dq_input in node.all_input_nodes: - if is_dequant(dq_input): - tag_as_implicit_q_dq(dq_input) - - # Tag node_output (q node) as an implicit q/dq node - if is_quant(node_output): - tag_as_implicit_q_dq(node_output) - quantized_deps.append(node_output) return deps + quantized_deps + def get_partition( + self, node: torch.fx.Node, ep: ExportedProgram + ) -> List[torch.fx.Node]: + partition = super().get_partition(node, ep) + # Tag q/dq nodes as implicit only once the partition is taken. A rejected + # node keeps them as separate ops. + for dep in partition: + if dep is not node and (is_dequant(dep) or is_quant(dep)): + tag_as_implicit_q_dq(dep) + + return partition + class QuantizedPerTensorConfig(GenericNodePartitionerConfig): target_name = "quantize_per_tensor.default" From da14ba1804ed7892538e9b2816c35009cc506462 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 13:52:19 -0700 Subject: [PATCH 06/23] Fix the 3-D convolution comment in the XNNPACK quantizer The comment said the weight rank is the only reliable way to tell the dims of a convolution. That is true in the partitioner, which sees the generic convolution op, but the quantizer annotators only match the conv1d, conv2d and conv3d ops and their transposed forms. The comment now only says why these convolutions are skipped. --- backends/xnnpack/quantizer/xnnpack_quantizer_utils.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py index c5f9ea0e839..509df834101 100644 --- a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py +++ b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py @@ -241,8 +241,7 @@ def _annotate_linear_relu( def _is_1d_or_2d_conv(weight: Node) -> bool: - # XNNPACK runs no other convs, and the weight rank is the only reliable - # indicator of the conv dimensionality. + # XNNPACK runs no other convs. weight_shape = getattr(weight.meta.get("val"), "shape", None) return weight_shape is None or len(weight_shape) <= 4 From f6cf9ef7875c5ea1dd435f3b1cdd0bf1b1c21d71 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 15:12:24 -0700 Subject: [PATCH 07/23] Leave out the activation of an XNNPACK linear above the dims limit A float linear on an input with more than 6 dims is decomposed into views around a 2-D matrix multiply, and the matrix multiply is still delegated. But a ReLU or Hardtanh after the linear has the same high rank as its output. The matrix multiply took it as a fused activation, and the dims check then rejected the whole partition, so the matrix multiply ran outside XNNPACK. Now a float matrix multiply fuses an activation only when the activation fits in XNNPACK. Otherwise the activation runs outside on its own, and the matrix multiply stays delegated. Tested by adding a ReLU case to the float linear permute test, in both partitioner modes. Without this change the matrix multiply is not delegated in the default mode. --- .../xnnpack/partition/config/gemm_configs.py | 3 +++ backends/xnnpack/test/ops/test_permute.py | 21 ++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 2f67a4b8dce..0b5a7fbeb56 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -231,9 +231,12 @@ def _get_output_deps( node_users = list(node.users.keys()) if len(node_users) == 1: n_output = node_users[0] + # After a decomposed linear, the activation can have more dims than + # XNNPACK allows even when the 2-D matmul does not. Leave it out then. if ( n_output.op == "call_function" and format_target_name(n_output.target.__name__) in self.fused_acts + and self._check_tensor_ranks(n_output) ): gemm_deps.append(n_output) diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index b7c29baa00e..d79ed084094 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -99,12 +99,15 @@ def test_fp32_permute_copy(self): ) class LinearPermute8d(torch.nn.Module): - def __init__(self): + def __init__(self, relu=False): super().__init__() self.linear = torch.nn.Linear(4, 4) + self.relu = relu def forward(self, x): y = self.linear(x.reshape(2, 2, 2, 2, 2, 2, 2, 4)) + if self.relu: + y = torch.relu(y) return y.permute(0, 2, 5, 3, 6, 1, 4, 7).reshape(2, 64, -1) * 2 def test_fp32_permute_max_rank(self): @@ -124,11 +127,13 @@ def test_fp32_permute_max_rank(self): .run_method_and_compare_outputs() ) - def _test_permute_above_max_rank(self, quant_config=None, partitioner=None): + def _test_permute_above_max_rank( + self, quant_config=None, partitioner=None, relu=False + ): # A delegate that holds a tensor above 6 dims fails to load, so the 8-D # views and permute stay outside and the mul is delegated. The linear's # 2-D matmul is delegated too, unless its activation is quantized in 8-D. - tester = Tester(self.LinearPermute8d().eval(), (torch.randn(2, 64, 4),)) + tester = Tester(self.LinearPermute8d(relu).eval(), (torch.randn(2, 64, 4),)) if quant_config is not None: tester.quantize(Quantize(quantization_config=quant_config)) tester.export().to_edge_transform_and_lower( @@ -154,11 +159,13 @@ def _test_permute_above_max_rank(self, quant_config=None, partitioner=None): def test_fp32_permute_above_max_rank(self): for force_non_static_weights in (False, True): - self._test_permute_above_max_rank( - partitioner=XnnpackPartitioner( - force_non_static_weights_for_f32_linear=force_non_static_weights + for relu in (False, True): + self._test_permute_above_max_rank( + partitioner=XnnpackPartitioner( + force_non_static_weights_for_f32_linear=force_non_static_weights + ), + relu=relu, ) - ) def test_qs8_permute_above_max_rank(self): self._test_permute_above_max_rank(get_symmetric_quantization_config()) From f4cb7270d956c42eee58dc68b36c2dc79f6314ae Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 15:18:26 -0700 Subject: [PATCH 08/23] Mark folded q/dq nodes in one place in the XNNPACK partitioner The conv and linear configs and the generic op configs each marked the quantize and dequantize nodes of a taken partition as implicit, in two copies of the same loop. Now XNNPartitionerConfig.get_partition does it for every config, right after its dims check. It marks the q/dq nodes of the partition except the partitioned node itself, so a standalone quantize or dequantize partition stays a separate op. No config returns other q/dq nodes today, so the marked nodes are the same as before. With one copy, the order of the check and the marking is covered by the existing tests: marking before the check breaks the static quantized permute test, and no marking breaks the quantized conv tests. --- .../xnnpack/partition/config/gemm_configs.py | 13 ------------- .../partition/config/generic_node_configs.py | 18 +----------------- .../xnnpack/partition/config/xnnpack_config.py | 8 ++++++++ 3 files changed, 9 insertions(+), 30 deletions(-) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 0b5a7fbeb56..50e1dd383de 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -25,7 +25,6 @@ is_per_tensor, is_qparam, is_quant, - tag_as_implicit_q_dq, ) from executorch.backends.xnnpack.utils.utils import ( get_input_node, @@ -83,18 +82,6 @@ def get_node_and_deps( return partition - def get_partition( - self, node: torch.fx.Node, ep: ExportedProgram - ) -> List[torch.fx.Node]: - partition = super().get_partition(node, ep) - # Tag q/dq nodes as implicit only once the partition is taken. A rejected - # node keeps them as separate ops. - for n in partition: - if is_dequant(n) or is_quant(n): - tag_as_implicit_q_dq(n) - - return partition - def get_original_aten(self) -> Optional[torch._ops.OpOverload]: return None diff --git a/backends/xnnpack/partition/config/generic_node_configs.py b/backends/xnnpack/partition/config/generic_node_configs.py index 66fa3c2cbc8..a9a323d9453 100644 --- a/backends/xnnpack/partition/config/generic_node_configs.py +++ b/backends/xnnpack/partition/config/generic_node_configs.py @@ -16,11 +16,7 @@ ConfigPrecisionType, XNNPartitionerConfig, ) -from executorch.backends.xnnpack.utils.quant_utils import ( - is_dequant, - is_quant, - tag_as_implicit_q_dq, -) +from executorch.backends.xnnpack.utils.quant_utils import is_dequant, is_quant from executorch.backends.xnnpack.utils.utils import ( get_input_node, get_param_tensor, @@ -169,18 +165,6 @@ def get_node_and_deps( return deps + quantized_deps - def get_partition( - self, node: torch.fx.Node, ep: ExportedProgram - ) -> List[torch.fx.Node]: - partition = super().get_partition(node, ep) - # Tag q/dq nodes as implicit only once the partition is taken. A rejected - # node keeps them as separate ops. - for dep in partition: - if dep is not node and (is_dequant(dep) or is_quant(dep)): - tag_as_implicit_q_dq(dep) - - return partition - class QuantizedPerTensorConfig(GenericNodePartitionerConfig): target_name = "quantize_per_tensor.default" diff --git a/backends/xnnpack/partition/config/xnnpack_config.py b/backends/xnnpack/partition/config/xnnpack_config.py index 1e3ebcb3eea..7f083b7d2da 100644 --- a/backends/xnnpack/partition/config/xnnpack_config.py +++ b/backends/xnnpack/partition/config/xnnpack_config.py @@ -14,6 +14,7 @@ is_dequant, is_qparam, is_quant, + tag_as_implicit_q_dq, ) from executorch.backends.xnnpack.utils.xnnpack_constants import XNN_MAX_TENSOR_DIMS from executorch.exir.backend.canonical_partitioners.config_partitioner import ( @@ -73,6 +74,13 @@ def get_partition( if not all(self._check_tensor_ranks(n) for n in partitioned_nodes): return [] + # Fold the q/dq nodes around the node only once its partition is taken, so + # a rejected node keeps them as separate ops. A q/dq node that is itself + # the partitioned node stays a separate op too. + for n in partitioned_nodes: + if n is not node and (is_quant(n) or is_dequant(n)): + tag_as_implicit_q_dq(n) + # label partitioned nodes with the name of the partitioner config for node in partitioned_nodes: if "xnn_partitioner_config" in node.meta: From e6752df2065b07cf32a4605ce8b770fd040996ca Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 15:19:51 -0700 Subject: [PATCH 09/23] Check that the XNNPACK quantizer still quantizes 2-D convs in the 3-D test The test for 3-D convolutions only checked that no quantize or dequantize ops appear, so it would also pass if the quantizer quantized nothing at all. It now runs the same model in 2-D too and checks that that one is quantized. The name now says what it checks. --- backends/xnnpack/test/ops/test_conv2d.py | 26 ++++++++++++++---------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 5b9edaf10a0..03bf29f2e1f 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -356,18 +356,22 @@ def test_fp32_conv3d_single_element_stride_doesnt_partition(self) -> None: .run_method_and_compare_outputs() ) - def test_qs8_conv3d_not_quantized(self) -> None: + def test_qs8_quantizer_skips_conv3d(self) -> None: # XNNPACK has no conv3d support, so its quantizer leaves 3d convs in float, - # with or without a fused relu. - m = torch.nn.Sequential( - torch.nn.Conv3d(2, 2, 2), torch.nn.ReLU(), torch.nn.ConvTranspose3d(2, 2, 2) - ) - ( - Tester(m.eval(), (torch.randn(1, 2, 4, 4, 4),)) - .quantize() - .export() - .check_not(["torch.ops.quantized_decomposed"]) - ) + # with or without a fused relu. The same model in 2d is still quantized. + for dims, conv, conv_transpose in ( + (2, torch.nn.Conv2d, torch.nn.ConvTranspose2d), + (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d), + ): + m = torch.nn.Sequential( + conv(2, 2, 2), torch.nn.ReLU(), conv_transpose(2, 2, 2) + ) + tester = Tester(m.eval(), (torch.randn([1, 2] + [4] * dims),)) + tester.quantize().export() + if dims == 2: + tester.check(["torch.ops.quantized_decomposed"]) + else: + tester.check_not(["torch.ops.quantized_decomposed"]) def test_fp32_conv2d_permute(self) -> None: for transpose in (True, False): From a6dc406fef07257d2a14f7c6dc85ca8aca088232 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 17:06:59 -0700 Subject: [PATCH 10/23] Describe the dims check and q/dq marking in the get_partition docstring XNNPartitionerConfig.get_partition can now return an empty partition when a node has more dims than XNNPACK supports, and it marks the quantize and dequantize nodes of a taken partition as implicit. The docstring now says so, so that the author of a new config knows that the q/dq nodes it returns are folded into its op. The comment above the marking loop now only gives the reason for it. --- backends/xnnpack/partition/config/xnnpack_config.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/backends/xnnpack/partition/config/xnnpack_config.py b/backends/xnnpack/partition/config/xnnpack_config.py index 7f083b7d2da..cb8f2bd6448 100644 --- a/backends/xnnpack/partition/config/xnnpack_config.py +++ b/backends/xnnpack/partition/config/xnnpack_config.py @@ -68,15 +68,19 @@ def get_partition( with the name of the XNNPartitionerConfig class which return this set of nodes. This enforces that all partitions returned by XNNPartitioner configs are labeled with the partitioner config which returned them + + Returns an empty list if any of the nodes has more dims than XNNPACK supports. + Otherwise marks every quantize and dequantize node in the partition, other + than the given node, as implicit, so that it is folded into the op it feeds + or follows. """ partitioned_nodes = self.get_node_and_deps(node, ep) # A partition can take nodes that their own configs reject. if not all(self._check_tensor_ranks(n) for n in partitioned_nodes): return [] - # Fold the q/dq nodes around the node only once its partition is taken, so - # a rejected node keeps them as separate ops. A q/dq node that is itself - # the partitioned node stays a separate op too. + # Fold only now, so a rejected node keeps its q/dq nodes as separate ops. A + # q/dq node partitioned on its own stays a separate op as well. for n in partitioned_nodes: if n is not node and (is_quant(n) or is_dequant(n)): tag_as_implicit_q_dq(n) From 57fe4a516ba528338956cae9e3a494af6ef49e97 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 20:50:14 -0700 Subject: [PATCH 11/23] Apply force_non_static_weights_for_f32_linear only to float linears With force_non_static_weights_for_f32_linear, a linear that is decomposed into a matrix multiply keeps only the deps inside its source partition. Like the other uses of the flag, in the weight and bias deps, this is meant for float linears, but it also ran for quantized ones. Their quantize and dequantize nodes are outside the source partition, so the partition dropped them. Such a linear then failed to lower or to load, or its matrix multiply ran in float. Now only a float linear keeps just the deps inside its source partition. A quantized linear is partitioned the same way with or without the flag. Tested with a statically quantized linear lowered through to_backend with the flag, which now is delegated whole, and with the static quantized linear permute test run with the flag too. Both fail without this change. --- .../xnnpack/partition/config/gemm_configs.py | 7 ++++-- backends/xnnpack/test/ops/test_linear.py | 23 +++++++++++++++++++ backends/xnnpack/test/ops/test_permute.py | 8 ++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/backends/xnnpack/partition/config/gemm_configs.py b/backends/xnnpack/partition/config/gemm_configs.py index 50e1dd383de..1796b5bdbbe 100644 --- a/backends/xnnpack/partition/config/gemm_configs.py +++ b/backends/xnnpack/partition/config/gemm_configs.py @@ -567,16 +567,19 @@ def find_partition_args(input_node): node.args = tuple(fake_args) node.users = src_partition.output_nodes[0].users valid_deps, deps = super().get_deps(node, ep) + _, precision = self._overwrite_precision(node) # Reset addmm node back to old args and users node.args = old_args node.users = old_users - # When using force_non_static_weights_for_f32_linear, we want to get_deps to overwrite the source partition nodes. - # Else we want to be greedy, but leave out views XNNPACK cannot hold. + # A float linear with force_non_static_weights_for_f32_linear takes only the + # deps inside the source partition. Else we want to be greedy, but leave out + # views XNNPACK cannot hold. ret_deps = ( list(set(deps) & set(src_partition.nodes)) if self.force_non_static_weights_for_f32_linear + and precision == ConfigPrecisionType.FP32 else list( set(deps) | {n for n in src_partition.nodes if self._check_tensor_ranks(n)} diff --git a/backends/xnnpack/test/ops/test_linear.py b/backends/xnnpack/test/ops/test_linear.py index 7e4cb4ab586..503cd832bd6 100644 --- a/backends/xnnpack/test/ops/test_linear.py +++ b/backends/xnnpack/test/ops/test_linear.py @@ -1152,6 +1152,29 @@ def test_linear_qd8_as_fp32(self): }, ) + def test_qs8_linear_with_force_non_static_weights_for_f32_linear(self): + # The flag is for float linears. A quantized linear that is decomposed into + # a matmul keeps its quantize and dequantize nodes and is delegated whole. + module = BaseLinear(use_bias=True) + quant_config = get_symmetric_quantization_config(is_per_channel=True) + partitioner = XnnpackPartitioner(force_non_static_weights_for_f32_linear=True) + ( + Tester(module.eval(), module.get_inputs(rank=2)) + .quantize(Quantize(quantization_config=quant_config)) + .export() + .to_edge() + .partition(Partition(partitioner=partitioner)) + .check_not( + [ + "executorch_exir_dialects_edge__ops_aten_addmm_default", + "quantized_decomposed", + ] + ) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + def test_linear_with_force_non_static_weights_for_f32_linear(self): def check_signature( signature: ExportGraphSignature, diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index d79ed084094..82ed301e8a6 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -168,7 +168,13 @@ def test_fp32_permute_above_max_rank(self): ) def test_qs8_permute_above_max_rank(self): - self._test_permute_above_max_rank(get_symmetric_quantization_config()) + for force_non_static_weights in (False, True): + self._test_permute_above_max_rank( + get_symmetric_quantization_config(), + partitioner=XnnpackPartitioner( + force_non_static_weights_for_f32_linear=force_non_static_weights + ), + ) def test_qd8_permute_above_max_rank(self): self._test_permute_above_max_rank( From 5248256ba9adc2c5b85428a71072ea0ab9c000eb Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 19:03:42 -0700 Subject: [PATCH 12/23] Log the op that a partition dims check rejects, and document q/dq folding When XNNPartitionerConfig.get_partition rejects a partition because one of its nodes has too many dims, only that node was logged, so the debug log did not say why the op itself stayed out. It now also logs the op, naming the node that was too big. The labeling loop in get_partition reused the name of the node parameter, which the q/dq folding loop above it depends on. It now uses its own name. The get_node_and_deps docstring, which new configs implement, now says that the quantize and dequantize nodes it returns, other than the node itself, are folded into the op. --- .../xnnpack/partition/config/xnnpack_config.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/backends/xnnpack/partition/config/xnnpack_config.py b/backends/xnnpack/partition/config/xnnpack_config.py index cb8f2bd6448..b5267d2405a 100644 --- a/backends/xnnpack/partition/config/xnnpack_config.py +++ b/backends/xnnpack/partition/config/xnnpack_config.py @@ -76,8 +76,10 @@ def get_partition( """ partitioned_nodes = self.get_node_and_deps(node, ep) # A partition can take nodes that their own configs reject. - if not all(self._check_tensor_ranks(n) for n in partitioned_nodes): - return [] + for n in partitioned_nodes: + if not self._check_tensor_ranks(n): + why(node, reason=f"its partition takes {n}, which has too many dims") + return [] # Fold only now, so a rejected node keeps its q/dq nodes as separate ops. A # q/dq node partitioned on its own stays a separate op as well. @@ -86,11 +88,11 @@ def get_partition( tag_as_implicit_q_dq(n) # label partitioned nodes with the name of the partitioner config - for node in partitioned_nodes: - if "xnn_partitioner_config" in node.meta: - node.meta["xnn_partitioner_config"].append(self.__class__.__name__) + for n in partitioned_nodes: + if "xnn_partitioner_config" in n.meta: + n.meta["xnn_partitioner_config"].append(self.__class__.__name__) else: - node.meta["xnn_partitioner_config"] = [self.__class__.__name__] + n.meta["xnn_partitioner_config"] = [self.__class__.__name__] return partitioned_nodes @@ -111,7 +113,9 @@ def get_node_and_deps( ) -> List[torch.fx.Node]: """ Takes in a node and its exported program and returns a list of nodes - and its dependencies that need to be partitioned together + and its dependencies that need to be partitioned together. Quantize and + dequantize nodes in the list, other than the node itself, are folded into + the partition's op once get_partition takes it. Args: node: Node to be partitioned From 5b1aa3b2d71ed64b5abfaca6b952ef86f275a3d2 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 20:49:10 -0700 Subject: [PATCH 13/23] Test a statically quantized XNNPACK linear with a float output The float-output case was only tested for a convolution. A statically quantized linear whose output is not quantized went through the same early marking of its input quantize and dequantize nodes, and returned wrong values. The new linear test fails before the marking fix and passes after it. The test needs the quantized kernels for the weight dequantize that runs outside the delegate, so the file now imports the runtime and the quantized kernels when it can, with the same guard as the permute and conv tests. Buck preloads them instead. --- backends/xnnpack/test/ops/test_linear.py | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backends/xnnpack/test/ops/test_linear.py b/backends/xnnpack/test/ops/test_linear.py index 503cd832bd6..cb0eb4db158 100644 --- a/backends/xnnpack/test/ops/test_linear.py +++ b/backends/xnnpack/test/ops/test_linear.py @@ -6,6 +6,7 @@ # pyre-unsafe import unittest +from dataclasses import replace from itertools import product from typing import Callable, Dict, List, Optional, Tuple @@ -33,6 +34,13 @@ from torch.export.graph_signature import ExportGraphSignature, InputKind +# Buck preloads the quantized kernels instead of shipping this module. +try: + import executorch.extension.pybindings.portable_lib # noqa[F401] + import executorch.kernels.quantized # noqa[F401] +except ImportError: + pass + try: from torchao.quantization.granularity import PerGroup from torchao.quantization.quant_api import ( @@ -840,6 +848,26 @@ def test_qs8_linear(self): quant_type="per_tensor", ) + def test_qs8_linear_float_output(self): + # XNNPACK leaves out a quantized linear whose output is not quantized. The + # quantize and dequantize of its input then run in a delegate of their own. + quant_config = replace( + get_symmetric_quantization_config(is_per_channel=True), + output_activation=None, + ) + module = BaseLinear(use_bias=True) + ( + Tester(module.eval(), module.get_inputs(rank=2)) + .quantize(Quantize(quantization_config=quant_config)) + .export() + .to_edge_transform_and_lower() + .check(["executorch_exir_dialects_edge__ops_aten_addmm_default"]) + .check_count({"torch.ops.higher_order.executorch_call_delegate": 1}) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) + # Tests for q[dp]8-f16-qc8w def test_qd8_f16_per_channel_linear(self): self._test_qd8_per_channel_linear(dtype=torch.half) From e6f0f742815a1dfa9a1572af35d525f806109e3f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 20:50:24 -0700 Subject: [PATCH 14/23] Count delegates only in the float cases of the 8-D linear permute test In the quantized cases, the second delegate held only the transpose of the left-out linear's weight. Counting it tied the test to how constant permutes are partitioned, which has nothing to do with the dims limit. The quantized cases now check only where the matrix multiply, the 8-D permute and the mul end up, and run the program. The float cases still check that the matrix multiply and the mul are the two delegates. --- backends/xnnpack/test/ops/test_permute.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index 82ed301e8a6..6b3138548b4 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -141,15 +141,14 @@ def _test_permute_above_max_rank( ) addmm = ["executorch_exir_dialects_edge__ops_aten_addmm_default"] if quant_config is None: - tester.check_not(addmm) + tester.check_not(addmm).check_count( + {"torch.ops.higher_order.executorch_call_delegate": 2} + ) else: tester.check(addmm) ( tester.check_count( - { - "torch.ops.higher_order.executorch_call_delegate": 2, - "executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1, - } + {"executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1} ) .check_not(["executorch_exir_dialects_edge__ops_aten_mul_Tensor"]) .to_executorch() From 03f41e3745aaedf8f410c0cba9638d11ac418e8f Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 21:48:41 -0700 Subject: [PATCH 15/23] Test force_non_static_weights_for_f32_linear with dynamic quantization too The tests for the flag covered float and statically quantized linears only. The decomposed linear test lowered through to_backend now also runs with dynamic quantization, and the dynamically quantized 8-D linear permute test now also runs with the flag. Making the flag apply to dynamically quantized linears again breaks both. --- backends/xnnpack/test/ops/test_linear.py | 39 ++++++++++++----------- backends/xnnpack/test/ops/test_permute.py | 10 ++++-- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/backends/xnnpack/test/ops/test_linear.py b/backends/xnnpack/test/ops/test_linear.py index cb0eb4db158..e4f7448e4be 100644 --- a/backends/xnnpack/test/ops/test_linear.py +++ b/backends/xnnpack/test/ops/test_linear.py @@ -1180,28 +1180,31 @@ def test_linear_qd8_as_fp32(self): }, ) - def test_qs8_linear_with_force_non_static_weights_for_f32_linear(self): + def test_quantized_linear_with_force_non_static_weights_for_f32_linear(self): # The flag is for float linears. A quantized linear that is decomposed into # a matmul keeps its quantize and dequantize nodes and is delegated whole. - module = BaseLinear(use_bias=True) - quant_config = get_symmetric_quantization_config(is_per_channel=True) partitioner = XnnpackPartitioner(force_non_static_weights_for_f32_linear=True) - ( - Tester(module.eval(), module.get_inputs(rank=2)) - .quantize(Quantize(quantization_config=quant_config)) - .export() - .to_edge() - .partition(Partition(partitioner=partitioner)) - .check_not( - [ - "executorch_exir_dialects_edge__ops_aten_addmm_default", - "quantized_decomposed", - ] + for is_dynamic in (False, True): + module = BaseLinear(use_bias=True) + quant_config = get_symmetric_quantization_config( + is_per_channel=True, is_dynamic=is_dynamic + ) + ( + Tester(module.eval(), module.get_inputs(rank=2)) + .quantize(Quantize(quantization_config=quant_config)) + .export() + .to_edge() + .partition(Partition(partitioner=partitioner)) + .check_not( + [ + "executorch_exir_dialects_edge__ops_aten_addmm_default", + "quantized_decomposed", + ] + ) + .to_executorch() + .serialize() + .run_method_and_compare_outputs(atol=5e-2) ) - .to_executorch() - .serialize() - .run_method_and_compare_outputs() - ) def test_linear_with_force_non_static_weights_for_f32_linear(self): def check_signature( diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index 6b3138548b4..2fabaa1e0b3 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -176,9 +176,13 @@ def test_qs8_permute_above_max_rank(self): ) def test_qd8_permute_above_max_rank(self): - self._test_permute_above_max_rank( - get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True) - ) + for force_non_static_weights in (False, True): + self._test_permute_above_max_rank( + get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True), + partitioner=XnnpackPartitioner( + force_non_static_weights_for_f32_linear=force_non_static_weights + ), + ) def test_qs8_permute(self): inputs = (torch.randn(1, 1, 4, 4),) From f59206d476e938e8827ca9bce79c0f2e5a054ccc Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 21:49:42 -0700 Subject: [PATCH 16/23] Test a bare 3-D convolution in the XNNPACK quantizer test The 3-D convolution in the quantizer test was always followed by a ReLU, so it went through the conv + relu annotator only. The model now also has a 3-D convolution on its own, the most common case, so the plain conv annotator is checked for both a convolution and a transposed one. --- backends/xnnpack/test/ops/test_conv2d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 03bf29f2e1f..2fd94f47cec 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -364,7 +364,7 @@ def test_qs8_quantizer_skips_conv3d(self) -> None: (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d), ): m = torch.nn.Sequential( - conv(2, 2, 2), torch.nn.ReLU(), conv_transpose(2, 2, 2) + conv(2, 2, 2), torch.nn.ReLU(), conv(2, 2, 2), conv_transpose(2, 2, 2) ) tester = Tester(m.eval(), (torch.randn([1, 2] + [4] * dims),)) tester.quantize().export() From 05eae3e1eb155ee58d905d410d882decc2ee7090 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 22:31:56 -0700 Subject: [PATCH 17/23] Read the conv weight shape once in the XNNPACK quantizer The 3-D convolution check read the weight shape on its own, and the dynamic quantization check read it again a few lines later. Both conv annotators now read it once and pass it to the check. --- backends/xnnpack/quantizer/xnnpack_quantizer_utils.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py index 509df834101..1602c30561a 100644 --- a/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py +++ b/backends/xnnpack/quantizer/xnnpack_quantizer_utils.py @@ -240,9 +240,8 @@ def _annotate_linear_relu( return annotated_partitions -def _is_1d_or_2d_conv(weight: Node) -> bool: +def _is_1d_or_2d_conv(weight_shape: Optional[torch.Size]) -> bool: # XNNPACK runs no other convs. - weight_shape = getattr(weight.meta.get("val"), "shape", None) return weight_shape is None or len(weight_shape) <= 4 @@ -280,10 +279,11 @@ def _do_annotate_conv( assert isinstance(weight, Node) weight_qspec = get_weight_qspec(quantization_config) num_groups = get_groups_from_conv(conv_node) + weight_shape = getattr(weight.meta.get("val", None), "shape", None) # skip if transposed conv has more than 1 group skip = skip or (is_conv_transpose and num_groups != 1) - skip = skip or not _is_1d_or_2d_conv(weight) + skip = skip or not _is_1d_or_2d_conv(weight_shape) if is_conv_transpose: # transposed convs per output channel quantization @@ -298,8 +298,6 @@ def _do_annotate_conv( # Only annotate dynamically quantized conv if it's 2D and not depthwise if is_dynamic: - weight_val = weight.meta.get("val", None) - weight_shape = getattr(weight_val, "shape", None) # Skip if not a 4D weight tensor (i.e. not conv2d) skip = skip or (weight_shape is not None and len(weight_shape) != 4) # Skip if depthwise (default to groups=1 since it's not an arg) @@ -363,6 +361,7 @@ def _do_annotate_conv_relu( assert isinstance(weight, Node) weight_qspec = get_weight_qspec(quantization_config) groups = get_groups_from_conv(conv_node) + weight_shape = getattr(weight.meta.get("val", None), "shape", None) if is_conv_transpose: # transposed convs per output channel quantization weight_qspec = change_quantization_config(weight_qspec, ch_axis=1) @@ -378,7 +377,7 @@ def _do_annotate_conv_relu( if _is_annotated(partition): continue - if (is_conv_transpose and groups != 1) or not _is_1d_or_2d_conv(weight): + if (is_conv_transpose and groups != 1) or not _is_1d_or_2d_conv(weight_shape): continue if filter_fn and not filter_fn(conv_node): From fcc1b3bb68a2037498b38665e478f450287ac7eb Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 22:31:56 -0700 Subject: [PATCH 18/23] Loop over the force flag inside the 8-D linear permute test helper Every caller of the helper ran it with and without force_non_static_weights_for_f32_linear, so the loop now lives in the helper, and its unused default partitioner is gone. --- backends/xnnpack/test/ops/test_permute.py | 76 +++++++++-------------- 1 file changed, 31 insertions(+), 45 deletions(-) diff --git a/backends/xnnpack/test/ops/test_permute.py b/backends/xnnpack/test/ops/test_permute.py index 2fabaa1e0b3..055108ab472 100644 --- a/backends/xnnpack/test/ops/test_permute.py +++ b/backends/xnnpack/test/ops/test_permute.py @@ -127,62 +127,48 @@ def test_fp32_permute_max_rank(self): .run_method_and_compare_outputs() ) - def _test_permute_above_max_rank( - self, quant_config=None, partitioner=None, relu=False - ): + def _test_permute_above_max_rank(self, quant_config=None, relu=False): # A delegate that holds a tensor above 6 dims fails to load, so the 8-D # views and permute stay outside and the mul is delegated. The linear's # 2-D matmul is delegated too, unless its activation is quantized in 8-D. - tester = Tester(self.LinearPermute8d(relu).eval(), (torch.randn(2, 64, 4),)) - if quant_config is not None: - tester.quantize(Quantize(quantization_config=quant_config)) - tester.export().to_edge_transform_and_lower( - ToEdgeTransformAndLower([partitioner]) if partitioner else None - ) - addmm = ["executorch_exir_dialects_edge__ops_aten_addmm_default"] - if quant_config is None: - tester.check_not(addmm).check_count( - {"torch.ops.higher_order.executorch_call_delegate": 2} + for force_non_static_weights in (False, True): + tester = Tester(self.LinearPermute8d(relu).eval(), (torch.randn(2, 64, 4),)) + if quant_config is not None: + tester.quantize(Quantize(quantization_config=quant_config)) + partitioner = XnnpackPartitioner( + force_non_static_weights_for_f32_linear=force_non_static_weights ) - else: - tester.check(addmm) - ( - tester.check_count( - {"executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1} + tester.export().to_edge_transform_and_lower( + ToEdgeTransformAndLower([partitioner]) + ) + addmm = ["executorch_exir_dialects_edge__ops_aten_addmm_default"] + if quant_config is None: + tester.check_not(addmm).check_count( + {"torch.ops.higher_order.executorch_call_delegate": 2} + ) + else: + tester.check(addmm) + ( + tester.check_count( + {"executorch_exir_dialects_edge__ops_aten_permute_copy_default": 1} + ) + .check_not(["executorch_exir_dialects_edge__ops_aten_mul_Tensor"]) + .to_executorch() + .serialize() + .run_method_and_compare_outputs(qtol=1) ) - .check_not(["executorch_exir_dialects_edge__ops_aten_mul_Tensor"]) - .to_executorch() - .serialize() - .run_method_and_compare_outputs(qtol=1) - ) def test_fp32_permute_above_max_rank(self): - for force_non_static_weights in (False, True): - for relu in (False, True): - self._test_permute_above_max_rank( - partitioner=XnnpackPartitioner( - force_non_static_weights_for_f32_linear=force_non_static_weights - ), - relu=relu, - ) + for relu in (False, True): + self._test_permute_above_max_rank(relu=relu) def test_qs8_permute_above_max_rank(self): - for force_non_static_weights in (False, True): - self._test_permute_above_max_rank( - get_symmetric_quantization_config(), - partitioner=XnnpackPartitioner( - force_non_static_weights_for_f32_linear=force_non_static_weights - ), - ) + self._test_permute_above_max_rank(get_symmetric_quantization_config()) def test_qd8_permute_above_max_rank(self): - for force_non_static_weights in (False, True): - self._test_permute_above_max_rank( - get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True), - partitioner=XnnpackPartitioner( - force_non_static_weights_for_f32_linear=force_non_static_weights - ), - ) + self._test_permute_above_max_rank( + get_symmetric_quantization_config(is_per_channel=True, is_dynamic=True) + ) def test_qs8_permute(self): inputs = (torch.randn(1, 1, 4, 4),) From bfb1d2c4e0db4c7f9ccc7310e2f94b6347e75353 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sat, 31 Oct 2026 23:16:21 -0700 Subject: [PATCH 19/23] Run the rejected cases of the XNNPACK per-channel transposed conv test XNNPACK rejects a transposed conv with per-channel weights when groups is above 1 or the channel axis is not 1. For those cases the test only checked that the conv stays out of the delegate. It now also runs the program and compares the output. This is the path of the wrong-output bug: the partitioner used to mark the quantize and dequantize nodes around the conv as folded before it rejected the conv, and the program returned all zeros. These cases fail that way without the fix, so the bug is now covered by a 2-D conv that the CPU kernels can run. --- backends/xnnpack/test/ops/test_conv2d.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 2fd94f47cec..785a5c1646d 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -870,14 +870,24 @@ def get_inputs(self): conv_count=1, ) else: - with self.assertRaises(RuntimeError): - self._test( - PerChannelConvTranspose2d( - 3 * groups, 5 * groups, groups, ch_axis - ), # ch_axis=0 - quant_config=None, - conv_count=1, + # XNNPACK leaves this conv out, and the quantize and dequantize + # nodes around it then run in delegates of their own. + m = PerChannelConvTranspose2d( + 3 * groups, 5 * groups, groups, ch_axis + ) + ( + Tester(m.eval(), m.get_inputs()) + .export() + .to_edge_transform_and_lower() + .check( + [ + "executorch_exir_dialects_edge__ops_aten_convolution_default" + ] ) + .to_executorch() + .serialize() + .run_method_and_compare_outputs() + ) def test_fp32_tconv_output_padding(self): """Test transposed convolution with non-zero output padding.""" From aff2a9be8c37782719c280b6879ad46c69ed2882 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 00:35:03 -0700 Subject: [PATCH 20/23] Drop 3-D transposed convs from the XNNPACK quantizer targets The conv annotators now leave 3-D convolutions in float, but CONV_TRANSPOSE_TARGETS still listed conv_transpose3d. With set_operator_type(torch.ops.aten.conv_transpose3d.input, config), the quantizer then quantized the 1-D and 2-D transposed convs of the model with that config and none of its 3-D ones. The table now lists only the transposed convs that the annotators quantize, like CONV_TARGETS. --- backends/xnnpack/quantizer/xnnpack_quantizer.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/backends/xnnpack/quantizer/xnnpack_quantizer.py b/backends/xnnpack/quantizer/xnnpack_quantizer.py index b84a15901fe..db4f4796942 100644 --- a/backends/xnnpack/quantizer/xnnpack_quantizer.py +++ b/backends/xnnpack/quantizer/xnnpack_quantizer.py @@ -257,8 +257,6 @@ class QuantPattern: torch.ops.aten.conv_transpose1d.default, torch.ops.aten.conv_transpose2d, torch.ops.aten.conv_transpose2d.input, - torch.ops.aten.conv_transpose3d, - torch.ops.aten.conv_transpose3d.input, } LINEAR_TARGETS = { From d9cec8f9e5bc7de8977da0cd5864dba7e087fe85 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 01:11:11 -0700 Subject: [PATCH 21/23] Count the quantized weights in the XNNPACK quantizer 3-D conv test The 2-D half of the test passed as soon as one quantize or dequantize node appeared, so it would not notice if one of the three convs stopped being quantized. The test now uses per-channel weights and counts the weight dequantize nodes: one per conv in 2-D, none in 3-D. --- backends/xnnpack/test/ops/test_conv2d.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index 785a5c1646d..bc8f657cc95 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -358,20 +358,23 @@ def test_fp32_conv3d_single_element_stride_doesnt_partition(self) -> None: def test_qs8_quantizer_skips_conv3d(self) -> None: # XNNPACK has no conv3d support, so its quantizer leaves 3d convs in float, - # with or without a fused relu. The same model in 2d is still quantized. - for dims, conv, conv_transpose in ( - (2, torch.nn.Conv2d, torch.nn.ConvTranspose2d), - (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d), + # with or without a fused relu. Each conv of the same model in 2d gets its + # weight quantized. + quant_config = get_symmetric_quantization_config(is_per_channel=True) + weight_dq = "torch.ops.quantized_decomposed.dequantize_per_channel.default" + for dims, conv, conv_transpose, quantized_convs in ( + (2, torch.nn.Conv2d, torch.nn.ConvTranspose2d, 3), + (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d, 0), ): m = torch.nn.Sequential( conv(2, 2, 2), torch.nn.ReLU(), conv(2, 2, 2), conv_transpose(2, 2, 2) ) - tester = Tester(m.eval(), (torch.randn([1, 2] + [4] * dims),)) - tester.quantize().export() - if dims == 2: - tester.check(["torch.ops.quantized_decomposed"]) - else: - tester.check_not(["torch.ops.quantized_decomposed"]) + ( + Tester(m.eval(), (torch.randn([1, 2] + [4] * dims),)) + .quantize(Quantize(quantization_config=quant_config)) + .export() + .check_count({weight_dq: quantized_convs}) + ) def test_fp32_conv2d_permute(self) -> None: for transpose in (True, False): From d1afd82277ac16b0b5e011516ffb6571fa774542 Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 03:30:40 -0800 Subject: [PATCH 22/23] Count the delegates in the rejected cases of the transposed conv test The rejected cases of the per-channel transposed conv test ran the program, but did not check that XNNPACK still runs the quantize and dequantize nodes around the conv. They now count those two delegates, like the float-output tests do. They also allow one quantization step of difference, like the accepted case. The XNNPACK quantize ignores quant_min, so a value that should clamp to -127 comes out as -128. With the test's own random inputs, this happened in 6 to 14 percent of runs per case. The per-test seed in conftest.py hid it in CI, but a runner without that seed would see it. --- backends/xnnpack/test/ops/test_conv2d.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index bc8f657cc95..c9aa2ab1ff3 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -887,9 +887,12 @@ def get_inputs(self): "executorch_exir_dialects_edge__ops_aten_convolution_default" ] ) + .check_count( + {"torch.ops.higher_order.executorch_call_delegate": 2} + ) .to_executorch() .serialize() - .run_method_and_compare_outputs() + .run_method_and_compare_outputs(qtol=1) ) def test_fp32_tconv_output_padding(self): From a86580f2aeaa7c4db56a4e7d874e5f2bd0616c6d Mon Sep 17 00:00:00 2001 From: Anthony Shoumikhin Date: Sun, 1 Nov 2026 03:30:51 -0800 Subject: [PATCH 23/23] Count the quantized activations in the XNNPACK quantizer 3-D conv test The test counted only the weight dequantize nodes, so it would still pass if the quantizer skipped the weights of a 3-D conv but kept quantizing its input and output. It now counts all four quantize and dequantize ops: in 2-D, four for the activations and three dequantize nodes for the weights, and in 3-D, none. --- backends/xnnpack/test/ops/test_conv2d.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/backends/xnnpack/test/ops/test_conv2d.py b/backends/xnnpack/test/ops/test_conv2d.py index c9aa2ab1ff3..2daacfd44cf 100644 --- a/backends/xnnpack/test/ops/test_conv2d.py +++ b/backends/xnnpack/test/ops/test_conv2d.py @@ -358,13 +358,14 @@ def test_fp32_conv3d_single_element_stride_doesnt_partition(self) -> None: def test_qs8_quantizer_skips_conv3d(self) -> None: # XNNPACK has no conv3d support, so its quantizer leaves 3d convs in float, - # with or without a fused relu. Each conv of the same model in 2d gets its - # weight quantized. + # with or without a fused relu. The same model in 2d gets its activations + # and weights quantized. The weights are quantized ahead of time, so only + # their dequantize nodes remain. quant_config = get_symmetric_quantization_config(is_per_channel=True) - weight_dq = "torch.ops.quantized_decomposed.dequantize_per_channel.default" - for dims, conv, conv_transpose, quantized_convs in ( - (2, torch.nn.Conv2d, torch.nn.ConvTranspose2d, 3), - (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d, 0), + ops = "torch.ops.quantized_decomposed" + for dims, conv, conv_transpose, quantized_activations, quantized_weights in ( + (2, torch.nn.Conv2d, torch.nn.ConvTranspose2d, 4, 3), + (3, torch.nn.Conv3d, torch.nn.ConvTranspose3d, 0, 0), ): m = torch.nn.Sequential( conv(2, 2, 2), torch.nn.ReLU(), conv(2, 2, 2), conv_transpose(2, 2, 2) @@ -373,7 +374,14 @@ def test_qs8_quantizer_skips_conv3d(self) -> None: Tester(m.eval(), (torch.randn([1, 2] + [4] * dims),)) .quantize(Quantize(quantization_config=quant_config)) .export() - .check_count({weight_dq: quantized_convs}) + .check_count( + { + f"{ops}.quantize_per_tensor.default": quantized_activations, + f"{ops}.dequantize_per_tensor.default": quantized_activations, + f"{ops}.quantize_per_channel.default": 0, + f"{ops}.dequantize_per_channel.default": quantized_weights, + } + ) ) def test_fp32_conv2d_permute(self) -> None: