Rate this Page

Source code for torchrl.modules.inference_server._process_slot

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations

import multiprocessing as mp
import queue
import time
from ctypes import Array, c_byte, c_double
from multiprocessing.queues import SimpleQueue
from multiprocessing.synchronize import Event, Lock, Semaphore

import torch
from tensordict.base import _is_leaf_nontensor, TensorDictBase
from tensordict.utils import NestedKey

from torchrl._comm import MailboxPeerClosedError, MailboxTransportError
from torchrl.modules.inference_server._client import (
    _NO_INTERACTION_TYPE_CODE,
    _REMOTE_INTERACTION_TYPE_KEY,
)
from torchrl.modules.inference_server._slot_utils import (
    _make_slot_bank,
    _take_ready_slots,
)
from torchrl.modules.inference_server._transport import InferenceTransport

_MISSING = object()
_PEER_CHECK_INTERVAL = 0.1


class _ProcessSlotFuture:
    """Future for one request in a fixed process-shared slot."""

    def __init__(self, client: _ProcessSlotClient):
        self._client = client
        self._outcome = _MISSING

    def done(self) -> bool:
        """Return whether the response is ready or the server has exited."""
        if self._outcome is not _MISSING:
            return True
        if self._client._response_event.is_set():
            return True
        peer_alive = self._client._peer_alive
        return peer_alive is not None and not peer_alive.is_set()

    def result(self, timeout: float | None = None) -> TensorDictBase:
        """Return the response, retaining the slot when a timeout elapses."""
        if self._outcome is _MISSING:
            self._outcome = self._client._receive(timeout)
        if isinstance(self._outcome, BaseException):
            raise self._outcome
        return self._outcome


class _ProcessSlotClient:
    """Process-side client bound to one :class:`ProcessSlotTransport` slot."""

    def __init__(
        self,
        *,
        slot_id: int,
        request_slots: TensorDictBase,
        response_slots: TensorDictBase,
        request_keys: list[NestedKey],
        request_ready: Array[c_byte],
        request_lock: Lock,
        submitted_at: Array[c_double],
        response_status: Array[c_byte],
        response_event: Event,
        exception_queue: SimpleQueue,
        work_semaphore: Semaphore,
        peer_alive: Event | None,
        copy_result: bool,
    ):
        self._slot_id = slot_id
        self._request_slots = request_slots
        self._response_slots = response_slots
        self._request_keys = request_keys
        self._request_ready = request_ready
        self._request_lock = request_lock
        self._submitted_at = submitted_at
        self._response_status = response_status
        self._response_event = response_event
        self._exception_queue = exception_queue
        self._work_semaphore = work_semaphore
        self._peer_alive = peer_alive
        self._copy_result = copy_result
        self._in_flight = False

    @property
    def client_id(self) -> int:
        """The fixed slot identifier assigned to this client."""
        return self._slot_id

    def submit(self, td: TensorDictBase) -> _ProcessSlotFuture:
        """Write a request into this client's slot and signal the server."""
        if self._in_flight:
            raise RuntimeError(
                "ProcessSlotTransport clients support one in-flight request. "
                "Wait for the current future before submitting another."
            )
        if self._peer_alive is not None and not self._peer_alive.is_set():
            raise MailboxPeerClosedError(
                "Inference server process closed before request submission."
            )
        request = td.select(*self._request_keys, strict=True)
        for key, value in request.items(include_nested=True, leaves_only=True):
            device = getattr(value, "device", None)
            if device is None or device.type != "cpu":
                raise ValueError(
                    "ProcessSlotTransport only accepts CPU tensors; got "
                    f"device {device} for key {key!r}. Keep environment workers "
                    "CPU-side and let the server move batches to the policy device."
                )

        slot = self._slot_id
        self._request_slots[slot].update_(request)
        interaction_code = td.get(_REMOTE_INTERACTION_TYPE_KEY, default=None)
        interaction_slot = self._request_slots[slot].get(_REMOTE_INTERACTION_TYPE_KEY)
        if interaction_code is None:
            interaction_slot.fill_(_NO_INTERACTION_TYPE_CODE)
        else:
            interaction_slot.copy_(interaction_code)
        self._response_event.clear()
        self._response_status[slot] = 0
        self._submitted_at[slot] = time.monotonic()
        self._in_flight = True
        # The server takes this lock even after a timed-out semaphore wait.
        # Releasing it publishes all preceding payload writes to that reader.
        with self._request_lock:
            self._request_ready[slot] = 1
            self._work_semaphore.release()
        return _ProcessSlotFuture(self)

    def __call__(
        self, td: TensorDictBase, timeout: float | None = None
    ) -> TensorDictBase:
        """Submit a request and block for its response."""
        return self.submit(td).result(timeout=timeout)

    def _receive(self, timeout: float | None) -> TensorDictBase | BaseException:
        deadline = None if timeout is None else time.monotonic() + timeout
        while not self._response_event.is_set():
            remaining = None if deadline is None else deadline - time.monotonic()
            if remaining is not None and remaining <= 0:
                raise queue.Empty(
                    f"Timeout waiting for process slot {self._slot_id}."
                ) from None
            wait_timeout = (
                _PEER_CHECK_INTERVAL
                if remaining is None
                else min(remaining, _PEER_CHECK_INTERVAL)
            )
            self._response_event.wait(timeout=wait_timeout)
            if self._response_event.is_set():
                break
            if self._peer_alive is not None:
                try:
                    peer_is_alive = self._peer_alive.is_set()
                except Exception as err:
                    raise MailboxTransportError(
                        "Failed to query the inference server's liveness."
                    ) from err
                if not peer_is_alive:
                    self._in_flight = False
                    raise MailboxPeerClosedError(
                        "Inference server process closed before replying to "
                        f"slot {self._slot_id}."
                    ) from None

        slot = self._slot_id
        try:
            if self._response_status[slot]:
                return self._exception_queue.get()
            result = self._response_slots[slot]
            return result.clone() if self._copy_result else result
        finally:
            self._response_event.clear()
            self._in_flight = False


[docs] class ProcessSlotTransport(InferenceTransport): """Fixed-slot shared-memory transport for environment worker processes. Each client owns one CPU shared-memory request/response slot. A worker copies an observation into its slot and releases a process-shared semaphore; the inference server sweeps ready slots in round-robin order, batches their tensor views, writes actions back, and wakes the matching workers. Only synchronization signals cross process boundaries on the inference hot path. This transport allows environment workers and a :class:`~torchrl.modules.inference_server.ProcessInferenceServer` to communicate without routing observations or actions through the driver. Each client permits one in-flight request, which naturally applies per-environment backpressure. Args: request_spec (TensorDictBase): representative request whose keys, shapes, dtypes and batch size define each request slot. Leaves must be CPU tensors. response_spec (TensorDictBase): representative response, including server-added keys such as ``"policy_version"``. Leaves must be CPU tensors. Keyword Args: num_slots (int): number of fixed slots and maximum number of clients. ctx (multiprocessing context, optional): context used for process synchronization primitives. Defaults to ``"spawn"``. copy_result (bool, optional): whether clients clone responses before returning them. Defaults to ``True``. If ``False``, a response is a borrowed view valid only until that client submits again. .. note:: Create at most one client per environment worker. Unlike queue-based transports, clients do not need registration with the already-running server because every slot and signal is allocated at construction. .. note:: :class:`~torchrl.modules.inference_server.InferenceServer` serves this transport with one batched pass per sweep: ready slots are gathered straight into a host staging batch (pinned when the policy runs on CUDA), copied to the policy device without blocking, and the responses are copied back and scattered into the response slots with one copy per leaf. One CUDA event per pass replaces device-wide synchronization. Example: >>> import torch >>> from tensordict import TensorDict >>> from tensordict.nn import TensorDictModule >>> from torchrl.modules.inference_server import ( ... InferenceServer, ... ProcessSlotTransport, ... ) >>> transport = ProcessSlotTransport( ... TensorDict({"observation": torch.zeros(4)}), ... TensorDict( ... { ... "action": torch.zeros(2), ... "policy_version": torch.zeros((), dtype=torch.long), ... } ... ), ... num_slots=4, ... ) >>> client = transport.client() >>> policy = TensorDictModule( ... torch.nn.Linear(4, 2), in_keys=["observation"], out_keys=["action"] ... ) >>> with InferenceServer(policy, transport, max_batch_size=4): ... result = client(TensorDict({"observation": torch.randn(4)})) >>> result["action"].shape torch.Size([2]) """ _clients_require_registration = False _batched_slot_io = True def __init__( self, request_spec: TensorDictBase, response_spec: TensorDictBase, *, num_slots: int, ctx: mp.context.BaseContext | None = None, copy_result: bool = True, ): if isinstance(num_slots, bool) or not isinstance(num_slots, int): raise TypeError("num_slots must be an integer.") if num_slots < 1: raise ValueError(f"num_slots must be positive, got {num_slots}.") self._num_slots = num_slots self._ctx = ctx if ctx is not None else mp.get_context("spawn") self._copy_result = bool(copy_result) self._next_client_slot = 0 self._next_slot = 0 self._acquired_signals = 0 request_keys = list( request_spec.keys( include_nested=True, leaves_only=True, is_leaf=_is_leaf_nontensor ) ) if not request_keys: raise ValueError("request_spec must contain at least one tensor leaf.") self._request_keys = [ key for key in request_keys if key != _REMOTE_INTERACTION_TYPE_KEY ] request_slot_spec = request_spec.clone(recurse=False) if _REMOTE_INTERACTION_TYPE_KEY not in request_keys: request_slot_spec.set( _REMOTE_INTERACTION_TYPE_KEY, torch.full( request_spec.batch_size, _NO_INTERACTION_TYPE_CODE, dtype=torch.int8, device="cpu", ), ) self._request_slots = _make_slot_bank( request_slot_spec, self._num_slots, type(self).__name__, "request_spec" ) self._response_slots = _make_slot_bank( response_spec, self._num_slots, type(self).__name__, "response_spec" ) self._response_keys = list( response_spec.keys(include_nested=True, leaves_only=True) ) self._request_ready = self._ctx.Array("b", num_slots, lock=False) self._request_lock = self._ctx.Lock() self._submitted_at = self._ctx.Array("d", num_slots, lock=False) self._response_status = self._ctx.Array("b", num_slots, lock=False) self._response_events = [self._ctx.Event() for _ in range(num_slots)] self._exception_queues = [self._ctx.SimpleQueue() for _ in range(num_slots)] self._work_semaphore = self._ctx.Semaphore(0) self._peer_alive = self._ctx.Event() self._peer_alive.set() def _set_peer_alive(self, alive_event) -> None: self._peer_alive = alive_event
[docs] def client(self) -> _ProcessSlotClient: """Create a client bound to the next unused slot.""" slot_id = self._next_client_slot if slot_id >= self._num_slots: raise RuntimeError( f"ProcessSlotTransport has {self._num_slots} slots but client() " f"was called {slot_id + 1} times." ) self._next_client_slot += 1 return _ProcessSlotClient( slot_id=slot_id, request_slots=self._request_slots, response_slots=self._response_slots, request_keys=self._request_keys, request_ready=self._request_ready, request_lock=self._request_lock, submitted_at=self._submitted_at, response_status=self._response_status, response_event=self._response_events[slot_id], exception_queue=self._exception_queues[slot_id], work_semaphore=self._work_semaphore, peer_alive=self._peer_alive, copy_result=self._copy_result, )
[docs] def submit(self, td: TensorDictBase): """Reject unbound submissions; callers must first obtain a client.""" raise NotImplementedError( "ProcessSlotTransport does not support submit(). Call client() " "to obtain a fixed-slot client." )
[docs] def wait_for_work(self, timeout: float) -> None: """Wait until an environment worker marks a request slot ready.""" if self._work_semaphore.acquire(timeout=timeout): self._acquired_signals += 1
[docs] def drain(self, max_items: int) -> tuple[list[TensorDictBase], list[int]]: """Sweep ready slots in round-robin order.""" items, callbacks, _submitted_at = self.drain_with_timing(max_items) return items, callbacks
[docs] def drain_with_timing( self, max_items: int ) -> tuple[list[TensorDictBase], list[int], list[float | None]]: """Sweep ready slots and return request submission timestamps.""" slots, submitted_at = self.drain_slots(max_items) items = [self._request_slots[slot].copy() for slot in slots] return items, slots, submitted_at
[docs] def drain_slots(self, max_items: int) -> tuple[list[int], list[float]]: """Claim ready slots in round-robin order without copying their payloads. The requests stay in the slot bank until :meth:`gather_requests` collates them. Args: max_items (int): maximum number of slots to claim. Returns: The claimed slot indices and their submission timestamps. """ # The semaphore is a doorbell, not the payload's memory barrier: a # timed drain can run without acquiring a signal. Synchronize with each # publisher before looking at flags, including on weakly ordered CPUs. with self._request_lock: slots = _take_ready_slots(self._request_ready, self._next_slot, max_items) if slots: self._next_slot = (slots[-1] + 1) % self._num_slots submitted_at = [] for slot in slots: submitted_at.append(self._submitted_at[slot]) self._submitted_at[slot] = 0.0 # Consume doorbells for drained requests, including a signal already # consumed by wait_for_work(). Extra wakeups are harmless. signals_to_consume = len(slots) acquired = min(signals_to_consume, self._acquired_signals) self._acquired_signals -= acquired signals_to_consume -= acquired for _ in range(signals_to_consume): self._work_semaphore.acquire(block=False) return slots, submitted_at
[docs] def request_batch(self, capacity: int) -> TensorDictBase: """Allocate a private, contiguous CPU batch of ``capacity`` requests. The batch has the request slot layout (including the interaction-type key) and is the staging area that :meth:`gather_requests` fills. Args: capacity (int): number of rows. """ return ( self._request_slots[0] .unsqueeze(0) .expand(capacity, *self._request_slots.batch_size[1:]) .clone() )
[docs] def response_batch(self, capacity: int) -> TensorDictBase: """Allocate a private, contiguous CPU batch of ``capacity`` responses. The batch has the response slot layout and is the staging area that :meth:`resolve_batch` scatters into the slots. Args: capacity (int): number of rows. """ return ( self._response_slots[0] .unsqueeze(0) .expand(capacity, *self._response_slots.batch_size[1:]) .clone() )
[docs] def gather_requests(self, slots: list[int], out: TensorDictBase) -> None: """Collate request slots into ``out[:len(slots)]`` with one gather per leaf. Args: slots (list of int): slots to collate, typically the ones returned by :meth:`drain_slots`; row ``i`` of ``out`` receives ``slots[i]``. out (TensorDictBase): batch allocated with :meth:`request_batch` (possibly pinned) holding at least ``len(slots)`` rows. """ num_slots = len(slots) if num_slots > out.batch_size[0]: raise ValueError( f"Cannot gather {num_slots} request slots into a batch of " f"{out.batch_size[0]} rows." ) index = torch.tensor(slots, dtype=torch.long, device="cpu") for key, bank in self._request_slots.items( include_nested=True, leaves_only=True ): torch.index_select(bank, 0, index, out=out.get(key)[:num_slots])
[docs] def resolve_batch(self, slots: list[int], results: TensorDictBase) -> None: """Write a batch of responses into their slots and wake the owning workers. Args: slots (list of int): slots served by the pass; row ``i`` of ``results`` is written to ``slots[i]``. results (TensorDictBase): batch of ``len(slots)`` responses whose leaves match the response layout (shapes and dtypes). Undeclared keys are dropped and a missing declared key raises a :class:`KeyError`. """ if not slots: return self._response_slots[ torch.tensor(slots, dtype=torch.long, device="cpu") ] = results.select(*self._response_keys, strict=True) for slot in slots: self._response_status[slot] = 0 self._response_events[slot].set()
[docs] def resolve(self, callback: int, result: TensorDictBase) -> None: """Copy a response into its slot and wake the owning worker.""" self._response_slots[callback].update_( result.select(*self._response_keys, strict=True) ) self._response_status[callback] = 0 self._response_events[callback].set()
[docs] def resolve_exception(self, callback: int, exc: BaseException) -> None: """Send a model exception to the owning worker and wake it.""" self._exception_queues[callback].put(exc) self._response_status[callback] = 1 self._response_events[callback].set()