@@ -46,8 +46,8 @@ If the logits match HuggingFace, you only need the registry entry.
4646Create ` src/mobius/models/<model_name>.py ` . The minimal template:
4747
4848``` python
49- # Copyright (c) ONNX Project Contributors
50- # SPDX-License-Identifier: Apache-2.0
49+ # Copyright (c) Microsoft Corporation.
50+ # Licensed under the MIT License.
5151
5252from __future__ import annotations
5353
@@ -304,16 +304,28 @@ new model is a significant addition.
304304
305305## Checklist
306306
307- - [ ] Model file in ` src/mobius/models/ ` with Apache-2.0 copyright header
307+ This is the ** implementation** checklist — the steps needed to wire up a new
308+ model in the codebase. For the full ** definition-of-done** quality checklist
309+ (L1–L5 tests, ORT GenAI, Foundry Local, Olive quantization, multi-dtype,
310+ code review), see the
311+ [ quality-checklist skill] ( ../quality-checklist/SKILL.md ) .
312+
313+ - [ ] Model file in ` src/mobius/models/ ` with Microsoft MIT copyright header
308314- [ ] Class has ` default_task ` and ` category ` attributes (if not standard text-generation)
309315- [ ] Class has a descriptive docstring (first paragraph used in generated docs)
310316- [ ] ` preprocess_weights ` handles any key mismatches
311317- [ ] Registered in ` _create_default_registry() `
312318- [ ] Exported from ` models/__init__.py `
313319- [ ] Config extraction works (` ArchitectureConfig.from_transformers ` )
314320- [ ] Tiny config in ` tests/_test_configs.py ` (with ` is_representative ` flag)
315- - [ ] Integration test model in ` tests/integration_test.py ` (if small checkpoint available)
321+ - [ ] L2 YAML test case in ` testdata/cases/ ` with ` test_model_id `
322+ - [ ] L3 synthetic parity passes (` tests/synthetic_parity_test.py -k "<model_type>" ` )
323+ - [ ] Integration test model in ` tests/integration_test.py ` (real-weight integration suite, if small checkpoint available)
324+ - [ ] L4 golden file generated and committed (` testdata/golden/ ` )
325+ - [ ] L5 generation golden file generated and committed
326+ - [ ] ORT GenAI test added to ` tests/ort_genai_test.py ` (text-generation and VLM models)
316327- [ ] CLI build works (` mobius build --model ... ` )
328+ - [ ] Multi-dtype correctness verified (fp32, fp16, bf16)
317329
318330** Note:** Default optimizer passes (CSE, deduplicate initializers, identity
319331elimination, remove unused nodes/opsets) are applied automatically by
@@ -819,3 +831,150 @@ When adding a new model, use these files as canonical references:
819831| ** Minimal** — encoder subclass | ` models/layoutlmv3.py ` | Extends ` BertModel ` , only overrides ` preprocess_weights() ` . Same pattern for encoder-only models. |
820832| ** Moderate** — custom components | ` models/gemma.py ` | Adds custom attention (soft-capping), custom MLP (GeGLU), and custom normalization. Good example of component subclassing. |
821833| ** 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. |
834+
835+ ## KV sharing across layers (num_kv_shared_layers)
836+
837+ Some models (e.g. Gemma 4) reduce parameter count by having the last N
838+ decoder layers ** borrow** Key and Value states from an earlier "source" layer
839+ of the same type instead of projecting their own K,V. This is controlled by
840+ ` num_kv_shared_layers ` in the HuggingFace config.
841+
842+ ### What it means
843+
844+ ```
845+ first_kv_shared_idx = num_hidden_layers - num_kv_shared_layers
846+
847+ Layers [0 .. first_kv_shared_idx - 1]: normal — own k_proj, v_proj, k_norm
848+ Layers [first_kv_shared_idx .. end]: shared — NO k_proj/v_proj weights
849+ ```
850+
851+ Each shared layer reuses K,V from the ** last non-shared layer of the same
852+ attention type** (e.g. sliding vs. full attention). Only Q is computed fresh.
853+
854+ ### Impact on the checkpoint
855+
856+ Shared layers have ** no ` k_proj ` , ` v_proj ` , ` k_norm ` ** keys in the
857+ HuggingFace checkpoint. ` preprocess_weights ` must not assert these keys
858+ exist for shared-layer indices — they simply won't be present.
859+
860+ ``` python
861+ def preprocess_weights (self , state_dict ):
862+ # shared layers have no k/v proj — remove them silently if accidentally present
863+ first_shared = self .config.num_hidden_layers - self .config.num_kv_shared_layers
864+ for i in range (first_shared, self .config.num_hidden_layers):
865+ for suffix in (" k_proj.weight" , " v_proj.weight" , " k_norm.weight" ):
866+ state_dict.pop(f " model.layers. { i} .self_attn. { suffix} " , None )
867+ return super ().preprocess_weights(state_dict)
868+ ```
869+
870+ ### Attention module: is_kv_shared_layer flag
871+
872+ The attention class detects at ` __init__ ` time whether it is a shared layer:
873+
874+ ``` python
875+ class Gemma4Attention (nn .Module ):
876+ def __init__ (self , config , layer_idx , layer_types , first_kv_shared_idx , ...):
877+ self .is_kv_shared_layer = layer_idx >= first_kv_shared_idx > 0
878+ prev_layers = layer_types[:first_kv_shared_idx]
879+
880+ if self .is_kv_shared_layer:
881+ # Index of the source layer whose K,V this layer borrows
882+ self .kv_shared_layer_index = (
883+ len (prev_layers) - 1 - prev_layers[::- 1 ].index(layer_types[layer_idx])
884+ )
885+ self .store_full_length_kv = False
886+ else :
887+ self .kv_shared_layer_index = None
888+ # True for the last non-shared layer of each type that has downstream
889+ # KV-shared layers depending on it — it stores K,V for reuse.
890+ self .store_full_length_kv = first_kv_shared_idx > 0 and (
891+ layer_idx
892+ == len (prev_layers) - 1 - prev_layers[::- 1 ].index(layer_types[layer_idx])
893+ )
894+
895+ # All layers have Q projection
896+ self .q_proj = Linear(config.hidden_size, num_heads * head_dim)
897+ self .q_norm = RMSNorm(head_dim)
898+ self .o_proj = Linear(num_heads * head_dim, config.hidden_size)
899+
900+ # Only non-shared layers have K/V projections
901+ if not self .is_kv_shared_layer:
902+ self .k_proj = Linear(config.hidden_size, num_kv_heads * head_dim)
903+ self .v_proj = Linear(config.hidden_size, num_kv_heads * head_dim)
904+ self .k_norm = RMSNorm(head_dim)
905+ ```
906+
907+ ### forward(): shared layers consume shared_kv_states dict
908+
909+ Pass a mutable ` shared_kv_states ` dict through the forward call. Source
910+ layers populate it; shared layers read from it:
911+
912+ ``` python
913+ def forward (self , op , hidden_states , ..., shared_kv_states , past_key_value ):
914+ # Q projection (all layers)
915+ query_states = self .q_proj(op, hidden_states)
916+ ...
917+
918+ if self .is_kv_shared_layer:
919+ # Borrow K,V from source layer (already in shared_kv_states)
920+ src_key, src_value = shared_kv_states[self .kv_shared_layer_index]
921+ # Reshape from present_kv 4D [B, kv_heads, total_seq, head_dim]
922+ # to Attention input 3D [B, total_seq, kv_heads * head_dim]
923+ src_key = op.Transpose(src_key, perm = [0 , 2 , 1 , 3 ])
924+ key_states = op.Reshape(src_key, ... )
925+ value_states = ...
926+ else :
927+ # Normal K/V projection + norm
928+ key_states = self .k_proj(op, hidden_states)
929+ value_states = self .v_proj(op, hidden_states)
930+ ...
931+
932+ hidden_out, present_kv = _apply_attention(op, query_states, key_states, ... )
933+
934+ if self .store_full_length_kv:
935+ # Store present_kv [B, kv_heads, total_seq, head_dim] for downstream shared layers
936+ shared_kv_states[self .layer_idx] = (present_kv_key, present_kv_value)
937+
938+ return hidden_out, present_kv
939+ ```
940+
941+ ### Text model: KV cache has only num_kv_layers entries
942+
943+ KV-shared layers do ** not** append to ` present_key_values ` . The output list
944+ has ` num_hidden_layers - num_kv_shared_layers ` entries, not ` num_hidden_layers ` :
945+
946+ ``` python
947+ # In Gemma4TextModel.forward():
948+ shared_kv_states: dict = {}
949+ present_key_values = []
950+
951+ # past_key_values has only num_kv_layers entries (no entry for KV-shared layers).
952+ # Expand it to a full per-layer list so we can zip cleanly over all layers.
953+ if past_key_values is not None :
954+ kv_iter = iter (past_key_values)
955+ past_kvs: list = [
956+ None if layer.self_attn.is_kv_shared_layer else next (kv_iter)
957+ for layer in self .layers
958+ ]
959+ else :
960+ past_kvs = [None ] * len (self .layers)
961+
962+ for i, (layer, layer_type, past_kv) in enumerate (
963+ zip (self .layers, self .layer_types, past_kvs)
964+ ):
965+ hidden_states, present_kv = layer(
966+ op,
967+ hidden_states = hidden_states,
968+ attention_bias = attention_bias_dict[layer_type],
969+ position_embeddings = position_embeddings_dict[layer_type],
970+ shared_kv_states = shared_kv_states,
971+ past_key_value = past_kv,
972+ )
973+ # KV-shared layers borrow K,V — exclude from present_key_values so the
974+ # output has exactly num_kv_layers (not num_hidden_layers) entries.
975+ if not layer.self_attn.is_kv_shared_layer:
976+ present_key_values.append(present_kv)
977+ ```
978+
979+ The task's KV cache inputs/outputs must use the correct count:
980+ ` num_kv_layers = config.num_hidden_layers - config.num_kv_shared_layers ` .
0 commit comments