@@ -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+
210316class _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):
0 commit comments