# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import time from collections.abc import Callable, Mapping from contextlib import contextmanager from dataclasses import dataclass, field from functools import cached_property from typing import TYPE_CHECKING, Any, get_type_hints, overload import torch from typing_extensions import TypeVar from vllm.exceptions import VLLMValidationError from vllm.inputs import MultiModalDataDict from vllm.logger import init_logger from vllm.multimodal.parse import ( DictEmbeddingItems, EmbeddingItems, MultiModalDataItems, MultiModalDataParser, ) from vllm.tokenizers import TokenizerLike from vllm.transformers_utils.processor import cached_processor_from_config from vllm.utils.func_utils import get_allowed_kwarg_only_overrides from vllm.utils.jsontree import JSONTree, json_map_leaves from vllm.utils.mistral import is_mistral_tokenizer if TYPE_CHECKING: from transformers.configuration_utils import PreTrainedConfig from transformers.feature_extraction_utils import BatchFeature from transformers.processing_utils import ProcessorMixin from vllm.config import ModelConfig from vllm.renderers import TokenizeParams else: PreTrainedConfig = object BatchFeature = object ProcessorMixin = object ModelConfig = object TokenizeParams = object logger = init_logger(__name__) # HuggingFace processors accept a shared flat namespace in addition to the # nested processor kwarg scopes ``text_kwargs`` / ``images_kwargs`` / # ``videos_kwargs`` / ``audio_kwargs``. vLLM recognizes these scopes when # resolving ``mm_processor_kwargs``. _HF_PROCESSOR_KWARG_SCOPES = ( "text_kwargs", "images_kwargs", "videos_kwargs", "audio_kwargs", ) def _merge_scoped_mm_processor_value( flat_value: object, scoped_value: object, ) -> object: """Merge a flat value with an existing, more specific scoped value. Mappings are merged recursively; otherwise the scoped value replaces the flat value. """ if isinstance(flat_value, Mapping) and isinstance(scoped_value, Mapping): # Merge recursively so scoped leaves override matching flat leaves # without dropping unrelated flat entries. merged = dict(flat_value) for key, value in scoped_value.items(): if key in merged: merged[key] = _merge_scoped_mm_processor_value(merged[key], value) else: merged[key] = value return merged return scoped_value def _resolve_mm_processor_kwargs( kwargs: Mapping[str, object], supported_mm_processor_kwargs: Mapping[str, set[str]] | None = None, ) -> dict[str, object]: """Resolve flat processor kwargs into HuggingFace processor kwarg scopes. With ``supported_mm_processor_kwargs``, each scope lists the flat keys it supports. Matching flat keys are routed to every scope that supports them, existing scoped values take precedence, and keys not supported by any scope remain flat. Without ``supported_mm_processor_kwargs``, flat keys already represented in at least one existing mapping-valued scope are removed from the shared flat namespace; all other flat keys are left unchanged because there is no information to determine which scopes should receive them. """ resolved = dict(kwargs) # Without ``supported_mm_processor_kwargs``, remove flat entries already # represented in at least one existing mapping-valued scope. There is no # information to route the remaining flat entries. if supported_mm_processor_kwargs is None: # Collect all keys represented in existing mapping-valued scopes. # Non-mapping scope values do not contain scoped kwargs and are left # untouched. represented_scoped_keys: set[str] = set() for scoped_key in _HF_PROCESSOR_KWARG_SCOPES: scoped_kwargs = resolved.get(scoped_key) if isinstance(scoped_kwargs, Mapping): represented_scoped_keys.update(scoped_kwargs) # Remove flat entries for keys already represented in a scope. for key in represented_scoped_keys: if key not in _HF_PROCESSOR_KWARG_SCOPES: resolved.pop(key, None) return resolved # With ``supported_mm_processor_kwargs``, collect the flat kwargs before # routing each one to the processor kwarg scopes that support it. flat_kwargs = { key: value for key, value in resolved.items() if key not in _HF_PROCESSOR_KWARG_SCOPES } for key, flat_value in flat_kwargs.items(): # Find every processor kwarg scope that supports this flat key. If none # supports it, keep the key in the shared flat namespace. supported_scopes = [ scoped_key for scoped_key in _HF_PROCESSOR_KWARG_SCOPES if key in supported_mm_processor_kwargs.get(scoped_key, set()) ] if not supported_scopes: continue # Add the flat value to each supported destination scope. for scoped_key in supported_scopes: # Start from a copy of the existing scoped kwargs, or an empty scope # if absent. if scoped_key in resolved: scoped_kwargs = resolved[scoped_key] # A scope must be a mapping before keys can be added to it. Do # not replace an explicit non-mapping value. if not isinstance(scoped_kwargs, Mapping): raise TypeError(f"`{scoped_key}` must be a mapping") scoped_kwargs = dict(scoped_kwargs) else: scoped_kwargs = {} # Add the flat value to this scope. Existing scoped values take # precedence. if key in scoped_kwargs: scoped_kwargs[key] = _merge_scoped_mm_processor_value( flat_value, scoped_kwargs[key] ) else: # Assign the flat value to this scope. For mappings, assign a # copy so the same mapping object is not shared between the flat # value and multiple scopes. scoped_kwargs[key] = ( dict(flat_value) if isinstance(flat_value, Mapping) else flat_value ) resolved[scoped_key] = scoped_kwargs # After routing to every destination, remove the flat copy. resolved.pop(key) return resolved @dataclass class TimingContext: """Helper class to record execution times during multi-modal processing.""" enabled: bool = True """If disabled, `TimingContext.record` becomes a no-op.""" stage_secs: dict[str, float] = field(default_factory=dict) """The execution time (in seconds) for each processing stage.""" @property def total_secs(self) -> float: return sum(self.stage_secs.values()) @contextmanager def record(self, stage: str): """Record the execution time for a processing stage.""" if not self.enabled: yield return start_time = time.perf_counter() try: yield finally: elapsed = time.perf_counter() - start_time self.stage_secs.setdefault(stage, 0.0) self.stage_secs[stage] += elapsed def get_stats_dict(self): stats_dict = { f"{stage}_secs": time_s for stage, time_s in self.stage_secs.items() } stats_dict["preprocessor_total_secs"] = self.total_secs return stats_dict _T = TypeVar("_T") _C = TypeVar("_C", bound=PreTrainedConfig, default=PreTrainedConfig) _P = TypeVar("_P", bound=ProcessorMixin, default=ProcessorMixin) @dataclass(frozen=True) class InputProcessingContext: """Contains information about the model which may be used to modify the inputs. """ model_config: ModelConfig """The configuration of the model.""" tokenizer: TokenizerLike | None """The tokenizer used to tokenize the inputs.""" def get_tokenizer(self) -> TokenizerLike: if self.tokenizer is None: raise ValueError( "You cannot pass text prompts when `skip_tokenizer_init=True`" ) return self.tokenizer @overload def get_hf_config(self, /) -> PreTrainedConfig: ... @overload def get_hf_config( self, typ: type[_C] | tuple[type[_C], ...], /, ) -> _C: ... def get_hf_config( self, typ: type[Any] | tuple[type[Any], ...] | None = None, /, ) -> Any: """Get the HuggingFace configuration (`transformers.PreTrainedConfig`) of the model, additionally checking its type. Raises: TypeError: If the configuration is not of the specified type. """ if typ is None: from transformers.configuration_utils import PreTrainedConfig typ = PreTrainedConfig hf_config = self.model_config.hf_config if not isinstance(hf_config, typ): raise TypeError( "Invalid type of HuggingFace config. " f"Expected type: {typ}, but " f"found type: {type(hf_config)}" ) return hf_config def get_hf_image_processor_config(self) -> dict[str, Any]: """Get the HuggingFace image processor configuration of the model.""" return self.model_config.hf_image_processor_config def get_mm_config(self): """Get the multimodal config of the model. Raises: RuntimeError: If the model is not a multimodal model. """ mm_config = self.model_config.multimodal_config if mm_config is None: raise RuntimeError("Not a multimodal model") return mm_config @overload def get_hf_processor(self, /, **kwargs: object) -> ProcessorMixin: ... @overload def get_hf_processor( self, typ: type[_P] | tuple[type[_P], ...], /, **kwargs: object, ) -> _P: ... def get_hf_processor( self, typ: type[Any] | tuple[type[Any], ...] | None = None, /, **kwargs: object, ) -> Any: """Get the HuggingFace processor (`transformers.ProcessorMixin`) of the model, additionally checking its type. Raises: TypeError: If the processor is not of the specified type. """ if typ is None: from transformers.processing_utils import ProcessorMixin typ = ProcessorMixin tokenizer = self.tokenizer if is_mistral_tokenizer(tokenizer): tokenizer = tokenizer.transformers_tokenizer # type: ignore[union-attr] merged_kwargs = self.get_merged_mm_kwargs(kwargs) merged_kwargs.pop("tokenizer", None) return cached_processor_from_config( self.model_config, processor_cls=typ, tokenizer=tokenizer, **merged_kwargs, ) def init_processor( self, typ: type[_T], /, **kwargs: object, ) -> _T: """Initialize a HuggingFace-like processor class, merging the keyword arguments with those in the model's configuration. """ merged_kwargs = self.get_merged_mm_kwargs(kwargs) return typ(**merged_kwargs) def _postprocess_output( self, output: JSONTree, ) -> JSONTree: # "torch_shm" puts tensors on a torch.multiprocessing queue, which # shares device tensors by CUDA IPC handle, so a device-side processor # can hand `pixel_values` straight to the worker. Every other transport # serializes host bytes, so the result has to be copied back first. keep_on_device = ( self.model_config.get_multimodal_config().mm_tensor_ipc == "torch_shm" ) def _postprocess_one(x: object): if not isinstance(x, torch.Tensor): return x # Bind to a Tensor-typed local: reassigning the `object`-typed # parameter would discard the isinstance narrowing. tensor = x # This mimics the behavior of transformers.BatchFeature if tensor.is_floating_point(): tensor = tensor.to(dtype=self.model_config.dtype) if not tensor.is_cpu and not keep_on_device: tensor = tensor.cpu() return tensor return json_map_leaves(_postprocess_one, output) def get_merged_mm_kwargs( self, kwargs: Mapping[str, object], *, supported_mm_processor_kwargs: Mapping[str, set[str]] | None = None, ) -> dict[str, Any]: """Merge configured and request ``mm_processor_kwargs``. When ``supported_mm_processor_kwargs`` is provided, flat keys are matched against the keys supported by each HuggingFace processor kwarg scope. Matching flat keys are routed to every scope that supports them and removed from the shared flat namespace; keys not supported by any scope remain flat. Without ``supported_mm_processor_kwargs``, flat keys already represented in at least one existing mapping-valued scope are removed from the shared flat namespace; all other flat keys are left unchanged because there is no information to determine which scopes should receive them. """ mm_config = self.model_config.get_multimodal_config() merged = mm_config.merge_mm_processor_kwargs(kwargs) return _resolve_mm_processor_kwargs( merged, supported_mm_processor_kwargs=supported_mm_processor_kwargs, ) def call_hf_processor( self, hf_processor: Callable[..., BatchFeature] | ProcessorMixin, data: Mapping[str, object], kwargs: Mapping[str, object] = {}, ) -> BatchFeature: """Call `hf_processor` on the prompt `data` (text, image, audio...) with configurable options `kwargs`. """ assert callable(hf_processor) merged_kwargs = self.get_merged_mm_kwargs(kwargs) allowed_kwargs = get_allowed_kwarg_only_overrides( hf_processor, merged_kwargs, requires_kw_only=False, allow_var_kwargs=True, ) allowed_kwargs.setdefault("return_tensors", "pt") try: output = hf_processor(**data, **allowed_kwargs) except Exception as exc: msg = ( f"Failed to apply {type(hf_processor).__name__} " f"on data={data} with kwargs={allowed_kwargs}" ) raise ValueError(msg) from exc # this emulates output.to(dtype=self.model_config.dtype) from transformers.feature_extraction_utils import BatchFeature if isinstance(output, BatchFeature): output_ = self._postprocess_output(output.data) return BatchFeature(output_) # type: ignore logger.warning_once( "%s did not return `BatchFeature`. " "Make sure to match the behaviour of `ProcessorMixin` when " "implementing custom processors.", type(hf_processor).__name__, ) return self._postprocess_output(output) # type: ignore class BaseProcessingInfo: """Base class to provide the information necessary for data processing.""" def __init__(self, ctx: InputProcessingContext) -> None: super().__init__() self.ctx = ctx @property def model_id(self) -> str: return self.ctx.model_config.model def get_tokenizer(self) -> TokenizerLike: return self.ctx.get_tokenizer() def get_hf_config(self) -> PreTrainedConfig: return self.ctx.get_hf_config() def get_hf_processor(self, **kwargs: object) -> ProcessorMixin: """Subclasses can override this method to handle specific kwargs from model config or user inputs. """ return self.ctx.get_hf_processor(**kwargs) def get_supported_mm_processor_kwargs(self) -> dict[str, set[str]]: """Return supported kwarg names for each HF processor kwargs scope.""" processor = self.get_hf_processor() processor_kwargs = get_type_hints(processor.valid_processor_kwargs) supported = { "text_kwargs": set(processor_kwargs["text_kwargs"].__annotations__), } if "image" in self.supported_mm_limits: supported["images_kwargs"] = set( processor.image_processor.valid_kwargs.__annotations__ ) if "video" in self.supported_mm_limits: supported["videos_kwargs"] = set( processor.video_processor.valid_kwargs.__annotations__ ) if "audio" in self.supported_mm_limits: supported["audio_kwargs"] = set( processor_kwargs["audio_kwargs"].__annotations__ ) return supported @cached_property def supported_mm_processor_kwargs(self) -> dict[str, set[str]]: """Supported kwarg names for each HF processor kwargs scope.""" return self.get_supported_mm_processor_kwargs() def _merge_and_resolve_mm_processor_kwargs( self, mm_kwargs: Mapping[str, object], ) -> dict[str, Any]: """Merge configured and request ``mm_processor_kwargs``. Flat kwargs are routed into the HuggingFace processor kwarg scopes that support them after the configured/request merge. When a routed flat value conflicts with an existing scoped value, the scoped value takes precedence. """ return self.ctx.get_merged_mm_kwargs( mm_kwargs, supported_mm_processor_kwargs=self.supported_mm_processor_kwargs, ) def get_default_tok_params(self) -> TokenizeParams: """Construct the default parameters for tokenization.""" from vllm.renderers import TokenizeParams model_config = self.ctx.model_config encoder_config = model_config.encoder_config or {} return TokenizeParams( max_total_tokens=model_config.max_model_len, do_lower_case=encoder_config.get("do_lower_case", False), add_special_tokens=True, ) @cached_property def default_tok_params(self) -> TokenizeParams: return self.get_default_tok_params() def _get_expected_hidden_size(self) -> int | None: """Get expected hidden size for embedding validation if `mm_embeds` are enabled. This validates hidden dimensions to prevent a vulnerability where embeddings with correct `ndim` but wrong `shape` could cause crashes at inference time. """ model_config = self.ctx.model_config mm_config = model_config.get_multimodal_config() if mm_config.enable_mm_embeds: return model_config.get_inputs_embeds_size() return None @property def allow_missing_mm_embeddings(self) -> bool: """Whether pre-computed embedding tensors may be omitted.""" mm_config = self.ctx.model_config.multimodal_config return mm_config is not None and mm_config.allow_missing_mm_embeddings def get_data_parser(self) -> MultiModalDataParser: """Constructs a parser to preprocess multi-modal data items before passing them to [`_get_hf_mm_inputs`][vllm.multimodal.processing.BaseMultiModalProcessor._get_hf_mm_inputs]. You can support additional modalities by creating a subclass of [`MultiModalDataParser`][vllm.multimodal.parse.MultiModalDataParser] that has additional subparsers. """ return MultiModalDataParser( expected_hidden_size=self._get_expected_hidden_size(), allow_missing_mm_embeddings=self.allow_missing_mm_embeddings, ) @cached_property def data_parser(self) -> MultiModalDataParser: return self.get_data_parser() @property def skip_prompt_length_check(self) -> bool: return False def get_supported_mm_limits(self) -> Mapping[str, int | None]: """Return the maximum supported number of items for each modality. A value of `None` means unlimited number of items. Omitting a modality from the returned dictionary means that it is not supported at all. """ raise NotImplementedError @cached_property def supported_mm_limits(self) -> Mapping[str, int | None]: """The maximum supported number of items for each modality.""" return self.get_supported_mm_limits() @cached_property def allowed_mm_limits(self) -> Mapping[str, int]: """The maximum allowed number of items for each modality.""" mm_config = self.ctx.get_mm_config() allowed_limits = dict[str, int]() for modality, supported_limit in self.supported_mm_limits.items(): user_limit = mm_config.get_limit_per_prompt(modality) allowed_limits[modality] = ( user_limit if supported_limit is None else min(user_limit, supported_limit) ) return allowed_limits def validate_num_items(self, modality: str, num_items: int) -> None: """Raise `ValueError` if the number of input items for the given modality is invalid. """ supported_limit = self.supported_mm_limits.get(modality, 0) allowed_limit = self.allowed_mm_limits.get(modality, 0) if supported_limit is None: supported_limit = allowed_limit limit = min(supported_limit, allowed_limit) if num_items > limit: msg = f"At most {limit} {modality}(s) may be provided in one prompt." if num_items <= supported_limit: msg += " Set `--limit-mm-per-prompt` to increase this limit." raise VLLMValidationError(msg, parameter=modality) def parse_mm_data( self, mm_data: MultiModalDataDict, *, validate: bool = True, ) -> MultiModalDataItems: """Normalize [`MultiModalDataDict`][vllm.inputs.MultiModalDataDict] to [`MultiModalDataItems`][vllm.multimodal.parse.MultiModalDataItems] before passing them to [`_get_hf_mm_inputs`][vllm.multimodal.processing.BaseMultiModalProcessor._get_hf_mm_inputs]. """ mm_items = self.data_parser.parse_mm_data(mm_data) if validate: mm_config = self.ctx.get_mm_config() for modality, items in mm_items.items(): if isinstance(items, (EmbeddingItems, DictEmbeddingItems)): if not mm_config.enable_mm_embeds: raise ValueError( f"You must set `--enable-mm-embeds` to input " f"`{modality}_embeds`" ) if mm_config.get_limit_per_prompt(modality) == 0: logger.debug( "Skipping count validation for modality " "'%s' (embeddings with limit=0)", modality, ) continue self.validate_num_items(modality, len(items)) return mm_items def get_mm_max_tokens_per_item( self, seq_len: int, mm_counts: Mapping[str, int], ) -> Mapping[str, int] | None: """Return the maximum number of tokens per item of for each modality. When `None` (the default) is returned, vLLM will generate dummy inputs (images/videos) at maximum possible sizes and process them to determine the maximum token count per modality. This approach works but can be very slow for certain models (e.g., Qwen2.5-VL), leading to very long startup time. For better performance, each model can override this method to return pre-computed maximum token counts, avoiding the need for dummy input generation and processing. Note: The maximum number of tokens per item of each modality returned from this function should respect the model's maximum sequence length and the maximum number of items of each modality allowed, and agree with dummy inputs (images/videos) at maximum possible sizes. """ return None