Skip to content

Commit ec3455b

Browse files
titaiwangmsCopilot
andauthored
Add Pixtral/Ministral3 VLM support: dynamic vision, FP8 dequant, PatchMerger fix, MHA (#130)
## Summary Adds Pixtral/Ministral3 VLM support with 3-model split (vision/embedding/decoder) for onnxruntime-genai. ## Key Changes ### Vision encoder (`_pixtral_vision.py`) - `PixtralVisionTower`: Conv2d patch embedding → RMSNorm → 24-layer transformer with 2D RoPE - `PixtralRoPE2D`: Precomputed 2D rotary position embeddings over spatial grid - `PixtralAttention`: Bidirectional MHA with 2D RoPE (`com.microsoft.MultiHeadAttention`; `op.Attention` fallback for onnx-standard EP) - `Mistral3PatchMerger`: Spatial 2×2 patch merging matching HF `F.unfold` dim-major ordering - `Mistral3MultiModalProjector`: norm → merge → GELU MLP projection ### PatchMerger fix (cosine sim 0.007 → 0.999973) The original transpose permutation `[0,1,3,2,4,5]` produced patch-major ordering, but HF `F.unfold` produces dim-major `[D, ms_h, ms_w]`. The learned `merging_layer` weights expect HF ordering. Fixed to `[0,1,3,5,2,4]`. ### FP8 weight dequantization (`_weight_loading.py`) - Detects FP8 weights, dequantizes via `fp8.to(bf16) * scale` - Always returns new dict (non-mutating, addresses review comment) - Docstring updated to list all removed suffixes ### Attention scale optimization (`_attention.py`) - Moved `attn_scale` Mul before RoPE (scaling commutes with rotation) to preserve `RotaryEmbedding → Attention` pattern for GQA rewrite rule matching ### Task and infrastructure - `PixtralVLTask`: Dynamic H×W vision input with Squeeze for rank-2 output - Registered as `pixtral-vl` task in `_vision_language_3model.py` - Olive-recipe `optimize.py` integration verified ## Testing - Cosine sim vs HuggingFace PyTorch: **0.999973** - E2E `model-mm.py` with fish.jpg and challenge.jpg: correct descriptions - Olive-recipe pipeline export + E2E verified - `test_patch_merger_matches_hf_unfold_ordering`: regression test against HF F.unfold - All 1261 unit tests pass, lintrunner clean ## Files Changed - `src/mobius/components/_pixtral_vision.py` — PatchMerger fix + MHA - `src/mobius/components/_pixtral_vision_test.py` — F.unfold ordering test - `src/mobius/tasks/_vision_language_3model.py` — Squeeze vision output - `src/mobius/_weight_loading.py` — Docstring + mutation fix - `src/mobius/components/_attention.py` — attn_scale before RoPE --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent db7c0ca commit ec3455b

13 files changed

Lines changed: 592 additions & 21 deletions

src/mobius/_registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,14 +476,14 @@ def _detect_fallback_registration(hf_config) -> ModelRegistration | None:
476476
"llava_next": ModelRegistration(LLaVAModel, task="vision-language"),
477477
"llava_next_video": ModelRegistration(LLaVAModel, task="vision-language"),
478478
"llava_onevision": ModelRegistration(LLaVAModel, task="vision-language"),
479-
"mistral3": ModelRegistration(LLaVAModel, task="vision-language"),
479+
"mistral3": ModelRegistration(LLaVAModel, task="pixtral-vl"),
480480
"mllama": ModelRegistration(MllamaCausalLMModel, task="mllama-vision-language"),
481481
"molmo": ModelRegistration(LLaVAModel, task="vision-language"),
482482
"ovis2": ModelRegistration(LLaVAModel, task="vision-language"),
483483
"paligemma": ModelRegistration(LLaVAModel, task="vision-language"),
484484
"phi4_multimodal": ModelRegistration(Phi4MMMultiModalModel, task="phi4mm-multimodal"),
485485
"phi4mm": ModelRegistration(Phi4MMMultiModalModel, task="phi4mm-multimodal"),
486-
"pixtral": ModelRegistration(LLaVAModel, task="vision-language"),
486+
"pixtral": ModelRegistration(LLaVAModel, task="pixtral-vl"),
487487
"qwen2_5_vl": ModelRegistration(Qwen25VLCausalLMModel, task="qwen-vl"),
488488
"qwen2_5_vl_text": ModelRegistration(Qwen25VLTextModel),
489489
"qwen2_vl": ModelRegistration(Qwen25VLCausalLMModel, task="qwen-vl"),

src/mobius/_weight_loading.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,58 @@ def _parallel_download(
147147
return [path_map[f] for f in filenames]
148148

149149

150+
def _dequantize_fp8_weights(state_dict: dict[str, torch.Tensor]) -> dict[str, torch.Tensor]:
151+
"""Dequantize FP8 weights and return a new dict with float tensors.
152+
153+
Some HuggingFace checkpoints (e.g. Ministral-3-3B) store linear layer
154+
weights as float8_e4m3fn with a scalar ``weight_scale_inv`` tensor.
155+
The real weight value is ``fp8_weight.to(bfloat16) * weight_scale_inv``.
156+
157+
Dequantization targets bfloat16 because that is the native training
158+
dtype for FP8-quantized checkpoints — the FP8 values represent
159+
bfloat16 values scaled into the FP8 range.
160+
161+
This function detects FP8 tensors, applies the scale, and removes the
162+
auxiliary ``weight_scale_inv``, ``activation_scale``, and
163+
``input_scale`` tensors.
164+
165+
Returns:
166+
A new dict with FP8 weights dequantized to bfloat16 and the
167+
auxiliary scale tensors removed. Always returns a new dict,
168+
even when no FP8 weights are found.
169+
"""
170+
fp8_dtypes = {torch.float8_e4m3fn, torch.float8_e5m2}
171+
fp8_keys = [k for k, v in state_dict.items() if v.dtype in fp8_dtypes]
172+
if not fp8_keys:
173+
return dict(state_dict)
174+
175+
# Work on a copy to avoid mutating the caller's dict
176+
result = dict(state_dict)
177+
178+
logger.info("Dequantizing %d FP8 weights", len(fp8_keys))
179+
for key in fp8_keys:
180+
# Derive scale key from weight key using suffix replacement to avoid
181+
# replacing '.weight' substrings that appear in the middle of the key
182+
# (e.g. 'model.weight_proj.weight' → 'model.weight_proj.weight_scale_inv').
183+
if key.endswith(".weight"):
184+
scale_key = key[: -len(".weight")] + ".weight_scale_inv"
185+
else:
186+
scale_key = key + "_scale_inv"
187+
188+
if scale_key in result:
189+
# Cast scale to bfloat16 to guarantee the output dtype is bfloat16,
190+
# even when weight_scale_inv is stored as FP32 in the checkpoint.
191+
scale = result[scale_key].to(torch.bfloat16)
192+
result[key] = result[key].to(torch.bfloat16) * scale
193+
else:
194+
logger.warning("FP8 weight '%s' has no scale_inv — casting without scaling", key)
195+
result[key] = result[key].to(torch.bfloat16)
196+
197+
# Remove auxiliary FP8 tensors (not needed in the ONNX graph)
198+
aux_suffixes = (".weight_scale_inv", ".activation_scale", ".input_scale")
199+
return {k: v for k, v in result.items() if not any(k.endswith(s) for s in aux_suffixes)}
200+
201+
150202
def _download_weights(model_id: str) -> dict[str, torch.Tensor]:
151203
"""Download weights from HuggingFace and return as a state dict.
152204
@@ -171,4 +223,6 @@ def _download_weights(model_id: str) -> dict[str, torch.Tensor]:
171223
state_dict: dict[str, torch.Tensor] = {}
172224
for path in tqdm.tqdm(paths, desc="Loading weights"):
173225
state_dict.update(safetensors.torch.load_file(path))
226+
227+
state_dict = _dequantize_fp8_weights(state_dict)
174228
return state_dict

src/mobius/_weight_loading_test.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,3 +518,108 @@ def build(self, module, config):
518518
module = CausalLMModel(config)
519519
pkg = build_from_module(module, config, task=StubTask())
520520
assert pkg["model"].graph.name == "stub"
521+
522+
523+
class TestDequantizeFP8Weights:
524+
"""Tests for _dequantize_fp8_weights."""
525+
526+
def test_no_fp8_returns_unchanged(self):
527+
"""Non-FP8 state dicts pass through unchanged."""
528+
from mobius._weight_loading import _dequantize_fp8_weights
529+
530+
state_dict = {
531+
"layer.weight": torch.randn(4, 4),
532+
"layer.bias": torch.randn(4),
533+
}
534+
result = _dequantize_fp8_weights(state_dict)
535+
assert set(result.keys()) == set(state_dict.keys())
536+
assert torch.equal(result["layer.weight"], state_dict["layer.weight"])
537+
538+
def test_fp8_e4m3fn_dequantized(self):
539+
"""FP8 weights are multiplied by weight_scale_inv."""
540+
from mobius._weight_loading import _dequantize_fp8_weights
541+
542+
fp8_weight = torch.tensor([1.0, 2.0, -1.0, 0.5], dtype=torch.float32).to(
543+
torch.float8_e4m3fn
544+
)
545+
scale_inv = torch.tensor(0.5, dtype=torch.bfloat16)
546+
state_dict = {
547+
"proj.weight": fp8_weight,
548+
"proj.weight_scale_inv": scale_inv,
549+
}
550+
result = _dequantize_fp8_weights(state_dict)
551+
assert "proj.weight" in result
552+
assert "proj.weight_scale_inv" not in result # aux tensor removed
553+
assert result["proj.weight"].dtype == torch.bfloat16
554+
# Verify dequant: fp8→bf16 * scale_inv
555+
expected = fp8_weight.to(torch.bfloat16) * scale_inv
556+
assert torch.allclose(result["proj.weight"], expected)
557+
558+
def test_activation_scale_removed(self):
559+
"""Auxiliary activation_scale tensors are removed."""
560+
from mobius._weight_loading import _dequantize_fp8_weights
561+
562+
state_dict = {
563+
"proj.weight": torch.tensor([1.0], dtype=torch.float32).to(torch.float8_e4m3fn),
564+
"proj.weight_scale_inv": torch.tensor(1.0, dtype=torch.bfloat16),
565+
"proj.activation_scale": torch.tensor(1.0, dtype=torch.bfloat16),
566+
}
567+
result = _dequantize_fp8_weights(state_dict)
568+
assert "proj.activation_scale" not in result
569+
570+
def test_suffix_replace_not_greedy(self):
571+
"""The scale key derivation uses suffix replacement, not global replace.
572+
573+
For a key like 'model.weight_proj.weight', the scale key should be
574+
'model.weight_proj.weight_scale_inv' (not 'model.weight_scale_inv_proj.weight_scale_inv').
575+
"""
576+
from mobius._weight_loading import _dequantize_fp8_weights
577+
578+
fp8_weight = torch.tensor([1.0], dtype=torch.float32).to(torch.float8_e4m3fn)
579+
scale = torch.tensor(2.0, dtype=torch.bfloat16)
580+
state_dict = {
581+
"model.weight_proj.weight": fp8_weight,
582+
"model.weight_proj.weight_scale_inv": scale,
583+
}
584+
result = _dequantize_fp8_weights(state_dict)
585+
assert "model.weight_proj.weight" in result
586+
assert result["model.weight_proj.weight"].dtype == torch.bfloat16
587+
588+
def test_missing_scale_casts_without_scaling(self):
589+
"""FP8 weight without scale_inv is cast to bfloat16 without scaling."""
590+
from mobius._weight_loading import _dequantize_fp8_weights
591+
592+
fp8_weight = torch.tensor([1.0, 2.0], dtype=torch.float32).to(torch.float8_e4m3fn)
593+
state_dict = {"orphan.weight": fp8_weight}
594+
result = _dequantize_fp8_weights(state_dict)
595+
assert result["orphan.weight"].dtype == torch.bfloat16
596+
597+
def test_fp32_scale_produces_bf16_output(self):
598+
"""FP32 weight_scale_inv should still produce bfloat16 output."""
599+
from mobius._weight_loading import _dequantize_fp8_weights
600+
601+
fp8_weight = torch.tensor([1.0, 2.0], dtype=torch.float32).to(torch.float8_e4m3fn)
602+
# Scale stored as FP32 (common for scalar scales in real checkpoints)
603+
scale_inv = torch.tensor(0.5, dtype=torch.float32)
604+
state_dict = {
605+
"proj.weight": fp8_weight,
606+
"proj.weight_scale_inv": scale_inv,
607+
}
608+
result = _dequantize_fp8_weights(state_dict)
609+
assert result["proj.weight"].dtype == torch.bfloat16, (
610+
f"Expected bfloat16, got {result['proj.weight'].dtype}"
611+
)
612+
613+
def test_does_not_mutate_input(self):
614+
"""_dequantize_fp8_weights should not mutate the input dict."""
615+
from mobius._weight_loading import _dequantize_fp8_weights
616+
617+
fp8_weight = torch.tensor([1.0], dtype=torch.float32).to(torch.float8_e4m3fn)
618+
scale = torch.tensor(1.0, dtype=torch.bfloat16)
619+
original = {
620+
"proj.weight": fp8_weight,
621+
"proj.weight_scale_inv": scale,
622+
}
623+
original_keys = set(original.keys())
624+
_dequantize_fp8_weights(original)
625+
assert set(original.keys()) == original_keys, "Input dict was mutated"

src/mobius/components/_attention.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ def forward(
304304

305305
# Apply rotary position embeddings (skip when not provided)
306306
if position_embeddings is not None:
307+
# Apply llama_4_attn_scale if present (Ministral3/Mistral4).
308+
# The scale is computed from position_ids by the RoPE module
309+
# and passed as the 3rd element of position_embeddings.
310+
# Applied BEFORE RoPE so the graph keeps the
311+
# RotaryEmbedding → Attention pattern that the
312+
# RotaryAttentionToGQA rewrite rule matches. Scaling
313+
# commutes with rotation: scale(RoPE(q)) == RoPE(scale(q)).
314+
if len(position_embeddings) > 2:
315+
attn_scale = position_embeddings[2]
316+
query_states = op.Mul(query_states, attn_scale)
317+
307318
query_states = apply_rotary_pos_emb(
308319
op,
309320
x=query_states,

src/mobius/components/_pixtral_vision.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from onnxscript import nn
2525
from onnxscript._internal import builder
2626

27+
from mobius._build_context import ep_capabilities
2728
from mobius._configs import ArchitectureConfig
2829
from mobius.components._common import Linear
2930
from mobius.components._conv import Conv2dNoBias
@@ -174,17 +175,31 @@ def forward(
174175
num_heads=self._num_heads,
175176
)
176177

177-
# Bidirectional attention (is_causal=0, no KV cache)
178+
# Bidirectional attention (no causal mask, no KV cache).
179+
# Use com.microsoft.MultiHeadAttention for all EPs that support
180+
# custom-domain ops (it has fused kernels on CUDA/DML and runs
181+
# correctly on CPU). Fall back to standard opset-23 Attention
182+
# for onnx-standard EP which prohibits custom-domain ops.
178183
scale = float(1.0 / (self._head_dim**0.5))
179-
attn_output = op.Attention(
180-
q,
181-
k,
182-
v,
183-
q_num_heads=self._num_heads,
184-
kv_num_heads=self._num_heads,
185-
scale=scale,
186-
is_causal=0,
187-
)
184+
if ep_capabilities().name == "onnx-standard":
185+
attn_output = op.Attention(
186+
q,
187+
k,
188+
v,
189+
q_num_heads=self._num_heads,
190+
kv_num_heads=self._num_heads,
191+
scale=scale,
192+
is_causal=0,
193+
)
194+
else:
195+
attn_output = op.MultiHeadAttention(
196+
q,
197+
k,
198+
v,
199+
num_heads=self._num_heads,
200+
scale=scale,
201+
_domain="com.microsoft",
202+
)
188203
return self.o_proj(op, attn_output)
189204

190205

@@ -349,10 +364,15 @@ def forward(
349364
)
350365
x = op.Reshape(hidden_states, shape_6d)
351366

352-
# Transpose: [batch, H/ms, W/ms, ms, ms, D]
353-
x = op.Transpose(x, perm=[0, 1, 3, 2, 4, 5])
367+
# Transpose to match HuggingFace F.unfold ordering (dim-major).
368+
# F.unfold groups elements as [D, ms_h, ms_w] per spatial position,
369+
# meaning the hidden dim D is the outermost loop. To reproduce
370+
# this with reshape + transpose:
371+
# from: [batch, H/ms, ms, W/ms, ms, D]
372+
# to: [batch, H/ms, W/ms, D, ms, ms]
373+
x = op.Transpose(x, perm=[0, 1, 3, 5, 2, 4])
354374

355-
# Flatten: [batch, (H/ms)*(W/ms), ms*ms*D]
375+
# Flatten: [batch, (H/ms)*(W/ms), D*ms*ms]
356376
merged_count = op.Mul(h_m_1d, w_m_1d)
357377
shape_3d = op.Concat(
358378
batch,

src/mobius/components/_pixtral_vision_test.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from __future__ import annotations
77

88
import numpy as np
9+
import onnx_ir as ir
10+
import onnxruntime as ort
911

1012
from mobius._configs import ArchitectureConfig, VisionConfig
1113
from mobius.components._pixtral_vision import (
@@ -103,3 +105,98 @@ def test_patch_merger_builds():
103105
merger = Mistral3PatchMerger(hidden_size=32, spatial_merge_size=2)
104106
# input_dim = 32 * 2 * 2 = 128, output_dim = 32
105107
assert list(merger.merging_layer.weight.shape) == [32, 128]
108+
109+
110+
def test_patch_merger_matches_hf_unfold_ordering():
111+
"""PatchMerger element ordering matches HuggingFace F.unfold (dim-major).
112+
113+
HF uses ``F.unfold(image_grid, kernel_size=ms, stride=ms)`` which
114+
groups elements as ``[D, ms_h, ms_w]`` per spatial position (dim is
115+
the outermost loop). The ONNX implementation must reproduce this
116+
ordering so the learned ``merging_layer`` projection is correct.
117+
"""
118+
import tempfile
119+
120+
import torch
121+
from onnxscript._internal.builder import GraphBuilder
122+
123+
hidden_size = 8
124+
ms = 2
125+
grid_h, grid_w = 4, 4
126+
seq_len = grid_h * grid_w
127+
128+
rng = np.random.default_rng(42)
129+
x = rng.standard_normal((1, seq_len, hidden_size)).astype(np.float32)
130+
131+
# HF reference: F.unfold ordering
132+
x_torch = torch.from_numpy(x.squeeze(0)) # (seq_len, D)
133+
image_grid = x_torch.view(grid_h, grid_w, hidden_size).permute(2, 0, 1).unsqueeze(0)
134+
grid = torch.nn.functional.unfold(image_grid, kernel_size=ms, stride=ms)
135+
hf_merged = grid.view(hidden_size * ms * ms, -1).t().numpy() # (num_merged, D*ms*ms)
136+
137+
# Build ONNX model that performs only the reshape+transpose+flatten
138+
# (no linear projection) so we can compare the raw merge ordering.
139+
x_input = ir.Value(
140+
name="x",
141+
shape=ir.Shape([1, seq_len, hidden_size]),
142+
type=ir.TensorType(ir.DataType.FLOAT),
143+
)
144+
gh_input = ir.Value(
145+
name="grid_h",
146+
shape=ir.Shape([]),
147+
type=ir.TensorType(ir.DataType.INT64),
148+
)
149+
gw_input = ir.Value(
150+
name="grid_w",
151+
shape=ir.Shape([]),
152+
type=ir.TensorType(ir.DataType.INT64),
153+
)
154+
graph = ir.Graph(
155+
inputs=[x_input, gh_input, gw_input],
156+
outputs=[],
157+
nodes=[],
158+
name="test_merge_ordering",
159+
opset_imports={"": 23},
160+
)
161+
gb = GraphBuilder(graph)
162+
op = gb.op
163+
164+
# Reproduce the PatchMerger reshape+transpose+flatten logic
165+
batch = op.Shape(x_input, start=0, end=1)
166+
d = op.Shape(x_input, start=2, end=3)
167+
ms_scalar = op.Constant(value_int=ms)
168+
h_m = op.Div(gh_input, ms_scalar)
169+
w_m = op.Div(gw_input, ms_scalar)
170+
ms_1d = op.Constant(value_ints=[ms])
171+
h_m_1d = op.Reshape(h_m, op.Constant(value_ints=[1]))
172+
w_m_1d = op.Reshape(w_m, op.Constant(value_ints=[1]))
173+
shape_6d = op.Concat(batch, h_m_1d, ms_1d, w_m_1d, ms_1d, d, axis=0)
174+
merged = op.Reshape(x_input, shape_6d)
175+
merged = op.Transpose(merged, perm=[0, 1, 3, 5, 2, 4])
176+
merged_count = op.Mul(h_m_1d, w_m_1d)
177+
shape_3d = op.Concat(batch, merged_count, op.Constant(value_ints=[-1]), axis=0)
178+
result = op.Reshape(merged, shape_3d)
179+
result.name = "output"
180+
graph.outputs.append(result)
181+
182+
model = ir.Model(graph, ir_version=11)
183+
184+
with tempfile.NamedTemporaryFile(suffix=".onnx", delete=True) as f:
185+
ir.save(model, f.name)
186+
sess = ort.InferenceSession(f.name, providers=["CPUExecutionProvider"])
187+
onnx_out = sess.run(
188+
None,
189+
{
190+
"x": x,
191+
"grid_h": np.array(grid_h, dtype=np.int64),
192+
"grid_w": np.array(grid_w, dtype=np.int64),
193+
},
194+
)[0]
195+
196+
np.testing.assert_allclose(
197+
onnx_out.squeeze(0),
198+
hf_merged,
199+
atol=1e-5,
200+
rtol=1e-5,
201+
err_msg="PatchMerger ordering does not match HF F.unfold",
202+
)

0 commit comments

Comments
 (0)