@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / b09806e28f
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 11, 12:30 UTC

[Bugfix] Dictionary MM embeddings for online chat (#30507)

Signed-off-by: DarkLight1337 <tlleungac@connect.ust.hk>

Cyrus Leung committed Dec 13, 2025 at 15:48 UTC b09806e28ffcc3e63176d668b2b3e965b35c986c
3 files changed +193 -44
tests/entrypoints/test_chat_utils.py
+105 -5
@@ -796,9 +796,13 @@ def test_parse_chat_messages_empty_image_embeds_with_uuid(
796 "content": "<|image_1|>\nWhat's in this image?",
797 }
798 ]
799 +
800 assert mm_data is not None
801 assert "image" in mm_data
801 - assert mm_data["image"] is None
802 + assert isinstance(mm_data["image"], list)
803 + assert len(mm_data["image"]) == 1
804 + assert mm_data["image"][0] is None
805 +
806 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
807
808
@@ -825,10 +829,11 @@ def test_parse_chat_messages_empty_audio_embeds_with_uuid(
829 # Should have audio in mm_data as None (UUID provided)
830 assert mm_data is not None
831 assert "audio" in mm_data
828 - assert mm_data["audio"] is None
832 + assert isinstance(mm_data["audio"], list)
833 + assert len(mm_data["audio"]) == 1
834 + assert mm_data["audio"][0] is None
835 +
836 # UUID should be recorded
830 - assert mm_uuids is not None
831 - assert "audio" in mm_uuids
837 _assert_mm_uuids(mm_uuids, 1, modality="audio", expected_uuids=[uuid])
838
839
@@ -1121,10 +1126,105 @@ async def test_parse_chat_messages_empty_image_embeds_with_uuid_async(
1126 mm_data = await mm_future
1127 assert mm_data is not None
1128 assert "image" in mm_data
1124 - assert mm_data["image"] is None
1129 + assert isinstance(mm_data["image"], list)
1130 + assert len(mm_data["image"]) == 1
1131 + assert mm_data["image"][0] is None
1132 +
1133 _assert_mm_uuids(mm_uuids, 1, expected_uuids=[uuid])
1134
1135
1136 +def test_parse_chat_messages_empty_dict_image_embeds(
1137 + phi3v_model_config_image_embeds,
1138 +):
1139 + """Test that empty dictionary for image_embeds is handled without errors."""
1140 + conversation, mm_data, mm_uuids = parse_chat_messages(
1141 + [
1142 + {
1143 + "role": "user",
1144 + "content": [
1145 + {"type": "image_embeds", "image_embeds": {}},
1146 + {"type": "text", "text": "What's in this image?"},
1147 + ],
1148 + }
1149 + ],
1150 + phi3v_model_config_image_embeds,
1151 + content_format="string",
1152 + )
1153 +
1154 + # Verify conversation structure
1155 + assert conversation == [
1156 + {
1157 + "role": "user",
1158 + "content": "<|image_1|>\nWhat's in this image?",
1159 + }
1160 + ]
1161 +
1162 + # Verify mm_data contains an empty dictionary of embeddings
1163 + assert mm_data is not None
1164 + assert "image" in mm_data
1165 + assert isinstance(mm_data["image"], dict)
1166 + assert len(mm_data["image"]) == 0
1167 +
1168 + # Verify UUIDs (None since we didn't provide any)
1169 + _assert_mm_uuids(mm_uuids, 1, expected_uuids=[None])
1170 +
1171 +
1172 +def test_parse_chat_messages_multiple_dict_image_embeds(
1173 + phi3v_model_config_image_embeds,
1174 +):
1175 + """Test that multiple dictionaries for image_embeds is handled without errors."""
1176 + # Create two sample image embedding tensors
1177 + batch_size = 2
1178 + image_embedding_1 = torch.randn(batch_size, 256, 1024)
1179 + image_embedding_2 = torch.randn(batch_size, 3)
1180 +
1181 + conversation, mm_data, mm_uuids = parse_chat_messages(
1182 + [
1183 + {
1184 + "role": "user",
1185 + "content": [
1186 + {
1187 + "type": "image_embeds",
1188 + "image_embeds": {
1189 + "image_embedding_1": tensor2base64(p),
1190 + "image_embedding_2": tensor2base64(i),
1191 + },
1192 + }
1193 + for p, i in zip(image_embedding_1, image_embedding_2)
1194 + ]
1195 + + [
1196 + {"type": "text", "text": "Describe these two images."},
1197 + ],
1198 + }
1199 + ],
1200 + phi3v_model_config_image_embeds,
1201 + content_format="string",
1202 + )
1203 +
1204 + # Verify conversation structure
1205 + assert conversation == [
1206 + {
1207 + "role": "user",
1208 + "content": "<|image_1|>\n<|image_2|>\nDescribe these two images.",
1209 + }
1210 + ]
1211 +
1212 + # Verify mm_data contains a dictionary of multi-embeddings
1213 + assert mm_data is not None
1214 + assert "image" in mm_data
1215 + assert isinstance(mm_data["image"], dict)
1216 + assert len(mm_data["image"]) == batch_size
1217 +
1218 + # Verify each embedding has the correct shape
1219 + assert isinstance(mm_data["image"]["image_embedding_1"], torch.Tensor)
1220 + assert mm_data["image"]["image_embedding_1"].shape == image_embedding_1.shape
1221 + assert isinstance(mm_data["image"]["image_embedding_2"], torch.Tensor)
1222 + assert mm_data["image"]["image_embedding_2"].shape == image_embedding_2.shape
1223 +
1224 + # Verify UUIDs (None since we didn't provide any)
1225 + _assert_mm_uuids(mm_uuids, batch_size, expected_uuids=[None, None])
1226 +
1227 +
1228 @pytest.mark.asyncio
1229 async def test_parse_chat_messages_multiple_images_async(
1230 phi3v_model_config,
vllm/entrypoints/chat_utils.py
+68 -29
@@ -9,7 +9,7 @@ from collections import Counter, defaultdict, deque
9 from collections.abc import Awaitable, Callable, Iterable
10 from functools import cached_property, lru_cache, partial
11 from pathlib import Path
12 -from typing import Any, Generic, Literal, TypeAlias, TypeVar, cast
12 +from typing import TYPE_CHECKING, Any, Generic, Literal, TypeAlias, TypeVar, cast
13
14 import jinja2
15 import jinja2.ext
@@ -53,7 +53,14 @@ from vllm.tokenizers import MistralTokenizer, TokenizerLike
53 from vllm.transformers_utils.chat_templates import get_chat_template_fallback_path
54 from vllm.transformers_utils.processor import cached_get_processor
55 from vllm.utils import random_uuid
56 +from vllm.utils.collection_utils import is_list_of
57 from vllm.utils.func_utils import supports_kw
58 +from vllm.utils.import_utils import LazyLoader
59 +
60 +if TYPE_CHECKING:
61 + import torch
62 +else:
63 + torch = LazyLoader("torch", globals(), "torch")
64
65 logger = init_logger(__name__)
66
@@ -620,6 +627,44 @@ ModalityStr = Literal["image", "audio", "video", "image_embeds", "audio_embeds"]
627 _T = TypeVar("_T")
628
629
630 +def _extract_embeds(tensors: list[torch.Tensor]):
631 + if len(tensors) == 0:
632 + return tensors
633 +
634 + if len(tensors) == 1:
635 + tensors[0]._is_single_item = True # type: ignore
636 + return tensors[0] # To keep backwards compatibility for single item input
637 +
638 + first_shape = tensors[0].shape
639 + if all(t.shape == first_shape for t in tensors):
640 + return torch.stack(tensors)
641 +
642 + return tensors
643 +
644 +
645 +def _get_embeds_data(items_by_modality: dict[str, list[Any]], modality: str):
646 + embeds_key = f"{modality}_embeds"
647 + embeds = items_by_modality[embeds_key]
648 +
649 + if len(embeds) == 0:
650 + return embeds
651 + if is_list_of(embeds, torch.Tensor):
652 + return _extract_embeds(embeds)
653 + if is_list_of(embeds, dict):
654 + if not embeds:
655 + return {}
656 +
657 + first_keys = set(embeds[0].keys())
658 + if any(set(item.keys()) != first_keys for item in embeds[1:]):
659 + raise ValueError(
660 + "All dictionaries in the list of embeddings must have the same keys."
661 + )
662 +
663 + return {k: _extract_embeds([item[k] for item in embeds]) for k in first_keys}
664 +
665 + return embeds
666 +
667 +
668 class BaseMultiModalItemTracker(ABC, Generic[_T]):
669 """
670 Tracks multi-modal items in a given request and ensures that the number
@@ -688,11 +733,14 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
733 def all_mm_uuids(self) -> MultiModalUUIDDict | None:
734 if not self._items_by_modality:
735 return None
691 - mm_uuids = {}
736 +
737 uuids_by_modality = dict(self._uuids_by_modality)
738 if "image" in uuids_by_modality and "image_embeds" in uuids_by_modality:
739 raise ValueError("Mixing raw image and embedding inputs is not allowed")
740 + if "audio" in uuids_by_modality and "audio_embeds" in uuids_by_modality:
741 + raise ValueError("Mixing raw audio and embedding inputs is not allowed")
742
743 + mm_uuids = {}
744 if "image_embeds" in uuids_by_modality:
745 mm_uuids["image"] = uuids_by_modality["image_embeds"]
746 if "image" in uuids_by_modality:
@@ -703,6 +751,7 @@ class BaseMultiModalItemTracker(ABC, Generic[_T]):
751 mm_uuids["audio"] = uuids_by_modality["audio"] # UUIDs of audios
752 if "video" in uuids_by_modality:
753 mm_uuids["video"] = uuids_by_modality["video"] # UUIDs of videos
754 +
755 return mm_uuids
756
757 @abstractmethod
@@ -714,29 +763,25 @@ class MultiModalItemTracker(BaseMultiModalItemTracker[object]):
763 def all_mm_data(self) -> MultiModalDataDict | None:
764 if not self._items_by_modality:
765 return None
717 - mm_inputs = {}
766 +
767 items_by_modality = dict(self._items_by_modality)
768 if "image" in items_by_modality and "image_embeds" in items_by_modality:
769 raise ValueError("Mixing raw image and embedding inputs is not allowed")
770 if "audio" in items_by_modality and "audio_embeds" in items_by_modality:
771 raise ValueError("Mixing raw audio and embedding inputs is not allowed")
772
773 + mm_inputs = {}
774 if "image_embeds" in items_by_modality:
725 - image_embeds_lst = items_by_modality["image_embeds"]
726 - mm_inputs["image"] = (
727 - image_embeds_lst if len(image_embeds_lst) != 1 else image_embeds_lst[0]
728 - )
775 + mm_inputs["image"] = _get_embeds_data(items_by_modality, "image")
776 if "image" in items_by_modality:
777 mm_inputs["image"] = items_by_modality["image"] # A list of images
778 if "audio_embeds" in items_by_modality:
732 - audio_embeds_lst = items_by_modality["audio_embeds"]
733 - mm_inputs["audio"] = (
734 - audio_embeds_lst if len(audio_embeds_lst) != 1 else audio_embeds_lst[0]
735 - )
779 + mm_inputs["audio"] = _get_embeds_data(items_by_modality, "audio")
780 if "audio" in items_by_modality:
781 mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
782 if "video" in items_by_modality:
783 mm_inputs["video"] = items_by_modality["video"] # A list of videos
784 +
785 return mm_inputs
786
787 def create_parser(self) -> "BaseMultiModalContentParser":
@@ -747,38 +792,32 @@ class AsyncMultiModalItemTracker(BaseMultiModalItemTracker[Awaitable[object]]):
792 async def all_mm_data(self) -> MultiModalDataDict | None:
793 if not self._items_by_modality:
794 return None
750 - mm_inputs = {}
751 - items_by_modality = {}
752 - for modality, items in self._items_by_modality.items():
753 - coros = []
754 - for item in items:
755 - if item is not None:
756 - coros.append(item)
757 - else:
758 - coros.append(asyncio.sleep(0))
759 - items_by_modality[modality] = await asyncio.gather(*coros)
795
796 + coros_by_modality = {
797 + modality: [item or asyncio.sleep(0) for item in items]
798 + for modality, items in self._items_by_modality.items()
799 + }
800 + items_by_modality: dict[str, list[object | None]] = {
801 + modality: await asyncio.gather(*coros)
802 + for modality, coros in coros_by_modality.items()
803 + }
804 if "image" in items_by_modality and "image_embeds" in items_by_modality:
805 raise ValueError("Mixing raw image and embedding inputs is not allowed")
806 if "audio" in items_by_modality and "audio_embeds" in items_by_modality:
807 raise ValueError("Mixing raw audio and embedding inputs is not allowed")
808
809 + mm_inputs = {}
810 if "image_embeds" in items_by_modality:
767 - image_embeds_lst = items_by_modality["image_embeds"]
768 - mm_inputs["image"] = (
769 - image_embeds_lst if len(image_embeds_lst) != 1 else image_embeds_lst[0]
770 - )
811 + mm_inputs["image"] = _get_embeds_data(items_by_modality, "image")
812 if "image" in items_by_modality:
813 mm_inputs["image"] = items_by_modality["image"] # A list of images
814 if "audio_embeds" in items_by_modality:
774 - audio_embeds_lst = items_by_modality["audio_embeds"]
775 - mm_inputs["audio"] = (
776 - audio_embeds_lst if len(audio_embeds_lst) != 1 else audio_embeds_lst[0]
777 - )
815 + mm_inputs["audio"] = _get_embeds_data(items_by_modality, "audio")
816 if "audio" in items_by_modality:
817 mm_inputs["audio"] = items_by_modality["audio"] # A list of audios
818 if "video" in items_by_modality:
819 mm_inputs["video"] = items_by_modality["video"] # A list of videos
820 +
821 return mm_inputs
822
823 def create_parser(self) -> "BaseMultiModalContentParser":
vllm/v1/engine/input_processor.py
+20 -10
@@ -188,29 +188,39 @@ class InputProcessor:
188 def _validate_single_prompt(single_prompt: dict | str) -> None:
189 if not isinstance(single_prompt, dict):
190 return
191 +
192 mm_data = single_prompt.get("multi_modal_data")
193 mm_uuids = single_prompt.get("multi_modal_uuids")
194 if not mm_data or not mm_uuids:
195 return
196
197 + import torch
198 +
199 + def _get_len(items: object):
200 + if isinstance(items, dict): # Embedding inputs
201 + return _get_len(next(iter(items.values()))) if items else 1
202 +
203 + if isinstance(items, list):
204 + return len(items)
205 + if isinstance(items, torch.Tensor):
206 + # To keep backwards compatibility for single item embedding input
207 + return 1 if getattr(items, "_is_single_item", False) else len(items)
208 +
209 + return 1
210 +
211 for modality, items in mm_data.items():
212 if modality in mm_uuids:
198 - data_len = len(items) if isinstance(items, list) else 1
199 - uuid_len = (
200 - len(mm_uuids[modality])
201 - if isinstance(mm_uuids[modality], list)
202 - else 1
203 - )
213 + data_len = _get_len(items)
214 + uuid_len = _get_len(mm_uuids[modality])
215 if uuid_len != data_len:
216 raise ValueError(
206 - f"multi_modal_uuids for modality '{modality}' "
217 + f"multi_modal_uuids for modality {modality!r} "
218 "must have same length as data: got "
208 - f"{uuid_len} uuids vs "
209 - f"{data_len} items."
219 + f"{uuid_len} uuids vs {data_len} items."
220 )
221 else:
222 raise ValueError(
213 - f"multi_modal_uuids for modality '{modality}' must "
223 + f"multi_modal_uuids for modality {modality!r} must "
224 "be provided if multi_modal_data is provided."
225 )
226