One request builder instead of two: when the generic dummy run opts out (num_frames <= 0), _make_dummy_request itself falls back to the model's declared dlo_dummy_run_recipe under distributed layerwise offload, and _dummy_run keeps its original shape. The recipe request is text-only even for image-capable models — the recipe names its own task, and t2va rejects image conditions; caught by a boot smoke after folding (the standalone builder had built the prompt fresh) and pinned by a regression test. Signed-off-by: hsliuustc0106 <liuhongsheng4@huawei.com>
366 lines
13 KiB
Python
366 lines
13 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from vllm_omni.diffusion import io_support
|
|
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode
|
|
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
|
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVRequest
|
|
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
|
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
|
|
|
|
|
def test_dummy_run_num_frames_uses_explicit_model_setting(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class JointAudioVideoModel:
|
|
dummy_run_num_frames = 2
|
|
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: JointAudioVideoModel,
|
|
)
|
|
|
|
assert io_support.get_dummy_run_num_frames("joint_audio_video", supports_audio_input=False) == 2
|
|
|
|
|
|
def test_dummy_run_num_frames_keeps_audio_output_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class AudioOutputModel:
|
|
support_audio_output = True
|
|
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: AudioOutputModel,
|
|
)
|
|
|
|
assert io_support.get_dummy_run_num_frames("audio_output", supports_audio_input=False) == 2
|
|
|
|
|
|
def test_dummy_run_num_frames_defaults_to_single_frame(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class VideoOnlyModel:
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: VideoOnlyModel,
|
|
)
|
|
|
|
assert io_support.get_dummy_run_num_frames("video_only", supports_audio_input=False) == 1
|
|
|
|
|
|
def test_dummy_run_num_frames_uses_audio_input_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: None,
|
|
)
|
|
|
|
assert io_support.get_dummy_run_num_frames("unknown", supports_audio_input=True) == 2
|
|
|
|
|
|
def test_dummy_run_image_count_resolves_hunyuan_architecture_alias() -> None:
|
|
assert io_support.get_dummy_run_num_image_inputs("HunyuanImage3ForCausalMM") == 3
|
|
assert io_support.get_dummy_run_num_image_inputs("unknown") == 1
|
|
|
|
|
|
def test_dlo_dummy_run_recipe_returns_none_without_declaration(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class PlainModel:
|
|
pass
|
|
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: PlainModel,
|
|
)
|
|
|
|
assert io_support.get_dlo_dummy_run_recipe("plain") is None
|
|
|
|
|
|
def test_dlo_dummy_run_recipe_returns_copy_of_declaration(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
declared = {"task": "t2va", "duration": 4.0}
|
|
|
|
class RecipeModel:
|
|
dlo_dummy_run_recipe = declared
|
|
|
|
monkeypatch.setattr(
|
|
io_support.DiffusionModelRegistry,
|
|
"_try_load_model_cls",
|
|
lambda model_class_name: RecipeModel,
|
|
)
|
|
|
|
recipe = io_support.get_dlo_dummy_run_recipe("recipe")
|
|
assert recipe == declared
|
|
assert recipe is not declared
|
|
|
|
|
|
def test_minimax_h3_declares_a_valid_dlo_warmup_recipe() -> None:
|
|
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
|
MINIMAX_H3_MAX_OUTPUT_SECONDS,
|
|
MiniMaxH3Pipeline,
|
|
)
|
|
|
|
recipe = MiniMaxH3Pipeline.dlo_dummy_run_recipe
|
|
assert recipe is not None
|
|
assert recipe["task"] == "t2va"
|
|
assert recipe["aspect_ratio"] == "16:9"
|
|
# Maximum duration makes the learned peak dominate every production
|
|
# request at the fixed short edge, so no real request re-learns.
|
|
assert float(recipe["duration"]) == MINIMAX_H3_MAX_OUTPUT_SECONDS
|
|
assert MiniMaxH3Pipeline.dummy_run_num_frames == 0 # generic warmup stays opted out
|
|
|
|
|
|
def _dlo_recipe_engine(enable_dlo: bool) -> DiffusionEngine:
|
|
engine = object.__new__(DiffusionEngine)
|
|
engine.od_config = SimpleNamespace(
|
|
model_class_name="RecipeModel",
|
|
enable_distributed_layerwise_offload=enable_dlo,
|
|
)
|
|
return engine
|
|
|
|
|
|
def _opt_out_generic_warmup(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
recipe: dict | None,
|
|
*,
|
|
supports_image_input: bool = False,
|
|
) -> None:
|
|
"""Make the generic dummy run opt out and stub the declared recipe."""
|
|
|
|
monkeypatch.setattr(
|
|
"vllm_omni.diffusion.diffusion_engine.supports_multimodal_input",
|
|
lambda od_config: (supports_image_input, False),
|
|
)
|
|
monkeypatch.setattr(
|
|
"vllm_omni.diffusion.diffusion_engine.get_dummy_run_num_frames",
|
|
lambda model_class_name, supports_audio_input: 0,
|
|
)
|
|
monkeypatch.setattr(
|
|
"vllm_omni.diffusion.diffusion_engine.get_dlo_dummy_run_recipe",
|
|
lambda model_class_name: recipe,
|
|
)
|
|
|
|
|
|
def test_make_dummy_request_skips_recipe_without_dlo(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_opt_out_generic_warmup(monkeypatch, {"task": "t2va"})
|
|
|
|
request = _dlo_recipe_engine(enable_dlo=False)._make_dummy_request(height=512, width=512, guidance_scale=0.0)
|
|
|
|
assert request is None
|
|
|
|
|
|
def test_make_dummy_request_builds_recipe_request_when_generic_opted_out(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
_opt_out_generic_warmup(
|
|
monkeypatch,
|
|
{
|
|
"task": "t2va",
|
|
"duration": 4.0,
|
|
"aspect_ratio": "16:9",
|
|
"num_inference_steps": 2,
|
|
"guidance_scale": 1.0,
|
|
},
|
|
)
|
|
|
|
request = _dlo_recipe_engine(enable_dlo=True)._make_dummy_request(height=512, width=512, guidance_scale=0.0)
|
|
|
|
assert request is not None
|
|
assert request.is_dummy_run()
|
|
assert request.sampling_params.num_inference_steps == 2
|
|
assert request.sampling_params.guidance_scale == 1.0
|
|
extra = request.sampling_params.extra_args
|
|
assert extra["task"] == "t2va"
|
|
assert extra["duration"] == 4.0
|
|
assert extra["aspect_ratio"] == "16:9"
|
|
assert extra["cfg_text_scale"] == 1.0
|
|
|
|
|
|
def test_make_dummy_request_recipe_stays_text_only_with_image_capable_model(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
# H3 advertises image input for fl2va, but the warmup recipe names t2va,
|
|
# which rejects image conditions; the recipe request must not inherit the
|
|
# generic dummy run's synthetic multimodal data.
|
|
_opt_out_generic_warmup(
|
|
monkeypatch,
|
|
{"task": "t2va", "duration": 4.0, "aspect_ratio": "16:9"},
|
|
supports_image_input=True,
|
|
)
|
|
|
|
request = _dlo_recipe_engine(enable_dlo=True)._make_dummy_request(height=512, width=512, guidance_scale=0.0)
|
|
|
|
assert request is not None
|
|
assert not request.prompt.get("multi_modal_data")
|
|
|
|
|
|
def test_dummy_run_sends_recipe_request_when_generic_opted_out(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_opt_out_generic_warmup(monkeypatch, {"task": "t2va", "duration": 15.0, "aspect_ratio": "16:9"})
|
|
engine = _dlo_recipe_engine(enable_dlo=True)
|
|
engine.add_req_and_wait_for_response = Mock(return_value=SimpleNamespace(error=None, request_id="dlo-dummy"))
|
|
|
|
engine._dummy_run()
|
|
|
|
sent = engine.add_req_and_wait_for_response.call_args.args[0]
|
|
assert sent.is_dummy_run()
|
|
assert sent.sampling_params.extra_args["task"] == "t2va"
|
|
|
|
|
|
def test_dummy_run_still_skips_without_recipe(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
_opt_out_generic_warmup(monkeypatch, None)
|
|
engine = _dlo_recipe_engine(enable_dlo=True)
|
|
engine.add_req_and_wait_for_response = Mock()
|
|
|
|
engine._dummy_run()
|
|
|
|
engine.add_req_and_wait_for_response.assert_not_called()
|
|
|
|
|
|
def test_dense_mode_does_not_build_kv_profile_request() -> None:
|
|
engine = object.__new__(DiffusionEngine)
|
|
engine.od_config = SimpleNamespace(diffusion_kv_mode=DiffusionKVCacheMode.DENSE_LEGACY)
|
|
engine._make_dummy_request = Mock(side_effect=AssertionError("dense mode must not profile"))
|
|
|
|
assert engine._prepare_diffusion_kv_profile_requests() is None
|
|
engine._make_dummy_request.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("execution_mode", "uses_dlo_dp", "max_num_seqs", "expected_profile_requests"),
|
|
[
|
|
(DiffusionExecutionMode.STEP_BATCH, False, 2, 2),
|
|
(DiffusionExecutionMode.REQUEST_BATCH, True, 4, 1),
|
|
(DiffusionExecutionMode.STEP_BATCH, True, 3, 3),
|
|
],
|
|
)
|
|
def test_paged_kv_profile_requests_match_per_rank_batch(
|
|
execution_mode: DiffusionExecutionMode,
|
|
uses_dlo_dp: bool,
|
|
max_num_seqs: int,
|
|
expected_profile_requests: int,
|
|
) -> None:
|
|
engine = object.__new__(DiffusionEngine)
|
|
engine.execution_mode = execution_mode
|
|
engine.od_config = SimpleNamespace(
|
|
diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER,
|
|
model_class_name="HunyuanImage3ForCausalMM",
|
|
max_num_seqs=max_num_seqs,
|
|
parallel_config=SimpleNamespace(data_parallel_size=max_num_seqs if uses_dlo_dp else 1),
|
|
enable_distributed_layerwise_offload=uses_dlo_dp,
|
|
dlo_use_allgather=True,
|
|
)
|
|
request = OmniDiffusionRequest(
|
|
prompt="profile",
|
|
request_id="profile-request",
|
|
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1),
|
|
)
|
|
engine._make_dummy_request = Mock(return_value=request)
|
|
prepared_layout = object()
|
|
scheduler_kv_state = (
|
|
DiffusionKVRequest(
|
|
"profile-request/diffusion-kv/0",
|
|
sequence_id=0,
|
|
prefix_len=3,
|
|
target_len=8,
|
|
seq_len=12,
|
|
),
|
|
DiffusionKVRequest(
|
|
"profile-request/diffusion-kv/1",
|
|
sequence_id=1,
|
|
prefix_len=4,
|
|
target_len=8,
|
|
seq_len=13,
|
|
),
|
|
)
|
|
|
|
def preprocess(req: OmniDiffusionRequest) -> OmniDiffusionRequest:
|
|
req.prepared_layout = prepared_layout
|
|
req.diffusion_kv_requests = scheduler_kv_state
|
|
return req
|
|
|
|
engine._prepare_request_for_admission = Mock(side_effect=preprocess)
|
|
|
|
result = engine._prepare_diffusion_kv_profile_requests()
|
|
|
|
assert result is not None
|
|
assert len(result) == expected_profile_requests
|
|
assert len({profile_request.request_id for profile_request in result}) == expected_profile_requests
|
|
assert all(profile_request.is_dummy_run() for profile_request in result)
|
|
assert all(profile_request.prepared_layout is prepared_layout for profile_request in result)
|
|
assert all(profile_request.diffusion_kv_requests is None for profile_request in result)
|
|
assert len({id(profile_request.sampling_params) for profile_request in result}) == expected_profile_requests
|
|
assert engine._diffusion_kv_profile_limits == (2, 13, 8)
|
|
assert len(result) * engine._diffusion_kv_profile_limits[0] == expected_profile_requests * 2
|
|
engine._make_dummy_request.assert_called_once_with(
|
|
height=1024,
|
|
width=1024,
|
|
guidance_scale=5.0,
|
|
num_image_inputs=3,
|
|
)
|
|
engine._prepare_request_for_admission.assert_called_once_with(request)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("num_sequences", "seq_len", "target_len"),
|
|
[
|
|
(3, 16, 8),
|
|
(1, 17, 8),
|
|
(1, 16, 9),
|
|
],
|
|
)
|
|
def test_paged_kv_admission_rejects_shape_beyond_profile_envelope(
|
|
num_sequences: int,
|
|
seq_len: int,
|
|
target_len: int,
|
|
) -> None:
|
|
engine = object.__new__(DiffusionEngine)
|
|
engine._diffusion_kv_profile_limits = (2, 16, 8)
|
|
engine.pre_process_func = lambda request: request
|
|
request = OmniDiffusionRequest(
|
|
prompt="too large",
|
|
request_id="too-large",
|
|
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1),
|
|
diffusion_kv_requests=tuple(
|
|
DiffusionKVRequest(
|
|
f"too-large/diffusion-kv/{sequence_id}",
|
|
sequence_id=sequence_id,
|
|
prefix_len=0,
|
|
target_len=target_len,
|
|
seq_len=seq_len,
|
|
)
|
|
for sequence_id in range(num_sequences)
|
|
),
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="exceeds the startup memory-profile envelope"):
|
|
engine._prepare_request_for_admission(request)
|
|
|
|
|
|
def test_paged_kv_admission_accepts_shape_at_profile_envelope() -> None:
|
|
engine = object.__new__(DiffusionEngine)
|
|
engine._diffusion_kv_profile_limits = (2, 16, 8)
|
|
engine.pre_process_func = lambda request: request
|
|
request = OmniDiffusionRequest(
|
|
prompt="fits",
|
|
request_id="fits",
|
|
sampling_params=OmniDiffusionSamplingParams(num_inference_steps=1),
|
|
diffusion_kv_requests=tuple(
|
|
DiffusionKVRequest(
|
|
f"fits/diffusion-kv/{sequence_id}",
|
|
sequence_id=sequence_id,
|
|
prefix_len=8,
|
|
target_len=8,
|
|
seq_len=16,
|
|
)
|
|
for sequence_id in range(2)
|
|
),
|
|
)
|
|
|
|
assert engine._prepare_request_for_admission(request) is request
|