diff --git a/tensorrt_llm/_torch/model_config.py b/tensorrt_llm/_torch/model_config.py index fd15550fd6e1..a1c60072c16a 100644 --- a/tensorrt_llm/_torch/model_config.py +++ b/tensorrt_llm/_torch/model_config.py @@ -190,6 +190,13 @@ class ModelConfig(Generic[TConfig]): # If true, ONLY the vision encoder part of the full model is loaded/executed. mm_encoder_only: bool = False + # If true, the multimodal encoder of a multimodal checkpoint is NOT + # instantiated/loaded and the model serves text-only requests. This is + # opt-in per model: each model implementation must honor this flag when + # building its encoder (currently the Qwen3-VL / Qwen3.5-VL models); a + # model that does not check it simply ignores the flag (no-op). + disable_mm_encoder: bool = False + # Video pruning rate for VLM models (None = EVS disabled) video_pruning_rate: Optional[float] = None diff --git a/tensorrt_llm/_torch/models/modeling_qwen3_5.py b/tensorrt_llm/_torch/models/modeling_qwen3_5.py index b7ae1c48da07..8d169374d1bf 100644 --- a/tensorrt_llm/_torch/models/modeling_qwen3_5.py +++ b/tensorrt_llm/_torch/models/modeling_qwen3_5.py @@ -34,7 +34,6 @@ from ..pyexecutor.config_utils import get_qwen3_hybrid_layer_types from .checkpoints.base_weight_mapper import BaseWeightMapper from .checkpoints.hf.qwen3_5_weight_mapper import Qwen3_5MoeHfWeightMapper -from .modeling_multimodal_utils import _is_mm_disagg from .modeling_qwen3_next import Qwen3NextForCausalLM from .modeling_qwen3vl import ( Qwen3VisionModel, @@ -691,7 +690,8 @@ def multimodal_data_device_paths(self) -> List[str]: ] def load_weights(self, weights: Dict[str, torch.Tensor], weight_mapper: BaseWeightMapper): - if not _is_mm_disagg(): + # None under MM E/P disagg or disable_mm_encoder. + if self.mm_encoder is not None: self.mm_encoder.load_weights(weights) weight_mapper = Qwen3_5MoeHfWeightMapper() diff --git a/tensorrt_llm/_torch/models/modeling_qwen3vl.py b/tensorrt_llm/_torch/models/modeling_qwen3vl.py index 95da74e5d3bd..6d4c0a6249c1 100644 --- a/tensorrt_llm/_torch/models/modeling_qwen3vl.py +++ b/tensorrt_llm/_torch/models/modeling_qwen3vl.py @@ -1253,11 +1253,18 @@ def __init__( self.llm = AutoModelForCausalLM.from_config(llm_model_config) self.mm_encoder = None - # Normal workers own the encoder. MM E/P handoff uses attached embeddings. - if not _is_mm_disagg(): + # Normal workers own the encoder. MM E/P handoff uses attached + # embeddings; disable_mm_encoder serves the checkpoint text-only and + # saves the encoder's GPU memory for the KV cache pool. + if not (_is_mm_disagg() or model_config.disable_mm_encoder): self.mm_encoder = Qwen3VisionModelBase( copy.deepcopy(model_config), kwargs.get("vision_model_class", None) ).eval() + elif model_config.disable_mm_encoder: + logger.info( + f"{type(self).__name__}: multimodal encoder disabled " + "(disable_mm_encoder=True); serving text-only requests." + ) self.use_deepstack = hasattr(config.vision_config, "deepstack_visual_indexes") self.deepstack_num_level = ( diff --git a/tensorrt_llm/_torch/pyexecutor/_util.py b/tensorrt_llm/_torch/pyexecutor/_util.py index 157d5876d926..11f2a8fe1e57 100644 --- a/tensorrt_llm/_torch/pyexecutor/_util.py +++ b/tensorrt_llm/_torch/pyexecutor/_util.py @@ -579,6 +579,10 @@ def _create_dummy_encoder_inputs(self) -> List[MultimodalParams]: self._profiling_stage_data, dict) and not self._profiling_stage_data.get("enable_mm_reqs"): return [] + # No local multimodal encoder (disable_mm_encoder or MM E/P disagg): + # nothing to profile. + if getattr(self._model_engine.model, "mm_encoder", object()) is None: + return [] input_processor = self._model_engine.input_processor _, encoder_max_num_tokens = self._llm_args.get_encoder_runtime_sizes() # Modality-agnostic: the model declares each modality's per-item token diff --git a/tensorrt_llm/_torch/pyexecutor/model_loader.py b/tensorrt_llm/_torch/pyexecutor/model_loader.py index 7596c2a68291..7554c5aa630b 100644 --- a/tensorrt_llm/_torch/pyexecutor/model_loader.py +++ b/tensorrt_llm/_torch/pyexecutor/model_loader.py @@ -1184,6 +1184,7 @@ def _load_and_validate_config( lora_config=self.lora_config, allreduce_strategy=self.llm_args.allreduce_strategy, mm_encoder_only=self.llm_args.mm_encoder_only, + disable_mm_encoder=self.llm_args.disable_mm_encoder, attn_backend=self.llm_args.attn_backend, moe_backend=self.llm_args.moe_config.backend, moe_disable_finalize_fusion=self.llm_args.moe_config. diff --git a/tensorrt_llm/bench/benchmark/throughput.py b/tensorrt_llm/bench/benchmark/throughput.py index 844e63fb18ee..b31a4608a5c3 100755 --- a/tensorrt_llm/bench/benchmark/throughput.py +++ b/tensorrt_llm/bench/benchmark/throughput.py @@ -449,6 +449,16 @@ def throughput_command( kwargs = kwargs | runtime_config.get_llm_args() kwargs['skip_tokenizer_init'] = not no_skip_tokenizer_init kwargs['backend'] = options.backend + if (options.modality is None and options.backend == "pytorch" + and "disable_mm_encoder" not in kwargs + and not kwargs.get("mm_encoder_only", False)): + # Text-only benchmark: skip a multimodal checkpoint's encoder so + # its GPU memory goes to the KV cache pool instead. Text-only + # models ignore this flag. Overridable via extra_llm_api_options. + kwargs["disable_mm_encoder"] = True + logger.info( + "Text-only benchmark (--modality not set): the multimodal " + "encoder, if the model has one, will not be loaded.") if bench_env.telemetry_config is not None: kwargs["telemetry_config"] = bench_env.telemetry_config diff --git a/tensorrt_llm/llmapi/llm_args.py b/tensorrt_llm/llmapi/llm_args.py index 5c6c89e88b3c..66131f40f80e 100644 --- a/tensorrt_llm/llmapi/llm_args.py +++ b/tensorrt_llm/llmapi/llm_args.py @@ -5097,6 +5097,18 @@ def validate_encoder_runtime_sizes(cls, v: Optional[int]) -> Optional[int]: "Only load/execute the vision encoder part of the full model. Defaults to False.", status="prototype") + disable_mm_encoder: bool = Field( + default=False, + description= + "Skip instantiating and loading the multimodal (e.g. vision) encoder " + "of a multimodal checkpoint and serve it text-only. Saves the " + "encoder's GPU memory (enlarging the KV cache pool) for workloads " + "that never send image/video/audio inputs; such requests are " + "rejected. Only takes effect for model implementations that support " + "it (currently the Qwen3-VL / Qwen3.5-VL family); a no-op otherwise. " + "Defaults to False.", + status="prototype") + encode_only: bool = Field( default=False, description= @@ -5272,6 +5284,11 @@ def validate_encoder_modes(self) -> 'TorchLlmArgs': "Use encode_only=True for LLM.encode(), or use " "MultimodalEncoder/mm_encoder_only for multimodal encoder " "execution.") + if self.disable_mm_encoder and self.mm_encoder_only: + raise ValueError( + "disable_mm_encoder and mm_encoder_only are mutually " + "exclusive: one skips the multimodal encoder, the other runs " + "only the multimodal encoder.") return self @model_validator(mode="after") diff --git a/tensorrt_llm/usage/llm_args_golden_manifest.json b/tensorrt_llm/usage/llm_args_golden_manifest.json index 1362f671ccfb..334c2f15d7e9 100644 --- a/tensorrt_llm/usage/llm_args_golden_manifest.json +++ b/tensorrt_llm/usage/llm_args_golden_manifest.json @@ -307,6 +307,13 @@ "kind": "value", "path": "cuda_graph_config.seq_lens" }, + { + "allowed_values": [], + "annotation": "", + "converter": "", + "kind": "value", + "path": "disable_mm_encoder" + }, { "allowed_values": [], "annotation": "", diff --git a/tests/unittest/_torch/modeling/test_modeling_qwen3_5_vl.py b/tests/unittest/_torch/modeling/test_modeling_qwen3_5_vl.py index fe097dabd79a..53f6fe8f3fcf 100644 --- a/tests/unittest/_torch/modeling/test_modeling_qwen3_5_vl.py +++ b/tests/unittest/_torch/modeling/test_modeling_qwen3_5_vl.py @@ -165,6 +165,31 @@ def test_qwen35_dense_vl_resolves_model_and_mapper(tmp_path: Path) -> None: ) +def test_qwen35_dense_vl_disable_mm_encoder_skips_vision_tower( + tmp_path: Path, +) -> None: + """disable_mm_encoder must not instantiate the vision encoder (nvbug + 6405760: the tower's GPU memory shrank the KV cache pool for text-only + serving of VLM checkpoints).""" + config = load_pretrained_config(str(_write_qwen35_dense_vl_config(tmp_path))) + model_config = ModelConfig(pretrained_config=config, disable_mm_encoder=True) + with torch.device("cuda"): + model = Qwen3_5VLModel(model_config) + + assert model.mm_encoder is None + assert not any("mm_encoder" in name for name, _ in model.named_parameters()) + + +def test_qwen35_dense_vl_default_keeps_vision_tower(tmp_path: Path) -> None: + config = load_pretrained_config(str(_write_qwen35_dense_vl_config(tmp_path))) + model_config = ModelConfig(pretrained_config=config) + with torch.device("cuda"): + model = Qwen3_5VLModel(model_config) + + assert model.mm_encoder is not None + assert any("mm_encoder" in name for name, _ in model.named_parameters()) + + def test_qwen35_dense_vl_placeholder_metadata_registered() -> None: metadata = MULTIMODAL_PLACEHOLDER_REGISTRY.get_placeholder_metadata("qwen3_5") diff --git a/tests/unittest/api_stability/references/llm.yaml b/tests/unittest/api_stability/references/llm.yaml index 0db864d9e540..7ff208c41ce3 100644 --- a/tests/unittest/api_stability/references/llm.yaml +++ b/tests/unittest/api_stability/references/llm.yaml @@ -119,6 +119,10 @@ methods: annotation: bool default: False status: prototype + disable_mm_encoder: + annotation: bool + default: False + status: prototype encode_only: annotation: bool default: False