How-to recipes#
Short recipes for common training and data tasks. Each one answers a “how do I … with tensordict?” question, and links to the page that explains the details.
The examples run on a CPU, in order, in one Python session. They start with
these imports and a temporary directory, root:
>>> import tempfile
>>> from pathlib import Path
>>> import torch
>>> from torch import nn
>>> from torch.utils.data import DataLoader
>>> from tensordict import TensorDict
>>> tmp = tempfile.TemporaryDirectory()
>>> root = Path(tmp.name)
>>> _ = torch.manual_seed(0)
Batch samples of different lengths#
Sequences, point clouds, and graphs often have a different length for each
sample. pad_sequence() pads the samples to the longest one
and stacks them. With return_mask=True, it adds a "masks" entry that
marks the values that are not padding.
unpad_sequence() gives back the samples:
>>> from tensordict import pad_sequence, unpad_sequence
>>> samples = [
... TensorDict({"tokens": torch.arange(n), "embedding": torch.ones(n, 4)}, batch_size=[n])
... for n in (3, 1, 2)
... ]
>>> padded = pad_sequence(samples, return_mask=True)
>>> padded["tokens"]
tensor([[0, 1, 2],
[0, 0, 0],
[0, 1, 0]])
>>> padded["masks", "tokens"]
tensor([[ True, True, True],
[ True, False, False],
[ True, True, False]])
>>> [s.batch_size for s in unpad_sequence(padded).tensordicts]
[torch.Size([3]), torch.Size([1]), torch.Size([2])]
To skip the padding, stack the samples with lazy_stack(),
and read an entry as a nested tensor with the jagged layout. Its
values() holds the samples one after the other, and its offsets()
gives the index where each sample starts, the layout of the cu_seqlens
argument of variable-length attention kernels:
>>> from tensordict import lazy_stack
>>> ragged = lazy_stack(samples)
>>> nt = ragged.get("tokens", as_nested_tensor=True)
>>> nt.values()
tensor([0, 1, 2, 0, 0, 1])
>>> nt.offsets()
tensor([0, 3, 4, 6])
>>> nt.offsets().diff() # the length of each sample
tensor([3, 1, 2])
Variable-size data compares these options.
Pack variable-length sequences#
Variable-length attention kernels, such as FlashAttention’s
flash_attn_varlen_func and PyTorch’s
torch.nn.attention.varlen.varlen_attn, take a batch without padding: the
tokens of all the sequences one after the other in one dimension, the offsets
where each sequence starts (cu_seqlens, which ends with the total number
of tokens), and the longest length. Point clouds and graphs use the same
packed layout, for example the ptr and batch tensors of PyG.
From a list of samples#
torch.cat() along the first dimension packs samples of batch size
[length]. The offsets are the cumulative sum of the lengths, with a zero
in front:
>>> import torch.nn.functional as F
>>> samples = [
... TensorDict({"input_ids": torch.arange(n), "hidden": torch.ones(n, 4)}, batch_size=[n])
... for n in (3, 5, 2)
... ]
>>> packed = torch.cat(samples, 0)
>>> packed.batch_size
torch.Size([10])
>>> lengths = torch.tensor([s.batch_size[0] for s in samples])
>>> cu_seqlens = F.pad(lengths.cumsum(0, dtype=torch.int32), (1, 0))
>>> cu_seqlens
tensor([ 0, 3, 8, 10], dtype=torch.int32)
>>> max_seqlen = int(lengths.max())
FlashAttention takes int32 offsets, while nested tensors and PyG use
int64: set dtype for the code that reads them. Some models also take
the index of the sequence of each token, and the position of each token in
its sequence:
>>> seq_ids = torch.repeat_interleave(torch.arange(len(lengths)), lengths)
>>> seq_ids
tensor([0, 0, 0, 1, 1, 1, 1, 1, 2, 2])
>>> position_ids = torch.arange(len(packed)) - cu_seqlens[seq_ids]
>>> position_ids
tensor([0, 1, 2, 0, 1, 2, 3, 4, 0, 1])
split() gives back the samples. For a lazy
stack of samples, pack its tensordicts:
>>> [s.batch_size for s in packed.split(lengths.tolist())]
[torch.Size([3]), torch.Size([5]), torch.Size([2])]
>>> (torch.cat(lazy_stack(samples).tensordicts, 0) == packed).all()
True
From nested tensors#
The values() of a nested tensor with the torch.jagged layout is its
packed form, and its offsets() are the cu_seqlens, in int64. On a
tensordict of such nested tensors, for example from
densify(),
apply() with a new batch_size packs every
entry. torch.nested.nested_tensor_from_jagged() goes back:
>>> data = lazy_stack(samples).densify(layout=torch.jagged)
>>> offsets = data["input_ids"].offsets()
>>> offsets
tensor([ 0, 3, 8, 10])
>>> packed = data.apply(lambda x: x.values(), batch_size=[int(offsets[-1])])
>>> packed.batch_size
torch.Size([10])
>>> data = packed.apply(
... lambda x: torch.nested.nested_tensor_from_jagged(x, offsets),
... batch_size=[len(offsets) - 1],
... )
>>> [t.shape for t in data["hidden"].unbind()]
[torch.Size([3, 4]), torch.Size([5, 4]), torch.Size([2, 4])]
Indexing, split, and torch.cat() work along the first dimension of
such a tensordict, so you can select or split the sequences before you pack
them, as Split and balance the sequences below does.
From a padded batch with a mask#
Give the padded batch the shape of the mask as its batch size, and index it with the mask. To go back, write the packed values into a padded tensordict:
>>> padded = pad_sequence(samples, return_mask=True)
>>> mask = padded["masks", "input_ids"]
>>> body = padded.exclude("masks")
>>> body.batch_size = mask.shape
>>> packed = body[mask]
>>> packed.batch_size
torch.Size([10])
>>> restored = torch.zeros_like(body)
>>> restored[mask] = packed
>>> (restored == body).all()
True
Split and balance the sequences#
To keep each micro-batch under a token budget, compute the number of
sequences of each micro-batch in a loop, and pass the list to split. The
loop keeps the order of the sequences, and starts a new micro-batch when the
next sequence would go over the budget. A sequence longer than the budget gets
a micro-batch of its own, which goes over:
>>> def budget_sizes(lengths, budget):
... sizes, tokens = [], 0
... for n in lengths.tolist():
... if not sizes or tokens + n > budget:
... sizes.append(0)
... tokens = 0
... sizes[-1] += 1
... tokens += n
... return sizes
>>> sizes = budget_sizes(lengths, budget=8)
>>> sizes
[2, 1]
>>> [int(mb["input_ids"].offsets()[-1]) for mb in data.split(sizes)]
[8, 2]
The data-parallel ranks wait for the slowest one, so each rank should get about the same total cost. The cost of a sequence can be its length, or a model of its compute time. A short greedy gives each sequence, from the most costly to the least, to the rank with the lowest total so far:
>>> import heapq
>>> def balance(cost, k):
... heap = [(0, rank) for rank in range(k)]
... parts = [[] for _ in range(k)]
... for i in torch.argsort(cost, descending=True, stable=True).tolist():
... total, rank = heapq.heappop(heap)
... parts[rank].append(i)
... heapq.heappush(heap, (total + cost[i].item(), rank))
... return [torch.tensor(sorted(p), dtype=torch.long) for p in parts]
>>> lengths = torch.tensor([7, 1, 5, 3, 3, 2, 8, 4])
>>> batch = lazy_stack(
... [TensorDict({"input_ids": torch.arange(n)}, batch_size=[n]) for n in lengths.tolist()]
... )
>>> parts = balance(lengths, k=3)
>>> [p.tolist() for p in parts], [int(lengths[p].sum()) for p in parts]
([[4, 6], [0, 1, 3], [2, 5, 7]], [11, 11, 11])
>>> local = batch[parts[1]] # the sequences of rank 1
With many sequences on each rank, this greedy is usually within a fraction of a percent of the best balance. For a tighter one, use the Karmarkar-Karp heuristic (the largest differencing method).
Load a memory-mapped dataset with a DataLoader#
Save the dataset once with memmap(). Each
leaf goes to its own file. load_memmap()
opens the files, and a batch reads only its rows from disk. A tensordict
reads a whole batch with one index operation, and
collate() keeps that batch as it is. Unlike a lambda,
collate also works with worker processes that pickle it:
>>> from tensordict import collate
>>> _ = TensorDict(
... {"image": torch.randn(100, 1, 8, 8), "label": torch.arange(100)}, batch_size=[100]
... ).memmap(root / "dataset")
>>> dataset = TensorDict.load_memmap(root / "dataset")
>>> device = "cuda" if torch.cuda.is_available() else "cpu"
>>> loader = DataLoader(
... dataset,
... batch_size=32,
... shuffle=True,
... collate_fn=collate,
... pin_memory=torch.cuda.is_available(),
... )
>>> for batch in loader:
... batch = batch.to(device, non_blocking=True)
>>> batch.batch_size, batch["image"].shape
(torch.Size([4]), torch.Size([4, 1, 8, 8]))
With pin_memory=True, the DataLoader pins each batch, so that the
copy to the GPU with non_blocking=True does not block the CPU.
Load data and share it between processes covers worker processes, batch samplers, and datasets
that return one tensordict per sample.
Split a dataset and loop over shuffled minibatches#
Index the dataset with a random permutation, and split it with
split(). Every entry gets the same split.
For the minibatches, split a new permutation in each epoch:
>>> data = TensorDict(
... {"x": torch.randn(100, 3), "y": torch.randn(100, 1)}, batch_size=[100]
... )
>>> train, val = data[torch.randperm(len(data))].split([80, 20])
>>> train.batch_size, val.batch_size
(torch.Size([80]), torch.Size([20]))
>>> model = nn.Linear(3, 1)
>>> optim = torch.optim.SGD(model.parameters(), lr=0.1)
>>> for epoch in range(3):
... for idx in torch.randperm(len(train)).split(16):
... batch = train[idx]
... loss = nn.functional.mse_loss(model(batch["x"]), batch["y"])
... optim.zero_grad()
... loss.backward()
... optim.step()
... with torch.no_grad():
... val_loss = nn.functional.mse_loss(model(val["x"]), val["y"])
To split into k parts of nearly equal size, use
chunk().
Set optimizer options for groups of parameters#
from_module() collects the parameters of a
module in a tensordict, with one nested tensordict per sub-module. Its
leaves are the parameters of the module, not copies.
split_keys() with a function
fn(key, value) splits the leaves into groups, for example to apply weight
decay to the weight matrices only, and not to the biases and the norm
layers:
>>> model = nn.Sequential(nn.Linear(3, 8), nn.LayerNorm(8), nn.Linear(8, 2))
>>> params = TensorDict.from_module(model)
>>> params["0", "weight"] is model[0].weight
True
>>> matrices, others = params.split_keys(lambda key, value: value.ndim >= 2)
>>> sorted(matrices.keys(True, True))
[('0', 'weight'), ('2', 'weight')]
>>> optim = torch.optim.AdamW(
... [
... {"params": list(matrices.values(True, True)), "weight_decay": 0.01},
... {"params": list(others.values(True, True)), "weight_decay": 0.0},
... ],
... lr=1e-3,
... )
keys(True, True) and values(True, True) list the leaves, nested
ones included. The function gets the key as a tuple of strings, so it can
also test a name, for example key[-1] == "bias". A list of keys selects
whole sub-modules: params.split_keys(["0"]) puts the first layer in its
own group.
Keep a moving average of the parameters#
An exponential moving average (EMA) keeps a smoothed copy of the parameters.
Make the copy with detach().clone(). After each optimizer step, move it
towards the parameters with lerp_(), under
torch.no_grad(), as for any update that the optimizer does not make.
To evaluate the model with the average,
to_module() as a context manager puts it in
the model for some lines only:
>>> ema = params.detach().clone()
>>> decay = 0.99
>>> x = torch.randn(16, 3)
>>> for step in range(10):
... loss = model(x).pow(2).mean()
... optim.zero_grad()
... loss.backward()
... optim.step()
... with torch.no_grad():
... _ = ema.lerp_(params, 1 - decay)
>>> with ema.to_module(model):
... y_ema = model(x)
>>> model[0].weight is params["0", "weight"] # the model has its own parameters again
True
ema is a tensordict, so ema.to(device) and ema.save(path) work
as for any other one. Parameters as tensordicts explains to_module.
Save and resume training#
Put the parameters, the moving average, and the step counter in one
tensordict, and save it with save().
A tensordict cannot hold the state dict of an optimizer: its keys are
integers, and its param_groups hold lists of Python values. Save it with
torch.save():
>>> state = TensorDict({"model": params, "ema": ema, "step": step})
>>> _ = state.save(root / "checkpoint")
>>> torch.save(optim.state_dict(), root / "optim.pt")
To resume, load the files. to_module(..., inplace=True) copies the values
into the parameters of the model, so the optimizer keeps the same
parameters. clone() copies the moving
average from the files into memory:
>>> state = TensorDict.load_memmap(root / "checkpoint")
>>> _ = state["model"].to_module(model, inplace=True)
>>> ema = state["ema"].clone()
>>> optim.load_state_dict(torch.load(root / "optim.pt", weights_only=True))
>>> start = int(state["step"]) + 1
>>> start
10
Save and load tensordicts explains the files, and how to load a part of a checkpoint.
Train an ensemble with torch.vmap#
from_modules() stacks the parameters of several
models with the same structure, along a new first dimension. With
as_module=True, the result is a
TensorDictParams, a module whose parameters an
optimizer can take. torch.vmap() then calls one model with each set of
parameters, and one backward pass gives the gradients of all the models:
>>> members = [nn.Linear(3, 1) for _ in range(5)]
>>> ensemble = TensorDict.from_modules(*members, as_module=True)
>>> ensemble["weight"].shape
torch.Size([5, 1, 3])
>>> base = nn.Linear(3, 1).to("meta") # holds the structure, not the values
>>> def call(params, x):
... with params.to_module(base):
... return base(x)
>>> optim = torch.optim.Adam(ensemble.parameters(), lr=1e-2)
>>> x, y = torch.randn(32, 3), torch.randn(32, 1)
>>> for step in range(10):
... pred = torch.vmap(call, (0, None))(ensemble, x) # one prediction per member
... loss = (pred - y).pow(2).mean()
... optim.zero_grad()
... loss.backward()
... optim.step()
>>> pred.shape
torch.Size([5, 32, 1])
ensemble[2] holds the parameters of the third model. To copy them into
its own module, use ensemble.data[2].to_module(members[2], inplace=True).
Modules: TensorDictModule and tensordict.nn shows the same pattern with
EnsembleModule, and torch.compile, torch.func, torch.export, and CUDA graphs with
torch.compile().
Process a dataset in parallel#
map() splits a tensordict into chunks,
calls a function on each chunk in a pool of worker processes, and writes the
results into out. On a memory-mapped dataset, the workers read the
chunks from the files, and write the results into the files of out,
without a copy. The function must return a new tensordict, because the
chunks are locked.
The example starts processes, so it is a complete script. Save it to a file
and run it with python. It normalizes each image of a dataset on disk:
import tempfile
import torch
from tensordict import TensorDict
def normalize(chunk):
image = chunk["image"].float()
mean = image.mean(dim=(-2, -1), keepdim=True)
std = image.std(dim=(-2, -1), keepdim=True)
return TensorDict({"image": (image - mean) / std}, batch_size=chunk.batch_size)
if __name__ == "__main__":
with tempfile.TemporaryDirectory() as path:
images = torch.randint(256, (1_000, 1, 8, 8), dtype=torch.uint8)
dataset = TensorDict({"image": images}, batch_size=[1_000]).memmap(f"{path}/raw")
out = TensorDict.from_schema(
{"image": ([1, 8, 8], torch.float32)},
batch_size=[1_000],
storage="memmap",
prefix=f"{path}/normalized",
)
dataset.map(
normalize,
num_workers=2,
chunksize=100,
out=out,
generator=torch.default_generator,
)
print(out["image"][0].mean().abs() < 1e-5) # tensor(True)
Load data and share it between processes lists the options of map, and shows how processes
share one tensordict in memory.
Cast the floating-point leaves only#
td.to(torch.bfloat16) casts every leaf, the integer and boolean ones
too. Pass cast_filter=is_floating to cast only the floating-point leaves,
as torch.nn.Module.to() does. Labels and masks then keep their dtype:
>>> from tensordict import is_floating
>>> batch = TensorDict(
... {
... "image": torch.randn(4, 1, 8, 8),
... "label": torch.arange(4),
... "mask": torch.ones(4, 8, 8, dtype=torch.bool),
... },
... batch_size=[4],
... )
>>> bf16 = batch.to(torch.bfloat16, cast_filter=is_floating)
>>> bf16["image"].dtype, bf16["label"].dtype, bf16["mask"].dtype
(torch.bfloat16, torch.int64, torch.bool)
>>> batch.half(cast_filter=is_floating)["label"].dtype
torch.int64
is_inexact() also casts complex leaves. A tensorclass can
set a default cast_filter for all its casts, with
@tensorclass(cast_filter=is_floating) (see tensorclass()).
>>> tmp.cleanup()