Rate this Page

Source code for torchrl.envs.async_envs

# 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 abc
import multiprocessing
import os
import queue
import threading
import traceback
import warnings
from collections.abc import Callable, Mapping, Sequence
from concurrent.futures import as_completed, FIRST_COMPLETED, ThreadPoolExecutor, wait
from multiprocessing import Queue
from numbers import Integral
from typing import Literal

import torch
from tensordict import (
    lazy_stack,
    LazyStackedTensorDict,
    maybe_dense_stack,
    TensorDict,
    TensorDictBase,
)
from tensordict.tensorclass import NonTensorData, NonTensorStack
from tensordict.utils import _zip_strict, expand_as_right, NestedKey

from torchrl._utils import logger as torchrl_logger, timeit
from torchrl.data.tensor_specs import NonTensor
from torchrl.envs._async_exchange import _receive_batch, _SharedSlotExchange
from torchrl.envs.common import _EnvPostInit, EnvBase


class _ValidatedCPUAffinity(tuple):
    pass


def _validate_cpu_affinity(
    cpu_affinity: Sequence[int], *, option_name: str
) -> tuple[int, ...]:
    if isinstance(cpu_affinity, _ValidatedCPUAffinity):
        return cpu_affinity
    if not hasattr(os, "sched_setaffinity") or not hasattr(os, "sched_getaffinity"):
        raise NotImplementedError(
            f"{option_name} requires os.sched_getaffinity and "
            "os.sched_setaffinity, which are only available on Linux."
        )
    try:
        cpu_affinity = tuple(cpu_affinity)
    except TypeError as err:
        raise TypeError(f"{option_name} must be a sequence of CPU indices.") from err
    if not cpu_affinity:
        raise ValueError(f"{option_name} must contain at least one CPU index.")
    if any(
        isinstance(cpu, bool) or not isinstance(cpu, Integral) for cpu in cpu_affinity
    ):
        raise TypeError(f"{option_name} must contain only integer CPU indices.")
    if any(cpu < 0 for cpu in cpu_affinity):
        raise ValueError(f"{option_name} CPU indices must be non-negative.")
    cpu_affinity = _ValidatedCPUAffinity(int(cpu) for cpu in cpu_affinity)
    available_cpus = os.sched_getaffinity(0)
    unavailable_cpus = sorted(set(cpu_affinity).difference(available_cpus))
    if unavailable_cpus:
        raise ValueError(
            f"{option_name} contains CPUs unavailable to this process: "
            f"{unavailable_cpus}."
        )
    return cpu_affinity


class _AsyncEnvMeta(_EnvPostInit):
    """A metaclass for asynchronous environment pools that determines the backend implementation to use based on the provided arguments.

    This class is responsible for instantiating the appropriate subclass of `AsyncEnvPool` based on the specified
    backend, such as threading or multiprocessing.
    """

    def __call__(cls, *args, **kwargs):
        backend = kwargs.get("backend", "threading")
        if cls is AsyncEnvPool:
            if backend == "threading":
                instance: ThreadingAsyncEnvPool = ThreadingAsyncEnvPool(*args, **kwargs)
            elif backend == "multiprocessing":
                instance: ProcessorAsyncEnvPool = ProcessorAsyncEnvPool(*args, **kwargs)
            elif backend == "asyncio":
                raise NotImplementedError
                # instance: AsyncioAsyncEnvPool = AsyncioAsyncEnvPool(*args, **kwargs)
            else:
                raise NotImplementedError
            return instance
        else:
            return super().__call__(*args, **kwargs)


[docs] class AsyncEnvPool(EnvBase, metaclass=_AsyncEnvMeta): """A base class for asynchronous environment pools, providing a common interface for managing multiple environments concurrently. This class supports different backends for parallel execution, such as threading and multiprocessing, and provides methods for asynchronous stepping and resetting of environments. .. note:: This class and its subclasses should work when nested in with :class:`~torchrl.envs.TransformedEnv` and batched environments, but users won't currently be able to use the async features of the base environment when it's nested in these classes. One should prefer nested transformed envs within an `AsyncEnvPool` instead. If this is not possible, please raise an issue. Args: env_makers (Callable[[], EnvBase] | EnvBase | list[EnvBase] | list[Callable[[], EnvBase]]): A callable or list of callables that create environment instances, or environment instances themselves. backend (Literal["threading", "multiprocessing", "asyncio"], optional): The backend to use for parallel execution. Defaults to `"threading"`. stack (Literal["dense", "maybe_dense", "lazy"], optional): The method to use for stacking environment outputs. Defaults to `"dense"`. exchange (Literal["queue", "shm", "auto"], optional): Data exchange used by the multiprocessing backend. ``"queue"`` supports dynamic data and copies received tensors out of multiprocessing shared memory so retaining results does not retain one mapping per tensor. ``"shm"`` stores fixed-shape tensor data in shared slots and sends only readiness descriptors through queues; it requires identical, fixed-shape, CPU, tensor-only schemas across workers. ``"auto"`` selects ``"shm"`` when the env schema supports it and falls back to ``"queue"`` otherwise (the resolution is reported by :attr:`resolved_exchange` and logged on fallback). Defaults to ``"queue"``. .. warning:: The default will change from ``"queue"`` to ``"auto"`` in v0.15 for the multiprocessing backend. A ``FutureWarning`` is emitted when the default is relied upon. worker_affinity (Sequence[Sequence[int]] or Callable[[int], Sequence[int]], optional): Optional Linux CPU placement for multiprocessing workers. This is useful when environment workers share a constrained CPU set with CPU-heavy simulators or other services: without affinity, the scheduler may place them on the same CPUs and increase environment step-time jitter. Most users should leave this as ``None``. TorchRL can discover which CPUs the current process may use, but it cannot infer which CPUs the application has reserved for other work or how many threads an environment and its subprocesses need. It therefore does not choose a partition automatically. Provide one mask per worker process, or a callable mapping a worker index to its mask. The mask is applied before environment threads and factories start and is inherited by subprocesses they start. Defaults to ``None``. See :ref:`async_env_pool_cpu_affinity` for an example and deployment guidance. envs_per_worker (int, optional): Number of environments hosted by each multiprocessing worker process. Environments within a worker are executed concurrently in threads. This option is only supported by the multiprocessing backend. Defaults to ``1``. create_env_kwargs (dict, optional): Keyword arguments to pass to the environment maker. Defaults to `{}`. Attributes: min_get (int): Minimum number of environments to process in a batch. env_makers (list): List of environment makers or environments. num_envs (int): Number of environments in the pool. envs_per_worker (int): Number of environments hosted by each multiprocessing worker. backend (str): Backend used for parallel execution. stack (str): Method used for stacking environment outputs. Examples: >>> from functools import partial >>> from torchrl.envs import AsyncEnvPool, GymEnv >>> import torch >>> # Choose backend >>> backend = "threading" >>> env = AsyncEnvPool([partial(GymEnv, "Pendulum-v1"), partial(GymEnv, "CartPole-v1")], stack="lazy", backend=backend) >>> assert env.batch_size == (2,) >>> # Execute a sync reset >>> reset = env.reset() >>> print(reset) LazyStackedTensorDict( fields={ done: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), env_index: NonTensorStack( [0, 1], batch_size=torch.Size([2]), device=None), observation: Tensor(shape=torch.Size([2, 3]), device=cpu, dtype=torch.float32, is_shared=False), terminated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), truncated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False)}, exclusive_fields={ }, batch_size=torch.Size([2]), device=None, is_shared=False, stack_dim=0) >>> # Execute a sync step >>> s = env.rand_step(reset) >>> print(s) LazyStackedTensorDict( fields={ action: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.float32, is_shared=False), done: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), env_index: NonTensorStack( [0, 1], batch_size=torch.Size([2]), device=None), next: LazyStackedTensorDict( fields={ done: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), observation: Tensor(shape=torch.Size([2, 3]), device=cpu, dtype=torch.float32, is_shared=False), reward: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.float32, is_shared=False), terminated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), truncated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False)}, exclusive_fields={ }, batch_size=torch.Size([2]), device=None, is_shared=False, stack_dim=0), observation: Tensor(shape=torch.Size([2, 3]), device=cpu, dtype=torch.float32, is_shared=False), terminated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False), truncated: Tensor(shape=torch.Size([2, 1]), device=cpu, dtype=torch.bool, is_shared=False)}, exclusive_fields={ }, batch_size=torch.Size([2]), device=None, is_shared=False, stack_dim=0) >>> s = env.step_mdp(s) >>> # Execute an asynchronous step in env 0 >>> s0 = s[0] >>> s0["action"] = torch.randn(1).clamp(-1, 1) >>> # We must tell the env which data this is from >>> s0["env_index"] = 0 >>> env.async_step_send(s0) >>> # Receive data >>> s0_result = env.async_step_recv() >>> print('result', s0_result) result LazyStackedTensorDict( fields={ action: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.float32, is_shared=False), done: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False), env_index: NonTensorStack( [0], batch_size=torch.Size([1]), device=None), next: LazyStackedTensorDict( fields={ done: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False), observation: Tensor(shape=torch.Size([1, 3]), device=cpu, dtype=torch.float32, is_shared=False), reward: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.float32, is_shared=False), terminated: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False), truncated: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False)}, exclusive_fields={ }, batch_size=torch.Size([1]), device=None, is_shared=False, stack_dim=0), observation: Tensor(shape=torch.Size([1, 3]), device=cpu, dtype=torch.float32, is_shared=False), terminated: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False), truncated: Tensor(shape=torch.Size([1, 1]), device=cpu, dtype=torch.bool, is_shared=False)}, exclusive_fields={ }, batch_size=torch.Size([1]), device=None, is_shared=False, stack_dim=0) >>> # Close env >>> env.close() """ _env_idx_key = "env_index" def __init__( self, env_makers: Callable[[], EnvBase] | EnvBase | list[EnvBase] | list[Callable[[], EnvBase]], *, backend: Literal["threading", "multiprocessing", "asyncio"] = "threading", stack: Literal["dense", "maybe_dense", "lazy"] = "dense", exchange: Literal["queue", "shm", "auto"] | None = None, worker_affinity: Sequence[Sequence[int]] | Callable[[int], Sequence[int]] | None = None, envs_per_worker: int = 1, create_env_kwargs: dict | list[dict] | None = None, ) -> None: if not isinstance(env_makers, Sequence): env_makers = [env_makers] self.min_get = 1 self.env_makers = env_makers self.num_envs = len(env_makers) self.backend = backend if ( isinstance(envs_per_worker, bool) or not isinstance(envs_per_worker, int) or envs_per_worker < 1 ): raise ValueError( "envs_per_worker must be a positive integer, got " f"{envs_per_worker!r}." ) if backend != "multiprocessing" and envs_per_worker != 1: raise ValueError( "envs_per_worker is only supported with backend='multiprocessing'." ) self.envs_per_worker = envs_per_worker self.worker_affinity = worker_affinity self._worker_affinity = None if worker_affinity is not None: if backend != "multiprocessing": raise ValueError( "worker_affinity is only supported with " "backend='multiprocessing'." ) num_workers = (self.num_envs + envs_per_worker - 1) // envs_per_worker if callable(worker_affinity): affinity_masks = [ worker_affinity(worker_index) for worker_index in range(num_workers) ] else: affinity_masks = list(worker_affinity) if len(affinity_masks) != num_workers: raise ValueError( "worker_affinity must provide one CPU mask per " f"worker process, got {len(affinity_masks)} masks for " f"{num_workers} worker processes." ) self._worker_affinity = [ _validate_cpu_affinity( affinity, option_name=f"worker_affinity[{worker_index}]", ) for worker_index, affinity in enumerate(affinity_masks) ] if exchange is None: if backend == "multiprocessing": warnings.warn( "The default exchange of AsyncEnvPool with the " "multiprocessing backend will change from 'queue' to " "'auto' in v0.15. Pass exchange='queue' to keep the " "current behavior, or exchange='auto' to adopt the " "future default now.", FutureWarning, ) exchange = "queue" if exchange not in ("queue", "shm", "auto"): raise ValueError( f"exchange must be 'queue', 'shm' or 'auto', got {exchange!r}." ) if backend != "multiprocessing" and exchange == "shm": raise ValueError( "exchange='shm' is only supported with backend='multiprocessing'." ) self.exchange = exchange if create_env_kwargs is None: create_env_kwargs = {} if isinstance(create_env_kwargs, Mapping): create_env_kwargs = [create_env_kwargs] * self.num_envs if len(create_env_kwargs) != self.num_envs: raise ValueError( f"create_env_kwargs must be a dict or a list of dicts with length {self.num_envs}" ) self.create_env_kwargs = create_env_kwargs self.stack = stack if stack == "dense": self._stack_func = torch.stack elif stack == "maybe_dense": self._stack_func = maybe_dense_stack elif stack == "lazy": self._stack_func = lazy_stack else: raise NotImplementedError output_spec, input_spec = self._setup() input_spec["full_state_spec"].set( self._env_idx_key, NonTensor(example_data=0, shape=input_spec.shape) ) self.__dict__["_output_spec"] = output_spec self.__dict__["_input_spec"] = input_spec # Use spec shape as batch_size since it correctly includes both pool dimension # and child env batch dimensions (e.g., (4, 1) for 4 envs with batch_size=(1,)) super().__init__(batch_size=input_spec.shape) self._busy = set() self._lock = threading.Lock() @property def env_batch_sizes(self) -> list[torch.Size]: """Returns the batch-sizes of every env.""" raise NotImplementedError @abc.abstractmethod def _get_child_specs(self) -> list: """Returns the list of child env specs for stacking. For ThreadingAsyncEnvPool, returns [env.full_*_spec for env in self.envs]. For ProcessorAsyncEnvPool, returns cached specs from setup. """ raise NotImplementedError # Override spec properties to properly stack child env specs. # This bypasses the problematic StackedComposite.get() behavior that loses # nested keys like full_action_spec when cloning stacked specs. @property def full_action_spec(self): child_specs = self._get_child_specs() return torch.stack( [s["input_spec"]["full_action_spec"] for s in child_specs], dim=0 ) @property def full_observation_spec(self): child_specs = self._get_child_specs() return torch.stack( [s["output_spec"]["full_observation_spec"] for s in child_specs], dim=0 ) @property def full_reward_spec(self): child_specs = self._get_child_specs() return torch.stack( [s["output_spec"]["full_reward_spec"] for s in child_specs], dim=0 ) @property def full_done_spec(self): child_specs = self._get_child_specs() return torch.stack( [s["output_spec"]["full_done_spec"] for s in child_specs], dim=0 ) @property def full_state_spec(self): child_specs = self._get_child_specs() specs = torch.stack( [s["input_spec"]["full_state_spec"] for s in child_specs], dim=0 ) # Add env_index key for async tracking specs.set(self._env_idx_key, NonTensor(example_data=0, shape=specs.shape)) return specs # TODO: _make_single_env_spec (used by *_unbatched properties) takes spec[0], # which assumes all child envs have identical specs. Should add validation # that child specs match, and error if they differ. def _reset( self, tensordict: TensorDictBase | None = None, **kwargs, ) -> TensorDictBase: if self._current_step > 0: raise RuntimeError("Some envs are still processing a step.") if tensordict is None: if self._stack_func in ("lazy_stack", "maybe_dense"): tensordict = LazyStackedTensorDict( *[ TensorDict(batch_size=self.env_batch_sizes[i]) for i in range(self.num_envs) ] ) else: tensordict = TensorDict( batch_size=(self.num_envs,) + self.env_batch_sizes[0] ) env_idx_nt = NonTensorStack(*range(tensordict.shape[0])) while env_idx_nt.batch_dims < tensordict.batch_dims: env_idx_nt = expand_as_right(env_idx_nt, tensordict) tensordict[self._env_idx_key] = env_idx_nt self._async_private_reset_send(tensordict) tensordict = self._async_private_reset_recv(min_get=self.num_envs) return tensordict def _step(self, tensordict: TensorDictBase) -> TensorDictBase: if self._current_step > 0: raise RuntimeError("Some envs are still processing a step.") tensordict.set(self._env_idx_key, torch.arange(tensordict.shape[0])) self._async_private_step_send(tensordict) tensordict = self._async_private_step_recv(min_get=self.num_envs) # Using pop instead of del to account for tensorclasses tensordict.pop(self._env_idx_key) return tensordict def step_and_maybe_reset( self, tensordict: TensorDictBase ) -> tuple[TensorDictBase, TensorDictBase]: if self._current_step_reset > 0: raise RuntimeError("Some envs are still processing a step.") tensordict.set(self._env_idx_key, torch.arange(tensordict.shape[0])) self.async_step_and_maybe_reset_send(tensordict) tensordict, tensordict_ = self.async_step_and_maybe_reset_recv( min_get=self.num_envs ) return tensordict, tensordict_
[docs] def step(self, tensordict: TensorDictBase) -> TensorDictBase: if self._current_step > 0: raise RuntimeError("Some envs are still processing a step.") tensordict.set(self._env_idx_key, torch.arange(tensordict.shape[0])) self.async_step_send(tensordict) tensordict = self.async_step_recv(min_get=self.num_envs) return tensordict
[docs] def reset( self, tensordict: TensorDictBase | None = None, **kwargs, ) -> TensorDictBase: if self._current_step > 0: raise RuntimeError("Some envs are still processing a step.") if tensordict is None: if self._stack_func in ("lazy_stack", "maybe_dense"): tensordict = LazyStackedTensorDict( *[ TensorDict(batch_size=self.env_batch_sizes[i]) for i in range(self.num_envs) ] ) else: tensordict = TensorDict( batch_size=(self.num_envs,) + self.env_batch_sizes[0] ) indices = NonTensorStack(*range(tensordict.shape[0])) if indices.shape != tensordict.shape: indices = expand_as_right(indices, tensordict) tensordict[self._env_idx_key] = indices self.async_reset_send(tensordict) tensordict = self.async_reset_recv(min_get=self.num_envs) return tensordict
def _sort_results(self, results, *other_results): # Extract env indices from results. When child envs have a batch dimension # (e.g., batch_size=(1,)), r[self._env_idx_key] may be a 1D sequence # instead of a scalar, so we need to handle both cases. idx = [] for r in results: env_idx = r[self._env_idx_key] # Handle sequence types (NonTensorStack, etc.) by taking first element while hasattr(env_idx, "__len__") and not isinstance(env_idx, (str, bytes)): if len(env_idx) == 1: env_idx = env_idx[0] else: break idx.append(int(env_idx)) argsort = torch.argsort(torch.tensor(idx)).tolist() results = [results[i] for i in argsort] if other_results: other_results = [ [other_results[i] for i in argsort] for other_results in other_results ] return results, *other_results, idx return results, idx def _set_seed(self, seed: int | None) -> None: raise NotImplementedError @abc.abstractmethod def _setup(self) -> None: raise NotImplementedError def _maybe_make_tensordict(self, tensordict, env_index, make_if_none): if env_index is None: env_idx = tensordict.view(-1)[self._env_idx_key] if isinstance(env_idx, torch.Tensor): env_idx = env_idx.tolist() if isinstance(env_idx, int): # If we squeezed a td with shape (1,) and got a NonTensorStack -> NonTensorData, then # unsqueezed the NonTensorData, we'd still have a NonTensorData with shape (1,) # This will give us an integer now, but we don't want to unsqueeze the full td because then # we'd have a td with shape (1, 1) if tensordict.shape != (1, *self.env_batch_sizes[env_idx]): tensordict = tensordict.unsqueeze(0) env_idx = [env_idx] elif isinstance(env_index, int): if make_if_none and tensordict is None: tensordict = TensorDict( batch_size=self.env_batch_sizes[env_index], device=self.device ) # Always add a leading dim so that unbind(0) works in send methods if self.stack in ("lazy_stack", "maybe_dense"): tensordict = tensordict.unsqueeze(0) else: tensordict = lazy_stack([tensordict]) tensordict[self._env_idx_key] = NonTensorStack(env_index) env_idx = [env_index] else: if make_if_none and tensordict is None: if self.stack in ("lazy_stack", "maybe_dense"): tensordict = lazy_stack( [TensorDict(device=self.device) for _ in env_index] ) else: tensordict = TensorDict( batch_size=(len(env_index),), device=self.device ) tensordict[self._env_idx_key] = NonTensorStack(*env_index) env_idx = env_index return tensordict, env_idx @abc.abstractmethod def async_step_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: raise NotImplementedError
[docs] @abc.abstractmethod def async_step_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: """Collects step results from the pool. Args: min_get (int, optional): minimum number of results to collect before returning. Defaults to ``self.min_get``. env_index (int, optional): if provided, waits for the result of this specific env instead of forming a batch; ``min_get``, ``max_get`` and ``timeout`` are ignored. Keyword Args: max_get (int, optional): maximum number of results to return. ``None`` (default) drains everything available once ``min_get`` is satisfied. timeout (float, optional): deadline in seconds for the entire call, measured from call entry. If fewer than ``min_get`` results are available when the deadline expires, a :class:`TimeoutError` is raised; results collected so far are put back and remain available to the next call. Once ``min_get`` is satisfied, the call returns at the deadline with whatever is ready (up to ``max_get``). ``timeout=0`` polls without blocking. ``None`` (default) waits indefinitely. Returns: TensorDictBase: the stacked results, carrying ``"env_index"``. """ raise NotImplementedError
@abc.abstractmethod def async_step_and_maybe_reset_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: raise NotImplementedError
[docs] @abc.abstractmethod def async_step_and_maybe_reset_recv( self, min_get: int | None = None, env_index: int | list[int] | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> tuple[TensorDictBase, TensorDictBase]: """Collects step-and-maybe-reset results from the pool. ``min_get``, ``env_index``, ``max_get`` and ``timeout`` behave as in :meth:`async_step_recv`; in particular ``timeout`` bounds the entire call and raises :class:`TimeoutError` (without losing results) if fewer than ``min_get`` results arrive in time. Returns: tuple of (TensorDictBase, TensorDictBase): the stacked step results and the stacked roots of the next steps (reset where needed), both carrying ``"env_index"``. """ raise NotImplementedError
@abc.abstractmethod def async_reset_send( self, tensordict: TensorDictBase | None = None, env_index: int | list[int] | None = None, ) -> None: raise NotImplementedError
[docs] @abc.abstractmethod def async_reset_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: """Collects reset results from the pool. ``min_get``, ``env_index``, ``max_get`` and ``timeout`` behave as in :meth:`async_step_recv`; in particular ``timeout`` bounds the entire call and raises :class:`TimeoutError` (without losing results) if fewer than ``min_get`` results arrive in time. Returns: TensorDictBase: the stacked reset results, carrying ``"env_index"``. """ raise NotImplementedError
@property def resolved_exchange(self) -> Literal["queue", "shm"]: """The data exchange actually in use, after resolving ``exchange="auto"``. ``"shm"`` when the shared-memory slot exchange is active, ``"queue"`` otherwise (including the threading backend, which has no shared-memory exchange). """ return "shm" if getattr(self, "_slot_exchange", None) is not None else "queue" @property def exchange_keys(self) -> tuple[NestedKey, ...]: """The tensor keys accepted by the active shared-memory exchange. Returns an empty tuple when the resolved exchange is ``"queue"``. """ exchange = getattr(self, "_slot_exchange", None) if exchange is None: return () return tuple(exchange._input_keys)
[docs] def stats(self, *, reset: bool = False) -> dict[str, float | int]: """Return shared-memory exchange statistics. Args: reset: Whether to clear the counters after taking the snapshot. Returns: Batch counts, fill rates, and exchange latency statistics. Queue and threading exchanges return an empty dictionary. """ exchange = getattr(self, "_slot_exchange", None) if exchange is None: return {} return exchange.stats(reset=reset)
def __del__(self): self._maybe_shutdown() def _maybe_shutdown(self): try: self.shutdown() except Exception: pass @abc.abstractmethod def shutdown(self): raise NotImplementedError def close(self, *, raise_if_closed: bool = True): if raise_if_closed: self.shutdown() else: self._maybe_shutdown()
[docs] class ProcessorAsyncEnvPool(AsyncEnvPool): """An implementation of `AsyncEnvPool` using multiprocessing for parallel execution of environments. This class manages a pool of environments across worker processes and provides methods for asynchronous stepping and resetting using inter-process communication. A worker can host multiple environments in dedicated threads. Supports per-env ``recv`` via ``env_index`` for thread-safe concurrent access from multiple collector threads. .. note:: This class and its subclasses should work when nested in with :class:`~torchrl.envs.TransformedEnv` and batched environments, but users won't currently be able to use the async features of the base environment when it's nested in these classes. One should prefer nested transformed envs within an `AsyncEnvPool` instead. If this is not possible, please raise an issue. Methods: _setup(): Initializes multiprocessing queues and worker processes. async_step_send(tensordict): Sends a step command to the environments. async_step_recv(min_get): Receives the results of the step command. async_reset_send(tensordict): Sends a reset command to the environments. async_reset_recv(min_get): Receives the results of the reset command. shutdown(): Shuts down all environment processes. """
[docs] def _setup(self) -> None: self._error_queue = Queue() self._worker_error = None self._worker_liveness_timer = timeit("async_env_worker_liveness").start() self.step_queue = Queue(maxsize=self.num_envs) self.reset_queue = Queue(maxsize=self.num_envs) self.step_reset_queue = Queue(maxsize=self.num_envs) # Per-env result queues for thread-safe per-env recv self._per_env_step_queues = [Queue(maxsize=1) for _ in range(self.num_envs)] self._per_env_reset_queues = [Queue(maxsize=1) for _ in range(self.num_envs)] self._per_env_step_reset_queues = [ Queue(maxsize=1) for _ in range(self.num_envs) ] self._worker_env_indices = [ tuple(range(start, min(start + self.envs_per_worker, self.num_envs))) for start in range(0, self.num_envs, self.envs_per_worker) ] self.num_workers = len(self._worker_env_indices) self._env_to_worker = [ worker_index for worker_index, env_indices in enumerate(self._worker_env_indices) for _ in env_indices ] self.input_queue = [Queue(maxsize=1) for _ in range(self.num_workers)] self.output_queue = [ Queue(maxsize=len(env_indices)) for env_indices in self._worker_env_indices ] self._current_reset = 0 self._current_step = 0 self._current_step_reset = 0 self._slot_exchange = None self.threads = [] try: for worker_index, env_indices in enumerate(self._worker_env_indices): if len(env_indices) == 1: env_index = env_indices[0] target = self._env_exec worker_kwargs = { "i": env_index, "env_or_factory": self.env_makers[env_index], "create_env_kwargs": self.create_env_kwargs[env_index], "input_queue": self.input_queue[worker_index], "output_queue": self.output_queue[worker_index], "step_reset_queue": self.step_reset_queue, "step_queue": self.step_queue, "reset_queue": self.reset_queue, "per_env_step_queue": self._per_env_step_queues[env_index], "per_env_reset_queue": self._per_env_reset_queues[env_index], "per_env_step_reset_queue": self._per_env_step_reset_queues[ env_index ], "grouped_input": True, } else: target = self._worker_exec worker_kwargs = { "env_indices": env_indices, "env_makers": [self.env_makers[i] for i in env_indices], "create_env_kwargs": [ self.create_env_kwargs[i] for i in env_indices ], "input_queue": self.input_queue[worker_index], "output_queue": self.output_queue[worker_index], "step_reset_queue": self.step_reset_queue, "step_queue": self.step_queue, "reset_queue": self.reset_queue, "per_env_step_queues": [ self._per_env_step_queues[i] for i in env_indices ], "per_env_reset_queues": [ self._per_env_reset_queues[i] for i in env_indices ], "per_env_step_reset_queues": [ self._per_env_step_reset_queues[i] for i in env_indices ], } worker_kwargs["error_queue"] = self._error_queue worker_kwargs["cpu_affinity"] = ( None if self._worker_affinity is None else self._worker_affinity[worker_index] ) thread = multiprocessing.Process( target=target, kwargs=worker_kwargs, ) self.threads.append(thread) thread.start() if self._worker_affinity is not None: try: for i in range(self.num_workers): status, payload = _receive_batch( self.output_queue[i], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0] if status != "affinity_ready": raise RuntimeError( f"AsyncEnvPool worker {i} failed to set its CPU " f"affinity: {payload}" ) except Exception: for thread in self.threads: if thread.is_alive(): thread.terminate() for thread in self.threads: thread.join() raise # Get specs from each worker and cache them for _get_child_specs() for worker_index, env_indices in enumerate(self._worker_env_indices): self.input_queue[worker_index].put( ("get_specs", [(env_index, None) for env_index in env_indices]) ) self._child_specs = [None] * self.num_envs for worker_index, env_indices in enumerate(self._worker_env_indices): for _ in env_indices: env_index, spec = _receive_batch( self.output_queue[worker_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0] self._child_specs[env_index] = spec # Batch sizes are already available from the worker specs. Caching them # here avoids a later round trip over the input queues used for steps, # which may block behind in-flight env work. self._env_batch_sizes = [ torch.Size(spec.shape) for spec in self._child_specs ] specs = torch.stack(list(self._child_specs)) output_spec = specs["output_spec"] input_spec = specs["input_spec"] if self.exchange in ("shm", "auto"): for worker_index, env_indices in enumerate(self._worker_env_indices): self.input_queue[worker_index].put( ( "get_fake_tensordict", [(env_index, None) for env_index in env_indices], ) ) fake_tensordicts = [None] * self.num_envs for worker_index, env_indices in enumerate(self._worker_env_indices): for _ in env_indices: env_index, fake = _receive_batch( self.output_queue[worker_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0] fake_tensordicts[env_index] = fake try: self._slot_exchange = _SharedSlotExchange(fake_tensordicts) except (TypeError, ValueError, RuntimeError) as err: if self.exchange == "shm": raise torchrl_logger.info( "AsyncEnvPool(exchange='auto'): the env schema does not " f"support the shared-memory exchange, falling back to " f"the queue exchange. Reason: {err}" ) if self._slot_exchange is not None: for worker_index, env_indices in enumerate( self._worker_env_indices ): self.input_queue[worker_index].put( ( "init_shm", self._slot_exchange.worker_slots( env_indices[0], env_indices[-1] + 1 ), ) ) for worker_index, env_indices in enumerate( self._worker_env_indices ): for _ in env_indices: _receive_batch( self.output_queue[worker_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0] return output_spec, input_spec except Exception: for process in self.threads: if process.is_alive(): process.terminate() for process in self.threads: process.join() raise
def _get_child_specs(self) -> list: """Returns the cached specs from each child environment process.""" return self._child_specs @property def env_batch_sizes(self) -> list[torch.Size]: return self._env_batch_sizes def _send_worker_batches( self, msg: str, env_idx: list[int], local_tds: tuple[TensorDictBase, ...], *, per_env: bool, record_action: bool, ) -> None: requests = [[] for _ in range(self.num_workers)] for env_index, local_td in _zip_strict(env_idx, local_tds): data = self._prepare_worker_data( env_index, local_td, record_action=record_action ) requests[self._env_to_worker[env_index]].append((env_index, data)) for worker_index, worker_requests in enumerate(requests): if worker_requests: self.input_queue[worker_index].put((msg, worker_requests, per_env)) def _prepare_worker_data( self, env_index: int, tensordict: TensorDictBase, *, record_action: bool, ): if self._slot_exchange is None: return tensordict return self._slot_exchange.write_input( env_index, tensordict, record_action=record_action ) def _check_worker_errors(self) -> None: if self._worker_error is None: try: env_index, error = self._error_queue.get_nowait() except queue.Empty: if self._worker_liveness_timer.elapsed() >= 0.1: self._worker_liveness_timer.start() failed = [p for p in self.threads if p.exitcode is not None] if failed: self._worker_error = ( "AsyncEnvPool worker process exited unexpectedly." ) else: self._worker_error = ( f"AsyncEnvPool environment {env_index} failed:\n{error}" ) if self._worker_error is not None: raise RuntimeError(self._worker_error) def _receive_items( self, result_queue, min_get: int, max_get: int | None, timeout: float | None, *, track_action: bool, ): if self._slot_exchange is None: return _receive_batch( result_queue, min_get, max_get, timeout, check_worker_errors=self._check_worker_errors, ) return self._slot_exchange.receive( result_queue, min_get, max_get, timeout, track_action=track_action, check_worker_errors=self._check_worker_errors, ) def _stack_queue_results(self, results) -> TensorDictBase: result = self._stack_func(results) if isinstance(result, LazyStackedTensorDict): # A lazy stack retains the individual TensorDicts reconstructed by # multiprocessing.Queue, and therefore one shared-memory mapping # per tensor. Clone its constituents into process-private storage; # dense stacks already own their newly allocated tensor storage. result = result.clone() return result
[docs] def async_step_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) self._send_worker_batches( "step", env_idx, tensordict.unbind(0), per_env=_per_env, record_action=True, ) if not _per_env: self._current_step = self._current_step + len(env_idx)
[docs] def async_step_recv( self, min_get: int = 1, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: if env_index is not None: if self._slot_exchange is None: return _receive_batch( self._per_env_step_queues[env_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0].clone() descriptor = self._slot_exchange.receive_one( self._per_env_step_queues[env_index], track_action=True, check_worker_errors=self._check_worker_errors, ) return self._slot_exchange.read_one(descriptor) if min_get is None: min_get = self.min_get if min_get > self._current_step: raise RuntimeError( f"Cannot await {min_get} step when only {self._current_step} are being stepped." ) r = self._receive_items( self.step_queue, min_get, max_get, timeout, track_action=True, ) self._current_step = self._current_step - len(r) if self._slot_exchange is not None: idx = [item[0] for item in r] result = self._slot_exchange.read(r, self._stack_func) self._busy.difference_update(idx) return result r, idx = self._sort_results(r) self._busy.difference_update(idx) return self._stack_queue_results(r)
def _async_private_step_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) self._send_worker_batches( "_step", env_idx, tensordict.unbind(0), per_env=False, record_action=True, ) self._current_step = self._current_step + len(env_idx) _async_private_step_recv = async_step_recv def async_step_and_maybe_reset_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) if not _per_env: self._current_step_reset = self._current_step_reset + len(env_idx) self._send_worker_batches( "step_and_maybe_reset", env_idx, tensordict.unbind(0), per_env=_per_env, record_action=True, )
[docs] def async_step_and_maybe_reset_recv( self, min_get: int = 1, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> tuple[TensorDictBase, TensorDictBase]: if env_index is not None: if self._slot_exchange is None: result, next_result = _receive_batch( self._per_env_step_reset_queues[env_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0] return result.clone(), next_result.clone() descriptor = self._slot_exchange.receive_one( self._per_env_step_reset_queues[env_index], track_action=True, check_worker_errors=self._check_worker_errors, ) return self._slot_exchange.read_pair_one(descriptor) if min_get is None: min_get = self.min_get if min_get > self._current_step_reset: raise RuntimeError( f"Cannot await {min_get} step_and_maybe_reset when only {self._current_step_reset} are being stepped." ) r = self._receive_items( self.step_reset_queue, min_get, max_get, timeout, track_action=True, ) self._current_step_reset = self._current_step_reset - len(r) if self._slot_exchange is not None: idx = [item[0] for item in r] result = self._slot_exchange.read_pair(r, self._stack_func) self._busy.difference_update(idx) return result r, r_ = zip(*r) r, r_, idx = self._sort_results(r, r_) self._busy.difference_update(idx) return self._stack_queue_results(r), self._stack_queue_results(r_)
[docs] def async_reset_send( self, tensordict: TensorDictBase | None = None, env_index: int | list[int] | None = None, ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, True) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) if not _per_env: self._current_reset = self._current_reset + len(env_idx) self._send_worker_batches( "reset", env_idx, tensordict.unbind(0), per_env=_per_env, record_action=False, )
[docs] def async_reset_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: if env_index is not None: if self._slot_exchange is None: return _receive_batch( self._per_env_reset_queues[env_index], 1, 1, None, check_worker_errors=self._check_worker_errors, )[0].clone() descriptor = self._slot_exchange.receive_one( self._per_env_reset_queues[env_index], track_action=False, check_worker_errors=self._check_worker_errors, ) return self._slot_exchange.read_one(descriptor) if min_get is None: min_get = self.min_get if min_get > self._current_reset: raise RuntimeError( f"Cannot await {min_get} reset when only {self._current_reset} are being reset." ) r = self._receive_items( self.reset_queue, min_get, max_get, timeout, track_action=False, ) self._current_reset = self._current_reset - len(r) if self._slot_exchange is not None: idx = [item[0] for item in r] result = self._slot_exchange.read(r, self._stack_func) self._busy.difference_update(idx) return result r, idx = self._sort_results(r) self._busy.difference_update(idx) return self._stack_queue_results(r)
def _async_private_reset_send( self, tensordict: TensorDictBase | None = None, env_index: int | list[int] | None = None, ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, True) if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) self._current_reset = self._current_reset + len(env_idx) self._send_worker_batches( "_reset", env_idx, tensordict.unbind(0), per_env=False, record_action=False, ) _async_private_reset_recv = async_reset_recv _SHUTDOWN_TIMEOUT = 60.0 def _drain_result_queues(self) -> None: for result_queue in ( self.step_queue, self.reset_queue, self.step_reset_queue, *self._per_env_step_queues, *self._per_env_reset_queues, *self._per_env_step_reset_queues, ): while True: try: result_queue.get_nowait() except queue.Empty: break except (EOFError, OSError): # The item has already been removed from the queue, but # rebuilding a discarded tensor can fail once its worker's # resource sharer has exited. Keep draining the remaining # items so other workers can finish flushing their queues. continue
[docs] def shutdown(self): deadline = timeit("async_env_shutdown_deadline").start() pending = set(range(self.num_workers)) while pending and deadline.elapsed() < self._SHUTDOWN_TIMEOUT: self._drain_result_queues() for worker_index in tuple(pending): if not self.threads[worker_index].is_alive(): pending.remove(worker_index) continue try: self.input_queue[worker_index].put(("shutdown", None), timeout=0.01) except queue.Full: continue pending.remove(worker_index) # A worker whose unread results still sit in a result queue cannot # exit: its process teardown joins the queue's feeder thread, which # blocks writing into the full pipe that nothing reads any more. # Draining the result queues while joining unblocks those feeders so # the workers exit through the normal teardown path. for thread in self.threads: while thread.is_alive() and deadline.elapsed() < self._SHUTDOWN_TIMEOUT: self._drain_result_queues() thread.join(timeout=0.1) stragglers = [thread for thread in self.threads if thread.is_alive()] if stragglers: torchrl_logger.warning( f"AsyncEnvPool.shutdown: terminating {len(stragglers)} worker " f"process(es) that did not exit within " f"{self._SHUTDOWN_TIMEOUT}s." ) for thread in stragglers: thread.terminate() for thread in stragglers: thread.join()
@classmethod def _worker_exec( cls, env_indices, env_makers, create_env_kwargs, input_queue, output_queue, step_queue, step_reset_queue, reset_queue, per_env_step_queues=None, per_env_reset_queues=None, per_env_step_reset_queues=None, cpu_affinity=None, error_queue=None, ): if cpu_affinity is not None: try: os.sched_setaffinity(0, cpu_affinity) except Exception as err: output_queue.put(("affinity_error", repr(err))) return output_queue.put(("affinity_ready", None)) local_input_queues = {env_index: queue.Queue(1) for env_index in env_indices} env_threads = [] for ( env_index, env_maker, kwargs, per_env_step_queue, per_env_reset_queue, per_env_step_reset_queue, ) in zip( env_indices, env_makers, create_env_kwargs, per_env_step_queues, per_env_reset_queues, per_env_step_reset_queues, ): env_thread = threading.Thread( target=cls._env_exec, daemon=True, kwargs={ "i": env_index, "error_queue": error_queue, "env_or_factory": env_maker, "create_env_kwargs": kwargs, "input_queue": local_input_queues[env_index], "output_queue": output_queue, "step_queue": step_queue, "step_reset_queue": step_reset_queue, "reset_queue": reset_queue, "per_env_step_queue": per_env_step_queue, "per_env_reset_queue": per_env_reset_queue, "per_env_step_reset_queue": per_env_step_reset_queue, }, ) env_threads.append(env_thread) env_thread.start() while True: msg_data = input_queue.get() if len(msg_data) == 3: msg, requests, per_env = msg_data else: msg, requests = msg_data per_env = False if msg == "shutdown": for env_thread, local_queue in zip( env_threads, local_input_queues.values() ): if env_thread.is_alive(): local_queue.put(("shutdown", None)) for env_thread in env_threads: env_thread.join() break if msg == "init_shm": input_slots, result_slots, next_slots, clock = requests requests = [ (env_index, (input_slot, result_slot, next_slot, clock)) for env_index, input_slot, result_slot, next_slot in zip( env_indices, input_slots.unbind(0), result_slots.unbind(0), next_slots.unbind(0), ) ] for env_index, data in requests: local_input_queues[env_index].put((msg, data, per_env)) @classmethod def _env_exec( cls, i, env_or_factory, create_env_kwargs, input_queue, output_queue, step_queue, step_reset_queue, reset_queue, per_env_step_queue=None, per_env_reset_queue=None, per_env_step_reset_queue=None, cpu_affinity=None, error_queue=None, grouped_input=False, ): try: if cpu_affinity is not None: try: os.sched_setaffinity(0, cpu_affinity) except Exception as err: output_queue.put(("affinity_error", repr(err))) return output_queue.put(("affinity_ready", None)) if not isinstance(env_or_factory, EnvBase): env = env_or_factory(**create_env_kwargs) else: env = env_or_factory shared_slots = None while True: msg_data = input_queue.get() if len(msg_data) == 3: msg, data, per_env = msg_data else: msg, data = msg_data per_env = False if grouped_input and msg != "shutdown": if msg == "init_shm": input_slots, result_slots, next_slots, clock = data data = ( input_slots[0], result_slots[0], next_slots[0], clock, ) else: _, data = data[0] if msg == "get_specs": output_queue.put((i, env.specs)) elif msg == "get_fake_tensordict": output_queue.put((i, env.fake_tensordict())) elif msg == "init_shm": shared_slots = data output_queue.put(True) elif msg == "reset": if shared_slots is not None: data = shared_slots[0].select(*data, strict=True) data = env.reset(data) target = per_env_reset_queue if per_env else reset_queue if shared_slots is None: data.set(cls._env_idx_key, NonTensorData(data=i)) target.put(data) else: keys, ready_s = _SharedSlotExchange.publish( shared_slots[1], data, shared_slots[3] ) target.put((i, keys, ready_s)) elif msg == "_reset": if shared_slots is not None: data = shared_slots[0].select(*data, strict=True) data = env._reset(data) if shared_slots is None: data.set(cls._env_idx_key, NonTensorData(data=i)) reset_queue.put(data) else: keys, ready_s = _SharedSlotExchange.publish( shared_slots[1], data, shared_slots[3] ) reset_queue.put((i, keys, ready_s)) elif msg == "step_and_maybe_reset": if shared_slots is not None: data = shared_slots[0].select(*data, strict=True) data, data_ = env.step_and_maybe_reset(data) target = per_env_step_reset_queue if per_env else step_reset_queue if shared_slots is None: data.set(cls._env_idx_key, NonTensorData(data=i)) data_.set(cls._env_idx_key, NonTensorData(data=i)) target.put((data, data_)) else: ( result_keys, next_keys, ready_s, ) = _SharedSlotExchange.publish_pair( shared_slots[1], shared_slots[2], data, data_, shared_slots[3], ) target.put((i, result_keys, next_keys, ready_s)) elif msg == "step": if shared_slots is not None: data = shared_slots[0].select(*data, strict=True) data = env.step(data) target = per_env_step_queue if per_env else step_queue if shared_slots is None: data.set(cls._env_idx_key, NonTensorData(data=i)) target.put(data) else: keys, ready_s = _SharedSlotExchange.publish( shared_slots[1], data, shared_slots[3] ) target.put((i, keys, ready_s)) elif msg == "_step": if shared_slots is not None: data = shared_slots[0].select(*data, strict=True) data = env._step(data) if shared_slots is None: data.set(cls._env_idx_key, NonTensorData(data=i)) step_queue.put(data) else: keys, ready_s = _SharedSlotExchange.publish( shared_slots[1], data, shared_slots[3] ) step_queue.put((i, keys, ready_s)) elif msg == "shutdown": env.close() break else: raise RuntimeError(f"Unknown msg {msg} for worker {i}") except Exception: if error_queue is None: raise error_queue.put((i, traceback.format_exc()))
[docs] class ThreadingAsyncEnvPool(AsyncEnvPool): """An implementation of `AsyncEnvPool` using threading for parallel execution of environments. This class manages a pool of environments, each running in its own thread, and provides methods for asynchronous stepping and resetting of environments using a thread pool executor. Supports per-env ``recv`` via ``env_index`` for thread-safe concurrent access from multiple collector threads. .. note:: This class and its subclasses should work when nested in with :class:`~torchrl.envs.TransformedEnv` and batched environments, but users won't currently be able to use the async features of the base environment when it's nested in these classes. One should prefer nested transformed envs within an `AsyncEnvPool` instead. If this is not possible, please raise an issue. Methods: _setup(): Initializes the thread pool and environment instances. async_step_send(tensordict): Sends a step command to the environments. async_step_recv(min_get): Receives the results of the step command. async_reset_send(tensordict): Sends a reset command to the environments. async_reset_recv(min_get): Receives the results of the reset command. shutdown(): Shuts down the thread pool. """
[docs] def _setup(self) -> None: self._pool = ThreadPoolExecutor(max_workers=self.num_envs) self.envs = [ env_factory(**create_env_kwargs) if not isinstance(env_factory, EnvBase) else env_factory for env_factory, create_env_kwargs in zip( self.env_makers, self.create_env_kwargs ) ] self._reset_futures = [] self._private_reset_futures = [] self._step_futures = [] self._private_step_futures = [] self._step_and_maybe_reset_futures = [] # Per-env future dicts for thread-safe per-env recv self._per_env_step_futures: dict[int, object] = {} self._per_env_step_reset_futures: dict[int, object] = {} self._per_env_reset_futures: dict[int, object] = {} self._current_step = 0 self._current_step_reset = 0 self._current_reset = 0 # get specs specs = torch.stack([env.specs for env in self.envs]) return specs["output_spec"].clone(), specs["input_spec"].clone()
@property def env_batch_sizes(self) -> list[torch.Size]: return [env.batch_size for env in self.envs] def _get_child_specs(self) -> list: """Returns the specs from each child environment.""" return [env.specs for env in self.envs] @classmethod def _get_specs(cls, env: EnvBase): return env.specs @classmethod def _step_func(cls, env_td: tuple[EnvBase, TensorDictBase, int]): env, td, idx = env_td return env.step(td).set(cls._env_idx_key, NonTensorData(data=idx)) @classmethod def _private_step_func(cls, env_td: tuple[EnvBase, TensorDictBase, int]): env, td, idx = env_td return env._step(td).set(cls._env_idx_key, NonTensorData(data=idx)) @classmethod def _reset_func(cls, env_td: tuple[EnvBase, TensorDictBase]): env, td, idx = env_td return env.reset(td).set(cls._env_idx_key, NonTensorData(data=idx)) @classmethod def _private_reset_func(cls, env_td: tuple[EnvBase, TensorDictBase]): env, td, idx = env_td return env._reset(td).set(cls._env_idx_key, NonTensorData(data=idx)) @classmethod def _step_and_maybe_reset_func(cls, env_td: tuple[EnvBase, TensorDictBase]): env, td, idx = env_td td, td_ = env.step_and_maybe_reset(td) idx = NonTensorData(data=idx) return td.set(cls._env_idx_key, idx), td_.set(cls._env_idx_key, idx) @staticmethod def _receive_futures(futures, min_get, max_get, timeout): if min_get < 1: raise ValueError(f"min_get must be positive, got {min_get}.") if max_get is not None and max_get < min_get: raise ValueError( f"max_get must be greater than or equal to min_get, got " f"min_get={min_get} and max_get={max_get}." ) if timeout is not None and timeout < 0: raise ValueError(f"timeout must be non-negative, got {timeout}.") limit = len(futures) if max_get is None else max_get pending = set(futures) completed = [] # The deadline is anchored at call entry and bounds the entire call, # including the wait for the first result. deadline_timer = ( None if timeout is None else timeit("async_env_future_batch_deadline").start() ) while pending and len(completed) < min_get: if deadline_timer is None: done, pending = wait(pending, return_when=FIRST_COMPLETED) else: remaining = timeout - deadline_timer.elapsed() # A non-positive remaining still polls: wait(timeout=0) # returns whatever is already done without blocking. done, pending = wait( pending, timeout=max(remaining, 0.0), return_when=FIRST_COMPLETED ) if not done: # Completed futures stay in the pool's pending lists (the # caller only removes returned ones), so no result is # lost and state stays consistent. raise TimeoutError( f"async recv timed out: {len(completed)}/{min_get} " f"results after {timeout}s; completed futures remain " f"available to the next call." ) completed.extend(list(done)[: limit - len(completed)]) while pending and len(completed) < limit: done = {future for future in pending if future.done()} if not done: if deadline_timer is None: break remaining = timeout - deadline_timer.elapsed() if remaining <= 0: break done, _ = wait(pending, timeout=remaining, return_when=FIRST_COMPLETED) if not done: break room = limit - len(completed) selected = list(done)[:room] completed.extend(selected) pending.difference_update(selected) return completed
[docs] def async_step_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) tds = tensordict.unbind(0) envs = [self.envs[idx] for idx in env_idx] futures = [ self._pool.submit(self._step_func, (env, td, idx)) for env, td, idx in zip(envs, tds, env_idx) ] if _per_env: self._per_env_step_futures[env_index] = futures[0] else: self._step_futures.extend(futures) self._current_step = self._current_step + len(futures)
[docs] def async_step_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: if env_index is not None: future = self._per_env_step_futures.pop(env_index) return future.result() if min_get is None: min_get = self.min_get if min_get > self._current_step: raise RuntimeError( f"Cannot await {min_get} step when only {self._current_step_reset} are being stepped." ) futures = self._step_futures completed_futures = self._receive_futures(futures, min_get, max_get, timeout) results = [future.result() for future in completed_futures] self._current_step -= len(completed_futures) self._step_futures = [ f for f in self._step_futures if f not in completed_futures ] results, idx = self._sort_results(results) self._busy.difference_update(idx) return self._stack_func(results)
def _async_private_step_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) tds = tensordict.unbind(0) envs = [self.envs[idx] for idx in env_idx] futures = [ self._pool.submit(self._private_step_func, (env, td, idx)) for env, td, idx in zip(envs, tds, env_idx) ] self._private_step_futures.extend(futures) self._current_step = self._current_step + len(futures) def _async_private_step_recv(self, min_get: int | None = None) -> TensorDictBase: if min_get is None: min_get = self.min_get if min_get > self._current_step: raise RuntimeError( f"Cannot await {min_get} step when only {self._current_step_reset} are being stepped." ) results = [] futures = self._private_step_futures completed_futures = [] for future in as_completed(futures): results.append(future.result()) completed_futures.append(future) self._current_step = self._current_step - 1 if len(results) >= min_get and sum([f.done() for f in futures]) == 0: break self._private_step_futures = [ f for f in self._private_step_futures if f not in completed_futures ] results, idx = self._sort_results(results) self._busy.difference_update(idx) return self._stack_func(results) def async_step_and_maybe_reset_send( self, tensordict: TensorDictBase, env_index: int | list[int] | None = None ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, False) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) tds = tensordict.unbind(0) envs = [self.envs[idx] for idx in env_idx] futures = [ self._pool.submit(self._step_and_maybe_reset_func, (env, td, idx)) for env, td, idx in zip(envs, tds, env_idx) ] if _per_env: self._per_env_step_reset_futures[env_index] = futures[0] else: self._step_and_maybe_reset_futures.extend(futures) self._current_step_reset = self._current_step_reset + len(futures)
[docs] def async_step_and_maybe_reset_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> tuple[TensorDictBase, TensorDictBase]: if env_index is not None: future = self._per_env_step_reset_futures.pop(env_index) return future.result() if min_get is None: min_get = self.min_get if min_get > self._current_step_reset: raise RuntimeError( f"Cannot await {min_get} step_and_maybe_reset when only {self._current_step_reset} are being stepped." ) futures = self._step_and_maybe_reset_futures completed_futures = self._receive_futures(futures, min_get, max_get, timeout) results = [future.result() for future in completed_futures] self._current_step_reset -= len(completed_futures) self._step_and_maybe_reset_futures = [ f for f in self._step_and_maybe_reset_futures if f not in completed_futures ] results, results_ = zip(*results) results, results_, idx = self._sort_results(results, results_) self._busy.difference_update(idx) return self._stack_func(results), self._stack_func(results_)
[docs] def async_reset_send( self, tensordict: TensorDictBase | None = None, env_index: int | list[int] | None = None, ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, True) _per_env = isinstance(env_index, int) if not _per_env: if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) tds = tensordict.unbind(0) envs = [self.envs[idx] for idx in env_idx] futures = [ self._pool.submit(self._reset_func, (env, td, idx)) for env, td, idx in zip(envs, tds, env_idx) ] if _per_env: self._per_env_reset_futures[env_index] = futures[0] else: self._current_reset = self._current_reset + len(futures) self._reset_futures.extend(futures)
[docs] def async_reset_recv( self, min_get: int | None = None, env_index: int | None = None, *, max_get: int | None = None, timeout: float | None = None, ) -> TensorDictBase: if env_index is not None: future = self._per_env_reset_futures.pop(env_index) return future.result() if min_get is None: min_get = self.min_get if min_get > self._current_reset: raise RuntimeError( f"Cannot await {min_get} reset when only {self._current_step_reset} are being reset." ) futures = self._reset_futures completed_futures = self._receive_futures(futures, min_get, max_get, timeout) results = [future.result() for future in completed_futures] self._current_reset -= len(completed_futures) self._reset_futures = [ f for f in self._reset_futures if f not in completed_futures ] results, idx = self._sort_results(results) self._busy.difference_update(idx) return self._stack_func(results)
def _async_private_reset_send( self, tensordict: TensorDictBase | None = None, env_index: int | list[int] | None = None, ) -> None: tensordict, env_idx = self._maybe_make_tensordict(tensordict, env_index, True) if self._busy.intersection(env_idx): raise RuntimeError( f"Some envs are still processing a step: envs that are busy: {self._busy}, queried: {env_idx}." ) self._busy.update(env_idx) tds = tensordict.unbind(0) envs = [self.envs[idx] for idx in env_idx] futures = [ self._pool.submit(self._private_reset_func, (env, td, idx)) for env, td, idx in zip(envs, tds, env_idx) ] self._current_reset = self._current_reset + len(futures) self._private_reset_futures.extend(futures) def _async_private_reset_recv(self, min_get: int | None = None) -> TensorDictBase: if min_get is None: min_get = self.min_get if min_get > self._current_reset: raise RuntimeError( f"Cannot await {min_get} reset when only {self._current_step_reset} are being reset." ) results = [] futures = self._private_reset_futures completed_futures = [] for future in as_completed(futures): results.append(future.result()) completed_futures.append(future) self._current_reset = self._current_reset - 1 if len(results) >= min_get and sum([f.done() for f in futures]) == 0: break self._private_reset_futures = [ f for f in self._private_reset_futures if f not in completed_futures ] results, idx = self._sort_results(results) self._busy.difference_update(idx) return self._stack_func(results)
[docs] def shutdown(self): self._pool.shutdown()