@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 53fa09d085
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 2, 04:45 UTC

[Misc] Support local image encoding in benchmarks (#43843)

Signed-off-by: xiaoz <Sukra1@outlook.com>

XiaoZ committed Jun 2, 2026 at 23:15 UTC 53fa09d0857fb07da061137ea03c932da5e05f5d
3 files changed +238 -15
docs/benchmarking/cli.md
+8 -2
@@ -246,6 +246,12 @@ Every image listed in "image_files" is added to the request in the listed order
246
247 The "image" shorthand accepts the same values as "image_files". The "image_url" field accepts either an OpenAI-style object with a "url" field or a URL string.
248
249 +By default, image references are sent to the serving endpoint as provided, with local image paths converted to `file://` URLs.
250 +
251 +If the benchmark client should load local and HTTP(S) images before sending requests, pass `--custom-ensure-client-side-data` to encode them as base64 data URLs on the client side.
252 +
253 +Existing `data:image/...` URLs are already self-contained and are kept unchanged.
254 +
255 ```bash
256 # need a model with vision capability here
257 vllm serve Qwen/Qwen2-VL-7B-Instruct
@@ -253,13 +259,13 @@ vllm serve Qwen/Qwen2-VL-7B-Instruct
259
260 ```bash
261 # run benchmarking script
256 -vllm bench serve--save-result --save-detailed \
262 +vllm bench serve --save-result --save-detailed \
263 --backend openai-chat \
264 --model Qwen/Qwen2-VL-7B-Instruct \
265 --endpoint /v1/chat/completions \
266 --dataset-name custom_image \
267 --dataset-path <path-to-your-image-data-jsonl> \
262 - --allowed-local-media-path /path/to/image/folder
268 + --custom-ensure-client-side-data
269 ```
270
271 Note that we need to use the `openai-chat` backend and `/v1/chat/completions` endpoint for multimodal inputs.
tests/benchmarks/test_custom_image_dataset.py
+140
@@ -2,11 +2,15 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 import json
4 from argparse import Namespace
5 +from io import BytesIO
6 from pathlib import Path
7 from typing import Any
8
9 +import pybase64 as base64
10 import pytest
11 +from PIL import Image
12
13 +import vllm.benchmarks.datasets.datasets as datasets_module
14 from vllm.benchmarks.datasets import CustomImageDataset, get_samples
15 from vllm.benchmarks.lib.endpoint_request_func import (
16 RequestFuncInput,
@@ -33,6 +37,22 @@ def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
37 f.write(json.dumps(row) + "\n")
38
39
40 +def _write_png(path: Path, color: tuple[int, int, int] = (255, 0, 0)) -> None:
41 + Image.new("RGB", (1, 1), color=color).save(path)
42 +
43 +
44 +def _decode_data_url(data_url: str) -> tuple[str, bytes]:
45 + prefix, image_base64 = data_url.split(",", 1)
46 + return prefix, base64.b64decode(image_base64)
47 +
48 +
49 +def _assert_png_data_url(data_url: str) -> None:
50 + prefix, image_bytes = _decode_data_url(data_url)
51 + assert prefix == "data:image/png;base64"
52 + with Image.open(BytesIO(image_bytes)) as image:
53 + image.verify()
54 +
55 +
56 def _args_for_custom_image(dataset_path: Path) -> Namespace:
57 return Namespace(
58 dataset_name="custom_image",
@@ -42,6 +62,7 @@ def _args_for_custom_image(dataset_path: Path) -> Namespace:
62 num_prompts=2,
63 custom_output_len=32,
64 enable_multimodal_chat=False,
65 + custom_ensure_client_side_data=False,
66 request_id_prefix="req-",
67 no_oversample=False,
68 )
@@ -230,6 +251,125 @@ def test_custom_image_dataset_wraps_interleaved_content_for_multimodal_chat(
251 assert _get_chat_messages(request_input) == sample.prompt
252
253
254 +@pytest.mark.benchmark
255 +def test_custom_image_dataset_encodes_image_media_when_requested(
256 + tmp_path: Path,
257 + monkeypatch: pytest.MonkeyPatch,
258 +) -> None:
259 + image_a = tmp_path / "chart_a.png"
260 + image_b = tmp_path / "chart b.png"
261 + _write_png(image_a, color=(255, 0, 0))
262 + _write_png(image_b, color=(0, 255, 0))
263 + data_url = "data:image/png;base64,Zm9v"
264 + remote_url = "https://example.com/chart.png"
265 + original_fetch_image = datasets_module.fetch_image
266 +
267 + def fake_fetch_image(image_url: str) -> Image.Image:
268 + if image_url == remote_url:
269 + return Image.new("RGB", (1, 1), color=(0, 0, 255))
270 + return original_fetch_image(image_url)
271 +
272 + monkeypatch.setattr(datasets_module, "fetch_image", fake_fetch_image)
273 +
274 + jsonl = tmp_path / "images.jsonl"
275 + _write_jsonl(
276 + jsonl,
277 + [
278 + {
279 + "prompt": "Compare the charts.",
280 + "image_files": [
281 + str(image_a),
282 + image_b.as_uri(),
283 + remote_url,
284 + data_url,
285 + ],
286 + }
287 + ],
288 + )
289 +
290 + dataset = CustomImageDataset(dataset_path=str(jsonl), disable_shuffle=True)
291 + samples = dataset.sample(
292 + tokenizer=_Tokenizer(),
293 + num_requests=1,
294 + output_len=32,
295 + ensure_client_side_data=True,
296 + )
297 +
298 + assert len(samples) == 1
299 + assert isinstance(samples[0].multi_modal_data, list)
300 + image_urls = [part["image_url"]["url"] for part in samples[0].multi_modal_data]
301 +
302 + _assert_png_data_url(image_urls[0])
303 + _assert_png_data_url(image_urls[1])
304 + _assert_png_data_url(image_urls[2])
305 + assert image_urls[3] == data_url
306 +
307 +
308 +@pytest.mark.benchmark
309 +def test_custom_image_dataset_encodes_interleaved_image_media(
310 + tmp_path: Path,
311 +) -> None:
312 + image_a = tmp_path / "chart_a.png"
313 + image_b = tmp_path / "chart_b.png"
314 + _write_png(image_a, color=(255, 0, 0))
315 + _write_png(image_b, color=(0, 255, 0))
316 + jsonl = tmp_path / "images.jsonl"
317 + _write_jsonl(
318 + jsonl,
319 + [
320 + {
321 + "content": [
322 + {"type": "text", "text": "Compare "},
323 + {"type": "image", "image": str(image_a)},
324 + {
325 + "type": "image_url",
326 + "image_url": {
327 + "url": image_b.as_uri(),
328 + "detail": "low",
329 + },
330 + },
331 + ],
332 + }
333 + ],
334 + )
335 +
336 + dataset = CustomImageDataset(dataset_path=str(jsonl), disable_shuffle=True)
337 + samples = dataset.sample(
338 + tokenizer=_Tokenizer(),
339 + num_requests=1,
340 + output_len=32,
341 + ensure_client_side_data=True,
342 + )
343 +
344 + sample = samples[0]
345 + assert isinstance(sample.prompt, list)
346 + _assert_png_data_url(sample.prompt[1]["image_url"]["url"])
347 + _assert_png_data_url(sample.prompt[2]["image_url"]["url"])
348 + assert sample.prompt[2]["image_url"]["detail"] == "low"
349 +
350 +
351 +@pytest.mark.benchmark
352 +def test_custom_image_dataset_rejects_invalid_image_media(
353 + tmp_path: Path,
354 +) -> None:
355 + invalid_image = tmp_path / "not_an_image.png"
356 + invalid_image.write_text("not an image")
357 + jsonl = tmp_path / "images.jsonl"
358 + _write_jsonl(
359 + jsonl,
360 + [{"prompt": "Describe the image.", "image_files": [str(invalid_image)]}],
361 + )
362 +
363 + dataset = CustomImageDataset(dataset_path=str(jsonl), disable_shuffle=True)
364 + with pytest.raises(ValueError, match="Invalid image URL"):
365 + dataset.sample(
366 + tokenizer=_Tokenizer(),
367 + num_requests=1,
368 + output_len=32,
369 + ensure_client_side_data=True,
370 + )
371 +
372 +
373 @pytest.mark.benchmark
374 def test_custom_image_dataset_rejects_invalid_content_part(
375 tmp_path: Path,
vllm/benchmarks/datasets/datasets.py
+90 -13
@@ -44,6 +44,7 @@ from vllm.lora.request import LoRARequest
44 from vllm.lora.utils import get_adapter_absolute_path
45 from vllm.multimodal.audio import get_audio_duration
46 from vllm.multimodal.image import convert_image_mode
47 +from vllm.multimodal.utils import encode_image_url, fetch_image
48 from vllm.tokenizers import TokenizerLike
49 from vllm.transformers_utils.repo_utils import hf_api
50 from vllm.utils.argparse_utils import FlexibleArgumentParser
@@ -363,7 +364,11 @@ def lora_path_on_disk(lora_path: str) -> str:
364 lora_tokenizer_cache: dict[int, TokenizerLike] = {}
365
366
366 -def process_image(image: Any) -> Mapping[str, Any]:
367 +def process_image(
368 + image: Any,
369 + *,
370 + ensure_client_side_data: bool = False,
371 +) -> Mapping[str, Any]:
372 """
373 Process a single image input and return a multimedia content dictionary.
374
@@ -380,6 +385,9 @@ def process_image(image: Any) -> Mapping[str, Any]:
385 encoded data. - If string starts with "data:image/", treats as base64.
386 - If string starts with "http://", "https://", or "file://", treats as URL.
387 - Otherwise treats as local file path and prepends "file://".
388 + - If ensure_client_side_data is True, local and HTTP(S) image references
389 + are loaded and encoded as base64 image data URLs. Existing data:image
390 + URLs are kept unchanged.
391 - Returns a dictionary with the image URL or base64 data.
392
393 Raises:
@@ -403,6 +411,13 @@ def process_image(image: Any) -> Mapping[str, Any]:
411 if image.startswith(("http://", "https://", "file://", "data:image/"))
412 else f"file://{image}"
413 )
414 +
415 + if ensure_client_side_data and not image_url.startswith("data:image/"):
416 + try:
417 + fetched_image = fetch_image(image_url)
418 + image_url = encode_image_url(fetched_image)
419 + except Exception as e:
420 + raise ValueError(f"Invalid image URL: {image_url}") from e
421 return {"type": "image_url", "image_url": {"url": image_url}}
422
423 raise ValueError(
@@ -1645,6 +1660,16 @@ def add_dataset_parser(parser: FlexibleArgumentParser):
1660 "value overrides potential output length loaded from the dataset. It is "
1661 "used only for custom dataset.",
1662 )
1663 + custom_group.add_argument(
1664 + "--custom-ensure-client-side-data",
1665 + action="store_true",
1666 + help=(
1667 + "Ensure custom dataset media is sent as client-side data instead "
1668 + "of references. For custom_image datasets, this loads local and "
1669 + "HTTP(S) images on the benchmark client and encodes them as "
1670 + "base64 data URLs. Existing data:image URLs are kept unchanged."
1671 + ),
1672 + )
1673
1674 spec_bench_group = parser.add_argument_group("spec bench dataset options")
1675 spec_bench_group.add_argument(
@@ -2075,6 +2100,9 @@ def get_samples(args, tokenizer: TokenizerLike) -> list[SampleRequest]:
2100 tokenizer=tokenizer,
2101 output_len=args.custom_output_len,
2102 enable_multimodal_chat=args.enable_multimodal_chat,
2103 + ensure_client_side_data=getattr(
2104 + args, "custom_ensure_client_side_data", False
2105 + ),
2106 request_id_prefix=args.request_id_prefix,
2107 no_oversample=args.no_oversample,
2108 )
@@ -2627,7 +2655,12 @@ class CustomImageDataset(CustomDataset):
2655 return parts
2656
2657 @classmethod
2630 - def _process_content_part(cls, part: dict[str, Any]) -> dict[str, Any]:
2658 + def _process_content_part(
2659 + cls,
2660 + part: dict[str, Any],
2661 + *,
2662 + ensure_client_side_data: bool = False,
2663 + ) -> dict[str, Any]:
2664 content_type = part.get("type")
2665 if content_type == "text":
2666 text = part.get("text")
@@ -2638,12 +2671,22 @@ class CustomImageDataset(CustomDataset):
2671 if content_type == "image":
2672 if "image" not in part:
2673 raise ValueError("Image content parts must contain an 'image' field.")
2641 - return dict(process_image(part["image"]))
2674 + return dict(
2675 + process_image(
2676 + part["image"],
2677 + ensure_client_side_data=ensure_client_side_data,
2678 + )
2679 + )
2680
2681 if content_type == "image_url":
2682 image_url = part.get("image_url")
2683 if isinstance(image_url, str):
2646 - return dict(process_image(image_url))
2684 + return dict(
2685 + process_image(
2686 + image_url,
2687 + ensure_client_side_data=ensure_client_side_data,
2688 + )
2689 + )
2690
2691 if isinstance(image_url, dict):
2692 url = image_url.get("url")
@@ -2652,7 +2695,12 @@ class CustomImageDataset(CustomDataset):
2695 "Image URL content parts must contain a string 'image_url.url'."
2696 )
2697
2655 - processed_part = dict(process_image(url))
2698 + processed_part = dict(
2699 + process_image(
2700 + url,
2701 + ensure_client_side_data=ensure_client_side_data,
2702 + )
2703 + )
2704 processed_image_url = dict(processed_part["image_url"])
2705 processed_image_url.update(
2706 {key: value for key, value in image_url.items() if key != "url"}
@@ -2671,9 +2719,17 @@ class CustomImageDataset(CustomDataset):
2719 )
2720
2721 @classmethod
2674 - def _process_interleaved_content(cls, content: Any) -> list[dict[str, Any]]:
2722 + def _process_interleaved_content(
2723 + cls,
2724 + content: Any,
2725 + *,
2726 + ensure_client_side_data: bool = False,
2727 + ) -> list[dict[str, Any]]:
2728 return [
2676 - cls._process_content_part(part)
2729 + cls._process_content_part(
2730 + part,
2731 + ensure_client_side_data=ensure_client_side_data,
2732 + )
2733 for part in cls._validate_content_parts(content)
2734 ]
2735
@@ -2682,11 +2738,23 @@ class CustomImageDataset(CustomDataset):
2738 return "".join(part["text"] for part in content if part.get("type") == "text")
2739
2740 @staticmethod
2685 - def _process_image_files(images: Any) -> dict[str, Any] | list[dict[str, Any]]:
2741 + def _process_image_files(
2742 + images: Any,
2743 + *,
2744 + ensure_client_side_data: bool = False,
2745 + ) -> dict[str, Any] | list[dict[str, Any]]:
2746 if not isinstance(images, list) or not images:
2747 raise ValueError("'image_files' must be a non-empty list.")
2748
2689 - mm_content = [dict(process_image(image)) for image in images]
2749 + mm_content = [
2750 + dict(
2751 + process_image(
2752 + image,
2753 + ensure_client_side_data=ensure_client_side_data,
2754 + )
2755 + )
2756 + for image in images
2757 + ]
2758 if len(mm_content) == 1:
2759 return mm_content[0]
2760
@@ -2698,6 +2766,7 @@ class CustomImageDataset(CustomDataset):
2766 num_requests: int,
2767 output_len: int | None = None,
2768 enable_multimodal_chat: bool = False,
2769 + ensure_client_side_data: bool = False,
2770 request_id_prefix: str = "",
2771 no_oversample: bool = False,
2772 **kwargs,
@@ -2718,9 +2787,14 @@ class CustomImageDataset(CustomDataset):
2787 break
2788
2789 if "content" in item:
2721 - content = self._process_interleaved_content(item["content"])
2790 + content = self._process_interleaved_content(
2791 + item["content"],
2792 + ensure_client_side_data=ensure_client_side_data,
2793 + )
2794 text_prompt = self._get_text_from_content(content)
2723 - prompt_len = len(tokenizer(text_prompt).input_ids)
2795 + prompt_len = (
2796 + 1 if tokenizer is None else len(tokenizer(text_prompt).input_ids)
2797 + )
2798 prompt = (
2799 [{"role": "user", "content": content}]
2800 if enable_multimodal_chat
@@ -2741,8 +2815,11 @@ class CustomImageDataset(CustomDataset):
2815 if not isinstance(prompt, str):
2816 raise ValueError("'prompt' must be a string.")
2817
2744 - prompt_len = len(tokenizer(prompt).input_ids)
2745 - mm_content = self._process_image_files(item["image_files"])
2818 + prompt_len = 1 if tokenizer is None else len(tokenizer(prompt).input_ids)
2819 + mm_content = self._process_image_files(
2820 + item["image_files"],
2821 + ensure_client_side_data=ensure_client_side_data,
2822 + )
2823 if enable_multimodal_chat:
2824 # Note: when chat is enabled the request prompt_len is no longer
2825 # accurate and we will be using request output to count the