torch.compile, torch.func, torch.export, and CUDA graphs#
This page lists what works when tensordicts and tensorclasses go through
torch.compile(), torch.func, torch.export.export(), and CUDA
graphs, what makes a function recompile or fail, and how to work around each
limit.
How tensordicts compile#
TensorDict, LazyStackedTensorDict,
and tensorclasses are registered as pytree nodes, so torch.func and
torch.export.export() see their tensors as leaves. Inside
torch.compile(), Dynamo traces the Python code of tensordict like user
code: indexing, apply, arithmetic, reductions (see Arithmetic, apply, reductions, and gradients),
shape operations, stacking, and construction all end up in the graph. Most
operations compile with fullgraph=True. With that flag, a graph break
raises an error instead of splitting the function into several graphs.
>>> import torch
>>> from tensordict import TensorDict, tensorclass
>>> @tensorclass
... class Transition:
... obs: torch.Tensor
... reward: torch.Tensor
>>> @torch.compile(fullgraph=True)
... def scale(td):
... return td.apply(lambda x: x / x.abs().max()) * 2
>>> @torch.compile(fullgraph=True)
... def step(data):
... return Transition(data.obs + 1, data.reward * 0.5, batch_size=data.batch_size)
>>> td = TensorDict(
... {
... "obs": torch.randn(8, 3),
... "next": TensorDict({"obs": torch.randn(8, 3)}, batch_size=[8]),
... },
... batch_size=[8],
... )
>>> scale(td).batch_size
torch.Size([8])
>>> step(Transition(torch.zeros(8, 3), torch.ones(8), batch_size=[8])).reward[0]
tensor(0.5000)
To find out why a function recompiles or breaks, run it with
TORCH_LOGS="recompiles" or TORCH_LOGS="graph_breaks" in the
environment, or call torch._logging.set_logs(recompiles=True) first.
Recompiles#
Dynamo specializes a compiled function on the structure of its inputs and
installs guards that check it on every call. A call that fails a guard
compiles the function again. By default, a function compiles at most 8 times
(torch._dynamo.config.recompile_limit). After that, it runs in eager mode,
or raises FailOnRecompileLimitHit with fullgraph=True. With
tensordicts, a function recompiles for the following reasons.
- Another key set
An operation that goes over all the entries (arithmetic,
apply,clone, reductions) is compiled for the keys it saw. An input with another key set compiles a new graph. Reading one entry (td["obs"]) does not depend on the other keys. Atd.get(key, default)that returneddefaultcompiles again once the input haskey.- Writing into an input tensordict
td["y"] = ...,td.set,td.update, and aTensorDictModule(which writes itsout_keysinto its input, see Modules: TensorDictModule and tensordict.nn) recompile whenever the key set of the input changes, even when the key written already exists. Dynamo guards on all the keys of a dict that the function writes into (#1606, pytorch/pytorch#175858). A write into a nested tensordict only depends on the keys of that nested tensordict. If the key sets vary, build a new tensordict in the compiled function and write it into the input outside:>>> @torch.compile(fullgraph=True) ... def policy(td): ... return TensorDict({"action": td["obs"].tanh()}, batch_size=td.batch_size) >>> td = TensorDict({"obs": torch.randn(4, 3)}, batch_size=[4]) >>> td["action"] = policy(td)["action"]
- Another batch size
The first call with a new batch size recompiles once with dynamic shapes (automatic dynamic shapes), and later sizes reuse that graph.
torch.compile(fn, dynamic=True)avoids the second compilation.torch._dynamo.mark_dynamicon an entry does not: the batch size holds Python ints, and a function that builds a tensordict withbatch_size=td.batch_sizethen raisesConstraintViolationError.split,chunk,unbind, andtolistrecompile for each batch size, also after that.- Lazy stacks of another length
A
LazyStackedTensorDictis specialized on the number of tensordicts it stacks, also withdynamic=True. A new stack of the same length and structure reuses the graph.- Strings in the output
Each distinct
strvalue of a tensorclass field or of aNonTensorDataentry (see Non-tensor data and unbatched entries) recompiles the function when the function returns it, for instance throughapply,clone, or the constructor of a tensorclass. Dynamo specializes the graph on the value. Reading only the tensors does not recompile. Return tensors from the compiled function and rebuild the tensorclass outside, or keep the strings out of the compiled function.- New tensorclasses and pytree flattening
A compiled function that flattens a tensordict as a pytree (a compiled
torch.vmap,torch.utils._pytree.tree_map) recompiles after a new tensorclass orTypedTensorDictsubclass is defined, because PyTorch guards on the size of its pytree registry. Define the classes before the first call.
With TORCH_LOGS="recompiles", the guard that failed names the cause. A
guard on len(td._tensordict) means that the key set of a tensordict that
the function writes into changed. A guard on td._batch_size[0] means a new
batch size.
Known limits#
The following patterns break the graph (an error with fullgraph=True).
The error messages in the table are those of PyTorch 2.13, 2.14, and the 2.16
nightly. Other versions can word them differently.
Pattern |
What happens |
Workaround |
|---|---|---|
|
Dynamo turns the operator into a |
Call the method: |
|
These return a Python |
Reduce in the graph:
|
Boolean-mask indexing: |
The result has a data-dependent shape, also with
|
Keep the shapes fixed: |
|
Eager-only operations (files, shared memory, Python classes built at run time). See Devices, dtypes, and memory and Save and load tensordicts. |
Call them outside the compiled function. |
|
Dynamo does not trace the backward pass:
|
Return the loss and call |
Building a |
Dynamo cannot build the new module inside the graph:
|
Build the subsequence once, outside, and call it inside. |
PyTorch 2.11 and 2.12 also break the graph on state_dict(), as 2.14 does
(pytorch/pytorch#201124).
On PyTorch 2.12, building a frozen tensor_only tensorclass whose custom
__init__ calls object.__setattr__ raises InternalTorchDynamoError
inside a compiled function: build it outside.
torch.func#
torch.vmap() maps over the batch dimensions of tensordicts and
tensorclasses: in_dims and out_dims refer to batch dimensions, and
tensordict methods, arithmetic included, work on the per-sample tensordict.
torch.compile(torch.vmap(fn)) compiles the same code.
>>> td = TensorDict({"a": torch.randn(5, 3), "b": torch.randn(5, 3)}, batch_size=[5])
>>> torch.vmap(lambda t: (t * 2 + 1).exp())(td).batch_size
torch.Size([5])
>>> torch.vmap(lambda t, x: t["a"] @ x, (0, None))(td, torch.randn(3)).shape
torch.Size([5])
Under torch.func transforms, pointwise tensordict methods run one
operation per entry, so they are slower than in eager mode
(pytorch/pytorch#200456).
torch.func.grad(), grad_and_value(),
vjp(), and jacrev() accept tensordict
inputs. The gradient is a tensordict with the structure of the input:
>>> grads = torch.func.grad(lambda t: (t["a"] ** 2).sum())(td)
>>> torch.testing.assert_close(grads["a"], 2 * td["a"])
For Jacobians and Hessians of functions that take and return tensordicts,
and for torch.autograd.grad and backward on tensordicts, see the
“Gradients” section of Arithmetic, apply, reductions, and gradients.
To call a module with other parameters, extract them with
from_module() and swap them in with
to_module(). This works in eager mode, compiled,
and under torch.vmap(), which gives model ensembles and per-sample
gradients:
>>> from torch import nn
>>> module = nn.Linear(3, 1)
>>> def call(params, x):
... with params.to_module(module):
... return module(x)
>>> x = torch.randn(10, 3)
>>> ensemble = TensorDict.from_modules(*[nn.Linear(3, 1) for _ in range(4)])
>>> torch.vmap(call, (0, None))(ensemble, x).shape
torch.Size([4, 10, 1])
>>> def loss(params, x):
... return call(params, x).pow(2).sum()
>>> params = TensorDict.from_module(module).detach()
>>> per_sample = torch.vmap(torch.func.grad(loss), (None, 0))(params, x)
>>> per_sample["weight"].shape
torch.Size([10, 1, 3])
torch.func.functional_call() only accepts a dict: pass
params.flatten_keys(".").to_dict() instead of the tensordict. See the
Functional calls and ensembles with TensorDict tutorial for more.
torch.export#
torch.export.export() accepts tensordict and tensorclass inputs and
outputs, in strict and non-strict mode (non-strict is the default). To keep
the batch dimension dynamic, give dynamic_shapes one entry per entry of
the tensordict, in the order of td.keys() (insertion order), with a nested
list for a nested tensordict:
>>> class Model(nn.Module):
... def forward(self, td):
... return TensorDict({"c": td["a"] * 2 + td["n", "b"]}, batch_size=td.batch_size)
>>> def make(batch):
... return TensorDict(
... {
... "a": torch.randn(batch, 3),
... "n": TensorDict({"b": torch.randn(batch, 3)}, batch_size=[batch]),
... },
... batch_size=[batch],
... )
>>> batch = torch.export.Dim("batch")
>>> program = torch.export.export(
... Model(), (make(4),), dynamic_shapes=([{0: batch}, [{0: batch}]],)
... )
>>> program.module()(make(6)).batch_size
torch.Size([6])
Without dynamic_shapes, a call with another batch size fails a guard of
the exported program (AssertionError: Guard failed). Both modes accept
tensordicts with dimension names or non-tensor entries.
A non-tensor entry, such as a string, takes [] in dynamic_shapes
(None or {} raises ValueError: Node type mismatch):
>>> class Double(nn.Module):
... def forward(self, td):
... return td["a"] * 2
>>> def make_named(batch):
... return TensorDict({"a": torch.randn(batch, 3), "name": "run-1"}, batch_size=[batch])
>>> program = torch.export.export(
... Double(), (make_named(4),), dynamic_shapes=([{0: batch}, []],)
... )
>>> program.module()(make_named(6)).shape
torch.Size([6, 3])
A tensorclass with a non-tensor field does not keep a dynamic batch dimension
yet: export raises Constraints violated
(#3163).
To export a TensorDictModule so that it takes and
returns plain tensors, see the Exporting tensordict modules tutorial.
CUDA graphs#
Two tools record CUDA graphs. CudaGraphModule works
with or without torch.compile(), records one graph, and checks the
inputs of each call against it. torch.compile(..., mode="reduce-overhead")
needs compilation and manages its own graphs: it records a new graph for each
new batch size.
CudaGraphModule#
CudaGraphModule captures a function, or a
TensorDictModule, in a
torch.cuda.CUDAGraph and replays it. The function can be compiled.
Give it enough warmup calls to finish compiling, for instance
CudaGraphModule(torch.compile(module), warmup=3). The first
warmup - 1 calls (warmup=2 by default) run the function on a side
stream. The next call runs it once more and captures the graph, and later
calls copy their inputs into the graph’s buffers and replay it. See
CudaGraphModule for the other requirements: no
data-dependent control flow, and inputs that do not require gradients.
>>> from tensordict.nn import CudaGraphModule, TensorDictModule
>>> module = TensorDictModule(lambda x: x + 1, in_keys=["x"], out_keys=["y"])
>>> graph_module = CudaGraphModule(module)
>>> results = [
... graph_module(TensorDict({"x": torch.full((3,), float(i), device="cuda")}, batch_size=[3]))
... for i in range(4)
... ]
>>> [result["y"][0].item() for result in results]
[1.0, 2.0, 3.0, 4.0]
The replay follows these rules:
The inputs must keep the shapes and batch size of the capture. A tensordict input without one of the
in_keysraises aKeyError, and a tensordict input with another batch size or entry shape raises aValueError, as a tensor input of another shape does. Extra keys are ignored. After the capture, a CPU input is copied to the device.The outputs are new tensors: the values written into the input tensordict, and the outputs of a function that returns a new tensordict or tensors, are clones of the graph’s buffers. A result kept from an earlier call keeps its values.
In-place writes to the inputs inside the function (
x.add_(1)) do not reach the caller’s tensors after the capture: the graph writes its own copies.The inputs are copied, and the graph replayed, on the current stream. If the inputs are written on another stream, order that work before the call, for instance with
torch.cuda.current_stream().wait_stream(stream). Issue consecutive calls from one stream, or order them in the same way.Arguments that are not tensors (a
float, astr) must not change after the capture. If the function takes a tensordict, the replay uses the captured values, ignores the new ones and emits aFutureWarning. Starting with TensorDict 0.17, the default will change to raising aValueError(pytorch/tensordict#2075). Every tensor must be an entry of the tensordict, not a separate argument. If the function takes tensors, a changed value raises aValueError.
mode="reduce-overhead"#
torch.compile(module, mode="reduce-overhead") records CUDA graphs with
the cudagraph trees of Inductor. It works with tensordict inputs and outputs,
and the PyTorch rules apply:
The output tensors of a call are overwritten by the next call. Reading them afterwards raises
RuntimeError: Error: accessing tensor output of CUDAGraphs that has been overwritten by a subsequent run. Clone the outputs that you keep (out = compiled(td).clone()).A function that writes in place into an input tensor (
td["x"].add_(1)) runs without CUDA graphs (skipping cudagraphs due to mutated inputswithTORCH_LOGS="cudagraphs").For inference, call the function under
torch.no_grad. With gradients enabled and no backward pass, the calls run without recording a CUDA graph (Running eager functionwithTORCH_LOGS="cudagraphs").
For example:
>>> policy = torch.compile(
... TensorDictModule(nn.Linear(3, 4).cuda(), in_keys=["obs"], out_keys=["action"]),
... mode="reduce-overhead",
... )
>>> with torch.no_grad():
... outputs = [
... policy(TensorDict({"obs": torch.randn(8, 3, device="cuda")}, batch_size=[8])).clone()
... for _ in range(4)
... ]
>>> outputs[0]["action"].shape
torch.Size([8, 4])
See also#
Arithmetic, apply, reductions, and gradients: arithmetic,
apply, reductions, and gradients with tensordicts.Modules: TensorDictModule and tensordict.nn:
TensorDictModuleand the other modules oftensordict.nn.Devices, dtypes, and memory:
to(), pinned memory, and shared memory.FX tracing (prototype): FX tracing of tensordict modules, a prototype.