[Refactor][CI] Reorganize test tree, and move slow e2e to weekly (#4951)
Signed-off-by: wangyu <410167048@qq.com>
This commit is contained in:
1 parent
925347332f
commit
7c17111ff5
158 files changed
+3479
-4130
No files matched your search
@@ -0,0 +1 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
@@ -0,0 +1,317 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CPU contracts for capability-driven AR-Diffusion sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.experimental.ar_diffusion.capability import (
|
||||
ARDiffusionCrossAttentionKVSpec,
|
||||
ARDiffusionKVBranchSpec,
|
||||
ARDiffusionKVCacheSpec,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVConfig
|
||||
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
|
||||
|
||||
BLOCK = 16
|
||||
POS = "positive"
|
||||
NEG = "negative"
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def lingbot_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec:
|
||||
"""Single-kv_branch causal DMD: 3 latent frames/block + sink/window + text KV."""
|
||||
return ARDiffusionKVCacheSpec(
|
||||
num_layers=2,
|
||||
num_kv_heads=4, # already TP-local
|
||||
head_size=64,
|
||||
tokens_per_frame=BLOCK,
|
||||
frames_per_block=3,
|
||||
window_frames=5,
|
||||
sink_frames=1,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 0),),
|
||||
session_capacity=capacity,
|
||||
cross_attention=(ARDiffusionCrossAttentionKVSpec("text", 8),),
|
||||
)
|
||||
|
||||
|
||||
def dreamzero_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec:
|
||||
return ARDiffusionKVCacheSpec(
|
||||
num_layers=2,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
tokens_per_frame=BLOCK,
|
||||
frames_per_block=4,
|
||||
window_frames=6,
|
||||
kv_branches=(ARDiffusionKVBranchSpec(POS, 0), ARDiffusionKVBranchSpec(NEG, 1)),
|
||||
session_capacity=capacity,
|
||||
cross_attention=(ARDiffusionCrossAttentionKVSpec("text", 8),),
|
||||
)
|
||||
|
||||
|
||||
class CapablePipeline:
|
||||
def __init__(self, spec: ARDiffusionKVCacheSpec) -> None:
|
||||
self.spec = spec
|
||||
self.bound_state = None
|
||||
self.binds: list[str] = []
|
||||
self.resets: list[str] = []
|
||||
self.closes: list[str] = []
|
||||
|
||||
def ar_diffusion_kv_cache_spec(self) -> ARDiffusionKVCacheSpec:
|
||||
return self.spec
|
||||
|
||||
@contextmanager
|
||||
def bind_ar_diffusion_state(self, session_id: str, state):
|
||||
assert self.bound_state is None
|
||||
self.bound_state = state
|
||||
self.binds.append(session_id)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.bound_state = None
|
||||
|
||||
def reset_ar_diffusion_session(self, session_id: str) -> None:
|
||||
self.resets.append(session_id)
|
||||
|
||||
def close_ar_diffusion_session(self, session_id: str) -> None:
|
||||
self.closes.append(session_id)
|
||||
|
||||
|
||||
class WarmupPipeline(CapablePipeline):
|
||||
def __init__(self, spec: ARDiffusionKVCacheSpec, requests: list[object]) -> None:
|
||||
super().__init__(spec)
|
||||
self.requests = requests
|
||||
|
||||
def ar_diffusion_warmup_requests(self, session_id: str):
|
||||
assert session_id == ARDiffusionModelRunner._WARMUP_SID
|
||||
return iter(self.requests)
|
||||
|
||||
|
||||
class BatchCapablePipeline(CapablePipeline):
|
||||
supports_request_batch = True
|
||||
|
||||
|
||||
def make_runner(
|
||||
pipeline: object,
|
||||
*,
|
||||
available_bytes: int = 1 << 28,
|
||||
step_execution: bool = False,
|
||||
) -> ARDiffusionModelRunner:
|
||||
runner = object.__new__(ARDiffusionModelRunner)
|
||||
runner.od_config = SimpleNamespace(
|
||||
max_num_seqs=1,
|
||||
dtype=torch.float32,
|
||||
enforce_eager=True,
|
||||
step_execution=step_execution,
|
||||
)
|
||||
runner.device = torch.device("cpu")
|
||||
runner.pipeline = pipeline
|
||||
runner.ar_diffusion_kv_config = ARDiffusionKVConfig(enable=True)
|
||||
runner.kv_cache = None
|
||||
runner._ar_diffusion_capability = None
|
||||
runner._ar_diffusion_kv_cache_spec = None
|
||||
runner._sessions = OrderedDict()
|
||||
runner._session_capacity = 0
|
||||
runner._perf_e2e_times = []
|
||||
runner._preallocate_kv_cache(available_bytes=available_bytes)
|
||||
return runner
|
||||
|
||||
|
||||
def commit_one_frame(runner: ARDiffusionModelRunner, session_id: str, kv_branch: str):
|
||||
state = runner._get_or_create_session(session_id)
|
||||
ctx = state.get_kv_caches(kv_branch, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
state.commit_paged_context(kv_branch)
|
||||
return state
|
||||
|
||||
|
||||
def test_ar_runner_rejects_pipeline_without_capability():
|
||||
runner = object.__new__(ARDiffusionModelRunner)
|
||||
runner.od_config = SimpleNamespace(max_num_seqs=1)
|
||||
runner.pipeline = object()
|
||||
with pytest.raises(TypeError, match="SupportsARDiffusionPipeline"):
|
||||
runner._preallocate_kv_cache(available_bytes=1 << 20)
|
||||
|
||||
|
||||
def test_lingbot_like_single_branch_session_reuse_reset_and_close():
|
||||
pipeline = CapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline)
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
assert kv.num_local_kv_branches == 1
|
||||
assert kv.frames_per_block == 3
|
||||
assert kv.spec.window_chunks == 5
|
||||
assert kv.spec.sink_chunks == 1
|
||||
assert kv.cross_attention_lengths == {"text": 8}
|
||||
|
||||
first = commit_one_frame(runner, "s1", "main")
|
||||
assert runner._get_or_create_session("s1") is first
|
||||
k = torch.randn(1, 8, 4, 64)
|
||||
v = torch.randn(1, 8, 4, 64)
|
||||
first.populate_cross_attention("main", "text", [(k, v)] * first.num_layers)
|
||||
assert first.get_cross_attention_kv("main", "text")[0]["k"].shape == k.shape
|
||||
|
||||
runner.reset_session("s1")
|
||||
assert "s1" not in runner._sessions
|
||||
assert "s1" not in kv._cross_sessions
|
||||
assert pipeline.resets == ["s1"]
|
||||
second = runner._get_or_create_session("s1")
|
||||
assert second is not first
|
||||
|
||||
runner.close_session("s1")
|
||||
assert "s1" not in runner._sessions
|
||||
assert pipeline.closes == ["s1"]
|
||||
|
||||
|
||||
def test_lingbot_like_sink_survives_sliding_window_eviction():
|
||||
runner = make_runner(CapablePipeline(lingbot_like_spec()))
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
|
||||
for _ in range(8):
|
||||
state = commit_one_frame(runner, "s1", "main")
|
||||
|
||||
table = kv.block_table(state.adapter("main"))
|
||||
assert table[0] != kv.null_block_id
|
||||
assert table[1] == kv.null_block_id
|
||||
|
||||
ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=False)[0].forward_ctx
|
||||
visible, _ = ctx.video_block_table(torch.device("cpu"))
|
||||
assert visible[0] == table[0]
|
||||
assert len(visible) == 6 # sink + recent window, including current scratch
|
||||
|
||||
|
||||
def test_dreamzero_like_two_branches_are_independent():
|
||||
runner = make_runner(CapablePipeline(dreamzero_like_spec()))
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None and kv.num_local_kv_branches == 2
|
||||
state = commit_one_frame(runner, "s1", POS)
|
||||
assert len(kv.window_block_ids(state.adapter(POS))) == 1
|
||||
assert kv.window_block_ids(state.adapter(NEG)) == []
|
||||
|
||||
|
||||
def test_lru_eviction_releases_blocks_and_notifies_pipeline():
|
||||
pipeline = CapablePipeline(lingbot_like_spec(capacity=8))
|
||||
runner = make_runner(pipeline)
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
assert runner._session_capacity == 1
|
||||
assert kv.session_capacity == 1
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
old = commit_one_frame(runner, "old", "main")
|
||||
k = torch.randn(1, 8, 4, 64)
|
||||
old.populate_cross_attention("main", "text", [(k, k)] * old.num_layers)
|
||||
assert kv.manager.block_pool.get_num_free_blocks() < free_total
|
||||
|
||||
runner._get_or_create_session("new")
|
||||
|
||||
assert tuple(runner._sessions) == ("new",)
|
||||
assert pipeline.closes == ["old"]
|
||||
assert "old" not in kv._cross_sessions
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_forward_exception_releases_pending_allocation_and_model_state(monkeypatch):
|
||||
pipeline = CapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline)
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
def boom(self, req, kv_prefetch_job=None):
|
||||
state = pipeline.bound_state
|
||||
ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
raise RuntimeError("layer exploded")
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_model", boom)
|
||||
request = SimpleNamespace(sampling_params=SimpleNamespace(extra_args={"session_id": "broken"}))
|
||||
|
||||
with pytest.raises(RuntimeError, match="layer exploded"):
|
||||
runner.execute_model(request)
|
||||
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.closes == ["broken"]
|
||||
assert not runner._sessions
|
||||
assert not kv._adapters
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_synchronize_exception_uses_forward_cleanup_path(monkeypatch):
|
||||
pipeline = CapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline)
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
def return_after_allocation(self, req, kv_prefetch_job=None):
|
||||
state = pipeline.bound_state
|
||||
ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
return object()
|
||||
|
||||
def synchronize_boom(device):
|
||||
raise RuntimeError("asynchronous kernel failed")
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_model", return_after_allocation)
|
||||
monkeypatch.setattr(torch.accelerator, "synchronize", synchronize_boom)
|
||||
runner.device = torch.device("cuda")
|
||||
request = SimpleNamespace(sampling_params=SimpleNamespace(extra_args={"session_id": "broken"}))
|
||||
|
||||
with pytest.raises(RuntimeError, match="asynchronous kernel failed"):
|
||||
runner.execute_model(request)
|
||||
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.closes == ["broken"]
|
||||
assert not runner._sessions
|
||||
assert not kv._adapters
|
||||
assert not runner._perf_e2e_times
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_ar_runner_rejects_step_and_request_batch_modes():
|
||||
with pytest.raises(ValueError, match="step_execution=True"):
|
||||
make_runner(CapablePipeline(lingbot_like_spec()), step_execution=True)
|
||||
with pytest.raises(ValueError, match="request-batch execution"):
|
||||
make_runner(BatchCapablePipeline(lingbot_like_spec()))
|
||||
|
||||
|
||||
def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints():
|
||||
runner = object.__new__(ARDiffusionModelRunner)
|
||||
with pytest.raises(RuntimeError, match="request-batch execution"):
|
||||
runner.execute_model_batch(None, None)
|
||||
with pytest.raises(RuntimeError, match="step execution"):
|
||||
runner.execute_stepwise(None)
|
||||
|
||||
|
||||
def test_model_specific_warmup_provider_is_consumed(monkeypatch):
|
||||
requests = [object(), object()]
|
||||
pipeline = WarmupPipeline(lingbot_like_spec(), requests)
|
||||
runner = make_runner(pipeline)
|
||||
seen: list[object] = []
|
||||
monkeypatch.setattr(runner, "execute_model", seen.append)
|
||||
|
||||
runner._warmup_ar_rollout()
|
||||
|
||||
assert seen == requests
|
||||
assert pipeline.closes == [runner._WARMUP_SID]
|
||||
|
||||
|
||||
def test_pipeline_without_warmup_provider_is_safely_skipped(monkeypatch):
|
||||
pipeline = CapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline)
|
||||
execute = SimpleNamespace(called=False)
|
||||
|
||||
def fail_if_called(request):
|
||||
execute.called = True
|
||||
|
||||
monkeypatch.setattr(runner, "execute_model", fail_if_called)
|
||||
runner._warmup_ar_rollout()
|
||||
assert execute.called is False
|
||||
@@ -0,0 +1,152 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for the generic engine-backend dispatcher + AR-Diffusion runner wiring.
|
||||
|
||||
``DiffusionEngine.resolve_engine_class`` is a generic dispatcher (``"default"`` / a
|
||||
``DiffusionEngine`` subclass / an import-path string). DreamZero selects the
|
||||
AR-Diffusion engine via its deploy config's ``engine_backend`` qualname — no
|
||||
DreamZero/ar_diffusion knowledge lives in the public base, so the routing check
|
||||
here is simply that the ``ARDiffusionEngine`` qualname resolves correctly.
|
||||
"""
|
||||
|
||||
from dataclasses import fields
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from vllm.utils.import_utils import resolve_obj_by_qualname
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
|
||||
from vllm_omni.experimental.ar_diffusion.engine import (
|
||||
AR_DIFFUSION_MODEL_RUNNER_CLS,
|
||||
ARDiffusionEngine,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _cfg(backend):
|
||||
return SimpleNamespace(engine_backend=backend)
|
||||
|
||||
|
||||
def test_default_resolves_to_diffusion_engine():
|
||||
assert DiffusionEngine.resolve_engine_class(_cfg("default")) is DiffusionEngine
|
||||
|
||||
|
||||
def test_subclass_type_is_returned():
|
||||
assert DiffusionEngine.resolve_engine_class(_cfg(ARDiffusionEngine)) is ARDiffusionEngine
|
||||
|
||||
|
||||
def test_ar_diffusion_qualname_resolves():
|
||||
# How DreamZero's deploy config selects the engine: a full import-path string.
|
||||
cls = DiffusionEngine.resolve_engine_class(_cfg("vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"))
|
||||
assert cls is ARDiffusionEngine
|
||||
|
||||
|
||||
def test_non_engine_type_raises():
|
||||
with pytest.raises(TypeError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg(dict))
|
||||
|
||||
|
||||
def test_qualname_not_an_engine_raises():
|
||||
with pytest.raises(TypeError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg("builtins.dict"))
|
||||
|
||||
|
||||
def test_bad_qualname_raises():
|
||||
with pytest.raises(ValueError):
|
||||
DiffusionEngine.resolve_engine_class(_cfg("not.a.real.module.NoSuchEngine"))
|
||||
|
||||
|
||||
def test_ar_diffusion_engine_is_diffusion_engine_subclass():
|
||||
assert issubclass(ARDiffusionEngine, DiffusionEngine)
|
||||
|
||||
|
||||
def test_config_engine_backend_field_defaults_to_default():
|
||||
field = {f.name: f for f in fields(OmniDiffusionConfig)}["engine_backend"]
|
||||
assert field.default == "default"
|
||||
|
||||
|
||||
# --- AR-Diffusion worker/runner wiring -----------------------------------------------
|
||||
|
||||
|
||||
def test_ar_diffusion_model_runner_cls_resolves():
|
||||
cls = resolve_obj_by_qualname(AR_DIFFUSION_MODEL_RUNNER_CLS)
|
||||
assert cls.__name__ == "ARDiffusionModelRunner"
|
||||
|
||||
|
||||
def test_engine_class_declares_its_runner():
|
||||
"""Routing lives on the engine class (review: hsliuustc0106) — engines
|
||||
never mutate od_config; the worker resolves the runner off the class."""
|
||||
assert ARDiffusionEngine.default_diffusion_model_runner_cls == AR_DIFFUSION_MODEL_RUNNER_CLS
|
||||
assert DiffusionEngine.default_diffusion_model_runner_cls is None
|
||||
|
||||
|
||||
def _select_runner(od_config, platform_default="PLATFORM"):
|
||||
"""Mirror of the worker's runner-selection chain (override > engine > platform)."""
|
||||
runner_override = getattr(od_config, "diffusion_model_runner_cls", None)
|
||||
if isinstance(runner_override, str) and runner_override:
|
||||
return runner_override
|
||||
engine_cls = DiffusionEngine.resolve_engine_class(od_config)
|
||||
engine_runner = getattr(engine_cls, "default_diffusion_model_runner_cls", None)
|
||||
if isinstance(engine_runner, str) and engine_runner:
|
||||
return engine_runner
|
||||
return platform_default
|
||||
|
||||
|
||||
def test_runner_selection_chain():
|
||||
ar = "vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine"
|
||||
# engine-declared runner wins over platform default
|
||||
assert (
|
||||
_select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend=ar))
|
||||
== AR_DIFFUSION_MODEL_RUNNER_CLS
|
||||
)
|
||||
# explicit override wins over the engine's declaration
|
||||
assert (
|
||||
_select_runner(SimpleNamespace(diffusion_model_runner_cls="my.custom.Runner", engine_backend=ar))
|
||||
== "my.custom.Runner"
|
||||
)
|
||||
# default engine -> platform default
|
||||
assert _select_runner(SimpleNamespace(diffusion_model_runner_cls=None, engine_backend="default")) == "PLATFORM"
|
||||
|
||||
|
||||
def test_dreamzero_pipeline_rejects_non_ar_diffusion_engine():
|
||||
"""Review (hsliuustc0106): a stale config with engine_backend='default'
|
||||
must fail at init, not mid-forward on the first KV access."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
|
||||
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
|
||||
DreamZeroPipeline(od_config=SimpleNamespace(engine_backend="default"))
|
||||
with pytest.raises(ValueError, match="requires the AR-Diffusion engine"):
|
||||
DreamZeroPipeline(od_config=SimpleNamespace())
|
||||
|
||||
|
||||
def test_runner_rejects_multi_sequence_config():
|
||||
"""Review (hsliuustc0106): the paged path writes batch index 0 only, so
|
||||
max_num_seqs > 1 must be rejected at init."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
|
||||
|
||||
fake = SimpleNamespace(od_config=SimpleNamespace(max_num_seqs=4))
|
||||
with pytest.raises(ValueError, match="max_num_seqs=1"):
|
||||
ARDiffusionModelRunner._preallocate_kv_cache(fake)
|
||||
|
||||
|
||||
def test_execute_model_accepts_kv_prefetch_job_kwarg():
|
||||
"""Regression: the worker passes ``kv_prefetch_job`` to every runner
|
||||
(added with the KV prefetch path, #4448); the AR-Diffusion override must
|
||||
accept and forward it or DreamZero crashes on the warm-up dummy run."""
|
||||
import inspect
|
||||
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.experimental.ar_diffusion.runner import ARDiffusionModelRunner
|
||||
|
||||
for cls in (DiffusionModelRunner, ARDiffusionModelRunner):
|
||||
params = inspect.signature(cls.execute_model).parameters
|
||||
assert "kv_prefetch_job" in params, cls
|
||||
@@ -0,0 +1,305 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2).
|
||||
|
||||
Covers the request adapter, the chunk-window spec/manager (registration + the
|
||||
eviction policy), and the pool builder — exercised against the installed vLLM
|
||||
KV stack on CPU (block bookkeeping only, no GPU tensors).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.v1.kv_cache_interface import KVCacheSpecKind, get_kv_cache_spec_kind
|
||||
from vllm.v1.kv_cache_spec_registry import KVCacheSpecRegistry
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionKVCache,
|
||||
ARDiffusionKVConfig,
|
||||
ARDiffusionRequestAdapter,
|
||||
ChunkWindowManager,
|
||||
ChunkWindowSpec,
|
||||
allocate_kv_pool_with_views,
|
||||
build_kv_manager,
|
||||
compute_num_blocks,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
BLOCK = 16
|
||||
|
||||
|
||||
def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False):
|
||||
return ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=window_chunks * chunk_size,
|
||||
chunk_size=chunk_size,
|
||||
window_chunks=window_chunks,
|
||||
sink_chunks=sink_chunks,
|
||||
reset_at_boundary=reset_at_boundary,
|
||||
)
|
||||
|
||||
|
||||
# --- ChunkWindowSpec registration -------------------------------------------
|
||||
|
||||
|
||||
def test_spec_registration_resolves_to_chunk_window_manager():
|
||||
# Without explicit registration the MRO walk would fall back to the parent
|
||||
# SlidingWindowManager; assert the subclass manager wins.
|
||||
spec = make_spec()
|
||||
assert KVCacheSpecRegistry.get_manager_class(spec) is ChunkWindowManager
|
||||
|
||||
|
||||
def test_spec_kind_is_sliding_window():
|
||||
assert get_kv_cache_spec_kind(make_spec()) == KVCacheSpecKind.SLIDING_WINDOW
|
||||
|
||||
|
||||
def test_spec_rejects_inconsistent_window():
|
||||
with pytest.raises(ValueError):
|
||||
ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=99, # != window_chunks * chunk_size
|
||||
chunk_size=BLOCK,
|
||||
window_chunks=2,
|
||||
)
|
||||
|
||||
|
||||
# --- eviction policy (pure) -------------------------------------------------
|
||||
|
||||
|
||||
def test_sliding_replace_keeps_window():
|
||||
# window = 2 chunks * 16 = 32. Base sliding formula keeps `window` tokens;
|
||||
# the snap is to chunk boundaries.
|
||||
def skip(n):
|
||||
return chunk_window_skipped_tokens(n, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
|
||||
|
||||
assert skip(32) == 0 # nothing past the window yet
|
||||
assert skip(48) == 16 # one chunk fell out of the window
|
||||
assert skip(64) == 32
|
||||
|
||||
|
||||
def test_sliding_replace_snaps_to_chunk_boundary():
|
||||
# A non-chunk-aligned overflow must snap down so a chunk is never half-evicted.
|
||||
skip = chunk_window_skipped_tokens(50, chunk_size=16, sliding_window=32, sink_chunks=0, reset_at_boundary=False)
|
||||
assert skip % 16 == 0 and skip == 16
|
||||
|
||||
|
||||
def test_sink_chunks_protected():
|
||||
# sink = 1 chunk (16 tokens) is never skipped.
|
||||
skip = chunk_window_skipped_tokens(80, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=False)
|
||||
# raw overflow = 80 - 32 - 16 = 32 -> snapped 32
|
||||
assert skip == 32
|
||||
|
||||
|
||||
def test_reset_at_boundary_drops_completed_past_sink():
|
||||
skip = chunk_window_skipped_tokens(48, chunk_size=16, sliding_window=32, sink_chunks=1, reset_at_boundary=True)
|
||||
# completed = 48; sink = 16 -> drop 32
|
||||
assert skip == 32
|
||||
|
||||
|
||||
# --- ARDiffusionKVConfig ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_kv_config_sliding_window_property():
|
||||
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=3).sliding_window == 48
|
||||
assert ARDiffusionKVConfig(chunk_size=16, window_chunks=None).sliding_window is None
|
||||
|
||||
|
||||
# --- ARDiffusionRequestAdapter ------------------------------------------------------
|
||||
|
||||
|
||||
def test_adapter_advances_per_chunk_not_per_step():
|
||||
a = ARDiffusionRequestAdapter("r0", chunk_size=16)
|
||||
assert a.num_computed_tokens == 0
|
||||
assert a.num_tokens == 16 # in-flight chunk
|
||||
a.on_chunk_committed()
|
||||
assert a.num_computed_tokens == 16
|
||||
assert a.num_tokens == 32
|
||||
|
||||
|
||||
def test_adapter_accounts_for_prefill_prefix():
|
||||
a = ARDiffusionRequestAdapter("r0", chunk_size=16, prefill_prefix_tokens=4)
|
||||
assert a.num_computed_tokens == 4
|
||||
assert a.num_prompt_tokens == 4
|
||||
assert a.num_tokens == 20
|
||||
|
||||
|
||||
def test_adapter_status_is_vllm_enum():
|
||||
assert isinstance(ARDiffusionRequestAdapter("r0", chunk_size=16).status, RequestStatus)
|
||||
|
||||
|
||||
# --- pool / manager ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_compute_num_blocks():
|
||||
# 1 MiB budget at fraction 0.5 with 16 KiB pages -> 32 blocks.
|
||||
assert compute_num_blocks(1 << 20, 0.5, 16 << 10) == 32
|
||||
|
||||
|
||||
def test_paged_pool_layout_exposes_flat_slot_views():
|
||||
kv_pools, k_pools, v_pools = allocate_kv_pool_with_views(
|
||||
num_blocks=4,
|
||||
block_size=BLOCK,
|
||||
num_layers=1,
|
||||
num_kv_heads=4,
|
||||
head_dim=64,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64)
|
||||
assert k_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
assert v_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
|
||||
k_pools[0][BLOCK + 3].fill_(7)
|
||||
v_pools[0][2 * BLOCK + 5].fill_(11)
|
||||
assert torch.equal(kv_pools[0][0, 1, 3], k_pools[0][BLOCK + 3])
|
||||
assert torch.equal(kv_pools[0][1, 2, 5], v_pools[0][2 * BLOCK + 5])
|
||||
|
||||
|
||||
def test_build_manager_allocate_free_roundtrip():
|
||||
"""End-to-end: a ARDiffusionRequestAdapter drives a real KVCacheManager.
|
||||
|
||||
This is the adapter conformance check — if the adapter were missing an
|
||||
attribute the manager reads, allocate_slots/free would raise here.
|
||||
"""
|
||||
spec = make_spec()
|
||||
mgr = build_kv_manager(spec, ["layer0"], num_blocks=16, max_model_len=1024)
|
||||
free_before = mgr.block_pool.get_num_free_blocks()
|
||||
|
||||
adapter = ARDiffusionRequestAdapter("req-0", chunk_size=BLOCK)
|
||||
blocks = mgr.allocate_slots(adapter, num_new_tokens=BLOCK, full_sequence_must_fit=True)
|
||||
assert blocks is not None
|
||||
assert mgr.block_pool.get_num_free_blocks() < free_before
|
||||
|
||||
mgr.free(adapter)
|
||||
assert mgr.block_pool.get_num_free_blocks() == free_before
|
||||
|
||||
|
||||
def test_cross_attn_pool_deducted_from_self_attn_budget():
|
||||
"""The cross-attn pool is allocated directly; its bytes are subtracted from
|
||||
the self-attn paged-pool budget so the two together stay within the free
|
||||
memory budget (review: zwhzzz0821)."""
|
||||
L = 512
|
||||
avail = 1 << 30 # 1 GiB
|
||||
kv = ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2, gpu_memory_fraction=0.5),
|
||||
num_layers=2,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=avail,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 0)),
|
||||
session_capacity=1,
|
||||
cross_attention_lengths={"text": L},
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
cross_bytes = 2 * 2 * L * 4 * 64 * torch.float16.itemsize * 2 # K+V, pos+neg, layers
|
||||
expected = compute_num_blocks(avail - cross_bytes, 0.5, kv.spec.page_size_bytes * 2)
|
||||
assert kv.num_blocks == expected
|
||||
assert expected > 1 * (2 + 1) + 2 # above the local-slot floor, so the cross deduction is what's tested
|
||||
|
||||
|
||||
def _make_kv(
|
||||
*,
|
||||
local_branches,
|
||||
num_frame_per_block=2,
|
||||
window_chunks=9,
|
||||
max_scratch_tokens_per_branch=0,
|
||||
):
|
||||
kv_branches = (
|
||||
(ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 0))
|
||||
if local_branches == 1
|
||||
else (ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 1))
|
||||
)
|
||||
return ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks),
|
||||
num_layers=1,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float32,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 16, # tiny -> the floor binds
|
||||
device=torch.device("cpu"),
|
||||
kv_branches=kv_branches,
|
||||
session_capacity=1,
|
||||
frames_per_block=num_frame_per_block,
|
||||
max_scratch_tokens_per_branch=max_scratch_tokens_per_branch,
|
||||
)
|
||||
|
||||
|
||||
def test_pool_floor_is_branch_aware():
|
||||
"""CFG-parallel rank (one local kv_branch) sizes for one window + in-flight chunk;
|
||||
a single-process run (both kv_branches) sizes for two. Scratch scales the same way."""
|
||||
one = _make_kv(local_branches=1)
|
||||
two = _make_kv(local_branches=2)
|
||||
|
||||
assert one.managed_num_blocks == 1 * (9 + 2) + 2 # 13
|
||||
assert two.managed_num_blocks == 2 * (9 + 2) + 2 # 24
|
||||
assert one.scratch_num_blocks == one.scratch_blocks_per_kv_branch
|
||||
assert two.scratch_num_blocks == 2 * two.scratch_blocks_per_kv_branch
|
||||
assert one.scratch_blocks_per_kv_branch == 2
|
||||
assert one.num_blocks_total == 13 + one.scratch_blocks_per_kv_branch
|
||||
|
||||
|
||||
def test_scratch_capacity_is_derived_from_declared_geometry(monkeypatch):
|
||||
monkeypatch.delenv("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", raising=False)
|
||||
kv = _make_kv(
|
||||
local_branches=1,
|
||||
num_frame_per_block=6,
|
||||
max_scratch_tokens_per_branch=BLOCK + 1,
|
||||
)
|
||||
assert kv.scratch_blocks_per_kv_branch == 8 # six video blocks + two auxiliary blocks
|
||||
|
||||
|
||||
def test_scratch_env_override_cannot_reduce_declared_minimum(monkeypatch):
|
||||
monkeypatch.setenv("AR_DIFFUSION_KV_SCRATCH_BLOCKS_PER_BRANCH", "1")
|
||||
kv = _make_kv(
|
||||
local_branches=1,
|
||||
num_frame_per_block=6,
|
||||
max_scratch_tokens_per_branch=BLOCK + 1,
|
||||
)
|
||||
assert kv.scratch_blocks_per_kv_branch == 8
|
||||
|
||||
|
||||
def test_scratch_maps_to_slot_zero_with_one_local_branch():
|
||||
"""A CFG-parallel rank runs exactly one kv_branch: whichever CFG side it is,
|
||||
its scratch lands in the rank's single slot (no dead second slot)."""
|
||||
one = _make_kv(local_branches=1)
|
||||
assert one.scratch_block_ids("positive", 0, 2) == one.scratch_block_ids("negative", 0, 2)
|
||||
|
||||
two = _make_kv(local_branches=2)
|
||||
assert two.scratch_block_ids("positive", 0, 2) != two.scratch_block_ids("negative", 0, 2)
|
||||
|
||||
|
||||
def test_scratch_exhaustion_still_raises():
|
||||
one = _make_kv(local_branches=1)
|
||||
cap = one.scratch_blocks_per_kv_branch
|
||||
with pytest.raises(RuntimeError, match="scratch blocks exhausted"):
|
||||
one.scratch_block_ids("positive", 0, cap + 1)
|
||||
|
||||
|
||||
def test_non_contiguous_branch_indices_rejected():
|
||||
with pytest.raises(ValueError, match="contiguous"):
|
||||
ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=2),
|
||||
num_layers=1,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float32,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 16,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 1),),
|
||||
session_capacity=1,
|
||||
)
|
||||
@@ -0,0 +1,108 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for ARDiffusionKVCache — the engine-level KV orchestration body (Phase 1)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
DIMS = dict(
|
||||
num_layers=2,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
block_size=16,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("positive", 0), ARDiffusionKVBranchSpec("negative", 1)),
|
||||
session_capacity=2,
|
||||
)
|
||||
|
||||
|
||||
def make_cache(*, chunk_size=16, window_chunks=2, available_bytes=1 << 24):
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=chunk_size, window_chunks=window_chunks)
|
||||
return ARDiffusionKVCache(cfg, max_model_len=4096, available_bytes=available_bytes, **DIMS)
|
||||
|
||||
|
||||
def test_requires_enabled_config():
|
||||
with pytest.raises(ValueError):
|
||||
ARDiffusionKVCache(ARDiffusionKVConfig(enable=False), max_model_len=256, available_bytes=1 << 20, **DIMS)
|
||||
|
||||
|
||||
def test_requires_bounded_window():
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=16, window_chunks=None)
|
||||
with pytest.raises(ValueError):
|
||||
ARDiffusionKVCache(cfg, max_model_len=256, available_bytes=1 << 20, **DIMS)
|
||||
|
||||
|
||||
def test_full_request_lifecycle_and_eviction():
|
||||
"""begin -> per-chunk allocate/slots/commit over a long rollout -> free.
|
||||
|
||||
Exercises the orchestrator end-to-end and asserts the chunk window bounds
|
||||
memory (pool plateaus) and frees cleanly.
|
||||
"""
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
adapter = kv.begin_request("req-0")
|
||||
free_after = []
|
||||
for k in range(10):
|
||||
block_table = kv.allocate_chunk(adapter)
|
||||
slots = kv.chunk_write_slots(adapter)
|
||||
# Slots target real blocks in this chunk's table; length == chunk_size.
|
||||
assert len(slots) == kv.spec.chunk_size
|
||||
used = {int(s) // kv.block_size for s in slots}
|
||||
assert kv.null_block_id not in used
|
||||
assert used <= set(block_table)
|
||||
# Resident window stays bounded by window + the in-flight chunk.
|
||||
assert len(kv.window_block_ids(adapter)) <= kv.spec.window_chunks + 1
|
||||
free_after.append(kv.manager.block_pool.get_num_free_blocks())
|
||||
kv.commit_chunk(adapter)
|
||||
|
||||
# Pool memory plateaus once the window is full (eviction recycles blocks).
|
||||
assert free_after[-1] == free_after[kv.spec.window_chunks]
|
||||
|
||||
kv.end_request(adapter)
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_num_computed_advances_per_chunk():
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
a = kv.begin_request("r")
|
||||
assert a.num_computed_tokens == 0
|
||||
kv.allocate_chunk(a)
|
||||
kv.commit_chunk(a)
|
||||
assert a.num_computed_tokens == 16
|
||||
kv.end_request(a)
|
||||
|
||||
|
||||
def test_state_close_frees_both_branch_blocks():
|
||||
"""ARDiffusionKVState.close() returns both CFG kv_branches' pool blocks to the pool.
|
||||
|
||||
This is the primitive the runner's LRU eviction relies on: when a session is
|
||||
evicted, close() must free the blocks both adapters hold, or session churn
|
||||
leaks pool ownership (review P1).
|
||||
"""
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
kv = make_cache(chunk_size=16, window_chunks=2)
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
pos = kv.begin_request("bde__s")
|
||||
neg = kv.begin_request("bde__s__neg")
|
||||
state = ARDiffusionKVState(
|
||||
kv,
|
||||
"s",
|
||||
{"positive": pos, "negative": neg},
|
||||
num_layers=kv.num_layers,
|
||||
)
|
||||
for _ in range(3):
|
||||
for adapter in (pos, neg):
|
||||
kv.allocate_chunk(adapter)
|
||||
kv.commit_chunk(adapter)
|
||||
# Both kv_branches hold resident blocks now.
|
||||
assert kv.manager.block_pool.get_num_free_blocks() < free_total
|
||||
|
||||
state.close()
|
||||
# All blocks returned — no leak across an evicted session.
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
@@ -0,0 +1,409 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for AR-Diffusion paged self-attention contexts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionKVCache,
|
||||
ARDiffusionKVConfig,
|
||||
ARDiffusionPagedLayerContext,
|
||||
ARDiffusionPagedLayerInputs,
|
||||
ar_diffusion_paged_attention,
|
||||
paged_write_attn,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
BLOCK = 16
|
||||
N_HEADS = 4
|
||||
HEAD_DIM = 64
|
||||
POS = "positive"
|
||||
NEG = "negative"
|
||||
|
||||
|
||||
def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu")):
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks)
|
||||
kv = ARDiffusionKVCache(
|
||||
cfg,
|
||||
num_layers=num_layers,
|
||||
num_kv_heads=N_HEADS,
|
||||
head_size=HEAD_DIM,
|
||||
dtype=dtype,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 26,
|
||||
kv_branches=(ARDiffusionKVBranchSpec(POS, 0), ARDiffusionKVBranchSpec(NEG, 1)),
|
||||
session_capacity=2,
|
||||
frames_per_block=2,
|
||||
max_scratch_tokens_per_branch=BLOCK,
|
||||
device=device,
|
||||
)
|
||||
pos = kv.begin_request("r-pos")
|
||||
neg = kv.begin_request("r-neg")
|
||||
return kv, ARDiffusionKVState(kv, "s1", {POS: pos, NEG: neg}, num_layers=num_layers)
|
||||
|
||||
|
||||
def _dense_attention(query: torch.Tensor, key: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
|
||||
scores = torch.einsum("bqhd,bkhd->bhqk", query.float(), key.float()) * (HEAD_DIM**-0.5)
|
||||
probs = torch.softmax(scores, dim=-1).to(value.dtype)
|
||||
return torch.einsum("bhqk,bkhd->bqhd", probs, value)
|
||||
|
||||
|
||||
def _cuda_flash_attn_usable() -> bool:
|
||||
if not torch.cuda.is_available():
|
||||
return False
|
||||
try:
|
||||
spec = find_spec("vllm.vllm_flash_attn")
|
||||
if spec is None or spec.origin is None:
|
||||
return True
|
||||
fa2_so = Path(spec.origin).parent / "_vllm_fa2_C.abi3.so"
|
||||
linked = subprocess.check_output(["ldd", str(fa2_so)], text=True, timeout=5)
|
||||
except Exception:
|
||||
return True
|
||||
if "libcudart.so.13" not in linked:
|
||||
return True
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["nvidia-smi", "--query-gpu=driver_version", "--format=csv,noheader"],
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
driver_major = int(out.splitlines()[0].split(".")[0])
|
||||
except Exception:
|
||||
return True
|
||||
return driver_major >= 580
|
||||
|
||||
|
||||
def _commit_video_span(
|
||||
kv: ARDiffusionKVCache,
|
||||
st: ARDiffusionKVState,
|
||||
*,
|
||||
kv_branch: str,
|
||||
n_chunks: int,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
ctx = st.get_kv_caches(kv_branch, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx.ensure_video_slots(device)
|
||||
k = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
v = torch.randn(1, n_chunks * BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
kv._k_pools[0][ctx.current_video_slot_mapping] = k[0]
|
||||
kv._v_pools[0][ctx.current_video_slot_mapping] = v[0]
|
||||
st.commit_paged_context(kv_branch)
|
||||
return k, v
|
||||
|
||||
|
||||
def test_paged_context_allocates_lazily_and_commits_after_forward():
|
||||
_, st = make_state()
|
||||
|
||||
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)
|
||||
ctx = contexts[0].forward_ctx
|
||||
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert ctx.current_video_slot_mapping is None
|
||||
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert len(ctx.current_video_block_ids) == 1
|
||||
|
||||
st.commit_paged_context(POS)
|
||||
assert st.adapter(POS).completed_chunks == 1
|
||||
assert st._committed[POS] == BLOCK
|
||||
|
||||
|
||||
def test_scratch_video_and_action_blocks_do_not_commit():
|
||||
kv, st = make_state()
|
||||
|
||||
ctx = st.get_kv_caches(POS, seq_len=2 * BLOCK, commit_current=False)[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
ctx.ensure_action_slots(3, torch.device("cpu"))
|
||||
|
||||
assert ctx.current_video_block_ids == kv.scratch_block_ids(POS, 0, 2)
|
||||
assert ctx.action_scratch_block_ids == kv.scratch_block_ids(POS, 2, 1)
|
||||
st.commit_paged_context(POS)
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert st._committed[POS] == 0
|
||||
|
||||
|
||||
def test_pipeline_kv_get_paged_path_has_no_gather_backend():
|
||||
kv, st = make_state()
|
||||
assert not hasattr(kv, "gather_window_all_layers")
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
|
||||
pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipeline._ar_diffusion_kv_state = st
|
||||
contexts = pipeline._kv_get(MagicMock(), False, seq_len=BLOCK, update_kv_cache=False)
|
||||
|
||||
assert len(contexts) == 1
|
||||
assert isinstance(contexts[0], ARDiffusionPagedLayerContext)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_chunks", [0, 1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
@pytest.mark.parametrize("commit_current", [False, True])
|
||||
def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len, commit_current):
|
||||
torch.manual_seed(0)
|
||||
device = torch.device("cpu")
|
||||
dtype = torch.float32
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
|
||||
|
||||
history_k_parts: list[torch.Tensor] = []
|
||||
history_v_parts: list[torch.Tensor] = []
|
||||
if history_chunks:
|
||||
k, v = _commit_video_span(
|
||||
kv,
|
||||
st,
|
||||
kv_branch=POS,
|
||||
n_chunks=history_chunks,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
history_k_parts.append(k)
|
||||
history_v_parts.append(v)
|
||||
|
||||
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit_current)[0].forward_ctx
|
||||
ctx.ensure_video_slots(device)
|
||||
current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
kv._k_pools[0][ctx.current_video_slot_mapping] = current_k[0]
|
||||
kv._v_pools[0][ctx.current_video_slot_mapping] = current_v[0]
|
||||
|
||||
action_k = action_v = None
|
||||
if action_len:
|
||||
ctx.ensure_action_slots(action_len, device)
|
||||
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
kv._k_pools[0][ctx.action_slot_mapping] = action_k[0]
|
||||
kv._v_pools[0][ctx.action_slot_mapping] = action_v[0]
|
||||
|
||||
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
block_table, query_start_loc, seq_lens, max_query_len, max_seq_len = ctx.build_block_table(
|
||||
action_len=action_len,
|
||||
query_len=query.shape[1],
|
||||
device=device,
|
||||
)
|
||||
paged = ar_diffusion_paged_attention(
|
||||
query,
|
||||
kv.key_cache(0),
|
||||
kv.value_cache(0),
|
||||
block_table=block_table,
|
||||
query_start_loc=query_start_loc,
|
||||
seq_lens=seq_lens,
|
||||
max_query_len=max_query_len,
|
||||
max_seq_len=max_seq_len,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
causal=False,
|
||||
)
|
||||
|
||||
if history_k_parts:
|
||||
history_k = torch.cat(history_k_parts, dim=1)
|
||||
history_v = torch.cat(history_v_parts, dim=1)
|
||||
else:
|
||||
history_k = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
history_v = torch.empty(1, 0, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
|
||||
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
|
||||
if action_len:
|
||||
new_k = torch.cat([new_k, action_k], dim=1)
|
||||
new_v = torch.cat([new_v, action_v], dim=1)
|
||||
ref = _dense_attention(query, new_k, new_v)
|
||||
|
||||
torch.testing.assert_close(paged, ref, rtol=1e-5, atol=1e-5)
|
||||
|
||||
before = st.adapter(POS).completed_chunks
|
||||
st.commit_paged_context(POS)
|
||||
assert st.adapter(POS).completed_chunks == before + (1 if commit_current else 0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _cuda_flash_attn_usable(), reason="usable CUDA FlashAttention is required")
|
||||
@pytest.mark.parametrize("history_chunks", [1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
@pytest.mark.parametrize("commit_current", [False, True])
|
||||
def test_paged_attention_matches_dense_reference_gpu(history_chunks, action_len, commit_current):
|
||||
pytest.importorskip("vllm.vllm_flash_attn")
|
||||
torch.manual_seed(0)
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.float16
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
|
||||
|
||||
history_k, history_v = _commit_video_span(
|
||||
kv,
|
||||
st,
|
||||
kv_branch=POS,
|
||||
n_chunks=history_chunks,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit_current)[0]
|
||||
ctx = layer_ctx.forward_ctx
|
||||
current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_k = action_v = None
|
||||
if action_len:
|
||||
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
|
||||
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
|
||||
# The production path: once-per-forward host prep, then the fused
|
||||
# write+attend custom op consuming the NamedTuple payload.
|
||||
ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1])
|
||||
inputs = layer_ctx.to_layer_inputs()
|
||||
assert isinstance(inputs, ARDiffusionPagedLayerInputs)
|
||||
paged = paged_write_attn(
|
||||
inputs,
|
||||
query[0],
|
||||
current_k[0],
|
||||
current_v[0],
|
||||
action_k[0] if action_len else None,
|
||||
action_v[0] if action_len else None,
|
||||
HEAD_DIM**-0.5,
|
||||
).unsqueeze(0)
|
||||
|
||||
# Direct python-fn call on the same (already written) pools must be
|
||||
# bit-exact: identical kernel, identical inputs.
|
||||
direct = ar_diffusion_paged_attention(
|
||||
query,
|
||||
kv.key_cache(0),
|
||||
kv.value_cache(0),
|
||||
block_table=ctx.block_table,
|
||||
query_start_loc=ctx.query_start_loc,
|
||||
seq_lens=ctx.seq_lens,
|
||||
max_query_len=ctx.max_query_len,
|
||||
max_seq_len=ctx.max_seq_len,
|
||||
softmax_scale=HEAD_DIM**-0.5,
|
||||
causal=False,
|
||||
)
|
||||
assert torch.equal(paged, direct)
|
||||
|
||||
new_k = torch.cat([history_k, current_k], dim=1)[:, -kv.spec.sliding_window :]
|
||||
new_v = torch.cat([history_v, current_v], dim=1)[:, -kv.spec.sliding_window :]
|
||||
if action_len:
|
||||
new_k = torch.cat([new_k, action_k], dim=1)
|
||||
new_v = torch.cat([new_v, action_v], dim=1)
|
||||
ref = _dense_attention(query, new_k, new_v)
|
||||
|
||||
torch.testing.assert_close(paged, ref, rtol=2e-2, atol=2e-2)
|
||||
|
||||
|
||||
def test_block_table_padded_to_fixed_width():
|
||||
"""Shapes must be constant across window growth: only values change."""
|
||||
device = torch.device("cpu")
|
||||
kv, st = make_state(window_chunks=2)
|
||||
|
||||
ctx1 = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx1.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
st.commit_paged_context(POS)
|
||||
|
||||
ctx2 = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx2.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
st.commit_paged_context(POS)
|
||||
|
||||
# 1-block vs 2-block visible history: same table width, same max_seq_len.
|
||||
assert ctx1.block_table.shape == ctx2.block_table.shape
|
||||
assert ctx1.max_seq_len == ctx2.max_seq_len
|
||||
expected_width = kv.spec.sliding_window // kv.block_size + 1
|
||||
assert ctx1.block_table.shape == (1, expected_width)
|
||||
# Real lengths live in seq_lens, not the padded table.
|
||||
assert int(ctx1.seq_lens[0]) == BLOCK
|
||||
assert int(ctx2.seq_lens[0]) == 2 * BLOCK
|
||||
|
||||
|
||||
def test_prepare_is_idempotent_and_layers_share_metadata():
|
||||
device = torch.device("cpu")
|
||||
_, st = make_state(num_layers=2)
|
||||
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)
|
||||
fctx = contexts[0].forward_ctx
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
table = fctx.block_table
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
assert fctx.block_table is table # memoized, not rebuilt
|
||||
|
||||
i0, i1 = contexts[0].to_layer_inputs(), contexts[1].to_layer_inputs()
|
||||
# 0-dim tensors (NOT python ints) so dynamo doesn't install per-layer
|
||||
# value guards on the shared block code object.
|
||||
assert isinstance(i0.layer_idx, torch.Tensor) and int(i0.layer_idx) == 0
|
||||
assert isinstance(i1.layer_idx, torch.Tensor) and int(i1.layer_idx) == 1
|
||||
# All layers share the same metadata tensor objects.
|
||||
assert i0.block_table is i1.block_table
|
||||
assert i0.seq_lens is i1.seq_lens
|
||||
assert i0.video_slots is i1.video_slots
|
||||
|
||||
|
||||
def test_layer_inputs_before_prepare_raises():
|
||||
_, st = make_state()
|
||||
layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0]
|
||||
with pytest.raises(RuntimeError, match="before prepare"):
|
||||
layer_ctx.to_layer_inputs()
|
||||
|
||||
|
||||
def test_custom_op_registration_idempotent():
|
||||
import importlib
|
||||
import sys
|
||||
|
||||
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
|
||||
mod = "vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention"
|
||||
saved = sys.modules.pop(mod)
|
||||
try:
|
||||
importlib.import_module(mod) # re-registration must not raise
|
||||
finally:
|
||||
sys.modules[mod] = saved
|
||||
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
|
||||
|
||||
|
||||
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
|
||||
"""The op must trace as one opaque node: fullgraph OK, and changed tensor
|
||||
VALUES (new slots / block ids) must not trigger recompilation."""
|
||||
import torch._dynamo
|
||||
|
||||
device = torch.device("cpu")
|
||||
kv, st = make_state(num_layers=2, window_chunks=2)
|
||||
|
||||
def run_one_forward(commit):
|
||||
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit)
|
||||
fctx = contexts[0].forward_ctx
|
||||
fctx.prepare(device=device, action_len=0, query_len=BLOCK)
|
||||
q = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
k = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
v = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
# Both layers through ONE compiled fn: layer_idx is a tensor, so a
|
||||
# different layer must NOT recompile (all 40 DiT blocks share the
|
||||
# block-forward code object in production).
|
||||
for layer_ctx in contexts:
|
||||
out = compiled(layer_ctx.to_layer_inputs(), q, k, v)
|
||||
st.commit_paged_context(POS)
|
||||
return out
|
||||
|
||||
torch._dynamo.reset()
|
||||
try:
|
||||
from torch._dynamo.testing import CompileCounter
|
||||
|
||||
counter = CompileCounter()
|
||||
|
||||
def fn(inputs, q, k, v):
|
||||
return paged_write_attn(inputs, q, k, v, None, None, HEAD_DIM**-0.5) * 1.0
|
||||
|
||||
compiled = torch.compile(fn, backend=counter, fullgraph=True)
|
||||
|
||||
run_one_forward(commit=True) # history grows between calls ->
|
||||
run_one_forward(commit=True) # block-table VALUES change, shapes don't
|
||||
run_one_forward(commit=False)
|
||||
|
||||
assert counter.frame_count == 1, f"recompiled: frame_count={counter.frame_count}"
|
||||
finally:
|
||||
# Leave a clean dynamo state for later suites in the same pytest process
|
||||
# (e.g. model_executor transformers models).
|
||||
torch._dynamo.reset()
|
||||
@@ -0,0 +1,280 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Tests for the ARDiffusionKVState paged-attention pipeline bridge."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionKVCache,
|
||||
ARDiffusionKVConfig,
|
||||
ARDiffusionPagedLayerContext,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
BLOCK = 16
|
||||
N_HEADS = 4
|
||||
HEAD_DIM = 64
|
||||
POS = "positive"
|
||||
NEG = "negative"
|
||||
|
||||
|
||||
def make_state(num_layers=1, window_chunks=4, cross_attn_length=0, shared_local_index=False):
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks)
|
||||
kv = ARDiffusionKVCache(
|
||||
cfg,
|
||||
num_layers=num_layers,
|
||||
num_kv_heads=N_HEADS,
|
||||
head_size=HEAD_DIM,
|
||||
dtype=torch.float32,
|
||||
block_size=BLOCK,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 24,
|
||||
kv_branches=(
|
||||
ARDiffusionKVBranchSpec(POS, 0),
|
||||
ARDiffusionKVBranchSpec(NEG, 0 if shared_local_index else 1),
|
||||
),
|
||||
session_capacity=2,
|
||||
cross_attention_lengths={"text": cross_attn_length} if cross_attn_length else None,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
pos = kv.begin_request("r-pos")
|
||||
neg = kv.begin_request("r-neg")
|
||||
return kv, ARDiffusionKVState(kv, "s1", {POS: pos, NEG: neg}, num_layers=num_layers)
|
||||
|
||||
|
||||
def _prepare_and_commit(st: ARDiffusionKVState, kv_branch: str, n_chunks: int) -> None:
|
||||
ctx = st.get_kv_caches(kv_branch, seq_len=n_chunks * BLOCK, commit_current=True)[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
st.commit_paged_context(kv_branch)
|
||||
|
||||
|
||||
def test_get_returns_paged_layer_contexts_when_nothing_committed():
|
||||
_, st = make_state(num_layers=2)
|
||||
for kv_branch in (POS, NEG):
|
||||
contexts = st.get_kv_caches(kv_branch, seq_len=BLOCK, commit_current=False)
|
||||
assert len(contexts) == 2
|
||||
assert all(isinstance(ctx, ARDiffusionPagedLayerContext) for ctx in contexts)
|
||||
assert contexts[0].history_block_ids == []
|
||||
|
||||
|
||||
def test_managed_paged_context_commits_only_after_forward():
|
||||
kv, st = make_state(num_layers=1)
|
||||
contexts = st.get_kv_caches(POS, seq_len=2 * BLOCK, commit_current=True)
|
||||
ctx = contexts[0].forward_ctx
|
||||
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert kv.window_block_ids(st.adapter(POS)) == []
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert len(ctx.current_video_block_ids) == 2
|
||||
|
||||
st.commit_paged_context(POS)
|
||||
|
||||
assert st.adapter(POS).completed_chunks == 2
|
||||
assert st._committed[POS] == 2 * BLOCK
|
||||
assert len(kv.window_block_ids(st.adapter(POS))) == 2
|
||||
|
||||
|
||||
def test_scratch_paged_context_does_not_commit():
|
||||
kv, st = make_state(num_layers=1)
|
||||
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)
|
||||
ctx = contexts[0].forward_ctx
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
|
||||
assert ctx.current_video_block_ids == kv.scratch_block_ids(POS, 0, 1)
|
||||
st.commit_paged_context(POS)
|
||||
|
||||
assert st.adapter(POS).completed_chunks == 0
|
||||
assert st._committed[POS] == 0
|
||||
assert kv.window_block_ids(st.adapter(POS)) == []
|
||||
|
||||
|
||||
def test_eviction_bounds_resident_window():
|
||||
kv, st = make_state(num_layers=1, window_chunks=3)
|
||||
_prepare_and_commit(st, POS, 3)
|
||||
assert len(kv.window_block_ids(st.adapter(POS))) == 3
|
||||
|
||||
_prepare_and_commit(st, POS, 2)
|
||||
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
|
||||
visible_blocks, video_len = ctx.video_block_table(torch.device("cpu"))
|
||||
assert len(visible_blocks) == 3
|
||||
assert video_len == 3 * BLOCK
|
||||
|
||||
|
||||
def test_branches_are_independent():
|
||||
kv, st = make_state()
|
||||
_prepare_and_commit(st, POS, 1)
|
||||
assert len(kv.window_block_ids(st.adapter(POS))) == 1
|
||||
assert kv.window_block_ids(st.adapter(NEG)) == []
|
||||
|
||||
|
||||
def test_reset_clears_session_window():
|
||||
kv, st = make_state(num_layers=1, window_chunks=4)
|
||||
_prepare_and_commit(st, POS, 2)
|
||||
_prepare_and_commit(st, NEG, 1)
|
||||
assert st._committed[POS] == 2 * BLOCK and st._committed[NEG] == BLOCK
|
||||
free_before = kv.manager.block_pool.get_num_free_blocks()
|
||||
|
||||
st.reset()
|
||||
|
||||
assert st._committed == {POS: 0, NEG: 0}
|
||||
assert st._paged_pending == {POS: None, NEG: None}
|
||||
assert kv.window_block_ids(st.adapter(POS)) == []
|
||||
assert kv.window_block_ids(st.adapter(NEG)) == []
|
||||
assert kv.manager.block_pool.get_num_free_blocks() > free_before
|
||||
_prepare_and_commit(st, POS, 1)
|
||||
assert len(kv.window_block_ids(st.adapter(POS))) == 1
|
||||
|
||||
|
||||
def test_get_cross_attention_kv_raises_before_populate():
|
||||
_, st = make_state(cross_attn_length=8)
|
||||
for kv_branch in (POS, NEG):
|
||||
with pytest.raises(RuntimeError, match="before it was populated"):
|
||||
st.get_cross_attention_kv(kv_branch, "text")
|
||||
|
||||
|
||||
def test_get_cross_attention_kv_returns_session_pool_dicts_when_populated():
|
||||
L = 8
|
||||
kv, st = make_state(num_layers=2, cross_attn_length=L)
|
||||
written = []
|
||||
for _ in range(2):
|
||||
k = torch.randn(1, L, N_HEADS, HEAD_DIM)
|
||||
v = torch.randn(1, L, N_HEADS, HEAD_DIM)
|
||||
written.append((k, v))
|
||||
st.populate_cross_attention(POS, "text", written)
|
||||
|
||||
out = st.get_cross_attention_kv(POS, "text")
|
||||
assert len(out) == 2
|
||||
for i, (k, v) in enumerate(written):
|
||||
assert out[i]["is_init"] is True
|
||||
assert out[i]["k"].shape == (1, L, N_HEADS, HEAD_DIM)
|
||||
assert torch.equal(out[i]["k"], k)
|
||||
assert torch.equal(out[i]["v"], v)
|
||||
|
||||
|
||||
def test_window_reset_can_keep_named_cross_attention_cache():
|
||||
kv, st = make_state(num_layers=1, cross_attn_length=8)
|
||||
k = torch.randn(1, 8, N_HEADS, HEAD_DIM)
|
||||
v = torch.randn(1, 8, N_HEADS, HEAD_DIM)
|
||||
st.populate_cross_attention(POS, "text", [(k, v)])
|
||||
old_adapter = st.adapter(POS)
|
||||
|
||||
st.reset(keep_cross_attention=("text",))
|
||||
|
||||
assert st.adapter(POS) is not old_adapter
|
||||
assert torch.equal(st.get_cross_attention_kv(POS, "text")[0]["k"], k)
|
||||
assert "s1" in kv._cross_sessions
|
||||
|
||||
st.reset()
|
||||
assert "s1" not in kv._cross_sessions
|
||||
assert not st.is_cross_attention_populated(POS, "text")
|
||||
|
||||
|
||||
def test_cross_attention_uses_logical_branch_when_local_slot_is_shared():
|
||||
_, st = make_state(num_layers=2, cross_attn_length=8, shared_local_index=True)
|
||||
assert st.kv_cache.num_local_kv_branches == 1
|
||||
pos_layers = [
|
||||
(torch.full((1, 8, N_HEADS, HEAD_DIM), 1.0), torch.full((1, 8, N_HEADS, HEAD_DIM), 2.0)) for _ in range(2)
|
||||
]
|
||||
neg_layers = [
|
||||
(torch.full((1, 8, N_HEADS, HEAD_DIM), 3.0), torch.full((1, 8, N_HEADS, HEAD_DIM), 4.0)) for _ in range(2)
|
||||
]
|
||||
|
||||
st.populate_cross_attention(POS, "text", pos_layers)
|
||||
st.populate_cross_attention(NEG, "text", neg_layers)
|
||||
|
||||
for layer in st.get_cross_attention_kv(POS, "text"):
|
||||
assert torch.count_nonzero(layer["k"] != 1.0) == 0
|
||||
assert torch.count_nonzero(layer["v"] != 2.0) == 0
|
||||
for layer in st.get_cross_attention_kv(NEG, "text"):
|
||||
assert torch.count_nonzero(layer["k"] != 3.0) == 0
|
||||
assert torch.count_nonzero(layer["v"] != 4.0) == 0
|
||||
|
||||
|
||||
def test_cross_attention_population_is_transactional():
|
||||
kv, st = make_state(num_layers=2, cross_attn_length=8)
|
||||
original = [
|
||||
(torch.full((1, 8, N_HEADS, HEAD_DIM), value), torch.full((1, 8, N_HEADS, HEAD_DIM), -value))
|
||||
for value in (1.0, 2.0)
|
||||
]
|
||||
replacement = (
|
||||
torch.full((1, 8, N_HEADS, HEAD_DIM), 9.0),
|
||||
torch.full((1, 8, N_HEADS, HEAD_DIM), -9.0),
|
||||
)
|
||||
|
||||
def interrupted_population():
|
||||
yield replacement
|
||||
raise RuntimeError("projection failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="projection failed"):
|
||||
st.populate_cross_attention(POS, "text", interrupted_population())
|
||||
assert not st.is_cross_attention_populated(POS, "text")
|
||||
assert "s1" not in kv._cross_sessions
|
||||
|
||||
st.populate_cross_attention(POS, "text", original)
|
||||
|
||||
with pytest.raises(RuntimeError, match="projection failed"):
|
||||
st.populate_cross_attention(POS, "text", interrupted_population())
|
||||
out = st.get_cross_attention_kv(POS, "text")
|
||||
for layer_index, layer in enumerate(out):
|
||||
assert torch.equal(layer["k"], original[layer_index][0])
|
||||
assert torch.equal(layer["v"], original[layer_index][1])
|
||||
|
||||
for invalid_layers in (original[:1], [*original, replacement]):
|
||||
with pytest.raises(ValueError, match="expected 2 layers"):
|
||||
st.populate_cross_attention(POS, "text", invalid_layers)
|
||||
out = st.get_cross_attention_kv(POS, "text")
|
||||
for layer_index, layer in enumerate(out):
|
||||
assert torch.equal(layer["k"], original[layer_index][0])
|
||||
assert torch.equal(layer["v"], original[layer_index][1])
|
||||
|
||||
invalid_shape = (torch.empty(1, 7, N_HEADS, HEAD_DIM), original[0][1])
|
||||
with pytest.raises(ValueError, match="expected k/v shape"):
|
||||
st.populate_cross_attention(POS, "text", [invalid_shape, original[1]])
|
||||
out = st.get_cross_attention_kv(POS, "text")
|
||||
assert torch.equal(out[0]["k"], original[0][0])
|
||||
|
||||
|
||||
def test_closed_state_cannot_repopulate_cross_attention():
|
||||
kv, st = make_state(num_layers=1, cross_attn_length=8)
|
||||
layer = (torch.ones(1, 8, N_HEADS, HEAD_DIM), torch.ones(1, 8, N_HEADS, HEAD_DIM))
|
||||
st.close()
|
||||
|
||||
with pytest.raises(RuntimeError, match="is closed"):
|
||||
st.populate_cross_attention(POS, "text", [layer])
|
||||
assert "s1" not in kv._cross_sessions
|
||||
|
||||
|
||||
def test_kv_create_is_noop_engine_owns_allocation():
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
|
||||
p = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
p._ar_diffusion_kv_state = object()
|
||||
state = MagicMock()
|
||||
p._kv_create(state, 1, "float32", "cpu", 24, 4, 64)
|
||||
state.create_kv_caches.assert_not_called()
|
||||
|
||||
|
||||
def test_get_kv_cache_requires_frame_aligned_seqlen():
|
||||
_, st = make_state()
|
||||
with pytest.raises(AssertionError, match="frame-aligned"):
|
||||
st.get_kv_caches(POS, seq_len=BLOCK + 1, commit_current=True)
|
||||
|
||||
|
||||
def test_prepare_one_branch_allocates_nothing_for_the_other():
|
||||
"""CFG-parallel laziness: a rank prepares only the kv_branch it runs."""
|
||||
kv, st = make_state(num_layers=1, window_chunks=4)
|
||||
pos_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
neg_ctx = st.get_kv_caches(NEG, seq_len=BLOCK, commit_current=True)[0].forward_ctx
|
||||
|
||||
pos_ctx.prepare(device=torch.device("cpu"), action_len=0, query_len=BLOCK)
|
||||
|
||||
assert pos_ctx._allocated_video
|
||||
assert not neg_ctx._allocated_video
|
||||
assert kv.window_block_ids(st.adapter(NEG)) == []
|
||||
@@ -0,0 +1,79 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Slot-mapping / block-table tests for the AR-Diffusion engine (Phase 1, Step 3)."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ARDiffusionRequestAdapter,
|
||||
ChunkWindowSpec,
|
||||
build_kv_manager,
|
||||
chunk_slot_mapping,
|
||||
compute_slot_mapping,
|
||||
resident_block_ids,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
BLOCK = 16
|
||||
|
||||
|
||||
def make_spec(*, window_chunks=2):
|
||||
return ChunkWindowSpec(
|
||||
block_size=BLOCK,
|
||||
num_kv_heads=4,
|
||||
head_size=64,
|
||||
dtype=torch.float16,
|
||||
sliding_window=window_chunks * BLOCK,
|
||||
chunk_size=BLOCK,
|
||||
window_chunks=window_chunks,
|
||||
)
|
||||
|
||||
|
||||
def test_slot_mapping_matches_block_offsets():
|
||||
# block table [5, 2, 9], block_size 4. slot = block*4 + (pos % 4).
|
||||
slots = compute_slot_mapping([5, 2, 9], [0, 1, 4, 5, 8, 9], block_size=4)
|
||||
assert slots.tolist() == [20, 21, 8, 9, 36, 37]
|
||||
|
||||
|
||||
def test_slot_mapping_rejects_bad_block_size():
|
||||
with pytest.raises(ValueError):
|
||||
compute_slot_mapping([1], [0], block_size=0)
|
||||
|
||||
|
||||
def test_chunk_slot_mapping_targets_current_chunk():
|
||||
# After 2 committed chunks (num_computed=32), the in-flight chunk maps to the
|
||||
# block covering positions 32..47 — i.e. block table index 2.
|
||||
block_ids = [7, 4, 3] # block index 2 -> physical block 3
|
||||
slots = chunk_slot_mapping(block_ids, num_computed_tokens=32, chunk_size=4, block_size=16)
|
||||
# positions 32..35 -> block_index 2 -> physical 3 -> slots 48..51
|
||||
assert slots.tolist() == [48, 49, 50, 51]
|
||||
|
||||
|
||||
def test_resident_block_ids_excludes_null():
|
||||
assert resident_block_ids([0, 1, 0, 2, 3], null_block_id=0) == [1, 2, 3]
|
||||
|
||||
|
||||
def test_blocktable_build_single_request():
|
||||
"""Integration: a real KVCacheManager block table -> a valid slot mapping.
|
||||
|
||||
Every slot for the in-flight chunk must land inside a real (non-null) block
|
||||
that the manager actually allocated for the request.
|
||||
"""
|
||||
spec = make_spec()
|
||||
mgr = build_kv_manager(spec, ["l0"], num_blocks=16, max_model_len=1024)
|
||||
null_id = mgr.block_pool.null_block.block_id
|
||||
adapter = ARDiffusionRequestAdapter("req", chunk_size=BLOCK)
|
||||
|
||||
mgr.allocate_slots(adapter, num_new_tokens=BLOCK)
|
||||
block_ids = mgr.get_block_ids(adapter.request_id)[0]
|
||||
real = set(resident_block_ids(block_ids, null_id))
|
||||
assert real, "expected at least one real block after allocation"
|
||||
|
||||
slots = chunk_slot_mapping(block_ids, adapter.num_computed_tokens, spec.chunk_size, spec.block_size)
|
||||
# Each slot resolves to a real allocated block, none to null_block.
|
||||
blocks_used = {int(s) // spec.block_size for s in slots}
|
||||
assert blocks_used <= real
|
||||
assert null_id not in blocks_used
|
||||
assert len(slots) == spec.chunk_size
|
||||
@@ -288,6 +288,13 @@ class TestBuildAttentionConfig:
|
||||
class TestOmniDiffusionConfigAttentionParsing:
|
||||
"""Test OmniDiffusionConfig attention shorthand and structured config."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_diffusion_attention_backend_env(self, monkeypatch):
|
||||
# OmniDiffusionConfig.__post_init__ applies DIFFUSION_ATTENTION_BACKEND via
|
||||
# build_attention_config(); clear it so these tests assert config defaults,
|
||||
# not whatever the process inherited from CI / sibling tests.
|
||||
monkeypatch.delenv("DIFFUSION_ATTENTION_BACKEND", raising=False)
|
||||
|
||||
def test_diffusion_attention_backend_sets_default(self):
|
||||
config = OmniDiffusionConfig.from_kwargs(diffusion_attention_backend="SAGE_ATTN")
|
||||
assert isinstance(config.diffusion_attention_config, AttentionConfig)
|
||||
|
||||
@@ -49,6 +49,8 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
from vllm_omni.diffusion.forward_context import set_forward_context
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def update_environment_variables(envs_dict: dict[str, str]):
|
||||
"""Update multiple environment variables with logging."""
|
||||
|
||||
@@ -18,6 +18,9 @@ from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
|
||||
from vllm_omni.diffusion.attention.backends.utils import fa # noqa: E402
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
is_gpu = current_omni_platform.is_cuda_alike() or current_omni_platform.is_xpu()
|
||||
HAS_FLASH_ATTN = fa.HAS_FLASH_ATTN
|
||||
flash_attn_func = fa.flash_attn_func # noqa: N813
|
||||
|
||||
@@ -27,6 +27,9 @@ from vllm_omni.diffusion.attention.backends.utils.piecewise_attn import (
|
||||
piecewise_attn,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
DEVICE = torch.device("cpu")
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import pytest
|
||||
import torch
|
||||
from vllm.platforms import current_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
SAGE_ATTN3_MODULE = "vllm_omni.diffusion.attention.backends.sage_attn3"
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, set_forward_context
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,12 +1,20 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for SeqAllToAll4D and SeqAllToAll5D communication primitives."""
|
||||
"""Tests for SeqAllToAll4D, SeqAllToAll5D, and RingComm communication primitives.
|
||||
|
||||
CPU tests use gloo on CPU tensors (no GPU). Nightly parity tests run the same
|
||||
checks on real multi-GPU NCCL collectives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.distributed.comm import RingComm, SeqAllToAll4D, SeqAllToAll5D
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
@@ -16,56 +24,51 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4)
|
||||
|
||||
def update_environment_variables(envs_dict: dict[str, str]):
|
||||
"""Update multiple environment variables with logging."""
|
||||
for k, v in envs_dict.items():
|
||||
os.environ[k] = v
|
||||
DeviceKind = Literal["cpu", "cuda"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("batch_size", [2])
|
||||
@pytest.mark.parametrize("seq_len_per_rank", [8])
|
||||
@pytest.mark.parametrize("num_heads", [8])
|
||||
@pytest.mark.parametrize("head_size", [32])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_4d_identity(
|
||||
def _update_environment_variables(envs_dict: dict[str, str]) -> None:
|
||||
for key, value in envs_dict.items():
|
||||
os.environ[key] = value
|
||||
|
||||
|
||||
def _worker_device(local_rank: int, device_kind: DeviceKind) -> torch.device:
|
||||
if device_kind == "cpu":
|
||||
return torch.device("cpu")
|
||||
return torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
|
||||
|
||||
def _init_worker(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
seq_len_per_rank: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
):
|
||||
"""Test that two consecutive all-to-all operations return the original input."""
|
||||
# Skip if not enough GPUs available
|
||||
available_gpus = current_omni_platform.get_device_count()
|
||||
if available_gpus < world_size:
|
||||
pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available")
|
||||
|
||||
# Ensure num_heads is divisible by world_size
|
||||
if num_heads % world_size != 0:
|
||||
pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})")
|
||||
|
||||
# Run test with multiprocessing spawn
|
||||
torch.multiprocessing.spawn(
|
||||
_test_4d_identity_worker,
|
||||
args=(
|
||||
world_size,
|
||||
dtype,
|
||||
batch_size,
|
||||
seq_len_per_rank,
|
||||
num_heads,
|
||||
head_size,
|
||||
use_sync,
|
||||
),
|
||||
nprocs=world_size,
|
||||
master_port: int,
|
||||
device_kind: DeviceKind,
|
||||
) -> None:
|
||||
_update_environment_variables(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": str(master_port),
|
||||
}
|
||||
)
|
||||
backend = "gloo" if device_kind == "cpu" else None
|
||||
init_distributed_environment(backend=backend)
|
||||
|
||||
|
||||
def _test_4d_identity_worker(
|
||||
def _close_tolerance(dtype: torch.dtype) -> tuple[float, float]:
|
||||
if dtype == torch.bfloat16:
|
||||
return 1e-3, 1e-3
|
||||
if dtype == torch.float16:
|
||||
return 1e-3, 1e-3
|
||||
return 1e-5, 1e-5
|
||||
|
||||
|
||||
def _run_4d_identity(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
@@ -74,29 +77,17 @@ def _test_4d_identity_worker(
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
):
|
||||
"""Worker function for test_4d_identity."""
|
||||
# Set device
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
device = _worker_device(local_rank, device_kind)
|
||||
if device_kind == "cuda":
|
||||
current_omni_platform.set_device(device)
|
||||
|
||||
# Set environment variables for distributed training
|
||||
update_environment_variables(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": "29500",
|
||||
}
|
||||
)
|
||||
_init_worker(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(ulysses_degree=world_size)
|
||||
sp_group = get_sp_group().ulysses_group
|
||||
|
||||
# Initialize distributed environment
|
||||
init_distributed_environment()
|
||||
initialize_model_parallel(ulysses_degree=world_size) # test ulysses sp by default
|
||||
sp_group = get_sp_group().ulysses_group # get ulysses sp group not ring sp group
|
||||
|
||||
# Create input tensor: (bs, seqlen/P, hc, hs)
|
||||
torch.manual_seed(42 + local_rank)
|
||||
input_tensor = torch.randn(
|
||||
batch_size,
|
||||
@@ -106,100 +97,36 @@ def _test_4d_identity_worker(
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Save original input for comparison
|
||||
original_input = input_tensor.clone()
|
||||
|
||||
# First all-to-all: (bs, seqlen/P, hc, hs) -> (bs, seqlen, hc/P, hs)
|
||||
intermediate = SeqAllToAll4D.apply(
|
||||
sp_group,
|
||||
input_tensor,
|
||||
2, # scatter head dimension
|
||||
1, # gather sequence dimension
|
||||
use_sync,
|
||||
)
|
||||
|
||||
# Verify intermediate shape
|
||||
expected_shape = (
|
||||
intermediate = SeqAllToAll4D.apply(sp_group, input_tensor, 2, 1, use_sync)
|
||||
expected_intermediate_shape = (
|
||||
batch_size,
|
||||
seq_len_per_rank * world_size,
|
||||
num_heads // world_size,
|
||||
head_size,
|
||||
)
|
||||
assert intermediate.shape == expected_shape, (
|
||||
f"Intermediate shape mismatch: expected {expected_shape}, got {intermediate.shape}"
|
||||
assert intermediate.shape == expected_intermediate_shape, (
|
||||
f"Intermediate shape mismatch: expected {expected_intermediate_shape}, got {intermediate.shape}"
|
||||
)
|
||||
|
||||
# Second all-to-all: (bs, seqlen, hc/P, hs) -> (bs, seqlen/P, hc, hs)
|
||||
output = SeqAllToAll4D.apply(
|
||||
sp_group,
|
||||
intermediate,
|
||||
1, # scatter sequence dimension
|
||||
2, # gather head dimension
|
||||
use_sync,
|
||||
)
|
||||
|
||||
# Verify output shape matches input
|
||||
output = SeqAllToAll4D.apply(sp_group, intermediate, 1, 2, use_sync)
|
||||
assert output.shape == original_input.shape, (
|
||||
f"Output shape mismatch: expected {original_input.shape}, got {output.shape}"
|
||||
)
|
||||
|
||||
# Verify output matches original input
|
||||
rtol, atol = _close_tolerance(dtype)
|
||||
torch.testing.assert_close(
|
||||
output,
|
||||
original_input,
|
||||
rtol=1e-5,
|
||||
atol=1e-5,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
msg="Output does not match original input after two all-to-all operations",
|
||||
)
|
||||
|
||||
# Cleanup distributed environment
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("batch_size", [2])
|
||||
@pytest.mark.parametrize("seq_len_per_rank", [8])
|
||||
@pytest.mark.parametrize("num_heads", [8])
|
||||
@pytest.mark.parametrize("head_size", [32])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_5d_identity(
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
seq_len_per_rank: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
):
|
||||
"""Test that two consecutive all-to-all operations return the original input."""
|
||||
# Skip if not enough GPUs available
|
||||
available_gpus = current_omni_platform.get_device_count()
|
||||
if available_gpus < world_size:
|
||||
pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available")
|
||||
|
||||
# Ensure num_heads is divisible by world_size
|
||||
if num_heads % world_size != 0:
|
||||
pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})")
|
||||
|
||||
# Run test with multiprocessing spawn
|
||||
torch.multiprocessing.spawn(
|
||||
_test_5d_identity_worker,
|
||||
args=(
|
||||
world_size,
|
||||
dtype,
|
||||
batch_size,
|
||||
seq_len_per_rank,
|
||||
num_heads,
|
||||
head_size,
|
||||
use_sync,
|
||||
),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def _test_5d_identity_worker(
|
||||
def _run_5d_identity(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
@@ -208,199 +135,329 @@ def _test_5d_identity_worker(
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
):
|
||||
"""Worker function for test_5d_identity."""
|
||||
# Set device
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
device = _worker_device(local_rank, device_kind)
|
||||
if device_kind == "cuda":
|
||||
current_omni_platform.set_device(device)
|
||||
|
||||
# Set environment variables for distributed training
|
||||
update_environment_variables(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": "29500",
|
||||
}
|
||||
)
|
||||
_init_worker(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(ulysses_degree=world_size)
|
||||
sp_group = get_sp_group().ulysses_group
|
||||
|
||||
# Initialize distributed environment
|
||||
init_distributed_environment()
|
||||
initialize_model_parallel(ulysses_degree=world_size) # test ulysses sp by default
|
||||
sp_group = get_sp_group().ulysses_group # get ulysses sp group not ring sp group
|
||||
|
||||
# Create input tensor: (bs, seqlen/P, 3, hc, hs)
|
||||
# The '3' dimension is for Q, K, V
|
||||
torch.manual_seed(42 + local_rank)
|
||||
input_tensor = torch.randn(
|
||||
batch_size,
|
||||
seq_len_per_rank,
|
||||
3, # Q, K, V
|
||||
3,
|
||||
num_heads,
|
||||
head_size,
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
# Save original input for comparison
|
||||
original_input = input_tensor.clone()
|
||||
|
||||
# First all-to-all: (bs, seqlen/P, 3, hc, hs) -> (bs, seqlen, 3, hc/P, hs)
|
||||
intermediate = SeqAllToAll5D.apply(
|
||||
sp_group,
|
||||
input_tensor,
|
||||
3, # scatter head dimension
|
||||
1, # gather sequence dimension
|
||||
use_sync,
|
||||
)
|
||||
|
||||
# Verify intermediate shape
|
||||
expected_shape = (
|
||||
intermediate = SeqAllToAll5D.apply(sp_group, input_tensor, 3, 1, use_sync)
|
||||
expected_intermediate_shape = (
|
||||
batch_size,
|
||||
seq_len_per_rank * world_size,
|
||||
3,
|
||||
num_heads // world_size,
|
||||
head_size,
|
||||
)
|
||||
assert intermediate.shape == expected_shape, (
|
||||
f"Intermediate shape mismatch: expected {expected_shape}, got {intermediate.shape}"
|
||||
assert intermediate.shape == expected_intermediate_shape, (
|
||||
f"Intermediate shape mismatch: expected {expected_intermediate_shape}, got {intermediate.shape}"
|
||||
)
|
||||
|
||||
# Second all-to-all: (bs, seqlen, 3, hc/P, hs) -> (bs, seqlen/P, 3, hc, hs)
|
||||
output = SeqAllToAll5D.apply(
|
||||
sp_group,
|
||||
intermediate,
|
||||
1, # scatter sequence dimension
|
||||
3, # gather head dimension
|
||||
use_sync,
|
||||
)
|
||||
|
||||
# Verify output shape matches input
|
||||
output = SeqAllToAll5D.apply(sp_group, intermediate, 1, 3, use_sync)
|
||||
assert output.shape == original_input.shape, (
|
||||
f"Output shape mismatch: expected {original_input.shape}, got {output.shape}"
|
||||
)
|
||||
|
||||
# Verify output matches original input
|
||||
rtol, atol = _close_tolerance(dtype)
|
||||
torch.testing.assert_close(
|
||||
output,
|
||||
original_input,
|
||||
rtol=1e-5,
|
||||
atol=1e-5,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
msg="Output does not match original input after two all-to-all operations",
|
||||
)
|
||||
|
||||
# Cleanup distributed environment
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("batch_size", [2])
|
||||
@pytest.mark.parametrize("num_heads", [8])
|
||||
@pytest.mark.parametrize("head_size", [128])
|
||||
def test_ring_p2p(
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
):
|
||||
"""Test Ring P2P communication (send_recv)."""
|
||||
# Skip if not enough GPUs available
|
||||
available_gpus = current_omni_platform.get_device_count()
|
||||
if available_gpus < world_size:
|
||||
pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available")
|
||||
|
||||
torch.multiprocessing.spawn(
|
||||
_test_ring_p2p_worker,
|
||||
args=(world_size, dtype, batch_size, num_heads, head_size),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def _test_ring_p2p_worker(
|
||||
def _run_ring_p2p(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
):
|
||||
"""Worker for Ring P2P test."""
|
||||
import sys
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
device = _worker_device(local_rank, device_kind)
|
||||
if device_kind == "cuda":
|
||||
current_omni_platform.set_device(device)
|
||||
|
||||
# Set device
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
|
||||
# Set env vars
|
||||
# Use a different port to avoid conflict with other tests if run in parallel
|
||||
update_environment_variables(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": "29501",
|
||||
}
|
||||
)
|
||||
|
||||
# Init distributed
|
||||
try:
|
||||
init_distributed_environment()
|
||||
# Ring degree = world_size to test ring group
|
||||
_init_worker(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(ring_degree=world_size)
|
||||
sp_group = get_sp_group()
|
||||
|
||||
print(f"[Rank {local_rank}] Initialized. Ring group size: {sp_group.ring_group.size()}")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Create RingComm
|
||||
comm = RingComm(sp_group.ring_group)
|
||||
|
||||
# Create tensor: rank-specific data
|
||||
# (batch, num_heads, head_size)
|
||||
# Fill with rank value + 1 to avoid 0 and make verification easy
|
||||
input_tensor = torch.full(
|
||||
(batch_size, num_heads, head_size), fill_value=float(local_rank + 1), dtype=dtype, device=device
|
||||
(batch_size, num_heads, head_size),
|
||||
fill_value=float(local_rank + 1),
|
||||
dtype=dtype,
|
||||
device=device,
|
||||
)
|
||||
|
||||
print(f"[Rank {local_rank}] Input sum: {input_tensor.sum().item()}")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Send input, receive from prev
|
||||
# RingComm.send_recv sends to next, receives from prev
|
||||
t0 = __import__("time").time()
|
||||
recv_tensor = comm.send_recv(input_tensor)
|
||||
comm.commit()
|
||||
comm.wait()
|
||||
t1 = __import__("time").time()
|
||||
|
||||
print(f"[Rank {local_rank}] Communication done in {t1 - t0:.4f}s")
|
||||
|
||||
# Verify
|
||||
# Expected value: from (rank - 1) % world_size
|
||||
prev_rank = (local_rank - 1 + world_size) % world_size
|
||||
expected_value = float(prev_rank + 1)
|
||||
|
||||
recv_sum = recv_tensor.sum().item()
|
||||
print(f"[Rank {local_rank}] Received sum: {recv_sum}, Expected value: {expected_value}")
|
||||
sys.stdout.flush()
|
||||
|
||||
expected_tensor = torch.full_like(recv_tensor, fill_value=expected_value)
|
||||
|
||||
# Use a slightly loose tolerance for bfloat16
|
||||
rtol, atol = _close_tolerance(dtype)
|
||||
torch.testing.assert_close(
|
||||
recv_tensor, expected_tensor, rtol=1e-3, atol=1e-3, msg=f"[Rank {local_rank}] Data mismatch!"
|
||||
recv_tensor,
|
||||
expected_tensor,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
msg=f"[Rank {local_rank}] Ring P2P data mismatch",
|
||||
)
|
||||
print(f"[Rank {local_rank}] Verification PASSED")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Rank {local_rank}] FAILED with error: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise e
|
||||
finally:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
def _spawn_4d_identity(
|
||||
*,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
seq_len_per_rank: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
torch.multiprocessing.spawn(
|
||||
_run_4d_identity,
|
||||
args=(
|
||||
world_size,
|
||||
dtype,
|
||||
batch_size,
|
||||
seq_len_per_rank,
|
||||
num_heads,
|
||||
head_size,
|
||||
use_sync,
|
||||
device_kind,
|
||||
master_port,
|
||||
),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def _spawn_5d_identity(
|
||||
*,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
seq_len_per_rank: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
use_sync: bool,
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
torch.multiprocessing.spawn(
|
||||
_run_5d_identity,
|
||||
args=(
|
||||
world_size,
|
||||
dtype,
|
||||
batch_size,
|
||||
seq_len_per_rank,
|
||||
num_heads,
|
||||
head_size,
|
||||
use_sync,
|
||||
device_kind,
|
||||
master_port,
|
||||
),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def _spawn_ring_p2p(
|
||||
*,
|
||||
world_size: int,
|
||||
dtype: torch.dtype,
|
||||
batch_size: int,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
torch.multiprocessing.spawn(
|
||||
_run_ring_p2p,
|
||||
args=(world_size, dtype, batch_size, num_heads, head_size, device_kind, master_port),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def _require_heads_divisible(num_heads: int, world_size: int) -> None:
|
||||
if num_heads % world_size != 0:
|
||||
pytest.skip(f"num_heads ({num_heads}) not divisible by world_size ({world_size})")
|
||||
|
||||
|
||||
def _require_gpus(world_size: int) -> None:
|
||||
available_gpus = current_omni_platform.get_device_count()
|
||||
if available_gpus < world_size:
|
||||
pytest.skip(f"Test requires {world_size} GPUs but only {available_gpus} available")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CPU: gloo collectives on CPU tensors (no GPU)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_4d_identity(world_size: int, use_sync: bool):
|
||||
_require_heads_divisible(8, world_size)
|
||||
_spawn_4d_identity(
|
||||
world_size=world_size,
|
||||
dtype=torch.float32,
|
||||
batch_size=2,
|
||||
seq_len_per_rank=8,
|
||||
num_heads=8,
|
||||
head_size=32,
|
||||
use_sync=use_sync,
|
||||
device_kind="cpu",
|
||||
master_port=29610,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_5d_identity(world_size: int, use_sync: bool):
|
||||
_require_heads_divisible(8, world_size)
|
||||
_spawn_5d_identity(
|
||||
world_size=world_size,
|
||||
dtype=torch.float32,
|
||||
batch_size=2,
|
||||
seq_len_per_rank=8,
|
||||
num_heads=8,
|
||||
head_size=32,
|
||||
use_sync=use_sync,
|
||||
device_kind="cpu",
|
||||
master_port=29611,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
def test_ring_p2p(world_size: int):
|
||||
_spawn_ring_p2p(
|
||||
world_size=world_size,
|
||||
dtype=torch.float32,
|
||||
batch_size=2,
|
||||
num_heads=8,
|
||||
head_size=128,
|
||||
device_kind="cpu",
|
||||
master_port=29612,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nightly: same checks on real multi-GPU NCCL collectives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize(
|
||||
"world_size",
|
||||
[
|
||||
pytest.param(2, marks=_L4_TWO_GPU),
|
||||
pytest.param(4, marks=_L4_FOUR_GPU),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_4d_identity_parity(world_size: int, dtype: torch.dtype, use_sync: bool):
|
||||
_require_gpus(world_size)
|
||||
_require_heads_divisible(8, world_size)
|
||||
_spawn_4d_identity(
|
||||
world_size=world_size,
|
||||
dtype=dtype,
|
||||
batch_size=2,
|
||||
seq_len_per_rank=8,
|
||||
num_heads=8,
|
||||
head_size=32,
|
||||
use_sync=use_sync,
|
||||
device_kind="cuda",
|
||||
master_port=29500,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize(
|
||||
"world_size",
|
||||
[
|
||||
pytest.param(2, marks=_L4_TWO_GPU),
|
||||
pytest.param(4, marks=_L4_FOUR_GPU),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("use_sync", [False, True])
|
||||
def test_5d_identity_parity(world_size: int, dtype: torch.dtype, use_sync: bool):
|
||||
_require_gpus(world_size)
|
||||
_require_heads_divisible(8, world_size)
|
||||
_spawn_5d_identity(
|
||||
world_size=world_size,
|
||||
dtype=dtype,
|
||||
batch_size=2,
|
||||
seq_len_per_rank=8,
|
||||
num_heads=8,
|
||||
head_size=32,
|
||||
use_sync=use_sync,
|
||||
device_kind="cuda",
|
||||
master_port=29502,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize(
|
||||
"world_size",
|
||||
[
|
||||
pytest.param(2, marks=_L4_TWO_GPU),
|
||||
pytest.param(4, marks=_L4_FOUR_GPU),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16])
|
||||
def test_ring_p2p_parity(world_size: int, dtype: torch.dtype):
|
||||
_require_gpus(world_size)
|
||||
_spawn_ring_p2p(
|
||||
world_size=world_size,
|
||||
dtype=dtype,
|
||||
batch_size=2,
|
||||
num_heads=8,
|
||||
head_size=128,
|
||||
device_kind="cuda",
|
||||
master_port=29501,
|
||||
)
|
||||
@@ -1,8 +1,17 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests for PipelineParallelMixin and related PP communication helpers.
|
||||
|
||||
CPU unit tests exercise mixin logic without a process group. Distributed CPU
|
||||
tests use gloo on CPU tensors; nightly parity tests run the same checks on
|
||||
real multi-GPU NCCL collectives.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
from typing import Literal
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -11,6 +20,7 @@ from vllm.sequence import IntermediateTensors
|
||||
from vllm.v1.worker.gpu_worker import AsyncIntermediateTensors
|
||||
|
||||
import vllm_omni.diffusion.distributed.pipeline_parallel as pp_module
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
@@ -22,7 +32,12 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.parallel]
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
_L4_THREE_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=3)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4)
|
||||
|
||||
DeviceKind = Literal["cpu", "cuda"]
|
||||
_UNIT_MARKS = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _find_free_port() -> str:
|
||||
@@ -36,6 +51,22 @@ def update_environment_variables(envs_dict: dict[str, str]) -> None:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _cleanup_distributed() -> None:
|
||||
"""Destroy omni distributed state and clear test-process leftovers.
|
||||
|
||||
``destroy_distributed_env`` leaves ``vllm.distributed.parallel_state._PP``
|
||||
aliased to the destroyed group. Parent-process baselines must clear that
|
||||
dangling reference (and RANK/MASTER_* env) so later unit tests that call
|
||||
``vllm.distributed.initialize_model_parallel`` do not see stale state.
|
||||
"""
|
||||
destroy_distributed_env()
|
||||
import vllm.distributed.parallel_state as vllm_parallel_state
|
||||
|
||||
vllm_parallel_state._PP = None
|
||||
for key in ("MASTER_ADDR", "MASTER_PORT", "RANK", "WORLD_SIZE", "LOCAL_RANK"):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared stubs used by both unit and distributed tests
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -143,7 +174,7 @@ class MockPipelineParallel(PipelineParallelMixin, CFGParallelMixin):
|
||||
class TestAsyncLatents:
|
||||
"""Verifies the lazy-resolution behaviour of AsyncLatents without a real process group."""
|
||||
|
||||
pytestmark = [pytest.mark.cpu]
|
||||
pytestmark = _UNIT_MARKS
|
||||
|
||||
def _make(self, tensor: torch.Tensor, handles: list | None = None, postproc: list | None = None) -> AsyncLatents:
|
||||
return AsyncLatents({"latents": tensor}, handles or [], postproc or [])
|
||||
@@ -214,7 +245,7 @@ class TestAsyncLatents:
|
||||
class TestSyncPPSend:
|
||||
"""Verifies PipelineParallelMixin's internal PP-send flush."""
|
||||
|
||||
pytestmark = [pytest.mark.cpu]
|
||||
pytestmark = _UNIT_MARKS
|
||||
|
||||
@staticmethod
|
||||
def _make_pipeline() -> PipelineParallelMixin:
|
||||
@@ -247,7 +278,7 @@ class TestSyncPPSend:
|
||||
class TestDiffuseWrapper:
|
||||
"""Verifies that PipelineParallelMixin flushes pending sends when diffuse() exits."""
|
||||
|
||||
pytestmark = [pytest.mark.cpu]
|
||||
pytestmark = _UNIT_MARKS
|
||||
|
||||
def test_diffuse_flushes_pending_sends_on_success(self):
|
||||
work = FakeWork()
|
||||
@@ -289,7 +320,7 @@ class TestDiffuseWrapper:
|
||||
|
||||
|
||||
class TestVaeDecodeGuard:
|
||||
pytestmark = [pytest.mark.cpu]
|
||||
pytestmark = _UNIT_MARKS
|
||||
|
||||
@staticmethod
|
||||
def _make_pipeline(distributed_enabled: bool = False) -> PipelineParallelMixin:
|
||||
@@ -351,6 +382,8 @@ class TestVaeDecodeGuard:
|
||||
assert pipeline.vae.decode.__doc__ == "Original decode docstring."
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
def test_pipeline_parallel_requires_cfg_mixin():
|
||||
with pytest.raises(TypeError, match="inherits PipelineParallelMixin but not CFGParallelMixin"):
|
||||
@@ -359,6 +392,8 @@ def test_pipeline_parallel_requires_cfg_mixin():
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
def test_pipeline_parallel_requires_mro_before_cfg_mixin():
|
||||
with pytest.raises(TypeError, match="must inherit PipelineParallelMixin before CFGParallelMixin"):
|
||||
@@ -372,10 +407,22 @@ def test_pipeline_parallel_requires_mro_before_cfg_mixin():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def init_dist(local_rank: int, world_size: int, master_port: str) -> torch.device:
|
||||
def _worker_device(local_rank: int, device_kind: DeviceKind) -> torch.device:
|
||||
if device_kind == "cpu":
|
||||
return torch.device("cpu")
|
||||
return torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
|
||||
|
||||
def init_dist(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
master_port: str,
|
||||
device_kind: DeviceKind,
|
||||
) -> torch.device:
|
||||
"""Initialise the distributed environment for a spawned worker."""
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
device = _worker_device(local_rank, device_kind)
|
||||
if device_kind == "cuda":
|
||||
current_omni_platform.set_device(device)
|
||||
update_environment_variables(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
@@ -385,13 +432,31 @@ def init_dist(local_rank: int, world_size: int, master_port: str) -> torch.devic
|
||||
"MASTER_PORT": master_port,
|
||||
}
|
||||
)
|
||||
init_distributed_environment()
|
||||
backend = "gloo" if device_kind == "cpu" else None
|
||||
init_distributed_environment(backend=backend)
|
||||
return device
|
||||
|
||||
|
||||
def _mp_backend(device_kind: DeviceKind) -> str | None:
|
||||
"""Backend for model-parallel process groups.
|
||||
|
||||
``GroupCoordinator.all_gather`` always uses ``device_group``. CPU tensors
|
||||
therefore require gloo device groups; otherwise a CUDA host's default NCCL
|
||||
backend breaks PP+CFG CPU tests at CFG all_gather (pp2-cfg2).
|
||||
isend/irecv of CPU tensors can already fall back to ``cpu_group``.
|
||||
"""
|
||||
return "gloo" if device_kind == "cpu" else None
|
||||
|
||||
|
||||
def _seed_device(input_seed: int, device: torch.device) -> None:
|
||||
torch.manual_seed(input_seed)
|
||||
if device.type == "cuda":
|
||||
torch.cuda.manual_seed_all(input_seed)
|
||||
|
||||
|
||||
def make_pipeline_and_inputs(
|
||||
test_config: dict, dtype: torch.dtype, device: torch.device, do_true_cfg: bool = False
|
||||
) -> tuple["MockPipelineParallel", dict, dict | None]:
|
||||
) -> tuple[MockPipelineParallel, dict, dict | None]:
|
||||
"""Create a MockPipelineParallel and seeded inputs from a test_config dict.
|
||||
|
||||
Must be called after ``initialize_model_parallel`` so that ``make_layers``
|
||||
@@ -408,16 +473,12 @@ def make_pipeline_and_inputs(
|
||||
dtype=dtype,
|
||||
)
|
||||
|
||||
torch.manual_seed(test_config["input_seed"])
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(test_config["input_seed"])
|
||||
_seed_device(test_config["input_seed"], device)
|
||||
pos_x = {"x": torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device)}
|
||||
|
||||
negative_kwargs = None
|
||||
if do_true_cfg:
|
||||
torch.manual_seed(test_config["input_seed"] + 1)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(test_config["input_seed"] + 1)
|
||||
_seed_device(test_config["input_seed"] + 1, device)
|
||||
neg_x = torch.randn(test_config["batch_size"], test_config["dim"], dtype=dtype, device=device)
|
||||
negative_kwargs = {"x": neg_x}
|
||||
|
||||
@@ -425,19 +486,23 @@ def make_pipeline_and_inputs(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. isend_tensor_dict / irecv_tensor_dict (2 GPUs)
|
||||
# 3. isend_tensor_dict / irecv_tensor_dict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def isend_irecv_worker(local_rank: int, world_size: int, master_port: str, result_queue):
|
||||
device = init_dist(local_rank, world_size, master_port)
|
||||
initialize_model_parallel(pipeline_parallel_size=world_size)
|
||||
def isend_irecv_worker(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
master_port: str,
|
||||
device_kind: DeviceKind,
|
||||
result_queue,
|
||||
):
|
||||
device = init_dist(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(pipeline_parallel_size=world_size, backend=_mp_backend(device_kind))
|
||||
pp_group = get_pp_group()
|
||||
|
||||
if pp_group.is_first_rank:
|
||||
torch.manual_seed(77)
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.manual_seed_all(77)
|
||||
_seed_device(77, device)
|
||||
tensor = torch.randn(3, 5, dtype=torch.float32, device=device)
|
||||
handles = pp_group.isend_tensor_dict({"t": tensor})
|
||||
for h in handles:
|
||||
@@ -449,27 +514,42 @@ def isend_irecv_worker(local_rank: int, world_size: int, master_port: str, resul
|
||||
|
||||
if torch.distributed.is_initialized():
|
||||
torch.distributed.barrier()
|
||||
destroy_distributed_env()
|
||||
_cleanup_distributed()
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs")
|
||||
@pytest.mark.parametrize("pp_size", [2])
|
||||
def test_isend_irecv_tensor_dict(pp_size: int):
|
||||
"""isend_tensor_dict / irecv_tensor_dict transfer a tensor dict without loss."""
|
||||
def _run_isend_irecv(pp_size: int, device_kind: DeviceKind, master_port: str) -> None:
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
q = manager.Queue()
|
||||
|
||||
port = _find_free_port()
|
||||
torch.multiprocessing.spawn(isend_irecv_worker, args=(pp_size, port, q), nprocs=pp_size)
|
||||
|
||||
torch.multiprocessing.spawn(
|
||||
isend_irecv_worker,
|
||||
args=(pp_size, master_port, device_kind, q),
|
||||
nprocs=pp_size,
|
||||
)
|
||||
results = {label: tensor for label, tensor in [q.get(), q.get()]}
|
||||
torch.testing.assert_close(
|
||||
results["received"], results["sent"], rtol=0, atol=0, msg="isend/irecv transferred tensor incorrectly"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("pp_size", [2])
|
||||
def test_isend_irecv_tensor_dict(pp_size: int):
|
||||
"""isend_tensor_dict / irecv_tensor_dict transfer a tensor dict without loss."""
|
||||
_run_isend_irecv(pp_size, device_kind="cpu", master_port=_find_free_port())
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize("pp_size", [pytest.param(2, marks=_L4_TWO_GPU)])
|
||||
def test_isend_irecv_tensor_dict_parity(pp_size: int):
|
||||
"""Nightly: isend/irecv on real multi-GPU NCCL."""
|
||||
_run_isend_irecv(pp_size, device_kind="cuda", master_port=_find_free_port())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. predict_noise_maybe_with_cfg
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -477,12 +557,13 @@ def test_isend_irecv_tensor_dict(pp_size: int):
|
||||
_baseline_cache: dict[tuple, torch.Tensor] = {}
|
||||
|
||||
|
||||
def compute_single_gpu_baseline(test_config: dict, dtype: torch.dtype, do_true_cfg: bool) -> torch.Tensor:
|
||||
"""Compute expected single-GPU output using the same MockPipelineParallel.
|
||||
|
||||
Initializes a trivial distributed env (world_size=1) so that ``make_layers`` and the PP/CFG mixins work normally.
|
||||
Results are cached so identical configs are only computed once.
|
||||
"""
|
||||
def compute_single_gpu_baseline(
|
||||
test_config: dict,
|
||||
dtype: torch.dtype,
|
||||
do_true_cfg: bool,
|
||||
device_kind: DeviceKind,
|
||||
) -> torch.Tensor:
|
||||
"""Compute expected single-process output using the same MockPipelineParallel."""
|
||||
key = (
|
||||
test_config["num_layers"],
|
||||
test_config["dim"],
|
||||
@@ -492,27 +573,29 @@ def compute_single_gpu_baseline(test_config: dict, dtype: torch.dtype, do_true_c
|
||||
test_config["cfg_scale"],
|
||||
dtype,
|
||||
do_true_cfg,
|
||||
device_kind,
|
||||
)
|
||||
if key in _baseline_cache:
|
||||
return _baseline_cache[key]
|
||||
|
||||
device = init_dist(0, 1, _find_free_port())
|
||||
initialize_model_parallel(pipeline_parallel_size=1)
|
||||
device = init_dist(0, 1, _find_free_port(), device_kind)
|
||||
try:
|
||||
initialize_model_parallel(pipeline_parallel_size=1, backend=_mp_backend(device_kind))
|
||||
|
||||
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
|
||||
test_config, dtype, device, do_true_cfg=do_true_cfg
|
||||
)
|
||||
|
||||
with torch.inference_mode():
|
||||
noise_pred = pipeline.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=test_config["cfg_scale"],
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=False,
|
||||
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
|
||||
test_config, dtype, device, do_true_cfg=do_true_cfg
|
||||
)
|
||||
|
||||
destroy_distributed_env()
|
||||
with torch.inference_mode():
|
||||
noise_pred = pipeline.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=test_config["cfg_scale"],
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=False,
|
||||
)
|
||||
finally:
|
||||
_cleanup_distributed()
|
||||
|
||||
_baseline_cache[key] = noise_pred.cpu()
|
||||
return _baseline_cache[key]
|
||||
@@ -527,11 +610,16 @@ def predict_noise_worker(
|
||||
do_true_cfg: bool,
|
||||
dtype: torch.dtype,
|
||||
test_config: dict,
|
||||
device_kind: DeviceKind,
|
||||
result_queue,
|
||||
):
|
||||
"""Generic predict-noise worker parameterized by PP and CFG topology."""
|
||||
device = init_dist(local_rank, world_size, master_port)
|
||||
initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size)
|
||||
device = init_dist(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(
|
||||
pipeline_parallel_size=pp_size,
|
||||
cfg_parallel_size=cfg_size,
|
||||
backend=_mp_backend(device_kind),
|
||||
)
|
||||
|
||||
pp_group = get_pp_group()
|
||||
cfg_rank = get_classifier_free_guidance_rank()
|
||||
@@ -548,8 +636,6 @@ def predict_noise_worker(
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=False,
|
||||
)
|
||||
# This worker exercises predict_noise_maybe_with_cfg directly, bypassing diffuse().
|
||||
# Flush the non-last PP rank's async send before barrier / process teardown.
|
||||
pipeline._sync_pp_send()
|
||||
|
||||
if pp_group.is_last_rank:
|
||||
@@ -561,77 +647,22 @@ def predict_noise_worker(
|
||||
|
||||
if torch.distributed.is_initialized():
|
||||
torch.distributed.barrier()
|
||||
destroy_distributed_env()
|
||||
_cleanup_distributed()
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol",
|
||||
[
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
False,
|
||||
torch.float32,
|
||||
4,
|
||||
100,
|
||||
1e-5,
|
||||
1e-5,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
|
||||
id="pp2-no_cfg-float32",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
False,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
|
||||
id="pp2-no_cfg-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
True,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
|
||||
id="pp2-seq_cfg-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
2,
|
||||
True,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"),
|
||||
id="pp2-cfg2-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
3,
|
||||
1,
|
||||
False,
|
||||
torch.bfloat16,
|
||||
6,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 3, reason="Need at least 3 GPUs"),
|
||||
id="pp3-no_cfg-bfloat16",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol):
|
||||
"""predict_noise_maybe_with_cfg output matches the single-GPU baseline across PP / CFG topologies."""
|
||||
def _run_predict_noise(
|
||||
*,
|
||||
pp_size: int,
|
||||
cfg_size: int,
|
||||
do_true_cfg: bool,
|
||||
dtype: torch.dtype,
|
||||
num_layers: int,
|
||||
input_seed: int,
|
||||
rtol: float,
|
||||
atol: float,
|
||||
device_kind: DeviceKind,
|
||||
master_port: str,
|
||||
) -> None:
|
||||
test_config = {
|
||||
"num_layers": num_layers,
|
||||
"dim": 64,
|
||||
@@ -641,22 +672,20 @@ def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_
|
||||
"input_seed": input_seed,
|
||||
}
|
||||
|
||||
baseline_out = compute_single_gpu_baseline(test_config, dtype, do_true_cfg)
|
||||
baseline_out = compute_single_gpu_baseline(test_config, dtype, do_true_cfg, device_kind)
|
||||
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
pp_q = manager.Queue()
|
||||
|
||||
world_size = pp_size * cfg_size
|
||||
port = _find_free_port()
|
||||
torch.multiprocessing.spawn(
|
||||
predict_noise_worker,
|
||||
args=(world_size, port, pp_size, cfg_size, do_true_cfg, dtype, test_config, pp_q),
|
||||
args=(world_size, master_port, pp_size, cfg_size, do_true_cfg, dtype, test_config, device_kind, pp_q),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
pp_out = pp_q.get()
|
||||
|
||||
assert baseline_out.shape == pp_out.shape
|
||||
torch.testing.assert_close(
|
||||
pp_out,
|
||||
@@ -667,35 +696,157 @@ def test_predict_noise(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_
|
||||
)
|
||||
|
||||
|
||||
_CPU_PREDICT_NOISE_CASES = [
|
||||
pytest.param(2, 1, False, 4, 100, 1e-5, 1e-5, id="pp2-no_cfg"),
|
||||
pytest.param(2, 1, True, 4, 100, 1e-5, 1e-5, id="pp2-seq_cfg"),
|
||||
pytest.param(2, 2, True, 4, 100, 1e-5, 1e-5, id="pp2-cfg2"),
|
||||
pytest.param(3, 1, False, 6, 100, 1e-5, 1e-5, id="pp3-no_cfg"),
|
||||
]
|
||||
|
||||
_GPU_PREDICT_NOISE_CASES = [
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
False,
|
||||
torch.float32,
|
||||
4,
|
||||
100,
|
||||
1e-5,
|
||||
1e-5,
|
||||
marks=_L4_TWO_GPU,
|
||||
id="pp2-no_cfg-float32",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
False,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=_L4_TWO_GPU,
|
||||
id="pp2-no_cfg-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
True,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=_L4_TWO_GPU,
|
||||
id="pp2-seq_cfg-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
2,
|
||||
True,
|
||||
torch.bfloat16,
|
||||
4,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=_L4_FOUR_GPU,
|
||||
id="pp2-cfg2-bfloat16",
|
||||
),
|
||||
pytest.param(
|
||||
3,
|
||||
1,
|
||||
False,
|
||||
torch.bfloat16,
|
||||
6,
|
||||
100,
|
||||
1e-2,
|
||||
1e-2,
|
||||
marks=_L4_THREE_GPU,
|
||||
id="pp3-no_cfg-bfloat16",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, num_layers, input_seed, rtol, atol",
|
||||
_CPU_PREDICT_NOISE_CASES,
|
||||
)
|
||||
def test_predict_noise(pp_size, cfg_size, do_true_cfg, num_layers, input_seed, rtol, atol):
|
||||
"""predict_noise_maybe_with_cfg output matches baseline across PP / CFG topologies."""
|
||||
_run_predict_noise(
|
||||
pp_size=pp_size,
|
||||
cfg_size=cfg_size,
|
||||
do_true_cfg=do_true_cfg,
|
||||
dtype=torch.float32,
|
||||
num_layers=num_layers,
|
||||
input_seed=input_seed,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
device_kind="cpu",
|
||||
master_port=_find_free_port(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol",
|
||||
_GPU_PREDICT_NOISE_CASES,
|
||||
)
|
||||
def test_predict_noise_parity(pp_size, cfg_size, do_true_cfg, dtype, num_layers, input_seed, rtol, atol):
|
||||
"""Nightly: predict_noise parity on real multi-GPU NCCL."""
|
||||
_run_predict_noise(
|
||||
pp_size=pp_size,
|
||||
cfg_size=cfg_size,
|
||||
do_true_cfg=do_true_cfg,
|
||||
dtype=dtype,
|
||||
num_layers=num_layers,
|
||||
input_seed=input_seed,
|
||||
rtol=rtol,
|
||||
atol=atol,
|
||||
device_kind="cuda",
|
||||
master_port=_find_free_port(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. scheduler_step_maybe_with_cfg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_scheduler_step_baseline(test_config: dict, do_true_cfg: bool) -> torch.Tensor:
|
||||
"""Single-GPU reference: predict_noise + scheduler_step."""
|
||||
device = init_dist(0, 1, _find_free_port())
|
||||
initialize_model_parallel(pipeline_parallel_size=1)
|
||||
def compute_scheduler_step_baseline(
|
||||
test_config: dict,
|
||||
do_true_cfg: bool,
|
||||
device_kind: DeviceKind,
|
||||
) -> torch.Tensor:
|
||||
"""Single-process reference: predict_noise + scheduler_step."""
|
||||
device = init_dist(0, 1, _find_free_port(), device_kind)
|
||||
try:
|
||||
initialize_model_parallel(pipeline_parallel_size=1, backend=_mp_backend(device_kind))
|
||||
|
||||
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
|
||||
test_config, torch.float32, device, do_true_cfg=do_true_cfg
|
||||
)
|
||||
latents = positive_kwargs["x"]
|
||||
t = torch.tensor(500, device=device)
|
||||
|
||||
with torch.inference_mode():
|
||||
noise_pred = pipeline.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=test_config["cfg_scale"],
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=False,
|
||||
)
|
||||
result = pipeline.scheduler_step_maybe_with_cfg(
|
||||
noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg
|
||||
pipeline, positive_kwargs, negative_kwargs = make_pipeline_and_inputs(
|
||||
test_config, torch.float32, device, do_true_cfg=do_true_cfg
|
||||
)
|
||||
latents = positive_kwargs["x"]
|
||||
t = torch.tensor(500, device=device)
|
||||
|
||||
destroy_distributed_env()
|
||||
with torch.inference_mode():
|
||||
noise_pred = pipeline.predict_noise_maybe_with_cfg(
|
||||
do_true_cfg=do_true_cfg,
|
||||
true_cfg_scale=test_config["cfg_scale"],
|
||||
positive_kwargs=positive_kwargs,
|
||||
negative_kwargs=negative_kwargs,
|
||||
cfg_normalize=False,
|
||||
)
|
||||
result = pipeline.scheduler_step_maybe_with_cfg(
|
||||
noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg
|
||||
)
|
||||
finally:
|
||||
_cleanup_distributed()
|
||||
return result.cpu()
|
||||
|
||||
|
||||
@@ -707,10 +858,15 @@ def scheduler_step_worker(
|
||||
cfg_size: int,
|
||||
do_true_cfg: bool,
|
||||
test_config: dict,
|
||||
device_kind: DeviceKind,
|
||||
result_queue,
|
||||
):
|
||||
device = init_dist(local_rank, world_size, master_port)
|
||||
initialize_model_parallel(pipeline_parallel_size=pp_size, cfg_parallel_size=cfg_size)
|
||||
device = init_dist(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(
|
||||
pipeline_parallel_size=pp_size,
|
||||
cfg_parallel_size=cfg_size,
|
||||
backend=_mp_backend(device_kind),
|
||||
)
|
||||
|
||||
pp_group = get_pp_group()
|
||||
cfg_rank = get_classifier_free_guidance_rank()
|
||||
@@ -732,43 +888,25 @@ def scheduler_step_worker(
|
||||
latents = pipeline.scheduler_step_maybe_with_cfg(
|
||||
noise_pred=noise_pred, t=t, latents=latents, do_true_cfg=do_true_cfg
|
||||
)
|
||||
# This worker exercises scheduler_step_maybe_with_cfg directly, bypassing diffuse().
|
||||
# Flush the last PP rank's async latent send before barrier / process teardown.
|
||||
pipeline._sync_pp_send()
|
||||
|
||||
if pp_group.is_first_rank and cfg_rank == 0:
|
||||
resolved = latents.contiguous()
|
||||
result_queue.put(resolved.cpu())
|
||||
result_queue.put(latents.contiguous().cpu())
|
||||
|
||||
if torch.distributed.is_initialized():
|
||||
torch.distributed.barrier()
|
||||
destroy_distributed_env()
|
||||
_cleanup_distributed()
|
||||
|
||||
|
||||
@pytest.mark.gpu
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, input_seed",
|
||||
[
|
||||
pytest.param(
|
||||
2,
|
||||
1,
|
||||
False,
|
||||
300,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 2, reason="Need at least 2 GPUs"),
|
||||
id="pp2-no_cfg",
|
||||
),
|
||||
pytest.param(
|
||||
2,
|
||||
2,
|
||||
True,
|
||||
600,
|
||||
marks=pytest.mark.skipif(current_omni_platform.get_device_count() < 4, reason="Need at least 4 GPUs"),
|
||||
id="pp2-cfg2-true_cfg",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed):
|
||||
"""Rank 0 latents after scheduler_step match the single-GPU baseline across PP / CFG topologies."""
|
||||
def _run_scheduler_step(
|
||||
*,
|
||||
pp_size: int,
|
||||
cfg_size: int,
|
||||
do_true_cfg: bool,
|
||||
input_seed: int,
|
||||
device_kind: DeviceKind,
|
||||
master_port: str,
|
||||
) -> None:
|
||||
test_config = {
|
||||
"num_layers": 4,
|
||||
"dim": 64,
|
||||
@@ -778,17 +916,16 @@ def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed):
|
||||
"input_seed": input_seed,
|
||||
}
|
||||
|
||||
baseline = compute_scheduler_step_baseline(test_config, do_true_cfg)
|
||||
baseline = compute_scheduler_step_baseline(test_config, do_true_cfg, device_kind)
|
||||
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
q = manager.Queue()
|
||||
|
||||
port = _find_free_port()
|
||||
world_size = pp_size * cfg_size
|
||||
torch.multiprocessing.spawn(
|
||||
scheduler_step_worker,
|
||||
args=(world_size, port, pp_size, cfg_size, do_true_cfg, test_config, q),
|
||||
args=(world_size, master_port, pp_size, cfg_size, do_true_cfg, test_config, device_kind, q),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
@@ -800,3 +937,47 @@ def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed):
|
||||
atol=0,
|
||||
msg=f"PP={pp_size} CFG={cfg_size} scheduler step latents on rank 0 do not match single-GPU baseline",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, input_seed",
|
||||
[
|
||||
pytest.param(2, 1, False, 300, id="pp2-no_cfg"),
|
||||
pytest.param(2, 2, True, 600, id="pp2-cfg2-true_cfg"),
|
||||
],
|
||||
)
|
||||
def test_scheduler_step(pp_size, cfg_size, do_true_cfg, input_seed):
|
||||
"""Rank 0 latents after scheduler_step match baseline across PP / CFG topologies."""
|
||||
_run_scheduler_step(
|
||||
pp_size=pp_size,
|
||||
cfg_size=cfg_size,
|
||||
do_true_cfg=do_true_cfg,
|
||||
input_seed=input_seed,
|
||||
device_kind="cpu",
|
||||
master_port=_find_free_port(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.parametrize(
|
||||
"pp_size, cfg_size, do_true_cfg, input_seed",
|
||||
[
|
||||
pytest.param(2, 1, False, 300, marks=_L4_TWO_GPU, id="pp2-no_cfg"),
|
||||
pytest.param(2, 2, True, 600, marks=_L4_FOUR_GPU, id="pp2-cfg2-true_cfg"),
|
||||
],
|
||||
)
|
||||
def test_scheduler_step_parity(pp_size, cfg_size, do_true_cfg, input_seed):
|
||||
"""Nightly: scheduler_step parity on real multi-GPU NCCL."""
|
||||
_run_scheduler_step(
|
||||
pp_size=pp_size,
|
||||
cfg_size=cfg_size,
|
||||
do_true_cfg=do_true_cfg,
|
||||
input_seed=input_seed,
|
||||
device_kind="cuda",
|
||||
master_port=_find_free_port(),
|
||||
)
|
||||
+20
-110
@@ -1,16 +1,14 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Z-Image end-to-end tests for diffusion parallelism.
|
||||
"""DiT tensor-parallel parity and perf checks for diffusion models.
|
||||
|
||||
This file currently covers:
|
||||
- DiT tensor parallelism (TP=2) vs TP=1.
|
||||
- VAE patch parallelism (vae_patch_parallel_size=2) vs baseline on TP=2.
|
||||
|
||||
Note: CUDA-only (>=2 GPUs). We use `enforce_eager=False` (default) to enable
|
||||
`torch.compile`.
|
||||
Compares TP=2 against TP=1 on output correctness, latency, and peak memory.
|
||||
Uses ``Tongyi-MAI/Z-Image-Turbo`` as the parity model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -31,22 +29,15 @@ from vllm_omni.platforms import current_omni_platform
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
PROMPT = "a photo of a cat sitting on a laptop keyboard"
|
||||
|
||||
|
||||
def _get_zimage_model() -> str:
|
||||
# Allow overriding the model for local/offline environments.
|
||||
# Can be either a HuggingFace repo id or a local path.
|
||||
return os.environ.get("VLLM_TEST_ZIMAGE_MODEL", "Tongyi-MAI/Z-Image-Turbo")
|
||||
DEFAULT_MODEL = "Tongyi-MAI/Z-Image-Turbo"
|
||||
|
||||
|
||||
def _pil_to_float_rgb_tensor(img: Image.Image) -> torch.Tensor:
|
||||
"""Convert PIL image to float32 RGB tensor in [0, 1] with shape [H, W, 3]."""
|
||||
arr = np.asarray(img.convert("RGB"), dtype=np.float32) / 255.0
|
||||
return torch.from_numpy(arr)
|
||||
|
||||
|
||||
def _diff_metrics(a: Image.Image, b: Image.Image) -> tuple[float, float]:
|
||||
"""Return (mean_abs_diff, p99_abs_diff) over RGB pixels in [0, 1]."""
|
||||
ta = _pil_to_float_rgb_tensor(a)
|
||||
tb = _pil_to_float_rgb_tensor(b)
|
||||
assert ta.shape == tb.shape, f"Image shapes differ: {ta.shape} vs {tb.shape}"
|
||||
@@ -71,7 +62,7 @@ def _extract_single_image(outputs) -> Image.Image:
|
||||
return images[0]
|
||||
|
||||
|
||||
def _run_zimage_generate(
|
||||
def _run_generate(
|
||||
*,
|
||||
tp_size: int,
|
||||
height: int,
|
||||
@@ -79,8 +70,6 @@ def _run_zimage_generate(
|
||||
num_inference_steps: int,
|
||||
seed: int,
|
||||
enforce_eager: bool,
|
||||
vae_use_tiling: bool = False,
|
||||
vae_patch_parallel_size: int = 1,
|
||||
num_requests: int = 4,
|
||||
) -> tuple[Image.Image, float, float]:
|
||||
if num_requests < 2:
|
||||
@@ -91,25 +80,11 @@ def _run_zimage_generate(
|
||||
monitor = DeviceMemoryMonitor(device_index=device_index, interval=0.02)
|
||||
monitor.start()
|
||||
try:
|
||||
# Each run needs a distinct DiffusionParallelConfig; use OmniRunner per call (not the
|
||||
# parametrized omni_runner fixture, which is fixed per module).
|
||||
with OmniRunner(
|
||||
_get_zimage_model(),
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
tensor_parallel_size=tp_size,
|
||||
vae_patch_parallel_size=vae_patch_parallel_size,
|
||||
),
|
||||
DEFAULT_MODEL,
|
||||
parallel_config=DiffusionParallelConfig(tensor_parallel_size=tp_size),
|
||||
enforce_eager=enforce_eager,
|
||||
vae_use_tiling=vae_use_tiling,
|
||||
) as runner:
|
||||
# NOTE: Omni closes itself when a generate() call is exhausted.
|
||||
# To avoid measuring teardown time (process shutdown, memory cleanup),
|
||||
# we measure the latency to produce *subsequent* outputs within a single
|
||||
# generator run.
|
||||
#
|
||||
# This also serves as a warmup: the first output may include extra
|
||||
# compilation/caching overhead, while later outputs are closer to
|
||||
# steady-state inference.
|
||||
gen = runner.omni.generate(
|
||||
[PROMPT] * num_requests,
|
||||
OmniDiffusionSamplingParams(
|
||||
@@ -134,14 +109,11 @@ def _run_zimage_generate(
|
||||
per_request_times_s.append(t_now - t_prev)
|
||||
t_prev = t_now
|
||||
|
||||
# Ensure the generator is fully consumed so it can clean up.
|
||||
for _ in gen:
|
||||
pass
|
||||
|
||||
median_time_s = float(np.median(per_request_times_s))
|
||||
|
||||
peak_memory_mb = monitor.peak_used_mb
|
||||
|
||||
return _extract_single_image([last_output]), median_time_s, peak_memory_mb
|
||||
finally:
|
||||
monitor.stop()
|
||||
@@ -151,20 +123,20 @@ def _run_zimage_generate(
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
def test_tensor_parallel_tp2(tmp_path: Path):
|
||||
if current_omni_platform.is_npu():
|
||||
pytest.skip("Z-Image TP e2e test is only supported on CUDA and ROCm for now.")
|
||||
pytest.skip("Tensor parallel parity test is only supported on CUDA and ROCm for now.")
|
||||
if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
|
||||
pytest.skip("Z-Image TP=2 requires >= 2 devices.")
|
||||
pytest.skip("Tensor parallel TP=2 requires >= 2 devices.")
|
||||
|
||||
model_name = DEFAULT_MODEL
|
||||
enforce_eager = False
|
||||
|
||||
height = 512
|
||||
width = 512
|
||||
num_inference_steps = 2
|
||||
seed = 42
|
||||
|
||||
tp1_img, tp1_time_s, tp1_peak_mem = _run_zimage_generate(
|
||||
tp1_img, tp1_time_s, tp1_peak_mem = _run_generate(
|
||||
tp_size=1,
|
||||
height=height,
|
||||
width=width,
|
||||
@@ -172,7 +144,7 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
seed=seed,
|
||||
enforce_eager=enforce_eager,
|
||||
)
|
||||
tp2_img, tp2_time_s, tp2_peak_mem = _run_zimage_generate(
|
||||
tp2_img, tp2_time_s, tp2_peak_mem = _run_generate(
|
||||
tp_size=2,
|
||||
height=height,
|
||||
width=width,
|
||||
@@ -181,8 +153,8 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
enforce_eager=enforce_eager,
|
||||
)
|
||||
|
||||
tp1_path = tmp_path / "zimage_tp1.png"
|
||||
tp2_path = tmp_path / "zimage_tp2.png"
|
||||
tp1_path = tmp_path / "tp1.png"
|
||||
tp2_path = tmp_path / "tp2.png"
|
||||
tp1_img.save(tp1_path)
|
||||
tp2_img.save(tp2_path)
|
||||
|
||||
@@ -193,7 +165,7 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
mean_threshold = 3e-2
|
||||
p99_threshold = 2.5e-1
|
||||
print(
|
||||
"Z-Image TP image diff stats (TP=1 vs TP=2): "
|
||||
f"{model_name} TP image diff (TP=1 vs TP=2): "
|
||||
f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
|
||||
f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
|
||||
f"tp1_img={tp1_path}, tp2_img={tp2_path}"
|
||||
@@ -203,73 +175,11 @@ def test_zimage_tensor_parallel_tp2(tmp_path: Path):
|
||||
f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
|
||||
)
|
||||
|
||||
print(f"Z-Image TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
|
||||
# ROCm is not optimized TP2 can be slower than TP1
|
||||
print(f"TP perf (lower is better): tp1_time_s={tp1_time_s:.6f}, tp2_time_s={tp2_time_s:.6f}")
|
||||
if not current_omni_platform.is_rocm():
|
||||
assert tp2_time_s < tp1_time_s, f"Expected TP=2 to be faster than TP=1 (tp1={tp1_time_s}, tp2={tp2_time_s})"
|
||||
|
||||
print(f"Z-Image TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
|
||||
print(f"TP peak memory (MB): tp1_peak_mem={tp1_peak_mem:.2f}, tp2_peak_mem={tp2_peak_mem:.2f}")
|
||||
assert tp2_peak_mem < tp1_peak_mem, (
|
||||
f"Expected TP=2 to use less peak memory than TP=1 (tp1={tp1_peak_mem}, tp2={tp2_peak_mem})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.advanced_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
def test_zimage_vae_patch_parallel_tp2(tmp_path: Path):
|
||||
if current_omni_platform.is_npu():
|
||||
pytest.skip("Z-Image VAE patch parallel e2e test is only supported on CUDA and ROCm for now.")
|
||||
if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
|
||||
pytest.skip("Z-Image VAE patch parallel TP=2 requires >= 2 devices.")
|
||||
|
||||
enforce_eager = False
|
||||
|
||||
# Use a larger image to ensure there are multiple VAE tiles.
|
||||
height = 1152
|
||||
width = 1152
|
||||
num_inference_steps = 2
|
||||
seed = 42
|
||||
|
||||
baseline_img, _baseline_time_s, _baseline_peak_mem = _run_zimage_generate(
|
||||
tp_size=2,
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=num_inference_steps,
|
||||
seed=seed,
|
||||
enforce_eager=enforce_eager,
|
||||
vae_use_tiling=True,
|
||||
vae_patch_parallel_size=1,
|
||||
num_requests=2,
|
||||
)
|
||||
pp2_img, _pp2_time_s, _pp2_peak_mem = _run_zimage_generate(
|
||||
tp_size=2,
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=num_inference_steps,
|
||||
seed=seed,
|
||||
enforce_eager=enforce_eager,
|
||||
vae_use_tiling=True,
|
||||
vae_patch_parallel_size=2,
|
||||
num_requests=2,
|
||||
)
|
||||
|
||||
baseline_path = tmp_path / "zimage_tp2_vae_pp1.png"
|
||||
pp2_path = tmp_path / "zimage_tp2_vae_pp2.png"
|
||||
baseline_img.save(baseline_path)
|
||||
pp2_img.save(pp2_path)
|
||||
|
||||
mean_abs_diff, p99_abs_diff = _diff_metrics(baseline_img, pp2_img)
|
||||
mean_threshold = 5e-3
|
||||
p99_threshold = 1e-1
|
||||
print(
|
||||
"Z-Image VAE patch parallel image diff stats (TP=2, pp=1 vs pp=2): "
|
||||
f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
|
||||
f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
|
||||
f"pp1_img={baseline_path}, pp2_img={pp2_path}"
|
||||
)
|
||||
assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
|
||||
f"Image diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e} "
|
||||
f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
|
||||
)
|
||||
@@ -1,151 +1,181 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""VAE patch-parallel decode parity tests for diffusion models.
|
||||
|
||||
Compares ``vae_patch_parallel_size=2`` against ``vae_patch_parallel_size=1`` on
|
||||
TP=2 with VAE tiling enabled. Covers one video model (Wan T2V) and one image
|
||||
model (Qwen-Image). CUDA/ROCm-only (>=2 devices).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
# ruff: noqa: E402
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
import time
|
||||
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn"
|
||||
|
||||
all_model_configs = [
|
||||
{
|
||||
"model_name": "Wan-AI/Wan2.2-TI2V-5B-Diffusers",
|
||||
"out_width": 1280,
|
||||
"out_height": 720,
|
||||
"out_frames": 16,
|
||||
},
|
||||
{
|
||||
"model_name": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
|
||||
"out_width": 1280,
|
||||
"out_height": 720,
|
||||
"out_frames": 16,
|
||||
},
|
||||
{
|
||||
"model_name": "Wan-AI/Wan2.2-I2V-A14B-Diffusers",
|
||||
"out_width": 1280,
|
||||
"out_height": 720,
|
||||
"out_frames": 16,
|
||||
},
|
||||
{
|
||||
"model_name": "Qwen/Qwen-Image",
|
||||
"out_width": 3072,
|
||||
"out_height": 3072,
|
||||
"out_frames": 1,
|
||||
},
|
||||
# {
|
||||
# "model_name": "stepfun-ai/NextStep-1.1",
|
||||
# "out_width": 3072,
|
||||
# "out_height": 3072,
|
||||
# "out_frames": 1,
|
||||
# },
|
||||
PROMPT = "A cat sitting on a table"
|
||||
|
||||
MODEL_CASES = [
|
||||
pytest.param(
|
||||
{
|
||||
"model_name": "Wan-AI/Wan2.2-T2V-A14B-Diffusers",
|
||||
"height": 512,
|
||||
"width": 512,
|
||||
"num_frames": 5,
|
||||
"needs_image": False,
|
||||
"is_moe": False,
|
||||
"mean_threshold": 3e-2,
|
||||
"p99_threshold": 3e-2,
|
||||
},
|
||||
id="wan22_t2v_a14b",
|
||||
),
|
||||
pytest.param(
|
||||
{
|
||||
"model_name": "Qwen/Qwen-Image",
|
||||
"height": 1152,
|
||||
"width": 1152,
|
||||
"num_frames": 1,
|
||||
"needs_image": False,
|
||||
"is_moe": False,
|
||||
"mean_threshold": 5e-3,
|
||||
"p99_threshold": 1e-1,
|
||||
},
|
||||
id="qwen_image",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def is_nextstep_model(model_name: str) -> bool:
|
||||
"""Check if the model is a NextStep model by reading its config."""
|
||||
from vllm.transformers_utils.config import get_hf_file_to_dict
|
||||
|
||||
try:
|
||||
cfg = get_hf_file_to_dict("config.json", model_name)
|
||||
if cfg and cfg.get("model_type") == "nextstep":
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
def _to_float_array(data: Any) -> np.ndarray:
|
||||
if isinstance(data, Image.Image):
|
||||
return np.asarray(data.convert("RGB"), dtype=np.float32) / 255.0
|
||||
if isinstance(data, torch.Tensor):
|
||||
arr = data.detach().cpu().numpy()
|
||||
else:
|
||||
arr = np.asarray(data)
|
||||
if arr.dtype == np.uint8:
|
||||
return arr.astype(np.float32) / 255.0
|
||||
return arr.astype(np.float32)
|
||||
|
||||
|
||||
def model_run(model_configs, tp, out_height, out_width, out_frames, using_tile, vae_patch_parallel_size=1):
|
||||
def _extract_output_array(output: Any) -> np.ndarray:
|
||||
req_out = output.request_output
|
||||
payload = req_out.images[0]
|
||||
return _to_float_array(payload)
|
||||
|
||||
|
||||
def _diff_metrics(a: np.ndarray, b: np.ndarray) -> tuple[float, float]:
|
||||
assert a.shape == b.shape, f"Output shapes differ: {a.shape} vs {b.shape}"
|
||||
abs_diff = np.abs(a - b)
|
||||
return float(abs_diff.mean()), float(np.quantile(abs_diff, 0.99))
|
||||
|
||||
|
||||
def _save_preview(arr: np.ndarray, path: Path) -> None:
|
||||
"""Save a single RGB frame preview; video tensors use the first frame."""
|
||||
preview = np.asarray(arr)
|
||||
# (B, T, H, W, C) video
|
||||
if preview.ndim == 5:
|
||||
preview = preview[0, 0]
|
||||
# (T, H, W, C) video or (B, H, W, C) image batch
|
||||
elif preview.ndim == 4:
|
||||
preview = preview[0]
|
||||
if preview.ndim != 3 or preview.shape[-1] not in (3, 4):
|
||||
raise ValueError(f"Expected HWC RGB(A) preview, got shape {arr.shape} -> {preview.shape}")
|
||||
preview = np.clip(preview[..., :3], 0.0, 1.0)
|
||||
Image.fromarray((preview * 255.0).astype(np.uint8)).save(path)
|
||||
|
||||
|
||||
def _build_sampling_params(model_case: dict[str, Any], seed: int) -> OmniDiffusionSamplingParams:
|
||||
kwargs: dict[str, Any] = {
|
||||
"height": model_case["height"],
|
||||
"width": model_case["width"],
|
||||
"num_frames": model_case["num_frames"],
|
||||
"num_inference_steps": 2,
|
||||
"seed": seed,
|
||||
}
|
||||
if model_case["model_name"].startswith("Wan-AI/"):
|
||||
kwargs["guidance_scale"] = 4.0
|
||||
if model_case["is_moe"]:
|
||||
kwargs["guidance_scale_2"] = 1.0
|
||||
kwargs["boundary_ratio"] = 0.5
|
||||
return OmniDiffusionSamplingParams(**kwargs)
|
||||
|
||||
|
||||
def _run_generate(
|
||||
*,
|
||||
model_case: dict[str, Any],
|
||||
vae_patch_parallel_size: int,
|
||||
seed: int,
|
||||
) -> np.ndarray:
|
||||
current_omni_platform.empty_cache()
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
tensor_parallel_size=tp,
|
||||
tensor_parallel_size=2,
|
||||
vae_patch_parallel_size=vae_patch_parallel_size,
|
||||
)
|
||||
|
||||
omni_kwargs = {
|
||||
"vae_use_tiling": using_tile,
|
||||
"parallel_config": parallel_config,
|
||||
}
|
||||
use_nextstep = is_nextstep_model(model_configs["model_name"])
|
||||
if use_nextstep:
|
||||
# NextStep-1.1 requires explicit pipeline class
|
||||
omni_kwargs["model_class_name"] = "NextStep11Pipeline"
|
||||
|
||||
with OmniRunner(model_configs["model_name"], **omni_kwargs) as runner:
|
||||
m = runner.omni
|
||||
image = Image.new("RGB", (out_width, out_height), (0, 0, 0))
|
||||
start = time.perf_counter()
|
||||
outputs = m.generate(
|
||||
{
|
||||
"prompt": "A cat sitting on a table",
|
||||
"multi_modal_data": {"image": image},
|
||||
},
|
||||
sampling_params_list=OmniDiffusionSamplingParams(
|
||||
height=model_configs["out_height"],
|
||||
width=model_configs["out_width"],
|
||||
num_frames=model_configs["out_frames"],
|
||||
num_inference_steps=2,
|
||||
generator=torch.Generator(current_omni_platform.device_type).manual_seed(42),
|
||||
),
|
||||
with OmniRunner(
|
||||
model_case["model_name"],
|
||||
parallel_config=parallel_config,
|
||||
vae_use_tiling=True,
|
||||
enforce_eager=False,
|
||||
) as runner:
|
||||
request: dict[str, Any] = {"prompt": model_case.get("prompt", PROMPT)}
|
||||
if model_case["needs_image"]:
|
||||
request["multi_modal_data"] = {
|
||||
"image": Image.new("RGB", (model_case["width"], model_case["height"]), (0, 0, 0))
|
||||
}
|
||||
outputs = runner.omni.generate(
|
||||
request,
|
||||
sampling_params_list=_build_sampling_params(model_case, seed),
|
||||
)
|
||||
end = time.perf_counter()
|
||||
first_output = outputs[0]
|
||||
req_out = first_output.request_output
|
||||
frames = req_out.images[0]
|
||||
if isinstance(frames, torch.Tensor):
|
||||
frames = frames.detach().cpu().numpy()
|
||||
elif isinstance(frames, Image.Image):
|
||||
frames = np.array(frames)
|
||||
# frames shape: (batch, num_frames, height, width, channels)
|
||||
cost = (end - start) * 1000
|
||||
return frames, cost
|
||||
cleanup_dist_env_and_memory()
|
||||
return _extract_output_array(outputs[0])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_configs", all_model_configs)
|
||||
def test_vae_parallel_model(model_configs: str):
|
||||
parallel_result, parallel_time = model_run(
|
||||
model_configs=model_configs,
|
||||
tp=2,
|
||||
out_width=1280,
|
||||
out_height=704,
|
||||
out_frames=5,
|
||||
using_tile=True,
|
||||
vae_patch_parallel_size=2,
|
||||
)
|
||||
non_parallel_result, non_parallel_time = model_run(
|
||||
model_configs=model_configs,
|
||||
tp=2,
|
||||
out_width=1280,
|
||||
out_height=704,
|
||||
out_frames=5,
|
||||
using_tile=True,
|
||||
vae_patch_parallel_size=1,
|
||||
)
|
||||
result_diff = np.abs(non_parallel_result - parallel_result)
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards={"cuda": 4, "rocm": 2})
|
||||
@pytest.mark.parametrize("model_case", MODEL_CASES)
|
||||
def test_vae_patch_parallel_tp2(model_case: dict[str, Any], tmp_path: Path):
|
||||
if current_omni_platform.is_npu():
|
||||
pytest.skip("VAE patch parallel test is only supported on CUDA and ROCm for now.")
|
||||
if not current_omni_platform.is_available() or current_omni_platform.device_count() < 2:
|
||||
pytest.skip("VAE patch parallel TP=2 requires >= 2 devices.")
|
||||
|
||||
mean_threshold = 3e-2
|
||||
max_threshold = 3e-2 # they should be totally same
|
||||
seed = 42
|
||||
model_id = model_case["model_name"].split("/")[-1]
|
||||
|
||||
baseline = _run_generate(model_case=model_case, vae_patch_parallel_size=1, seed=seed)
|
||||
parallel = _run_generate(model_case=model_case, vae_patch_parallel_size=2, seed=seed)
|
||||
|
||||
baseline_path = tmp_path / f"{model_id}_tp2_vae_pp1.png"
|
||||
parallel_path = tmp_path / f"{model_id}_tp2_vae_pp2.png"
|
||||
_save_preview(baseline, baseline_path)
|
||||
_save_preview(parallel, parallel_path)
|
||||
|
||||
mean_abs_diff, p99_abs_diff = _diff_metrics(baseline, parallel)
|
||||
mean_threshold = model_case["mean_threshold"]
|
||||
p99_threshold = model_case["p99_threshold"]
|
||||
print(
|
||||
f"{model_configs['model_name']} TP = 2 (tile + parallel vs tile): "
|
||||
f"mean_abs_diff={result_diff.mean():.6e}, max_abs_diff={result_diff.max():.6e}; "
|
||||
f"thresholds: mean<={mean_threshold:.6e}, max<={max_threshold:.6e}; "
|
||||
f"parallel generate take time: {parallel_time:.2f} ms, non-parallel take time: {non_parallel_time:.2f} ms"
|
||||
f"{model_case['model_name']} VAE patch parallel diff (TP=2, pp=1 vs pp=2): "
|
||||
f"mean_abs_diff={mean_abs_diff:.6e}, p99_abs_diff={p99_abs_diff:.6e}; "
|
||||
f"thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e}; "
|
||||
f"pp1_preview={baseline_path}, pp2_preview={parallel_path}"
|
||||
)
|
||||
assert mean_abs_diff <= mean_threshold and p99_abs_diff <= p99_threshold, (
|
||||
f"Output diff exceeded threshold: mean_abs_diff={mean_abs_diff:.6e}, "
|
||||
f"p99_abs_diff={p99_abs_diff:.6e} "
|
||||
f"(thresholds: mean<={mean_threshold:.6e}, p99<={p99_threshold:.6e})"
|
||||
)
|
||||
# assert non_parallel_time > parallel_time
|
||||
assert result_diff.mean() < mean_threshold
|
||||
assert result_diff.max() < max_threshold
|
||||
@@ -12,6 +12,9 @@ from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.hooks.base import HookRegistry, ModelHook
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
DEFAULT_OUT = "ECHO"
|
||||
OVERRIDE_OUT = "OVERRIDE"
|
||||
INPUT_KWARG = "inp"
|
||||
|
||||
@@ -13,6 +13,8 @@ from __future__ import annotations
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _apply_rotary_emb_helios_original(
|
||||
hidden_states: torch.Tensor,
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,114 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Tests that DreamZero's cross-attention KV cache is actually init'd and reused.
|
||||
|
||||
DreamZero's I2V cross-attention caches the text k/v and image k_img/v_img in a
|
||||
per-layer ``crossattn_cache`` dict: they are computed on the first forward of a
|
||||
session (``is_init`` False -> True) and reused on every later step, because the
|
||||
text/image context is session-invariant. Only the query (which depends on the
|
||||
per-step hidden state) is recomputed.
|
||||
|
||||
These tests lock in that ``WanI2VCrossAttention.forward`` initialises the cache
|
||||
on the first call and reuses it (not recompute) on later calls, and recomputes
|
||||
every call when no cache is provided.
|
||||
|
||||
CPU-only: ``WanI2VCrossAttention.__init__`` needs a tensor-parallel group (it
|
||||
builds ColumnParallel/RowParallel linears), so we bypass ``__init__`` and stub
|
||||
the projections/attention to count calls and exercise only the caching logic.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.causal_wan_model import WanI2VCrossAttention
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_CLIP_LEN = 257 # image tokens the I2V cross-attn splits off the front of context
|
||||
|
||||
|
||||
def _make_cross_attn(n_heads: int = 2, head_dim: int = 4) -> WanI2VCrossAttention:
|
||||
"""A WanI2VCrossAttention whose submodules are call-counting stubs.
|
||||
|
||||
``__init__`` is bypassed (it needs a TP group); we only wire what
|
||||
``forward`` touches. Each projection returns a [B, S, n*head_dim] tensor so
|
||||
the subsequent ``unflatten(2, (n, head_dim))`` works, and is a MagicMock so
|
||||
we can assert how many times it was invoked.
|
||||
"""
|
||||
inner = n_heads * head_dim
|
||||
module = WanI2VCrossAttention.__new__(WanI2VCrossAttention)
|
||||
module.tp_num_heads = n_heads
|
||||
module.head_dim = head_dim
|
||||
|
||||
def _proj() -> MagicMock:
|
||||
return MagicMock(side_effect=lambda t: torch.zeros(t.shape[0], t.shape[1], inner))
|
||||
|
||||
module.q = _proj()
|
||||
module.k = _proj()
|
||||
module.v = _proj()
|
||||
module.k_img = _proj()
|
||||
module.v_img = _proj()
|
||||
module.norm_q = lambda t: t
|
||||
module.norm_k = lambda t: t
|
||||
module.norm_k_img = lambda t: t
|
||||
# attn returns the query unchanged ([B, S, n, d]); flatten(2) downstream is happy.
|
||||
module.attn = lambda q, k, v: q
|
||||
module.o = lambda t: t
|
||||
return module
|
||||
|
||||
|
||||
def _fresh_cache() -> dict:
|
||||
return {"is_init": False, "k": None, "v": None, "k_img": None, "v_img": None}
|
||||
|
||||
|
||||
def _inputs(inner: int = 8):
|
||||
x = torch.randn(1, 3, inner) # B=1, query_len=3
|
||||
context = torch.randn(1, _CLIP_LEN + 5, inner) # 257 image tokens + 5 text tokens
|
||||
return x, context
|
||||
|
||||
|
||||
def test_cache_inits_on_first_call_and_reuses_on_second() -> None:
|
||||
module = _make_cross_attn()
|
||||
cache = _fresh_cache()
|
||||
x, context = _inputs()
|
||||
|
||||
# First call: cache is initialised and every k/v projection runs once.
|
||||
module.forward(x, context, crossattn_cache=cache)
|
||||
assert cache["is_init"] is True
|
||||
for key in ("k", "v", "k_img", "v_img"):
|
||||
assert cache[key] is not None, f"{key} should be cached after first call"
|
||||
assert module.k.call_count == 1
|
||||
assert module.v.call_count == 1
|
||||
assert module.k_img.call_count == 1
|
||||
assert module.v_img.call_count == 1
|
||||
|
||||
cached = {key: cache[key] for key in ("k", "v", "k_img", "v_img")}
|
||||
|
||||
# Second call: k/v/k_img/v_img are read from cache, NOT recomputed.
|
||||
module.forward(x, context, crossattn_cache=cache)
|
||||
assert module.k.call_count == 1
|
||||
assert module.v.call_count == 1
|
||||
assert module.k_img.call_count == 1
|
||||
assert module.v_img.call_count == 1
|
||||
for key, tensor in cached.items():
|
||||
assert cache[key] is tensor, f"{key} should be the same cached object"
|
||||
|
||||
# The query always depends on the per-step hidden state, so it is recomputed.
|
||||
assert module.q.call_count == 2
|
||||
|
||||
|
||||
def test_no_cache_recomputes_every_call() -> None:
|
||||
module = _make_cross_attn()
|
||||
x, context = _inputs()
|
||||
|
||||
module.forward(x, context, crossattn_cache=None)
|
||||
module.forward(x, context, crossattn_cache=None)
|
||||
|
||||
# Without a cache, the image/text k/v are recomputed on every call.
|
||||
assert module.k_img.call_count == 2
|
||||
assert module.v_img.call_count == 2
|
||||
assert module.k.call_count == 2
|
||||
assert module.v.call_count == 2
|
||||
@@ -0,0 +1,57 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Every shipped DreamZero deploy config must select the AR-Diffusion engine.
|
||||
|
||||
``DreamZeroPipeline.__init__`` rejects any other engine, so a config missing
|
||||
``engine_backend`` fails at worker startup rather than at request time. That
|
||||
only surfaces in slow multi-GPU e2e serving tests (see issue #5046), so assert
|
||||
it here on CPU instead.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tests.helpers.stage_config import get_deploy_config_path
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
|
||||
from vllm_omni.experimental.ar_diffusion.engine import ARDiffusionEngine
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Discover siblings of a known config so a future dreamzero*.yaml is covered
|
||||
# without editing this test.
|
||||
DEPLOY_DIR = Path(get_deploy_config_path("dreamzero.yaml")).parent
|
||||
|
||||
|
||||
def _dreamzero_stages() -> list[tuple[str, int, dict]]:
|
||||
stages = []
|
||||
for name in sorted(path.name for path in DEPLOY_DIR.glob("dreamzero*.yaml")):
|
||||
config = yaml.safe_load(Path(get_deploy_config_path(name)).read_text())
|
||||
for index, stage in enumerate(config.get("stages") or []):
|
||||
stages.append((name, index, stage))
|
||||
return stages
|
||||
|
||||
|
||||
def test_dreamzero_deploy_configs_exist() -> None:
|
||||
assert _dreamzero_stages(), f"no DreamZero deploy configs found under {DEPLOY_DIR}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("config_name", "stage_index", "stage"),
|
||||
_dreamzero_stages(),
|
||||
ids=lambda value: value if isinstance(value, str) else None,
|
||||
)
|
||||
def test_dreamzero_stage_selects_ar_diffusion_engine(config_name: str, stage_index: int, stage: dict) -> None:
|
||||
backend = stage.get("engine_backend")
|
||||
assert backend, (
|
||||
f"{config_name} stage {stage_index} does not set engine_backend; "
|
||||
"DreamZeroPipeline requires the AR-Diffusion engine"
|
||||
)
|
||||
# Resolving also proves the import path is real, not just a lookalike string.
|
||||
engine_class = DiffusionEngine.resolve_engine_class(SimpleNamespace(engine_backend=backend))
|
||||
assert issubclass(engine_class, ARDiffusionEngine), (
|
||||
f"{config_name} stage {stage_index} resolves to {engine_class.__name__}, not an AR-Diffusion engine"
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.utils import (
|
||||
DEFAULT_CFG_SCALE,
|
||||
DEFAULT_EMBODIMENT_NAME_TO_ID,
|
||||
DEFAULT_NEGATIVE_PROMPT,
|
||||
DEFAULT_NUM_INFERENCE_STEPS,
|
||||
DEFAULT_SEED,
|
||||
DEFAULT_SIGMA_SHIFT,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_dreamzero_default_constants_match_source_baseline():
|
||||
assert DEFAULT_NUM_INFERENCE_STEPS == 16
|
||||
assert DEFAULT_CFG_SCALE == 5.0
|
||||
assert DEFAULT_SIGMA_SHIFT == 5.0
|
||||
assert DEFAULT_SEED == 1140
|
||||
assert "worst quality" in DEFAULT_NEGATIVE_PROMPT
|
||||
assert DEFAULT_EMBODIMENT_NAME_TO_ID["oxe_droid"] == 17
|
||||
@@ -0,0 +1,112 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Equivalence tests for DreamZero's fused q/k qk-norm TP all-reduce.
|
||||
|
||||
`fused_qk_rms_norm` packs the q and k per-token sum-of-squares into one tensor
|
||||
and issues a single tensor-parallel all-reduce instead of two. These tests lock
|
||||
in that it is numerically identical to applying `norm_q`/`norm_k` separately,
|
||||
and that it really issues exactly one (fused) all-reduce.
|
||||
|
||||
CPU-only: the TP world size and the collective are mocked, so a single process
|
||||
simulates one rank (all-reduce is identity). This exercises the cat/split and
|
||||
count-scaling logic that a real multi-rank run depends on.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.causal_wan_model import (
|
||||
DistributedRMSNorm,
|
||||
fused_qk_rms_norm,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
_MODULE = "vllm_omni.diffusion.models.dreamzero.causal_wan_model"
|
||||
|
||||
|
||||
def _make_norms(dim: int) -> tuple[DistributedRMSNorm, DistributedRMSNorm]:
|
||||
"""Two norms with distinct random weights so a swapped q/k slice is caught."""
|
||||
torch.manual_seed(0)
|
||||
norm_q = DistributedRMSNorm(dim)
|
||||
norm_k = DistributedRMSNorm(dim)
|
||||
norm_q.weight.data.normal_()
|
||||
norm_k.weight.data.normal_()
|
||||
return norm_q, norm_k
|
||||
|
||||
|
||||
def _identity_all_reduce(tensor: torch.Tensor) -> torch.Tensor:
|
||||
"""Single-rank all-reduce: the reduction over one rank is the identity."""
|
||||
return tensor
|
||||
|
||||
|
||||
def test_fused_matches_separate_tp1() -> None:
|
||||
dim = 64
|
||||
norm_q, norm_k = _make_norms(dim)
|
||||
q = torch.randn(2, 5, dim)
|
||||
k = torch.randn(2, 5, dim)
|
||||
|
||||
with patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=1):
|
||||
ref_q, ref_k = norm_q(q), norm_k(k)
|
||||
fused_q, fused_k = fused_qk_rms_norm(norm_q, norm_k, q, k)
|
||||
|
||||
torch.testing.assert_close(fused_q, ref_q, rtol=0, atol=0)
|
||||
torch.testing.assert_close(fused_k, ref_k, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_fused_matches_separate_tp_gt1() -> None:
|
||||
dim = 64
|
||||
norm_q, norm_k = _make_norms(dim)
|
||||
q = torch.randn(2, 5, dim)
|
||||
k = torch.randn(2, 5, dim)
|
||||
|
||||
# tp_size=2 exercises the reduce branch and the `global_count *= tp_size`
|
||||
# path in both the per-norm forward and the fused helper.
|
||||
with (
|
||||
patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=2),
|
||||
patch(f"{_MODULE}.tensor_model_parallel_all_reduce", side_effect=_identity_all_reduce),
|
||||
):
|
||||
ref_q, ref_k = norm_q(q), norm_k(k)
|
||||
fused_q, fused_k = fused_qk_rms_norm(norm_q, norm_k, q, k)
|
||||
|
||||
torch.testing.assert_close(fused_q, ref_q, rtol=0, atol=0)
|
||||
torch.testing.assert_close(fused_k, ref_k, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_fused_issues_single_all_reduce_over_packed_pair() -> None:
|
||||
dim = 64
|
||||
norm_q, norm_k = _make_norms(dim)
|
||||
q = torch.randn(2, 5, dim)
|
||||
k = torch.randn(2, 5, dim)
|
||||
|
||||
with (
|
||||
patch(f"{_MODULE}.get_tensor_model_parallel_world_size", return_value=2),
|
||||
patch(
|
||||
f"{_MODULE}.tensor_model_parallel_all_reduce",
|
||||
side_effect=_identity_all_reduce,
|
||||
) as mock_ar,
|
||||
):
|
||||
fused_qk_rms_norm(norm_q, norm_k, q, k)
|
||||
|
||||
# Exactly one collective, over a tensor whose last dim packs the q+k pair.
|
||||
assert mock_ar.call_count == 1
|
||||
packed = mock_ar.call_args[0][0]
|
||||
assert packed.shape[-1] == 2
|
||||
|
||||
|
||||
def test_fused_identity_fallback_when_qk_norm_disabled() -> None:
|
||||
# qk_norm=False makes the norms nn.Identity; the helper must fall back to
|
||||
# plain passthrough without touching the collective.
|
||||
norm_q, norm_k = torch.nn.Identity(), torch.nn.Identity()
|
||||
q = torch.randn(2, 5, 64)
|
||||
k = torch.randn(2, 5, 64)
|
||||
|
||||
with patch(f"{_MODULE}.tensor_model_parallel_all_reduce") as mock_ar:
|
||||
fused_q, fused_k = fused_qk_rms_norm(norm_q, norm_k, q, k)
|
||||
|
||||
torch.testing.assert_close(fused_q, q, rtol=0, atol=0)
|
||||
torch.testing.assert_close(fused_k, k, rtol=0, atol=0)
|
||||
mock_ar.assert_not_called()
|
||||
@@ -0,0 +1,97 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _empty_pipeline() -> DreamZeroPipeline:
|
||||
pipeline = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipeline._states = OrderedDict()
|
||||
return pipeline
|
||||
|
||||
|
||||
def test_dreamzero_pipeline_state_is_session_keyed() -> None:
|
||||
pipeline = _empty_pipeline()
|
||||
|
||||
session_a = pipeline._get_or_create_state("session-a")
|
||||
session_b = pipeline._get_or_create_state("session-b")
|
||||
session_a.call_count = 7
|
||||
session_b.call_count = 3
|
||||
|
||||
assert pipeline._get_or_create_state("session-a") is session_a
|
||||
assert pipeline._get_or_create_state("session-b") is session_b
|
||||
assert session_a.call_count == 7
|
||||
assert session_b.call_count == 3
|
||||
|
||||
|
||||
def test_dreamzero_pipeline_state_follows_runner_lifecycle_notifications() -> None:
|
||||
pipeline = _empty_pipeline()
|
||||
|
||||
session_a = pipeline._get_or_create_state("session-a")
|
||||
session_b = pipeline._get_or_create_state("session-b")
|
||||
pipeline.state = session_b
|
||||
|
||||
pipeline.close_ar_diffusion_session("session-a")
|
||||
assert pipeline.state is session_b
|
||||
pipeline.reset_ar_diffusion_session("session-b")
|
||||
|
||||
assert not pipeline._states
|
||||
assert pipeline.state is None
|
||||
assert pipeline._get_or_create_state("session-a") is not session_a
|
||||
assert pipeline._get_or_create_state("session-b") is not session_b
|
||||
|
||||
|
||||
def test_close_session_clears_active_state_alias() -> None:
|
||||
pipeline = _empty_pipeline()
|
||||
session_a = pipeline._get_or_create_state("session-a")
|
||||
pipeline.state = session_a
|
||||
|
||||
pipeline.close_ar_diffusion_session("session-a")
|
||||
|
||||
assert not pipeline._states
|
||||
assert pipeline.state is None
|
||||
|
||||
|
||||
def test_dreamzero_warmup_provider_builds_session_scoped_requests() -> None:
|
||||
provider = SimpleNamespace(
|
||||
ar_diffusion_kv_cache_spec=lambda: SimpleNamespace(window_frames=5, frames_per_block=4),
|
||||
od_config=SimpleNamespace(
|
||||
ar_diffusion_kv_config=None,
|
||||
model_config={"policy_server_config": {"image_resolution": [8, 16]}},
|
||||
),
|
||||
_ar_warmup_robot_obs=DreamZeroPipeline._ar_warmup_robot_obs,
|
||||
)
|
||||
|
||||
requests = list(DreamZeroPipeline.ar_diffusion_warmup_requests(provider, "warmup-session"))
|
||||
|
||||
assert [request.prompt for request in requests] == ["warmup", "warmup"]
|
||||
assert all(request.sampling_params.extra_args["session_id"] == "warmup-session" for request in requests)
|
||||
assert requests[0].sampling_params.extra_args["robot_obs"]["observation/exterior_image_0_left"].shape == (
|
||||
8,
|
||||
16,
|
||||
3,
|
||||
)
|
||||
assert requests[1].sampling_params.extra_args["robot_obs"]["observation/exterior_image_0_left"].shape == (
|
||||
4,
|
||||
8,
|
||||
16,
|
||||
3,
|
||||
)
|
||||
|
||||
|
||||
def test_dreamzero_state_owns_no_kv_caches() -> None:
|
||||
"""KV (self- and cross-attention) is engine-owned since the AR-Diffusion
|
||||
paged backend: the model-local cache accessors must be gone so nothing can
|
||||
silently bypass the engine pool."""
|
||||
state = DreamZeroState()
|
||||
|
||||
for removed in ("get_kv_caches", "create_kv_caches", "update_kv_cache", "get_crossattn_caches"):
|
||||
assert not hasattr(state, removed)
|
||||
@@ -0,0 +1,117 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
"""Equivalence test for DreamZero self-attn QKV fusion.
|
||||
|
||||
CausalWanSelfAttention now projects q/k/v with a single QKVParallelLinear and
|
||||
splits the result, instead of three separate ColumnParallelLinear. The DreamZero
|
||||
checkpoint still ships separate q/k/v weights, so load_weights routes each into
|
||||
the packed `qkv` param with its shard id. This test proves the chain
|
||||
"load separate q/k/v via shard id -> single GEMM -> split" reproduces the three
|
||||
independent projections, given identical weights.
|
||||
|
||||
CPU-only: a single-rank (world_size=1) tensor-parallel group is initialized and
|
||||
the GEMM dispatcher is forced to the default (CPU-compatible) path, following
|
||||
tests/diffusion/layers/test_adalayernorm.py.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _init_distributed():
|
||||
"""Minimal world_size=1 TP group required to build QKVParallelLinear."""
|
||||
from vllm.distributed.parallel_state import (
|
||||
cleanup_dist_env_and_memory,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29507")
|
||||
init_distributed_environment(
|
||||
world_size=1,
|
||||
rank=0,
|
||||
local_rank=0,
|
||||
distributed_init_method="env://",
|
||||
)
|
||||
initialize_model_parallel()
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_default_gemm(monkeypatch):
|
||||
"""Force CPU-compatible GEMM dispatch for CPU test tensors."""
|
||||
from vllm.model_executor.layers.utils import default_unquantized_gemm
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
def test_fused_qkv_load_and_split_matches_separate_projections():
|
||||
from vllm.model_executor.layers.linear import QKVParallelLinear
|
||||
|
||||
torch.manual_seed(0)
|
||||
dim, head_dim, num_heads = 16, 4, 4 # inner_dim == dim, no GQA
|
||||
|
||||
qkv = QKVParallelLinear(
|
||||
hidden_size=dim,
|
||||
head_size=head_dim,
|
||||
total_num_heads=num_heads,
|
||||
bias=True,
|
||||
)
|
||||
|
||||
# Distinct random weight + bias per projection, loaded the exact way
|
||||
# DreamZero.load_weights does: param.weight_loader(param, tensor, shard_id).
|
||||
refs: dict[str, tuple[torch.Tensor, torch.Tensor]] = {}
|
||||
for shard_id in ("q", "k", "v"):
|
||||
w = torch.randn(dim, dim)
|
||||
b = torch.randn(dim)
|
||||
refs[shard_id] = (w, b)
|
||||
qkv.weight.weight_loader(qkv.weight, w, shard_id)
|
||||
qkv.bias.weight_loader(qkv.bias, b, shard_id)
|
||||
|
||||
x = torch.randn(2, 3, dim)
|
||||
fused, _ = qkv(x)
|
||||
q, k, v = fused.split([dim, dim, dim], dim=-1)
|
||||
|
||||
for got, shard_id in zip((q, k, v), ("q", "k", "v"), strict=True):
|
||||
w, b = refs[shard_id]
|
||||
ref = torch.nn.functional.linear(x, w, b)
|
||||
torch.testing.assert_close(got, ref)
|
||||
|
||||
|
||||
def test_fused_qkv_shard_ids_are_not_transposed():
|
||||
# Guard against q/k/v being routed to the wrong slice: give each projection a
|
||||
# constant, distinct weight so a swapped shard id would surface immediately.
|
||||
from vllm.model_executor.layers.linear import QKVParallelLinear
|
||||
|
||||
dim, head_dim, num_heads = 16, 4, 4
|
||||
qkv = QKVParallelLinear(
|
||||
hidden_size=dim,
|
||||
head_size=head_dim,
|
||||
total_num_heads=num_heads,
|
||||
bias=True,
|
||||
)
|
||||
|
||||
scale = {"q": 1.0, "k": 2.0, "v": 3.0}
|
||||
for shard_id, s in scale.items():
|
||||
qkv.weight.weight_loader(qkv.weight, torch.full((dim, dim), s), shard_id)
|
||||
qkv.bias.weight_loader(qkv.bias, torch.zeros(dim), shard_id)
|
||||
|
||||
x = torch.ones(1, dim)
|
||||
fused, _ = qkv(x)
|
||||
q, k, v = fused.split([dim, dim, dim], dim=-1)
|
||||
|
||||
# F.linear(ones, full(s)) == s * dim per element.
|
||||
torch.testing.assert_close(q, torch.full((1, dim), scale["q"] * dim))
|
||||
torch.testing.assert_close(k, torch.full((1, dim), scale["k"] * dim))
|
||||
torch.testing.assert_close(v, torch.full((1, dim), scale["v"] * dim))
|
||||
@@ -0,0 +1,201 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Per-device byte accounting and the bounded VAE encoder ring (RFC #4480).
|
||||
|
||||
Two things are pinned here. First, that a session reports the bytes it actually
|
||||
holds: both buckets walked, storages rather than logical element counts, and
|
||||
aliases counted once. Second, that bounding the adapter's VAE encoder history
|
||||
changes no numbers -- ``quant_conv`` is pointwise in time, so the frames the
|
||||
reader keeps do not depend on the ones the ring drops.
|
||||
|
||||
All CPU, tiny tensors, no model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import (
|
||||
DreamZeroStateAdapter,
|
||||
)
|
||||
from vllm_omni.experimental.world_models.session_state import (
|
||||
LatentBuffer,
|
||||
SessionStateManager,
|
||||
device_bytes,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _bytes(value: object) -> dict[str, int]:
|
||||
return device_bytes(value, set())
|
||||
|
||||
|
||||
# -- device_bytes ------------------------------------------------------------
|
||||
|
||||
|
||||
def test_counts_tensor_storage_under_its_device() -> None:
|
||||
tensor = torch.zeros(64, dtype=torch.float32)
|
||||
assert _bytes(tensor) == {"cpu": 256}
|
||||
|
||||
|
||||
def test_views_on_one_storage_counted_once() -> None:
|
||||
tensor = torch.zeros(64, dtype=torch.float32)
|
||||
seen: set[tuple[str, int]] = set()
|
||||
first = device_bytes(tensor, seen)
|
||||
second = device_bytes(tensor[:8], seen)
|
||||
assert first == {"cpu": 256}
|
||||
assert second == {}
|
||||
|
||||
|
||||
def test_slice_reports_the_storage_it_pins() -> None:
|
||||
# The whole point of counting storages: this slice keeps all 256 bytes
|
||||
# alive, and ``numel() * element_size()`` would claim 32.
|
||||
sliced = torch.zeros(64, dtype=torch.float32)[:8]
|
||||
assert _bytes(sliced) == {"cpu": 256}
|
||||
|
||||
|
||||
def test_containers_recurse_and_scalars_contribute_nothing() -> None:
|
||||
payload = {
|
||||
"tensors": [torch.zeros(16, dtype=torch.float32), None],
|
||||
"count": 7,
|
||||
"flag": True,
|
||||
"name": "not-a-buffer",
|
||||
}
|
||||
assert _bytes(payload) == {"cpu": 64}
|
||||
|
||||
|
||||
def test_numpy_arrays_land_on_the_host() -> None:
|
||||
assert _bytes(np.zeros(32, dtype=np.float32)) == {"cpu": 128}
|
||||
|
||||
|
||||
# -- SessionState / manager --------------------------------------------------
|
||||
|
||||
|
||||
def test_session_counts_both_buckets_and_dedupes_across_them() -> None:
|
||||
manager = SessionStateManager()
|
||||
session = manager.get_or_create_session("s")
|
||||
|
||||
buffer: LatentBuffer[torch.Tensor] = LatentBuffer()
|
||||
buffer.allocate(maxlen=None)
|
||||
shared = torch.zeros(64, dtype=torch.float32) # 256 bytes
|
||||
buffer.append(shared)
|
||||
session.put("latents", buffer)
|
||||
session.attrs["conditioning"] = torch.zeros(16, dtype=torch.float32) # 64 bytes
|
||||
|
||||
assert session.nbytes_by_device() == {"cpu": 320}
|
||||
|
||||
# The same storage reachable from both buckets is one allocation, not two.
|
||||
session.attrs["alias"] = shared
|
||||
assert session.nbytes_by_device() == {"cpu": 320}
|
||||
|
||||
|
||||
def test_stats_reports_per_device_totals() -> None:
|
||||
manager = SessionStateManager()
|
||||
manager.get_or_create_session("s").attrs["x"] = torch.zeros(64, dtype=torch.float32)
|
||||
|
||||
stats = manager.stats()
|
||||
assert stats["nbytes:cpu"] == 256
|
||||
assert stats["total_nbytes"] == 256
|
||||
|
||||
|
||||
def test_attribute_bytes_were_previously_invisible() -> None:
|
||||
"""The defect behind tzhouam's review comment on PR #4487.
|
||||
|
||||
Every byte here lives in ``attrs``, and the old ``nbytes`` walked only the
|
||||
typed objects -- so a session holding real memory reported zero.
|
||||
"""
|
||||
manager = SessionStateManager()
|
||||
session = manager.get_or_create_session("s")
|
||||
session.attrs["conv_cache"] = [torch.zeros(1024, dtype=torch.float32)]
|
||||
|
||||
assert session.nbytes_by_device() == {"cpu": 4096}
|
||||
|
||||
|
||||
# -- the largest value DreamZero holds ---------------------------------------
|
||||
|
||||
|
||||
def test_feat_map_bytes_are_counted() -> None:
|
||||
"""``vae_enc_feat_map`` is the largest thing a DreamZero session holds --
|
||||
603 MiB at the shipped config -- and it stays an attribute, so this is
|
||||
exactly the memory the old accounting missed."""
|
||||
manager = SessionStateManager()
|
||||
adapter = DreamZeroStateAdapter("s", manager)
|
||||
adapter.vae_enc_feat_map = [torch.zeros(1024, dtype=torch.float32), None]
|
||||
|
||||
assert manager.stats()["nbytes:cpu"] == 4096
|
||||
|
||||
# Freeing it piecemeal is not something the manager can do: the cache has no
|
||||
# recompute source and the stream breaks without it. Ending the stream (or
|
||||
# the session) is the only release, and that reclaims attributes too.
|
||||
adapter.reset_vae_encoder_stream()
|
||||
assert manager.stats().get("nbytes:cpu", 0) == 0
|
||||
|
||||
|
||||
# -- bounded VAE encoder ring ------------------------------------------------
|
||||
|
||||
|
||||
def _quantize(encoder_out: torch.Tensor) -> torch.Tensor:
|
||||
"""Stand-in for ``_vae_quantize_encoder_out``.
|
||||
|
||||
The real one is ``WanCausalConv3d(z_dim*2, z_dim*2, 1)`` followed by a
|
||||
per-channel affine rescale. Every operation is pointwise in time, which is
|
||||
the property the ring relies on; a 1x1x1 convolution reproduces it.
|
||||
"""
|
||||
weight = torch.arange(1, encoder_out.shape[1] + 1, dtype=encoder_out.dtype).view(1, -1, 1, 1, 1)
|
||||
return encoder_out * weight + 0.5
|
||||
|
||||
|
||||
def test_ring_is_bounded() -> None:
|
||||
adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2)
|
||||
adapter.vae_encoder_out = torch.randn(1, 4, 1, 2, 2)
|
||||
for _ in range(10):
|
||||
adapter.append_vae_encoder_chunk(torch.randn(1, 4, 1, 2, 2))
|
||||
|
||||
assert adapter.vae_encoder_out is not None
|
||||
assert adapter.vae_encoder_out.shape[2] == 2
|
||||
|
||||
|
||||
def test_ring_matches_unbounded_history_for_the_frames_that_are_read() -> None:
|
||||
window = 2
|
||||
adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=window)
|
||||
seed = torch.randn(1, 4, 1, 2, 2)
|
||||
chunks = [torch.randn(1, 4, 1, 2, 2) for _ in range(9)]
|
||||
|
||||
adapter.vae_encoder_out = seed
|
||||
unbounded = seed
|
||||
for chunk in chunks:
|
||||
adapter.append_vae_encoder_chunk(chunk)
|
||||
unbounded = torch.cat([unbounded, chunk], dim=2)
|
||||
|
||||
# What the reader actually returns: quantise, then keep the last `window`.
|
||||
from_ring = _quantize(adapter.vae_encoder_out)[:, :, -window:]
|
||||
from_history = _quantize(unbounded)[:, :, -window:]
|
||||
torch.testing.assert_close(from_ring, from_history, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_ring_holds_a_single_frame_before_the_first_body_chunk() -> None:
|
||||
# After `_vae_stream_seed` there is exactly one latent frame, which is what
|
||||
# sends the reader down its padding branch.
|
||||
adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2)
|
||||
seed = torch.randn(1, 4, 1, 2, 2)
|
||||
adapter.vae_encoder_out = seed
|
||||
|
||||
assert adapter.vae_encoder_out is not None
|
||||
assert adapter.vae_encoder_out.shape[2] == 1
|
||||
torch.testing.assert_close(adapter.vae_encoder_out, seed, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_resetting_the_stream_clears_the_ring() -> None:
|
||||
adapter = DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=2)
|
||||
adapter.vae_encoder_out = torch.randn(1, 4, 1, 2, 2)
|
||||
adapter.reset_vae_encoder_stream()
|
||||
|
||||
assert adapter.vae_encoder_out is None
|
||||
|
||||
|
||||
def test_window_must_be_positive() -> None:
|
||||
with pytest.raises(ValueError, match="vae_encoder_window"):
|
||||
DreamZeroStateAdapter("s", SessionStateManager(), vae_encoder_window=0)
|
||||
@@ -0,0 +1,257 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Bit-equivalence tests: DreamZeroStateAdapter vs bespoke DreamZeroState.
|
||||
|
||||
These drive both state objects directly with tiny CPU tensors -- no model, no
|
||||
GPU -- and assert the adapter (backed by the SessionStateManager) stores and
|
||||
returns exactly what the bespoke DreamZeroState does. This gates routing
|
||||
DreamZero through the shared session state manager (RFC #4480).
|
||||
|
||||
DreamZero's attention KV is engine-owned (AR-Diffusion paged pool, PR #4534)
|
||||
and out of the adapter's scope; the state surface tested here is the model's
|
||||
non-KV session state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.state_dreamzero import (
|
||||
FRAMES_PER_CHUNK,
|
||||
DreamZeroState,
|
||||
)
|
||||
from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import DreamZeroStateAdapter
|
||||
from vllm_omni.experimental.world_models.session_state import SessionStateManager
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _create_both() -> tuple[DreamZeroState, DreamZeroStateAdapter]:
|
||||
return DreamZeroState(), DreamZeroStateAdapter("session-0", SessionStateManager())
|
||||
|
||||
|
||||
def test_accumulate_frames_and_call_count_match() -> None:
|
||||
bespoke, adapter = _create_both()
|
||||
rng = np.random.default_rng(0)
|
||||
for _ in range(2 * FRAMES_PER_CHUNK + 1):
|
||||
frame = rng.integers(0, 255, size=(5, 6, 3), dtype=np.uint8)
|
||||
b_out = bespoke.accumulate_frames(frame)
|
||||
a_out = adapter.accumulate_frames(frame)
|
||||
np.testing.assert_array_equal(b_out, a_out)
|
||||
assert bespoke.call_count == adapter.call_count
|
||||
assert len(bespoke.stitched_buffer) == len(adapter.stitched_buffer)
|
||||
# Deque rollover capped at FRAMES_PER_CHUNK.
|
||||
assert len(adapter.stitched_buffer) == FRAMES_PER_CHUNK
|
||||
|
||||
|
||||
def test_accumulate_frames_4d_extend_matches() -> None:
|
||||
bespoke, adapter = _create_both()
|
||||
clip = np.random.default_rng(1).integers(0, 255, size=(3, 5, 6, 3), dtype=np.uint8)
|
||||
np.testing.assert_array_equal(bespoke.accumulate_frames(clip), adapter.accumulate_frames(clip))
|
||||
assert len(bespoke.stitched_buffer) == len(adapter.stitched_buffer)
|
||||
|
||||
|
||||
def test_video_latents_roundtrip_matches() -> None:
|
||||
bespoke, adapter = _create_both()
|
||||
assert bespoke.get_concatenated_video_latents() is None
|
||||
assert adapter.get_concatenated_video_latents() is None
|
||||
|
||||
rng = torch.Generator().manual_seed(0)
|
||||
for _ in range(3):
|
||||
# (B, T, C, H, W) as returned by the denoise loop.
|
||||
chunk = torch.randn(1, 2, 3, 4, 4, generator=rng)
|
||||
bespoke.append_video_latents(chunk)
|
||||
adapter.append_video_latents(chunk)
|
||||
torch.testing.assert_close(
|
||||
bespoke.get_concatenated_video_latents(),
|
||||
adapter.get_concatenated_video_latents(),
|
||||
)
|
||||
|
||||
bespoke.clear_video_latents()
|
||||
adapter.clear_video_latents()
|
||||
assert bespoke.get_concatenated_video_latents() is None
|
||||
assert adapter.get_concatenated_video_latents() is None
|
||||
|
||||
|
||||
def test_vae_stream_fields_match_bespoke() -> None:
|
||||
bespoke, adapter = _create_both()
|
||||
for state in (bespoke, adapter):
|
||||
assert state.vae_stream_initialized is False
|
||||
assert state.vae_enc_feat_map is None
|
||||
assert state.vae_encoder_out is None
|
||||
assert state.vae_pending_body_frames is None
|
||||
|
||||
feat_map: list[torch.Tensor | None] = [torch.ones(1, 2), None]
|
||||
encoder_out = torch.randn(1, 4, 2, 3, 3)
|
||||
pending = torch.randn(1, 3, 2, 8, 8)
|
||||
for state in (bespoke, adapter):
|
||||
state.vae_enc_feat_map = feat_map
|
||||
state.vae_encoder_out = encoder_out
|
||||
state.vae_pending_body_frames = pending
|
||||
state.vae_stream_initialized = True
|
||||
|
||||
assert adapter.vae_stream_initialized is bespoke.vae_stream_initialized is True
|
||||
torch.testing.assert_close(adapter.vae_encoder_out, bespoke.vae_encoder_out)
|
||||
torch.testing.assert_close(adapter.vae_pending_body_frames, bespoke.vae_pending_body_frames)
|
||||
assert adapter.vae_enc_feat_map is feat_map
|
||||
|
||||
for state in (bespoke, adapter):
|
||||
state.reset_vae_encoder_stream()
|
||||
assert state.vae_stream_initialized is False
|
||||
assert state.vae_enc_feat_map is None
|
||||
assert state.vae_encoder_out is None
|
||||
assert state.vae_pending_body_frames is None
|
||||
|
||||
|
||||
def test_reset_clears_state_and_should_reset_matches() -> None:
|
||||
bespoke, adapter = _create_both()
|
||||
tokens = torch.tensor([1, 2, 3])
|
||||
# language unset -> both want reset.
|
||||
assert bespoke.should_reset(tokens, 4, -1) is adapter.should_reset(tokens, 4, -1) is True
|
||||
|
||||
for state in (bespoke, adapter):
|
||||
state.language = tokens
|
||||
state.current_start_frame = 2
|
||||
# same language -> no reset; both agree.
|
||||
assert bespoke.should_reset(tokens, 4, -1) == adapter.should_reset(tokens, 4, -1) is False
|
||||
# language change -> reset.
|
||||
other = torch.tensor([9, 9])
|
||||
assert bespoke.should_reset(other, 4, -1) == adapter.should_reset(other, 4, -1) is True
|
||||
# local_attn_size exceeded -> reset ("inference", not "session").
|
||||
assert bespoke.reset_reason(tokens, 4, 1) == adapter.reset_reason(tokens, 4, 1) == "inference"
|
||||
|
||||
# A session reset clears everything, including the prompt-embed cache and
|
||||
# the VAE encoder stream (mirrors DreamZeroState.reset()).
|
||||
for state in (bespoke, adapter):
|
||||
state.prompt_embeds = torch.ones(1, 2)
|
||||
state.vae_encoder_out = torch.ones(1, 4, 1, 2, 2)
|
||||
state.vae_stream_initialized = True
|
||||
state.reset()
|
||||
assert state.language is None
|
||||
assert state.prompt_embeds is None
|
||||
assert state.current_start_frame == 0
|
||||
assert state.call_count == 0
|
||||
assert state.vae_stream_initialized is False
|
||||
assert state.vae_encoder_out is None
|
||||
|
||||
|
||||
def test_window_reset_preserves_latents_prompt_and_vae_stream() -> None:
|
||||
# reset(clear_video_latents=False) is the window ("inference") reset: the
|
||||
# prompt is unchanged and the VAE feat_cache history is independent of the
|
||||
# DiT attention window, so both survive alongside the video latents.
|
||||
bespoke, adapter = _create_both()
|
||||
tokens = torch.tensor([1, 2, 3])
|
||||
embeds = torch.randn(1, 4)
|
||||
encoder_out = torch.randn(1, 4, 2, 3, 3)
|
||||
chunk = torch.randn(1, 2, 3, 4, 4)
|
||||
for state in (bespoke, adapter):
|
||||
state.language = tokens
|
||||
state.prompt_embeds = embeds
|
||||
state.vae_encoder_out = encoder_out
|
||||
state.vae_stream_initialized = True
|
||||
state.current_start_frame = 7
|
||||
state.append_video_latents(chunk)
|
||||
|
||||
state.reset_inference_state()
|
||||
|
||||
assert state.current_start_frame == 0
|
||||
assert state.call_count == 0
|
||||
assert state.clip_feas is None
|
||||
torch.testing.assert_close(state.language, tokens)
|
||||
torch.testing.assert_close(state.prompt_embeds, embeds)
|
||||
assert state.vae_stream_initialized is True
|
||||
torch.testing.assert_close(state.vae_encoder_out, encoder_out)
|
||||
assert state.get_concatenated_video_latents() is not None
|
||||
|
||||
torch.testing.assert_close(
|
||||
bespoke.get_concatenated_video_latents(),
|
||||
adapter.get_concatenated_video_latents(),
|
||||
)
|
||||
|
||||
|
||||
def test_metadata_fields_persist_across_adapter_instances() -> None:
|
||||
# A fresh adapter for the same session must see prior metadata (the manager
|
||||
# is the single source of truth).
|
||||
manager = SessionStateManager()
|
||||
first = DreamZeroStateAdapter("s", manager)
|
||||
first.current_start_frame = 5
|
||||
first.clip_feas = torch.ones(2, 2)
|
||||
first.append_video_latents(torch.ones(1, 2, 3, 4, 4))
|
||||
|
||||
second = DreamZeroStateAdapter("s", manager)
|
||||
assert second.current_start_frame == 5
|
||||
torch.testing.assert_close(second.clip_feas, torch.ones(2, 2))
|
||||
# Video latents appended via `first` are visible via `second`.
|
||||
assert second.get_concatenated_video_latents() is not None
|
||||
|
||||
|
||||
def test_session_lru_caps_retained_sessions() -> None:
|
||||
manager = SessionStateManager(max_sessions=3)
|
||||
for i in range(5):
|
||||
DreamZeroStateAdapter(f"s{i}", manager)
|
||||
assert len(manager) == 3
|
||||
# Oldest two evicted; newest three retained.
|
||||
assert "s0" not in manager
|
||||
assert "s1" not in manager
|
||||
for i in (2, 3, 4):
|
||||
assert f"s{i}" in manager
|
||||
|
||||
|
||||
def test_adapter_keeps_state_when_its_session_is_evicted() -> None:
|
||||
# Regression: an adapter mid-generation must not lose its data when the
|
||||
# manager evicts its session to make room for newer ones. The adapter pins
|
||||
# the session, so its stored latents survive eviction from the lookup table.
|
||||
manager = SessionStateManager(max_sessions=2)
|
||||
adapter = DreamZeroStateAdapter("active", manager)
|
||||
chunk = torch.randn(1, 2, 3, 4, 4)
|
||||
adapter.append_video_latents(chunk)
|
||||
adapter.language = torch.tensor([1, 2, 3])
|
||||
|
||||
# Push "active" out of the manager's table with newer sessions.
|
||||
for i in range(3):
|
||||
DreamZeroStateAdapter(f"other{i}", manager)
|
||||
assert "active" not in manager
|
||||
|
||||
# The in-use adapter still returns its own data.
|
||||
assert adapter.get_concatenated_video_latents() is not None
|
||||
torch.testing.assert_close(adapter.language, torch.tensor([1, 2, 3]))
|
||||
|
||||
|
||||
def test_session_reset_clears_metadata() -> None:
|
||||
# Regression: resetting a session must not leave stale metadata behind, so a
|
||||
# fresh adapter for the same session sees defaults, not the old values.
|
||||
manager = SessionStateManager()
|
||||
adapter = DreamZeroStateAdapter("s", manager)
|
||||
adapter.language = torch.tensor([1, 2, 3])
|
||||
adapter.current_start_frame = 5
|
||||
adapter.clip_feas = torch.ones(2, 2)
|
||||
adapter.prompt_embeds = torch.ones(1, 2)
|
||||
|
||||
adapter.reset()
|
||||
|
||||
fresh = DreamZeroStateAdapter("s", manager)
|
||||
assert fresh.language is None
|
||||
assert fresh.current_start_frame == 0
|
||||
assert fresh.clip_feas is None
|
||||
assert fresh.prompt_embeds is None
|
||||
|
||||
|
||||
def test_bespoke_path_without_memory_manager() -> None:
|
||||
# Regression: a pipeline built via __new__ (lightweight fixtures) seeds only
|
||||
# the bespoke fields and never sets _memory_manager; _get_or_create_state
|
||||
# must fall through to the bespoke DreamZeroState path without AttributeError.
|
||||
from collections import OrderedDict
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import (
|
||||
MAX_DREAMZERO_SESSIONS,
|
||||
DreamZeroPipeline,
|
||||
)
|
||||
|
||||
pipe = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipe._states = OrderedDict()
|
||||
pipe._max_session_states = MAX_DREAMZERO_SESSIONS
|
||||
|
||||
state = DreamZeroPipeline._get_or_create_state(pipe, "x")
|
||||
assert isinstance(state, DreamZeroState)
|
||||
@@ -0,0 +1,115 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Session end-of-life routing (RFC #4480, adapting to #5271).
|
||||
|
||||
The AR-Diffusion runner raises an explicit end-of-session signal on reset,
|
||||
close, eviction, and failed forwards (``reset_ar_diffusion_session`` /
|
||||
``close_ar_diffusion_session``). On the bespoke path those pop ``_states``; on
|
||||
the opt-in manager path the session lives in the manager instead, so the hooks
|
||||
must release it there or every closed session leaks its buffers.
|
||||
|
||||
All CPU, tiny tensors, no model.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.dreamzero.pipeline_dreamzero import DreamZeroPipeline
|
||||
from vllm_omni.diffusion.models.dreamzero.state_dreamzero import DreamZeroState
|
||||
from vllm_omni.experimental.world_models.adapters.state_dreamzero_adapter import (
|
||||
DreamZeroStateAdapter,
|
||||
)
|
||||
from vllm_omni.experimental.world_models.session_state import (
|
||||
LatentBuffer,
|
||||
SessionStateManager,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
# -- SessionStateManager.drop_session ---------------------------------------
|
||||
|
||||
|
||||
def test_drop_session_absent_returns_false() -> None:
|
||||
manager = SessionStateManager()
|
||||
assert manager.drop_session("missing") is False
|
||||
|
||||
|
||||
def test_drop_session_removes_and_frees() -> None:
|
||||
manager = SessionStateManager()
|
||||
session = manager.get_or_create_session("s")
|
||||
buffer: LatentBuffer[torch.Tensor] = LatentBuffer()
|
||||
buffer.allocate(maxlen=None)
|
||||
buffer.append(torch.zeros(256, dtype=torch.float32))
|
||||
session.put("payload", buffer)
|
||||
assert manager.nbytes_by_device().get("cpu", 0) >= 1024
|
||||
|
||||
assert manager.drop_session("s") is True
|
||||
assert "s" not in manager
|
||||
assert len(manager) == 0
|
||||
# The session is gone from the table and its buffers were reset, so the
|
||||
# manager reports no held bytes.
|
||||
assert manager.nbytes_by_device() == {}
|
||||
|
||||
|
||||
def test_drop_session_recreates_fresh() -> None:
|
||||
manager = SessionStateManager()
|
||||
manager.get_or_create_session("s").attrs["k"] = 1
|
||||
manager.drop_session("s")
|
||||
assert manager.get_or_create_session("s").attrs == {}
|
||||
|
||||
|
||||
# -- pipeline hook routing ---------------------------------------------------
|
||||
|
||||
|
||||
def _manager_pipe(manager: SessionStateManager, session_id: str) -> DreamZeroPipeline:
|
||||
"""A pipeline built via __new__ whose state is a manager-backed adapter."""
|
||||
pipe = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipe._states = OrderedDict()
|
||||
pipe._memory_manager = manager
|
||||
pipe.state = DreamZeroStateAdapter(session_id, manager, vae_encoder_window=2)
|
||||
return pipe
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hook", ["close_ar_diffusion_session", "reset_ar_diffusion_session"])
|
||||
def test_hook_releases_manager_session_and_clears_alias(hook: str) -> None:
|
||||
manager = SessionStateManager()
|
||||
pipe = _manager_pipe(manager, "sess")
|
||||
assert "sess" in manager
|
||||
|
||||
getattr(DreamZeroPipeline, hook)(pipe, "sess")
|
||||
|
||||
assert "sess" not in manager
|
||||
# The alias viewed the released session, so it is dropped and rebuilt lazily.
|
||||
assert pipe.state is None
|
||||
|
||||
|
||||
def test_hook_keeps_alias_for_a_different_session() -> None:
|
||||
manager = SessionStateManager()
|
||||
pipe = _manager_pipe(manager, "default")
|
||||
manager.get_or_create_session("other")
|
||||
|
||||
DreamZeroPipeline.close_ar_diffusion_session(pipe, "other")
|
||||
|
||||
assert "other" not in manager
|
||||
# The alias views "default", not the closed session, so it survives.
|
||||
assert isinstance(pipe.state, DreamZeroStateAdapter)
|
||||
assert pipe.state.session_id == "default"
|
||||
|
||||
|
||||
def test_bespoke_hook_pops_states_and_clears_alias() -> None:
|
||||
pipe = DreamZeroPipeline.__new__(DreamZeroPipeline)
|
||||
pipe._states = OrderedDict()
|
||||
pipe._memory_manager = None
|
||||
state = DreamZeroState()
|
||||
pipe._states["sess"] = state
|
||||
pipe.state = state
|
||||
|
||||
DreamZeroPipeline.close_ar_diffusion_session(pipe, "sess")
|
||||
|
||||
assert "sess" not in pipe._states
|
||||
assert pipe.state is None
|
||||
@@ -9,6 +9,8 @@ from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransforme
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_ltx_rms_norm_no_affine_matches_official_torch_module():
|
||||
norm = _make_rms_norm(8, eps=1e-6, elementwise_affine=False)
|
||||
|
||||
@@ -6,6 +6,8 @@ import torch
|
||||
|
||||
from vllm_omni.diffusion.models.sensenova_u1.pipeline_sensenova_u1 import SenseNovaU1Pipeline
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _pipeline_without_init() -> SenseNovaU1Pipeline:
|
||||
return object.__new__(SenseNovaU1Pipeline)
|
||||
|
||||
@@ -5,7 +5,6 @@ import pytest
|
||||
import torch
|
||||
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
|
||||
|
||||
from tests.helpers import skip_if_gated_repo_inaccessible
|
||||
from tests.helpers.env import DeviceMemoryMonitor
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
@@ -16,32 +15,26 @@ AUDIO_MODEL = {
|
||||
"stabilityai/stable-audio-open-1.0": {"cuda": 100, "rocm": None},
|
||||
}
|
||||
|
||||
IMAGE_VIDEO_MODELS = {
|
||||
IMAGE_MODELS = {
|
||||
"riverclouds/qwen_image_random": {"cuda": 2200, "rocm": 2100},
|
||||
"Tongyi-MAI/Z-Image-Turbo": {"cuda": 2500, "rocm": 2100},
|
||||
"OmniGen2/OmniGen2": {"cuda": 2500, "rocm": 2100},
|
||||
}
|
||||
|
||||
MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
|
||||
MODELS = {**AUDIO_MODEL, **IMAGE_MODELS}
|
||||
|
||||
MODEL_MARKS = {
|
||||
"riverclouds/qwen_image_random": pytest.mark.core_model,
|
||||
"Tongyi-MAI/Z-Image-Turbo": pytest.mark.advanced_model,
|
||||
"stabilityai/stable-audio-open-1.0": pytest.mark.full_model,
|
||||
"OmniGen2/OmniGen2": pytest.mark.full_model,
|
||||
}
|
||||
|
||||
_GATED_MODELS = {"stabilityai/stable-audio-open-1.0"}
|
||||
|
||||
# Aliased for backward compatibility (imported by test_diffusion_layerwise_offload.py).
|
||||
_skip_if_gated_repo_inaccessible = skip_if_gated_repo_inaccessible
|
||||
|
||||
AUDIO_MODEL_PARAMS = {
|
||||
"runner_params": {},
|
||||
"sampler_params": {},
|
||||
}
|
||||
|
||||
IMAGE_VIDEO_MODELS_PARAMS = {
|
||||
IMAGE_MODEL_PARAMS = {
|
||||
"runner_params": {},
|
||||
"sampler_params": {
|
||||
"height": 256,
|
||||
@@ -60,7 +53,7 @@ def inference(model_name: str, offload: bool = True):
|
||||
if model_name in AUDIO_MODEL:
|
||||
params = AUDIO_MODEL_PARAMS
|
||||
else:
|
||||
params = IMAGE_VIDEO_MODELS_PARAMS
|
||||
params = IMAGE_MODEL_PARAMS
|
||||
|
||||
with OmniRunner(
|
||||
model_name,
|
||||
@@ -111,19 +104,12 @@ def check_audio_determinism(audio1, audio2, atol=1e-2):
|
||||
[pytest.param(name, marks=MODEL_MARKS[name]) for name in MODELS],
|
||||
)
|
||||
def test_cpu_offload_diffusion_model(model_name: str):
|
||||
if model_name == "OmniGen2/OmniGen2":
|
||||
pytest.skip("issue #4537")
|
||||
if model_name in _GATED_MODELS:
|
||||
_skip_if_gated_repo_inaccessible(model_name)
|
||||
try:
|
||||
offload_peak_memory, output_offload = inference(model_name, offload=True)
|
||||
cleanup_dist_env_and_memory()
|
||||
no_offload_peak_memory, output_no_offload = inference(model_name, offload=False)
|
||||
except ValueError as exc:
|
||||
# omni_snapshot_download wraps GatedRepoError in a ValueError.
|
||||
# If the pre-flight guard above did not catch it (e.g. partial
|
||||
# HF_TOKEN where config.json is accessible but weight shards are
|
||||
# blocked), skip instead of failing.
|
||||
# omni_snapshot_download wraps GatedRepoError in a ValueError; skip instead of failing.
|
||||
if "Access to model" in str(exc) and "is restricted" in str(exc):
|
||||
pytest.skip(
|
||||
f"Skipping: gated HF repo {model_name!r} inaccessible "
|
||||
|
||||
@@ -3,11 +3,8 @@ import pytest
|
||||
import torch
|
||||
from vllm.distributed.parallel_state import cleanup_dist_env_and_memory
|
||||
|
||||
from tests.diffusion.offloader.test_diffusion_cpu_offload import (
|
||||
_GATED_MODELS,
|
||||
_skip_if_gated_repo_inaccessible,
|
||||
)
|
||||
from tests.helpers.env import DeviceMemoryMonitor
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -23,6 +20,19 @@ IMAGE_VIDEO_MODELS = {
|
||||
|
||||
MODELS = {**AUDIO_MODEL, **IMAGE_VIDEO_MODELS}
|
||||
|
||||
LAYERWISE_MODEL_CASES = [
|
||||
pytest.param(
|
||||
"stabilityai/stable-audio-open-1.0",
|
||||
marks=[pytest.mark.full_model, pytest.mark.diffusion],
|
||||
id="stable_audio",
|
||||
),
|
||||
pytest.param(
|
||||
"riverclouds/qwen_image_random",
|
||||
marks=[pytest.mark.core_model, pytest.mark.diffusion],
|
||||
id="qwen_image_random",
|
||||
),
|
||||
]
|
||||
|
||||
AUDIO_MODEL_PARAMS = {
|
||||
"runner_params": {},
|
||||
"sampler_params": {},
|
||||
@@ -91,7 +101,8 @@ def run_inference(
|
||||
return peak, output
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", list(MODELS.keys()))
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
@pytest.mark.parametrize("model_name", LAYERWISE_MODEL_CASES)
|
||||
def test_layerwise_offload_diffusion_model(model_name: str):
|
||||
"""Test that layerwise offloading reduces GPU memory usage.
|
||||
|
||||
@@ -100,8 +111,6 @@ def test_layerwise_offload_diffusion_model(model_name: str):
|
||||
offloader keeps only a single transformer block on GPU at a time, with
|
||||
prefetching for compute-memory overlap.
|
||||
"""
|
||||
if model_name in _GATED_MODELS:
|
||||
_skip_if_gated_repo_inaccessible(model_name)
|
||||
try:
|
||||
# Run without layerwise offloading (baseline)
|
||||
no_offload_peak_memory, output_no_offload = run_inference(model_name, layerwise_offload=False)
|
||||
@@ -111,10 +120,7 @@ def test_layerwise_offload_diffusion_model(model_name: str):
|
||||
layerwise_offload_peak_memory, output_offload = run_inference(model_name, layerwise_offload=True)
|
||||
cleanup_dist_env_and_memory()
|
||||
except ValueError as exc:
|
||||
# omni_snapshot_download wraps GatedRepoError in a ValueError.
|
||||
# If the pre-flight guard above did not catch it (e.g. partial
|
||||
# HF_TOKEN where config.json is accessible but weight shards are
|
||||
# blocked), skip instead of failing.
|
||||
# omni_snapshot_download wraps GatedRepoError in a ValueError; skip instead of failing.
|
||||
if "Access to model" in str(exc) and "is restricted" in str(exc):
|
||||
pytest.skip(
|
||||
f"Skipping: gated HF repo {model_name!r} inaccessible "
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""End-to-end tests for GGUF quantization on diffusion models.
|
||||
|
||||
Validates that GGUF-quantized diffusion models generate valid images and
|
||||
use less peak GPU memory than BF16 baseline.
|
||||
|
||||
Requires vllm-omni to be installed alongside the vllm-gguf-plugin package.
|
||||
|
||||
Usage:
|
||||
pytest tests/diffusion/quantization/test_gguf_diffusion.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import NamedTuple
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.env import DeviceMemoryMonitor
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.runtime import OmniRunner
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
class DiffusionGGUFTestConfig(NamedTuple):
|
||||
artifact_prefix: str
|
||||
gguf_model: str
|
||||
hf_model: str
|
||||
min_cosine_similarity: float = 0.95
|
||||
|
||||
|
||||
Z_IMAGE_CONFIG = DiffusionGGUFTestConfig(
|
||||
artifact_prefix="zimage",
|
||||
hf_model="Tongyi-MAI/Z-Image-Turbo",
|
||||
gguf_model="unsloth/Z-Image-Turbo-GGUF:Q4_0",
|
||||
)
|
||||
|
||||
|
||||
FLUX_CONFIG = DiffusionGGUFTestConfig(
|
||||
artifact_prefix="flux2_klein",
|
||||
hf_model="black-forest-labs/FLUX.2-klein-4B",
|
||||
gguf_model="unsloth/FLUX.2-klein-4B-GGUF:Q8_0",
|
||||
)
|
||||
|
||||
|
||||
def _image_cosine_similarity(hf_image, gguf_image) -> float:
|
||||
hf_tensor = torch.as_tensor(np.array(hf_image.convert("RGB"), copy=True), dtype=torch.float32).flatten()
|
||||
gguf_tensor = torch.as_tensor(np.array(gguf_image.convert("RGB"), copy=True), dtype=torch.float32).flatten()
|
||||
return torch.nn.functional.cosine_similarity(hf_tensor, gguf_tensor, dim=0).item()
|
||||
|
||||
|
||||
def _generate_single_stage_image(
|
||||
model: str,
|
||||
height: int = 256,
|
||||
width: int = 256,
|
||||
num_inference_steps: int = 20,
|
||||
seed: int = 42,
|
||||
**extra_kwargs,
|
||||
) -> tuple[list, float]:
|
||||
"""Generate an image with a single-stage diffusion model.
|
||||
|
||||
Returns (images, peak_memory_gib).
|
||||
"""
|
||||
omni_kwargs = dict(extra_kwargs)
|
||||
|
||||
memory_monitor = DeviceMemoryMonitor(device_index=0)
|
||||
memory_monitor.start()
|
||||
try:
|
||||
with OmniRunner(model, **omni_kwargs) as runner:
|
||||
generator = torch.Generator(
|
||||
device=current_omni_platform.device_type,
|
||||
).manual_seed(seed)
|
||||
outputs = runner.omni.generate(
|
||||
"a photo of a cat sitting on a laptop keyboard",
|
||||
OmniDiffusionSamplingParams(
|
||||
height=height,
|
||||
width=width,
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=0.0,
|
||||
generator=generator,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
memory_monitor.stop()
|
||||
peak_mem = memory_monitor.peak_used_mb / 1024
|
||||
|
||||
first_output = outputs[0]
|
||||
assert first_output.final_output_type == "image"
|
||||
if hasattr(first_output, "images") and first_output.images:
|
||||
images = first_output.images
|
||||
else:
|
||||
assert hasattr(first_output, "request_output") and first_output.request_output
|
||||
request_output = first_output.request_output
|
||||
if isinstance(request_output, list):
|
||||
req_out = request_output[0]
|
||||
else:
|
||||
req_out = request_output
|
||||
assert isinstance(req_out, OmniRequestOutput) and hasattr(req_out, "images")
|
||||
images = req_out.images
|
||||
assert len(images) >= 1
|
||||
assert images[0].width == width
|
||||
assert images[0].height == height
|
||||
|
||||
return images, peak_mem
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parametrize("model", [Z_IMAGE_CONFIG, FLUX_CONFIG], ids=["Z-Image-Turbo", "FLUX.2-klein"])
|
||||
def test_single_stage_diffusion_gguf(model: DiffusionGGUFTestConfig, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Z-Image-Turbo GGUF generates valid images and uses less memory than BF16."""
|
||||
monkeypatch.setenv("VLLM_WORKER_MULTIPROC_METHOD", "spawn")
|
||||
|
||||
# BF16 baseline
|
||||
hf_images, mem_bf16 = _generate_single_stage_image(
|
||||
model=model.hf_model,
|
||||
)
|
||||
|
||||
# GGUF
|
||||
images, mem_gguf = _generate_single_stage_image(
|
||||
model=model.hf_model,
|
||||
diffusion_quantization_config={
|
||||
"method": "gguf",
|
||||
"gguf_model": model.gguf_model,
|
||||
},
|
||||
)
|
||||
|
||||
hf_image_path = f"test_{model.artifact_prefix}_hf.png"
|
||||
gguf_image_path = f"test_{model.artifact_prefix}_gguf.png"
|
||||
assert len(hf_images) >= 1
|
||||
hf_images[0].save(hf_image_path)
|
||||
assert len(images) >= 1
|
||||
images[0].save(gguf_image_path)
|
||||
print(f"Saved HF image: {hf_image_path}")
|
||||
print(f"Saved GGUF image: {gguf_image_path}")
|
||||
|
||||
image_cosine_similarity = _image_cosine_similarity(hf_images[0], images[0])
|
||||
print(f"{model.artifact_prefix} image cosine similarity: {image_cosine_similarity:.4f}")
|
||||
assert image_cosine_similarity >= model.min_cosine_similarity, (
|
||||
f"GGUF image cosine similarity ({image_cosine_similarity:.4f}) should be >= {model.min_cosine_similarity:.4f}"
|
||||
)
|
||||
|
||||
print(f"{model.artifact_prefix} BF16 peak VRAM delta: {mem_bf16:.2f} GiB")
|
||||
print(f"{model.artifact_prefix} GGUF peak VRAM delta: {mem_gguf:.2f} GiB")
|
||||
reduction = (mem_bf16 - mem_gguf) / mem_bf16 * 100
|
||||
print(f"VRAM reduction: {reduction:.1f}%")
|
||||
assert mem_gguf < mem_bf16, f"GGUF ({mem_gguf:.2f} GiB) should use less VRAM than BF16 ({mem_bf16:.2f} GiB)"
|
||||
@@ -431,6 +431,8 @@ def _free_gpu_memory():
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_benchmark_generate_image_unwraps_nested_omni_request_output(monkeypatch):
|
||||
from benchmarks.diffusion.quantization_quality import _generate_image as benchmark_generate_image
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
|
||||
@@ -22,6 +22,8 @@ from vllm_omni.quantization.tools.compare_diffusion_trajectory_similarity import
|
||||
summarize_output_image_metrics,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def test_compute_tensor_metrics_identical_tensors():
|
||||
metrics = compute_tensor_metrics(torch.ones(2, 3), torch.ones(2, 3))
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Ensure diffusion stage YAML configs only use valid OmniDiffusionConfig fields.
|
||||
|
||||
Regression test for https://github.com/vllm-project/vllm-omni/issues/2563
|
||||
"""
|
||||
|
||||
from dataclasses import fields
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
try:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
except Exception:
|
||||
OmniDiffusionConfig = None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
OmniDiffusionConfig is None,
|
||||
reason="OmniDiffusionConfig could not be imported (missing torch?)",
|
||||
)
|
||||
def test_diffusion_stage_configs_only_contain_valid_fields():
|
||||
"""Diffusion stage engine_args must only contain OmniDiffusionConfig fields.
|
||||
|
||||
Regression test for https://github.com/vllm-project/vllm-omni/issues/2563
|
||||
"""
|
||||
# Scan both main configs and test configs
|
||||
repo_root = Path(__file__).parent.parent.parent
|
||||
config_dirs = [
|
||||
repo_root / "vllm_omni" / "model_executor" / "stage_configs",
|
||||
]
|
||||
# Also scan test directories recursively
|
||||
test_dir = repo_root / "tests"
|
||||
|
||||
yaml_paths: list[Path] = []
|
||||
for config_dir in config_dirs:
|
||||
yaml_paths.extend(sorted(config_dir.glob("*.yaml")))
|
||||
yaml_paths.extend(sorted(test_dir.rglob("*.yaml")))
|
||||
|
||||
valid_fields = {f.name for f in fields(OmniDiffusionConfig)}
|
||||
# model_stage is consumed by the stage init layer, not OmniDiffusionConfig
|
||||
valid_fields.add("model_stage")
|
||||
# model_arch is consumed by the stage init layer for diffusion model class resolution
|
||||
valid_fields.add("model_arch")
|
||||
# "quantization" is mapped to "quantization_config" by from_kwargs() backwards-compat
|
||||
valid_fields.add("quantization")
|
||||
|
||||
invalid_entries: list[tuple[str, set[str]]] = []
|
||||
for yaml_path in yaml_paths:
|
||||
with open(yaml_path) as fh:
|
||||
config = yaml.safe_load(fh)
|
||||
|
||||
stages = config.get("stage_args", config.get("stages", []))
|
||||
for stage in stages:
|
||||
if stage.get("stage_type") != "diffusion":
|
||||
continue
|
||||
engine_args = stage.get("engine_args", {})
|
||||
invalid = set(engine_args.keys()) - valid_fields
|
||||
if invalid:
|
||||
invalid_entries.append((yaml_path.relative_to(repo_root), invalid))
|
||||
|
||||
assert not invalid_entries, "Diffusion stage configs contain fields not in OmniDiffusionConfig:\n" + "\n".join(
|
||||
f" {name}: {sorted(bad)}" for name, bad in invalid_entries
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Tests that parallel_config survives the create_default_diffusion roundtrip.
|
||||
|
||||
Regression tests for https://github.com/vllm-project/vllm-omni/issues/1862
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.diffusion.data import (
|
||||
DiffusionParallelConfig,
|
||||
OmniDiffusionConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.model_metadata import QWEN_IMAGE_EDIT_PLUS_MAX_INPUT_IMAGES
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _roundtrip_diffusion_config(**kwargs) -> OmniDiffusionConfig:
|
||||
"""Simulate the real path: create_default_diffusion → OmniDiffusionConfig.
|
||||
|
||||
Does NOT manually reconstruct parallel_config — relies on
|
||||
OmniDiffusionConfig.__post_init__ to handle the dict, just like
|
||||
the production code path does.
|
||||
"""
|
||||
stages = StageConfigFactory.create_default_diffusion(kwargs)
|
||||
engine_args = dict(stages[0]["engine_args"])
|
||||
return OmniDiffusionConfig.from_kwargs(**engine_args)
|
||||
|
||||
|
||||
class TestParallelConfigPropagation:
|
||||
"""Core regression tests: parallel_config must survive serialization."""
|
||||
|
||||
def test_tp2_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(tensor_parallel_size=2)
|
||||
od = _roundtrip_diffusion_config(model="test-model", parallel_config=pc)
|
||||
assert od.parallel_config.tensor_parallel_size == 2
|
||||
assert od.parallel_config.world_size == 2
|
||||
|
||||
def test_tp4_devices_and_config(self):
|
||||
pc = DiffusionParallelConfig(tensor_parallel_size=4)
|
||||
stages = StageConfigFactory.create_default_diffusion({"parallel_config": pc, "model": "x"})
|
||||
assert stages[0]["runtime"]["devices"] == "0,1,2,3"
|
||||
|
||||
# Let __post_init__ reconstruct from dict (real code path)
|
||||
ea = dict(stages[0]["engine_args"])
|
||||
od = OmniDiffusionConfig.from_kwargs(**ea)
|
||||
assert od.parallel_config.tensor_parallel_size == 4
|
||||
assert od.parallel_config.world_size == 4
|
||||
|
||||
def test_sp_config_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(
|
||||
tensor_parallel_size=2,
|
||||
ulysses_degree=2,
|
||||
ring_degree=1,
|
||||
)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.ulysses_degree == 2
|
||||
assert od.parallel_config.ring_degree == 1
|
||||
|
||||
def test_mask_sp_padding_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(ulysses_degree=2, mask_sp_padding=True)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.mask_sp_padding is True
|
||||
|
||||
def test_mask_sp_padding_defaults_false(self):
|
||||
pc = DiffusionParallelConfig(ulysses_degree=2)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.mask_sp_padding is False
|
||||
|
||||
def test_cfg_parallel_roundtrip(self):
|
||||
pc = DiffusionParallelConfig(cfg_parallel_size=2)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.parallel_config.cfg_parallel_size == 2
|
||||
assert od.parallel_config.world_size == 2
|
||||
|
||||
def test_no_parallel_config_defaults_to_tp1(self):
|
||||
od = _roundtrip_diffusion_config(model="x")
|
||||
assert od.parallel_config.tensor_parallel_size == 1
|
||||
assert od.parallel_config.world_size == 1
|
||||
|
||||
def test_num_gpus_derived_from_world_size(self):
|
||||
pc = DiffusionParallelConfig(tensor_parallel_size=2)
|
||||
od = _roundtrip_diffusion_config(model="x", parallel_config=pc)
|
||||
assert od.num_gpus == 2
|
||||
|
||||
|
||||
class TestCreateDefaultDiffusion:
|
||||
"""Verify engine_args structure from create_default_diffusion."""
|
||||
|
||||
def test_parallel_config_serialized_as_dict(self):
|
||||
"""The key fix: parallel_config must appear in engine_args as a dict."""
|
||||
pc = DiffusionParallelConfig(tensor_parallel_size=2)
|
||||
stages = StageConfigFactory.create_default_diffusion({"model": "x", "parallel_config": pc})
|
||||
ea = stages[0]["engine_args"]
|
||||
assert "parallel_config" in ea
|
||||
assert isinstance(ea["parallel_config"], Mapping)
|
||||
assert ea["parallel_config"]["tensor_parallel_size"] == 2
|
||||
|
||||
def test_dtype_serialized_as_string(self):
|
||||
stages = StageConfigFactory.create_default_diffusion({"dtype": torch.float16, "model": "x"})
|
||||
assert stages[0]["engine_args"]["dtype"] == "torch.float16"
|
||||
|
||||
def test_cache_backend_defaults_to_none(self):
|
||||
stages = StageConfigFactory.create_default_diffusion({"model": "x"})
|
||||
assert stages[0]["engine_args"]["cache_backend"] == "none"
|
||||
|
||||
def test_single_gpu_default_devices(self):
|
||||
stages = StageConfigFactory.create_default_diffusion({"model": "x"})
|
||||
assert stages[0]["runtime"]["devices"] == "0"
|
||||
|
||||
def test_extra_kwargs_forwarded(self):
|
||||
stages = StageConfigFactory.create_default_diffusion(
|
||||
{"model": "x", "enforce_eager": True, "lora_path": "/tmp/lora"}
|
||||
)
|
||||
ea = stages[0]["engine_args"]
|
||||
assert ea["enforce_eager"] is True
|
||||
assert ea["lora_path"] == "/tmp/lora"
|
||||
|
||||
|
||||
def test_qwen_image_edit_plus_sets_generic_multimodal_limit():
|
||||
od_config = OmniDiffusionConfig(model="Qwen/Qwen-Image-Edit-2511", model_class_name="QwenImageEditPlusPipeline")
|
||||
|
||||
od_config.update_multimodal_support()
|
||||
|
||||
assert od_config.supports_multimodal_inputs is True
|
||||
assert od_config.max_multimodal_image_inputs == QWEN_IMAGE_EDIT_PLUS_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)
|
||||
assert od.additional_config == additional_config
|
||||
@@ -14,6 +14,7 @@ import torch
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
import vllm_omni.diffusion.diffusion_engine as diffusion_engine_module
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.diffusion_engine import (
|
||||
DiffusionEngine,
|
||||
@@ -30,6 +31,8 @@ from vllm_omni.diffusion.sched.interface import (
|
||||
)
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiffusionSchedulerOutput:
|
||||
@@ -56,6 +59,8 @@ class MockScheduler:
|
||||
def __init__(self):
|
||||
self._waiting_queue = []
|
||||
self._step_id = 0
|
||||
# Match RequestScheduler API used by request-batch admission wait.
|
||||
self.max_num_running_reqs = 5
|
||||
|
||||
def add_request(self, request):
|
||||
self._waiting_queue.append(request)
|
||||
@@ -64,6 +69,12 @@ class MockScheduler:
|
||||
def has_requests(self):
|
||||
return len(self._waiting_queue) > 0
|
||||
|
||||
def num_waiting_requests(self) -> int:
|
||||
return len(self._waiting_queue)
|
||||
|
||||
def num_running_requests(self) -> int:
|
||||
return 0
|
||||
|
||||
def schedule(self) -> DiffusionSchedulerOutput:
|
||||
if not self._waiting_queue:
|
||||
return DiffusionSchedulerOutput(step_id=self._step_id)
|
||||
@@ -582,9 +593,7 @@ def test_move_tensor_tree_returns_non_tensor_values_unchanged() -> None:
|
||||
assert moved is value
|
||||
|
||||
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_move_tensor_tree_moves_nested_cuda_tensors_to_cpu() -> None:
|
||||
tensor = torch.arange(8, dtype=torch.float32, device="cuda")
|
||||
other = torch.arange(4, dtype=torch.int64, device="cuda")
|
||||
@@ -610,6 +619,7 @@ async def _consume_final_output(generator):
|
||||
return final_output
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_add_req_and_stream_response():
|
||||
engine = object.__new__(DiffusionEngine)
|
||||
@@ -621,13 +631,24 @@ async def test_async_add_req_and_stream_response():
|
||||
engine._cv = threading.Condition(engine._rpc_lock)
|
||||
engine._init_lock = asyncio.Lock()
|
||||
engine._closed = False
|
||||
engine.od_config = SimpleNamespace(streaming_output=False)
|
||||
# Enable admission wait so concurrent adds land in one schedule wave;
|
||||
# otherwise the first request can execute alone (~1s) and the rest in a
|
||||
# second wave (~2s), failing the latency-spread assertion.
|
||||
engine.od_config = SimpleNamespace(
|
||||
streaming_output=False,
|
||||
request_batch_max_wait_ms=500.0,
|
||||
)
|
||||
engine._loop_started = False
|
||||
engine.main_loop = None
|
||||
engine.supports_request_batch = False
|
||||
engine.supports_request_batch = True
|
||||
engine.step_execution = False
|
||||
engine.execution_mode = DiffusionExecutionMode.REQUEST_BATCH
|
||||
|
||||
engine._finalize_finished_request = lambda rid, out, err: out.result
|
||||
def _finalize(rid, out, err=None):
|
||||
# Stream consumers stop on ``finished``; keep result_data for assertions.
|
||||
return SimpleNamespace(result_data=out.result.result_data, finished=True)
|
||||
|
||||
engine._finalize_finished_request = _finalize
|
||||
|
||||
def mock_execute_batch(sched_output):
|
||||
request_ids = sched_output.scheduled_request_ids
|
||||
|
||||
@@ -12,6 +12,11 @@ from __future__ import annotations
|
||||
import ast
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
_ENGINE_PATH = os.path.normpath(
|
||||
os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
|
||||
Reference in new issue
Block a user