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
+29
View File
@@ -0,0 +1,29 @@
from ray.experimental.rdt.rdt_manager import (
RDTManager,
set_target_for_ref,
wait_tensor_freed,
)
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
TensorTransportManager,
TensorTransportMetadata,
)
from ray.experimental.rdt.util import (
deregister_nixl_memory,
register_nixl_memory,
register_nixl_memory_pool,
register_tensor_transport,
)
__all__ = [
"RDTManager",
"wait_tensor_freed",
"register_tensor_transport",
"register_nixl_memory",
"deregister_nixl_memory",
"register_nixl_memory_pool",
"TensorTransportManager",
"TensorTransportMetadata",
"CommunicatorMetadata",
"set_target_for_ref",
]
@@ -0,0 +1,203 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, List, Optional
import ray
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
TensorTransportManager,
TensorTransportMetadata,
)
if TYPE_CHECKING:
import torch
@dataclass
class CollectiveTransportMetadata(TensorTransportMetadata):
"""Metadata for tensors stored in the GPU object store for collective transport."""
@dataclass
class CollectiveCommunicatorMetadata(CommunicatorMetadata):
"""Metadata for the collective communicator (e.g. NCCL, GLOO).
Args:
src_rank: The rank of the source actor.
dst_rank: The rank of the destination actor.
"""
communicator_name: str = ""
src_rank: Optional[int] = None
dst_rank: Optional[int] = None
class CollectiveTensorTransport(TensorTransportManager):
def tensor_transport_backend(self) -> str:
raise NotImplementedError(
"NCCLTensorTransport or GLOOTensorTransport should be used instead of this base class."
)
@staticmethod
def is_one_sided() -> bool:
return False
@staticmethod
def can_abort_transport() -> bool:
return False
def actor_has_tensor_transport(self, actor: "ray.actor.ActorHandle") -> bool:
from ray.experimental.collective import get_collective_groups
communicators = get_collective_groups(
[actor], backend=self.tensor_transport_backend()
)
return len(communicators) > 0
def extract_tensor_transport_metadata(
self,
obj_id: str,
rdt_object: List["torch.Tensor"],
) -> CollectiveTransportMetadata:
tensor_meta = []
device = None
if rdt_object:
device = rdt_object[0].device
for t in rdt_object:
if t.device.type != device.type:
raise ValueError(
"All tensors in an RDT object must have the same device type."
)
tensor_meta.append((t.shape, t.dtype))
return CollectiveTransportMetadata(
tensor_meta=tensor_meta,
tensor_device=device.type if device else None,
)
def get_communicator_metadata(
self,
src_actor: "ray.actor.ActorHandle",
dst_actor: "ray.actor.ActorHandle",
backend: Optional[str] = None,
) -> CollectiveCommunicatorMetadata:
from ray.experimental.collective import get_collective_groups
communicators = get_collective_groups(
[src_actor, dst_actor],
backend=backend,
)
# TODO(kevin85421): Support multiple communicators.
if len(communicators) == 0:
raise ValueError(
f"No communicators found for actors {src_actor} and {dst_actor}. "
"Create a communicator with "
"`ray.experimental.collective.create_collective_group` "
"before calling actor tasks. with non-default tensor_transport."
)
elif len(communicators) > 1:
raise ValueError(
f"There are {len(communicators)} possible communicators that contain actors {src_actor} and {dst_actor}. "
"Currently, RDT objects only support one communicator. Please make sure only "
"one communicator exists."
)
communicator = communicators[0]
src_rank = communicator.get_rank(src_actor)
if src_rank == -1:
raise ValueError(
f"Sender actor {src_actor} not found in communicator. "
"Please make sure the sender and receiver are in the same communicator."
)
dst_rank = communicator.get_rank(dst_actor)
if dst_rank == -1:
raise ValueError(
f"Receiver actor {dst_actor} not found in communicator. "
"Please make sure the sender and receiver are in the same communicator."
)
communicator_metadata = CollectiveCommunicatorMetadata(
communicator_name=communicator.name,
src_rank=src_rank,
dst_rank=dst_rank,
)
return communicator_metadata
def recv_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List["torch.Tensor"]] = None,
):
from ray.experimental.rdt.util import (
create_empty_tensors_from_metadata,
)
from ray.util.collective.collective import recv
assert isinstance(tensor_transport_metadata, CollectiveTransportMetadata)
assert isinstance(communicator_metadata, CollectiveCommunicatorMetadata)
tensors = target_buffers or create_empty_tensors_from_metadata(
tensor_transport_metadata
)
for tensor in tensors:
recv(
tensor,
communicator_metadata.src_rank,
communicator_metadata.communicator_name,
)
return tensors
def send_multiple_tensors(
self,
tensors: List["torch.Tensor"],
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
):
import ray.util.collective as collective
assert isinstance(
tensor_transport_metadata, CollectiveTransportMetadata
), "metadata must be a CollectiveTransportMetadata object for non-NIXL transport"
assert isinstance(
communicator_metadata, CollectiveCommunicatorMetadata
), "metadata must be a CollectiveCommunicatorMetadata object for non-NIXL transport"
device = tensors[0].device if tensors else None
for tensor in tensors:
if tensor.device.type != device.type:
raise ValueError(
f"tensor device {tensor.device} does not match device {device}"
)
collective.send(
tensor,
communicator_metadata.dst_rank,
communicator_metadata.communicator_name,
)
def garbage_collect(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
tensors: List["torch.Tensor"],
):
pass
def abort_transport(
self,
obj_id: str,
communicator_metadata: CommunicatorMetadata,
):
raise NotImplementedError(
"Collective transport does not support abort_transport for now."
)
class NCCLTensorTransport(CollectiveTensorTransport):
def tensor_transport_backend(self) -> str:
return "NCCL"
class GLOOTensorTransport(CollectiveTensorTransport):
def tensor_transport_backend(self) -> str:
return "GLOO"
@@ -0,0 +1,214 @@
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional
import ray
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
TensorTransportManager,
TensorTransportMetadata,
)
if TYPE_CHECKING:
import torch
@dataclass
class CudaIpcCommunicatorMetadata(CommunicatorMetadata):
"""Metadata for the CUDA IPC communicator."""
@dataclass
class CudaIpcTransportMetadata(TensorTransportMetadata):
"""Metadata for tensors stored in the GPU object store for CUDA IPC transport."""
# List of tuples, each containing the function and metadata to reconstruct the tensor.
cuda_ipc_handles: Optional[List[Any]] = None
# The IPC handle of the event that is used to synchronize the sender and receiver.
cuda_ipc_event_ipc_handle: Optional[bytes] = None
# The index of the GPU that the tensors are on. This requires that the GPU is
# assigned by Ray, e.g., using @ray.remote(num_gpus=1).
ray_gpu_idx: Optional[int] = None
# The node that the GPU that the tensors are on is on.
ray_node_id: Optional[str] = None
class CudaIpcTransport(TensorTransportManager):
def __init__(self):
pass
@property
def tensor_transport_backend(self) -> str:
return "CUDA_IPC"
@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:
# TODO: Ideally we would check if torch.cuda.is_available() on the actor
# and if so, return True. But we want to avoid blocking in ray.get() in
# this method since it gets called before submitting an actor task.
return True
def extract_tensor_transport_metadata(
self,
obj_id: str,
rdt_object: List["torch.Tensor"],
) -> CudaIpcTransportMetadata:
tensor_meta = []
device = None
cuda_ipc_handles = []
event_ipc_handle = None
ray_gpu_idx = None
ray_node_id = None
if rdt_object:
import torch
from torch.multiprocessing.reductions import reduce_tensor
device = rdt_object[0].device
ray_gpu_idx = ray.get_gpu_ids()[device.index]
ray_node_id = ray.get_runtime_context().get_node_id()
# Create an interprocess-shareable CUDA event so that the receiver
# can wait for the sender's computations to complete.
event = torch.cuda.Event(interprocess=True)
torch.cuda.current_stream(device).record_event(event)
for t in rdt_object:
if t.device.type != device.type:
raise ValueError(
"All tensors in an RDT object must have the same device type."
)
if t.device.index != device.index:
raise ValueError(
"All tensors in an RDT object must be on the same GPU."
)
tensor_meta.append((t.shape, t.dtype))
ipc_handle = reduce_tensor(t)
cuda_ipc_handles.append(ipc_handle)
event_ipc_handle = event.ipc_handle()
return CudaIpcTransportMetadata(
tensor_meta=tensor_meta,
tensor_device=device.type if device else None,
cuda_ipc_handles=cuda_ipc_handles,
cuda_ipc_event_ipc_handle=event_ipc_handle,
ray_gpu_idx=ray_gpu_idx,
ray_node_id=ray_node_id,
)
def get_communicator_metadata(
self,
src_actor: "ray.actor.ActorHandle",
dst_actor: "ray.actor.ActorHandle",
backend: Optional[str] = None,
) -> CudaIpcCommunicatorMetadata:
communicator_metadata = CudaIpcCommunicatorMetadata()
return communicator_metadata
def recv_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List["torch.Tensor"]] = None,
) -> List["torch.Tensor"]:
assert isinstance(
tensor_transport_metadata, CudaIpcTransportMetadata
), "metadata must be a CudaIpcTransportMetadata object for CUDA IPC transport"
assert isinstance(
communicator_metadata, CudaIpcCommunicatorMetadata
), "metadata must be a CudaIpcCommunicatorMetadata object for CUDA IPC transport"
if target_buffers:
raise ValueError(
"The CUDA IPC transport does not support receiving into buffers."
)
tensors = []
if tensor_transport_metadata.tensor_meta:
import torch
cur_node_id = ray.get_runtime_context().get_node_id()
if cur_node_id != tensor_transport_metadata.ray_node_id:
raise ValueError(
f"CUDA IPC transport only supports tensors on the same node, but the current node ID: {cur_node_id} and the sender node ID: {tensor_transport_metadata.ray_node_id} are different."
)
try:
device_idx = ray.get_gpu_ids().index(
tensor_transport_metadata.ray_gpu_idx
)
except ValueError:
raise ValueError(
f"CUDA IPC transport only supports tensors on the same GPU, but the receiver was not allocated the same GPUs by Ray as the sender (GPU: {tensor_transport_metadata.ray_gpu_idx}). To use the CUDA IPC RDT transport, ensure that the receiver is allocated the same GPU by Ray as the sender, and that CUDA_VISIBLE_DEVICES is set to `ray.get_gpu_ids()`, the GPUs assigned by Ray (this is the default behavior)."
)
device = torch.device(f"cuda:{device_idx}")
event_ipc_handle = tensor_transport_metadata.cuda_ipc_event_ipc_handle
if event_ipc_handle is not None:
# Reconstruct the event from IPC handle
event_remote = torch.cuda.Event.from_ipc_handle(
device=device, handle=event_ipc_handle
)
# Make current stream wait for the sender's event
# This ensures sender's computation is complete before we use the tensor
# This is asynchronous - doesn't block CPU, only GPU stream
torch.cuda.current_stream(device).wait_event(event_remote)
for i, ipc_handle in enumerate(tensor_transport_metadata.cuda_ipc_handles):
# Reconstruct the tensor
func, args = ipc_handle
list_args = list(args)
# Fields specified in https://github.com/pytorch/pytorch/blob/1495b35d29512f303ab37780760c5e692158514b/torch/multiprocessing/reductions.py#L155
# Update device ID to match current process's device mapping
if not isinstance(list_args[6], int):
raise RuntimeError(
f"Expected CUDA IPC tensor reconstruction list_args[6] to be device ID, but got {list_args[6]}. Please file an issue at https://github.com/ray-project/ray/issues/new/choose."
)
list_args[6] = device.index
try:
tensor = func(*list_args)
except Exception as e:
raise RuntimeError(
"Error reconstructing CUDA IPC tensor. Source actor may have failed."
) from e
tensors.append(tensor)
return tensors
def send_multiple_tensors(
self,
tensors: List["torch.Tensor"],
tensor_transport_metadata: CudaIpcTransportMetadata,
communicator_metadata: CudaIpcCommunicatorMetadata,
):
raise NotImplementedError(
"CUDA IPC transport does not support send_multiple_tensors, since it is a one-sided transport."
)
def garbage_collect(
self,
obj_id: str,
tensor_transport_meta: CudaIpcTransportMetadata,
tensors: List["torch.Tensor"],
):
pass
def abort_transport(
self,
obj_id: str,
communicator_metadata: CudaIpcCommunicatorMetadata,
):
# TODO: Implement CUDA IPC abort transport.
raise NotImplementedError(
"CUDA IPC transport does not support abort_transport for now."
)
@@ -0,0 +1,289 @@
"""Memory pool management for NIXL RDT optimization."""
import logging
from typing import TYPE_CHECKING, Dict, List, Optional
if TYPE_CHECKING:
import torch
logger = logging.getLogger(__name__)
class NixlOutOfMemoryError(RuntimeError):
"""Raised when the NIXL memory pool runs out of space.
The pre-allocated memory pool does not have enough free space for the
requested allocation. Increase the pool size passed to
``register_nixl_memory_pool`` to avoid this error.
"""
class MemoryBlock:
"""Represents a memory block in the pool."""
def __init__(self, offset: int, size: int):
self.offset = offset
self.size = size
def __repr__(self):
return f"MemoryBlock(offset={self.offset}, size={self.size})"
class MemoryPoolManager:
"""Manages a pre-allocated memory pool for NIXL RDT transfers.
This class provides a memory allocator interface over a pre-allocated memory pool,
allowing reuse of registered memory descriptors across multiple transfers.
It also tracks which storage data pointers have allocated blocks, enabling
cross-call reuse (the same storage can reuse its pool slot across multiple
ray.put calls) and pool-level block management.
"""
def __init__(self, pool_size: int, device: "torch.device"):
"""Initialize the memory pool manager.
Args:
pool_size: Size of the memory pool in bytes.
device: Device to allocate the pool on.
"""
import torch
self.pool_size = pool_size
self.device = device
# Allocate the memory pool as a single tensor
# We use a 1D tensor of uint8 to represent raw memory
self._pool_tensor = torch.zeros(
pool_size, dtype=torch.uint8, device=self.device
)
# Track free blocks using a largest-request-first, first-fit allocator.
# List of MemoryBlock for free blocks, sorted by offset.
self._free_blocks: List[MemoryBlock] = [MemoryBlock(offset=0, size=pool_size)]
# Track allocated blocks by storage data pointer.
# Maps storage_data_ptr -> MemoryBlock in the pool.
self._allocated_blocks: Dict[int, MemoryBlock] = {}
def get_pool_tensor(self) -> "torch.Tensor":
"""Get the underlying pool tensor.
Returns:
The pre-allocated tensor representing the memory pool.
"""
return self._pool_tensor
def has_block(self, tensor: "torch.Tensor") -> bool:
"""Check if a tensor has an allocated block in the pool.
Args:
tensor: The tensor to check.
Returns:
True if the tensor's storage has an allocated block.
"""
return tensor.untyped_storage().data_ptr() in self._allocated_blocks
def free_tensors(self, tensors: List["torch.Tensor"]) -> None:
"""Return pool blocks for the given tensors back to the pool.
The caller is responsible for calling this method on the same tensors that were previously allocated in the pool before those tensors go out of scope.
Args:
tensors: Tensors whose pool blocks should be freed.
"""
blocks = []
for tensor in tensors:
ptr = tensor.untyped_storage().data_ptr()
if ptr in self._allocated_blocks:
blocks.append(self._allocated_blocks.pop(ptr))
if blocks:
self._free_multiple(blocks)
def allocate_for_tensors(
self, tensors: List["torch.Tensor"]
) -> List["torch.Tensor"]:
"""Allocate pool blocks for unique storages, copy data in,
and return pool-backed tensor views for each input tensor. The caller is responsible for calling free on the original tensors to return the allocated tensor views back to the pool before the original tensors go out of scope.
Handles storage-level deduplication: views of the same storage share
one pool block within a single call, and the same storage reuses its
existing pool slot across calls.
Args:
tensors: Source tensors to allocate pool memory for.
Returns:
List of pool-backed tensor views, one per input tensor,
in the same order.
Raises:
NixlOutOfMemoryError: If the pool has insufficient space.
"""
new_allocations = None
newly_tracked_ptrs: List[int] = []
try:
import torch
# Deduplicate storages: group tensors by storage data_ptr so
# views of the same storage share one pool allocation.
# Maps storage data_ptr -> index in alloc_sizes/new_allocations,
# or -1 for storages that already have a pool block (cache hit).
storage_idx: Dict[int, int] = {}
# Maps storage data_ptr -> a representative tensor (for copy).
ptr_to_tensor: Dict[int, "torch.Tensor"] = {}
alloc_sizes: List[int] = []
for tensor in tensors:
ptr = tensor.untyped_storage().data_ptr()
if ptr in storage_idx:
continue
ptr_to_tensor[ptr] = tensor
if self.has_block(tensor):
storage_idx[ptr] = -1
else:
storage_idx[ptr] = len(alloc_sizes)
alloc_sizes.append(tensor.untyped_storage().nbytes())
# Allocate new (non-cached) storages atomically.
if alloc_sizes:
new_allocations = self._allocate_multiple(alloc_sizes)
if new_allocations is None:
raise NixlOutOfMemoryError(
f"NIXL memory pool out of memory: cannot allocate "
f"{len(alloc_sizes)} block(s) totaling "
f"{sum(alloc_sizes)} bytes. Consider increasing "
f"the pool size when calling "
f"register_nixl_memory_pool."
)
# Track and copy newly allocated blocks. Cache hits keep the
# originally copied data -- any mutations to the source storage
# since the first ray.put are not reflected in outstanding refs.
for ptr, idx in storage_idx.items():
if idx < 0:
continue
blk = new_allocations[idx]
self._allocated_blocks[ptr] = blk
newly_tracked_ptrs.append(ptr)
# Copy the tensor's full underlying storage into the pool block.
src = ptr_to_tensor[ptr]
storage_size = src.untyped_storage().nbytes()
storage_bytes = torch.tensor(
[], dtype=torch.uint8, device=src.device
).set_(src.untyped_storage())
self._pool_tensor[blk.offset : blk.offset + storage_size].copy_(
storage_bytes
)
# Build pool-backed tensor views for each input tensor.
pool_views: List["torch.Tensor"] = []
for tensor in tensors:
ptr = tensor.untyped_storage().data_ptr()
blk = self._allocated_blocks[ptr]
pool_offset = blk.offset + (
tensor.storage_offset() * tensor.element_size()
)
view_byte_size = tensor.numel() * tensor.element_size()
pool_bytes = self._pool_tensor[
pool_offset : pool_offset + view_byte_size
]
pool_views.append(pool_bytes.view(tensor.dtype).reshape(tensor.shape))
return pool_views
except Exception:
# Roll back any pool mutations made in this call, then re-raise.
try:
if new_allocations is not None:
self._free_multiple(new_allocations)
for ptr in newly_tracked_ptrs:
self._allocated_blocks.pop(ptr, None)
except Exception as cleanup_err:
logger.error(f"Memory pool cleanup failed: {cleanup_err}.")
raise
def _allocate_multiple(self, sizes: List[int]) -> Optional[List[MemoryBlock]]:
"""Allocate multiple memory blocks from the pool atomically.
Either all allocations succeed, or none of them do.
Args:
sizes: List of sizes to allocate in bytes.
Returns:
List of MemoryBlock if all allocations succeed, None otherwise.
"""
if not sizes or any(s <= 0 for s in sizes):
raise ValueError("Invalid allocation request")
# If total free space is less than total requested, fail fast.
total_requested = sum(sizes)
total_free = sum(b.size for b in self._free_blocks)
if total_free < total_requested:
return None
# Allocate largest first to reduce fragmentation; then return in original order.
order = sorted(range(len(sizes)), key=lambda i: -sizes[i])
sorted_sizes = [sizes[i] for i in order]
# Try to allocate all blocks atomically.
allocations: List[MemoryBlock] = []
temp_free_blocks = [MemoryBlock(b.offset, b.size) for b in self._free_blocks]
for size in sorted_sizes:
allocated = False
for i, block in enumerate(temp_free_blocks):
if block.size >= size:
# Allocate at the start of the current free block
offset = block.offset
remaining_after = block.size - size
if remaining_after == 0:
temp_free_blocks.pop(i)
else:
block.offset = offset + size
block.size = remaining_after
allocations.append(MemoryBlock(offset, size))
allocated = True
break
if not allocated:
# If any size cannot be allocated, the entire batch fails,
# do not modify the real state.
return None
# Reorder allocations back to original request order
result: List[MemoryBlock] = [MemoryBlock(0, 0)] * len(sizes)
for k, alloc in enumerate(allocations):
result[order[k]] = alloc
# All successful, submit modifications
temp_free_blocks.sort(key=lambda b: b.offset)
self._free_blocks = temp_free_blocks
return result
def _free_multiple(self, blocks: List[MemoryBlock]) -> None:
"""Free multiple memory blocks back to the pool.
Args:
blocks: Memory blocks to free.
"""
if not blocks:
raise ValueError("Invalid free request")
self._free_blocks.extend(blocks)
# Single pass: merge all adjacent free blocks
self._free_blocks.sort(key=lambda b: b.offset)
i = 0
while i < len(self._free_blocks) - 1:
curr = self._free_blocks[i]
next_block = self._free_blocks[i + 1]
if curr.offset + curr.size == next_block.offset:
curr.size += next_block.size
self._free_blocks.pop(i + 1)
else:
i += 1
@@ -0,0 +1,738 @@
import functools
import glob
import logging
import os
import threading
import time
import traceback
from collections import OrderedDict
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional
import ray
from ray._private.ray_constants import (
NIXL_REMOTE_AGENT_CACHE_MAXSIZE,
)
from ray.experimental.rdt.nixl_memory_pool import MemoryPoolManager
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
FetchRequest,
TensorTransportManager,
TensorTransportMetadata,
)
if TYPE_CHECKING:
import torch
logger = logging.getLogger(__name__)
@functools.lru_cache(maxsize=1)
def _is_efa_available() -> bool:
"""Detect whether AWS EFA (Elastic Fabric Adapter) devices are present.
A bare host exposes ``efa*`` netdevs, but inside a container/Kubernetes pod
netdevs are network-namespaced away and only the rdma-verbs devices under
``/sys/class/infiniband`` are mounted in. Those verbs devices are not
EFA-specific -- ordinary InfiniBand/RoCE NICs appear there too -- so we
confirm each one is bound to the kernel ``efa`` driver before treating it as
EFA. Without that check, non-AWS RDMA nodes would wrongly auto-select the
LIBFABRIC backend instead of UCX.
"""
if glob.glob("/sys/class/net/efa*"):
return True
for ib_dev in glob.glob("/sys/class/infiniband/*"):
# A stale or broken sysfs entry shouldn't abort the scan; skip it and
# keep looking (defaulting to UCX if nothing resolves to the efa driver).
try:
driver = os.path.realpath(os.path.join(ib_dev, "device", "driver"))
except OSError:
continue
if os.path.basename(driver) == "efa":
return True
return False
def _nixl_transport_available_in_process() -> bool:
"""Returns whether the NIXL tensor transport can be initialized in this process.
Returns:
True if the NIXL agent initializes successfully, False on any failure
(e.g. nixl not installed, LIBFABRIC/EFA probe failure, or other backend
init errors).
"""
try:
from ray.experimental.rdt.util import get_tensor_transport_manager
get_tensor_transport_manager("NIXL").get_nixl_agent()
return True
except Exception:
logger.debug("NIXL tensor transport unavailable on actor.", exc_info=True)
return False
@dataclass
class NixlCommunicatorMetadata(CommunicatorMetadata):
"""Metadata for the NIXL communicator."""
@dataclass
class NixlTransportMetadata(TensorTransportMetadata):
"""Metadata for tensors stored in the GPU object store for NIXL transport.
Args:
nixl_serialized_descs: Serialized tensor descriptors for NIXL transport.
nixl_agent_meta: The additional metadata of the remote NIXL agent.
nixl_agent_name: The name of the NIXL agent.
nixl_agent_meta_version: The version of the NIXL agent metadata.
"""
nixl_serialized_descs: Optional[bytes] = None
nixl_agent_meta: Optional[bytes] = None
nixl_agent_name: Optional[str] = None
nixl_agent_meta_version: Optional[int] = 0
__eq__ = object.__eq__
__hash__ = object.__hash__
@dataclass
class TensorDesc:
# nixlRegDList handle, or None for pool-managed tensors (pool memory is
# registered once at pool creation, so individual tensors don't need their
# own NIXL registration).
reg_desc: Any
# tracks the number of NIXL metadata containing the tensor.
metadata_count: int
@dataclass
class NixlFetchRequest(FetchRequest):
"""NIXL-specific FetchRequest carrying the async transfer state.
Returned by fetch_multiple_tensors and consumed by wait_fetch_complete.
Args:
obj_id: Inherited. The object ID for the transfer, used for abort checks and cleanup.
tensors: Inherited. Pre-allocated output tensors (populated before the transfer starts).
xfer_handle: NIXL transfer request handle.
nixl_agent: Reference to the NIXL agent.
remote_name: Name of the remote NIXL agent.
remove_tensor_descs: Whether to remove tensor descriptors from the cache during cleanup.
"""
xfer_handle: Any = None
nixl_agent: Any = None
remote_name: Optional[str] = None
remove_tensor_descs: bool = False
transport: Any = None
def __del__(self):
if self.transport is not None:
self.transport._cleanup_transfer(
self.obj_id,
self.tensors,
self.xfer_handle,
self.remote_name,
self.remove_tensor_descs,
)
class NixlTensorTransport(TensorTransportManager):
def __init__(self):
# This is lazily initialized because it requires NIXL to actually be installed and we want to allow an owner that is just coordinating to not need to have NIXL installed.
self._nixl_agent = None
self._aborted_transfer_obj_ids = set()
self._aborted_transfer_obj_ids_lock = threading.Lock()
# Mapping from tensor storage data pointer to the NIXL descriptor and reference count.
# Unlike _managed_meta_nixl, we only deregister tensors when ALL metadata containing the tensor is freed.
# For pool-managed tensors, reg_desc is None and the pool block is returned instead of deregistering.
self._tensor_desc_cache: Dict[int, TensorDesc] = {}
# Mapping from object ID to the NIXL managed meta.
# The lifetime of _managed_meta_nixl is tied to the object ref and freed when the ref goes out of scope.
self._managed_meta_nixl: Dict[str, Any] = {}
# Lock protecting _tensor_desc_cache and _managed_meta_nixl since they can be
# accessed from the main task execution thread or the _ray_system thread.
self._cache_lock = threading.RLock()
# LRU cache of remote agent names. When full, the least
# recently used remote agent is evicted and remove_remote_agent is called.
self._remote_agents: OrderedDict = OrderedDict()
# Increment the version whenever memory is deregistered.
self._nixl_agent_meta_version = 0
self._memory_pool: Optional[MemoryPoolManager] = None
# The NIXL backend the agent was actually created with ("UCX" or "LIBFABRIC").
self._backend: Optional[str] = None
def tensor_transport_backend(self) -> str:
return "NIXL"
@staticmethod
def is_one_sided() -> bool:
return True
@staticmethod
def can_abort_transport() -> bool:
return True
def register_nixl_memory(self, tensor: "torch.Tensor") -> None:
"""Registers the tensor's memory with NIXL and bumps the reference count so the memory region is never deregistered."""
self._add_tensor_descs([tensor])
def register_nixl_memory_pool(self, size: int, device: "torch.device") -> None:
"""Pre-allocates a memory pool and registers it with NIXL.
Args:
size: Size of the memory pool in bytes.
device: Device to allocate the pool on (cpu or cuda).
Raises:
ValueError: If a memory pool is already registered.
"""
if self._memory_pool is not None:
raise ValueError(
"A memory pool is already registered. "
"Only one memory pool is supported."
)
nixl_agent = self.get_nixl_agent()
pool = MemoryPoolManager(pool_size=size, device=device)
nixl_agent.register_memory(pool.get_pool_tensor())
self._memory_pool = pool
def deregister_nixl_memory(self, tensor: "torch.Tensor") -> None:
"""Decrements the reference count for the tensor's NIXL memory registration.
If the count reaches 0, the memory is deregistered from NIXL.
"""
self._remove_tensor_descs([tensor])
def select_backend(self) -> str:
"""Returns the NIXL backend to attempt.
Prefers LIBFABRIC when EFA devices are present and UCX everywhere else.
LIBFABRIC requires GPUDirect (GDR) for CUDA registration; if it isn't
available, ``_add_tensor_descs`` surfaces a clear error at registration
time with backend-specific troubleshooting guidance.
"""
return "LIBFABRIC" if _is_efa_available() else "UCX"
def _make_nixl_agent(self, backend: str):
"""Creates a NIXL agent configured with the given backend."""
from nixl._api import nixl_agent, nixl_agent_config
agent_config = nixl_agent_config(backends=[backend])
ctx = ray.get_runtime_context()
actor_id = ctx.get_actor_id()
if actor_id is None:
# If the actor id is None, it means the current process is a driver.
import uuid
actor_id = f"RAY-DRIVER-{uuid.uuid4()}"
return nixl_agent(actor_id, agent_config)
def get_nixl_agent(self):
"""Returns the NIXL agent, building it once on first use."""
if self._nixl_agent is None:
self._nixl_agent = self._init_nixl_agent()
return self._nixl_agent
def _init_nixl_agent(self):
"""Builds the NIXL agent for the selected backend."""
backend = self.select_backend()
agent = self._make_nixl_agent(backend)
self._backend = backend
logger.info("Using NIXL backend: %s", backend)
return agent
def actor_has_tensor_transport(self, actor: "ray.actor.ActorHandle") -> bool:
# TODO(dayshah): This is called on a .remote RDT call, so it's quite expensive.
def __ray_actor_has_tensor_transport__(
self: "ray.actor.ActorHandle",
) -> bool:
return _nixl_transport_available_in_process()
return ray.get(
actor.__ray_call__.options(concurrency_group="_ray_system").remote(
__ray_actor_has_tensor_transport__
)
)
def extract_tensor_transport_metadata(
self,
obj_id: str,
rdt_object: List["torch.Tensor"],
) -> NixlTransportMetadata:
import torch
with self._cache_lock:
device = None
tensor_meta = []
if rdt_object:
# We assume all tensors in one RDT object have the same device type,
# but we don't assume they're all on the same device.
devices = set()
device = rdt_object[0].device
for t in rdt_object:
if t.device.type != device.type:
raise ValueError(
"All tensors in an RDT object must have the same device type."
)
if not t.is_contiguous():
raise ValueError(
"All tensors in an RDT object must be contiguous."
)
tensor_meta.append((t.shape, t.dtype))
devices.add(t.device)
if device.type == "cuda":
# We have to synchronize before memory registration to assure the
# object has been created because nixl doesn't guarantee it will.
for dev in devices:
torch.cuda.synchronize(dev)
nixl_agent = self.get_nixl_agent()
# Use the pool only when every tensor lives on the exact same
# device as the pool, AND no tensor already has an existing
# NIXL registration (via register_nixl_memory).
pool_eligible = (
self._memory_pool is not None
and all(
t.device == self._memory_pool.get_pool_tensor().device
for t in rdt_object
)
and not any(self._tensor_memory_registered(t) for t in rdt_object)
)
if pool_eligible:
xfer_descs = self._allocate_pool_xfer_descs(rdt_object)
else:
self._add_tensor_descs(rdt_object)
xfer_descs = nixl_agent.get_xfer_descs(rdt_object)
serialized_descs = nixl_agent.get_serialized_descs(xfer_descs)
agent_meta = nixl_agent.get_agent_metadata()
agent_name = nixl_agent.name
agent_meta_version = self._nixl_agent_meta_version
else:
serialized_descs, agent_meta = None, None
agent_name, agent_meta_version = None, None
ret = NixlTransportMetadata(
tensor_meta=tensor_meta,
tensor_device=device.type if device else None,
nixl_serialized_descs=serialized_descs,
nixl_agent_meta=agent_meta,
nixl_agent_name=agent_name,
nixl_agent_meta_version=agent_meta_version,
)
self._put_meta(obj_id, ret)
return ret
def get_communicator_metadata(
self,
src_actor: "ray.actor.ActorHandle",
dst_actor: "ray.actor.ActorHandle",
backend: Optional[str] = None,
) -> NixlCommunicatorMetadata:
return NixlCommunicatorMetadata()
def fetch_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List["torch.Tensor"]] = None,
) -> NixlFetchRequest:
"""Initiates an async transfer for multiple tensors.
This triggers the transfer but does not wait for completion.
Call wait_fetch_complete(fetch_request) to wait for the transfer to
finish and retrieve the tensors.
Args:
obj_id: The object ID for the transfer.
tensor_transport_metadata: Metadata for the tensor transport.
communicator_metadata: Metadata for the communicator.
target_buffers: Optional pre-allocated buffers to receive tensors into.
Returns:
A NixlFetchRequest carrying the async transfer state.
"""
from ray.experimental.rdt.util import (
create_empty_tensors_from_metadata,
)
tensors = target_buffers or create_empty_tensors_from_metadata(
tensor_transport_metadata
)
assert isinstance(tensor_transport_metadata, NixlTransportMetadata)
assert isinstance(communicator_metadata, NixlCommunicatorMetadata)
nixl_serialized_descs = tensor_transport_metadata.nixl_serialized_descs
remote_nixl_agent_meta = tensor_transport_metadata.nixl_agent_meta
with self._aborted_transfer_obj_ids_lock:
if obj_id in self._aborted_transfer_obj_ids:
self._aborted_transfer_obj_ids.remove(obj_id)
raise RuntimeError(f"NIXL transfer aborted for object id: {obj_id}")
remote_name = None
xfer_handle = None
added_tensor_descs = False
assert tensors
try:
nixl_agent = self.get_nixl_agent()
remote_xfer_descs = nixl_agent.deserialize_descs(nixl_serialized_descs)
# This creates a placeholder for the tensor in the tensor_desc_cache even though it doesn't have an object ref for caching purposes.
self._add_tensor_descs(tensors)
added_tensor_descs = True
local_xfer_descs = nixl_agent.get_xfer_descs(tensors)
remote_name = tensor_transport_metadata.nixl_agent_name
remote_agent_meta_version = (
tensor_transport_metadata.nixl_agent_meta_version
)
# Nixl agent reuse is enabled.
if NIXL_REMOTE_AGENT_CACHE_MAXSIZE > 0:
if remote_name in self._remote_agents:
# If the remote agent metadata version is different from the cached one,
# it means there was memory deregistered. We need to remove the remote agent
# before adding it, because `nixlRemoteSection` currently does not support
# updating descriptor list in such a case (there is potential memory overlap).
if remote_agent_meta_version != self._remote_agents[remote_name]:
nixl_agent.remove_remote_agent(remote_name)
self._remote_agents.move_to_end(remote_name)
elif len(self._remote_agents) >= NIXL_REMOTE_AGENT_CACHE_MAXSIZE:
evicted_agent_name, _ = self._remote_agents.popitem(last=False)
nixl_agent.remove_remote_agent(evicted_agent_name)
self._remote_agents[remote_name] = remote_agent_meta_version
nixl_agent.add_remote_agent(remote_nixl_agent_meta)
xfer_handle = nixl_agent.initialize_xfer(
"READ",
local_xfer_descs,
remote_xfer_descs,
remote_name,
b"UUID",
)
state = nixl_agent.transfer(xfer_handle)
if state == "ERR":
raise RuntimeError("NIXL transfer got to Error state.")
return NixlFetchRequest(
tensors=tensors,
obj_id=obj_id,
xfer_handle=xfer_handle,
nixl_agent=nixl_agent,
remote_name=remote_name,
remove_tensor_descs=added_tensor_descs,
transport=self,
)
except Exception:
self._cleanup_transfer(
obj_id, tensors, xfer_handle, remote_name, added_tensor_descs
)
# TODO(swang): There is a circular import error because ray.util
# currently depends on ray.experimental.internal_kv.
from ray.exceptions import RayDirectTransportError
raise RayDirectTransportError(
f"The NIXL transfer failed for object id: {obj_id}. The source actor may have died during the transfer. "
f"The exception thrown from nixl transfer was:\n {traceback.format_exc()}"
) from None
def wait_fetch_complete(
self, fetch_request: FetchRequest, timeout: float = -1
) -> List["torch.Tensor"]:
"""Waits for a previously initiated fetch to complete and returns the tensors.
Args:
fetch_request: The NixlFetchRequest returned by fetch_multiple_tensors.
timeout: Maximum time in seconds to wait. -1 means wait indefinitely.
0 means return immediately if not ready.
Returns:
List of tensors that were transferred.
Raises:
RayDirectTransportError: If the transfer failed.
TimeoutError: If the timeout is exceeded.
"""
assert isinstance(fetch_request, NixlFetchRequest)
obj_id = fetch_request.obj_id
if not fetch_request.tensors:
return fetch_request.tensors
try:
# Check the state of the transfer continuously.
deadline = None if timeout < 0 else time.monotonic() + timeout
while True:
state = self.get_nixl_agent().check_xfer_state(
fetch_request.xfer_handle
)
if state == "ERR":
raise RuntimeError("NIXL transfer got to Error state.")
if state == "PROC":
if deadline is not None and time.monotonic() >= deadline:
raise TimeoutError(
f"NIXL transfer timed out after {timeout}s for object id: {obj_id}"
)
with self._aborted_transfer_obj_ids_lock:
if obj_id in self._aborted_transfer_obj_ids:
self._aborted_transfer_obj_ids.remove(obj_id)
raise RuntimeError(
f"NIXL transfer aborted for object id: {obj_id}"
)
time.sleep(0.001) # Avoid busy waiting
elif state == "DONE":
break
return fetch_request.tensors
except TimeoutError:
raise
except Exception:
from ray.exceptions import RayDirectTransportError
raise RayDirectTransportError(
f"The NIXL transfer failed for object id: {obj_id}. The source actor may have died during the transfer. "
f"The exception thrown from nixl transfer was:\n {traceback.format_exc()}"
) from None
def _cleanup_transfer(
self,
obj_id: str,
tensors: List["torch.Tensor"],
xfer_handle: Any,
remote_name: Optional[str],
remove_tensor_descs: bool,
) -> None:
"""Cleans up resources after a transfer completes or fails."""
# We could raise errors or NIXL could raise errors like NIXL_ERR_REMOTE_DISCONNECT,
# so doing best effort cleanup.
nixl_agent = self._nixl_agent
if nixl_agent is None:
return
# We could raise errors or NIXL could raise errors like NIXL_ERR_REMOTE_DISCONNECT,
# so doing best effort cleanup.
with self._aborted_transfer_obj_ids_lock:
self._aborted_transfer_obj_ids.discard(obj_id)
if xfer_handle:
nixl_agent.release_xfer_handle(xfer_handle)
if NIXL_REMOTE_AGENT_CACHE_MAXSIZE == 0 and remote_name:
nixl_agent.remove_remote_agent(remote_name)
if remove_tensor_descs:
self._remove_tensor_descs(tensors)
def recv_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List["torch.Tensor"]] = None,
) -> List["torch.Tensor"]:
"""Receives multiple tensors synchronously."""
fetch_request = self.fetch_multiple_tensors(
obj_id, tensor_transport_metadata, communicator_metadata, target_buffers
)
return self.wait_fetch_complete(fetch_request)
def send_multiple_tensors(
self,
tensors: List["torch.Tensor"],
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
):
raise NotImplementedError(
"NIXL transport does not support send_multiple_tensors, since it is a one-sided transport."
)
def garbage_collect(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
tensors: List["torch.Tensor"],
):
with self._cache_lock:
assert isinstance(tensor_transport_meta, NixlTransportMetadata)
if obj_id not in self._managed_meta_nixl:
return
self._managed_meta_nixl.pop(obj_id, None)
self._remove_tensor_descs(tensors)
def abort_transport(
self,
obj_id: str,
communicator_metadata: CommunicatorMetadata,
):
with self._aborted_transfer_obj_ids_lock:
self._aborted_transfer_obj_ids.add(obj_id)
def _get_num_managed_meta_nixl(self) -> int:
with self._cache_lock:
return len(self._managed_meta_nixl)
def _get_meta(self, object_id: str) -> Optional[NixlTransportMetadata]:
"""
Get the NIXL transport metadata for the given object ID if it exists
"""
with self._cache_lock:
if object_id in self._managed_meta_nixl:
return self._managed_meta_nixl[object_id]
return None
def _put_meta(self, object_id: str, meta: NixlTransportMetadata):
"""
Store the NIXL transport metadata for the given object ID
"""
with self._cache_lock:
self._managed_meta_nixl[object_id] = meta
def _remove_tensor_descs(self, tensors: List["torch.Tensor"]):
"""
Decrements the reference count for each tensor. If the count reaches 0,
traditionally-registered memory is deregistered from NIXL, while
pool-managed blocks (reg_desc is None) are returned to the pool.
"""
with self._cache_lock:
pool_return_tensors: List["torch.Tensor"] = []
for tensor in tensors:
key = tensor.untyped_storage().data_ptr()
if key not in self._tensor_desc_cache:
continue
tensor_desc = self._tensor_desc_cache[key]
tensor_desc.metadata_count -= 1
if tensor_desc.metadata_count == 0:
self._tensor_desc_cache.pop(key)
if tensor_desc.reg_desc is not None:
# Traditional path: deregister NIXL memory.
self.get_nixl_agent().deregister_memory(tensor_desc.reg_desc)
self._nixl_agent_meta_version += 1
else:
# Pool path: return block to pool.
pool_return_tensors.append(tensor)
if pool_return_tensors and self._memory_pool is not None:
self._memory_pool.free_tensors(pool_return_tensors)
def _add_tensor_descs(self, tensors: List["torch.Tensor"]):
"""
If this is the first time the tensor is being registered, we register the
full underlying pytorch storage object with NIXL. Otherwise, we increment the reference count.
"""
with self._cache_lock:
for tensor in tensors:
key = tensor.untyped_storage().data_ptr()
if key in self._tensor_desc_cache:
self._tensor_desc_cache[key].metadata_count += 1
continue
mem_type = "cuda" if tensor.is_cuda else "cpu"
# the GPU ID of the device the tensor is on.
# NOTE: we clip this to 0 since the GPU ID is not used for
# CPU tensors, and get_device returns -1 for CPU tensors.
# This triggers an error in nixl since it expects an unsigned.
gpu_id = max(tensor.get_device(), 0)
# Registering the full underlying pytorch storage object by
# constructing a memory region with the data pointer, size,
# GPU ID, and meta info. Doing the equivalent of what nixl
# does for pytorch tensors internally:
# https://github.com/ai-dynamo/nixl/blob/dd23ef01bd366aef89fa552f2b042f89a0b45fcb/src/api/python/_api.py#L1034
try:
reg_desc = self.get_nixl_agent().register_memory(
[
(
tensor.untyped_storage().data_ptr(),
tensor.untyped_storage().nbytes(),
gpu_id,
"",
)
],
mem_type=mem_type,
)
except Exception as e:
# TODO(xyuzh): Remove the warning after nixl surfaces the error message
if self._backend == "LIBFABRIC":
troubleshooting = (
"See https://github.com/ai-dynamo/nixl/blob/main/src/plugins/libfabric/README.md "
"for LIBFABRIC troubleshooting. "
"Set FI_LOG_LEVEL=Debug for libfabric diagnostics."
)
else:
troubleshooting = (
"See https://docs.ray.io/en/latest/ray-core/direct-transport/direct-transport.html "
"for NIXL/UCX configuration. "
"Set UCX_LOG_LEVEL=debug for UCX diagnostics."
)
vmm_hint = ""
alloc_conf = os.environ.get("PYTORCH_CUDA_ALLOC_CONF", "").lower()
if mem_type == "cuda" and "expandable_segments:true" in alloc_conf:
vmm_hint = (
" PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True is set; "
"CUDA VMM memory can't be RDMA-registered — allocate "
"transferred tensors without expandable_segments."
)
raise RuntimeError(
f"Failed to register {mem_type} memory with NIXL "
f"(backend={self._backend}, "
f"size={tensor.untyped_storage().nbytes()} bytes, "
f"gpu_id={gpu_id}).{vmm_hint} {troubleshooting}"
) from e
self._tensor_desc_cache[key] = TensorDesc(reg_desc, 1)
def _tensor_memory_registered(self, t: "torch.Tensor") -> bool:
"""Check if the tensor's memory has been registered with NIXL."""
entry = self._tensor_desc_cache.get(t.untyped_storage().data_ptr())
return entry is not None and entry.reg_desc is not None
def _add_pool_tensor_descs(self, tensors: List["torch.Tensor"]):
"""Add pool-managed tensor entries to the unified _tensor_desc_cache.
Pool-managed tensors use reg_desc=None since pool memory is registered
once at pool creation. The metadata_count tracks reference counting
just like traditional tensors.
Note: Entries are keyed by the source tensor's storage ``data_ptr()``.
If PyTorch frees and reallocates that storage address before GC runs,
a stale cache entry could map to an unrelated tensor. This is the same
constraint as the traditional (non-pool) path and is mitigated by the
fact that pool blocks hold a reference to pool memory, not the source
storage.
"""
with self._cache_lock:
for tensor in tensors:
key = tensor.untyped_storage().data_ptr()
if key in self._tensor_desc_cache:
self._tensor_desc_cache[key].metadata_count += 1
else:
self._tensor_desc_cache[key] = TensorDesc(
reg_desc=None, metadata_count=1
)
def _allocate_pool_xfer_descs(self, tensors: List["torch.Tensor"]) -> Any:
"""Allocate pool memory for tensors and return NIXL transfer descriptors.
Handles rollback of newly allocated pool blocks if get_xfer_descs
fails, without disturbing cached blocks from prior calls.
"""
pool = self._memory_pool
# Remember which storages already have a pool block (cache hits)
# so we don't free them on rollback.
pre_existing = {
t.untyped_storage().data_ptr() for t in tensors if pool.has_block(t)
}
pool_tensor_views = pool.allocate_for_tensors(tensors)
try:
xfer_descs = self._nixl_agent.get_xfer_descs(pool_tensor_views)
except Exception:
# Only free newly allocated blocks, not cache hits.
new_tensors = [
t for t in tensors if t.untyped_storage().data_ptr() not in pre_existing
]
if new_tensors:
pool.free_tensors(new_tensors)
raise
self._add_pool_tensor_descs(tensors)
return xfer_descs
+963
View File
@@ -0,0 +1,963 @@
import logging
import threading
import time
import warnings
import weakref
from collections import defaultdict
from dataclasses import dataclass
from queue import Queue
from typing import (
TYPE_CHECKING,
Any,
Dict,
List,
NamedTuple,
Optional,
Set,
Tuple,
Union,
)
import ray
from ray._private import ray_constants
from ray._raylet import ObjectRef
from ray.experimental.rdt.tensor_transport_manager import FetchRequest
from ray.util.annotations import PublicAPI
@dataclass
class ObjectStoreFetchRequest(FetchRequest):
"""Pending fetch via the object store. Holds the remote ObjectRef to ray.get on.
Args:
obj_id: The RDT object ID being fetched.
object_ref: The ObjectRef returned by the __ray_fetch_rdt_object__ remote call.
tensors: Unused. Tensors are returned directly by ray.get.
"""
object_ref: Optional[ObjectRef] = None
tensors: Optional[List[Any]] = None
if TYPE_CHECKING:
from ray.experimental.rdt.rdt_store import (
RDTStore,
)
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
FetchRequest,
TensorTransportMetadata,
)
logger = logging.getLogger(__name__)
# RDTMeta is a named tuple containing the source actor, tensor transport
# backend, tensor metadata, and other information that needs to be recorded.
# - The tensor transport backend is the backend used to transport the tensors.
# - The tensor metadata is a list of tuples, each containing the shape and dtype
# of a tensor in the RDT store.
class RDTMeta(NamedTuple):
src_actor: "ray.actor.ActorHandle"
tensor_transport_backend: str
# This is set when the actual object is created and the metadata makes it back to the owner.
# For ray.put the owner is the creator so it's immediately set.
tensor_transport_meta: Optional["TensorTransportMetadata"]
# sent_dest_actors tracks the set of actor IDs that this object has been sent to.
# Note that since the set is mutable, it shouldn't be accessed without a lock.
sent_dest_actors: Set[str]
# sent_to_src_actor_and_others_warned indicates whether the object has already triggered a warning about being sent back to the source actor and other actors simultaneously.
sent_to_src_actor_and_others_warned: bool
# If the user set buffers for the object, the object will be fetched directly into the buffers on a ray.get
target_buffers: Optional[List[weakref.ReferenceType[Any]]]
# This is used to periodically check in on the RDT transfer through the refs from
# __ray_send__ and __ray_recv__ and abort operations in case of failures / timeouts.
class TransferMetadata(NamedTuple):
src_actor: "ray.actor.ActorHandle"
dst_actor: "ray.actor.ActorHandle"
send_ref: Optional[ObjectRef]
recv_ref: ObjectRef
communicator_meta: "CommunicatorMetadata"
backend: str
obj_id: str
timeout: float
@PublicAPI(stability="alpha")
def wait_tensor_freed(tensor: Any, timeout: Optional[float] = None):
"""
Wait for the tensor to be freed.
This function is useful for cases where an actor keeps a reference to a
tensor after returning the tensor from a task annotated with
`@ray.method(tensor_transport=...)`. In this case, Ray will store a
*reference* to the tensor, so any in-place modifications made by the actor
that returned the tensor could be seen by other actors. See
:ref:`Ray Direct Transport (RDT) <direct-transport>` for more details.
Call this function for RDT objects to ensure that all corresponding
`ray.ObjectRefs` have gone out of scope and therefore the tensor is safe to
write to again.
Args:
tensor: The tensor to wait to be freed. This should be a tensor that was
previously returned by a task annotated with
`@ray.method(tensor_transport=...)` or stored via
`ray.put(_tensor_transport="...")`.
timeout: The timeout in seconds to wait for all references to the tensor
to go out of scope. Set to None to wait indefinitely. Note that if
None is used, this function could hang if the `ray.ObjectRefs` that
refer to this tensor never go out of scope.
"""
rdt_manager = ray.worker.global_worker.rdt_manager
rdt_manager.rdt_store.wait_tensor_freed(tensor, timeout)
@PublicAPI(stability="alpha")
def set_target_for_ref(ref: ObjectRef, target: List[Any]):
"""
Set target buffers for an RDT ObjectRef to fetch tensors into when `ray.get` is called.
This is only supported by some transports (e.g., NIXL). If the transport
does not support this feature, an exception will be raised during ray.get.
Before receiving, Ray validates that the provided target buffers match the metadata
of the tensors in the object (e.g., shape, dtype, device). If validation fails,
a `ValueError` is raised. We recommend sending over lists of tensors and passing a list
of the same length here because the serialization order from the sender-side must match
the order of the target tensors here.
Args:
ref: The ObjectRef to set the target buffers for. The ref must be for an RDT object.
target: A list of tensors to be used as the target buffers to receive into.
"""
rdt_manager = ray.worker.global_worker.rdt_manager
rdt_manager.set_target_buffers_for_ref(ref, target)
class RDTManager:
def __init__(self):
# This lock protects _managed_rdt_metadata, _queued_transfers, and _queued_frees since
# they can be accessed from the user's python thread or the CoreWorker's main io service thread.
self._lock = threading.Lock()
# A dictionary that maps from owned object's ID to RDTMeta.
# This dictionary is hosted on the "driver" process of the actors that
# store and send/receive RDT objects.
self._managed_rdt_metadata: Dict[str, RDTMeta] = {}
# Condition variable to wait for the tensor transport meta to be set.
self._tensor_transport_meta_cv = threading.Condition(self._lock)
# A dictionary that maps from an object id to a list of actors
# that are queued to receive the object.
self._queued_transfers: Dict[str, List["ray.actor.ActorHandle"]] = defaultdict(
list
)
# A set of object ids that are queued to be freed. This is used when the object is freed
# before the owner knows it's created (the tensor transport metadata is not available yet).
self._queued_frees: Set[str] = set()
# This lock makes sure the _rdt_store and _monitor_failures_thread are only created once.
self._init_lock = threading.Lock()
# Per-actor local storage for RDT objects. We create the RDT store
# lazily, if a user specifies a non-default tensor_transport, to avoid
# circular import and because it imports third-party dependencies like
# PyTorch.
self._rdt_store: Optional["RDTStore"] = None
# Thread safe queue of transport refs that the monitor thread needs to start monitoring
self._unmonitored_transfers: Queue[TransferMetadata] = Queue()
# Background thread to poll on the transfer operation.
self._monitor_failures_thread = None
# Event to signal the monitor_failures thread to shutdown
self._monitor_failures_shutdown_event = threading.Event()
# If the actor isn't in the dict, the task to launch the custom transport registration task hasn't been submitted yet.
# If the value is an object ref, we have to wait for the registration task to complete.
# If the value is True, the actor has registered any custom transports.
# The value should never be False.
# TODO: This is a short-term solution. In the future, we'll do registration with actor initialization
# to make actor restarts and submitting from another worker work.
self.actor_id_to_transports_registered: Dict[str, Union[ObjectRef, bool]] = {}
def register_custom_transports_on_actor(self, actor: "ray.actor.ActorHandle"):
from ray.experimental.rdt.util import (
register_custom_tensor_transports_on_actor,
)
ref = register_custom_tensor_transports_on_actor(actor)
# ref is None if there are no custom transports registered.
self.actor_id_to_transports_registered[actor._actor_id] = (
True if ref is None else ref
)
def wait_until_custom_transports_registered(self, actor: "ray.actor.ActorHandle"):
actor_id = actor._actor_id
if actor_id not in self.actor_id_to_transports_registered:
self.register_custom_transports_on_actor(actor)
if self.actor_id_to_transports_registered[actor_id] is not True:
ray.get(self.actor_id_to_transports_registered[actor_id])
self.actor_id_to_transports_registered[actor_id] = True
@property
def rdt_store(self) -> "ray.experimental.RDTStore":
with self._init_lock:
if self._rdt_store is None:
from ray.experimental.rdt.rdt_store import (
RDTStore,
)
self._rdt_store = RDTStore()
return self._rdt_store
def shutdown(self):
"""
Interrupt and join the _monitor_failures_thread
"""
with self._init_lock:
if self._monitor_failures_thread:
self._monitor_failures_shutdown_event.set()
self._monitor_failures_thread.join()
self._monitor_failures_shutdown_event.clear()
self._monitor_failures_thread = None
def start_monitor_thread_if_needed(self):
with self._init_lock:
# To make sure _monitor_failures_thread is started only once
if self._monitor_failures_thread is None:
self._monitor_failures_thread = threading.Thread(
target=self._monitor_failures, daemon=True
)
self._monitor_failures_thread.start()
def is_managed_object(self, obj_id: str) -> bool:
"""
Check if the RDT object is owned or borrowed by this process.
"""
with self._lock:
return obj_id in self._managed_rdt_metadata
def set_rdt_metadata(self, obj_id: str, rdt_meta: RDTMeta):
with self._lock:
self._managed_rdt_metadata[obj_id] = rdt_meta
def get_rdt_metadata(self, obj_id: str) -> Optional[RDTMeta]:
with self._lock:
return self._managed_rdt_metadata.get(obj_id, None)
def wait_for_tensor_transport_metadata(
self, obj_id: str, timeout: float
) -> Optional["TensorTransportMetadata"]:
with self._tensor_transport_meta_cv:
if self._tensor_transport_meta_cv.wait_for(
lambda: self._managed_rdt_metadata[obj_id].tensor_transport_meta
is not None,
timeout=timeout,
):
return self._managed_rdt_metadata[obj_id].tensor_transport_meta
else:
return None
def _monitor_failures(self):
"""
Monitor the refs from send and recv tasks and abort the transfers
if they error out or timeout to prevent hanging.
"""
not_done = []
done = []
ref_info_map = {}
while not self._monitor_failures_shutdown_event.is_set():
while not self._unmonitored_transfers.empty():
ref_info = self._unmonitored_transfers.get()
if ref_info.send_ref:
not_done.append(ref_info.send_ref)
ref_info_map[ref_info.send_ref.hex()] = ref_info
not_done.append(ref_info.recv_ref)
ref_info_map[ref_info.recv_ref.hex()] = ref_info
if len(not_done) > 0:
done, not_done = ray.wait(not_done, num_returns=1, timeout=1)
if len(done) > 0:
try:
ray.get(done[0])
ref_info_map.pop(done[0].hex(), None)
except Exception as e:
self._abort_transport(done[0], ref_info_map, e)
while len(not_done) > 0:
if not_done[0].hex() not in ref_info_map:
# The associated transfer was already aborted.
not_done.pop(0)
elif ref_info_map[not_done[0].hex()].timeout < time.time():
self._abort_transport(
not_done[0],
ref_info_map,
TimeoutError(
f"RDT transfer failed after {ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS}s. "
"You can increase the timeout by setting RAY_rdt_fetch_fail_timeout_milliseconds"
),
)
else:
# wait returns lists in the same order they were passed in, so if
# the timeout of first hasn't been reached, neither have the others.
break
if len(not_done) == 0:
# If we emptied out _unmonitored_transfers on this iteration, wait for a bit.
self._monitor_failures_shutdown_event.wait(1)
def _abort_transport(
self,
failed_ref: ObjectRef,
ref_info_map: Dict[str, TransferMetadata],
exception: Exception,
):
"""
Cleans up the ref_info_map, kill the src and dst actors, and destroy the
collective group if necessary.
"""
from ray.experimental.collective import destroy_collective_group
from ray.experimental.rdt.collective_tensor_transport import (
CollectiveCommunicatorMetadata,
)
from ray.experimental.rdt.rdt_store import (
__ray_abort_transport__,
)
from ray.experimental.rdt.util import (
get_tensor_transport_manager,
)
ref_info = ref_info_map.pop(failed_ref.hex(), None)
if ref_info is None:
return
if ref_info.send_ref:
ref_info_map.pop(ref_info.send_ref.hex(), None)
ref_info_map.pop(ref_info.recv_ref.hex(), None)
tensor_transport_manager = get_tensor_transport_manager(ref_info.backend)
if tensor_transport_manager.can_abort_transport():
if not tensor_transport_manager.__class__.is_one_sided():
# This is dead code until we implement a NCCL abort since NIXL
# is the only abortable transport for now and is one-sided.
ref_info.src_actor.__ray_call__.options(
concurrency_group="_ray_system_error"
).remote(
__ray_abort_transport__,
ref_info.obj_id,
ref_info.communicator_meta,
ref_info.backend,
)
ref_info.dst_actor.__ray_call__.options(
concurrency_group="_ray_system_error"
).remote(
__ray_abort_transport__,
ref_info.obj_id,
ref_info.communicator_meta,
ref_info.backend,
)
logger.info(
"RDT transfer with src actor %s and dst actor %s failed due to %s.",
ref_info.src_actor,
ref_info.dst_actor,
exception,
)
else:
# TODO(#51276): Kill all actors in the collective group when we support more collective operations
ray.kill(ref_info.src_actor)
ray.kill(ref_info.dst_actor)
logger.error(
"RDT transfer with src actor %s and dst actor %s failed. Killing the actors. "
"Transfer failed with exception: %s",
ref_info.src_actor,
ref_info.dst_actor,
exception,
)
# isinstance does an implicit cast and makes communicator_name inaccessible
# so we have to get communicator_name before the cast.
if isinstance(ref_info.communicator_meta, CollectiveCommunicatorMetadata):
try:
collective_group_name = ref_info.communicator_meta.communicator_name
destroy_collective_group(collective_group_name)
logger.error(
"Destroyed collective group %s due to a hanging/failed RDT transfer",
collective_group_name,
)
except ValueError:
# Collective group was already destroyed
pass
def add_rdt_ref(
self,
obj_ref: ObjectRef,
src_actor: "ray.actor.ActorHandle",
tensor_transport: str,
tensor_transport_meta: Optional["TensorTransportMetadata"] = None,
):
"""Add an RDT object reference to the RDT manager. This should be
called whenever the current process calls a task that is annotated with
`@ray.method(tensor_transport=...)`.
Args:
obj_ref: The ObjectRef of the task output.
src_actor: The actor that executes the task and that creates the RDT object.
tensor_transport: The tensor transport protocol to use for the RDT object.
tensor_transport_meta: The tensor transport metadata that is pre-computed.
This is known at ref creation time if the object is created through ray.put.
"""
self.set_rdt_metadata(
obj_ref.hex(),
RDTMeta(
src_actor=src_actor,
tensor_transport_backend=tensor_transport,
tensor_transport_meta=tensor_transport_meta, # None if not from ray.put
sent_dest_actors=set(),
sent_to_src_actor_and_others_warned=False,
target_buffers=None,
),
)
def set_tensor_transport_metadata_and_trigger_queued_operations(
self, obj_id: str, tensor_transport_meta: "TensorTransportMetadata"
):
"""
Sets the tensor transport metadata for an object and triggers any queued
up transfers or frees for that object.
"""
dst_actors = None
free_object = False
with self._tensor_transport_meta_cv:
self._managed_rdt_metadata[obj_id] = self._managed_rdt_metadata[
obj_id
]._replace(tensor_transport_meta=tensor_transport_meta)
dst_actors = self._queued_transfers.pop(obj_id, None)
free_object = obj_id in self._queued_frees
if free_object:
self._queued_frees.remove(obj_id)
# There shouldn't be any transfers queued if the free was queued,
# since we clear the queued transfers when queueing the free.
assert dst_actors is None
self._tensor_transport_meta_cv.notify_all()
if free_object:
self.free_object_primary_copy(obj_id)
if dst_actors:
for dst_actor in dst_actors:
# Trigger the transfer now that the metadata is available.
self.trigger_out_of_band_tensor_transfer(dst_actor, obj_id)
def set_target_buffers_for_ref(self, ref: ObjectRef, target_buffers: List[Any]):
with self._lock:
if ref.hex() not in self._managed_rdt_metadata:
raise ValueError(f"Ref {ref} is not an RDT object.")
self._managed_rdt_metadata[ref.hex()] = self._managed_rdt_metadata[
ref.hex()
]._replace(
target_buffers=[
weakref.ref(target_buffer) for target_buffer in target_buffers
]
)
def _trigger_fetch(
self,
obj_id: str,
use_object_store: bool,
) -> FetchRequest:
"""
Start fetching an RDT object.
If the specified transport supports async fetches, this will trigger the
fetch without blocking. Note that this always triggers a fetch, even if
the object is already in the store.
Args:
obj_id: The object ID of the RDT object.
use_object_store: Whether to fetch through the object store or through
the designated one-sided tensor transport.
Returns:
A FetchRequest. Wait on the FetchRequest to get the tensors.
"""
from ray.experimental.rdt.rdt_store import (
__ray_fetch_rdt_object__,
)
from ray.experimental.rdt.util import (
get_tensor_transport_manager,
is_one_sided_transport,
)
rdt_meta = self.get_rdt_metadata(obj_id)
assert rdt_meta is not None
if use_object_store:
if rdt_meta.target_buffers:
logger.warning(
"Target buffers are not supported for use_object_store=True. Ignoring the target buffers."
)
src_actor = rdt_meta.src_actor
object_ref = src_actor.__ray_call__.options(
concurrency_group="_ray_system"
).remote(__ray_fetch_rdt_object__, obj_id)
return ObjectStoreFetchRequest(
obj_id=obj_id, object_ref=object_ref, tensors=[]
)
else:
tensor_transport = rdt_meta.tensor_transport_backend
if not is_one_sided_transport(tensor_transport):
raise ValueError(
f"ray.get is not allowed on RDT objects using the two-sided transport {tensor_transport}. "
"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."
)
tensor_transport_manager = get_tensor_transport_manager(tensor_transport)
communicator_meta = tensor_transport_manager.get_communicator_metadata(
None, None, tensor_transport
)
tensor_transport_meta = rdt_meta.tensor_transport_meta
if tensor_transport_meta is None:
# We can't fetch the object until we know the creator has actually created the object.
timeout = ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS
tensor_transport_meta = self.wait_for_tensor_transport_metadata(
obj_id, timeout
)
if tensor_transport_meta is None:
raise TimeoutError(
f"Timed out after {timeout}s waiting for object {obj_id} to be created while trying to get the object. "
"You can increase the timeout by setting RAY_rdt_fetch_fail_timeout_milliseconds."
)
target_buffers = None
if rdt_meta.target_buffers:
# Try to get the target buffers from the weak references. If any of the
# target buffers are not alive, we just won't use the target buffers.
target_buffers = []
for target_buffer in rdt_meta.target_buffers:
buffer = target_buffer()
if buffer is None:
target_buffers = None
break
else:
target_buffers.append(buffer)
if target_buffers is not None:
from ray.experimental.rdt.rdt_store import validate_tensor_buffers
device = tensor_transport_meta.tensor_device
tensor_meta = tensor_transport_meta.tensor_meta
validate_tensor_buffers(target_buffers, tensor_meta, device)
return tensor_transport_manager.fetch_multiple_tensors(
obj_id,
tensor_transport_meta,
communicator_meta,
target_buffers,
)
def _wait_fetch(
self, obj_id: str, fetch_request: FetchRequest, timeout: float = -1
) -> List[Any]:
"""
Waits for a previously triggered fetch to complete and returns the tensors.
Args:
obj_id: The object ID of the RDT object.
fetch_request: An ObjectStoreFetchRequest representing an object
transferred via Ray's object store or a FetchRequest
representing an object transferred via a tensor transport.
timeout: Maximum time in seconds to wait. -1 means wait indefinitely.
0 means return immediately if not ready.
Returns:
The list of tensors fetched.
"""
if isinstance(fetch_request, ObjectStoreFetchRequest):
return ray.get(fetch_request.object_ref, timeout=timeout)
else:
from ray.experimental.rdt.util import get_tensor_transport_manager
rdt_meta = self.get_rdt_metadata(obj_id)
tensor_transport_manager = get_tensor_transport_manager(
rdt_meta.tensor_transport_backend
)
return tensor_transport_manager.wait_fetch_complete(
fetch_request, timeout=timeout
)
def queue_or_trigger_out_of_band_tensor_transfer(
self, dst_actor: "ray.actor.ActorHandle", task_args: Tuple[Any, ...]
):
"""
Triggers the transfer if the tensor metadata is available for the object. If it's
not available, the transfer is queued up until the metadata is available.
"""
rdt_object_ids: Set[str] = set()
for arg in task_args:
# If an ObjectRef is managed, it means the actual value is a list of tensors stored
# on a remote actor. Therefore, this function will trigger a tensor communication
# operation between the sender and receiver actors.
if not isinstance(arg, ObjectRef):
continue
obj_id = arg.hex()
if self.is_managed_object(obj_id):
rdt_object_ids.add(obj_id)
if rdt_object_ids:
self.wait_until_custom_transports_registered(dst_actor)
for obj_id in rdt_object_ids:
# Atomically gets the tensor transport metadata for an object and queues up a transfer
# if the tensor transport metadata is not available.
with self._lock:
tensor_transport_meta = self._managed_rdt_metadata[
obj_id
].tensor_transport_meta
if tensor_transport_meta is None:
self._queued_transfers[obj_id].append(dst_actor)
if tensor_transport_meta is not None:
self.trigger_out_of_band_tensor_transfer(dst_actor, obj_id)
def trigger_out_of_band_tensor_transfer(
self, dst_actor: "ray.actor.ActorHandle", obj_id: str
):
"""
Triggers tensor communication operations between actors. When a managed ObjectRef is passed
to another actor task, CPU data will still be passed through the object store, but the in-actor
tensors will be passed out-of-band.
This function triggers the out-of-band tensor transfer by submitting Ray actor
tasks `__ray_send__` to the sender actor and `__ray_recv__` to the receiver actor to initiate
tensor communication using protocols like NCCL or GLOO.
Before the receiver actor executes the actor task, the deserializer combines the
CPU data with the tensors from the sender actor to reconstruct the original task output
generated by the sender actor.
Args:
dst_actor: The target actor to receive tensors
obj_id: ID of the object to send to the dst_actor.
Returns:
None
"""
from ray.experimental.rdt.rdt_store import (
__ray_recv__,
__ray_send__,
)
from ray.experimental.rdt.util import (
get_tensor_transport_manager,
)
with self._lock:
# Since sent_dest_actors is mutable, this whole block needs to be protected.
rdt_meta = self._managed_rdt_metadata[obj_id]
src_actor = rdt_meta.src_actor
tensor_transport_meta = rdt_meta.tensor_transport_meta
# Update the set of destination actors for this object
# The set inside NamedTuple is mutable, so we can modify it directly
rdt_meta.sent_dest_actors.add(dst_actor._actor_id)
# Check if a warning should be triggered for this object:
# 1. object has not triggered a warning yet.
# 2. object is sent back to its source actor.
# 3. object is also sent to at least one other actor
if (
not rdt_meta.sent_to_src_actor_and_others_warned
and src_actor._actor_id in rdt_meta.sent_dest_actors
and len(rdt_meta.sent_dest_actors) > 1
):
warnings.warn(
f"RDT ObjectRef({obj_id}) is being passed back to the actor that created it {src_actor}. "
"Note that RDT objects are mutable. If the tensor is modified, Ray's internal copy will "
"also be updated, and subsequent passes to other actors will receive the updated version "
"instead of the original.",
UserWarning,
)
# Mark the object as warned so that we don't warn again for this object.
self._managed_rdt_metadata[obj_id] = rdt_meta._replace(
sent_to_src_actor_and_others_warned=True
)
if src_actor._actor_id == dst_actor._actor_id:
# If the source and destination actors are the same, the tensors can
# be transferred intra-process, so we skip the out-of-band tensor
# transfer.
return
tensor_transport_manager = get_tensor_transport_manager(
rdt_meta.tensor_transport_backend
)
communicator_meta = tensor_transport_manager.get_communicator_metadata(
src_actor,
dst_actor,
rdt_meta.tensor_transport_backend,
)
send_ref = None
if not tensor_transport_manager.__class__.is_one_sided():
# Send tensors stored in the `src_actor`'s GPU object store to the
# destination rank `dst_rank`.
# NOTE: We put this task on the background thread to avoid tasks
# executing on the main thread blocking the data transfer.
send_ref = src_actor.__ray_call__.options(
concurrency_group="_ray_system"
).remote(
__ray_send__,
obj_id,
tensor_transport_meta,
communicator_meta,
rdt_meta.tensor_transport_backend,
)
# Receive tensors from the source rank and store them in the
# `dst_actor`'s GPU object store.
# NOTE: Putting this task on the background thread is technically only
# needed for the sender task, but we put the receiver task on the same
# background thread to ensure that all communication operations are
# executed in a global order.
recv_ref = dst_actor.__ray_call__.options(
concurrency_group="_ray_system"
).remote(
__ray_recv__,
obj_id,
tensor_transport_meta,
communicator_meta,
rdt_meta.tensor_transport_backend,
)
self._unmonitored_transfers.put(
TransferMetadata(
src_actor=src_actor,
dst_actor=dst_actor,
send_ref=send_ref,
recv_ref=recv_ref,
communicator_meta=communicator_meta,
backend=rdt_meta.tensor_transport_backend,
obj_id=obj_id,
timeout=time.time() + ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS,
)
)
self.start_monitor_thread_if_needed()
def get_rdt_objects(
self,
object_ids: List[str],
) -> Dict[str, List[Any]]:
"""
Get RDT objects that have already been transferred (e.g. via __ray_recv__).
This is used in the task argument deserialization path where the
out-of-band tensor transfer has already been triggered by the caller.
It only waits on the local RDT store for the tensors to arrive.
Args:
object_ids: The object IDs of the RDT objects.
Returns:
A dict mapping object ID to the RDT object (list of tensors).
"""
rdt_store = self.rdt_store
result: Dict[str, List[Any]] = {}
for object_id in object_ids:
pop_object = not rdt_store.is_primary_copy(object_id)
if pop_object:
result[object_id] = rdt_store.wait_and_pop_object(
object_id, timeout=ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS
)
else:
result[object_id] = rdt_store.wait_and_get_object(
object_id, timeout=ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS
)
return result
def fetch_and_get_rdt_objects(
self,
object_ids: List[str],
timeout: Optional[float] = None,
use_object_store: bool = False,
) -> Dict[str, List[Any]]:
"""
Fetch and get RDT objects for a list of object IDs, pipelining async fetches.
This is used in the ray.get codepath where the caller initiates the
tensor fetch. For one-sided transports (e.g. NIXL), all transfers are
triggered first before waiting, eliminating serial transfer latency.
Args:
object_ids: The object IDs of the RDT objects.
timeout: The user-specified timeout from ray.get, or None for no
user timeout. The actual deadline is the minimum of this and
RDT_FETCH_FAIL_TIMEOUT_SECONDS.
use_object_store: Whether to fetch through the object store or through
the designated tensor transport.
Returns:
A dict mapping object ID to the RDT object (list of tensors).
Raises:
GetTimeoutError: If the user-specified timeout is exceeded.
ObjectFetchTimedOutError: If RDT_FETCH_FAIL_TIMEOUT_SECONDS is exceeded.
"""
from ray.exceptions import GetTimeoutError, ObjectFetchTimedOutError
rdt_timeout = ray_constants.RDT_FETCH_FAIL_TIMEOUT_SECONDS
now = time.time()
if timeout is not None and timeout >= 0:
rdt_deadline = now + rdt_timeout
user_deadline = now + timeout
if user_deadline < rdt_deadline:
deadline = user_deadline
user_timeout_is_smaller = True
else:
deadline = rdt_deadline
user_timeout_is_smaller = False
else:
deadline = now + rdt_timeout
user_timeout_is_smaller = False
rdt_store = self.rdt_store
result: Dict[str, List[Any]] = {}
# First, try to get objects that are already available in the store
# These are primary copies, or secondary copies created via
# __ray_recv__ that haven't been consumed yet.
if not use_object_store:
for object_id in object_ids:
try:
result[object_id] = rdt_store.wait_and_get_object(
object_id, timeout=0
)
except TimeoutError:
pass
# For remaining objects, trigger fetches.
fetch_requests: Dict[str, "FetchRequest"] = {}
for object_id in object_ids:
if object_id in result:
continue
assert self.is_managed_object(
object_id
), f"No metadata found for {object_id}"
fetch_requests[object_id] = self._trigger_fetch(object_id, use_object_store)
# Wait for all in-flight fetches to complete.
while fetch_requests:
object_id, fetch_request = fetch_requests.popitem()
remaining = deadline - time.time()
if remaining < 0:
if user_timeout_is_smaller:
# User passed a timeout to ray.get that expired.
raise GetTimeoutError(f"ray.get timed out after {timeout}s.")
else:
# Object fetch timeout expired. Throw an error in case we
# hung.
raise ObjectFetchTimedOutError(
object_ref_hex=object_id,
owner_address="",
call_site="",
)
try:
result[object_id] = self._wait_fetch(
object_id, fetch_request, timeout=remaining
)
except (TimeoutError, GetTimeoutError):
if user_timeout_is_smaller:
raise GetTimeoutError(f"ray.get timed out after {timeout}s.")
else:
raise ObjectFetchTimedOutError(
object_ref_hex=object_id,
owner_address="",
call_site="",
)
return result
def queue_or_free_object_primary_copy(self, object_id: str):
"""
Free the RDT object on the primary copy holder and free metadata
if the tensor metadata is available (the object has been created).
Otherwise, queue up the free operation until the tensor metadata is available.
"""
# NOTE: This may have to change if we support lineage reconstruction for RDT
# TODO(#57962): Metadata is currently not removed on borrowers that borrow through
# the NIXL ray.put / ray.get
with self._lock:
self._queued_transfers.pop(object_id, None)
rdt_meta = self._managed_rdt_metadata[object_id]
tensor_transport_meta = rdt_meta.tensor_transport_meta
if tensor_transport_meta is None:
# The object hasn't been created at the time of the free.
self._queued_frees.add(object_id)
if tensor_transport_meta is not None:
self.free_object_primary_copy(object_id)
def free_object_primary_copy(self, object_id: str):
from ray.experimental.rdt.rdt_store import (
__ray_free__,
)
with self._lock:
rdt_meta = self._managed_rdt_metadata.pop(object_id)
# TODO(#60434): Once the driver has some notion about where rdt args are stored, we can
# integrate __ray_free__ with the current free objects RPC and avoid having to call
# an actor task here.
if not ray.is_initialized():
return
src_actor = rdt_meta.src_actor
tensor_transport_backend = rdt_meta.tensor_transport_backend
tensor_transport_meta = rdt_meta.tensor_transport_meta
src_actor.__ray_call__.options(concurrency_group="_ray_system").remote(
__ray_free__,
object_id,
tensor_transport_backend,
tensor_transport_meta,
)
@staticmethod
def actor_has_tensor_transport(
actor: "ray.actor.ActorHandle", tensor_transport: str
):
"""
Check if the actor has a communicator for the given tensor transport backend.
Args:
actor: The actor to check.
tensor_transport: The tensor transport backend to check.
Returns:
True if the actor has a communicator for the given tensor transport backend, False otherwise.
"""
from ray.experimental.rdt.util import (
get_tensor_transport_manager,
)
tensor_transport_manager = get_tensor_transport_manager(tensor_transport)
return tensor_transport_manager.actor_has_tensor_transport(actor)
def put_object(
self,
obj_ref: ObjectRef,
tensor_transport: str,
tensors: List[Any],
):
"""
Put the RDT object into the RDT manager.
Args:
obj_ref: The object ref of the RDT object.
tensor_transport: The tensor transport backend to use.
tensors: The tensors to put into the RDT manager.
"""
src_actor = ray.get_runtime_context().current_actor
tensor_transport_meta = self.rdt_store.add_object_primary(
obj_ref.hex(), tensors, tensor_transport
)
self.add_rdt_ref(
obj_ref,
src_actor,
tensor_transport,
tensor_transport_meta=tensor_transport_meta,
)
+370
View File
@@ -0,0 +1,370 @@
import threading
from collections import defaultdict, deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
from ray.experimental.rdt.tensor_transport_manager import (
CommunicatorMetadata,
TensorTransportMetadata,
)
from ray.experimental.rdt.util import (
device_match_transport,
get_tensor_transport_manager,
)
if TYPE_CHECKING:
import torch
def __ray_send__(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
communicator_meta: CommunicatorMetadata,
backend: str,
):
"""Helper function that runs on the src actor to send tensors to the dst actor."""
from ray._private.worker import global_worker
rdt_store = global_worker.rdt_manager._rdt_store
assert rdt_store.has_object(obj_id), f"obj_id={obj_id} not found in RDT store"
tensors = rdt_store.get_object(obj_id)
tensor_transport_manager = get_tensor_transport_manager(backend)
tensor_transport_manager.send_multiple_tensors(
tensors,
tensor_transport_meta,
communicator_meta,
)
def validate_tensor_buffers(
tensor_buffers: List["torch.Tensor"],
tensor_meta: List[Tuple["torch.Size", "torch.dtype"]],
device: str,
):
if len(tensor_buffers) != len(tensor_meta):
raise ValueError(
f"Length of tensor_buffers ({len(tensor_buffers)}) does not match length from object metadata ({len(tensor_meta)})."
)
def tensor_buffer_mismatch_msg(prop, idx, actual, expected):
return f"{prop} of tensor_buffer at index {idx} ({actual}) does not match {prop.lower()} from object metadata ({expected})."
for idx, single_buffer in enumerate(tensor_buffers):
shape, dtype = tensor_meta[idx]
if single_buffer.shape != shape:
raise ValueError(
tensor_buffer_mismatch_msg("Shape", idx, single_buffer.shape, shape)
)
if single_buffer.dtype != dtype:
raise ValueError(
tensor_buffer_mismatch_msg("Dtype", idx, single_buffer.dtype, dtype)
)
if single_buffer.device.type != device:
raise ValueError(
tensor_buffer_mismatch_msg(
"Device", idx, single_buffer.device.type, device
)
)
if not single_buffer.is_contiguous():
raise ValueError(f"Tensor buffer at index {idx} is not contiguous.")
def __ray_recv__(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
communicator_meta: CommunicatorMetadata,
backend: str,
target_buffers: Optional[List[Any]] = None,
):
"""Helper function that runs on the dst actor to receive tensors from the src actor."""
from ray._private.worker import global_worker
rdt_store = global_worker.rdt_manager.rdt_store
try:
tensor_transport_manager = get_tensor_transport_manager(backend)
if target_buffers:
# Currently only torch tensors are supported as target buffers. We could make this
# more generic in the future by adding a pluggable buffer validation function.
validate_tensor_buffers(
target_buffers,
tensor_transport_meta.tensor_meta,
tensor_transport_meta.tensor_device,
)
tensors = tensor_transport_manager.recv_multiple_tensors(
obj_id,
tensor_transport_meta,
communicator_meta,
target_buffers,
)
assert len(tensors) == len(tensor_transport_meta.tensor_meta)
rdt_store.add_object(obj_id, tensors)
except Exception as e:
# Store the error as an RDT object if the recv fails, so waiters will raise the error.
rdt_store.add_object(obj_id, e)
def __ray_abort_transport__(
self, obj_id: str, communicator_meta: CommunicatorMetadata, backend: str
):
"""Helper function that can run on an actor doing a send or recv to abort the transport."""
tensor_transport_manager = get_tensor_transport_manager(backend)
tensor_transport_manager.abort_transport(obj_id, communicator_meta)
def __ray_free__(
self,
obj_id: str,
tensor_transport_backend: str,
tensor_transport_meta: TensorTransportMetadata,
):
try:
from ray._private.worker import global_worker
tensor_transport_manager = get_tensor_transport_manager(
tensor_transport_backend
)
rdt_manager = global_worker.rdt_manager
rdt_store = rdt_manager.rdt_store
if not rdt_store.has_object(obj_id):
return
tensors = rdt_store.get_object(obj_id)
tensor_transport_manager.garbage_collect(obj_id, tensor_transport_meta, tensors)
rdt_store.pop_object(obj_id)
except AssertionError:
# This could fail if this is a retry and it's already been freed.
pass
def __ray_fetch_rdt_object__(self, obj_id: str):
"""Helper function that runs on the src actor to fetch tensors from the RDT store via the object store."""
from ray._private.worker import global_worker
rdt_store = global_worker.rdt_manager.rdt_store
rdt_object = rdt_store.wait_and_get_object(obj_id)
return rdt_object
@dataclass
class _RDTObject:
# A list of tensors representing the RDT object.
data: List[Any]
# Whether the RDT object is the primary copy.
is_primary: bool
# If a recv failed, we store the error here.
error: Optional[Exception] = None
class RDTStore:
"""
This class is thread-safe. The GPU object store is meant to be read and
written by the following threads:
1. The main thread, which is executing user code. This thread may get, put,
and pop objects.
2. The background _ray_system thread, which executes data transfers. This
thread may get and put objects.
3. The background CoreWorker server thread, which executes garbage
collection callbacks that pop objects that are no longer in use.
"""
def __init__(self):
# A dictionary that maps from an object ID to a queue of tensor lists.
#
# Note: Currently, `_rdt_store` is only supported for Ray Actors.
self._rdt_store: Dict[str, deque[_RDTObject]] = defaultdict(deque)
# Mapping from tensor data pointer to the IDs of objects that contain it.
self._tensor_to_object_ids: Dict[int, Set[str]] = defaultdict[int, Set[str]](
set
)
# Synchronization for the RDT store.
self._lock = threading.RLock()
# Signal when an object becomes present in the object store.
self._object_present_cv = threading.Condition(self._lock)
# Signal when an object is freed from the object store.
self._object_freed_cv = threading.Condition(self._lock)
def has_object(self, obj_id: str) -> bool:
with self._lock:
existed = obj_id in self._rdt_store
if existed:
return len(self._rdt_store[obj_id]) > 0
return existed
def has_tensor(self, tensor: Any) -> bool:
# Method only used for testing.
with self._lock:
return id(tensor) in self._tensor_to_object_ids
def get_object(self, obj_id: str) -> Optional[List[Any]]:
with self._lock:
if self._rdt_store[obj_id][0].error:
raise self._rdt_store[obj_id][0].error
return self._rdt_store[obj_id][0].data
def add_object(
self,
obj_id: str,
rdt_object: Union[List[Any], Exception],
is_primary: bool = False,
):
"""
Add an RDT object to the RDT store.
Args:
obj_id: The object ID of the RDT object.
rdt_object: A list of tensors representing the RDT object.
is_primary: Whether the RDT object is the primary copy.
"""
with self._object_present_cv:
if isinstance(rdt_object, Exception):
self._rdt_store[obj_id].append(
_RDTObject([], is_primary, error=rdt_object)
)
else:
for tensor in rdt_object:
self._tensor_to_object_ids[id(tensor)].add(obj_id)
# Append to the queue instead of overwriting
self._rdt_store[obj_id].append(
_RDTObject(
rdt_object,
is_primary,
)
)
self._object_present_cv.notify_all()
def add_object_primary(
self, obj_id: str, tensors: List[Any], tensor_transport: str
) -> TensorTransportMetadata:
with self._object_present_cv:
# A primary entry may already exist from a prior attempt of the
# same task (e.g., a task that succeeded and populated the RDT
# store but whose reply was lost, then got retried). Keep the
# existing primary — do not re-store — and return metadata
# derived from it so the metadata matches what `__ray_send__`
# will actually transmit.
queue = self._rdt_store.get(obj_id)
if queue:
tensors_to_describe = queue[0].data
else:
self.add_object(obj_id, tensors, is_primary=True)
tensors_to_describe = tensors
tensor_transport_manager = get_tensor_transport_manager(tensor_transport)
tensor_transport_meta = (
tensor_transport_manager.extract_tensor_transport_metadata(
obj_id, tensors_to_describe
)
)
if tensor_transport_meta.tensor_meta and not device_match_transport(
tensor_transport_meta.tensor_device, tensor_transport
):
raise ValueError(
f"Tensor transport backend {tensor_transport} does not support "
f"tensor transfer on device {tensor_transport_meta.tensor_device}."
)
return tensor_transport_meta
def is_primary_copy(self, obj_id: str) -> bool:
with self._lock:
return self.has_object(obj_id) and self._rdt_store[obj_id][0].is_primary
def wait_and_get_object(
self, obj_id: str, timeout: Optional[float] = None
) -> List[Any]:
"""Atomically waits for the RDT object to be present in the RDT
store, then gets it. If the object is not present after the optional
timeout, raise a TimeoutError.
Args:
obj_id: The object ID to wait for.
timeout: The maximum time in seconds to wait for the object to be
present in the RDT store. If not specified, wait indefinitely.
Returns:
The tensors in the RDT object.
"""
with self._lock:
self._wait_object(obj_id, timeout)
return self.get_object(obj_id)
def wait_and_pop_object(
self, obj_id: str, timeout: Optional[float] = None
) -> List[Any]:
"""Atomically waits for the RDT object to be present in the RDT
store, then pops it. If the object is not present after the optional
timeout, raise a TimeoutError.
Args:
obj_id: The object ID to wait for.
timeout: The maximum time in seconds to wait for the object to be
present in the RDT store. If not specified, wait indefinitely.
Returns:
The RDT object.
"""
with self._lock:
self._wait_object(obj_id, timeout)
return self.pop_object(obj_id)
def _wait_object(self, obj_id: str, timeout: Optional[float] = None) -> None:
"""Helper method to wait for the RDT object to be present in the RDT store.
If the object is not present after the optional timeout, raise a
TimeoutError.
Args:
obj_id: The object ID to wait for.
timeout: The maximum time in seconds to wait for the object to be
present in the RDT store. If not specified, wait indefinitely.
"""
with self._object_present_cv:
if not self._object_present_cv.wait_for(
lambda: self.has_object(obj_id),
timeout=timeout,
):
raise TimeoutError(
f"ObjectRef({obj_id}) not found in RDT object store after {timeout}s, transfer may have failed. Please report this issue on GitHub: https://github.com/ray-project/ray/issues/new/choose"
)
def pop_object(self, obj_id: str) -> List[Any]:
with self._lock:
queue = self._rdt_store.get(obj_id)
assert queue is not None, f"obj_id={obj_id} not found in RDT store"
rdt_object = queue.popleft()
if len(queue) == 0:
del self._rdt_store[obj_id]
if rdt_object.error:
raise rdt_object.error
for tensor in rdt_object.data:
self._tensor_to_object_ids[id(tensor)].remove(obj_id)
if len(self._tensor_to_object_ids[id(tensor)]) == 0:
self._tensor_to_object_ids.pop(id(tensor))
self._object_freed_cv.notify_all()
return rdt_object.data
def wait_tensor_freed(self, tensor: Any, timeout: Optional[float] = None) -> None:
"""
Wait for the object to be freed from the RDT store.
"""
with self._object_freed_cv:
if not self._object_freed_cv.wait_for(
lambda: id(tensor) not in self._tensor_to_object_ids,
timeout=timeout,
):
raise TimeoutError(
f"Tensor {tensor} not freed from RDT object store after {timeout}s. The tensor will not be freed until all ObjectRefs containing the tensor have gone out of scope."
)
def get_num_objects(self) -> int:
"""
Return the number of objects in the RDT store.
"""
with self._lock:
# Count total objects across all queues
return sum(len(queue) for queue in self._rdt_store.values())
@@ -0,0 +1,295 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Union
if TYPE_CHECKING:
import numpy as np
import torch
import ray
# NOTE: This is a public facing abstract interface for custom tensor transports.
# Be sure to update the direct-transport docs when making changes to this interface, especially if changing the path to the file.
@dataclass
class CommunicatorMetadata:
"""Metadata for the communicator."""
@dataclass
class TensorTransportMetadata:
"""Metadata for tensors stored in the GPU object store.
Args:
tensor_meta: A list of tuples, each containing the shape and dtype of a tensor.
tensor_device: The device of the tensor. Currently, we require all tensors in the
list have the same device type.
"""
tensor_meta: List[
Union[Tuple["torch.Size", "torch.dtype"], Tuple[Tuple[int, ...], "np.dtype"]]
]
tensor_device: Optional[str] = None
@dataclass
class FetchRequest:
"""Represents a pending or completed tensor fetch operation.
The default fetch/wait implementation stores the tensors here directly
after a synchronous recv. Transports with true async capability may
subclass this to carry additional state needed by wait_fetch_complete.
Subclasses should handle all resource cleanup in __del__ rather than
in wait_fetch_complete, so that resources are released even if the
caller never waits on the request.
Args:
obj_id: The object ID for the fetch operation.
tensors: The fetched tensors.
"""
obj_id: str
tensors: List[Any]
class TensorTransportManager(ABC):
"""
Interface with which to implement custom tensor transports.
"""
@abstractmethod
def tensor_transport_backend(self) -> str:
"""
Returns the name of your tensor transport backend.
Ray uses this name to match your transport with the ``tensor_transport`` argument
on the method.
Returns:
str: The backend of the tensor transport.
"""
@staticmethod
@abstractmethod
def is_one_sided() -> bool:
"""
Indicates whether your transport uses one-sided communication where only the receiver
initiates the transfer.
One-sided transports: The receiver can directly read the sender's memory without the sender
actively participating. NIXL and CUDA-IPC are examples.
Two-sided transports: Both sender and receiver must actively participate in the transfer.
Collective communication libraries like NCCL and GLOO are examples.
This affects how Ray orchestrates the transfer and handles failures. Two-sided transports
have extra limitations described in :ref:`limitations <limitations>`. Ray will not call
`send_multiple_tensors` for one-sided transports; the transfer is expected to happen through
just `recv_multiple_tensors`.
Returns:
bool: True if the backend is one-sided, False otherwise.
"""
@staticmethod
@abstractmethod
def can_abort_transport() -> bool:
"""
Indicates whether your transport can safely abort an in-progress transfer.
If ``True``, Ray calls `abort_transport` on both the source and destination actors when a
send / recv error, allowing your transport to clean up gracefully.
If ``False``, Ray kills the involved actors to prevent deadlocks when errors occur during
transfer.
Return ``True`` only if your transport can reliably interrupt an in-progress send or receive
operation without leaving either party in a blocked state.
Returns:
bool: True if the backend can abort the transport.
"""
@abstractmethod
def actor_has_tensor_transport(self, actor: "ray.actor.ActorHandle") -> bool:
"""Whether the actor has the tensor transport available.
Args:
actor: The actor to check.
Returns:
bool: True if the actor has the tensor transport available, False otherwise.
"""
@abstractmethod
def extract_tensor_transport_metadata(
self,
obj_id: str,
rdt_object: List[Any],
) -> TensorTransportMetadata:
"""
Implement this method to create the TensorTransportMetadata you defined previously.
Ray calls this on the source actor immediately after the actor task creates the result tensors.
Implement this to:
1. Record tensor shapes, dtypes, and devices.
2. Perform any transport-specific tensor registration such as registering memory for RDMA.
3. Store any handles or identifiers needed for the transfer.
Args:
obj_id: The ID of the RDT object to extract the tensor transport metadata from.
rdt_object: The RDT object to extract the tensor transport metadata from.
Returns:
TensorTransportMetadata: The tensor transport metadata.
"""
@abstractmethod
def get_communicator_metadata(
self,
src_actor: "ray.actor.ActorHandle",
dst_actor: "ray.actor.ActorHandle",
backend: Optional[str] = None,
) -> CommunicatorMetadata:
"""
Gets the CommunicatorMetadata for a send/recv. Ray calls this on the owner/driver process before
orchestrating the transfer. You can typically implement this to return information both actors
need to identify each other such as ranks in a collective group. Many forms of transports such
as one-sided RDMA reads may be ok just returning empty CommunicatorMetadata here.
Args:
src_actor: The actor that runs this function.
dst_actor: The actor that runs this function.
backend: The backend to use for the collective operation.
Returns:
CommunicatorMetadata: The communicator metadata.
"""
@abstractmethod
def recv_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List[Any]] = None,
) -> List[Any]:
"""
Receives tensors on the destination actor. Ray calls this on the destination
actor during the transfer.
Args:
obj_id: The object ID for related GPU object.
tensor_transport_metadata: The tensor transport metadata for the GPU object.
communicator_metadata: The communicator metadata for the send/recv operation.
target_buffers: Pre-allocated buffers to receive the tensors into if possible.
Returns:
List[Any]: The received tensors.
"""
def fetch_multiple_tensors(
self,
obj_id: str,
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
target_buffers: Optional[List[Any]] = None,
) -> FetchRequest:
"""Initiate a fetch for multiple tensors without waiting for completion.
The default implementation calls recv_multiple_tensors synchronously and
stores the result in a FetchRequest. Transports with true async capability
should override both this method and wait_fetch_complete.
Call wait_fetch_complete(fetch_request) afterward to retrieve the tensors.
Args:
obj_id: The object ID for the related GPU object.
tensor_transport_metadata: The tensor transport metadata for the GPU object.
communicator_metadata: The communicator metadata for the send/recv operation.
target_buffers: Pre-allocated buffers to receive the tensors into if possible.
Returns:
A FetchRequest whose tensors field is already populated.
"""
tensors = self.recv_multiple_tensors(
obj_id, tensor_transport_metadata, communicator_metadata, target_buffers
)
return FetchRequest(obj_id=obj_id, tensors=tensors)
def wait_fetch_complete(
self, fetch_request: FetchRequest, timeout: float = -1
) -> List[Any]:
"""Wait for a previously initiated fetch to complete and return the tensors.
The default implementation returns the tensors stored in the FetchRequest
directly, since the default fetch_multiple_tensors is synchronous.
Args:
fetch_request: The FetchRequest returned by fetch_multiple_tensors.
timeout: Maximum time in seconds to wait. -1 means wait indefinitely.
0 means return immediately if not ready.
Returns:
The received tensors.
Raises:
TimeoutError: If timeout is exceeded.
"""
return fetch_request.tensors
@abstractmethod
def send_multiple_tensors(
self,
tensors: List[Any],
tensor_transport_metadata: TensorTransportMetadata,
communicator_metadata: CommunicatorMetadata,
):
"""
Sends tensors from the source actor to the destination actor. Ray calls this on the source actor
during the transfer. Implement this to perform the actual data transfer using your transport's
send mechanism. For one-sided transports, you can simply avoid implementing this method or even
raise a NotImplementedError to ensure it's not being called.
Args:
tensors: The tensors or jax arrays to send.
tensor_transport_metadata: The tensor transport metadata for the RDT object.
communicator_metadata: The communicator metadata for the send/recv operation.
"""
@abstractmethod
def garbage_collect(
self,
obj_id: str,
tensor_transport_meta: TensorTransportMetadata,
tensors: List[Any],
):
"""
Clean up resources for an RDT object. Ray calls this on the source actor
after Ray's distributed reference counting protocol determines the object is out of scope.
Use this to release any resources your transport allocated, such as deregistering memory buffers.
On the receiver side, no cleanup is needed — Ray does not hold onto the tensor after
returning it to the user, so it is garbage collected normally when the user releases it.
Args:
obj_id: The ID of the GPU object to garbage collect.
tensor_transport_meta: The tensor transport metadata.
tensors: The tensors that are contained in the ObjectRef that is being freed.
"""
@abstractmethod
def abort_transport(
self,
obj_id: str,
communicator_metadata: CommunicatorMetadata,
):
"""
Aborts an in-progress transfer. Ray calls this on both the source and destination actors
when a system error occurs if `can_abort_transport` returns ``True``.
Args:
obj_id: The object ID for related GPU object.
communicator_metadata: The communicator metadata for the send/recv operation.
"""
+350
View File
@@ -0,0 +1,350 @@
import threading
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
import ray
from ray._raylet import ObjectRef
from ray.experimental.rdt.collective_tensor_transport import (
GLOOTensorTransport,
NCCLTensorTransport,
)
from ray.experimental.rdt.cuda_ipc_transport import CudaIpcTransport
from ray.experimental.rdt.nixl_tensor_transport import (
NixlTensorTransport,
)
from ray.experimental.rdt.tensor_transport_manager import (
TensorTransportManager,
TensorTransportMetadata,
)
from ray.util.annotations import PublicAPI
if TYPE_CHECKING:
import torch
class TransportManagerInfo(NamedTuple):
# Class that implements TensorTransportManager
transport_manager_class: type[TensorTransportManager]
# List of supported device types for the transport
devices: List[str]
# Data type for this transport (e.g. torch.Tensor or jax.Array)
# If not provided, defaults to torch.Tensor
data_type: type
transport_manager_info: Dict[str, TransportManagerInfo] = {}
# Singleton instances of transport managers
transport_managers: Dict[str, TensorTransportManager] = {}
# To protect the singleton instances of transport managers
transport_managers_lock = threading.Lock()
# Flipped to True when the first custom transport is registered.
has_custom_transports = False
@PublicAPI(stability="alpha")
def register_tensor_transport(
transport_name: str,
devices: List[str],
transport_manager_class: type[TensorTransportManager],
data_type: type,
):
"""
Register a new tensor transport for use in Ray. Note that this needs to be called
before you create the actors that will use the transport. The actors also
need to be created in the same process from which you call this function.
Args:
transport_name: The name of the transport protocol.
devices: List of PyTorch device types supported by this transport (e.g., ["cuda", "cpu"]).
transport_manager_class: A class that implements TensorTransportManager.
data_type: The data type for this transport (e.g. torch.Tensor or jax.Array).
Raises:
ValueError: If transport_manager_class is not a subclass of TensorTransportManager.
"""
global transport_manager_info
global has_custom_transports
transport_name = transport_name.upper()
if transport_name in transport_manager_info:
raise ValueError(f"Transport {transport_name} already registered.")
if not issubclass(transport_manager_class, TensorTransportManager):
raise ValueError(
f"transport_manager_class {transport_manager_class.__name__} must be a subclass of TensorTransportManager."
)
transport_manager_info[transport_name] = TransportManagerInfo(
transport_manager_class, devices, data_type
)
if transport_name not in DEFAULT_TRANSPORTS:
has_custom_transports = True
DEFAULT_TRANSPORTS = ["NIXL", "GLOO", "NCCL", "CUDA_IPC"]
_default_transports_registered = False
def _ensure_default_transports_registered():
global _default_transports_registered
with transport_managers_lock:
if _default_transports_registered:
return
_default_transports_registered = True
try:
import torch
register_tensor_transport(
"NIXL", ["cuda", "cpu"], NixlTensorTransport, torch.Tensor
)
register_tensor_transport(
"GLOO", ["cpu"], GLOOTensorTransport, torch.Tensor
)
register_tensor_transport(
"NCCL", ["cuda"], NCCLTensorTransport, torch.Tensor
)
register_tensor_transport(
"CUDA_IPC", ["cuda"], CudaIpcTransport, torch.Tensor
)
except ImportError:
pass
def get_transport_data_type(tensor_transport: str) -> type:
_ensure_default_transports_registered()
if tensor_transport not in transport_manager_info:
raise ValueError(f"Unsupported tensor transport protocol: {tensor_transport}")
return transport_manager_info[tensor_transport].data_type
def get_tensor_transport_manager(
transport_name: str,
) -> "TensorTransportManager":
"""Get the tensor transport manager for the given tensor transport protocol.
Args:
transport_name: The tensor transport protocol to use for the GPU object.
Returns:
TensorTransportManager: The tensor transport manager for the given tensor transport protocol.
"""
global transport_manager_info
global transport_managers
global transport_managers_lock
_ensure_default_transports_registered()
with transport_managers_lock:
if transport_name in transport_managers:
return transport_managers[transport_name]
if transport_name not in transport_manager_info:
raise ValueError(f"Unsupported tensor transport protocol: {transport_name}")
transport_managers[transport_name] = transport_manager_info[
transport_name
].transport_manager_class()
return transport_managers[transport_name]
def register_custom_tensor_transports_on_actor(
actor: "ray.actor.ActorHandle",
) -> Optional[ObjectRef]:
"""
If there's no custom transports to register, returns None.
Otherwise returns an ObjectRef for a task on the actor that will register the custom transports.
"""
global transport_manager_info
global has_custom_transports
_ensure_default_transports_registered()
if not has_custom_transports:
return None
def register_transport_on_actor(
self, owner_transport_manager_info: Dict[str, TransportManagerInfo]
):
from ray.experimental.rdt.util import (
_ensure_default_transports_registered,
register_tensor_transport,
transport_manager_info,
)
_ensure_default_transports_registered()
for transport_name, transport_info in owner_transport_manager_info.items():
if transport_name not in transport_manager_info:
register_tensor_transport(
transport_name,
transport_info.devices,
transport_info.transport_manager_class,
transport_info.data_type,
)
return actor.__ray_call__.options(concurrency_group="_ray_system").remote(
register_transport_on_actor, transport_manager_info
)
def device_match_transport(device: str, tensor_transport: str) -> bool:
"""Check if the device matches the transport."""
_ensure_default_transports_registered()
if tensor_transport not in transport_manager_info:
raise ValueError(f"Unsupported tensor transport protocol: {tensor_transport}")
return device in transport_manager_info[tensor_transport].devices
def normalize_and_validate_tensor_transport(tensor_transport: str) -> str:
_ensure_default_transports_registered()
tensor_transport = tensor_transport.upper()
if tensor_transport not in transport_manager_info:
raise ValueError(f"Invalid tensor transport: {tensor_transport}")
return tensor_transport
def is_one_sided_transport(tensor_transport: str) -> bool:
_ensure_default_transports_registered()
return transport_manager_info[
tensor_transport
].transport_manager_class.is_one_sided()
@PublicAPI(stability="alpha")
def register_nixl_memory(tensor: "torch.Tensor") -> None:
"""Registers the tensor's memory with NIXL and bumps the reference count so the memory region is never deregistered.
By default, the lifetime of the NIXL memory registration is tied to the ObjectRef. This means that only when the ObjectRef is created
do we register the memory with NIXL and deregister it when the ObjectRef goes out of scope. However, this function can be used
to pre-register a tensor's memory with NIXL and keep it registered for the lifetime of the process which can improve performance
if the same tensor is re-used in multiple RDT objects.
If called on a tensor that is already registered with NIXL, we still prevent the tensor's memory from being deregistered.
Args:
tensor: A PyTorch tensor whose memory should be registered with NIXL.
Example:
.. code-block:: python
import torch
import ray
from ray.experimental import register_nixl_memory
@ray.remote(num_gpus=1, enable_tensor_transport=True)
class Trainer:
def __init__(self):
self.weight = torch.randn(1000, 1000, device="cuda")
# Pre-register the memory with NIXL for the lifetime of the process
register_nixl_memory(self.weight)
# Both of the below methods will use the cached NIXL memory registration on multiple calls. You can also mix them,
# i.e. call get_weight_ref_by_rows then get_weight_ref and get_weight_ref will not trigger a new NIXL memory registration.
# You can ray.put views to each row of the weight matrix if you want to use them separately in your code
def get_weight_ref_by_rows(self):
views = [self.weight[i] for i in range(1000)]
# Each put call does not trigger a new NIXL memory registration
return ray.put(views, _tensor_transport="nixl")
# You can also ray.put the entire weight matrix at once
def get_weight_ref(self):
return ray.put(self.weight, _tensor_transport="nixl")
"""
nixl_transport = get_tensor_transport_manager("NIXL")
nixl_transport.register_nixl_memory(tensor)
@PublicAPI(stability="alpha")
def deregister_nixl_memory(tensor: "torch.Tensor") -> None:
"""Decrements the reference count for the tensor's NIXL memory registration added by :func:`ray.experimental.register_nixl_memory`.
If the reference count reaches 0, the memory is deregistered from NIXL.
This should only be called after :func:`ray.experimental.register_nixl_memory` has been called for this tensor.
Any existing ``ray.ObjectRef`` instances that reference this tensor's memory will keep the
NIXL memory registration alive independently until they go out of scope.
Args:
tensor: A PyTorch tensor whose NIXL memory registration reference count should be decremented.
Example:
.. code-block:: python
# Extending the example from register_nixl_memory:
@ray.remote(num_gpus=1, enable_tensor_transport=True)
class Trainer:
def deregister_weight(self):
# Remove the NIXL memory registration added by register_nixl_memory.
# The memory may still be registered if there are live ObjectRefs
# that reference it.
deregister_nixl_memory(self.weight)
"""
nixl_transport = get_tensor_transport_manager("NIXL")
nixl_transport.deregister_nixl_memory(tensor)
@PublicAPI(stability="alpha")
def register_nixl_memory_pool(size: int, device: "torch.device") -> None:
"""Pre-allocates a memory pool and registers it with NIXL.
This enables pool-based memory management for NIXL transfers, which can improve
performance by avoiding repeated memory registration/deregistration. The pool is
registered once with NIXL and individual tensors are copied into it on ``ray.put``.
Within a single ``ray.put`` call, tensors sharing the same underlying storage
(including views) are automatically deduplicated — only one copy of each unique
storage is allocated. Across multiple ``ray.put`` calls, if the same storage
appears again, the existing pool slot is reused without re-copying the data.
As a result, data can be potentially stale once you ``ray.put`` the storage
tensor — subsequent mutations to that storage may not be reflected in outstanding refs.
Clone the tensor before ``ray.put`` if snapshot semantics are required.
If the pool has insufficient space for an allocation,
:class:`NixlOutOfMemoryError` is raised.
Args:
size: Size of the memory pool in bytes.
device: Device to allocate the pool on (e.g., ``torch.device("cpu")``
or ``torch.device("cuda")``).
Example:
.. code-block:: python
import torch
import ray
from ray.experimental import register_nixl_memory_pool
@ray.remote(num_gpus=1, enable_tensor_transport=True)
class Trainer:
def __init__(self):
# Pre-allocate a 1GB GPU memory pool for NIXL transfers
register_nixl_memory_pool(1024 * 1024 * 1024, torch.device("cuda"))
def get_weight_ref(self):
weight = torch.randn(1000, 1000, device="cuda")
return ray.put(weight, _tensor_transport="nixl")
"""
nixl_transport = get_tensor_transport_manager("NIXL")
nixl_transport.register_nixl_memory_pool(size, device)
def create_empty_tensors_from_metadata(
tensor_transport_meta: TensorTransportMetadata,
) -> List["torch.Tensor"]:
import torch
tensors = []
device = tensor_transport_meta.tensor_device
for meta in tensor_transport_meta.tensor_meta:
shape, dtype = meta
tensor = torch.empty(shape, dtype=dtype, device=device)
tensors.append(tensor)
return tensors