Skip to content

Commit 5bfbe1d

Browse files
Arm backend: Fold scalar mul into convolution (#20838)
Fold constant scalar or channel-wise multiplication after convolution into the convolution weights and bias. This removes post-conv mul ops when the scale is a scalar or is broadcastable only across output channels. Handle both pre-edge ATen conv2d graphs and edge aten.convolution graphs, and skip folding when the convolution output has multiple users or the scale does not represent channel scaling. cc @digantdesai @freddan80 @per @zingo @mansnils @Sebastian-Larsson @robell @rascani --------- Signed-off-by: Oscar Andersson <oscar.andersson@arm.com> Co-authored-by: Rob Elliott <Robert.Elliott@arm.com>
1 parent e182e8b commit 5bfbe1d

5 files changed

Lines changed: 583 additions & 0 deletions

File tree

backends/arm/_passes/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@
111111
FoldAndAnnotateQParamsPass,
112112
QuantizeClampArgumentsPass,
113113
)
114+
from .fold_scalar_mul_into_conv_pass import FoldScalarMulIntoConvPass # noqa
114115
from .fuse_batch_norm2d_pass import FuseBatchNorm2dPass # noqa
115116
from .fuse_consecutive_concat_shapes import FuseConsecutiveConcatShapesPass # noqa
116117
from .fuse_consecutive_rescales_pass import FuseConsecutiveRescalesPass # noqa

backends/arm/_passes/arm_pass_manager.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
EnsureUniqueOutputNodesPass,
107107
ExirToTosaPass,
108108
FoldAndAnnotateQParamsPass,
109+
FoldScalarMulIntoConvPass,
109110
FuseBatchNorm2dPass,
110111
FuseConsecutiveConcatShapesPass,
111112
FuseConsecutiveRescalesPass,
@@ -621,6 +622,7 @@ def _tosa_pipeline(
621622
RewriteUpsamplePass(),
622623
RewriteMaxPool2dPass(),
623624
DecomposeAdaptiveMaxPool2dPass(),
625+
FoldScalarMulIntoConvPass(exported_program),
624626
RewriteConvPass(exported_program),
625627
RewriteMXFPConv2dPass(exported_program),
626628
RewriteMXFPLinearPass(exported_program),
@@ -692,6 +694,7 @@ def transform_for_annotation_pipeline(self, graph_module: GraphModule):
692694
ConvertInt64ConstOpsToInt32Pass(tfa_pass=True),
693695
ConvertInt64OutputOpsToInt32Pass(tfa_pass=True),
694696
InsertInt32CastsAfterInt64PlaceholdersPass(tfa_pass=True),
697+
FoldScalarMulIntoConvPass(tfa_pass=True),
695698
DecomposeEmbeddingPass(tfa_pass=True),
696699
DecomposeScaledDotProductAttentionPass(tfa_pass=True),
697700
DecomposeLogitPass(tfa_pass=True),
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
# Copyright 2026 Arm Limited and/or its affiliates.
2+
#
3+
# This source code is licensed under the BSD-style license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
6+
from typing import Optional, Set, Type
7+
8+
import torch
9+
from executorch.backends.arm._passes import ArmPass
10+
from executorch.backends.arm._passes.arm_pass_utils import (
11+
get_constant_placeholder_kind,
12+
get_param_tensor,
13+
)
14+
from executorch.backends.transforms.utils import (
15+
create_constant_placeholder,
16+
delete_constant_placeholder,
17+
)
18+
from executorch.exir.dialects._ops import ops as exir_ops
19+
from executorch.exir.pass_base import ExportPass, PassResult
20+
from torch.export.graph_signature import InputKind
21+
from torch.fx import GraphModule, Node
22+
from torch.fx.node import Argument
23+
24+
ConvScaleMatch = tuple[Node, torch.Tensor, Optional[Node]]
25+
26+
27+
class FoldScalarMulIntoConvPass(ArmPass):
28+
"""Fold constant output-channel scaling after convolution.
29+
30+
Rewrites patterns equivalent to ``conv(x, weight, bias, ...) * scale``
31+
to a convolution with scaled weight and bias constants. ``scale`` may be
32+
a Python scalar, a scalar tensor constant, or a tensor constant
33+
broadcastable over the convolution output with only the channel dimension
34+
non-unit.
35+
36+
"""
37+
38+
_passes_required_after: Set[Type[ExportPass]] = set()
39+
40+
_CONV_OPS = {
41+
torch.ops.aten.conv2d.default,
42+
torch.ops.aten.convolution.default,
43+
exir_ops.edge.aten.convolution.default,
44+
}
45+
_MUL_TENSOR_OPS = {
46+
torch.ops.aten.mul.Tensor,
47+
exir_ops.edge.aten.mul.Tensor,
48+
}
49+
_MUL_SCALAR_OPS = {
50+
torch.ops.aten.mul.Scalar,
51+
exir_ops.edge.aten.mul.Scalar,
52+
}
53+
54+
def __init__(
55+
self,
56+
exported_program: Optional[torch.export.ExportedProgram] = None,
57+
*args,
58+
**kwargs,
59+
) -> None:
60+
super().__init__(*args, **kwargs)
61+
self.exported_program = exported_program
62+
63+
def call(self, graph_module: GraphModule) -> PassResult:
64+
graph = graph_module.graph
65+
modified = False
66+
constant_placeholders_to_delete: set[Node] = set()
67+
68+
for node in list(graph.nodes):
69+
if not self.allowed_to_transform(node.meta):
70+
continue
71+
match = self._match_mul_after_conv(node)
72+
if match is None:
73+
continue
74+
75+
conv_node, scale, scale_node = match
76+
folded_placeholders = self._fold_scale_into_conv(
77+
graph, conv_node, node, scale
78+
)
79+
if folded_placeholders is not None:
80+
constant_placeholders_to_delete.update(folded_placeholders)
81+
if scale_node is not None:
82+
constant_placeholders_to_delete.add(scale_node)
83+
modified = True
84+
85+
if modified:
86+
graph.eliminate_dead_code()
87+
if self.exported_program is not None:
88+
for constant_placeholder in constant_placeholders_to_delete:
89+
if len(constant_placeholder.users) == 0:
90+
delete_constant_placeholder(
91+
self.exported_program, constant_placeholder
92+
)
93+
graph.lint()
94+
graph_module.recompile()
95+
96+
return PassResult(graph_module, modified)
97+
98+
def _match_mul_after_conv(self, node: Node) -> Optional[ConvScaleMatch]:
99+
if node.op != "call_function":
100+
return None
101+
102+
candidates: tuple[tuple[Argument, Argument], ...]
103+
if node.target in self._MUL_TENSOR_OPS:
104+
lhs, rhs = node.args[:2]
105+
candidates = ((lhs, rhs), (rhs, lhs))
106+
elif node.target in self._MUL_SCALAR_OPS:
107+
candidates = ((node.args[0], node.args[1]),)
108+
else:
109+
return None
110+
111+
for conv_candidate, scale_candidate in candidates:
112+
if not isinstance(conv_candidate, Node):
113+
continue
114+
if not self._is_foldable_conv(conv_candidate, node):
115+
continue
116+
scale = self._get_scale_tensor(scale_candidate)
117+
if scale is None:
118+
continue
119+
channel_scale = self._get_channel_scale(conv_candidate, scale)
120+
if channel_scale is None:
121+
continue
122+
scale_node = scale_candidate if isinstance(scale_candidate, Node) else None
123+
return conv_candidate, channel_scale, scale_node
124+
125+
return None
126+
127+
def _is_foldable_conv(self, conv_node: Node, mul_node: Node) -> bool:
128+
is_call_function = conv_node.op == "call_function"
129+
is_convolution = conv_node.target in self._CONV_OPS
130+
if not (is_call_function and is_convolution):
131+
return False
132+
if len(conv_node.users) != 1 or mul_node not in conv_node.users:
133+
return False
134+
if conv_node.target == torch.ops.aten.conv2d.default:
135+
return len(conv_node.args) >= 3
136+
if len(conv_node.args) < 9:
137+
return False
138+
transposed = bool(conv_node.args[6])
139+
return not transposed
140+
141+
def _get_scale_tensor(self, scale) -> Optional[torch.Tensor]:
142+
if isinstance(scale, (int, float)):
143+
return torch.tensor(float(scale), dtype=torch.float32)
144+
if not isinstance(scale, Node):
145+
return None
146+
try:
147+
scale_tensor = self._get_constant_tensor(scale)
148+
except RuntimeError:
149+
return None
150+
if scale_tensor is None:
151+
return None
152+
if not torch.is_floating_point(scale_tensor):
153+
return None
154+
return scale_tensor.detach()
155+
156+
def _get_channel_scale(
157+
self, conv_node: Node, scale: torch.Tensor
158+
) -> Optional[torch.Tensor]:
159+
output_val = conv_node.meta.get("val")
160+
if output_val is None or not hasattr(output_val, "shape"):
161+
return None
162+
output_shape = tuple(output_val.shape)
163+
if len(output_shape) < 2:
164+
return None
165+
out_channels = int(output_shape[1])
166+
167+
if scale.numel() == 1:
168+
return scale.reshape(1).expand(out_channels).clone()
169+
170+
if scale.numel() != out_channels:
171+
return None
172+
173+
scale_shape = tuple(scale.shape)
174+
if len(scale_shape) > len(output_shape):
175+
return None
176+
177+
rank_padding = (1,) * (len(output_shape) - len(scale_shape))
178+
padded_shape = rank_padding + scale_shape
179+
for dim, size in enumerate(padded_shape):
180+
if dim == 1:
181+
if int(size) not in (1, out_channels):
182+
return None
183+
elif int(size) != 1:
184+
return None
185+
186+
return scale.reshape(out_channels)
187+
188+
def _fold_scale_into_conv( # noqa: C901
189+
self,
190+
graph: torch.fx.Graph,
191+
conv_node: Node,
192+
mul_node: Node,
193+
channel_scale: torch.Tensor,
194+
) -> Optional[set[Node]]:
195+
weight_node = conv_node.args[1]
196+
bias_node = conv_node.args[2]
197+
if not isinstance(weight_node, Node):
198+
return None
199+
if bias_node is not None and not isinstance(bias_node, Node):
200+
return None
201+
202+
try:
203+
weight = self._get_constant_tensor(weight_node)
204+
bias = (
205+
self._get_constant_tensor(bias_node)
206+
if isinstance(bias_node, Node)
207+
else None
208+
)
209+
except RuntimeError:
210+
return None
211+
212+
if weight is None:
213+
return None
214+
if not torch.is_floating_point(weight):
215+
return None
216+
if bias is not None and not torch.is_floating_point(bias):
217+
return None
218+
219+
scale = channel_scale.to(device=weight.device, dtype=weight.dtype)
220+
scaled_weight = weight.detach().clone() * scale.reshape(
221+
(-1,) + (1,) * (weight.dim() - 1)
222+
)
223+
scaled_bias = None
224+
if bias is not None:
225+
scaled_bias = bias.detach().clone() * scale.to(
226+
device=bias.device, dtype=bias.dtype
227+
)
228+
229+
if self.exported_program is None:
230+
if not self._can_mutate_get_attr(weight_node, conv_node):
231+
return None
232+
if isinstance(bias_node, Node) and not self._can_mutate_get_attr(
233+
bias_node, conv_node
234+
):
235+
return None
236+
if not self._set_get_attr_tensor(weight_node, scaled_weight):
237+
return None
238+
if isinstance(bias_node, Node) and scaled_bias is not None:
239+
if not self._set_get_attr_tensor(bias_node, scaled_bias):
240+
return None
241+
scaled_weight_node = weight_node
242+
scaled_bias_node = bias_node
243+
folded_placeholders = set()
244+
else:
245+
weight_kind = self._constant_kind(weight_node, InputKind.PARAMETER)
246+
bias_kind = (
247+
self._constant_kind(bias_node, InputKind.PARAMETER)
248+
if isinstance(bias_node, Node)
249+
else InputKind.PARAMETER
250+
)
251+
252+
with graph.inserting_before(weight_node):
253+
scaled_weight_node = create_constant_placeholder(
254+
exp_program=self.exported_program,
255+
graph=graph,
256+
kind=weight_kind,
257+
name=f"{weight_node.name}_folded_mul",
258+
data=scaled_weight,
259+
)
260+
scaled_bias_node = None
261+
if scaled_bias is not None:
262+
assert isinstance(bias_node, Node)
263+
with graph.inserting_before(bias_node):
264+
scaled_bias_node = create_constant_placeholder(
265+
exp_program=self.exported_program,
266+
graph=graph,
267+
kind=bias_kind,
268+
name=f"{bias_node.name}_folded_mul",
269+
data=scaled_bias,
270+
)
271+
272+
folded_placeholders = {weight_node}
273+
if isinstance(bias_node, Node):
274+
folded_placeholders.add(bias_node)
275+
276+
conv_node.args = (
277+
conv_node.args[0],
278+
scaled_weight_node,
279+
scaled_bias_node,
280+
*conv_node.args[3:],
281+
)
282+
mul_node.replace_all_uses_with(conv_node)
283+
graph.erase_node(mul_node)
284+
return folded_placeholders
285+
286+
def _get_constant_tensor(self, node: Node) -> Optional[torch.Tensor]:
287+
if self.exported_program is not None:
288+
return get_param_tensor(self.exported_program, node)
289+
if node.op != "get_attr":
290+
return None
291+
owning_module = node.graph.owning_module
292+
if owning_module is None:
293+
return None
294+
target = str(node.target)
295+
tensor: torch.Tensor | None
296+
try:
297+
tensor = owning_module.get_parameter(target)
298+
except AttributeError:
299+
try:
300+
tensor = owning_module.get_buffer(target)
301+
except AttributeError:
302+
tensor = getattr(owning_module, target, None)
303+
return tensor if isinstance(tensor, torch.Tensor) else None
304+
305+
def _can_mutate_get_attr(self, node: Node, conv_node: Node) -> bool:
306+
return node.op == "get_attr" and set(node.users) == {conv_node}
307+
308+
def _set_get_attr_tensor(self, node: Node, tensor: torch.Tensor) -> bool:
309+
owning_module = node.graph.owning_module
310+
if owning_module is None:
311+
return False
312+
target = str(node.target)
313+
attr_tensor: torch.Tensor | None
314+
try:
315+
attr_tensor = owning_module.get_parameter(target)
316+
except AttributeError:
317+
try:
318+
attr_tensor = owning_module.get_buffer(target)
319+
except AttributeError:
320+
attr_tensor = getattr(owning_module, target, None)
321+
if not isinstance(attr_tensor, torch.Tensor):
322+
return False
323+
with torch.no_grad():
324+
attr_tensor.copy_(
325+
tensor.to(device=attr_tensor.device, dtype=attr_tensor.dtype)
326+
)
327+
return True
328+
329+
def _constant_kind(self, node: Node, default: InputKind) -> InputKind:
330+
if self.exported_program is None:
331+
return default
332+
try:
333+
return get_constant_placeholder_kind(self.exported_program, node)
334+
except RuntimeError:
335+
return default

0 commit comments

Comments
 (0)