[Diffusion][HunyuanImage3] Add request paged prefix caching (#7719)

Signed-off-by: zwhzzz0821 <2831474076@qq.com>
This commit is contained in:
Yukim1 authored and GitHub committed 2026-09-23 15:34:56 +00:00
1 parent 9a1a05facd
commit 205aa01c7b
49 files changed
+2889 -127

No files matched your search

+1
View File
@@ -0,0 +1 @@
{"prompt": "基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴", "image_files": ["https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_0.png", "https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_1.png"]}
+35 -3
View File
@@ -9,6 +9,7 @@ import sys
from pathlib import Path
import pytest
import requests
from PIL import Image
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
@@ -311,7 +312,8 @@ def test_trajectory_judge_payload_collapses_six_frames_into_single_storyboard():
assert judge_images[0].size == (24, 12)
def test_image_edit_client_uses_openai_image_edit_endpoint(monkeypatch):
@pytest.mark.parametrize("num_images", [1, 2])
def test_image_edit_client_uses_openai_image_edit_endpoint(monkeypatch, num_images):
captured = {}
class FakeResponse:
@@ -344,16 +346,46 @@ def test_image_edit_client_uses_openai_image_edit_endpoint(monkeypatch):
output = client.generate_image_edit(
model="Qwen/Qwen-Image-Edit",
prompt="edit this image",
images=image,
images=image if num_images == 1 else [image] * num_images,
width=512,
height=512,
guidance_scale=2.5,
seed=42,
bot_task="think_recaption",
sys_type="en_unified",
)
assert output.size == (1, 1)
assert captured["url"] == "http://127.0.0.1:8093/v1/images/edits"
assert captured["data"]["prompt"] == "edit this image"
assert captured["data"]["size"] == "512x512"
assert captured["files"][0][0] == "image"
assert len(captured["files"]) == num_images
assert all(field == ("image" if num_images == 1 else "image[]") for field, _ in captured["files"])
assert captured["data"]["guidance_scale"] == "2.5"
assert captured["data"]["seed"] == "42"
assert captured["data"]["bot_task"] == "think_recaption"
assert captured["data"]["sys_type"] == "en_unified"
@pytest.mark.parametrize("status_code", [400, 500])
def test_image_edit_client_preserves_server_error_detail(mocker, status_code):
response = requests.Response()
response.status_code = status_code
response._content = b'{"error": "image input limit exceeded"}'
post = mocker.patch("benchmarks.accuracy.common.requests.post", return_value=response)
client = VllmOmniImageClient(base_url="http://127.0.0.1:8093")
with pytest.raises(requests.HTTPError, match="image input limit exceeded") as exc:
client.generate_image_edit(
model="test-model",
prompt="edit this image",
images=Image.new("RGB", (2, 2)),
width=512,
height=512,
)
assert exc.value.response is response
post.assert_called_once()
def test_text_to_image_client_forwards_output_compression(monkeypatch):
+71
View File
@@ -6,6 +6,7 @@ import json
import os
import subprocess
import textwrap
import time
from pathlib import Path
import pytest
@@ -21,6 +22,76 @@ from vllm_omni.utils.tracking_parser import TrackingArgumentParser
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.benchmark]
def test_it2i_dataset_uses_upstream_warmups(monkeypatch, tmp_path):
"""Exercise real dataset/benchmark code; replace only the HTTP backend."""
from vllm_omni.benchmarks import serve
from vllm_omni.benchmarks.patch import patch
from vllm_omni.entrypoints.cli.benchmark.serve import OmniBenchmarkServingSubcommand
dataset_path = Path(__file__).parents[1] / "assets/hunyuan_image3/it2i.jsonl"
sample = json.loads(dataset_path.read_text(encoding="utf-8"))
parser = argparse.ArgumentParser()
OmniBenchmarkServingSubcommand.add_cli_args(parser)
args = parser.parse_args(
[
"--model",
"test-model",
"--endpoint",
"/v1/images/edits",
"--dataset-name",
"custom_image",
"--dataset-path",
str(dataset_path),
"--skip-tokenizer-init",
"--disable-shuffle",
"--disable-tqdm",
"--num-prompts",
"8",
"--num-warmups",
"2",
"--max-concurrency",
"1",
"--ready-check-timeout-sec",
"0",
"--percentile-metrics",
"e2el",
"--extra-body",
'{"seed":42,"guidance_scale":2.5,"bot_task":"think_recaption"}',
"--save-result",
"--result-dir",
str(tmp_path),
"--result-filename",
"result.json",
]
)
requests = []
async def request_func(request_func_input, session, pbar=None):
requests.append(request_func_input)
if pbar is not None:
pbar.update(1)
return patch.MixRequestFuncOutput(
success=True,
prompt_len=1,
output_tokens=1,
image_count=1,
start_time=time.perf_counter(),
latency=99.0 if len(requests) <= 2 else 0.1,
)
monkeypatch.setitem(patch.ASYNC_REQUEST_FUNCS, "/v1/images/edits", request_func)
result = serve.main(args)
assert len(requests) == 10
for request in requests:
assert request.prompt == sample["prompt"]
assert [image["image_url"]["url"] for image in request.multi_modal_content] == sample["image_files"]
assert request.extra_body == args.extra_body
assert result["completed"] == 8
saved = json.loads((tmp_path / "result.json").read_text())
assert saved["completed"] == 8
assert saved["mean_e2el_ms"] == pytest.approx(100.0) # Excludes both 99-second warmups.
@pytest.mark.parametrize(
"dataset_name",
[
+62
View File
@@ -1578,6 +1578,68 @@ def test_diffusion_config_preserves_existing_coercion_hooks():
assert cfg.max_cpu_loras == 1
@pytest.mark.parametrize("enabled", [False, True])
def test_diffusion_projection_retains_prefix_caching(enabled):
projection = omni_config_module._DiffusionConfigProjection
assert projection.from_kwargs().enable_prefix_caching is False
config = projection.from_kwargs(
diffusion_kv_mode="paged_scheduler",
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=enabled,
)
assert config.enable_prefix_caching is enabled
@pytest.mark.parametrize(
"pipeline_value,stage_value,cli_value,expected",
[
(None, None, None, False),
(True, None, None, True),
(False, True, None, True),
(True, False, None, False),
(None, None, True, True),
(True, True, False, False),
(False, False, True, True),
],
)
def test_diffusion_prefix_caching_precedence_and_transport(
tmp_path,
pipeline_value,
stage_value,
cli_value,
expected,
):
import yaml
from vllm_omni.engine.stage_init_utils import _project_omni_stage_engine_args
stage_deploy = {
"stage_id": 0,
"diffusion_kv_mode": "paged_scheduler",
"diffusion_kv_max_rows_per_request": 2,
}
deploy = {"pipeline": "hunyuan_image3_dit", "async_chunk": False, "stages": [stage_deploy]}
if pipeline_value is not None:
deploy["enable_prefix_caching"] = pipeline_value
if stage_value is not None:
stage_deploy["enable_prefix_caching"] = stage_value
deploy_path = tmp_path / "prefix.yaml"
deploy_path.write_text(yaml.safe_dump(deploy))
cli = {} if cli_value is None else {"stage_0_enable_prefix_caching": cli_value}
stage = _from_pipeline_key(
"hunyuan_image3_dit",
deploy_config_path=str(deploy_path),
cli_overrides=cli,
).stage_by_id(0)
assert bool(stage.cache_config.enable_prefix_caching) is expected
assert stage.diffusion_config.enable_prefix_caching is expected
assert _project_omni_stage_engine_args(stage)["enable_prefix_caching"] is expected
serialized = _serialize_stage_config(stage)
restored = omni_config_module._DiffusionConfigProjection.from_kwargs(**serialized["diffusion_config"])
assert restored.enable_prefix_caching is expected
def test_diffusion_config_from_kwargs_reuses_legacy_normalization(monkeypatch):
from vllm_omni.platforms import current_omni_platform
@@ -0,0 +1,131 @@
[
{
"test_name": "test_hunyuan_image3_prefix_dense",
"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 4}}, "diffusion", "local_model"],
"description": "Repeated two-image IT2I request on a single DiT stage: dense baseline (TP4, CFGP1)",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"trust-remote-code": true,
"deploy-config": "vllm_omni/deploy/hunyuan_image3_dit.yaml"
},
"stage_overrides": {
"0": {
"max_model_len": 22800,
"max_num_batched_tokens": 32768,
"diffusion_kv_mode": "dense_legacy",
"diffusion_kv_max_rows_per_request": 2,
"enable_prefix_caching": false,
"vae_use_tiling": true,
"parallel_config": {"vae_patch_parallel_size": 4}
}
}
},
"benchmark_params": [{
"dataset_name": "custom_image",
"dataset_path": "tests/assets/hunyuan_image3/it2i.jsonl",
"endpoint": "/v1/images/edits",
"skip_tokenizer_init": true,
"disable_shuffle": true,
"num_prompts": 8,
"max_concurrency": 1,
"num_warmups": 2,
"seed": 42,
"percentile_metrics": "e2el",
"extra_body": {
"guidance_scale": 2.5,
"sys_type": "en_unified",
"seed": 42,
"size": "1280x720",
"num_inference_steps": 8,
"bot_task": "think_recaption"
}
}]
},
{
"test_name": "test_hunyuan_image3_prefix_paged_no_cache",
"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 4}}, "diffusion", "local_model"],
"description": "Repeated two-image IT2I request on a single DiT stage: paged without prefix caching (TP4, CFGP1)",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"trust-remote-code": true,
"deploy-config": "vllm_omni/deploy/hunyuan_image3_dit.yaml"
},
"stage_overrides": {
"0": {
"max_model_len": 22800,
"max_num_batched_tokens": 32768,
"diffusion_kv_mode": "paged_scheduler",
"diffusion_kv_max_rows_per_request": 2,
"enable_prefix_caching": false,
"vae_use_tiling": true,
"parallel_config": {"vae_patch_parallel_size": 4}
}
}
},
"benchmark_params": [{
"dataset_name": "custom_image",
"dataset_path": "tests/assets/hunyuan_image3/it2i.jsonl",
"endpoint": "/v1/images/edits",
"skip_tokenizer_init": true,
"disable_shuffle": true,
"num_prompts": 8,
"max_concurrency": 1,
"num_warmups": 2,
"seed": 42,
"percentile_metrics": "e2el",
"extra_body": {
"guidance_scale": 2.5,
"sys_type": "en_unified",
"seed": 42,
"size": "1280x720",
"num_inference_steps": 8,
"bot_task": "think_recaption"
}
}]
},
{
"test_name": "test_hunyuan_image3_prefix_paged_prefix",
"mark": [{"hardware_marks": {"res": {"cuda": "H100"}, "num_cards": 4}}, "diffusion", "local_model"],
"description": "Repeated two-image IT2I request on a single DiT stage: paged with full prefix reuse (TP4, CFGP1)",
"server_params": {
"model": "tencent/HunyuanImage-3.0-Instruct",
"serve_args": {
"trust-remote-code": true,
"deploy-config": "vllm_omni/deploy/hunyuan_image3_dit.yaml"
},
"stage_overrides": {
"0": {
"max_model_len": 22800,
"max_num_batched_tokens": 32768,
"diffusion_kv_mode": "paged_scheduler",
"diffusion_kv_max_rows_per_request": 2,
"enable_prefix_caching": true,
"vae_use_tiling": true,
"parallel_config": {"vae_patch_parallel_size": 4}
}
}
},
"benchmark_params": [{
"dataset_name": "custom_image",
"dataset_path": "tests/assets/hunyuan_image3/it2i.jsonl",
"endpoint": "/v1/images/edits",
"skip_tokenizer_init": true,
"disable_shuffle": true,
"num_prompts": 8,
"max_concurrency": 1,
"num_warmups": 2,
"seed": 42,
"percentile_metrics": "e2el",
"extra_body": {
"guidance_scale": 2.5,
"sys_type": "en_unified",
"seed": 42,
"size": "1280x720",
"num_inference_steps": 8,
"bot_task": "think_recaption"
}
}]
}
]
@@ -254,6 +254,38 @@ def test_merge_omni_default_server_args_respects_json():
]
def test_prefix_benchmark_uses_unified_runner(monkeypatch):
from pathlib import Path
from tests.dfx.conftest import is_diffusion_perf_config, load_benchmark_configs
from tests.dfx.perf.scripts import run_benchmark
configs = load_benchmark_configs(str(Path(__file__).with_name("test_hunyuan_image3_prefix_caching.json")))
assert len(configs) == 3
assert all(not is_diffusion_perf_config(config) for config in configs)
assert all(config["benchmark_params"] == configs[0]["benchmark_params"] for config in configs)
monkeypatch.setattr(run_benchmark, "BENCHMARK_CONFIGS", configs)
monkeypatch.setattr(run_benchmark, "get_runtime_resource_label", lambda: "H100")
calls = []
def benchmark(**kwargs):
calls.append(kwargs)
assert "--name" not in kwargs["args"]
assert "--warmup-dataset-path" not in kwargs["args"]
assert "tests/assets/hunyuan_image3/it2i.jsonl" in kwargs["args"]
assert kwargs["num_warmups"] == 2
return {"completed": 8}
monkeypatch.setattr(run_benchmark, "run_benchmark", benchmark)
for config in configs:
for params in config["benchmark_params"]:
run_benchmark.test_performance_benchmark(
SimpleNamespace(host="localhost", port=8000, model="test-model"),
{"test_name": config["test_name"], "params": params},
)
assert len(calls) == 3
def test_benchmark_param_id_suffix_from_task_eval_phase():
from tests.dfx.conftest import _unique_benchmark_param_id_suffixes
@@ -21,6 +21,86 @@ def test_dense_legacy_is_default() -> None:
assert config.diffusion_kv_mode is DiffusionKVCacheMode.DENSE_LEGACY
@pytest.mark.parametrize("config_cls", [OmniDiffusionConfig, _DiffusionConfigProjection])
@pytest.mark.parametrize("mode", [None, "dense_legacy", DiffusionKVCacheMode.DENSE_LEGACY])
def test_prefix_caching_rejects_non_paged_mode(config_cls, mode) -> None:
kwargs = {} if mode is None else {"diffusion_kv_mode": mode}
with pytest.raises(
ValueError,
match="set diffusion_kv_mode='paged_scheduler' or disable enable_prefix_caching",
):
config_cls.from_kwargs(enable_prefix_caching=True, **kwargs)
@pytest.mark.parametrize("config_cls", [OmniDiffusionConfig, _DiffusionConfigProjection])
def test_prefix_caching_accepts_paged_scheduler(config_cls) -> None:
config = config_cls.from_kwargs(
diffusion_kv_mode="paged_scheduler",
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=True,
)
assert config.enable_prefix_caching is True
assert config.diffusion_kv_mode is DiffusionKVCacheMode.PAGED_SCHEDULER
@pytest.mark.parametrize("build_config", [OmniDiffusionConfig, OmniDiffusionConfig.from_kwargs])
def test_prefix_caching_rejects_sleep_mode(build_config) -> None:
with pytest.raises(ValueError, match="disable enable_prefix_caching or enable_sleep_mode"):
build_config(
diffusion_kv_mode="paged_scheduler",
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=True,
enable_sleep_mode=True,
)
@pytest.mark.parametrize("mode", ["dense_legacy", "paged_scheduler"])
def test_sleep_mode_accepts_disabled_prefix_caching(mode) -> None:
config = OmniDiffusionConfig.from_kwargs(
diffusion_kv_mode=mode,
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=False,
enable_sleep_mode=True,
)
assert config.enable_sleep_mode is True
assert config.enable_prefix_caching is False
@pytest.mark.parametrize("enable_prefix_caching", [False, True])
def test_native_kv_transfer_requires_prefix_caching_disabled(enable_prefix_caching) -> None:
kwargs = dict(
diffusion_kv_mode="paged_scheduler",
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=enable_prefix_caching,
kv_transfer_config={
"kv_connector": "MooncakeConnector",
"kv_role": "kv_consumer",
"engine_id": "test-diffusion",
},
)
if enable_prefix_caching:
with pytest.raises(ValueError, match="disable enable_prefix_caching for AR KV import"):
OmniDiffusionConfig.from_kwargs(**kwargs)
else:
config = OmniDiffusionConfig.from_kwargs(**kwargs)
assert config.kv_transfer_config.kv_role == "kv_consumer"
@pytest.mark.parametrize("config_cls", [OmniDiffusionConfig, _DiffusionConfigProjection])
@pytest.mark.parametrize("mode", ["dense_legacy", "paged_scheduler"])
def test_disabled_prefix_caching_accepts_both_modes(config_cls, mode) -> None:
config = config_cls.from_kwargs(
diffusion_kv_mode=mode,
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=False,
)
assert config.enable_prefix_caching is False
assert config.diffusion_kv_mode.value == mode
def test_paged_worker_local_is_rejected_until_implemented() -> None:
with pytest.raises(ValueError, match="reserved but not implemented"):
OmniDiffusionConfig.from_kwargs(diffusion_kv_mode="paged_worker_local")
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
from unittest.mock import Mock
@@ -225,6 +225,22 @@ def test_paged_config_forwards_gpu_memory_utilization_to_native_cache_config() -
assert vllm_config.cache_config.gpu_memory_utilization == 0.42
@pytest.mark.parametrize("enable_prefix_caching", [False, True])
@pytest.mark.parametrize("requests,rows", [(1, 2), (2, 3)])
def test_native_config_counts_cfg_rows_once(enable_prefix_caching, requests, rows) -> None:
od_config = _od_config(
max_num_seqs=requests,
diffusion_kv_max_rows_per_request=rows,
enable_prefix_caching=enable_prefix_caching,
)
config = diffusion_vllm_config.create_diffusion_vllm_config(torch.device("cpu"), od_config)
# Engine and Worker must see the same row capacity, including on reconfiguration.
diffusion_vllm_config.configure_diffusion_vllm_config(config, od_config)
assert config.scheduler_config.max_num_seqs == requests * rows
assert od_config.max_num_seqs == requests
assert config.cache_config.enable_prefix_caching is enable_prefix_caching
def test_diffusion_vllm_model_config_supplies_dtype_for_quant_methods() -> None:
quantization_config = build_quant_config(
{
@@ -0,0 +1,277 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Native multimodal block hashing and diffusion-specific identity helpers."""
from types import SimpleNamespace
from unittest.mock import Mock
import numpy as np
import pytest
import torch
from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange
from vllm.utils.hashing import get_hash_fn_by_name
from vllm.v1.core.kv_cache_utils import BlockHash, generate_block_hash_extra_keys, hash_block_tokens
import vllm_omni.diffusion.diffusion_engine as engine_module
import vllm_omni.diffusion.diffusion_kv.kv_cache_utils as kv_utils
import vllm_omni.diffusion.diffusion_kv.request as request_module
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.diffusion.diffusion_kv.kv_cache_utils import get_cache_namespace, hash_prefix_cache_value
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 _request(*, num_rows=1, seed=7, adapter_id=None, scale=1.0):
request = OmniDiffusionRequest(
request_id="fake", prompt="fake model", sampling_params=OmniDiffusionSamplingParams(seed=seed)
)
request.sampling_params.lora_request = None if adapter_id is None else SimpleNamespace(lora_int_id=adapter_id)
request.sampling_params.lora_scale = scale
request.diffusion_kv_requests = tuple(
DiffusionKVRequest(f"fake/{row}", sequence_id=row, prefix_len=8, target_len=4, seq_len=12)
for row in range(num_rows)
)
return request
def _feature(*, value="image", start=4, end=8):
return MultiModalFeatureSpec(
data=None,
modality="image",
identifier=hash_prefix_cache_value((value, start, end)).hex(),
mm_position=PlaceholderRange(offset=start, length=end - start),
)
def _prepare(request, feature=None):
namespace = get_cache_namespace("fake-model-v1", request.sampling_params)
for row in request.diffusion_kv_requests:
row.cache_token_ids = tuple(range(row.prefix_len))
row.mm_features = [feature or _feature()]
row.cache_namespace = namespace
def _hashes(request, feature=None):
_prepare(request, feature)
row = request.diffusion_kv_requests[0]
row.build_block_hashes(4, get_hash_fn_by_name("sha256"))
return row.block_hashes
def test_partial_hits_and_randomness_are_model_opt_in():
base = _hashes(_request())
assert base == _hashes(_request(seed=999))
changed = _hashes(_request(), _feature(value="other image"))
assert base[0] == changed[0]
assert base[1] != changed[1]
changed_geometry = _hashes(_request(), _feature(end=9))
assert base[0] == changed_geometry[0]
assert base[1] != changed_geometry[1]
@pytest.mark.parametrize(("adapter_id", "scale"), [(None, 1.0), (2, 0.5), (1, 1.0)])
def test_lora_is_common_request_wide_identity(adapter_id, scale):
base = _hashes(_request(adapter_id=1, scale=0.5))
changed = _hashes(_request(adapter_id=adapter_id, scale=scale))
assert all(a != b for a, b in zip(base, changed, strict=True))
assert _hashes(_request()) == _hashes(_request(scale=0.5))
def test_block_hashing_uses_upstream_extra_keys_directly(monkeypatch):
row = DiffusionKVRequest(
"native",
sequence_id=0,
prefix_len=14,
target_len=2,
seq_len=16,
cache_token_ids=range(16),
mm_features=[
_feature(start=2, end=7),
_feature(start=7, end=10),
],
)
upstream = Mock(wraps=generate_block_hash_extra_keys)
monkeypatch.setattr(request_module, "generate_block_hash_extra_keys", upstream)
hash_fn = get_hash_fn_by_name("sha256")
row.build_block_hashes(4, hash_fn)
assert [(call.args[1], call.args[2]) for call in upstream.call_args_list] == [(0, 4), (4, 8), (8, 12)]
# Independent expected native extras: a crossing item has a negative
# relative offset; a block can include the end of one item and the next.
first, second = (feature.identifier for feature in row.mm_features)
extras = [((first, 2),), ((first, -2), (second, 3)), ((second, -1),)]
parent = BlockHash(hash_fn(("diffusion-prefix-root-v1", row.cache_namespace)))
expected = []
for start, keys in zip(range(0, 12, 4), extras, strict=True):
parent = hash_block_tokens(hash_fn, parent, list(range(start, start + 4)), keys)
expected.append(parent)
assert row.block_hashes == expected
# Never hash the partial prefix tail or mutable target [14, 16), even
# though cache_token_ids deliberately contains the whole sequence.
assert len(row.block_hashes) == 3
def test_same_identifier_at_different_offsets_changes_block_hash():
first = _feature(start=4, end=6)
shifted = _feature(start=5, end=7)
shifted.identifier = first.identifier
a = _hashes(_request(), first)
b = _hashes(_request(), shifted)
assert a[0] == b[0]
assert a[1] != b[1]
def test_multimodal_identity_beyond_prefix_does_not_affect_hashes():
assert _hashes(_request(), _feature(value="a", start=8, end=12)) == _hashes(
_request(), _feature(value="b", start=8, end=12)
)
@pytest.mark.parametrize(
"positions",
[
[(-1, 2)],
[(4, 4)],
[(4, 13)],
[(5, 7), (4, 5)],
[(3, 7), (4, 6)],
],
)
def test_invalid_or_overlapping_positions_fail_before_hashing(positions):
row = _request().diffusion_kv_requests[0]
row.cache_token_ids = tuple(range(8))
row.mm_features = [_feature(start=start, end=end) for start, end in positions]
fail = Mock(side_effect=AssertionError("invalid positions must fail before hashing"))
with pytest.raises(ValueError, match="sorted, non-overlapping"):
row.build_block_hashes(4, fail)
fail.assert_not_called()
assert row.block_hashes == []
def test_empty_multimodal_identifier_is_rejected():
row = _request().diffusion_kv_requests[0]
row.cache_token_ids = tuple(range(8))
feature = _feature()
feature.identifier = ""
row.mm_features = [feature]
with pytest.raises(ValueError, match="content identifiers"):
row.build_block_hashes(4, get_hash_fn_by_name("sha256"))
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16, torch.int64, torch.bool])
def test_tensor_identity_includes_dtype_shape_and_values(dtype):
tensor = torch.tensor([[0, 1], [1, 0]], dtype=dtype)
assert hash_prefix_cache_value(tensor) == hash_prefix_cache_value(tensor.clone())
assert hash_prefix_cache_value(tensor) == hash_prefix_cache_value(tensor.t().contiguous().t())
assert hash_prefix_cache_value(tensor) != hash_prefix_cache_value(tensor.reshape(4))
changed = tensor.clone()
changed[0, 0] = 1
assert hash_prefix_cache_value(tensor) != hash_prefix_cache_value(changed)
assert hash_prefix_cache_value(tensor) != hash_prefix_cache_value(tensor.to(torch.float64))
def test_numpy_scalar_shape_and_nested_dict_order():
assert hash_prefix_cache_value({"b": {"y": 2, "x": 1}, "a": None}) == hash_prefix_cache_value(
{"a": None, "b": {"x": 1, "y": 2}}
)
assert hash_prefix_cache_value(np.array(1)) != hash_prefix_cache_value(np.array([1]))
assert hash_prefix_cache_value(np.int64(1)) == hash_prefix_cache_value(1)
@pytest.mark.parametrize(
("first", "second"), [(1, True), (1, 1.0), ("1", b"1"), ([], {}), ([None], []), (["ab", "c"], ["a", "bc"])]
)
def test_identity_has_unambiguous_type_and_container_boundaries(first, second):
assert hash_prefix_cache_value(first) != hash_prefix_cache_value(second)
@pytest.mark.parametrize("value", [object(), {1: "value"}, np.array([object()]), np.longdouble(1)])
def test_identity_rejects_unsupported_values_without_pickle(value):
with pytest.raises(TypeError):
hash_prefix_cache_value(value)
@pytest.mark.parametrize(
("mode", "enabled", "expect_hook"),
[
(DiffusionKVCacheMode.PAGED_SCHEDULER, False, False),
(DiffusionKVCacheMode.PAGED_SCHEDULER, True, True),
(DiffusionKVCacheMode.DENSE_LEGACY, True, False),
],
)
def test_engine_gates_hook_loading_and_admission(monkeypatch, mode, enabled, expect_hook):
events = []
request = _request()
def preprocess(req):
events.append("layout")
req.prepared_layout = "fake layout"
return req
def prepare(req):
assert req.prepared_layout == "fake layout"
assert req.diffusion_kv_requests[0].cache_token_ids == ()
events.append("cache_inputs")
_prepare(req)
hook = Mock(side_effect=prepare if expect_hook else AssertionError("disabled hook"))
load_hook = Mock(return_value=hook)
if not expect_hook:
load_hook.side_effect = AssertionError("disabled hook factory")
monkeypatch.setattr(kv_utils, "hash_prefix_cache_value", Mock(side_effect=AssertionError("disabled hash")))
monkeypatch.setattr(engine_module, "get_diffusion_pre_process_func", lambda _config: preprocess)
monkeypatch.setattr(engine_module, "get_diffusion_post_process_func", lambda _config: None)
monkeypatch.setattr(engine_module, "get_diffusion_prefix_cache_func", load_hook)
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.od_config = SimpleNamespace(diffusion_kv_mode=mode, enable_prefix_caching=enabled)
engine._init_process_hooks(engine.od_config)
engine.prefix_cache_func = hook
assert engine._prepare_request_for_admission(request) is request
row = request.diffusion_kv_requests[0]
if expect_hook:
assert events == ["layout", "cache_inputs"]
assert row.cache_token_ids == tuple(range(8))
load_hook.assert_called_once_with(engine.od_config)
hook.assert_called_once_with(request)
else:
assert events == ["layout"]
assert row.cache_token_ids == ()
assert row.mm_features == []
load_hook.assert_not_called()
hook.assert_not_called()
@pytest.mark.parametrize(
"model_class_name,load_format",
[(None, "default"), ("UnregisteredPipeline", "default"), ("HunyuanImage3ForCausalMM", "diffusers")],
)
def test_engine_rejects_enabled_prefix_caching_without_hook(monkeypatch, model_class_name, load_format):
from vllm_omni.diffusion.data import OmniDiffusionConfig
monkeypatch.setattr(OmniDiffusionConfig, "_resolve_master_port", lambda _self: 29500)
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.od_config = OmniDiffusionConfig.from_kwargs(
model_class_name=model_class_name,
diffusion_load_format=load_format,
diffusion_kv_mode="paged_scheduler",
diffusion_kv_max_rows_per_request=2,
enable_prefix_caching=True,
)
with pytest.raises(ValueError, match="requires a registered prefix-cache hook"):
engine._init_process_hooks(engine.od_config)
def test_engine_rejects_out_of_profile_request_before_hashing():
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.od_config = SimpleNamespace(
diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER, enable_prefix_caching=True
)
engine._diffusion_kv_profile_limits = (1, 8, 4)
engine.prefix_cache_func = Mock(side_effect=AssertionError("invalid request must fail before identity work"))
with pytest.raises(ValueError, match="exceeds the startup memory-profile envelope"):
engine._prepare_request_for_admission(_request())
engine.prefix_cache_func.assert_not_called()
+137 -4
View File
@@ -30,26 +30,159 @@ def _config(num_blocks: int) -> KVCacheConfig:
)
def _request(public_id: str, sequence_id: int, *, seq_len: int = 8, kv_contexts=()) -> DiffusionKVRequest:
def _request(
public_id: str,
sequence_id: int,
*,
prefix_len: int = 4,
target_len: int = 4,
seq_len: int = 8,
cache_token_ids=(),
mm_features=(),
kv_contexts=(),
) -> DiffusionKVRequest:
return DiffusionKVRequest(
f"{public_id}/diffusion-kv/{sequence_id}",
sequence_id=sequence_id,
prefix_len=4,
target_len=4,
prefix_len=prefix_len,
target_len=target_len,
seq_len=seq_len,
cache_token_ids=cache_token_ids,
mm_features=mm_features,
kv_contexts=kv_contexts,
)
def _manager(num_blocks: int, *, max_model_len: int = 64) -> DiffusionKVCacheManager:
def _manager(
num_blocks: int,
*,
max_model_len: int = 64,
enable_prefix_caching: bool = False,
) -> DiffusionKVCacheManager:
return DiffusionKVCacheManager(
_config(num_blocks),
max_model_len=max_model_len,
scheduler_block_size=BLOCK_SIZE,
hash_block_size=BLOCK_SIZE,
enable_prefix_caching=enable_prefix_caching,
)
def test_successful_request_publishes_prefix_for_a_warm_hit() -> None:
manager = _manager(8, enable_prefix_caching=True)
cold = _request("cold", 0, cache_token_ids=range(4))
cold_metadata = manager.reserve_request("cold", (cold,))
assert cold_metadata is not None
assert cold_metadata.sequences[0].cached_prefix_len == 0
cold_prefix_block = cold_metadata.sequences[0].block_ids[0][0]
manager.publish_request("cold")
manager.free_request("cold")
warm = _request("warm", 0, cache_token_ids=range(4))
warm_metadata = manager.reserve_request("warm", (warm,))
assert warm_metadata is not None
assert warm_metadata.sequences[0].cached_prefix_len == 4
assert warm_metadata.sequences[0].block_ids[0][0] == cold_prefix_block
# Metadata contains both the shared hit and the newly allocated target.
assert len(warm_metadata.sequences[0].block_ids[0]) == 2
manager.free_request("warm")
assert warm.num_computed_tokens == 0
assert warm.shared_prefix_boundary == 0
def test_prefix_hash_root_is_stable_across_manager_instances() -> None:
first_manager = _manager(8, enable_prefix_caching=True)
second_manager = _manager(8, enable_prefix_caching=True)
first = _request("first", 0, cache_token_ids=range(4))
second = _request("second", 0, cache_token_ids=range(4))
first_manager._prepare_block_hashes((first,))
second_manager._prepare_block_hashes((second,))
assert first.block_hashes == second.block_hashes
def test_warm_hit_fits_at_the_same_capacity_as_the_cold_request() -> None:
manager = _manager(3, enable_prefix_caching=True)
cold = _request("cold", 0, cache_token_ids=range(4))
assert manager.reserve_request("cold", (cold,)) is not None
manager.publish_request("cold")
manager.free_request("cold")
warm = _request("warm", 0, cache_token_ids=range(4))
metadata = manager.reserve_request("warm", (warm,))
assert metadata is not None
assert metadata.sequences[0].cached_prefix_len == 4
def test_unpublished_request_does_not_become_a_prefix_hit() -> None:
manager = _manager(8, enable_prefix_caching=True)
failed = _request("failed", 0, cache_token_ids=range(4))
assert manager.reserve_request("failed", (failed,)) is not None
manager.free_request("failed")
retry = _request("retry", 0, cache_token_ids=range(4))
metadata = manager.reserve_request("retry", (retry,))
assert metadata is not None
assert metadata.sequences[0].cached_prefix_len == 0
def test_partial_prefix_hit_recomputes_from_the_first_changed_block() -> None:
manager = _manager(10, enable_prefix_caching=True)
cold = _request(
"cold",
0,
prefix_len=8,
seq_len=12,
cache_token_ids=range(8),
)
assert manager.reserve_request("cold", (cold,)) is not None
manager.publish_request("cold")
manager.free_request("cold")
warm = _request(
"warm",
0,
prefix_len=8,
seq_len=12,
cache_token_ids=(*range(4), 100, 101, 102, 103),
)
metadata = manager.reserve_request("warm", (warm,))
assert metadata is not None
assert metadata.sequences[0].cached_prefix_len == 4
assert len(metadata.sequences[0].block_ids[0]) == 3
def test_cfg_rows_use_the_minimum_warm_hit_as_one_execution_boundary() -> None:
manager = _manager(16, enable_prefix_caching=True)
for public_id, tokens in (("seed-a", range(8)), ("seed-b", (*range(4), 20, 21, 22, 23))):
request = _request(public_id, 0, prefix_len=8, seq_len=12, cache_token_ids=tokens)
assert manager.reserve_request(public_id, (request,)) is not None
manager.publish_request(public_id)
manager.free_request(public_id)
requests = (
_request("warm", 0, prefix_len=8, seq_len=12, cache_token_ids=range(8)),
_request(
"warm",
1,
prefix_len=8,
seq_len=12,
cache_token_ids=(*range(4), 30, 31, 32, 33),
),
)
metadata = manager.reserve_request("warm", requests)
assert metadata is not None
assert [sequence.cached_prefix_len for sequence in metadata.sequences] == [4, 4]
def test_reserve_and_free_multi_cfg_request() -> None:
manager = _manager(8)
free_before = manager.native_manager.block_pool.get_num_free_blocks()
@@ -256,7 +256,11 @@ def _scheduler(monkeypatch, num_blocks=24):
kv_cache_config=config,
scheduler_block_size=4,
hash_block_size=4,
kv_vllm_config=SimpleNamespace(model_config=SimpleNamespace(max_model_len=64), max_in_flight_tokens=64),
kv_vllm_config=SimpleNamespace(
model_config=SimpleNamespace(max_model_len=64),
max_in_flight_tokens=64,
cache_config=SimpleNamespace(enable_prefix_caching=False),
),
)
return scheduler, connector
@@ -113,7 +113,7 @@ class _FakeLayer:
self.head_size_v = 4
self.spec = _FakeSpec(non_causal=non_causal)
self.updates: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = []
self.calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor, object]] = []
self.calls: list[tuple[torch.Tensor, torch.Tensor, torch.Tensor, _FakeNativeMetadata]] = []
self.native_events: list[str] = []
self.layer_name = "layer-0"
self.kv_cache = object()
@@ -122,6 +122,8 @@ class _FakeLayer:
class _FakeNativeImpl:
vllm_flash_attn_version: int
def __init__(self, layer: _FakeLayer) -> None:
self.layer = layer
@@ -143,7 +145,7 @@ class _FakeNativeImpl:
key: torch.Tensor,
value: torch.Tensor,
_kv_cache,
metadata: object,
metadata: _FakeNativeMetadata,
output: torch.Tensor,
) -> torch.Tensor:
self.layer.native_events.append("forward")
@@ -202,7 +204,7 @@ def _make_adapter(
config = SimpleNamespace(
name="vllm-config",
model_config=SimpleNamespace(dtype=torch.float32),
model_config=SimpleNamespace(dtype=torch.float32, rswa_window=None),
)
adapter = DiffusionPagedAttentionAdapter(
vllm_config=config,
@@ -1222,9 +1224,15 @@ def test_row_contract_rejects_invalid_identity_or_span(kwargs: dict, message: st
DiffusionPagedAttentionRow(**values)
@pytest.mark.parametrize("pcp_size", [1, 2])
@pytest.mark.parametrize("selection_fails", [False, True])
def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads(
monkeypatch: pytest.MonkeyPatch,
pcp_size: int,
selection_fails: bool,
) -> None:
from vllm.v1.attention import selector
selected_backends = []
specialized_backends = []
impl_cls = Mock(return_value=SimpleNamespace(forward=Mock(), do_kv_cache_update=Mock()))
@@ -1237,28 +1245,39 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
original_backend_per_kind = {"full": object()}
config = SimpleNamespace(
attention_config=SimpleNamespace(backend=None, backend_per_kind=original_backend_per_kind),
parallel_config=SimpleNamespace(prefill_context_parallel_size=2),
model_config=SimpleNamespace(dtype=torch.float16),
cache_config=SimpleNamespace(cache_dtype="auto"),
attention_config=SimpleNamespace(backend=None, backend_per_kind=original_backend_per_kind, use_non_causal=True),
parallel_config=SimpleNamespace(prefill_context_parallel_size=pcp_size, decode_context_parallel_size=1),
model_config=SimpleNamespace(dtype=torch.float16, rswa_window=None),
cache_config=SimpleNamespace(cache_dtype="auto", user_specified_block_size=False),
kv_transfer_config=None,
speculative_config=None,
)
def select_backend(**_kwargs):
selected_backends.append(
(
config.attention_config.backend,
config.attention_config.backend_per_kind,
config.parallel_config.prefill_context_parallel_size,
)
)
def select_backend(*, backend, attn_selector_config, num_heads):
# Exercise the real 0.29 selector, replacing only platform resolution.
# Ulysses has already gathered tokens and sharded heads before the
# native kernel; the MoE PCP mapping must not request PCP attention.
assert not attn_selector_config.use_pcp
assert not attn_selector_config.use_dcp
assert attn_selector_config.use_non_causal
assert num_heads == 4
selected_backends.append((backend, config.attention_config.backend_per_kind))
if selection_fails:
raise ValueError("test backend unavailable")
return native_backend
monkeypatch.setattr(adapter_module, "get_attn_backend", select_backend)
monkeypatch.setattr("vllm.config.get_current_vllm_config", lambda: config)
monkeypatch.setattr(selector, "_cached_get_attn_backend", select_backend)
monkeypatch.setattr(adapter_module, "set_current_vllm_config", lambda _config: nullcontext())
def specialize_backend(backend, *, ulysses_degree):
specialized_backends.append((backend, ulysses_degree))
return backend
monkeypatch.setattr(
adapter_module.current_omni_platform,
"get_diffusion_paged_kv_attn_backend",
lambda backend, *, ulysses_degree: specialized_backends.append((backend, ulysses_degree)) or backend,
specialize_backend,
)
layer = SimpleNamespace(num_heads=8, softmax_scale=0.125)
spec = FullAttentionSpec(
@@ -1269,7 +1288,7 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
non_causal=True,
)
native_layer = adapter_module.DiffusionPagedAttentionLayerAdapter(
kwargs = dict(
layer_name="layer-0",
layer=layer,
spec=spec,
@@ -1277,12 +1296,21 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
device=torch.device("cpu"),
ulysses_degree=2,
)
if selection_fails:
with pytest.raises(ValueError, match="test backend unavailable"):
adapter_module.DiffusionPagedAttentionLayerAdapter(**kwargs)
else:
native_layer = adapter_module.DiffusionPagedAttentionLayerAdapter(**kwargs)
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 1)]
assert specialized_backends == [(native_backend, 2)]
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {})]
assert config.attention_config.backend is None
assert config.attention_config.backend_per_kind is original_backend_per_kind
assert config.parallel_config.prefill_context_parallel_size == 2
assert config.parallel_config.prefill_context_parallel_size == pcp_size
if selection_fails:
assert not specialized_backends
impl_cls.assert_not_called()
return
assert specialized_backends == [(native_backend, 2)]
assert native_layer.num_heads == 4
assert native_layer.num_kv_heads == 2
assert native_layer.spec.num_kv_heads == 2
@@ -1432,7 +1460,12 @@ def test_omni_attention_keeps_dense_kernel_without_active_adapter() -> None:
layer._no_parallel_strategy = object()
layer._get_active_parallel_strategy = lambda: Strategy()
layer._with_kv_cache_dtype = lambda metadata: metadata
layer._run_local_attention = lambda query, _key, _value, _metadata: events.append("dense") or query
def run_local_attention(query, _key, _value, _metadata):
events.append("dense")
return query
layer._run_local_attention = run_local_attention
qkv = torch.zeros(1, 2, 2, 4)
assert not layer.is_paged_kv_active()
+48 -1
View File
@@ -4,8 +4,10 @@
import pytest
import torch
import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers
from vllm.multimodal.inputs import MultiModalFeatureSpec, PlaceholderRange
from vllm.utils.hashing import get_hash_fn_by_name
from vllm.v1.core.kv_cache_manager import KVCacheManager
from vllm.v1.core.kv_cache_utils import BlockHash
from vllm.v1.core.kv_cache_utils import BlockHash, init_none_hash
from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
@@ -49,6 +51,51 @@ def test_request_exposes_native_request_and_diffusion_semantics() -> None:
assert request.status is RequestStatus.WAITING
def test_request_builds_only_full_prefix_block_hashes() -> None:
hash_function = get_hash_fn_by_name("sha256")
init_none_hash(hash_function)
request = _request(
prefix_len=6,
target_len=2,
cache_token_ids=range(6),
mm_features=[
MultiModalFeatureSpec(
data=None,
modality="image",
identifier="tail",
mm_position=PlaceholderRange(offset=4, length=2),
)
],
)
request.build_block_hashes(BLOCK_SIZE, hash_function)
assert len(request.block_hashes) == 1
assert request.skip_reading_prefix_cache is False
def test_changed_multimodal_identity_changes_the_block_hash() -> None:
hash_function = get_hash_fn_by_name("sha256")
init_none_hash(hash_function)
first = _request(cache_token_ids=range(4))
second = _request(
cache_token_ids=range(4),
mm_features=[
MultiModalFeatureSpec(
data=None,
modality="image",
identifier="image-a",
mm_position=PlaceholderRange(offset=2, length=2),
)
],
)
first.build_block_hashes(BLOCK_SIZE, hash_function)
second.build_block_hashes(BLOCK_SIZE, hash_function)
assert first.block_hashes != second.block_hashes
@pytest.mark.parametrize(
("overrides", "message"),
[
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
from unittest.mock import Mock
@@ -111,6 +111,29 @@ def test_runner_builds_prefill_and_denoise_rows_from_scheduler_metadata() -> Non
] == [("req-0", 0, 4, 2, 6), ("req-0", 1, 5, 2, 7)]
def test_runner_builds_suffix_prefill_row_for_a_prefix_hit() -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
metadata = DiffusionKVMetadata(
request_id="req-0",
allocation_generation=1,
sequences=(
DiffusionKVSequenceMetadata(
sequence_id=0,
prefix_len=8,
target_len=4,
seq_len=12,
block_ids=([1, 2, 3],),
cached_prefix_len=4,
),
),
)
attn_metadata = runner._build_paged_attention_metadata([metadata])
row = attn_metadata.prefill_rows[0]
assert (row.kv_start_pos, row.query_len, row.seq_len) == (4, 8, 12)
def test_runner_selects_only_local_cfg_parallel_row(monkeypatch: pytest.MonkeyPatch) -> None:
runner = make_runner(DiffusionKVCacheMode.PAGED_SCHEDULER)
runner.od_config.parallel_config.cfg_parallel_size = 2
@@ -2,13 +2,24 @@
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
import torch
import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers
from transformers.utils.generic import ModelOutput
from vllm.lora.request import LoRARequest
from vllm.utils.hashing import get_hash_fn_by_name
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec
import vllm_omni.diffusion.diffusion_engine as engine_module
import vllm_omni.diffusion.diffusion_kv.kv_cache_utils as kv_utils
import vllm_omni.diffusion.models.hunyuan_image3.pipeline_hunyuan_image3 as pipeline_module
import vllm_omni.diffusion.models.hunyuan_image3.request_layout as layout_module
from tests.helpers.kv_layout import build_kv_cache_tensor
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
from vllm_omni.diffusion.diffusion_kv.manager import DiffusionKVCacheManager
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVRequest
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_tokenizer import TokenizerEncodeOutput
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
@@ -27,6 +38,7 @@ from vllm_omni.diffusion.models.hunyuan_image3.request_layout import (
hunyuan_num_special_tokens,
normalize_hunyuan_cot_text,
prepare_hunyuan_layout,
prepare_hunyuan_prefix_cache,
)
from vllm_omni.diffusion.request import OmniDiffusionRequest
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -68,18 +80,26 @@ class _FakeTokenizerWrapper:
seq_lens = [prefix_len + 20 for prefix_len in self.prefix_lens]
padded_seq_len = max(seq_lens)
positions = torch.arange(padded_seq_len)
joint_image_slices = [[slice(1, 3), slice(3, 5)] if has_cond_image else [] for _ in range(rows)]
# Like the real tokenizer, joint spans enclose VAE + separator + ViT,
# while all_image_slices contains the individual RoPE spans. Leave one
# full text block before the image so partial reuse can be tested.
joint_image_slices = [[slice(5, 10)] if has_cond_image else [] for _ in range(rows)]
cond_vae_image_slices = [[slice(5, 7)] if has_cond_image else [] for _ in range(rows)]
cond_vit_image_slices = [[slice(8, 10)] if has_cond_image else [] for _ in range(rows)]
gen_image_slices = [[slice(length + 1, length + 17)] for length in self.prefix_lens]
all_image_slices = [joint + generated for joint, generated in zip(joint_image_slices, gen_image_slices)]
all_image_slices = [
vae + vit + generated
for vae, vit, generated in zip(cond_vae_image_slices, cond_vit_image_slices, gen_image_slices)
]
cond_vae_image_mask = (
torch.stack([(positions >= 1) & (positions < 3) for _ in range(rows)]) if has_cond_image else None
torch.stack([(positions >= 5) & (positions < 7) for _ in range(rows)]) if has_cond_image else None
)
cond_vit_image_mask = (
torch.stack([(positions >= 3) & (positions < 5) for _ in range(rows)]) if has_cond_image else None
torch.stack([(positions >= 8) & (positions < 10) for _ in range(rows)]) if has_cond_image else None
)
sections = []
for _ in range(rows):
row_sections = []
row_sections: list[dict[str, object]] = []
if has_cond_image:
row_sections.append(
{
@@ -101,6 +121,8 @@ class _FakeTokenizerWrapper:
),
cond_vae_image_mask=cond_vae_image_mask,
cond_vit_image_mask=cond_vit_image_mask,
cond_vae_image_slices=cond_vae_image_slices,
cond_vit_image_slices=cond_vit_image_slices,
joint_image_slices=joint_image_slices,
gen_image_slices=gen_image_slices,
real_pos=torch.tensor(seq_lens, dtype=torch.long).reshape(rows, 1),
@@ -155,6 +177,7 @@ def _request(
guidance_scale: float,
prompt="draw a cat",
request_id: str = "req",
seed: int | None = None,
) -> OmniDiffusionRequest:
return OmniDiffusionRequest(
prompt=prompt,
@@ -163,6 +186,7 @@ def _request(
width=1024,
guidance_scale=guidance_scale,
num_inference_steps=4,
seed=seed,
),
request_id=request_id,
)
@@ -205,6 +229,8 @@ def test_builds_kv_request_lengths_without_model_execution() -> None:
assert tokenizer.calls[0]["sequence_template"] == "instruct"
assert tokenizer.calls[0]["cfg_factor"] == 1
assert kv_requests[0].block_hashes == []
assert kv_requests[0].cache_token_ids == ()
assert kv_requests[0].mm_features == []
assert kv_requests[0].kv_contexts == ()
assert kv_requests[0].skip_reading_prefix_cache is True
@@ -226,12 +252,14 @@ def _reference_image() -> JointImageInfo:
return JointImageInfo(
vae_image_info=ImageInfo(
image_type="vae",
image_tensor=torch.arange(12, dtype=torch.float32).reshape(1, 3, 2, 2),
token_width=8,
token_height=8,
image_token_length=64,
),
vision_image_info=ImageInfo(
image_type="siglip2",
image_tensor=torch.arange(8, dtype=torch.float32).reshape(1, 2, 2, 2),
token_width=4,
token_height=4,
image_token_length=16,
@@ -243,6 +271,342 @@ def _reference_image() -> JointImageInfo:
)
def _prepare_cached_requests(request, tokenizer, image_processor):
layout = _prepare(request, tokenizer, image_processor)
request.diffusion_kv_requests = build_hunyuan_diffusion_kv_requests(request, layout)
prepare_hunyuan_prefix_cache(request)
return request.diffusion_kv_requests
def test_reference_prefix_identity_includes_image_content_and_vae_seed() -> None:
tokenizer, image_processor = _components([20])
def build(request_id: str, image: JointImageInfo, seed: int):
request = _request(
guidance_scale=1.0,
request_id=request_id,
seed=seed,
prompt={
"prompt": "edit this image",
"additional_information": {"batch_cond_image_info": [image]},
},
)
return _prepare_cached_requests(request, tokenizer, image_processor)[0]
first = build("first", _reference_image(), 7)
same = build("same", _reference_image(), 7)
changed_seed = build("changed-seed", _reference_image(), 8)
changed_image_value = _reference_image()
changed_image_value.vae_image_info.image_tensor[0, 0, 0, 0] = 99
changed_image = build("changed-image", changed_image_value, 7)
assert first.mm_features == same.mm_features
assert [(feature.mm_position.offset, feature.mm_position.length) for feature in first.mm_features] == [
(5, 2),
(8, 2),
]
for changed in (changed_seed, changed_image):
# Both subspans must carry the identity, not just the first one.
assert all(a.identifier != b.identifier for a, b in zip(first.mm_features, changed.mm_features, strict=True))
def test_reference_prefix_rejects_missing_inputs_with_nested_spans() -> None:
tokenizer, image_processor = _components([20])
request = _request(
guidance_scale=1.0,
prompt={"prompt": "edit this image", "additional_information": {"batch_cond_image_info": [_reference_image()]}},
)
layout = _prepare(request, tokenizer, image_processor)
request.diffusion_kv_requests = build_hunyuan_diffusion_kv_requests(request, layout)
request.prompt["additional_information"].pop("batch_cond_image_info")
with pytest.raises(ValueError, match="missing its canonical inputs"):
prepare_hunyuan_prefix_cache(request)
def test_reference_content_is_hashed_once_across_cfg_rows(monkeypatch):
tokenizer, image_processor = _components([20, 20])
image = _reference_image()
hasher = Mock(wraps=layout_module.hash_prefix_cache_value)
monkeypatch.setattr(layout_module, "hash_prefix_cache_value", hasher)
def build(request_id):
request = _request(
guidance_scale=5.0,
request_id=request_id,
seed=7,
prompt={"prompt": "edit", "additional_information": {"batch_cond_image_info": [image]}},
)
return _prepare_cached_requests(request, tokenizer, image_processor)
first = build("first")
assert sum(call.args[0][0] == "hunyuan-reference-v1" for call in hasher.call_args_list) == 1
assert len(first[0].mm_features) == len(first[1].mm_features) == 2
assert first[0].mm_features == first[1].mm_features
image.vae_image_info.image_tensor[0, 0, 0, 0] += 1
changed = build("changed")
assert sum(call.args[0][0] == "hunyuan-reference-v1" for call in hasher.call_args_list) == 2
assert all(a.identifier != b.identifier for a, b in zip(first[0].mm_features, changed[0].mm_features, strict=True))
def test_failed_cfg_cache_preparation_does_not_install_partial_inputs():
tokenizer, image_processor = _components([20, 20])
request = _request(
guidance_scale=5.0,
prompt={"prompt": "edit", "additional_information": {"batch_cond_image_info": [_reference_image()]}},
)
layout = _prepare(request, tokenizer, image_processor)
request.diffusion_kv_requests = build_hunyuan_diffusion_kv_requests(request, layout)
# First row prepares successfully; the second exposes inconsistent inputs.
layout.rope_image_info[0] = []
request.prompt["additional_information"].pop("batch_cond_image_info")
with pytest.raises(ValueError, match="missing its canonical inputs"):
prepare_hunyuan_prefix_cache(request)
assert all(row.cache_token_ids == () and row.mm_features == [] for row in request.diffusion_kv_requests)
@pytest.mark.parametrize("generator_list", [False, True])
def test_reference_identity_uses_explicit_generator_state_before_seed(generator_list):
tokenizer, image_processor = _components([20])
def build(request_id, seed, advance):
request = _request(
guidance_scale=1.0,
request_id=request_id,
seed=seed,
prompt={"prompt": "edit", "additional_information": {"batch_cond_image_info": [_reference_image()]}},
)
generator = torch.Generator(device="cpu").manual_seed(123)
if advance:
torch.rand(1, generator=generator)
request.sampling_params.generator = [generator] if generator_list else generator
before = generator.get_state().clone()
row = _prepare_cached_requests(request, tokenizer, image_processor)[0]
torch.testing.assert_close(before, generator.get_state())
row.build_block_hashes(4, get_hash_fn_by_name("sha256"))
return row.block_hashes
first = build("first", 7, False)
assert first == build("same-generator-different-unused-seed", 8, False)
changed = build("advanced-generator-same-seed", 7, True)
assert first[:1] == changed[:1]
assert all(a != b for a, b in zip(first[1:], changed[1:], strict=True))
def test_unseeded_reference_identity_is_request_local():
tokenizer, image_processor = _components([20])
def build(request_id):
request = _request(
guidance_scale=1.0,
request_id=request_id,
prompt={"prompt": "edit", "additional_information": {"batch_cond_image_info": [_reference_image()]}},
)
# Real requests normally receive an automatic seed in __post_init__.
request.sampling_params.seed = None
row = _prepare_cached_requests(request, tokenizer, image_processor)[0]
row.build_block_hashes(4, get_hash_fn_by_name("sha256"))
return row.block_hashes
first = build("first")
assert first == build("first")
changed = build("second")
assert first[:1] == changed[:1]
assert all(a != b for a, b in zip(first[1:], changed[1:], strict=True))
@pytest.mark.parametrize("guidance_scale", [1.0, 5.0])
def test_text_prefix_hashes_include_lora_identity_and_scale(guidance_scale: float) -> None:
tokenizer, image_processor = _components([20] * (1 + int(guidance_scale > 1.0)))
def build(request_id: str, adapter_id: int | None, scale: float):
request = _request(guidance_scale=guidance_scale, request_id=request_id)
if adapter_id is not None:
request.sampling_params.lora_request = LoRARequest(
lora_name=f"adapter-{adapter_id}", lora_int_id=adapter_id, lora_path=f"/adapters/{adapter_id}"
)
request.sampling_params.lora_scale = scale
kv_requests = _prepare_cached_requests(request, tokenizer, image_processor)
for kv_request in kv_requests:
kv_request.build_block_hashes(4, get_hash_fn_by_name("sha256"))
return kv_requests
base = build("base", None, 1.0)
same_base = build("same-base", None, 0.5) # Scale has no effect without an adapter.
first = build("first", 1, 0.5)
same = build("same", 1, 0.5)
changed_id = build("changed-id", 2, 0.5)
changed_scale = build("changed-scale", 1, 1.0)
for row in range(len(first)):
assert base[row].block_hashes == same_base[row].block_hashes
assert first[row].block_hashes == same[row].block_hashes
assert len(first[row].block_hashes) == 5
for changed in (base, changed_id, changed_scale):
assert first[row].cache_token_ids == changed[row].cache_token_ids
# LoRA changes even the text prefix, so every block must differ.
assert all(a != b for a, b in zip(first[row].block_hashes, changed[row].block_hashes, strict=True))
@pytest.fixture
def prefix_cache_manager(request):
native_kv_managers.register_all_kvcache_specs(None)
spec = FullAttentionSpec(block_size=4, num_kv_heads=2, head_size=8, dtype=torch.bfloat16)
num_blocks = 64
config = KVCacheConfig(
num_blocks=num_blocks,
kv_cache_tensors=[build_kv_cache_tensor(spec, num_blocks, ["layer0"])],
kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)],
)
manager = DiffusionKVCacheManager(
config,
max_model_len=64,
scheduler_block_size=4,
hash_block_size=4,
enable_prefix_caching=getattr(request, "param", True),
)
yield manager
manager.close()
@pytest.mark.parametrize("prefix_cache_manager", [False], indirect=True)
@pytest.mark.parametrize("guidance_scale", [1.0, 5.0])
def test_paged_cache_off_prepares_and_allocates_without_identity_work(
monkeypatch, prefix_cache_manager, guidance_scale
):
tokenizer, image_processor = _components([20] * (1 + int(guidance_scale > 1.0)))
config = SimpleNamespace(
model="fake",
model_class_name="HunyuanImage3ForCausalMM",
diffusion_kv_mode=DiffusionKVCacheMode.PAGED_SCHEDULER,
enable_prefix_caching=False,
)
monkeypatch.setattr(
pipeline_module,
"get_config",
lambda *_args, **_kwargs: SimpleNamespace(
vae_downsample_factor=(8, 8),
patch_size=2,
image_base_size=1024,
),
)
monkeypatch.setattr(pipeline_module, "HunyuanImage3ImageProcessor", lambda _config: image_processor)
monkeypatch.setattr(pipeline_module, "TokenizerWrapper", lambda _model: tokenizer)
monkeypatch.setattr(pipeline_module.GenerationConfig, "from_pretrained", lambda _model: SimpleNamespace())
monkeypatch.setattr(engine_module, "get_diffusion_post_process_func", lambda _config: None)
fail = Mock(side_effect=AssertionError("disabled prefix cache must not prepare identity"))
monkeypatch.setattr(engine_module, "get_diffusion_prefix_cache_func", fail)
monkeypatch.setattr(layout_module, "prepare_hunyuan_prefix_cache", fail)
monkeypatch.setattr(layout_module, "hash_prefix_cache_value", fail)
monkeypatch.setattr(kv_utils, "hash_prefix_cache_value", fail)
monkeypatch.setattr(kv_utils.MultiModalHasher, "hash_kwargs", fail)
monkeypatch.setattr(torch.Tensor, "tolist", fail)
monkeypatch.setattr(prefix_cache_manager, "_hash_function", fail)
engine = DiffusionEngine.__new__(DiffusionEngine)
engine.od_config = config
engine._init_process_hooks(config)
# Guard admission too, even if someone injects a hook after initialization.
engine.prefix_cache_func = fail
request = _request(
guidance_scale=guidance_scale,
prompt={"prompt": "edit", "additional_information": {"batch_cond_image_info": [_reference_image()]}},
)
request.sampling_params.seed = None
request.sampling_params.generator = SimpleNamespace(get_state=fail)
engine._prepare_request_for_admission(request)
assert isinstance(request.prepared_layout, HunyuanPreparedLayout)
assert all(row.cache_token_ids == () and row.mm_features == [] for row in request.diffusion_kv_requests)
metadata = prefix_cache_manager.reserve_request(request.request_id, request.diffusion_kv_requests)
assert metadata is not None
assert all(row.cached_prefix_len == 0 for row in metadata.sequences)
prefix_cache_manager.publish_request(request.request_id)
prefix_cache_manager.free_request(request.request_id)
fail.assert_not_called()
@pytest.mark.parametrize("guidance_scale", [1.0, 5.0])
@pytest.mark.parametrize(
("change", "expected_hit"),
[
("same", 20),
("prompt_suffix", 12),
("target", 20),
("vae", 4),
("vit", 4),
("vision_kwargs", 4),
("rope", 4),
("seed", 4),
("lora_id", 0),
("lora_scale", 0),
("base", 0),
],
)
def test_prefix_lookup_respects_reference_and_lora_identity(
prefix_cache_manager: DiffusionKVCacheManager, guidance_scale: float, change: str, expected_hit: int
) -> None:
tokenizer, image_processor = _components([20] * (1 + int(guidance_scale > 1.0)))
def build(request_id: str, change: str):
image = _reference_image()
if change == "vae":
image.vae_image_info.image_tensor[0, 0, 0, 0] = 99
elif change == "vit":
image.vision_image_info.image_tensor[0, 0, 0, 0] = 99
elif change == "vision_kwargs":
image.vision_encoder_kwargs["pixel_attention_mask"][0] = False
request = _request(
guidance_scale=guidance_scale,
request_id=request_id,
seed=8 if change == "seed" else 7,
prompt={"prompt": "edit this image", "additional_information": {"batch_cond_image_info": [image]}},
)
if change != "base":
adapter_id = 2 if change == "lora_id" else 1
request.sampling_params.lora_request = LoRARequest(
lora_name=f"adapter-{adapter_id}", lora_int_id=adapter_id, lora_path=f"/adapters/{adapter_id}"
)
request.sampling_params.lora_scale = 1.0 if change == "lora_scale" else 0.5
layout = _prepare(request, tokenizer, image_processor)
if change == "rope":
for spans in layout.rope_image_info:
spans[0] = (slice(5, 7), (2, 1))
elif change == "prompt_suffix":
layout.tokenizer_output.tokens[:, 12] += 100
elif change == "target":
layout.tokenizer_output.tokens[:, 20:] += 100
request.diffusion_kv_requests = build_hunyuan_diffusion_kv_requests(request, layout)
prepare_hunyuan_prefix_cache(request)
return request.diffusion_kv_requests
manager = prefix_cache_manager
cold = build("cold", "same")
cold_metadata = manager.reserve_request("cold", cold)
assert cold_metadata is not None
assert all(sequence.cached_prefix_len == 0 for sequence in cold_metadata.sequences)
# CPU-only: simulate successful materialization to test native cache lookup.
manager.publish_request("cold")
manager.free_request("cold")
warm = build("warm", change)
warm_metadata = manager.reserve_request("warm", warm)
assert warm_metadata is not None
for cold_row, warm_row, cold_sequence, warm_sequence in zip(
cold, warm, cold_metadata.sequences, warm_metadata.sequences, strict=True
):
if change == "prompt_suffix":
assert cold_row.cache_token_ids[:12] == warm_row.cache_token_ids[:12]
assert cold_row.cache_token_ids[12] != warm_row.cache_token_ids[12]
else:
assert cold_row.cache_token_ids == warm_row.cache_token_ids
assert warm_sequence.cached_prefix_len == expected_hit
hit_blocks = expected_hit // 4
assert cold_sequence.block_ids[0][:hit_blocks] == warm_sequence.block_ids[0][:hit_blocks]
assert cold_row.block_hashes[:hit_blocks] == warm_row.block_hashes[:hit_blocks]
assert all(
a != b for a, b in zip(cold_row.block_hashes[hit_blocks:], warm_row.block_hashes[hit_blocks:], strict=True)
)
def test_passes_preprocessed_reference_image_geometry_to_tokenizer() -> None:
tokenizer, image_processor = _components([20])
joint_image = _reference_image()
@@ -366,13 +730,17 @@ def test_prepared_model_inputs_match_local_tokenization(
# Preparation runs without a forward context. Coverage must come from
# the scheduler metadata copied onto this request, including every CFG row.
reused = pipeline.prepare_model_inputs(
**common_kwargs, prepared_layout=prepared_layout, kv_computed_tokens=(5,) * len(prefix_lens)
# The image span is [5, 10); native KV must cover its end, not
# merely reach the first image token.
**common_kwargs,
prepared_layout=prepared_layout,
kv_computed_tokens=(10,) * len(prefix_lens),
)
assert reused["cond_vae_images"] is None and reused["cond_vit_images"] is None
partial = pipeline.prepare_model_inputs(
**common_kwargs,
prepared_layout=prepared_layout,
kv_computed_tokens=(5,) * (len(prefix_lens) - 1) + (4,),
kv_computed_tokens=(10,) * (len(prefix_lens) - 1) + (9,),
)
assert partial["cond_vae_images"] is not None and partial["cond_vit_images"] is not None
@@ -496,6 +864,9 @@ def test_cfg_rows_preserve_reusable_token_ids_and_native_transfer_boundary(trans
tokenizer, image_processor = _components([12, 14])
request = _request(guidance_scale=5.0)
# Prompt token IDs are only materialized for the native AR -> DiT
# transfer path; local prefix caching does not need them.
request.kv_transfer_params = {"num_transfer_tokens": 0}
layout = _prepare(request, tokenizer, image_processor)
layout.tokenizer_output.think_recaption_end_pos = [[7], [5]]
layout.tokenizer_output.uncond_cfg_start_pos = [[None], [5]]
@@ -147,6 +147,80 @@ def test_prepare_model_inputs_reuses_prepared_layout(monkeypatch):
torch.testing.assert_close(model_inputs["input_ids"], prepared.tokenizer_output.tokens)
def test_slice_cached_prefix_inputs_keeps_full_kv_axis() -> None:
inputs_embeds = torch.arange(12, dtype=torch.float32).reshape(1, 6, 2)
attention_mask = torch.arange(36).reshape(1, 1, 6, 6)
position_ids = torch.arange(6).reshape(1, 6)
custom_pos_emb = (torch.arange(6).reshape(1, 6), torch.arange(10, 16).reshape(1, 6))
image_mask = torch.tensor([[False, False, True, True, True, True]])
gen_timestep_scatter_index = torch.tensor([[2]])
sliced = HunyuanImage3Pipeline._slice_cached_prefix_inputs(
inputs_embeds,
attention_mask,
position_ids,
custom_pos_emb,
image_mask,
gen_timestep_scatter_index,
[6],
[6],
2,
)
(
sliced_embeds,
sliced_mask,
sliced_positions,
sliced_rope,
sliced_image_mask,
sliced_scatter,
sliced_query_lens,
sliced_seq_len,
) = sliced
torch.testing.assert_close(sliced_embeds, inputs_embeds[:, 2:])
torch.testing.assert_close(sliced_mask, attention_mask[:, :, 2:, :])
torch.testing.assert_close(sliced_positions, position_ids[:, 2:])
torch.testing.assert_close(sliced_rope[0], custom_pos_emb[0][:, 2:])
torch.testing.assert_close(sliced_rope[1], custom_pos_emb[1][:, 2:])
torch.testing.assert_close(sliced_image_mask, image_mask[:, 2:])
torch.testing.assert_close(sliced_scatter, torch.tensor([[0]]))
assert sliced_query_lens == [4]
assert sliced_seq_len == 4
@pytest.mark.parametrize("local_prefix_hit", [True, False])
def test_ar_reuse_does_not_consume_local_prefix_hits(local_prefix_hit):
from vllm_omni.diffusion.forward_context import set_forward_context
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import (
HunyuanImage3Text2ImagePipeline,
)
pipe = object.__new__(HunyuanImage3Text2ImagePipeline)
input_ids = torch.arange(12).reshape(1, 12)
cond_image = torch.ones(1, 1)
kwargs = dict(
query_lens=[12],
attention_mask=torch.ones(1, 1, 12, 12),
position_ids=input_ids.clone(),
image_mask=torch.zeros_like(input_ids, dtype=torch.bool),
gen_timestep_scatter_index=torch.tensor([[8]]),
cond_vae_images=cond_image,
)
runtime = SimpleNamespace(metadata=SimpleNamespace(prefill_rows=[SimpleNamespace(kv_start_pos=4)]))
with set_forward_context(paged_kv_runtime=runtime, paged_kv_cached_prefix_len=4 if local_prefix_hit else 0):
output, ar_reuse_len = pipe._maybe_handle_ar_kv_reuse(input_ids, kwargs, 1, False, None, torch.device("cpu"))
if local_prefix_hit:
assert ar_reuse_len == 0
assert output is input_ids
assert kwargs["query_lens"] == [12]
assert kwargs["cond_vae_images"] is cond_image
else:
assert ar_reuse_len == 4
torch.testing.assert_close(output, input_ids[:, 4:])
assert kwargs["query_lens"] == [8]
assert "cond_vae_images" not in kwargs
def test_hunyuan_step_group_key_ignores_step_index_for_later_steps():
pipeline = _pipeline()
states = [_state("req-0", 1), _state("req-1", 3)]
@@ -416,7 +490,7 @@ def test_denoise_step_uses_input_batch_group_order_and_splits_back(monkeypatch):
}
]
captured = {}
captured: dict[str, object] = {}
def fake_restore_prompt_kv_cache(states_arg, row_state_indexes, row_branches):
del states_arg
@@ -443,7 +517,9 @@ def test_denoise_step_uses_input_batch_group_order_and_splits_back(monkeypatch):
assert captured["row_state_indexes"] == [0, 1, 0, 1]
assert captured["row_branches"] == [0, 0, 1, 1]
assert captured["input_ids"] is None
assert isinstance(captured["images"], torch.Tensor)
assert tuple(captured["images"].shape) == (4, 1)
assert isinstance(captured["timestep"], torch.Tensor)
assert captured["timestep"].tolist() == [0.5, 0.0, 0.5, 0.0]
assert captured["merged_attention_mask_shape"] == (4, 1, 2, 6)
assert captured["merged_full_attn_spans"] == [[(4, 6)], [(4, 6)], [(4, 6)], [(4, 6)]]
@@ -8,7 +8,7 @@ Covers: cache → reuse flow, AR KV injection, CFG (sequential & parallel), SP,
from __future__ import annotations
import math
from contextlib import ExitStack, contextmanager
from contextlib import AbstractContextManager, ExitStack, contextmanager
from types import SimpleNamespace
from unittest.mock import patch
@@ -59,7 +59,7 @@ class MockAttention(nn.Module):
@contextmanager
def patched_mgr_env(sp_size=1):
target = _TRANSFORMER_MODULE
patches = [
patches: list[AbstractContextManager] = [
patch(f"{target}.get_sequence_parallel_world_size", return_value=sp_size),
patch(f"{target}.get_allgather_parallel_world_size", return_value=sp_size),
patch(f"{target}.get_ulysses_parallel_world_size", return_value=1, create=True),
@@ -242,6 +242,39 @@ def test_legacy_tensor_prefix_still_uses_dense_cache_and_reuse() -> None:
assert mgr.attn.calls[-1][1].shape == (1, prefix_len + first_len, NUM_HEADS, HEAD_DIM)
@pytest.mark.parametrize("prefix_len", [0, 19])
@pytest.mark.parametrize("prompt_query_len", [0, 3])
def test_paged_sp_splits_uncached_queries(prefix_len, prompt_query_len) -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
mgr = _make_cache_mgr(sp_size=2)
mgr.attn.paged_kv_active = True
shard_image_size = IMAGE_TOKEN_LEN // 2
query_len = prompt_query_len + shard_image_size
query = torch.randn(2 * query_len, NUM_HEADS, HEAD_DIM)
key, value = _make_known_kv(2 * query_len)
with set_forward_context(), override_paged_kv_adapter(object()):
output = mgr(
query,
key,
value,
first_step=True,
query_lens=[query_len] * 2,
seq_lens=[prefix_len + query_len] * 2,
shard_image_size=shard_image_size,
full_attn_spans=[[(prefix_len + prompt_query_len, prefix_len + query_len)]] * 2,
)
torch.testing.assert_close(output, query)
metadata = mgr.attn.paged_metadata[0]
torch.testing.assert_close(
metadata.joint_key, key.reshape(2, query_len, NUM_KV_HEADS, HEAD_DIM)[:, :prompt_query_len]
)
assert metadata.joint_query.shape[1] == prompt_query_len
assert mgr.attn.paged_calls[0][0].shape[1] == shard_image_size
assert mgr.image_kv_cache_map is None
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
@@ -391,6 +424,7 @@ def test_no_ar_kv(bs):
gen_timestep_scatter_index=_gen_timestep_index(bs, prompt_len),
)
assert mgr.image_kv_cache_map is not None
cached_key, cached_value = mgr.image_kv_cache_map
# 3 prompt tokens cached per batch
assert cached_key.shape == (bs, prompt_len, NUM_KV_HEADS, HEAD_DIM)
@@ -478,12 +512,14 @@ def test_ar_kv_no_cfg(sp_size):
gen_timestep_scatter_index=_gen_timestep_index(bs, current_start),
)
assert mgr.image_kv_cache_map is not None
cached_key, cached_value = mgr.image_kv_cache_map
if sp_size == 1:
# cached = ar(5) + prompt(3) = 8
expected_cached_len = 8
else:
# cached = seq_len - shard_image_size = 17 - 4 = 13
assert shard_image_size is not None
expected_cached_len = seq_len - shard_image_size
assert cached_key.shape == (bs, expected_cached_len, NUM_KV_HEADS, HEAD_DIM)
@@ -5,6 +5,7 @@
Regression tests for https://github.com/vllm-project/vllm-omni/issues/1862
"""
import json
from collections.abc import Mapping
import pytest
@@ -266,6 +267,21 @@ def test_architecture_name_resolves_via_pipeline_class_fallback():
assert hunyuan_od_config.max_multimodal_image_inputs == HUNYUAN_IMAGE3_MAX_INPUT_IMAGES
def test_architecture_only_checkpoint_propagates_multimodal_limit(tmp_path):
"""A config.json-only checkpoint must expose its image-input capability."""
(tmp_path / "config.json").write_text(
json.dumps({"architectures": ["HunyuanImage3ForCausalMM"]}),
encoding="utf-8",
)
od_config = OmniDiffusionConfig(model=str(tmp_path))
od_config.enrich_config()
assert od_config.model_class_name == "HunyuanImage3ForCausalMM"
assert od_config.supports_multimodal_inputs is True
assert od_config.max_multimodal_image_inputs == HUNYUAN_IMAGE3_MAX_INPUT_IMAGES
def test_additional_config_roundtrip():
additional_config = {"torchair_graph_config": {"enabled": True}}
od = _roundtrip_diffusion_config(model="x", additional_config=additional_config)
@@ -149,6 +149,24 @@ def test_dense_mode_does_not_build_kv_profile_request() -> None:
engine._make_dummy_request.assert_not_called()
def test_explicit_profile_frames_preserve_warmup_skip(monkeypatch):
engine = object.__new__(DiffusionEngine)
engine.od_config = OmniDiffusionConfig.__new__(OmniDiffusionConfig)
engine.od_config.model_class_name = "HunyuanImage3ForCausalMM"
monkeypatch.setattr("vllm_omni.diffusion.diffusion_engine.supports_multimodal_input", lambda _: (True, False))
monkeypatch.setattr("vllm_omni.diffusion.diffusion_engine.image_color_format", lambda _: "RGB")
monkeypatch.setattr("vllm_omni.diffusion.diffusion_engine.get_dummy_run_num_frames", lambda *_: 0)
kwargs = dict(height=1024, width=1024, guidance_scale=5.0, num_image_inputs=3)
assert engine._make_dummy_request(**kwargs) is None
request = engine._make_dummy_request(**kwargs, num_frames=1)
assert request is not None
assert request.sampling_params.num_frames == 1
assert request.sampling_params.num_inference_steps == 1
assert len(request.prompt["multi_modal_data"]["image"]) == 3
@pytest.mark.parametrize(
("execution_mode", "uses_dlo_dp", "max_num_seqs", "expected_profile_requests"),
[
+54 -4
View File
@@ -223,7 +223,7 @@ def test_release_captured_graphs_tolerates_a_pipeline_without_captures():
assert not hasattr(runner, "graph_runners")
def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summary: bool = True):
def _make_runner(cache_backend, cache_backend_name: str | None, enable_cache_dit_summary: bool = True):
runner = object.__new__(DiffusionModelRunner)
runner.vllm_config = object()
runner.device = torch.device("cpu")
@@ -253,6 +253,55 @@ def _make_runner(cache_backend, cache_backend_name: str, enable_cache_dit_summar
return runner
@pytest.mark.core_model
@pytest.mark.cpu
@pytest.mark.parametrize("imported_tokens", [None, (4, 4), (4, 2)])
def test_request_forward_separates_imported_kv_and_local_hits(monkeypatch, imported_tokens):
from contextlib import nullcontext
from vllm_omni.diffusion.diffusion_kv.metadata import DiffusionKVMetadata, DiffusionKVSequenceMetadata
runner = _make_runner(cache_backend=None, cache_backend_name="none")
runner.od_config.kv_transfer_config = object() if imported_tokens else None
request = _make_request()
metadata = DiffusionKVMetadata(
request_id=request.request_id,
allocation_generation=1,
sequences=tuple(
DiffusionKVSequenceMetadata(
sequence_id=i,
prefix_len=8,
target_len=4,
seq_len=12,
block_ids=([1, 2, 3],),
cached_prefix_len=0 if imported_tokens else 4,
num_computed_tokens=imported_tokens[i] if imported_tokens else 4,
)
for i in range(2)
),
)
captured = {}
@contextmanager
def capture_context(**kwargs):
captured.update(kwargs)
yield
monkeypatch.setattr(model_runner_module, "set_forward_context", capture_context)
runner.diffusion_kv_backend.activate_paged_attention_metadata = Mock(return_value=(object(), nullcontext()))
runner._execute_request_list(
[request],
od_config=runner.od_config,
allow_single_output=True,
require_request_batch_support=False,
record_name="test",
record_output_peak_memory=False,
diffusion_kv_metadata=[metadata],
)
assert captured["paged_kv_cached_prefix_len"] == (0 if imported_tokens else 4)
assert getattr(request, "kv_computed_tokens", ()) == (imported_tokens or ())
def _make_compile_runner(
model=None,
*,
@@ -310,7 +359,8 @@ def test_refresh_cache_prefers_request_steps_then_schedule_then_pipeline_default
@pytest.mark.core_model
@pytest.mark.cpu
def test_refresh_cache_without_request_or_pipeline_default_warns(caplog):
def test_refresh_cache_without_request_or_pipeline_default_warns(caplog, monkeypatch):
monkeypatch.setattr(model_runner_module.logger, "handlers", [*model_runner_module.logger.handlers, caplog.handler])
cache_backend = _EnabledCacheBackend()
runner = _make_runner(cache_backend=cache_backend, cache_backend_name="cache_dit")
req = _make_request()
@@ -828,7 +878,7 @@ def test_profile_run_executes_maximum_step_batch_without_resetting_peak(monkeypa
observed_batch_rows.append((len(states), int(input_batch.latents.shape[0])))
return original_denoise_step(input_batch, states)
runner.pipeline.denoise_step = denoise_step
monkeypatch.setattr(runner.pipeline, "denoise_step", denoise_step)
runner._validate_diffusion_kv_metadata = Mock(side_effect=AssertionError("profile must bypass admission"))
forward_context_calls = []
@@ -1313,7 +1363,7 @@ def test_vllm_set_forward_context_implementation(monkeypatch):
device_config=DeviceConfig(device="cpu"),
compilation_config=CompilationConfig(),
)
calls = []
calls: list[tuple[str, object]] = []
@contextmanager
def _set_priority():
@@ -20,8 +20,10 @@ from vllm_omni.diffusion.registry import (
_DIFFUSION_MODELS,
_DIFFUSION_POST_PROCESS_FUNCS,
_DIFFUSION_PRE_PROCESS_FUNCS,
_DIFFUSION_PREFIX_CACHE_FUNCS,
get_diffusion_post_process_func,
get_diffusion_pre_process_func,
get_diffusion_prefix_cache_func,
register_diffusion_model,
)
from vllm_omni.platforms.interface import OmniPlatform, OmniPlatformEnum
@@ -70,6 +72,7 @@ class TestRegisterDiffusionModel:
original_pre = _DIFFUSION_PRE_PROCESS_FUNCS.copy()
original_post = _DIFFUSION_POST_PROCESS_FUNCS.copy()
original_ir_op_priority = _DIFFUSION_IR_OP_PRIORITY_FUNCS.copy()
original_prefix_cache = _DIFFUSION_PREFIX_CACHE_FUNCS.copy()
yield
_DIFFUSION_MODELS.clear()
_DIFFUSION_MODELS.update(original_models)
@@ -79,6 +82,8 @@ class TestRegisterDiffusionModel:
_DIFFUSION_POST_PROCESS_FUNCS.update(original_post)
_DIFFUSION_IR_OP_PRIORITY_FUNCS.clear()
_DIFFUSION_IR_OP_PRIORITY_FUNCS.update(original_ir_op_priority)
_DIFFUSION_PREFIX_CACHE_FUNCS.clear()
_DIFFUSION_PREFIX_CACHE_FUNCS.update(original_prefix_cache)
def test_register_new_model(self):
"""Test registering a new diffusion model with pre/post process functions."""
@@ -89,6 +94,7 @@ class TestRegisterDiffusionModel:
pre_process_func_name="test_pre_process",
post_process_func_name="test_post_process",
ir_op_priority_func_name="test_ir_op_priority",
prefix_cache_func_name="test_prefix_cache",
)
assert "TestPipeline" in _DIFFUSION_MODELS
assert _DIFFUSION_MODELS["TestPipeline"] == (
@@ -99,6 +105,29 @@ class TestRegisterDiffusionModel:
assert _DIFFUSION_PRE_PROCESS_FUNCS["TestPipeline"] == "test_pre_process"
assert _DIFFUSION_POST_PROCESS_FUNCS["TestPipeline"] == "test_post_process"
assert _DIFFUSION_IR_OP_PRIORITY_FUNCS["TestPipeline"] == "test_ir_op_priority"
assert _DIFFUSION_PREFIX_CACHE_FUNCS["TestPipeline"] == "test_prefix_cache"
def test_registered_prefix_hook_is_loaded_from_plugin(self):
hook = Mock()
factory = Mock(return_value=hook)
register_diffusion_model(
"TestPrefixPipeline", "test_plugin.pipeline", "TestPrefixPipeline", prefix_cache_func_name="prefix_factory"
)
config = SimpleNamespace(model_class_name="TestPrefixPipeline")
with patch(
"vllm_omni.diffusion.registry.importlib.import_module", return_value=SimpleNamespace(prefix_factory=factory)
) as load:
assert get_diffusion_prefix_cache_func(config) is hook
load.assert_called_once_with("test_plugin.pipeline")
factory.assert_called_once_with(config)
def test_builtin_hunyuan_prefix_hook(self):
from vllm_omni.diffusion.models.hunyuan_image3.request_layout import prepare_hunyuan_prefix_cache
assert (
get_diffusion_prefix_cache_func(SimpleNamespace(model_class_name="HunyuanImage3ForCausalMM"))
is prepare_hunyuan_prefix_cache
)
def test_register_model_accepts_deprecated_action_postprocess_keyword(self):
"""Deprecated action hook keyword is accepted but not registered."""
+219 -6
View File
@@ -26,7 +26,7 @@ from vllm_omni.diffusion.sched import (
Scheduler,
StepScheduler,
)
from vllm_omni.diffusion.sched.interface import CachedRequestData, NewRequestData
from vllm_omni.diffusion.sched.interface import CachedRequestData, NewRequestData, SchedulerRequestState
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
@@ -96,6 +96,7 @@ def _initialize_paged_scheduler(
*,
num_blocks: int = 64,
max_num_seqs: int = 1,
enable_prefix_caching: bool = False,
) -> None:
native_kv_managers.register_all_kvcache_specs(None)
spec = FullAttentionSpec(
@@ -121,22 +122,42 @@ def _initialize_paged_scheduler(
kv_vllm_config=SimpleNamespace(
model_config=SimpleNamespace(max_model_len=64),
max_in_flight_tokens=64,
cache_config=SimpleNamespace(
enable_prefix_caching=enable_prefix_caching,
prefix_caching_hash_algo="sha256",
),
),
)
def _attach_diffusion_kv(request: OmniDiffusionRequest, *, seq_len: int = 8) -> None:
def _attach_diffusion_kv(
request: OmniDiffusionRequest,
*,
seq_len: int = 8,
prefix_len: int = 4,
cache_token_ids=(),
) -> None:
request.diffusion_kv_requests = (
DiffusionKVRequest(
f"{request.request_id}/diffusion-kv/0",
sequence_id=0,
prefix_len=4,
prefix_len=prefix_len,
target_len=4,
seq_len=seq_len,
cache_token_ids=cache_token_ids,
),
)
def _make_aborted_request_output(req_id: str) -> RunnerOutput:
return RunnerOutput(
request_id=req_id,
step_index=None,
finished=True,
result=DiffusionOutput(output=None, aborted=True),
)
@pytest.mark.asyncio
@pytest.mark.parametrize("scheduler_cls", [RequestScheduler, StepScheduler])
@pytest.mark.parametrize("failure", ["timeout", "registration"])
@@ -168,7 +189,7 @@ async def test_single_native_kv_failure_reaches_output_stream(mocker, scheduler_
engine.abort_queue = queue.Queue()
engine._cv = threading.Condition()
engine.main_loop = asyncio.get_running_loop()
stream = asyncio.Queue()
stream: asyncio.Queue[DiffusionOutput] = asyncio.Queue()
engine._out_streams = {"failed": stream}
engine.executor = mocker.Mock()
engine.executor.prepare_kv_for_forward.return_value = KVConnectorOutput()
@@ -192,7 +213,7 @@ class _StubScheduler:
self._output = output
self.initialized_with = None
self._request_id = request.request_id
self._state = None
self._state: SchedulerRequestState | None = None
self._scheduled = False
self.max_num_running_reqs = 1
@@ -201,7 +222,7 @@ class _StubScheduler:
def add_request(self, request: OmniDiffusionRequest) -> str:
assert request is self._request
self._state = SimpleNamespace(request_id=self._request_id, req=request)
self._state = SchedulerRequestState(request_id=self._request_id, req=request)
return self._request_id
def schedule(self):
@@ -223,6 +244,7 @@ class _StubScheduler:
def update_from_output(self, sched_output, output) -> set[str]:
del sched_output
assert output is self._output
assert self._state is not None
self._state.status = DiffusionRequestStatus.FINISHED_COMPLETED
return {self._request_id}
@@ -553,6 +575,197 @@ class TestRequestScheduler:
assert metadata.request_id == request.request_id
assert len(metadata.sequences[0].block_ids[0]) == 4
def test_diffusion_kv_publishes_only_after_successful_completion(self) -> None:
_initialize_paged_scheduler(self.scheduler, enable_prefix_caching=True)
first = _make_request("publish-success")
_attach_diffusion_kv(first, cache_token_ids=range(4))
self.scheduler.add_request(first)
first_schedule = self.scheduler.schedule()
assert self.scheduler.update_from_output(first_schedule, _make_request_output(first.request_id)) == {
first.request_id
}
warm = _make_request("publish-warm")
_attach_diffusion_kv(warm, cache_token_ids=range(4))
self.scheduler.add_request(warm)
warm_schedule = self.scheduler.schedule()
metadata = warm_schedule.scheduled_new_reqs[0].diffusion_kv_metadata
assert metadata is not None
assert metadata.sequences[0].cached_prefix_len == 4
@pytest.mark.parametrize("boundaries", [(8, 4), (8, 0), (0, 8), (4, 8), (8, 8), (4, 4), (0, 0)])
@pytest.mark.parametrize("num_branches", [1, 2])
def test_diffusion_kv_batches_only_matching_prefix_boundaries(self, boundaries, num_branches) -> None:
_initialize_paged_scheduler(self.scheduler, max_num_seqs=2, enable_prefix_caching=True)
manager = self.scheduler._diffusion_kv_manager
assert manager is not None
pool = manager.native_manager.block_pool
empty_free_blocks = pool.get_num_free_blocks()
warmup = _make_request("warmup")
_attach_diffusion_kv(warmup, seq_len=12, prefix_len=8, cache_token_ids=range(8))
self.scheduler.add_request(warmup)
self.scheduler.update_from_output(self.scheduler.schedule(), _make_request_output("warmup"))
for request_id, boundary in zip(("first", "second"), boundaries):
request = _make_request(request_id)
# Each request's CFG branches share a boundary, but requests may not.
tokens = tuple(range(boundary)) + tuple(range(100, 108 - boundary))
request.diffusion_kv_requests = tuple(
DiffusionKVRequest(
f"{request_id}/diffusion-kv/{branch}",
sequence_id=branch,
prefix_len=8,
target_len=4,
seq_len=12,
cache_token_ids=tokens,
)
for branch in range(num_branches)
)
self.scheduler.add_request(request)
scheduled = self.scheduler.schedule()
matching = boundaries[0] == boundaries[1]
expected_ids = ["first", "second"] if matching else ["first"]
assert _new_ids(scheduled) == expected_ids
assert {
sequence.cached_prefix_len
for req in scheduled.scheduled_new_reqs
for sequence in req.diffusion_kv_metadata.sequences
} == {boundaries[0]}
if not matching:
second_state = self.scheduler.get_request_state("second")
assert second_state.status is DiffusionRequestStatus.WAITING
assert not manager.has_request("second")
# Retrying admission cannot leak reservations or publish its suffix.
free_blocks = pool.get_num_free_blocks()
for _ in range(3):
retry = self.scheduler.schedule()
assert _new_ids(retry) == [] and _cached_ids(retry) == ["first"]
assert pool.get_num_free_blocks() == free_blocks
assert not manager.has_request("second")
for row in second_state.diffusion_kv_requests:
assert row.num_computed_tokens == 0
assert manager.native_manager.get_computed_blocks(row)[1] == boundaries[1]
self.scheduler.update_from_output(
scheduled,
BatchRunnerOutput.from_list([_make_request_output(req_id) for req_id in expected_ids]),
)
if not matching:
next_wave = self.scheduler.schedule()
assert _new_ids(next_wave) == ["second"]
assert next_wave.scheduled_new_reqs[0].diffusion_kv_metadata.sequences[0].cached_prefix_len == boundaries[1]
self.scheduler.update_from_output(next_wave, _make_request_output("second"))
assert not self.scheduler.has_requests()
assert pool.get_num_free_blocks() == empty_free_blocks
def test_deferred_allocation_is_not_registered_for_transfer(self, mocker) -> None:
_initialize_paged_scheduler(self.scheduler, max_num_seqs=2)
manager = self.scheduler._diffusion_kv_manager
connector = mocker.Mock()
connector.get_num_new_matched_tokens.return_value = (4, True)
self.scheduler._kv_connector = connector
# Model a compatibility decision that needs the completed lookup.
can_schedule = self.scheduler._can_schedule_waiting
mocker.patch.object(
self.scheduler,
"_can_schedule_waiting",
side_effect=lambda state: can_schedule(state)
and not (state.request_id == "deferred" and manager.has_request("deferred")),
)
for request_id in ("admitted", "deferred"):
request = _make_request(request_id)
_attach_diffusion_kv(request)
request.diffusion_kv_requests[0].prompt_token_ids = [1, 2, 3, 4]
request.kv_transfer_params = {"num_transfer_tokens": 4}
self.scheduler.add_request(request)
assert _new_ids(self.scheduler.schedule()) == ["admitted"]
assert connector.update_state_after_alloc.call_count == 1
assert not manager.has_request("deferred")
assert "deferred" not in self.scheduler._kv_request_generations
assert "deferred" not in self.scheduler._kv_loading_request_ids
assert "deferred/diffusion-kv/0" not in self.scheduler._kv_transfer_request_ids
assert self.scheduler.get_request_state("deferred").status is DiffusionRequestStatus.WAITING
def test_diffusion_kv_deferred_request_can_be_cancelled(self) -> None:
_initialize_paged_scheduler(self.scheduler, max_num_seqs=2, enable_prefix_caching=True)
manager = self.scheduler._diffusion_kv_manager
pool = manager.native_manager.block_pool
empty_free_blocks = pool.get_num_free_blocks()
for request_id, tokens in (("warmup", range(4)), ("hit", range(4)), ("miss", range(10, 14))):
request = _make_request(request_id)
_attach_diffusion_kv(request, cache_token_ids=tokens)
self.scheduler.add_request(request)
if request_id == "warmup":
self.scheduler.update_from_output(self.scheduler.schedule(), _make_request_output("warmup"))
scheduled = self.scheduler.schedule()
assert _new_ids(scheduled) == ["hit"]
self.scheduler.finish_requests("miss", DiffusionRequestStatus.FINISHED_ABORTED)
self.scheduler.update_from_output(scheduled, _make_request_output("hit", error="worker failed"))
assert not manager.has_request("miss")
assert not self.scheduler.has_requests()
assert pool.get_num_free_blocks() == empty_free_blocks
def test_diffusion_kv_preempted_request_keeps_reservation_when_deferred(self) -> None:
_initialize_paged_scheduler(self.scheduler, max_num_seqs=2, enable_prefix_caching=True)
manager = self.scheduler._diffusion_kv_manager
warmup = _make_request("warmup")
_attach_diffusion_kv(warmup, cache_token_ids=range(4))
self.scheduler.add_request(warmup)
self.scheduler.update_from_output(self.scheduler.schedule(), _make_request_output("warmup"))
hit = _make_request("hit")
_attach_diffusion_kv(hit, cache_token_ids=range(4))
self.scheduler.add_request(hit)
self.scheduler.schedule()
miss = _make_request("miss")
_attach_diffusion_kv(miss, cache_token_ids=range(10, 14))
self.scheduler.add_request(miss)
# Install retained state as if this request were resuming an earlier wave.
miss_state = self.scheduler.get_request_state("miss")
miss_state.status = DiffusionRequestStatus.PREEMPTED
miss_metadata = manager.reserve_request("miss", miss_state.diffusion_kv_requests)
assert _cached_ids(self.scheduler.schedule()) == ["hit"]
assert self.scheduler.get_request_state("miss").status is DiffusionRequestStatus.PREEMPTED
assert manager.get_metadata("miss") is miss_metadata
self.scheduler.finish_requests("hit", DiffusionRequestStatus.FINISHED_ABORTED)
assert _cached_ids(self.scheduler.schedule()) == ["miss"]
assert manager.get_metadata("miss") is miss_metadata
self.scheduler.close()
@pytest.mark.parametrize(
"terminal_output",
[
pytest.param(
_make_request_output("publish-error", error="worker failed"),
id="error",
),
pytest.param(_make_aborted_request_output("publish-abort"), id="abort"),
],
)
def test_diffusion_kv_does_not_publish_failed_or_aborted_request(
self,
terminal_output: RunnerOutput,
) -> None:
_initialize_paged_scheduler(self.scheduler, enable_prefix_caching=True)
first = _make_request(terminal_output.request_id)
_attach_diffusion_kv(first, cache_token_ids=range(4))
self.scheduler.add_request(first)
first_schedule = self.scheduler.schedule()
assert self.scheduler.update_from_output(first_schedule, terminal_output) == {first.request_id}
warm = _make_request(f"{first.request_id}-warm")
_attach_diffusion_kv(warm, cache_token_ids=range(4))
self.scheduler.add_request(warm)
warm_schedule = self.scheduler.schedule()
metadata = warm_schedule.scheduled_new_reqs[0].diffusion_kv_metadata
assert metadata is not None
assert metadata.sequences[0].cached_prefix_len == 0
def test_diffusion_kv_capacity_backpressures_fifo_until_blocks_are_freed(self) -> None:
_initialize_paged_scheduler(self.scheduler, num_blocks=3, max_num_seqs=2)
first_request = _make_request("first")
+4 -6
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import copy
import gc
import importlib
import importlib.util
import json
import os
import tempfile
@@ -118,11 +118,9 @@ HUNYUAN_COT_REF_PATH = get_asset_path(f"{ASSET_SUBDIR}/{COT_REF_NAME}")
# Constants
# ============================================================================
# Test input
PROMPT = "基于图一的logo,参考图二中冰箱贴的材质,制作一个新的冰箱贴"
TEST_IMAGE_URLS = [
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_0.png",
"https://raw.githubusercontent.com/Tencent-Hunyuan/HunyuanImage-3.0/main/assets/demo_instruct_imgs/input_1_1.png",
]
_IT2I_INPUT = json.loads(get_asset_path("hunyuan_image3/it2i.jsonl").read_text(encoding="utf-8"))
PROMPT = _IT2I_INPUT["prompt"]
TEST_IMAGE_URLS = _IT2I_INPUT["image_files"]
SEED = 42
AR_TP_SIZE = len(AR_DEVICES.split(","))
DIT_TP_SIZE = len(DIT_DEVICES.split(","))
@@ -0,0 +1,201 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Single-DiT IT2I accuracy against the existing official Instruct reference.
Reuse the IT2I inputs and quality thresholds for cold, partial and repeated prefix
hits. The reference comes from AR->DiT; this is a reference-quality check, not a
claim of identical conditioning or bitwise equivalence between the pipelines.
"""
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
import pytest
from PIL import Image
from benchmarks.accuracy.common import VllmOmniImageClient
from tests.helpers.mark import hardware_test
from tests.helpers.media import get_asset_path
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion]
_REPO_ROOT = Path(__file__).resolve().parents[3]
_BENCHMARK_CONFIG = _REPO_ROOT / "tests/dfx/perf/tests/test_hunyuan_image3_prefix_caching.json"
_IT2I_INPUT = json.loads(get_asset_path("hunyuan_image3/it2i.jsonl").read_text(encoding="utf-8"))
_REFERENCE_PATH = get_asset_path("hunyuan_image3/hunyuan_image_instruct_ref.png")
_PROMPT = _IT2I_INPUT["prompt"]
# Warm up with a different prompt, then score only the original IT2I prompt.
# This exercises partial image-prefix hits without changing the golden's input.
_EDIT_PROMPT = (
f"请制作一张清晰的产品展示图:{_PROMPT}。保留标志的形状、文字与颜色,"
"呈现真实的材质纹理,使用柔和的自然光和简洁背景,不添加其他物体。"
)
# Match the official Instruct reference thresholds in test_hunyuan_image3.py.
_ACCURACY_THRESHOLDS = {"clip_score": 90.0, "ssim": 0.26, "psnr": 12.5}
_PREFILL_RE = re.compile(
r"Diffusion prefix prefill: request_id=(\S+) sequence_id=(\d+) "
r"cached_prefix_len=(\d+) prefix_len=(\d+) query_len=(\d+)"
)
_SLICE_RE = re.compile(r"Hunyuan prefix slice: cached_prefix_len=(\d+) query_len=(\d+)")
_REFERENCE_RE = re.compile(r"Hunyuan reference span: sequence_id=(\d+) start=(\d+) end=(\d+)")
@dataclass(frozen=True)
class PrefixPrefill:
request_id: str
sequence_id: int
cached_prefix_len: int
prefix_len: int
query_len: int
def _assert_prefix_execution(log: str) -> list[PrefixPrefill]:
events = [PrefixPrefill(request_id, *map(int, values)) for request_id, *values in _PREFILL_RE.findall(log)]
# TP ranks execute identical slices; compare sets rather than log counts.
expected = {(event.cached_prefix_len, event.query_len) for event in events if event.cached_prefix_len > 0}
actual = {tuple(map(int, match)) for match in _SLICE_RE.findall(log)}
assert actual == expected, f"Scheduled/model prefix slices differ: {expected=}, {actual=}"
if events:
assert len(events) == 2 and {event.sequence_id for event in events} == {0, 1}, events
assert len({event.request_id for event in events}) == 1, events
assert len({event.cached_prefix_len for event in events}) == 1, f"CFG boundaries differ: {events}"
return events
def _assert_reference_prefix_hit(events: list[PrefixPrefill], log: str) -> None:
# Require a hit into BOTH reference images, not only the system prompt.
spans = {tuple(map(int, match)) for match in _REFERENCE_RE.findall(log)}
for event in events:
row_spans = [(start, end) for row, start, end in spans if row == event.sequence_id]
assert len(row_spans) == len(_IT2I_INPUT["image_files"]), spans
assert all(0 <= start < end for start, end in row_spans), spans
assert max(start for start, _ in row_spans) < event.cached_prefix_len <= event.prefix_len, event
assert event.query_len > 0, event
def _generate_edit(client, *, model, images, prompt, output_path, capfd, **generation):
# Preserve startup/previous diagnostics and capture one sequential request.
before = capfd.readouterr()
output_path.with_suffix(".before.log").write_text(before.out + before.err)
try:
image = client.generate_image_edit(model=model, prompt=prompt, images=images, **generation)
assert image.size == (generation["width"], generation["height"]), image.size
image.save(output_path)
finally:
captured = capfd.readouterr()
output_path.with_suffix(".log").write_text(captured.out + captured.err)
log = captured.out + captured.err
return image, _assert_prefix_execution(log), log
@hardware_test(res={"cuda": "H100"}, num_cards=4)
@pytest.mark.parametrize("tp_size,sp_size", [(4, 1), (2, 2)], ids=["tp4-sp1", "tp2-sp2"])
def test_hunyuan_image3_warm_prefix_accuracy(tmp_path, capfd, tp_size, sp_size):
from tests.e2e.accuracy.helpers import CLIPScorer, compute_image_ssim_psnr, download_images, model_output_dir
from tests.helpers.runtime import OmniServer
configs = json.loads(_BENCHMARK_CONFIG.read_text())
model = os.environ.get("HUNYUAN_IMAGE3_MODEL", configs[0]["server_params"]["model"])
output_dir = model_output_dir(tmp_path, model)
images = download_images(_IT2I_INPUT["image_files"])
reference = Image.open(_REFERENCE_PATH).convert("RGB")
# Pin the official reference's settings independently of the shorter perf run.
generation = dict(
seed=42,
num_inference_steps=50,
guidance_scale=2.5,
width=1280,
height=720,
bot_task="think_recaption",
sys_type="en_unified",
)
results, traces = {}, {}
for config in configs:
mode = config["test_name"].removeprefix("test_hunyuan_image3_prefix_")
server_params = config["server_params"]
# Keep four workers; exercise trimmed queries with strict Ulysses,
# not the T2I/no-hit AllGather-KV path covered by other SP tests.
server_params["stage_overrides"]["0"]["parallel_config"].update(
tensor_parallel_size=tp_size,
sequence_parallel_size=sp_size,
ulysses_degree=sp_size,
ulysses_mode="strict",
ring_degree=1,
allgather_degree=1,
cfg_parallel_size=1,
)
server_args = [
"--deploy-config",
str(_REPO_ROOT / server_params["serve_args"]["deploy-config"]),
"--stage-overrides",
json.dumps(server_params["stage_overrides"]),
"--trust-remote-code",
"--stage-init-timeout",
"900",
]
requests = [("original", _PROMPT)]
if mode == "paged_prefix":
requests = [("warmup", _EDIT_PROMPT), ("partial", _PROMPT)]
requests += [(f"repeat{i}", _PROMPT) for i in range(1, 4)]
with OmniServer(
model,
server_args,
env_dict={
"VLLM_LOGGING_LEVEL": "DEBUG",
"DIFFUSION_ATTENTION_BACKEND": os.environ.get("DIFFUSION_ATTENTION_BACKEND", "FLASH_ATTN"),
},
) as server:
client = VllmOmniImageClient(f"http://{server.host}:{server.port}")
for case, prompt in requests:
label = f"{mode}_{case}"
image, events, log = _generate_edit(
client,
model=server.model,
images=images,
prompt=prompt,
output_path=output_dir / f"{label}.png",
capfd=capfd,
**generation,
)
if case != "warmup":
results[mode, case] = image
traces[label] = [vars(event) for event in events]
(output_dir / "prefix_traces.json").write_text(json.dumps(traces, indent=2))
if mode == "dense":
assert not events, events
continue
assert len(events) == 2, f"Missing CFG prefill traces: {events}"
if mode == "paged_no_cache" or case == "warmup":
assert all(event.cached_prefix_len == 0 for event in events), events
continue
_assert_reference_prefix_hit(events, log)
if case == "partial":
assert all(event.cached_prefix_len < event.prefix_len for event in events), events
else:
partial_len = traces["paged_prefix_partial"][0]["cached_prefix_len"]
assert events[0].cached_prefix_len > partial_len, events
if case != "repeat1":
first = traces["paged_prefix_repeat1"]
assert [event.cached_prefix_len for event in events] == [
event["cached_prefix_len"] for event in first
], events
assert image.tobytes() == results["paged_prefix", "repeat1"].tobytes(), label
# Score every mode against the official output, not an uncached Omni output.
clip_scorer = CLIPScorer()
metrics, failures = {}, []
for (mode, case), image in results.items():
ssim, psnr = compute_image_ssim_psnr(prediction=image, reference=reference)
label = f"{mode}_{case} vs official_ref"
metrics[label] = {"clip_score": clip_scorer.image_image_score(image, reference), "ssim": ssim, "psnr": psnr}
for name, threshold in _ACCURACY_THRESHOLDS.items():
value = metrics[label][name]
print(f"{label} {name}: {value:.6f}, required >= {threshold}")
if not value >= threshold:
failures.append(f"{label}: {name}={value:.6f} < {threshold}")
(output_dir / "quality_metrics.json").write_text(json.dumps(metrics, indent=2))
assert not failures, "\n".join(failures)