# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import asyncio import time import weakref from abc import ABC, abstractmethod from collections.abc import Mapping, Sequence from concurrent.futures import Future, ThreadPoolExecutor from contextlib import ExitStack from dataclasses import replace from functools import cached_property from typing import TYPE_CHECKING, Any, Generic, overload from typing_extensions import TypeVar from vllm.exceptions import VLLMValidationError from vllm.inputs import ( EmbedsInput, EmbedsPrompt, EncoderDecoderInput, EngineInput, MultiModalDataDict, MultiModalInput, MultiModalUUIDDict, SingletonInput, TextPrompt, TokensInput, TokensPrompt, build_enc_dec_input, embeds_input, tokens_input, ) from vllm.logger import init_logger from vllm.multimodal import MULTIMODAL_REGISTRY as mm_registry from vllm.multimodal.cache import ( BaseMultiModalProcessorCache, processor_cache_from_config, processor_only_cache_from_config, ) from vllm.multimodal.gpu_ipc_memory import maybe_init_mm_gpu_ipc_pool from vllm.multimodal.parse import ( MultiModalDataItems, MultiModalUUIDItems, parse_mm_uuids, ) from vllm.multimodal.processing import BaseMultiModalProcessor from vllm.multimodal.processing import ProcessorInputs as MMProcessorInputs from vllm.multimodal.registry import MultiModalTimingRegistry from vllm.tokenizers import TokenizerLike from vllm.utils.async_utils import make_async from vllm.utils.counter import AtomicCounter from vllm.utils.torch_utils import set_default_torch_num_threads from vllm.v1.metrics.stats import MultiModalCacheStats from .embed_utils import safe_load_prompt_embeds from .inputs import ( DictPrompt, EncoderDecoderDictPrompt, EncoderDecoderTokPrompt, SingletonDictPrompt, SingletonTokPrompt, TokPrompt, ) from .inputs.preprocess import extract_target_prompt from .params import ChatParams, TokenizeParams if TYPE_CHECKING: from vllm.config import VllmConfig from vllm.entrypoints.chat_utils import ( ChatCompletionMessageParam, ConversationMessage, ) logger = init_logger(__name__) _T = TypeVar("_T", bound=TokenizerLike, default=TokenizerLike) class BaseRenderer(ABC, Generic[_T]): def __init__(self, config: "VllmConfig", tokenizer: _T | None) -> None: super().__init__() self.config = config self.model_config = config.model_config self.api_process_rank = config.parallel_config._api_process_rank self._resources = ExitStack() self._resources.callback(logger.debug, f"[shutdown] {self.__class__.__name__}") self._finalizer = weakref.finalize(self, self._resources.close) self.tokenizer = tokenizer # Background multimodal warmup task (a future returned by # _mm_executor) so it overlaps engine-core initialization while # staying serialized with the serving path through the same # single-worker executor. None when no background warmup has been # launched (or it has already been joined). Routing the warmup through # _mm_executor (max_workers=1) guarantees it can never run # concurrently with a serving request inside the numba workqueue # parallel region, which would otherwise hit numba's # "Concurrent access has been detected" fatal abort (see # vllm/utils/jit_monitor.py, which forces the workqueue layer). # Joined by the first of reset_mm_cache / warmup / shutdown so the # mm_processor_cache is also never touched concurrently by the warmup # and the serving path. self._mm_warmup_future: Future[None] | None = None # Whether warmup_mm has already run. Survives _join_mm_warmup (which # clears the thread pointer), so warmup() can tell that the background # warmup already ran even after reset_mm_cache joined it first, and # avoid running warmup_mm a second time. self._mm_warmup_done: bool = False # Thread pool executor for blocking tokenizer operations. The # multimodal processor receives a deep-copied tokenizer (see #36557) # so it is safe to run tokenization and MM preprocessing concurrently. pool_workers = config.model_config.renderer_num_workers self._executor = ThreadPoolExecutor(max_workers=pool_workers) self._resources.callback(self._executor.shutdown, wait=False) # Separate single-worker executor so tokenization never queues behind # MM preprocessing; must stay single-worker per #38418 (P0/P1 order). self._mm_executor = ThreadPoolExecutor(max_workers=1) self._resources.callback(self._mm_executor.shutdown, wait=False) # Offload tokenization to the thread pool. The sync # ``_tokenize_prompt`` already encapsulates the unified ``__call__`` # path and char-offset extraction, so the async variant is just it # offloaded (mirrors ``_process_multimodal_async`` below). self._tokenize_prompt_async = make_async( self._tokenize_prompt, executor=self._executor ) self._async_tokenizer_decode = make_async(self._decode, executor=self._executor) self.mm_processor: BaseMultiModalProcessor | None = None self._clear_mm_cache_async = make_async( self.clear_mm_cache, executor=self._mm_executor ) self._process_multimodal_async = make_async( self._process_multimodal, executor=self._mm_executor ) self._safe_load_prompt_embeds_async = make_async( safe_load_prompt_embeds, executor=self._executor ) self._mm_processor_cache: BaseMultiModalProcessorCache | None = None # A second processor with its own processor-only cache. # Used by the tokenize endpoint so that tokenize-only # requests don't pollute the sender cache. self._mm_processor_only_cache: BaseMultiModalProcessorCache | None = None self._mm_cache_stats: MultiModalCacheStats | None = None if config.model_config.supports_multimodal_inputs: # Install the process-global GPU memory pool used to gate # frontend GPU-side multimodal decoding (no-op when the budget # is 0). Lives in the API-server process only. mm_config = config.model_config.multimodal_config if mm_config is not None: maybe_init_mm_gpu_ipc_pool( mm_config.mm_ipc_gpu_memory_gb, config.parallel_config._api_process_count, ) with set_default_torch_num_threads(): self.mm_processor = mm_registry.create_processor( config.model_config, tokenizer=self.tokenizer, ) self._mm_processor_cache = processor_cache_from_config(config) self._mm_processor_only_cache = processor_only_cache_from_config(config) # This is used to generate internal request ID for MM processing # It has no relation to the request ID for engine core self._mm_req_counter = AtomicCounter() self._mm_timing_registry = MultiModalTimingRegistry( config.observability_config ) if self._mm_processor_cache: self._mm_cache_stats = MultiModalCacheStats() self._resources.callback(self._mm_processor_cache.close) if self._mm_processor_only_cache: self._resources.callback(self._mm_processor_only_cache.close) def get_tokenizer(self) -> _T: tokenizer = self.tokenizer if tokenizer is None: raise ValueError("Tokenizer not available when `skip_tokenizer_init=True`") return tokenizer def _decode(self, *args, **kwargs): return self.get_tokenizer().decode(*args, **kwargs) def get_mm_processor(self) -> "BaseMultiModalProcessor": if self.mm_processor is None: raise ValueError("Multi-modal processor not available for text-only models") return self.mm_processor @property def mm_processor_cache(self) -> BaseMultiModalProcessorCache | None: return self._mm_processor_cache def stat_mm_cache(self) -> MultiModalCacheStats | None: mm_cache_stats = self._mm_cache_stats if mm_cache_stats is None: return None self._mm_cache_stats = MultiModalCacheStats() return mm_cache_stats def update_mm_cache_stats(self) -> None: mm_processor_cache = self._mm_processor_cache mm_cache_stats = self._mm_cache_stats if mm_processor_cache and mm_cache_stats: delta = mm_processor_cache.make_stats(delta=True) mm_cache_stats.record(delta.total, delta.hits) def clear_mm_cache(self) -> None: mm_processor_cache = self._mm_processor_cache if mm_processor_cache is not None: mm_processor_cache.clear_cache() if self._mm_cache_stats is not None: self._mm_cache_stats.reset = True def _warmup_mm_processor( self, processor: BaseMultiModalProcessor, cache: BaseMultiModalProcessorCache | None, *, log_prefix: str, ) -> None: mm_limits = {k: v for k, v in processor.info.allowed_mm_limits.items() if v > 0} start_time = time.perf_counter() _ = processor.get_dummy_mm_inputs( dict.fromkeys(mm_limits, 1), cache=cache, scheduler_config=self.config.scheduler_config, ) elapsed = time.perf_counter() - start_time logger.info("%s warmup completed in %.3fs", log_prefix, elapsed) def warmup_mm(self) -> None: # Run only the multimodal warmup (mm + readonly mm), serially. # Idempotent: runs at most once. A background warmup # (start_mm_warmup_in_background) may already have run it, and # reset_mm_cache may have joined that background warmup task before # warmup() reaches this point — the _mm_warmup_done flag survives the # join (the future pointer does not), so warmup() can still tell it # already ran. if self._mm_warmup_done: return try: # prevent MM processor hangs with set_default_torch_num_threads(1): if self.mm_processor: mm_processor_only_cache = self._mm_processor_only_cache try: logger.debug("Warming up multi-modal processing...") self._warmup_mm_processor( self.mm_processor, mm_processor_only_cache, log_prefix="Multi-modal", ) except Exception: logger.warning("Multi-modal warmup failed") finally: if mm_processor_only_cache: mm_processor_only_cache.clear_cache() finally: self._mm_warmup_done = True def start_mm_warmup_in_background(self) -> None: # Launch the multimodal warmup as a task on the single-worker # _mm_executor so it overlaps with engine-core initialization # (model loading) while staying serialized with the serving path. # # Why the same executor as serving (not a separate Thread): # _process_multimodal runs on _mm_executor (max_workers=1) and # enters the numba workqueue parallel region (e.g. Kimi K2.5 vision # preprocessing via kimi_k25_vision_fused). A separate warmup Thread # would run concurrently with that single serving worker, so both # threads would enter the numba workqueue parallel region at the same # time and trip numba's "Concurrent access has been detected" -> # "Fatal Python error: Aborted" (see vllm/utils/jit_monitor.py, which # forces the non-threadsafe workqueue layer). Submitting the warmup to # the same single-worker executor makes warmup and serving # physically incapable of overlapping on the numba threading layer, # while still overlapping at the process level with the (forked) # engine-core init in the main thread. # # The warmup task is joined by the first of: reset_mm_cache, warmup, or # shutdown. This also guarantees the frontend mm_processor_cache — # which is not designed for concurrent access (its get_and_update must # stay ordered with the engine-core cache) — is never touched # concurrently by the warmup and the serving path. if self.mm_processor is None: return # text-only model: nothing to warm up if self._mm_warmup_future is not None: return # already launched self._mm_warmup_future = self._mm_executor.submit(self.warmup_mm) def _join_mm_warmup(self) -> None: # Wait for the background MM warmup task to finish, if one was started. # Called from the main / event-loop thread (reset_mm_cache, warmup, # shutdown) — never from inside the _mm_executor worker — so # waiting on the future cannot deadlock. future = getattr(self, "_mm_warmup_future", None) if future is not None: future.result() self._mm_warmup_future = None def warmup(self, chat_params: ChatParams) -> None: """Warm up this renderer to avoid first-request latency. For chat requests: - Jinja2 template compilation If a background multimodal warmup was launched (to overlap engine-core init) it is joined first so it is not still touching the mm_processor_cache. warmup_mm is idempotent, so the call below is a no-op if the background warmup — or a prior reset_mm_cache that joined it — already ran it. """ from vllm.entrypoints.chat_utils import ChatTemplateResolutionError # prevent MM processor hangs with set_default_torch_num_threads(1): try: logger.debug("Warming up chat template processing...") start_time = time.perf_counter() self.render_chat([[{"role": "user", "content": "warmup"}]], chat_params) elapsed = time.perf_counter() - start_time logger.debug("Chat template warmup completed in %.3fs", elapsed) except ChatTemplateResolutionError: logger.debug("This model does not support chat template.") except Exception: logger.warning("Chat template warmup failed", exc_info=True) if self._mm_warmup_future is not None: # Join a background MM warmup launched to overlap engine-core init. self._join_mm_warmup() # Idempotent: no-op if the background warmup (or a prior reset_mm_cache) # already ran the MM warmup. self.warmup_mm() async def clear_mm_cache_async(self) -> None: """Serialize clear_mm_cache through the multimodal executor to avoid races with concurrent process_inputs on the mm_processor_cache.""" await self._clear_mm_cache_async() def shutdown(self) -> None: # Wait for any background MM warmup to finish first: it runs on the # _mm_executor and touches the mm_processor_cache, so it must be # quiescent before _resources.close() tears down the executors and # the cache (both are registered as resources in __init__). self._join_mm_warmup() self._resources.close() def get_bos_token_id(self) -> int | None: if self.tokenizer is None: logger.warning_once( "Using None for BOS token id because tokenizer is not initialized" ) return None return self.tokenizer.bos_token_id def get_eos_token_id(self) -> int | None: if self.tokenizer is None: logger.warning_once( "Using None for EOS token id because tokenizer is not initialized" ) return None return self.tokenizer.eos_token_id def get_dec_start_token_id(self) -> int: """Obtain the decoder start token id employed by an encoder/decoder model, raising an error if it is not available. """ dec_start_token_id = getattr( self.model_config.hf_config, "decoder_start_token_id", None ) if dec_start_token_id is None: logger.warning_once( "Falling back on for decoder start token id " "because decoder start token id is not available." ) dec_start_token_id = self.get_bos_token_id() if dec_start_token_id is None: raise RuntimeError("Cannot find decoder start token id or ") return dec_start_token_id @cached_property def default_cmpl_tok_params(self) -> TokenizeParams: mm_processor = self.mm_processor if mm_processor is not None: return mm_processor.info.default_tok_params model_config = self.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_chat_tok_params(self) -> TokenizeParams: mm_processor = self.mm_processor if mm_processor is not None: return mm_processor.info.default_tok_params model_config = self.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=False, ) # Step 1: Convert raw inputs to prompts def render_prompt( self, prompt: DictPrompt | bytes, ) -> DictPrompt: if isinstance(prompt, bytes): embeds = safe_load_prompt_embeds(self.model_config, prompt) prompt = EmbedsPrompt(prompt_embeds=embeds) return prompt def render_prompts( self, prompts: Sequence[DictPrompt | bytes], ) -> list[DictPrompt]: if len(prompts) == 0: raise ValueError("You must pass at least one prompt") return [self.render_prompt(prompt) for prompt in prompts] async def _render_prompt_async( self, prompt: DictPrompt | bytes, ) -> DictPrompt: if isinstance(prompt, bytes): embeds = await self._safe_load_prompt_embeds_async( self.model_config, prompt ) return EmbedsPrompt(prompt_embeds=embeds) return prompt async def render_prompts_async( self, prompts: Sequence[DictPrompt | bytes], ) -> list[DictPrompt]: if len(prompts) == 0: raise ValueError("You must pass at least one prompt") return await asyncio.gather( *(self._render_prompt_async(prompt) for prompt in prompts) ) @abstractmethod def render_messages( self, messages: list["ChatCompletionMessageParam"], params: ChatParams, ) -> tuple[list["ConversationMessage"], DictPrompt]: raise NotImplementedError async def render_messages_async( self, messages: list["ChatCompletionMessageParam"], params: ChatParams, ) -> tuple[list["ConversationMessage"], DictPrompt]: return self.render_messages(messages, params) # Step 2: Tokenize prompts if necessary def _can_produce_offsets(self) -> bool: """Whether this renderer's tokenizer can emit char-level offsets. Defaults to False; only renderers backed by an HF fast tokenizer (see ``HfRenderer``) can produce ``offset_mapping``. """ return False def _wants_offsets( self, prompt: "TextPrompt", params: "TokenizeParams", ) -> bool: return ( params.return_token_offsets and self._can_produce_offsets() and not prompt.get("multi_modal_data") and not prompt.get("multi_modal_uuids") ) @staticmethod def _build_tokens_prompt( token_ids: Sequence[int], prompt: "TextPrompt", *, offset_mapping: Sequence[tuple[int, int]] | None = None, ) -> "TokensPrompt": """Build a TokensPrompt from already-extracted token ids. ``offset_mapping`` is the per-token ``(start, end)`` sequence from a BatchEncoding; pass it only when offsets were requested, and it is attached as ``prompt_token_offsets``. """ if offset_mapping is not None: return TokensPrompt( prompt_token_ids=list(token_ids), prompt_token_offsets=[(int(s), int(e)) for s, e in offset_mapping], **prompt, ) return TokensPrompt(prompt_token_ids=list(token_ids), **prompt) @staticmethod def _apply_prompt_char_offset( prompt: TokensPrompt, char_offset: int ) -> TokensPrompt: """Map token offsets back to the original prompt after a text pre-trim.""" if char_offset == 0: return prompt offsets = prompt.get("prompt_token_offsets") if offsets is not None: prompt["prompt_token_offsets"] = [ # Fast tokenizers use (0, 0) for special tokens, which are # not character spans in the source prompt. (start, end) if (start, end) == (0, 0) else (start + char_offset, end + char_offset) for start, end in offsets ] return prompt def _tokenize_prompt( self, prompt: TextPrompt, params: TokenizeParams, ) -> TokensPrompt: tokenizer = self.get_tokenizer() want_offsets = self._wants_offsets(prompt, params) kwargs = params.get_encode_kwargs() if want_offsets: kwargs = {**kwargs, "return_offsets_mapping": True} encoding = tokenizer(prompt["prompt"], **kwargs) return self._build_tokens_prompt( encoding["input_ids"], prompt, offset_mapping=encoding["offset_mapping"] if want_offsets else None, ) def _detokenize_prompt(self, prompt: TokensPrompt) -> TokensPrompt: tokenizer = self.get_tokenizer() prompt["prompt"] = tokenizer.decode(prompt["prompt_token_ids"]) return prompt async def _detokenize_prompt_async(self, prompt: TokensPrompt) -> TokensPrompt: prompt["prompt"] = await self._async_tokenizer_decode( prompt["prompt_token_ids"] ) return prompt @overload def _tokenize_singleton_prompt( self, prompt: TextPrompt | TokensPrompt, params: TokenizeParams, ) -> TokensPrompt: ... @overload def _tokenize_singleton_prompt( # type: ignore[misc] self, prompt: EmbedsPrompt, params: TokenizeParams, ) -> EmbedsPrompt: ... def _tokenize_singleton_prompt( self, prompt: SingletonDictPrompt, params: TokenizeParams, ) -> SingletonTokPrompt: if "prompt_token_ids" not in prompt and "prompt_embeds" not in prompt: if not isinstance(prompt.get("prompt"), str): raise TypeError( "Expected prompt['prompt'] to be a string before tokenization; " "use 'prompt_token_ids' for token ID inputs" ) prompt_char_offset = params._get_text_truncation_offset( self.tokenizer, prompt["prompt"] ) prompt = params.apply_pre_tokenization(self.tokenizer, prompt) # type: ignore[arg-type] prompt = self._tokenize_prompt(prompt, params) prompt = self._apply_prompt_char_offset(prompt, prompt_char_offset) if params.needs_detokenization and "prompt" not in prompt: if "prompt_token_ids" not in prompt: raise RuntimeError("Cannot run detokenization on embeddings") prompt = self._detokenize_prompt(prompt) # type: ignore[arg-type] return params.apply_post_tokenization(self.tokenizer, prompt) # type: ignore[arg-type] @overload async def _tokenize_singleton_prompt_async( self, prompt: TextPrompt | TokensPrompt, params: TokenizeParams, ) -> TokensPrompt: ... @overload async def _tokenize_singleton_prompt_async( # type: ignore[misc] self, prompt: EmbedsPrompt, params: TokenizeParams, ) -> EmbedsPrompt: ... async def _tokenize_singleton_prompt_async( self, prompt: SingletonDictPrompt, params: TokenizeParams, ) -> SingletonTokPrompt: if "prompt_token_ids" not in prompt and "prompt_embeds" not in prompt: if not isinstance(prompt.get("prompt"), str): raise TypeError( "Expected prompt['prompt'] to be a string before tokenization; " "use 'prompt_token_ids' for token ID inputs" ) prompt_char_offset = params._get_text_truncation_offset( self.tokenizer, prompt["prompt"] ) prompt = params.apply_pre_tokenization(self.tokenizer, prompt) # type: ignore[arg-type] prompt = await self._tokenize_prompt_async(prompt, params) prompt = self._apply_prompt_char_offset(prompt, prompt_char_offset) if params.needs_detokenization and "prompt" not in prompt: if "prompt_token_ids" not in prompt: raise RuntimeError("Cannot run detokenization on embeddings") prompt = await self._detokenize_prompt_async(prompt) # type: ignore[arg-type] return params.apply_post_tokenization(self.tokenizer, prompt) # type: ignore[arg-type] def _tokenize_enc_dec_prompt( self, prompt: EncoderDecoderDictPrompt, params: TokenizeParams, ) -> EncoderDecoderTokPrompt: enc_prompt, dec_prompt = ( self._tokenize_singleton_prompt(prompt["encoder_prompt"], params), ( None if prompt["decoder_prompt"] is None else self._tokenize_singleton_prompt(prompt["decoder_prompt"], params) ), ) return EncoderDecoderTokPrompt( encoder_prompt=enc_prompt, decoder_prompt=dec_prompt, ) async def _tokenize_enc_dec_prompt_async( self, prompt: EncoderDecoderDictPrompt, params: TokenizeParams, ) -> EncoderDecoderTokPrompt: enc_prompt, dec_prompt = await asyncio.gather( self._tokenize_singleton_prompt_async(prompt["encoder_prompt"], params), ( asyncio.sleep(0) if prompt["decoder_prompt"] is None else self._tokenize_singleton_prompt_async( prompt["decoder_prompt"], params ) ), ) return EncoderDecoderTokPrompt( encoder_prompt=enc_prompt, decoder_prompt=dec_prompt, ) def tokenize_prompt( self, prompt: DictPrompt, params: TokenizeParams, ) -> TokPrompt: if "encoder_prompt" in prompt: return self._tokenize_enc_dec_prompt(prompt, params) # type: ignore[arg-type] return self._tokenize_singleton_prompt(prompt, params) def tokenize_prompts( self, prompts: Sequence[DictPrompt], params: TokenizeParams, ) -> list[TokPrompt]: return [self.tokenize_prompt(prompt, params) for prompt in prompts] async def tokenize_prompt_async( self, prompt: DictPrompt, params: TokenizeParams, ) -> TokPrompt: if "encoder_prompt" in prompt: return await self._tokenize_enc_dec_prompt_async(prompt, params) # type: ignore[arg-type] return await self._tokenize_singleton_prompt_async(prompt, params) async def tokenize_prompts_async( self, prompts: Sequence[DictPrompt], params: TokenizeParams, ) -> list[TokPrompt]: return await asyncio.gather( *(self.tokenize_prompt_async(prompt, params) for prompt in prompts) ) # Step 3: Add extra keys to the prompts def _apply_prompt_extras( self, prompts: Sequence[TokPrompt], prompt_extras: dict[str, Any] | None, ): if not prompt_extras: return for prompt in prompts: target_prompt = extract_target_prompt(self.model_config, prompt) target_prompt.update(prompt_extras) # type: ignore[arg-type] # Step 4: Convert to engine inputs def _validate_mm_uuids( self, mm_data: MultiModalDataDict, mm_data_items: MultiModalDataItems, mm_uuid_items: MultiModalUUIDItems, ) -> None: # NOTE: Keys corresponding to `None` in `mm_data` don't appear in # `mm_data_items` modalities = mm_data.keys() | mm_uuid_items.keys() for modality in modalities: data_items = mm_data_items.get(modality) uuid_items = mm_uuid_items.get(modality) if data_items is None: if uuid_items is None: raise ValueError( f"multi_modal_data[{modality!r}] is empty but " f"multi_modal_uuids[{modality!r}] is missing." ) elif uuid_items is not None: if len(data_items) != len(uuid_items): raise ValueError( f"If given, multi_modal_uuids[{modality!r}] must have " f"same length as multi_modal_data[{modality!r}], but " f"got {len(uuid_items)} vs {len(data_items)}." ) for i, item in enumerate(data_items): if item is None and uuid_items[i] is None: raise ValueError( f"multi_modal_data[{modality!r}][{i}] is empty but " f"multi_modal_uuids[{modality!r}][{i}] is missing." ) def _process_mm_uuids( self, mm_data: MultiModalDataDict, mm_data_items: MultiModalDataItems, mm_uuid_items: MultiModalUUIDItems, mm_req_id: str, ) -> MultiModalUUIDItems: model_config = self.model_config # Skip content hashing when both caches are disabled, but preserve # explicit UUIDs used to identify embeddings across EC instances. if ( model_config.multimodal_config and model_config.multimodal_config.mm_processor_cache_gb == 0 and not self.config.cache_config.enable_prefix_caching ): mm_uuid_items = dict(mm_uuid_items) for modality, data_count in mm_data_items.get_all_counts().items(): uuids = mm_uuid_items.get(modality) if uuids is None: uuids = [None] * data_count mm_uuid_items[modality] = [ uuid if uuid is not None else f"{mm_req_id}-{modality}-{i}" for i, uuid in enumerate(uuids) ] self._validate_mm_uuids(mm_data, mm_data_items, mm_uuid_items) return mm_uuid_items def _process_multimodal( self, prompt: list[int], mm_data: MultiModalDataDict, mm_uuids: MultiModalUUIDDict | None, mm_processor_kwargs: Mapping[str, object] | None, media_io_kwargs: Mapping[str, Mapping[str, object]] | None = None, *, skip_mm_cache: bool = False, ) -> "MultiModalInput": mm_processor = self.get_mm_processor() mm_req_id = f"renderer{self.api_process_rank}-mm-{self._mm_req_counter.inc(1)}" mm_data_items = mm_processor.info.parse_mm_data(mm_data) mm_uuid_items = parse_mm_uuids(mm_uuids) mm_uuid_items = self._process_mm_uuids( mm_data, mm_data_items, mm_uuid_items, mm_req_id ) mm_processor_inputs = MMProcessorInputs( prompt, mm_data_items, mm_uuid_items, hf_processor_mm_kwargs=mm_processor_kwargs or {}, media_io_kwargs=media_io_kwargs or {}, cache=( self._mm_processor_only_cache if skip_mm_cache else self._mm_processor_cache ), ) mm_timing_ctx = self._mm_timing_registry.get(mm_req_id) with set_default_torch_num_threads(): mm_inputs = mm_processor.apply(mm_processor_inputs, mm_timing_ctx) self.update_mm_cache_stats() return mm_inputs def _process_tokens( self, prompt: TokensPrompt, *, skip_mm_cache: bool = False, ) -> TokensInput | MultiModalInput: """Process token inputs, with multimodal preprocessing offloaded to the shared thread pool in the async variant. """ prompt_token_ids = prompt["prompt_token_ids"] engine_input: TokensInput | MultiModalInput if multi_modal_data := prompt.get("multi_modal_data"): engine_input = self._process_multimodal( prompt_token_ids, multi_modal_data, mm_processor_kwargs=prompt.get("mm_processor_kwargs"), mm_uuids=prompt.get("multi_modal_uuids"), media_io_kwargs=prompt.get("media_io_kwargs"), skip_mm_cache=skip_mm_cache, ) else: engine_input = tokens_input(prompt_token_ids) if prompt_text := prompt.get("prompt"): engine_input["prompt"] = prompt_text if cache_salt := prompt.get("cache_salt"): engine_input["cache_salt"] = cache_salt # Narrow the union — `prompt_token_offsets` is only on TokensInput. if engine_input["type"] == "token" and ( (offsets := prompt.get("prompt_token_offsets")) is not None ): engine_input["prompt_token_offsets"] = offsets return engine_input def _process_embeds(self, prompt: EmbedsPrompt) -> EmbedsInput: if not self.model_config.enable_prompt_embeds: raise ValueError( "You must set `--enable-prompt-embeds` to input `prompt_embeds`." ) prompt_embeds = prompt["prompt_embeds"] # prompt_embeds must be (seq_len, hidden_size), but if the user # passes in a batch of size 1, i.e. (1, seq_len, hidden_size), # we can unambiguously process the intent by squeezing the batch # dimension. if prompt_embeds.ndim == 3: prompt_embeds = prompt_embeds.squeeze(dim=0) if prompt_embeds.ndim != 2: raise ValueError("prompt_embeds must be of shape (seq_len, hidden_size).") # Tensors must be on CPU for serialization between processes # in the MsgpackEncoder. Casting to CPU here ensures that there is no # hidden device transfer in the critical path of generation. prompt_embeds = prompt_embeds.cpu() return embeds_input( prompt_embeds=prompt_embeds, cache_salt=prompt.get("cache_salt"), prompt_token_ids=prompt.get("prompt_token_ids"), is_token_ids=prompt.get("prompt_is_token_ids"), ) async def _process_tokens_async( self, prompt: TokensPrompt, *, skip_mm_cache: bool = False, ) -> TokensInput | MultiModalInput: prompt_token_ids = prompt["prompt_token_ids"] engine_input: TokensInput | MultiModalInput if multi_modal_data := prompt.get("multi_modal_data"): engine_input = await self._process_multimodal_async( prompt_token_ids, multi_modal_data, mm_processor_kwargs=prompt.get("mm_processor_kwargs"), mm_uuids=prompt.get("multi_modal_uuids"), media_io_kwargs=prompt.get("media_io_kwargs"), skip_mm_cache=skip_mm_cache, ) else: engine_input = tokens_input(prompt_token_ids) if prompt_text := prompt.get("prompt"): engine_input["prompt"] = prompt_text if cache_salt := prompt.get("cache_salt"): engine_input["cache_salt"] = cache_salt # Narrow the union — `prompt_token_offsets` is only on TokensInput. if engine_input["type"] == "token" and ( (offsets := prompt.get("prompt_token_offsets")) is not None ): engine_input["prompt_token_offsets"] = offsets return engine_input def _truncate_expanded_prompt( self, engine_input: TokensInput | MultiModalInput, params: TokenizeParams | None, ) -> None: """Re-apply `truncate_prompt_tokens` once placeholders are expanded. `TokenizeParams.apply_post_tokenization` truncates the prompt before multimodal expansion, so a single placeholder token can expand into many and push the result back over the requested bound. Multimodal spans are never cut: a request whose spans would not all survive intact is rejected, matching the post-expansion rejection in `vllm.v1.engine.input_processor._validate_model_input`. Only multimodal inputs can grow after `apply_post_tokenization`, so everything else returns immediately. """ if engine_input["type"] != "multimodal": return if params is None or params.truncate_prompt_tokens is None: return prompt_token_ids = engine_input.get("prompt_token_ids") if prompt_token_ids is None: return num_tokens = len(prompt_token_ids) truncation = params._truncation_slice(self.tokenizer, num_tokens) if truncation is None: return start, stop, _ = truncation.indices(num_tokens) mm_placeholders = engine_input["mm_placeholders"] for modality, positions in mm_placeholders.items(): for position in positions: end = position.offset + position.length if position.offset >= start and end <= stop: continue raise VLLMValidationError( f"truncate_prompt_tokens={params.truncate_prompt_tokens} " f"would split or drop a(n) {modality} item, which spans " f"tokens {position.offset} to {end} of the " f"{num_tokens}-token prompt. Multimodal placeholders " f"expand after truncation is applied, so the limit must " f"leave every multimodal item intact. Please raise " f"truncate_prompt_tokens or shorten the multimodal input.", parameter="truncate_prompt_tokens", value=params.truncate_prompt_tokens, ) if start: engine_input["mm_placeholders"] = { modality: [ replace(position, offset=position.offset - start) for position in positions ] for modality, positions in mm_placeholders.items() } engine_input["prompt_token_ids"] = prompt_token_ids[truncation] def _process_singleton( self, prompt: SingletonTokPrompt, *, skip_mm_cache: bool = False, tok_params: TokenizeParams | None = None, ) -> SingletonInput: if "prompt_embeds" in prompt: return self._process_embeds(prompt) # type: ignore[arg-type] engine_input = self._process_tokens( # type: ignore[arg-type] prompt, # type: ignore[arg-type] skip_mm_cache=skip_mm_cache, ) self._truncate_expanded_prompt(engine_input, tok_params) return engine_input async def _process_singleton_async( self, prompt: SingletonTokPrompt, *, skip_mm_cache: bool = False, tok_params: TokenizeParams | None = None, ) -> SingletonInput: if "prompt_embeds" in prompt: return self._process_embeds(prompt) # type: ignore[arg-type] engine_input = await self._process_tokens_async( # type: ignore[arg-type] prompt, # type: ignore[arg-type] skip_mm_cache=skip_mm_cache, ) self._truncate_expanded_prompt(engine_input, tok_params) return engine_input def _get_skip_decoder_start_token(self) -> bool: """Whether the multimodal processor supplies a complete decoder prefix.""" if self.mm_processor is not None: from vllm.multimodal.processing import EncDecMultiModalProcessor if isinstance(self.mm_processor, EncDecMultiModalProcessor): return self.mm_processor.skip_decoder_start_token return False def _process_enc_dec( self, prompt: EncoderDecoderTokPrompt, *, skip_mm_cache: bool = False, ) -> EncoderDecoderInput: enc_prompt = prompt["encoder_prompt"] dec_prompt = prompt["decoder_prompt"] return build_enc_dec_input( encoder_input=self._process_singleton( enc_prompt, skip_mm_cache=skip_mm_cache ), decoder_input=( None if dec_prompt is None else self._process_singleton(dec_prompt, skip_mm_cache=skip_mm_cache) ), decoder_start_token_id=self.get_dec_start_token_id(), skip_decoder_start_token=self._get_skip_decoder_start_token(), ) async def _process_enc_dec_async( self, prompt: EncoderDecoderTokPrompt, *, skip_mm_cache: bool = False, ) -> EncoderDecoderInput: enc_prompt = prompt["encoder_prompt"] dec_prompt = prompt["decoder_prompt"] encoder_input, decoder_input = await asyncio.gather( self._process_singleton_async(enc_prompt, skip_mm_cache=skip_mm_cache), ( asyncio.sleep(0) if dec_prompt is None else self._process_singleton_async( dec_prompt, skip_mm_cache=skip_mm_cache ) ), ) return build_enc_dec_input( encoder_input=encoder_input, decoder_input=decoder_input, decoder_start_token_id=self.get_dec_start_token_id(), skip_decoder_start_token=self._get_skip_decoder_start_token(), ) def process_for_engine( self, prompt: TokPrompt, arrival_time: float, *, skip_mm_cache: bool = False, tok_params: TokenizeParams | None = None, ) -> EngineInput: engine_input: EngineInput if "encoder_prompt" in prompt: engine_input = self._process_enc_dec(prompt, skip_mm_cache=skip_mm_cache) # type: ignore[arg-type] else: engine_input = self._process_singleton( prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) engine_input["arrival_time"] = arrival_time return engine_input async def process_for_engine_async( self, prompt: TokPrompt, arrival_time: float, *, skip_mm_cache: bool = False, tok_params: TokenizeParams | None = None, ) -> EngineInput: engine_input: EngineInput if "encoder_prompt" in prompt: engine_input = await self._process_enc_dec_async( prompt, # type: ignore[arg-type] skip_mm_cache=skip_mm_cache, ) else: engine_input = await self._process_singleton_async( prompt, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) engine_input["arrival_time"] = arrival_time return engine_input # Top-level methods def render_cmpl( self, prompts: Sequence[DictPrompt | bytes], tok_params: TokenizeParams | None = None, *, prompt_extras: dict[str, Any] | None = None, skip_mm_cache: bool = False, ): arrival_time = time.time() if tok_params is None: tok_params = self.default_cmpl_tok_params dict_prompts = self.render_prompts(prompts) tok_prompts = self.tokenize_prompts(dict_prompts, tok_params) self._apply_prompt_extras(tok_prompts, prompt_extras) return [ self.process_for_engine( prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) for prompt in tok_prompts ] async def render_cmpl_async( self, prompts: Sequence[DictPrompt | bytes], tok_params: TokenizeParams | None = None, *, prompt_extras: dict[str, Any] | None = None, skip_mm_cache: bool = False, ): arrival_time = time.time() if tok_params is None: tok_params = self.default_cmpl_tok_params dict_prompts = await self.render_prompts_async(prompts) tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params) self._apply_prompt_extras(tok_prompts, prompt_extras) return await asyncio.gather( *( self.process_for_engine_async( p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) for p in tok_prompts ) ) def render_chat( self, conversations: Sequence[list["ChatCompletionMessageParam"]], chat_params: ChatParams, tok_params: TokenizeParams | None = None, *, prompt_extras: dict[str, Any] | None = None, skip_mm_cache: bool = False, ): arrival_time = time.time() if tok_params is None: tok_params = self.default_chat_tok_params rendered = [ self.render_messages(conversation, chat_params) for conversation in conversations ] out_conversations = list[list["ConversationMessage"]]() dict_prompts = list[DictPrompt]() for conv, prompt in rendered: out_conversations.append(conv) dict_prompts.append(prompt) tok_prompts = self.tokenize_prompts(dict_prompts, tok_params) prompt_extras = dict(prompt_extras or {}) prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {} self._apply_prompt_extras(tok_prompts, prompt_extras) eng_prompts = [ self.process_for_engine( prompt, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) for prompt in tok_prompts ] return out_conversations, eng_prompts async def render_chat_async( self, conversations: Sequence[list["ChatCompletionMessageParam"]], chat_params: ChatParams, tok_params: TokenizeParams | None = None, *, prompt_extras: dict[str, Any] | None = None, skip_mm_cache: bool = False, ): arrival_time = time.time() if tok_params is None: tok_params = self.default_chat_tok_params rendered = [ self.render_messages_async(conversation, chat_params) for conversation in conversations ] out_conversations = list[list["ConversationMessage"]]() dict_prompts = list[DictPrompt]() for conv, prompt in await asyncio.gather(*rendered): out_conversations.append(conv) dict_prompts.append(prompt) tok_prompts = await self.tokenize_prompts_async(dict_prompts, tok_params) prompt_extras = dict(prompt_extras or {}) prompt_extras["media_io_kwargs"] = chat_params.media_io_kwargs or {} self._apply_prompt_extras(tok_prompts, prompt_extras) eng_prompts = await asyncio.gather( *( self.process_for_engine_async( p, arrival_time, skip_mm_cache=skip_mm_cache, tok_params=tok_params ) for p in tok_prompts ) ) return out_conversations, eng_prompts