Repository navigation
[XNNPACK] Lower depthwise transposed convs as polyphase depthwise convs + depth_to_space #24114
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
borys3kk
wants to merge
8
commits into
pytorch:main
Choose a base branch
from
borys3kk:xnnpack-depthwise-conv-transpose-main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
f7bb1c5
[XNNPACK] Lower upsample_nearest2d x2 and keep NHWC across scalar / l…
borys3kk d6e8816
Make delete_constant_placeholder edit the graph signature in place
borys3kk b929250
[XNNPACK] Lower depthwise transposed convs as polyphase depthwise con…
borys3kk 743c690
Remove the deleted spec with a slice assignment
borys3kk 1c8a70d
Use a Software Mansion copyright header on the new files
borys3kk 445b1d5
Drop the channels-last broadcast operand changes that moved out of #2…
borys3kk c22288b
Key the phase weight placeholders by stride and padding as well
borys3kk 12f2694
Merge remote-tracking branch 'origin/main' into xnnpack-depthwise-con…
borys3kk File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
225 changes: 225 additions & 0 deletions
225
backends/xnnpack/_passes/decompose_depthwise_conv_transpose.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,225 @@ | ||
| # Copyright (c) 2026 Software Mansion | ||
| # All rights reserved. | ||
| # | ||
| # This source code is licensed under the BSD-style license found in the | ||
| # LICENSE file in the root directory of this source tree. | ||
|
|
||
| import math | ||
| from typing import cast, List, Optional, Tuple | ||
|
|
||
| import executorch.backends.xnnpack.custom_ops_lib # noqa: F401 registers et_xnnpack ops | ||
| import torch | ||
| from executorch.backends.transforms.utils import ( | ||
| create_constant_placeholder, | ||
| delete_constant_placeholder, | ||
| get_param_tensor, | ||
| is_param_node, | ||
| ) | ||
| from executorch.backends.xnnpack._passes.xnnpack_pass import XNNPACKPass | ||
| from executorch.exir.dialects._ops import ops as exir_ops | ||
| from executorch.exir.pass_base import PassResult | ||
|
|
||
|
|
||
| class DecomposeDepthwiseConvTransposePass(XNNPACKPass): | ||
| """ | ||
| Lower a depthwise transposed conv (groups == in_channels == out_channels) | ||
| with stride s > 1 into s*s depthwise convs on the low-resolution input, a | ||
| channel concatenation and a block-major depth_to_space_2d. | ||
|
|
||
| XNNPACK runs transposed convs through its subconvolution IGEMM path, one | ||
| group at a time; with one channel per group that is far slower than its | ||
| depthwise conv microkernels. Polyphase decomposition: output phase a of a | ||
| stride-s transposed conv is an ordinary stride-1 correlation of the input | ||
| with every s-th tap of the kernel, | ||
|
|
||
| y[s*m + a] = sum_t x[m - t] * w[s*t + a + p], 0 <= s*t + a + p < k, | ||
|
|
||
| per axis. The s*s phase outputs concatenated along channels in (ay, ax) | ||
| order are exactly the block-major input depth_to_space_2d expects. Each | ||
| phase reads the input through a (possibly asymmetric) zero pad. | ||
|
|
||
| Only applied when the transposed conv is a clean x s upscale, i.e. | ||
| (H - 1)*s - 2p + k + output_padding == s*H on both axes with the same s, | ||
| dilation is 1 and every phase's taps stay inside the input; any other | ||
| transposed conv is left to XNNPACK's own node. | ||
| """ | ||
|
|
||
| def call(self, graph_module: torch.fx.GraphModule): | ||
| graph = graph_module.graph | ||
| # Plan every conv before rewriting any: the nodes created by | ||
| # _decompose have no meta["val"] until the retrace below. | ||
| plans = [] | ||
| for node in graph.nodes: | ||
| if ( | ||
| node.op == "call_function" | ||
| and node.target == exir_ops.edge.aten.convolution.default | ||
| ): | ||
| plan = self._plan(node) | ||
| if plan is not None: | ||
| plans.append((node, plan)) | ||
| for node, plan in plans: | ||
| self._decompose(graph, node, *plan) | ||
|
|
||
| modified = bool(plans) | ||
| if modified: | ||
| graph_module.recompile() | ||
| graph_module = super().call(graph_module).graph_module | ||
| return PassResult(graph_module, modified) | ||
|
|
||
| # (t_min, t_max) per phase for one axis, None when the axis | ||
| # does not decompose. | ||
| @staticmethod | ||
| def _axis_phases( | ||
| size: int, kernel: int, stride: int, padding: int, output_padding: int | ||
| ) -> Optional[List[Tuple[int, int]]]: | ||
| if stride < 2: | ||
| return None | ||
| if (size - 1) * stride - 2 * padding + kernel + output_padding != size * stride: | ||
| return None | ||
| phases = [] | ||
| for a in range(stride): | ||
| # integer t with 0 <= stride*t + a + padding <= kernel - 1 | ||
| t_min = math.ceil(-(a + padding) / stride) | ||
| t_max = math.floor((kernel - 1 - a - padding) / stride) | ||
| if t_min > 0 or t_max < 0: | ||
| return None | ||
| phases.append((t_min, t_max)) | ||
| return phases | ||
|
|
||
| def _plan(self, node: torch.fx.Node): | ||
| ( | ||
| x, | ||
| weight, | ||
| _bias, | ||
| stride, | ||
| padding, | ||
| dilation, | ||
| transposed, | ||
| output_padding, | ||
| groups, | ||
| ) = node.args | ||
| if not transposed: | ||
| return None | ||
| if not is_param_node(self.exported_program, weight): | ||
| return None | ||
| x_shape = list(x.meta["val"].shape) | ||
| w_shape = list(weight.meta["val"].shape) | ||
| if len(x_shape) != 4 or len(w_shape) != 4: | ||
| return None | ||
| channels = x_shape[1] | ||
| # transposed conv weight is (in, out / groups, kh, kw) | ||
| if groups != channels or w_shape[0] != channels or w_shape[1] != 1: | ||
| return None | ||
| if any(d != 1 for d in dilation): | ||
| return None | ||
| # ATen broadcasts a single spatial value over both spatial dims. | ||
| stride, padding, output_padding = ( | ||
| p * 2 if len(p) == 1 else p | ||
| for p in cast(List[List[int]], [stride, padding, output_padding]) | ||
| ) | ||
| if stride[0] != stride[1]: | ||
| return None | ||
| phases_y = self._axis_phases( | ||
| x_shape[2], w_shape[2], stride[0], padding[0], output_padding[0] | ||
| ) | ||
| phases_x = self._axis_phases( | ||
| x_shape[3], w_shape[3], stride[1], padding[1], output_padding[1] | ||
| ) | ||
| if phases_y is None or phases_x is None: | ||
| return None | ||
| return stride[0], padding, phases_y, phases_x | ||
|
|
||
| def _decompose( | ||
| self, | ||
| graph: torch.fx.Graph, | ||
| node: torch.fx.Node, | ||
| s: int, | ||
| padding: List[int], | ||
| phases_y: List[Tuple[int, int]], | ||
| phases_x: List[Tuple[int, int]], | ||
| ) -> None: | ||
| x, weight_node, bias_node = node.args[0], node.args[1], node.args[2] | ||
| groups = node.args[8] | ||
| weight = get_param_tensor(self.exported_program, weight_node) | ||
| assert weight is not None | ||
| weight_spec = next( | ||
| spec | ||
| for spec in self.exported_program.graph_signature.input_specs | ||
| if spec.arg.name == weight_node.name | ||
| ) | ||
|
|
||
| phase_convs = [] | ||
| for ay, (ty_min, ty_max) in enumerate(phases_y): | ||
| for ax, (tx_min, tx_max) in enumerate(phases_x): | ||
| # taps of phase (ay, ax) as a stride-1 correlation kernel: | ||
| # W[q] = w[s * (t_max - q) + a + p] | ||
| iy = torch.tensor( | ||
| [ | ||
| s * (ty_max - q) + ay + padding[0] | ||
| for q in range(ty_max - ty_min + 1) | ||
| ] | ||
| ) | ||
| ix = torch.tensor( | ||
| [ | ||
| s * (tx_max - q) + ax + padding[1] | ||
| for q in range(tx_max - tx_min + 1) | ||
| ] | ||
| ) | ||
| phase_weight = weight[:, :, iy][:, :, :, ix].contiguous() | ||
| # The phase taps depend on s and padding too, so a weight | ||
| # shared by differently configured deconvs must not reuse | ||
| # the placeholders create_constant_placeholder finds by name. | ||
| phase_name = ( | ||
| f"{weight_node.name}_s{s}_p{padding[0]}x{padding[1]}" | ||
| f"_phase_{ay}_{ax}" | ||
| ) | ||
| with graph.inserting_before(weight_node): | ||
| phase_weight_node = create_constant_placeholder( | ||
| exp_program=self.exported_program, | ||
| graph=graph, | ||
| kind=weight_spec.kind, | ||
| persistent_buffer=weight_spec.persistent, | ||
| name=phase_name, | ||
| data=phase_weight, | ||
| ) | ||
| # (left, right, top, bottom) | ||
| pad = [tx_max, -tx_min, ty_max, -ty_min] | ||
| with graph.inserting_before(node): | ||
| conv_input = x | ||
| if any(pad): | ||
| conv_input = graph.create_node( | ||
| "call_function", | ||
| exir_ops.edge.aten.constant_pad_nd.default, | ||
| (x, pad, 0.0), | ||
| ) | ||
| phase_convs.append( | ||
| graph.create_node( | ||
| "call_function", | ||
| exir_ops.edge.aten.convolution.default, | ||
| ( | ||
| conv_input, | ||
| phase_weight_node, | ||
| bias_node, | ||
| [1, 1], | ||
| [0, 0], | ||
| [1, 1], | ||
| False, | ||
| [0, 0], | ||
| groups, | ||
| ), | ||
| ) | ||
| ) | ||
|
|
||
| with graph.inserting_before(node): | ||
| cat = graph.create_node( | ||
| "call_function", exir_ops.edge.aten.cat.default, (phase_convs, 1) | ||
| ) | ||
| d2s = graph.create_node( | ||
| "call_function", | ||
| exir_ops.edge.et_xnnpack.depth_to_space_2d.default, | ||
| (cat, s), | ||
| ) | ||
| node.replace_all_uses_with(d2s) | ||
| graph.erase_node(node) | ||
| if len(weight_node.users) == 0: | ||
| delete_constant_placeholder(self.exported_program, weight_node) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
phase weights fall out of the external
.ptdcreate_constant_placeholdersets onlymeta["val"]on the new placeholder. So the phase weights lose the original weight'smeta["custom"]["delegate_constant_tag"], and line 225 then deletes the tagged original.NodeVisitoruses that tag to route constants to the external file (operators/node_visitor.py:650). Without it, the phase weights silently land in the.pte.PropagateCustomMetaPassonly handles q/dq nodes and runs before this pass, so nothing puts the tag back.Repro: tag
DwDeconv(4, 2, 1)withdelegate_external_constants_pass_unlifted(gen_tag_fn=lambda _: "model")(same setup astest_propagate_custom_meta_pass.py), then lower and serialize..pte(the phase weights) and 1 inmodel.ptd(the bias).pteand 5 inmodel.ptdSuggested fix: copy it right after creating each phase placeholder
and add a test modeled on
test_propagate_custom_meta_pass.pythat asserts the.ptehas no named data.FuseBatchNormPasshas the same gap, so havingcreate_constant_placeholdertake a node to copy meta from would be the general fix, but that can wait for a follow-up.