Cross-engine checks: serving-scale capture and how TorchLens verifies it¶
TorchLens captures one local eager forward pass and records every operation it runs. That is the right tool for understanding a model and for checking other tools against it on a small model. It is not a serving engine: it does not batch requests, page a KV cache across requests, shard a model over GPUs or run quantized serving kernels.
Where to go for serving-scale capture¶
For activations from a model under serving load, use the serving engine's own hooks or extension points:
- vLLM: https://docs.vllm.ai
- SGLang: https://docs.sglang.ai
- TensorRT-LLM: https://nvidia.github.io/TensorRT-LLM/
Capture libraries that run inside these engines, on remote GPUs or across devices are listed in where to go instead. TorchLens has no integration with these engines and makes no claim about which sites their hooks expose or what tensors those hooks return. Serving engines run fused kernels, so a hook named like a TransformerLens or nnterp site may read a different tensor than the eager model computes there. The check below finds that out before you rely on it.
How TorchLens checks an engine on a small model¶
Run the same small model through the engine and through TorchLens, read the same named sites from both, and compare them. TorchLens is the eager reference: it records every executed op, so a site that disagrees means the engine's hook reads a different tensor (a fused kernel, a different hook point, a tuple element other than the one you expected), or the two runs differ in mode, dtype, device or padding.
Name the sites the way your interpretability library does.
torchlens.mechinterp.resolve_alias(trace, name) accepts TransformerLens names (2.x,
3.x bridge-cache and 4.x canonical names) and nnterp's StandardizedTransformer accessor
names, and resolves either to the captured site:
| nnterp name | TransformerLens name | TorchLens facet |
|---|---|---|
token_embeddings, embed_tokens.output |
hook_embed, embed.hook_out |
lookup on the token embedding |
layers_input[i] |
blocks.{i}.hook_resid_pre |
resid_pre |
attentions_input[i] |
blocks.{i}.attn.hook_in |
input on the attention module |
attention_probabilities[i] |
blocks.{i}.attn.hook_pattern |
pattern |
attentions_output[i] |
blocks.{i}.hook_attn_out |
attn_out |
mlps_input[i] |
blocks.{i}.mlp.hook_in |
input on the MLP module |
mlps_output[i] |
blocks.{i}.hook_mlp_out |
output on the MLP module |
layers_output[i] |
blocks.{i}.hook_resid_post |
resid_post |
ln_final.input |
ln_final.hook_in |
final_norm_input |
lm_head.output, logits |
unembed.hook_out |
logits |
nnterp standardizes no per-block norm names, and ln_final.output has no row (no facet
names the final norm's output apart from the per-block norms; read ln_final.input and
apply the norm, or address the module directly). Under a fused attention kernel there is no
softmax op, so attention_probabilities[i] resolves reconstructed_read_only: the pattern
rebuilt from the recorded kernel inputs and checked against the kernel output, readable but
not editable (nnterp needs enable_attention_probs=True to read it at all).
alias_report(trace, generation="nnterp") lists every site of the trace under its nnterp
name, with an honest status for each (generation="2.x" and "4.x" give the
TransformerLens names). The model below runs the default fused attention; its attention
patterns resolve as read-only reconstructions, so no site is missing:
# migration-test: tool=torchlens expected=(19, [], 'transformer.h.1', 'resid_post')
import torch
import transformers
import torchlens as tl
import torchlens.mechinterp as mi
torch.manual_seed(0)
config = transformers.GPT2Config(n_layer=2, n_embd=32, n_head=2, vocab_size=64, n_positions=16)
model = transformers.GPT2LMHeadModel(config).eval()
ids = torch.tensor([[1, 2, 3, 4]])
trace = tl.trace(model, ids, save="all")
report = mi.alias_report(trace, generation="nnterp")
not_live = sorted(r.query for r in report if r.status not in ("real", "reconstructed_read_only"))
site = mi.resolve_alias(trace, "layers_output[1]")
RESULT = (len(report), not_live, site.module_address, site.facet)
interp-engine point addresses¶
interp-engine (https://github.com/decoderesearch/interp-engine) names a site by a
canonical point address: a point name, then the layer, then any extra coordinate
(resid_post.5, embeddings, resid_streams.5.stream-2). Its capture(token_ids, points)
takes those strings as they are. resolve_alias and alias_report(trace,
generation="interp-engine") accept the addresses below. The table is pinned to
interp-engine 1.14.3 (commit 2afe000da14f1fff8b9c611665f7fe343f523dd0); its address fields
are name, layer and stream.
| interp-engine point | TransformerLens name | TorchLens facet |
|---|---|---|
embeddings |
hook_embed |
lookup on the token embedding |
resid_pre.L |
blocks.L.hook_resid_pre |
resid_pre |
attn_in.L |
blocks.L.attn.hook_in |
input on the attention module |
value.L |
blocks.L.attn.hook_v |
v |
attn_scores.L |
blocks.L.attn.hook_attn_scores |
scores |
attn_probs.L |
blocks.L.attn.hook_pattern |
pattern |
z.L |
blocks.L.attn.hook_z |
z |
attn_out.L |
blocks.L.attn.hook_out |
attn_out |
resid_mid.L |
blocks.L.hook_resid_mid |
resid_mid |
mlp_in.L |
blocks.L.mlp.hook_in |
input on the MLP module |
mlp_pre.L |
blocks.L.mlp.hook_pre |
up_out (plain MLP only) |
mlp_pre_linear.L |
blocks.L.mlp.hook_pre_linear |
up_out (gated MLP only) |
mlp_act.L |
blocks.L.mlp.hook_post |
intermediate |
mlp_out.L |
blocks.L.mlp.hook_out |
output on the MLP module |
resid_post.L |
blocks.L.hook_resid_post |
resid_post |
lm_head |
(the returned logits) | logits |
alias_report(trace, generation="interp-engine") lists the trace's sites under these
addresses, then one structurally_absent entry for each of the 18 other interp-engine points,
with the reason none of them has a TorchLens site: the QK-norm points (q_norm_in,
q_norm_out, k_norm_in, k_norm_out), attn_gate, the post-sublayer norm outputs
(attn_out_post, mlp_out_post), the MoE routing points (router_logits,
expert_weights, expert_indices), final_norm, and the seven hyper-connection points.
Hand interp-engine the addresses whose status says TorchLens has the site:
# migration-test: tool=torchlens expected=(48, 18, ['mlp_pre_linear.0', 'mlp_pre_linear.1'], 'transformer.h.1')
import torch
import transformers
import torchlens as tl
import torchlens.mechinterp as mi
torch.manual_seed(0)
config = transformers.GPT2Config(
n_layer=2, n_embd=32, n_head=2, vocab_size=64, n_positions=16, attn_implementation="eager"
)
model = transformers.GPT2LMHeadModel(config).eval()
ids = torch.tensor([[1, 2, 3, 4]])
trace = tl.trace(model, ids, save="all")
report = mi.alias_report(trace, generation="interp-engine")
has_site = ("real", "reconstructed_read_only", "needs_capture")
capture_points = [r.query for r in report if r.status in has_site]
no_site = [r.query for r in report if r.facet is None]
not_live = sorted(r.query for r in report if r.facet is not None and r.status not in has_site)
site = mi.resolve_alias(trace, "resid_post.1")
# With interp-engine: acts = await engine.capture(ids[0].tolist(), capture_points)
RESULT = (len(report), len(no_site), not_live, site.module_address)
Limits of the mapping:
- interp-engine returns one prompt without a batch dimension:
[n_tokens, width]for most points (value.Lwith its heads flattened into the width) and[heads, query, key]forattn_scores.Landattn_probs.L. TorchLens keeps the model's own layout, so drop the batch dimension, flattenvalue.L's heads (v[0].reshape(n_tokens, -1)), and forz.Lmove the head dimension behind the token dimension (z[0].permute(1, 0, 2).reshape(n_tokens, -1)) before comparing. layeris interp-engine's flattened forward order. It equals the block index on single-stack decoders (GPT-2, Llama-style models), which are the models this table was checked on; on other layouts check the engine's numbering before pairing.embeddingsis the token embedding module's output. GPT-2's learned position embedding (transformer.wpe) has no interp-engine point.attn_out.Landmlp_out.Lare the raw sublayer outputs. On families with a norm after each sublayer (Gemma-2, OLMo-2) interp-engine'sattn_out_postandmlp_out_postdiffer from them, and TorchLens has no facet for those.- The query and key projections, the attention
resultper head, the gated MLP'sgated_out,down_out, the norms'normalizedandinv_rms, the embeddingindicesandfinal_norm_inputhave no interp-engine point.
On GPT-2 small (158 addresses) and SmolLM2-135M (392 addresses), in float32 on CPU with interp-engine's eager backend, every address above that TorchLens has a site for was captured by interp-engine and matched the TorchLens tensor exactly. Attention scores match including the masked entries.
Worked example: checking nnsight's hooks against TorchLens¶
nnsight (pip install nnsight) reads module inputs and outputs inside a trace context.
The example reads five sites per layer plus the logits through nnsight, captures the same
model with TorchLens, resolves the nnterp names, and compares every pair. nnsight reads
modules in execution order, so each layer is read input first and output last.
# migration-test: tool=nnsight expected=(11, [])
import torch
import transformers
from nnsight import NNsight
import torchlens as tl
import torchlens.mechinterp as mi
torch.manual_seed(0)
config = transformers.GPT2Config(n_layer=2, n_embd=32, n_head=2, vocab_size=64, n_positions=16)
hf_model = transformers.GPT2LMHeadModel(config).eval()
ids = torch.tensor([[1, 2, 3, 4]])
# The engine side: hook values read through nnsight, keyed by nnterp name.
engine = NNsight(hf_model)
engine_values = {}
with engine.trace(ids):
for i, block in enumerate(engine.transformer.h):
engine_values[f"layers_input[{i}]"] = block.input.save()
engine_values[f"attentions_output[{i}]"] = block.attn.output[0].save()
engine_values[f"mlps_input[{i}]"] = block.mlp.input.save()
engine_values[f"mlps_output[{i}]"] = block.mlp.output.save()
engine_values[f"layers_output[{i}]"] = block.output.save()
engine_values["lm_head.output"] = engine.lm_head.output.save()
# The reference side: one TorchLens capture, sites found by the same names.
trace = tl.trace(hf_model, ids, save="all")
mismatched = []
for name, engine_value in engine_values.items():
hit = mi.resolve_alias(trace, name)
reference = trace.modules[hit.module_address].facets[hit.facet]
if not torch.allclose(torch.as_tensor(engine_value), reference, atol=1e-6, rtol=1e-5):
mismatched.append(name)
RESULT = (len(engine_values), mismatched)
An empty mismatched list means every engine hook read the tensor its name promises. For
a serving engine, do the same with its hooks on a small model it can load, in eval mode
and with the dtype and device it serves in, and set the tolerance to the precision the
engine runs at; a mismatch names the site to investigate before you trust that hook at
scale.