Skip to content

Commit abbde4e

Browse files
committed
Preserve method output contracts with current TensorDict
1 parent 77e2a85 commit abbde4e

3 files changed

Lines changed: 77 additions & 1 deletion

File tree

src/tdhook/modules.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from tensordict.nn import TensorDictModuleWrapper, TensorDictModuleBase, TensorDictSequential
22
from tensordict import NonTensorData, TensorDict, TensorDictBase
3-
from tensordict.utils import NestedKey
3+
from tensordict.utils import NestedKey, unravel_key_list
44
from typing import Callable, Optional, TYPE_CHECKING, List
55
import torch
66
from textwrap import indent
@@ -271,6 +271,19 @@ def __init__(
271271
self._hooking_context = hooking_context
272272
self._relative_path = relative_path
273273

274+
@TensorDictModuleWrapper.out_keys.setter
275+
def out_keys(self, value: List[NestedKey]):
276+
# Finalization can publish extra outputs that the wrapped model does not
277+
# return. Keep their declaration and native key selection on this wrapper.
278+
keys = unravel_key_list(list(value))
279+
if "_out_keys" not in self.__dict__:
280+
self._out_keys = keys
281+
self._out_keys_apparent = keys
282+
283+
@property
284+
def out_keys_source(self):
285+
return self.__dict__.get("_out_keys", self.td_module.out_keys_source)
286+
274287
@property
275288
def hook_root(self) -> TensorDictModuleBase:
276289
"""Return the caller-owned module against which hook paths resolve."""

tests/latent/test_activation_caching.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from tdhook.modules import get_best_device
1212
from tdhook.runtime import HookProgram, HookSpec
1313
from tdhook.targets import Target
14+
from tdhook.workflow import Workflow
1415

1516

1617
class TestActivationCaching:
@@ -48,6 +49,38 @@ def test_tensordict_execution_publishes_a_native_cache_output(self, default_test
4849
assert hooked_module.out_keys == ["output", ("activations", "cache")]
4950
assert result["activations", "cache"]["linear2"].shape == (2, 20)
5051

52+
@pytest.mark.parametrize("run_in_workflow", [False, True])
53+
def test_cache_publication_preserves_the_callers_model_contract(self, run_in_workflow):
54+
raw_model = torch.nn.Sequential(torch.nn.Linear(3, 4))
55+
input_key = ("inputs", "value")
56+
output_key = ("predictions", "value")
57+
cache_key = ("activations", "hidden")
58+
model = TensorDictModule(raw_model, in_keys=[input_key], out_keys=[output_key])
59+
method = ActivationCaching(r"module\.0$", cache_key=cache_key)
60+
inputs = torch.randn(2, 3)
61+
expected = raw_model(inputs)
62+
63+
for _ in range(2):
64+
data = TensorDict({input_key: inputs}, batch_size=[2])
65+
if run_in_workflow:
66+
result = Workflow(method)(model, data)
67+
else:
68+
context = method.prepare(model)
69+
assert model.out_keys == [output_key]
70+
assert context.module.out_keys == [output_key, cache_key]
71+
with context as hooked_module:
72+
result = hooked_module(data)
73+
74+
assert model.in_keys == [input_key]
75+
assert model.out_keys == [output_key]
76+
assert model.out_keys_source == [output_key]
77+
torch.testing.assert_close(result[output_key], expected)
78+
torch.testing.assert_close(result[cache_key]["module.0"], expected)
79+
80+
plain_result = model(TensorDict({input_key: inputs}, batch_size=[2]))
81+
torch.testing.assert_close(plain_result[output_key], expected)
82+
assert cache_key not in plain_result.keys(include_nested=True)
83+
5184
def test_target_selection_is_cached_and_reported(self, default_test_model):
5285
target = Target("linear2", "activation", -1, (0, 2))
5386

tests/test_modules.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,33 @@ def test_bound_module_is_context_owned_and_finalizes_results(default_test_model)
111111

112112
with pytest.raises(RuntimeError, match="called in context"):
113113
prepared(TensorDict({"input": torch.ones(2, 10)}, batch_size=[2]))
114+
115+
116+
def test_method_outputs_can_be_selected_and_reset_without_changing_model_outputs():
117+
class PublishingModule(HookedModule):
118+
def __init__(self, *args, **kwargs):
119+
super().__init__(*args, **kwargs)
120+
self.out_keys = [*self.out_keys, ("metrics", "sum")]
121+
122+
def finalize_tensordict(self, data):
123+
return data.set(("metrics", "sum"), data["output"].sum(-1))
124+
125+
class PublishingMethod(HookingContextFactory):
126+
_hooked_module_class = PublishingModule
127+
128+
model = TensorDictModule(torch.nn.Identity(), in_keys=["input"], out_keys=["output"])
129+
with PublishingMethod().prepare(model) as prepared:
130+
assert prepared.out_keys_source == ["output", ("metrics", "sum")]
131+
prepared.select_out_keys(("metrics", "sum"))
132+
selected = prepared(TensorDict({"input": torch.ones(2, 3)}, batch_size=[2]))
133+
assert prepared.out_keys == [("metrics", "sum")]
134+
assert "output" not in selected
135+
torch.testing.assert_close(selected["metrics", "sum"], torch.full((2,), 3.0))
136+
137+
prepared.reset_out_keys()
138+
assert prepared.out_keys == ["output", ("metrics", "sum")]
139+
restored = prepared(TensorDict({"input": torch.ones(2, 3)}, batch_size=[2]))
140+
torch.testing.assert_close(restored["output"], torch.ones(2, 3))
141+
142+
assert model.out_keys == ["output"]
143+
assert model.out_keys_source == ["output"]

0 commit comments

Comments
 (0)