Rate this Page

Source code for torchrl.data.replay_buffers.samplers.prioritized

# 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 json
import warnings
from copy import deepcopy
from multiprocessing.context import get_spawning_popen
from pathlib import Path
from typing import Any

import numpy as np
import torch
from pyvers import implement_for
from tensordict import MemoryMappedTensor
from torch.utils._pytree import tree_map
from torchrl._extension import EXTENSION_WARNING
from torchrl._utils import logger, rl_warnings
from torchrl.data.replay_buffers.storages import Storage, TensorStorage
from torchrl.data.replay_buffers.utils import _is_int, unravel_index

try:
    from torchrl._torchrl import (
        MinSegmentTreeFp32,
        MinSegmentTreeFp64,
        SumSegmentTreeFp32,
        SumSegmentTreeFp64,
    )
except ImportError:
    # Make default values
    MinSegmentTreeFp32 = None
    MinSegmentTreeFp64 = None
    SumSegmentTreeFp32 = None
    SumSegmentTreeFp64 = None

try:
    from torchrl._torchrl import (
        CudaMinSegmentTreeFp32,
        CudaMinSegmentTreeFp64,
        CudaSumSegmentTreeFp32,
        CudaSumSegmentTreeFp64,
    )
except ImportError:
    CudaMinSegmentTreeFp32 = None
    CudaMinSegmentTreeFp64 = None
    CudaSumSegmentTreeFp32 = None
    CudaSumSegmentTreeFp64 = None

_EMPTY_STORAGE_ERROR = "Cannot sample from an empty storage."


# Maps a "with replacement" sampler class to its "without replacement" counterpart.
# Populated at module import time after the relevant classes are defined.
# Consumed by :class:`_SamplerMeta` to dispatch ``Cls(replacement=False, ...)`` calls
# to ``_REPLACEMENT_DISPATCH[Cls](...)``.
_REPLACEMENT_DISPATCH: dict[type, type] = {}


from .base import Sampler


class PrioritizedSampler(Sampler):
    r"""Prioritized sampler for replay buffer.

    This sampler implements Prioritized Experience Replay (PER) as presented in
    "Schaul, T.; Quan, J.; Antonoglou, I.; and Silver, D. 2015. Prioritized experience replay."
    (https://arxiv.org/abs/1511.05952)

    **Core Idea**: Instead of sampling experiences uniformly from the replay buffer,
    PER samples experiences with probability proportional to their "importance" - typically
    measured by the magnitude of their temporal-difference (TD) error. This prioritization
    can lead to faster learning by focusing on experiences that are most informative.

    **How it works**:
    1. Each experience is assigned a priority based on its TD error: :math:`p_i = |\delta_i| + \epsilon`
    2. Sampling probability is computed as: :math:`P(i) = \frac{p_i^\alpha}{\sum_j p_j^\alpha}`
    3. Importance sampling weights correct for the bias: :math:`w_i = (N \cdot P(i))^{-\beta}`

    Args:
        max_capacity (int): maximum capacity of the buffer.
        alpha (:obj:`float`): exponent :math:`\alpha` determines how much prioritization is used.
            - :math:`\alpha = 0`: uniform sampling (no prioritization)
            - :math:`\alpha = 1`: full prioritization based on TD error magnitude
            - Typical values: 0.4-0.7 for balanced prioritization
            - Higher :math:`\alpha` means more aggressive prioritization of high-error experiences
        beta (:obj:`float`): importance sampling negative exponent :math:`\beta`.
            - :math:`\beta` controls the correction for the bias introduced by prioritization
            - :math:`\beta = 0`: no correction (biased towards high-priority samples)
            - :math:`\beta = 1`: full correction (unbiased but potentially unstable)
            - Typical values: start at 0.4-0.6 and anneal to 1.0 during training
            - Lower :math:`\beta` early in training provides stability, higher :math:`\beta` later reduces bias
        eps (:obj:`float`, optional): small constant added to priorities to ensure
            no experience has zero priority. This prevents experiences from never
            being sampled. Defaults to 1e-8.
        reduction (str, optional): the reduction method for multidimensional
            tensordicts (ie stored trajectory). Can be one of "max", "min",
            "median" or "mean".
        max_priority_within_buffer (bool, optional): if ``True``, the max-priority
            is tracked within the buffer. When ``False``, the max-priority tracks
            the maximum value since the instantiation of the sampler.
        device (torch.device or str, optional): device that holds the priority
            trees. Defaults to ``None``, in which case CUDA storage selects a CUDA
            tree when the installed TorchRL extension was built with CUDA support,
            and CPU storage keeps the existing CPU tree.

    **Parameter Guidelines**:

    - **:math:`\alpha` (alpha)**: Controls how much to prioritize high-error experiences.
      0.4-0.7: Good balance between learning speed and stability.
      1.0: Maximum prioritization (may be unstable).
      0.0: Uniform sampling (no prioritization benefit).

    - **:math:`\beta` (beta)**: Controls importance sampling correction.
      Start at 0.4-0.6 for training stability.
      Anneal to 1.0 over training to reduce bias.
      Lower values = more stable but biased.
      Higher values = less biased but potentially unstable.

    - **:math:`\epsilon`**: Small constant to prevent zero priorities.
      1e-8: Good default value.
      Too small: may cause numerical issues.
      Too large: reduces prioritization effect.

    Examples:
        >>> from torchrl.data.replay_buffers import ReplayBuffer, LazyTensorStorage, PrioritizedSampler
        >>> from tensordict import TensorDict
        >>> rb = ReplayBuffer(storage=LazyTensorStorage(10), sampler=PrioritizedSampler(max_capacity=10, alpha=1.0, beta=1.0))
        >>> priority = torch.tensor([0, 1000])
        >>> data_0 = TensorDict({"reward": 0, "obs": [0], "action": [0], "priority": priority[0]}, [])
        >>> data_1 = TensorDict({"reward": 1, "obs": [1], "action": [2], "priority": priority[1]}, [])
        >>> rb.add(data_0)
        >>> rb.add(data_1)
        >>> rb.update_priority(torch.tensor([0, 1]), priority=priority)
        >>> sample, info = rb.sample(10, return_info=True)
        >>> print(sample)
        TensorDict(
                fields={
                    action: Tensor(shape=torch.Size([10, 1]), device=cpu, dtype=torch.int64, is_shared=False),
                    obs: Tensor(shape=torch.Size([10, 1]), device=cpu, dtype=torch.int64, is_shared=False),
                    priority: Tensor(shape=torch.Size([10]), device=cpu, dtype=torch.int64, is_shared=False),
                    reward: Tensor(shape=torch.Size([10]), device=cpu, dtype=torch.int64, is_shared=False)},
                batch_size=torch.Size([10]),
                device=cpu,
                is_shared=False)
        >>> print(info)
        {'priority_weight': array([1.e-11, 1.e-11, 1.e-11, 1.e-11, 1.e-11, 1.e-11, 1.e-11, 1.e-11,
               1.e-11, 1.e-11], dtype=float32), 'index': array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1])}

    .. note:: Using a :class:`~torchrl.data.replay_buffers.TensorDictReplayBuffer` can smoothen the
        process of updating the priorities:

            >>> from torchrl.data.replay_buffers import TensorDictReplayBuffer as TDRB, LazyTensorStorage, PrioritizedSampler
            >>> from tensordict import TensorDict
            >>> rb = TDRB(
            ...     storage=LazyTensorStorage(10),
            ...     sampler=PrioritizedSampler(max_capacity=10, alpha=1.0, beta=1.0),
            ...     priority_key="priority",  # This kwarg isn't present in regular RBs
            ... )
            >>> priority = torch.tensor([0, 1000])
            >>> data_0 = TensorDict({"reward": 0, "obs": [0], "action": [0], "priority": priority[0]}, [])
            >>> data_1 = TensorDict({"reward": 1, "obs": [1], "action": [2], "priority": priority[1]}, [])
            >>> data = torch.stack([data_0, data_1])
            >>> rb.extend(data)
            >>> rb.update_priority(data)  # Reads the "priority" key as indicated in the constructor
            >>> sample, info = rb.sample(10, return_info=True)
            >>> print(sample['index'])  # The index is packed with the tensordict
            tensor([1, 1, 1, 1, 1, 1, 1, 1, 1, 1])

    """

    # Version of the state_dict / dumps payload schema. Version 1 marks
    # payloads whose _max_priority is stored in the raw priority domain
    # (pytorch/rl#3925); version-less payloads are treated as pre-#3925.
    _STATE_SCHEMA_VERSION: int = 1

    def __init__(
        self,
        max_capacity: int,
        alpha: float,
        beta: float,
        eps: float = 1e-8,
        dtype: torch.dtype = torch.float,
        reduction: str = "max",
        max_priority_within_buffer: bool = False,
        device: torch.device | str | None = None,
    ) -> None:
        if alpha < 0:
            raise ValueError(
                f"alpha must be greater or equal than 0, got alpha={alpha}"
            )
        if beta < 0:
            raise ValueError(f"beta must be greater or equal to 0, got beta={beta}")

        self._max_capacity = max_capacity
        self._alpha = alpha
        self._beta = beta
        self._eps = eps
        self.reduction = reduction
        self.dtype = dtype
        self._max_priority_within_buffer = max_priority_within_buffer
        self._device = torch.device(device) if device is not None else None
        self._init()
        if rl_warnings() and SumSegmentTreeFp32 is None:
            logger.warning(EXTENSION_WARNING)

    def __repr__(self):
        return f"{self.__class__.__name__}(alpha={self._alpha}, beta={self._beta}, eps={self._eps}, reduction={self.reduction})"

    @property
    def max_size(self):
        return self._max_capacity

    @property
    def device(self) -> torch.device:
        tree_device = getattr(self._sum_tree, "device", None)
        if tree_device is not None:
            return torch.device(tree_device)
        if self._device is not None:
            return self._device
        return torch.device("cpu")

    @property
    def alpha(self):
        """The priority exponent.

        .. note:: Setting ``alpha`` on a sampler that already holds priorities
          (e.g. when annealing it with a
          :class:`~torchrl.data.replay_buffers.scheduler.ParameterScheduler`)
          re-transforms the ``(p + eps) ** alpha`` values stored in the
          sum/min trees to the new exponent in a single O(capacity) pass, so
          sampling probabilities stay consistent with the new value. The one
          exception is changing ``alpha`` away from exactly ``0``: the raw
          priorities cannot be recovered from the trees in that regime, so the
          stored (uniform) values are kept -- and a warning is emitted --
          until each entry's priority is next updated.
        """
        return self._alpha

    @alpha.setter
    def alpha(self, value):
        if value < 0:
            raise ValueError(
                f"alpha must be greater or equal than 0, got alpha={value}"
            )
        old_alpha = self._alpha
        self._alpha = value
        if value != old_alpha:
            self._retransform_priority_trees(old_alpha, value)

    @property
    def beta(self):
        return self._beta

    @beta.setter
    def beta(self, value):
        self._beta = value

    def __getstate__(self):
        if get_spawning_popen() is not None:
            raise RuntimeError(
                f"Samplers of type {type(self)} cannot be shared between processes. "
                "Use TensorDictPrioritizedReplayBuffer(sync=False) instead: "
                "the writer process gets a uniform sampler and the learner "
                "keeps a local prioritized sampler."
            )
        return super().__getstate__()

    def _tree_device_from_storage(self, storage: Storage | None) -> torch.device | None:
        if self._device is not None:
            return self._device
        if storage is None:
            return None
        device = getattr(storage, "device", None)
        if device is None or device == "auto":
            return None
        device = torch.device(device)
        if device.type == "cuda":
            return device
        return None

    def _maybe_init_from_storage(self, storage: Storage | None) -> None:
        device = self._tree_device_from_storage(storage)
        if device is not None and device != self.device:
            self._device = device
            self._init()

    def _init(self) -> None:
        if SumSegmentTreeFp32 is None:
            raise RuntimeError(
                "SumSegmentTreeFp32 is not available. See warning above."
            )
        if MinSegmentTreeFp32 is None:
            raise RuntimeError(
                "MinSegmentTreeFp32 is not available. See warning above."
            )
        if SumSegmentTreeFp64 is None:
            raise RuntimeError(
                "SumSegmentTreeFp64 is not available. See warning above."
            )
        if MinSegmentTreeFp64 is None:
            raise RuntimeError(
                "MinSegmentTreeFp64 is not available. See warning above."
            )
        device = self._device
        if device is not None and device.type == "cuda":
            if (
                CudaSumSegmentTreeFp32 is None
                or CudaMinSegmentTreeFp32 is None
                or CudaSumSegmentTreeFp64 is None
                or CudaMinSegmentTreeFp64 is None
            ):
                raise RuntimeError(
                    "CUDA prioritized replay buffers require a TorchRL CUDA wheel. "
                    "Install a TorchRL wheel matching your PyTorch CUDA variant or "
                    "rebuild TorchRL with FORCE_CUDA=1."
                )
            if self.dtype in (torch.float, torch.FloatType, torch.float32):
                self._sum_tree = CudaSumSegmentTreeFp32(self._max_capacity, device)
                self._min_tree = CudaMinSegmentTreeFp32(self._max_capacity, device)
            elif self.dtype in (torch.double, torch.DoubleTensor, torch.float64):
                self._sum_tree = CudaSumSegmentTreeFp64(self._max_capacity, device)
                self._min_tree = CudaMinSegmentTreeFp64(self._max_capacity, device)
            else:
                raise NotImplementedError(
                    f"dtype {self.dtype} not supported by PrioritizedSampler"
                )
            self._max_priority = None
            return
        if self.dtype in (torch.float, torch.FloatType, torch.float32):
            self._sum_tree = SumSegmentTreeFp32(self._max_capacity)
            self._min_tree = MinSegmentTreeFp32(self._max_capacity)
        elif self.dtype in (torch.double, torch.DoubleTensor, torch.float64):
            self._sum_tree = SumSegmentTreeFp64(self._max_capacity)
            self._min_tree = MinSegmentTreeFp64(self._max_capacity)
        else:
            raise NotImplementedError(
                f"dtype {self.dtype} not supported by PrioritizedSampler"
            )
        self._max_priority = None

    def _empty(self) -> None:
        self._init()

    @property
    def _max_priority(self) -> tuple[float | None, int | None]:
        max_priority_index = self.__dict__.get("_max_priority")
        if max_priority_index is None:
            return (None, None)
        return max_priority_index

    @_max_priority.setter
    def _max_priority(self, value: tuple[float | None, int | None]) -> None:
        self.__dict__["_max_priority"] = value

    def _maybe_erase_max_priority(
        self, index: torch.Tensor | int | slice | tuple
    ) -> None:
        if not self._max_priority_within_buffer:
            return
        max_priority_index = self._max_priority[1]
        if max_priority_index is None:
            return

        def check_index(index=index, max_priority_index=max_priority_index):
            if isinstance(index, torch.Tensor):
                # index can be 1d or 2d
                if index.ndim == 1:
                    is_overwritten = (index == max_priority_index).any()
                else:
                    is_overwritten = (index == max_priority_index).all(-1).any()
            elif isinstance(index, int):
                is_overwritten = index == max_priority_index
            elif isinstance(index, slice):
                # This won't work if called recursively
                is_overwritten = max_priority_index in range(
                    index.indices(self._max_capacity)
                )
            elif isinstance(index, tuple):
                is_overwritten = isinstance(max_priority_index, tuple)
                if is_overwritten:
                    for idx, mpi in zip(index, max_priority_index):
                        is_overwritten &= check_index(idx, mpi)
            else:
                raise TypeError(f"index of type {type(index)} is not recognized.")
            return is_overwritten

        is_overwritten = check_index()
        if isinstance(is_overwritten, torch.Tensor):
            if is_overwritten.device.type == "cuda":
                self._max_priority = None
                return
            is_overwritten = bool(is_overwritten.item())
        if is_overwritten:
            self._max_priority = None

    def _tree_argmax(self) -> tuple[torch.Tensor, torch.Tensor]:
        device = self.device
        indices = torch.arange(self._max_capacity, dtype=torch.long, device=device)
        values = torch.as_tensor(self._sum_tree[indices], device=device)
        return values.max(0)

    def _retransform_priority_trees(self, old_alpha: float, new_alpha: float) -> None:
        """Rewrites the tree entries from ``(p + eps) ** old_alpha`` to ``(p + eps) ** new_alpha``.

        A single O(capacity) pass keeps the sampling probabilities (and the
        within-buffer max-priority recomputation, which inverts tree values
        with the current ``alpha``) consistent when ``alpha`` changes, e.g.
        when annealed by a
        :class:`~torchrl.data.replay_buffers.scheduler.ParameterScheduler`.
        ``_max_priority`` is tracked in the raw domain and needs no update.
        """
        device = self.device
        indices = torch.arange(self._max_capacity, dtype=torch.long, device=device)
        values = torch.as_tensor(self._sum_tree[indices], device=device)
        # Entries that were never written hold the sum-tree neutral value 0
        # and must stay 0; written entries hold (p + eps) ** alpha > 0.
        written = values > 0
        if not written.any():
            return
        if old_alpha == 0:
            # With alpha == 0 every written entry is 1.0, so the raw
            # priorities cannot be recovered from the trees. Keep the stored
            # (uniform) values; they are corrected as entries get their
            # priority updated.
            warnings.warn(
                "Changing alpha away from 0 on a PrioritizedSampler that "
                "already holds priorities cannot recover the raw priorities "
                "from the sum/min trees (alpha == 0 stores 1.0 for every "
                "written entry). Sampling stays uniform for those entries "
                "until their priority is next updated, so annealing away "
                "from exactly 0 is approximate."
            )
            return
        indices = indices[written]
        raw = (values[written].double() ** (1.0 / old_alpha) - self._eps).clamp_min(0)
        new_values = ((raw + self._eps) ** new_alpha).to(self.dtype)
        self._sum_tree[indices] = new_values
        self._min_tree[indices] = new_values

    def _recompute_max_priority_from_tree(self) -> None:
        """Recomputes the raw ``_max_priority`` from the sum-tree entries.

        The sum-tree stores ``(p + eps) ** alpha`` while the tracked max
        priority lives in the raw domain, so the tree max is inverted before
        being stored. Used when restoring a checkpoint saved before the
        raw-domain convention (pytorch/rl#3925), whose persisted
        ``_max_priority`` may hold a transformed tree value.
        """
        if self._alpha == 0:
            # (p + eps) ** 0 == 1 for every written entry: the raw max cannot
            # be recovered from the tree. Keep the restored value.
            return
        maxval, maxidx = self._tree_argmax()
        if maxval <= 0:
            # nothing was ever written to the trees
            self._max_priority = None
            return
        maxval = (maxval ** (1.0 / self._alpha) - self._eps).clamp_min(0)
        self._max_priority = (maxval, maxidx)

    @property
    def default_priority(self) -> float | torch.Tensor:
        # Return the RAW max priority. Every consumer feeds this value back through
        # ``update_priority``, which applies the ``(p + eps) ** alpha`` transform
        # exactly once. Returning an already-transformed value here caused new items
        # to be transformed twice (``((p + eps) ** alpha + eps) ** alpha``), which
        # systematically under-prioritized them (for ``alpha < 1``) and broke PER's
        # "new experience is sampled at least once" guarantee.
        mp = self._max_priority[0]
        if mp is None:
            mp = 1.0
        if isinstance(mp, torch.Tensor):
            mp = mp.to(self.device)
        return mp

    def sample(self, storage: Storage, batch_size: int) -> torch.Tensor:
        self._maybe_init_from_storage(storage)
        if len(storage) == 0:
            raise RuntimeError(_EMPTY_STORAGE_ERROR)
        tree_device = self.device
        is_cuda = tree_device.type == "cuda"
        if is_cuda:
            left = torch.zeros((), dtype=torch.long, device=tree_device)
            right = torch.full((), len(storage), dtype=torch.long, device=tree_device)
            p_sum = self._sum_tree.query(left, right)
            p_min = self._min_tree.query(left, right)
        else:
            p_sum = self._sum_tree.query(0, len(storage))
            p_min = self._min_tree.query(0, len(storage))

        if not is_cuda:
            if p_sum <= 0:
                raise RuntimeError("non-positive p_sum")
            if p_min <= 0:
                raise RuntimeError("non-positive p_min")
        # For some undefined reason, only np.random works here.
        # All PT attempts fail, even when subsequently transformed into numpy
        if is_cuda:
            mass = torch.rand(batch_size, device=tree_device, generator=self._rng)
            mass = mass * p_sum
        elif self._rng is None:
            mass = np.random.uniform(0.0, p_sum, size=batch_size)
        else:
            mass = torch.rand(batch_size, generator=self._rng) * p_sum

        # mass = torch.zeros(batch_size, dtype=torch.double).uniform_(0.0, p_sum)
        # mass = torch.rand(batch_size).mul_(p_sum)
        index = self._sum_tree.scan_lower_bound(mass)
        index = torch.as_tensor(index)
        if index.device != tree_device:
            index = index.to(tree_device)
        if not index.ndim:
            index = index.unsqueeze(0)
        index.clamp_max_(len(storage) - 1)
        weight = torch.as_tensor(self._sum_tree[index], device=tree_device)
        if not is_cuda:
            # get indices where weight is 0
            zero_weight = weight == 0
            while zero_weight.any():
                index = torch.where(zero_weight, index - 1, index)
                if (index < 0).any():
                    raise RuntimeError("Failed to find a suitable index")
                weight = torch.as_tensor(self._sum_tree[index])
                zero_weight = weight == 0

        # Importance sampling weight formula:
        #   w_i = (p_i / sum(p) * N) ^ (-beta)
        #   weight_i = w_i / max(w)
        #   weight_i = (p_i / sum(p) * N) ^ (-beta) /
        #       ((min(p) / sum(p) * N) ^ (-beta))
        #   weight_i = ((p_i / sum(p) * N) / (min(p) / sum(p) * N)) ^ (-beta)
        #   weight_i = (p_i / min(p)) ^ (-beta)
        # weight = np.power(weight / (p_min + self._eps), -self._beta)
        weight = torch.pow(weight / p_min, -self._beta)
        if storage.ndim > 1:
            index = unravel_index(index, storage.shape)
        return index, {"priority_weight": weight}

    def add(self, index: torch.Tensor | int) -> None:
        super().add(index)
        self._maybe_erase_max_priority(index)

    def extend(self, index: torch.Tensor | tuple) -> None:
        super().extend(index)
        self._maybe_erase_max_priority(index)

[docs] @torch.no_grad() def update_priority( self, index: int | torch.Tensor, priority: float | torch.Tensor, *, storage: TensorStorage | None = None, ) -> None: # noqa: D417 """Updates the priority of the data pointed by the index. Args: index (int or torch.Tensor): indexes of the priorities to be updated. priority (Number or torch.Tensor): new priorities of the indexed elements. Keyword Args: storage (Storage, optional): a storage used to map the Nd index size to the 1d size of the sum_tree and min_tree. Only required whenever ``index.ndim > 2``. """ self._maybe_init_from_storage(storage) tree_device = self.device priority = torch.as_tensor(priority, device=tree_device).detach() index = torch.as_tensor(index, dtype=torch.long, device=tree_device) # we need to reshape priority if it has more than one element or if it has # a different shape than index if priority.numel() > 1 and priority.shape != index.shape: try: priority = priority.reshape(index.shape[:1]) except Exception as err: raise RuntimeError( "priority should be a number or an iterable of the same " f"length as index. Got priority of shape {priority.shape} and index " f"{index.shape}." ) from err elif priority.numel() <= 1: priority = priority.squeeze() # MaxValueWriter will set -1 for items in the data that we don't want # to update. We therefore have to keep only the non-negative indices. if _is_int(index) and not isinstance(index, torch.Tensor): if index == -1: return else: if index.ndim == 0: index = index.view(1) if priority.ndim == 0: priority = priority.view(1) if index.ndim > 1: if storage is None: raise RuntimeError( "storage should be provided to Sampler.update_priority when the storage has more " "than one dimension." ) try: shape = storage.shape except AttributeError: raise AttributeError( "Could not retrieve the storage shape. If your storage is not a TensorStorage subclass " "or its shape isn't accessible via the shape attribute, submit an issue on GitHub." ) if tree_device.type == "cuda": multipliers = torch.ones( index.shape[-1], dtype=torch.long, device=tree_device ) for dim in range(index.shape[-1] - 2, -1, -1): multipliers[dim] = multipliers[dim + 1] * shape[dim + 1] index = (index * multipliers).sum(-1) else: index = torch.as_tensor( np.ravel_multi_index(index.unbind(-1), shape) ) valid_index = index >= 0 if tree_device.type == "cuda": index = index[valid_index] if priority.ndim: priority = priority[valid_index] if index.numel() == 0: return elif not valid_index.any(): return elif not valid_index.all(): index = index[valid_index] if priority.ndim: priority = priority[valid_index] max_p, max_p_idx = priority.max(dim=0) cur_max_priority, cur_max_priority_index = self._max_priority if cur_max_priority is None: cur_max_priority, cur_max_priority_index = self._max_priority = ( max_p, index[max_p_idx] if index.ndim else index, ) elif tree_device.type == "cuda": if self._max_priority_within_buffer: cur_max_priority, cur_max_priority_index = max_p, ( index[max_p_idx] if index.ndim else index ) else: cur_max_priority = torch.maximum( max_p, torch.as_tensor(cur_max_priority, device=tree_device) ) self._max_priority = (cur_max_priority, cur_max_priority_index) elif max_p > cur_max_priority: cur_max_priority, cur_max_priority_index = self._max_priority = ( max_p, index[max_p_idx] if index.ndim else index, ) priority = torch.pow(priority + self._eps, self._alpha) self._sum_tree[index] = priority self._min_tree[index] = priority if self._max_priority_within_buffer and cur_max_priority_index is not None: if self._alpha == 0: # With alpha == 0 the tree stores (p + eps) ** 0 == 1 for every # entry, so the raw priorities of untouched entries cannot be # recovered from it. Keep the raw max tracked above instead of # storing a transformed value in ``_max_priority``; sampling is # uniform in this regime, so a stale max only matters if alpha # is raised later (see the ``alpha`` setter). return if tree_device.type == "cuda": maxval, maxidx = self._tree_argmax() elif (index == cur_max_priority_index).any(): maxval, maxidx = self._tree_argmax() else: return # ``maxval`` is read from the sum-tree, which stores (p + eps) ** alpha. # Convert it back to the raw priority so ``_max_priority`` is always the # raw max, matching the non-recomputed path and what ``default_priority`` # expects (it re-applies the alpha transform once via ``update_priority``). maxval = (maxval ** (1.0 / self._alpha) - self._eps).clamp_min(0) self._max_priority = (maxval, maxidx)
def mark_update( self, index: int | torch.Tensor, *, storage: Storage | None = None ) -> None: self.update_priority(index, self.default_priority, storage=storage) def state_dict(self) -> dict[str, Any]: return { "_schema_version": self._STATE_SCHEMA_VERSION, "_alpha": self._alpha, "_beta": self._beta, "_eps": self._eps, "_max_priority": self._max_priority, "_sum_tree": deepcopy(self._sum_tree), "_min_tree": deepcopy(self._min_tree), } def load_state_dict(self, state_dict: dict[str, Any]) -> None: # Version-less payloads predate pytorch/rl#3925, whose within-buffer # recompute stored the transformed tree value in _max_priority. version = state_dict.get("_schema_version", 0) self._alpha = state_dict["_alpha"] self._beta = state_dict["_beta"] self._eps = state_dict["_eps"] # deepcopy to decouple the sampler state from the caller's objects # (this also clones any tensor held in _max_priority) self._max_priority = deepcopy(state_dict["_max_priority"]) self._sum_tree = deepcopy(state_dict["_sum_tree"]) self._min_tree = deepcopy(state_dict["_min_tree"]) if ( version < 1 and self._max_priority_within_buffer and self._max_priority[0] is not None ): self._recompute_max_priority_from_tree() @implement_for("torch", None, "2.5.0") def dumps(self, path): raise NotImplementedError("This method is not implemented for Torch < 2.5.0") @implement_for("torch", "2.5.0", None) def dumps(self, path): # noqa: F811 path = Path(path).absolute() path.mkdir(exist_ok=True) try: mm_st = MemoryMappedTensor.from_filename( shape=(self._max_capacity,), dtype=torch.float64, filename=path / "sumtree.memmap", ) mm_mt = MemoryMappedTensor.from_filename( shape=(self._max_capacity,), dtype=torch.float64, filename=path / "mintree.memmap", ) except FileNotFoundError: mm_st = MemoryMappedTensor.empty( (self._max_capacity,), dtype=torch.float64, filename=path / "sumtree.memmap", ) mm_mt = MemoryMappedTensor.empty( (self._max_capacity,), dtype=torch.float64, filename=path / "mintree.memmap", ) mm_st.copy_( torch.as_tensor([self._sum_tree[i] for i in range(self._max_capacity)]) ) mm_mt.copy_( torch.as_tensor([self._min_tree[i] for i in range(self._max_capacity)]) ) metadata = tree_map( float, { "_alpha": self._alpha, "_beta": self._beta, "_eps": self._eps, "_max_priority": self._max_priority, "_max_capacity": self._max_capacity, }, ) metadata["_schema_version"] = self._STATE_SCHEMA_VERSION with open(path / "sampler_metadata.json", "w") as file: json.dump(metadata, file) @implement_for("torch", None, "2.5.0") def loads(self, path): raise NotImplementedError("This method is not implemented for Torch < 2.5.0") @implement_for("torch", "2.5.0", None) def loads(self, path): # noqa: F811 path = Path(path).absolute() with open(path / "sampler_metadata.json") as file: metadata = json.load(file) # Version-less payloads predate pytorch/rl#3925, whose within-buffer # recompute stored the transformed tree value in _max_priority. version = metadata.get("_schema_version", 0) self._alpha = metadata["_alpha"] self._beta = metadata["_beta"] self._eps = metadata["_eps"] maxp = tree_map( lambda dest, orig: dest.copy_(orig) if dest is not None else orig, tuple(self._max_priority), tuple(metadata["_max_priority"]), ) if all(x is None for x in self._max_priority): self._max_priority = maxp _max_capacity = metadata["_max_capacity"] if _max_capacity != self._max_capacity: raise RuntimeError( f"max capacity of loaded metadata ({_max_capacity}) differs from self._max_capacity ({self._max_capacity})." ) mm_st = MemoryMappedTensor.from_filename( shape=(self._max_capacity,), dtype=torch.float64, filename=path / "sumtree.memmap", ) mm_mt = MemoryMappedTensor.from_filename( shape=(self._max_capacity,), dtype=torch.float64, filename=path / "mintree.memmap", ) for i, elt in enumerate(mm_st.tolist()): self._sum_tree[i] = elt for i, elt in enumerate(mm_mt.tolist()): self._min_tree[i] = elt if ( version < 1 and self._max_priority_within_buffer and self._max_priority[0] is not None ): self._recompute_max_priority_from_tree()