Merge branch 'main' into feat/moss-tts-nano

Signed-off-by: Yueqian Lin <70319226+linyueqian@users.noreply.github.com>
This commit is contained in:
Yueqian Lin authored and GitHub committed 2026-04-21 17:50:21 -04:00
commit 3bd77d3f8f
611 files changed
+48775 -14783

No files matched your search

View File
Whitespace-only changes.
+111
View File
@@ -0,0 +1,111 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for the central pipeline registry (2.5/N)."""
from __future__ import annotations
import pytest
from vllm_omni.config.pipeline_registry import (
_DIFFUSION_PIPELINES,
_OMNI_PIPELINES,
_VLLM_OMNI_PIPELINES,
)
from vllm_omni.config.stage_config import (
_PIPELINE_REGISTRY,
PipelineConfig,
StageExecutionType,
StagePipelineConfig,
register_pipeline,
)
class TestCentralRegistryDeclarations:
"""Every in-tree pipeline must be declared exactly once in the central registry."""
def test_union_contains_all_omni(self):
for key in _OMNI_PIPELINES:
assert key in _VLLM_OMNI_PIPELINES
def test_union_contains_all_diffusion(self):
for key in _DIFFUSION_PIPELINES:
assert key in _VLLM_OMNI_PIPELINES
def test_no_duplicate_model_type_between_omni_and_diffusion(self):
overlap = set(_OMNI_PIPELINES) & set(_DIFFUSION_PIPELINES)
assert not overlap, f"Duplicate model_types across omni/diffusion: {overlap}"
def test_expected_omni_pipelines_present(self):
# Guard against accidental removal during future refactors.
assert "qwen2_5_omni" in _OMNI_PIPELINES
assert "qwen2_5_omni_thinker_only" in _OMNI_PIPELINES
assert "qwen3_omni_moe" in _OMNI_PIPELINES
assert "qwen3_tts" in _OMNI_PIPELINES
class TestLazyLoading:
"""Pipelines are imported only on first access."""
def test_contains_without_import(self):
# ``in`` hits the lazy map, not the loaded cache.
assert "qwen3_omni_moe" in _PIPELINE_REGISTRY
def test_getitem_loads_correct_pipeline(self):
pipeline = _PIPELINE_REGISTRY["qwen3_omni_moe"]
assert pipeline.model_type == "qwen3_omni_moe"
assert pipeline.model_arch == "Qwen3OmniMoeForConditionalGeneration"
def test_unknown_model_type_returns_none_via_get(self):
assert _PIPELINE_REGISTRY.get("not_a_real_pipeline") is None
def test_unknown_model_type_raises_keyerror_via_getitem(self):
with pytest.raises(KeyError):
_PIPELINE_REGISTRY["not_a_real_pipeline"]
def test_iteration_yields_registered_pipelines(self):
keys = set(_PIPELINE_REGISTRY)
assert "qwen2_5_omni" in keys
assert "qwen3_omni_moe" in keys
class TestDynamicRegistration:
"""``register_pipeline()`` still works for plugins and tests."""
def test_register_adds_to_registry(self):
custom = PipelineConfig(
model_type="_test_dynamic_registration",
model_arch="DynamicTestModel",
stages=(
StagePipelineConfig(
stage_id=0,
model_stage="test",
execution_type=StageExecutionType.LLM_AR,
input_sources=(),
final_output=True,
),
),
)
register_pipeline(custom)
try:
assert "_test_dynamic_registration" in _PIPELINE_REGISTRY
assert _PIPELINE_REGISTRY["_test_dynamic_registration"] is custom
finally:
# Don't leak the test registration into other tests.
if "_test_dynamic_registration" in _PIPELINE_REGISTRY:
del _PIPELINE_REGISTRY["_test_dynamic_registration"]
def test_dynamic_registration_overrides_lazy_entry(self):
# Build a substitute for qwen3_omni_moe that we can distinguish.
original = _PIPELINE_REGISTRY["qwen3_omni_moe"]
override = PipelineConfig(
model_type="qwen3_omni_moe",
model_arch="OverriddenArch",
stages=original.stages,
)
register_pipeline(override)
try:
assert _PIPELINE_REGISTRY["qwen3_omni_moe"].model_arch == "OverriddenArch"
finally:
# Remove the dynamic override so later tests see the original.
if "qwen3_omni_moe" in _PIPELINE_REGISTRY._loaded:
del _PIPELINE_REGISTRY["qwen3_omni_moe"]
+61 -3422
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,129 @@
"""Unit tests for OmniSchedulerMixin streaming session replacement.
These tests pin the behavior of `_replace_session_with_streaming_update` against
current vLLM `Request` / `StreamingUpdate` (and Omni patches). When upgrading
vLLM, failures here should highlight incompatible changes to request state or
update payloads early.
"""
from __future__ import annotations
from dataclasses import replace
import pytest
# Imports must run in this order: vllm_omni applies patches to vllm.v1.request before
# Request / StreamingUpdate are bound in this module. Ruff isort would reorder them.
# isort: off
import vllm_omni # noqa: F401 - import for side effects (patch vLLM)
from vllm.sampling_params import SamplingParams
from vllm.v1.engine import EngineCoreEventType
from vllm.v1.request import Request, RequestStatus, StreamingUpdate
from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin
# isort: on
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _SchedulerStub(OmniSchedulerMixin):
"""Minimal scheduler surface required by OmniSchedulerMixin."""
def __init__(self, *, log_stats: bool = False) -> None:
self.num_waiting_for_streaming_input = 0
self.log_stats = log_stats
def _make_request(**kwargs) -> Request:
sp = SamplingParams(max_tokens=8)
defaults = dict(
request_id="req-mixin-test",
prompt_token_ids=[1, 2, 3],
sampling_params=sp,
pooling_params=None,
arrival_time=100.0,
block_hasher=None,
)
defaults.update(kwargs)
return Request(**defaults)
def _make_update(**kwargs) -> StreamingUpdate:
sp_new = SamplingParams(max_tokens=16)
defaults = dict(
mm_features=None,
prompt_token_ids=[10, 20],
max_tokens=32,
arrival_time=200.0,
sampling_params=sp_new,
)
defaults.update(kwargs)
return StreamingUpdate(**defaults)
class TestReplaceSessionWithStreamingUpdate:
def test_resets_tokens_and_prompt_from_update(self) -> None:
sched = _SchedulerStub()
session = _make_request()
session.append_output_token_ids([7, 8])
session.num_computed_tokens = 99
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
update = _make_update(prompt_token_ids=[40, 41, 42])
sched.num_waiting_for_streaming_input = 3
sched._replace_session_with_streaming_update(session, update)
assert session._output_token_ids == []
assert list(session._all_token_ids) == [40, 41, 42]
assert session.prompt_token_ids == [40, 41, 42]
assert session.num_computed_tokens == 0
assert session.num_prompt_tokens == 3
assert session.arrival_time == 200.0
assert session.sampling_params is update.sampling_params
assert session.status == RequestStatus.WAITING
assert sched.num_waiting_for_streaming_input == 2
def test_none_prompt_token_ids_becomes_empty(self) -> None:
sched = _SchedulerStub()
session = _make_request()
session.status = RequestStatus.RUNNING
update = _make_update(prompt_token_ids=None)
sched._replace_session_with_streaming_update(session, update)
assert session.prompt_token_ids == ()
assert list(session._all_token_ids) == []
assert session.num_prompt_tokens == 0
assert sched.num_waiting_for_streaming_input == 0
def test_additional_information_cleared_when_update_omits_it(self) -> None:
sched = _SchedulerStub()
session = _make_request()
if not hasattr(session, "additional_information"):
pytest.skip("Request has no additional_information (Omni patch inactive?)")
session.additional_information = {"keep": True}
session.status = RequestStatus.RUNNING
base = _make_update()
if not hasattr(base, "additional_information"):
pytest.skip("StreamingUpdate has no additional_information (Omni patch inactive?)")
update = replace(base, additional_information=None)
sched._replace_session_with_streaming_update(session, update)
assert session.additional_information is None
def test_does_not_decrement_waiting_when_not_streaming_status(self) -> None:
sched = _SchedulerStub()
session = _make_request()
session.status = RequestStatus.RUNNING
sched.num_waiting_for_streaming_input = 5
sched._replace_session_with_streaming_update(session, _make_update())
assert sched.num_waiting_for_streaming_input == 5
def test_records_queued_event_when_log_stats_enabled(self) -> None:
sched = _SchedulerStub(log_stats=True)
session = _make_request()
session.status = RequestStatus.WAITING_FOR_STREAMING_REQ
sched._replace_session_with_streaming_update(session, _make_update())
assert session.events
assert session.events[-1].type == EngineCoreEventType.QUEUED
+349
View File
@@ -0,0 +1,349 @@
import pytest
import torch
from vllm_omni.core.prefix_cache import OmniTensorPrefixCache
DEFAULT_SEQ_LEN = 15
NUM_BLOCKS = 10
BLOCK_SIZE = 4
HIDDEN_SIZE = 2
DTYPE = torch.float32
OTHER_DTYPE = torch.float16
DEFAULT_SHAPE = torch.Size([NUM_BLOCKS, BLOCK_SIZE, HIDDEN_SIZE])
class MockInputBatch:
def __init__(self, num_computed_tokens_cpu):
self.req_ids = ["req1", "req2"]
self.req_id_to_index = {req_id: i for i, req_id in enumerate(self.req_ids)}
self.num_computed_tokens_cpu = num_computed_tokens_cpu
# Block table is only mocked for validation of length;
# we don't actually need to add valid values here since
# we patch the table when testing.
class _DummyBlockTable:
pass
self.block_table = _DummyBlockTable()
self.block_table.block_tables = [None]
def get_omni_pcache_with_mm_tensors(feat_dims, seq_len) -> OmniTensorPrefixCache:
"""Build an OmniTensorPrefixCache and init mm tensors."""
cache = get_omni_pcache()
mm_outputs = get_multimodal_outputs(feat_dims, seq_len)
cache.maybe_init_missing_mm_cache_keys(mm_outputs, seq_len)
return cache
def get_omni_pcache() -> OmniTensorPrefixCache:
"""Build an OmniTensorPrefixCache, but don't init mm tensors."""
cache = OmniTensorPrefixCache(
num_blocks=NUM_BLOCKS,
block_size=BLOCK_SIZE,
hidden_size=HIDDEN_SIZE,
hs_dtype=DTYPE,
)
return cache
def get_multimodal_outputs(feat_dims: dict[str, int], seq_len: int) -> dict[str, torch.Tensor]:
fake_mm_inputs = {}
for mm_key, feat_dim in feat_dims.items():
fake_mm_inputs[mm_key] = torch.rand((seq_len, feat_dim), dtype=DTYPE)
return fake_mm_inputs
### Tests for initialization
def test_initialization_simple():
"""Check default initialization only creates the hidden states."""
cache = get_omni_pcache()
assert isinstance(cache.hidden_states_cache, torch.Tensor)
assert cache.hidden_states_cache.shape == DEFAULT_SHAPE
assert len(cache.mm_outputs_cache) == 0
assert len(cache.mm_cache_keys) == 0
def test_initialization_with_multimodal():
"""Check initialization + registration of multimodal outputs."""
cache = get_omni_pcache()
feat_dims = {"foo": 100, "bar": 50, "baz": 10}
mm_outputs = get_multimodal_outputs(
feat_dims,
seq_len=DEFAULT_SEQ_LEN,
)
# Cast one of the keys to a different dtype; the dtype of the tensor
# that is used to initialize the cache dictates the cache dtype.
mm_outputs["foo"] = mm_outputs["foo"].to(OTHER_DTYPE)
cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
assert len(cache.mm_cache_keys) == 3
assert set(cache.mm_cache_keys) == set(feat_dims.keys())
for mm_key in cache.mm_cache_keys:
cache_tensor = cache.mm_outputs_cache[mm_key]
assert isinstance(cache_tensor, torch.Tensor)
assert cache_tensor.shape[-1] == feat_dims[mm_key]
assert mm_outputs[mm_key].dtype == cache_tensor.dtype
def test_init_missing_mm_cache_keys_is_idempotent():
"""Ensure that the cache doesn't reinitialize old keys."""
cache = get_omni_pcache()
mm_key = "foo"
feat_dims = {mm_key: 100}
mm_outputs = get_multimodal_outputs(
feat_dims,
seq_len=DEFAULT_SEQ_LEN,
)
cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
assert len(cache.mm_cache_keys) == 1
assert mm_key in cache.mm_cache_keys
# Cache is initialized to 0 - fill it with 1s
cache.mm_outputs_cache[mm_key].fill_(1)
# Ensure that running another initialization
# doesn't zero out our cache values
cache.maybe_init_missing_mm_cache_keys(mm_outputs, DEFAULT_SEQ_LEN)
assert len(cache.mm_cache_keys) == 1
assert mm_key in cache.mm_cache_keys
assert torch.all(cache.mm_outputs_cache[mm_key] == 1)
### Tests for Update
def test_update_no_multimodal():
"""Test that slot mappings act as row indices hidden states."""
cache = get_omni_pcache()
num_tokens_unpadded = 8
slot_offset = 8
slot_mapping = torch.arange(slot_offset, slot_offset + num_tokens_unpadded)
new_hidden_states = torch.rand((num_tokens_unpadded, HIDDEN_SIZE), dtype=DTYPE)
cache.update_omni_tensor_prefix_cache(
hidden_states=new_hidden_states,
multimodal_outputs=None,
num_tokens_unpadded=num_tokens_unpadded,
slot_mapping=slot_mapping,
)
# Ensure that if we reshape our 3D cache back to 2D, we can use the
# indices in our slot mappings to access the hidden states as expected
hs_rows = cache.hidden_states_cache.view(NUM_BLOCKS * BLOCK_SIZE, HIDDEN_SIZE)
for slot_idx, new_states in zip(slot_mapping, new_hidden_states):
slot_states = hs_rows[slot_idx]
assert torch.all(slot_states == new_states)
@pytest.mark.parametrize(
"feat_dims",
[
{"foo": 100, "bar": 100},
{"foo": 100, "bar": 50, "baz": 10},
],
)
def test_update_with_multimodal_outputs(feat_dims):
"""Test that slot mappings are correct for multimodal tensors."""
cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN)
num_tokens_unpadded = 8
slot_offset = 8
slot_mapping = torch.arange(slot_offset, slot_offset + num_tokens_unpadded)
feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
mm_outputs = {key: torch.rand((num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys}
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs=mm_outputs,
num_tokens_unpadded=num_tokens_unpadded,
slot_mapping=slot_mapping,
)
for mm_key in feat_dims.keys():
assert mm_key in cache.mm_outputs_cache
key_feat_dim = feature_dims[mm_key]
mm_state_rows = cache.mm_outputs_cache[mm_key].view(NUM_BLOCKS * BLOCK_SIZE, key_feat_dim)
# Similar to hidden states, but for each key in the dict;
# Different tensors may have different feature dims
new_mm_outputs = mm_outputs[mm_key]
for slot_idx, new_output in zip(slot_mapping, new_mm_outputs):
slot_states = mm_state_rows[slot_idx]
assert torch.all(slot_states == new_output)
### Tests for Merging
def fake_get_cached_block_ids(self, req_idx, *args, **kwargs):
"""Fake block table lookup.
Assumption:
req_idx 0 is a cache hit with slots 8, 9, ..., 15
req_idx 1 is a cache miss
"""
assert req_idx < 2
if req_idx == 0:
# With the slot offset we provided (8), the corresponding
# blocks IDs are 2 & 3 because the block size is 4.
return torch.tensor([2, 3], dtype=torch.long)
return torch.tensor([], dtype=torch.long)
@pytest.mark.parametrize("num_tokens_padded", [None, 16])
def test_get_merged_hidden_states(num_tokens_padded, mocker):
"""Ensure that hidden states are merged correctly."""
cache = get_omni_pcache()
orig_num_tokens_unpadded = 8
slot_offset = 8 # We'll put our states in slots 8, 9, 10, ..., 15
orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded)
orig_hidden_states = torch.rand((orig_num_tokens_unpadded, HIDDEN_SIZE), dtype=DTYPE)
cache.update_omni_tensor_prefix_cache(
hidden_states=orig_hidden_states,
multimodal_outputs=None,
num_tokens_unpadded=orig_num_tokens_unpadded,
slot_mapping=orig_slot_mapping,
num_tokens_padded=num_tokens_padded,
)
# Say that we have two requests, but only one of them is a cache hit
num_new_toks_req1 = 3
num_new_toks_req2 = 2
cache.add_prefix_cached_new_req_id("req1")
num_scheduled_tokens = {
"req1": num_new_toks_req1,
"req2": num_new_toks_req2,
}
new_hidden_states = torch.rand(
(num_new_toks_req1 + num_new_toks_req2, HIDDEN_SIZE),
dtype=DTYPE,
)
req1_new_states = new_hidden_states[:num_new_toks_req1]
req2_new_states = new_hidden_states[-num_new_toks_req2:]
input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0]))
mocker.patch(
"vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
new=fake_get_cached_block_ids,
)
merged_states = cache.get_merged_hidden_states(
query_start_loc=[0, num_new_toks_req1],
input_batch=input_batch,
hidden_states=new_hidden_states,
num_scheduled_tokens=num_scheduled_tokens,
)
assert "req1" in merged_states and "req2" in merged_states
req1_merged_states = merged_states["req1"]
req2_merged_states = merged_states["req2"]
# First, check the cache hit case
assert req1_merged_states.shape == torch.Size([orig_num_tokens_unpadded + num_new_toks_req1, HIDDEN_SIZE])
# Ensure that the req1 merged states are the cached states + the new req1 states
assert torch.all(req1_merged_states[:orig_num_tokens_unpadded] == orig_hidden_states)
assert torch.all(req1_merged_states[-num_new_toks_req1:] == req1_new_states)
# Next, ensure that the cache miss case only has the new states
assert req2_merged_states.shape == torch.Size([num_new_toks_req2, HIDDEN_SIZE])
assert torch.all(req2_merged_states == req2_new_states)
@pytest.mark.parametrize("num_tokens_padded", [None, 16])
@pytest.mark.parametrize(
"feat_dims",
[
{"foo": 100, "bar": 100},
{"foo": 100, "bar": 50, "baz": 10},
],
)
def test_get_merged_multimodal_outputs(feat_dims, num_tokens_padded, mocker):
cache = get_omni_pcache_with_mm_tensors(feat_dims, seq_len=DEFAULT_SEQ_LEN)
orig_num_tokens_unpadded = 8
slot_offset = 8 # We'll put our states in slots 8, 9, 10, ..., 15
orig_slot_mapping = torch.arange(slot_offset, slot_offset + orig_num_tokens_unpadded)
feature_dims = {key: val.shape[-1] for key, val in cache.mm_outputs_cache.items()}
orig_mm_outputs = {
key: torch.rand((orig_num_tokens_unpadded, feature_dims[key]), dtype=DTYPE) for key in cache.mm_cache_keys
}
cache.update_omni_tensor_prefix_cache(
hidden_states=None,
multimodal_outputs=orig_mm_outputs,
num_tokens_unpadded=orig_num_tokens_unpadded,
slot_mapping=orig_slot_mapping,
num_tokens_padded=num_tokens_padded,
)
# Similar to hs test- say that we have two requests, but only one of them is a cache hit
num_new_toks_req1 = 3
num_new_toks_req2 = 2
cache.add_prefix_cached_new_req_id("req1")
num_scheduled_tokens = {
"req1": num_new_toks_req1,
"req2": num_new_toks_req2,
}
new_mm_outputs = {}
for mm_key in cache.mm_cache_keys:
new_mm_outputs[mm_key] = torch.rand(
(num_new_toks_req1 + num_new_toks_req2, feature_dims[mm_key]),
dtype=DTYPE,
)
# We also want to make sure passthrough data (outside of our keys) isn't dropped
new_mm_outputs["passthrough_data"] = "Something else"
# Lists are a special case because we can't split them yet if we want to match
# the nonprefix cache behavior, because this runs before post process.
new_mm_outputs["passthrough_list"] = ["should", "not", "split"]
input_batch = MockInputBatch(num_computed_tokens_cpu=torch.Tensor([orig_num_tokens_unpadded, 0]))
mocker.patch(
"vllm_omni.core.prefix_cache.OmniTensorPrefixCache._get_cached_block_ids",
new=fake_get_cached_block_ids,
)
merged_mm_outputs = cache.get_merged_multimodal_states(
query_start_loc=[0, num_new_toks_req1],
input_batch=input_batch,
multimodal_outputs=new_mm_outputs,
num_scheduled_tokens=num_scheduled_tokens,
)
# Ensure the passthrough data wasn't dropped
assert "passthrough_data" in merged_mm_outputs
assert "passthrough_list" in merged_mm_outputs
for mm_key, mm_output in merged_mm_outputs.items():
# Ensure passthrough data is just forwarded normally and not duplicated
assert isinstance(mm_output, dict)
assert "req1" in mm_output and "req2" in mm_output
if mm_key == "passthrough_data":
assert mm_key not in cache.mm_cache_keys
assert new_mm_outputs[mm_key] == mm_output["req1"]
assert new_mm_outputs[mm_key] == mm_output["req2"]
elif mm_key == "passthrough_list":
assert mm_key not in cache.mm_cache_keys
assert new_mm_outputs[mm_key] == mm_output["req1"]
assert new_mm_outputs[mm_key] == mm_output["req2"]
else:
assert mm_key in cache.mm_cache_keys
curr_feat_dim = feature_dims[mm_key]
# Ensure that req1 (cache hit) merged the mm data
req1_merged_mm_outputs = mm_output["req1"]
req1_new_mm_outputs = new_mm_outputs[mm_key][:num_new_toks_req1]
assert req1_merged_mm_outputs.shape == torch.Size(
[orig_num_tokens_unpadded + num_new_toks_req1, curr_feat_dim]
)
# Ensure that the req1 merged mm data are the cached data + the new data
assert torch.all(req1_merged_mm_outputs[:orig_num_tokens_unpadded] == orig_mm_outputs[mm_key])
assert torch.all(req1_merged_mm_outputs[-num_new_toks_req1:] == req1_new_mm_outputs)
# Ensure that req2 (cache miss) only has the new mm data
req2_merged_mm_outputs = mm_output["req2"]
req2_new_mm_outputs = new_mm_outputs[mm_key][-num_new_toks_req2:]
assert req2_merged_mm_outputs.shape == torch.Size([num_new_toks_req2, curr_feat_dim])
assert torch.all(req2_merged_mm_outputs == req2_new_mm_outputs)
+29 -7
View File
@@ -2,7 +2,9 @@ import json
from pathlib import Path
from typing import Any
from tests.conftest import modify_stage_config
import pytest
from tests.helpers.stage_config import modify_stage_config
def load_configs(config_path: str) -> list[dict[str, Any]]:
@@ -38,22 +40,32 @@ def modify_stage(default_path, updates, deletes):
def create_unique_server_params(
configs: list[dict[str, Any]],
stage_configs_dir: Path,
) -> list[tuple[str, str, str]]:
) -> list[tuple[str, str, str | None, str | None, tuple[str, ...]]]:
unique_params = []
seen = set()
for config in configs:
test_name = config["test_name"]
model = config["server_params"]["model"]
stage_config_name = config["server_params"].get("stage_config_name")
server_params = config["server_params"]
model = server_params["model"]
stage_config_name = server_params.get("stage_config_name")
if stage_config_name:
stage_config_path = str(stage_configs_dir / stage_config_name)
delete = config["server_params"].get("delete", None)
update = config["server_params"].get("update", None)
delete = server_params.get("delete", None)
update = server_params.get("update", None)
stage_config_path = modify_stage(stage_config_path, update, delete)
else:
stage_config_path = None
server_param = (test_name, model, stage_config_path)
stage_overrides = server_params.get("stage_overrides")
stage_overrides_json = json.dumps(stage_overrides) if stage_overrides else None
# ``extra_cli_args`` passes raw CLI flags straight through to
# ``vllm_omni.entrypoints.cli.main serve`` — used for flags that
# don't map to stage-level overrides, e.g. ``--async-chunk`` /
# ``--no-async-chunk`` toggling the deploy-level async_chunk bool.
extra_cli_args = tuple(server_params.get("extra_cli_args") or ())
server_param = (test_name, model, stage_config_path, stage_overrides_json, extra_cli_args)
if server_param not in seen:
seen.add(server_param)
unique_params.append(server_param)
@@ -95,3 +107,13 @@ def create_benchmark_indices(
indices.append((test_name, idx))
return indices
def pytest_addoption(parser: pytest.Parser) -> None:
"""Register shared CLI options for DFX benchmark suites."""
parser.addoption(
"--test-config-file",
action="store",
default=None,
help=("Path to benchmark config JSON. Example: --test-config-file tests/dfx/perf/tests/test_tts.json"),
)
@@ -0,0 +1,86 @@
[
{
"test_name": null,
"backend": null,
"timestamp": null,
"server_params": {
"model": null,
"serve_args": {
"enable-diffusion-pipeline-profiler": false
}
},
"benchmark_params": {
"name": null,
"dataset": null,
"task": null,
"width": 0,
"height": 0,
"num-inference-steps": 0,
"num-prompts": 0,
"max-concurrency": 0,
"num-input-images": 0,
"enable-negative-prompt": false,
"baseline": {
"throughput_qps": 0,
"latency_mean": 0,
"peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0
}
},
"result": {
"duration": 0,
"completed_requests": 0,
"failed_requests": 0,
"throughput_qps": 0,
"latency_mean": 0,
"latency_median": 0,
"latency_p99": 0,
"latency_p95": 0,
"latency_p50": 0,
"peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0,
"peak_memory_mb_median": 0,
"stage_durations_mean": {},
"stage_durations_p50": {},
"stage_durations_p99": {},
"backend": null,
"model": null,
"dataset": null,
"task": null
},
"log_file": null,
"Model": null,
"Framework": null,
"Hardware": null,
"Deployment": null,
"Task": null,
"Dataset": null,
"resolution": null,
"Parallelism": null,
"max_concurrency": 0,
"Cache": null,
"Quantization": null,
"offload": null,
"compile": null,
"Attn_backend": null,
"num_inference_steps": 0,
"completed": 0,
"failed": 0,
"throughput_qps": 0,
"latency_mean": 0,
"latency_median": 0,
"latency_p99": 0,
"latency_p95": 0,
"latency_p50": 0,
"peak_memory_mb_max": 0,
"peak_memory_mb_mean": 0,
"peak_memory_mb_median": 0,
"stage_durations_mean": {},
"stage_durations_p50": {},
"stage_durations_p99": {},
"commit_sha": null,
"build_id": null,
"build_url": null,
"source_file": null
}
]
@@ -0,0 +1,55 @@
{
"date": null,
"endpoint_type": null,
"backend": null,
"label": null,
"model_id": null,
"tokenizer_id": null,
"num_prompts": 0,
"request_rate": null,
"burstiness": 0,
"max_concurrency": 0,
"duration": 0,
"completed": 0,
"failed": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"request_throughput": 0,
"request_goodput": null,
"output_throughput": 0,
"total_token_throughput": 0,
"total_audio_duration_s": 0,
"total_audio_frames": 0,
"audio_throughput": 0,
"max_output_tokens_per_s": 0,
"max_concurrent_requests": 0,
"rtfx": 0,
"mean_ttft_ms": 0,
"median_ttft_ms": 0,
"p99_ttft_ms": 0,
"mean_tpot_ms": 0,
"median_tpot_ms": 0,
"p99_tpot_ms": 0,
"mean_itl_ms": 0,
"median_itl_ms": 0,
"p99_itl_ms": 0,
"mean_e2el_ms": 0,
"median_e2el_ms": 0,
"p99_e2el_ms": 0,
"mean_audio_rtf": 0,
"median_audio_rtf": 0,
"p99_audio_rtf": 0,
"mean_audio_ttfp_ms": 0,
"median_audio_ttfp_ms": 0,
"p99_audio_ttfp_ms": 0,
"mean_audio_duration_s": 0,
"median_audio_duration_s": 0,
"p99_audio_duration_s": 0,
"baseline": {
"mean_ttft_ms": 0,
"mean_audio_ttfp_ms": 0,
"mean_audio_rtf": 0
},
"random_input_len": 0,
"random_output_len": 0
}
+122 -14
View File
@@ -8,7 +8,6 @@ from typing import Any
import pytest
from tests.conftest import OmniServer
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
@@ -16,18 +15,41 @@ from tests.dfx.conftest import (
get_benchmark_params_for_server,
load_configs,
)
from tests.helpers.runtime import OmniServer
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
CONFIG_FILE_PATH = str(Path(__file__).parent.parent / "tests" / "test.json")
def _get_config_file_from_argv() -> str | None:
"""Read ``--test-config-file`` from ``sys.argv`` at import time so parametrization can use it."""
import sys
for i, arg in enumerate(sys.argv):
if arg == "--test-config-file" and i + 1 < len(sys.argv):
return sys.argv[i + 1]
if arg.startswith("--test-config-file="):
return arg.split("=", 1)[1]
return None
_PERF_TESTS_DIR = Path(__file__).resolve().parent.parent / "tests"
_DEFAULT_CONFIG_FILE = str(_PERF_TESTS_DIR / "test_qwen_omni.json")
CONFIG_FILE_PATH = _get_config_file_from_argv()
if CONFIG_FILE_PATH is None:
print(
"No --test-config-file in argv, using default: tests/dfx/perf/tests/test_qwen_omni.json "
"(override with e.g. --test-config-file tests/dfx/perf/tests/test_tts.json)"
)
CONFIG_FILE_PATH = _DEFAULT_CONFIG_FILE
BENCHMARK_CONFIGS = load_configs(CONFIG_FILE_PATH)
STAGE_INIT_TIMEOUT = 600
OMNI_RESULT_TEMPLATE_PATH = Path(__file__).parent / "result_omni_template.json"
STAGE_CONFIGS_DIR = Path(__file__).parent.parent / "stage_configs"
test_params = create_unique_server_params(BENCHMARK_CONFIGS, STAGE_CONFIGS_DIR)
DEPLOY_CONFIGS_DIR = Path(__file__).parent.parent / "deploy"
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR)
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS)
_omni_server_lock = threading.Lock()
@@ -40,13 +62,19 @@ def omni_server(request):
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
test_name, model, stage_config_path = request.param
test_name, model, stage_config_path, stage_overrides, extra_cli_args = request.param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
server_args = ["--stage-init-timeout", str(STAGE_INIT_TIMEOUT), "--init-timeout", "900"]
server_args = ["--stage-init-timeout", "600", "--init-timeout", "900"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--stage-configs-path", stage_config_path] + server_args
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args) as server:
server.test_name = test_name
print("OmniServer started successfully")
@@ -56,24 +84,47 @@ def omni_server(request):
print("OmniServer stopped")
def _safe_filename_token(value: Any | None, *, default: str = "na") -> str:
"""Make a single path segment safe for result filenames on common filesystems."""
if value is None:
return default
s = str(value).strip()
for bad in ("/", "\\", ":", "*", "?", '"', "<", ">", "|"):
s = s.replace(bad, "_")
return s if s else default
def run_benchmark(
args: list,
test_name: str,
flow,
dataset_name: str,
num_prompt,
*,
baseline_config: dict[str, Any] | None = None,
sweep_index: int | None = None,
request_rate: Any | None = None,
max_concurrency: Any | None = None,
random_input_len: Any | None = None,
random_output_len: Any | None = None,
) -> Any:
"""Run a single benchmark iteration and return the parsed result JSON."""
"""Run a single benchmark iteration and return the parsed result JSON.
After ``vllm bench`` writes the JSON, ``result["baseline"]`` holds the same
per-metric resolved thresholds as ``assert_result`` (via ``_baseline_thresholds_for_step``).
When ``random_input_len`` / ``random_output_len`` are set, they are also written into the result JSON;
omitted keys when not configured.
"""
current_dt = datetime.now().strftime("%Y%m%d-%H%M%S")
result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_{current_dt}.json"
ri = _safe_filename_token(random_input_len)
ro = _safe_filename_token(random_output_len)
result_filename = f"result_{test_name}_{dataset_name}_{flow}_{num_prompt}_in{ri}_out{ro}_{current_dt}.json"
if "--result-filename" in args:
print(f"The result file will be overwritten by {result_filename}")
command = (
["vllm", "bench", "serve", "--omni"]
+ args
+ [
"--num-warmups",
"2",
"--save-result",
"--result-dir",
os.environ.get("BENCHMARK_DIR", "tests"),
@@ -97,8 +148,34 @@ def run_benchmark(
else:
result_dir = "./"
with open(os.path.join(result_dir, result_filename), encoding="utf-8") as f:
result = json.load(f)
result_path = os.path.join(result_dir, result_filename)
if not os.path.exists(result_path):
with open(OMNI_RESULT_TEMPLATE_PATH, encoding="utf-8") as f:
template_result: dict[str, Any] = json.load(f)
Path(result_path).parent.mkdir(parents=True, exist_ok=True)
with open(result_path, "w", encoding="utf-8") as f:
json.dump(template_result, f, ensure_ascii=False, indent=2)
print(f"Benchmark result file not generated, fallback to template: {result_path}")
result = template_result
else:
with open(result_path, encoding="utf-8") as f:
result = json.load(f)
if baseline_config:
result["baseline"] = _baseline_thresholds_for_step(
baseline_config,
sweep_index=sweep_index,
request_rate=request_rate,
max_concurrency=max_concurrency,
)
else:
result["baseline"] = {}
if random_input_len is not None:
result["random_input_len"] = random_input_len
if random_output_len is not None:
result["random_output_len"] = random_output_len
with open(result_path, "w", encoding="utf-8") as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return result
@@ -168,6 +245,25 @@ def _resolve_baseline_value(
return baseline_raw
def _baseline_thresholds_for_step(
baseline_data: dict[str, Any],
*,
sweep_index: int | None = None,
max_concurrency: Any = None,
request_rate: Any = None,
) -> dict[str, Any]:
"""Resolve ``test.json`` ``baseline`` block to one threshold per metric (same as ``assert_result``)."""
return {
metric_name: _resolve_baseline_value(
baseline_raw,
sweep_index=sweep_index,
max_concurrency=max_concurrency,
request_rate=request_rate,
)
for metric_name, baseline_raw in baseline_data.items()
}
def assert_result(
result,
params,
@@ -258,6 +354,12 @@ def test_performance_benchmark(omni_server, benchmark_params):
flow=qps,
dataset_name=dataset_name,
num_prompt=num_prompt,
baseline_config=params.get("baseline"),
sweep_index=i,
request_rate=qps,
max_concurrency=None,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
)
assert_result(
result,
@@ -276,6 +378,12 @@ def test_performance_benchmark(omni_server, benchmark_params):
flow=concurrency,
dataset_name=dataset_name,
num_prompt=num_prompt,
baseline_config=params.get("baseline"),
sweep_index=i,
request_rate=None,
max_concurrency=concurrency,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
)
assert_result(
result,
@@ -5,8 +5,8 @@ Supports vLLM-Omni server backend:
- vllm-omni (default): starts DiffusionServer via vllm_omni.entrypoints.cli.main,
benchmarks with diffusion_benchmark_serving.py --backend vllm-omni
A config JSON file is REQUIRED via --config-file:
pytest run_diffusion_benchmark.py --config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
A config JSON file is REQUIRED via --test-config-file:
pytest run_diffusion_benchmark.py --test-config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json
JSON config entries use a "server_type" field, and this runner executes
the vllm-omni path.
@@ -52,19 +52,20 @@ BENCHMARK_SCRIPT = str(
_SESSION_TIMESTAMP = datetime.now().strftime("%Y%m%d-%H%M%S")
_RESULT_LOCK = threading.Lock()
_BRANCHPOINT_COMMIT_SHA: str | None = None
DIFFUSION_RESULT_TEMPLATE_PATH = Path(__file__).parent / "diffusion_result_template.json"
def _get_config_file_from_argv() -> str | None:
"""Read --config-file from sys.argv at import time so pytest parametrize can use it.
"""Read --test-config-file from sys.argv at import time so pytest parametrize can use it.
pytest_addoption (below) registers the same flag so pytest does not reject it.
Supports both ``--config-file path`` and ``--config-file=path`` forms.
Supports both ``--test-config-file path`` and ``--test-config-file=path`` forms.
Returns None if the flag is not present; callers must handle the missing case.
"""
for i, arg in enumerate(sys.argv):
if arg == "--config-file" and i + 1 < len(sys.argv):
if arg == "--test-config-file" and i + 1 < len(sys.argv):
return sys.argv[i + 1]
if arg.startswith("--config-file="):
if arg.startswith("--test-config-file="):
return arg.split("=", 1)[1]
return None
@@ -133,19 +134,6 @@ def _append_to_aggregated_file(record: dict[str, Any]) -> None:
json.dump(records, f, indent=2, ensure_ascii=False)
# Register --config-file with pytest so it does not reject the argument.
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption(
"--config-file",
action="store",
default=None,
help=(
"Path to the benchmark config JSON file (required). "
"Example: --config-file tests/dfx/perf/tests/test_qwen_image_vllm_omni.json"
),
)
_server_lock = threading.Lock()
# ---------------------------------------------------------------------------
@@ -576,10 +564,17 @@ def run_benchmark(
if process.returncode != 0:
tmp_result_file.unlink(missing_ok=True)
raise RuntimeError(f"Benchmark script exited with code {process.returncode}")
print(f"ERROR:Benchmark script exited with code {process.returncode}")
if not tmp_result_file.exists():
raise FileNotFoundError(f"Benchmark result file not found: {tmp_result_file}")
with open(DIFFUSION_RESULT_TEMPLATE_PATH, encoding="utf-8") as f:
template_payload = json.load(f)
# Template schema is fixed and owned by this repo:
# ``diffusion_result_template.json`` is a one-item list and metrics live at [0]["result"].
template_metrics: dict[str, Any] = template_payload[0]["result"]
with open(tmp_result_file, "w", encoding="utf-8") as f:
json.dump(template_metrics, f, ensure_ascii=False, indent=2)
print(f"Benchmark result file not generated, fallback to template: {tmp_result_file}")
try:
with open(tmp_result_file, encoding="utf-8") as f:
@@ -1,101 +0,0 @@
# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
# Stage 0: Thinker (multimodal understanding + text generation)
# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
# The following config has been verified on 2x H100-80G GPUs.
async_chunk: false
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "0"
engine_args:
model_stage: thinker
max_num_seqs: 64
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.9
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output hidden states for talker
distributed_executor_backend: "mp"
enable_prefix_caching: false
max_num_batched_tokens: 32768
hf_config_name: thinker_config
tensor_parallel_size: 1
final_output: true
final_output_type: text
is_comprehension: true
default_sampling_params:
temperature: 0.4
top_p: 0.9
top_k: 1
max_tokens: 2048
seed: 42
detokenize: True
repetition_penalty: 1.05
- stage_id: 1
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "1"
engine_args:
model_stage: talker
max_num_seqs: 64
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.6
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output codec codes for code2wav
enable_prefix_caching: false
max_num_batched_tokens: 32768
distributed_executor_backend: "mp"
hf_config_name: talker_config
engine_input_source: [0]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
# final_output: true
# final_output_type: text
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
detokenize: False
repetition_penalty: 1.05
stop_token_ids: [2150]
- stage_id: 2
stage_type: llm # Use llm stage type for AR stages
runtime:
devices: "1"
engine_args:
model_stage: code2wav
max_num_seqs: 64
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
max_num_batched_tokens: 100000
hf_config_name: thinker_config
engine_input_source: [1]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
final_output: true
final_output_type: audio
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
detokenize: True
repetition_penalty: 1.1
@@ -1,96 +0,0 @@
# Stage config for running Qwen3-TTS with 2-stage architecture
# Stage 0: Talker (text -> 8-layer RVQ codec codes)
# Stage 1: Code2Wav (codec codes -> audio waveform)
#
# The following config has been verified on 1x H100-80G GPU.
async_chunk: true
stage_args:
- stage_id: 0
stage_type: llm
is_comprehension: true
runtime:
devices: "0"
engine_args:
max_num_seqs: 4
model_stage: qwen3_tts
model_arch: Qwen3TTSTalkerForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
enforce_eager: false
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: latent
gpu_memory_utilization: 0.3
distributed_executor_backend: "mp"
max_num_batched_tokens: 512
max_model_len: 4096
custom_process_next_stage_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_tts.talker2code2wav_async_chunk
output_connectors:
to_stage_1: connector_of_shared_memory
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
detokenize: false
repetition_penalty: 1.05
stop_token_ids: [2150]
- stage_id: 1
stage_type: llm
runtime:
devices: "0"
engine_args:
max_num_seqs: 4
model_stage: code2wav
model_arch: Qwen3TTSCode2Wav
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio
gpu_memory_utilization: 0.2
distributed_executor_backend: "mp"
max_num_batched_tokens: 8192
max_model_len: 32768
engine_input_source: [0]
final_output: true
final_output_type: audio
input_connectors:
from_stage_0: connector_of_shared_memory
tts_args:
max_instructions_length: 500
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
detokenize: true
repetition_penalty: 1.0
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 4
connectors:
connector_of_shared_memory:
name: SharedMemoryConnector
extra:
shm_threshold_bytes: 65536
codec_streaming: true
connector_get_sleep_s: 0.01
connector_get_max_wait_first_chunk: 3000
connector_get_max_wait: 300
codec_chunk_frames: 25
codec_left_context_frames: 72
edges:
- from: 0
to: 1
window_size: -1
@@ -0,0 +1,49 @@
[
{
"test_name": "test_qwen_image_layered_single_device",
"description": "Single-device baseline",
"server_type": "vllm-omni",
"server_params": {
"model": "Qwen/Qwen-Image-Layered",
"serve_args": {
"enable-diffusion-pipeline-profiler": true
}
},
"benchmark_params": [
{
"name": "640x640_steps20_i2i",
"dataset": "random",
"task": "i2i",
"width": 640,
"height": 640,
"num-inference-steps": 20,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.02,
"latency_mean": 40.0,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
}
},
{
"name": "1024x1024_steps35_i2i",
"dataset": "random",
"task": "i2i",
"width": 1024,
"height": 1024,
"num-inference-steps": 35,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.005,
"latency_mean": 80.0,
"peak_memory_mb_max": 70000,
"peak_memory_mb_mean": 70000
}
}
]
}
]
@@ -44,6 +44,52 @@
}
]
},
{
"test_name": "test_qwen_image_single_device_step_execution",
"description": "Single-device baseline (no parallelism) with step execution",
"server_type": "vllm-omni",
"server_params": {
"model": "Qwen/Qwen-Image",
"serve_args": {
"enable-diffusion-pipeline-profiler": true,
"step-execution": true
}
},
"benchmark_params": [
{
"name": "512x512_steps20",
"dataset": "random",
"task": "t2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.30,
"latency_mean": 3.50,
"peak_memory_mb_mean": 67000
}
},
{
"name": "1536x1536_steps35",
"dataset": "random",
"task": "t2i",
"width": 1536,
"height": 1536,
"num-inference-steps": 35,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.037,
"latency_mean": 27.0,
"peak_memory_mb_mean": 74000
}
}
]
},
{
"test_name": "test_qwen_image_ulysses2_cfg2_vae_patch4",
"description": "Ulysses SP=2 + CFG-parallel=2 + VAE Patch Parallel=4",
@@ -59,22 +105,6 @@
}
},
"benchmark_params": [
{
"name": "512x512_steps20",
"dataset": "random",
"task": "t2i",
"width": 512,
"height": 512,
"num-inference-steps": 20,
"num-prompts": 10,
"max-concurrency": 1,
"enable-negative-prompt": true,
"baseline": {
"throughput_qps": 0.1,
"latency_mean": 2.7,
"peak_memory_mb_mean": 61000
}
},
{
"name": "1536x1536_steps35",
"dataset": "random",
@@ -3,7 +3,7 @@
"test_name": "test_qwen3_omni",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml"
"extra_cli_args": ["--no-async-chunk"]
},
"benchmark_params": [
{
@@ -109,25 +109,7 @@
"test_name": "test_qwen3_omni_chunk",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml",
"update": {
"async_chunk": true,
"stage_args": {
"0": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
},
"1": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
}
}
},
"delete": {
"stage_args": {
"2": [
"custom_process_input_func"
]
}
}
"extra_cli_args": ["--async-chunk"]
},
"benchmark_params": [
{
@@ -329,37 +311,5 @@
}
}
]
},
{
"test_name": "test_qwen3_tts",
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"num_prompts": [
10,
40
],
"max_concurrency": [
1,
4
],
"random_input_len": 100,
"random_output_len": 100,
"extra_body": {
"voice": "Vivian",
"language": "English"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_ttfp_ms": [6000, 6000],
"mean_audio_rtf": [0.3, 0.3]
}
}
]
}
]
+34
View File
@@ -0,0 +1,34 @@
[
{
"test_name": "test_qwen3_tts",
"server_params": {
"model": "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
},
"benchmark_params": [
{
"dataset_name": "random",
"backend": "openai-audio-speech",
"endpoint": "/v1/audio/speech",
"num_prompts": [
10,
40
],
"max_concurrency": [
1,
4
],
"random_input_len": 100,
"random_output_len": 100,
"extra_body": {
"voice": "Vivian",
"language": "English"
},
"percentile-metrics": "ttft,e2el,audio_rtf,audio_ttfp,audio_duration",
"baseline": {
"mean_audio_ttfp_ms": [6000, 6000],
"mean_audio_rtf": [0.3, 0.3]
}
}
]
}
]
+11 -119
View File
@@ -1,125 +1,17 @@
"""
Stability-specific conftest: when pytest is executed under this directory,
resource monitoring is started before each test and finalized after each test,
so each stability test case gets its own HTML report (one report per case).
No need to wrap pytest with `bash resource_monitor.sh run -- pytest ...`.
"""
"""Stability pytest hooks and fixtures."""
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
import pytest
STABILITY_DIR = Path(__file__).resolve().parent
RESOURCE_MONITOR_SCRIPT = STABILITY_DIR / "scripts" / "resource_monitor.sh"
REPO_ROOT = STABILITY_DIR.parent.parent.parent
def _start_resource_monitor():
"""Start `resource_monitor.sh start` in the background and return `Popen` or `None`."""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
proc = subprocess.Popen(
["bash", str(RESOURCE_MONITOR_SCRIPT), "start", "--backend", "gpu"],
cwd=str(REPO_ROOT),
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
proc.wait(timeout=2)
if proc.returncode != 0:
stderr = proc.stderr.read().decode("utf-8", errors="ignore") if proc.stderr else ""
if stderr.strip():
sys.stderr.write(f"[Stability] Resource monitor failed to start: {stderr.strip()}\n")
return None
except subprocess.TimeoutExpired:
pass
return proc
except (FileNotFoundError, OSError):
return None
def _get_monitor_data_root() -> Path:
data_root = os.environ.get("RESOURCE_MONITOR_DATA_ROOT") or os.environ.get("GPU_MONITOR_DATA_ROOT")
if data_root:
return Path(data_root)
return STABILITY_DIR / "gpu_monitor_data"
def _wait_for_run_dir(timeout_sec: int = 10) -> Path | None:
data_root = _get_monitor_data_root()
run_id_file = data_root / "current_run_id"
deadline = time.time() + timeout_sec
while time.time() < deadline:
if run_id_file.is_file():
run_id = run_id_file.read_text(encoding="utf-8").strip()
if run_id:
run_dir = data_root / run_id
if run_dir.is_dir():
return run_dir
time.sleep(0.5)
return None
def _report_latest_gpu_samples(stop_event: threading.Event) -> None:
"""Periodically print the latest sampled GPU line."""
log_interval = int(
os.environ.get("RESOURCE_MONITOR_LOG_INTERVAL") or os.environ.get("GPU_MONITOR_LOG_INTERVAL") or "15"
)
log_interval = max(log_interval, 1)
last_line = ""
time.sleep(min(log_interval, 5))
while not stop_event.wait(log_interval):
run_dir = _wait_for_run_dir(timeout_sec=1)
if run_dir is None:
continue
csv_file = run_dir / "gpu_metrics.csv"
if not csv_file.is_file():
continue
try:
lines = csv_file.read_text(encoding="utf-8").splitlines()
except OSError:
continue
if len(lines) <= 1:
continue
latest = lines[-1].strip()
if latest and latest != last_line:
last_line = latest
sys.stderr.write(f"[GPU] {latest}\n")
def _finalize_resource_monitor() -> str | None:
"""
Run `resource_monitor.sh finalize` for the current run and generate the report.
Returns the bundle dir path (for this test case's report) if successful, else None.
"""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
result = subprocess.run(
["bash", str(RESOURCE_MONITOR_SCRIPT), "finalize", "--backend", "gpu"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=60,
check=False,
)
if result.returncode != 0:
return None
for line in (result.stdout or "").splitlines():
if line.startswith("GPU_MONITOR_BUNDLE_DIR=") or line.startswith("RESOURCE_MONITOR_BUNDLE_DIR="):
_, _, value = line.partition("=")
return value.strip() if value else None
return None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
from tests.dfx.stability.helpers import (
finalize_resource_monitor,
report_latest_gpu_samples,
start_resource_monitor,
wait_for_run_dir,
)
@pytest.fixture(autouse=True)
@@ -128,19 +20,19 @@ def stability_resource_monitor_per_test(request: pytest.FixtureRequest):
For each test under this directory: start GPU monitor before the test,
then finalize after the test so this case gets its own report.html.
"""
proc = _start_resource_monitor()
proc = start_resource_monitor()
stop_event = threading.Event()
reporter: threading.Thread | None = None
if proc is not None:
reporter = threading.Thread(
target=_report_latest_gpu_samples,
target=report_latest_gpu_samples,
args=(stop_event,),
name="stability-resource-monitor-reporter",
daemon=True,
)
reporter.start()
run_dir = _wait_for_run_dir(timeout_sec=5)
run_dir = wait_for_run_dir(timeout_sec=5)
node_name = request.node.name
if run_dir is not None:
sys.stderr.write(f"[Stability] Resource monitor started for test: {node_name} | run dir: {run_dir}\n")
@@ -161,7 +53,7 @@ def stability_resource_monitor_per_test(request: pytest.FixtureRequest):
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
bundle_dir = _finalize_resource_monitor()
bundle_dir = finalize_resource_monitor()
node_name = request.node.name
if bundle_dir:
sys.stderr.write(f"[Stability] Report for test «{node_name}»: {bundle_dir}/report.html\n")
+117
View File
@@ -0,0 +1,117 @@
"""Stability resource monitor helpers."""
from __future__ import annotations
import os
import subprocess
import sys
import threading
import time
from pathlib import Path
STABILITY_DIR = Path(__file__).resolve().parent
RESOURCE_MONITOR_SCRIPT = STABILITY_DIR / "scripts" / "resource_monitor.sh"
REPO_ROOT = STABILITY_DIR.parent.parent.parent
def start_resource_monitor():
"""Start `resource_monitor.sh start` in the background and return `Popen` or `None`."""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
proc = subprocess.Popen(
["bash", str(RESOURCE_MONITOR_SCRIPT), "start", "--backend", "gpu"],
cwd=str(REPO_ROOT),
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
proc.wait(timeout=2)
if proc.returncode != 0:
stderr = proc.stderr.read().decode("utf-8", errors="ignore") if proc.stderr else ""
if stderr.strip():
sys.stderr.write(f"[Stability] Resource monitor failed to start: {stderr.strip()}\n")
return None
except subprocess.TimeoutExpired:
pass
return proc
except (FileNotFoundError, OSError):
return None
def get_monitor_data_root() -> Path:
data_root = os.environ.get("RESOURCE_MONITOR_DATA_ROOT") or os.environ.get("GPU_MONITOR_DATA_ROOT")
if data_root:
return Path(data_root)
return STABILITY_DIR / "gpu_monitor_data"
def wait_for_run_dir(timeout_sec: int = 10) -> Path | None:
data_root = get_monitor_data_root()
run_id_file = data_root / "current_run_id"
deadline = time.time() + timeout_sec
while time.time() < deadline:
if run_id_file.is_file():
run_id = run_id_file.read_text(encoding="utf-8").strip()
if run_id:
run_dir = data_root / run_id
if run_dir.is_dir():
return run_dir
time.sleep(0.5)
return None
def report_latest_gpu_samples(stop_event: threading.Event) -> None:
"""Periodically print the latest sampled GPU line."""
log_interval = int(
os.environ.get("RESOURCE_MONITOR_LOG_INTERVAL") or os.environ.get("GPU_MONITOR_LOG_INTERVAL") or "15"
)
log_interval = max(log_interval, 1)
last_line = ""
time.sleep(min(log_interval, 5))
while not stop_event.wait(log_interval):
run_dir = wait_for_run_dir(timeout_sec=1)
if run_dir is None:
continue
csv_file = run_dir / "gpu_metrics.csv"
if not csv_file.is_file():
continue
try:
lines = csv_file.read_text(encoding="utf-8").splitlines()
except OSError:
continue
if len(lines) <= 1:
continue
latest = lines[-1].strip()
if latest and latest != last_line:
last_line = latest
sys.stderr.write(f"[GPU] {latest}\n")
def finalize_resource_monitor() -> str | None:
"""
Run `resource_monitor.sh finalize` for the current run and generate the report.
Returns the bundle dir path (for this test case's report) if successful, else None.
"""
if not RESOURCE_MONITOR_SCRIPT.is_file():
return None
try:
result = subprocess.run(
["bash", str(RESOURCE_MONITOR_SCRIPT), "finalize", "--backend", "gpu"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=60,
check=False,
)
if result.returncode != 0:
return None
for line in (result.stdout or "").splitlines():
if line.startswith("GPU_MONITOR_BUNDLE_DIR=") or line.startswith("RESOURCE_MONITOR_BUNDLE_DIR="):
_, _, value = line.partition("=")
return value.strip() if value else None
return None
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
@@ -24,7 +24,6 @@ from typing import Any
import pytest
from tests.conftest import OmniServer
from tests.dfx.conftest import (
create_benchmark_indices,
create_test_parameter_mapping,
@@ -33,9 +32,10 @@ from tests.dfx.conftest import (
load_configs,
)
from tests.dfx.perf.scripts.run_benchmark import run_benchmark
from tests.helpers.runtime import OmniServer
STABILITY_DIR = Path(__file__).resolve().parent.parent
STAGE_CONFIGS_DIR = STABILITY_DIR / "stage_configs"
DEPLOY_CONFIGS_DIR = STABILITY_DIR / "deploy"
CONFIG_FILE_PATH = str(STABILITY_DIR / "tests" / "test.json")
DEFAULT_NUM_PROMPTS_PER_BATCH = 20
@@ -45,7 +45,7 @@ try:
except FileNotFoundError:
BENCHMARK_CONFIGS = []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, STAGE_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
test_params = create_unique_server_params(BENCHMARK_CONFIGS, DEPLOY_CONFIGS_DIR) if BENCHMARK_CONFIGS else []
server_to_benchmark_mapping = create_test_parameter_mapping(BENCHMARK_CONFIGS) if BENCHMARK_CONFIGS else {}
_omni_server_lock = threading.Lock()
@@ -112,6 +112,8 @@ def _run_one_benchmark_batch(
flow=flow,
dataset_name=dataset_name,
num_prompt=num_prompts,
random_input_len=params.get("random_input_len"),
random_output_len=params.get("random_output_len"),
)
return result
except (FileNotFoundError, OSError) as e:
@@ -217,11 +219,20 @@ def omni_server(request):
Multi-stage initialization can take 10-20+ minutes.
"""
with _omni_server_lock:
test_name, model, stage_config_path = request.param
test_name, model, stage_config_path, stage_overrides, extra_cli_args = request.param
print(f"Starting OmniServer with test: {test_name}, model: {model}")
with OmniServer(model, ["--stage-configs-path", stage_config_path, "--stage-init-timeout", "120"]) as server:
server_args = ["--stage-init-timeout", "120"]
# --deploy-config and --stage-overrides compose at the CLI (see vllm_omni/entrypoints/utils.py):
# deploy-config sets the base; stage-overrides are applied on top. Both can be set.
if stage_config_path:
server_args = ["--deploy-config", stage_config_path] + server_args
if stage_overrides:
server_args = ["--stage-overrides", stage_overrides] + server_args
if extra_cli_args:
server_args = list(extra_cli_args) + server_args
with OmniServer(model, server_args) as server:
server.test_name = test_name
print("OmniServer started successfully")
yield server
@@ -1,101 +0,0 @@
# Stage config for running Qwen3-Omni-MoE with 3-stage architecture
# Stage 0: Thinker (multimodal understanding + text generation)
# Stage 1: Talker (text embeddings → 8-layer RVQ codec codes)
# Stage 2: Code2Wav (8-layer RVQ codes → audio waveform)
# The following config has been verified on 2x H100-80G GPUs.
async_chunk: false
stage_args:
- stage_id: 0
stage_type: llm # Use llm stage type to launch OmniLLM
runtime:
devices: "0"
max_batch_size: 64
engine_args:
model_stage: thinker
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.9
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output hidden states for talker
distributed_executor_backend: "mp"
enable_prefix_caching: false
max_num_batched_tokens: 32768
hf_config_name: thinker_config
tensor_parallel_size: 1
final_output: true
final_output_type: text
is_comprehension: true
default_sampling_params:
temperature: 0.4
top_p: 0.9
top_k: 1
max_tokens: 2048
seed: 42
detokenize: True
repetition_penalty: 1.05
- stage_id: 1
stage_type: llm # Use llm stage type to launch OmniLLM
runtime:
devices: "1"
max_batch_size: 64
engine_args:
model_stage: talker
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
gpu_memory_utilization: 0.6
enforce_eager: false
trust_remote_code: true
engine_output_type: latent # Output codec codes for code2wav
enable_prefix_caching: false
max_num_batched_tokens: 32768
distributed_executor_backend: "mp"
hf_config_name: talker_config
engine_input_source: [0]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker
# final_output: true
# final_output_type: text
default_sampling_params:
temperature: 0.9
top_k: 50
max_tokens: 4096
seed: 42
detokenize: False
repetition_penalty: 1.05
stop_token_ids: [2150]
- stage_id: 2
stage_type: llm # Use llm stage type to launch OmniLLM
runtime:
devices: "1"
max_batch_size: 64
engine_args:
model_stage: code2wav
model_arch: Qwen3OmniMoeForConditionalGeneration
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
enforce_eager: true
trust_remote_code: true
async_scheduling: false
enable_prefix_caching: false
engine_output_type: audio # Final output: audio waveform
gpu_memory_utilization: 0.1
distributed_executor_backend: "mp"
max_num_batched_tokens: 1000000
hf_config_name: thinker_config
engine_input_source: [1]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav
final_output: true
final_output_type: audio
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 65536
seed: 42
detokenize: True
repetition_penalty: 1.1
+9 -18
View File
@@ -3,7 +3,11 @@
"test_name": "test_qwen3_omni_stability",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml"
"stage_overrides": {
"2": {
"max_num_batched_tokens": 1000000
}
}
},
"benchmark_params": [
{
@@ -36,25 +40,12 @@
"test_name": "test_qwen3_omni_stability_async_chunk",
"server_params": {
"model": "Qwen/Qwen3-Omni-30B-A3B-Instruct",
"stage_config_name": "qwen3_omni.yaml",
"update": {
"async_chunk": true,
"stage_args": {
"0": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.thinker2talker_async_chunk"
},
"1": {
"engine_args.custom_process_next_stage_input_func": "vllm_omni.model_executor.stage_input_processors.qwen3_omni.talker2code2wav_async_chunk"
}
"stage_overrides": {
"2": {
"max_num_batched_tokens": 1000000
}
},
"delete": {
"stage_args": {
"2": [
"custom_process_input_func"
]
}
}
"extra_cli_args": ["--async-chunk"]
},
"benchmark_params": [
{
+40
View File
@@ -0,0 +1,40 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Model specific tests for CacheDiT enablement.
"""
from unittest.mock import Mock, patch
import pytest
import vllm_omni.diffusion.cache.cache_dit_backend as cd_backend
from vllm_omni.diffusion.data import DiffusionCacheConfig
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
SEPARATE_CFG_ENABLERS = [
cd_backend.enable_cache_for_ltx2,
cd_backend.enable_cache_for_wan22,
cd_backend.enable_cache_for_longcat_image,
]
SAMPLE_CACHE_CONFIG = DiffusionCacheConfig()
@pytest.mark.parametrize("enabler", SEPARATE_CFG_ENABLERS)
@patch("vllm_omni.diffusion.cache.cache_dit_backend.BlockAdapter")
@patch("vllm_omni.diffusion.cache.cache_dit_backend.cache_dit")
def test_separate_cfg(mock_cache_dit, mock_block_adapter, enabler):
"""Ensure that custom enablers for models with separate CFG pass
the param through to cache_dit correctly.
Regression test for: https://github.com/vllm-project/vllm-omni/pull/2860
"""
mock_pipeline = Mock()
enabler(mock_pipeline, SAMPLE_CACHE_CONFIG)
mock_cache_dit.enable_cache.assert_called_once()
adapter_kwargs = mock_block_adapter.call_args.kwargs
assert adapter_kwargs["has_separate_cfg"] is True
+1 -1
View File
@@ -21,7 +21,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
import torch
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.cache.teacache.extractors import extract_flux2_context, extract_flux2_klein_context
from vllm_omni.diffusion.models.flux2_klein.flux2_klein_transformer import (
Flux2Transformer2DModel,
@@ -17,7 +17,7 @@ import pytest
import torch
import torch.distributed as dist
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.attention.parallel.ulysses import (
_all_gather_int,
_ulysses_all_to_all_any_o,
+453
View File
@@ -0,0 +1,453 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for LayerNorm and RMSNorm custom ops in diffusion layers."""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
# ── Import tests ──
def test_layernorm_import():
"""Verify LayerNorm can be imported from the norm module."""
from vllm_omni.diffusion.layers.norm import LayerNorm # noqa: F401
def test_rmsnorm_import():
"""Verify RMSNorm can be imported from the norm module."""
from vllm_omni.diffusion.layers.norm import RMSNorm # noqa: F401
# ── LayerNorm tests ──
def test_layernorm_forward_shape():
"""LayerNorm produces correct output shapes."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
batch = 2
seq_len = 4
norm = LayerNorm(dim)
x = torch.randn(batch, seq_len, dim)
out = norm(x)
assert out.shape == (batch, seq_len, dim)
def test_layernorm_forward_shape_2d():
"""LayerNorm works with 2D input tensors."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
batch = 2
norm = LayerNorm(dim)
x = torch.randn(batch, dim)
out = norm(x)
assert out.shape == (batch, dim)
def test_layernorm_preserves_dtype_fp32():
"""LayerNorm preserves float32 dtype."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
x = torch.randn(2, 4, dim, dtype=torch.float32)
out = norm(x)
assert out.dtype == torch.float32
def test_layernorm_preserves_dtype_fp16():
"""LayerNorm preserves float16 dtype."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
x = torch.randn(2, 4, dim, dtype=torch.float16)
out = norm(x)
assert out.dtype == torch.float16
def test_layernorm_preserves_dtype_bf16():
"""LayerNorm preserves bfloat16 dtype."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
x = torch.randn(2, 4, dim, dtype=torch.bfloat16)
out = norm(x)
assert out.dtype == torch.bfloat16
def test_layernorm_without_elementwise_affine():
"""LayerNorm works without elementwise_affine (no learned parameters)."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim, elementwise_affine=False)
assert norm.weight is None
assert norm.bias is None
x = torch.randn(2, 4, dim)
out = norm(x)
assert out.shape == (2, 4, dim)
def test_layernorm_custom_eps():
"""LayerNorm accepts custom epsilon value."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
eps = 1e-5
norm = LayerNorm(dim, eps=eps)
assert norm.eps == eps
def test_layernorm_has_learnable_parameters():
"""LayerNorm has learnable weight and bias by default."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
assert norm.weight is not None
assert norm.bias is not None
assert norm.weight.shape == (dim,)
assert norm.bias.shape == (dim,)
def test_layernorm_matches_fp32_reference():
"""Verify LayerNorm produces identical output to FP32 nn.LayerNorm."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
eps = 1e-6
torch.manual_seed(42)
ours = LayerNorm(dim, eps=eps)
ref = torch.nn.LayerNorm(dim, eps=eps)
# Copy weights
ref.weight.data.copy_(ours.weight.data)
ref.bias.data.copy_(ours.bias.data)
x = torch.randn(2, 4, dim)
out_ours = ours(x)
out_ref = ref(x.float()).to(x.dtype)
torch.testing.assert_close(out_ours, out_ref, atol=1e-5, rtol=1e-5)
def test_layernorm_matches_diffusers_fp32layernorm():
"""Verify LayerNorm produces identical output to diffusers FP32LayerNorm."""
from diffusers.models.normalization import FP32LayerNorm
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
eps = 1e-6
torch.manual_seed(42)
ours = LayerNorm(dim, eps=eps)
ref = FP32LayerNorm(dim, eps=eps)
# Copy weights
ref.weight.data.copy_(ours.weight.data)
ref.bias.data.copy_(ours.bias.data)
# Test with fp16 input to verify FP32 computation
x = torch.randn(2, 4, dim, dtype=torch.float16)
out_ours = ours(x)
out_ref = ref(x)
torch.testing.assert_close(out_ours, out_ref, atol=1e-3, rtol=1e-3)
# ── RMSNorm tests ──
def test_rmsnorm_forward_shape():
"""RMSNorm produces correct output shapes."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
batch = 2
seq_len = 4
norm = RMSNorm(hidden_size)
x = torch.randn(batch, seq_len, hidden_size)
out = norm(x)
assert out.shape == (batch, seq_len, hidden_size)
def test_rmsnorm_forward_shape_2d():
"""RMSNorm works with 2D input tensors."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
batch = 2
norm = RMSNorm(hidden_size)
x = torch.randn(batch, hidden_size)
out = norm(x)
assert out.shape == (batch, hidden_size)
def test_rmsnorm_preserves_dtype_fp32():
"""RMSNorm preserves float32 dtype."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
x = torch.randn(2, 4, hidden_size, dtype=torch.float32)
out = norm(x)
assert out.dtype == torch.float32
def test_rmsnorm_preserves_dtype_fp16():
"""RMSNorm preserves float16 dtype."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
x = torch.randn(2, 4, hidden_size, dtype=torch.float16)
out = norm(x)
assert out.dtype == torch.float16
def test_rmsnorm_preserves_dtype_bf16():
"""RMSNorm preserves bfloat16 dtype."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
x = torch.randn(2, 4, hidden_size, dtype=torch.bfloat16)
out = norm(x)
assert out.dtype == torch.bfloat16
def test_rmsnorm_custom_eps():
"""RMSNorm accepts custom epsilon value."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
eps = 1e-5
norm = RMSNorm(hidden_size, eps=eps)
assert norm.variance_epsilon == eps
def test_rmsnorm_has_weight_parameter():
"""RMSNorm has learnable weight parameter initialized to ones."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
assert norm.weight is not None
assert norm.weight.shape == (hidden_size,)
torch.testing.assert_close(norm.weight, torch.ones(hidden_size))
def test_rmsnorm_numerical_correctness():
"""Verify RMSNorm produces numerically correct output."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
eps = 1e-6
torch.manual_seed(42)
norm = RMSNorm(hidden_size, eps=eps)
x = torch.randn(2, 4, hidden_size)
# Compute expected output manually
x_fp32 = x.to(torch.float32)
variance = x_fp32.pow(2).mean(-1, keepdim=True)
expected = x_fp32 * torch.rsqrt(variance + eps)
expected = norm.weight.to(torch.float32) * expected
expected = expected.to(x.dtype)
out = norm(x)
torch.testing.assert_close(out, expected, atol=1e-5, rtol=1e-5)
def test_rmsnorm_matches_reference_implementation():
"""Verify RMSNorm matches a reference implementation."""
from vllm_omni.diffusion.layers.norm import RMSNorm
def reference_rmsnorm(x, weight, eps):
"""Reference RMSNorm implementation."""
input_dtype = x.dtype
x = x.to(torch.float32)
variance = x.pow(2).mean(-1, keepdim=True)
out = x * torch.rsqrt(variance + eps)
out = weight.to(torch.float32) * out
return out.to(input_dtype)
hidden_size = 128
eps = 1e-6
torch.manual_seed(123)
norm = RMSNorm(hidden_size, eps=eps)
# Test with various dtypes
for dtype in [torch.float32, torch.float16, torch.bfloat16]:
x = torch.randn(4, 8, hidden_size, dtype=dtype)
expected = reference_rmsnorm(x, norm.weight, eps)
out = norm(x)
torch.testing.assert_close(out, expected, atol=1e-3, rtol=1e-3)
# ── CustomOp dispatch tests ──
def test_layernorm_inherits_from_customop():
"""LayerNorm inherits from CustomOp for platform dispatch."""
from vllm_omni.diffusion.layers.custom_op import CustomOp
from vllm_omni.diffusion.layers.norm import LayerNorm
norm = LayerNorm(64)
assert isinstance(norm, CustomOp)
def test_rmsnorm_inherits_from_customop():
"""RMSNorm inherits from CustomOp for platform dispatch."""
from vllm_omni.diffusion.layers.custom_op import CustomOp
from vllm_omni.diffusion.layers.norm import RMSNorm
norm = RMSNorm(64)
assert isinstance(norm, CustomOp)
def test_layernorm_has_platform_methods():
"""LayerNorm has forward methods for each platform."""
from vllm_omni.diffusion.layers.norm import LayerNorm
norm = LayerNorm(64)
assert hasattr(norm, "forward_cuda")
assert hasattr(norm, "forward_hip")
assert hasattr(norm, "forward_xpu")
assert hasattr(norm, "forward_npu")
assert hasattr(norm, "forward_native")
def test_rmsnorm_has_platform_methods():
"""RMSNorm has forward methods for each platform."""
from vllm_omni.diffusion.layers.norm import RMSNorm
norm = RMSNorm(64)
assert hasattr(norm, "forward_cuda")
assert hasattr(norm, "forward_hip")
assert hasattr(norm, "forward_xpu")
assert hasattr(norm, "forward_npu")
assert hasattr(norm, "forward_native")
def test_layernorm_forward_native_directly():
"""LayerNorm.forward_native can be called directly."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
x = torch.randn(2, 4, dim)
out = norm.forward_native(x)
assert out.shape == (2, 4, dim)
def test_rmsnorm_forward_native_directly():
"""RMSNorm.forward_native can be called directly."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
x = torch.randn(2, 4, hidden_size)
out = norm.forward_native(x)
assert out.shape == (2, 4, hidden_size)
# ── Edge case tests ──
def test_layernorm_with_large_dim():
"""LayerNorm works with large hidden dimensions."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 4096
norm = LayerNorm(dim)
x = torch.randn(1, 16, dim)
out = norm(x)
assert out.shape == (1, 16, dim)
def test_rmsnorm_with_large_dim():
"""RMSNorm works with large hidden dimensions."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 4096
norm = RMSNorm(hidden_size)
x = torch.randn(1, 16, hidden_size)
out = norm(x)
assert out.shape == (1, 16, hidden_size)
def test_layernorm_with_single_element_batch():
"""LayerNorm works with batch size of 1."""
from vllm_omni.diffusion.layers.norm import LayerNorm
dim = 64
norm = LayerNorm(dim)
x = torch.randn(1, 1, dim)
out = norm(x)
assert out.shape == (1, 1, dim)
def test_rmsnorm_with_single_element_batch():
"""RMSNorm works with batch size of 1."""
from vllm_omni.diffusion.layers.norm import RMSNorm
hidden_size = 64
norm = RMSNorm(hidden_size)
x = torch.randn(1, 1, hidden_size)
out = norm(x)
assert out.shape == (1, 1, hidden_size)
@@ -0,0 +1,112 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
Numerical equivalence tests for rotary embedding implementations (#2436).
Verifies that the optimized stack+flatten RoPE produces bit-identical results
to the original strided-slice implementation across various tensor shapes and
dtypes, ensuring the refactor is safe.
"""
from __future__ import annotations
import pytest
import torch
def _apply_rotary_emb_helios_original(
hidden_states: torch.Tensor,
freqs_cis: torch.Tensor,
) -> torch.Tensor:
"""Original Helios RoPE using strided slice assignment (pre-#2436)."""
x_1, x_2 = hidden_states.unflatten(-1, (-1, 2)).unbind(-1)
cos, sin = freqs_cis.unsqueeze(-2).chunk(2, dim=-1)
out = torch.empty_like(hidden_states)
out[..., 0::2] = x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2]
out[..., 1::2] = x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2]
return out.type_as(hidden_states)
def _apply_rotary_emb_helios_optimized(
hidden_states: torch.Tensor,
freqs_cis: torch.Tensor,
) -> torch.Tensor:
"""Optimized Helios RoPE using stack+flatten (post-#2436)."""
x_1, x_2 = hidden_states.unflatten(-1, (-1, 2)).unbind(-1)
cos, sin = freqs_cis.unsqueeze(-2).chunk(2, dim=-1)
rotated = torch.stack(
(
x_1 * cos[..., 0::2] - x_2 * sin[..., 1::2],
x_1 * sin[..., 1::2] + x_2 * cos[..., 0::2],
),
dim=-1,
)
return rotated.flatten(-2, -1).type_as(hidden_states)
def _make_inputs(
batch: int,
seq_len: int,
num_heads: int,
head_dim: int,
dtype: torch.dtype = torch.float32,
) -> tuple[torch.Tensor, torch.Tensor]:
"""Generate random hidden_states and freqs_cis for testing."""
torch.manual_seed(42)
hidden_states = torch.randn(batch, seq_len, num_heads, head_dim, dtype=dtype)
# freqs_cis: [B, seq, head_dim*2] — cos and sin concatenated along last dim
freqs_cis = torch.randn(batch, seq_len, head_dim * 2, dtype=dtype)
return hidden_states, freqs_cis
class TestHeliosRoPEEquivalence:
"""Verify optimized Helios RoPE is numerically identical to original."""
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
def test_equivalence_across_dtypes(self, dtype: torch.dtype) -> None:
"""Optimized output must be bit-identical to original across dtypes."""
hidden, freqs = _make_inputs(2, 16, 8, 64, dtype=dtype)
original = _apply_rotary_emb_helios_original(hidden, freqs)
optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
torch.testing.assert_close(optimized, original, atol=0, rtol=0)
@pytest.mark.parametrize(
"batch,seq_len,num_heads,head_dim",
[
(1, 8, 1, 32), # minimal: single batch, single head
(2, 16, 8, 64), # typical transformer config
(1, 8192, 4, 64), # video-scale patch tokens (720p DiT)
(4, 32, 16, 128), # large head_dim
],
)
def test_equivalence_across_shapes(self, batch: int, seq_len: int, num_heads: int, head_dim: int) -> None:
"""Equivalence must hold across different tensor shapes."""
hidden, freqs = _make_inputs(batch, seq_len, num_heads, head_dim)
original = _apply_rotary_emb_helios_original(hidden, freqs)
optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
torch.testing.assert_close(optimized, original, atol=0, rtol=0)
def test_output_contiguous(self) -> None:
"""Optimized output should be contiguous in memory."""
hidden, freqs = _make_inputs(2, 16, 8, 64)
optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
assert optimized.is_contiguous()
def test_output_shape_preserved(self) -> None:
"""Output shape must match input shape."""
hidden, freqs = _make_inputs(2, 16, 8, 64)
optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
assert optimized.shape == hidden.shape
def test_output_dtype_preserved(self) -> None:
"""Output dtype must match input dtype."""
hidden, freqs = _make_inputs(2, 16, 8, 64, dtype=torch.float16)
optimized = _apply_rotary_emb_helios_optimized(hidden, freqs)
assert optimized.dtype == hidden.dtype
def test_odd_head_dim_raises(self) -> None:
"""Odd head_dim should fail at unflatten (not a valid RoPE config)."""
hidden = torch.randn(1, 4, 2, 63)
freqs = torch.randn(1, 4, 126)
with pytest.raises(RuntimeError):
_apply_rotary_emb_helios_optimized(hidden, freqs)
File renamed without changes.
+1 -1
View File
@@ -8,7 +8,7 @@ import torch
from vllm.lora.lora_weights import LoRALayerWeights
from vllm.lora.utils import get_supported_lora_modules
from tests.diffusion.lora.conftest import (
from tests.diffusion.lora.helpers import (
DummyBaseLayerWithLoRA,
FakeLinearBase,
fake_replace_submodule,
@@ -11,7 +11,7 @@ import pytest
import torch
from safetensors.torch import save_file
from tests.diffusion.lora.conftest import (
from tests.diffusion.lora.helpers import (
DummyBaseLayerWithLoRA,
FakeLinearBase,
fake_replace_submodule,
Whitespace-only changes.
@@ -0,0 +1,180 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest.mock import MagicMock, patch
import pytest
import torch
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest, OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# DMD2 subclass → immediate base pipeline whose __init__ loads model weights (mocked in tests).
_DMD2_BASE = {
WanT2VDMD2Pipeline: Wan22Pipeline,
WanI2VDMD2Pipeline: Wan22I2VPipeline,
LTX2T2VDMD2Pipeline: LTX2Pipeline,
LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline,
}
def _make_pipeline(cls):
"""Run the DMD2 __init__ with the base pipeline mocked out (no model weights loaded)."""
base = _DMD2_BASE[cls]
od_config = MagicMock()
od_config.model = "/nonexistent"
def _mock_base_init(self, *a, **kw):
self.od_config = od_config
with patch.object(base, "__init__", _mock_base_init):
pipeline = object.__new__(cls)
torch.nn.Module.__init__(pipeline)
cls.__init__(pipeline, od_config=od_config)
return pipeline
def _make_request(prompts=None, **sp_kwargs) -> OmniDiffusionRequest:
sp = OmniDiffusionSamplingParams(**sp_kwargs)
return OmniDiffusionRequest(
prompts=prompts or [{"prompt": "a cat dancing"}],
sampling_params=sp,
)
@pytest.fixture(
params=list(_DMD2_BASE.keys()),
ids=["wan_t2v", "wan_i2v", "ltx2_t2v", "ltx2_i2v"],
)
def pipeline(request):
return _make_pipeline(request.param)
# ---------------------------------------------------------------------------
# num_inference_steps
# ---------------------------------------------------------------------------
def test_num_inference_steps_forced_to_dmd2_value(pipeline):
req = _make_request(num_inference_steps=40)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.num_inference_steps == pipeline.num_inference_steps
def test_num_inference_steps_already_correct(pipeline):
req = _make_request(num_inference_steps=pipeline.num_inference_steps)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.num_inference_steps == pipeline.num_inference_steps
# ---------------------------------------------------------------------------
# guidance_scale
# ---------------------------------------------------------------------------
def test_guidance_scale_forced_to_one(pipeline):
req = _make_request(guidance_scale=5.0, guidance_scale_provided=True)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
assert req.sampling_params.guidance_scale_provided is False
def test_guidance_scale_already_correct(pipeline):
req = _make_request(guidance_scale=pipeline.dmd2_guidance_scale, guidance_scale_provided=False)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
def test_guidance_scale_provided_flag_cleared(pipeline):
"""guidance_scale_provided=True must be cleared even if scale is already dmd2_guidance_scale."""
req = _make_request(guidance_scale=pipeline.dmd2_guidance_scale, guidance_scale_provided=True)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale_provided is False
def test_guidance_scale_2_cleared(pipeline):
req = _make_request(guidance_scale_2=3.0)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale_2 is None
def test_guidance_scale_2_unset_unchanged(pipeline):
req = _make_request()
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale_2 is None
def test_true_cfg_scale_cleared(pipeline):
req = _make_request(true_cfg_scale=2.0)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.true_cfg_scale is None
def test_do_classifier_free_guidance_forced_false(pipeline):
req = _make_request(do_classifier_free_guidance=True)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.do_classifier_free_guidance is False
def test_is_cfg_negative_forced_false(pipeline):
req = _make_request(is_cfg_negative=True)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.is_cfg_negative is False
def test_negative_prompt_stripped_from_prompt_dict(pipeline):
req = _make_request(prompts=[{"prompt": "a cat", "negative_prompt": "blurry"}])
pipeline._sanitize_dmd2_request(req)
assert "negative_prompt" not in req.prompts[0]
assert req.prompts[0]["prompt"] == "a cat"
def test_no_negative_prompt_unchanged(pipeline):
req = _make_request(prompts=[{"prompt": "a cat"}])
pipeline._sanitize_dmd2_request(req)
assert req.prompts[0] == {"prompt": "a cat"}
def test_string_prompt_not_mutated(pipeline):
"""String prompts (not dicts) must pass through unchanged."""
req = _make_request(prompts=["a cat dancing"])
pipeline._sanitize_dmd2_request(req)
assert req.prompts == ["a cat dancing"]
def test_multiple_prompts_all_sanitized(pipeline):
req = _make_request(
prompts=[
{"prompt": "a cat", "negative_prompt": "blurry"},
{"prompt": "a dog", "negative_prompt": "ugly"},
]
)
pipeline._sanitize_dmd2_request(req)
for p in req.prompts:
assert "negative_prompt" not in p
# ---------------------------------------------------------------------------
# Clean request — nothing changes
# ---------------------------------------------------------------------------
def test_clean_request_no_changes(pipeline):
req = _make_request(
guidance_scale=pipeline.dmd2_guidance_scale,
guidance_scale_provided=False,
do_classifier_free_guidance=False,
is_cfg_negative=False,
)
pipeline._sanitize_dmd2_request(req)
assert req.sampling_params.guidance_scale == pipeline.dmd2_guidance_scale
assert req.sampling_params.guidance_scale_provided is False
assert req.sampling_params.guidance_scale_2 is None
assert req.sampling_params.true_cfg_scale is None
assert req.sampling_params.do_classifier_free_guidance is False
assert req.sampling_params.is_cfg_negative is False
@@ -0,0 +1,93 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from unittest.mock import MagicMock, patch
import pytest
import torch
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline, LTX2T2VDMD2Pipeline
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2_image2video import LTX2I2VDMD2Pipeline, LTX2ImageToVideoPipeline
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline, WanT2VDMD2Pipeline
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import Wan22I2VPipeline, WanI2VDMD2Pipeline
from vllm_omni.diffusion.request import OmniDiffusionRequest, OmniDiffusionSamplingParams
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
_DMD2_TIMESTEPS = [999, 937, 833, 624]
# DMD2 subclass → immediate base pipeline whose __init__ loads model weights (mocked in tests).
_DMD2_BASE = {
WanT2VDMD2Pipeline: Wan22Pipeline,
WanI2VDMD2Pipeline: Wan22I2VPipeline,
LTX2T2VDMD2Pipeline: LTX2Pipeline,
LTX2I2VDMD2Pipeline: LTX2ImageToVideoPipeline,
}
def _make_pipeline(cls):
"""Run the DMD2 __init__ (including __init_dmd2__) with the base pipeline mocked."""
base = _DMD2_BASE[cls]
od_config = MagicMock()
od_config.model = "/nonexistent"
def _mock_base_init(self, *a, **kw):
self.od_config = od_config # __init_dmd2__ needs this
with patch.object(base, "__init__", _mock_base_init):
pipeline = object.__new__(cls)
torch.nn.Module.__init__(pipeline)
cls.__init__(pipeline, od_config=od_config)
return pipeline
def _make_request(**sp_kwargs) -> OmniDiffusionRequest:
sp = OmniDiffusionSamplingParams(**sp_kwargs)
return OmniDiffusionRequest(prompts=[{"prompt": "a cat"}], sampling_params=sp)
@pytest.fixture(
params=list(_DMD2_BASE.keys()),
ids=["wan_t2v", "wan_i2v", "ltx2_t2v", "ltx2_i2v"],
)
def pipeline(request):
return _make_pipeline(request.param)
# ---------------------------------------------------------------------------
# forward() timestep injection
# ---------------------------------------------------------------------------
def _fake_parent_forward(self, req, *args, num_inference_steps=40, **kwargs):
"""Stub that calls set_timesteps as the real parent does."""
self.scheduler.set_timesteps(num_inference_steps, device="cpu")
return MagicMock()
def test_forward_timesteps_match_dmd2_schedule(pipeline):
"""After forward() runs, scheduler.timesteps must equal the DMD2 training schedule."""
parent = _DMD2_BASE[type(pipeline)]
# Baseline: calling set_timesteps(40) without the DMD2 override gives a different schedule
pipeline.scheduler.set_timesteps(40, device="cpu")
default_timesteps = pipeline.scheduler.timesteps.long().tolist()
assert default_timesteps == _DMD2_TIMESTEPS, (
"DMD2EulerScheduler should always return DMD2 timesteps regardless of num_steps"
)
with patch.object(parent, "forward", _fake_parent_forward):
pipeline.forward(_make_request())
assert pipeline.scheduler.timesteps.long().tolist() == _DMD2_TIMESTEPS
def test_forward_timesteps_idempotent_across_calls(pipeline):
"""Successive forward() calls must not cause scheduler state to drift."""
parent = _DMD2_BASE[type(pipeline)]
with patch.object(parent, "forward", _fake_parent_forward):
pipeline.forward(_make_request())
pipeline.forward(_make_request())
assert pipeline.scheduler.timesteps.long().tolist() == _DMD2_TIMESTEPS
@@ -2,7 +2,7 @@ import pytest
import torch
from pytest_mock import MockerFixture
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.models.flux2.flux2_transformer import (
Flux2PosEmbed,
Flux2Transformer2DModel,
@@ -0,0 +1,133 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for GLM-Image Sequence Parallelism support."""
import pytest
from vllm_omni.diffusion.data import DiffusionParallelConfig
@pytest.fixture(scope="function", autouse=True)
def setup_sp_groups(mocker):
"""Set up SP and TP groups for each test function."""
mock_get_sp_group = mocker.patch("vllm_omni.diffusion.distributed.parallel_state.get_sp_group")
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1)
mock_get_tp_group = mocker.patch("vllm.distributed.parallel_state.get_tp_group")
mock_sp_group = mocker.MagicMock()
mock_sp_group.world_size = 4
mock_get_sp_group.return_value = mock_sp_group
mock_tp_group = mocker.MagicMock()
mock_tp_group.world_size = 1
mock_get_tp_group.return_value = mock_tp_group
yield
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_glm_image_sp_plan_defined():
"""Test that _sp_plan is properly defined on GlmImageTransformer2DModel."""
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImageTransformer2DModel,
)
assert hasattr(GlmImageTransformer2DModel, "_sp_plan")
plan = GlmImageTransformer2DModel._sp_plan
assert plan is not None
# Verify plan structure
assert "prepare" in plan
assert "proj_out" in plan
def test_glm_image_sp_plan_valid():
"""Validate _sp_plan structure."""
from vllm_omni.diffusion.distributed.sp_plan import validate_sp_plan
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImageTransformer2DModel,
)
plan = GlmImageTransformer2DModel._sp_plan
validate_sp_plan(plan)
def test_glm_image_prepare_module_exists():
"""Test that GlmImagePrepare module exists."""
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImagePrepare,
)
assert GlmImagePrepare is not None
def test_glm_image_attention_accepts_parallel_config():
"""Test that GlmImageAttention accepts parallel_config parameter."""
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImageAttention,
)
parallel_config = DiffusionParallelConfig(
ulysses_degree=2,
ring_degree=2,
tensor_parallel_size=1,
sequence_parallel_size=4,
)
attn = GlmImageAttention(
dim=2560,
num_heads=64,
head_dim=40,
parallel_config=parallel_config,
)
assert attn.parallel_config is not None
assert attn.parallel_config.sequence_parallel_size == 4
def test_glm_image_transformer_block_accepts_parallel_config():
"""Test that GlmImageTransformerBlock accepts parallel_config parameter."""
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImageTransformerBlock,
)
parallel_config = DiffusionParallelConfig(
ulysses_degree=2,
ring_degree=2,
tensor_parallel_size=1,
sequence_parallel_size=4,
)
block = GlmImageTransformerBlock(
dim=2560,
num_attention_heads=64,
attention_head_dim=40,
time_embed_dim=512,
parallel_config=parallel_config,
)
assert block.attn1.parallel_config is not None
assert block.attn1.parallel_config.sequence_parallel_size == 4
def test_glm_image_has_sp_support():
"""Test that GLM-Image has SP support implemented."""
from vllm_omni.diffusion.models.glm_image.glm_image_transformer import (
GlmImageTransformer2DModel,
)
# Check that the model has parallel_config support
assert hasattr(GlmImageTransformer2DModel, "__init__")
# Verify the model can be instantiated with SP config
# This test just verifies the structure exists
# Actual SP testing requires multi-GPU setup
@pytest.mark.cuda
@pytest.mark.sp
def test_glm_image_sp_inference():
"""Test SP inference (requires multi-GPU setup)."""
pytest.skip("Requires multi-GPU SP setup")
@@ -12,7 +12,7 @@ class TestSetForwardContextNumTokens:
def test_sets_num_tokens_when_context_available(self, mocker):
"""num_tokens should be set on ForwardContext when available."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_ctx = mocker.MagicMock()
del mock_ctx.in_profile_run # simulate missing attr
@@ -26,7 +26,7 @@ class TestSetForwardContextNumTokens:
def test_sets_in_profile_run_only_if_missing(self, mocker):
"""in_profile_run should not be overwritten if already set."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_ctx = mocker.MagicMock()
mock_ctx.in_profile_run = True # already set
@@ -40,7 +40,7 @@ class TestSetForwardContextNumTokens:
def test_noop_when_context_unavailable(self, mocker):
"""Should do nothing when ForwardContext is not available."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mocker.patch.object(hunyuan_moe._vllm_fc, "is_forward_context_available", return_value=False)
mock_get = mocker.patch.object(hunyuan_moe._vllm_fc, "get_forward_context")
@@ -55,11 +55,11 @@ class TestHunyuanFusedMoEPlatformDispatch:
def test_default_platform_uses_default_impl_qualname(self, mocker):
"""HunyuanFusedMoE should resolve the impl class from the platform hook."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
mock_platform = mocker.MagicMock()
mock_platform.get_diffusion_model_impl_qualname.return_value = (
"vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
"vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
)
mocker.patch.object(
@@ -71,7 +71,7 @@ class TestHunyuanFusedMoEPlatformDispatch:
mock_impl = mocker.MagicMock()
mock_resolve.return_value = mock_impl
from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
@@ -80,7 +80,7 @@ class TestHunyuanFusedMoEPlatformDispatch:
mock_platform.prepare_diffusion_op_runtime.assert_called_once_with("hunyuan_fused_moe")
mock_platform.get_diffusion_model_impl_qualname.assert_called_once_with("hunyuan_fused_moe")
mock_resolve.assert_called_once_with(
"vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
"vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe.HunyuanFusedMoEDefault"
)
mock_impl.assert_called_once_with(prefix="")
@@ -90,7 +90,7 @@ class TestHunyuanFusedMoEFactory:
def test_new_delegates_to_impl_class(self, mocker):
"""HunyuanFusedMoE(prefix=..., **kwargs) should instantiate and return impl instance."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
class MockImpl:
def __init__(self, *, prefix: str = "", **kwargs):
@@ -104,7 +104,7 @@ class TestHunyuanFusedMoEFactory:
mock_impl_class = mocker.MagicMock(return_value=MockImpl(prefix="test", a=1))
mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class)
from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
@@ -119,7 +119,7 @@ class TestHunyuanFusedMoEFactory:
def test_make_expert_params_mapping_delegates_to_impl(self, mocker):
"""make_expert_params_mapping should delegate to impl class method."""
import vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe as hunyuan_moe
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe as hunyuan_moe
expected_mapping = [("a", "b", 0, "c")]
mock_platform = mocker.MagicMock()
@@ -130,7 +130,7 @@ class TestHunyuanFusedMoEFactory:
mock_impl_class.make_expert_params_mapping = mocker.MagicMock(return_value=expected_mapping)
mocker.patch.object(hunyuan_moe, "resolve_obj_by_qualname", return_value=mock_impl_class)
from vllm_omni.diffusion.models.hunyuan_image_3.hunyuan_fused_moe import (
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_fused_moe import (
HunyuanFusedMoE,
)
@@ -0,0 +1,190 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for HunyuanImage3 AR sampler logic (stage transitions,
ratio restriction, comprehension blocking)."""
import pytest
import torch
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
# Fake token IDs for testing (avoid importing the real model).
END_OF_THINK = 100
RECAPTION = 101
END_OF_RECAPTION = 102
ANSWER = 103
BOI = 104
SIZE_TOKEN = 105
EOS = 106
RATIO_START = 200
RATIO_END = 210
RATIO_OTHER_START = 220
RATIO_OTHER_END = 223
class FakeSamplerModel:
"""Minimal stub that replicates the sampler-relevant attributes of
HunyuanImage3ForConditionalGeneration without loading real weights."""
def __init__(self, *, is_comprehension: bool = False):
self._is_comprehension = is_comprehension
self._eos_token_id = EOS
self._end_of_think_id = END_OF_THINK
self._recaption_id = RECAPTION
self._end_of_recaption_id = END_OF_RECAPTION
self._answer_id = ANSWER
self._mrope_boi_token_id = BOI
self._size_token_id = SIZE_TOKEN
self._start_ratio_id = RATIO_START
self._end_ratio_id = RATIO_END
self._ratio_other_slices = [(RATIO_OTHER_START, RATIO_OTHER_END + 1)]
self._all_ratio_ids = set(range(RATIO_START, RATIO_END + 1))
self._all_ratio_ids.update(range(RATIO_OTHER_START, RATIO_OTHER_END + 1))
self._stage_transitions: dict[int, list[int]] = {}
if not is_comprehension:
self._stage_transitions[END_OF_THINK] = [RECAPTION]
self._stage_transitions[END_OF_RECAPTION] = [ANSWER, BOI, SIZE_TOKEN]
self._blocked_token_ids: set[int] = set()
if is_comprehension:
self._blocked_token_ids.update([BOI, SIZE_TOKEN])
self._blocked_token_ids.update(self._all_ratio_ids)
# Bind the real methods from the model class.
from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
HunyuanImage3ForConditionalGeneration as _Real,
)
_get_forced_token = _Real._get_forced_token
_apply_ratio_restriction = _Real._apply_ratio_restriction
class TestGetForcedToken:
"""Tests for the stateless _get_forced_token method."""
def setup_method(self):
self.model = FakeSamplerModel(is_comprehension=False)
def test_no_trigger_returns_none(self):
assert self.model._get_forced_token([1, 2, 3]) is None
def test_empty_history_returns_none(self):
assert self.model._get_forced_token([]) is None
def test_end_of_think_forces_recaption(self):
assert self.model._get_forced_token([END_OF_THINK]) == RECAPTION
def test_end_of_think_completed(self):
assert self.model._get_forced_token([END_OF_THINK, RECAPTION]) is None
def test_end_of_recaption_forces_answer(self):
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
assert self.model._get_forced_token(tokens) == ANSWER
def test_end_of_recaption_forces_boi_after_answer(self):
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER]
assert self.model._get_forced_token(tokens) == BOI
def test_end_of_recaption_forces_size_after_boi(self):
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI]
assert self.model._get_forced_token(tokens) == SIZE_TOKEN
def test_full_sequence_complete(self):
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION, ANSWER, BOI, SIZE_TOKEN]
assert self.model._get_forced_token(tokens) is None
def test_diverged_history_returns_none(self):
tokens = [END_OF_RECAPTION, 999] # 999 != ANSWER
assert self.model._get_forced_token(tokens) is None
def test_later_trigger_takes_precedence(self):
tokens = [END_OF_THINK, RECAPTION, END_OF_RECAPTION]
assert self.model._get_forced_token(tokens) == ANSWER
def test_trigger_with_extra_tokens_before(self):
tokens = [1, 2, 3, END_OF_THINK]
assert self.model._get_forced_token(tokens) == RECAPTION
class TestComprehensionBlocking:
"""Tests for comprehension mode token blocking."""
def test_blocked_tokens_masked(self):
model = FakeSamplerModel(is_comprehension=True)
vocab_size = 300
logits = torch.zeros(1, vocab_size)
logits[0, BOI] = 5.0
logits[0, SIZE_TOKEN] = 3.0
logits[0, RATIO_START] = 2.0
min_score = torch.finfo(logits.dtype).min
for tid in model._blocked_token_ids:
if tid < vocab_size:
logits[0, tid] = min_score
assert logits[0, BOI].item() == min_score
assert logits[0, SIZE_TOKEN].item() == min_score
assert logits[0, RATIO_START].item() == min_score
def test_non_blocked_tokens_preserved(self):
model = FakeSamplerModel(is_comprehension=True)
vocab_size = 300
logits = torch.zeros(1, vocab_size)
logits[0, 50] = 7.0
min_score = torch.finfo(logits.dtype).min
for tid in model._blocked_token_ids:
if tid < vocab_size:
logits[0, tid] = min_score
assert logits[0, 50].item() == 7.0
class TestRatioRestriction:
"""Tests for _apply_ratio_restriction (greedy: only argmax ratio survives)."""
def test_greedy_selects_single_ratio_token(self):
model = FakeSamplerModel(is_comprehension=False)
vocab_size = 300
logits = torch.zeros(1, vocab_size)
logits[0, RATIO_START + 3] = 10.0
logits[0, RATIO_START + 1] = 5.0
logits[0, 50] = 20.0 # non-ratio, should be masked
min_score = torch.finfo(logits.dtype).min
model._apply_ratio_restriction(logits, 0, min_score)
assert logits[0, RATIO_START + 3].item() == 0
assert logits[0, RATIO_START + 1].item() == min_score
assert logits[0, 50].item() == min_score
def test_extra_ratio_slices_considered(self):
model = FakeSamplerModel(is_comprehension=False)
vocab_size = 300
logits = torch.zeros(1, vocab_size)
logits[0, RATIO_OTHER_START] = 15.0
logits[0, RATIO_START] = 5.0
min_score = torch.finfo(logits.dtype).min
model._apply_ratio_restriction(logits, 0, min_score)
assert logits[0, RATIO_OTHER_START].item() == 0
assert logits[0, RATIO_START].item() == min_score
class TestForceEosAfterRatio:
"""Tests that a ratio token as last_token forces EOS."""
def test_ratio_token_forces_eos(self):
model = FakeSamplerModel(is_comprehension=False)
vocab_size = 300
logits = torch.randn(1, vocab_size)
min_score = torch.finfo(logits.dtype).min
logits[0].fill_(min_score)
logits[0, model._eos_token_id] = 0
assert logits[0, EOS].item() == 0
non_eos_max = logits[0, :EOS].max().item()
assert non_eos_max == min_score
@@ -0,0 +1,25 @@
import pytest
import torch.nn as nn
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_ltx2_exposes_hsdp_shard_conditions_for_transformer_blocks():
model = object.__new__(LTX2VideoTransformer3DModel)
nn.Module.__init__(model)
model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
model.norm_out = nn.LayerNorm(4)
conditions = getattr(model, "_hsdp_shard_conditions", None)
assert conditions is not None
assert len(conditions) == 1
matched = []
for name, module in model.named_modules():
if any(cond(name, module) for cond in conditions):
matched.append(name)
assert matched == ["transformer_blocks.0", "transformer_blocks.1"]
@@ -0,0 +1,38 @@
# SPDX-License-Identifier: Apache-2.0
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from PIL import Image
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit_plus import (
get_qwen_image_edit_plus_pre_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
def test_qwen_image_edit_plus_rejects_too_many_input_images(tmp_path: Path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
# Keep the mock config intentionally minimal: this test only needs the
# fields touched during pre-process initialization.
(vae_dir / "config.json").write_text(json.dumps({"z_dim": 16}))
pre_process = get_qwen_image_edit_plus_pre_process_func(SimpleNamespace(model=str(tmp_path)))
image = Image.fromarray(np.zeros((32, 32, 3), dtype=np.uint8))
request = SimpleNamespace(
prompts=[
{
"prompt": "combine",
"multi_modal_data": {"image": [image, image, image, image, image]},
}
],
sampling_params=SimpleNamespace(height=None, width=None),
)
with pytest.raises(ValueError, match=r"At most 4 images are supported by this model"):
pre_process(request)
@@ -0,0 +1,260 @@
import inspect
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import (
QwenImagePipeline,
)
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import (
QwenImageEditPipeline,
)
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit_plus import (
QwenImageEditPlusPipeline,
)
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_layered import (
QwenImageLayeredPipeline,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
class _RejectingTextEncoder:
dtype = torch.float32
def __call__(self, *args, **kwargs):
raise AssertionError("text encoder should not run for prompts that exceed max_sequence_length")
class _FakeModelInputs:
def __init__(self, total_sequence_length: int):
attention_mask = torch.ones((1, total_sequence_length), dtype=torch.long)
self.input_ids = attention_mask.clone()
self.attention_mask = attention_mask
self.pixel_values = None
self.image_grid_thw = None
def to(self, device):
return self
class _FakeTokenizer:
def __init__(self, total_sequence_length: int | list[int]):
if isinstance(total_sequence_length, list):
self.total_sequence_lengths = list(total_sequence_length)
else:
self.total_sequence_lengths = [total_sequence_length]
def __call__(self, *args, **kwargs):
if len(self.total_sequence_lengths) > 1:
total_sequence_length = self.total_sequence_lengths.pop(0)
else:
total_sequence_length = self.total_sequence_lengths[0]
return _FakeModelInputs(total_sequence_length)
class _FakeProcessor(_FakeTokenizer):
pass
class _FakeScheduler:
def __init__(self):
self.begin_index = None
def set_begin_index(self, begin_index: int):
self.begin_index = begin_index
PIPELINE_CASES = [
pytest.param(QwenImagePipeline, 34, "tokenizer", id="qwen-image"),
pytest.param(QwenImageLayeredPipeline, 34, "tokenizer", id="qwen-image-layered"),
pytest.param(QwenImageEditPipeline, 64, "processor", id="qwen-image-edit"),
pytest.param(QwenImageEditPlusPipeline, 64, "processor", id="qwen-image-edit-plus"),
]
def _make_pipeline(
pipeline_class: type,
*,
total_sequence_length: int,
drop_idx: int,
input_kind: str,
):
pipeline = object.__new__(pipeline_class)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.text_encoder = _RejectingTextEncoder()
pipeline.tokenizer_max_length = 1024
pipeline.prompt_template_encode = "{}"
pipeline.prompt_template_encode_start_idx = drop_idx
pipeline.tokenizer = _FakeTokenizer([total_sequence_length, 0])
if input_kind == "processor":
pipeline.processor = _FakeProcessor(total_sequence_length)
return pipeline
@pytest.mark.parametrize(("pipeline_class", "drop_idx", "input_kind"), PIPELINE_CASES)
def test_encode_prompt_rejects_prompt_longer_than_default_max_sequence_length(
pipeline_class: type,
drop_idx: int,
input_kind: str,
):
pipeline = _make_pipeline(
pipeline_class,
total_sequence_length=1025,
drop_idx=drop_idx,
input_kind=input_kind,
)
with pytest.raises(ValueError, match=r"got 1025 tokens, but `max_sequence_length` is 1024"):
pipeline.encode_prompt(prompt="prompt")
@pytest.mark.parametrize(("pipeline_class", "drop_idx", "input_kind"), PIPELINE_CASES)
def test_encode_prompt_rejects_prompt_longer_than_explicit_max_sequence_length(
pipeline_class: type,
drop_idx: int,
input_kind: str,
):
pipeline = _make_pipeline(
pipeline_class,
total_sequence_length=17,
drop_idx=drop_idx,
input_kind=input_kind,
)
with pytest.raises(ValueError, match=r"got 17 tokens, but `max_sequence_length` is 16"):
pipeline.encode_prompt(prompt="prompt", max_sequence_length=16)
def test_prepare_encode_defaults_to_tokenizer_max_length():
pipeline = object.__new__(QwenImagePipeline)
nn.Module.__init__(pipeline)
pipeline.tokenizer_max_length = 1024
pipeline.vae_scale_factor = 8
pipeline.default_sample_size = 128
pipeline.scheduler = _FakeScheduler()
pipeline._extract_prompts = lambda prompts: (["prompt"], None)
captured = {}
def _fake_prepare_generation_context(**kwargs):
captured["max_sequence_length"] = kwargs["max_sequence_length"]
embeds = torch.ones((1, 1, 1))
mask = torch.ones((1, 1), dtype=torch.long)
return {
"prompt_embeds": embeds,
"prompt_embeds_mask": mask,
"negative_prompt_embeds": None,
"negative_prompt_embeds_mask": None,
"latents": embeds,
"timesteps": torch.tensor([1]),
"do_true_cfg": False,
"guidance": None,
"img_shapes": [[(1, 1, 1)]],
"txt_seq_lens": [1],
"negative_txt_seq_lens": None,
}
pipeline._prepare_generation_context = _fake_prepare_generation_context
state = SimpleNamespace(
prompts=["prompt"],
sampling=SimpleNamespace(
height=None,
width=None,
num_inference_steps=None,
sigmas=None,
guidance_scale_provided=False,
num_outputs_per_prompt=0,
generator=None,
true_cfg_scale=None,
max_sequence_length=None,
),
)
pipeline.prepare_encode(state)
assert captured["max_sequence_length"] == 1024
@pytest.mark.parametrize(
("pipeline_class", "drop_idx"),
[
pytest.param(QwenImageEditPipeline, 64, id="qwen-image-edit"),
pytest.param(QwenImageEditPlusPipeline, 64, id="qwen-image-edit-plus"),
],
)
def test_edit_pipelines_validate_text_prompt_length_before_image_token_expansion(
pipeline_class: type,
drop_idx: int,
):
pipeline = object.__new__(pipeline_class)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.text_encoder = _RejectingTextEncoder()
pipeline.tokenizer_max_length = 1024
pipeline.prompt_template_encode = "{}"
pipeline.prompt_template_encode_start_idx = drop_idx
pipeline.tokenizer = _FakeTokenizer([8, 0])
pipeline.processor = _FakeProcessor(drop_idx + 1500)
with pytest.raises(AssertionError, match="text encoder should not run"):
pipeline.encode_prompt(prompt="short prompt")
@pytest.mark.parametrize(
"pipeline_class",
[
pytest.param(QwenImagePipeline, id="qwen-image"),
pytest.param(QwenImageLayeredPipeline, id="qwen-image-layered"),
],
)
def test_qwen_generation_validator_excludes_template_suffix_from_budget(pipeline_class: type):
pipeline = object.__new__(pipeline_class)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.text_encoder = _RejectingTextEncoder()
pipeline.tokenizer_max_length = 1024
pipeline.prompt_template_encode = "{}"
pipeline.prompt_template_encode_start_idx = 34
pipeline.tokenizer = _FakeTokenizer([1029, 5])
with pytest.raises(AssertionError, match="text encoder should not run"):
pipeline.encode_prompt(prompt="boundary prompt")
@pytest.mark.parametrize(
"pipeline_class",
[
pytest.param(QwenImageEditPipeline, id="qwen-image-edit"),
pytest.param(QwenImageEditPlusPipeline, id="qwen-image-edit-plus"),
],
)
def test_qwen_edit_validator_excludes_image_placeholders_from_budget(pipeline_class: type):
pipeline = object.__new__(pipeline_class)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.text_encoder = _RejectingTextEncoder()
pipeline.tokenizer_max_length = 1024
pipeline.prompt_template_encode = "{}"
pipeline.prompt_template_encode_start_idx = 64
pipeline.tokenizer = _FakeTokenizer([30, 20])
pipeline.processor = _FakeProcessor(1500)
with pytest.raises(AssertionError, match="text encoder should not run"):
pipeline.encode_prompt(prompt="short prompt")
@pytest.mark.parametrize(
"pipeline_class",
[
QwenImagePipeline,
QwenImageLayeredPipeline,
QwenImageEditPipeline,
QwenImageEditPlusPipeline,
],
)
def test_forward_max_sequence_length_default_is_1024(pipeline_class: type):
assert inspect.signature(pipeline_class.forward).parameters["max_sequence_length"].default == 1024
@@ -0,0 +1,25 @@
import pytest
import torch.nn as nn
from vllm_omni.diffusion.models.stable_audio.stable_audio_transformer import StableAudioDiTModel
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def test_stable_audio_exposes_hsdp_shard_conditions_for_transformer_blocks():
model = object.__new__(StableAudioDiTModel)
nn.Module.__init__(model)
model.transformer_blocks = nn.ModuleList([nn.Linear(4, 4) for _ in range(2)])
model.proj_out = nn.Linear(4, 4)
conditions = getattr(model, "_hsdp_shard_conditions", None)
assert conditions is not None
assert len(conditions) == 1
matched = []
for name, module in model.named_modules():
if any(cond(name, module) for cond in conditions):
matched.append(name)
assert matched == ["transformer_blocks.0", "transformer_blocks.1"]
Whitespace-only changes.
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from contextlib import contextmanager
from types import SimpleNamespace
import torch
from torch import nn
class StubTransformer(nn.Module):
def __init__(self, *, name: str = "transformer", in_channels: int = 4, out_channels: int = 4) -> None:
super().__init__()
self.name = name
self.config = SimpleNamespace(
patch_size=(1, 2, 2),
in_channels=in_channels,
out_channels=out_channels,
image_dim=None,
)
@property
def dtype(self) -> torch.dtype:
return torch.float32
def forward(self, **kwargs):
hidden_states = kwargs["hidden_states"]
return (torch.zeros_like(hidden_states[:, : self.config.out_channels]),)
class StubScheduler:
def __init__(self, timesteps: list[int]) -> None:
self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
self.config = SimpleNamespace(num_train_timesteps=1000)
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
self.set_timesteps_calls.append((num_steps, device))
class StubVAE:
dtype = torch.float32
def __init__(self, z_dim: int = 4) -> None:
self.config = SimpleNamespace(
z_dim=z_dim,
scale_factor_temporal=4,
scale_factor_spatial=8,
latents_mean=[0.0] * z_dim,
latents_std=[1.0] * z_dim,
)
def encode(self, video: torch.Tensor):
latent_frames = (video.shape[2] + self.config.scale_factor_temporal - 1) // self.config.scale_factor_temporal
latent_height = video.shape[-2] // self.config.scale_factor_spatial
latent_width = video.shape[-1] // self.config.scale_factor_spatial
latents = torch.ones(
video.shape[0],
self.config.z_dim,
latent_frames,
latent_height,
latent_width,
dtype=video.dtype,
device=video.device,
)
return SimpleNamespace(latents=latents)
def decode(self, latents: torch.Tensor, return_dict: bool = False):
del return_dict
return (latents,)
@contextmanager
def noop_progress_bar(*args, **kwargs):
del args, kwargs
class Bar:
def update(self) -> None:
return None
yield Bar()
@@ -0,0 +1,125 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
from torch import nn
from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_i2v import (
Wan22I2VPipeline,
get_wan22_i2v_pre_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _make_i2v_pipeline(*, expand_timesteps: bool) -> Wan22I2VPipeline:
pipeline = object.__new__(Wan22I2VPipeline)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = StubTransformer(name="high", in_channels=8, out_channels=4)
pipeline.transformer_2 = StubTransformer(name="low", in_channels=8, out_channels=4)
pipeline.vae = StubVAE(z_dim=4)
pipeline.vae_scale_factor_temporal = 4
pipeline.vae_scale_factor_spatial = 8
pipeline.expand_timesteps = expand_timesteps
pipeline.progress_bar = noop_progress_bar
return pipeline
def test_i2v_preprocess_requires_image_and_resizes_to_480p_aspect() -> None:
preprocess = get_wan22_i2v_pre_process_func(SimpleNamespace())
request = SimpleNamespace(
prompts=[{"prompt": "p", "multi_modal_data": {"image": Image.new("RGB", (320, 160), "red")}}],
sampling_params=SimpleNamespace(height=None, width=None),
)
result = preprocess(request)
prompt = result.prompts[0]
assert result.sampling_params.height == 432
assert result.sampling_params.width == 880
assert prompt["multi_modal_data"]["image"].size == (880, 432)
missing_image = SimpleNamespace(
prompts=[{"prompt": "p", "multi_modal_data": {}}],
sampling_params=SimpleNamespace(height=None, width=None),
)
with pytest.raises(ValueError, match="No image is provided"):
preprocess(missing_image)
def test_i2v_diffuse_selects_stage_guidance_and_expands_timesteps() -> None:
pipeline = _make_i2v_pipeline(expand_timesteps=True)
latents = torch.zeros(1, 4, 2, 4, 4)
condition = torch.ones_like(latents)
first_frame_mask = torch.ones(1, 1, 2, 4, 4)
first_frame_mask[:, :, 0] = 0
timesteps = torch.tensor([900, 100])
calls = []
def fake_predict_noise_maybe_with_cfg(**kwargs):
positive = kwargs["positive_kwargs"]
calls.append(
{
"model": positive["current_model"].name,
"scale": kwargs["true_cfg_scale"],
"timestep_shape": tuple(positive["timestep"].shape),
"timestep_values": positive["timestep"].clone(),
"hidden_states": positive["hidden_states"].clone(),
}
)
return torch.ones_like(latents)
pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
result = pipeline.diffuse(
latents=latents,
timesteps=timesteps,
prompt_embeds=torch.zeros(1, 2, 3),
negative_prompt_embeds=None,
image_embeds=None,
guidance_low=1.0,
guidance_high=2.0,
boundary_timestep=500.0,
dtype=torch.float32,
attention_kwargs={},
condition=condition,
first_frame_mask=first_frame_mask,
)
assert [call["model"] for call in calls] == ["high", "low"]
assert [call["scale"] for call in calls] == [1.0, 2.0]
assert calls[0]["timestep_shape"] == (1, 8)
timestep_dtype = calls[0]["timestep_values"].dtype
torch.testing.assert_close(calls[0]["timestep_values"][0, :4], torch.zeros(4, dtype=timestep_dtype))
torch.testing.assert_close(calls[0]["timestep_values"][0, 4:], torch.full((4,), 900, dtype=timestep_dtype))
torch.testing.assert_close(calls[0]["hidden_states"][:, :, 0], torch.ones(1, 4, 4, 4))
torch.testing.assert_close(result, torch.full_like(latents, 2.0))
def test_i2v_prepare_latents_builds_expand_condition_and_first_frame_mask() -> None:
pipeline = _make_i2v_pipeline(expand_timesteps=True)
latents, condition, first_frame_mask = pipeline.prepare_latents(
image=torch.zeros(1, 3, 16, 16),
batch_size=1,
num_channels_latents=4,
height=16,
width=16,
num_frames=5,
dtype=torch.float32,
device=torch.device("cpu"),
generator=torch.Generator(device="cpu").manual_seed(0),
)
assert latents.shape == (1, 4, 2, 2, 2)
assert condition.shape == (1, 4, 1, 2, 2)
assert first_frame_mask.shape == (1, 1, 2, 2, 2)
assert first_frame_mask[:, :, 0].sum() == 0
assert first_frame_mask[:, :, 1].sum() == 4
@@ -0,0 +1,155 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from contextlib import contextmanager
from types import SimpleNamespace
import pytest
import torch
from torch import nn
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import Wan22Pipeline
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _StubTransformer(nn.Module):
@property
def dtype(self) -> torch.dtype:
return torch.float32
class _StubScheduler:
def __init__(self, timesteps: list[int]) -> None:
self.timesteps = torch.tensor(timesteps, dtype=torch.int64)
self.config = SimpleNamespace(num_train_timesteps=1000)
self.set_timesteps_calls: list[tuple[int, torch.device]] = []
def set_timesteps(self, num_steps: int, device: torch.device) -> None:
self.set_timesteps_calls.append((num_steps, device))
@contextmanager
def _noop_progress_bar(*args, **kwargs):
del args, kwargs
class _Bar:
def update(self) -> None:
return None
yield _Bar()
def _make_pipeline() -> Wan22Pipeline:
pipeline = object.__new__(Wan22Pipeline)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = _StubTransformer()
pipeline.transformer_2 = None
pipeline.transformer_config = SimpleNamespace(patch_size=(1, 2, 2), in_channels=4, out_channels=4)
pipeline.scheduler = _StubScheduler([9, 5])
pipeline.od_config = SimpleNamespace(flow_shift=5.0)
pipeline._sample_solver = "unipc"
pipeline._flow_shift = 5.0
pipeline.vae_scale_factor_temporal = 4
pipeline.vae_scale_factor_spatial = 8
pipeline.boundary_ratio = 0.875
pipeline.expand_timesteps = False
pipeline._guidance_scale = None
pipeline._guidance_scale_2 = None
pipeline._num_timesteps = None
pipeline._current_timestep = None
pipeline.check_inputs = lambda **kwargs: None
pipeline.prepare_latents = lambda **kwargs: torch.zeros((1, 4, 1, 8, 8), dtype=torch.float32)
pipeline.progress_bar = _noop_progress_bar
return pipeline
def test_forward_delegates_denoising_to_diffuse(monkeypatch) -> None:
pipeline = _make_pipeline()
prompt_embeds = torch.randn(1, 8)
captured: dict[str, object] = {}
def _fake_diffuse(**kwargs):
captured.update(kwargs)
return kwargs["latents"] + 1
pipeline.diffuse = _fake_diffuse # type: ignore[method-assign]
req = SimpleNamespace(
prompts=["prompt"],
sampling_params=SimpleNamespace(
height=None,
width=None,
num_frames=1,
num_inference_steps=2,
guidance_scale_provided=False,
guidance_scale=None,
guidance_scale_2=None,
boundary_ratio=None,
generator=None,
seed=None,
num_outputs_per_prompt=1,
max_sequence_length=32,
latents=None,
extra_args={},
),
)
output = pipeline.forward(req, prompt_embeds=prompt_embeds, output_type="latent", guidance_scale=1.0)
assert torch.equal(output.output, torch.ones((1, 4, 1, 8, 8)))
assert torch.equal(captured["timesteps"], pipeline.scheduler.timesteps)
assert captured["guidance_low"] == 1.0
assert captured["guidance_high"] == 1.0
assert captured["boundary_timestep"] == pytest.approx(875.0)
assert captured["latent_condition"] is None
assert captured["first_frame_mask"] is None
assert pipeline.scheduler.set_timesteps_calls == [(2, torch.device("cpu"))]
def test_diffuse_runs_prediction_and_scheduler_for_each_timestep() -> None:
pipeline = _make_pipeline()
latents = torch.zeros((1, 1, 1, 2, 2), dtype=torch.float32)
timesteps = torch.tensor([7, 3], dtype=torch.int64)
prompt_embeds = torch.randn(1, 8)
predict_calls: list[dict[str, object]] = []
scheduler_calls: list[tuple[float, int, float, bool]] = []
def _fake_predict_noise_maybe_with_cfg(**kwargs):
predict_calls.append(kwargs)
timestep = kwargs["positive_kwargs"]["timestep"]
assert isinstance(timestep, torch.Tensor)
return torch.full_like(latents, float(timestep[0].item()))
def _fake_scheduler_step_maybe_with_cfg(noise_pred, t, current_latents, do_true_cfg):
scheduler_calls.append(
(float(noise_pred[0, 0, 0, 0, 0]), int(t.item()), float(current_latents.sum()), do_true_cfg)
)
return current_latents + noise_pred
pipeline.predict_noise_maybe_with_cfg = _fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
pipeline.scheduler_step_maybe_with_cfg = _fake_scheduler_step_maybe_with_cfg # type: ignore[method-assign]
result = pipeline.diffuse(
latents=latents,
timesteps=timesteps,
prompt_embeds=prompt_embeds,
negative_prompt_embeds=None,
guidance_low=1.0,
guidance_high=2.0,
boundary_timestep=5.0,
dtype=torch.float32,
attention_kwargs={},
)
assert len(predict_calls) == 2
assert predict_calls[0]["true_cfg_scale"] == 1.0
assert predict_calls[1]["true_cfg_scale"] == 2.0
assert scheduler_calls == [
(7.0, 7, 0.0, False),
(3.0, 3, 28.0, False),
]
assert torch.equal(result, torch.full_like(latents, 10.0))
@@ -0,0 +1,81 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import json
from types import SimpleNamespace
import pytest
import torch
import vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 as wan22_module
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2 import (
create_transformer_from_config,
load_transformer_config,
retrieve_latents,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
class _LatentDist:
def sample(self, generator):
assert isinstance(generator, torch.Generator)
return torch.tensor([1.0])
def mode(self):
return torch.tensor([2.0])
def test_retrieve_latents_supports_sample_mode_argmax_and_direct_latents() -> None:
generator = torch.Generator(device="cpu")
assert retrieve_latents(SimpleNamespace(latent_dist=_LatentDist()), generator).item() == 1.0
assert retrieve_latents(SimpleNamespace(latent_dist=_LatentDist()), sample_mode="argmax").item() == 2.0
torch.testing.assert_close(retrieve_latents(SimpleNamespace(latents=torch.tensor([3.0]))), torch.tensor([3.0]))
def test_retrieve_latents_rejects_unknown_encoder_output() -> None:
with pytest.raises(AttributeError, match="Could not access latents"):
retrieve_latents(SimpleNamespace())
def test_load_transformer_config_reads_local_subfolder_config(tmp_path) -> None:
config_dir = tmp_path / "transformer_2"
config_dir.mkdir(parents=True)
(config_dir / "config.json").write_text(json.dumps({"patch_size": [1, 2, 2], "num_layers": 2}))
assert load_transformer_config(str(tmp_path), "transformer_2") == {"patch_size": [1, 2, 2], "num_layers": 2}
assert load_transformer_config(str(tmp_path), "missing") == {}
def test_create_transformer_from_config_maps_supported_keys(monkeypatch) -> None:
captured = {}
class FakeTransformer:
def __init__(self, **kwargs) -> None:
captured.update(kwargs)
monkeypatch.setattr(wan22_module, "WanTransformer3DModel", FakeTransformer)
transformer = create_transformer_from_config(
{
"patch_size": [1, 2, 2],
"num_attention_heads": 8,
"attention_head_dim": 128,
"in_channels": 16,
"out_channels": 16,
"text_dim": 4096,
"vace_layers": [0],
"ignored": "value",
}
)
assert isinstance(transformer, FakeTransformer)
assert captured == {
"patch_size": (1, 2, 2),
"num_attention_heads": 8,
"attention_head_dim": 128,
"in_channels": 16,
"out_channels": 16,
"text_dim": 4096,
}
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
from torch import nn
from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_ti2v import (
Wan22TI2VPipeline,
get_wan22_ti2v_pre_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _make_ti2v_pipeline() -> Wan22TI2VPipeline:
pipeline = object.__new__(Wan22TI2VPipeline)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = StubTransformer(in_channels=4, out_channels=4)
pipeline.vae = StubVAE(z_dim=4)
pipeline.vae_scale_factor_temporal = 4
pipeline.vae_scale_factor_spatial = 8
pipeline.progress_bar = noop_progress_bar
return pipeline
def test_ti2v_preprocess_uses_720p_area_for_image_condition() -> None:
preprocess = get_wan22_ti2v_pre_process_func(SimpleNamespace())
request = SimpleNamespace(
prompts=[{"prompt": "p", "multi_modal_data": {"image": Image.new("RGB", (320, 160), "blue")}}],
sampling_params=SimpleNamespace(height=None, width=None),
)
result = preprocess(request)
assert result.sampling_params.height == 672
assert result.sampling_params.width == 1344
assert result.prompts[0]["multi_modal_data"]["image"].size == (1344, 672)
def test_ti2v_diffuse_without_image_condition_expands_patch_timesteps() -> None:
pipeline = _make_ti2v_pipeline()
latents = torch.zeros(1, 4, 2, 4, 4)
calls = []
def fake_predict_noise_maybe_with_cfg(**kwargs):
calls.append(kwargs)
return torch.ones_like(latents)
pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
result = pipeline.diffuse(
latents=latents,
timesteps=torch.tensor([7]),
prompt_embeds=torch.zeros(1, 2, 3),
negative_prompt_embeds=torch.zeros(1, 2, 3),
guidance_scale=3.0,
dtype=torch.float32,
attention_kwargs={"a": "b"},
num_latent_frames=2,
latent_height=4,
latent_width=4,
)
positive = calls[0]["positive_kwargs"]
assert calls[0]["do_true_cfg"] is True
assert positive["timestep"].shape == (1, 8)
torch.testing.assert_close(positive["timestep"], torch.full((1, 8), 7, dtype=positive["timestep"].dtype))
torch.testing.assert_close(positive["hidden_states"], latents)
torch.testing.assert_close(result, torch.ones_like(latents))
def test_ti2v_prepare_i2v_latents_encodes_condition_and_masks_first_frame() -> None:
pipeline = _make_ti2v_pipeline()
latents, latent_condition, first_frame_mask = pipeline.prepare_i2v_latents(
image=torch.zeros(1, 3, 16, 16),
batch_size=1,
num_channels_latents=4,
height=16,
width=16,
num_frames=5,
dtype=torch.float32,
device=torch.device("cpu"),
generator=None,
latents=torch.zeros(1, 4, 2, 2, 2),
)
torch.testing.assert_close(latents, torch.zeros(1, 4, 2, 2, 2))
assert latent_condition.shape == (1, 4, 1, 2, 2)
assert first_frame_mask[:, :, 0].sum() == 0
assert first_frame_mask[:, :, 1].sum() == 4
@@ -0,0 +1,137 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
from torch import nn
from tests.diffusion.models.wan2_2.conftest import StubTransformer, StubVAE, noop_progress_bar
from vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace import (
Wan22VACEPipeline,
create_vace_transformer_from_config,
get_wan22_vace_pre_process_func,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
def _make_vace_pipeline() -> Wan22VACEPipeline:
pipeline = object.__new__(Wan22VACEPipeline)
nn.Module.__init__(pipeline)
pipeline.device = torch.device("cpu")
pipeline.transformer = StubTransformer(in_channels=4, out_channels=4)
pipeline.transformer_config = pipeline.transformer.config
pipeline.vae = StubVAE(z_dim=4)
pipeline.vae_scale_factor_temporal = 4
pipeline.vae_scale_factor_spatial = 8
pipeline.progress_bar = noop_progress_bar
return pipeline
def test_vace_preprocess_collects_reference_video_and_mask_inputs() -> None:
preprocess = get_wan22_vace_pre_process_func(SimpleNamespace())
ref = Image.new("RGB", (320, 160), "green")
frame = Image.new("RGB", (64, 64), "black")
mask = Image.new("L", (64, 64), 255)
request = SimpleNamespace(
prompts=[
{
"prompt": "p",
"multi_modal_data": {
"image": ref,
"video": [frame],
"mask": mask,
},
}
],
sampling_params=SimpleNamespace(height=None, width=None),
)
result = preprocess(request)
additional_info = result.prompts[0]["additional_information"]
assert result.sampling_params.height == 432
assert result.sampling_params.width == 880
assert additional_info["reference_images"] == [ref]
assert additional_info["source_video"] == [frame]
assert additional_info["mask"] == [mask]
def test_create_vace_transformer_from_config_maps_vace_specific_keys(monkeypatch) -> None:
captured = {}
class FakeVACETransformer:
def __init__(self, **kwargs) -> None:
captured.update(kwargs)
monkeypatch.setattr(
"vllm_omni.diffusion.models.wan2_2.pipeline_wan2_2_vace.WanVACETransformer3DModel",
FakeVACETransformer,
)
transformer = create_vace_transformer_from_config(
{
"patch_size": [1, 2, 2],
"in_channels": 96,
"out_channels": 16,
"vace_layers": [0, 1, 2],
"vace_in_channels": 132,
"unknown": "ignored",
}
)
assert isinstance(transformer, FakeVACETransformer)
assert captured == {
"patch_size": (1, 2, 2),
"in_channels": 96,
"out_channels": 16,
"vace_layers": [0, 1, 2],
"vace_in_channels": 132,
}
def test_vace_prepare_masks_encodes_spatial_stride_and_reference_padding() -> None:
pipeline = _make_vace_pipeline()
mask = torch.ones(1, 3, 5, 16, 16)
reference_images = [[torch.zeros(3, 16, 16), torch.zeros(3, 16, 16)]]
encoded = pipeline.prepare_masks(mask, reference_images)
assert encoded.shape == (1, 64, 4, 2, 2)
torch.testing.assert_close(encoded[:, :, :2], torch.zeros(1, 64, 2, 2, 2))
torch.testing.assert_close(encoded[:, :, 2:], torch.ones(1, 64, 2, 2, 2))
def test_vace_diffuse_passes_context_and_scale_to_cfg_branches() -> None:
pipeline = _make_vace_pipeline()
latents = torch.zeros(1, 4, 1, 2, 2)
vace_context = torch.ones(1, 12, 1, 2, 2)
calls = []
def fake_predict_noise_maybe_with_cfg(**kwargs):
calls.append(kwargs)
return torch.ones_like(latents)
pipeline.predict_noise_maybe_with_cfg = fake_predict_noise_maybe_with_cfg # type: ignore[method-assign]
pipeline.scheduler_step_maybe_with_cfg = lambda noise, t, current, cfg: current + noise # type: ignore[method-assign]
result = pipeline.diffuse(
latents=latents,
timesteps=torch.tensor([5]),
prompt_embeds=torch.zeros(1, 2, 3),
negative_prompt_embeds=torch.zeros(1, 2, 3),
guidance_scale=4.0,
dtype=torch.float32,
attention_kwargs={},
vace_context=vace_context,
vace_context_scale=0.75,
)
assert calls[0]["do_true_cfg"] is True
assert calls[0]["true_cfg_scale"] == 4.0
assert calls[0]["positive_kwargs"]["vace_context"] is vace_context
assert calls[0]["negative_kwargs"]["vace_context_scale"] == 0.75
torch.testing.assert_close(result, torch.ones_like(latents))
@@ -32,7 +32,7 @@ import numpy as np
import pytest
import torch
from tests.utils import hardware_marks
from tests.helpers.mark import hardware_marks
# ---------------------------------------------------------------------------
# Configuration — add new quantization methods / models here
@@ -8,7 +8,7 @@ import pytest
import torch
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
pytestmark = [pytest.mark.diffusion]
@@ -13,7 +13,7 @@ import torch
from pytest_mock import MockerFixture
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
+42 -15
View File
@@ -16,7 +16,7 @@ from pytest_mock import MockerFixture
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
@pytest.fixture
@@ -81,17 +81,31 @@ class TestDiffusionWorkerLoadWeights:
class TestDiffusionWorkerSleep:
"""Test DiffusionWorker.sleep method."""
@pytest.fixture(autouse=True)
def setup_allocator(self, mocker: MockerFixture):
"""
Unified interception of Allocators, and provision of default security values.
"""
self.mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
self.mock_allocator = mocker.Mock()
self.mock_allocator_class.get_instance.return_value = self.mock_allocator
self.mock_allocator.get_current_usage.return_value = 4 * 1024**3
self.mock_allocator.sleep = mocker.Mock()
def test_sleep_level_1(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep mode level 1 (offload weights only)."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform.get_free_memory.side_effect = [10 * 1024**3, 12 * 1024**3]
mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Setup process-scoped memory mocks
# Before sleep: 3GB used
# After sleep: 1GB used (freed 2GB)
initial_usage = 3 * 1024**3
mock_get_process_memory.side_effect = [
3 * 1024**3,
initial_usage,
1 * 1024**3,
]
@@ -99,25 +113,29 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
mock_allocator.get_current_usage.return_value = initial_usage
# Call sleep with level 1
result = mock_gpu_worker.sleep(level=1)
# Verify sleep was called with correct tags
mock_allocator.sleep.assert_called_once_with(offload_tags=("weights",))
assert result is True
assert bool(result) is True
# Verify buffers were NOT saved (level 1 doesn't save buffers)
assert len(mock_gpu_worker._sleep_saved_buffers) == 0
def test_sleep_level_2(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep mode level 2 (offload all, save buffers)."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform.get_free_memory.side_effect = [5 * 1024**3, 10 * 1024**3]
mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Setup process-scoped memory mocks
initial_usage = 5 * 1024**3
mock_get_process_memory.side_effect = [
5 * 1024**3, # Before sleep
initial_usage, # Before sleep
1 * 1024**3, # After sleep (freed 4GB)
]
@@ -125,6 +143,7 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
mock_allocator.get_current_usage.return_value = initial_usage
# Mock pipeline buffers
mock_buffer1 = torch.randn(10, 10)
@@ -141,7 +160,7 @@ class TestDiffusionWorkerSleep:
# Verify sleep was called with empty tags (offload all)
mock_allocator.sleep.assert_called_once_with(offload_tags=tuple())
assert result is True
assert bool(result) is True
# Verify buffers were saved
assert len(mock_gpu_worker._sleep_saved_buffers) == 2
@@ -151,22 +170,26 @@ class TestDiffusionWorkerSleep:
def test_sleep_memory_freed_validation(self, mocker: MockerFixture, mock_gpu_worker):
"""Test that sleep validates memory was actually freed."""
mock_allocator_class = mocker.patch("vllm.device_allocator.cumem.CuMemAllocator")
mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.current_omni_platform")
mock_platform.get_free_memory.return_value = 10 * 1024**3
mock_platform.get_device_total_memory.return_value = 80 * 1024**3
mock_get_process_memory = mocker.patch("vllm_omni.diffusion.worker.diffusion_worker.get_process_gpu_memory")
# Simulate process memory increase (should trigger assertion error)
initial_usage = 1 * 1024**3
mock_get_process_memory.side_effect = [
1 * 1024**3, # Before sleep: 1GB used
initial_usage, # Before sleep: 1GB used
3 * 1024**3, # After sleep: 3GB used (negative freed)
]
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
mock_allocator.get_current_usage.return_value = initial_usage
# This should raise an assertion error
with pytest.raises(AssertionError, match="Memory usage increased after sleeping"):
mock_gpu_worker.sleep(level=1)
result = mock_gpu_worker.sleep(level=1)
assert result == initial_usage
def test_sleep_falls_back_to_device_memory_when_nvml_unavailable(self, mocker: MockerFixture, mock_gpu_worker):
"""Test sleep uses device-scoped fallback when NVML is unavailable."""
@@ -184,11 +207,12 @@ class TestDiffusionWorkerSleep:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.sleep = mocker.Mock()
mock_allocator.get_current_usage.return_value = 2 * 1024**3
result = mock_gpu_worker.sleep(level=1)
mock_allocator.sleep.assert_called_once_with(offload_tags=("weights",))
assert result is True
assert bool(result) is True
class TestDiffusionWorkerWakeUp:
@@ -202,6 +226,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Ensure no saved buffers
mock_gpu_worker._sleep_saved_buffers = {}
@@ -211,7 +236,7 @@ class TestDiffusionWorkerWakeUp:
# Verify allocator.wake_up was called
mock_allocator.wake_up.assert_called_once_with(["weights"])
assert result is True
assert bool(result) is True
def test_wake_up_with_buffers(self, mocker: MockerFixture, mock_gpu_worker):
"""Test wake_up with saved buffers (level 2 sleep)."""
@@ -221,6 +246,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Create saved buffers
saved_buffer1 = torch.randn(10, 10)
@@ -255,7 +281,7 @@ class TestDiffusionWorkerWakeUp:
# Verify saved buffers were cleared
assert len(mock_gpu_worker._sleep_saved_buffers) == 0
assert result is True
assert bool(result) is True
def test_wake_up_partial_buffer_restore(self, mocker: MockerFixture, mock_gpu_worker):
"""Test wake_up only restores buffers that were saved."""
@@ -265,6 +291,7 @@ class TestDiffusionWorkerWakeUp:
mock_allocator = mocker.Mock()
mock_allocator_class.get_instance = mocker.Mock(return_value=mock_allocator)
mock_allocator.wake_up = mocker.Mock()
mock_allocator.get_current_usage.return_value = 10 * 1024**3
# Only save buffer1, not buffer2
saved_buffer1 = torch.randn(10, 10)
@@ -293,4 +320,4 @@ class TestDiffusionWorkerWakeUp:
# buffer2 should NOT be restored since it wasn't saved
mock_buffer2.data.copy_.assert_not_called()
assert result is True
assert bool(result) is True
@@ -0,0 +1,96 @@
from __future__ import annotations
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from vllm_omni.diffusion.data import OmniDiffusionConfig
from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
from vllm_omni.engine.stage_init_utils import StageMetadata
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
@pytest.fixture
def mock_engine():
with patch("vllm_omni.diffusion.inline_stage_diffusion_client.DiffusionEngine") as mock:
engine_instance = MagicMock()
mock.make_engine.return_value = engine_instance
yield engine_instance
@pytest.fixture
def client(mock_engine):
metadata = StageMetadata(
stage_id=0,
stage_type="diffusion",
engine_output_type="image",
is_comprehension=False,
requires_multimodal_data=False,
engine_input_source="prompt",
final_output=True,
final_output_type="image",
default_sampling_params={},
custom_process_input_func=None,
model_stage=None,
runtime_cfg=None,
)
with patch.object(InlineStageDiffusionClient, "_enrich_config"):
od_config = MagicMock(spec=OmniDiffusionConfig)
c = InlineStageDiffusionClient(model="test_model", od_config=od_config, metadata=metadata, batch_size=1)
yield c
c.shutdown()
@pytest.mark.asyncio
async def test_inline_dispatch_request_success(client, mock_engine):
# Setup mock engine step to return a successful result
mock_result = OmniRequestOutput.from_diffusion(request_id="req-1", images=[MagicMock()])
mock_engine.step.return_value = [mock_result]
sampling_params = OmniDiffusionSamplingParams()
await client.add_request_async("req-1", "A test prompt", sampling_params)
# Wait for the task to be processed
for _ in range(10):
output = client.get_diffusion_output_nowait()
if output is not None:
break
await asyncio.sleep(0.01)
assert output is not None
assert output.request_id == "req-1"
mock_engine.step.assert_called_once()
@pytest.mark.asyncio
async def test_inline_dispatch_request_error(client, mock_engine):
# Setup mock engine step to raise an exception
mock_engine.step.side_effect = RuntimeError("Engine failure")
sampling_params = OmniDiffusionSamplingParams()
await client.add_request_async("req-err", "A test prompt", sampling_params)
for _ in range(10):
output = client.get_diffusion_output_nowait()
if output is not None:
break
await asyncio.sleep(0.01)
assert output is not None
assert output.request_id == "req-err"
assert output.error == "Engine failure"
assert not output.images
def test_inline_shutdown(client, mock_engine):
assert not client._shutting_down
# Shutting down should cleanly cancel anything queued and close engine
client.shutdown()
assert client._shutting_down
mock_engine.close.assert_called_once()
@@ -1,17 +1,25 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import asyncio
import multiprocessing as mp
import queue
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
import pytest
import torch
import zmq
from vllm.v1.engine.exceptions import EngineDeadError
from vllm_omni.diffusion.data import DiffusionOutput
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.executor.multiproc_executor import MultiprocDiffusionExecutor
from vllm_omni.diffusion.sched import RequestScheduler
from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc
from vllm_omni.outputs import OmniRequestOutput
pytestmark = [pytest.mark.diffusion, pytest.mark.core_model, pytest.mark.cpu]
@@ -51,6 +59,8 @@ def _make_executor(num_gpus: int = 1):
executor._result_mq = mock_rmq
executor._closed = False
executor._processes = []
executor.is_failed = False
executor._failure_callbacks = []
return executor, req_q, res_q
@@ -334,8 +344,9 @@ class TestSerialEngineOperations:
class TestCollectiveRpcTimeoutWhileLockHeld:
"""``collective_rpc(timeout=...)`` must honour its timeout even when
another thread holds ``engine._rpc_lock`` indefinitely (e.g. a stalled
``add_req`` waiting on an unresponsive worker).
another thread holds ``engine._rpc_lock`` indefinitely (e.g. request
execution stalled on ``add_req_and_wait_for_response`` → ``execute_fn``
→ ``collective_rpc`` while blocked on an unresponsive worker).
"""
def test_rpc_times_out_when_lock_held_directly(self):
@@ -359,10 +370,10 @@ class TestCollectiveRpcTimeoutWhileLockHeld:
with pytest.raises(TimeoutError):
engine.collective_rpc("health", timeout=0.5)
def test_rpc_times_out_when_add_req_stalled_on_worker(self):
def test_rpc_times_out_when_request_execution_stalled_on_worker(self):
"""Real-world scenario the bot flagged:
``add_req`` holds ``_rpc_lock`` while blocked on
The scheduler/execute path holds ``_rpc_lock`` while blocked on
``executor._result_mq.dequeue()`` because the worker never replies.
A concurrent ``collective_rpc(timeout=...)`` must still time out
instead of hanging forever waiting for the lock.
@@ -428,3 +439,353 @@ class TestCollectiveRpcTimeoutWhileLockHeld:
t.join(5)
assert result.error == "ok"
# ───────── error handling: EngineDeadError propagation through layers ─────
class TestMultiprocExecutorRaisesEngineDeadError:
"""``collective_rpc`` raises ``EngineDeadError`` when the engine is failed."""
def test_collective_rpc_raises_when_is_failed(self):
executor = object.__new__(MultiprocDiffusionExecutor)
executor._closed = False
executor._broadcast_mq = MagicMock()
executor._result_mq = MagicMock()
executor._result_mq.dequeue = MagicMock(side_effect=TimeoutError)
executor.is_failed = True
with pytest.raises(EngineDeadError):
executor.collective_rpc(
"generate",
args=(MagicMock(),),
unique_reply_rank=0,
exec_all_ranks=True,
)
def test_collective_rpc_raises_mid_dequeue_when_is_failed(self):
"""Worker dies while we are polling the dequeue loop."""
executor, _, res_q = _make_executor()
call_count = 0
orig_dequeue = executor._result_mq.dequeue
def _dying_dequeue(timeout=None):
nonlocal call_count
call_count += 1
if call_count == 1:
executor.is_failed = True
raise TimeoutError
return orig_dequeue(timeout=timeout)
executor._result_mq.dequeue = _dying_dequeue
with pytest.raises(EngineDeadError):
executor.collective_rpc(
"generate",
args=(MagicMock(),),
unique_reply_rank=0,
exec_all_ranks=True,
)
class TestDiffusionEngineDeadErrorPassthrough:
"""``DiffusionEngine.add_req_and_wait_for_response`` re-raises
``EngineDeadError`` from executor and wraps other errors."""
def test_engine_dead_error_propagates(self):
engine, executor, _, _ = _make_engine()
engine.execute_fn = Mock(side_effect=EngineDeadError())
with pytest.raises(EngineDeadError):
engine.add_req_and_wait_for_response(_mock_request("dead"))
def test_runtime_error_wrapped_in_output(self):
engine, executor, _, _ = _make_engine()
engine.execute_fn = Mock(side_effect=RuntimeError("gpu fault"))
out = engine.add_req_and_wait_for_response(_mock_request("fault"))
assert isinstance(out, DiffusionOutput)
assert "gpu fault" in out.error
class TestStageDiffusionClientErrorPropagation:
"""Error surface behaviour of ``StageDiffusionClient``.
Uses ``object.__new__`` to construct a client without spawning a real
subprocess, then manually sets the fields needed for each test.
"""
def _make_client(self, *, engine_dead=False, proc_alive=True):
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
client = object.__new__(StageDiffusionClient)
client.stage_id = 0
client.final_output = True
client.final_output_type = "image"
client.default_sampling_params = None
client.custom_process_input_func = None
client.engine_input_source = None
client._output_queue = asyncio.Queue()
client._rpc_results = {}
client._pending_rpcs = set()
client._tasks = {}
client._shutting_down = False
client._engine_dead = engine_dead
client._owns_process = True
client._proc = MagicMock(
is_alive=MagicMock(return_value=proc_alive),
exitcode=1,
)
client._request_socket = MagicMock()
client._response_socket = MagicMock()
client._encoder = MagicMock()
client._decoder = MagicMock()
return client
@pytest.mark.asyncio
async def test_add_request_raises_when_dead(self):
client = self._make_client(engine_dead=True)
with pytest.raises(EngineDeadError):
await client.add_request_async("req-3", "test prompt", None)
def test_check_health_raises_when_dead(self):
client = self._make_client(engine_dead=True)
with pytest.raises(EngineDeadError):
client.check_health()
def test_check_health_ok_when_alive(self):
client = self._make_client()
client.check_health()
def test_get_output_raises_engine_dead_when_dead(self):
"""When ``_engine_dead`` is True and the output queue is empty,
``get_diffusion_output_nowait`` must raise ``EngineDeadError``."""
client = self._make_client(engine_dead=True)
# Simulate _drain_responses as a no-op (no ZMQ socket)
client._response_socket.recv.side_effect = zmq.Again
with pytest.raises(EngineDeadError):
client.get_diffusion_output_nowait()
def test_get_output_returns_none_when_alive_and_empty(self):
"""When the engine is alive and the queue is empty, return None."""
client = self._make_client()
client._response_socket.recv.side_effect = zmq.Again
assert client.get_diffusion_output_nowait() is None
def test_check_health_raises_when_proc_dead(self):
"""``check_health`` detects a dead subprocess via ``_proc.is_alive()``
and raises ``EngineDeadError``, setting ``_engine_dead`` as a
side effect."""
client = self._make_client(proc_alive=False)
with pytest.raises(EngineDeadError, match="not alive"):
client.check_health()
assert client._engine_dead is True
def test_get_output_raises_when_proc_dead(self):
"""When the subprocess has died (non-signal exit) and the output
queue is empty, ``get_diffusion_output_nowait`` must raise
``EngineDeadError`` with the exit code."""
client = self._make_client(proc_alive=False)
client._response_socket.recv.side_effect = zmq.Again
with pytest.raises(EngineDeadError, match="exit code"):
client.get_diffusion_output_nowait()
assert client._engine_dead is True
def test_get_output_returns_none_on_signal_death(self):
"""When the subprocess was killed by a signal (exit code > 128),
``get_diffusion_output_nowait`` returns ``None`` and sets
``_shutting_down`` instead of raising."""
client = self._make_client(proc_alive=False)
client._proc.exitcode = 137 # SIGKILL (128 + 9)
client._response_socket.recv.side_effect = zmq.Again
result = client.get_diffusion_output_nowait()
assert result is None
assert client._shutting_down is True
assert client._engine_dead is True
# ───────── monitor thread & death sentinel integration tests ─────────
def _poll_flag(get_flag, *, timeout=5.0, interval=0.05) -> bool:
"""Poll until ``get_flag()`` returns True or *timeout* elapses."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if get_flag():
return True
time.sleep(interval)
return False
def _make_short_lived_process() -> mp.Process:
"""Spawn a real subprocess that exits immediately.
The process must be started with ``"fork"`` (or the platform default)
so that it can use a plain ``lambda`` as its target — ``"spawn"`` would
fail to pickle it.
"""
ctx = mp.get_context("fork")
p = ctx.Process(target=lambda: None, name="ShortLivedWorker-0")
p.start()
return p
class TestMultiprocExecutorWorkerMonitor:
"""Integration tests for ``start_worker_monitor``.
Uses real short-lived subprocesses so that OS-level sentinel fd
readiness is exercised end-to-end.
"""
def test_worker_monitor_sets_is_failed_and_calls_callbacks_on_death(self):
"""When a worker process dies, the monitor thread must:
1. Set ``is_failed = True``
2. Call ``shutdown()`` (which sets ``_closed = True``)
3. Invoke all registered failure callbacks
"""
executor = object.__new__(MultiprocDiffusionExecutor)
executor._closed = False
executor.is_failed = False
executor._failure_callbacks = []
executor._broadcast_mq = None
executor._result_mq = None
executor.resources = None
# Use a no-op so shutdown() doesn't crash on None resources.
executor._finalizer = lambda: None
proc = _make_short_lived_process()
executor._processes = [proc]
callback_called = threading.Event()
executor.register_failure_callback(callback_called.set)
executor.start_worker_monitor()
# Wait for the process to exit and the monitor to react.
proc.join(5)
assert _poll_flag(lambda: executor.is_failed), "is_failed was not set"
assert executor._closed, "shutdown() was not called"
assert callback_called.wait(timeout=2), "failure callback was not invoked"
def test_worker_monitor_noop_when_already_closed(self):
"""If ``_closed`` is already True when the process dies (orderly
shutdown), the monitor must *not* set ``is_failed``."""
executor = object.__new__(MultiprocDiffusionExecutor)
executor._closed = True # already shut down
executor.is_failed = False
executor._failure_callbacks = []
executor._broadcast_mq = None
executor._result_mq = None
executor.resources = None
executor._finalizer = lambda: None
proc = _make_short_lived_process()
executor._processes = [proc]
executor.start_worker_monitor()
proc.join(5)
# Give the monitor thread a chance to run (it should early-return).
time.sleep(0.3)
assert not executor.is_failed, "is_failed should remain False on orderly shutdown"
class TestStageDiffusionClientProcMonitor:
"""Integration test for ``StageDiffusionClient._start_proc_monitor``.
Uses a real short-lived subprocess to verify the sentinel-based
detection pipeline.
"""
def test_proc_monitor_sets_engine_dead_on_process_death(self):
"""When the subprocess dies, the monitor thread must set
``_engine_dead = True``."""
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
client = object.__new__(StageDiffusionClient)
client.stage_id = 0
client._shutting_down = False
client._engine_dead = False
proc = _make_short_lived_process()
client._proc = proc
client._start_proc_monitor()
proc.join(5)
assert _poll_flag(lambda: client._engine_dead), "_engine_dead was not set"
class TestDrainResponsesDeathSentinel:
"""Tests for death sentinel and error routing in
``StageDiffusionClient._drain_responses()``.
"""
def _make_client(self):
from vllm_omni.diffusion.stage_diffusion_client import StageDiffusionClient
client = object.__new__(StageDiffusionClient)
client.stage_id = 0
client._engine_dead = False
client._shutting_down = False
client._output_queue = asyncio.Queue()
client._rpc_results = {}
client._pending_rpcs = set()
client._response_socket = MagicMock()
client._decoder = MagicMock()
return client
def test_drain_responses_sets_engine_dead_on_death_sentinel(self):
"""When ``_drain_responses`` receives the ``DIFFUSION_PROC_DEAD``
sentinel, it must set ``_engine_dead = True`` and stop draining
(decoder is never called)."""
client = self._make_client()
# First recv returns the death sentinel, second would be a normal
# message but should never be reached.
client._response_socket.recv.side_effect = [
StageDiffusionProc.DIFFUSION_PROC_DEAD,
b"should-not-be-reached",
]
client._drain_responses()
assert client._engine_dead is True
client._decoder.decode.assert_not_called()
def test_drain_responses_routes_error_as_omni_request_output(self):
"""When ``_drain_responses`` receives a ``{"type": "error"}`` message
with a ``request_id``, it must place an ``OmniRequestOutput`` with
the error on ``_output_queue``."""
client = self._make_client()
error_msg = {
"type": "error",
"request_id": "req-fail",
"error": "gpu fault",
}
# First recv returns the encoded error, second raises zmq.Again.
client._response_socket.recv.side_effect = [b"encoded-error", zmq.Again]
client._decoder.decode.return_value = error_msg
client._drain_responses()
assert not client._output_queue.empty()
output = client._output_queue.get_nowait()
assert isinstance(output, OmniRequestOutput)
assert output.request_id == "req-fail"
assert output.error == "gpu fault"
assert output.finished is True
@@ -4,10 +4,12 @@
import threading
from collections import deque
from types import SimpleNamespace
from unittest.mock import patch
import pytest
import torch
from pytest_mock import MockerFixture
from vllm.v1.core.sched.scheduler import Scheduler as VLLMScheduler
from vllm.v1.request import RequestStatus
from vllm_omni.distributed.omni_connectors.transfer_adapter.base import OmniTransferAdapterBase
@@ -335,6 +337,27 @@ def test_cleanup_after_poll_flow(build_adapter):
assert "ext-flow" not in adapter.request_payload
def test_finish_requests_restores_status(build_adapter):
"""Abort path must pop ``requests_origin_status`` and restore pre-wait status.
While ``process_pending_chunks`` holds a request off the scheduler queues, the
adapter records the prior status (WAITING or RUNNING). ``finish_requests`` must
put that status back on the live ``Request`` so base ``Scheduler.finish_requests``
can finish bookkeeping without inconsistent state / crashes.
"""
adapter, _ = build_adapter(stage_id=1)
req_id = "req-abort-during-chunk"
prior = RequestStatus.RUNNING
request = _req(req_id, RequestStatus.WAITING_FOR_CHUNK)
adapter.requests_origin_status[req_id] = prior
requests_map = {req_id: request}
adapter.finish_requests([req_id], RequestStatus.FINISHED_ABORTED, requests_map)
assert request.status == prior
assert req_id not in adapter.requests_origin_status
# ---------------------------------------------------------------
# Scheduler trigger tests
# ---------------------------------------------------------------
@@ -508,3 +531,31 @@ def test_ar_scheduler_defers_cleanup_and_queues_save_on_finished(mocker: MockerF
assert len(cleanup_calls) == 0
assert len(save_calls) == 1
def test_omni_ar_scheduler_finish_requests(mocker: MockerFixture):
"""``OmniARScheduler.finish_requests`` must run chunk adapter hook before vLLM base."""
from vllm_omni.core.sched.omni_ar_scheduler import OmniARScheduler
order: list[str] = []
adapter = mocker.MagicMock()
def _adapter_finish(request_ids, finished_status, requests):
order.append("adapter")
return []
adapter.finish_requests.side_effect = _adapter_finish
def _super_finish(_self, request_ids, finished_status):
order.append("super")
return []
sched = OmniARScheduler.__new__(OmniARScheduler)
sched.chunk_transfer_adapter = adapter
sched.requests = {}
with patch.object(VLLMScheduler, "finish_requests", _super_finish):
OmniARScheduler.finish_requests(sched, ["r1"], RequestStatus.FINISHED_ABORTED)
assert order == ["adapter", "super"]
@@ -0,0 +1,716 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Unit tests for rank-aware KV transfer (TP > 1) and heterogeneous TP support.
Covers:
- _build_rank_aware_send_keys / _build_rank_aware_recv_keys
- _get_kv_source_ranks / _get_kv_target_ranks / get_kv_connector_key
- update_sender_info storing base host/port
- receive path constructing per-rank metadata for connector.get()
- Mooncake connector _query_metadata_at and partial-metadata get() path
"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import torch
from vllm_omni.distributed.omni_connectors.kv_transfer_manager import (
KVCacheTransferData,
OmniKVCacheConfig,
OmniKVTransferManager,
)
from vllm_omni.distributed.omni_connectors.utils.initialization import (
KV_RANK_PORT_STRIDE,
)
from vllm_omni.distributed.omni_connectors.utils.kv_utils import (
KVTPTopology,
build_rank_aware_recv_keys,
build_rank_aware_send_keys,
get_kv_connector_key,
get_kv_source_ranks,
get_kv_target_ranks,
merge_received_rank_shards,
slice_received_rank_shard,
)
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
def _make_manager(
from_tp: int = 1,
to_tp: int = 1,
local_rank: int = 0,
from_stage: str = "stage0",
to_stage: str = "stage1",
stage_id: str = "stage1",
need_recv: bool = True,
need_send: bool = False,
recv_timeout: float = 0.3,
) -> OmniKVTransferManager:
"""Build a manager with TP params injected, bypassing torch.distributed."""
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage=from_stage,
to_stage=to_stage,
stage_id=stage_id,
need_recv_cache=need_recv,
need_send_cache=need_send,
recv_timeout=recv_timeout,
from_tp=from_tp,
to_tp=to_tp,
)
with (
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=local_rank),
patch(
"vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size",
return_value=max(from_tp, to_tp),
),
):
mgr = OmniKVTransferManager(config)
return mgr
def _make_payload(head_values: list[float], request_id: str = "req-1") -> dict:
head_tensor = torch.tensor(head_values, dtype=torch.float32).view(1, len(head_values), 1).repeat(2, 1, 1)
return {
"request_id": request_id,
"layer_blocks": {
"key_cache": [head_tensor.clone()],
"value_cache": [(head_tensor + 100).clone()],
},
"block_ids": [0],
"metadata": {"seq_len": 2},
}
def _make_transfer_data(head_values: list[float], request_id: str = "req-1") -> KVCacheTransferData:
payload = _make_payload(head_values, request_id=request_id)
return KVCacheTransferData(
request_id=request_id,
layer_blocks=payload["layer_blocks"],
block_ids=payload["block_ids"],
metadata=payload["metadata"],
)
# ── Key format helper ────────────────────────────────────────────────
class TestConnectorKeyFormat:
def test_key_format_matches_pr2677(self):
key = get_kv_connector_key("req-1", "stage0", 0, 1, 2)
assert key == "req-1_stage0_0_1_2"
def test_key_fields_are_positional(self):
key = get_kv_connector_key("r", "s", 5, 3, 7)
parts = key.split("_")
assert parts == ["r", "s", "5", "3", "7"]
# ── Source / target rank mapping ─────────────────────────────────────
class TestRankMapping:
"""Verify get_kv_target_ranks and get_kv_source_ranks for various TP configs."""
def test_homogeneous_tp2_rank0(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
assert get_kv_target_ranks(topo) == [0]
assert get_kv_source_ranks(topo) == [0]
def test_homogeneous_tp2_rank1(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=1)
assert get_kv_target_ranks(topo) == [1]
assert get_kv_source_ranks(topo) == [1]
def test_homogeneous_tp4_rank3(self):
topo = KVTPTopology(source_tp_size=4, target_tp_size=4, local_rank=3)
assert get_kv_target_ranks(topo) == [3]
assert get_kv_source_ranks(topo) == [3]
def test_sender_gt_receiver_tp4_to_tp2_rank0(self):
"""Receiver rank 0 should receive from sender rank 0 and 1."""
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=0)
assert get_kv_source_ranks(topo) == [0, 1]
def test_sender_gt_receiver_tp4_to_tp2_rank1(self):
"""Receiver rank 1 should receive from sender rank 2 and 3."""
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
assert get_kv_source_ranks(topo) == [2, 3]
def test_sender_lt_receiver_tp2_to_tp4_rank0(self):
"""Sender rank 0 should send to receiver ranks 0 and 1."""
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
assert get_kv_target_ranks(topo) == [0, 1]
def test_sender_lt_receiver_tp2_to_tp4_rank1(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
assert get_kv_target_ranks(topo) == [2, 3]
def test_receiver_lt_sender_source_ranks(self):
"""Receiver rank 0 with tp2_to_tp4 should source from rank 0 only."""
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
assert get_kv_source_ranks(topo) == [0]
def test_invalid_topology_raises(self):
topo = KVTPTopology(source_tp_size=3, target_tp_size=2, local_rank=0)
with pytest.raises(ValueError, match="divisible"):
get_kv_source_ranks(topo)
# ── _build_rank_aware_recv_keys ──────────────────────────────────────
class TestBuildRankAwareRecvKeys:
"""Verify build_rank_aware_recv_keys returns (key, from_rank) tuples."""
def test_tp1_returns_legacy_key_with_none_rank(self):
topo = KVTPTopology(source_tp_size=1, target_tp_size=1, local_rank=0)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 1
key, rank = pairs[0]
assert key == "omni_stage0_to_stage1_kv_cache_req-1"
assert rank is None
def test_homogeneous_tp2_rank0(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 1
key, rank = pairs[0]
assert key == "req-1_stage0_0_0_0"
assert rank == 0
def test_homogeneous_tp2_rank1(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=1)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 1
key, rank = pairs[0]
assert key == "req-1_stage0_0_1_1"
assert rank == 1
def test_heterogeneous_tp4_to_tp2_rank0_gets_two_keys(self):
"""Receiver rank 0 with source_tp=4, target_tp=2 should get 2 keys."""
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=0)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 2
keys = [k for k, _ in pairs]
ranks = [r for _, r in pairs]
assert keys == ["req-1_stage0_0_0_0", "req-1_stage0_0_1_0"]
assert ranks == [0, 1]
def test_heterogeneous_tp4_to_tp2_rank1_gets_two_keys(self):
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 2
ranks = [r for _, r in pairs]
assert ranks == [2, 3]
def test_heterogeneous_tp2_to_tp4_rank2_gets_one_key(self):
"""Receiver rank 2 with source_tp=2, target_tp=4 should get 1 key from sender rank 1."""
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=2)
pairs = build_rank_aware_recv_keys("req-1", "stage0", "stage1", topo)
assert len(pairs) == 1
key, rank = pairs[0]
assert rank == 1
assert key == "req-1_stage0_0_1_2"
# ── _build_rank_aware_send_keys ──────────────────────────────────────
class TestBuildRankAwareSendKeys:
def test_tp1_returns_legacy_key(self):
topo = KVTPTopology(source_tp_size=1, target_tp_size=1, local_rank=0)
keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
assert keys == ["omni_stage0_to_stage1_kv_cache_req-1"]
def test_homogeneous_tp2_rank0(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=2, local_rank=0)
keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
assert keys == ["req-1_stage0_0_0_0"]
def test_sender_lt_receiver_tp2_to_tp4_rank0_sends_two_keys(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
keys = build_rank_aware_send_keys("req-1", "stage0", "stage1", topo)
assert len(keys) == 2
assert keys == ["req-1_stage0_0_0_0", "req-1_stage0_0_0_1"]
# ── update_sender_info stores base host/port ─────────────────────────
class TestUpdateSenderInfoBase:
def test_stores_base_host_and_port(self):
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=0)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
assert mgr._sender_base_host == "10.0.0.1"
assert mgr._sender_base_zmq_port == 50151
def test_rank1_adjusts_default_port_but_preserves_base(self):
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
assert mgr._sender_base_host == "10.0.0.1"
assert mgr._sender_base_zmq_port == 50151
expected_adjusted = 50151 + 1 * KV_RANK_PORT_STRIDE
assert mgr.config.connector_config["sender_zmq_port"] == expected_adjusted
def test_nested_sender_info_resolves_correctly(self):
"""Nested sender_info keyed by integer stage id should resolve
using recv_stages (engine_input_source → recv_from)."""
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
stage_id=2,
engine_input_source=[1],
need_recv_cache=True,
from_tp=2,
to_tp=2,
)
with (
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=2),
):
mgr = OmniKVTransferManager(config)
mgr.update_sender_info(
{
0: {"host": "10.0.0.1", "zmq_port": 50151},
1: {"host": "10.0.0.2", "zmq_port": 50152},
}
)
assert mgr._sender_base_host == "10.0.0.2"
assert mgr._sender_base_zmq_port == 50152
# ── receive path constructs per-rank metadata ────────────────────────
class TestReceiveConstructsMetadata:
"""Verify that receive_kv_cache_for_request passes metadata with
correct (host, port) to connector.get() for heterogeneous TP."""
def test_tp1_no_metadata_passed(self):
"""TP=1: connector.get() should be called WITHOUT metadata."""
mgr = _make_manager(from_tp=1, to_tp=1, local_rank=0, recv_timeout=0.05)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
calls = []
class _Connector:
def get(self, from_stage, to_stage, get_key, metadata=None):
calls.append({"key": get_key, "metadata": metadata})
return None
mgr._connector = _Connector()
mgr.receive_kv_cache_for_request("req-1")
assert len(calls) > 0
assert calls[0]["metadata"] is None
def test_homogeneous_tp2_rank0_passes_metadata(self):
"""TP=2 rank 0: metadata should point to sender rank 0's port."""
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=0, recv_timeout=0.05)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
calls = []
class _Connector:
def get(self, from_stage, to_stage, get_key, metadata=None):
calls.append({"key": get_key, "metadata": metadata})
return None
mgr._connector = _Connector()
mgr.receive_kv_cache_for_request("req-1")
assert len(calls) > 0
meta = calls[0]["metadata"]
assert meta is not None
assert meta["source_host"] == "10.0.0.1"
assert meta["source_port"] == 50151 + 0 * KV_RANK_PORT_STRIDE
def test_homogeneous_tp2_rank1_passes_metadata_with_offset(self):
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1, recv_timeout=0.05)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
calls = []
class _Connector:
def get(self, from_stage, to_stage, get_key, metadata=None):
calls.append({"key": get_key, "metadata": metadata})
return None
mgr._connector = _Connector()
mgr.receive_kv_cache_for_request("req-1")
meta = calls[0]["metadata"]
assert meta["source_port"] == 50151 + 1 * KV_RANK_PORT_STRIDE
def test_heterogeneous_tp4_to_tp2_rank0_multiple_metadata(self):
"""Receiver rank 0 with source_tp=4, target_tp=2 should call get() with
two different metadata entries for sender ranks 0 and 1."""
mgr = _make_manager(from_tp=4, to_tp=2, local_rank=0, recv_timeout=0.05)
mgr.update_sender_info({"host": "10.0.0.1", "zmq_port": 50151})
calls = []
class _Connector:
def get(self, from_stage, to_stage, get_key, metadata=None):
calls.append({"key": get_key, "metadata": metadata})
return None
mgr._connector = _Connector()
mgr.receive_kv_cache_for_request("req-1")
seen_ports = set()
for c in calls:
if c["metadata"]:
seen_ports.add(c["metadata"]["source_port"])
expected_ports = {
50151 + 0 * KV_RANK_PORT_STRIDE,
50151 + 1 * KV_RANK_PORT_STRIDE,
}
assert expected_ports.issubset(seen_ports)
# ── Mooncake connector _query_metadata_at ────────────────────────────
class TestMooncakeQueryMetadataAt:
"""Test the connector's _query_metadata_at method and partial-metadata
path in get() without requiring real RDMA/Mooncake."""
def test_query_metadata_at_returns_full_metadata(self):
"""Mock the ZMQ interaction to verify _query_metadata_at returns
complete metadata including data_size."""
try:
from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector import (
MooncakeTransferEngineConnector,
QueryResponse,
)
except ImportError:
pytest.skip("Mooncake not available")
import msgspec
connector = MagicMock(spec=MooncakeTransferEngineConnector)
connector._get_req_socket = MagicMock()
mock_socket = MagicMock()
resp = QueryResponse(request_id="test_key@s0_s1", data_size=4096, is_fast_path=True)
mock_socket.recv.return_value = msgspec.msgpack.encode(resp)
connector._get_req_socket.return_value = mock_socket
result = MooncakeTransferEngineConnector._query_metadata_at(
connector,
"test_key@s0_s1",
"10.0.0.1",
50151,
)
assert result is not None
assert result["source_host"] == "10.0.0.1"
assert result["source_port"] == 50151
assert result["data_size"] == 4096
assert result["is_fast_path"] is True
def test_query_metadata_at_returns_none_on_not_found(self):
try:
from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector import (
INFO_NOT_FOUND,
MooncakeTransferEngineConnector,
)
except ImportError:
pytest.skip("Mooncake not available")
connector = MagicMock(spec=MooncakeTransferEngineConnector)
mock_socket = MagicMock()
mock_socket.recv.return_value = INFO_NOT_FOUND
connector._get_req_socket.return_value = mock_socket
result = MooncakeTransferEngineConnector._query_metadata_at(
connector,
"test_key@s0_s1",
"10.0.0.1",
50151,
)
assert result is None
# ── Merge / slice hooks ──────────────────────────────────────────────
class TestMergeSliceHooks:
def test_single_shard_passes_through(self):
payload = {"layer_blocks": {"key_cache": [1]}}
assert merge_received_rank_shards([payload]) == payload
def test_default_merger_concats_head_dim(self):
p0 = _make_payload([0.0])
p1 = _make_payload([1.0])
result = merge_received_rank_shards([p0, p1])
key_cache = result["layer_blocks"]["key_cache"][0]
value_cache = result["layer_blocks"]["value_cache"][0]
assert key_cache.shape == (2, 2, 1)
assert value_cache.shape == (2, 2, 1)
assert torch.equal(key_cache[:, :, 0], torch.tensor([[0.0, 1.0], [0.0, 1.0]]))
assert torch.equal(value_cache[:, :, 0], torch.tensor([[100.0, 101.0], [100.0, 101.0]]))
def test_custom_merger_hook_called(self):
merged = {"merged": True}
assert merge_received_rank_shards([{}, {}], merger=lambda payloads: merged) == merged
def test_slicer_hook_called(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=0)
sliced = {"sliced": True}
assert slice_received_rank_shard({"full": True}, topo, slicer=lambda payload: sliced) == sliced
def test_default_slicer_extracts_rank_local_heads(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
payload = _make_payload([0.0, 1.0])
result = slice_received_rank_shard(payload, topo)
key_cache = result["layer_blocks"]["key_cache"][0]
value_cache = result["layer_blocks"]["value_cache"][0]
assert key_cache.shape == (2, 1, 1)
assert value_cache.shape == (2, 1, 1)
assert torch.equal(key_cache[:, :, 0], torch.tensor([[1.0], [1.0]]))
assert torch.equal(value_cache[:, :, 0], torch.tensor([[101.0], [101.0]]))
def test_presliced_payload_is_not_sliced_twice(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=1)
payload = _make_payload([1.0])
payload["metadata"]["tp_head_slice"] = {"applied": True, "target_rank": 1}
result = slice_received_rank_shard(payload, topo)
assert result is payload
def test_round_trip_merge_from_tp4_to_tp2(self):
topo = KVTPTopology(source_tp_size=4, target_tp_size=2, local_rank=1)
source_ranks = get_kv_source_ranks(topo)
payloads = [_make_payload([float(rank)]) for rank in source_ranks]
result = merge_received_rank_shards(payloads)
key_cache = result["layer_blocks"]["key_cache"][0]
assert torch.equal(key_cache[:, :, 0], torch.tensor([[2.0, 3.0], [2.0, 3.0]]))
def test_round_trip_slice_from_tp2_to_tp4(self):
topo = KVTPTopology(source_tp_size=2, target_tp_size=4, local_rank=3)
payload = _make_payload([2.0, 3.0])
result = slice_received_rank_shard(payload, topo)
key_cache = result["layer_blocks"]["key_cache"][0]
assert torch.equal(key_cache[:, :, 0], torch.tensor([[3.0], [3.0]]))
class TestSenderSideSlicing:
def test_transfer_slices_before_sending_to_multiple_targets(self):
mgr = _make_manager(
from_tp=2,
to_tp=4,
local_rank=0,
need_send=True,
need_recv=False,
)
sent_payloads = []
class _Connector:
supports_raw_data = False
def put(self, from_stage, to_stage, put_key, data):
sent_payloads.append((put_key, KVCacheTransferData.from_bytes(data)))
return True, len(data), {}
mgr._connector = _Connector()
mgr._transfer_kv_cache(_make_transfer_data([0.0, 1.0]), "req-1")
assert [key for key, _ in sent_payloads] == ["req-1_stage0_0_0_0", "req-1_stage0_0_0_1"]
assert sent_payloads[0][1]["layer_blocks"]["key_cache"][0].shape == (2, 1, 1)
assert sent_payloads[1][1]["layer_blocks"]["key_cache"][0].shape == (2, 1, 1)
assert torch.equal(
sent_payloads[0][1]["layer_blocks"]["key_cache"][0][:, :, 0],
torch.tensor([[0.0], [0.0]]),
)
assert torch.equal(
sent_payloads[1][1]["layer_blocks"]["key_cache"][0][:, :, 0],
torch.tensor([[1.0], [1.0]]),
)
assert sent_payloads[0][1]["metadata"]["tp_head_slice"]["target_rank"] == 0
assert sent_payloads[1][1]["metadata"]["tp_head_slice"]["target_rank"] == 1
class _MockBroadcastGroup:
def __init__(self, world_size: int, rank_in_group: int, broadcast_value=None, recv_value=None):
self.world_size = world_size
self.rank_in_group = rank_in_group
self.broadcast_value = broadcast_value
self.recv_value = recv_value
self.broadcast_calls = []
self.send_calls = []
self.recv_calls = []
self.shm_broadcaster = None
def broadcast_object(self, obj=None, src: int = 0):
self.broadcast_calls.append((obj, src))
return self.broadcast_value if self.broadcast_value is not None else obj
def send_object(self, obj, dst: int):
self.send_calls.append((dst, obj))
def recv_object(self, src: int):
self.recv_calls.append(src)
return self.recv_value
class TestDistributedReceive:
def test_tp_cfg_leader_receives_then_sends_branch_local_payloads(self):
mgr = _make_manager(from_tp=2, to_tp=4, local_rank=0)
req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
world_group = _MockBroadcastGroup(world_size=4, rank_in_group=2)
cfg_group = _MockBroadcastGroup(world_size=3, rank_in_group=0)
def _receive(req_obj, cfg_func, target_device):
req_obj.past_key_values = SimpleNamespace(key_cache=[torch.tensor([1.0])])
req_obj.kv_metadata = {"source": "leader"}
req_obj.sampling_params.past_key_values = req_obj.past_key_values
req_obj.sampling_params.kv_metadata = req_obj.kv_metadata
req_obj.sampling_params.cfg_text_past_key_values = SimpleNamespace(key_cache=[torch.tensor([2.0])])
req_obj.sampling_params.cfg_text_kv_metadata = {"source": "cfg_text"}
req_obj.sampling_params.cfg_img_past_key_values = SimpleNamespace(key_cache=[torch.tensor([3.0])])
req_obj.sampling_params.cfg_img_kv_metadata = {"source": "cfg_img"}
return True
mgr.receive_multi_kv_cache = MagicMock(side_effect=_receive)
with (
patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_world_size",
return_value=3,
),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_rank",
return_value=0,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_cfg_group", return_value=cfg_group),
):
assert mgr.receive_multi_kv_cache_distributed(req) is True
mgr.receive_multi_kv_cache.assert_called_once()
assert mgr.receive_multi_kv_cache.call_args.args[2] == torch.device("cpu")
assert req.kv_metadata == {"source": "leader"}
assert cfg_group.broadcast_calls == []
assert [dst for dst, _ in cfg_group.send_calls] == [1, 2]
rank1_payload = cfg_group.send_calls[0][1]
rank2_payload = cfg_group.send_calls[1][1]
assert torch.equal(rank1_payload["past_key_values"].key_cache[0], torch.tensor([1.0]))
assert torch.equal(rank2_payload["past_key_values"].key_cache[0], torch.tensor([1.0]))
assert rank1_payload["sp.cfg_active_branch"] == "cfg_text"
assert rank2_payload["sp.cfg_active_branch"] == "cfg_img"
assert rank1_payload["sp.cfg_branch_roles"] == ["cfg_text", "cfg_img"]
assert rank2_payload["sp.cfg_branch_roles"] == ["cfg_text", "cfg_img"]
assert "sp.cfg_branch_past_key_values" in rank1_payload
assert "sp.cfg_branch_past_key_values" in rank2_payload
assert list(rank1_payload["sp.cfg_branch_past_key_values"].keys()) == ["cfg_text"]
assert list(rank2_payload["sp.cfg_branch_past_key_values"].keys()) == ["cfg_img"]
assert "sp.cfg_text_past_key_values" in rank1_payload
assert "sp.cfg_img_past_key_values" not in rank1_payload
assert "sp.cfg_img_past_key_values" in rank2_payload
assert "sp.cfg_text_past_key_values" not in rank2_payload
def test_tp_cfg_follower_receives_local_payload_without_receiving(self):
mgr = _make_manager(from_tp=2, to_tp=4, local_rank=1)
req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
world_group = _MockBroadcastGroup(world_size=4, rank_in_group=3)
cfg_payload = {
"past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"kv_metadata": {"source": "main"},
"sp.past_key_values": SimpleNamespace(key_cache=[torch.tensor([1.0])]),
"sp.kv_metadata": {"source": "main"},
"sp.cfg_active_branch": "cfg_text",
"sp.cfg_branch_roles": ["cfg_text", "cfg_img"],
"sp.cfg_branch_past_key_values": {
"cfg_text": SimpleNamespace(key_cache=[torch.tensor([2.0])]),
},
"sp.cfg_branch_kv_metadata": {"cfg_text": {"source": "cfg-text"}},
"sp.cfg_text_past_key_values": SimpleNamespace(key_cache=[torch.tensor([2.0])]),
}
cfg_group = _MockBroadcastGroup(world_size=2, rank_in_group=1, recv_value=cfg_payload)
mgr.receive_multi_kv_cache = MagicMock(return_value=True)
with (
patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_world_size",
return_value=2,
),
patch(
"vllm_omni.diffusion.distributed.parallel_state.get_classifier_free_guidance_rank",
return_value=1,
),
patch("vllm_omni.diffusion.distributed.parallel_state.get_cfg_group", return_value=cfg_group),
):
assert mgr.receive_multi_kv_cache_distributed(req) is True
mgr.receive_multi_kv_cache.assert_not_called()
assert req.kv_metadata == {"source": "main"}
assert torch.equal(req.past_key_values.key_cache[0], torch.tensor([1.0]))
assert torch.equal(req.sampling_params.past_key_values.key_cache[0], torch.tensor([1.0]))
assert req.sampling_params.cfg_active_branch == "cfg_text"
assert req.sampling_params.cfg_branch_roles == ["cfg_text", "cfg_img"]
assert torch.equal(
req.sampling_params.cfg_branch_past_key_values["cfg_text"].key_cache[0],
torch.tensor([2.0]),
)
assert req.sampling_params.cfg_branch_kv_metadata == {"cfg_text": {"source": "cfg-text"}}
assert torch.equal(req.sampling_params.cfg_text_past_key_values.key_cache[0], torch.tensor([2.0]))
assert cfg_group.broadcast_calls == []
assert cfg_group.recv_calls == [0]
def test_tp_without_cfg_keeps_independent_receive_path(self):
mgr = _make_manager(from_tp=2, to_tp=2, local_rank=1)
req = SimpleNamespace(request_id="req-1", sampling_params=SimpleNamespace())
world_group = _MockBroadcastGroup(world_size=2, rank_in_group=1)
mgr.receive_multi_kv_cache = MagicMock(return_value=True)
with patch("vllm_omni.diffusion.distributed.parallel_state.get_world_group", return_value=world_group):
assert mgr.receive_multi_kv_cache_distributed(req, target_device=torch.device("cpu")) is True
mgr.receive_multi_kv_cache.assert_called_once_with(req, None, torch.device("cpu"))
# ── TP auto-detect ───────────────────────────────────────────────────
class TestAutoDetectTP:
def test_auto_detect_when_config_defaults(self):
"""When config from_tp/to_tp == 1 (default), manager should auto-detect."""
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="s0",
stage_id="s1",
need_recv_cache=True,
)
with (
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=4),
):
mgr = OmniKVTransferManager(config)
assert mgr._tp_topo.source_tp_size == 4
assert mgr._tp_topo.target_tp_size == 4
def test_explicit_tp_overrides_auto_detect(self):
config = OmniKVCacheConfig(
connector_config={"type": "mock"},
from_stage="s0",
stage_id="s1",
need_recv_cache=True,
from_tp=2,
to_tp=4,
)
with (
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_local_tp_rank", return_value=0),
patch("vllm_omni.distributed.omni_connectors.kv_transfer_manager.get_tp_world_size", return_value=8),
):
mgr = OmniKVTransferManager(config)
assert mgr._tp_topo.source_tp_size == 2
assert mgr._tp_topo.target_tp_size == 4
@@ -7,7 +7,9 @@ import pytest
from vllm_omni.distributed.omni_coordinator import (
InstanceInfo,
LeastQueueLengthBalancer,
RandomBalancer,
RoundRobinBalancer,
StageStatus,
)
@@ -60,3 +62,173 @@ def test_load_balancer_select_returns_valid_index():
assert isinstance(index, int)
assert 0 <= index < len(instances)
def test_round_robin_balancer_cycles_instances():
now = time()
instances = [
InstanceInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
queue_length=1,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
]
balancer = RoundRobinBalancer()
results = [balancer.select({}, instances) for _ in range(5)]
# Default start_index=0 => 0,1,2,0,1
assert results == [0, 1, 2, 0, 1]
def test_round_robin_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
RoundRobinBalancer().select({}, [])
def test_round_robin_balancer_after_large_index_and_shorter_list():
"""Large start_index % len(instances) then counter wraps with shorter list."""
now = time()
two = [
InstanceInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
]
balancer = RoundRobinBalancer(start_index=7)
assert balancer.select({}, two) == 1 # 7 % 2
assert balancer.select({}, two) == 0 # next index wrapped to 0
def test_least_queue_length_balancer_picks_min_queue():
now = time()
instances = [
InstanceInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
queue_length=2,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
queue_length=0,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
queue_length=5,
last_heartbeat=now,
registered_at=now,
),
]
balancer = LeastQueueLengthBalancer()
index = balancer.select({}, instances)
assert index == 1
def test_least_queue_length_balancer_empty_instances_raises():
with pytest.raises(ValueError, match="instances must not be empty"):
LeastQueueLengthBalancer().select({}, [])
def test_least_queue_length_balancer_equal_queues_uses_choice(mocker):
now = time()
instances = [
InstanceInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10002",
output_addr="tcp://host:10002-out",
stage_id=0,
status=StageStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
InstanceInfo(
input_addr="tcp://host:10003",
output_addr="tcp://host:10003-out",
stage_id=1,
status=StageStatus.UP,
queue_length=3,
last_heartbeat=now,
registered_at=now,
),
]
balancer = LeastQueueLengthBalancer()
mocker.patch(
"vllm_omni.distributed.omni_coordinator.load_balancer.random.choice",
return_value=2,
)
assert balancer.select({}, instances) == 2
def test_least_queue_length_balancer_negative_queue_raises():
now = time()
instances = [
InstanceInfo(
input_addr="tcp://host:10001",
output_addr="tcp://host:10001-out",
stage_id=0,
status=StageStatus.UP,
queue_length=-1,
last_heartbeat=now,
registered_at=now,
),
]
with pytest.raises(ValueError, match="queue_length must be non-negative"):
LeastQueueLengthBalancer().select({}, instances)
+22 -10
View File
@@ -1,16 +1,18 @@
from __future__ import annotations
import os
import shutil
import subprocess
from contextlib import contextmanager
from dataclasses import dataclass
from io import BytesIO
from pathlib import Path
import pytest
import requests
import torch
from PIL import Image
from tests.conftest import OmniServer, OmniServerParams
from tests.helpers.runtime import OmniServer, OmniServerParams
def pytest_addoption(parser):
@@ -183,16 +185,26 @@ def accuracy_artifact_root() -> Path:
return root
def reset_artifact_dir(path: Path) -> Path:
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True, exist_ok=True)
return path
@pytest.fixture(scope="session")
def qwen_bear_image(accuracy_artifact_root: Path) -> Image.Image:
"""Download the Qwen bear image from the URL and save it to the accuracy artifact root."""
QWEN_BEAR_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/qwen-bear.png"
response = requests.get(QWEN_BEAR_IMAGE_URL, timeout=60)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
image.save(accuracy_artifact_root / "qwen_bear.png")
return image
def infer_model_label(model: str) -> str:
label = Path(model.rstrip("/\\")).name or "model"
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in label)
@pytest.fixture(scope="session")
def rabbit_image(accuracy_artifact_root: Path) -> Image.Image:
"""Download the rabbit image from the URL and save it to the accuracy artifact root."""
RABBIT_IMAGE_URL = "https://vllm-public-assets.s3.us-west-2.amazonaws.com/omni-assets/rabbit.png"
response = requests.get(RABBIT_IMAGE_URL, timeout=60)
response.raise_for_status()
image = Image.open(BytesIO(response.content)).convert("RGB")
image.save(accuracy_artifact_root / "rabbit.png")
return image
def _build_accuracy_server_config(
+115
View File
@@ -0,0 +1,115 @@
from pathlib import Path
import numpy as np
import pytest
import torch
from PIL import Image
from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure
def reset_artifact_dir(path: Path) -> Path:
import shutil
if path.exists():
shutil.rmtree(path)
path.mkdir(parents=True, exist_ok=True)
return path
def infer_model_label(model: str) -> str:
label = Path(model.rstrip("/\\")).name or "model"
return "".join(char if char.isalnum() or char in {"-", "_"} else "_" for char in label)
def model_output_dir(parent_dir: Path, model: str) -> Path:
safe_model_name = model.split("/")[-1].replace(".", "_")
path = parent_dir / safe_model_name
path.mkdir(parents=True, exist_ok=True)
return path
def assert_similarity(
*,
model_name: str,
vllm_image: Image.Image,
diffusers_image: Image.Image,
ssim_threshold: float,
psnr_threshold: float,
width: int | None = None,
height: int | None = None,
compare_mode: str = "RGB",
) -> None:
requested_size = (width, height) if width is not None and height is not None else None
if requested_size is not None and diffusers_image.size != requested_size:
pytest.skip(
"Skipping as diffusers baseline output is corrupt and not comparable: "
f"dimensions do not match requested size; requested={requested_size}, got={diffusers_image.size}."
)
assert vllm_image.size == diffusers_image.size, (
f"Online and diffusers output sizes mismatch: online={vllm_image.size}, diffusers={diffusers_image.size}"
)
ssim_score, psnr_score = compute_image_ssim_psnr(
prediction=vllm_image,
reference=diffusers_image,
compare_mode=compare_mode,
)
print(f"{model_name} similarity metrics:")
print(f" SSIM: value={ssim_score:.6f}, threshold>={ssim_threshold:.6f}, range=[-1, 1], higher_is_better=True")
print(
f" PSNR: value={psnr_score:.6f} dB, threshold>={psnr_threshold:.6f} dB, range=[0, +inf), higher_is_better=True"
)
assert ssim_score >= ssim_threshold, (
f"SSIM below threshold for {model_name}: got {ssim_score:.6f}, expected >= {ssim_threshold:.6f}."
)
assert psnr_score >= psnr_threshold, (
f"PSNR below threshold for {model_name}: got {psnr_score:.6f}, expected >= {psnr_threshold:.6f}."
)
def assert_image_sequence_similarity(
*,
model_name: str,
vllm_images: list[Image.Image],
diffusers_images: list[Image.Image],
ssim_threshold: float,
psnr_threshold: float,
compare_mode: str = "RGB",
) -> None:
assert len(vllm_images) == len(diffusers_images), (
f"Output image count mismatch for {model_name}: online={len(vllm_images)}, diffusers={len(diffusers_images)}"
)
for index, (vllm_image, diffusers_image) in enumerate(zip(vllm_images, diffusers_images, strict=True), start=1):
assert_similarity(
model_name=f"{model_name}[layer={index}]",
vllm_image=vllm_image,
diffusers_image=diffusers_image,
ssim_threshold=ssim_threshold,
psnr_threshold=psnr_threshold,
compare_mode=compare_mode,
)
def compute_image_ssim_psnr(
*,
prediction: Image.Image,
reference: Image.Image,
compare_mode: str = "RGB",
) -> tuple[float, float]:
pred_tensor = _pil_to_batched_tensor(prediction, compare_mode=compare_mode)
ref_tensor = _pil_to_batched_tensor(reference, compare_mode=compare_mode)
ssim_metric = StructuralSimilarityIndexMeasure(data_range=1.0)
psnr_metric = PeakSignalNoiseRatio(data_range=1.0)
ssim_value = float(ssim_metric(pred_tensor, ref_tensor).item())
psnr_value = float(psnr_metric(pred_tensor, ref_tensor).item())
return ssim_value, psnr_value
def _pil_to_batched_tensor(image: Image.Image, *, compare_mode: str) -> torch.Tensor:
array = np.asarray(image.convert(compare_mode), dtype=np.float32) / 255.0
tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
return tensor
@@ -6,8 +6,8 @@ from pathlib import Path
import pytest
from benchmarks.accuracy.text_to_image.gbench import main as gbench_main
from tests.e2e.accuracy.conftest import infer_model_label, reset_artifact_dir
from tests.utils import hardware_test
from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir
from tests.helpers.mark import hardware_test
@pytest.mark.advanced_model
@@ -7,8 +7,8 @@ import pytest
from benchmarks.accuracy.image_to_image.gedit_bench import GROUPS
from benchmarks.accuracy.image_to_image.gedit_bench import main as gedit_main
from tests.e2e.accuracy.conftest import infer_model_label, reset_artifact_dir
from tests.utils import hardware_test
from tests.e2e.accuracy.helpers import infer_model_label, reset_artifact_dir
from tests.helpers.mark import hardware_test
@pytest.mark.advanced_model
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import base64
import gc
import io
import os
from pathlib import Path
import pytest
import requests
import torch
from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from PIL import Image
from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
MODEL_ID = "Qwen/Qwen-Image"
MODEL_ENV_VAR = "QWEN_IMAGE_MODEL"
PROMPT = "A photo of a cat sitting on a laptop keyboard, digital art style."
NEGATIVE_PROMPT = "blurry, low quality"
WIDTH = 512
HEIGHT = 512
NUM_INFERENCE_STEPS = 20
TRUE_CFG_SCALE = 4.0
SEED = 42
SSIM_THRESHOLD = 0.97
PSNR_THRESHOLD = 30.0
def _model_name() -> str:
return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
def _local_files_only(model: str) -> bool:
return Path(model).exists()
def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image:
server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
with OmniServer(model, server_args, use_omni=True) as omni_server:
response = requests.post(
f"http://{omni_server.host}:{omni_server.port}/v1/images/generations",
json={
"model": omni_server.model,
"prompt": PROMPT,
"size": f"{WIDTH}x{HEIGHT}",
"n": 1,
"response_format": "b64_json",
"negative_prompt": NEGATIVE_PROMPT,
"num_inference_steps": NUM_INFERENCE_STEPS,
"true_cfg_scale": TRUE_CFG_SCALE,
"seed": SEED,
},
timeout=600,
)
response.raise_for_status()
payload = response.json()
assert len(payload["data"]) == 1
image_bytes = base64.b64decode(payload["data"][0]["b64_json"])
image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
image.load()
image.save(output_path)
return image
def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image:
run_pre_test_cleanup(enable_force=True)
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
model,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
local_files_only=_local_files_only(model),
).to("cuda")
generator = torch.Generator(device="cuda").manual_seed(SEED)
result = pipe( # pyright: ignore[reportCallIssue]
prompt=PROMPT,
negative_prompt=NEGATIVE_PROMPT,
width=WIDTH,
height=HEIGHT,
num_inference_steps=NUM_INFERENCE_STEPS,
true_cfg_scale=TRUE_CFG_SCALE,
generator=generator,
)
output_image = result.images[0].convert("RGB")
output_image.save(output_path)
return output_image
finally:
if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
pipe.maybe_free_model_hooks()
del pipe
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
run_post_test_cleanup(enable_force=True)
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None:
model = _model_name()
output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID)
vllm_output = _run_vllm_omni_qwen_image(model=model, output_path=output_dir / "vllm_omni.png")
diffusers_output = _run_diffusers_qwen_image(model=model, output_path=output_dir / "diffusers.png")
assert_similarity(
model_name=MODEL_ID,
vllm_image=vllm_output,
diffusers_image=diffusers_output,
width=WIDTH,
height=HEIGHT,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
+229
View File
@@ -0,0 +1,229 @@
from __future__ import annotations
import gc
from pathlib import Path
import pytest
import requests
import torch
from diffusers import QwenImageEditPipeline, QwenImageEditPlusPipeline
from PIL import Image
from benchmarks.accuracy.common import decode_base64_image, pil_to_png_bytes
from tests.e2e.accuracy.helpers import assert_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
SINGLE_MODEL = "Qwen/Qwen-Image-Edit"
MULTIPLE_MODEL = "Qwen/Qwen-Image-Edit-2509"
WIDTH = 512
HEIGHT = 512
NUM_INFERENCE_STEPS = 20
TRUE_CFG_SCALE = 4.0
SEED = 42
SSIM_THRESHOLD = 0.94
PSNR_THRESHOLD = 28.0
PROMPT_SINGLE_IMAGE = "The input is a 2D cartoon bear mascot. Restyle it into a painterly oil artwork with warm colors while preserving the main structure."
PROMPT_MULTIPLE_IMAGE = "Put the cartoon bear mascot and the furry rabbit into one coherent scene with a painterly oil artwork style and consistent lighting."
NEGATIVE_PROMPT = "low quality, blurry, artifacts, distortion"
SERVER_ARGS = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
def _run_vllm_omni_image_edit(
*,
omni_server: OmniServer,
prompt: str,
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
response = requests.post(
f"http://{omni_server.host}:{omni_server.port}/v1/images/edits",
data={
"model": omni_server.model,
"prompt": prompt,
"size": f"{WIDTH}x{HEIGHT}",
"n": 1,
"response_format": "b64_json",
"negative_prompt": NEGATIVE_PROMPT,
"num_inference_steps": NUM_INFERENCE_STEPS,
"true_cfg_scale": TRUE_CFG_SCALE,
"seed": SEED,
},
files=[
("image", (f"image_{index}.png", pil_to_png_bytes(image), "image/png"))
for index, image in enumerate(input_images)
],
timeout=600,
)
response.raise_for_status()
payload = response.json()
assert len(payload["data"]) == 1
image = decode_base64_image(payload["data"][0]["b64_json"])
image.load()
image.save(output_path)
return image
def _run_diffusers_image_edit(
*,
model: str,
pipeline_class: type[QwenImageEditPipeline] | type[QwenImageEditPlusPipeline],
prompt: str,
input_images: list[Image.Image],
output_path: Path,
) -> Image.Image:
run_pre_test_cleanup(enable_force=True)
pipe: QwenImageEditPipeline | QwenImageEditPlusPipeline | None = None
device = torch.device("cuda:0")
torch.cuda.set_device(device)
try:
images = input_images[0] if len(input_images) == 1 else input_images
pipe = pipeline_class.from_pretrained(
model,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
).to(device)
pipe.set_progress_bar_config(disable=False)
generator = torch.Generator(device=device).manual_seed(SEED)
result = pipe( # pyright: ignore[reportCallIssue]
prompt=prompt,
image=images,
negative_prompt=NEGATIVE_PROMPT,
num_inference_steps=NUM_INFERENCE_STEPS,
true_cfg_scale=TRUE_CFG_SCALE,
width=WIDTH,
height=HEIGHT,
generator=generator,
)
output_image = result.images[0].convert("RGB") # pyright: ignore[reportAttributeAccessIssue]
output_image.save(output_path)
return output_image
finally:
if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
pipe.maybe_free_model_hooks()
del pipe
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
run_post_test_cleanup(enable_force=True)
def _vllm_omni_output_single_image(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, SINGLE_MODEL)
output_path = output_dir / "vllm_omni_single.png"
with OmniServer(model=SINGLE_MODEL, serve_args=SERVER_ARGS) as server:
output = _run_vllm_omni_image_edit(
omni_server=server,
prompt=PROMPT_SINGLE_IMAGE,
input_images=[qwen_bear_image],
output_path=output_path,
)
return output
def _diffusers_output_single_image(accuracy_artifact_root: Path, qwen_bear_image: Image.Image) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, SINGLE_MODEL)
output_path = output_dir / "diffusers_single.png"
return _run_diffusers_image_edit(
model=SINGLE_MODEL,
pipeline_class=QwenImageEditPipeline,
prompt=PROMPT_SINGLE_IMAGE,
input_images=[qwen_bear_image],
output_path=output_path,
)
def _vllm_omni_output_multiple_image(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
rabbit_image: Image.Image,
) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, MULTIPLE_MODEL)
output_path = output_dir / "vllm_omni_multiple.png"
with OmniServer(model=MULTIPLE_MODEL, serve_args=SERVER_ARGS) as server:
output = _run_vllm_omni_image_edit(
omni_server=server,
prompt=PROMPT_MULTIPLE_IMAGE,
input_images=[qwen_bear_image, rabbit_image],
output_path=output_path,
)
return output
def _diffusers_output_multiple_image(
accuracy_artifact_root: Path, qwen_bear_image: Image.Image, rabbit_image: Image.Image
) -> Image.Image:
output_dir = model_output_dir(accuracy_artifact_root, MULTIPLE_MODEL)
output_path = output_dir / "diffusers_multiple.png"
return _run_diffusers_image_edit(
model=MULTIPLE_MODEL,
pipeline_class=QwenImageEditPlusPipeline,
prompt=PROMPT_MULTIPLE_IMAGE,
input_images=[qwen_bear_image, rabbit_image],
output_path=output_path,
)
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_edit_single_matches_diffusers(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
) -> None:
vllm_image = _vllm_omni_output_single_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
)
diffusers_image = _diffusers_output_single_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
)
assert_similarity(
model_name=SINGLE_MODEL,
vllm_image=vllm_image,
diffusers_image=diffusers_image,
width=WIDTH,
height=HEIGHT,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
@pytest.mark.skip(
reason="Skipping as the second image seems to be ignored by the API. Will come back to this later after #2772 is merged."
)
def test_qwen_image_edit_multiple_matches_diffusers(
accuracy_artifact_root: Path,
qwen_bear_image: Image.Image,
rabbit_image: Image.Image,
) -> None:
vllm_image = _vllm_omni_output_multiple_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
rabbit_image=rabbit_image,
)
diffusers_image = _diffusers_output_multiple_image(
accuracy_artifact_root=accuracy_artifact_root,
qwen_bear_image=qwen_bear_image,
rabbit_image=rabbit_image,
)
assert_similarity(
model_name=MULTIPLE_MODEL,
vllm_image=vllm_image,
diffusers_image=diffusers_image,
width=WIDTH,
height=HEIGHT,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
)
@@ -0,0 +1,148 @@
from __future__ import annotations
import base64
import gc
import io
import os
from pathlib import Path
import pytest
import requests
import torch
from diffusers.pipelines.pipeline_utils import DiffusionPipeline
from PIL import Image
from tests.e2e.accuracy.helpers import assert_image_sequence_similarity, model_output_dir
from tests.helpers.env import run_post_test_cleanup, run_pre_test_cleanup
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServer
MODEL_ID = "Qwen/Qwen-Image-Layered"
MODEL_ENV_VAR = "QWEN_IMAGE_LAYERED_MODEL"
PROMPT = "decompose into layers"
NEGATIVE_PROMPT = " "
NUM_INFERENCE_STEPS = 20
TRUE_CFG_SCALE = 4.0
SEED = 777
LAYERS = 3
RESOLUTION = 640
SSIM_THRESHOLD = 0.97
PSNR_THRESHOLD = 30.0
def _model_name() -> str:
return os.environ.get(MODEL_ENV_VAR, MODEL_ID)
def _local_files_only(model: str) -> bool:
return Path(model).exists()
def _normalize_layered_images(images: object) -> list[Image.Image]:
if not isinstance(images, list) or not images:
raise AssertionError(f"Unexpected layered output container: {type(images).__name__}")
first_item = images[0]
if isinstance(first_item, Image.Image):
return [image.convert("RGBA") for image in images if isinstance(image, Image.Image)]
if isinstance(first_item, (list, tuple)):
return [image.convert("RGBA") for image in first_item if isinstance(image, Image.Image)]
raise AssertionError(f"Unexpected layered image element type: {type(first_item).__name__}")
def _run_vllm_omni_qwen_image_layered(*, model: str, input_image: Image.Image, output_dir: Path) -> list[Image.Image]:
input_image.save(output_dir / "input.png")
server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"]
with OmniServer(model, server_args, use_omni=True) as omni_server:
buffer = io.BytesIO()
input_image.save(buffer, format="PNG")
buffer.seek(0)
response = requests.post(
f"http://{omni_server.host}:{omni_server.port}/v1/images/edits",
data={
"model": omni_server.model,
"prompt": PROMPT,
"size": "auto",
"n": 1,
"response_format": "b64_json",
"negative_prompt": NEGATIVE_PROMPT,
"num_inference_steps": NUM_INFERENCE_STEPS,
"true_cfg_scale": TRUE_CFG_SCALE,
"seed": SEED,
"layers": LAYERS,
"resolution": RESOLUTION,
},
files=[("image", ("input.png", buffer, "image/png"))],
timeout=600,
)
response.raise_for_status()
payload = response.json()
assert len(payload["data"]) == LAYERS
output_images = []
for item in payload["data"]:
image_bytes = base64.b64decode(item["b64_json"])
image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
image.load()
output_images.append(image)
for index, image in enumerate(output_images, start=1):
image.save(output_dir / f"vllm_omni_layer_{index}.png")
return output_images
def _run_diffusers_qwen_image_layered(*, model: str, input_image: Image.Image, output_dir: Path) -> list[Image.Image]:
run_pre_test_cleanup(enable_force=True)
pipe: DiffusionPipeline | None = None
try:
pipe = DiffusionPipeline.from_pretrained(
model,
torch_dtype=torch.bfloat16,
trust_remote_code=True,
local_files_only=_local_files_only(model),
).to("cuda")
generator = torch.Generator(device="cuda").manual_seed(SEED)
result = pipe( # pyright: ignore[reportCallIssue]
image=input_image,
prompt=PROMPT,
negative_prompt=NEGATIVE_PROMPT,
num_inference_steps=NUM_INFERENCE_STEPS,
true_cfg_scale=TRUE_CFG_SCALE,
generator=generator,
num_images_per_prompt=1,
layers=LAYERS,
resolution=RESOLUTION,
)
output_images = _normalize_layered_images(result.images)
assert len(output_images) == LAYERS, f"Expected {LAYERS} diffusers layers, got {len(output_images)}"
for index, image in enumerate(output_images, start=1):
image.save(output_dir / f"diffusers_layer_{index}.png")
return output_images
finally:
if pipe is not None and hasattr(pipe, "maybe_free_model_hooks"):
pipe.maybe_free_model_hooks()
del pipe
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
run_post_test_cleanup(enable_force=True)
@pytest.mark.advanced_model
@pytest.mark.benchmark
@pytest.mark.diffusion
@hardware_test(res={"cuda": "H100"}, num_cards=1)
def test_qwen_image_layered_matches_diffusers(accuracy_artifact_root: Path, qwen_bear_image: Image.Image) -> None:
model = _model_name()
output_dir = model_output_dir(accuracy_artifact_root, MODEL_ID)
input_image = qwen_bear_image.convert("RGBA")
vllm_outputs = _run_vllm_omni_qwen_image_layered(model=model, input_image=input_image, output_dir=output_dir)
diffusers_outputs = _run_diffusers_qwen_image_layered(model=model, input_image=input_image, output_dir=output_dir)
assert_image_sequence_similarity(
model_name=MODEL_ID,
vllm_images=vllm_outputs,
diffusers_images=diffusers_outputs,
ssim_threshold=SSIM_THRESHOLD,
psnr_threshold=PSNR_THRESHOLD,
compare_mode="RGBA",
)
@@ -22,7 +22,6 @@ import torch
from diffusers import UniPCMultistepScheduler
from PIL import Image
from tests.conftest import OmniServerParams
from tests.e2e.accuracy.wan22_i2v.run_wan22_i2v_diffusers_cp import (
_configure_scheduler,
_ensure_wan_ftfy_fallback,
@@ -48,7 +47,8 @@ from tests.e2e.accuracy.wan22_i2v.wan22_i2v_video_similarity_common import (
SSIM_THRESHOLD,
WIDTH,
)
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniServerParams
def test_parse_video_metadata_extracts_dimensions_and_fps() -> None:
@@ -26,7 +26,7 @@ from contextlib import ExitStack
import pytest
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -19,7 +19,7 @@ import numpy as np
import pytest
from transformers import AutoTokenizer
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -10,7 +10,7 @@ import pytest
from tests.e2e.offline_inference.custom_pipeline.worker_extension import (
vLLMOmniColocateWorkerExtensionForTest,
)
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.worker.diffusion_worker import CustomPipelineWorkerExtension
from vllm_omni.entrypoints.async_omni import AsyncOmni
@@ -64,9 +64,6 @@ stage_args:
# Top-level runtime config with Mooncake connector
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 1
connectors:
mooncake_connector:
name: MooncakeConnector
@@ -80,4 +77,3 @@ runtime:
edges:
- from: 0
to: 1
window_size: -1
@@ -62,10 +62,6 @@ stage_args:
# Runtime edges
runtime:
enabled: true
defaults:
window_size: -1
max_inflight: 1
# Distributed connectors configuration (optional)
# More connectors will be supported in the future.
connectors:
@@ -78,4 +74,3 @@ runtime:
edges:
- from: 0
to: 1
window_size: -1
@@ -1,103 +0,0 @@
# stage config for running qwen2.5-omni for multi-stage omni runtime.
# This config is optimized for CI e2e tests.
stage_args:
- stage_id: 0
runtime:
process: true # Run this stage in a separate process
devices: "0"
engine_args:
model_stage: thinker
model_arch: Qwen2_5OmniForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
max_model_len: 896
max_num_batched_tokens: 896
max_num_seqs: 1
gpu_memory_utilization: 0.8
skip_mm_profiling: true
enforce_eager: true # Now we only support eager mode
trust_remote_code: true
engine_output_type: latent
enable_prefix_caching: false
mm_processor_cache_gb: 0
is_comprehension: true
final_output: true
final_output_type: text
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 128
seed: 42
detokenize: True
repetition_penalty: 1.1
- stage_id: 1
runtime:
process: true
devices: "1"
engine_args:
model_stage: talker
model_arch: Qwen2_5OmniForConditionalGeneration
worker_type: ar
scheduler_cls: vllm_omni.core.sched.omni_ar_scheduler.OmniARScheduler
max_model_len: 896
max_num_batched_tokens: 896
max_num_seqs: 1
gpu_memory_utilization: 0.8
skip_mm_profiling: true
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
engine_output_type: latent
engine_input_source: [0]
custom_process_input_func: vllm_omni.model_executor.stage_input_processors.qwen2_5_omni.thinker2talker
default_sampling_params:
temperature: 0.9
top_p: 0.8
top_k: 40
max_tokens: 128
seed: 42
detokenize: True
repetition_penalty: 1.05
stop_token_ids: [8294]
- stage_id: 2
runtime:
process: true
devices: "0" # Example: use a different GPU than the previous stage; use "0" if single GPU
engine_args:
model_stage: code2wav
max_num_seqs: 1
model_arch: Qwen2_5OmniForConditionalGeneration
worker_type: generation
scheduler_cls: vllm_omni.core.sched.omni_generation_scheduler.OmniGenerationScheduler
gpu_memory_utilization: 0.15
enforce_eager: true
trust_remote_code: true
enable_prefix_caching: false
engine_output_type: audio
engine_input_source: [1]
final_output: true
final_output_type: audio
default_sampling_params:
temperature: 0.0
top_p: 1.0
top_k: -1
max_tokens: 128
seed: 42
detokenize: True
repetition_penalty: 1.1
# Top-level runtime config (concise): default windows and stage edges
runtime:
enabled: true
defaults:
window_size: -1 # Simplified: trigger downstream only after full upstream completion
max_inflight: 1 # Simplified: process serially within each stage
edges:
- from: 0 # thinker → talker: trigger only after receiving full input (-1)
to: 1
window_size: -1
- from: 1 # talker → code2wav: trigger only after receiving full input (-1)
to: 2
window_size: -1
@@ -22,9 +22,10 @@ import pytest
from PIL import Image
from vllm.assets.image import ImageAsset
from tests.conftest import OmniRunner, modify_stage_config
from tests.utils import hardware_test
from vllm_omni import Omni
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import modify_stage_config
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.platforms import current_omni_platform
# Reference pixel data extracted from the known-good output image
@@ -32,30 +33,30 @@ from vllm_omni.platforms import current_omni_platform
# prompt='Change the grass color to red',
# input image: 2560px-Gfp-wisconsin-madison-the-nature-boardwalk.jpg
REFERENCE_PIXELS = [
{"position": (100, 100), "rgb": (157, 172, 217)},
{"position": (400, 50), "rgb": (105, 144, 218)},
{"position": (700, 100), "rgb": (118, 159, 233)},
{"position": (150, 400), "rgb": (195, 34, 60)},
{"position": (512, 336), "rgb": (222, 214, 193)},
{"position": (700, 400), "rgb": (197, 15, 43)},
{"position": (100, 600), "rgb": (105, 13, 18)},
{"position": (400, 600), "rgb": (169, 33, 44)},
{"position": (700, 600), "rgb": (101, 86, 93)},
{"position": (256, 256), "rgb": (181, 202, 222)},
{"position": (100, 100), "rgb": (156, 172, 217)},
{"position": (400, 50), "rgb": (105, 144, 217)},
{"position": (700, 100), "rgb": (118, 159, 232)},
{"position": (150, 400), "rgb": (180, 22, 52)},
{"position": (512, 336), "rgb": (221, 211, 194)},
{"position": (700, 400), "rgb": (192, 10, 46)},
{"position": (100, 600), "rgb": (102, 12, 22)},
{"position": (400, 600), "rgb": (161, 28, 47)},
{"position": (700, 600), "rgb": (100, 87, 94)},
{"position": (256, 256), "rgb": (181, 201, 221)},
]
if current_omni_platform.is_rocm():
REFERENCE_PIXELS = [
{"position": (100, 100), "rgb": (156, 172, 215)},
{"position": (400, 50), "rgb": (106, 144, 216)},
{"position": (700, 100), "rgb": (118, 158, 231)},
{"position": (150, 400), "rgb": (183, 23, 48)},
{"position": (512, 336), "rgb": (218, 215, 191)},
{"position": (700, 400), "rgb": (194, 14, 42)},
{"position": (100, 600), "rgb": (105, 10, 16)},
{"position": (400, 600), "rgb": (167, 33, 46)},
{"position": (700, 600), "rgb": (102, 86, 92)},
{"position": (256, 256), "rgb": (181, 201, 220)},
{"position": (100, 100), "rgb": (156, 172, 217)},
{"position": (400, 50), "rgb": (105, 144, 217)},
{"position": (700, 100), "rgb": (118, 159, 232)},
{"position": (150, 400), "rgb": (180, 22, 52)},
{"position": (512, 336), "rgb": (221, 211, 194)},
{"position": (700, 400), "rgb": (192, 10, 46)},
{"position": (100, 600), "rgb": (102, 12, 22)},
{"position": (400, 600), "rgb": (161, 28, 47)},
{"position": (700, 600), "rgb": (100, 87, 94)},
{"position": (256, 256), "rgb": (181, 201, 221)},
]
PIXEL_TOLERANCE = 10
@@ -31,9 +31,10 @@ import torch
from PIL import Image
from safetensors.torch import save_file
from tests.conftest import OmniRunner, modify_stage_config
from tests.utils import hardware_test
from vllm_omni import Omni
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import modify_stage_config
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.lora.request import LoRARequest
from vllm_omni.lora.utils import stable_lora_int_id
@@ -27,9 +27,10 @@ from typing import Any
import pytest
from PIL import Image
from tests.conftest import OmniRunner, modify_stage_config
from tests.utils import hardware_test
from vllm_omni import Omni
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import modify_stage_config
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.platforms import current_omni_platform
# Reference pixel data extracted from the known-good output image
@@ -37,30 +38,30 @@ from vllm_omni.platforms import current_omni_platform
# "Generated with seed=52, num_inference_steps=15,
# prompt='A futuristic city skyline at twilight, cyberpunk style'"
REFERENCE_PIXELS = [
{"position": (100, 100), "rgb": (121, 118, 100)},
{"position": (400, 50), "rgb": (163, 162, 143)},
{"position": (700, 100), "rgb": (170, 156, 127)},
{"position": (150, 400), "rgb": (129, 127, 112)},
{"position": (512, 512), "rgb": (135, 61, 59)},
{"position": (700, 400), "rgb": (205, 107, 43)},
{"position": (100, 700), "rgb": (197, 177, 157)},
{"position": (400, 700), "rgb": (139, 107, 86)},
{"position": (700, 700), "rgb": (247, 205, 146)},
{"position": (256, 256), "rgb": (171, 160, 153)},
{"position": (100, 100), "rgb": (115, 113, 94)},
{"position": (400, 50), "rgb": (159, 160, 144)},
{"position": (700, 100), "rgb": (164, 151, 123)},
{"position": (150, 400), "rgb": (120, 121, 107)},
{"position": (512, 512), "rgb": (165, 133, 127)},
{"position": (700, 400), "rgb": (217, 130, 66)},
{"position": (100, 700), "rgb": (191, 168, 152)},
{"position": (400, 700), "rgb": (130, 96, 77)},
{"position": (700, 700), "rgb": (247, 203, 140)},
{"position": (256, 256), "rgb": (167, 156, 150)},
]
if current_omni_platform.is_rocm():
REFERENCE_PIXELS = [
{"position": (100, 100), "rgb": (123, 119, 100)},
{"position": (400, 50), "rgb": (162, 161, 142)},
{"position": (700, 100), "rgb": (171, 156, 127)},
{"position": (150, 400), "rgb": (131, 128, 112)},
{"position": (512, 512), "rgb": (134, 61, 59)},
{"position": (700, 400), "rgb": (204, 107, 43)},
{"position": (100, 700), "rgb": (201, 180, 165)},
{"position": (400, 700), "rgb": (140, 108, 87)},
{"position": (700, 700), "rgb": (247, 205, 145)},
{"position": (256, 256), "rgb": (171, 160, 153)},
{"position": (100, 100), "rgb": (115, 113, 94)},
{"position": (400, 50), "rgb": (159, 160, 144)},
{"position": (700, 100), "rgb": (164, 151, 123)},
{"position": (150, 400), "rgb": (120, 121, 107)},
{"position": (512, 512), "rgb": (165, 133, 127)},
{"position": (700, 400), "rgb": (217, 130, 66)},
{"position": (100, 700), "rgb": (191, 168, 152)},
{"position": (400, 700), "rgb": (130, 96, 77)},
{"position": (700, 700), "rgb": (247, 203, 140)},
{"position": (256, 256), "rgb": (167, 156, 150)},
]
# Maximum allowed difference per color channel
@@ -26,8 +26,9 @@ from pathlib import Path
import pytest
from vllm.assets.image import ImageAsset
from tests.conftest import OmniRunner, modify_stage_config
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import modify_stage_config
MODEL_NAME = "ByteDance-Seed/BAGEL-7B-MoT"
STAGE_CONFIG = str(Path(__file__).parent / "stage_configs" / "bagel_sharedmemory_ci.yaml")
@@ -11,8 +11,8 @@ It uses minimal settings to keep test time short for CI.
import pytest
import torch
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
+50 -68
View File
@@ -15,19 +15,18 @@ from __future__ import annotations
import functools
import io
import os
import tempfile
from pathlib import Path
from urllib.request import urlopen
import numpy as np
import pytest
import soundfile as sf
import yaml
from huggingface_hub import snapshot_download
from vllm.sampling_params import SamplingParams
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from tests.helpers.stage_config import get_deploy_config_path
from vllm_omni.model_executor.models.cosyvoice3.config import CosyVoice3Config
from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer
@@ -44,13 +43,9 @@ REFERENCE_STAGE0_TEMPERATURE = 1.0
REFERENCE_STAGE0_REPETITION_PENALTY = 2.0
def _stage_config(name: str) -> str:
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
STAGE_CONFIGS = [
_stage_config("cosyvoice3.yaml"),
_stage_config("cosyvoice3_async_chunk.yaml"),
ASYNC_CHUNK_MODES = [
pytest.param(False, id="sync"),
pytest.param(True, id="async_chunk"),
]
@@ -138,19 +133,6 @@ def _get_stage_engine_outputs(omni_runner: OmniRunner, stage_id: int):
raise AttributeError("Unable to locate stage outputs on Omni runner")
def _patched_stage_config(base_stage_config: str, model_dir: Path, tmp_dir: Path) -> str:
cfg = yaml.safe_load(Path(base_stage_config).read_text(encoding="utf-8"))
tokenizer_path = str(model_dir / "CosyVoice-BlankEN")
for stage in cfg.get("stage_args", []):
engine_args = stage.setdefault("engine_args", {})
engine_args["tokenizer"] = tokenizer_path
engine_args["enforce_eager"] = True
engine_args["hf_overrides"] = {"architectures": ["CosyVoice3Model"]}
out_path = tmp_dir / Path(base_stage_config).name
out_path.write_text(yaml.safe_dump(cfg, sort_keys=False), encoding="utf-8")
return str(out_path)
def _build_reference_inputs(prompt_audio: tuple[np.ndarray, int]) -> list[dict[str, object]]:
return [
{
@@ -165,54 +147,54 @@ def _build_reference_inputs(prompt_audio: tuple[np.ndarray, int]) -> list[dict[s
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "L4"}, num_cards=1)
@pytest.mark.parametrize("base_stage_config", STAGE_CONFIGS)
def test_cosyvoice3_offline_reference_zero_shot(base_stage_config: str) -> None:
@pytest.mark.parametrize("async_chunk", ASYNC_CHUNK_MODES)
def test_cosyvoice3_offline_reference_zero_shot(async_chunk: bool) -> None:
"""CosyVoice3 zero-shot reference inference should stop cleanly and produce sane audio."""
prompt_audio, prompt_sr = _load_reference_prompt_wav()
model_dir = _resolve_model_dir()
expected_stop_token = int(CosyVoice3Config().llm["eos_token_id"])
with tempfile.TemporaryDirectory(prefix="cv3-e2e-") as tmp:
stage_config = _patched_stage_config(base_stage_config, model_dir, Path(tmp))
with OmniRunner(
str(model_dir), seed=42, stage_configs_path=stage_config, stage_init_timeout=300
) as omni_runner:
sampling_params_list = omni_runner.get_default_sampling_params_list()
sampling_params_list[0] = _reference_zero_shot_stage0_sampling(text=REFERENCE_SYNTH_TEXT)
with OmniRunner(
str(model_dir),
seed=42,
stage_configs_path=get_deploy_config_path("cosyvoice3.yaml"),
async_chunk=async_chunk,
stage_init_timeout=300,
) as omni_runner:
sampling_params_list = omni_runner.get_default_sampling_params_list()
sampling_params_list[0] = _reference_zero_shot_stage0_sampling(text=REFERENCE_SYNTH_TEXT)
outputs = omni_runner.omni.generate(
_build_reference_inputs((prompt_audio, prompt_sr)), sampling_params_list
outputs = omni_runner.omni.generate(_build_reference_inputs((prompt_audio, prompt_sr)), sampling_params_list)
assert outputs, "No outputs returned"
audio_mm = outputs[0].multimodal_output
assert "audio" in audio_mm, "No audio output found"
audio = _concat_audio(audio_mm["audio"])
assert audio.size > 0, "Generated audio is empty"
sr_val = audio_mm.get("sr", 24000)
if isinstance(sr_val, list) and sr_val:
sr_val = sr_val[-1]
if hasattr(sr_val, "item"):
sr_val = sr_val.item()
sr = int(sr_val)
assert sr == 24000, f"Unexpected sample_rate={sr}"
duration_s = audio.size / sr
assert 2.8 <= duration_s <= 8.8, f"Unexpected duration={duration_s:.3f}s (samples={audio.size}, sr={sr})"
stage0_outputs = _get_stage_engine_outputs(omni_runner, 0)
if stage0_outputs:
completion = stage0_outputs[0].outputs[0]
finish_reason = getattr(completion, "finish_reason", None)
stop_reason = getattr(completion, "stop_reason", None)
num_tokens = len(getattr(completion, "token_ids", []) or [])
assert finish_reason == "stop", f"Stage-0 finish_reason={finish_reason}, expected 'stop'"
assert int(stop_reason) == expected_stop_token, (
f"Stage-0 stop_reason={stop_reason}, expected {expected_stop_token}"
)
assert outputs, "No outputs returned"
audio_mm = outputs[0].multimodal_output
assert "audio" in audio_mm, "No audio output found"
audio = _concat_audio(audio_mm["audio"])
assert audio.size > 0, "Generated audio is empty"
sr_val = audio_mm.get("sr", 24000)
if isinstance(sr_val, list) and sr_val:
sr_val = sr_val[-1]
if hasattr(sr_val, "item"):
sr_val = sr_val.item()
sr = int(sr_val)
assert sr == 24000, f"Unexpected sample_rate={sr}"
duration_s = audio.size / sr
assert 2.8 <= duration_s <= 8.8, f"Unexpected duration={duration_s:.3f}s (samples={audio.size}, sr={sr})"
stage0_outputs = _get_stage_engine_outputs(omni_runner, 0)
if stage0_outputs:
completion = stage0_outputs[0].outputs[0]
finish_reason = getattr(completion, "finish_reason", None)
stop_reason = getattr(completion, "stop_reason", None)
num_tokens = len(getattr(completion, "token_ids", []) or [])
assert finish_reason == "stop", f"Stage-0 finish_reason={finish_reason}, expected 'stop'"
assert int(stop_reason) == expected_stop_token, (
f"Stage-0 stop_reason={stop_reason}, expected {expected_stop_token}"
)
assert 80 <= num_tokens <= 220, f"Stage-0 num_tokens={num_tokens}, expected sane stop-bound range"
else:
assert "async_chunk" in Path(base_stage_config).name, "Stage-0 produced no engine outputs"
assert 80 <= num_tokens <= 220, f"Stage-0 num_tokens={num_tokens}, expected sane stop-bound range"
else:
assert async_chunk, "Stage-0 produced no engine outputs"
@@ -4,8 +4,9 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.conftest import OmniRunner
from tests.utils import DeviceMemoryMonitor, hardware_test
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -2,8 +2,8 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.conftest import OmniRunner
from tests.utils import DeviceMemoryMonitor
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -7,7 +7,7 @@ import pytest
import torch
from safetensors.torch import save_file
from tests.conftest import OmniRunner
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -18,7 +18,7 @@ import pytest
import torch
from transformers import AutoTokenizer
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
@@ -18,8 +18,8 @@ import torch
import torch.distributed as dist
from PIL import Image
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
+37
View File
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Tests for Flux1 Schnell."""
import pytest
from PIL import Image
from vllm_omni.entrypoints.omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "black-forest-labs/FLUX.1-schnell"
@pytest.mark.core_model
@pytest.mark.diffusion
def test_flux_schnell_text_to_image():
"""Test FLUX.1-schnell text-to-image generation."""
omni = Omni(model=MODEL)
omni_outputs = list(
omni.generate(
prompts=["A photo of a cat sitting on a laptop"],
sampling_params_list=OmniDiffusionSamplingParams(
height=512,
width=512,
num_inference_steps=2,
seed=42,
),
)
)
assert len(omni_outputs) > 0
images = omni_outputs[0].images
assert len(images) == 1
assert isinstance(images[0], Image.Image)
assert images[0].size == (512, 512)
@@ -0,0 +1,216 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
End-to-end test for Flux2 Klein inpainting.
"""
# ruff: noqa: E402
import os
import sys
from pathlib import Path
import pytest
import torch
from PIL import Image, ImageDraw
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
REPO_ROOT = Path(__file__).resolve().parents[2]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from vllm_omni import Omni
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
MODEL = "black-forest-labs/FLUX.2-klein-4B"
_HEIGHT = 512
_WIDTH = 512
_NUM_INFERENCE_STEPS = 4
def _create_test_image(width: int = _WIDTH, height: int = _HEIGHT, color: tuple = (128, 128, 128)) -> Image.Image:
return Image.new("RGB", (width, height), color)
def _create_test_mask(width: int = _WIDTH, height: int = _HEIGHT) -> Image.Image:
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rectangle([width // 4, height // 4, width * 3 // 4, height * 3 // 4], fill=255)
return mask
def _create_test_inputs(color: tuple = (100, 150, 200)):
return _create_test_image(_WIDTH, _HEIGHT, color), _create_test_mask(_WIDTH, _HEIGHT)
def _extract_images_from_output(outputs: list) -> list[Image.Image]:
images = []
for req_output in outputs:
if hasattr(req_output, "images") and req_output.images:
images.extend(req_output.images)
elif hasattr(req_output, "request_output") and req_output.request_output:
stage_out = req_output.request_output
if isinstance(stage_out, OmniRequestOutput) and hasattr(stage_out, "images"):
images.extend(stage_out.images)
elif isinstance(stage_out, list):
for s in stage_out:
if hasattr(s, "images") and s.images:
images.extend(s.images)
return images
@pytest.mark.core_model
@pytest.mark.diffusion
def test_flux2_klein_inpaint_basic():
m = None
try:
m = Omni(model=MODEL)
input_image, mask_image = _create_test_inputs()
outputs = m.generate(
prompts=[
{
"prompt": "Fill in the masked area with a beautiful garden",
"multi_modal_data": {"image": input_image, "mask_image": mask_image},
}
],
sampling_params_list=OmniDiffusionSamplingParams(
height=_HEIGHT,
width=_WIDTH,
num_inference_steps=_NUM_INFERENCE_STEPS,
guidance_scale=0.0,
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
num_outputs_per_prompt=1,
),
)
images = _extract_images_from_output(list(outputs))
assert len(images) == 1
assert images[0].size == (_WIDTH, _HEIGHT)
finally:
if m is not None and hasattr(m, "close"):
m.close()
@pytest.mark.diffusion
def test_flux2_klein_inpaint_deterministic():
m = None
try:
m = Omni(model=MODEL)
input_image, mask_image = _create_test_inputs()
seed = 12345
gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(seed)
outputs1 = m.generate(
prompts=[
{
"prompt": "A red flower in a field",
"multi_modal_data": {"image": input_image, "mask_image": mask_image},
}
],
sampling_params_list=OmniDiffusionSamplingParams(
height=_HEIGHT,
width=_WIDTH,
num_inference_steps=_NUM_INFERENCE_STEPS,
guidance_scale=0.0,
generator=gen1,
num_outputs_per_prompt=1,
),
)
outputs2 = m.generate(
prompts=[
{
"prompt": "A red flower in a field",
"multi_modal_data": {"image": input_image, "mask_image": mask_image},
}
],
sampling_params_list=OmniDiffusionSamplingParams(
height=_HEIGHT,
width=_WIDTH,
num_inference_steps=_NUM_INFERENCE_STEPS,
guidance_scale=0.0,
generator=gen2,
num_outputs_per_prompt=1,
),
)
images1 = _extract_images_from_output(list(outputs1))
images2 = _extract_images_from_output(list(outputs2))
assert len(images1) == 1
assert len(images2) == 1
assert list(images1[0].getdata()) == list(images2[0].getdata()), (
"Same input with same seed should produce identical output. "
"This is critical for offline/online consistency."
)
finally:
if m is not None and hasattr(m, "close"):
m.close()
@pytest.mark.diffusion
def test_flux2_klein_inpaint_different_seeds_different_output():
m = None
try:
m = Omni(model=MODEL)
input_image, mask_image = _create_test_inputs()
gen1 = torch.Generator(current_omni_platform.device_type).manual_seed(42)
gen2 = torch.Generator(current_omni_platform.device_type).manual_seed(99999)
outputs1 = m.generate(
prompts=[
{
"prompt": "A beautiful landscape",
"multi_modal_data": {"image": input_image, "mask_image": mask_image},
}
],
sampling_params_list=OmniDiffusionSamplingParams(
height=_HEIGHT,
width=_WIDTH,
num_inference_steps=_NUM_INFERENCE_STEPS,
guidance_scale=0.0,
generator=gen1,
num_outputs_per_prompt=1,
),
)
outputs2 = m.generate(
prompts=[
{
"prompt": "A beautiful landscape",
"multi_modal_data": {"image": input_image, "mask_image": mask_image},
}
],
sampling_params_list=OmniDiffusionSamplingParams(
height=_HEIGHT,
width=_WIDTH,
num_inference_steps=_NUM_INFERENCE_STEPS,
guidance_scale=0.0,
generator=gen2,
num_outputs_per_prompt=1,
),
)
images1 = _extract_images_from_output(list(outputs1))
images2 = _extract_images_from_output(list(outputs2))
assert len(images1) == 1
assert len(images2) == 1
different_pixel_count = sum(1 for p1, p2 in zip(images1[0].getdata(), images2[0].getdata()) if p1 != p2)
assert different_pixel_count > 0, "Different seeds should produce different outputs"
finally:
if m is not None and hasattr(m, "close"):
m.close()
@@ -14,8 +14,9 @@ import pytest
import torch
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
from tests.conftest import OmniRunner
from tests.utils import DeviceMemoryMonitor, hardware_test
from tests.helpers.env import DeviceMemoryMonitor
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -13,7 +13,7 @@ import pytest
from PIL import Image
from vllm.assets.image import ImageAsset
from tests.conftest import OmniRunner
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -8,7 +8,7 @@ import torch.nn.functional as F
from PIL import Image
from transformers import CLIPModel, CLIPProcessor
from tests.conftest import OmniRunner
from tests.helpers.runtime import OmniRunner
from vllm_omni import Omni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
@@ -17,7 +17,7 @@ PROMPT = "A brown and white dog is running on the grass"
MODEL_NAME = "tencent/HunyuanImage-3.0"
LOCAL_CLIP_PATH = "openai/clip-vit-base-patch32"
REPO_ROOT = Path(__file__).resolve().parents[3]
STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image_3_moe.yaml"
STAGE_CONFIG_PATH = REPO_ROOT / "vllm_omni" / "model_executor" / "stage_configs" / "hunyuan_image3_t2i.yaml"
pytestmark = [pytest.mark.advanced_model, pytest.mark.diffusion]
@@ -11,7 +11,7 @@ import numpy as np
import pytest
from PIL import Image
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
REPO_ROOT = Path(__file__).resolve().parents[3]
T2V_EXAMPLE = REPO_ROOT / "examples" / "offline_inference" / "text_to_video" / "text_to_video.py"
@@ -8,8 +8,8 @@ import av
import numpy as np
import pytest
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.utils.media_utils import mux_video_audio_bytes
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -23,8 +23,10 @@ import pytest
import torch
from vllm.sampling_params import SamplingParams
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "1"
# ---------------------------------------------------------------------------
# Constants
@@ -0,0 +1,142 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.helpers.mark import hardware_test
from tests.helpers.media import (
generate_synthetic_audio,
generate_synthetic_image,
generate_synthetic_video,
)
from tests.helpers.stage_config import modify_stage_config
models = ["Jonathan1909/Ming-flash-omni-2.0"]
# Ming-specific
SYSTEM_PROMPT = "你是一个友好的AI助手。\n\ndetailed thinking off"
EOS_TOKEN = "<|role_end|>"
IMAGE_TOKEN = "<IMAGE>"
VIDEO_TOKEN = "<VIDEO>"
AUDIO_TOKEN = "<AUDIO>"
def build_prompt(user_text: str) -> str:
"""Build a Ming chat prompt."""
return (
f"<role>SYSTEM</role>{SYSTEM_PROMPT}{EOS_TOKEN}<role>HUMAN</role>{user_text}{EOS_TOKEN}<role>ASSISTANT</role>"
)
def get_eager_config():
path = modify_stage_config(
str(Path(__file__).parent.parent / "stage_configs" / "bailingmm_moe_v2_lite_ci.yaml"),
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
},
},
)
return path
stage_configs = [get_eager_config()]
test_params = [(model, stage_config) for model in models for stage_config in stage_configs]
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_text_to_text(omni_runner, omni_runner_handler) -> None:
"""
Test text-only input processing and text output generation.
Input Modal: text
Output Modal: text
"""
prompt = build_prompt("请详细介绍鹦鹉的生活习性。")
request_config = {"prompts": prompt, "modalities": ["text"]}
omni_runner_handler.send_request(request_config)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_image_to_text(omni_runner, omni_runner_handler) -> None:
"""
Test image understanding with text output.
Input Modal: image + text
Output Modal: text
"""
image = generate_synthetic_image(224, 224)["np_array"]
prompt = build_prompt(f"{IMAGE_TOKEN}Describe this image briefly.")
request_config = {"prompts": prompt, "images": image, "modalities": ["text"]}
omni_runner_handler.send_request(request_config)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_audio_to_text(omni_runner, omni_runner_handler) -> None:
"""
Test audio understanding with text output.
Input Modal: audio + text
Output Modal: text
"""
audio = generate_synthetic_audio(2, 1, 16000)["np_array"]
if len(audio.shape) == 2:
audio = audio.squeeze()
prompt = build_prompt(f"{AUDIO_TOKEN}Please recognize the language of this speech and transcribe it. Format: oral.")
request_config = {"prompts": prompt, "audios": audio, "modalities": ["text"]}
omni_runner_handler.send_request(request_config)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_video_to_text(omni_runner, omni_runner_handler) -> None:
"""
Test video understanding with text output.
Input Modal: video + text
Output Modal: text
"""
video = generate_synthetic_video(224, 224, 30)["np_array"]
prompt = build_prompt(f"{VIDEO_TOKEN}Describe what is happening in this video.")
request_config = {"prompts": prompt, "videos": video, "modalities": ["text"]}
omni_runner_handler.send_request(request_config)
@pytest.mark.core_model
@pytest.mark.omni
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("omni_runner", test_params, indirect=True)
def test_mixed_to_text(omni_runner, omni_runner_handler) -> None:
"""
Test mixed modality input (image + audio) with text output.
Input Modal: image + audio + text
Output Modal: text
"""
image = generate_synthetic_image(224, 224)["np_array"]
audio = generate_synthetic_audio(2, 1, 16000)["np_array"]
if len(audio.shape) == 2:
audio = audio.squeeze()
prompt = build_prompt(f"{IMAGE_TOKEN}{AUDIO_TOKEN}Describe the image and transcribe the audio.")
request_config = {"prompts": prompt, "images": image, "audios": audio, "modalities": ["text"]}
omni_runner_handler.send_request(request_config)
@@ -0,0 +1,159 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""
End-to-end tests for Omni Sleep Mode across various model architectures.
"""
import pytest
import torch
from vllm import SamplingParams
from tests.helpers.mark import hardware_test
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
MODEL = "ByteDance-Seed/BAGEL-7B-MoT"
MODEL_DIFF = "riverclouds/qwen_image_random"
def get_ack_info(ack, key, default=None):
if hasattr(ack, key):
return getattr(ack, key)
if isinstance(ack, dict):
return ack.get(key, default)
return default
def get_dynamic_devices(stage_idx, num_stages, tp_size):
total_gpus = torch.cuda.device_count()
gpus_per_stage = tp_size
start_idx = stage_idx * gpus_per_stage
if start_idx + gpus_per_stage > total_gpus:
start_idx = start_idx % total_gpus
device_ids = [str(start_idx + i) for i in range(gpus_per_stage)]
return ",".join(device_ids)
# Test 1: Diffusion Model (2-Stage BAGEL)
@pytest.mark.advanced_model
@pytest.mark.omni
@pytest.mark.parametrize("tp_size", [1, 2])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.asyncio
async def test_diffusion_model_sleep_tp(tp_size):
num_gpus = torch.cuda.device_count()
if num_gpus < tp_size:
pytest.skip(f"Skipping TP={tp_size}")
engine_args = {
"model": MODEL,
"enable_sleep_mode": True,
"tensor_parallel_size": tp_size,
"enforce_eager": True,
"trust_remote_code": True,
"dtype": "bfloat16",
"gpu_memory_utilization": 0.5,
}
engine = AsyncOmni(**engine_args, stage_init_timeout=1200)
try:
# BAGEL requires 2 params
diff_sp = OmniDiffusionSamplingParams(num_inference_steps=2, height=256, width=256)
llm_sp = SamplingParams()
# Warmup
async for _ in engine.generate("test", sampling_params=[llm_sp, diff_sp]):
pass
# Sleep all
acks = await engine.sleep(level=2)
statuses = [get_ack_info(ack, "status") for ack in acks]
assert all(s == "SUCCESS" for s in statuses), f"Sleep failed. Statuses: {statuses}"
# Wakeup & Verify
await engine.wake_up()
async for _ in engine.generate("verify", sampling_params=[llm_sp, diff_sp]):
pass
print(f"Diffusion TP={tp_size} Lifecycle OK")
finally:
engine.shutdown()
# Test 2: Multi-stage Manual Config
@pytest.mark.advanced_model
@pytest.mark.omni
@pytest.mark.parametrize("tp_size", [1, 2])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.asyncio
async def test_multistage_sleep_h100(tp_size):
num_gpus = torch.cuda.device_count()
if num_gpus < tp_size * 2:
pytest.skip("Not enough GPUs")
stages = []
for i in range(2):
devs = get_dynamic_devices(i, 2, tp_size)
stages.append(
{
"stage_id": i,
"stage_type": "llm" if i == 0 else "diffusion",
"runtime": {"process": True, "devices": devs},
"engine_args": {
"model": MODEL,
"model_stage": "thinker" if i == 0 else "base",
"tensor_parallel_size": tp_size,
"gpu_memory_utilization": 0.4,
"dtype": "bfloat16",
"enable_sleep_mode": True,
"trust_remote_code": True,
},
}
)
connectors = [{"src_stage_id": 0, "dst_stage_id": 1, "connector_type": "queue"}]
engine = AsyncOmni(
model=MODEL, stages=stages, connectors=connectors, enable_sleep_mode=True, stage_init_timeout=1200
)
try:
sp = OmniDiffusionSamplingParams(num_inference_steps=2)
async for _ in engine.generate("warmup", sampling_params=[SamplingParams(), sp]):
pass
acks = await engine.sleep(stage_ids=[0, 1], level=2)
assert len(acks) == 2 * tp_size
await engine.wake_up(stage_ids=[0, 1])
async for _ in engine.generate("verify", sampling_params=[SamplingParams(), sp]):
pass
finally:
engine.shutdown()
# Test 3: Pure Diffusion Single-Stage
@pytest.mark.advanced_model
@pytest.mark.omni
@pytest.mark.parametrize("tp_size", [1, 2])
@hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2)
@pytest.mark.asyncio
async def test_pure_diffusion_scenario(tp_size):
engine_args = {
"model": MODEL_DIFF,
"enable_sleep_mode": True,
"tensor_parallel_size": tp_size,
"enforce_eager": True,
"dtype": "bfloat16",
"gpu_memory_utilization": 0.5,
}
engine = AsyncOmni(**engine_args, stage_init_timeout=1200)
try:
await engine.sleep(level=1)
await engine.wake_up()
async for _ in engine.generate("test", sampling_params=[SamplingParams()]):
pass
print("Pure Diffusion OK")
finally:
engine.shutdown()
@@ -16,8 +16,8 @@ from pathlib import Path
import numpy as np
import pytest
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
MODEL = "k2-fsa/OmniVoice"
@@ -16,7 +16,7 @@ import pytest
import torch
from pytest_mock import MockerFixture
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from vllm_omni.diffusion.data import OmniDiffusionConfig, TransformerConfig
# Mock the OvisImageTransformer2DModel to avoid complex init if needed,
@@ -36,8 +36,8 @@ from typing import Any
import pytest
import torch
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
from vllm_omni.platforms import current_omni_platform
@@ -2,46 +2,39 @@
E2E tests for Qwen2.5-Omni model with mixed modality inputs, audio and text output.
"""
from pathlib import Path
import pytest
from tests.conftest import (
from tests.helpers.mark import hardware_test
from tests.helpers.media import (
generate_synthetic_audio,
generate_synthetic_image,
generate_synthetic_video,
modify_stage_config,
)
from tests.utils import hardware_test
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen2.5-Omni-7B"]
# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
# platforms: section. NPU still uses the legacy per-platform YAML until it
# also migrates to the new schema.
_CI_DEPLOY = get_deploy_config_path("ci/qwen2_5_omni.yaml")
def get_cuda_graph_config():
path = modify_stage_config(
str(Path(__file__).parent.parent / "stage_configs" / "qwen2_5_omni_ci.yaml"),
return modify_stage_config(
_CI_DEPLOY,
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
"stages": {
0: {"enforce_eager": True},
1: {"enforce_eager": True},
},
},
)
return path
# CI stage config optimized for 24GB GPU (L4/RTX3090) or NPU
if current_omni_platform.is_npu():
stage_config = str(Path(__file__).parent / "stage_configs" / "npu" / "qwen2_5_omni_ci.yaml")
elif current_omni_platform.is_rocm():
# ROCm stage config optimized for MI325 GPU
stage_config = str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen2_5_omni_ci.yaml")
elif current_omni_platform.is_xpu():
# Intel XPU stage config optimized for B60 GPU
stage_config = str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen2_5_omni_ci.yaml")
if current_omni_platform.is_rocm() or current_omni_platform.is_xpu() or current_omni_platform.is_npu():
stage_config = _CI_DEPLOY
else:
stage_config = get_cuda_graph_config()
+15 -21
View File
@@ -7,41 +7,35 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import (
generate_synthetic_video,
modify_stage_config,
)
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.media import generate_synthetic_video
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
from vllm_omni.platforms import current_omni_platform
models = ["Qwen/Qwen3-Omni-30B-A3B-Instruct"]
# Single CI deploy YAML; rocm/xpu deltas are picked automatically via the
# platforms: section. Only CUDA needs an extra enforce_eager tweak.
_CI_DEPLOY = get_deploy_config_path("ci/qwen3_omni_moe.yaml")
def get_cuda_graph_config():
path = modify_stage_config(
str(Path(__file__).parent.parent / "stage_configs" / "qwen3_omni_ci.yaml"),
return modify_stage_config(
_CI_DEPLOY,
updates={
"stage_args": {
0: {
"engine_args.enforce_eager": "true",
},
1: {"engine_args.enforce_eager": "true"},
"stages": {
0: {"enforce_eager": True},
1: {"enforce_eager": True},
},
},
)
return path
# CI stage config for 2xH100-80G GPUs or AMD GPU MI325
if current_omni_platform.is_rocm():
# ROCm stage config optimized for MI325 GPU
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "rocm" / "qwen3_omni_ci.yaml")]
elif current_omni_platform.is_xpu():
stage_configs = [str(Path(__file__).parent.parent / "stage_configs" / "xpu" / "qwen3_omni_ci.yaml")]
if current_omni_platform.is_rocm() or current_omni_platform.is_xpu():
stage_configs = [_CI_DEPLOY]
else:
stage_configs = [get_cuda_graph_config()]
@@ -13,12 +13,10 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import modify_stage_config
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "Qwen/Qwen3-TTS-12Hz-0.6B-Base"
REF_AUDIO_URL = "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen3-TTS-Repo/clone_2.wav"
@@ -26,23 +24,31 @@ REF_TEXT = "Okay. Yeah. I resent you. I love you. I respect you. But you know wh
def get_cuda_graph_config():
path = modify_stage_config(
get_stage_config(),
"""Build a temp deploy yaml mirroring the deleted qwen3_tts_no_async_chunk.yaml.
Composes the synchronous (no-async-chunk) variant on top of the bundled
qwen3_tts.yaml prod default, with cudagraphs disabled. Replaces the deleted
standalone variant yaml; same effective config, no checked-in file needed.
"""
return modify_stage_config(
get_deploy_config_path("qwen3_tts.yaml"),
updates={
"stage_args": {
"async_chunk": False,
"stages": {
0: {
"engine_args.enforce_eager": "true",
"max_num_seqs": 1,
"gpu_memory_utilization": 0.2,
"enforce_eager": True,
"async_scheduling": False,
},
1: {
"gpu_memory_utilization": 0.2,
"enforce_eager": True,
"async_scheduling": False,
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
"""Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
@@ -13,34 +13,40 @@ import os
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
os.environ["VLLM_TEST_CLEAN_GPU_MEMORY"] = "0"
from pathlib import Path
import pytest
from tests.conftest import modify_stage_config
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
MODEL = "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice"
def get_cuda_graph_config():
path = modify_stage_config(
get_stage_config(),
"""Build a temp deploy yaml mirroring the deleted qwen3_tts_no_async_chunk.yaml.
Composes the synchronous (no-async-chunk) variant on top of the bundled
qwen3_tts.yaml prod default, with cudagraphs disabled. Replaces the deleted
standalone variant yaml; same effective config, no checked-in file needed.
"""
return modify_stage_config(
get_deploy_config_path("qwen3_tts.yaml"),
updates={
"stage_args": {
"async_chunk": False,
"stages": {
0: {
"engine_args.enforce_eager": "true",
"max_num_seqs": 1,
"gpu_memory_utilization": 0.2,
"enforce_eager": True,
"async_scheduling": False,
},
1: {
"gpu_memory_utilization": 0.2,
"enforce_eager": True,
"async_scheduling": False,
},
1: {"engine_args.enforce_eager": "true"},
},
},
)
return path
def get_stage_config(name: str = "qwen3_tts_no_async_chunk.yaml"):
"""Get the no_async_chunk stage config path (async_chunk disable, cuda_graph disabled)."""
return str(Path(__file__).parent.parent.parent.parent / "vllm_omni" / "model_executor" / "stage_configs" / name)
# Same structure as test_qwen3_omni: models, stage_configs, test_params
@@ -36,8 +36,8 @@ from pathlib import Path
import pytest
import torch
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.entrypoints.async_omni import AsyncOmni
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.outputs import OmniRequestOutput
@@ -20,8 +20,8 @@ import torch
import torch.distributed as dist
from PIL import Image
from tests.conftest import OmniRunner
from tests.utils import hardware_test
from tests.helpers.mark import hardware_test
from tests.helpers.runtime import OmniRunner
from vllm_omni.diffusion.data import DiffusionParallelConfig
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
from vllm_omni.platforms import current_omni_platform
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff. Show more