Skip to content

Commit 307eb5a

Browse files
committed
[Feature] Add remote policy module for inference server clients
ghstack-source-id: c6f010b Pull-Request: #3894 # Conflicts: # docs/source/reference/modules_inference_server.rst
1 parent 898cee0 commit 307eb5a

5 files changed

Lines changed: 229 additions & 1 deletion

File tree

docs/source/reference/modules_inference_server.rst

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Core API
2121
InferenceDeviceConfig
2222
ProcessInferenceServer
2323
InferenceClient
24+
PolicyClientModule
2425
InferenceTransport
2526

2627
Transport Backends
@@ -109,6 +110,22 @@ drive the collector-side transfers:
109110
),
110111
)
111112
113+
Remote policy module
114+
^^^^^^^^^^^^^^^^^^^^
115+
116+
Use :class:`PolicyClientModule` when an actor or collector expects a regular
117+
TensorDict policy but inference should be served by the policy server:
118+
119+
.. code-block:: python
120+
121+
remote_policy = PolicyClientModule(
122+
transport,
123+
in_keys=["observation"],
124+
out_keys=["action"],
125+
)
126+
127+
data = remote_policy(data)
128+
112129
Weight Synchronisation
113130
^^^^^^^^^^^^^^^^^^^^^^
114131

test/test_inference_server.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import concurrent.futures
88
import importlib.util
99
import multiprocessing as mp
10+
import pickle
1011
import threading
1112
import time
1213

@@ -25,6 +26,7 @@
2526
InferenceServerConfig,
2627
InferenceTransport,
2728
MPTransport,
29+
PolicyClientModule,
2830
ProcessInferenceServer,
2931
RayTransport,
3032
SlotTransport,
@@ -407,6 +409,83 @@ def test_submit_returns_future(self):
407409
assert "action" in result.keys()
408410

409411

412+
def _echo_client(td):
413+
"""Picklable stand-in client for pickling tests."""
414+
return td
415+
416+
417+
class TestPolicyClientModule:
418+
def test_forward_as_tensordict_module(self):
419+
transport = ThreadingTransport()
420+
policy = _make_policy()
421+
with InferenceServer(policy, transport, max_batch_size=4):
422+
remote_policy = PolicyClientModule(
423+
transport,
424+
in_keys=["observation"],
425+
out_keys=["action"],
426+
)
427+
td = TensorDict({"observation": torch.randn(4)})
428+
result = remote_policy(td)
429+
assert result["action"].shape == (2,)
430+
assert remote_policy.in_keys == ["observation"]
431+
assert remote_policy.out_keys == ["action"]
432+
433+
def test_submit(self):
434+
transport = ThreadingTransport()
435+
policy = _make_policy()
436+
with InferenceServer(policy, transport, max_batch_size=4):
437+
remote_policy = PolicyClientModule(transport)
438+
future = remote_policy.submit(TensorDict({"observation": torch.randn(4)}))
439+
result = future.result(timeout=5.0)
440+
assert "action" in result.keys()
441+
442+
def test_nested_keys(self):
443+
"""in_keys/out_keys accept nested keys end to end."""
444+
transport = ThreadingTransport()
445+
policy = TensorDictModule(
446+
nn.Linear(4, 2),
447+
in_keys=[("agents", "observation")],
448+
out_keys=[("agents", "action")],
449+
)
450+
with InferenceServer(policy, transport, max_batch_size=4):
451+
remote_policy = PolicyClientModule(
452+
transport,
453+
in_keys=[("agents", "observation")],
454+
out_keys=[("agents", "action")],
455+
)
456+
td = TensorDict({("agents", "observation"): torch.randn(4)})
457+
result = remote_policy(td)
458+
assert result["agents", "action"].shape == (2,)
459+
assert remote_policy.in_keys == [("agents", "observation")]
460+
assert remote_policy.out_keys == [("agents", "action")]
461+
462+
def test_client_contract_picklable_no_lifecycle(self):
463+
"""Clients pickle cleanly and expose no lifecycle methods."""
464+
remote_policy = PolicyClientModule(
465+
_echo_client, in_keys=["observation"], out_keys=["observation"]
466+
)
467+
restored = pickle.loads(pickle.dumps(remote_policy))
468+
td = TensorDict({"observation": torch.randn(4)})
469+
result = restored(td)
470+
assert "observation" in result.keys()
471+
assert restored.in_keys == ["observation"]
472+
# Clients carry no lifecycle rights over the service
473+
for lifecycle in ("start", "shutdown", "close", "flush"):
474+
assert not hasattr(restored, lifecycle)
475+
476+
def test_plain_callable_client_defers_errors(self):
477+
"""A plain-callable client defers exceptions to result()."""
478+
479+
def failing_client(td):
480+
raise ValueError("local policy failure")
481+
482+
remote_policy = PolicyClientModule(failing_client)
483+
future = remote_policy.submit(TensorDict({}))
484+
assert future.done()
485+
with pytest.raises(ValueError, match="local policy failure"):
486+
future.result()
487+
488+
410489
# =============================================================================
411490
# Tests: ThreadingTransport (Commit 2)
412491
# =============================================================================

torchrl/collectors/_async_batched.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
InferenceDeviceConfig,
2121
InferenceServer,
2222
InferenceServerConfig,
23+
PolicyClientModule,
2324
ProcessInferenceServer,
2425
ThreadingTransport,
2526
)
@@ -432,7 +433,10 @@ def _ensure_started(self) -> None:
432433
# Create clients before a process server starts so response queues are
433434
# inherited by the child process.
434435
if self._clients is None:
435-
self._clients = [self._transport.client() for _ in range(self._num_envs)]
436+
self._clients = [
437+
PolicyClientModule(self._transport.client())
438+
for _ in range(self._num_envs)
439+
]
436440

437441
# Start inference server
438442
if not self._server.is_alive:

torchrl/modules/inference_server/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# This source code is licensed under the MIT license found in the
44
# LICENSE file in the root directory of this source tree.
55

6+
from torchrl.modules.inference_server._client import PolicyClientModule
67
from torchrl.modules.inference_server._config import (
78
InferenceDeviceConfig,
89
InferenceServerConfig,
@@ -27,6 +28,7 @@
2728
"InferenceTransport",
2829
"MonarchTransport",
2930
"MPTransport",
31+
"PolicyClientModule",
3032
"ProcessInferenceServer",
3133
"RayTransport",
3234
"SlotTransport",
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
#
3+
# This source code is licensed under the MIT license found in the
4+
# LICENSE file in the root directory of this source tree.
5+
from __future__ import annotations
6+
7+
from collections.abc import Callable, Sequence
8+
from concurrent.futures import Future
9+
10+
from tensordict.base import TensorDictBase
11+
from tensordict.nn import TensorDictModuleBase
12+
from tensordict.utils import NestedKey
13+
14+
from torchrl.modules.inference_server._transport import InferenceTransport
15+
16+
17+
class _ImmediateFuture:
18+
def __init__(self, result: TensorDictBase | BaseException):
19+
self._result = result
20+
21+
def done(self) -> bool:
22+
return True
23+
24+
def result(self, timeout: float | None = None) -> TensorDictBase:
25+
if isinstance(self._result, BaseException):
26+
raise self._result
27+
return self._result
28+
29+
30+
class PolicyClientModule(TensorDictModuleBase):
31+
"""TensorDict policy wrapper for remote inference-server clients.
32+
33+
``PolicyClientModule`` makes a transport client look like a TorchRL policy:
34+
it accepts a :class:`~tensordict.TensorDictBase`, submits it to an
35+
:class:`~torchrl.modules.inference_server.InferenceServer`, and returns the
36+
TensorDict produced by the remote policy. It can be passed anywhere a
37+
TensorDict policy module is expected.
38+
39+
This class is the reference implementation of TorchRL's service *client*
40+
contract: it duck-types the domain interface (a policy client IS a
41+
TensorDict policy, so consumer code cannot tell local from remote), it is
42+
cheap and picklable (it can be handed to spawned workers), and it carries
43+
no lifecycle rights -- clients can call the service but never start or
44+
shut it down; only the owner that constructed the server can.
45+
46+
.. note::
47+
Unlike a local :class:`~tensordict.nn.TensorDictModule`, the result
48+
crosses a transport boundary, so :meth:`forward` returns a *new*
49+
TensorDict rather than writing the ``out_keys`` into the input
50+
TensorDict. Use the return value; do not rely on in-place updates of
51+
the input.
52+
53+
Args:
54+
client (Callable or InferenceTransport): actor-side inference client.
55+
If a transport is provided, ``transport.client()`` is called.
56+
57+
Keyword Args:
58+
in_keys (sequence of NestedKey, optional): input keys advertised by the
59+
module. The full input TensorDict is still sent to the server.
60+
out_keys (sequence of NestedKey, optional): output keys advertised by
61+
the module.
62+
63+
Examples:
64+
>>> import torch
65+
>>> import torch.nn as nn
66+
>>> from tensordict import TensorDict
67+
>>> from tensordict.nn import TensorDictModule
68+
>>> from torchrl.modules.inference_server import (
69+
... InferenceServer,
70+
... PolicyClientModule,
71+
... ThreadingTransport,
72+
... )
73+
>>> policy = TensorDictModule(
74+
... nn.Linear(4, 2), in_keys=["observation"], out_keys=["action"]
75+
... )
76+
>>> transport = ThreadingTransport()
77+
>>> server = InferenceServer(policy, transport).start()
78+
>>> remote_policy = PolicyClientModule(
79+
... transport, in_keys=["observation"], out_keys=["action"]
80+
... )
81+
>>> td = remote_policy(TensorDict({"observation": torch.randn(4)}))
82+
>>> "action" in td.keys()
83+
True
84+
>>> server.shutdown()
85+
"""
86+
87+
def __init__(
88+
self,
89+
client: Callable[[TensorDictBase], TensorDictBase] | InferenceTransport,
90+
*,
91+
in_keys: Sequence[NestedKey] | None = None,
92+
out_keys: Sequence[NestedKey] | None = None,
93+
) -> None:
94+
super().__init__()
95+
if isinstance(client, InferenceTransport):
96+
client = client.client()
97+
self.client = client
98+
self.in_keys = list(in_keys or [])
99+
self.out_keys = list(out_keys or [])
100+
101+
def submit(self, tensordict: TensorDictBase) -> Future | _ImmediateFuture:
102+
"""Submit a TensorDict request and return a future-like object.
103+
104+
Args:
105+
tensordict (TensorDictBase): observation TensorDict to send to the
106+
remote policy.
107+
108+
Returns:
109+
Future-like object whose ``result()`` method returns a TensorDict.
110+
When the wrapped client exposes ``submit`` this is the transport's
111+
:class:`~concurrent.futures.Future` and submission errors raise
112+
synchronously; for a plain callable client the call runs eagerly
113+
and errors are deferred to ``result()`` on a reduced future that
114+
only implements ``done()`` and ``result()``.
115+
"""
116+
submit = getattr(self.client, "submit", None)
117+
if submit is None:
118+
try:
119+
result = self.client(tensordict)
120+
return _ImmediateFuture(result)
121+
except Exception as exc:
122+
return _ImmediateFuture(exc)
123+
return submit(tensordict)
124+
125+
def forward(self, tensordict: TensorDictBase) -> TensorDictBase:
126+
return self.submit(tensordict).result()

0 commit comments

Comments
 (0)