[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
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)
|
||||
|
||||
Reference in new issue
Block a user