# coding: utf-8 import logging import sys from typing import Dict, List, Tuple import pytest import torch import ray import ray.cluster_utils from ray._private.test_utils import get_actor_node_id from ray.experimental.channel.conftest import ( Barrier, TracedChannel, start_nccl_mock, ) from ray.experimental.channel.torch_tensor_accelerator_channel import ( _init_communicator, ) from ray.experimental.channel.torch_tensor_type import TorchTensorType logger = logging.getLogger(__name__) @ray.remote(num_cpus=0, num_gpus=1) class Worker: def __init__(self): self.tensor_chan = None # Key -> a patched channel that we can use to record write ops. self.traced_channels: Dict[str, TracedChannel] = {} def start_mock(self): """ Patch methods that require CUDA. """ start_nccl_mock() def set_nccl_channel(self, typ, tensor_chan): typ.register_custom_serializer() self.tensor_chan = tensor_chan def create_traced_channel(self, key, readers): self.traced_channels[key] = TracedChannel( ray.get_runtime_context().current_actor, readers ) def get_num_channel_ops(self, key): ops = self.traced_channels[key].ops self.traced_channels[key].ops = [] return len(ops) def create_nccl_channel( self, typ: TorchTensorType, reader_and_node_list: List[Tuple[ray.actor.ActorHandle, str]], tensor_metadata_channel_key=None, cpu_data_channel_key=None, ): typ.register_custom_serializer() tensor_metadata_channel = None if tensor_metadata_channel_key is not None: tensor_metadata_channel = self.traced_channels[tensor_metadata_channel_key] cpu_data_channel = None if cpu_data_channel_key is not None: cpu_data_channel = self.traced_channels[cpu_data_channel_key] self.tensor_chan = typ.create_channel( ray.get_runtime_context().current_actor, reader_and_node_list, driver_actor_id=None, _cpu_data_channel=cpu_data_channel, _tensor_metadata_channel=tensor_metadata_channel, ) return self.tensor_chan def send(self, val, shape, dtype): t = torch.ones(shape, dtype=dtype) * val self.tensor_chan.write(t) def receive(self): t = self.tensor_chan.read() data = (t[0].item(), t.shape, t.dtype) return data def send_dict(self, tensor_dict): self.tensor_chan.write(tensor_dict) def receive_dict(self): tensor_dict = self.tensor_chan.read() vals = [] for key, t in tensor_dict.items(): vals.append((key, t[0].item(), t.shape, t.dtype)) return vals @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 2, "num_gpus": 2, "num_nodes": 1, } ], indirect=True, ) def test_p2p(ray_start_cluster): """ Test simple sender -> receiver pattern. Check that receiver receives correct results. """ # Barrier name should be barrier-{lower rank}-{higher rank}. barrier = Barrier.options(name="barrier-0-1").remote() # noqa sender = Worker.remote() receiver = Worker.remote() receiver_node = get_actor_node_id(receiver) ray.get( [ sender.start_mock.remote(), receiver.start_mock.remote(), ] ) nccl_id = _init_communicator([sender, receiver]) chan_typ = TorchTensorType(transport="accelerator") chan_typ.set_communicator_id(nccl_id) chan_ref = sender.create_nccl_channel.remote(chan_typ, [(receiver, receiver_node)]) receiver_ready = receiver.set_nccl_channel.remote(chan_typ, chan_ref) ray.get([chan_ref, receiver_ready]) shape = (10,) dtype = torch.float16 refs = [] for i in range(3): sender.send.remote(i, shape, dtype) refs.append(receiver.receive.remote()) assert ray.get(refs) == [(i, shape, dtype) for i in range(3)] shapes = [(10,), (20,), (30,)] dtypes = [torch.float, torch.float16, torch.int] refs = [] for i in range(3): sender.send.remote(i, shapes[i], dtypes[i]) refs.append(receiver.receive.remote()) assert ray.get(refs) == [(i, shapes[i], dtypes[i]) for i in range(3)] @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 4, "num_gpus": 4, "num_nodes": 1, } ], indirect=True, ) def test_multiple_receivers(ray_start_cluster): """ Test sender with multiple receivers pattern. Check that all receivers receive correct results. """ # Create one barrier per sender-receiver pair. barriers = [ # noqa Barrier.options(name=f"barrier-0-{i}").remote() for i in range(1, 4) ] # noqa sender = Worker.remote() receiver_to_node = [] for _ in range(3): handle = Worker.remote() node = get_actor_node_id(handle) receiver_to_node.append((handle, node)) workers = [sender] + [receiver for receiver, _ in receiver_to_node] ray.get([worker.start_mock.remote() for worker in workers]) nccl_id = _init_communicator(workers) chan_typ = TorchTensorType(transport="accelerator") chan_typ.set_communicator_id(nccl_id) chan_ref = sender.create_nccl_channel.remote(chan_typ, receiver_to_node) receiver_ready = [ receiver.set_nccl_channel.remote(chan_typ, chan_ref) for receiver, _ in receiver_to_node ] ray.get(receiver_ready) shapes = [(10,), (20,), (30,)] dtypes = [torch.float, torch.float16, torch.int] all_refs = [] for i in range(3): sender.send.remote(i, shapes[i], dtypes[i]) all_refs.append([receiver.receive.remote() for receiver, _ in receiver_to_node]) # Check that all receivers received the correct value. for i, refs in enumerate(all_refs): for val in ray.get(refs): assert val == (i, shapes[i], dtypes[i]) @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 2, "num_gpus": 2, "num_nodes": 1, } ], indirect=True, ) def test_static_shape(ray_start_cluster): """ Test that when static_shape=True is passed, we only send metadata for the first operation. Afterwards, we reuse the same shape and dtype for future tensors. Sending a tensor of the wrong shape or dtype throws an error. """ # Barrier name should be barrier-{lower rank}-{higher rank}. barrier = Barrier.options(name="barrier-0-1").remote() # noqa sender = Worker.remote() receiver = Worker.remote() ray.get( [ sender.start_mock.remote(), receiver.start_mock.remote(), ] ) nccl_id = _init_communicator([sender, receiver]) chan_typ = TorchTensorType( transport="accelerator", _static_shape=True, ) chan_typ.set_communicator_id(nccl_id) receiver_to_node = [(receiver, get_actor_node_id(receiver))] sender.create_traced_channel.remote("tensor_metadata", receiver_to_node) sender.create_traced_channel.remote("cpu_data", receiver_to_node) chan_ref = sender.create_nccl_channel.remote( chan_typ, receiver_to_node, "tensor_metadata", "cpu_data", ) receiver_ready = receiver.set_nccl_channel.remote(chan_typ, chan_ref) ray.get([chan_ref, receiver_ready]) shape = (10,) dtype = torch.float16 refs = [] for i in range(3): sender.send.remote(i, shape, dtype) refs.append(receiver.receive.remote()) assert ray.get(refs) == [(i, shape, dtype) for i in range(3)] # When static_shape=True, we should only send one metadata op. After the # first metadata is sent, we reuse it for all future sends. num_tensor_metadata_ops = ray.get( sender.get_num_channel_ops.remote("tensor_metadata") ) assert num_tensor_metadata_ops == 1 num_cpu_data_ops = ray.get(sender.get_num_channel_ops.remote("cpu_data")) assert num_cpu_data_ops == 3 # Attempting to write tensors of the wrong shape or dtype will error. with pytest.raises(ValueError): ray.get(sender.send.remote(4, (20,), dtype)) with pytest.raises(ValueError): ray.get(sender.send.remote(4, shape, torch.float32)) # Attempting to write a different number of tensors will error. with pytest.raises(ValueError): ray.get(sender.send_dict.remote({})) with pytest.raises(ValueError): ray.get( sender.send_dict.remote( { "first": torch.zeros(10), "second": torch.zeros(10), } ) ) # The channel is still usable for valid tensors after errors occur. sender.send.remote(4, shape, dtype) ray.get(receiver.receive.remote()) == (4, shape, dtype) @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 2, "num_gpus": 2, "num_nodes": 1, } ], indirect=True, ) def test_direct_return(ray_start_cluster): """ Test that when _direct_return=True is passed, we never send metadata. """ # Barrier name should be barrier-{lower rank}-{higher rank}. barrier = Barrier.options(name="barrier-0-1").remote() # noqa sender = Worker.remote() receiver = Worker.remote() ray.get( [ sender.start_mock.remote(), receiver.start_mock.remote(), ] ) nccl_id = _init_communicator([sender, receiver]) chan_typ = TorchTensorType( transport="accelerator", _direct_return=True, ) chan_typ.set_communicator_id(nccl_id) receiver_to_node = [(receiver, get_actor_node_id(receiver))] sender.create_traced_channel.remote("tensor_metadata", receiver_to_node) chan_ref = sender.create_nccl_channel.remote( chan_typ, receiver_to_node, "tensor_metadata", ) receiver_ready = receiver.set_nccl_channel.remote(chan_typ, chan_ref) ray.get([chan_ref, receiver_ready]) shape = (10,) dtype = torch.float16 # Sending tensors of different shapes is okay as long as the non-tensor # data stays the same. refs = [] values = [] for i in range(1, 4): sender.send.remote(i, shape, dtype) values.append((i, shape, dtype)) refs.append(receiver.receive.remote()) assert ray.get(refs) == values # When _direct_return=True, we should never send non-tensor data. num_tensor_metadata_ops = ray.get( sender.get_num_channel_ops.remote("tensor_metadata") ) assert num_tensor_metadata_ops == 3 # Attempting to write a different number of tensors will error. with pytest.raises(ValueError): ray.get(sender.send_dict.remote({})) with pytest.raises(ValueError): ray.get( sender.send_dict.remote( { "first": torch.zeros(10), "second": torch.zeros(10), } ) ) # The channel is still usable for valid tensors after errors occur. sender.send.remote(1, shape, dtype) assert ray.get(receiver.receive.remote()) == (1, shape, dtype) @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 2, "num_gpus": 2, "num_nodes": 1, } ], indirect=True, ) def test_static_shape_and_direct_return(ray_start_cluster): """ Test that when static_shape=True and direct_return=True are passed, we only send metadata for the first operation. """ # Barrier name should be barrier-{lower rank}-{higher rank}. barrier = Barrier.options(name="barrier-0-1").remote() # noqa sender = Worker.remote() receiver = Worker.remote() ray.get( [ sender.start_mock.remote(), receiver.start_mock.remote(), ] ) nccl_id = _init_communicator([sender, receiver]) chan_typ = TorchTensorType( transport="accelerator", _static_shape=True, _direct_return=True, ) chan_typ.set_communicator_id(nccl_id) receiver_to_node = [(receiver, get_actor_node_id(receiver))] sender.create_traced_channel.remote("tensor_metadata", receiver_to_node) chan_ref = sender.create_nccl_channel.remote( chan_typ, receiver_to_node, "tensor_metadata", ) receiver_ready = receiver.set_nccl_channel.remote(chan_typ, chan_ref) ray.get([chan_ref, receiver_ready]) shape = (10,) dtype = torch.float16 refs = [] for i in range(3): sender.send.remote(i, shape, dtype) refs.append(receiver.receive.remote()) assert ray.get(refs) == [(i, shape, dtype) for i in range(3)] # When static_shape=True, we should only send one metadata op. After the # first metadata is sent, we reuse it for all future sends. num_tensor_metadata_ops = ray.get( sender.get_num_channel_ops.remote("tensor_metadata") ) assert num_tensor_metadata_ops == 1 # Attempting to write tensors of the wrong shape or dtype will error. with pytest.raises(ValueError): ray.get(sender.send.remote(4, (20,), dtype)) with pytest.raises(ValueError): ray.get(sender.send.remote(4, shape, torch.float32)) # Attempting to write a different number of tensors will error. with pytest.raises(ValueError): ray.get(sender.send_dict.remote({})) with pytest.raises(ValueError): ray.get( sender.send_dict.remote( { "first": torch.zeros(10), "second": torch.zeros(10), } ) ) # The channel is still usable for valid tensors after errors occur. sender.send.remote(4, shape, dtype) ray.get(receiver.receive.remote()) == (4, shape, dtype) @pytest.mark.parametrize( "ray_start_cluster", [ { "num_cpus": 2, "num_gpus": 2, "num_nodes": 1, } ], indirect=True, ) def test_direct_return_with_cpu_data_channel(ray_start_cluster): """ Test that when _direct_return=True is passed, cpu_data_channel must be None. If it is not None, an exception should be raised. """ barrier = Barrier.options(name="barrier-0-1").remote() # noqa sender = Worker.remote() receiver = Worker.remote() ray.get( [ sender.start_mock.remote(), receiver.start_mock.remote(), ] ) nccl_id = _init_communicator([sender, receiver]) chan_typ = TorchTensorType( transport="accelerator", _direct_return=True, ) chan_typ.set_communicator_id(nccl_id) receiver_to_node = [(receiver, get_actor_node_id(receiver))] sender.create_traced_channel.remote("cpu_data", receiver_to_node) chan_ref = sender.create_nccl_channel.remote( chan_typ, receiver_to_node, None, "cpu_data", ) with pytest.raises( AssertionError, match="CPU channel should be None if direct return is enabled" ): ray.get(chan_ref) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))