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
+9
View File
@@ -0,0 +1,9 @@
load("//bazel:python.bzl", "doctest")
doctest(
files = glob(
["**/*.py"],
exclude = ["tf_utils.py"],
),
tags = ["team:core"],
)
+30
View File
@@ -0,0 +1,30 @@
from ray.experimental.dynamic_resources import set_resource
from ray.experimental.locations import get_local_object_locations, get_object_locations
from ray.experimental.rdt import (
CommunicatorMetadata,
RDTManager,
TensorTransportManager,
TensorTransportMetadata,
deregister_nixl_memory,
register_nixl_memory,
register_nixl_memory_pool,
register_tensor_transport,
set_target_for_ref,
wait_tensor_freed,
)
__all__ = [
"get_object_locations",
"get_local_object_locations",
"set_resource",
"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,43 @@
from ray.experimental.channel.cached_channel import CachedChannel
from ray.experimental.channel.common import ( # noqa: F401
AwaitableBackgroundReader,
AwaitableBackgroundWriter,
ChannelContext,
ChannelInterface,
ChannelOutputType,
CompiledDAGArgs,
ReaderInterface,
SynchronousReader,
SynchronousWriter,
WriterInterface,
)
from ray.experimental.channel.communicator import Communicator
from ray.experimental.channel.cpu_communicator import CPUCommunicator
from ray.experimental.channel.intra_process_channel import IntraProcessChannel
from ray.experimental.channel.shared_memory_channel import (
BufferedSharedMemoryChannel,
Channel,
CompositeChannel,
)
from ray.experimental.channel.torch_tensor_accelerator_channel import (
TorchTensorAcceleratorChannel,
)
__all__ = [
"AwaitableBackgroundReader",
"AwaitableBackgroundWriter",
"CachedChannel",
"Channel",
"Communicator",
"CPUCommunicator",
"ReaderInterface",
"SynchronousReader",
"SynchronousWriter",
"WriterInterface",
"ChannelContext",
"TorchTensorAcceleratorChannel",
"IntraProcessChannel",
"CompositeChannel",
"BufferedSharedMemoryChannel",
"CompiledDAGArgs",
]
@@ -0,0 +1,246 @@
import importlib
import threading
from contextlib import nullcontext
from typing import TYPE_CHECKING, ContextManager, List, Optional, Type
import ray
from ray._private.accelerators import get_accelerator_manager_for_resource
from ray.experimental.channel.communicator import Communicator
if TYPE_CHECKING:
import torch
# The accelerator context singleton on this process.
_accelerator_context_lock = threading.Lock()
_default_accelerator_context: Optional["AcceleratorContext"] = None
_global_custom_context: Optional["AcceleratorContext"] = None
class AcceleratorContext:
"""
Provides a unified interface for managing different accelerator backends
This includes stream management, event creation, device context control,
and communicator support for distributed communication.
"""
def __init__(self, torch_module_name: str, communicator_cls: Type[Communicator]):
"""
Initializes an accelerator context with the specified torch device module
and communicator class.
Args:
torch_module_name: Name of the torch device module (e.g., "cuda", "cpu").
communicator_cls: Class used to handle communication.
"""
# The name of the torch module (e.g., 'cuda', 'npu')
self._torch_module_name: str = torch_module_name
# The Communicator class used to manage communication
self._communicator_cls: Type[Communicator] = communicator_cls
# Import the torch backend module (e.g., torch.cuda) if the device is not 'cpu'.
if torch_module_name != "cpu":
self._torch_mod = importlib.import_module(f"torch.{torch_module_name}")
@staticmethod
def get() -> "AcceleratorContext":
"""
Returns the singleton instance of the accelerator context.
If a custom accelerator has been registered, initializes the context
based on the registration. Otherwise, selects an appropriate runtime
based on the available device (CUDA or CPU) and registers the
corresponding default communicator.
Returns:
AcceleratorContext: A singleton instance of the appropriate
runtime context.
"""
global _default_accelerator_context, _global_custom_context
with _accelerator_context_lock:
if _global_custom_context is not None:
return _global_custom_context
if _default_accelerator_context is None:
if len(ray.get_gpu_ids()) > 0:
from ray.experimental.channel.nccl_group import _NcclGroup
_default_accelerator_context = AcceleratorContext(
"cuda", _NcclGroup
)
else:
from ray.experimental.channel.cpu_communicator import (
CPUCommunicator,
)
_default_accelerator_context = AcceleratorContext(
"cpu", CPUCommunicator
)
return _default_accelerator_context
@staticmethod
def set(accelerator_context: "AcceleratorContext") -> None:
"""
Overwrites the default accelerator context.
Args:
accelerator_context: The context to register.
"""
global _global_custom_context
# Accelerator context is registered.
_global_custom_context = accelerator_context
def get_accelerator_devices(self) -> List["torch.device"]:
"""
Gets the torch device list configured for this process.
Returns:
List[torch.device]: The torch device list.
"""
import torch
if self._torch_module_name == "cpu":
return [torch.device("cpu")]
if self._torch_module_name == "cuda":
accelerator_ids = [str(id) for id in ray.get_gpu_ids()]
accelerator_manager = get_accelerator_manager_for_resource("GPU")
else:
accelerator_ids = [
str(id)
for id in ray.get_runtime_context().get_accelerator_ids()[
self._torch_module_name.upper()
]
]
accelerator_manager = get_accelerator_manager_for_resource(
self._torch_module_name.upper()
)
device_ids = []
if len(accelerator_ids) > 0:
accelerator_visible_list = (
accelerator_manager.get_current_process_visible_accelerator_ids()
)
if accelerator_visible_list is None:
accelerator_visible_list = []
# If there are multiple Accelerators, return a list of devices.
# If using fractional Accelerators, these IDs are not guaranteed
# to be unique across different processes.
for accelerator_id in accelerator_ids:
try:
device_ids.append(accelerator_visible_list.index(accelerator_id))
except ValueError:
raise RuntimeError(
f"{accelerator_manager.get_visible_accelerator_ids_env_var()} set incorrectly. "
f"expected to include {accelerator_id}. "
"Did you override this environment"
" variable? If not, please help file an issue on Github."
)
else:
# If called on the driver or outside of Ray Train, return the
# 0th device.
device_ids.append(0)
return [
torch.device(f"{self._torch_module_name}:{device_id}")
for device_id in device_ids
]
def get_device_context(self, device: "torch.device") -> ContextManager:
"""
Retrieves the context manager for the specified accelerator device.
There is no device context for CPU, returning a nullcontext.
Args:
device: The target device for which the context manager is required.
Returns:
ContextManager: A context manager specific to the device type.
"""
if device.type == "cpu":
return nullcontext()
return self._torch_mod.device(device)
def current_stream(self):
"""
Retrieves the current execution stream for the accelerator device.
"""
return self._torch_mod.current_stream()
def create_event(self):
"""
Creates an event object for the accelerator device.
"""
return self._torch_mod.Event()
def generate_communicator_id(self) -> str:
"""
Generates a communication identifier for communication group.
"""
return self._communicator_cls.generate_communicator_id()
def create_communicator(self, *args, **kwargs) -> Communicator:
"""
Creates a communication group for collective operations.
"""
return self._communicator_cls(*args, **kwargs)
@property
def module_name(self) -> str:
"""
Gets the name of the torch module backing the accelerator.
"""
return self._torch_module_name
@property
def communicator_cls(self) -> Optional[Type[Communicator]]:
"""
Returns the communicator class.
"""
return self._communicator_cls
@property
def accelerator_count(self) -> int:
"""
Returns the number of accelerators assigned by ray.
"""
if self._torch_module_name == "cuda":
return len(ray.get_gpu_ids())
else:
accelerator_ids = ray.get_runtime_context().get_accelerator_ids()
return len(accelerator_ids.get(self._torch_module_name.upper(), []))
def register_accelerator_context(
torch_module_name: str, communicator_cls: Type[Communicator]
):
"""
Registers the accelerator context with the specified device type and communicator.
Args:
torch_module_name: The name of the device module under torch.
communicator_cls: The communicator class associated with the device.
"""
accelerator_context = AcceleratorContext(torch_module_name, communicator_cls)
AcceleratorContext.set(accelerator_context)
def is_accelerator_context_registered():
"""
Checks whether a custom accelerator context has been registered.
Returns:
bool: True if a custom accelerator context is registered
(_global_custom_context is not None), False otherwise.
"""
if _global_custom_context is not None:
return True
return False
@@ -0,0 +1,185 @@
from typing import Dict, List, Optional, Tuple, Union
import ray
from ray.experimental.channel import ChannelOutputType
from ray.experimental.channel.torch_tensor_type import TorchTensorType
from ray.experimental.util.types import Device
class AutoTransportType(ChannelOutputType):
"""
Type hint for automatic transport selection for tensors.
With this type hint Compiled Graphs automatically decide the best transport
to use (e.g., accellerator or shared memory) based on the node locations and
GPU IDs of the readers and writers.
"""
def __init__(
self,
device: Device = Device.DEFAULT,
_static_shape: bool = False,
_direct_return: bool = False,
):
self._device = device
self._static_shape = _static_shape
self._direct_return = _direct_return
@property
def device(self) -> Device:
return self._device
def create_channel(
self,
writer: Optional["ray.actor.ActorHandle"],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
driver_actor_id: Optional[str] = None,
) -> "ChannelOutputType":
"""
Directly calling create_channel() on AutoTransportType should not happen,
just raise an exception with informative message.
"""
raise ValueError(
"This should not happen: AutoTransportType should "
"have been resolved before creating a channel. "
"Please file a Ray GitHub issue for bug report."
)
class TypeHintResolver:
"""
This class is used to resolve `AutoChannelType` into an actual channel type
(e.g., `TorchTensorType` with proper transport) based on node locations and
GPU IDs of the readers and writers.
"""
def __init__(self, actor_to_gpu_ids: Dict["ray.actor.ActorHandle", List[str]]):
"""Initialize the type hint resolver.
Args:
actor_to_gpu_ids: Mapping from actor handle to its GPU IDs.
"""
self._actor_to_gpu_ids = actor_to_gpu_ids
def _get_gpu_ids(self, actor: "ray.actor.ActorHandle") -> List[str]:
"""Get the GPU IDs of the actor.
Args:
actor: The actor handle to look up.
Returns:
The GPU IDs of the actor. If the actor is not found,
return an empty list.
"""
gpu_ids = self._actor_to_gpu_ids.get(actor, [])
assert len(gpu_ids) <= 1, (
"Compiled Graphs currently don't support allocating multiple GPUs "
"to a single actor"
)
return gpu_ids
def _use_same_gpu(
self,
writer_and_node: Tuple["ray.actor.ActorHandle", str],
reader_and_node: Union[
Tuple["ray.actor.ActorHandle", str],
List[Tuple["ray.actor.ActorHandle", str]],
],
) -> bool:
"""
Check if the writer and readers use the same GPU.
Args:
writer_and_node: A tuple of writer actor handle and its node ID.
reader_and_node: A tuple of reader actor handle and its node ID, or
a list of such tuples.
Returns:
True if the writer and all the readers use the same GPU, False otherwise.
"""
if isinstance(reader_and_node, list):
return all(
self._use_same_gpu(writer_and_node, entry) for entry in reader_and_node
)
if writer_and_node[1] != reader_and_node[1]:
return False
writer_gpu_ids = self._get_gpu_ids(writer_and_node[0])
reader_gpu_ids = self._get_gpu_ids(reader_and_node[0])
return writer_gpu_ids == reader_gpu_ids
def _use_gpu(
self, actors: Union["ray.actor.ActorHandle", List["ray.actor.ActorHandle"]]
) -> bool:
"""
Check if the actors use GPUs.
Args:
actors: An actor handle or a list of actor handles.
Returns:
True if the actors use GPUs, False otherwise.
"""
if isinstance(actors, list):
return all(self._use_gpu(actor) for actor in actors)
gpu_ids = self._get_gpu_ids(actors)
return len(gpu_ids) > 0
def resolve(
self,
auto_transport_type: AutoTransportType,
writer_and_node: Tuple[Optional["ray.actor.ActorHandle"], str],
reader_and_node_list: List[Tuple[Optional["ray.actor.ActorHandle"], str]],
) -> "ChannelOutputType":
"""
Resolve auto_transport_type to the actual channel output type
based on the node locations and GPU IDs.
Args:
auto_transport_type: The type to resolve
writer_and_node: A tuple of writer actor handle and its node ID.
A None writer actor handle means the writer is the driver.
reader_and_node_list: A list of tuples of reader actor handle and its
node ID. A None reader actor handle means the reader is the driver.
Returns:
The actual channel type.
"""
writer = writer_and_node[0]
readers = [reader for reader, _ in reader_and_node_list]
if writer is None or any(reader is None for reader in readers):
# None means actor is the driver, currently driver on GPU
# is not supported, so we always use shared memory to transfer
# tensors.
return TorchTensorType(
device=auto_transport_type.device,
_static_shape=auto_transport_type._static_shape,
_direct_return=auto_transport_type._direct_return,
)
# Case 1: writer and readers don't both use GPU, use shared memory
# to transport the tensors
if not (self._use_gpu(writer) and self._use_gpu(readers)):
return TorchTensorType(
device=auto_transport_type.device,
_static_shape=auto_transport_type._static_shape,
_direct_return=auto_transport_type._direct_return,
)
# Case 2: writer and readers use the same GPU are are on the same node,
# use shared memory to transport the tensors
if self._use_same_gpu(writer_and_node, reader_and_node_list):
return TorchTensorType(
device=auto_transport_type.device,
_static_shape=auto_transport_type._static_shape,
_direct_return=auto_transport_type._direct_return,
)
# Case 3: writer and readers use different GPUs, use accelerator to transport
# the tensors
return TorchTensorType(
transport="accelerator",
device=auto_transport_type.device,
_static_shape=auto_transport_type._static_shape,
_direct_return=auto_transport_type._direct_return,
)
@@ -0,0 +1,111 @@
import uuid
from typing import Any, Optional
from ray.experimental.channel.common import ChannelInterface
class CachedChannel(ChannelInterface):
"""
CachedChannel wraps an inner channel and caches the data read from it until
`num_reads` reads have completed. If inner channel is None, the data
is written to serialization context and retrieved from there. This is useful
when passing data within the same actor and a shared memory channel can be
avoided.
Args:
num_reads: The number of reads from this channel that must happen before
writing again. Readers must be methods of the same actor.
inner_channel: The inner channel to cache data from. If None, the data is
read from the serialization context.
_channel_id: The unique ID for the channel. If None, a new ID is generated.
"""
def __init__(
self,
num_reads: int,
inner_channel: Optional[ChannelInterface] = None,
_channel_id: Optional[str] = None,
):
assert num_reads > 0, "num_reads must be greater than 0."
self._num_reads = num_reads
self._inner_channel = inner_channel
# Generate a unique ID for the channel. The writer and reader will use
# this ID to store and retrieve data from the _SerializationContext.
self._channel_id = _channel_id
if self._channel_id is None:
self._channel_id = str(uuid.uuid4())
def ensure_registered_as_writer(self) -> None:
if self._inner_channel is not None:
self._inner_channel.ensure_registered_as_writer()
def ensure_registered_as_reader(self) -> None:
if self._inner_channel is not None:
self._inner_channel.ensure_registered_as_reader()
def __reduce__(self):
return CachedChannel, (
self._num_reads,
self._inner_channel,
self._channel_id,
)
def __str__(self) -> str:
return (
f"CachedChannel(channel_id={self._channel_id}, "
f"num_reads={self._num_reads}), "
f"inner_channel={self._inner_channel})"
)
def write(self, value: Any, timeout: Optional[float] = None):
self.ensure_registered_as_writer()
# TODO: better organize the imports
from ray.experimental.channel import ChannelContext
if self._inner_channel is not None:
self._inner_channel.write(value, timeout)
return
# Otherwise no need to check timeout as the operation is non-blocking.
# Because both the reader and writer are in the same worker process,
# we can directly store the data in the context instead of storing
# it in the channel object. This removes the serialization overhead of `value`.
ctx = ChannelContext.get_current().serialization_context
ctx.set_data(self._channel_id, value, self._num_reads)
def read(self, timeout: Optional[float] = None) -> Any:
self.ensure_registered_as_reader()
# TODO: better organize the imports
from ray.experimental.channel import ChannelContext
ctx = ChannelContext.get_current().serialization_context
if ctx.has_data(self._channel_id):
# No need to check timeout as the operation is non-blocking.
return ctx.get_data(self._channel_id)
assert (
self._inner_channel is not None
), "Cannot read from the serialization context while inner channel is None."
value = self._inner_channel.read(timeout)
ctx.set_data(self._channel_id, value, self._num_reads)
# NOTE: Currently we make a contract with Compiled Graph users that the
# channel results should not be mutated by the actor methods.
# When the user needs to modify the channel results, they should
# make a copy of the channel results and modify the copy.
# This is the same contract as used in IntraProcessChannel.
# This contract is NOT enforced right now in either case.
# TODO(rui): introduce a flag to control the behavior:
# for example, by default we make a deep copy of the channel
# result, but the user can turn off the deep copy for performance
# improvements.
# https://github.com/ray-project/ray/issues/47409
return ctx.get_data(self._channel_id)
def close(self) -> None:
from ray.experimental.channel import ChannelContext
if self._inner_channel is not None:
self._inner_channel.close()
ctx = ChannelContext.get_current().serialization_context
ctx.reset_data(self._channel_id)
+688
View File
@@ -0,0 +1,688 @@
import asyncio
import concurrent
import sys
import threading
import time
from dataclasses import dataclass
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
NamedTuple,
Optional,
Tuple,
Union,
)
import ray
import ray.exceptions
from ray.experimental.channel.accelerator_context import AcceleratorContext
from ray.experimental.channel.communicator import Communicator
from ray.experimental.channel.communicator_handle import CommunicatorHandle
from ray.experimental.channel.serialization_context import _SerializationContext
from ray.util.annotations import DeveloperAPI, PublicAPI
# The context singleton on this process.
_default_context: "Optional[ChannelContext]" = None
_context_lock = threading.Lock()
if TYPE_CHECKING:
import torch
def retry_and_check_interpreter_exit(f: Callable[[], None]) -> bool:
"""This function is only useful when f contains channel read/write.
Keep retrying channel read/write inside `f` and check if interpreter exits.
It is important in case the read/write happens in a separate thread pool.
See https://github.com/ray-project/ray/pull/47702
f should a function that doesn't receive any input and return nothing.
"""
exiting = False
while True:
try:
f()
break
except ray.exceptions.RayChannelTimeoutError:
if sys.is_finalizing():
# Interpreter exits. We should ignore the error and
# stop reading so that the thread can join.
exiting = True
break
return exiting
# Holds the input arguments for Compiled Graph
@PublicAPI(stability="alpha")
class CompiledDAGArgs(NamedTuple):
args: Tuple[Any, ...]
kwargs: Dict[str, Any]
@PublicAPI(stability="alpha")
class ChannelOutputType:
def register_custom_serializer(self) -> None:
"""
Register any custom serializers needed to pass data of this type. This
method should be run on the reader(s) and writer of a channel, which
are the driver and/or Ray actors.
NOTE: When custom serializers are registered with Ray, the registered
deserializer is shipped with the serialized value and used on the
receiving end. Therefore, the deserializer function should *not*
capture state that is meant to be worker-local, such as the worker's
default device. Instead, these should be extracted from the
worker-local _SerializationContext.
"""
pass
def create_channel(
self,
writer: Optional["ray.actor.ActorHandle"],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
driver_actor_id: Optional[str] = None,
) -> "ChannelInterface":
"""
Instantiate a ChannelInterface class that can be used
to pass data of this type.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
driver_actor_id: If this is a CompositeChannel that is read by a driver and
that driver is an actual actor, this will be the actor ID of that
driver actor.
Returns:
A ChannelInterface that can be used to pass data
of this type.
"""
raise NotImplementedError
def requires_accelerator(self) -> bool:
# By default, channels do not require accelerator.
return False
def get_custom_communicator(self) -> Optional[Communicator]:
"""
Return the custom communicator group if one is specified.
"""
return None
def set_communicator_id(self, group_id: str) -> None:
raise NotImplementedError
@DeveloperAPI
@dataclass
class ChannelContext:
serialization_context = _SerializationContext()
_torch_available: Optional[bool] = None
_torch_device: Optional["torch.device"] = None
_current_stream: Optional["torch.cuda.Stream"] = None
def __init__(self):
# Used for the torch.Tensor accelerator transport.
self.communicators: Dict[str, "Communicator"] = {}
# Used for driver process to store actors in the communicator.
self.communicator_handles: Dict[str, "CommunicatorHandle"] = {}
@staticmethod
def get_current() -> "ChannelContext":
"""Get or create a singleton context.
If the context has not yet been created in this process, it will be
initialized with default settings.
"""
global _default_context
with _context_lock:
if _default_context is None:
_default_context = ChannelContext()
return _default_context
@property
def torch_available(self) -> bool:
"""
Check if torch package is available.
"""
if self._torch_available is not None:
return self._torch_available
try:
import torch # noqa: F401
except ImportError:
self._torch_available = False
return False
self._torch_available = True
return True
@property
def torch_device(self) -> "torch.device":
if self._torch_device is None:
self._torch_device = AcceleratorContext.get().get_accelerator_devices()[0]
return self._torch_device
def set_torch_device(self, device: "torch.device"):
self._torch_device = device
@PublicAPI(stability="alpha")
class ChannelInterface:
"""
Abstraction for a transport between a writer actor and some number of
reader actors.
"""
def __init__(
self,
writer: Optional[ray.actor.ActorHandle],
readers: List[Optional[ray.actor.ActorHandle]],
typ: Optional["ChannelOutputType"],
):
"""
Create a channel that can be read and written by a Ray driver or actor.
Args:
writer: The actor that may write to the channel. None signifies the driver.
readers: The actors that may read from the channel. None signifies
the driver.
typ: Type information about the values passed through the channel.
"""
pass
def ensure_registered_as_writer(self):
"""
Check whether the process is a valid writer. This method must be idempotent.
"""
raise NotImplementedError
def ensure_registered_as_reader(self):
"""
Check whether the process is a valid reader. This method must be idempotent.
"""
raise NotImplementedError
def write(self, value: Any, timeout: Optional[float] = None) -> None:
"""
Write a value to the channel.
Blocks if there are still pending readers for the previous value. The
writer may not write again until the specified number of readers have
read the value.
Args:
value: The value to write.
timeout: The maximum time in seconds to wait to write the value.
None means using default timeout, 0 means immediate timeout
(immediate success or timeout without blocking), -1 means
infinite timeout (block indefinitely).
"""
raise NotImplementedError
def read(self, timeout: Optional[float] = None) -> Any:
"""
Read the latest value from the channel. This call will block until a
value is available to read.
Subsequent calls to read() may *block* if the deserialized object is
zero-copy (e.g., bytes or a numpy array) *and* the object is still in scope.
Args:
timeout: The maximum time in seconds to wait to read the value.
None means using default timeout, 0 means immediate timeout
(immediate success or timeout without blocking), -1 means
infinite timeout (block indefinitely).
Returns:
Any: The deserialized value. If the deserialized value is an
Exception, it will be returned directly instead of being raised.
"""
raise NotImplementedError
def close(self) -> None:
"""
Close this channel. This method must not block and it must be made
idempotent. Any existing values in the channel may be lost after the
channel is closed.
"""
raise NotImplementedError
# Interfaces for channel I/O.
@DeveloperAPI
class ReaderInterface:
def __init__(
self,
input_channels: List[ChannelInterface],
):
assert isinstance(input_channels, list)
for chan in input_channels:
assert isinstance(chan, ChannelInterface)
self._input_channels = input_channels
self._closed = False
self._num_reads = 0
# A list of channels that were not read in the last `read` call
# because the reader returned immediately when a RayTaskError was found.
# These channels must be consumed before the next read to avoid reading
# stale data remaining from the last read.
self._leftover_channels: List[ChannelInterface] = []
def get_num_reads(self) -> int:
return self._num_reads
def start(self):
raise NotImplementedError
def _read_list(self, timeout: Optional[float] = None) -> List[Any]:
"""Read a list of values from this reader.
Args:
timeout: The maximum time in seconds to wait for reading.
None means using default timeout which is infinite, 0 means immediate
timeout (immediate success or timeout without blocking), -1 means
infinite timeout (block indefinitely).
Returns:
The list of values read from the underlying input channels.
"""
raise NotImplementedError
def read(self, timeout: Optional[float] = None) -> List[Any]:
"""Read from this reader.
Args:
timeout: The maximum time in seconds to wait for reading.
None means using default timeout, 0 means immediate timeout
(immediate success or timeout without blocking), -1 means
infinite timeout (block indefinitely).
Returns:
The list of values read from this reader.
"""
assert (
timeout is None or timeout >= 0 or timeout == -1
), "Timeout must be non-negative or -1."
outputs = self._read_list(timeout)
self._num_reads += 1
return outputs
def close(self) -> None:
self._closed = True
for channel in self._input_channels:
channel.close()
def _consume_leftover_channels_if_needed(
self, timeout: Optional[float] = None
) -> None:
# Consume the channels that were not read in the last `read` call because a
# RayTaskError was returned from another channel. If we don't do this, the
# read operation will read stale versions of the object refs.
#
# If a RayTaskError is returned from a leftover channel, it will be ignored.
# If a read operation times out, a RayChannelTimeoutError exception will be
# raised.
#
# TODO(kevin85421): Currently, a DAG with NCCL channels and fast fail enabled
# may not be reusable. Revisit this in the future.
for c in self._leftover_channels:
start_time = time.monotonic()
c.read(timeout)
if timeout is not None:
timeout -= time.monotonic() - start_time
timeout = max(timeout, 0)
self._leftover_channels = []
@DeveloperAPI
class SynchronousReader(ReaderInterface):
def __init__(
self,
input_channels: List[ChannelInterface],
):
super().__init__(input_channels)
def start(self):
pass
def _read_list(self, timeout: Optional[float] = None) -> List[Any]:
self._consume_leftover_channels_if_needed(timeout)
# We don't update `remaining_timeout` here because in the worst case,
# consuming leftover channels requires reading all `_input_channels`,
# which users expect to complete within the original `timeout`. Updating
# `remaining_timeout` could cause unexpected timeouts in subsequent read
# operations.
# It is a special case that `timeout` is set to 0, which means
# read once for each channel.
is_zero_timeout = timeout == 0
results = [None for _ in range(len(self._input_channels))]
if timeout is None or timeout == -1:
timeout = float("inf")
timeout_point = time.monotonic() + timeout
remaining_timeout = timeout
from ray.dag import DAGContext
ctx = DAGContext.get_current()
iteration_timeout = ctx.read_iteration_timeout
# Iterate over the input channels with a shorter timeout for each iteration
# to detect RayTaskError early and fail fast.
done_channels = set()
while len(done_channels) < len(self._input_channels):
for i, c in enumerate(self._input_channels):
if c in done_channels:
continue
try:
result = c.read(min(remaining_timeout, iteration_timeout))
results[i] = result
done_channels.add(c)
if isinstance(result, ray.exceptions.RayTaskError):
# If we raise an exception immediately, it will be considered
# as a system error which will cause the execution loop to
# exit. Hence, return immediately and let `_process_return_vals`
# handle the exception.
#
# Return a list of RayTaskError so that the caller will not
# get an undefined partial result.
self._leftover_channels = [
c for c in self._input_channels if c not in done_channels
]
return [result for _ in range(len(self._input_channels))]
except ray.exceptions.RayChannelTimeoutError as e:
remaining_timeout = max(timeout_point - time.monotonic(), 0)
if remaining_timeout == 0:
raise e
continue
remaining_timeout = max(timeout_point - time.monotonic(), 0)
if remaining_timeout == 0 and not is_zero_timeout:
raise ray.exceptions.RayChannelTimeoutError(
f"Cannot read all channels within {timeout} seconds"
)
return results
def release_channel_buffers(self, timeout: Optional[float] = None) -> None:
for c in self._input_channels:
start_time = time.monotonic()
assert hasattr(
c, "release_buffer"
), "release_buffer() is only supported for shared memory channel "
"(e.g., Channel, BufferedSharedMemoryChannel, CompositeChannel) "
"and used between the last actor and the driver, but got a channel"
f" of type {type(c)}."
c.release_buffer(timeout)
if timeout is not None:
timeout -= time.monotonic() - start_time
timeout = max(timeout, 0)
@DeveloperAPI
class AwaitableBackgroundReader(ReaderInterface):
"""
Asyncio-compatible channel reader.
The reader is constructed with an async queue of futures whose values it
will fulfill. It uses a threadpool to execute the blocking calls to read
from the input channel(s).
"""
def __init__(
self,
input_channels: List[ChannelInterface],
fut_queue: asyncio.Queue,
):
super().__init__(input_channels)
self._fut_queue = fut_queue
self._background_task = None
self._background_task_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="channel.AwaitableBackgroundReader"
)
def start(self):
self._background_task = asyncio.ensure_future(self.run())
def _run(self):
# Give it a default timeout 60 seconds to release the buffers
# of the channels that were not read in the last `read` call.
self._consume_leftover_channels_if_needed(60)
results = [None for _ in range(len(self._input_channels))]
from ray.dag import DAGContext
ctx = DAGContext.get_current()
iteration_timeout = ctx.read_iteration_timeout
done_channels = set()
while len(done_channels) < len(self._input_channels):
for i, c in enumerate(self._input_channels):
if c in done_channels:
continue
try:
result = c.read(iteration_timeout)
results[i] = result
done_channels.add(c)
if isinstance(result, ray.exceptions.RayTaskError):
self._leftover_channels = [
c for c in self._input_channels if c not in done_channels
]
return [result for _ in range(len(self._input_channels))]
except ray.exceptions.RayChannelTimeoutError:
pass
if sys.is_finalizing():
return results
return results
async def run(self):
loop = asyncio.get_running_loop()
while not self._closed:
res, fut = await asyncio.gather(
loop.run_in_executor(self._background_task_executor, self._run),
self._fut_queue.get(),
return_exceptions=True,
)
# Set the result on the main thread.
fut.set_result(res)
# NOTE(swang): If the object is zero-copy deserialized, then it
# will stay in scope as long as ret and the future are in scope.
# Therefore, we must delete both here after fulfilling the future.
del res
del fut
def close(self):
super().close()
self._background_task_executor.shutdown(cancel_futures=True)
self._background_task.cancel()
@DeveloperAPI
class WriterInterface:
def __init__(
self,
output_channels: List[ChannelInterface],
output_idxs: List[Optional[Union[int, str]]],
is_input: bool = False,
):
"""
Initialize the writer.
Args:
output_channels: The output channels to write to.
output_idxs: The indices of the values to write to each channel.
This has the same length as `output_channels`. If `is_input` is True,
the index can be an integer or a string to retrieve the corresponding
value from `args` or `kwargs` in the DAG's input. If `is_input`
is False, the entire value is written if the index is None. Otherwise,
the value at the specified index in the tuple is written.
is_input: Whether the writer is DAG input writer or not.
"""
assert len(output_channels) == len(output_idxs)
self._output_channels = output_channels
self._output_idxs = output_idxs
self._closed = False
self._num_writes = 0
self._is_input = is_input
def get_num_writes(self) -> int:
return self._num_writes
def start(self):
raise NotImplementedError()
def write(self, val: Any, timeout: Optional[float] = None) -> None:
"""Write the value.
Args:
val: The value to write to the output channels.
timeout: The maximum time in seconds to wait for writing. 0 means
immediate timeout (immediate success or timeout without blocking).
-1 and None mean infinite timeout (blocks indefinitely).
"""
raise NotImplementedError()
def close(self) -> None:
self._closed = True
for channel in self._output_channels:
channel.close()
def _adapt(raw_args: Any, key: Optional[Union[int, str]], is_input: bool):
"""Adapt the raw arguments to the key.
If ``is_input`` is True, this method will retrieve the value from the input
data for an InputAttributeNode. Otherwise, it will retrieve either a partial
value or the entire value from the output of a ClassMethodNode.
Args:
raw_args: The raw arguments to adapt.
key: The key to adapt.
is_input: Whether the writer is DAG input writer or not.
Returns:
The value retrieved from ``raw_args`` according to ``key`` and
``is_input``.
"""
if is_input:
if not isinstance(raw_args, CompiledDAGArgs):
# Fast path for a single input.
return raw_args
else:
args = raw_args.args
kwargs = raw_args.kwargs
if isinstance(key, int):
return args[key]
else:
return kwargs[key]
else:
if key is not None:
return raw_args[key]
else:
return raw_args
@DeveloperAPI
class SynchronousWriter(WriterInterface):
def start(self):
for channel in self._output_channels:
channel.ensure_registered_as_writer()
def write(self, val: Any, timeout: Optional[float] = None) -> None:
# If it is an exception, there's only 1 return value.
# We have to send the same data to all channels.
if isinstance(val, Exception):
if len(self._output_channels) > 1:
val = tuple(val for _ in range(len(self._output_channels)))
if not self._is_input:
if len(self._output_channels) > 1:
if not isinstance(val, tuple):
raise ValueError(
f"Expected a tuple of {len(self._output_channels)} outputs, "
f"but got {type(val)}"
)
if len(val) != len(self._output_channels):
raise ValueError(
f"Expected {len(self._output_channels)} outputs, but got "
f"{len(val)} outputs"
)
for i, channel in enumerate(self._output_channels):
idx = self._output_idxs[i]
val_i = _adapt(val, idx, self._is_input)
channel.write(val_i, timeout)
self._num_writes += 1
@DeveloperAPI
class AwaitableBackgroundWriter(WriterInterface):
def __init__(
self,
output_channels: List[ChannelInterface],
output_idxs: List[Optional[Union[int, str]]],
is_input: bool = False,
):
super().__init__(output_channels, output_idxs, is_input=is_input)
self._queue = asyncio.Queue()
self._background_task = None
self._background_task_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=1, thread_name_prefix="channel.AwaitableBackgroundWriter"
)
def start(self):
for channel in self._output_channels:
channel.ensure_registered_as_writer()
self._background_task = asyncio.ensure_future(self.run())
def _run(self, res):
if not self._is_input:
if len(self._output_channels) > 1:
if not isinstance(res, tuple):
raise ValueError(
f"Expected a tuple of {len(self._output_channels)} outputs, "
f"but got {type(res)}"
)
if len(res) != len(self._output_channels):
raise ValueError(
f"Expected {len(self._output_channels)} outputs, but got "
f"{len(res)} outputs"
)
for i, channel in enumerate(self._output_channels):
idx = self._output_idxs[i]
res_i = _adapt(res, idx, self._is_input)
exiting = retry_and_check_interpreter_exit(
lambda: channel.write(res_i, timeout=1)
)
if exiting:
break
async def run(self):
loop = asyncio.get_event_loop()
while True:
res = await self._queue.get()
await loop.run_in_executor(self._background_task_executor, self._run, res)
async def write(self, val: Any) -> None:
if self._closed:
raise RuntimeError("DAG execution cancelled")
await self._queue.put(val)
self._num_writes += 1
def close(self):
self._background_task.cancel()
super().close()
@@ -0,0 +1,203 @@
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
import ray
from ray.experimental.util.types import ReduceOp
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
import torch
# Signature for a torch.Tensor allocator is:
# (shape: Tuple[int], dtype: torch.dtype) -> torch.Tensor.
TorchTensorAllocator = Callable[[Tuple[int], "torch.dtype"], "torch.Tensor"]
@DeveloperAPI
class Communicator(ABC):
"""
Communicator for a group of Compiled Graph actors on NVIDIA GPU.
The Compiled Graph execution leverages this internally to support communication
between actors in the group.
"""
@abstractmethod
def initialize(self, rank: int) -> None:
"""
Initialize the communicator from the actor.
This is called once by Compiled Graph on each actor to initialize the
communicator,before any other methods.
Args:
rank: The rank of this actor in the group.
"""
raise NotImplementedError
@abstractmethod
def get_actor_handles(self) -> List["ray.actor.ActorHandle"]:
"""
Get handles of all actors for this communicator group.
"""
raise NotImplementedError
@abstractmethod
def get_rank(self, actor: ray.actor.ActorHandle) -> int:
"""Return the given actor's rank in the group.
Args:
actor: The actor handle to look up.
Returns:
The rank of ``actor`` within the communicator group.
"""
raise NotImplementedError
@abstractmethod
def get_self_rank(self) -> Optional[int]:
"""
Return this actor's rank.
"""
raise NotImplementedError
def get_world_size(self) -> int:
"""
Return the number of ranks in the group.
"""
raise NotImplementedError
@abstractmethod
def send(self, value: "torch.Tensor", peer_rank: int) -> None:
"""
Send a torch.Tensor to a peer.
This returns when the send kernel has been queued, but the kernel may
not have completed. Therefore, the caller should ensure that there are
no concurrent writes to the sent `value` until the send has finished.
Args:
value: The torch.Tensor to send. It should already be on this
actor's default device.
peer_rank: The rank of the actor to send to.
"""
raise NotImplementedError
@abstractmethod
def recv(
self,
shape: Tuple[int],
dtype: "torch.dtype",
peer_rank: int,
allocator: Optional[TorchTensorAllocator] = None,
) -> "torch.Tensor":
"""Receive a torch.Tensor from a peer and synchronize.
After this call returns, the receive buffer is safe to read from from
any stream. An RayChannelError will be raised if an error occurred (e.g.,
remote actor died), and the buffer is not safe to read.
Args:
shape: The shape of the tensor to receive.
dtype: The dtype of the tensor to receive.
peer_rank: The rank of the actor to receive from.
allocator: A function to allocate the tensor to receive into.
Returns:
The tensor received from ``peer_rank``.
"""
raise NotImplementedError
@property
@abstractmethod
def recv_stream(self):
"""
Return the torch stream context used for receiving tensors.
"""
raise NotImplementedError
@property
@abstractmethod
def send_stream(self):
"""
Return the torch stream context used for sending tensors.
"""
raise NotImplementedError
@abstractmethod
def allgather(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
) -> None:
"""
Collectively allgather the tensor across the group.
Args:
send_buf: The input torch.tensor to allgather. It should already be
on this actor's default device.
recv_buf: The output torch.tensor to store the allgather result.
"""
raise NotImplementedError
@abstractmethod
def allreduce(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp,
) -> None:
"""
Collectively allreduce the tensor across the group.
Args:
send_buf: The input torch.tensor to allreduce. It should already be
on this actor's default device.
recv_buf: The output torch.tensor to store the allreduce result.
op: The reduce operation.
"""
raise NotImplementedError
@abstractmethod
def reducescatter(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp,
) -> None:
"""
Collectively reducescatter the tensor across the group.
Args:
send_buf: The input torch.tensor to reducescatter. It should already be
on this actor's default device.
recv_buf: The output torch.tensor to store the reducescatter result.
op: The reduce operation.
"""
raise NotImplementedError
@abstractmethod
def destroy(self) -> None:
"""
Destroy the GPU communicator.
Any destruction and cleanup for the GPU communicator should be
done here. Implement as a noop is nothing is needed.
"""
raise NotImplementedError
@abstractmethod
def get_transport_name(self) -> str:
"""
Return the type of the communicator (gpu or cpu).
"""
raise NotImplementedError
@classmethod
@abstractmethod
def generate_communicator_id(cls) -> str:
"""
Return the unique id of the communicator.
"""
raise NotImplementedError
@@ -0,0 +1,28 @@
from typing import List
import ray
class CommunicatorHandle:
"""
A lightweight communicator handle used by the driver to store handles to
the actors in the communicator.
"""
def __init__(
self,
actor_handles: List["ray.actor.ActorHandle"],
):
"""
Initializes the CommunicatorHandle with the given actor handles.
Args:
actor_handles: A list of actor handles to be stored.
"""
self._actor_handles = actor_handles
def get_actor_handles(self) -> List["ray.actor.ActorHandle"]:
"""
Retuan all actor handles in this communicator.
"""
return self._actor_handles
+185
View File
@@ -0,0 +1,185 @@
import asyncio
from collections import defaultdict
from typing import Optional, Tuple
from unittest import mock
import torch
import ray
import ray.dag
import ray.experimental.channel as ray_channel
from ray.experimental.channel import nccl_group
from ray.experimental.channel.communicator import TorchTensorAllocator
from ray.experimental.util.types import Device
@ray.remote(num_cpus=0)
class Barrier:
"""
Barrier that blocks the given number of actors until all actors have
reached the barrier. This is used to mock out blocking NCCL ops.
"""
def __init__(self, num_actors=2):
self.num_actors = num_actors
self.condition = asyncio.Condition()
# Buffer for the data that is "sent" between the actors, each entry is
# one p2p op.
self.data = {}
# Buffer for the number of actors seen, each entry is one p2p op.
self.num_actors_seen = defaultdict(int)
# Add a new mock for the TorchTensorType.device property
device_property_patcher = mock.patch(
"ray.experimental.channel.torch_tensor_type.TorchTensorType.device",
new_callable=mock.PropertyMock,
return_value=Device.CPU,
)
device_property_patcher.start()
async def wait(self, idx: int, data=None):
"""
Wait at barrier until all actors have sent `idx`. One actor should
provide `data`, and this value will be returned by this method for all
other actors.
"""
async with self.condition:
if data is not None:
assert idx not in self.data, (self.data, self.num_actors_seen)
self.data[idx] = data
self.num_actors_seen[idx] += 1
if self.num_actors_seen[idx] == self.num_actors:
# Wake up all tasks waiting on this condition.
self.condition.notify_all()
else:
await self.condition.wait_for(
lambda: self.num_actors_seen[idx] == self.num_actors
)
if data is None:
data = self.data[idx]
return data
class MockCudaStream:
def __init__(self):
self.cuda_stream = 0
def synchronize(self):
pass
class MockNcclGroup(nccl_group._NcclGroup):
"""
Mock the internal _NcclGroup to use a barrier actor instead of a NCCL group
for communication.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# We use the op index to synchronize the sender and receiver at the
# barrier.
self.num_ops = defaultdict(int)
self.barriers = set()
def send(self, tensor: torch.Tensor, peer_rank: int):
# "Send" the tensor to the barrier actor.
barrier_key = sorted([self.get_self_rank(), peer_rank])
barrier_key = f"barrier-{barrier_key[0]}-{barrier_key[1]}"
barrier = ray.get_actor(name=barrier_key)
self.barriers.add(barrier)
ray.get(barrier.wait.remote(self.num_ops[barrier_key], tensor))
self.num_ops[barrier_key] += 1
def recv(
self,
shape: Tuple[int],
dtype: torch.dtype,
peer_rank: int,
allocator: Optional[TorchTensorAllocator] = None,
):
# "Receive" the tensor from the barrier actor.
barrier_key = sorted([self.get_self_rank(), peer_rank])
barrier_key = f"barrier-{barrier_key[0]}-{barrier_key[1]}"
barrier = ray.get_actor(name=barrier_key)
self.barriers.add(barrier)
received_tensor = ray.get(barrier.wait.remote(self.num_ops[barrier_key]))
assert (
allocator is not None
), "torch tensor allocator is required for MockNcclGroup"
buf = allocator(shape, dtype)
buf[:] = received_tensor[:]
self.num_ops[barrier_key] += 1
return buf
def destroy(self) -> None:
for barrier in self.barriers:
ray.kill(barrier)
def start_nccl_mock():
"""
Patch methods that require CUDA.
"""
# Mock cupy dependencies.
nccl_mock = mock.MagicMock()
nccl_mock.nccl.get_unique_id.return_value = 0
cp_patcher = mock.patch.dict(
"sys.modules",
{
"cupy.cuda": nccl_mock,
"cupy": mock.MagicMock(),
"ray.util.collective.collective_group": mock.MagicMock(),
},
)
cp_patcher.start()
# Mock send/recv ops to use an actor instead of NCCL.
ray.experimental.channel.nccl_group._NcclGroup = MockNcclGroup
# PyTorch mocks.
stream_patcher = mock.patch(
"torch.cuda.current_stream", new_callable=lambda: MockCudaStream
)
stream_patcher.start()
new_stream_patcher = mock.patch(
"torch.cuda.Stream", new_callable=lambda: MockCudaStream
)
new_stream_patcher.start()
tensor_patcher = mock.patch("torch.Tensor.device", torch.device("cuda"))
tensor_patcher.start()
tensor_patcher = mock.patch("torch.Tensor.is_cuda", True)
tensor_patcher.start()
tensor_allocator_patcher = mock.patch(
"ray.experimental.channel.torch_tensor_accelerator_channel._torch_tensor_allocator",
lambda shape, dtype: torch.empty(shape, dtype=dtype),
)
tensor_allocator_patcher.start()
# Add a new mock for the TorchTensorType.device property
device_property_patcher = mock.patch(
"ray.experimental.channel.torch_tensor_type.TorchTensorType.device",
new_callable=mock.PropertyMock,
return_value=Device.CPU,
)
device_property_patcher.start()
ctx = ray_channel.ChannelContext.get_current()
ctx.set_torch_device(torch.device("cuda"))
class TracedChannel(ray_channel.shared_memory_channel.Channel):
"""
Patched Channel that records all write ops for testing.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ops = []
def write(self, *args, **kwargs):
self.ops.append((args, kwargs))
return super().write(*args, **kwargs)
@@ -0,0 +1,209 @@
import asyncio
from collections import defaultdict
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import ray
from ray.experimental.channel.communicator import (
Communicator,
ReduceOp,
TorchTensorAllocator,
)
if TYPE_CHECKING:
import torch
@ray.remote(num_cpus=0)
class CPUCommBarrier:
"""
Barrier actor that blocks the given number of actors until all actors have
reached the Barrier.
p2p operations are not done here (completed via shared memory channel).
"""
def __init__(self, num_actors: int):
self.num_actors = num_actors
self.condition = asyncio.Condition()
# Stores the data for each collective operation
self.collective_data: Dict[int, List["torch.Tensor"]] = defaultdict(list)
# Stores the shape of data for each collective operation
self.collective_data_shape: Dict[int, "torch.Tensor.type"] = {}
# Buffer for the number of actors seen
self.num_actors_seen = defaultdict(int)
# Number of actors who have read the result, and are about to exit the function.
# State is kept so we only garbage collect after the last actor has read the
# relevant data.
self.num_actors_read = defaultdict(int)
async def wait_collective(self, op_id: int, data: "torch.Tensor", op: ReduceOp):
"""
Wait at the communicator until all actors have sent `op_id` and `data`.
Once data from all actors is received, execute the collective `op`
on the communicator actor and return the result.
"""
async with self.condition:
self.collective_data[op_id].append(data)
self.num_actors_seen[op_id] += 1
if self.num_actors_seen[op_id] == self.num_actors:
# Apply the collective operation across all gathered tensors
data = self._apply_op(op, self.collective_data[op_id])
self.collective_data[op_id] = data
self.condition.notify_all()
else:
await self.condition.wait_for(
lambda: self.num_actors_seen[op_id] == self.num_actors
)
data = self.collective_data[op_id]
self.num_actors_read[op_id] += 1
if self.num_actors_read[op_id] == self.num_actors:
del self.collective_data[op_id]
del self.num_actors_seen[op_id]
del self.num_actors_read[op_id]
return data
def _apply_op(self, op: ReduceOp, tensors: List["torch.Tensor"]) -> "torch.Tensor":
"""Apply the specified reduction operation across a list of tensors."""
result = tensors[0].clone()
if op == ReduceOp.SUM:
for tensor in tensors[1:]:
result += tensor
elif op == ReduceOp.PRODUCT:
for tensor in tensors[1:]:
result *= tensor
elif op == ReduceOp.MAX:
for tensor in tensors[1:]:
result = torch.max(result, tensor)
elif op == ReduceOp.MIN:
for tensor in tensors[1:]:
result = torch.min(result, tensor)
elif op == ReduceOp.AVG:
result = sum(tensors) / len(tensors)
else:
raise ValueError(f"Operation {op} not supported")
return result
class CPUCommunicator(Communicator):
"""
Uses a CPU-based communicator actor instead of an accelerator group like NCCL.
"""
def __init__(self, world_size: int, actor_handles: List["ray.actor.ActorHandle"]):
"""We use the op index to synchronize the sender and receiver at the
communicator actor."""
self._world_size = world_size
self._actor_handles = actor_handles
self.num_ops = defaultdict(int)
# For collective communication, one barrier will be created for
# each unique group of participants.
self.barriers = set()
self._rank = None
def send(self, tensor: "torch.Tensor", peer_rank: int):
# p2p operations are done via a shared memory channel, initialized in
# `create_channel` of `TorchTensorType`
pass
def recv(
self,
shape: Tuple[int],
dtype: "torch.dtype",
peer_rank: int,
allocator: Optional[TorchTensorAllocator] = None,
):
# See the comment on `send`
pass
def allgather(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
):
raise NotImplementedError
def allreduce(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
):
all_ranks = [
self.get_rank(actor_handle) for actor_handle in self.get_actor_handles()
]
barrier_key = "barrier-collective-" + "-".join(map(str, sorted(all_ranks)))
barrier = CPUCommBarrier.options(name=barrier_key, get_if_exists=True).remote(
self._world_size
)
self.barriers.add(barrier)
result = ray.get(
barrier.wait_collective.remote(self.num_ops[barrier_key], send_buf, op)
)
assert recv_buf is not None, "Receiving buffer required for CPUCommunicator"
recv_buf[:] = result[:]
self.num_ops[barrier_key] += 1
def reducescatter(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
):
raise NotImplementedError
def destroy(self) -> None:
for barrier in self.barriers:
ray.kill(barrier)
def initialize(self, rank: int) -> None:
self._rank = rank
def get_actor_handles(self) -> List["ray.actor.ActorHandle"]:
return self._actor_handles
def get_rank(self, actor: ray.actor.ActorHandle) -> int:
"""Return the given actor's rank in the CPU communicator.
Args:
actor: The actor handle to look up.
Returns:
The rank of ``actor`` within the CPU communicator group.
"""
actor_ids = [a._ray_actor_id for a in self._actor_handles]
try:
rank = actor_ids.index(actor._ray_actor_id)
except ValueError:
raise ValueError("Actor is not in the CPUCommunicator group.")
return rank
def get_self_rank(self) -> Optional[int]:
return self._rank
def get_world_size(self) -> int:
"""
Return the number of ranks in the CPU communicator.
"""
return self._world_size
def get_transport_name(self) -> str:
return "cpu"
def recv_stream(self):
raise NotImplementedError
def send_stream(self):
raise NotImplementedError
@classmethod
def generate_communicator_id(cls) -> str:
import uuid
return str(uuid.uuid4())
@@ -0,0 +1,72 @@
import uuid
from typing import Any, Optional
from ray.experimental.channel import ChannelContext
from ray.experimental.channel.common import ChannelInterface
from ray.util.annotations import PublicAPI
@PublicAPI(stability="alpha")
class IntraProcessChannel(ChannelInterface):
"""IntraProcessChannel is a channel for communication between two tasks in the same
worker process. It writes data directly to the worker's _SerializationContext
and reads data from the _SerializationContext to avoid the serialization
overhead and the need for reading/writing from shared memory. Note that if the
readers may mutate the data, users should deep copy the data themselves to avoid
side effects.
Args:
num_readers: The number of readers that will read from this channel. Readers
can be the same method of the same actor.
_channel_id: Optional pre-generated channel identifier. If ``None``, a
new UUID4 is generated. Used internally for re-creating channels
after pickling.
"""
def __init__(
self,
num_readers: int,
_channel_id: Optional[str] = None,
):
# Generate a unique ID for the channel. The writer and reader will use
# this ID to store and retrieve data from the _SerializationContext.
self._channel_id = _channel_id
self._num_readers = num_readers
if self._channel_id is None:
self._channel_id = str(uuid.uuid4())
def ensure_registered_as_writer(self) -> None:
pass
def ensure_registered_as_reader(self) -> None:
pass
def __reduce__(self):
return IntraProcessChannel, (
self._num_readers,
self._channel_id,
)
def __str__(self) -> str:
return f"IntraProcessChannel(channel_id={self._channel_id})"
def write(self, value: Any, timeout: Optional[float] = None):
self.ensure_registered_as_writer()
# No need to check timeout as the operation is non-blocking.
# Because both the reader and writer are in the same worker process,
# we can directly store the data in the context instead of storing
# it in the channel object. This removes the serialization overhead of `value`.
ctx = ChannelContext.get_current().serialization_context
ctx.set_data(self._channel_id, value, self._num_readers)
def read(self, timeout: Optional[float] = None, deserialize: bool = True) -> Any:
self.ensure_registered_as_reader()
assert deserialize, "Data passed from the actor to itself is never serialized"
# No need to check timeout as the operation is non-blocking.
ctx = ChannelContext.get_current().serialization_context
return ctx.get_data(self._channel_id)
def close(self) -> None:
ctx = ChannelContext.get_current().serialization_context
ctx.reset_data(self._channel_id)
@@ -0,0 +1,380 @@
import logging
from types import ModuleType
from typing import TYPE_CHECKING, Callable, List, Optional, Tuple
import ray
from ray.exceptions import RayChannelError
from ray.experimental.channel.accelerator_context import AcceleratorContext
from ray.experimental.channel.communicator import Communicator, TorchTensorAllocator
from ray.experimental.util.types import ReduceOp
if TYPE_CHECKING:
import torch
# Logger for this module. It should be configured at the entry point
# into the program using Ray. Ray provides a default configuration at
# entry/init points.
logger = logging.getLogger(__name__)
class _NcclGroup(Communicator):
"""
Represents an actor's NCCL communicator. This is the default NCCL communicator
to be used in Compiled Graph if a custom communicator is not provided.
This class is not thread-safe.
"""
def __init__(
self,
world_size: int,
comm_id: tuple,
rank: Optional[int],
actor_handles: List["ray.actor.ActorHandle"],
cuda_stream: Optional["torch.cuda.Stream"],
use_communication_streams: bool = False,
):
"""
Initialize a NCCL communicator that can be used to communicate p2p with
other GPU actors.
This method blocks until the same call has been made on all other
actors in the group, with the same arguments for world_size and
comm_id.
NOTE: A concurrent NCCL group can coexist with this one but using the
two groups concurrently on different CUDA streams may cause deadlock.
See
https://docs.nvidia.com/deeplearning/nccl/user-guide/docs/usage/communicators.html
#using-multiple-nccl-communicators-concurrently.
If the user can guarantee that all involved actors execute the same ops
in the same order, then the other NCCL group should use the given
`cuda_stream`, and there will not be a concurrency issue. Otherwise,
the other stream needs to synchronize with the given `cuda_stream`
before and after it launches NCCL ops, e.g., at the beginning and end
of a DAG task.
Args:
world_size: The number of participating actors/devices.
comm_id: A unique communicator ID returned by
cupy.cuda.nccl.get_unique_id().
rank: The rank of this actor. If None, then the caller is not a
participant of the NCCL group.
actor_handles: A list of actor handles, in rank order.
cuda_stream: A raw CUDA stream to dispatch NCCL ops to. If rank is
specified, then this must be specified too.
use_communication_streams: Whether to use dedicated send and recv
streams for communication. If True, communication and computation
can be overlapped to improve performance.
"""
self._world_size = world_size
self._rank: Optional[int] = rank
self.nccl_util: Optional[ModuleType] = None
self._actor_handles = actor_handles
self._use_communication_streams = use_communication_streams
if rank is not None:
assert ray.get_gpu_ids(), "NCCL actor has no GPUs assigned"
assert cuda_stream is not None, "NCCL actor must specify cuda_stream"
expected_rank = self.get_rank(ray.get_runtime_context().current_actor)
assert (
rank == expected_rank
), f"NCCL actor's rank {rank} does not match expected rank {expected_rank}"
from ray.util.collective.collective_group import nccl_util
self.nccl_util = nccl_util
self._comm = self.nccl_util.NcclCommunicator(world_size, comm_id, rank)
else:
# Driver does not have a rank.
self._comm = None
self._cuda_stream: Optional["torch.cuda.Stream"] = None
self._send_stream: Optional["torch.cuda.Stream"] = None
self._recv_stream: Optional["torch.cuda.Stream"] = None
if cuda_stream is not None:
assert rank is not None, "NCCL actor has no rank assigned"
self._cuda_stream = cuda_stream
if use_communication_streams:
import torch
# TODO(swang): Allow default device to be overridden.
device = AcceleratorContext.get().get_accelerator_devices()[0]
self._send_stream = torch.cuda.Stream(device=device)
self._recv_stream = torch.cuda.Stream(device=device)
else:
self._send_stream = self._cuda_stream
self._recv_stream = self._cuda_stream
self._closed = False
def initialize(self, rank: int) -> None:
# No additional initialization is needed.
pass
def get_actor_handles(self) -> List["ray.actor.ActorHandle"]:
return self._actor_handles
def get_rank(self, actor: ray.actor.ActorHandle) -> int:
"""Return the given actor's rank in the NCCL communicator.
Args:
actor: The actor handle to look up.
Returns:
The rank of ``actor`` within the NCCL group.
"""
actor_ids = [a._ray_actor_id for a in self._actor_handles]
try:
rank = actor_ids.index(actor._ray_actor_id)
except ValueError:
raise ValueError("Actor is not in the NCCL group.")
return rank
def get_self_rank(self) -> Optional[int]:
"""
Return this actor's rank.
"""
return self._rank
def get_world_size(self) -> int:
"""
Return the number of ranks in the NCCL communicator.
"""
return self._world_size
def send(self, buf: "torch.Tensor", peer_rank: int) -> None:
"""
Send a torch.Tensor to a peer.
This returns when the send kernel has been queued, but the kernel may
not have completed. Therefore, the caller should ensure that there are
no concurrent writes to the sent `buf` until the send has finished.
That is, either all writes should be submitted on the current stream
(self._cuda_stream) or, if on a different stream, that stream should
synchronize with the current stream.
Args:
buf: The torch.Tensor to send. It should already be on this
actor's default device.
peer_rank: The rank of the actor to send to.
"""
if self._closed:
raise RayChannelError("NCCL group has been destroyed.")
if self._use_communication_streams:
# We observed that if all recv/compute/send operations run on GPU,
# since there is no synchronization, the CPU execution loop may be
# far ahead of the GPU operations and lead to runtime failures.
# To avoid that, we synchronize on the send stream.
# TODO(rui): find a better approach
self._send_stream.synchronize()
# TODO(swang): Handle send/recv async NCCL errors such as network
# failures.
self._comm.send(
self.nccl_util.get_tensor_ptr(buf),
buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(buf),
peer_rank,
self._send_stream.cuda_stream,
)
def recv(
self,
shape: Tuple[int],
dtype: "torch.dtype",
peer_rank: int,
allocator: Optional[TorchTensorAllocator] = None,
) -> "torch.Tensor":
"""Receive a torch.Tensor from a peer and synchronize the current stream.
After this call returns, the receive buffer is safe to read from from
any stream. An RayChannelError will be raised if an error occurred (e.g.,
remote actor died), and the buffer is not safe to read.
Args:
shape: The shape of the tensor to receive.
dtype: The dtype of the tensor to receive.
peer_rank: The rank of the actor to receive from.
allocator: A function used to allocate the receive buffer.
Returns:
The tensor received from ``peer_rank``.
"""
if self._closed:
raise RayChannelError("NCCL group has been destroyed.")
assert allocator is not None, "NCCL group requires a tensor allocator"
buf = allocator(shape, dtype)
if self._use_communication_streams:
# We observed that if all recv/compute/send operations run on GPU,
# since there is no synchronization, the CPU execution loop may be
# far ahead of the GPU operations and lead to runtime failures.
# To avoid that, we synchronize on the recv stream.
# TODO(rui): find a better approach
self._recv_stream.synchronize()
self._comm.recv(
self.nccl_util.get_tensor_ptr(buf),
buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(buf),
peer_rank,
self._recv_stream.cuda_stream,
)
else:
self._comm.recv(
self.nccl_util.get_tensor_ptr(buf),
buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(buf),
peer_rank,
self._recv_stream.cuda_stream,
)
# Buffer values are undefined if NCCL ops are aborted. Therefore, we
# need to synchronize here and check that the channel is still open to
# ensure that the receive buffer is valid.
# TODO(swang): Avoid CUDA synchronization.
self._cuda_stream.synchronize()
if self._closed:
raise RayChannelError("NCCL group has been destroyed.")
return buf
def _exec_collective(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
operation: "Callable[..., None]",
*operation_args,
):
if self._closed:
raise RayChannelError("NCCL group has been destroyed.")
assert send_buf.dtype == recv_buf.dtype, (
"Ray Compiled Graph derived the dtype of recv_buf from send_buf, "
"so send_buf and recv_buf must have the same dtype. "
"If you see this error, please file an issue at Ray repository."
)
operation(*operation_args)
# Buffer values are undefined if NCCL ops are aborted. Therefore, we
# need to synchronize here and check that the channel is still open to
# ensure that the receive buffer is valid.
# TODO(swang): Avoid CUDA synchronization.
# TODO(wxdeng): This synchronize will be optional after merging the unify PR.
self._cuda_stream.synchronize()
if self._closed:
raise RayChannelError(
"NCCL group has been destroyed during allreduce operation. "
"There may be a dtype mismatch between input tensors from "
"different ranks."
)
def allgather(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
):
operation_args = [
self.nccl_util.get_tensor_ptr(send_buf),
self.nccl_util.get_tensor_ptr(recv_buf),
send_buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(send_buf),
self._cuda_stream.cuda_stream,
]
self._exec_collective(
send_buf,
recv_buf,
self._comm.allGather,
*operation_args,
)
def allreduce(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
):
operation_args = [
self.nccl_util.get_tensor_ptr(send_buf),
self.nccl_util.get_tensor_ptr(recv_buf),
send_buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(send_buf),
op.value,
self._cuda_stream.cuda_stream,
]
self._exec_collective(
send_buf,
recv_buf,
self._comm.allReduce,
*operation_args,
)
def reducescatter(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
):
operation_args = [
self.nccl_util.get_tensor_ptr(send_buf),
self.nccl_util.get_tensor_ptr(recv_buf),
recv_buf.numel(),
self.nccl_util.get_nccl_tensor_dtype(send_buf),
op.value,
self._cuda_stream.cuda_stream,
]
self._exec_collective(
send_buf,
recv_buf,
self._comm.reduceScatter,
*operation_args,
)
@property
def recv_stream(self):
import torch
return torch.cuda.StreamContext(self._recv_stream)
@property
def send_stream(self):
import torch
return torch.cuda.StreamContext(self._send_stream)
def destroy(self) -> None:
"""
Destroy the NCCL group.
"""
if self._closed:
return
self._closed = True
if self._comm is not None:
logger.info(
"Destructing NCCL group on actor: "
f"{ray.get_runtime_context().current_actor}"
)
# Abort *after* setting the _closed flag. This ensures that NCCL
# ops that were blocked on a remote peer will see that the _closed
# flag is True when they exit from the abort.
self._comm.abort()
self._comm.destroy()
def get_transport_name(self) -> str:
return "accelerator"
@classmethod
def generate_communicator_id(cls) -> str:
from cupy.cuda import nccl
return nccl.get_unique_id()
@@ -0,0 +1,231 @@
import warnings
from typing import TYPE_CHECKING, Any, Dict, List, Set, Tuple, Union
from ray.experimental.util.types import Device
if TYPE_CHECKING:
import numpy as np
import torch
_TORCH_WARNING_FILTER_ACTIVATE = True
class _SerializationContext:
def __init__(self):
# If true, then tensors found in the data to serialize are extracted
# and the caller should send them through an external transport.
self._use_external_transport: bool = False
# If _use_external_transport is True, then these are
# the tensors that should be sent or received
# out-of-band, through the external transport.
self._out_of_band_tensors: List["torch.Tensor"] = []
# During serialization, tensors sent out-of-band are replaced with
# integer placeholders. This tracks the set of placeholders seen.
self._deserialized_tensor_placeholders: Set[int] = set()
# Buffer for transferring data between tasks in the same worker process.
# The key is the channel ID, and the value is the data. We don't use a
# lock when reading/writing the buffer because a DAG node actor will only
# execute one task at a time in `do_exec_tasks`. It will not execute multiple
# Ray tasks on a single actor simultaneously.
self.intra_process_channel_buffers: Dict[str, Any] = {}
# The number of readers for each channel. When the number of readers
# reaches 0, remove the data from the buffer.
self.channel_id_to_num_readers: Dict[str, int] = {}
def set_target_device(self, device: Device) -> None:
self._target_device = device
def set_data(self, channel_id: str, value: Any, num_readers: int) -> None:
assert num_readers > 0, "num_readers must be greater than 0."
assert (
channel_id not in self.intra_process_channel_buffers
), f"Channel {channel_id} already exists in the buffer."
assert (
channel_id not in self.channel_id_to_num_readers
), f"Channel {channel_id} already exists in the channel_id_to_num_readers."
self.intra_process_channel_buffers[channel_id] = value
self.channel_id_to_num_readers[channel_id] = num_readers
def has_data(self, channel_id: str) -> bool:
return channel_id in self.intra_process_channel_buffers
def get_data(self, channel_id: str) -> Any:
assert (
channel_id in self.intra_process_channel_buffers
), f"Channel {channel_id} does not exist in the buffer."
assert (
channel_id in self.channel_id_to_num_readers
), f"Channel {channel_id} does not exist in the channel_id_to_num_readers."
self.channel_id_to_num_readers[channel_id] -= 1
if self.channel_id_to_num_readers[channel_id] == 0:
# All readers have read the data, so we can remove it.
self.channel_id_to_num_readers.pop(channel_id)
return self.intra_process_channel_buffers.pop(channel_id)
return self.intra_process_channel_buffers[channel_id]
def reset_data(self, channel_id: str) -> None:
self.intra_process_channel_buffers.pop(channel_id, None)
self.channel_id_to_num_readers.pop(channel_id, None)
def set_use_external_transport(self, use_external_transport: bool) -> None:
self._use_external_transport = use_external_transport
@property
def use_external_transport(self) -> bool:
return self._use_external_transport
def reset_out_of_band_tensors(
self, tensors: List["torch.Tensor"]
) -> Tuple[List["torch.Tensor"], Set[int]]:
"""
Return and reset the out-of-band tensors and all tensor placeholders
that were deserialized since the last call to reset.
"""
prev_tensors = self._out_of_band_tensors
deserialized_tensor_placeholders = self._deserialized_tensor_placeholders
self._out_of_band_tensors = tensors
self._deserialized_tensor_placeholders = set()
return prev_tensors, deserialized_tensor_placeholders
def serialize_tensor(
self, tensor: "torch.Tensor"
) -> Union[int, Tuple["np.ndarray", "torch.dtype", str]]:
from ray.experimental.channel import ChannelContext
ctx = ChannelContext.get_current()
if self._use_external_transport and (
ctx._torch_device is None or ctx._torch_device == tensor.device
):
# External transport is enabled and we found a tensor that matches
# our device. Add the actual tensor to a buffer. The buffer of
# tensors should later be popped by the caller and sent via
# external transport.
self._out_of_band_tensors.append(tensor)
# Return a placeholder.
return len(self._out_of_band_tensors) - 1
return self.serialize_to_numpy_or_scalar(tensor)
def serialize_to_numpy_or_scalar(
self, tensor: "torch.Tensor"
) -> Tuple[Union["np.ndarray", Any], "torch.dtype", str]:
"""
Serialize a tensor to a numpy array,
or a scalar when the tensor is 0-dim.
"""
import torch
tensor_device_type = tensor.device.type
# Transfer through Ray's shared memory store for now.
# TODO(swang): This requires two copies, one to transfer from GPU to
# CPU and another from CPU to shared memory. Ideally we should elide
# the first copy and memcpy directly from GPU to the shared memory
# buffer.
if tensor_device_type != "cpu":
tensor = tensor.to("cpu")
# Numpy does not have an equivalent dtype for all torch dtypes, so
# instead of casting directly to numpy:
# 1) for non-scalar tensors, we first use a view with a common dtype (uint8)
# and then view as numpy array.
# 2) for scalar tensors, we cannot use a uint8 view when the size differs,
# so we save the original item and type information.
if tensor.dim() > 0:
return (tensor.view(torch.uint8).numpy(), tensor.dtype, tensor_device_type)
else:
return (tensor.item(), tensor.dtype, tensor_device_type)
def deserialize_tensor(
self,
val: Union[Tuple["np.ndarray", "torch.dtype", str], int],
target_device: Device,
):
# Found a placeholder for a tensor that was serialized via accelerator.
# Replace it with the corresponding deserialized tensor.
if isinstance(val, int):
placeholder = val
self._deserialized_tensor_placeholders.add(placeholder)
assert placeholder < len(self._out_of_band_tensors), (
"placeholder",
placeholder,
"out_of_band_tensors",
self._out_of_band_tensors,
)
tensor = self._out_of_band_tensors[placeholder]
if target_device == Device.CPU:
tensor = tensor.to("cpu")
return tensor
np_array, dtype, tensor_device_type = val
return self.deserialize_from_numpy_or_scalar(
np_array, dtype, tensor_device_type, target_device
)
def deserialize_from_numpy_or_scalar(
self,
np_array: Union["np.ndarray", Any],
dtype: "torch.dtype",
tensor_device_type: str,
target_device: Device,
):
import numpy as np
import torch
if target_device == Device.DEFAULT:
target_device_type = tensor_device_type
elif target_device in [Device.GPU, Device.CUDA]:
target_device_type = "cuda"
else:
target_device_type = target_device.value
# TODO(swang): Support local P2P transfers if available.
if target_device_type != "cpu":
def convert_numpy_to_tensor(np_array):
if not isinstance(np_array, np.ndarray):
# For scalar tensors, create the 0-dim tensor.
return torch.tensor(
np_array, device=target_device_type, dtype=dtype
)
else:
# For non-scalar tensors, view as the original dtype.
# It does zero-copy convert np_array inside shared memory to
# a tensor. Since we move data to GPU immediately, it is safe.
cpu_tensor = torch.from_numpy(np_array).view(dtype)
return cpu_tensor.to(device=target_device_type)
global _TORCH_WARNING_FILTER_ACTIVATE
# filtering warning messages would be the bottleneck for
# deserializing torch tensors. Since the warning only prompts once,
# we would only deal with it for the first time.
if _TORCH_WARNING_FILTER_ACTIVATE:
with warnings.catch_warnings():
# Since np_array.is_writable is False (it is set by Ray),
# this raises a warning. Suppress it.
warnings.filterwarnings(
"ignore",
category=UserWarning,
message="The given NumPy array is not writable",
)
gpu_tensor = convert_numpy_to_tensor(np_array)
_TORCH_WARNING_FILTER_ACTIVATE = False
else:
gpu_tensor = convert_numpy_to_tensor(np_array)
return gpu_tensor
# TODO(swang): Use zero-copy from_numpy() if np_array.flags.writeable
# is True. This is safe to set when deserializing np_array if the
# upstream task has num_readers=1.
if not isinstance(np_array, np.ndarray):
# For scalar tensors, create the 0-dim tensor.
return torch.tensor(np_array, device=target_device_type, dtype=dtype)
else:
# For non-scalar tensors, view as the original dtype.
return torch.tensor(np_array, device=target_device_type).view(dtype)
@@ -0,0 +1,819 @@
import io
import logging
import time
from collections import defaultdict, namedtuple
from typing import Any, Dict, List, Optional, Set, Tuple, Union
import ray
import ray.exceptions
from ray._raylet import SerializedObject
from ray.experimental.channel import utils
from ray.experimental.channel.common import ChannelInterface, ChannelOutputType
from ray.experimental.channel.intra_process_channel import IntraProcessChannel
from ray.experimental.channel.utils import get_self_actor
from ray.util.annotations import DeveloperAPI, PublicAPI
# Logger for this module. It should be configured at the entry point
# into the program using Ray. Ray provides a default configuration at
# entry/init points.
logger = logging.getLogger(__name__)
def _create_channel_ref(
self: Any,
buffer_size_bytes: int,
) -> "ray.ObjectRef":
"""Create a channel that can be read and written through Ray's shared-memory
object store.
The channel has no buffer, so the writer will block until reader(s) have
read the previous value.
A writer and colocated readers can communicate via a shared memory buffer.
If the readers are remote, then RPC is used to synchronize the writer and
readers' buffers.
Args:
self: The actor on which to allocate the channel buffer. Passed via
``__ray_call__`` so this function executes on the actor.
buffer_size_bytes: The initial buffer size in bytes for messages
that can be passed between tasks in the DAG. The buffers will
be automatically resized if larger messages are written to the
channel.
Returns:
A wrapper around ``ray.ObjectRef`` backing the channel.
"""
worker = ray._private.worker.global_worker
worker.check_connected()
value = b"0" * buffer_size_bytes
try:
object_ref = worker.put_object(value, _is_experimental_channel=True)
except ray.exceptions.ObjectStoreFullError:
logger.info(
"Put failed since the value was either too large or the "
"store was full of pinned objects."
)
raise
return object_ref
# Compiled Graph maintains 1 reader object reference (also called buffer) per node.
# reader_ref: The object reference.
# ref_owner_actor_id: The actor who created the object reference.
# num_readers: The number of reader actors who reads this object reference.
ReaderRefInfo = namedtuple(
"ReaderRefInfo", ["reader_ref", "ref_owner_actor_id", "num_reader_actors"]
)
class _ResizeChannel:
"""Sentinel used to resize a channel's backing store on the readers.
When a channel must be resized, the channel backing store must be resized on both
the writer and the reader nodes. The writer first resizes its own backing store. The
writer then uses an instance of this class as a sentinel value to tell the reader to
resize its own backing store. The class instance is sent through the channel.
"""
def __init__(
self,
_node_id_to_reader_ref_info: Dict[str, ReaderRefInfo],
):
"""Initialize the resize sentinel.
Args:
_node_id_to_reader_ref_info: Mapping from node id to ``ReaderRefInfo``
describing the new reader buffers per node.
"""
self._node_id_to_reader_ref_info = _node_id_to_reader_ref_info
class SharedMemoryType(ChannelOutputType):
def __init__(
self,
*,
buffer_size_bytes: Optional[int] = None,
num_shm_buffers: Optional[int] = None,
):
"""Initialize a ``SharedMemoryType``.
Args:
buffer_size_bytes: The initial buffer size in bytes for messages
that can be passed between tasks in the DAG. The buffers will
be automatically resized if larger messages are written to the
channel.
num_shm_buffers: The number of shared memory buffers per channel.
Note: In the case of multiple nodes, we only support 1 shared
memory buffer.
"""
super().__init__()
from ray.dag import DAGContext
ctx = DAGContext.get_current()
if buffer_size_bytes is None:
buffer_size_bytes = ctx.buffer_size_bytes
self.buffer_size_bytes = buffer_size_bytes
if num_shm_buffers is None:
num_shm_buffers = 1
self._num_shm_buffers = num_shm_buffers
def create_channel(
self,
writer: Optional["ray.actor.ActorHandle"],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
driver_actor_id: Optional[str] = None,
) -> "Channel":
"""
Instantiate a ChannelInterface class that can be used
to pass data of this type.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
driver_actor_id: If this channel is read by a driver and that driver is an
actual actor, this will be the actor ID of that driver actor.
Returns:
A ChannelInterface that can be used to pass data
of this type.
"""
return CompositeChannel(
writer,
reader_and_node_list,
self._num_shm_buffers,
driver_actor_id,
)
@PublicAPI(stability="alpha")
class Channel(ChannelInterface):
"""
A wrapper type for ray.ObjectRef. Currently supports ray.get but not
ray.wait.
"""
def __init__(
self,
writer: Optional[ray.actor.ActorHandle],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
typ: Optional[Union[int, SharedMemoryType]] = None,
_writer_node_id: Optional["ray.NodeID"] = None,
_writer_ref: Optional["ray.ObjectRef"] = None,
_node_id_to_reader_ref_info: Optional[Dict[str, ReaderRefInfo]] = None,
_writer_registered: bool = False,
_reader_registered: bool = False,
):
"""Create a channel that can be read and written by co-located Ray processes.
Anyone may write to or read from the channel. The channel has no
buffer, so the writer will block until reader(s) have read the previous
value.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
typ: Type information about the values passed through the channel.
Either an integer representing the max buffer size in bytes
allowed, or a SharedMemoryType.
_writer_node_id: Internal. Node ID hosting the writer. Provided
when rehydrating a channel that was constructed on another
process.
_writer_ref: Internal. Pre-existing writer-side ``ObjectRef``.
When set, the constructor skips allocating a new writer buffer.
_node_id_to_reader_ref_info: Internal. Mapping from node id to
``ReaderRefInfo`` describing existing reader buffers per node.
_writer_registered: Internal. Whether the writer side has already
been registered with the core worker.
_reader_registered: Internal. Whether the reader side has already
been registered with the core worker.
"""
assert len(reader_and_node_list) > 0
for reader, _ in reader_and_node_list:
assert isinstance(reader, ray.actor.ActorHandle)
if typ is None:
typ = SharedMemoryType()
elif isinstance(typ, int):
typ = SharedMemoryType(buffer_size_bytes=typ)
# The min buffer size must be large enough to at least fit an instance of the
# _ResizeChannel class along with any metadata.
MIN_BUFFER_SIZE = int(1000) # 1000 bytes
if typ.buffer_size_bytes < MIN_BUFFER_SIZE:
raise ValueError(
"typ.buffer_size_bytes must be at least MIN_BUFFER_SIZE "
f"({MIN_BUFFER_SIZE} bytes)"
)
self._writer = writer
self._reader_and_node_list = reader_and_node_list
self._typ = typ
self._worker = ray._private.worker.global_worker
self._worker.check_connected()
self._writer_registered = _writer_registered
self._reader_registered = _reader_registered
# NodeID -> ReaderRefInfo on that node. Note that there's only 1
# reader ref per node.
self._node_id_to_reader_ref_info: Dict[str, ReaderRefInfo] = (
_node_id_to_reader_ref_info or {}
)
# Node ID -> a list of reader actors.
self._node_id_to_readers: Dict[str, "ray.actor.ActorHandle"] = defaultdict(list)
for reader, node_id in self._reader_and_node_list:
self._node_id_to_readers[node_id].append(reader)
# Number of readers in a local node.
self._num_local_readers = 0
if _writer_ref is None:
# We are the writer. Check that the passed handle matches the
# current actor (or it is the driver).
# TODO(swang): Channels must be initially constructed by the writer
# actor, so we shouldn't need to include `writer` in the
# constructor args. Either support Channels being constructed by
# someone other than the writer or remove it from the args.
self_actor = get_self_actor()
assert writer == self_actor
self._writer_node_id = (
ray.runtime_context.get_runtime_context().get_node_id()
)
self._writer_ref = _create_channel_ref(self, typ.buffer_size_bytes)
self._create_reader_refs(typ.buffer_size_bytes)
else:
assert (
_writer_node_id is not None
), "_writer_node_id must also be passed to the constructor when "
"_writer_ref is."
assert _node_id_to_reader_ref_info is not None, (
"_node_id_to_reader_ref_info must also be passed to the constructor "
"when _writer_ref is."
)
self._writer_ref = _writer_ref
self._writer_node_id = _writer_node_id
self._node_id_to_reader_ref_info = _node_id_to_reader_ref_info
assert self._num_local_readers == 0
remote_node_exists = False
for node_id, readers in self._node_id_to_readers.items():
if self.is_local_node(node_id):
self._num_local_readers += len(readers)
else:
remote_node_exists = True
# If remote node exists, we have 1 additional reader that listens
# to object changes and push them to remote nodes.
if remote_node_exists:
self._num_local_readers += 1
# There must be at least 1 local reader
assert self._num_local_readers > 0
self._local_reader_ref: Optional["ray.ObjectRef"] = self._get_local_reader_ref(
self._node_id_to_reader_ref_info
)
def _get_local_reader_ref(
self, _node_id_to_reader_ref_info: Dict[str, ReaderRefInfo]
) -> Optional["ray.ObjectRef"]:
for node_id, reader_ref_info in _node_id_to_reader_ref_info.items():
if self.is_local_node(node_id):
return reader_ref_info.reader_ref
return None
def _create_reader_refs(
self,
buffer_size_bytes: int,
):
# TODO(jhumphri): Free the current reader ref once the reference to it is
# destroyed below.
for node_id, readers in self._node_id_to_readers.items():
if not self.is_local_node(node_id):
# Find 1 reader in a remote node to create a reference that's
# shared by all readers. When a new value is written to a reference,
# it is sent to this reference.
reader = readers[0]
fn = reader.__ray_call__
self._node_id_to_reader_ref_info[node_id] = ReaderRefInfo(
reader_ref=ray.get(
fn.remote(_create_channel_ref, buffer_size_bytes)
),
ref_owner_actor_id=reader._actor_id,
num_reader_actors=len(readers),
)
else:
writer_id = ray.ActorID.nil()
if self._writer is not None:
writer_id = self._writer._actor_id
self._node_id_to_reader_ref_info[node_id] = ReaderRefInfo(
reader_ref=self._writer_ref,
ref_owner_actor_id=writer_id,
num_reader_actors=len(readers),
)
# There must be only 1 node reader reference per node.
assert len(self._node_id_to_reader_ref_info) == len(self._node_id_to_readers)
# We need to register the new writer_ref.
self._writer_registered = False
self.ensure_registered_as_writer()
@staticmethod
def is_local_node(node_id):
return ray.runtime_context.get_runtime_context().get_node_id() == node_id
def ensure_registered_as_writer(self) -> None:
if self._writer_registered:
return
if not self.is_local_node(self._writer_node_id):
raise ValueError(
"`ensure_registered_as_writer()` must only be called on the node that "
"the writer is on."
)
remote_reader_ref_info: Dict[str, ReaderRefInfo] = {}
for node_id, reader_ref_info in self._node_id_to_reader_ref_info.items():
if self.is_local_node(node_id):
continue
remote_reader_ref_info[node_id] = reader_ref_info
self._worker.core_worker.experimental_channel_register_writer(
self._writer_ref,
remote_reader_ref_info,
)
self._writer_registered = True
def ensure_registered_as_reader(self) -> None:
if self._reader_registered:
return
for node_id, reader_ref_info in self._node_id_to_reader_ref_info.items():
if self.is_local_node(node_id):
self._worker.core_worker.experimental_channel_register_reader(
reader_ref_info.reader_ref,
)
self._reader_registered = True
@staticmethod
def _deserialize_reader_channel(
writer: ray.actor.ActorHandle,
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
typ: int,
writer_node_id,
writer_ref: "ray.ObjectRef",
node_id_to_reader_ref_info: Dict[str, ReaderRefInfo],
writer_registered: bool,
reader_registered: bool,
) -> "Channel":
chan = Channel(
writer,
reader_and_node_list,
typ,
_writer_node_id=writer_node_id,
_writer_ref=writer_ref,
_node_id_to_reader_ref_info=node_id_to_reader_ref_info,
_writer_registered=writer_registered,
_reader_registered=reader_registered,
)
return chan
def __reduce__(self):
assert self._node_id_to_reader_ref_info is not None
return self._deserialize_reader_channel, (
self._writer,
self._reader_and_node_list,
self._typ,
self._writer_node_id,
self._writer_ref,
self._node_id_to_reader_ref_info,
self._writer_registered,
self._reader_registered,
)
def __str__(self) -> str:
return (
f"Channel(_node_id_to_reader_ref_info={self._node_id_to_reader_ref_info}, "
f"_writer_ref={self._writer_ref})"
)
def _resize_channel_if_needed(self, serialized_value: str, timeout_ms: int):
# serialized_value.total_bytes *only* includes the size of the data. It does not
# include the size of the metadata, so we must account for the size of the
# metadata explicitly.
size = serialized_value.total_bytes + len(serialized_value.metadata)
if size > self._typ.buffer_size_bytes:
# Now make the channel backing store larger.
self._typ.buffer_size_bytes = size
# TODO(jhumphri): Free the current writer ref once the reference to it is
# destroyed below.
# TODO(sang): Support different policies such as 2X buffer size.
prev_writer_ref = self._writer_ref
self._writer_ref = _create_channel_ref(self, self._typ.buffer_size_bytes)
self._create_reader_refs(self._typ.buffer_size_bytes)
self._local_reader_ref = self._get_local_reader_ref(
self._node_id_to_reader_ref_info
)
# Write a special message to the channel so that the readers know to
# stop using the current reader_ref.
special_message = _ResizeChannel(self._node_id_to_reader_ref_info)
special_message_serialized = (
self._worker.get_serialization_context().serialize(special_message)
)
self._worker.core_worker.experimental_channel_put_serialized(
special_message_serialized,
prev_writer_ref,
self._num_local_readers,
timeout_ms,
)
# TODO(sang): Clean the previous ref that won't be used.
# Right now, if we just close it here, it will not work because
# of race conditions.
# self._worker.core_worker.experimental_channel_set_error(
# prev_writer_ref
# )
def write(self, value: Any, timeout: Optional[float] = None) -> None:
self.ensure_registered_as_writer()
assert (
timeout is None or timeout >= 0 or timeout == -1
), "Timeout must be non-negative or -1."
# -1 means no timeout (block indefinitely)
timeout_ms = int(timeout * 1000) if timeout is not None else -1
if not isinstance(value, SerializedObject):
try:
serialized_value = self._worker.get_serialization_context().serialize(
value
)
except TypeError as e:
sio = io.StringIO()
ray.util.inspect_serializability(value, print_file=sio)
msg = (
"Could not serialize the put value "
f"{repr(value)}:\n"
f"{sio.getvalue()}"
)
raise TypeError(msg) from e
else:
serialized_value = value
start_time = time.monotonic()
self._resize_channel_if_needed(serialized_value, timeout_ms)
if timeout is not None:
timeout_ms -= int((time.monotonic() - start_time) * 1000)
timeout_ms = max(timeout_ms, 0)
self._worker.core_worker.experimental_channel_put_serialized(
serialized_value,
self._writer_ref,
self._num_local_readers,
timeout_ms,
)
def read(self, timeout: Optional[float] = None) -> Any:
assert (
timeout is None or timeout >= 0 or timeout == -1
), "Timeout must be non-negative or -1."
self.ensure_registered_as_reader()
start_time = time.monotonic()
ret = self._worker.get_objects(
[self._local_reader_ref], timeout=timeout, return_exceptions=True
)[0][0]
if isinstance(ret, _ResizeChannel):
self._node_id_to_reader_ref_info = ret._node_id_to_reader_ref_info
self._local_reader_ref = self._get_local_reader_ref(
self._node_id_to_reader_ref_info
)
# We need to register the new reader_ref.
self._reader_registered = False
self.ensure_registered_as_reader()
if timeout is not None:
timeout -= time.monotonic() - start_time
timeout = max(timeout, 0)
ret = self._worker.get_objects(
[self._local_reader_ref], timeout=timeout, return_exceptions=True
)[0][0]
return ret
def release_buffer(self, timeout: Optional[float] = None) -> None:
assert (
timeout is None or timeout >= 0 or timeout == -1
), "Timeout must be non-negative or -1."
self.ensure_registered_as_reader()
self._worker.get_objects(
[self._local_reader_ref],
timeout=timeout,
return_exceptions=True,
skip_deserialization=True,
)
def close(self) -> None:
"""
Close this channel by setting the error bit on both the writer_ref and the
reader_ref.
"""
self._worker.core_worker.experimental_channel_set_error(self._writer_ref)
is_local_node_reader = False
for node_id in self._node_id_to_readers.keys():
if self.is_local_node(node_id):
is_local_node_reader = True
if is_local_node_reader:
self.ensure_registered_as_reader()
for reader_ref_info in self._node_id_to_reader_ref_info.values():
self._worker.core_worker.experimental_channel_set_error(
reader_ref_info.reader_ref
)
@DeveloperAPI
class BufferedSharedMemoryChannel(ChannelInterface):
"""A channel that can be read and written by Ray processes.
It creates `num_shm_buffers` number of buffers and allows buffered read and
write APIs. I.e., read and write APIs are non-blocking as long as it can write to
next buffer or read from a next buffer. See `read` and `write` APIs for
more details.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located. Note that currently
we only support this for readers on the same node as the writer.
num_shm_buffers: Number of shared memory buffers to read/write.
typ: Type information about the values passed through the channel.
Either an integer representing the max buffer size in bytes
allowed, or a SharedMemoryType.
"""
def __init__(
self,
writer: Optional[ray.actor.ActorHandle],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
num_shm_buffers: int,
typ: Optional[Union[int, SharedMemoryType]] = None,
):
self._num_shm_buffers = num_shm_buffers
self._buffers = [
# We use Channel directly as a buffer implementation as
# channel only allows to have 1 shared memory buffer.
Channel(writer, reader_and_node_list, typ)
for _ in range(num_shm_buffers)
]
# The next index to write from self._buffers.
self._next_write_index = 0
# The next index to read from self._buffers.
self._next_read_index = 0
def ensure_registered_as_writer(self):
"""
Check whether the process is a valid writer. This method must be idempotent.
"""
for buffer in self._buffers:
buffer.ensure_registered_as_writer()
def ensure_registered_as_reader(self):
"""
Check whether the process is a valid reader. This method must be idempotent.
"""
for buffer in self._buffers:
buffer.ensure_registered_as_reader()
def write(self, value: Any, timeout: Optional[float] = None) -> None:
"""Write a value to a channel.
If the next buffer is available, it returns immediately. If the next
buffer is not read by downstream consumers, it blocks until a buffer is
available to write. If a buffer is not available within timeout, it raises
RayChannelTimeoutError.
"""
self.ensure_registered_as_writer()
# A single channel is not supposed to read and write at the same time.
assert self._next_read_index == 0
self._buffers[self._next_write_index].write(value, timeout)
self._next_write_index += 1
self._next_write_index %= self._num_shm_buffers
def read(self, timeout: Optional[float] = None) -> Any:
"""Read a value from a channel.
If the next buffer is available, it returns immediately. If the next
buffer is not written by an upstream producer, it blocks until a buffer is
available to read. If a buffer is not available within timeout, it raises
RayChannelTimeoutError.
"""
self.ensure_registered_as_reader()
# A single channel is not supposed to read and write at the same time.
assert self._next_write_index == 0
output = self._buffers[self._next_read_index].read(timeout)
self._next_read_index += 1
self._next_read_index %= self._num_shm_buffers
return output
def release_buffer(self, timeout: Optional[float] = None):
"""Release the native buffer of the channel to allow the buffer to be reused for
future data.
If the next buffer is available, it returns immediately. If the next
buffer is not written by an upstream producer, it blocks until a buffer is
available to be released. If a buffer is not available within timeout, it raises
RayChannelTimeoutError.
"""
# A single channel is not supposed to read and write at the same time.
assert self._next_write_index == 0
self._buffers[self._next_read_index].release_buffer(timeout)
self._next_read_index += 1
self._next_read_index %= self._num_shm_buffers
def close(self) -> None:
for buffer in self._buffers:
buffer.close()
@property
def next_write_index(self):
# Testing only
return self._next_write_index
@property
def next_read_index(self):
# Testing only
return self._next_read_index
@PublicAPI(stability="alpha")
class CompositeChannel(ChannelInterface):
"""Routes data to different readers via per-locality channels.
For example, if the reader is in the same worker process as the writer,
the data can be sent via IntraProcessChannel. If the reader is in a different
worker process, the data can be sent via shared memory channel.
"""
def __init__(
self,
writer: Optional[ray.actor.ActorHandle],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
num_shm_buffers: int,
driver_actor_id: Optional[str] = None,
_channel_dict: Optional[Dict[ray.ActorID, ChannelInterface]] = None,
_channels: Optional[Set[ChannelInterface]] = None,
_writer_registered: bool = False,
_reader_registered: bool = False,
):
"""Initialize a ``CompositeChannel``.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
num_shm_buffers: The number of shared memory buffers per channel.
Note: In the case of multiple nodes, we only support 1 shared
memory buffer.
driver_actor_id: If this channel is read by a driver and that driver is an
actual actor, this will be the actor ID of that driver actor.
_channel_dict: Internal. Pre-populated mapping from actor id to
the underlying channel. When provided, channels are not
re-created (used during deserialization).
_channels: Internal. Deduplicated set of channels backing
``_channel_dict``. When provided, channels are not re-created.
_writer_registered: Internal. Whether the writer side has already
been registered with the core worker.
_reader_registered: Internal. Whether the reader side has already
been registered with the core worker.
"""
self._writer = writer
self._reader_and_node_list = reader_and_node_list
self._num_shm_buffers = num_shm_buffers
self._driver_actor_id = driver_actor_id
self._writer_registered = _writer_registered
self._reader_registered = _reader_registered
# A dictionary that maps the actor ID to the channel object.
self._channel_dict = _channel_dict or {}
# The set of channels is a deduplicated version of the _channel_dict values.
self._channels = _channels or set()
if self._channels:
# This CompositeChannel object is created by deserialization.
# We don't need to create channels again.
return
(
remote_reader_and_node_list,
local_reader_and_node_list,
) = utils.split_readers_by_locality(self._writer, self._reader_and_node_list)
# There are some local readers which are the same worker process as the writer.
# Create a local channel for the writer and the local readers.
num_local_readers = len(local_reader_and_node_list)
if num_local_readers > 0:
# Use num_readers = 1 when creating the local channel,
# because we have channel cache to support reading
# from the same channel multiple times.
local_channel = IntraProcessChannel(num_readers=1)
self._channels.add(local_channel)
actor_id = self._get_actor_id(self._writer)
self._channel_dict[actor_id] = local_channel
# There are some remote readers which are not the same Ray actor as the writer.
# We create a BufferedSharedMemoryChannel for readers on the same node, and
# a single Channel for readers on different nodes due to
# https://github.com/ray-project/ray/issues/49044
(
readers_same_node,
readers_different_node,
) = utils.split_actors_by_node_locality(
utils.get_actor_node(self._writer), remote_reader_and_node_list
)
if len(readers_same_node) != 0:
remote_channel = BufferedSharedMemoryChannel(
self._writer, readers_same_node, num_shm_buffers
)
self._channels.add(remote_channel)
for reader, _ in readers_same_node:
actor_id = self._get_actor_id(reader)
self._channel_dict[actor_id] = remote_channel
if len(readers_different_node) != 0:
remote_channel = Channel(self._writer, readers_different_node)
self._channels.add(remote_channel)
for reader, _ in readers_different_node:
actor_id = self._get_actor_id(reader)
self._channel_dict[actor_id] = remote_channel
def _get_actor_id(self, reader: ray.actor.ActorHandle) -> str:
return reader._actor_id.hex()
def ensure_registered_as_writer(self) -> None:
if self._writer_registered:
return
for channel in self._channels:
channel.ensure_registered_as_writer()
self._writer_registered = True
def ensure_registered_as_reader(self) -> None:
if self._reader_registered:
return
for channel in self._channels:
channel.ensure_registered_as_reader()
self._reader_registered = True
def __reduce__(self):
return CompositeChannel, (
self._writer,
self._reader_and_node_list,
self._num_shm_buffers,
self._driver_actor_id,
self._channel_dict,
self._channels,
self._writer_registered,
self._reader_registered,
)
def __str__(self) -> str:
return (
"CompositeChannel(_channels="
f"{[str(channel) for channel in self._channels]})"
)
def write(self, value: Any, timeout: Optional[float] = None) -> None:
self.ensure_registered_as_writer()
for channel in self._channels:
channel.write(value, timeout)
def read(self, timeout: Optional[float] = None) -> Any:
self.ensure_registered_as_reader()
return self._channel_dict[self._resolve_actor_id()].read(timeout)
def release_buffer(self, timeout: Optional[float] = None):
self.ensure_registered_as_reader()
self._channel_dict[self._resolve_actor_id()].release_buffer(timeout)
def _resolve_actor_id(self) -> str:
actor_id = ray.get_runtime_context().get_actor_id()
# If actor_id is None, read was called by the driver
# If the driver is an actor, driver_actor_id will be set to that actor id
if actor_id is None or actor_id == self._driver_actor_id:
# Use the actor ID of the DAGDriverProxyActor.
# The proxy actor is always the first actor in the reader_and_node_list.
assert len(self._reader_and_node_list) >= 1
driver_proxy_actor = self._reader_and_node_list[0][0]
actor_id = self._get_actor_id(driver_proxy_actor)
return actor_id
def close(self) -> None:
for channel in self._channels:
channel.close()
@@ -0,0 +1,882 @@
import io
import logging
import uuid
from dataclasses import dataclass
from types import ModuleType
from typing import TYPE_CHECKING, Any, List, Optional, Tuple, Type, Union
import ray
import ray.util.serialization
from ray.experimental.channel import ChannelContext, utils
from ray.experimental.channel.accelerator_context import (
AcceleratorContext,
is_accelerator_context_registered,
register_accelerator_context,
)
from ray.experimental.channel.common import ChannelInterface
from ray.experimental.channel.communicator import Communicator
from ray.experimental.channel.communicator_handle import CommunicatorHandle
from ray.experimental.channel.cpu_communicator import CPUCommunicator
from ray.experimental.channel.intra_process_channel import IntraProcessChannel
from ray.experimental.channel.shared_memory_channel import SharedMemoryType
from ray.experimental.channel.torch_tensor_type import TorchTensorType
from ray.util.annotations import DeveloperAPI
if TYPE_CHECKING:
import torch
from ray.experimental.channel.shared_memory_channel import Channel
# Logger for this module. It should be configured at the entry point
# into the program using Ray. Ray provides a default configuration at
# entry/init points.
logger = logging.getLogger(__name__)
@dataclass
class _TorchTensorMetadata:
"""
Metadata for torch.Tensors that can be sent between processes to determine
how large of a buffer to allocate on the receiver(s).
"""
shape: Union[int, Tuple[int]]
dtype: "torch.dtype"
@DeveloperAPI
class TorchTensorAcceleratorChannel(ChannelInterface):
def __init__(
self,
writer: ray.actor.ActorHandle,
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
typ: "TorchTensorType",
driver_actor_id: str,
tensor_metadata_channel: Optional["Channel"] = None,
_cpu_data_channel: Optional["Channel"] = None,
_gpu_data_channel: Optional["_TorchTensorAcceleratorChannel"] = None,
_local_channel: Optional["IntraProcessChannel"] = None,
):
"""
Can be used to send accelerator tensors nested inside other data. The data is
sent via shared memory while the accelerator tensors are sent through a P2P
transport (e.g., NCCL for GPU).
NOTE: This class is currently not thread-safe because it reads and
writes the worker-local
ray.experimental.channel.serialization_context._SerializationContext
when serializing data.
Args:
writer: The actor that may write to the channel. None signifies the
driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
typ: Type information about the values passed through the channel.
driver_actor_id: The actor ID of the DAGDriverProxyActor.
tensor_metadata_channel: A shared-memory channel for sending tensor
metadata.
_cpu_data_channel: A shared-memory channel for sending
non-tensor data. Its writer and readers should match the given
writer and readers. If None is provided, then we assume that
there is no CPU-specific data, i.e. the task directly returned
a CUDA torch.Tensor.
_gpu_data_channel: A channel for sending torch.Tensors via accelerator.
_local_channel: A channel for sending data between the writer and
local readers.
NOTE: `tensor_metadata_channel` will be set only for testing purposes.
`_cpu_data_channel` is set for testing purposes and for deserialization.
`_gpu_data_channel` and `_local_channel` are set only during deserialization.
"""
self._writer = writer
self._reader_and_node_list = reader_and_node_list
self._typ = typ
(
remote_reader_and_node_list,
local_reader_and_node_list,
) = utils.split_readers_by_locality(self._writer, self._reader_and_node_list)
num_local_readers = len(local_reader_and_node_list)
self._local_channel = _local_channel
if self._local_channel is None and num_local_readers > 0:
# There are some local readers which are the same worker process as
# the writer. Create a local channel for the writer and the local readers.
#
# Use num_readers = 1 when creating the local channel,
# because we have channel cache to support reading
# from the same channel multiple times.
self._local_channel = IntraProcessChannel(num_readers=1)
assert len(remote_reader_and_node_list) > 0, (
"All readers are from the same actor. "
"The TorchTensorType type hint is not needed. "
"No accelerator channel will be created."
)
self._gpu_data_channel = _gpu_data_channel
if self._gpu_data_channel is None:
self._gpu_data_channel: _TorchTensorAcceleratorChannel = (
_TorchTensorAcceleratorChannel(
writer,
remote_reader_and_node_list,
typ,
_meta_channel=tensor_metadata_channel,
)
)
self._cpu_data_channel: Optional["Channel"] = _cpu_data_channel
if self._cpu_data_channel is not None:
assert (
not self._typ.direct_return
), "CPU channel should be None if direct return is enabled"
if self._cpu_data_channel is None and not self._typ.direct_return:
# Create a CPU channel to send non-tensor data.
self._cpu_data_channel = SharedMemoryType().create_channel(
writer, remote_reader_and_node_list, driver_actor_id
)
# Used for serialization.
self._worker = ray._private.worker.global_worker
self._worker.check_connected()
ctx = ChannelContext.get_current()
self.serialization_ctx = ctx.serialization_context
assert self.serialization_ctx is not None
def __reduce__(self):
return (
TorchTensorAcceleratorChannel,
(
self._writer,
self._reader_and_node_list,
self._typ,
# driver_actor_id and tensor_metadata_channel are used to initialize
# the _cpu_data_channel and _gpu_data_channel, so we don't need to
# pass them in here.
None,
None,
self._cpu_data_channel,
self._gpu_data_channel,
self._local_channel,
),
)
def ensure_registered_as_writer(self):
if self._local_channel is not None:
self._local_channel.ensure_registered_as_writer()
self._gpu_data_channel.ensure_registered_as_writer()
if self._cpu_data_channel is not None:
self._cpu_data_channel.ensure_registered_as_writer()
def ensure_registered_as_reader(self):
reader = utils.get_self_actor()
if reader == self._writer:
self._local_channel.ensure_registered_as_reader()
return
self._gpu_data_channel.ensure_registered_as_reader()
if self._cpu_data_channel is not None:
self._cpu_data_channel.ensure_registered_as_reader()
def _send_cpu_and_gpu_data(self, value: Any, timeout: Optional[float]):
self.serialization_ctx.reset_out_of_band_tensors([])
# All tensors found in `value` will be transferred via accelerator.
self.serialization_ctx.set_use_external_transport(True)
try:
# Serialize the data. All tensors that match our current device
# will be extracted into the serialization context and replaced
# with a placeholder.
cpu_data = self._worker.get_serialization_context().serialize(value)
except TypeError as e:
sio = io.StringIO()
ray.util.inspect_serializability(value, print_file=sio)
msg = (
"Could not serialize the put value "
f"{repr(value)}:\n"
f"{sio.getvalue()}"
)
raise TypeError(msg) from e
finally:
# Pop the tensors that were found during serialization of `value`.
gpu_tensors, _ = self.serialization_ctx.reset_out_of_band_tensors([])
# Reset the serialization method to now serialize torch.Tensors
# normally.
self.serialization_ctx.set_use_external_transport(False)
# First send the extracted tensors through a GPU-specific channel.
self._gpu_data_channel.write(gpu_tensors)
# Next send the non-tensor data through a CPU-specific channel. The
# data contains placeholders for the extracted tensors.
self._cpu_data_channel.write(cpu_data)
def write(self, value: Any, timeout: Optional[float] = None) -> None:
"""
Send a value that may contain torch.Tensors that should be sent via
external transport.
Case 1: Use `_local_channel` to send the data to local readers.
Case 2: Otherwise, use the following method to send the data to remote readers.
1) Serializes `value`. During serialization, all torch.Tensors that are
on the default device are extracted and replaced with a unique
placeholder. Thus, the serialized value will contain all non-tensor
data, and any tensors that were not on the default device (e.g., CPU
tensor returned by a GPU actor).
2) Sends extracted torch.Tensors via the tensor data channel (e.g.,
NCCL).
3) Sends the non-tensor data via the non-tensor data channel.
If static_non_tensor_data=True was specified, then we only perform step
(3) on the first `write` call. The reader is expected to reuse the sent
data for subsequent messages.
"""
self.ensure_registered_as_writer()
if self._local_channel is not None:
self._local_channel.write(value)
if isinstance(value, ray.exceptions.RayTaskError):
if self._typ.static_shape or self._typ.direct_return:
# Raise a fatal error to teardown the DAG.
# This error will also be caught from `CompiledDAGRef.get()`
# and raised to the user
# TODO(swang): Write exceptions to the tensor metadata or
# non-tensor data channel if it is available to make these
# exceptions recoverable.
raise value
if self._cpu_data_channel is None:
# Handle the case where _direct_return=True. In this case, we check
# that the task returned a CUDA torch.Tensor and just send it
# directly without trying to serialize it first.
import torch
# These ValueErrors will also be caught from `CompiledDAGRef.get()`
# and raised to the user
if not isinstance(value, torch.Tensor):
# TODO(swang): These errors are currently fatal for the DAG.
# This could be improved by sending the exception through the
# gpu_data_channel's CPU-based metadata channel, if one exists.
raise ValueError(
"Task annotated with _direct_return=True must "
"return a CUDA torch.Tensor, instead found value "
f"`{value}`. DAG will shut down."
)
elif not value.is_cuda:
raise ValueError(
"Task annotated with _direct_return=True must "
"return a CUDA torch.Tensor, instead found CPU tensor. "
"DAG will shut down."
)
self._gpu_data_channel.write([value], timeout=timeout)
else:
self._send_cpu_and_gpu_data(value, timeout)
def _recv_cpu_and_gpu_data(
self, tensors: List["torch.Tensor"], timeout: Optional[float] = None
) -> Any:
"""Helper method to receive data that contains a mix of CPU and GPU data.
Args:
tensors: The GPU data. This is a list of the torch.Tensors that
were found in the sent data.
timeout: Timeout for channel receive.
Returns:
The deserialized non-tensor data with tensor placeholders replaced
by the entries of ``tensors``.
"""
self.serialization_ctx.reset_out_of_band_tensors(tensors)
# Next, read and deserialize the non-tensor data. The registered custom
# deserializer will replace the found tensor placeholders with
# `tensors`.
data = self._cpu_data_channel.read(
timeout=timeout,
)
# Check that all placeholders had a corresponding tensor.
(
_,
deserialized_tensor_placeholders,
) = self.serialization_ctx.reset_out_of_band_tensors([])
assert deserialized_tensor_placeholders == set(range(len(tensors)))
return data
def read(self, timeout: Optional[float] = None) -> Any:
"""
Read a value that may contain torch.Tensors sent via external
transport.
Case 1: If the reader is a local reader and is the same actor as the writer,
then use the `_local_channel` to read the data.
Case 2: Otherwise, use the following method to read data from remote readers.
1) Receives torch.Tensors via the tensor data channel (e.g., NCCL).
2) Reads the serialized non-tensor data.
3) Deserializes the non-tensor data. During deserialization, replaces
all found placeholders with the received torch.Tensors.
If _direct_return=True was specified, then we skip step (2) and (3) and
directly return the data received in (1).
"""
self.ensure_registered_as_reader()
# If the reader is the same actor as the writer, then we can use the
# local channel to read the data.
reader = utils.get_self_actor()
if reader == self._writer:
assert self._local_channel is not None
return self._local_channel.read()
# First, read the tensor data.
tensors = self._gpu_data_channel.read(timeout)
if self._cpu_data_channel is None:
# Handle _direct_return=True. In this case, we expect to receive
# only one tensor, and we return it directly.
assert len(tensors) == 1
data = tensors[0]
else:
data = self._recv_cpu_and_gpu_data(tensors, timeout)
return data
def close(self) -> None:
self._gpu_data_channel.close()
if self._cpu_data_channel is not None:
self._cpu_data_channel.close()
if self._local_channel is not None:
self._local_channel.close()
def _torch_tensor_allocator(
shape: Union[int, Tuple[int]],
dtype: "torch.dtype",
):
"""
Allocate a tensor buffer matching the given metadata.
"""
import torch
ctx = ChannelContext.get_current()
return torch.empty(shape, dtype=dtype, device=ctx.torch_device)
class _TorchTensorAcceleratorChannel(ChannelInterface):
def __init__(
self,
writer: ray.actor.ActorHandle,
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
typ: "TorchTensorType",
_meta_channel: Optional["Channel"] = None,
):
"""
A helper channel for TorchTensorAcceleratorChannel that is used to transfer
lists of torch.Tensors via accelerator. This class can only transfer
torch.Tensors and cannot transfer other CPU data, such as Exception
objects or tensors nested inside of a dictionary.
Args:
writer: The actor that may write to the channel. None signifies the driver.
reader_and_node_list: A list of tuples, where each tuple contains a reader
actor handle and the node ID where the actor is located.
typ: Type information about the values passed through the channel.
_meta_channel: A channel used to send metadata for the tensors,
i.e. shape and dtype. If not provided, and if the typ does not
specify a static shape and dtype, then a metadata channel based
on shared memory will be created.
"""
import torch
self.torch: ModuleType = torch
self._writer = writer
self._writer_rank: Optional[int] = None
self._reader_and_node_list = reader_and_node_list
self._reader_ranks: Optional[List[int]] = None
self._writer_registered: bool = False
self._reader_registered: bool = False
ctx = ChannelContext.get_current()
assert isinstance(
typ.communicator_id, str
), f"accelerator group ID ({typ.communicator_id}) must be a str."
self._typ = typ
self._static_shape = typ.static_shape
assert self._typ.communicator_id is not None, "No accelerator group specified."
self._accelerator_group_id: str = self._typ.communicator_id
# If the communicators does not contain the group_id, it means the current
# process is the driver, and theres no need to fetch the comm_group.
if self._typ.communicator_id in ctx.communicators:
self._accelerator_group: "Communicator" = ctx.communicators[
self._typ.communicator_id
]
assert (
self._accelerator_group is not None
), "ChannelContext.accelerator_group is not initialized."
self._writer_rank = self._accelerator_group.get_rank(self._writer)
self._reader_ranks = [
self._accelerator_group.get_rank(reader)
for reader, _ in self._reader_and_node_list
]
if (
self._writer_rank is not None
and self._writer_rank == self._accelerator_group.get_self_rank()
):
self._writer_registered = True
if (
self._reader_ranks
and self._accelerator_group.get_self_rank() in self._reader_ranks
):
self._reader_registered = True
# If the channel type specifies that the tensor shape is static, then the
# receiver can allocate buffers without needing to coordinate with the
# sender. We set the metadata on the first send-recv op. Thereafter,
# the sender must ensure that sent tensors match this metadata, and the
# receiver will allocate tensors with this shape.
self._static_tensor_metadata: Optional[List[_TorchTensorMetadata]] = None
self._meta_channel: Optional[Channel] = _meta_channel
if self._meta_channel is None and self._writer_registered:
# We are the writer. Therefore, we also need to allocate a metadata
# channel that will be used to send the shape and dtype of the
# tensor to the receiver(s).
metadata_type = SharedMemoryType()
self._meta_channel = metadata_type.create_channel(
self._writer,
self._reader_and_node_list,
None,
)
def ensure_registered_as_writer(self):
assert (
self._accelerator_group is not None
), "Actor is not part of an accelerator group"
assert self._writer_registered
ctx = ChannelContext.get_current()
assert ctx.torch_device.type != "cpu"
def ensure_registered_as_reader(self) -> bool:
assert (
self._accelerator_group is not None
), "Actor is not part of an accelerator group"
assert self._reader_registered
ctx = ChannelContext.get_current()
assert ctx.torch_device.type != "cpu"
def __reduce__(self):
return (
self.__class__,
(
self._writer,
self._reader_and_node_list,
self._typ,
self._meta_channel,
),
)
def _get_send_tensors_metadata(
self, tensors: List["torch.Tensor"]
) -> Optional[List[_TorchTensorMetadata]]:
"""
Helper method to get the metadata that should be sent to the reader so
that they can allocate the proper-sized buffer(s). Throws error if
static_shape=True was set and the given tensors do not match the
inferred shapes.
Returns: The metadata to send to the reader. None means that we should
not send any metadata message to the reader.
"""
ctx = ChannelContext.get_current()
# TODO(swang): Currently any exceptions thrown during this method are
# fatal for the DAG because there is no way for the receiver to receive
# the exception. This can be improved by sending the exception through
# the CPU-based non-tensor-data channel, if one exists. The tensor
# channel can send empty data alongside the exception to avoid hanging.
# Get the shape and dtype of each tensor to send.
metadata_list = []
for tensor in tensors:
# Basic type checking.
if not isinstance(tensor, self.torch.Tensor):
raise ValueError("Task must return torch.Tensors")
if tensor.device != ctx.torch_device:
raise ValueError(
f"torch.Tensor must be on the default device: {ctx.torch_device}"
)
metadata = _TorchTensorMetadata(tensor.shape, tensor.dtype)
metadata_list.append(metadata)
if self._static_tensor_metadata is not None:
if metadata_list != self._static_tensor_metadata:
metadata_str = [
f"(shape={m.shape}, dtype={m.dtype})" for m in metadata_list
]
expected_str = [
f"(shape={m.shape}, dtype={m.dtype})"
for m in self._static_tensor_metadata
]
raise ValueError(
"Expected torch.Tensors with shapes and dtypes: "
"[" + ", ".join(expected_str) + "], "
"found: [" + ", ".join(metadata_str) + "]. "
"DAG will shut down."
)
# The receiver has already determined the shape and dtype of the
# tensors from a previous send, so no need to send the metadata
# again.
return None
if self._static_shape:
# The shape and dtype is static. This is the first send op and
# afterwards, a ValueError will be thrown if the sent tensors do
# not match this metadata.
self._static_tensor_metadata = metadata_list
return metadata_list
def write(
self,
tensors: List["torch.Tensor"],
timeout: Optional[float] = None,
):
"""
Write a list of tensors via accelerator:
1) Send the tensor metadata, i.e. the shape and dtypes of all tensors
via the shared-memory metadata channel.
2) Send the tensor data via accelerator.
If static_shape=True was set, then we only perform step (1) on the
first message. The reader is expected to reuse the sent metadata for
subsequent messages.
"""
self.ensure_registered_as_writer()
import torch
for tensor in tensors:
assert isinstance(
tensor, torch.Tensor
), f"{tensor} must be instance of torch.Tensor"
# Send the tensors metadata so that the receiver knows what buffers to
# allocate.
metadata = self._get_send_tensors_metadata(tensors)
if metadata is not None:
self._meta_channel.write(metadata)
# NOTE(swang): We must send the metadata *before* launching the accelerator
# send. We are using blocking accelerator ops, so the following calls will
# block until the kernel has been enqueued. Also, peers must launch the
# kernel together before either can proceed. Therefore, we send the
# metadata first so that the receiver can read the metadata and then
# launch the same accelerator op.
for tensor in tensors:
# TODO: If there are multiple readers, can replace with a
# broadcast.
for rank in self._reader_ranks:
self._accelerator_group.send(tensor, rank)
def _get_recv_tensors_metadata(
self, timeout: Optional[float] = None
) -> List[_TorchTensorMetadata]:
"""
Get the shape(s) and dtype(s) of the tensors to receive from the
metadata channel. If static_shape=True was set, then we reuse the first
metadata received.
"""
if self._static_tensor_metadata is not None:
return self._static_tensor_metadata
meta = self._meta_channel.read(timeout)
if self._static_shape:
self._static_tensor_metadata = meta
return meta
def read(
self,
timeout: Optional[float] = None,
) -> Union["torch.Tensor", List["torch.Tensor"]]:
"""
Receive a list of tensors.
(1) Receive the tensor metadata via the shared-memory metadata channel.
(2) Allocate buffers on our default device according to the received
tensor metadata.
(3) Receive the tensor data via accelerator.
If static_data=True was set, then we only perform step (1) on the first
message. Subsequent messages reuse the same metadata.
NOTE: Currently `timeout` only applies to receiving the CPU-based
tensor metadata. The GPU recv may exceed the timeout without throwing
an error.
"""
self.ensure_registered_as_reader()
meta_list: List[_TorchTensorMetadata] = self._get_recv_tensors_metadata(timeout)
bufs: List["torch.Tensor"] = []
for meta in meta_list:
buf = self._accelerator_group.recv(
meta.shape, meta.dtype, self._writer_rank, _torch_tensor_allocator
)
bufs.append(buf)
# TODO: Sync CUDA stream after receiving all tensors, instead of after
# each tensor.
return bufs
def close(self) -> None:
self._meta_channel.close()
self._accelerator_group.destroy()
ctx = ChannelContext.get_current()
if self._accelerator_group_id in ctx.communicators:
del ctx.communicators[self._accelerator_group_id]
def _do_init_communicator(
self,
group_id,
world_size,
comm_id,
rank,
actor_handles,
use_communication_streams,
custom_communicator: Optional[Communicator] = None,
):
if not custom_communicator:
assert (
AcceleratorContext.get().accelerator_count > 0
), "Actors participating in Communication group must have at least one Accelerator assigned"
ctx = ChannelContext.get_current()
if custom_communicator is not None:
custom_communicator.initialize(rank)
ctx.communicators[group_id] = custom_communicator
else:
# default to CommGroup
ctx.communicators[group_id] = AcceleratorContext.get().create_communicator(
world_size,
comm_id,
rank,
actor_handles,
AcceleratorContext.get().current_stream(),
use_communication_streams,
)
def _do_destroy_communicator(self, group_id):
ctx = ChannelContext.get_current()
if group_id not in ctx.communicators:
return
ctx.communicators[group_id].destroy()
# Keep the communicator group in the map after destruction in case there is
# still a task loop running.
def _do_check_has_accelerators(self) -> str:
return AcceleratorContext.get().accelerator_count > 0
def do_register_accelerator_context(self, name: str, communicator: Type[Communicator]):
register_accelerator_context(name, communicator)
def _do_get_unique_communication_id(self) -> bool:
return AcceleratorContext.get().generate_communicator_id()
def _get_ranks(
actors: List[ray.actor.ActorHandle], custom_comm_group: Optional[Communicator]
) -> List[int]:
"""Get ranks for the communicator group to use.
If ``custom_comm_group`` is specified, return the ranks of the actors in the
custom communicator group, in the same order of the actors; otherwise,
return ``list(range(len(actors)))``.
Args:
actors: A list of actors that participate in the communicator group.
custom_comm_group: The custom communicator group to use.
Returns:
The list of ranks corresponding to ``actors``.
"""
if custom_comm_group is None:
return list(range(len(actors)))
assert len(actors) == custom_comm_group.get_world_size(), (
"The world size of the custom communicator group does not match the "
"number of actors."
)
ranks = []
for actor in actors:
rank = custom_comm_group.get_rank(actor)
assert rank not in ranks, "Duplicate rank in custom communicator group"
ranks.append(rank)
assert custom_comm_group.get_world_size() == len(actors), (
"The world size of the custom communicator group "
f"({custom_comm_group.get_world_size()}) "
"does not match the number of actors "
f"({len(actors)})."
)
return ranks
def _init_communicator(
actors: List[ray.actor.ActorHandle],
custom_communicator: Optional[Communicator] = None,
use_communication_streams: bool = False,
accelerator_module_name: Optional[str] = None,
accelerator_communicator_cls: Optional[Type[Communicator]] = None,
) -> str:
"""Initialize a communicator group with the given actors.
If a custom communicator group is provided, then it will be used, otherwise
a new communicator group will be created.
Args:
actors: A list of actors that participate in the communicator group.
custom_communicator: A custom communicator group to initialize.
use_communication_streams: Whether to use dedicated send and recv
streams for communication. If True, communication and computation
can be overlapped to improve performance.
accelerator_module_name: Optional name of the accelerator module to use.
accelerator_communicator_cls: Optional communicator class for the accelerator.
Returns:
The unique ``group_id`` identifying the initialized communicator group.
"""
ctx = ChannelContext.get_current()
is_cpu_communicator = custom_communicator and isinstance(
custom_communicator, CPUCommunicator
)
# Register accelerator context for all actors if accelerator is not default
if accelerator_module_name and accelerator_communicator_cls:
if is_accelerator_context_registered():
ray.get(
[
actor.__ray_call__.remote(
do_register_accelerator_context,
accelerator_module_name,
accelerator_communicator_cls,
)
for actor in actors
]
)
has_accelerators = ray.get(
[actor.__ray_call__.remote(_do_check_has_accelerators) for actor in actors]
)
for has_accelerator, actor in zip(has_accelerators, actors):
if not has_accelerator and not is_cpu_communicator:
raise ValueError(
f"Actor {actor} returns a tensor with type hint "
'TorchTensor(transport="accelerator") or '
"TorchTensor(transport=accelerator_group_handle) "
"but actor does not have an accelerator assigned by Ray."
)
actor_ids = {actor._ray_actor_id for actor in actors}
assert len(actor_ids) == len(actors), "Actors must be unique"
# Allocate a communicator ID on one of the actors that will participate in
# the group. This is in case the driver is not on the same node as one of
# the communicator actors.
comm_id = ray.get(actors[0].__ray_call__.remote(_do_get_unique_communication_id))
# Used to uniquely identify this communicator group.
group_id = str(uuid.uuid4())
if custom_communicator is not None:
logger.info(
f"Initializing custom communicator group {group_id} on actors: {actors}"
)
else:
logger.info(f"Creating communicator group {group_id} on actors: {actors}")
world_size = len(actors)
ranks = _get_ranks(actors, custom_communicator)
init_tasks = [
actor.__ray_call__.remote(
_do_init_communicator,
group_id,
world_size,
comm_id,
rank,
actors,
use_communication_streams,
custom_communicator,
)
for rank, actor in zip(ranks, actors)
]
try:
ray.get(init_tasks, timeout=30)
except ray.exceptions.GetTimeoutError:
logger.warning(
"Communicator group creation not done after 30s. communicator group"
"creation may be hung."
)
ray.get(init_tasks)
logger.info("Communicator group initialized.")
if custom_communicator is not None:
ctx.communicator_handles[group_id] = CommunicatorHandle(
actor_handles=custom_communicator.get_actor_handles(),
)
else:
ctx.communicator_handles[group_id] = CommunicatorHandle(
actor_handles=actors,
)
return group_id
def _destroy_communicator(group_id: str) -> None:
"""
Destroy the communicator group with the given ID.
"""
ctx = ChannelContext.get_current()
if group_id not in ctx.communicator_handles:
return
group = ctx.communicator_handles[group_id]
actors = group.get_actor_handles()
destroy_tasks = [
actor.__ray_call__.remote(
_do_destroy_communicator,
group_id,
)
for actor in actors
]
_, unready = ray.wait(destroy_tasks, timeout=30, num_returns=len(destroy_tasks))
if unready:
logger.warning(
"Communicator group destruction not done after 30s. Communicator"
"group destruction may be hung."
)
del ctx.communicator_handles[group_id]
@@ -0,0 +1,193 @@
import logging
from typing import TYPE_CHECKING, List, Optional, Tuple, Union
import ray
from ray.experimental.channel import ChannelContext, ChannelOutputType
from ray.experimental.channel.communicator import Communicator
from ray.experimental.channel.shared_memory_channel import SharedMemoryType
from ray.experimental.util.types import Device
from ray.util.annotations import PublicAPI
if TYPE_CHECKING:
from ray.experimental.channel.shared_memory_channel import Channel
logger = logging.getLogger(__name__)
@PublicAPI(stability="alpha")
class TorchTensorType(ChannelOutputType):
AUTO = "auto"
CPU = "cpu"
ACCELERATOR = "accelerator"
def __init__(
self,
transport: Optional[Union[str, Communicator]] = AUTO,
device: Device = Device.DEFAULT,
_static_shape: bool = False,
_direct_return: Optional[bool] = False,
):
"""
A type hint that can be used to annotate DAG nodes that return a
torch.Tensor.
NOTE: Use of this type in the DAG will register a custom serializer for
torch.Tensor that moves the tensor to the correct device on the
receiver. If you are using ray.cloudpickle to serialize objects and you
do not want this behavior, deregister the custom serializer using
ray.util.serialization.deregister_serializer(torch.Tensor).
Args:
transport: "auto" (default) means that tensors will be passed via
host memory, using numpy as the serialization format. Pass
TorchTensorType.ACCELERATOR or "accelerator" to use accelerator
instead, avoiding the host memory copy.
device: Target device for tensor transport. Options:
- "default": Retains the same device type as the sender.
- "cpu": Moves tensor to CPU on the receiver. Not compatible
with accelerator transport.
- "gpu" or "cuda": Moves tensor to GPU on the receiver.
_static_shape: A hint indicating whether the shape(s) and dtype(s)
of tensor(s) contained in this value always remain the same
across different executions of the DAG.
_direct_return: Whether the tensor is sent directly or inside of
other data. If a non-default `transport` is used, this allows
the sender and receiver to eliminate performance overhead from
an additional data transfer.
NOTE: Setting static_shape=True and _direct_return=True can improve
performance if a non-default transport is used. However, if either flag
is set, then the user must ensure that the condition is met.
If using this type as a Compiled Graph annotation, an exception will
be thrown in the following cases, and the DAG will be torn down. To
continue execution, a new DAG must be created:
1. If _static_shape=True, and the found tensors don't match the
previous shape or dtype(s).
2. If _direct_return=True, and the returned value is not a
torch.Tensor.
"""
super().__init__()
self._device = device
self._static_shape = _static_shape
self._direct_return = _direct_return
self._communicator: Optional[Communicator] = None
if isinstance(transport, Communicator):
self._communicator = transport
transport = transport.get_transport_name()
if transport not in [self.AUTO, self.CPU, self.ACCELERATOR]:
raise ValueError(
"`transport` must be TorchTensorType.AUTO, TorchTensorType.ACCELERATOR "
"or TorchTensorType.CPU"
)
if device == Device.CPU and transport == self.ACCELERATOR:
raise ValueError(
"accelerator transport is not supported with CPU target device."
)
self.transport = transport
self._communicator_id: Optional[str] = None
if self._static_shape and self.transport == self.AUTO:
logger.info(
"TorchTensorType(_static_shape=True) has no effect when "
"`transport` is TorchTensorType.AUTO (default)."
)
if self._direct_return and self.transport == self.AUTO:
logger.info(
"TorchTensorType(_direct_return=True) has no effect when "
"`transport` is TorchTensorType.AUTO (default)."
)
@property
def device(self) -> Device:
return self._device
@property
def static_shape(self):
return self._static_shape
@property
def direct_return(self):
return self._direct_return
def register_custom_serializer(self) -> None:
super().register_custom_serializer()
import torch
def serialize(t):
ctx = ChannelContext.get_current()
return ctx.serialization_context.serialize_tensor(t)
def deserialize(b):
ctx = ChannelContext.get_current()
return ctx.serialization_context.deserialize_tensor(b, self.device)
ray.util.serialization.register_serializer(
torch.Tensor,
serializer=serialize,
deserializer=deserialize,
)
def create_channel(
self,
writer: Optional["ray.actor.ActorHandle"],
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
driver_actor_id: Optional[str] = None,
_cpu_data_channel: Optional["Channel"] = None,
_tensor_metadata_channel: Optional["Channel"] = None,
) -> type:
if self.requires_accelerator():
from ray.experimental.channel.torch_tensor_accelerator_channel import (
TorchTensorAcceleratorChannel,
)
return TorchTensorAcceleratorChannel(
writer,
reader_and_node_list,
self,
driver_actor_id,
_tensor_metadata_channel,
_cpu_data_channel,
)
# Data does not require accelerator. Transfer via host memory using a
# shared-memory channel.
# TODO(swang): Allow the initial max buffer size to be overridden.
typ = SharedMemoryType()
return typ.create_channel(writer, reader_and_node_list, driver_actor_id)
def requires_accelerator(self) -> bool:
return self.transport == self.ACCELERATOR
def get_custom_communicator(self) -> Optional[Communicator]:
"""
Return the communicator group if one is specified.
"""
return self._communicator
def set_communicator_id(self, group_id: str) -> None:
self._communicator_id = group_id
@property
def communicator_id(self) -> Optional[str]:
return self._communicator_id
def __deepcopy__(self, memo):
"""
Deep copy all the fields except for the communicator group. The communicator
group should not be deep copied because it can be shared across `TorchTensorType`
instances.
"""
copy = TorchTensorType(
transport=self.transport,
_static_shape=self._static_shape,
_direct_return=self._direct_return,
)
copy._communicator = self._communicator
copy._communicator_id = self._communicator_id
return copy
+92
View File
@@ -0,0 +1,92 @@
from typing import List, Optional, Tuple
import ray
def get_self_actor() -> Optional["ray.actor.ActorHandle"]:
"""
Get the current actor handle in this worker.
If this is called in a driver process, it will return None.
"""
try:
return ray.get_runtime_context().current_actor
except RuntimeError:
return None
def split_readers_by_locality(
writer: "ray.actor.ActorHandle",
reader_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
) -> Tuple[
List[Tuple["ray.actor.ActorHandle", str]], List[Tuple["ray.actor.ActorHandle", str]]
]:
"""Split readers into remote and local readers based on writer.
Args:
writer: The actor handle of the writer
reader_and_node_list: List of (reader, node) tuples
Returns:
Tuple containing:
- List of (reader, node) tuples for remote readers
- List of (reader, node) tuples for local readers
"""
remote_readers = []
local_readers = []
for reader, node in reader_and_node_list:
if reader != writer:
remote_readers.append((reader, node))
else:
local_readers.append((reader, node))
return remote_readers, local_readers
def split_actors_by_node_locality(
node: str,
actor_and_node_list: List[Tuple["ray.actor.ActorHandle", str]],
) -> Tuple[
List[Tuple["ray.actor.ActorHandle", str]], List[Tuple["ray.actor.ActorHandle", str]]
]:
"""Split actors into remote and local actors based on node. The local actors will be
on the same node as the given node. The remote actors will be on a different node.
Args:
node: The node to compare actor locations against.
actor_and_node_list: List of (actor, node) tuples
Returns:
Tuple containing:
- List of (actor, node) tuples for actors on the same node
- List of (actor, node) tuples for actors on a different node
"""
actors_on_same_node = []
actors_on_different_node = []
for actor, actor_node in actor_and_node_list:
if node == actor_node:
actors_on_same_node.append((actor, actor_node))
else:
actors_on_different_node.append((actor, actor_node))
return actors_on_same_node, actors_on_different_node
def get_actor_node(actor: Optional["ray.actor.ActorHandle"]) -> str:
"""Get the node of the actor.
Args:
actor: The actor handle of the actor
Returns:
The node of the actor
"""
if actor is None or actor == ray.get_runtime_context().current_actor:
return ray.get_runtime_context().get_node_id()
else:
return ray.get(
actor.__ray_call__.remote(
lambda self: ray.get_runtime_context().get_node_id()
)
)
@@ -0,0 +1,21 @@
from ray.experimental.collective.collective import (
create_collective_group,
destroy_all_collective_groups,
destroy_collective_group,
get_collective_groups,
)
from ray.experimental.collective.operations import (
allgather,
allreduce,
reducescatter,
)
__all__ = [
"allgather",
"allreduce",
"reducescatter",
"get_collective_groups",
"create_collective_group",
"destroy_collective_group",
"destroy_all_collective_groups",
]
@@ -0,0 +1,209 @@
import threading
import uuid
from typing import Dict, List, Optional, Union
import ray
import ray.experimental.internal_kv as internal_kv
from ray.experimental.collective.communicator import CommunicatorHandle
from ray.util.annotations import PublicAPI
from ray.util.collective.collective_group.torch_gloo_collective_group import (
get_master_address_metadata_key,
)
from ray.util.collective.types import Backend
_remote_communicator_manager: "Optional[RemoteCommunicatorManager]" = None
_remote_communicator_manager_lock = threading.Lock()
class RemoteCommunicatorManager:
"""Singleton class to store the mapping between actors and communicators
that the actors are a part of.
"""
def __init__(self):
# Handles to communicators that we created. Key is a user-provided
# name or UUID.
self._remote_communicators: Dict[str, CommunicatorHandle] = {}
@staticmethod
def get() -> "RemoteCommunicatorManager":
global _remote_communicator_manager
with _remote_communicator_manager_lock:
if _remote_communicator_manager is None:
_remote_communicator_manager = RemoteCommunicatorManager()
return _remote_communicator_manager
def add_remote_communicator(self, comm_handle: CommunicatorHandle):
self._remote_communicators[comm_handle.name] = comm_handle
def remove_remote_communicator(self, name: str):
return self._remote_communicators.pop(name, None)
def get_collective_groups(
self,
actors: Optional[List[ray.actor.ActorHandle]] = None,
backend: Optional[Backend] = None,
):
"""
Get the collective groups that the given actors are a subset of. Filter by
backend if provided.
"""
actors = actors or []
actors = set(actors)
collectives = []
# Find all collective groups that the given actors are a subset
# of, with the matching backend if provided.
for collective in self._remote_communicators.values():
if actors.issubset(set(collective.actors)):
if backend is None or collective.backend == backend:
collectives.append(collective)
return collectives
@PublicAPI(stability="alpha")
def get_collective_groups(
actors: List[ray.actor.ActorHandle], backend: Optional[str] = None
) -> List[CommunicatorHandle]:
"""
Get the collective groups that the given actors are a subset of. Filter by
backend if provided.
Args:
actors: List of actors. Return handles to all collective groups that
these actors are a subset of.
backend: An optional backend to filter by. See
ray.util.collective.types.Backend for valid backends.
Returns:
A list of communicator handles that the actors are a subset of.
"""
manager = RemoteCommunicatorManager.get()
backend = Backend(backend) if backend is not None else None
return manager.get_collective_groups(actors, backend)
@PublicAPI(stability="alpha")
def create_collective_group(
actors: List[ray.actor.ActorHandle],
backend: str,
name: Optional[str] = None,
) -> CommunicatorHandle:
"""Create a collective group on the given list of actors. If this function
returns successfully, then the collective group has been initialized on all
actors, using the given order of actors as the ranks.
Currently, an actor can only participate in one collective group per
backend at a time. To reuse an actor, destroy its collective group and
create a new one.
Args:
actors: The actors to participate in the collective group.
backend: The backend to use. See ray.util.collective.types.Backend for
valid backends.
name: A name to use for the collective group. If None is provided, a
random name will be generated.
Returns:
Handle to the communicator.
"""
manager = RemoteCommunicatorManager.get()
if name is None:
name = str(uuid.uuid4())
# Validate the backend.
backend = Backend(backend)
world_size = len(actors)
for actor in actors:
if manager.get_collective_groups([actor], backend):
raise RuntimeError(
f"Actor {actor} already in group for backend {backend}. Actors can currently only participate in at most one group per backend."
)
actor_ids = [actor._ray_actor_id for actor in actors]
if len(set(actor_ids)) != len(actor_ids):
raise ValueError(f"All actors must be unique, got: {actors}")
metadata_key = None
if backend == Backend.GLOO:
metadata_key = get_master_address_metadata_key(name)
def _do_init_collective_group(self, rank: int):
ray.util.collective.init_collective_group(
world_size, rank, backend, group_name=name
)
try:
init_tasks = [
actor.__ray_call__.remote(
_do_init_collective_group,
rank,
)
for rank, actor in enumerate(actors)
]
ray.get(init_tasks)
finally:
# Clean up the metadata once collective group is initialized
# (or failed to initialize).
if metadata_key is not None:
internal_kv._internal_kv_del(metadata_key)
# Group was successfully created.
comm = CommunicatorHandle(actors, name, backend)
manager.add_remote_communicator(comm)
return comm
@PublicAPI(stability="alpha")
def destroy_collective_group(group_or_name: Union[CommunicatorHandle, str]):
"""
Destroy a collective group. If this functions returns successfully, then
the actors that were in the collective can be reused to create a new
collective group.
Args:
group_or_name: Either a communicator handle or the name of the group to
destroy.
"""
if isinstance(group_or_name, CommunicatorHandle):
name = group_or_name.name
elif isinstance(group_or_name, str):
name = group_or_name
else:
raise ValueError("Expected CommunicatorHandle or str (group name).")
manager = RemoteCommunicatorManager.get()
group = manager.remove_remote_communicator(name)
if group is not None:
def _do_destroy_collective_group(self):
ray.util.collective.destroy_collective_group(name)
destroy_tasks = [
actor.__ray_call__.options(concurrency_group="_ray_system").remote(
_do_destroy_collective_group
)
for actor in group.actors
]
try:
ray.get(destroy_tasks)
except ray.exceptions.ActorDiedError:
pass
else:
raise ValueError(f"No group with name {name} found.")
@PublicAPI(stability="alpha")
def destroy_all_collective_groups():
"""
Destroy all collective groups. This will destroy all collective groups that
were previously created by this process. After this function returns, the
actors participating in those collective groups can be reused to create a
new collective group.
"""
manager = RemoteCommunicatorManager.get()
for collective in manager.get_collective_groups():
destroy_collective_group(collective.name)
@@ -0,0 +1,63 @@
from dataclasses import dataclass
from typing import List
import ray
from ray.util.collective.types import Backend
@dataclass
class Communicator:
"""
A handle to a communicator that we are a member of.
"""
# The name of the communicator.
name: str
# Our rank in the collective group.
rank: int
# A valid backend, as defined by
# ray.util.collective.types.Backend.
backend: str
class CommunicatorHandle:
"""
A communicator handle used by the driver to store handles to the
actors in the communicator.
"""
def __init__(self, actors: List[ray.actor.ActorHandle], name: str, backend: str):
"""
Initializes the CommunicatorHandle with the given actor handles.
Assumes that the communicator has already been initialized on all actors.
Args:
actors: A list of actor handles to be stored.
name: Name of the communicator.
backend: Communicator backend. See
ray.util.collective.types for valid values.
"""
self._actors = actors
self._name = name
self._backend = Backend(backend)
def get_rank(self, actor: ray.actor.ActorHandle):
for i, a in enumerate(self._actors):
if a == actor:
return i
return -1
@property
def actors(self) -> List[ray.actor.ActorHandle]:
"""
Return all actor handles in this communicator.
"""
return self._actors[:]
@property
def name(self) -> str:
return self._name
@property
def backend(self) -> str:
return self._backend
@@ -0,0 +1,243 @@
import uuid
from typing import Dict, FrozenSet, List, Optional, Set, Tuple, Type
import torch
import ray
from ray.experimental.channel.common import ChannelContext
from ray.experimental.channel.communicator import (
Communicator,
ReduceOp,
TorchTensorAllocator,
)
class AbstractNcclGroup(Communicator):
"""
A dummy NCCL group for testing.
"""
def __init__(self, actor_handles: List[ray.actor.ActorHandle]):
self._actor_handles = actor_handles
self._rank = None
def initialize(self, rank: int) -> None:
self._rank = rank
def get_rank(self, actor: ray.actor.ActorHandle) -> int:
return self._actor_handles.index(actor)
def get_world_size(self) -> int:
return len(self._actor_handles)
def get_self_rank(self) -> Optional[int]:
return self._rank
def get_actor_handles(self) -> List["ray.actor.ActorHandle"]:
return self._actor_handles
def send(self, value: "torch.Tensor", peer_rank: int) -> None:
raise NotImplementedError
def recv(
self,
shape: Tuple[int],
dtype: "torch.dtype",
peer_rank: int,
allocator: Optional[TorchTensorAllocator] = None,
) -> "torch.Tensor":
raise NotImplementedError
def allgather(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
) -> None:
raise NotImplementedError
def allreduce(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
) -> None:
raise NotImplementedError
def reducescatter(
self,
send_buf: "torch.Tensor",
recv_buf: "torch.Tensor",
op: ReduceOp = ReduceOp.SUM,
) -> None:
raise NotImplementedError
@property
def recv_stream(self):
return None
@property
def send_stream(self):
return None
def destroy(self) -> None:
pass
def get_transport_name(self) -> str:
return "accelerator"
@classmethod
def generate_communicator_id(cls) -> str:
pass
class MockNcclGroupSet:
def __init__(self):
# Represents a mapping from a NCCL group ID to a set of actors and a custom
# NCCL group.
self.ids_to_actors_and_custom_comms: Dict[
str, Tuple[FrozenSet["ray.actor.ActorHandle"], Optional[Communicator]]
] = {}
def __call__(
self,
actors: List["ray.actor.ActorHandle"],
custom_nccl_group: Optional[Communicator] = None,
use_communication_streams: bool = False,
accelerator_module_name: Optional[str] = None,
accelerator_communicator_cls: Optional[Type[Communicator]] = None,
) -> str:
group_id = str(uuid.uuid4())
self.ids_to_actors_and_custom_comms[group_id] = (
frozenset(actors),
custom_nccl_group,
)
if custom_nccl_group is None:
ranks = list(range(len(actors)))
else:
ranks = [custom_nccl_group.get_rank(actor) for actor in actors]
init_tasks = [
actor.__ray_call__.remote(
mock_do_init_nccl_group,
group_id,
rank,
actors,
custom_nccl_group,
)
for rank, actor in zip(ranks, actors)
]
ray.get(init_tasks, timeout=30)
ctx = ChannelContext.get_current()
if custom_nccl_group is not None:
ctx.communicators[group_id] = custom_nccl_group
else:
ctx.communicators[group_id] = AbstractNcclGroup(actors)
return group_id
def mock_destroy_nccl_group(self, group_id: str) -> None:
ctx = ChannelContext.get_current()
if group_id not in ctx.communicators:
return
actors, _ = self.ids_to_actors_and_custom_comms[group_id]
destroy_tasks = [
actor.__ray_call__.remote(
mock_do_destroy_nccl_group,
group_id,
)
for actor in actors
]
ray.wait(destroy_tasks, timeout=30)
if group_id in self.ids_to_actors_and_custom_comms:
del self.ids_to_actors_and_custom_comms[group_id]
ctx.communicators[group_id].destroy()
del ctx.communicators[group_id]
def check_teardown(self, nccl_group_ids: List[str]) -> None:
ctx = ChannelContext.get_current()
for nccl_group_id in nccl_group_ids:
assert nccl_group_id not in self.ids_to_actors_and_custom_comms
assert nccl_group_id not in ctx.communicators
@ray.remote
class CPUTorchTensorWorker:
def __init__(self):
self.device = "cpu"
def return_tensor(
self, size: int, dtype: Optional[torch.dtype] = None
) -> torch.Tensor:
return torch.ones(size, dtype=dtype, device=self.device)
def recv(self, tensor: torch.Tensor) -> Tuple[int, int]:
assert tensor.device == self.device
return tensor.shape, tensor[0]
def recv_tensors(self, *tensors) -> Tuple[torch.Tensor, ...]:
return tuple(tensors)
def mock_do_init_nccl_group(
self,
group_id: str,
rank: int,
actors: List[ray.actor.ActorHandle],
custom_nccl_group: Optional[Communicator],
) -> None:
ctx = ChannelContext.get_current()
if custom_nccl_group is None:
nccl_group = AbstractNcclGroup(actors)
nccl_group.initialize(rank)
ctx.communicators[group_id] = nccl_group
else:
custom_nccl_group.initialize(rank)
ctx.communicators[group_id] = custom_nccl_group
def mock_do_destroy_nccl_group(self, group_id: str) -> None:
ctx = ChannelContext.get_current()
if group_id not in ctx.communicators:
return
ctx.communicators[group_id].destroy()
del ctx.communicators[group_id]
def check_nccl_group_init(
monkeypatch,
dag: "ray.dag.DAGNode",
actors_and_custom_comms: Set[
Tuple[FrozenSet["ray.actor.ActorHandle"], Optional[Communicator]]
],
) -> "ray.dag.CompiledDAG":
mock_nccl_group_set = MockNcclGroupSet()
monkeypatch.setattr(
"ray.dag.compiled_dag_node._init_communicator",
mock_nccl_group_set,
)
compiled_dag = dag.experimental_compile()
assert (
set(mock_nccl_group_set.ids_to_actors_and_custom_comms.values())
== actors_and_custom_comms
)
return compiled_dag, mock_nccl_group_set
def check_nccl_group_teardown(
monkeypatch,
compiled_dag: "ray.dag.CompiledDAG",
mock_nccl_group_set: MockNcclGroupSet,
):
monkeypatch.setattr(
"ray.dag.compiled_dag_node._destroy_communicator",
mock_nccl_group_set.mock_destroy_nccl_group,
)
created_communicator_ids = compiled_dag._actors_to_created_communicator_id.values()
compiled_dag.teardown()
mock_nccl_group_set.check_teardown(created_communicator_ids)
@@ -0,0 +1,203 @@
import logging
from typing import List, Optional, Union
import ray
from ray.dag.collective_node import CollectiveOutputNode, _CollectiveOperation
from ray.dag.constants import (
BIND_INDEX_KEY,
COLLECTIVE_OPERATION_KEY,
IS_CLASS_METHOD_OUTPUT_KEY,
PARENT_CLASS_NODE_KEY,
)
from ray.experimental.channel.torch_tensor_type import Communicator, TorchTensorType
from ray.experimental.util.types import (
AllGatherOp,
AllReduceOp,
ReduceOp,
ReduceScatterOp,
_CollectiveOp,
)
from ray.util.collective.types import ReduceOp as RayReduceOp
logger = logging.getLogger(__name__)
def _bind(
inputs: Union[List["ray.dag.DAGNode"], List[List["ray.dag.DAGNode"]]],
op: _CollectiveOp,
transport: Optional[Union[str, Communicator]] = None,
):
"""
Bind inputs (input nodes or lists of input nodes) with a collective operation.
The collective operation is applied to each list of input nodes. The output nodes
will have the same shape as the input nodes.
Example of binding a list of input node:
with InputNode() as inp:
res_comp1 = [actor.comp1.bind(inp) for actor in actors]
res_comp2 = [actor.comp2.bind(inp) for actor in actors]
res_ar = allreduce.bind([res_comp1, res_comp2])
Requirements:
1. Each input node returns a torch tensor.
2. Each input node within a list is from a different actor.
3. If lists of input nodes are provided, the order of actors should
be the same for each nested list.
4. If a custom transport is specified, its actor set matches the actor
set of the input nodes.
5. If input nodes are provided, then all tensors have the same shape.
If lists of input nodes are provided, then all tensors in each
list have the same shape.
Requirements 1-3 are checked in the `CollectiveGroup` constructor.
Requirement 4 is not checked yet.
Args:
inputs: A list of DAG nodes or a list of lists of DAG nodes. Each leaf list
should contain one object per actor.
op: The collective operation.
transport: GPU communicator for the collective operation. If not
specified, the default ACCELERATOR is used.
Returns:
A list of collective output nodes or a list of lists of collective output nodes,
with the same shape as the input nodes. Each output node has the same order and
belongs to the same actor as the corresponding input node.
"""
if isinstance(inputs[0], list) and not isinstance(op, AllReduceOp):
raise ValueError(
"Currently binding a nested list of dag nodes is only supported for allreduce"
)
# Convert list of DAGNode into nested list for type checking
if not isinstance(inputs[0], list):
inputs = [inputs]
if transport is None:
transport = TorchTensorType.ACCELERATOR
collective_op = _CollectiveOperation(inputs, op, transport)
collective_output_nodes: List[CollectiveOutputNode] = []
if isinstance(op, AllGatherOp):
method_name = "allgather"
elif isinstance(op, AllReduceOp):
method_name = f"allreduce.{op.reduceOp}"
elif isinstance(op, ReduceScatterOp):
method_name = f"reducescatter.{op.reduceOp}"
else:
raise ValueError(f"Expected a collective operation, but got {op}")
for i in range(len(inputs[0])):
input_node_list = [l[i] for l in inputs if l]
actor_handle: Optional["ray.actor.ActorHandle"] = input_node_list[
0
]._get_actor_handle()
assert actor_handle is not None
collective_output_node = CollectiveOutputNode(
method_name=method_name,
method_args=tuple(input_node_list),
method_kwargs=dict(),
method_options=dict(),
other_args_to_resolve={
PARENT_CLASS_NODE_KEY: actor_handle,
BIND_INDEX_KEY: actor_handle._ray_dag_bind_index,
COLLECTIVE_OPERATION_KEY: collective_op,
},
)
actor_handle._ray_dag_bind_index += 1
if len(input_node_list) > 1:
output_nodes: List[CollectiveOutputNode] = []
for i in range(len(input_node_list)):
output_node = CollectiveOutputNode(
f"return_idx_{i}",
(collective_output_node, i),
dict(),
dict(),
{
BIND_INDEX_KEY: collective_output_node._get_bind_index(),
IS_CLASS_METHOD_OUTPUT_KEY: True,
PARENT_CLASS_NODE_KEY: actor_handle,
},
)
output_nodes.append(output_node)
collective_output_nodes.append(output_nodes)
else:
collective_output_nodes.append(collective_output_node)
return collective_output_nodes
class AllGatherWrapper:
"""Wrapper for NCCL all-gather."""
def bind(
self,
input_nodes: List["ray.dag.DAGNode"],
transport: Optional[Union[str, Communicator]] = None,
) -> List[CollectiveOutputNode]:
return _bind(input_nodes, AllGatherOp(), transport)
def __call__(
self,
tensor_list,
tensor,
group_name: str = "default",
):
from ray.util.collective.collective import allgather
return allgather(tensor_list, tensor, group_name)
class AllReduceWrapper:
"""Wrapper for NCCL all-reduce."""
def bind(
self,
input_nodes: List["ray.dag.DAGNode"],
op: ReduceOp = ReduceOp.SUM,
transport: Optional[Union[str, Communicator]] = None,
) -> List[CollectiveOutputNode]:
if not isinstance(op, ReduceOp):
raise ValueError(f"Unexpected operation: {op}")
return _bind(input_nodes, AllReduceOp(reduceOp=op), transport)
def __call__(
self,
tensor,
group_name: str = "default",
op: RayReduceOp = RayReduceOp.SUM,
):
from ray.util.collective.collective import allreduce
return allreduce(tensor, group_name, op)
class ReduceScatterWrapper:
"""Wrapper for NCCL reduce-scatter."""
def bind(
self,
input_nodes: List["ray.dag.DAGNode"],
op: ReduceOp = ReduceOp.SUM,
transport: Optional[Union[str, Communicator]] = None,
) -> List[CollectiveOutputNode]:
if not isinstance(op, ReduceOp):
raise ValueError(f"Unexpected operation: {op}")
return _bind(input_nodes, ReduceScatterOp(reduceOp=op), transport)
def __call__(
self,
tensor,
group_name: str = "default",
op: RayReduceOp = RayReduceOp.SUM,
):
from ray.util.collective.collective import reducescatter
return reducescatter(tensor, group_name, op)
allgather = AllGatherWrapper()
allreduce = AllReduceWrapper()
reducescatter = ReduceScatterWrapper()
+225
View File
@@ -0,0 +1,225 @@
import asyncio
from typing import Any, List, Optional
import ray
from ray.exceptions import (
GetTimeoutError,
RayChannelError,
RayChannelTimeoutError,
RayTaskError,
)
from ray.util.annotations import PublicAPI
def _process_return_vals(return_vals: List[Any], return_single_output: bool):
"""
Process return values for return to the DAG caller. Any exceptions found in
return_vals will be raised. If return_single_output=True, it indicates that
the original DAG did not have a MultiOutputNode, so the DAG caller expects
a single return value instead of a list.
"""
# Check for exceptions.
if isinstance(return_vals, Exception):
raise return_vals
for val in return_vals:
if isinstance(val, RayTaskError):
raise val.as_instanceof_cause()
if return_single_output:
assert len(return_vals) == 1
return return_vals[0]
return return_vals
@PublicAPI(stability="alpha")
class CompiledDAGRef:
"""
A reference to a compiled DAG execution result.
This is a subclass of ObjectRef and resembles ObjectRef. For example,
similar to ObjectRef, ray.get() can be called on it to retrieve the result.
However, there are several major differences:
1. ray.get() can only be called once per CompiledDAGRef.
2. ray.wait() is not supported.
3. CompiledDAGRef cannot be copied, deep copied, or pickled.
4. CompiledDAGRef cannot be passed as an argument to another task.
"""
def __init__(
self,
dag: "ray.experimental.CompiledDAG",
execution_index: int,
channel_index: Optional[int] = None,
):
"""Initialize a CompiledDAGRef.
Args:
dag: The compiled DAG that generated this CompiledDAGRef.
execution_index: The index of the execution for the DAG.
A DAG can be executed multiple times, and execution index
indicates which execution this CompiledDAGRef corresponds to.
channel_index: The index of the DAG's output channel to fetch
the result from. A DAG can have multiple output channels, and
channel index indicates which channel this CompiledDAGRef
corresponds to. If channel index is not provided, this CompiledDAGRef
wraps the results from all output channels.
"""
self._dag = dag
self._execution_index = execution_index
self._channel_index = channel_index
# Whether ray.get() was called on this CompiledDAGRef.
self._ray_get_called = False
self._dag_output_channels = dag.dag_output_channels
def __str__(self):
return (
f"CompiledDAGRef({self._dag.get_id()}, "
f"execution_index={self._execution_index}, "
f"channel_index={self._channel_index})"
)
def __copy__(self):
raise ValueError("CompiledDAGRef cannot be copied.")
def __deepcopy__(self, memo):
raise ValueError("CompiledDAGRef cannot be deep copied.")
def __reduce__(self):
raise ValueError("CompiledDAGRef cannot be pickled.")
def __del__(self):
# If the dag is already teardown, it should do nothing.
if self._dag.is_teardown:
return
if self._ray_get_called:
# get() was already called, no further cleanup is needed.
return
self._dag._delete_execution_results(self._execution_index, self._channel_index)
def get(self, timeout: Optional[float] = None):
if self._ray_get_called:
raise ValueError(
"ray.get() can only be called once "
"on a CompiledDAGRef, and it was already called."
)
self._ray_get_called = True
try:
self._dag._execute_until(
self._execution_index, self._channel_index, timeout
)
return_vals = self._dag._get_execution_results(
self._execution_index, self._channel_index
)
except RayChannelTimeoutError:
raise
except RayChannelError as channel_error:
# If we get a channel error, we'd like to call ray.get() on
# the actor execution loop refs to check if this is a result
# of task execution error which could not be passed down
# (e.g., when a pure NCCL channel is used, it is only
# able to send tensors, but not the wrapped exceptions).
# In this case, we'd like to raise the task execution error
# (which is the actual cause of the channel error) instead
# of the channel error itself.
# TODO(rui): determine which error to raise if multiple
# actor task refs have errors.
actor_execution_loop_refs = list(self._dag.worker_task_refs.values())
try:
ray.get(actor_execution_loop_refs, timeout=10)
except GetTimeoutError as timeout_error:
raise Exception(
"Timed out when getting the actor execution loop exception. "
"This should not happen, please file a GitHub issue."
) from timeout_error
except Exception as execution_error:
# Use 'from None' to suppress the context of the original
# channel error, which is not useful to the user.
raise execution_error from None
else:
raise channel_error
except Exception:
raise
return _process_return_vals(return_vals, True)
@PublicAPI(stability="alpha")
class CompiledDAGFuture:
"""
A reference to a compiled DAG execution result, when executed with asyncio.
This differs from CompiledDAGRef in that `await` must be called on the
future to get the result, instead of `ray.get()`.
This resembles async usage of ObjectRefs. For example, similar to
ObjectRef, `await` can be called directly on the CompiledDAGFuture to
retrieve the result. However, there are several major differences:
1. `await` can only be called once per CompiledDAGFuture.
2. ray.wait() is not supported.
3. CompiledDAGFuture cannot be copied, deep copied, or pickled.
4. CompiledDAGFuture cannot be passed as an argument to another task.
"""
def __init__(
self,
dag: "ray.experimental.CompiledDAG",
execution_index: int,
fut: "asyncio.Future",
channel_index: Optional[int] = None,
):
self._dag = dag
self._execution_index = execution_index
self._fut = fut
self._channel_index = channel_index
def __str__(self):
return (
f"CompiledDAGFuture({self._dag.get_id()}, "
f"execution_index={self._execution_index}, "
f"channel_index={self._channel_index})"
)
def __copy__(self):
raise ValueError("CompiledDAGFuture cannot be copied.")
def __deepcopy__(self, memo):
raise ValueError("CompiledDAGFuture cannot be deep copied.")
def __reduce__(self):
raise ValueError("CompiledDAGFuture cannot be pickled.")
def __await__(self):
if self._fut is None:
raise ValueError(
"CompiledDAGFuture can only be awaited upon once, and it has "
"already been awaited upon."
)
# NOTE(swang): If the object is zero-copy deserialized, then it will
# stay in scope as long as this future is in scope. Therefore, we
# delete self._fut here before we return the result to the user.
fut = self._fut
self._fut = None
if not self._dag._has_execution_results(self._execution_index):
result = yield from fut.__await__()
self._dag._max_finished_execution_index += 1
self._dag._cache_execution_results(self._execution_index, result)
return_vals = self._dag._get_execution_results(
self._execution_index, self._channel_index
)
return _process_return_vals(return_vals, True)
def __del__(self):
if self._dag.is_teardown:
return
if self._fut is None:
# await() was already called, no further cleanup is needed.
return
self._dag._delete_execution_results(self._execution_index, self._channel_index)
@@ -0,0 +1,7 @@
def set_resource(resource_name, capacity, node_id=None):
raise DeprecationWarning(
"Dynamic custom resources are deprecated. Consider using placement "
"groups instead (docs.ray.io/en/master/placement-group.html). You "
"can also specify resources at Ray start time with the 'resources' "
"field in the cluster autoscaler."
)
+12
View File
@@ -0,0 +1,12 @@
def type_to_string(_type: type) -> str:
"""Gets the string representation of a type.
THe original type can be derived from the returned string representation through
pydoc.locate().
"""
if _type.__module__ == "typing":
return f"{_type.__module__}.{_type._name}"
elif _type.__module__ == "builtins":
return _type.__name__
else:
return f"{_type.__module__}.{_type.__name__}"
+127
View File
@@ -0,0 +1,127 @@
from typing import List, Optional, Union
from ray._private.client_mode_hook import client_mode_hook
from ray._raylet import GcsClient
_initialized = False
global_gcs_client = None
def _internal_kv_reset():
global global_gcs_client, _initialized
global_gcs_client = None
_initialized = False
def internal_kv_get_gcs_client():
return global_gcs_client
def _initialize_internal_kv(gcs_client: GcsClient):
"""Initialize the internal KV for use in other function calls."""
global global_gcs_client, _initialized
assert gcs_client is not None
global_gcs_client = gcs_client
_initialized = True
@client_mode_hook
def _internal_kv_initialized():
return global_gcs_client is not None
@client_mode_hook
def _internal_kv_get(
key: Union[str, bytes], *, namespace: Optional[Union[str, bytes]] = None
) -> bytes:
"""Fetch the value of a binary key."""
if isinstance(key, str):
key = key.encode()
if isinstance(namespace, str):
namespace = namespace.encode()
assert isinstance(key, bytes)
return global_gcs_client.internal_kv_get(key, namespace)
@client_mode_hook
def _internal_kv_exists(
key: Union[str, bytes], *, namespace: Optional[Union[str, bytes]] = None
) -> bool:
"""Check key exists or not."""
if isinstance(key, str):
key = key.encode()
if isinstance(namespace, str):
namespace = namespace.encode()
assert isinstance(key, bytes)
return global_gcs_client.internal_kv_exists(key, namespace)
@client_mode_hook
def _pin_runtime_env_uri(uri: str, *, expiration_s: int) -> None:
"""Pin a runtime_env URI for expiration_s."""
return global_gcs_client.pin_runtime_env_uri(uri, expiration_s)
@client_mode_hook
def _internal_kv_put(
key: Union[str, bytes],
value: Union[str, bytes],
overwrite: bool = True,
*,
namespace: Optional[Union[str, bytes]] = None
) -> bool:
"""Globally associates a value with a given binary key.
This only has an effect if the key does not already have a value.
Args:
key: The binary key to associate the value with.
value: The binary value to store under the key.
overwrite: Whether to overwrite an existing value for the key.
namespace: Optional namespace under which the key is scoped.
Returns:
Whether the value already exists.
"""
if isinstance(key, str):
key = key.encode()
if isinstance(value, str):
value = value.encode()
if isinstance(namespace, str):
namespace = namespace.encode()
assert (
isinstance(key, bytes)
and isinstance(value, bytes)
and isinstance(overwrite, bool)
)
return global_gcs_client.internal_kv_put(key, value, overwrite, namespace) == 0
@client_mode_hook
def _internal_kv_del(
key: Union[str, bytes],
*,
del_by_prefix: bool = False,
namespace: Optional[Union[str, bytes]] = None
) -> int:
if isinstance(key, str):
key = key.encode()
if isinstance(namespace, str):
namespace = namespace.encode()
assert isinstance(key, bytes)
return global_gcs_client.internal_kv_del(key, del_by_prefix, namespace)
@client_mode_hook
def _internal_kv_list(
prefix: Union[str, bytes], *, namespace: Optional[Union[str, bytes]] = None
) -> List[bytes]:
"""List all keys in the internal KV store that start with the prefix."""
if isinstance(prefix, str):
prefix = prefix.encode()
if isinstance(namespace, str):
namespace = namespace.encode()
return global_gcs_client.internal_kv_keys(prefix, namespace)
@@ -0,0 +1,77 @@
# Regular ray application that user wrote and runs on local cluster.
# intermediate status are dumped to GCS
import argparse
import time
import ray
import ray.experimental.internal_kv as ray_kv
@ray.remote
class StepActor:
def __init__(self, interval_s=1, total_steps=3):
self.interval_s = interval_s
self.stopped = False
self.current_step = 1
self.total_steps = total_steps
worker = ray._private.worker.global_worker
worker_id = worker.core_worker.get_actor_id()
ray_kv._internal_kv_put(f"JOB:{worker_id}", self.current_step, overwrite=True)
def run(self):
worker = ray._private.worker.global_worker
worker_id = worker.core_worker.get_actor_id()
while self.current_step <= self.total_steps:
if not self.stopped:
print(
f"Sleeping {self.interval_s} secs to executing "
f"step {self.current_step}"
)
time.sleep(self.interval_s)
self.current_step += 1
ray_kv._internal_kv_put(
f"JOB:{worker_id}", self.current_step, overwrite=True
)
else:
print("Stop called or reached final step.")
break
self.stopped = True
ray_kv._internal_kv_put(f"JOB:{worker_id}", "DONE", overwrite=True)
return "DONE"
def get_step(self):
return self.current_step
def stop(self):
self.stopped = True
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument(
"--interval-s",
required=False,
type=int,
default=1,
help="Address to use to connect to Ray",
)
parser.add_argument(
"--total-steps",
required=False,
type=int,
default=3,
help="Password for connecting to Redis",
)
args, _ = parser.parse_known_args()
ray.init()
step_actor = StepActor.remote(
interval_s=args.interval_s, total_steps=args.total_steps
)
ref = step_actor.run.remote()
print(ray.get([ref]))
job_key = ray_kv._internal_kv_list("JOB:")[0]
print(f"{job_key}, {ray_kv._internal_kv_get(job_key)}")
@@ -0,0 +1,5 @@
name: example_job
command: python demo_script.py
runtime_env:
working_dir: .
docker: anyscale-ml/ray-ml:nightly-py38-cpu
+76
View File
@@ -0,0 +1,76 @@
from typing import Any, Dict, List
import ray
from ray._raylet import ObjectRef
def get_object_locations(
obj_refs: List[ObjectRef], timeout_ms: int = -1
) -> Dict[ObjectRef, Dict[str, Any]]:
"""Lookup the locations for a list of objects.
It returns a dict maps from an object to its location. The dict excludes
those objects whose location lookup failed.
Args:
obj_refs: List of object refs.
timeout_ms: The maximum amount of time in micro seconds to wait
before returning. Wait infinitely if it's negative.
Returns:
A dict maps from an object to its location. The dict excludes those
objects whose location lookup failed.
The location is stored as a dict with following attributes:
- node_ids (List[str]): The hex IDs of the nodes that have a
copy of this object. Objects less than 100KB will be in memory
store not plasma store and therefore will have nodes_id = [].
- object_size (int): The size of data + metadata in bytes. Can be None if the
size is unknown yet (e.g. task not completed).
Raises:
RuntimeError: if the processes were not started by ray.init().
ray.exceptions.GetTimeoutError: if it couldn't finish the
request in time.
"""
if not ray.is_initialized():
raise RuntimeError("Ray hasn't been initialized.")
return ray._private.worker.global_worker.core_worker.get_object_locations(
obj_refs, timeout_ms
)
def get_local_object_locations(
obj_refs: List[ObjectRef],
) -> Dict[ObjectRef, Dict[str, Any]]:
"""Lookup the locations for a list of objects *from the local core worker*. No RPCs
are made in this method.
It returns a dict maps from an object to its location. The dict excludes
those objects whose location lookup failed.
Args:
obj_refs: List of object refs.
Returns:
A dict maps from an object to its location. The dict excludes those
objects whose location lookup failed.
The location is stored as a dict with following attributes:
- node_ids (List[str]): The hex IDs of the nodes that have a
copy of this object. Objects less than 100KB will be in memory
store not plasma store and therefore will have nodes_id = [].
- object_size (int): The size of data + metadata in bytes. Can be None if the
size is unknown yet (e.g. task not completed).
Raises:
RuntimeError: if the processes were not started by ray.init().
"""
if not ray.is_initialized():
raise RuntimeError("Ray hasn't been initialized.")
core_worker = ray._private.worker.global_worker.core_worker
return core_worker.get_local_object_locations(obj_refs)
@@ -0,0 +1,5 @@
from multiprocessing import TimeoutError
from .pool import Pool
__all__ = ["Pool", "TimeoutError"]
@@ -0,0 +1,5 @@
from ray.util import multiprocessing
class Pool(multiprocessing.Pool):
pass # moved to util package
+13
View File
@@ -0,0 +1,13 @@
import warnings
from ray.util.queue import Empty, Full, Queue
warnings.warn(
DeprecationWarning(
"ray.experimental.queue has been moved to ray.util.queue. "
"Please update your import path."
),
stacklevel=2,
)
__all__ = ["Empty", "Full", "Queue"]
@@ -0,0 +1,44 @@
import os
from ray.experimental.raysort.types import ByteCount, PartId, RecordCount
__DIR__ = os.path.dirname(os.path.abspath(__file__))
# Basics
RECORD_SIZE = 100 # bytes
# Progress Tracker Actor
PROGRESS_TRACKER_ACTOR = "ProgressTrackerActor"
# Executable locations
GENSORT_PATH = os.path.join(__DIR__, "bin/gensort/64/gensort")
VALSORT_PATH = os.path.join(__DIR__, "bin/gensort/64/valsort")
# Filenames
WORK_DIR = "/tmp/raysort"
INPUT_MANIFEST_FILE = os.path.join(WORK_DIR, "input-manifest.csv")
OUTPUT_MANIFEST_FILE = os.path.join(WORK_DIR, "output-manifest.csv")
DATA_DIR_FMT = {
"input": "{mnt}/tmp/input/",
"output": "{mnt}/tmp/output/",
"temp": "{mnt}/tmp/temp/",
}
FILENAME_FMT = {
"input": "input-{part_id:08}",
"output": "output-{part_id:08}",
"temp": "temp-{part_id:08}",
}
# Prometheus config
PROM_RAY_EXPORTER_PORT = 8090
PROM_NODE_EXPORTER_PORT = 8091
# Convenience functions
def bytes_to_records(n_bytes: ByteCount) -> RecordCount:
assert n_bytes % RECORD_SIZE == 0
return int(n_bytes / RECORD_SIZE)
def merge_part_ids(reducer_id: PartId, mapper_id: PartId) -> PartId:
return reducer_id * 1_000_000 + mapper_id
@@ -0,0 +1,9 @@
import logging
def init():
fmt = "%(levelname)s %(asctime)s %(filename)s:%(lineno)s] %(message)s"
logging.basicConfig(
format=fmt,
level=logging.INFO,
)
+475
View File
@@ -0,0 +1,475 @@
import argparse
import contextlib
import csv
import logging
import os
import random
import subprocess
import tempfile
from typing import Callable, Dict, Iterable, List
import numpy as np
import ray
from ray.experimental.raysort import constants, logging_utils, sortlib, tracing_utils
from ray.experimental.raysort.types import (
BlockInfo,
ByteCount,
PartId,
PartInfo,
Path,
RecordCount,
)
from ray.util.scheduling_strategies import PlacementGroupSchedulingStrategy
Args = argparse.Namespace
# ------------------------------------------------------------
# Parse Arguments
# ------------------------------------------------------------
def get_args(*args, **kwargs):
parser = argparse.ArgumentParser()
parser.add_argument(
"--ray_address",
default="auto",
type=str,
help="if set to None, will launch a local Ray cluster",
)
parser.add_argument(
"--total_data_size",
default=1 * 1000 * 1024 * 1024 * 1024,
type=ByteCount,
help="total data size in bytes",
)
parser.add_argument(
"--num_mappers",
default=256,
type=int,
help="number of map tasks",
)
parser.add_argument(
"--num_mappers_per_round",
default=16,
type=int,
help="number of map tasks per first-stage merge tasks",
)
parser.add_argument(
"--num_reducers",
default=16,
type=int,
help="number of second-stage reduce tasks",
)
parser.add_argument(
"--num_concurrent_rounds",
default=4,
type=int,
help="max number of rounds of map/merge tasks in flight",
)
parser.add_argument(
"--reducer_input_chunk",
default=100 * 1024 * 1024,
type=ByteCount,
help="bytes to read from each file in reduce tasks",
)
parser.add_argument(
"--skip_sorting",
default=False,
action="store_true",
help="if set, no sorting is actually performed",
)
parser.add_argument(
"--skip_input",
default=False,
action="store_true",
help="if set, mappers will not read data from disk",
)
parser.add_argument(
"--skip_output",
default=False,
action="store_true",
help="if set, reducers will not write out results to disk",
)
# Which tasks to run?
tasks_group = parser.add_argument_group(
"tasks to run", "if no task is specified, will run all tasks"
)
tasks = ["generate_input", "sort", "validate_output"]
for task in tasks:
tasks_group.add_argument(f"--{task}", action="store_true")
args = parser.parse_args(*args, **kwargs)
# Derive additional arguments.
args.input_part_size = ByteCount(args.total_data_size / args.num_mappers)
assert args.num_mappers % args.num_mappers_per_round == 0
args.num_rounds = int(args.num_mappers / args.num_mappers_per_round)
args.mount_points = _get_mount_points()
# If no tasks are specified, run all tasks.
args_dict = vars(args)
if not any(args_dict[task] for task in tasks):
for task in tasks:
args_dict[task] = True
return args
def _get_mount_points():
default_ret = [tempfile.gettempdir()]
mnt = "/mnt"
if os.path.exists(mnt):
ret = [os.path.join(mnt, d) for d in os.listdir(mnt)]
if len(ret) > 0:
return ret
return default_ret
# ------------------------------------------------------------
# Generate Input
# ------------------------------------------------------------
def _part_info(args: Args, part_id: PartId, kind="input") -> PartInfo:
node = ray._private.worker.global_worker.node_ip_address
mnt = random.choice(args.mount_points)
filepath = _get_part_path(mnt, part_id, kind)
return PartInfo(part_id, node, filepath)
def _get_part_path(mnt: Path, part_id: PartId, kind="input") -> Path:
assert kind in {"input", "output", "temp"}
dir_fmt = constants.DATA_DIR_FMT[kind]
dirpath = dir_fmt.format(mnt=mnt)
os.makedirs(dirpath, exist_ok=True)
filename_fmt = constants.FILENAME_FMT[kind]
filename = filename_fmt.format(part_id=part_id)
filepath = os.path.join(dirpath, filename)
return filepath
@ray.remote
def generate_part(
args: Args, part_id: PartId, size: RecordCount, offset: RecordCount
) -> PartInfo:
logging_utils.init()
pinfo = _part_info(args, part_id)
subprocess.run(
[constants.GENSORT_PATH, f"-b{offset}", f"{size}", pinfo.path], check=True
)
logging.info(f"Generated input {pinfo}")
return pinfo
def generate_input(args: Args):
if args.skip_input:
return
size = constants.bytes_to_records(args.input_part_size)
offset = 0
tasks = []
for part_id in range(args.num_mappers):
tasks.append(generate_part.remote(args, part_id, size, offset))
offset += size
assert offset == constants.bytes_to_records(args.total_data_size), args
logging.info(f"Generating {len(tasks)} partitions")
parts = ray.get(tasks)
with open(constants.INPUT_MANIFEST_FILE, "w") as fout:
writer = csv.writer(fout)
writer.writerows(parts)
# ------------------------------------------------------------
# Sort
# ------------------------------------------------------------
def _load_manifest(args: Args, path: Path) -> List[PartInfo]:
if args.skip_input:
return [PartInfo(i, None, None) for i in range(args.num_mappers)]
with open(path) as fin:
reader = csv.reader(fin)
return [PartInfo(int(part_id), node, path) for part_id, node, path in reader]
def _load_partition(args: Args, path: Path) -> np.ndarray:
if args.skip_input:
return np.frombuffer(
np.random.bytes(args.input_part_size), dtype=np.uint8
).copy()
return np.fromfile(path, dtype=np.uint8)
def _dummy_sort_and_partition(
part: np.ndarray, boundaries: List[int]
) -> List[BlockInfo]:
N = len(boundaries)
offset = 0
size = int(np.ceil(part.size / N))
blocks = []
for _ in range(N):
blocks.append((offset, size))
offset += size
return blocks
@ray.remote
@tracing_utils.timeit("map")
def mapper(
args: Args, mapper_id: PartId, boundaries: List[int], path: Path
) -> List[np.ndarray]:
logging_utils.init()
part = _load_partition(args, path)
sort_fn = (
_dummy_sort_and_partition if args.skip_sorting else sortlib.sort_and_partition
)
blocks = sort_fn(part, boundaries)
return [part[offset : offset + size] for offset, size in blocks]
def _dummy_merge(
num_blocks: int, _n: int, get_block: Callable[[int, int], np.ndarray]
) -> Iterable[np.ndarray]:
blocks = [((i, 0), get_block(i, 0)) for i in range(num_blocks)]
while len(blocks) > 0:
(m, d), block = blocks.pop(random.randrange(len(blocks)))
yield block
d_ = d + 1
block = get_block(m, d_)
if block is None:
continue
blocks.append(((m, d_), block))
def _merge_impl(
args: Args,
M: int,
pinfo: PartInfo,
get_block: Callable[[int, int], np.ndarray],
skip_output=False,
):
merge_fn = _dummy_merge if args.skip_sorting else sortlib.merge_partitions
merger = merge_fn(M, get_block)
if skip_output:
for datachunk in merger:
del datachunk
else:
with open(pinfo.path, "wb") as fout:
for datachunk in merger:
fout.write(datachunk)
return pinfo
# See worker_placement_groups() for why `num_cpus=0`.
@ray.remote(num_cpus=0, resources={"worker": 1})
@tracing_utils.timeit("merge")
def merge_mapper_blocks(
args: Args, reducer_id: PartId, mapper_id: PartId, *blocks: List[np.ndarray]
) -> PartInfo:
part_id = constants.merge_part_ids(reducer_id, mapper_id)
pinfo = _part_info(args, part_id, kind="temp")
M = len(blocks)
def get_block(i, d):
if i >= M or d > 0:
return None
return blocks[i]
return _merge_impl(args, M, pinfo, get_block)
# See worker_placement_groups() for why `num_cpus=0`.
@ray.remote(num_cpus=0, resources={"worker": 1})
@tracing_utils.timeit("reduce")
def final_merge(
args: Args, reducer_id: PartId, *merged_parts: List[PartInfo]
) -> PartInfo:
M = len(merged_parts)
def _load_block_chunk(pinfo: PartInfo, d: int) -> np.ndarray:
return np.fromfile(
pinfo.path,
dtype=np.uint8,
count=args.reducer_input_chunk,
offset=d * args.reducer_input_chunk,
)
def get_block(i, d):
ret = _load_block_chunk(merged_parts[i], d)
if ret.size == 0:
return None
return ret
pinfo = _part_info(args, reducer_id, "output")
return _merge_impl(args, M, pinfo, get_block, args.skip_output)
def _node_res(node: str) -> Dict[str, float]:
return {"resources": {f"node:{node}": 1e-3}}
@contextlib.contextmanager
def worker_placement_groups(args: Args) -> List[ray.PlacementGroupID]:
"""
Returns one placement group per node with a `worker` resource. To run
tasks in the placement group, use
`@ray.remote(num_cpus=0, resources={"worker": 1})`. Ray does not
automatically reserve CPU resources, so tasks must specify `num_cpus=0`
in order to run in a placement group.
"""
pgs = [ray.util.placement_group([{"worker": 1}]) for _ in range(args.num_reducers)]
ray.get([pg.ready() for pg in pgs])
try:
yield pgs
finally:
for pg in pgs:
ray.util.remove_placement_group(pg)
@tracing_utils.timeit("sort", report_time=True)
def sort_main(args: Args):
parts = _load_manifest(args, constants.INPUT_MANIFEST_FILE)
assert len(parts) == args.num_mappers
boundaries = sortlib.get_boundaries(args.num_reducers)
# The exception of 'ValueError("Resource quantities >1 must be whole numbers.")'
# will be raised if the `num_cpus` > 1 and not an integer.
num_cpus = os.cpu_count() / args.num_concurrent_rounds
if num_cpus > 1.0:
num_cpus = int(num_cpus)
mapper_opt = {
"num_returns": args.num_reducers,
"num_cpus": num_cpus,
} # Load balance across worker nodes by setting `num_cpus`.
merge_results = np.empty((args.num_rounds, args.num_reducers), dtype=object)
part_id = 0
with worker_placement_groups(args) as pgs:
for round in range(args.num_rounds):
# Limit the number of in-flight rounds.
num_extra_rounds = round - args.num_concurrent_rounds + 1
if num_extra_rounds > 0:
ray.wait(
[f for f in merge_results.flatten() if f is not None],
num_returns=num_extra_rounds * args.num_reducers,
)
# Submit map tasks.
mapper_results = np.empty(
(args.num_mappers_per_round, args.num_reducers), dtype=object
)
for _ in range(args.num_mappers_per_round):
_, node, path = parts[part_id]
m = part_id % args.num_mappers_per_round
mapper_results[m, :] = mapper.options(**mapper_opt).remote(
args, part_id, boundaries, path
)
part_id += 1
# Submit merge tasks.
merge_results[round, :] = [
merge_mapper_blocks.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pgs[r]
)
).remote(args, r, round, *mapper_results[:, r].tolist())
for r in range(args.num_reducers)
]
# Delete local references to mapper results.
mapper_results = None
# Submit second-stage reduce tasks.
reducer_results = [
final_merge.options(
scheduling_strategy=PlacementGroupSchedulingStrategy(
placement_group=pgs[r]
)
).remote(args, r, *merge_results[:, r].tolist())
for r in range(args.num_reducers)
]
reducer_results = ray.get(reducer_results)
if not args.skip_output:
with open(constants.OUTPUT_MANIFEST_FILE, "w") as fout:
writer = csv.writer(fout)
writer.writerows(reducer_results)
logging.info(ray._private.internal_api.memory_summary(stats_only=True))
# ------------------------------------------------------------
# Validate Output
# ------------------------------------------------------------
def _run_valsort(args: List[str]):
proc = subprocess.run([constants.VALSORT_PATH] + args, capture_output=True)
if proc.returncode != 0:
logging.critical("\n" + proc.stderr.decode("ascii"))
raise RuntimeError(f"Validation failed: {args}")
@ray.remote
def validate_part(path: Path):
logging_utils.init()
sum_path = path + ".sum"
_run_valsort(["-o", sum_path, path])
logging.info(f"Validated output {path}")
with open(sum_path, "rb") as fin:
return os.path.getsize(path), fin.read()
def validate_output(args: Args):
if args.skip_sorting or args.skip_output:
return
partitions = _load_manifest(args, constants.OUTPUT_MANIFEST_FILE)
results = []
for _, node, path in partitions:
results.append(validate_part.options(**_node_res(node)).remote(path))
logging.info(f"Validating {len(results)} partitions")
results = ray.get(results)
total = sum(s for s, _ in results)
assert total == args.total_data_size, total - args.total_data_size
all_checksum = b"".join(c for _, c in results)
with tempfile.NamedTemporaryFile() as fout:
fout.write(all_checksum)
fout.flush()
_run_valsort(["-s", fout.name])
logging.info("All OK!")
# ------------------------------------------------------------
# Main
# ------------------------------------------------------------
def init(args: Args):
if not args.ray_address:
ray.init(resources={"worker": os.cpu_count()})
else:
ray.init(address=args.ray_address)
logging_utils.init()
logging.info(args)
os.makedirs(constants.WORK_DIR, exist_ok=True)
resources = ray.cluster_resources()
logging.info(resources)
args.num_workers = resources["worker"]
progress_tracker = tracing_utils.create_progress_tracker(args)
return progress_tracker
def main(args: Args):
# Keep the actor handle in scope for the duration of the program.
_progress_tracker = init(args) # noqa F841
if args.generate_input:
generate_input(args)
if args.sort:
sort_main(args)
if args.validate_output:
validate_output(args)
if __name__ == "__main__":
main(get_args())
@@ -0,0 +1,28 @@
from typing import Callable, Iterable, List
import numpy as np
from ray.experimental.raysort.types import BlockInfo
def get_boundaries(num_parts: int) -> List[int]:
return [0] * num_parts
def sort_and_partition(part: np.ndarray, boundaries: List[int]) -> List[BlockInfo]:
N = len(boundaries)
offset = 0
size = int(np.ceil(part.size / N))
blocks = []
for _ in range(N):
blocks.append((offset, size))
offset += size
return blocks
def merge_partitions(
num_blocks: int, get_block: Callable[[int, int], np.ndarray]
) -> Iterable[memoryview]:
blocks = [get_block(i, 0) for i in range(num_blocks)]
for block in blocks:
yield block
@@ -0,0 +1,120 @@
import datetime
import functools
import logging
import os
import time
from typing import List, Tuple
import ray
from ray.experimental.raysort import constants, logging_utils
from ray.util.metrics import Gauge, Histogram
HISTOGRAM_BOUNDARIES = list(range(50, 200, 50))
def timeit(
event: str,
report_time=False,
report_in_progress=True,
report_completed=True,
):
def decorator(f):
@functools.wraps(f)
def wrapped_f(*args, **kwargs):
progress_tracker = ray.get_actor(constants.PROGRESS_TRACKER_ACTOR)
progress_tracker.inc.remote(f"{event}_in_progress", echo=report_in_progress)
try:
start = time.time()
ret = f(*args, **kwargs)
end = time.time()
duration = end - start
progress_tracker.observe.remote(
f"{event}_time",
duration,
echo=report_time,
)
progress_tracker.inc.remote(f"{event}_completed", echo=report_completed)
return ret
finally:
progress_tracker.dec.remote(f"{event}_in_progress")
return wrapped_f
return decorator
def get_metrics(_args):
return {
"gauges": [
"map_in_progress",
"merge_in_progress",
"reduce_in_progress",
"sort_in_progress",
"map_completed",
"merge_completed",
"reduce_completed",
"sort_completed",
],
"histograms": [
("map_time", HISTOGRAM_BOUNDARIES),
("merge_time", HISTOGRAM_BOUNDARIES),
("reduce_time", HISTOGRAM_BOUNDARIES),
("sort_time", HISTOGRAM_BOUNDARIES),
],
}
def create_progress_tracker(args):
return ProgressTracker.options(name=constants.PROGRESS_TRACKER_ACTOR).remote(
**get_metrics(args)
)
@ray.remote
class ProgressTracker:
def __init__(
self,
gauges: List[str],
histograms: List[Tuple[str, List[int]]],
):
self.counts = {m: 0 for m in gauges}
self.gauges = {m: Gauge(m) for m in gauges}
self.reset_gauges()
self.histograms = {m: Histogram(m, boundaries=b) for m, b in histograms}
logging_utils.init()
def reset_gauges(self):
for g in self.gauges.values():
g.set(0)
def inc(self, metric_name, value=1, echo=False):
gauge = self.gauges.get(metric_name)
if gauge is None:
logging.warning(f"No such Gauge: {metric_name}")
return
self.counts[metric_name] += value
gauge.set(self.counts[metric_name])
if echo:
logging.info(f"{metric_name} {self.counts[metric_name]}")
def dec(self, metric_name, value=1, echo=False):
return self.inc(metric_name, -value, echo)
def observe(self, metric_name, value, echo=False):
histogram = self.histograms.get(metric_name)
if histogram is None:
logging.warning(f"No such Histogram: {metric_name}")
return
histogram.observe(value)
if echo:
logging.info(f"{metric_name} {value}")
def export_timeline():
timestr = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"ray-timeline-{timestr}.json"
temp_dir = ray.get_runtime_context().get_temp_dir()
filepath = os.path.join(temp_dir, filename)
ray.timeline(filename=filepath)
logging.info(f"Exported Ray timeline to {filepath}")
+18
View File
@@ -0,0 +1,18 @@
from typing import NamedTuple, Tuple
ByteCount = int
NodeAddress = str
PartId = int
Path = str
RecordCount = int
BlockInfo = Tuple[int, int]
class PartInfo(NamedTuple):
part_id: PartId
node: NodeAddress
path: Path
def __repr__(self):
return f"Part({self.node}:{self.path})"
+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
+358
View File
@@ -0,0 +1,358 @@
"""A simple distributed shuffle implementation in Ray.
This utility provides a `simple_shuffle` function that can be used to
redistribute M input partitions into N output partitions. It does this with
a single wave of shuffle map tasks followed by a single wave of shuffle reduce
tasks. Each shuffle map task generates O(N) output objects, and each shuffle
reduce task consumes O(M) input objects, for a total of O(N*M) objects.
To try an example 10GB shuffle, run:
$ python -m ray.experimental.shuffle \
--num-partitions=50 --partition-size=200e6 \
--object-store-memory=1e9
This will print out some statistics on the shuffle execution such as:
--- Aggregate object store stats across all nodes ---
Plasma memory usage 0 MiB, 0 objects, 0.0% full
Spilled 9487 MiB, 2487 objects, avg write throughput 1023 MiB/s
Restored 9487 MiB, 2487 objects, avg read throughput 1358 MiB/s
Objects consumed by Ray tasks: 9537 MiB.
Shuffled 9536 MiB in 16.579771757125854 seconds
"""
import time
from typing import Any, Callable, Iterable, List, Tuple, Union
import ray
from ray import ObjectRef
from ray.cluster_utils import Cluster
# TODO(ekl) why doesn't TypeVar() deserialize properly in Ray?
# The type produced by the input reader function.
InType = Any
# The type produced by the output writer function.
OutType = Any
# Integer identifying the partition number.
PartitionID = int
class ObjectStoreWriter:
"""This class is used to stream shuffle map outputs to the object store.
It can be subclassed to optimize writing (e.g., batching together small
records into larger objects). This will be performance critical if your
input records are small (the example shuffle uses very large records, so
the naive strategy works well).
"""
def __init__(self):
self.results = []
def add(self, item: InType) -> None:
"""Queue a single item to be written to the object store.
This base implementation immediately writes each given item to the
object store as a standalone object.
"""
self.results.append(ray.put(item))
def finish(self) -> List[ObjectRef]:
"""Return list of object refs representing written items."""
return self.results
class ObjectStoreWriterNonStreaming(ObjectStoreWriter):
def __init__(self):
self.results = []
def add(self, item: InType) -> None:
self.results.append(item)
def finish(self) -> List[Any]:
return self.results
def round_robin_partitioner(
input_stream: Iterable[InType], num_partitions: int
) -> Iterable[Tuple[PartitionID, InType]]:
"""Round robin partitions items from the input reader.
You can write custom partitioning functions for your use case.
Args:
input_stream: Iterator over items from the input reader.
num_partitions: Number of output partitions.
Yields:
Tuple[PartitionID, InType]: A tuple of partition id and the
corresponding input item.
"""
i = 0
for item in input_stream:
yield (i, item)
i += 1
i %= num_partitions
@ray.remote
class _StatusTracker:
def __init__(self):
self.num_map = 0
self.num_reduce = 0
self.map_refs = []
self.reduce_refs = []
def register_objectrefs(self, map_refs, reduce_refs):
self.map_refs = map_refs
self.reduce_refs = reduce_refs
def get_progress(self):
if self.map_refs:
ready, self.map_refs = ray.wait(
self.map_refs,
timeout=1,
num_returns=len(self.map_refs),
fetch_local=False,
)
self.num_map += len(ready)
elif self.reduce_refs:
ready, self.reduce_refs = ray.wait(
self.reduce_refs,
timeout=1,
num_returns=len(self.reduce_refs),
fetch_local=False,
)
self.num_reduce += len(ready)
return self.num_map, self.num_reduce
def render_progress_bar(tracker, input_num_partitions, output_num_partitions):
from tqdm import tqdm
num_map = 0
num_reduce = 0
map_bar = tqdm(total=input_num_partitions, position=0)
map_bar.set_description("Map Progress.")
reduce_bar = tqdm(total=output_num_partitions, position=1)
reduce_bar.set_description("Reduce Progress.")
while num_map < input_num_partitions or num_reduce < output_num_partitions:
new_num_map, new_num_reduce = ray.get(tracker.get_progress.remote())
map_bar.update(new_num_map - num_map)
reduce_bar.update(new_num_reduce - num_reduce)
num_map = new_num_map
num_reduce = new_num_reduce
time.sleep(0.1)
map_bar.close()
reduce_bar.close()
def simple_shuffle(
*,
input_reader: Callable[[PartitionID], Iterable[InType]],
input_num_partitions: int,
output_num_partitions: int,
output_writer: Callable[[PartitionID, List[Union[ObjectRef, Any]]], OutType],
partitioner: Callable[
[Iterable[InType], int], Iterable[PartitionID]
] = round_robin_partitioner,
object_store_writer: ObjectStoreWriter = ObjectStoreWriter,
tracker: _StatusTracker = None,
streaming: bool = True,
) -> List[OutType]:
"""Simple distributed shuffle in Ray.
Args:
input_reader: Function that generates the input items for a
partition (e.g., data records).
input_num_partitions: The number of input partitions.
output_num_partitions: The desired number of output partitions.
output_writer: Function that consumes a iterator of items for a
given output partition. It returns a single value that will be
collected across all output partitions.
partitioner: Partitioning function to use. Defaults to round-robin
partitioning of input items.
object_store_writer: Class used to write input items to the
object store in an efficient way. Defaults to a naive
implementation that writes each input record as one object.
tracker: Tracker actor that is used to display the progress bar.
streaming: Whether or not if the shuffle will be streaming.
Returns:
List of outputs from the output writers.
"""
@ray.remote(num_returns=output_num_partitions)
def shuffle_map(i: PartitionID) -> List[List[Union[Any, ObjectRef]]]:
writers = [object_store_writer() for _ in range(output_num_partitions)]
for out_i, item in partitioner(input_reader(i), output_num_partitions):
writers[out_i].add(item)
return [c.finish() for c in writers]
@ray.remote
def shuffle_reduce(
i: PartitionID, *mapper_outputs: List[List[Union[Any, ObjectRef]]]
) -> OutType:
input_objects = []
assert len(mapper_outputs) == input_num_partitions
for obj_refs in mapper_outputs:
for obj_ref in obj_refs:
input_objects.append(obj_ref)
return output_writer(i, input_objects)
shuffle_map_out = [shuffle_map.remote(i) for i in range(input_num_partitions)]
shuffle_reduce_out = [
shuffle_reduce.remote(
j, *[shuffle_map_out[i][j] for i in range(input_num_partitions)]
)
for j in range(output_num_partitions)
]
if tracker:
tracker.register_objectrefs.remote(
[map_out[0] for map_out in shuffle_map_out], shuffle_reduce_out
)
render_progress_bar(tracker, input_num_partitions, output_num_partitions)
return ray.get(shuffle_reduce_out)
def build_cluster(num_nodes, num_cpus, object_store_memory):
cluster = Cluster()
for _ in range(num_nodes):
cluster.add_node(num_cpus=num_cpus, object_store_memory=object_store_memory)
cluster.wait_for_nodes()
return cluster
def run(
ray_address=None,
object_store_memory=1e9,
num_partitions=5,
partition_size=200e6,
num_nodes=None,
num_cpus=8,
no_streaming=False,
use_wait=False,
tracker=None,
):
import time
import numpy as np
is_multi_node = num_nodes
if ray_address:
print("Connecting to a existing cluster...")
ray.init(address=ray_address, ignore_reinit_error=True)
elif is_multi_node:
print("Emulating a cluster...")
print(f"Num nodes: {num_nodes}")
print(f"Num CPU per node: {num_cpus}")
print(f"Object store memory per node: {object_store_memory}")
cluster = build_cluster(num_nodes, num_cpus, object_store_memory)
ray.init(address=cluster.address)
else:
print("Start a new cluster...")
ray.init(num_cpus=num_cpus, object_store_memory=object_store_memory)
partition_size = int(partition_size)
num_partitions = num_partitions
rows_per_partition = partition_size // (8 * 2)
if tracker is None:
tracker = _StatusTracker.remote()
use_wait = use_wait
def input_reader(i: PartitionID) -> Iterable[InType]:
for _ in range(num_partitions):
yield np.ones((rows_per_partition // num_partitions, 2), dtype=np.int64)
def output_writer(i: PartitionID, shuffle_inputs: List[ObjectRef]) -> OutType:
total = 0
if not use_wait:
for obj_ref in shuffle_inputs:
arr = ray.get(obj_ref)
total += arr.size * arr.itemsize
else:
while shuffle_inputs:
[ready], shuffle_inputs = ray.wait(shuffle_inputs, num_returns=1)
arr = ray.get(ready)
total += arr.size * arr.itemsize
return total
def output_writer_non_streaming(
i: PartitionID, shuffle_inputs: List[Any]
) -> OutType:
total = 0
for arr in shuffle_inputs:
total += arr.size * arr.itemsize
return total
if no_streaming:
output_writer_callable = output_writer_non_streaming
object_store_writer = ObjectStoreWriterNonStreaming
else:
object_store_writer = ObjectStoreWriter
output_writer_callable = output_writer
start = time.time()
output_sizes = simple_shuffle(
input_reader=input_reader,
input_num_partitions=num_partitions,
output_num_partitions=num_partitions,
output_writer=output_writer_callable,
object_store_writer=object_store_writer,
tracker=tracker,
)
delta = time.time() - start
time.sleep(0.5)
print()
summary = None
for i in range(5):
try:
summary = ray._private.internal_api.memory_summary(stats_only=True)
except Exception:
time.sleep(1)
pass
if summary:
break
print(summary)
print()
print(
"Shuffled", int(sum(output_sizes) / (1024 * 1024)), "MiB in", delta, "seconds"
)
def main():
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--ray-address", type=str, default=None)
parser.add_argument("--object-store-memory", type=float, default=1e9)
parser.add_argument("--num-partitions", type=int, default=5)
parser.add_argument("--partition-size", type=float, default=200e6)
parser.add_argument("--num-nodes", type=int, default=None)
parser.add_argument("--num-cpus", type=int, default=8)
parser.add_argument("--no-streaming", action="store_true", default=False)
parser.add_argument("--use-wait", action="store_true", default=False)
args = parser.parse_args()
run(
ray_address=args.ray_address,
object_store_memory=args.object_store_memory,
num_partitions=args.num_partitions,
partition_size=args.partition_size,
num_nodes=args.num_nodes,
num_cpus=args.num_cpus,
no_streaming=args.no_streaming,
use_wait=args.use_wait,
)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
from ray.util.state import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
+4
View File
@@ -0,0 +1,4 @@
from ray.util.state.common import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
@@ -0,0 +1,4 @@
from ray._private.custom_types import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
@@ -0,0 +1,4 @@
from ray.util.state.exception import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
@@ -0,0 +1,4 @@
from ray.util.state.state_cli import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
@@ -0,0 +1,4 @@
from ray.util.state.state_manager import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
+4
View File
@@ -0,0 +1,4 @@
from ray.util.state.util import * # noqa: F401 F403
from ray.util.state.util import record_deprecated_state_api_import
record_deprecated_state_api_import()
+4
View File
@@ -0,0 +1,4 @@
raise ImportError(
"ray.experimental.tf_utils has been removed. "
"Use: from ray.rllib.utils import tf_utils."
)
+425
View File
@@ -0,0 +1,425 @@
import builtins
import copy
import json
import logging
import os
import sys
import threading
import time
import uuid
from typing import Any, Dict, Iterable, Optional
import colorama
import ray
from ray._private.ray_constants import env_bool
from ray.util.debug import log_once
try:
import tqdm.auto as real_tqdm
except ImportError:
real_tqdm = None
logger = logging.getLogger(__name__)
# Describes the state of a single progress bar.
ProgressBarState = Dict[str, Any]
# Magic token used to identify Ray TQDM log lines.
RAY_TQDM_MAGIC = "__ray_tqdm_magic_token__"
# Global manager singleton.
_manager: Optional["_BarManager"] = None
_mgr_lock = threading.Lock()
_print = builtins.print
def safe_print(*args, **kwargs):
"""Use this as an alternative to `print` that will not corrupt tqdm output.
By default, the builtin print will be patched to this function when tqdm_ray is
used. To disable this, set RAY_TQDM_PATCH_PRINT=0.
"""
# Ignore prints to StringIO objects, etc.
if kwargs.get("file") not in [sys.stdout, sys.stderr, None]:
return _print(*args, **kwargs)
try:
instance().hide_bars()
_print(*args, **kwargs)
finally:
instance().unhide_bars()
class tqdm:
"""Experimental: Ray distributed tqdm implementation.
This class lets you use tqdm from any Ray remote task or actor, and have the
progress centrally reported from the driver. This avoids issues with overlapping
/ conflicting progress bars, as the driver centrally manages tqdm positions.
Supports a limited subset of tqdm args.
"""
DEFAULT_FLUSH_INTERVAL_SECONDS = 1.0
def __init__(
self,
iterable: Optional[Iterable] = None,
desc: Optional[str] = None,
total: Optional[int] = None,
unit: Optional[str] = None,
position: Optional[int] = None,
flush_interval_s: Optional[float] = None,
):
import ray._private.services as services
if total is None and iterable is not None:
try:
total = len(iterable)
except (TypeError, AttributeError):
total = None
self._iterable = iterable
self._desc = desc or ""
self._total = total
self._unit = unit or "it"
self._ip = services.get_node_ip_address()
self._pid = os.getpid()
self._pos = position or 0
self._uuid = uuid.uuid4().hex
self._x = 0
self._closed = False
self._flush_interval_s = (
flush_interval_s
if flush_interval_s is not None
else self.DEFAULT_FLUSH_INTERVAL_SECONDS
)
self._last_flush_time = 0.0
def set_description(self, desc):
"""Implements tqdm.tqdm.set_description."""
self._desc = desc
self._dump_state()
def update(self, n=1):
"""Implements tqdm.tqdm.update."""
self._x += n
self._dump_state()
def close(self):
"""Implements tqdm.tqdm.close."""
self._closed = True
# Don't bother if ray is shutdown (in __del__ hook).
if ray is not None:
self._dump_state(force_flush=True)
def refresh(self):
"""Implements tqdm.tqdm.refresh."""
self._dump_state()
@property
def total(self) -> Optional[int]:
return self._total
@total.setter
def total(self, total: int):
self._total = total
@property
def n(self) -> int:
return self._x
@n.setter
def n(self, n: int):
self._x = n
def _dump_state(self, force_flush=False) -> None:
now = time.time()
if not force_flush and now - self._last_flush_time < self._flush_interval_s:
return
self._last_flush_time = now
if ray._private.worker.global_worker.mode == ray.WORKER_MODE:
# Include newline in payload to avoid split prints.
# TODO(ekl) we should move this to events.json to avoid log corruption.
print(json.dumps(self._get_state()) + "\n", end="")
else:
instance().process_state_update(copy.deepcopy(self._get_state()))
def _get_state(self) -> ProgressBarState:
return {
"__magic_token__": RAY_TQDM_MAGIC,
"x": self._x,
"pos": self._pos,
"desc": self._desc,
"total": self._total,
"unit": self._unit,
"ip": self._ip,
"pid": self._pid,
"uuid": self._uuid,
"closed": self._closed,
}
def __iter__(self):
if self._iterable is None:
raise ValueError("No iterable provided")
for x in iter(self._iterable):
self.update(1)
yield x
class _Bar:
"""Manages a single virtual progress bar on the driver.
The actual position of individual bars is calculated as (pos_offset + position),
where `pos_offset` is the position offset determined by the BarManager.
"""
def __init__(self, state: ProgressBarState, pos_offset: int):
"""Initialize a bar.
Args:
state: The initial progress bar state.
pos_offset: The position offset determined by the BarManager.
"""
self.state = state
self.pos_offset = pos_offset
self.bar = real_tqdm.tqdm(
desc=state["desc"],
total=state["total"],
unit=state["unit"],
position=pos_offset + state["pos"],
dynamic_ncols=True,
unit_scale=True,
)
if state["x"]:
self.bar.update(state["x"])
def update(self, state: ProgressBarState) -> None:
"""Apply the updated worker progress bar state."""
if state["desc"] != self.state["desc"]:
self.bar.set_description(state["desc"])
if state["total"] != self.state["total"]:
self.bar.total = state["total"]
self.bar.refresh()
delta = state["x"] - self.state["x"]
if delta:
self.bar.update(delta)
self.bar.refresh()
self.state = state
def close(self):
"""The progress bar has been closed."""
self.bar.close()
def update_offset(self, pos_offset: int) -> None:
"""Update the position offset assigned by the BarManager."""
if pos_offset != self.pos_offset:
self.pos_offset = pos_offset
self.bar.clear()
self.bar.pos = -(pos_offset + self.state["pos"])
self.bar.refresh()
class _BarGroup:
"""Manages a group of virtual progress bar produced by a single worker.
All the progress bars in the group have the same `pos_offset` determined by the
BarManager for the process.
"""
def __init__(self, ip, pid, pos_offset):
self.ip = ip
self.pid = pid
self.pos_offset = pos_offset
self.bars_by_uuid: Dict[str, _Bar] = {}
def has_bar(self, bar_uuid) -> bool:
"""Return whether this bar exists."""
return bar_uuid in self.bars_by_uuid
def allocate_bar(self, state: ProgressBarState) -> None:
"""Add a new bar to this group."""
self.bars_by_uuid[state["uuid"]] = _Bar(state, self.pos_offset)
def update_bar(self, state: ProgressBarState) -> None:
"""Update the state of a managed bar in this group."""
bar = self.bars_by_uuid[state["uuid"]]
bar.update(state)
def close_bar(self, state: ProgressBarState) -> None:
"""Remove a bar from this group."""
bar = self.bars_by_uuid[state["uuid"]]
# Note: Hide and then unhide bars to prevent flashing of the
# last bar when we are closing multiple bars sequentially.
instance().hide_bars()
bar.close()
del self.bars_by_uuid[state["uuid"]]
instance().unhide_bars()
def slots_required(self):
"""Return the number of pos slots we need to accommodate bars in this group."""
if not self.bars_by_uuid:
return 0
return 1 + max(bar.state["pos"] for bar in self.bars_by_uuid.values())
def update_offset(self, offset: int) -> None:
"""Update the position offset assigned by the BarManager."""
if offset != self.pos_offset:
self.pos_offset = offset
for bar in self.bars_by_uuid.values():
bar.update_offset(offset)
def hide_bars(self) -> None:
"""Temporarily hide visible bars to avoid conflict with other log messages."""
for bar in self.bars_by_uuid.values():
bar.bar.clear()
def unhide_bars(self) -> None:
"""Opposite of hide_bars()."""
for bar in self.bars_by_uuid.values():
bar.bar.refresh()
class _BarManager:
"""Central tqdm manager run on the driver.
This class holds a collection of BarGroups and updates their `pos_offset` as
needed to ensure individual progress bars do not collide in position, kind of
like a virtual memory manager.
"""
def __init__(self):
import ray._private.services as services
self.ip = services.get_node_ip_address()
self.pid = os.getpid()
self.bar_groups = {}
self.in_hidden_state = False
self.num_hides = 0
self.lock = threading.RLock()
# Avoid colorizing Jupyter output, since the tqdm bar is rendered in
# ipywidgets instead of in the console.
self.should_colorize = not ray.widgets.util.in_notebook()
def process_state_update(self, state: ProgressBarState) -> None:
"""Apply the remote progress bar state update.
This creates a new bar locally if it doesn't already exist. When a bar is
created or destroyed, we also recalculate and update the `pos_offset` of each
BarGroup on the screen.
"""
with self.lock:
self._process_state_update_locked(state)
def _process_state_update_locked(self, state: ProgressBarState) -> None:
if not real_tqdm:
if log_once("no_tqdm"):
logger.warning("tqdm is not installed. Progress bars will be disabled.")
return
if state["ip"] == self.ip:
if state["pid"] == self.pid:
prefix = ""
else:
prefix = "(pid={}) ".format(state.get("pid"))
if self.should_colorize:
prefix = "{}{}{}{}".format(
colorama.Style.DIM,
colorama.Fore.CYAN,
prefix,
colorama.Style.RESET_ALL,
)
else:
prefix = "(pid={}, ip={}) ".format(
state.get("pid"),
state.get("ip"),
)
if self.should_colorize:
prefix = "{}{}{}{}".format(
colorama.Style.DIM,
colorama.Fore.CYAN,
prefix,
colorama.Style.RESET_ALL,
)
state["desc"] = prefix + state["desc"]
process = self._get_or_allocate_bar_group(state)
if process.has_bar(state["uuid"]):
# Always call `update_bar` to sync any last remaining updates
# prior to closing. Otherwise, the displayed progress bars
# can be left incomplete, even after execution finishes.
# Fixes https://github.com/ray-project/ray/issues/44983
process.update_bar(state)
if state["closed"]:
process.close_bar(state)
self._update_offsets()
else:
process.allocate_bar(state)
self._update_offsets()
def hide_bars(self) -> None:
"""Temporarily hide visible bars to avoid conflict with other log messages."""
with self.lock:
if not self.in_hidden_state:
self.in_hidden_state = True
self.num_hides += 1
for group in self.bar_groups.values():
group.hide_bars()
def unhide_bars(self) -> None:
"""Opposite of hide_bars()."""
with self.lock:
if self.in_hidden_state:
self.in_hidden_state = False
for group in self.bar_groups.values():
group.unhide_bars()
def _get_or_allocate_bar_group(self, state: ProgressBarState):
ptuple = (state["ip"], state["pid"])
if ptuple not in self.bar_groups:
offset = sum(p.slots_required() for p in self.bar_groups.values())
self.bar_groups[ptuple] = _BarGroup(state["ip"], state["pid"], offset)
return self.bar_groups[ptuple]
def _update_offsets(self):
offset = 0
for proc in self.bar_groups.values():
proc.update_offset(offset)
offset += proc.slots_required()
def instance() -> _BarManager:
"""Get or create a BarManager for this process."""
global _manager
with _mgr_lock:
if _manager is None:
_manager = _BarManager()
if env_bool("RAY_TQDM_PATCH_PRINT", True):
import builtins
builtins.print = safe_print
return _manager
if __name__ == "__main__":
@ray.remote
def processing(delay):
def sleep(x):
print("Intermediate result", x)
time.sleep(delay)
return x
ray.data.range(1000, override_num_blocks=100).map(
sleep, compute=ray.data.ActorPoolStrategy(size=1)
).count()
ray.get(
[
processing.remote(0.03),
processing.remote(0.01),
processing.remote(0.05),
]
)
+46
View File
@@ -0,0 +1,46 @@
from dataclasses import dataclass
from enum import Enum
from ray.util.annotations import PublicAPI
class _CollectiveOp:
pass
@PublicAPI
class ReduceOp(Enum):
SUM = 0
PRODUCT = 1
MAX = 2
MIN = 3
AVG = 4
@PublicAPI
@dataclass
class AllGatherOp(_CollectiveOp):
pass
@PublicAPI
@dataclass
class AllReduceOp(_CollectiveOp):
reduceOp: ReduceOp = ReduceOp.SUM
@PublicAPI
@dataclass
class ReduceScatterOp(_CollectiveOp):
reduceOp: ReduceOp = ReduceOp.SUM
@PublicAPI(stability="alpha")
class Device(Enum):
DEFAULT = "default"
CPU = "cpu"
GPU = "gpu"
CUDA = "cuda"
def __str__(self):
return self.value