Skip to content

Commit 4866659

Browse files
authored
Fix grouped conv1d conversion after decomposition (pytorch#22213)
Allow callers to configure graph-local weight producers that are safe to convert. Configure Arm to accept the weight slices created by grouped convolution decomposition. Fix test_conv1d_groups for the arm_tosa_fp and arm_tosa_int flows. Add TOSA FP and INT coverage for per-tensor and per-channel quantization. Signed-off-by: Zingo Andersen <Zingo.Andersen@arm.com>
1 parent c27baa8 commit 4866659

4 files changed

Lines changed: 171 additions & 1 deletion

File tree

‎backends/arm/_passes/conv1d_unsqueeze_pass.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from executorch.backends.transforms.convert_conv1d_to_conv2d_pass import (
1717
ConvertConv1dToConv2dPass,
1818
)
19+
from executorch.exir import ExportedProgram
1920
from executorch.exir.dialects._ops import ops as exir_ops
2021
from executorch.exir.pass_base import ExportPass
2122

@@ -29,3 +30,12 @@ class Conv1dUnsqueezePass(ConvertConv1dToConv2dPass, ArmOpTargetedPass):
2930
SizeAdjustInputPass,
3031
}
3132
target_ops = (exir_ops.edge.aten.convolution.default,)
33+
34+
def __init__(self, exported_program: ExportedProgram) -> None:
35+
# Grouped-convolution decomposition creates one graph-local weight
36+
# slice per group. Allow the shared pass to add the unit-height
37+
# dimension after these producers.
38+
super().__init__(
39+
exported_program,
40+
graph_local_weight_targets={exir_ops.edge.aten.slice_copy.Tensor},
41+
)

‎backends/arm/test/ops/test_conv1d.py‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,20 @@ def forward(self, x):
217217
batches=1,
218218
)
219219

220+
# This is grouped but not depthwise: each group consumes two input channels.
221+
# Arm therefore decomposes it into three ordinary convolutions whose weights
222+
# are graph-local slices rather than lifted model parameters.
223+
conv1d_6_6x3x50_groups3 = Conv1d(
224+
in_channels=6,
225+
out_channels=6,
226+
kernel_size=3,
227+
stride=1,
228+
padding=0,
229+
groups=3,
230+
length=50,
231+
batches=4,
232+
)
233+
220234
conv1d_7_1x3x16_st2_pd1_dl2 = Conv1d(
221235
in_channels=3,
222236
out_channels=3,
@@ -318,6 +332,42 @@ def test_convolution_1d_tosa_FP(test_data):
318332
pipeline.run()
319333

320334

335+
def test_convolution_1d_grouped_tosa_FP():
336+
"""Ensure sliced grouped-Conv1d weights are converted in the FP flow."""
337+
pipeline = TosaPipelineFP[input_t](
338+
conv1d_6_6x3x50_groups3,
339+
conv1d_6_6x3x50_groups3.get_inputs(),
340+
aten_op,
341+
exir_op,
342+
)
343+
pipeline.run()
344+
345+
346+
@common.parametrize(
347+
"per_channel_quantization", {"per_channel": True, "per_tensor": False}
348+
)
349+
def test_convolution_1d_grouped_tosa_INT(per_channel_quantization):
350+
"""Ensure sliced grouped-Conv1d weights are converted in the INT flow.
351+
352+
The decomposition must preserve both per-channel and per-tensor weight
353+
quantization while converting each three-dimensional slice for Conv2d.
354+
355+
Args:
356+
per_channel_quantization (bool): Whether weights use per-channel
357+
instead of per-tensor quantization.
358+
359+
"""
360+
pipeline = TosaPipelineINT[input_t](
361+
conv1d_6_6x3x50_groups3,
362+
conv1d_6_6x3x50_groups3.get_inputs(),
363+
aten_op,
364+
exir_op,
365+
per_channel_quantization=per_channel_quantization,
366+
qtol=1,
367+
)
368+
pipeline.run()
369+
370+
321371
@common.parametrize("test_data", test_data_INT)
322372
def test_convolution_1d_tosa_INT(test_data):
323373
model, per_channel_quantization = test_data()

‎backends/transforms/convert_conv1d_to_conv2d_pass.py‎

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
# This source code is licensed under the BSD-style license found in the
66
# LICENSE file in the root directory of this source tree.
77

8+
from collections.abc import Collection
9+
810
import torch
911

1012
from executorch.backends.transforms.utils import (
@@ -25,13 +27,30 @@ class ConvertConv1dToConv2dPass(ExportPass):
2527
are left unchanged.
2628
Weights passed across control-flow boundaries are unsqueezed inside the
2729
nested graph so its input signature remains unchanged.
30+
Root graph-local weights are skipped by default. Backends may provide an
31+
allow-list of safe producer targets when constructing the pass.
2832
The pass emits squeeze and unsqueeze boundaries for each converted
2933
convolution; downstream view cleanup can fold adjacent boundaries.
3034
"""
3135

32-
def __init__(self, exported_program: ExportedProgram) -> None:
36+
def __init__(
37+
self,
38+
exported_program: ExportedProgram,
39+
graph_local_weight_targets: Collection[torch.fx.node.Target] = (),
40+
) -> None:
41+
"""Initialize the transform.
42+
43+
Args:
44+
exported_program (ExportedProgram): Program containing the graph
45+
and its lifted weights.
46+
graph_local_weight_targets (Collection[torch.fx.node.Target]):
47+
Producer targets whose three-dimensional outputs may be used
48+
as Conv1d weights. All graph-local weights are skipped when
49+
this is empty.
50+
"""
3351
super().__init__()
3452
self.exported_program = exported_program
53+
self._graph_local_weight_targets = frozenset(graph_local_weight_targets)
3554

3655
def _unsqueeze_weight(self, weight_node: torch.fx.Node) -> bool:
3756
weight = get_param_tensor(self.exported_program, weight_node)
@@ -182,6 +201,59 @@ def _convert_nested_graph(self, graph_module: torch.fx.GraphModule) -> bool:
182201
graph_module.recompile()
183202
return modified
184203

204+
def _convert_graph_local_weights(self, graph: torch.fx.Graph) -> bool:
205+
"""Convert supported root Conv1d nodes with graph-local weights.
206+
207+
A graph-local weight is produced by another graph operation rather
208+
than stored as a model parameter. Callers choose which producers are
209+
safe to convert. The original weight remains unchanged; an
210+
unsqueeze is inserted between the producer and the convolution.
211+
212+
Args:
213+
graph (torch.fx.Graph): Root graph whose Conv1d nodes are examined.
214+
215+
Returns:
216+
bool: True if at least one Conv1d node was converted.
217+
"""
218+
modified = False
219+
for node in list(graph.nodes):
220+
if not self._is_conv1d(node):
221+
continue
222+
223+
input_node, weight_node = node.args[:2]
224+
if not isinstance(input_node, torch.fx.Node) or not isinstance(
225+
weight_node, torch.fx.Node
226+
):
227+
continue
228+
229+
weight_meta = weight_node.meta.get("val")
230+
weight_source_is_allowed = (
231+
weight_node.target in self._graph_local_weight_targets
232+
)
233+
has_conv1d_weight_shape = (
234+
isinstance(weight_meta, torch.Tensor) and weight_meta.dim() == 3
235+
)
236+
if not weight_source_is_allowed or not has_conv1d_weight_shape:
237+
continue
238+
239+
# The weight producer must keep its original output shape. Insert
240+
# the missing unit-height dimension only on the path to this conv:
241+
#
242+
# graph-local weight [O, I, K]
243+
# |
244+
# unsqueeze(2)
245+
# v
246+
# Conv2d weight [O, I, 1, K]
247+
self._convert_node(
248+
graph,
249+
node,
250+
input_node,
251+
weight_node,
252+
unsqueeze_weight=True,
253+
)
254+
modified = True
255+
return modified
256+
185257
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
186258
graph = graph_module.graph
187259
is_root = graph_module is self.exported_program.graph_module
@@ -229,6 +301,12 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
229301
)
230302
modified = True
231303

304+
# Some backends create graph-local weights known to be safe before this
305+
# pass. A caller may opt those weights into the same local unsqueeze
306+
# used by nested graphs without enabling arbitrary dynamic weights.
307+
if is_root:
308+
modified = self._convert_graph_local_weights(graph) or modified
309+
232310
# The normal entry point invokes this call method for the root graph.
233311
# Explicitly apply the rewrite to its child control-flow GraphModules.
234312
if is_root:

‎backends/transforms/test/test_convert_conv1d_to_conv2d_pass.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,38 @@ def test_skip_unfolded_qdq_weight():
306306
assert not result.modified
307307

308308

309+
@pytest.mark.parametrize("allow_slice_weight", [False, True])
310+
def test_graph_local_weight_target_opt_in(allow_slice_weight: bool):
311+
model = Conv1d().eval()
312+
x = torch.randn(1, 2, 8)
313+
edge = _edge(model, (x,))
314+
[conv] = [
315+
node
316+
for node in edge.graph.nodes
317+
if node.target == exir_ops.edge.aten.convolution.default
318+
]
319+
weight = conv.args[1]
320+
with edge.graph.inserting_before(conv):
321+
sliced_weight = edge.graph.call_function(
322+
exir_ops.edge.aten.slice_copy.Tensor,
323+
args=(weight, 0, 0, weight.meta["val"].shape[0]),
324+
)
325+
sliced_weight.meta["val"] = weight.meta["val"]
326+
conv.replace_input_with(weight, sliced_weight)
327+
328+
allowed_targets = (
329+
{exir_ops.edge.aten.slice_copy.Tensor} if allow_slice_weight else set()
330+
)
331+
result = ConvertConv1dToConv2dPass(
332+
edge, graph_local_weight_targets=allowed_targets
333+
).call(edge.graph_module)
334+
335+
assert result.modified is allow_slice_weight
336+
if allow_slice_weight:
337+
assert conv.args[1].target == exir_ops.edge.aten.unsqueeze_copy.default
338+
torch.testing.assert_close(edge.module()(x), model(x))
339+
340+
309341
def test_preserve_convolution_metadata():
310342
edge = _edge(Conv1d(), (torch.randn(1, 2, 8),))
311343
[conv] = [

0 commit comments

Comments
 (0)