This is a large jump, about 10 months. There are enough changes that I'm disabling all of the new lints. One notable feature is that ruff now formats Python within .md files, I had to correct a few sections that were actually Bazel. This makes some changes to the wrapper to respect .md files, and to fix errors being swallowed. We no longer use `--quiet` and instead filter the output ourselves. MODULAR_ORIG_COMMIT_REV_ID: 682865af1c774db9426438626d45425c2191239d
8.7 KiB
Reading Hugging Face modeling code
The HF modeling_<type>.py file is the ground truth for what your MAX
implementation must compute. You read it while building the delta list,
while implementing the graph, and again during the divergence hunt.
Locating the file
import importlib
mod = importlib.import_module(
"transformers.models.<model_type>.modeling_<model_type>"
)
print(mod.__file__)
For models with trust_remote_code=True, the modeling code lives in the
Hugging Face cache under ~/.cache/huggingface/hub/<repo>/snapshots/....
What to read, in order
Read the file top-down following the class hierarchy. For a typical causal LM, the relevant classes are:
<ModelType>Config— already covered when you readconfig.json.<ModelType>Attention— how Q/K/V are computed and combined.<ModelType>MLP— the feedforward layer.<ModelType>DecoderLayer— how attention + MLP + norms are wired.<ModelType>Model— the embedding, layer stack, final norm.<ModelType>ForCausalLM— the LM head and the loss.
You're looking at __init__ (what layers exist) and forward (how they
compose). Skip everything else.
What to look for in __init__
This is the easiest delta hunt. Compare the list of self.xxx = …
assignments to the equivalent class in the MAX architecture you're starting
from.
Common findings:
- Extra norms —
self.q_norm = nn.RMSNorm(...),self.k_norm = ...indicates QK-norm. Two extra norms inside attention; their dim is eitherhead_dim(Olmo2-style, per-head) orhidden_size(full Q/K). - Extra projections —
self.q_a_proj,self.q_b_proj,self.kv_a_proj_with_mqa,self.kv_b_projis MLA. Two-stage Q projection, two-stage KV projection with a latent in the middle. - Router —
self.gate = nn.Linear(hidden, num_experts, bias=False)is MoE. Followed byself.experts = nn.ModuleList([...]). - Side embeddings —
self.alibi_bias,self.rotary_embat the attention level (vs. module level) signals positional encoding variants.
What to look for in attention forward
This is where most architectures diverge. Read line by line:
- Q/K/V computation — are they separate (
self.q_proj(x), ...) or fused (self.qkv_proj(x).chunk(3))? The weight adapter needs to match. - Shape reshaping —
view(bsz, q_len, num_heads, head_dim)is standard. If you seeview(bsz, q_len, num_heads, 2, head_dim // 2), that's interleaved RoPE. - QK norm —
q = self.q_norm(q)before the dot product. Check the dim the norm acts on (head_dim is per-head, hidden_size is full). - RoPE application —
apply_rotary_pos_emb(q, k, cos, sin). Look at the rotary embedding class: does it userotate_half(split-half) or some variant? Is RoPE applied to only part of the head (q[..., :rope_dim]vs. fullq)? - GQA repeat — for
num_key_value_heads < num_attention_heads, K and V get repeated.repeat_kv(key_states, self.num_key_value_groups). - Mask — sliding window vs. causal vs. sink-token. Check whether the layer index conditionally selects between two masks.
- Softmax scale —
attn_weights / math.sqrt(self.head_dim)is default. Some models use a different scaling (1 / dfor normalized attention, MuPattention_multiplier). - Softcap —
attn_weights = soft_cap * torch.tanh(attn_weights / soft_cap)sits between the score and the softmax in Gemma 2. - Output projection —
self.o_proj(attn_output), usually unremarkable.
What to look for in MLP forward
Most MLPs are one of three shapes:
- Gated SwiGLU (Llama-family):
down_proj(silu(gate_proj(x)) * up_proj(x)). - Standard two-layer (GPT-style):
fc2(act(fc1(x))). Activation may begelu,gelu_new,gelu_tanh,relu. - MoE (Mixtral/Qwen3-MoE/DeepSeek):
gate(x)→top_k→ per-expert SwiGLU → weighted sum. May include a "shared expert" applied to every token in addition to the routed experts.
Things that bite:
- Activation name.
gelu_newandgelu_tanhand plaingeluare not the same function. CheckACT2FNintransformers/activations.py. - Bias. Most modern models have
mlp_bias=False, but GPT-NeoX, OPT, and some embedders have biases. - Up/gate fusion. Some implementations fuse
gate_projandup_projinto a singlegate_up_projwithchunk(2); the weight adapter must match.
What to look for in the block forward
The block class composes attention and MLP with norms and residual connections. Three common patterns:
-
Pre-norm (Llama, Mistral, Qwen, default):
h = x + attn(input_layernorm(x)) out = h + mlp(post_attention_layernorm(h)) -
Post-norm (some older models, EXAONE 4):
h = x + post_attention_layernorm(attn(x)) out = h + post_feedforward_layernorm(mlp(h)) -
Peri-LN / dual norm (Gemma 2, HyperCLOVAX): both pre-norm and an additional norm on the sublayer output before residual add:
h = x + post_norm1(attn(input_layernorm(x))) out = h + post_norm2(mlp(post_attention_layernorm(h)))
If your model is anything other than the first pattern, the stock MAX
TransformerBlock will not match — see "Choosing an edit strategy" below.
What to look for in the final head
Read <ModelType>ForCausalLM.forward, not only __init__. The LM head
class is often a plain nn.Linear, but the forward pass may scale hidden
states before the matmul.
Search for lm_head( and pre-head scaling
In the causal-LM forward, find the line that produces logits. Grep for
lm_head( and read the expression passed in:
pixi run rg -n 'lm_head\(' modeling_<type>.py
Flag anything that is not a bare hidden_states (or outputs[0]):
- Width divisor —
self.lm_head(h / (hidden_size / dim_model_base)),h / self.scale_width, orh * (dim_model_base / hidden_size). Common on MiniCPM-family and some Cohere-style configs.dim_model_baseis often smaller thanhidden_size; missing the divisor makes logits wrong with no load error. - MuP
logits_scaling— multiply or divide logits afterlm_head(see divergences.md §13). Do not confuse with a pre-head width divisor; check order in HF. - Final logit softcap — Gemma 2:
softcap * tanh(logits / softcap)after the linear.
Record the exact formula in your delta list (scalar name, config keys, and
whether scaling happens before or after lm_head). In MAX, mirror that
order in <slug>.py — usually ops.mul / ops.div on the last hidden
state before the output Linear, not only a post-hoc logits tweak.
Other head variants
- LM head —
self.lm_head = nn.Linear(hidden, vocab, bias=False). Ifconfig.tie_word_embeddings=True, the LM head reuses the embedding matrix. - Multi-step head — some models apply additional layers between the
final block and the LM head (
LayerNorm → Linear → activation → Linear(vocab)). The MAX template likely ends with a single Linear; you need to add the extra layers.
Choosing an edit strategy
Given your delta list, pick one of three approaches:
| What differs | Strategy |
|---|---|
| Only weight names; computation identical | Edit weight_adapters.py only |
| One sublayer differs (custom MLP, QK-norm) | Subclass the layer, override one method |
| One attention variant (sliding window, MLA, softcap) | Subclass Attention, override __call__ |
| Block layout differs (post-norm, peri-LN) | Subclass TransformerBlock, override __call__ |
| Multi-step head | Subclass the top-level model, override the head |
| Attention is fundamentally new (recurrence, state-space) | Write from scratch with max.nn primitives |
| MoE routing differs from existing MAX MoE archs | Write from scratch |
Prefer subclassing. Every layer you write from scratch is a layer you can get wrong; every layer you inherit is a layer that already passed someone else's parity check.
Inheritance traps
HF modeling code often inherits across model families in non-obvious ways. Before you conclude "this is just Llama with renamed fields":
- Read the full MRO of the class.
print(MyClass.__mro__). - Check whether any parent overrides
forwardor__init__in ways your class inherits silently. - Look for
if config.use_xxx:branches in parent classes that your model activates via config.
The class hierarchy is where the easter eggs live.