|
| 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