Files
2026-07-13 13:17:40 +08:00

498 lines
16 KiB
Python

# coding: utf-8
import logging
import os
import sys
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
import pytest
import ray
import ray.experimental.collective as collective
from ray.dag import InputNode, MultiOutputNode
from ray.experimental.channel import CPUCommunicator
from ray.experimental.collective.conftest import (
AbstractNcclGroup,
CPUTorchTensorWorker,
check_nccl_group_init,
check_nccl_group_teardown,
)
from ray.experimental.util.types import ReduceOp
from ray.tests.conftest import * # noqa
if TYPE_CHECKING:
import cupy as cp
import torch
logger = logging.getLogger(__name__)
if sys.platform != "linux" and sys.platform != "darwin":
pytest.skip("Skipping, requires Linux or Mac.", allow_module_level=True)
class MockCommunicator(CPUCommunicator):
"""
Use a mock communicator to test the actor schedules.
"""
def __init__(self, world_size: int, actor_handles: List["ray.actor.ActorHandle"]):
self._world_size = world_size
self._actor_handles = actor_handles
def send(self, value: "torch.Tensor", peer_rank: int) -> None:
raise NotImplementedError
def recv(
self,
shape: Tuple[int],
dtype: "torch.dtype",
peer_rank: int,
allocator: Optional[
Callable[[Tuple[int], "torch.dtype"], "torch.Tensor"]
] = None,
) -> "torch.Tensor":
raise NotImplementedError
def allgather(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
) -> None:
raise NotImplementedError
def allreduce(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp,
) -> None:
raise NotImplementedError
def reducescatter(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp,
) -> None:
raise NotImplementedError
@property
def recv_stream(self) -> Optional["cp.cuda.ExternalStream"]:
raise NotImplementedError
@property
def send_stream(self) -> Optional["cp.cuda.ExternalStream"]:
raise NotImplementedError
def destroy(self) -> None:
raise NotImplementedError
@ray.remote
class DDPWorker:
def __init__(self):
return
def backward(self, _):
return 0
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_duplicate_actors(ray_start_regular):
"""
Test an error is thrown when two input nodes from the same actor bind to
an all-reduce.
"""
actor_cls = CPUTorchTensorWorker.options()
worker = actor_cls.remote()
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for _ in range(2)]
with pytest.raises(
ValueError,
match="Expected unique actor handles, but found duplicate actor handles from input nodes",
):
collective.allreduce.bind(computes)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_custom_comm_wrong_actors(ray_start_regular):
"""
Test an error is thrown when an all-reduce binds to a custom NCCL group and
a wrong set of actors.
"""
actor_cls = CPUTorchTensorWorker.options()
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
nccl_group = AbstractNcclGroup([workers[0]])
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
with pytest.raises(
ValueError,
match="Expected actor handles to match the custom communicator group",
):
collective.allreduce.bind(computes, transport=nccl_group)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_bind_list_of_nodes_duplicate_nodes(ray_start_regular):
"""
Test an error is thrown when an all-reduce binds to lists of nodes
that are duplicated.
"""
actor_cls = CPUTorchTensorWorker.options()
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
nccl_group = AbstractNcclGroup([workers[0]])
with InputNode() as inp:
computes_0 = [worker.return_tensor.bind(inp) for worker in workers]
computes_1 = [workers[0].return_tensor.bind(inp) for _ in range(2)]
with pytest.raises(
ValueError,
match="Expected unique actor handles at list at index",
):
collective.allreduce.bind([computes_0, computes_1], transport=nccl_group)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_bind_list_of_nodes_unequal_number_of_nodes(ray_start_regular):
"""
Test an error is thrown when an all-reduce binds to lists of nodes
of different number of nodes across actors.
"""
actor_cls = CPUTorchTensorWorker.options()
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
nccl_group = AbstractNcclGroup([workers[0]])
with InputNode() as inp:
computes_0 = [worker.return_tensor.bind(inp) for worker in workers]
computes_1 = [worker.return_tensor.bind(inp) for worker in workers[1:]]
with pytest.raises(
ValueError,
match="Expected all input lists to have the same number of nodes",
):
collective.allreduce.bind([computes_0, computes_1], transport=nccl_group)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_bind_list_of_nodes_different_actors(ray_start_regular):
"""
Test an error is thrown when an all-reduce binds to a list of nodes
from different set of actors.
"""
actor_cls = CPUTorchTensorWorker.options()
num_workers = 3
workers = [actor_cls.remote() for _ in range(num_workers)]
nccl_group = AbstractNcclGroup([workers[0]])
with InputNode() as inp:
computes_0 = [worker.return_tensor.bind(inp) for worker in workers[:2]]
computes_1 = [worker.return_tensor.bind(inp) for worker in workers[1:]]
with pytest.raises(
ValueError,
match="Expected all input lists to have the same set of actor handles",
):
collective.allreduce.bind([computes_0, computes_1], transport=nccl_group)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
def test_all_reduce_bind_list_of_nodes_different_dtypes(ray_start_regular):
"""
Test an error is thrown when an all-reduce binds to a list of nodes
that execute with tensors of different dtypes.
"""
actor_cls = CPUTorchTensorWorker.options()
num_workers = 3
workers = [actor_cls.remote() for _ in range(num_workers)]
comm = MockCommunicator(num_workers, workers)
with InputNode() as inp:
computes_0 = [worker.return_tensor.bind(inp[0], inp[1]) for worker in workers]
computes_1 = [worker.return_tensor.bind(inp[0], inp[2]) for worker in workers]
collectives = collective.allreduce.bind(
[computes_0, computes_1], transport=comm
)
recvs = [
worker.recv_tensors.bind(*collective)
for worker, collective in zip(workers, collectives)
]
dag = MultiOutputNode(recvs)
compiled_dag = dag.experimental_compile()
with pytest.raises(
ValueError,
match="Expected all input tensors to have the same dtype",
):
import torch
ray.get(compiled_dag.execute(1, torch.float16, torch.float32))
@pytest.mark.parametrize(
"ray_start_regular", [{"num_cpus": 4, "num_gpus": 4}], indirect=True
)
def test_comm_all_reduces(ray_start_regular, monkeypatch):
"""
Test different communicators are used for different all-reduce calls of
different sets of actors.
"""
actor_cls = CPUTorchTensorWorker.options(num_cpus=0, num_gpus=1)
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
# There are two all-reduces, each on one actor.
collectives = [collective.allreduce.bind([compute]) for compute in computes]
# collective[0] is the only CollectiveOutputNode for each all-reduce.
dag = MultiOutputNode([collective[0] for collective in collectives])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{
(frozenset([workers[0]]), None),
(frozenset([workers[1]]), None),
},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
@pytest.mark.parametrize(
"ray_start_regular", [{"num_cpus": 4, "num_gpus": 4}], indirect=True
)
def test_comm_deduplicate_all_reduces(ray_start_regular, monkeypatch):
"""
Test communicators are deduplicated when all-reduces are called on the same
group of actors more than once.
"""
actor_cls = CPUTorchTensorWorker.options(num_cpus=0, num_gpus=1)
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
with InputNode() as inp:
tensors = [worker.return_tensor.bind(inp) for worker in workers]
collectives = collective.allreduce.bind(tensors)
collectives = collective.allreduce.bind(collectives)
dag = MultiOutputNode(collectives)
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{(frozenset(workers), None)},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
@pytest.mark.parametrize(
"ray_start_regular", [{"num_cpus": 4, "num_gpus": 4}], indirect=True
)
def test_comm_deduplicate_p2p_and_collective(ray_start_regular, monkeypatch):
"""
Test communicators are deduplicated when the collective and the P2P are on
the same set of actors.
"""
actor_cls = CPUTorchTensorWorker.options(num_cpus=0, num_gpus=1)
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
collectives = collective.allreduce.bind(computes)
recvs = [
# Each of the 2 workers receives from the other.
workers[0].recv.bind(
collectives[1].with_tensor_transport(transport="nccl")
),
workers[1].recv.bind(
collectives[0].with_tensor_transport(transport="nccl")
),
]
dag = MultiOutputNode(recvs)
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{(frozenset(workers), None)},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
collectives = collective.allreduce.bind(computes)
# Sender is workers[0] and receiver is workers[1].
dag = workers[1].recv.bind(
collectives[0].with_tensor_transport(transport="nccl")
)
dag = MultiOutputNode([dag, collectives[1]])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{(frozenset(workers), None)},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
@pytest.mark.parametrize(
"ray_start_regular", [{"num_cpus": 4, "num_gpus": 4}], indirect=True
)
def test_custom_comm(ray_start_regular, monkeypatch):
"""
Test a custom GPU communicator is used when specified and a default
communicator is used otherwise.
"""
actor_cls = CPUTorchTensorWorker.options(num_cpus=0, num_gpus=1)
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
comm = AbstractNcclGroup(workers)
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
collectives = collective.allreduce.bind(computes, transport=comm)
collectives = collective.allreduce.bind(collectives)
dag = workers[0].recv.bind(
collectives[1].with_tensor_transport(transport="nccl")
)
dag = MultiOutputNode([dag, collectives[0]])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{
(frozenset(workers), comm),
(frozenset(workers), None),
},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
comm = AbstractNcclGroup(workers)
with InputNode() as inp:
computes = [worker.return_tensor.bind(inp) for worker in workers]
collectives = collective.allreduce.bind(computes)
collectives = collective.allreduce.bind(collectives)
dag = workers[0].recv.bind(collectives[1].with_tensor_transport(transport=comm))
dag = MultiOutputNode([dag, collectives[0]])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{
(frozenset(workers), comm),
(frozenset(workers), None),
},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
@pytest.mark.parametrize(
"ray_start_regular", [{"num_cpus": 4, "num_gpus": 4}], indirect=True
)
def test_custom_comm_init_teardown(ray_start_regular, monkeypatch):
"""
Test custom NCCL groups are properly initialized and destroyed.
1. Test when multiple type hints have the same `transport=custom_nccl_group`,
the `custom_nccl_group` is initialized only once.
2. Test all initialized NCCL groups are destroyed during teardown.
"""
actor_cls = CPUTorchTensorWorker.options(num_cpus=0, num_gpus=1)
num_workers = 2
workers = [actor_cls.remote() for _ in range(num_workers)]
comm = AbstractNcclGroup(workers)
with InputNode() as inp:
tensors = [worker.return_tensor.bind(inp) for worker in workers]
allreduce = collective.allreduce.bind(tensors, transport=comm)
dag = workers[0].recv.bind(allreduce[1].with_tensor_transport(transport=comm))
dag = MultiOutputNode([dag, allreduce[0]])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{(frozenset(workers), comm)},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
comm_1 = AbstractNcclGroup(workers)
comm_2 = AbstractNcclGroup(workers)
comm_3 = AbstractNcclGroup(workers)
with InputNode() as inp:
tensors = [worker.return_tensor.bind(inp) for worker in workers]
allreduce1 = collective.allreduce.bind(tensors, transport=comm_1)
allreduce2 = collective.allreduce.bind(allreduce1, transport=comm_2)
dag = workers[0].recv.bind(
allreduce2[1].with_tensor_transport(transport=comm_3)
)
dag = MultiOutputNode([dag, allreduce2[0]])
compiled_dag, mock_nccl_group_set = check_nccl_group_init(
monkeypatch,
dag,
{
(frozenset(workers), comm_1),
(frozenset(workers), comm_2),
(frozenset(workers), comm_3),
},
)
check_nccl_group_teardown(monkeypatch, compiled_dag, mock_nccl_group_set)
@pytest.mark.parametrize("ray_start_regular", [{"num_cpus": 4}], indirect=True)
@pytest.mark.parametrize("num_workers", [2, 4])
def test_exec_schedules_ddp(ray_start_regular, num_workers):
"""
Test the execution schedules for the DDP strategy. Each worker should have
identical schedules.
"""
actor_cls = DDPWorker.options(num_cpus=1)
workers = [actor_cls.remote() for _ in range(num_workers)]
comm = MockCommunicator(num_workers, workers)
outputs = []
with InputNode() as inp:
grads = [worker.backward.bind(inp) for worker in workers]
grads_reduced = collective.allreduce.bind(grads, transport=comm)
outputs.extend(grads_reduced)
grads = [worker.backward.bind(grad) for worker, grad in zip(workers, grads)]
grads_reduced = collective.allreduce.bind(grads, transport=comm)
outputs.extend(grads_reduced)
dag = MultiOutputNode(outputs)
compiled_dag = dag.experimental_compile(_default_communicator=comm)
actor_to_execution_schedule = list(
compiled_dag.actor_to_execution_schedule.values()
)
expected_schedule = actor_to_execution_schedule[0]
for schedule in actor_to_execution_schedule[1:]:
assert schedule == expected_schedule
if __name__ == "__main__":
if os.environ.get("PARALLEL_CI"):
sys.exit(pytest.main(["-n", "auto", "--boxed", "-vs", __file__]))
else:
sys.exit(pytest.main(["-sv", __file__]))