Performance guide¶
Steering or patching over many forwards, or through generation? Read
Performance: choosing a fast path first: the capture-free
tl.when(site, action).bind(model) runs at about the cost of a plain forward hook.
TorchLens is fastest when it captures only the payloads you plan to inspect. A full
tl.trace(model, x) records a complete operation graph and saves activations for the selected
sites; tl.record(model, x, save=...) is the lighter path for tight loops where you only need
selected records and can materialize a Trace later.
Backend note: these timings and sparse-recording examples are torch-oriented. tl.record() /
sparse recording (tl.record()) is torch-only in the backend-v1 registry. Non-torch preview backends may have different
capture costs; JAX, tinygrad, Paddle, and TensorFlow .tlspec saves materialize array payloads but
loaded traces cannot replay-validate stripped runtime captures. Paddle preview capture has its own
dygraph/eager replay and static-inventory audit costs; TensorFlow preview capture runs live eager
op_callbacks plus self-consistency and per-op replay/perturbation accounting.
Decision tree¶
| Need | Use | Notes |
|---|---|---|
| Complete graph metadata and a few activations | tl.trace(model, x, save=predicate) |
Best default for debugging and one-off analysis. |
| Repeated activation pulls in a loop | tl.record(model, x, save=predicate) |
Torch-only lower-overhead path; call Recording.to_trace() only when you need graph structure, Trace.draw options or facets (a Recording has neither). |
| A local window around a later op | tl.trace(..., save=tl.followed_by(...), capture=CaptureOptions(lookback=K)) |
Retains bounded recent metadata, and optionally bounded recent payloads. |
| Disk-backed selected payloads | tl.trace(..., storage=tl.to_disk(path)) |
Keeps selected payloads portable without retaining them all in RAM. |
| Intervention during the forward pass | tl.trace(..., intervene=tl.when(...), save=...) |
Live edits cost more than passive capture; use only when the model must execute edited values. |
Steering or patching over many forwards or through generate() |
tl.when(site, action).bind(model) |
Capture-free, about 1x a plain forward hook; see choosing a fast path. |
| Final logits only | plain model(x) |
TorchLens adds wrapper dispatch and metadata work; skip it when no intermediate data is needed. |
Fast activation pull¶
import torch
from torch import nn
import torchlens as tl
from torchlens.options import CaptureOptions
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).eval()
x = torch.randn(2, 4)
trace = tl.trace(
model,
x,
save=tl.func("relu"),
capture=CaptureOptions(save_source_context=False),
)
relu_site = trace.find_sites(tl.func("relu")).first()
relu_activation = relu_site.out
assert relu_activation.shape == (2, 4)
Use save=tl.func(...), save=tl.in_module(...), or composed predicates such as
tl.func("conv2d") & tl.followed_by(tl.func("relu")) instead of saving every payload. The graph
metadata remains available, but unsaved payloads are intentionally absent.
Sparse recording loop¶
import torch
from torch import nn
import torchlens as tl
model = nn.Sequential(nn.Linear(4, 4), nn.GELU(), nn.Linear(4, 2)).eval()
x = torch.randn(2, 4)
recording = tl.record(model, x, save=tl.func("gelu"))
trace = recording.to_trace()
gelu_site = trace.find_sites(tl.func("gelu")).first()
assert gelu_site.out.shape == (2, 4)
tl.record(save=...) is the only torch sparse-capture spelling; the old
keep_op=/keep_module= alias kwargs are removed.
When a tl.record() forward raises, the default remains on_forward_error="raise". Opt into
on_forward_error="attach_partial" to attach exc.partial_recording and re-raise, or
on_forward_error="return_partial" to return a failed partial Recording (its output refuses
with output_forward_failed). Failed partials set status="partial_error", failed=True,
string-only error metadata, num_committed_ops, and best-effort last_event_* fields. user-op
failures exclude the failing call; TL-side capture failures may include a skipped/partial
current-call event. Failed partials cannot be converted with Recording.to_trace() or used with
Recording.backward(). MemoryError is swallowed like any other forward exception under
"return_partial"/"attach_partial", and materializing the partial allocates more memory
inside an already-exhausted heap — under genuine memory pressure keep the default
on_forward_error="raise". Full tl.trace(...) failures raise tl.errors.TraceCaptureError
with the partial capture on exc.partial_trace.
Speed knobs¶
| Knob | Faster setting | Tradeoff |
|---|---|---|
| Payload selection | save=tl.func(...) or save=tl.in_module(...) |
Unsaved activations cannot be read later. |
| Source text | capture=CaptureOptions(save_source_context=False) (the default) |
File/line identity is stored, but no source text: it is read from the file when you look at it, so it reflects the file on disk at read time, and a file changed or removed since capture shows that reason instead of text. save_source_context=True snapshots the text at capture. |
| Window payloads | lookback_payload_policy="metadata_only" |
tl.followed_by(...) can select metadata without retaining raw tensors. |
| Retroactive payloads | lookback_payload_policy="detached_raw" |
Enables payload recovery for recent matched ops at bounded memory cost. |
| Disk storage | storage=tl.to_disk(path) |
Reduces RAM pressure. The model output the forward returns stays in memory (the output op may alias it), and a later trace.run(...) re-captures every payload into RAM while the bundle keeps the original capture. Blob writes overlap capture on a bounded async pipeline by default; max_pending_bytes (256 MiB default) caps the RAM pending writes may hold, and a disk slower than capture blocks the forward instead of accumulating memory. async_writes=False restores synchronous per-blob writes. |
| Gradients | capture=CaptureOptions(save_grads=False) (the default) unless needed |
Backward-ready captures preserve more state and hooks. |
| Forward-only autograd | capture=CaptureOptions(inference_only=True), or capture inside torch.no_grad() |
Runs forward capture under torch.no_grad(); incompatible with backward capture. A capture entered under torch.no_grad() or torch.inference_mode() implies it unless a backward option is given. |
| Recording gradient hooks | omit backward_ready / save_grads on tl.record |
tl.record installs gradient hooks (1 to 5% of a record on CPU transformers, measured 2026-10-11) only when a backward is requested; Recording.backward() refuses otherwise. |
| Forward chunking | capture=CaptureOptions(chunk_size=N) |
Reduces forward-pass peak memory for single-batch tensor inputs; final saved activations are still accumulated in memory. |
| Recurrence detection | capture=CaptureOptions(recurrence_detection=False) |
Measured 39-42% of capture time off models with thousands of repeated ops (hand-rolled top-level loops, unrolled decodes). Repeated ops stay separate layers instead of rolling into one multi-pass layer, so a 6-iteration loop yields relu_1_2 ... relu_6_7 (each num_passes=1) instead of one relu_1_2 with num_passes=6. is_recurrent and max_layer_op_count are still reported. It is a TIME knob only: retained activation bytes are unchanged. |
| Visualization | Call trace.draw() after capture, not during hot loops |
Rendering is separate from activation collection. |
Escape diagnostics¶
The sys.modules crawler is deleted (replaced by the rescue re-run + the mechanical belt), so the default capture pays no crawl cost. The callable detector is a separate, opt-in diagnostic cost:
import torch
from torch import nn
import torchlens as tl
from torchlens.backends.torch.wrappers import unwrap_torch, wrap_torch
model = nn.ReLU()
x = torch.randn(4)
wrap_torch(escape_detector="shadow")
trace = tl.trace(model, x)
print(trace.health.num_escape_detector_events, trace.health.escape_detector_callback_ns)
unwrap_torch()
CaptureOptions(aten_ops=True) records the ATen rows behind each op and aten_ops="values" also
keeps what each ATen call returned (AtenOp.out). Measured on one 4-thread CPU worker (torch 2.13,
medians of 3): ResNet-18 at 224px, plain trace 0.53 s, rows 0.64 s, rows with values 0.61 s
(107 rows, 44 MiB of ATen values against 31 MiB of Op.out); GPT-2 small at 1x32 tokens, plain
trace 2.18 s, rows 2.9 s, rows with values 2.8 s (518 rows, 256 MiB against 96 MiB). The dispatch
mode is the cost; the values add memory (roughly 1.4x to 2.7x the saved Op.out bytes, since every
intermediate ATen result is kept), not time. With the switch off nothing is installed.
The independent aten-level completeness witness is also opt-in and can run alone or alongside the
callable detector. This is the only route to capture_verified=True. A plain tl.trace() never
arms it: every default capture reads capture_verified=None, which every honesty surface renders as
"not checked" -- not a clean bill. Escapes the wrappers themselves observe (worker-thread ops,
torch.jit.script regions, direct torch.ops.aten.* calls, .data.copy_()) still ceiling the
default capture to False, and tensor-to-scalar escapes (.item(), int(argmax)) raise the
scalar_escape advisory; but host tensor sources the wrappers cannot see -- torch.from_numpy(...)
/ torch.as_tensor(np_array) / torch.from_dlpack(...) round-trips, an autograd.Function whose
forward runs in NumPy, and in-forward storage writes through .numpy() / untyped_storage()
aliases -- leave NO ceiling on the default capture (the op is visible to a graph
reader as an internal-source op with parents=()). A default capture whose recorded ops include
torch.from_numpy, torch.from_dlpack, torch.frombuffer, or torch.as_tensor on non-tensor data
records one host_tensor_source row in trace.annotations["capture_advisories"] (count, sources and
the first location when code context was kept; no warning, no verdict change), so the shared artifact
says host data entered the graph unchecked. The other host sources above record nothing. Arm the witness (or run tl.validate) when that
class of escape matters:
import torch
from torch import nn
import torchlens as tl
from torchlens.backends.torch.wrappers import unwrap_torch, wrap_torch
model = nn.ReLU()
x = torch.randn(4)
wrap_torch(
escape_detector="shadow",
completeness_witness=True,
)
trace = tl.trace(model, x)
print(trace.health.completeness_verified)
print(trace.health.num_unaccounted_witness_events)
unwrap_torch()
A value-free capture (structure_only=True, on meta or real tensors) is never verified: under the
witness it still reads capture_verified=None, and completeness_witness_verified records the
census result.
The witness attaches each aten dispatch to the live wrapper token, func_call_id, and leaf barcode;
it does not correlate by clock time. A captured Python call may own several ordered aten operations,
recorded in trace.health.completeness_decompositions. Dispatches with no owner, or owned by a wrapper whose
func_call_id emits no logged op, produce a CaptureGapWarning, set
trace._health_completeness_verified=False, and append structured rows to
trace.completeness_diagnostics. Work under pause_logging() is outside the census. The documented
torch.func/functorch transform boundary runs its interior under that paused scope, and fused or opaque
kernel interiors below the dispatcher are not observable; the dispatched boundary operator itself is
still accounted normally.
completeness_witness_verified=True makes only this dispatch-census claim: every owner-thread aten
event observed during active logging was correlated to an emitted op or to an exact audited boundary.
It does not prove that every user call route entered active logging. In particular, a transform callable
built before wrapping can escape without a transform boundary op; TorchLens detects that route and leaves
the census result True while setting capture_verified=False with
capture_verification_reason="transform_call_route_unverified". escape_detector_verified=True makes
the separate claim that the callable detector saw no observable raw-call escape. capture_verified=True
is the combined result only when the enabled census/detector checks pass, no raw-transform escape was
detected, and the owner-thread qualification remains valid. A bypassed torch.compile region reports
the more specific capture_verification_reason="dynamo_region_not_logged" in preference to any of the
above, since Dynamo's compile threads and unaccounted aten dispatches are symptoms of that one region.
The honest rollout comparison is legacy with no guard versus scoped with the requested
guard, not crawl time in isolation. On Python 3.10–3.11, shadow mode uses sys.setprofile and can
be expensive for Python-call-heavy models: a representative call-heavy 16-layer MLP measurement on
Python 3.11 was +371% versus the unguarded capture. Treat shadow as an expensive, diagnostic-only
soak tool, not a production capture setting. On Python 3.12+ it prefers local Python-start monitoring
plus diagnostic CALL events. Shadow remains default-off.
The dispatcher witness is likewise default-off. Its diagnostic cost is environment- and workload-
dependent; use an alternating on/off measurement on the target host rather than relying on a fixed
cross-hardware percentage. On a Python 3.11.6 / PyTorch 2.8.0 CPU environment, a
16-pair Linear(64, 64)/ReLU MLP at batch size 16 measured +17.8% median overhead: 0.374
s/capture with the witness off and 0.440 s/capture with it on. Ten alternating batches of five
full tl.trace calls per mode followed four two-capture warmup batches; the per-batch ranges were
0.341–0.379 s off and 0.415–0.453 s on. The witness-off route does not enter the witness-only
pause_logging() contexts used to keep TorchLens bookkeeping dispatches out of the user-op census.
Phase timing buckets¶
trace._phase_timings groups wall-clock timings by stable snake_case bucket names. Capture
buckets include ctx_build_*, dispatch_*, clone_save_*, backward_grad_event_* and
object_construction_op. Postprocess buckets use the postprocess pipeline's step names
(add_output_layers through release_param_refs). Graphviz rendering records
render_graphviz_forward, render_graphviz_backward, or render_graphviz_combined when those
render entrypoints run.
Chunked forward capture¶
import torch
from torch import nn
import torchlens as tl
model = nn.Sequential(nn.Linear(1024, 1024), nn.ReLU()).eval()
x = torch.randn(128, 1024)
trace = tl.trace(model, x, save=tl.func("relu"), capture=tl.options.CaptureOptions(chunk_size=16))
assert trace.find_sites(tl.func("relu")).first().out.shape[0] == 128
chunk_size= is forward-only sugar over a first trace(...) followed by
trace.append(...) for later chunks. It splits selected positional tensor leaves along
dimension 0, executes one sub-batch at a time, and returns one accumulated in-memory Trace.
The v1 limits are intentionally narrow: torch backend only, positional inputs only, no
backward_ready, no save_grads, no live hooks=, no public intervene=, and no
storage=tl.to_disk(...)/storage=. Loaded or live chunked traces also reject
backward() because they do not retain one full-batch autograd graph.
Auto mode splits only when there is exactly one ndim > 0 tensor leaf under standard Python
containers (list, tuple, dict, or namedtuple). If there are several candidates, pass
explicit paths:
import torch
from torch import nn
import torchlens as tl
class MaskedModel(nn.Module):
def forward(self, tokens, attention_mask):
return (tokens * attention_mask).sum(dim=-1)
model = MaskedModel().eval()
tokens = torch.randn(64, 16)
attention_mask = torch.ones(64, 16)
trace = tl.trace(model, (tokens, attention_mask), chunk_paths=["0", "1"], capture=tl.options.CaptureOptions(chunk_size=8))
Unlisted leaves are passed unchanged to every chunk, which is useful for shared masks or bias
tables. The memory contract is forward-pass peak only: final saved activations are concatenated
and retained according to save=, and preprocessing still sees the full batch if CaptureOptions(input_transform=...)
materializes it.
For activation extraction without a Trace, use tl.extract_dataset(...) (the former tl.batched_extract alias is removed); that path returns
tensors or .pt files rather than accumulated graph metadata. chunk_size= covers the remaining
"dataloader wrapper" case for stacked multi-pass trace capture. Disk mode (path=) writes
atomic shards plus a self-describing manifest.json (site identity, stimulus provenance, axis
semantics, dtypes); an interrupted long extraction continues from its last completed shard with
resume=True, and torchlens.load(path) reads the artifact back
with its metadata (both names are experimental).
Windowed and disk-backed capture¶
from pathlib import Path
import torch
from torch import nn
import torchlens as tl
model = nn.Sequential(nn.Conv2d(1, 2, 3), nn.ReLU()).eval()
x = torch.randn(1, 1, 5, 5)
path = Path(DOCS_TMPDIR) / "windowed.tlspec"
predicate = tl.func("conv2d") & tl.followed_by(tl.func("relu"))
trace = tl.trace(
model,
x,
save=predicate,
storage=tl.to_disk(path), capture=tl.options.CaptureOptions(lookback=4, lookback_payload_policy="detached_raw"),
)
assert trace.find_sites(tl.func("conv2d")).first().out.shape == (1, 2, 3, 3)
Use disk-backed storage for selected payloads that are too large or numerous to keep in memory.
Trace repr, notebook HTML, and JSON explanation do not materialize lazy disk payloads for their
NaN/Inf summary; those refs are reported as unexamined until you call op.payload.materialize_out().
This exemption applies to predicate-selected disk-only payloads. Exhaustive saving
(save="all", the default) keeps RAM copies until postprocess, so combine a narrower
save= with disk streaming to reduce peak retained memory. Note that save= itself takes a
predicate, selector, or CaptureOptions — never the string "all".
Portable .tlspec/ bundles store manifest data plus tensor sidecars when the backend supports
materialized payloads; executable Python callables are not portable. Backend-aware manifest schema
v2 adds backend, backend_runtime, nullable torch-specific fields, and payload_policy.
JAX, tinygrad, Paddle, and TensorFlow preview bundles materialize array payloads; loaded traces
still report replay validation as unavailable because portable save strips runtime replay captures.
Intervention cost¶
import torch
from torch import nn
import torchlens as tl
model = nn.Sequential(nn.Linear(4, 4), nn.ReLU(), nn.Linear(4, 2)).eval()
x = torch.randn(2, 4)
patched = tl.trace(
model,
x,
save=tl.func("relu"),
intervene=tl.when(tl.func("relu"), tl.zero_ablate()),
)
assert torch.count_nonzero(patched.find_sites(tl.func("relu")).first().out) == 0
Use intervene= when the edited value must affect downstream execution. For post-hoc experiments
on an existing trace, prefer trace.fork() and do(site, edit), which stages the edit and pushes
it downstream in one call (which engine fits which question: intervention explainers).
Long sequences and long contexts¶
Saved activations grow with sequence length at every op, and attention probabilities grow with
its square: one layer's (batch, heads, q, kv) matrix for a 4096-token sequence with 32 heads in
bfloat16 is 32 x 4096 x 4096 x 2 bytes, 1 GiB, so a 32-layer model's set is 32 GiB. Plan what
to keep before the first long capture:
- Name the sites with
save=(tl.module("model.layers.20"),tl.func("linear") & tl.in_module("model.layers.20")) instead of the whole-graph default. CaptureOptions(save_budget=...)(default"auto") caps saved bytes; an exceeded budget raisesSaveBudgetExceededErrorrather than exhausting memory, andoutput_device="cpu"keeps saved activations in host memory.- For a logit-lens sweep over many positions,
torchlens._semantic.logit_lens_predictionsprojects one layer at a time and keeps only top-k summaries. - For many forwards or generation, a bound spec (and its
generate) saves nothing at all; editing the cached past of a long prompt is the KV-cache recipe. - For a dataset of long sequences,
tl.extract_dataset(..., path=...)writes the named layers to disk batch by batch.
Counting the backward that ran¶
tl.report.backward_status(trace) reports the compute of the backward pass that actually
ran, counted call by call. When a capture requested a backward (backward_ready=True,
save_grads= or default_grad=), every ATen call that backward dispatches is recorded on
its BackwardPass.aten_ops, with its shapes, its dtypes and the GradFn call it ran
under. Each call is then counted with the same convention as torch.utils.flop_counter: one
multiply-add counts 2 FLOPs, and only matmul and convolution calls are counted. Any other call
is listed by name in uncounted_ops; it is never counted as zero and never estimated.
Frozen weights, partial requires_grad and second-order passes are counted exactly as
they ran. A create_graph=True gradient and the backward run through it are separate passes
in executed_flops_per_pass.
import torch
from torch import nn
import torchlens as tl
model = nn.Sequential(nn.Linear(16, 32), nn.ReLU(), nn.Linear(32, 4))
model[0].requires_grad_(False) # frozen first layer, and x needs no gradient
x = torch.randn(2, 16)
trace = tl.trace(model, x, capture=tl.options.CaptureOptions(backward_ready=True))
trace.backward(trace[trace.output_layers[0]].out.sum())
status = tl.report.backward_status(trace)
# Only the last layer's weight gradient runs (2 * 2 * 32 * 4 FLOPs): nothing upstream of
# it needs a gradient, so no input-gradient matmul is dispatched.
assert status.executed_flops == 2 * 2 * 32 * 4
print(status.status_line)
print(status.uncounted_op_counts) # the bias-gradient sums and other calls, named, not zeroed
from torch.nn.attention import SDPBackend, sdpa_kernel
class Attend(nn.Module):
def __init__(self):
super().__init__()
self.qkv = nn.Linear(8, 24)
def forward(self, x):
q, k, v = self.qkv(x).view(1, 6, 3, 2, 4).permute(2, 0, 3, 1, 4)
return nn.functional.scaled_dot_product_attention(q, k, v)
def executed(model, x):
trace = tl.trace(model, x, capture=tl.options.CaptureOptions(backward_ready=True))
trace.backward(trace[trace.output_layers[0]].out.sum())
return tl.report.backward_status(trace).executed_flops
attend, x = Attend(), torch.randn(1, 6, 8)
fused = executed(attend, x) # CPU picks its fused attention kernel
with sdpa_kernel(SDPBackend.MATH):
assert executed(attend, x) == fused # the decomposed math counts the same
Calls TorchLens makes inside its own GradFn posthooks (gradient logging and the backward
edits they apply) are not recorded; the copies its tensor hooks make when saving gradients
are recorded as zero-arithmetic calls and never change the count. The record exists only in the session that ran the backward: a
saved and reloaded trace reports the executed backward as unknown. A capture that requests
no backward never installs the recorder and pays nothing for it. The training-cost estimate
tl.report.backward_estimate(trace) is a separate figure, always labeled hypothetical, and it
never appears where an executed cost belongs.
What is counted, and where FlopCounterMode differs¶
Forward, each op's flops_forward follows its rule, with one multiply-add counted as 2 FLOPs:
- A linear or convolution weight computed inside
forward(weight norm, spectral norm, a weight passed in as an input) is read from the call's arguments, as a parameter weight is. - The fused recurrent kernels behind
nn.RNN,nn.GRU,nn.LSTMand their cells count one multiply-add per weight-matrix element per output row, plus their bias adds; the gate nonlinearities inside the kernel are not counted. A sequence kernel's whole call is counted on its sequence output and its final states read 0.lstm_cellreturns two outputs of one shape that cannot be told apart, so half the call is counted on each. torch.linalg.multi_dotcounts its chain in the order it runs, the cheapest one.einsumequations with...are counted; repeated labels within one operand and equations over three or more operands stay unknown.mv,dot,vdotandspmmcount as matrix products; a sparsespmmoperand is counted as dense, from its shape.
Backward, tl.report.backward_status(trace) also counts two fused kernels as the math they
replace: the CPU fused attention backward (_scaled_dot_product_flash_attention_for_cpu_backward:
the four matrix products of the attention gradient over the full query x key square, which is
what the MATH attention backend dispatches) and the oneDNN LSTM layer backward
(mkldnn_rnn_layer_backward: dX, dW_ih, dH and dW_hh).
The count follows torch.utils.flop_counter.FlopCounterMode's convention and differs from it in
two named ways. FlopCounterMode counts a grouped or depthwise convolution's weight gradient
groups times over; TorchLens counts it once. And FlopCounterMode has no rule for mv,
addmv, dot, vdot, the CPU fused attention kernels or the oneDNN LSTM layer kernels, so it
reports 0 for that work; TorchLens counts it.
When another tool is faster¶
Use raw PyTorch when you only need the final output. Use torch.profiler when the question is
kernel timing rather than activation provenance. Use TransformerLens when your workflow is entirely
inside its supported transformer families and you already want its named hook points. Use Captum or
Inseq when you want mature attribution algorithms out of the box rather than the lower-level
activation and gradient substrate.
Measured numbers (canonical bench host)¶
Measured on the canonical bench host (Intel i9-9900X) at SHA 712a4e5 on
2026-06-16, from a full non-smoke run (baseline_status: "canonical"). The complete 196-row
table (CPU + CUDA, every model and row) lives in
docs/_perf_numbers.md and the raw baseline in
benchmarks/perf_baselines/linux-cpu.json.
Headline: with sparse recording and an early halt at ~25% depth, capture runs faster than the
raw forward pass itself — fastlog_halt_25 is 0.84x raw forward on ResNet-18 (CPU) and 0.83x on
GPT-2 (HookedTransformer, CPU). You only pay for the layers you actually reach.
Representative CPU rows ("vs raw" = multiple of the raw forward pass):
| Model | Row | Median ms | vs raw forward |
|---|---|---|---|
| resnet18 | raw_forward | 72.2 | 1.00x |
| resnet18 | tl_trace (full capture) | 994.6 | 13.78x |
| resnet18 | fastlog_zero (predicate false) | 157.5 | 2.18x |
| resnet18 | fastlog_halt_25 | 60.8 | 0.84x |
| gpt2_hf | raw_forward | 130.4 | 1.00x |
| gpt2_hf | tl_trace (full capture) | 1927.0 | 14.77x |
| gpt2_hf | fastlog_zero (predicate false) | 506.0 | 3.88x |
| gpt2_hf | fastlog_halt_25 | 134.7 | 1.03x |
| gpt2_hooked | raw_forward | 343.4 | 1.00x |
| gpt2_hooked | tl_trace (full capture) | 5048.5 | 14.70x |
| gpt2_hooked | fastlog_halt_25 | 283.6 | 0.83x |
(Full exhaustive capture (tl_trace) costs ~14x the forward and amortizes on large models; the
fastlog_* rows show selective / early-exit capture, where halting at 25% depth drops below the
raw-forward cost. TinyNet ratios are dominated by fixed per-capture overhead — see the full table.)
Re-capturing baselines (canonical host)¶
# Full suite on the canonical bench host (quiet machine):
python -m benchmarks.perf_suite --rerun --baseline-status canonical \
--out-json benchmarks/perf_baselines/<host>-<device>.json \
--out-md benchmarks/perf_results_<date>.md
# Sanity self-compare:
python -m benchmarks.perf_gate --baseline benchmarks/perf_baselines/<host>-<device>.json \
--current benchmarks/perf_baselines/<host>-<device>.json # expect "passed": true
# Regenerate the docs table:
python -m benchmarks.generate_perf_numbers benchmarks/perf_baselines/<host>-<device>.json \
--out docs/_perf_numbers_provisional.md
On the canonical host, drop the -provisional filename suffix; --baseline-status canonical
requires a full non-smoke, non-addendum run and emits generated speed headlines.
The gate judges TorchLens-owned rows on process-CPU statistics (cpu_median_ms/cpu_iqr_ms);
the wall-clock fallback for pre-CPU-metric payloads is not authoritative — a TorchLens row
judged on wall clock fails the gate by default (the committed 2026-06-16 linux-cpu.json
baseline predates the CPU metrics, so comparisons against it need either a rebaseline on the
canonical host or the explicit, disclosed --allow-wall-clock-only legacy opt-out).