Repository navigation
[BugFix] Pointwise ops with a batch-shaped tensor give the right values for a nested lazy stack - #3041
Merged
peterdsharpe merged 2 commits intoOct 19, 2026
Conversation
…es for a nested lazy stack A pointwise op with a batch-shaped tensor, such as td.add(w), expanded the tensor against each leaf with expand_as_right. A lazy stack nested in the tensordict runs that function on the leaves of its members, which lack the stack dim, so the tensor was aligned with the wrong dims: the call raised, or gave wrong values when the sizes matched. lerp, addcmul and addcdiv raised for such trees on purpose, through the _has_nested_lazy_stack guard of pytorch#3002. The broadcast branch of _maybe_broadcast_other now treats a nested lazy stack as a leaf and calls the op on it, so it takes the per-member split that a lazy stack at the root already gets (pytorch#2365). The guard is removed, and the two-operand ops now work on these trees. Dense trees and lazy stacks at the root give the same results as before. Closes pytorch#3005.
…nsorclass or TypedTensorDict The previous commit called the op on a nested lazy stack, but only when the lazy stack itself was the entry. A lazy stack of tensorclasses, or a TypedTensorDict built on a lazy stack, nested in a tensordict, still ran the op on the leaves of the members: add gave wrong values as on main, and lerp, addcmul and addcdiv, which the removed guard made raise, gave wrong values too. The is_leaf of the broadcast branch now also stops at tensorclasses and TypedTensorDicts, so the op is called on the wrapper. A TypedTensorDict on a lazy stack runs the op on the stack and wraps the result, which also fixes its out-of-place ops at the root. The wider is_leaf is used only when the tree holds a lazy stack: calling the op on each dense tensorclass cost 30% on dense trees. Closes pytorch#3005.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3005.
A tensor with the batch shape, such as a per-sample weight, is meant to line up with the batch dims of every leaf. When a tensordict holds a lazy stack below its root, as heterogeneous data or a
lazy_stackof observations often does,add,mul,maximumand the other pointwise ops lined it up with the feature dims of the stacked leaves instead. The call raised when the sizes differed, and gave wrong values with no error when they matched. With this PR the nested lazy stack gives the same values as the dense tensordict, also when a tensorclass or aTypedTensorDictwraps it, andlerp,addcmulandaddcdiv, which #3002 made raise on such trees on purpose, now work too.What was wrong
A lazy stack of tensorclasses, or a
TypedTensorDictbuilt on a lazy stack withfrom_tensordict, nested in a tensordict, gives the same wrong values foraddon main.The fix
For a tensor operand, the broadcast branch of
_maybe_broadcast_other(tensordict/base.py) callsself._fast_apply(lambda x: op(x, expand_as_right(other, x))). A nested lazy stack runs that function on the leaves of its members (LazyStackedTensorDict._apply_nest), and those leaves lack the stack dim, soexpand_as_rightlineswup with the wrong dims. A lazy stack at the root never gets there: an earlier branch gives each member its slice of the operand (#2365).When the tree holds a lazy stack (
_has_lazy_stack, which looks through tensorclasses and TypedTensorDicts), the_fast_applycall now passesis_leaf=_is_leaf_or_lazy_stack, a new private helper. It accepts what_default_is_leafaccepts, plus aLazyStackedTensorDictthat is not aNonTensorStack, a tensorclass and aTypedTensorDict. The function then runs on the nested stack, or on the wrapper that holds it:expand_as_rightexpandswto its batch size, and the op called on it reaches the root branch, which splitswalong the stack dim. Deeper nesting is handled the same way at each level. The two-operand branch (lerp,addcmul,addcdiv) passes the sameis_leafto_apply_pointwise, which pairs the nested stack with the entry of the tensordict operand under the same key. That made the_has_nested_lazy_stackguard of #3002 unnecessary, so I removed it; it had no other caller.A
TypedTensorDictdelegates its pointwise ops to the base class, so the op called on one that wraps a lazy stack ran_fast_applythrough the stack's members as well: on mainS.from_tensordict(lazy_stack(...)).add(w)gives wrong values at the root too (the in-place forms were right). The root branch now runs the op on the lazy stack that aTypedTensorDictwraps and wraps the result in the same class. Without this, the nested case above would still give wrong values for aTypedTensorDict.Trees with no lazy stack keep main's path:
is_leafstaysNone, and the walk of_has_lazy_stackis the only added step (2.7 us on a tree of 30 leaves). Calling the op on every tensorclass instead cost 30% on a dense tree that holds tensorclasses, so the wideris_leafis used only when it is needed._fast_applykeeps its dense fast path (_dense_apply) for the newis_leaf, as it does for the default one.What I checked:
self, and the state ofselfafterwards:add,add(alpha=2),sub,mul,div,pow,%,rsub,+,w + td,+=,maximum,minimum,clamp_min,clamp_max_,clamp,lerp(tensordict end, dense end, tensor end),addcmul,addcdivand the in-place forms. The trees: dense; a lazy stack at the root; a lazy stack nested one or two levels down; a lazy stack of tensorclasses at the root and nested; aTypedTensorDicton a lazy stack at the root and nested; and a dense tensorclass and a denseTypedTensorDictnested in a tensordict. Stack dims 0 and 1, homogeneous and heterogeneous members, members that hold a nested lazy stack themselves, and operands of the batch shape, a broadcastable shape, a shape with an extra leading dim, and 0-d. Every call on a dense tree (with or without tensorclasses andTypedTensorDicts) and every call on a root lazy stack whose members hold no lazy stack gives the same result as main. No call that works on main raises here.TypedTensorDict), where the dense tensordict raises and the lazy stack returns a new, larger tensordict. That is main's behaviour for a root lazy stack ([BugFix] clamp writes into out, and the in-place and two-operand pointwise ops broadcast a batch-shaped tensor #3002), unchanged here. 432 calls with heterogeneous members and an operand with an extra leading dim were not checked.endortensor1all give the dense values; tensorclasses stay tensorclasses andTypedTensorDicts keep their class. ATensorDictParamsthat holds a lazy stack raises as on main, and memory-mapped tensordicts give main's results.torch.compile(fullgraph=True)compilesadd(w),add_(w),lerp(td + 1, w)andaddcmul(w, td + 1)on a dense tree, a root lazy stack and a lazy stack nested plain, as a tensorclass or as aTypedTensorDict, with values equal to eager, when the members of the stack have a batch dim. With members of batch size[],lerpon a nested lazy stack fails to compile (Inductor raisesGuardOnDataDependentSymNode), as a root lazy stack of the same members does on main; main raised on that call in eager too.td * wdoes not compile withfullgraph=Trueon main either, for dense and lazy trees, and is unchanged.Behaviour changes
Only calls on trees that hold a lazy stack below the root (or inside the members of a root lazy stack, or inside a
TypedTensorDict) change:add,sub,mul,div,maximum,minimum,clamp,clamp_min,clamp_maxand the other ops of_maybe_broadcast_other, with a tensor that has dims, now give the dense values. On main they raisedRuntimeErrorfromexpand_as_right, or gave wrong values when the leaf shapes matched the operand. This includes lazy stacks of tensorclasses, aTypedTensorDicton a lazy stack (nested, and at the root for the out-of-place ops), and nested lazy stacks seen through a view such asunsqueezeortranspose.selfand write into its leaves. On main they returned a new tensordict when they did not raise.lerp,lerp_,addcmul(_)andaddcdiv(_)with a tensordict operand and a batch-shaped tensor now work; they raisedTypeError.addcmulandaddcdivwith members that have an empty batch size give each member a 0-d slice, and then raise main'sTypeErrorfrom_foreach_addcmulfor a 0-d tensor with a tensordict, as a root lazy stack of such members already does on main (the 0-d bug noted in the [BUG] lerp, addcmul and addcdiv raise when one operand is a tensordict and the other a batch-shaped tensor #2963 triage, not fixed here). In-place ops with an operand that adds a leading dim raise the sameRuntimeErroras the dense tensordict.lerpwith a denseendand heterogeneous members raisesKeyErrorfor the keys that only some members have.The test
TestLazyStackedTensorDict.test_arithmetic_ops_broadcast_tensor_nestedintest/tensordict/test_lazy.pychecksadd,sub,mul,maximum,clamp_min,+,lerp,addcmulandmul_on a lazy stack nested one and two levels down, stack dims 0 and 1, against the dense tensordict. The leaves have the shape of the batch, so main gives wrong values without an error; all 4 cases fail on main with "Tensor-likes are not close!" and pass here.TestLazyStackedTensorDict.test_arithmetic_ops_broadcast_tensor_nested_wrappeddoes the same for a tensorclass and aTypedTensorDictthat wrap the nested lazy stack, and checks that they keep their class; its 4 cases fail on main the same way and pass here.TestPointwiseOps.test_nested_lazy_and_tensor_operandsintest/tensordict/test_misc.pypinned theTypeErrorof the guard; it now checks thatlerp,addcmul,addcdivand their in-place forms give the dense values and that the in-place forms returnself. Its 12 cases fail on main and pass here.What I ran
Local testing was CPU-only: Python 3.12, torch 2.14.1+cpu, with CI's environment variables (
TD_GET_DEFAULTS_TO_NONE=1 LIST_TO_STACK=1 TD_CHECK_INVARIANTS=1).test/tensordict/test_misc.pyandtest/tensordict/test_lazy.py: 2757 passed, 4 skipped.test/tensordict/test_methods.py,test/tensordict/test_nontensor.py,test/nn/test_functorch.py,test/tensorclass/test_tensorclass.py,test/tensorclass/test_typedtensordict.py: 11541 passed, 458 skipped.test/compile/test_compile.py -k "add or lerp or pointwise or arith or mul or broadcast or lazy": 116 passed, 8 skipped.test_misc.pyandtest_lazy.py: 2743 passed, 18 skipped.pre-commit runon the changed files (ruff check, ruff format, torchfix, whitespace): passed.import tensordictwith-W error::DeprecationWarningis silent.taskset -c 5, torch 2.14.1+cpu, Python 3.14), main against this branch, the minimum of 5 interleaved runs of 7 repeats, with a batch-shaped tensorwof shape[64]:add(w)31.72 against 32.92 us (+3.8%),mul(w)+3.1%,add_(w)+4.5%.add(w)280.2 against 284.3 us (+1.5%),add_(w)+1.5%.add(w)60.59 against 63.19 us (+4.3%),mul(w)+4.5%,add_(w)+4.2%.lerp(td, w)is 1-3% faster in all three, andtd + 1.0is unchanged.The cost is the walk that looks for a lazy stack below the root (about 1 us for 10 leaves, 3 us with the nested entries), paid only when an operand is a tensor.