@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / b0746fae3d
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 15, 19:45 UTC

[Frontend] support image embeds (#13955)

Signed-off-by: chaunceyjiang <chaunceyjiang@gmail.com>

Chauncey committed Mar 10, 2025 at 20:36 UTC b0746fae3d576528598d43da129ec5655197560b
4 files changed +201 -12
docs/source/serving/multimodal_inputs.md
+66 -1
@@ -462,4 +462,69 @@ export VLLM_AUDIO_FETCH_TIMEOUT=<timeout>
462
463 ### Embedding Inputs
464
465 -TBD
465 +To input pre-computed embeddings belonging to a data type (i.e. image, video, or audio) directly to the language model,
466 +pass a tensor of shape to the corresponding field of the multi-modal dictionary.
467 +#### Image Embedding Inputs
468 +For image embeddings, you can pass the base64-encoded tensor to the `image_embeds` field.
469 +The following example demonstrates how to pass image embeddings to the OpenAI server:
470 +
471 +```python
472 +image_embedding = torch.load(...)
473 +grid_thw = torch.load(...) # Required by Qwen/Qwen2-VL-2B-Instruct
474 +
475 +buffer = io.BytesIO()
476 +torch.save(image_embedding, buffer)
477 +buffer.seek(0)
478 +binary_data = buffer.read()
479 +base64_image_embedding = base64.b64encode(binary_data).decode('utf-8')
480 +
481 +client = OpenAI(
482 + # defaults to os.environ.get("OPENAI_API_KEY")
483 + api_key=openai_api_key,
484 + base_url=openai_api_base,
485 +)
486 +
487 +# Basic usage - this is equivalent to the LLaVA example for offline inference
488 +model = "llava-hf/llava-1.5-7b-hf"
489 +embeds = {
490 + "type": "image_embeds",
491 + "image_embeds": f"{base64_image_embedding}"
492 +}
493 +
494 +# Pass additional parameters (available to Qwen2-VL and MiniCPM-V)
495 +model = "Qwen/Qwen2-VL-2B-Instruct"
496 +embeds = {
497 + "type": "image_embeds",
498 + "image_embeds": {
499 + "image_embeds": f"{base64_image_embedding}" , # Required
500 + "image_grid_thw": f"{base64_image_grid_thw}" # Required by Qwen/Qwen2-VL-2B-Instruct
501 + },
502 +}
503 +model = "openbmb/MiniCPM-V-2_6"
504 +embeds = {
505 + "type": "image_embeds",
506 + "image_embeds": {
507 + "image_embeds": f"{base64_image_embedding}" , # Required
508 + "image_sizes": f"{base64_image_sizes}" # Required by openbmb/MiniCPM-V-2_6
509 + },
510 +}
511 +chat_completion = client.chat.completions.create(
512 + messages=[
513 + {"role": "system", "content": "You are a helpful assistant."},
514 + {"role": "user", "content": [
515 + {
516 + "type": "text",
517 + "text": "What's in this image?",
518 + },
519 + embeds,
520 + ],
521 + },
522 +],
523 + model=model,
524 +)
525 +```
526 +
527 +:::{note}
528 +Only one message can contain `{"type": "image_embeds"}`.
529 +If used with a model that requires additional parameters, you must also provide a tensor for each of them, e.g. `image_grid_thw`, `image_sizes`, etc.
530 +:::
vllm/entrypoints/chat_utils.py
+103 -10
@@ -56,6 +56,17 @@ class ChatCompletionContentPartAudioParam(TypedDict, total=False):
56 """The type of the content part."""
57
58
59 +class ChatCompletionContentPartImageEmbedsParam(TypedDict, total=False):
60 + image_embeds: Required[Union[str, dict[str, str]]]
61 + """
62 + The image embeddings. It can be either:
63 + - A single base64 string.
64 + - A dictionary where each value is a base64 string.
65 + """
66 + type: Required[Literal["image_embeds"]]
67 + """The type of the content part."""
68 +
69 +
70 class VideoURL(TypedDict, total=False):
71 url: Required[str]
72 """
@@ -109,6 +120,7 @@ ChatCompletionContentPartParam: TypeAlias = Union[
120 ChatCompletionContentPartInputAudioParam,
121 ChatCompletionContentPartVideoParam, ChatCompletionContentPartRefusalParam,
122 CustomChatCompletionContentSimpleImageParam,
123 + ChatCompletionContentPartImageEmbedsParam,
124 CustomChatCompletionContentSimpleAudioParam,
125 CustomChatCompletionContentSimpleVideoParam, str]
126
@@ -350,7 +362,7 @@ def resolve_chat_template_content_format(
362 return detected_format
363
364
353 -ModalityStr = Literal["image", "audio", "video"]
365 +ModalityStr = Literal["image", "audio", "video", "image_embeds"]
366 _T = TypeVar("_T")
367
368
@@ -391,7 +403,7 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
403 hf_config = self._model_config.hf_config
404 model_type = hf_config.model_type
405
394 - if modality == "image":
406 + if modality in ["image", "image_embeds"]:
407 if model_type == "phi3_v":
408 # Workaround since this token is not defined in the tokenizer
409 return f"<|image_{current_count}|>"
@@ -470,10 +482,27 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
482 class MultiModalItemTracker(BaseMultiModalItemTracker[object]):
483
484 def all_mm_data(self) -> Optional[MultiModalDataDict]:
473 - if self._items_by_modality:
474 - return dict(self._items_by_modality)
475 -
476 - return None
485 + if not self._items_by_modality:
486 + return None
487 + mm_inputs = {}
488 + items_by_modality = dict(self._items_by_modality)
489 + if "image" in items_by_modality and "image_embeds" in items_by_modality:
490 + raise ValueError(\
491 + "Mixing raw image and embedding inputs is not allowed")
492 +
493 + if "image_embeds" in items_by_modality:
494 + image_embeds_lst = items_by_modality["image_embeds"]
495 + if len(image_embeds_lst) > 1:
496 + raise ValueError(\
497 + "Only one message can have {'type': 'image_embeds'}")
498 + mm_inputs["image"] = image_embeds_lst[0]
499 + elif "image" in items_by_modality:
500 + mm_inputs["image"] = items_by_modality["image"] # A list of images
501 + elif "audio" in items_by_modality:
502 + mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
503 + elif "video" in items_by_modality:
504 + mm_inputs["video"] = items_by_modality["video"] # A list of videos
505 + return mm_inputs
506
507 def create_parser(self) -> "BaseMultiModalContentParser":
508 return MultiModalContentParser(self)
@@ -482,13 +511,31 @@ class MultiModalItemTracker(BaseMultiModalItemTracker[object]):
511 class AsyncMultiModalItemTracker(BaseMultiModalItemTracker[Awaitable[object]]):
512
513 async def all_mm_data(self) -> Optional[MultiModalDataDict]:
485 - if self._items_by_modality:
486 - return {
514 + if not self._items_by_modality:
515 + return None
516 + mm_inputs = {}
517 + items_by_modality = {
518 modality: await asyncio.gather(*items)
519 for modality, items in self._items_by_modality.items()
520 }
521
491 - return None
522 + if "image" in items_by_modality and "image_embeds" in items_by_modality:
523 + raise ValueError(
524 + "Mixing raw image and embedding inputs is not allowed")
525 +
526 + if "image_embeds" in items_by_modality:
527 + image_embeds_lst = items_by_modality["image_embeds"]
528 + if len(image_embeds_lst) > 1:
529 + raise ValueError(
530 + "Only one message can have {'type': 'image_embeds'}")
531 + mm_inputs["image"] = image_embeds_lst[0]
532 + elif "image" in items_by_modality:
533 + mm_inputs["image"] = items_by_modality["image"] # A list of images
534 + elif "audio" in items_by_modality:
535 + mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
536 + elif "video" in items_by_modality:
537 + mm_inputs["video"] = items_by_modality["video"] # A list of videos
538 + return mm_inputs
539
540 def create_parser(self) -> "BaseMultiModalContentParser":
541 return AsyncMultiModalContentParser(self)
@@ -513,6 +560,11 @@ class BaseMultiModalContentParser(ABC):
560 def parse_image(self, image_url: str) -> None:
561 raise NotImplementedError
562
563 + @abstractmethod
564 + def parse_image_embeds(self,
565 + image_embeds: Union[str, dict[str, str]]) -> None:
566 + raise NotImplementedError
567 +
568 @abstractmethod
569 def parse_audio(self, audio_url: str) -> None:
570 raise NotImplementedError
@@ -543,6 +595,21 @@ class MultiModalContentParser(BaseMultiModalContentParser):
595 placeholder = self._tracker.add("image", image)
596 self._add_placeholder(placeholder)
597
598 + def parse_image_embeds(self,
599 + image_embeds: Union[str, dict[str, str]]) -> None:
600 + if isinstance(image_embeds, dict):
601 + embeds = {
602 + k: self._connector.fetch_image_embedding(v)
603 + for k, v in image_embeds.items()
604 + }
605 + placeholder = self._tracker.add("image_embeds", embeds)
606 +
607 + if isinstance(image_embeds, str):
608 + embedding = self._connector.fetch_image_embedding(image_embeds)
609 + placeholder = self._tracker.add("image_embeds", embedding)
610 +
611 + self._add_placeholder(placeholder)
612 +
613 def parse_audio(self, audio_url: str) -> None:
614 audio = self._connector.fetch_audio(audio_url)
615
@@ -579,6 +646,25 @@ class AsyncMultiModalContentParser(BaseMultiModalContentParser):
646 placeholder = self._tracker.add("image", image_coro)
647 self._add_placeholder(placeholder)
648
649 + def parse_image_embeds(self,
650 + image_embeds: Union[str, dict[str, str]]) -> None:
651 + future: asyncio.Future[Union[str, dict[str, str]]] = asyncio.Future()
652 +
653 + if isinstance(image_embeds, dict):
654 + embeds = {
655 + k: self._connector.fetch_image_embedding(v)
656 + for k, v in image_embeds.items()
657 + }
658 + future.set_result(embeds)
659 +
660 + if isinstance(image_embeds, str):
661 + embedding = self._connector.\
662 + fetch_image_embedding(image_embeds)
663 + future.set_result(embedding)
664 +
665 + placeholder = self._tracker.add("image_embeds", future)
666 + self._add_placeholder(placeholder)
667 +
668 def parse_audio(self, audio_url: str) -> None:
669 audio_coro = self._connector.fetch_audio_async(audio_url)
670
@@ -684,6 +770,7 @@ def _get_full_multimodal_text_prompt(placeholder_counts: dict[str, int],
770 # No need to validate using Pydantic again
771 _TextParser = partial(cast, ChatCompletionContentPartTextParam)
772 _ImageParser = partial(cast, ChatCompletionContentPartImageParam)
773 +_ImageEmbedsParser = partial(cast, ChatCompletionContentPartImageEmbedsParam)
774 _AudioParser = partial(cast, ChatCompletionContentPartAudioParam)
775 _InputAudioParser = partial(cast, ChatCompletionContentPartInputAudioParam)
776 _RefusalParser = partial(cast, ChatCompletionContentPartRefusalParam)
@@ -700,6 +787,8 @@ MM_PARSER_MAP: dict[
787 lambda part: _TextParser(part).get("text", ""),
788 "image_url":
789 lambda part: _ImageParser(part).get("image_url", {}).get("url", ""),
790 + "image_embeds":
791 + lambda part: _ImageEmbedsParser(part).get("image_embeds", {}),
792 "audio_url":
793 lambda part: _AudioParser(part).get("audio_url", {}).get("url", ""),
794 "input_audio":
@@ -769,6 +858,7 @@ def _parse_chat_message_content_mm_part(
858
859
860 VALID_MESSAGE_CONTENT_MM_PART_TYPES = ("text", "refusal", "image_url",
861 + "image_embeds",
862 "audio_url", "input_audio", "video_url")
863
864
@@ -843,7 +933,10 @@ def _parse_chat_message_content_part(
933 str_content = cast(str, content)
934 mm_parser.parse_image(str_content)
935 return {'type': 'image'} if wrap_dicts else None
846 -
936 + if part_type == "image_embeds":
937 + content = cast(Union[str, dict[str, str]], content)
938 + mm_parser.parse_image_embeds(content)
939 + return {'type': 'image'} if wrap_dicts else None
940 if part_type == "audio_url":
941 str_content = cast(str, content)
942 mm_parser.parse_audio(str_content)
vllm/multimodal/image.py
+19
@@ -134,3 +134,22 @@ class ImageMediaIO(MediaIO[Image.Image]):
134 data = buffer.getvalue()
135
136 return base64.b64encode(data).decode('utf-8')
137 +
138 +
139 +class ImageEmbeddingMediaIO(MediaIO[torch.Tensor]):
140 +
141 + def __init__(self) -> None:
142 + super().__init__()
143 +
144 + def load_bytes(self, data: bytes) -> torch.Tensor:
145 + buffer = BytesIO(data)
146 + return torch.load(buffer, weights_only=True)
147 +
148 + def load_base64(self, media_type: str, data: str) -> torch.Tensor:
149 + return self.load_bytes(base64.b64decode(data))
150 +
151 + def load_file(self, filepath: Path) -> torch.Tensor:
152 + return torch.load(filepath)
153 +
154 + def encode_base64(self, media: torch.Tensor) -> str:
155 + return base64.b64encode(media.numpy()).decode('utf-8')
vllm/multimodal/utils.py
+13 -1
@@ -7,6 +7,7 @@ from urllib.parse import ParseResult, urlparse
7
8 import numpy as np
9 import numpy.typing as npt
10 +import torch
11 from PIL import Image
12
13 import vllm.envs as envs
@@ -16,7 +17,7 @@ from vllm.transformers_utils.tokenizer import AnyTokenizer
17
18 from .audio import AudioMediaIO
19 from .base import MediaIO
19 -from .image import ImageMediaIO
20 +from .image import ImageEmbeddingMediaIO, ImageMediaIO
21 from .inputs import PlaceholderRange
22 from .video import VideoMediaIO
23
@@ -245,6 +246,17 @@ class MediaConnector:
246 fetch_timeout=envs.VLLM_VIDEO_FETCH_TIMEOUT,
247 )
248
249 + def fetch_image_embedding(
250 + self,
251 + data: str,
252 + ) -> torch.Tensor:
253 + """
254 + Load image embedding from a URL.
255 + """
256 + image_embedding_io = ImageEmbeddingMediaIO()
257 +
258 + return image_embedding_io.load_base64("", data)
259 +
260
261 global_media_connector = MediaConnector()
262 """The global :class:`MediaConnector` instance used by vLLM."""