Skip to content

Commit 8bd6457

Browse files
justinchubyCopilot
andauthored
feat: add Gemma 4 support (text, vision, audio, MoE) (#137)
Adds full Gemma 4 support for all four production variants. ## Model Variants | Model ID | Type | Modalities | |---|---|---| | `google/gemma-4-E2B-it` | Any-to-Any | Vision + Audio + Text | | `google/gemma-4-E4B-it` | Any-to-Any | Vision + Audio + Text | | `google/gemma-4-26B-A4B-it` | Image-Text | Vision + Text | | `google/gemma-4-31B-it` | Image-Text | Vision + Text | ## Architecture - **Dual RoPE**: sliding-window layers use `rope_type=default, θ=10k`; full-attention layers use `rope_type=proportional, partial_factor=0.25, θ=1M` - **5:1 sliding/full pattern**: every 6th layer is full attention (`layer_types` list from HF config) - **GQA**: 8 Q heads / 1 KV head; `head_dim=256` for sliding layers, `global_head_dim=512` for full-attention layers - **KV sharing** (`num_kv_shared_layers=20` for E2B/E4B): last N layers receive K,V as graph edges from source layers — no `k_proj`/`v_proj` weights allocated - **Double-wide MLP** (`use_double_wide_mlp=True`): KV-shared layers use `2x intermediate_size` - **Final logit softcapping**: `logit_cap * tanh(x / logit_cap)` with `logit_cap=30.0` - **Per-layer input embeddings**: optional gating from `vocab_size_per_layer_input` embeddings - **`layer_scalar`**: per-layer learned scalar applied after per-layer input contribution ## Audio (E2B/E4B only) - `Gemma4AudioEncoder`: Conformer-based encoder with 12 layers, `hidden_size=1024` - Causal chunked attention (`attention_chunk_size=12`, `context_left=13`) - Convolutional subsampler with channels `[128, 32]` (4x temporal downsampling) - Projects to text `hidden_size` via `output_proj_dims=1536` ## MoE (26B-A4B and 31B) - Direct `com.microsoft.MoE` op emission in `Gemma4DecoderLayer` when `enable_moe_block=True` and EP supports MoE - Falls back to dense MLP when EP does not support MoE - Gate router matches HF `Gemma4SparseMoeBlock` routing ## Tasks - **`Gemma4VisionLanguageTask`** (3-model split): `decoder` + `vision` + `embedding` — for 26B-A4B, 31B - **`Gemma4AnyToAnyTask`** (4-model split): `decoder` + `vision` + `audio` + `embedding` — for E2B, E4B ## Example `examples/gemma4_multimodal.py` — end-to-end Any-to-Any generation with interleaved image/audio/text. ## Tests - `test_graph_builds_without_weights[gemma4_text]` + 3 parametrized variants - `test_gemma4_multimodal_graph` — 3-model split (decoder + vision + embedding) - `test_gemma4_any_to_any_graph` — 4-model split with `num_kv_shared_layers=1`, audio encoder - **2377 total tests passing** --------- Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent e0a8b40 commit 8bd6457

32 files changed

Lines changed: 6454 additions & 34 deletions

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

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -534,45 +534,48 @@ gathered = op.Gather(padded, indices, axis=0)
534534
result = op.Where(image_mask, gathered, text_embeddings)
535535
```
536536

537-
## Any-to-Any 4-model task (vision+audio+text)
537+
## Conditional 3-or-4-model task (vision+audio+text)
538538

539539
Some models come in two tiers: small variants support vision **and** audio
540540
(Any-to-Any), while large variants support vision only (Image-Text-to-Text).
541-
Each tier uses a different number of exported ONNX models.
541+
A **single unified task class** handles both tiers by checking whether
542+
`config.audio is not None` to decide whether to include the speech encoder.
542543

543544
### Tier split
544545

545546
| Tier | Models | ONNX split | Example |
546547
|------|--------|-----------|---------|
547-
| Small Any-to-Any | E2B, E4B | 4 models: decoder + vision + **audio** + embedding | `google/gemma-4-E2B-it` |
548+
| Small Any-to-Any | E2B, E4B | 4 models: decoder + vision + **speech** + embedding | `google/gemma-4-E2B-it` |
548549
| Large Image-Text-to-Text | 26B-A4B, 31B | 3 models: decoder + vision + embedding | `google/gemma-4-26B-A4B-it` |
549550

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).
551+
`ArchitectureConfig.from_transformers` populates the `audio` field when the
552+
HuggingFace config contains an audio sub-config; otherwise it is `None`.
553553

554-
### 4-model task structure
554+
### 4-model task structure (when audio is present)
555555

556556
```
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]
557+
decoder inputs_embeds [B, S, H] → logits + KV cache
558+
vision pixel_values [B, N, 3*P^2] → image_features [B*N, H]
559+
speech input_features [B, T, mel] → audio_features [num_audio_tokens, H]
560+
embedding input_ids + image_features + audio_features → inputs_embeds [B, S, H]
561561
```
562562

563-
Reference implementation: `Gemma4AnyToAnyTask` in
564-
`src/mobius/tasks/_gemma4.py`. This follows the same 4-model structural pattern as
563+
Reference implementation: `Gemma4Task` in `src/mobius/tasks/_gemma4.py`.
564+
This follows the same multi-model structural pattern as
565565
`Phi4MMMultiModalTask` in `src/mobius/tasks/_phi4mm_multimodal.py`
566566
(each modality is a separate ONNX model; embedding splices features at placeholder
567567
positions), though the exact I/O shapes differ per architecture.
568568

569-
### Audio encoder wiring
569+
`Gemma4VisionLanguageTask` and `Gemma4AnyToAnyTask` are backward-compatible
570+
aliases pointing to `Gemma4Task`.
570571

571-
The audio encoder takes raw mel-spectrogram frames and outputs token-level
572-
features at the text hidden size:
572+
### Speech encoder wiring
573+
574+
The speech (audio) encoder takes raw mel-spectrogram frames and outputs
575+
token-level features at the text hidden size:
573576

574577
```python
575-
# In Gemma4AnyToAnyTask._build_audio():
578+
# In Gemma4Task._build_speech():
576579
input_features = ir.Value(
577580
name="input_features",
578581
shape=ir.Shape([batch, time, input_size]), # [B, T, 128]
@@ -583,37 +586,37 @@ audio_features = audio_encoder(op, input_features)
583586
```
584587

585588
The audio encoder (`Gemma4AudioEncoder` / `_Gemma4AudioEncoderModel`) is
586-
its own `nn.Module` subgraph exported as the `"audio"` key in the
589+
its own `nn.Module` subgraph exported as the `"speech"` key in the
587590
`ModelPackage`.
588591

589592
### Embedding model fuses all modalities
590593

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+
The embedding model receives `input_ids`, `image_features`, and optionally
595+
`audio_features` as inputs and splices them into the token embedding sequence
596+
at the placeholder positions:
594597

595598
```python
596-
# In Gemma4AnyToAnyTask._build_embedding():
599+
# In Gemma4Task._build_embedding():
597600
inputs_embeds = embedding(
598601
op,
599602
input_ids=input_ids, # [B, S]
600603
image_features=image_features, # [num_image_tokens, H]
601-
audio_features=audio_features, # [num_audio_tokens, H]
604+
audio_features=audio_features, # [num_audio_tokens, H] — only when audio present
602605
)
603606
# returns inputs_embeds: [B, S, H]
604607
```
605608

606-
### Task class tier detection
609+
### Task class conditional pattern
607610

608611
```python
609-
class MyAnyToAnyTask(ModelTask):
612+
class MyConditionalTask(ModelTask):
610613
def build(self, module, config):
611614
models = {}
612615
models["decoder"] = self._build_decoder(module.decoder, config)
613616
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
617+
# Build speech encoder only when audio config is present
616618
if config.audio is not None:
617-
models["audio"] = self._build_audio(module.audio_encoder, config)
619+
models["speech"] = self._build_speech(module.audio_encoder, config)
620+
models["embedding"] = self._build_embedding(module.embedding, config)
618621
return ModelPackage(models, config=config)
619622
```
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
# Gemma 4 ORT GenAI Config Files
2+
3+
ORT GenAI configuration files for the **`google/gemma-4-E2B-it`** checkpoint
4+
(Gemma 4 Embedding-Enhanced 2B instruction-tuned model).
5+
6+
> **Important:** The `gemma4`, `gemma4_text`, and `gemma4_any_to_any` model
7+
> types are not yet in a released ORT GenAI build. These configs require ORT
8+
> GenAI support for the Gemma 4 architecture. See the *ORT GenAI Support*
9+
> section below.
10+
11+
---
12+
13+
## Directory layout
14+
15+
```
16+
ort_genai/
17+
├── text/
18+
│ └── genai_config.json # Text-only decoder (model.onnx)
19+
├── vlm/
20+
│ ├── genai_config.json # 3-model VLM (model.onnx + vision.onnx + embedding.onnx)
21+
│ └── processor_config.json # SigLIP image processor config
22+
└── any_to_any/
23+
└── genai_config.json # 4-model AnyToAny (+speech.onnx)
24+
```
25+
26+
---
27+
28+
## Key architecture values (`google/gemma-4-E2B-it`)
29+
30+
| Field | Value |
31+
|---|---|
32+
| `vocab_size` | 262 144 |
33+
| `hidden_size` | 1 536 |
34+
| `num_attention_heads` | 8 |
35+
| `num_key_value_heads` | 1 |
36+
| `head_dim` (local/sliding layers) | 256 |
37+
| `global_head_dim` (full-attention layers) | 512 |
38+
| `num_hidden_layers` (total) | 35 |
39+
| `num_kv_shared_layers` | 20 |
40+
| **KV cache depth** | **15** (= 35 − 20) |
41+
| `sliding_window` | 512 |
42+
| `max_position_embeddings` | 131 072 |
43+
| `bos_token_id` | 2 |
44+
| `eos_token_id` | `[1, 106]` |
45+
| `image_token_id` | 255 999 (`boi_token_id`) |
46+
| `audio_token_id` | 258 881 |
47+
| Vision patch size | 16 |
48+
| Vision tokens per image | 280 |
49+
50+
---
51+
52+
## KV cache sharing (15, not 35, layers)
53+
54+
Gemma 4 uses **KV projection sharing**: the last `num_kv_shared_layers = 20`
55+
decoder layers reuse the K and V projections from the preceding layers.
56+
Those 20 layers therefore have **no independent KV cache entries**.
57+
58+
Only the first `35 − 20 = 15` layers produce their own KV cache, so:
59+
60+
- `num_hidden_layers` in `genai_config.json` is **15**, not 35.
61+
- `past_key_values.{0..14}.key/value` are the only KV inputs/outputs.
62+
63+
ORT GenAI must be aware of this sharing pattern to feed the correct cached
64+
K/V values to the shared layers during autoregressive decoding.
65+
66+
---
67+
68+
## Sliding window attention (5:1 local:global pattern)
69+
70+
The 35-layer stack alternates 4 sliding-window (local) layers followed by
71+
1 full-attention (global) layer, repeated 7 times:
72+
73+
```
74+
layers 0–3 : sliding_attention (window = 512 tokens)
75+
layer 4 : full_attention
76+
layers 5–8 : sliding_attention
77+
layer 9 : full_attention
78+
layers 10–13 : sliding_attention
79+
layer 14 : full_attention
80+
(layers 15–34 share KV — no independent cache)
81+
```
82+
83+
The `sliding_window.layers` field in the configs lists the **sliding** layer
84+
indices within the 15-entry KV cache: `[0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 12, 13]`.
85+
86+
### Dual head_dim
87+
88+
Sliding (local) attention layers use `head_dim = 256`; full (global)
89+
attention layers use `global_head_dim = 512`. The `head_size` field in
90+
`genai_config.json` is set to **256** (the local value). ORT GenAI support
91+
for the Gemma 4 model type must handle the per-layer head_dim difference
92+
internally.
93+
94+
---
95+
96+
## Vision encoding
97+
98+
Gemma 4 uses a **SigLIP** vision encoder (ViT, patch_size=16, image_size=448)
99+
with a "pan-and-scan" tiling strategy for high-resolution images.
100+
101+
The ONNX vision model (`vision.onnx`) takes **pre-patchified** inputs:
102+
- `pixel_values [batch, num_patches, 3 * 16 * 16]` — flattened patch pixels
103+
- `pixel_position_ids [batch, num_patches, 2]` — (row, col) patch coordinates
104+
105+
This differs from other VLMs that pass raw images and use `image_grid_thw`.
106+
The HuggingFace `AutoProcessor` produces the pre-patchified format directly.
107+
108+
Each image produces **280 soft tokens** (`vision_soft_tokens_per_image = 280`).
109+
110+
---
111+
112+
## Audio encoding
113+
114+
Gemma 4 Any-to-Any uses a **Conformer** audio encoder (12 layers,
115+
hidden_size=1024) with 4× subsampling.
116+
117+
The ONNX audio model (`speech.onnx`) takes:
118+
- `input_features [batch, time, 128]` — 128-dim mel spectrogram
119+
120+
Output: `audio_features [batch, time/4, 1536]` — projected to text hidden_size.
121+
122+
`audio_token_id = 258881` identifies audio soft-token positions in `input_ids`.
123+
124+
---
125+
126+
## ORT GenAI support required
127+
128+
These configs use model types not yet in a released ORT GenAI build:
129+
130+
| Config | `model.type` | Required new type |
131+
|---|---|---|
132+
| `text/genai_config.json` | `gemma4_text` | New decoder-only type |
133+
| `vlm/genai_config.json` | `gemma4` | New VLM type |
134+
| `any_to_any/genai_config.json` | `gemma4_any_to_any` | New multimodal type |
135+
136+
Until these types are supported, you can approximate text-only generation
137+
using `"type": "gemma3_text"` (without KV sharing or dual head_dim support).
138+
139+
---
140+
141+
## Usage example
142+
143+
```python
144+
import onnxruntime_genai as og
145+
146+
# Text-only
147+
model = og.Model("path/to/gemma4/ort_genai/text")
148+
tokenizer = og.Tokenizer(model)
149+
params = og.GeneratorParams(model)
150+
params.set_search_options(max_length=512)
151+
generator = og.Generator(model, params)
152+
153+
# VLM (requires vision.onnx + embedding.onnx alongside model.onnx)
154+
model = og.Model("path/to/gemma4/ort_genai/vlm")
155+
processor = og.MultiModalProcessor(model)
156+
# ... load image and tokenize prompt with processor ...
157+
```
158+
159+
The model directory must contain the ONNX files exported from mobius:
160+
```
161+
text/
162+
model.onnx ← exported by Gemma4TextCausalLMTask
163+
tokenizer.json
164+
genai_config.json
165+
166+
vlm/
167+
model.onnx ← decoder (Gemma4VisionLanguageTask)
168+
vision.onnx ← vision encoder
169+
embedding.onnx ← embedding fusion
170+
tokenizer.json
171+
genai_config.json
172+
processor_config.json
173+
174+
any_to_any/
175+
model.onnx ← decoder (Gemma4AnyToAnyTask)
176+
vision.onnx
177+
speech.onnx ← Conformer audio encoder
178+
embedding.onnx
179+
tokenizer.json
180+
genai_config.json
181+
processor_config.json ← copy from vlm/
182+
```
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
{
2+
"model": {
3+
"type": "gemma4_any_to_any",
4+
"vocab_size": 262144,
5+
"context_length": 131072,
6+
"bos_token_id": 2,
7+
"eos_token_id": [1, 106],
8+
"pad_token_id": 0,
9+
"image_token_id": 255999,
10+
"audio_token_id": 258881,
11+
"decoder": {
12+
"session_options": {
13+
"log_id": "onnxruntime-genai",
14+
"provider_options": []
15+
},
16+
"filename": "model.onnx",
17+
"hidden_size": 1536,
18+
"head_size": 256,
19+
"num_attention_heads": 8,
20+
"num_key_value_heads": 1,
21+
"num_hidden_layers": 15,
22+
"inputs": {
23+
"inputs_embeds": "inputs_embeds",
24+
"attention_mask": "attention_mask",
25+
"position_ids": "position_ids",
26+
"past_key_names": "past_key_values.%d.key",
27+
"past_value_names": "past_key_values.%d.value"
28+
},
29+
"outputs": {
30+
"logits": "logits",
31+
"present_key_names": "present.%d.key",
32+
"present_value_names": "present.%d.value"
33+
},
34+
"sliding_window": {
35+
"window_size": 512,
36+
"pad_value": 0,
37+
"alignment": "right",
38+
"slide_key_value_cache": true,
39+
"slide_inputs": true,
40+
"layers": [0, 1, 2, 3, 5, 6, 7, 8, 10, 11, 12, 13]
41+
}
42+
},
43+
"vision": {
44+
"filename": "vision.onnx",
45+
"config_filename": "processor_config.json",
46+
"session_options": {
47+
"log_id": "onnxruntime-genai",
48+
"provider_options": []
49+
},
50+
"inputs": {
51+
"pixel_values": "pixel_values",
52+
"pixel_position_ids": "pixel_position_ids"
53+
},
54+
"outputs": {
55+
"image_features": "image_features"
56+
}
57+
},
58+
"speech": {
59+
"filename": "speech.onnx",
60+
"session_options": {
61+
"log_id": "onnxruntime-genai",
62+
"provider_options": []
63+
},
64+
"inputs": {
65+
"input_features": "input_features"
66+
},
67+
"outputs": {
68+
"audio_features": "audio_features"
69+
}
70+
},
71+
"embedding": {
72+
"filename": "embedding.onnx",
73+
"session_options": {
74+
"log_id": "onnxruntime-genai",
75+
"provider_options": []
76+
},
77+
"inputs": {
78+
"input_ids": "input_ids",
79+
"image_features": "image_features",
80+
"audio_features": "audio_features"
81+
},
82+
"outputs": {
83+
"inputs_embeds": "inputs_embeds"
84+
}
85+
}
86+
},
87+
"search": {
88+
"do_sample": false,
89+
"early_stopping": true,
90+
"max_length": 8192,
91+
"min_length": 0,
92+
"num_beams": 1,
93+
"num_return_sequences": 1,
94+
"past_present_share_buffer": false,
95+
"repetition_penalty": 1.0,
96+
"temperature": 1.0,
97+
"top_k": 1,
98+
"top_p": 1.0
99+
}
100+
}

0 commit comments

Comments
 (0)