Merge branch 'main' into codex-diffusion-shutdown-escalation
This commit is contained in:
621 files changed
+44129
-12315
No files matched your search
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU contracts for capability-driven AR-Diffusion sessions."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -10,6 +11,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.sched.interface import CachedRequestData, DiffusionSchedulerOutput
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.experimental.ar_diffusion.capability import (
|
||||
ARDiffusionCrossAttentionKVSpec,
|
||||
@@ -27,6 +29,22 @@ NEG = "negative"
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def platform_synchronize_calls(monkeypatch) -> list[None]:
|
||||
"""Stub the platform barrier the runner takes at every chunk boundary.
|
||||
|
||||
These tests run on a CPU host, where the resolved platform is
|
||||
``UnspecifiedOmniPlatform`` and ``synchronize()`` raises. Tests that need
|
||||
a failing barrier override this with their own fake.
|
||||
"""
|
||||
calls: list[None] = []
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.experimental.ar_diffusion.runner.current_omni_platform",
|
||||
SimpleNamespace(synchronize=lambda: calls.append(None)),
|
||||
)
|
||||
return calls
|
||||
|
||||
|
||||
def lingbot_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec:
|
||||
"""Single-kv_branch causal DMD: 3 latent frames/block + sink/window + text KV."""
|
||||
return ARDiffusionKVCacheSpec(
|
||||
@@ -114,6 +132,43 @@ class BatchCapablePipeline(CapablePipeline):
|
||||
supports_request_batch = True
|
||||
|
||||
|
||||
class StepCapablePipeline(CapablePipeline):
|
||||
supports_step_execution = True
|
||||
|
||||
def prepare_encode(self, state, **kwargs):
|
||||
del kwargs
|
||||
return state
|
||||
|
||||
def denoise_step(self, input_batch, *, states=None, **kwargs):
|
||||
del input_batch, states, kwargs
|
||||
return None
|
||||
|
||||
def step_scheduler(self, state, noise_pred, **kwargs):
|
||||
del state, noise_pred, kwargs
|
||||
|
||||
def post_decode(self, state, **kwargs):
|
||||
del state, kwargs
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
|
||||
return DiffusionOutput()
|
||||
|
||||
|
||||
def scheduler_output(
|
||||
request_ids: tuple[str, ...] = ("req-1",),
|
||||
*,
|
||||
finished: tuple[str, ...] = (),
|
||||
) -> DiffusionSchedulerOutput:
|
||||
"""Build the real scheduler payload; stepwise requests arrive as cached rows."""
|
||||
return DiffusionSchedulerOutput(
|
||||
step_id=0,
|
||||
scheduled_new_reqs=[],
|
||||
scheduled_cached_reqs=CachedRequestData(request_ids=list(request_ids)),
|
||||
finished_req_ids=set(finished),
|
||||
num_running_reqs=len(request_ids),
|
||||
num_waiting_reqs=0,
|
||||
)
|
||||
|
||||
|
||||
def make_runner(
|
||||
pipeline: object,
|
||||
*,
|
||||
@@ -140,6 +195,7 @@ def make_runner(
|
||||
runner._sessions = OrderedDict()
|
||||
runner._session_capacity = 0
|
||||
runner._perf_e2e_times = []
|
||||
runner._stepwise_chunk_started = {}
|
||||
runner._preallocate_kv_cache(available_bytes=available_bytes)
|
||||
return runner
|
||||
|
||||
@@ -395,12 +451,16 @@ def test_synchronize_exception_uses_forward_cleanup_path(monkeypatch):
|
||||
ctx.ensure_video_slots(torch.device("cpu"))
|
||||
return object()
|
||||
|
||||
def synchronize_boom(device):
|
||||
def synchronize_boom():
|
||||
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")
|
||||
# The runner defers the device barrier to the platform, so the failure is
|
||||
# injected there rather than on a device-specific torch entry point.
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.experimental.ar_diffusion.runner.current_omni_platform",
|
||||
SimpleNamespace(synchronize=synchronize_boom),
|
||||
)
|
||||
request = SimpleNamespace(
|
||||
request_id="broken-request",
|
||||
sampling_params=SimpleNamespace(extra_args={"session_id": "broken"}),
|
||||
@@ -424,6 +484,11 @@ def test_ar_runner_rejects_step_and_request_batch_modes():
|
||||
make_runner(BatchCapablePipeline(lingbot_like_spec()))
|
||||
|
||||
|
||||
def test_ar_runner_allows_step_execution_when_pipeline_implements_the_contract():
|
||||
runner = make_runner(StepCapablePipeline(lingbot_like_spec()), step_execution=True)
|
||||
assert runner.kv_cache is not None
|
||||
|
||||
|
||||
def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints():
|
||||
runner = object.__new__(ARDiffusionModelRunner)
|
||||
with pytest.raises(RuntimeError, match="request-batch execution"):
|
||||
@@ -432,6 +497,144 @@ def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints():
|
||||
runner.execute_stepwise(None)
|
||||
|
||||
|
||||
def test_execute_stepwise_binds_request_id_session_and_unbinds_after_call(monkeypatch):
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
bound_during = []
|
||||
|
||||
def fake_execute(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
bound_during.append(pipeline.bound_state is not None)
|
||||
return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False)])
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute)
|
||||
output = runner.execute_stepwise(scheduler_output())
|
||||
|
||||
assert bound_during == [True]
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.binds == ["req-1"]
|
||||
assert "req-1" in runner._sessions
|
||||
assert output.get_request_output("req-1") is not None
|
||||
assert not pipeline.closes
|
||||
|
||||
|
||||
def test_execute_stepwise_closes_session_when_request_finishes(monkeypatch):
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
|
||||
def fake_execute(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=True)])
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute)
|
||||
runner.execute_stepwise(scheduler_output())
|
||||
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.closes == ["req-1"]
|
||||
assert "req-1" not in runner._sessions
|
||||
|
||||
|
||||
def test_execute_stepwise_exception_releases_kv_and_does_not_resume_pages(monkeypatch):
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
first = runner._get_or_create_session("req-1")
|
||||
|
||||
def boom(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
raise RuntimeError("step failed")
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", boom)
|
||||
with pytest.raises(RuntimeError, match="step failed"):
|
||||
runner.execute_stepwise(scheduler_output())
|
||||
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.closes == ["req-1"]
|
||||
assert "req-1" not in runner._sessions
|
||||
second = runner._get_or_create_session("req-1")
|
||||
assert second is not first
|
||||
|
||||
|
||||
def test_execute_stepwise_closes_session_for_scheduler_aborted_request(monkeypatch):
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput
|
||||
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
runner._get_or_create_session("req-1")
|
||||
|
||||
def fake_execute(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
return BatchRunnerOutput.from_list([])
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute)
|
||||
# An aborted request is never scheduled again and never reports finished,
|
||||
# so the retire path is the only thing that can free its KV.
|
||||
runner.execute_stepwise(scheduler_output(request_ids=(), finished=("req-1",)))
|
||||
|
||||
assert "req-1" not in runner._sessions
|
||||
assert pipeline.closes == ["req-1"]
|
||||
|
||||
|
||||
def test_execute_stepwise_times_once_per_chunk_not_once_per_step(monkeypatch, platform_synchronize_calls):
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
results = [None, None, None, DiffusionOutput()]
|
||||
|
||||
def fake_execute(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False, result=results.pop(0))])
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute)
|
||||
for _ in range(4):
|
||||
runner.execute_stepwise(scheduler_output())
|
||||
|
||||
# Four denoise steps, one emitted AR block: one timing sample, matching the
|
||||
# one-sample-per-block meaning request mode already has.
|
||||
assert len(runner._perf_e2e_times) == 1
|
||||
assert len(platform_synchronize_calls) == 1
|
||||
assert "req-1" not in runner._stepwise_chunk_started
|
||||
|
||||
|
||||
def test_execute_stepwise_synchronize_exception_fails_closed(monkeypatch):
|
||||
"""The chunk barrier is where an asynchronous device error surfaces. It must
|
||||
clean up like an in-step failure instead of leaving the session, the cached
|
||||
step state and the timing entry to the scheduler's next cycle."""
|
||||
from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput
|
||||
|
||||
pipeline = StepCapablePipeline(lingbot_like_spec())
|
||||
runner = make_runner(pipeline, step_execution=True)
|
||||
runner.state_cache = {"req-1": object()}
|
||||
|
||||
def fake_execute(self, scheduler_output):
|
||||
del self, scheduler_output
|
||||
return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False, result=object())])
|
||||
|
||||
def synchronize_boom():
|
||||
raise RuntimeError("asynchronous kernel failed")
|
||||
|
||||
monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute)
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.experimental.ar_diffusion.runner.current_omni_platform",
|
||||
SimpleNamespace(synchronize=synchronize_boom),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="asynchronous kernel failed"):
|
||||
runner.execute_stepwise(scheduler_output())
|
||||
|
||||
assert pipeline.bound_state is None
|
||||
assert pipeline.closes == ["req-1"]
|
||||
assert "req-1" not in runner._sessions
|
||||
assert "req-1" not in runner.state_cache
|
||||
assert "req-1" not in runner._stepwise_chunk_started
|
||||
assert not runner._perf_e2e_times
|
||||
|
||||
|
||||
def test_model_specific_warmup_provider_is_consumed(monkeypatch):
|
||||
requests = [object(), object()]
|
||||
pipeline = WarmupPipeline(lingbot_like_spec(), requests)
|
||||
|
||||
@@ -25,12 +25,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
)
|
||||
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
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False):
|
||||
@@ -155,14 +152,155 @@ def test_paged_pool_layout_exposes_flat_slot_views():
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64)
|
||||
key_cache, value_cache = kv_pools[0]
|
||||
assert key_cache.shape == value_cache.shape == (4, BLOCK, 4, 64)
|
||||
assert k_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
assert v_pools[0].shape == (4 * BLOCK, 4, 64)
|
||||
|
||||
# The flat views must alias their block-shaped cache, so a slot write is
|
||||
# visible through the layout the attention kernel reads.
|
||||
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])
|
||||
assert torch.equal(key_cache[1, 3], k_pools[0][BLOCK + 3])
|
||||
assert torch.equal(value_cache[2, 5], v_pools[0][2 * BLOCK + 5])
|
||||
|
||||
|
||||
def test_key_and_value_caches_do_not_share_storage():
|
||||
"""K and V must be separate allocations, not halves of one tensor.
|
||||
|
||||
The paged-write custom op declares both as mutated. When they alias one
|
||||
storage, inductor's reinplace pass can re-inplace only the first of the
|
||||
two, and auto_functionalized_v2's clone of the entire second pool survives
|
||||
into the compiled graph -- one full pool copied per compiled region per
|
||||
denoising step.
|
||||
"""
|
||||
kv_pools, k_pools, v_pools = allocate_kv_pool_with_views(
|
||||
num_blocks=4,
|
||||
block_size=BLOCK,
|
||||
num_layers=2,
|
||||
num_kv_heads=4,
|
||||
head_dim=64,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
seen = set()
|
||||
for layer, (key_cache, value_cache) in enumerate(kv_pools):
|
||||
k_storage = key_cache.untyped_storage().data_ptr()
|
||||
v_storage = value_cache.untyped_storage().data_ptr()
|
||||
assert k_storage != v_storage, f"layer {layer}: K and V share one allocation"
|
||||
assert k_pools[layer].untyped_storage().data_ptr() == k_storage
|
||||
assert v_pools[layer].untyped_storage().data_ptr() == v_storage
|
||||
seen.update((k_storage, v_storage))
|
||||
# Every layer's K and V are distinct allocations as well.
|
||||
assert len(seen) == 2 * len(kv_pools)
|
||||
|
||||
# Negative control: writing V must not disturb K.
|
||||
k_pools[0].fill_(0)
|
||||
v_pools[0].fill_(5)
|
||||
assert torch.equal(k_pools[0], torch.zeros_like(k_pools[0]))
|
||||
|
||||
|
||||
def _pool_sized_allocations(code: str, pool_numel: int) -> list[int]:
|
||||
"""Sizes of generated allocations at least as large as one pool."""
|
||||
import re
|
||||
|
||||
sizes = []
|
||||
for shape in re.findall(r"empty_strided_\w+\(\(([\d, ]*?)\)", code):
|
||||
dims = [int(part) for part in shape.replace(" ", "").strip(",").split(",") if part]
|
||||
numel = 1
|
||||
for dim_size in dims:
|
||||
numel *= dim_size
|
||||
if dims and numel >= pool_numel:
|
||||
sizes.append(numel)
|
||||
return sizes
|
||||
|
||||
|
||||
def _generated_code_for_pools(key_cache, value_cache):
|
||||
"""Compile a paged-write-shaped op over these pools, return the generated code."""
|
||||
from torch._inductor import config as inductor_config
|
||||
from torch._inductor.utils import run_and_get_code
|
||||
|
||||
class _Holder(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.register_buffer("key_cache", key_cache)
|
||||
self.register_buffer("value_cache", value_cache)
|
||||
|
||||
def forward(self, value):
|
||||
return torch.ops.test_kv_pool_clone.write(self.key_cache, self.value_cache, value)
|
||||
|
||||
torch._dynamo.reset()
|
||||
# Inductor caches compiled code across ``torch._dynamo.reset()``, so the
|
||||
# second call in a process would otherwise return the first one's code and
|
||||
# both branches of this test would describe the same graph.
|
||||
with inductor_config.patch(force_disable_caches=True):
|
||||
_, codes = run_and_get_code(torch.compile(_Holder(), backend="inductor", fullgraph=True), torch.ones(1))
|
||||
return "\n".join(codes)
|
||||
|
||||
|
||||
def test_compiled_paged_write_does_not_clone_the_pool():
|
||||
"""The compiled graph must write the pools in place, not through a clone.
|
||||
|
||||
``test_key_and_value_caches_do_not_share_storage`` pins the mechanism this
|
||||
change introduces -- two allocations rather than one. This pins the effect
|
||||
that mechanism buys, which is the reason the change exists: with K and V as
|
||||
halves of one tensor, inductor's reinplace pass re-inplaces only the first,
|
||||
and ``auto_functionalized_v2``'s clone of the pool survives into the
|
||||
generated code as a pool-sized allocation plus a copy, once per compiled
|
||||
region per denoising step.
|
||||
|
||||
The shared layout is compiled here as a positive control. Without it, a
|
||||
codegen change that stops emitting ``empty_strided_*`` would leave the
|
||||
detector matching nothing and this test passing for the wrong reason.
|
||||
"""
|
||||
if "write" not in dir(getattr(torch.ops, "test_kv_pool_clone", object())):
|
||||
|
||||
@torch.library.custom_op("test_kv_pool_clone::write", mutates_args=("key_pool", "value_pool"))
|
||||
def _write(key_pool: torch.Tensor, value_pool: torch.Tensor, value: torch.Tensor) -> torch.Tensor:
|
||||
key_pool[0, 0, 0, 0] = value[0]
|
||||
value_pool[0, 0, 0, 0] = value[0]
|
||||
return value * 2
|
||||
|
||||
@_write.register_fake
|
||||
def _(key_pool, value_pool, value):
|
||||
return torch.empty_like(value)
|
||||
|
||||
# The stand-in models the real op's contract; that declaration is what
|
||||
# drives the reinplace pass, so a drift here would silently test nothing.
|
||||
real_schema = torch.ops.vllm_omni.ar_diffusion_paged_write_attn.default._schema
|
||||
real_mutated = [arg.name for arg in real_schema.arguments if arg.alias_info and arg.alias_info.is_write]
|
||||
assert real_mutated == ["key_pool", "value_pool"], (
|
||||
f"the real paged-write op now mutates {real_mutated}; this test still models two pools"
|
||||
)
|
||||
|
||||
num_blocks, heads, dim = 8, 4, 16
|
||||
cache_shape = (num_blocks, BLOCK, heads, dim)
|
||||
pool_numel = num_blocks * BLOCK * heads * dim
|
||||
|
||||
# Positive control: one allocation, K and V aliasing it.
|
||||
shared = torch.empty(2, *cache_shape)
|
||||
shared_allocations = _pool_sized_allocations(_generated_code_for_pools(shared[0], shared[1]), pool_numel)
|
||||
assert shared_allocations, (
|
||||
"the shared layout produced no pool-sized allocation, so this test can no "
|
||||
"longer tell the two layouts apart -- inductor's codegen or the detector changed"
|
||||
)
|
||||
|
||||
# The allocator under test.
|
||||
kv_pools, _, _ = allocate_kv_pool_with_views(
|
||||
num_blocks=num_blocks,
|
||||
block_size=BLOCK,
|
||||
num_layers=1,
|
||||
num_kv_heads=heads,
|
||||
head_dim=dim,
|
||||
dtype=torch.float32,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
key_cache, value_cache = kv_pools[0]
|
||||
separate_allocations = _pool_sized_allocations(_generated_code_for_pools(key_cache, value_cache), pool_numel)
|
||||
assert not separate_allocations, (
|
||||
"the compiled graph allocates a pool-sized buffer, so the pool is being cloned "
|
||||
f"rather than written in place: {separate_allocations} (control saw {shared_allocations})"
|
||||
)
|
||||
|
||||
|
||||
def test_build_manager_allocate_free_roundtrip():
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""CUDA-only regression test for the AR-Diffusion paged KV write op.
|
||||
|
||||
Split out of ``test_kv_cache.py``: that module is swept into CI's
|
||||
``core_model and cpu`` job via a module-level ``pytest.mark.cpu``, and
|
||||
``hardware_test(..., num_cards=1)`` adds no ``skipif`` guard for a missing
|
||||
GPU, so this test was being collected (and would fail to import a CUDA
|
||||
device) on CPU-only runners. Keeping it in its own, non-``cpu`` module means
|
||||
it is only ever collected by a job that explicitly selects ``cuda`` for this
|
||||
path -- none does yet, so for now this is intentionally not wired into any
|
||||
CI job.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import allocate_kv_pool_with_views
|
||||
|
||||
BLOCK = 16
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
def test_real_paged_write_op_does_not_clone_the_pool_on_cuda():
|
||||
"""Same assertion as the CPU test, against the real op's schema and CUDA codegen.
|
||||
|
||||
The CPU test compiles a stand-in with the same ``mutates_args`` contract,
|
||||
because the real op needs a device and an attention kernel. That leaves
|
||||
one thing unverified: whether Triton codegen reinplaces the same way,
|
||||
which is the path the reported regression was observed on.
|
||||
|
||||
This does not call the real op directly: ``ar_diffusion_paged_write_attn``
|
||||
runs FlashAttention's paged ``flash_attn_varlen_func`` internally, and
|
||||
compiling that under ``fullgraph=True`` can fail for reasons that have
|
||||
nothing to do with cloning (a missing kernel for this toy shape, a graph
|
||||
break, or an autotune illegal-memory-access on the control pool). Instead
|
||||
this registers a second stand-in that copies the real op's full argument
|
||||
list and ``mutates_args`` declaration -- so reinplace sees the identical
|
||||
contract the real op presents -- but skips straight to the pool writes,
|
||||
the same way the real op's own implementation does before it calls
|
||||
attention. That isolates "does Inductor clone this schema on CUDA" from
|
||||
"does FlashAttention compile", which is a separate, already-covered
|
||||
concern.
|
||||
|
||||
Note the pools passed here are the flat views, since that is what the op
|
||||
takes -- it indexes them by slot and unflattens internally.
|
||||
"""
|
||||
import re
|
||||
|
||||
from torch._inductor import config as inductor_config
|
||||
from torch._inductor.utils import run_and_get_code
|
||||
|
||||
device = torch.device("cuda")
|
||||
num_blocks, heads, dim, num_tokens = 8, 4, 64, 8
|
||||
pool_numel = num_blocks * BLOCK * heads * dim
|
||||
# The size a genuine clone would produce: K and V pool-sized allocations,
|
||||
# coalesced into one buffer the way `auto_functionalized_v2` was cloning
|
||||
# them before this PR's fix. Matching this exact size (rather than "any
|
||||
# allocation at least as large as one pool") avoids false positives from
|
||||
# unrelated workspace/scratch buffers Inductor may emit in that range.
|
||||
clone_numel = 2 * pool_numel
|
||||
|
||||
if "write" not in dir(getattr(torch.ops, "test_kv_pool_clone_schema", object())):
|
||||
|
||||
@torch.library.custom_op("test_kv_pool_clone_schema::write", mutates_args=("key_pool", "value_pool"))
|
||||
def _write(
|
||||
query: torch.Tensor,
|
||||
k_curr: torch.Tensor,
|
||||
v_curr: torch.Tensor,
|
||||
k_act: torch.Tensor | None,
|
||||
v_act: torch.Tensor | None,
|
||||
key_pool: torch.Tensor,
|
||||
value_pool: torch.Tensor,
|
||||
block_size: int,
|
||||
video_slots: torch.Tensor,
|
||||
action_slots: torch.Tensor,
|
||||
block_table: torch.Tensor,
|
||||
query_start_loc: torch.Tensor,
|
||||
seq_lens: torch.Tensor,
|
||||
max_query_len: int,
|
||||
max_seq_len: int,
|
||||
softmax_scale: float,
|
||||
) -> torch.Tensor:
|
||||
# Same pool-write side effect as `_paged_write_attn_impl`, minus
|
||||
# the `ar_diffusion_paged_attention` call -- that is the part
|
||||
# this test is not trying to exercise.
|
||||
key_pool[video_slots] = k_curr.to(key_pool.dtype)
|
||||
value_pool[video_slots] = v_curr.to(value_pool.dtype)
|
||||
if k_act is not None and v_act is not None and k_act.shape[0] > 0:
|
||||
key_pool[action_slots] = k_act.to(key_pool.dtype)
|
||||
value_pool[action_slots] = v_act.to(value_pool.dtype)
|
||||
return query * 2
|
||||
|
||||
@_write.register_fake
|
||||
def _(
|
||||
query,
|
||||
k_curr,
|
||||
v_curr,
|
||||
k_act,
|
||||
v_act,
|
||||
key_pool,
|
||||
value_pool,
|
||||
block_size,
|
||||
video_slots,
|
||||
action_slots,
|
||||
block_table,
|
||||
query_start_loc,
|
||||
seq_lens,
|
||||
max_query_len,
|
||||
max_seq_len,
|
||||
softmax_scale,
|
||||
):
|
||||
return torch.empty_like(query)
|
||||
|
||||
class _Holder(torch.nn.Module):
|
||||
def __init__(self, key_flat, value_flat):
|
||||
super().__init__()
|
||||
self.register_buffer("key_flat", key_flat)
|
||||
self.register_buffer("value_flat", value_flat)
|
||||
|
||||
def forward(self, query, k_curr, v_curr, video_slots, action_slots, block_table, query_start_loc, seq_lens):
|
||||
return torch.ops.test_kv_pool_clone_schema.write(
|
||||
query,
|
||||
k_curr,
|
||||
v_curr,
|
||||
None,
|
||||
None,
|
||||
self.key_flat,
|
||||
self.value_flat,
|
||||
BLOCK,
|
||||
video_slots,
|
||||
action_slots,
|
||||
block_table,
|
||||
query_start_loc,
|
||||
seq_lens,
|
||||
num_tokens,
|
||||
num_tokens,
|
||||
dim**-0.5,
|
||||
)
|
||||
|
||||
def compiled_pool_allocations(key_flat, value_flat) -> list[int]:
|
||||
args = (
|
||||
torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device),
|
||||
torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device),
|
||||
torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device),
|
||||
torch.arange(num_tokens, device=device),
|
||||
torch.empty(0, dtype=torch.long, device=device),
|
||||
torch.arange(num_blocks, device=device, dtype=torch.int32).unsqueeze(0),
|
||||
torch.tensor([0, num_tokens], device=device, dtype=torch.int32),
|
||||
torch.tensor([num_tokens], device=device, dtype=torch.int32),
|
||||
)
|
||||
torch._dynamo.reset()
|
||||
# See the CPU test: inductor's cache outlives torch._dynamo.reset().
|
||||
with inductor_config.patch(force_disable_caches=True):
|
||||
_, codes = run_and_get_code(
|
||||
torch.compile(_Holder(key_flat, value_flat), backend="inductor", fullgraph=True), *args
|
||||
)
|
||||
sizes = []
|
||||
for shape in re.findall(r"empty_strided_cuda\(\(([\d, ]*?)\)", "\n".join(codes)):
|
||||
dims = [int(part) for part in shape.replace(" ", "").strip(",").split(",") if part]
|
||||
numel = 1
|
||||
for dim_size in dims:
|
||||
numel *= dim_size
|
||||
if dims and numel >= pool_numel:
|
||||
sizes.append(numel)
|
||||
return sizes
|
||||
|
||||
# Positive control: one allocation, K and V as its two halves. This must
|
||||
# produce an allocation of exactly `clone_numel` -- that is the clone
|
||||
# signature the assertion below checks for, not "no allocation this big".
|
||||
shared = torch.empty(2, num_blocks * BLOCK, heads, dim, dtype=torch.bfloat16, device=device)
|
||||
control = compiled_pool_allocations(shared[0], shared[1])
|
||||
assert clone_numel in control, (
|
||||
f"the shared layout produced no {clone_numel}-element allocation ({control}), so this test can no "
|
||||
"longer tell the two layouts apart -- inductor's codegen or the detector changed"
|
||||
)
|
||||
|
||||
_, k_pools, v_pools = allocate_kv_pool_with_views(
|
||||
num_blocks=num_blocks,
|
||||
block_size=BLOCK,
|
||||
num_layers=1,
|
||||
num_kv_heads=heads,
|
||||
head_dim=dim,
|
||||
dtype=torch.bfloat16,
|
||||
device=device,
|
||||
)
|
||||
separate = compiled_pool_allocations(k_pools[0], v_pools[0])
|
||||
assert clone_numel not in separate, (
|
||||
f"the compiled graph allocates a {clone_numel}-element buffer, so the pool is being cloned "
|
||||
f"rather than written in place: {separate} (control saw {control})"
|
||||
)
|
||||
@@ -13,6 +13,12 @@ from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
|
||||
def _clear_hub_module_cache(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.attention.backends import flash_attn_hub
|
||||
|
||||
monkeypatch.setattr(flash_attn_hub, "_hub_modules", {})
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_explicit_kernels_hub_selection_does_not_fallback(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -54,6 +60,74 @@ def test_explicit_kernels_hub_selection_does_not_fallback(monkeypatch: pytest.Mo
|
||||
CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_explicit_kernels_hub_variant_miss_does_not_fallback(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Explicit Hub backends must fail loudly when no build variant resolves (#6971)."""
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
|
||||
from vllm_omni.diffusion.envs import PACKAGES_CHECKER
|
||||
from vllm_omni.platforms.cuda.platform import CudaOmniPlatform
|
||||
|
||||
monkeypatch.setattr(
|
||||
CudaOmniPlatform,
|
||||
"get_device_capability",
|
||||
classmethod(lambda cls, device_id=0: DeviceCapability(9, 0)),
|
||||
)
|
||||
monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": True})
|
||||
_clear_hub_module_cache(monkeypatch)
|
||||
|
||||
kernels_module = types.ModuleType("kernels")
|
||||
|
||||
def _missing_variant(repo_id, version=None, **kwargs):
|
||||
raise FileNotFoundError(f"Cannot find a build variant for this system in {repo_id}")
|
||||
|
||||
kernels_module.get_kernel = _missing_variant # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "kernels", kernels_module)
|
||||
|
||||
with pytest.raises(RuntimeError, match="no compatible build"):
|
||||
CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64)
|
||||
with pytest.raises(RuntimeError, match="no compatible build"):
|
||||
CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_HUB", head_size=64)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_explicit_kernels_hub_preflight_uses_v2_when_v1_misses(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Preflight must share _load_hub_module's (1, 2) policy, not pin version=1."""
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum
|
||||
from vllm_omni.diffusion.envs import PACKAGES_CHECKER
|
||||
from vllm_omni.platforms.cuda.platform import CudaOmniPlatform
|
||||
|
||||
monkeypatch.setattr(
|
||||
CudaOmniPlatform,
|
||||
"get_device_capability",
|
||||
classmethod(lambda cls, device_id=0: DeviceCapability(9, 0)),
|
||||
)
|
||||
monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": True})
|
||||
_clear_hub_module_cache(monkeypatch)
|
||||
|
||||
kernels_module = types.ModuleType("kernels")
|
||||
versions_tried: list[int | None] = []
|
||||
|
||||
def _v1_miss_v2_hit(repo_id, version=None, **kwargs):
|
||||
versions_tried.append(version)
|
||||
if version == 1:
|
||||
raise FileNotFoundError(f"Cannot find a build variant for this system in {repo_id}")
|
||||
return types.SimpleNamespace(name=f"{repo_id}@v{version}")
|
||||
|
||||
kernels_module.get_kernel = _v1_miss_v2_hit # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "kernels", kernels_module)
|
||||
|
||||
fa3_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64)
|
||||
assert fa3_path == DiffusionAttentionBackendEnum.FLASH_ATTN_3_HUB.get_path()
|
||||
fa2_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_HUB", head_size=64)
|
||||
assert fa2_path == DiffusionAttentionBackendEnum.FLASH_ATTN_HUB.get_path()
|
||||
assert versions_tried == [1, 2, 1, 2]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_explicit_flash_attention_unavailable_does_not_fallback(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -27,6 +27,202 @@ from vllm_omni.platforms import current_omni_platform
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_uaa_equal_rank_contract_skips_length_gather(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses
|
||||
|
||||
class FakeGroup:
|
||||
ulysses_group = object()
|
||||
ulysses_world_size = 2
|
||||
ulysses_rank = 0
|
||||
ring_world_size = 1
|
||||
|
||||
observed = []
|
||||
|
||||
def fake_all_to_all(pg, tensor, **kwargs):
|
||||
observed.append((kwargs["seq_lens"], kwargs["padded_head_cnt"]))
|
||||
return tensor, tensor.shape[2]
|
||||
|
||||
monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all)
|
||||
monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa")
|
||||
monkeypatch.setattr(
|
||||
ulysses,
|
||||
"_all_gather_int",
|
||||
lambda *args, **kwargs: pytest.fail("length gather must be skipped"),
|
||||
)
|
||||
strategy = ulysses.UlyssesParallelAttention(
|
||||
FakeGroup(),
|
||||
scatter_idx=2,
|
||||
gather_idx=1,
|
||||
use_sync=False,
|
||||
)
|
||||
|
||||
with set_forward_context():
|
||||
get_forward_context()._sp_equal_pad_stack.append(True)
|
||||
strategy.pre_attention(
|
||||
torch.zeros(1, 3, 28, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
None,
|
||||
)
|
||||
|
||||
assert observed == [([3, 3], 32), ([3, 3], 8), ([3, 3], 8)]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_uaa_hybrid_equal_rank_contract_skips_ulysses_gather_keeps_ring_check(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Contract + ring: the fast path skips only the Ulysses gather.
|
||||
|
||||
The ring equality check is a separate collective on ring_group and must
|
||||
still run, receiving s_global derived from the contract.
|
||||
"""
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses
|
||||
|
||||
class FakeGroup:
|
||||
ulysses_group = object()
|
||||
ring_group = object()
|
||||
ulysses_world_size = 2
|
||||
ulysses_rank = 0
|
||||
ring_world_size = 2
|
||||
|
||||
sp_group = FakeGroup()
|
||||
observed = []
|
||||
ring_checks = []
|
||||
|
||||
def fake_all_to_all(pg, tensor, **kwargs):
|
||||
observed.append((kwargs["seq_lens"], kwargs["padded_head_cnt"]))
|
||||
return tensor, tensor.shape[2]
|
||||
|
||||
def fake_gather_int(pg, value, *, device):
|
||||
if pg is sp_group.ulysses_group:
|
||||
pytest.fail("Ulysses length gather must be skipped under the contract")
|
||||
assert pg is sp_group.ring_group
|
||||
ring_checks.append(int(value))
|
||||
return [int(value), int(value)]
|
||||
|
||||
monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all)
|
||||
monkeypatch.setattr(ulysses, "_all_gather_int", fake_gather_int)
|
||||
monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa")
|
||||
strategy = ulysses.UlyssesParallelAttention(
|
||||
sp_group,
|
||||
scatter_idx=2,
|
||||
gather_idx=1,
|
||||
use_sync=False,
|
||||
)
|
||||
|
||||
with set_forward_context():
|
||||
get_forward_context()._sp_equal_pad_stack.append(True)
|
||||
strategy.pre_attention(
|
||||
torch.zeros(1, 3, 28, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
None,
|
||||
)
|
||||
|
||||
# Ring check ran: s_global = ulysses_world_size * local = 2 * 3.
|
||||
assert ring_checks == [6]
|
||||
assert observed == [([3, 3], 32), ([3, 3], 8), ([3, 3], 8)]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_uaa_without_contract_keeps_dynamic_length_gather(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No auto_pad boundary means shards may differ, so the gather must stay."""
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses
|
||||
|
||||
class FakeGroup:
|
||||
ulysses_group = object()
|
||||
ulysses_world_size = 2
|
||||
ulysses_rank = 0
|
||||
ring_world_size = 1
|
||||
|
||||
observed = []
|
||||
monkeypatch.setattr(
|
||||
ulysses,
|
||||
"_all_gather_int",
|
||||
lambda *args, **kwargs: [3, 4],
|
||||
)
|
||||
monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa")
|
||||
|
||||
def fake_all_to_all(pg, tensor, **kwargs):
|
||||
observed.append(kwargs["seq_lens"])
|
||||
return tensor, tensor.shape[2]
|
||||
|
||||
monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all)
|
||||
strategy = ulysses.UlyssesParallelAttention(
|
||||
FakeGroup(),
|
||||
scatter_idx=2,
|
||||
gather_idx=1,
|
||||
use_sync=False,
|
||||
)
|
||||
|
||||
with set_forward_context():
|
||||
strategy.pre_attention(
|
||||
torch.zeros(1, 3, 28, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
None,
|
||||
)
|
||||
|
||||
assert observed == [[3, 4], [3, 4], [3, 4]]
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_uaa_mixed_boundaries_keep_the_dynamic_length_gather(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""One manual boundary inside an auto_pad region voids the contract."""
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses
|
||||
|
||||
class FakeGroup:
|
||||
ulysses_group = object()
|
||||
ulysses_world_size = 2
|
||||
ulysses_rank = 0
|
||||
ring_world_size = 1
|
||||
|
||||
monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa")
|
||||
gathered = []
|
||||
|
||||
def fake_gather(*args, **kwargs):
|
||||
gathered.append(args)
|
||||
return [3, 4]
|
||||
|
||||
monkeypatch.setattr(ulysses, "_all_gather_int", fake_gather)
|
||||
monkeypatch.setattr(
|
||||
ulysses,
|
||||
"_ulysses_all_to_all_any_qkv",
|
||||
lambda pg, tensor, **kwargs: (tensor, tensor.shape[2]),
|
||||
)
|
||||
strategy = ulysses.UlyssesParallelAttention(
|
||||
FakeGroup(),
|
||||
scatter_idx=2,
|
||||
gather_idx=1,
|
||||
use_sync=False,
|
||||
)
|
||||
|
||||
with set_forward_context():
|
||||
ctx = get_forward_context()
|
||||
ctx._sp_equal_pad_stack.extend([True, False])
|
||||
assert not ctx.sp_rank_local_seq_lens_equal
|
||||
strategy.pre_attention(
|
||||
torch.zeros(1, 3, 28, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
torch.zeros(1, 3, 7, 4),
|
||||
None,
|
||||
)
|
||||
|
||||
assert len(gathered) == 1
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_uaa_gqa_head_padding_preserves_the_query_to_kv_ratio(
|
||||
@@ -634,6 +830,187 @@ def test_ulysses_uaa_matches_baseline(
|
||||
pass
|
||||
|
||||
|
||||
def _run_contract_skip_case(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
init_method: str,
|
||||
input_file: str,
|
||||
num_heads: int,
|
||||
head_size: int,
|
||||
num_kv_heads: int | None,
|
||||
) -> None:
|
||||
"""Per-rank worker: slow path vs contract fast path on real rank pairs.
|
||||
|
||||
Phase 1 leaves the equal-pad stack empty so the Ulysses length all-gather
|
||||
really runs (counted); phase 2 pushes the split hook's auto_pad marker and
|
||||
replaces _all_gather_int with a raising guard. With ring_degree=1 any call
|
||||
to it is the Ulysses length gather, so the guard proves the skip, and the
|
||||
per-rank output comparison proves the fast path is numerically equivalent
|
||||
to the slow path it replaces.
|
||||
"""
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses as ulysses_mod
|
||||
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
|
||||
os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA"
|
||||
|
||||
init_distributed_environment(world_size=world_size, rank=local_rank, distributed_init_method=init_method)
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=1,
|
||||
cfg_parallel_size=1,
|
||||
sequence_parallel_size=world_size,
|
||||
ulysses_degree=world_size,
|
||||
ring_degree=1,
|
||||
tensor_parallel_size=1,
|
||||
pipeline_parallel_size=1,
|
||||
)
|
||||
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
pipeline_parallel_size=1,
|
||||
data_parallel_size=1,
|
||||
tensor_parallel_size=1,
|
||||
sequence_parallel_size=world_size,
|
||||
ulysses_degree=world_size,
|
||||
ring_degree=1,
|
||||
cfg_parallel_size=1,
|
||||
ulysses_mode="advanced_uaa",
|
||||
)
|
||||
od_config = OmniDiffusionConfig(model="test", dtype=torch.float32, parallel_config=parallel_config)
|
||||
|
||||
with set_forward_context(omni_diffusion_config=od_config), set_current_diffusion_config(od_config):
|
||||
attn = Attention(
|
||||
num_heads=num_heads,
|
||||
head_size=head_size,
|
||||
causal=False,
|
||||
softmax_scale=1.0 / (head_size**0.5),
|
||||
num_kv_heads=num_kv_heads,
|
||||
).to(device=device, dtype=torch.float32)
|
||||
|
||||
with np.load(input_file, allow_pickle=False) as payload:
|
||||
q_full = torch.from_numpy(payload["q"]).to(device=device)
|
||||
k_full = torch.from_numpy(payload["k"]).to(device=device)
|
||||
v_full = torch.from_numpy(payload["v"]).to(device=device)
|
||||
|
||||
def make_shards() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
# Fresh contiguous shards for each phase; seq_len is divisible by
|
||||
# world_size, so every rank gets the same local length.
|
||||
return (
|
||||
torch.tensor_split(q_full, world_size, dim=1)[local_rank].contiguous(),
|
||||
torch.tensor_split(k_full, world_size, dim=1)[local_rank].contiguous(),
|
||||
torch.tensor_split(v_full, world_size, dim=1)[local_rank].contiguous(),
|
||||
)
|
||||
|
||||
# The Attention layer only enables SP communication when
|
||||
# ForwardContext.sp_active is True; in production the split hook sets
|
||||
# this (and pushes the auto_pad equal-length marker onto
|
||||
# _sp_equal_pad_stack). Here we drive it directly.
|
||||
ctx = get_forward_context()
|
||||
ctx._sp_shard_depth = 1
|
||||
|
||||
def make_sdp_ctx() -> object:
|
||||
# Fresh instance per phase: the @contextmanager instance releases
|
||||
# its state on first exit and cannot be re-entered.
|
||||
return torch.backends.cuda.sdp_kernel(enable_flash=False, enable_mem_efficient=False, enable_math=True)
|
||||
|
||||
# Phase 1: contract absent -> the length all-gather must really run.
|
||||
real_gather = ulysses_mod._all_gather_int
|
||||
gather_calls: list[object] = []
|
||||
|
||||
def counting_gather(pg: object, value: int, *, device: torch.device) -> list[int]:
|
||||
gather_calls.append(pg)
|
||||
return real_gather(pg, value, device=device)
|
||||
|
||||
ulysses_mod._all_gather_int = counting_gather
|
||||
try:
|
||||
q, k, v = make_shards()
|
||||
with make_sdp_ctx():
|
||||
out_slow = attn(q, k, v, attn_metadata=None).contiguous()
|
||||
finally:
|
||||
ulysses_mod._all_gather_int = real_gather
|
||||
assert len(gather_calls) >= 1, "slow path must call the Ulysses length all-gather"
|
||||
|
||||
# Phase 2: replicate the split hook's auto_pad marker so the fast
|
||||
# path fires; any _all_gather_int call in this phase is the Ulysses
|
||||
# length gather and must not happen.
|
||||
ctx._sp_equal_pad_stack.append(True)
|
||||
|
||||
def fail_on_gather(pg: object, value: int, *, device: torch.device) -> list[int]:
|
||||
raise AssertionError("the Ulysses length all-gather must be skipped under the equal-pad contract")
|
||||
|
||||
ulysses_mod._all_gather_int = fail_on_gather
|
||||
try:
|
||||
q, k, v = make_shards()
|
||||
with make_sdp_ctx():
|
||||
out_fast = attn(q, k, v, attn_metadata=None).contiguous()
|
||||
finally:
|
||||
ulysses_mod._all_gather_int = real_gather
|
||||
ctx._sp_equal_pad_stack.pop()
|
||||
|
||||
torch.testing.assert_close(out_fast, out_slow, atol=1e-5, rtol=1e-5)
|
||||
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_heads,num_kv_heads",
|
||||
[
|
||||
(3, None), # MHA: heads padded 3 -> 4 by ulysses_degree=2
|
||||
(28, 7), # GQA: derived-Q padding 28/7 -> 32/8, the main case for the skip
|
||||
],
|
||||
)
|
||||
def test_ulysses_uaa_contract_skips_length_gather_matches_slow_path(num_heads: int, num_kv_heads: int | None) -> None:
|
||||
"""Real 2-rank parity: contract fast path == slow path, gather skipped.
|
||||
|
||||
The CPU-only contract tests replace the all-to-all with a fake; this test
|
||||
runs the real strategy on two ranks. Each rank first computes the slow
|
||||
path (empty equal-pad stack, real length all-gather), then the fast path
|
||||
with the split hook's auto_pad marker pushed and _all_gather_int replaced
|
||||
by a raising guard -- which also proves the collective was truly skipped,
|
||||
not just silently replaced.
|
||||
"""
|
||||
sp_world_size = 2
|
||||
if current_omni_platform.get_device_count() < sp_world_size:
|
||||
pytest.skip(f"Test requires {sp_world_size} GPUs")
|
||||
|
||||
batch_size = 2
|
||||
head_size = 8
|
||||
seq_len = 6 # divisible by 2 so both ranks hold equal-length shards
|
||||
|
||||
init_method = get_distributed_init_method()
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".npz") as f_in:
|
||||
input_file = f_in.name
|
||||
|
||||
try:
|
||||
torch.manual_seed(0)
|
||||
kv_heads = num_kv_heads if num_kv_heads is not None else num_heads
|
||||
np.savez(
|
||||
input_file,
|
||||
q=torch.randn(batch_size, seq_len, num_heads, head_size, dtype=torch.float32).numpy(),
|
||||
k=torch.randn(batch_size, seq_len, kv_heads, head_size, dtype=torch.float32).numpy(),
|
||||
v=torch.randn(batch_size, seq_len, kv_heads, head_size, dtype=torch.float32).numpy(),
|
||||
)
|
||||
|
||||
torch.multiprocessing.spawn(
|
||||
_run_contract_skip_case,
|
||||
args=(
|
||||
sp_world_size,
|
||||
init_method,
|
||||
input_file,
|
||||
num_heads,
|
||||
head_size,
|
||||
num_kv_heads,
|
||||
),
|
||||
nprocs=sp_world_size,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
os.remove(input_file)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"num_heads,num_kv_heads,joint_len",
|
||||
[
|
||||
|
||||
@@ -279,17 +279,17 @@ def _install_native_modules(monkeypatch: pytest.MonkeyPatch, events: list[tuple]
|
||||
group = SimpleNamespace(get_metadata_builder=lambda _index: SimpleNamespace(reorder_batch_threshold=None))
|
||||
return [[group], [group]], "cg-support", [4, 8]
|
||||
|
||||
# vLLM 0.29 dropped attn_groups and cache_dtype from init_kv_cache: the
|
||||
# layout now rides on the resolved CacheConfig.kv_cache_layout instead.
|
||||
def init_cache(
|
||||
runner_kv_caches,
|
||||
forward_context,
|
||||
kv_cache_config,
|
||||
attn_groups,
|
||||
device,
|
||||
cache_dtype,
|
||||
kernel_block_sizes,
|
||||
vllm_config,
|
||||
):
|
||||
events.append(("cache", kv_cache_config, attn_groups, kernel_block_sizes))
|
||||
events.append(("cache", kv_cache_config, kernel_block_sizes))
|
||||
runner_kv_caches.extend(["cache-0", "cache-1"])
|
||||
return {"layer-0": "tensor-0", "layer-1": "tensor-1"}
|
||||
|
||||
|
||||
@@ -299,8 +299,10 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None
|
||||
)
|
||||
|
||||
assert vllm_config.model_config.original_max_model_len == -1
|
||||
# Sixteen physical pages provide ``16 * block_size`` tokens. The native
|
||||
# backend chooses the block geometry, so keep this assertion backend
|
||||
# Sixteen physical pages provide ``(16 - 1) * block_size`` tokens: vLLM 0.29
|
||||
# subtracts the null block that BlockPool permanently holds back before
|
||||
# sizing (check_enough_kv_cache_memory), so only 15 pages are usable. The
|
||||
# native backend chooses the block geometry, so keep this assertion backend
|
||||
# independent instead of assuming the generic 16-token block size.
|
||||
expected_max_model_len = min(model_limit, spec.block_size * 16)
|
||||
expected_max_model_len = min(model_limit, spec.block_size * (16 - 1))
|
||||
assert vllm_config.model_config.max_model_len == expected_max_model_len
|
||||
@@ -0,0 +1,147 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Physical KV cache layout selection and propagation for the Diffusion cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.config import CacheConfig
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec
|
||||
from vllm.v1.kv_cache_layout import KVCacheLayout
|
||||
|
||||
from vllm_omni.diffusion.diffusion_kv.layout import (
|
||||
DEFAULT_DIFFUSION_KV_CACHE_LAYOUT,
|
||||
adopt_kv_cache_layout,
|
||||
assert_backend_layout_supported,
|
||||
build_kv_cache_tensor,
|
||||
resolve_diffusion_kv_cache_layout,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _config():
|
||||
return SimpleNamespace(cache_config=CacheConfig())
|
||||
|
||||
|
||||
class _DenseBackend:
|
||||
@classmethod
|
||||
def indexes_kv_by_block_stride(cls) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _BlockStrideBackend:
|
||||
@classmethod
|
||||
def indexes_kv_by_block_stride(cls) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def test_default_layout_is_not_block_outermost() -> None:
|
||||
"""The default reproduces the pre-0.29 indexes_kv_by_block_stride=False."""
|
||||
vllm_config = _config()
|
||||
layout = resolve_diffusion_kv_cache_layout(vllm_config)
|
||||
|
||||
assert layout is DEFAULT_DIFFUSION_KV_CACHE_LAYOUT
|
||||
assert layout.is_block_outermost is False
|
||||
assert vllm_config.cache_config.get_resolved_kv_cache_layout() is layout
|
||||
|
||||
|
||||
def test_block_stride_backend_selects_block_outermost_layout() -> None:
|
||||
layout = resolve_diffusion_kv_cache_layout(_config(), indexes_kv_by_block_stride=True)
|
||||
|
||||
assert layout.is_block_outermost is True
|
||||
|
||||
|
||||
def test_resolution_is_idempotent() -> None:
|
||||
vllm_config = _config()
|
||||
|
||||
assert resolve_diffusion_kv_cache_layout(vllm_config) is resolve_diffusion_kv_cache_layout(vllm_config)
|
||||
|
||||
|
||||
def test_contradicting_requirement_raises_instead_of_misaddressing() -> None:
|
||||
vllm_config = _config()
|
||||
resolve_diffusion_kv_cache_layout(vllm_config)
|
||||
|
||||
with pytest.raises(ValueError, match="contradicts the attention"):
|
||||
resolve_diffusion_kv_cache_layout(vllm_config, indexes_kv_by_block_stride=True)
|
||||
|
||||
|
||||
def test_preset_layout_is_honoured() -> None:
|
||||
vllm_config = _config()
|
||||
vllm_config.cache_config.kv_cache_layout = KVCacheLayout.BLNHC.name
|
||||
|
||||
layout = resolve_diffusion_kv_cache_layout(vllm_config, indexes_kv_by_block_stride=True)
|
||||
|
||||
assert layout is KVCacheLayout.BLNHC
|
||||
|
||||
|
||||
def test_backend_assertion_is_quiet_until_the_layout_is_resolved() -> None:
|
||||
"""Workers collect specs before the control plane hands them the layout."""
|
||||
vllm_config = _config()
|
||||
|
||||
assert_backend_layout_supported(vllm_config, _BlockStrideBackend) # unresolved: nothing to check
|
||||
|
||||
resolve_diffusion_kv_cache_layout(vllm_config)
|
||||
assert_backend_layout_supported(vllm_config, _DenseBackend)
|
||||
assert_backend_layout_supported(vllm_config, None)
|
||||
with pytest.raises(ValueError, match="contradicts the attention"):
|
||||
assert_backend_layout_supported(vllm_config, _BlockStrideBackend)
|
||||
|
||||
|
||||
def test_worker_adopts_the_layout_carried_on_the_kv_cache_config() -> None:
|
||||
"""Diffusion workers receive only a KVCacheConfig, so the name rides on it."""
|
||||
worker = _config()
|
||||
shipped = SimpleNamespace(kv_cache_layout=KVCacheLayout.BLNHC.name)
|
||||
|
||||
assert adopt_kv_cache_layout(worker, shipped) is KVCacheLayout.BLNHC
|
||||
assert worker.cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.BLNHC
|
||||
|
||||
|
||||
def test_worker_without_a_carried_layout_falls_back_to_the_same_default() -> None:
|
||||
worker = _config()
|
||||
|
||||
assert adopt_kv_cache_layout(worker, SimpleNamespace(kv_cache_layout=None)) is DEFAULT_DIFFUSION_KV_CACHE_LAYOUT
|
||||
|
||||
|
||||
def test_worker_disagreeing_with_the_control_plane_raises() -> None:
|
||||
worker = _config()
|
||||
resolve_diffusion_kv_cache_layout(worker)
|
||||
|
||||
with pytest.raises(ValueError, match="disagrees with the layout"):
|
||||
adopt_kv_cache_layout(worker, SimpleNamespace(kv_cache_layout=KVCacheLayout.BLNHC.name))
|
||||
|
||||
|
||||
def test_block_stride_requirement_propagates_control_plane_to_worker() -> None:
|
||||
"""A block-stride backend must reach the worker as a block-outermost layout.
|
||||
|
||||
Covers the whole seam: the control plane resolves with the requirement, the
|
||||
name rides on each rank-local KVCacheConfig, the worker adopts it, and the
|
||||
backend contract check then passes instead of raising.
|
||||
"""
|
||||
control_plane = _config()
|
||||
layout = resolve_diffusion_kv_cache_layout(control_plane, indexes_kv_by_block_stride=True)
|
||||
assert layout.is_block_outermost is True
|
||||
|
||||
shipped = SimpleNamespace(kv_cache_layout=layout.name)
|
||||
|
||||
worker = _config()
|
||||
assert adopt_kv_cache_layout(worker, shipped) is layout
|
||||
# The worker's own backend requirement now agrees with what it was handed.
|
||||
assert_backend_layout_supported(worker, _BlockStrideBackend)
|
||||
with pytest.raises(ValueError, match="contradicts the attention"):
|
||||
assert_backend_layout_supported(worker, _DenseBackend)
|
||||
|
||||
|
||||
def test_tensor_strides_come_from_the_upstream_derivation() -> None:
|
||||
spec = FullAttentionSpec(block_size=16, num_kv_heads=2, head_size=8, dtype=torch.bfloat16)
|
||||
|
||||
tensor = build_kv_cache_tensor(spec, 4, ["layer0", "layer1"])
|
||||
|
||||
assert tensor.layers == ["layer0", "layer1"]
|
||||
assert tensor.size == spec.page_size_bytes * 2 * 4
|
||||
# Layer-outermost: each layer owns a contiguous run of its own blocks.
|
||||
assert tensor.layer_stride == spec.page_size_bytes * 4
|
||||
assert tensor.block_stride == spec.page_size_bytes
|
||||
@@ -1,11 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec
|
||||
|
||||
from tests.helpers.kv_layout import build_kv_cache_tensor
|
||||
from vllm_omni.diffusion.diffusion_kv.manager import DiffusionKVAdmissionError, DiffusionKVCacheManager
|
||||
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVContext, DiffusionKVRequest
|
||||
|
||||
@@ -24,7 +25,7 @@ def _config(num_blocks: int) -> KVCacheConfig:
|
||||
)
|
||||
return KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=["layer0"])],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(spec, num_blocks, ["layer0"])],
|
||||
kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)],
|
||||
)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import torch
|
||||
from torch import nn
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec
|
||||
|
||||
from tests.helpers.kv_layout import layout_for_backend
|
||||
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl
|
||||
from vllm_omni.diffusion.attention.layer import Attention
|
||||
from vllm_omni.diffusion.diffusion_kv import paged_attention_adapter as adapter_module
|
||||
@@ -1239,7 +1240,8 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
|
||||
assert native_layer.num_heads == 4
|
||||
assert native_layer.num_kv_heads == 2
|
||||
assert native_layer.spec.num_kv_heads == 2
|
||||
assert native_layer.spec.indexes_kv_by_block_stride is True
|
||||
# 0.29: the flag no longer rides on the spec, it selects the layout.
|
||||
assert layout_for_backend(native_layer.attn_backend).is_block_outermost is True
|
||||
assert native_layer._q_scale_float == 1.0
|
||||
assert native_layer._k_scale_float == 1.0
|
||||
assert native_layer._v_scale_float == 1.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,12 +10,14 @@ import torch
|
||||
from torch import nn
|
||||
from torch.nn import functional as F
|
||||
from vllm.config import AttentionConfig, CacheConfig, VllmConfig, set_current_vllm_config
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec
|
||||
from vllm.v1.worker.gpu.attn_utils import init_attn_backend, init_kv_cache
|
||||
from vllm.v1.worker.gpu.block_table import BlockTables
|
||||
|
||||
from tests.helpers.kv_layout import build_kv_cache_tensor
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl
|
||||
from vllm_omni.diffusion.diffusion_kv.layout import resolve_diffusion_kv_cache_layout
|
||||
from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import (
|
||||
DiffusionPagedAttentionAdapter,
|
||||
DiffusionPagedAttentionLayerAdapter,
|
||||
@@ -88,12 +90,7 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
canonical_spec = native_layer.spec
|
||||
kv_cache_config = KVCacheConfig(
|
||||
num_blocks=4,
|
||||
kv_cache_tensors=[
|
||||
KVCacheTensor(
|
||||
size=canonical_spec.page_size_bytes * 4,
|
||||
shared_by=[_LAYER_NAME],
|
||||
)
|
||||
],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(canonical_spec, 4, [_LAYER_NAME])],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
layer_names=[_LAYER_NAME],
|
||||
@@ -101,6 +98,9 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
)
|
||||
],
|
||||
)
|
||||
# 0.29 reads the resolved physical layout when allocating; the diffusion
|
||||
# control plane pins it, so this standalone harness must too.
|
||||
resolve_diffusion_kv_cache_layout(vllm_config)
|
||||
with set_current_vllm_config(vllm_config):
|
||||
attn_groups, _, kernel_block_sizes = init_attn_backend(
|
||||
kv_cache_config,
|
||||
@@ -108,13 +108,13 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
device,
|
||||
)
|
||||
runner_kv_caches: list[torch.Tensor | list[torch.Tensor]] = []
|
||||
# vLLM 0.29 dropped attn_groups and cache_dtype here: the layout now
|
||||
# comes from the resolved CacheConfig.kv_cache_layout inside the call.
|
||||
init_kv_cache(
|
||||
runner_kv_caches,
|
||||
vllm_config.compilation_config.static_forward_context,
|
||||
kv_cache_config,
|
||||
attn_groups,
|
||||
device,
|
||||
vllm_config.cache_config.cache_dtype,
|
||||
kernel_block_sizes,
|
||||
vllm_config,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -10,10 +10,10 @@ from vllm.v1.kv_cache_interface import (
|
||||
FullAttentionSpec,
|
||||
KVCacheConfig,
|
||||
KVCacheGroupSpec,
|
||||
KVCacheTensor,
|
||||
)
|
||||
from vllm.v1.request import RequestStatus
|
||||
|
||||
from tests.helpers.kv_layout import build_kv_cache_tensor
|
||||
from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVContext, DiffusionKVRequest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
@@ -119,7 +119,7 @@ def _manager(*, enable_caching: bool) -> KVCacheManager:
|
||||
group = KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)
|
||||
config = KVCacheConfig(
|
||||
num_blocks=8,
|
||||
kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["layer0"])],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["layer0"])],
|
||||
kv_cache_groups=[group],
|
||||
)
|
||||
return KVCacheManager(
|
||||
|
||||
@@ -8,9 +8,10 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec
|
||||
|
||||
import vllm_omni.diffusion.worker.diffusion_worker as diffusion_worker_module
|
||||
from tests.helpers.kv_layout import build_kv_cache_tensor, layout_for_backend
|
||||
from vllm_omni.diffusion.attention.layer import Attention
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
@@ -79,7 +80,9 @@ def test_runner_discovers_native_spec_from_loaded_attention() -> None:
|
||||
assert spec.num_kv_heads == 2
|
||||
assert spec.head_size == 8
|
||||
assert spec.dtype is torch.bfloat16
|
||||
assert spec.indexes_kv_by_block_stride is True
|
||||
# vLLM 0.29 moved the block-stride decision off the spec onto the resolved
|
||||
# physical layout; the backend's declaration must still select one.
|
||||
assert layout_for_backend(_Backend).is_block_outermost is True
|
||||
assert spec.non_causal is True
|
||||
|
||||
|
||||
@@ -112,7 +115,7 @@ def test_runner_retains_matching_rank_local_config() -> None:
|
||||
spec = runner.get_kv_cache_spec()["image_attention"]
|
||||
config = KVCacheConfig(
|
||||
num_blocks=8,
|
||||
kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["image_attention"])],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["image_attention"])],
|
||||
kv_cache_groups=[KVCacheGroupSpec(layer_names=["image_attention"], kv_cache_spec=spec)],
|
||||
)
|
||||
|
||||
@@ -126,7 +129,7 @@ def test_runner_rejects_rank_local_config_for_different_layers() -> None:
|
||||
spec = runner.get_kv_cache_spec()["image_attention"]
|
||||
config = KVCacheConfig(
|
||||
num_blocks=8,
|
||||
kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["other_attention"])],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["other_attention"])],
|
||||
kv_cache_groups=[KVCacheGroupSpec(layer_names=["other_attention"], kv_cache_spec=spec)],
|
||||
)
|
||||
|
||||
|
||||
@@ -1113,6 +1113,120 @@ class TestStrictModeSplitValidation:
|
||||
hook._prepare_sp_input(x, metadata["x"], (), {})
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
class TestEqualPadContract:
|
||||
"""The stack of per-boundary auto_pad flags that lets attention skip a gather.
|
||||
|
||||
A boundary is recorded as equal-length only when the framework padded it,
|
||||
because only then is every rank's shard guaranteed the same size.
|
||||
"""
|
||||
|
||||
def _split_hook(self, *, auto_pad: bool, monkeypatch):
|
||||
from vllm_omni.diffusion.attention import selector
|
||||
from vllm_omni.diffusion.distributed import parallel_state, sp_sharding
|
||||
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
|
||||
from vllm_omni.diffusion.hooks.sequence_parallel import (
|
||||
SequenceParallelSplitHook,
|
||||
)
|
||||
|
||||
class MaskBackend:
|
||||
supports_attention_mask = True
|
||||
|
||||
@staticmethod
|
||||
def get_name():
|
||||
return "test"
|
||||
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 2)
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_rank", lambda: 1)
|
||||
monkeypatch.setattr(parallel_state, "get_ring_parallel_world_size", lambda: 1)
|
||||
monkeypatch.setattr(selector, "get_attn_backend_for_role", lambda **_: (MaskBackend(), None))
|
||||
# sp_shard, used by the manual path, binds these at import time.
|
||||
monkeypatch.setattr(sp_sharding, "get_sequence_parallel_world_size", lambda: 2)
|
||||
monkeypatch.setattr(sp_sharding, "get_sequence_parallel_rank", lambda: 1)
|
||||
|
||||
hook = SequenceParallelSplitHook(
|
||||
{
|
||||
0: SequenceParallelInput(
|
||||
split_dim=1,
|
||||
expected_dims=3,
|
||||
split_output=True,
|
||||
auto_pad=auto_pad,
|
||||
)
|
||||
},
|
||||
SequenceParallelConfig(ulysses_degree=2),
|
||||
)
|
||||
hook.initialize_hook(nn.Identity())
|
||||
return hook
|
||||
|
||||
def test_auto_pad_split_claims_equal_rank_lengths(self, monkeypatch):
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
get_forward_context,
|
||||
set_forward_context,
|
||||
)
|
||||
|
||||
hook = self._split_hook(auto_pad=True, monkeypatch=monkeypatch)
|
||||
with set_forward_context():
|
||||
hook.pre_forward(nn.Identity())
|
||||
shard = hook.post_forward(nn.Identity(), torch.arange(5).reshape(1, 5, 1))
|
||||
ctx = get_forward_context()
|
||||
|
||||
# 5 tokens padded to 6, so both ranks hold 3.
|
||||
assert shard.shape[1] == 3
|
||||
assert ctx._sp_equal_pad_stack == [True]
|
||||
assert ctx.sp_rank_local_seq_lens_equal
|
||||
|
||||
def test_manual_split_does_not_claim_equal_rank_lengths(self, monkeypatch):
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
get_forward_context,
|
||||
set_forward_context,
|
||||
)
|
||||
|
||||
hook = self._split_hook(auto_pad=False, monkeypatch=monkeypatch)
|
||||
with set_forward_context():
|
||||
hook.pre_forward(nn.Identity())
|
||||
hook.post_forward(nn.Identity(), torch.arange(6).reshape(1, 6, 1))
|
||||
ctx = get_forward_context()
|
||||
|
||||
assert ctx._sp_equal_pad_stack == [False]
|
||||
assert not ctx.sp_rank_local_seq_lens_equal
|
||||
|
||||
def test_gather_pops_only_its_own_boundary(self, monkeypatch):
|
||||
"""A stack, not a counter: the gather must not resurrect the contract.
|
||||
|
||||
With one auto_pad and one manual boundary open, a counter could not
|
||||
tell which one a gather closes and would wrongly report equal lengths.
|
||||
"""
|
||||
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
|
||||
from vllm_omni.diffusion.forward_context import (
|
||||
get_forward_context,
|
||||
set_forward_context,
|
||||
)
|
||||
from vllm_omni.diffusion.hooks import sequence_parallel
|
||||
from vllm_omni.diffusion.hooks.sequence_parallel import (
|
||||
SequenceParallelGatherHook,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
sequence_parallel,
|
||||
"sp_gather",
|
||||
lambda tensor, dim, validate: torch.cat([tensor, tensor], dim=dim),
|
||||
)
|
||||
hook = SequenceParallelGatherHook(
|
||||
SequenceParallelOutput(gather_dim=1, expected_dims=3),
|
||||
SequenceParallelConfig(ulysses_degree=2),
|
||||
)
|
||||
|
||||
with set_forward_context():
|
||||
ctx = get_forward_context()
|
||||
ctx._sp_shard_depth = 2
|
||||
ctx._sp_equal_pad_stack.extend([False, True])
|
||||
hook.post_forward(nn.Identity(), torch.zeros(1, 3, 4))
|
||||
|
||||
assert ctx._sp_shard_depth == 1
|
||||
assert ctx._sp_equal_pad_stack == [False]
|
||||
assert not ctx.sp_rank_local_seq_lens_equal
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
class TestSequenceParallelConfig:
|
||||
"""Test SequenceParallelConfig dataclass."""
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import weakref
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.autoencoders import AutoencoderKLWan
|
||||
|
||||
from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import (
|
||||
DistributedAutoencoderKLWan,
|
||||
OmniAutoencoderKLWan,
|
||||
)
|
||||
from vllm_omni.diffusion.models.interface import supports_chunked_vae_decode
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def vae():
|
||||
# Real causal convolutions and caches, with small random weights for CPU CI.
|
||||
return OmniAutoencoderKLWan(
|
||||
base_dim=4,
|
||||
z_dim=2,
|
||||
dim_mult=[1, 2],
|
||||
num_res_blocks=1,
|
||||
temperal_downsample=[True],
|
||||
scale_factor_spatial=2,
|
||||
scale_factor_temporal=2,
|
||||
).eval()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("patch_size", [None, 2])
|
||||
@pytest.mark.parametrize("return_dict", [False, True])
|
||||
@torch.no_grad()
|
||||
def test_chunked_decode_matches_diffusers(vae, patch_size, return_dict):
|
||||
if patch_size is not None:
|
||||
vae = OmniAutoencoderKLWan.from_config(vae.config, patch_size=patch_size, out_channels=12).eval()
|
||||
z = torch.randn(1, 2, 3, 2, 2)
|
||||
expected = AutoencoderKLWan._decode(vae, z).sample
|
||||
actual = vae.decode(z, return_dict=return_dict)
|
||||
torch.testing.assert_close(actual.sample if return_dict else actual[0], expected, rtol=0, atol=0)
|
||||
chunks: list[torch.Tensor] = []
|
||||
assert supports_chunked_vae_decode(vae)
|
||||
assert not supports_chunked_vae_decode(object())
|
||||
assert vae.decode_with_chunks(z, on_chunk=chunks.append) is None
|
||||
assert [chunk.shape[2] for chunk in chunks] == [1, 2, 2]
|
||||
torch.testing.assert_close(torch.cat(chunks, dim=2), expected, rtol=0, atol=0)
|
||||
assert all(entry is None for entry in vae._feat_map)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_slicing_and_forward_hooks(vae):
|
||||
z = torch.randn(2, 2, 2, 2, 2)
|
||||
vae.enable_slicing()
|
||||
batches = []
|
||||
handle = vae.post_quant_conv.register_forward_pre_hook(lambda module, args: batches.append(args[0].shape[0]))
|
||||
vae._hf_hook = SimpleNamespace(pre_forward=Mock())
|
||||
try:
|
||||
assert vae.decode(z).sample.shape[0] == 2
|
||||
assert batches == [1, 1]
|
||||
vae._hf_hook.pre_forward.assert_called_once_with(vae)
|
||||
batches.clear()
|
||||
with pytest.raises(ValueError, match="use_slicing"):
|
||||
vae.decode_with_chunks(z, on_chunk=Mock())
|
||||
assert batches == []
|
||||
vae._hf_hook.pre_forward.reset_mock()
|
||||
vae.decode_with_chunks(z[:1], on_chunk=Mock())
|
||||
assert batches == [1]
|
||||
vae._hf_hook.pre_forward.assert_called_once_with(vae)
|
||||
finally:
|
||||
handle.remove()
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_callback_failure_drains_and_clears_cache(vae):
|
||||
z = torch.randn(1, 2, 4, 2, 2)
|
||||
decoded = Mock(return_value=None)
|
||||
handle = vae.decoder.register_forward_hook(lambda *args: decoded())
|
||||
error = RuntimeError("consumer failed")
|
||||
callback = Mock(side_effect=error)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="consumer failed") as exc:
|
||||
vae.decode_with_chunks(z, on_chunk=callback)
|
||||
assert exc.value is error
|
||||
assert callback.call_count == 1
|
||||
assert decoded.call_count == z.shape[2]
|
||||
assert all(entry is None for entry in vae._feat_map)
|
||||
finally:
|
||||
handle.remove()
|
||||
# The same instance can decode again after the failure.
|
||||
chunks: list[torch.Tensor] = []
|
||||
vae.decode_with_chunks(z, on_chunk=chunks.append)
|
||||
torch.testing.assert_close(torch.cat(chunks, 2), vae.decode(z).sample, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_chunked_decode_rejects_spatial_tiling(vae):
|
||||
vae.enable_tiling(tile_sample_min_height=2, tile_sample_min_width=2)
|
||||
with pytest.raises(ValueError, match="spatial tiling"):
|
||||
vae.decode_with_chunks(torch.zeros(1, 2, 2, 2, 2), on_chunk=Mock())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1])
|
||||
@pytest.mark.parametrize("fail_callback", [False, True])
|
||||
def test_spatial_callback_ownership_and_bounded_retention(monkeypatch, rank, fail_callback):
|
||||
# Isolate the temporal loop from halo kernels; weakrefs detect retention of
|
||||
# decoded GPU-sized tensors even after the consumer has failed.
|
||||
monkeypatch.setattr(wan_spatial_shard, "install_wan_spatial_shard_decode", lambda *args, **kwargs: None)
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (rank, 2))
|
||||
references: list[weakref.ReferenceType[torch.Tensor]] = []
|
||||
|
||||
def decode(x, **kwargs):
|
||||
assert sum(ref() is not None for ref in references) <= 1
|
||||
chunk = x.clone()
|
||||
references.append(weakref.ref(chunk))
|
||||
return chunk
|
||||
|
||||
vae = SimpleNamespace(
|
||||
config=SimpleNamespace(patch_size=None),
|
||||
clear_cache=Mock(),
|
||||
post_quant_conv=lambda z: z,
|
||||
decoder=decode,
|
||||
_feat_map=[],
|
||||
)
|
||||
callback = Mock(side_effect=RuntimeError("consumer failed") if fail_callback else None)
|
||||
z = torch.zeros(1, 3, 6, 2, 2)
|
||||
if rank == 0 and fail_callback:
|
||||
with pytest.raises(RuntimeError, match="consumer failed"):
|
||||
wan_spatial_shard.spatial_shard_decode(vae, z, group=None, on_chunk=callback)
|
||||
else:
|
||||
assert wan_spatial_shard.spatial_shard_decode(vae, z, group=None, on_chunk=callback) is None
|
||||
assert len(references) == z.shape[2]
|
||||
assert callback.call_count == (0 if rank else 1 if fail_callback else z.shape[2])
|
||||
assert vae.clear_cache.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1])
|
||||
@torch.no_grad()
|
||||
def test_distributed_small_latent_callback_ownership(vae, monkeypatch, rank):
|
||||
vae.is_distributed_enabled = lambda: True
|
||||
vae.distributed_executor = SimpleNamespace(group=None)
|
||||
monkeypatch.setattr(torch.distributed, "get_rank", lambda **kwargs: rank)
|
||||
callback = Mock()
|
||||
vae.decode_with_chunks(torch.zeros(1, 2, 2, 2, 2), on_chunk=callback)
|
||||
assert callback.call_count == (2 if rank == 0 else 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["spatial_shard_height", "spatial_shard_width", "tile"])
|
||||
def test_distributed_chunked_routing(monkeypatch, mode):
|
||||
vae = DistributedAutoencoderKLWan.__new__(DistributedAutoencoderKLWan)
|
||||
torch.nn.Module.__init__(vae)
|
||||
vae.use_tiling = True
|
||||
vae.use_slicing = False
|
||||
vae.tile_sample_min_height = vae.tile_sample_min_width = 2
|
||||
vae.spatial_compression_ratio = 2
|
||||
vae.distributed_executor = SimpleNamespace(group=None, parallel_mode=mode, parallel_size=2)
|
||||
monkeypatch.setattr(vae, "is_distributed_enabled", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "get_world_size", lambda **kwargs: 2)
|
||||
decode = Mock()
|
||||
monkeypatch.setattr(wan_spatial_shard, "spatial_shard_decode", decode)
|
||||
callback = Mock()
|
||||
z = torch.zeros(1, 2, 2, 2, 2)
|
||||
if mode == "tile":
|
||||
with pytest.raises(ValueError, match="require spatial-shard"):
|
||||
vae.decode_with_chunks(z, on_chunk=callback)
|
||||
decode.assert_not_called()
|
||||
else:
|
||||
assert vae.decode_with_chunks(z, on_chunk=callback) is None
|
||||
decode.assert_called_once_with(
|
||||
vae, z, group=None, return_dict=True, split_dim=mode.removeprefix("spatial_shard_"), on_chunk=callback
|
||||
)
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.layers.activation import SiluAndMul
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
@@ -19,9 +19,17 @@ def test_silu_and_mul_native_matches_packed_reference() -> None:
|
||||
torch.testing.assert_close(SiluAndMul().forward_native(packed), F.silu(gate) * up)
|
||||
|
||||
|
||||
@hardware_test(res={"npu": "A3"}, num_cards=1)
|
||||
def test_silu_and_mul_npu_matches_packed_reference() -> None:
|
||||
packed = torch.randn(257, 256, device="npu", dtype=torch.bfloat16)
|
||||
@pytest.mark.parametrize(
|
||||
"dtype",
|
||||
[
|
||||
pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A2"}, num_cards=1), id="A2-bf16"),
|
||||
pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A3"}, num_cards=1), id="A3-bf16"),
|
||||
pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A5"}, num_cards=1), id="A5-bf16"),
|
||||
pytest.param(torch.float16, marks=hardware_marks(res={"npu": "310P"}, num_cards=1), id="310P-fp16"),
|
||||
],
|
||||
)
|
||||
def test_silu_and_mul_npu_matches_packed_reference(dtype: torch.dtype) -> None:
|
||||
packed = torch.randn(257, 256, device="npu", dtype=dtype)
|
||||
gate, up = packed.chunk(2, dim=-1)
|
||||
|
||||
torch.testing.assert_close(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for shared AdaLayerNorm layers used by FLUX and other models."""
|
||||
|
||||
import os
|
||||
@@ -46,7 +46,7 @@ def _force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""
|
||||
Shape broadcasting regression tests for RotaryEmbedding.forward_native.
|
||||
|
||||
@@ -16,7 +16,11 @@ import types
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.layers.rope import RotaryEmbedding, apply_rotary_emb_mindiesd
|
||||
from vllm_omni.diffusion.layers.rope import (
|
||||
RotaryEmbedding,
|
||||
RotaryEmbeddingWan,
|
||||
apply_rotary_emb_mindiesd,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -239,6 +243,25 @@ class TestRotaryEmbeddingNativeShapeRegression:
|
||||
assert not torch.isnan(output).any()
|
||||
|
||||
|
||||
def test_cuda_paths_fall_back_without_vendored_rotary_kernel() -> None:
|
||||
"""Source/CPU installs need not contain the wheel-vendored CUDA kernel."""
|
||||
x = torch.randn(1, 5, 2, 8)
|
||||
|
||||
rope = RotaryEmbedding(is_neox_style=True)
|
||||
rope.apply_rotary_emb_vllm_flash_attn = None
|
||||
output = rope.forward_cuda(x, torch.randn(5, 4), torch.randn(5, 4))
|
||||
assert output.shape == x.shape
|
||||
|
||||
wan_rope = RotaryEmbeddingWan(is_neox_style=False, half_head_dim=True)
|
||||
wan_rope.apply_rotary_emb_vllm_flash_attn = None
|
||||
output = wan_rope.forward_cuda(
|
||||
x,
|
||||
torch.randn(1, 5, 1, 4),
|
||||
torch.randn(1, 5, 1, 4),
|
||||
)
|
||||
assert output.shape == x.shape
|
||||
|
||||
|
||||
def test_mindie_full_dim_rope_keeps_h3_cos_sin_layout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""H3 passes complete rotary dims to MindIE instead of the CUDA half-dim layout."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Shared test helpers for diffusion LoRA tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -26,15 +26,39 @@ class DummyBaseLayerWithLoRA(torch.nn.Module):
|
||||
tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor]
|
||||
] = []
|
||||
self.reset_calls: int = 0
|
||||
self.suspend_calls: int = 0
|
||||
self.resume_calls: int = 0
|
||||
self.active_slices: tuple[bool, ...] = ()
|
||||
self.suspended_slices: tuple[bool, ...] | None = None
|
||||
self.create_calls: int = 0
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
assert index == 0
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
if isinstance(lora_b, list):
|
||||
self.active_slices = tuple(b is not None for b in lora_b)
|
||||
else:
|
||||
self.active_slices = (True,)
|
||||
self.suspended_slices = None
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
assert index == 0
|
||||
self.reset_calls += 1
|
||||
self.active_slices = ()
|
||||
self.suspended_slices = None
|
||||
|
||||
def suspend_lora(self) -> None:
|
||||
if self.suspended_slices is not None:
|
||||
return
|
||||
self.suspended_slices = self.active_slices
|
||||
self.active_slices = (False,) * len(self.active_slices)
|
||||
self.suspend_calls += 1
|
||||
|
||||
def resume_lora(self) -> None:
|
||||
if self.suspended_slices is not None:
|
||||
self.active_slices = self.suspended_slices
|
||||
self.suspended_slices = None
|
||||
self.resume_calls += 1
|
||||
|
||||
def create_lora_weights(self, max_loras, lora_config, model_config):
|
||||
self.create_calls += 1
|
||||
|
||||
@@ -30,14 +30,38 @@ class _DummyLoRALayer:
|
||||
tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor]
|
||||
] = []
|
||||
self.reset_calls: int = 0
|
||||
self.suspend_calls: int = 0
|
||||
self.resume_calls: int = 0
|
||||
self.active_slices: tuple[bool, ...] = ()
|
||||
self.suspended_slices: tuple[bool, ...] | None = None
|
||||
|
||||
def set_lora(self, index: int, lora_a, lora_b):
|
||||
assert index == 0
|
||||
self.set_calls.append((lora_a, lora_b))
|
||||
if isinstance(lora_b, list):
|
||||
self.active_slices = tuple(b is not None for b in lora_b)
|
||||
else:
|
||||
self.active_slices = (True,)
|
||||
self.suspended_slices = None
|
||||
|
||||
def reset_lora(self, index: int):
|
||||
assert index == 0
|
||||
self.reset_calls += 1
|
||||
self.active_slices = ()
|
||||
self.suspended_slices = None
|
||||
|
||||
def suspend_lora(self) -> None:
|
||||
if self.suspended_slices is not None:
|
||||
return
|
||||
self.suspended_slices = self.active_slices
|
||||
self.active_slices = (False,) * len(self.active_slices)
|
||||
self.suspend_calls += 1
|
||||
|
||||
def resume_lora(self) -> None:
|
||||
if self.suspended_slices is not None:
|
||||
self.active_slices = self.suspended_slices
|
||||
self.suspended_slices = None
|
||||
self.resume_calls += 1
|
||||
|
||||
|
||||
# Aliases for backward compatibility within this file
|
||||
@@ -723,3 +747,104 @@ def test_lora_manager_discovers_unet_component(monkeypatch):
|
||||
assert "unet.down_block.proj" in manager._lora_modules
|
||||
# Verify the module was actually replaced in the tree (not just recorded)
|
||||
assert isinstance(pipeline.unet.down_block.proj, _DummyBaseLayerWithLoRA)
|
||||
|
||||
|
||||
def _suspend_harness(monkeypatch, adapter_id: int = 7, rank: int = 2):
|
||||
"""Manager wired to a single dummy layer, ready to activate `adapter_id`."""
|
||||
import vllm_omni.diffusion.lora.manager as manager_mod
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA)
|
||||
|
||||
lora_model = _DummyLM(rank=rank)
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=_DummyPipeline(),
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
)
|
||||
monkeypatch.setattr(manager, "_load_adapter", lambda _req: (lora_model, type("PH", (), {"r": rank})()))
|
||||
manager._registered_adapters = {adapter_id: lora_model}
|
||||
manager._lora_modules = {"transformer.foo": lora_model.transformer.foo}
|
||||
return manager, lora_model.transformer.foo
|
||||
|
||||
|
||||
def test_reactivating_same_adapter_resumes_without_rebinding(monkeypatch):
|
||||
"""activate -> deactivate -> activate at the same scale must re-arm the
|
||||
saved mask instead of re-uploading every layer."""
|
||||
adapter_id = 7
|
||||
req = _dummy_lora_request(adapter_id)
|
||||
manager, layer = _suspend_harness(monkeypatch, adapter_id)
|
||||
|
||||
manager.set_active_adapter(req, lora_scale=0.5)
|
||||
assert len(layer.set_calls) == 1
|
||||
assert layer.active_slices == (True,)
|
||||
|
||||
manager.set_active_adapter(None)
|
||||
assert manager._suspended_adapter_id == adapter_id
|
||||
assert layer.suspend_calls == 1
|
||||
assert layer.active_slices == (False,)
|
||||
# Suspending must not tear the upload down.
|
||||
assert layer.reset_calls == 0
|
||||
|
||||
manager.set_active_adapter(req, lora_scale=0.5)
|
||||
assert layer.resume_calls == 1
|
||||
assert len(layer.set_calls) == 1, "resume must not rebind"
|
||||
assert layer.active_slices == (True,)
|
||||
assert manager._active_adapter_id == adapter_id
|
||||
assert manager._suspended_adapter_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("second_scale", [0.5, 0.25])
|
||||
def test_scale_or_adapter_change_forces_rebind(monkeypatch, second_scale):
|
||||
"""A different scale, or a different adapter, must take the full bind
|
||||
path rather than re-arming a stale mask."""
|
||||
adapter_id = 7
|
||||
manager, layer = _suspend_harness(monkeypatch, adapter_id)
|
||||
|
||||
manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5)
|
||||
manager.set_active_adapter(None)
|
||||
assert manager._suspended_adapter_id == adapter_id
|
||||
|
||||
# Same id at a new scale, or a different id entirely.
|
||||
other_id = adapter_id if second_scale != 0.5 else adapter_id + 1
|
||||
if other_id != adapter_id:
|
||||
manager._registered_adapters[other_id] = manager._registered_adapters[adapter_id]
|
||||
|
||||
manager.set_active_adapter(_dummy_lora_request(other_id), lora_scale=second_scale)
|
||||
assert len(layer.set_calls) == 2, "must rebind"
|
||||
assert manager._suspended_adapter_id is None
|
||||
assert manager._active_adapter_id == other_id
|
||||
|
||||
|
||||
def test_partial_packed_mask_survives_suspend_resume(monkeypatch):
|
||||
"""A packed layer where only some slices carry LoRA must come back with
|
||||
the same mask, not an all-True one."""
|
||||
adapter_id = 7
|
||||
manager, layer = _suspend_harness(monkeypatch, adapter_id)
|
||||
|
||||
# Emulate a packed bind that left slice 1 empty.
|
||||
layer.set_lora(0, [torch.ones(2, 2), None], [torch.ones(2, 2), None])
|
||||
manager._active_adapter_id = adapter_id
|
||||
manager._update_adapter_scale(adapter_id, 0.5)
|
||||
assert layer.active_slices == (True, False)
|
||||
|
||||
manager.set_active_adapter(None)
|
||||
assert layer.active_slices == (False, False)
|
||||
|
||||
manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5)
|
||||
assert layer.active_slices == (True, False), "mask must survive unchanged"
|
||||
|
||||
|
||||
def test_removing_suspended_adapter_drops_the_upload(monkeypatch):
|
||||
"""A removed adapter can never be resumed, so its weights must not stay
|
||||
in the stacked buffers."""
|
||||
adapter_id = 7
|
||||
manager, layer = _suspend_harness(monkeypatch, adapter_id)
|
||||
|
||||
manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5)
|
||||
manager.set_active_adapter(None)
|
||||
assert manager._suspended_adapter_id == adapter_id
|
||||
|
||||
manager.remove_adapter(adapter_id)
|
||||
assert manager._suspended_adapter_id is None
|
||||
assert layer.reset_calls == 1, "the upload must be torn down"
|
||||
assert layer.suspended_slices is None
|
||||
@@ -13,7 +13,6 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
from huggingface_hub import snapshot_download
|
||||
from safetensors.torch import save_file
|
||||
from vllm.config.load import LoadConfig
|
||||
|
||||
@@ -32,6 +31,7 @@ from vllm_omni.diffusion.models.helios import HeliosPipeline
|
||||
from vllm_omni.diffusion.models.host_weight_contract import FinalLayoutModelContract
|
||||
from vllm_omni.diffusion.registry import initialize_model
|
||||
from vllm_omni.quantization.component_config import ComponentQuantizationConfig
|
||||
from vllm_omni.transformers_utils.repo_utils import hf_api
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
@@ -41,7 +41,7 @@ model_path = "hf-internal-testing/tiny-helios-modular-pipe"
|
||||
@pytest.fixture(scope="module")
|
||||
def prefetch_helios_model():
|
||||
"""Downloads the tiny helios model prior to running a test."""
|
||||
snapshot_download(model_path)
|
||||
hf_api().snapshot_download(model_path)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import contextlib
|
||||
|
||||
@@ -14,10 +14,10 @@ pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
def test_prefetch_subfolders_propagates_revision(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_snapshot_download(**kwargs):
|
||||
def fake_snapshot_download(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr(hub_prefetch, "_repo_prefetch_lock", lambda _model: contextlib.nullcontext())
|
||||
|
||||
hub_prefetch.prefetch_subfolders(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.utils import make_attention_mask
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq_lengths", [[16], [16, 16]])
|
||||
def test_attention_mask_is_skipped_for_dense_batch(seq_lengths):
|
||||
hidden_states = torch.empty(len(seq_lengths), 16, 64)
|
||||
|
||||
assert make_attention_mask(hidden_states, seq_lengths) is None
|
||||
|
||||
|
||||
def test_attention_mask_preserves_variable_length_padding():
|
||||
hidden_states = torch.empty(2, 16, 64)
|
||||
|
||||
attention_mask = make_attention_mask(hidden_states, [16, 12])
|
||||
|
||||
expected = torch.tensor(
|
||||
[
|
||||
[True] * 16,
|
||||
[True] * 12 + [False] * 4,
|
||||
]
|
||||
)
|
||||
torch.testing.assert_close(attention_mask, expected)
|
||||
@@ -0,0 +1,96 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""L1 tests for checkpoint-to-pipeline routing of Boogu-Image Turbo."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_turbo_registry_entry_loads_the_turbo_class():
|
||||
from vllm_omni.diffusion.models.boogu_image import BooguImageTurboPipeline
|
||||
from vllm_omni.diffusion.registry import DiffusionModelRegistry
|
||||
|
||||
assert DiffusionModelRegistry._try_load_model_cls("BooguImageTurboPipeline") is BooguImageTurboPipeline
|
||||
|
||||
|
||||
def test_turbo_entry_disables_request_batching():
|
||||
from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline, BooguImageTurboPipeline
|
||||
|
||||
assert BooguImagePipeline.supports_request_batch
|
||||
assert not BooguImageTurboPipeline.supports_request_batch
|
||||
|
||||
|
||||
def test_turbo_process_funcs_resolve_to_the_shared_boogu_helpers():
|
||||
from vllm_omni.diffusion.models import boogu_image
|
||||
from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS, _DIFFUSION_PRE_PROCESS_FUNCS
|
||||
|
||||
module = boogu_image.pipeline_boogu_image
|
||||
for funcs in (_DIFFUSION_PRE_PROCESS_FUNCS, _DIFFUSION_POST_PROCESS_FUNCS):
|
||||
name = funcs["BooguImageTurboPipeline"]
|
||||
assert name == funcs["BooguImagePipeline"]
|
||||
assert callable(getattr(module, name))
|
||||
|
||||
|
||||
def test_turbo_inherits_the_base_multimodal_metadata():
|
||||
from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata
|
||||
|
||||
metadata = get_diffusion_model_metadata("BooguImageTurboPipeline")
|
||||
|
||||
assert metadata == get_diffusion_model_metadata("BooguImagePipeline")
|
||||
assert metadata.supports_multimodal_inputs
|
||||
assert metadata.max_multimodal_image_inputs == 1
|
||||
|
||||
|
||||
def test_turbo_model_index_resolves_and_enriches(tmp_path):
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name
|
||||
|
||||
(tmp_path / "model_index.json").write_text(
|
||||
json.dumps({"_class_name": "BooguImageTurboPipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert resolve_model_class_name(str(tmp_path)) == "BooguImageTurboPipeline"
|
||||
|
||||
config = OmniDiffusionConfig(model=str(tmp_path))
|
||||
config.enrich_config()
|
||||
|
||||
assert config.model_class_name == "BooguImageTurboPipeline"
|
||||
assert config.supports_multimodal_inputs
|
||||
assert config.max_multimodal_image_inputs == 1
|
||||
|
||||
|
||||
def test_edit_turbo_override_selects_the_turbo_class(tmp_path):
|
||||
"""Edit-Turbo publishes ``BooguImagePipeline``; ``--model-class-name`` must win."""
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.registry import DiffusionModelRegistry
|
||||
|
||||
(tmp_path / "model_index.json").write_text(
|
||||
json.dumps({"_class_name": "BooguImagePipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = OmniDiffusionConfig(model=str(tmp_path), model_class_name="BooguImageTurboPipeline")
|
||||
config.enrich_config()
|
||||
|
||||
assert config.model_class_name == "BooguImageTurboPipeline"
|
||||
assert DiffusionModelRegistry._try_load_model_cls(config.model_class_name)._is_turbo
|
||||
|
||||
|
||||
def test_edit_turbo_without_override_keeps_the_base_class(tmp_path):
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.registry import DiffusionModelRegistry
|
||||
|
||||
(tmp_path / "model_index.json").write_text(
|
||||
json.dumps({"_class_name": "BooguImagePipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = OmniDiffusionConfig(model=str(tmp_path))
|
||||
config.enrich_config()
|
||||
|
||||
assert config.model_class_name == "BooguImagePipeline"
|
||||
assert not DiffusionModelRegistry._try_load_model_cls(config.model_class_name)._is_turbo
|
||||
@@ -59,7 +59,7 @@ def _force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1180,6 +1180,21 @@ def test_transfer_fps_matches_resolved_frame_rate_precedence() -> None:
|
||||
assert cfg.fps == sp.resolved_frame_rate == 12.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_path", [False, True], ids=["image", "path"])
|
||||
def test_transfer_pil_conversion_returns_writable_array(tmp_path, use_path: bool) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import transfer
|
||||
|
||||
image = Image.new("RGB", (5, 4), "red")
|
||||
value = image
|
||||
if use_path:
|
||||
value = tmp_path / "control.png"
|
||||
image.save(value)
|
||||
|
||||
array = transfer._pil_to_uint8_rgb(value)
|
||||
|
||||
assert array.flags.writeable
|
||||
|
||||
|
||||
def test_transfer_vae_executor_requires_distributed_vae() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
|
||||
|
||||
@@ -2386,6 +2401,42 @@ def test_forward_transfer_runs_multichunk_overlap_path(
|
||||
torch.testing.assert_close(captured["targets"][1][:, :, 0], torch.full((1, 3, 16, 16), -0.2))
|
||||
|
||||
|
||||
def test_forward_transfer_non_output_rank_uses_canonical_envelope(
|
||||
make_cosmos3_pipeline,
|
||||
sequential_cfg_parallel,
|
||||
) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.vae.distributed_executor = SimpleNamespace(rank=1)
|
||||
pipeline.vae.is_distributed_enabled = lambda: True
|
||||
pipeline._transfer_bucket_size = lambda sp, source_hw: (16, 16, "1,1")
|
||||
pipeline._format_and_tokenize_prompts = lambda *args, **kwargs: (_ids(2), _mask(), _ids(1), _mask())
|
||||
pipeline._set_flow_shift = lambda *_args, **_kwargs: None
|
||||
decoded = torch.zeros(1, 3, 1, 16, 16)
|
||||
pipeline._decode_latents = lambda latents: decoded
|
||||
|
||||
request = SimpleNamespace(
|
||||
prompts=[{"prompt": "transfer", "modalities": ["video"]}],
|
||||
sampling_params=make_sampling_params(
|
||||
height=16,
|
||||
width=16,
|
||||
num_inference_steps=1,
|
||||
guidance_scale=1.0,
|
||||
extra_args={
|
||||
"edge": {"control": torch.zeros(3, 1, 16, 16, dtype=torch.uint8)},
|
||||
"max_frames": 1,
|
||||
"num_video_frames_per_chunk": 1,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
output = pipeline.forward(request)
|
||||
|
||||
assert set(output.output) == {"payload", "metadata"}
|
||||
assert set(output.output["payload"]) == {"video"}
|
||||
torch.testing.assert_close(output.output["payload"]["video"], decoded)
|
||||
assert output.output["metadata"] == {"video": {"fps": 24.0}}
|
||||
|
||||
|
||||
def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -> None:
|
||||
"""With cache-dit active the uncond pass is kept even outside the guidance
|
||||
interval (so cache-dit's has_separate_cfg parity stays in phase), and the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -81,9 +81,8 @@ def test_from_config_loads_local_diffusers_component(tmp_path, monkeypatch: pyte
|
||||
|
||||
|
||||
def test_from_config_downloads_component_from_hf_repo(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import huggingface_hub
|
||||
|
||||
from vllm_omni.diffusion.models.cosmos3 import sound_tokenizer
|
||||
from vllm_omni.transformers_utils import repo_utils
|
||||
|
||||
cache_dir = tmp_path / "hf"
|
||||
_write_component(cache_dir, checkpoint_name=DIFFUSERS_SOUND_TOKENIZER_CHECKPOINT_NAME)
|
||||
@@ -95,7 +94,7 @@ def test_from_config_downloads_component_from_hf_repo(tmp_path, monkeypatch: pyt
|
||||
calls.append((repo_id, revision, allow_patterns))
|
||||
return str(cache_dir)
|
||||
|
||||
monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr(repo_utils.hf_api(), "snapshot_download", fake_snapshot_download)
|
||||
|
||||
sound_tokenizer.Cosmos3SoundTokenizer.from_config(
|
||||
SimpleNamespace(
|
||||
|
||||
@@ -383,6 +383,117 @@ def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_gen_sp_plan_auto_pads_hidden_states_and_rope_together() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer
|
||||
|
||||
prepare_plan = Cosmos3VFMTransformer._sp_plan["gen_sp_prepare"]
|
||||
assert set(prepare_plan) == {0, 1, 2}
|
||||
assert all(spec.auto_pad for spec in prepare_plan.values())
|
||||
assert [prepare_plan[index].split_dim for index in range(3)] == [1, 1, 1]
|
||||
|
||||
|
||||
def test_gen_sp_prepare_resets_branch_local_padding_metadata() -> None:
|
||||
from vllm_omni.diffusion.forward_context import ForwardContext, override_forward_context
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GenSPPrepare
|
||||
|
||||
ctx = ForwardContext(sp_padding_size=1, sp_original_seq_len=12121)
|
||||
hidden = torch.zeros(1, 4, 8)
|
||||
rope = torch.zeros(1, 4, 1, 2)
|
||||
with override_forward_context(ctx):
|
||||
output = Cosmos3GenSPPrepare()(hidden, rope, rope)
|
||||
|
||||
assert output[0] is hidden
|
||||
assert output[1] is rope
|
||||
assert output[2] is rope
|
||||
assert ctx.sp_padding_size == 0
|
||||
assert ctx.sp_original_seq_len is None
|
||||
|
||||
|
||||
def test_sp_attention_masks_padded_gen_and_joint_und_keys() -> None:
|
||||
from vllm_omni.diffusion.forward_context import ForwardContext, override_forward_context
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3CrossAttention
|
||||
|
||||
class RecordingAttention(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.metadata: Any | None = None
|
||||
|
||||
def forward(self, query, key, value, metadata):
|
||||
del key, value
|
||||
self.metadata = metadata
|
||||
return query
|
||||
|
||||
module = object.__new__(Cosmos3CrossAttention)
|
||||
nn.Module.__init__(module)
|
||||
module.num_heads_local = 2
|
||||
module.head_dim = 4
|
||||
recording_attention = RecordingAttention()
|
||||
module.attn = recording_attention
|
||||
|
||||
q = torch.zeros(1, 2, 2, 4)
|
||||
k = torch.zeros_like(q)
|
||||
v = torch.zeros_like(q)
|
||||
k_und = torch.zeros(1, 3, 2, 4)
|
||||
v_und = torch.zeros_like(k_und)
|
||||
ctx = ForwardContext(sp_padding_size=1, sp_original_seq_len=3)
|
||||
with override_forward_context(ctx):
|
||||
output = module._forward_sp(q, k, v, k_und, v_und)
|
||||
|
||||
assert output.shape == (1, 2, 8)
|
||||
assert recording_attention.metadata is not None
|
||||
assert recording_attention.metadata.attn_mask.tolist() == [[True, True, True, False]]
|
||||
assert recording_attention.metadata.joint_attn_mask.tolist() == [[True, True, True]]
|
||||
|
||||
|
||||
def test_ulysses_2d_mask_uses_key_length_with_kv_only_joint_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.parallel import ulysses
|
||||
|
||||
def fake_all_to_all(
|
||||
_group: object,
|
||||
tensor: torch.Tensor,
|
||||
_scatter_idx: int,
|
||||
_gather_idx: int,
|
||||
_use_sync: bool,
|
||||
) -> torch.Tensor:
|
||||
return torch.cat([tensor, tensor], dim=1)[:, :, :1]
|
||||
|
||||
monkeypatch.setattr(ulysses.SeqAllToAll4D, "apply", staticmethod(fake_all_to_all))
|
||||
strategy = ulysses.UlyssesParallelAttention(
|
||||
sp_group=SimpleNamespace(
|
||||
ulysses_group=object(),
|
||||
ulysses_world_size=2,
|
||||
ulysses_rank=0,
|
||||
ring_world_size=1,
|
||||
),
|
||||
scatter_idx=2,
|
||||
gather_idx=1,
|
||||
use_sync=False,
|
||||
)
|
||||
query = torch.zeros(1, 2, 2, 4)
|
||||
key = torch.zeros_like(query)
|
||||
value = torch.zeros_like(query)
|
||||
joint_query = torch.zeros(1, 0, 2, 4)
|
||||
joint_key = torch.zeros(1, 3, 2, 4)
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=torch.tensor([[True, True, True, False]]),
|
||||
joint_attn_mask=torch.ones(1, 3, dtype=torch.bool),
|
||||
joint_query=joint_query,
|
||||
joint_key=joint_key,
|
||||
joint_value=torch.zeros_like(joint_key),
|
||||
joint_strategy="front",
|
||||
)
|
||||
|
||||
query_out, key_out, _, metadata_out, _ = strategy.pre_attention(query, key, value, metadata)
|
||||
|
||||
assert query_out.shape[1] == 4
|
||||
assert key_out.shape[1] == 7
|
||||
assert metadata_out is not None
|
||||
assert metadata_out.attn_mask.tolist() == [[True, True, True, True, True, True, False]]
|
||||
|
||||
|
||||
def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
@@ -721,7 +832,7 @@ def test_forward_returns_video_plus_optional_modality_predictions(
|
||||
assert [tuple(tensor.shape) for tensor in output] == expected_shapes
|
||||
|
||||
|
||||
def test_forward_with_sound_ulysses_error_mentions_combined_sequence(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_forward_with_sound_odd_sequence_uses_auto_padding_contract(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 as cosmos3_module
|
||||
|
||||
model = cosmos3_module.Cosmos3VFMTransformer(
|
||||
@@ -732,16 +843,17 @@ def test_forward_with_sound_ulysses_error_mentions_combined_sequence(monkeypatch
|
||||
)
|
||||
monkeypatch.setattr(cosmos3_module, "_get_ulysses_state", lambda: (2, 0, None))
|
||||
|
||||
with pytest.raises(ValueError, match=r"GEN sequence length \(3 = video tokens 2 \+ sound tokens 1\)"):
|
||||
model(
|
||||
hidden_states=torch.zeros(1, 2, 1, 1, 2),
|
||||
timestep=torch.tensor([1.0]),
|
||||
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
||||
text_mask=torch.ones(1, 2, dtype=torch.long),
|
||||
video_shape=(1, 1, 2),
|
||||
fps=24.0,
|
||||
sound_latents=torch.zeros(1, 3, 1),
|
||||
)
|
||||
output = model(
|
||||
hidden_states=torch.zeros(1, 2, 1, 1, 2),
|
||||
timestep=torch.tensor([1.0]),
|
||||
text_ids=torch.tensor([[1, 2]], dtype=torch.long),
|
||||
text_mask=torch.ones(1, 2, dtype=torch.long),
|
||||
video_shape=(1, 1, 2),
|
||||
fps=24.0,
|
||||
sound_latents=torch.zeros(1, 3, 1),
|
||||
)
|
||||
|
||||
assert [tuple(tensor.shape) for tensor in output] == [(1, 2, 1, 1, 2), (1, 3, 1)]
|
||||
|
||||
|
||||
def test_sound_latent_frames_padded_for_sequence_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Equivalence test for DreamZero self-attn QKV fusion.
|
||||
|
||||
@@ -52,7 +52,7 @@ def _force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for GLM-Image quantization support (W4A16/AutoRound).
|
||||
|
||||
These tests verify that the GLM-Image DiT transformer correctly accepts and uses
|
||||
@@ -69,7 +69,7 @@ def _force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""The request's seed must reach GR00T's flow-matching noise.
|
||||
|
||||
The runner builds ``sampling_params.generator`` from ``sampling_params.seed``, the pipeline hands
|
||||
that generator to the policy, and the action head draws its initial noise from it, so the same
|
||||
seed reproduces the same action chunk and different seeds give independent ones. These tests pin
|
||||
both ends of that plumbing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers.feature_extraction_utils import BatchFeature
|
||||
|
||||
from vllm_omni.diffusion.models.gr00t import pipeline_gr00t
|
||||
from vllm_omni.diffusion.models.gr00t.configs.gr00t_n1d7 import Gr00tN1d7Config
|
||||
from vllm_omni.diffusion.models.gr00t.modeling.gr00t_n1d7 import Gr00tN1d7ActionHead
|
||||
from vllm_omni.diffusion.models.gr00t.pipeline_gr00t import Gr00tN1d7Pipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
ROBOT_OBS = {
|
||||
"images": {"cam": np.zeros((1, 1, 8, 8, 3), dtype=np.uint8)},
|
||||
"state": {"joint": np.zeros((1, 1, 2), dtype=np.float32)},
|
||||
"prompt": "pick the cube",
|
||||
}
|
||||
|
||||
|
||||
class _OptionsRecordingPolicy:
|
||||
"""Stand-in for ``Gr00tPolicy``; the pipeline only needs ``language_key`` and ``get_action``."""
|
||||
|
||||
def __init__(self, *, model_path: str, embodiment_tag: str, device: str, strict: bool) -> None:
|
||||
del model_path, embodiment_tag, device, strict # the pipeline's constructor contract; unused here
|
||||
self.language_key = "annotation.language.language_instruction"
|
||||
self.seen_options: dict[str, object] | None = None
|
||||
|
||||
def get_action(self, obs: dict[str, object], options: dict[str, object] | None = None):
|
||||
self.seen_options = options
|
||||
return {"arm": np.zeros((1, 2, 2), dtype=np.float32)}, {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline(monkeypatch) -> Gr00tN1d7Pipeline:
|
||||
monkeypatch.setattr(pipeline_gr00t, "Gr00tPolicy", _OptionsRecordingPolicy)
|
||||
od_config = SimpleNamespace(
|
||||
model="nvidia/GR00T-N1.7-3B",
|
||||
model_config={"embodiment_tag": "LIBERO_PANDA", "strict": False},
|
||||
custom_pipeline_args={},
|
||||
)
|
||||
return Gr00tN1d7Pipeline(od_config=od_config)
|
||||
|
||||
|
||||
def _request(**sampling_kwargs) -> OmniDiffusionRequest:
|
||||
return OmniDiffusionRequest(
|
||||
prompt="pick",
|
||||
request_id="req",
|
||||
sampling_params=OmniDiffusionSamplingParams(extra_args={"robot_obs": ROBOT_OBS}, **sampling_kwargs),
|
||||
)
|
||||
|
||||
|
||||
def test_runner_generator_is_passed_through_untouched(pipeline):
|
||||
"""In serving the runner has already built ``sampling_params.generator``; the head must get that object."""
|
||||
generator = torch.Generator().manual_seed(99)
|
||||
|
||||
assert pipeline.forward(_request(generator=generator)).error is None
|
||||
|
||||
assert pipeline.policy.seen_options["generator"] is generator
|
||||
|
||||
|
||||
def test_seed_without_generator_builds_one_on_the_pipeline_device(pipeline):
|
||||
"""Callers that bypass the runner (direct use, tests) still get the seed honoured."""
|
||||
pipeline.forward(_request(seed=1234))
|
||||
|
||||
generator = pipeline.policy.seen_options["generator"]
|
||||
assert isinstance(generator, torch.Generator)
|
||||
assert generator.initial_seed() == 1234
|
||||
assert generator.device.type == pipeline.device
|
||||
|
||||
|
||||
def test_auto_assigned_seed_reaches_the_head(pipeline):
|
||||
"""``OmniDiffusionRequest`` seeds every request, so the head is never left on the global RNG."""
|
||||
req = _request()
|
||||
assert req.sampling_params.seed is not None
|
||||
|
||||
pipeline.forward(req)
|
||||
|
||||
assert pipeline.policy.seen_options["generator"].initial_seed() == req.sampling_params.seed
|
||||
|
||||
|
||||
def _captured_warnings(monkeypatch) -> list[tuple]:
|
||||
"""vLLM loggers do not propagate to the root logger, so ``caplog`` cannot see them; record calls instead."""
|
||||
captured: list[tuple] = []
|
||||
monkeypatch.setattr(pipeline_gr00t.logger, "warning", lambda *args, **kwargs: captured.append(args))
|
||||
return captured
|
||||
|
||||
|
||||
def test_unexpected_generator_form_warns_and_falls_back_to_the_seed(pipeline, monkeypatch):
|
||||
"""GR00T draws one noise tensor per request, so a list of several generators cannot be honoured."""
|
||||
warnings = _captured_warnings(monkeypatch)
|
||||
generators = [torch.Generator().manual_seed(1), torch.Generator().manual_seed(2)]
|
||||
|
||||
pipeline.forward(_request(generator=generators, seed=7))
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert "torch.Generator" in warnings[0][0]
|
||||
assert pipeline.policy.seen_options["generator"].initial_seed() == 7
|
||||
|
||||
|
||||
def test_unexpected_generator_form_without_a_seed_warns_and_leaves_the_global_rng(pipeline, monkeypatch):
|
||||
warnings = _captured_warnings(monkeypatch)
|
||||
generators = [torch.Generator().manual_seed(1), torch.Generator().manual_seed(2)]
|
||||
|
||||
pipeline.forward(_request(generator=generators))
|
||||
|
||||
assert len(warnings) == 1
|
||||
assert pipeline.policy.seen_options is None
|
||||
|
||||
|
||||
def _noise_only_head(action_horizon: int, action_dim: int) -> Gr00tN1d7ActionHead:
|
||||
"""An action head whose single denoising step is the identity, so ``action_pred`` is the initial noise."""
|
||||
config = Gr00tN1d7Config(
|
||||
max_action_dim=action_dim,
|
||||
action_horizon=action_horizon,
|
||||
num_inference_timesteps=1,
|
||||
add_pos_embed=False,
|
||||
use_alternate_vl_dit=False,
|
||||
)
|
||||
head = Gr00tN1d7ActionHead.__new__(Gr00tN1d7ActionHead)
|
||||
nn.Module.__init__(head)
|
||||
head.config = config
|
||||
head.action_dim = config.max_action_dim
|
||||
head.action_horizon = config.action_horizon
|
||||
head.num_inference_timesteps = config.num_inference_timesteps
|
||||
head.num_timestep_buckets = config.num_timestep_buckets
|
||||
head.action_encoder = lambda actions, timesteps, embodiment_id: actions
|
||||
head.model = lambda hidden_states, encoder_hidden_states, timestep: hidden_states
|
||||
head.action_decoder = lambda hidden_states, embodiment_id: torch.zeros_like(hidden_states)
|
||||
return head
|
||||
|
||||
|
||||
def _predict(head: Gr00tN1d7ActionHead, options) -> torch.Tensor:
|
||||
batch, seq, dim = 2, 3, head.action_dim
|
||||
return head.get_action_with_features(
|
||||
backbone_features=torch.zeros(batch, seq, dim),
|
||||
state_features=torch.zeros(batch, 1, dim),
|
||||
embodiment_id=torch.zeros(batch, dtype=torch.long),
|
||||
backbone_output=BatchFeature(data={}),
|
||||
action_input=BatchFeature(data={}),
|
||||
options=options,
|
||||
)["action_pred"]
|
||||
|
||||
|
||||
def test_head_draws_its_noise_from_the_request_generator():
|
||||
head = _noise_only_head(action_horizon=4, action_dim=3)
|
||||
expected = torch.randn(2, 4, 3, generator=torch.Generator().manual_seed(5))
|
||||
|
||||
assert torch.equal(_predict(head, {"generator": torch.Generator().manual_seed(5)}), expected)
|
||||
assert not torch.equal(_predict(head, {"generator": torch.Generator().manual_seed(6)}), expected)
|
||||
|
||||
|
||||
def test_head_without_generator_stays_on_the_global_rng():
|
||||
"""Upstream Isaac-GR00T parity: no generator means a bare ``torch.randn`` on the global RNG."""
|
||||
head = _noise_only_head(action_horizon=4, action_dim=3)
|
||||
|
||||
torch.manual_seed(5)
|
||||
expected = torch.randn(2, 4, 3)
|
||||
torch.manual_seed(5)
|
||||
|
||||
assert torch.equal(_predict(head, None), expected)
|
||||
@@ -1,4 +1,8 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -12,12 +16,12 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FakeGr00tPolicy:
|
||||
instances = []
|
||||
instances: list["FakeGr00tPolicy"] = []
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.reset_calls = 0
|
||||
self.seen_obs = None
|
||||
self.seen_obs: dict[str, Any] | None = None
|
||||
self.embodiment_tag = SimpleNamespace(value="fake_embodiment")
|
||||
self.language_key = "annotation.language.language_instruction"
|
||||
self.modality_configs = {
|
||||
@@ -40,7 +44,7 @@ class FakeGr00tPolicy:
|
||||
)
|
||||
FakeGr00tPolicy.instances.append(self)
|
||||
|
||||
def get_action(self, obs):
|
||||
def get_action(self, obs, options=None):
|
||||
self.seen_obs = obs
|
||||
return {
|
||||
"arm": np.array([[[1.0, 2.0]]], dtype=np.float64),
|
||||
@@ -107,6 +111,7 @@ def test_forward_returns_dict_actions_in_output():
|
||||
assert actions["arm"].dtype == np.float32
|
||||
np.testing.assert_allclose(actions["arm"], np.array([[[1.0, 2.0]]], dtype=np.float32))
|
||||
policy = FakeGr00tPolicy.instances[0]
|
||||
assert policy.seen_obs is not None
|
||||
assert "video" in policy.seen_obs
|
||||
assert policy.seen_obs["language"] == {"annotation.language.language_instruction": [["pick the cube"]]}
|
||||
assert "images" not in policy.seen_obs
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""GR00T-N1.7 eval image transform: chosen from ``processor_config.json`` and bit-exact with Isaac-GR00T.
|
||||
|
||||
``GOLDEN`` holds sha256 digests of the ``(C, H, W)`` uint8 output of Isaac-GR00T's own eval transform
|
||||
(``build_image_transformations_albumentations(...)[1]`` applied through ``apply_with_replay``) for the
|
||||
synthetic frames built below, at Isaac-GR00T 51d4c89 / albumentations 1.4.18 / opencv 5.0.0.93. The frame
|
||||
generators are integer-only so they are identical on every platform.
|
||||
|
||||
Regenerating a digest needs Isaac-GR00T installed, whose transformers pin conflicts with this repo's, so it
|
||||
cannot run here: in an Isaac-GR00T environment push each ``synthetic_frames()`` frame through that transform
|
||||
with ``letter_box_transform`` False and True and hash the output with ``digest()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
import torchvision.transforms.v2 as transforms
|
||||
|
||||
from vllm_omni.diffusion.models.gr00t.modeling import processing_gr00t_n1d7 as processing
|
||||
from vllm_omni.diffusion.models.gr00t.modeling.processing_gr00t_n1d7 import (
|
||||
AlbumentationsEvalImageTransform,
|
||||
Gr00tN1d7Processor,
|
||||
LetterBoxTransform,
|
||||
_build_eval_image_transform,
|
||||
_fractional_center_crop,
|
||||
_smallest_edge_resize,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# nvidia/GR00T-N1.7-3B and nvidia/GR00T-N1.7-DROID both ship exactly these processor_kwargs.
|
||||
TARGET, CROP, EDGE, FRACTION = [256, 256], [230, 230], 256, 0.95
|
||||
MODALITY_CONFIGS = {
|
||||
"new_embodiment": {
|
||||
"video": {"delta_indices": [0], "modality_keys": ["cam"]},
|
||||
"state": {"delta_indices": [0], "modality_keys": ["joint"]},
|
||||
"action": {"delta_indices": [0, 1], "modality_keys": ["joint"]},
|
||||
}
|
||||
}
|
||||
|
||||
# Isaac-GR00T eval transform output for synthetic_frames(); key = "<frame>|letterbox=<flag>".
|
||||
GOLDEN: dict[str, tuple[tuple[int, int, int], str]] = {
|
||||
"gradient_256x256|letterbox=False": (
|
||||
(3, 256, 256),
|
||||
"4e90a1a3c49539b3648aad752bba59af452bd7231e5712e871ab941cbea1d715",
|
||||
),
|
||||
"checker_256x256|letterbox=False": (
|
||||
(3, 256, 256),
|
||||
"d2225b94efea5ab3f1c3e6dbae52b7afa3e0f7172d5de24439a2e729ab56080c",
|
||||
),
|
||||
"noise_256x256|letterbox=False": (
|
||||
(3, 256, 256),
|
||||
"a427ae3b84ebcc6e87470c79c0e45cf684770d6e31158a49f8736685e0da72c6",
|
||||
),
|
||||
"noise_512x768|letterbox=False": (
|
||||
(3, 256, 383),
|
||||
"45bc3a309f3d06c3f31051911bc8a544ff3f067b023b999dd9c2b7e6029ecc93",
|
||||
),
|
||||
"gradient_256x256|letterbox=True": (
|
||||
(3, 256, 256),
|
||||
"4e90a1a3c49539b3648aad752bba59af452bd7231e5712e871ab941cbea1d715",
|
||||
),
|
||||
"checker_256x256|letterbox=True": (
|
||||
(3, 256, 256),
|
||||
"d2225b94efea5ab3f1c3e6dbae52b7afa3e0f7172d5de24439a2e729ab56080c",
|
||||
),
|
||||
"noise_256x256|letterbox=True": ((3, 256, 256), "a427ae3b84ebcc6e87470c79c0e45cf684770d6e31158a49f8736685e0da72c6"),
|
||||
"noise_512x768|letterbox=True": ((3, 256, 256), "52a3db185e6ee4f00617b87711dd198b8f2e2ad53865713d34e56225a4ec61a2"),
|
||||
}
|
||||
|
||||
|
||||
def _hash_noise(height: int, width: int, seed: int) -> np.ndarray:
|
||||
"""Integer-only pseudo-noise (murmur-style mixing on uint32); no RNG or float, so identical everywhere."""
|
||||
ys, xs = np.mgrid[0:height, 0:width].astype(np.uint32)
|
||||
channels = []
|
||||
for channel in range(3):
|
||||
v = xs * np.uint32(73856093) ^ ys * np.uint32(19349663) ^ np.uint32((seed * 3 + channel) * 83492791)
|
||||
v ^= v >> np.uint32(13)
|
||||
v *= np.uint32(0x5BD1E995)
|
||||
v ^= v >> np.uint32(15)
|
||||
channels.append((v & np.uint32(0xFF)).astype(np.uint8))
|
||||
return np.stack(channels, axis=-1)
|
||||
|
||||
|
||||
def synthetic_frames() -> dict[str, np.ndarray]:
|
||||
"""Deterministic HWC uint8 RGB frames: smooth, hard-edged, noisy, and a non-square noisy one.
|
||||
|
||||
The non-square frame is 512x768 so both INTER_AREA downscales (2x without letterbox, 3x with it)
|
||||
are integer factors, which OpenCV computes with exact integer sums: the goldens do not depend on
|
||||
the CPU architecture. The 243->256 upscale is a fixed-point bilinear on uint8, also exact.
|
||||
"""
|
||||
ys, xs = np.mgrid[0:256, 0:256].astype(np.int64)
|
||||
frames = {
|
||||
"gradient_256x256": np.stack([xs % 256, ys % 256, (xs + ys) // 2 % 256], axis=-1).astype(np.uint8),
|
||||
"checker_256x256": np.stack(
|
||||
[((ys // 16 + xs // 16) % 2) * 255, ((ys // 8 + xs // 8) % 2) * 255, ((ys // 32) % 2) * 255],
|
||||
axis=-1,
|
||||
).astype(np.uint8),
|
||||
"noise_256x256": _hash_noise(256, 256, seed=1),
|
||||
"noise_512x768": _hash_noise(512, 768, seed=2),
|
||||
}
|
||||
return frames
|
||||
|
||||
|
||||
def digest(tensor: torch.Tensor) -> str:
|
||||
return hashlib.sha256(np.ascontiguousarray(tensor.numpy()).tobytes()).hexdigest()
|
||||
|
||||
|
||||
class _FakeTokenizer:
|
||||
padding_side = "right"
|
||||
|
||||
|
||||
class _FakeVlmProcessor:
|
||||
"""The two attributes ``Gr00tN1d7Processor`` and its collator touch on the Qwen3-VL processor."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.tokenizer = _FakeTokenizer()
|
||||
|
||||
def apply_chat_template(self, *_args: object, **_kwargs: object) -> str:
|
||||
return "<prompt>"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_vlm_processor(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The real Qwen3-VL processor is a gated download and irrelevant to the image pipeline."""
|
||||
monkeypatch.setattr(processing, "build_processor", lambda _model_name, _kwargs: _FakeVlmProcessor())
|
||||
|
||||
|
||||
def _processor(**kwargs) -> Gr00tN1d7Processor:
|
||||
return Gr00tN1d7Processor(
|
||||
modality_configs=MODALITY_CONFIGS, image_crop_size=CROP, image_target_size=TARGET, **kwargs
|
||||
)
|
||||
|
||||
|
||||
def _has_letterbox(compose: transforms.Compose) -> bool:
|
||||
return any(isinstance(step, LetterBoxTransform) for step in compose.transforms)
|
||||
|
||||
|
||||
def test_processor_builds_the_pipeline_named_by_processor_config():
|
||||
albumentations = _processor(use_albumentations=True, shortest_image_edge=EDGE, crop_fraction=FRACTION)
|
||||
transform = albumentations.eval_image_transform
|
||||
assert isinstance(transform, AlbumentationsEvalImageTransform)
|
||||
assert (transform.max_size, transform.fraction, transform.letter_box_transform) == (EDGE, FRACTION, False)
|
||||
assert albumentations.use_albumentations is True
|
||||
|
||||
torchvision = _processor(use_albumentations=False)
|
||||
assert isinstance(torchvision.eval_image_transform, transforms.Compose)
|
||||
assert not _has_letterbox(torchvision.eval_image_transform)
|
||||
|
||||
letterboxed = _processor(use_albumentations=False, letter_box_transform=True)
|
||||
assert _has_letterbox(letterboxed.eval_image_transform)
|
||||
assert _processor(use_albumentations=True, letter_box_transform=True).eval_image_transform.letter_box_transform
|
||||
|
||||
|
||||
def test_legacy_config_without_the_flags_keeps_the_torchvision_recipe():
|
||||
"""Checkpoints predating the flags behave like Isaac-GR00T's defaults; training-only keys stay ignored."""
|
||||
processor = _processor(state_dropout_prob=0.2, extra_augmentation_config=None)
|
||||
assert processor.use_albumentations is False
|
||||
assert processor.letter_box_transform is False
|
||||
assert isinstance(processor.eval_image_transform, transforms.Compose)
|
||||
assert not _has_letterbox(processor.eval_image_transform)
|
||||
|
||||
|
||||
def test_fraction_and_max_size_fall_back_like_isaac_gr00t():
|
||||
transform = _build_eval_image_transform(
|
||||
TARGET, CROP, shortest_image_edge=None, crop_fraction=None, use_albumentations=True
|
||||
)
|
||||
assert isinstance(transform, AlbumentationsEvalImageTransform)
|
||||
assert transform.fraction == CROP[0] / TARGET[0]
|
||||
assert transform.max_size == TARGET[0]
|
||||
with pytest.raises(ValueError, match="crop_fraction is None"):
|
||||
_build_eval_image_transform(TARGET, None, crop_fraction=None, use_albumentations=True)
|
||||
|
||||
|
||||
def test_geometry_follows_isaac_rounding_rules():
|
||||
frame = _hash_noise(540, 640, seed=3)
|
||||
resized = _smallest_edge_resize(frame, EDGE)
|
||||
assert resized.shape == (256, 303, 3) # round() per dimension, shortest edge -> 256
|
||||
cropped = _fractional_center_crop(resized, FRACTION)
|
||||
assert cropped.shape == (243, 287, 3) # int() truncation: 243.2 -> 243, 287.85 -> 287
|
||||
assert np.array_equal(cropped, resized[6:249, 8:295]) # (256-243)//2, (303-287)//2
|
||||
square = frame[:256, :256]
|
||||
assert _smallest_edge_resize(square, EDGE) is square # scale == 1.0 is a no-op, not a resample
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", sorted(GOLDEN))
|
||||
def test_albumentations_recipe_is_bit_exact_with_isaac_gr00t(case: str):
|
||||
name, flag = case.split("|letterbox=")
|
||||
transform = _build_eval_image_transform(
|
||||
TARGET,
|
||||
CROP,
|
||||
shortest_image_edge=EDGE,
|
||||
crop_fraction=FRACTION,
|
||||
use_albumentations=True,
|
||||
letter_box_transform=(flag == "True"),
|
||||
)
|
||||
out = transform(synthetic_frames()[name])
|
||||
expected_shape, expected_digest = GOLDEN[case]
|
||||
assert out.dtype == torch.uint8
|
||||
assert tuple(out.shape) == expected_shape
|
||||
assert digest(out) == expected_digest
|
||||
|
||||
|
||||
def test_letterbox_is_gated_on_both_recipes():
|
||||
frame = _hash_noise(540, 640, seed=3)
|
||||
|
||||
def albumentations(letterbox: bool) -> torch.Tensor:
|
||||
return _build_eval_image_transform(
|
||||
TARGET,
|
||||
CROP,
|
||||
shortest_image_edge=EDGE,
|
||||
crop_fraction=FRACTION,
|
||||
use_albumentations=True,
|
||||
letter_box_transform=letterbox,
|
||||
)(frame)
|
||||
|
||||
padded, unpadded = albumentations(True), albumentations(False)
|
||||
assert tuple(padded.shape) == (3, 256, 256) # 540x640 -> 640x640 -> 256x256 -> 243x243 -> 256x256
|
||||
assert tuple(unpadded.shape) == (3, 256, 302) # aspect preserved: 256x303 -> 243x287 -> 256x302
|
||||
|
||||
tv_padded = _build_eval_image_transform(TARGET, CROP, use_albumentations=False, letter_box_transform=True)(frame)
|
||||
tv_unpadded = _build_eval_image_transform(TARGET, CROP, use_albumentations=False, letter_box_transform=False)(frame)
|
||||
assert tuple(tv_padded.shape) == tuple(tv_unpadded.shape) == (3, 256, 256)
|
||||
assert not torch.equal(tv_padded, tv_unpadded)
|
||||
assert (tv_padded[:, :2, :] == 0).all() and not (tv_unpadded[:, :2, :] == 0).all() # black bars only when gated
|
||||
|
||||
|
||||
def test_albumentations_recipe_rejects_frames_outside_the_contract():
|
||||
transform = _build_eval_image_transform(TARGET, CROP, use_albumentations=True)
|
||||
with pytest.raises(ValueError, match="HWC uint8"):
|
||||
transform(np.zeros((256, 256, 3), dtype=np.float32))
|
||||
with pytest.raises(ValueError, match="HWC uint8"):
|
||||
transform(np.zeros((3, 256, 256), dtype=np.uint8))
|
||||
with pytest.raises(ValueError, match="HWC uint8"):
|
||||
transform(np.zeros((256, 256), dtype=np.uint8))
|
||||
|
||||
|
||||
def test_get_vlm_inputs_runs_the_selected_transform_per_frame():
|
||||
processor = _processor(use_albumentations=True, shortest_image_edge=EDGE, crop_fraction=FRACTION)
|
||||
frames = np.stack([_hash_noise(540, 640, seed=3)] * 2) # (T, H, W, C) like policy.py hands over
|
||||
vlm_inputs = processor._get_vlm_inputs(
|
||||
image_keys=["cam"],
|
||||
images={"cam": frames},
|
||||
masks=None,
|
||||
image_transform=processor.eval_image_transform,
|
||||
language="x",
|
||||
)
|
||||
images = vlm_inputs["vlm_content"]["images"]
|
||||
assert len(images) == 2
|
||||
assert all(img.size == (302, 256) for img in images) # PIL reports (W, H); no letterbox for this checkpoint
|
||||
|
||||
|
||||
def test_flags_round_trip_through_save_pretrained_and_from_pretrained(tmp_path):
|
||||
saved = _processor(
|
||||
use_albumentations=True, letter_box_transform=True, shortest_image_edge=EDGE, crop_fraction=FRACTION
|
||||
)
|
||||
saved.save_pretrained(tmp_path)
|
||||
kwargs = json.loads((tmp_path / "processor_config.json").read_text())["processor_kwargs"]
|
||||
assert kwargs["use_albumentations"] is True
|
||||
assert kwargs["letter_box_transform"] is True
|
||||
|
||||
loaded = Gr00tN1d7Processor.from_pretrained(tmp_path)
|
||||
assert loaded.use_albumentations is True
|
||||
assert loaded.letter_box_transform is True
|
||||
assert isinstance(loaded.eval_image_transform, AlbumentationsEvalImageTransform)
|
||||
assert loaded.eval_image_transform.letter_box_transform is True
|
||||
|
||||
|
||||
def test_from_pretrained_defaults_missing_flags_to_isaac_gr00t_defaults(tmp_path):
|
||||
(tmp_path / "processor_config.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"processor_class": "Gr00tN1d7Processor",
|
||||
"processor_kwargs": {
|
||||
"modality_configs": MODALITY_CONFIGS,
|
||||
"image_crop_size": CROP,
|
||||
"image_target_size": TARGET,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
(tmp_path / "statistics.json").write_text("{}")
|
||||
loaded = Gr00tN1d7Processor.from_pretrained(tmp_path)
|
||||
assert loaded.use_albumentations is False
|
||||
assert loaded.letter_box_transform is False
|
||||
assert isinstance(loaded.eval_image_transform, transforms.Compose)
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -13,6 +14,7 @@ from vllm_omni.diffusion.models.lingbot_world.actions import (
|
||||
LingBotCameraControlReducer,
|
||||
integrate_lingbot_camera_actions,
|
||||
parse_lingbot_camera_action_frames,
|
||||
parse_lingbot_camera_action_script,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.session import (
|
||||
ARDiffusionSession,
|
||||
@@ -292,3 +294,8 @@ def test_session_reducer_keeps_prompt_and_actions_atomic_and_resets() -> None:
|
||||
assert consumer.ticks[1].controls[0].data["frames"] == ((), (), ())
|
||||
|
||||
asyncio.run(exercise())
|
||||
|
||||
|
||||
def test_parse_camera_action_script_rejects_wrong_chunk_width() -> None:
|
||||
with pytest.raises(ValueError, match="exactly 3 per-latent-frame"):
|
||||
parse_lingbot_camera_action_script([[["w"], ["w"]]], frames_per_chunk=3)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -15,8 +15,10 @@ from diffusers.utils.torch_utils import randn_tensor as _diffusers_randn_tensor
|
||||
from PIL import Image
|
||||
from torch import nn
|
||||
|
||||
import vllm_omni.diffusion.models.lingbot_world.dmd_block as lingbot_dmd_block
|
||||
import vllm_omni.diffusion.models.lingbot_world.pipeline as lingbot_pipeline
|
||||
from tests.diffusion.models.wan2_2.conftest import noop_progress_bar
|
||||
from vllm_omni.diffusion.models.interface import SupportsStepExecution, supports_step_execution
|
||||
from vllm_omni.diffusion.models.lingbot_world.actions import (
|
||||
integrate_lingbot_camera_actions,
|
||||
)
|
||||
@@ -24,6 +26,8 @@ from vllm_omni.diffusion.models.lingbot_world.camera import CameraTrajectory as
|
||||
from vllm_omni.diffusion.models.lingbot_world.camera import (
|
||||
build_plucker_embedding as _real_build_plucker_embedding,
|
||||
)
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.utils import StepRequestState
|
||||
from vllm_omni.experimental.ar_diffusion.tick_protocol import (
|
||||
ARDiffusionControlInput,
|
||||
ARDiffusionTickRequest,
|
||||
@@ -253,6 +257,8 @@ class _SamplingParams:
|
||||
if extra_args is not None:
|
||||
self.extra_args.update(extra_args)
|
||||
self.latents = None
|
||||
self.guidance_scale = None
|
||||
self.guidance_scale_2 = None
|
||||
|
||||
|
||||
class _RequestBatch:
|
||||
@@ -345,7 +351,6 @@ def _stub_pipeline_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"FlowUniPCMultistepScheduler": _FakeScheduler,
|
||||
"CausalLingBotWorldTransformer3DModel": _FakeTransformerFactory,
|
||||
"get_local_device": lambda: torch.device("cpu"),
|
||||
"set_forward_context_denoise_step_idx": lambda index: None,
|
||||
"prefetch_subfolders": prefetch_subfolders,
|
||||
"from_pretrained_with_prefetch": from_pretrained_with_prefetch,
|
||||
"load_transformer_config": load_transformer_config,
|
||||
@@ -358,6 +363,9 @@ def _stub_pipeline_dependencies(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
}
|
||||
for name, value in replacements.items():
|
||||
monkeypatch.setattr(lingbot_pipeline, name, value)
|
||||
# The DMD block math lives in its own module; stub the symbols it imports.
|
||||
monkeypatch.setattr(lingbot_dmd_block, "set_forward_context_denoise_step_idx", lambda index: None)
|
||||
monkeypatch.setattr(lingbot_dmd_block, "randn_tensor", _diffusers_randn_tensor)
|
||||
monkeypatch.setattr(lingbot_pipeline, "_loader_state", loader_state, raising=False)
|
||||
|
||||
|
||||
@@ -774,6 +782,7 @@ def test_denoise_state_stays_fp32_while_transformer_inputs_use_model_dtype() ->
|
||||
return torch.zeros(shape, device=device, dtype=dtype)
|
||||
|
||||
module.randn_tensor = randn
|
||||
lingbot_dmd_block.randn_tensor = randn
|
||||
result = pipeline(_request())
|
||||
|
||||
assert requested_noise_dtypes == [torch.float32] * 4
|
||||
@@ -1139,7 +1148,7 @@ def test_first_frame_condition_and_camera_fold_match_transformer_contract() -> N
|
||||
module = _load_pipeline_module()
|
||||
transformer = _RecordingTransformer()
|
||||
pipeline = _pipeline(module, transformer=transformer)
|
||||
module.randn_tensor = lambda shape, **kwargs: torch.full(
|
||||
module.randn_tensor = lingbot_dmd_block.randn_tensor = lambda shape, **kwargs: torch.full(
|
||||
shape,
|
||||
-99.0,
|
||||
device=kwargs["device"],
|
||||
@@ -1745,6 +1754,288 @@ def test_request_cache_becomes_unreachable_after_transformer_error() -> None:
|
||||
assert not hasattr(pipeline, "transformer_cache")
|
||||
|
||||
|
||||
class _FakeARState:
|
||||
def __init__(self, session_id: str = "req-1") -> None:
|
||||
self.session_id = session_id
|
||||
self.commits: list[str] = []
|
||||
|
||||
def get_kv_caches(self, branch, *, seq_len, commit_current):
|
||||
del branch, seq_len, commit_current
|
||||
return [SimpleNamespace()]
|
||||
|
||||
def commit_paged_context(self, branch):
|
||||
self.commits.append(branch)
|
||||
|
||||
def clear_cross_attention(self):
|
||||
return None
|
||||
|
||||
def is_cross_attention_populated(self, branch, name):
|
||||
del branch, name
|
||||
return True
|
||||
|
||||
def get_cross_attention_kv(self, branch, name):
|
||||
del branch, name
|
||||
zeros = torch.zeros(1, 1, 2, 4)
|
||||
return [{"k": zeros, "v": zeros} for _ in range(2)]
|
||||
|
||||
|
||||
def _empty_action_script(num_chunks: int):
|
||||
return tuple(((), (), ()) for _ in range(num_chunks))
|
||||
|
||||
|
||||
def _stepwise_state(
|
||||
*,
|
||||
request_id: str = "req-1",
|
||||
num_frames: int = 21,
|
||||
script=None,
|
||||
seed: int = 17,
|
||||
):
|
||||
num_chunks = ((num_frames - 1) // 4 + 1) // 3
|
||||
sampling = _SamplingParams(num_frames=num_frames, seed=seed)
|
||||
sampling.extra_args["_lingbot_camera_trajectory"] = None
|
||||
sampling.extra_args["_lingbot_camera_actions"] = None
|
||||
sampling.extra_args["_lingbot_camera_action_script"] = (
|
||||
script if script is not None else _empty_action_script(num_chunks)
|
||||
)
|
||||
return StepRequestState(
|
||||
request_id=request_id,
|
||||
sampling=sampling,
|
||||
prompt=_prompt(),
|
||||
)
|
||||
|
||||
|
||||
def _run_stepwise(pipeline, state):
|
||||
outputs = []
|
||||
pipeline.prepare_encode(state)
|
||||
while not state.request_denoise_completed:
|
||||
noise = pipeline.denoise_step(None, states=[state])
|
||||
pipeline.step_scheduler(state, noise)
|
||||
if state.chunk_denoise_completed:
|
||||
outputs.append(pipeline.post_decode(state))
|
||||
return outputs
|
||||
|
||||
|
||||
def test_pipeline_declares_step_execution_support() -> None:
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module)
|
||||
|
||||
assert pipeline.supports_step_execution is True
|
||||
assert isinstance(pipeline, SupportsStepExecution)
|
||||
assert supports_step_execution(pipeline) is True
|
||||
|
||||
|
||||
def test_preprocess_materializes_camera_action_script_without_action_path() -> None:
|
||||
module = _load_pipeline_module()
|
||||
sampling = _SamplingParams(include_action=False)
|
||||
sampling.extra_args["camera_action_script"] = [[["w"], ["w"], ["w"]], [["a"], [], []]]
|
||||
request = OmniDiffusionRequest(prompt=_prompt(), sampling_params=sampling, request_id="req-1")
|
||||
|
||||
result = module.get_lingbot_world_pre_process_func(_od_config())(request)
|
||||
|
||||
assert result.sampling_params.extra_args["_lingbot_camera_trajectory"] is None
|
||||
assert result.sampling_params.extra_args["_lingbot_camera_actions"] is None
|
||||
assert result.sampling_params.extra_args["_lingbot_camera_action_script"] == (
|
||||
(("w",), ("w",), ("w",)),
|
||||
(("a",), (), ()),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_args",
|
||||
[
|
||||
pytest.param({}, id="request_mode"),
|
||||
pytest.param(_tick_extra_args(chunk_index=0), id="tick_mode"),
|
||||
],
|
||||
)
|
||||
def test_forward_rejects_a_stepwise_camera_action_script(extra_args) -> None:
|
||||
"""A script only steers step execution, so request mode must not drop it silently."""
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module)
|
||||
sampling = _SamplingParams(
|
||||
include_action=False,
|
||||
extra_args={
|
||||
**extra_args,
|
||||
"_lingbot_camera_trajectory": None,
|
||||
"_lingbot_camera_action_script": _empty_action_script(1),
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="camera_action_script is read only by LingBot step execution"):
|
||||
pipeline(_request(sampling=sampling))
|
||||
|
||||
|
||||
def test_stepwise_progress_metadata_and_commit_trace() -> None:
|
||||
module = _load_pipeline_module()
|
||||
transformer = _RecordingTransformer()
|
||||
pipeline = _pipeline(module, transformer=transformer)
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
state = _stepwise_state(num_frames=21)
|
||||
fake = _FakeARState(state.request_id)
|
||||
|
||||
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
|
||||
outputs = _run_stepwise(pipeline, state)
|
||||
|
||||
assert pipeline._ar_diffusion_kv_state is None
|
||||
assert len(outputs) == 2
|
||||
assert [output.chunk_index for output in outputs] == [0, 1]
|
||||
assert all(output.total_chunks == 2 for output in outputs)
|
||||
assert outputs[-1].finished is True
|
||||
assert [call["update_cache"] for call in transformer.calls] == [False, False, False, False, True] * 2
|
||||
assert [call["start_frame"] for call in transformer.calls] == [0] * 5 + [3] * 5
|
||||
assert fake.commits == ["main", "main"]
|
||||
for chunk_index, output in enumerate(outputs):
|
||||
metadata = output.output["metadata"]["ar_diffusion"]
|
||||
assert metadata == {
|
||||
"session_id": "req-1",
|
||||
"request_id": "req-1",
|
||||
"chunk_index": chunk_index,
|
||||
"applied_event_ids": [],
|
||||
}
|
||||
assert output.output["payload"]["latents"].shape == (1, 16, 3, 2, 2)
|
||||
|
||||
|
||||
def test_stepwise_matches_tick_transformer_trace_and_latents() -> None:
|
||||
module = _load_pipeline_module()
|
||||
transformer = _RecordingTransformer()
|
||||
pipeline = _pipeline(module, transformer=transformer)
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
script = ((("w",), ("w",), ("w",)), (("d",), ("d",), ("d",)))
|
||||
fake = _FakeARState("world-1")
|
||||
|
||||
with pipeline.bind_ar_diffusion_state("world-1", fake):
|
||||
tick_latents = []
|
||||
for chunk_index, frames in enumerate(script):
|
||||
tick = ARDiffusionTickRequest(
|
||||
session_id="world-1",
|
||||
request_id="legacy-lingbot-request",
|
||||
chunk_index=chunk_index,
|
||||
controls=(
|
||||
ARDiffusionControlInput(
|
||||
track="camera",
|
||||
schema="lingbot.camera_actions.v1",
|
||||
data={"mode": "frames", "frames": [list(frame) for frame in frames]},
|
||||
),
|
||||
),
|
||||
)
|
||||
sampling = _SamplingParams(extra_args=tick.to_extra_args(), seed=17)
|
||||
sampling.extra_args["_lingbot_camera_trajectory"] = None
|
||||
sampling.extra_args["_lingbot_camera_actions"] = frames
|
||||
result = pipeline(_request(sampling=sampling))
|
||||
tick_latents.append(result.output["payload"]["latents"].clone())
|
||||
tick_calls = list(transformer.calls)
|
||||
transformer.calls.clear()
|
||||
pipeline.close_ar_diffusion_session("world-1")
|
||||
|
||||
stepwise_state = _stepwise_state(request_id="req-1", num_frames=21, script=script, seed=17)
|
||||
stepwise_fake = _FakeARState(stepwise_state.request_id)
|
||||
with pipeline.bind_ar_diffusion_state(stepwise_state.request_id, stepwise_fake):
|
||||
stepwise_outputs = _run_stepwise(pipeline, stepwise_state)
|
||||
|
||||
assert [call["update_cache"] for call in transformer.calls] == [call["update_cache"] for call in tick_calls]
|
||||
assert [call["start_frame"] for call in transformer.calls] == [call["start_frame"] for call in tick_calls]
|
||||
for tick_latent, output in zip(tick_latents, stepwise_outputs, strict=True):
|
||||
torch.testing.assert_close(output.output["payload"]["latents"], tick_latent)
|
||||
|
||||
|
||||
def test_stepwise_trajectory_camera_matches_request_mode_under_non_uniform_speed(monkeypatch) -> None:
|
||||
"""A trajectory that slows down between blocks must condition every stepwise
|
||||
chunk exactly as request mode does. Request mode embeds the whole trajectory
|
||||
once; embedding per chunk would re-normalize each block's framewise
|
||||
translations by its own largest step and present a slow block as full-speed
|
||||
motion."""
|
||||
from vllm_omni.diffusion.models.lingbot_world import camera as camera_module
|
||||
|
||||
module = _load_pipeline_module()
|
||||
# The fixture stubs the ray embedding with a pose-blind ramp; parity needs
|
||||
# the real geometry so a scale mismatch actually shows up.
|
||||
monkeypatch.setattr(module, "build_plucker_embedding", camera_module.build_plucker_embedding)
|
||||
transformer = _RecordingTransformer()
|
||||
pipeline = _pipeline(module, transformer=transformer)
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
|
||||
# Six latent frames -> two 3-frame blocks. The first block moves at full
|
||||
# speed and everything after it at a tenth of that, so a per-chunk
|
||||
# normalization would rescale the second block by 10x.
|
||||
steps = torch.tensor([1.0] * 3 + [0.1] * 18)
|
||||
poses = torch.eye(4).repeat(21, 1, 1)
|
||||
poses[:, 2, 3] = torch.cumsum(steps, dim=0) - steps[0]
|
||||
trajectory = _CameraTrajectory(
|
||||
poses=poses,
|
||||
intrinsics=torch.tensor([[100.0, 100.0, 8.0, 8.0]]).repeat(21, 1),
|
||||
)
|
||||
|
||||
sampling = _SamplingParams(num_frames=21)
|
||||
sampling.extra_args["_lingbot_camera_trajectory"] = trajectory
|
||||
pipeline(_request(sampling=sampling))
|
||||
request_cameras = [call["camera_hidden_states"] for call in transformer.calls]
|
||||
transformer.calls.clear()
|
||||
|
||||
state = _stepwise_state(num_frames=21)
|
||||
state.sampling.extra_args["_lingbot_camera_trajectory"] = trajectory
|
||||
state.sampling.extra_args.pop("_lingbot_camera_action_script")
|
||||
with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)):
|
||||
_run_stepwise(pipeline, state)
|
||||
stepwise_cameras = [call["camera_hidden_states"] for call in transformer.calls]
|
||||
|
||||
# Two blocks x (four probes + one commit), in the same order on both paths.
|
||||
assert len(request_cameras) == len(stepwise_cameras) == 10
|
||||
for stepwise, request in zip(stepwise_cameras, request_cameras, strict=True):
|
||||
torch.testing.assert_close(stepwise, request)
|
||||
|
||||
|
||||
def test_stepwise_emits_latents_without_touching_the_vae() -> None:
|
||||
"""Latent mode must stay latent: streaming decode is opt-in per request."""
|
||||
module = _load_pipeline_module()
|
||||
pipeline = _pipeline(module, transformer=_RecordingTransformer())
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
state = _stepwise_state(num_frames=21)
|
||||
fake = _FakeARState(state.request_id)
|
||||
|
||||
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
|
||||
outputs = _run_stepwise(pipeline, state)
|
||||
|
||||
assert [set(output.output["payload"]) for output in outputs] == [{"latents"}, {"latents"}]
|
||||
assert pipeline.vae.decode_inputs == []
|
||||
|
||||
|
||||
def test_stepwise_decodes_each_chunk_for_streaming_consumers(monkeypatch) -> None:
|
||||
"""A non-latent request must stream pixels under the primary "video" key."""
|
||||
import diffusers.video_processor as video_processor_module
|
||||
|
||||
module = _load_pipeline_module()
|
||||
processed = []
|
||||
|
||||
class VideoProcessor:
|
||||
def __init__(self, *, vae_scale_factor):
|
||||
assert vae_scale_factor == 8
|
||||
|
||||
def postprocess_video(self, video, *, output_type):
|
||||
processed.append((tuple(video.shape), output_type))
|
||||
return f"frames-{len(processed)}"
|
||||
|
||||
monkeypatch.setattr(video_processor_module, "VideoProcessor", VideoProcessor)
|
||||
pipeline = _pipeline(module, transformer=_RecordingTransformer())
|
||||
pipeline._ar_height = 16
|
||||
pipeline._ar_width = 16
|
||||
state = _stepwise_state(num_frames=21)
|
||||
state.sampling.output_type = "np"
|
||||
fake = _FakeARState(state.request_id)
|
||||
|
||||
with pipeline.bind_ar_diffusion_state(state.request_id, fake):
|
||||
outputs = _run_stepwise(pipeline, state)
|
||||
|
||||
# One decode per AR block, each seeing only that block's three latent frames.
|
||||
assert [tuple(latents.shape) for latents in pipeline.vae.decode_inputs] == [(1, 16, 3, 2, 2)] * 2
|
||||
assert [output_type for _, output_type in processed] == ["np", "np"]
|
||||
assert [output.output["payload"] for output in outputs] == [{"video": "frames-1"}, {"video": "frames-2"}]
|
||||
# Identity metadata survives the switch to pixel output.
|
||||
assert [output.output["metadata"]["ar_diffusion"]["chunk_index"] for output in outputs] == [0, 1]
|
||||
|
||||
|
||||
def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> None:
|
||||
module = _load_pipeline_module()
|
||||
resolved, entry, cache_acceleration_disabled, preprocess_name, preprocess = _resolve_pipeline_through_real_registry(
|
||||
@@ -1755,7 +2046,7 @@ def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> No
|
||||
assert resolved is module.LingBotWorldCausalDMDPipeline
|
||||
assert cache_acceleration_disabled
|
||||
assert preprocess_name == "get_lingbot_world_pre_process_func"
|
||||
request = SimpleNamespace(prompt=_prompt(), sampling_params=_SamplingParams())
|
||||
request = OmniDiffusionRequest(prompt=_prompt(), sampling_params=_SamplingParams(), request_id="req-1")
|
||||
assert preprocess(request) is request
|
||||
assert isinstance(request.sampling_params.extra_args["_lingbot_camera_trajectory"], _CameraTrajectory)
|
||||
assert module.LingBotWorldCausalDMDPipeline.__name__ == "LingBotWorldCausalDMDPipeline"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import json
|
||||
from dataclasses import replace
|
||||
from threading import Lock
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -185,7 +186,7 @@ def test_ltx25_missing_gemma4_recommends_supported_transformers_range(monkeypatc
|
||||
ltx2_components.initialize_pipeline_components(pipe, od_config)
|
||||
|
||||
assert str(exc_info.value) == (
|
||||
"LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.10.1,<5.15."
|
||||
"LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.13.0,<5.15."
|
||||
)
|
||||
|
||||
|
||||
@@ -386,7 +387,7 @@ def test_ltx25_natten_failure_has_actionable_omni_remedy(monkeypatch):
|
||||
MissingNattenProcessor,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match=r"kernels==0\.15\.2.*supported GPU.*allow Hub access"):
|
||||
with pytest.raises(RuntimeError, match=r"kernels==0\.16\.1.*supported GPU.*allow Hub access"):
|
||||
ltx2_components._create_ltx25_natten_processor()
|
||||
|
||||
|
||||
@@ -500,6 +501,32 @@ def test_ltx25_checkpoint_selects_distilled_two_stage_profile(tmp_path, monkeypa
|
||||
assert pipe.pipeline_recipe is LTX25_DISTILLED_TWO_STAGE_RECIPE
|
||||
|
||||
|
||||
def test_ltx_runtime_profiles_phase_methods_separately(monkeypatch):
|
||||
pipe = object.__new__(LTX2DistilledTwoStagePipeline)
|
||||
pipe.enable_diffusion_pipeline_profiler = True
|
||||
pipe._profiler_lock = Lock()
|
||||
pipe._stage_durations = {}
|
||||
monkeypatch.setattr(ltx2_runtime.current_omni_platform, "is_available", lambda: False)
|
||||
|
||||
with pipe._profile_phase_method("run_phase", "generate_lowres"):
|
||||
pass
|
||||
with pipe._profile_phase_method("run_phase", "refine"):
|
||||
pass
|
||||
with pipe._profile_phase_method("decode_phase"):
|
||||
pass
|
||||
output = DiffusionOutput()
|
||||
assert pipe._attach_profiled_stage_durations(output) is output
|
||||
|
||||
expected_keys = {
|
||||
"LTX2DistilledTwoStagePipeline.generate_lowres.run_phase",
|
||||
"LTX2DistilledTwoStagePipeline.refine.run_phase",
|
||||
"LTX2DistilledTwoStagePipeline.decode_phase",
|
||||
}
|
||||
assert set(pipe.stage_durations) == expected_keys
|
||||
assert set(output.stage_durations) == expected_keys
|
||||
assert all(duration >= 0.0 for duration in pipe.stage_durations.values())
|
||||
|
||||
|
||||
def test_ltx25_full_config_enrichment_uses_selected_transformer_subfolder(tmp_path):
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Native FNA against an independent, test-only explicit neighborhood mask."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.nn.attention import SDPBackend, sdpa_kernel
|
||||
|
||||
from vllm_omni.diffusion.models.ltx2.ops import resolve_ltx2_vae_operators
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _selected_fna():
|
||||
if not current_omni_platform.is_available():
|
||||
pytest.skip("No accelerator is available")
|
||||
operators = resolve_ltx2_vae_operators(current_omni_platform.get_torch_device())
|
||||
if operators is None or operators.fna is None:
|
||||
pytest.skip("LTX DiffVAE FNA is unavailable for this device or installation")
|
||||
return operators.fna
|
||||
|
||||
|
||||
def _load_fna_ops():
|
||||
return pytest.importorskip("vllm_omni.diffusion.models.ltx2.ops.fna", exc_type=ImportError)
|
||||
|
||||
|
||||
def _pack(x, tile):
|
||||
b, t, h, w, heads, dim = x.shape
|
||||
tt, th, tw = tile
|
||||
nt, nh, nw = (t + tt - 1) // tt, (h + th - 1) // th, (w + tw - 1) // tw
|
||||
padded = x.new_zeros((b, nt * tt, nh * th, nw * tw, heads, dim))
|
||||
padded[:, :t, :h, :w] = x
|
||||
# Outer and inner spatial tiles both flatten in W,H,T order.
|
||||
return (
|
||||
padded.view(b, nt, tt, nh, th, nw, tw, heads, dim)
|
||||
.permute(0, 5, 3, 1, 6, 4, 2, 7, 8)
|
||||
.reshape(b, nt * nh * nw * tt * th * tw, heads, dim)
|
||||
)
|
||||
|
||||
|
||||
def _unpack(x, shape):
|
||||
t, h, w = shape
|
||||
b, _, heads, dim = x.shape
|
||||
nt, nh, nw = ((n + 3) // 4 for n in shape)
|
||||
return (
|
||||
x.view(b, nw, nh, nt, 4, 4, 4, heads, dim)
|
||||
.permute(0, 3, 6, 2, 5, 1, 4, 7, 8)
|
||||
.reshape(b, nt * 4, nh * 4, nw * 4, heads, dim)[:, :t, :h, :w]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("batch,shape", [(1, (11, 11, 11)), (2, (13, 12, 17))])
|
||||
@torch.inference_mode()
|
||||
def test_fna_matches_explicit_inward_shift_mask(batch, shape):
|
||||
fna = _selected_fna()
|
||||
generator = torch.Generator(device="cuda").manual_seed(42)
|
||||
q, k, v = [
|
||||
torch.randn(batch, *shape, 4, 64, device="cuda", dtype=torch.bfloat16, generator=generator) for _ in range(3)
|
||||
]
|
||||
q = q * 0.125
|
||||
coordinates = torch.stack(
|
||||
torch.meshgrid(*(torch.arange(n, device="cuda") for n in shape), indexing="ij"), dim=-1
|
||||
).reshape(-1, 3)
|
||||
start = (coordinates - 5).clamp_min(0).minimum(torch.tensor(shape, device="cuda") - 11)
|
||||
mask = ((coordinates[None] >= start[:, None]) & (coordinates[None] < start[:, None] + 11)).all(-1)
|
||||
with sdpa_kernel(SDPBackend.MATH):
|
||||
expected = (
|
||||
torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.flatten(1, 3).transpose(1, 2).float() for x in (q, k, v)), attn_mask=mask, scale=1.0
|
||||
)
|
||||
.transpose(1, 2)
|
||||
.reshape(q.shape)
|
||||
)
|
||||
actual = _unpack(
|
||||
fna(_pack(q, (4, 4, 4)), _pack(k, (4, 4, 8)), _pack(v, (4, 4, 8)), shape=shape, window=(11, 11, 11)),
|
||||
shape,
|
||||
).float()
|
||||
assert actual.isfinite().all()
|
||||
assert float((actual - expected).norm() / expected.norm()) < 0.005
|
||||
torch.testing.assert_close(actual, expected, atol=0.008, rtol=0.025)
|
||||
|
||||
|
||||
def test_fna_rejects_cpu_without_fallback():
|
||||
fna_ops = _load_fna_ops()
|
||||
x = torch.zeros(1, 64, 4, 64, dtype=torch.bfloat16)
|
||||
with pytest.raises(ValueError, match="CUDA BF16"):
|
||||
fna_ops.fna3d_tilelang(x, x, x, shape=(11, 11, 11), window=(11, 11, 11))
|
||||
@@ -0,0 +1,409 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.ltx2.ops import qk_rms_norm as qk_rms_norm_ops
|
||||
from vllm_omni.diffusion.models.ltx2.ops import residual_adaln as residual_adaln_ops
|
||||
from vllm_omni.diffusion.models.ltx2.ops import resolve_ltx2_vae_operators
|
||||
from vllm_omni.diffusion.models.ltx2.ops import swiglu as swiglu_ops
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cuda, pytest.mark.diffusion]
|
||||
|
||||
_DIM_SPLIT = (16, 24, 24)
|
||||
|
||||
|
||||
def _selected_operators():
|
||||
if not current_omni_platform.is_available():
|
||||
pytest.skip("No accelerator is available")
|
||||
operators = resolve_ltx2_vae_operators(current_omni_platform.get_torch_device())
|
||||
if operators is None:
|
||||
pytest.skip("No LTX DiffVAE operators are available for this device")
|
||||
return operators
|
||||
|
||||
|
||||
def _selected_fna():
|
||||
operators = _selected_operators()
|
||||
if operators.fna is None:
|
||||
pytest.skip("LTX DiffVAE FNA is unavailable for this device or installation")
|
||||
return operators.fna
|
||||
|
||||
|
||||
def _load_fna_ops():
|
||||
return pytest.importorskip("vllm_omni.diffusion.models.ltx2.ops.fna", exc_type=ImportError)
|
||||
|
||||
|
||||
def test_diffvae_model_and_tests_do_not_import_tilelang_backend() -> None:
|
||||
# Use a fresh interpreter: reloading the model in this process would leave
|
||||
# already-imported distributed subclasses pointing at the old base class.
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; "
|
||||
"sys.modules['vllm_omni.diffusion.models.ltx2.ops.fna'] = None; "
|
||||
"sys.modules['vllm.tilelang_utils'] = None; "
|
||||
"from vllm_omni.diffusion.models.ltx2.vae.decoder import LTX2VideoDiffusionDecoderModel; "
|
||||
"import tests.diffusion.models.ltx2.test_ltx2_diffvae_attention; "
|
||||
"import tests.diffusion.models.ltx2.test_ltx2_diffvae_ops",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
def test_diffvae_fna_tile_map_covers_exact_neighborhood() -> None:
|
||||
fna_ops = _load_fna_ops()
|
||||
problem = fna_ops.Problem(13, 14, 17, (11, 11, 11))
|
||||
indices, counts = fna_ops.build_kv_tile_map(problem)
|
||||
q_grid_t, q_grid_h, _ = problem.q_grid
|
||||
kv_grid_t, kv_grid_h, _ = problem.kv_grid
|
||||
|
||||
for q_block in range(indices.shape[0]):
|
||||
selected = set(indices[q_block, : counts[q_block]].tolist())
|
||||
q_outer_t = q_block % q_grid_t
|
||||
q_outer_h = (q_block // q_grid_t) % q_grid_h
|
||||
q_outer_w = q_block // (q_grid_t * q_grid_h)
|
||||
for q_inner in range(fna_ops.Q_TOKENS):
|
||||
query = (
|
||||
q_outer_t * 4 + q_inner % 4,
|
||||
q_outer_h * 4 + (q_inner // 4) % 4,
|
||||
q_outer_w * 4 + q_inner // 16,
|
||||
)
|
||||
if any(index >= length for index, length in zip(query, problem.shape, strict=True)):
|
||||
continue
|
||||
starts = tuple(
|
||||
fna_ops._window_start(index, window, length)
|
||||
for index, window, length in zip(query, problem.window, problem.shape, strict=True)
|
||||
)
|
||||
for key_t in range(starts[0], starts[0] + problem.window[0]):
|
||||
for key_h in range(starts[1], starts[1] + problem.window[1]):
|
||||
for key_w in range(starts[2], starts[2] + problem.window[2]):
|
||||
key_block = ((key_w // 8) * kv_grid_h + key_h // 4) * kv_grid_t + key_t // 4
|
||||
assert key_block in selected
|
||||
|
||||
|
||||
def test_diffvae_fna_metadata_uses_static_kernel_shapes() -> None:
|
||||
fna_ops = _load_fna_ops()
|
||||
for shape in ((11, 11, 11), (13, 14, 17), (66, 96, 128), (79, 192, 192)):
|
||||
problem = fna_ops.Problem(*shape, (11, 11, 11))
|
||||
_, t_patterns, h_patterns, w_patterns = fna_ops.build_kv_tile_metadata(problem)
|
||||
assert {table.numel() for table in (t_patterns, h_patterns, w_patterns)} == {fna_ops.PATTERN_TABLE_SIZE}
|
||||
assert {limit for _, _, limit in fna_ops.build_kv_tile_buckets(problem)} <= set(fna_ops.KV_BUCKET_LIMITS)
|
||||
for ids, metadata, limit in fna_ops.build_kv_tile_buckets(problem):
|
||||
assert metadata.shape == (ids.numel(), limit)
|
||||
|
||||
|
||||
def test_diffvae_fna_rejects_oversized_packed_tile_grid() -> None:
|
||||
fna_ops = _load_fna_ops()
|
||||
with pytest.raises(ValueError, match="15-bit metadata"):
|
||||
fna_ops.Problem(128, 256, 256, (11, 11, 11))
|
||||
|
||||
|
||||
def test_diffvae_fna_propagates_launch_failures(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
fna_ops = _load_fna_ops()
|
||||
fna = _selected_fna()
|
||||
problem = fna_ops.Problem(11, 11, 11, (11, 11, 11))
|
||||
query = torch.randn(1, problem.q_length, fna_ops.HEADS, 64, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn(1, problem.kv_length, fna_ops.HEADS, 64, device="cuda", dtype=torch.bfloat16)
|
||||
value = torch.randn_like(key)
|
||||
calls = 0
|
||||
|
||||
def fail_launch(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(fna_ops, "_fna3d_tilelang", fail_launch)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
with pytest.raises(RuntimeError, match="injected launch failure"):
|
||||
fna(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
shape=problem.shape,
|
||||
window=problem.window,
|
||||
)
|
||||
assert calls == 2
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_runtime_state():
|
||||
targets = (
|
||||
(qk_rms_norm_ops, "_FAILED_ROPE_KEYS"),
|
||||
(qk_rms_norm_ops, "_VERIFIED_ROPE_KEYS"),
|
||||
(swiglu_ops, "_FAILED_KEYS"),
|
||||
(swiglu_ops, "_VERIFIED_KEYS"),
|
||||
(residual_adaln_ops, "_FAILED_ADALN_KEYS"),
|
||||
(residual_adaln_ops, "_VERIFIED_ADALN_KEYS"),
|
||||
(residual_adaln_ops, "_FAILED_ADD_DEVICES"),
|
||||
(residual_adaln_ops, "_VERIFIED_ADD_DEVICES"),
|
||||
)
|
||||
saved = [set(getattr(module, name)) for module, name in targets]
|
||||
for module, name in targets:
|
||||
getattr(module, name).clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for (module, name), state in zip(targets, saved, strict=True):
|
||||
target = getattr(module, name)
|
||||
target.clear()
|
||||
target.update(state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dim", "rows"),
|
||||
[
|
||||
(256, 257),
|
||||
(256, 16385),
|
||||
(256, 131075),
|
||||
(512, 65539),
|
||||
(1024, 32771),
|
||||
(2048, 16387),
|
||||
],
|
||||
)
|
||||
def test_diffvae_swiglu_tiled_is_bit_exact(dim: int, rows: int, isolated_runtime_state) -> None:
|
||||
from diffusers.models.autoencoders.ltx2_diffusion_decoder import LTX2VideoVaeSwiGLU
|
||||
|
||||
operators = _selected_operators()
|
||||
generator = torch.Generator(device="cuda").manual_seed(20260829)
|
||||
hidden_states = torch.randn((1, 1, 1, rows, dim), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
gate_weight = torch.randn((4 * dim, dim), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
up_weight = torch.randn_like(gate_weight)
|
||||
down_weight = torch.randn((dim, 4 * dim), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
with torch.device("meta"):
|
||||
reference = LTX2VideoVaeSwiGLU(dim, 4 * dim)
|
||||
reference.w_gate.weight = torch.nn.Parameter(gate_weight, requires_grad=False)
|
||||
reference.w_up.weight = torch.nn.Parameter(up_weight, requires_grad=False)
|
||||
reference.w_down.weight = torch.nn.Parameter(down_weight, requires_grad=False)
|
||||
|
||||
with torch.inference_mode():
|
||||
expected = reference(hidden_states)
|
||||
actual = operators.swiglu(hidden_states, gate_weight, up_weight, down_weight)
|
||||
|
||||
assert actual is not None
|
||||
assert torch.equal(actual, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dim", "expected_rows"),
|
||||
[(256, 131072), (512, 65536), (1024, 32768), (2048, 16384)],
|
||||
)
|
||||
def test_diffvae_swiglu_tile_rows_keep_a_constant_hidden_budget(dim: int, expected_rows: int) -> None:
|
||||
assert swiglu_ops._tile_rows(1_000_000, 4 * dim) == expected_rows
|
||||
|
||||
|
||||
def test_diffvae_swiglu_tiled_permanently_falls_back_after_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
isolated_runtime_state,
|
||||
) -> None:
|
||||
operators = _selected_operators()
|
||||
hidden_states = torch.randn(1, 1, 1, 17, 256, device="cuda", dtype=torch.bfloat16)
|
||||
gate_weight = torch.randn(1024, 256, device="cuda", dtype=torch.bfloat16)
|
||||
up_weight = torch.randn_like(gate_weight)
|
||||
down_weight = torch.randn(256, 1024, device="cuda", dtype=torch.bfloat16)
|
||||
calls = 0
|
||||
|
||||
def fail_launch(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(swiglu_ops, "_launch", fail_launch)
|
||||
with torch.inference_mode():
|
||||
assert operators.swiglu(hidden_states, gate_weight, up_weight, down_weight) is None
|
||||
assert operators.swiglu(hidden_states, gate_weight, up_weight, down_weight) is None
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("residual_count", [1, 2])
|
||||
def test_diffvae_residual_rms_norm_modulate_is_bit_exact(
|
||||
residual_count: int,
|
||||
isolated_runtime_state,
|
||||
) -> None:
|
||||
operators = _selected_operators()
|
||||
generator = torch.Generator(device="cuda").manual_seed(20260829)
|
||||
shape = (2, 3, 7, 7, 256)
|
||||
x = torch.randn(shape, device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
residual_a = torch.randn_like(x)
|
||||
residual_b = torch.randn_like(x) if residual_count == 2 else None
|
||||
norm_weight = torch.randn((256,), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
scale = torch.randn((2, 1, 1, 1, 256), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
shift = torch.randn_like(scale)
|
||||
hidden_states = x + residual_a
|
||||
if residual_b is not None:
|
||||
hidden_states = hidden_states + residual_b
|
||||
expected = torch.nn.functional.rms_norm(hidden_states, (256,), norm_weight, eps=1e-6) * (1 + scale) + shift
|
||||
|
||||
with torch.inference_mode():
|
||||
actual = operators.residual_norm(
|
||||
x,
|
||||
residual_a,
|
||||
residual_b,
|
||||
norm_weight,
|
||||
scale,
|
||||
shift,
|
||||
1e-6,
|
||||
)
|
||||
|
||||
assert actual is not None
|
||||
assert torch.equal(actual, expected)
|
||||
|
||||
|
||||
def test_diffvae_residual_add3_is_bit_exact(isolated_runtime_state) -> None:
|
||||
operators = _selected_operators()
|
||||
generator = torch.Generator(device="cuda").manual_seed(20260829)
|
||||
tensors = [
|
||||
torch.randn((2, 3, 7, 7, 256), device="cuda", dtype=torch.bfloat16, generator=generator) for _ in range(4)
|
||||
]
|
||||
expected = tensors[0] + tensors[1]
|
||||
expected = expected + tensors[2]
|
||||
expected = expected + tensors[3]
|
||||
|
||||
with torch.inference_mode():
|
||||
actual = operators.residual_add(*tensors)
|
||||
|
||||
assert actual is not None
|
||||
assert torch.equal(actual, expected)
|
||||
|
||||
|
||||
def test_diffvae_residual_adaln_permanently_falls_back_after_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
isolated_runtime_state,
|
||||
) -> None:
|
||||
operators = _selected_operators()
|
||||
shape = (1, 3, 7, 7, 256)
|
||||
x = torch.randn(shape, device="cuda", dtype=torch.bfloat16)
|
||||
residual = torch.randn_like(x)
|
||||
weight = torch.randn(256, device="cuda", dtype=torch.bfloat16)
|
||||
scale = torch.randn(1, 1, 1, 1, 256, device="cuda", dtype=torch.bfloat16)
|
||||
shift = torch.randn_like(scale)
|
||||
calls = 0
|
||||
|
||||
def fail_launch(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(residual_adaln_ops, "_launch_adaln", fail_launch)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
assert operators.residual_norm(x, residual, None, weight, scale, shift, 1e-6) is None
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def _tables(
|
||||
frames: int,
|
||||
height: int,
|
||||
width: int,
|
||||
dim_split: tuple[int, int, int] = _DIM_SPLIT,
|
||||
base: float = 10000.0,
|
||||
) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]:
|
||||
tables = []
|
||||
for length, dim in zip((frames, height, width), dim_split, strict=True):
|
||||
exponents = torch.arange(0, dim, 2, dtype=torch.float64, device="cuda") / dim
|
||||
inv_freqs = (1.0 / base**exponents).to(torch.float32)
|
||||
positions = torch.arange(length, dtype=torch.float32, device="cuda")
|
||||
angles = positions[:, None] * inv_freqs[None, :]
|
||||
tables.append((angles.cos(), angles.sin()))
|
||||
return tuple(tables)
|
||||
|
||||
|
||||
def _apply_rope_reference(
|
||||
hidden_states: torch.Tensor,
|
||||
tables: tuple[tuple[torch.Tensor, torch.Tensor], ...],
|
||||
dim_split: tuple[int, int, int] = _DIM_SPLIT,
|
||||
) -> torch.Tensor:
|
||||
outputs = []
|
||||
offset = 0
|
||||
for axis, (dim, (cos, sin)) in enumerate(zip(dim_split, tables, strict=True), 1):
|
||||
chunk = hidden_states[..., offset : offset + dim]
|
||||
pairs = chunk.reshape(*chunk.shape[:-1], dim // 2, 2)
|
||||
even = pairs[..., 0].float()
|
||||
odd = pairs[..., 1].float()
|
||||
shape = [1, 1, 1, 1, 1, dim // 2]
|
||||
shape[axis] = cos.shape[0]
|
||||
cos = cos.reshape(shape)
|
||||
sin = sin.reshape(shape)
|
||||
rotated = torch.stack([even * cos - odd * sin, even * sin + odd * cos], dim=-1)
|
||||
outputs.append(rotated.reshape(chunk.shape).to(hidden_states.dtype))
|
||||
offset += dim
|
||||
return torch.cat(outputs, dim=-1)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frames", "height", "width", "heads"),
|
||||
[(3, 7, 7, 1), (31, 136, 192, 4)],
|
||||
)
|
||||
def test_diffvae_qk_rms_norm_scale_rope_3d_is_bit_exact(
|
||||
frames: int,
|
||||
height: int,
|
||||
width: int,
|
||||
heads: int,
|
||||
isolated_runtime_state,
|
||||
) -> None:
|
||||
operators = _selected_operators()
|
||||
generator = torch.Generator(device="cuda").manual_seed(20260829)
|
||||
shape = (1, frames, height, width, heads, 64)
|
||||
query = torch.randn(shape, device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
key = torch.randn_like(query)
|
||||
query_weight = torch.randn((64,), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
key_weight = torch.randn((64,), device="cuda", dtype=torch.bfloat16, generator=generator)
|
||||
tables = _tables(frames, height, width)
|
||||
expected = (
|
||||
_apply_rope_reference(torch.nn.functional.rms_norm(query, (64,), query_weight, 1e-6) * 0.125, tables),
|
||||
_apply_rope_reference(torch.nn.functional.rms_norm(key, (64,), key_weight, 1e-6), tables),
|
||||
)
|
||||
|
||||
with torch.inference_mode():
|
||||
actual = operators.qk_norm_rope(
|
||||
query,
|
||||
key,
|
||||
query_weight,
|
||||
key_weight,
|
||||
1e-6,
|
||||
0.125,
|
||||
_DIM_SPLIT,
|
||||
10000.0,
|
||||
)
|
||||
|
||||
assert actual is not None
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
def test_diffvae_qk_rms_norm_scale_rope_3d_permanently_falls_back_after_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
isolated_runtime_state,
|
||||
) -> None:
|
||||
operators = _selected_operators()
|
||||
query = torch.randn(1, 3, 7, 7, 1, 64, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn_like(query)
|
||||
weight = torch.ones(64, device="cuda", dtype=torch.bfloat16)
|
||||
calls = 0
|
||||
|
||||
def fail_launch(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(qk_rms_norm_ops, "_launch_combined", fail_launch)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
assert operators.qk_norm_rope(query, key, weight, weight, 1e-6, 0.125, _DIM_SPLIT, 10000.0) is None
|
||||
|
||||
assert calls == 1
|
||||
@@ -103,7 +103,7 @@ def test_prepare_audio_latents_samples_directly_in_packed_token_space():
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_prepare_audio_latents_pads_generated_dummy_length_for_sp():
|
||||
def test_prepare_audio_latents_pads_short_audio_for_sp():
|
||||
pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=2)
|
||||
|
||||
latents, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.platforms.interface import DeviceCapability
|
||||
|
||||
from vllm_omni.diffusion.models.ltx2 import ops as ltx2_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _TensorProbe:
|
||||
is_cuda: bool
|
||||
device: torch.device
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_platform_cache():
|
||||
ltx2_platform._cuda_compute_capability.cache_clear()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
ltx2_platform._cuda_compute_capability.cache_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capability", "triton", "tilelang", "fusion_expected", "fna_expected"),
|
||||
[
|
||||
(80, True, True, False, False),
|
||||
(90, True, True, True, True),
|
||||
(90, True, False, True, False),
|
||||
(90, False, True, False, False),
|
||||
(100, True, True, True, False),
|
||||
(100, True, False, True, False),
|
||||
(103, True, True, True, False),
|
||||
(103, True, False, True, False),
|
||||
(110, True, True, False, False),
|
||||
(120, True, True, False, False),
|
||||
(121, True, True, False, False),
|
||||
],
|
||||
)
|
||||
def test_production_resolver_and_tensor_eligibility_agree(
|
||||
monkeypatch, capability, triton, tilelang, fusion_expected, fna_expected
|
||||
):
|
||||
monkeypatch.setattr(ltx2_platform, "HAS_TRITON", triton)
|
||||
monkeypatch.setattr(ltx2_platform, "has_tilelang", lambda: tilelang)
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: True)
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
ltx2_platform.current_omni_platform,
|
||||
"get_device_capability",
|
||||
lambda device_id=0: DeviceCapability(major=capability // 10, minor=capability % 10),
|
||||
)
|
||||
# Resolution must be lazy even when FNA is selected.
|
||||
monkeypatch.setitem(sys.modules, "vllm_omni.diffusion.models.ltx2.ops.fna", None)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False)
|
||||
device = torch.device("cuda:0")
|
||||
operators = ltx2_platform.resolve_ltx2_vae_operators(device)
|
||||
assert (operators is not None) is fusion_expected
|
||||
assert (operators is not None and operators.fna is not None) is fna_expected
|
||||
if operators is not None:
|
||||
from vllm_omni.diffusion.models.ltx2.ops import qk_rms_norm, residual_adaln, swiglu
|
||||
|
||||
assert operators.qk_norm_rope is qk_rms_norm.try_qk_rms_norm_scale_rope_3d_exact
|
||||
assert operators.residual_norm is residual_adaln.try_residual_rms_norm_modulate_exact
|
||||
assert operators.residual_add is residual_adaln.try_residual_add3_exact
|
||||
assert operators.swiglu is swiglu.try_swiglu_tiled_exact
|
||||
|
||||
tensor = _TensorProbe(is_cuda=True, device=device)
|
||||
with torch.inference_mode():
|
||||
assert ltx2_platform.is_ltx2_fusion_eligible(tensor) is fusion_expected
|
||||
assert ltx2_platform.is_ltx2_fna_eligible(tensor) is fna_expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device", "is_cuda", "available"),
|
||||
[("cpu", True, True), ("cuda:0", False, True), ("cuda:0", True, False)],
|
||||
)
|
||||
def test_resolver_rejects_unsupported_platform_before_capability_lookup(monkeypatch, device, is_cuda, available):
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: is_cuda)
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: available)
|
||||
|
||||
def unexpected_lookup(_index):
|
||||
raise AssertionError("Unsupported platforms must not query CUDA capability")
|
||||
|
||||
monkeypatch.setattr(ltx2_platform, "_cuda_compute_capability", unexpected_lookup)
|
||||
assert ltx2_platform.resolve_ltx2_vae_operators(torch.device(device)) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("compiling", "grad_enabled"), [(True, False), (False, True)])
|
||||
def test_runtime_guards_bypass_operator_resolution(monkeypatch, compiling, grad_enabled):
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: compiling)
|
||||
monkeypatch.setattr(torch, "is_grad_enabled", lambda: grad_enabled)
|
||||
|
||||
def unexpected_resolve(_device):
|
||||
raise AssertionError("Ineligible runtime must not select eager operators")
|
||||
|
||||
monkeypatch.setattr(ltx2_platform, "resolve_ltx2_vae_operators", unexpected_resolve)
|
||||
tensor = _TensorProbe(is_cuda=True, device=torch.device("cuda:0"))
|
||||
assert not ltx2_platform.is_ltx2_fusion_eligible(tensor)
|
||||
assert not ltx2_platform.is_ltx2_fna_eligible(tensor)
|
||||
|
||||
|
||||
def test_device_capability_is_cached(monkeypatch):
|
||||
calls = 0
|
||||
monkeypatch.setattr(ltx2_platform, "HAS_TRITON", True)
|
||||
|
||||
def get_device_capability(device_id=0):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return DeviceCapability(major=9, minor=0)
|
||||
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: True)
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: True)
|
||||
monkeypatch.setattr(ltx2_platform.current_omni_platform, "get_device_capability", get_device_capability)
|
||||
assert ltx2_platform._cuda_compute_capability(0) == 90
|
||||
assert ltx2_platform._cuda_compute_capability(0) == 90
|
||||
assert calls == 1
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Unit tests for LTX guidance and forward-parallel behavior."""
|
||||
|
||||
@@ -85,14 +85,14 @@ class TestCFGParallelHelpers:
|
||||
|
||||
LTX_GUIDANCE_EXECUTOR.validate_guidance_world_size(plan, guidance_world_size)
|
||||
|
||||
def test_guidance_parallel_dummy_warms_full_guidance_plan(self, monkeypatch):
|
||||
def test_guidance_parallel_preserves_full_guidance_plan(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_recipes import LTX23_ONE_STAGE_RECIPE
|
||||
from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline
|
||||
|
||||
monkeypatch.setattr(ltx2_runtime, "get_guidance_parallel_world_size", lambda: 2)
|
||||
pipe = object.__new__(LTX2Pipeline)
|
||||
req = SimpleNamespace(is_dummy_run=lambda: True)
|
||||
req = SimpleNamespace()
|
||||
request_inputs = SimpleNamespace(guidance=LTX23_ONE_STAGE_RECIPE.request_guidance)
|
||||
|
||||
guidance_parallel_ready = pipe._setup_forward_runtime(req, request_inputs, attention_kwargs=None)
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import huggingface_hub
|
||||
import numpy as np
|
||||
import pytest
|
||||
import torch
|
||||
@@ -175,7 +176,19 @@ def test_ltx_device_uint8_output_rejects_invalid_contract(video, error):
|
||||
_prepare_ltx2_video_output(video, do_normalize=True)
|
||||
|
||||
|
||||
def test_ltx_profiler_covers_video_output_transport(tmp_path, monkeypatch):
|
||||
@pytest.mark.parametrize(
|
||||
"parallel_config",
|
||||
[
|
||||
None,
|
||||
SimpleNamespace(ulysses_degree=4),
|
||||
SimpleNamespace(tensor_parallel_size=2),
|
||||
SimpleNamespace(use_hsdp=True, hsdp_shard_size=2),
|
||||
SimpleNamespace(ulysses_degree=2, tensor_parallel_size=2),
|
||||
SimpleNamespace(ulysses_degree=2, use_hsdp=True, hsdp_shard_size=2),
|
||||
],
|
||||
ids=["single", "ulysses", "tp", "hsdp", "tp-ulysses", "hsdp-ulysses"],
|
||||
)
|
||||
def test_ltx_supported_parallel_configs_keep_output_profiling(tmp_path, monkeypatch, parallel_config):
|
||||
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
|
||||
|
||||
(tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2Vocoder"]}))
|
||||
@@ -193,7 +206,13 @@ def test_ltx_profiler_covers_video_output_transport(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components)
|
||||
monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", capture_profiler_setup)
|
||||
|
||||
LTX2Pipeline(od_config=SimpleNamespace(model=str(tmp_path), enable_diffusion_pipeline_profiler=False))
|
||||
LTX2Pipeline(
|
||||
od_config=SimpleNamespace(
|
||||
model=str(tmp_path),
|
||||
parallel_config=parallel_config,
|
||||
enable_diffusion_pipeline_profiler=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert "video_processor.postprocess_video" in profiler_setup["profiler_targets"]
|
||||
assert "_prepare_video_output_for_transport" in profiler_setup["profiler_targets"]
|
||||
@@ -284,11 +303,11 @@ def test_ltx_artifact_uses_source_revision_and_hub_fallback(
|
||||
filename = "ltx-sidecar.safetensors"
|
||||
calls = []
|
||||
|
||||
def fake_download(**kwargs):
|
||||
def fake_download(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return "/cache/ltx-sidecar.safetensors"
|
||||
|
||||
monkeypatch.setattr(ltx2_components, "hf_hub_download", fake_download)
|
||||
monkeypatch.setattr(huggingface_hub.HfApi, "hf_hub_download", fake_download)
|
||||
|
||||
assert (
|
||||
resolve_ltx_artifact(
|
||||
@@ -313,7 +332,9 @@ def test_ltx_artifact_prefers_model_root(tmp_path, monkeypatch):
|
||||
filename = "ltx-sidecar.safetensors"
|
||||
expected = tmp_path / filename
|
||||
expected.write_bytes(b"sidecar")
|
||||
monkeypatch.setattr(ltx2_components, "hf_hub_download", lambda **_kwargs: pytest.fail("unexpected Hub lookup"))
|
||||
monkeypatch.setattr(
|
||||
huggingface_hub.HfApi, "hf_hub_download", lambda *_args, **_kwargs: pytest.fail("unexpected Hub lookup")
|
||||
)
|
||||
|
||||
assert resolve_ltx_artifact(
|
||||
str(tmp_path),
|
||||
@@ -327,11 +348,11 @@ def test_ltx_artifact_prefers_model_root(tmp_path, monkeypatch):
|
||||
def test_ltx_artifact_local_model_missing_sidecar_falls_back_to_hub(tmp_path, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_download(**kwargs):
|
||||
def fake_download(self, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return "/cache/ltx-sidecar.safetensors"
|
||||
|
||||
monkeypatch.setattr(ltx2_components, "hf_hub_download", fake_download)
|
||||
monkeypatch.setattr(huggingface_hub.HfApi, "hf_hub_download", fake_download)
|
||||
|
||||
assert (
|
||||
resolve_ltx_artifact(
|
||||
@@ -383,14 +404,42 @@ def test_ltx_checkpoint_validation_rejects_mismatched_scheduler_metadata(expecte
|
||||
)
|
||||
|
||||
|
||||
def test_ltx_rejects_advanced_uaa_before_component_initialization():
|
||||
@pytest.mark.parametrize(
|
||||
"pipeline_cls",
|
||||
[
|
||||
LTX2Pipeline,
|
||||
LTX2DistilledOneStagePipeline,
|
||||
LTX2TwoStagePipeline,
|
||||
LTX2DistilledTwoStagePipeline,
|
||||
LTX2T2VDMD2Pipeline,
|
||||
LTX2I2VDMD2Pipeline,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("parallel_config", "error"),
|
||||
[
|
||||
(SimpleNamespace(ulysses_mode="advanced_uaa"), "does not support ulysses_mode='advanced_uaa'"),
|
||||
(SimpleNamespace(ring_degree=2), "pure Ulysses sequence parallelism only"),
|
||||
(SimpleNamespace(ulysses_degree=2, ring_degree=2), "pure Ulysses sequence parallelism only"),
|
||||
(SimpleNamespace(allgather_degree=2), "pure Ulysses sequence parallelism only"),
|
||||
],
|
||||
ids=["advanced-uaa", "ring", "hybrid", "allgather-kv"],
|
||||
)
|
||||
def test_ltx_rejects_unsupported_sp_before_component_initialization(pipeline_cls, parallel_config, error, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2 import ltx2_runtime
|
||||
|
||||
def unexpected_model_access(*_args, **_kwargs):
|
||||
pytest.fail("Unsupported SP must be rejected before accessing model files or initializing components")
|
||||
|
||||
monkeypatch.setattr(ltx2_runtime, "detect_ltx_model_version", unexpected_model_access)
|
||||
monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", unexpected_model_access)
|
||||
od_config = SimpleNamespace(
|
||||
model="unused",
|
||||
parallel_config=SimpleNamespace(ulysses_mode="advanced_uaa"),
|
||||
parallel_config=parallel_config,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="does not support ulysses_mode='advanced_uaa'"):
|
||||
LTX2Pipeline(od_config=od_config)
|
||||
with pytest.raises(ValueError, match=error):
|
||||
pipeline_cls(od_config=od_config)
|
||||
|
||||
|
||||
def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, monkeypatch):
|
||||
@@ -1409,39 +1458,19 @@ def test_ltx_distilled_forward_rejects_fixed_recipe_overrides(direct_kwargs, sam
|
||||
pipeline.forward(req, **direct_kwargs)
|
||||
|
||||
|
||||
def test_ltx_distilled_dummy_run_uses_fixed_recipe_values():
|
||||
from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
req = DiffusionRequestBatch(
|
||||
[
|
||||
OmniDiffusionRequest(
|
||||
prompt="dummy run",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=512,
|
||||
width=512,
|
||||
num_frames=LTX2DistilledPipeline.dummy_run_num_frames,
|
||||
num_inference_steps=1,
|
||||
guidance_scale=0.0,
|
||||
),
|
||||
request_id=DUMMY_DIFFUSION_REQUEST_ID,
|
||||
)
|
||||
]
|
||||
)
|
||||
pipeline = _make_ltx_request_pipe(LTX2DistilledPipeline)
|
||||
seen = {}
|
||||
|
||||
def fake_run_recipe(_req, request_inputs, **_kwargs):
|
||||
seen["request_inputs"] = request_inputs
|
||||
return "dummy-output"
|
||||
|
||||
object.__setattr__(pipeline, "_run_recipe", fake_run_recipe)
|
||||
|
||||
assert pipeline.forward(req) == "dummy-output"
|
||||
assert seen["request_inputs"].num_frames == 1
|
||||
assert seen["request_inputs"].num_inference_steps == 8
|
||||
assert seen["request_inputs"].guidance == LTXGuidanceSpec.positive_only()
|
||||
@pytest.mark.parametrize(
|
||||
"pipeline_cls",
|
||||
[
|
||||
LTX2Pipeline,
|
||||
LTX2DistilledOneStagePipeline,
|
||||
LTX2TwoStagePipeline,
|
||||
LTX2DistilledTwoStagePipeline,
|
||||
LTX2T2VDMD2Pipeline,
|
||||
LTX2I2VDMD2Pipeline,
|
||||
],
|
||||
)
|
||||
def test_ltx_pipelines_disable_startup_dummy_run(pipeline_cls):
|
||||
assert pipeline_cls.dummy_run_num_frames == 0
|
||||
|
||||
|
||||
def test_ltx_custom_sigmas_bypass_scheduler_shifting():
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_sequence_parallel import (
|
||||
LTX2VideoToAudioParallelAttention,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _run_video_to_audio_parity(rank: int, world_size: int, master_port: int) -> None:
|
||||
dist.init_process_group(
|
||||
"gloo",
|
||||
init_method=f"tcp://127.0.0.1:{master_port}",
|
||||
rank=rank,
|
||||
world_size=world_size,
|
||||
timeout=timedelta(seconds=60),
|
||||
)
|
||||
try:
|
||||
# Non-contiguous SP subgroups match TP-fastest rank ordering. Different
|
||||
# seeds across groups catch collectives that accidentally use WORLD.
|
||||
group_count = world_size // 2
|
||||
ulysses_group = dist.group.WORLD
|
||||
if group_count > 1:
|
||||
for offset in range(group_count):
|
||||
ranks = list(range(offset, world_size, group_count))
|
||||
group = dist.new_group(ranks)
|
||||
if rank in ranks:
|
||||
ulysses_group = group
|
||||
ulysses_rank = dist.get_rank(ulysses_group)
|
||||
sp_group = SimpleNamespace(
|
||||
ring_world_size=1,
|
||||
ulysses_group=ulysses_group,
|
||||
ulysses_world_size=2,
|
||||
ulysses_rank=ulysses_rank,
|
||||
)
|
||||
strategy = LTX2VideoToAudioParallelAttention(sp_group)
|
||||
|
||||
for batch_size, masked in [(1, False), (2, True)]:
|
||||
torch.manual_seed(17 + rank % group_count)
|
||||
query = torch.randn(batch_size, 3, 4, 8)
|
||||
global_key = torch.randn(batch_size, 6, 4, 8)
|
||||
global_value = torch.randn(batch_size, 6, 4, 8)
|
||||
key = global_key.chunk(2, dim=1)[ulysses_rank].contiguous()
|
||||
value = global_value.chunk(2, dim=1)[ulysses_rank].contiguous()
|
||||
mask = None
|
||||
if masked:
|
||||
mask = torch.ones(batch_size, 1, 1, 6, dtype=torch.bool)
|
||||
mask[0, ..., -1] = False
|
||||
mask[1, ..., -2:] = False
|
||||
metadata = AttentionMetadata(attn_mask=mask) if masked else None
|
||||
|
||||
local_query, local_key, local_value, local_metadata, ctx = strategy.pre_attention(
|
||||
query, key, value, metadata
|
||||
)
|
||||
assert local_metadata is metadata
|
||||
local_output = F.scaled_dot_product_attention(
|
||||
local_query.transpose(1, 2),
|
||||
local_key.transpose(1, 2),
|
||||
local_value.transpose(1, 2),
|
||||
attn_mask=local_metadata.attn_mask if local_metadata is not None else None,
|
||||
).transpose(1, 2)
|
||||
actual = strategy.post_attention(local_output, ctx)
|
||||
expected = F.scaled_dot_product_attention(
|
||||
query.transpose(1, 2),
|
||||
global_key.transpose(1, 2),
|
||||
global_value.transpose(1, 2),
|
||||
attn_mask=mask,
|
||||
).transpose(1, 2)
|
||||
|
||||
torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-6)
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("world_size", [2, 4], ids=["world", "subgroups"])
|
||||
def test_ltx_video_to_audio_sp_matches_replicated_attention(unused_tcp_port, world_size):
|
||||
torch.multiprocessing.spawn(
|
||||
_run_video_to_audio_parity,
|
||||
args=(world_size, unused_tcp_port),
|
||||
nprocs=world_size,
|
||||
)
|
||||
|
||||
|
||||
def test_ltx_video_to_audio_sp_rejects_ring_parallelism():
|
||||
sp_group = SimpleNamespace(ring_world_size=2)
|
||||
|
||||
with pytest.raises(NotImplementedError, match="ring_degree must be 1"):
|
||||
LTX2VideoToAudioParallelAttention(sp_group)
|
||||
@@ -141,16 +141,54 @@ def test_ltx_transformer_exposes_hsdp_shard_conditions_for_blocks():
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rope_type", ["interleaved", "split"])
|
||||
def test_ltx_sp_plan_shards_video_and_audio_timesteps_together(rope_type):
|
||||
root_plan = LTX2VideoTransformer3DModel._build_sp_plan(rope_type)[""]
|
||||
def test_ltx_sp_plan_only_shards_video_stream(rope_type):
|
||||
plan = LTX2VideoTransformer3DModel._build_sp_plan(rope_type)
|
||||
root_plan = plan[""]
|
||||
|
||||
video_timestep = root_plan["timestep"]
|
||||
audio_timestep = root_plan["audio_timestep"]
|
||||
assert set(root_plan) == {"hidden_states", "keyframes_mask", "timestep"}
|
||||
assert root_plan["hidden_states"].split_dim == 1
|
||||
assert root_plan["keyframes_mask"].split_dim == 1
|
||||
assert root_plan["timestep"].split_dim == 1
|
||||
assert root_plan["timestep"].expected_dims == 2
|
||||
assert not root_plan["timestep"].split_output
|
||||
assert set(plan) == {"", "rope", "cross_attn_rope", "proj_out"}
|
||||
|
||||
assert video_timestep.split_dim == audio_timestep.split_dim == 1
|
||||
assert video_timestep.expected_dims == audio_timestep.expected_dims == 2
|
||||
assert not video_timestep.split_output
|
||||
assert not audio_timestep.split_output
|
||||
|
||||
def test_ltx_attention_assigns_video_only_sp_modes(monkeypatch):
|
||||
modes = {}
|
||||
|
||||
class FakeAttention(nn.Module):
|
||||
def __init__(self, *, prefix, sequence_parallel_mode="local", **_kwargs):
|
||||
super().__init__()
|
||||
modes[prefix.rsplit(".", 1)[-1]] = sequence_parallel_mode
|
||||
|
||||
class FakeFeedForward(nn.Module):
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
super().__init__()
|
||||
|
||||
monkeypatch.setattr(ltx2_transformer, "LTX2Attention", FakeAttention)
|
||||
monkeypatch.setattr(ltx2_transformer, "LTX2FeedForward", FakeFeedForward)
|
||||
|
||||
LTX2VideoTransformerBlock(
|
||||
dim=8,
|
||||
num_attention_heads=2,
|
||||
attention_head_dim=4,
|
||||
cross_attention_dim=8,
|
||||
audio_dim=8,
|
||||
audio_num_attention_heads=2,
|
||||
audio_attention_head_dim=4,
|
||||
audio_cross_attention_dim=8,
|
||||
prefix="transformer_blocks.0",
|
||||
)
|
||||
|
||||
assert modes == {
|
||||
"attn1": "video_self",
|
||||
"audio_attn1": "local",
|
||||
"attn2": "local",
|
||||
"audio_attn2": "local",
|
||||
"audio_to_video_attn": "local",
|
||||
"video_to_audio_attn": "video_to_audio",
|
||||
}
|
||||
|
||||
|
||||
def test_ltx_sp_keeps_cross_attention_key_padding_mask(monkeypatch):
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Full-transformer numeric parity for LTX Ulysses sequence parallelism.
|
||||
|
||||
This FP32/SDPA check verifies that Ulysses preserves the complete transformer
|
||||
mathematical path. It does not guarantee pixel-level E2E parity: production
|
||||
BF16 and compiled attention kernels can change rounding order, and diffusion
|
||||
can amplify that drift across denoising steps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig
|
||||
from vllm_omni.diffusion.forward_context import get_forward_context, set_forward_context
|
||||
from vllm_omni.diffusion.hooks.sequence_parallel import apply_sequence_parallel
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.parallel]
|
||||
|
||||
_FP32_REL_L2 = 1e-5
|
||||
_TINY_CONFIG = {
|
||||
"in_channels": 4,
|
||||
"out_channels": 4,
|
||||
"num_attention_heads": 2,
|
||||
"attention_head_dim": 4,
|
||||
"cross_attention_dim": 8,
|
||||
"vae_scale_factors": (1, 1, 1),
|
||||
"pos_embed_max_pos": 4,
|
||||
"base_height": 2,
|
||||
"base_width": 2,
|
||||
"audio_in_channels": 4,
|
||||
"audio_out_channels": 4,
|
||||
"audio_num_attention_heads": 2,
|
||||
"audio_attention_head_dim": 4,
|
||||
"audio_cross_attention_dim": 8,
|
||||
"audio_scale_factor": 1,
|
||||
"audio_pos_embed_max_pos": 4,
|
||||
"audio_sampling_rate": 16,
|
||||
"audio_hop_length": 4,
|
||||
"num_layers": 1,
|
||||
"caption_channels": 8,
|
||||
"rope_type": "split",
|
||||
"use_prompt_embeddings": False,
|
||||
"use_keyframes_abs_pos_embedding": True,
|
||||
}
|
||||
|
||||
|
||||
def _relative_l2(actual: torch.Tensor, expected: torch.Tensor) -> float:
|
||||
return ((actual - expected).double().norm() / expected.double().norm()).item()
|
||||
|
||||
|
||||
def _omni_config(sp_size: int) -> OmniDiffusionConfig:
|
||||
return OmniDiffusionConfig.from_kwargs(
|
||||
model="test",
|
||||
dtype=torch.float32,
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
pipeline_parallel_size=1,
|
||||
data_parallel_size=1,
|
||||
tensor_parallel_size=1,
|
||||
sequence_parallel_size=sp_size,
|
||||
ulysses_degree=sp_size,
|
||||
ring_degree=1,
|
||||
cfg_parallel_size=1,
|
||||
),
|
||||
diffusion_attention_backend="TORCH_SDPA",
|
||||
)
|
||||
|
||||
|
||||
def _inputs(device: torch.device) -> dict[str, torch.Tensor | int | bool]:
|
||||
generator = torch.Generator().manual_seed(123)
|
||||
return {
|
||||
"hidden_states": torch.randn(1, 8, 4, generator=generator, device="cpu").to(device),
|
||||
"audio_hidden_states": torch.randn(1, 4, 4, generator=generator, device="cpu").to(device),
|
||||
"encoder_hidden_states": torch.randn(1, 3, 8, generator=generator, device="cpu").to(device),
|
||||
"audio_encoder_hidden_states": torch.randn(1, 3, 8, generator=generator, device="cpu").to(device),
|
||||
"timestep": torch.linspace(0.1, 0.8, 8, device=device).view(1, 8),
|
||||
"audio_timestep": torch.linspace(0.2, 0.5, 4, device=device).view(1, 4),
|
||||
"keyframes_mask": torch.tensor([[[1.0], [0.0], [1.0], [0.0], [0.0], [1.0], [0.0], [0.0]]], device=device),
|
||||
"sigma": torch.tensor([0.1], device=device),
|
||||
"audio_sigma": torch.tensor([0.2], device=device),
|
||||
"num_frames": 2,
|
||||
"height": 2,
|
||||
"width": 2,
|
||||
"audio_num_frames": 4,
|
||||
"return_dict": False,
|
||||
}
|
||||
|
||||
|
||||
def _run_transformer_parity(rank: int, world_size: int, master_port: int) -> None:
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
os.environ.update(
|
||||
{
|
||||
"RANK": str(rank),
|
||||
"LOCAL_RANK": str(rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": str(master_port),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
init_distributed_environment()
|
||||
initialize_model_parallel(sequence_parallel_size=world_size, ulysses_degree=world_size)
|
||||
|
||||
sp_config = _omni_config(world_size)
|
||||
torch.manual_seed(42)
|
||||
with set_forward_context(omni_diffusion_config=sp_config), set_current_diffusion_config(sp_config):
|
||||
model = LTX2VideoTransformer3DModel(**_TINY_CONFIG).to(device).eval()
|
||||
with torch.no_grad():
|
||||
for _, parameter in sorted(model.named_parameters()):
|
||||
torch.nn.init.normal_(parameter, mean=0.0, std=0.02)
|
||||
|
||||
inputs = _inputs(device)
|
||||
reference_config = _omni_config(1)
|
||||
with torch.no_grad(), set_forward_context(omni_diffusion_config=reference_config):
|
||||
expected_video, expected_audio = model(**inputs)
|
||||
|
||||
apply_sequence_parallel(
|
||||
model,
|
||||
SequenceParallelConfig(ulysses_degree=world_size, ring_degree=1),
|
||||
model._sp_plan,
|
||||
)
|
||||
with torch.no_grad(), set_forward_context(omni_diffusion_config=sp_config):
|
||||
get_forward_context().sp_plan_hooks_applied = True
|
||||
actual_video, actual_audio = model(**inputs)
|
||||
|
||||
v2a_attention = model.transformer_blocks[0].video_to_audio_attn.attn
|
||||
assert v2a_attention._video_to_audio_strategy is not None
|
||||
assert torch.isfinite(expected_video).all()
|
||||
assert torch.isfinite(expected_audio).all()
|
||||
for modality, actual, expected in (
|
||||
("video", actual_video, expected_video),
|
||||
("audio", actual_audio, expected_audio),
|
||||
):
|
||||
drift = _relative_l2(actual, expected)
|
||||
assert drift <= _FP32_REL_L2, (
|
||||
f"LTX SP{world_size} {modality} output drifted rel_l2={drift:.3e} from SP1 (bound {_FP32_REL_L2:.1e})"
|
||||
)
|
||||
finally:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
@pytest.mark.full_model
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2)
|
||||
def test_ltx_ulysses_transformer_matches_sp1(unused_tcp_port) -> None:
|
||||
"""Exercise real plan hooks, RoPE/timestep sharding, and attention routing."""
|
||||
world_size = 2
|
||||
device_count = current_omni_platform.device_count()
|
||||
assert device_count >= world_size, (
|
||||
f"LTX Ulysses parity requires {world_size} accelerator devices; only {device_count} are visible"
|
||||
)
|
||||
torch.multiprocessing.spawn(
|
||||
_run_transformer_parity,
|
||||
args=(world_size, unused_tcp_port),
|
||||
nprocs=world_size,
|
||||
)
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
"""Unit tests for LTX video VAE tiling and distributed decode behavior."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -12,6 +14,45 @@ import torch
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_deterministic", [False, True])
|
||||
@pytest.mark.parametrize("fail", [False, True])
|
||||
def test_ltx_vocoder_deterministic_context(monkeypatch, initial_deterministic, fail):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _deterministic_ltx_vocoder
|
||||
|
||||
monkeypatch.setattr(torch.backends.cudnn, "deterministic", initial_deterministic)
|
||||
if torch.backends.cudnn.is_available():
|
||||
monkeypatch.setattr(torch.backends.cudnn, "benchmark_limit", 17)
|
||||
settings = {
|
||||
name: getattr(torch.backends.cudnn, name) for name in ("enabled", "benchmark", "benchmark_limit", "allow_tf32")
|
||||
}
|
||||
try:
|
||||
with _deterministic_ltx_vocoder():
|
||||
assert torch.backends.cudnn.deterministic
|
||||
assert {name: getattr(torch.backends.cudnn, name) for name in settings} == settings
|
||||
if fail:
|
||||
raise RuntimeError("injected failure")
|
||||
except RuntimeError as exc:
|
||||
assert fail and str(exc) == "injected failure"
|
||||
assert torch.backends.cudnn.deterministic == initial_deterministic
|
||||
assert {name: getattr(torch.backends.cudnn, name) for name in settings} == settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _OperatorSetProbe:
|
||||
fna: Callable[..., torch.Tensor] | None
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AttentionProbe:
|
||||
kernel_size: tuple[int, int, int]
|
||||
head_dim: int
|
||||
to_out: tuple[torch.nn.Module, ...]
|
||||
projected: torch.Tensor
|
||||
|
||||
def project_qkv(self, _hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
return self.projected, self.projected, self.projected
|
||||
|
||||
|
||||
def test_ltx_base_vocoder_keeps_native_dtype(monkeypatch):
|
||||
import vllm_omni.diffusion.models.ltx2.ltx2_runtime as ltx_runtime
|
||||
|
||||
@@ -38,9 +79,164 @@ def test_ltx_base_vocoder_keeps_native_dtype(monkeypatch):
|
||||
|
||||
|
||||
class TestLTXDiffusionDecoder:
|
||||
def test_diffusion_decoder_reuses_diffusers_with_scoped_overrides(self, monkeypatch):
|
||||
from diffusers.models.autoencoders.ltx2_diffusion_decoder import (
|
||||
LTX2VideoDiffusionDecoderModel as DiffusersModel,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.models.ltx2.vae.decoder import (
|
||||
LTX2VideoDiffusionDecoderModel,
|
||||
LTX2VideoVaeDiffusionNABlock,
|
||||
LTX2VideoVaeNeighborhoodAttention,
|
||||
LTX2VideoVaeSwiGLU,
|
||||
)
|
||||
|
||||
original_init = DiffusersModel.__init__
|
||||
original_parameters = {}
|
||||
|
||||
def capture_parameters(self, *args, **kwargs):
|
||||
original_init(self, *args, **kwargs)
|
||||
original_parameters.update(self.named_parameters())
|
||||
|
||||
with torch.device("meta"):
|
||||
reference = DiffusersModel()
|
||||
monkeypatch.setattr(DiffusersModel, "__init__", capture_parameters)
|
||||
model = LTX2VideoDiffusionDecoderModel()
|
||||
assert isinstance(model, DiffusersModel)
|
||||
assert all(parameter is original_parameters[name] for name, parameter in model.named_parameters())
|
||||
assert {name: tensor.shape for name, tensor in model.state_dict().items()} == {
|
||||
name: tensor.shape for name, tensor in reference.state_dict().items()
|
||||
}
|
||||
|
||||
modules = tuple(model.decoder.modules())
|
||||
expected_blocks = sum(model.config.decoder_stage_depths)
|
||||
assert sum(isinstance(module, LTX2VideoVaeNeighborhoodAttention) for module in modules) == expected_blocks
|
||||
assert sum(isinstance(module, LTX2VideoVaeSwiGLU) for module in modules) == expected_blocks
|
||||
assert (
|
||||
sum(isinstance(module, LTX2VideoVaeDiffusionNABlock) for module in modules)
|
||||
== model.config.decoder_stage_depths[-1]
|
||||
)
|
||||
|
||||
assert type(model.decoder).__module__.startswith("vllm_omni.")
|
||||
assert model.tiled_decode.__func__ is DiffusersModel.tiled_decode
|
||||
assert type(reference.decoder) is type(model.decoder).__bases__[0]
|
||||
|
||||
def test_natten_processor_routes_stage5_through_tilelang_fna(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2.vae import decoder as diffvae_modules
|
||||
|
||||
processor = object.__new__(diffvae_modules.LTX2VideoVaeNeighborhoodNattenProcessor)
|
||||
processor.backend = None
|
||||
fallback_calls = []
|
||||
processor._na3d = lambda *args, **kwargs: fallback_calls.append((args, kwargs))
|
||||
|
||||
hidden_states = torch.randn(1, 11, 11, 11, 256, dtype=torch.bfloat16)
|
||||
projected = hidden_states.view(1, 11, 11, 11, 4, 64)
|
||||
|
||||
class FakeAttention:
|
||||
kernel_size = (11, 11, 11)
|
||||
head_dim = 64
|
||||
to_out = (torch.nn.Identity(),)
|
||||
|
||||
@staticmethod
|
||||
def project_qkv(_hidden_states):
|
||||
return projected, projected, projected
|
||||
|
||||
def token_permute(value, _tile, *, flip_tiled_dims):
|
||||
assert flip_tiled_dims
|
||||
return value, value.shape, None
|
||||
|
||||
def token_unpermute(value, *, token_layout_shape, tile_shape, flip_tiled_dims):
|
||||
assert token_layout_shape == projected.shape
|
||||
assert tile_shape == (4, 4, 4)
|
||||
assert flip_tiled_dims
|
||||
return value
|
||||
|
||||
fast_path_calls = []
|
||||
|
||||
def fast_path(query, key, value, *, shape, window):
|
||||
fast_path_calls.append((query, key, value, shape, window))
|
||||
return query
|
||||
|
||||
monkeypatch.setattr(diffvae_modules, "is_ltx2_fna_eligible", lambda _tensor: True)
|
||||
monkeypatch.setattr(
|
||||
diffvae_modules, "resolve_ltx2_vae_operators", lambda _device: _OperatorSetProbe(fna=fast_path)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
diffvae_modules,
|
||||
"_load_natten_token_permutation",
|
||||
lambda: (token_permute, token_unpermute),
|
||||
)
|
||||
|
||||
output = processor(FakeAttention(), hidden_states)
|
||||
|
||||
assert torch.equal(output, hidden_states)
|
||||
assert len(fast_path_calls) == 1
|
||||
assert fast_path_calls[0][3:] == ((11, 11, 11), (11, 11, 11))
|
||||
assert not fallback_calls
|
||||
|
||||
@pytest.mark.parametrize("reason", ["device", "head_dim", "channels", "dtype", "tilelang_missing", "kernel"])
|
||||
def test_natten_processor_keeps_natten_when_fast_path_does_not_apply(self, monkeypatch, reason):
|
||||
from vllm_omni.diffusion.models.ltx2.vae import decoder as ops
|
||||
|
||||
processor = object.__new__(ops.LTX2VideoVaeNeighborhoodNattenProcessor)
|
||||
processor.backend = None
|
||||
channels = 128 if reason == "channels" else 256
|
||||
head_dim = 32 if reason == "head_dim" else 64
|
||||
x = torch.zeros(1, 11, 11, 11, channels, dtype=torch.float32 if reason == "dtype" else torch.bfloat16)
|
||||
projected = x.view(1, 11, 11, 11, channels // head_dim, head_dim)
|
||||
attn = _AttentionProbe(
|
||||
kernel_size=(3, 5, 5) if reason == "kernel" else (11, 11, 11),
|
||||
head_dim=head_dim,
|
||||
to_out=(torch.nn.Identity(),),
|
||||
projected=projected,
|
||||
)
|
||||
calls = []
|
||||
|
||||
def natten(q, k, v, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return q
|
||||
|
||||
def unexpected_native_load(*_args, **_kwargs):
|
||||
raise AssertionError("Native FNA must not be loaded for an unsupported configuration")
|
||||
|
||||
processor._na3d = natten
|
||||
monkeypatch.setattr(ops, "is_ltx2_fna_eligible", lambda _tensor: reason != "device")
|
||||
monkeypatch.setattr(
|
||||
ops,
|
||||
"resolve_ltx2_vae_operators",
|
||||
lambda _device: _OperatorSetProbe(fna=None if reason == "tilelang_missing" else unexpected_native_load),
|
||||
)
|
||||
assert torch.equal(processor(attn, x), x)
|
||||
assert calls == [{"kernel_size": attn.kernel_size, "scale": 1.0, "backend": None}]
|
||||
|
||||
def test_diffusion_decoder_noneligible_block_uses_diffusers_forward(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2.vae import decoder as diffvae_modules
|
||||
|
||||
block = object.__new__(diffvae_modules.LTX2VideoVaeDiffusionNABlock)
|
||||
torch.nn.Module.__init__(block)
|
||||
expected = torch.randn(1, 1, 1, 1, 1)
|
||||
calls = []
|
||||
|
||||
def upstream_forward(_self, hidden_states, latent_context, modulation, block_mask):
|
||||
assert block_mask is None
|
||||
calls.append((hidden_states, latent_context, modulation))
|
||||
return expected
|
||||
|
||||
monkeypatch.setattr(
|
||||
diffvae_modules.diffusers_decoder.LTX2VideoVaeDiffusionNABlock,
|
||||
"forward",
|
||||
upstream_forward,
|
||||
)
|
||||
hidden_states = torch.randn(1, 1, 1, 1, 1)
|
||||
latent_context = torch.randn_like(hidden_states)
|
||||
modulation = (torch.randn_like(hidden_states),)
|
||||
|
||||
assert block.forward(hidden_states, latent_context, modulation) is expected
|
||||
assert calls == [(hidden_states, latent_context, modulation)]
|
||||
|
||||
@pytest.mark.parametrize("mode", ["spatial_shard_height", "spatial_shard_width"])
|
||||
def test_distributed_diffusion_decoder_rejects_non_tile_parallel_modes(self, mode):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.distributed import (
|
||||
DistributedLTX2VideoDiffusionDecoderModel,
|
||||
)
|
||||
|
||||
@@ -52,7 +248,7 @@ class TestLTXDiffusionDecoder:
|
||||
model.set_parallel_size(2, mode=mode)
|
||||
|
||||
def test_distributed_diffusion_decoder_accepts_tile_parallel_mode(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.distributed import (
|
||||
DistributedLTX2VideoDiffusionDecoderModel,
|
||||
)
|
||||
|
||||
@@ -68,7 +264,7 @@ class TestLTXDiffusionDecoder:
|
||||
assert calls == [(4, "tile")]
|
||||
|
||||
def test_short_clip_keeps_stage5_temporal_context_then_crops_output(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.decoder import (
|
||||
LTX2VideoDiffusionDecoder3d,
|
||||
LTX2VideoDiffusionDecoderModel,
|
||||
)
|
||||
@@ -143,7 +339,7 @@ class TestLTXDiffusionDecoder:
|
||||
_ltx2_use_diffusion_decoder(SimpleNamespace(extras={"ltx2_use_conv_vae": "true"}), "2.5")
|
||||
|
||||
def test_native_diffusion_decoder_conversion_splits_qkv_and_folds_gates(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.decoder import (
|
||||
convert_ltx25_native_diffusion_decoder_state_dict,
|
||||
)
|
||||
|
||||
@@ -190,7 +386,7 @@ class TestLTXDiffusionDecoder:
|
||||
assert not any("type_emb" in key or "coarse" in key or "gate_msa" in key for key in converted)
|
||||
|
||||
def test_native_diffusion_decoder_conversion_rejects_invalid_fused_qkv(self):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.decoder import (
|
||||
convert_ltx25_native_diffusion_decoder_state_dict,
|
||||
)
|
||||
|
||||
@@ -388,8 +584,8 @@ class TestLTXDiffusionDecoder:
|
||||
assert output.output[1].numel() == 0
|
||||
|
||||
def test_diffusion_decoder_patch_parallel_size_one_uses_native_tiling(self, monkeypatch):
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import LTX2VideoDiffusionDecoderModel
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.decoder import LTX2VideoDiffusionDecoderModel
|
||||
from vllm_omni.diffusion.models.ltx2.vae.distributed import (
|
||||
DistributedLTX2VideoDiffusionDecoderModel,
|
||||
)
|
||||
|
||||
@@ -424,7 +620,7 @@ class TestLTXDiffusionDecoder:
|
||||
def test_distributed_diffusion_tiles_preserve_serial_noise_order(self, monkeypatch):
|
||||
from diffusers.utils.torch_utils import randn_tensor
|
||||
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import (
|
||||
from vllm_omni.diffusion.models.ltx2.vae.distributed import (
|
||||
DistributedLTX2VideoDiffusionDecoderModel,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""CUDA regression tests for LTX-2 BWE vocoder precision."""
|
||||
"""GPU regression tests for LTX-2 BWE vocoder precision."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -14,10 +14,13 @@ pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
class _BWEConvVocoder(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Match the production LTX vocoder's conv_pre geometry. The previous
|
||||
# single-channel 1x1 probe intermittently had no MIOpen algorithm.
|
||||
self.conv = torch.nn.Conv1d(
|
||||
1,
|
||||
1,
|
||||
kernel_size=1,
|
||||
128,
|
||||
1024,
|
||||
kernel_size=7,
|
||||
padding=3,
|
||||
bias=False,
|
||||
device="cuda",
|
||||
dtype=torch.bfloat16,
|
||||
@@ -33,12 +36,12 @@ class _BWEConvVocoder(torch.nn.Module):
|
||||
return output
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_ltx_bwe_vocoder_runs_real_cuda_autocast_in_fp32():
|
||||
from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _run_ltx_vocoder
|
||||
|
||||
vocoder = _BWEConvVocoder()
|
||||
generated_mel = torch.ones((1, 1, 4), device="cuda", dtype=torch.bfloat16)
|
||||
generated_mel = torch.ones((1, 128, 64), device="cuda", dtype=torch.bfloat16)
|
||||
|
||||
output = _run_ltx_vocoder(vocoder, generated_mel)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
import json
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from multiprocessing.reduction import ForkingPickler
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, TypeVar
|
||||
@@ -13,6 +14,8 @@ import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.lora import TurboSpec, parse_turbo_filename
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
_ItemT = TypeVar("_ItemT")
|
||||
@@ -24,6 +27,13 @@ def _append_and_return(items: list[_ItemT], item: _ItemT, result: _ResultT) -> _
|
||||
return result
|
||||
|
||||
|
||||
def _turbo_spec(filename: str) -> TurboSpec:
|
||||
"""Build the spec a loaded artifact of this name would carry."""
|
||||
spec = parse_turbo_filename(filename)
|
||||
assert spec is not None
|
||||
return spec
|
||||
|
||||
|
||||
def test_h3_prepares_resolved_cache_state_immediately_before_denoise():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
@@ -167,9 +177,9 @@ def _write_partition_index(path, *, partition, tasks):
|
||||
|
||||
|
||||
def test_modular_diffusers_index_is_resolved_generically(tmp_path):
|
||||
from vllm_omni.config.resolver import resolve_omni_config
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name
|
||||
from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model
|
||||
from vllm_omni.entrypoints.utils import resolve_model_config_path
|
||||
|
||||
(tmp_path / "modular_model_index.json").write_text(
|
||||
json.dumps(
|
||||
@@ -183,7 +193,16 @@ def test_modular_diffusers_index_is_resolved_generically(tmp_path):
|
||||
|
||||
assert is_diffusion_model(str(tmp_path))
|
||||
assert resolve_model_class_name(str(tmp_path)) == "MiniMaxH3ModularPipeline"
|
||||
assert resolve_model_config_path(str(tmp_path)) is None
|
||||
resolved = resolve_omni_config(
|
||||
str(tmp_path),
|
||||
trust_remote_code=False,
|
||||
deploy_config_path=None,
|
||||
cli_overrides=None,
|
||||
stage_overrides=None,
|
||||
strategy_config_path=None,
|
||||
)
|
||||
assert resolved.config_path is None
|
||||
assert resolved.stage_by_id(0).engine_args.model_class_name == "MiniMaxH3ModularPipeline"
|
||||
|
||||
config = OmniDiffusionConfig(model=str(tmp_path))
|
||||
config.enrich_config()
|
||||
@@ -244,9 +263,12 @@ def test_combined_task_inference_and_transformer_routing():
|
||||
assert pipeline._resolve_task(None, {"image": object()}) == "fl2va"
|
||||
assert pipeline._resolve_task(None, {"audio": object()}) == "ref2va"
|
||||
assert pipeline._resolve_task(None, {"video": object()}) == "ref2va"
|
||||
assert pipeline._resolve_task("fl2va", {"image": object()}, has_turbo_lora=True) == "fl2va"
|
||||
with pytest.raises(OmniClientError, match="supports T2VA/FL2VA requests only"):
|
||||
pipeline._resolve_task("ref2va", {}, has_turbo_lora=True)
|
||||
fl2v_spec = _turbo_spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")
|
||||
ref2v_spec = _turbo_spec("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors")
|
||||
assert pipeline._resolve_task("fl2va", {"image": object()}, turbo_spec=fl2v_spec) == "fl2va"
|
||||
with pytest.raises(OmniClientError, match="serves"):
|
||||
pipeline._resolve_task("ref2va", {}, turbo_spec=fl2v_spec)
|
||||
assert pipeline._resolve_task("ref2va", {"image": object()}, turbo_spec=ref2v_spec) == "ref2va"
|
||||
pipeline.partition = "ref2va"
|
||||
pipeline.supported_tasks = frozenset({"ref2va"})
|
||||
assert pipeline._resolve_task(None, {"image": object()}) == "ref2va"
|
||||
@@ -265,14 +287,16 @@ def test_combined_task_inference_and_transformer_routing():
|
||||
"component_partition",
|
||||
"source_partitions",
|
||||
"expected_tasks",
|
||||
"load_text_encoder",
|
||||
),
|
||||
[
|
||||
(None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}),
|
||||
("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}),
|
||||
("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}),
|
||||
(None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}, True),
|
||||
("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}, True),
|
||||
("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}, True),
|
||||
("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}, False),
|
||||
],
|
||||
)
|
||||
def test_pipeline_loads_task_selected_dits_and_shared_components_once(
|
||||
def test_pipeline_loads_task_selected_components_with_encoder_ownership(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
task_type,
|
||||
@@ -281,6 +305,7 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once(
|
||||
component_partition,
|
||||
source_partitions,
|
||||
expected_tasks,
|
||||
load_text_encoder,
|
||||
):
|
||||
from vllm_omni.diffusion.data import (
|
||||
DiffusionParallelConfig,
|
||||
@@ -392,6 +417,11 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once(
|
||||
revision=None,
|
||||
task_type=task_type,
|
||||
quantization_config=None,
|
||||
model_loaded={
|
||||
"transformer": True,
|
||||
"vae": True,
|
||||
"text_encoder": load_text_encoder,
|
||||
},
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
cfg_parallel_size=1,
|
||||
text_encoder_tp_size=1,
|
||||
@@ -403,34 +433,54 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once(
|
||||
assert pipeline.supported_tasks == expected_tasks
|
||||
assert len(created["dit"]) == expected_dits
|
||||
component_path = tmp_path / component_partition
|
||||
assert created["text_encoder"] == [str(component_path / "text_encoder")]
|
||||
assert created["video_vae"] == [str(component_path / "video_vae")]
|
||||
assert created["audio_vae"] == [str(component_path / "audio_vae")]
|
||||
assert len(tokenizer_calls) == 1
|
||||
assert len(processor_calls) == 1
|
||||
expected_dit_modules = ["transformer", "transformers_ref"] if expected_dits == 2 else ["transformer"]
|
||||
assert pipeline._dit_modules == expected_dit_modules
|
||||
assert hasattr(pipeline, "transformers_ref") is (expected_dits == 2)
|
||||
if load_text_encoder:
|
||||
assert created["text_encoder"] == [str(component_path / "text_encoder")]
|
||||
assert len(tokenizer_calls) == 1
|
||||
assert len(processor_calls) == 1
|
||||
assert pipeline.text_encoder is not None
|
||||
assert pipeline.tokenizer is not None
|
||||
assert pipeline.processor is not None
|
||||
assert pipeline._encoder_modules == ["text_encoder"]
|
||||
assert pipeline.text_encoder_tp_size == 1
|
||||
else:
|
||||
assert created["text_encoder"] == []
|
||||
assert tokenizer_calls == []
|
||||
assert processor_calls == []
|
||||
assert pipeline.text_encoder is None
|
||||
assert pipeline.tokenizer is None
|
||||
assert pipeline.processor is None
|
||||
assert pipeline._encoder_modules == []
|
||||
assert pipeline.text_encoder_tp_size == 0
|
||||
assert pipeline.text_encoder_group is None
|
||||
if expected_partition == "ref2va":
|
||||
assert pipeline._transformer_for_task("ref2va") is pipeline.transformer
|
||||
assert [source.model_or_path for source in pipeline.weights_sources] == [
|
||||
*(str(tmp_path / partition_name) for partition_name in source_partitions),
|
||||
str(component_path),
|
||||
*((str(component_path),) if load_text_encoder else ()),
|
||||
]
|
||||
assert [source.subfolder for source in pipeline.weights_sources] == [
|
||||
*("transformer" for _ in source_partitions),
|
||||
"text_encoder",
|
||||
*(("text_encoder",) if load_text_encoder else ()),
|
||||
]
|
||||
expected_prefixes = ["transformer."]
|
||||
if expected_dits == 2:
|
||||
expected_prefixes.append("transformers_ref.")
|
||||
expected_prefixes.append("text_encoder.")
|
||||
if load_text_encoder:
|
||||
expected_prefixes.append("text_encoder.")
|
||||
assert [source.prefix for source in pipeline.weights_sources] == expected_prefixes
|
||||
expected_patterns = (
|
||||
pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS
|
||||
if expected_partition == "combined"
|
||||
else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition]
|
||||
)
|
||||
if load_text_encoder:
|
||||
expected_patterns = (
|
||||
pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS
|
||||
if expected_partition == "combined"
|
||||
else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition]
|
||||
)
|
||||
else:
|
||||
expected_patterns = pipeline_module.MINIMAX_H3_DIFFUSION_DOWNLOAD_PATTERNS[expected_partition]
|
||||
assert download_calls == [
|
||||
{
|
||||
"model_name_or_path": "MiniMaxAI/MiniMax-H3",
|
||||
@@ -1510,6 +1560,97 @@ def test_video_vae_keeps_reference_fp32_weights(monkeypatch):
|
||||
assert next(video_vae.parameters()).dtype == torch.float32
|
||||
|
||||
|
||||
def test_keyframe_encode_pins_and_restores_cudnn_settings():
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import (
|
||||
_minimax_h3_keyframe_encode_context,
|
||||
)
|
||||
|
||||
cudnn = torch.backends.cudnn
|
||||
original = (
|
||||
cudnn.enabled,
|
||||
cudnn.benchmark,
|
||||
cudnn.deterministic,
|
||||
cudnn.allow_tf32,
|
||||
)
|
||||
try:
|
||||
cudnn.enabled = False
|
||||
cudnn.benchmark = True
|
||||
cudnn.deterministic = False
|
||||
cudnn.allow_tf32 = False
|
||||
|
||||
with _minimax_h3_keyframe_encode_context(torch.device("cuda")):
|
||||
assert cudnn.enabled
|
||||
assert not cudnn.benchmark
|
||||
assert cudnn.deterministic
|
||||
assert cudnn.allow_tf32
|
||||
|
||||
assert not cudnn.enabled
|
||||
assert cudnn.benchmark
|
||||
assert not cudnn.deterministic
|
||||
assert not cudnn.allow_tf32
|
||||
finally:
|
||||
(
|
||||
cudnn.enabled,
|
||||
cudnn.benchmark,
|
||||
cudnn.deterministic,
|
||||
cudnn.allow_tf32,
|
||||
) = original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_encode", [False, True])
|
||||
def test_keyframe_encode_enters_backend_context(monkeypatch, fail_encode):
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
events = []
|
||||
input_image = Image.new("RGB", (32, 32))
|
||||
|
||||
@contextmanager
|
||||
def track_context(device):
|
||||
assert device == torch.device("cpu")
|
||||
events.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append("exit")
|
||||
|
||||
class FakeModel(torch.nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.weight = torch.nn.Parameter(torch.ones(1))
|
||||
self.parallel_tiling = True
|
||||
|
||||
def encode_images(self, image, *, use_fp16_latent):
|
||||
assert events == ["enter"]
|
||||
assert image is input_image
|
||||
assert use_fp16_latent
|
||||
events.append("encode")
|
||||
if fail_encode:
|
||||
raise RuntimeError("encode failed")
|
||||
return [torch.ones(1, 1, 2, 2)]
|
||||
|
||||
video_vae = object.__new__(vae_module.MiniMaxH3VideoVAE)
|
||||
torch.nn.Module.__init__(video_vae)
|
||||
video_vae.model = FakeModel()
|
||||
video_vae.config_dict = {
|
||||
"latent_channels": 1,
|
||||
"latents_mean": [0.0],
|
||||
"latents_std": [1.0],
|
||||
}
|
||||
monkeypatch.setattr(vae_module, "_minimax_h3_keyframe_encode_context", track_context)
|
||||
|
||||
if fail_encode:
|
||||
with pytest.raises(RuntimeError, match="encode failed"):
|
||||
video_vae.encode_image(input_image)
|
||||
else:
|
||||
rows = video_vae.encode_image(input_image)
|
||||
torch.testing.assert_close(rows, torch.ones(1, 4))
|
||||
|
||||
assert events == ["enter", "encode", "exit"]
|
||||
assert video_vae.model.parallel_tiling
|
||||
|
||||
|
||||
def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ def _pipeline_stub(fusion=None):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "combined"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._fasth3 = fusion
|
||||
pipeline.od_config = SimpleNamespace()
|
||||
pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0
|
||||
|
||||
@@ -13,7 +13,9 @@ from vllm.lora.lora_weights import PackedLoRALayerWeights
|
||||
from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager
|
||||
from vllm_omni.diffusion.models.minimax_h3 import lora as lora_module
|
||||
from vllm_omni.diffusion.models.minimax_h3.lora import (
|
||||
TurboSpec,
|
||||
load_minimax_h3_turbo_lora,
|
||||
parse_turbo_filename,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
@@ -46,7 +48,7 @@ def _request(path) -> LoRARequest:
|
||||
def _write_tiny_turbo(
|
||||
path,
|
||||
*,
|
||||
alpha: str = "128",
|
||||
alpha: str | None = "128",
|
||||
key_format: str = "minimax-h3-diffusers",
|
||||
omit_target: str | None = None,
|
||||
shape_overrides: dict[str, tuple[int, int]] | None = None,
|
||||
@@ -88,11 +90,17 @@ def _write_tiny_turbo(
|
||||
)
|
||||
else:
|
||||
tensors[b_name] = torch.ones(overrides.get(b_name, (output_dim, rank)))
|
||||
save_file(
|
||||
tensors,
|
||||
str(path),
|
||||
metadata={"alpha": alpha, "key_format": key_format},
|
||||
)
|
||||
metadata = {"key_format": key_format}
|
||||
if alpha is not None:
|
||||
metadata["alpha"] = alpha
|
||||
save_file(tensors, str(path), metadata=metadata)
|
||||
|
||||
|
||||
def _spec(filename: str = "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors") -> TurboSpec:
|
||||
"""Build the spec a loaded artifact of this name would carry."""
|
||||
spec = parse_turbo_filename(filename)
|
||||
assert spec is not None
|
||||
return spec
|
||||
|
||||
|
||||
def test_h3_turbo_loads_through_legacy_lora_model_and_swaps_ffn(tmp_path):
|
||||
@@ -107,9 +115,12 @@ def test_h3_turbo_loads_through_legacy_lora_model_and_swaps_ffn(tmp_path):
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
lora_model, peft_helper = loaded
|
||||
lora_model, peft_helper, spec = loaded
|
||||
assert peft_helper.r == 128
|
||||
assert peft_helper.lora_alpha == 128
|
||||
assert spec.denoise_steps == 4
|
||||
assert spec.sigma_points == 5
|
||||
assert spec.task_family == "fl2v"
|
||||
assert len(lora_model.loras) == 312
|
||||
assert "blocks.0.attn.to_q" in lora_model.loras
|
||||
assert "blocks.0.mlp.fc1" in lora_model.loras
|
||||
@@ -152,7 +163,8 @@ def test_legacy_manager_uses_the_h3_model_loader_without_changing_its_interface(
|
||||
|
||||
class _Pipeline:
|
||||
def _load_diffusion_lora_adapter(self, **kwargs):
|
||||
return load_minimax_h3_turbo_lora(partition="fl2va", **kwargs)
|
||||
lora_model, peft_helper, _spec_unused = load_minimax_h3_turbo_lora(partition="fl2va", **kwargs)
|
||||
return lora_model, peft_helper
|
||||
|
||||
manager = object.__new__(DiffusionLoRAManager)
|
||||
manager.pipeline = _Pipeline()
|
||||
@@ -168,22 +180,30 @@ def test_legacy_manager_uses_the_h3_model_loader_without_changing_its_interface(
|
||||
assert "blocks.0.mlp.fc1" in lora_model.loras
|
||||
|
||||
|
||||
def test_h3_turbo_rejects_wrong_alpha_and_ref2va(tmp_path):
|
||||
wrong_alpha = tmp_path / "wrong_alpha" / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"
|
||||
wrong_alpha.parent.mkdir()
|
||||
_write_tiny_turbo(wrong_alpha, alpha="8")
|
||||
with pytest.raises(ValueError, match="requires alpha=128"):
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(wrong_alpha),
|
||||
lora_path=wrong_alpha,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
def test_h3_turbo_honours_the_declared_alpha_and_task_family(tmp_path):
|
||||
"""Alpha comes from the artifact; the family decides which server serves it."""
|
||||
|
||||
eight_step_alpha = tmp_path / "alpha8" / "minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors"
|
||||
eight_step_alpha.parent.mkdir()
|
||||
_write_tiny_turbo(eight_step_alpha, alpha="8")
|
||||
loaded = load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(eight_step_alpha),
|
||||
lora_path=eight_step_alpha,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert loaded is not None
|
||||
_, peft_helper, spec = loaded
|
||||
# The eight-step artifact is a 1/16-strength adapter; pinning alpha to the
|
||||
# four-step value would over-drive it by 16x.
|
||||
assert peft_helper.lora_alpha == 8
|
||||
assert spec.alpha == 8
|
||||
assert spec.sigma_points == 9
|
||||
|
||||
valid = tmp_path / "valid" / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"
|
||||
valid.parent.mkdir()
|
||||
_write_tiny_turbo(valid)
|
||||
with pytest.raises(ValueError, match="supports FL2VA/T2VA only"):
|
||||
with pytest.raises(ValueError, match="Ref2VA-only server cannot serve it"):
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition="ref2va",
|
||||
lora_request=_request(valid),
|
||||
@@ -192,6 +212,43 @@ def test_h3_turbo_rejects_wrong_alpha_and_ref2va(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("partition", ["fl2va", "combined"])
|
||||
def test_h3_turbo_ref2v_artifact_needs_a_ref2va_only_server(tmp_path, partition):
|
||||
"""A combined server routes ref2va to ``transformers_ref``, which the
|
||||
adapter cannot bind to: the LoRA target pattern only injects into
|
||||
``transformer``. Admitting it there would run an undistilled DiT on the
|
||||
artifact's few-step schedule with no error anywhere."""
|
||||
|
||||
path = tmp_path / "minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_turbo(path)
|
||||
|
||||
with pytest.raises(ValueError, match="start the server with --task-type ref2va"):
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition=partition,
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
|
||||
def test_h3_turbo_ref2v_artifact_loads_on_a_ref2va_server(tmp_path):
|
||||
path = tmp_path / "minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_turbo(path)
|
||||
|
||||
loaded = load_minimax_h3_turbo_lora(
|
||||
partition="ref2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
spec = loaded[2]
|
||||
assert spec.task_family == "ref2v"
|
||||
assert spec.supported_tasks == frozenset({"ref2va"})
|
||||
assert spec.sigma_points == 9
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offload_mode",
|
||||
[
|
||||
@@ -214,6 +271,8 @@ def test_h3_turbo_rejects_offload_modes(tmp_path, offload_mode):
|
||||
|
||||
|
||||
def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch):
|
||||
"""DLO keeps the LoRA buffers resident, so it is not an unsupported mode."""
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import (
|
||||
pipeline_minimax_h3 as pipeline_module,
|
||||
@@ -222,7 +281,7 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch):
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
@@ -231,10 +290,11 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch):
|
||||
enable_distributed_layerwise_offload=True,
|
||||
)
|
||||
captured = {}
|
||||
spec = _spec()
|
||||
|
||||
def load_turbo(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return object(), object()
|
||||
return object(), object(), spec
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", load_turbo)
|
||||
loaded = pipeline._load_diffusion_lora_adapter(
|
||||
@@ -245,6 +305,10 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch):
|
||||
|
||||
assert loaded is not None
|
||||
assert captured["unsupported_offload_mode"] is None
|
||||
# The pipeline passes its own partition through and keeps the spec that
|
||||
# decides the request contract.
|
||||
assert captured["partition"] == "fl2va"
|
||||
assert pipeline._turbo_lora_specs == {1: spec}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -268,7 +332,7 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection(
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
@@ -281,10 +345,11 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection(
|
||||
pin_cpu_memory=True,
|
||||
)
|
||||
captured = {}
|
||||
spec = _spec()
|
||||
|
||||
def load_turbo(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return object(), object()
|
||||
return object(), object(), spec
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", load_turbo)
|
||||
|
||||
@@ -296,31 +361,84 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection(
|
||||
assert captured["unsupported_offload_mode"] == expected_mode
|
||||
|
||||
|
||||
def test_h3_turbo_accepts_only_the_declared_v1_artifact(tmp_path):
|
||||
unsupported = tmp_path / "minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors"
|
||||
_write_tiny_turbo(unsupported)
|
||||
def test_h3_turbo_uses_the_reference_alpha_when_the_artifact_declares_none(tmp_path):
|
||||
"""``4step_v0.1`` ships no alpha. LightX2V's reference script applies
|
||||
``scale * alpha / rank`` with alpha defaulting to 8, so the fallback must be
|
||||
8; alpha == rank would drive the adapter 16x too strongly."""
|
||||
|
||||
with pytest.raises(ValueError, match="supports only"):
|
||||
path = tmp_path / "minimax_h3_fl2v_turbo_4step_v0.1.safetensors"
|
||||
_write_tiny_turbo(path, alpha=None)
|
||||
|
||||
loaded = load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
|
||||
assert loaded is not None
|
||||
_, peft_helper, spec = loaded
|
||||
assert spec.alpha == 8
|
||||
assert peft_helper.lora_alpha == 8
|
||||
assert spec.alpha / spec.rank == 0.0625
|
||||
|
||||
|
||||
def test_h3_turbo_declines_the_comfyui_export(tmp_path):
|
||||
"""The ComfyUI exports fuse Q/K/V, so they are not this loader's format."""
|
||||
|
||||
path = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors"
|
||||
_write_tiny_turbo(path)
|
||||
|
||||
assert (
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(unsupported),
|
||||
lora_path=unsupported,
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
supported = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_turbo(supported)
|
||||
# Directory resolution selects the declared artifact even when another
|
||||
# v1.0 checkpoint is present beside it.
|
||||
assert (
|
||||
|
||||
def test_h3_turbo_accepts_every_published_artifact_name(tmp_path):
|
||||
"""Each published filename loads and carries its own sampler contract."""
|
||||
|
||||
for index, (name, steps, shift) in enumerate(
|
||||
(
|
||||
("minimax_h3_fl2v_turbo_4step_v0.1.safetensors", 4, 12.0),
|
||||
("minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors", 4, 6.0),
|
||||
("minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors", 8, 12.0),
|
||||
("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors", 8, 6.0),
|
||||
)
|
||||
):
|
||||
directory = tmp_path / f"artifact{index}"
|
||||
directory.mkdir()
|
||||
path = directory / name
|
||||
_write_tiny_turbo(path)
|
||||
loaded = load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(path),
|
||||
lora_path=path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
assert loaded is not None, name
|
||||
spec = loaded[2]
|
||||
assert spec.denoise_steps == steps
|
||||
assert spec.sigma_points == steps + 1
|
||||
assert spec.video_shift == shift
|
||||
|
||||
|
||||
def test_h3_turbo_directory_with_several_artifacts_is_ambiguous(tmp_path):
|
||||
_write_tiny_turbo(tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")
|
||||
_write_tiny_turbo(tmp_path / "minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors")
|
||||
|
||||
with pytest.raises(ValueError, match="point --lora-path at one file"):
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(tmp_path),
|
||||
lora_path=tmp_path,
|
||||
dtype=torch.float32,
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_h3_turbo_rejects_a_truncated_declared_artifact(tmp_path):
|
||||
@@ -352,7 +470,7 @@ def test_non_h3_checkpoint_falls_back_to_the_generic_peft_loader(tmp_path):
|
||||
|
||||
declared = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors"
|
||||
_write_tiny_turbo(declared, key_format="other")
|
||||
with pytest.raises(ValueError, match="requires safetensors metadata"):
|
||||
with pytest.raises(ValueError, match="expected 'minimax-h3-diffusers'"):
|
||||
load_minimax_h3_turbo_lora(
|
||||
partition="fl2va",
|
||||
lora_request=_request(declared),
|
||||
@@ -371,7 +489,7 @@ def test_only_an_active_recognized_turbo_adapter_restricts_ref2va(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "combined"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
request = _request("generic-peft")
|
||||
@@ -388,14 +506,14 @@ def test_only_an_active_recognized_turbo_adapter_restricts_ref2va(monkeypatch):
|
||||
return pipeline._resolve_task(
|
||||
"ref2va",
|
||||
{},
|
||||
has_turbo_lora=pipeline._has_active_turbo_lora(sampling),
|
||||
turbo_spec=pipeline._active_turbo_spec(sampling),
|
||||
)
|
||||
|
||||
recognized = (object(), object())
|
||||
recognized = (object(), object(), _spec())
|
||||
monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", lambda **_: recognized)
|
||||
assert load() is recognized
|
||||
assert load() == recognized[:2]
|
||||
assert resolve(0.0) == "ref2va"
|
||||
with pytest.raises(OmniClientError, match="supports T2VA/FL2VA requests only"):
|
||||
with pytest.raises(OmniClientError, match="serves"):
|
||||
resolve(1.0)
|
||||
|
||||
# Simulate manager eviction followed by a generic PEFT adapter reusing the
|
||||
@@ -410,7 +528,7 @@ def test_h3_turbo_requires_all_loaded_targets_to_bind():
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._turbo_lora_adapter_ids = {1}
|
||||
pipeline._turbo_lora_specs = {1: _spec()}
|
||||
lora_model = SimpleNamespace(id=1, loras={"to_q": object(), "to_k": object()})
|
||||
|
||||
pipeline._validate_diffusion_lora_binding(
|
||||
@@ -448,7 +566,7 @@ def test_h3_turbo_rejects_unsupported_sampling(num_inference_steps, extra_args,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match=error):
|
||||
pipeline._validate_turbo_sampling(sampling)
|
||||
pipeline._validate_turbo_sampling(sampling, _spec())
|
||||
|
||||
|
||||
def test_h3_turbo_accepts_five_sigma_points_for_four_nfe():
|
||||
@@ -461,5 +579,6 @@ def test_h3_turbo_accepts_five_sigma_points_for_four_nfe():
|
||||
SimpleNamespace(
|
||||
num_inference_steps=5,
|
||||
extra_args={"flow_shift": 6.0, "audio_flow_shift": 3.0},
|
||||
)
|
||||
),
|
||||
_spec(),
|
||||
)
|
||||
@@ -5,14 +5,154 @@ import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.attention.ops.minimax_h3_modulation import (
|
||||
_MAX_1D_GRID_SIZE,
|
||||
_iter_row_chunks,
|
||||
_launch_row_chunks,
|
||||
indexed_gate,
|
||||
indexed_gate_rms_norm_scale_shift,
|
||||
indexed_scale_shift_,
|
||||
rms_norm_indexed_scale_shift,
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
("rows", "expected"),
|
||||
[
|
||||
(0, []),
|
||||
(_MAX_1D_GRID_SIZE, [(0, _MAX_1D_GRID_SIZE)]),
|
||||
(_MAX_1D_GRID_SIZE + 1, [(0, _MAX_1D_GRID_SIZE), (_MAX_1D_GRID_SIZE, 1)]),
|
||||
(69888, [(0, _MAX_1D_GRID_SIZE), (_MAX_1D_GRID_SIZE, 4353)]),
|
||||
],
|
||||
)
|
||||
def test_row_chunks_respect_ascend_grid_limit(rows, expected):
|
||||
assert list(_iter_row_chunks(rows)) == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
("device_type", "expected_grids_and_offsets"),
|
||||
[
|
||||
("npu", [(_MAX_1D_GRID_SIZE, 0), (4353, _MAX_1D_GRID_SIZE)]),
|
||||
("cuda", [(69888, 0)]),
|
||||
],
|
||||
)
|
||||
def test_launch_row_chunks_is_npu_only(device_type, expected_grids_and_offsets):
|
||||
class RecordingKernel:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def __getitem__(self, grid):
|
||||
def launch(*args, **kwargs):
|
||||
self.calls.append((grid, args, kwargs))
|
||||
|
||||
return launch
|
||||
|
||||
kernel = RecordingKernel()
|
||||
first_arg = object()
|
||||
second_arg = object()
|
||||
|
||||
_launch_row_chunks(kernel, 69888, device_type, first_arg, second_arg, block_n=16)
|
||||
|
||||
assert kernel.calls == [
|
||||
((grid,), (first_arg, second_arg, row_offset), {"block_n": 16})
|
||||
for grid, row_offset in expected_grids_and_offsets
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="requires Ascend NPU")
|
||||
@pytest.mark.parametrize(
|
||||
"device",
|
||||
[pytest.param("npu", marks=hardware_marks(res={"npu": "A3"}, num_cards=1))],
|
||||
)
|
||||
def test_modulation_wrappers_match_reference_above_grid_limit(device):
|
||||
rows = 69888
|
||||
hidden_size = 16
|
||||
num_conditions = 7
|
||||
eps = 1e-6
|
||||
dtype = torch.bfloat16
|
||||
|
||||
torch.manual_seed(0)
|
||||
x = torch.randn(rows, hidden_size, dtype=dtype)
|
||||
residual = torch.randn_like(x)
|
||||
branch = torch.randn_like(x)
|
||||
weight = torch.randn(hidden_size, dtype=dtype)
|
||||
shift = torch.randn(num_conditions, hidden_size, dtype=dtype)
|
||||
scale = torch.randn_like(shift)
|
||||
gate = torch.randn_like(shift)
|
||||
indices = torch.arange(rows) % num_conditions
|
||||
|
||||
indexed_shift = shift.index_select(0, indices)
|
||||
indexed_scale = scale.index_select(0, indices)
|
||||
indexed_gate_values = gate.index_select(0, indices)
|
||||
|
||||
x_float = x.float()
|
||||
branch_float = branch.float()
|
||||
indexed_shift_float = indexed_shift.float()
|
||||
indexed_scale_float = indexed_scale.float()
|
||||
indexed_gate_float = indexed_gate_values.float()
|
||||
|
||||
expected_scale_shift = (x_float * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype)
|
||||
expected_gate = (x_float + indexed_gate_float * branch_float).to(dtype)
|
||||
|
||||
normalized = x_float
|
||||
normalized = normalized * torch.rsqrt(normalized.pow(2).mean(-1, keepdim=True) + eps)
|
||||
normalized = normalized * weight.float()
|
||||
expected_rms = (normalized * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype)
|
||||
|
||||
updated_residual = residual.float() + indexed_gate_float * branch_float
|
||||
expected_residual = updated_residual.to(dtype)
|
||||
normalized_residual = updated_residual
|
||||
normalized_residual = normalized_residual * torch.rsqrt(normalized_residual.pow(2).mean(-1, keepdim=True) + eps)
|
||||
normalized_residual = normalized_residual * weight.float()
|
||||
expected_modulated = (normalized_residual * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype)
|
||||
|
||||
npu_x = x.to(device)
|
||||
npu_residual = residual.to(device)
|
||||
npu_branch = branch.to(device)
|
||||
npu_weight = weight.to(device)
|
||||
npu_shift = shift.to(device)
|
||||
npu_scale = scale.to(device)
|
||||
npu_gate = gate.to(device)
|
||||
npu_indices = indices.to(device)
|
||||
|
||||
actual_scale_shift = indexed_scale_shift_(npu_x.clone(), npu_shift, npu_scale, npu_indices)
|
||||
actual_gate = indexed_gate(npu_x, npu_gate, npu_branch, npu_indices)
|
||||
actual_rms = rms_norm_indexed_scale_shift(
|
||||
npu_x,
|
||||
npu_weight,
|
||||
npu_shift,
|
||||
npu_scale,
|
||||
npu_indices,
|
||||
eps,
|
||||
)
|
||||
actual_residual, actual_modulated = indexed_gate_rms_norm_scale_shift(
|
||||
npu_residual,
|
||||
npu_gate,
|
||||
npu_branch,
|
||||
npu_weight,
|
||||
npu_shift,
|
||||
npu_scale,
|
||||
npu_indices,
|
||||
eps,
|
||||
)
|
||||
|
||||
for actual, expected in (
|
||||
(actual_scale_shift, expected_scale_shift),
|
||||
(actual_gate, expected_gate),
|
||||
(actual_rms, expected_rms),
|
||||
(actual_residual, expected_residual),
|
||||
(actual_modulated, expected_modulated),
|
||||
):
|
||||
torch.testing.assert_close(actual.cpu(), expected, atol=2e-2, rtol=2e-2)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_fused_modulation_preserves_bf16_residual_boundary() -> None:
|
||||
|
||||
@@ -309,7 +309,7 @@ def test_pipeline_native_schedule_and_task_validation(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
@@ -330,7 +330,7 @@ def test_pipeline_native_schedule_and_task_validation(monkeypatch):
|
||||
pipeline._resolve_task(
|
||||
"fl2va",
|
||||
{},
|
||||
has_turbo_lora=False,
|
||||
turbo_spec=None,
|
||||
has_native_lora=True,
|
||||
)
|
||||
|
||||
@@ -418,7 +418,7 @@ def test_pipeline_replaces_native_classification_after_reload(monkeypatch, tmp_p
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
|
||||
@@ -472,7 +472,7 @@ def test_h3_native_allows_distributed_layerwise_offload(monkeypatch):
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=True,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
captured: dict[str, object] = {}
|
||||
@@ -612,7 +612,7 @@ def test_legacy_manager_uses_native_loader(tmp_path):
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
return pipeline._load_diffusion_lora_adapter(**kwargs)
|
||||
@@ -634,7 +634,7 @@ def test_pipeline_schedule_inactive_when_scale_zero():
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.partition = "fl2va"
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = {7}
|
||||
pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
@@ -664,7 +664,7 @@ def test_pipeline_schedule_falls_back_after_eviction(monkeypatch, tmp_path):
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._turbo_lora_adapter_ids = set()
|
||||
pipeline._turbo_lora_specs = {}
|
||||
pipeline._native_lora_adapter_ids = set()
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
pipeline._base_schedule_by_partition = {"fl2va": None}
|
||||
|
||||
@@ -13,12 +13,14 @@ from PIL import Image
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_h3_profiler_targets_cover_aggregate_reference_encoders():
|
||||
def test_h3_profiler_targets_cover_reference_encoders_and_vae_decoders():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
targets = MiniMaxH3Pipeline._PROFILER_TARGETS
|
||||
assert "_encode_visual_conditions" in targets
|
||||
assert "_encode_reference_audio_conditions" in targets
|
||||
assert "video_vae.decode_latent" in targets
|
||||
assert "audio_vae.decode_latent" in targets
|
||||
assert "_encode_video_conditions" not in targets
|
||||
assert "_encode_video_audio_conditions" not in targets
|
||||
assert "_encode_audio_conditions" not in targets
|
||||
|
||||
@@ -42,10 +42,10 @@ _QUALITY_CONFIG = QualityTestConfig(
|
||||
|
||||
|
||||
def _resolve_fl2va_model_ref() -> str:
|
||||
from huggingface_hub import snapshot_download
|
||||
from vllm_omni.transformers_utils.repo_utils import hf_api
|
||||
|
||||
repo_root = Path(
|
||||
snapshot_download(
|
||||
hf_api().snapshot_download(
|
||||
repo_id=_MINIMAX_H3_REPO,
|
||||
revision=_MINIMAX_H3_REVISION,
|
||||
allow_patterns=["FL2VA/**"],
|
||||
@@ -253,13 +253,13 @@ def test_resolve_fl2va_model_ref(tmp_path, monkeypatch):
|
||||
fl2va_root.mkdir()
|
||||
(fl2va_root / "model_index.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
def fake_snapshot_download(*, repo_id, revision, allow_patterns):
|
||||
def fake_snapshot_download(self, *, repo_id, revision, allow_patterns):
|
||||
assert repo_id == _MINIMAX_H3_REPO
|
||||
assert revision == _MINIMAX_H3_REVISION
|
||||
assert allow_patterns == ["FL2VA/**"]
|
||||
return str(tmp_path)
|
||||
|
||||
monkeypatch.setattr("huggingface_hub.snapshot_download", fake_snapshot_download)
|
||||
monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", fake_snapshot_download)
|
||||
assert _resolve_fl2va_model_ref() == str(fl2va_root)
|
||||
|
||||
|
||||
|
||||
@@ -49,3 +49,29 @@ def test_qwen3_vl_npu_swiglu_matches_two_projection_reference() -> None:
|
||||
atol=2e-2,
|
||||
rtol=2e-2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="requires Ascend NPU")
|
||||
def test_qwen3_vl_npu_swiglu_forward_uses_projection_module(mocker) -> None:
|
||||
from vllm_omni.platforms.npu.models import minimax_h3
|
||||
from vllm_omni.platforms.npu.models.minimax_h3 import (
|
||||
_forward_minimax_h3_qwen3vl_text_mlp_npu,
|
||||
)
|
||||
|
||||
x = torch.randn(1, 16, 128, device="npu", dtype=torch.bfloat16)
|
||||
packed = torch.randn(1, 16, 256, device="npu", dtype=torch.bfloat16)
|
||||
activated = torch.randn(1, 16, 128, device="npu", dtype=torch.bfloat16)
|
||||
gate_up_proj = mocker.Mock(return_value=packed)
|
||||
down_proj = mocker.Mock(side_effect=lambda value: value)
|
||||
mlp = mocker.Mock(gate_up_proj=gate_up_proj, down_proj=down_proj)
|
||||
swiglu = mocker.patch.object(minimax_h3, "npu_swiglu_from_packed", return_value=activated)
|
||||
|
||||
output = _forward_minimax_h3_qwen3vl_text_mlp_npu(mlp, x)
|
||||
|
||||
gate_up_proj.assert_called_once()
|
||||
assert gate_up_proj.call_args.args[0] is x
|
||||
swiglu.assert_called_once()
|
||||
assert swiglu.call_args.args[0] is packed
|
||||
down_proj.assert_called_once()
|
||||
assert down_proj.call_args.args[0] is activated
|
||||
assert output is activated
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Contract coverage for the LightX2V MiniMax-H3 Turbo artifact matrix."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.lora import (
|
||||
_TURBO_AUDIO_SHIFT,
|
||||
_TURBO_VIDEO_SHIFT_544P,
|
||||
_TURBO_VIDEO_SHIFT_768P,
|
||||
TurboSpec,
|
||||
parse_turbo_filename,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
# The eight Diffusers-layout artifacts published at lightx2v/Minimax-h3-Turbo,
|
||||
# with the contract each one implies. The ComfyUI export of an artifact is a
|
||||
# fused-QKV re-packing of the same weights and is not served.
|
||||
PUBLISHED = [
|
||||
("minimax_h3_fl2v_turbo_4step_v0.1.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_544P),
|
||||
("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P),
|
||||
("minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P),
|
||||
("minimax_h3_fl2v_turbo_4step_v1.2_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P),
|
||||
("minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors", "fl2v", 8, _TURBO_VIDEO_SHIFT_544P),
|
||||
("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors", "fl2v", 8, _TURBO_VIDEO_SHIFT_768P),
|
||||
("minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors", "ref2v", 4, _TURBO_VIDEO_SHIFT_544P),
|
||||
("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors", "ref2v", 8, _TURBO_VIDEO_SHIFT_768P),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("filename", "task", "steps", "video_shift"), PUBLISHED)
|
||||
def test_every_published_artifact_is_recognised(filename: str, task: str, steps: int, video_shift: float) -> None:
|
||||
spec = parse_turbo_filename(filename)
|
||||
assert spec is not None, filename
|
||||
assert spec.filename == filename
|
||||
assert spec.task_family == task
|
||||
assert spec.denoise_steps == steps
|
||||
assert spec.video_shift == video_shift
|
||||
assert spec.audio_shift == _TURBO_AUDIO_SHIFT
|
||||
# Until the loader reads metadata, alpha is LightX2V's reference default.
|
||||
assert spec.rank == 128
|
||||
assert spec.alpha == 8.0
|
||||
|
||||
|
||||
def test_all_eight_artifacts_are_covered() -> None:
|
||||
assert len(PUBLISHED) == 8
|
||||
assert len({name for name, *_ in PUBLISHED}) == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"adapter_model.safetensors",
|
||||
"minimax_h3_fl2v_turbo_v1.0_768p_bf16.safetensors",
|
||||
"minimax_h3_t2v_turbo_4step_v1.0.safetensors",
|
||||
"minimax_h3_fl2v_turbo_0step_v1.0.safetensors",
|
||||
# The ComfyUI exports are a different tensor layout, not a variant of
|
||||
# the supported one; the loader refuses them by name.
|
||||
"minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors",
|
||||
"minimax_h3_ref2v_turbo_8step_v1.0_768p_comfyui_bf16.safetensors",
|
||||
],
|
||||
)
|
||||
def test_unrelated_names_are_rejected(filename: str) -> None:
|
||||
assert parse_turbo_filename(filename) is None
|
||||
|
||||
|
||||
def _spec(filename: str) -> TurboSpec:
|
||||
spec = parse_turbo_filename(filename)
|
||||
assert spec is not None
|
||||
return spec
|
||||
|
||||
|
||||
def test_sigma_points_follow_the_step_count() -> None:
|
||||
assert _spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors").sigma_points == 5
|
||||
assert _spec("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors").sigma_points == 9
|
||||
|
||||
|
||||
def test_task_family_decides_supported_tasks() -> None:
|
||||
fl2v = _spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors")
|
||||
ref2v = _spec("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors")
|
||||
assert fl2v.supported_tasks == frozenset({"t2va", "fl2va"})
|
||||
assert ref2v.supported_tasks == frozenset({"ref2va"})
|
||||
@@ -0,0 +1,357 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import gc
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.chunked_decode import decode_h3_chunks
|
||||
from vllm_omni.diffusion.models.minimax_h3.temporal_chunks import decode_temporal_chunks
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cpu_process_group():
|
||||
if dist.is_initialized():
|
||||
yield dist.group.WORLD
|
||||
return
|
||||
|
||||
with tempfile.NamedTemporaryFile(prefix="h3_chunk_dist_") as rendezvous:
|
||||
init_method = f"file://{rendezvous.name}"
|
||||
dist.init_process_group("gloo", rank=0, world_size=1, init_method=init_method)
|
||||
try:
|
||||
yield dist.group.WORLD
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
gc.collect()
|
||||
|
||||
|
||||
class _FakeTemporalModel:
|
||||
use_3d_conv = True
|
||||
token_drop = 3
|
||||
tokens_chunk_size = 5
|
||||
token_overlap = 2
|
||||
vae_ratio_t = 4
|
||||
frame_pre_padding = 3
|
||||
frame_overlap = 5
|
||||
isolated_first_frame = False
|
||||
isolated_last_frame = False
|
||||
|
||||
def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens):
|
||||
del z, z_head, z_tail, num_chunks, pad_tokens
|
||||
return 35, 0, 35
|
||||
|
||||
def _adaptive_decode(self, clip):
|
||||
value = float(clip[:, :, 0].mean())
|
||||
return torch.full((1, 1, 24, 2, 2), value)
|
||||
|
||||
@staticmethod
|
||||
def blend(overlap, part, frame_overlap, dim):
|
||||
del overlap, frame_overlap, dim
|
||||
return part
|
||||
|
||||
|
||||
class _FakeHost:
|
||||
def __init__(self):
|
||||
self.model = _FakeTemporalModel()
|
||||
|
||||
@staticmethod
|
||||
def _denormalize_latent(latent):
|
||||
return latent
|
||||
|
||||
@staticmethod
|
||||
def _normalize_decoded_frames(frames):
|
||||
return frames.float()
|
||||
|
||||
|
||||
def test_temporal_chunks_emit_ordered_frames_and_collect_when_unconsumed():
|
||||
model = _FakeTemporalModel()
|
||||
latent = torch.arange(8, dtype=torch.float32).view(1, 1, 8, 1, 1)
|
||||
chunks = []
|
||||
marker = decode_temporal_chunks(model, latent, chunks.append)
|
||||
|
||||
assert marker.shape == (0,)
|
||||
assert [chunk.shape[2] for chunk in chunks] == [17, 17, 1]
|
||||
assert torch.equal(torch.cat(chunks, dim=2), decode_temporal_chunks(model, latent, None))
|
||||
|
||||
|
||||
def test_h3_callback_failure_is_deferred_until_temporal_decode_finishes():
|
||||
host = _FakeHost()
|
||||
latent = torch.zeros(1, 1, 8, 1, 1)
|
||||
seen = []
|
||||
|
||||
def fail_once(frames):
|
||||
seen.append(frames.shape[2])
|
||||
raise RuntimeError("sink failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="sink failed"):
|
||||
decode_h3_chunks(host, latent, fail_once, group=None)
|
||||
assert seen == [17]
|
||||
|
||||
|
||||
def test_h3_without_callback_is_a_plain_full_decode_on_a_vae_group(cpu_process_group):
|
||||
host = _FakeHost()
|
||||
latent = torch.arange(8, dtype=torch.float32).view(1, 1, 8, 1, 1)
|
||||
|
||||
full = decode_h3_chunks(host, latent, None, group=cpu_process_group)
|
||||
|
||||
chunks = []
|
||||
decode_h3_chunks(host, latent, chunks.append, group=cpu_process_group)
|
||||
assert torch.equal(torch.cat(chunks, dim=2), full)
|
||||
|
||||
|
||||
class _SimulatedGroup:
|
||||
"""Stand in for a VAE group so one process can check every rank's verdict.
|
||||
|
||||
``all_reduce`` is replaced with the sum the real collective would produce
|
||||
for a group of ``world_size`` ranks, ``supplied`` of which handed in a
|
||||
callback. That sum is what makes a rank-local verdict observable from a
|
||||
single process.
|
||||
"""
|
||||
|
||||
def __init__(self, world_size: int, supplied: int):
|
||||
self.world_size = world_size
|
||||
self.supplied = supplied
|
||||
|
||||
|
||||
def _patch_simulated_ranks(monkeypatch, simulated: _SimulatedGroup, rank: int) -> None:
|
||||
from vllm_omni.diffusion.models.minimax_h3 import chunked_decode as mod
|
||||
|
||||
def fake_all_reduce(tensor, group=None):
|
||||
del group # the simulated census below stands in for the real reduction
|
||||
tensor.copy_(torch.tensor([simulated.supplied], dtype=tensor.dtype))
|
||||
|
||||
monkeypatch.setattr(mod.dist, "get_rank", lambda _group: rank)
|
||||
monkeypatch.setattr(mod.dist, "get_world_size", lambda _group: simulated.world_size)
|
||||
monkeypatch.setattr(mod.dist, "all_reduce", fake_all_reduce)
|
||||
monkeypatch.setattr(mod.dist, "broadcast", lambda tensor, src=0, group=None: None)
|
||||
monkeypatch.setattr(mod.dist, "get_global_rank", lambda _group, _rank: 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1, 2])
|
||||
def test_h3_every_rank_rejects_a_partially_supplied_callback(monkeypatch, rank):
|
||||
"""Ranks that disagree must all refuse, not let one decode alone.
|
||||
|
||||
A rank-local verdict let a peer fall through into the decoder collectives
|
||||
while its peers raised, hanging the stage instead of failing it.
|
||||
"""
|
||||
host = _FakeHost()
|
||||
latent = torch.zeros(1, 1, 8, 1, 1)
|
||||
# Two ranks of three handed in a callback.
|
||||
group = _SimulatedGroup(world_size=3, supplied=2)
|
||||
_patch_simulated_ranks(monkeypatch, group, rank)
|
||||
|
||||
with pytest.raises(ValueError, match="every rank"):
|
||||
decode_h3_chunks(host, latent, lambda _frames: None, group=object())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1, 2])
|
||||
def test_h3_only_rank_zero_publishes_when_every_rank_supplies(monkeypatch, rank):
|
||||
"""Ownership is positional: all ranks stream, rank 0 publishes."""
|
||||
host = _FakeHost()
|
||||
latent = torch.zeros(1, 1, 8, 1, 1)
|
||||
group = _SimulatedGroup(world_size=3, supplied=3)
|
||||
_patch_simulated_ranks(monkeypatch, group, rank)
|
||||
|
||||
seen: list[int] = []
|
||||
decode_h3_chunks(host, latent, lambda f: seen.append(int(f.shape[2])), group=object())
|
||||
|
||||
# Peers run the loop to stay in the collectives but publish nothing.
|
||||
assert (seen != []) is (rank == 0)
|
||||
|
||||
|
||||
def _stub_video_vae(*, parallel_size: int, tile_count: int):
|
||||
"""A MiniMaxH3VideoVAE with only the pieces the decode entry points read."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
vae.parallel_size = parallel_size
|
||||
vae.model = SimpleNamespace(_adaptive_decode=lambda clip: clip)
|
||||
vae.entered_rank_local = False
|
||||
|
||||
@contextmanager
|
||||
def rank_local():
|
||||
vae.entered_rank_local = True
|
||||
yield
|
||||
|
||||
vae._decoder_tile_count = lambda latent: tile_count
|
||||
vae._rank_local_tiling = rank_local
|
||||
vae.is_distributed_enabled = lambda: False
|
||||
return vae
|
||||
|
||||
|
||||
def test_chunked_decode_falls_back_when_tiles_are_fewer_than_ranks(monkeypatch):
|
||||
"""Without the fallback, tileless ranks raise while their peers block."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_mod
|
||||
|
||||
vae = _stub_video_vae(parallel_size=4, tile_count=1)
|
||||
monkeypatch.setattr(vae_mod, "decode_h3_chunks", lambda *args, **kwargs: torch.zeros(1))
|
||||
|
||||
vae.decode_with_chunks(torch.zeros(1, 1, 8, 1, 1), on_chunk=lambda _frames: None)
|
||||
|
||||
assert vae.entered_rank_local, "chunked decode must share decode_latent's too-few-tiles fallback"
|
||||
|
||||
|
||||
def test_chunked_decode_keeps_shared_tiling_when_every_rank_has_a_tile(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import vae as vae_mod
|
||||
|
||||
vae = _stub_video_vae(parallel_size=4, tile_count=8)
|
||||
monkeypatch.setattr(vae_mod, "decode_h3_chunks", lambda *args, **kwargs: torch.zeros(1))
|
||||
|
||||
vae.decode_with_chunks(torch.zeros(1, 1, 8, 1, 1), on_chunk=lambda _frames: None)
|
||||
|
||||
assert not vae.entered_rank_local
|
||||
|
||||
|
||||
def test_h3_vae_declares_the_chunked_decode_capability():
|
||||
"""A consumer must be able to detect the capability and the pixel range."""
|
||||
from vllm_omni.diffusion.models.interface import supports_chunked_vae_decode
|
||||
from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE
|
||||
|
||||
vae = object.__new__(MiniMaxH3VideoVAE)
|
||||
|
||||
assert supports_chunked_vae_decode(vae)
|
||||
# H3 reverts through the checkpoint's processor, which lands in [0, 1];
|
||||
# a Wan VAE publishes [-1, 1], so the range cannot be assumed.
|
||||
assert vae.chunk_value_range == (0.0, 1.0)
|
||||
|
||||
|
||||
def _native_decode_temporal_streaming(model, z, z_head, z_tail, num_chunks, pad_tokens):
|
||||
"""The released ``_decode_temporal_streaming`` loop, as the parity oracle.
|
||||
|
||||
Transcribed from the checkpoint's ``AutoencoderKLLegacy`` so the fork can be
|
||||
compared against the control flow it mirrors, including the isolated
|
||||
head/tail extraction.
|
||||
"""
|
||||
total_frames, pad_frames, output_frames = model._decode_temporal_output_frame_plan(
|
||||
z, z_head, z_tail, num_chunks, pad_tokens
|
||||
)
|
||||
chunk_dec = model.tokens_chunk_size * model.vae_ratio_t
|
||||
split_count = int(model.token_drop > 0) + 1
|
||||
dec = None
|
||||
dec_overlap = None
|
||||
write_pos = 0
|
||||
|
||||
def write_part(part):
|
||||
nonlocal dec, write_pos
|
||||
part_frames = int(part.shape[2])
|
||||
if part_frames <= 0:
|
||||
return
|
||||
if dec is None:
|
||||
out_shape = list(part.shape)
|
||||
out_shape[2] = output_frames
|
||||
dec = torch.empty(out_shape, dtype=part.dtype, device=part.device)
|
||||
remaining = int(dec.shape[2]) - write_pos
|
||||
copy_frames = min(part_frames, max(0, remaining))
|
||||
if copy_frames > 0:
|
||||
dec[:, :, write_pos : write_pos + copy_frames].copy_(part[:, :, :copy_frames])
|
||||
write_pos += copy_frames
|
||||
|
||||
for i in range(num_chunks):
|
||||
t_start = i * model.tokens_chunk_size
|
||||
clip_z = z[:, :, t_start : t_start + model.tokens_chunk_size + model.token_overlap]
|
||||
if i == 0 and z_head is not None:
|
||||
clip_z = torch.cat([z_head, clip_z], dim=2)
|
||||
if i == num_chunks - 1 and z_tail is not None:
|
||||
clip_z = torch.cat([clip_z, z_tail], dim=2)
|
||||
|
||||
clip_dec = model._adaptive_decode(clip_z)
|
||||
|
||||
dec_tail = None
|
||||
if i == 0 and z_head is not None:
|
||||
write_part(clip_dec[:, :, model.vae_ratio_t - 1 : model.vae_ratio_t])
|
||||
clip_dec = clip_dec[:, :, model.vae_ratio_t :]
|
||||
if i == num_chunks - 1 and z_tail is not None:
|
||||
dec_tail = clip_dec[:, :, -1:]
|
||||
clip_dec = clip_dec[:, :, : -model.vae_ratio_t]
|
||||
|
||||
for j in range(split_count):
|
||||
f_start = j * chunk_dec
|
||||
f_end = min(f_start + chunk_dec, clip_dec.shape[2])
|
||||
chunk = clip_dec[:, :, f_start:f_end][:, :, model.frame_pre_padding :]
|
||||
if j == 0:
|
||||
if dec_overlap is not None:
|
||||
chunk = model.blend(dec_overlap, chunk, model.frame_overlap, dim=-3)
|
||||
dec_overlap = None
|
||||
write_part(chunk)
|
||||
else:
|
||||
dec_overlap = chunk.contiguous()
|
||||
|
||||
if i == num_chunks - 1:
|
||||
if dec_overlap is not None:
|
||||
write_part(dec_overlap)
|
||||
dec_overlap = None
|
||||
if dec_tail is not None:
|
||||
write_part(dec_tail)
|
||||
|
||||
return dec
|
||||
|
||||
|
||||
class _IsolatedFrameModel(_FakeTemporalModel):
|
||||
"""Fake decoder with the isolated boundaries the released config can set."""
|
||||
|
||||
frame_pre_padding = 0
|
||||
|
||||
def __init__(self, *, first: bool, last: bool):
|
||||
self.isolated_first_frame = first
|
||||
self.isolated_last_frame = last
|
||||
|
||||
def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens):
|
||||
del pad_tokens
|
||||
frames = num_chunks * self.tokens_chunk_size * self.vae_ratio_t
|
||||
frames += int(z_head is not None) + int(z_tail is not None)
|
||||
del z
|
||||
return frames, 0, frames
|
||||
|
||||
def _adaptive_decode(self, clip):
|
||||
# Every latent token decodes to a distinct, ordered block, so a shifted
|
||||
# or reordered emission is visible in the values themselves.
|
||||
base = float(clip[:, :, 0].mean())
|
||||
frames = int(clip.shape[2]) * self.vae_ratio_t
|
||||
return torch.arange(frames, dtype=torch.float32).view(1, 1, frames, 1, 1) + base
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("first", "last"),
|
||||
[(True, False), (False, True), (True, True)],
|
||||
)
|
||||
def test_temporal_chunks_match_the_native_loop_at_isolated_boundaries(first, last):
|
||||
"""The fork must extract the isolated head/tail the way the native loop does."""
|
||||
model = _IsolatedFrameModel(first=first, last=last)
|
||||
latent = torch.arange(12, dtype=torch.float32).view(1, 1, 12, 1, 1)
|
||||
|
||||
chunks: list[torch.Tensor] = []
|
||||
decode_temporal_chunks(model, latent, chunks.append)
|
||||
streamed = torch.cat(chunks, dim=2)
|
||||
|
||||
# Feed the oracle exactly what the released ``decode_temporal`` would:
|
||||
# split the isolated tokens off, then pad the remainder to a whole chunk.
|
||||
isolated = int(first) + int(last)
|
||||
pseudo_tokens = int(latent.shape[2]) - isolated + model.token_drop
|
||||
remainder = pseudo_tokens % model.tokens_chunk_size
|
||||
pad_tokens = (model.tokens_chunk_size - remainder) if remainder else 0
|
||||
num_chunks = (pseudo_tokens + pad_tokens) // model.tokens_chunk_size - int(model.token_drop > 0)
|
||||
|
||||
body = latent
|
||||
z_head = None
|
||||
if first:
|
||||
z_head = body[:, :, :1]
|
||||
body = body[:, :, 1:]
|
||||
z_tail = None
|
||||
if last:
|
||||
z_tail = body[:, :, -1:]
|
||||
body = body[:, :, :-1]
|
||||
if pad_tokens:
|
||||
body = torch.cat([body, body[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)], dim=2)
|
||||
|
||||
expected = _native_decode_temporal_streaming(model, body, z_head, z_tail, num_chunks, pad_tokens)
|
||||
|
||||
assert streamed.shape == expected.shape
|
||||
assert torch.equal(streamed, expected)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -64,6 +64,13 @@ def setup_tp_group(monkeypatch, mocker):
|
||||
lambda: 0,
|
||||
)
|
||||
|
||||
# vLLM 0.29 added a CUDA-only fused embedding kernel that engages whenever
|
||||
# tp_size > 1; these tests drive CPU tensors, so keep the portable masked path.
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.vocab_parallel_embedding.current_platform.is_cuda",
|
||||
lambda: False,
|
||||
)
|
||||
|
||||
mock_tp_group = mocker.MagicMock()
|
||||
mock_tp_group.world_size = 2
|
||||
mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_tp_group)
|
||||
@@ -94,7 +101,7 @@ def setup_tp_group(monkeypatch, mocker):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
with set_current_vllm_config(VllmConfig(device_config=device_config)):
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Behavioral regression tests for Qwen-Image Edit ``txt_seq_lens`` wiring.
|
||||
|
||||
RoPE text length must follow padded embed width, not valid-token count from
|
||||
``mask.sum()``. This exercises the Edit pipeline call site: when masks are
|
||||
shorter than padded embeds, ``forward()`` must pass padded width into
|
||||
``diffuse()``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import (
|
||||
QwenImageEditPipeline,
|
||||
)
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
PADDED_WIDTH = 64
|
||||
VALID_TOKENS = 10
|
||||
EMBED_DIM = 8
|
||||
|
||||
|
||||
def _make_padded_prompt_pair():
|
||||
prompt_embeds = torch.zeros(1, PADDED_WIDTH, EMBED_DIM)
|
||||
prompt_embeds_mask = torch.zeros(1, PADDED_WIDTH, dtype=torch.bool)
|
||||
prompt_embeds_mask[:, :VALID_TOKENS] = True
|
||||
return prompt_embeds, prompt_embeds_mask
|
||||
|
||||
|
||||
def _make_edit_forward_batch(*, negative_prompt: str | None) -> DiffusionRequestBatch:
|
||||
preprocessed = torch.zeros(3, 32, 32)
|
||||
return DiffusionRequestBatch(
|
||||
requests=[
|
||||
SimpleNamespace(
|
||||
request_id="edit-txt-seq-lens",
|
||||
prompt={
|
||||
"prompt": "edit prompt",
|
||||
"negative_prompt": negative_prompt,
|
||||
"additional_information": {
|
||||
"prompt_image": preprocessed,
|
||||
"preprocessed_image": preprocessed,
|
||||
"calculated_height": 32,
|
||||
"calculated_width": 32,
|
||||
},
|
||||
},
|
||||
sampling_params=SimpleNamespace(
|
||||
height=32,
|
||||
width=32,
|
||||
num_inference_steps=2,
|
||||
sigmas=None,
|
||||
max_sequence_length=1024,
|
||||
generator=None,
|
||||
true_cfg_scale=4.0,
|
||||
guidance_scale_provided=False,
|
||||
guidance_scale=1.0,
|
||||
num_outputs_per_prompt=1,
|
||||
latents=None,
|
||||
output_type="latent",
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _make_stubbed_edit_pipeline():
|
||||
pipeline = object.__new__(QwenImageEditPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
|
||||
pos_embeds, pos_mask = _make_padded_prompt_pair()
|
||||
neg_embeds, neg_mask = _make_padded_prompt_pair()
|
||||
|
||||
pipeline.check_inputs = lambda *args, **kwargs: None
|
||||
pipeline.check_cfg_parallel_validity = lambda *args, **kwargs: None
|
||||
pipeline.vae_scale_factor = 8
|
||||
|
||||
class _GuidanceFreeTransformer:
|
||||
guidance_embeds = False
|
||||
in_channels = 16
|
||||
|
||||
pipeline.transformer = _GuidanceFreeTransformer()
|
||||
pipeline._attention_kwargs = None
|
||||
|
||||
def _fake_encode_prompt(*, prompt_name="prompt", **kwargs):
|
||||
if prompt_name == "negative_prompt":
|
||||
return neg_embeds, neg_mask
|
||||
return pos_embeds, pos_mask
|
||||
|
||||
pipeline.encode_prompt = _fake_encode_prompt
|
||||
|
||||
latents = torch.zeros(1, 4, 8, 8)
|
||||
image_latents = torch.zeros(1, 4, 8, 8)
|
||||
pipeline.prepare_latents = lambda *args, **kwargs: (latents, image_latents)
|
||||
pipeline.prepare_timesteps = lambda *args, **kwargs: (torch.tensor([1.0, 0.5]), 2)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_diffuse(
|
||||
_prompt_embeds,
|
||||
_prompt_embeds_mask,
|
||||
_negative_prompt_embeds,
|
||||
_negative_prompt_embeds_mask,
|
||||
latents_arg,
|
||||
_img_shapes,
|
||||
txt_seq_lens,
|
||||
negative_txt_seq_lens,
|
||||
_timesteps,
|
||||
do_true_cfg,
|
||||
_guidance,
|
||||
_true_cfg_scale,
|
||||
**kwargs,
|
||||
):
|
||||
captured["txt_seq_lens"] = txt_seq_lens
|
||||
captured["negative_txt_seq_lens"] = negative_txt_seq_lens
|
||||
captured["do_true_cfg"] = do_true_cfg
|
||||
return latents_arg
|
||||
|
||||
pipeline.diffuse = _fake_diffuse
|
||||
return pipeline, captured, pos_mask, neg_mask
|
||||
|
||||
|
||||
def test_qwen_image_edit_forward_passes_padded_txt_seq_lens_to_diffuse():
|
||||
pipeline, captured, pos_mask, neg_mask = _make_stubbed_edit_pipeline()
|
||||
batch = _make_edit_forward_batch(negative_prompt="bad prompt")
|
||||
|
||||
pipeline.forward(batch)
|
||||
|
||||
assert pos_mask.sum(dim=1).tolist() == [VALID_TOKENS]
|
||||
assert neg_mask.sum(dim=1).tolist() == [VALID_TOKENS]
|
||||
assert captured["txt_seq_lens"] == [PADDED_WIDTH]
|
||||
assert captured["negative_txt_seq_lens"] == [PADDED_WIDTH]
|
||||
assert captured["do_true_cfg"] is True
|
||||
|
||||
|
||||
def test_qwen_image_edit_forward_skips_negative_txt_seq_lens_without_cfg():
|
||||
pipeline, captured, pos_mask, _neg_mask = _make_stubbed_edit_pipeline()
|
||||
batch = _make_edit_forward_batch(negative_prompt=None)
|
||||
|
||||
pipeline.forward(batch)
|
||||
|
||||
assert pos_mask.sum(dim=1).tolist() == [VALID_TOKENS]
|
||||
assert captured["txt_seq_lens"] == [PADDED_WIDTH]
|
||||
assert captured["negative_txt_seq_lens"] is None
|
||||
assert captured["do_true_cfg"] is False
|
||||
@@ -1,44 +0,0 @@
|
||||
"""Regression tests for #4443: padded prompt width vs RoPE table length.
|
||||
|
||||
When ``max(txt_seq_lens) < prompt_embeds.shape[1]``, ``QwenEmbedRope`` builds a
|
||||
text frequency table that is too short for the padded encoder width.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope
|
||||
from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Representative request-batch padding: valid tokens 10/20, shared width 32.
|
||||
PADDED_WIDTH = 32
|
||||
VALID_LENS = [10, 20]
|
||||
|
||||
|
||||
def test_pre_pr_txt_seq_lens_is_shorter_than_padded_width():
|
||||
mask = torch.zeros(len(VALID_LENS), PADDED_WIDTH, dtype=torch.bool)
|
||||
mask[0, : VALID_LENS[0]] = True
|
||||
mask[1, : VALID_LENS[1]] = True
|
||||
|
||||
pre_pr_txt_seq_lens = mask.sum(dim=1).tolist()
|
||||
assert pre_pr_txt_seq_lens == VALID_LENS
|
||||
assert max(pre_pr_txt_seq_lens) < PADDED_WIDTH
|
||||
|
||||
|
||||
def test_post_pr_txt_seq_lens_matches_padded_width():
|
||||
prompt_embeds = torch.zeros(len(VALID_LENS), PADDED_WIDTH, 8)
|
||||
assert txt_seq_lens_from_embeds(prompt_embeds) == [PADDED_WIDTH, PADDED_WIDTH]
|
||||
|
||||
|
||||
def test_rope_table_length_matches_padded_width_after_fix():
|
||||
rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True)
|
||||
img_shapes = [[(1, 16, 16)]]
|
||||
|
||||
_, freqs_pre_pr = rope(img_shapes, VALID_LENS, device="cpu")
|
||||
_, freqs_post_pr = rope(img_shapes, [PADDED_WIDTH, PADDED_WIDTH], device="cpu")
|
||||
|
||||
assert freqs_pre_pr.shape[0] == max(VALID_LENS)
|
||||
assert freqs_pre_pr.shape[0] < PADDED_WIDTH
|
||||
assert freqs_post_pr.shape[0] == PADDED_WIDTH
|
||||
@@ -1,11 +1,39 @@
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import (
|
||||
QwenEmbedRope,
|
||||
_apply_qwen_image_rotary_emb,
|
||||
)
|
||||
from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16])
|
||||
@pytest.mark.parametrize("noncontiguous", [False, True])
|
||||
def test_qwen_rotary_preserves_frequency_precision(dtype, noncontiguous):
|
||||
from diffusers.models.transformers.transformer_qwenimage import apply_rotary_emb_qwen
|
||||
|
||||
generator = torch.Generator().manual_seed(42)
|
||||
x = torch.randn(2, 17, 3, 128, generator=generator).to(dtype)
|
||||
if noncontiguous:
|
||||
x = x.transpose(1, 2).contiguous().transpose(1, 2)
|
||||
angles = torch.randn(17, 64, generator=generator)
|
||||
freqs = torch.polar(torch.ones_like(angles), angles)
|
||||
expected = apply_rotary_emb_qwen(x, freqs, use_real=False)
|
||||
actual = _apply_qwen_image_rotary_emb(x, freqs)
|
||||
assert actual.dtype == dtype
|
||||
assert actual.shape == x.shape
|
||||
torch.testing.assert_close(actual, expected, rtol=0, atol=0)
|
||||
|
||||
if dtype == torch.bfloat16:
|
||||
# A regression to early BF16 frequency rounding must be observable.
|
||||
rounded_freqs = torch.complex(freqs.real.to(dtype).float(), freqs.imag.to(dtype).float())
|
||||
rounded = _apply_qwen_image_rotary_emb(x, rounded_freqs)
|
||||
assert not torch.equal(actual, rounded)
|
||||
|
||||
|
||||
def test_txt_seq_lens_from_embeds_uses_padded_width_not_valid_token_count():
|
||||
prompt_embeds = torch.zeros(2, 64, 8)
|
||||
prompt_embeds_mask = torch.zeros(2, 64, dtype=torch.bool)
|
||||
@@ -15,6 +43,17 @@ def test_txt_seq_lens_from_embeds_uses_padded_width_not_valid_token_count():
|
||||
assert txt_seq_lens_from_embeds(prompt_embeds) == [64, 64]
|
||||
|
||||
|
||||
def test_txt_seq_lens_from_embeds_builds_rope_table_for_padded_width():
|
||||
padded_width = 32
|
||||
prompt_embeds = torch.zeros(2, padded_width, 8)
|
||||
txt_seq_lens = txt_seq_lens_from_embeds(prompt_embeds)
|
||||
rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True)
|
||||
|
||||
_, txt_freqs = rope([[(1, 16, 16)]], txt_seq_lens, device="cpu")
|
||||
|
||||
assert txt_freqs.shape[0] == padded_width
|
||||
|
||||
|
||||
def test_txt_seq_lens_from_embeds_supports_2d_embeds():
|
||||
prompt_embeds = torch.zeros(48, 16)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
@@ -155,6 +155,11 @@ def test_sana_video_rejects_unvalidated_cache_backends():
|
||||
_validate_cache_offload_parallelism(OmniDiffusionConfig(cache_backend="tea_cache"))
|
||||
|
||||
|
||||
def test_sana_video_accepts_none_cache_backend():
|
||||
"""An explicit ``cache_backend=None`` means "no cache", same as ``"none"``."""
|
||||
_validate_cache_offload_parallelism(OmniDiffusionConfig(cache_backend=None))
|
||||
|
||||
|
||||
def test_sana_video_rejects_cache_dit_with_distributed_layerwise_offload():
|
||||
config = OmniDiffusionConfig(cache_backend="cache_dit", enable_distributed_layerwise_offload=True)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
|
||||
@@ -22,7 +22,7 @@ def _init_distributed(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29501")
|
||||
|
||||
@@ -41,8 +41,8 @@ def tp1_group():
|
||||
|
||||
os.environ.setdefault("MASTER_ADDR", "localhost")
|
||||
os.environ.setdefault("MASTER_PORT", "29501")
|
||||
init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://")
|
||||
initialize_model_parallel()
|
||||
init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://", backend="gloo")
|
||||
initialize_model_parallel(backend="gloo")
|
||||
yield
|
||||
cleanup_dist_env_and_memory()
|
||||
|
||||
@@ -54,7 +54,7 @@ def force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
@@ -482,8 +482,12 @@ class _LockstepP2PDist:
|
||||
def test_tiny_transformer_sp2_lockstep_matches_dense(tp1_group, force_default_gemm, mocker, task) -> None:
|
||||
"""Two lockstep rank threads over uneven frame shards must reproduce the
|
||||
dense tiny-transformer output, and must actually have sharded the work."""
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel
|
||||
|
||||
# These CPU tensor tests must not probe a CUDA device during construction.
|
||||
mocker.patch("vllm_omni.diffusion.attention.selector._cached_get_backend_cls", return_value=SDPABackend)
|
||||
|
||||
torch.manual_seed(3)
|
||||
model = SanaVideoTransformer3DModel(**_TINY_SP_CONFIG).eval()
|
||||
for _, param in sorted(model.named_parameters()):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""TP unit tests for SANA-Video: distributed RMSNorm, sharded attention and
|
||||
sharded GLUMB temporal conv.
|
||||
@@ -51,7 +51,7 @@ def force_default_gemm(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.linear.dispatch_unquantized_gemm",
|
||||
lambda: default_unquantized_gemm,
|
||||
lambda *_args, **_kwargs: default_unquantized_gemm,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import torch
|
||||
from vllm.lora.layers import BaseLayerWithLoRA
|
||||
|
||||
import vllm_omni.diffusion.models.sensenova_u1.paged_decode as paged_decode
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.models.sensenova_u1.paged_decode import (
|
||||
BLOCK_SIZE,
|
||||
BUCKETS,
|
||||
@@ -169,7 +170,8 @@ def test_length_lives_in_device_tensors():
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The capture contract. These need CUDA and the bundled flash-attention kernel.
|
||||
# The capture contract. Graph tests need a torch.cuda-compatible accelerator;
|
||||
# paged-attention equivalence also needs the bundled kernel's paged API.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@@ -181,8 +183,7 @@ vllm_flash_attn_only = pytest.mark.skipif(
|
||||
|
||||
@cuda_only
|
||||
@vllm_flash_attn_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_paged_attention_matches_sdpa_over_the_same_buffer():
|
||||
import torch.nn.functional as F
|
||||
|
||||
@@ -223,8 +224,7 @@ def test_paged_attention_matches_sdpa_over_the_same_buffer():
|
||||
|
||||
@cuda_only
|
||||
@vllm_flash_attn_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_a_captured_graph_follows_a_later_length():
|
||||
"""One capture has to serve every length in the bucket.
|
||||
|
||||
@@ -285,8 +285,7 @@ def test_a_captured_graph_follows_a_later_length():
|
||||
|
||||
@cuda_only
|
||||
@vllm_flash_attn_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_a_captured_graph_writes_each_step_to_its_own_slot():
|
||||
"""The write slot has to be a device tensor, not a Python index.
|
||||
|
||||
@@ -347,8 +346,7 @@ def test_a_captured_graph_writes_each_step_to_its_own_slot():
|
||||
|
||||
@cuda_only
|
||||
@vllm_flash_attn_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_a_whole_decode_loop_matches_the_unpaged_path():
|
||||
"""Per-step kernel equivalence is not loop equivalence.
|
||||
|
||||
@@ -413,8 +411,7 @@ class _StubLM(torch.nn.Module):
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_the_decode_graph_captures_into_the_shared_platform_pool(monkeypatch):
|
||||
"""``_decode_context`` builds a runner per request, so a private capture pool
|
||||
hands every request its own arena and never gives it back. Measured on a
|
||||
@@ -556,10 +553,12 @@ def test_a_kernel_missing_any_kwarg_we_pass_is_not_supported(monkeypatch):
|
||||
def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch):
|
||||
"""A positional standard argument binds to whatever a future wheel puts in
|
||||
that slot, and the probe above only checks that the names exist."""
|
||||
seen = {}
|
||||
seen_args: tuple[object, ...] = ()
|
||||
seen_kwargs: dict[str, object] = {}
|
||||
|
||||
def recorder(*args, **kwargs):
|
||||
seen["args"], seen["kwargs"] = args, kwargs
|
||||
def recorder(*args: object, **kwargs: object):
|
||||
nonlocal seen_args, seen_kwargs
|
||||
seen_args, seen_kwargs = args, kwargs
|
||||
return torch.zeros(1, N_HEADS, HEAD_DIM)
|
||||
|
||||
monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: recorder)
|
||||
@@ -572,8 +571,8 @@ def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch):
|
||||
torch.zeros(1, KV_HEADS, 1, HEAD_DIM),
|
||||
1.0,
|
||||
)
|
||||
assert len(seen["args"]) == 3, "only q, k and v may be positional"
|
||||
assert set(seen["kwargs"]) == {
|
||||
assert len(seen_args) == 3, "only q, k and v may be positional"
|
||||
assert set(seen_kwargs) == {
|
||||
"max_seqlen_q",
|
||||
"cu_seqlens_q",
|
||||
"max_seqlen_k",
|
||||
@@ -694,8 +693,7 @@ def test_no_capture_crosses_a_request_once_lora_wrappers_are_installed(monkeypat
|
||||
|
||||
|
||||
@cuda_only
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.L4
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"})
|
||||
def test_a_second_request_replays_the_first_capture(monkeypatch):
|
||||
"""Reuse is only worth anything if the capture survives it."""
|
||||
dev = torch.device("cuda")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
from transformers import T5Config
|
||||
@@ -54,6 +54,13 @@ def setup_tp_group(monkeypatch, mocker):
|
||||
lambda: 0,
|
||||
)
|
||||
|
||||
# vLLM 0.29 added a CUDA-only fused embedding kernel that engages whenever
|
||||
# tp_size > 1; these tests drive CPU tensors, so keep the portable masked path.
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.layers.vocab_parallel_embedding.current_platform.is_cuda",
|
||||
lambda: False,
|
||||
)
|
||||
|
||||
# TP group
|
||||
mock_tp_group = mocker.MagicMock()
|
||||
mock_tp_group.world_size = 2
|
||||
|
||||
@@ -52,6 +52,7 @@ from vllm_omni.diffusion.offloader.offload_plan import (
|
||||
OffloadPlan,
|
||||
get_offload_plan,
|
||||
)
|
||||
from vllm_omni.diffusion.offloader.plan_resolver import resolve_offload_plan
|
||||
from vllm_omni.diffusion.offloader.startup import OffloadStartupState, attach_offload_startup_state
|
||||
from vllm_omni.host_weight_runtime import MappedHostRegion
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -2813,18 +2814,21 @@ class TestDistributedComponentSelection:
|
||||
|
||||
def test_encoder_allgather_rejects_stub_rank_before_block_discovery(self):
|
||||
"""Every rank must reject an unsafe encoder group, including stub ranks."""
|
||||
backend = DistributedLayerwiseOffloadBackend(
|
||||
OffloadConfig(
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
pin_cpu_memory=False,
|
||||
dp_size=2,
|
||||
components=frozenset({"text_encoder"}),
|
||||
dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"},
|
||||
),
|
||||
torch.device("cpu"),
|
||||
|
||||
class StubEncoderPipeline(nn.Module):
|
||||
_offload_plan = OffloadPlan(encoder_block_attrs={"text_encoder": ("missing.blocks",)})
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.text_encoder = nn.Module()
|
||||
|
||||
config = OffloadConfig(
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
pin_cpu_memory=False,
|
||||
dp_size=2,
|
||||
components=frozenset({"text_encoder"}),
|
||||
dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"},
|
||||
)
|
||||
backend.dp_group = object()
|
||||
stub_plan = OffloadPlan(encoder_block_attrs={"text_encoder": ("missing.blocks",)})
|
||||
|
||||
with pytest.raises(ValueError, match="not declared replicated"):
|
||||
backend._try_layerwise_offload_encoder(nn.Module(), "text_encoder", stub_plan)
|
||||
resolve_offload_plan(StubEncoderPipeline(), config)
|
||||
@@ -488,7 +488,8 @@ class TestLayerwiseComponentSelection:
|
||||
with pytest.raises(ValueError, match="Selected text encoder 'text_encoder_2' requires"):
|
||||
backend.enable(pipeline)
|
||||
|
||||
assert not pipeline.text_encoder._omni_layerwise_enabled
|
||||
# The plan resolver rejects the topology before any encoder is hooked.
|
||||
assert not getattr(pipeline.text_encoder, "_omni_layerwise_enabled", False)
|
||||
|
||||
def test_default_selection_preserves_unplanned_auxiliaries(self, patched_offload_runtime):
|
||||
pipeline = _LegacyComponentPipeline()
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Unit tests for the shared offload plan resolver."""
|
||||
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from tests.diffusion.offloader.helpers import (
|
||||
_DummyBlock,
|
||||
_PlainEncoder,
|
||||
_SingleBlockModel,
|
||||
_StagedEncoder,
|
||||
_StagedVAE,
|
||||
patch_offload_runtime,
|
||||
)
|
||||
from vllm_omni.diffusion.offloader import layerwise_backend as layerwise_backend_module
|
||||
from vllm_omni.diffusion.offloader import plan_resolver as plan_resolver_module
|
||||
from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy
|
||||
from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend
|
||||
from vllm_omni.diffusion.offloader.offload_plan import OffloadPlan
|
||||
from vllm_omni.diffusion.offloader.plan_resolver import resolve_offload_plan
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
CPU = torch.device("cpu")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_offload_runtime(monkeypatch):
|
||||
patch_offload_runtime(monkeypatch, layerwise_backend_module.current_omni_platform)
|
||||
|
||||
|
||||
def _config(
|
||||
components: set[str] | None = None,
|
||||
strategy: OffloadStrategy = OffloadStrategy.LAYER_WISE,
|
||||
**kwargs,
|
||||
) -> OffloadConfig:
|
||||
return OffloadConfig(
|
||||
strategy=strategy,
|
||||
pin_cpu_memory=False,
|
||||
components=None if components is None else frozenset(components),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _stack_of(component):
|
||||
assert len(component.stacks) == 1
|
||||
return component.stacks[0]
|
||||
|
||||
|
||||
class _WanStylePipeline(nn.Module):
|
||||
"""Streamable DiT plus a plan-declared, replicated text encoder."""
|
||||
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
||||
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
|
||||
_vae_modules: ClassVar[list[str]] = ["vae"]
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
_offload_plan = OffloadPlan(
|
||||
encoder_component_types={"text_encoder": "text_encoder"},
|
||||
encoder_block_attrs={"text_encoder": ("encoder.block",)},
|
||||
encoder_dlo_weight_replication=frozenset({"text_encoder"}),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=4)
|
||||
self.text_encoder = _PlainEncoder()
|
||||
self.vae = _StagedVAE()
|
||||
|
||||
|
||||
class _StagedComponentPipeline(nn.Module):
|
||||
"""MiniMax-H3 shape: two encoder stacks plus pipeline-staged components."""
|
||||
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
||||
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
|
||||
_vae_modules: ClassVar[list[str]] = ["vae"]
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
_offload_plan = OffloadPlan(
|
||||
encoder_component_types={"text_encoder": "text_encoder"},
|
||||
encoder_block_attrs={"text_encoder": ("vision.blocks", "text_model.layers")},
|
||||
on_demand_component_paths=frozenset({"text_encoder", "vae"}),
|
||||
resident_dit_paths=frozenset({"transformer"}),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=4)
|
||||
self.text_encoder = _StagedEncoder()
|
||||
self.vae = _StagedVAE()
|
||||
|
||||
|
||||
class _LegacyScanPipeline(nn.Module):
|
||||
"""No SupportsComponentDiscovery declaration and no OffloadPlan."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=3)
|
||||
self.text_encoder = _PlainEncoder()
|
||||
|
||||
|
||||
class TestProducerPrecedence:
|
||||
def test_plan_block_attrs_win_over_class_attributes(self):
|
||||
class TwoStackDiT(nn.Module):
|
||||
_layerwise_offload_blocks_attrs = ["blocks"]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.blocks = nn.ModuleList([_DummyBlock() for _ in range(2)])
|
||||
self.alt_blocks = nn.ModuleList([_DummyBlock() for _ in range(3)])
|
||||
|
||||
class Pipeline(nn.Module):
|
||||
_offload_plan = OffloadPlan(block_attrs={"transformer": ("alt_blocks",)})
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = TwoStackDiT()
|
||||
|
||||
pipeline = Pipeline()
|
||||
resolved = resolve_offload_plan(pipeline, _config({"dit"}))
|
||||
|
||||
stack = _stack_of(resolved.dits[0])
|
||||
assert stack.attrs == ("alt_blocks",)
|
||||
assert list(stack.blocks) == list(pipeline.transformer.alt_blocks)
|
||||
|
||||
def test_class_attributes_are_used_without_a_plan(self):
|
||||
pipeline = _LegacyScanPipeline()
|
||||
resolved = resolve_offload_plan(pipeline, _config({"dit"}))
|
||||
|
||||
assert list(_stack_of(resolved.dits[0]).blocks) == list(pipeline.transformer.blocks)
|
||||
|
||||
def test_declared_component_type_wins_over_leaf_name(self):
|
||||
class Pipeline(nn.Module):
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
||||
_encoder_modules: ClassVar[list[str]] = ["mllm"]
|
||||
_vae_modules: ClassVar[list[str]] = []
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
_offload_plan = OffloadPlan(
|
||||
encoder_component_types={"mllm": "text_encoder"},
|
||||
encoder_block_attrs={"mllm": ("encoder.block",)},
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
self.mllm = _PlainEncoder()
|
||||
|
||||
resolved = resolve_offload_plan(Pipeline(), _config({"text_encoder"}))
|
||||
|
||||
encoder = resolved.encoders[0]
|
||||
assert encoder.path == "mllm"
|
||||
assert encoder.selected
|
||||
assert len(_stack_of(encoder).blocks) == 2
|
||||
|
||||
def test_component_declaration_wins_over_attribute_scan(self):
|
||||
class Pipeline(nn.Module):
|
||||
_dit_modules: ClassVar[list[str]] = ["custom_dit"]
|
||||
_encoder_modules: ClassVar[list[str]] = []
|
||||
_vae_modules: ClassVar[list[str]] = []
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.custom_dit = _SingleBlockModel(num_blocks=2)
|
||||
# Would be picked up by the deprecated attribute scan.
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
|
||||
resolved = resolve_offload_plan(Pipeline(), _config({"dit"}))
|
||||
|
||||
assert [component.path for component in resolved.dits] == ["custom_dit"]
|
||||
|
||||
def test_attribute_scan_warns_once_per_pipeline_class(self, monkeypatch):
|
||||
warnings: list[str] = []
|
||||
|
||||
class _Recorder:
|
||||
def warning(self, message, *args):
|
||||
warnings.append(message % args if args else message)
|
||||
|
||||
def __getattr__(self, _name):
|
||||
return lambda *args, **kwargs: None
|
||||
|
||||
monkeypatch.setattr(plan_resolver_module, "logger", _Recorder())
|
||||
|
||||
class UndeclaredPipeline(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
|
||||
resolve_offload_plan(UndeclaredPipeline(), _config({"dit"}))
|
||||
resolve_offload_plan(UndeclaredPipeline(), _config({"dit"}))
|
||||
|
||||
scan_warnings = [message for message in warnings if "SupportsComponentDiscovery" in message]
|
||||
assert len(scan_warnings) == 1
|
||||
assert "deprecated" in scan_warnings[0]
|
||||
|
||||
|
||||
class TestResolvedTopology:
|
||||
def test_encoder_plan_resolves_one_stack_per_declared_path(self):
|
||||
pipeline = _WanStylePipeline()
|
||||
resolved = resolve_offload_plan(pipeline, _config({"dit", "text_encoder"}))
|
||||
|
||||
encoder = resolved.encoders[0]
|
||||
assert encoder.selected and not encoder.on_demand
|
||||
assert list(_stack_of(encoder).blocks) == list(pipeline.text_encoder.encoder.block)
|
||||
assert [component.path for component in resolved.vaes] == ["vae"]
|
||||
|
||||
def test_staged_plan_resolves_every_declared_stack(self):
|
||||
pipeline = _StagedComponentPipeline()
|
||||
resolved = resolve_offload_plan(pipeline, _config({"text_encoder"}))
|
||||
|
||||
encoder = resolved.encoders[0]
|
||||
assert encoder.on_demand
|
||||
assert [len(stack.blocks) for stack in encoder.stacks] == [2, 2]
|
||||
# An explicit selection never stages a VAE through the legacy plan.
|
||||
assert not resolved.vaes[0].on_demand
|
||||
# DiT is unselected, so it keeps no streaming topology.
|
||||
assert resolved.dits[0].selected is False
|
||||
assert resolved.dits[0].stacks == ()
|
||||
|
||||
def test_omitted_selector_keeps_the_legacy_staged_vae(self):
|
||||
resolved = resolve_offload_plan(_StagedComponentPipeline(), _config())
|
||||
|
||||
assert resolved.vaes[0].on_demand
|
||||
assert resolved.encoders[0].on_demand
|
||||
assert resolved.dits[0].selected
|
||||
|
||||
def test_on_demand_only_plan_resolves_without_stacks(self):
|
||||
class Pipeline(nn.Module):
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
||||
_encoder_modules: ClassVar[list[str]] = ["text_encoder"]
|
||||
_vae_modules: ClassVar[list[str]] = ["vae"]
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
_offload_plan = OffloadPlan(on_demand_component_paths=frozenset({"text_encoder", "vae"}))
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
self.text_encoder = _StagedEncoder()
|
||||
self.vae = _StagedVAE()
|
||||
|
||||
resolved = resolve_offload_plan(Pipeline(), _config({"text_encoder"}))
|
||||
|
||||
assert resolved.encoders[0].on_demand
|
||||
assert resolved.encoders[0].stacks == ()
|
||||
|
||||
def test_module_strategy_resolves_selection_without_stacks(self):
|
||||
resolved = resolve_offload_plan(
|
||||
_WanStylePipeline(),
|
||||
_config({"dit", "text_encoder"}, strategy=OffloadStrategy.MODEL_LEVEL),
|
||||
)
|
||||
|
||||
assert [component.path for component in resolved.components] == ["transformer", "text_encoder", "vae"]
|
||||
assert all(component.stacks == () for component in resolved.components)
|
||||
assert resolved.encoders[0].selected
|
||||
|
||||
def test_module_strategy_leaves_staged_residency_to_the_pipeline(self):
|
||||
resolved = resolve_offload_plan(
|
||||
_StagedComponentPipeline(),
|
||||
_config({"dit", "text_encoder"}, strategy=OffloadStrategy.MODEL_LEVEL),
|
||||
)
|
||||
|
||||
assert not any(component.on_demand for component in resolved.components)
|
||||
|
||||
|
||||
class TestResidentLayers:
|
||||
def _dlo_config(self, resident_layers: int, components: set[str] | None = None) -> OffloadConfig:
|
||||
return _config(
|
||||
components,
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
dlo_transfers={"dit": "rank-local", "text_encoder": "rank-local"},
|
||||
dlo_resident_layers=resident_layers,
|
||||
)
|
||||
|
||||
def test_resident_head_splits_the_declared_dit(self):
|
||||
pipeline = _StagedComponentPipeline()
|
||||
resolved = resolve_offload_plan(pipeline, self._dlo_config(2, {"dit"}))
|
||||
|
||||
stack = _stack_of(resolved.dits[0])
|
||||
assert stack.resident_head == 2
|
||||
assert list(stack.resident) == list(pipeline.transformer.blocks)[:2]
|
||||
assert list(stack.streaming) == list(pipeline.transformer.blocks)[2:]
|
||||
|
||||
def test_resident_count_beyond_the_stack_keeps_every_block(self):
|
||||
resolved = resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(9, {"dit"}))
|
||||
|
||||
stack = _stack_of(resolved.dits[0])
|
||||
assert stack.resident_head == 4
|
||||
assert stack.streaming == ()
|
||||
|
||||
def test_resident_head_leaving_one_streaming_block_is_rejected(self):
|
||||
with pytest.raises(ValueError, match="leaves only one streaming block"):
|
||||
resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(3, {"dit"}))
|
||||
|
||||
def test_legacy_single_streaming_block_stays_resident(self):
|
||||
resolved = resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(3))
|
||||
|
||||
stack = _stack_of(resolved.dits[0])
|
||||
assert stack.resident_head == 4
|
||||
assert stack.streaming == ()
|
||||
|
||||
def test_resident_layers_without_a_declared_path_is_rejected(self):
|
||||
with pytest.raises(ValueError, match="no matching resident_dit_paths"):
|
||||
resolve_offload_plan(_WanStylePipeline(), self._dlo_config(2, {"dit"}))
|
||||
|
||||
|
||||
class TestValidation:
|
||||
def test_selected_dit_requires_a_discovered_module(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.text_encoder = _PlainEncoder()
|
||||
|
||||
with pytest.raises(ValueError, match="No DiT/transformer modules found"):
|
||||
resolve_offload_plan(pipeline, _config({"dit"}))
|
||||
|
||||
def test_model_level_selection_requires_both_swap_sides(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.transformer = _SingleBlockModel(num_blocks=2)
|
||||
|
||||
with pytest.raises(ValueError, match="requires an encoder execution stage"):
|
||||
resolve_offload_plan(pipeline, _config({"dit"}, strategy=OffloadStrategy.MODEL_LEVEL))
|
||||
|
||||
def test_selected_text_encoder_requires_a_matching_module(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.transformer = _SingleBlockModel(num_blocks=2)
|
||||
|
||||
with pytest.raises(ValueError, match="No text encoder modules found"):
|
||||
resolve_offload_plan(pipeline, _config({"text_encoder"}))
|
||||
|
||||
def test_selected_encoder_requires_a_streamable_or_staged_plan(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.transformer = _SingleBlockModel(num_blocks=2)
|
||||
pipeline.text_encoder = _PlainEncoder()
|
||||
|
||||
with pytest.raises(ValueError, match="Selected text encoder 'text_encoder' requires"):
|
||||
resolve_offload_plan(pipeline, _config({"text_encoder"}))
|
||||
|
||||
def test_staged_component_requires_the_explicit_lifecycle(self):
|
||||
class Pipeline(nn.Module):
|
||||
_offload_plan = OffloadPlan(on_demand_component_paths=frozenset({"text_encoder"}))
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
self.text_encoder = _PlainEncoder()
|
||||
|
||||
with pytest.raises(ValueError, match="must implement load_to_device"):
|
||||
resolve_offload_plan(Pipeline(), _config({"text_encoder"}))
|
||||
|
||||
def test_single_block_dit_is_rejected_for_layer_offload(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.transformer = _SingleBlockModel(num_blocks=1)
|
||||
|
||||
with pytest.raises(ValueError, match="requires at least two streamable"):
|
||||
resolve_offload_plan(pipeline, _config({"dit"}))
|
||||
|
||||
def test_encoder_allgather_requires_declared_replication(self):
|
||||
config = _config(
|
||||
{"text_encoder"},
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
dp_size=2,
|
||||
dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="not declared replicated"):
|
||||
resolve_offload_plan(_StagedComponentPipeline(), config)
|
||||
|
||||
def test_two_dits_cannot_own_the_same_blocks(self):
|
||||
class Pipeline(nn.Module):
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer", "transformer_2"]
|
||||
_encoder_modules: ClassVar[list[str]] = []
|
||||
_vae_modules: ClassVar[list[str]] = []
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
self.transformer_2 = _SingleBlockModel(num_blocks=2)
|
||||
self.transformer_2.blocks = self.transformer.blocks
|
||||
|
||||
with pytest.raises(ValueError, match="claimed by both"):
|
||||
resolve_offload_plan(Pipeline(), _config({"dit"}))
|
||||
|
||||
def test_legacy_selection_keeps_unstreamable_components(self):
|
||||
pipeline = nn.Module()
|
||||
pipeline.transformer = _SingleBlockModel(num_blocks=1)
|
||||
pipeline.text_encoder = _PlainEncoder()
|
||||
|
||||
resolved = resolve_offload_plan(pipeline, _config())
|
||||
|
||||
assert resolved.dits[0].stacks == ()
|
||||
assert resolved.encoders[0].stacks == ()
|
||||
|
||||
|
||||
class TestPurity:
|
||||
def _snapshot(self, pipeline: nn.Module):
|
||||
return {
|
||||
"devices": [(name, tensor.device) for name, tensor in pipeline.named_parameters()],
|
||||
"attrs": {name: sorted(vars(module)) for name, module in pipeline.named_modules()},
|
||||
}
|
||||
|
||||
def test_resolution_does_not_touch_the_model(self):
|
||||
pipeline = _StagedComponentPipeline()
|
||||
before = self._snapshot(pipeline)
|
||||
|
||||
resolve_offload_plan(pipeline, _config({"dit", "text_encoder"}))
|
||||
|
||||
assert self._snapshot(pipeline) == before
|
||||
assert pipeline.text_encoder.offload_calls == 0
|
||||
assert pipeline.text_encoder.to_calls == 0
|
||||
assert pipeline.vae.to_calls == 0
|
||||
|
||||
def test_resolution_is_repeatable(self):
|
||||
pipeline = _WanStylePipeline()
|
||||
config = _config({"dit", "text_encoder"})
|
||||
|
||||
first = resolve_offload_plan(pipeline, config)
|
||||
second = resolve_offload_plan(pipeline, config)
|
||||
|
||||
assert [component.path for component in first.components] == [component.path for component in second.components]
|
||||
for left, right in zip(first.components, second.components, strict=True):
|
||||
assert left.selected == right.selected
|
||||
assert left.on_demand == right.on_demand
|
||||
assert [list(stack.blocks) for stack in left.stacks] == [list(stack.blocks) for stack in right.stacks]
|
||||
|
||||
def test_backend_enable_rejects_before_touching_earlier_components(self, patched_offload_runtime):
|
||||
class Pipeline(nn.Module):
|
||||
_dit_modules: ClassVar[list[str]] = ["transformer"]
|
||||
_encoder_modules: ClassVar[list[str]] = ["text_encoder", "text_encoder_2"]
|
||||
_vae_modules: ClassVar[list[str]] = ["vae"]
|
||||
_resident_modules: ClassVar[list[str]] = []
|
||||
_offload_plan = OffloadPlan(
|
||||
encoder_component_types={
|
||||
"text_encoder": "text_encoder",
|
||||
"text_encoder_2": "text_encoder",
|
||||
},
|
||||
encoder_block_attrs={"text_encoder": ("encoder.block",)},
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = _SingleBlockModel(num_blocks=2)
|
||||
self.text_encoder = _PlainEncoder()
|
||||
self.text_encoder_2 = _PlainEncoder()
|
||||
self.vae = _StagedVAE()
|
||||
|
||||
pipeline = Pipeline()
|
||||
backend = LayerWiseOffloadBackend(_config({"text_encoder"}), CPU)
|
||||
|
||||
with pytest.raises(ValueError, match="Selected text encoder 'text_encoder_2' requires"):
|
||||
backend.enable(pipeline)
|
||||
|
||||
assert not backend.enabled
|
||||
assert not getattr(pipeline.text_encoder, "_omni_layerwise_enabled", False)
|
||||
for block in pipeline.text_encoder.encoder.block:
|
||||
assert getattr(block, "_hook_registry", None) is None
|
||||
assert pipeline.vae.to_calls == 0
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for component routing for quantization."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -372,7 +372,8 @@ class TestComponentApplyVllmMapper:
|
||||
"thinker.model": "thinker.language_model.model",
|
||||
"talker.model": "talker.language_model.model",
|
||||
}
|
||||
).get_unstacked_mapper()
|
||||
# vLLM 0.29 renamed get_unstacked_mapper to get_rename_mapper.
|
||||
).get_rename_mapper()
|
||||
|
||||
config.apply_vllm_mapper(mapper)
|
||||
|
||||
|
||||
@@ -300,7 +300,8 @@ def test_single_stage_zimage_fp8_uses_less_memory():
|
||||
assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)"
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"})
|
||||
@hardware_test(res={"cuda": "L4", "xpu": "B60"})
|
||||
@pytest.mark.advanced_model
|
||||
def test_single_stage_qwen_image_fp8():
|
||||
"""Qwen-Image (random weights) with FP8 generates valid images."""
|
||||
model = "riverclouds/qwen_image_random"
|
||||
|
||||
File renamed without changes.
+288
-49
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for midway prompt update across runner, batch, pipeline, and entrypoint layers."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -7,6 +7,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import queue
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -21,6 +22,11 @@ from tests.engine.test_orchestrator import (
|
||||
)
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient
|
||||
from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator
|
||||
from vllm_omni.diffusion.interaction.modality_handlers.prompt import (
|
||||
PromptInteractionHandler,
|
||||
PromptSession,
|
||||
)
|
||||
from vllm_omni.diffusion.models.helios.pipeline_helios import HeliosPipeline
|
||||
from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner
|
||||
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
||||
@@ -54,30 +60,55 @@ def pipeline() -> HeliosPipeline:
|
||||
None,
|
||||
)
|
||||
)
|
||||
pipeline._prepare_next_chunk = MagicMock()
|
||||
pipeline.prepare_next_chunk = MagicMock()
|
||||
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
|
||||
pipeline._interaction_coordinator = InteractionCoordinator.build(pipeline, od_config)
|
||||
return pipeline
|
||||
|
||||
|
||||
def _make_diffusion_request_state(*, request_id: str = "req-1") -> StepRequestState:
|
||||
def _make_diffusion_request_state(*, request_id: str = "req-1", fps: int | None = None) -> StepRequestState:
|
||||
state = StepRequestState(
|
||||
request_id=request_id,
|
||||
sampling=SimpleNamespace(num_outputs_per_prompt=1, max_sequence_length=226), # pyright: ignore[reportArgumentType]
|
||||
sampling=SimpleNamespace( # pyright: ignore[reportArgumentType]
|
||||
num_outputs_per_prompt=1,
|
||||
max_sequence_length=226,
|
||||
fps=fps,
|
||||
),
|
||||
prompt="hello",
|
||||
)
|
||||
state.prompt_embeds = torch.zeros(1, 4, 2)
|
||||
state.extra = {}
|
||||
state.extra = {"window_num_frames": 8}
|
||||
return state
|
||||
|
||||
|
||||
def _make_diffusion_model_runner(*, pipeline, streaming_output: bool = True) -> DiffusionModelRunner:
|
||||
def _make_diffusion_model_runner(
|
||||
*,
|
||||
pipeline,
|
||||
streaming_output: bool = True,
|
||||
model_class_name: str = "HeliosPipeline",
|
||||
) -> DiffusionModelRunner:
|
||||
runner = object.__new__(DiffusionModelRunner)
|
||||
runner.pipeline = pipeline
|
||||
runner.state_cache = {}
|
||||
runner.od_config = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue]
|
||||
model_class_name="HeliosPipeline",
|
||||
model_class_name=model_class_name,
|
||||
streaming_output=streaming_output,
|
||||
cache_backend=None,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False),
|
||||
)
|
||||
runner.vllm_config = object()
|
||||
runner.device = torch.device("cpu")
|
||||
runner.cache_backend = None
|
||||
runner.offload_backend = None
|
||||
runner.prompt_embed_cache = None
|
||||
runner.kv_transfer_manager = SimpleNamespace(
|
||||
receive_multi_kv_cache_distributed=lambda *a, **k: None,
|
||||
)
|
||||
# HeliosPipeline skeletons from object.__new__ are not full SupportsStepExecution
|
||||
# instances; unit tests of submit_interaction stub this. Stepwise path tests
|
||||
# rebind to the real method.
|
||||
runner._supports_step_mode = lambda: True
|
||||
runner._interaction_coordinator = None
|
||||
return runner
|
||||
|
||||
|
||||
@@ -108,10 +139,13 @@ class TestPromptUpdateExecution:
|
||||
runner.submit_interaction("req-1", _prompt_interaction(transition_chunks=2))
|
||||
|
||||
pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue]
|
||||
pending = state.extra["pending_prompt_update"]
|
||||
assert pending["event_id"] == "ui-update-1"
|
||||
assert pending["transition_chunks"] == 2
|
||||
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
|
||||
session = state.interaction_sessions["prompt"]
|
||||
assert isinstance(session, PromptSession)
|
||||
pending = session.pending_event
|
||||
assert pending is not None
|
||||
assert pending.event_id == "ui-update-1"
|
||||
assert pending.transition_chunks == 2
|
||||
assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0))
|
||||
|
||||
def test_runner_prompt_update_rejects_unsupported_pipeline(self) -> None:
|
||||
"""Runner rejects prompt updates when the pipeline lacks prompt-update support."""
|
||||
@@ -135,6 +169,9 @@ class TestPromptUpdateExecution:
|
||||
{},
|
||||
{"multi_modal_data": {"camera": {"type": "pose"}}},
|
||||
{"event": {"prompt": "updated", "multi_modal_data": {"camera": {"type": "pose"}}}},
|
||||
{"event": {"prompt": "updated", "multi_modal_data": {}}},
|
||||
{"event": {"prompt": "updated", "multi_modal_data": None}},
|
||||
{"event": {"prompt": "updated", "multi_modal_data": "bad"}},
|
||||
],
|
||||
)
|
||||
def test_runner_interaction_rejects_structural_payloads_until_implemented(
|
||||
@@ -149,6 +186,34 @@ class TestPromptUpdateExecution:
|
||||
with pytest.raises(NotImplementedError, match="Only text-only prompt update interactions"):
|
||||
runner.submit_interaction("req-1", cast(Any, interaction))
|
||||
|
||||
@pytest.mark.parametrize("model_class_name", ["HeliosPipeline", "HeliosPyramidPipeline"])
|
||||
def test_coordinator_registers_prompt_for_helios_aliases(
|
||||
self,
|
||||
pipeline: HeliosPipeline,
|
||||
model_class_name: str,
|
||||
) -> None:
|
||||
"""Both Helios architecture names resolve the prompt handler registry."""
|
||||
od_config = SimpleNamespace(model_class_name=model_class_name)
|
||||
coordinator = InteractionCoordinator.build(pipeline, od_config)
|
||||
assert coordinator.has_modality("prompt")
|
||||
|
||||
runner = _make_diffusion_model_runner(pipeline=pipeline, model_class_name=model_class_name)
|
||||
runner.state_cache["req-1"] = _make_diffusion_request_state()
|
||||
runner.submit_interaction("req-1", _prompt_interaction())
|
||||
session = runner.state_cache["req-1"].interaction_sessions["prompt"]
|
||||
assert isinstance(session, PromptSession)
|
||||
assert session.pending_event is not None
|
||||
|
||||
def test_helios_peek_chunk_media_requires_positive_fps(self, pipeline: HeliosPipeline) -> None:
|
||||
"""Media-timeline peek must raise on missing/invalid fps instead of coercing to 0.0."""
|
||||
state = _make_diffusion_request_state(fps=None)
|
||||
with pytest.raises(ValueError, match="sampling.fps is required and must be > 0"):
|
||||
pipeline.peek_chunk_media(state)
|
||||
|
||||
state.sampling.fps = 0 # pyright: ignore[reportAttributeAccessIssue]
|
||||
with pytest.raises(ValueError, match="sampling.fps is required and must be > 0"):
|
||||
pipeline.peek_chunk_media(state)
|
||||
|
||||
def test_input_batch_refreshes_prompt_embeds_on_version_change(self) -> None:
|
||||
"""InputBatch rebuilds prompt_embeds when prompt_update_version changes."""
|
||||
state = StepRequestState(
|
||||
@@ -159,28 +224,37 @@ class TestPromptUpdateExecution:
|
||||
state.prompt_embeds = torch.zeros(1, 2, 3)
|
||||
state.latents = torch.zeros(1, 2)
|
||||
state.timesteps = torch.tensor([1.0])
|
||||
state.extra["prompt_update_version"] = 0
|
||||
state.interaction_sessions["prompt"] = PromptSession(version=0)
|
||||
|
||||
batch = InputBatch.make_batch([state])
|
||||
assert torch.equal(batch.prompt_embeds, torch.zeros(1, 2, 3)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
state.prompt_embeds = torch.ones(1, 2, 3)
|
||||
state.extra["prompt_update_version"] = 1
|
||||
state.interaction_sessions["prompt"] = PromptSession(version=1)
|
||||
refreshed = InputBatch.make_batch([state], cached_batch=batch)
|
||||
assert torch.equal(refreshed.prompt_embeds, torch.ones(1, 2, 3)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def test_helios_prepare_prompt_update_queues_pending_target(self, pipeline: HeliosPipeline) -> None:
|
||||
"""HeliosPipeline queues target embeds without mutating current prompt_embeds."""
|
||||
def test_helios_prompt_handler_enqueue_queues_pending_target(self, pipeline: HeliosPipeline) -> None:
|
||||
"""Prompt handler queues target embeds without mutating current prompt_embeds."""
|
||||
state = _make_diffusion_request_state()
|
||||
|
||||
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
|
||||
PromptInteractionHandler.from_pipeline(pipeline).enqueue(
|
||||
state,
|
||||
event_id="ui-update-1",
|
||||
received_at=0.0,
|
||||
payload={"prompt": "new scene"},
|
||||
transition_chunks=2,
|
||||
)
|
||||
|
||||
pending = state.extra["pending_prompt_update"]
|
||||
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
|
||||
assert pending["transition_chunks"] == 2
|
||||
session = state.interaction_sessions["prompt"]
|
||||
assert isinstance(session, PromptSession)
|
||||
pending = session.pending_event
|
||||
assert pending is not None
|
||||
assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0))
|
||||
assert pending.transition_chunks == 2
|
||||
assert torch.equal(state.prompt_embeds, torch.zeros(1, 4, 2)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def test_helios_prepare_prompt_update_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None:
|
||||
def test_helios_prompt_handler_enqueue_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None:
|
||||
"""Reject prompt updates submitted before initial prompt embeds exist."""
|
||||
state = _make_diffusion_request_state()
|
||||
state.prompt_embeds = None
|
||||
@@ -189,43 +263,209 @@ class TestPromptUpdateExecution:
|
||||
ValueError,
|
||||
match="prompt_update is not allowed before initial generation has started",
|
||||
):
|
||||
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
|
||||
PromptInteractionHandler.from_pipeline(pipeline).enqueue(
|
||||
state,
|
||||
event_id="ui-update-1",
|
||||
received_at=0.0,
|
||||
payload={"prompt": "new scene"},
|
||||
transition_chunks=2,
|
||||
)
|
||||
|
||||
assert "pending_prompt_update" not in state.extra
|
||||
session = state.interaction_sessions.get("prompt")
|
||||
assert session is None or (isinstance(session, PromptSession) and session.pending_event is None)
|
||||
|
||||
def test_helios_apply_prompt_update_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None:
|
||||
def test_helios_apply_interaction_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None:
|
||||
"""At chunk boundary, starts transition state and bumps prompt_update_version."""
|
||||
state = _make_diffusion_request_state()
|
||||
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2)
|
||||
state = _make_diffusion_request_state(fps=None)
|
||||
PromptInteractionHandler.from_pipeline(pipeline).enqueue(
|
||||
state,
|
||||
event_id="ui-update-1",
|
||||
received_at=0.0,
|
||||
payload={"prompt": "new scene"},
|
||||
transition_chunks=2,
|
||||
)
|
||||
|
||||
pipeline._apply_prompt_update_at_chunk_boundary(state)
|
||||
pipeline.apply_interaction_at_chunk_boundary(state)
|
||||
|
||||
assert state.extra.get("prompt_update_state") is not None
|
||||
session = state.interaction_sessions["prompt"]
|
||||
assert isinstance(session, PromptSession)
|
||||
assert session.active_event is not None
|
||||
assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType]
|
||||
assert state.extra["prompt_update_version"] == 1
|
||||
assert state.extra["prompt_update_chunk_metadata"] == {
|
||||
assert session.version == 1
|
||||
assert state.interaction_chunk_metadata is not None
|
||||
assert state.interaction_chunk_metadata.as_dict() == {
|
||||
"started_event_ids": ["ui-update-1"],
|
||||
"active_event_ids": ["ui-update-1"],
|
||||
"completed_event_ids": [],
|
||||
}
|
||||
|
||||
def test_helios_apply_prompt_update_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None:
|
||||
def test_helios_apply_interaction_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None:
|
||||
"""At chunk boundary, interpolates embeds until the target prompt is reached."""
|
||||
state = _make_diffusion_request_state()
|
||||
pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=3)
|
||||
pipeline._apply_prompt_update_at_chunk_boundary(state)
|
||||
state = _make_diffusion_request_state(fps=None)
|
||||
PromptInteractionHandler.from_pipeline(pipeline).enqueue(
|
||||
state,
|
||||
event_id="ui-update-1",
|
||||
received_at=0.0,
|
||||
payload={"prompt": "new scene"},
|
||||
transition_chunks=3,
|
||||
)
|
||||
pipeline.apply_interaction_at_chunk_boundary(state)
|
||||
|
||||
assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0 / 3.0)) # pyright: ignore[reportArgumentType]
|
||||
|
||||
for _ in range(2):
|
||||
pipeline._apply_prompt_update_at_chunk_boundary(state)
|
||||
pipeline.apply_interaction_at_chunk_boundary(state)
|
||||
|
||||
assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0)) # pyright: ignore[reportArgumentType]
|
||||
assert state.extra["prompt_update_version"] == 3
|
||||
session = state.interaction_sessions["prompt"]
|
||||
assert isinstance(session, PromptSession)
|
||||
assert session.version == 3
|
||||
|
||||
# Further chunk boundaries after completion must not bump the version.
|
||||
pipeline._apply_prompt_update_at_chunk_boundary(state)
|
||||
assert state.extra["prompt_update_version"] == 3
|
||||
pipeline.apply_interaction_at_chunk_boundary(state)
|
||||
assert session.version == 3
|
||||
|
||||
def test_stepwise_runner_applies_prompt_update_and_acks(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Drive the production runner loop: submit → apply(no media args) → ACK on next chunk.
|
||||
|
||||
Also regresses ``sampling.fps is None`` on the prompt-only path (no peek).
|
||||
ACK ids land on the *next* chunk's ``DiffusionOutput`` via
|
||||
``_attach_stepwise_metadata`` consuming the prior boundary metadata.
|
||||
"""
|
||||
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
|
||||
from vllm_omni.diffusion.interaction.mixin import InteractionMixin
|
||||
from vllm_omni.diffusion.interaction.types import ChunkMediaSpec
|
||||
|
||||
chunks = [
|
||||
DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2), # pyright: ignore[reportArgumentType]
|
||||
DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2), # pyright: ignore[reportArgumentType]
|
||||
]
|
||||
|
||||
class _InteractiveChunkPipeline(InteractionMixin):
|
||||
device = torch.device("cpu")
|
||||
supports_step_execution = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._outputs = chunks
|
||||
self.decode_calls = 0
|
||||
self.transformer = SimpleNamespace(dtype=torch.float32)
|
||||
self.encode_prompt = MagicMock(return_value=(torch.full((1, 4, 2), 2.0), None))
|
||||
self.prepare_next_chunk = MagicMock()
|
||||
od_config = SimpleNamespace(model_class_name="HeliosPipeline")
|
||||
self._interaction_coordinator = InteractionCoordinator.build(self, od_config) # pyright: ignore[reportArgumentType]
|
||||
|
||||
def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec:
|
||||
# Same contract as Helios: never coerce missing fps to 0.0.
|
||||
fps = state.sampling.fps
|
||||
if fps is None or float(fps) <= 0:
|
||||
raise ValueError(
|
||||
"sampling.fps is required and must be > 0 for interaction modalities "
|
||||
f"that use the chunk media timeline, got {fps!r} "
|
||||
f"(request_id={state.request_id!r})"
|
||||
)
|
||||
return ChunkMediaSpec(num_frames=int(state.extra["window_num_frames"]), fps=float(fps))
|
||||
|
||||
def prepare_encode(self, state: StepRequestState) -> StepRequestState:
|
||||
state.prompt_embeds = torch.zeros(1, 4, 2)
|
||||
state.latents = torch.zeros(1, 1)
|
||||
state.timesteps = torch.tensor([1.0, 0.0, 1.0, 0.0])
|
||||
state.step_index = 0
|
||||
state.step_in_chunk = 0
|
||||
state.chunk_num_steps = 2
|
||||
state.total_chunks = 2
|
||||
state.extra = {"window_num_frames": 8}
|
||||
return state
|
||||
|
||||
def denoise_step(self, input_batch: Any, states: Any) -> torch.Tensor:
|
||||
del states
|
||||
return torch.ones_like(input_batch.latents)
|
||||
|
||||
def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor | None) -> None:
|
||||
state.latents = noise_pred
|
||||
state.step_index += 1
|
||||
state.step_in_chunk += 1
|
||||
|
||||
def post_decode(self, state: StepRequestState) -> DiffusionOutput:
|
||||
output = self._outputs[self.decode_calls]
|
||||
self.decode_calls += 1
|
||||
state.chunk_index += 1
|
||||
state.step_index = 0
|
||||
state.step_in_chunk = 0
|
||||
if not state.request_denoise_completed:
|
||||
state.latents = torch.zeros(1, 1)
|
||||
return output
|
||||
|
||||
pipeline = _InteractiveChunkPipeline()
|
||||
runner = _make_diffusion_model_runner(pipeline=pipeline)
|
||||
runner._supports_step_mode = lambda: DiffusionModelRunner._supports_step_mode(runner)
|
||||
runner.od_config.streaming_output = True
|
||||
runner.od_config.step_execution = True
|
||||
runner.diffusion_kv_backend = SimpleNamespace(remove_diffusion_kv_requests=lambda *_: 0) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
sampling = SimpleNamespace(
|
||||
generator=None,
|
||||
seed=None,
|
||||
generator_device=None,
|
||||
num_inference_steps=4,
|
||||
num_outputs_per_prompt=1,
|
||||
max_sequence_length=226,
|
||||
fps=None,
|
||||
)
|
||||
req = SimpleNamespace(
|
||||
request_id="req",
|
||||
prompt="a prompt",
|
||||
sampling_params=sampling,
|
||||
kv_sender_info=None,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _noop_forward_context(*args: Any, **kwargs: Any):
|
||||
del args, kwargs
|
||||
yield
|
||||
|
||||
monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context)
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None)
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0)
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0)
|
||||
monkeypatch.setattr(model_runner_module.current_omni_platform, "is_available", lambda: False)
|
||||
|
||||
scheduler_output = SimpleNamespace(
|
||||
finished_req_ids=set(),
|
||||
scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)],
|
||||
scheduled_cached_reqs=SimpleNamespace(request_ids=[]),
|
||||
)
|
||||
# Admit request and run first denoise step (no chunk boundary yet).
|
||||
DiffusionModelRunner.execute_stepwise(runner, scheduler_output) # pyright: ignore[reportArgumentType]
|
||||
assert "req" in runner.state_cache
|
||||
assert runner.state_cache["req"].sampling.fps is None
|
||||
|
||||
runner.submit_interaction("req", _prompt_interaction("new scene", transition_chunks=2))
|
||||
|
||||
cached = SimpleNamespace(
|
||||
finished_req_ids=set(),
|
||||
scheduled_new_reqs=[],
|
||||
scheduled_cached_reqs=SimpleNamespace(request_ids=["req"]),
|
||||
)
|
||||
# Second step completes chunk 0: apply runs (no peek), metadata staged.
|
||||
chunk0 = DiffusionModelRunner.execute_stepwise(runner, cached).get_request_output("req") # pyright: ignore[reportArgumentType]
|
||||
assert chunk0 is not None and chunk0.result is not None
|
||||
assert chunk0.result.started_event_ids == []
|
||||
state = runner.state_cache["req"]
|
||||
assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType]
|
||||
assert state.interaction_chunk_metadata is not None
|
||||
assert state.interaction_chunk_metadata.started_event_ids == ["ui-update-1"]
|
||||
pipeline.prepare_next_chunk.assert_called() # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
# Next chunk decode attaches prior-boundary ACK ids onto DiffusionOutput.
|
||||
DiffusionModelRunner.execute_stepwise(runner, cached) # pyright: ignore[reportArgumentType]
|
||||
chunk1 = DiffusionModelRunner.execute_stepwise(runner, cached).get_request_output("req") # pyright: ignore[reportArgumentType]
|
||||
assert chunk1 is not None and chunk1.result is not None
|
||||
assert chunk1.result.started_event_ids == ["ui-update-1"]
|
||||
assert chunk1.result.active_event_ids == ["ui-update-1"]
|
||||
assert chunk1.finished is True
|
||||
|
||||
|
||||
class TestPromptUpdateIntegration:
|
||||
@@ -266,8 +506,8 @@ class TestPromptUpdateIntegration:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_prompt_update_failure_surfaces_non_fatal_error(self, pipeline: HeliosPipeline) -> None:
|
||||
"""Runner-side prepare_prompt_update rejection is reported through the orchestrator."""
|
||||
pipeline.prepare_prompt_update = MagicMock( # pyright: ignore[reportAttributeAccessIssue, reportMethodAssignment]
|
||||
"""Runner-side prompt encode rejection is reported through the orchestrator."""
|
||||
pipeline.encode_prompt = MagicMock( # pyright: ignore[reportAttributeAccessIssue]
|
||||
side_effect=ValueError("prompt embeds are not ready")
|
||||
)
|
||||
runner = _make_diffusion_model_runner(pipeline=pipeline)
|
||||
@@ -293,12 +533,7 @@ class TestPromptUpdateIntegration:
|
||||
finally:
|
||||
inline_client.shutdown()
|
||||
|
||||
pipeline.prepare_prompt_update.assert_called_once_with( # pyright: ignore[reportAttributeAccessIssue]
|
||||
runner.state_cache["req-1"],
|
||||
"new scene",
|
||||
"ui-update-1",
|
||||
2,
|
||||
)
|
||||
pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue]
|
||||
msg = output_queue.get_nowait()
|
||||
assert msg == ErrorMessage(
|
||||
error="Failed interaction for request req-1: prompt embeds are not ready",
|
||||
@@ -337,9 +572,10 @@ class TestPromptUpdateIntegration:
|
||||
|
||||
outputs = await generate_task
|
||||
|
||||
pending = runner.state_cache[internal_request_id].extra["pending_prompt_update"]
|
||||
assert pending["transition_chunks"] == 2
|
||||
assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0))
|
||||
pending = runner.state_cache[internal_request_id].interaction_sessions["prompt"].pending_event
|
||||
assert pending is not None
|
||||
assert pending.transition_chunks == 2
|
||||
assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0))
|
||||
pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue]
|
||||
assert [output.custom_output["chunk"] for output in outputs] == [0, 1]
|
||||
finally:
|
||||
@@ -544,7 +780,10 @@ class TestPromptUpdateIntegration:
|
||||
]
|
||||
|
||||
deadline = time.monotonic() + 5.0
|
||||
while "pending_prompt_update" not in state.extra:
|
||||
while (
|
||||
not isinstance(state.interaction_sessions.get("prompt"), PromptSession)
|
||||
or state.interaction_sessions["prompt"].pending_event is None
|
||||
):
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("timed out waiting for prompt update during streaming")
|
||||
await asyncio.sleep(0.01)
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests that parallel_config survives the create_default_diffusion roundtrip.
|
||||
|
||||
Regression tests for https://github.com/vllm-project/vllm-omni/issues/1862
|
||||
@@ -131,12 +131,21 @@ class TestCreateDefaultDiffusion:
|
||||
|
||||
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"
|
||||
assert stages[0]["engine_args"]["dtype"] == "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_explicit_none_cache_backend_canonicalizes_to_none(self):
|
||||
"""Regression for #7032: ``cache_backend=None`` (e.g. an example CLI default)
|
||||
must reach the pipeline as the canonical ``"none"`` string."""
|
||||
od = _roundtrip_diffusion_config(model="x", cache_backend=None)
|
||||
assert od.cache_backend == "none"
|
||||
|
||||
od = OmniDiffusionConfig.from_kwargs(model="x", cache_backend=None)
|
||||
assert od.cache_backend == "none"
|
||||
|
||||
def test_single_gpu_default_devices(self):
|
||||
stages = StageConfigFactory.create_default_diffusion({"model": "x"})
|
||||
assert stages[0]["runtime"]["devices"] == "0"
|
||||
|
||||
@@ -140,6 +140,7 @@ class TestMetricKeys:
|
||||
raise AssertionError("diffusion_engine_total_time_ms should be attached in step_streaming()")
|
||||
|
||||
assert '"diffusion_engine_exec_time_ms": exec_total_time * 1000' in step_streaming_source
|
||||
assert '"output_ready_wait_time_ms": output_ready_wait_time * 1000' in step_streaming_source
|
||||
# step_total_ms is no longer emitted as a metric (no family consumes
|
||||
# it); it lives only in the debug log breakdown below.
|
||||
assert '"diffusion_engine_total_time_ms"' not in step_streaming_source
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import asyncio
|
||||
import queue
|
||||
@@ -10,8 +10,9 @@ import pytest
|
||||
import torch
|
||||
import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers
|
||||
from pytest_mock import MockerFixture
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor
|
||||
from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec
|
||||
|
||||
from tests.helpers.kv_layout import build_kv_cache_tensor
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, DiffusionRequestAbortedError
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
@@ -104,7 +105,7 @@ def _initialize_paged_scheduler(
|
||||
)
|
||||
config = KVCacheConfig(
|
||||
num_blocks=num_blocks,
|
||||
kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=["layer0"])],
|
||||
kv_cache_tensors=[build_kv_cache_tensor(spec, num_blocks, ["layer0"])],
|
||||
kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)],
|
||||
)
|
||||
scheduler.initialize(
|
||||
@@ -276,12 +277,16 @@ class TestGetRequestBatchSamplingParamsKey:
|
||||
generator: torch.Generator | None = None,
|
||||
extra_args: dict | None = None,
|
||||
condition_key: tuple | None = None,
|
||||
guidance_scale: float | None = None,
|
||||
guidance_scale_2: float | None = None,
|
||||
) -> OmniDiffusionRequest:
|
||||
sp = OmniDiffusionSamplingParams(
|
||||
num_inference_steps=num_inference_steps,
|
||||
seed=seed,
|
||||
generator=generator,
|
||||
extra_args=extra_args or {},
|
||||
guidance_scale=guidance_scale,
|
||||
guidance_scale_2=guidance_scale_2,
|
||||
)
|
||||
return OmniDiffusionRequest(
|
||||
prompt="prompt",
|
||||
@@ -356,6 +361,21 @@ class TestGetRequestBatchSamplingParamsKey:
|
||||
self._make(condition_key=("wan22_s2v_condition", True))
|
||||
) != scheduler._build_sampling_params_key(self._make(condition_key=("wan22_s2v_condition", False)))
|
||||
|
||||
def test_distinguishes_explicit_guidance_scale_2(self) -> None:
|
||||
# An omitted guidance_scale_2 is auto-filled from guidance_scale, so a
|
||||
# request that omits it and one that passes the same value explicitly end
|
||||
# up with an identical numeric guidance_scale_2 but different
|
||||
# guidance_scale_2_provided. Pipelines read guidance_scale_2_provided from
|
||||
# the batch's first request to gate image guidance, so the two must not
|
||||
# share a request batch.
|
||||
scheduler = RequestScheduler()
|
||||
omitted = self._make(guidance_scale=2.0)
|
||||
explicit = self._make(guidance_scale=2.0, guidance_scale_2=2.0)
|
||||
|
||||
assert omitted.sampling_params.guidance_scale_2 == explicit.sampling_params.guidance_scale_2
|
||||
assert omitted.sampling_params.guidance_scale_2_provided != explicit.sampling_params.guidance_scale_2_provided
|
||||
assert scheduler._build_sampling_params_key(omitted) != scheduler._build_sampling_params_key(explicit)
|
||||
|
||||
|
||||
class TestRequestScheduler:
|
||||
def setup_method(self) -> None:
|
||||
|
||||
@@ -15,6 +15,7 @@ from pytest_mock import MockerFixture
|
||||
|
||||
import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.config.config_factory import StageConfigFactory
|
||||
from vllm_omni.diffusion.data import DiffusionOutput
|
||||
from vllm_omni.diffusion.diffusion_engine import DiffusionEngine
|
||||
from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode
|
||||
@@ -46,7 +47,6 @@ from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunn
|
||||
from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker
|
||||
from vllm_omni.diffusion.worker.input_batch import InputBatch
|
||||
from vllm_omni.diffusion.worker.utils import RunnerOutput, StepRequestState
|
||||
from vllm_omni.engine.async_omni_engine import AsyncOmniEngine
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
@@ -1182,7 +1182,7 @@ class TestSupportedPipelines:
|
||||
"""Step-execution protocol checks for supported pipelines."""
|
||||
|
||||
def test_default_stage_config_includes_step_execution(self):
|
||||
stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(
|
||||
stage_cfg = StageConfigFactory.create_default_diffusion(
|
||||
{
|
||||
"step_execution": True,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import hashlib
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.utils.media_utils import ChunkedMP4Encoder, mux_av_video_audio_bytes
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _frames() -> np.ndarray:
|
||||
rng = np.random.default_rng(6872)
|
||||
return rng.integers(0, 256, size=(8, 16, 24, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def test_chunked_mp4_is_byte_identical_to_whole_mux() -> None:
|
||||
frames = _frames()
|
||||
baseline = mux_av_video_audio_bytes(
|
||||
(av.VideoFrame.from_ndarray(frame, format="rgb24") for frame in frames),
|
||||
width=24,
|
||||
height=16,
|
||||
fps=24,
|
||||
video_codec_options={"threads": "1"},
|
||||
)
|
||||
encoder = ChunkedMP4Encoder(
|
||||
width=24,
|
||||
height=16,
|
||||
fps=24,
|
||||
max_pending=2,
|
||||
video_codec_options={"threads": "1"},
|
||||
)
|
||||
for start in (0, 1, 5):
|
||||
stop = {0: 1, 1: 5, 5: len(frames)}[start]
|
||||
encoder.push(np.ascontiguousarray(frames[start:stop]))
|
||||
chunked = encoder.finish()
|
||||
assert chunked == baseline
|
||||
assert hashlib.sha256(chunked).digest() == hashlib.sha256(baseline).digest()
|
||||
|
||||
audio = np.zeros((2, 320), dtype=np.float32)
|
||||
baseline_audio = mux_av_video_audio_bytes(
|
||||
(av.VideoFrame.from_ndarray(frame, format="rgb24") for frame in frames),
|
||||
width=24,
|
||||
height=16,
|
||||
fps=24,
|
||||
audio_waveform=audio,
|
||||
audio_sample_rate=32000,
|
||||
video_codec_options={"threads": "1"},
|
||||
)
|
||||
encoder_audio = ChunkedMP4Encoder(
|
||||
width=24,
|
||||
height=16,
|
||||
fps=24,
|
||||
audio_waveform=audio,
|
||||
audio_sample_rate=32000,
|
||||
video_codec_options={"threads": "1"},
|
||||
)
|
||||
encoder_audio.push(frames)
|
||||
assert encoder_audio.finish() == baseline_audio
|
||||
|
||||
|
||||
def test_chunked_mp4_abort_joins_worker() -> None:
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24)
|
||||
encoder.push(_frames()[:1])
|
||||
encoder.abort()
|
||||
assert not encoder._thread.is_alive()
|
||||
with pytest.raises(RuntimeError, match="already closed"):
|
||||
encoder.push(_frames()[:1])
|
||||
|
||||
|
||||
def test_chunked_mp4_validates_shape_and_dtype() -> None:
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24)
|
||||
with pytest.raises(ValueError, match="shape"):
|
||||
encoder.push(np.zeros((16, 24, 3), dtype=np.uint8))
|
||||
with pytest.raises(ValueError, match="dtype"):
|
||||
encoder.push(np.zeros((1, 16, 24, 3), dtype=np.float32))
|
||||
encoder.abort()
|
||||
|
||||
|
||||
def test_chunked_mp4_close_returns_encoded_bytes() -> None:
|
||||
import io
|
||||
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24)
|
||||
encoder.push(_frames())
|
||||
result = encoder.close()
|
||||
assert result == encoder.finish() == encoder.close()
|
||||
with av.open(io.BytesIO(result)) as container:
|
||||
assert len(list(container.decode(video=0))) == len(_frames())
|
||||
|
||||
|
||||
def test_chunked_mp4_failure_during_push(monkeypatch) -> None:
|
||||
from threading import Event
|
||||
|
||||
from vllm_omni.diffusion.utils import media_utils
|
||||
|
||||
fail = Event()
|
||||
draining = Event()
|
||||
error = RuntimeError("encoding failed")
|
||||
|
||||
def mux(*args, **kwargs):
|
||||
assert fail.wait(5)
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux)
|
||||
drain = ChunkedMP4Encoder._drain_until_done
|
||||
|
||||
def notified_drain(self):
|
||||
draining.set()
|
||||
drain(self)
|
||||
|
||||
monkeypatch.setattr(ChunkedMP4Encoder, "_drain_until_done", notified_drain)
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24)
|
||||
put = encoder._queue.put
|
||||
|
||||
def fail_before_put(item):
|
||||
fail.set()
|
||||
assert draining.wait(5)
|
||||
put(item)
|
||||
|
||||
monkeypatch.setattr(encoder._queue, "put", fail_before_put)
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="encoding failed") as exc:
|
||||
encoder.push(_frames())
|
||||
assert exc.value is error
|
||||
with pytest.raises(RuntimeError, match="encoding failed"):
|
||||
encoder.finish()
|
||||
finally:
|
||||
fail.set()
|
||||
encoder.abort()
|
||||
assert not encoder._thread.is_alive()
|
||||
|
||||
|
||||
def test_chunked_mp4_flush_failure_does_not_hang(monkeypatch) -> None:
|
||||
from threading import Thread
|
||||
|
||||
from vllm_omni.diffusion.utils import media_utils
|
||||
|
||||
error = RuntimeError("flush failed")
|
||||
|
||||
def mux(frames, **kwargs):
|
||||
list(frames)
|
||||
raise error
|
||||
|
||||
monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux)
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24)
|
||||
encoder.push(_frames())
|
||||
errors = []
|
||||
|
||||
def finish():
|
||||
try:
|
||||
encoder.finish()
|
||||
except RuntimeError as exc:
|
||||
errors.append(exc)
|
||||
|
||||
closer = Thread(target=finish, daemon=True)
|
||||
closer.start()
|
||||
closer.join(5)
|
||||
try:
|
||||
assert not closer.is_alive(), "finish hung after the worker consumed its sentinel"
|
||||
assert errors == [error]
|
||||
finally:
|
||||
if closer.is_alive():
|
||||
encoder._send_done()
|
||||
closer.join(5)
|
||||
|
||||
|
||||
def test_chunked_mp4_abort_discards_pending_chunks(monkeypatch) -> None:
|
||||
from threading import Event, Thread
|
||||
|
||||
from vllm_omni.diffusion.utils import media_utils
|
||||
|
||||
entered = Event()
|
||||
release = Event()
|
||||
done_sent = Event()
|
||||
encoded = []
|
||||
|
||||
def mux(frames, **kwargs):
|
||||
encoded.append(next(frames))
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
encoded.extend(frames)
|
||||
return b"unused"
|
||||
|
||||
monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux)
|
||||
encoder = ChunkedMP4Encoder(width=24, height=16, fps=24, max_pending=1)
|
||||
send_done = encoder._send_done
|
||||
|
||||
def notified_done():
|
||||
send_done()
|
||||
done_sent.set()
|
||||
|
||||
monkeypatch.setattr(encoder, "_send_done", notified_done)
|
||||
encoder.push(_frames()[:1])
|
||||
assert entered.wait(5)
|
||||
encoder.push(_frames()[1:])
|
||||
closer = Thread(target=encoder.abort, daemon=True)
|
||||
closer.start()
|
||||
try:
|
||||
assert done_sent.wait(5), "abort waited for encoding before discarding the pending queue"
|
||||
finally:
|
||||
release.set()
|
||||
closer.join(5)
|
||||
assert not closer.is_alive()
|
||||
assert len(encoded) == 1
|
||||
with pytest.raises(RuntimeError, match="aborted"):
|
||||
encoder.finish()
|
||||
Reference in new issue
Block a user