Skip to content

Commit 7791a5f

Browse files
authored
Recognise the repeat_interleave idiom in RemovePermutesAroundElementwiseOps (pytorch#21790)
Differential Revision: D114508262 Pull Request resolved: pytorch#21790
1 parent 47e536d commit 7791a5f

2 files changed

Lines changed: 577 additions & 19 deletions

File tree

‎backends/transforms/remove_permutes_around_elementwise_ops.py‎

Lines changed: 248 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class RemovePermutesAroundElementwiseOps(ExportPass):
2323
permutes if possible.
2424
Allows special handling for certain non-elementwise ops that can be easily updated
2525
based on the permute's parameter such as mean, cat, and slice.
26+
The repeat_interleave idiom (unsqueeze -> expand_copy -> merging view_copy) is
27+
recognised as a single rank-preserving unit; see _interleave_triple.
2628
"""
2729

2830
@dataclass()
@@ -44,6 +46,11 @@ class Subgraph:
4446
node_end_permute: dict[torch.fx.Node, list[int]] = field(default_factory=dict)
4547
# Per-node expected start permutation for upstream traversal.
4648
node_start_permute: dict[torch.fx.Node, list[int]] = field(default_factory=dict)
49+
# repeat_interleave triples keyed by their unit-dim-inserting head node,
50+
# mapping to (dim, scale, expand_node, view_node). See _interleave_triple.
51+
interleaves: dict[
52+
torch.fx.Node, tuple[int, int, torch.fx.Node, torch.fx.Node]
53+
] = field(default_factory=dict)
4754

4855
def __init__(self, extra_permutable_ops: set | None = None) -> None:
4956
super().__init__()
@@ -72,6 +79,10 @@ def __init__(self, extra_permutable_ops: set | None = None) -> None:
7279
if extra_permutable_ops:
7380
self._permutable_ops |= extra_permutable_ops
7481
self._sq_unsq_cache: dict[torch.fx.Node, bool] = {}
82+
self._interleave_cache: dict[
83+
torch.fx.Node,
84+
tuple[int, int, torch.fx.Node, torch.fx.Node] | None,
85+
] = {}
7586

7687
_VIEW_OPS = (
7788
exir_ops.edge.aten.view_copy.default,
@@ -165,6 +176,101 @@ def _is_permutation_sink_view(self, node: torch.fx.Node) -> bool:
165176
non_unit = [d for d in shape if not (isinstance(d, int) and d == 1)]
166177
return len(non_unit) <= 1
167178

179+
def _inserted_unit_dim(self, node: torch.fx.Node) -> int | None:
180+
"""Position of the size-1 dim ``node`` inserts, else None.
181+
182+
Accepts both an explicit unsqueeze and a view_copy that only adds a
183+
single unit dim, matching how the rest of the pass treats the two
184+
spellings interchangeably.
185+
"""
186+
is_unsqueeze = node.target == exir_ops.edge.aten.unsqueeze_copy.default
187+
if not is_unsqueeze and node.target not in self._VIEW_OPS:
188+
return None
189+
inp = node.args[0]
190+
if not isinstance(inp, torch.fx.Node):
191+
return None
192+
in_shape = self._concrete_shape(inp)
193+
out_shape = self._concrete_shape(node)
194+
if in_shape is None or out_shape is None:
195+
return None
196+
if len(out_shape) != len(in_shape) + 1:
197+
return None
198+
if is_unsqueeze:
199+
dim = get_arg(node, "dim", int)
200+
pos = dim if dim >= 0 else dim + len(out_shape)
201+
if not 0 <= pos < len(out_shape) or out_shape[pos] != 1:
202+
return None
203+
return pos
204+
positions = self._find_extra_ones(out_shape, in_shape)
205+
if positions is None or len(positions) != 1:
206+
return None
207+
return positions[0]
208+
209+
def _interleave_triple(
210+
self, node: torch.fx.Node
211+
) -> tuple[int, int, torch.fx.Node, torch.fx.Node] | None:
212+
"""Recognise a repeat_interleave and return (dim, scale, expand, view)."""
213+
if node not in self._interleave_cache:
214+
self._interleave_cache[node] = self._match_interleave_triple(node)
215+
return self._interleave_cache[node]
216+
217+
def _match_interleave_triple(
218+
self, node: torch.fx.Node
219+
) -> tuple[int, int, torch.fx.Node, torch.fx.Node] | None:
220+
"""Match a repeat_interleave lowered to three shape operations.
221+
222+
``repeat_interleave(scale, dim)`` lowers to::
223+
224+
unsqueeze(dim + 1) -> expand_copy(scale at dim + 1)
225+
-> view_copy(merge dim, dim + 1)
226+
227+
(e.g. torchaudio's Stretch2d). The triple is rank-preserving overall, so
228+
a permutation flows through it unchanged and only the dim it acts on has
229+
to be remapped -- unlike the merging view_copy on its own, which is not
230+
layout-invariant. Handling the three nodes as one unit also avoids having
231+
to pick an un-permuted position for the intermediate unit dim, a choice
232+
that would otherwise decide whether the merge stays legal.
233+
"""
234+
pos = self._inserted_unit_dim(node)
235+
if pos is None or pos == 0 or len(node.users) != 1:
236+
return None
237+
dim = pos - 1
238+
239+
expand_node = next(iter(node.users))
240+
if (
241+
expand_node.target != exir_ops.edge.aten.expand_copy.default
242+
or len(expand_node.users) != 1
243+
):
244+
return None
245+
246+
unsq_shape = self._concrete_shape(node)
247+
if unsq_shape is None:
248+
return None
249+
size = get_arg(expand_node, "size")
250+
if not isinstance(size, (list, tuple)) or len(size) != len(unsq_shape):
251+
return None
252+
size = list(size)
253+
if not all(isinstance(s, int) for s in size):
254+
return None
255+
# Every dim other than the inserted one must pass through untouched.
256+
if any(s != -1 and s != unsq_shape[k] for k, s in enumerate(size) if k != pos):
257+
return None
258+
scale = size[pos]
259+
if scale < 1:
260+
return None
261+
262+
view_node = next(iter(expand_node.users))
263+
if view_node.target not in self._VIEW_OPS:
264+
return None
265+
in_shape = self._concrete_shape(cast(torch.fx.Node, node.args[0]))
266+
if in_shape is None:
267+
return None
268+
merged = list(in_shape)
269+
merged[dim] *= scale
270+
if self._concrete_shape(view_node) != merged:
271+
return None
272+
return dim, scale, expand_node, view_node
273+
168274
def _adapt_permute_across_view(
169275
self, permute: list[int], node: torch.fx.Node
170276
) -> list[int] | None:
@@ -216,6 +322,7 @@ def _adapt_permute_across_view(
216322

217323
def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901
218324
self._sq_unsq_cache.clear()
325+
self._interleave_cache.clear()
219326
subgraphs_found: list[RemovePermutesAroundElementwiseOps.Subgraph] = []
220327
processed_nodes: set[torch.fx.Node] = set()
221328
for node in graph_module.graph.find_nodes(
@@ -229,7 +336,10 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901
229336

230337
# Try direct users first (same-rank matching)
231338
for user in node.users:
232-
if not self.is_node_permutable(user):
339+
if (
340+
not self.is_node_permutable(user)
341+
and self._interleave_triple(user) is None
342+
):
233343
continue
234344
subgraph = self.Subgraph(start_permute, end_permute)
235345
if self.visit(user, subgraph, processed_nodes):
@@ -256,7 +366,10 @@ def call(self, graph_module: torch.fx.GraphModule) -> PassResult: # noqa: C901
256366
adapted_start.index(i) for i in range(len(adapted_start))
257367
]
258368
for view_user in view_node.users:
259-
if not self.is_node_permutable(view_user):
369+
if (
370+
not self.is_node_permutable(view_user)
371+
and self._interleave_triple(view_user) is None
372+
):
260373
continue
261374
subgraph = self.Subgraph(adapted_start, adapted_end)
262375
# Include the view in the subgraph
@@ -305,29 +418,56 @@ def visit( # noqa: C901
305418

306419
if node in subgraph.nodes:
307420
return True
308-
if node in processed_nodes or not self.is_node_permutable(node):
421+
if node in processed_nodes:
422+
return False
423+
# Explicit unsqueeze nodes are not generally permutable after shape-op
424+
# canonicalization, but a guarded repeat_interleave triple is handled as
425+
# one rank-preserving unit.
426+
triple = self._interleave_triple(node)
427+
if triple is None and not self.is_node_permutable(node):
309428
return False
310-
# A permutable op can still change rank via broadcasting (e.g.
311-
# [8, 1] + [1, 1, 1] -> [1, 8, 1]), which would leave the node carrying
312-
# a permutation of the wrong rank. Downstream rewrites index the
313-
# permutation by dim (update_cat / update_mean_dim / update_slice_copy),
314-
# so bail out rather than mis-permute or index out of range.
315-
# Squeeze/unsqueeze views are exempt: they intentionally carry their
316-
# input-rank permutation and are rank-checked in
317-
# _adapt_permute_across_view.
318-
if not self._is_squeeze_unsqueeze_view(node):
319-
node_shape = getattr(node.meta.get("val"), "shape", None)
320-
if node_shape is not None and len(node_shape) != len(current_start_permute):
429+
if triple is not None:
430+
inp = node.args[0] if node.args else None
431+
if not isinstance(inp, torch.fx.Node):
321432
return False
433+
in_shape = self._concrete_shape(inp)
434+
if in_shape is None or len(current_start_permute) != len(in_shape):
435+
return False
436+
else:
437+
# A permutable op can still change rank via broadcasting (e.g.
438+
# [8, 1] + [1, 1, 1] -> [1, 8, 1]), which would leave the node
439+
# carrying a permutation of the wrong rank. Squeeze/unsqueeze views
440+
# are exempt because _adapt_permute_across_view checks their ranks.
441+
if not self._is_squeeze_unsqueeze_view(node):
442+
node_shape = getattr(node.meta.get("val"), "shape", None)
443+
if node_shape is not None and len(node_shape) != len(
444+
current_start_permute
445+
):
446+
return False
322447
subgraph.nodes.add(node)
323448
subgraph.node_end_permute[node] = current_end_permute
324449
subgraph.node_start_permute[node] = current_start_permute
325450

451+
# A repeat_interleave triple is absorbed whole: its interior nodes are
452+
# not layout-invariant individually, but the triple is rank-preserving.
453+
users_source = node
454+
if triple is not None:
455+
users_source = self._absorb_interleave(
456+
node,
457+
triple,
458+
subgraph,
459+
processed_nodes,
460+
current_end_permute,
461+
current_start_permute,
462+
)
463+
if users_source is None:
464+
return False
465+
326466
# If this is a squeeze/unsqueeze view, adapt permutations for
327467
# traversal across the rank change boundary.
328468
downstream_end = current_end_permute
329469
downstream_start = current_start_permute
330-
if self._is_squeeze_unsqueeze_view(node):
470+
if triple is None and self._is_squeeze_unsqueeze_view(node):
331471
# Adapt start permute for downstream (input-rank → output-rank)
332472
adapted_start = self._adapt_permute_across_view(current_start_permute, node)
333473
if adapted_start is None:
@@ -342,19 +482,19 @@ def visit( # noqa: C901
342482
downstream_end = [adapted_start.index(i) for i in range(len(adapted_start))]
343483

344484
# Traverse downstream:
345-
for user in node.users:
485+
for user in users_source.users:
346486
if user.target == exir_ops.edge.aten.permute_copy.default:
347487
user_perm = self.get_permutation(user)
348488
if user_perm == downstream_end:
349-
subgraph.edges_out.add((node, user))
489+
subgraph.edges_out.add((users_source, user))
350490
else:
351491
# Check if permute → view(squeeze/unsqueeze) forms an
352492
# end boundary at a different rank.
353493
user_users = list(user.users.keys())
354494
if len(user_users) == 1 and self._is_squeeze_unsqueeze_view(
355495
user_users[0]
356496
):
357-
view_after = user_users[0]
497+
view_after: torch.fx.Node = user_users[0]
358498
# Adapt the start permute across the view and derive
359499
# the expected end permute as its inverse.
360500
adapted_start_after = self._adapt_permute_across_view(
@@ -367,7 +507,7 @@ def visit( # noqa: C901
367507
]
368508
if user_perm == adapted:
369509
# Include both the permute and the view as end edges
370-
subgraph.edges_out.add((node, user))
510+
subgraph.edges_out.add((users_source, user))
371511
# Mark the view for inclusion so it gets preserved
372512
continue
373513
return False
@@ -420,6 +560,26 @@ def visit( # noqa: C901
420560

421561
return True
422562

563+
def _absorb_interleave(
564+
self,
565+
head: torch.fx.Node,
566+
triple: tuple[int, int, torch.fx.Node, torch.fx.Node],
567+
subgraph: Subgraph,
568+
processed_nodes: set[torch.fx.Node],
569+
current_end_permute: list[int],
570+
current_start_permute: list[int],
571+
) -> torch.fx.Node | None:
572+
"""Add a matched interleave's interior nodes and return its tail."""
573+
_, _, expand_node, view_node = triple
574+
if expand_node in processed_nodes or view_node in processed_nodes:
575+
return None
576+
for interior in (expand_node, view_node):
577+
subgraph.nodes.add(interior)
578+
subgraph.node_end_permute[interior] = current_end_permute
579+
subgraph.node_start_permute[interior] = current_start_permute
580+
subgraph.interleaves[head] = triple
581+
return view_node
582+
423583
def _is_constant(self, node: torch.fx.Node) -> bool:
424584
"""Check if a node's value is available at compile time.
425585
Only considers direct constants (get_attr, parameter/buffer/constant
@@ -491,12 +651,29 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901
491651
if not self._subgraph_edges_are_current(subgraph):
492652
return False
493653

654+
# Nodes belonging to a repeat_interleave triple are rewritten as a unit
655+
# below, so they must skip the per-node dim handling and the view rank
656+
# check (the triple's interior ranks intentionally differ from the
657+
# region's permutation rank).
658+
interleave_nodes: set[torch.fx.Node] = set()
659+
for head, (_, _, expand_node, view_node) in subgraph.interleaves.items():
660+
interleave_nodes.update((head, expand_node, view_node))
661+
perm = subgraph.node_start_permute.get(head, subgraph.start_permute)
662+
inp = head.args[0] if head.args else None
663+
if not isinstance(inp, torch.fx.Node):
664+
return False
665+
in_shape = self._concrete_shape(inp)
666+
if in_shape is None or len(perm) != len(in_shape):
667+
return False
668+
494669
# Validate: every view_copy node's permutation rank must match its
495670
# input tensor rank. A mismatch can occur when a squeeze/unsqueeze
496671
# view is reached via upstream traversal with a permutation that was
497672
# already adapted to a different rank. Applying the optimisation in
498673
# this case would produce an invalid graph, so skip the subgraph.
499674
for node in subgraph.nodes:
675+
if node in interleave_nodes:
676+
continue
500677
if node.target in self._VIEW_OPS:
501678
perm = subgraph.node_start_permute.get(node, subgraph.start_permute)
502679
inp = node.args[0]
@@ -507,6 +684,8 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901
507684
# Handle dimension related node arguments FIRST, before
508685
# bypassing permutes (which changes node inputs/metadata).
509686
for node in subgraph.nodes:
687+
if node in interleave_nodes:
688+
continue
510689
node_start_perm = subgraph.node_start_permute.get(
511690
node, subgraph.start_permute
512691
)
@@ -524,6 +703,13 @@ def permute_subgraph(self, subgraph: Subgraph) -> bool: # noqa: C901
524703
elif node.target in self._VIEW_OPS:
525704
self.update_view_copy(node, node_start_perm)
526705

706+
for head, triple in subgraph.interleaves.items():
707+
self.update_interleave(
708+
head,
709+
triple,
710+
subgraph.node_start_permute.get(head, subgraph.start_permute),
711+
)
712+
527713
# Skip incoming permutes.
528714
for inp, out in subgraph.edges_in:
529715
assert inp.target == exir_ops.edge.aten.permute_copy.default
@@ -594,8 +780,51 @@ def _subgraph_edges_are_current(self, subgraph: Subgraph) -> bool:
594780
if const_node not in user_node.all_input_nodes:
595781
return False
596782

783+
for head, (_, _, expand_node, view_node) in subgraph.interleaves.items():
784+
if (
785+
len(head.users) != 1
786+
or len(expand_node.users) != 1
787+
or expand_node not in head.users
788+
or view_node not in expand_node.users
789+
):
790+
return False
791+
597792
return True
598793

794+
def update_interleave(
795+
self,
796+
head: torch.fx.Node,
797+
triple: tuple[int, int, torch.fx.Node, torch.fx.Node],
798+
start_permute: list[int],
799+
) -> None:
800+
"""Retarget a repeat_interleave triple at the un-permuted layout.
801+
802+
After the boundary permutes are removed the triple's input is in the
803+
original layout, so the dim it interleaves moves from ``dim`` to
804+
``start_permute[dim]`` and all three shape arguments are rebuilt there.
805+
"""
806+
dim, scale, expand_node, view_node = triple
807+
inp = cast(torch.fx.Node, head.args[0])
808+
in_shape = [int(d) for d in inp.meta["val"].shape]
809+
inverse_permute = [start_permute.index(i) for i in range(len(start_permute))]
810+
unpermuted_in = [in_shape[inverse_permute[i]] for i in range(len(in_shape))]
811+
target_dim = start_permute[dim]
812+
813+
if head.target == exir_ops.edge.aten.unsqueeze_copy.default:
814+
set_arg(head, "dim", target_dim + 1)
815+
else:
816+
unsqueezed = list(unpermuted_in)
817+
unsqueezed.insert(target_dim + 1, 1)
818+
set_arg(head, "size", unsqueezed)
819+
820+
expand_size = list(unpermuted_in)
821+
expand_size.insert(target_dim + 1, scale)
822+
set_arg(expand_node, "size", expand_size)
823+
824+
merged = list(unpermuted_in)
825+
merged[target_dim] *= scale
826+
set_arg(view_node, "size", merged)
827+
599828
def update_cat(self, node: torch.fx.Node, start_permute: list[int]) -> None:
600829
dim = get_arg(node, "dim", int)
601830
set_arg(node, "dim", start_permute[dim])

0 commit comments

Comments
 (0)