Rate this Page

Source code for torchrl.data.replay_buffers.storages.list

# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
from __future__ import annotations

import sys
import warnings
from collections import OrderedDict
from collections.abc import Callable, Mapping, Sequence
from copy import deepcopy
from multiprocessing.context import get_spawning_popen
from typing import Any

import numpy as np
import tensordict
import torch
from tensordict import is_tensor_collection, lazy_stack, TensorDict, TensorDictBase
from tensordict.utils import _zip_strict
from torch.utils._pytree import tree_map

from torchrl.data.replay_buffers.checkpointers import (
    CompressedListStorageCheckpointer,
    ListStorageCheckpointer,
)
from torchrl.data.replay_buffers.utils import INT_CLASSES

from .base import Storage


class ListStorage(Storage):
    """A storage stored in a list.

    This class cannot be extended with PyTrees, the data provided during calls to
    :meth:`~torchrl.data.replay_buffers.ReplayBuffer.extend` should be iterables
    (like lists, tuples, tensors or tensordicts with non-empty batch-size).

    Args:
        max_size (int, optional): the maximum number of elements stored in the storage.
            If not provided, an unlimited storage is created.

    Keyword Args:
        compilable (bool, optional): if ``True``, the storage will be made compatible with :func:`~torch.compile` at
            the cost of being executable in multiprocessed settings.
        device (str, optional): the device to use for the storage. Defaults to `None` (inputs are not moved to the device).

    """

    _default_checkpointer = ListStorageCheckpointer

    def __init__(
        self,
        max_size: int | None = None,
        *,
        compilable: bool = False,
        device: torch.device | str | int | None = None,
    ):
        if max_size is None:
            max_size = torch.iinfo(torch.int64).max
        super().__init__(max_size, compilable=compilable)
        self._storage = []
        self.device = device

    def _to_device(self, data: Any) -> Any:
        """Utility method to move data to the device."""
        if self.device is not None:
            if hasattr(data, "to"):
                data = data.to(self.device)
            else:
                data = tree_map(
                    lambda x: x.to(self.device) if hasattr(x, "to") else x, data
                )
        return data

    def set(
        self,
        cursor: int | Sequence[int] | slice,
        data: Any,
        *,
        set_cursor: bool = True,
    ):
        if not isinstance(cursor, INT_CLASSES):
            if (isinstance(cursor, torch.Tensor) and cursor.ndim == 0) or (
                isinstance(cursor, np.ndarray) and cursor.ndim == 0
            ):
                self.set(int(cursor), data, set_cursor=set_cursor)
                return
            if isinstance(cursor, slice):
                data = self._to_device(data)
                self._set_slice(cursor, data)
                self._bump_mutation_revision()
                return
            if isinstance(
                data,
                (
                    list,
                    tuple,
                    torch.Tensor,
                    TensorDictBase,
                    *tensordict.base._ACCEPTED_CLASSES,
                    range,
                    set,
                    np.ndarray,
                ),
            ):
                for _cursor, _data in _zip_strict(cursor, data):
                    self.set(_cursor, _data, set_cursor=set_cursor)
            else:
                raise TypeError(
                    f"Cannot extend a {type(self)} with data of type {type(data)}. "
                    f"Provide a list, tuple, set, range, np.ndarray, tensor or tensordict subclass instead."
                )
            return
        else:
            if cursor > len(self._storage):
                raise RuntimeError(
                    "Cannot append data located more than one item away from "
                    f"the storage size: the storage size is {len(self._storage)} "
                    f"and the index of the item to be set is {cursor}."
                )
            if cursor >= self.max_size:
                raise RuntimeError(
                    f"Cannot append data to the list storage: "
                    f"maximum capacity is {self.max_size} "
                    f"and the index of the item to be set is {cursor}."
                )
            data = self._to_device(data)
            self._set_item(cursor, data)
            self._bump_mutation_revision()

    def _set_item(self, cursor: int, data: Any) -> None:
        """Set a single item in the storage."""
        if cursor == len(self._storage):
            self._storage.append(data)
        else:
            self._storage[cursor] = data

    def _set_slice(self, cursor: slice, data: Any) -> None:
        """Set a slice in the storage."""
        self._storage[cursor] = data

    def get(self, index: int | Sequence[int] | slice) -> Any:
        if isinstance(index, INT_CLASSES):
            return self._get_item(index)
        elif isinstance(index, slice):
            return self._get_slice(index)
        elif isinstance(index, tuple):
            if len(index) > 1:
                raise RuntimeError(
                    f"{type(self).__name__} can only be indexed with one-length tuples."
                )
            return self.get(index[0])
        else:
            if isinstance(index, torch.Tensor) and index.device.type != "cpu":
                index = index.cpu().tolist()
            return self._get_list(index)

    def _get_item(self, index: int) -> Any:
        """Get a single item from the storage."""
        return self._storage[index]

    def _get_slice(self, index: slice) -> Any:
        """Get a slice from the storage."""
        return self._storage[index]

    def _get_list(self, index: list) -> list:
        """Get a list of items from the storage."""
        return [self._storage[i] for i in index]

    def __len__(self):
        """Get the length of the storage."""
        return len(self._storage)

    def state_dict(self) -> dict[str, Any]:
        return {
            "_storage": [
                elt if not hasattr(elt, "state_dict") else elt.state_dict()
                for elt in self._storage
            ]
        }

    def load_state_dict(self, state_dict):
        _storage = state_dict["_storage"]
        self._storage = []
        for elt in _storage:
            # clone to decouple the storage from the caller's tensors (which may
            # e.g. be mmap-backed views over a checkpoint file)
            if isinstance(elt, torch.Tensor):
                self._storage.append(elt.clone())
            elif isinstance(elt, (dict, OrderedDict)):
                self._storage.append(
                    TensorDict().load_state_dict(elt, strict=False).clone()
                )
            else:
                raise TypeError(
                    f"Objects of type {type(elt)} are not supported by ListStorage.load_state_dict"
                )
        self._bump_mutation_revision()

    def _empty(self):
        self._storage = []
        self._bump_mutation_revision()

    def __getstate__(self):
        if get_spawning_popen() is not None:
            raise RuntimeError(
                f"Cannot share a storage of type {type(self)} between processes."
            )
        state = super().__getstate__()
        return state

    def __repr__(self):
        storage = getattr(self, "_storage", [None])
        if not storage:
            return f"{self.__class__.__name__}()"
        return f"{self.__class__.__name__}(items=[{storage[0]}, ...])"

    def contains(self, item):
        if isinstance(item, int):
            if item < 0:
                item += len(self._storage)
            return self._contains_int(item)
        if isinstance(item, torch.Tensor):
            return torch.tensor(
                [self.contains(elt) for elt in item.tolist()],
                dtype=torch.bool,
                device=item.device,
            ).reshape_as(item)
        raise NotImplementedError(f"type {type(item)} is not supported yet.")

    def _contains_int(self, item: int) -> bool:
        """Check if an integer index is contained in the storage."""
        return 0 <= item < len(self._storage)


class LazyStackStorage(ListStorage):
    """A ListStorage that returns LazyStackTensorDict instances.

    This storage allows for heterougeneous structures to be indexed as a single `TensorDict` representation.
    It uses :class:`~tensordict.LazyStackedTensorDict` which operates on non-contiguous lists of tensordicts,
    lazily stacking items when queried.
    This means that this storage is going to be fast to sample but data access may be slow (as it requires a stack).
    Tensors of heterogeneous shapes can also be stored within the storage and stacked together.
    Because the storage is represented as a list, the number of tensors to store in memory will grow linearly with
    the size of the buffer.

    If possible, nested tensors can also be created via :meth:`~tensordict.LazyStackedTensorDict.densify`
    (see :mod:`~torch.nested`).

    Args:
        max_size (int, optional): the maximum number of elements stored in the storage.
            If not provided, an unlimited storage is created.

    Keyword Args:
        compilable (bool, optional): if ``True``, the storage will be made compatible with :func:`~torch.compile` at
            the cost of being executable in multiprocessed settings.
        stack_dim (int, optional): the stack dimension in terms of TensorDict batch sizes. Defaults to `0`.
        device (str, optional): the device to use for the storage. Defaults to `None` (inputs are not moved to the device).

    Examples:
        >>> import torch
        >>> from torchrl.data import ReplayBuffer, LazyStackStorage
        >>> from tensordict import TensorDict
        >>> _ = torch.manual_seed(0)
        >>> rb = ReplayBuffer(storage=LazyStackStorage(max_size=1000, stack_dim=-1))
        >>> data0 = TensorDict(a=torch.randn((10,)), b=torch.rand(4), c="a string!")
        >>> data1 = TensorDict(a=torch.randn((11,)), b=torch.rand(4), c="another string!")
        >>> _ = rb.add(data0)
        >>> _ = rb.add(data1)
        >>> rb.sample(10)
        LazyStackedTensorDict(
            fields={
                a: Tensor(shape=torch.Size([10, -1]), device=cpu, dtype=torch.float32, is_shared=False),
                b: Tensor(shape=torch.Size([10, 4]), device=cpu, dtype=torch.float32, is_shared=False),
                c: NonTensorStack(
                    ['another string!', 'another string!', 'another st...,
                    batch_size=torch.Size([10]),
                    device=None)},
            exclusive_fields={
            },
            batch_size=torch.Size([10]),
            device=None,
            is_shared=False,
            stack_dim=0)
    """

    def __init__(
        self,
        max_size: int | None = None,
        *,
        compilable: bool = False,
        stack_dim: int = 0,
        device: torch.device | str | int | None = None,
    ):
        super().__init__(max_size=max_size, compilable=compilable, device=device)
        self.stack_dim = stack_dim

    def get(self, index: int | Sequence[int] | slice) -> Any:
        out = super().get(index=index)
        if isinstance(out, list):
            stack_dim = self.stack_dim
            if stack_dim < 0:
                stack_dim = out[0].ndim + 1 + stack_dim
            out = lazy_stack(list(out), stack_dim)
            return out
        return out


class CompressedListStorage(ListStorage):
    """A storage that compresses and decompresses data.

    This storage compresses data when storing and decompresses when retrieving.
    It's particularly useful for storing raw sensory observations like images
    that can be compressed significantly to save memory.

    Args:
        max_size (int): size of the storage, i.e. maximum number of elements stored
            in the buffer.
        compression_fn (callable, optional): function to compress data. Should take
            a tensor and return a compressed byte tensor. Defaults to zstd compression.
        decompression_fn (callable, optional): function to decompress data. Should take
            a compressed byte tensor and return the original tensor. Defaults to zstd decompression.
        compression_level (int, optional): compression level (1-22 for zstd) when using the default compression function.
            Defaults to 3.
        device (torch.device, optional): device where the sampled tensors will be
            stored and sent. Default is :obj:`torch.device("cpu")`.
        compilable (bool, optional): whether the storage is compilable.
            If ``True``, the writer cannot be shared between multiple processes.
            Defaults to ``False``.

    Examples:
        >>> import torch
        >>> from torchrl.data import CompressedListStorage, ReplayBuffer
        >>> from tensordict import TensorDict
        >>>
        >>> # Create a compressed storage for image data
        >>> storage = CompressedListStorage(max_size=1000, compression_level=3)
        >>> rb = ReplayBuffer(storage=storage, batch_size=5)
        >>>
        >>> # Add some image data
        >>> images = torch.randn(10, 3, 84, 84)  # Atari-like frames
        >>> data = TensorDict({"obs": images}, batch_size=[10])
        >>> rb.extend(data)
        >>>
        >>> # Sample and verify data is decompressed correctly
        >>> sample = rb.sample(3)
        >>> print(sample["obs"].shape)  # torch.Size([3, 3, 84, 84])

    """

    _default_checkpointer = CompressedListStorageCheckpointer

    def __init__(
        self,
        max_size: int,
        *,
        compression_fn: Callable | None = None,
        decompression_fn: Callable | None = None,
        compression_level: int = 3,
        device: torch.device = "cpu",
        compilable: bool = False,
    ):
        super().__init__(max_size, compilable=compilable, device=device)
        self.compression_level = compression_level

        # Set up compression functions
        if compression_fn is None:
            self.compression_fn = self._default_compression_fn
        else:
            self.compression_fn = compression_fn

        if decompression_fn is None:
            self.decompression_fn = self._default_decompression_fn
        else:
            self.decompression_fn = decompression_fn

        # Store compressed data and metadata
        self._storage = []
        self._metadata = []  # Store shape, dtype, device info for each item

    def _default_compression_fn(self, tensor: torch.Tensor) -> torch.Tensor:
        """Default compression using zstd."""
        if sys.version_info >= (3, 14):
            from compression import zstd

            compressor_fn = zstd.compress

        else:
            import zlib

            compressor_fn = zlib.compress

        # Convert tensor to bytes
        tensor_bytes = self.to_bytestream(tensor)

        # Compress with zstd
        compressed_bytes = compressor_fn(tensor_bytes, level=self.compression_level)

        # Convert to tensor
        return torch.frombuffer(bytearray(compressed_bytes), dtype=torch.uint8)

    def _default_decompression_fn(
        self, compressed_tensor: torch.Tensor, metadata: dict
    ) -> torch.Tensor:
        """Default decompression using zstd."""
        if sys.version_info >= (3, 14):
            from compression import zstd

            decompressor_fn = zstd.decompress

        else:
            import zlib

            decompressor_fn = zlib.decompress

        # Convert tensor to bytes
        compressed_bytes = self.to_bytestream(compressed_tensor.cpu())

        # Decompress with zstd
        decompressed_bytes = decompressor_fn(compressed_bytes)

        # Convert back to tensor
        tensor = torch.frombuffer(
            bytearray(decompressed_bytes), dtype=metadata["dtype"]
        )
        tensor = tensor.reshape(metadata["shape"])
        tensor = tensor.to(metadata["device"])

        return tensor

    def _compress_item(self, item: Any) -> tuple[torch.Tensor, dict]:
        """Compress a single item and return compressed data with metadata."""
        if isinstance(item, torch.Tensor):
            metadata = {
                "type": "tensor",
                "shape": item.shape,
                "dtype": item.dtype,
                "device": item.device,
            }
            compressed = self.compression_fn(item)
        elif is_tensor_collection(item):
            # For TensorDict, compress each tensor field
            compressed_fields = {}
            metadata = {"type": "tensordict", "fields": {}}

            for key, value in item.items():
                if isinstance(value, torch.Tensor):
                    compressed_fields[key] = self.compression_fn(value)
                    metadata["fields"][key] = {
                        "type": "tensor",
                        "shape": value.shape,
                        "dtype": value.dtype,
                        "device": value.device,
                    }
                else:
                    # For non-tensor data, store as-is
                    compressed_fields[key] = value
                    metadata["fields"][key] = {"type": "non_tensor", "value": value}

            compressed = compressed_fields
        else:
            # For other types, store as-is
            compressed = item
            metadata = {"type": "other", "value": item}

        return compressed, metadata

    def _decompress_item(self, compressed_data: Any, metadata: dict) -> Any:
        """Decompress a single item using its metadata."""
        if metadata["type"] == "tensor":
            return self.decompression_fn(compressed_data, metadata)
        elif metadata["type"] == "tensordict":
            # Reconstruct TensorDict
            result = TensorDict({}, batch_size=metadata.get("batch_size", []))

            for key, field_metadata in metadata["fields"].items():
                if field_metadata["type"] == "non_tensor":
                    result[key] = field_metadata["value"]
                else:
                    # Decompress tensor field
                    result[key] = self.decompression_fn(
                        compressed_data[key], field_metadata
                    )

            return result
        else:
            # Return as-is for other types
            return metadata["value"]

    def _set_item(self, cursor: int, data: Any) -> None:
        """Set a single item in the compressed storage."""
        # Ensure we have enough space
        while len(self._storage) <= cursor:
            self._storage.append(None)
            self._metadata.append(None)

        # Compress and store
        compressed_data, metadata = self._compress_item(data)
        self._storage[cursor] = compressed_data
        self._metadata[cursor] = metadata

    def _set_slice(self, cursor: slice, data: Any) -> None:
        """Set a slice in the compressed storage."""
        # Handle slice assignment
        if not hasattr(data, "__iter__"):
            data = [data]
        start, stop, step = cursor.indices(len(self._storage))
        indices = list(range(start, stop, step))

        for i, value in zip(indices, data):
            self._set_item(i, value)

    def _get_item(self, index: int) -> Any:
        """Get a single item from the compressed storage."""
        if index >= len(self._storage) or self._storage[index] is None:
            raise IndexError(f"Index {index} out of bounds or not set")

        compressed_data = self._storage[index]
        metadata = self._metadata[index]
        return self._decompress_item(compressed_data, metadata)

    def _get_slice(self, index: slice) -> list:
        """Get a slice from the compressed storage."""
        start, stop, step = index.indices(len(self._storage))
        results = []
        for i in range(start, stop, step):
            if i < len(self._storage) and self._storage[i] is not None:
                results.append(self._get_item(i))
        return results

    def _get_list(self, index: list) -> list:
        """Get a list of items from the compressed storage."""
        if isinstance(index, torch.Tensor) and index.device.type != "cpu":
            index = index.cpu().tolist()

        results = []
        for i in index:
            if i >= len(self._storage) or self._storage[i] is None:
                raise IndexError(f"Index {i} out of bounds or not set")
            results.append(self._get_item(i))
        return results

    def __len__(self) -> int:
        """Get the length of the compressed storage."""
        return len([item for item in self._storage if item is not None])

    def _contains_int(self, item: int) -> bool:
        """Check if an integer index is contained in the compressed storage."""
        return 0 <= item < len(self._storage) and self._storage[item] is not None

    def _empty(self):
        """Empty the storage."""
        self._storage = []
        self._metadata = []
        self._bump_mutation_revision()

[docs] def state_dict(self) -> dict[str, Any]: """Save the storage state.""" return { "_storage": self._storage, "_metadata": self._metadata, }
[docs] def load_state_dict(self, state_dict: dict[str, Any]) -> None: """Load the storage state.""" # clone tensors and copy containers to decouple the storage from the # caller's objects self._storage = [ elt.clone() if isinstance(elt, torch.Tensor) else elt for elt in state_dict["_storage"] ] self._metadata = deepcopy(state_dict["_metadata"]) self._bump_mutation_revision()
[docs] def to_bytestream(self, data_to_bytestream: torch.Tensor | np.array | Any) -> bytes: """Convert data to a byte stream.""" if isinstance(data_to_bytestream, torch.Tensor): byte_stream = data_to_bytestream.cpu().numpy().tobytes() elif isinstance(data_to_bytestream, np.array): byte_stream = bytes(data_to_bytestream.tobytes()) else: import io import pickle buffer = io.BytesIO() pickle.dump(data_to_bytestream, buffer) buffer.seek(0) byte_stream = bytes(buffer.read()) return byte_stream
[docs] def bytes(self): """Return the number of bytes in the storage.""" def compressed_size_from_list(data: Any) -> int: if data is None: return 0 elif isinstance(data, (bytes,)): return len(data) elif isinstance(data, (np.ndarray,)): return data.nbytes elif isinstance(data, (torch.Tensor)): return compressed_size_from_list(data.cpu().numpy()) elif isinstance(data, (tuple, list, Sequence)): return sum(compressed_size_from_list(item) for item in data) elif isinstance(data, Mapping) or is_tensor_collection(data): return sum(compressed_size_from_list(value) for value in data.values()) else: return 0 compressed_size_estimate = compressed_size_from_list(self._storage) if compressed_size_estimate == 0: if len(self._storage) > 0: raise RuntimeError( "Compressed storage is not empty but the compressed size is 0. This is a bug." ) warnings.warn("Compressed storage is empty, returning 0 bytes.") return compressed_size_estimate