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

722 lines
27 KiB
Python

import sys
import pytest
import torch
import ray
from ray._common.test_utils import SignalActor, wait_for_condition
from ray.experimental import set_target_for_ref
from ray.experimental.rdt.util import get_tensor_transport_manager
@ray.remote(num_gpus=1, num_cpus=0, enable_tensor_transport=True)
class GPUTestActor:
def __init__(self):
self.reserved_tensor1 = torch.tensor([1, 2, 3]).to("cuda")
self.reserved_tensor2 = torch.tensor([4, 5, 6]).to("cuda")
self.reserved_tensor3 = torch.tensor([7, 8, 9]).to("cuda")
@ray.method(tensor_transport="nixl")
def echo(self, data, device):
return data.to(device)
def sum(self, data, device):
assert data.device.type == device
return data.sum().item()
def produce(self, tensors):
refs = []
for t in tensors:
refs.append(ray.put(t, _tensor_transport="nixl"))
return refs
def consume_with_nixl(self, refs):
tensors = [ray.get(ref) for ref in refs]
sum = 0
for t in tensors:
assert t.device.type == "cuda"
sum += t.sum().item()
return sum
def consume_with_object_store(self, refs):
tensors = [ray.get(ref, _use_object_store=True) for ref in refs]
sum = 0
for t in tensors:
assert t.device.type == "cuda"
sum += t.sum().item()
return sum
def gc(self):
tensor = torch.tensor([1, 2, 3]).to("cuda")
ref = ray.put(tensor, _tensor_transport="nixl")
obj_id = ref.hex()
rdt_manager = ray._private.worker.global_worker.rdt_manager
nixl_transport = get_tensor_transport_manager("NIXL")
assert rdt_manager.rdt_store.has_tensor(tensor)
assert rdt_manager.is_managed_object(obj_id)
assert obj_id in nixl_transport._managed_meta_nixl
# Tensor-level metadata counting: the tensor should have metadata_count=1
key = tensor.untyped_storage().data_ptr()
assert key in nixl_transport._tensor_desc_cache
assert nixl_transport._tensor_desc_cache[key].metadata_count == 1
del ref
rdt_manager.rdt_store.wait_tensor_freed(tensor, timeout=10)
assert not rdt_manager.rdt_store.has_tensor(tensor)
assert not rdt_manager.is_managed_object(obj_id)
assert obj_id not in nixl_transport._managed_meta_nixl
assert key not in nixl_transport._tensor_desc_cache
return "Success"
@ray.method(tensor_transport="nixl")
def send_dict1(self):
return {"round1-1": self.reserved_tensor1, "round1-2": self.reserved_tensor2}
@ray.method(tensor_transport="nixl")
def send_dict2(self):
return {"round2-1": self.reserved_tensor1, "round2-3": self.reserved_tensor3}
def sum_dict(self, dict):
return sum(v.sum().item() for v in dict.values())
def get_num_rdt_objects(self):
rdt_manager = ray._private.worker.global_worker.rdt_manager
return rdt_manager.rdt_store.get_num_objects()
def get_num_managed_meta_nixl(self):
return get_tensor_transport_manager("NIXL")._get_num_managed_meta_nixl()
def put_shared_tensor_lists(self):
"""Create two tensor lists that share a common tensor and put them with NIXL transport."""
t1 = torch.tensor([1, 2, 3]).to("cuda")
t2 = torch.tensor([4, 5, 6]).to("cuda")
t3 = torch.tensor([7, 8, 9]).to("cuda")
list1 = [t1, t2]
list2 = [t2, t3]
ref1 = ray.put(list1, _tensor_transport="nixl")
# Nixl itself doesn't handle duplicate memory registrations,
# hence this call would fail without proper deduplication.
ref2 = ray.put(list2, _tensor_transport="nixl")
return ref1, ref2
@ray.method(concurrency_group="_ray_system")
def block_background_thread(self, signal_actor):
ray.get(signal_actor.wait.remote())
def borrow_and_sum(self, ref_list):
return ray.get(ref_list[0]).sum().item()
def block_main_thread(self, signal_actor):
ray.get(signal_actor.wait.remote())
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_ray_get_rdt_ref_created_by_actor_task(ray_start_regular):
actor = GPUTestActor.remote()
tensor = torch.tensor([1, 2, 3]).to("cuda")
ref1 = actor.echo.remote(tensor, "cuda")
ref2 = actor.echo.remote(tensor, "cuda")
ref3 = actor.echo.remote(tensor, "cuda")
# Test ray.get with default tensor transport, should use nixl here.
# TODO: Verify it's using the correct tensor transport.
assert torch.equal(ray.get(ref1), tensor)
# # Test ray.get with nixl tensor transport
assert torch.equal(ray.get(ref2), tensor)
# # Test ray.get with object store tensor transport
assert torch.equal(ray.get(ref3, _use_object_store=True), tensor)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_p2p(ray_start_regular):
num_actors = 2
actors = [GPUTestActor.remote() for _ in range(num_actors)]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
tensor1 = torch.tensor([4, 5, 6])
# Test GPU to GPU transfer
ref = src_actor.echo.remote(tensor, "cuda")
# Trigger tensor transfer from src to dst actor
result = dst_actor.sum.remote(ref, "cuda")
assert tensor.sum().item() == ray.get(result)
# Test CPU to CPU transfer
ref1 = src_actor.echo.remote(tensor1, "cpu")
result1 = dst_actor.sum.remote(ref1, "cpu")
assert tensor1.sum().item() == ray.get(result1)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_intra_rdt_tensor_transfer(ray_start_regular):
actor = GPUTestActor.remote()
tensor = torch.tensor([1, 2, 3])
# Intra-actor communication for pure GPU tensors
ref = actor.echo.remote(tensor, "cuda")
result = actor.sum.remote(ref, "cuda")
assert tensor.sum().item() == ray.get(result)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_put_and_get_object_with_nixl(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
src_actor, dst_actor = actors[0], actors[1]
tensor1 = torch.tensor([1, 2, 3]).to("cuda")
tensor2 = torch.tensor([4, 5, 6, 0]).to("cuda")
tensor3 = torch.tensor([7, 8, 9, 0, 0]).to("cuda")
tensors = [tensor1, tensor2, tensor3]
ref = src_actor.produce.remote(tensors)
ref1 = dst_actor.consume_with_nixl.remote(ref)
result1 = ray.get(ref1)
assert result1 == 45
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_put_and_get_object_with_object_store(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
src_actor, dst_actor = actors[0], actors[1]
tensor1 = torch.tensor([1, 2, 3]).to("cuda")
tensor2 = torch.tensor([4, 5, 6, 0]).to("cuda")
tensor3 = torch.tensor([7, 8, 9, 0, 0]).to("cuda")
tensors = [tensor1, tensor2, tensor3]
ref = src_actor.produce.remote(tensors)
ref1 = dst_actor.consume_with_object_store.remote(ref)
result1 = ray.get(ref1)
assert result1 == 45
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_put_gc(ray_start_regular):
actor = GPUTestActor.remote()
ref = actor.gc.remote()
assert ray.get(ref) == "Success"
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_send_duplicate_tensor(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
src_actor, dst_actor = actors[0], actors[1]
ref1 = src_actor.send_dict1.remote()
result1 = dst_actor.sum_dict.remote(ref1)
assert ray.get(result1) == 21
ref2 = src_actor.send_dict1.remote()
result2 = dst_actor.sum_dict.remote(ref2)
assert ray.get(result2) == 21
del ref1
del ref2
wait_for_condition(
lambda: ray.get(src_actor.get_num_rdt_objects.remote()) == 0,
timeout=10,
retry_interval_ms=100,
)
wait_for_condition(
lambda: ray.get(src_actor.get_num_managed_meta_nixl.remote()) == 0,
timeout=10,
retry_interval_ms=100,
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_abort_sender_dies_before_creating(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
# Trigger transfer and kill sender before the receiver starts receiving
signal_actor = SignalActor.remote()
actors[0].block_main_thread.remote(signal_actor)
ref = actors[0].echo.remote(torch.randn((100, 100)), "cuda")
result = actors[1].sum.remote(ref, "cuda")
ray.kill(actors[0])
with pytest.raises(ray.exceptions.ActorDiedError):
ray.get(result)
# Try a transfer with actor[1] receiving again
new_actor = GPUTestActor.remote()
ref = new_actor.echo.remote(torch.tensor([4, 5, 6]), "cuda")
result = actors[1].sum.remote(ref, "cuda")
assert ray.get(result) == 15
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_abort_sender_dies_before_sending(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
"""
1. Block background thread on receiver so receive doesn't start
2. Wait until the object is created so the transfer gets triggered
3. Kill the sender
4. Unblock the receiver
"""
signal_actor = SignalActor.remote()
actors[1].block_background_thread.remote(signal_actor)
ref = actors[0].echo.remote(torch.randn((100, 100)), "cuda")
result = actors[1].sum.remote(ref, "cuda")
ray.wait([ref])
ray.kill(actors[0])
signal_actor.send.remote()
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(result)
exc_str = str(excinfo.value)
assert "nixlBackendError" in exc_str and "The source actor may have died" in exc_str
# Try a transfer with actor[1] receiving again
new_actor = GPUTestActor.remote()
ref = new_actor.echo.remote(torch.tensor([4, 5, 6]), "cuda")
result = actors[1].sum.remote(ref, "cuda")
assert ray.get(result) == 15
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_del_before_creating(ray_start_regular):
"""
Blocking the main thread until we free the object from the reference counter.
Then unblocking the actor's main thread so the object can be created and then
asserting that the object was actually freed.
"""
signal_actor = SignalActor.remote()
actor = GPUTestActor.remote()
actor.block_main_thread.remote(signal_actor)
ref = actor.echo.remote(torch.tensor([4, 5, 6]), "cuda")
obj_id = ref.hex()
del ref
ray.get(signal_actor.send.remote())
wait_for_condition(
lambda: ray._private.worker.global_worker.rdt_manager.get_rdt_metadata(obj_id)
is None,
)
wait_for_condition(
lambda: ray.get(actor.get_num_rdt_objects.remote()) == 0,
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_owner_gets_from_launched_task(ray_start_regular):
actor = GPUTestActor.remote()
tensor = torch.randn((100, 100))
ref = actor.echo.remote(tensor, "cuda")
assert torch.equal(ray.get(ref), tensor.to("cuda"))
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_out_of_order_actors(ray_start_regular):
@ray.remote(num_cpus=0, num_gpus=1, max_concurrency=10)
class GPUTestActor:
def __init__(self):
self.tensor = torch.tensor([4, 5, 6], device="cuda")
@ray.method(tensor_transport="nixl")
async def get_tensor(self):
return self.tensor
async def sum(self, data):
return data.sum().item()
actors = [GPUTestActor.remote() for _ in range(2)]
results = []
for _ in range(100):
ref = actors[0].get_tensor.remote()
result = actors[1].sum.remote(ref)
results.append(result)
results = ray.get(results)
assert sum(results) == 1500
@pytest.mark.skip(
"If the tensor metadata doesn't exist at the time of borrowing, this will fail."
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_borrow_after_abort(ray_start_regular):
actors = [GPUTestActor.remote() for _ in range(2)]
nixl_ref = actors[0].echo.remote(torch.tensor([4, 5, 6]), "cuda")
assert ray.get(actors[1].borrow_and_sum.remote([nixl_ref])) == 15
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_shared_tensor_deduplication(ray_start_regular):
"""
Test that tensors shared across multiple lists are properly deduplicated.
Creates list1 = [T1, T2] and list2 = [T2, T3] where T2 is shared.
"""
actor = GPUTestActor.remote()
ray.get(actor.put_shared_tensor_lists.remote())
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_agent_reuse(ray_start_regular):
"""
We reuse nixl remote agent by default. The receiver should successfully receive
all tensors while the sender may trigger GC in between.
"""
actors = [GPUTestActor.remote() for _ in range(2)]
src_actor, dst_actor = actors[0], actors[1]
ref1 = src_actor.echo.remote(torch.tensor([1, 2, 3]).to("cuda"), "cuda")
assert ray.get(dst_actor.sum.remote(ref1, "cuda")) == 6
# Trigger another transfer. The receiver successfully gets
# the latest tensor (nixl agent is reused internally).
ref2 = src_actor.echo.remote(torch.tensor([4, 5, 6]).to("cuda"), "cuda")
assert ray.get(dst_actor.sum.remote(ref2, "cuda")) == 15
del ref1, ref2
# Wait for GC to free the tensors on the sender.
wait_for_condition(
lambda: ray.get(src_actor.get_num_managed_meta_nixl.remote()) == 0,
timeout=10,
retry_interval_ms=100,
)
# Transfer after GC. The receiver successfully gets
# the latest tensor (nixl agent is reset internally).
ref3 = src_actor.echo.remote(torch.tensor([7, 8, 9]).to("cuda"), "cuda")
assert ray.get(dst_actor.sum.remote(ref3, "cuda")) == 24
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_agent_reuse_with_partial_tensors(ray_start_regular):
"""
We reuse nixl remote agent by default. The receiver should successfully choose
and receive part of the tensors.
"""
actors = [GPUTestActor.remote() for _ in range(2)]
src_actor, dst_actor = actors[0], actors[1]
ref1 = src_actor.echo.remote(torch.tensor([1, 2, 3, 4, 5, 6]).to("cuda"), "cuda")
assert ray.get(dst_actor.sum.remote(ref1, "cuda")) == 21
del ref1
# Wait for GC to free the tensors on the sender.
wait_for_condition(
lambda: ray.get(src_actor.get_num_managed_meta_nixl.remote()) == 0,
timeout=10,
retry_interval_ms=100,
)
# Create the second tensor at the sender. The memory address of
# this tensor may overlap with the first tensor (de-registered).
ref2 = src_actor.echo.remote(torch.tensor([1, 2, 3]).to("cuda"), "cuda")
# Create the third tensor at the sender. The memory address of
# this tensor may overlap with the first tensor (de-registered).
ref3 = src_actor.echo.remote(torch.tensor([4, 5, 6]).to("cuda"), "cuda")
# Trigger the transfer. The receiver successfully gets
# the third tensor (nixl agent is reset internally).
assert ray.get(dst_actor.sum.remote(ref3, "cuda")) == 15
del ref2, ref3
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_storage_level_overlapping_views_reference_count(ray_start_regular):
"""Test that two overlapping tensors sharing the same underlying storage produce a
single NIXL registration. When each tensor's ref goes out of scope via
garbage_collect, the metadata_count decrements. After both are freed,
the registration is removed."""
from ray.experimental.rdt.nixl_tensor_transport import (
NixlTensorTransport,
)
transport = NixlTensorTransport()
tensor = torch.tensor([[1, 1], [2, 2], [3, 3]], dtype=torch.float32).to("cuda")
view0 = tensor[0:2]
view1 = tensor[1:3]
storage_key = tensor.untyped_storage().data_ptr()
assert view0.untyped_storage().data_ptr() == storage_key
assert view1.untyped_storage().data_ptr() == storage_key
assert view0.data_ptr() != view1.data_ptr()
# Simulate ray.put(view0)
obj_id1 = "test_obj_id_1"
meta1 = transport.extract_tensor_transport_metadata(obj_id1, [view0])
assert len(transport._tensor_desc_cache) == 1
assert transport._tensor_desc_cache[storage_key].metadata_count == 1
# Simulate ray.put(view1) and check that the a new entry is not created in the tensor desc cache
# since they share the same storage key and the metadata_count is incremented by 1
obj_id2 = "test_obj_id_2"
meta2 = transport.extract_tensor_transport_metadata(obj_id2, [view1])
assert len(transport._tensor_desc_cache) == 1
assert transport._tensor_desc_cache[storage_key].metadata_count == 2
# Simulate the obj ref for view0 going out of scope and check that the nixl memory registration is
# not cleared since the object ref for view1 is still in scope
transport.garbage_collect(obj_id1, meta1, [view0])
assert storage_key in transport._tensor_desc_cache
assert transport._tensor_desc_cache[storage_key].metadata_count == 1
# Simulate the obj ref for view1 going out of scope and check that the nixl memory registration is cleared
transport.garbage_collect(obj_id2, meta2, [view1])
assert storage_key not in transport._tensor_desc_cache
@ray.remote(num_gpus=1, num_cpus=0, enable_tensor_transport=True)
class OverlappingViewProducer:
def produce_overlapping_views(self):
tensor = torch.tensor([1, 2, 3, 4, 5], dtype=torch.float32).to("cuda")
slices = [tensor[0:2], tensor[1:3], tensor[2:4]]
refs = []
for s in slices:
refs.append(ray.put(s, _tensor_transport="nixl"))
return refs
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_storage_level_overlapping_views(ray_start_regular):
"""Test that overlapping views of the same storage tensor are properly transferred."""
actors = [OverlappingViewProducer.remote(), GPUTestActor.remote()]
src_actor, dst_actor = actors[0], actors[1]
refs = ray.get(src_actor.produce_overlapping_views.remote())
result = ray.get(dst_actor.consume_with_nixl.remote(refs))
assert result == 15
@ray.remote(num_gpus=1, num_cpus=0, enable_tensor_transport=True)
class WaitTensorFreedActor:
def test_wait_tensor_freed_views(self):
from ray.experimental import wait_tensor_freed
tensor = torch.tensor([1, 2, 3, 4, 5], dtype=torch.float32).to("cuda")
slices = [tensor[0:3], tensor[1:4], tensor[2:5]]
ref1 = ray.put(slices[0], _tensor_transport="nixl")
ref2 = ray.put(slices[1], _tensor_transport="nixl")
ref3 = ray.put(slices[2], _tensor_transport="nixl")
del ref1
wait_tensor_freed(slices[0], timeout=10)
with pytest.raises(TimeoutError):
wait_tensor_freed(slices[1], timeout=1)
with pytest.raises(TimeoutError):
wait_tensor_freed(slices[2], timeout=1)
del ref2
with pytest.raises(TimeoutError):
wait_tensor_freed(slices[2], timeout=1)
wait_tensor_freed(slices[1], timeout=10)
del ref3
wait_tensor_freed(slices[2], timeout=10)
return "Success"
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_wait_tensor_freed_views(ray_start_regular):
"""Test that wait_tensor_freed tracks each view independently,
not the shared underlying storage."""
actor = WaitTensorFreedActor.remote()
result = ray.get(actor.test_wait_tensor_freed_views.remote())
assert result == "Success"
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_get_into_tensor_buffers(ray_start_regular):
@ray.remote(num_gpus=1, num_cpus=0)
class GPUTestActor:
def __init__(self):
self.tensor_list = [
torch.tensor([1, 2, 3]).to("cuda"),
torch.tensor([4, 5, 6]).to("cuda"),
]
def get_ref(self):
return ray.put(self.tensor_list, _tensor_transport="nixl")
def get_with_buffers(self, refs):
set_target_for_ref(refs[0], self.tensor_list)
tensors = ray.get(refs[0])
# Make sure we ray.get-ted into the buffers
for new_tensor, tensor_buffer in zip(tensors, self.tensor_list):
assert id(new_tensor) == id(tensor_buffer)
return True
def get_with_wrong_buffers(self, refs):
wrong_tensor_buffer = [
torch.tensor([1, 2]).to("cuda"),
torch.tensor([4, 5]).to("cuda"),
]
set_target_for_ref(refs[0], wrong_tensor_buffer)
with pytest.raises(ValueError) as excinfo:
ray.get(refs[0])
assert "Shape of tensor_buffer at index 0" in str(excinfo.value)
return True
actors = [GPUTestActor.remote() for _ in range(2)]
ref = ray.get(actors[0].get_ref.remote())
result = actors[1].get_with_buffers.remote([ref])
assert ray.get(result)
result = actors[1].get_with_wrong_buffers.remote([ref])
assert ray.get(result)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_register_deregister_nixl_memory(ray_start_regular):
"""
Test that register_nixl_memory persists the NIXL memory registration when the object ref goes out of scope
"""
from ray.experimental.rdt.nixl_tensor_transport import (
NixlTensorTransport,
)
transport = NixlTensorTransport()
tensor = torch.tensor([1, 2, 3]).to("cuda")
transport.register_nixl_memory(tensor)
key = tensor.untyped_storage().data_ptr()
assert key in transport._tensor_desc_cache
assert transport._tensor_desc_cache[key].metadata_count == 1
# Simulate ray.put via extract_tensor_transport_metadata and bump the reference count
obj_id = "test_obj_id"
meta = transport.extract_tensor_transport_metadata(obj_id, [tensor])
assert transport._tensor_desc_cache[key].metadata_count == 2
# Simulate GC via garbage_collect and decrement the reference count
transport.garbage_collect(obj_id, meta, [tensor])
assert key in transport._tensor_desc_cache
# The reference count should be 1 due to being bumped by register_nixl_memory
assert transport._tensor_desc_cache[key].metadata_count == 1
# decrement the remaining count to 0 and deregister the memory
transport.deregister_nixl_memory(tensor)
assert key not in transport._tensor_desc_cache
@pytest.mark.parametrize("device", ["cpu", "cuda"])
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_nixl_memory_pool(ray_start_regular, device):
"""
Test NIXL memory pool: use the pre-allocated memory pool for NIXL transfers when available.
When the pool cannot accommodate an allocation, an error is raised.
"""
@ray.remote(num_gpus=1, num_cpus=0, enable_tensor_transport=True)
class PoolActor:
def __init__(self, pool_device, pool_size):
from ray.experimental import register_nixl_memory_pool
register_nixl_memory_pool(pool_size, torch.device(pool_device))
@ray.method(tensor_transport="nixl")
def echo(self, data, device):
return data.to(device)
def get_num_managed_meta_nixl(self):
return get_tensor_transport_manager("NIXL")._get_num_managed_meta_nixl()
src_actor = PoolActor.remote(device, 48)
dst_actor = GPUTestActor.remote()
# Transfer the first small tensor (using memory pool internally).
ref1 = src_actor.echo.remote(torch.tensor([1, 2, 3]).to(device), device)
assert ray.get(dst_actor.sum.remote(ref1, device)) == 6
# Transfer the second small tensor (using memory pool internally).
ref2 = src_actor.echo.remote(torch.tensor([4, 5, 6]).to(device), device)
assert ray.get(dst_actor.sum.remote(ref2, device)) == 15
# Third transfer: pool is full. The allocation raises
# NixlOutOfMemoryError, which surfaces as a RayTaskError.
ref3 = src_actor.echo.remote(torch.tensor([7, 8, 9]).to(device), device)
with pytest.raises(ray.exceptions.RayTaskError) as excinfo:
ray.get(dst_actor.sum.remote(ref3, device))
assert "NixlOutOfMemoryError" in str(excinfo.value) and "out of memory" in str(
excinfo.value
)
del ref1, ref2, ref3
# Wait for GC to free the tensors on the sender.
wait_for_condition(
lambda: ray.get(src_actor.get_num_managed_meta_nixl.remote()) == 0,
timeout=10,
retry_interval_ms=100,
)
# Transfer the fourth tensor (after GC, using memory pool internally).
ref4 = src_actor.echo.remote(torch.tensor([1, 2, 3, 4, 5, 6]).to(device), device)
assert ray.get(dst_actor.sum.remote(ref4, device)) == 21
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_nixl_memory_pool_view_deduplication(ray_start_regular):
"""
Test that views of the same tensor within a single ray.put share a single
pool allocation, and that across ray.put calls the same storage reuses its
pool slot.
"""
from ray.experimental.rdt.nixl_tensor_transport import (
NixlTensorTransport,
)
transport = NixlTensorTransport()
base = torch.tensor([[1, 2], [3, 4], [5, 6]], dtype=torch.float32).to("cuda")
storage_size = base.untyped_storage().nbytes()
# Pool sized to exactly one full storage copy — enough for the shared
# storage, and small enough that a duplicate allocation would fail.
transport.register_nixl_memory_pool(storage_size, torch.device("cuda"))
view_a = base[0:2]
view_b = base[1:3]
# Both views share the same storage
assert view_a.untyped_storage().data_ptr() == base.untyped_storage().data_ptr()
assert view_b.untyped_storage().data_ptr() == base.untyped_storage().data_ptr()
# Put both views in one object — shared storage should be allocated only once,
# but metadata_count increments once per tensor.
obj_id1 = "view_obj_1"
meta1 = transport.extract_tensor_transport_metadata(obj_id1, [view_a, view_b])
ptr = base.untyped_storage().data_ptr()
pool = transport._memory_pool
assert pool.has_block(base)
assert ptr in transport._tensor_desc_cache
assert transport._tensor_desc_cache[ptr].reg_desc is None
assert transport._tensor_desc_cache[ptr].metadata_count == 2
# Second put of the same view — should reuse the same pool slot (cross-call cache)
obj_id2 = "view_obj_2"
meta2 = transport.extract_tensor_transport_metadata(obj_id2, [view_a])
assert pool.has_block(base)
assert transport._tensor_desc_cache[ptr].metadata_count == 3
# GC: metadata_count decrements once per tensor passed in, symmetric with
# _add_pool_tensor_descs.
transport.garbage_collect(obj_id1, meta1, [view_a, view_b])
assert ptr in transport._tensor_desc_cache
assert transport._tensor_desc_cache[ptr].metadata_count == 1
transport.garbage_collect(obj_id2, meta2, [view_a])
# All refs gone, pool block freed
assert ptr not in transport._tensor_desc_cache
assert not pool.has_block(base)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))