Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/mobius/_configs/_vision_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@ def apply_vision_defaults(config, parent_config, model_type: str, fields: dict)
# per-model hook may overwrite later via fields.update(...).
fields["mm_tokens_per_image"] = getattr(vision_source, "mm_tokens_per_image", None)
fields["image_token_id"] = getattr(vision_source, "image_token_id", None)
fields["video_token_id"] = getattr(vision_source, "video_token_id", None)
fields["vision_start_token_id"] = getattr(vision_source, "vision_start_token_id", None)
fields["vision_end_token_id"] = getattr(vision_source, "vision_end_token_id", None)

# MRoPE section — only for composite VL models (parent_config != config).
if parent_config is not None and parent_config is not config:
Expand Down
458 changes: 125 additions & 333 deletions src/mobius/components/_qwen3_vl_vision.py

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions src/mobius/components/_qwen3_vl_vision_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.

from __future__ import annotations

import onnx_ir as ir

from mobius._testing import count_op_type, create_test_builder, create_test_input
from mobius.components._qwen3_vl_vision import Qwen3VLVisionModel

_PATCH_DIM = 3 * 2 * 16 * 16


def _build_vision_graph() -> ir.Graph:
module = Qwen3VLVisionModel(
depth=1,
hidden_size=32,
intermediate_size=64,
num_heads=4,
patch_size=16,
temporal_patch_size=2,
in_channels=3,
out_hidden_size=64,
spatial_merge_size=2,
num_position_embeddings=16,
deepstack_visual_indexes=[],
)
builder, op, graph = create_test_builder()
pixel_values = create_test_input(
builder,
"pixel_values",
["total_patches", _PATCH_DIM],
dtype=ir.DataType.FLOAT,
)
grid_thw = create_test_input(
builder,
"grid_thw",
["num_media", 3],
dtype=ir.DataType.INT64,
)
image_features = module(op, pixel_values, grid_thw)[0]
image_features.name = "image_features"
graph.outputs.append(image_features)
return graph


def test_packed_coordinates_are_linear_and_shared():
graph = _build_vision_graph()

# Media ownership uses boundary scatter + prefix sum, not a quadratic
# [total_patches, num_media] comparison matrix.
assert count_op_type(graph, "ScatterElements") == 1
# The only remaining comparison belongs to the single attention block.
assert count_op_type(graph, "GreaterOrEqual") == 1

# The two row/column coordinate values each feed both interpolation (Cast)
# and rotary IDs (Unsqueeze), proving the coordinate graph is emitted once.
shared_coordinates = []
for node in graph:
if node.op_type != "Add":
continue
for output in node.outputs:
consumer_types = {consumer.op_type for consumer, _ in output.uses()}
if {"Cast", "Unsqueeze"} <= consumer_types:
shared_coordinates.append(output)
assert len(shared_coordinates) == 2
17 changes: 14 additions & 3 deletions src/mobius/integrations/ort_genai/auto_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,9 @@
"qwen2_vl": "qwen2_5_vl",
"qwen3_vl": "qwen3_vl",
"qwen3_vl_text": "qwen3_vl",
"qwen3_5": "qwen2_5_vl",
"qwen3_5_vl": "qwen2_5_vl",
"qwen3_5": "qwen3_5",
"qwen3_5_vl": "qwen3_5",
"qwen3_5_text": "qwen3_5_text",
# MiniCPM uses standard 1D decoder position IDs (unlike Qwen-VL MRoPE).
# The phi3v multimodal runtime provides that contract; callers supply
# HF-preprocessed packed pixels through Generator.set_inputs().
Expand Down Expand Up @@ -136,6 +137,7 @@
"qwen3_vl_text",
"qwen3_5",
"qwen3_5_vl",
"qwen3_5_text",
"qwen3_5_moe",
"videochat_flash_qwen",
}
Expand Down Expand Up @@ -184,6 +186,11 @@ def _select_ort_model_type(
"""
if is_decoder_only and config_model_type in _ORT_GENAI_MODEL_TYPE:
return _ORT_GENAI_MODEL_TYPE[config_model_type]
if not is_decoder_only and config_model_type == "qwen3_5_text":
# Qwen3.5/Qwen3.8 multimodal builds unwrap the parent config to its
# text subtype, but their vision/embedding package uses the multimodal
# ORT pipeline and processor metadata.
return "qwen3_5"
return _resolve_ort_genai_model_type(hf_model_type or "unknown")


Expand Down Expand Up @@ -1377,7 +1384,11 @@ def write_ort_genai_config(
# does not bind, so borrowing that type would mis-wire the graph.
ort_model_type = "gemma3n"
else:
ort_model_type = _resolve_ort_genai_model_type(raw_type)
ort_model_type = _select_ort_model_type(
raw_type,
raw_type,
is_decoder_only=is_decoder_only,
)
if ort_model_type == "unknown":
logger.warning(
"Could not determine ORT-GenAI model type: pkg.config.model_type "
Expand Down
154 changes: 154 additions & 0 deletions src/mobius/integrations/ort_genai/auto_export_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ class FakeConfig:
class TestResolveOrtGenaiModelType:
def test_known_model_type(self):
assert _resolve_ort_genai_model_type("qwen3") == "qwen2"
assert _resolve_ort_genai_model_type("qwen3_5") == "qwen3_5"
assert _resolve_ort_genai_model_type("qwen3_5_vl") == "qwen3_5"
assert _resolve_ort_genai_model_type("qwen3_5_text") == "qwen3_5_text"
assert _resolve_ort_genai_model_type("gemma2") == "gemma"
assert _resolve_ort_genai_model_type("llama") == "llama"

Expand Down Expand Up @@ -141,6 +144,24 @@ def test_multimodal_keeps_hf_type(self):
def test_decoder_only_falls_back_to_hf_when_config_missing(self):
assert _select_ort_model_type(None, "qwen3", is_decoder_only=True) == "qwen2"

def test_qwen35_text_type_depends_on_package_topology(self):
assert (
_select_ort_model_type(
"qwen3_5_text",
"qwen3_5",
is_decoder_only=True,
)
== "qwen3_5_text"
)
assert (
_select_ort_model_type(
"qwen3_5_text",
"qwen3_5_text",
is_decoder_only=False,
)
== "qwen3_5"
)

def test_decoder_only_unknown_config_falls_back_to_hf(self):
# An unrecognised config.model_type (not in _ORT_GENAI_MODEL_TYPE) must
# not pass straight through as an invalid ORT type; fall back to the
Expand Down Expand Up @@ -354,6 +375,38 @@ def test_muse_glimmer_uses_packed_qwen_image_pipeline(self, tmp_path):
"merge_size": 2,
}

def test_qwen35_text_subtype_uses_packed_qwen_image_pipeline(self, tmp_path):
vision = types.SimpleNamespace(
image_size=448,
patch_size=16,
spatial_merge_size=2,
model_type="qwen3_5",
)
config = types.SimpleNamespace(
vision=vision,
model_type="qwen3_5_text",
spatial_merge_size=2,
temporal_patch_size=2,
)

path = _write_vision_processor_config(config, str(tmp_path))

assert path is not None
with open(path, encoding="utf-8") as config_file:
processor = json.load(config_file)["processor"]
assert processor["name"] == "qwen2_5_image_processor"
transforms = processor["transforms"]
assert transforms[-1]["operation"] == {
"name": "patch_image",
"type": "PatchImage",
"attrs": {
"patch_size": 16,
"temporal_patch_size": 2,
"merge_size": 2,
},
}
assert transforms[-2]["operation"]["attrs"]["qwen2_5_vl"] == 1

def test_gemma3_vision_config(self, tmp_path):
"""Gemma3 gets a fixed-size resize + Permute3D (not the generic branch).

Expand Down Expand Up @@ -1085,6 +1138,107 @@ class FakeConfig:
"present_conv_names": "present.%d.conv_state",
}

def test_qwen35_vl_hybrid_metadata_is_emitted_without_runtime_gate(self, tmp_path):
import dataclasses

from mobius._model_package import ModelPackage

@dataclasses.dataclass
class FakeConfig:
model_type: str = "qwen3_5_text"
vocab_size: int = 256
hidden_size: int = 64
num_hidden_layers: int = 4
num_attention_heads: int = 4
num_key_value_heads: int = 2
head_dim: int = 16
max_position_embeddings: int = 128
pad_token_id: int = 0

package = ModelPackage(
{
"decoder": _mock_model(
inputs=[
"inputs_embeds",
"attention_mask",
"position_ids",
"past_key_values.0.conv_state",
"past_key_values.0.recurrent_state",
"past_key_values.3.key",
"past_key_values.3.value",
],
outputs=[
"logits",
"present.0.conv_state",
"present.0.recurrent_state",
"present.3.key",
"present.3.value",
],
),
"embedding": _mock_model(inputs=["input_ids", "image_features"]),
"vision_encoder": _mock_model(
inputs=["pixel_values", "image_grid_thw"],
outputs=["image_features"],
),
},
config=FakeConfig(),
)

result = write_ort_genai_config(package, str(tmp_path))

with open(result["genai_config"], encoding="utf-8") as config_file:
generated = json.load(config_file)
decoder = generated["model"]["decoder"]
assert generated["model"]["type"] == "qwen3_5"
assert decoder["num_hidden_layers"] == 4
assert decoder["inputs"]["past_key_names"] == "past_key_values.%d.key"
assert decoder["inputs"]["past_conv_names"] == "past_key_values.%d.conv_state"

def test_qwen35_text_package_preserves_decoder_only_model_type(self, tmp_path):
import dataclasses

from mobius._model_package import ModelPackage

@dataclasses.dataclass
class FakeConfig:
model_type: str = "qwen3_5_text"
vocab_size: int = 256
hidden_size: int = 64
num_hidden_layers: int = 4
num_attention_heads: int = 4
num_key_value_heads: int = 2
head_dim: int = 16
max_position_embeddings: int = 128
pad_token_id: int = 0

package = ModelPackage(
{
"model": _mock_model(
inputs=[
"input_ids",
"attention_mask",
"position_ids",
"past_key_values.0.conv_state",
"past_key_values.3.key",
"past_key_values.3.value",
],
outputs=[
"logits",
"present.0.conv_state",
"present.3.key",
"present.3.value",
],
)
},
config=FakeConfig(),
)

result = write_ort_genai_config(package, str(tmp_path))

with open(result["genai_config"], encoding="utf-8") as config_file:
generated = json.load(config_file)
assert generated["model"]["type"] == "qwen3_5_text"

def test_olive_renamed_logits_output_is_emitted(self, tmp_path):
pkg = _make_fake_llm_pkg("qwen2")
pkg["model"] = _mock_model(
Expand Down
20 changes: 10 additions & 10 deletions src/mobius/models/qwen35.py
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,10 @@ def preprocess_weights(
- Stripping ``language_model.`` prefix from HF checkpoint keys
(HF stores weights as ``model.language_model.*`` in safetensors)
- Dropping visual encoder keys (``model.visual.*``)
- Dropping multi-token prediction (MTP) keys (``mtp*``):
MTP heads are auxiliary decoding heads used only during
HuggingFace training; they are not needed for inference.
- Dropping multi-token prediction (MTP) keys (``mtp*``). The target
model's normal forward path does not consume this optional
self-speculative drafter; it is packaged separately as
:class:`Qwen35MtpModel` when speculative decoding is requested.
- Weight tying (``tie_word_embeddings``)
"""
cleaned: dict[str, torch.Tensor] = {}
Expand Down Expand Up @@ -376,9 +377,9 @@ def preprocess_weights(
"""Preprocess HuggingFace state dict for Qwen3.5-MoE.

Handles:
- Dropping multi-token prediction (MTP) keys (``mtp_*``, ``mtp.*``):
MTP heads are auxiliary decoding heads used only during
HuggingFace training; they are not needed for inference.
- Dropping multi-token prediction (MTP) keys (``mtp_*``, ``mtp.*``).
The target model's normal forward path does not consume this optional
self-speculative drafter, which has a separate package contract.
- Stripping ``language_model.`` prefix from HF checkpoint keys
(HF stores weights as ``model.language_model.*`` in safetensors)
- Dropping visual encoder keys (``model.visual.*``)
Expand Down Expand Up @@ -537,9 +538,8 @@ def preprocess_weights(
"""
renamed: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
# Drop multi-token prediction (MTP) keys: MTP heads are
# auxiliary decoding heads used only during HuggingFace
# training; they are not needed for inference.
# The standard target package excludes the optional
# self-speculative MTP drafter, which has a separate graph contract.
if key.startswith(("mtp_", "mtp.")):
continue

Expand Down Expand Up @@ -611,7 +611,7 @@ def preprocess_weights(
"""Route language_model weights for standalone decoder build."""
renamed: dict[str, torch.Tensor] = {}
for key, value in state_dict.items():
# Drop MTP heads (training-only auxiliary decoders)
# The optional self-speculative MTP drafter is packaged separately.
if key.startswith(("mtp_", "mtp.")):
continue
stripped = key
Expand Down
Loading
Loading