Rate this Page

Source code for torchrl.modules.inference_server._client

# 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 contextlib
import queue
import threading
from collections.abc import Callable, Sequence
from concurrent.futures import Future
from typing import Any

import torch
from tensordict.base import TensorDictBase
from tensordict.nn import TensorDictModuleBase
from tensordict.nn.probabilistic import (
    interaction_type,
    InteractionType,
    set_interaction_type,
)
from tensordict.utils import NestedKey

from torchrl.modules.inference_server._transport import InferenceTransport
from torchrl.services.base import Service

_REMOTE_INTERACTION_TYPE_KEY = "_torchrl_inference_interaction_type"
# Code stamped when the caller has no active interaction context; keeping the
# key always present makes server batches homogeneous in key structure.
_NO_INTERACTION_TYPE_CODE = -1
_INTERACTION_TYPE_TO_CODE = {
    "mode": 0,
    "median": 1,
    "mean": 2,
    "random": 3,
    "deterministic": 4,
}


def _stamp_interaction_type(
    tensordict: TensorDictBase, mode: InteractionType | None
) -> TensorDictBase:
    """Return a shallow copy of ``tensordict`` carrying ``mode`` as a request code.

    ``None`` stamps the sentinel code, which the server maps to the served
    module's default interaction type. The key is always present so server
    batches stay homogeneous in key structure.
    """
    code = (
        _INTERACTION_TYPE_TO_CODE[mode.value]
        if mode is not None
        else _NO_INTERACTION_TYPE_CODE
    )
    tensordict = tensordict.clone(recurse=False)
    tensordict.set(
        _REMOTE_INTERACTION_TYPE_KEY,
        torch.full(
            tensordict.batch_size,
            code,
            dtype=torch.int8,
            device=tensordict.device or torch.device("cpu"),
        ),
    )
    return tensordict


class _ImmediateFuture:
    def __init__(self, result: TensorDictBase | BaseException):
        self._result = result

    def done(self) -> bool:
        return True

    def result(self, timeout: float | None = None) -> TensorDictBase:
        if isinstance(self._result, BaseException):
            raise self._result
        return self._result


class _InflightGuardedFuture:
    """Future proxy that frees an inflight slot when the request *completes*.

    Callback-capable futures (:class:`concurrent.futures.Future`) release
    through ``add_done_callback``, so a dropped or cancelled future cannot
    leak its slot. Pull-based futures (e.g. queue transports) release when a
    completed result is first observed through :meth:`result` or
    :meth:`done`. In both cases a ``result(timeout=...)`` that times out does
    **not** free the slot -- the request is still running on the server, and
    releasing early would let the number of genuinely inflight requests
    exceed ``max_inflight``. Garbage collection of the proxy releases the
    slot as a last resort so an abandoned pull-based future cannot
    permanently exhaust the guard.
    """

    def __init__(self, future, release: Callable[[], None]) -> None:
        self.future = future
        self._release_cb = release
        self._released = False
        self._release_lock = threading.Lock()
        add_done_callback = getattr(future, "add_done_callback", None)
        if add_done_callback is not None:
            add_done_callback(lambda _fut: self._release_once())

    def _release_once(self) -> None:
        with self._release_lock:
            if self._released:
                return
            self._released = True
        self._release_cb()

    def done(self) -> bool:
        is_done = self.future.done()
        if is_done:
            self._release_once()
        return is_done

    def result(self, timeout: float | None = None) -> TensorDictBase:
        try:
            result = self.future.result(timeout=timeout)
        except (queue.Empty, TimeoutError):
            # The request is still inflight on the server; keep the slot.
            raise
        except BaseException:
            self._release_once()
            raise
        self._release_once()
        return result

    def __getattr__(self, name: str) -> Any:
        # __getattr__ only fires for missing attributes; route through
        # __dict__ explicitly so a partially-initialised proxy (e.g. during
        # unpickling) raises AttributeError instead of recursing.
        try:
            future = object.__getattribute__(self, "future")
        except AttributeError:
            raise AttributeError(name) from None
        return getattr(future, name)

    def __del__(self) -> None:
        try:
            self._release_once()
        except Exception:
            pass


[docs] class PolicyClientModule(TensorDictModuleBase): """TensorDict policy wrapper for remote inference-server clients. ``PolicyClientModule`` makes a transport client look like a TorchRL policy: it accepts a :class:`~tensordict.TensorDictBase`, submits it to an :class:`~torchrl.modules.inference_server.InferenceServer`, and returns the TensorDict produced by the remote policy. It can be passed anywhere a TensorDict policy module is expected. This class is the reference implementation of TorchRL's service *client* contract: it duck-types the domain interface (a policy client IS a TensorDict policy, so consumer code cannot tell local from remote), it is cheap and picklable (it can be handed to spawned workers), and it carries no lifecycle rights -- clients can call the service but never start or shut it down; only the owner that constructed the server can. .. note:: Unlike a local :class:`~tensordict.nn.TensorDictModule`, the result crosses a transport boundary, so :meth:`forward` returns a *new* TensorDict rather than writing the ``out_keys`` into the input TensorDict. Use the return value; do not rely on in-place updates of the input. Args: client (Callable or InferenceTransport): actor-side inference client. If a transport is provided, ``transport.client()`` is called. Keyword Args: in_keys (sequence of NestedKey, optional): input keys advertised by the module. The full input TensorDict is still sent to the server. out_keys (sequence of NestedKey, optional): output keys advertised by the module. max_inflight (int, optional): maximum number of unresolved asynchronous requests submitted through this module; further :meth:`submit` calls block until a slot frees up. A slot is freed when its request *completes* (including errors), not when ``result()`` is first called; a timed-out ``result()`` keeps the slot. Must be at least ``1``. ``None`` means unbounded. interaction_type (InteractionType, optional): sampling mode stamped on every request. Defaults to ``None``: the caller's active :func:`~tensordict.nn.interaction_type` is read at submission time. Pass an explicit mode whenever another thread of the process may set the interaction type while requests are submitted, since that context is process-wide (a learner thread's loss forward would otherwise decide how the served policy samples). :class:`~torchrl.collectors.AsyncBatchedCollector` always passes its ``exploration_type``. .. note:: The interaction type travels with the request: the explicit ``interaction_type`` or, when none is given, the caller's active :func:`tensordict.nn.interaction_type` is attached to every transport request, and the server executes the remote policy under it -- exactly as a local policy would see it. The serving thread's own (process-wide) context is never consulted. In-process (plain callable) clients enter the explicit context when given, otherwise retain the caller's context. .. note:: Version tracking is an instance of the generic *service-stamped metadata* pattern: a service may stamp every response with metadata describing the state it was served from (here: the behavior-policy version), and the data pipeline may enforce freshness constraints on that metadata. Bounded-staleness enforcement lives in the replay buffer through :class:`~torchrl.envs.transforms.PolicyAgeFilter`, which silently drops too-old elements on extend or sample instead of raising in the consumer. .. note:: The default ``"policy_version"`` key is shared on purpose with the :class:`~torchrl.envs.transforms.PolicyVersion` transform and the collectors' ``track_policy_version`` mechanism: they stamp the same concept (the behavior-policy version that produced the data), so consumers such as :class:`~torchrl.envs.transforms.PolicyAgeFilter` can read it without caring which component wrote it. Both counters are driven by the same weight-update cascade (``update_policy_weights_``), so they agree when wired through a weight-sync scheme. Keep a single authoritative writer per data stream -- in a policy-server topology that is the server, which owns the weights; do not stack an independently-initialized ``PolicyVersion`` transform on top of server-stamped data. Examples: >>> import torch >>> import torch.nn as nn >>> from tensordict import TensorDict >>> from tensordict.nn import TensorDictModule >>> from torchrl.modules.inference_server import ( ... InferenceServer, ... PolicyClientModule, ... ThreadingTransport, ... ) >>> policy = TensorDictModule( ... nn.Linear(4, 2), in_keys=["observation"], out_keys=["action"] ... ) >>> transport = ThreadingTransport() >>> server = InferenceServer(policy, transport).start() >>> remote_policy = PolicyClientModule( ... transport, in_keys=["observation"], out_keys=["action"] ... ) >>> td = remote_policy(TensorDict({"observation": torch.randn(4)})) >>> "action" in td.keys() True >>> server.shutdown() """ def __init__( self, client: Callable[[TensorDictBase], TensorDictBase] | InferenceTransport | Service, *, in_keys: Sequence[NestedKey] | None = None, out_keys: Sequence[NestedKey] | None = None, max_inflight: int | None = None, interaction_type: InteractionType | None = None, ) -> None: super().__init__() if isinstance(client, (InferenceTransport, Service)): client = client.client() if max_inflight is not None and max_inflight < 1: raise ValueError( f"max_inflight must be at least 1 (got {max_inflight}); " "use None to disable the guard." ) self.client = client self.in_keys = list(in_keys or []) self.out_keys = list(out_keys or []) self.max_inflight = max_inflight self.interaction_type = ( InteractionType(interaction_type) if interaction_type is not None else None ) self._inflight_sem = ( threading.BoundedSemaphore(max_inflight) if max_inflight is not None else None ) def __getstate__(self): # Semaphores are not picklable; the guard is a per-process resource, # so a fresh one (with a full complement of slots) is rebuilt on # unpickling. This keeps clients picklable per the Client contract. state = super().__getstate__() state = dict(state) state["_inflight_sem"] = None return state def __setstate__(self, state) -> None: super().__setstate__(state) if self.max_inflight is not None: self._inflight_sem = threading.BoundedSemaphore(self.max_inflight) def _acquire_inflight(self) -> Callable[[], None]: if self._inflight_sem is None: return lambda: None self._inflight_sem.acquire() return self._inflight_sem.release
[docs] def submit(self, tensordict: TensorDictBase) -> Future | _ImmediateFuture: """Submit a TensorDict request and return a future-like object. Args: tensordict (TensorDictBase): observation TensorDict to send to the remote policy. Returns: Future-like object whose ``result()`` method returns a TensorDict. When the wrapped client exposes ``submit`` this is the transport's :class:`~concurrent.futures.Future` and submission errors raise synchronously; for a plain callable client the call runs eagerly and errors are deferred to ``result()`` on a reduced future that only implements ``done()`` and ``result()``. """ release = self._acquire_inflight() submit = getattr(self.client, "submit", None) if submit is not None: # Cross-boundary request: carry the exploration context so the # server-side forward behaves like a local call. An explicit mode # wins over the caller's ambient context, which is process-wide # and may be changed by other threads at any time. tensordict = _stamp_interaction_type( tensordict, self.interaction_type if self.interaction_type is not None else interaction_type(), ) if submit is None: # The plain-callable path runs eagerly, so the request has # already completed here: free the slot immediately. try: with ( set_interaction_type(self.interaction_type) if self.interaction_type is not None else contextlib.nullcontext() ): result = self.client(tensordict) return _ImmediateFuture(result) except Exception as exc: return _ImmediateFuture(exc) finally: release() try: future = submit(tensordict) except BaseException: release() raise return _InflightGuardedFuture(future, release)
[docs] def forward(self, tensordict: TensorDictBase) -> TensorDictBase: return self.submit(tensordict).result()