main
py 225 lines 7.43 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Canonicalize tensor partitions to a single ``split_with_sizes`` node.
4
5 Functionalization may replay the individual outputs of a multi-output split as
6 contiguous ``slice`` nodes. This pass converts complete slice partitions back
7 to ``split_with_sizes`` and coalesces equivalent split nodes so downstream
8 pattern-matching passes see one canonical split with all users attached.
9
10 See Also:
11 - vLLM #33295 (original duplicate-split issue)
12 - PyTorch #174472 (upstream CSE gap)
13 - PyTorch #194037 (multi-output views replayed as individual slices)
14
15 """
16
17 import operator
18 from collections import defaultdict
19 from copy import copy
20 from dataclasses import dataclass
21 from typing import Any
22
23 import torch
24 from torch import fx
25
26 from vllm.logger import init_logger
27
28 from ..fx_utils import is_func
29 from ..vllm_inductor_pass import VllmInductorPass
30
31 logger = init_logger(__name__)
32
33
34 @dataclass(frozen=True)
35 class _SliceInfo:
36 node: fx.Node
37 dim: int
38 start: int
39 end: int
40
41
42 class SplitCoalescingPass(VllmInductorPass):
43 """Canonicalize tensor partitions to one shared ``split_with_sizes``.
44
45 A complete contiguous slice partition is rewritten from::
46
47 q = slice(qkv, 0, q_size)
48 k = slice(qkv, q_size, q_size + kv_size)
49 v = slice(qkv, q_size + kv_size, q_size + 2 * kv_size)
50
51 to::
52
53 qkv_split = split_with_sizes(qkv, [q_size, kv_size, kv_size])
54 q = qkv_split[0]
55 k = qkv_split[1]
56 v = qkv_split[2]
57
58 Equivalent splits are also coalesced from::
59
60 q = split_with_sizes(qkv, sizes)[0]
61 k = split_with_sizes(qkv, sizes)[1]
62
63 to::
64
65 qkv_split = split_with_sizes(qkv, sizes)
66 q = qkv_split[0]
67 k = qkv_split[1]
68 """
69
70 @staticmethod
71 def _get_arg(node: fx.Node, index: int, name: str, default: Any) -> Any:
72 """Read an FX argument by position, keyword, or schema default."""
73 if len(node.args) > index:
74 return node.args[index]
75 return node.kwargs.get(name, default)
76
77 @staticmethod
78 def _normalize_dim(dim: int, rank: int) -> int:
79 return dim + rank if dim < 0 else dim
80
81 @staticmethod
82 def _tensor_val(node: fx.Node) -> torch.Tensor | None:
83 value = node.meta.get("val")
84 return value if isinstance(value, torch.Tensor) else None
85
86 def _slice_info(self, node: fx.Node) -> _SliceInfo | None:
87 source = node.args[0]
88 source_val = self._tensor_val(source)
89 slice_val = self._tensor_val(node)
90 # in case someone forgot to set meta !
91 if source_val is None or slice_val is None:
92 return None
93
94 dim = self._normalize_dim(self._get_arg(node, 1, "dim", 0), source_val.dim())
95 extent = source_val.shape[dim]
96
97 # do not support dynamic slices.
98 if not isinstance(extent, int):
99 return None
100
101 start = self._get_arg(node, 2, "start", None)
102 end = self._get_arg(node, 3, "end", None)
103 step = self._get_arg(node, 4, "step", 1)
104 start = 0 if start is None else start
105 end = extent if end is None else end
106 if not isinstance(start, int) or not isinstance(end, int) or step != 1:
107 return None
108
109 if start < 0 or end < 0:
110 return None
111
112 # aten.slice clamps an oversized end to the selected dimension.
113 end = min(end, extent)
114 if start >= end:
115 return None
116
117 return _SliceInfo(node=node, dim=dim, start=start, end=end)
118
119 def _canonicalize_slices(self, graph: fx.Graph) -> int:
120 groups: dict[tuple[fx.Node, int], list[_SliceInfo]] = defaultdict(list)
121
122 for node in graph.find_nodes(
123 op="call_function", target=torch.ops.aten.slice.Tensor
124 ):
125 slice_info = self._slice_info(node)
126 if slice_info is None:
127 continue
128 groups[(node.args[0], slice_info.dim)].append(slice_info)
129
130 count = 0
131 for slice_group in groups.values():
132 if len(slice_group) < 2:
133 continue
134
135 source = slice_group[0].node.args[0]
136 dim = slice_group[0].dim
137 source_val = self._tensor_val(source)
138 assert source_val is not None
139 extent = source_val.shape[dim]
140 assert isinstance(extent, int)
141
142 ordered = sorted(slice_group, key=lambda info: info.start)
143 # Ensure they are contiguous partitions.
144 if (
145 ordered[0].start != 0
146 or ordered[-1].end != extent
147 or any(
148 left.end != right.start for left, right in zip(ordered, ordered[1:])
149 )
150 ):
151 continue
152
153 split_sizes = [info.end - info.start for info in ordered]
154 # Use -1 for the last dimension; otherwise use the positive dimension index.
155 split_dim = -1 if dim == source_val.dim() - 1 else dim
156 first_slice = slice_group[0].node
157
158 with graph.inserting_before(first_slice):
159 split = graph.call_function(
160 torch.ops.aten.split_with_sizes.default,
161 args=(source, split_sizes, split_dim),
162 )
163 split.meta["val"] = [info.node.meta["val"] for info in ordered]
164
165 replacements: list[tuple[fx.Node, fx.Node]] = []
166 for index, info in enumerate(ordered):
167 getitem = graph.call_function(
168 operator.getitem,
169 args=(split, index),
170 type_expr=info.node.type,
171 )
172 getitem.meta = copy(info.node.meta)
173 replacements.append((info.node, getitem))
174
175 for slice_node, getitem in replacements:
176 slice_node.replace_all_uses_with(getitem)
177 graph.erase_node(slice_node)
178
179 count += 1
180
181 return count
182
183 def _coalesce_splits(self, graph: fx.Graph) -> int:
184 count = 0
185 split_nodes: dict[tuple[fx.Node, int], list[fx.Node]] = defaultdict(list)
186
187 for node in graph.find_nodes(
188 op="call_function", target=torch.ops.aten.split_with_sizes.default
189 ):
190 if not all(is_func(user, operator.getitem) for user in node.users):
191 continue
192
193 source, split_sizes = node.args[:2]
194 source_val = self._tensor_val(source)
195 if source_val is None:
196 continue
197 raw_dim = self._get_arg(node, 2, "dim", 0)
198 dim = self._normalize_dim(raw_dim, source_val.dim())
199 candidates = split_nodes[(source, dim)]
200 canonical = next(
201 (
202 candidate
203 for candidate in candidates
204 if list(candidate.args[1]) == list(split_sizes)
205 ),
206 None,
207 )
208 if canonical is not None:
209 node.replace_all_uses_with(canonical)
210 graph.erase_node(node)
211 count += 1
212 else:
213 candidates.append(node)
214
215 return count
216
217 @VllmInductorPass.time_and_log
218 def __call__(self, graph: fx.Graph) -> None:
219 canonicalized = self._canonicalize_slices(graph)
220 coalesced = self._coalesce_splits(graph)
221 logger.debug(
222 "Canonicalized %d slice partitions and coalesced %d split nodes",
223 canonicalized,
224 coalesced,
225 )