Skip to content

Commit 02e0c6a

Browse files
committed
[Performance] Add fast path for TensorDict copy_at_
1 parent 05efeca commit 02e0c6a

6 files changed

Lines changed: 231 additions & 5 deletions

File tree

benchmarks/common/collector_write_benchmarks_test.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ def _stack_out(rollout, steps, device):
146146

147147
def _copy_at(rollout, steps, device):
148148
for i, step in enumerate(steps):
149-
rollout.copy_at_(step, idx=(slice(None), i))
149+
rollout.copy_at_(step, idx=(slice(None), i), fast=True)
150150
_maybe_synchronize(device)
151151

152152

tensordict/_td.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@
3939
_default_is_leaf,
4040
_device_recorder,
4141
_expand_to_match_shape,
42+
_foreach_copy_,
43+
_foreach_copy_compiled,
4244
_is_leaf_nontensor,
4345
_is_tensor_collection,
4446
_load_metadata,
@@ -2714,6 +2716,62 @@ def _set_at_tuple(self, key, value, idx, *, validated, non_blocking: bool):
27142716
)
27152717
return self
27162718

2719+
def _update_at_fast(
2720+
self,
2721+
input_dict_or_td: dict[str, CompatibleType] | T,
2722+
idx: IndexType,
2723+
clone: bool,
2724+
*,
2725+
non_blocking: bool,
2726+
keys_to_update: Sequence[NestedKey] | None,
2727+
) -> Any:
2728+
if (
2729+
clone
2730+
or keys_to_update is not None
2731+
or type(self) is not TensorDict
2732+
or type(input_dict_or_td) is not TensorDict
2733+
):
2734+
return NotImplemented
2735+
source_keys, source_values = input_dict_or_td._items_list(True, True)
2736+
if not source_keys:
2737+
if input_dict_or_td.is_empty():
2738+
return self
2739+
return NotImplemented
2740+
dest_values = []
2741+
for key in source_keys:
2742+
dest = self
2743+
for subkey in _unravel_key_to_tuple(key):
2744+
if type(dest) is not TensorDict or subkey not in dest._tensordict:
2745+
return NotImplemented
2746+
dest = dest._tensordict[subkey]
2747+
dest_values.append(dest)
2748+
if not isinstance(idx, tuple):
2749+
idx = (idx,)
2750+
idx = convert_ellipsis_to_idx(idx, self.batch_size)
2751+
indexed_dest_values = []
2752+
for dest, source in zip(dest_values, source_values):
2753+
if not isinstance(dest, Tensor) or not isinstance(source, Tensor):
2754+
return NotImplemented
2755+
try:
2756+
dest_indexed = dest[idx]
2757+
except (IndexError, RuntimeError, TypeError):
2758+
return NotImplemented
2759+
if dest_indexed.shape != source.shape:
2760+
return NotImplemented
2761+
if (
2762+
dest_indexed is not dest
2763+
and getattr(dest_indexed, "_base", None) is None
2764+
):
2765+
return NotImplemented
2766+
indexed_dest_values.append(dest_indexed)
2767+
if _foreach_copy_ is not None:
2768+
copy_fn = _foreach_copy_compiled if is_compiling() else _foreach_copy_
2769+
copy_fn(indexed_dest_values, source_values, non_blocking=non_blocking)
2770+
else:
2771+
for dest, source in zip(indexed_dest_values, source_values):
2772+
dest.copy_(source, non_blocking=non_blocking)
2773+
return self
2774+
27172775
@lock_blocked
27182776
def del_(self, key: NestedKey) -> Self:
27192777
key = _unravel_key_to_tuple(key)

tensordict/_tensorcollection.pyi

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -985,7 +985,12 @@ class TensorCollection:
985985
def create_nested(self, key): ...
986986
def copy_(self, tensordict: T, non_blocking: bool = False) -> Self: ...
987987
def copy_at_(
988-
self, tensordict: T, idx: IndexType, non_blocking: bool = False
988+
self,
989+
tensordict: T,
990+
idx: IndexType,
991+
non_blocking: bool = False,
992+
*,
993+
fast: bool | None = None,
989994
) -> Self: ...
990995
def is_empty(self) -> bool: ...
991996
def setdefault(

tensordict/base.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8553,6 +8553,9 @@ def update_at_(
85538553
"""Updates the TensorDict in-place at the specified index with values from either a dictionary or another TensorDict.
85548554

85558555
Unlike TensorDict.update, this function will throw an error if the key is unknown to the TensorDict.
8556+
This method keeps the general ``set_at_`` semantics and supports tensor
8557+
and non-tensor leaves. For optimized tensor-only copies in hot paths,
8558+
use :meth:`~tensordict.TensorDictBase.copy_at_` with ``fast=True``.
85568559

85578560
Args:
85588561
input_dict_or_td (TensorDictBase or dict): input data to be written
@@ -8620,6 +8623,17 @@ def update_at_(
86208623
self.set_at_((firstkey, *nextkeys), value, idx, non_blocking=non_blocking)
86218624
return self
86228625

8626+
def _update_at_fast(
8627+
self,
8628+
input_dict_or_td: dict[str, CompatibleType] | T,
8629+
idx: IndexType,
8630+
clone: bool,
8631+
*,
8632+
non_blocking: bool,
8633+
keys_to_update: Sequence[NestedKey] | None,
8634+
) -> Any:
8635+
return NotImplemented
8636+
86238637
def replace(self, *args, **kwargs):
86248638
"""Creates a shallow copy of the tensordict where entries have been replaced.
86258639

@@ -8729,9 +8743,70 @@ def copy_(self, tensordict: T, non_blocking: bool = False) -> Self:
87298743
return self.update_(tensordict, non_blocking=non_blocking)
87308744

87318745
def copy_at_(
8732-
self, tensordict: T, idx: IndexType, non_blocking: bool = False
8746+
self,
8747+
tensordict: T,
8748+
idx: IndexType,
8749+
non_blocking: bool = False,
8750+
*,
8751+
fast: bool | None = None,
87338752
) -> Self:
8734-
"""See :obj:`TensorDictBase.update_at_`."""
8753+
"""Copies values from ``tensordict`` into ``self`` at the specified index.
8754+
8755+
``copy_at_`` is an explicit copy-oriented variant of
8756+
:meth:`~tensordict.TensorDictBase.update_at_`. Unlike ``update_at_``,
8757+
it may use optimized tensor-only copy paths and is intended for hot
8758+
paths where the source and destination structures are known to match.
8759+
8760+
Args:
8761+
tensordict (TensorDictBase): input data to be copied in ``self``.
8762+
idx (int, torch.Tensor, iterable, slice): index of the tensordict
8763+
where the copy should occur.
8764+
non_blocking (bool, optional): if ``True`` and this copy is between
8765+
different devices, the copy may occur asynchronously with respect
8766+
to the host.
8767+
8768+
Keyword Args:
8769+
fast (bool or None, optional): controls whether ``copy_at_`` may
8770+
fall back to :meth:`~tensordict.TensorDictBase.update_at_`.
8771+
If ``True``, only the optimized tensor-only path is used and a
8772+
``RuntimeError`` is raised when the fast path is not available.
8773+
If ``False``, this method delegates directly to ``update_at_``.
8774+
If ``None``, the current default, ``copy_at_`` warns and falls
8775+
back to ``update_at_`` when the fast path is unavailable. The
8776+
default will become ``True`` in v0.14.
8777+
8778+
Returns:
8779+
self
8780+
"""
8781+
if fast is None:
8782+
warnings.warn(
8783+
"copy_at_(..., fast=None) currently falls back to update_at_ "
8784+
"when the optimized tensor-only copy path is unavailable. "
8785+
"This default will change to fast=True in v0.14, making "
8786+
"copy_at_ fast-only by default. Pass fast=False to keep the "
8787+
"current fallback behavior, or fast=True to require the fast "
8788+
"path.",
8789+
FutureWarning,
8790+
stacklevel=2,
8791+
)
8792+
elif fast is False:
8793+
return self.update_at_(tensordict, idx, non_blocking=non_blocking)
8794+
result = self._update_at_fast(
8795+
input_dict_or_td=tensordict,
8796+
idx=idx,
8797+
clone=False,
8798+
non_blocking=non_blocking,
8799+
keys_to_update=None,
8800+
)
8801+
if result is not NotImplemented:
8802+
return result
8803+
if fast:
8804+
raise RuntimeError(
8805+
"copy_at_(..., fast=True) requires the optimized tensor-only "
8806+
"copy path, but the source, destination, or index is not "
8807+
"compatible. Use fast=False or update_at_ for the general "
8808+
"update semantics."
8809+
)
87358810
return self.update_at_(tensordict, idx, non_blocking=non_blocking)
87368811

87378812
def is_empty(self) -> bool:

tensordict/tensorclass.pyi

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1030,7 +1030,12 @@ class TensorClass:
10301030
def create_nested(self, key: NestedKey) -> Self: ...
10311031
def copy_(self, tensordict: T, non_blocking: bool = False) -> Self: ...
10321032
def copy_at_(
1033-
self, tensordict: T, idx: IndexType, non_blocking: bool = False
1033+
self,
1034+
tensordict: T,
1035+
idx: IndexType,
1036+
non_blocking: bool = False,
1037+
*,
1038+
fast: bool | None = None,
10341039
) -> Self: ...
10351040
def is_empty(self) -> bool: ...
10361041
def setdefault(

test/test_tensordict.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9232,6 +9232,89 @@ def test_update_at_(self, td_name, device):
92329232
td.update_at_(td0, 0)
92339233
assert (td[0] == 0).all()
92349234

9235+
def test_update_at_nested_time_slice(self, td_name, device):
9236+
td = TensorDict(
9237+
{
9238+
"a": torch.zeros(4, 3, 5, device=device),
9239+
"b": TensorDict(
9240+
{"c": torch.zeros(4, 3, 2, device=device)},
9241+
batch_size=[4, 3],
9242+
device=device,
9243+
),
9244+
},
9245+
batch_size=[4, 3],
9246+
device=device,
9247+
)
9248+
td0 = TensorDict(
9249+
{
9250+
"a": torch.ones(4, 5, device=device),
9251+
"b": TensorDict(
9252+
{"c": torch.ones(4, 2, device=device)},
9253+
batch_size=[4],
9254+
device=device,
9255+
),
9256+
},
9257+
batch_size=[4],
9258+
device=device,
9259+
)
9260+
td.update_at_(td0, (slice(None), 1))
9261+
assert (td[:, 1] == td0).all()
9262+
assert (td[:, 0] == 0).all()
9263+
assert (td[:, 2] == 0).all()
9264+
9265+
def test_update_at_nested_time_slice_locked(self, td_name, device):
9266+
td = TensorDict(
9267+
{
9268+
"a": torch.zeros(4, 3, 5, device=device),
9269+
"b": TensorDict(
9270+
{"c": torch.zeros(4, 3, 2, device=device)},
9271+
batch_size=[4, 3],
9272+
device=device,
9273+
),
9274+
},
9275+
batch_size=[4, 3],
9276+
device=device,
9277+
).lock_()
9278+
td0 = TensorDict(
9279+
{
9280+
"a": torch.ones(4, 5, device=device),
9281+
"b": TensorDict(
9282+
{"c": torch.ones(4, 2, device=device)},
9283+
batch_size=[4],
9284+
device=device,
9285+
),
9286+
},
9287+
batch_size=[4],
9288+
device=device,
9289+
)
9290+
td.update_at_(td0, (slice(None), 2))
9291+
assert (td[:, 2] == td0).all()
9292+
9293+
@pytest.mark.parametrize("method", ["copy_at_", "update_at_"])
9294+
def test_update_at_nontensor_data(self, td_name, device, method):
9295+
td = TensorDict({"val": NonTensorData(data=0, batch_size=[10])}, [10])
9296+
newdata = TensorDict({"val": NonTensorData(data=1, batch_size=[5])}, [5])
9297+
9298+
if method == "copy_at_":
9299+
getattr(td, method)(newdata, slice(1, None, 2), fast=False)
9300+
else:
9301+
getattr(td, method)(newdata, slice(1, None, 2))
9302+
9303+
assert td.get("val").tolist() == [0, 1] * 5
9304+
9305+
def test_copy_at_fast_transition(self, td_name, device):
9306+
td = TensorDict({"val": NonTensorData(data=0, batch_size=[10])}, [10])
9307+
newdata = TensorDict({"val": NonTensorData(data=1, batch_size=[5])}, [5])
9308+
9309+
with pytest.warns(FutureWarning, match="fast=True in v0.14"):
9310+
td.copy_at_(newdata, slice(1, None, 2))
9311+
assert td.get("val").tolist() == [0, 1] * 5
9312+
9313+
td = TensorDict({"val": NonTensorData(data=0, batch_size=[10])}, [10])
9314+
with pytest.raises(RuntimeError, match="fast=True"):
9315+
td.copy_at_(newdata, slice(1, None, 2), fast=True)
9316+
assert td.get("val").tolist() == [0] * 10
9317+
92359318
# This is needed because update in lazy permute/view etc does not behave correctly when
92369319
# legacy is False. When these classes will be deprecated, we can just remove the decorator
92379320
@set_lazy_legacy(True)

0 commit comments

Comments
 (0)