Skip to content

Commit 7d76652

Browse files
committed
Merge remote-tracking branch 'origin/main' into justinchu/bio-models
Signed-off-by: justinchuby <justinchuby@users.noreply.github.com> # Conflicts: # scripts/generate_golden.py # src/mobius/_registry.py # src/mobius/models/__init__.py # src/mobius/models/bert.py # src/mobius/tasks/__init__.py # testdata/cases/encoder/esm2-8m.yaml # testdata/golden/encoder/esm2-8m.json
2 parents cf0d304 + 3dc671c commit 7d76652

998 files changed

Lines changed: 140383 additions & 23655 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.agents/skills/adding-a-new-model/SKILL.md

Lines changed: 450 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
# Architecture Patterns Reference
2+
3+
Detailed code templates, compatibility rules, and advanced patterns for
4+
non-standard model architectures. Read this when implementing a model that
5+
is **not** a standard decoder-only causal LM.
6+
7+
## Non-LLM model type table
8+
9+
For models that aren't causal LMs, use the appropriate base class and task:
10+
11+
| Model type | Base class / pattern | Task | Config |
12+
|------------|---------------------|------|--------|
13+
| Encoder-only (BERT-like) | `BertModel` | `feature-extraction` | `ArchitectureConfig` |
14+
| Encoder-only (ModernBERT) | `ModernBertModel` | `feature-extraction` | `ArchitectureConfig` |
15+
| Encoder-decoder (BART/T5-like) | `BartForConditionalGeneration` or `T5ForConditionalGeneration` | `seq2seq` | `ArchitectureConfig` |
16+
| Vision (ViT-like) | `ViTModel` or `CLIPVisionModel` | `image-classification` | `ArchitectureConfig` |
17+
| Object detection | `YolosForObjectDetection` | `object-detection` | `ArchitectureConfig` |
18+
| Depth estimation | `DepthAnythingForDepthEstimation` | `image-classification` | `ArchitectureConfig` |
19+
| Segmentation | `SegformerForSemanticSegmentation` or `Sam2VisionModel` | `image-classification` | `ArchitectureConfig` |
20+
| Audio encoder (Wav2Vec2-like) | `Wav2Vec2Model` | `audio-feature-extraction` | `ArchitectureConfig` |
21+
| Multimodal (LLaVA-like) | `LLaVAModel` | `vision-language` | `ArchitectureConfig` |
22+
| Document AI | `LayoutLMv3Model` | `feature-extraction` | `ArchitectureConfig` |
23+
| OCR decoder | `TrOCRForConditionalGeneration` | `seq2seq` | `ArchitectureConfig` |
24+
| Diffusion denoiser | Custom (`UNet2DConditionModel`, etc.) | `denoising` | Custom config (e.g. `UNet2DConfig`) |
25+
| VAE | `AutoencoderKLModel` | `vae` | `VAEConfig` |
26+
| Adapter | `T2IAdapterModel` / `IPAdapterModel` | `adapter` | Custom config |
27+
28+
Many new models can be registered as aliases of existing classes (e.g.
29+
`reg.register("my_bert_variant", BertModel)`) if the architecture matches.
30+
31+
## False Compatibility Pitfalls
32+
33+
When registering models as aliases of existing base classes, **tests passing
34+
does not mean the mapping is correct.** Graph-build tests only check that an
35+
ONNX graph can be constructed — they do NOT verify that the graph matches
36+
the model's actual computation.
37+
38+
### Safe approximate mappings
39+
40+
The project accepts "approximate" registry aliases when the model uses
41+
similar-but-not-identical attention. These produce structurally correct ONNX
42+
graphs; weight-loading may need minor adjustments:
43+
44+
| Model | Maps to | Why it works |
45+
|-------|---------|-------------|
46+
| DeBERTa | `BertModel` | Disentangled attention is a variant of standard attention |
47+
| Swin | `ViTModel` | Shifted window attention is still self-attention over patches |
48+
| SqueezeBERT | `BertModel` | Grouped convolution replaces dense attention, but same I/O shape |
49+
50+
### NEVER safe as registry aliases
51+
52+
These model families have fundamentally different computation that **cannot**
53+
be represented by standard base classes, even though `build_graph_test` passes:
54+
55+
| Category | Models | Why it fails |
56+
|----------|--------|-------------|
57+
| Pure CNNs | ConvNeXt, ResNet, MobileNet, EfficientNet, RegNet | No attention at all — base ViT/BERT classes produce attention-based graphs |
58+
| Spatial pooling | PoolFormer | Uses spatial average pooling instead of attention — structurally incompatible |
59+
| SSM / state-space models | Mamba, Mamba2, FalconMamba, RWKV, RecurrentGemma | Sequential scan / linear recurrence, not attention |
60+
| Fundamentally different attention | Longformer (sparse), BigBird (block sparse), Funnel (downsampling) | Attention pattern differs from dense self-attention at a structural level |
61+
| Custom tokenization | CANINE (character-level) | Byte-level input, hash embeddings — not a standard vocab embedding |
62+
63+
**Rule of thumb:** If the HuggingFace model's `forward()` method doesn't call
64+
`self_attn(query, key, value)` in a standard way, it is NOT a safe alias.
65+
66+
### Future work
67+
68+
CI currently only runs graph-build tests (shape inference, op validity). To
69+
catch false compatibility in approximate mappings, we need **weight-loading
70+
tests** that:
71+
1. Load real HuggingFace weights into the ONNX graph
72+
2. Run inference on a test input
73+
3. Compare output against HuggingFace PyTorch output
74+
4. Fail if max abs diff exceeds a threshold (e.g. 0.01)
75+
76+
This would catch shape mismatches, wrong norm types, and missing scaling
77+
factors that graph-build tests cannot detect.
78+
79+
## KV sharing across layers (num_kv_shared_layers)
80+
81+
Some models (e.g. Gemma 4) reduce parameter count by having the last N
82+
decoder layers **borrow** Key and Value states from an earlier "source" layer
83+
of the same type instead of projecting their own K,V. This is controlled by
84+
`num_kv_shared_layers` in the HuggingFace config.
85+
86+
### What it means
87+
88+
```
89+
first_kv_shared_idx = num_hidden_layers - num_kv_shared_layers
90+
91+
Layers [0 .. first_kv_shared_idx - 1]: normal — own k_proj, v_proj, k_norm
92+
Layers [first_kv_shared_idx .. end]: shared — NO k_proj/v_proj weights
93+
```
94+
95+
Each shared layer reuses K,V from the **last non-shared layer of the same
96+
attention type** (e.g. sliding vs. full attention). Only Q is computed fresh.
97+
98+
### Impact on the checkpoint
99+
100+
Shared layers have **no `k_proj`, `v_proj`, `k_norm`** keys in the
101+
HuggingFace checkpoint. `preprocess_weights` must not assert these keys
102+
exist for shared-layer indices — they simply won't be present.
103+
104+
```python
105+
def preprocess_weights(self, state_dict):
106+
# shared layers have no k/v proj — remove them silently if accidentally present
107+
first_shared = self.config.num_hidden_layers - self.config.num_kv_shared_layers
108+
for i in range(first_shared, self.config.num_hidden_layers):
109+
for suffix in ("k_proj.weight", "v_proj.weight", "k_norm.weight"):
110+
state_dict.pop(f"model.layers.{i}.self_attn.{suffix}", None)
111+
return super().preprocess_weights(state_dict)
112+
```
113+
114+
### Attention module: is_kv_shared_layer flag
115+
116+
The attention class detects at `__init__` time whether it is a shared layer:
117+
118+
```python
119+
class Gemma4Attention(nn.Module):
120+
def __init__(self, config, layer_idx, layer_types, first_kv_shared_idx, ...):
121+
self.is_kv_shared_layer = layer_idx >= first_kv_shared_idx > 0
122+
prev_layers = layer_types[:first_kv_shared_idx]
123+
124+
if self.is_kv_shared_layer:
125+
# Index of the source layer whose K,V this layer borrows
126+
self.kv_shared_layer_index = (
127+
len(prev_layers) - 1 - prev_layers[::-1].index(layer_types[layer_idx])
128+
)
129+
self.store_full_length_kv = False
130+
else:
131+
self.kv_shared_layer_index = None
132+
# True for the last non-shared layer of each type that has downstream
133+
# KV-shared layers depending on it — it stores K,V for reuse.
134+
self.store_full_length_kv = first_kv_shared_idx > 0 and (
135+
layer_idx
136+
== len(prev_layers) - 1 - prev_layers[::-1].index(layer_types[layer_idx])
137+
)
138+
139+
# All layers have Q projection
140+
self.q_proj = Linear(config.hidden_size, num_heads * head_dim)
141+
self.q_norm = RMSNorm(head_dim)
142+
self.o_proj = Linear(num_heads * head_dim, config.hidden_size)
143+
144+
# Only non-shared layers have K/V projections
145+
if not self.is_kv_shared_layer:
146+
self.k_proj = Linear(config.hidden_size, num_kv_heads * head_dim)
147+
self.v_proj = Linear(config.hidden_size, num_kv_heads * head_dim)
148+
self.k_norm = RMSNorm(head_dim)
149+
```
150+
151+
### forward(): shared layers consume shared_kv_states dict
152+
153+
Pass a mutable `shared_kv_states` dict through the forward call. Source
154+
layers populate it; shared layers read from it:
155+
156+
```python
157+
def forward(self, op, hidden_states, ..., shared_kv_states, past_key_value):
158+
# Q projection (all layers)
159+
query_states = self.q_proj(op, hidden_states)
160+
...
161+
162+
if self.is_kv_shared_layer:
163+
# Borrow K,V from source layer (already in shared_kv_states)
164+
src_key, src_value = shared_kv_states[self.kv_shared_layer_index]
165+
# Reshape from present_kv 4D [B, kv_heads, total_seq, head_dim]
166+
# to Attention input 3D [B, total_seq, kv_heads * head_dim]
167+
src_key = op.Transpose(src_key, perm=[0, 2, 1, 3])
168+
key_states = op.Reshape(src_key, ...)
169+
value_states = ...
170+
else:
171+
# Normal K/V projection + norm
172+
key_states = self.k_proj(op, hidden_states)
173+
value_states = self.v_proj(op, hidden_states)
174+
...
175+
176+
hidden_out, present_kv = _apply_attention(op, query_states, key_states, ...)
177+
178+
if self.store_full_length_kv:
179+
# Store present_kv [B, kv_heads, total_seq, head_dim] for downstream shared layers
180+
shared_kv_states[self.layer_idx] = (present_kv_key, present_kv_value)
181+
182+
return hidden_out, present_kv
183+
```
184+
185+
### Text model: KV cache has only num_kv_layers entries
186+
187+
KV-shared layers do **not** append to `present_key_values`. The output list
188+
has `num_hidden_layers - num_kv_shared_layers` entries, not `num_hidden_layers`:
189+
190+
```python
191+
# In Gemma4TextModel.forward():
192+
shared_kv_states: dict = {}
193+
present_key_values = []
194+
195+
# past_key_values has only num_kv_layers entries (no entry for KV-shared layers).
196+
# Expand it to a full per-layer list so we can zip cleanly over all layers.
197+
if past_key_values is not None:
198+
kv_iter = iter(past_key_values)
199+
past_kvs: list = [
200+
None if layer.self_attn.is_kv_shared_layer else next(kv_iter)
201+
for layer in self.layers
202+
]
203+
else:
204+
past_kvs = [None] * len(self.layers)
205+
206+
for i, (layer, layer_type, past_kv) in enumerate(
207+
zip(self.layers, self.layer_types, past_kvs)
208+
):
209+
hidden_states, present_kv = layer(
210+
op,
211+
hidden_states=hidden_states,
212+
attention_bias=attention_bias_dict[layer_type],
213+
position_embeddings=position_embeddings_dict[layer_type],
214+
shared_kv_states=shared_kv_states,
215+
past_key_value=past_kv,
216+
)
217+
# KV-shared layers borrow K,V — exclude from present_key_values so the
218+
# output has exactly num_kv_layers (not num_hidden_layers) entries.
219+
if not layer.self_attn.is_kv_shared_layer:
220+
present_key_values.append(present_kv)
221+
```
222+
223+
The task's KV cache inputs/outputs must use the correct count:
224+
`num_kv_layers = config.num_hidden_layers - config.num_kv_shared_layers`.
225+
226+
## Reference implementations
227+
228+
| Model | File | Key differences from base |
229+
|-------|------|--------------------------|
230+
| Granite | `models/granite.py` | 4 scaling multipliers, custom attention scale |
231+
| OLMo-1B | `models/olmo.py` | Weight-free LayerNorm (not RMSNorm), eps=1e-5 |
232+
| OLMo-2 | `models/olmo.py` | Post-norm decoder layers, QK full norm |
233+
| Gemma | `models/gemma.py` | RMSNorm weight+1, embedding scaling |
234+
| Whisper | `components/_whisper.py` | Q pre-scaling, LayerNorm eps=1e-5, is_causal attr |
235+
| Phi3.5 | `components/_rotary_embedding.py` | LongRope with float32 factors |
236+
| Qwen3.5 | `models/qwen.py` | Hybrid DeltaNet + full attention, gated GQA, OffsetRMSNorm, interleaved MRoPE |
237+
| Qwen3.5-MoE | `models/qwen.py` | Same hybrid attention + MoE FFN with shared expert (sigmoid gate) |
238+
| Qwen3-TTS | `models/qwen3_tts.py` | 4-model TTS split, 2-token code predictor prefill, small_to_mtp projection, Identity-exposed weights |
239+
| **BLIP** | `models/blip.py` | Subclass of ViTModel — only `preprocess_weights` (fused QKV split, renaming) |
240+
| **YOLOS** | `models/yolos.py` | ViT + detection tokens + DETR-style MLP heads. New `object-detection` task |
241+
| **Depth Anything** | `models/depth_anything.py` | ViT backbone + DPT decoder (reassemble + fusion + depth head). Uses `ConvTranspose2d` |
242+
| **Segformer** | `models/segformer.py` | Hierarchical 4-stage encoder, efficient attention (strided Conv2d on K/V), Mix-FFN with depthwise conv |
243+
| **SAM2** | `models/sam2.py` | Hiera backbone (per-stage dim transitions, fused QKV attention) + FPN neck with top-down fusion |
244+
| **LayoutLMv3** | `models/layoutlmv3.py` | Subclass of BertModel — only `preprocess_weights` (spatial embedding filtering) |
245+
| **TrOCR** | `models/trocr.py` | Subclass of BartForConditionalGeneration — only `preprocess_weights` (`output_projection` rename) |
246+
| **ModernBERT** | `models/modernbert.py` | Pre-norm encoder with RoPE + GeGLU + bidirectional attention. Fused QKV/Wi splitting. Both encoder and decoder variants |
247+
| **Gemma3n** | `models/gemma3n.py` | AltUp predict/correct, Laurel low-rank, per-layer input gating, hybrid local/global attention |
248+
| **Mllama** | `models/mllama.py` | Interleaved cross-attention decoder, tanh-gated residual, manual QK-norm |
249+
250+
## Reference examples by complexity
251+
252+
When adding a new model, use these files as canonical references:
253+
254+
| Complexity | File | Why |
255+
|---|---|---|
256+
| **Minimal** — base class works, only weight mapping needed | `models/phi3.py` (38 lines) | Extends `CausalLMModel`, only overrides `preprocess_weights()` to split fused QKV and gate-up projections. Shows the simplest possible model addition. |
257+
| **Minimal** — encoder subclass | `models/layoutlmv3.py` | Extends `BertModel`, only overrides `preprocess_weights()`. Same pattern for encoder-only models. |
258+
| **Moderate** — custom components | `models/gemma.py` | Adds custom attention (soft-capping), custom MLP (GeGLU), and custom normalization. Good example of component subclassing. |
259+
| **Complex** — multi-model architecture | `models/qwen3_tts.py` | 4-model TTS split with talker, code predictor, embedding, and speaker encoder sub-modules. Shows how to structure multi-model architectures. |

0 commit comments

Comments
 (0)