@@ -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