Capture speed, by kind of use¶
What each way of using TorchLens costs against a plain forward pass of the same model, measured
on 2026-11-03. TorchLens records every operation the model runs, so most kinds of capture cost
far more than a forward hook; this page says how much, for each kind, including where TorchLens
is slow. Every number is a median wall time from the harness in benchmarks/capture/, and every
one comes from a result file in data/ (one file per run; see
Hosts, versions and method).
The machines were shared and loaded, so absolute times are slower than on an idle machine. Compare rows within a workload, not across workloads or hosts.
Summary¶
Each cell gives the ratio to a plain forward of the same workload on the same host, then the absolute median in brackets. For the LSTM tagger and ResNet-18 the reference is the harness's plain-forward arm (nothing attached). The GPT-2 runs had no plain-forward arm, so their reference is the forward-hook arm of the same run, which on the other models timed within a quarter of a plain forward. "Not measured" means no run covered that cell; Not measured yet lists them.
| Kind of use | LSTM tagger, CPU | ResNet-18, CPU | ResNet-18, GPU | GPT-2 small, CPU (against forward hooks) | GPT-2 small, GPU (against forward hooks) |
|---|---|---|---|---|---|
| A forward hook on one module, no TorchLens (for reference) | 1.23x (1.75 ms) | 1.00x (13.77 ms) | 1.01x (2.32 ms) | 1.00x (the reference) | 1.00x (the reference) |
| A plain forward after a trace: TorchLens's torch wrappers stay installed, nothing is recorded | 1.02x (1.50 ms) | 1.47x (22.11 ms) | 1.04x (2.39 ms) | not measured | not measured |
A live edit bound to the model, tl.when(...).bind(model), one edit, nothing recorded |
1.81x (2.78 ms) | 1.39x (26.78 ms) | 1.57x (3.70 ms) | not measured | not measured |
| The same bound live edit over a sweep of edits | 2.1x (25.60 ms) | 1.30x (195.36 ms) | 2.2x (33.63 ms) | 1.02x (27.15 s) | not measured |
The fast recorder, tl.record(model, x, save=...): selected sites, no graph |
not measured | 11x (150.06 ms) | 43x (98.55 ms) | 1.34x (39.17 s) | 50x (8.35 s) |
A trace saving selected sites, tl.trace(model, x, save=...): the full graph, few tensors kept |
55x (77.96 ms) | 37x (505.21 ms) | 169x (389.71 ms) | 3.4x (99.88 s) | 296x (49.67 s) |
A trace saving every activation, tl.trace(model, x) |
55x (81.38 ms) | 47x (710.60 ms) | 186x (427.04 ms) | not measured | not measured |
One module's output by name, tl.extract |
58x (82.93 ms) | 41x (562.88 ms) | 164x (376.36 ms) | not measured | not measured |
A sweep of edits, one recording per edit, tl.record(..., intervene=...) |
not measured | 12x (1.79 s) | 70x (1.06 s) | 1.20x (32.32 s) | 53x (6.30 s) |
A sweep of edits, one trace per edit, tl.trace(..., intervene=...) |
52x (640.23 ms) | 35x (5.26 s) | 238x (3.61 s) | 1.85x (49.97 s) | 199x (23.84 s) |
Features over a stimulus set, tl.extract_dataset |
37x (356.00 ms) | 21x (2.69 s) | 241x (1.99 s), second GPU machine | not measured | not measured |
A gradient at one module: tl.trace with gradients saved, then backward |
34x (117.50 ms) | 12x (980.27 ms) | 62x (551.37 ms) | not measured | not measured |
A forward replay check, tl.validate(model, x, scope="forward") |
not measured | 361x (5.42 s) | 1107x (2.55 s) | not measured | not measured |
Stopping the forward early, stop_after= |
not measured | not measured | not measured | not measured | not measured |
A fast rerun of a trace on new inputs, trace.run_transaction(inputs=..., fast=True) (measured on 2026-11-05 on other workloads; see Reruns of a finished trace) |
not measured | not measured | not measured | not measured | not measured |
| A trace of metadata only, no activations saved | not measured | not measured | not measured | not measured | not measured |
| Recording a generation (measured on Pythia-70M only; see its section) | not measured | not measured | not measured | not measured | not measured |
The workloads behind the cells. LSTM tagger and ResNet-18: one input; a sweep is eight steering
strengths; tl.extract_dataset reads one module over 16 stimuli in batches of 4; the gradient is
taken at one module output. GPT-2 small: the recorder and trace rows cache every block's output
for 256 sequences of 32 tokens in batches of 16 (16 forwards); a sweep is 12 steered forwards (3
layers by 4 strengths) on a batch of 16 sequences of 32 tokens. The ResNet-18 GPU column ran on a
workstation with an RTX 2080 Ti; its tl.extract_dataset cell ran on a second RTX 2080 Ti machine
after a fix (see tl.extract_dataset).
Two patterns run through the table:
- TorchLens adds a roughly fixed amount of time per recorded operation, so the ratio depends on how fast the plain forward is. A full trace of ResNet-18 adds about 696 ms on the CPU worker (710.60 ms against 15.01 ms) and about 425 ms on the GPU workstation (427.04 ms against 2.30 ms); the GPU forward is about 6.5 times faster, so the GPU ratio is about four times larger. GPT-2 small on one loaded CPU thread takes about 1.8 s per forward, so the trace's added cost is a small multiple there and about 300 times the forward on a GPU.
- Editing without recording is cheap; recording is not. The bound live edit stays within about two times a plain forward on every model measured. Every kind that records lands between 11 and 241 times a plain forward on the LSTM tagger and ResNet-18, and the replay check costs more.
A plain forward after a trace¶
What it measures: a forward pass with nothing recorded, run after a trace in the same process.
import torchlens leaves torch untouched; the first capture installs TorchLens's torch wrappers,
and they stay installed until torchlens.backends.torch.wrappers.unwrap_torch() (or a capture
with CaptureOptions(unwrap_when_done=True)) removes them. Every forward
your process runs afterwards, including code that never calls TorchLens, goes through them. This
row says what that costs.
Numbers: 1.02x on the LSTM tagger on CPU (1.50 ms against 1.47 ms), 1.04x on ResNet-18 on GPU (2.39 ms against 2.30 ms) and 1.03x on the LSTM tagger on GPU (0.36 ms against 0.35 ms). On ResNet-18 on the loaded CPU worker the median was 1.47x (22.11 ms against 15.01 ms), but the lowest process medians were 15.41 ms against 14.32 ms, and two identical plain-forward arms on that worker differed by 54%, so this cell is mostly the worker's noise.
Known costs: the one-time cost of the first capture in a process (installing the wrappers) is not in any row here; every arm ran 3 warmup calls first.
Live edits at hook speed¶
What it does: tl.when(site, edit).bind(model) applies an edit inside the model's own forward and
records nothing. Each timed call builds the bound edit and runs it, the same way the hook arm
registers its hook, runs, and removes it.
When to use it: steering or patching over many forwards, or through generation, when you do not need a record of each forward.
Numbers: within about two times a plain forward on every model measured (see the summary). The first run's rows (LSTM tagger on CPU, and GPT-2 small on CPU):
| Workload (model, device, repeats) | Tool | Median |
|---|---|---|
| GPT-2 small, CPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | forward hooks | 26.70 s |
| GPT-2 small, CPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | tl.when(...).bind(model) |
27.15 s |
| LSTM tagger, CPU, one module replaced by zeros, n=3 processes of 20 samples | plain forward, nothing attached | 1.54 ms |
| LSTM tagger, CPU, one module replaced by zeros, n=3 processes of 20 samples | forward hook | 1.70 ms |
| LSTM tagger, CPU, one module replaced by zeros, n=3 processes of 20 samples | tl.when(...).bind(model) |
2.78 ms |
| LSTM tagger, CPU, one module replaced by zeros, n=3 processes of 20 samples | nnsight 0.8.1 | 2.06 ms |
| LSTM tagger, CPU, plain forward after a trace with TorchLens's torch wrappers still installed, n=2 processes of 20 samples | plain forward after a trace, nothing recorded | 1.50 ms |
| LSTM tagger, CPU, plain forward after a trace with TorchLens's torch wrappers still installed, n=2 processes of 20 samples | plain forward | 1.47 ms |
On GPT-2 small the bound edit and the forward hook land 0.46 s apart on a 27 s sweep, inside the spread of three repeats (hooks 25.36 to 28.00 s, bound edit 26.80 to 27.74 s), and the steered logits are identical (largest absolute difference 0.0). On the LSTM tagger, whose plain forward takes about 1.5 ms, the bound edit's fixed setup cost per call is visible. A plain forward after a trace, with TorchLens's wrappers still installed and nothing recorded, runs at plain-forward speed.
Known costs: a bound edit records nothing, so it cannot give you activations or gradients; pair it with a hook or a capture for those.
The fast recorder, tl.record¶
What it does: tl.record(model, x, save=...) runs the forward through TorchLens's wrapped
operations and keeps events and payloads for the selected sites, with no graph postprocessing. It
returns a Recording; call Recording.to_trace() later if you need the graph.
When to use it: pulling activations from many forwards in a loop, when you know the sites.
Numbers: 11x a plain forward on ResNet-18 on CPU (150.06 ms), 43x on GPU (98.55 ms), 50x on the LSTM tagger on GPU (18.16 ms against 0.36 ms), and 50x on GPT-2 small on GPU (8.35 s against 168 ms for forward hooks). It costs a fraction of a trace of the same sites: 3.4 times less on ResNet-18 on CPU (150.06 ms against 505.21 ms), 4.0 times less on GPU (98.55 ms against 389.71 ms) and about 6 times less on GPT-2 small on GPU (8.35 s against 49.67 s).
Known costs: it still runs every operation through the wrappers, so it stays well above a forward
hook. Backward capture needs a trace; these runs did not time tl.record with gradients.
Traces: selected sites or every activation¶
What it does: tl.trace(model, x) records every operation's metadata and graph edges, checks
them, and saves every activation. tl.trace(model, x, save=...) records the same graph and keeps
only the selected payloads. tl.extract(model, x, {...}) reads named module outputs from one such
call.
When to use it: you need operations no module returns, you do not yet know the site, you want the graph, or you want the recording checked against the forward.
Numbers: 37x to 58x a plain forward on the LSTM tagger and ResNet-18 on CPU, 164x to 186x on ResNet-18 on GPU, and 296x on GPT-2 small on GPU; on GPT-2 small on one loaded CPU thread, where each forward takes about 1.8 s, 3.4x (see the summary).
Saving only selected sites keeps fewer tensors but takes about the same time, because the structure of every operation is still recorded: on ResNet-18 on GPU a full trace took 427.04 ms and a trace keeping one module 381.29 ms; on the LSTM tagger on CPU 81.38 ms and 83.49 ms.
Known costs: the trace's fixed cost dominates on small models (the LSTM tagger's 1.5 ms forward becomes about 80 ms).
Reruns of a finished trace¶
Measured on 2026-11-05, on a shared 32-core Intel Xeon 2.80 GHz Linux CPU worker, one torch thread per process, load average 14 to 19. Python 3.10, torch 2.13.0 (CPU build), transformers 5.18.0,
and a development build of TorchLens (2.38.1) from that day. Raw data:
data/capture_2026-11-05-rerun.json. Each LSTM tagger and
ResNet-18 row is three processes (GPT-2 two); each process times up to 15 samples after 3 warmup
calls (GPT-2: up to 5 after 1) and reports its median, and the table gives the median of the
process medians with the lowest and highest in brackets. The GPT-2 small rows use the GPT-2 small
architecture with random weights (no download). The timing script is not yet part of
benchmarks/. The forward-hook rows ran
in a second job on the same worker; each one names that job's own plain forward.
What it does: trace.run(model, new_x) reruns the model on new input and returns a new trace with
the same saved sites. On a trace that saved a selection of sites (save=tl.module(...) or
tl.func(...)) it takes the guarded fast rerun, the model's native forward with the staged hooks, if
any, writing the saved sites into a new trace, with or without a staged edit; a trace that saved
every activation recaptures, and when a guard refuses (a changed call structure, a value
replacement, a non-module target) it falls back to recapturing the whole forward, with the reason in
last_run["fast_refused"]. The tables below were measured on the earlier build, in which an
unedited trace.run recaptured and then copied each operation's state into the new trace, costing
1.69 times a fresh tl.trace on ResNet-18, 1.57 times on GPT-2 small and 1.53 times on the LSTM
tagger; the fast engine's timings are in the next subsection. trace.run_transaction(inputs=new_x,
fast=True) takes the same engine whether or not an edit is staged, as long as the capture saved a
selection, and raises instead of falling back.
When to use it: to read the same sites on many new inputs, or to rerun a staged edit,
run_transaction(inputs=new_x, fast=True) costs 1.3 to 3 times a plain forward on these models.
Correctness: the fast rerun with a staged edit matched a plain forward with the same edit applied
by a forward hook (largest absolute difference 0 on GPT-2 small and ResNet-18, 1.9e-08 on the LSTM
tagger), and the unedited fast rerun's saved site matched a fresh tl.trace of the new input
exactly on all three models.
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
| ResNet-18, CPU, one 64x64 image, n=3 processes | plain forward, nothing attached | 14.20 ms (13.30 ms to 14.26 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | forward hook on the saved module (second job; its plain forward: 14.39 ms) | 14.56 ms (14.20 ms to 18.52 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | fresh tl.trace(model, x, save=tl.module(site)) |
405.28 ms (395.31 ms to 408.93 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | fresh tl.trace saving every listed module |
417.94 ms (402.45 ms to 665.18 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run(model, new_x) on a finished trace (earlier build: recaptures) |
684.49 ms (622.50 ms to 693.68 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run_transaction(inputs=new_x) |
855.95 ms (849.77 ms to 898.79 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
18.85 ms (18.19 ms to 19.18 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | fresh tl.trace(..., intervene=tl.when(...)) |
452.97 ms (444.02 ms to 521.99 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run(model, new_x) on a trace with a staged edit |
49.66 ms (48.54 ms to 56.45 ms) |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run_transaction(inputs=new_x, fast=True) with a staged edit |
19.36 ms (19.35 ms to 22.06 ms) |
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | plain forward, nothing attached | 1.64 s (1.63 s to 1.65 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | forward hook on the saved module (second job; its plain forward: 1.63 s) | 1.34 s (1.33 s to 1.35 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | fresh tl.trace(model, x, save=tl.module(site)) |
2.99 s (2.91 s to 3.07 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | fresh tl.trace saving every listed module |
4.28 s (4.21 s to 4.34 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run(model, new_x) on a finished trace (earlier build: recaptures) |
4.71 s (4.65 s to 4.77 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run_transaction(inputs=new_x) |
7.86 s (7.71 s to 8.01 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
1.33 s (1.33 s to 1.34 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | fresh tl.trace(..., intervene=tl.when(...)) |
3.17 s (3.05 s to 3.29 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run(model, new_x) on a trace with a staged edit |
1.41 s (1.38 s to 1.45 s) |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run_transaction(inputs=new_x, fast=True) with a staged edit |
1.32 s (1.28 s to 1.37 s) |
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | plain forward, nothing attached | 1.08 ms (1.03 ms to 1.46 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | forward hook on the saved module (second job; its plain forward: 1.07 ms) | 1.11 ms (1.08 ms to 1.13 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | fresh tl.trace(model, x, save=tl.module(site)) |
43.16 ms (42.24 ms to 44.64 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | fresh tl.trace saving every listed module |
43.95 ms (43.04 ms to 45.28 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run(model, new_x) on a finished trace (earlier build: recaptures) |
66.08 ms (65.84 ms to 66.79 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run_transaction(inputs=new_x) |
82.67 ms (80.72 ms to 82.98 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
3.11 ms (3.09 ms to 3.23 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | fresh tl.trace(..., intervene=tl.when(...)) |
51.34 ms (47.44 ms to 53.41 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run(model, new_x) on a trace with a staged edit |
8.89 ms (8.74 ms to 14.64 ms) |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run_transaction(inputs=new_x, fast=True) with a staged edit |
3.32 ms (3.30 ms to 3.75 ms) |
Saving more sites on the rerun, trace.run_transaction(inputs=new_x, save=[...module addresses]),
took about as long as the same saves, measured on a second worker (two processes each; plain
forward 10.61 and 10.73 ms on ResNet-18): ResNet-18
637.45 and 731.43 ms against
692.01 and 719.65 ms; LSTM tagger
58.84 and 58.65 ms against
60.71 and 60.09 ms. trace.run takes no save=.
Known costs: on GPT-2 small the plain-forward arm timed slower than the forward-hook and fast
arms in both jobs, and the cause was not found; compare GPT-2 rows with the fresh trace rather
than the plain forward. A save= selector such as tl.module(...) on run_transaction is
refused in this build (module addresses work). The first capture in a process took 1.7 to 2.4 s
on ResNet-18, 1.3 to 2.7 s on the LSTM tagger and 4.9 to 5.5 s on GPT-2 small (one call each,
installing the torch wrappers).
trace.run on new inputs with the fast engine (development build)¶
A development build of TorchLens leading to 3.0 sends trace.run(model, new_x) on an
unedited trace that saved a selection through the guarded fast engine instead of a full recapture.
Both builds were timed in two jobs that ran at the same time on the same shared 32-core Intel Xeon
2.80 GHz Linux CPU worker (one torch thread per process, load average 6 to 15), with the same
models, inputs, repeats and saved site as the tables above. The plain forward timed differently in
the two jobs, so compare the "times plain forward" columns, not the milliseconds across columns.
Correctness: the run reported engine rerun before and guarded_fast after on every call, with no
fallback. Before and after, the saved site of the new trace equalled a fresh tl.trace of the new
input exactly (largest absolute difference 0) on all three models, and the new trace's output
matched a plain forward (0 on ResNet-18 and GPT-2 small, 3.0e-08 on the LSTM tagger).
After the change, trace.run costs less than a fresh tl.trace on every model (0.11 times on
ResNet-18, 0.51 on GPT-2 small, 0.16 on the LSTM tagger) but still more than
run_transaction(inputs=new_x, fast=True) on the two small models (about 28 ms more on ResNet-18
and 7 ms more on the LSTM tagger per call).
| Workload (model, device, repeats) | Tool | Before: median (lowest to highest) | Before: times plain forward | After: median (lowest to highest) | After: times plain forward |
|---|---|---|---|---|---|
| ResNet-18, CPU, one 64x64 image, n=3 processes | plain forward, nothing attached | 22.68 ms (20.90 ms to 23.02 ms) | 1.00x | 14.42 ms (14.28 ms to 15.26 ms) | 1.00x |
| ResNet-18, CPU, one 64x64 image, n=3 processes | fresh tl.trace(model, x, save=tl.module(site)) |
508.54 ms (490.37 ms to 709.94 ms) | 22.4x | 442.97 ms (423.87 ms to 442.99 ms) | 30.7x |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run(model, new_x), no edit staged |
651.95 ms (649.85 ms to 1.08 s) | 28.7x | 47.48 ms (46.99 ms to 48.89 ms) | 3.29x |
| ResNet-18, CPU, one 64x64 image, n=3 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
19.70 ms (18.89 ms to 29.97 ms) | 0.87x | 19.18 ms (19.15 ms to 19.80 ms) | 1.33x |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | plain forward, nothing attached | 1.29 s (1.28 s to 1.30 s) | 1.00x | 1.32 s (1.31 s to 1.34 s) | 1.00x |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | fresh tl.trace(model, x, save=tl.module(site)) |
2.91 s (2.86 s to 2.96 s) | 2.26x | 2.95 s (2.87 s to 3.03 s) | 2.23x |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run(model, new_x), no edit staged |
4.28 s (4.28 s to 4.29 s) | 3.32x | 1.50 s (1.47 s to 1.54 s) | 1.13x |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, n=2 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
1.45 s (1.45 s to 1.45 s) | 1.12x | 1.47 s (1.47 s to 1.47 s) | 1.11x |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | plain forward, nothing attached | 1.04 ms (1.04 ms to 1.07 ms) | 1.00x | 1.67 ms (1.62 ms to 1.67 ms) | 1.00x |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | fresh tl.trace(model, x, save=tl.module(site)) |
42.45 ms (42.11 ms to 43.35 ms) | 40.7x | 70.56 ms (67.46 ms to 71.20 ms) | 42.4x |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run(model, new_x), no edit staged |
64.86 ms (64.10 ms to 90.98 ms) | 62.2x | 11.58 ms (10.93 ms to 12.07 ms) | 6.96x |
| LSTM tagger, CPU, one sequence of 32 tokens, n=3 processes | trace.run_transaction(inputs=new_x, fast=True), no edit staged |
3.00 ms (2.97 ms to 3.04 ms) | 2.88x | 4.66 ms (4.45 ms to 4.85 ms) | 2.80x |
Stopping a rerun early¶
trace.run_transaction(inputs=new_x, fast=True, until=<saved module>) stops the fast rerun once
that module has run. The stopped module's output equalled a fresh tl.trace of the new input
exactly on all three models. Without fast=True the same until= still costs about a full trace.
Same worker and build as above; each row is the median of up to 15 samples (GPT-2: 5) in one
process.
| Workload (model, device, repeats) | Tool | Median |
|---|---|---|
| ResNet-18, CPU, one 64x64 image, one process | plain forward, nothing attached | 13.65 ms |
| ResNet-18, CPU, one 64x64 image, one process | trace.run_transaction(inputs=new_x, fast=True), whole forward |
20.30 ms |
| ResNet-18, CPU, one 64x64 image, one process | the same with until="layer1" |
6.16 ms |
| ResNet-18, CPU, one 64x64 image, one process | the same with until="layer2" |
8.43 ms |
| ResNet-18, CPU, one 64x64 image, one process | trace.run_transaction(inputs=new_x, until="layer1"), without fast=True |
434.31 ms |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, one process | plain forward, nothing attached | 1.62 s |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, one process | trace.run_transaction(inputs=new_x, fast=True), whole forward |
1.51 s |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, one process | the same with until="transformer.h.1" |
161.36 ms |
| GPT-2 small (random weights), CPU, one forward of 16 sequences of 32 tokens, one process | trace.run_transaction(inputs=new_x, until="transformer.h.1"), without fast=True |
3.40 s |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | plain forward, nothing attached | 1.01 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | trace.run_transaction(inputs=new_x, fast=True), whole forward |
3.36 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | the same with until="embed" |
0.77 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | trace.run_transaction(inputs=new_x, until="embed"), without fast=True |
51.97 ms |
Replaying a saved trace without the model¶
A trace captured with CaptureOptions(replayable=True) and saved with
trace.save(path, level="runnable", include_weights=True) can be loaded with tl.load(path) and
run on new input with loaded.run_transaction(inputs=new_x), with no model object. Without
include_weights=True the file holds no weights and the replay runs on a fresh random
initialization (TorchLens warns runnable_random_init_run). With the weights, the replay's
output equalled model(new_x) (largest absolute difference 0 on ResNet-18 and GPT-2 small, 3e-08
on the LSTM tagger), and every run reported path faithfulness verified with the state taken from
the file. Measured on 2026-11-05 on a shared 32-core Intel Xeon 2.80 GHz Linux CPU worker, one torch thread per process, load average 14 to 21, same build as above, in one process per model (median
of up to 15 samples, GPT-2 up to 5). The worker was busier than for the rows above, so compare
these rows with each other only.
| Workload (model, device, repeats) | Tool | Median |
|---|---|---|
| ResNet-18, CPU, one 64x64 image, one process | plain forward, nothing attached | 20.47 ms |
| ResNet-18, CPU, one 64x64 image, one process | fresh tl.trace(model, x) |
694.92 ms |
| ResNet-18, CPU, one 64x64 image, one process | fresh capture with CaptureOptions(replayable=True) |
3.37 s |
| ResNet-18, CPU, one 64x64 image, one process | replay of the loaded runnable file on new input, loaded.run_transaction(inputs=new_x) |
583.57 ms |
| GPT-2 small (random weights, no key-value cache), CPU, one forward of 16 sequences of 32 tokens, one process | plain forward, nothing attached | 2.34 s |
| GPT-2 small (random weights, no key-value cache), CPU, one forward of 16 sequences of 32 tokens, one process | fresh tl.trace(model, x) |
5.27 s |
| GPT-2 small (random weights, no key-value cache), CPU, one forward of 16 sequences of 32 tokens, one process | fresh capture with CaptureOptions(replayable=True) |
17.56 s |
| GPT-2 small (random weights, no key-value cache), CPU, one forward of 16 sequences of 32 tokens, one process | replay of the loaded runnable file on new input, loaded.run_transaction(inputs=new_x) |
10.40 s |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | plain forward, nothing attached | 1.59 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | fresh tl.trace(model, x) |
66.93 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | fresh capture with CaptureOptions(replayable=True) |
290.84 ms |
| LSTM tagger, CPU, one sequence of 32 tokens, one process | replay of the loaded runnable file on new input, loaded.run_transaction(inputs=new_x) |
58.86 ms |
One-time costs per model: ResNet-18 save with weights 5.08 s (a 46.3 MiB file) and load 3.31 s;
GPT-2 small 26.85 s (627.8 MiB) and 14.42 s; LSTM tagger 1.15 s (1.7 MiB) and 498 ms. The replay
costs about what a fresh trace does on ResNet-18 and the LSTM tagger (0.84 and 0.88 times) and
about twice a fresh trace on GPT-2 small; the replayable capture itself is 4 to 5 times a default
trace. GPT-2 small saves only with use_cache=False: with the default key-value cache the save is
refused (output_container_contract_missing).
Edit sweeps with a recording per edit¶
What it does: one tl.record(..., intervene=tl.when(...)) or one
tl.trace(..., intervene=tl.when(...)) per edit, each returning the edited output.
When to use it: you need a record of each edited forward (activations, a graph, a check). If you only need the edited outputs, the bound live edit is 9 to 107 times faster on the LSTM tagger and ResNet-18. On GPT-2 small on CPU, where each forward takes seconds, the gap is under two times.
Numbers: on ResNet-18 on CPU, eight strengths took 195.36 ms bound, 1.79 s with a recording per strength and 5.26 s with a trace per strength; on GPU 33.63 ms, 1.06 s and 3.61 s.
Features over a stimulus set, tl.extract_dataset¶
What it does: runs a trace per batch and gathers the requested module's output across batches, in stimulus order.
Numbers: 37x a plain loop over the batches on the LSTM tagger on CPU (356.00 ms against 9.69 ms),
21x on ResNet-18 on CPU (2.69 s against 129.86 ms) and 241x on ResNet-18 on GPU (1.99 s against
8.25 ms). On the same GPU machine one tl.record per batch took 428.00 ms, about a fifth.
Known costs: the capture cost is paid per batch, so large batches spread it. On GPU the first run failed with inputs on two devices; the cause was fixed the same day and the GPU cell above is the fixed run, on a second RTX 2080 Ti machine.
Gradients¶
What it does: tl.trace(..., capture=CaptureOptions(save_grads=...)) then trace.backward(...)
records the forward graph and the gradient at the selected module.
Numbers: 34x a plain forward and backward on the LSTM tagger on CPU (117.50 ms against 3.49 ms), 12x on ResNet-18 on CPU (980.27 ms against 80.91 ms) and 62x on GPU (551.37 ms against 8.88 ms). A forward hook plus a tensor hook took 70.31 ms on ResNet-18 on CPU, and Captum's layer gradient 39.27 ms.
Known costs: the LSTM tagger's gradient cells could not run on GPU for any tool (cuDNN's RNN backward needs training mode, and the harness runs in eval mode).
Operations on a finished trace¶
The same runs timed what you do with a trace after capture. Medians of three processes, from the same result files:
| Operation | ResNet-18, CPU | ResNet-18, GPU | LSTM tagger, GPU |
|---|---|---|---|
Draw the whole graph to an SVG file, trace.draw(...) |
256.97 ms | 234.26 ms | 67.78 ms |
| Save a portable trace file and load it back | 3.22 s | 3.40 s | 187.84 ms |
Forward replay check, tl.validate(model, x, scope="forward") |
5.42 s | 2.55 s | 372.44 ms |
Trace clean and ablated forwards, then align them in a tl.Bundle supergraph |
1.33 s | 986.80 ms | 79.72 ms |
The one-call tl.summary(model, x) is measured separately, with its own tool, on
Summary performance.
Recording a generation¶
Recording a generation keeps a record of every decode step. Measured on 2026-11-03 on
the same 32-core Intel Xeon worker, four torch threads, one process per token count, on a
development build of TorchLens from that day, with a 70-million-parameter GPT-NeoX-style model
(EleutherAI/pythia-70m, float32), greedy decoding from a 7-token prompt, and the output of
every block's MLP and of the final layer norm saved at every step. Each row is one call of
tl.record(model, ids, save=sites, generate_kwargs={"max_new_tokens": n, "min_new_tokens": n,
"do_sample": False, "use_cache": True}), where sites joins tl.module(...) for each saved
module with |. One process per row, no repeats. Peak process memory is the operating system's
maximum resident set size; saved activations are the summed saved bytes of every step's record.
No run timed a plain model.generate(...) for comparison. Raw data:
data/capture_2026-11-03-generation.json and
data/capture_2026-11-03-gpu2.json.
Current behaviour: process memory grows linearly, about 2.7 MiB per token, and the time per token stays flat (the 500- and 1,000-token rows from the fix's own check, the 2,000-token row from the third run):
| Generated tokens | Wall time per token | Peak process memory (RSS) | Saved activations at the end |
|---|---|---|---|
| 500 | 356 ms | 2.1 GiB | 6.9 MiB |
| 1,000 | 395 ms | 3.4 GiB | 13.8 MiB |
| 2,000 | 397 ms | 6.2 GiB | 27.4 MiB |
The same 2,000-token run on the RTX 2080 Ti node (four torch threads) took 269 ms per token, peaked at 6.8 GiB of process memory and 458 MiB of GPU memory, and saved the same 27.4 MiB of activations.
Before a fix on 2026-11-03 the recorder kept every decode pass's whole output, including the model's key-value cache, so retained memory grew with the square of the generated tokens and the time per token grew with the run:
| Generated tokens | Wall time per token | Peak process memory (RSS) | Saved activations at the end |
|---|---|---|---|
| 500 | 857 ms | 5.3 GiB | 6.9 MiB |
| 1,000 | 1,336 ms | 15.7 GiB | 13.8 MiB |
| 2,000 | not reached | over 17 GiB (stopped after 17 min) | not reached |
The saved activations were a small part of that growth: 6.9 MiB of saved tensors at 500 tokens against 5.3 GiB of process memory; 13.8 MiB of saved tensors at 1,000 tokens against 15.7 GiB.
For long generations, record a shorter run or save fewer sites.
Where TorchLens is slower¶
A capture records every operation's metadata and checks it, so it costs much more than a hook that reads one tensor. On a small model the capture's fixed cost dominates. The first run's rows (LSTM tagger on CPU):
| Workload (model, device, repeats) | Tool | Median |
|---|---|---|
| LSTM tagger, CPU, full trace of every operation, n=2 processes of 20 samples | tl.trace(model, x) |
81.38 ms |
| LSTM tagger, CPU, full trace of every operation, n=2 processes of 20 samples | forward hooks on every module | 1.61 ms |
| LSTM tagger, CPU, one module's output, n=3 processes of 20 samples | tl.trace(..., save=tl.module(site)) |
77.96 ms |
| LSTM tagger, CPU, one module's output, n=3 processes of 20 samples | tl.extract |
82.93 ms |
| LSTM tagger, CPU, one module's output, n=3 processes of 20 samples | forward hook | 1.75 ms |
| LSTM tagger, CPU, eight steering strengths, n=3 processes of 20 samples | tl.trace(..., intervene=...) per strength |
640.23 ms |
| LSTM tagger, CPU, eight steering strengths, n=3 processes of 20 samples | tl.when(...).bind(model) |
25.60 ms |
| LSTM tagger, CPU, eight steering strengths, n=3 processes of 20 samples | forward hooks | 12.52 ms |
| LSTM tagger, CPU, one module's output over 16 stimuli in batches of 4, n=2 processes of 20 samples | tl.extract_dataset |
356.00 ms |
| LSTM tagger, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes of 20 samples | forward hook | 10.21 ms |
| LSTM tagger, CPU, gradient at one module output, n=3 processes of 20 samples | tl.trace with gradients saved |
117.50 ms |
| LSTM tagger, CPU, gradient at one module output, n=3 processes of 20 samples | tensor hook | 4.40 ms |
What this means in practice:
- To read one module's output, a forward hook is the faster tool. A capture is worth its cost when you need operations no module returns, do not yet know the site, or want the recording checked against the forward.
- To sweep many edits, use the bound edit (
tl.when(...).bind(model)), not a trace per edit. - To pull selected activations in a loop, use
tl.record, nottl.trace. - Batch feature extraction pays the capture cost per batch. Large batches spread it.
- Saving only selected sites (
save=) keeps fewer tensors, not less time: the structure of every operation is still recorded, which is why the one-module trace costs about what the full trace does. - On GPU the ratios are larger, because the plain forward is faster and TorchLens's added time is not.
- torchvision's fx feature extraction is faster than a plain forward of ResNet-18 for one module, because it builds a graph that stops at that module.
- To time a model itself, locate the slow part with TorchLens, then measure it clean with
torch.utils.benchmark.Timer.
Not measured yet¶
- Stopping a fresh capture early with
stop_after=(stopping a fast rerun early is in Stopping a rerun early). - A fast rerun of a trace on new inputs on the 3 Nov workloads (the 5 Nov reruns are in Reruns of a finished trace).
- A trace of metadata only, saving no activations.
tl.recordon the LSTM tagger on CPU, and every kind except the recorder, the trace and the sweeps on GPT-2 small (the GPT-2 runs used their own workloads and had no plain-forward arm).- The one-time cost of the first capture in a process.
- A plain
model.generate(...)as the reference for recording a generation, and generation past 2,000 tokens. - fx on the LSTM tagger: its data-dependent control flow cannot be traced by fx.
- Gradient arms on the LSTM tagger on GPU: cuDNN's RNN backward needs training mode.
Hosts, versions and method¶
Each run's result file names its host and versions; the page's tables quote the files' medians.
- First run (LSTM tagger on CPU; GPT-2 small live edits on CPU): a shared 32-core Intel Xeon 2.80 GHz Linux CPU worker, one torch thread per process, load average about 40.
Python 3.10, torch 2.13.0 (CPU build), transformers 5.18.0, TorchLens 2.36.3, nnsight 0.8.1.
Raw data:
data/capture_2026-11-03.json. - Second run, CPU rows (ResNet-18 and GPT-2 small): a shared 32-core Intel Xeon 2.80 GHz Linux CPU worker, one torch thread per process, load average 7 to 28.
Versions as in the first run, with torchvision 0.28.0 and Captum 0.9.0. Raw data:
data/capture_2026-11-03-run2.json. - Second run, GPU rows (ResNet-18): a 20-core Intel Core i9-9900X 3.50 GHz Linux workstation with an NVIDIA GeForce RTX 2080 Ti, one torch thread per process, load average about 10.
Versions as above except torch 2.13.0 and torchvision 0.28.0 built for CUDA 12.6. Raw data:
data/capture_2026-11-03-gpu.json. - Third run, GPT-2 small and LSTM tagger on GPU: a 256-core AMD EPYC 7662 Linux GPU node with an NVIDIA L40 (46 GiB), one torch thread per process, load average 56 to 74 (a shared node).
Raw data:
data/capture_2026-11-03-gpu3.json. - Third run,
tl.extract_dataseton GPU and the GPU generation run: a 48-core Intel Xeon Gold 6126 2.60 GHz Linux GPU node with an NVIDIA GeForce RTX 2080 Ti (11 GiB), one torch thread per process, load average 1 to 3. Raw data:data/capture_2026-11-03-gpu2.json. - Third run versions on both GPU nodes: Python 3.10, torch 2.13.0 built for CUDA 12.6, transformers 5.18.0, nnsight 0.8.1, TransformerLens 4.3.0, and a development build of TorchLens from that day.
- Reruns of a finished trace (2026-11-05): see their section.
- Recording a generation: see its section.
Method. Each LSTM tagger and ResNet-18 row is three separate processes (two for some first-run rows, as each row says); each process times up to 20 samples after 3 warmup calls (as few as 3 for the slowest TorchLens cells, which reach the 20 s budget first) and reports its median. The tables give the median of the process medians, and in brackets the lowest and highest. Each GPT-2 row is one process that runs the whole workload three times; the tables give the median of the three wall times and, in brackets, the fastest and slowest. The CPU GPT-2 rows come from two runs at different times on the shared worker, and its load changed between them, so each run carries its own forward-hook row: compare a tool with the hook row of the same run. Every GPU arm synchronises the GPU before it stops its clock, and each GPU job had one GPU to itself.
Each tool's output was compared with the forward-hook arm's output and matched: bit for bit for
every TorchLens and nnsight arm on ResNet-18, and with a largest absolute difference of 0.0 on
GPT-2. The CPU worker was loaded while the second run's rows ran: two identical plain-forward arms
timed on it differed by 54%, against 5% on the GPU workstation. TransformerLens runs its own GPT-2
implementation loaded with the same weights, so its rows time a different model code path with
hooks on hook_resid_post; every other GPT-2 row runs the transformers model.
All measurements¶
Every row the summary draws on, by run, with the peer tools measured beside TorchLens.
GPT-2 small on CPU (second run)¶
| Workload (model, device, repeats) | Tool | Median (fastest to slowest) |
|---|---|---|
| GPT-2 small, CPU, first run, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | forward hooks | 26.44 s (26.24 to 26.59 s) |
| GPT-2 small, CPU, first run, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | nnsight 0.8.1 | 26.92 s (26.43 to 27.40 s) |
| GPT-2 small, CPU, second run, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | forward hooks | 29.30 s (28.76 to 30.95 s) |
| GPT-2 small, CPU, second run, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | tl.record |
39.17 s (39.05 to 40.18 s) |
| GPT-2 small, CPU, second run, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
99.88 s (93.72 to 106.37 s) |
| GPT-2 small, CPU, first run, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | forward hooks | 20.50 s (19.86 to 20.67 s) |
| GPT-2 small, CPU, first run, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | nnsight 0.8.1 | 20.73 s (20.20 to 22.00 s) |
| GPT-2 small, CPU, second run, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | forward hooks | 26.96 s (26.48 to 27.50 s) |
| GPT-2 small, CPU, second run, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | tl.record |
32.32 s (27.23 to 35.98 s) |
| GPT-2 small, CPU, second run, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
49.97 s (49.80 to 59.79 s) |
| GPT-2 small, CPU, first run, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | forward hooks | 7.07 s (6.96 to 7.24 s) |
| GPT-2 small, CPU, first run, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | nnsight 0.8.1 | 7.24 s (7.16 to 7.36 s) |
| GPT-2 small, CPU, second run, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | forward hooks | 6.62 s (6.39 to 6.80 s) |
| GPT-2 small, CPU, second run, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | tl.record |
43.85 s (40.75 to 47.59 s) |
| GPT-2 small, CPU, second run, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
143.45 s (116.07 to 165.04 s) |
ResNet-18 on CPU (second run)¶
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
ResNet-18, CPU, one module's output (layer2), n=3 processes |
plain forward, nothing attached | 13.74 ms (13.69 ms to 19.94 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
forward hook | 13.77 ms (13.51 ms to 20.05 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
nnsight 0.8.1 | 15.24 ms (14.81 ms to 20.51 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
torchvision fx create_feature_extractor |
4.79 ms (4.62 ms to 7.86 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
tl.record(..., save=tl.module(site)) |
150.06 ms (141.98 ms to 154.78 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
tl.trace(..., save=tl.module(site)) |
505.21 ms (482.66 ms to 776.78 ms) |
ResNet-18, CPU, one module's output (layer2), n=3 processes |
tl.extract |
562.88 ms (519.58 ms to 758.70 ms) |
| ResNet-18, CPU, one module replaced by zeros, n=3 processes | forward hook | 14.21 ms (13.30 ms to 21.36 ms) |
| ResNet-18, CPU, one module replaced by zeros, n=3 processes | nnsight 0.8.1 | 15.83 ms (15.66 ms to 16.31 ms) |
| ResNet-18, CPU, one module replaced by zeros, n=3 processes | tl.when(...).bind(model) |
26.78 ms (18.16 ms to 27.01 ms) |
| ResNet-18, CPU, one module replaced by zeros, n=3 processes | tl.record(..., intervene=tl.when(...)) |
167.86 ms (159.73 ms to 246.03 ms) |
| ResNet-18, CPU, eight steering strengths, n=3 processes | forward hooks | 152.54 ms (150.13 ms to 168.64 ms) |
| ResNet-18, CPU, eight steering strengths, n=3 processes | nnsight 0.8.1 | 130.62 ms (120.35 ms to 182.87 ms) |
| ResNet-18, CPU, eight steering strengths, n=3 processes | tl.when(...).bind(model) |
195.36 ms (144.42 ms to 196.22 ms) |
| ResNet-18, CPU, eight steering strengths, n=3 processes | tl.record(..., intervene=...) per strength |
1.79 s (1.52 s to 2.31 s) |
| ResNet-18, CPU, eight steering strengths, n=3 processes | tl.trace(..., intervene=...) per strength |
5.26 s (4.58 s to 5.57 s) |
| ResNet-18, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes | forward hook | 130.10 ms (127.56 ms to 143.35 ms) |
| ResNet-18, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes | nnsight 0.8.1 | 143.95 ms (128.83 ms to 168.10 ms) |
| ResNet-18, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes | torchvision fx create_feature_extractor |
58.35 ms (47.24 ms to 70.77 ms) |
| ResNet-18, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.record per batch |
965.86 ms (681.98 ms to 1.09 s) |
| ResNet-18, CPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.extract_dataset |
2.69 s (2.60 s to 4.12 s) |
ResNet-18, CPU, gradient at one module output (layer2), n=3 processes |
forward hook plus tensor hook | 70.31 ms (67.09 ms to 97.64 ms) |
ResNet-18, CPU, gradient at one module output (layer2), n=3 processes |
Captum 0.9.0 layer gradient | 39.27 ms (31.06 ms to 42.61 ms) |
ResNet-18, CPU, gradient at one module output (layer2), n=3 processes |
tl.trace with gradients saved |
980.27 ms (740.80 ms to 1.06 s) |
| ResNet-18, CPU, full forward, n=3 processes | plain forward | 15.01 ms (14.32 ms to 15.64 ms) |
| ResNet-18, CPU, full forward, n=3 processes | plain forward after a trace (torch wrappers still installed, nothing recorded) | 22.11 ms (15.41 ms to 22.90 ms) |
| ResNet-18, CPU, full forward, n=3 processes | forward hooks on every module | 17.80 ms (15.83 ms to 23.05 ms) |
| ResNet-18, CPU, full forward, n=3 processes | tl.record (sparse recorder) |
236.13 ms (219.81 ms to 237.66 ms) |
| ResNet-18, CPU, full forward, n=3 processes | tl.trace(model, x) (full trace) |
710.60 ms (494.10 ms to 745.69 ms) |
ResNet-18 on GPU (second run, RTX 2080 Ti workstation)¶
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
ResNet-18, GPU, one module's output (layer2), n=3 processes |
plain forward, nothing attached | 2.30 ms (2.29 ms to 2.34 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
forward hook | 2.32 ms (2.31 ms to 2.32 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
nnsight 0.8.1 | 2.61 ms (2.56 ms to 2.72 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
torchvision fx create_feature_extractor |
0.76 ms (0.75 ms to 0.82 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
tl.record(..., save=tl.module(site)) |
98.55 ms (95.98 ms to 158.48 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
tl.trace(..., save=tl.module(site)) |
389.71 ms (376.99 ms to 403.93 ms) |
ResNet-18, GPU, one module's output (layer2), n=3 processes |
tl.extract |
376.36 ms (372.56 ms to 393.48 ms) |
| ResNet-18, GPU, one module replaced by zeros, n=3 processes | forward hook | 2.34 ms (2.34 ms to 2.36 ms) |
| ResNet-18, GPU, one module replaced by zeros, n=3 processes | nnsight 0.8.1 | 2.65 ms (2.61 ms to 2.94 ms) |
| ResNet-18, GPU, one module replaced by zeros, n=3 processes | tl.when(...).bind(model) |
3.70 ms (3.58 ms to 4.06 ms) |
| ResNet-18, GPU, one module replaced by zeros, n=3 processes | tl.record(..., intervene=tl.when(...)) |
103.33 ms (102.42 ms to 115.96 ms) |
| ResNet-18, GPU, eight steering strengths, n=3 processes | forward hooks | 13.10 ms (13.08 ms to 15.33 ms) |
| ResNet-18, GPU, eight steering strengths, n=3 processes | nnsight 0.8.1 | 17.01 ms (16.59 ms to 17.29 ms) |
| ResNet-18, GPU, eight steering strengths, n=3 processes | tl.when(...).bind(model) |
33.63 ms (29.87 ms to 49.86 ms) |
| ResNet-18, GPU, eight steering strengths, n=3 processes | tl.record(..., intervene=...) per strength |
1.06 s (1.05 s to 1.19 s) |
| ResNet-18, GPU, eight steering strengths, n=3 processes | tl.trace(..., intervene=...) per strength |
3.61 s (3.59 s to 4.20 s) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | forward hook | 8.20 ms (7.59 ms to 8.25 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | nnsight 0.8.1 | 8.48 ms (8.47 ms to 9.35 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | torchvision fx create_feature_extractor |
3.02 ms (3.00 ms to 3.13 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.record per batch |
393.03 ms (387.17 ms to 421.00 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4 | tl.extract_dataset |
fails: inputs end up on two devices (cuda:0 and cpu) |
ResNet-18, GPU, gradient at one module output (layer2), n=3 processes |
forward hook plus tensor hook | 9.27 ms (8.69 ms to 10.54 ms) |
ResNet-18, GPU, gradient at one module output (layer2), n=3 processes |
Captum 0.9.0 layer gradient | 7.76 ms (7.32 ms to 8.92 ms) |
ResNet-18, GPU, gradient at one module output (layer2), n=3 processes |
tl.trace with gradients saved |
551.37 ms (466.61 ms to 576.62 ms) |
| ResNet-18, GPU, full forward, n=3 processes | plain forward | 2.30 ms (2.27 ms to 2.33 ms) |
| ResNet-18, GPU, full forward, n=3 processes | plain forward after a trace (torch wrappers still installed, nothing recorded) | 2.39 ms (2.36 ms to 3.11 ms) |
| ResNet-18, GPU, full forward, n=3 processes | forward hooks on every module | 3.10 ms (3.08 ms to 3.44 ms) |
| ResNet-18, GPU, full forward, n=3 processes | tl.record (sparse recorder) |
99.82 ms (95.38 ms to 109.57 ms) |
| ResNet-18, GPU, full forward, n=3 processes | tl.trace(model, x) (full trace) |
427.04 ms (391.87 ms to 435.26 ms) |
The fx rows are faster than the plain forward because create_feature_extractor builds a graph
that stops at the requested module, so it runs only part of ResNet-18. The other arms run the
whole network. On GPU the tl.extract_dataset arm failed in this run with "Expected all tensors
to be on the same device" (cuda:0 and cpu): in-memory results were copied to the host while the
model stayed on the GPU. It was fixed the same day; the last table in this section measured the
fixed arm.
GPT-2 small on GPU (third run, L40 node)¶
| Workload (model, device, repeats) | Tool | Median (fastest to slowest) |
|---|---|---|
| GPT-2 small, GPU, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | forward hooks | 168 ms (167 ms to 173 ms) |
| GPT-2 small, GPU, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | nnsight 0.8.1 | 200 ms (194 ms to 200 ms) |
| GPT-2 small, GPU, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | TransformerLens 4.3.0 (its own GPT-2, hooks on hook_resid_post) |
369 ms (368 ms to 376 ms) |
| GPT-2 small, GPU, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | tl.record |
8.35 s (8.07 s to 9.84 s) |
| GPT-2 small, GPU, cache every block's output for 256 sequences of 32 tokens in batches of 16 (16 forwards), n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
49.67 s (48.81 s to 49.85 s) |
| GPT-2 small, GPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | forward hooks | 120 ms (119 ms to 120 ms) |
| GPT-2 small, GPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | nnsight 0.8.1 | 135 ms (134 ms to 139 ms) |
| GPT-2 small, GPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | TransformerLens 4.3.0 (its own GPT-2, hooks on hook_resid_post) |
259 ms (250 ms to 260 ms) |
| GPT-2 small, GPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | tl.record |
6.30 s (6.28 s to 6.39 s) |
| GPT-2 small, GPU, 12 steered forwards (3 layers by 4 strengths) on a batch of 16 sequences of 32 tokens, n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
23.84 s (23.22 s to 24.57 s) |
| GPT-2 small, GPU, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | forward hooks | 447 ms (447 ms to 454 ms) |
| GPT-2 small, GPU, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | nnsight 0.8.1 | 524 ms (504 ms to 536 ms) |
| GPT-2 small, GPU, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | TransformerLens 4.3.0 (its own GPT-2, hooks on hook_resid_post) |
1.11 s (1.08 s to 1.14 s) |
| GPT-2 small, GPU, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | tl.record |
26.29 s (26.03 s to 26.74 s) |
| GPT-2 small, GPU, activation patching sweep, 50 patched forwards of 16 tokens, n=3 | tl.trace(..., save=...) (full graph, selected outputs saved) |
92.10 s (91.33 s to 93.01 s) |
LSTM tagger on GPU (third run, L40 node)¶
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
| LSTM tagger, GPU, one module's output, n=3 processes | plain forward, nothing attached | 0.36 ms (0.35 ms to 0.36 ms) |
| LSTM tagger, GPU, one module's output, n=3 processes | forward hook | 0.37 ms (0.37 ms to 0.38 ms) |
| LSTM tagger, GPU, one module's output, n=3 processes | nnsight 0.8.1 | 0.44 ms (0.44 ms to 0.46 ms) |
| LSTM tagger, GPU, one module's output, n=3 processes | tl.record(..., save=tl.module(site)) |
18.16 ms (17.96 ms to 19.11 ms) |
| LSTM tagger, GPU, one module's output, n=3 processes | tl.trace(..., save=tl.module(site)) |
35.26 ms (35.17 ms to 37.08 ms) |
| LSTM tagger, GPU, one module's output, n=3 processes | tl.extract |
36.25 ms (35.57 ms to 38.38 ms) |
| LSTM tagger, GPU, one module replaced by zeros, n=3 processes | forward hook | 0.39 ms (0.39 ms to 0.41 ms) |
| LSTM tagger, GPU, one module replaced by zeros, n=3 processes | nnsight 0.8.1 | 0.49 ms (0.48 ms to 0.50 ms) |
| LSTM tagger, GPU, one module replaced by zeros, n=3 processes | tl.when(...).bind(model) |
0.77 ms (0.75 ms to 0.79 ms) |
| LSTM tagger, GPU, one module replaced by zeros, n=3 processes | tl.record(..., intervene=tl.when(...)) |
18.61 ms (18.60 ms to 19.79 ms) |
| LSTM tagger, GPU, eight steering strengths, n=3 processes | forward hooks | 2.55 ms (2.49 ms to 2.67 ms) |
| LSTM tagger, GPU, eight steering strengths, n=3 processes | nnsight 0.8.1 | 3.62 ms (3.49 ms to 3.73 ms) |
| LSTM tagger, GPU, eight steering strengths, n=3 processes | tl.when(...).bind(model) |
7.85 ms (7.45 ms to 8.21 ms) |
| LSTM tagger, GPU, eight steering strengths, n=3 processes | tl.record(..., intervene=...) per strength |
150.25 ms (148.79 ms to 157.70 ms) |
| LSTM tagger, GPU, eight steering strengths, n=3 processes | tl.trace(..., intervene=...) per strength |
307.09 ms (306.43 ms to 324.52 ms) |
| LSTM tagger, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | forward hook | 1.46 ms (1.37 ms to 1.48 ms) |
| LSTM tagger, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | nnsight 0.8.1 | 1.86 ms (1.76 ms to 1.91 ms) |
| LSTM tagger, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.record per batch |
72.40 ms (71.18 ms to 75.21 ms) |
| LSTM tagger, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.extract_dataset |
159.69 ms (149.83 ms to 202.53 ms) |
| LSTM tagger, GPU, full forward, n=3 processes | plain forward | 0.35 ms (0.35 ms to 0.36 ms) |
| LSTM tagger, GPU, full forward, n=3 processes | plain forward after a trace (torch wrappers still installed, nothing recorded) | 0.36 ms (0.36 ms to 0.38 ms) |
| LSTM tagger, GPU, full forward, n=3 processes | forward hooks on every module | 0.39 ms (0.39 ms to 0.39 ms) |
| LSTM tagger, GPU, full forward, n=3 processes | tl.record (sparse recorder) |
18.20 ms (18.06 ms to 19.07 ms) |
| LSTM tagger, GPU, full forward, n=3 processes | tl.trace(model, x) (full trace) |
36.66 ms (36.39 ms to 38.00 ms) |
The LSTM tagger's gradient arms could not run on GPU for any tool (cuDNN's RNN backward is only available in training mode, and the harness runs the model in eval mode), and fx refuses the model's data-dependent control flow. Of the peer tools, nnsight ran; Captum is a gradient arm; TransformerLens has no LSTM.
ResNet-18 on GPU after the tl.extract_dataset fix (third run, RTX 2080 Ti node)¶
| Workload (model, device, repeats) | Tool | Median (lowest to highest process median) |
|---|---|---|
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | forward hook | 8.29 ms (8.28 ms to 8.33 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | torchvision fx create_feature_extractor |
3.67 ms (3.62 ms to 3.68 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.record per batch |
428.00 ms (416.55 ms to 450.06 ms) |
| ResNet-18, GPU, one module's output over 16 stimuli in batches of 4, n=3 processes | tl.extract_dataset |
1.99 s (1.83 s to 2.02 s) |
Reproduce¶
pip install -e ".[dev,test]" nnsight==0.8.1 captum==0.9.0
OMP_NUM_THREADS=1 python benchmarks/capture/driver.py --smoke --warmups 3 --samples 20 \
--budget-s 20 --processes 3 --no-sweep --subjects lstm-tagger --out capture-lstm.json
OMP_NUM_THREADS=1 python -m benchmarks.capture.mi_workloads.matrix --model gpt2 \
--tools hooks,tl_bind --workloads steer_sweep --out mi-gpt2.jsonl --resdir mi-res
# second run: ResNet-18 (add --device cuda for the GPU rows) and GPT-2 small full forwards
OMP_NUM_THREADS=1 python benchmarks/capture/driver.py --smoke --warmups 3 --samples 20 \
--budget-s 20 --threads 1 --processes 3 --no-sweep --subjects resnet18 --out capture-resnet18.json
OMP_NUM_THREADS=1 python -m benchmarks.capture.mi_workloads.matrix --model gpt2 --timeout 1500 \
--tools hooks,nnsight,tl_record,tl_trace --workloads cache,steer_sweep,patch_sweep \
--out mi-gpt2.jsonl --resdir mi-res -- --slow-rep-s 0
# third run: the same on a GPU (TL_BENCH_DEVICE moves the GPT-2 workloads; the driver takes --device)
pip install transformer-lens==4.3.0
TL_BENCH_DEVICE=cuda OMP_NUM_THREADS=1 python -m benchmarks.capture.mi_workloads.matrix --model gpt2 \
--timeout 1500 --tools hooks,nnsight,tlens,tl_record,tl_trace --workloads cache,steer_sweep,patch_sweep \
--out mi-gpu-gpt2.jsonl --resdir mi-res -- --slow-rep-s 0
OMP_NUM_THREADS=1 python benchmarks/capture/driver.py --device cuda --smoke --warmups 3 --samples 20 \
--budget-s 20 --threads 1 --processes 3 --no-sweep --subjects lstm-tagger,resnet18 --out capture-gpu.json