Skip to content

Commit b205844

Browse files
authored
Merge branch 'main' into dependabot/pip/requirements/lintrunner/ruff-0.16.0
2 parents 98685df + 3dc671c commit b205844

21 files changed

Lines changed: 551 additions & 63 deletions

.github/workflows/publish.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,11 @@ jobs:
2727
name: Build distribution
2828
runs-on: ubuntu-latest
2929
steps:
30-
- uses: actions/checkout@v6
30+
- uses: actions/checkout@v7
3131
with:
3232
persist-credentials: false
3333
- name: Setup Python
34-
uses: actions/setup-python@v6
34+
uses: actions/setup-python@v7
3535
with:
3636
python-version: "3.12"
3737
- name: Install build dependencies

CODE_OF_CONDUCT.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Microsoft Open Source Code of Conduct
2+
3+
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
4+
5+
Resources:
6+
7+
- [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/)
8+
- [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/)
9+
- Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns
10+
- Employees can reach out at [aka.ms/opensource/moderation-support](https://aka.ms/opensource/moderation-support)

scripts/generate_golden.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,23 @@ def _generate_causal_lm(case: TestCase, json_path: Path, device: str) -> None:
237237
_apply_nemotron_h_generate_patch(model)
238238

239239
model_device = _get_model_device(model, device)
240+
gen_ids = torch.from_numpy(input_ids).to(model_device)
241+
max_new = case.generation_params.get("max_new_tokens", 20)
240242
with torch.no_grad():
241-
gen_output = model.generate(
242-
torch.from_numpy(input_ids).to(model_device),
243-
max_new_tokens=case.generation_params.get("max_new_tokens", 20),
244-
do_sample=False,
245-
)
243+
try:
244+
gen_output = model.generate(gen_ids, max_new_tokens=max_new, do_sample=False)
245+
except ValueError as e:
246+
# All-attention GraniteMoeHybrid variants (e.g. granite-4.0-1b)
247+
# trip transformers' hybrid Mamba/attention generation cache,
248+
# which assumes at least one linear-attention (Mamba) layer:
249+
# "`has_previous_state` can only be called on LinearAttention
250+
# layers". Greedy output is cache-independent, so fall back to
251+
# the (slower) cache-free path.
252+
if "has_previous_state" not in str(e):
253+
raise
254+
gen_output = model.generate(
255+
gen_ids, max_new_tokens=max_new, do_sample=False, use_cache=False
256+
)
246257
generated_ids = gen_output[0, seq_len:].cpu().numpy()
247258

248259
save_golden_ref(

src/mobius/_builder.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,7 +417,8 @@ def build(
417417
can use ``GroupQueryAttention`` on GQA-capable execution providers.
418418
Raises :class:`ValueError` if the resolved ``model_type`` has no
419419
text-only sibling. Currently supported for ``gemma4_unified``
420-
(``google/gemma-4-12B``).
420+
(``google/gemma-4-12B``) and ``qwen3_5_moe_vl``
421+
(``Qwen/Qwen3.6-35B-A3B``).
421422
422423
Returns:
423424
A :class:`ModelPackage` containing the built model(s).

src/mobius/_configs/_base.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2226,10 +2226,39 @@ class GraniteMoeHybridConfig(BambaConfig):
22262226

22272227
@classmethod
22282228
def from_transformers(cls, config, parent_config=None) -> GraniteMoeHybridConfig:
2229-
# Reuse BambaConfig.from_transformers for mamba fields + layer_types conversion
2230-
# (converts HF "mamba"→"mamba2" and "attention"→"full_attention")
2229+
# Reuse BambaConfig.from_transformers for mamba fields, MoE/RoPE/multiplier
2230+
# extraction, then rebuild layer_types from GraniteMoeHybrid's own naming.
22312231
bamba = BambaConfig.from_transformers(config, parent_config)
22322232
bamba_fields = _shallow_fields(bamba)
2233+
2234+
# GraniteMoeHybrid names layers "full_attention" / "linear_attention"
2235+
# (linear_attention == Mamba2/SSD), unlike Bamba's "attention" / "mamba".
2236+
raw_layer_types = (
2237+
getattr(config, "layer_types", None)
2238+
or getattr(config, "layers_block_type", None)
2239+
or []
2240+
)
2241+
_attn = {"full_attention", "attention"}
2242+
_mamba = {"linear_attention", "mamba", "mamba2"}
2243+
layer_types: list[str] = []
2244+
for ltype in raw_layer_types:
2245+
if ltype in _attn:
2246+
layer_types.append("full_attention")
2247+
elif ltype in _mamba:
2248+
layer_types.append("mamba2")
2249+
else:
2250+
raise ValueError(f"Unknown GraniteMoeHybrid layer type: {ltype!r}")
2251+
if layer_types:
2252+
bamba_fields["layer_types"] = layer_types
2253+
2254+
# Respect position_embedding_type: GraniteMoeHybrid checkpoints ship
2255+
# default ``rope_parameters`` even for the NoPE variant
2256+
# (granite-4.0-tiny-preview: position_embedding_type='nope'). Only apply
2257+
# RoPE when explicitly requested; otherwise disable it so
2258+
# ``initialize_rope`` returns None and attention runs NoPE.
2259+
if getattr(config, "position_embedding_type", "rope") != "rope":
2260+
bamba_fields["rope_type"] = None
2261+
22332262
return cls(
22342263
**bamba_fields,
22352264
shared_intermediate_size=getattr(config, "shared_intermediate_size", 1024),

src/mobius/_registry.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,6 +604,13 @@ def _detect_fallback_registration(hf_config) -> ModelRegistration | None:
604604
"qwen2_vl_text": ModelRegistration(Qwen25VLTextModel),
605605
"qwen3_5": ModelRegistration(Qwen35VL3ModelCausalLMModel, task="hybrid-qwen-vl"),
606606
"qwen3_5_moe_vl": ModelRegistration(Qwen35MoEVL3ModelCausalLMModel, task="hybrid-qwen-vl"),
607+
# Text-only sibling of ``qwen3_5_moe_vl`` (Qwen3.6-35B-A3B). The MoE
608+
# backbone ``Qwen35MoECausalLMModel`` already strips ``language_model.``
609+
# and drops ``visual.``/MTP keys, so it consumes the VL checkpoint's text
610+
# weights directly; ``build(..., text_only=True)`` routes here via
611+
# ``_TEXT_ONLY_MODEL_TYPE``. It also matches the VL ``text_config``'s own
612+
# ``model_type=qwen3_5_moe_text`` so that config resolves cleanly.
613+
"qwen3_5_moe_text": ModelRegistration(Qwen35MoECausalLMModel),
607614
"qwen3_5_vl": ModelRegistration(Qwen35VL3ModelCausalLMModel, task="hybrid-qwen-vl"),
608615
"qwen3_5_vl_text": ModelRegistration(Qwen35VLTextModel),
609616
"qwen3_vl": ModelRegistration(Qwen3VL3ModelCausalLMModel, task="qwen-vl"),
@@ -791,6 +798,12 @@ def _create_default_registry() -> ModelRegistry:
791798
_TEXT_ONLY_MODEL_TYPE: dict[str, str] = {
792799
"gemma4_unified": "gemma4_unified_text",
793800
"gemma4_unified_text": "gemma4_unified_text",
801+
# Qwen3.5-MoE-VL (Qwen3.6-35B-A3B): export just the hybrid MoE text
802+
# backbone as a standalone decoder-only LLM. The builder overrides
803+
# ``qwen3_5_moe`` -> ``qwen3_5_moe_vl`` when a ``vision_config`` is present,
804+
# so the text-only override keys off the VL type here.
805+
"qwen3_5_moe_vl": "qwen3_5_moe_text",
806+
"qwen3_5_moe_text": "qwen3_5_moe_text",
794807
}
795808

796809

@@ -894,6 +907,7 @@ def _create_default_registry() -> ModelRegistry:
894907
"qwen2_moe": "Qwen/Qwen1.5-MoE-A2.7B-Chat",
895908
"qwen3_moe": "Qwen/Qwen3-30B-A3B",
896909
"qwen3_5_moe": "Qwen/Qwen3.5-MoE-A3B-128K",
910+
"qwen3_5_moe_text": "Qwen/Qwen3.6-35B-A3B",
897911
"qwen3_next": "Qwen/Qwen3-235B-A22B",
898912
"granitemoe": "ibm-granite/granite-3.0-1b-a400m-instruct",
899913
"olmoe": "allenai/OLMoE-1B-7B-0924",
@@ -1141,6 +1155,7 @@ def _create_default_registry() -> ModelRegistry:
11411155
"qwen3_moe": "qwen",
11421156
"qwen3_5_text": "qwen",
11431157
"qwen3_5_moe": "qwen",
1158+
"qwen3_5_moe_text": "qwen",
11441159
"qwen3_next": "qwen",
11451160
"qwen2_vl": "qwen",
11461161
"qwen2_vl_text": "qwen",

src/mobius/_testing/torch_reference.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,18 @@ def torch_forward(
334334
)
335335
kwargs["past_key_values"] = cache
336336

337-
outputs = model(**kwargs)
337+
try:
338+
outputs = model(**kwargs)
339+
except ValueError as e:
340+
# All-attention hybrid models (e.g. GraniteMoeHybrid granite-4.0-1b)
341+
# trip transformers' recurrent-mask builder, which assumes the hybrid
342+
# cache contains a linear-attention (Mamba) layer: "`has_previous_state`
343+
# can only be called on LinearAttention layers". A single-pass forward's
344+
# logits don't depend on caching, so retry without a cache.
345+
if "has_previous_state" not in str(e) or "past_key_values" in kwargs:
346+
raise
347+
kwargs["use_cache"] = False
348+
outputs = model(**kwargs)
338349
logits = outputs.logits.cpu().numpy()
339350

340351
# Extract KV cache if available (Mamba models don't have it)

src/mobius/functions/packed_multi_head_attention.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@
1717
1818
# Build block-diagonal bias (0 for same segment, -inf for different)
1919
same = Equal(segment_ids[:, None], segment_ids[None, :])
20-
bias = Where(same, 0.0, -10000.0)
20+
attn_bias = Where(same, 0.0, -10000.0)
2121
2222
# Standard Attention
23-
output = Attention(query, key, value, bias,
23+
output = Attention(query, key, value, attn_bias,
2424
q_num_heads=<num_heads>, kv_num_heads=<num_heads>,
2525
scale=<scale>)
2626
27-
The ``token_offset`` input is consumed by the native kernel but is
28-
unused by the fallback body (segment boundaries from
29-
``cumulative_sequence_length`` are sufficient).
27+
The optional ``bias`` (slot 4) and ``token_offset`` inputs are consumed by
28+
the native kernel but are unused by the fallback body (the ``attn_bias``
29+
computed above from ``cumulative_sequence_length`` is sufficient, and is
30+
unrelated to the formal ``bias`` input). They are still declared
31+
as formal inputs to preserve the positional slot alignment expected by the
32+
ORT ``PackedMultiHeadAttention`` signature.
3033
3134
Attributes:
3235
num_heads (int): Number of attention heads.
@@ -53,6 +56,7 @@ def packed_multi_head_attention() -> ir.Function:
5356
query: (token_count, hidden_size)
5457
key: (token_count, hidden_size)
5558
value: (token_count, v_hidden_size)
59+
bias: (optional) — unused in fallback
5660
token_offset: (batch_size, sequence_length) — unused in fallback
5761
cumulative_sequence_length: (batch_size + 1,) INT32
5862
@@ -69,9 +73,14 @@ def body(
6973
query_input,
7074
key_input,
7175
value_input,
76+
bias_input,
7277
token_offset_input,
7378
cumulative_sequence_length_input,
7479
):
80+
# bias_input is the optional slot-4 input of the ORT
81+
# PackedMultiHeadAttention signature. It is unused by this fallback
82+
# (the block-diagonal bias is reconstructed from cu_seqlens below) but
83+
# must exist as a formal input to keep the positional slots aligned.
7584
# --- Compute sequence length from query shape ---
7685
# query: (token_count, hidden_size)
7786
token_count = op.Shape(query_input, start=0, end=1)
@@ -159,6 +168,7 @@ def body(
159168
ir.Value(name="query"),
160169
ir.Value(name="key"),
161170
ir.Value(name="value"),
171+
ir.Value(name="bias"),
162172
ir.Value(name="token_offset"),
163173
ir.Value(name="cumulative_sequence_length"),
164174
],
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Unit tests for the PackedMultiHeadAttention fallback ``ir.Function``.
5+
6+
These guard the formal-input arity/order of the standard-ONNX fallback for
7+
``com.microsoft::PackedMultiHeadAttention``. ORT's op has a 6-7 input
8+
positional signature::
9+
10+
query, key(opt), value(opt), bias(opt),
11+
token_offset, cumulative_sequence_length, attention_bias(opt)
12+
13+
Because ``token_offset`` / ``cumulative_sequence_length`` occupy positional
14+
slots 5 and 6, the optional ``bias`` at slot 4 must exist as a formal input
15+
even though the fallback body ignores it. Call sites emit 6 inputs
16+
``(q, k, v, "", token_offset, cu_seqlens)``; if the function declared only 5
17+
formals, onnx-genai's function-inline admission rejects the call with a
18+
FunctionArityMismatch (``call.input.len()=6 > func.input.len()=5``).
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from mobius.functions.packed_multi_head_attention import (
24+
packed_multi_head_attention,
25+
)
26+
27+
EXPECTED_INPUT_ORDER = [
28+
"query",
29+
"key",
30+
"value",
31+
"bias",
32+
"token_offset",
33+
"cumulative_sequence_length",
34+
]
35+
36+
37+
def test_packed_mha_declares_six_positional_formal_inputs() -> None:
38+
func = packed_multi_head_attention()
39+
40+
actual_order = [value.name for value in func.inputs]
41+
# Assert the full positional order, not just the count: the ``bias`` slot
42+
# must sit at index 3 (between ``value`` and ``token_offset``) to match the
43+
# ORT PackedMultiHeadAttention signature.
44+
assert actual_order == EXPECTED_INPUT_ORDER
45+
assert func.inputs[3].name == "bias"
46+
47+
48+
def test_packed_mha_admits_six_input_call() -> None:
49+
func = packed_multi_head_attention()
50+
51+
# A call site emits 6 inputs: (q, k, v, "", token_offset, cu_seqlens).
52+
# onnx-genai admits a call when len(call inputs) <= len(func inputs), so
53+
# the function must declare at least 6 formals for the call to be inlined.
54+
call_input_count = 6
55+
assert call_input_count <= len(func.inputs)
56+
57+
58+
def test_packed_mha_function_identity() -> None:
59+
func = packed_multi_head_attention()
60+
61+
assert func.domain == "com.microsoft"
62+
assert func.name == "PackedMultiHeadAttention"

src/mobius/integrations/ort_genai/auto_export.py

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,10 +87,10 @@
8787
# ORT GenAI (see onnxruntime-genai/src/models/model_type.h LLM list).
8888
"hunyuan_v1_dense": "decoder",
8989
"deepseek_v4": "decoder",
90-
# Qwen VL models all use the same GenAI pipeline as qwen2_5_vl
90+
# Qwen VL model families have separate ORT GenAI model types.
9191
"qwen2_vl": "qwen2_5_vl",
92-
"qwen3_vl": "qwen2_5_vl",
93-
"qwen3_vl_text": "qwen2_5_vl",
92+
"qwen3_vl": "qwen3_vl",
93+
"qwen3_vl_text": "qwen3_vl",
9494
"qwen3_5": "qwen2_5_vl",
9595
"qwen3_5_vl": "qwen2_5_vl",
9696
}
@@ -880,6 +880,16 @@ def _write_genai_config(
880880
if sms is not None:
881881
vision_kwargs["spatial_merge_size"] = sms
882882
vision_kwargs["config_filename"] = "processor_config.json"
883+
if model_type in {"qwen3_vl", "qwen3_vl_text"}:
884+
patch_size = getattr(vision_cfg, "patch_size", None)
885+
window_size = getattr(vision_cfg, "window_size", None)
886+
if patch_size is not None:
887+
vision_kwargs["patch_size"] = patch_size
888+
if window_size is not None:
889+
vision_kwargs["window_size"] = window_size
890+
vision_kwargs["tokens_per_second"] = float(
891+
getattr(config, "tokens_per_second", 2.0)
892+
)
883893

884894
if vision_input_mapping is not None:
885895
vision_kwargs["input_names"] = vision_input_mapping
@@ -889,6 +899,12 @@ def _write_genai_config(
889899
embedding_output_mapping = _introspect_outputs(pkg, "embedding")
890900
if embedding_output_mapping is not None:
891901
vision_kwargs["embedding_output_names"] = embedding_output_mapping
902+
vision_start_token_id = getattr(config, "vision_start_token_id", None)
903+
video_token_id = getattr(config, "video_token_id", None)
904+
if vision_start_token_id is not None:
905+
vision_kwargs["vision_start_token_id"] = vision_start_token_id
906+
if video_token_id is not None:
907+
vision_kwargs["video_token_id"] = video_token_id
892908

893909
generator.with_vision(image_token_id=image_token_id, **vision_kwargs)
894910

@@ -946,8 +962,8 @@ def write_ort_genai_config(
946962
pkg: Already-built :class:`~mobius._model_package.ModelPackage` with
947963
weights applied and ``config`` set.
948964
directory: Output directory (created if needed).
949-
hf_model_id: HuggingFace model ID. When provided, used to fetch token
950-
IDs (``bos``/``eos``/``pad``) and download tokenizer files.
965+
hf_model_id: HuggingFace model ID or local model directory. When provided,
966+
used to fetch token IDs (``bos``/``eos``/``pad``) and copy tokenizer files.
951967
When ``None``, token IDs are read from ``pkg.config`` fields
952968
(``bos_token_id``, ``eos_token_id``, ``pad_token_id``) populated
953969
by :meth:`~mobius._configs.ArchitectureConfig.from_transformers`,
@@ -1112,11 +1128,15 @@ def write_ort_genai_config(
11121128
f.write("\n")
11131129
result["mtp_config"] = mtp_path
11141130

1115-
# Copy tokenizer files — HF Hub takes precedence; local dir is the fallback
1116-
# for --config mode where no HF model ID is available.
1131+
# Copy tokenizer files. A local hf_model_id is a local model directory, not a
1132+
# Hub repo id; copy directly instead of calling hf_hub_download.
11171133
if hf_model_id is not None:
1118-
logger.info("Copying tokenizer files from %s", hf_model_id)
1119-
tokenizer_files = _copy_tokenizer_files(hf_model_id, directory)
1134+
if os.path.isdir(hf_model_id):
1135+
logger.info("Copying tokenizer files from local model directory %s", hf_model_id)
1136+
tokenizer_files = _copy_tokenizer_files_from_local(hf_model_id, directory)
1137+
else:
1138+
logger.info("Copying tokenizer files from %s", hf_model_id)
1139+
tokenizer_files = _copy_tokenizer_files(hf_model_id, directory)
11201140
for tf in tokenizer_files:
11211141
result[tf] = os.path.join(directory, tf)
11221142
elif local_config_dir is not None:

0 commit comments

Comments
 (0)