Rate this Page

Source code for monarch._src.rdma.rdma

# Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.

# pyre-unsafe
import functools
import logging
import sys
import threading
import warnings
import weakref
from typing import Any, cast, Dict, Optional, Tuple, TYPE_CHECKING

if TYPE_CHECKING:
    import torch

from monarch._rust_bindings.monarch_hyperactor.pytokio import PythonTask, Shared
from monarch._src.actor.actor_mesh import Actor, context
from monarch._src.actor.endpoint import endpoint
from monarch._src.actor.future import Future
from monarch._src.actor.proc_mesh import get_or_spawn_controller, ProcMesh
from pyre_extensions import none_throws

_NATIVE_RDMA_IMPORT_ERROR: Optional[ImportError] = None

try:
    from monarch._rust_bindings.rdma import (
        _assert_1d_contiguous,
        _get_memoryview_addr_and_size,
        _get_tensor_addr_and_size,
        _LocalMemoryHandle,
        _make_local_memory_handle_from_memoryview,
        _make_local_memory_handle_from_tensor,
        _RdmaAction,
        _RdmaBuffer,
        _RdmaManager,
        _WeakLocalMemoryHandle,
        is_ibverbs_available as _is_ibverbs_available,
        rdma_supported as _rdma_supported,
    )
except ImportError as e:
    # These fallbacks let the module import on platforms without the native
    # RDMA bindings; every entry point raises on use. We hide them from the
    # type checker with `if not TYPE_CHECKING` so it resolves these names to
    # their real types from the `try` import (via the `.pyi` stub) rather
    # than the catch-all `_UnavailableNativeBinding`, which has none of the
    # real attributes. At runtime `TYPE_CHECKING` is `False`, so the
    # fallbacks below are the ones that take effect.
    if not TYPE_CHECKING:
        _NATIVE_RDMA_IMPORT_ERROR = e
        logging.warning("RDMA native bindings are not available: %s", e)

        class _UnavailableNativeBinding:
            def __init__(self, *args: Any, **kwargs: Any) -> None:
                raise ImportError(
                    "RDMA native bindings are not available on this platform"
                ) from _NATIVE_RDMA_IMPORT_ERROR

        _LocalMemoryHandle = _UnavailableNativeBinding
        _WeakLocalMemoryHandle = _UnavailableNativeBinding
        _RdmaAction = _UnavailableNativeBinding
        _RdmaBuffer = _UnavailableNativeBinding
        _RdmaManager = _UnavailableNativeBinding

        def _make_local_memory_handle_from_memoryview(mv: memoryview) -> Any:
            raise ImportError(
                "RDMA native bindings are not available on this platform"
            ) from _NATIVE_RDMA_IMPORT_ERROR

        def _make_local_memory_handle_from_tensor(tensor: Any) -> Any:
            raise ImportError(
                "RDMA native bindings are not available on this platform"
            ) from _NATIVE_RDMA_IMPORT_ERROR

        def _assert_1d_contiguous(buf: Any) -> None:
            raise ImportError(
                "RDMA native bindings are not available on this platform"
            ) from _NATIVE_RDMA_IMPORT_ERROR

        def _get_memoryview_addr_and_size(mv: memoryview) -> tuple[int, int]:
            raise ImportError(
                "RDMA native bindings are not available on this platform"
            ) from _NATIVE_RDMA_IMPORT_ERROR

        def _get_tensor_addr_and_size(tensor: Any) -> tuple[int, int]:
            raise ImportError(
                "RDMA native bindings are not available on this platform"
            ) from _NATIVE_RDMA_IMPORT_ERROR

        def _is_ibverbs_available() -> bool:
            return False

        def _rdma_supported() -> bool:
            return False


# RDMARead/WriteTransferWarnings are warnings that are only printed once per process.
# Remove these once GPU support is added.
class RDMAReadTransferWarning(Warning):
    pass


class RDMAWriteTransferWarning(Warning):
    pass


class RDMATcpFallbackWarning(Warning):
    pass


warnings.simplefilter("once", RDMAReadTransferWarning)
warnings.simplefilter("once", RDMAWriteTransferWarning)
warnings.simplefilter("once", RDMATcpFallbackWarning)


[docs]def is_ibverbs_available() -> bool: """Whether ibverbs RDMA hardware is available on this system.""" return _is_ibverbs_available()
[docs]def is_rdma_available() -> bool: """Whether RDMA over ibverbs is available on this system. .. deprecated:: Monarch now supports multiple RDMA backends, so `is_rdma_available` is ambiguous and will be removed in a future release. Use :func:`is_ibverbs_available` or :func:`get_rdma_backend` instead. """ warnings.warn( "is_rdma_available is deprecated because Monarch now supports multiple " "RDMA backends, making this function ambiguous. For now it indicates " "whether RDMA over ibverbs is available. Use is_ibverbs_available() or " "get_rdma_backend() instead.", DeprecationWarning, stacklevel=2, ) return is_ibverbs_available()
[docs]def get_rdma_backend() -> str: """Return available RDMA backend. Returns: str: One of 'ibverbs', 'tcp', or 'none' indicating the available backend. Both Mellanox and EFA hardware are accessed through ibverbs. 'tcp' indicates the TCP fallback transport is enabled. """ if _is_ibverbs_available(): return "ibverbs" if _rdma_supported(): return "tcp" return "none"
# Cached so that we don't have to call out to the root client every time, # which may be on a different host. @functools.cache def _ensure_init_rdma_manager() -> Shared[None]: """Initialize the RDMA manager for this node's backend (ibverbs or EFA).""" async def task() -> None: # Ensure the proc mesh is initialized before we can send it over the wire, # since pickling the proc mesh before it is initiliazed would block the # tokio runtime and cause a panic. await context().actor_instance.proc_mesh.initialized await ( await get_or_spawn_controller("rdma_controller", RdmaController) ).init_rdma_on_mesh.call_one(none_throws(context().actor_instance.proc_mesh)) return PythonTask.from_coroutine(task()).spawn() def _is_torch_tensor(obj: object) -> bool: """Check whether obj is a torch.Tensor without importing torch.""" torch_mod = sys.modules.get("torch") if torch_mod is None: return False return isinstance(obj, torch_mod.Tensor) # Cache of weak handles to local memory regions, keyed by # `(backing_id, addr, size)`. `backing_id` is `id(t.untyped_storage())` # for tensors and `id(mv)` for memoryviews — the id of the stable # backing object, not of any per-call view. Transient tensor views like # `tensor.view(...).flatten()` share their storage's id and so reuse one # entry; distinct memoryviews over the same buffer keep separate entries # (their ids differ). `addr` and `size` further disambiguate slices of # one backing. # # The cached `_WeakLocalMemoryHandle` does NOT pin the backing, so the # cache cannot prolong an allocation's lifetime. Eviction is driven by # a `weakref` to the backing — a tensor's `untyped_storage()` or the # memoryview itself — whose callback drops the entry once the backing # is garbage-collected. _local_memory_cache: "Dict[Tuple[int, int, int], _WeakLocalMemoryHandle]" = {} # Eviction weakrefs, keyed identically. Holding the weakref alive is # what lets its callback fire; the callback removes the entry only when # its own weakref is still the registered one, so a stale callback # cannot evict a fresh registration whose backing reused a freed id. _local_memory_cache_refs: "Dict[Tuple[int, int, int], weakref.ref]" = {} # Serializes the get/upgrade/insert and eviction sequences; the GIL # only makes the individual dict ops atomic. _local_memory_cache_lock = threading.Lock() def _evict_local_memory(key: Tuple[int, int, int], ref: "weakref.ref") -> None: """Drop the cache entry for ``key`` when its backing is collected. Guards against id reuse: a newer registration whose backing reused a freed object's id installs a fresh weakref under the same key, so this fires only when the registered weakref is still the one that scheduled the callback. """ with _local_memory_cache_lock: if _local_memory_cache_refs.get(key) is ref: del _local_memory_cache_refs[key] _local_memory_cache.pop(key, None) def _make_local_memory_handle( data: "torch.Tensor | memoryview", ) -> _LocalMemoryHandle: _assert_1d_contiguous(data) if isinstance(data, memoryview): addr, size = _get_memoryview_addr_and_size(data) backing = data elif _is_torch_tensor(data): addr, size = _get_tensor_addr_and_size(data) backing = data.untyped_storage() # type: ignore[union-attr] else: raise RuntimeError( "Trying to make a local memory handle for an unsupported type. " "Expected memoryview or torch.Tensor. Got: {}".format(type(data)) ) key = (id(backing), addr, size) with _local_memory_cache_lock: weak = _local_memory_cache.get(key) if weak is not None: cached = weak.upgrade() if cached is not None: return cached # The backing is gone but its eviction callback has not run # yet; drop the stale entry now. _local_memory_cache.pop(key, None) _local_memory_cache_refs.pop(key, None) if isinstance(data, memoryview): strong = _make_local_memory_handle_from_memoryview(data) else: strong = _make_local_memory_handle_from_tensor(data) weak = strong.downgrade() if weak is not None: ref = weakref.ref(backing, lambda _r, _k=key: _evict_local_memory(_k, _r)) with _local_memory_cache_lock: _local_memory_cache[key] = weak _local_memory_cache_refs[key] = ref return strong class RdmaController(Actor): def __init__(self) -> None: self._manager_futures: Dict[ProcMesh, Future[_RdmaManager]] = {} @endpoint async def init_rdma_on_mesh(self, proc_mesh: ProcMesh) -> None: # Note: RdmaController acts as coordinator and can run on any node # The RDMA support check should happen on the target proc_mesh nodes, not on RdmaController's node if proc_mesh not in self._manager_futures: async def create_manager() -> _RdmaManager: proc_mesh_result = await Future( coro=cast("PythonTask[Any]", proc_mesh._proc_mesh.task()) ) return none_throws( await _RdmaManager.create_rdma_manager_nonblocking( proc_mesh_result, context().actor_instance ) ) self._manager_futures[proc_mesh] = Future(coro=create_manager()) await self._manager_futures[proc_mesh] def pt_cuda_allocator_compatibility() -> bool: """ Check if PyTorch CUDA caching allocator is compatible with RDMA. This checks if both the CUDA caching allocator is enabled AND expandable segments are enabled, which is required for RDMA operations with CUDA tensors. Returns: bool: True if both conditions are met, False otherwise """ import torch if not torch.cuda.is_available(): return False # Get allocator snapshot which contains settings snapshot = torch.cuda.memory._snapshot() allocator_settings = snapshot.get("allocator_settings", {}) # Check if expandable_segments is enabled # pyrefly: ignore [missing-attribute] return allocator_settings.get("expandable_segments", False) @functools.cache def _check_cuda_expandable_segments_enabled() -> bool: """ Check if PyTorch CUDA caching allocator is using expandable segments. Returns: bool: True if expandable segments are enabled, False otherwise """ try: # Call the Python implementation of pt_cuda_allocator_compatibility pt_cuda_compat = pt_cuda_allocator_compatibility() if not pt_cuda_compat: warnings.warn( "CUDA caching allocator is not using expandable segments.\n" "This is required to maximize RDMA performance with CUDA tensors.\n\n" "To fix this, set the environment variable BEFORE importing PyTorch:\n" "1. In shell:\n" ' export PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True"\n' "2. Or in Python script (BEFORE any PyTorch imports):\n" " import os\n" ' os.environ["PYTORCH_CUDA_ALLOC_CONF"] = "expandable_segments:True"\n' " import torch # Must come after setting the env var\n\n", UserWarning, stacklevel=2, ) return False return True except Exception: warnings.warn( "Unable to verify CUDA allocator configuration.\n" "Please ensure expandable segments are enabled for best RDMA performance with CUDA tensors:\n" ' export PYTORCH_CUDA_ALLOC_CONF="expandable_segments:True"\n' "Set this environment variable before importing PyTorch.", UserWarning, stacklevel=2, ) return False
[docs]class RDMABuffer:
[docs] def __init__( self, data: "torch.Tensor | memoryview", ) -> None: """ RDMABuffer supports 1d contiguous tensors (including tensor views/slices) or 1d c-contiguous memoryviews. Args: data: torch.Tensor or memoryview to create the buffer from. Must be 1d and contiguous. If provided, addr and size must not be specified. Raises: ValueError: If data is not 1d contiguous, if size is 0, or if data is a GPU tensor. RuntimeError: If no RDMA backend is available on this platform. Note: Currently only CPU tensors are supported. GPU tensor support will be added in the future. TODO: Create TensorBuffer, which will be main user API supporting non-contiguous tensors """ if _is_torch_tensor(data) and data.device.type == "cuda": # type: ignore[union-attr] # Check if CUDA caching allocator is using expandable segments _check_cuda_expandable_segments_enabled() backend = get_rdma_backend() assert backend != "none", ( "Tried to create an RDMABuffer, but RDMA is not available on this platform. " "To enable TCP fallback transport, call " "monarch.configure(rdma_allow_tcp_fallback=True) before creating buffers." ) if backend == "tcp": warnings.warn( "No ibverbs RDMA hardware detected. Falling back to TCP transport, " "which has significantly lower throughput and higher latency than " "native RDMA. To disable this fallback and fail explicitly, call " "monarch.configure(rdma_allow_tcp_fallback=False).", RDMATcpFallbackWarning, stacklevel=2, ) # We need to ensure that _RdmaManager is initialized at this point, because under the hood # _RdmaBuffer.create_rdma_buffer_blocking relies on this being the case. _ensure_init_rdma_manager().block_on() handle = _make_local_memory_handle(data) try: if handle.size == 0: raise ValueError("Cannot create RDMABuffer with size 0.") ctx = context() self._buffer: _RdmaBuffer = _RdmaBuffer.create_rdma_buffer_blocking( local=handle, client=ctx.actor_instance, ) # TODO - specific exception except Exception as e: logging.error("Failed to create buffer %s", e) raise e
@property def backend(self) -> str: """Return the RDMA backend in use ('ibverbs').""" return get_rdma_backend()
[docs] def size(self) -> int: return self._buffer.size()
[docs] def read_into( self, dst: "torch.Tensor | memoryview", *, timeout: int = 60, ) -> Future[None]: """Read data from this RDMABuffer into ``dst``. ``dst`` must be a 1D contiguous tensor or c-contiguous memoryview whose byte-size is at least ``self.size()``. Args: dst: Destination tensor or memoryview to read into. Keyword Args: timeout (int, optional): Timeout in seconds. Defaults to 60s. Returns: Future[None]: A Monarch Future that resolves to ``None`` when the read completes. Raises: ValueError: If ``dst`` is smaller than the RDMA buffer. """ return RDMAAction().read_remote(dst, self).submit(timeout=timeout)
[docs] def write_from( self, src: "torch.Tensor | memoryview", *, timeout: int = 60, ) -> Future[None]: """Write data from ``src`` into this RDMABuffer. ``src`` must be a 1D contiguous tensor or c-contiguous memoryview whose byte-size is at most ``self.size()``. Args: src: Source tensor or memoryview containing the bytes to write to the RDMA buffer. Keyword Args: timeout (int, optional): Timeout in seconds. Defaults to 60s. Returns: Future[None]: A Monarch Future that resolves to ``None`` when the write completes. Raises: ValueError: If ``src`` exceeds the RDMA buffer size. """ return RDMAAction().write_remote(self, src).submit(timeout=timeout)
[docs] def drop(self) -> Future[None]: """ Release the handle on the memory that the src holds to this memory. """ client = context().actor_instance async def drop_nonblocking() -> None: await _ensure_init_rdma_manager() await self._buffer.drop( client=client, ) return Future(coro=drop_nonblocking())
@property def owner(self) -> str: """ The owner reference (str) """ return self._buffer.owner_actor_id()
if TYPE_CHECKING: LocalMemory = torch.Tensor | memoryview
[docs]class RDMAAction: """Schedule a batch of RDMA operations and submit them as one unit. All bookkeeping (per-op validation, intra-batch local-memory race detection, backend grouping, parallel dispatch) lives in the Rust `_RdmaAction`; this class is a thin wrapper around it. """
[docs] def __init__(self) -> None: self._inner: _RdmaAction = _RdmaAction()
# pyrefly: ignore [not-a-type]
[docs] def read_remote(self, dst: "LocalMemory", src: RDMABuffer) -> "RDMAAction": """Queue a read from RDMA buffer ``src`` into local memory ``dst``.""" handle = _make_local_memory_handle(dst) self._inner.add_read_into_local(remote=src._buffer, local=handle) return self
# pyrefly: ignore [not-a-type]
[docs] def write_remote(self, dst: RDMABuffer, src: "LocalMemory") -> "RDMAAction": """Queue a write from local memory ``src`` into RDMA buffer ``dst``.""" handle = _make_local_memory_handle(src) self._inner.add_write_from_local(remote=dst._buffer, local=handle) return self
# pyrefly: ignore [not-a-type]
[docs] def fetch_add(self, src: RDMABuffer, dst: "LocalMemory", add: int) -> "RDMAAction": raise NotImplementedError("Not yet supported")
[docs] def compare_and_swap( self, src: RDMABuffer, dst: "LocalMemory", compare: int, swap: int, # pyrefly: ignore [not-a-type] ) -> "RDMAAction": raise NotImplementedError("Not yet supported")
[docs] def submit(self, *, timeout: int = 60) -> Future[None]: """Schedule the queued ops. Safe to call multiple times. The returned Future does not resolve until every op in the batch completes, or until the timeout is reached. If any op fails, the Future resolves with an exception. """ client = context().actor_instance inner = self._inner async def run() -> None: await _ensure_init_rdma_manager() await inner.submit(client=client, timeout=timeout) return Future(coro=run())