Skip to content

Commit fd16c76

Browse files
authored
Merge branch 'main' into fix-pixtral-dynamic-vision
2 parents f664016 + db7c0ca commit fd16c76

49 files changed

Lines changed: 3044 additions & 670 deletions

Some content is hidden

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

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

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,12 @@ new model is a significant addition.
304304

305305
## Checklist
306306

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+
307313
- [ ] 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)
@@ -312,8 +318,14 @@ new model is a significant addition.
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+
```

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

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -533,3 +533,87 @@ gathered = op.Gather(padded, indices, axis=0)
533533
# Where mask selects only real features; padding row is never used
534534
result = op.Where(image_mask, gathered, text_embeddings)
535535
```
536+
537+
## Any-to-Any 4-model task (vision+audio+text)
538+
539+
Some models come in two tiers: small variants support vision **and** audio
540+
(Any-to-Any), while large variants support vision only (Image-Text-to-Text).
541+
Each tier uses a different number of exported ONNX models.
542+
543+
### Tier split
544+
545+
| Tier | Models | ONNX split | Example |
546+
|------|--------|-----------|---------|
547+
| Small Any-to-Any | E2B, E4B | 4 models: decoder + vision + **audio** + embedding | `google/gemma-4-E2B-it` |
548+
| Large Image-Text-to-Text | 26B-A4B, 31B | 3 models: decoder + vision + embedding | `google/gemma-4-26B-A4B-it` |
549+
550+
The task class detects which tier to use from the config (e.g. whether
551+
`config.audio is not None``ArchitectureConfig.from_transformers` populates
552+
the `audio` field when the HuggingFace config contains an audio sub-config).
553+
554+
### 4-model task structure
555+
556+
```
557+
decoder inputs_embeds [B, S, H] → logits + KV cache
558+
vision_encoder pixel_values [B, 3, H, W] → image_features [num_image_tokens, H]
559+
audio_encoder input_features [B, T, mel] → audio_features [num_audio_tokens, H]
560+
embedding input_ids + image_features + audio_features → inputs_embeds [B, S, H]
561+
```
562+
563+
Reference implementation: `Gemma4AnyToAnyTask` in
564+
`src/mobius/tasks/_gemma4.py`. This follows the same 4-model structural pattern as
565+
`Phi4MMMultiModalTask` in `src/mobius/tasks/_phi4mm_multimodal.py`
566+
(each modality is a separate ONNX model; embedding splices features at placeholder
567+
positions), though the exact I/O shapes differ per architecture.
568+
569+
### Audio encoder wiring
570+
571+
The audio encoder takes raw mel-spectrogram frames and outputs token-level
572+
features at the text hidden size:
573+
574+
```python
575+
# In Gemma4AnyToAnyTask._build_audio():
576+
input_features = ir.Value(
577+
name="input_features",
578+
shape=ir.Shape([batch, time, input_size]), # [B, T, 128]
579+
type=ir.TensorType(config.dtype),
580+
)
581+
audio_features = audio_encoder(op, input_features)
582+
# audio_features: [B, T//4, text_hidden_size]
583+
```
584+
585+
The audio encoder (`Gemma4AudioEncoder` / `_Gemma4AudioEncoderModel`) is
586+
its own `nn.Module` subgraph exported as the `"audio"` key in the
587+
`ModelPackage`.
588+
589+
### Embedding model fuses all modalities
590+
591+
The embedding model receives `input_ids`, `image_features`, and
592+
`audio_features` as separate inputs and splices them into the token
593+
embedding sequence at the placeholder positions:
594+
595+
```python
596+
# In Gemma4AnyToAnyTask._build_embedding():
597+
inputs_embeds = embedding(
598+
op,
599+
input_ids=input_ids, # [B, S]
600+
image_features=image_features, # [num_image_tokens, H]
601+
audio_features=audio_features, # [num_audio_tokens, H]
602+
)
603+
# returns inputs_embeds: [B, S, H]
604+
```
605+
606+
### Task class tier detection
607+
608+
```python
609+
class MyAnyToAnyTask(ModelTask):
610+
def build(self, module, config):
611+
models = {}
612+
models["decoder"] = self._build_decoder(module.decoder, config)
613+
models["vision"] = self._build_vision(module.vision_encoder, config)
614+
models["embedding"] = self._build_embedding(module.embedding, config)
615+
# Build audio encoder only when audio config is present
616+
if config.audio is not None:
617+
models["audio"] = self._build_audio(module.audio_encoder, config)
618+
return ModelPackage(models, config=config)
619+
```

0 commit comments

Comments
 (0)