import logging import random import re import sys import threading import time import pytest import torch import ray from ray._common.test_utils import SignalActor, wait_for_condition from ray.experimental.collective import create_collective_group from ray.experimental.rdt.collective_tensor_transport import ( CollectiveTransportMetadata, ) # tensordict is not supported on macos ci, so we skip the tests support_tensordict = sys.platform != "darwin" if support_tensordict: from tensordict import TensorDict # TODO: check whether concurrency groups are created correctly if # enable_tensor_transport is True or if any methods are decorated with # @ray.method(tensor_transport=...). Check that specifying # .options(tensor_transport=...) fails if enable_tensor_transport is False. @ray.remote class GPUTestActor: @ray.method(tensor_transport="gloo") def echo(self, data): return data def add(self, a, b): return a + b def double(self, data): if isinstance(data, list): return [self.double(d) for d in data] if support_tensordict and isinstance(data, TensorDict): return data.apply(lambda x: x * 2) return data * 2 def increment(self, data): data += 1 return data def get_out_of_band_tensors(self, obj_id: str, timeout=None): rdt_store = ray._private.worker.global_worker.rdt_manager.rdt_store if timeout is None: timeout = 0 return rdt_store.wait_and_get_object(obj_id, timeout) def get_num_rdt_objects(self): rdt_manager = ray._private.worker.global_worker.rdt_manager return rdt_manager.rdt_store.get_num_objects() def fail(self, error_message): raise Exception(error_message) @ray.remote class ErrorActor: @ray.method(tensor_transport="gloo") def send(self, tensor): return tensor def recv(self, tensor): return tensor def clear_rdt_store(self): rdt_store = ray._private.worker.global_worker.rdt_manager.rdt_store with rdt_store._lock: assert len(rdt_store._rdt_store) > 0 rdt_store._rdt_store.clear() @ray.method(concurrency_group="_ray_system") def block_background_thread(self): time.sleep(100) def block_main_thread(self): time.sleep(100) @pytest.mark.parametrize("data_size_bytes", [100]) def test_gc_rdt_object(ray_start_regular, data_size_bytes): """ For small data, GPU objects are inlined, but the actual data lives on the remote actor. Therefore, if we decrement the reference count upon inlining, we may cause the tensors on the sender actor to be freed before transferring to the receiver actor. # TODO(kevin85421): Add a test for large CPU data that is not inlined # after https://github.com/ray-project/ray/issues/54281 is fixed. """ world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") small_tensor = torch.randn((1,)) cpu_data = b"1" * data_size_bytes data = [small_tensor, cpu_data] sender = actors[0] receiver = actors[1] ref1 = sender.echo.remote(data) ref2 = receiver.double.remote(ref1) ref3 = receiver.double.remote(ref1) result = ray.get(ref2) assert result[0] == pytest.approx(small_tensor * 2) assert result[1] == cpu_data * 2 result = ray.get(ref3) assert result[0] == pytest.approx(small_tensor * 2) assert result[1] == cpu_data * 2 wait_for_condition( lambda: ray.get(receiver.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) del ref1 wait_for_condition( lambda: ray.get(sender.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) def test_gc_rdt_metadata(ray_start_regular): actors = [GPUTestActor.remote() for _ in range(2)] create_collective_group(actors, backend="gloo") tensor = torch.randn((100, 100)) ref = actors[0].echo.remote(tensor) rdt_ref_id = ref.hex() rdt_manager = ray._private.worker.global_worker.rdt_manager assert rdt_manager.is_managed_object(rdt_ref_id) ray.get(actors[1].double.remote(ref)) del ref wait_for_condition( lambda: not rdt_manager.is_managed_object(rdt_ref_id), ) @pytest.mark.parametrize("data_size_bytes", [100]) def test_gc_del_ref_before_recv_finish(ray_start_regular, data_size_bytes): """ This test deletes the ObjectRef of the GPU object before calling `ray.get` to ensure the receiver finishes receiving the GPU object. """ world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") small_tensor = torch.randn((1,)) cpu_data = b"1" * data_size_bytes data = [small_tensor, cpu_data] sender = actors[0] receiver = actors[1] ref1 = sender.echo.remote(data) ref2 = receiver.double.remote(ref1) del ref1 result = ray.get(ref2) assert result[0] == pytest.approx(small_tensor * 2) assert result[1] == cpu_data * 2 wait_for_condition( lambda: ray.get(receiver.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) wait_for_condition( lambda: ray.get(sender.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) def test_gc_intra_actor_rdt_object(ray_start_regular): """ This test checks that passes a GPU object ref to the same actor multiple times. """ actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") small_tensor = torch.randn((1,)) ref = actor.echo.remote(small_tensor) result = actor.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) result = actor.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) del ref wait_for_condition( lambda: ray.get(actor.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) def test_gc_pass_ref_to_same_and_different_actors(ray_start_regular): """ This test checks that passes a GPU object ref to the same actor and a different actor. """ actor1 = GPUTestActor.remote() actor2 = GPUTestActor.remote() create_collective_group([actor1, actor2], backend="gloo") small_tensor = torch.randn((1,)) ref = actor1.echo.remote(small_tensor) result1 = actor1.double.remote(ref) result2 = actor2.double.remote(ref) assert ray.get(result1) == pytest.approx(small_tensor * 2) assert ray.get(result2) == pytest.approx(small_tensor * 2) wait_for_condition( lambda: ray.get(actor2.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) del ref wait_for_condition( lambda: ray.get(actor1.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) def test_p2p(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") small_tensor = torch.randn((1,)) sender = actors[0] receiver = actors[1] ref = sender.echo.remote(small_tensor) result = receiver.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) medium_tensor = torch.randn((500, 500)) ref = sender.echo.remote(medium_tensor) result = receiver.double.remote(ref) assert ray.get(result) == pytest.approx(medium_tensor * 2) def test_p2p_errors_before_group_creation(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] small_tensor = torch.randn((1,)) sender = actors[0] with pytest.raises( ValueError, match="Actor.* does not have tensor transport GLOO available.*", ): sender.echo.remote(small_tensor) @pytest.mark.parametrize("has_tensor_transport_method", [True, False]) def test_p2p_blocking(ray_start_regular, has_tensor_transport_method): """Test that p2p transfers still work when sender is blocked in another task. This should work whether the actor has (a) a tensor transport method (a method decorated with @ray.method(tensor_transport=...)) or (b) an actor-level decorator @ray.remote(enable_tensor_transport=True).""" class _GPUTestActor: def double(self, data): if isinstance(data, list): return [self.double(d) for d in data] if support_tensordict and isinstance(data, TensorDict): return data.apply(lambda x: x * 2) return data * 2 def infinite_sleep(self, signal): signal.send.remote() while True: time.sleep(0.1) if has_tensor_transport_method: # Test tensor transport annotation via ray.method. @ray.remote class GPUTestActor(_GPUTestActor): @ray.method(tensor_transport="gloo") def echo(self, data): return data else: # Test tensor transport annotation via ray.remote. @ray.remote(enable_tensor_transport=True) class GPUTestActor(_GPUTestActor): def echo(self, data): return data sender, receiver = GPUTestActor.remote(), GPUTestActor.remote() signal = SignalActor.remote() create_collective_group([sender, receiver], backend="gloo") tensor = torch.randn((500, 500)) # If the actor does not have a tensor transport method declared, declare it # dynamically using .options(). sender_fn = ( sender.echo if has_tensor_transport_method else sender.echo.options(tensor_transport="gloo") ) ref = sender_fn.remote(tensor) # Start a blocking task on the sender actor. sender.infinite_sleep.remote(signal) ray.get(signal.wait.remote(), timeout=10) # Ensure that others can still receive the object. result = receiver.double.remote(ref) result = ray.get(result, timeout=10) assert result == pytest.approx(tensor * 2) def test_p2p_with_cpu_data(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") sender = actors[0] receiver = actors[1] cpu_data = 123 ref = sender.echo.remote(cpu_data) result = receiver.double.remote(ref) assert ray.get(result) == cpu_data * 2 def test_send_same_ref_to_same_actor_task_multiple_times(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") small_tensor = torch.randn((1,)) sender = actors[0] receiver = actors[1] ref = sender.echo.remote(small_tensor) result = receiver.add.remote(ref, ref) assert ray.get(result) == pytest.approx(small_tensor * 2) wait_for_condition( lambda: ray.get(receiver.get_num_rdt_objects.remote()) == 0, timeout=10, retry_interval_ms=100, ) def test_send_same_ref_to_same_actor_multiple_times(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") small_tensor = torch.randn((1,)) sender = actors[0] receiver = actors[1] ref = sender.echo.remote(small_tensor) result = receiver.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) result = receiver.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) def test_intra_rdt_tensor_transfer(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") small_tensor = torch.randn((1,)) # Intra-actor communication for pure GPU tensors ref = actor.echo.remote(small_tensor) result = actor.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) # Intra-actor communication for mixed CPU and GPU data cpu_data = random.randint(0, 100) data = [small_tensor, cpu_data] ref = actor.echo.remote(data) result = actor.double.remote(ref) assert ray.get(result) == pytest.approx([small_tensor * 2, cpu_data * 2]) # Intra-actor communication for multiple GPU tensors tensor1 = torch.randn((1,)) tensor2 = torch.randn((2,)) data = [tensor1, tensor2, cpu_data] ref = actor.echo.remote(data) result = actor.double.remote(ref) result = ray.get(result) assert result[0] == pytest.approx(tensor1 * 2) assert result[1] == pytest.approx(tensor2 * 2) assert result[2] == cpu_data * 2 def test_send_same_ref_multiple_times_intra_actor(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") small_tensor = torch.randn((1,)) ref = actor.echo.remote(small_tensor) result = actor.add.remote(ref, ref) assert ray.get(result) == pytest.approx(small_tensor * 2) def test_mix_cpu_gpu_data(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") tensor = torch.randn((1,)) cpu_data = random.randint(0, 100) data = [tensor, cpu_data] sender, receiver = actors[0], actors[1] ref = sender.echo.remote(data) ref = receiver.double.remote(ref) result = ray.get(ref) assert result[0] == pytest.approx(tensor * 2) assert result[1] == cpu_data * 2 def test_object_in_plasma(ray_start_regular): """ This test uses a CPU object that is large enough to be stored in plasma instead of being inlined in the gRPC message. """ world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") tensor = torch.randn((1,)) cpu_data = b"1" * 1000 * 1000 data = [tensor, cpu_data] sender, receiver = actors[0], actors[1] ref = sender.echo.remote(data) ref = receiver.double.remote(ref) result = ray.get(ref) assert result[0] == pytest.approx(tensor * 2) assert result[1] == cpu_data * 2 def test_multiple_tensors(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") tensor1 = torch.randn((1,)) tensor2 = torch.randn((2,)) if support_tensordict: td1 = TensorDict( {"action1": torch.randn((2,)), "reward1": torch.randn((2,))}, batch_size=[2] ) td2 = TensorDict( {"action2": torch.randn((2,)), "reward2": torch.randn((2,))}, batch_size=[2] ) else: td1 = 0 td2 = 0 cpu_data = random.randint(0, 100) data = [tensor1, tensor2, cpu_data, td1, td2] sender, receiver = actors[0], actors[1] ref = sender.echo.remote(data) ref = receiver.double.remote(ref) result = ray.get(ref) assert result[0] == pytest.approx(tensor1 * 2) assert result[1] == pytest.approx(tensor2 * 2) assert result[2] == cpu_data * 2 if support_tensordict: assert result[3]["action1"] == pytest.approx(td1["action1"] * 2) assert result[3]["reward1"] == pytest.approx(td1["reward1"] * 2) assert result[4]["action2"] == pytest.approx(td2["action2"] * 2) assert result[4]["reward2"] == pytest.approx(td2["reward2"] * 2) def test_trigger_out_of_band_tensor_transfer(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") src_actor, dst_actor = actors[0], actors[1] tensor = torch.tensor([1, 2, 3]) rdt_ref = src_actor.echo.remote(tensor) rdt_ref_id = rdt_ref.hex() # Check src_actor has the GPU object ret_val_src = ray.get(src_actor.get_out_of_band_tensors.remote(rdt_ref_id)) assert ret_val_src is not None assert len(ret_val_src) == 1 assert torch.equal(ret_val_src[0], tensor) rdt_manager = ray._private.worker.global_worker.rdt_manager rdt_manager.add_rdt_ref(rdt_ref, src_actor, "GLOO") # Trigger out-of-band tensor transfer from src_actor to dst_actor. task_args = (rdt_ref,) rdt_manager.queue_or_trigger_out_of_band_tensor_transfer(dst_actor, task_args) rdt_manager.set_tensor_transport_metadata_and_trigger_queued_operations( rdt_ref_id, CollectiveTransportMetadata( tensor_meta=[(tensor.shape, tensor.dtype)], tensor_device=tensor.device.type, ), ) # Check dst_actor has the GPU object ret_val_dst = ray.get( dst_actor.get_out_of_band_tensors.remote(rdt_ref_id, timeout=10) ) assert ret_val_dst is not None assert len(ret_val_dst) == 1 assert torch.equal(ret_val_dst[0], tensor) def test_fetch_rdt_object_to_driver(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") tensor1 = torch.tensor([1, 2, 3]) tensor2 = torch.tensor([4, 5, 6]) # Case 1: Single tensor ref = actor.echo.remote(tensor1) assert torch.equal(ray.get(ref, _use_object_store=True), tensor1) # Case 2: Multiple tensors ref = actor.echo.remote([tensor1, tensor2]) result = ray.get(ref, _use_object_store=True) assert torch.equal(result[0], tensor1) assert torch.equal(result[1], tensor2) # Case 3: Mixed CPU and GPU data data = [tensor1, tensor2, 7] ref = actor.echo.remote(data) result = ray.get(ref, _use_object_store=True) assert torch.equal(result[0], tensor1) assert torch.equal(result[1], tensor2) assert result[2] == 7 def test_invalid_tensor_transport(ray_start_regular): with pytest.raises(ValueError, match="Invalid tensor transport"): @ray.remote class InvalidActor: @ray.method(tensor_transport="invalid") def echo(self, data): return data actor = GPUTestActor.remote() with pytest.raises(ValueError, match="Invalid tensor transport"): actor.double.options(tensor_transport="invalid").remote(torch.randn((1,))) with pytest.raises(ValueError, match="Invalid tensor transport"): ray.put(torch.randn((1,)), _tensor_transport="invalid") @pytest.mark.skipif( not support_tensordict, reason="tensordict is not supported on this platform", ) def test_tensordict_transfer(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") td = TensorDict( {"action": torch.randn((2,)), "reward": torch.randn((2,))}, batch_size=[2] ) sender, receiver = actors[0], actors[1] ref = sender.echo.remote(td) result = receiver.double.remote(ref) td_result = ray.get(result) assert td_result["action"] == pytest.approx(td["action"] * 2) assert td_result["reward"] == pytest.approx(td["reward"] * 2) @pytest.mark.skipif( not support_tensordict, reason="tensordict is not supported on this platform", ) def test_nested_tensordict(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") inner_td = TensorDict( {"action": torch.randn((2,)), "reward": torch.randn((2,))}, batch_size=[2] ) outer_td = TensorDict( {"inner_td": inner_td, "test": torch.randn((2,))}, batch_size=[2] ) sender = actors[0] receiver = actors[1] rdt_ref = sender.echo.remote(outer_td) ret_val_src = ray.get(receiver.double.remote(rdt_ref)) assert ret_val_src is not None assert torch.equal(ret_val_src["inner_td"]["action"], inner_td["action"] * 2) assert torch.equal(ret_val_src["inner_td"]["reward"], inner_td["reward"] * 2) assert torch.equal(ret_val_src["test"], outer_td["test"] * 2) @pytest.mark.skipif( not support_tensordict, reason="tensordict is not supported on this platform", ) def test_tensor_extracted_from_tensordict_in_rdt_store(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") td = TensorDict( {"action": torch.randn((2,)), "reward": torch.randn((2,))}, batch_size=[2] ).to("cpu") rdt_ref = actor.echo.remote(td) # Since the tensor is extracted from the tensordict, the `ret_val_src` will be a list of tensors # instead of a tensordict. ret_val_src = ray.get(actor.get_out_of_band_tensors.remote(rdt_ref.hex())) assert ret_val_src is not None assert len(ret_val_src) == 2 assert torch.equal(ret_val_src[0], td["action"]) assert torch.equal(ret_val_src[1], td["reward"]) @pytest.mark.parametrize("enable_tensor_transport", [True, False]) def test_dynamic_tensor_transport_via_options( ray_start_regular, enable_tensor_transport ): """Test that tensor_transport can be set dynamically via .options() at call time, if enable_tensor_transport is set to True in @ray.remote.""" class TestActor: def __init__(self): pass def normal_method(self): return "normal" def tensor_method(self): return torch.randn(5, 5) def double(self, data): return data * 2 if enable_tensor_transport: TestActor = ray.remote(enable_tensor_transport=True)(TestActor) else: TestActor = ray.remote(TestActor) # Create actor without any tensor_transport decorators sender = TestActor.remote() receiver = TestActor.remote() create_collective_group([sender, receiver], backend="gloo") # Test normal method call result = ray.get(sender.normal_method.remote()) assert result == "normal" # Test method call with tensor_transport specified via .options() if enable_tensor_transport: # If enable_tensor_transport is set to True, then it's okay to use # dynamic tensor_transport. ref = sender.tensor_method.options(tensor_transport="gloo").remote() tensor = ray.get(ref, _use_object_store=True) result = ray.get(receiver.double.remote(ref), _use_object_store=True) assert result == pytest.approx(tensor * 2) else: # If enable_tensor_transport is not set, then user cannot use # dynamic tensor_transport. with pytest.raises( ValueError, match='Currently, methods with .options\\(tensor_transport="GLOO"\\) are not supported when enable_tensor_transport=False. Please set @ray.remote\\(enable_tensor_transport=True\\) on the actor class definition.', ): ref = sender.tensor_method.options(tensor_transport="gloo").remote() def test_app_error_inter_actor(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") src_actor, dst_actor = actors[0], actors[1] # Make sure the receiver can receive an exception from the sender. ref = src_actor.fail.options(tensor_transport="gloo").remote("test_app_error") with pytest.raises(Exception, match="test_app_error"): ray.get(dst_actor.double.remote(ref)) # Make sure the sender and receiver do not hang. small_tensor = torch.randn((1,)) ref = src_actor.echo.remote(small_tensor) result = dst_actor.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) def test_app_error_intra_actor(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") # Make sure the receiver can receive an exception from the sender. ref = actor.fail.options(tensor_transport="gloo").remote("test_app_error") with pytest.raises(Exception, match="test_app_error"): ray.get(actor.double.remote(ref)) # Make sure the sender and receiver do not hang. small_tensor = torch.randn((1,)) ref = actor.echo.remote(small_tensor) result = actor.double.remote(ref) assert ray.get(result) == pytest.approx(small_tensor * 2) def test_app_error_fetch_to_driver(ray_start_regular): actor = GPUTestActor.remote() create_collective_group([actor], backend="gloo") ref = actor.fail.options(tensor_transport="gloo").remote("test_app_error") with pytest.raises(Exception, match="test_app_error"): ray.get(ref, _use_object_store=True) # Make sure the driver can receive an exception from the actor. small_tensor = torch.tensor([1, 2, 3]) ref = actor.echo.remote(small_tensor) assert torch.equal(ray.get(ref, _use_object_store=True), small_tensor) @ray.remote class FailingRDTActor: def __init__(self): self.attempts = 0 @ray.method( tensor_transport="gloo", max_task_retries=1, retry_exceptions=[ValueError] ) def fail_first_attempt(self): self.attempts += 1 if self.attempts == 1: raise ValueError("first-attempt failure") return torch.tensor([1, 2, 3]) @ray.method( tensor_transport="gloo", max_task_retries=1, retry_exceptions=[ValueError] ) def rdt_obj_always_fails(self): self.attempts += 1 raise ValueError("permanent failure") def consume(self, tensor): return tensor def get_num_rdt_objects(self): return ray._private.worker.global_worker.rdt_manager.rdt_store.get_num_objects() def test_rdt_retry_then_succeeds(ray_start_regular): """ Retryable exception on first attempt, success on second Only one entry should be in the RDTStore. """ sender = FailingRDTActor.remote() receiver = FailingRDTActor.remote() create_collective_group([sender, receiver], backend="gloo") ref = sender.fail_first_attempt.remote() result = ray.get(receiver.consume.remote(ref)) assert torch.equal(result, torch.tensor([1, 2, 3])) # Sender should hold one primary entry for this ref assert ray.get(sender.get_num_rdt_objects.remote()) == 1 def test_rdt_retry_fetch_through_obj_store(ray_start_regular): """ Retryable exception on first attempt, successful fetch to driver on second """ sender = FailingRDTActor.remote() create_collective_group([sender], backend="gloo") ref = sender.fail_first_attempt.remote() assert torch.equal(ray.get(ref, _use_object_store=True), torch.tensor([1, 2, 3])) def test_rdt_retries_exhausted_raises(ray_start_regular): """ When all retries fail, the user's exception must propagate to the consumer via the CPU path (no direct_transport_metadata is set on the final reply, so the consumer sees the error when deserializing the arg). """ sender = FailingRDTActor.remote() receiver = FailingRDTActor.remote() create_collective_group([sender, receiver], backend="gloo") ref = sender.rdt_obj_always_fails.remote() with pytest.raises(Exception, match="permanent failure"): ray.get(receiver.consume.remote(ref)) def test_write_after_save(ray_start_regular): """Check that an actor can safely write to a tensor after saving it to its local state by calling `ray.experimental.wait_tensor_freed`.""" @ray.remote(enable_tensor_transport=True) class GPUTestActor: @ray.method(tensor_transport="gloo") def save(self, data: torch.Tensor): # Save the tensor to the actor's local state. self.data = data return data def receive(self, data: torch.Tensor): return data def increment_saved(self): ray.experimental.wait_tensor_freed(self.data) # Write to the saved tensor. self.data += 1 return self.data world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") medium_tensor = torch.randn((500, 500)) sender, receiver = actors ref = sender.save.remote(medium_tensor) # Sender writes to the GPU object while Ray sends the object to a receiver # task in the background. tensor1 = sender.increment_saved.remote() tensor2 = receiver.receive.remote(ref) # The sender task should not have returned yet because the ObjectRef is # still in scope. with pytest.raises(ray.exceptions.GetTimeoutError): ray.get(tensor1, timeout=1) del ref # Check that Ray completed the transfer of the original tensor before the # sender writes to it. assert torch.allclose(ray.get(tensor1), medium_tensor + 1) assert torch.allclose(ray.get(tensor2), medium_tensor) def test_wait_tensor_freed(ray_start_regular): """Unit test for ray.experimental.wait_tensor_freed. Check that the call returns when the tensor has been freed from the GPU object store.""" rdt_store = ray.worker.global_worker.rdt_manager.rdt_store obj_id = "random_id" tensor = torch.randn((1,)) rdt_store.add_object_primary(obj_id, [tensor], "GLOO") assert rdt_store.has_object(obj_id) with pytest.raises(TimeoutError): ray.experimental.wait_tensor_freed(tensor, timeout=1) assert rdt_store.has_object(obj_id) # Simulate garbage collection in a background thread. def gc(): time.sleep(0.1) rdt_store.pop_object(obj_id) gc_thread = threading.Thread(target=gc) gc_thread.start() # Now the wait_tensor_freed call should be able to return. ray.experimental.wait_tensor_freed(tensor) gc_thread.join() assert not rdt_store.has_object(obj_id) def test_wait_tensor_freed_double_tensor(ray_start_regular): """Unit test for ray.experimental.wait_tensor_freed when multiple objects contain the same tensor.""" rdt_store = ray.worker.global_worker.rdt_manager.rdt_store obj_id1 = "random_id1" obj_id2 = "random_id2" tensor = torch.randn((1,)) rdt_store.add_object_primary(obj_id1, [tensor], "GLOO") rdt_store.add_object_primary(obj_id2, [tensor], "GLOO") assert rdt_store.has_object(obj_id1) assert rdt_store.has_object(obj_id2) with pytest.raises(TimeoutError): ray.experimental.wait_tensor_freed(tensor, timeout=1) assert rdt_store.has_object(obj_id1) assert rdt_store.has_object(obj_id2) # Simulate garbage collection in a background thread. def gc(obj_id): time.sleep(0.1) rdt_store.pop_object(obj_id) # Free one object. Tensor should still be stored. gc_thread = threading.Thread(target=gc, args=(obj_id1,)) gc_thread.start() with pytest.raises(TimeoutError): ray.experimental.wait_tensor_freed(tensor, timeout=1) gc_thread.join() assert not rdt_store.has_object(obj_id1) # Free the other object. Now the wait_tensor_freed call should be able to # return. gc_thread = threading.Thread(target=gc, args=(obj_id2,)) gc_thread.start() ray.experimental.wait_tensor_freed(tensor) gc_thread.join() assert not rdt_store.has_object(obj_id2) def test_send_back_and_dst_warning(ray_start_regular): # Test warning when object is sent back to the src actor and to dst actors world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") src_actor, dst_actor = actors[0], actors[1] tensor = torch.tensor([1, 2, 3]) warning_message = r"RDT ObjectRef\(.+\)" with pytest.warns(UserWarning, match=warning_message): t = src_actor.echo.remote(tensor) t1 = src_actor.echo.remote(t) # Sent back to the source actor t2 = dst_actor.echo.remote(t) # Also sent to another actor ray.get([t1, t2], _use_object_store=True) # Second transmission of ObjectRef `t` to `dst_actor` should not trigger a warning # Verify no `pytest.warns` context is used here because no warning should be raised t3 = dst_actor.echo.remote(t) ray.get(t3, _use_object_store=True) def test_duplicate_objectref_transfer(ray_start_regular): world_size = 2 actors = [GPUTestActor.remote() for _ in range(world_size)] create_collective_group(actors, backend="gloo") actor0, actor1 = actors[0], actors[1] small_tensor = torch.randn((1,)) # Store the original value for comparison original_value = small_tensor ref = actor0.echo.remote(small_tensor) # Pass the same ref to actor1 twice result1 = actor1.increment.remote(ref) result2 = actor1.increment.remote(ref) # Both should return original_value + 1 because each increment task should receive the same object value. val1 = ray.get(result1) val2 = ray.get(result2) # Check for correctness assert val1 == pytest.approx( original_value + 1 ), f"Result1 incorrect: got {val1}, expected {original_value + 1}" assert val2 == pytest.approx( original_value + 1 ), f"Result2 incorrect: got {val2}, expected {original_value + 1}" # Additional check: results should be equal (both got clean copies) assert val1 == pytest.approx( val2 ), f"Results differ: result1={val1}, result2={val2}" def test_transfer_from_not_actor_creator(ray_start_regular): @ray.remote class Actor: @ray.method(tensor_transport="gloo") def create(self): return torch.tensor([1, 2, 3]) def consume(self, obj): return obj def do_transfer(self, a1, a2): create_collective_group([a1, a2], backend="torch_gloo") return ray.get(a1.consume.remote(a2.create.remote())) actor = [Actor.remote() for _ in range(3)] assert ray.get(actor[2].do_transfer.remote(actor[0], actor[1])) == pytest.approx( torch.tensor([1, 2, 3]) ) def test_send_fails(ray_start_regular): actors = [ErrorActor.remote() for _ in range(2)] create_collective_group(actors, backend="torch_gloo") # The gpu object will be gone when we trigger the transfer # so the send will error out rdt_ref = actors[0].send.remote(torch.randn((100, 100))) ray.get(actors[0].clear_rdt_store.remote()) result_ref = actors[1].recv.remote(rdt_ref) with pytest.raises(ray.exceptions.ActorDiedError): ray.get(result_ref) def test_send_actor_dies_before_creating(ray_start_regular): actors = [ErrorActor.remote() for _ in range(2)] create_collective_group(actors, backend="torch_gloo") # Block the main thread so the object doesn't get created before the kill actors[0].block_main_thread.remote() gpu_obj_ref = actors[0].send.remote(torch.randn(100, 100)) result_ref = actors[1].recv.remote(gpu_obj_ref) ray.kill(actors[0]) with pytest.raises(ray.exceptions.ActorDiedError): ray.get(result_ref) def test_send_actor_dies_before_sending(ray_start_regular): actors = [ErrorActor.remote() for _ in range(2)] create_collective_group(actors, backend="torch_gloo") rdt_ref = actors[0].send.remote(torch.randn(100, 100)) # Wait for the object to actually be created on the sender ray.wait([rdt_ref]) # Block the background thread before triggering the transfer # so the send doesn't happen before the actor is killed actors[0].block_background_thread.remote() result_ref = actors[1].recv.remote(rdt_ref) ray.kill(actors[0]) with pytest.raises(ray.exceptions.ActorDiedError): ray.get(result_ref) def test_recv_actor_dies(ray_start_regular, caplog, propagate_logs): actors = [ErrorActor.remote() for _ in range(2)] create_collective_group(actors, backend="torch_gloo") # Do a transfer with the receiver's background thread blocked, # so the recv doesn't happen before the actor is killed rdt_ref = actors[0].send.remote(torch.randn((100, 100))) actors[1].block_background_thread.remote() result_ref = actors[1].recv.remote(rdt_ref) ray.kill(actors[1]) def check_logs(): records = caplog.records return any( record.levelno == logging.ERROR and re.search(r"RDT transfer with.*failed", record.message) for record in records ) and any( record.levelno == logging.ERROR and "Destroyed collective group" in record.message for record in records ) wait_for_condition(check_logs) with pytest.raises(ray.exceptions.ActorDiedError): ray.get(result_ref) with pytest.raises(ray.exceptions.ActorDiedError): ray.get(actors[0].recv.remote(1)) @pytest.mark.skip( "Lineage Reconstruction currently results in a check failure with RDT" ) def test_rdt_lineage_reconstruction(ray_start_cluster): cluster = ray_start_cluster cluster.add_node(num_cpus=0) ray.init(address=cluster.address) cluster.add_node(num_cpus=1) worker_to_kill = cluster.add_node(num_cpus=1, resources={"to_restart": 1}) @ray.remote(max_restarts=1, max_task_retries=1, resources={"to_restart": 1}) class RecvRestartableActor: def recv(self, obj): return obj send_actor = GPUTestActor.remote() recv_actor = RecvRestartableActor.remote() create_collective_group([send_actor, recv_actor], backend="gloo") one_mb_tensor = torch.randn((1024 * 1024,)) ref = recv_actor.recv.remote(send_actor.echo.remote(one_mb_tensor)) ray.wait([ref], fetch_local=False) cluster.remove_node(worker_to_kill, allow_graceful=False) cluster.add_node(num_cpus=1, resources={"to_restart": 1}) assert ray.get(ref).nbytes >= (1024 * 1024) if __name__ == "__main__": sys.exit(pytest.main(["-sv", __file__]))