Skip to content
Merged
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
9 changes: 7 additions & 2 deletions src/models/multi_modal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -650,8 +650,13 @@ DecoderState::DecoderState(const MultiModalLanguageModel& model, DeviceSpan<int3
recurrent_state_{CreateRecurrentState(*this)} {
inputs_embeds_.Add();

// Some multimodal decoders (e.g., Gemma4) require input_ids alongside inputs_embeds
if (model_.session_info_.HasInput(model_.config_->model.decoder.inputs.input_ids)) {
// Some multimodal decoders (e.g., Gemma4) require input_ids alongside inputs_embeds.
// Use a decoder-only SessionInfo to avoid false positives from the embedding session
// (which always has input_ids), preventing incorrect injection into decoders that only
// accept inputs_embeds (e.g., Qwen3.5, Mistral3/Pixtral).
SessionInfo decoder_only_info;
decoder_only_info.Add(*model_.decoder_session_);
if (decoder_only_info.HasInput(model_.config_->model.decoder.inputs.input_ids)) {
decoder_input_ids_ = std::make_unique<DefaultInputIDs>(*this);
decoder_input_ids_->Add();
Comment thread
apsonawane marked this conversation as resolved.
Outdated
}
Expand Down
77 changes: 72 additions & 5 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") is False
Comment thread
apsonawane marked this conversation as resolved.
Outdated
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,35 @@ def _setup_hybrid_cache_io(self):
self.output_names["present.key"] = filtered_key_outputs
self.output_names["present.value"] = filtered_value_outputs

def make_inputs_and_outputs(self):
super().make_inputs_and_outputs()

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"],
)
expand_name = "/model/position_ids_expand/Expand"
expand_output = f"{expand_name}/output_0"
self.make_expand(
expand_name,
[unsq_output, "/model/constants/INT64/[3, 1, 1]"],
Comment thread
apsonawane marked this conversation as resolved.
Outdated
ir.DataType.INT64,
[3, "batch_size", "sequence_length"],
)
# Store the 3D position_ids name for mRoPE usage
# Keep self.input_names["position_ids"] as "position_ids" for genai_config
self._pos_ids_3d = expand_output
Comment thread
apsonawane marked this conversation as resolved.
Outdated
else:
self._pos_ids_3d = self.input_names["position_ids"]

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 +1357,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._pos_ids_3d)
Output: cos [B, S, rdim_half], sin [B, S, rdim_half]
"""
pos_ids = self.input_names["position_ids"]
pos_ids = self._pos_ids_3d
cos_cache = "model.rotary_emb.cos_cache"
Comment thread
apsonawane marked this conversation as resolved.
Outdated
sin_cache = "model.rotary_emb.sin_cache"
h_mask = "model.rotary_emb.h_mask"
Expand Down Expand Up @@ -1997,7 +2034,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 All @@ -2012,3 +2049,33 @@ def make_genai_config(self, model_name_or_path, extra_kwargs, out_dir):
del self.input_names["past_key_values.value"]
del self.output_names["present.key"]
del self.output_names["present.value"]

def save_processing(self, model_name_or_path, extra_kwargs, out_dir):
super().save_processing(model_name_or_path, extra_kwargs, out_dir)
# Patch tokenizer regex: remove \p{M} (Unicode Mark category) which is
# unsupported by the C++ std::regex engine in onnxruntime-extensions.
import json
import os

def _patch_pM(obj):
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, str) and "\\p{M}" in v:
obj[k] = v.replace("[\\p{L}\\p{M}]", "\\p{L}").replace(
"[^\\s\\p{L}\\p{M}\\p{N}]", "[^\\s\\p{L}\\p{N}]"
)
else:
_patch_pM(v)
elif isinstance(obj, list):
for item in obj:
_patch_pM(item)

for fname in ("tokenizer_config.json", "tokenizer.json"):
fpath = os.path.join(out_dir, fname)
if os.path.exists(fpath):
with open(fpath, "r") as f:
data = json.load(f)
_patch_pM(data)
with open(fpath, "w") as f:
Comment thread
apsonawane marked this conversation as resolved.
Outdated
json.dump(data, f, indent=2, ensure_ascii=False)
Comment thread
apsonawane marked this conversation as resolved.
Outdated
print(f"Patched unsupported \\p{{M}} regex in {fname}")
Loading