Skip to content
View as Markdown

Layouts

Every value has one axis layout, the same on every family: attention_probabilities is [batch, num_heads, query, key] on GPT-2, Llama and BLOOM alike. The layout is a named jaxtyping type from nnterp.components (Residual, Pattern, Queries, ...), read off the value as value.layout, with its axes as value.dims.

from nnterp import Attention, Layer, StandardizedTransformer
from nnterp.components import Pattern, Queries, Residual

print(Layer.layer_output.dims, Layer.layer_output.layout is Residual)
print(Attention.attention_queries.dims)

model = StandardizedTransformer("openai-community/gpt2", dispatch=True, device="cpu", attn_implementation="eager")
with model.trace("The Eiffel Tower is in"):
    q = model.layers[0].self_attn.attention_queries.save()
    pattern = model.layers[0].self_attn.attention_probabilities.save()
    out = model.layers[0].layer_output.save()
print(tuple(q.shape), tuple(pattern.shape), tuple(out.shape))
print(isinstance(q, Queries), isinstance(pattern, Pattern), isinstance(out, Pattern))
print(isinstance(q[0], Queries), isinstance(q.long(), Queries))
# ('batch', 'seq', 'hidden_size') True
# ('batch', 'num_heads', 'seq', 'qk_head_dim')
# (1, 12, 7, 64) (1, 12, 7, 7) (1, 7, 768)
# True True False
# False False

isinstance(tensor, value.layout) checks rank and dtype, not axis sizes: q[0] fails on rank and q.long() on dtype. Read the layout off the class (Attention.attention_queries) or off the instance's type (type(model.layers[0].self_attn).attention_queries). A family that redefines a value annotates it with the same name, so the layout cannot drift from the base.

The names

The values GPT-2 serves carry these layouts, and print(model) shows each value's beside its description (Names).

layout axes values
Residual batch seq hidden_size layer_input, layer_output, attention_output, mlp_output, token_embeddings, the block's norm boundaries
Logits batch seq vocab_size logits, lm_head_output
NextTokenProbs batch vocab_size next_token_probs
Tokens batch seq, integer input_ids, attention_mask
Queries batch num_heads seq qk_head_dim attention_queries
Keys batch num_kv_heads seq qk_head_dim attention_keys
Values batch num_kv_heads seq head_dim attention_values
Pattern batch num_heads query key attention_scores, attention_probabilities
HeadOutputs batch seq num_heads head_dim attention_head_outputs, attention_premix
Neurons batch seq intermediate_size mlp_activation, mlp_neurons

There are 37 names in all. The mixture's (RouterLogits, ExpertWeights, ...), the recurrent mixers' (State, ScanQK, SSDValues, ...) and the vision tower's (Patches, ImageFeatures) are on their Guide pages and defined beside the envoy that serves them under the Documentation tab (components). nnterp.components exports 34 of them; the root's three, Logits, NextTokenProbs and Tokens, are in nnterp.standardized. A value with no tensor annotation, such as input_size, has layout and dims of None.

Axes named as sizes

An axis that is a fact about the model is named exactly as the size attribute that holds it (hidden_size, vocab_size, num_heads, num_kv_heads, head_dim, qk_head_dim, intermediate_size, num_experts, top_k), and nnterp.axis_size(envoy, axis) resolves it: the attribute on the envoy's class when it declares one, else on the first envoy above it that does, up to the root. The names are constants in nnterp.components (HEADS is "num_heads").

from nnterp import axis_size

attn = model.layers[0].self_attn
print(axis_size(attn, "num_heads"), axis_size(attn, "num_kv_heads"), axis_size(attn, "head_dim"))
print(axis_size(model.layers[0], "hidden_size"), axis_size(model, "vocab_size"))
print({axis: axis_size(attn, axis) for axis in Attention.attention_keys.dims if axis not in ("batch", "seq")})
try:
    axis_size(attn, "key")
except ValueError as error:
    print(error)
# 12 12 64
# 768 50257
# {'num_kv_heads': 12, 'qk_head_dim': 64}
# axis 'key' of a value on model.transformer.h.0.attn is not a fact about the model: no envoy from there up to the root holds a size by that name

num_heads on an attention value is that block's own count, which differs from the root's on Gemma-4, MiMo-V2-Flash and Laguna. The other axes (batch, seq, query, key, a mixer's heads, key_dim, state_dim, ...) are not facts about the model, so axis_size refuses them and their size is the tensor's.

The sequence axis

Batch is axis 0 everywhere. The sequence axis is 1 on every value that has one, except the queries, keys and values of softmax attention, where it is 2, the [batch, num_heads, seq, head_dim] layout transformers hands its attention interface. attention_head_outputs is back to sequence first, and a pattern has two token axes, query then key. The last token is therefore out[:, -1] for a residual-stream value, q[:, :, -1] for the queries, and pattern[:, :, -1, :] for the last query row.

print(tuple(out[:, -1].shape), tuple(q[:, :, -1].shape), tuple(pattern[:, :, -1, :].shape))
# (1, 768) (1, 12) (1, 12, 7)

Under grouped-query attention the keys and values are read before repeat_kv, so their head axis is num_kv_heads wide: on Qwen3-0.6B the queries come out (1, 16, seq, 128) and the keys (1, 8, seq, 128). GPT-2 has no grouping, so the two counts coincide above.

The exceptions

On three families one value has a layout of its own. On DeepSeek-V4 layer_output (and layer_input) is Streams, [batch, seq, streams, hidden_size], because the block carries parallel copies of the residual stream; the contributions stay Residual, and code written for Residual runs on a Streams value without an error and answers per stream. On Kimi-Linear linear_attn.decays is ChannelGates, [batch, seq, heads, key_dim], one decay per key channel instead of the Gates [batch, seq, heads] of the other DeltaNet families. On OPT layer_mid is FlatResidual, [tokens, hidden_size], the stream with batch and sequence flattened as its block holds it around the feed-forward path.

from nnterp.components import ChannelGates, FlatResidual, Streams
from nnterp.families import deepseek_v4, kimi_linear, opt

print(deepseek_v4.Layer.layer_output.layout is Streams, deepseek_v4.Layer.layer_output.dims)
print(kimi_linear.LinearAttention.decays.layout is ChannelGates, kimi_linear.LinearAttention.decays.dims)
print(opt.Layer.layer_mid.layout is FlatResidual, opt.Layer.layer_mid.dims)
# True ('batch', 'seq', 'streams', 'hidden_size')
# True ('batch', 'seq', 'heads', 'key_dim')
# True ('tokens', 'hidden_size')

Where a recipe must run across families, check value.layout (or the tensor's rank) rather than assuming Residual. A value of your own takes a name the same way, as its return annotation (Custom values).