Rate this Page

Source code for torchrl.collectors._multi_base

from __future__ import annotations

import _pickle
import abc

import contextlib
import sys
import time
import warnings
from collections import OrderedDict
from collections.abc import Callable, Mapping, Sequence
from typing import Any, Literal

import numpy as np
import torch
from tensordict import TensorDict, TensorDictBase
from tensordict.nn import CudaGraphModule, TensorDictModule, TensorDictModuleBase
from tensordict.utils import _zip_strict
from torch import multiprocessing as mp, nn
from torchrl import logger as torchrl_logger
from torchrl._utils import (
    _check_for_faulty_process,
    _get_mp_ctx,
    _make_process_no_warn_cls,
    _mp_sharing_strategy_for_spawn,
    _set_mp_start_method_if_unset,
    RL_WARNINGS,
)
from torchrl.collectors._base import _ProfilerHook, BaseCollector, ProfileConfig
from torchrl.collectors._constants import (
    _Interruptor,
    DEFAULT_EXPLORATION_TYPE,
    ExplorationType,
    INSTANTIATE_TIMEOUT,
)
from torchrl.collectors._runner import _main_async_collector
from torchrl.collectors._single import Collector
from torchrl.collectors.utils import (
    _CollectorProgress,
    _make_meta_policy_cm,
    _TrajectoryPool,
    _validate_replay_write_mode,
    _validate_traj_format,
)
from torchrl.collectors.weight_update import WeightUpdaterBase
from torchrl.data import ReplayBuffer
from torchrl.data.utils import CloudpickleWrapper, DEVICE_TYPING
from torchrl.envs import EnvBase, EnvCreator
from torchrl.envs.transforms import PolicyVersion
from torchrl.weight_update import (
    MultiProcessWeightSyncScheme,
    SharedMemWeightSyncScheme,
    WeightSyncScheme,
)
from torchrl.weight_update.utils import _resolve_model


class _MultiCollectorMeta(abc.ABCMeta):
    """Metaclass for MultiCollector that dispatches based on sync parameter.

    When MultiCollector is instantiated with sync=True or sync=False, the metaclass
    intercepts the call and returns the appropriate subclass instance:
    - sync=True: returns MultiSyncCollector (alias: MultiSyncCollector)
    - sync=False: returns MultiAsyncCollector (alias: MultiAsyncCollector)
    """

    def __call__(cls, *args, sync: bool | None = None, **kwargs):
        # Only dispatch if we're instantiating MultiCollector directly (not a subclass)
        # and sync is explicitly provided
        if cls.__name__ == "MultiCollector" and sync is not None:
            if sync:
                from torchrl.collectors._multi_sync import MultiSyncCollector

                return MultiSyncCollector(*args, **kwargs)
            else:
                from torchrl.collectors._multi_async import MultiAsyncCollector

                return MultiAsyncCollector(*args, **kwargs)
        return super().__call__(*args, **kwargs)


[docs] class MultiCollector(BaseCollector, metaclass=_MultiCollectorMeta): """Runs a given number of DataCollectors on separate processes. .. note:: Use :class:`~torchrl.collectors.Collector` with ``num_collectors`` and ``sync`` to construct local process collectors in new code. This class remains the concrete process-collector API. Args: create_env_fn (List[Callabled]): list of Callables, each returning an instance of :class:`~torchrl.envs.EnvBase`. policy (Callable): Policy to be executed in the environment. Must accept :class:`tensordict.tensordict.TensorDictBase` object as input. If ``None`` is provided (default), the policy used will be a :class:`~torchrl.collectors.RandomPolicy` instance with the environment ``action_spec``. Accepted policies are usually subclasses of :class:`~tensordict.nn.TensorDictModuleBase`. This is the recommended usage of the collector. Other callables are accepted too: If the policy is not a ``TensorDictModuleBase`` (e.g., a regular :class:`~torch.nn.Module` instances) it will be wrapped in a `nn.Module` first. Then, the collector will try to assess if these modules require wrapping in a :class:`~tensordict.nn.TensorDictModule` or not. - If the policy forward signature matches any of ``forward(self, tensordict)``, ``forward(self, td)`` or ``forward(self, <anything>: TensorDictBase)`` (or any typing with a single argument typed as a subclass of ``TensorDictBase``) then the policy won't be wrapped in a :class:`~tensordict.nn.TensorDictModule`. - In all other cases an attempt to wrap it will be undergone as such: ``TensorDictModule(policy, in_keys=env_obs_key, out_keys=env.action_keys)``. .. note:: If the policy needs to be passed as a policy factory (e.g., in case it mustn't be serialized / pickled directly), the ``policy_factory`` should be used instead. .. note:: When using ``weight_sync_schemes``, both ``policy`` and ``policy_factory`` can be provided together. In this case, the ``policy`` is used ONLY for weight extraction (via ``TensorDict.from_module()``) to set up weight synchronization, but it is NOT sent to workers and its weights are NOT depopulated. The ``policy_factory`` is what actually gets passed to workers to create their local policy instances. This is useful when the policy is hard to serialize but you have a copy on the main node for weight synchronization purposes. Keyword Args: sync (bool, optional): if ``True``, the collector will run in sync mode (:class:`~torchrl.collectors.MultiSyncCollector`). If `False`, the collector will run in async mode (:class:`~torchrl.collectors.MultiAsyncCollector`). policy_factory (Callable[[], Callable], list of Callable[[], Callable], optional): a callable (or list of callables) that returns a policy instance. When not using ``weight_sync_schemes``, this is mutually exclusive with the ``policy`` argument. When using ``weight_sync_schemes``, both ``policy`` and ``policy_factory`` can be provided: the ``policy`` is used for weight extraction only, while ``policy_factory`` creates policies on workers. .. note:: `policy_factory` comes in handy whenever the policy cannot be serialized. .. warning:: `policy_factory` is currently not compatible with multiprocessed data collectors. num_workers (int, optional): number of workers to use. If `create_env_fn` is a list, this will be ignored. Defaults to `None` (workers determined by the `create_env_fn` length). frames_per_batch (int, Sequence[int]): A keyword-only argument representing the total number of elements in a batch. If a sequence is provided, represents the number of elements in a batch per worker. Total number of elements in a batch is then the sum over the sequence. total_frames (int, optional): A keyword-only argument representing the total number of frames returned by the collector during its lifespan. If the ``total_frames`` is not divisible by ``frames_per_batch``, an exception is raised. Endless collectors can be created by passing ``total_frames=-1``. Defaults to ``-1`` (never ending collector). device (int, str or torch.device, optional): The generic device of the collector. The ``device`` args fills any non-specified device: if ``device`` is not ``None`` and any of ``storing_device``, ``policy_device`` or ``env_device`` is not specified, its value will be set to ``device``. Defaults to ``None`` (No default device). Supports a list of devices if one wishes to indicate a different device for each worker. The list must be as long as the number of workers. storing_device (int, str or torch.device, optional): The device on which the output :class:`~tensordict.TensorDict` will be stored. If ``device`` is passed and ``storing_device`` is ``None``, it will default to the value indicated by ``device``. For long trajectories, it may be necessary to store the data on a different device than the one where the policy and env are executed. Defaults to ``None`` (the output tensordict isn't on a specific device, leaf tensors sit on the device where they were created). Supports a list of devices if one wishes to indicate a different device for each worker. The list must be as long as the number of workers. env_device (int, str or torch.device, optional): The device on which the environment should be cast (or executed if that functionality is supported). If not specified and the env has a non-``None`` device, ``env_device`` will default to that value. If ``device`` is passed and ``env_device=None``, it will default to ``device``. If the value as such specified of ``env_device`` differs from ``policy_device`` and one of them is not ``None``, the data will be cast to ``env_device`` before being passed to the env (i.e., passing different devices to policy and env is supported). Defaults to ``None``. Supports a list of devices if one wishes to indicate a different device for each worker. The list must be as long as the number of workers. policy_device (int, str or torch.device, optional): The device on which the policy should be cast. If ``device`` is passed and ``policy_device=None``, it will default to ``device``. If the value as such specified of ``policy_device`` differs from ``env_device`` and one of them is not ``None``, the data will be cast to ``policy_device`` before being passed to the policy (i.e., passing different devices to policy and env is supported). Defaults to ``None``. Supports a list of devices if one wishes to indicate a different device for each worker. The list must be as long as the number of workers. create_env_kwargs (dict, optional): A dictionary with the keyword arguments used to create an environment. If a list is provided, each of its elements will be assigned to a sub-collector. collector_class (Python class or constructor): a collector class to be remotely instantiated. Can be :class:`~torchrl.collectors.Collector`, :class:`~torchrl.collectors.MultiSyncCollector`, :class:`~torchrl.collectors.MultiAsyncCollector` or a derived class of these. Defaults to :class:`~torchrl.collectors.Collector`. max_frames_per_traj (int, optional): Maximum steps per trajectory. Note that a trajectory can span across multiple batches (unless ``reset_at_each_iter`` is set to ``True``, see below). Once a trajectory reaches ``n_steps``, the environment is reset. If the environment wraps multiple environments together, the number of steps is tracked for each environment independently. Negative values are allowed, in which case this argument is ignored. Defaults to ``None`` (i.e. no maximum number of steps). init_random_frames (int, optional): Number of frames for which the policy is ignored before it is called. This feature is mainly intended to be used in offline/model-based settings, where a batch of random trajectories can be used to initialize training. If provided, it will be rounded up to the closest multiple of frames_per_batch. Defaults to ``None`` (i.e. no random frames). reset_at_each_iter (bool, optional): Whether environments should be reset at the beginning of a batch collection. Defaults to ``False``. postproc (Callable, optional): A post-processing transform, such as a :class:`~torchrl.envs.Transform` or a :class:`~torchrl.data.postprocs.MultiStep` instance. Defaults to ``None``. split_trajs (bool, optional): Boolean indicating whether the resulting TensorDict should be split according to the trajectories. See :func:`~torchrl.collectors.utils.split_trajectories` for more information. Defaults to ``False``. exploration_type (ExplorationType, optional): interaction mode to be used when collecting data. Must be one of ``torchrl.envs.utils.ExplorationType.DETERMINISTIC``, ``torchrl.envs.utils.ExplorationType.RANDOM``, ``torchrl.envs.utils.ExplorationType.MODE`` or ``torchrl.envs.utils.ExplorationType.MEAN``. reset_when_done (bool, optional): if ``True`` (default), an environment that return a ``True`` value in its ``"done"`` or ``"truncated"`` entry will be reset at the corresponding indices. update_at_each_batch (boolm optional): if ``True``, :meth:`update_policy_weights_()` will be called before (sync) or after (async) each data collection. Defaults to ``False``. preemptive_threshold (:obj:`float`, optional): a value between 0.0 and 1.0 that specifies the ratio of workers that will be allowed to finish collecting their rollout before the rest are forced to end early. Frames that were not collected by the preempted workers are marked invalid: their ``("collector", "traj_ids")`` entry is ``-1`` and, with ``cat_results="stack"``, a ``("collector", "mask")`` entry flags the valid frames (with ``cat_results=-1`` the invalid frames are dropped from the batch instead). num_threads (int, optional): number of threads for this process. Defaults to the number of workers. num_sub_threads (int, optional): number of threads of the subprocesses. Should be equal to one plus the number of processes launched within each subprocess (or one if a single process is launched). Defaults to 1 for safety: if none is indicated, launching multiple workers may charge the cpu load too much and harm performance. cat_results (str, int or None): (:class:`~torchrl.collectors.MultiSyncCollector` exclusively). If ``"stack"``, the data collected from the workers will be stacked along the first dimension. This is the preferred behavior as it is the most compatible with the rest of the library. If ``0``, results will be concatenated along the first dimension of the outputs, which can be the batched dimension if the environments are batched or the time dimension if not. A ``cat_results`` value of ``-1`` will always concatenate results along the time dimension. This should be preferred over the default. Intermediate values are also accepted. Defaults to ``"stack"``. .. note:: From v0.5, this argument will default to ``"stack"`` for a better interoperability with the rest of the library. set_truncated (bool, optional): if ``True``, the truncated signals (and corresponding ``"done"`` but not ``"terminated"``) will be set to ``True`` when the last frame of a rollout is reached. If no ``"truncated"`` key is found, an exception is raised. Truncated keys can be set through ``env.add_truncated_keys``. Defaults to ``False``. With a multi-process collector writing to a shared replay buffer, this marks the worker-batch seams that would otherwise be invisible to a :class:`~torchrl.data.replay_buffers.SliceSampler`; see :ref:`the trajectory-boundary documentation <ref_traj_boundaries>` and :ref:`collectors_replay_trajs` for the trade-offs. trajs_per_batch (int, optional): When set without ``replay_buffer``, the multi-collector assembles trajectories from the worker batches and yields zero-padded batches of shape ``(trajs_per_batch, max_traj_len)`` with a ``("collector", "mask")`` boolean field, or flat unpadded concatenations with ``traj_format="cat"``. Defaults to ``None`` (fixed-frame batches). replay_write_mode (``"rollout"``, ``"trajectory"``, optional): Selects fixed-frame rollout writes or complete-trajectory writes to the shared replay buffer. In trajectory mode, assembly is delegated to each worker and only flat, completed trajectories are inserted. Defaults to ``None``; the legacy combination of ``replay_buffer`` and ``trajs_per_batch`` still selects trajectory writes. traj_format (str, optional): layout of the batches yielded when ``trajs_per_batch`` is set without a ``replay_buffer``: ``"padded"`` for zero-padded ``(trajs_per_batch, max_traj_len)`` stacks with a ``("collector", "mask")`` entry, ``"cat"`` for flat unpadded concatenations along time (trajectories delimited by ``("next", "done")`` and ``("collector", "traj_ids")``). Replay-buffer writes are always flat. Raises if set without ``trajs_per_batch``. Defaults to ``None``, which currently resolves to ``"padded"`` and emits a :class:`FutureWarning` when ``trajs_per_batch`` batches are yielded without an explicit choice: the default will change to ``"cat"`` in torchrl v0.16. use_buffers (bool, optional): if ``True``, a buffer will be used to stack the data. This isn't compatible with environments with dynamic specs. Defaults to ``True`` for envs without dynamic specs, ``False`` for others. replay_buffer (ReplayBuffer, optional): if provided, the collector will not yield tensordicts but populate the buffer instead. Defaults to ``None``. extend_buffer (bool, optional): if `True`, the replay buffer is extended with entire rollouts and not with single steps. Defaults to `True` for multiprocessed data collectors. trust_policy (bool, optional): if ``True``, a non-TensorDictModule policy will be trusted to be assumed to be compatible with the collector. This defaults to ``True`` for CudaGraphModules and ``False`` otherwise. compile_policy (bool or Dict[str, Any], optional): if ``True``, the policy will be compiled using :func:`~torch.compile` default behaviour. If a dictionary of kwargs is passed, it will be used to compile the policy. cudagraph_policy (bool or Dict[str, Any], optional): if ``True``, the policy will be wrapped in :class:`~tensordict.nn.CudaGraphModule` with default kwargs. If a dictionary of kwargs is passed, it will be used to wrap the policy. no_cuda_sync (bool): if ``True``, explicit CUDA synchronizations calls will be bypassed. For environments running directly on CUDA (`IsaacLab <https://github.com/isaac-sim/IsaacLab/>`_ or `ManiSkills <https://github.com/haosulab/ManiSkill/>`_) cuda synchronization may cause unexpected crashes. Defaults to ``False``. auto_register_policy_transforms (bool, optional): forwarded to each worker :class:`~torchrl.collectors.Collector`. When ``True``, workers append :class:`~torchrl.envs.transforms.InitTracker` and recurrent-state :class:`~torchrl.envs.transforms.TensorDictPrimer` transforms to their envs if the env specs don't already provide them. ``False`` disables it; ``None`` (default through v0.14) preserves pre-v0.15 behavior and emits a :class:`FutureWarning` when wrapping would have been needed. Default flips to ``True`` in v0.15. weight_updater (WeightUpdaterBase or constructor, optional): An instance of :class:`~torchrl.collectors.WeightUpdaterBase` or its subclass, responsible for updating the policy weights on remote inference workers. If not provided, a :class:`~torchrl.collectors.MultiProcessedWeightUpdater` will be used by default, which handles weight synchronization across multiple processes. Consider using a constructor if the updater needs to be serialized. weight_sync_schemes (dict[str, WeightSyncScheme], optional): Dictionary of weight sync schemes for SENDING weights to worker sub-collectors. Keys are model identifiers (e.g., "policy") and values are WeightSyncScheme instances configured to send weights to child processes. If not provided, a :class:`~torchrl.collectors.MultiProcessWeightSyncScheme` will be used by default. This is for propagating weights DOWN the hierarchy (parent -> children). weight_recv_schemes (dict[str, WeightSyncScheme], optional): Dictionary of weight sync schemes for RECEIVING weights from parent collectors. Keys are model identifiers (e.g., "policy") and values are WeightSyncScheme instances configured to receive weights. This enables cascading in hierarchies like: RPCCollector -> MultiSyncCollector -> Collector. Received weights are automatically propagated to sub-collectors if matching model_ids exist. Defaults to ``None``. track_policy_version (bool or PolicyVersion, optional): if ``True``, the collector will track the version of the policy. A :class:`~torchrl.envs.transforms.PolicyVersion` transform is installed on each worker's environment, tagging every collected frame with the current version under the ``"policy_version"`` key. Each worker's transform is bumped after the new weights have actually been applied in that worker, so per-frame tagging tracks real weight updates rather than rollout iterations. Note that in asynchronous mode a batch that was already in flight when :meth:`update_policy_weights_` is called may straddle the bump (some frames tagged with the old version, the remainder with the new). Treat the value as the version under which each individual frame was produced, not as a batch-level label. For multi-process collectors, the ``"policy_version"`` entries in the collected tensordict are produced by worker-local transforms and are the source of truth for data provenance. The parent collector's :attr:`policy_version` property is only available while all workers are known to have acknowledged the same sequence of weight updates. Use :meth:`worker_policy_versions` for an explicit per-worker snapshot. The recommended path is ``track_policy_version=True``: let the collector own the transform. Passing a :class:`~torchrl.envs.transforms.PolicyVersion` instance directly is reserved for advanced use cases that wire up a ``PolicyVersion`` **without** going through a collector. With multi-process collectors that pre-built tracker lives in the *parent* and is not propagated into workers, so per-frame tagging will still be driven by per-worker transforms — favor ``True``. Defaults to ``False``. compact_obs (bool, optional): if ``True``, each worker drops the observation and state keys from the ``("next", ...)`` sub-tensordict before stacking. See :class:`~torchrl.collectors.Collector` for the full explanation and tradeoffs (most notably: :class:`~torchrl.envs.transforms.MultiStepTransform` cannot be used in compact mode), plus the pairing with :class:`~torchrl.envs.transforms.NextStateReconstructor` at sampling time, the boundary-preserving lossy alternative :class:`~torchrl.envs.transforms.NextObservationDelta`, and the *Memory-efficient RL training* tutorial. Defaults to ``False``. worker_idx (int, optional): the index of the worker. Examples: >>> from torchrl.collectors import MultiCollector >>> from torchrl.envs import GymEnv >>> >>> def make_env(): ... return GymEnv("CartPole-v1") >>> >>> # Synchronous collection (for on-policy algorithms like PPO) >>> sync_collector = MultiCollector( ... create_env_fn=[make_env] * 4, # 4 parallel workers ... policy=my_policy, ... frames_per_batch=1000, ... total_frames=100000, ... sync=True, # All workers complete before batch is delivered ... ) >>> >>> # Asynchronous collection (for off-policy algorithms like SAC) >>> async_collector = MultiCollector( ... create_env_fn=[make_env] * 4, ... policy=my_policy, ... frames_per_batch=1000, ... total_frames=100000, ... sync=False, # First-come-first-serve delivery ... ) >>> >>> # Iterate over collected data >>> for data in sync_collector: ... # data is a TensorDict with collected transitions ... pass >>> sync_collector.shutdown() """ def __init__( self, create_env_fn: Sequence[Callable[[], EnvBase]], policy: None | (TensorDictModule | Callable[[TensorDictBase], TensorDictBase]) = None, *, num_workers: int | None = None, policy_factory: ( Callable[[], Callable] | list[Callable[[], Callable]] | None ) = None, frames_per_batch: int | Sequence[int], total_frames: int | None = -1, device: DEVICE_TYPING | Sequence[DEVICE_TYPING] | None = None, storing_device: DEVICE_TYPING | Sequence[DEVICE_TYPING] | None = None, env_device: DEVICE_TYPING | Sequence[DEVICE_TYPING] | None = None, policy_device: DEVICE_TYPING | Sequence[DEVICE_TYPING] | None = None, create_env_kwargs: Sequence[dict] | None = None, collector_class: type | Callable[[], BaseCollector] | None = None, max_frames_per_traj: int | None = None, init_random_frames: int | None = None, reset_at_each_iter: bool = False, postproc: Callable[[TensorDictBase], TensorDictBase] | None = None, split_trajs: bool | None = None, exploration_type: ExplorationType = DEFAULT_EXPLORATION_TYPE, reset_when_done: bool = True, update_at_each_batch: bool = False, preemptive_threshold: float | None = None, num_threads: int | None = None, num_sub_threads: int = 1, cat_results: str | int | None = None, set_truncated: bool = False, use_buffers: bool | None = None, replay_buffer: ReplayBuffer | None = None, extend_buffer: bool = True, trust_policy: bool | None = None, compile_policy: bool | dict[str, Any] | None = None, cudagraph_policy: bool | dict[str, Any] | None = None, no_cuda_sync: bool = False, weight_updater: ( WeightUpdaterBase | Callable[[], WeightUpdaterBase] | None ) = None, weight_sync_schemes: dict[str, WeightSyncScheme] | None = None, weight_recv_schemes: dict[str, WeightSyncScheme] | None = None, track_policy_version: bool = False, worker_idx: int | None = None, trajs_per_batch: int | None = None, trajs_per_write: int | None = None, replay_write_mode: Literal["rollout", "trajectory"] | None = None, traj_format: Literal["padded", "cat"] | None = None, init_fn: Callable[[], None] | None = None, auto_register_policy_transforms: bool | None = None, pre_collect_hook: Callable[[], None] | None = None, post_collect_hook: Callable[[TensorDictBase], None] | None = None, compact_obs: bool = False, ): self.closed = True self._shutdown_complete = False self.worker_idx = worker_idx self.trajs_per_batch = trajs_per_batch self.trajs_per_write = trajs_per_write self.replay_write_mode = _validate_replay_write_mode( replay_write_mode, has_replay_buffer=replay_buffer is not None, trajs_per_batch=trajs_per_batch, trajs_per_write=trajs_per_write, ) # Preserve the raw selector for workers. In the legacy # replay_buffer + trajs_per_batch form it must stay None so that the # worker can resolve that combination without seeing two selectors. self._worker_replay_write_mode = replay_write_mode self._trajectory_writes_in_workers = self.replay_write_mode == "trajectory" self.traj_format = _validate_traj_format( traj_format, trajs_per_batch, has_replay_buffer=replay_buffer is not None ) self._auto_register_policy_transforms = auto_register_policy_transforms super().__init__( pre_collect_hook=pre_collect_hook, post_collect_hook=post_collect_hook, ) self._worker_pre_collect_hook = ( CloudpickleWrapper(pre_collect_hook) if pre_collect_hook is not None else None ) self._worker_post_collect_hook = ( CloudpickleWrapper(post_collect_hook) if post_collect_hook is not None else None ) self._worker_trajs_per_batch = None # Wrap init_fn with CloudpickleWrapper to support lambdas / closures # across the spawn start method. self._worker_init_fn = ( CloudpickleWrapper(init_fn) if init_fn is not None else None ) # Set up workers and environment functions create_env_fn, total_frames_per_batch = self._setup_workers_and_env_fns( create_env_fn, num_workers, frames_per_batch ) # Set up basic configuration self.set_truncated = set_truncated self.num_sub_threads = num_sub_threads self.num_threads = num_threads self.create_env_fn = create_env_fn self._read_compile_kwargs(compile_policy, cudagraph_policy) # Set up environment kwargs self.create_env_kwargs = self._setup_env_kwargs(create_env_kwargs) # Set up devices storing_devices, policy_devices, env_devices = self._get_devices( storing_device=storing_device, env_device=env_device, policy_device=policy_device, device=device, ) self.storing_device = storing_devices self.policy_device = policy_devices self.env_device = env_devices self.collector_class = collector_class del storing_device, env_device, policy_device, device self.no_cuda_sync = no_cuda_sync # Set up replay buffer self._use_buffers = use_buffers self.replay_buffer = replay_buffer # Set up policy and weights if trust_policy is None: trust_policy = policy is not None and isinstance(policy, CudaGraphModule) self.trust_policy = trust_policy policy_factory = self._setup_policy_factory(policy_factory) # Set up weight synchronization if weight_sync_schemes is None and weight_updater is None: weight_sync_schemes = {} elif weight_sync_schemes is not None and weight_updater is not None: raise TypeError( "Cannot specify both weight_sync_schemes and weight_updater." ) if ( weight_sync_schemes is not None and not weight_sync_schemes and weight_updater is None and isinstance(policy, nn.Module) ): weight_sync_schemes["policy"] = SharedMemWeightSyncScheme() self._setup_multi_weight_sync(weight_updater, weight_sync_schemes) # Store policy and policy_factory - temporary set to make them visible to the receiver self.policy = policy self.policy_factory = policy_factory # Policy-version tracking must be configured before replay-buffer # initialization so the shared storage reserves worker-produced fields. self._setup_multi_policy_version_tracking(track_policy_version) self._setup_multi_replay_buffer(replay_buffer, extend_buffer) # Set up weight receivers if provided if weight_recv_schemes is not None: self.register_scheme_receiver(weight_recv_schemes) self._setup_multi_policy_and_weights( self.policy, self.policy_factory, weight_updater, weight_sync_schemes ) # # Set up fallback policy for weight extraction # self._setup_fallback_policy(policy, policy_factory, weight_sync_schemes) # Set up total frames and other parameters self._setup_multi_total_frames( total_frames, total_frames_per_batch, frames_per_batch ) self.reset_at_each_iter = reset_at_each_iter self.postproc = postproc self.compact_obs = bool(compact_obs) self.max_frames_per_traj = ( int(max_frames_per_traj) if max_frames_per_traj is not None else 0 ) # Set up split trajectories self.requested_frames_per_batch = total_frames_per_batch self.reset_when_done = reset_when_done self._setup_split_trajs(split_trajs, reset_when_done) # Set up other parameters self.init_random_frames = ( int(init_random_frames) if init_random_frames is not None else 0 ) self.update_at_each_batch = update_at_each_batch self.exploration_type = exploration_type self.frames_per_worker = np.inf # Set up preemptive threshold self._setup_preemptive_threshold(preemptive_threshold) # Run worker processes try: self._run_processes() except Exception as e: self.shutdown(raise_on_error=False) raise e # Set up frame tracking and other options self._exclude_private_keys = True self._frames = 0 self._iter = -1 # Validate cat_results self._validate_cat_results(cat_results) def _setup_workers_and_env_fns( self, create_env_fn: Sequence[Callable] | Callable, num_workers: int | None, frames_per_batch: int | Sequence[int], ) -> tuple[list[Callable], int]: """Set up workers and environment functions.""" if isinstance(create_env_fn, Sequence): self.num_workers = len(create_env_fn) else: self.num_workers = num_workers create_env_fn = [create_env_fn] * self.num_workers if ( isinstance(frames_per_batch, Sequence) and len(frames_per_batch) != self.num_workers ): raise ValueError( "If `frames_per_batch` is provided as a sequence, it should contain exactly one value per worker." f"Got {len(frames_per_batch)} values for {self.num_workers} workers." ) self._frames_per_batch = frames_per_batch total_frames_per_batch = ( sum(frames_per_batch) if isinstance(frames_per_batch, Sequence) else frames_per_batch ) return create_env_fn, total_frames_per_batch def _setup_env_kwargs( self, create_env_kwargs: Sequence[dict] | dict | None ) -> list[dict]: """Set up environment kwargs for each worker.""" if isinstance(create_env_kwargs, Mapping): create_env_kwargs = [create_env_kwargs] * self.num_workers elif create_env_kwargs is None: create_env_kwargs = [{}] * self.num_workers elif isinstance(create_env_kwargs, (tuple, list)): create_env_kwargs = list(create_env_kwargs) if len(create_env_kwargs) != self.num_workers: raise ValueError( f"len(create_env_kwargs) must be equal to num_workers, got {len(create_env_kwargs)=} and {self.num_workers=}" ) return create_env_kwargs def _setup_multi_replay_buffer( self, replay_buffer: ReplayBuffer | None, extend_buffer: bool, ) -> None: """Set up replay buffer for multi-process collector.""" self.local_init_rb = True self._check_replay_buffer_init() self.extend_buffer = extend_buffer if ( replay_buffer is not None and hasattr(replay_buffer, "shared") and not replay_buffer.shared ): torchrl_logger.info("Replay buffer is not shared. Sharing it.") replay_buffer.share() def _setup_policy_factory( self, policy_factory: Callable | list[Callable] | None ) -> list[Callable | None]: """Set up policy factory for each worker.""" if not isinstance(policy_factory, Sequence): policy_factory = [policy_factory] * self.num_workers return policy_factory def _setup_multi_policy_and_weights( self, policy: TensorDictModule | Callable | None, policy_factory: list[Callable | None], weight_updater: WeightUpdaterBase | Callable | None, weight_sync_schemes: dict[str, WeightSyncScheme] | None, ) -> None: """Set up policy for multi-process collector. With weight sync schemes: validates and stores policy without weight extraction. With weight updater: extracts weights and creates stateful policies. When both policy and policy_factory are provided (with weight_sync_schemes): - The policy is used ONLY for weight extraction via get_model() - The policy is NOT depopulated of its weights - The policy is NOT sent to workers - The policy_factory is used to create policies on workers """ if any(policy_factory) and policy is not None: if weight_sync_schemes is None: raise TypeError( "policy_factory and policy are mutually exclusive when not using weight_sync_schemes. " "When using weight_sync_schemes, policy can be provided alongside policy_factory " "for weight extraction purposes only (the policy will not be sent to workers)." ) # Store policy as fallback for weight extraction only # The policy keeps its weights and is NOT sent to workers self._fallback_policy = policy if weight_sync_schemes is not None: weight_sync_policy = weight_sync_schemes.get("policy") if weight_sync_policy is None: return # # If we only have a policy_factory (no policy instance), the scheme must # # be pre-initialized on the sender, since there is no policy on the # # collector to extract weights from. # if any(p is not None for p in policy_factory) and policy is None: # if not weight_sync_policy.initialized_on_sender: # raise RuntimeError( # "the weight sync scheme must be initialized on sender ahead of time " # "when passing a policy_factory without a policy instance on the collector. " # f"Got {policy_factory=}" # ) # # When a policy instance is provided alongside a policy_factory, the scheme # # can rely on the collector context (and its policy) to extract weights. # # Weight sync scheme initialization then happens in _run_processes where # # pipes and workers are available. else: # Using legacy weight updater - extract weights and create stateful policies self._setup_multi_policy_and_weights_legacy( policy, policy_factory, weight_updater, weight_sync_schemes ) def _setup_multi_policy_and_weights_legacy( self, policy: TensorDictModule | Callable | None, policy_factory: list[Callable | None], weight_updater: WeightUpdaterBase | Callable | None, weight_sync_schemes: dict[str, WeightSyncScheme] | None, ) -> None: """Set up policy and extract weights for each device. Creates stateful policies with weights extracted and placed in shared memory. Used with weight updater for in-place weight replacement. """ self._policy_weights_dict = {} self._fallback_policy = None # Policy to use for weight extraction fallback if not any(policy_factory): for policy_device, env_maker, env_maker_kwargs in _zip_strict( self.policy_device, self.create_env_fn, self.create_env_kwargs ): policy_new_device, get_weights_fn = self._get_policy_and_device( policy=policy, policy_device=policy_device, env_maker=env_maker, env_maker_kwargs=env_maker_kwargs, ) if type(policy_new_device) is not type(policy): policy = policy_new_device weights = ( TensorDict.from_module(policy_new_device) if isinstance(policy_new_device, nn.Module) else TensorDict() ) # For multi-process collectors, ensure weights are in shared memory if policy_device and policy_device.type == "cpu": weights = weights.share_memory_() self._policy_weights_dict[policy_device] = weights # Store the first policy instance for fallback weight extraction if self._fallback_policy is None: self._fallback_policy = policy_new_device self._get_weights_fn = get_weights_fn if weight_updater is None: # For multiprocessed collectors, use MultiProcessWeightSyncScheme by default if weight_sync_schemes is None: weight_sync_schemes = {"policy": MultiProcessWeightSyncScheme()} self._weight_sync_schemes = weight_sync_schemes elif weight_updater is None: warnings.warn( "weight_updater is None, but policy_factory is provided. This means that the server will " "not know how to send the weights to the workers. If the workers can handle their weight synchronization " "on their own (via some specialized worker type / constructor) this may well work, but make sure " "your weight synchronization strategy is properly set. To suppress this warning, you can use " "RemoteModuleWeightUpdater() which enforces explicit weight passing when calling update_policy_weights_(weights). " "This will work whenever your inference and training policies are nn.Module instances with similar structures." ) def _setup_multi_weight_sync( self, weight_updater: WeightUpdaterBase | Callable | None, weight_sync_schemes: dict[str, WeightSyncScheme] | None, ) -> None: """Set up weight synchronization for multi-process collector.""" if weight_sync_schemes is not None: # Use weight sync schemes for weight distribution self._weight_sync_schemes = weight_sync_schemes # Senders will be created in _run_processes self.weight_updater = None else: # Use weight updater for weight distribution self.weight_updater = weight_updater self._weight_sync_schemes = None def _setup_multi_policy_version_tracking( self, track_policy_version: bool | PolicyVersion ) -> None: """Set up policy version tracking for multi-process collector.""" self.policy_version_tracker = track_policy_version if PolicyVersion is not None: if isinstance(track_policy_version, bool) and track_policy_version: self.policy_version_tracker = PolicyVersion() elif hasattr(track_policy_version, "increment_version"): self.policy_version_tracker = track_policy_version else: self.policy_version_tracker = None else: if track_policy_version: raise ImportError( "PolicyVersion is not available. Please install the LLM dependencies or set track_policy_version=False." ) self.policy_version_tracker = None self._acknowledged_policy_version = ( self.policy_version_tracker.version if self.policy_version_tracker is not None else None )
[docs] def update_policy_weights_( self, policy_or_weights: ( TensorDictBase | TensorDictModuleBase | nn.Module | dict | None ) = None, *, worker_ids: int | list[int] | torch.device | list[torch.device] | None = None, **kwargs, ) -> None: """Update worker policy weights and track an acknowledged version. The aggregate :attr:`policy_version` is advanced only when every worker synchronously acknowledges the update. Partial or asynchronous updates invalidate the aggregate because a single version can no longer describe every worker. """ acknowledged_version = self._acknowledged_policy_version if self.policy_version_tracker is not None: # If synchronization fails after some workers have applied the update, # retaining the previous aggregate would be misleading. self._acknowledged_policy_version = None super().update_policy_weights_( policy_or_weights=policy_or_weights, worker_ids=worker_ids, **kwargs ) if self.policy_version_tracker is None or acknowledged_version is None: return if worker_ids is None: all_workers_targeted = True elif isinstance(worker_ids, int): all_workers_targeted = self.num_workers == 1 and worker_ids == 0 elif isinstance(worker_ids, torch.device): all_workers_targeted = False elif all(isinstance(worker_id, int) for worker_id in worker_ids): all_workers_targeted = len(worker_ids) == self.num_workers and set( worker_ids ) == set(range(self.num_workers)) else: all_workers_targeted = False weights = kwargs.get("weights", policy_or_weights) per_worker_weights = ( isinstance(weights, dict) and bool(weights) and all(isinstance(worker_id, int) for worker_id in weights) ) if per_worker_weights: all_workers_targeted = all_workers_targeted and set(weights) == set( range(self.num_workers) ) weights_dict = kwargs.get("weights_dict") if weights_dict is not None: model_ids = list(weights_dict) for model_weights in weights_dict.values(): if ( isinstance(model_weights, dict) and model_weights and all(isinstance(worker_id, int) for worker_id in model_weights) ): all_workers_targeted = all_workers_targeted and set( model_weights ) == set(range(self.num_workers)) else: model_ids = [kwargs.get("model_id") or "policy"] if not model_ids: self._acknowledged_policy_version = acknowledged_version return schemes = self._weight_sync_schemes synchronously_acknowledged = bool(schemes) and all( model_id in schemes and getattr(schemes[model_id], "sync", True) for model_id in model_ids ) if all_workers_targeted and synchronously_acknowledged: # BaseCollector synchronizes each model separately, and the worker # cascade increments its local tracker once per synced model. # Rebuild the aggregate with that same per-model count only after # every worker has acknowledged the full synchronous update. self.policy_version_tracker.version = acknowledged_version for _ in model_ids: self.increment_version()
# TODO: Remove this def _setup_fallback_policy( self, policy: TensorDictModule | Callable | None, policy_factory: list[Callable | None], weight_sync_schemes: dict[str, WeightSyncScheme] | None, ) -> None: """Set up fallback policy for weight extraction when using policy_factory.""" # _fallback_policy is already set in _setup_multi_policy_and_weights if a policy was provided # If policy_factory was used, create a policy instance to use as fallback if policy is None and any(policy_factory) and weight_sync_schemes is not None: if not hasattr(self, "_fallback_policy") or self._fallback_policy is None: first_factory = ( policy_factory[0] if isinstance(policy_factory, list) else policy_factory ) if first_factory is not None: # Create a policy instance for weight extraction # This will be a reference to a policy with the same structure # For shared memory, modifications to any policy will be visible here self._fallback_policy = first_factory() def _setup_multi_total_frames( self, total_frames: int, total_frames_per_batch: int, frames_per_batch: int | Sequence[int], ) -> None: """Validate and set total frames for multi-process collector.""" if total_frames is None or total_frames < 0: total_frames = float("inf") else: remainder = total_frames % total_frames_per_batch if remainder != 0 and RL_WARNINGS: warnings.warn( f"total_frames ({total_frames}) is not exactly divisible by frames_per_batch ({total_frames_per_batch}). " f"This means {total_frames_per_batch - remainder} additional frames will be collected. " "To silence this message, set the environment variable RL_WARNINGS to False." ) self.total_frames = ( int(total_frames) if total_frames != float("inf") else total_frames ) def _setup_split_trajs( self, split_trajs: bool | None, reset_when_done: bool ) -> None: """Set up split trajectories option.""" if split_trajs is None: split_trajs = False elif not reset_when_done and split_trajs: raise RuntimeError( "Cannot split trajectories when reset_when_done is False." ) elif split_trajs: warnings.warn( "split_trajs=True produces a (N_traj, T_max) zero-padded " "tensordict with a 'mask' key. For sequence training, prefer " "the contiguous-trajectory layout: pass a replay_buffer to " "the collector and sample with " ":class:`~torchrl.data.SliceSampler` (variable-length slices, " "no padding, no mask). See " ":ref:`Data layout: contiguous trajectories <data-layout>` " "in the docs. This advisory will become a " "DeprecationWarning in a future release.", stacklevel=3, ) self.split_trajs = split_trajs def _setup_preemptive_threshold(self, preemptive_threshold: float | None) -> None: """Set up preemptive threshold for early stopping.""" if preemptive_threshold is not None: preemptive_threshold = float(preemptive_threshold) if not 0.0 <= preemptive_threshold <= 1.0: raise ValueError( f"preemptive_threshold must be between 0.0 and 1.0, got {preemptive_threshold}." ) self.preemptive_threshold = preemptive_threshold # A threshold of 1.0 waits for every worker, so preemption can never # fire: skip the interruptor and its per-step polling in the workers. self.interruptor = _Interruptor() if preemptive_threshold < 1.0 else None else: self.preemptive_threshold = 1.0 self.interruptor = None def _should_use_random_frames(self) -> bool: """Determine if random frames should be used instead of the policy. When a replay buffer is provided, uses `replay_buffer.write_count` as the global step counter to support `.start()` mode where `_frames` isn't updated until after collection. Otherwise, uses the internal `_frames` counter. Returns: bool: True if random frames should be used, False otherwise. """ if self.init_random_frames is None or self.init_random_frames <= 0: return False # Use replay_buffer.write_count when available for accurate counting in .start() mode if self.replay_buffer is not None: return self.replay_buffer.write_count < self.init_random_frames return self._frames < self.init_random_frames def _validate_cat_results(self, cat_results: str | int | None) -> None: """Validate cat_results parameter.""" if cat_results is not None and ( not isinstance(cat_results, (int, str)) or (isinstance(cat_results, str) and cat_results != "stack") ): raise ValueError( "cat_results must be a string ('stack') " f"or an integer representing the cat dimension. Got {cat_results}." ) # Lazy import to avoid circular dependency from torchrl.collectors._multi_sync import MultiSyncCollector if not isinstance(self, MultiSyncCollector) and cat_results not in ( "stack", None, ): raise ValueError( "cat_results can only be used with ``MultiSyncCollector``." ) self.cat_results = cat_results def _check_replay_buffer_init(self): if self.replay_buffer is None: return # Warn when a SliceSampler is used without trajectory writes: workers # write batches independently so adjacent frames in the buffer can # come from different episodes without an intervening done signal. # This hazard is specific to multi-process collectors: a single # Collector writes batches in temporal order, so consecutive batches # are contiguous continuations of the same trajectories and the only # mid-trajectory edge is the live write cursor, which SliceSampler # already resolves at read time (see the trajectory-boundary section # of the replay-buffer docs). from torchrl.data.replay_buffers.samplers import SliceSampler if ( self.replay_write_mode != "trajectory" and isinstance(getattr(self.replay_buffer, "_sampler", None), SliceSampler) and not self.set_truncated ): warnings.warn( "A SliceSampler is used with a multi-process collector but " "replay_write_mode='trajectory' is not set and " "set_truncated is False. " "Adjacent frames in the replay buffer may come from different " "workers and different episodes, causing SliceSampler to " "sample slices that cross trajectory boundaries. " "Consider setting replay_write_mode='trajectory' to write " "only complete trajectories, or set_truncated=True to mark batch " "boundaries (note: this introduces artificial truncations).", category=UserWarning, stacklevel=2, ) if self.replay_write_mode == "trajectory": # Trajectory assembly will happen at the worker level: each worker's # inner Collector uses _iter_by_trajectories() to assemble complete # trajectories and write them to the shared replay buffer. # Null out trajs_per_batch on the multi-collector so that __iter__ # routes to self.iterator() directly (not _iter_by_trajectories, # which would spin forever on the None yields from the RB path). if self.trajs_per_batch is not None: self._worker_trajs_per_batch = self.trajs_per_batch self.trajs_per_batch = None is_init = hasattr(self.replay_buffer, "_storage") and getattr( self.replay_buffer._storage, "initialized", True ) if is_init: self._validate_policy_version_replay_schema() if not is_init: storage = self.replay_buffer._storage if self._should_init_replay_buffer_from_worker(storage): self._enable_replay_buffer_worker_init(storage) self.replay_buffer.share() return if self.local_init_rb and getattr(storage, "shared_init", False): # New behavior: storage handles all coordination itself # Nothing to do here - the storage will coordinate during first write self.replay_buffer.share() return # Legacy behavior: fake tensordict initialization if isinstance(self.create_env_fn[0], EnvCreator): fake_td = self.create_env_fn[0].meta_data.tensordict elif isinstance(self.create_env_fn[0], EnvBase): fake_td = self.create_env_fn[0].fake_tensordict() else: fake_td = self.create_env_fn[0]( **self.create_env_kwargs[0] ).fake_tensordict() fake_td = self._add_collector_outputs_to_fake_td(fake_td) if self.replay_write_mode == "trajectory": # In trajectory mode, workers write flat 1-D timesteps to # the buffer. Initialise the storage as 1-D so that the # shapes match when real trajectories are written. fake_td = fake_td.reshape(-1)[:1] fake_td["collector", "traj_ids"] = torch.zeros( fake_td.shape, dtype=torch.long ) self.replay_buffer.extend(fake_td) else: fake_td["collector", "traj_ids"] = torch.zeros( fake_td.shape, dtype=torch.long ) # Use extend to avoid time-related transforms to fail self.replay_buffer.extend(fake_td.unsqueeze(-1)) self.replay_buffer.empty() def _should_init_replay_buffer_from_worker(self, storage): return ( self.local_init_rb and self.policy is None and any(self.policy_factory) and hasattr(storage, "shared_init") and hasattr(storage, "_make_init_directory") ) @staticmethod def _enable_replay_buffer_worker_init(storage): if getattr(storage, "_compilable", False): raise RuntimeError( "Cannot initialize a compilable replay-buffer storage from " "multi-collector workers." ) if getattr(storage, "shared_init", False): return storage_device = getattr(storage, "device", None) if ( storage_device is not None and storage_device != "auto" and torch.device(storage_device).type != "cpu" ): warnings.warn( f"Worker-initialized replay buffers store data in a CPU " f"memory-mapped tensordict; the storage device " f"({storage_device}) cannot be honored and will be reset to " f"'cpu' at initialization time." ) storage.shared_init = True storage._init_lock = mp.Lock() storage._init_event = mp.Event() storage._make_init_directory() def _validate_policy_version_replay_schema(self): if self.policy_version_tracker is None: return storage = getattr(self.replay_buffer._storage, "_storage", None) if not isinstance(storage, TensorDictBase): return key = ("next", "policy_version") if key not in storage.keys(True, True): raise RuntimeError( "The replay-buffer storage was initialized without the required " "('next', 'policy_version') field. Use an uninitialized lazy " "storage or include a field matching the configured policy-version " "type at that key before enabling track_policy_version." ) value = storage.get(key) version_type = getattr(self.policy_version_tracker, "version_type", int) if version_type in (int, "int"): if not isinstance(value, torch.Tensor) or value.dtype != torch.int64: dtype = getattr(value, "dtype", type(value).__name__) raise RuntimeError( "The replay-buffer field ('next', 'policy_version') must have " f"dtype torch.int64 for integer policy versions, got {dtype}." ) elif version_type in (str, "uuid") and isinstance(value, torch.Tensor): raise RuntimeError( "The replay-buffer field ('next', 'policy_version') must contain " "non-tensor values for UUID policy versions." ) def _add_collector_outputs_to_fake_td(self, fake_td): policy = getattr(self, "policy", None) out_keys = getattr(policy, "out_keys", None) if out_keys: with torch.no_grad(): policy_output = policy(fake_td.copy()) policy_output_keys = policy_output.keys(True, True) for key in out_keys: if key in fake_td.keys(True, True) or key not in policy_output_keys: continue fake_td.set(key, policy_output.get(key)) if self.policy_version_tracker is not None: next_td = self.policy_version_tracker._step(fake_td, fake_td.get("next")) fake_td.set("next", next_td) return fake_td
[docs] def fake_tensordict(self) -> TensorDictBase: """Not implemented for multi-process collectors. Honoring the multi-collector contract here would require either creating an env in the main process (which defeats the purpose of a multi-process collector — Isaac Lab / mujoco-mjx etc. can only run in workers) or routing a request to a worker over the pipe (which requires workers to be alive and adds protocol surface). Neither is implemented; call :meth:`~torchrl.collectors.Collector.fake_tensordict` on a single :class:`~torchrl.collectors.Collector` instead, or build the template directly from the env spec. """ raise NotImplementedError( f"{type(self).__name__}.fake_tensordict() is not implemented. " "Use Collector.fake_tensordict() on a single-process collector " "for storage / cudagraph warmup, or build the template from the " "env spec directly." )
@classmethod def _total_workers_from_env(cls, env_creators): if isinstance(env_creators, (tuple, list)): return sum( cls._total_workers_from_env(env_creator) for env_creator in env_creators ) from torchrl.envs import ParallelEnv if isinstance(env_creators, ParallelEnv): return env_creators.num_workers return 1 def _get_devices( self, *, storing_device: torch.device, policy_device: torch.device, env_device: torch.device, device: torch.device, ): # convert all devices to lists if not isinstance(storing_device, (list, tuple)): storing_device = [ storing_device, ] * self.num_workers if not isinstance(policy_device, (list, tuple)): policy_device = [ policy_device, ] * self.num_workers if not isinstance(env_device, (list, tuple)): env_device = [ env_device, ] * self.num_workers if not isinstance(device, (list, tuple)): device = [ device, ] * self.num_workers if not ( len(device) == len(storing_device) == len(policy_device) == len(env_device) == self.num_workers ): raise RuntimeError( f"The length of the devices does not match the number of workers: {self.num_workers}." ) storing_device, policy_device, env_device = zip( *[ Collector._get_devices( storing_device=storing_device, policy_device=policy_device, env_device=env_device, device=device, ) for (storing_device, policy_device, env_device, device) in zip( storing_device, policy_device, env_device, device ) ] ) return storing_device, policy_device, env_device def frames_per_batch_worker(self, *, worker_idx: int | None = None) -> int: raise NotImplementedError @property def _queue_len(self) -> int: raise NotImplementedError def _recv_and_check( self, pipe, *, timeout: float | None = None, check_interval: float = 1.0, worker_idx: int | None = None, ): """Receive from a pipe while periodically checking worker health. This method prevents the main process from hanging indefinitely if a worker dies while we're waiting for a response. It polls the pipe with a timeout and checks if all worker processes are still alive between polls. The overhead is minimal: if data is already available, `poll()` returns immediately and no health check is performed. Health checks only run when actually waiting for a slow response. Args: pipe: The pipe to receive from. timeout: Maximum total time to wait for a message (seconds). If None (default), wait indefinitely but still check worker health periodically. check_interval: How often to check worker health (seconds). Default 1.0. worker_idx: Optional worker index for error messages. Returns: The received message. Raises: RuntimeError: If a worker process dies while waiting. TimeoutError: If no message is received within the timeout (only if timeout is not None). """ # Fast path: check if data is already available (no overhead) if pipe.poll(0): return pipe.recv() # Slow path: wait with periodic health checks elapsed = 0.0 while timeout is None or elapsed < timeout: if pipe.poll(check_interval): return pipe.recv() elapsed += check_interval # Check if any worker has died _check_for_faulty_process(self.procs) torchrl_logger.debug( f"MultiCollector._recv_and_check: Still waiting after {elapsed:.1f}s" + (f" for worker {worker_idx}" if worker_idx is not None else "") ) # Final check before timeout _check_for_faulty_process(self.procs) worker_info = f" from worker {worker_idx}" if worker_idx is not None else "" raise TimeoutError( f"Timed out after {timeout}s waiting for message{worker_info}. " f"All workers are still alive - this may indicate a deadlock or very slow operation." ) def _run_processes(self) -> None: if self.num_threads is None: total_workers = self._total_workers_from_env(self.create_env_fn) self.num_threads = max( 1, torch.get_num_threads() - total_workers ) # 1 more thread for this proc # Set up for worker processes torch.set_num_threads(self.num_threads) ctx = _get_mp_ctx() # Best-effort global init (only if unset) to keep other mp users consistent. _set_mp_start_method_if_unset(ctx.get_start_method()) if sys.platform == "linux" and ctx.get_start_method() == "spawn": # On older PyTorch versions (< 2.8), pickling Process objects for "spawn" # can pass file descriptors for shared storages, causing spawn-time failures. # The strategy function returns "file_system" for old PyTorch, None otherwise. strategy = _mp_sharing_strategy_for_spawn() if strategy is not None: mp.set_sharing_strategy(strategy) queue_out = ctx.Queue(self._queue_len) # sends data from proc to main self.queue_out = queue_out self.procs = [] self._traj_pool = _TrajectoryPool(ctx=ctx, lock=True) # Workers and coordinator each get a single-writer row. The extra # coordinator row accounts for trajectory assembly performed by this # process when no replay buffer is attached. self._collector_progress = _CollectorProgress(self.num_workers + 1, ctx=ctx) self._collector_progress_worker_idx = self.num_workers self._collector_progress_aggregate = True self._collector_progress_pending_on_ingest = True # Create all pipes upfront (needed for weight sync scheme initialization) # Store as list of (parent, child) tuples for use in worker creation pipe_pairs = [ctx.Pipe() for _ in range(self.num_workers)] # Extract parent pipes for external use (e.g., polling, receiving messages) self.pipes = [pipe_parent for pipe_parent, _ in pipe_pairs] _ProcessNoWarnCtx = _make_process_no_warn_cls(ctx) # Initialize all weight sync schemes now that pipes are available # Both SharedMemWeightSyncScheme (uses queues) and MultiProcessWeightSyncScheme (uses pipes) # can be initialized here since all required resources exist if self._weight_sync_schemes: for model_id, scheme in self._weight_sync_schemes.items(): if not scheme.initialized_on_sender: torchrl_logger.debug( f"Init weight sync scheme {type(scheme).__name__} for {model_id=}." ) scheme.init_on_sender(model_id=model_id, context=self, ctx=ctx) # Create a policy on the right device policy_factory = self.policy_factory has_policy_factory = any(policy_factory) if has_policy_factory: policy_factory = [ CloudpickleWrapper(_policy_factory) for _policy_factory in policy_factory ] for i, (env_fun, env_fun_kwargs) in enumerate( zip(self.create_env_fn, self.create_env_kwargs) ): pipe_parent, pipe_child = pipe_pairs[i] # use pre-created pipes if env_fun.__class__.__name__ != "EnvCreator" and not isinstance( env_fun, EnvBase ): # to avoid circular imports env_fun = CloudpickleWrapper(env_fun) policy_device = self.policy_device[i] storing_device = self.storing_device[i] env_device = self.env_device[i] # Prepare policy for worker based on weight synchronization method. # IMPORTANT: when a policy_factory is provided, the policy instance # is used ONLY on the main process (for weight extraction etc.) and # is NOT sent to workers. policy = self.policy if self._weight_sync_schemes: # With weight sync schemes, send stateless policies. # Schemes handle weight distribution on worker side. if has_policy_factory: # Factory will create policy in worker; don't send policy. policy_to_send = None cm = contextlib.nullcontext() elif policy is not None: # Send a stateless policy down to workers: schemes apply weights. policy_to_send = policy cm = _make_meta_policy_cm( policy, mp_start_method=ctx.get_start_method() ) else: policy_to_send = None cm = contextlib.nullcontext() elif hasattr(self, "_policy_weights_dict"): # LEGACY: # With weight updater, use in-place weight replacement. # Take the weights and locally dispatch them to the policy before sending. # This ensures a given set of shared weights for a device are shared # for all policies that rely on that device. policy_weights = self._policy_weights_dict.get(policy_device) if has_policy_factory: # Even in legacy mode, when a policy_factory is present, do not # send the stateful policy down to workers. policy_to_send = None cm = contextlib.nullcontext() else: policy_to_send = policy if policy is not None and policy_weights is not None: cm = policy_weights.to_module( policy, preserve_module_state=False ) else: cm = contextlib.nullcontext() else: # Parameter-less policy. cm = contextlib.nullcontext() # When a policy_factory exists, never send the policy instance. policy_to_send = None if has_policy_factory else policy with cm: kwargs = { "policy_factory": policy_factory[i], "pipe_child": pipe_child, "queue_out": queue_out, "create_env_fn": env_fun, "create_env_kwargs": env_fun_kwargs, "policy": policy_to_send, "max_frames_per_traj": self.max_frames_per_traj, "frames_per_batch": self.frames_per_batch_worker(worker_idx=i), "reset_at_each_iter": self.reset_at_each_iter, "policy_device": policy_device, "storing_device": storing_device, "env_device": env_device, "exploration_type": self.exploration_type, "reset_when_done": self.reset_when_done, "idx": i, "interruptor": self.interruptor, "set_truncated": self.set_truncated, "use_buffers": self._use_buffers, "replay_buffer": self.replay_buffer, "extend_buffer": self.extend_buffer, "traj_pool": self._traj_pool, "trust_policy": self.trust_policy, "compile_policy": ( self.compiled_policy_kwargs if self.compiled_policy else False ), "cudagraph_policy": ( self.cudagraphed_policy_kwargs if self.cudagraphed_policy else False ), "no_cuda_sync": self.no_cuda_sync, "collector_class": self.collector_class, "postproc": ( self.postproc if self.replay_buffer is not None else None ), "weight_sync_schemes": self._weight_sync_schemes, "worker_idx": i, # Worker index for queue-based weight distribution "init_random_frames": self.init_random_frames, "trajs_per_batch": self._worker_trajs_per_batch, "trajs_per_write": self.trajs_per_write, "replay_write_mode": self._worker_replay_write_mode, "init_fn": self._worker_init_fn, "auto_register_policy_transforms": self._auto_register_policy_transforms, "track_policy_version": self.policy_version_tracker is not None, "pre_collect_hook": self._worker_pre_collect_hook, "post_collect_hook": self._worker_post_collect_hook, "compact_obs": self.compact_obs, "collector_progress": self._collector_progress, } proc = _ProcessNoWarnCtx( target=_main_async_collector, num_threads=self.num_sub_threads, _start_method=ctx.get_start_method(), kwargs=kwargs, ) # proc.daemon can't be set as daemonic processes may be launched by the process itself try: proc.start() except TypeError as err: if "cannot pickle" in str(err): raise RuntimeError( "A non-serializable object was passed to the collector workers." ) from err except RuntimeError as err: if "Cowardly refusing to serialize non-leaf tensor" in str(err): raise RuntimeError( "At least one of the tensors in the policy, replay buffer, environment constructor or postprocessor requires gradients. " "This is not supported in multiprocessed data collectors.\n- For ReplayBuffer transforms, use a `transform_factory` instead with `delayed_init=True`.\n" "- Make sure your environment constructor does not reference tensors already instantiated on the main process.\n" "- Since no gradient can be propagated through the Collector pipes, the backward graph is never needed. Consider using detached tensors instead." ) from err elif "_share_fd_: only available on CPU" in str( err ) or "_share_filename_: only available on CPU" in str(err): # This is a common failure mode on older PyTorch versions when using the # "spawn" multiprocessing start method: the process object contains a # CUDA/MPS tensor (or a module/buffer on a non-CPU device), which must be # pickled when spawning workers. # # See: https://github.com/pytorch/pytorch/issues/87688#issuecomment-1968901877 start_method = None try: start_method = mp.get_start_method(allow_none=True) except Exception: # Best effort: some environments may disallow querying here. start_method = None raise RuntimeError( "Failed to start a collector worker process because a non-CPU tensor " "was captured in the worker process arguments and had to be serialized " "(pickled) at process start.\n\n" f"Detected multiprocessing start method: {start_method!r}.\n\n" "Workarounds:\n" "- Keep any tensors/modules referenced by your collector constructor " "(policy, replay buffer, postprocs, env factory captures, etc.) on CPU " "when using a spawning start method (common on macOS/Windows).\n" "- Or set the multiprocessing start method to 'fork' *before* creating " "the collector (Unix only). Example:\n\n" " import torch.multiprocessing as mp\n" " if __name__ == '__main__':\n" " mp.set_start_method('fork', force=True)\n\n" "Upstream context: https://github.com/pytorch/pytorch/issues/87688#issuecomment-1968901877" ) from err else: raise err except ValueError as err: if "bad value(s) in fds_to_keep" in str(err): # This error occurs on old Python versions (e.g., 3.9) with old PyTorch (e.g., 2.3) # when using the spawn multiprocessing start method. The spawn implementation tries to # preserve file descriptors across exec, but some descriptors may be invalid/closed. # This is a compatibility issue with old Python multiprocessing implementations. python_version = ( f"{sys.version_info.major}.{sys.version_info.minor}" ) raise RuntimeError( f"Failed to start collector worker process due to file descriptor issues " f"with spawn multiprocessing on Python {python_version}.\n\n" f"This is a known compatibility issue with old Python/PyTorch stacks. " f"Consider upgrading to Python >= 3.10 and PyTorch >= 2.5, or use the 'fork' " f"multiprocessing start method on Unix systems.\n\n" f"Workarounds:\n" f"- Upgrade Python to >= 3.10 and PyTorch to >= 2.5\n" f"- On Unix systems, force fork start method:\n" f" import torch.multiprocessing as mp\n" f" if __name__ == '__main__':\n" f" mp.set_start_method('fork', force=True)\n\n" f"Upstream Python issue: https://github.com/python/cpython/issues/87706" ) from err except _pickle.PicklingError as err: if "<lambda>" in str(err): raise RuntimeError( """Can't open a process with doubly cloud-pickled lambda function. This error is likely due to an attempt to use a ParallelEnv in a multiprocessed data collector. To do this, consider wrapping your lambda function in an `torchrl.envs.EnvCreator` wrapper as follows: `env = ParallelEnv(N, EnvCreator(my_lambda_function))`. This will not only ensure that your lambda function is cloud-pickled once, but also that the state dict is synchronised across processes if needed.""" ) from err pipe_child.close() self.procs.append(proc) # Synchronize initial weights with workers AFTER starting processes but BEFORE waiting for "instantiated" # This must happen after proc.start() but before workers send "instantiated" to avoid deadlock: # Workers will call receiver.collect() during init and may block waiting for data if self._weight_sync_schemes: # start with policy policy_scheme = self._weight_sync_schemes.get("policy") if policy_scheme is not None: policy_scheme.connect() for key, scheme in self._weight_sync_schemes.items(): if key == "policy": continue scheme.connect() # Wait for workers to be ready for i, pipe_parent in enumerate(self.pipes): pipe_parent.poll(timeout=INSTANTIATE_TIMEOUT) try: msg = pipe_parent.recv() except EOFError as e: raise RuntimeError( f"Worker {i} failed to initialize and closed the connection before sending status. " f"This typically indicates that the worker process crashed during initialization. " f"Check the worker process logs for the actual error." ) from e if msg != "instantiated": # Check if it's an error dict from worker if isinstance(msg, dict) and msg.get("error"): # Reconstruct the exception from the worker exc_type_name = msg["exception_type"] exc_msg = msg["exception_msg"] traceback_str = msg["traceback"] # Try to get the actual exception class exc_class = None exc_module = msg["exception_module"] if exc_module == "builtins": # Get from builtins import builtins exc_class = getattr(builtins, exc_type_name, None) else: # Try to import from the module try: import importlib mod = importlib.import_module(exc_module) exc_class = getattr(mod, exc_type_name, None) except Exception: pass # Re-raise with original exception type if possible if exc_class is not None: raise exc_class( f"{exc_msg}\n\nWorker traceback:\n{traceback_str}" ) else: # Fall back to RuntimeError if we can't get the original type raise RuntimeError( f"Worker {i} raised {exc_type_name}: {exc_msg}\n\nWorker traceback:\n{traceback_str}" ) else: # Legacy string error message raise RuntimeError(msg) self.queue_out = queue_out self.closed = False _running_free = False
[docs] def start(self): """Starts the collector(s) for asynchronous data collection. The collected data is stored in the provided replay buffer. This method initiates the background collection of data across multiple processes, allowing for decoupling of data collection and training. Raises: RuntimeError: If no replay buffer is defined during the collector's initialization. Example: >>> from torchrl.modules import RandomPolicy >>> >>> import time >>> from functools import partial >>> >>> import tqdm >>> >>> from torchrl.collectors import MultiAsyncCollector >>> from torchrl.data import LazyTensorStorage, ReplayBuffer >>> from torchrl.envs import GymEnv, set_gym_backend >>> import ale_py >>> >>> # Set the gym backend to gymnasium >>> set_gym_backend("gymnasium").set() >>> >>> if __name__ == "__main__": ... # Create a random policy for the Pong environment ... env_fn = partial(GymEnv, "ALE/Pong-v5") ... policy = RandomPolicy(env_fn().action_spec) ... ... # Initialize a shared replay buffer ... rb = ReplayBuffer(storage=LazyTensorStorage(10000), shared=True) ... ... # Create a multi-async data collector with 16 environments ... num_envs = 16 ... collector = MultiAsyncCollector( ... [env_fn] * num_envs, ... policy=policy, ... replay_buffer=rb, ... frames_per_batch=num_envs * 16, ... total_frames=-1, ... ) ... ... # Progress bar to track the number of collected frames ... pbar = tqdm.tqdm(total=100_000) ... ... # Start the collector asynchronously ... collector.start() ... ... # Track the write count of the replay buffer ... prec_wc = 0 ... while True: ... wc = rb.write_count ... c = wc - prec_wc ... prec_wc = wc ... ... # Update the progress bar ... pbar.update(c) ... pbar.set_description(f"Write Count: {rb.write_count}") ... ... # Check the write count every 0.5 seconds ... time.sleep(0.5) ... ... # Stop when the desired number of frames is reached ... if rb.write_count . 100_000: ... break ... ... # Shut down the collector ... collector.async_shutdown() """ if self.replay_buffer is None: raise RuntimeError("Replay buffer must be defined for execution.") self._running_free = True torchrl_logger.debug( f"MultiCollector.start(): Sending run_free to {len(self.pipes)} workers..." ) for i, pipe in enumerate(self.pipes): pipe.send((None, "run_free")) torchrl_logger.debug(f"MultiCollector.start(): Sent run_free to worker {i}")
[docs] @contextlib.contextmanager def pause(self): """Context manager that pauses the collector if it is running free.""" if self._running_free: for pipe in self.pipes: pipe.send((None, "pause")) # Make sure all workers are paused for i in range(len(self.pipes)): # Use timeout with health check to avoid hanging if a worker dies timeout = 30.0 check_interval = 1.0 elapsed = 0.0 while elapsed < timeout: try: idx, msg = self.queue_out.get(timeout=check_interval) break except Exception: elapsed += check_interval _check_for_faulty_process(self.procs) else: _check_for_faulty_process(self.procs) raise TimeoutError( f"Timed out waiting for worker {i} to pause after {timeout}s" ) if msg != "paused": raise ValueError(f"Expected paused, but got {msg=}.") torchrl_logger.debug(f"Worker {idx} is paused.") self._running_free = False yield None for pipe in self.pipes: pipe.send((None, "restart")) self._running_free = True else: raise RuntimeError("Collector cannot be paused.")
def _install_profile_hooks(self, config: ProfileConfig) -> None: """Install per-worker :class:`_ProfilerHook` on each selected worker. Each worker gets its own ``_ProfilerHook(config, worker_idx=idx)`` wrapped in :class:`CloudpickleWrapper` and pushed via the existing ``"setattr"`` pipe message. Workers not in ``config.workers`` are left untouched. """ if not hasattr(self, "pipes") or getattr(self, "closed", True): raise RuntimeError( "MultiCollector workers are not running; call enable_profile() " "before close()/shutdown()." ) _check_for_faulty_process(self.procs) targeted = [idx for idx in config.workers if idx < self.num_workers] for idx in targeted: hook = CloudpickleWrapper(_ProfilerHook(config, worker_idx=idx)) self.pipes[idx].send((("post_collect_hook", hook), "setattr")) for idx in targeted: result, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "setattr": raise RuntimeError(f"Worker {idx}: Expected 'setattr' ack, got {msg}") if isinstance(result, Exception): raise result def _uninstall_profile_hooks(self, config: ProfileConfig) -> None: """Stop the per-worker profiler hooks and clear ``post_collect_hook``.""" if not hasattr(self, "pipes") or getattr(self, "closed", True): return targeted = [idx for idx in config.workers if idx < self.num_workers] # Stop the inner profiler — early-stop is harmless if it already auto-stopped. for idx in targeted: self.pipes[idx].send( (("post_collect_hook.fn.stop", (), {}), "cascade_execute") ) for idx in targeted: try: result, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "cascade_execute": raise RuntimeError( f"Worker {idx}: Expected 'cascade_execute' ack, got {msg}" ) if isinstance(result, Exception): raise result except Exception: # Swallow per-worker stop errors — we still want to clear the hook below. pass # Clear the hook on each targeted worker. for idx in targeted: self.pipes[idx].send((("post_collect_hook", None), "setattr")) for idx in targeted: self._recv_and_check(self.pipes[idx], worker_idx=idx) def _set_worker_attr(self, attr_name: str, value: Any) -> None: if not hasattr(self, "pipes") or getattr(self, "closed", True): return _check_for_faulty_process(self.procs) for pipe in self.pipes: pipe.send(((attr_name, value), "setattr")) for idx, pipe in enumerate(self.pipes): result, msg = self._recv_and_check(pipe, worker_idx=idx) if msg != "setattr": raise RuntimeError(f"Expected msg='setattr', got {msg}") if isinstance(result, Exception): raise result @property def pre_collect_hook(self) -> Callable[[], None] | None: return self._pre_collect_hook @pre_collect_hook.setter def pre_collect_hook(self, hook: Callable[[], None] | None) -> None: self._pre_collect_hook = hook self._worker_pre_collect_hook = ( CloudpickleWrapper(hook) if hook is not None else None ) self._set_worker_attr("pre_collect_hook", self._worker_pre_collect_hook) @property def post_collect_hook(self) -> Callable[[TensorDictBase], None] | None: return self._post_collect_hook @post_collect_hook.setter def post_collect_hook(self, hook: Callable[[TensorDictBase], None] | None) -> None: self._post_collect_hook = hook self._worker_post_collect_hook = ( CloudpickleWrapper(hook) if hook is not None else None ) self._set_worker_attr("post_collect_hook", self._worker_post_collect_hook) def _normalize_worker_calls( self, list_of_args: list[tuple] | None = None, list_of_kwargs: list[dict] | None = None, ) -> tuple[list[tuple], list[dict]]: if list_of_args is None and list_of_kwargs is None: list_of_args = [()] * self.num_workers list_of_kwargs = [{}] * self.num_workers elif list_of_args is None: list_of_args = [()] * len(list_of_kwargs) elif list_of_kwargs is None: list_of_kwargs = [{}] * len(list_of_args) if len(list_of_args) != self.num_workers: raise ValueError( f"Expected {self.num_workers} argument entries, got {len(list_of_args)}." ) if len(list_of_kwargs) != self.num_workers: raise ValueError( f"Expected {self.num_workers} keyword-argument entries, got {len(list_of_kwargs)}." ) return list_of_args, list_of_kwargs
[docs] def map_fn( self, method_name: str, list_of_args: list[tuple] | None = None, list_of_kwargs: list[dict] | None = None, ) -> list[Any]: """Apply a method to each worker collector.""" list_of_args, list_of_kwargs = self._normalize_worker_calls( list_of_args, list_of_kwargs ) _check_for_faulty_process(self.procs) for pipe, args, kwargs in zip(self.pipes, list_of_args, list_of_kwargs): pipe.send(((method_name, args, kwargs), "cascade_execute")) results = [] for idx, pipe in enumerate(self.pipes): result, msg = self._recv_and_check(pipe, worker_idx=idx) if msg != "cascade_execute": raise RuntimeError(f"Expected msg='cascade_execute', got {msg}") if isinstance(result, Exception): raise result results.append(result) return results
[docs] def get_distant_attr(self, attr: str) -> list[Any]: """Get a nested attribute from each worker collector.""" _check_for_faulty_process(self.procs) for pipe in self.pipes: pipe.send((attr, "get_distant_attr")) results = [] for idx, pipe in enumerate(self.pipes): result, msg = self._recv_and_check(pipe, worker_idx=idx) if msg != "get_distant_attr": raise RuntimeError(f"Expected msg='get_distant_attr', got {msg}") if isinstance(result, Exception): raise result results.append(result) return results
def __del__(self): try: self.shutdown() except Exception: # an AttributeError will typically be raised if the collector is deleted when the program ends. # In the future, insignificant changes to the close method may change the error type. # We excplicitely assume that any error raised during closure in # __del__ will not affect the program. pass
[docs] def shutdown( self, timeout: float | None = None, close_env: bool = True, raise_on_error: bool = True, ) -> None: """Shuts down all processes. This operation is irreversible. Args: timeout (float, optional): The timeout for closing pipes between workers. close_env (bool, optional): Whether to close the environment. Defaults to `True`. raise_on_error (bool, optional): Whether to raise an error if the shutdown fails. Defaults to `True`. """ if not close_env: raise RuntimeError( f"Cannot shutdown {type(self).__name__} collector without environment being closed." ) try: self._shutdown_main(timeout) except Exception as e: if raise_on_error: raise e else: pass
def _shutdown_main(self, timeout: float | None = None) -> None: if timeout is None: timeout = 10 if timeout < 0: raise ValueError(f"timeout must be non-negative, got {timeout}.") if getattr(self, "_shutdown_complete", False): return # Claim shutdown before touching any IPC resource. This makes repeated # calls no-ops even when the first call finds an already-dead worker or # a pipe that the worker has closed. self.closed = True started_at = time.monotonic() # Reserve part of the caller's single deadline for forced cleanup: # terminate after 80%, then retain the final 5% for a kill fallback. graceful_timeout = timeout * 0.8 terminate_timeout = timeout * 0.95 procs = getattr(self, "procs", ()) pipes = getattr(self, "pipes", ()) try: for proc, pipe in zip(procs, pipes): if proc._closed or not proc.is_alive(): continue try: pipe.send((None, "close")) except (EOFError, OSError, ValueError): # The worker may already have observed the parent closing # its end of the channel. It will be joined below. pass # Workers acknowledge "close" on their control pipes, but process # exit is the shutdown contract; draining those replies would spend # the same bounded deadline without improving teardown guarantees. pending = { idx for idx, proc in enumerate(procs) if not proc._closed and proc.is_alive() } while pending and time.monotonic() - started_at < graceful_timeout: for idx in tuple(pending): proc = procs[idx] if proc._closed or not proc.is_alive(): pending.remove(idx) continue remaining = graceful_timeout - (time.monotonic() - started_at) if remaining <= 0: break proc.join(timeout=min(0.05, remaining)) if proc._closed or not proc.is_alive(): pending.remove(idx) stragglers = [ proc for proc in procs if not proc._closed and proc.is_alive() ] for proc in stragglers: try: proc.terminate() except (OSError, ValueError): pass for proc in stragglers: remaining = terminate_timeout - (time.monotonic() - started_at) proc.join(timeout=max(remaining, 0.0)) stragglers = [ proc for proc in procs if not proc._closed and proc.is_alive() ] for proc in stragglers: try: proc.kill() except (AttributeError, OSError, ValueError): # ``kill`` is unavailable on older Python versions. The # earlier terminate request still bounds the parent call. pass for proc in stragglers: remaining = timeout - (time.monotonic() - started_at) proc.join(timeout=max(remaining, 0.0)) finally: import torchrl queue_out = getattr(self, "queue_out", None) if queue_out is not None: try: queue_out.close() except (OSError, ValueError): pass for pipe in pipes: try: pipe.close() except (OSError, ValueError): pass num_threads = min( torchrl._THREAD_POOL_INIT, torch.get_num_threads() + self._total_workers_from_env(self.create_env_fn), ) torch.set_num_threads(num_threads) progress = getattr(self, "_collector_progress", None) if progress is not None: progress.clear_pending() self._shutdown_complete = True
[docs] def async_shutdown(self, timeout: float | None = None): return self.shutdown(timeout=timeout)
[docs] def set_seed(self, seed: int, static_seed: bool = False) -> int: """Sets the seeds of the environments stored in the DataCollector. Args: seed: integer representing the seed to be used for the environment. static_seed (bool, optional): if ``True``, the seed is not incremented. Defaults to False Returns: Output seed. This is useful when more than one environment is contained in the DataCollector, as the seed will be incremented for each of these. The resulting seed is the seed of the last environment. Examples: >>> from torchrl.envs import ParallelEnv >>> from torchrl.envs.libs.gym import GymEnv >>> from tensordict.nn import TensorDictModule >>> from torch import nn >>> env_fn = lambda: GymEnv("Pendulum-v1") >>> env_fn_parallel = lambda: ParallelEnv(6, env_fn) >>> policy = TensorDictModule(nn.Linear(3, 1), in_keys=["observation"], out_keys=["action"]) >>> collector = Collector(env_fn_parallel, policy, frames_per_batch=100, total_frames=300) >>> out_seed = collector.set_seed(1) # out_seed = 6 """ _check_for_faulty_process(self.procs) for idx in range(self.num_workers): self.pipes[idx].send(((seed, static_seed), "seed")) new_seed, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "seeded": raise RuntimeError(f"Expected msg='seeded', got {msg}") seed = new_seed self.reset() return seed
[docs] def reset(self, reset_idx: Sequence[bool] | None = None) -> None: """Resets the environments to a new initial state. Args: reset_idx: Optional. Sequence indicating which environments have to be reset. If None, all environments are reset. """ _check_for_faulty_process(self.procs) # drop parent-level trajectory-assembly state (trajs_per_batch # without a replay buffer assembles trajectories on this process) self._flush_trajectory_assembly() if reset_idx is None: reset_idx = [True for _ in range(self.num_workers)] for idx in range(self.num_workers): if reset_idx[idx]: self.pipes[idx].send((None, "reset")) for idx in range(self.num_workers): if reset_idx[idx]: j, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "reset": raise RuntimeError(f"Expected msg='reset', got {msg}")
[docs] def state_dict(self) -> OrderedDict: """Returns the state_dict of the data collector. Each field represents a worker containing its own state_dict. """ for idx in range(self.num_workers): self.pipes[idx].send((None, "state_dict")) state_dict = OrderedDict() traj_pool_state = None for idx in range(self.num_workers): _state_dict, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "state_dict": raise RuntimeError(f"Expected msg='state_dict', got {msg}") worker_traj_pool_state = _state_dict.pop("traj_pool", None) if traj_pool_state is None and worker_traj_pool_state is not None: traj_pool_state = worker_traj_pool_state state_dict[f"worker{idx}"] = _state_dict state_dict.update( { "frames": self._frames, "iter": self._iter, "collector_progress_coordinator": self._progress_state_dict(), } ) if traj_pool_state is not None: state_dict["traj_pool"] = traj_pool_state if self.policy_version_tracker is not None: state_dict["policy_version"] = self.policy_version return state_dict
[docs] def load_state_dict(self, state_dict: OrderedDict) -> None: """Loads the state_dict on the workers. Args: state_dict (OrderedDict): state_dict of the form ``{"worker0": state_dict0, "worker1": state_dict1}``. """ traj_pool_state = state_dict.get("traj_pool") if traj_pool_state is not None: self._traj_pool.load_state_dict(traj_pool_state) for idx in range(self.num_workers): self.pipes[idx].send((state_dict[f"worker{idx}"], "load_state_dict")) for idx in range(self.num_workers): _, msg = self._recv_and_check(self.pipes[idx], worker_idx=idx) if msg != "loaded": raise RuntimeError(f"Expected msg='loaded', got {msg}") self._frames = state_dict["frames"] self._iter = state_dict["iter"] self._load_progress_state_dict(state_dict.get("collector_progress_coordinator")) if "policy_version" in state_dict and self.policy_version_tracker is not None: policy_version = state_dict["policy_version"] self._acknowledged_policy_version = policy_version if policy_version is not None: self.policy_version_tracker.version = policy_version
[docs] def increment_version(self): """Increment the policy version.""" if self.policy_version_tracker is not None: if not hasattr(self.policy_version_tracker, "increment_version"): raise RuntimeError( "Policy version tracker is not a PolicyVersion instance. Please pass a PolicyVersion instance to the collector." ) self.policy_version_tracker.increment_version() self._acknowledged_policy_version = self.policy_version_tracker.version
@property def policy_version(self) -> str | int | None: """The policy version acknowledged by every worker. For multi-process collectors, worker-local :class:`~torchrl.envs.transforms.PolicyVersion` transforms write the per-frame ``"policy_version"`` values in returned batches and remain the source of truth for collected data. This property returns ``None`` after a partial-worker or asynchronous update because no single scalar is then known to describe every worker. Worker-local versions can be queried explicitly with :meth:`worker_policy_versions`. """ return self._acknowledged_policy_version
[docs] def get_policy_version(self) -> str | int | None: """Get the policy version acknowledged by every worker. This method exists to support remote calls in Ray actors, since properties cannot be accessed directly through Ray's RPC mechanism. Returns: The aggregate version number (int) or UUID (str), or ``None`` if version tracking is disabled or workers may differ. """ return self.policy_version
[docs] def worker_policy_versions(self) -> dict[int, str | int | None]: """Query the policy version currently reported by each worker. Unlike :attr:`policy_version`, this method performs worker RPCs and can expose divergent versions after partial updates. It shares the worker control channels with other coordinator commands and therefore should not race with weight updates issued from another thread. Returns: A mapping from worker index to its local policy version. Values are ``None`` when policy-version tracking is disabled. """ versions = dict(enumerate(self.map_fn("get_policy_version"))) if getattr(self, "running", False): # map_fn has consumed every worker reply, so the control pipes are # ready for the command that resumes asynchronous collection. msg = "continue_random" if self._should_use_random_frames() else "continue" for idx, pipe in enumerate(self.pipes): pipe.send((idx, msg)) return versions
[docs] def getattr_policy(self, attr): """Get an attribute from the policy of the first worker. Args: attr (str): The attribute name to retrieve from the policy. Returns: The attribute value from the policy of the first worker. Raises: AttributeError: If the attribute doesn't exist on the policy. """ _check_for_faulty_process(self.procs) # Send command to first worker (index 0) self.pipes[0].send((attr, "getattr_policy")) result, msg = self._recv_and_check(self.pipes[0], worker_idx=0) if msg != "getattr_policy": raise RuntimeError(f"Expected msg='getattr_policy', got {msg}") # If the worker returned an AttributeError, re-raise it if isinstance(result, AttributeError): raise result return result
[docs] def getattr_env(self, attr): """Get an attribute from the environment of the first worker. Args: attr (str): The attribute name to retrieve from the environment. Returns: The attribute value from the environment of the first worker. Raises: AttributeError: If the attribute doesn't exist on the environment. """ _check_for_faulty_process(self.procs) # Send command to first worker (index 0) self.pipes[0].send((attr, "getattr_env")) result, msg = self._recv_and_check(self.pipes[0], worker_idx=0) if msg != "getattr_env": raise RuntimeError(f"Expected msg='getattr_env', got {msg}") # If the worker returned an AttributeError, re-raise it if isinstance(result, AttributeError): raise result return result
[docs] def getattr_rb(self, attr): """Get an attribute from the replay buffer.""" return getattr(self.replay_buffer, attr)
[docs] def get_model(self, model_id: str): """Get model instance by ID (for weight sync schemes). Args: model_id: Model identifier (e.g., "policy", "value_net") Returns: The model instance Raises: ValueError: If model_id is not recognized """ if model_id == "policy": # Return the fallback policy instance if (fallback_policy := getattr(self, "_fallback_policy", None)) is not None: return fallback_policy elif hasattr(self, "policy") and self.policy is not None: return self.policy else: raise ValueError(f"No policy found for model_id '{model_id}'") else: # Try to resolve via attribute access return _resolve_model(self, model_id)
[docs] def get_cached_weights(self, model_id: str): """Get cached shared memory weights if available (for weight sync schemes). Args: model_id: Model identifier Returns: Cached TensorDict weights or None if not available """ if model_id == "policy" and hasattr(self, "_policy_weights_dict"): # Get the policy device (first device if list) policy_device = self.policy_device if isinstance(policy_device, (list, tuple)): policy_device = policy_device[0] if len(policy_device) > 0 else None # Return cached weights for this device return self._policy_weights_dict.get(policy_device) return None
def _weight_update_impl( self, policy_or_weights: TensorDictBase | nn.Module | dict | None = None, *, worker_ids: int | list[int] | torch.device | list[torch.device] | None = None, model_id: str | None = None, weights_dict: dict[str, Any] | None = None, **kwargs, ) -> None: """Update weights on workers. Weight sync schemes now use background threads on the receiver side. The scheme's send() method: 1. Puts weights in the queue (or updates shared memory) 2. Sends a "receive" instruction to the worker's background thread 3. Waits for acknowledgment (if sync=True) No pipe signaling is needed - the scheme handles everything internally. """ # Call parent implementation which calls scheme.send() # The scheme handles instruction delivery and acknowledgments super()._weight_update_impl( policy_or_weights=policy_or_weights, worker_ids=worker_ids, model_id=model_id, weights_dict=weights_dict, **kwargs, ) # for RPC
[docs] def receive_weights(self, policy_or_weights: TensorDictBase | None = None): return super().receive_weights(policy_or_weights)
# for RPC def _receive_weights_scheme(self, model_version: int | None = None): return super()._receive_weights_scheme(model_version=model_version)
# Backward-compatible alias (deprecated, use MultiCollector instead) MultiCollector = MultiCollector