Skip to content

Commit 897fc8e

Browse files
authored
Move split/concat chain folding to OSS (pytorch#22628)
Summary: Move `MergeSplitConcatChainPass` into `executorch.backends.transforms` package and implement it as a standard `PassBase`. The pass recognizes complete, ordered outputs from ATen `chunk`, `split`, and `split_with_sizes`, as well as edge `split_with_sizes_copy`, followed by `cat`. Replace the chain with `view_copy` only when the input is contiguous, element counts match, and every axis crossed between the split and concat dimensions is singleton. This preserves flattened storage order for both pre-edge and post-edge consumers. Reviewed By: DrJessop Differential Revision: D119242414 Pull Request resolved: pytorch#22628
1 parent 3413fdf commit 897fc8e

3 files changed

Lines changed: 374 additions & 0 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
from __future__ import annotations
8+
9+
import operator
10+
from collections.abc import Callable, Sequence, Set
11+
12+
import torch
13+
from executorch.backends.transforms.permute_pass_utils import get_arg
14+
from executorch.exir.dialects._ops import ops as exir_ops
15+
from executorch.exir.pass_base import PassResult
16+
from torch.fx import GraphModule, Node
17+
from torch.fx.passes.infra.pass_base import PassBase
18+
19+
20+
_RAW_SPLIT_TARGETS: frozenset[Callable[..., object]] = frozenset(
21+
{
22+
torch.ops.aten.chunk.default,
23+
torch.ops.aten.split.Tensor,
24+
torch.ops.aten.split_with_sizes.default,
25+
}
26+
)
27+
_EDGE_SPLIT_TARGETS: frozenset[Callable[..., object]] = frozenset(
28+
{exir_ops.edge.aten.split_with_sizes_copy.default}
29+
)
30+
31+
32+
def _normalize_dim(dim: int, rank: int) -> int:
33+
normalized = dim + rank if dim < 0 else dim
34+
assert 0 <= normalized < rank
35+
return normalized
36+
37+
38+
def _ordered_split_inputs(
39+
cat_node: Node,
40+
split_targets: Set[Callable[..., object]],
41+
) -> tuple[Node, list[Node]] | None:
42+
cat_inputs = get_arg(cat_node, "tensors")
43+
if not isinstance(cat_inputs, Sequence) or not cat_inputs:
44+
return None
45+
46+
getitem_nodes: list[Node] = []
47+
for inp in cat_inputs:
48+
if not isinstance(inp, Node) or inp.target != operator.getitem:
49+
return None
50+
getitem_nodes.append(inp)
51+
52+
split_node = getitem_nodes[0].args[0]
53+
if not isinstance(split_node, Node) or split_node.target not in split_targets:
54+
return None
55+
56+
split_outputs = split_node.meta["val"]
57+
if not isinstance(split_outputs, (tuple, list)) or len(getitem_nodes) != len(
58+
split_outputs
59+
):
60+
return None
61+
for index, getitem_node in enumerate(getitem_nodes):
62+
if getitem_node.args[0] != split_node or getitem_node.args[1] != index:
63+
return None
64+
return split_node, getitem_nodes
65+
66+
67+
def _is_view_equivalent(
68+
cat_node: Node,
69+
split_node: Node,
70+
getitem_nodes: Sequence[Node],
71+
) -> bool:
72+
split_input = get_arg(split_node, "input", Node)
73+
input_val = split_input.meta["val"]
74+
output_val = cat_node.meta["val"]
75+
if not isinstance(input_val, torch.Tensor) or not isinstance(
76+
output_val, torch.Tensor
77+
):
78+
return False
79+
if not input_val.is_contiguous() or input_val.numel() != output_val.numel():
80+
return False
81+
82+
rank = input_val.ndim
83+
split_dim = _normalize_dim(get_arg(split_node, "dim", int), rank)
84+
cat_dim = _normalize_dim(get_arg(cat_node, "dim", int), rank)
85+
86+
first_moved_dim, last_moved_dim = sorted((split_dim, cat_dim))
87+
# Moving split parts across axes preserves flattened storage order only
88+
# when every crossed axis is singleton.
89+
for getitem_node in getitem_nodes:
90+
value = getitem_node.meta["val"]
91+
if not isinstance(value, torch.Tensor) or any(
92+
size != 1 for size in value.shape[first_moved_dim:last_moved_dim]
93+
):
94+
return False
95+
return True
96+
97+
98+
class MergeSplitConcatChainPass(PassBase):
99+
"""Replace view-equivalent split/getitem/cat chains with a view.
100+
101+
For example, splitting a contiguous ``[2, 1, 6, 4, 4]`` tensor into
102+
three parts along dimension 2 and concatenating them along dimension 1
103+
is equivalent to a view with shape ``[2, 3, 2, 4, 4]``.
104+
"""
105+
106+
def _replace_cats(
107+
self,
108+
graph_module: GraphModule,
109+
cat_target: Callable[..., object],
110+
split_targets: Set[Callable[..., object]],
111+
view_target: Callable[..., object],
112+
) -> bool:
113+
modified = False
114+
for cat_node in graph_module.graph.find_nodes(
115+
op="call_function", target=cat_target
116+
):
117+
match = _ordered_split_inputs(cat_node, split_targets)
118+
if match is None:
119+
continue
120+
split_node, getitem_nodes = match
121+
if not _is_view_equivalent(cat_node, split_node, getitem_nodes):
122+
continue
123+
124+
output_val = cat_node.meta["val"]
125+
assert isinstance(output_val, torch.Tensor)
126+
split_input = get_arg(split_node, "input", Node)
127+
with graph_module.graph.inserting_before(cat_node):
128+
replacement_view = graph_module.graph.call_function(
129+
view_target,
130+
(split_input, list(output_val.shape)),
131+
)
132+
replacement_view.meta = cat_node.meta.copy()
133+
cat_node.replace_all_uses_with(replacement_view)
134+
modified = True
135+
return modified
136+
137+
def call(self, graph_module: GraphModule) -> PassResult:
138+
modified = self._replace_cats(
139+
graph_module,
140+
torch.ops.aten.cat.default,
141+
_RAW_SPLIT_TARGETS,
142+
torch.ops.aten.view_copy.default,
143+
)
144+
modified |= self._replace_cats(
145+
graph_module,
146+
exir_ops.edge.aten.cat.default,
147+
_EDGE_SPLIT_TARGETS,
148+
exir_ops.edge.aten.view_copy.default,
149+
)
150+
if modified:
151+
graph_module.graph.eliminate_dead_code()
152+
graph_module.recompile()
153+
return PassResult(graph_module, modified)

‎backends/transforms/targets.bzl‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,30 @@ def define_common_targets():
521521
],
522522
)
523523

524+
runtime.python_library(
525+
name = "merge_split_concat_chain",
526+
srcs = ["merge_split_concat_chain.py"],
527+
visibility = ["PUBLIC"],
528+
deps = [
529+
":permute_pass_utils",
530+
"//caffe2:torch",
531+
"//executorch/exir:pass_base",
532+
"//executorch/exir/dialects:lib",
533+
],
534+
)
535+
536+
runtime.python_test(
537+
name = "test_merge_split_concat_chain",
538+
srcs = ["test/test_merge_split_concat_chain.py"],
539+
deps = [
540+
":merge_split_concat_chain",
541+
"//caffe2:torch",
542+
"//executorch/backends/test:graph_builder",
543+
"//executorch/exir:pass_base",
544+
"//executorch/exir/dialects:lib",
545+
],
546+
)
547+
524548
runtime.python_library(
525549
name = "fuse_cascaded_transpose_or_permute_ops",
526550
srcs = ["fuse_cascaded_transpose_or_permute_ops.py"],
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
from __future__ import annotations
8+
9+
import copy
10+
import operator
11+
import unittest
12+
from collections.abc import Callable, Sequence
13+
from typing import cast
14+
15+
import torch
16+
from executorch.backends.test.graph_builder import GraphBuilder
17+
from executorch.backends.transforms.merge_split_concat_chain import (
18+
MergeSplitConcatChainPass,
19+
)
20+
from executorch.exir.dialects._ops import ops as exir_ops
21+
from executorch.exir.pass_base import PassResult
22+
from torch.fx import GraphModule
23+
24+
25+
def _build_split_cat_graph(
26+
input_value: torch.Tensor,
27+
split_target: Callable[..., object],
28+
split_spec: int | list[int],
29+
split_dim: int,
30+
output_count: int,
31+
cat_target: Callable[..., object],
32+
cat_dim: int,
33+
order: Sequence[int] | None = None,
34+
) -> GraphModule:
35+
builder = GraphBuilder()
36+
input_node = builder.placeholder("input", input_value)
37+
split = builder.call_operator(
38+
split_target,
39+
(input_node, split_spec, split_dim),
40+
)
41+
output_order = range(output_count) if order is None else order
42+
split_outputs = [
43+
builder.call_operator(operator.getitem, (split, index))
44+
for index in output_order
45+
]
46+
cat = builder.call_operator(cat_target, (split_outputs, cat_dim))
47+
builder.output([cat])
48+
return builder.get_graph_module()
49+
50+
51+
_VIEW_EQUIVALENT_CASES = (
52+
(
53+
"split",
54+
torch.ops.aten.split.Tensor,
55+
2,
56+
(2, 1, 6, 4, 4),
57+
2,
58+
3,
59+
torch.ops.aten.cat.default,
60+
1,
61+
torch.ops.aten.view_copy.default,
62+
),
63+
(
64+
"chunk",
65+
torch.ops.aten.chunk.default,
66+
3,
67+
(2, 1, 5, 4, 4),
68+
2,
69+
3,
70+
torch.ops.aten.cat.default,
71+
2,
72+
torch.ops.aten.view_copy.default,
73+
),
74+
(
75+
"split_with_sizes",
76+
torch.ops.aten.split_with_sizes.default,
77+
[1, 1, 1, 1],
78+
(1, 4, 3, 2),
79+
1,
80+
4,
81+
torch.ops.aten.cat.default,
82+
0,
83+
torch.ops.aten.view_copy.default,
84+
),
85+
(
86+
"edge_split_with_sizes",
87+
exir_ops.edge.aten.split_with_sizes_copy.default,
88+
[1, 1, 1, 1],
89+
(4, 1, 3, 2),
90+
0,
91+
4,
92+
exir_ops.edge.aten.cat.default,
93+
1,
94+
exir_ops.edge.aten.view_copy.default,
95+
),
96+
)
97+
98+
_UNSAFE_CASES = (
99+
("reordered_outputs", [1, 0, 2, 3], 0, False),
100+
("non_singleton_crossing", [0, 1, 2, 3], 3, False),
101+
("non_contiguous_input", [0, 1, 2, 3], 0, True),
102+
)
103+
104+
_SPLIT_CAT_DIALECTS = (
105+
(
106+
"aten",
107+
torch.ops.aten.split_with_sizes.default,
108+
torch.ops.aten.cat.default,
109+
),
110+
(
111+
"edge",
112+
exir_ops.edge.aten.split_with_sizes_copy.default,
113+
exir_ops.edge.aten.cat.default,
114+
),
115+
)
116+
117+
118+
class MergeSplitConcatChainPassTest(unittest.TestCase):
119+
def test_merges_view_equivalent_chains(self) -> None:
120+
for (
121+
name,
122+
split_target,
123+
split_spec,
124+
input_shape,
125+
split_dim,
126+
output_count,
127+
cat_target,
128+
cat_dim,
129+
view_target,
130+
) in _VIEW_EQUIVALENT_CASES:
131+
with self.subTest(name=name):
132+
input_value = torch.randn(input_shape)
133+
graph_module = _build_split_cat_graph(
134+
input_value,
135+
split_target,
136+
split_spec,
137+
split_dim,
138+
output_count,
139+
cat_target,
140+
cat_dim,
141+
)
142+
reference = copy.deepcopy(graph_module)
143+
144+
result = cast(PassResult, MergeSplitConcatChainPass()(graph_module))
145+
146+
self.assertTrue(result.modified)
147+
torch.testing.assert_close(
148+
reference(input_value), result.graph_module(input_value)
149+
)
150+
for target, expected_count in (
151+
(split_target, 0),
152+
(cat_target, 0),
153+
(view_target, 1),
154+
):
155+
self.assertEqual(
156+
expected_count,
157+
len(
158+
result.graph_module.graph.find_nodes(
159+
op="call_function", target=target
160+
)
161+
),
162+
)
163+
164+
def test_does_not_merge_unsafe_chains(self) -> None:
165+
for dialect, split_target, cat_target in _SPLIT_CAT_DIALECTS:
166+
for name, order, cat_dim, non_contiguous in _UNSAFE_CASES:
167+
with self.subTest(dialect=dialect, name=name):
168+
input_value = torch.randn(1, 4, 3, 2)
169+
if non_contiguous:
170+
input_value = input_value.transpose(2, 3)
171+
graph_module = _build_split_cat_graph(
172+
input_value,
173+
split_target,
174+
[1, 1, 1, 1],
175+
1,
176+
4,
177+
cat_target,
178+
cat_dim,
179+
order,
180+
)
181+
reference = copy.deepcopy(graph_module)
182+
183+
result = cast(PassResult, MergeSplitConcatChainPass()(graph_module))
184+
185+
self.assertFalse(result.modified)
186+
torch.testing.assert_close(
187+
reference(input_value), result.graph_module(input_value)
188+
)
189+
self.assertEqual(
190+
1,
191+
len(
192+
result.graph_module.graph.find_nodes(
193+
op="call_function",
194+
target=cat_target,
195+
)
196+
),
197+
)

0 commit comments

Comments
 (0)