Files
2026-07-13 13:17:40 +08:00

381 lines
14 KiB
Python

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()