Rate this Page
★ ★ ★ ★ ★

Source code for torchrl.data.replay_buffers.dataloader

# 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

from collections.abc import Mapping
from itertools import islice
from typing import Any, TYPE_CHECKING

import torch
from tensordict import is_tensor_collection, LazyStackedTensorDict
from torch.utils.data import Dataset, get_worker_info, IterableDataset
from torch.utils.data._utils.collate import default_collate

if TYPE_CHECKING:
    from torch.utils.data._utils.worker import WorkerInfo

    from torchrl.data.replay_buffers.replay_buffers import ReplayBuffer
    from torchrl.data.replay_buffers.storages import Storage

__all__ = ["ReplayBufferDataset", "StorageDataset", "tensordict_collate"]


[docs] def tensordict_collate(batch: Any) -> Any: """Collate function for a :class:`torch.utils.data.DataLoader` reading TorchRL storages or buffers. A batch that is already a tensor, a tensor collection or a tuple of them, as fetched by :class:`StorageDataset` or yielded by :class:`ReplayBufferDataset`, is returned unchanged. A list of samples, as produced by per-item storages or by composing datasets with :class:`torch.utils.data.ConcatDataset`, is stacked: tensor collections lazily when their shapes differ, tensors densely, and mappings or tuples element-wise. The default torch collation iterates a tensordict over its batch dimension and cannot be used. Args: batch (Tensor, TensorDictBase or list): the fetched batch. Examples: >>> import torch >>> from tensordict import TensorDict >>> from torch.utils.data import DataLoader >>> from torchrl.data import LazyTensorStorage, ReplayBuffer, tensordict_collate >>> rb = ReplayBuffer(storage=LazyTensorStorage(100)) >>> _ = rb.extend(TensorDict({"obs": torch.arange(100)}, [100])) >>> loader = DataLoader( ... rb.storage.as_dataset(), ... batch_size=4, ... shuffle=True, ... collate_fn=tensordict_collate, ... ) >>> next(iter(loader))["obs"].shape torch.Size([4]) """ if not isinstance(batch, list) or not batch: return batch first = batch[0] if is_tensor_collection(first): return LazyStackedTensorDict.maybe_dense_stack(batch) if isinstance(first, torch.Tensor): return torch.stack(batch) if isinstance(first, Mapping): return {key: tensordict_collate([item[key] for item in batch]) for key in first} if isinstance(first, tuple): return tuple(tensordict_collate(list(items)) for items in zip(*batch)) return default_collate(batch)
def _check_fork_safe(storage: Storage) -> None: if not storage._fork_safe(): raise RuntimeError( f"This DataLoader worker was forked with a private copy of a " f"{type(storage).__name__} allocated after the dataset was created, " "and would read stale rows. Create the dataset once the storage " "holds data, or use a LazyMemmapStorage or the spawn start method." )
[docs] class StorageDataset(Dataset): """A map-style :class:`torch.utils.data.Dataset` reading a TorchRL storage. The dataset has one item per storage entry and a :class:`torch.utils.data.DataLoader` reads it with any torch sampler and worker processes. ``__getitems__`` fetches an index batch with a single :meth:`~torchrl.data.replay_buffers.Storage.get` call, so the loader receives one batch rather than a list of items: pass :func:`tensordict_collate` as ``collate_fn``, which returns such batches unchanged and stacks lists of items. Storages with more than one dimension are read through :meth:`~torchrl.data.replay_buffers.Storage.flatten`. Reading the storage directly with a DataLoader, without this adapter, fetches items one by one and passes the list to the collate function. DataLoader workers read the storage content live: by default, creating the dataset moves CPU tensor storage to shared memory and memory-mapped storages are read through their files, so rows written after the workers start are visible to them under every start method. Reads are not synchronized with writes, and a row written while a worker reads it can come back partially updated. A :class:`~torchrl.data.replay_buffers.ListStorage` cannot be sent to spawned workers and forked workers read a snapshot of it. Only the storage is sent to the workers, not the buffers attached to it. Args: storage (Storage): the storage to read. Must be one-dimensional. Keyword Args: share_memory (bool, optional): whether to move CPU tensor storage to shared memory when creating the dataset, so DataLoader workers observe later writes. Defaults to ``True``. Set to ``False`` for reads in the calling process or when storage sharing is already arranged. Examples: >>> import torch >>> from tensordict import TensorDict >>> from torch.utils.data import DataLoader >>> from torchrl.data import LazyTensorStorage, ReplayBuffer, tensordict_collate >>> rb = ReplayBuffer(storage=LazyTensorStorage(100)) >>> _ = rb.extend(TensorDict({"obs": torch.arange(100)}, [100])) >>> dataset = rb.storage.as_dataset() >>> len(dataset) 100 >>> loader = DataLoader( ... dataset, batch_size=4, shuffle=True, collate_fn=tensordict_collate ... ) >>> next(iter(loader))["obs"].shape torch.Size([4]) """ def __init__(self, storage: Storage, *, share_memory: bool = True) -> None: if storage.ndim > 1: raise RuntimeError( f"A {type(storage).__name__} with ndim={storage.ndim} is not a " "flat dataset. Read storage.flatten().as_dataset() instead." ) if share_memory: storage._share_memory_() self.storage = storage self._worker_checked = False def __getstate__(self) -> dict[str, Any]: storage = self.storage storage_state = storage.__getstate__() storage_state["_attached_entities_list"] = [] return {**self.__dict__, "storage": (type(storage), storage_state)} def __setstate__(self, state: dict[str, Any]) -> None: storage_cls, storage_state = state.pop("storage") storage = storage_cls.__new__(storage_cls) storage.__setstate__(storage_state) self.__dict__.update(state, storage=storage) def _check_worker(self) -> None: if not self._worker_checked and get_worker_info() is not None: _check_fork_safe(self.storage) self._worker_checked = True def __len__(self) -> int: return len(self.storage) def __getitem__(self, index: int | list[int]) -> Any: self._check_worker() return self.storage[index] def __getitems__(self, indices: list[int]) -> Any: self._check_worker() return self.storage.get(indices) def __repr__(self) -> str: return f"{type(self).__name__}(storage={self.storage!r})"
[docs] class ReplayBufferDataset(IterableDataset): """A :class:`torch.utils.data.IterableDataset` streaming batches from a replay buffer. Iterating the dataset iterates the buffer: every item is one batch of ``replay_buffer.batch_size`` elements with the buffer transforms applied. Pass the dataset to a :class:`torch.utils.data.DataLoader` with ``batch_size=None`` and :func:`tensordict_collate` to sample in worker processes. Each worker holds its own copy of the buffer, so the sampler and the transforms run in the worker and ``num_batches`` is split between workers. The storage content is shared rather than copied and workers observe later writes as described in :class:`StorageDataset`. Buffer prefetching is disabled in workers and prefetched batches are never serialized to them, the DataLoader prefetches instead. A buffer built with a :class:`torch.Generator` is reseeded once per worker from the worker seed, so sampling in workers is reproducible when the DataLoader is seeded (``torch.manual_seed`` or ``DataLoader(generator=...)``). Samplers whose :attr:`~torchrl.data.replay_buffers.Sampler.requires_shared_state` is ``True``, which is every sampler except those that declare their draws stateless such as :class:`~torchrl.data.replay_buffers.RandomSampler` and :class:`~torchrl.data.replay_buffers.SliceSampler`, are rejected when workers are used. So is a :class:`~torchrl.data.RateLimitedReplayBuffer` that has not been shared with :meth:`~torchrl.data.ReplayBuffer.share`, since each worker would otherwise spend its own copy of the sample budget. Args: replay_buffer (ReplayBuffer): the buffer to sample from. Its ``batch_size`` must be set. Keyword Args: num_batches (int or None, optional): number of batches yielded by one iterator, shared between DataLoader workers. ``None`` streams batches until the sampler runs out, which samplers with replacement never do. Defaults to ``None``. Examples: >>> import torch >>> from tensordict import TensorDict >>> from torch.utils.data import DataLoader >>> from torchrl.data import ( ... LazyMemmapStorage, ... SliceSampler, ... TensorDictReplayBuffer, ... tensordict_collate, ... ) >>> rb = TensorDictReplayBuffer( ... storage=LazyMemmapStorage(1000), ... sampler=SliceSampler(num_slices=4, traj_key="episode", cache_values=True), ... batch_size=32, ... ) >>> _ = rb.extend( ... TensorDict( ... {"obs": torch.randn(1000, 3), "episode": torch.arange(1000) // 50}, ... [1000], ... ) ... ) >>> loader = DataLoader( ... rb.as_dataset(num_batches=8), ... batch_size=None, ... num_workers=4, ... persistent_workers=True, ... collate_fn=tensordict_collate, ... ) >>> batches = list(loader) >>> len(batches), batches[0]["obs"].shape (8, torch.Size([32, 3])) """ def __init__( self, replay_buffer: ReplayBuffer, *, num_batches: int | None = None ) -> None: replay_buffer.storage._share_memory_() self.replay_buffer = replay_buffer self.num_batches = num_batches self._worker_seed = None def __getstate__(self) -> dict[str, Any]: replay_buffer = self.replay_buffer self._check_shared_state(replay_buffer) return { **self.__dict__, "replay_buffer": ( type(replay_buffer), replay_buffer._state_without_prefetch(), ), } def __setstate__(self, state: dict[str, Any]) -> None: buffer_cls, buffer_state = state.pop("replay_buffer") replay_buffer = buffer_cls.__new__(buffer_cls) replay_buffer.__setstate__(buffer_state) self.__dict__.update(state, replay_buffer=replay_buffer) def _check_shared_state(self, replay_buffer: ReplayBuffer) -> None: sampler = replay_buffer.sampler if sampler.requires_shared_state: raise RuntimeError( f"{type(sampler).__name__} keeps sampling state that DataLoader " "workers cannot share. Use num_workers=0 or a sampler without " "cross-process state." ) if replay_buffer._requires_shared_state: raise RuntimeError( f"{type(replay_buffer).__name__} keeps sampling state that " "DataLoader workers cannot share unless the buffer is shared. " "Call share() on the buffer or use num_workers=0." ) def _setup_worker(self) -> WorkerInfo | None: worker = get_worker_info() if worker is None: return None replay_buffer = self.replay_buffer self._check_shared_state(replay_buffer) _check_fork_safe(replay_buffer.storage) replay_buffer._reset_worker_state() rng = replay_buffer._rng if rng is not None and self._worker_seed != worker.seed: replay_buffer.set_rng( torch.Generator(device=rng.device).manual_seed(worker.seed) ) self._worker_seed = worker.seed return worker def __iter__(self): worker = self._setup_worker() num_batches = self.num_batches if worker is not None and num_batches is not None: num_batches = len(range(worker.id, num_batches, worker.num_workers)) yield from islice(self.replay_buffer, num_batches) def __repr__(self) -> str: return ( f"{type(self).__name__}(replay_buffer={self.replay_buffer!r}, " f"num_batches={self.num_batches})" )