chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,95 @@
"""CPU-only unit tests for NIXL backend selection.
These tests cover the backend-selection logic in
``ray.experimental.rdt.nixl_tensor_transport`` without requiring a GPU, NIXL, or
EFA hardware. They exercise the hardware-to-backend mapping (host vs. container
EFA layouts and non-EFA RDMA).
"""
import sys
import pytest
from ray.experimental.rdt import nixl_tensor_transport as ntt
from ray.experimental.rdt.nixl_tensor_transport import (
NixlTensorTransport,
_is_efa_available,
_nixl_transport_available_in_process,
)
@pytest.fixture(autouse=True)
def _clear_caches(monkeypatch):
# _is_efa_available is lru_cached; clear it so each test sees fresh globs.
_is_efa_available.cache_clear()
yield
_is_efa_available.cache_clear()
def _patch_globs(monkeypatch, present):
"""Make glob.glob return a match only for patterns in ``present``.
The returned path is derived from the pattern (its trailing ``*`` replaced)
so that, e.g., ``/sys/class/infiniband/*`` yields a path under
``/sys/class/infiniband/`` that ``_patch_ib_driver`` can recognize.
"""
def fake_glob(pattern):
return [pattern.replace("*", "dev0")] if pattern in present else []
monkeypatch.setattr(ntt.glob, "glob", fake_glob)
def _patch_ib_driver(monkeypatch, driver):
"""Make every /sys/class/infiniband device resolve to ``driver``."""
real_realpath = ntt.os.path.realpath
def fake_realpath(path):
if path.startswith("/sys/class/infiniband/"):
return f"/sys/bus/pci/drivers/{driver}"
return real_realpath(path)
monkeypatch.setattr(ntt.os.path, "realpath", fake_realpath)
@pytest.mark.parametrize(
"globs,ib_driver,expected",
[
# Host: EFA exposes an efa* netdev.
({"/sys/class/net/efa*"}, None, "LIBFABRIC"),
# Container: netdev is namespaced away, but the EFA device plugin mounts
# verbs devices bound to the efa kernel driver.
({"/sys/class/infiniband/*"}, "efa", "LIBFABRIC"),
# Ordinary InfiniBand/RoCE exposes verbs devices too, but under a
# different driver, so it must not be treated as EFA.
({"/sys/class/infiniband/*"}, "mlx5_core", "UCX"),
# No RDMA hardware at all.
(set(), None, "UCX"),
],
)
def test_select_backend_from_hardware(monkeypatch, globs, ib_driver, expected):
_patch_globs(monkeypatch, globs)
if ib_driver is not None:
_patch_ib_driver(monkeypatch, ib_driver)
assert NixlTensorTransport().select_backend() == expected
@pytest.mark.parametrize(
"exc",
[
ImportError("nixl is not installed"),
RuntimeError("LIBFABRIC probe failed"),
],
)
def test_nixl_transport_available_in_process_returns_false_on_init_failure(
monkeypatch, exc
):
def fail_init(self):
raise exc
monkeypatch.setattr(NixlTensorTransport, "get_nixl_agent", fail_init)
assert _nixl_transport_available_in_process() is False
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
@@ -0,0 +1,345 @@
"""Unit tests for MemoryPoolManager.
"""
import sys
import pytest
import torch
from ray.experimental.rdt.nixl_memory_pool import (
MemoryPoolManager,
NixlOutOfMemoryError,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tensor(values, dtype=torch.float32):
"""Create a contiguous CPU tensor."""
return torch.tensor(values, dtype=dtype)
# ---------------------------------------------------------------------------
# allocate_for_tensors — basic allocation and data copy
# ---------------------------------------------------------------------------
class TestAllocateForTensors:
def test_single_tensor(self):
t = _make_tensor([1.0, 2.0, 3.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
assert len(views) == 1
assert torch.equal(views[0], t)
assert pool.has_block(t)
def test_multiple_independent_tensors(self):
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0, 5.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t1, t2])
assert len(views) == 2
assert torch.equal(views[0], t1)
assert torch.equal(views[1], t2)
assert pool.has_block(t1)
assert pool.has_block(t2)
def test_pool_views_are_backed_by_pool_tensor(self):
"""Returned views should be backed by the pool's internal tensor,
not the source tensor's storage."""
t = _make_tensor([10.0, 20.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
# The view's storage should be the pool tensor's storage.
assert (
views[0].untyped_storage().data_ptr()
== pool.get_pool_tensor().untyped_storage().data_ptr()
)
def test_data_is_copied_not_aliased(self):
"""Mutating the source tensor after allocation should not affect
the pool copy."""
t = _make_tensor([1.0, 2.0, 3.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
original = views[0].clone()
t[0] = 999.0
assert torch.equal(views[0], original)
# ---------------------------------------------------------------------------
# allocate_for_tensors — storage deduplication
# ---------------------------------------------------------------------------
class TestStorageDeduplication:
def test_views_of_same_storage_share_one_block(self):
"""Two views of the same underlying storage should produce only one
pool allocation."""
base = _make_tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
view_a = base[0:2]
view_b = base[1:3]
storage_size = base.untyped_storage().nbytes()
# Pool is exactly one storage — a second allocation would OOM.
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views = pool.allocate_for_tensors([view_a, view_b])
assert len(views) == 2
assert torch.equal(views[0], view_a)
assert torch.equal(views[1], view_b)
def test_duplicate_tensor_in_list(self):
"""The exact same tensor object appearing twice should deduplicate."""
t = _make_tensor([1.0, 2.0])
storage_size = t.untyped_storage().nbytes()
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t, t])
assert len(views) == 2
assert torch.equal(views[0], t)
assert torch.equal(views[1], t)
def test_cross_call_reuse(self):
"""A second allocate_for_tensors call with the same tensor should
reuse the existing pool block (cache hit), not allocate a new one."""
t = _make_tensor([1.0, 2.0, 3.0])
storage_size = t.untyped_storage().nbytes()
# Pool fits exactly one storage.
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views1 = pool.allocate_for_tensors([t])
# Second call should hit cache, not OOM.
views2 = pool.allocate_for_tensors([t])
assert torch.equal(views1[0], t)
assert torch.equal(views2[0], t)
def test_mixed_cache_hit_and_new_allocation(self):
"""One call with a mix of already-allocated and new tensors should
only allocate for the new ones."""
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0, 5.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
# Pre-allocate t1.
pool.allocate_for_tensors([t1])
# Now allocate both — t1 should cache-hit, t2 should get new block.
views = pool.allocate_for_tensors([t1, t2])
assert len(views) == 2
assert torch.equal(views[0], t1)
assert torch.equal(views[1], t2)
assert pool.has_block(t2)
# ---------------------------------------------------------------------------
# allocate_for_tensors — OOM
# ---------------------------------------------------------------------------
class TestOOM:
def test_oom_single_tensor(self):
t = _make_tensor([1.0, 2.0, 3.0]) # 12 bytes
pool = MemoryPoolManager(pool_size=4, device=torch.device("cpu"))
with pytest.raises(NixlOutOfMemoryError, match="out of memory"):
pool.allocate_for_tensors([t])
def test_oom_does_not_corrupt_pool_state(self):
"""After an OOM error, the pool state should be unchanged — previously
allocated blocks remain valid and no partial allocation leaks."""
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
t2 = _make_tensor([3.0, 4.0, 5.0]) # 12 bytes
pool = MemoryPoolManager(pool_size=12, device=torch.device("cpu"))
views1 = pool.allocate_for_tensors([t1])
assert torch.equal(views1[0], t1)
# t2 doesn't fit in the remaining 4 bytes.
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t2])
# Pool should still be intact — t1's block is still valid.
assert pool.has_block(t1)
def test_atomic_allocation_failure(self):
"""When allocating multiple tensors atomically, if one doesn't fit,
none should be allocated."""
t1 = _make_tensor([1.0]) # 4 bytes
t2 = _make_tensor([1.0] * 100) # 400 bytes — won't fit
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t1, t2])
# Neither tensor should have been tracked.
assert not pool.has_block(t1)
assert not pool.has_block(t2)
# ---------------------------------------------------------------------------
# free_tensors
# ---------------------------------------------------------------------------
class TestFreeTensors:
def test_free_and_reallocate(self):
"""After freeing, the space should be reusable."""
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
pool = MemoryPoolManager(pool_size=8, device=torch.device("cpu"))
pool.allocate_for_tensors([t1])
assert pool.has_block(t1)
pool.free_tensors([t1])
assert not pool.has_block(t1)
# Now a new tensor of the same size should fit.
t2 = _make_tensor([3.0, 4.0])
views = pool.allocate_for_tensors([t2])
assert torch.equal(views[0], t2)
def test_free_unknown_tensor_is_noop(self):
"""Freeing a tensor that was never allocated should not raise."""
t = _make_tensor([1.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
# Should not raise.
pool.free_tensors([t])
def test_free_multiple_tensors(self):
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t1])
pool.allocate_for_tensors([t2])
pool.free_tensors([t1, t2])
assert not pool.has_block(t1)
assert not pool.has_block(t2)
def test_free_then_cross_call_reuse_is_broken(self):
"""After freeing, the same tensor should NOT get a cache hit — it
should allocate a fresh block."""
t = _make_tensor([1.0, 2.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t])
pool.free_tensors([t])
assert not pool.has_block(t)
# Re-allocate — should work (fresh allocation, not cache hit).
views = pool.allocate_for_tensors([t])
assert torch.equal(views[0], t)
assert pool.has_block(t)
def test_double_free_is_noop(self):
"""Freeing an already-freed tensor should not raise or corrupt state."""
t = _make_tensor([1.0, 2.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t])
pool.free_tensors([t])
# Second free — should be a no-op.
pool.free_tensors([t])
assert not pool.has_block(t)
# ---------------------------------------------------------------------------
# Block merging — allocation succeeds only after freed blocks are coalesced
# ---------------------------------------------------------------------------
class TestBlockMerging:
def test_allocation_requires_merged_free_space(self):
"""After freeing adjacent blocks, the merged space should be usable
for a single large allocation that wouldn't fit in either fragment."""
# Pool: 24 bytes, allocate three 8-byte tensors to fill it.
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
t2 = _make_tensor([3.0, 4.0]) # 8 bytes
t3 = _make_tensor([5.0, 6.0]) # 8 bytes
pool = MemoryPoolManager(pool_size=24, device=torch.device("cpu"))
pool.allocate_for_tensors([t1, t2, t3])
t_big = _make_tensor([7.0, 8.0, 9.0, 10.0]) # 16 bytes
# Free only t1 — 8 bytes free, not enough for t_big (16 bytes).
pool.free_tensors([t1])
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t_big])
# Free t2 — now t1+t2 are adjacent and merged into 16 bytes free.
pool.free_tensors([t2])
views = pool.allocate_for_tensors([t_big])
assert torch.equal(views[0], t_big)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_tensor_list(self):
"""allocate_for_tensors with an empty list should return an empty list."""
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
views = pool.allocate_for_tensors([])
assert views == []
def test_different_dtypes(self):
"""Tensors of different dtypes should each get their own block."""
t_f32 = torch.tensor([1.0], dtype=torch.float32)
t_f64 = torch.tensor([1.0], dtype=torch.float64)
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t_f32, t_f64])
assert views[0].dtype == torch.float32
assert views[1].dtype == torch.float64
assert torch.equal(views[0], t_f32)
assert torch.equal(views[1], t_f64)
def test_view_with_storage_offset(self):
"""A tensor view with non-zero storage offset should be correctly
mapped to the pool."""
base = _make_tensor([1.0, 2.0, 3.0, 4.0, 5.0])
view = base[2:4] # [3.0, 4.0], storage_offset = 2
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([view])
assert torch.equal(views[0], view)
assert views[0].shape == (2,)
def test_multidimensional_tensor_shape_preserved(self):
"""Multi-dimensional tensor shapes should be preserved in pool views."""
t = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
assert views[0].shape == (3, 2)
assert torch.equal(views[0], t)
def test_allocate_multiple_preserves_request_order(self):
"""_allocate_multiple should return blocks in the same order as the
input sizes, even though it allocates largest-first internally."""
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
# Sizes in non-sorted order.
sizes = [10, 50, 20, 40]
result = pool._allocate_multiple(sizes)
assert result is not None
# Each result block should match the requested size, in order.
for i, size in enumerate(sizes):
assert result[i].size == size
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+156
View File
@@ -0,0 +1,156 @@
import multiprocessing.shared_memory as shm
import pickle
import sys
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import numpy
import pytest
import ray
from ray.experimental import (
CommunicatorMetadata,
TensorTransportManager,
TensorTransportMetadata,
register_tensor_transport,
)
@dataclass
class ShmTransportMetadata(TensorTransportMetadata):
shm_name: Optional[str] = None
shm_size: Optional[int] = None
@dataclass
class ShmCommunicatorMetadata(CommunicatorMetadata):
pass
class SharedMemoryTransport(TensorTransportManager):
def __init__(self):
self.shared_memory_objects: Dict[str, shm.SharedMemory] = {}
def tensor_transport_backend(self) -> str:
return "shared_memory"
@staticmethod
def is_one_sided() -> bool:
return True
@staticmethod
def can_abort_transport() -> bool:
return False
def actor_has_tensor_transport(self, actor: "ray.actor.ActorHandle") -> bool:
return True
def extract_tensor_transport_metadata(
self,
obj_id: str,
rdt_object: List[numpy.ndarray],
) -> TensorTransportMetadata:
tensor_meta = []
if rdt_object:
for tensor in rdt_object:
tensor_meta.append((tensor.shape, tensor.dtype))
serialized_rdt_object = pickle.dumps(rdt_object)
size = len(serialized_rdt_object)
# Shm name can't be as long as the obj_id, so we truncate it.
name = obj_id[:20]
shm_obj = shm.SharedMemory(name=name, create=True, size=size)
shm_obj.buf[:size] = serialized_rdt_object
self.shared_memory_objects[obj_id] = shm_obj
return ShmTransportMetadata(
tensor_meta=tensor_meta, tensor_device="cpu", shm_name=name, shm_size=size
)
def get_communicator_metadata(
self,
src_actor: "ray.actor.ActorHandle",
dst_actor: "ray.actor.ActorHandle",
backend: Optional[str] = None,
) -> CommunicatorMetadata:
return ShmCommunicatorMetadata()
def recv_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List[Any]] = None,
):
shm_name = tensor_transport_metadata.shm_name
size = tensor_transport_metadata.shm_size
shm_block = shm.SharedMemory(name=shm_name)
recv_tensors = pickle.loads(shm_block.buf[:size])
shm_block.close()
return recv_tensors
def send_multiple_tensors(
self,
tensors: List[numpy.ndarray],
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
):
pass
def garbage_collect(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
tensors: List[numpy.ndarray],
):
self.shared_memory_objects[obj_id].close()
self.shared_memory_objects[obj_id].unlink()
del self.shared_memory_objects[obj_id]
def abort_transport(
self,
obj_id: str,
communicator_metadata: CommunicatorMetadata,
):
pass
def test_register_and_use_custom_transport(ray_start_regular):
register_tensor_transport(
"shared_memory", ["cpu"], SharedMemoryTransport, numpy.ndarray
)
@ray.remote
class Actor:
@ray.method(tensor_transport="shared_memory")
def echo(self, data):
return data
def non_rdt_echo(self, data):
return data
def sum(self, data):
return data.sum().item()
# Classes defined in test files get pickled by ref. So we need to
# explicitly pickle the transport class in this module by value.
# Note that this doesn't happen if you define the transport class on the
# driver, something with pytest convinces cloudpickle to pickle by ref.
from ray import cloudpickle
cloudpickle.register_pickle_by_value(sys.modules[SharedMemoryTransport.__module__])
actors = [Actor.remote() for _ in range(2)]
ref = actors[0].echo.remote(numpy.array([1, 2, 3]))
result = actors[1].sum.remote(ref)
assert ray.get(result) == 6
# Test that non-rdt methods that return the data type still work.
ref = actors[0].non_rdt_echo.remote(numpy.array([1, 2, 3]))
result = actors[1].sum.remote(ref)
assert ray.get(result) == 6
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
import sys
import pytest
import torch
import ray
@ray.remote(enable_tensor_transport=True)
class GPUTestActor:
def __init__(self):
self.tensor = None
@ray.method(tensor_transport="cuda_ipc")
def echo(self, data):
self.tensor = data.to("cuda")
return self.tensor
def double(self, data):
data.mul_(2)
return data
def wait_tensor_freed(self):
rdt_manager = ray.worker.global_worker.rdt_manager
ray.experimental.wait_tensor_freed(self.tensor, timeout=10)
assert not rdt_manager.rdt_store.has_tensor(self.tensor)
return "freed"
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_colocated_actors(ray_start_regular):
world_size = 2
actors = [
GPUTestActor.options(num_gpus=0.5, num_cpus=0).remote()
for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor
ray.get(dst_actor.double.remote(rdt_ref))
# Check that the tensor is modified in place, and is reflected on the source actor
assert torch.equal(
ray.get(rdt_ref, _use_object_store=True),
torch.tensor([2, 4, 6], device="cuda"),
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_different_devices(ray_start_regular):
world_size = 2
actors = [
GPUTestActor.options(num_gpus=1, num_cpus=0).remote() for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor. Since CUDA IPC transport does not
# support cross-device tensor transfers, this should raise a ValueError.
with pytest.raises(
ValueError, match="CUDA IPC transport only supports tensors on the same GPU*"
):
ray.get(dst_actor.double.remote(rdt_ref))
def test_different_nodes(ray_start_cluster):
# Test that inter-node CUDA IPC transfers throw an error.
cluster = ray_start_cluster
num_nodes = 2
num_cpus = 1
num_gpus = 1
for _ in range(num_nodes):
cluster.add_node(num_cpus=num_cpus, num_gpus=num_gpus)
ray.init(address=cluster.address)
world_size = 2
actors = [
GPUTestActor.options(num_gpus=1, num_cpus=0).remote() for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor. Since CUDA IPC transport does not
# support cross-device tensor transfers, this should raise a ValueError.
with pytest.raises(
ValueError, match="CUDA IPC transport only supports tensors on the same node.*"
):
ray.get(dst_actor.double.remote(rdt_ref))
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_ref_freed(ray_start_regular):
world_size = 2
actors = [
GPUTestActor.options(num_gpus=0.5, num_cpus=0).remote()
for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor
res_ref = dst_actor.double.remote(rdt_ref)
del rdt_ref
free_res = ray.get(src_actor.wait_tensor_freed.remote())
assert free_res == "freed"
assert torch.equal(
ray.get(res_ref, _use_object_store=True),
torch.tensor([2, 4, 6], device="cuda"),
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_source_actor_fails_after_transfer(ray_start_regular):
world_size = 2
actors = [
GPUTestActor.options(num_gpus=0.5, num_cpus=0).remote()
for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor
res_ref = dst_actor.double.remote(rdt_ref)
assert torch.equal(
ray.get(res_ref, _use_object_store=True),
torch.tensor([2, 4, 6], device="cuda"),
)
# Kill the source actor.
ray.kill(src_actor)
with pytest.raises(ray.exceptions.RayActorError):
ray.get(src_actor.wait_tensor_freed.remote())
# Check that the tensor is still available on the destination actor.
assert torch.equal(
ray.get(res_ref, _use_object_store=True),
torch.tensor([2, 4, 6], device="cuda"),
)
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 1}], indirect=True)
def test_source_actor_fails_before_transfer(ray_start_regular):
world_size = 2
actors = [
GPUTestActor.options(num_gpus=0.5, num_cpus=0).remote()
for _ in range(world_size)
]
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Wait for object to be created.
assert torch.equal(
ray.get(rdt_ref, _use_object_store=True),
torch.tensor([1, 2, 3], device="cuda"),
)
# Kill the source actor.
ray.kill(src_actor)
with pytest.raises(ray.exceptions.RayActorError):
ray.get(src_actor.wait_tensor_freed.remote())
# Check that the tensor is still available on the destination actor.
with pytest.raises(ray.exceptions.RayTaskError):
res_ref = dst_actor.double.remote(rdt_ref)
ray.get(res_ref, _use_object_store=True)
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+345
View File
@@ -0,0 +1,345 @@
"""Unit tests for RDTManager."""
import re
import sys
from dataclasses import dataclass
from typing import Any, List
import pytest
from ray.exceptions import GetTimeoutError
from ray.experimental import (
CommunicatorMetadata,
TensorTransportManager,
TensorTransportMetadata,
register_tensor_transport,
)
from ray.experimental.rdt.rdt_manager import RDTManager, RDTMeta
from ray.experimental.rdt.tensor_transport_manager import FetchRequest
_BACKEND_NAME = "TEST_PIPELINE"
_TWO_SIDED_BACKEND_NAME = "TEST_TWO_SIDED"
@dataclass
class _TestCommMeta(CommunicatorMetadata):
pass
@dataclass
class _TrackedFetchRequest(FetchRequest):
"""FetchRequest subclass that records when it is deleted."""
def __del__(self):
_PipelineCheckingTransport.deleted_requests.add(self.obj_id)
class _PipelineCheckingTransport(TensorTransportManager):
"""Fake one-sided transport that records the order of fetch/wait calls.
Each fetch_multiple_tensors call appends ("fetch", obj_id) to call_log,
and each wait_fetch_complete call appends ("wait", obj_id). The test
asserts that all fetch entries appear before any wait entry.
call_log is a class-level list so the singleton instance created by
get_tensor_transport_manager records to the same list across all tests.
"""
call_log: List = []
fail_on_wait: set = set()
wait_delay: float = 0
deleted_requests: set = set()
def tensor_transport_backend(self) -> str:
return _BACKEND_NAME
@staticmethod
def is_one_sided() -> bool:
return True
@staticmethod
def can_abort_transport() -> bool:
return False
def actor_has_tensor_transport(self, actor) -> bool:
return True
def extract_tensor_transport_metadata(self, obj_id, rdt_object):
return TensorTransportMetadata(tensor_meta=[], tensor_device="cpu")
def get_communicator_metadata(self, src_actor, dst_actor, backend=None):
return _TestCommMeta()
def fetch_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata,
communicator_metadata,
target_buffers=None,
) -> FetchRequest:
self.__class__.call_log.append(("fetch", obj_id))
return _TrackedFetchRequest(obj_id=obj_id, tensors=[f"val:{obj_id}"])
def wait_fetch_complete(
self, fetch_request: FetchRequest, timeout: float = -1
) -> List[Any]:
if self.__class__.wait_delay > 0:
import time
time.sleep(self.__class__.wait_delay)
self.__class__.call_log.append(("wait", fetch_request.obj_id))
if fetch_request.obj_id in self.__class__.fail_on_wait:
raise RuntimeError(f"wait failed for {fetch_request.obj_id}")
return fetch_request.tensors
def recv_multiple_tensors(self, obj_id, meta, comm_meta, target_buffers=None):
return []
def send_multiple_tensors(self, tensors, meta, comm_meta):
pass
def garbage_collect(self, obj_id, meta, tensors):
pass
def abort_transport(self, obj_id, comm_meta):
pass
class _TwoSidedTransport(TensorTransportManager):
"""Fake two-sided transport (e.g. NCCL/GLOO style)."""
def tensor_transport_backend(self) -> str:
return _TWO_SIDED_BACKEND_NAME
@staticmethod
def is_one_sided() -> bool:
return False
@staticmethod
def can_abort_transport() -> bool:
return False
def actor_has_tensor_transport(self, actor) -> bool:
return True
def extract_tensor_transport_metadata(self, obj_id, rdt_object):
return TensorTransportMetadata(tensor_meta=[], tensor_device="cpu")
def get_communicator_metadata(self, src_actor, dst_actor, backend=None):
return _TestCommMeta()
def fetch_multiple_tensors(self, obj_id, meta, comm_meta, target_buffers=None):
raise NotImplementedError
def recv_multiple_tensors(self, obj_id, meta, comm_meta, target_buffers=None):
raise NotImplementedError
def send_multiple_tensors(self, tensors, meta, comm_meta):
raise NotImplementedError
def garbage_collect(self, obj_id, meta, tensors):
pass
def abort_transport(self, obj_id, comm_meta):
pass
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module", autouse=True)
def register_test_transports():
"""Register both test transports once for the lifetime of the module."""
try:
register_tensor_transport(
_BACKEND_NAME, ["cpu"], _PipelineCheckingTransport, list
)
except ValueError:
pass # already registered (e.g. test module loaded more than once)
try:
register_tensor_transport(
_TWO_SIDED_BACKEND_NAME, ["cpu"], _TwoSidedTransport, list
)
except ValueError:
pass
@pytest.fixture(autouse=True)
def clear_call_log():
"""Reset the pipeline transport's call log and error config before each test."""
_PipelineCheckingTransport.call_log.clear()
_PipelineCheckingTransport.fail_on_wait.clear()
_PipelineCheckingTransport.wait_delay = 0
_PipelineCheckingTransport.deleted_requests.clear()
def _build_manager(object_ids: List[str], backend: str = _BACKEND_NAME) -> RDTManager:
"""Return an RDTManager pre-populated with fake RDT metadata.
Uses a real RDTStore so no Ray cluster is required.
All objects are non-primary copies (pop_object=True in fetch_and_get_rdt_objects).
"""
manager = RDTManager()
meta = TensorTransportMetadata(tensor_meta=[], tensor_device="cpu")
for obj_id in object_ids:
manager.set_rdt_metadata(
obj_id,
RDTMeta(
src_actor=None,
tensor_transport_backend=backend,
tensor_transport_meta=meta,
sent_dest_actors=set(),
sent_to_src_actor_and_others_warned=False,
target_buffers=None,
),
)
return manager
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_fetch_and_get():
object_ids = ["obj1", "obj2", "obj3"]
manager = _build_manager(object_ids)
result = manager.fetch_and_get_rdt_objects(object_ids)
call_log = _PipelineCheckingTransport.call_log
fetch_indices = [i for i, (kind, _) in enumerate(call_log) if kind == "fetch"]
wait_indices = [i for i, (kind, _) in enumerate(call_log) if kind == "wait"]
# All fetch_multiple_tensors calls must precede all wait_fetch_complete
# calls.
assert len(fetch_indices) == len(object_ids), f"call_log={call_log}"
assert len(wait_indices) == len(object_ids), f"call_log={call_log}"
assert max(fetch_indices) < min(
wait_indices
), f"Expected all fetches before all waits, got call_log={call_log}"
# One entry per requested object ID.
assert set(result.keys()) == set(object_ids)
call_log = _PipelineCheckingTransport.call_log
# Each object ID triggers exactly one fetch and one wait.
fetched = [oid for kind, oid in call_log if kind == "fetch"]
waited = [oid for kind, oid in call_log if kind == "wait"]
assert sorted(fetched) == sorted(object_ids)
assert sorted(waited) == sorted(object_ids)
def test_primary_copy_objects_skip_fetch():
"""Objects already in the store must not trigger a fetch."""
secondary_ids = ["secondary1", "secondary2"]
primary_id = "primary1"
manager = _build_manager(secondary_ids + [primary_id])
# Add the primary-copy and one secondary-copy object to the store directly.
# Phase 1 of fetch_and_get_rdt_objects skips objects in store.
manager.rdt_store.add_object(primary_id, ["primary_value"], is_primary=True)
manager.rdt_store.add_object(secondary_ids[0], ["secondary"], is_primary=False)
result = manager.fetch_and_get_rdt_objects(secondary_ids + [primary_id])
call_log = _PipelineCheckingTransport.call_log
fetched = [oid for kind, oid in call_log if kind == "fetch"]
assert set(fetched) == set(
secondary_ids[1:]
), f"objects in store should not be fetched; got fetched={fetched}"
# One fetch + one wait for each secondary object; zero for the primary one.
assert len(call_log) == 2, f"call_log={call_log}"
# All objects should be returned in the results.
assert set(result.keys()) == set(secondary_ids + [primary_id])
assert result[primary_id] == ["primary_value"]
assert result[secondary_ids[0]] == ["secondary"]
def test_empty_object_list_returns_empty_dict():
"""Calling fetch_and_get_rdt_objects with an empty list returns an empty dict."""
manager = _build_manager([])
result = manager.fetch_and_get_rdt_objects([])
assert result == {}
assert _PipelineCheckingTransport.call_log == []
def test_two_sided_transport_raises_on_fetch_and_get_rdt_objects():
"""ray.get (use_object_store=False) must raise ValueError for two-sided transports."""
obj_id = "two_sided_obj"
manager = _build_manager([obj_id], backend=_TWO_SIDED_BACKEND_NAME)
with pytest.raises(
ValueError,
match=re.escape(
f"ray.get is not allowed on RDT objects using the two-sided transport {_TWO_SIDED_BACKEND_NAME}. "
"Either use a one-sided RDT transport or pass _use_object_store=True to ray.get to fetch the object through the object store instead."
),
):
manager.fetch_and_get_rdt_objects([obj_id], use_object_store=False)
def test_fetch_requests_deleted_on_exception():
"""If _wait_fetch raises, all FetchRequests are deleted (cleaning up resources via __del__)."""
import gc
object_ids = ["obj1", "obj2", "obj3"]
manager = _build_manager(object_ids)
_PipelineCheckingTransport.fail_on_wait.add("obj1")
with pytest.raises(RuntimeError, match="wait failed for obj1"):
manager.fetch_and_get_rdt_objects(object_ids)
gc.collect()
assert _PipelineCheckingTransport.deleted_requests == set(object_ids), (
f"All FetchRequests must be GCed even if one fails; "
f"deleted={_PipelineCheckingTransport.deleted_requests}"
)
def test_object_fetch_timed_out_error():
"""fetch_and_get_rdt_objects raises ObjectFetchTimedOutError when RDT timeout is hit."""
from ray.exceptions import ObjectFetchTimedOutError
object_ids = ["obj1", "obj2"]
manager = _build_manager(object_ids)
# Make wait_fetch_complete slow enough to exceed a very short timeout.
_PipelineCheckingTransport.wait_delay = 0.2
with pytest.raises(ObjectFetchTimedOutError):
# timeout=None means no user timeout, so only RDT timeout applies.
# We monkeypatch the constant to a very small value.
import ray._private.ray_constants as rc
original = rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS
rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS = 0.1
try:
manager.fetch_and_get_rdt_objects(object_ids)
finally:
rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS = original
def test_get_timed_out_error():
"""fetch_and_get_rdt_objects raises GetTimeoutError when user timeout is hit."""
object_ids = ["obj1", "obj2"]
manager = _build_manager(object_ids)
# Make wait_fetch_complete slow enough to exceed a very short timeout.
_PipelineCheckingTransport.wait_delay = 0.2
# Check that user timeout triggers before fetch fail timeout.
with pytest.raises(GetTimeoutError):
import ray._private.ray_constants as rc
original = rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS
rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS = 1
try:
manager.fetch_and_get_rdt_objects(object_ids, timeout=0.1)
finally:
rc.RDT_FETCH_FAIL_TIMEOUT_SECONDS = original
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+39
View File
@@ -0,0 +1,39 @@
import sys
import pytest
import torch
import ray
from ray.experimental.collective import create_collective_group
@ray.remote(num_gpus=1, num_cpus=0, enable_tensor_transport=True)
class GPUTestActor:
@ray.method(tensor_transport="nccl")
def echo(self, data):
return data.to("cuda")
def sum(self, data):
return data.sum().item()
@pytest.mark.parametrize("ray_start_regular", [{"num_gpus": 2}], indirect=True)
def test_p2p(ray_start_regular):
# TODO(swang): Add tests for mocked NCCL that can run on CPU-only machines.
world_size = 2
actors = [GPUTestActor.remote() for _ in range(world_size)]
create_collective_group(actors, backend="nccl")
src_actor, dst_actor = actors[0], actors[1]
# Create test tensor
tensor = torch.tensor([1, 2, 3])
rdt_ref = src_actor.echo.remote(tensor)
# Trigger tensor transfer from src to dst actor
remote_sum = ray.get(dst_actor.sum.remote(rdt_ref))
assert tensor.sum().item() == remote_sum
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))
+721
View File
@@ -0,0 +1,721 @@
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__]))