chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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__]))
|
||||
@@ -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
@@ -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__]))
|
||||
@@ -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__]))
|
||||
@@ -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__]))
|
||||
@@ -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__]))
|
||||
Reference in New Issue
Block a user