chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:17:40 +08:00
commit f1825c8ceb
10096 changed files with 2364182 additions and 0 deletions
@@ -0,0 +1,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()
)
)