Skip to content

Commit cd1406e

Browse files
committed
[Feature] Track behavior policy versions in inference server
ghstack-source-id: 048a08f Pull-Request: #3895 # Conflicts: # test/test_inference_server.py # torchrl/modules/inference_server/_server.py # Conflicts: # torchrl/collectors/_async_batched.py # torchrl/modules/inference_server/_client.py
1 parent 307eb5a commit cd1406e

9 files changed

Lines changed: 426 additions & 6 deletions

File tree

docs/source/reference/envs_transforms.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ Available Transforms
295295
MultiAction
296296
NextObservationDelta
297297
NextStateReconstructor
298+
PolicyAgeFilter
298299
NoopResetEnv
299300
ObservationNorm
300301
ObservationTransform

docs/source/reference/modules_inference_server.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,20 @@ TensorDict policy but inference should be served by the policy server:
121121
remote_policy = PolicyClientModule(
122122
transport,
123123
in_keys=["observation"],
124-
out_keys=["action"],
124+
out_keys=["action", "policy_version"],
125125
)
126126
127127
data = remote_policy(data)
128128
129+
The server writes ``policy_version`` by default so asynchronous collectors can
130+
track behavior-policy lag. This is the general *service-stamped metadata*
131+
pattern: any service may stamp its responses with metadata about the state it
132+
served them from, and the data pipeline may enforce freshness constraints on
133+
it. Bounded staleness is enforced by the replay buffer through
134+
:class:`~torchrl.envs.transforms.PolicyAgeFilter`, which drops elements whose
135+
stamped version lags the live version by more than ``max_policy_lag`` --
136+
either at extension time or dynamically at sampling time.
137+
129138
Weight Synchronisation
130139
^^^^^^^^^^^^^^^^^^^^^^
131140

test/rb/test_rb_transforms.py

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from torchrl.data import ReplayBuffer, TensorDictReplayBuffer
1818
from torchrl.data.replay_buffers.samplers import RandomSampler, SliceSampler
1919
from torchrl.data.replay_buffers.storages import LazyMemmapStorage, LazyTensorStorage
20-
from torchrl.envs.transforms import NextStateReconstructor
20+
from torchrl.envs.transforms import NextStateReconstructor, PolicyAgeFilter
2121
from torchrl.envs.transforms.transforms import (
2222
BinarizeReward,
2323
CatFrames,
@@ -511,6 +511,99 @@ def test_bad_batch_dims_errors(self):
511511
NextStateReconstructor()(td)
512512

513513

514+
class TestPolicyAgeFilter:
515+
@staticmethod
516+
def _data(versions):
517+
versions = torch.as_tensor(versions)
518+
return TensorDict(
519+
{
520+
"observation": torch.randn(versions.shape[0], 3),
521+
"policy_version": versions,
522+
},
523+
batch_size=[versions.shape[0]],
524+
)
525+
526+
def test_filters_on_extend(self):
527+
rb = ReplayBuffer(
528+
storage=LazyTensorStorage(100),
529+
transform=PolicyAgeFilter(3, max_policy_lag=1),
530+
)
531+
rb.extend(self._data([0, 2, 2, 3]))
532+
assert len(rb) == 3
533+
534+
def test_filters_dynamically_on_sample(self):
535+
current = {"version": 3}
536+
rb = ReplayBuffer(
537+
storage=LazyTensorStorage(100),
538+
transform=PolicyAgeFilter(lambda: current["version"], max_policy_lag=1),
539+
)
540+
rb.extend(self._data([2, 2, 3, 3]))
541+
assert len(rb) == 4
542+
assert rb.sample(4).batch_size[0] == 4
543+
# The policy moved on: version-2 data became stale since insertion.
544+
# The random sampler draws with replacement, so the batch size is
545+
# not deterministic; the invariant is that no stale element passes.
546+
current["version"] = 4
547+
sample = rb.sample(4)
548+
assert (sample["policy_version"] == 3).all()
549+
# Once everything is stale the batch is deterministically empty.
550+
current["version"] = 10
551+
sample = rb.sample(4)
552+
assert sample.batch_size[0] == 0
553+
554+
def test_nested_version_key(self):
555+
rb = ReplayBuffer(
556+
storage=LazyTensorStorage(100),
557+
transform=PolicyAgeFilter(
558+
3, max_policy_lag=0, policy_version_key=("meta", "policy_version")
559+
),
560+
)
561+
data = TensorDict(
562+
{
563+
"observation": torch.randn(3, 2),
564+
("meta", "policy_version"): torch.tensor([1, 3, 3]),
565+
},
566+
batch_size=[3],
567+
)
568+
rb.extend(data)
569+
assert len(rb) == 2
570+
571+
def test_row_uses_oldest_element(self):
572+
# [B, T] data: a single stale element marks the whole row stale
573+
filt = PolicyAgeFilter(5, max_policy_lag=2)
574+
td = TensorDict(
575+
{
576+
"observation": torch.randn(2, 4),
577+
"policy_version": torch.tensor([[3, 5, 5, 5], [4, 5, 5, 2]]),
578+
},
579+
batch_size=[2, 4],
580+
)
581+
out = filt(td)
582+
assert out.batch_size[0] == 1
583+
584+
def test_missing_key_warns_and_passes_through(self):
585+
filt = PolicyAgeFilter(3, max_policy_lag=1)
586+
td = TensorDict({"observation": torch.randn(4, 3)}, batch_size=[4])
587+
out = filt(td)
588+
assert out.batch_size[0] == 4
589+
with pytest.raises(KeyError, match="PolicyAgeFilter"):
590+
PolicyAgeFilter(3, max_policy_lag=1, strict=True)(td)
591+
592+
def test_negative_lag_rejected(self):
593+
with pytest.raises(ValueError, match="non-negative"):
594+
PolicyAgeFilter(3, max_policy_lag=-1)
595+
596+
def test_noop_when_attached_to_env(self):
597+
from torchrl.envs import TransformedEnv
598+
from torchrl.testing.mocking_classes import CountingEnv
599+
600+
env = TransformedEnv(
601+
CountingEnv(max_steps=4), PolicyAgeFilter(0, max_policy_lag=0)
602+
)
603+
rollout = env.rollout(3)
604+
assert rollout.batch_size[0] == 3
605+
606+
514607
if __name__ == "__main__":
515608
args, unknown = argparse.ArgumentParser().parse_known_args()
516609
pytest.main([__file__, "--capture", "no", "--exitfirst"] + unknown)

test/test_inference_server.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ def test_stats_accounting(self):
286286
assert stats["batches"] >= 1
287287
assert stats["avg_batch_size"] > 0
288288
assert stats["p95_forward_ms"] >= 0
289+
assert stats["policy_version"] == 0
289290

290291
def test_structured_config(self):
291292
transport = ThreadingTransport()
@@ -369,6 +370,19 @@ def recording_forward(x):
369370
assert all(device.type == "cuda" for device in seen_devices)
370371
assert next(policy.parameters()).device.type == "cuda"
371372

373+
def test_policy_version_is_returned(self):
374+
transport = ThreadingTransport()
375+
policy = _make_policy()
376+
with InferenceServer(
377+
policy,
378+
transport,
379+
policy_version=12,
380+
policy_version_key=("meta", "policy_version"),
381+
):
382+
client = transport.client()
383+
result = client(TensorDict({"observation": torch.randn(4)}))
384+
assert result["meta", "policy_version"].item() == 12
385+
372386
@pytest.mark.gpu
373387
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs CUDA")
374388
def test_cuda_policy_cpu_output(self):
@@ -485,6 +499,15 @@ def failing_client(td):
485499
with pytest.raises(ValueError, match="local policy failure"):
486500
future.result()
487501

502+
def test_update_policy_weights_cascade_bumps_version(self):
503+
"""The weight-sync cascade hook increments the policy version."""
504+
transport = ThreadingTransport()
505+
policy = _make_policy()
506+
server = InferenceServer(policy, transport, policy_version=0)
507+
assert server.policy_version == 0
508+
server.update_policy_weights_()
509+
assert server.policy_version == 1
510+
488511

489512
# =============================================================================
490513
# Tests: ThreadingTransport (Commit 2)
@@ -889,6 +912,8 @@ def test_weight_update_applied(self):
889912
result_after = client(td)
890913
# With zero weights the linear output should be zero (bias=0 too)
891914
assert torch.allclose(result_after["action"], torch.zeros(2), atol=1e-6)
915+
assert result_after["policy_version"].item() == 1
916+
assert server.stats()["weight_updates"] == 1
892917

893918
def test_inference_continues_after_weight_update(self):
894919
"""The server keeps serving after a weight update."""
@@ -1213,6 +1238,20 @@ def test_process_server_backend_smoke(self):
12131238
collector.shutdown()
12141239
assert total >= 20
12151240

1241+
def test_policy_version_key_none_disables_annotations(self):
1242+
collector = AsyncBatchedCollector(
1243+
create_env_fn=[_counting_env_factory] * 2,
1244+
policy=_make_counting_policy(),
1245+
frames_per_batch=10,
1246+
total_frames=10,
1247+
max_batch_size=2,
1248+
env_backend="threading",
1249+
policy_version_key=None,
1250+
)
1251+
for batch in collector:
1252+
assert "policy_version" not in batch.keys()
1253+
collector.shutdown()
1254+
12161255
def test_invalid_server_backend_raises(self):
12171256
with pytest.raises(ValueError, match="backend"):
12181257
InferenceServerConfig(backend="not-a-backend")

torchrl/collectors/_async_batched.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Literal
1212

1313
import torch
14-
from tensordict import lazy_stack, TensorDictBase
14+
from tensordict import lazy_stack, NestedKey, TensorDictBase
1515

1616
from torchrl._utils import _maybe_record_function_decorator, logger as torchrl_logger
1717
from torchrl.collectors._base import BaseCollector
@@ -184,6 +184,11 @@ class AsyncBatchedCollector(BaseCollector):
184184
placement (``policy_device``, ``output_device``, ``env_device``,
185185
``storing_device``) for the whole collection pipeline. Mutually
186186
exclusive with ``device``.
187+
policy_version (int, optional): initial behavior-policy version
188+
attached to server outputs. Defaults to ``0``.
189+
policy_version_key (NestedKey or None, optional): TensorDict key used
190+
for behavior-policy version annotations. ``None`` disables
191+
annotations. Defaults to ``"policy_version"``.
187192
backend (str, optional): global default backend for both
188193
environments and policy inference. Specific overrides
189194
``env_backend`` and ``policy_backend`` take precedence when set.
@@ -271,6 +276,8 @@ def __init__(
271276
create_env_kwargs: dict | list[dict] | None = None,
272277
server_config: InferenceServerConfig | None = None,
273278
device_config: InferenceDeviceConfig | None = None,
279+
policy_version: int = 0,
280+
policy_version_key: NestedKey | None = "policy_version",
274281
):
275282
if policy is not None and policy_factory is not None:
276283
raise TypeError("policy and policy_factory are mutually exclusive.")
@@ -372,6 +379,8 @@ def __init__(
372379
weight_sync_model_id=weight_sync_model_id,
373380
collect_stats=_server_defaults.collect_stats,
374381
stats_window_size=_server_defaults.stats_window_size,
382+
policy_version=policy_version,
383+
policy_version_key=policy_version_key,
375384
)
376385
else:
377386
self._server = InferenceServer(
@@ -386,7 +395,10 @@ def __init__(
386395
weight_sync_model_id=weight_sync_model_id,
387396
collect_stats=_server_defaults.collect_stats,
388397
stats_window_size=_server_defaults.stats_window_size,
398+
policy_version=policy_version,
399+
policy_version_key=policy_version_key,
389400
)
401+
self._policy_version_key = policy_version_key
390402

391403
# ---- collector settings -----------------------------------------------
392404
self.requested_frames_per_batch = frames_per_batch
@@ -490,6 +502,11 @@ def server_stats(self, *, reset: bool = False) -> dict[str, float | int]:
490502
return {}
491503
return stats(reset=reset)
492504

505+
@property
506+
def policy_version(self) -> int:
507+
"""The live behavior-policy version of the inference server."""
508+
return self._server.policy_version
509+
493510
# ------------------------------------------------------------------
494511
# Rollout: drain the result queue
495512
# ------------------------------------------------------------------

torchrl/envs/transforms/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from .module import ModuleTransform
1717
from .r3m import R3MTransform
1818
from .ray_service import RayTransform
19-
from .rb_transforms import MultiStepTransform, NextStateReconstructor
19+
from .rb_transforms import MultiStepTransform, NextStateReconstructor, PolicyAgeFilter
2020
from .rnd import RNDTransform, RunningMeanStd
2121
from .transforms import (
2222
ActionChunkTransform,
@@ -179,6 +179,7 @@ def __getattr__(name: str):
179179
"MultiStepTransform",
180180
"NextObservationDelta",
181181
"NextStateReconstructor",
182+
"PolicyAgeFilter",
182183
"NoopResetEnv",
183184
"ObservationNorm",
184185
"ObservationTransform",

0 commit comments

Comments
 (0)