main
py 394 lines 13.3 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Tests for the LlamaNemotronVL model family:
4 - nvidia/llama-nemotron-embed-vl-1b-v2 (LlamaNemotronVLForCausalLM / embed)
5 - nvidia/llama-nemotron-rerank-vl-1b-v2
6 (LlamaNemotronVLForSequenceClassification / rerank)
7
8 Both variants share a SigLIP vision encoder with a bidirectional LLaMA backbone.
9 """
10
11 from collections.abc import Sequence
12 from io import BytesIO
13 from pathlib import Path
14
15 import pybase64 as base64
16 import pytest
17 import torch
18 from PIL import Image
19 from transformers import AutoModel, AutoModelForSequenceClassification, AutoProcessor
20
21 from vllm.entrypoints.chat_utils import (
22 ChatCompletionContentPartImageParam,
23 ChatCompletionContentPartTextParam,
24 )
25 from vllm.entrypoints.pooling.scoring.typing import ScoreMultiModalParam
26 from vllm.model_executor.layers.fusion.mm_input_norm import FusedMMInputNorm
27 from vllm.platforms import current_platform
28 from vllm.transformers_utils.processors.nemotron_vl import (
29 SIGLIP_MEAN,
30 SIGLIP_STD,
31 LlamaNemotronVLEmbedImageProcessor,
32 )
33
34 from ....conftest import IMAGE_ASSETS, HfRunner, PromptImageInput, VllmRunner
35 from ....utils import ROCM_ENGINE_KWARGS
36 from ...utils import check_embeddings_close
37
38 # Prefixes used by the model API
39 QUERY_PREFIX = "query: "
40 PASSAGE_PREFIX = "passage: "
41
42 # Text prompts for text-only embedding
43 HF_TEXT_PROMPTS = [
44 # T -> X (text embedding queries)
45 f"{QUERY_PREFIX}The label of the object is stop sign",
46 f"{QUERY_PREFIX}cherry blossom",
47 ]
48
49 # Image prompts using the model's expected format
50 HF_IMAGE_PROMPTS = IMAGE_ASSETS.prompts(
51 {
52 # I -> X (image embedding as passage/document)
53 "stop_sign": f"{PASSAGE_PREFIX}<image>",
54 "cherry_blossom": f"{PASSAGE_PREFIX}<image>",
55 }
56 )
57
58 MODELS = ["nvidia/llama-nemotron-embed-vl-1b-v2"]
59
60
61 @pytest.mark.usefixtures("default_vllm_config")
62 def test_device_normalization_matches_cpu_transform() -> None:
63 device = current_platform.device_type
64 image = Image.new("RGB", (48, 32), color=(12, 128, 240))
65 processor_kwargs = dict(
66 image_size=32,
67 min_dynamic_patch=1,
68 max_dynamic_patch=1,
69 dynamic_image_size=True,
70 use_thumbnail=False,
71 )
72
73 cpu_processor = LlamaNemotronVLEmbedImageProcessor(**processor_kwargs)
74 device_processor = LlamaNemotronVLEmbedImageProcessor(
75 **processor_kwargs, do_rescale=False, do_normalize=False
76 )
77
78 expected = cpu_processor._images_to_pixel_values_lst([image])[0]
79 raw_pixels = device_processor._images_to_pixel_values_lst([image])[0]
80 input_norm = FusedMMInputNorm(
81 image_mean=list(SIGLIP_MEAN),
82 image_std=list(SIGLIP_STD),
83 rescale_factor=1 / 255,
84 ).to(device)
85 actual = input_norm(
86 raw_pixels.flatten(start_dim=1).to(device), expected.dtype
87 ).view_as(expected)
88
89 assert raw_pixels.dtype == torch.uint8
90 torch.testing.assert_close(actual, expected.to(device))
91
92
93 @pytest.mark.parametrize(("do_rescale", "do_normalize"), [(True, False), (False, True)])
94 def test_device_normalization_rejects_partial_cpu_processing(
95 do_rescale: bool, do_normalize: bool
96 ) -> None:
97 with pytest.raises(ValueError, match="both enabled or both disabled"):
98 LlamaNemotronVLEmbedImageProcessor(
99 image_size=32,
100 min_dynamic_patch=1,
101 max_dynamic_patch=1,
102 dynamic_image_size=True,
103 use_thumbnail=False,
104 do_rescale=do_rescale,
105 do_normalize=do_normalize,
106 )
107
108
109 def _run_test(
110 hf_runner: type[HfRunner],
111 vllm_runner: type[VllmRunner],
112 input_cases: list[tuple[list[str], PromptImageInput]],
113 model: str,
114 *,
115 dtype: str,
116 ) -> None:
117 """Compare HF and vLLM embeddings for all input cases.
118
119 NOTE: Run vLLM first to avoid CUDA initialization issues with multiprocessing.
120 """
121 with vllm_runner(
122 model,
123 runner="pooling",
124 dtype=dtype,
125 max_model_len=2048,
126 enforce_eager=True,
127 trust_remote_code=True,
128 **ROCM_ENGINE_KWARGS,
129 ) as vllm_model:
130 vllm_outputs_per_case = [
131 vllm_model.embed(input_texts, images=input_images)
132 for input_texts, input_images in input_cases
133 ]
134
135 with hf_runner(model, dtype=dtype, auto_cls=AutoModel) as hf_model:
136 hf_outputs_per_case = []
137 for input_texts, input_images in input_cases:
138 hf_outputs = []
139 for text, image in zip(input_texts, input_images):
140 with torch.inference_mode():
141 if text.startswith(QUERY_PREFIX):
142 query_text = text[len(QUERY_PREFIX) :]
143 embedding = hf_model.model.encode_queries([query_text])
144 elif text.startswith(PASSAGE_PREFIX):
145 passage_text = text[len(PASSAGE_PREFIX) :]
146 if image is not None:
147 embedding = hf_model.model.encode_documents(
148 images=[image],
149 texts=[passage_text],
150 )
151 else:
152 embedding = hf_model.model.encode_documents(
153 texts=[passage_text]
154 )
155 else:
156 raise ValueError(
157 f"Text must start with {QUERY_PREFIX!r} "
158 f"or {PASSAGE_PREFIX!r}"
159 )
160
161 hf_outputs.append(embedding[0].tolist())
162 hf_outputs_per_case.append(hf_outputs)
163
164 for hf_outputs, vllm_outputs in zip(hf_outputs_per_case, vllm_outputs_per_case):
165 check_embeddings_close(
166 embeddings_0_lst=hf_outputs,
167 embeddings_1_lst=vllm_outputs,
168 name_0="hf",
169 name_1="vllm",
170 )
171
172
173 @pytest.mark.parametrize("model", MODELS)
174 @pytest.mark.parametrize("dtype", ["half"])
175 def test_models(
176 hf_runner,
177 vllm_runner,
178 image_assets,
179 model: str,
180 dtype: str,
181 ) -> None:
182 """Test text and image embedding."""
183 text_images = [None] * len(HF_TEXT_PROMPTS)
184 images = [asset.pil_image for asset in image_assets]
185 input_cases = [
186 (HF_TEXT_PROMPTS, text_images),
187 (HF_IMAGE_PROMPTS, images),
188 ]
189
190 _run_test(
191 hf_runner,
192 vllm_runner,
193 input_cases,
194 model,
195 dtype=dtype,
196 )
197
198
199 # ---------------------------------------------------------------------------
200 # Reranker tests — nvidia/llama-nemotron-rerank-vl-1b-v2
201 # ---------------------------------------------------------------------------
202
203 RERANKER_MODELS = ["nvidia/llama-nemotron-rerank-vl-1b-v2"]
204
205 # The tokenizer's built-in chat template is not suitable for the Score/Rerank
206 # APIs (it's inherited from the base LLM). We must use the provided override.
207 _RERANKER_SCORE_TEMPLATE = (
208 Path(__file__).parents[4]
209 / "examples/pooling/score/template/nemotron-vl-rerank.jinja"
210 ).read_text()
211
212 RERANKER_TEXT_QUERY = "How is AI improving the intelligence and capabilities of robots?"
213 RERANKER_TEXT_DOCS = [
214 "AI enables robots to perceive, plan, and act autonomously.",
215 (
216 "A biological foundation model designed to analyze DNA, RNA, "
217 "and protein sequences."
218 ),
219 ]
220
221 RERANKER_IMAGE_QUERY = "photo of a red stop sign on a street"
222
223 RerankerDocument = tuple[str | None, Image.Image | None]
224 RerankerCase = tuple[str, Sequence[RerankerDocument]]
225
226
227 def _pil_to_data_uri(image: Image.Image) -> str:
228 buf = BytesIO()
229 image.save(buf, format="PNG")
230 b64 = base64.b64encode(buf.getvalue()).decode()
231 return f"data:image/png;base64,{b64}"
232
233
234 def _run_hf_reranker(
235 hf_runner: type[HfRunner],
236 model: str,
237 dtype: str,
238 input_cases: Sequence[RerankerCase],
239 ) -> list[list[float]]:
240 """Run all HF reranker cases in one model lifecycle."""
241 with hf_runner(
242 model,
243 dtype=dtype,
244 trust_remote_code=True,
245 auto_cls=AutoModelForSequenceClassification,
246 ) as hf_model:
247 processor = AutoProcessor.from_pretrained(
248 model,
249 trust_remote_code=True,
250 max_input_tiles=6,
251 use_thumbnail=True,
252 rerank_max_length=2048,
253 )
254 scores_per_case = []
255 for query, docs in input_cases:
256 examples = [
257 {
258 "question": query,
259 "doc_text": doc_text if doc_text is not None else "",
260 "doc_image": doc_image if doc_image is not None else "",
261 }
262 for doc_text, doc_image in docs
263 ]
264 batch_dict = processor.process_queries_documents_crossencoder(examples)
265 batch_dict = {
266 k: v.to(hf_model.model.device) if isinstance(v, torch.Tensor) else v
267 for k, v in batch_dict.items()
268 }
269 with torch.inference_mode():
270 logits = hf_model.model(**batch_dict, return_dict=True).logits
271 # vLLM applies sigmoid activation to raw logits before returning scores.
272 scores = torch.sigmoid(logits.squeeze(-1).float())
273 scores_per_case.append(scores.detach().cpu().tolist())
274
275 return scores_per_case
276
277
278 def _run_vllm_reranker(
279 vllm_runner: type[VllmRunner],
280 model: str,
281 dtype: str,
282 input_cases: Sequence[RerankerCase],
283 ) -> list[list[float]]:
284 """Run all vLLM reranker cases in one model lifecycle."""
285 with vllm_runner(
286 model,
287 runner="pooling",
288 dtype=dtype,
289 max_model_len=2048,
290 enforce_eager=True,
291 trust_remote_code=True,
292 **ROCM_ENGINE_KWARGS,
293 ) as vllm_model:
294 scores_per_case = []
295 for query, docs in input_cases:
296 has_images = any(img is not None for _, img in docs)
297
298 if not has_images:
299 queries = [query] * len(docs)
300 doc_texts = [doc_text for doc_text, _ in docs]
301 outputs = vllm_model.score(
302 queries,
303 doc_texts,
304 chat_template=_RERANKER_SCORE_TEMPLATE,
305 )
306 else:
307 query_params = [
308 ScoreMultiModalParam(
309 content=[
310 ChatCompletionContentPartTextParam(
311 type="text",
312 text=query,
313 )
314 ]
315 )
316 ] * len(docs)
317
318 doc_params = []
319 for doc_text, doc_image in docs:
320 content: list = []
321 if doc_image is not None:
322 content.append(
323 ChatCompletionContentPartImageParam(
324 type="image_url",
325 image_url={"url": _pil_to_data_uri(doc_image)},
326 )
327 )
328 if doc_text:
329 content.append(
330 ChatCompletionContentPartTextParam(
331 type="text",
332 text=doc_text,
333 )
334 )
335 doc_params.append(ScoreMultiModalParam(content=content))
336
337 raw_outputs = vllm_model.llm.score(
338 query_params,
339 doc_params,
340 chat_template=_RERANKER_SCORE_TEMPLATE,
341 )
342 outputs = [output.outputs.score for output in raw_outputs]
343
344 scores_per_case.append(outputs)
345
346 return scores_per_case
347
348
349 def _run_reranker_test(
350 hf_runner: type[HfRunner],
351 vllm_runner: type[VllmRunner],
352 model: str,
353 dtype: str,
354 input_cases: Sequence[RerankerCase],
355 ) -> None:
356 """Compare HF and vLLM reranker scores for all input cases.
357
358 NOTE: Run vLLM first to avoid CUDA initialization issues with multiprocessing.
359 """
360 vllm_scores_per_case = _run_vllm_reranker(vllm_runner, model, dtype, input_cases)
361 hf_scores_per_case = _run_hf_reranker(hf_runner, model, dtype, input_cases)
362
363 # ROCm has slightly higher variance because vLLM and HF use different
364 # attention backends.
365 rel_tol = 0.022 if current_platform.is_rocm() else 0.02
366 for hf_scores, vllm_scores in zip(hf_scores_per_case, vllm_scores_per_case):
367 assert len(hf_scores) == len(vllm_scores), (
368 f"Output length mismatch: HF={len(hf_scores)}, vLLM={len(vllm_scores)}"
369 )
370 for i, (hf_score, vllm_score) in enumerate(zip(hf_scores, vllm_scores)):
371 assert hf_score == pytest.approx(vllm_score, rel=rel_tol), (
372 f"Score mismatch at index {i}: HF={hf_score:.4f}, vLLM={vllm_score:.4f}"
373 )
374
375
376 @pytest.mark.parametrize("model", RERANKER_MODELS)
377 @pytest.mark.parametrize("dtype", ["half"])
378 def test_reranker(
379 hf_runner,
380 vllm_runner,
381 image_assets,
382 model: str,
383 dtype: str,
384 ) -> None:
385 """Test reranking with text and image documents."""
386 text_docs: list[RerankerDocument] = [(text, None) for text in RERANKER_TEXT_DOCS]
387 image_docs: list[RerankerDocument] = [
388 (None, asset.pil_image) for asset in image_assets
389 ]
390 input_cases: list[RerankerCase] = [
391 (RERANKER_TEXT_QUERY, text_docs),
392 (RERANKER_IMAGE_QUERY, image_docs),
393 ]
394 _run_reranker_test(hf_runner, vllm_runner, model, dtype, input_cases)