Files
wehub-resource-sync eec33d25b2
pre-commit / pre-commit (push) Failing after 1s
Build Wheel / build (3.11) (push) Failing after 1s
Build Wheel / build (3.12) (push) Failing after 0s
chore: import upstream snapshot with attribution
2026-07-13 12:29:08 +08:00

360 lines
12 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""
Test multimodal processing correctness for Omni models.
Tests that:
1. Cached vs non-cached processor outputs are identical.
2. Text prompt vs token prompt inputs produce identical results after processing.
Adapted from vllm/tests/models/multimodal/processing/test_common.py
"""
from functools import partial
import numpy as np
import pytest
from PIL import Image
from vllm.config.multimodal import (
AudioDummyOptions,
BaseDummyOptions,
ImageDummyOptions,
VideoDummyOptions,
)
from vllm.inputs import MultiModalDataDict, MultiModalInput
from vllm.multimodal.cache import MultiModalProcessorOnlyCache
from vllm.multimodal.inputs import batched_tensors_equal
from vllm.multimodal.processing import BaseMultiModalProcessor, InputProcessingContext
from vllm.tokenizers import TokenizerLike, cached_tokenizer_from_config
from tests.model_executor.helpers import bootstrap_vllm_layer_custom_op_modules
from tests.model_executor.models.registry import (
_MULTIMODAL_OMNI_EXAMPLE_MODELS,
_OmniExamplesInfo,
)
from vllm_omni.config import OmniModelConfig
from vllm_omni.model_executor.models.registry import OmniModelRegistry
def random_image(rng: np.random.RandomState, min_wh: int, max_wh: int):
w, h = rng.randint(min_wh, max_wh, size=(2,))
arr = rng.randint(0, 255, size=(w, h, 3), dtype=np.uint8)
return Image.fromarray(arr)
def random_video(
rng: np.random.RandomState,
min_frames: int,
max_frames: int,
min_wh: int,
max_wh: int,
):
num_frames = rng.randint(min_frames, max_frames)
w, h = rng.randint(min_wh, max_wh, size=(2,))
return rng.randint(0, 255, size=(num_frames, w, h, 3), dtype=np.uint8)
def random_audio(
rng: np.random.RandomState,
min_len: int,
max_len: int,
sr: int,
):
audio_len = rng.randint(min_len, max_len)
return rng.rand(audio_len), sr
_IGNORE_MM_KEYS: dict[str, set[str]] = {}
def _get_model_archs_to_test() -> list[str]:
"""Return architecture strings for parametrization."""
return list(_MULTIMODAL_OMNI_EXAMPLE_MODELS.keys())
def get_text_token_prompts(
processor: BaseMultiModalProcessor,
mm_data: MultiModalDataDict,
):
"""Return ``(text_prompt, token_prompt)`` for the given multimodal data."""
dummy_inputs = processor.dummy_inputs
tokenizer: TokenizerLike = processor.info.get_tokenizer()
model_config = processor.info.ctx.model_config
parsed_data = processor.info.parse_mm_data(mm_data)
mm_counts = {k: len(vs) for k, vs in parsed_data.items()}
inputs = dummy_inputs.get_dummy_processor_inputs(
model_config.max_model_len,
mm_counts,
mm_options={},
)
text_prompt: str | None
token_prompt: list[int]
if isinstance(inputs.prompt, list):
text_prompt = None
token_prompt = inputs.prompt
elif isinstance(inputs.prompt, str):
text_prompt = inputs.prompt
token_prompt = tokenizer.encode(
text_prompt,
**processor.info.get_default_tok_params().get_encode_kwargs(),
)
else:
raise TypeError(type(inputs.prompt))
return text_prompt, token_prompt
def _build_model_config(model_arch: str, info: _OmniExamplesInfo) -> OmniModelConfig:
"""Create an ``OmniModelConfig`` suitable for processor testing."""
kwargs: dict = dict(
model=info.default,
tokenizer=info.default,
tokenizer_mode="auto",
trust_remote_code=info.trust_remote_code,
model_arch=model_arch,
model_stage=info.model_stage,
max_model_len=info.max_model_len,
enforce_eager=True,
dtype="auto",
)
if info.hf_config_name is not None:
kwargs["hf_config_name"] = info.hf_config_name
model_config = OmniModelConfig(**kwargs)
# Ensure cache is effectively unlimited to prevent eviction during test
model_config.multimodal_config.mm_processor_cache_gb = 2048
return model_config
def _get_model_class_for_omni_processing(
model_config: OmniModelConfig,
):
"""Resolve the multimodal model class for processor tests.
vLLM's ``MULTIMODAL_REGISTRY._get_model_cls`` uses
:func:`get_model_architecture`, which re-imports
:mod:`vllm.model_executor.model_loader` and can re-register the same
custom Torch / CustomOp names when tests run in isolation
(``tests/model_executor/``). Omni-registered classes are loaded
unambiguously via :meth:`OmniModelRegistry._try_load_model_cls`.
:func:`bootstrap_vllm_layer_custom_op_modules` also runs (see
:file:`../helpers.py` and :file:`../conftest.py`) so vLLM layer modules are in :data:`sys.modules`
before shims like ``qwen2_5_omni_thinker`` import
:mod:`vllm.model_executor.models.qwen2_5_omni_thinker` (avoids duplicate
``@CustomOp.register`` e.g. ``fatrelu_and_mul``).
"""
bootstrap_vllm_layer_custom_op_modules()
if not model_config.model_arch:
raise ValueError("OmniModelConfig.model_arch is required for processing tests")
model_cls = OmniModelRegistry._try_load_model_cls(model_config.model_arch)
if model_cls is None:
raise RuntimeError(
f"OmniModelRegistry has no class registered for {model_config.model_arch!r}; "
"add it to vllm_omni.model_executor.models or fix the test matrix."
)
return model_cls
def _test_processing_correctness(
model_arch: str,
hit_rate: float,
num_batches: int,
simplify_rate: float,
):
"""Run the full processing-correctness test for one Omni model."""
info = _MULTIMODAL_OMNI_EXAMPLE_MODELS[model_arch]
info.check_transformers_version(on_fail="skip")
info.check_available_online(on_fail="skip")
model_config = _build_model_config(model_arch, info)
model_cls = _get_model_class_for_omni_processing(model_config)
factories = model_cls._processor_factory
ctx = InputProcessingContext(
model_config,
tokenizer=cached_tokenizer_from_config(model_config),
)
cache = MultiModalProcessorOnlyCache(model_config)
processing_info = factories.info(ctx)
supported_mm_limits = processing_info.get_supported_mm_limits()
limit_mm_per_prompt_ints = {
modality: 3 if limit is None else limit for modality, limit in supported_mm_limits.items()
}
def _to_dummy_options(modality: str, count: int) -> BaseDummyOptions:
if modality == "video":
return VideoDummyOptions(count=count)
if modality == "image":
return ImageDummyOptions(count=count)
if modality == "audio":
return AudioDummyOptions(count=count)
return BaseDummyOptions(count=count)
model_config.get_multimodal_config().limit_per_prompt = {
modality: _to_dummy_options(modality, count) for modality, count in limit_mm_per_prompt_ints.items()
}
baseline_processor = factories.build_processor(ctx, cache=None)
cached_processor = factories.build_processor(ctx, cache=cache)
rng = np.random.RandomState(0)
input_to_hit = {
"image": Image.new("RGB", size=(128, 128)),
"video": np.zeros((4, 128, 128, 3), dtype=np.uint8),
"audio": (np.zeros((512,)), 16000),
}
input_factory = {
"image": partial(random_image, rng, min_wh=128, max_wh=256),
"video": partial(random_video, rng, min_frames=2, max_frames=16, min_wh=128, max_wh=256),
"audio": partial(random_audio, rng, min_len=512, max_len=1024, sr=16000),
}
for batch_idx in range(num_batches):
mm_data = {
k: [
(input_to_hit[k] if rng.rand() < hit_rate else input_factory[k]())
for _ in range(rng.randint(limit + 1))
]
for k, limit in limit_mm_per_prompt_ints.items()
}
# Drop unnecessary keys and test single -> multi conversion
if rng.rand() < simplify_rate:
for k in list(mm_data.keys()):
if not mm_data[k]:
del mm_data[k]
elif len(mm_data[k]) == 1:
mm_data[k] = mm_data[k][0]
_test_processing_correctness_one(
model_config,
mm_data,
baseline_processor,
cached_processor,
batch_idx,
)
def _test_processing_correctness_one(
model_config: OmniModelConfig,
mm_data: MultiModalDataDict,
baseline_processor: BaseMultiModalProcessor,
cached_processor: BaseMultiModalProcessor,
batch_idx: int,
):
model_type = model_config.hf_config.model_type
text_prompt, token_prompt = get_text_token_prompts(baseline_processor, mm_data)
mm_items = baseline_processor.info.parse_mm_data(mm_data)
ignore_mm_keys = _IGNORE_MM_KEYS.get(model_type, set[str]())
baseline_tokenized_result = baseline_processor(
token_prompt,
mm_items=mm_items,
hf_processor_mm_kwargs={},
)
cached_tokenized_result = cached_processor(
token_prompt,
mm_items=mm_items,
hf_processor_mm_kwargs={},
)
_assert_inputs_equal(
baseline_tokenized_result,
cached_tokenized_result,
ignore_mm_keys=ignore_mm_keys,
msg=f"Failed ({batch_idx=}, {token_prompt=}, {mm_data=})",
)
if text_prompt is not None:
baseline_text_result = baseline_processor(
text_prompt,
mm_items=mm_items,
hf_processor_mm_kwargs={},
)
cached_text_result = cached_processor(
text_prompt,
mm_items=mm_items,
hf_processor_mm_kwargs={},
)
_assert_inputs_equal(
baseline_text_result,
cached_text_result,
ignore_mm_keys=ignore_mm_keys,
msg=f"Failed ({batch_idx=}, {text_prompt=}, {mm_data=})",
)
_assert_inputs_equal(
baseline_text_result,
baseline_tokenized_result,
ignore_mm_keys=ignore_mm_keys,
msg=f"Failed ({batch_idx=}, {text_prompt=}, {token_prompt=}, {mm_data=})",
)
_assert_inputs_equal(
cached_text_result,
cached_tokenized_result,
ignore_mm_keys=ignore_mm_keys,
msg=f"Failed ({batch_idx=}, {text_prompt=}, {token_prompt=}, {mm_data=})",
)
@pytest.mark.core_model
@pytest.mark.omni
@pytest.mark.cpu
@pytest.mark.parametrize("model_arch", _get_model_archs_to_test())
@pytest.mark.parametrize("hit_rate", [0.3, 0.5, 1.0])
@pytest.mark.parametrize("num_batches", [32])
@pytest.mark.parametrize("simplify_rate", [1.0])
def test_omni_processing_correctness(
model_arch: str,
hit_rate: float,
num_batches: int,
simplify_rate: float,
):
"""
For each Omni model verify that:
- cached and non-cached processor outputs are identical.
- text-prompt and token-prompt inputs yield the same result.
"""
_test_processing_correctness(
model_arch,
hit_rate=hit_rate,
num_batches=num_batches,
simplify_rate=simplify_rate,
)
def _assert_inputs_equal(
a: MultiModalInput,
b: MultiModalInput,
*,
ignore_mm_keys: set[str] | None = None,
msg: str = "",
):
if ignore_mm_keys is None:
ignore_mm_keys = set()
ignore_prompt_keys = ("prompt", "mm_kwargs")
a_rest = {k: v for k, v in a.items() if k not in ignore_prompt_keys}
b_rest = {k: v for k, v in b.items() if k not in ignore_prompt_keys}
assert a_rest == b_rest, msg
a_data = a["mm_kwargs"].get_data()
b_data = b["mm_kwargs"].get_data()
for key in ignore_mm_keys:
a_data.pop(key, None)
b_data.pop(key, None)
assert batched_tensors_equal(a_data, b_data), msg