Causal tracing¶
ROME-style activation patching (Meng et al., 2022): corrupt the subject's embeddings, then
restore the clean residual stream at one (layer, position) at a time and see how much of the
answer comes back. The result is a (layers × positions) map of where the fact is carried.
Three passes on Qwen/Qwen3-8B: a clean run that keeps every layer's residual stream, a
corrupted run, and then one request per (layer, position) — all of a layer's positions batched
into one trace, which vLLM schedules together.
Setup¶
import torch
import nnsight
from nnsight.modeling.vllm import VLLM
model = VLLM("Qwen/Qwen3-8B", dispatch=True)
tok = model.tokenizer
layers = model.model.layers
prompt = "The Eiffel Tower is in the city of"
subject = "Eiffel Tower"
answer = tok.encode(" Paris")[0]
enc = tok(prompt, return_offsets_mapping=True)
T = len(enc["input_ids"])
lo, hi = prompt.index(subject), prompt.index(subject) + len(subject)
subject_pos = [i for i, (a, b) in enumerate(enc["offset_mapping"]) if a < hi and b > lo]
print(T, "tokens; subject at", subject_pos, [tok.decode(enc["input_ids"][i]) for i in subject_pos])
# 10 tokens; subject at [1, 2, 3, 4] [' E', 'iff', 'el', ' Tower']
1. Clean run¶
Keep the residual stream leaving every block, for every position, and the answer probability. The embedding scale sets the noise level: ROME corrupts with three standard deviations of the embeddings.
with model.trace(prompt, temperature=0.0, max_tokens=1):
emb = model.model.embed_tokens.output
sigma = nnsight.save(emb.float().std().item())
clean = list().save()
for layer in layers:
clean.append(sum(layer.output).clone()) # [T, d_model], resid_post
p_clean = nnsight.save(model.logits.float().softmax(-1)[0, answer].item())
print(f"P({tok.decode(answer)!r}) clean = {p_clean:.3f} embedding std = {sigma:.4f}")
# P(' Paris') clean = 0.833 embedding std = 0.0261
2. Corrupted run¶
Seeded noise on the subject rows of the embedding output, in place, before block 0 reads it.
def corrupt():
emb = model.model.embed_tokens.output
g = torch.Generator(device=emb.device).manual_seed(0)
noise = torch.randn(len(subject_pos), emb.shape[-1], generator=g, device=emb.device)
emb[subject_pos] += (3 * sigma * noise).to(emb.dtype)
with model.trace(prompt, temperature=0.0, max_tokens=1):
corrupt()
p_corrupt = nnsight.save(model.logits.float().softmax(-1)[0, answer].item())
guess = model.logits.argmax(-1).save()
print(f"P(answer) corrupted = {p_corrupt:.3f}; the model now says {tok.decode(guess)!r}")
# P(answer) corrupted = 0.000; the model now says ' E'
3. Restore, one (layer, position) per request¶
layers[l].output is (hidden, residual) and the stream is their sum, so writing
hidden[pos] = clean[pos] - residual[pos] restores exactly the clean stream at that position.
One trace per layer, one invoke per position: the ten requests of a layer run as one batch.
recovery = torch.zeros(len(layers), T)
for l, clean_l in enumerate(clean):
layer = layers[l] # bound outside the block: see Performance
with model.trace(temperature=0.0, max_tokens=1) as tracer:
for pos in range(T):
with tracer.invoke(prompt):
corrupt()
hidden, residual = layer.output
hidden[pos] = clean_l[pos] - residual[pos]
p = nnsight.save(model.logits.float().softmax(-1)[0, answer].item())
recovery[l] = (torch.tensor(p) - p_corrupt) / (p_clean - p_corrupt)
chars = " ░▒▓█"
print(" " + "".join(f"{tok.decode(t)[:6]:>7}" for t in enc["input_ids"]))
for l in range(0, len(layers), 3):
row = "".join(f"{chars[int(min(max(v, 0), 0.999) * 5)]:>7}" for v in recovery[l].tolist())
print(f"L{l:02d} {row}")
best = recovery.flatten().topk(3)
for v, i in zip(best.values.tolist(), best.indices.tolist()):
print(f"layer {i // T:2d} pos {i % T} ({tok.decode(enc['input_ids'][i % T])!r}) recovery {v:.2f}")
# The E iff el Tower is in the city of
# L00 █ █
# L03 ▓ █
# L06 ░ █
# L09 ░ █
# L12 ░ █
# L15 █
# L18 ▓
# L21
# L24 ▓
# L27 █
# L30 █
# L33 █
# layer 11 pos 4 (' Tower') recovery 1.08
# layer 12 pos 4 (' Tower') recovery 1.08
# layer 13 pos 4 (' Tower') recovery 1.07
The ROME picture: restoring the last subject token (Tower) in the early and middle layers
brings the answer back, and from layer 24 on only the final position matters. All 360 patched
requests took 5.0 s.
p is one name saved by ten invokes, so it comes back as a list of ten, in position order.
What this costs¶
360 requests. Each trace ships its block once per invoke — a few kilobytes plus the one
[T, d_model] clean slice it references — and vLLM batches the invokes of a trace into as few
steps as they fit. The same experiment through a per-request HTTP hook is 360 round trips with
the hook function and the clean vector cloudpickled into each; see
Comparisons.