Skip to content

Commit aa7a101

Browse files
committed
[Feature] Add prepared TensorDict copy_at writer
1 parent 7eac383 commit aa7a101

2 files changed

Lines changed: 161 additions & 0 deletions

File tree

tensordict/base.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,112 @@ class _NoDefault(enum.IntEnum):
207207
Self = Any
208208

209209

210+
class _TensorDictCopyAtWriter:
211+
def __init__(
212+
self,
213+
destination: TensorDictBase,
214+
dim: int,
215+
source: TensorDictBase | None = None,
216+
):
217+
self.destination = destination
218+
self.dim = _maybe_correct_neg_dim(dim, destination.batch_size)
219+
self.keys, self.dest_values = destination._items_list(True, True)
220+
self._indexed_dest_values: dict[Any, list[Tensor]] = {}
221+
if any(not isinstance(dest, Tensor) for dest in self.dest_values):
222+
raise TypeError(
223+
"prepare_copy_at_ only supports tensor leaves in the destination "
224+
"tensordict."
225+
)
226+
if source is not None:
227+
self._source_values(source)
228+
229+
def _source_values(self, source: TensorDictBase) -> list[Tensor]:
230+
if not _is_tensor_collection(type(source)):
231+
raise TypeError(
232+
"prepare_copy_at_.copy_ expected a TensorDictBase source, "
233+
f"got {type(source)}."
234+
)
235+
source_keys, source_values = source._items_list(True, True)
236+
if source_keys != self.keys:
237+
_, source_values = source._items_list(
238+
True, True, sorting_keys=self.keys, default=None
239+
)
240+
if len(source_values) != len(self.dest_values):
241+
raise RuntimeError(
242+
"The source and destination tensordicts must have matching leaves."
243+
)
244+
if any(not isinstance(source, Tensor) for source in source_values):
245+
raise TypeError(
246+
"prepare_copy_at_ only supports tensor leaves in the source "
247+
"tensordict."
248+
)
249+
return source_values
250+
251+
def _index(self, index: IndexType) -> tuple:
252+
idx = [slice(None)] * self.destination.batch_dims
253+
idx[self.dim] = index
254+
return tuple(idx)
255+
256+
def _cache_key(self, index: IndexType):
257+
try:
258+
hash(index)
259+
except TypeError:
260+
return None
261+
return index
262+
263+
def _get_indexed_dest_values(self, index: IndexType) -> list[Tensor]:
264+
cache_key = self._cache_key(index)
265+
if cache_key is not None:
266+
cached = self._indexed_dest_values.get(cache_key)
267+
if cached is not None:
268+
return cached
269+
idx = self._index(index)
270+
indexed_dest_values = []
271+
for key, dest in zip(self.keys, self.dest_values):
272+
try:
273+
dest_indexed = dest[idx]
274+
except (IndexError, RuntimeError, TypeError) as err:
275+
raise IndexError(
276+
f"Could not index destination leaf {key} with index {idx}."
277+
) from err
278+
if (
279+
dest_indexed is not dest
280+
and getattr(dest_indexed, "_base", None) is None
281+
):
282+
raise IndexError(
283+
f"Index {idx} does not produce a writable view for key {key}."
284+
)
285+
indexed_dest_values.append(dest_indexed)
286+
if cache_key is not None:
287+
self._indexed_dest_values[cache_key] = indexed_dest_values
288+
return indexed_dest_values
289+
290+
def copy_(
291+
self,
292+
source: TensorDictBase,
293+
index: IndexType,
294+
*,
295+
non_blocking: bool = False,
296+
) -> TensorDictBase:
297+
source_values = self._source_values(source)
298+
indexed_dest_values = self._get_indexed_dest_values(index)
299+
for key, dest_indexed, source_value in zip(
300+
self.keys, indexed_dest_values, source_values
301+
):
302+
if dest_indexed.shape != source_value.shape:
303+
raise RuntimeError(
304+
f"Shape mismatch for key {key}: indexed destination has shape "
305+
f"{dest_indexed.shape}, source has shape {source_value.shape}."
306+
)
307+
if _foreach_copy_ is not None:
308+
copy_fn = _foreach_copy_compiled if is_compiling() else _foreach_copy_
309+
copy_fn(indexed_dest_values, source_values, non_blocking=non_blocking)
310+
else:
311+
for dest, source_value in zip(indexed_dest_values, source_values):
312+
dest.copy_(source_value, non_blocking=non_blocking)
313+
return self.destination
314+
315+
210316
class _BEST_ATTEMPT_INPLACE:
211317
def __bool__(self):
212318
# we use an exception to exit when running `inplace = BEST_ATTEMPT_INPLACE if inplace else False`
@@ -8754,6 +8860,29 @@ def copy_at_(
87548860
"""See :obj:`TensorDictBase.update_at_`."""
87558861
return self.update_at_(tensordict, idx, non_blocking=non_blocking)
87568862

8863+
def prepare_copy_at_(
8864+
self, dim: int, source: T | None = None
8865+
) -> _TensorDictCopyAtWriter:
8866+
"""Creates a reusable writer for repeated indexed copies.
8867+
8868+
Args:
8869+
dim (int): dimension of ``self`` that will receive each source
8870+
tensordict.
8871+
source (TensorDictBase, optional): if provided, validates that the
8872+
source structure is compatible with ``self``.
8873+
8874+
Returns:
8875+
A lightweight writer with a ``copy_(source, index)`` method.
8876+
8877+
Examples:
8878+
>>> dest = TensorDict({"x": torch.zeros(3, 2)}, batch_size=[3, 2])
8879+
>>> src = TensorDict({"x": torch.ones(3)}, batch_size=[3])
8880+
>>> writer = dest.prepare_copy_at_(dim=1, source=src)
8881+
>>> writer.copy_(src, index=0)
8882+
>>> assert (dest[:, 0] == src).all()
8883+
"""
8884+
return _TensorDictCopyAtWriter(self, dim=dim, source=source)
8885+
87578886
def is_empty(self) -> bool:
87588887
"""Checks if the tensordict contains any leaf."""
87598888
for _ in self.keys(True, True):

test/test_tensordict.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9290,6 +9290,38 @@ def test_update_at_nested_time_slice_locked(self, td_name, device):
92909290
td.update_at_(td0, (slice(None), 2))
92919291
assert (td[:, 2] == td0).all()
92929292

9293+
def test_prepare_copy_at_(self, td_name, device):
9294+
td = TensorDict(
9295+
{
9296+
"a": torch.zeros(4, 3, 5, device=device),
9297+
"b": TensorDict(
9298+
{"c": torch.zeros(4, 3, 2, device=device)},
9299+
batch_size=[4, 3],
9300+
device=device,
9301+
),
9302+
},
9303+
batch_size=[4, 3],
9304+
device=device,
9305+
)
9306+
td0 = TensorDict(
9307+
{
9308+
"a": torch.ones(4, 5, device=device),
9309+
"b": TensorDict(
9310+
{"c": torch.ones(4, 2, device=device)},
9311+
batch_size=[4],
9312+
device=device,
9313+
),
9314+
},
9315+
batch_size=[4],
9316+
device=device,
9317+
)
9318+
writer = td.prepare_copy_at_(dim=1, source=td0)
9319+
writer.copy_(td0, index=0)
9320+
writer.copy_(td0.clone().zero_(), index=2)
9321+
assert (td[:, 0] == td0).all()
9322+
assert (td[:, 1] == 0).all()
9323+
assert (td[:, 2] == 0).all()
9324+
92939325
# This is needed because update in lazy permute/view etc does not behave correctly when
92949326
# legacy is False. When these classes will be deprecated, we can just remove the decorator
92959327
@set_lazy_legacy(True)

0 commit comments

Comments
 (0)