Skip to content

Attention

The paged-attention kernel never materializes the score matrix, so attn_probs is not a location on any vLLM model. Everything around the kernel is: the fused projection, q and k after QK-norm and RoPE, v, and the per-head output before W_O. The pattern is rebuilt from q and k, inside the worker, in a few lines.

q, k, v

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)
attn = model.model.layers[10].self_attn

with model.trace("The capital of France is", temperature=0.0):
    (q, k, v), _ = attn.attn.inputs                 # after QK-norm and RoPE; what the kernel gets
    q, k, v = q.clone().save(), k.clone().save(), v.clone().save()

print(q.shape, k.shape, v.shape)                    # [pos, 32*128], [pos, 8*128], [pos, 8*128]
# torch.Size([5, 4096]) torch.Size([5, 1024]) torch.Size([5, 1024])

attn.qkv_proj.output[0] is the same three before norm and RoPE, fused into [pos, 6144]; split it as [4096, 1024, 1024].

Scores and probabilities

Rebuilt from the tensors the kernel was handed. Qwen3-8B has 32 query heads over 8 KV heads, so each key head serves four query heads.

import torch
from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)
attn = model.model.layers[10].self_attn
n_heads, n_kv, d = 32, 8, 128


def pattern(q, k):
    T = q.shape[0]
    qh = q.view(T, n_heads, d).transpose(0, 1).float()                        # [H, T, d]
    kh = k.view(T, n_kv, d).transpose(0, 1).repeat_interleave(n_heads // n_kv, 0).float()
    scores = (qh @ kh.transpose(-1, -2)) * d ** -0.5
    scores.masked_fill_(torch.ones(T, T, dtype=torch.bool, device=q.device).triu(1), float("-inf"))
    return scores, scores.softmax(-1)


with model.trace("The capital of France is", temperature=0.0):
    (q, k, v), _ = attn.attn.inputs
    scores, probs = pattern(q, k)
    scores, probs = scores.save(), probs.save()                             # [H, dest, src]

print(probs.shape, [round(p, 3) for p in probs[0, -1].tolist()])
# torch.Size([32, 5, 5]) [0.904, 0.02, 0.018, 0.019, 0.039]

Against HuggingFace's eager attention on the same prompt this matches to bf16 noise (max difference 0.026, cosine 0.99998). pattern is an ordinary function, shipped with the block and run in the worker, so only what you saved crossed back.

Per-head outputs and direct attribution

o_proj.input is z: every head's output, before W_O, head h at columns h*128:(h+1)*128. probs @ v == z holds head by head, which is what direct feature attribution needs.

import torch
from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)
attn = model.model.layers[10].self_attn
n_heads, n_kv, d = 32, 8, 128

with model.trace("The capital of France is", temperature=0.0):
    (q, k, v), _ = attn.attn.inputs
    z = attn.o_proj.input.clone().save()                                     # [pos, 32*128]
    _, probs = pattern(q, k)
    vh = v.view(-1, n_kv, d).transpose(0, 1).repeat_interleave(n_heads // n_kv, 0)
    z_rebuilt = (probs.to(v.dtype) @ vh).transpose(0, 1).reshape(-1, n_heads * d).save()

print((z - z_rebuilt).abs().max().item())
# 0.00390625

A head's contribution to the residual is its slice of z through its slice of W_O:

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)
attn = model.model.layers[10].self_attn
head, d = 7, 128

with model.trace("The capital of France is", temperature=0.0):
    z = attn.o_proj.input
    W_O = attn.o_proj.weight                                                 # [4096, 4096]
    contribution = (z[:, head * d:(head + 1) * d] @ W_O[:, head * d:(head + 1) * d].T).save()

print(contribution.shape)                                                    # [pos, d_model]
# torch.Size([5, 4096])

Several layers

Read in forward order within one block.

from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)

with model.trace("The capital of France is", temperature=0.0):
    patterns = {}
    for i in (0, 10, 20):
        (q, k, _), _ = model.model.layers[i].self_attn.attn.inputs
        patterns[i] = pattern(q, k)[1]
    patterns = patterns.save()

print({i: tuple(p.shape) for i, p in patterns.items()})
# {0: (32, 5, 5), 10: (32, 5, 5), 20: (32, 5, 5)}

While generating

On a decode step q has one row; k and v have one row too — the rest of the keys live in the paged KV cache, which is not a module. A pattern over the whole context on a decode step therefore needs the keys you kept from earlier steps:

import torch
from nnsight.modeling.vllm import VLLM

model = VLLM("Qwen/Qwen3-8B", dispatch=True)
attn = model.model.layers[10].self_attn
n_heads, n_kv, d = 32, 8, 128

with model.trace("The capital of France is", temperature=0.0, max_tokens=4) as tracer:
    keys, last_row = [], list().save()
    for _ in tracer.iter[:4]:
        (q, k, _), _ = attn.attn.inputs
        keys.append(k.clone())
        K = torch.cat(keys)                                                  # every key so far
        qh = q[-1:].view(1, n_heads, d).transpose(0, 1).float()
        kh = K.view(-1, n_kv, d).transpose(0, 1).repeat_interleave(n_heads // n_kv, 0).float()
        last_row.append(((qh @ kh.transpose(-1, -2)) * d ** -0.5).softmax(-1)[:, 0])

print([tuple(r.shape) for r in last_row])                                    # [H, src] grows by one
# [(32, 5), (32, 6), (32, 7), (32, 8)]

Under tensor parallelism

Heads are sharded across ranks; nnsight gathers qkv_proj.output and o_proj.input before your block reads them, so the code above is unchanged at tensor_parallel_size=2. The gathered fused tensor is in rank order — [q0 k0 v0 | q1 k1 v1] — so split it by head, not by [:q_size]. See Tensor parallelism.