Skip to content

vllm.compilation.passes.utility.split_coalescing

Canonicalize tensor partitions to a single split_with_sizes node.

Functionalization may replay the individual outputs of a multi-output split as contiguous slice nodes. This pass converts complete slice partitions back to split_with_sizes and coalesces equivalent split nodes so downstream pattern-matching passes see one canonical split with all users attached.

See Also
  • vLLM #33295 (original duplicate-split issue)
  • PyTorch #174472 (upstream CSE gap)
  • PyTorch #194037 (multi-output views replayed as individual slices)

Classes:

SplitCoalescingPass

Bases: VllmInductorPass

Canonicalize tensor partitions to one shared split_with_sizes.

A complete contiguous slice partition is rewritten from::

q = slice(qkv, 0, q_size)
k = slice(qkv, q_size, q_size + kv_size)
v = slice(qkv, q_size + kv_size, q_size + 2 * kv_size)

to::

qkv_split = split_with_sizes(qkv, [q_size, kv_size, kv_size])
q = qkv_split[0]
k = qkv_split[1]
v = qkv_split[2]

Equivalent splits are also coalesced from::

q = split_with_sizes(qkv, sizes)[0]
k = split_with_sizes(qkv, sizes)[1]

to::

qkv_split = split_with_sizes(qkv, sizes)
q = qkv_split[0]
k = qkv_split[1]
Source code in vllm/compilation/passes/utility/split_coalescing.py
class SplitCoalescingPass(VllmInductorPass):
    """Canonicalize tensor partitions to one shared ``split_with_sizes``.

    A complete contiguous slice partition is rewritten from::

        q = slice(qkv, 0, q_size)
        k = slice(qkv, q_size, q_size + kv_size)
        v = slice(qkv, q_size + kv_size, q_size + 2 * kv_size)

    to::

        qkv_split = split_with_sizes(qkv, [q_size, kv_size, kv_size])
        q = qkv_split[0]
        k = qkv_split[1]
        v = qkv_split[2]

    Equivalent splits are also coalesced from::

        q = split_with_sizes(qkv, sizes)[0]
        k = split_with_sizes(qkv, sizes)[1]

    to::

        qkv_split = split_with_sizes(qkv, sizes)
        q = qkv_split[0]
        k = qkv_split[1]
    """

    @staticmethod
    def _get_arg(node: fx.Node, index: int, name: str, default: Any) -> Any:
        """Read an FX argument by position, keyword, or schema default."""
        if len(node.args) > index:
            return node.args[index]
        return node.kwargs.get(name, default)

    @staticmethod
    def _normalize_dim(dim: int, rank: int) -> int:
        return dim + rank if dim < 0 else dim

    @staticmethod
    def _tensor_val(node: fx.Node) -> torch.Tensor | None:
        value = node.meta.get("val")
        return value if isinstance(value, torch.Tensor) else None

    def _slice_info(self, node: fx.Node) -> _SliceInfo | None:
        source = node.args[0]
        source_val = self._tensor_val(source)
        slice_val = self._tensor_val(node)
        # in case someone forgot to set meta !
        if source_val is None or slice_val is None:
            return None

        dim = self._normalize_dim(self._get_arg(node, 1, "dim", 0), source_val.dim())
        extent = source_val.shape[dim]

        # do not support dynamic slices.
        if not isinstance(extent, int):
            return None

        start = self._get_arg(node, 2, "start", None)
        end = self._get_arg(node, 3, "end", None)
        step = self._get_arg(node, 4, "step", 1)
        start = 0 if start is None else start
        end = extent if end is None else end
        if not isinstance(start, int) or not isinstance(end, int) or step != 1:
            return None

        if start < 0 or end < 0:
            return None

        # aten.slice clamps an oversized end to the selected dimension.
        end = min(end, extent)
        if start >= end:
            return None

        return _SliceInfo(node=node, dim=dim, start=start, end=end)

    def _canonicalize_slices(self, graph: fx.Graph) -> int:
        groups: dict[tuple[fx.Node, int], list[_SliceInfo]] = defaultdict(list)

        for node in graph.find_nodes(
            op="call_function", target=torch.ops.aten.slice.Tensor
        ):
            slice_info = self._slice_info(node)
            if slice_info is None:
                continue
            groups[(node.args[0], slice_info.dim)].append(slice_info)

        count = 0
        for slice_group in groups.values():
            if len(slice_group) < 2:
                continue

            source = slice_group[0].node.args[0]
            dim = slice_group[0].dim
            source_val = self._tensor_val(source)
            assert source_val is not None
            extent = source_val.shape[dim]
            assert isinstance(extent, int)

            ordered = sorted(slice_group, key=lambda info: info.start)
            # Ensure they are contiguous partitions.
            if (
                ordered[0].start != 0
                or ordered[-1].end != extent
                or any(
                    left.end != right.start for left, right in zip(ordered, ordered[1:])
                )
            ):
                continue

            split_sizes = [info.end - info.start for info in ordered]
            # Use -1 for the last dimension; otherwise use the positive dimension index.
            split_dim = -1 if dim == source_val.dim() - 1 else dim
            first_slice = slice_group[0].node

            with graph.inserting_before(first_slice):
                split = graph.call_function(
                    torch.ops.aten.split_with_sizes.default,
                    args=(source, split_sizes, split_dim),
                )
                split.meta["val"] = [info.node.meta["val"] for info in ordered]

                replacements: list[tuple[fx.Node, fx.Node]] = []
                for index, info in enumerate(ordered):
                    getitem = graph.call_function(
                        operator.getitem,
                        args=(split, index),
                        type_expr=info.node.type,
                    )
                    getitem.meta = copy(info.node.meta)
                    replacements.append((info.node, getitem))

            for slice_node, getitem in replacements:
                slice_node.replace_all_uses_with(getitem)
                graph.erase_node(slice_node)

            count += 1

        return count

    def _coalesce_splits(self, graph: fx.Graph) -> int:
        count = 0
        split_nodes: dict[tuple[fx.Node, int], list[fx.Node]] = defaultdict(list)

        for node in graph.find_nodes(
            op="call_function", target=torch.ops.aten.split_with_sizes.default
        ):
            if not all(is_func(user, operator.getitem) for user in node.users):
                continue

            source, split_sizes = node.args[:2]
            source_val = self._tensor_val(source)
            if source_val is None:
                continue
            raw_dim = self._get_arg(node, 2, "dim", 0)
            dim = self._normalize_dim(raw_dim, source_val.dim())
            candidates = split_nodes[(source, dim)]
            canonical = next(
                (
                    candidate
                    for candidate in candidates
                    if list(candidate.args[1]) == list(split_sizes)
                ),
                None,
            )
            if canonical is not None:
                node.replace_all_uses_with(canonical)
                graph.erase_node(node)
                count += 1
            else:
                candidates.append(node)

        return count

    @VllmInductorPass.time_and_log
    def __call__(self, graph: fx.Graph) -> None:
        canonicalized = self._canonicalize_slices(graph)
        coalesced = self._coalesce_splits(graph)
        logger.debug(
            "Canonicalized %d slice partitions and coalesced %d split nodes",
            canonicalized,
            coalesced,
        )

_get_arg(node, index, name, default) staticmethod

Read an FX argument by position, keyword, or schema default.

Source code in vllm/compilation/passes/utility/split_coalescing.py
@staticmethod
def _get_arg(node: fx.Node, index: int, name: str, default: Any) -> Any:
    """Read an FX argument by position, keyword, or schema default."""
    if len(node.args) > index:
        return node.args[index]
    return node.kwargs.get(name, default)