Skip to content

Commit fd3656d

Browse files
mcremon-metafacebook-github-bot
authored andcommitted
Remove permutes around fused_quant elementwise ops (#21481)
Summary: `ConvToChannelsLast` wraps every conv in `permute(NCHW->NHWC) -> conv -> permute(NHWC->NCHW)`. When convs are joined by elementwise fused_quant ops (residual add/mul, activations), permutes end up threaded through the surrounding region and were never removed: the existing Cadence `RemovePermutesAroundElementwiseOps` only recognizes aten/cadence elementwise ops, not the SAS fused_quant ops -- and it would also wrongly treat their lifted scale/zero_point operands as constants to be permuted. This adds a fused_quant-aware permute-removal pass and wires it into the edge optimization group: - Extend the shared ExecuTorch `RemovePermutesAroundElementwiseOps` with a small overridable seam (`_permute_relevant_inputs`) so a subclass can hide operands from layout propagation. Behavior-preserving for existing users. - New `RemovePermutesAroundFusedQuantElementwiseOps` (SAS) subclasses it, adding `fused_quant.add`/`mul` and the activation ops as permutable and exposing only their tensor operands, so the lifted scale/zero_point placeholders are never permuted/compensated (which would break lowering). Ops with per-channel qparams are skipped (not permutation-invariant). - Replace the two Cadence permute passes (which no-op pre-Lower) in the optimization group with this single pass. - Teach the shared subgraph engine about "permutation-sink" flattens: a `view_copy` whose input has <=1 non-unit dim (e.g. the `[1, C, 1, 1] -> [1, C]` after a global pool) is layout-invariant, so a permutation flowing into it simply dies. The region can terminate cleanly there with no compensating permute -- which lets the residual-block permutes collapse across the avgpool -> flatten -> classifier head instead of being stranded by it. Note: fused_quant is currently SAS-specific, NOT yet a generic cross-backend dialect, so the fused_quant knowledge deliberately stays in the SAS subclass rather than the shared ExecuTorch pass. When fused_quant graduates to a shared dialect, this can fold into the base pass via `extra_permutable_ops` + the seam. (The permutation-sink flatten handling is generic and correctly lives in the shared pass.) On resnet18 the optimized graph goes from 83 permutes down to a single one (the model-input boundary); every permute around the residual add/relu blocks and across the global-pool flatten is removed. Reviewed By: DrJessop Differential Revision: D113424191
1 parent e8feb9e commit fd3656d

4 files changed

Lines changed: 107 additions & 1 deletion

File tree

backends/arm/test/passes/test_remove_permutes_around_elementwise_tosa_ops.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
TosaLoweringContext,
1515
TosaSpecification,
1616
)
17+
from executorch.backends.transforms.remove_permutes_around_elementwise_ops import (
18+
RemovePermutesAroundElementwiseOps,
19+
)
1720
from executorch.exir import ExportedProgram
1821
from executorch.exir.dialects._ops import ops as exir_ops
1922

@@ -47,6 +50,49 @@ def _count_nodes(graph_module: torch.fx.GraphModule, target) -> int:
4750
)
4851

4952

53+
def test_extra_permutable_ops_makes_op_permutable() -> None:
54+
"""Ops in extra_permutable_ops are permutable in the base pass."""
55+
56+
def build() -> torch.fx.GraphModule:
57+
graph = torch.fx.Graph()
58+
x = graph.placeholder("x")
59+
x.meta["val"] = torch.randn(1, 3, 4, 5)
60+
permute_in = graph.create_node(
61+
"call_function",
62+
PERMUTE_TARGET,
63+
args=(x, [0, 2, 3, 1]),
64+
)
65+
permute_in.meta["val"] = torch.randn(1, 4, 5, 3)
66+
rescale = graph.create_node(
67+
"call_function",
68+
RESCALE_TARGET,
69+
args=(permute_in, torch.int8, [1.0], 0, 0),
70+
)
71+
rescale.meta["val"] = torch.randn(1, 4, 5, 3)
72+
permute_out = graph.create_node(
73+
"call_function",
74+
PERMUTE_TARGET,
75+
args=(rescale, [0, 3, 1, 2]),
76+
)
77+
permute_out.meta["val"] = torch.randn(1, 3, 4, 5)
78+
graph.output(permute_out)
79+
return torch.fx.GraphModule({}, graph)
80+
81+
# RESCALE is not permutable by default, so the boundary permutes stay.
82+
baseline = RemovePermutesAroundElementwiseOps().call(build())
83+
assert not baseline.modified
84+
assert _count_nodes(baseline.graph_module, PERMUTE_TARGET) == 2
85+
86+
# Supplying RESCALE via extra_permutable_ops lets the region collapse.
87+
with TosaLoweringContext(TOSA_INT_SPEC):
88+
result = RemovePermutesAroundElementwiseOps(
89+
extra_permutable_ops={RESCALE_TARGET}
90+
).call(build())
91+
assert result.modified
92+
assert _count_nodes(result.graph_module, PERMUTE_TARGET) == 0
93+
assert _count_nodes(result.graph_module, RESCALE_TARGET) == 1
94+
95+
5096
def test_remove_permutes_around_rescale_tosa_INT() -> None:
5197
graph = torch.fx.Graph()
5298
x = graph.placeholder("x")
@@ -140,7 +186,10 @@ def test_remove_permutes_around_gelu_with_folded_scalar_constants_tosa_FP() -> N
140186
)
141187

142188
assert result.modified
143-
assert _count_nodes(result.graph_module, PERMUTE_TARGET) == 3
189+
# The scalar constants are numel-1 (1,1,1,1): layout-invariant, so they are
190+
# left wired directly with no compensating permute, and every permute in the
191+
# region cancels.
192+
assert _count_nodes(result.graph_module, PERMUTE_TARGET) == 0
144193
assert _count_nodes(result.graph_module, ERF_TARGET) == 1
145194

146195

backends/transforms/remove_permutes_around_elementwise_ops.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,26 @@ def _check_squeeze_unsqueeze_view(self, node: torch.fx.Node) -> bool:
116116
return self._find_extra_one(in_shape, out_shape) != -1
117117
return False
118118

119+
def _is_permutation_sink_view(self, node: torch.fx.Node) -> bool:
120+
"""True if ``node`` is a reshape whose input has at most one non-unit dim.
121+
122+
Flattening such a tensor -- e.g. the ``[1, C, 1, 1] -> [1, C]`` after a
123+
global pool -- is permutation-invariant: every layout of the input
124+
produces the identical output (the single non-unit run of elements is
125+
contiguous regardless of which axis holds it). A permutation propagating
126+
into it therefore simply dies, so the region can terminate here with no
127+
compensating permute.
128+
"""
129+
if node.target not in self._VIEW_OPS:
130+
return False
131+
inp = node.args[0]
132+
assert isinstance(inp, torch.fx.Node)
133+
shape = inp.meta["val"].shape
134+
# Count a dim as non-unit unless it is a concrete size-1 (symbolic dims
135+
# are treated as non-unit, i.e. conservatively not a sink).
136+
non_unit = [d for d in shape if not (isinstance(d, int) and d == 1)]
137+
return len(non_unit) <= 1
138+
119139
def _adapt_permute_across_view(
120140
self, permute: list[int], node: torch.fx.Node
121141
) -> list[int] | None:
@@ -321,6 +341,13 @@ def visit( # noqa: C901
321341
return False
322342
elif user.op == "output":
323343
return False
344+
elif not self._is_squeeze_unsqueeze_view(
345+
user
346+
) and self._is_permutation_sink_view(user):
347+
# The permutation dies at this reshape (see
348+
# _is_permutation_sink_view), so terminate the region here with
349+
# no compensating permute and no further downstream traversal.
350+
continue
324351
elif not self.visit(
325352
user, subgraph, processed_nodes, downstream_end, downstream_start
326353
):
@@ -332,6 +359,13 @@ def visit( # noqa: C901
332359
if self.get_permutation(inp) != current_start_permute:
333360
return False
334361
subgraph.edges_in.add((inp, node))
362+
elif (inp_val := inp.meta.get("val")) is not None and inp_val.numel() == 1:
363+
# A numel-1 input (per-tensor quant scale / zero_point, scalar
364+
# constant, ...) is layout-invariant: it broadcasts identically
365+
# under any permutation, so it needs no compensating permute and
366+
# stays wired directly. Notably this keeps lifted per-tensor
367+
# qparam placeholders as placeholders, which lowering requires.
368+
continue
335369
elif self._is_constant(inp):
336370
const_rank = self._get_node_rank(inp)
337371
permute_rank = len(current_end_permute)

backends/transforms/targets.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ def define_common_targets():
409409
srcs = ["remove_permutes_around_elementwise_ops.py"],
410410
visibility = [
411411
"//executorch/backends/...",
412+
"@EXECUTORCH_CLIENTS",
412413
],
413414
deps = [
414415
":permute_pass_utils",

backends/transforms/test/test_permute_optimization_passes.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1097,3 +1097,25 @@ def test_upstream_view_rank_mismatch_no_crash(self) -> None:
10971097
[x_data],
10981098
"upstream_view_rank_mismatch_no_crash",
10991099
)
1100+
1101+
1102+
# ──────────────────────────────────────────────────────────────────────
1103+
# Tests for RemovePermutesAroundElementwiseOps
1104+
# ──────────────────────────────────────────────────────────────────────
1105+
1106+
1107+
class RemovePermutesAroundElementwiseOpsTest(unittest.TestCase):
1108+
def test_no_permutes_is_noop(self) -> None:
1109+
"""With no surrounding permutes, the pass makes no change."""
1110+
builder = GraphBuilder()
1111+
x = builder.placeholder("x", torch.randn(1, 4, 8, 8))
1112+
mul = builder.call_operator(op=exir_ops.edge.aten.mul.Tensor, args=(x, x))
1113+
builder.output([mul])
1114+
original = builder.get_graph_module()
1115+
1116+
p = RemovePermutesAroundElementwiseOps()
1117+
result = cast(PassResult, p(original))
1118+
self.assertFalse(result.modified)
1119+
self.assertEqual(
1120+
count_node(result.graph_module, exir_ops.edge.aten.permute_copy.default), 0
1121+
)

0 commit comments

Comments
 (0)