Skip to content

Commit e005489

Browse files
justinchubyCopilot
andauthored
Fix Gemma4 bidirectional attention + add gemma-4-12B (gemma4_unified) (#338)
## Summary Two related changes for the Gemma 4 family: ### 1. Fix bidirectional vision-block attention (existing gemma4 models) The existing gemma4 (32B) and gemma4 MoE multimodal models always built a purely **causal** decoder, ignoring `use_bidirectional_attention="vision"`. HuggingFace gemma4 makes contiguous vision-token spans attend **bidirectionally** (via a blockwise overlay OR-ed onto the causal/sliding mask, applied to both full- and sliding-attention layers). This adds: - `use_bidirectional_attention` to `Gemma4Config` + extraction. - `block_sequence_ids` plumbed embedding → decoder; `create_attention_bias` gains a `block_sequence_ids` blockwise-overlay path (forces the float-bias, non-GQA attention path with `is_causal=0`). ### 2. Add gemma-4-12B (`gemma4_unified`) The full encoder-free multimodal `Gemma4UnifiedForConditionalGeneration`: - **Text backbone**: 48 layers, hidden 3840, dual head_dim (local 256 / global 512), `attention_k_eq_v` with a single global KV head, dual RoPE, final-logit softcapping. Reuses `Gemma4CausalLMModel`. - **Vision**: encoder-free embedder — raw patches (6912) → LN → Dense → LN → factorized 2D posemb → LN → scale-free RMSNorm → Linear(→3840). - **Audio**: encoder-free embedder — raw frames (640) → scale-free RMSNorm → Linear(→3840). - Both strip padding inside the ONNX graph (Compress). - `Gemma4UnifiedModel` + `Gemma4UnifiedTask` build a 3/4-model package (decoder, vision_encoder, embedding, optional audio_encoder). ## Testing Fast suite: 2783 passed. L1 graph-build tests for text and 4-model multimodal packages. Real `google/gemma-4-12B` checkpoint, float32 on CUDA (H200): - **Text prefill** parity vs HF: max_abs_diff ~0.01. - **Bidirectional mask** parity test. - **Full multimodal prefill** parity vs HF: vision cos_sim=1.000000, decoder last-token cos_sim=1.000000, max_abs_diff=1.10, argmax match, no NaN. (Bidirectional reference requires passing `mm_token_type_ids` to HF.) --------- Signed-off-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Justin Chu <11205048+justinchuby@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f972184 commit e005489

35 files changed

Lines changed: 3289 additions & 79 deletions

examples/gemma4_unified_ort_genai.py

Lines changed: 491 additions & 0 deletions
Large diffs are not rendered by default.

scripts/generate_golden.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -403,10 +403,14 @@ def _generate_vision_language(case: TestCase, json_path: Path, device: str) -> N
403403
# Load images from testdata/
404404
images = [Image.open(Path("testdata") / img_path) for img_path in case.images]
405405

406-
# Build chat-formatted prompt with image placeholders if the
407-
# processor supports apply_chat_template (Qwen-VL, Gemma-3, etc.)
406+
# Build chat-formatted prompt with image placeholders if the processor has a
407+
# usable chat template (Qwen-VL, Gemma-3, etc.). Base checkpoints (e.g.
408+
# google/gemma-4-12B) ship no chat template, so fall back to manually
409+
# prepending one image placeholder token per image — the processor then
410+
# expands each into the correct number of soft tokens (mirrors how
411+
# examples/gemma4_unified_ort_genai.py formats image prompts).
408412
prompt_text = case.prompts[0]
409-
if hasattr(processor, "apply_chat_template"):
413+
if getattr(processor, "chat_template", None):
410414
content: list[dict[str, str]] = []
411415
for img_path in case.images:
412416
content.append({"type": "image", "image": str(Path("testdata") / img_path)})
@@ -415,6 +419,8 @@ def _generate_vision_language(case: TestCase, json_path: Path, device: str) -> N
415419
prompt_text = processor.apply_chat_template(
416420
messages, tokenize=False, add_generation_prompt=True
417421
)
422+
elif getattr(processor, "image_token", None):
423+
prompt_text = processor.image_token * len(case.images) + prompt_text
418424

419425
# Process multimodal inputs through the HF processor
420426
processed = processor(
@@ -739,7 +745,7 @@ def _prepare_speech_language_inputs(
739745
else:
740746
# Gemma4-style: text prompt + audio
741747
prompt_text = case.prompts[0]
742-
if hasattr(processor, "apply_chat_template"):
748+
if getattr(processor, "chat_template", None):
743749
content: list[dict[str, str]] = [
744750
{"type": "audio", "audio": str(audio_path)},
745751
{"type": "text", "text": prompt_text},
@@ -748,6 +754,10 @@ def _prepare_speech_language_inputs(
748754
prompt_text = processor.apply_chat_template(
749755
messages, tokenize=False, add_generation_prompt=True
750756
)
757+
elif getattr(processor, "audio_token", None):
758+
# Base checkpoint (no chat template): manually prepend the audio
759+
# placeholder; the processor expands it to the right token count.
760+
prompt_text = processor.audio_token + prompt_text
751761
model_device = _get_model_device(model, device)
752762
processed = processor(
753763
text=prompt_text,

src/mobius/_configs/_base.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,6 +1267,22 @@ class Gemma4Config(VisionLanguageConfig):
12671267
enable_moe_block: bool = False
12681268
attention_k_eq_v: bool = False
12691269
boa_token_id: int | None = None
1270+
use_bidirectional_attention: str | None = None
1271+
"""Bidirectional attention mode for the text decoder.
1272+
1273+
Mirrors HF ``Gemma4TextConfig.use_bidirectional_attention``:
1274+
- ``None``: fully causal (smaller Gemma4 models, e.g. E2B).
1275+
- ``"vision"``: text stays causal, but contiguous image-token blocks
1276+
attend bidirectionally within each block (larger models, e.g.
1277+
12B/26B/32B). Implemented via a per-position ``block_sequence_ids``
1278+
overlay added onto the causal mask. Audio placeholders are *not*
1279+
included (HF marks audio as token-type 3, excluded from the vision
1280+
block mask), so audio tokens keep causal attention.
1281+
- ``"all"``: HF mode where every token attends bidirectionally. Not used
1282+
by any currently supported Gemma4 model and not implemented here; the
1283+
decoder raises ``NotImplementedError`` rather than silently degrading to
1284+
causal attention (only ``None`` and ``"vision"`` are accepted).
1285+
"""
12701286

12711287
@classmethod
12721288
def from_transformers(cls, config, parent_config=None) -> Gemma4Config:
@@ -1340,6 +1356,7 @@ def from_transformers(cls, config, parent_config=None) -> Gemma4Config:
13401356
enable_moe_block=getattr(config, "enable_moe_block", False),
13411357
attention_k_eq_v=getattr(config, "attention_k_eq_v", False),
13421358
boa_token_id=getattr(parent_config, "boa_token_id", None),
1359+
use_bidirectional_attention=getattr(config, "use_bidirectional_attention", None),
13431360
)
13441361

13451362

src/mobius/_configs/_sub_configs.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,9 @@ class AudioConfig:
201201
audio_start_token_id: int | None = None
202202
audio_end_token_id: int | None = None
203203
classify_num: int | None = None
204+
# RMSNorm epsilon for the audio encoder/embedder (may differ from the text
205+
# decoder's rms_norm_eps). Falls back to the text value when unset.
206+
rms_norm_eps: float | None = None
204207
# Qwen3-ASR chunked conv parameters. ``n_window`` is half the
205208
# number of mel frames per conv chunk (so chunk_size = 2 *
206209
# n_window). ``n_window_infer`` is the attention window in mel

src/mobius/_configs/per_model/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
# may freely re-sort this block.
2525
from mobius._configs.per_model import ( # noqa: F401
2626
_gemma4_audio,
27+
_gemma4_unified_audio,
28+
_gemma4_unified_vision,
2729
_hunyuan_vl_mot_vision,
2830
_internvl_vision,
2931
_phi4mm_audio,
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Gemma4-unified (gemma-4-12B) audio extractor hook.
5+
6+
The ``gemma4_unified`` audio config describes an *encoder-free* embedder (no
7+
Conformer tower). It exposes only ``audio_embed_dim`` (input feature size for
8+
the projection) and ``rms_norm_eps``. This hook maps those onto
9+
:class:`Gemma4AudioConfig` so
10+
:class:`~mobius.models.gemma4._Gemma4UnifiedAudioEmbedderModel` can read them.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from mobius._configs._extractors import register_audio_hook
16+
from mobius._configs._sub_configs import Gemma4AudioConfig
17+
18+
_UNIFIED_TYPES = ("gemma4_unified", "gemma4_unified_text", "gemma4_unified_audio")
19+
20+
21+
@register_audio_hook
22+
def _gemma4_unified_audio(config, parent_config, model_type: str, fields: dict):
23+
composite = parent_config or config
24+
parent_model_type = getattr(composite, "model_type", "")
25+
if model_type not in _UNIFIED_TYPES and parent_model_type != "gemma4_unified":
26+
return None
27+
hf_audio = getattr(composite, "audio_config", None)
28+
if hf_audio is None:
29+
return None
30+
audio_embed_dim = getattr(hf_audio, "audio_embed_dim", 640)
31+
return {
32+
"audio": Gemma4AudioConfig(
33+
hidden_size=audio_embed_dim,
34+
output_proj_dims=audio_embed_dim,
35+
audio_token_id=getattr(composite, "audio_token_id", None),
36+
rms_norm_eps=getattr(hf_audio, "rms_norm_eps", None),
37+
)
38+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Gemma4-unified (gemma-4-12B) vision extractor hook.
5+
6+
The ``gemma4_unified`` vision config describes an *encoder-free* embedder, not
7+
a SigLIP tower. Its fields differ from the generic ``vision_config``:
8+
9+
- ``patch_size`` / ``pooling_kernel_size`` → merged ``model_patch_size``
10+
- ``mm_embed_dim`` → embedder hidden size (``VisionConfig.hidden_size``)
11+
- ``mm_posemb_size`` → factorized positional-embedding table size
12+
(``VisionConfig.position_embedding_size``)
13+
- ``output_proj_dims`` → projection input dim (``VisionConfig.out_hidden_size``)
14+
15+
This hook maps those onto :class:`VisionConfig` so
16+
:class:`~mobius.models.gemma4._Gemma4UnifiedVisionEmbedderModel` can read them.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from mobius._configs._extractors import register_vision_hook
22+
23+
_UNIFIED_TYPES = ("gemma4_unified", "gemma4_unified_text", "gemma4_unified_vision")
24+
25+
26+
@register_vision_hook
27+
def _gemma4_unified_vision(config, parent_config, model_type: str, fields: dict):
28+
composite = parent_config or config
29+
parent_model_type = getattr(composite, "model_type", "")
30+
if model_type not in _UNIFIED_TYPES and parent_model_type != "gemma4_unified":
31+
return None
32+
hf_vision = getattr(composite, "vision_config", None)
33+
if hf_vision is None:
34+
return None
35+
36+
def _get(name, default=None):
37+
return getattr(hf_vision, name, default)
38+
39+
fields.update(
40+
model_type="gemma4_unified_vision",
41+
hidden_size=_get("mm_embed_dim", 3840),
42+
patch_size=_get("patch_size", 16),
43+
pooling_kernel_size=_get("pooling_kernel_size", 3),
44+
position_embedding_size=_get("mm_posemb_size", 1120),
45+
out_hidden_size=_get("output_proj_dims", _get("mm_embed_dim", 3840)),
46+
norm_eps=_get("rms_norm_eps", 1e-6),
47+
)
48+
fields["image_token_id"] = getattr(composite, "image_token_id", None)
49+
return None

src/mobius/_optimizations.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,12 @@ def _get_optimization_passes(
284284

285285
# --- Attention fusion (decoder only) ---
286286
if model_role == "decoder" and dtype in caps.gqa_dtypes:
287-
fuse.append(("GQAFusion", list(group_query_attention_rules())))
287+
fuse.append(
288+
(
289+
"GQAFusion",
290+
list(group_query_attention_rules()),
291+
)
292+
)
288293

289294
# --- QKV packing (decoder only, gated by qkv_pack_dtypes) ---
290295
if model_role == "decoder" and dtype in caps.qkv_pack_dtypes:

src/mobius/_registry.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
Gemma3MultiModalModel,
4646
Gemma4CausalLMModel,
4747
Gemma4Model,
48+
Gemma4UnifiedModel,
4849
GemmaCausalLMModel,
4950
Glm4CausalLMModel,
5051
Glm4MoECausalLMModel,
@@ -398,6 +399,7 @@ def _detect_fallback_registration(hf_config) -> ModelRegistration | None:
398399
"gemma3n": ModelRegistration(Gemma3nCausalLMModel),
399400
"gemma3n_text": ModelRegistration(Gemma3nCausalLMModel),
400401
"gemma4_text": ModelRegistration(Gemma4CausalLMModel, config_class=Gemma4Config),
402+
"gemma4_unified_text": ModelRegistration(Gemma4CausalLMModel, config_class=Gemma4Config),
401403
"glm": ModelRegistration(GlmCausalLMModel),
402404
"glm4": ModelRegistration(Glm4CausalLMModel),
403405
"gpt_neox": ModelRegistration(GPTNeoXCausalLMModel),
@@ -472,6 +474,9 @@ def _detect_fallback_registration(hf_config) -> ModelRegistration | None:
472474
"florence2": ModelRegistration(LLaVAModel, task="vision-language"),
473475
"fuyu": ModelRegistration(LLaVAModel, task="vision-language"),
474476
"gemma4": ModelRegistration(Gemma4Model, task="gemma4", config_class=Gemma4Config),
477+
"gemma4_unified": ModelRegistration(
478+
Gemma4UnifiedModel, task="gemma4-unified", config_class=Gemma4Config
479+
),
475480
"glm4v": ModelRegistration(LLaVAModel, task="vision-language"),
476481
"glm4v_moe": ModelRegistration(LLaVAModel, task="vision-language"),
477482
"glm4v_moe_text": ModelRegistration(Glm4MoECausalLMModel),
@@ -819,6 +824,8 @@ def _create_default_registry() -> ModelRegistry:
819824
"llava_next": "llava-hf/llava-v1.6-mistral-7b-hf",
820825
"mllama": "meta-llama/Llama-3.2-11B-Vision-Instruct",
821826
"gemma4": "google/gemma-4-E2B-it",
827+
"gemma4_unified": "google/gemma-4-12B",
828+
"gemma4_unified_text": "google/gemma-4-12B",
822829
"internvl2": "OpenGVLab/InternVL2-1B",
823830
"phi4mm": "microsoft/Phi-4-multimodal-instruct",
824831
"phi4_multimodal": "microsoft/Phi-4-multimodal-instruct",

src/mobius/components/_attention.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ def _apply_attention(
8888
scale: float,
8989
softcap: float = 0.0,
9090
static_cache: StaticCacheState | None = None,
91+
is_causal: int = 1,
9192
) -> tuple[ir.Value, ir.Value, ir.Value]:
9293
"""Apply the ONNX Attention op with internal or static KV cache.
9394
@@ -103,10 +104,20 @@ def _apply_attention(
103104
Also uses ``is_causal=1``.
104105
Returns ``(attn_output, updated_key_cache, updated_value_cache)``.
105106
107+
Args:
108+
is_causal: Whether the Attention op applies its built-in causal
109+
mask (default ``1``). Set to ``0`` when ``attn_mask`` already
110+
bakes the FULL mask (causal + sliding + padding, and any
111+
bidirectional unmasking such as Gemma4's vision-block overlay)
112+
into a float additive bias. Leaving ``is_causal=1`` in that
113+
case would re-apply causality and cancel any future-position
114+
unmasking encoded in the bias.
115+
106116
Note:
107-
Both paths set ``is_causal=1`` on the Attention op, which enables
108-
built-in causal masking. This means ``attn_mask`` should encode
109-
only padding information (as a bool mask), not causality.
117+
Both paths default to ``is_causal=1`` on the Attention op, which
118+
enables built-in causal masking. This means ``attn_mask`` should
119+
encode only padding information (as a bool mask), not causality,
120+
unless ``is_causal=0`` is passed explicitly.
110121
111122
Note:
112123
``nonpad_kv_seqlen`` (input #6) is only valid in static cache mode
@@ -167,7 +178,7 @@ def _apply_attention(
167178
kv_num_heads=num_key_value_heads,
168179
scale=scale,
169180
softcap=softcap,
170-
is_causal=1,
181+
is_causal=is_causal,
171182
_outputs=3,
172183
)
173184
return attn_output, updated_k, updated_v
@@ -191,7 +202,7 @@ def _apply_attention(
191202
kv_num_heads=num_key_value_heads,
192203
scale=scale,
193204
softcap=softcap,
194-
is_causal=1,
205+
is_causal=is_causal,
195206
_outputs=3,
196207
)
197208
return attn_output, present_key, present_value

0 commit comments

Comments
 (0)