Skip to content

Commit 228d01e

Browse files
authored
Merge branch 'main' into justinchu/fix-castlike-dtype
2 parents 02e6fdb + 7c1972d commit 228d01e

351 files changed

Lines changed: 17380 additions & 2986 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.

.github/copilot-instructions.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,13 @@ and wrapper modules for nesting. See the `weight-name-alignment` skill.
101101
- Use ONNX opset 23 `op.Attention` with `q_num_heads`/`kv_num_heads`
102102
attributes (not `num_heads`)
103103

104+
### Protobuf prohibition
105+
106+
- **Zero explicit protobuf operations in this repo**
107+
- Never use `onnx.helper`, `onnx.TensorProto`, `onnx.ModelProto`, or any protobuf construction APIs
108+
- Always use `onnx_ir` APIs (`ir.Graph`, `ir.Node`, `ir.Function`, `ir.Value`, `ir.Tensor`) — `onnxscript.ir` is a deprecated alias, do not use it
109+
- This applies to all code: models, components, tasks, tests, utilities, and function bodies
110+
104111
### Comments and documentation
105112

106113
- **Add inline comments that explain the model architecture**: annotate

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

Lines changed: 163 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ If the logits match HuggingFace, you only need the registry entry.
4646
Create `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

5252
from __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
319331
elimination, 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`.

.github/skills/moe-models/SKILL.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,3 +260,124 @@ should be small enough for CI (~1-4B params). The test pattern:
260260
3. Optionally test greedy generation (token-ID matching)
261261

262262
See `tests/moe_integration_test.py` for the complete pattern.
263+
264+
## Direct MoE op emission (com.microsoft.MoE)
265+
266+
OnnxRuntime ships a fused `com.microsoft.MoE` contrib op (CUDA float32/fp16/bf16,
267+
CPU float32/fp16). For new model architectures, **emit it directly** — like
268+
`com.microsoft.GroupQueryAttention` — rather than relying on a rewrite rule.
269+
270+
### When to use
271+
272+
Use `com.microsoft.MoE` when:
273+
- The model uses top-k MoE routing (standard softmax gate → TopK)
274+
- All expert weights are the same shape (no dynamic expert counts)
275+
- The EP's `caps.supports_fused_moe` is `True`
276+
277+
Fall back to the loop-over-experts path when `supports_fused_moe` is `False`
278+
(CPU EP without contrib ops, or EPs that don't support the custom op).
279+
280+
### Gate output: full pre-topk router_probs
281+
282+
The op takes the **full** `(num_tokens, num_experts)` probability tensor and
283+
performs top-k selection internally via the `k` attribute. The gate must
284+
produce the full softmax distribution — not already-selected top-k indices.
285+
286+
```python
287+
# In your gate forward(), return shape [num_tokens, num_experts]
288+
router_probs = op.Softmax(op.MatMul(hidden_states, self.weight), axis=-1)
289+
```
290+
291+
### Emission pattern (from Gemma 4 implementation)
292+
293+
```python
294+
from mobius._build_context import ep_capabilities
295+
296+
caps = ep_capabilities()
297+
if caps.supports_fused_moe:
298+
moe_out = op.CastLike(
299+
op.MoE( # type: ignore[attr-defined]
300+
normed_hidden, # [num_tokens, hidden_size]
301+
router_probs, # [num_tokens, num_experts] — full pre-topk
302+
self.fc1_experts_weights, # [E, inter_size, hidden_size]
303+
self.fc2_experts_weights, # [E, hidden_size, inter_size]
304+
activation_type="silu",
305+
k=self._top_k,
306+
normalize_routing_weights=1,
307+
_domain="com.microsoft",
308+
),
309+
normed_hidden, # CastLike: preserve bf16/fp16/fp32 — NOT hardcoded float32
310+
)
311+
else:
312+
moe_out = self._dispatch_moe_fallback(op, normed_hidden, router_probs)
313+
```
314+
315+
**Critical: use `CastLike` after the MoE op.** The `com.microsoft.MoE` custom
316+
op has `type=None` on its output — ONNX type propagation cannot infer the
317+
output dtype. `op.CastLike(moe_output, target=input)` restores the correct
318+
dtype (bf16/fp16/fp32), which allows downstream ops to share scalar
319+
initializers and avoids hard-coded `Cast` to float32.
320+
321+
### preprocess_weights: expert weight mapping
322+
323+
HuggingFace Gemma 4 stores experts as a 3D tensor per projection:
324+
`layers.N.experts.gate_up_proj [E, 2*inter, H]`
325+
`layers.N.experts.down_proj [E, H, inter]`
326+
327+
Map these to the parameter names used by the ONNX MoE op:
328+
329+
```python
330+
def preprocess_weights(self, state_dict):
331+
for key in list(state_dict.keys()):
332+
if ".experts.gate_up_proj" in key:
333+
new_key = key.replace(".experts.gate_up_proj", ".fc1_experts_weights")
334+
state_dict[new_key] = state_dict.pop(key)
335+
elif ".experts.down_proj" in key:
336+
new_key = key.replace(".experts.down_proj", ".fc2_experts_weights")
337+
state_dict[new_key] = state_dict.pop(key)
338+
return super().preprocess_weights(state_dict)
339+
```
340+
341+
For models that store per-expert weights separately (one matrix per expert),
342+
stack them into 3D tensors in `preprocess_weights`:
343+
344+
```python
345+
n = config.num_local_experts
346+
gate = torch.stack([state_dict.pop(f"experts.{i}.gate_proj.weight") for i in range(n)])
347+
down = torch.stack([state_dict.pop(f"experts.{i}.down_proj.weight") for i in range(n)])
348+
state_dict["fc1_experts_weights"] = gate # [E, inter, hidden]
349+
state_dict["fc2_experts_weights"] = down # [E, hidden, inter]
350+
```
351+
352+
### EP capability check (matches GQA pattern)
353+
354+
```python
355+
# In _execution_providers.py EpCapabilities:
356+
supports_fused_moe: bool = True # set False for EPs without com.microsoft.MoE
357+
358+
# In model forward():
359+
from mobius._execution_providers import ep_capabilities
360+
caps = ep_capabilities()
361+
if caps.supports_fused_moe:
362+
# emit com.microsoft.MoE
363+
else:
364+
# fallback loop
365+
```
366+
367+
### Fallback: TopKGate + loop dispatch
368+
369+
When `supports_fused_moe` is False, implement a static unroll:
370+
371+
```python
372+
def _dispatch_moe_fallback(self, op, hidden, router_probs):
373+
k_tensor = op.Constant(value_ints=[self._top_k])
374+
top_weights, top_indices = op.TopK(router_probs, k_tensor, axis=-1)
375+
top_weights = op.Softmax(top_weights, axis=-1) # renormalize
376+
output = op.CastLike(op.ConstantOfShape(op.Shape(hidden), value=0.0), hidden)
377+
for e_idx in range(self._num_experts):
378+
w1 = op.Squeeze(op.Gather(self.fc1_experts_weights, [e_idx], axis=0), [0])
379+
w2 = op.Squeeze(op.Gather(self.fc2_experts_weights, [e_idx], axis=0), [0])
380+
# expert output, gated by routing weight
381+
...
382+
return output
383+
```

0 commit comments

Comments
 (0)