Skip to content

Commit d8b05ca

Browse files
authored
[BugFix] Make async recv timeout bound the whole call (#4184)
1 parent 45dedbc commit d8b05ca

3 files changed

Lines changed: 146 additions & 14 deletions

File tree

‎test/envs/test_special.py‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -945,9 +945,12 @@ def test_shared_memory_lazy_result_lifetime(self, make_envs):
945945
def test_shared_memory_deadline_batching(self, make_envs):
946946
env = AsyncEnvPool(make_envs, backend="multiprocessing", exchange="shm")
947947
try:
948-
env.async_reset_send(env_index=list(range(env.num_envs)))
949-
first = env.async_reset_recv(min_get=1, max_get=4, timeout=0.0)
950-
remaining = env.async_reset_recv(min_get=3, max_get=3, timeout=0.1)
948+
# Only env 0 is commanded, so the first recv returns a partial
949+
# batch (1 of max_get=4) when its deadline expires.
950+
env.async_reset_send(env_index=[0])
951+
first = env.async_reset_recv(min_get=1, max_get=4, timeout=1.0)
952+
env.async_reset_send(env_index=[1, 2, 3])
953+
remaining = env.async_reset_recv(min_get=3, max_get=3, timeout=10.0)
951954
assert first.shape[0] == 1
952955
assert remaining.shape[0] == 3
953956
stats = env.stats(reset=True)
@@ -967,13 +970,47 @@ def test_deadline_batching(self, make_envs, backend):
967970
env = AsyncEnvPool(make_envs, backend=backend)
968971
try:
969972
env.async_reset_send(env_index=list(range(env.num_envs)))
970-
first = env.async_reset_recv(min_get=1, max_get=1, timeout=0.0)
971-
remaining = env.async_reset_recv(min_get=3, max_get=3, timeout=0.1)
973+
first = env.async_reset_recv(min_get=1, max_get=1, timeout=10.0)
974+
remaining = env.async_reset_recv(min_get=3, max_get=3, timeout=10.0)
972975
assert first.shape[0] == 1
973976
assert remaining.shape[0] == 3
974977
finally:
975978
env._maybe_shutdown()
976979

980+
@pytest.mark.parametrize(
981+
"backend,exchange",
982+
[
983+
("multiprocessing", "queue"),
984+
("multiprocessing", "shm"),
985+
("threading", "queue"),
986+
],
987+
)
988+
@set_capture_non_tensor_stack(False)
989+
def test_recv_timeout_bounds_whole_call(self, backend, exchange):
990+
"""The recv deadline covers the wait for min_get, without losing results.
991+
992+
One env steps immediately, the other takes ~1s. A recv asking for both
993+
with a 0.2s deadline must raise TimeoutError instead of blocking until
994+
the slow env finishes, and the fast env's result must remain available
995+
to the next call.
996+
"""
997+
makers = [
998+
partial(_DelayedCountingEnv, delay=0.0, max_steps=1000),
999+
partial(_DelayedCountingEnv, delay=1.0, max_steps=1000),
1000+
]
1001+
kwargs = {"exchange": exchange} if backend == "multiprocessing" else {}
1002+
env = AsyncEnvPool(makers, backend=backend, **kwargs)
1003+
try:
1004+
reset = env.reset()
1005+
reset.set("action", torch.ones(reset.shape + (1,)))
1006+
env.async_step_send(reset)
1007+
with pytest.raises(TimeoutError, match="timed out"):
1008+
env.async_step_recv(min_get=2, timeout=0.2)
1009+
result = env.async_step_recv(min_get=2, timeout=60.0)
1010+
assert result.shape[0] == 2
1011+
finally:
1012+
env._maybe_shutdown()
1013+
9771014
@pytest.mark.parametrize("backend", ["multiprocessing", "threading"])
9781015
def test_deadline_batch_validation(self, make_envs, backend):
9791016
env = AsyncEnvPool(make_envs, backend=backend)

‎torchrl/envs/_async_exchange.py‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,48 @@ def _receive_batch(
3232
if timeout is not None and timeout < 0:
3333
raise ValueError(f"timeout must be non-negative, got {timeout}.")
3434

35-
items = [result_queue.get()]
35+
# The deadline is anchored at call entry and bounds the entire call,
36+
# including the wait for the first result.
3637
deadline_timer = (
3738
None if timeout is None else timeit("async_env_batch_deadline").start()
3839
)
3940

41+
items: list[Any] = []
4042
while len(items) < min_get:
41-
items.append(result_queue.get())
43+
if deadline_timer is None:
44+
items.append(result_queue.get())
45+
continue
46+
remaining = timeout - deadline_timer.elapsed()
47+
try:
48+
if remaining <= 0:
49+
# Deadline passed: drain what is already available without
50+
# waiting before giving up.
51+
items.append(result_queue.get_nowait())
52+
else:
53+
items.append(result_queue.get(timeout=remaining))
54+
except queue.Empty:
55+
# Requeue the partial harvest so no result is lost, then signal
56+
# the missed deadline. The pool's pending-result accounting is
57+
# only updated by the caller on success, so state stays
58+
# consistent.
59+
for item in items:
60+
result_queue.put(item)
61+
raise TimeoutError(
62+
f"async recv timed out: {len(items)}/{min_get} results after "
63+
f"{timeout}s; partial results were requeued and remain "
64+
f"available to the next call."
65+
) from None
4266

4367
limit = max_get if max_get is not None else float("inf")
4468
while len(items) < limit:
4569
try:
4670
if deadline_timer is None:
4771
items.append(result_queue.get_nowait())
72+
continue
73+
remaining = timeout - deadline_timer.elapsed()
74+
if remaining <= 0:
75+
items.append(result_queue.get_nowait())
4876
else:
49-
remaining = timeout - deadline_timer.elapsed()
50-
if remaining <= 0:
51-
break
5277
items.append(result_queue.get(timeout=remaining))
5378
except queue.Empty:
5479
break

‎torchrl/envs/async_envs.py‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,32 @@ def async_step_recv(
480480
max_get: int | None = None,
481481
timeout: float | None = None,
482482
) -> TensorDictBase:
483+
"""Collects step results from the pool.
484+
485+
Args:
486+
min_get (int, optional): minimum number of results to collect
487+
before returning. Defaults to ``self.min_get``.
488+
env_index (int, optional): if provided, waits for the result of
489+
this specific env instead of forming a batch; ``min_get``,
490+
``max_get`` and ``timeout`` are ignored.
491+
492+
Keyword Args:
493+
max_get (int, optional): maximum number of results to return.
494+
``None`` (default) drains everything available once
495+
``min_get`` is satisfied.
496+
timeout (float, optional): deadline in seconds for the entire
497+
call, measured from call entry. If fewer than ``min_get``
498+
results are available when the deadline expires, a
499+
:class:`TimeoutError` is raised; results collected so far are
500+
put back and remain available to the next call. Once
501+
``min_get`` is satisfied, the call returns at the deadline
502+
with whatever is ready (up to ``max_get``). ``timeout=0``
503+
polls without blocking. ``None`` (default) waits
504+
indefinitely.
505+
506+
Returns:
507+
TensorDictBase: the stacked results, carrying ``"env_index"``.
508+
"""
483509
raise NotImplementedError
484510

485511
@abc.abstractmethod
@@ -497,6 +523,18 @@ def async_step_and_maybe_reset_recv(
497523
max_get: int | None = None,
498524
timeout: float | None = None,
499525
) -> tuple[TensorDictBase, TensorDictBase]:
526+
"""Collects step-and-maybe-reset results from the pool.
527+
528+
``min_get``, ``env_index``, ``max_get`` and ``timeout`` behave as in
529+
:meth:`async_step_recv`; in particular ``timeout`` bounds the entire
530+
call and raises :class:`TimeoutError` (without losing results) if
531+
fewer than ``min_get`` results arrive in time.
532+
533+
Returns:
534+
tuple of (TensorDictBase, TensorDictBase): the stacked step
535+
results and the stacked roots of the next steps (reset where
536+
needed), both carrying ``"env_index"``.
537+
"""
500538
raise NotImplementedError
501539

502540
@abc.abstractmethod
@@ -516,6 +554,17 @@ def async_reset_recv(
516554
max_get: int | None = None,
517555
timeout: float | None = None,
518556
) -> TensorDictBase:
557+
"""Collects reset results from the pool.
558+
559+
``min_get``, ``env_index``, ``max_get`` and ``timeout`` behave as in
560+
:meth:`async_step_recv`; in particular ``timeout`` bounds the entire
561+
call and raises :class:`TimeoutError` (without losing results) if
562+
fewer than ``min_get`` results arrive in time.
563+
564+
Returns:
565+
TensorDictBase: the stacked reset results, carrying
566+
``"env_index"``.
567+
"""
519568
raise NotImplementedError
520569

521570
def stats(self, *, reset: bool = False) -> dict[str, float | int]:
@@ -1118,12 +1167,33 @@ def _receive_futures(futures, min_get, max_get, timeout):
11181167
limit = len(futures) if max_get is None else max_get
11191168
pending = set(futures)
11201169
completed = []
1121-
deadline_timer = None
1170+
# The deadline is anchored at call entry and bounds the entire call,
1171+
# including the wait for the first result.
1172+
deadline_timer = (
1173+
None
1174+
if timeout is None
1175+
else timeit("async_env_future_batch_deadline").start()
1176+
)
11221177
while pending and len(completed) < min_get:
1123-
done, pending = wait(pending, return_when=FIRST_COMPLETED)
1178+
if deadline_timer is None:
1179+
done, pending = wait(pending, return_when=FIRST_COMPLETED)
1180+
else:
1181+
remaining = timeout - deadline_timer.elapsed()
1182+
# A non-positive remaining still polls: wait(timeout=0)
1183+
# returns whatever is already done without blocking.
1184+
done, pending = wait(
1185+
pending, timeout=max(remaining, 0.0), return_when=FIRST_COMPLETED
1186+
)
1187+
if not done:
1188+
# Completed futures stay in the pool's pending lists (the
1189+
# caller only removes returned ones), so no result is
1190+
# lost and state stays consistent.
1191+
raise TimeoutError(
1192+
f"async recv timed out: {len(completed)}/{min_get} "
1193+
f"results after {timeout}s; completed futures remain "
1194+
f"available to the next call."
1195+
)
11241196
completed.extend(list(done)[: limit - len(completed)])
1125-
if deadline_timer is None and timeout is not None:
1126-
deadline_timer = timeit("async_env_future_batch_deadline").start()
11271197
while pending and len(completed) < limit:
11281198
done = {future for future in pending if future.done()}
11291199
if not done:

0 commit comments

Comments
 (0)