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
1 change: 1 addition & 0 deletions docs/source/models/supported-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ Note: Support for other models may vary. Features marked "N/A" are not applicabl
| `Step3p7ForConditionalGeneration` | Yes | Yes | Untested | Yes | Untested | Untested | Untested | Untested | L + I |
| `MiniMaxM3SparseForConditionalGeneration` [^12] | Yes | Yes | Untested | Yes | Untested | No | Untested | Untested | L + I + V |
| `Cosmos3ForConditionalGeneration` [^13] | Yes | Yes | Yes | Yes | Yes | Yes | Untested | Untested | L + I + V |
| `Qwen3_5ForConditionalGeneration` | Yes | Yes | Untested | Yes | Yes | No | Untested | Yes | L + I + V |
| `Qwen3_5MoeForConditionalGeneration` | Yes | Yes | Untested | Yes | Yes | No | Untested | Yes | L + I + V |

Note:
Expand Down
3 changes: 2 additions & 1 deletion tensorrt_llm/_torch/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
from .modeling_qwen2vl import Qwen2_5_VLModel, Qwen2VLModel
from .modeling_qwen3 import Qwen3ForCausalLM
from .modeling_qwen3_5 import (Qwen3_5ForCausalLM, Qwen3_5MoeForCausalLM,
Qwen3_5MoeVLModel)
Qwen3_5MoeVLModel, Qwen3_5VLModel)
from .modeling_qwen3_moe import Qwen3MoeForCausalLM
from .modeling_qwen3_next import Qwen3NextForCausalLM
from .modeling_qwen3vl import Qwen3VLModel
Expand Down Expand Up @@ -117,6 +117,7 @@
"Qwen3_5MoeForCausalLM",
"QwenImageBenchModel",
"Qwen3_5MoeVLModel",
"Qwen3_5VLModel",
"Qwen3NextForCausalLM",
"Qwen3MoeVLModel",
"GptOssForCausalLM",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
@register_mapper("HF", "Qwen3_5MoeForCausalLM")
@register_mapper("HF", "Qwen3_5MoeForConditionalGeneration")
@register_mapper("HF", "Qwen3_5ForCausalLM")
@register_mapper("HF", "Qwen3_5ForConditionalGeneration")
class Qwen3_5MoeHfWeightMapper(Qwen3NextHfWeightMapper):
"""Weight mapper for Qwen3.5 MoE text checkpoints.

Expand Down
116 changes: 93 additions & 23 deletions tensorrt_llm/_torch/models/modeling_qwen3_5.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,23 +330,45 @@ def _normalize_qwen35_quantization_config(model_config) -> None:
quantization_config["modules_to_not_convert"] = sorted(set(normalized_modules))


def _normalize_qwen35_moe_vl_config(model_config) -> None:
"""Adapt HF Qwen3.5-MoE VLM config to TRT-LLM runtime conventions."""
# Map the inner (text) causal-LM arch to the outer VLM arch, used only for the
# defensive fallback when a config arrives without an `architectures` field.
_INNER_TO_OUTER_VL_ARCH = {
"Qwen3_5MoeForCausalLM": "Qwen3_5MoeForConditionalGeneration",
"Qwen3_5ForCausalLM": "Qwen3_5ForConditionalGeneration",
}


def _normalize_qwen35_vl_config(model_config, inner_arch: str) -> None:
"""Adapt an HF Qwen3.5 VLM config (MoE or dense) to TRT-LLM conventions.

Shared by both the MoE (`Qwen3_5MoeForConditionalGeneration` ->
`Qwen3_5MoeForCausalLM`) and dense (`Qwen3_5ForConditionalGeneration` ->
`Qwen3_5ForCausalLM`) VLM paths. The only difference between the two is the
inner causal-LM arch string written onto `text_config`; everything else
(mRoPE flattening, Qwen3Next text aliases, quantization exclude-module
rewrite) is identical. `_normalize_qwen35_qwen3next_text_aliases` is a no-op
for dense (its native `intermediate_size` is already present).
"""
if not getattr(model_config, "architectures", None):
model_config.architectures = ["Qwen3_5MoeForConditionalGeneration"]
model_config.architectures = [_INNER_TO_OUTER_VL_ARCH.get(inner_arch, inner_arch)]

text_config = getattr(model_config, "text_config", None)
if text_config is None:
raise ValueError("Qwen3.5-MoE VLM config is missing text_config")
raise ValueError("Qwen3.5 VLM config is missing text_config")

text_config.architectures = ["Qwen3_5MoeForCausalLM"]
text_config.architectures = [inner_arch]
_normalize_qwen35_qwen3next_text_aliases(text_config)
_normalize_qwen35_mrope_config(text_config)

model_config.get_text_config = lambda decoder=False: text_config
_normalize_qwen35_quantization_config(model_config)


def _normalize_qwen35_moe_vl_config(model_config) -> None:
"""Adapt HF Qwen3.5-MoE VLM config to TRT-LLM runtime conventions."""
_normalize_qwen35_vl_config(model_config, inner_arch="Qwen3_5MoeForCausalLM")


def _normalize_qwen35_exclude_modules(model_config):
"""Normalize NVFP4/FP8 exclude_modules from HF naming to TRT-LLM naming.

Expand Down Expand Up @@ -435,25 +457,41 @@ def __init__(self, model_config):
super().__init__(model_config)


# TODO: Add tests for disaggregated support.
@support_multimodal_disaggregated
@register_vision_encoder(Qwen3VisionModelBase, vlm_base_model=Qwen3VisionModel)
@register_auto_model("Qwen3_5MoeForConditionalGeneration")
@register_input_processor(
Qwen3VLInputProcessorBase,
model_type="qwen3_5_moe",
placeholder_metadata=MultimodalPlaceholderMetadata(
placeholder_map={
"image": "<|vision_start|><|image_pad|><|vision_end|>",
"video": "<|vision_start|><|video_pad|><|vision_end|>",
},
placeholder_placement=MultimodalPlaceholderPlacement.BEFORE_TEXT,
placeholders_separator="",
content_format=ContentFormat.STRING,
),
# Shared placeholder metadata for both Qwen3.5 VLM variants. The image/video
# placeholder layout is identical for MoE and dense; only the registration
# `model_type` differs (set per concrete class below).
_QWEN3_5_VL_PLACEHOLDER_METADATA = MultimodalPlaceholderMetadata(
placeholder_map={
"image": "<|vision_start|><|image_pad|><|vision_end|>",
"video": "<|vision_start|><|video_pad|><|vision_end|>",
},
placeholder_placement=MultimodalPlaceholderPlacement.BEFORE_TEXT,
placeholders_separator="",
content_format=ContentFormat.STRING,
)
class Qwen3_5MoeVLModel(Qwen3VLModelBase):
"""VLM wrapper composing Qwen3 vision encoder with Qwen3.5 MoE text decoder."""


class _Qwen3_5VLModel(Qwen3VLModelBase):
"""Shared VLM wrapper composing the Qwen3 vision encoder with a Qwen3.5
(Qwen3Next-based) text decoder.

MoE and dense differ only in the inner causal-LM the config normalizer
selects (`Qwen3_5MoeForCausalLM` vs `Qwen3_5ForCausalLM`) — both reuse the
same vision tower, weight mapper, and forward path, so the wrapper body is
shared here. The concrete subclasses below carry only the registration
decorators (outer arch string + input-processor `model_type`).
"""
Comment thread
moraxu marked this conversation as resolved.

@classmethod
def get_model_defaults(cls, llm_args):
# `ModelLoader` applies `get_model_defaults()` on the resolved outer
# model class (this VLM wrapper), not on the inner decoder. Both
# inner LMs (`Qwen3_5MoeForCausalLM` / `Qwen3_5ForCausalLM`) inherit
# `Qwen3NextForCausalLM`'s defaults unchanged, so delegate to it to
# propagate `enable_block_reuse=False` — the hybrid Mamba/SSM path
# doesn't support KV-cache block reuse. Without this the VLM path
# would silently fall back to the global default (block reuse on).
return Qwen3NextForCausalLM.get_model_defaults(llm_args)

def __init__(self, model_config: ModelConfig[PretrainedConfig], *args, **kwargs):
kwargs["vision_model_class"] = Qwen3VisionModel
Expand Down Expand Up @@ -481,3 +519,35 @@ def load_weights(self, weights: Dict[str, torch.Tensor], weight_mapper: BaseWeig
r"^model\.language_model\.(.*)$": r"model.\1",
}
self.llm.load_weights(filtered_weights, weight_mapper, params_map=params_map)


# TODO(TRTLLM-13417): Add tests for disaggregated support.
@support_multimodal_disaggregated
@register_vision_encoder(Qwen3VisionModelBase, vlm_base_model=Qwen3VisionModel)
@register_auto_model("Qwen3_5MoeForConditionalGeneration")
@register_input_processor(
Qwen3VLInputProcessorBase,
model_type="qwen3_5_moe",
placeholder_metadata=_QWEN3_5_VL_PLACEHOLDER_METADATA,
)
class Qwen3_5MoeVLModel(_Qwen3_5VLModel):
"""VLM wrapper composing Qwen3 vision encoder with Qwen3.5 MoE text decoder."""


# TODO(TRTLLM-13417): Add tests for disaggregated support.
@support_multimodal_disaggregated
@register_vision_encoder(Qwen3VisionModelBase, vlm_base_model=Qwen3VisionModel)
@register_auto_model("Qwen3_5ForConditionalGeneration")
@register_input_processor(
Qwen3VLInputProcessorBase,
model_type="qwen3_5",
placeholder_metadata=_QWEN3_5_VL_PLACEHOLDER_METADATA,
)
class Qwen3_5VLModel(_Qwen3_5VLModel):
"""VLM wrapper composing Qwen3 vision encoder with dense Qwen3.5 text decoder.

Dense sibling of `Qwen3_5MoeVLModel` (arch `Qwen3_5ForConditionalGeneration`,
`model_type="qwen3_5"`). Same hybrid Qwen3Next runtime, with `GatedMLP`
instead of `SparseMoeBlock` (the dense text config has a native `intermediate_size`
and no `num_experts`).
"""
21 changes: 21 additions & 0 deletions tensorrt_llm/_torch/models/modeling_qwen3vl.py
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,7 @@ def __init__(
"QwenImageBenchForConditionalGeneration": "Qwen3_5ForCausalLM",
"Cosmos3ForConditionalGeneration": "Qwen3ForCausalLM",
"Qwen3_5MoeForConditionalGeneration": "Qwen3_5MoeForCausalLM",
"Qwen3_5ForConditionalGeneration": "Qwen3_5ForCausalLM",
}
llm_arch = vlm_to_llm_arch.get(self.original_arch)
if llm_arch is None:
Expand Down Expand Up @@ -1225,6 +1226,26 @@ def vocab_size_padded(self) -> int:
def infer_max_seq_len(self) -> int:
return self.llm.infer_max_seq_len()

# Draft-model (two-model speculative decoding, e.g. DFlash / Eagle3)
# delegation: `ModelLoader.load` reads `draft_config` / `draft_model` and
# calls `load_draft_weights` on the *outer* model it resolved, but the
# spec-decoding wrapper (`SpecDecOneEngineForCausalLM`) is applied to the
# inner `self.llm` when this VLM composes it. Composite checkpoints
# (e.g. Qwen3.5-4B publishes text_config + vision_config) route text-only
# spec tests through this wrapper, so surface the inner LM's draft state.
# Note: `load_draft_weights` must keep an explicit signature — the loader
# dispatches kwargs via `inspect.getfullargspec`.
@property
def draft_config(self):
return self.llm.draft_config

@property
def draft_model(self):
return self.llm.draft_model

def load_draft_weights(self, weights: Dict, weight_mapper: Optional[BaseWeightMapper] = None):
return self.llm.load_draft_weights(weights, weight_mapper=weight_mapper)

def apply_llm_torch_compile(self, *, backend: Any, fullgraph: bool) -> None:
# TODO: Move this hook to MultimodalModelMixin once multimodal models
# consistently expose an LLM compile contract.
Expand Down
18 changes: 16 additions & 2 deletions tensorrt_llm/_torch/pyexecutor/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -418,10 +418,24 @@ def load_pretrained_config(model_name_or_path: str,
and architectures[0] == "Qwen3_5MoeForConditionalGeneration"))):
# Qwen3.5-MoE VLM: HF native composite config + model-side normalizer.
from tensorrt_llm._torch.models.modeling_qwen3_5 import \
_normalize_qwen35_moe_vl_config
_normalize_qwen35_vl_config
model_config = transformers.Qwen3_5MoeConfig.from_pretrained(
model_name_or_path, **kwargs)
_normalize_qwen35_moe_vl_config(model_config)
_normalize_qwen35_vl_config(model_config,
inner_arch="Qwen3_5MoeForCausalLM")
elif (model_type == "qwen3_5" and
(("text_config" in config_dict and "vision_config" in config_dict) or
(architectures
and architectures[0] == "Qwen3_5ForConditionalGeneration"))):
# Qwen3.5 dense VLM: HF native composite config + model-side normalizer.
# Must precede the text-only `qwen3_5` branch below so the composite
# config isn't flattened and vision_config dropped.
from tensorrt_llm._torch.models.modeling_qwen3_5 import \
_normalize_qwen35_vl_config
model_config = transformers.Qwen3_5Config.from_pretrained(
model_name_or_path, **kwargs)
_normalize_qwen35_vl_config(model_config,
inner_arch="Qwen3_5ForCausalLM")
elif model_type in _CONFIG_REGISTRY:
config_class = _CONFIG_REGISTRY[model_type]
model_config = config_class.from_pretrained(model_name_or_path,
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/defs/accuracy/references/mmmu.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ Qwen/Qwen3-VL-8B-Instruct:
- accuracy: 55.11
mistralai/Mistral-Small-3.1-24B-Instruct-2503:
- accuracy: 57.0
Qwen/Qwen3.5-27B:
- accuracy: 62.222
Qwen/Qwen3.5-35B-A3B:
# The default accuracy for `test_auto_dtype` tests.
- accuracy: 59.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,36 @@ def test_fp8_prequantized(self) -> None:
task.evaluate(llm, sampling_params=self.sampling_params)


@skip_pre_hopper
@pytest.mark.skip_less_device_memory(80000)
class TestQwen3_5_27B_VL(LlmapiAccuracyTestHarness):
MODEL_NAME = "Qwen/Qwen3.5-27B"
MODEL_PATH = f"{llm_models_root()}/Qwen3.5-27B"
MAX_NUM_TOKENS = 16384
MAX_BATCH_SIZE = 32

sampling_params = SamplingParams(
max_tokens=MAX_NUM_TOKENS,
truncate_prompt_tokens=MMMU.MAX_INPUT_LEN,
stop="<|endoftext|>",
)

kv_cache_config = KvCacheConfig(free_gpu_memory_fraction=0.6, enable_block_reuse=False)

def _make_llm(self, model_path: str) -> LLM:
return LLM(
model_path,
max_num_tokens=self.MAX_NUM_TOKENS,
max_batch_size=self.MAX_BATCH_SIZE,
kv_cache_config=self.kv_cache_config,
)

def test_auto_dtype(self) -> None:
with self._make_llm(self.MODEL_PATH) as llm:
task = MMMU(self.MODEL_NAME)
task.evaluate(llm, sampling_params=self.sampling_params)

Comment thread
moraxu marked this conversation as resolved.

class TestQwen3VL(LlmapiAccuracyTestHarness):
MODEL_NAME = "Qwen/Qwen3-VL-8B-Instruct"
MODEL_PATH = f"{llm_models_root()}/Qwen3/Qwen3-VL-8B-Instruct"
Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_lists/qa/llm_function_core.txt
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,7 @@ accuracy/test_llm_api_pytorch_multimodal.py::TestStep3_7::test_nvfp4[mtp_nextn=0
accuracy/test_llm_api_pytorch_multimodal.py::TestStep3_7::test_nvfp4[mtp_nextn=3] TIMEOUT (120)
accuracy/test_llm_api_pytorch_multimodal.py::TestQwen3_5_35B_A3B_VL::test_auto_dtype
accuracy/test_llm_api_pytorch_multimodal.py::TestQwen3_5_35B_A3B_VL::test_fp8_prequantized
accuracy/test_llm_api_pytorch_multimodal.py::TestQwen3_5_27B_VL::test_auto_dtype
accuracy/test_llm_api_pytorch_multimodal.py::TestVILA1_5_3B::test_auto_dtype
accuracy/test_llm_api_pytorch_ray.py::TestLlama3_1_8BInstruct::test_pp2_ray
unittest/disaggregated/test_openai_disagg_server.py
Expand Down
2 changes: 2 additions & 0 deletions tests/integration/test_lists/test-db/l0_h100.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ l0_h100:
# test must run on Hopper-or-newer GPUs. Peer Qwen3-VL / Qwen3-VL-MoE
# tests stay on L40s because they're pure attention and don't trigger the GDN kernel.
- unittest/_torch/modeling/test_modeling_qwen3_5_vl_moe.py::TestQwen3_5MoeVL::test_all
# Dense Qwen3.5-VL is the same hybrid family (sm90+ GDN kernel), so it lands here too.
- unittest/_torch/modeling/test_modeling_qwen3_5_vl.py::TestQwen3_5VL::test_all
- unittest/disaggregated/test_disagg_utils.py
- unittest/disaggregated/test_router.py
- unittest/disaggregated/test_remoteDictionary.py
Expand Down
Loading
Loading