Rate this Page

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

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

import abc
import multiprocessing as mp
from copy import copy
from multiprocessing.context import get_spawning_popen
from typing import Any

import torch

from torchrl.data.replay_buffers.checkpointers import StorageCheckpointerBase

try:
    from torch.compiler import disable as compile_disable, is_compiling
except ImportError:
    from torch._dynamo import disable as compile_disable, is_compiling


class Storage:
    """A Storage is the container of a replay buffer.

    Every storage must have a set, get and __len__ methods implemented.
    Get and set should support integers as well as list of integers.

    The storage does not need to have a definite size, but if it does one should
    make sure that it is compatible with the buffer size.

    """

    ndim = 1
    max_size: int
    supports_conditional_update: bool = False
    _default_checkpointer: StorageCheckpointerBase = StorageCheckpointerBase
    _rng: torch.Generator | None = None

    def __init__(
        self,
        max_size: int,
        checkpointer: StorageCheckpointerBase | None = None,
        compilable: bool = False,
    ) -> None:
        self.max_size = int(max_size)
        self.checkpointer = checkpointer
        self._compilable = compilable
        self._attached_entities_list = []
        self._mutation_revision_value = (
            torch.zeros((), dtype=torch.int64) if compilable else mp.Value("q", 0)
        )
        self._last_cursor_index_value = (
            torch.full((), -1, dtype=torch.int64) if compilable else mp.Value("q", -1)
        )

    @property
    def _mutation_revision(self) -> int | torch.Tensor:
        """Monotonic storage-content revision shared with spawned processes."""
        revision = getattr(self, "_mutation_revision_value", None)
        if not self._compilable:
            if revision is None:
                revision = self._mutation_revision_value = mp.Value("q", 0)
            return revision.value
        if revision is None:
            revision = self._mutation_revision_value = torch.zeros(
                (), dtype=torch.int64
            )
        return revision if is_compiling() else int(revision.item())

    def _bump_mutation_revision(self) -> None:
        """Invalidates process-local metadata derived from storage contents."""
        revision = getattr(self, "_mutation_revision_value", None)
        if not self._compilable:
            if revision is None:
                revision = self._mutation_revision_value = mp.Value("q", 0)
            with revision.get_lock():
                revision.value += 1
        else:
            if revision is None:
                revision = self._mutation_revision_value = torch.zeros(
                    (), dtype=torch.int64
                )
            revision.add_(1)

    @property
    def _last_cursor_index(self) -> int | torch.Tensor | None:
        """Last written time coordinate, shared with spawned processes."""
        cursor = getattr(self, "_last_cursor_index_value", None)
        if cursor is None:
            return None
        if self._compilable:
            if is_compiling():
                return cursor
            cursor = int(cursor.item())
        else:
            cursor = cursor.value
        return None if cursor < 0 else cursor

    def _set_last_cursor(self, cursor: Any) -> None:
        self._last_cursor = cursor
        if isinstance(cursor, torch.Tensor):
            cursor = cursor.reshape(-1)
            cursor = cursor[-1] if cursor.numel() else -1
        elif isinstance(cursor, range):
            cursor = int(cursor[-1]) if len(cursor) else -1
        elif isinstance(cursor, slice):
            # Resolve against the addressable range: set() records the cursor
            # before writing, so len(self) may still be 0 and only max_size
            # bounds the slice reliably.
            cursor_range = range(*cursor.indices(self.max_size))
            cursor = int(cursor_range[-1]) if len(cursor_range) else -1
        elif isinstance(cursor, (tuple, list)):
            time_cursor = cursor[0]
            if isinstance(time_cursor, torch.Tensor):
                time_cursor = time_cursor.reshape(-1)
                cursor = time_cursor[-1] if time_cursor.numel() else -1
            else:
                cursor = int(time_cursor)
        elif cursor is None:
            cursor = -1
        else:
            cursor = int(cursor)
        if self._compilable:
            if isinstance(cursor, torch.Tensor):
                self._last_cursor_index_value = cursor
            else:
                self._last_cursor_index_value.fill_(cursor)
            return
        if isinstance(cursor, torch.Tensor):
            cursor = int(cursor.item())
        shared_cursor = self._last_cursor_index_value
        with shared_cursor.get_lock():
            shared_cursor.value = cursor

    @property
    def checkpointer(self):
        return self._checkpointer

[docs] def register_save_hook(self, hook): """Register a save hook for this storage. The hook is forwarded to the checkpointer. """ self._checkpointer.register_save_hook(hook)
[docs] def register_load_hook(self, hook): """Register a load hook for this storage. The hook is forwarded to the checkpointer. """ self._checkpointer.register_load_hook(hook)
@checkpointer.setter def checkpointer(self, value: StorageCheckpointerBase | None) -> None: if value is None: value = self._default_checkpointer() self._checkpointer = value @property def _is_full(self): return len(self) == self.max_size @property def _attached_entities(self) -> list: # RBs that use a given instance of Storage should add # themselves to this set. _attached_entities_list = getattr(self, "_attached_entities_list", None) if _attached_entities_list is None: self._attached_entities_list = _attached_entities_list = [] return _attached_entities_list # TODO: Check this @torch._dynamo.assume_constant_result def _attached_entities_iter(self): return self._attached_entities @abc.abstractmethod def set(self, cursor: int, data: Any, *, set_cursor: bool = True): ... @abc.abstractmethod def get(self, index: int) -> Any: ... def dumps(self, path): self.checkpointer.dumps(self, path) def loads(self, path): self.checkpointer.loads(self, path) self._bump_mutation_revision()
[docs] def attach(self, buffer: Any) -> None: """This function attaches a sampler to this storage. Buffers that read from this storage must be included as an attached entity by calling this method. This guarantees that when data in the storage changes, components are made aware of changes even if the storage is shared with other buffers (eg. Priority Samplers). Args: buffer: the object that reads from this storage. """ if buffer not in self._attached_entities: self._attached_entities.append(buffer)
def __getitem__(self, item): return self.get(item) def __setitem__(self, index, value): """Sets values in the storage without updating the cursor or length.""" return self.set(index, value, set_cursor=False) def __iter__(self): for i in range(len(self)): yield self[i] @abc.abstractmethod def __len__(self): ... @abc.abstractmethod def state_dict(self) -> dict[str, Any]: ... @abc.abstractmethod def load_state_dict(self, state_dict: dict[str, Any]) -> None: ... @abc.abstractmethod def _empty(self): ... # TODO: Without this disable, compiler recompiles due to changing len(self) guards. @compile_disable() def _rand_given_ndim(self, batch_size): # a method to return random indices given the storage ndim if self.ndim == 1: return torch.randint( 0, len(self), (batch_size,), generator=self._rng, device=getattr(self, "device", None), ) raise RuntimeError( f"Random number generation is not implemented for storage of type {type(self)} with ndim {self.ndim}. " f"Please report this exception as well as the use case (incl. buffer construction) on github." ) @property def shape(self): if self.ndim == 1: return torch.Size([self.max_size]) raise RuntimeError( f"storage.shape is not supported for storages of type {type(self)} when ndim > 1." f"Please report this exception as well as the use case (incl. buffer construction) on github." ) def _max_size_along_dim0(self, *, single_data=None, batched_data=None): if self.ndim == 1: return self.max_size raise RuntimeError( f"storage._max_size_along_dim0 is not supported for storages of type {type(self)} when ndim > 1." f"Please report this exception as well as the use case (incl. buffer construction) on github." ) def flatten(self): if self.ndim == 1: return self raise RuntimeError( f"storage.flatten is not supported for storages of type {type(self)} when ndim > 1." f"Please report this exception as well as the use case (incl. buffer construction) on github." )
[docs] def save(self, *args, **kwargs): """Alias for :meth:`dumps`.""" return self.dumps(*args, **kwargs)
[docs] def dump(self, *args, **kwargs): """Alias for :meth:`dumps`.""" return self.dumps(*args, **kwargs)
[docs] def load(self, *args, **kwargs): """Alias for :meth:`loads`.""" return self.loads(*args, **kwargs)
def __getstate__(self): state = copy(self.__dict__) state["_rng"] = None if get_spawning_popen() is None: revision = self._mutation_revision last_cursor = self._last_cursor_index state.pop("_mutation_revision_value", None) state.pop("_last_cursor_index_value", None) state["mutation_revision__context"] = revision state["last_cursor_index__context"] = last_cursor return state def __setstate__(self, state): revision = state.pop("mutation_revision__context", None) last_cursor = state.pop("last_cursor_index__context", None) compilable = state.get("_compilable", False) state.setdefault("_compilable", compilable) if revision is not None: if compilable: state["_mutation_revision_value"] = torch.tensor( revision, dtype=torch.int64 ) else: state["_mutation_revision_value"] = mp.Value("q", revision) if last_cursor is not None: if compilable: state["_last_cursor_index_value"] = torch.tensor( last_cursor, dtype=torch.int64 ) else: state["_last_cursor_index_value"] = mp.Value("q", last_cursor) elif "_last_cursor_index_value" not in state: state["_last_cursor_index_value"] = ( torch.full((), -1, dtype=torch.int64) if compilable else mp.Value("q", -1) ) if "_mutation_revision_value" not in state: state["_mutation_revision_value"] = ( torch.zeros((), dtype=torch.int64) if compilable else mp.Value("q", 0) ) self.__dict__.update(state) def __contains__(self, item): return self.contains(item) @abc.abstractmethod def contains(self, item): ...