# 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 functools as ft
import importlib.util
import inspect
import multiprocessing as mp
import queue
import threading
import time
from collections.abc import Callable
from concurrent.futures import Future
from dataclasses import replace
from multiprocessing.synchronize import Event as MPEvent
from statistics import mean
from typing import Any, Literal
import torch
from tensordict import lazy_stack, maybe_dense_stack, TensorDict
from tensordict.base import TensorDictBase
from tensordict.nn import CudaGraphModule
from tensordict.nn.probabilistic import (
interaction_type,
InteractionType,
set_interaction_type,
)
from tensordict.utils import NestedKey, unravel_key
from torch import nn
from torchrl._comm import CommandChannel, Mailbox, watch_process_liveness
from torchrl._comm.backends import (
_contextual_backend_error,
_get_service_backend,
_get_transport_backend,
_resolve_service_backend,
_resolve_transport_backend,
)
from torchrl._comm.mailbox import _exit_on_parent_exit
from torchrl._comm.ray_runtime import _RayRuntimeLease, _set_ray_client_liveness
from torchrl._utils import mark_weight_update
from torchrl.modules.inference_server._client import (
_INTERACTION_TYPE_TO_CODE,
_NO_INTERACTION_TYPE_CODE,
_REMOTE_INTERACTION_TYPE_KEY,
_stamp_interaction_type,
)
from torchrl.modules.inference_server._config import (
_resolve_device_config,
_validate_static_batch_size,
InferenceDeviceConfig,
InferenceServerConfig,
)
from torchrl.modules.inference_server._factory import (
_inference_transport_kind,
_make_inference_transport,
_validate_inference_transport_selection,
)
from torchrl.modules.inference_server._threading import ThreadingTransport
from torchrl.modules.inference_server._transport import InferenceTransport
from torchrl.weight_update import (
SharedMemWeightSyncScheme,
WeightStrategy,
WeightSyncScheme,
)
from torchrl.weight_update.utils import _weight_tensor_signature
_CODE_TO_INTERACTION_TYPE = {
0: InteractionType.MODE,
1: InteractionType.MEDIAN,
2: InteractionType.MEAN,
3: InteractionType.RANDOM,
4: InteractionType.DETERMINISTIC,
}
_has_ray = importlib.util.find_spec("ray") is not None
def _validate_cudagraph_device(
static_batch_size: int | None, policy_device: torch.device | None
) -> None:
if static_batch_size is not None and (
policy_device is None or policy_device.type != "cuda"
):
raise ValueError("static_batch_size requires an explicit CUDA policy_device.")
class _InferenceServerMeta(type):
"""Select a private owner while keeping InferenceServer as the API."""
def __call__(cls, *args, **kwargs):
if cls is not InferenceServer:
return super().__call__(*args, **kwargs)
if len(args) > 2:
raise TypeError(
"InferenceServer accepts at most model and transport positionally."
)
model = args[0] if args else kwargs.pop("model", None)
transport = args[1] if len(args) > 1 else kwargs.pop("transport", None)
policy_factory = kwargs.pop("policy_factory", None)
server_config = kwargs.get("server_config")
configured_backend = (
server_config.service_backend if server_config is not None else "thread"
)
service_backend = kwargs.pop("service_backend", None)
service_backend_from_context = (
service_backend is None
and server_config is None
and _get_service_backend() is not None
)
if service_backend is None:
service_backend = _resolve_service_backend(
configured_backend if server_config is not None else None,
default="thread",
)
else:
service_backend = _resolve_service_backend(
service_backend, default="thread"
)
if server_config is not None and configured_backend not in (
"thread",
service_backend,
):
raise ValueError(
"service_backend conflicts with server_config.service_backend."
)
if service_backend not in ("thread", "process", "ray"):
raise ValueError(
_contextual_backend_error(
"InferenceServer service_backend must be 'thread', 'process', or 'ray'.",
service=service_backend_from_context,
)
)
service_options = dict(kwargs.pop("service_backend_options", None) or {})
transport_backend_from_context = (
transport is None and _get_transport_backend() is not None
)
if transport is None:
transport = _resolve_transport_backend(None, default="auto")
transport_options = kwargs.pop("transport_options", None)
request_spec = kwargs.pop("request_spec", None)
response_spec = kwargs.pop("response_spec", None)
num_clients = kwargs.pop("num_clients", None)
try:
_validate_inference_transport_selection(
transport,
service_backend=service_backend,
transport_options=transport_options,
request_spec=request_spec,
response_spec=response_spec,
)
except ValueError as err:
message = str(err)
service_error = (
service_backend_from_context and "service_backend" in message
)
transport_error = transport_backend_from_context and "transport" in message
if not service_error and not transport_error:
raise
raise ValueError(
_contextual_backend_error(
message,
service=service_error,
transport=transport_error,
)
) from err
if service_backend == "ray":
if model is not None:
raise ValueError(
"service_backend='ray' requires policy_factory so the policy "
"is constructed on the Ray actor."
)
if policy_factory is None:
raise ValueError(
"policy_factory is required for service_backend='ray'."
)
return _RayInferenceServer(
policy_factory=policy_factory,
transport=transport,
transport_options=transport_options,
service_backend_options=service_options,
request_spec=request_spec,
response_spec=response_spec,
num_clients=num_clients,
**kwargs,
)
resolved_transport = _make_inference_transport(
transport,
service_backend=service_backend,
transport_options=transport_options,
request_spec=request_spec,
response_spec=response_spec,
num_clients=num_clients,
)
if service_backend == "process":
if model is not None:
raise ValueError(
"service_backend='process' requires policy_factory so the policy "
"is constructed in the child process."
)
if policy_factory is None:
raise ValueError(
"policy_factory is required for service_backend='process'."
)
allowed_options = {"mp_context", "startup_timeout"}
extra_options = set(service_options) - allowed_options
if extra_options:
raise ValueError(
"Unsupported process service_backend_options: "
f"{sorted(extra_options)}."
)
return ProcessInferenceServer(
policy_factory=policy_factory,
transport=resolved_transport,
request_spec=request_spec,
**service_options,
**kwargs,
)
if service_options:
raise ValueError(
"service_backend_options are only valid for process or Ray services."
)
if model is None:
if policy_factory is None:
raise ValueError("Either model or policy_factory must be provided.")
model = policy_factory()
elif policy_factory is not None:
raise ValueError("model and policy_factory are mutually exclusive.")
return super().__call__(
model, resolved_transport, request_spec=request_spec, **kwargs
)
def _normalize_tensordict_device_metadata(data: TensorDictBase) -> TensorDictBase:
target_device = data.device
if target_device is not None:
target_device = torch.device(target_device)
for value in data.values(include_nested=True, leaves_only=True):
value_device = getattr(value, "device", None)
if value_device is None:
continue
value_device = torch.device(value_device)
if target_device is None:
target_device = value_device
elif value_device != target_device:
return data
if target_device is None:
return data
needs_normalization = data.device != target_device
if not needs_normalization:
for value in data.values(include_nested=True, leaves_only=False):
if isinstance(value, TensorDictBase) and value.device != target_device:
needs_normalization = True
break
if not needs_normalization:
return data
data = data.copy()
data.clear_device_()
return data.to(target_device)
def _default_collate(items: list[TensorDictBase]) -> TensorDictBase:
return lazy_stack(
[
_normalize_tensordict_device_metadata(item)
if isinstance(item, TensorDictBase)
else item
for item in items
]
)
# Collate functions that stack requests without transforming them. Transports
# with fixed slot banks gather such batches straight from their slots; any
# other collate_fn keeps the per-request path.
_STACKING_COLLATE_FNS = (_default_collate, lazy_stack, maybe_dense_stack, torch.stack)
class _SlotBatches:
"""Reusable staging batches for a transport with fixed slot banks.
``host_request`` and ``host_response`` mirror the transport's slot layouts
on the host and are pinned when the policy runs on CUDA, so the per-pass
copies to and from ``device_request`` (the persistent policy-device batch,
sized for the CUDA graph when one is configured) do not block the host.
One event per pass then waits for the device-to-host copy of the
responses instead of a device-wide synchronize.
"""
def __init__(
self,
transport: InferenceTransport,
*,
capacity: int,
device_capacity: int,
policy_device: torch.device | None,
policy_version_key: NestedKey | None,
):
self.host_request = transport.request_batch(capacity)
self.host_response = transport.response_batch(capacity)
self.device_request: TensorDictBase | None = None
self.event: torch.cuda.Event | None = None
self.non_blocking = False
if policy_device is not None and policy_device.type != "cpu":
self.device_request = (
transport.request_batch(device_capacity)
.exclude(_REMOTE_INTERACTION_TYPE_KEY)
.to(policy_device)
)
if policy_device.type == "cuda":
self.host_request = self.host_request.pin_memory()
self.host_response = self.host_response.pin_memory()
self.event = torch.cuda.Event()
self.non_blocking = True
# The server stamps the policy version on the host; every other
# response key comes from the model output.
self.model_response_keys = list(
self.host_response.keys(include_nested=True, leaves_only=True)
)
self.version: torch.Tensor | None = None
if policy_version_key is not None:
self.version = self.host_response.get(policy_version_key, default=None)
if self.version is not None:
self.model_response_keys.remove(unravel_key(policy_version_key))
[docs]
class InferenceServer(metaclass=_InferenceServerMeta):
"""Auto-batching inference server.
Actors submit individual TensorDicts via the *transport* and receive
results asynchronously. A background worker drains the transport queue,
batches inputs, runs the model, and fans results back to the callers.
Args:
model (nn.Module or Callable, optional): callable that maps a batched
TensorDictBase to a batched TensorDictBase (e.g. a
:class:`~tensordict.nn.TensorDictModule`). Pass ``policy_factory``
instead when a process or Ray actor owns the policy.
transport (InferenceTransport or str, optional): payload transport.
``"auto"`` selects a backend-appropriate transport and is the
recommended default.
Keyword Args:
policy_factory (Callable, optional): zero-argument policy constructor.
Required for ``service_backend="process"`` and
``service_backend="ray"`` so policy parameters are created by the
process that owns them.
service_backend (str, optional): where inference runs: ``"thread"``,
``"process"``, or ``"ray"``. Defaults to ``"thread"``.
service_backend_options (dict, optional): owner configuration. The Ray
backend accepts ``ray_init_config`` and ``remote_config``; the
process backend accepts ``mp_context`` and ``startup_timeout``.
transport_options (dict, optional): options forwarded to the selected
transport. For ``"distributed"``, ``backend`` selects ``"gloo"``
or ``"nccl"``. Explicit selectors never fall back to another
transport.
request_spec (TensorDictBase, optional): static request layout for
``"shared_memory"``, ``"process_slot"``, or process-owned
distributed transports, and the representative unbatched request
used for CUDA-graph capture when ``static_batch_size`` is set.
Ray-owned distributed transports infer and bind this layout on first use.
response_spec (TensorDictBase, optional): static response layout paired
with ``request_spec``.
num_clients (int, optional): expected concurrent client count for
transports that allocate a fixed number of slots.
max_batch_size (int, optional): upper bound on the number of requests
processed in a single forward pass. Default: ``64``.
static_batch_size (int, optional): fixed leading batch size used to
CUDA-graph the served policy. Partial batches repeat their last
request up to this size, and padded outputs are discarded. The
graph is captured before the serve loop starts using
``request_spec``. Requires an explicit CUDA ``policy_device`` and
must be at least ``max_batch_size``. Defaults to ``None`` (eager
policy execution).
min_batch_size (int, optional): minimum number of requests to
accumulate before dispatching a batch. After the first request
arrives the server keeps draining for up to ``timeout`` seconds
until at least this many items are collected. ``1`` (default)
dispatches immediately.
timeout (float, optional): seconds to wait for new work before
dispatching a partial batch. Default: ``0.01``.
collate_fn (Callable, optional): function used to stack a list of
TensorDicts into a batch. Default: :func:`~tensordict.lazy_stack`.
device (torch.device or str, optional): device to move batches to
before calling the model. This is kept as an alias for
``policy_device`` for backward compatibility. ``None`` means no
device transfer.
policy_device (torch.device or str, optional): device that owns the
policy and receives batched requests before model execution.
If omitted, ``device`` is used.
output_device (torch.device or str, optional): device where individual
inference results are moved before being returned to actors. This
is useful when a CUDA policy serves CPU environment workers.
collect_stats (bool, optional): if ``True``, collect lightweight
batching, queue-wait, and forward-latency statistics. Defaults to
``True``.
stats_window_size (int, optional): number of recent timing samples
kept for percentile statistics. Defaults to ``1024``.
weight_sync: an optional
:class:`~torchrl.weight_update.WeightSyncScheme` used to receive
updated model weights from a trainer. When set, the server polls
for new weights between inference batches.
weight_sync_model_id (str, optional): the model identifier used when
initialising the weight sync scheme on the receiver side.
Default: ``"policy"``.
server_config (InferenceServerConfig, optional): structured server
configuration. Mutually exclusive with the ``max_batch_size``,
``static_batch_size``, ``min_batch_size``, ``timeout``,
``collect_stats``, and ``stats_window_size`` keyword arguments
(passing any of them alongside a config raises, even when the value
equals the default).
device_config (InferenceDeviceConfig, optional): structured device
placement configuration. Mutually exclusive with ``device``,
``policy_device``, and ``output_device``. The server consumes
``policy_device`` and ``output_device`` only; ``env_device`` is
used as a fallback for ``output_device`` and ``storing_device``
is rejected (it is a collector-level setting).
policy_version (int, optional): initial behavior-policy version
attached to inference outputs. Defaults to ``0``.
policy_version_key (NestedKey or None, optional): TensorDict key used
for behavior-policy version annotations. ``None`` disables
annotations. Defaults to ``"policy_version"``.
Example:
>>> import torch
>>> from tensordict import TensorDict
>>> from tensordict.nn import TensorDictModule
>>> from torchrl.modules.inference_server import InferenceServer
>>> import torch.nn as nn
>>> policy = TensorDictModule(
... nn.Linear(4, 2), in_keys=["obs"], out_keys=["act"]
... )
>>> with InferenceServer(policy, transport="auto", max_batch_size=8) as server:
... result = server.client()(TensorDict({"obs": torch.randn(4)}))
>>> result["act"].shape
torch.Size([2])
"""
def __init__(
self,
model: nn.Module | Callable[[TensorDictBase], TensorDictBase] | None = None,
transport: InferenceTransport
| Literal[
"auto",
"thread",
"process",
"ray",
"shared_memory",
"process_slot",
"direct",
"distributed",
] = "auto",
*,
policy_factory: Callable[
[], nn.Module | Callable[[TensorDictBase], TensorDictBase]
]
| None = None,
service_backend: Literal["thread", "process", "ray"] = "thread",
service_backend_options: dict[str, Any] | None = None,
transport_options: dict[str, Any] | None = None,
request_spec: TensorDictBase | None = None,
response_spec: TensorDictBase | None = None,
num_clients: int | None = None,
max_batch_size: int | None = None,
static_batch_size: int | None = None,
min_batch_size: int | None = None,
timeout: float | None = None,
collate_fn: Callable | None = None,
device: torch.device | str | None = None,
policy_device: torch.device | str | None = None,
output_device: torch.device | str | None = None,
collect_stats: bool | None = None,
stats_window_size: int | None = None,
weight_sync=None,
weight_sync_model_id: str = "policy",
server_config: InferenceServerConfig | None = None,
device_config: InferenceDeviceConfig | None = None,
shutdown_event: threading.Event | MPEvent | None = None,
policy_version: int = 0,
policy_version_key: NestedKey | None = "policy_version",
):
# Deployment keywords are consumed by the metaclass before this local
# implementation is constructed. Keeping them in the signature makes
# the canonical API discoverable to help() and static tooling.
del (
policy_factory,
service_backend,
service_backend_options,
transport_options,
response_spec,
num_clients,
)
if model is None or not isinstance(transport, InferenceTransport):
raise RuntimeError(
"InferenceServer deployment arguments were not resolved before "
"constructing the local server."
)
if server_config is not None and any(
kwarg is not None
for kwarg in (
max_batch_size,
static_batch_size,
min_batch_size,
timeout,
collect_stats,
stats_window_size,
)
):
raise ValueError(
"server_config is mutually exclusive with the max_batch_size, "
"static_batch_size, min_batch_size, timeout, collect_stats, "
"and stats_window_size keyword arguments."
)
# Unset kwargs fall back to the (given or default) config values, so
# the signature carries no duplicated default literals.
_server_defaults = (
server_config if server_config is not None else InferenceServerConfig()
)
if max_batch_size is None:
max_batch_size = _server_defaults.max_batch_size
if static_batch_size is None:
static_batch_size = _server_defaults.static_batch_size
if min_batch_size is None:
min_batch_size = _server_defaults.min_batch_size
if timeout is None:
timeout = _server_defaults.timeout
if collect_stats is None:
collect_stats = _server_defaults.collect_stats
if stats_window_size is None:
stats_window_size = _server_defaults.stats_window_size
_devices = _resolve_device_config(
device_config,
device=device,
policy_device=policy_device,
output_device=output_device,
allow_storing_device=False,
)
self.model = model
self.transport = transport
self.max_batch_size = max_batch_size
self.static_batch_size = static_batch_size
self.min_batch_size = min_batch_size
self.timeout = timeout
self.collate_fn = collate_fn if collate_fn is not None else _default_collate
self.policy_device = _devices.policy_device
self.device = self.policy_device
self.output_device = _devices.output_device
self.weight_sync = weight_sync
self._weight_sync_model_id = weight_sync_model_id
self._policy_version = int(policy_version)
# Optional multiprocessing.Value mirror so a parent process can read
# the live version of a server running in a child process.
self._policy_version_shared = None
self.policy_version_key = policy_version_key
self.collect_stats = collect_stats
self.stats_window_size = stats_window_size
self._cudagraph_model: CudaGraphModule | None = None
self._cudagraph_request_spec = (
request_spec.clone() if request_spec is not None else None
)
self._cudagraph_interaction_code: int | None = None
self._cudagraph_storage_signature = None
self._cudagraph_tensor_references = None
self._cudagraph_input_keys: frozenset[NestedKey] | None = None
self._cudagraph_input_keys_validated = False
self._slot_batches: _SlotBatches | None = None
_validate_static_batch_size(self.static_batch_size, self.max_batch_size)
_validate_cudagraph_device(self.static_batch_size, self.policy_device)
self._shutdown_event = (
threading.Event() if shutdown_event is None else shutdown_event
)
self._worker: threading.Thread | None = None
# Protects model access during weight updates
self._model_lock = threading.Lock()
self._stats_lock = threading.Lock()
self._reset_stats()
if self.policy_device is not None and hasattr(self.model, "to"):
self.model.to(self.policy_device)
# -- stats ---------------------------------------------------------------
def _reset_stats(self) -> None:
self._stats_started_at = time.monotonic()
self._num_requests = 0
self._num_batches = 0
self._num_weight_updates = 0
self._batch_sizes: list[int] = []
self._queue_wait_ms: list[float] = []
self._forward_ms: list[float] = []
@staticmethod
def _percentile(values: list[float], percentile: float) -> float:
if not values:
return 0.0
sorted_values = sorted(values)
index = int(round((len(sorted_values) - 1) * percentile))
return float(sorted_values[index])
def _extend_window(self, target: list, values: list) -> None:
target.extend(values)
excess = len(target) - self.stats_window_size
if excess > 0:
del target[:excess]
def _record_batch_stats(
self,
*,
batch_size: int,
queue_wait_ms: list[float],
forward_ms: float,
) -> None:
if not self.collect_stats:
return
with self._stats_lock:
self._num_requests += batch_size
self._num_batches += 1
self._extend_window(self._batch_sizes, [batch_size])
self._extend_window(self._queue_wait_ms, queue_wait_ms)
self._extend_window(self._forward_ms, [forward_ms])
[docs]
def stats(self, *, reset: bool = False) -> dict[str, float | int]:
"""Return lightweight inference-server throughput statistics.
Args:
reset (bool, optional): if ``True``, clear counters after taking
the snapshot. Defaults to ``False``.
Returns:
A dictionary with request/batch counts, rates, average batch size,
and p50/p95 queue and forward latencies in milliseconds.
"""
with self._stats_lock:
elapsed = max(time.monotonic() - self._stats_started_at, 1e-12)
num_requests = self._num_requests
num_batches = self._num_batches
batch_sizes = list(self._batch_sizes)
queue_wait_ms = list(self._queue_wait_ms)
forward_ms = list(self._forward_ms)
result = {
"requests": num_requests,
"batches": num_batches,
"requests_per_s": num_requests / elapsed,
"batches_per_s": num_batches / elapsed,
"avg_batch_size": float(mean(batch_sizes)) if batch_sizes else 0.0,
"p50_queue_ms": self._percentile(queue_wait_ms, 0.50),
"p95_queue_ms": self._percentile(queue_wait_ms, 0.95),
"p50_forward_ms": self._percentile(forward_ms, 0.50),
"p95_forward_ms": self._percentile(forward_ms, 0.95),
"policy_version": self._policy_version,
"weight_updates": self._num_weight_updates,
}
if reset:
self._reset_stats()
return result
@property
def policy_version(self) -> int:
"""The current behavior-policy version served with inference outputs."""
return self._policy_version
def _mark_weight_update(self, model_version: int | None = None) -> None:
with self._stats_lock:
if model_version is None:
self._policy_version += 1
else:
self._policy_version = int(model_version)
self._num_weight_updates += 1
if self._policy_version_shared is not None:
self._policy_version_shared.value = self._policy_version
mark_weight_update(self.model)
[docs]
def update_policy_weights_(self, model_id=None, policy_or_weights=None, **kwargs):
"""Weight-sync cascade hook: record an applied weight update.
Weight-sync schemes cascade to their ``context`` after applying
weights to the registered model. The server installs itself as the
scheme context (when none is set) so that the policy version is
bumped exactly when weights are actually applied -- including
shared-memory schemes whose background receiver thread applies
weights outside the server's polling loop.
"""
try:
self._validate_cudagraph_storage()
finally:
self._mark_weight_update()
[docs]
def update_model(
self,
update_fn: Callable[[nn.Module], Any],
*,
mark_weight_update: bool = True,
) -> Any:
"""Apply an in-place update to the served model under the model lock.
Args:
update_fn (Callable): function called with ``self.model`` while
inference is blocked by the server's model lock.
mark_weight_update (bool, optional): if ``True``, increment the
behavior-policy version and weight-update counter after
``update_fn`` succeeds. Defaults to ``True``.
Returns:
The value returned by ``update_fn``.
"""
with self._model_lock:
result = update_fn(self.model)
try:
self._validate_cudagraph_storage()
finally:
if mark_weight_update:
self._mark_weight_update()
return result
# -- lifecycle ------------------------------------------------------------
[docs]
def start(self) -> InferenceServer:
"""Start the background inference loop.
Returns:
self, for fluent chaining.
"""
if self._worker is not None and self._worker.is_alive():
raise RuntimeError("Server is already running.")
if self.static_batch_size is not None and self._cudagraph_model is None:
if self._cudagraph_request_spec is None:
raise RuntimeError(
"static_batch_size requires request_spec so the CUDA graph "
"can be captured before the serve loop starts."
)
self.prepare_cudagraph(self._cudagraph_request_spec)
if (
self._slot_batches is None
and self.transport._batched_slot_io
and self.collate_fn in _STACKING_COLLATE_FNS
):
self._slot_batches = _SlotBatches(
self.transport,
capacity=self.max_batch_size,
device_capacity=self.static_batch_size or self.max_batch_size,
policy_device=self.policy_device,
policy_version_key=self.policy_version_key,
)
self._shutdown_event.clear()
self._worker = threading.Thread(
target=self._run, daemon=True, name="InferenceServer-worker"
)
self._worker.start()
return self
[docs]
def shutdown(self, timeout: float | None = 5.0) -> None:
"""Signal the background worker to stop and wait for it to finish.
Args:
timeout (float or None): seconds to wait for the worker thread to
join. ``None`` waits indefinitely.
"""
self._shutdown_event.set()
if self._worker is not None:
self._worker.join(timeout=timeout)
self._worker = None
@property
def is_alive(self) -> bool:
"""Whether the background worker thread is running."""
return self._worker is not None and self._worker.is_alive()
@property
def service_backend(self) -> str:
"""Execution backend that owns the policy."""
return "thread"
@property
def transport_kind(self) -> str:
"""Physical transport used for inference payloads."""
return _inference_transport_kind(self.transport)
[docs]
def client(self) -> Any:
"""Return a restricted inference client from the owned transport."""
return self.transport.client()
[docs]
def clients(self, num_clients: int) -> list[Any]:
"""Return one independently routed client per concurrent consumer."""
if isinstance(num_clients, bool) or not isinstance(num_clients, int):
raise TypeError("num_clients must be an integer.")
if num_clients < 1:
raise ValueError("num_clients must be at least 1.")
return [self.client() for _ in range(num_clients)]
# -- background loop ------------------------------------------------------
def _model_tensor_references(self):
if not isinstance(self.model, nn.Module):
return None
return tuple(
(
name,
value,
)
for name, value in (
*self.model.named_parameters(),
*self.model.named_buffers(),
)
)
@staticmethod
def _tensor_storage_signature(tensor_references):
if tensor_references is None:
return None
return tuple(
(
name,
value.untyped_storage().data_ptr(),
value.storage_offset(),
value.shape,
value.stride(),
value.dtype,
value.device,
)
for name, value in tensor_references
)
def _model_storage_signature(self):
return self._tensor_storage_signature(self._model_tensor_references())
def _collate_model_batch(
self, items: list[TensorDictBase], *, pad_to_static: bool = False
) -> TensorDictBase:
if not pad_to_static or self.static_batch_size is None:
return self.collate_fn(items)
if len(items) > self.static_batch_size:
raise RuntimeError(
f"Received {len(items)} requests for static_batch_size="
f"{self.static_batch_size}."
)
# Lazy collation can allocate stacked leaves on their first access.
# Materialize them before capture so the graph owns stable input storage.
# Transfer real requests before padding so host-to-device traffic scales
# with the ready batch rather than the configured graph capacity.
batch = self.collate_fn(items).contiguous().to(self.policy_device)
padding = self.static_batch_size - len(items)
if padding:
# Index the final request repeatedly: concatenating an expanded
# tail can mix NonTensorStack and NonTensorData metadata leaves.
index = torch.arange(
self.static_batch_size, device=self.policy_device
).clamp_max_(len(items) - 1)
batch = batch[index]
return batch
def _validate_cudagraph_storage(self) -> None:
if self._cudagraph_model is None:
return
captured_signature = self._tensor_storage_signature(
self._cudagraph_tensor_references
)
current_signature = self._model_storage_signature()
if (
captured_signature == self._cudagraph_storage_signature
and current_signature == self._cudagraph_storage_signature
):
return
self._cudagraph_model = None
self._cudagraph_tensor_references = None
self._cudagraph_storage_signature = None
self._cudagraph_input_keys = None
self._cudagraph_input_keys_validated = False
raise RuntimeError(
"A model update replaced parameter or buffer storage used by the "
"CUDA graph. CUDA-graphed inference only supports in-place weight "
"updates; the server has fallen back to eager inference."
)
[docs]
@torch.no_grad()
def prepare_cudagraph(
self,
request_spec: TensorDictBase,
*,
interaction_type: InteractionType | None = None,
) -> None:
"""Capture the configured static CUDA graph before server start.
Args:
request_spec (TensorDictBase): representative unbatched request.
Keyword Args:
interaction_type (InteractionType, optional): sampling mode the
graph is captured under; every request must then carry the
same mode (see
:class:`~torchrl.modules.inference_server.PolicyClientModule`).
Defaults to ``None``: the mode already stamped on
``request_spec`` if any, otherwise the ambient
:func:`~tensordict.nn.set_interaction_type` context (or the
module default when no context is active). Pass an explicit
mode when other threads may change the ambient context.
"""
if self.static_batch_size is None:
return
if self.is_alive:
raise RuntimeError("The CUDA graph must be prepared before server start.")
if interaction_type is not None:
request_spec = _stamp_interaction_type(
request_spec, InteractionType(interaction_type)
)
self._init_weight_sync()
self._cudagraph_request_spec = request_spec.clone()
cudagraph_model = CudaGraphModule(
self.model, warmup=2, device=self.policy_device
)
captured_interaction_code = None
captured_input_keys = None
for warmup_index in range(2):
batch = self._collate_model_batch(
[request_spec.clone()], pad_to_static=True
)
if self.policy_device is not None:
batch = batch.to(self.policy_device)
(
interaction_context,
batch,
interaction_code,
) = self._interaction_type_context(batch)
if warmup_index == 0:
captured_interaction_code = interaction_code
model_in_keys = getattr(self.model, "in_keys", None)
captured_input_keys = frozenset(
model_in_keys
if model_in_keys is not None
else batch.keys(include_nested=True, leaves_only=True)
)
elif interaction_code != captured_interaction_code:
raise RuntimeError(
"The interaction type changed while preparing the CUDA graph."
)
with interaction_context:
cudagraph_model(batch)
self._cudagraph_model = cudagraph_model
self._cudagraph_interaction_code = captured_interaction_code
self._cudagraph_tensor_references = self._model_tensor_references()
self._cudagraph_storage_signature = self._tensor_storage_signature(
self._cudagraph_tensor_references
)
self._cudagraph_input_keys = captured_input_keys
self._cudagraph_input_keys_validated = False
def _init_weight_sync(self) -> None:
"""Initialise the weight sync scheme on the receiver (server) side."""
ws = self.weight_sync
if ws is None:
return
# Ride the scheme's post-application cascade so the version is bumped
# when weights are actually applied (see update_policy_weights_).
if isinstance(ws, WeightSyncScheme) and ws.context is None:
ws.context = self
if not ws.initialized_on_receiver:
ws.init_on_receiver(
model_id=self._weight_sync_model_id,
model=self.model,
worker_idx=0,
)
if not ws.synchronized_on_receiver:
ws.connect(worker_idx=0)
def _poll_weight_update(self) -> None:
"""Non-blocking check for fresh weights from the trainer."""
ws = self.weight_sync
if ws is None:
return
if isinstance(ws, SharedMemWeightSyncScheme):
# Shared-memory schemes apply weights in place through a
# background receiver thread started at connect() time; polling
# receive() here would re-apply and re-count the same shared
# buffer on every server iteration. Version bumps arrive through
# the update_policy_weights_ cascade instead.
return
with self._model_lock:
weights = ws.receive(timeout=0.0)
if weights is not None:
self._validate_cudagraph_storage()
if weights is not None and getattr(ws, "context", None) is not self:
# When the server is the scheme context, receive() already
# cascaded into update_policy_weights_; do not count twice.
self._mark_weight_update()
def _set_policy_version(self, result_batch: TensorDictBase) -> TensorDictBase:
"""Annotate inference outputs with the behavior policy version."""
if self.policy_version_key is None:
return result_batch
device = result_batch.device
if device is None:
device = self.output_device or self.policy_device or torch.device("cpu")
version = torch.full(
result_batch.batch_size,
self.policy_version,
dtype=torch.long,
device=device,
)
return result_batch.set(self.policy_version_key, version)
def _interaction_type_context(self, batch: TensorDictBase):
# Stamped requests ignore the ambient context; unstamped requests retain
# the standalone server's ambient-context behavior. Entering a stamped
# context still mutates tensordict's process-wide global during forward.
code = batch.get(_REMOTE_INTERACTION_TYPE_KEY, default=None)
if code is None:
current_interaction_type = interaction_type()
interaction_code = (
_INTERACTION_TYPE_TO_CODE[current_interaction_type.value]
if current_interaction_type is not None
else _NO_INTERACTION_TYPE_CODE
)
return contextlib.nullcontext(), batch, interaction_code
if not isinstance(code, torch.Tensor):
interaction_code = int(code)
else:
flat_code = code.reshape(-1)
if flat_code.numel() == 0:
return (
set_interaction_type(None),
batch.exclude(_REMOTE_INTERACTION_TYPE_KEY, inplace=False),
_NO_INTERACTION_TYPE_CODE,
)
interaction_code = int(flat_code[0].item())
if not flat_code.eq(interaction_code).all():
raise RuntimeError(
"InferenceServer received a mixed interaction-type batch. "
"Use homogeneous server requests or a smaller max_batch_size."
)
batch = batch.exclude(_REMOTE_INTERACTION_TYPE_KEY, inplace=False)
if interaction_code == _NO_INTERACTION_TYPE_CODE:
# Sentinel: the caller had no active interaction context.
return set_interaction_type(None), batch, interaction_code
interaction_type_value = _CODE_TO_INTERACTION_TYPE[interaction_code]
return set_interaction_type(interaction_type_value), batch, interaction_code
@torch.no_grad()
def _run(self) -> None:
self._init_weight_sync()
transport = self.transport
slot_batches = self._slot_batches
if slot_batches is not None:
# Requests stay in the slot bank; _serve_slot_batch gathers them.
def drain(max_items):
slots, submitted_at = transport.drain_slots(max_items)
return None, slots, submitted_at
else:
drain_with_timing = getattr(transport, "drain_with_timing", None)
if drain_with_timing is not None:
drain = drain_with_timing
else:
def drain(max_items):
items, callbacks = transport.drain(max_items)
return items, callbacks, [None] * len(items)
try:
while not self._shutdown_event.is_set():
self._poll_weight_update()
transport.wait_for_work(timeout=self.timeout)
items, callbacks, submitted_at = drain(self.max_batch_size)
if not callbacks:
continue
# Accumulate up to min_batch_size (or until timeout expires)
if len(callbacks) < self.min_batch_size:
deadline = time.monotonic() + self.timeout
while len(callbacks) < self.min_batch_size:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
transport.wait_for_work(timeout=remaining)
more_items, more_cbs, more_submitted_at = drain(
self.max_batch_size - len(callbacks)
)
if items is not None:
items.extend(more_items)
callbacks.extend(more_cbs)
submitted_at.extend(more_submitted_at)
try:
if slot_batches is not None:
self._serve_slot_batch(slot_batches, callbacks, submitted_at)
else:
self._serve_batch(items, callbacks, submitted_at)
except Exception as exc:
for cb in callbacks:
transport.resolve_exception(cb, exc)
finally:
self._drain_pending_on_shutdown()
def _check_cudagraph_batch(
self, batch: TensorDictBase, interaction_code: int
) -> bool:
"""Validate a batch against the captured graph; return whether to replay it.
Must run under the model lock so a concurrent weight update cannot
drop the graph between the check and the forward pass.
"""
if self._cudagraph_model is None:
return False
if interaction_code != self._cudagraph_interaction_code:
raise RuntimeError(
"CUDA-graphed inference requires the interaction "
"type used during capture."
)
if not self._cudagraph_input_keys_validated:
request_keys = frozenset(batch.keys(include_nested=True, leaves_only=True))
missing_keys = self._cudagraph_input_keys - request_keys
if missing_keys:
raise RuntimeError(
"The first CUDA-graph request does not match "
"request_spec; missing policy input keys "
f"{list(missing_keys)!r}."
)
self._cudagraph_input_keys_validated = True
return True
def _serve_batch(
self,
items: list[TensorDictBase],
callbacks: list,
submitted_at: list[float | None],
) -> None:
"""Collate, run and resolve one batch of individually drained requests."""
now = time.monotonic()
queue_wait_ms = [
(now - item_submitted_at) * 1000.0
for item_submitted_at in submitted_at
if item_submitted_at is not None
]
real_batch_size = len(callbacks)
padded_for_cudagraph = self._cudagraph_model is not None
batch = self._collate_model_batch(items, pad_to_static=padded_for_cudagraph)
if self.policy_device is not None:
batch = batch.to(self.policy_device)
forward_start = time.monotonic()
with self._model_lock:
(
interaction_context,
batch,
interaction_code,
) = self._interaction_type_context(batch)
use_cudagraph = self._check_cudagraph_batch(batch, interaction_code)
with interaction_context:
if not use_cudagraph:
result_batch = self.model(batch)
else:
result_batch = self._cudagraph_model(batch)
if padded_for_cudagraph:
result_batch = result_batch[:real_batch_size]
if self.output_device is not None:
result_batch = result_batch.to(self.output_device)
if use_cudagraph:
result_batch = result_batch.clone()
result_batch = self._set_policy_version(result_batch)
forward_ms = (time.monotonic() - forward_start) * 1000.0
self._record_batch_stats(
batch_size=len(callbacks),
queue_wait_ms=queue_wait_ms,
forward_ms=forward_ms,
)
results = result_batch.unbind(0)
if len(results) != len(callbacks):
raise RuntimeError(
f"Model returned {len(results)} results for a "
f"batch of {len(callbacks)} inputs."
)
for cb, res in zip(callbacks, results):
self.transport.resolve(cb, res)
def _serve_slot_batch(
self,
batches: _SlotBatches,
slots: list[int],
submitted_at: list[float | None],
) -> None:
"""Serve one pass straight from and into the transport's slot banks.
The ready slots are gathered into the host staging batch, copied to
the persistent device batch (padded to the CUDA-graph size by
repeating the last request), run through the model, and the response
keys are copied back into the host response batch. A single event
wait per pass makes the responses visible before they are scattered
into the slots with one copy per leaf.
"""
now = time.monotonic()
queue_wait_ms = [
(now - item_submitted_at) * 1000.0
for item_submitted_at in submitted_at
if item_submitted_at is not None
]
real_batch_size = len(slots)
self.transport.gather_requests(slots, out=batches.host_request)
host_batch = batches.host_request[:real_batch_size]
forward_start = time.monotonic()
with self._model_lock:
# The interaction code is read from the host copy: no device sync.
(
interaction_context,
host_batch,
interaction_code,
) = self._interaction_type_context(host_batch)
use_cudagraph = self._check_cudagraph_batch(host_batch, interaction_code)
device_batch = batches.device_request
if device_batch is None:
batch = host_batch
else:
device_batch[:real_batch_size].update_(
host_batch, non_blocking=batches.non_blocking
)
if use_cudagraph:
padding = device_batch.batch_size[0] - real_batch_size
if padding:
device_batch[real_batch_size:].update_(
device_batch[real_batch_size - 1 : real_batch_size].expand(
padding, *device_batch.batch_size[1:]
)
)
# A shallow copy receives the module's output keys so the
# persistent batch keeps the request layout.
batch = device_batch.copy()
else:
batch = device_batch[:real_batch_size]
with interaction_context:
if not use_cudagraph:
result_batch = self.model(batch)
else:
result_batch = self._cudagraph_model(batch)
if use_cudagraph:
result_batch = result_batch[:real_batch_size]
if (
result_batch.batch_dims == 0
or result_batch.batch_size[0] != real_batch_size
):
raise RuntimeError(
f"Model returned {result_batch.batch_size} results for a "
f"batch of {real_batch_size} inputs."
)
# Only the declared response keys travel back to the host. The
# CUDA-graph output is read before the next replay overwrites it,
# so no clone is needed.
host_response = batches.host_response[:real_batch_size]
host_response.update_(
result_batch.select(*batches.model_response_keys, strict=True),
non_blocking=batches.non_blocking,
)
if batches.version is not None:
batches.version[:real_batch_size].fill_(self.policy_version)
if batches.event is not None:
batches.event.record(torch.cuda.current_stream(self.policy_device))
batches.event.synchronize()
forward_ms = (time.monotonic() - forward_start) * 1000.0
self._record_batch_stats(
batch_size=real_batch_size,
queue_wait_ms=queue_wait_ms,
forward_ms=forward_ms,
)
self.transport.resolve_batch(slots, host_response)
def _drain_pending_on_shutdown(self) -> None:
"""Resolve all pending requests with an error during shutdown."""
shutdown_exc = RuntimeError("InferenceServer is shutting down.")
while True:
items, callbacks = self.transport.drain(self.max_batch_size)
if not items:
break
for cb in callbacks:
self.transport.resolve_exception(cb, shutdown_exc)
# -- context manager ------------------------------------------------------
def __enter__(self) -> InferenceServer:
return self.start()
def __exit__(self, *exc_info) -> None:
self.shutdown()
def __del__(self) -> None:
# getattr: __del__ also runs on instances whose __init__ raised
# before attribute assignment (e.g. config-validation errors).
worker = getattr(self, "_worker", None)
if worker is not None and worker.is_alive():
self.shutdown(timeout=1.0)
_inference_server_signature = inspect.signature(InferenceServer.__init__)
InferenceServer.__signature__ = _inference_server_signature.replace(
parameters=tuple(_inference_server_signature.parameters.values())[1:]
)
def _process_server_entry(
policy_factory: Callable[[], nn.Module],
transport: InferenceTransport,
server_kwargs: dict,
shutdown_event: MPEvent,
ready_queue,
control_channel: CommandChannel,
policy_version_value=None,
) -> None:
"""Run an :class:`InferenceServer` loop inside a child process."""
threading.Thread(target=_exit_on_parent_exit, daemon=True).start()
try:
model = policy_factory()
server = InferenceServer(
model=model,
transport=transport,
shutdown_event=shutdown_event,
**server_kwargs,
)
# Mirror the live policy version into shared memory so the parent's
# ProcessInferenceServer.policy_version stays accurate.
server._policy_version_shared = policy_version_value
server.start()
except BaseException as exc:
# The ready queue is only used for the startup handshake. Failures
# after a successful handshake propagate through the child's exit
# code instead, so that a restart cannot pick up a stale sentinel.
ready_queue.put((False, repr(exc)))
raise
ready_queue.put((True, None))
try:
while not shutdown_event.is_set():
if not server.is_alive:
# The serve loop died (its own finally already drained and
# rejected pending requests). Exit with an error so the
# parent sees a dead process instead of a healthy control
# plane fronting a dead server.
raise RuntimeError(
"InferenceServer serve loop died inside the server process."
)
request = control_channel.receive(timeout=0.05)
if request is None:
continue
verb = request.verb
payload_in = request.payload
try:
if verb == "stats":
payload = server.stats(**payload_in)
elif verb == "health":
payload = {
"alive": server.is_alive,
"policy_version": server.policy_version,
}
elif verb == "update_model_weights":
weights = payload_in["weights"]
mark_weight_update = payload_in.get("mark_weight_update", True)
def update_model(model, weights=weights):
if hasattr(model, "load_policy_weights"):
model.load_policy_weights(weights)
else:
WeightStrategy(extract_as="tensordict").apply_weights(
model,
weights.to(server.policy_device)
if server.policy_device is not None
else weights,
)
server.update_model(
update_model, mark_weight_update=mark_weight_update
)
payload = {"accepted": True}
elif verb == "shutdown":
shutdown_event.set()
payload = {"accepted": True}
else:
raise RuntimeError(f"Unknown process-server verb: {verb}")
except Exception as exc:
control_channel.reject(
request,
RuntimeError(
f"ProcessInferenceServer command {verb!r} failed: {exc!r}"
),
)
else:
control_channel.resolve(request, payload)
finally:
# Join the serve loop without a deadline: its shutdown path drains
# and rejects pending requests, which must not be skipped even when
# a slow forward pass is in flight (clients would hang forever).
# The parent enforces the hard deadline via process.join/terminate.
try:
server.shutdown(timeout=None)
finally:
control_channel.close(
RuntimeError("ProcessInferenceServer control channel closed.")
)
[docs]
class ProcessInferenceServer:
"""Dedicated-process wrapper around :class:`InferenceServer`.
This server is intended for actor/env workers that communicate through a
queue-based transport such as
:class:`~torchrl.modules.inference_server.MPTransport`. The restricted
client returned by :meth:`client` is created before the server process is
spawned so its response queue is inherited safely.
Args:
policy_factory (Callable[[], nn.Module]): picklable factory that creates
the policy inside the server process.
transport (InferenceTransport): transport shared with actor clients.
Keyword Args:
request_spec (TensorDictBase, optional): representative unbatched
request used to capture a static CUDA graph before child-process
readiness.
max_batch_size (int, optional): maximum requests per forward pass.
static_batch_size (int, optional): fixed CUDA-graph batch size forwarded
to :class:`InferenceServer`. Requires an explicit CUDA
``policy_device``.
min_batch_size (int, optional): minimum requests to accumulate before
dispatching a partial batch.
timeout (float, optional): wait timeout in seconds.
collate_fn (Callable, optional): collate function for requests.
device (torch.device or str, optional): alias for ``policy_device``.
policy_device (torch.device or str, optional): policy execution device.
output_device (torch.device or str, optional): actor response device.
collect_stats (bool, optional): forwarded to :class:`InferenceServer`.
stats_window_size (int, optional): forwarded to :class:`InferenceServer`.
weight_sync: optional weight synchronization scheme.
weight_sync_model_id (str, optional): model id for weight sync.
server_config (InferenceServerConfig, optional): structured server
configuration. Mutually exclusive with the ``max_batch_size``,
``static_batch_size``, ``min_batch_size``, ``timeout``,
``collect_stats``, and ``stats_window_size`` keyword arguments.
device_config (InferenceDeviceConfig, optional): structured device
placement configuration. Mutually exclusive with ``device``,
``policy_device``, and ``output_device``. Same field subset as
:class:`InferenceServer`: ``storing_device`` is rejected.
policy_version (int, optional): initial behavior-policy version
attached to inference outputs. Defaults to ``0``.
policy_version_key (NestedKey or None, optional): TensorDict key used
for behavior-policy version annotations. ``None`` disables
annotations. Defaults to ``"policy_version"``.
mp_context: multiprocessing context or start-method name. Defaults to
``"spawn"``.
startup_timeout (float, optional): seconds :meth:`start` waits for the
child process to build the policy and report readiness. Increase
this when the policy factory loads a large checkpoint. Defaults to
``300.0``.
Examples:
>>> import multiprocessing as mp
>>> import torch.nn as nn
>>> from tensordict.nn import TensorDictModule
>>> from torchrl.modules.inference_server import MPTransport
>>> def make_policy():
... return TensorDictModule(
... nn.Linear(4, 2), in_keys=["observation"], out_keys=["action"]
... )
>>> ctx = mp.get_context("spawn")
>>> transport = MPTransport(ctx=ctx)
>>> server = ProcessInferenceServer(
... policy_factory=make_policy,
... transport=transport,
... mp_context=ctx,
... )
>>> server.start()
>>> client = server.client()
>>> server.shutdown()
"""
def __init__(
self,
*,
policy_factory: Callable[[], nn.Module],
transport: InferenceTransport,
request_spec: TensorDictBase | None = None,
max_batch_size: int | None = None,
static_batch_size: int | None = None,
min_batch_size: int | None = None,
timeout: float | None = None,
collate_fn: Callable | None = None,
device: torch.device | str | None = None,
policy_device: torch.device | str | None = None,
output_device: torch.device | str | None = None,
collect_stats: bool | None = None,
stats_window_size: int | None = None,
weight_sync=None,
weight_sync_model_id: str = "policy",
server_config: InferenceServerConfig | None = None,
device_config: InferenceDeviceConfig | None = None,
policy_version: int = 0,
policy_version_key: NestedKey | None = "policy_version",
mp_context: str | mp.context.BaseContext | None = None,
startup_timeout: float = 300.0,
) -> None:
if server_config is not None and any(
kwarg is not None
for kwarg in (
max_batch_size,
static_batch_size,
min_batch_size,
timeout,
collect_stats,
stats_window_size,
)
):
raise ValueError(
"server_config is mutually exclusive with the max_batch_size, "
"static_batch_size, min_batch_size, timeout, collect_stats, "
"and stats_window_size keyword arguments."
)
_server_defaults = (
server_config if server_config is not None else InferenceServerConfig()
)
if max_batch_size is None:
max_batch_size = _server_defaults.max_batch_size
if static_batch_size is None:
static_batch_size = _server_defaults.static_batch_size
if min_batch_size is None:
min_batch_size = _server_defaults.min_batch_size
if timeout is None:
timeout = _server_defaults.timeout
if collect_stats is None:
collect_stats = _server_defaults.collect_stats
if stats_window_size is None:
stats_window_size = _server_defaults.stats_window_size
_devices = _resolve_device_config(
device_config,
device=device,
policy_device=policy_device,
output_device=output_device,
allow_storing_device=False,
)
_validate_static_batch_size(static_batch_size, max_batch_size)
_validate_cudagraph_device(static_batch_size, _devices.policy_device)
self.policy_factory = policy_factory
self.transport = transport
self.static_batch_size = static_batch_size
self.startup_timeout = startup_timeout
if isinstance(mp_context, str):
self._ctx = mp.get_context(mp_context)
elif mp_context is None:
self._ctx = mp.get_context("spawn")
else:
self._ctx = mp_context
# Server-liveness flag consulted by blocking client waits. Reuse the
# transport's flag when it has one (MPTransport creates it eagerly so
# clients created before this server also see it); otherwise create
# one and attach it. A monitor thread clears it when the server
# process exits so blocked clients raise MailboxPeerClosedError
# instead of hanging forever.
peer_alive = getattr(transport, "_peer_alive", None)
if peer_alive is None:
peer_alive = self._ctx.Event()
peer_alive.set()
transport._set_peer_alive(peer_alive)
self._peer_alive = peer_alive
self._process_monitor: threading.Thread | None = None
self._service_client = (
transport.client() if transport._clients_require_registration else None
)
self._shutdown_event = self._ctx.Event()
self._ready_queue = self._ctx.Queue()
control_request_queue = self._ctx.Queue()
control_mailbox = Mailbox(
control_request_queue,
self._ctx.Queue,
)
self._control_channel = CommandChannel(control_mailbox)
self._control_client = self._control_channel.client()
self._process: mp.Process | None = None
self._server_kwargs = {
"max_batch_size": max_batch_size,
"static_batch_size": static_batch_size,
"min_batch_size": min_batch_size,
"timeout": timeout,
"collate_fn": collate_fn,
# Devices are pre-resolved here; the child server's own resolution
# is a no-op on these values.
"policy_device": _devices.policy_device,
"output_device": _devices.output_device,
"collect_stats": collect_stats,
"stats_window_size": stats_window_size,
"weight_sync": weight_sync,
"weight_sync_model_id": weight_sync_model_id,
"policy_version": policy_version,
"policy_version_key": policy_version_key,
"request_spec": request_spec,
}
# Live mirror of the child's policy version ("q" = signed 64-bit).
self._policy_version_value = self._ctx.Value("q", int(policy_version))
[docs]
def prepare_cudagraph(
self,
request_spec: TensorDictBase,
*,
interaction_type: InteractionType | None = None,
) -> None:
"""Set the representative request used for child-process capture.
Args:
request_spec (TensorDictBase): representative unbatched request.
Keyword Args:
interaction_type (InteractionType, optional): sampling mode the
child process captures under, stamped on the stored request.
See :meth:`InferenceServer.prepare_cudagraph`.
"""
if self.is_alive:
raise RuntimeError(
"The process inference server must prepare its CUDA graph before start."
)
if interaction_type is not None:
request_spec = _stamp_interaction_type(
request_spec, InteractionType(interaction_type)
)
self._server_kwargs["request_spec"] = request_spec.clone()
@property
def policy_version(self) -> int:
"""The live behavior-policy version of the child server."""
return int(self._policy_version_value.value)
[docs]
def start(self) -> ProcessInferenceServer:
"""Start the child process and wait until the policy is initialized."""
if self.is_alive:
raise RuntimeError("Server is already running.")
previous_monitor = self._process_monitor
if previous_monitor is not None:
previous_monitor.join(timeout=self.startup_timeout)
if previous_monitor.is_alive():
raise RuntimeError(
"The previous ProcessInferenceServer monitor did not stop."
)
self._process_monitor = None
self._shutdown_event.clear()
self._peer_alive.set()
self._process = self._ctx.Process(
target=_process_server_entry,
kwargs={
"policy_factory": self.policy_factory,
"transport": self.transport,
"server_kwargs": self._server_kwargs,
"shutdown_event": self._shutdown_event,
"ready_queue": self._ready_queue,
"control_channel": self._control_channel,
"policy_version_value": self._policy_version_value,
},
daemon=True,
name="ProcessInferenceServer",
)
self._process.start()
self._process_monitor = threading.Thread(
target=watch_process_liveness,
args=(self._process.sentinel, self._peer_alive),
daemon=True,
name="ProcessInferenceServerMonitor",
)
self._process_monitor.start()
try:
ok, payload = self._ready_queue.get(timeout=self.startup_timeout)
except queue.Empty:
self.shutdown(timeout=1.0)
raise TimeoutError(
f"ProcessInferenceServer did not report readiness within "
f"{self.startup_timeout} seconds. If the policy factory loads a "
f"large checkpoint, increase startup_timeout."
) from None
if not ok:
self.shutdown(timeout=1.0)
raise RuntimeError(f"ProcessInferenceServer failed to start: {payload}")
return self
def _request_control(
self,
verb: Literal["stats", "health", "update_model_weights", "shutdown"],
payload: dict | None = None,
timeout: float = 5.0,
):
"""One control-plane round trip: verb + payload out, reply back.
Messages use the generic command-channel shape (request ``{"id",
"verb", "payload"}``, reply ``{"id", "ok", "payload"}``) so this can
later ride a shared CommandChannel abstraction unchanged.
"""
if self._process is None:
raise RuntimeError("ProcessInferenceServer is not running.")
if not self._process.is_alive():
raise RuntimeError(
"ProcessInferenceServer process is not alive "
f"(exitcode={self._process.exitcode})."
)
try:
return self._control_client.call(verb, payload or {}, timeout=timeout)
except queue.Empty:
raise TimeoutError(
f"Timed out waiting for ProcessInferenceServer {verb!r}."
) from None
[docs]
def shutdown(self, timeout: float | None = 5.0) -> None:
"""Signal the child process to stop and wait for it to exit."""
process = self._process
if process is None:
return
if self.is_alive:
try:
self._request_control(
"shutdown", timeout=5.0 if timeout is None else timeout
)
except Exception:
pass
if process.is_alive():
self._shutdown_event.set()
process.join(timeout=timeout)
if process.is_alive():
process.terminate()
process.join(timeout=max(1.0, timeout) if timeout is not None else None)
monitor = self._process_monitor
if monitor is not None:
monitor.join(timeout=timeout)
if not monitor.is_alive():
self._process_monitor = None
self._process = None
@property
def is_alive(self) -> bool:
"""Whether the child process is alive."""
return self._process is not None and self._process.is_alive()
@property
def service_backend(self) -> str:
"""Execution backend that owns the policy."""
return "process"
@property
def transport_kind(self) -> str:
"""Physical transport used for inference payloads."""
return _inference_transport_kind(self.transport)
[docs]
def client(self) -> Any:
"""Return a restricted inference client from the owned transport."""
if self._service_client is None:
self._service_client = self.transport.client()
return self._service_client
[docs]
def clients(self, num_clients: int) -> list[Any]:
"""Return one independently routed client per concurrent consumer."""
if isinstance(num_clients, bool) or not isinstance(num_clients, int):
raise TypeError("num_clients must be an integer.")
if num_clients < 1:
raise ValueError("num_clients must be at least 1.")
# Transports may route replies per client, so reserve fresh endpoints.
return [self.transport.client() for _ in range(num_clients)]
[docs]
def stats(
self, *, reset: bool = False, timeout: float = 5.0
) -> dict[str, float | int]:
"""Return process-server stats from the child process.
This is a blocking control-plane round trip: it can take up to
``timeout`` seconds and raises :class:`TimeoutError` when the child
does not answer in time, or :class:`RuntimeError` when the child is
not running.
Args:
reset (bool, optional): if ``True``, reset counters in the child
process after taking the snapshot.
timeout (float, optional): seconds to wait for the child's
answer. Defaults to ``5.0``.
"""
return self._request_control("stats", {"reset": reset}, timeout=timeout)
[docs]
def update_model_weights(
self,
weights: TensorDictBase,
*,
mark_weight_update: bool = True,
timeout: float = 300.0,
) -> dict[str, bool]:
"""Apply TensorDict weights to the model hosted by the child process.
This is a blocking control-plane round trip; large models can take a
while to transfer and apply, hence the generous default timeout.
Args:
weights (TensorDictBase): weights to apply to the child's model.
mark_weight_update (bool, optional): whether to bump the child's
behavior-policy version. Defaults to ``True``.
timeout (float, optional): seconds to wait for the child to apply
the weights. Defaults to ``300.0``.
"""
return self._request_control(
"update_model_weights",
{"weights": weights, "mark_weight_update": mark_weight_update},
timeout=timeout,
)
[docs]
def health(self, *, timeout: float = 5.0) -> dict[str, int | bool | None]:
"""Return a lightweight child-process health snapshot.
Never raises on a dead or unresponsive child; degraded fields are
reported in the returned dictionary instead (``process_alive`` /
``control_error``), so this is safe to call from monitoring loops.
Args:
timeout (float, optional): seconds to wait for the child's
answer. Defaults to ``5.0``.
"""
process = self._process
result = {
"process_alive": process.is_alive() if process is not None else False,
"pid": process.pid if process is not None else None,
"exitcode": process.exitcode if process is not None else None,
}
if process is not None and process.is_alive():
try:
result.update(self._request_control("health", timeout=timeout))
except (RuntimeError, TimeoutError) as exc:
# The child may have died between the liveness check and the
# control round trip; a health probe reports that instead of
# raising.
result["process_alive"] = (
process.is_alive() if process is not None else False
)
result["exitcode"] = process.exitcode if process is not None else None
result["control_error"] = repr(exc)
return result
def __enter__(self) -> ProcessInferenceServer:
return self.start()
def __exit__(self, *exc_info) -> None:
self.shutdown()
def __del__(self) -> None:
# getattr: __del__ also runs on instances whose __init__ raised
# before attribute assignment (e.g. config-validation errors).
process = getattr(self, "_process", None)
if process is not None and process.is_alive():
self.shutdown(timeout=1.0)
class _RayInferenceServerActor:
"""Ray actor that owns a policy, transport, and local inference loop."""
def __init__(
self,
policy_factory: Callable[[], nn.Module],
transport: InferenceTransport | str | None,
transport_options: dict[str, Any] | None,
request_spec: TensorDictBase | None,
response_spec: TensorDictBase | None,
num_clients: int | None,
server_kwargs: dict[str, Any],
) -> None:
self.model = policy_factory()
self._transport = transport
self._transport_options = transport_options
self._num_clients = num_clients
self._bootstrap_lock = threading.Lock()
config = server_kwargs.get("server_config")
if config is not None and config.service_backend != "thread":
server_kwargs["server_config"] = replace(config, service_backend="thread")
self._server_kwargs = server_kwargs
self._receiver_schemes: dict[str, WeightSyncScheme] = {}
self.server = None
if transport == "distributed" and (
request_spec is not None or response_spec is not None
):
if request_spec is None or response_spec is None:
raise ValueError(
"request_spec and response_spec must be provided together."
)
self._start_server(request_spec, response_spec)
elif transport != "distributed":
resolved_transport = _make_inference_transport(
transport,
service_backend="ray",
transport_options=transport_options,
request_spec=None,
response_spec=None,
num_clients=num_clients,
)
self.server = InferenceServer(
self.model,
resolved_transport,
service_backend="thread",
request_spec=request_spec,
**server_kwargs,
).start()
def _start_server(
self, request_spec: TensorDictBase, response_spec: TensorDictBase
) -> None:
resolved_transport = _make_inference_transport(
self._transport,
service_backend="ray",
transport_options=self._transport_options,
request_spec=request_spec,
response_spec=response_spec,
num_clients=self._num_clients,
)
self.server = InferenceServer(
self.model,
resolved_transport,
service_backend="thread",
request_spec=request_spec,
**self._server_kwargs,
).start()
def bootstrap_distributed(self, request: TensorDictBase):
"""Bind the distributed layout on the first endpoint generation."""
with self._bootstrap_lock:
if self.server is None:
# Reuse InferenceServer's collation, placement, and versioning
# rules to derive the reply layout without publishing a second
# public configuration surface.
probe = InferenceServer(
self.model,
ThreadingTransport(),
service_backend="thread",
**self._server_kwargs,
)
# Collation may return a lazy stack backed by the request. A
# TensorDictModule then writes its output keys into that stack,
# which must not widen the request schema used by the transport.
batch = probe.collate_fn([request.clone()])
if probe.policy_device is not None:
batch = batch.to(probe.policy_device)
interaction_context, batch, _ = probe._interaction_type_context(batch)
with interaction_context:
response = self.model(batch)
if probe.output_device is not None:
response = response.to(probe.output_device)
response = probe._set_policy_version(response).unbind(0)[0]
self._start_server(request, response)
return self.server.client()
def client(self):
if self.server is None:
raise RuntimeError(
"The distributed transport has not established its schema yet."
)
return self.server.client()
def stats(self, reset: bool = False) -> dict[str, float | int]:
if self.server is None:
return {"num_requests": 0, "num_batches": 0}
return self.server.stats(reset=reset)
def health(self) -> dict[str, Any]:
return {
"is_alive": self.server is None or self.server.is_alive,
"policy_version": (
int(self._server_kwargs.get("policy_version", 0))
if self.server is None
else self.server.policy_version
),
"transport": (
"distributed"
if self.server is None
else _inference_transport_kind(self.server.transport)
),
}
def update_model_weights(
self, weights: TensorDictBase, mark_weight_update: bool = True
) -> None:
if self.server is None:
weights.to_module(self.model, inplace=True)
if mark_weight_update:
self._server_kwargs["policy_version"] = (
int(self._server_kwargs.get("policy_version", 0)) + 1
)
else:
self.server.update_model(
ft.partial(weights.to_module, inplace=True),
mark_weight_update=mark_weight_update,
)
def register_scheme_receiver(
self,
weight_recv_schemes: dict[str, WeightSyncScheme],
*,
synchronize_weights: bool = True,
) -> None:
"""Install restricted weight receivers for learner-rank publication."""
for model_id, scheme in weight_recv_schemes.items():
previous_scheme = self._receiver_schemes.get(model_id)
if previous_scheme is not None and previous_scheme is not scheme:
previous_scheme.shutdown()
scheme.init_on_receiver(
model_id=model_id,
worker_idx=0,
model=self.model,
)
self._receiver_schemes[model_id] = scheme
if synchronize_weights:
for scheme in self._receiver_schemes.values():
scheme.connect(worker_idx=0)
def _weight_sync_signature(
self, model_id: str
) -> tuple[tuple[tuple[str, ...], tuple[int, ...], str], ...]:
"""Return the ordered tensor schema expected by this service."""
if model_id != "policy":
raise KeyError(f"Unknown inference model_id {model_id!r}.")
weights = TensorDict.from_module(self.model)
return _weight_tensor_signature(weights)
def _receive_weights_scheme(self, model_version: int | None = None) -> None:
if not self._receiver_schemes:
raise RuntimeError("No inference weight receiver is configured.")
if self.server is None:
for scheme in self._receiver_schemes.values():
scheme.receive()
current_version = int(self._server_kwargs.get("policy_version", 0))
self._server_kwargs["policy_version"] = (
current_version + 1 if model_version is None else model_version
)
else:
with self.server._model_lock:
for scheme in self._receiver_schemes.values():
scheme.receive()
try:
self.server._validate_cudagraph_storage()
finally:
self.server._mark_weight_update(model_version)
def _connect_weights_scheme(self, model_version: int | None = None) -> None:
if not self._receiver_schemes:
raise RuntimeError("No inference weight receiver is configured.")
if self.server is None:
for scheme in self._receiver_schemes.values():
if not scheme.synchronized_on_receiver:
scheme.connect(worker_idx=0)
current_version = int(self._server_kwargs.get("policy_version", 0))
self._server_kwargs["policy_version"] = (
current_version + 1 if model_version is None else model_version
)
else:
with self.server._model_lock:
for scheme in self._receiver_schemes.values():
if not scheme.synchronized_on_receiver:
scheme.connect(worker_idx=0)
try:
self.server._validate_cudagraph_storage()
finally:
self.server._mark_weight_update(model_version)
def shutdown(self) -> None:
for scheme in self._receiver_schemes.values():
scheme.shutdown()
self._receiver_schemes.clear()
if self.server is not None:
self.server.shutdown(timeout=None)
close = getattr(self.server.transport, "close", None)
if close is not None:
close()
class _RayDistributedInferenceClient:
"""Restricted client that lazily binds a distributed TensorDict schema."""
def __init__(self, actor) -> None:
self._actor = actor
self._client = None
def __call__(
self, payload: TensorDictBase, timeout: float | None = None
) -> TensorDictBase:
if self._client is None:
import ray
self._client = ray.get(self._actor.bootstrap_distributed.remote(payload))
_set_ray_client_liveness(self._client, self._actor)
return self._client(payload, timeout=timeout)
class _RayInferenceServer(InferenceServer):
"""Private Ray owner returned by ``InferenceServer`` dispatch."""
def __init__(
self,
*,
policy_factory: Callable[[], nn.Module],
transport: InferenceTransport | str | None = "auto",
transport_options: dict[str, Any] | None = None,
service_backend_options: dict[str, Any] | None = None,
request_spec: TensorDictBase | None = None,
response_spec: TensorDictBase | None = None,
num_clients: int | None = None,
**server_kwargs,
) -> None:
if not _has_ray:
raise ImportError("Ray is required for service_backend='ray'.")
import ray
options = dict(service_backend_options or {})
ray_init_config = options.pop("ray_init_config", None)
remote_config = dict(options.pop("remote_config", None) or {})
if options:
raise ValueError(
f"Unsupported Ray service_backend_options: {sorted(options)}."
)
if remote_config.get("num_gpus", 0):
if (
server_kwargs.get("device") is None
and server_kwargs.get("policy_device") is None
and server_kwargs.get("device_config") is None
):
server_kwargs["policy_device"] = "cuda:0"
if (
server_kwargs.get("output_device") is None
and server_kwargs.get("device_config") is None
):
server_kwargs["output_device"] = "cpu"
self._runtime_lease = _RayRuntimeLease.acquire(ray_init_config)
self._actor = None
self._lazy_distributed = (
transport == "distributed"
and request_spec is None
and response_spec is None
)
self._distributed = transport == "distributed"
try:
actor_cls = ray.remote(**remote_config)(_RayInferenceServerActor)
self._actor = actor_cls.remote(
policy_factory,
transport,
transport_options,
request_spec,
response_spec,
num_clients,
server_kwargs,
)
ray.get(self._actor.health.remote())
except BaseException:
if self._actor is not None:
with contextlib.suppress(Exception):
ray.kill(self._actor, no_restart=True)
self._actor = None
self._runtime_lease.release()
raise
@property
def service_backend(self) -> str:
return "ray"
@property
def is_alive(self) -> bool:
actor = getattr(self, "_actor", None)
if actor is None:
return False
import ray
try:
return bool(ray.get(actor.health.remote())["is_alive"])
except Exception:
return False
@property
def policy_version(self) -> int:
return int(self.health()["policy_version"])
@property
def transport_kind(self) -> str:
return str(self.health()["transport"])
def start(self) -> _RayInferenceServer:
if not self.is_alive:
raise RuntimeError("The Ray inference server is not alive.")
return self
def prepare_cudagraph(
self,
request_spec: TensorDictBase,
*,
interaction_type: InteractionType | None = None,
) -> None:
"""Reject post-construction capture for the already-running Ray actor.
Args:
request_spec (TensorDictBase): unused representative request.
Keyword Args:
interaction_type (InteractionType, optional): unused sampling mode.
"""
del request_spec, interaction_type
raise RuntimeError(
"A Ray inference server captures inside its actor; pass request_spec "
"when constructing InferenceServer."
)
def client(self):
if self._actor is None:
raise RuntimeError("The Ray inference server is closed.")
if self._lazy_distributed:
return _RayDistributedInferenceClient(self._actor)
import ray
client = ray.get(self._actor.client.remote())
if self._distributed:
_set_ray_client_liveness(client, self._actor)
return client
def clients(self, num_clients: int) -> list[Any]:
if isinstance(num_clients, bool) or not isinstance(num_clients, int):
raise TypeError("num_clients must be an integer.")
if num_clients < 1:
raise ValueError("num_clients must be at least 1.")
return [self.client() for _ in range(num_clients)]
def stats(self, *, reset: bool = False) -> dict[str, float | int]:
if self._actor is None:
raise RuntimeError("The Ray inference server is closed.")
import ray
return ray.get(self._actor.stats.remote(reset))
def health(self) -> dict[str, Any]:
if self._actor is None:
return {"is_alive": False}
import ray
return ray.get(self._actor.health.remote())
def update_model_weights(
self,
weights: TensorDictBase,
*,
mark_weight_update: bool = True,
) -> None:
if self._actor is None:
raise RuntimeError("The Ray inference server is closed.")
import ray
ray.get(self._actor.update_model_weights.remote(weights, mark_weight_update))
def update_policy_weights_(
self, model_id=None, policy_or_weights=None, **kwargs
) -> None:
del kwargs
if (
policy_or_weights is None
and model_id is not None
and not isinstance(model_id, str)
):
policy_or_weights = model_id
if isinstance(policy_or_weights, nn.Module):
policy_or_weights = TensorDict.from_module(policy_or_weights).data
if policy_or_weights is None:
raise ValueError("Policy weights must be provided.")
self.update_model_weights(policy_or_weights)
def shutdown(self, timeout: float | None = 5.0) -> None:
actor = getattr(self, "_actor", None)
if actor is None:
return
import ray
try:
ray.get(actor.shutdown.remote(), timeout=timeout)
except Exception:
pass
try:
ray.kill(actor, no_restart=True)
except Exception:
pass
self._actor = None
self._runtime_lease.release()
close = shutdown
def __enter__(self) -> _RayInferenceServer:
return self.start()
def __exit__(self, *exc_info) -> None:
self.shutdown()
def __del__(self) -> None:
if getattr(self, "_actor", None) is not None:
with contextlib.suppress(BaseException):
self.shutdown(timeout=1.0)
[docs]
class InferenceClient:
"""Actor-side handle for an :class:`InferenceServer`.
Wraps a transport's :meth:`~InferenceTransport.submit` so that calling
``client(td)`` looks like a regular synchronous policy call, while the
actual computation is batched on the server.
Args:
transport (InferenceTransport): the transport shared with the server.
Example:
>>> client = transport.client()
>>> td_out = client(td_in) # blocking
>>> future = client.submit(td_in) # non-blocking
>>> td_out = future.result()
"""
def __init__(self, transport: InferenceTransport):
self._transport = transport
def __call__(self, td: TensorDictBase) -> TensorDictBase:
"""Submit a request and block until the result is ready."""
return self._transport.submit(td).result()
[docs]
def submit(self, td: TensorDictBase) -> Future[TensorDictBase]:
"""Submit a request and return a Future immediately."""
return self._transport.submit(td)