Skip to content

Commit 1e3b7fb

Browse files
Arm backend: Support rank-3 max_pool2d inputs (pytorch#22441)
Normalize unbatched max_pool2d inputs from [C, H, W] to [1, C, H, W] before decomposition, then restore the rank-3 output. Enable rank-3 support checks and add FP, INT, and VGF test coverage, including quantized VGF execution. Change-Id: Ib8852053b5248601d59685669523079fa19c0955 Signed-off-by: Yufeng Shi <yufeng.shi@arm.com>
1 parent c7bbe0b commit 1e3b7fb

6 files changed

Lines changed: 217 additions & 4 deletions

File tree

‎backends/arm/_passes/__init__.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,9 @@
170170
from .normalize_index_put_none_indices_pass import ( # noqa
171171
NormalizeIndexPutNoneIndicesPass,
172172
)
173+
from .normalize_max_pool2d_input_rank_pass import ( # noqa
174+
NormalizeMaxPool2dInputRankPass,
175+
)
173176
from .normalize_while_initial_args_pass import NormalizeWhileInitialArgsPass # noqa
174177
from .promote_bool_operands_pass import PromoteBoolOperandsPass # noqa
175178
from .propagate_view_copy_permute_pass import ( # noqa

‎backends/arm/_passes/arm_pass_manager.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
NormalizeDelegateIOLayoutPass,
139139
NormalizeIndexPutBoolIndexTensorPass,
140140
NormalizeIndexPutNoneIndicesPass,
141+
NormalizeMaxPool2dInputRankPass,
141142
NormalizeTransformInputPlaceholdersPass,
142143
NormalizeWhileInitialArgsPass,
143144
PromoteBoolOperandsPass,
@@ -637,6 +638,7 @@ def _tosa_pipeline(
637638
UnsqueezeBeforeRepeatPass(),
638639
DecomposeCumsumPass(exported_program),
639640
DecomposeAsStridedCopyPass(),
641+
NormalizeMaxPool2dInputRankPass(),
640642
DecomposeMaxPool2dPass(),
641643
DecomposeLargeStrideMaxPool2dForU55Pass(),
642644
SizeAdjustInputPass(),
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 Set, Type
7+
8+
import torch
9+
from executorch.backends.arm._passes import ArmOpTargetedPass
10+
from executorch.backends.arm._passes.arm_pass_utils import (
11+
create_node,
12+
get_first_fake_tensor,
13+
)
14+
from executorch.backends.arm._passes.convert_squeezes_to_view import (
15+
ConvertSqueezesToViewPass,
16+
)
17+
from executorch.backends.arm._passes.decompose_maxpool2d_with_dilation_pass import (
18+
DecomposeMaxPool2dPass,
19+
)
20+
from executorch.backends.arm._passes.rewrite_max_pool2d_pass import RewriteMaxPool2dPass
21+
from executorch.backends.arm._passes.size_adjust_input_pass import SizeAdjustInputPass
22+
from executorch.exir.dialects._ops import ops as exir_ops
23+
from executorch.exir.pass_base import ExportPass, PassResult
24+
from torch.fx import Node
25+
26+
27+
class NormalizeMaxPool2dInputRankPass(ArmOpTargetedPass):
28+
"""Normalize unbatched rank-3 max_pool2d inputs to rank 4.
29+
30+
Unsqueeze inputs from ``[C, H, W]`` to ``[1, C, H, W]``.
31+
32+
Squeeze the leading dimension after pooling to restore the rank-3 output.
33+
34+
The complete shape transformation is::
35+
36+
[C, H, W]
37+
-> unsqueeze(0) -> [1, C, H, W]
38+
-> max_pool2d -> [1, C, H_out, W_out]
39+
-> squeeze(0) -> [C, H_out, W_out]
40+
41+
"""
42+
43+
target_ops = (exir_ops.edge.aten.max_pool2d.default,)
44+
_passes_required_after: Set[Type[ExportPass]] = {
45+
ConvertSqueezesToViewPass,
46+
DecomposeMaxPool2dPass,
47+
RewriteMaxPool2dPass,
48+
SizeAdjustInputPass,
49+
}
50+
51+
def call(self, graph_module: torch.fx.GraphModule) -> PassResult:
52+
graph = graph_module.graph
53+
modified = False
54+
pool_nodes = graph.find_nodes(op="call_function", target=self.target_ops[0])
55+
56+
for pool_node in pool_nodes:
57+
input_node = pool_node.args[0]
58+
if not isinstance(input_node, Node):
59+
raise RuntimeError("Expected max_pool2d input to be a node")
60+
61+
input_fake = get_first_fake_tensor(input_node)
62+
if input_fake.dim() != 3:
63+
continue
64+
65+
output_fake = get_first_fake_tensor(pool_node)
66+
with graph.inserting_before(pool_node):
67+
unsqueeze = create_node(
68+
graph,
69+
exir_ops.edge.aten.unsqueeze_copy.default,
70+
args=(input_node, 0),
71+
from_node=pool_node,
72+
inherit_qparams=False,
73+
)
74+
unsqueeze.meta["val"] = input_fake.unsqueeze(0)
75+
pool_node.replace_input_with(input_node, unsqueeze)
76+
77+
pool_node.meta["val"] = output_fake.unsqueeze(0)
78+
original_users = list(pool_node.users)
79+
with graph.inserting_after(pool_node):
80+
squeeze = create_node(
81+
graph,
82+
exir_ops.edge.aten.squeeze_copy.dims,
83+
args=(pool_node, [0]),
84+
from_node=pool_node,
85+
inherit_qparams=False,
86+
)
87+
squeeze.meta["val"] = output_fake
88+
for user in original_users:
89+
user.replace_input_with(pool_node, squeeze)
90+
91+
modified = True
92+
93+
if modified:
94+
graph.lint()
95+
graph_module.recompile()
96+
97+
return PassResult(graph_module, modified)

‎backends/arm/operator_support/pool_2d_support.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,14 @@ def is_node_tosa_supported(self, node: fx.Node, tosa_spec: TosaSpecification):
228228
"""
229229

230230
shape = cast(torch.Tensor, node.all_input_nodes[0].meta["val"]).shape
231+
if len(shape) == 3:
232+
shape = torch.Size((1, *shape))
233+
elif len(shape) != 4:
234+
self.reporter.report_reject(
235+
node, f"Maxpool2d needs rank 3 or 4 input, got shape {list(shape)}"
236+
)
237+
return False
238+
231239
kernel = cast(tuple[int, int], node.args[1])
232240
stride = cast(tuple[int, int], node.args[2])
233241
padding = cast(tuple[int, int], node.args[3]) if len(node.args) >= 4 else (0, 0)

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

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,12 @@
8282
[3, 2, 1],
8383
),
8484
}
85+
86+
test_data_suite_rank3 = {
87+
"bev_class_slice": lambda: (torch.rand(1, 8, 8), [1, 1, 0]),
88+
"spatial_pool": lambda: (torch.rand(3, 9, 11), [3, 2, 1]),
89+
}
90+
8591
test_data_suite_fp8 = {
8692
"rand_fp8e4m3": lambda: (
8793
torch.rand(1, 8, 20, 20).to(torch.float8_e4m3fn),
@@ -168,7 +174,11 @@ def forward(self, x):
168174

169175

170176
@common.parametrize(
171-
"test_data", test_data_suite | test_data_suite_fp16 | test_data_suite_bf16
177+
"test_data",
178+
test_data_suite
179+
| test_data_suite_rank3
180+
| test_data_suite_fp16
181+
| test_data_suite_bf16,
172182
)
173183
def test_max_pool2d_tosa_FP(test_data: torch.Tensor):
174184
test_data, model_params = test_data()
@@ -197,7 +207,7 @@ def test_max_pool2d_tosa_FP_fp8(test_data: torch.Tensor):
197207
pipeline.run()
198208

199209

200-
@common.parametrize("test_data", test_data_suite)
210+
@common.parametrize("test_data", test_data_suite | test_data_suite_rank3)
201211
def test_max_pool2d_tosa_INT(test_data: torch.Tensor):
202212
test_data, model_params = test_data()
203213
pipeline = TosaPipelineINT[input_t1](
@@ -374,22 +384,28 @@ def test_max_pool2d_tosa_INT_dilation(test_data):
374384

375385
# VGF tests
376386
@common.parametrize(
377-
"test_data", test_data_suite | test_data_suite_bf16 | test_data_suite_fp16
387+
"test_data",
388+
test_data_suite
389+
| test_data_suite_rank3
390+
| test_data_suite_bf16
391+
| test_data_suite_fp16,
378392
)
379393
@common.SkipIfNoModelConverter
380394
def test_max_pool2d_vgf_no_quant(test_data: torch.Tensor):
381395
test_data, model_params = test_data()
396+
run_on_vulkan_runtime = test_data.dim() == 4
382397
pipeline = VgfPipeline[input_t1](
383398
MaxPool2d(*model_params),
384399
(test_data,),
385400
aten_op,
386401
exir_op,
387402
quantize=False,
403+
run_on_vulkan_runtime=run_on_vulkan_runtime,
388404
)
389405
pipeline.run()
390406

391407

392-
@common.parametrize("test_data", test_data_suite)
408+
@common.parametrize("test_data", test_data_suite | test_data_suite_rank3)
393409
@common.SkipIfNoModelConverter
394410
def test_max_pool2d_vgf_quant(test_data: torch.Tensor):
395411
test_data, model_params = test_data()
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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 Tuple
7+
8+
import torch
9+
from executorch.backends.arm._passes import (
10+
NormalizeMaxPool2dInputRankPass,
11+
RemoveGetItemPass,
12+
)
13+
from executorch.backends.arm.test.tester.test_pipeline import PassPipeline
14+
from executorch.backends.test.harness.stages import StageType
15+
from executorch.exir.dialects._ops import ops as exir_ops
16+
17+
18+
input_t = Tuple[torch.Tensor]
19+
20+
21+
class MaxPool2d(torch.nn.Module):
22+
def forward(self, x: torch.Tensor) -> torch.Tensor:
23+
return torch.nn.functional.max_pool2d(
24+
x,
25+
kernel_size=(3, 2),
26+
stride=(2, 1),
27+
padding=(1, 0),
28+
dilation=(1, 1),
29+
ceil_mode=True,
30+
)
31+
32+
33+
def test_normalize_rank3_max_pool2d_input() -> None:
34+
pipeline = PassPipeline[input_t](
35+
MaxPool2d(),
36+
(torch.rand(3, 9, 11),),
37+
ops_before_pass={
38+
"executorch_exir_dialects_edge__ops_aten_max_pool2d_with_indices_default": 1,
39+
},
40+
ops_after_pass={
41+
"executorch_exir_dialects_edge__ops_aten_unsqueeze_copy_default": 1,
42+
"executorch_exir_dialects_edge__ops_aten_max_pool2d_default": 1,
43+
"executorch_exir_dialects_edge__ops_aten_squeeze_copy_dims": 1,
44+
},
45+
pass_list=[RemoveGetItemPass, NormalizeMaxPool2dInputRankPass],
46+
)
47+
pipeline.run()
48+
49+
exported_program = pipeline.tester.get_artifact(
50+
StageType.RUN_PASSES
51+
).exported_program()
52+
pool_node = next(
53+
node
54+
for node in exported_program.graph.nodes
55+
if node.target == exir_ops.edge.aten.max_pool2d.default
56+
)
57+
unsqueeze_node = pool_node.args[0]
58+
assert isinstance(unsqueeze_node, torch.fx.Node)
59+
assert unsqueeze_node.target == exir_ops.edge.aten.unsqueeze_copy.default
60+
assert unsqueeze_node.args[1] == 0
61+
assert tuple(pool_node.args[1]) == (3, 2)
62+
assert tuple(pool_node.args[2]) == (2, 1)
63+
assert tuple(pool_node.args[3]) == (1, 0)
64+
assert tuple(pool_node.args[4]) == (1, 1)
65+
assert pool_node.args[5] is True
66+
67+
squeeze_node = next(iter(pool_node.users))
68+
assert squeeze_node.target == exir_ops.edge.aten.squeeze_copy.dims
69+
assert squeeze_node.args == (pool_node, [0])
70+
71+
72+
def test_normalize_rank4_max_pool2d_input_is_noop() -> None:
73+
PassPipeline[input_t](
74+
MaxPool2d(),
75+
(torch.rand(1, 3, 9, 11),),
76+
ops_before_pass={
77+
"executorch_exir_dialects_edge__ops_aten_max_pool2d_with_indices_default": 1,
78+
},
79+
ops_after_pass={
80+
"executorch_exir_dialects_edge__ops_aten_max_pool2d_default": 1,
81+
},
82+
ops_not_after_pass=[
83+
"executorch_exir_dialects_edge__ops_aten_unsqueeze_copy_default",
84+
"executorch_exir_dialects_edge__ops_aten_squeeze_copy_dims",
85+
],
86+
pass_list=[RemoveGetItemPass, NormalizeMaxPool2dInputRankPass],
87+
).run()

0 commit comments

Comments
 (0)