How TorchLens works¶
This page explains what happens inside TorchLens between tl.trace(model, x) and the Trace you
read afterwards, and what the later operations (lookups, gradients, edits, drawing, saving) do with
it. It is written for someone who has run TorchLens at least once and wants a working model of the
internals, either to predict its behaviour or to start reading the code. Terms are used with the
meanings fixed in the glossary. Contributors who need the module-by-module
map, package boundaries and the rules for changing them should continue with the
maintainer architecture page.
flowchart LR
A["tl.trace(model, x)"] --> B["Prepare: resolve the request,<br/>wrap torch, prepare the model"]
B --> C["Run: forward pass with<br/>logging on; wrappers emit<br/>capture events"]
C --> D["Finalize: pick the exit arm,<br/>settle outputs"]
D --> E["Postprocess pipeline:<br/>events become Ops, Ops group<br/>into Layers, labels, Modules"]
E --> F["Trace"]
F --> G["Lookups and collections"]
F --> H["Gradients and backward graph"]
F --> I["Edits: do, run, Bundles"]
F --> J["draw"]
F --> K["save / tl.load"]
Wrapping torch, lazily¶
TorchLens sees a model by watching the functions it calls, not by reading its source. Every public
torch function (the torch.* namespace, torch.nn.functional, tensor methods and operators) is
replaced with a thin wrapper around the original. Nothing is wrapped when you import torchlens:
most of the package loads only when you first use one of its public names, and torch itself is
first wrapped at the start of the first capture. From then on the wrappers stay installed. Each one checks a single module-level
flag; when no capture is running, the check fails and the wrapper calls the original function
straight away, so ordinary torch code pays one boolean test per call.
Installation happens once per process. The first capture decorates every function TorchLens knows about, registers the wrappers with the TorchScript builtin table so scripted code still resolves, and sweeps for references to torch functions that were bound by name before the wrap. Later captures only revalidate: they re-install the cached wrappers if something removed them and decorate any custom operators registered since.
Watching function calls has one blind spot: code that reaches torch through a path the wrappers do
not cover. Two opt-in diagnostics check for that. The escape detector reports calls that bypassed a
wrapper, and the completeness witness counts low-level dispatcher events and checks that each one is
accounted for by a recorded operation. Their findings are reported in trace.health.
The model is handled differently from torch. The first time TorchLens sees a model instance it
records static metadata for each submodule (its address, such as encoder.layer.0.attention, and
its class) in a registry keyed by module identity, so nothing is stored on the module objects. The
forward wrappers on submodules, by contrast, exist only for the length of one capture: they are
installed when the capture starts and removed when it ends, so between captures the model carries
no TorchLens code and copies, pickles and saves as if TorchLens had never touched it. During the
forward pass those wrappers push and pop a module stack, so every operation knows which module
calls it is nested inside.
What a capture records¶
When the flag is on, a wrapper does a fixed sequence of work around the real call. It snapshots the
arguments (recording which of them are tensors TorchLens has already seen), the random-number and
autocast state, and the time; it calls the original function; and then it decides which outputs to
log. An output is logged when it is a tensor (subclasses count, nn.Parameter does not) and either
it has no TorchLens label yet or the call is a bottom-level one, meaning it is not running inside
another wrapped call. That rule keeps composite functions from being recorded twice: when
torch.nn.functional.linear calls lower-level operations internally, only the outermost visible
call becomes an operation.
Each logged output tensor receives a raw label, stored in a small private metadata slot on the
tensor itself. Raw labels look like cos_3_raw and are only meaningful during the capture. When a
later operation receives that tensor as an argument, the wrapper reads the label back, which is how
TorchLens knows the dataflow edge between the two operations without ever inspecting Python
variables. In-place operations are handled explicitly so that the edited tensor's history stays
correct.
What leaves the wrapper is a capture event: the function, its parent labels, the argument
templates, the module stack, shapes and dtypes, timing, and the output tensor if the request asked
for it to be kept. Whether a payload is kept is decided during the forward pass by the save=
request; everything else is recorded for every operation.
One forward pass, end to end¶
tl.trace resolves its keywords and hands the request to a dispatcher, which picks the backend
(torch unless you pass backend=) and calls that backend's capture entry point. For torch, a
capture runs in four stages.
- Prepare turns the call into a resolved request: the
capture=,save=,intervene=,stop_after=andstorage=options are checked and normalized, and thesave=request is resolved into the check each operation will meet during the forward pass. - Wrap makes sure torch and the model are instrumented, as described above.
- Run seeds and snapshots random state, labels the inputs, and calls the model's forward with
logging switched on. The forward ends in one of four ways: it completes; it is halted on purpose
(
stop_after=reached its target); it fails because the model raised; or it is interrupted. - Finalize handles each of those exits. A completed forward records the model's outputs and
runs the postprocess pipeline. A failed forward raises
TraceCaptureError, which carries the recoverable prefix asexc.partial_trace; a partial trace is never passed off as complete.
The postprocess pipeline is where the raw events become the objects you query. It is an ordered list of named steps, each of which declares the fields it reads and writes, and the order matters: later steps rely on facts that earlier steps established.
flowchart TB
E["Capture events"] --> M["Materialize Op rows"]
M --> G["Graph steps: add output nodes,<br/>find output ancestors, remove orphans,<br/>mark depths"]
G --> CF["Control flow: conditional branches,<br/>buffer fixes"]
CF --> L["Loop grouping: Ops become<br/>Layers with passes"]
L --> LB["Labels: final labels, rename references,<br/>lookup keys, source variable names"]
LB --> FN["Finish: settle saved payloads,<br/>build Layer and Module records,<br/>hash graph shape, freeze storage"]
FN --> T["Trace (tracing finished)"]
The first step materializes the events into operation rows. The graph steps add explicit output nodes, find everything that feeds the outputs, drop orphans, and compute depths. The control-flow steps mark conditional branches and tidy buffer bookkeeping. The grouping step decides which operations are passes of the same layer. The labeling steps assign final labels, rewrite every reference from raw to final labels, build the lookup keys, and attach source variable names. The finishing steps settle deferred saves, build the Layer and Module records, hash the graph shape, mark tracing as finished, and release references that are no longer needed.
The operations themselves live in one columnar store owned by the trace. While the pipeline runs,
rows are mutable lists; when it finishes they freeze into per-field columns. An Op object is a
small facade holding the trace and a row index, so a trace with a million operations does not hold
a million full Python objects. The trace keeps a flag that says whether tracing is finished, and a
few behaviours (lookups among them) differ before and after it flips.
Ops, Layers, Modules and passes¶
The three main record types answer different questions.
- An Op is one executed call: one wrapped function, one set of inputs, one output slot.
- A Layer groups the Ops that are the same piece of code run more than once. Each of those Ops is one pass of the Layer.
- A Module is one
nn.Moduleinstance; each time it is called you get a module call, addressed asblock:1,block:2.
Grouping is structural. Two Ops belong to the same Layer when they share four things: the innermost
module instance that ran them, the call site (the Python frame chain inside that module's
forward), the operation name, and the output slot. A for loop that applies the same code three
times therefore yields one Layer with three passes, while the same three calls written out by hand
yield three Layers, because their call sites differ. The grouping guide
covers the rule and its edge cases.
Final labels follow from this. A Layer label has the form {layer_type}_{type_num}_{total_num},
for example relu_1_2: the first relu Layer, and the second Layer in capture order counting
only operations (inputs, outputs and buffers are not counted). Inputs, outputs
and buffers use the shorter {type}_{num}. When a Layer has more than one pass, its Ops carry a
pass suffix, relu_1_2:3; passes are numbered from 1 in capture order. A single-pass Layer's Op has
no suffix, so the common case reads cleanly.
import torch
import torchlens as tl
from torch import nn
class Looped(nn.Module):
def forward(self, x):
for _ in range(3):
x = torch.tanh(x) * 0.5
return x
trace = tl.trace(Looped(), torch.ones(2, 4))
tanh_layers = [layer for layer in trace.layers if layer.func_name == "tanh"]
assert len(tanh_layers) == 1
assert tanh_layers[0].num_passes == 3
torch.manual_seed(0)
model = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 2)).eval()
x = torch.randn(3, 4)
saved = tl.trace(model, x)
edited = saved.fork("edited")
edited.do(tl.module("1"), tl.zero_ablate())
head_out = edited.find_sites(tl.module("2")).first().out
head_layer = model[2]
assert torch.equal(head_out, head_layer(torch.zeros(3, 8))) # the head saw a zeroed ReLU output
Lookups and the typed collections¶
There are two ways in. The typed collections each return one record type: trace.layers,
trace.ops, trace.modules, trace.module_calls, trace.params and trace.buffers. Each accepts
an index, a label or a slice, has filters such as trace.layers.compute, trace.layers.recurrent
or trace.params.trainable, and serves .labels, the tuple of its members' lookup labels. Use
these when you know what kind of thing you want.
The general door is trace[...]. It returns a Layer, an Op, a Selection, a Module, a module
call, a Param, a Buffer or a backward-graph node: the kind the key names. Integers and slices index the ordered
list of Ops. A string is tried in a fixed order: a module address is first rewritten to its
canonical form; then the string is looked up as a Layer label, which gives that Layer (single- or
multi-pass); then among the other lookup keys the labeling step built for every Op; then in each
typed collection in turn; then case-insensitively; and finally as a substring that must match
exactly one Op. If nothing matches it raises KeyError with a help message. Pass tokens work
wherever an Op label does: relu_1_2:2 names one pass, relu_1_2:last the last, and
relu_1_2:all returns a Selection of every pass. A bare label of a multi-pass Layer gives the
aggregate Layer; a door that must give one Op refuses it instead of guessing a pass:
trace[tl.label("relu_1_2")] with the code multipass_bare_label_ambiguous and
trace.ops["relu_1_2"] with ambiguous_op_lookup.
The third form is a selector. tl.module(...), tl.func(...), tl.label(...), tl.in_module(...),
tl.where(...) and tl.grad(...) build a tl.Selector, a query that does not depend on any
particular trace. Selectors combine with |, &, - and ~. Indexing a trace with one,
trace[selector], resolves it against that trace and returns a tl.Selection, an ordered tuple of
tl.types.Site rows (a key, a mask and the provenance of the match). A Selection is tied to the
live session; save the selector, not the Selection. The same selectors drive save=, intervene=
and trace.do, so one spelling names a site everywhere.
Saved activations, gradients and the backward graph¶
Activations are saved during the forward pass. When save= is a predicate or a label TorchLens can
recognise while the model runs, each matching Op's output is kept as it is produced. A few
selectors need the finalized labels to resolve; for those TorchLens records the forward, resolves
the selector during postprocess, and settles the payloads before tracing finishes. Either way, a
saved Op exposes its tensor as op.out and says so with op.has_saved_activation. Saved
activations stay connected to the autograd graph by default in tl.trace
(detach_saved_activations=False), so you can backpropagate through them. The glossary entry for
saved-activation autograd history lists which doors detach.
Gradients use the same machinery in the other direction. CaptureOptions(save_grads=...) names
the Ops whose gradients you want, and the capture installs tensor-level backward hooks on exactly
those outputs. You then run the backward pass through the trace, with trace.backward(loss) or
inside with trace.capture_backward(): loss.backward(), and each hooked Op fills in op.grad.
During the same backward pass TorchLens walks the autograd graph from the loss and records it: one
record per grad_fn node, one per call of that node, and one per backward pass. They are read
through trace.autograd (trace.autograd.grad_fns, trace.autograd.backward_passes), and the
backward graph can be drawn on its own or overlaid on the forward graph with
trace.draw(graph="backward") or trace.draw(graph="combined"). The live autograd graph exists
only in the session; a saved artifact keeps the records, not the graph.
Interventions¶
An intervention is an edit to an intermediate value. The edit is a rule: a selector naming where,
and an edit saying what, such as tl.zero_ablate(), tl.scale(0.5) or your own function.
tl.when(selector, edit) builds a tl.Recipe holding such rules (tl.intervention.Rule). There
are three places to apply one.
- During capture.
tl.trace(model, x, intervene=recipe)applies the edit as the forward runs; the trace records the edited run. - After capture, with
trace.do.dois the one editing verb. It stages the edit and then propagates it with one of three engines. Withoutmodel=andinputs=, the replay engine re-executes the saved graph: it computes the cone of Ops downstream of the edited sites, orders that cone by dependency, and calls each Op's recorded function again on its recorded arguments, substituting edited parents, pass by pass. The model'sforwardis never called. With bothmodel=andinputs=, the rerun engine runs the whole model again with the edit armed as a hook, which is what edits on gradients need.InterventionOptions(engine="set_only")stages the edit without propagating it.doedits the trace in place and returns it; calltrace.fork()first (a copy-on-write copy) to keep the original.InterventionOptions(differentiable=True)replays onto a new fork whose outputs stay differentiable. - Without a trace.
recipe.bind(model)returns a bound model that applies the rules as hooks on every call, with no capture at all.
trace.run(...) re-executes the model on the same or new inputs with the trace's current recipe
armed. It returns the refreshed trace, or a lighter result object on the explicit fast path
(fast=True). The trace keeps its edit history: trace.recipe holds the
active rules, trace.clear_edits(...) removes them, and trace.state_history records what
happened.
A tl.Bundle is a named collection of aligned traces, for example a clean run and several
edited forks of it. tl.sweep builds one by applying an edit across a range of values.
Bundles compare their members (bundle.compare(...)), draw them, apply an edit to
every member with bundle.do(...), and index into all members at once with bundle.at.
Drawing¶
trace.draw() turns a trace into a graph picture and returns a tl.viz.Drawing. The path is in
two parts. First the trace is projected into a render description that is independent of any
drawing library: nodes, edges, clusters for modules, and the options you passed (graph= for
forward, backward or combined; rolled=True to draw each multi-pass Layer once with a pass count
instead of unrolling every pass; collapse= to fold modules; layout= to choose the layout
engine). Then a renderer turns
that description into output. Renderers are looked up in a registry; Graphviz is the default. Edited
sites are marked on the drawing, and trace.animate(site) steps through passes.
Saving, loading and validation¶
trace.save(path) writes a .tlspec directory: a manifest.json describing the contents, a
pickled metadata file, and safetensors files holding the saved tensors. The write goes to a temporary
directory first and is renamed into place when complete, so an interrupted save never leaves a
half-written artifact under the final name, and leftover temporary directories are cleaned up
later. level= controls what is kept. "portable" (the default) keeps the records with the saved
activations and gradients. "audit" keeps the records without tensor payloads. "runnable" is
also payload-free but keeps what is needed to execute the saved graph again, and requires a capture
made with capture=CaptureOptions(replayable=True). "executable_with_callables" keeps the
payloads plus the saved arguments and random-number states needed to re-execute operations.
tl.load(path) reads the manifest first and checks it before it trusts the metadata file, and it
unpickles that file with an allowlist, so loading an artifact does not execute arbitrary code.
With lazy=True tensors are read from disk on first access. Depending on what was saved, tl.load
returns a Trace, a tl.Bundle or a tl.Recipe. Some facts exist only in a live session: a
loaded trace has no live model return value, and trace.output refuses with output_not_stored
rather than returning None.
Validation is how TorchLens checks itself. tl.validate(model, x) captures the model and then
checks the trace in three ways: the trace's outputs must match a fresh forward pass; every saved
operation, re-executed on its saved parents, must reproduce its saved output; and re-executing an
operation from a perturbed parent must change its output, which shows the previous check is not
passing trivially. trace.check_metadata_invariants() checks the internal
consistency of a trace's records. These checks are tripwires: a validation failure means TorchLens
recorded something wrong, and the fix belongs in the capture code, never in the check.
Backends¶
Torch is the reference backend and the only stable one. The capture machinery talks to a backend
through a protocol (tl.backends.BackendProtocol), and backends are registered with a spec saying
whether they are stable or preview and whether they execute eagerly or lazily. TensorFlow, JAX and
Paddle are eager preview backends; MLX and tinygrad are lazy preview backends, captured by
realizing the computation once. You select one with tl.trace(..., backend=...). The preview
backends build their traces through a shared finalizer that reuses the torch pipeline's loop
grouping and module-record steps, so they produce the same Trace, Layer and Op types and the
same label and lookup rules. What each backend supports is listed in its scorecard. See
Backends.
The sparse path¶
tl.record(model, x, save=...) is a lighter capture for when you only need a few payloads. It
records a sparse event stream as a Recording instead of building the full trace during the
capture. Recording.to_trace() runs the same postprocess pipeline to produce a full Trace when
you need the graph. Reading a payload that save= did not select fails explicitly.
Where to read the code¶
All paths are inside the torchlens/ package.
| Part | Where |
|---|---|
| Public names and lazy imports | __init__.py; _user_funcs.py (trace, draw, summary) |
| Torch wrapping, module stack, model prep | backends/torch/ (wrappers.py, model_prep.py, module_stack.py, _tl.py for tensor labels) |
| Capture stages | _capture/ (prepare/, run_entry.py, run.py, finalize/, stages.py) |
| Capture events | _ir/ (capture_events.py, events.py) |
| Postprocess pipeline | _postprocess/ (_pipeline.py lists the steps in order; _materialize.py, graph_traversal.py, control_flow.py, loop_detection.py, labeling.py, finalization.py) |
| Trace, Op, Layer, Module records | _data_classes/ (trace.py, op.py, layer.py, interface.py for trace[...]); _trace_core/op_store.py for the row store |
| Selectors and Selections | _selection/ |
| Gradients and the backward graph | backends/torch/backward.py |
| Interventions | intervention/ (spec.py, _trace_edits.py for do, replay.py and _replay_cone.py for the replay engine, rerun.py, _trace_run.py, helpers.py, sweep.py) |
| Bundles | bundle/ |
| Drawing | _visualization/ (_trace_draw.py, render_ir.py, renderer_registry.py, renderers/) |
| Saving and loading | _io/ (bundle.py, manifest.py, _safe_unpickle.py) |
| Validation | validation/ (consolidated.py for tl.validate, core.py, invariants.py) |
| Backends | backends/registry.py, backends/_protocol.py, one folder per backend under backends/ |
| Sparse recording | recording/, _capture/projections.py |
Further reading¶
- Glossary: the meaning of every public term used here.
- Grouping guide: how Ops become Layers, in detail.
- Intervention API: every editing door and its options.
- Backward: gradients and the backward graph.
- Maintainer architecture: package boundaries and rules for changing them.