432 lines
15 KiB
Python
432 lines
15 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
"""
|
|
Comprehensive tests for UnsafeCloneEliminationPass.
|
|
|
|
This test suite exercises all possible valid FX graph patterns involving clones:
|
|
1. Clone with no users (dead code)
|
|
2. Clone with read-only users
|
|
3. Clone with mutation users
|
|
4. Clone of graph input
|
|
5. Clone with original used after mutation
|
|
6. Clone chains
|
|
"""
|
|
|
|
import pytest
|
|
import torch
|
|
from torch import fx
|
|
from torch.fx.experimental.proxy_tensor import make_fx
|
|
|
|
from vllm.compilation.passes.fx_utils import find_op_nodes
|
|
from vllm.compilation.passes.inductor_pass import get_pass_context, pass_context
|
|
from vllm.compilation.passes.ir.clone_elimination import (
|
|
UnsafeCloneEliminationPass,
|
|
user_writes_to_node,
|
|
)
|
|
from vllm.config import VllmConfig
|
|
from vllm.config.utils import Range
|
|
|
|
|
|
def count_clones(graph: fx.Graph) -> int:
|
|
"""Count clone nodes in a graph."""
|
|
return len(list(find_op_nodes(torch.ops.aten.clone.default, graph)))
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def clone_cleanup_pass():
|
|
return UnsafeCloneEliminationPass(VllmConfig())
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_pass_context():
|
|
"""Set up pass context for each test."""
|
|
with pass_context(compile_range=Range(1, 8192)):
|
|
yield
|
|
|
|
|
|
class TestCloneCleanup:
|
|
"""Test UnsafeCloneEliminationPass behavior on various graph patterns."""
|
|
|
|
def test_remove_clone_readonly_users(self, clone_cleanup_pass):
|
|
"""Clone with only read-only users should be removed."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x_clone = x.clone()
|
|
return x_clone + 1
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
assert count_clones(graph_module.graph) == 0
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_keep_clone_with_mutation_and_original_used_after(self, clone_cleanup_pass):
|
|
"""Clone must be kept if it's mutated AND original is used after mutation."""
|
|
|
|
def f(x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
x = x.relu() # not a graph param
|
|
x_clone = x.clone()
|
|
x_clone.add_(1)
|
|
return x, x_clone
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
# Clone should be KEPT because original is used after mutation
|
|
assert count_clones(graph_module.graph) == 1
|
|
torch.testing.assert_close(actual[0], expected[0])
|
|
torch.testing.assert_close(actual[1], expected[1])
|
|
|
|
def test_remove_clone_with_mutation_no_original_use(self, clone_cleanup_pass):
|
|
"""Clone can be removed if it's mutated but original is not used after."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x = x.relu() # not a graph param
|
|
x_clone = x.clone()
|
|
x_clone.add_(1)
|
|
return x_clone
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
assert count_clones(graph_module.graph) == 0
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_clone_chain(self, clone_cleanup_pass):
|
|
"""Test handling of clone chains: x -> clone1 -> clone2."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x = x.relu() # not a graph param
|
|
x1 = x.clone()
|
|
x2 = x1.clone()
|
|
return x2 + 1
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 2
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
# Both clones should be removed
|
|
assert count_clones(graph_module.graph) == 0
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_keep_clone_that_changes_layout(self, clone_cleanup_pass):
|
|
"""Clone must be kept when it materializes a compact slice layout."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
return x[:, :3].contiguous()
|
|
|
|
inp = torch.randn(4, 5)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
assert count_clones(graph_module.graph) == 1
|
|
assert actual.stride() == expected.stride() == (3, 1)
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_multiple_clones_of_same_input(self, clone_cleanup_pass):
|
|
"""Test multiple independent clones of the same input."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x1 = x.clone()
|
|
x2 = x.clone()
|
|
return x1 + x2
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 2
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
# Both clones should be removed (only readonly uses)
|
|
assert count_clones(graph_module.graph) == 0
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_no_clones_in_graph(self, clone_cleanup_pass):
|
|
"""Test pass behavior when graph has no clones."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
return x + 1
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 0
|
|
|
|
expected = graph_module(inp)
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
|
|
assert count_clones(graph_module.graph) == 0
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_multiple_passes(self, clone_cleanup_pass):
|
|
"""Test running the pass multiple times (should be idempotent)."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x1 = x.clone()
|
|
return x1 + 1
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
expected = graph_module(inp)
|
|
|
|
clone_cleanup_pass(graph_module.graph)
|
|
assert count_clones(graph_module.graph) == 0
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
clone_cleanup_pass(graph_module.graph)
|
|
assert count_clones(graph_module.graph) == 0
|
|
graph_module.recompile()
|
|
actual = graph_module(inp)
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_output_node_no_write(self):
|
|
"""Output nodes never write to their inputs."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
return x
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3))
|
|
x_node = [n for n in graph_module.graph.nodes if n.op == "placeholder"][0]
|
|
output_node = [n for n in graph_module.graph.nodes if n.op == "output"][0]
|
|
|
|
assert not user_writes_to_node(output_node, x_node)
|
|
|
|
def test_readonly_op_no_write(self):
|
|
"""Readonly operations don't write to inputs."""
|
|
|
|
def f(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
|
|
return x + y
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3), torch.randn(2, 3))
|
|
placeholders = [n for n in graph_module.graph.nodes if n.op == "placeholder"]
|
|
add_node = [
|
|
n
|
|
for n in graph_module.graph.nodes
|
|
if n.op == "call_function" and n.target == torch.ops.aten.add.Tensor
|
|
][0]
|
|
|
|
assert not user_writes_to_node(add_node, placeholders[0])
|
|
assert not user_writes_to_node(add_node, placeholders[1])
|
|
|
|
def test_inplace_op_writes(self):
|
|
"""Inplace operations write to first argument."""
|
|
|
|
def f(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
|
|
x.add_(y)
|
|
return x
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3), torch.randn(2, 3))
|
|
placeholders = [n for n in graph_module.graph.nodes if n.op == "placeholder"]
|
|
add_node = [
|
|
n
|
|
for n in graph_module.graph.nodes
|
|
if n.op == "call_function" and "add_" in str(n.target)
|
|
][0]
|
|
|
|
# add_ writes to first arg but not second
|
|
assert user_writes_to_node(add_node, placeholders[0])
|
|
assert not user_writes_to_node(add_node, placeholders[1])
|
|
|
|
def test_copy_writes(self):
|
|
"""copy_ operation writes to first argument."""
|
|
|
|
def f(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
|
|
x.copy_(y)
|
|
return x
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3), torch.randn(2, 3))
|
|
placeholders = [n for n in graph_module.graph.nodes if n.op == "placeholder"]
|
|
copy_node = [
|
|
n
|
|
for n in graph_module.graph.nodes
|
|
if n.op == "call_function" and "copy_" in str(n.target)
|
|
][0]
|
|
|
|
assert user_writes_to_node(copy_node, placeholders[0])
|
|
assert not user_writes_to_node(copy_node, placeholders[1])
|
|
|
|
def test_auto_functionalized_not_a_write(self):
|
|
"""auto_functionalized ops are follow-up uses, not writes."""
|
|
from torch._higher_order_ops.auto_functionalize import auto_functionalized
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
return x
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3))
|
|
x_node = [n for n in graph_module.graph.nodes if n.op == "placeholder"][0]
|
|
|
|
# Create an auto_functionalized node in the graph
|
|
with graph_module.graph.inserting_before(None):
|
|
af_node = graph_module.graph.call_function(
|
|
auto_functionalized, kwargs={"input": x_node}
|
|
)
|
|
|
|
# auto_functionalized should not be treated as a write
|
|
assert not user_writes_to_node(af_node, x_node)
|
|
|
|
def test_higher_order_op_conservatively_writes(self):
|
|
"""Other higher-order operators are conservatively treated as writes."""
|
|
from torch._ops import HigherOrderOperator
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
return x
|
|
|
|
graph_module = make_fx(f)(torch.randn(2, 3))
|
|
x_node = [n for n in graph_module.graph.nodes if n.op == "placeholder"][0]
|
|
|
|
# Create a concrete higher-order operator subclass
|
|
class MockHigherOrderOp(HigherOrderOperator):
|
|
def __call__(self, *args, **kwargs):
|
|
return args[0] if args else None
|
|
|
|
mock_hoo = MockHigherOrderOp("mock_higher_order_op")
|
|
|
|
with graph_module.graph.inserting_before(None):
|
|
hoo_node = graph_module.graph.call_function(mock_hoo, args=(x_node,))
|
|
|
|
# Should be conservative and assume it could write
|
|
assert user_writes_to_node(hoo_node, x_node)
|
|
|
|
|
|
class TestCloneCleanupWithDonatedInputs:
|
|
"""Test UnsafeCloneEliminationPass with donated input tracking via PassContext."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def setup_pass_context(self):
|
|
"""Set up pass context for each test."""
|
|
with pass_context(compile_range=Range(1, 8192)):
|
|
yield
|
|
|
|
def test_donated_input_clone_removed(self, clone_cleanup_pass):
|
|
"""Clone of donated input should be removed."""
|
|
|
|
def f(x: torch.Tensor) -> torch.Tensor:
|
|
x_clone = x.clone()
|
|
x_clone.add_(1)
|
|
return x_clone
|
|
|
|
inp = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
# Mark first parameter as donated
|
|
get_pass_context().donated_input_ids = {0}
|
|
|
|
expected = graph_module(inp.clone())
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
|
|
# Clone should be removed since input is donated
|
|
assert count_clones(graph_module.graph) == 0
|
|
|
|
# Input can be mutated (donated)
|
|
inp_copy = inp.clone()
|
|
actual = graph_module(inp_copy)
|
|
torch.testing.assert_close(actual, expected)
|
|
|
|
def test_non_donated_input_clone_kept(self, clone_cleanup_pass):
|
|
"""Clone of non-donated input with mutation should be kept."""
|
|
|
|
def f(x: torch.Tensor, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
x_clone = x.clone()
|
|
x_clone.add_(1)
|
|
return x, x_clone
|
|
|
|
inp_x = torch.randn(2, 3)
|
|
inp_y = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp_x, inp_y)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
# No donated inputs
|
|
get_pass_context().donated_input_ids = set()
|
|
|
|
expected = graph_module(inp_x.clone(), inp_y.clone())
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
|
|
# Clone should be kept since input is not donated and original is used
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
# Verify inputs are not mutated
|
|
inp_x_before = inp_x.clone()
|
|
inp_y_before = inp_y.clone()
|
|
actual = graph_module(inp_x, inp_y)
|
|
torch.testing.assert_close(
|
|
inp_x, inp_x_before, msg="Input x should not be mutated"
|
|
)
|
|
torch.testing.assert_close(
|
|
inp_y, inp_y_before, msg="Input y should not be mutated"
|
|
)
|
|
torch.testing.assert_close(actual[0], expected[0])
|
|
torch.testing.assert_close(actual[1], expected[1])
|
|
|
|
def test_mixed_donated_inputs(self, clone_cleanup_pass):
|
|
"""Test with some inputs donated and some not."""
|
|
|
|
def f(x: torch.Tensor, y: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
|
|
x_clone = x.clone()
|
|
x_clone.add_(1)
|
|
y_clone = y.clone()
|
|
y_clone.add_(2)
|
|
return x_clone, y_clone
|
|
|
|
inp_x = torch.randn(2, 3)
|
|
inp_y = torch.randn(2, 3)
|
|
graph_module = make_fx(f)(inp_x, inp_y)
|
|
assert count_clones(graph_module.graph) == 2
|
|
|
|
# Only x is donated
|
|
get_pass_context().donated_input_ids = {0}
|
|
|
|
expected = graph_module(inp_x.clone(), inp_y.clone())
|
|
clone_cleanup_pass(graph_module.graph)
|
|
graph_module.recompile()
|
|
|
|
# x_clone removed (x is donated), y_clone kept (y is not donated)
|
|
assert count_clones(graph_module.graph) == 1
|
|
|
|
# Verify y is not mutated (x can be mutated since it's donated)
|
|
inp_y_before = inp_y.clone()
|
|
actual = graph_module(inp_x.clone(), inp_y)
|
|
torch.testing.assert_close(
|
|
inp_y, inp_y_before, msg="Input y should not be mutated"
|
|
)
|
|
torch.testing.assert_close(actual[0], expected[0])
|
|
torch.testing.assert_close(actual[1], expected[1])
|