# 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 contextvars
import importlib.util
import typing
from collections.abc import Iterable
from typing import Any
import torch
import torch.nn.functional as F
import torch.utils.checkpoint as _torch_checkpoint
from tensordict import TensorDict, TensorDictBase, unravel_key_list
from tensordict.base import NO_DEFAULT
from tensordict.nn import dispatch, TensorDictModuleBase as ModuleBase
from tensordict.utils import expand_as_right, NestedKey, set_lazy_legacy
from torch import nn, Tensor
from torch.autograd.function import once_differentiable
from torch.nn.modules.rnn import RNNCellBase
from torchrl._utils import (
_ContextManager,
_DecoratorContextManager,
_triton_version_at_least,
implement_for,
is_compiling,
)
from torchrl.data.tensor_specs import Unbounded
from torchrl.modules.tensordict_module._rnn_precision import _validate_user_precision
try:
# ``torch.compiler`` was added in torch 2.0; on older builds (exercised by
# the olddeps CI job) the module import below would otherwise raise
# ``AttributeError: module 'torch' has no attribute 'compiler'``.
from torch.compiler import disable as _compiler_disable
except (ImportError, AttributeError):
def _compiler_disable(fn=None, *args, **kwargs):
# No-op passthrough: on torch builds without ``torch.compiler`` there is
# no graph to break out of, and the wrapped helpers run eagerly anyway.
if fn is None:
return lambda inner: inner
return fn
_has_hoptorch = importlib.util.find_spec("hoptorch") is not None
_hoptorch_scan = None
_ensure_scan_backward = None
def _get_hoptorch_scan() -> tuple[Any, Any]:
global _hoptorch_scan, _ensure_scan_backward
if not _has_hoptorch:
raise NotImplementedError(
"recurrent_backend='scan' requires hoptorch. Install it with "
"`pip install hoptorch>=0.1.4`."
)
if _hoptorch_scan is None or _ensure_scan_backward is None:
# Optional dependency: keep the import lazy because CI jobs install
# TorchRL with --no-deps, even though hoptorch is a core dependency.
from hoptorch import scan
from hoptorch.scan import ensure_scan_backward
_hoptorch_scan = scan
_ensure_scan_backward = ensure_scan_backward
return _hoptorch_scan, _ensure_scan_backward
def _maybe_warm_scan_backward(device: torch.device | str | int | None) -> None:
device = torch.device("cpu") if device is None else torch.device(device)
if device.type != "meta":
_, ensure_scan_backward = _get_hoptorch_scan()
ensure_scan_backward(device)
# Mirrors the probe in :mod:`torchrl.modules.tensordict_module._rnn_triton`.
# The backend requires ``triton.language.extra.libdevice`` which is only
# available from Triton 2.2 onwards. Older Triton builds fall back to the
# scan / pad backends.
_has_triton = _triton_version_at_least("2.2")
def _canonical_stride(shape: typing.Sequence[int]) -> tuple[int, ...]:
strides: list[int] = []
running = 1
for size in reversed(shape):
strides.append(running)
running *= size
strides.reverse()
return tuple(strides)
def _canonical_contiguous(value: Tensor) -> Tensor:
# torch._higher_order_ops.scan and the triton RNN kernels read strides
# directly and reject inputs whose strides don't match the canonical
# row-major layout. A size-1 dim left behind by indexing+transposing a
# hidden-state buffer can pass is_contiguous() while having non-canonical
# strides — re-materialize then.
if tuple(value.stride()) == _canonical_stride(value.shape):
return value
result = torch.empty_like(value, memory_format=torch.contiguous_format)
return result.copy_(value)
def _split_gru_gate_param(
param: Tensor | None,
hidden_size: int,
) -> tuple[Tensor | None, Tensor | None, Tensor | None]:
if param is None:
return None, None, None
gate_chunks = (hidden_size, hidden_size, hidden_size)
return tuple(gate.clone() for gate in param.split(gate_chunks, 0))
def _gru_cell_from_gate_params(
x: Tensor,
hx: Tensor,
weight_ih: tuple[Tensor, Tensor, Tensor],
bias_ih: tuple[Tensor | None, Tensor | None, Tensor | None],
weight_hh: tuple[Tensor, Tensor, Tensor],
bias_hh: tuple[Tensor | None, Tensor | None, Tensor | None],
) -> Tensor:
x = x.view(-1, x.size(1))
i_r = F.linear(x, weight_ih[0], bias_ih[0])
i_i = F.linear(x, weight_ih[1], bias_ih[1])
i_n = F.linear(x, weight_ih[2], bias_ih[2])
h_r = F.linear(hx, weight_hh[0], bias_hh[0])
h_i = F.linear(hx, weight_hh[1], bias_hh[1])
h_n = F.linear(hx, weight_hh[2], bias_hh[2])
resetgate = (i_r + h_r).sigmoid()
inputgate = (i_i + h_i).sigmoid()
newgate = (i_n + (resetgate * h_n)).tanh()
return newgate + inputgate * (hx - newgate)
class _GRUScanFunction(torch.autograd.Function):
"""GRU sequence with a hidden-gradient-only reverse scan.
The input projection and all shared-parameter gradient reductions run over
the flattened batch-time dimensions. Only the recurrent hidden-state
derivative remains inside the reverse scan.
``torch.func`` transforms (``jacrev``, ``vmap``, ``grad``) are not
supported: this Function uses the ctx-style ``forward``, which PyTorch
rejects with its standard ``setup_context`` error before the recurrence
runs. The backward is marked ``once_differentiable`` because it consumes
gate states saved without autograd history.
"""
@staticmethod
def forward(ctx, x, initial, reset_hidden, is_init, w_ih, w_hh, b_ih, b_hh):
time, batch, _ = x.shape
hidden_size = initial.shape[-1]
gate_chunks = (hidden_size, hidden_size, hidden_size)
gates_x = F.linear(x.flatten(0, 1), w_ih, b_ih).view(
time, batch, 3 * hidden_size
)
def step(carry, inputs):
gates_x_t, reset_hidden_t, init_t = inputs
h_previous = torch.where(init_t.unsqueeze(-1), reset_hidden_t, carry)
gates_h = F.linear(h_previous, w_hh, b_hh)
i_r, i_z, i_n = gates_x_t.split(gate_chunks, -1)
h_r, h_z, h_n = gates_h.split(gate_chunks, -1)
resetgate = (i_r + h_r).sigmoid()
updategate = (i_z + h_z).sigmoid()
newgate = (i_n + resetgate * h_n).tanh()
hidden = newgate + updategate * (h_previous - newgate)
return hidden, (
hidden.clone(),
resetgate,
updategate,
newgate,
h_n,
)
_, (hidden, resetgate, updategate, newgate, h_n) = _scan(
step, initial, (gates_x, reset_hidden, is_init), dim=0
)
ctx.hidden_size = hidden_size
ctx.save_for_backward(
x,
initial,
reset_hidden,
is_init,
w_ih,
w_hh,
hidden,
resetgate,
updategate,
newgate,
h_n,
)
return hidden
@staticmethod
@once_differentiable
def backward(ctx, grad_hidden):
(
x,
initial,
reset_hidden,
is_init,
w_ih,
w_hh,
hidden,
resetgate,
updategate,
newgate,
h_n,
) = ctx.saved_tensors
hidden_size = ctx.hidden_size
time, batch, input_size = x.shape
# The forward may have run under autocast with fp32 parameters, so the
# weights are cast to the gradient dtype before the backward linears.
gate_dtype = torch.promote_types(grad_hidden.dtype, resetgate.dtype)
w_hh_t = w_hh.t().to(gate_dtype)
previous_hidden = torch.cat((initial.unsqueeze(0), hidden[:-1]), 0)
previous_hidden = torch.where(
is_init.unsqueeze(-1), reset_hidden, previous_hidden
)
def reverse_step(d_hidden_next, inputs):
(
d_hidden_t,
previous_hidden_t,
resetgate_t,
updategate_t,
newgate_t,
h_n_t,
init_t,
) = inputs
d_hidden = d_hidden_t + d_hidden_next
d_newgate = d_hidden * (1 - updategate_t)
d_updategate = d_hidden * (previous_hidden_t - newgate_t)
d_previous_direct = d_hidden * updategate_t
d_newgate_pre = d_newgate * (1 - newgate_t.square())
d_h_n = d_newgate_pre * resetgate_t
d_updategate_pre = d_updategate * updategate_t * (1 - updategate_t)
d_resetgate_pre = d_newgate_pre * h_n_t * resetgate_t * (1 - resetgate_t)
d_gates_x = torch.cat(
(d_resetgate_pre, d_updategate_pre, d_newgate_pre), -1
)
d_gates_h = torch.cat((d_resetgate_pre, d_updategate_pre, d_h_n), -1)
d_previous = d_previous_direct + F.linear(d_gates_h, w_hh_t)
d_hidden_next = torch.where(
init_t.unsqueeze(-1), torch.zeros_like(d_previous), d_previous
)
return d_hidden_next, (d_gates_x, d_gates_h, d_previous)
reversed_inputs = tuple(
value.flip(0)
for value in (
grad_hidden,
previous_hidden,
resetgate,
updategate,
newgate,
h_n,
is_init,
)
)
d_initial, (d_gates_x, d_gates_h, d_previous) = _scan(
reverse_step,
torch.zeros_like(initial),
reversed_inputs,
dim=0,
)
d_gates_x = d_gates_x.flip(0)
d_gates_h = d_gates_h.flip(0)
d_previous = d_previous.flip(0)
d_gates_x_flat = d_gates_x.reshape(time * batch, 3 * hidden_size)
d_gates_h_flat = d_gates_h.reshape(time * batch, 3 * hidden_size)
x_flat = x.reshape(time * batch, input_size)
previous_hidden_flat = previous_hidden.reshape(time * batch, hidden_size)
d_x = F.linear(d_gates_x_flat, w_ih.t().to(d_gates_x_flat.dtype)).view_as(x)
d_w_ih = d_gates_x_flat.t() @ x_flat
d_w_hh = d_gates_h_flat.t() @ previous_hidden_flat
d_b_ih = d_gates_x_flat.sum(0)
d_b_hh = d_gates_h_flat.sum(0)
d_reset_hidden = torch.where(
is_init.unsqueeze(-1), d_previous, torch.zeros_like(d_previous)
)
return (
d_x,
d_initial,
d_reset_hidden,
None,
d_w_ih,
d_w_hh,
d_b_ih,
d_b_hh,
)
@implement_for("torch", None, "2.6.0", compilable=True)
def _scan(*args: Any, **kwargs: Any) -> Any:
raise NotImplementedError(
"torch._higher_order_ops.scan is required for the scan recurrent backend "
"and is available in PyTorch >= 2.6.0."
)
@implement_for("torch", "2.6.0", compilable=True)
def _scan(*args: Any, **kwargs: Any) -> Any: # noqa: F811
scan, _ = _get_hoptorch_scan()
return scan(*args, **kwargs)
@_compiler_disable
def _split_and_pad_for_reset(
tensordict_shaped: TensorDictBase,
in_keys: list,
) -> tuple[TensorDictBase | None, torch.Tensor | None, torch.Size | None]:
"""Split a flattened rollout into per-trajectory padded windows on reset.
Returns ``(padded_td, splits, original_shape)`` when ``is_init`` fires
mid-row (so the rollout must be cut into per-trajectory windows of shape
``[N, T']`` before the RNN runs), or ``(None, None, None)`` otherwise.
This is an eager island under :func:`torch.compile` (``compiler.disable``):
the per-trajectory lengths are data-dependent, so both the padded shape and
the boolean-mask reconstruction in :func:`_inv_pad_for_reset`
(``tensor[mask]``) produce data-dependent shapes that FakeTensor tracing
cannot represent -- Inductor otherwise raises ``torch.Size() takes an
iterable of 'int' (item 0 is 'FakeTensor')`` from ``broadcast_shapes``. The
surrounding RNN compute still compiles; only this glue runs eagerly.
"""
from torchrl.objectives.value.utils import (
_get_num_per_traj_init,
_split_and_pad_sequence,
)
is_init = tensordict_shaped["is_init"].squeeze(-1)
if not is_init[..., 1:].any():
return None, None, None
splits = _get_num_per_traj_init(is_init)
original_shape = tensordict_shaped.shape
padded = _split_and_pad_sequence(
tensordict_shaped.select(*in_keys, strict=False), splits
)
return padded, splits, original_shape
@_compiler_disable
def _inv_pad_for_reset(
tensordict_shaped: TensorDictBase,
splits: torch.Tensor,
shape: torch.Size,
) -> TensorDictBase:
"""Inverse of :func:`_split_and_pad_for_reset` (eager island, see there)."""
from torchrl.objectives.value.utils import _inv_pad_sequence
return _inv_pad_sequence(tensordict_shaped, splits).reshape(shape)
def _place_at_traj_end(
h: torch.Tensor, splits: torch.Tensor, steps: int
) -> torch.Tensor:
"""Scatter per-trajectory hidden states onto a zero-padded time grid.
Given ``h`` of shape ``[N, *F]``, returns a tensor of shape
``[N, steps, *F]`` whose row ``i`` is zero everywhere except at index
``splits[i] - 1`` along dim 1, where it holds ``h[i]``. Out-of-place
``scatter`` is used so the call is compatible with :func:`torch.vmap`.
"""
h_padded = torch.zeros(
h.shape[0], steps, *h.shape[1:], device=h.device, dtype=h.dtype
)
idx = (
(splits - 1)
.long()
.view(-1, 1, *([1] * (h.dim() - 1)))
.expand_as(h.unsqueeze(1))
)
return h_padded.scatter(1, idx, h.unsqueeze(1))
def _num_directions(rnn: nn.RNNBase) -> int:
return 2 if rnn.bidirectional else 1
def _end_mask_from_is_init(is_init: torch.Tensor) -> torch.Tensor:
end_mask = torch.empty_like(is_init)
end_mask[:, :-1] = is_init[:, 1:]
end_mask[:, -1] = True
return end_mask
class LSTMCell(RNNCellBase):
r"""A long short-term memory (LSTM) cell that performs the same operation as nn.LSTMCell but is fully coded in Python.
.. note::
This class is implemented without relying on CuDNN, which makes it compatible with :func:`torch.vmap` and :func:`torch.compile`.
Examples:
>>> import torch
>>> from torchrl.modules.tensordict_module.rnn import LSTMCell
>>> device = torch.device("cuda") if torch.cuda.device_count() else torch.device("cpu")
>>> B = 2
>>> N_IN = 10
>>> N_OUT = 20
>>> V = 4 # vector size
>>> lstm_cell = LSTMCell(input_size=N_IN, hidden_size=N_OUT, device=device)
# single call
>>> x = torch.randn(B, 10, device=device)
>>> h0 = torch.zeros(B, 20, device=device)
>>> c0 = torch.zeros(B, 20, device=device)
>>> with torch.no_grad():
... (h1, c1) = lstm_cell(x, (h0, c0))
# vectorised call - not possible with nn.LSTMCell
>>> def call_lstm(x, h, c):
... h_out, c_out = lstm_cell(x, (h, c))
... return h_out, c_out
>>> batched_call = torch.vmap(call_lstm)
>>> x = torch.randn(V, B, 10, device=device)
>>> h0 = torch.zeros(V, B, 20, device=device)
>>> c0 = torch.zeros(V, B, 20, device=device)
>>> with torch.no_grad():
... (h1, c1) = batched_call(x, h0, c0)
"""
__doc__ += nn.LSTMCell.__doc__
def __init__(
self,
input_size: int,
hidden_size: int,
bias: bool = True,
device=None,
dtype=None,
) -> None:
factory_kwargs = {"device": device, "dtype": dtype}
super().__init__(input_size, hidden_size, bias, num_chunks=4, **factory_kwargs)
def forward(
self, input: Tensor, hx: tuple[Tensor, Tensor] | None = None
) -> tuple[Tensor, Tensor]:
if input.dim() not in (1, 2):
raise ValueError(
f"LSTMCell: Expected input to be 1D or 2D, got {input.dim()}D instead"
)
if hx is not None:
for idx, value in enumerate(hx):
if value.dim() not in (1, 2):
raise ValueError(
f"LSTMCell: Expected hx[{idx}] to be 1D or 2D, got {value.dim()}D instead"
)
is_batched = input.dim() == 2
if not is_batched:
input = input.unsqueeze(0)
if hx is None:
zeros = torch.zeros(
input.size(0), self.hidden_size, dtype=input.dtype, device=input.device
)
hx = (zeros, zeros)
else:
hx = (hx[0].unsqueeze(0), hx[1].unsqueeze(0)) if not is_batched else hx
ret = self.lstm_cell(input, hx[0], hx[1])
if not is_batched:
ret = (ret[0].squeeze(0), ret[1].squeeze(0))
return ret
def lstm_cell(self, x, hx, cx):
x = x.view(-1, x.size(1))
gates = F.linear(x, self.weight_ih, self.bias_ih) + F.linear(
hx, self.weight_hh, self.bias_hh
)
i_gate, f_gate, g_gate, o_gate = gates.chunk(4, 1)
i_gate = i_gate.sigmoid()
f_gate = f_gate.sigmoid()
g_gate = g_gate.tanh()
o_gate = o_gate.sigmoid()
cy = cx * f_gate + i_gate * g_gate
hy = o_gate * cy.tanh()
return hy, cy
# copy LSTM
class LSTMBase(nn.RNNBase):
"""A Base module for LSTM. Inheriting from LSTMBase enables compatibility with torch.compile."""
def __init__(self, *args, **kwargs):
return super().__init__("LSTM", *args, **kwargs)
for attr in nn.LSTM.__dict__:
if attr != "__init__":
setattr(LSTMBase, attr, getattr(nn.LSTM, attr))
class LSTM(LSTMBase):
"""A PyTorch module for executing multiple steps of a multi-layer LSTM. The module behaves exactly like :class:`torch.nn.LSTM`, but this implementation is exclusively coded in Python.
.. note::
This class is implemented without relying on CuDNN, which makes it compatible with :func:`torch.vmap` and :func:`torch.compile`.
Examples:
>>> import torch
>>> from torchrl.modules.tensordict_module.rnn import LSTM
>>> device = torch.device("cuda") if torch.cuda.device_count() else torch.device("cpu")
>>> B = 2
>>> T = 4
>>> N_IN = 10
>>> N_OUT = 20
>>> N_LAYERS = 2
>>> V = 4 # vector size
>>> lstm = LSTM(
... input_size=N_IN,
... hidden_size=N_OUT,
... device=device,
... num_layers=N_LAYERS,
... )
# single call
>>> x = torch.randn(B, T, N_IN, device=device)
>>> h0 = torch.zeros(N_LAYERS, B, N_OUT, device=device)
>>> c0 = torch.zeros(N_LAYERS, B, N_OUT, device=device)
>>> with torch.no_grad():
... h1, c1 = lstm(x, (h0, c0))
# vectorised call - not possible with nn.LSTM
>>> def call_lstm(x, h, c):
... h_out, c_out = lstm(x, (h, c))
... return h_out, c_out
>>> batched_call = torch.vmap(call_lstm)
>>> x = torch.randn(V, B, T, 10, device=device)
>>> h0 = torch.zeros(V, N_LAYERS, B, N_OUT, device=device)
>>> c0 = torch.zeros(V, N_LAYERS, B, N_OUT, device=device)
>>> with torch.no_grad():
... h1, c1 = batched_call(x, h0, c0)
"""
__doc__ += nn.LSTM.__doc__
use_scan: bool = False
def __init__(
self,
input_size: int,
hidden_size: int,
num_layers: int = 1,
batch_first: bool = True,
bias: bool = True,
dropout: float = 0.0,
bidirectional: float = False,
proj_size: int = 0,
device=None,
dtype=None,
use_scan: bool = False,
) -> None:
if bidirectional is True:
raise NotImplementedError(
"Bidirectional LSTMs are not supported yet in this implementation."
)
super().__init__(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
batch_first=batch_first,
dropout=dropout,
bidirectional=bidirectional,
proj_size=proj_size,
device=device,
dtype=dtype,
)
# Opt-in prototype: replace the python time loop with
# ``torch._higher_order_ops.scan``. Requires :func:`torch.compile`
# to capture the scan; eager use will fail. Dropout is not supported
# on this path. See :meth:`_lstm_scan`.
self.use_scan = use_scan
if use_scan:
_maybe_warm_scan_backward(device)
@staticmethod
def _lstm_cell(x, hx, cx, weight_ih, bias_ih, weight_hh, bias_hh):
gates = F.linear(x, weight_ih, bias_ih) + F.linear(hx, weight_hh, bias_hh)
i_gate, f_gate, g_gate, o_gate = gates.chunk(4, 1)
i_gate = i_gate.sigmoid()
f_gate = f_gate.sigmoid()
g_gate = g_gate.tanh()
o_gate = o_gate.sigmoid()
cy = cx * f_gate + i_gate * g_gate
hy = o_gate * cy.tanh()
return hy, cy
def _lstm(self, x, hx, mask=None):
"""Python LSTM rollout over the time dimension.
Shapes:
x: ``(B, T, input_size)`` when ``batch_first=True``, otherwise
``(T, B, input_size)``. ``T`` is the time/step dim.
hx: tuple ``(h, c)`` each of shape
``(num_layers, B, hidden_size)``.
mask: optional ``(B, T)`` (or ``(T, B)``) boolean tensor. Where
``mask`` is False the hidden/cell carry is frozen (the cell is
still evaluated but its output is discarded). Used to mask out
padded time steps in batches with mixed trajectory lengths.
Returns:
outputs: ``(B, T, hidden_size)`` (matches ``batch_first``).
(h_T, c_T): final hidden / cell state, each ``(num_layers, B, hidden_size)``.
Why the unbind/stack-by-layer pattern:
We iterate cells per layer because :class:`LSTMCell` is vmap- and
``torch.compile``-friendly while :class:`nn.LSTM` is not. Unbinding
``h``/``c`` along dim 0 (num_layers) gives a list of per-layer
states; the outer loop walks the time dim, the inner loop walks
layers and feeds layer ``L``'s output as layer ``L+1``'s input
(with dropout in between for training).
"""
if self.use_scan:
return self._lstm_scan(x, hx, mask)
h_t, c_t = hx
h_t, c_t = h_t.unbind(0), c_t.unbind(0)
outputs = []
weight_ihs = []
weight_hhs = []
bias_ihs = []
bias_hhs = []
for weights in self._all_weights:
# Retrieve weights
weight_ihs.append(getattr(self, weights[0]))
weight_hhs.append(getattr(self, weights[1]))
if self.bias:
bias_ihs.append(getattr(self, weights[2]))
bias_hhs.append(getattr(self, weights[3]))
else:
bias_ihs.append(None)
bias_hhs.append(None)
time_dim = int(self.batch_first)
x_unbound = x.unbind(time_dim)
if mask is not None:
mask_unbound = mask.unbind(time_dim)
for t, x_t in enumerate(x_unbound):
h_t_out = []
c_t_out = []
m_t = mask_unbound[t].unsqueeze(-1) if mask is not None else None
for layer, (
weight_ih,
bias_ih,
weight_hh,
bias_hh,
_h_t,
_c_t,
) in enumerate(zip(weight_ihs, bias_ihs, weight_hhs, bias_hhs, h_t, c_t)):
# Run cell
h_new, c_new = self._lstm_cell(
x_t, _h_t, _c_t, weight_ih, bias_ih, weight_hh, bias_hh
)
if m_t is not None:
# Freeze hidden/cell state for batch entries whose
# trajectory has already ended at this time step. The cell
# is still evaluated for the full batch (wasteful but
# vmap/compile-friendly); only the carry is masked.
h_new = torch.where(m_t, h_new, _h_t)
c_new = torch.where(m_t, c_new, _c_t)
h_t_out.append(h_new)
c_t_out.append(c_new)
# Apply dropout if in training mode
if layer < self.num_layers - 1 and self.dropout:
x_t = F.dropout(h_new, p=self.dropout, training=self.training)
else: # No dropout after the last layer
x_t = h_new
h_t = h_t_out
c_t = c_t_out
outputs.append(x_t)
outputs = torch.stack(outputs, dim=time_dim)
return outputs, (torch.stack(h_t_out, 0), torch.stack(c_t_out, 0))
def _lstm_scan(self, x, hx, mask=None):
"""Prototype scan-based time loop. Must be called inside ``torch.compile``.
``torch._higher_order_ops.scan`` is a prototype feature; this path is
opt-in via ``LSTM(..., use_scan=True)`` and replaces the python
``for`` loop over time. Dropout is not supported here.
"""
if self.dropout:
raise NotImplementedError(
"LSTM(use_scan=True) does not support dropout yet."
)
weight_ihs, weight_hhs, bias_ihs, bias_hhs = [], [], [], []
for weights in self._all_weights:
weight_ihs.append(getattr(self, weights[0]).clone())
weight_hhs.append(getattr(self, weights[1]).clone())
bias_ihs.append(getattr(self, weights[2]).clone() if self.bias else None)
bias_hhs.append(getattr(self, weights[3]).clone() if self.bias else None)
# scan iterates along dim 0; permute to time-first if needed.
if self.batch_first:
x = x.transpose(0, 1)
if mask is not None:
mask = mask.transpose(0, 1)
if mask is None:
mask = torch.ones(x.shape[0], x.shape[1], dtype=torch.bool, device=x.device)
num_layers = self.num_layers
def step(carry, inputs):
h_layers, c_layers = carry # each [num_layers, B, H]
x_t, m_t = inputs
m_t = m_t.unsqueeze(-1)
new_h, new_c = [], []
h_unbound = h_layers.unbind(0)
c_unbound = c_layers.unbind(0)
for layer in range(num_layers):
h_prev = h_unbound[layer]
c_prev = c_unbound[layer]
h_new, c_new = self._lstm_cell(
x_t,
h_prev,
c_prev,
weight_ihs[layer],
bias_ihs[layer],
weight_hhs[layer],
bias_hhs[layer],
)
h_new = torch.where(m_t, h_new, h_prev)
c_new = torch.where(m_t, c_new, c_prev)
new_h.append(h_new)
new_c.append(c_new)
x_t = h_new
new_h = torch.stack(new_h, 0).clone()
new_c = torch.stack(new_c, 0).clone()
return (new_h, new_c), x_t.clone()
h0, c0 = hx
(h_final, c_final), outputs = _scan(step, (h0, c0), (x, mask), dim=0)
if self.batch_first:
outputs = outputs.transpose(0, 1)
return outputs, (h_final, c_final)
def forward(self, input, hx=None, mask=None): # noqa: F811
real_hidden_size = self.proj_size if self.proj_size > 0 else self.hidden_size
if input.dim() != 3:
raise ValueError(
f"LSTM: Expected input to be 3D, got {input.dim()}D instead"
)
max_batch_size = input.size(0) if self.batch_first else input.size(1)
if hx is None:
h_zeros = torch.zeros(
self.num_layers,
max_batch_size,
real_hidden_size,
dtype=input.dtype,
device=input.device,
)
c_zeros = torch.zeros(
self.num_layers,
max_batch_size,
self.hidden_size,
dtype=input.dtype,
device=input.device,
)
hx = (h_zeros, c_zeros)
return self._lstm(input, hx, mask)
[docs]
class LSTMModule(ModuleBase):
"""An embedder for an LSTM module.
This class adds the following functionality to :class:`torch.nn.LSTM`:
- Compatibility with TensorDict: the hidden states are reshaped to match
the tensordict batch size.
- Optional multi-step execution: with torch.nn, one has to choose between
:class:`torch.nn.LSTMCell` and :class:`torch.nn.LSTM`, the former being
compatible with single step inputs and the latter being compatible with
multi-step. This class enables both usages.
After construction, the module is *not* set in recurrent mode, ie. it will
expect single steps inputs.
If in recurrent mode, it is expected that the last dimension of the tensordict
marks the number of steps. There is no constrain on the dimensionality of the
tensordict (except that it must be greater than one for temporal inputs).
.. note::
This class can handle multiple consecutive trajectories along the time dimension
*but* the final hidden values should not be trusted in those cases (ie. they
should not be reused for a consecutive trajectory).
The reason is that LSTM returns only the last hidden value, which for the
padded inputs we provide can correspond to a 0-filled input.
Args:
input_size: The number of expected features in the input `x`
hidden_size: The number of features in the hidden state `h`
num_layers: Number of recurrent layers. E.g., setting ``num_layers=2``
would mean stacking two LSTMs together to form a `stacked LSTM`,
with the second LSTM taking in outputs of the first LSTM and
computing the final results. Default: 1
bias: If ``False``, then the layer does not use bias weights `b_ih` and `b_hh`.
Default: ``True``
dropout: If non-zero, introduces a `Dropout` layer on the outputs of each
LSTM layer except the last layer, with dropout probability equal to
:attr:`dropout`. Default: 0
python_based: If ``True``, will use a full Python implementation of the LSTM cell. Default: ``False``
recurrent_backend: backend used in recurrent mode when trajectories reset
in the middle of a batch. ``"pad"`` keeps the existing split/pad
strategy. ``"scan"`` uses a scan loop over the time dimension and
avoids materializing padded trajectory chunks via ``hoptorch``.
``"triton"``
(prototype, CUDA only) uses Triton kernels where available and
otherwise preserves pad-backend recurrent semantics for dropout,
projections and bidirectional layers. ``"auto"`` uses ``"pad"``
in eager mode and ``"scan"`` when called under
:func:`torch.compile`. Default: ``"pad"``.
recurrent_compute_dtype: dtype used for the recurrent matmul inside the
``"triton"`` backend (``torch.float32`` -> TF32 on H100, default;
``torch.bfloat16`` -> bigger SMEM margin, lower precision).
Ignored by the other backends. Default: ``torch.float32``.
recurrent_recompute: when set to ``"full"``, trade extra compute for
lower backward activation memory. For ``recurrent_backend="triton"``
this drops the per-step gate buffers (``save_i/f/g/o/save_tanhc``)
from the autograd save set and replays the forward kernel during
backward. For ``recurrent_backend="scan"`` this swaps the
``torch._higher_order_ops.scan`` HOP for a python time-loop wrapped
with :func:`torch.utils.checkpoint.checkpoint`; gradients then
match the ``"pad"`` (cuDNN) backend to float precision. Only
``"none"`` (default) and ``"full"`` are accepted today; the
``"pad"`` backend rejects non-``"none"`` values because cuDNN
manages its own backward workspace. Default: ``"none"``.
recurrent_matmul_precision: precision used by ``tl.dot`` inside the
``"triton"`` backend's recurrent matmul (and the matching cuBLAS
calls in the autograd wrapper). Concrete modes: ``"ieee"`` (full
IEEE FP32, off tensor cores), ``"tf32"`` (matches cuDNN's
default, fastest on Ampere+), ``"tf32x3"`` (three-product
compensated TF32, ~22 bits of mantissa on tensor cores).
GPU-aware presets: ``"fast"`` (Ampere+ → ``"tf32"``, else
``"ieee"``) and ``"high-prec"`` (Ampere+ → ``"tf32x3"``, else
``"ieee"``). Or ``"auto"`` to derive from
:func:`torch.get_float32_matmul_precision` and the
``TORCHRL_RNN_PRECISION`` env var (``"highest"`` → ``"ieee"``,
``"high"`` → ``"high-prec"``, ``"medium"`` → ``"fast"``). See
:func:`torchrl.modules.set_recurrent_matmul_precision`. Ignored
by the other backends. Default: ``"auto"``.
Keyword Args:
in_key (str or tuple of str): the input key of the module. Exclusive use
with ``in_keys``. If provided, the recurrent keys are assumed to be
["recurrent_state_h", "recurrent_state_c"] and the ``in_key`` will be
appended before these.
in_keys (list of str): a triplet of strings corresponding to the input value,
first and second hidden key. Exclusive with ``in_key``.
out_key (str or tuple of str): the output key of the module. Exclusive use
with ``out_keys``. If provided, the recurrent keys are assumed to be
[("next", "recurrent_state_h"), ("next", "recurrent_state_c")]
and the ``out_key`` will be
appended before these.
out_keys (list of str): a triplet of strings corresponding to the output value,
first and second hidden key.
.. note::
For a better integration with TorchRL's environments, the best naming
for the output hidden key is ``("next", <custom_key>)``, such
that the hidden values are passed from step to step during a rollout.
device (torch.device or compatible): the device of the module.
lstm (torch.nn.LSTM, optional): an LSTM instance to be wrapped.
Exclusive with other nn.LSTM arguments.
default_recurrent_mode (bool, optional): if provided, the recurrent mode if it hasn't been overridden
by the :class:`~torchrl.modules.set_recurrent_mode` context manager / decorator.
Defaults to ``False``.
Attributes:
recurrent_mode: Returns the recurrent mode of the module.
Methods:
set_recurrent_mode: controls whether the module should be executed in
recurrent mode.
make_tensordict_primer: creates the TensorDictPrimer transforms for the environment to be aware of the
recurrent states of the RNN.
.. note:: This module relies on specific ``recurrent_state`` keys being present in the input
TensorDicts. To generate a :class:`~torchrl.envs.transforms.TensorDictPrimer` transform that will automatically
add hidden states to the environment TensorDicts, use the method :func:`~torchrl.modules.rnn.LSTMModule.make_tensordict_primer`.
If this class is a submodule in a larger module, the method :func:`~torchrl.modules.utils.get_primers_from_module` can be called
on the parent module to automatically generate the primer transforms required for all submodules, including this one.
Examples:
>>> from torchrl.envs import TransformedEnv, InitTracker
>>> from torchrl.envs import GymEnv
>>> from torchrl.modules import MLP, LSTMModule
>>> from torch import nn
>>> from tensordict.nn import TensorDictSequential as Seq, TensorDictModule as Mod
>>> env = TransformedEnv(GymEnv("Pendulum-v1"), InitTracker())
>>> lstm_module = LSTMModule(
... input_size=env.observation_spec["observation"].shape[-1],
... hidden_size=64,
... in_keys=["observation", "rs_h", "rs_c"],
... out_keys=["intermediate", ("next", "rs_h"), ("next", "rs_c")])
>>> mlp = MLP(num_cells=[64], out_features=1)
>>> policy = Seq(lstm_module, Mod(mlp, in_keys=["intermediate"], out_keys=["action"]))
>>> policy(env.reset())
TensorDict(
fields={
action: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.float32, is_shared=False),
done: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
intermediate: Tensor(shape=torch.Size([64]), device=cpu, dtype=torch.float32, is_shared=False),
is_init: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
next: TensorDict(
fields={
rs_c: Tensor(shape=torch.Size([1, 64]), device=cpu, dtype=torch.float32, is_shared=False),
rs_h: Tensor(shape=torch.Size([1, 64]), device=cpu, dtype=torch.float32, is_shared=False)},
batch_size=torch.Size([]),
device=cpu,
is_shared=False),
observation: Tensor(shape=torch.Size([3]), device=cpu, dtype=torch.float32, is_shared=False)},
terminated: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
truncated: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False)},
batch_size=torch.Size([]),
device=cpu,
is_shared=False)
"""
DEFAULT_IN_KEYS = ["recurrent_state_h", "recurrent_state_c"]
DEFAULT_OUT_KEYS = [("next", "recurrent_state_h"), ("next", "recurrent_state_c")]
def __init__(
self,
input_size: int | None = None,
hidden_size: int | None = None,
num_layers: int = 1,
bias: bool = True,
batch_first=True,
dropout=0,
proj_size=0,
bidirectional=False,
python_based=False,
recurrent_backend: typing.Literal["auto", "pad", "scan", "triton"] = "pad",
recurrent_compute_dtype: torch.dtype = torch.float32,
recurrent_recompute: typing.Literal["none", "full"] = "none",
recurrent_matmul_precision: typing.Literal[
"auto", "fast", "high-prec", "ieee", "tf32", "tf32x3"
] = "auto",
*,
in_key=None,
in_keys=None,
out_key=None,
out_keys=None,
device=None,
lstm=None,
default_recurrent_mode: bool | None = None,
):
super().__init__()
if recurrent_backend not in {"auto", "pad", "scan", "triton"}:
raise ValueError(
"recurrent_backend must be one of 'auto', 'pad', 'scan' or 'triton'. "
f"Got {recurrent_backend}."
)
if recurrent_backend == "triton" and not _has_triton:
raise RuntimeError(
"recurrent_backend='triton' requires the triton package. "
"Install it with `pip install triton`."
)
if recurrent_recompute not in {"none", "full"}:
raise ValueError(
"recurrent_recompute must be one of 'none' or 'full'. "
f"Got {recurrent_recompute}."
)
if recurrent_recompute != "none" and recurrent_backend in {"pad", "auto"}:
raise ValueError(
"recurrent_recompute is only supported for recurrent_backend "
"'scan' and 'triton'. Set recurrent_backend explicitly: "
"'auto' may select cuDNN's 'pad' backend, which manages its "
"own backward workspace."
)
_validate_user_precision(recurrent_matmul_precision)
if lstm is not None:
if not lstm.batch_first:
raise ValueError("The input lstm must have batch_first=True.")
if input_size is not None or hidden_size is not None:
raise ValueError(
"An LSTM instance cannot be passed along with class argument."
)
else:
if not batch_first:
raise ValueError("The input lstm must have batch_first=True.")
if not hidden_size:
raise ValueError("hidden_size must be passed.")
if python_based and bidirectional:
raise ValueError(
"python_based=True does not support bidirectional LSTMs."
)
if python_based:
lstm = LSTM(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
dropout=dropout,
proj_size=proj_size,
device=device,
batch_first=True,
bidirectional=bidirectional,
)
else:
lstm = nn.LSTM(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
dropout=dropout,
proj_size=proj_size,
device=device,
batch_first=True,
bidirectional=bidirectional,
)
if not ((in_key is None) ^ (in_keys is None)):
raise ValueError(
f"Either in_keys or in_key must be specified but not both or none. Got {in_keys} and {in_key} respectively."
)
elif in_key:
in_keys = [in_key, *self.DEFAULT_IN_KEYS]
if not ((out_key is None) ^ (out_keys is None)):
raise ValueError(
f"Either out_keys or out_key must be specified but not both or none. Got {out_keys} and {out_key} respectively."
)
elif out_key:
out_keys = [out_key, *self.DEFAULT_OUT_KEYS]
in_keys = unravel_key_list(in_keys)
out_keys = unravel_key_list(out_keys)
if not isinstance(in_keys, (tuple, list)) or (
len(in_keys) != 3 and not (len(in_keys) == 4 and in_keys[-1] == "is_init")
):
raise ValueError(
f"LSTMModule expects 3 inputs: a value, and two hidden states (and potentially an 'is_init' marker). Got in_keys {in_keys} instead."
)
if not isinstance(out_keys, (tuple, list)) or len(out_keys) != 3:
raise ValueError(
f"LSTMModule expects 3 outputs: a value, and two hidden states. Got out_keys {out_keys} instead."
)
self.lstm = lstm
if "is_init" not in in_keys:
in_keys = in_keys + ["is_init"]
self.in_keys = in_keys
self.out_keys = out_keys
self._recurrent_mode = default_recurrent_mode
self.recurrent_backend = recurrent_backend
self.recurrent_compute_dtype = recurrent_compute_dtype
self.recurrent_recompute = recurrent_recompute
self.recurrent_matmul_precision = recurrent_matmul_precision
if recurrent_backend == "scan":
param = next(lstm.parameters(), None)
_maybe_warm_scan_backward(device if param is None else param.device)
[docs]
def make_python_based(self) -> LSTMModule:
"""Transforms the LSTM layer in its python-based version.
Returns:
self
"""
if isinstance(self.lstm, LSTM):
return self
lstm = LSTM(
input_size=self.lstm.input_size,
hidden_size=self.lstm.hidden_size,
num_layers=self.lstm.num_layers,
bias=self.lstm.bias,
dropout=self.lstm.dropout,
proj_size=self.lstm.proj_size,
device="meta",
batch_first=self.lstm.batch_first,
bidirectional=self.lstm.bidirectional,
)
from tensordict import from_module
from_module(self.lstm).to_module(lstm, preserve_module_state=False)
self.lstm = lstm
return self
[docs]
def make_cudnn_based(self) -> LSTMModule:
"""Transforms the LSTM layer in its CuDNN-based version.
Returns:
self
"""
if isinstance(self.lstm, nn.LSTM):
return self
lstm = nn.LSTM(
input_size=self.lstm.input_size,
hidden_size=self.lstm.hidden_size,
num_layers=self.lstm.num_layers,
bias=self.lstm.bias,
dropout=self.lstm.dropout,
proj_size=self.lstm.proj_size,
device="meta",
batch_first=self.lstm.batch_first,
bidirectional=self.lstm.bidirectional,
)
from tensordict import from_module
from_module(self.lstm).to_module(lstm, preserve_module_state=False)
self.lstm = lstm
return self
[docs]
def make_tensordict_primer(self):
"""Makes a tensordict primer for the environment.
A :class:`~torchrl.envs.TensorDictPrimer` object will ensure that the policy is aware of the supplementary
inputs and outputs (recurrent states) during rollout execution. That way, the data can be shared across
processes and dealt with properly.
When using batched environments such as :class:`~torchrl.envs.ParallelEnv`, the transform can be used at the
single env instance level (i.e., a batch of transformed envs with tensordict primers set within) or at the
batched env instance level (i.e., a transformed batch of regular envs).
Not including a ``TensorDictPrimer`` in the environment may result in poorly defined behaviors, for instance
in parallel settings where a step involves copying the new recurrent state from ``"next"`` to the root
tensordict, which the meth:`~torchrl.EnvBase.step_mdp` method will not be able to do as the recurrent states
are not registered within the environment specs.
See :func:`torchrl.modules.utils.get_primers_from_module` for a method to generate all primers for a given
module.
Examples:
>>> from torchrl.collectors import Collector
>>> from torchrl.envs import TransformedEnv, InitTracker
>>> from torchrl.envs import GymEnv
>>> from torchrl.modules import MLP, LSTMModule
>>> from torch import nn
>>> from tensordict.nn import TensorDictSequential as Seq, TensorDictModule as Mod
>>>
>>> env = TransformedEnv(GymEnv("Pendulum-v1"), InitTracker())
>>> lstm_module = LSTMModule(
... input_size=env.observation_spec["observation"].shape[-1],
... hidden_size=64,
... in_keys=["observation", "rs_h", "rs_c"],
... out_keys=["intermediate", ("next", "rs_h"), ("next", "rs_c")])
>>> mlp = MLP(num_cells=[64], out_features=1)
>>> policy = Seq(lstm_module, Mod(mlp, in_keys=["intermediate"], out_keys=["action"]))
>>> policy(env.reset())
>>> env = env.append_transform(lstm_module.make_tensordict_primer())
>>> data_collector = Collector(
... env,
... policy,
... frames_per_batch=10
... )
>>> for data in data_collector:
... print(data)
... break
"""
from torchrl.envs.transforms.transforms import TensorDictPrimer
def make_tuple(key):
if isinstance(key, tuple):
return key
return (key,)
out_key1 = make_tuple(self.out_keys[1])
in_key1 = make_tuple(self.in_keys[1])
out_key2 = make_tuple(self.out_keys[2])
in_key2 = make_tuple(self.in_keys[2])
if out_key1 != ("next", *in_key1) or out_key2 != ("next", *in_key2):
raise RuntimeError(
"make_tensordict_primer is supposed to work with in_keys/out_keys that "
"have compatible names, ie. the out_keys should be named after ('next', <in_key>). Got "
f"in_keys={self.in_keys} and out_keys={self.out_keys} instead."
)
num_states = self.lstm.num_layers * _num_directions(self.lstm)
real_hidden_size = (
self.lstm.proj_size if self.lstm.proj_size > 0 else self.lstm.hidden_size
)
return TensorDictPrimer(
{
in_key1: Unbounded(shape=(num_states, real_hidden_size)),
in_key2: Unbounded(shape=(num_states, self.lstm.hidden_size)),
},
expand_specs=True,
)
@property
def canonical_keys(self) -> list[NestedKey]:
"""Return TensorDict keys whose canonical layout matters for this module.
The result is the union of ``self.in_keys`` and ``self.out_keys`` --
the minimal subset a caller needs to canonicalize before invoking the
module, so unrelated leaves (rewards, advantages, log-probs, ...) can
keep whatever layout the data pipeline produces.
.. seealso:: :meth:`canonicalize`,
:func:`~torchrl.modules.canonicalize_rnn_subset`.
"""
return list(self.in_keys) + list(self.out_keys)
[docs]
def canonicalize(
self, data: TensorDictBase, *, inplace: bool = False
) -> TensorDictBase:
"""Canonicalize only the RNN-relevant leaves of ``data``.
Equivalent to ``data.contiguous(canonical=True)`` restricted to
:attr:`canonical_keys`. Other leaves are left untouched, avoiding the
transient full-batch copy a top-level canonicalization would create.
Args:
data: TensorDict to canonicalize. Missing keys in
:attr:`canonical_keys` are skipped silently.
inplace: When ``True``, mutates ``data`` in place and returns it.
Defaults to ``False`` (returns a shallow copy with the
canonicalized leaves replaced).
Returns:
A TensorDict with canonical layout on the RNN keys.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.modules import LSTMModule
>>> module = LSTMModule(input_size=3, hidden_size=4, in_key="obs",
... out_key="out")
>>> td = TensorDict(
... {"obs": torch.zeros(2, 5, 3),
... "reward": torch.zeros(2, 5, 1)},
... batch_size=[2, 5],
... )
>>> td_canon = module.canonicalize(td)
>>> td_canon["obs"].is_contiguous()
True
"""
keys = [
key for key in self.canonical_keys if key in data.keys(include_nested=True)
]
if not keys:
return data
sub = data.select(*keys, strict=False)
try:
sub = sub.contiguous(canonical=True)
except TypeError:
sub = sub.contiguous()
out = data if inplace else data.copy()
out.update(sub)
return out
@property
def recurrent_mode(self):
rm = recurrent_mode()
if rm is None:
return bool(self._recurrent_mode)
return rm
@recurrent_mode.setter
def recurrent_mode(self, value):
raise RuntimeError(
"recurrent_mode cannot be changed in-place. Please use the set_recurrent_mode context manager."
)
@property
def temporal_mode(self):
raise RuntimeError(
"temporal_mode is deprecated, use recurrent_mode instead.",
)
[docs]
def set_recurrent_mode(self, mode: bool = True):
raise RuntimeError(
"The lstm.set_recurrent_mode() API has been removed in v0.8. "
"To set the recurrent mode, use the :class:`~torchrl.modules.set_recurrent_mode` context manager or "
"the `default_recurrent_mode` keyword argument in the constructor."
)
[docs]
@dispatch
def forward(self, tensordict: TensorDictBase):
"""Run the LSTM on a tensordict, honouring ``is_init`` for hidden-state resets.
Two execution paths, picked by :attr:`recurrent_mode`:
- **Sequential** (``recurrent_mode=False``): one step at a time, called
inside a collector rollout. Batch is flattened, a synthetic time dim
of size 1 is added, and ``is_init`` *zeros the incoming hidden* so
a fresh trajectory does not inherit the previous one's state
(see the ``torch.where`` block below).
- **Recurrent** (``recurrent_mode=True``): a full ``(B, T, ...)``
batch is processed at once, called inside loss / GAE / training
code. If any ``is_init[..., 1:]`` is set we have multiple
trajectories packed into the time dim; we split-and-pad along
trajectory boundaries (via ``_split_and_pad_sequence``) so each
chunk has a single trajectory, run the LSTM, then unpad. This is
what prevents hidden state from leaking *across* trajectories
within a single training batch.
``is_init`` is sourced from :class:`~torchrl.envs.InitTracker` on the
env side; without that transform there is no signal for boundary
resets and hidden state will silently leak across episodes.
"""
# we want to get an error if the value input is missing, but not the hidden states
defaults = [NO_DEFAULT, None, None]
shape = tensordict.shape
if self.recurrent_mode:
# Straight-line shape normalization. Time is the last batch dim;
# all earlier batch dims are folded into a single leading B.
# Cheaper and simpler than the historical ``while ndim < 3`` loop
# plus ``prod(...)`` + ``apply(..., batch_size=[...])``.
td_ndim = tensordict.ndim
if td_ndim == 0:
raise ValueError(
"LSTMModule(recurrent_mode=True) requires the input "
"tensordict to have at least one batch dim (time). Got a "
"0-d tensordict."
)
elif td_ndim == 1:
tensordict_shaped = tensordict.unsqueeze(0)
elif td_ndim == 2:
tensordict_shaped = tensordict
else:
tensordict_shaped = tensordict.flatten(0, -2)
else:
tensordict_shaped = tensordict.reshape(-1).unsqueeze(-1)
is_init = tensordict_shaped["is_init"].squeeze(-1)
splits = None
backend = self.recurrent_backend
if backend == "auto":
# In eager, CuDNN-backed pad is the fastest path; under torch.compile
# the data-dependent ``_split_and_pad_sequence`` branch is unfriendly,
# so prefer scan there.
backend = "scan" if is_compiling() else "pad"
use_scan = self.recurrent_mode and backend == "scan"
use_triton = self.recurrent_mode and backend == "triton"
if self.recurrent_mode and not use_scan and not use_triton:
# Multi-trajectory rollouts under the pad backend: split each row
# into per-trajectory windows of shape [N, T'], run the LSTM on
# the padded result, then stitch them back. Required for correctness
# whenever ``is_init`` fires mid-row. The split/unpad runs as an
# eager island so the pad backend stays usable under torch.compile
# (see _split_and_pad_for_reset).
padded, splits_maybe, tensordict_shaped_shape = _split_and_pad_for_reset(
tensordict_shaped, self.in_keys
)
if padded is not None:
tensordict_shaped = padded
splits = splits_maybe
is_init = tensordict_shaped["is_init"].squeeze(-1)
value, hidden0, hidden1 = (
tensordict_shaped.get(key, default)
for key, default in zip(self.in_keys, defaults)
)
# packed sequences do not help to get the accurate last hidden values
# if splits is not None:
# value = torch.nn.utils.rnn.pack_padded_sequence(value, splits, batch_first=True)
if not self.recurrent_mode and hidden0 is not None:
# We zero the hidden states if we're calling the lstm recursively
# as we assume the hidden state comes from the previous trajectory.
# When using the recurrent_mode=True option, the lstm can be called from
# any intermediate state, hence zeroing should not be done.
is_init_expand = expand_as_right(is_init, hidden0)
zeros = torch.zeros_like(hidden0)
hidden0 = torch.where(is_init_expand, zeros, hidden0)
hidden1 = torch.where(is_init_expand, zeros, hidden1)
batch, steps = value.shape[:2]
device = value.device
dtype = value.dtype
val, hidden0, hidden1 = self._lstm(
value,
batch,
steps,
device,
dtype,
hidden0,
hidden1,
splits,
is_init=is_init if (use_scan or use_triton) else None,
backend=backend if self.recurrent_mode else "pad",
)
tensordict_shaped.set(self.out_keys[0], val)
tensordict_shaped.set(self.out_keys[1], hidden0)
tensordict_shaped.set(self.out_keys[2], hidden1)
if splits is not None:
tensordict_shaped = _inv_pad_for_reset(
tensordict_shaped, splits, tensordict_shaped_shape
)
if shape != tensordict_shaped.shape or tensordict_shaped is not tensordict:
tensordict.update(tensordict_shaped.reshape(shape))
return tensordict
def _lstm(
self,
input: torch.Tensor,
batch,
steps,
device,
dtype,
hidden0_in: torch.Tensor | None = None,
hidden1_in: torch.Tensor | None = None,
splits: torch.Tensor | None = None,
is_init: torch.Tensor | None = None,
backend: str = "pad",
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""Drive ``self.lstm`` for one or more steps and shape its outputs for TensorDict.
Shapes:
input: ``(batch, steps, input_size)``.
hidden{0,1}_in: ``(batch, steps, num_layers, hidden_size)`` — the
tensordict-native layout, with a time dim. May be ``None``
(zero-initialised below).
Returns ``(y, h, c)``:
y: ``(batch, steps, hidden_size)``.
h, c: ``(batch, steps, num_layers, hidden_size)``, padded with
zeros at steps ``0..steps-2`` and the true final state at
step ``steps-1`` (see "Why we pad" below).
Why ``[..., 0, :, :]`` + transpose:
TensorDict stores hidden state with a time dim because every step
in a trajectory carries one. ``nn.LSTM`` does not: it wants a
single input hidden of shape ``(num_layers, batch, hidden_size)``.
So we pick the *first* step's hidden (``[..., 0, :, :]`` →
``(batch, num_layers, hidden)``), then transpose ``(-3, -2)`` to
put num_layers first. The mirror happens after the LSTM call:
transpose ``(0, 1)`` to move num_layers back behind batch.
Why we pad with zeros at intermediate steps:
``nn.LSTM`` returns only the *final* hidden across the whole
sequence, but tensordict expects a hidden value at every step.
We zero-pad steps ``0..steps-2`` and place the true final
hidden at ``steps-1``. Those padded values are not real
per-step hiddens — they are placeholders to keep shapes aligned.
This is why the class docstring warns that "final hidden values
should not be trusted" for multi-trajectory inputs: under
recurrent_mode the splitter (see :meth:`forward`) breaks the
input into per-trajectory chunks before reaching here, so the
"final" hidden is meaningful per-trajectory; in raw recurrent
calls without that split, the final hidden may correspond to a
padded slot.
"""
if not self.recurrent_mode and steps != 1:
raise ValueError("Expected a single step")
if hidden1_in is None and hidden0_in is None:
shape = (batch, steps)
num_states = self.lstm.num_layers * _num_directions(self.lstm)
real_hidden_size = (
self.lstm.proj_size
if self.lstm.proj_size > 0
else self.lstm.hidden_size
)
hidden0_in, hidden1_in = (
torch.zeros(
*shape,
num_states,
hidden_size,
device=device,
dtype=dtype,
)
for hidden_size in (real_hidden_size, self.lstm.hidden_size)
)
elif hidden1_in is None or hidden0_in is None:
raise RuntimeError(
f"got type(hidden0)={type(hidden0_in)} and type(hidden1)={type(hidden1_in)}"
)
# Pick the first step's hidden and move num_layers to the front for
# nn.LSTM. See the docstring for the shape reasoning.
_hidden0_in = hidden0_in[..., 0, :, :]
_hidden1_in = hidden1_in[..., 0, :, :]
hidden = (
_canonical_contiguous(_hidden0_in.transpose(-3, -2)),
_canonical_contiguous(_hidden1_in.transpose(-3, -2)),
)
if is_init is not None and backend == "triton":
return self._lstm_triton_with_resets(input, hidden0_in, hidden1_in, is_init)
if is_init is not None:
return self._lstm_scan_with_resets(
input, hidden0_in, hidden1_in, hidden, is_init
)
if splits is None:
y, hidden = self.lstm(input, hidden)
elif isinstance(self.lstm, nn.LSTM):
# Variable-length trajectories: pack so the LSTM does not consume
# padding zeros, and h_n/c_n reflect the state after the last real
# step of each trajectory rather than after the padded tail.
lengths = splits.detach().to(device="cpu", dtype=torch.long)
packed = nn.utils.rnn.pack_padded_sequence(
input, lengths, batch_first=True, enforce_sorted=False
)
packed_y, hidden = self.lstm(packed, hidden)
y, _ = nn.utils.rnn.pad_packed_sequence(
packed_y, batch_first=True, total_length=steps
)
else:
# python-based custom LSTM does not accept PackedSequence. Run the
# full padded batch through it but pass a per-step active mask so
# the cell freezes h/c for batch entries whose trajectory has
# already ended (wasteful compute on the padded tail, but the
# batch dimension stays vectorised -- vmap/compile-friendly).
mask = torch.arange(steps, device=device).unsqueeze(0) < splits.unsqueeze(1)
y, hidden = self.lstm(input, hidden, mask=mask)
# dim 0 in hidden is num_layers, but that will conflict with tensordict
hidden = tuple(_h.transpose(0, 1) for _h in hidden)
out = [y, *hidden]
# Place hidden states so that _inv_pad_sequence (which keeps the first
# splits[i] positions of each row) retains them.
for i in range(1, 3):
if splits is not None:
out[i] = _place_at_traj_end(out[i], splits, steps)
else:
out[i] = torch.stack(
[torch.zeros_like(out[i]) for _ in range(steps - 1)] + [out[i]],
1,
)
return tuple(out)
def _lstm_triton_with_resets(
self,
input: torch.Tensor,
hidden0_in: torch.Tensor,
hidden1_in: torch.Tensor,
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if self.lstm.bidirectional or self.lstm.proj_size:
return self._lstm_pad_with_resets(input, hidden0_in, hidden1_in, is_init)
from torchrl.modules.tensordict_module._rnn_triton import lstm_triton
if self.lstm.bidirectional:
raise RuntimeError(
"Triton LSTM layer composition expects unidirectional weights."
)
layer_input = _canonical_contiguous(input)
is_init = _canonical_contiguous(is_init)
# Canonicalize the full hidden buffers once. For num_layers=1
# (the common RL case) per-layer slicing then yields a canonical
# view for free; for num_layers>=2 adjacent layers are interleaved
# in the parent so per-layer slices still need a re-materialize
# below.
hidden0_in = _canonical_contiguous(hidden0_in)
hidden1_in = _canonical_contiguous(hidden1_in)
hidden0_layers = []
hidden1_layers = []
for layer in range(self.lstm.num_layers):
weights = self.lstm._all_weights[layer]
w_ih = getattr(self.lstm, weights[0])
w_hh = getattr(self.lstm, weights[1])
b_ih = getattr(self.lstm, weights[2]) if self.lstm.bias else None
b_hh = getattr(self.lstm, weights[3]) if self.lstm.bias else None
if b_ih is None or b_hh is None:
zeros = torch.zeros(
4 * self.lstm.hidden_size, device=input.device, dtype=input.dtype
)
b_ih = zeros if b_ih is None else b_ih
b_hh = zeros if b_hh is None else b_hh
hidden_per_step = _canonical_contiguous(hidden0_in[..., layer, :])
cell_per_step = _canonical_contiguous(hidden1_in[..., layer, :])
h_steps, c_steps, _, _ = lstm_triton(
layer_input,
hidden_per_step,
cell_per_step,
w_ih,
w_hh,
b_ih,
b_hh,
is_init,
compute_dtype=self.recurrent_compute_dtype,
recompute=self.recurrent_recompute == "full",
input_precision=self.recurrent_matmul_precision,
)
hidden0_layers.append(h_steps)
hidden1_layers.append(c_steps)
if layer < self.lstm.num_layers - 1 and self.lstm.dropout:
layer_input = F.dropout(
h_steps, p=self.lstm.dropout, training=self.lstm.training
)
else:
layer_input = h_steps
# Match the per-step "next hidden" semantics used by the scan backend:
# the [b, t] hidden slot is populated only at trajectory ends.
end_mask = _end_mask_from_is_init(is_init)
hidden0_steps = torch.stack(hidden0_layers, -2)
hidden1_steps = torch.stack(hidden1_layers, -2)
hidden0_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden0_steps,
torch.zeros_like(hidden0_steps),
)
hidden1_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden1_steps,
torch.zeros_like(hidden1_steps),
)
return layer_input, hidden0_steps, hidden1_steps
def _lstm_pad_with_resets(
self,
input: torch.Tensor,
hidden0_in: torch.Tensor,
hidden1_in: torch.Tensor,
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
from torchrl.objectives.value.functional import (
_inv_pad_sequence,
_split_and_pad_sequence,
)
from torchrl.objectives.value.utils import _get_num_per_traj_init
# The outer forward path intentionally skips split/pad when
# ``backend='triton'``. Configurations handled here need pad semantics,
# so the split/pad work is redone locally before re-entering ``_lstm``.
splits = _get_num_per_traj_init(is_init)
batch, steps = input.shape[:2]
# Private synthetic keys avoid collisions with user-provided in/out keys
# while this helper reshapes the data through TensorDict utilities.
source = TensorDict(
{
"_input": input,
"_hidden0": hidden0_in,
"_hidden1": hidden1_in,
"is_init": is_init.unsqueeze(-1),
},
[batch, steps],
)
padded = _split_and_pad_sequence(source, splits)
val, hidden0, hidden1 = self._lstm(
padded["_input"],
padded.shape[0],
padded.shape[1],
input.device,
input.dtype,
padded["_hidden0"],
padded["_hidden1"],
splits=splits,
is_init=None,
backend="pad",
)
padded.set("_value_out", val)
padded.set("_hidden0_out", hidden0)
padded.set("_hidden1_out", hidden1)
restored = _inv_pad_sequence(
padded.select("_value_out", "_hidden0_out", "_hidden1_out"), splits
).reshape(batch, steps)
return (
restored["_value_out"],
restored["_hidden0_out"],
restored["_hidden1_out"],
)
def _lstm_scan_with_resets(
self,
input: torch.Tensor,
hidden0_in: torch.Tensor,
hidden1_in: torch.Tensor,
initial_hidden: tuple[torch.Tensor, torch.Tensor],
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
if self.lstm.dropout:
raise NotImplementedError(
"LSTMModule(recurrent_backend='scan') does not support dropout yet."
)
if self.lstm.proj_size:
raise NotImplementedError(
"LSTMModule(recurrent_backend='scan') does not support proj_size yet."
)
if self.lstm.bidirectional:
raise ValueError(
"LSTMModule(recurrent_backend='scan') does not support bidirectional LSTMs yet."
)
recompute = self.recurrent_recompute == "full"
# Under recompute we run a python time-loop with per-step activation
# checkpointing. Parameters and step outputs are accessed via closure
# by name, so the .clone()s required to satisfy ``_scan``'s HOP aliasing
# rules can be dropped along that path.
# nn.LSTM with cuDNN flattens its parameters: weight_ih_l*,
# weight_hh_l*, bias_ih_l*, bias_hh_l* are views into a single
# flat storage. Closing the scan body over them as-is fails with
# "input-to-input aliasing" under torch.compile (the HOP tracer
# walks the FakeTensor graph and rejects shared storage on inputs).
# Cloning produces independent allocations when recompute is disabled;
# gradients still flow back to the parameters.
weight_ihs, weight_hhs, bias_ihs, bias_hhs = [], [], [], []
for layer in range(self.lstm.num_layers):
weights = self.lstm._all_weights[layer]
w_ih = getattr(self.lstm, weights[0])
w_hh = getattr(self.lstm, weights[1])
b_ih = getattr(self.lstm, weights[2]) if self.lstm.bias else None
b_hh = getattr(self.lstm, weights[3]) if self.lstm.bias else None
if not recompute:
w_ih = w_ih.clone()
w_hh = w_hh.clone()
b_ih = b_ih.clone() if b_ih is not None else None
b_hh = b_hh.clone() if b_hh is not None else None
weight_ihs.append(w_ih)
weight_hhs.append(w_hh)
bias_ihs.append(b_ih)
bias_hhs.append(b_hh)
input = _canonical_contiguous(input.transpose(0, 1))
is_init = _canonical_contiguous(is_init.transpose(0, 1))
reset_hidden0 = _canonical_contiguous(
hidden0_in.transpose(0, 1).transpose(-3, -2)
)
reset_hidden1 = _canonical_contiguous(
hidden1_in.transpose(0, 1).transpose(-3, -2)
)
num_layers = self.lstm.num_layers
def step(carry, inputs):
h_layers, c_layers = carry
x_t, init_t, reset_hidden0_t, reset_hidden1_t = inputs
init_t = init_t.unsqueeze(0).unsqueeze(-1)
h_layers = torch.where(init_t, reset_hidden0_t, h_layers)
c_layers = torch.where(init_t, reset_hidden1_t, c_layers)
h_unbound = h_layers.unbind(0)
c_unbound = c_layers.unbind(0)
new_h = []
new_c = []
for layer in range(num_layers):
h_new, c_new = LSTM._lstm_cell(
x_t,
h_unbound[layer],
c_unbound[layer],
weight_ihs[layer],
bias_ihs[layer],
weight_hhs[layer],
bias_hhs[layer],
)
new_h.append(h_new)
new_c.append(c_new)
x_t = h_new
# torch.stack already allocates a fresh tensor, so the carry
# outputs need no extra clone. The per-step outputs are derived
# from the carry via view-style ops (transpose+flatten), which
# scan's HOP tracer treats as aliasing the carry even though
# they copy at runtime — clone these and x_t (which aliases the
# last layer's pre-stack hidden) to break the aliasing edge.
new_h = torch.stack(new_h, 0)
new_c = torch.stack(new_c, 0)
hidden0_out = new_h.transpose(0, 1).flatten(1).clone()
hidden1_out = new_c.transpose(0, 1).flatten(1).clone()
return (new_h, new_c), (x_t.clone(), hidden0_out, hidden1_out)
def step_unpacked(
h_layers,
c_layers,
x_t,
init_t,
reset_hidden0_t,
reset_hidden1_t,
):
(new_h, new_c), (out_t, h0_out, h1_out) = step(
(h_layers, c_layers),
(x_t, init_t, reset_hidden0_t, reset_hidden1_t),
)
return new_h, new_c, out_t, h0_out, h1_out
if recompute:
h_carry, c_carry = initial_hidden
outputs_list: list[torch.Tensor] = []
hidden0_list: list[torch.Tensor] = []
hidden1_list: list[torch.Tensor] = []
time_steps = input.shape[0]
for t in range(time_steps):
(
h_carry,
c_carry,
out_t,
h0_out,
h1_out,
) = _torch_checkpoint.checkpoint(
step_unpacked,
h_carry,
c_carry,
input[t],
is_init[t],
reset_hidden0[t],
reset_hidden1[t],
use_reentrant=False,
)
outputs_list.append(out_t)
hidden0_list.append(h0_out)
hidden1_list.append(h1_out)
outputs = torch.stack(outputs_list, 0)
hidden0_steps = torch.stack(hidden0_list, 0)
hidden1_steps = torch.stack(hidden1_list, 0)
else:
_, (outputs, hidden0_steps, hidden1_steps) = _scan(
step,
initial_hidden,
(input, is_init, reset_hidden0, reset_hidden1),
dim=0,
)
outputs = outputs.transpose(0, 1)
hidden0_steps = hidden0_steps.unflatten(
-1, (self.lstm.num_layers, self.lstm.hidden_size)
).transpose(0, 1)
hidden1_steps = hidden1_steps.unflatten(
-1, (self.lstm.num_layers, self.lstm.hidden_size)
).transpose(0, 1)
end_mask = torch.empty_like(is_init.transpose(0, 1))
end_mask[:, :-1] = is_init.transpose(0, 1)[:, 1:]
end_mask[:, -1] = True
hidden0_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden0_steps,
torch.zeros_like(hidden0_steps),
)
hidden1_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden1_steps,
torch.zeros_like(hidden1_steps),
)
return outputs, hidden0_steps, hidden1_steps
class GRUCell(RNNCellBase):
r"""A gated recurrent unit (GRU) cell that performs the same operation as nn.LSTMCell but is fully coded in Python.
.. note::
This class is implemented without relying on CuDNN, which makes it compatible with :func:`torch.vmap` and :func:`torch.compile`.
Examples:
>>> import torch
>>> from torchrl.modules.tensordict_module.rnn import GRUCell
>>> device = torch.device("cuda") if torch.cuda.device_count() else torch.device("cpu")
>>> B = 2
>>> N_IN = 10
>>> N_OUT = 20
>>> V = 4 # vector size
>>> gru_cell = GRUCell(input_size=N_IN, hidden_size=N_OUT, device=device)
# single call
>>> x = torch.randn(B, 10, device=device)
>>> h0 = torch.zeros(B, 20, device=device)
>>> with torch.no_grad():
... h1 = gru_cell(x, h0)
# vectorised call - not possible with nn.GRUCell
>>> def call_gru(x, h):
... h_out = gru_cell(x, h)
... return h_out
>>> batched_call = torch.vmap(call_gru)
>>> x = torch.randn(V, B, 10, device=device)
>>> h0 = torch.zeros(V, B, 20, device=device)
>>> with torch.no_grad():
... h1 = batched_call(x, h0)
"""
__doc__ += nn.GRUCell.__doc__
def __init__(
self,
input_size: int,
hidden_size: int,
bias: bool = True,
device=None,
dtype=None,
) -> None:
factory_kwargs = {"device": device, "dtype": dtype}
super().__init__(input_size, hidden_size, bias, num_chunks=3, **factory_kwargs)
def forward(self, input: Tensor, hx: Tensor | None = None) -> Tensor:
if input.dim() not in (1, 2):
raise ValueError(
f"GRUCell: Expected input to be 1D or 2D, got {input.dim()}D instead"
)
if hx is not None and hx.dim() not in (1, 2):
raise ValueError(
f"GRUCell: Expected hidden to be 1D or 2D, got {hx.dim()}D instead"
)
is_batched = input.dim() == 2
if not is_batched:
input = input.unsqueeze(0)
if hx is None:
hx = torch.zeros(
input.size(0), self.hidden_size, dtype=input.dtype, device=input.device
)
else:
hx = hx.unsqueeze(0) if not is_batched else hx
ret = self.gru_cell(input, hx)
if not is_batched:
ret = ret.squeeze(0)
return ret
def gru_cell(self, x, hx):
x = x.view(-1, x.size(1))
gate_x = F.linear(x, self.weight_ih, self.bias_ih)
gate_h = F.linear(hx, self.weight_hh, self.bias_hh)
i_r, i_i, i_n = gate_x.chunk(3, 1)
h_r, h_i, h_n = gate_h.chunk(3, 1)
resetgate = F.sigmoid(i_r + h_r)
inputgate = F.sigmoid(i_i + h_i)
newgate = F.tanh(i_n + (resetgate * h_n))
hy = newgate + inputgate * (hx - newgate)
return hy
# copy GRU
class GRUBase(nn.RNNBase):
"""A Base module for GRU. Inheriting from GRUBase enables compatibility with torch.compile."""
def __init__(self, *args, **kwargs):
return super().__init__("GRU", *args, **kwargs)
for attr in nn.GRU.__dict__:
if attr != "__init__":
setattr(GRUBase, attr, getattr(nn.GRU, attr))
class GRU(GRUBase):
"""A PyTorch module for executing multiple steps of a multi-layer GRU. The module behaves exactly like :class:`torch.nn.GRU`, but this implementation is exclusively coded in Python.
.. note::
This class is implemented without relying on CuDNN, which makes it
compatible with :func:`torch.vmap` and :func:`torch.compile`.
Examples:
>>> import torch
>>> from torchrl.modules.tensordict_module.rnn import GRU
>>> device = torch.device("cuda") if torch.cuda.device_count() else torch.device("cpu")
>>> B = 2
>>> T = 4
>>> N_IN = 10
>>> N_OUT = 20
>>> N_LAYERS = 2
>>> V = 4 # vector size
>>> gru = GRU(
... input_size=N_IN,
... hidden_size=N_OUT,
... device=device,
... num_layers=N_LAYERS,
... )
# single call
>>> x = torch.randn(B, T, N_IN, device=device)
>>> h0 = torch.zeros(N_LAYERS, B, N_OUT, device=device)
>>> with torch.no_grad():
... h1 = gru(x, h0)
# vectorised call - not possible with nn.GRU
>>> def call_gru(x, h):
... h_out = gru(x, h)
... return h_out
>>> batched_call = torch.vmap(call_gru)
>>> x = torch.randn(V, B, T, 10, device=device)
>>> h0 = torch.zeros(V, N_LAYERS, B, N_OUT, device=device)
>>> with torch.no_grad():
... h1 = batched_call(x, h0)
"""
__doc__ += nn.GRU.__doc__
use_scan: bool = False
def __init__(
self,
input_size: int,
hidden_size: int,
num_layers: int = 1,
bias: bool = True,
batch_first: bool = True,
dropout: float = 0.0,
bidirectional: bool = False,
device=None,
dtype=None,
use_scan: bool = False,
) -> None:
if bidirectional:
raise NotImplementedError(
"Bidirectional LSTMs are not supported yet in this implementation."
)
super().__init__(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
batch_first=batch_first,
dropout=dropout,
bidirectional=False,
device=device,
dtype=dtype,
)
# Opt-in prototype: see :meth:`_gru_scan` and :class:`LSTM`.
self.use_scan = use_scan
if use_scan:
_maybe_warm_scan_backward(device)
@staticmethod
def _gru_cell(
x, hx, weight_ih, bias_ih, weight_hh, bias_hh, hidden_size: int | None = None
):
x = x.view(-1, x.size(1))
gate_x = F.linear(x, weight_ih, bias_ih)
gate_h = F.linear(hx, weight_hh, bias_hh)
if hidden_size is None:
i_r, i_i, i_n = gate_x.chunk(3, 1)
h_r, h_i, h_n = gate_h.chunk(3, 1)
else:
# In scan's compiled backward, ``chunk(3, dim)`` derives gate sizes
# from a symbolic ``3 * hidden_size`` dim, while ``split(hidden_size)``
# can leak that composite SymInt through the HOP partitioner. Passing
# explicit sections keeps the sections concrete and the graph valid.
gate_chunks = (hidden_size, hidden_size, hidden_size)
i_r, i_i, i_n = gate_x.split(gate_chunks, 1)
h_r, h_i, h_n = gate_h.split(gate_chunks, 1)
resetgate = (i_r + h_r).sigmoid()
inputgate = (i_i + h_i).sigmoid()
newgate = (i_n + (resetgate * h_n)).tanh()
hy = newgate + inputgate * (hx - newgate)
return hy
def _gru(self, x, hx, mask=None):
if self.use_scan:
return self._gru_scan(x, hx, mask)
if not self.batch_first:
x = x.permute(
1, 0, 2
) # Change (seq_len, batch, features) to (batch, seq_len, features)
if mask is not None:
mask = mask.permute(1, 0)
bs, seq_len, input_size = x.size()
h_t = list(hx.unbind(0))
weight_ih = []
weight_hh = []
bias_ih = []
bias_hh = []
for layer in range(self.num_layers):
# Retrieve weights
weights = self._all_weights[layer]
weight_ih.append(getattr(self, weights[0]))
weight_hh.append(getattr(self, weights[1]))
if self.bias:
bias_ih.append(getattr(self, weights[2]))
bias_hh.append(getattr(self, weights[3]))
else:
bias_ih.append(None)
bias_hh.append(None)
outputs = []
mask_unbound = mask.unbind(1) if mask is not None else None
for t, x_t in enumerate(x.unbind(1)):
m_t = mask_unbound[t].unsqueeze(-1) if mask_unbound is not None else None
for layer in range(self.num_layers):
h_prev = h_t[layer]
h_new = self._gru_cell(
x_t,
h_prev,
weight_ih[layer],
bias_ih[layer],
weight_hh[layer],
bias_hh[layer],
self.hidden_size,
)
if m_t is not None:
# Freeze hidden state for batch entries whose trajectory
# has already ended (see _lstm for rationale).
h_new = torch.where(m_t, h_new, h_prev)
h_t[layer] = h_new
# Apply dropout if in training mode and not the last layer
if layer < self.num_layers - 1 and self.dropout:
x_t = F.dropout(h_t[layer], p=self.dropout, training=self.training)
else:
x_t = h_t[layer]
outputs.append(x_t)
outputs = torch.stack(outputs, dim=1)
if not self.batch_first:
outputs = outputs.permute(
1, 0, 2
) # Change back (batch, seq_len, features) to (seq_len, batch, features)
return outputs, torch.stack(h_t, 0)
def _gru_scan(self, x, hx, mask=None):
"""Prototype scan-based time loop. See :meth:`LSTM._lstm_scan`."""
if self.dropout:
raise NotImplementedError(
"GRU(use_scan=True) does not support dropout yet."
)
hidden_size = self.hidden_size
weight_ihs, weight_hhs, bias_ihs, bias_hhs = [], [], [], []
for layer in range(self.num_layers):
weights = self._all_weights[layer]
weight_ihs.append(
_split_gru_gate_param(getattr(self, weights[0]), hidden_size)
)
weight_hhs.append(
_split_gru_gate_param(getattr(self, weights[1]), hidden_size)
)
bias_ihs.append(
_split_gru_gate_param(
getattr(self, weights[2]) if self.bias else None, hidden_size
)
)
bias_hhs.append(
_split_gru_gate_param(
getattr(self, weights[3]) if self.bias else None, hidden_size
)
)
if self.batch_first:
x = x.transpose(0, 1)
if mask is not None:
mask = mask.transpose(0, 1)
if mask is None:
mask = torch.ones(x.shape[0], x.shape[1], dtype=torch.bool, device=x.device)
num_layers = self.num_layers
def step(carry, inputs):
h_layers = carry # [num_layers, B, H]
x_t, m_t = inputs
m_t = m_t.unsqueeze(-1)
new_h = []
h_unbound = h_layers.unbind(0)
for layer in range(num_layers):
h_prev = h_unbound[layer]
h_new = _gru_cell_from_gate_params(
x_t,
h_prev,
weight_ihs[layer],
bias_ihs[layer],
weight_hhs[layer],
bias_hhs[layer],
)
h_new = torch.where(m_t, h_new, h_prev)
new_h.append(h_new)
x_t = h_new
new_h = torch.stack(new_h, 0).clone()
return new_h, x_t.clone()
h_final, outputs = _scan(step, hx, (x, mask), dim=0)
if self.batch_first:
outputs = outputs.transpose(0, 1)
return outputs, h_final
def forward(self, input, hx=None, mask=None): # noqa: F811
if input.dim() != 3:
raise ValueError(
f"GRU: Expected input to be 3D, got {input.dim()}D instead"
)
if hx is not None and hx.dim() != 3:
raise RuntimeError(
f"For batched 3-D input, hx should also be 3-D but got {hx.dim()}-D tensor"
)
max_batch_size = input.size(0) if self.batch_first else input.size(1)
if hx is None:
hx = torch.zeros(
self.num_layers,
max_batch_size,
self.hidden_size,
dtype=input.dtype,
device=input.device,
)
self.check_forward_args(input, hx, batch_sizes=None)
result = self._gru(input, hx, mask)
output = result[0]
hidden = result[1]
return output, hidden
[docs]
class GRUModule(ModuleBase):
"""An embedder for an GRU module.
This class adds the following functionality to :class:`torch.nn.GRU`:
- Compatibility with TensorDict: the hidden states are reshaped to match
the tensordict batch size.
- Optional multi-step execution: with torch.nn, one has to choose between
:class:`torch.nn.GRUCell` and :class:`torch.nn.GRU`, the former being
compatible with single step inputs and the latter being compatible with
multi-step. This class enables both usages.
After construction, the module is *not* set in recurrent mode, ie. it will
expect single steps inputs.
If in recurrent mode, it is expected that the last dimension of the tensordict
marks the number of steps. There is no constrain on the dimensionality of the
tensordict (except that it must be greater than one for temporal inputs).
Args:
input_size: The number of expected features in the input `x`
hidden_size: The number of features in the hidden state `h`
num_layers: Number of recurrent layers. E.g., setting ``num_layers=2``
would mean stacking two GRUs together to form a `stacked GRU`,
with the second GRU taking in outputs of the first GRU and
computing the final results. Default: 1
bias: If ``False``, then the layer does not use bias weights.
Default: ``True``
dropout: If non-zero, introduces a `Dropout` layer on the outputs of each
GRU layer except the last layer, with dropout probability equal to
:attr:`dropout`. Default: 0
python_based: If ``True``, will use a full Python implementation of the GRU cell. Default: ``False``
recurrent_backend: backend used in recurrent mode when trajectories reset
in the middle of a batch. ``"pad"`` keeps the existing split/pad
strategy. ``"scan"`` uses a scan loop over the time dimension and
avoids materializing padded trajectory chunks via ``hoptorch``.
``"triton"``
(prototype, CUDA only) uses Triton kernels where available and
otherwise preserves pad-backend recurrent semantics for dropout
and bidirectional layers.
``"auto"`` uses ``"pad"`` in eager mode and ``"scan"`` when called
under :func:`torch.compile`. Default: ``"pad"``.
recurrent_compute_dtype: dtype used for the recurrent matmul inside the
``"triton"`` backend (``torch.float32`` -> TF32 on H100, default;
``torch.bfloat16`` -> bigger SMEM margin, lower precision).
Ignored by the other backends. Default: ``torch.float32``.
recurrent_recompute: when set to ``"full"``, trade extra compute for
lower backward activation memory. For ``recurrent_backend="triton"``
this drops the per-step gate buffers (``save_r/z/n/save_gh_n``)
from the autograd save set and replays the forward kernel during
backward. For ``recurrent_backend="scan"`` this swaps the
``torch._higher_order_ops.scan`` HOP for a python time-loop wrapped
with :func:`torch.utils.checkpoint.checkpoint`. Only ``"none"``
(default) and ``"full"`` are accepted; the ``"pad"`` backend rejects
non-``"none"`` values because cuDNN manages its own backward
workspace. Default: ``"none"``.
recurrent_matmul_precision: precision used by ``tl.dot`` inside the
``"triton"`` backend's recurrent matmul (and the matching cuBLAS
calls in the autograd wrapper). Concrete modes: ``"ieee"`` (full
IEEE FP32, off tensor cores), ``"tf32"`` (matches cuDNN's
default, fastest on Ampere+), ``"tf32x3"`` (three-product
compensated TF32, ~22 bits of mantissa on tensor cores).
GPU-aware presets: ``"fast"`` (Ampere+ → ``"tf32"``, else
``"ieee"``) and ``"high-prec"`` (Ampere+ → ``"tf32x3"``, else
``"ieee"``). Or ``"auto"`` to derive from
:func:`torch.get_float32_matmul_precision` and the
``TORCHRL_RNN_PRECISION`` env var (``"highest"`` → ``"ieee"``,
``"high"`` → ``"high-prec"``, ``"medium"`` → ``"fast"``). See
:func:`torchrl.modules.set_recurrent_matmul_precision`. Ignored
by the other backends. Default: ``"auto"``.
Keyword Args:
in_key (str or tuple of str): the input key of the module. Exclusive use
with ``in_keys``. If provided, the recurrent keys are assumed to be
["recurrent_state"] and the ``in_key`` will be
appended before this.
in_keys (list of str): a pair of strings corresponding to the input value and recurrent entry.
Exclusive with ``in_key``.
out_key (str or tuple of str): the output key of the module. Exclusive use
with ``out_keys``. If provided, the recurrent keys are assumed to be
[("recurrent_state")] and the ``out_key`` will be
appended before these.
out_keys (list of str): a pair of strings corresponding to the output value,
first and second hidden key.
.. note::
For a better integration with TorchRL's environments, the best naming
for the output hidden key is ``("next", <custom_key>)``, such
that the hidden values are passed from step to step during a rollout.
device (torch.device or compatible): the device of the module.
gru (torch.nn.GRU, optional): a GRU instance to be wrapped.
Exclusive with other nn.GRU arguments.
default_recurrent_mode (bool, optional): if provided, the recurrent mode if it hasn't been overridden
by the :class:`~torchrl.modules.set_recurrent_mode` context manager / decorator.
Defaults to ``False``.
Attributes:
recurrent_mode: Returns the recurrent mode of the module.
Methods:
set_recurrent_mode: controls whether the module should be executed in
recurrent mode.
make_tensordict_primer: creates the TensorDictPrimer transforms for the environment to be aware of the
recurrent states of the RNN.
.. note:: This module relies on specific ``recurrent_state`` keys being present in the input
TensorDicts. To generate a :class:`~torchrl.envs.transforms.TensorDictPrimer` transform that will automatically
add hidden states to the environment TensorDicts, use the method :func:`~torchrl.modules.rnn.GRUModule.make_tensordict_primer`.
If this class is a submodule in a larger module, the method :func:`~torchrl.modules.utils.get_primers_from_module` can be called
on the parent module to automatically generate the primer transforms required for all submodules, including this one.
Examples:
>>> from torchrl.envs import TransformedEnv, InitTracker
>>> from torchrl.envs import GymEnv
>>> from torchrl.modules import MLP
>>> from torch import nn
>>> from tensordict.nn import TensorDictSequential as Seq, TensorDictModule as Mod
>>> env = TransformedEnv(GymEnv("Pendulum-v1"), InitTracker())
>>> gru_module = GRUModule(
... input_size=env.observation_spec["observation"].shape[-1],
... hidden_size=64,
... in_keys=["observation", "rs"],
... out_keys=["intermediate", ("next", "rs")])
>>> mlp = MLP(num_cells=[64], out_features=1)
>>> policy = Seq(gru_module, Mod(mlp, in_keys=["intermediate"], out_keys=["action"]))
>>> policy(env.reset())
TensorDict(
fields={
action: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.float32, is_shared=False),
done: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
intermediate: Tensor(shape=torch.Size([64]), device=cpu, dtype=torch.float32, is_shared=False),
is_init: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
next: TensorDict(
fields={
rs: Tensor(shape=torch.Size([1, 64]), device=cpu, dtype=torch.float32, is_shared=False)},
batch_size=torch.Size([]),
device=cpu,
is_shared=False),
observation: Tensor(shape=torch.Size([3]), device=cpu, dtype=torch.float32, is_shared=False),
terminated: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False),
truncated: Tensor(shape=torch.Size([1]), device=cpu, dtype=torch.bool, is_shared=False)},
batch_size=torch.Size([]),
device=cpu,
is_shared=False)
>>> gru_module_training = gru_module.set_recurrent_mode()
>>> policy_training = Seq(gru_module, Mod(mlp, in_keys=["intermediate"], out_keys=["action"]))
>>> traj_td = env.rollout(3) # some random temporal data
>>> traj_td = policy_training(traj_td)
>>> print(traj_td)
TensorDict(
fields={
action: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.float32, is_shared=False),
done: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
intermediate: Tensor(shape=torch.Size([3, 64]), device=cpu, dtype=torch.float32, is_shared=False),
is_init: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
next: TensorDict(
fields={
done: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
is_init: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
observation: Tensor(shape=torch.Size([3, 3]), device=cpu, dtype=torch.float32, is_shared=False),
reward: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.float32, is_shared=False),
rs: Tensor(shape=torch.Size([3, 1, 64]), device=cpu, dtype=torch.float32, is_shared=False),
terminated: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
truncated: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False)},
batch_size=torch.Size([3]),
device=cpu,
is_shared=False),
observation: Tensor(shape=torch.Size([3, 3]), device=cpu, dtype=torch.float32, is_shared=False),
terminated: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False),
truncated: Tensor(shape=torch.Size([3, 1]), device=cpu, dtype=torch.bool, is_shared=False)},
batch_size=torch.Size([3]),
device=cpu,
is_shared=False)
"""
DEFAULT_IN_KEYS = ["recurrent_state"]
DEFAULT_OUT_KEYS = [("next", "recurrent_state")]
def __init__(
self,
input_size: int | None = None,
hidden_size: int | None = None,
num_layers: int = 1,
bias: bool = True,
batch_first=True,
dropout=0,
bidirectional=False,
python_based=False,
recurrent_backend: typing.Literal["auto", "pad", "scan", "triton"] = "pad",
recurrent_compute_dtype: torch.dtype = torch.float32,
recurrent_recompute: typing.Literal["none", "full"] = "none",
recurrent_matmul_precision: typing.Literal[
"auto", "fast", "high-prec", "ieee", "tf32", "tf32x3"
] = "auto",
*,
in_key=None,
in_keys=None,
out_key=None,
out_keys=None,
device=None,
gru=None,
default_recurrent_mode: bool | None = None,
):
super().__init__()
if recurrent_backend not in {"auto", "pad", "scan", "triton"}:
raise ValueError(
"recurrent_backend must be one of 'auto', 'pad', 'scan' or 'triton'. "
f"Got {recurrent_backend}."
)
if recurrent_backend == "triton" and not _has_triton:
raise RuntimeError(
"recurrent_backend='triton' requires the triton package. "
"Install it with `pip install triton`."
)
if recurrent_recompute not in {"none", "full"}:
raise ValueError(
"recurrent_recompute must be one of 'none' or 'full'. "
f"Got {recurrent_recompute}."
)
if recurrent_recompute != "none" and recurrent_backend in {"pad", "auto"}:
raise ValueError(
"recurrent_recompute is only supported for recurrent_backend "
"'scan' and 'triton'. Set recurrent_backend explicitly: "
"'auto' may select cuDNN's 'pad' backend, which manages its "
"own backward workspace."
)
_validate_user_precision(recurrent_matmul_precision)
if gru is not None:
if not gru.batch_first:
raise ValueError("The input gru must have batch_first=True.")
if input_size is not None or hidden_size is not None:
raise ValueError(
"An GRU instance cannot be passed along with class argument."
)
else:
if not batch_first:
raise ValueError("The input gru must have batch_first=True.")
if python_based and bidirectional:
raise ValueError(
"python_based=True does not support bidirectional GRUs."
)
if python_based:
gru = GRU(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
dropout=dropout,
device=device,
batch_first=True,
bidirectional=bidirectional,
)
else:
gru = nn.GRU(
input_size=input_size,
hidden_size=hidden_size,
num_layers=num_layers,
bias=bias,
dropout=dropout,
device=device,
batch_first=True,
bidirectional=bidirectional,
)
if not ((in_key is None) ^ (in_keys is None)):
raise ValueError(
f"Either in_keys or in_key must be specified but not both or none. Got {in_keys} and {in_key} respectively."
)
elif in_key:
in_keys = [in_key, *self.DEFAULT_IN_KEYS]
if not ((out_key is None) ^ (out_keys is None)):
raise ValueError(
f"Either out_keys or out_key must be specified but not both or none. Got {out_keys} and {out_key} respectively."
)
elif out_key:
out_keys = [out_key, *self.DEFAULT_OUT_KEYS]
in_keys = unravel_key_list(in_keys)
out_keys = unravel_key_list(out_keys)
if not isinstance(in_keys, (tuple, list)) or (
len(in_keys) != 2 and not (len(in_keys) == 3 and in_keys[-1] == "is_init")
):
raise ValueError(
f"GRUModule expects 3 inputs: a value, and two hidden states (and potentially an 'is_init' marker). Got in_keys {in_keys} instead."
)
if not isinstance(out_keys, (tuple, list)) or len(out_keys) != 2:
raise ValueError(
f"GRUModule expects 3 outputs: a value, and two hidden states. Got out_keys {out_keys} instead."
)
self.gru = gru
if "is_init" not in in_keys:
in_keys = in_keys + ["is_init"]
self.in_keys = in_keys
self.out_keys = out_keys
self._recurrent_mode = default_recurrent_mode
self.recurrent_backend = recurrent_backend
self.recurrent_compute_dtype = recurrent_compute_dtype
self.recurrent_recompute = recurrent_recompute
self.recurrent_matmul_precision = recurrent_matmul_precision
if recurrent_backend == "scan":
param = next(gru.parameters(), None)
_maybe_warm_scan_backward(device if param is None else param.device)
[docs]
def make_python_based(self) -> GRUModule:
"""Transforms the GRU layer in its python-based version.
Returns:
self
"""
if isinstance(self.gru, GRU):
return self
gru = GRU(
input_size=self.gru.input_size,
hidden_size=self.gru.hidden_size,
num_layers=self.gru.num_layers,
bias=self.gru.bias,
dropout=self.gru.dropout,
device="meta",
batch_first=self.gru.batch_first,
bidirectional=self.gru.bidirectional,
)
from tensordict import from_module
from_module(self.gru).to_module(gru, preserve_module_state=False)
self.gru = gru
return self
[docs]
def make_cudnn_based(self) -> GRUModule:
"""Transforms the GRU layer in its CuDNN-based version.
Returns:
self
"""
if isinstance(self.gru, nn.GRU):
return self
gru = nn.GRU(
input_size=self.gru.input_size,
hidden_size=self.gru.hidden_size,
num_layers=self.gru.num_layers,
bias=self.gru.bias,
dropout=self.gru.dropout,
device="meta",
batch_first=self.gru.batch_first,
bidirectional=self.gru.bidirectional,
)
from tensordict import from_module
from_module(self.gru).to_module(gru, preserve_module_state=False)
self.gru = gru
return self
[docs]
def make_tensordict_primer(self):
"""Makes a tensordict primer for the environment.
A :class:`~torchrl.envs.TensorDictPrimer` object will ensure that the policy is aware of the supplementary
inputs and outputs (recurrent states) during rollout execution. That way, the data can be shared across
processes and dealt with properly.
Not including a ``TensorDictPrimer`` in the environment may result in poorly defined behaviors, for instance
in parallel settings where a step involves copying the new recurrent state from ``"next"`` to the root
tensordict, which the meth:`~torchrl.EnvBase.step_mdp` method will not be able to do as the recurrent states
are not registered within the environment specs.
When using batched environments such as :class:`~torchrl.envs.ParallelEnv`, the transform can be used at the
single env instance level (i.e., a batch of transformed envs with tensordict primers set within) or at the
batched env instance level (i.e., a transformed batch of regular envs).
See :func:`torchrl.modules.utils.get_primers_from_module` for a method to generate all primers for a given
module.
Examples:
>>> from torchrl.collectors import Collector
>>> from torchrl.envs import TransformedEnv, InitTracker
>>> from torchrl.envs import GymEnv
>>> from torchrl.modules import MLP, LSTMModule
>>> from torch import nn
>>> from tensordict.nn import TensorDictSequential as Seq, TensorDictModule as Mod
>>>
>>> env = TransformedEnv(GymEnv("Pendulum-v1"), InitTracker())
>>> gru_module = GRUModule(
... input_size=env.observation_spec["observation"].shape[-1],
... hidden_size=64,
... in_keys=["observation", "rs"],
... out_keys=["intermediate", ("next", "rs")])
>>> mlp = MLP(num_cells=[64], out_features=1)
>>> policy = Seq(gru_module, Mod(mlp, in_keys=["intermediate"], out_keys=["action"]))
>>> policy(env.reset())
>>> env = env.append_transform(gru_module.make_tensordict_primer())
>>> data_collector = Collector(
... env,
... policy,
... frames_per_batch=10
... )
>>> for data in data_collector:
... print(data)
... break
"""
from torchrl.envs import TensorDictPrimer
def make_tuple(key):
if isinstance(key, tuple):
return key
return (key,)
out_key1 = make_tuple(self.out_keys[1])
in_key1 = make_tuple(self.in_keys[1])
if out_key1 != ("next", *in_key1):
raise RuntimeError(
"make_tensordict_primer is supposed to work with in_keys/out_keys that "
"have compatible names, ie. the out_keys should be named after ('next', <in_key>). Got "
f"in_keys={self.in_keys} and out_keys={self.out_keys} instead."
)
return TensorDictPrimer(
{
in_key1: Unbounded(
shape=(
self.gru.num_layers * _num_directions(self.gru),
self.gru.hidden_size,
)
),
},
expand_specs=True,
)
@property
def canonical_keys(self) -> list[NestedKey]:
"""Return TensorDict keys whose canonical layout matters for this module.
The result is the union of ``self.in_keys`` and ``self.out_keys``.
.. seealso:: :meth:`canonicalize`,
:func:`~torchrl.modules.canonicalize_rnn_subset`.
"""
return list(self.in_keys) + list(self.out_keys)
[docs]
def canonicalize(
self, data: TensorDictBase, *, inplace: bool = False
) -> TensorDictBase:
"""Canonicalize only the RNN-relevant leaves of ``data``.
See :meth:`LSTMModule.canonicalize` for details.
Args:
data: TensorDict to canonicalize.
inplace: When ``True``, mutates ``data`` in place.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.modules import GRUModule
>>> module = GRUModule(input_size=3, hidden_size=4, in_key="obs",
... out_key="out")
>>> td = TensorDict({"obs": torch.zeros(2, 5, 3)}, batch_size=[2, 5])
>>> module.canonicalize(td)["obs"].is_contiguous()
True
"""
keys = [
key for key in self.canonical_keys if key in data.keys(include_nested=True)
]
if not keys:
return data
sub = data.select(*keys, strict=False)
try:
sub = sub.contiguous(canonical=True)
except TypeError:
sub = sub.contiguous()
out = data if inplace else data.copy()
out.update(sub)
return out
@property
def recurrent_mode(self):
rm = recurrent_mode()
if rm is None:
return bool(self._recurrent_mode)
return rm
@recurrent_mode.setter
def recurrent_mode(self, value):
raise RuntimeError(
"recurrent_mode cannot be changed in-place. Please use the set_recurrent_mode context manager."
)
@property
def temporal_mode(self):
raise RuntimeError(
"temporal_mode is deprecated, use recurrent_mode instead.",
)
[docs]
def set_recurrent_mode(self, mode: bool = True):
raise RuntimeError(
"The gru.set_recurrent_mode() API has been removed in v0.8. "
"To set the recurrent mode, use the :class:`~torchrl.modules.set_recurrent_mode` context manager or "
"the `default_recurrent_mode` keyword argument in the constructor.",
)
[docs]
@dispatch
@set_lazy_legacy(False)
def forward(self, tensordict: TensorDictBase):
# we want to get an error if the value input is missing, but not the hidden states
defaults = [NO_DEFAULT, None]
shape = tensordict.shape
if self.recurrent_mode:
# Straight-line shape normalization (see LSTMModule.forward).
td_ndim = tensordict.ndim
if td_ndim == 0:
raise ValueError(
"GRUModule(recurrent_mode=True) requires the input "
"tensordict to have at least one batch dim (time). Got a "
"0-d tensordict."
)
elif td_ndim == 1:
tensordict_shaped = tensordict.unsqueeze(0)
elif td_ndim == 2:
tensordict_shaped = tensordict
else:
tensordict_shaped = tensordict.flatten(0, -2)
else:
tensordict_shaped = tensordict.reshape(-1).unsqueeze(-1)
is_init = tensordict_shaped["is_init"].squeeze(-1)
splits = None
backend = self.recurrent_backend
if backend == "auto":
backend = "scan" if is_compiling() else "pad"
use_scan = self.recurrent_mode and backend == "scan"
use_triton = self.recurrent_mode and backend == "triton"
if self.recurrent_mode and not use_scan and not use_triton:
# Multi-trajectory rollouts under the pad backend: split each row
# into per-trajectory windows, run the GRU on the padded result,
# then stitch them back. The split/unpad runs as an eager island so
# the pad backend stays usable under torch.compile (see
# _split_and_pad_for_reset).
padded, splits_maybe, tensordict_shaped_shape = _split_and_pad_for_reset(
tensordict_shaped, self.in_keys
)
if padded is not None:
tensordict_shaped = padded
splits = splits_maybe
is_init = tensordict_shaped["is_init"].squeeze(-1)
value, hidden = (
tensordict_shaped.get(key, default)
for key, default in zip(self.in_keys, defaults)
)
batch, steps = value.shape[:2]
device = value.device
dtype = value.dtype
if not self.recurrent_mode and is_init.any() and hidden is not None:
is_init_expand = expand_as_right(is_init, hidden)
hidden = torch.where(is_init_expand, 0, hidden)
val, hidden = self._gru(
value,
batch,
steps,
device,
dtype,
hidden,
splits,
is_init=is_init if (use_scan or use_triton) else None,
backend=backend if self.recurrent_mode else "pad",
)
tensordict_shaped.set(self.out_keys[0], val)
tensordict_shaped.set(self.out_keys[1], hidden)
if splits is not None:
tensordict_shaped = _inv_pad_for_reset(
tensordict_shaped, splits, tensordict_shaped_shape
)
if shape != tensordict_shaped.shape or tensordict_shaped is not tensordict:
tensordict.update(tensordict_shaped.reshape(shape))
return tensordict
def _gru(
self,
input: torch.Tensor,
batch,
steps,
device,
dtype,
hidden_in: torch.Tensor | None = None,
splits: torch.Tensor | None = None,
is_init: torch.Tensor | None = None,
backend: str = "pad",
) -> tuple[torch.Tensor, torch.Tensor]:
if not self.recurrent_mode and steps != 1:
raise ValueError("Expected a single step")
if hidden_in is None:
shape = (batch, steps)
hidden_in = torch.zeros(
*shape,
self.gru.num_layers * _num_directions(self.gru),
self.gru.hidden_size,
device=device,
dtype=dtype,
)
# we only need the first hidden state
_hidden_in = hidden_in[:, 0]
hidden = _canonical_contiguous(_hidden_in.transpose(-3, -2))
if is_init is not None and backend == "triton":
return self._gru_triton_with_resets(input, hidden_in, is_init)
if is_init is not None:
return self._gru_scan_with_resets(input, hidden_in, hidden, is_init)
if splits is None:
y, hidden = self.gru(input, hidden)
elif isinstance(self.gru, nn.GRU):
# See LSTMModule._lstm for rationale.
lengths = splits.detach().to(device="cpu", dtype=torch.long)
packed = nn.utils.rnn.pack_padded_sequence(
input, lengths, batch_first=True, enforce_sorted=False
)
packed_y, hidden = self.gru(packed, hidden)
y, _ = nn.utils.rnn.pad_packed_sequence(
packed_y, batch_first=True, total_length=steps
)
else:
mask = torch.arange(steps, device=device).unsqueeze(0) < splits.unsqueeze(1)
y, hidden = self.gru(input, hidden, mask=mask)
# dim 0 in hidden is num_layers, but that will conflict with tensordict
hidden = hidden.transpose(0, 1)
if splits is not None:
hidden = _place_at_traj_end(hidden, splits, steps)
else:
# we pad the hidden states with zero to make tensordict happy
hidden = torch.stack(
[torch.zeros_like(hidden) for _ in range(steps - 1)] + [hidden],
1,
)
out = [y, hidden]
return tuple(out)
def _gru_triton_with_resets(
self,
input: torch.Tensor,
hidden_in: torch.Tensor,
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
if self.gru.bidirectional:
return self._gru_pad_with_resets(input, hidden_in, is_init)
from torchrl.modules.tensordict_module._rnn_triton import gru_triton
if self.gru.bidirectional:
raise RuntimeError(
"Triton GRU layer composition expects unidirectional weights."
)
layer_input = _canonical_contiguous(input)
is_init = _canonical_contiguous(is_init)
# Canonicalize the full hidden buffer once; per-layer slices are
# canonical for free for num_layers=1, still need the re-materialize
# below for num_layers>=2 (adjacent layers interleaved in memory).
hidden_in = _canonical_contiguous(hidden_in)
hidden_layers = []
for layer in range(self.gru.num_layers):
weights = self.gru._all_weights[layer]
w_ih = getattr(self.gru, weights[0])
w_hh = getattr(self.gru, weights[1])
b_ih = getattr(self.gru, weights[2]) if self.gru.bias else None
b_hh = getattr(self.gru, weights[3]) if self.gru.bias else None
if b_ih is None or b_hh is None:
zeros = torch.zeros(
3 * self.gru.hidden_size, device=input.device, dtype=input.dtype
)
b_ih = zeros if b_ih is None else b_ih
b_hh = zeros if b_hh is None else b_hh
hidden_per_step = _canonical_contiguous(hidden_in[..., layer, :])
h_steps, _ = gru_triton(
layer_input,
hidden_per_step,
w_ih,
w_hh,
b_ih,
b_hh,
is_init,
compute_dtype=self.recurrent_compute_dtype,
recompute=self.recurrent_recompute == "full",
input_precision=self.recurrent_matmul_precision,
)
hidden_layers.append(h_steps)
if layer < self.gru.num_layers - 1 and self.gru.dropout:
layer_input = F.dropout(
h_steps, p=self.gru.dropout, training=self.gru.training
)
else:
layer_input = h_steps
# Match the scan backend's per-step hidden output semantics.
end_mask = _end_mask_from_is_init(is_init)
hidden_steps = torch.stack(hidden_layers, -2)
hidden_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden_steps,
torch.zeros_like(hidden_steps),
)
return layer_input, hidden_steps
def _gru_pad_with_resets(
self,
input: torch.Tensor,
hidden_in: torch.Tensor,
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
from torchrl.objectives.value.functional import (
_inv_pad_sequence,
_split_and_pad_sequence,
)
from torchrl.objectives.value.utils import _get_num_per_traj_init
# See ``_lstm_pad_with_resets``: this helper owns split/pad because the
# outer recurrent path bypasses it for ``backend='triton'``.
splits = _get_num_per_traj_init(is_init)
batch, steps = input.shape[:2]
# Private synthetic keys avoid collisions with user-provided in/out keys.
source = TensorDict(
{
"_input": input,
"_hidden": hidden_in,
"is_init": is_init.unsqueeze(-1),
},
[batch, steps],
)
padded = _split_and_pad_sequence(source, splits)
val, hidden = self._gru(
padded["_input"],
padded.shape[0],
padded.shape[1],
input.device,
input.dtype,
padded["_hidden"],
splits=splits,
is_init=None,
backend="pad",
)
padded.set("_value_out", val)
padded.set("_hidden_out", hidden)
restored = _inv_pad_sequence(
padded.select("_value_out", "_hidden_out"), splits
).reshape(batch, steps)
return restored["_value_out"], restored["_hidden_out"]
def _gru_scan_with_resets(
self,
input: torch.Tensor,
hidden_in: torch.Tensor,
initial_hidden: torch.Tensor,
is_init: torch.Tensor,
) -> tuple[torch.Tensor, torch.Tensor]:
if self.gru.dropout:
raise NotImplementedError(
"GRUModule(recurrent_backend='scan') does not support dropout yet."
)
if self.gru.bidirectional:
raise ValueError(
"GRUModule(recurrent_backend='scan') does not support bidirectional GRUs yet."
)
recompute = self.recurrent_recompute == "full"
hidden_size = self.gru.hidden_size
input = _canonical_contiguous(input.transpose(0, 1))
is_init = _canonical_contiguous(is_init.transpose(0, 1))
reset_hidden = _canonical_contiguous(hidden_in.permute(1, 2, 0, 3))
num_layers = self.gru.num_layers
if recompute:
# Split the packed parameters outside the checkpointed loop. Each
# gate clone remains connected to the packed parameter while
# breaking cuDNN's shared flat-storage aliases.
weight_ihs, weight_hhs, bias_ihs, bias_hhs = [], [], [], []
for layer in range(num_layers):
weights = self.gru._all_weights[layer]
weight_ihs.append(
_split_gru_gate_param(getattr(self.gru, weights[0]), hidden_size)
)
weight_hhs.append(
_split_gru_gate_param(getattr(self.gru, weights[1]), hidden_size)
)
bias_ihs.append(
_split_gru_gate_param(
getattr(self.gru, weights[2]) if self.gru.bias else None,
hidden_size,
)
)
bias_hhs.append(
_split_gru_gate_param(
getattr(self.gru, weights[3]) if self.gru.bias else None,
hidden_size,
)
)
def step_unpacked(h_layers, x_t, init_t, reset_hidden_t):
init_t = init_t.unsqueeze(0).unsqueeze(-1)
h_layers = torch.where(init_t, reset_hidden_t, h_layers)
h_unbound = h_layers.unbind(0)
new_h = []
for layer in range(num_layers):
h_new = _gru_cell_from_gate_params(
x_t,
h_unbound[layer],
weight_ihs[layer],
bias_ihs[layer],
weight_hhs[layer],
bias_hhs[layer],
)
new_h.append(h_new)
x_t = h_new
new_h = torch.stack(new_h, 0)
hidden_out = new_h.transpose(0, 1).flatten(1)
return new_h, x_t, hidden_out
h_carry = initial_hidden
outputs_list: list[torch.Tensor] = []
hidden_list: list[torch.Tensor] = []
time_steps = input.shape[0]
for t in range(time_steps):
h_carry, out_t, hidden_out = _torch_checkpoint.checkpoint(
step_unpacked,
h_carry,
input[t],
is_init[t],
reset_hidden[t],
use_reentrant=False,
)
outputs_list.append(out_t)
hidden_list.append(hidden_out)
outputs = torch.stack(outputs_list, 0)
hidden_steps = torch.stack(hidden_list, 0)
else:
layer_input = input
hidden_layers = []
for layer in range(num_layers):
weights = self.gru._all_weights[layer]
# cuDNN may flatten every layer into shared storage. Independent
# clones keep scan's input aliasing rules satisfied while
# preserving gradients to the original packed parameters.
w_ih = getattr(self.gru, weights[0]).clone()
w_hh = getattr(self.gru, weights[1]).clone()
if self.gru.bias:
b_ih = getattr(self.gru, weights[2]).clone()
b_hh = getattr(self.gru, weights[3]).clone()
else:
b_ih = layer_input.new_zeros(3 * hidden_size)
b_hh = layer_input.new_zeros(3 * hidden_size)
layer_hidden = _GRUScanFunction.apply(
layer_input,
initial_hidden[layer],
reset_hidden[:, layer],
is_init,
w_ih,
w_hh,
b_ih,
b_hh,
)
hidden_layers.append(layer_hidden)
layer_input = layer_hidden
outputs = layer_input
hidden_steps = torch.stack(hidden_layers, -2).flatten(-2)
outputs = outputs.transpose(0, 1)
hidden_steps = hidden_steps.unflatten(
-1, (self.gru.num_layers, self.gru.hidden_size)
).transpose(0, 1)
end_mask = torch.empty_like(is_init.transpose(0, 1))
end_mask[:, :-1] = is_init.transpose(0, 1)[:, 1:]
end_mask[:, -1] = True
hidden_steps = torch.where(
end_mask.unsqueeze(-1).unsqueeze(-1),
hidden_steps,
torch.zeros_like(hidden_steps),
)
return outputs, hidden_steps
# Recurrent mode manager
#
# Process-wide flag toggled by :class:`set_recurrent_mode`. RNN modules
# (:class:`LSTMModule`, :class:`GRUModule`) read this via :func:`recurrent_mode`
# inside their ``forward`` to decide between sequential (one-step) and
# recurrent (full-sequence) execution. Keeping it as a context manager rather
# than per-module state means a single block can flip every RNN in a composed
# policy without touching submodule references. The custom subclass below
# exists to keep this working under ``torch.compile``: Dynamo cannot trace
# ``ContextVar.get``, so we mirror the mode into a plain attribute and read
# from it during compilation.
class _RecurrentModeContextManager(_ContextManager):
def __init__(self):
super().__init__()
self._context_mode = contextvars.ContextVar(
"torchrl_recurrent_mode", default=None
)
def get_mode(self) -> bool | None:
# Dynamo can't trace ContextVar.get; fall back to the parent's plain
# attribute under torch.compile. set_mode keeps both in sync so this
# stays correct (compile traces a single thread).
if is_compiling():
return self._mode
return self._context_mode.get()
def set_mode(self, mode: bool | None) -> None:
self._mode = mode
if not is_compiling():
self._context_mode.set(mode)
recurrent_mode_state_manager = _RecurrentModeContextManager()
def recurrent_mode() -> bool | None:
"""Returns the current sampling type."""
return recurrent_mode_state_manager.get_mode()
[docs]
class set_recurrent_mode(_DecoratorContextManager):
"""Context manager for setting RNNs recurrent mode.
Args:
mode (bool, "recurrent" or "sequential"): the recurrent mode to be used within the context manager.
`"recurrent"` leads to `mode=True` and `"sequential"` leads to `mode=False`.
An RNN executed with recurrent_mode "on" assumes that the data comes in time batches, otherwise
it is assumed that each data element in a tensordict is independent of the others.
The default value of this context manager is ``True``.
The default recurrent mode is ``None``, i.e., the default recurrent mode of the RNN is used
(see :class:`~torchrl.modules.LSTMModule` and :class:`~torchrl.modules.GRUModule` constructors).
.. seealso:: :class:`~torchrl.modules.recurrent_mode``.
.. note:: All of TorchRL methods are decorated with ``set_recurrent_mode(True)`` by default.
When to use which mode:
- **Sequential** (default, ``mode=False``): inside collectors, where
the policy is called step-by-step and the hidden state from the
previous step is fed back through the tensordict.
- **Recurrent** (``mode=True``): inside loss / advantage computation
(e.g. GAE) where a full ``(B, T, ...)`` batch is replayed and you
want the RNN to process the time dim in a single call. This is the
mode that activates the multi-trajectory split inside
:meth:`LSTMModule.forward`.
See the :ref:`Recurrent state lifecycle <ref_recurrent_state_lifecycle>`
guide for a full walkthrough of when each mode fires.
"""
def __init__(
self, mode: bool | typing.Literal["recurrent", "sequential"] | None = True
) -> None:
super().__init__()
if isinstance(mode, str):
if mode.lower() in ("recurrent",):
mode = True
elif mode.lower() in ("sequential",):
mode = False
else:
raise ValueError(
f"Unsupported recurrent mode. Must be a bool, or one of {('recurrent', 'sequential')}"
)
self.mode = mode
def clone(self) -> set_recurrent_mode:
# override this method if your children class takes __init__ parameters
return type(self)(self.mode)
def __enter__(self) -> None:
self.prev = recurrent_mode_state_manager.get_mode()
recurrent_mode_state_manager.set_mode(self.mode)
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
recurrent_mode_state_manager.set_mode(self.prev)
[docs]
def canonicalize_rnn_subset(
data: TensorDictBase,
modules: Iterable[LSTMModule | GRUModule],
*,
inplace: bool = False,
) -> TensorDictBase:
"""Canonicalize only the union of RNN keys used by ``modules``.
Convenience wrapper around :meth:`LSTMModule.canonicalize` /
:meth:`GRUModule.canonicalize` for pipelines that feed several recurrent
modules from the same TensorDict (e.g. a recurrent actor and a recurrent
critic). The union of every module's :attr:`canonical_keys` is collected,
canonicalized once, and merged back. Other leaves are untouched.
Args:
data: TensorDict to canonicalize.
modules: Iterable of :class:`LSTMModule` / :class:`GRUModule` whose
:attr:`canonical_keys` define the subset to canonicalize.
inplace: When ``True``, mutates ``data`` in place and returns it.
Defaults to ``False``.
Returns:
A TensorDict with canonical layout on the RNN-relevant leaves.
Examples:
>>> import torch
>>> from tensordict import TensorDict
>>> from torchrl.modules import LSTMModule, canonicalize_rnn_subset
>>> actor = LSTMModule(input_size=3, hidden_size=4, in_key="obs",
... out_key="actor_h")
>>> critic = LSTMModule(input_size=3, hidden_size=4, in_key="obs",
... out_key="critic_h")
>>> td = TensorDict({"obs": torch.zeros(2, 5, 3)}, batch_size=[2, 5])
>>> canonicalize_rnn_subset(td, [actor, critic])["obs"].is_contiguous()
True
"""
seen: set[tuple] = set()
keys: list[NestedKey] = []
for module in modules:
for key in module.canonical_keys:
tup = (key,) if isinstance(key, str) else tuple(key)
if tup not in seen:
seen.add(tup)
keys.append(key)
keys = [key for key in keys if key in data.keys(include_nested=True)]
if not keys:
return data
sub = data.select(*keys, strict=False)
try:
sub = sub.contiguous(canonical=True)
except TypeError:
sub = sub.contiguous()
out = data if inplace else data.copy()
out.update(sub)
return out