Skip to content
Merged
2 changes: 1 addition & 1 deletion cmake/deps.txt
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pybind11;https://github.com/pybind/pybind11/archive/refs/tags/v2.13.6.zip;f78029
googletest;https://github.com/google/googletest/archive/530d5c8c84abd2a46f38583ee817743c9b3a42b4.zip;5e3a61db2aa975cfd0f97ba92c818744e7fa7034
microsoft_wil;https://github.com/microsoft/wil/archive/refs/tags/v1.0.230629.1.zip;e4a542a323c070376f7c2d1973d0f7ddbc1d2fa5
directx_headers;https://github.com/microsoft/DirectX-Headers/archive/refs/tags/v1.613.1.zip;47653509a3371eabb156360f42faf582f314bf2e
onnxruntime_extensions;https://github.com/microsoft/onnxruntime-extensions.git;b62dd46f0d58a2f307d17f0a430cb14051349ac6
onnxruntime_extensions;https://github.com/microsoft/onnxruntime-extensions.git;f29716e7f7f60d17b803ca06fde1e088c545ae03

# These two dependencies are for the optional constrained decoding feature (USE_GUIDANCE)
llguidance;https://github.com/microsoft/llguidance.git;94fa39128ef184ffeda33845f6d333f332a34b4d
Expand Down
2 changes: 1 addition & 1 deletion src/models/model_type.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace Generators {
struct ModelType {
inline static bool IsLLM(const std::string& model_type) {
// Large-language model (LLM)
static constexpr std::array<std::string_view, 23> LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma4_text", "gpt2", "gptoss", "granite", "internlm2", "lfm2", "llama", "mistral", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen3", "smollm3"};
static constexpr std::array<std::string_view, 24> LLM = {"chatglm", "decoder", "ernie4_5", "gemma", "gemma2", "gemma3_text", "gemma4_text", "gpt2", "gptoss", "granite", "internlm2", "lfm2", "llama", "mistral", "nemotron", "olmo", "phi", "phimoe", "phi3", "phi3small", "qwen2", "qwen3", "qwen3_5_text", "smollm3"};
return std::find(LLM.begin(), LLM.end(), model_type) != LLM.end();
}

Expand Down
48 changes: 42 additions & 6 deletions src/python/py/models/builders/qwen.py
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,8 @@ class Qwen35TextModel(Model):

def __init__(self, config, io_dtype, onnx_dtype, ep, cache_dir, extra_options):
# Qwen3.5 is a VL model. The decoder takes inputs_embeds.
# When exclude_embeds is explicitly set to False, build as a standalone LLM.
self.is_text_only = extra_options.get("exclude_embeds", None) is False
if "exclude_embeds" not in extra_options:
extra_options["exclude_embeds"] = True
print("Setting exclude_embeds=True for Qwen3.5 VL decoder.")
Expand Down Expand Up @@ -969,8 +971,14 @@ def __init__(self, config, io_dtype, onnx_dtype, ep, cache_dir, extra_options):
# SkipSimplifiedLayerNormalization can be used directly.
self.layernorm_attrs["add_offset"] = 1

# 3D position_ids for mRoPE: [3, batch_size, sequence_length]
self.input_shapes["position_ids"] = [3, "batch_size", "sequence_length"]
# Position IDs input.
# In text-only mode the runtime provides standard 2D [B, S] position_ids.
# We expand them to 3D [3, B, S] inside the graph so mRoPE works unchanged.
# In VL mode the pipeline provides 3D position_ids directly.
Comment thread
kunal-vaishnavi marked this conversation as resolved.
if self.is_text_only:
self.input_shapes["position_ids"] = ["batch_size", "sequence_length"]
else:
self.input_shapes["position_ids"] = [3, "batch_size", "sequence_length"]
self.input_names["position_ids"] = "position_ids"

# mRoPE config
Expand Down Expand Up @@ -1077,6 +1085,34 @@ def _setup_hybrid_cache_io(self):
self.output_names["present.key"] = filtered_key_outputs
self.output_names["present.value"] = filtered_value_outputs

def make_position_ids_reformatting(self):
if self.is_text_only:
# The graph input is 2D position_ids [B, S].
# Expand to 3D [3, B, S] for mRoPE by stacking 3 copies.
Comment thread
kunal-vaishnavi marked this conversation as resolved.
pos_2d = "position_ids"
unsq_name = "/model/position_ids_expand/Unsqueeze"
unsq_output = f"{unsq_name}/output_0"
self.make_unsqueeze(
unsq_name,
[pos_2d, "/model/constants/INT64/[0]"],
ir.DataType.INT64,
[1, "batch_size", "sequence_length"],
)
tile_name = "/model/position_ids_expand/Tile"
tile_output = f"{tile_name}/output_0"
self.make_tile(
tile_name,
[unsq_output, "/model/constants/INT64/[3, 1, 1]"],
ir.DataType.INT64,
[3, "batch_size", "sequence_length"],
)
return tile_output
return self.input_names["position_ids"]

def make_preprocessing_nodes(self):
super().make_preprocessing_nodes()
self.position_ids_reformatted = self.make_position_ids_reformatting()

def make_attention(self, layer_id, attention, root_input, **kwargs):
"""Dispatch to full attention or GatedDeltaNet based on layer type."""
if self.layer_types[layer_id] == "linear_attention":
Expand Down Expand Up @@ -1320,10 +1356,10 @@ def _get_shared_l2_eps(self):
def _make_mrope_cos_sin(self, basename):
"""Build interleaved mRoPE cos/sin from pre-computed cache + position_ids.

Input: position_ids [3, B, S]
Input: position_ids [3, B, S] (from self.position_ids_reformatted)
Output: cos [B, S, rdim_half], sin [B, S, rdim_half]
"""
pos_ids = self.input_names["position_ids"]
pos_ids = self.position_ids_reformatted
cos_cache = "model.rotary_emb.cos_cache"
sin_cache = "model.rotary_emb.sin_cache"
h_mask = "model.rotary_emb.h_mask"
Expand Down Expand Up @@ -1388,7 +1424,7 @@ def _make_synthetic_position_ids(self):
created once and reused across all layers and Q/K calls.
"""
basename = "/model/attn/synthetic_pos_ids"
pos_ids_input = self.input_names["position_ids"]
pos_ids_input = self.position_ids_reformatted

# Shape(position_ids) → [3, B, S]
shape_name = f"{basename}/Shape"
Expand Down Expand Up @@ -1997,7 +2033,7 @@ def make_genai_config(self, model_name_or_path, extra_kwargs, out_dir):
"model_type": self.model_type,
}
self.num_layers = len(self.layer_types)
self.model_type = "Qwen3_5ForConditionalGeneration"
self.model_type = "Qwen3_5_textForCausalLM" if self.is_text_only else "Qwen3_5ForConditionalGeneration"
self.input_names["past_key_values.key"] = "past_key_values.%d.key"
self.input_names["past_key_values.value"] = "past_key_values.%d.value"
self.output_names["present.key"] = "present.%d.key"
Expand Down
65 changes: 65 additions & 0 deletions test/python/test_qwen35_text_only.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License

"""
Unit tests for Qwen3.5 text-only (LLM) model.

Tests cover model loading, tokenizer creation, generator creation, and
basic text generation for the text-only variant of Qwen3.5 (model type
"qwen3_5_text"), which uses 2D position_ids and hybrid KV/recurrent state.

Usage:
pytest test_qwen35_text_only.py --test_models=test/test_models
"""

import os
from pathlib import Path

import onnxruntime_genai as og
import pytest

MODEL_DIR = "qwen35-text-only"


def _model_path(test_data_path):
return os.fspath(Path(test_data_path) / MODEL_DIR)


def _skip_if_missing(test_data_path):
path = _model_path(test_data_path)
if not os.path.exists(path):
pytest.skip(f"{MODEL_DIR} test model not found at {path}")
return path


def test_qwen35_text_only_model_loads(test_data_path):
"""Test that a Qwen3.5 text-only model loads successfully."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
assert model is not None


def test_qwen35_text_only_generator_creates(test_data_path):
"""Test that a Generator can be created for the text-only model.
Validates that hybrid state auto-discovery works with qwen3_5_text type."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
params = og.GeneratorParams(model)
params.set_search_options(max_length=10)
generator = og.Generator(model, params)
assert generator is not None


def test_qwen35_text_only_accepts_input_ids(test_data_path):
"""Test that the text-only model accepts input_ids (not inputs_embeds).
The dummy model uses Identity pass-through which doesn't support KV cache
shape changes, so we only validate that the generator constructs and
is ready to accept tokens."""
model_path = _skip_if_missing(test_data_path)
model = og.Model(model_path)
params = og.GeneratorParams(model)
params.set_search_options(max_length=5)

# The model should accept raw token IDs (input_ids, not inputs_embeds)
generator = og.Generator(model, params)
assert generator is not None
Loading