| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import os |
| 5 | |
| 6 | os.environ["TOKENIZERS_PARALLELISM"] = "true" |
| 7 | |
| 8 | from typing import Any, NamedTuple |
| 9 | |
| 10 | import pytest |
| 11 | from pytest import MarkDecorator |
| 12 | from transformers import AutoModelForImageTextToText |
| 13 | |
| 14 | from vllm.assets.image import ImageAsset |
| 15 | from vllm.multimodal.image import rescale_image_size |
| 16 | from vllm.transformers_utils.repo_utils import hf_api |
| 17 | from vllm.utils.torch_utils import set_default_torch_num_threads |
| 18 | |
| 19 | from ...conftest import IMAGE_ASSETS, HfRunner, VllmRunner |
| 20 | from ...models.utils import check_logprobs_close |
| 21 | |
| 22 | |
| 23 | class GGUFMMTestConfig(NamedTuple): |
| 24 | original_model: str |
| 25 | gguf_repo: str |
| 26 | gguf_backbone: str |
| 27 | gguf_mmproj: str |
| 28 | prompt: list[str] |
| 29 | image_names: list[str] # Store names, load PIL images at runtime |
| 30 | max_model_len: int = 4096 |
| 31 | marks: list[MarkDecorator] = [] |
| 32 | mm_processor_kwargs: dict[str, Any] = {} |
| 33 | |
| 34 | @property |
| 35 | def gguf_model(self): |
| 36 | hf_api().hf_hub_download(self.gguf_repo, filename=self.gguf_mmproj) |
| 37 | return hf_api().hf_hub_download(self.gguf_repo, filename=self.gguf_backbone) |
| 38 | |
| 39 | |
| 40 | # Common prompts aligned with test_common.py "gemma3" entry format |
| 41 | _GEMMA3_PROMPTS = IMAGE_ASSETS.prompts( |
| 42 | { |
| 43 | "stop_sign": ( |
| 44 | "<bos><start_of_turn>user\n" |
| 45 | "<start_of_image>What's the content in the center of the image?" |
| 46 | "<end_of_turn>\n<start_of_turn>model\n" |
| 47 | ), |
| 48 | "cherry_blossom": ( |
| 49 | "<bos><start_of_turn>user\n" |
| 50 | "<start_of_image>What is the season?" |
| 51 | "<end_of_turn>\n<start_of_turn>model\n" |
| 52 | ), |
| 53 | } |
| 54 | ) |
| 55 | |
| 56 | # Image asset names - load at runtime to avoid pickle issues with subprocess |
| 57 | _GEMMA3_IMAGE_NAMES = ["stop_sign", "cherry_blossom"] |
| 58 | |
| 59 | # Regular multimodal (no pan-and-scan) - uses QAT Q4_0 GGUF |
| 60 | GEMMA3_CONFIG = GGUFMMTestConfig( |
| 61 | original_model="google/gemma-3-4b-it", |
| 62 | gguf_repo="google/gemma-3-4b-it-qat-q4_0-gguf", |
| 63 | gguf_backbone="gemma-3-4b-it-q4_0.gguf", |
| 64 | gguf_mmproj="mmproj-model-f16-4B.gguf", |
| 65 | prompt=_GEMMA3_PROMPTS, |
| 66 | image_names=_GEMMA3_IMAGE_NAMES, |
| 67 | max_model_len=4096, |
| 68 | mm_processor_kwargs={}, |
| 69 | ) |
| 70 | |
| 71 | # Pan-and-scan multimodal - uses unquantized BF16 GGUF |
| 72 | GEMMA3_CONFIG_PAN_AND_SCAN = GGUFMMTestConfig( |
| 73 | original_model="google/gemma-3-4b-it", |
| 74 | gguf_repo="google/gemma-3-4b-it-qat-q4_0-gguf", |
| 75 | gguf_backbone="gemma-3-4b-it-q4_0.gguf", |
| 76 | gguf_mmproj="mmproj-model-f16-4B.gguf", |
| 77 | prompt=_GEMMA3_PROMPTS, |
| 78 | image_names=_GEMMA3_IMAGE_NAMES, |
| 79 | max_model_len=4096, |
| 80 | mm_processor_kwargs={"do_pan_and_scan": True}, |
| 81 | ) |
| 82 | |
| 83 | MODELS_TO_TEST = [GEMMA3_CONFIG, GEMMA3_CONFIG_PAN_AND_SCAN] |
| 84 | |
| 85 | |
| 86 | def run_multimodal_gguf_test( |
| 87 | hf_runner: type[HfRunner], |
| 88 | vllm_runner: type[VllmRunner], |
| 89 | model: GGUFMMTestConfig, |
| 90 | dtype: str, |
| 91 | max_tokens: int, |
| 92 | num_logprobs: int, |
| 93 | ): |
| 94 | # Load images at runtime (inside subprocess) to avoid pickle issues |
| 95 | images = [ImageAsset(name).pil_image for name in model.image_names] |
| 96 | size_factors = [0.25, 0.5, 1.0] |
| 97 | inputs_per_image = [ |
| 98 | ( |
| 99 | [prompt for _ in size_factors], |
| 100 | [rescale_image_size(image, factor) for factor in size_factors], |
| 101 | ) |
| 102 | for image, prompt in zip(images, model.prompt) |
| 103 | ] |
| 104 | |
| 105 | # NOTE: Run vLLM first to avoid CUDA init issues with multiprocessing fork. |
| 106 | # Run GGUF model via vLLM. |
| 107 | with ( |
| 108 | set_default_torch_num_threads(1), |
| 109 | vllm_runner( |
| 110 | model_name=model.gguf_model, |
| 111 | enforce_eager=True, |
| 112 | tokenizer_name=model.original_model, |
| 113 | dtype=dtype, |
| 114 | max_model_len=model.max_model_len, |
| 115 | mm_processor_kwargs=model.mm_processor_kwargs, |
| 116 | ) as gguf_model, |
| 117 | ): |
| 118 | gguf_outputs_per_case = [ |
| 119 | gguf_model.generate_greedy_logprobs( |
| 120 | prompts, |
| 121 | max_tokens, |
| 122 | num_logprobs=num_logprobs, |
| 123 | images=images, |
| 124 | ) |
| 125 | for prompts, images in inputs_per_image |
| 126 | ] |
| 127 | |
| 128 | # Then run HfRunner for HuggingFace baseline comparison. |
| 129 | with hf_runner( |
| 130 | model.original_model, |
| 131 | dtype=dtype, |
| 132 | auto_cls=AutoModelForImageTextToText, |
| 133 | ) as hf_model: |
| 134 | hf_outputs_per_case = [ |
| 135 | hf_model.generate_greedy_logprobs_limit( |
| 136 | prompts, |
| 137 | max_tokens, |
| 138 | num_logprobs=num_logprobs, |
| 139 | images=images, |
| 140 | ) |
| 141 | for prompts, images in inputs_per_image |
| 142 | ] |
| 143 | |
| 144 | for hf_outputs, gguf_outputs in zip(hf_outputs_per_case, gguf_outputs_per_case): |
| 145 | check_logprobs_close( |
| 146 | outputs_0_lst=hf_outputs, |
| 147 | outputs_1_lst=gguf_outputs, |
| 148 | name_0="hf", |
| 149 | name_1="gguf", |
| 150 | ) |
| 151 | |
| 152 | |
| 153 | @pytest.mark.parametrize("model", MODELS_TO_TEST) |
| 154 | @pytest.mark.parametrize("dtype", ["bfloat16"]) |
| 155 | @pytest.mark.parametrize("max_tokens", [32]) |
| 156 | @pytest.mark.parametrize("num_logprobs", [10]) |
| 157 | def test_gemma3_mm_gguf( |
| 158 | hf_runner: type[HfRunner], |
| 159 | vllm_runner: type[VllmRunner], |
| 160 | model: GGUFMMTestConfig, |
| 161 | dtype: str, |
| 162 | max_tokens: int, |
| 163 | num_logprobs: int, |
| 164 | ) -> None: |
| 165 | run_multimodal_gguf_test( |
| 166 | hf_runner, vllm_runner, model, dtype, max_tokens, num_logprobs |
| 167 | ) |