Merge remote-tracking branch 'origin/main' into qwen-image-2.1
# Conflicts: # vllm_omni/diffusion/executor/multiproc_executor.py # vllm_omni/diffusion/offloader/sequential_backend.py
This commit is contained in:
commit
9174554e40
1332 files changed
+160051
-60504
No files matched your search
@@ -323,12 +323,18 @@ def test_lingbot_like_sink_survives_sliding_window_eviction():
|
||||
kv = runner.kv_cache
|
||||
assert kv is not None
|
||||
|
||||
for _ in range(8):
|
||||
state = commit_one_frame(runner, "s1", "main")
|
||||
sink_block = kv.block_table(state.adapter("main"))[0]
|
||||
for _ in range(7):
|
||||
state = commit_one_frame(runner, "s1", "main")
|
||||
|
||||
table = kv.block_table(state.adapter("main"))
|
||||
assert table[0] != kv.null_block_id
|
||||
assert table[1] == kv.null_block_id
|
||||
adapter = state.adapter("main")
|
||||
table = kv.block_table(adapter)
|
||||
assert len(table) == 6 # one sink + five recent frames
|
||||
assert table[0] == sink_block
|
||||
assert kv.null_block_id not in table
|
||||
assert adapter.absolute_num_computed_tokens == 8 * BLOCK
|
||||
assert adapter.num_computed_tokens == 6 * BLOCK
|
||||
|
||||
ctx = state.get_kv_caches("main", seq_len=BLOCK, commit_current=False)[0].forward_ctx
|
||||
visible, _ = ctx.video_block_table(torch.device("cpu"))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for the AR-Diffusion KV cache helpers (Phase 1, PR-2).
|
||||
|
||||
Covers the request adapter, the chunk-window spec/manager (registration + the
|
||||
@@ -269,9 +270,14 @@ def test_compiled_paged_write_does_not_clone_the_pool():
|
||||
# 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"], (
|
||||
# The staging buffers are mutated too when reuse_history_staging is on, but they are separate
|
||||
# allocations and not what this test is about; the pools are what must not be cloned.
|
||||
assert real_mutated[:2] == ["key_pool", "value_pool"], (
|
||||
f"the real paged-write op now mutates {real_mutated}; this test still models two pools"
|
||||
)
|
||||
assert set(real_mutated) - {"key_pool", "value_pool"} <= {"stage_key", "stage_value"}, (
|
||||
f"unexpected mutated argument in the real paged-write op: {real_mutated}"
|
||||
)
|
||||
|
||||
num_blocks, heads, dim = 8, 4, 16
|
||||
cache_shape = (num_blocks, BLOCK, heads, dim)
|
||||
@@ -357,6 +363,7 @@ def _make_tiny_capacity_kv(
|
||||
available_bytes: int,
|
||||
gpu_memory_fraction: float = 1.0,
|
||||
model_owned_state_bytes_per_session: int = 0,
|
||||
reuse_history_staging: bool = False,
|
||||
) -> ARDiffusionKVCache:
|
||||
return ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(
|
||||
@@ -365,6 +372,7 @@ def _make_tiny_capacity_kv(
|
||||
window_chunks=3,
|
||||
sink_chunks=3,
|
||||
gpu_memory_fraction=gpu_memory_fraction,
|
||||
reuse_history_staging=reuse_history_staging,
|
||||
),
|
||||
num_layers=1,
|
||||
num_kv_heads=1,
|
||||
@@ -382,6 +390,33 @@ def _make_tiny_capacity_kv(
|
||||
)
|
||||
|
||||
|
||||
def test_history_staging_is_reserved_in_the_kv_budget(monkeypatch):
|
||||
"""The per-layer staging pair is worker-wide memory allocated after admission; the budget must hold it."""
|
||||
monkeypatch.setenv("VLLM_OMNI_AR_DIFFUSION_KV_GATHER", "1")
|
||||
# Same geometry as the capacity-two test: 192 bytes fits two sessions exactly without staging.
|
||||
plain = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192)
|
||||
assert plain.session_capacity == 2 and plain.history_staging_reserved_bytes == 0
|
||||
|
||||
staged = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192, reuse_history_staging=True)
|
||||
# 2 (K, V) x 1 layer x (sink 3 + window 3 + one action-capacity block) tokens x 1 head x 1 dim x 4 bytes.
|
||||
assert staged.history_staging_reserved_bytes == 2 * 1 * 7 * 1 * 1 * 4
|
||||
# It comes out of the same budget, so the second session no longer fits.
|
||||
assert staged.session_capacity == 1
|
||||
assert staged.num_blocks_total * 8 + staged.history_staging_reserved_bytes <= 192
|
||||
|
||||
# Boundary: one session fits at 128 bytes without staging; with staging the same budget is rejected
|
||||
# up front instead of failing on the first forward.
|
||||
_make_tiny_capacity_kv(requested_capacity=1, available_bytes=128)
|
||||
with pytest.raises(ValueError, match="cannot fit one session"):
|
||||
_make_tiny_capacity_kv(requested_capacity=1, available_bytes=128, reuse_history_staging=True)
|
||||
|
||||
|
||||
def test_history_staging_is_not_reserved_when_the_gather_path_is_off(monkeypatch):
|
||||
monkeypatch.delenv("VLLM_OMNI_AR_DIFFUSION_KV_GATHER", raising=False)
|
||||
staged = _make_tiny_capacity_kv(requested_capacity=2, available_bytes=192, reuse_history_staging=True)
|
||||
assert staged.history_staging_reserved_bytes == 0 and staged.session_capacity == 2
|
||||
|
||||
|
||||
def test_capacity_two_retains_both_windows_and_allocates_next_block():
|
||||
# LingBot-like single-branch geometry: page=8 bytes; capacity=2 requires
|
||||
# 17 managed + 3 scratch blocks, plus two 16-byte cross-attention
|
||||
@@ -588,3 +623,70 @@ def test_non_contiguous_branch_indices_rejected():
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 1),),
|
||||
session_capacity=1,
|
||||
)
|
||||
|
||||
|
||||
def test_staged_window_reuse_matches_a_full_gather():
|
||||
"""Reusing the staged history must be byte-identical to re-gathering the whole window.
|
||||
|
||||
This is the property the optimisation rests on: the leading blocks were staged by an earlier forward of
|
||||
the same AR block, so skipping them can only be correct if what is already there equals what a fresh
|
||||
gather would write. The test stages once, mutates only the current chunk's blocks in the pool (what a
|
||||
later probe of the same block does), restages the tail alone, and compares against a full gather.
|
||||
"""
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import _stage_window
|
||||
|
||||
torch.manual_seed(0)
|
||||
num_blocks, block_size, heads, dim = 6, 4, 2, 8
|
||||
current_blocks = 2
|
||||
key_cache = torch.randn(num_blocks, block_size, heads, dim)
|
||||
value_cache = torch.randn(num_blocks, block_size, heads, dim)
|
||||
block_ids = torch.tensor([5, 4, 3, 2, 1, 0])
|
||||
stage_key = torch.zeros(num_blocks * block_size, heads, dim)
|
||||
stage_value = torch.zeros(num_blocks * block_size, heads, dim)
|
||||
|
||||
_stage_window(stage_key, stage_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=0)
|
||||
|
||||
# A later probe rewrites only the current chunk's blocks in the pool.
|
||||
for slot in block_ids[num_blocks - current_blocks :]:
|
||||
key_cache[slot] = torch.randn(block_size, heads, dim)
|
||||
value_cache[slot] = torch.randn(block_size, heads, dim)
|
||||
|
||||
_stage_window(
|
||||
stage_key,
|
||||
stage_value,
|
||||
key_cache,
|
||||
value_cache,
|
||||
block_ids,
|
||||
num_blocks,
|
||||
block_size,
|
||||
first_block=num_blocks - current_blocks,
|
||||
)
|
||||
|
||||
full_key = torch.zeros_like(stage_key)
|
||||
full_value = torch.zeros_like(stage_value)
|
||||
_stage_window(full_key, full_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=0)
|
||||
|
||||
torch.testing.assert_close(stage_key, full_key, rtol=0, atol=0)
|
||||
torch.testing.assert_close(stage_value, full_value, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_staged_window_tail_refresh_leaves_history_untouched():
|
||||
"""The tail restage must not write the history rows -- that is what makes it cheaper."""
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.paged_attention import _stage_window
|
||||
|
||||
torch.manual_seed(1)
|
||||
num_blocks, block_size, heads, dim = 5, 4, 1, 4
|
||||
key_cache = torch.randn(num_blocks, block_size, heads, dim)
|
||||
value_cache = torch.randn(num_blocks, block_size, heads, dim)
|
||||
block_ids = torch.arange(num_blocks)
|
||||
stage_key = torch.full((num_blocks * block_size, heads, dim), -1.0)
|
||||
stage_value = torch.full((num_blocks * block_size, heads, dim), -1.0)
|
||||
|
||||
_stage_window(
|
||||
stage_key, stage_value, key_cache, value_cache, block_ids, num_blocks, block_size, first_block=num_blocks - 1
|
||||
)
|
||||
|
||||
history_rows = (num_blocks - 1) * block_size
|
||||
assert (stage_key[:history_rows] == -1.0).all(), "history rows were rewritten by a tail restage"
|
||||
assert (stage_value[:history_rows] == -1.0).all()
|
||||
torch.testing.assert_close(stage_key[history_rows:], key_cache[-1].reshape(block_size, heads, dim), rtol=0, atol=0)
|
||||
@@ -6,6 +6,7 @@ import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
DIMS = dict(
|
||||
@@ -108,3 +109,104 @@ def test_state_close_frees_both_branch_blocks():
|
||||
state.close()
|
||||
# All blocks returned — no leak across an evicted session.
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"frames_per_call,block_size,max_model_len,num_calls",
|
||||
[(1, 16, 113, 1000), (3, 16, 113, 1000), (3, 1560, 1 << 20, 241)],
|
||||
ids=["single-frame", "three-frame", "lingbot-two-minutes"],
|
||||
)
|
||||
@pytest.mark.parametrize("sink_chunks,reset_at_boundary", [(0, False), (2, False), (0, True), (2, True)])
|
||||
def test_rollout_keeps_metadata_bounded(
|
||||
frames_per_call, block_size, max_model_len, num_calls, sink_chunks, reset_at_boundary
|
||||
):
|
||||
"""Long rollouts retain exact global-position K/V with bounded storage indices."""
|
||||
kv = ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(
|
||||
enable=True,
|
||||
chunk_size=block_size,
|
||||
window_chunks=3,
|
||||
sink_chunks=sink_chunks,
|
||||
reset_at_boundary=reset_at_boundary,
|
||||
),
|
||||
num_layers=1,
|
||||
num_kv_heads=1,
|
||||
head_size=4,
|
||||
dtype=torch.float32,
|
||||
block_size=block_size,
|
||||
max_model_len=max_model_len,
|
||||
available_bytes=1 << 24,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 0),),
|
||||
session_capacity=1,
|
||||
frames_per_block=frames_per_call,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
adapter = kv.begin_request("long-rollout")
|
||||
state = ARDiffusionKVState(kv, "long-rollout", {"main": adapter}, num_layers=1)
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
pool_shape = kv.key_cache(0).shape
|
||||
retained_frames = sink_chunks + (0 if reset_at_boundary else 3)
|
||||
for call_index in range(num_calls):
|
||||
start = call_index * frames_per_call * block_size
|
||||
stop = start + frames_per_call * block_size
|
||||
if frames_per_call == 1:
|
||||
kv.allocate_chunk(adapter)
|
||||
slots = kv.chunk_write_slots(adapter)
|
||||
else:
|
||||
context = state.get_kv_caches("main", seq_len=stop - start, commit_current=True)[0].forward_ctx
|
||||
context.ensure_video_slots(torch.device("cpu"))
|
||||
slots = context.current_video_slot_mapping
|
||||
assert len(kv.block_table(adapter)) <= retained_frames + frames_per_call
|
||||
values = torch.arange(start, stop, dtype=torch.float32).view(-1, 1, 1).expand(-1, 1, 4)
|
||||
kv.key_cache(0).flatten(0, 1)[slots] = values
|
||||
kv.value_cache(0).flatten(0, 1)[slots] = -values
|
||||
if frames_per_call == 1:
|
||||
kv.commit_chunk(adapter)
|
||||
else:
|
||||
state.commit_paged_context("main")
|
||||
assert state._committed["main"] == stop
|
||||
sink_end = min(stop, sink_chunks * block_size)
|
||||
tail_start = stop if reset_at_boundary else max(sink_end, stop - 3 * block_size)
|
||||
expected = torch.cat((torch.arange(sink_end), torch.arange(tail_start, stop))).float()
|
||||
expected = expected.view(-1, 1, 1).expand(-1, 1, 4)
|
||||
resident = kv.window_block_ids(adapter)
|
||||
torch.testing.assert_close(kv.key_cache(0)[resident].flatten(0, 1), expected)
|
||||
torch.testing.assert_close(kv.value_cache(0)[resident].flatten(0, 1), -expected)
|
||||
assert adapter.completed_chunks * block_size == stop
|
||||
assert adapter.absolute_num_computed_tokens == stop
|
||||
assert len(kv.block_table(adapter)) <= retained_frames
|
||||
assert adapter.num_computed_tokens <= retained_frames * block_size
|
||||
assert kv.key_cache(0).shape == pool_shape
|
||||
assert free_total - kv.manager.block_pool.get_num_free_blocks() == min(stop // block_size, retained_frames)
|
||||
state.reset()
|
||||
fresh = state.adapter("main")
|
||||
assert fresh.compacted_tokens == fresh.absolute_num_computed_tokens == fresh.num_computed_tokens == 0
|
||||
assert kv.block_table(fresh) == []
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
kv.allocate_chunk(fresh)
|
||||
assert len(kv.chunk_write_slots(fresh)) == block_size
|
||||
state.close()
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
|
||||
|
||||
def test_compaction_offsets_are_request_local():
|
||||
kv = make_cache()
|
||||
free_total = kv.manager.block_pool.get_num_free_blocks()
|
||||
first, second = kv.begin_request("first"), kv.begin_request("second")
|
||||
for index in range(8):
|
||||
kv.allocate_chunk(first)
|
||||
kv.commit_chunk(first)
|
||||
if index in (0, 4):
|
||||
kv.allocate_chunk(second)
|
||||
kv.commit_chunk(second)
|
||||
assert first.absolute_num_computed_tokens == 8 * 16
|
||||
assert first.compacted_tokens == 6 * 16
|
||||
assert second.absolute_num_computed_tokens == 2 * 16
|
||||
assert second.compacted_tokens == 0
|
||||
assert first.num_computed_tokens == second.num_computed_tokens == 2 * 16
|
||||
assert set(kv.block_table(first)).isdisjoint(kv.block_table(second))
|
||||
kv.end_request(first)
|
||||
kv.end_request(second)
|
||||
group = kv.manager.coordinator.single_type_managers[0]
|
||||
assert not group.req_to_blocks and not group.num_cached_block
|
||||
assert kv.manager.block_pool.get_num_free_blocks() == free_total
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Tests for AR-Diffusion paged self-attention contexts."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -20,6 +21,8 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import (
|
||||
ar_diffusion_paged_attention,
|
||||
paged_write_attn,
|
||||
)
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import paged_attention as paged_attention_module
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.config import KV_GATHER_ENV
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
@@ -32,8 +35,12 @@ POS = "positive"
|
||||
NEG = "negative"
|
||||
|
||||
|
||||
def make_state(*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu")):
|
||||
cfg = ARDiffusionKVConfig(enable=True, chunk_size=BLOCK, window_chunks=window_chunks)
|
||||
def make_state(
|
||||
*, num_layers=1, window_chunks=2, dtype=torch.float32, device=torch.device("cpu"), reuse_history_staging=False
|
||||
):
|
||||
cfg = ARDiffusionKVConfig(
|
||||
enable=True, chunk_size=BLOCK, window_chunks=window_chunks, reuse_history_staging=reuse_history_staging
|
||||
)
|
||||
kv = ARDiffusionKVCache(
|
||||
cfg,
|
||||
num_layers=num_layers,
|
||||
@@ -236,6 +243,86 @@ def test_paged_attention_matches_dense_reference_cpu(history_chunks, action_len,
|
||||
assert st.adapter(POS).completed_chunks == before + (1 if commit_current else 0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("history_chunks", [0, 1, 2, 3])
|
||||
def test_staged_reuse_refreshes_the_current_blocks_at_their_live_offset(monkeypatch, history_chunks):
|
||||
"""A second probe of the same AR block restages its current K/V where the table actually holds it.
|
||||
|
||||
The padded table always ends in at least one action-capacity block, and while the window is still
|
||||
growing in unused window capacity too, so "the last current_blocks entries" is padding. The refresh
|
||||
has to start right after the visible history: empty (window growing), partial, full, and after a slide.
|
||||
"""
|
||||
monkeypatch.setenv(KV_GATHER_ENV, "1")
|
||||
device = torch.device("cpu")
|
||||
dtype = torch.float32
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2, reuse_history_staging=True)
|
||||
if history_chunks:
|
||||
_commit_video_span(kv, st, kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device)
|
||||
|
||||
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
|
||||
ctx.ensure_video_slots(device)
|
||||
key_cache, value_cache = kv.key_cache(0), kv.value_cache(0)
|
||||
|
||||
def probe(step: int):
|
||||
# Each probe of the block writes new current K/V, then stages.
|
||||
kv._k_pools[0][ctx.current_video_slot_mapping] = torch.full((BLOCK, N_HEADS, HEAD_DIM), float(step))
|
||||
kv._v_pools[0][ctx.current_video_slot_mapping] = torch.full((BLOCK, N_HEADS, HEAD_DIM), -float(step))
|
||||
# Store the metadata on the context the way prepare() does.
|
||||
(ctx.block_table, ctx.query_start_loc, ctx.seq_lens, ctx.max_query_len, ctx.max_seq_len) = (
|
||||
ctx.build_block_table(action_len=0, query_len=BLOCK, device=device)
|
||||
)
|
||||
block_table, max_seq_len = ctx.block_table, ctx.max_seq_len
|
||||
ctx._prepare_history_staging(0)
|
||||
n_blocks = max_seq_len // BLOCK
|
||||
block_ids = block_table[0, :n_blocks].to(torch.long)
|
||||
stage_k, stage_v = ctx.history_staging(0)
|
||||
paged_attention_module._stage_window(
|
||||
stage_k,
|
||||
stage_v,
|
||||
key_cache,
|
||||
value_cache,
|
||||
block_ids,
|
||||
n_blocks,
|
||||
BLOCK,
|
||||
first_block=ctx.stage_first_block if ctx.reuse_history else 0,
|
||||
)
|
||||
full_k = key_cache.index_select(0, block_ids).reshape(n_blocks * BLOCK, N_HEADS, HEAD_DIM)
|
||||
full_v = value_cache.index_select(0, block_ids).reshape(n_blocks * BLOCK, N_HEADS, HEAD_DIM)
|
||||
return stage_k, stage_v, full_k, full_v, n_blocks
|
||||
|
||||
stage_k, stage_v, full_k, full_v, n_blocks = probe(1)
|
||||
assert ctx.reuse_history is False
|
||||
assert torch.equal(stage_k, full_k) and torch.equal(stage_v, full_v)
|
||||
|
||||
stage_k, stage_v, full_k, full_v, n_blocks = probe(2)
|
||||
assert ctx.reuse_history is True
|
||||
visible_history_blocks = min(history_chunks, 2 - 1) # window of 2 blocks minus the current one
|
||||
assert ctx.stage_first_block == visible_history_blocks
|
||||
# The padded table is wider than the live window, so the end-of-table guess is not the offset.
|
||||
assert n_blocks - 1 != ctx.stage_first_block
|
||||
assert torch.equal(stage_k, full_k) and torch.equal(stage_v, full_v)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gather_enabled", [False, True])
|
||||
def test_staging_is_only_allocated_for_the_gather_path(monkeypatch, gather_enabled):
|
||||
if gather_enabled:
|
||||
monkeypatch.setenv(KV_GATHER_ENV, "1")
|
||||
else:
|
||||
monkeypatch.delenv(KV_GATHER_ENV, raising=False)
|
||||
device = torch.device("cpu")
|
||||
kv, st = make_state(device=device, reuse_history_staging=True)
|
||||
# The manager owns the pairs and allocates them only when their consumer is on.
|
||||
assert bool(kv.history_staging) is gather_enabled
|
||||
ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0].forward_ctx
|
||||
ctx.ensure_video_slots(device)
|
||||
(ctx.block_table, ctx.query_start_loc, ctx.seq_lens, ctx.max_query_len, ctx.max_seq_len) = ctx.build_block_table(
|
||||
action_len=0, query_len=BLOCK, device=device
|
||||
)
|
||||
ctx._prepare_history_staging(0)
|
||||
assert ctx.staging_enabled is gather_enabled
|
||||
stage_k, _ = ctx.history_staging(0)
|
||||
assert (stage_k is not None) is gather_enabled
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _gpu_flash_attn_usable(), reason="usable GPU FlashAttention is required")
|
||||
@pytest.mark.parametrize("history_chunks", [1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
@@ -376,13 +463,27 @@ def test_custom_op_registration_idempotent():
|
||||
assert hasattr(torch.ops.vllm_omni, "ar_diffusion_paged_write_attn")
|
||||
|
||||
|
||||
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
|
||||
def test_custom_op_mutable_arguments_cannot_be_elided_as_defaults():
|
||||
# Older PyTorch ADInplaceOrView handlers index positional mutable inputs
|
||||
# directly. Default-valued trailing inputs can disappear before that handler.
|
||||
schema = torch.ops.vllm_omni.ar_diffusion_paged_write_attn.default._schema
|
||||
mutable = [arg for arg in schema.arguments if arg.alias_info is not None and arg.alias_info.is_write]
|
||||
assert {arg.name for arg in mutable} == {"key_pool", "value_pool", "stage_key", "stage_value"}
|
||||
assert all(not arg.has_default_value() for arg in mutable)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reuse_history_staging", [False, True])
|
||||
def test_custom_op_compiles_fullgraph_without_recompile_on_value_change(monkeypatch, reuse_history_staging):
|
||||
"""The op must trace as one opaque node: fullgraph OK, and changed tensor
|
||||
VALUES (new slots / block ids) must not trigger recompilation."""
|
||||
import torch._dynamo
|
||||
|
||||
device = torch.device("cpu")
|
||||
kv, st = make_state(num_layers=2, window_chunks=2)
|
||||
monkeypatch.setenv(KV_GATHER_ENV, "1")
|
||||
kv, st = make_state(num_layers=2, window_chunks=2, reuse_history_staging=reuse_history_staging)
|
||||
if reuse_history_staging:
|
||||
# Hold the host-side staging offset fixed while table/slot values change.
|
||||
_commit_video_span(kv, st, kv_branch=POS, n_chunks=2, dtype=torch.float32, device=device)
|
||||
|
||||
def run_one_forward(commit):
|
||||
contexts = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=commit)
|
||||
@@ -391,9 +492,8 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
|
||||
q = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
k = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
v = torch.randn(BLOCK, N_HEADS, HEAD_DIM)
|
||||
# Both layers through ONE compiled fn: layer_idx is a tensor, so a
|
||||
# different layer must NOT recompile (all 40 DiT blocks share the
|
||||
# block-forward code object in production).
|
||||
# Both layers use one compiled function; the tensor-valued layer index
|
||||
# must not specialize it.
|
||||
for layer_ctx in contexts:
|
||||
out = compiled(layer_ctx.to_layer_inputs(), q, k, v)
|
||||
st.commit_paged_context(POS)
|
||||
@@ -419,3 +519,52 @@ def test_custom_op_compiles_fullgraph_without_recompile_on_value_change():
|
||||
# Leave a clean dynamo state for later suites in the same pytest process
|
||||
# (e.g. model_executor transformers models).
|
||||
torch._dynamo.reset()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _gpu_flash_attn_usable(), reason="usable GPU FlashAttention is required")
|
||||
@pytest.mark.parametrize("history_chunks", [0, 1, 3])
|
||||
@pytest.mark.parametrize("action_len", [0, 3])
|
||||
def test_contiguous_kv_gather_path_matches_paged_path_gpu(monkeypatch, history_chunks, action_len):
|
||||
"""VLLM_OMNI_AR_DIFFUSION_KV_GATHER=1 gathers the visible blocks and runs varlen FA3 without a block table.
|
||||
|
||||
Covers an empty, partial and full window plus a partially filled action block:
|
||||
the gather reads the tail-padding null block, so its (zeroed) rows must be masked.
|
||||
"""
|
||||
|
||||
torch.manual_seed(0)
|
||||
device = torch.device("cuda")
|
||||
dtype = torch.float16
|
||||
kv, st = make_state(dtype=dtype, device=device, window_chunks=2)
|
||||
if history_chunks:
|
||||
_commit_video_span(kv, st, kv_branch=POS, n_chunks=history_chunks, dtype=dtype, device=device)
|
||||
|
||||
layer_ctx = st.get_kv_caches(POS, seq_len=BLOCK, commit_current=False)[0]
|
||||
ctx = layer_ctx.forward_ctx
|
||||
current_k = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
current_v = torch.randn(1, BLOCK, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_k = action_v = None
|
||||
if action_len:
|
||||
action_k = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
action_v = torch.randn(1, action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
query = torch.randn(1, BLOCK + action_len, N_HEADS, HEAD_DIM, dtype=dtype, device=device)
|
||||
ctx.prepare(device=device, action_len=action_len, query_len=query.shape[1])
|
||||
inputs = layer_ctx.to_layer_inputs()
|
||||
|
||||
def run() -> torch.Tensor:
|
||||
return paged_write_attn(
|
||||
inputs,
|
||||
query[0],
|
||||
current_k[0],
|
||||
current_v[0],
|
||||
action_k[0] if action_len else None,
|
||||
action_v[0] if action_len else None,
|
||||
HEAD_DIM**-0.5,
|
||||
).unsqueeze(0)
|
||||
|
||||
monkeypatch.delenv(KV_GATHER_ENV, raising=False)
|
||||
paged = run()
|
||||
monkeypatch.setenv(KV_GATHER_ENV, "1")
|
||||
gathered = run()
|
||||
assert torch.isfinite(gathered).all()
|
||||
# Same kernel family on the same K/V: only accumulation order differs.
|
||||
torch.testing.assert_close(gathered, paged, rtol=2e-3, atol=2e-3)
|
||||
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Streaming VAE decode against the real Wan autoencoder, on CPU.
|
||||
|
||||
The autoencoder is built from a config rather than a checkpoint -- weights are
|
||||
random, only shapes and the temporal cache protocol matter -- so these run
|
||||
without a device or a download.
|
||||
|
||||
The load-bearing test is equivalence: decoding a session chunk by chunk must
|
||||
produce exactly what decoding the whole clip produces. Everything else about
|
||||
streaming is worthless if that does not hold.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.experimental.ar_diffusion.streaming_decode import (
|
||||
StreamingDecodeState,
|
||||
SupportsStreamingDecode,
|
||||
WanStreamingDecoder,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Small enough to decode on CPU in a test, same architecture as the shipped
|
||||
# checkpoint: base_dim 96 / dim_mult [1,2,4,4] would be far too slow, so the
|
||||
# widths are scaled down while the causal structure is unchanged.
|
||||
VAE_CONFIG = {
|
||||
"base_dim": 8,
|
||||
"z_dim": 4,
|
||||
"dim_mult": [1, 2],
|
||||
"num_res_blocks": 1,
|
||||
"temperal_downsample": [True],
|
||||
"attn_scales": [],
|
||||
"dropout": 0.0,
|
||||
}
|
||||
LATENT_H = LATENT_W = 4
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vae():
|
||||
diffusers = pytest.importorskip("diffusers")
|
||||
torch.manual_seed(0)
|
||||
model = diffusers.AutoencoderKLWan(**VAE_CONFIG).eval()
|
||||
for parameter in model.parameters():
|
||||
parameter.requires_grad_(False)
|
||||
return model
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def decoder(vae):
|
||||
return WanStreamingDecoder(vae)
|
||||
|
||||
|
||||
def _latent(num_frames: int, *, seed: int = 0) -> torch.Tensor:
|
||||
generator = torch.Generator().manual_seed(seed)
|
||||
return torch.randn(1, VAE_CONFIG["z_dim"], num_frames, LATENT_H, LATENT_W, generator=generator)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def _decode_whole(vae, latent: torch.Tensor) -> torch.Tensor:
|
||||
"""The non-streaming reference: one call, cache cleared at both ends."""
|
||||
return vae._decode(latent, return_dict=False)[0]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Equivalence: the property that makes streaming worth doing at all
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stream(decoder, latent: torch.Tensor, chunk_sizes, *, session_id: str = "s") -> torch.Tensor:
|
||||
state = decoder.new_decode_state(session_id)
|
||||
pieces, offset = [], 0
|
||||
for size in chunk_sizes:
|
||||
pieces.append(decoder.decode_chunk(latent[:, :, offset : offset + size], state))
|
||||
offset += size
|
||||
return torch.cat(pieces, dim=2)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
@pytest.mark.parametrize("chunk_sizes", [(3, 3, 3), (1, 1, 1, 1, 1), (2, 4, 3)])
|
||||
def test_chunking_is_neutral(vae, decoder, chunk_sizes) -> None:
|
||||
"""Splitting a session into chunks must change nothing about its output.
|
||||
|
||||
This is the property streaming actually introduces, so it is the one
|
||||
asserted exactly. The control is the same session decoded in a single
|
||||
call through the same code path -- not ``_decode`` -- because ``_decode``
|
||||
differs in a way that has nothing to do with chunking; see
|
||||
``test_distance_from_the_one_shot_path_is_a_post_quant_conv_artifact``.
|
||||
"""
|
||||
total = sum(chunk_sizes)
|
||||
latent = _latent(total)
|
||||
reference = _stream(decoder, latent, (total,), session_id="ref")
|
||||
streamed = _stream(decoder, latent, chunk_sizes)
|
||||
|
||||
assert streamed.shape == reference.shape
|
||||
torch.testing.assert_close(streamed, reference, rtol=0, atol=0)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_distance_from_the_one_shot_path_is_a_post_quant_conv_artifact(vae, decoder) -> None:
|
||||
"""Explain, and bound, the gap against diffusers' ``_decode``.
|
||||
|
||||
``_decode`` applies ``post_quant_conv`` to the whole latent at once; the
|
||||
tiled path in this repo, and this decoder, apply it per frame. It is a
|
||||
1x1x1 convolution, so the two are mathematically identical -- but they can
|
||||
select different kernels, and on bf16 CUDA that difference is real and is
|
||||
amplified by the causal convolutions downstream.
|
||||
|
||||
Asserting equality with ``_decode`` would therefore pin a kernel-selection
|
||||
coincidence rather than a property of streaming. Assert instead that the
|
||||
gap is entirely explained by where ``post_quant_conv`` runs.
|
||||
"""
|
||||
latent = _latent(6)
|
||||
per_frame_pq = torch.cat([vae.post_quant_conv(latent[:, :, k : k + 1]) for k in range(latent.shape[2])], dim=2)
|
||||
whole_pq = vae.post_quant_conv(latent)
|
||||
|
||||
streamed = _stream(decoder, latent, (3, 3))
|
||||
one_shot = _decode_whole(vae, latent)
|
||||
|
||||
# Same inputs to the decoder => same output, whatever the backend does.
|
||||
if torch.equal(whole_pq, per_frame_pq):
|
||||
torch.testing.assert_close(streamed, one_shot, rtol=0, atol=0)
|
||||
else: # pragma: no cover - backend dependent
|
||||
pytest.skip("backend batches post_quant_conv differently; the gap is that, not chunking")
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_a_restarted_stream_does_not_match_a_continued_one(decoder) -> None:
|
||||
"""Negative control: the equivalence test above must be discriminating.
|
||||
|
||||
Clearing state between chunks -- what the non-streaming path does on every
|
||||
call -- has to change the pixels, otherwise the temporal cache carries
|
||||
nothing and the assertion above holds for a write-only cache too.
|
||||
|
||||
Restarting also changes the frame count, because a fresh state expands its
|
||||
opening latent frame to one raw frame. Comparing totals would therefore
|
||||
pass on the frame count alone and never look at a pixel, so this compares
|
||||
the tail both runs agree on: the frames from the chunk's later latents,
|
||||
which are the full temporal factor wide on either side.
|
||||
"""
|
||||
latent = _latent(6)
|
||||
state = decoder.new_decode_state("continued")
|
||||
decoder.decode_chunk(latent[:, :, :3], state)
|
||||
continued = decoder.decode_chunk(latent[:, :, 3:], state)
|
||||
restarted = decoder.decode_chunk(latent[:, :, 3:], decoder.new_decode_state("restarted"))
|
||||
|
||||
tail = min(continued.shape[2], restarted.shape[2]) - 1
|
||||
assert tail > 0, "the comparison needs at least one fully expanded frame on both sides"
|
||||
continued_tail, restarted_tail = continued[:, :, -tail:], restarted[:, :, -tail:]
|
||||
assert continued_tail.shape == restarted_tail.shape, "the tails must be comparable for this to mean anything"
|
||||
assert not torch.equal(continued_tail, restarted_tail), (
|
||||
"restarting the decoder produced the same pixels as continuing it, so the carried cache is not being read"
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Cross-session isolation: the failure that timing metrics cannot see
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
@pytest.mark.parametrize("seed_a, seed_b", [(1, 2), (11, 12)])
|
||||
def test_interleaved_sessions_keep_independent_temporal_context(vae, decoder, seed_a: int, seed_b: int) -> None:
|
||||
"""Two sessions ticking alternately must each match their solo decode.
|
||||
|
||||
A session that reads another's temporal cache produces a perfectly
|
||||
plausible but wrong video, and every latency metric stays green, so this
|
||||
has to be asserted against a recorded solo run rather than inferred. The
|
||||
control is a solo stream rather than ``_decode``, so it differs from the
|
||||
run under test only in the interleaving.
|
||||
"""
|
||||
latent_a = _latent(6, seed=seed_a)
|
||||
latent_b = _latent(6, seed=seed_b)
|
||||
solo_a = _stream(decoder, latent_a, (6,), session_id="solo-a")
|
||||
solo_b = _stream(decoder, latent_b, (6,), session_id="solo-b")
|
||||
assert not torch.equal(solo_a, solo_b), "negative control: the two sessions must differ"
|
||||
|
||||
state_a = decoder.new_decode_state("a")
|
||||
state_b = decoder.new_decode_state("b")
|
||||
out_a, out_b = [], []
|
||||
for start in (0, 3):
|
||||
window = slice(start, start + 3)
|
||||
out_a.append(decoder.decode_chunk(latent_a[:, :, window], state_a))
|
||||
out_b.append(decoder.decode_chunk(latent_b[:, :, window], state_b))
|
||||
|
||||
torch.testing.assert_close(torch.cat(out_a, dim=2), solo_a, rtol=0, atol=0)
|
||||
torch.testing.assert_close(torch.cat(out_b, dim=2), solo_b, rtol=0, atol=0)
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_the_module_cache_is_untouched_so_sessions_can_share_one_decoder(vae, decoder) -> None:
|
||||
vae.clear_cache()
|
||||
before = [entry for entry in vae._feat_map]
|
||||
decoder.decode_chunk(_latent(3), decoder.new_decode_state("s"))
|
||||
assert vae._feat_map == before, "streaming decode must not write the module-owned cache"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Causal geometry and boundedness
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_opening_chunk_is_shorter_and_later_chunks_are_full_length(vae, decoder) -> None:
|
||||
"""(n - 1) * factor + 1 for the opening chunk, n * factor after it."""
|
||||
factor = 2 ** sum(VAE_CONFIG["temperal_downsample"])
|
||||
state = decoder.new_decode_state("s")
|
||||
first = decoder.decode_chunk(_latent(3), state)
|
||||
second = decoder.decode_chunk(_latent(3, seed=5), state)
|
||||
|
||||
assert first.shape[2] == (3 - 1) * factor + 1
|
||||
assert second.shape[2] == 3 * factor
|
||||
assert state.chunks_decoded == 2
|
||||
assert state.frames_decoded == 6
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_resident_state_does_not_grow_with_session_length(vae, decoder) -> None:
|
||||
"""Each causal convolution retains at most CACHE_T temporal slices."""
|
||||
state = decoder.new_decode_state("s")
|
||||
decoder.decode_chunk(_latent(3), state)
|
||||
baseline = state.nbytes()
|
||||
assert baseline > 0
|
||||
|
||||
for step in range(8):
|
||||
decoder.decode_chunk(_latent(3, seed=step + 10), state)
|
||||
assert state.nbytes() == baseline
|
||||
|
||||
assert sum(state.nbytes_by_device().values()) == baseline
|
||||
|
||||
|
||||
@torch.no_grad()
|
||||
def test_release_returns_the_session_to_its_pre_stream_state(vae, decoder) -> None:
|
||||
state = decoder.new_decode_state("s")
|
||||
decoder.decode_chunk(_latent(3), state)
|
||||
assert state.started and state.nbytes() > 0
|
||||
|
||||
decoder.release(state)
|
||||
assert state.nbytes() == 0
|
||||
assert not state.started
|
||||
assert state.chunks_decoded == 0
|
||||
|
||||
# A released session restarts rather than resumes, so its first chunk is
|
||||
# short again -- the same semantics reset() has for AR KV.
|
||||
factor = 2 ** sum(VAE_CONFIG["temperal_downsample"])
|
||||
assert decoder.decode_chunk(_latent(3), state).shape[2] == (3 - 1) * factor + 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Contract
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_vaes_execution_context_is_entered_for_every_frame(vae) -> None:
|
||||
"""Decode must run inside the autoencoder's own execution context.
|
||||
|
||||
Every public compute entry point on ``OmniAutoencoderKLWan`` enters
|
||||
``_execution_context()``, which is where autocast is established for
|
||||
fp16/bf16 parameters -- and on NPU the only place it is established at all.
|
||||
Driving ``post_quant_conv`` and ``decoder`` directly bypasses it unless this
|
||||
decoder enters it itself, which would compute streamed chunks under a
|
||||
different numeric context from the whole-clip decode they are compared to.
|
||||
"""
|
||||
entered: list[str] = []
|
||||
|
||||
class _ContextualVAE:
|
||||
"""The parts of the contract this decoder uses, plus the hook."""
|
||||
|
||||
config = vae.config
|
||||
dtype = torch.float32
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.decoder = vae.decoder
|
||||
self.post_quant_conv = vae.post_quant_conv
|
||||
self._cached_conv_counts = vae._cached_conv_counts
|
||||
|
||||
@contextmanager
|
||||
def _execution_context(self):
|
||||
entered.append("enter")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
entered.append("exit")
|
||||
|
||||
decoder = WanStreamingDecoder(_ContextualVAE())
|
||||
with torch.no_grad():
|
||||
decoder.decode_chunk(_latent(3), decoder.new_decode_state("s"))
|
||||
|
||||
# One context for the call, not one per frame, and it closes.
|
||||
assert entered == ["enter", "exit"]
|
||||
|
||||
|
||||
def test_a_plain_diffusers_autoencoder_needs_no_execution_context(decoder) -> None:
|
||||
"""The hook is optional: a VAE without one still decodes."""
|
||||
with torch.no_grad():
|
||||
out = decoder.decode_chunk(_latent(1), decoder.new_decode_state("s"))
|
||||
assert out.shape[2] == 1
|
||||
|
||||
|
||||
def test_wan_decoder_satisfies_the_protocol(decoder) -> None:
|
||||
assert isinstance(decoder, SupportsStreamingDecode)
|
||||
|
||||
|
||||
def test_state_from_another_decoder_is_rejected(decoder) -> None:
|
||||
foreign = StreamingDecodeState(session_id="s", feat_map=[None])
|
||||
with pytest.raises(ValueError, match="does not belong to this decoder"):
|
||||
decoder.decode_chunk(_latent(1), foreign)
|
||||
|
||||
|
||||
def test_a_non_wan_module_is_rejected() -> None:
|
||||
with pytest.raises(TypeError, match="Wan-family autoencoder"):
|
||||
WanStreamingDecoder(object())
|
||||
|
||||
|
||||
def test_empty_and_malformed_latents_are_rejected(decoder) -> None:
|
||||
state = decoder.new_decode_state("s")
|
||||
with pytest.raises(ValueError, match=r"\[B, C, T, H, W\]"):
|
||||
decoder.decode_chunk(torch.zeros(1, 4, 4, 4), state)
|
||||
with pytest.raises(ValueError, match="at least one frame"):
|
||||
decoder.decode_chunk(torch.zeros(1, 4, 0, LATENT_H, LATENT_W), state)
|
||||
|
||||
|
||||
def test_a_batch_is_refused_rather_than_threaded_through_one_cache(decoder) -> None:
|
||||
"""One state is one session, so a batch has no correct interpretation here.
|
||||
|
||||
``AutoencoderKLWan.decode`` handles a batch by decoding each sample with
|
||||
its own cleared cache (``use_slicing``). This decoder carries exactly one
|
||||
temporal context, so a batch would mix samples into it and still return
|
||||
correctly shaped pixels.
|
||||
"""
|
||||
state = decoder.new_decode_state("s")
|
||||
batched = torch.zeros(2, VAE_CONFIG["z_dim"], 3, LATENT_H, LATENT_W)
|
||||
with pytest.raises(ValueError, match="one sample per session state"):
|
||||
decoder.decode_chunk(batched, state)
|
||||
assert state.frames_decoded == 0, "a refused call must not advance the session"
|
||||
|
||||
|
||||
def test_session_id_must_be_meaningful(decoder) -> None:
|
||||
with pytest.raises(ValueError, match="session_id"):
|
||||
decoder.new_decode_state(" ")
|
||||
|
||||
|
||||
def test_declared_state_bytes_scales_with_area_and_dtype(vae) -> None:
|
||||
# A local decoder, not the module-scoped fixture: the measured constant
|
||||
# belongs to this test, and leaking it would change what a reordered run
|
||||
# of the default-None case sees. Passing it here is also the constructor
|
||||
# argument's intended use.
|
||||
# Measured for the shipped checkpoint: 37832 KiB of fp32 cache at 64x64.
|
||||
decoder = WanStreamingDecoder(vae, bytes_per_pixel_fp32=37832 * 1024 / (64 * 64))
|
||||
fp32 = decoder.declared_state_bytes(height=480, width=832, dtype=torch.float32)
|
||||
bf16 = decoder.declared_state_bytes(height=480, width=832, dtype=torch.bfloat16)
|
||||
half_area = decoder.declared_state_bytes(height=240, width=832, dtype=torch.float32)
|
||||
assert bf16 == fp32 // 2
|
||||
assert half_area == fp32 // 2
|
||||
assert fp32 / 2**20 == pytest.approx(3602.0, abs=5.0)
|
||||
@@ -10,6 +10,7 @@ Tests cover:
|
||||
- AttentionMetadata.extra field
|
||||
"""
|
||||
|
||||
from dataclasses import replace
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -17,7 +18,12 @@ import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.attention.layer as layer_mod
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
import vllm_omni.diffusion.attention.selector as selector_mod
|
||||
from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
PackedPaddingMetadata,
|
||||
QueryRange,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionBackend
|
||||
from vllm_omni.diffusion.attention.layer import Attention
|
||||
from vllm_omni.diffusion.config import (
|
||||
@@ -477,6 +483,7 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
@pytest.mark.parametrize("mindiesd_available", [False, True])
|
||||
def test_paged_npu_memory_profile_uses_sdpa_only_without_mindiesd(self, monkeypatch, mindiesd_available):
|
||||
attention = Attention.__new__(Attention)
|
||||
attention.allow_fp32_fallback = False
|
||||
attention._scheduler_paged_kv = True
|
||||
attention.paged_kv_cache_role = "primary"
|
||||
attention.backend_pref = "FLASH_ATTN"
|
||||
@@ -519,6 +526,7 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
@pytest.mark.parametrize("fa4_available", [False, True])
|
||||
def test_paged_cuda_memory_profile_uses_sdpa_without_dense_fa4(self, monkeypatch, fa4_available):
|
||||
attention = Attention.__new__(Attention)
|
||||
attention.allow_fp32_fallback = False
|
||||
attention._scheduler_paged_kv = True
|
||||
attention.paged_kv_cache_role = "primary"
|
||||
attention.backend_pref = "FLASH_ATTN"
|
||||
@@ -561,6 +569,7 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
|
||||
def test_dense_flash_import_error_is_not_reclassified(self, monkeypatch):
|
||||
attention = Attention.__new__(Attention)
|
||||
attention.allow_fp32_fallback = False
|
||||
attention._scheduler_paged_kv = False
|
||||
attention.paged_kv_cache_role = "primary"
|
||||
attention.backend_pref = "FLASH_ATTN"
|
||||
@@ -900,6 +909,7 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
return sentinel
|
||||
|
||||
fake_attention = SimpleNamespace(
|
||||
allow_fp32_fallback=False,
|
||||
attention=SimpleNamespace(
|
||||
forward=_selected_forward,
|
||||
),
|
||||
@@ -938,6 +948,164 @@ class TestAttentionInitUsesCurrentDiffusionConfig:
|
||||
Attention._run_ring_attention(fake_attention, query, query, query, metadata)
|
||||
|
||||
|
||||
class TestOptInFloat32Fallback:
|
||||
@staticmethod
|
||||
def _make_attention(monkeypatch, *, backend=None, allow=True, kv_cache_dtype=None):
|
||||
events, state = [], {}
|
||||
context = object()
|
||||
|
||||
class _SelectedImpl:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
@staticmethod
|
||||
def supports_kv_cache_dtype(*args):
|
||||
return True
|
||||
|
||||
def forward(self, query, key, value, metadata):
|
||||
events.append("selected")
|
||||
state["kernel_call"] = (query, key, value, metadata)
|
||||
state["kernel_output"] = query + 4
|
||||
return state["kernel_output"]
|
||||
|
||||
class _FallbackImpl(_SelectedImpl):
|
||||
def forward(self, query, key, value, metadata):
|
||||
output = super().forward(query, key, value, metadata)
|
||||
events[-1] = "fallback"
|
||||
return output
|
||||
|
||||
class _FlashBackend(FlashAttentionBackend):
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return _SelectedImpl
|
||||
|
||||
class _SDPABackend(layer_mod.SDPABackend):
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return _SelectedImpl
|
||||
|
||||
class _Strategy:
|
||||
def pre_attention(self, query, key, value, metadata):
|
||||
events.append("pre")
|
||||
state["prepared"] = (
|
||||
query + 1,
|
||||
key + 2,
|
||||
value + 3,
|
||||
replace(metadata) if metadata is not None else None,
|
||||
)
|
||||
return (*state["prepared"], context)
|
||||
|
||||
def post_attention(self, output, ctx):
|
||||
events.append("post")
|
||||
assert output is state["kernel_output"]
|
||||
assert ctx is context
|
||||
return output + 7
|
||||
|
||||
# Keep real role/config resolution and construction; replace hardware
|
||||
# lookup and kernels. These are CPU dispatch tests, not SP collectives.
|
||||
monkeypatch.setattr(
|
||||
selector_mod,
|
||||
"_cached_get_backend_cls",
|
||||
lambda name, *args, **kwargs: _SDPABackend if name == "TORCH_SDPA" else _FlashBackend,
|
||||
)
|
||||
monkeypatch.setattr(layer_mod.SDPABackend, "get_impl_cls", staticmethod(lambda: _FallbackImpl))
|
||||
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kwargs: _Strategy())
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: False)
|
||||
config = OmniDiffusionConfig(
|
||||
diffusion_attention_config={"default": backend} if backend else {},
|
||||
diffusion_kv_cache_dtype=kv_cache_dtype,
|
||||
)
|
||||
with set_current_diffusion_config(config):
|
||||
attention = Attention(
|
||||
num_heads=4,
|
||||
num_kv_heads=2,
|
||||
head_size=8,
|
||||
causal=False,
|
||||
softmax_scale=0.25,
|
||||
**({"allow_fp32_fallback": True} if allow else {}),
|
||||
)
|
||||
return attention, events, state
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"backend,allow,dtype,is_cuda,expected",
|
||||
[
|
||||
pytest.param(None, True, torch.float32, True, "fallback", id="auto-fp32"),
|
||||
pytest.param(None, False, torch.float32, True, "selected", id="default-opt-out"),
|
||||
pytest.param("FLASH_ATTN", True, torch.float32, True, "selected", id="explicit-flash"),
|
||||
pytest.param("TORCH_SDPA", True, torch.float32, True, "selected", id="explicit-sdpa"),
|
||||
pytest.param(None, True, torch.bfloat16, True, "selected", id="bf16"),
|
||||
pytest.param(None, True, torch.float32, False, "selected", id="cpu-fp32"),
|
||||
],
|
||||
)
|
||||
def test_public_forward_preserves_strategy_and_backend_choice(
|
||||
self, monkeypatch, backend, allow, dtype, is_cuda, expected
|
||||
):
|
||||
attention, events, state = self._make_attention(monkeypatch, backend=backend, allow=allow)
|
||||
# Only the dispatch predicate is simulated; every tensor stays on CPU.
|
||||
monkeypatch.setattr(torch.Tensor, "is_cuda", property(lambda self: is_cuda))
|
||||
query = torch.ones(1, 2, 4, 8, dtype=dtype)
|
||||
key = torch.full((1, 2, 2, 8), 2, dtype=dtype)
|
||||
value = torch.full_like(key, 3)
|
||||
metadata = AttentionMetadata(attn_mask=torch.tensor([[True, False]]))
|
||||
|
||||
output = attention(query, key, value, metadata)
|
||||
|
||||
assert events == ["pre", expected, "post"]
|
||||
assert all(got is prepared for got, prepared in zip(state["kernel_call"], state["prepared"]))
|
||||
assert state["kernel_call"][3] is not metadata
|
||||
assert state["kernel_call"][3].attn_mask is metadata.attn_mask
|
||||
torch.testing.assert_close(output, query + 12)
|
||||
assert attention.backend_explicit is (backend is not None)
|
||||
impl = attention.sdpa_fallback if expected == "fallback" else attention.attention
|
||||
assert impl.kwargs["num_kv_heads"] == 2
|
||||
assert impl.kwargs["softmax_scale"] == 0.25
|
||||
|
||||
def test_explicit_backend_failure_is_not_retried_with_sdpa(self, monkeypatch):
|
||||
attention, events, _ = self._make_attention(monkeypatch, backend="FLASH_ATTN")
|
||||
monkeypatch.setattr(torch.Tensor, "is_cuda", property(lambda self: True))
|
||||
selected = Mock(side_effect=RuntimeError("selected backend rejects this dtype"))
|
||||
monkeypatch.setattr(attention.attention, "forward", selected)
|
||||
query = torch.ones(1, 2, 4, 8)
|
||||
|
||||
with pytest.raises(RuntimeError, match="selected backend rejects this dtype"):
|
||||
attention(query, query, query)
|
||||
|
||||
selected.assert_called_once()
|
||||
assert events == ["pre"]
|
||||
|
||||
@pytest.mark.parametrize("contract", ["packed", "packed-padding", "piecewise", "quantized-kv"])
|
||||
def test_backend_metadata_is_not_silently_sent_to_dense_sdpa(self, monkeypatch, contract):
|
||||
quant_dtype = "fp8" if contract == "quantized-kv" else None
|
||||
attention, events, state = self._make_attention(monkeypatch, kv_cache_dtype=quant_dtype)
|
||||
monkeypatch.setattr(torch.Tensor, "is_cuda", property(lambda self: True))
|
||||
lengths = torch.tensor([0, 1, 2], dtype=torch.int32)
|
||||
metadata = AttentionMetadata()
|
||||
if contract == "packed":
|
||||
metadata.extra = {
|
||||
"cu_seqlens_q": lengths,
|
||||
"cu_seqlens_k": lengths,
|
||||
"max_seqlen_q": 1,
|
||||
"max_seqlen_k": 1,
|
||||
}
|
||||
elif contract == "packed-padding":
|
||||
metadata.packed_padding = PackedPaddingMetadata(1, 1, lengths[:2], lengths[:2])
|
||||
elif contract == "piecewise":
|
||||
metadata.full_attn_spans = [[(0, 1)]]
|
||||
metadata.query_ranges = (QueryRange(0, 2, 0),)
|
||||
query = torch.ones(1, 2, 4, 8)
|
||||
|
||||
attention(query, query, query, metadata)
|
||||
|
||||
assert events == ["pre", "selected", "post"]
|
||||
forwarded = state["kernel_call"][3]
|
||||
if quant_dtype:
|
||||
# Quantization is injected by the public forward from config.
|
||||
assert forwarded.extra == {"kv_cache_dtype": "fp8"}
|
||||
assert metadata.extra == {}
|
||||
else:
|
||||
assert forwarded is state["prepared"][3]
|
||||
|
||||
|
||||
class TestDiffusionKvCacheQuantization:
|
||||
@staticmethod
|
||||
def _install_attention_init_stubs(monkeypatch, *, supports_kv_cache_dtype=True):
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch._subclasses.fake_tensor import FakeTensorMode
|
||||
|
||||
from vllm_omni.diffusion.attention.ops.block_sparse import (
|
||||
build_prefix_dense_block_map,
|
||||
fastvideo_block_sparse_attn_bshd,
|
||||
mean_pool_tiles,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix_blocks,topk", [(0, 1), (2, 0), (2, 1), (2, 3), (2, 5)])
|
||||
def test_block_map_keeps_prefix_dense_and_selects_non_prefix_keys(prefix_blocks, topk):
|
||||
sparse_blocks = 3
|
||||
blocks = prefix_blocks + sparse_blocks
|
||||
scores = torch.arange(2 * blocks * blocks, dtype=torch.float32).reshape(1, 2, blocks, blocks)
|
||||
block_map = build_prefix_dense_block_map(scores, prefix_blocks, sparse_blocks, topk)
|
||||
expected = torch.zeros_like(scores, dtype=torch.bool)
|
||||
# Scores increase with key index, so top-k always selects the last keys.
|
||||
for query_block in range(blocks):
|
||||
for key_block in range(blocks):
|
||||
if (
|
||||
query_block < prefix_blocks
|
||||
or key_block < prefix_blocks
|
||||
or key_block >= blocks - min(topk, sparse_blocks)
|
||||
):
|
||||
expected[..., query_block, key_block] = True
|
||||
assert torch.equal(block_map, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("block_size", [4, 32, 64])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_pooling_uses_valid_tile_sizes_and_fp32_accumulation(block_size, dtype):
|
||||
sizes = torch.tensor([block_size, block_size - 1, 0], dtype=torch.int32)
|
||||
tiles = torch.arange(2 * 3 * block_size * 2 * 3).reshape(2, 3, block_size, 2, 3).to(dtype)
|
||||
tiles[:, 1, -1] = 0
|
||||
tiles[:, 2] = 0
|
||||
expected = torch.stack(
|
||||
[tiles[:, 0].float().mean(dim=1), tiles[:, 1, :-1].float().mean(dim=1), torch.zeros(2, 2, 3)], dim=2
|
||||
)
|
||||
result = mean_pool_tiles(tiles.flatten(1, 2), sizes, block_size)
|
||||
assert result.dtype == torch.float32
|
||||
torch.testing.assert_close(result, expected, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_provider_wrapper_restores_transport_rows_and_bshd_layout(monkeypatch):
|
||||
monkeypatch.delenv("FASTVIDEO_VSA_SM100A", raising=False)
|
||||
calls = []
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, sizes):
|
||||
calls.append((q.shape, block_map.shape, sizes.tolist()))
|
||||
assert q.is_contiguous() and k.is_contiguous() and v.is_contiguous()
|
||||
assert sizes.dtype == torch.int32
|
||||
return q + k + v, None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
query = torch.randn(1, 4 * 64, 2, 8)
|
||||
block_map = torch.ones(1, 2, 4, 4, dtype=torch.bool)
|
||||
sizes = torch.tensor([64, 17, 64, 0])
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 3)
|
||||
expected = torch.zeros_like(query)
|
||||
expected[:, : 3 * 64] = query[:, : 3 * 64] * 3
|
||||
torch.testing.assert_close(output, expected)
|
||||
assert calls == [(torch.Size([1, 2, 3 * 64, 8]), torch.Size([1, 2, 3, 3]), [64, 17, 64])]
|
||||
|
||||
|
||||
def test_custom_op_fake_preserves_metadata_without_loading_provider(monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", None)
|
||||
with FakeTensorMode():
|
||||
query = torch.empty(1, 4 * 64, 2, 8, dtype=torch.bfloat16)
|
||||
block_map = torch.empty(1, 2, 4, 4, dtype=torch.bool)
|
||||
sizes = torch.empty(4, dtype=torch.int32)
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 3)
|
||||
assert output.shape == query.shape
|
||||
assert output.dtype == query.dtype
|
||||
assert output.device == query.device
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_type", [ImportError, RuntimeError, torch.AcceleratorError])
|
||||
def test_native_provider_failure_only_recovers_without_accelerator_fault(monkeypatch, mocker, error_type):
|
||||
monkeypatch.setenv("FASTVIDEO_VSA_SM100A", "1")
|
||||
error = error_type("native provider failure")
|
||||
native = mocker.Mock()
|
||||
native.is_supported.return_value = True
|
||||
native.block_sparse_attn_sm100a.side_effect = error
|
||||
provider = types.ModuleType("fastvideo_kernel")
|
||||
setattr(provider, "block_sparse_attn_sm100a", native)
|
||||
index = types.ModuleType("fastvideo_kernel.triton_kernels.index")
|
||||
setattr(
|
||||
index,
|
||||
"map_to_index",
|
||||
mocker.Mock(return_value=(torch.zeros(1, dtype=torch.int32), torch.ones(1, dtype=torch.int32))),
|
||||
)
|
||||
triton = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
fallback = mocker.Mock(side_effect=lambda q, *args: (q.clone(), None))
|
||||
setattr(triton, "block_sparse_attn", fallback)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", provider)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "fastvideo_kernel.triton_kernels", types.ModuleType("fastvideo_kernel.triton_kernels")
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.triton_kernels.index", index)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", triton)
|
||||
query = torch.arange(128 * 2 * 8, dtype=torch.float32).reshape(1, 128, 2, 8)
|
||||
block_map = torch.ones(1, 2, 2, 2, dtype=torch.bool)
|
||||
sizes = torch.tensor([64, 64], dtype=torch.int32)
|
||||
|
||||
if error_type is torch.AcceleratorError:
|
||||
with pytest.raises(torch.AcceleratorError, match="native provider failure") as raised:
|
||||
fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 2)
|
||||
assert raised.value is error
|
||||
fallback.assert_not_called()
|
||||
else:
|
||||
output = fastvideo_block_sparse_attn_bshd(query, query, query, block_map, sizes, 2)
|
||||
torch.testing.assert_close(output, query, rtol=0, atol=0)
|
||||
fallback.assert_called_once()
|
||||
@@ -1,7 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import math
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
@@ -12,14 +11,10 @@ import torch
|
||||
from vllm_omni.diffusion.attention.backends.abstract import (
|
||||
AttentionMetadata,
|
||||
PackedPaddingMetadata,
|
||||
VideoTokenLayout,
|
||||
VideoTokenSpan,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import (
|
||||
FastVideoVSABackend,
|
||||
FastVideoVSAImpl,
|
||||
_build_h3_block_map,
|
||||
_get_h3_tile_metadata,
|
||||
)
|
||||
from vllm_omni.diffusion.attention.backends.registry import (
|
||||
DiffusionAttentionBackendEnum,
|
||||
@@ -218,144 +213,8 @@ def test_fastvideo_vsa_allows_topk_equal_to_num_blocks():
|
||||
assert too_many._fallback_reason(query, query, query, metadata) == "topk 3 > num_blocks 2"
|
||||
|
||||
|
||||
def _h3_metadata(prefix_segments, video_shape, *, gate=None, packed_padding=None):
|
||||
extra: dict[str, Any] = {
|
||||
"vsa_h3_prefix_segments": prefix_segments,
|
||||
}
|
||||
if gate is not None:
|
||||
extra["gate_compress"] = gate
|
||||
return AttentionMetadata(
|
||||
packed_padding=packed_padding,
|
||||
video_layout=VideoTokenLayout(
|
||||
used_len=sum(prefix_segments) + math.prod(video_shape),
|
||||
video_spans=(VideoTokenSpan(start=sum(prefix_segments), latent_grid=video_shape, role="target"),),
|
||||
),
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
|
||||
def _fake_block_sparse_kernel(monkeypatch, calls):
|
||||
"""Stand in for fastvideo_kernel.block_sparse_attn with a mask-obeying SDPA."""
|
||||
|
||||
def block_sparse_attn(q, k, v, block_map, variable_block_sizes):
|
||||
calls["block_map"] = block_map.clone()
|
||||
calls["variable_block_sizes"] = variable_block_sizes.tolist()
|
||||
calls["q_shape"] = tuple(q.shape)
|
||||
blocks = block_map.shape[-1]
|
||||
assert blocks * 64 == q.shape[2]
|
||||
# Expand the per-block map to a per-token mask so the stand-in honours
|
||||
# the sparsity contract the real kernel implements, and drop the rows
|
||||
# each partial block pads with, which the real kernel skips through
|
||||
# variable_block_sizes.
|
||||
token_mask = block_map.repeat_interleave(64, dim=-1).repeat_interleave(64, dim=-2)
|
||||
within = torch.arange(64, device=q.device)[None, :] < variable_block_sizes[:, None]
|
||||
token_mask = token_mask & within.reshape(1, 1, 1, -1)
|
||||
out = torch.nn.functional.scaled_dot_product_attention(q.float(), k.float(), v.float(), attn_mask=token_mask)
|
||||
return out.to(q.dtype), None
|
||||
|
||||
module = types.ModuleType("fastvideo_kernel.block_sparse_attn")
|
||||
setattr(module, "block_sparse_attn", block_sparse_attn)
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel", types.ModuleType("fastvideo_kernel"))
|
||||
monkeypatch.setitem(sys.modules, "fastvideo_kernel.block_sparse_attn", module)
|
||||
|
||||
|
||||
def _h3_impl(**backend_kwargs):
|
||||
kwargs = {"topk": 1, "min_seq_len": 1, "disable_when_sp_active": False}
|
||||
kwargs.update(backend_kwargs)
|
||||
return FastVideoVSAImpl(num_heads=2, head_size=8, softmax_scale=8**-0.5, backend_kwargs=kwargs)
|
||||
|
||||
|
||||
def test_h3_forward_tiles_untiles_and_restores_the_original_row_order(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5, 70), (2, 4, 4)
|
||||
seq_len = sum(prefix_segments) + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
assert output.shape == query.shape
|
||||
# topk covers every video block, so the block map is dense and the tiled
|
||||
# kernel must reproduce plain attention over the same rows.
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(output.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
# 5 + 70 splits into segment-pure 5 | 64 | 6, then one (2, 4, 4) video tile.
|
||||
assert calls["variable_block_sizes"] == [5, 64, 6, 32]
|
||||
|
||||
|
||||
def test_h3_forward_keeps_prefix_dense_and_selects_top_k_video_tiles(monkeypatch):
|
||||
calls: dict[str, Any] = {}
|
||||
_fake_block_sparse_kernel(monkeypatch, calls)
|
||||
impl = _h3_impl(topk=1)
|
||||
prefix_segments, video_shape = (5,), (8, 4, 4)
|
||||
seq_len = 5 + 8 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
|
||||
# 1 prefix block + 2 video blocks. The odd count is padded to an even one
|
||||
# for the paired-CTA kernel contract, and the Triton route slices that
|
||||
# transport-only partner off again before launching.
|
||||
block_map = calls["block_map"]
|
||||
assert block_map.shape[-1] == 3
|
||||
assert calls["variable_block_sizes"] == [5, 64, 64]
|
||||
assert block_map[:, :, :1, :].all(), "prefix queries stay dense"
|
||||
assert block_map[..., :1].all(), "prefix keys are exempt from selection"
|
||||
assert (block_map[:, :, 1:, 1:].sum(dim=-1) == 1).all(), "one video tile per video query"
|
||||
|
||||
# The same odd geometry must still untile back onto the original rows.
|
||||
dense = _h3_impl(topk=64).forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
reference = torch.nn.functional.scaled_dot_product_attention(
|
||||
*(x.float().transpose(1, 2) for x in (query, query, query))
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(dense.float(), reference, atol=2e-2, rtol=2e-2)
|
||||
|
||||
|
||||
def test_h3_forward_applies_the_learned_compression_gate(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
seq_len = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, seq_len, 2, 8, dtype=torch.bfloat16)
|
||||
|
||||
without_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape))
|
||||
zero_gate = torch.zeros_like(query)
|
||||
with_zero_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=zero_gate))
|
||||
live_gate = torch.full_like(query, 0.5)
|
||||
with_live_gate = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, gate=live_gate))
|
||||
|
||||
torch.testing.assert_close(with_zero_gate.float(), without_gate.float())
|
||||
assert not torch.allclose(with_live_gate.float(), without_gate.float())
|
||||
|
||||
|
||||
def test_h3_forward_restores_rows_the_packed_padding_excludes(monkeypatch):
|
||||
_fake_block_sparse_kernel(monkeypatch, {})
|
||||
impl = _h3_impl(topk=64)
|
||||
prefix_segments, video_shape = (5,), (2, 4, 4)
|
||||
valid = 5 + 2 * 4 * 4
|
||||
torch.manual_seed(0)
|
||||
query = torch.randn(1, valid + 24, 2, 8, dtype=torch.bfloat16)
|
||||
padding = PackedPaddingMetadata(
|
||||
q_length=valid,
|
||||
kv_length=valid,
|
||||
cu_seqlens_q=torch.tensor([0, valid], dtype=torch.int32),
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
output = impl.forward_cuda(query, query, query, _h3_metadata(prefix_segments, video_shape, packed_padding=padding))
|
||||
|
||||
assert output.shape == query.shape
|
||||
assert torch.count_nonzero(output[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
# The backend advertises supports_packed_mask_free, so MiniMax-H3 skips
|
||||
# The backend advertises supports_packed_mask_free, so a model may skip
|
||||
# building the padding mask. Every fallback out of the VSA path must honour
|
||||
# that contract itself; SDPA reads attn_mask and nothing else.
|
||||
impl = FastVideoVSAImpl(
|
||||
@@ -374,6 +233,8 @@ def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
cu_seqlens_k=torch.tensor([0, valid], dtype=torch.int32),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(impl.sdpa_fallback, "forward", impl.sdpa_fallback.forward_cuda)
|
||||
|
||||
baseline = impl.forward_cuda(query, query, query, AttentionMetadata(packed_padding=padding, extra={}))
|
||||
perturbed_input = query.clone()
|
||||
perturbed_input[:, valid:] += 100.0
|
||||
@@ -384,22 +245,3 @@ def test_sdpa_fallback_never_attends_the_structural_padding(monkeypatch):
|
||||
assert baseline.shape == query.shape
|
||||
torch.testing.assert_close(baseline[:, :valid], perturbed[:, :valid])
|
||||
assert torch.count_nonzero(baseline[:, valid:]) == 0
|
||||
|
||||
|
||||
def test_h3_geometry_keeps_prefix_segments_pure_and_tiles_video_3d():
|
||||
partition, sizes, non_pad, untile, prefix_blocks, video_blocks = _get_h3_tile_metadata(
|
||||
(5, 70, 9), (5, 6, 6), torch.device("cpu")
|
||||
)
|
||||
assert sizes[:4].tolist() == [5, 64, 6, 9]
|
||||
assert prefix_blocks == 4
|
||||
assert video_blocks == 8
|
||||
assert int(sizes.sum()) == 5 + 70 + 9 + 5 * 6 * 6
|
||||
assert partition.numel() == non_pad.numel() == untile.numel() == int(sizes.sum())
|
||||
|
||||
|
||||
def test_h3_block_map_makes_prefix_queries_dense_and_prefix_keys_exempt():
|
||||
scores = torch.arange(1 * 2 * 5 * 5, dtype=torch.float32).reshape(1, 2, 5, 5)
|
||||
block_map = _build_h3_block_map(scores, num_prefix_blocks=2, num_video_blocks=3, topk=1)
|
||||
assert block_map[:, :, :2].all()
|
||||
assert block_map[..., :2].all()
|
||||
assert (block_map[:, :, 2:, 2:].sum(dim=-1) == 1).all()
|
||||
@@ -16,13 +16,14 @@ from unittest.mock import Mock
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl
|
||||
from vllm_omni.diffusion.attention.backends.utils import fa # noqa: E402
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
is_gpu = current_omni_platform.is_cuda_alike() or current_omni_platform.is_xpu()
|
||||
HAS_FLASH_ATTN = fa.HAS_FLASH_ATTN
|
||||
@@ -42,10 +43,12 @@ flash_attn_func = fa.flash_attn_func # noqa: N813
|
||||
(9, "4", frozenset((2, 3, 4)), 4),
|
||||
],
|
||||
)
|
||||
@pytest.mark.cpu
|
||||
def test_versioned_flash_attention_selection(device_major, requested, supported, expected):
|
||||
assert fa._choose_vllm_flash_attn_version(device_major, requested, supported) == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_versioned_flash_attention_selection_rejects_invalid_or_unavailable_version():
|
||||
with pytest.raises(ValueError, match="must be 2, 3, or 4"):
|
||||
fa._choose_vllm_flash_attn_version(9, "5", frozenset((2, 3, 4)))
|
||||
@@ -92,7 +95,8 @@ def pad_tensor(tensor: torch.Tensor, target_seq_len: int, pad_value: float = 0.0
|
||||
return torch.cat([tensor, padding], dim=1)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires CUDA or XPU")
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires an accelerator")
|
||||
def test_padding_equivalence():
|
||||
"""
|
||||
Case 1: Test that padded and unpadded inputs produce similar outputs.
|
||||
@@ -185,7 +189,8 @@ def test_padding_equivalence():
|
||||
print("✓ Case 1 PASSED: Padded and unpadded outputs are very close!")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires CUDA or XPU")
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires an accelerator")
|
||||
def test_fa_vs_sdpa():
|
||||
"""
|
||||
Case 2: Compare FlashAttention and SDPA backends with padding.
|
||||
@@ -302,7 +307,8 @@ def test_fa_vs_sdpa():
|
||||
print("✓ Case 2 PASSED: FA and SDPA outputs are very close!")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires CUDA or XPU")
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires an accelerator")
|
||||
def test_flash_attn_func_preferred_over_varlen():
|
||||
"""Test flash_attn_func availability and basic forward call."""
|
||||
if not HAS_FLASH_ATTN:
|
||||
@@ -332,7 +338,8 @@ def test_flash_attn_func_preferred_over_varlen():
|
||||
print("✓ flash_attn_func forward works correctly!")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires CUDA or XPU")
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
@pytest.mark.skipif(not is_gpu, reason="FlashAttention requires an accelerator")
|
||||
@pytest.mark.parametrize("k_len", [40, 256])
|
||||
def test_cross_attn_key_padding_vs_sdpa(k_len):
|
||||
"""
|
||||
@@ -390,6 +397,7 @@ def test_cross_attn_key_padding_vs_sdpa(k_len):
|
||||
print("✓ Case 3 PASSED: FA and SDPA cross-attention outputs are very close!")
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_varlen_masked_routing_by_role(monkeypatch):
|
||||
"""The unpad route is picked by role, not Q/K length equality; runs without a GPU."""
|
||||
calls = []
|
||||
@@ -424,6 +432,7 @@ def test_varlen_masked_routing_by_role(monkeypatch):
|
||||
assert cu_seqlens_k.tolist() == [0, 2, 5]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_piecewise_flash_attn_uses_varlen_fallback(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -454,6 +463,7 @@ def test_piecewise_flash_attn_uses_varlen_fallback(monkeypatch):
|
||||
assert calls[0]["softmax_scale"] == 0.5
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_packed_varlen_metadata_bypasses_mask_unpadding(monkeypatch):
|
||||
calls = []
|
||||
|
||||
@@ -486,6 +496,7 @@ def test_packed_varlen_metadata_bypasses_mask_unpadding(monkeypatch):
|
||||
assert calls[0][3]["max_seqlen_q"] == 6
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_packed_varlen_metadata_must_be_complete(monkeypatch):
|
||||
monkeypatch.setattr(fa, "HAS_FLASH_ATTN", True)
|
||||
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=False)
|
||||
@@ -503,11 +514,12 @@ def test_packed_varlen_metadata_must_be_complete(monkeypatch):
|
||||
# - boundary resolution in ``_resolve_packed_seq_npu`` (pure Python, no device
|
||||
# sync, no MindIE-SD import);
|
||||
# - env dispatch in ``forward_fa_npu`` (which op a packed forward takes);
|
||||
# - native torch_npu dispatch for right-down causal attention;
|
||||
# - the laser input pre-scaling math in ``_forward_prefix_kv_slice_npu``.
|
||||
#
|
||||
# They run on CPU without a real NPU by injecting a fake ``mindiesd`` module
|
||||
# into ``sys.modules`` (same pattern as ``test_paged_attention`` /
|
||||
# ``benchmarks/conftest``). No production code is exercised for real kernels.
|
||||
# They run on CPU without a real NPU by injecting fake ``mindiesd`` and
|
||||
# ``torch_npu`` modules into ``sys.modules`` (same pattern as
|
||||
# ``test_paged_attention`` / ``benchmarks/conftest``). No real kernel runs.
|
||||
|
||||
|
||||
def _npu_impl(causal: bool = False) -> FlashAttentionImpl:
|
||||
@@ -529,9 +541,19 @@ def _fake_mindiesd(monkeypatch, *, attention_forward=None, attention_forward_var
|
||||
return fake
|
||||
|
||||
|
||||
def _fake_torch_npu(monkeypatch, *, npu_fusion_attention=None):
|
||||
"""Install the minimal torch_npu surface used by causal NPU attention."""
|
||||
fake = SimpleNamespace(
|
||||
npu_fusion_attention=npu_fusion_attention or Mock(return_value=(torch.zeros(1), None, None, None, 0, 0, 0)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "torch_npu", fake)
|
||||
return fake
|
||||
|
||||
|
||||
# --- Test group A: boundary resolution (_resolve_packed_seq_npu) -------------
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_resolve_packed_seq_accepts_real_plus_pad_contract():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
@@ -541,6 +563,7 @@ def test_resolve_packed_seq_accepts_real_plus_pad_contract():
|
||||
assert impl._resolve_packed_seq_npu(q, q, extra) == ([5, 8], [5, 8])
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_resolve_packed_seq_single_document_no_padding():
|
||||
impl = _npu_impl()
|
||||
q = torch.randn(1, 8, 2, 4)
|
||||
@@ -564,6 +587,7 @@ def test_resolve_packed_seq_single_document_no_padding():
|
||||
"pad_longer_than_real",
|
||||
],
|
||||
)
|
||||
@pytest.mark.cpu
|
||||
def test_resolve_packed_seq_rejects_malformed_metadata(case):
|
||||
"""Any metadata outside the exact [real, pad] contract returns None so the
|
||||
caller falls back to the masked path."""
|
||||
@@ -620,6 +644,7 @@ def _npu_impl_with_mocked_paths() -> tuple[FlashAttentionImpl, torch.Tensor]:
|
||||
return impl, sentinel
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_npu_env_unset_uses_varlen(monkeypatch):
|
||||
monkeypatch.delenv("MINDIE_SD_FA_TYPE", raising=False)
|
||||
_fake_mindiesd(monkeypatch)
|
||||
@@ -632,6 +657,7 @@ def test_npu_env_unset_uses_varlen(monkeypatch):
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_npu_env_laser_uses_slice(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
_fake_mindiesd(monkeypatch)
|
||||
@@ -645,6 +671,7 @@ def test_npu_env_laser_uses_slice(monkeypatch):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fa_type", ["prompt_flash_attn", "fused_attn_score", "ascend_laser_attenton", "garbage"])
|
||||
@pytest.mark.cpu
|
||||
def test_npu_env_non_laser_value_falls_back_to_varlen(monkeypatch, fa_type):
|
||||
"""Locks in the CURRENT dispatch behavior (PR #5891).
|
||||
|
||||
@@ -665,6 +692,7 @@ def test_npu_env_non_laser_value_falls_back_to_varlen(monkeypatch, fa_type):
|
||||
impl._forward_prefix_kv_slice_npu.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_npu_varlen_opt_in_unset_takes_mask_path(monkeypatch):
|
||||
"""Models that do not set ``npu_attn_varlen`` (e.g. Wan/Cosmos) keep using
|
||||
the existing masked attention_forward path; the new branches are skipped."""
|
||||
@@ -685,9 +713,115 @@ def test_npu_varlen_opt_in_unset_takes_mask_path(monkeypatch):
|
||||
assert out is fake_forward.return_value
|
||||
|
||||
|
||||
# --- Test group C: laser input pre-scaling in _forward_prefix_kv_slice_npu --
|
||||
# --- Test group C: native NPU causal attention dispatch ----------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("query_length", "key_length", "expected_inner_precise"),
|
||||
[(4, 4, 0), (2, 4, 0), (4, 2, 2)],
|
||||
)
|
||||
@pytest.mark.cpu
|
||||
def test_npu_causal_uses_native_right_down_mode(
|
||||
monkeypatch,
|
||||
query_length,
|
||||
key_length,
|
||||
expected_inner_precise,
|
||||
):
|
||||
expected_out = torch.randn(1, query_length, 2, 4)
|
||||
fusion_attention = Mock(return_value=(expected_out, None, None, None, 0, 0, 0))
|
||||
fake_torch_npu = _fake_torch_npu(monkeypatch, npu_fusion_attention=fusion_attention)
|
||||
impl = FlashAttentionImpl(num_heads=8, head_size=4, softmax_scale=0.5, causal=True)
|
||||
query = torch.randn(1, query_length, 2, 4)
|
||||
key = torch.randn(1, key_length, 2, 4)
|
||||
|
||||
out = impl.forward_fa_npu(query, key, key)
|
||||
|
||||
fake_torch_npu.npu_fusion_attention.assert_called_once()
|
||||
args, kwargs = fake_torch_npu.npu_fusion_attention.call_args
|
||||
assert torch.equal(args[0], query)
|
||||
assert torch.equal(args[1], key)
|
||||
assert torch.equal(args[2], key)
|
||||
assert all(tensor.is_contiguous() for tensor in args[:3])
|
||||
assert kwargs["head_num"] == query.shape[2]
|
||||
assert kwargs["input_layout"] == "BSND"
|
||||
assert kwargs["scale"] == 0.5
|
||||
assert kwargs["keep_prob"] == 1.0
|
||||
assert kwargs["sparse_mode"] == 3
|
||||
assert kwargs["inner_precise"] == expected_inner_precise
|
||||
|
||||
block_mask = kwargs["atten_mask"]
|
||||
assert block_mask.shape == (2048, 2048)
|
||||
assert block_mask.dtype == torch.bool
|
||||
assert block_mask.is_contiguous()
|
||||
expected_corner = torch.tensor(
|
||||
[
|
||||
[False, True, True, True],
|
||||
[False, False, True, True],
|
||||
[False, False, False, True],
|
||||
[False, False, False, False],
|
||||
]
|
||||
)
|
||||
assert torch.equal(block_mask[:4, :4], expected_corner)
|
||||
assert out is expected_out
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_npu_causal_composes_explicit_keep_mask(monkeypatch):
|
||||
expected_out = torch.randn(1, 4, 2, 4)
|
||||
fusion_attention = Mock(return_value=(expected_out, None, None, None, 0, 0, 0))
|
||||
fake_torch_npu = _fake_torch_npu(monkeypatch, npu_fusion_attention=fusion_attention)
|
||||
impl = FlashAttentionImpl(num_heads=2, head_size=4, softmax_scale=0.5, causal=True)
|
||||
query = torch.randn(1, 4, 2, 4)
|
||||
key_keep_mask = torch.tensor([[True, False, True, True]])
|
||||
|
||||
out = impl.forward_fa_npu(
|
||||
query,
|
||||
query,
|
||||
query,
|
||||
AttentionMetadata(attn_mask=key_keep_mask),
|
||||
)
|
||||
|
||||
fake_torch_npu.npu_fusion_attention.assert_called_once()
|
||||
kwargs = fake_torch_npu.npu_fusion_attention.call_args.kwargs
|
||||
assert kwargs["sparse_mode"] == 1
|
||||
assert kwargs["inner_precise"] == 2
|
||||
expected_block_mask = torch.tensor(
|
||||
[
|
||||
[
|
||||
[
|
||||
[False, True, True, True],
|
||||
[False, True, True, True],
|
||||
[False, True, False, True],
|
||||
[False, True, False, False],
|
||||
]
|
||||
]
|
||||
]
|
||||
)
|
||||
assert torch.equal(kwargs["atten_mask"], expected_block_mask)
|
||||
assert kwargs["atten_mask"].is_contiguous()
|
||||
assert out is expected_out
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_npu_noncausal_without_explicit_mask_stays_unmasked(monkeypatch):
|
||||
captured: dict = {}
|
||||
|
||||
def fake_attention_forward(query, key, value, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return torch.zeros_like(query)
|
||||
|
||||
_fake_mindiesd(monkeypatch, attention_forward=fake_attention_forward)
|
||||
query = torch.randn(1, 4, 2, 4)
|
||||
|
||||
_npu_impl(causal=False).forward_fa_npu(query, query, query)
|
||||
|
||||
assert captured["attn_mask"] is None
|
||||
|
||||
|
||||
# --- Test group D: laser input pre-scaling in _forward_prefix_kv_slice_npu --
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_prefix_kv_slice_applies_laser_input_scaling(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
@@ -726,6 +860,7 @@ def test_prefix_kv_slice_applies_laser_input_scaling(monkeypatch):
|
||||
torch.testing.assert_close(out, torch.ones(1, 8, 2, 4) * 256.0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_prefix_kv_slice_no_scaling_without_factor(monkeypatch):
|
||||
monkeypatch.setenv("MINDIE_SD_FA_TYPE", "ascend_laser_attention")
|
||||
captured: dict = {}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.npu]
|
||||
|
||||
|
||||
def _bottom_right_causal_reference(
|
||||
query: torch.Tensor,
|
||||
key: torch.Tensor,
|
||||
value: torch.Tensor,
|
||||
scale: float,
|
||||
key_keep_mask: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
scores = torch.einsum("bqhd,bkhd->bhqk", query.float(), key.float()) * scale
|
||||
query_positions = torch.arange(query.shape[1]).unsqueeze(1)
|
||||
key_positions = torch.arange(key.shape[1]).unsqueeze(0)
|
||||
keep_mask = key_positions <= query_positions + (key.shape[1] - query.shape[1])
|
||||
if key_keep_mask is not None:
|
||||
assert query.shape[0] == 1
|
||||
keep_mask = keep_mask & key_keep_mask[0].bool().unsqueeze(0)
|
||||
valid_rows = keep_mask.any(dim=-1)
|
||||
|
||||
output = torch.zeros(
|
||||
query.shape[0],
|
||||
query.shape[1],
|
||||
query.shape[2],
|
||||
value.shape[-1],
|
||||
dtype=torch.float32,
|
||||
)
|
||||
valid_scores = scores[:, :, valid_rows].masked_fill(~keep_mask[valid_rows], float("-inf"))
|
||||
probabilities = torch.softmax(valid_scores, dim=-1)
|
||||
output[:, valid_rows] = torch.einsum("bhqk,bkhd->bqhd", probabilities, value.float())
|
||||
return output
|
||||
|
||||
|
||||
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="Native causal attention requires Ascend NPU")
|
||||
def test_npu_bottom_right_causal_fully_masked_rows_are_zero():
|
||||
torch.manual_seed(0)
|
||||
batch_size, query_length, key_length = 1, 4, 2
|
||||
num_heads, head_size = 2, 64
|
||||
scale = head_size**-0.5
|
||||
|
||||
query_cpu = torch.randn(batch_size, query_length, num_heads, head_size).to(torch.bfloat16)
|
||||
key_cpu = torch.randn(batch_size, key_length, num_heads, head_size).to(torch.bfloat16)
|
||||
value_cpu = torch.randn(batch_size, key_length, num_heads, head_size).to(torch.bfloat16)
|
||||
expected = _bottom_right_causal_reference(query_cpu, key_cpu, value_cpu, scale)
|
||||
|
||||
device = torch.device(current_omni_platform.device_type)
|
||||
query = query_cpu.to(device)
|
||||
key = key_cpu.to(device)
|
||||
value = value_cpu.to(device)
|
||||
impl = FlashAttentionImpl(
|
||||
num_heads=num_heads,
|
||||
head_size=head_size,
|
||||
softmax_scale=scale,
|
||||
causal=True,
|
||||
)
|
||||
|
||||
output = impl.forward_fa_npu(query, key, value)
|
||||
output_cpu = output.float().cpu()
|
||||
|
||||
assert torch.isfinite(output_cpu).all()
|
||||
assert torch.equal(output_cpu[:, : query_length - key_length], torch.zeros_like(output_cpu[:, :2]))
|
||||
torch.testing.assert_close(
|
||||
output_cpu[:, query_length - key_length :],
|
||||
expected[:, query_length - key_length :],
|
||||
rtol=2e-2,
|
||||
atol=2e-2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="Native causal attention requires Ascend NPU")
|
||||
def test_npu_bottom_right_causal_composes_explicit_keep_mask():
|
||||
torch.manual_seed(1)
|
||||
batch_size, sequence_length = 1, 4
|
||||
num_heads, head_size = 2, 64
|
||||
scale = head_size**-0.5
|
||||
key_keep_mask_cpu = torch.tensor([[True, False, True, True]])
|
||||
|
||||
query_cpu = torch.randn(batch_size, sequence_length, num_heads, head_size).to(torch.bfloat16)
|
||||
key_cpu = torch.randn(batch_size, sequence_length, num_heads, head_size).to(torch.bfloat16)
|
||||
value_cpu = torch.randn(batch_size, sequence_length, num_heads, head_size).to(torch.bfloat16)
|
||||
expected = _bottom_right_causal_reference(
|
||||
query_cpu,
|
||||
key_cpu,
|
||||
value_cpu,
|
||||
scale,
|
||||
key_keep_mask_cpu,
|
||||
)
|
||||
|
||||
device = torch.device(current_omni_platform.device_type)
|
||||
query = query_cpu.to(device)
|
||||
key = key_cpu.to(device)
|
||||
value = value_cpu.to(device)
|
||||
impl = FlashAttentionImpl(
|
||||
num_heads=num_heads,
|
||||
head_size=head_size,
|
||||
softmax_scale=scale,
|
||||
causal=True,
|
||||
)
|
||||
|
||||
output = impl.forward_fa_npu(
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
AttentionMetadata(attn_mask=key_keep_mask_cpu.to(device)),
|
||||
)
|
||||
output_cpu = output.float().cpu()
|
||||
|
||||
assert torch.isfinite(output_cpu).all()
|
||||
torch.testing.assert_close(output_cpu, expected, rtol=2e-2, atol=2e-2)
|
||||
@@ -0,0 +1,136 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
|
||||
from vllm_omni.diffusion.attention import layer
|
||||
from vllm_omni.diffusion.attention.backends.fastvideo_vsa import FastVideoVSABackend, FastVideoVSAImpl
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.attention.parallel.base import NoParallelAttention
|
||||
from vllm_omni.diffusion.data import AttentionSpec
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
class _SpecializedImpl(FastVideoVSAImpl):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local_attention(monkeypatch):
|
||||
monkeypatch.setattr(layer, "get_current_diffusion_config_or_none", lambda: None)
|
||||
monkeypatch.setattr(layer, "build_parallel_attention_strategy", lambda **kwargs: NoParallelAttention())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("explicit", [False, True])
|
||||
@pytest.mark.parametrize("override", [False, True])
|
||||
def test_specialization_preserves_selected_backend_and_options(mocker, local_attention, explicit, override):
|
||||
spec = AttentionSpec(backend="FASTVIDEO_VSA", fastvideo_vsa_topk=7) if explicit else None
|
||||
select = mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(FastVideoVSABackend, spec))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
role="video.self",
|
||||
role_category="self",
|
||||
qkv_layout="BSND",
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl} if override else None,
|
||||
)
|
||||
|
||||
assert attention.attn_backend is FastVideoVSABackend
|
||||
assert type(attention.attention) is (_SpecializedImpl if override else FastVideoVSAImpl)
|
||||
assert attention.attention.topk == (7 if explicit else 64)
|
||||
assert attention.attention.qkv_layout == "BSND"
|
||||
assert attention.backend_pref == "FASTVIDEO_VSA"
|
||||
assert attention.backend_explicit is explicit
|
||||
assert attention.attn_spec is spec
|
||||
assert isinstance(attention.parallel_strategy, NoParallelAttention)
|
||||
select.assert_called_once_with(
|
||||
role="video.self", head_size=8, attention_config=None, role_category="self", allow_trtllm_default=False
|
||||
)
|
||||
|
||||
|
||||
def test_unselected_specialization_does_not_replace_dense_backend(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(SDPABackend, None))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
assert attention.attn_backend is SDPABackend
|
||||
assert type(attention.attention) is SDPABackend.get_impl_cls()
|
||||
|
||||
|
||||
def test_specialization_does_not_bypass_selection_errors(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", side_effect=ImportError("optional kernel unavailable"))
|
||||
with pytest.raises(ImportError, match="optional kernel unavailable"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
|
||||
|
||||
def test_specialization_must_extend_selected_implementation(mocker, local_attention):
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(FastVideoVSABackend, None))
|
||||
with pytest.raises(TypeError, match="must subclass the selected implementation"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": SDPABackend.get_impl_cls()},
|
||||
)
|
||||
|
||||
|
||||
def test_platform_backend_capabilities_survive_model_specialization(mocker, local_attention):
|
||||
from vllm_omni.diffusion.models.minimax_h3.attention.fastvideo_h3 import MiniMaxH3VSAImpl
|
||||
|
||||
class PlatformBackend(FastVideoVSABackend):
|
||||
@classmethod
|
||||
def supports_packed_mask_free(cls):
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_supported_head_sizes():
|
||||
return [128]
|
||||
|
||||
# A registry/platform may refine capabilities without changing the
|
||||
# implementation. The model must not replace those capability decisions.
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(PlatformBackend, None))
|
||||
attention = layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=128,
|
||||
softmax_scale=128**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": MiniMaxH3VSAImpl},
|
||||
)
|
||||
assert attention.attn_backend is PlatformBackend
|
||||
assert not attention.attn_backend.supports_packed_mask_free()
|
||||
assert attention.attn_backend.get_supported_head_sizes() == [128]
|
||||
assert type(attention.attention) is MiniMaxH3VSAImpl
|
||||
|
||||
|
||||
def test_incompatible_platform_implementation_is_not_discarded(mocker, local_attention):
|
||||
class PlatformImpl(FastVideoVSAImpl):
|
||||
pass
|
||||
|
||||
class PlatformBackend(FastVideoVSABackend):
|
||||
@staticmethod
|
||||
def get_impl_cls():
|
||||
return PlatformImpl
|
||||
|
||||
mocker.patch.object(layer, "get_attn_backend_for_role", return_value=(PlatformBackend, None))
|
||||
with pytest.raises(TypeError, match="selected implementation .*PlatformImpl"):
|
||||
layer.Attention(
|
||||
num_heads=2,
|
||||
head_size=8,
|
||||
softmax_scale=8**-0.5,
|
||||
causal=False,
|
||||
impl_overrides={"FASTVIDEO_VSA": _SpecializedImpl},
|
||||
)
|
||||
@@ -0,0 +1,160 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Cross-repository CPU dispatch contracts, requiring the matching MindIE-SD source.
|
||||
|
||||
Only native operators are replaced. Public adapters, rotations, layouts, padding,
|
||||
scale preparation, sequence metadata and output cropping execute their real code.
|
||||
Operator substitutes carry floating tensors; these are not numerical quantization tests.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends import flash_attn, rainfusion_attn
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.platforms.npu.quant.kv_quant_npu import get_quant_attention_rotation
|
||||
|
||||
mindiesd = pytest.importorskip("mindiesd")
|
||||
torch_npu = importlib.import_module("torch_npu")
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"precision,batch,q_len,kv_len,q_heads",
|
||||
[
|
||||
("fp8", 1, 130, 130, 2),
|
||||
("mxfp8", 1, 130, 130, 2),
|
||||
("mxfp4", 1, 130, 130, 2),
|
||||
("mxfp8", 2, 17, 8, 4),
|
||||
("mxfp8", 2, 8, 17, 4),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("layout", ["BSND", "BNSD"])
|
||||
def test_omni_calls_real_dense_public_api(monkeypatch, precision, layout, batch, q_len, kv_len, q_heads):
|
||||
assert callable(getattr(mindiesd, "quant_attention", None)), "MindIE-SD must export quant_attention"
|
||||
monkeypatch.setattr(flash_attn, "current_omni_platform", SimpleNamespace(is_npu=lambda: True, device_name="npu"))
|
||||
monkeypatch.setattr(flash_attn, "get_current_diffusion_config_or_none", lambda: None)
|
||||
quantized_inputs = []
|
||||
|
||||
def dynamic_quant(tensor, **kwargs):
|
||||
quantized_inputs.append((tensor.clone(), kwargs))
|
||||
shape = list(tensor.shape)
|
||||
axis = kwargs.get("axis", -1)
|
||||
shape[axis] = max(1, shape[axis] // 32)
|
||||
return tensor, torch.ones(shape, dtype=torch.uint8)
|
||||
|
||||
monkeypatch.setattr(torch_npu, "npu_dynamic_block_quant", dynamic_quant, raising=False)
|
||||
monkeypatch.setattr(torch_npu, "npu_dynamic_mx_quant", dynamic_quant, raising=False)
|
||||
execute = Mock(side_effect=lambda query, *args, **kwargs: (query, None))
|
||||
metadata = Mock(return_value=torch.zeros(1, dtype=torch.int32))
|
||||
if precision == "fp8":
|
||||
monkeypatch.setattr(torch.ops.mindiesd, "fused_infer_attention_score_v2", execute, raising=False)
|
||||
elif precision == "mxfp8":
|
||||
monkeypatch.setattr(torch_npu, "npu_fused_infer_attention_score_v2", execute, raising=False)
|
||||
else:
|
||||
monkeypatch.setattr(torch.ops.mindiesd, "quant_flash_attn", execute, raising=False)
|
||||
monkeypatch.setattr(torch.ops.mindiesd, "quant_flash_attn_metadata", metadata, raising=False)
|
||||
|
||||
query = torch.randn(batch, q_len, q_heads, 64, dtype=torch.bfloat16)
|
||||
key = torch.randn(batch, kv_len, 2, 64, dtype=torch.bfloat16)
|
||||
value = torch.randn_like(key)
|
||||
if layout == "BNSD":
|
||||
query, key, value = (tensor.transpose(1, 2) for tensor in (query, key, value))
|
||||
impl = flash_attn.FlashAttentionImpl(
|
||||
num_heads=q_heads,
|
||||
head_size=64,
|
||||
softmax_scale=0.37,
|
||||
qkv_layout=layout,
|
||||
)
|
||||
output = impl.forward_npu(query, key, value, AttentionMetadata(extra={"kv_cache_dtype": precision}))
|
||||
rotation = get_quant_attention_rotation(query.device, query.dtype, 64)
|
||||
expected = query @ rotation
|
||||
torch.testing.assert_close(output, expected)
|
||||
assert output.shape == query.shape and len(quantized_inputs) == 3
|
||||
execute.assert_called_once()
|
||||
kwargs = execute.call_args.kwargs
|
||||
assert kwargs["softmax_scale"] == 0.37
|
||||
if precision == "fp8":
|
||||
assert kwargs["input_layout"] == "BNSD"
|
||||
assert quantized_inputs[0][0].shape == (2, 130, 64)
|
||||
assert quantized_inputs[0][1]["row_block_size"] == 128
|
||||
elif precision == "mxfp8":
|
||||
args = execute.call_args.args
|
||||
assert args[0].shape == (batch * q_len, q_heads, 64)
|
||||
assert args[1].shape == args[2].shape == (batch * kv_len, 2, 64)
|
||||
assert kwargs["input_layout"] == "TND"
|
||||
assert kwargs["num_query_heads"] == q_heads and kwargs["num_key_value_heads"] == 2
|
||||
assert kwargs["actual_seq_qlen"] == [q_len * (i + 1) for i in range(batch)]
|
||||
assert kwargs["actual_seq_kvlen"] == [kv_len * (i + 1) for i in range(batch)]
|
||||
assert [entry[1]["axis"] for entry in quantized_inputs] == [-1, -1, 0]
|
||||
else:
|
||||
seq_axis = 1 if layout == "BSND" else 2
|
||||
assert quantized_inputs[0][0].shape[seq_axis] == 512
|
||||
assert kwargs["seqused_q"].tolist() == kwargs["seqused_kv"].tolist() == [512]
|
||||
assert kwargs["layout_q"] == kwargs["layout_out"] == layout
|
||||
assert kwargs["q_dtype"] == torch_npu.float4_e2m1fn_x2
|
||||
assert kwargs["q_descale_dtype"] == torch_npu.float8_e8m0fnu
|
||||
assert execute.call_args.args[3].shape[:2] == (1, 2)
|
||||
assert [entry[1]["axis"] for entry in quantized_inputs] == [-1, -1, seq_axis]
|
||||
assert kwargs["metadata"] is metadata.return_value
|
||||
metadata.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("precision", ["fp8", "mxfp4"])
|
||||
@pytest.mark.parametrize("length", [65, 600])
|
||||
def test_omni_calls_real_sparse_public_api(monkeypatch, precision, length):
|
||||
rf = importlib.import_module("mindiesd.layers.flash_attn.sparse_flash_attn_rf_v3")
|
||||
monkeypatch.setattr(flash_attn, "current_omni_platform", SimpleNamespace(is_npu=lambda: True, device_name="npu"))
|
||||
for module in (flash_attn, rainfusion_attn):
|
||||
monkeypatch.setattr(module, "get_current_diffusion_config_or_none", lambda: None)
|
||||
rainfusion_attn._mindiesd_supports_precision.cache_clear()
|
||||
monkeypatch.setattr(rf, "do_tensor_rearrange_only", lambda q, k, v, *a, **kw: (q, k, v))
|
||||
monkeypatch.setattr(rf, "avgpool", lambda *a, **kw: torch.ones(1))
|
||||
monkeypatch.setattr(rf, "_generate_mask_direct", lambda *a, **kw: torch.ones(1, 2, 8, 8))
|
||||
monkeypatch.setattr(rf, "_bsa_inv_rearrange", lambda out, *a: out)
|
||||
|
||||
def quantize(tensor, **kwargs):
|
||||
shape = list(tensor.shape)
|
||||
axis = kwargs.get("axis", -1)
|
||||
shape[axis] = max(1, shape[axis] // 32)
|
||||
return tensor, torch.ones(shape, dtype=torch.uint8)
|
||||
|
||||
monkeypatch.setattr(torch_npu, "npu_dynamic_mx_quant", quantize, raising=False)
|
||||
monkeypatch.setattr(torch_npu, "npu_dynamic_block_quant", quantize, raising=False)
|
||||
execute = Mock(side_effect=lambda **kw: (torch.ones_like(kw["query"]), None))
|
||||
monkeypatch.setattr(torch.ops.mindiesd, "block_sparse_attention", execute, raising=False)
|
||||
query = torch.randn(1, length, 2, 64, dtype=torch.bfloat16)
|
||||
impl = rainfusion_attn.RainFusionAttentionImpl(
|
||||
num_heads=2,
|
||||
head_size=64,
|
||||
softmax_scale=0.37,
|
||||
qkv_layout="BSND",
|
||||
backend_kwargs={"sparsity": 0.8},
|
||||
)
|
||||
plan = rainfusion_attn.RainFusionPlan(used_len=length, prefix_len=0, latent_shape=(1, 1, length))
|
||||
try:
|
||||
output = impl._forward_sparse_npu(
|
||||
query,
|
||||
query,
|
||||
query,
|
||||
plan,
|
||||
AttentionMetadata(extra={"kv_cache_dtype": precision}),
|
||||
)
|
||||
finally:
|
||||
rainfusion_attn._mindiesd_supports_precision.cache_clear()
|
||||
torch.testing.assert_close(output, torch.ones_like(query))
|
||||
execute.assert_called_once()
|
||||
kwargs = execute.call_args.kwargs
|
||||
assert kwargs["actual_seq_lengths"] == kwargs["actual_seq_lengths_kv"] == [length]
|
||||
assert kwargs["q_input_layout"] == "BNSD"
|
||||
assert kwargs["block_sparse_mask"] is not None
|
||||
if precision == "mxfp4":
|
||||
assert kwargs["query"].shape[2] == (length + 63) // 64 * 64
|
||||
assert kwargs["quant_mode"] == 2
|
||||
assert kwargs["q_dtype"] == torch_npu.float4_e2m1fn_x2
|
||||
assert kwargs["v_scale_dtype"] == torch_npu.float8_e8m0fnu
|
||||
@@ -0,0 +1,323 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""CPU contract tests for MindIE adapters; no NPU kernels are mocked in production."""
|
||||
|
||||
import sys
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention import layer as layer_mod
|
||||
from vllm_omni.diffusion.attention.backends import flash_attn, rainfusion_attn
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata, VideoTokenLayout
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
_DEFAULT_SKIP_STEPS = frozenset({0, 1, 38, 39})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runtime(monkeypatch):
|
||||
mod = ModuleType("mindiesd")
|
||||
|
||||
def execute(q, k, v, *, precision, layout, scale, q_rot=None, k_rot=None):
|
||||
return q
|
||||
|
||||
setattr(mod, "quant_attention", Mock(side_effect=execute))
|
||||
monkeypatch.setitem(sys.modules, mod.__name__, mod)
|
||||
for module in (flash_attn, layer_mod):
|
||||
monkeypatch.setattr(module, "current_omni_platform", SimpleNamespace(is_npu=lambda: True, device_name="npu"))
|
||||
monkeypatch.setattr(flash_attn, "get_current_diffusion_config_or_none", lambda: None)
|
||||
monkeypatch.setattr(rainfusion_attn, "get_current_diffusion_config_or_none", lambda: None)
|
||||
monkeypatch.setattr(rainfusion_attn, "is_forward_context_available", lambda: False)
|
||||
supports_precision = rainfusion_attn._mindiesd_supports_precision
|
||||
supports_precision.cache_clear()
|
||||
yield mod
|
||||
supports_precision.cache_clear()
|
||||
|
||||
|
||||
def flash(layout="BSND"):
|
||||
return flash_attn.FlashAttentionImpl(
|
||||
num_heads=2,
|
||||
head_size=64,
|
||||
softmax_scale=0.37,
|
||||
qkv_layout=layout,
|
||||
)
|
||||
|
||||
|
||||
def sparse(**kwargs):
|
||||
return rainfusion_attn.RainFusionAttentionImpl(
|
||||
num_heads=2,
|
||||
head_size=64,
|
||||
softmax_scale=0.37,
|
||||
qkv_layout="BSND",
|
||||
backend_kwargs={"sparsity": 0.8, **kwargs},
|
||||
)
|
||||
|
||||
|
||||
def video_metadata(**extra):
|
||||
return AttentionMetadata(
|
||||
video_layout=VideoTokenLayout(prefix_len=0, latent_grid=(4, 32, 32), used_len=4096),
|
||||
extra={"max_seqlen_q": 4096, **extra},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["fp8", "mxfp8", "mxfp4"])
|
||||
@pytest.mark.parametrize("layout", [None, "BSND", "BNSD"])
|
||||
def test_exact_runtime_layout_scale_and_rotation(runtime, method, layout):
|
||||
q = torch.randn(1, 128, 2, 64, dtype=torch.bfloat16)
|
||||
if layout == "BNSD":
|
||||
q = q.transpose(1, 2)
|
||||
impl = flash(layout=layout)
|
||||
metadata = AttentionMetadata(extra={"kv_cache_dtype": method})
|
||||
assert impl.forward_npu(q, q, q, metadata) is q
|
||||
args, kwargs = runtime.quant_attention.call_args
|
||||
assert args[0] is q # no unconditional transpose or extra quantization
|
||||
assert kwargs["layout"] == (layout or "BSND") and kwargs["scale"] == 0.37 and kwargs["precision"] == method
|
||||
assert "attn_mask" not in kwargs
|
||||
from vllm_omni.platforms.npu.quant.kv_quant_npu import get_quant_attention_rotation
|
||||
|
||||
assert kwargs["q_rot"] is kwargs["k_rot"]
|
||||
torch.testing.assert_close(kwargs["q_rot"], get_quant_attention_rotation(q.device, q.dtype, 64))
|
||||
|
||||
|
||||
def test_missing_runtime_requires_config_change(runtime):
|
||||
q = torch.randn(1, 128, 2, 64, dtype=torch.bfloat16)
|
||||
del runtime.quant_attention
|
||||
with pytest.raises(ImportError, match="diffusion_kv_cache_dtype"):
|
||||
flash().forward_npu(q, q, q, AttentionMetadata(extra={"kv_cache_dtype": "mxfp4"}))
|
||||
|
||||
|
||||
def test_operator_failure_never_falls_back(runtime):
|
||||
q = torch.randn(1, 128, 2, 64, dtype=torch.bfloat16)
|
||||
runtime.quant_attention.side_effect = RuntimeError("operator failure")
|
||||
with pytest.raises(RuntimeError, match="operator failure"):
|
||||
flash().forward_npu(q, q, q, AttentionMetadata(extra={"kv_cache_dtype": "mxfp4"}))
|
||||
runtime.quant_attention.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["fp8", "mxfp8", "mxfp4"])
|
||||
def test_unsupported_shape_requires_config_change(runtime, method):
|
||||
q = torch.randn(1, 128, 2, 96, dtype=torch.bfloat16)
|
||||
with pytest.raises(ValueError, match="diffusion_kv_cache_dtype"):
|
||||
flash().forward_npu(q, q, q, AttentionMetadata(extra={"kv_cache_dtype": method}))
|
||||
runtime.quant_attention.assert_not_called()
|
||||
|
||||
|
||||
def test_packed_metadata_never_reaches_quant_runtime(runtime):
|
||||
q = torch.randn(1, 128, 2, 64, dtype=torch.bfloat16)
|
||||
metadata = AttentionMetadata(extra={"kv_cache_dtype": "mxfp8", "cu_seqlens_q": torch.tensor([0, 64, 128])})
|
||||
with pytest.raises(ValueError, match="packed/varlen"):
|
||||
flash().forward_npu(q, q, q, metadata)
|
||||
runtime.quant_attention.assert_not_called()
|
||||
|
||||
|
||||
def make_layer(runtime, monkeypatch, method="mxfp8", skip_steps=_DEFAULT_SKIP_STEPS):
|
||||
layer = object.__new__(layer_mod.Attention)
|
||||
torch.nn.Module.__init__(layer)
|
||||
layer.attention = flash()
|
||||
layer.attn_backend = flash_attn.FlashAttentionBackend
|
||||
layer._disable_kv_quant = False
|
||||
layer.layer_idx = 3
|
||||
cfg = SimpleNamespace(
|
||||
diffusion_kv_cache_dtype=method,
|
||||
diffusion_kv_cache_skip_step_indices=skip_steps,
|
||||
diffusion_kv_cache_skip_layer_indices=None,
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
)
|
||||
layer._init_kv_cache_quantization(cfg)
|
||||
return layer
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["fp8", "mxfp8", "mxfp4"])
|
||||
def test_global_quant_method_is_published_per_forward(runtime, monkeypatch, method):
|
||||
layer = make_layer(runtime, monkeypatch, method=method, skip_steps=None)
|
||||
assert layer._with_kv_cache_dtype(None).extra["kv_cache_dtype"] == method
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", [None, "auto", "float"])
|
||||
def test_optout_without_quant_policy_keeps_metadata_unchanged(runtime, monkeypatch, method):
|
||||
layer = make_layer(runtime, monkeypatch)
|
||||
layer._disable_kv_quant = True
|
||||
layer._init_kv_cache_quantization(
|
||||
SimpleNamespace(
|
||||
diffusion_kv_cache_dtype=method,
|
||||
diffusion_kv_cache_skip_step_indices=None,
|
||||
diffusion_kv_cache_skip_layer_indices=None,
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
)
|
||||
)
|
||||
source = video_metadata()
|
||||
assert layer._with_kv_cache_dtype(source) is source
|
||||
|
||||
|
||||
def test_step_skip_is_request_local_and_cross_optout(runtime, monkeypatch):
|
||||
layer = make_layer(runtime, monkeypatch)
|
||||
ctx = SimpleNamespace(denoise_step_idx=0)
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: True)
|
||||
monkeypatch.setattr(layer_mod, "get_forward_context", lambda: ctx)
|
||||
source = video_metadata()
|
||||
for step in list(range(40)) * 2:
|
||||
ctx.denoise_step_idx = step
|
||||
resolved = layer._with_kv_cache_dtype(source)
|
||||
assert resolved.extra["kv_cache_dtype"] == ("float" if step in (0, 1, 38, 39) else "mxfp8")
|
||||
assert resolved.video_layout is source.video_layout
|
||||
assert source.extra == {"max_seqlen_q": 4096}
|
||||
layer._disable_kv_quant = True
|
||||
assert layer._with_kv_cache_dtype(source).extra["kv_cache_dtype"] == "float"
|
||||
|
||||
|
||||
def test_ring_attention_is_rejected(runtime, monkeypatch):
|
||||
layer = make_layer(runtime, monkeypatch)
|
||||
cfg = SimpleNamespace(diffusion_kv_cache_dtype="mxfp8", parallel_config=SimpleNamespace(ring_degree=2))
|
||||
with pytest.raises(ValueError, match="ring"):
|
||||
layer._init_kv_cache_quantization(cfg)
|
||||
|
||||
|
||||
def test_sparse_tail_steps_and_dense_quant_dispatch(runtime, monkeypatch):
|
||||
ctx = SimpleNamespace(denoise_step_idx=49, total_denoise_steps=50)
|
||||
monkeypatch.setattr(rainfusion_attn, "is_forward_context_available", lambda: True)
|
||||
monkeypatch.setattr(rainfusion_attn, "get_forward_context", lambda: ctx)
|
||||
impl = sparse(start_step=2, end_step=2)
|
||||
metadata = video_metadata(kv_cache_dtype="fp8")
|
||||
assert impl._resolve_plan(metadata) is None
|
||||
q = torch.randn(1, 4096, 2, 64, dtype=torch.bfloat16)
|
||||
impl.forward_npu(q, q, q, metadata)
|
||||
assert runtime.quant_attention.call_args.kwargs["precision"] == "fp8"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["fp8", "mxfp4"])
|
||||
def test_sparse_quant_uses_public_runtime(runtime, monkeypatch, method):
|
||||
runtime.sparse_attention = Mock(side_effect=lambda q, k, v, **kwargs: q)
|
||||
monkeypatch.setattr(rainfusion_attn, "_mindiesd_supports_precision", lambda: True)
|
||||
q = torch.randn(1, 4224, 2, 64, dtype=torch.bfloat16)
|
||||
out = sparse().forward_npu(q, q, q, video_metadata(kv_cache_dtype=method))
|
||||
# The sparse path reuses the query tail as the output padding instead of
|
||||
# zero-filling a fresh buffer; rows past the 4096-row valid prefix are
|
||||
# never read back downstream (the model unpads by cu_seqlens), so pin the
|
||||
# passthrough rather than requiring zeros.
|
||||
assert out.shape == q.shape and torch.equal(out[:, 4096:], q[:, 4096:])
|
||||
kwargs = runtime.sparse_attention.call_args.kwargs
|
||||
assert (kwargs["precision"], kwargs["sparse_type"], kwargs["inner_precise"]) == (method, "rf_v3", 4)
|
||||
runtime.quant_attention.assert_not_called()
|
||||
|
||||
|
||||
def test_sparse_custom_mask_stays_dense(runtime):
|
||||
metadata = AttentionMetadata(
|
||||
attn_mask=torch.ones(1, 4096, dtype=torch.bool),
|
||||
video_layout=VideoTokenLayout(prefix_len=0, latent_grid=(4, 32, 32)),
|
||||
extra={"max_seqlen_q": 4096},
|
||||
)
|
||||
assert sparse()._resolve_plan(metadata) is None
|
||||
|
||||
|
||||
def test_sparse_model_padding_mask_is_equivalent_to_trimming(runtime):
|
||||
metadata = video_metadata()
|
||||
metadata.attn_mask = torch.arange(4224)[None] < 4096
|
||||
assert sparse()._resolve_plan(metadata).used_len == 4096
|
||||
|
||||
|
||||
def test_bsa_operator_failure_never_retries(runtime, monkeypatch):
|
||||
runtime.sparse_attention = Mock(side_effect=RuntimeError("BSA operator failure"))
|
||||
monkeypatch.setattr(rainfusion_attn, "_mindiesd_supports_precision", lambda: True)
|
||||
q = torch.randn(1, 4096, 2, 64, dtype=torch.bfloat16)
|
||||
with pytest.raises(RuntimeError, match="BSA operator failure"):
|
||||
sparse().forward_npu(q, q, q, video_metadata(kv_cache_dtype="mxfp4"))
|
||||
runtime.sparse_attention.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"method,shape,supports_precision,error",
|
||||
[
|
||||
("mxfp8", (1, 4096, 2, 64), True, "sparse mxfp8 is not supported"),
|
||||
("mxfp4", (1, 4096, 2, 64), False, "explicitly support precision"),
|
||||
("fp8", (2, 4096, 2, 64), True, "batch size 1"),
|
||||
("fp8", (1, 4096, 2, 32), True, "power-of-two head dimension"),
|
||||
("mxfp4", (1, 4096, 2, 96), True, "power-of-two head dimension"),
|
||||
],
|
||||
)
|
||||
def test_invalid_sparse_quant_requires_config_change(runtime, monkeypatch, method, shape, supports_precision, error):
|
||||
runtime.sparse_attention = Mock(side_effect=lambda q, k, v, **kwargs: q)
|
||||
monkeypatch.setattr(rainfusion_attn, "_mindiesd_supports_precision", lambda: supports_precision)
|
||||
q = torch.randn(shape, dtype=torch.bfloat16)
|
||||
with pytest.raises(ValueError, match=error):
|
||||
sparse().forward_npu(q, q, q, video_metadata(kv_cache_dtype=method))
|
||||
runtime.sparse_attention.assert_not_called()
|
||||
|
||||
|
||||
def test_layer_selector_requires_index_except_cross_optout(runtime, monkeypatch):
|
||||
layer = make_layer(runtime, monkeypatch)
|
||||
layer.layer_idx = None
|
||||
config = SimpleNamespace(
|
||||
diffusion_kv_cache_dtype="mxfp8",
|
||||
diffusion_kv_cache_skip_step_indices=None,
|
||||
diffusion_kv_cache_skip_layer_indices={0, 3},
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
)
|
||||
with pytest.raises(ValueError, match="parseable transformer block index"):
|
||||
layer._init_kv_cache_quantization(config)
|
||||
layer._disable_kv_quant = True
|
||||
layer._init_kv_cache_quantization(config)
|
||||
assert layer._with_kv_cache_dtype(None).extra["kv_cache_dtype"] == "float"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["transformer.blocks.3.attn1", "transformer_2.blocks.3.attn1"])
|
||||
@pytest.mark.parametrize("method", ["fp8", "mxfp4"])
|
||||
def test_expert_layer_and_step_skip_remain_sparse(runtime, monkeypatch, prefix, method):
|
||||
def execute(q, k, v, *, precision="bf16", **kwargs):
|
||||
return q
|
||||
|
||||
runtime.sparse_attention = Mock(wraps=execute)
|
||||
monkeypatch.setattr(rainfusion_attn, "_mindiesd_supports_precision", lambda: True)
|
||||
layer = make_layer(runtime, monkeypatch)
|
||||
layer.layer_idx = rainfusion_attn._try_extract_layer_index(prefix)
|
||||
assert layer.layer_idx == 3
|
||||
config = SimpleNamespace(
|
||||
diffusion_kv_cache_dtype=method,
|
||||
diffusion_kv_cache_skip_step_indices={0, 1, 38, 39},
|
||||
diffusion_kv_cache_skip_layer_indices={3},
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
)
|
||||
impl = sparse()
|
||||
layer.attention = impl
|
||||
layer.attn_backend = rainfusion_attn.RainFusionAttentionBackend
|
||||
layer._init_kv_cache_quantization(config)
|
||||
ctx = SimpleNamespace(denoise_step_idx=0)
|
||||
monkeypatch.setattr(layer_mod, "is_forward_context_available", lambda: True)
|
||||
monkeypatch.setattr(layer_mod, "get_forward_context", lambda: ctx)
|
||||
source = video_metadata(kv_cache_dtype="mxfp8")
|
||||
q = torch.randn(1, 4096, 2, 64, dtype=torch.bfloat16)
|
||||
for layer_idx, step in ((3, 0), (3, 2), (4, 0), (4, 1), (4, 2), (4, 37), (4, 38), (4, 39), (4, 0)):
|
||||
expected = "bf16" if layer_idx == 3 or step in (0, 1, 38, 39) else method
|
||||
layer.layer_idx = layer_idx
|
||||
ctx.denoise_step_idx = step
|
||||
metadata = layer._with_kv_cache_dtype(source)
|
||||
impl.forward_npu(q, q, q, metadata)
|
||||
assert runtime.sparse_attention.call_args.kwargs["precision"] == expected
|
||||
runtime.quant_attention.assert_not_called()
|
||||
assert source.extra["kv_cache_dtype"] == "mxfp8"
|
||||
|
||||
|
||||
def test_custom_attention_initialization_keeps_upstream_optout(monkeypatch):
|
||||
monkeypatch.setattr(layer_mod, "get_current_diffusion_config_or_none", lambda: None)
|
||||
monkeypatch.setattr(layer_mod, "build_parallel_attention_strategy", lambda **kw: object())
|
||||
monkeypatch.setattr(layer_mod, "NoParallelAttention", lambda: object())
|
||||
custom = torch.nn.Identity()
|
||||
layer = layer_mod.Attention(
|
||||
num_heads=2,
|
||||
head_size=64,
|
||||
softmax_scale=0.125,
|
||||
causal=False,
|
||||
custom_attention=custom,
|
||||
skip_sequence_parallel=True,
|
||||
)
|
||||
assert layer.attention is custom and layer._kv_cache_dtype is None
|
||||
|
||||
|
||||
def test_legacy_global_fp8_uses_implicit_bsnd(runtime):
|
||||
q = torch.randn(1, 17, 2, 64, dtype=torch.bfloat16)
|
||||
impl = flash(layout=None)
|
||||
assert impl.forward_npu(q, q, q, AttentionMetadata(extra={"kv_cache_dtype": "fp8"})) is q
|
||||
assert runtime.quant_attention.call_args.kwargs["layout"] == "BSND"
|
||||
assert runtime.quant_attention.call_args.kwargs["precision"] == "fp8"
|
||||
@@ -133,18 +133,17 @@ def test_invalid_ref2va_video_spans_fall_back_to_dense():
|
||||
assert make_impl()._resolve_plan(AttentionMetadata(extra={"max_seqlen_q": 12000}, video_layout=layout)) is None
|
||||
|
||||
|
||||
def test_validate_available_rejects_legacy_mindiesd(monkeypatch):
|
||||
def test_validate_available_accepts_single_video_mindiesd(monkeypatch):
|
||||
mindiesd = types.ModuleType("mindiesd")
|
||||
|
||||
def sparse_attention(query, key, value, **kwargs):
|
||||
return query
|
||||
|
||||
mindiesd.sparse_attention = sparse_attention
|
||||
monkeypatch.setattr(mindiesd, "sparse_attention", sparse_attention, raising=False)
|
||||
monkeypatch.setitem(sys.modules, "mindiesd", mindiesd)
|
||||
monkeypatch.setattr("importlib.util.find_spec", lambda _: object())
|
||||
|
||||
with pytest.raises(ValueError, match="video_spans"):
|
||||
RainFusionAttentionBackend.validate_available()
|
||||
RainFusionAttentionBackend.validate_available()
|
||||
|
||||
|
||||
def test_validate_available_accepts_new_mindiesd(monkeypatch):
|
||||
@@ -153,13 +152,25 @@ def test_validate_available_accepts_new_mindiesd(monkeypatch):
|
||||
def sparse_attention(query, key, value, *, video_spans=None, **kwargs):
|
||||
return query
|
||||
|
||||
mindiesd.sparse_attention = sparse_attention
|
||||
monkeypatch.setattr(mindiesd, "sparse_attention", sparse_attention, raising=False)
|
||||
monkeypatch.setitem(sys.modules, "mindiesd", mindiesd)
|
||||
monkeypatch.setattr("importlib.util.find_spec", lambda _: object())
|
||||
|
||||
RainFusionAttentionBackend.validate_available()
|
||||
|
||||
|
||||
def test_prefix_kv_slicing_contract_is_pinned():
|
||||
# Regression pin for the #5543 packed-padding crash: reverting the flag
|
||||
# would pass CI while reintroducing the ValueError for non-64-aligned
|
||||
# requests. Both halves of the contract must hold together — the impl
|
||||
# trims [real, pad] packed tensors to the valid prefix itself (so it may
|
||||
# advertise prefix slicing) and it never reads attn_mask (so it must keep
|
||||
# refusing one), which is what lets the model skip materializing the
|
||||
# padding mask that _assert_metadata_compatible would reject.
|
||||
assert RainFusionAttentionBackend.supports_prefix_kv_slicing is True
|
||||
assert RainFusionAttentionBackend.supports_attention_mask() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("grid", [(4, 24, 40), (1, 24, 40)])
|
||||
def test_short_video_stays_dense(grid):
|
||||
assert make_impl()._resolve_plan(make_metadata(grid)) is None
|
||||
@@ -300,22 +311,21 @@ def _fake_mindiesd_module():
|
||||
import types
|
||||
|
||||
fake = types.ModuleType("mindiesd")
|
||||
fake.sparse_attention = lambda *args, **kwargs: None
|
||||
setattr(fake, "sparse_attention", lambda *args, **kwargs: None)
|
||||
return fake
|
||||
|
||||
|
||||
def test_precision_non_bf16_requires_mindiesd_support():
|
||||
"""precision != bf16 against a mindiesd lacking the kwarg must raise RuntimeError."""
|
||||
"""Unsupported sparse precision must fail before any operator executes."""
|
||||
import sys
|
||||
|
||||
impl = make_impl(precision="mix")
|
||||
sys.modules["mindiesd"] = _fake_mindiesd_module()
|
||||
try:
|
||||
with mock.patch.dict(sys.modules, {"mindiesd": _fake_mindiesd_module()}):
|
||||
with mock.patch.object(rainfusion_attn, "_mindiesd_supports_precision", return_value=False):
|
||||
with pytest.raises(RuntimeError, match="requires MindIE-SD"):
|
||||
impl._forward_sparse_npu(None, None, None, None)
|
||||
finally:
|
||||
sys.modules.pop("mindiesd", None)
|
||||
with pytest.raises(ValueError, match="explicitly support precision"):
|
||||
impl._forward_sparse_npu(
|
||||
None, None, None, RainFusionPlan(prefix_len=0, used_len=8, latent_shape=[2, 2, 2])
|
||||
)
|
||||
|
||||
|
||||
def test_precision_non_bf16_passes_gate_when_supported():
|
||||
@@ -323,8 +333,7 @@ def test_precision_non_bf16_passes_gate_when_supported():
|
||||
import sys
|
||||
|
||||
impl = make_impl(precision="mix")
|
||||
sys.modules["mindiesd"] = _fake_mindiesd_module()
|
||||
try:
|
||||
with mock.patch.dict(sys.modules, {"mindiesd": _fake_mindiesd_module()}):
|
||||
with mock.patch.object(rainfusion_attn, "_mindiesd_supports_precision", return_value=True):
|
||||
# q/k/v shapes: [B, S, N, D]; plan geometry must match S.
|
||||
q = torch.randn(1, 8, 4, 128)
|
||||
@@ -332,5 +341,3 @@ def test_precision_non_bf16_passes_gate_when_supported():
|
||||
# The gate must pass; the fake mindiesd returns None so no crash.
|
||||
out = impl._forward_sparse_npu(q, q, q, plan)
|
||||
assert out is None
|
||||
finally:
|
||||
sys.modules.pop("mindiesd", None)
|
||||
@@ -0,0 +1,221 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.attention.backends import ring_flash_attn, ring_pytorch_attn
|
||||
from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_selector import AttnType
|
||||
from vllm_omni.diffusion.attention.backends.ring.ring_utils import ring_kv_block_valid_length
|
||||
from vllm_omni.diffusion.attention.parallel import ring as ring_parallel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
class _SingleRankComm:
|
||||
rank = 0
|
||||
world_size = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakeSequenceParallelGroup:
|
||||
ring_group: object
|
||||
|
||||
|
||||
def _fake_attention_result(q, _k, _v, **_kwargs):
|
||||
return torch.zeros_like(q), torch.zeros(q.shape[0], q.shape[2], q.shape[1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("block_rank", "expected"), [(0, 4), (1, 1), (2, 0)])
|
||||
def test_global_prefix_is_partitioned_across_ring_blocks(block_rank, expected):
|
||||
assert ring_kv_block_valid_length(5, 4, block_rank, 3) == expected
|
||||
|
||||
|
||||
def test_absent_prefix_length_keeps_the_full_ring_block():
|
||||
assert ring_kv_block_valid_length(None, 4, 2, 3) == 4
|
||||
|
||||
|
||||
def test_pytorch_ring_trims_padding_from_each_kv_block(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
captured.update(k=k, v=v, **kwargs)
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output = ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
group=object(),
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
assert captured["k"].shape[1] == 3
|
||||
assert captured["v"].shape[1] == 3
|
||||
|
||||
|
||||
def test_pytorch_ring_trims_circulated_blocks_by_source_rank(monkeypatch):
|
||||
observed_lengths = []
|
||||
# Each ring step sends K and V separately.
|
||||
blocks = [torch.randn(1, 4, 2, 8) for _ in range(4)]
|
||||
|
||||
class FakeRingComm:
|
||||
rank = 2
|
||||
world_size = 3
|
||||
|
||||
def __init__(self, _group):
|
||||
self._next_block = 0
|
||||
|
||||
def send_recv(self, _tensor):
|
||||
block = blocks[self._next_block]
|
||||
self._next_block += 1
|
||||
return block
|
||||
|
||||
def commit(self):
|
||||
pass
|
||||
|
||||
def wait(self):
|
||||
pass
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
observed_lengths.append(k.shape[1])
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", FakeRingComm)
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
group=object(),
|
||||
valid_kv_length=5,
|
||||
)
|
||||
|
||||
# Rank 2 starts with global block 2, then receives blocks 1 and 0.
|
||||
# The first block is empty and therefore does not launch attention.
|
||||
assert observed_lengths == [1, 4]
|
||||
|
||||
|
||||
def test_flash_ring_trims_padding_from_each_kv_block(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_attention(q, k, v, **kwargs):
|
||||
captured.update(k=k, v=v, **kwargs)
|
||||
return _fake_attention_result(q, k, v, **kwargs)
|
||||
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_flash_attn, "select_flash_attn_impl", lambda *_args, **_kwargs: fake_attention)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output, _ = ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=False,
|
||||
attn_type=AttnType.FA,
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
assert captured["k"].shape[1] == 3
|
||||
assert captured["v"].shape[1] == 3
|
||||
|
||||
|
||||
def test_ring_dispatch_forwards_global_valid_kv_length(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_ring_attention(*_args, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return "output"
|
||||
|
||||
monkeypatch.setattr(ring_pytorch_attn, "ring_pytorch_attn_func", fake_ring_attention)
|
||||
strategy = ring_parallel.RingParallelAttention(
|
||||
_FakeSequenceParallelGroup(ring_group=object()),
|
||||
attn_backend_pref="sdpa",
|
||||
)
|
||||
metadata = AttentionMetadata(extra={"valid_kv_length": 3})
|
||||
query = torch.randn(1, 4, 2, 8)
|
||||
|
||||
assert strategy.run_attention(query, query, query, metadata) == "output"
|
||||
assert captured["valid_kv_length"] == 3
|
||||
|
||||
|
||||
def test_pytorch_ring_rejects_causal_with_a_valid_prefix(monkeypatch):
|
||||
"""Trimming a K/V block under causal=True would silently shift the diagonal.
|
||||
|
||||
The query block keeps the full padded length while the circulated K/V block
|
||||
is trimmed, and a causal mask over a rectangular tile is bottom-right
|
||||
aligned, so the combination must be refused rather than computed.
|
||||
"""
|
||||
monkeypatch.setattr(ring_pytorch_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(ring_pytorch_attn, "pytorch_attn_forward", _fake_attention_result)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="valid_kv_length is not supported with causal=True"):
|
||||
ring_pytorch_attn.ring_pytorch_attn_func(
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
causal=True,
|
||||
group=object(),
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
|
||||
def test_flash_ring_rejects_causal_with_a_valid_prefix(monkeypatch):
|
||||
"""Same refusal on the Flash-Attention ring path."""
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(
|
||||
ring_flash_attn,
|
||||
"select_flash_attn_impl",
|
||||
lambda *_args, **_kwargs: _fake_attention_result,
|
||||
)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
with pytest.raises(ValueError, match="valid_kv_length is not supported with causal=True"):
|
||||
ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=True,
|
||||
attn_type=AttnType.FA,
|
||||
valid_kv_length=3,
|
||||
)
|
||||
|
||||
|
||||
def test_ring_paths_still_accept_causal_without_a_valid_prefix(monkeypatch):
|
||||
"""The guard is scoped to the padded case: plain causal Ring is unchanged."""
|
||||
monkeypatch.setattr(ring_flash_attn, "RingComm", lambda _group: _SingleRankComm())
|
||||
monkeypatch.setattr(
|
||||
ring_flash_attn,
|
||||
"select_flash_attn_impl",
|
||||
lambda *_args, **_kwargs: _fake_attention_result,
|
||||
)
|
||||
qkv = torch.randn(1, 4, 2, 8)
|
||||
|
||||
output, _ = ring_flash_attn.ring_flash_attn_forward(
|
||||
object(),
|
||||
qkv,
|
||||
qkv,
|
||||
qkv,
|
||||
softmax_scale=0.25,
|
||||
causal=True,
|
||||
attn_type=AttnType.FA,
|
||||
)
|
||||
|
||||
assert output.shape == qkv.shape
|
||||
+635
@@ -0,0 +1,635 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.runtime import get_distributed_init_method
|
||||
from vllm_omni.diffusion.cache.seacache import (
|
||||
SeaCacheBackend,
|
||||
SeaCacheConfig,
|
||||
SeaCacheRootHook,
|
||||
apply_sea_cache_hook,
|
||||
)
|
||||
from vllm_omni.diffusion.cache.seacache.sea_filter import (
|
||||
apply_sea_filter,
|
||||
extrapolate_residual,
|
||||
indicator_distance,
|
||||
)
|
||||
from vllm_omni.diffusion.cache.selector import get_cache_backend
|
||||
from vllm_omni.diffusion.cache.teacache.extractors import EXTRACTOR_REGISTRY, CacheContext, get_extractor
|
||||
from vllm_omni.diffusion.data import DiffusionCacheConfig
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class TinyCosmos3Transformer(torch.nn.Module):
|
||||
"""Small model that exposes a cache-neutral execution boundary."""
|
||||
|
||||
def _run_gen_layers(self, hidden_gen: torch.Tensor) -> torch.Tensor:
|
||||
return hidden_gen * 2
|
||||
|
||||
def forward(
|
||||
self,
|
||||
hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
text_ids: torch.Tensor | None = None,
|
||||
text_mask: torch.Tensor | None = None,
|
||||
video_shape: tuple[int, int, int] | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
control_latents: list[torch.Tensor] | torch.Tensor | None = None,
|
||||
**kwargs,
|
||||
) -> torch.Tensor:
|
||||
del timestep, text_ids, text_mask, video_shape, noisy_frame_mask
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected tiny transformer kwargs: {sorted(kwargs)}")
|
||||
controls = (
|
||||
[]
|
||||
if control_latents is None
|
||||
else [control_latents]
|
||||
if isinstance(control_latents, torch.Tensor)
|
||||
else list(control_latents)
|
||||
)
|
||||
inputs = [*controls, hidden_states]
|
||||
gen_input = torch.cat(
|
||||
[value.movedim(1, -1).flatten(1, 3) for value in inputs],
|
||||
dim=1,
|
||||
)
|
||||
return self._run_gen_layers(gen_input)
|
||||
|
||||
|
||||
def _extract_tiny_cosmos3_context(
|
||||
module: TinyCosmos3Transformer,
|
||||
hidden_states: torch.Tensor,
|
||||
timestep: torch.Tensor,
|
||||
text_ids: torch.Tensor | None = None,
|
||||
text_mask: torch.Tensor | None = None,
|
||||
video_shape: tuple[int, int, int] | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
control_latents: list[torch.Tensor] | torch.Tensor | None = None,
|
||||
**kwargs,
|
||||
) -> CacheContext:
|
||||
del timestep, text_ids, text_mask, video_shape
|
||||
if kwargs:
|
||||
raise TypeError(f"Unexpected tiny transformer kwargs: {sorted(kwargs)}")
|
||||
controls = (
|
||||
[]
|
||||
if control_latents is None
|
||||
else [control_latents]
|
||||
if isinstance(control_latents, torch.Tensor)
|
||||
else list(control_latents)
|
||||
)
|
||||
vision_items = [*controls, hidden_states]
|
||||
gen_input = torch.cat(
|
||||
[value.movedim(1, -1).flatten(1, 3) for value in vision_items],
|
||||
dim=1,
|
||||
)
|
||||
return CacheContext(
|
||||
modulated_input=gen_input,
|
||||
hidden_states=gen_input,
|
||||
encoder_hidden_states=None,
|
||||
temb=torch.zeros_like(gen_input[:, 0]),
|
||||
run_transformer_blocks=lambda: (module._run_gen_layers(gen_input),),
|
||||
postprocess=lambda output: output,
|
||||
extra_states={
|
||||
"sea_cache_latents": vision_items,
|
||||
"sea_cache_noisy_frame_mask": noisy_frame_mask,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Cosmos3OmniDiffusersPipeline:
|
||||
def __init__(self) -> None:
|
||||
self.transformer = TinyCosmos3Transformer()
|
||||
self._current_step_index: int | None = None
|
||||
self._current_sigma: float | None = None
|
||||
self._num_timesteps: int | None = None
|
||||
|
||||
@property
|
||||
def current_step_index(self) -> int | None:
|
||||
return self._current_step_index
|
||||
|
||||
@property
|
||||
def current_sigma(self) -> float | None:
|
||||
return self._current_sigma
|
||||
|
||||
@property
|
||||
def num_timesteps(self) -> int | None:
|
||||
return self._num_timesteps
|
||||
|
||||
|
||||
def _latent(value: float) -> torch.Tensor:
|
||||
return torch.full((1, 2, 2, 2, 2), value)
|
||||
|
||||
|
||||
def _run_step(
|
||||
transformer: TinyCosmos3Transformer,
|
||||
timestep: int,
|
||||
value: float,
|
||||
*,
|
||||
hook: SeaCacheRootHook | None = None,
|
||||
context: str = "cond",
|
||||
control: float | None = None,
|
||||
noisy_frame_mask: torch.Tensor | None = None,
|
||||
) -> torch.Tensor:
|
||||
controls = None if control is None else [_latent(control)]
|
||||
cache_context = hook.cache_context(context) if hook is not None else nullcontext()
|
||||
with torch.inference_mode(), cache_context:
|
||||
return transformer(
|
||||
hidden_states=_latent(value),
|
||||
timestep=torch.tensor([timestep]),
|
||||
noisy_frame_mask=noisy_frame_mask,
|
||||
control_latents=controls,
|
||||
)
|
||||
|
||||
|
||||
def _apply_test_hook(
|
||||
transformer: TinyCosmos3Transformer,
|
||||
metadata: SimpleNamespace,
|
||||
config: SeaCacheConfig | None = None,
|
||||
) -> SeaCacheRootHook:
|
||||
return apply_sea_cache_hook(
|
||||
transformer,
|
||||
config or SeaCacheConfig(threshold=100.0),
|
||||
current_step_callback=lambda: metadata.step,
|
||||
current_sigma_callback=lambda: metadata.sigma,
|
||||
num_inference_steps_callback=lambda: metadata.num_steps,
|
||||
extractor_fn=_extract_tiny_cosmos3_context,
|
||||
)
|
||||
|
||||
|
||||
def test_config_validation() -> None:
|
||||
assert SeaCacheConfig().threshold == 0.25
|
||||
with pytest.raises(ValueError, match="residual_order"):
|
||||
SeaCacheConfig(residual_order=-1)
|
||||
with pytest.raises(ValueError, match="max_consecutive_cached"):
|
||||
SeaCacheConfig(max_consecutive_cached=-1)
|
||||
|
||||
|
||||
def test_sea_filter_matches_reference_equation() -> None:
|
||||
hidden = torch.randn(3, 4, 5, 2, dtype=torch.float32)
|
||||
sigma = 0.4
|
||||
power_exp = 3.0
|
||||
|
||||
spectrum = torch.fft.fftn(hidden, dim=(0, 1, 2))
|
||||
gain = None
|
||||
for axis in (0, 1, 2):
|
||||
frequencies = torch.fft.fftfreq(hidden.shape[axis], dtype=torch.float32)
|
||||
clean_power = 1.0 / (frequencies.abs().pow(power_exp) + 1e-16)
|
||||
axis_gain = (1.0 - sigma) * clean_power / ((1.0 - sigma) ** 2 * clean_power + sigma**2 + 1e-16)
|
||||
shape = [1] * hidden.ndim
|
||||
shape[axis] = hidden.shape[axis]
|
||||
gain = axis_gain.reshape(shape) if gain is None else gain * axis_gain.reshape(shape)
|
||||
assert gain is not None
|
||||
gain = gain / gain.mean()
|
||||
expected = torch.fft.ifftn(spectrum * gain, dim=(0, 1, 2)).real
|
||||
|
||||
torch.testing.assert_close(
|
||||
apply_sea_filter(hidden, sigma=sigma, power_exp=power_exp),
|
||||
expected,
|
||||
)
|
||||
|
||||
|
||||
def test_indicator_distance_and_linear_extrapolation() -> None:
|
||||
previous = [torch.ones(2, 2)]
|
||||
current = [torch.full((2, 2), 1.5)]
|
||||
assert indicator_distance(current, previous) == pytest.approx(0.5)
|
||||
assert indicator_distance([torch.ones(3)], previous) == float("inf")
|
||||
|
||||
history = [
|
||||
(0, torch.full((2, 2), 2.0)),
|
||||
(2, torch.full((2, 2), 6.0)),
|
||||
]
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(history, step=3, order=1),
|
||||
torch.full((2, 2), 8.0),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(history, step=3, order=0),
|
||||
torch.full((2, 2), 6.0),
|
||||
)
|
||||
quadratic_history = [
|
||||
(0, torch.zeros(2, 2)),
|
||||
(1, torch.ones(2, 2)),
|
||||
(2, torch.full((2, 2), 4.0)),
|
||||
]
|
||||
torch.testing.assert_close(
|
||||
extrapolate_residual(quadratic_history, step=3, order=2),
|
||||
torch.full((2, 2), 9.0),
|
||||
)
|
||||
|
||||
|
||||
def test_hook_skips_middle_steps_and_forces_endpoints() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=4)
|
||||
hook = _apply_test_hook(
|
||||
transformer,
|
||||
metadata,
|
||||
SeaCacheConfig(threshold=100.0, max_consecutive_cached=2),
|
||||
)
|
||||
hook.refresh(transformer)
|
||||
|
||||
for step, timestep in enumerate((1000, 750, 500, 250)):
|
||||
metadata.step = step
|
||||
metadata.sigma = timestep / 1000
|
||||
_run_step(transformer, timestep, 1.0 - step * 0.01, hook=hook)
|
||||
|
||||
assert hook.full_count == 2
|
||||
assert hook.skip_count == 2
|
||||
assert [step for step, _ in hook.state_manager._states["cond"].history] == [0, 3]
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_skips_when_all_shard_ranks_agree(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook._parameter_sharded = True
|
||||
fs_group = object()
|
||||
reduced_groups: list[object] = []
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
parallel_state,
|
||||
"get_fs_group",
|
||||
lambda: SimpleNamespace(world_size=2, device_group=fs_group),
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 1)
|
||||
|
||||
def all_reduce(decision, *, op, group):
|
||||
assert op == torch.distributed.ReduceOp.MAX
|
||||
reduced_groups.append(group)
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "all_reduce", all_reduce)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.99, hook=hook)
|
||||
|
||||
assert hook.full_count == 1
|
||||
assert hook.skip_count == 1
|
||||
assert reduced_groups == [fs_group, fs_group]
|
||||
|
||||
|
||||
def test_parameter_sharded_peer_forces_full_compute(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook._parameter_sharded = True
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
parallel_state,
|
||||
"get_fs_group",
|
||||
lambda: SimpleNamespace(world_size=2, device_group=object()),
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 1)
|
||||
reduce_count = 0
|
||||
|
||||
def all_reduce(decision, *, op, group):
|
||||
nonlocal reduce_count
|
||||
del op, group
|
||||
reduce_count += 1
|
||||
if reduce_count == 2:
|
||||
decision.fill_(1)
|
||||
|
||||
monkeypatch.setattr(torch.distributed, "all_reduce", all_reduce)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.99, hook=hook)
|
||||
|
||||
assert hook.full_count == 2
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states["cond"].accumulated_distance == 0.0
|
||||
|
||||
|
||||
def _uneven_cfg_sharded_worker(
|
||||
rank: int,
|
||||
init_method: str,
|
||||
result_queue: torch.multiprocessing.Queue,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
torch.distributed.init_process_group(
|
||||
"gloo",
|
||||
init_method=init_method,
|
||||
rank=rank,
|
||||
world_size=4,
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
try:
|
||||
fs_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
sp_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
cfg_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 2], [1, 3])
|
||||
]
|
||||
fs_group = next(group for ranks, group in fs_groups if rank in ranks)
|
||||
sp_group = next(group for ranks, group in sp_groups if rank in ranks)
|
||||
cfg_group = next(group for ranks, group in cfg_groups if rank in ranks)
|
||||
parallel_state._FS = SimpleNamespace(world_size=2, device_group=fs_group)
|
||||
parallel_state._SP = SimpleNamespace(world_size=2, device_group=sp_group)
|
||||
|
||||
hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
hook._parameter_sharded = True
|
||||
hook._synchronize_compute(False, torch.device("cpu"))
|
||||
if rank in (0, 1):
|
||||
hook._synchronize_compute(False, torch.device("cpu"))
|
||||
|
||||
gathered = [torch.zeros(1) for _ in range(2)]
|
||||
torch.distributed.all_gather(
|
||||
gathered,
|
||||
torch.tensor([rank], dtype=torch.float32),
|
||||
group=cfg_group,
|
||||
)
|
||||
result_queue.put((rank, [int(value.item()) for value in gathered]))
|
||||
finally:
|
||||
parallel_state._FS = None
|
||||
parallel_state._SP = None
|
||||
torch.distributed.destroy_process_group()
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_handles_uneven_cfg_branch_dispatch() -> None:
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
result_queue = manager.Queue()
|
||||
torch.multiprocessing.spawn(
|
||||
_uneven_cfg_sharded_worker,
|
||||
args=(get_distributed_init_method("seacache_uneven_cfg_"), result_queue),
|
||||
nprocs=4,
|
||||
)
|
||||
|
||||
results = sorted(result_queue.get(timeout=2) for _ in range(4))
|
||||
assert results == [
|
||||
(0, [0, 2]),
|
||||
(1, [1, 3]),
|
||||
(2, [0, 2]),
|
||||
(3, [1, 3]),
|
||||
]
|
||||
|
||||
|
||||
def _hybrid_sp_worker(
|
||||
rank: int,
|
||||
init_method: str,
|
||||
result_queue: torch.multiprocessing.Queue,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
|
||||
torch.distributed.init_process_group(
|
||||
"gloo",
|
||||
init_method=init_method,
|
||||
rank=rank,
|
||||
world_size=4,
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
try:
|
||||
fs_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
ulysses_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 1], [2, 3])
|
||||
]
|
||||
ring_groups = [
|
||||
(ranks, torch.distributed.new_group(ranks, timeout=datetime.timedelta(seconds=10)))
|
||||
for ranks in ([0, 2], [1, 3])
|
||||
]
|
||||
sp_group = torch.distributed.new_group(
|
||||
[0, 1, 2, 3],
|
||||
timeout=datetime.timedelta(seconds=10),
|
||||
)
|
||||
fs_group = next(group for ranks, group in fs_groups if rank in ranks)
|
||||
ulysses_group = next(group for ranks, group in ulysses_groups if rank in ranks)
|
||||
ring_group = next(group for ranks, group in ring_groups if rank in ranks)
|
||||
parallel_state._FS = SimpleNamespace(world_size=2, device_group=fs_group)
|
||||
parallel_state._SP = SimpleNamespace(
|
||||
world_size=4,
|
||||
device_group=sp_group,
|
||||
ulysses_world_size=2,
|
||||
ulysses_group=ulysses_group,
|
||||
ring_world_size=2,
|
||||
ring_group=ring_group,
|
||||
)
|
||||
|
||||
compute = rank == 3
|
||||
non_sharded_hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
non_sharded_result = non_sharded_hook._synchronize_compute(compute, torch.device("cpu"))
|
||||
|
||||
sharded_hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
sharded_hook._parameter_sharded = True
|
||||
sharded_result = sharded_hook._synchronize_compute(compute, torch.device("cpu"))
|
||||
result_queue.put((rank, non_sharded_result, sharded_result))
|
||||
finally:
|
||||
parallel_state._FS = None
|
||||
parallel_state._SP = None
|
||||
torch.distributed.destroy_process_group()
|
||||
|
||||
|
||||
def test_hook_synchronizes_full_hybrid_sequence_parallel_group() -> None:
|
||||
mp_context = torch.multiprocessing.get_context("spawn")
|
||||
manager = mp_context.Manager()
|
||||
result_queue = manager.Queue()
|
||||
torch.multiprocessing.spawn(
|
||||
_hybrid_sp_worker,
|
||||
args=(get_distributed_init_method("seacache_hybrid_sp_"), result_queue),
|
||||
nprocs=4,
|
||||
)
|
||||
|
||||
results = sorted(result_queue.get(timeout=2) for _ in range(4))
|
||||
assert results == [
|
||||
(0, True, True),
|
||||
(1, True, True),
|
||||
(2, True, True),
|
||||
(3, True, True),
|
||||
]
|
||||
|
||||
|
||||
def test_parameter_sharded_hook_fails_open_without_distributed_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
hook = SeaCacheRootHook(SeaCacheConfig())
|
||||
hook._parameter_sharded = True
|
||||
monkeypatch.setattr(torch.distributed, "is_available", lambda: True)
|
||||
monkeypatch.setattr(torch.distributed, "is_initialized", lambda: False)
|
||||
|
||||
assert hook._synchronize_compute(False, torch.device("cpu")) is True
|
||||
|
||||
|
||||
def test_hook_keeps_three_transfer_branches_separate() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
|
||||
contexts = ("cond", "cond_no_control", "uncond")
|
||||
for step, (timestep, value) in enumerate(((1000, 1.0), (500, 0.9))):
|
||||
metadata.step = step
|
||||
metadata.sigma = timestep / 1000
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[0], control=0.5)
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[1])
|
||||
_run_step(transformer, timestep, value, hook=hook, context=contexts[2], control=0.5)
|
||||
|
||||
assert set(hook.state_manager._states) == set(contexts)
|
||||
assert hook.full_count == 3
|
||||
assert hook.skip_count == 3
|
||||
assert len(hook.state_manager._states["cond"].previous_indicator) == 2
|
||||
assert len(hook.state_manager._states["cond_no_control"].previous_indicator) == 1
|
||||
|
||||
|
||||
def test_hook_fails_open_without_noisy_vision() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
all_clean = torch.zeros(1, 1, 2, 1, 1)
|
||||
|
||||
_run_step(transformer, 1000, 1.0, hook=hook, noisy_frame_mask=all_clean)
|
||||
metadata.step = 1
|
||||
metadata.sigma = 0.5
|
||||
_run_step(transformer, 500, 0.9, hook=hook, noisy_frame_mask=all_clean)
|
||||
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
|
||||
|
||||
def test_conditioning_only_model_failure_runs_forward_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=3)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
forward_calls = 0
|
||||
|
||||
def fail_forward(hidden_gen: torch.Tensor) -> torch.Tensor:
|
||||
nonlocal forward_calls
|
||||
forward_calls += 1
|
||||
raise RuntimeError("model forward failed")
|
||||
|
||||
monkeypatch.setattr(transformer, "_run_gen_layers", fail_forward)
|
||||
all_clean = torch.zeros(1, 1, 2, 1, 1)
|
||||
|
||||
with pytest.raises(RuntimeError, match="model forward failed"):
|
||||
_run_step(transformer, 1000, 1.0, hook=hook, noisy_frame_mask=all_clean)
|
||||
|
||||
assert forward_calls == 1
|
||||
|
||||
|
||||
def test_hook_fails_open_without_explicit_context() -> None:
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=1.0, num_steps=2)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
|
||||
_run_step(transformer, 1000, 1.0)
|
||||
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states == {}
|
||||
|
||||
|
||||
def test_hook_uses_exact_sigma_callback(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.cache.seacache import hook as hook_module
|
||||
|
||||
transformer = TinyCosmos3Transformer()
|
||||
metadata = SimpleNamespace(step=0, sigma=0.37, num_steps=2)
|
||||
observed_sigmas: list[float] = []
|
||||
original_filter = hook_module.apply_sea_filter
|
||||
|
||||
def recording_filter(hidden_states: torch.Tensor, sigma: float, power_exp: float) -> torch.Tensor:
|
||||
observed_sigmas.append(sigma)
|
||||
return original_filter(hidden_states, sigma, power_exp)
|
||||
|
||||
monkeypatch.setattr(hook_module, "apply_sea_filter", recording_filter)
|
||||
hook = _apply_test_hook(transformer, metadata)
|
||||
hook.refresh(transformer)
|
||||
_run_step(transformer, timestep=999, value=1.0, hook=hook)
|
||||
|
||||
assert observed_sigmas
|
||||
assert all(sigma == pytest.approx(0.37) for sigma in observed_sigmas)
|
||||
|
||||
|
||||
def test_backend_selector_and_refresh(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(EXTRACTOR_REGISTRY, "TinyCosmos3Transformer", _extract_tiny_cosmos3_context)
|
||||
|
||||
class FSDPTinyCosmos3Transformer(TinyCosmos3Transformer):
|
||||
pass
|
||||
|
||||
assert get_extractor(FSDPTinyCosmos3Transformer) is _extract_tiny_cosmos3_context
|
||||
backend = get_cache_backend(
|
||||
"sea_cache",
|
||||
{
|
||||
"sea_threshold": 0.4,
|
||||
"sea_residual_order": 0,
|
||||
},
|
||||
)
|
||||
assert isinstance(backend, SeaCacheBackend)
|
||||
assert backend.config.sea_threshold == 0.4
|
||||
|
||||
pipeline = Cosmos3OmniDiffusersPipeline()
|
||||
backend.enable(pipeline)
|
||||
hook = pipeline.transformer._hook_registry.get_hook(SeaCacheRootHook._HOOK_NAME)
|
||||
assert isinstance(hook, SeaCacheRootHook)
|
||||
assert callable(getattr(pipeline, "_cache_context_factory", None))
|
||||
for name in ("_seacache_skip", "_seacache_record", "_seacache_residual", "_seacache_last_residual"):
|
||||
assert not hasattr(pipeline.transformer, name)
|
||||
pipeline._current_step_index = 0
|
||||
pipeline._current_sigma = 1.0
|
||||
pipeline._num_timesteps = 7
|
||||
_run_step(pipeline.transformer, 1000, 1.0, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
|
||||
backend.refresh(pipeline, num_inference_steps=7)
|
||||
assert hook.full_count == 0
|
||||
assert hook.skip_count == 0
|
||||
assert hook.state_manager._states == {}
|
||||
|
||||
|
||||
def test_backend_uses_resolved_pipeline_metadata_not_refresh_argument(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(EXTRACTOR_REGISTRY, "TinyCosmos3Transformer", _extract_tiny_cosmos3_context)
|
||||
pipeline = Cosmos3OmniDiffusersPipeline()
|
||||
backend = SeaCacheBackend(DiffusionCacheConfig())
|
||||
backend.enable(pipeline)
|
||||
hook = pipeline.transformer._hook_registry.get_hook(SeaCacheRootHook._HOOK_NAME)
|
||||
assert isinstance(hook, SeaCacheRootHook)
|
||||
backend.refresh(pipeline, num_inference_steps=35)
|
||||
pipeline._num_timesteps = 50
|
||||
pipeline._current_step_index = 0
|
||||
pipeline._current_sigma = 1.0
|
||||
_run_step(pipeline.transformer, 1000, 1.0, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
assert hook.num_inference_steps_callback is not None
|
||||
assert hook.num_inference_steps_callback() == 50
|
||||
|
||||
pipeline._current_step_index = 1
|
||||
pipeline._current_sigma = 0.98
|
||||
_run_step(pipeline.transformer, 980, 0.99, hook=hook)
|
||||
assert hook.full_count == 1
|
||||
assert hook.skip_count == 1
|
||||
|
||||
|
||||
def test_shared_config_defaults() -> None:
|
||||
config = DiffusionCacheConfig()
|
||||
assert config.sea_threshold == 0.25
|
||||
assert config.sea_residual_order == 1
|
||||
assert config.sea_max_consecutive_cached == 2
|
||||
assert config.sea_power_exp == 3.0
|
||||
+5
-5
@@ -68,12 +68,12 @@ class BaseExtractorTest(ABC):
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_module(self):
|
||||
def get_module(self, module, /):
|
||||
"""Return model module instance."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sample_inputs(self):
|
||||
def get_sample_inputs(self, sample_inputs, /):
|
||||
"""Return sample inputs for model."""
|
||||
pass
|
||||
|
||||
@@ -737,10 +737,10 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
|
||||
monkeypatch,
|
||||
):
|
||||
"""Strict SP must not mix local TeaCache state with gathered block rows."""
|
||||
from vllm_omni.diffusion.attention.ops import minimax_h3_modulation
|
||||
from vllm_omni.diffusion.cache.teacache.config import TeaCacheConfig
|
||||
from vllm_omni.diffusion.cache.teacache.hook import TeaCacheHook
|
||||
from vllm_omni.diffusion.distributed import parallel_state
|
||||
from vllm_omni.diffusion.layers import indexed_modulation
|
||||
|
||||
seq_len = sample_inputs["x"].shape[1]
|
||||
local_len = seq_len // 2
|
||||
@@ -844,13 +844,13 @@ class TestMiniMaxH3Extractor(BaseExtractorTest):
|
||||
)
|
||||
monkeypatch.setattr(parallel_state, "get_sp_group", lambda: sp_group)
|
||||
|
||||
original_indexed_scale_shift = minimax_h3_modulation.indexed_scale_shift_
|
||||
original_indexed_scale_shift = indexed_modulation.indexed_scale_shift_
|
||||
|
||||
def capture_indexed_scale_shift(hidden, shift, scale, indices):
|
||||
captured_modulation_indices.append(indices.clone())
|
||||
return original_indexed_scale_shift(hidden, shift, scale, indices)
|
||||
|
||||
monkeypatch.setattr(minimax_h3_modulation, "indexed_scale_shift_", capture_indexed_scale_shift)
|
||||
monkeypatch.setattr(indexed_modulation, "indexed_scale_shift_", capture_indexed_scale_shift)
|
||||
|
||||
local_img_pos = torch.tensor([0, 1])
|
||||
local_audio_pos = torch.tensor([2, 3])
|
||||
|
||||
@@ -325,7 +325,7 @@ def _runner(
|
||||
model_config=SimpleNamespace(max_model_len=max_len),
|
||||
scheduler_config=SimpleNamespace(
|
||||
max_num_batched_tokens=max_num_batched_tokens,
|
||||
max_num_seqs=max_num_seqs,
|
||||
max_num_seqs=max_num_seqs * max_rows_per_request,
|
||||
),
|
||||
parallel_config=SimpleNamespace(
|
||||
decode_context_parallel_size=cp_size,
|
||||
@@ -403,7 +403,7 @@ def test_initialize_failure_does_not_publish_partial_cache_state(
|
||||
successful_init_cache = model_runner_backend_module.init_kv_cache
|
||||
vllm_config = SimpleNamespace(
|
||||
model_config=SimpleNamespace(max_model_len=16),
|
||||
scheduler_config=SimpleNamespace(max_num_batched_tokens=32, max_num_seqs=1),
|
||||
scheduler_config=SimpleNamespace(max_num_batched_tokens=32, max_num_seqs=2),
|
||||
parallel_config=SimpleNamespace(
|
||||
decode_context_parallel_size=1,
|
||||
cp_kv_cache_interleave_size=1,
|
||||
@@ -445,7 +445,7 @@ def test_initialize_failure_does_not_publish_partial_cache_state(
|
||||
assert backend.block_tables is None
|
||||
assert backend.paged_attention_adapter is None
|
||||
assert backend._kv_cache_layer_adapters["layer-0"].kv_cache == "placeholder-0"
|
||||
assert backend.vllm_config.scheduler_config.max_num_seqs == 1
|
||||
assert backend.vllm_config.scheduler_config.max_num_seqs == 2
|
||||
|
||||
monkeypatch.setattr(model_runner_backend_module, "init_kv_cache", successful_init_cache)
|
||||
backend.initialize_kv_cache(config)
|
||||
|
||||
@@ -58,6 +58,7 @@ def _od_config(**overrides):
|
||||
gpu_memory_utilization=0.9,
|
||||
kv_cache_memory_bytes=None,
|
||||
max_num_seqs=1,
|
||||
diffusion_kv_max_rows_per_request=1,
|
||||
max_num_batched_tokens=64,
|
||||
num_gpus=1,
|
||||
)
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.config import KVTransferConfig
|
||||
from vllm.v1.core.kv_cache_manager import KVCacheBlocks
|
||||
from vllm.v1.outputs import KVConnectorOutput
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.diffusion_kv.kv_connector import (
|
||||
KVTransferRegistrationError,
|
||||
commit_kv_load,
|
||||
parse_kv_transfer_config,
|
||||
shutdown_kv_connector,
|
||||
wait_for_kv_load,
|
||||
)
|
||||
from vllm_omni.diffusion.sched.base_scheduler import BaseScheduler
|
||||
from vllm_omni.diffusion.vllm_config import create_diffusion_vllm_config
|
||||
@@ -138,3 +145,205 @@ def test_shutdown_kv_connector_is_idempotent() -> None:
|
||||
|
||||
assert ensure_shutdown.call_count == 2
|
||||
scheduler_connector.shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_commit_aligns_cfg_transport_pages_without_extending_computed_prefix() -> None:
|
||||
requests = tuple(
|
||||
SimpleNamespace(
|
||||
request_id=f"req/{sequence_id}",
|
||||
kv_transfer_params={"num_transfer_tokens": 9},
|
||||
num_tokens=16,
|
||||
num_computed_tokens=0,
|
||||
)
|
||||
for sequence_id in range(2)
|
||||
)
|
||||
manager = SimpleNamespace(
|
||||
get_blocks=lambda _request_id: KVCacheBlocks(([0, 1, 2, 3],)),
|
||||
kv_cache_config=SimpleNamespace(kv_cache_groups=[SimpleNamespace(kv_cache_spec=SimpleNamespace(block_size=4))]),
|
||||
)
|
||||
connector = mock.Mock()
|
||||
|
||||
expected = commit_kv_load(connector, manager, requests, [7, 5])
|
||||
|
||||
assert expected == {"req/0", "req/1"}
|
||||
calls = connector.update_state_after_alloc.call_args_list
|
||||
assert [len(call.args[1].blocks[0]) for call in calls] == [3, 3]
|
||||
assert [call.args[2] for call in calls] == [7, 5]
|
||||
assert [request.num_computed_tokens for request in requests] == [7, 5]
|
||||
|
||||
|
||||
def test_wait_without_pending_load_uses_vllm_post_forward_once() -> None:
|
||||
connector = mock.Mock()
|
||||
output = mock.Mock(invalid_block_ids=set())
|
||||
active_connector = mock.Mock(kv_connector=connector)
|
||||
active_connector.post_forward.return_value = output
|
||||
scheduler_output = SimpleNamespace(
|
||||
kv_transfer_request_ids=set(),
|
||||
finished_req_ids={"finished"},
|
||||
kv_finished_request_ids={"finished/diffusion-kv/0", "finished/diffusion-kv/1"},
|
||||
)
|
||||
|
||||
result = wait_for_kv_load(active_connector, scheduler_output, timeout=1.0)
|
||||
|
||||
assert result is output
|
||||
active_connector.pre_forward.assert_called_once_with(scheduler_output)
|
||||
active_connector.post_forward.assert_called_once_with({"finished/diffusion-kv/0", "finished/diffusion-kv/1"})
|
||||
connector.get_finished.assert_not_called()
|
||||
|
||||
|
||||
def test_timeout_returns_partial_completion_without_releasing_pages():
|
||||
active = mock.Mock()
|
||||
active.kv_connector.get_finished.return_value = (set(), {"cfg0"})
|
||||
active.kv_connector.get_block_ids_with_load_errors.return_value = set()
|
||||
active.post_forward.return_value = KVConnectorOutput()
|
||||
scheduled = SimpleNamespace(kv_transfer_request_ids={"cfg0", "cfg1"}, kv_finished_request_ids=set())
|
||||
output = wait_for_kv_load(active, scheduled, timeout=0)
|
||||
assert output.finished_recving == {"cfg0"}
|
||||
active.kv_connector.request_finished.assert_not_called()
|
||||
active.post_forward.assert_called_once_with(set())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cfg_registration():
|
||||
requests = tuple(
|
||||
SimpleNamespace(
|
||||
request_id=f"cfg{i}",
|
||||
status=None,
|
||||
num_tokens=8,
|
||||
kv_transfer_params={"num_transfer_tokens": 4, "transfer_id": "ticket", "do_remote_prefill": True},
|
||||
)
|
||||
for i in range(2)
|
||||
)
|
||||
manager = SimpleNamespace(
|
||||
get_blocks=lambda _: KVCacheBlocks(([0, 1],)),
|
||||
kv_cache_config=SimpleNamespace(kv_cache_groups=[SimpleNamespace(kv_cache_spec=SimpleNamespace(block_size=4))]),
|
||||
)
|
||||
return requests, manager
|
||||
|
||||
|
||||
def test_cfg_validation_is_atomic_before_connector_registration(cfg_registration):
|
||||
requests, manager = cfg_registration
|
||||
requests[1].kv_transfer_params["num_transfer_tokens"] = 9
|
||||
connector = mock.Mock()
|
||||
with pytest.raises(KVTransferRegistrationError, match="boundary"):
|
||||
commit_kv_load(connector, manager, requests, [4, 4])
|
||||
connector.update_state_after_alloc.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_init", [False, True])
|
||||
def test_sp_connector_init_snapshots_topology_and_restores_model_tp(monkeypatch, fail_init):
|
||||
import vllm.distributed.parallel_state as parallel_state
|
||||
|
||||
import vllm_omni.diffusion.diffusion_kv.kv_connector as module
|
||||
import vllm_omni.diffusion.distributed.parallel_state as omni_parallel
|
||||
|
||||
tp = SimpleNamespace(world_size=1, rank_in_group=0)
|
||||
sp = SimpleNamespace(world_size=2, rank_in_group=1)
|
||||
monkeypatch.setattr(parallel_state, "_TP", tp)
|
||||
monkeypatch.setattr(omni_parallel, "get_sp_group", lambda: sp)
|
||||
snapshot: dict[str, int] = {}
|
||||
|
||||
def initialize(*args):
|
||||
snapshot.update(
|
||||
rank=parallel_state.get_tensor_model_parallel_rank(),
|
||||
size=parallel_state.get_tensor_model_parallel_world_size(),
|
||||
)
|
||||
if fail_init:
|
||||
raise RuntimeError("initialization failed")
|
||||
|
||||
monkeypatch.setattr(module, "ensure_kv_transfer_initialized", initialize)
|
||||
config = SimpleNamespace(
|
||||
kv_transfer_config=SimpleNamespace(kv_connector="MooncakeConnector", engine_id="dit"),
|
||||
parallel_config=SimpleNamespace(prefill_context_parallel_size=2),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="initialization failed") if fail_init else nullcontext():
|
||||
module.init_worker_kv_connector(config, object())
|
||||
assert parallel_state._TP is tp
|
||||
assert snapshot == {"rank": 1, "size": 2}
|
||||
|
||||
|
||||
def test_partial_mooncake_registration_is_replaced_by_empty_notifications(cfg_registration):
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake.mooncake_connector import (
|
||||
MooncakeConnector,
|
||||
MooncakeConnectorScheduler,
|
||||
)
|
||||
|
||||
scheduler = object.__new__(MooncakeConnectorScheduler)
|
||||
scheduler.is_kv_producer = False
|
||||
scheduler.is_kv_consumer = True
|
||||
scheduler._reqs_need_recv = {}
|
||||
connector = object.__new__(MooncakeConnector)
|
||||
connector.connector_scheduler = scheduler
|
||||
requests, manager = cfg_registration
|
||||
for request in requests:
|
||||
request.kv_transfer_params.update(remote_engine_id="ar", remote_bootstrap_addr="http://localhost:8998")
|
||||
|
||||
def register(request, blocks, count):
|
||||
scheduler._reqs_need_recv[request.request_id] = (request, blocks)
|
||||
request.kv_transfer_params["do_remote_prefill"] = False
|
||||
if request.request_id == "cfg1":
|
||||
raise RuntimeError("partial registration")
|
||||
|
||||
connector.update_state_after_alloc = register
|
||||
with pytest.raises(KVTransferRegistrationError, match="partial registration"):
|
||||
commit_kv_load(connector, manager, requests, [4, 4])
|
||||
assert set(scheduler._reqs_need_recv) == {"cfg0", "cfg1"}
|
||||
assert all(blocks == [] for _, blocks in scheduler._reqs_need_recv.values())
|
||||
metadata = scheduler.build_connector_meta(None)
|
||||
# Even partial registration failure sends every row in one per-rank batch,
|
||||
# preserving the producer's complete fan-out count for the shared ticket.
|
||||
assert set(metadata.reqs_to_recv["ar"]) == {"cfg0", "cfg1"}
|
||||
assert all(
|
||||
meta.transfer_id == "ticket" and meta.local_block_ids == [] for meta in metadata.reqs_to_recv["ar"].values()
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("timeout", [0, -1, float("inf"), "60"])
|
||||
def test_native_transfer_timeout_must_be_positive_finite(timeout):
|
||||
with pytest.raises(ValueError, match="transfer_timeout"):
|
||||
parse_kv_transfer_config(
|
||||
{
|
||||
**KV_TRANSFER_CONFIG,
|
||||
"kv_connector_extra_config": {"transfer_timeout": timeout},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_receive_completion_requires_all_ranks_across_polls():
|
||||
from vllm_omni.diffusion.executor.abstract import DiffusionExecutor
|
||||
from vllm_omni.diffusion.sched.interface import CachedRequestData, DiffusionSchedulerOutput
|
||||
|
||||
executor = SimpleNamespace(od_config=SimpleNamespace(num_gpus=2))
|
||||
executor.collective_rpc = mock.Mock(
|
||||
side_effect=[
|
||||
[KVConnectorOutput(finished_recving={"cfg0", "cfg1"}), KVConnectorOutput(finished_recving={"cfg0"})],
|
||||
[KVConnectorOutput(), KVConnectorOutput(finished_recving={"cfg1"})],
|
||||
]
|
||||
)
|
||||
scheduled = DiffusionSchedulerOutput(
|
||||
step_id=0,
|
||||
scheduled_new_reqs=[],
|
||||
scheduled_cached_reqs=CachedRequestData.make_empty(),
|
||||
finished_req_ids=set(),
|
||||
num_running_reqs=0,
|
||||
num_waiting_reqs=0,
|
||||
kv_connector_metadata=object(),
|
||||
kv_transfer_request_ids={"cfg0", "cfg1"},
|
||||
)
|
||||
result = DiffusionExecutor.prepare_kv_for_forward(executor, scheduled)
|
||||
assert result.finished_recving == {"cfg0"}
|
||||
scheduled.kv_transfer_request_ids = set()
|
||||
result = DiffusionExecutor.prepare_kv_for_forward(executor, scheduled)
|
||||
assert result.finished_recving == {"cfg1"}
|
||||
assert not executor._kv_receive_completed_ranks
|
||||
|
||||
|
||||
def test_native_transfer_rejects_sleep_without_changing_legacy_sleep():
|
||||
with pytest.raises(ValueError, match="registered pages must remain mapped"):
|
||||
OmniDiffusionConfig.from_kwargs(
|
||||
diffusion_kv_mode="paged_scheduler",
|
||||
diffusion_kv_max_rows_per_request=2,
|
||||
kv_transfer_config=dict(KV_TRANSFER_CONFIG),
|
||||
enable_sleep_mode=True,
|
||||
)
|
||||
assert OmniDiffusionConfig.from_kwargs(enable_sleep_mode=True).enable_sleep_mode
|
||||
@@ -61,13 +61,14 @@ class _MetadataScheduler(BaseScheduler):
|
||||
|
||||
|
||||
def test_scheduler_copies_opaque_params_to_sequence_requests() -> None:
|
||||
params = {"transfer_id": "xfer-req-0", "do_remote_prefill": True}
|
||||
params = {"transfer_id": "xfer-req-0", "do_remote_prefill": True, "num_transfer_tokens": 3}
|
||||
sequence = DiffusionKVRequest(
|
||||
"req-0/diffusion-kv/0",
|
||||
sequence_id=0,
|
||||
prefix_len=4,
|
||||
target_len=2,
|
||||
seq_len=6,
|
||||
prompt_token_ids=[10, 11, 12, 13],
|
||||
)
|
||||
request = OmniDiffusionRequest(
|
||||
prompt="prompt",
|
||||
@@ -83,6 +84,9 @@ def test_scheduler_copies_opaque_params_to_sequence_requests() -> None:
|
||||
|
||||
assert state.diffusion_kv_requests[0].kv_transfer_params == params
|
||||
assert state.diffusion_kv_requests[0].kv_transfer_params is not params
|
||||
assert sequence.prompt_token_ids == [10, 11, 12]
|
||||
assert sequence.num_prompt_tokens == 3
|
||||
assert sequence.seq_len == 6
|
||||
|
||||
|
||||
def test_diffusion_kv_metadata_uses_native_cache_group_block_ids() -> None:
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Exercise upstream Mooncake completion accounting without device/network I/O."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import msgspec
|
||||
import pytest
|
||||
from vllm.distributed.kv_transfer.kv_connector.v1.mooncake import mooncake_connector as mc
|
||||
from vllm.v1.core.sched.scheduler import Scheduler
|
||||
from vllm.v1.outputs import KVConnectorOutput
|
||||
|
||||
from vllm_omni.diffusion.diffusion_kv.kv_connector import install_mooncake_cfg_fanout
|
||||
from vllm_omni.worker.mixins import OmniWorkerMixin
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def producer(monkeypatch):
|
||||
worker = object.__new__(mc.MooncakeConnectorWorker)
|
||||
worker.shutdown = Mock() # No hardware constructor, sockets or listener thread.
|
||||
worker.is_kv_consumer = False
|
||||
worker.tp_rank, worker.tp_size = 0, 1
|
||||
worker.transfer_topo = SimpleNamespace(handshake_target_ranks=lambda size: list(range(size)))
|
||||
worker.reqs_need_send, worker.finished_sending_reqs = {}, set()
|
||||
for name in (
|
||||
"kv_caches_base_addr",
|
||||
"block_len_per_layer",
|
||||
"kv_block_len_per_layer",
|
||||
"registered_layer_names",
|
||||
"registered_layer_indices",
|
||||
"registered_group_indices",
|
||||
):
|
||||
setattr(worker, name, [])
|
||||
worker._get_transfer_regions = Mock(return_value=[])
|
||||
worker._producer_cache_is_replicated = Mock(return_value=False)
|
||||
worker._encoder = msgspec.msgpack.Encoder()
|
||||
worker._sender_executor = None
|
||||
worker._send_blocks = Mock(return_value=0)
|
||||
worker.xfer_stats = Mock()
|
||||
worker._build_transfer_params = AsyncMock(return_value=([1], [2], [16], [], None))
|
||||
monkeypatch.setattr(mc, "_align_transfer_regions", lambda *_: ([], [], None))
|
||||
monkeypatch.setattr(mc, "_validate_asymmetric_region_lengths", lambda **_: None)
|
||||
monkeypatch.setattr(mc.envs, "VLLM_MOONCAKE_ABORT_REQUEST_TIMEOUT", 0.05)
|
||||
connector = object.__new__(mc.MooncakeConnector)
|
||||
connector.connector_worker = worker
|
||||
return connector
|
||||
|
||||
|
||||
async def register(worker, ticket):
|
||||
worker.sender_loop = asyncio.get_running_loop()
|
||||
for blocks in ([], [[10]]):
|
||||
meta = mc.MooncakeConnectorMetadata()
|
||||
meta.add_new_req(ticket, blocks, {"transfer_id": ticket}, load_remote_cache=False)
|
||||
await worker.record_send_reqs(meta)
|
||||
return worker.reqs_need_send[ticket]
|
||||
|
||||
|
||||
def packet(rank, ranks, tickets, empty=False):
|
||||
return mc.MooncakeXferMetadata(
|
||||
remote_hostname="localhost",
|
||||
remote_port=1234 + rank,
|
||||
remote_tp_size=ranks,
|
||||
remote_tp_rank=rank,
|
||||
req_blocks={
|
||||
f"{ticket}/{row}": (ticket, [] if empty else [[100 + rank * 10 + row]])
|
||||
for ticket, rows in tickets.items()
|
||||
for row in range(rows)
|
||||
},
|
||||
kv_caches_base_addr=[],
|
||||
block_lens=[],
|
||||
kv_block_lens=[],
|
||||
)
|
||||
|
||||
|
||||
async def send(worker, rank, ranks, tickets, empty=False):
|
||||
sock = SimpleNamespace(send_multipart=AsyncMock())
|
||||
await worker.send_kv_to_decode(str(rank).encode(), sock, packet(rank, ranks, tickets, empty))
|
||||
return [
|
||||
msgspec.msgpack.decode(c.args[0][1], type=mc.MooncakeXferResponse) for c in sock.send_multipart.call_args_list
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("rows", [1, 2])
|
||||
@pytest.mark.parametrize("ranks", [2, 4])
|
||||
@pytest.mark.parametrize("in_flight", [False, True])
|
||||
async def test_source_pages_wait_for_every_rank_and_row(producer, rows, ranks, in_flight):
|
||||
worker = producer.connector_worker
|
||||
entered, release = asyncio.Event(), asyncio.Event()
|
||||
original = worker._build_transfer_params
|
||||
|
||||
async def plan(ready, meta, *regions):
|
||||
if in_flight and meta.remote_tp_rank == ranks - 1:
|
||||
entered.set()
|
||||
await release.wait()
|
||||
return await original(ready, meta, *regions)
|
||||
|
||||
worker._build_transfer_params = plan
|
||||
install_mooncake_cfg_fanout(producer)
|
||||
adapted = worker._build_transfer_params
|
||||
install_mooncake_cfg_fanout(producer)
|
||||
assert worker._build_transfer_params is adapted # Do not multiply twice.
|
||||
ticket = await register(worker, "request")
|
||||
pending = asyncio.create_task(send(worker, ranks - 1, ranks, {"request": rows})) if in_flight else None
|
||||
try:
|
||||
if pending is not None:
|
||||
await asyncio.wait_for(entered.wait(), 2)
|
||||
for rank in range(ranks - 1):
|
||||
await send(worker, rank, ranks, {"request": rows})
|
||||
assert ticket.need_send == ranks * rows
|
||||
assert ticket.sent == (rank + 1) * rows
|
||||
assert worker.reqs_need_send["request"] is ticket
|
||||
finished = await worker.fetch_finished_sending_reqs()
|
||||
scheduler = SimpleNamespace(connector=None, requests={"request": object()}, _free_blocks=Mock())
|
||||
Scheduler._update_from_kv_xfer_finished(scheduler, KVConnectorOutput(finished_sending=finished))
|
||||
scheduler._free_blocks.assert_not_called()
|
||||
release.set()
|
||||
responses = (
|
||||
await asyncio.wait_for(pending, 2) if pending else await send(worker, ranks - 1, ranks, {"request": rows})
|
||||
)
|
||||
assert sum(len(response.ok_reqs or []) for response in responses) == rows
|
||||
assert all(not response.err_reqs for response in responses)
|
||||
assert "request" not in worker.reqs_need_send
|
||||
finished = await worker.fetch_finished_sending_reqs()
|
||||
assert finished == {"request"}
|
||||
Scheduler._update_from_kv_xfer_finished(scheduler, KVConnectorOutput(finished_sending=finished))
|
||||
scheduler._free_blocks.assert_called_once()
|
||||
assert not await worker.fetch_finished_sending_reqs()
|
||||
finally:
|
||||
release.set()
|
||||
if pending is not None:
|
||||
await asyncio.gather(pending, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("empty", [False, True])
|
||||
@pytest.mark.parametrize("ranks", [1, 2])
|
||||
async def test_mixed_tickets_and_empty_block_notifications(producer, empty, ranks):
|
||||
worker = producer.connector_worker
|
||||
if empty:
|
||||
worker._build_transfer_params.return_value = ([], [], [], [], None)
|
||||
install_mooncake_cfg_fanout(producer)
|
||||
single, cfg = await register(worker, "single"), await register(worker, "cfg")
|
||||
await asyncio.gather(*(send(worker, rank, ranks, {"single": 1, "cfg": 2}, empty) for rank in range(ranks)))
|
||||
assert (single.need_send, single.sent) == (ranks, ranks)
|
||||
assert (cfg.need_send, cfg.sent) == (2 * ranks, 2 * ranks)
|
||||
assert await worker.fetch_finished_sending_reqs() == {"single", "cfg"}
|
||||
assert not worker.reqs_need_send
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("failure", ["row_count", "transport"])
|
||||
async def test_failed_rank_does_not_report_source_complete(producer, failure):
|
||||
worker = producer.connector_worker
|
||||
install_mooncake_cfg_fanout(producer)
|
||||
ticket = await register(worker, "request")
|
||||
await send(worker, 0, 2, {"request": 2})
|
||||
if failure == "transport":
|
||||
worker._send_blocks.return_value = -1
|
||||
responses = await send(worker, 1, 2, {"request": 1 if failure == "row_count" else 2})
|
||||
assert any(response.err_reqs for response in responses)
|
||||
assert ticket.sent == 2 and ticket.need_send == 4 and ticket.sending == 0
|
||||
assert worker.reqs_need_send["request"] is ticket
|
||||
assert not await worker.fetch_finished_sending_reqs()
|
||||
# Preserve upstream timeout cleanup after all in-flight writes have stopped.
|
||||
ticket.expire_time = 0
|
||||
assert await worker.fetch_finished_sending_reqs() == {"request"}
|
||||
assert not worker.reqs_need_send
|
||||
|
||||
|
||||
def test_worker_installs_adapter_after_native_initialization(producer, monkeypatch):
|
||||
import vllm.distributed.kv_transfer as kv_transfer
|
||||
|
||||
worker = producer.connector_worker
|
||||
original = worker._build_transfer_params
|
||||
|
||||
class UpstreamWorker:
|
||||
def initialize_from_config(self, config):
|
||||
assert config == "config"
|
||||
assert worker._build_transfer_params is original
|
||||
|
||||
class OmniWorker(OmniWorkerMixin, UpstreamWorker):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(kv_transfer, "has_kv_transfer_group", lambda: True)
|
||||
monkeypatch.setattr(kv_transfer, "get_kv_transfer_group", lambda: producer)
|
||||
object.__new__(OmniWorker).initialize_from_config("config")
|
||||
assert worker._build_transfer_params is not original
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["other_connector", "scheduler", "consumer"])
|
||||
def test_adapter_leaves_other_roles_untouched(producer, role):
|
||||
worker = producer.connector_worker
|
||||
original = worker._build_transfer_params
|
||||
if role == "scheduler":
|
||||
producer.connector_worker = None
|
||||
elif role == "consumer":
|
||||
worker.is_kv_consumer = True
|
||||
install_mooncake_cfg_fanout(object() if role == "other_connector" else producer)
|
||||
assert worker._build_transfer_params is original
|
||||
@@ -94,7 +94,7 @@ class _FakeAttentionGroup:
|
||||
return self.builder
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@dataclass(slots=True)
|
||||
class _FakeNativeMetadata:
|
||||
build_id: int
|
||||
causal: bool
|
||||
@@ -102,6 +102,7 @@ class _FakeNativeMetadata:
|
||||
query_start_loc_cpu: torch.Tensor
|
||||
positions: torch.Tensor
|
||||
slot_mappings: torch.Tensor
|
||||
max_num_splits: int = 0
|
||||
|
||||
|
||||
class _FakeLayer:
|
||||
@@ -288,6 +289,8 @@ def test_omni_paged_backend_consumes_context_and_restores_diffusion_shape(
|
||||
assert output.shape == query.shape
|
||||
assert torch.equal(output, query)
|
||||
assert layer.calls[0][0].shape == (5, 2, 4)
|
||||
assert layer.calls[0][1] is None
|
||||
assert layer.calls[0][2] is None
|
||||
assert layer.native_events == ["update", "forward"]
|
||||
assert layer.calls[0][3] is events[0][2]
|
||||
|
||||
@@ -440,9 +443,7 @@ def test_platform_prewrite_updates_once_then_piecewise_reads_cache(monkeypatch:
|
||||
|
||||
def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, layer, events = _make_adapter(monkeypatch)
|
||||
# Exercise the Ascend-only homogeneous batch path. CUDA keeps its native
|
||||
# output-buffer contract and is covered by the GPU adapter tests.
|
||||
monkeypatch.setattr(adapter_module.current_omni_platform, "is_npu", lambda: True)
|
||||
layer.impl.vllm_flash_attn_version = 2
|
||||
batch = adapter.prepare_batch(
|
||||
[
|
||||
DiffusionPagedAttentionRow(
|
||||
@@ -499,10 +500,10 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.
|
||||
assert torch.equal(layer.updates[0][0], key.reshape(12, 2, 4))
|
||||
assert torch.equal(layer.updates[0][1], value.reshape(12, 2, 4))
|
||||
assert [call[0].shape[0] for call in layer.calls] == [4, 6, 2]
|
||||
assert [call[1].shape[0] for call in layer.calls] == [4, 6, 2]
|
||||
assert [call[2].shape[0] for call in layer.calls] == [4, 6, 2]
|
||||
assert all(call[1] is None and call[2] is None for call in layer.calls)
|
||||
assert all(call[3] is event[2] for call, event in zip(layer.calls, events[1:], strict=True))
|
||||
assert [metadata.build_id for metadata in context.piecewise_native_metadata] == [1, 2, 3]
|
||||
assert [metadata.max_num_splits for metadata in context.piecewise_native_metadata] == [1, 1, 1]
|
||||
|
||||
# The first metadata build is the normal whole-query path. Piecewise calls
|
||||
# then use causal [3, 5), full [5, 8), and causal [8, 9) segments.
|
||||
@@ -539,6 +540,51 @@ def test_omni_paged_backend_runs_hunyuan_piecewise_segments(monkeypatch: pytest.
|
||||
]
|
||||
|
||||
|
||||
def test_homogeneous_piecewise_packs_strided_query_only_per_segment(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
batch = adapter.prepare_batch(
|
||||
[
|
||||
DiffusionPagedAttentionRow(
|
||||
request_id="req-0",
|
||||
sequence_id=0,
|
||||
query_len=6,
|
||||
seq_len=9,
|
||||
kv_start_pos=3,
|
||||
),
|
||||
DiffusionPagedAttentionRow(
|
||||
request_id="req-1",
|
||||
sequence_id=1,
|
||||
query_len=6,
|
||||
seq_len=9,
|
||||
kv_start_pos=3,
|
||||
),
|
||||
]
|
||||
)
|
||||
packed_qkv = torch.randn(2, 6, 3, 2, 4)
|
||||
query, key, value = packed_qkv.unbind(dim=2)
|
||||
assert not query.is_contiguous()
|
||||
metadata = SimpleNamespace(
|
||||
attn_mask=None,
|
||||
full_attn_spans=[[(5, 8)], [(5, 8)]],
|
||||
query_ranges=None,
|
||||
extra={},
|
||||
)
|
||||
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context(
|
||||
"layer-0",
|
||||
query,
|
||||
key,
|
||||
value,
|
||||
omni_attn_metadata=metadata,
|
||||
)
|
||||
|
||||
assert not context.query.is_contiguous()
|
||||
assert context.query.data_ptr() == query.data_ptr()
|
||||
|
||||
|
||||
def test_piecewise_metadata_snapshots_reused_native_scheduler_buffer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
adapter, _, _, _ = _make_adapter(monkeypatch)
|
||||
shared_scheduler_metadata = torch.zeros(1, dtype=torch.int32)
|
||||
@@ -1232,7 +1278,7 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads
|
||||
ulysses_degree=2,
|
||||
)
|
||||
|
||||
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 2)]
|
||||
assert selected_backends == [(adapter_module.AttentionBackendEnum.FLASH_ATTN, {}, 1)]
|
||||
assert specialized_backends == [(native_backend, 2)]
|
||||
assert config.attention_config.backend is None
|
||||
assert config.attention_config.backend_per_kind is original_backend_per_kind
|
||||
|
||||
@@ -43,8 +43,7 @@ class _SmokeDiffusionAttention(nn.Module):
|
||||
self.softmax_scale = _HEAD_SIZE**-0.5
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention")
|
||||
def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> None:
|
||||
def _native_adapter(num_rows: int = 1):
|
||||
device = torch.device("cuda", torch.accelerator.current_device_index())
|
||||
vllm_config = VllmConfig(
|
||||
cache_config=CacheConfig(
|
||||
@@ -89,8 +88,8 @@ 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=[build_kv_cache_tensor(canonical_spec, 4, [_LAYER_NAME])],
|
||||
num_blocks=4 * num_rows,
|
||||
kv_cache_tensors=[build_kv_cache_tensor(canonical_spec, 4 * num_rows, [_LAYER_NAME])],
|
||||
kv_cache_groups=[
|
||||
KVCacheGroupSpec(
|
||||
layer_names=[_LAYER_NAME],
|
||||
@@ -120,14 +119,15 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
)
|
||||
block_tables = BlockTables(
|
||||
block_sizes=[_BLOCK_SIZE],
|
||||
max_num_reqs=1,
|
||||
max_num_batched_tokens=32,
|
||||
max_num_reqs=num_rows,
|
||||
max_num_batched_tokens=32 * num_rows,
|
||||
max_num_blocks_per_group=[2],
|
||||
device=device,
|
||||
kernel_block_sizes=kernel_block_sizes,
|
||||
)
|
||||
|
||||
block_tables.append_block_ids(0, ([3, 1],), overwrite=True)
|
||||
for row in range(num_rows):
|
||||
block_tables.append_block_ids(row, ([4 * row + 3, 4 * row + 1],), overwrite=True)
|
||||
block_tables.apply_staged_writes()
|
||||
adapter = DiffusionPagedAttentionAdapter(
|
||||
vllm_config=vllm_config,
|
||||
@@ -136,11 +136,17 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
block_tables=block_tables,
|
||||
attn_groups=attn_groups,
|
||||
layers={_LAYER_NAME: native_layer},
|
||||
resolve_row=lambda _request_id, _sequence_id, _context_id: DiffusionPagedAttentionRowBinding(
|
||||
row_index=0,
|
||||
resolve_row=lambda _request_id, sequence_id, _context_id: DiffusionPagedAttentionRowBinding(
|
||||
row_index=sequence_id,
|
||||
max_seq_len=19,
|
||||
),
|
||||
)
|
||||
return adapter, diffusion_layer, device
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention")
|
||||
def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> None:
|
||||
adapter, diffusion_layer, device = _native_adapter()
|
||||
prefix_batch = adapter.prepare_batch(
|
||||
[
|
||||
DiffusionPagedAttentionRow(
|
||||
@@ -258,3 +264,48 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N
|
||||
torch.testing.assert_close(prefix_output, prefix_reference, rtol=2e-2, atol=2e-2)
|
||||
torch.testing.assert_close(suffix_output, suffix_reference, rtol=2e-2, atol=2e-2)
|
||||
torch.testing.assert_close(piecewise_output, piecewise_reference, rtol=2e-2, atol=2e-2)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is required for native paged attention")
|
||||
def test_hunyuan_homogeneous_cfg_paged_attention_graph_replay() -> None:
|
||||
"""Real paged kernels, strided QKV, mixed image spans and changed replay inputs."""
|
||||
adapter, layer, device = _native_adapter(num_rows=2)
|
||||
packed = torch.randn(2, 19, 3, _NUM_HEADS, _HEAD_SIZE, device=device, dtype=torch.float16)
|
||||
query, key, value = packed.unbind(dim=2)
|
||||
backend = FlashAttentionImpl(
|
||||
num_heads=_NUM_HEADS, head_size=_HEAD_SIZE, softmax_scale=layer.softmax_scale, num_kv_heads=_NUM_HEADS
|
||||
)
|
||||
batch = adapter.prepare_batch(
|
||||
[DiffusionPagedAttentionRow(request_id="cfg", sequence_id=i, query_len=19, seq_len=19) for i in range(2)]
|
||||
)
|
||||
metadata = AttentionMetadata(full_attn_spans=[[(5, 10)], [(5, 10)]])
|
||||
with adapter.activate(batch):
|
||||
context = adapter.prepare_layer_context(_LAYER_NAME, query, key, value, omni_attn_metadata=metadata)
|
||||
assert context.piecewise_plan.homogeneous_batch_shape == (2, 19)
|
||||
warmup = torch.cuda.Stream()
|
||||
warmup.wait_stream(torch.cuda.current_stream())
|
||||
with torch.cuda.stream(warmup):
|
||||
for _ in range(3):
|
||||
backend.forward_paged(context)
|
||||
torch.cuda.current_stream().wait_stream(warmup)
|
||||
graph = torch.cuda.CUDAGraph()
|
||||
with torch.cuda.graph(graph):
|
||||
captured = backend.forward_paged(context)
|
||||
output_ptr = captured.data_ptr()
|
||||
mask = torch.ones(19, 19, device=device, dtype=torch.bool).tril()
|
||||
mask[5:10, 5:10] = True
|
||||
for _ in range(2):
|
||||
packed.copy_(torch.randn_like(packed))
|
||||
graph.replay()
|
||||
actual = captured.clone()
|
||||
eager = backend.forward_paged(context)
|
||||
reference = F.scaled_dot_product_attention(
|
||||
query.transpose(1, 2),
|
||||
key.transpose(1, 2),
|
||||
value.transpose(1, 2),
|
||||
attn_mask=mask,
|
||||
scale=layer.softmax_scale,
|
||||
).transpose(1, 2)
|
||||
torch.testing.assert_close(actual.reshape_as(reference), reference, rtol=2e-2, atol=2e-2)
|
||||
torch.testing.assert_close(actual, eager, rtol=2e-2, atol=2e-2)
|
||||
assert captured.data_ptr() == output_ptr
|
||||
@@ -176,10 +176,11 @@ def test_worker_honors_explicit_kv_memory_budget(monkeypatch) -> None:
|
||||
profile_request = object()
|
||||
|
||||
assert worker.determine_available_kv_memory(profile_request) == [4096]
|
||||
worker.model_runner.profile_run.assert_called_once_with(profile_request)
|
||||
worker.model_runner.profile_run.assert_not_called()
|
||||
|
||||
|
||||
def test_worker_treats_zero_kv_memory_budget_as_profiled_auto_sizing(monkeypatch) -> None:
|
||||
monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock()))
|
||||
worker = object.__new__(DiffusionWorker)
|
||||
worker.rank = 0
|
||||
worker.init_snapshot = SimpleNamespace(free_memory=900)
|
||||
@@ -209,6 +210,7 @@ def test_worker_treats_zero_kv_memory_budget_as_profiled_auto_sizing(monkeypatch
|
||||
|
||||
@pytest.mark.parametrize("non_kv_cache_memory", [750, 800])
|
||||
def test_worker_rejects_non_positive_profiled_kv_memory(monkeypatch, non_kv_cache_memory: int) -> None:
|
||||
monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock()))
|
||||
worker = object.__new__(DiffusionWorker)
|
||||
worker.rank = 0
|
||||
worker.init_snapshot = SimpleNamespace(free_memory=900)
|
||||
@@ -245,6 +247,7 @@ def test_worker_rejects_non_positive_profiled_kv_memory(monkeypatch, non_kv_cach
|
||||
|
||||
|
||||
def test_worker_profiles_activation_headroom_instead_of_current_residency(monkeypatch) -> None:
|
||||
monkeypatch.setattr(diffusion_worker_module, "current_omni_platform", SimpleNamespace(empty_cache=Mock()))
|
||||
worker = object.__new__(DiffusionWorker)
|
||||
worker.rank = 0
|
||||
worker.init_snapshot = SimpleNamespace(free_memory=1200)
|
||||
@@ -310,3 +313,31 @@ def test_rank_probe_gathers_local_failure_before_raising(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
assert gathered == [(False, "ValueError: local failure")]
|
||||
|
||||
|
||||
def test_hunyuan_native_layer_identity_matches_ar_spec(monkeypatch):
|
||||
import vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer as hy3
|
||||
|
||||
monkeypatch.setattr(hy3, "get_tensor_model_parallel_world_size", lambda: 1)
|
||||
monkeypatch.setattr(hy3, "get_sequence_parallel_world_size", lambda: 1)
|
||||
monkeypatch.setattr(hy3, "get_allgather_parallel_world_size", lambda: 1)
|
||||
monkeypatch.setattr(hy3, "get_sequence_parallel_rank", lambda: 0)
|
||||
monkeypatch.setattr(hy3, "QKVParallelLinear", lambda **_: nn.Identity())
|
||||
monkeypatch.setattr(hy3, "RowParallelLinear", lambda **_: nn.Identity())
|
||||
monkeypatch.setattr(hy3, "get_rope", lambda **_: nn.Identity())
|
||||
|
||||
# Only backend construction is fake; execute both HY3 constructors and the
|
||||
# real runner spec-discovery path, including the actual Attention prefix.
|
||||
def attention(**kwargs):
|
||||
return _attention(enabled=kwargs.get("paged_kv_cache_role") is not None, prefix=kwargs["prefix"])
|
||||
|
||||
monkeypatch.setattr(hy3, "Attention", attention)
|
||||
model = hy3.HunYuanAttention(
|
||||
config=SimpleNamespace(num_key_value_heads=2, attention_head_dim=8),
|
||||
hidden_size=16,
|
||||
num_heads=2,
|
||||
num_kv_heads=2,
|
||||
prefix="layers.3.self_attn",
|
||||
)
|
||||
runner = _runner(model.image_attn.attn)
|
||||
assert set(runner.get_kv_cache_spec()) == {"model.layers.3.self_attn.attn"}
|
||||
@@ -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 CFG (Classifier-Free Guidance) parallel functionality.
|
||||
|
||||
CPU tests mock the CFG process group and validate mixin logic without GPUs.
|
||||
@@ -32,8 +32,8 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
_L4_THREE_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=3)
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
_L4_THREE_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=3)
|
||||
|
||||
|
||||
def _set_random_seeds(seed: int) -> None:
|
||||
@@ -1009,7 +1009,7 @@ def _run_multi_branch_gpu_parity(
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=2)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
@pytest.mark.parametrize("batch_size", [2])
|
||||
@pytest.mark.parametrize("cfg_normalize", [False, True])
|
||||
@pytest.mark.parametrize("extra_kwargs", [None, {"step_i": 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
|
||||
"""Tests for SeqAllToAll4D, SeqAllToAll5D, and RingComm communication primitives.
|
||||
|
||||
CPU tests use gloo on CPU tensors (no GPU). Nightly parity tests run the same
|
||||
@@ -24,8 +24,8 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
)
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4)
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=4)
|
||||
|
||||
DeviceKind = Literal["cpu", "cuda"]
|
||||
|
||||
@@ -461,3 +461,46 @@ def test_ring_p2p_parity(world_size: int, dtype: torch.dtype):
|
||||
device_kind="cuda",
|
||||
master_port=29501,
|
||||
)
|
||||
|
||||
|
||||
def _run_fused_qkv_matches_three_4d(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
device_kind: DeviceKind,
|
||||
master_port: int,
|
||||
) -> None:
|
||||
"""LingBot's fused (B, S/N, 3, H, D) exchange must equal three per-tensor 4D exchanges."""
|
||||
from vllm_omni.diffusion.distributed.comm import all_to_all_5D
|
||||
|
||||
device = _worker_device(local_rank, device_kind)
|
||||
if device_kind == "cuda":
|
||||
current_omni_platform.set_device(device)
|
||||
_init_worker(local_rank, world_size, master_port, device_kind)
|
||||
initialize_model_parallel(ulysses_degree=world_size)
|
||||
sp_group = get_sp_group().ulysses_group
|
||||
try:
|
||||
batch, seq_per_rank, heads, head_size = 1, 6, 4 * world_size, 8
|
||||
# Distinguishable values: rank, q/k/v index, position, head, feature.
|
||||
base = torch.arange(seq_per_rank * heads * head_size, dtype=torch.float32, device=device).reshape(
|
||||
batch, seq_per_rank, heads, head_size
|
||||
)
|
||||
tensors = [base + 1000.0 * local_rank + 100000.0 * which for which in range(3)]
|
||||
separate = [SeqAllToAll4D.apply(sp_group, t, 2, 1, False) for t in tensors]
|
||||
fused = all_to_all_5D(torch.stack(tensors, dim=2), scatter_idx=3, gather_idx=1, group=sp_group)
|
||||
assert fused.shape == (batch, seq_per_rank * world_size, 3, heads // world_size, head_size)
|
||||
for which, expected in enumerate(separate):
|
||||
assert torch.equal(fused.unbind(2)[which], expected), f"q/k/v index {which} differs on rank {local_rank}"
|
||||
finally:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("world_size", [2, 4])
|
||||
def test_fused_qkv_all_to_all_matches_three_4d_exchanges_cpu(world_size: int) -> None:
|
||||
torch.multiprocessing.spawn(
|
||||
_run_fused_qkv_matches_three_4d,
|
||||
args=(world_size, "cpu", 29613),
|
||||
nprocs=world_size,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
"""
|
||||
System test for Expert Parallel (EP).
|
||||
|
||||
@@ -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
|
||||
|
||||
"""FLUX.2-dev VAE patch-parallel correctness tests.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
"""Unit tests for SP subgroup construction in parallel_state.py."""
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for SP subgroup construction for diffusion parallel state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,12 +8,55 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import vllm.distributed.parallel_state as vllm_parallel_state
|
||||
|
||||
from tests.helpers.runtime import get_distributed_init_method
|
||||
from vllm_omni.diffusion.distributed import parallel_state as omni_parallel_state
|
||||
from vllm_omni.diffusion.distributed.parallel_state import RankGenerator, set_seq_parallel_pg
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.parallel, pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def ensure_parallel_state_initialized():
|
||||
"""Ensure torch distributed is initialized and that all world / count vars are set."""
|
||||
assert torch.distributed.is_initialized()
|
||||
assert omni_parallel_state._WORLD is not None
|
||||
assert vllm_parallel_state._WORLD is not None
|
||||
assert vllm_parallel_state._NODE_COUNT is not None
|
||||
|
||||
|
||||
def ensure_parallel_state_not_initialized():
|
||||
"""Ensure torch distributed is not initialized and that all world / count vars are unset."""
|
||||
assert not torch.distributed.is_initialized()
|
||||
assert omni_parallel_state._WORLD is None
|
||||
assert vllm_parallel_state._WORLD is None
|
||||
assert vllm_parallel_state._NODE_COUNT is None
|
||||
|
||||
|
||||
def test_omni_manages_vllm_distributed_state(monkeypatch):
|
||||
"""Verify Omni initializes and tears down vLLM's distributed state properly.
|
||||
|
||||
This is a regression test for ensuring vLLM Omni also sets up vLLM's vars correctly
|
||||
to avoid potential misalignment in cases where Omni uses vLLM's native coordinator,
|
||||
e.g., MoE + diffusion.
|
||||
"""
|
||||
ensure_parallel_state_not_initialized()
|
||||
monkeypatch.setattr(omni_parallel_state.current_omni_platform, "get_device_count", lambda: 1)
|
||||
monkeypatch.setattr(omni_parallel_state.current_omni_platform, "set_device", lambda _device: None)
|
||||
omni_parallel_state.init_distributed_environment(
|
||||
world_size=1,
|
||||
rank=0,
|
||||
local_rank=0,
|
||||
distributed_init_method=get_distributed_init_method(),
|
||||
backend="gloo",
|
||||
)
|
||||
try:
|
||||
ensure_parallel_state_initialized()
|
||||
finally:
|
||||
omni_parallel_state.destroy_distributed_env()
|
||||
ensure_parallel_state_not_initialized()
|
||||
|
||||
|
||||
def _fake_new_group_factory(created_groups: list[SimpleNamespace]):
|
||||
def _fake_new_group(ranks, *args, **kwargs):
|
||||
group = SimpleNamespace(ranks=list(ranks))
|
||||
|
||||
@@ -31,9 +31,9 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
from vllm_omni.diffusion.distributed.pipeline_parallel import AsyncLatents, PipelineParallelMixin
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
_L4_THREE_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=3)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=4)
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
_L4_THREE_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=3)
|
||||
_L4_FOUR_GPU = hardware_marks(res={"cuda": ["L4", "B200"]}, num_cards=4)
|
||||
|
||||
DeviceKind = Literal["cpu", "cuda"]
|
||||
_UNIT_MARKS = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
@@ -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
|
||||
"""Real multi-process numeric equivalence gate for SANA-Video sequence parallel.
|
||||
|
||||
Runs the native transformer directly (no pipeline, no topology validation) with
|
||||
@@ -273,7 +273,7 @@ def _baseline_worker(
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=2)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
@pytest.mark.parametrize("task", ["t2v", "i2v"])
|
||||
@pytest.mark.parametrize("num_frames", [21, 5])
|
||||
def test_sp2_matches_sp1_fp32(task: str, num_frames: int) -> None:
|
||||
@@ -290,7 +290,7 @@ def test_sp2_matches_sp1_fp32(task: str, num_frames: int) -> None:
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=4)
|
||||
@pytest.mark.parametrize("task", ["t2v", "i2v"])
|
||||
@pytest.mark.parametrize("num_frames", [21, 5])
|
||||
def test_sp4_matches_sp1_fp32(task: str, num_frames: int) -> None:
|
||||
@@ -307,7 +307,7 @@ def test_sp4_matches_sp1_fp32(task: str, num_frames: int) -> None:
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=4)
|
||||
def test_sp2_cfg2_matches_sp1_fp32() -> None:
|
||||
"""Four processes: CFG branches with distinct inputs each run their own SP
|
||||
group; catches CFG/SP group crossover and deadlocks."""
|
||||
@@ -324,7 +324,7 @@ def test_sp2_cfg2_matches_sp1_fp32() -> None:
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=4)
|
||||
def test_tp2_sp2_matches_sp1_fp32() -> None:
|
||||
"""Four processes: TP shards heads and channels while SP shards frames; the
|
||||
combined mesh must still reproduce the serial output."""
|
||||
@@ -342,7 +342,7 @@ def test_tp2_sp2_matches_sp1_fp32() -> None:
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=2)
|
||||
@hardware_test(res={"cuda": ["L4", "B200"]}, num_cards=2)
|
||||
def test_sp2_bf16_drift_informational() -> None:
|
||||
"""Records the bf16 reduction-reorder drift magnitude; the load-bearing
|
||||
numeric gate is the fp32 suite above."""
|
||||
|
||||
@@ -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
|
||||
|
||||
"""
|
||||
System test for Sequence Parallel (SP) backends: Ulysses and Ring attention.
|
||||
|
||||
@@ -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
|
||||
|
||||
"""VAE patch-parallel decode parity tests for diffusion models.
|
||||
|
||||
@@ -145,7 +145,7 @@ def _run_generate(
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards={"cuda": 4})
|
||||
@hardware_test(res={"cuda": ["H100", "B200"]}, num_cards={"cuda": 4})
|
||||
@pytest.mark.parametrize("model_case", MODEL_CASES)
|
||||
def test_vae_patch_parallel_tp2(model_case: dict[str, Any], tmp_path: Path):
|
||||
if current_omni_platform.is_npu():
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -353,6 +356,94 @@ def test_attention_wrapper_rejects_spatial_extent_change(monkeypatch: pytest.Mon
|
||||
wan_spatial_shard._SPATIAL_SHARD_CONTEXT.reset(token)
|
||||
|
||||
|
||||
def _reference_conv_input(conv, x, cache_x):
|
||||
"""The conv input the previous implementation built: cat, F.pad, then halo_exchange."""
|
||||
padding = list(conv._padding)
|
||||
if cache_x is not None and padding[4] > 0:
|
||||
x = torch.cat([cache_x, x], dim=2)
|
||||
padding[4] -= cache_x.shape[2]
|
||||
x = torch.nn.functional.pad(x, padding)
|
||||
x_padded, _, _ = wan_spatial_shard.halo_exchange(
|
||||
x, group=object(), halo_size=conv.halo_size, split_dim=conv.split_dim
|
||||
)
|
||||
return x_padded, x.shape[conv.split_tensor_dim]
|
||||
|
||||
|
||||
def _dist_conv(split_dim, kernel=(3, 3, 3), padding=(1, 1, 1)):
|
||||
from diffusers.models.autoencoders.autoencoder_kl_wan import WanCausalConv3d
|
||||
|
||||
torch.manual_seed(0)
|
||||
source = WanCausalConv3d(3, 4, kernel, padding=padding)
|
||||
return source, wan_spatial_shard.WanDistCausalConv3d(source, object(), split_dim=split_dim)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("split_dim", ["width", "height"])
|
||||
@pytest.mark.parametrize("with_cache", [False, True])
|
||||
def test_single_pass_conv_input_matches_cat_pad_halo_on_one_rank(monkeypatch, split_dim, with_cache):
|
||||
"""One rank, halo-bearing kernel: same bytes as the previous cat -> F.pad -> halo_exchange path."""
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 1))
|
||||
_, conv = _dist_conv(split_dim)
|
||||
x = torch.randn(1, 3, 2, 6, 8)
|
||||
cache_x = torch.randn(1, 3, 2, 6, 8) if with_cache else None
|
||||
|
||||
expected, expected_extent = _reference_conv_input(conv, x, cache_x)
|
||||
got, got_extent = conv._assemble_input(x, cache_x)
|
||||
|
||||
assert got.shape == expected.shape and got_extent == expected_extent
|
||||
assert torch.equal(got, expected)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_single_pass_conv_rejects_cache_longer_than_causal_padding():
|
||||
_, conv = _dist_conv("width")
|
||||
with pytest.raises(AssertionError, match="temporal cache exceeds"):
|
||||
conv._assemble_input(torch.randn(1, 3, 2, 6, 8), torch.randn(1, 3, 3, 6, 8))
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("split_dim", ["width", "height"])
|
||||
def test_single_pass_conv_without_a_halo_matches_the_stock_causal_conv(monkeypatch, split_dim):
|
||||
"""A kernel with no spatial extent along the split (the temporal conv) needs no halo and equals the stock conv."""
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (0, 1))
|
||||
source, conv = _dist_conv(split_dim, kernel=(3, 1, 1), padding=(1, 0, 0))
|
||||
x = torch.randn(1, 3, 2, 6, 8)
|
||||
cache_x = torch.randn(1, 3, 2, 6, 8)
|
||||
|
||||
assert torch.equal(conv(x, cache_x), source(x, cache_x))
|
||||
assert torch.equal(conv(x, None), source(x, None))
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("split_dim", ["width", "height"])
|
||||
@pytest.mark.parametrize("rank", [0, 1])
|
||||
@pytest.mark.parametrize("kernel,padding", [((3, 3, 3), (1, 1, 1)), ((3, 1, 1), (0, 0, 0))])
|
||||
def test_single_pass_conv_input_matches_cat_pad_halo_on_two_ranks(monkeypatch, split_dim, rank, kernel, padding):
|
||||
"""Same bytes as cat -> F.pad -> halo_exchange, halos included, on either rank of a pair."""
|
||||
monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (rank, 2))
|
||||
|
||||
def fake_p2p(*, rank, world_size, group, top_row_ref, bottom_row_ref, recv_top_buf, recv_bottom_buf):
|
||||
# A neighbour's halo is whatever it sent; stand in with a marker derived from our own edge so
|
||||
# both the reference and the single-pass path see the same exchange. Global edges stay zero.
|
||||
recv_top_buf.copy_(top_row_ref * 0 + 7.0) if rank > 0 else recv_top_buf.zero_()
|
||||
recv_bottom_buf.copy_(bottom_row_ref * 0 + 9.0) if rank < world_size - 1 else recv_bottom_buf.zero_()
|
||||
|
||||
monkeypatch.setattr(wan_spatial_shard, "_halo_exchange_p2p", fake_p2p)
|
||||
_, conv = _dist_conv(split_dim, kernel=kernel, padding=padding)
|
||||
x = torch.randn(1, 3, 1, 6, 8)
|
||||
cache_x = torch.randn(1, 3, 2, 6, 8)
|
||||
|
||||
expected, expected_extent = _reference_conv_input(conv, x, cache_x)
|
||||
got, got_extent = conv._assemble_input(x, cache_x)
|
||||
|
||||
assert got.shape == expected.shape and got_extent == expected_extent
|
||||
assert torch.equal(got, expected)
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
def test_halo_exchange_single_rank_noop(monkeypatch: pytest.MonkeyPatch):
|
||||
@@ -538,7 +629,7 @@ def _spatial_shard_decode_worker(rank: int, split_dim: str, return_dict, master_
|
||||
@pytest.mark.full_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "H100"}, num_cards=_SPATIAL_SHARD_WORLD_SIZE)
|
||||
@hardware_test(res={"cuda": ["H100", "B200"]}, num_cards=_SPATIAL_SHARD_WORLD_SIZE)
|
||||
@pytest.mark.parametrize("split_dim", ["height", "width"])
|
||||
def test_spatial_shard_decode_matches_reference(split_dim: str):
|
||||
manager = mp.get_context("spawn").Manager()
|
||||
|
||||
@@ -41,13 +41,13 @@ from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
initialize_model_parallel,
|
||||
model_parallel_is_initialized,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.mot_qkv_parallel_linear import (
|
||||
from vllm_omni.diffusion.models.bagel.mot.mot_qkv_parallel_linear import (
|
||||
MoTQKVParallelLinear,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.mot_row_parallel_linear import (
|
||||
from vllm_omni.diffusion.models.bagel.mot.mot_row_parallel_linear import (
|
||||
MoTRowParallelLinear,
|
||||
)
|
||||
from vllm_omni.diffusion.layers.mot.ops.mot_gemm import (
|
||||
from vllm_omni.diffusion.models.bagel.mot.ops.mot_gemm import (
|
||||
get_best_mot_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
# ruff: noqa: N803, E741
|
||||
"""Layer-level correctness & performance test for MoTRMSNorm.
|
||||
|
||||
@@ -20,7 +23,7 @@ import torch
|
||||
from vllm import ir
|
||||
from vllm.config import VllmConfig, set_current_vllm_config
|
||||
|
||||
from vllm_omni.diffusion.layers.mot.mot_layernorm import MoTRMSNorm
|
||||
from vllm_omni.diffusion.models.bagel.mot.mot_layernorm import MoTRMSNorm
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.gpu]
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.layers.custom_op as custom_op
|
||||
from vllm_omni.diffusion.layers.mot.mot_layernorm import MoTRMSNorm
|
||||
from vllm_omni.diffusion.models.bagel.mot.mot_layernorm import MoTRMSNorm
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@@ -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 the CustomOp platform-dispatch base class."""
|
||||
|
||||
import pytest
|
||||
@@ -39,7 +39,7 @@ def test_forward_cuda_still_requires_an_implementation():
|
||||
|
||||
def test_mot_rmsnorm_dispatches_on_xpu_without_an_override():
|
||||
"""MoTRMSNorm relies on the inherited fallback (regression test)."""
|
||||
from vllm_omni.diffusion.layers.mot.mot_layernorm import MoTRMSNorm
|
||||
from vllm_omni.diffusion.models.bagel.mot.mot_layernorm import MoTRMSNorm
|
||||
|
||||
assert "forward_xpu" not in MoTRMSNorm.__dict__
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -80,3 +80,113 @@ def test_fused_qk_norm_rope_matches_bf16_reference(seq_len):
|
||||
|
||||
torch.testing.assert_close(actual_q, expected_q, atol=0.0625, rtol=0.02)
|
||||
torch.testing.assert_close(actual_k, expected_k, atol=0.0625, rtol=0.02)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# General geometry (any even head_dim, here Boogu-Image's 120) in both
|
||||
# pairing modes, against the module's own eager reference at the same
|
||||
# tolerance as the MiniMax-H3 test above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BOOGU_HEAD_DIM = 120
|
||||
|
||||
|
||||
def _boogu_inputs(strided: bool):
|
||||
torch.manual_seed(11)
|
||||
if strided:
|
||||
# Slices of a wider head axis: the op must honour q/k strides
|
||||
# (merged-QKV projections hand the op such views).
|
||||
q = torch.randn(4139, 35, _BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)[:, :28]
|
||||
k = torch.randn(4139, 35, _BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)[:, :7]
|
||||
else:
|
||||
q = torch.randn(4139, 28, _BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn(4139, 7, _BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
q_weight = torch.randn(_BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k_weight = torch.randn(_BOOGU_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
freqs = torch.randn(4139, _BOOGU_HEAD_DIM // 2, device="cuda", dtype=torch.float32)
|
||||
rope_table = torch.cat((torch.cos(freqs), torch.sin(freqs)), dim=-1)
|
||||
return q, k, q_weight, k_weight, rope_table
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize("strided", [False, True])
|
||||
def test_fused_qk_norm_rope_interleaved(strided):
|
||||
from vllm_omni.diffusion.layers.fused_qk_norm_rope import (
|
||||
_eager_qk_norm_rope,
|
||||
_launch_fused_qk_norm_rope,
|
||||
fused_qk_norm_rope,
|
||||
)
|
||||
|
||||
q, k, q_weight, k_weight, rope_table = _boogu_inputs(strided)
|
||||
expected = _eager_qk_norm_rope(q, k, q_weight, k_weight, rope_table, _EPS, _BOOGU_HEAD_DIM, _BOOGU_HEAD_DIM, True)
|
||||
# Check the public op AND the launcher directly: the latter cannot fall
|
||||
# back to eager, so a silent dispatch regression cannot go green here.
|
||||
for actual in (
|
||||
fused_qk_norm_rope(q, k, q_weight, k_weight, rope_table, _EPS, interleaved=True),
|
||||
_launch_fused_qk_norm_rope(q, k, q_weight, k_weight, rope_table, _EPS, interleaved=True),
|
||||
):
|
||||
torch.testing.assert_close(actual[0], expected[0], atol=0.0625, rtol=0.02)
|
||||
torch.testing.assert_close(actual[1], expected[1], atol=0.0625, rtol=0.02)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_fused_qk_norm_rope_half_split_general_dim():
|
||||
"""head_dim=120 off the MiniMax-H3 128 pin, half-split pairing.
|
||||
|
||||
Exercises the generalised combined kernel's half-split mode via the
|
||||
launcher. It is not routed in production (half-split traffic keeps the
|
||||
untouched pre-existing per-tensor kernel and its 128/96 contract, so the
|
||||
public op falls back to eager at this geometry), pending the
|
||||
maintainers' call.
|
||||
"""
|
||||
from vllm_omni.diffusion.layers.fused_qk_norm_rope import (
|
||||
_eager_qk_norm_rope,
|
||||
_launch_fused_qk_norm_rope,
|
||||
)
|
||||
|
||||
q, k, q_weight, k_weight, rope_table = _boogu_inputs(strided=False)
|
||||
expected = _eager_qk_norm_rope(q, k, q_weight, k_weight, rope_table, _EPS, _BOOGU_HEAD_DIM, _BOOGU_HEAD_DIM)
|
||||
actual = _launch_fused_qk_norm_rope(q, k, q_weight, k_weight, rope_table, _EPS, interleaved=False)
|
||||
torch.testing.assert_close(actual[0], expected[0], atol=0.0625, rtol=0.02)
|
||||
torch.testing.assert_close(actual[1], expected[1], atol=0.0625, rtol=0.02)
|
||||
|
||||
|
||||
def test_fused_qk_norm_rope_min_tokens_resolution(monkeypatch):
|
||||
"""The op-level token-gate override: unset or blank -> caller's default; a
|
||||
non-negative integer string overrides it (``0`` = always fuse); anything
|
||||
else is rejected naming the variable."""
|
||||
from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope_min_tokens
|
||||
|
||||
env = "VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS"
|
||||
monkeypatch.delenv(env, raising=False)
|
||||
assert fused_qk_norm_rope_min_tokens(2048) == 2048
|
||||
for blank in ("", " "):
|
||||
monkeypatch.setenv(env, blank)
|
||||
assert fused_qk_norm_rope_min_tokens(2048) == 2048
|
||||
monkeypatch.setenv(env, "0")
|
||||
assert fused_qk_norm_rope_min_tokens(2048) == 0
|
||||
monkeypatch.setenv(env, "4096")
|
||||
assert fused_qk_norm_rope_min_tokens(2048) == 4096
|
||||
for bad in ("-1", "abc"):
|
||||
monkeypatch.setenv(env, bad)
|
||||
with pytest.raises(ValueError, match=env):
|
||||
fused_qk_norm_rope_min_tokens(2048)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available() or not HAS_TRITON, reason="CUDA and Triton required")
|
||||
@pytest.mark.parametrize("interleaved", [False, True])
|
||||
def test_fused_qk_norm_rope_large_storage_offsets(interleaved):
|
||||
"""Only three tokens, but the last row lies beyond signed 32-bit indexing."""
|
||||
from vllm_omni.diffusion.layers.fused_qk_norm_rope import fused_qk_norm_rope
|
||||
|
||||
# 4 GiB of backing storage; initialize only the three small visible rows.
|
||||
q = torch.empty_strided((3, 1, 128), (2**30, 128, 1), device="cuda", dtype=torch.bfloat16)
|
||||
q.fill_(1)
|
||||
weight = torch.ones(128, device="cuda", dtype=torch.bfloat16)
|
||||
table = torch.zeros(3, 96, device="cuda", dtype=torch.bfloat16)
|
||||
table[:, :48] = 1
|
||||
actual = fused_qk_norm_rope(q, q, weight, weight, table, _EPS, interleaved=interleaved)
|
||||
for value in actual:
|
||||
torch.testing.assert_close(value, torch.ones_like(value))
|
||||
@@ -0,0 +1,125 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.embeddings import apply_rotary_emb
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_fused_qk_rope_fake_and_cpu_eligibility():
|
||||
from vllm_omni.diffusion.layers.fused_qk_rope import (
|
||||
_fused_qk_rope_fake,
|
||||
fused_qk_rope,
|
||||
fused_qk_rope_supported,
|
||||
)
|
||||
|
||||
q = torch.randn(2, 3, 4, 8, dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
cos = torch.randn(3, 8)
|
||||
sin = torch.randn_like(cos)
|
||||
|
||||
fake_q, fake_k = _fused_qk_rope_fake(q, k, cos, sin)
|
||||
assert fake_q.shape == q.shape and fake_q.dtype == q.dtype
|
||||
assert fake_k.shape == k.shape and fake_k.dtype == k.dtype
|
||||
assert not fused_qk_rope_supported(q, k, cos, sin)
|
||||
with pytest.raises(ValueError, match="contiguous CUDA BF16"):
|
||||
fused_qk_rope(q, k, cos, sin)
|
||||
|
||||
|
||||
def _cuda_inputs(batch: int, sequence: int, heads: int = 24, head_dim: int = 128):
|
||||
q = torch.randn(batch, sequence, heads, head_dim, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
# Deliberately do not repeat adjacent table columns. Diffusers consumes
|
||||
# full-width tables, so even and odd outputs may use different values.
|
||||
cos = torch.randn(sequence, head_dim, device="cuda", dtype=torch.float32)
|
||||
sin = torch.randn_like(cos)
|
||||
return q, k, cos, sin
|
||||
|
||||
|
||||
def _reference(q, k, cos, sin):
|
||||
rotary_emb = (cos, sin)
|
||||
return (
|
||||
apply_rotary_emb(q, rotary_emb, sequence_dim=1),
|
||||
apply_rotary_emb(k, rotary_emb, sequence_dim=1),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize("sequence", [512, 4096, 4608])
|
||||
def test_fused_qk_rope_is_bit_exact_at_longcat_production_shapes(sequence):
|
||||
from vllm_omni.diffusion.layers.fused_qk_rope import _launch_fused_qk_rope, fused_qk_rope
|
||||
|
||||
torch.manual_seed(sequence)
|
||||
with torch.inference_mode():
|
||||
q, k, cos, sin = _cuda_inputs(1, sequence)
|
||||
expected = _reference(q, k, cos, sin)
|
||||
for actual in (
|
||||
_launch_fused_qk_rope(q, k, cos, sin),
|
||||
fused_qk_rope(q, k, cos, sin),
|
||||
):
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
@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_qk_rope_is_bit_exact_for_batch_and_full_width_odd_even_tables():
|
||||
from vllm_omni.diffusion.layers.fused_qk_rope import fused_qk_rope
|
||||
|
||||
torch.manual_seed(11)
|
||||
with torch.inference_mode():
|
||||
q, k, cos, sin = _cuda_inputs(2, 17, heads=3)
|
||||
assert not torch.equal(cos[:, ::2], cos[:, 1::2])
|
||||
assert not torch.equal(sin[:, ::2], sin[:, 1::2])
|
||||
expected = _reference(q, k, cos, sin)
|
||||
actual = fused_qk_rope(q, k, cos, sin)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
@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_qk_rope_rejects_noncontiguous_and_non_fp32_tables():
|
||||
from vllm_omni.diffusion.layers.fused_qk_rope import fused_qk_rope_supported
|
||||
|
||||
q, k, cos, sin = _cuda_inputs(1, 8, heads=2)
|
||||
q_noncontiguous = torch.randn(1, 8, 2, 256, device="cuda", dtype=torch.bfloat16)[..., ::2]
|
||||
k_noncontiguous = torch.randn_like(q_noncontiguous)
|
||||
cos_noncontiguous = torch.randn(8, 256, device="cuda")[:, ::2]
|
||||
sin_noncontiguous = torch.randn(8, 256, device="cuda")[:, ::2]
|
||||
with torch.inference_mode():
|
||||
assert fused_qk_rope_supported(q, k, cos, sin)
|
||||
assert not fused_qk_rope_supported(q_noncontiguous, k_noncontiguous, cos, sin)
|
||||
assert not fused_qk_rope_supported(q, k, cos_noncontiguous, sin_noncontiguous)
|
||||
assert not fused_qk_rope_supported(q, k, cos.to(torch.bfloat16), sin.to(torch.bfloat16))
|
||||
|
||||
q_wide = torch.randn(1, 8, 2, 256, device="cuda", dtype=torch.bfloat16)
|
||||
k_wide = torch.randn_like(q_wide)
|
||||
cos_wide = torch.randn(8, 256, device="cuda")
|
||||
sin_wide = torch.randn_like(cos_wide)
|
||||
assert not fused_qk_rope_supported(q_wide, k_wide, cos_wide, sin_wide)
|
||||
|
||||
|
||||
@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_qk_rope_custom_op_has_fullgraph_fake():
|
||||
from vllm_omni.diffusion.layers.fused_qk_rope import fused_qk_rope
|
||||
|
||||
torch.manual_seed(29)
|
||||
with torch.inference_mode():
|
||||
q, k, cos, sin = _cuda_inputs(1, 19, heads=3)
|
||||
expected = _reference(q, k, cos, sin)
|
||||
compiled = torch.compile(fused_qk_rope, dynamic=True, fullgraph=True)
|
||||
actual = compiled(q, k, cos, sin)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
@@ -0,0 +1,299 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.layers import qwen_select01_modulation
|
||||
from vllm_omni.diffusion.layers.qwen_select01_modulation import (
|
||||
can_use_qwen_select01_triton,
|
||||
fused_layernorm_select01,
|
||||
fused_residual_layernorm_select01,
|
||||
select01_modulation_native,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize(
|
||||
("platform_is_cuda", "tensor_is_cuda", "has_triton", "expected"),
|
||||
[
|
||||
(True, True, True, True),
|
||||
(False, True, True, False),
|
||||
(True, False, True, False),
|
||||
(True, True, False, False),
|
||||
],
|
||||
)
|
||||
def test_qwen_select01_triton_requires_cuda_platform(
|
||||
monkeypatch,
|
||||
platform_is_cuda: bool,
|
||||
tensor_is_cuda: bool,
|
||||
has_triton: bool,
|
||||
expected: bool,
|
||||
):
|
||||
class FakePlatform:
|
||||
@staticmethod
|
||||
def is_cuda() -> bool:
|
||||
return platform_is_cuda
|
||||
|
||||
class FakeTensor:
|
||||
is_cuda = tensor_is_cuda
|
||||
|
||||
monkeypatch.setattr(qwen_select01_modulation, "current_platform", FakePlatform())
|
||||
monkeypatch.setattr(qwen_select01_modulation, "HAS_TRITON", has_triton)
|
||||
|
||||
assert can_use_qwen_select01_triton(FakeTensor()) is expected
|
||||
|
||||
|
||||
def _make_inputs(
|
||||
batch_size: int,
|
||||
seq_len: int,
|
||||
hidden_size: int,
|
||||
dtype: torch.dtype,
|
||||
device: torch.device,
|
||||
) -> tuple[torch.Tensor, ...]:
|
||||
generator = torch.Generator(device=device)
|
||||
generator.manual_seed(202817)
|
||||
x = torch.randn(batch_size, seq_len, hidden_size, dtype=dtype, device=device, generator=generator)
|
||||
residual = torch.randn(batch_size, seq_len, hidden_size, dtype=dtype, device=device, generator=generator)
|
||||
residual_gate = torch.randn(batch_size, 1, hidden_size, dtype=dtype, device=device, generator=generator)
|
||||
mod_params = torch.randn(batch_size * 2, hidden_size * 3, dtype=dtype, device=device, generator=generator)
|
||||
index = torch.randint(0, 2, (batch_size, seq_len), dtype=torch.int64, device=device, generator=generator)
|
||||
return x, residual, residual_gate, mod_params, index
|
||||
|
||||
|
||||
def _reference_layernorm_select01(
|
||||
x: torch.Tensor,
|
||||
mod_params: torch.Tensor,
|
||||
index: torch.Tensor,
|
||||
eps: float,
|
||||
) -> tuple[torch.Tensor, torch.Tensor]:
|
||||
scale, shift, gate = select01_modulation_native(mod_params, index)
|
||||
out = F.layer_norm(x.float(), (x.shape[-1],), eps=eps).to(x.dtype)
|
||||
return out * (1 + scale) + shift, gate
|
||||
|
||||
|
||||
def _reference_residual_layernorm_select01(
|
||||
x: torch.Tensor,
|
||||
residual: torch.Tensor,
|
||||
residual_gate: torch.Tensor,
|
||||
mod_params: torch.Tensor,
|
||||
index: torch.Tensor,
|
||||
eps: float,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
scale, shift, gate = select01_modulation_native(mod_params, index)
|
||||
residual_out = residual + residual_gate * x
|
||||
out = F.layer_norm(residual_out.float(), (residual_out.shape[-1],), eps=eps).to(residual_out.dtype)
|
||||
return out * (1 + scale) + shift, residual_out, gate
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
@pytest.mark.parametrize("broadcast_index", [False, True])
|
||||
def test_qwen_select01_native_fallback_matches_reference(dtype: torch.dtype, broadcast_index: bool):
|
||||
device = torch.device("cpu")
|
||||
x, residual, residual_gate, mod_params, index = _make_inputs(2, 17, 64, dtype, device)
|
||||
if broadcast_index:
|
||||
index = index[:1]
|
||||
eps = 1e-6
|
||||
|
||||
actual_norm, actual_gate = fused_layernorm_select01(x, mod_params, index, eps)
|
||||
ref_norm, ref_gate = _reference_layernorm_select01(x, mod_params, index, eps)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=1e-5, rtol=1e-5)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate,
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
ref_norm, ref_residual, ref_gate = _reference_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate,
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=1e-5, rtol=1e-5)
|
||||
torch.testing.assert_close(actual_residual, ref_residual, atol=1e-5, rtol=1e-5)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.parametrize(
|
||||
("dtype", "atol", "rtol"),
|
||||
[
|
||||
(torch.float32, 1e-5, 1e-5),
|
||||
(torch.bfloat16, 5e-2, 5e-2),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("seq_len", [1, 7, 128, 1024])
|
||||
def test_qwen_select01_triton_matches_reference(dtype: torch.dtype, atol: float, rtol: float, seq_len: int):
|
||||
device = torch.device("cuda:0")
|
||||
x, residual, residual_gate, mod_params, index = _make_inputs(2, seq_len, 256, dtype, device)
|
||||
eps = 1e-6
|
||||
|
||||
actual_norm, actual_gate = fused_layernorm_select01(x, mod_params, index, eps)
|
||||
ref_norm, ref_gate = _reference_layernorm_select01(x, mod_params, index, eps)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
ref_norm, ref_residual, ref_gate = _reference_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_residual, ref_residual, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.parametrize(
|
||||
("dtype", "atol", "rtol"),
|
||||
[
|
||||
(torch.float32, 1e-5, 1e-5),
|
||||
(torch.bfloat16, 5e-2, 5e-2),
|
||||
],
|
||||
)
|
||||
def test_qwen_select01_triton_matches_reference_for_structured_multi_batch_index(
|
||||
dtype: torch.dtype,
|
||||
atol: float,
|
||||
rtol: float,
|
||||
):
|
||||
device = torch.device("cuda:0")
|
||||
x, residual, residual_gate, mod_params, _ = _make_inputs(3, 16, 256, dtype, device)
|
||||
index = torch.tensor(
|
||||
[
|
||||
[0] * 16,
|
||||
[1] * 16,
|
||||
[0, 1] * 8,
|
||||
],
|
||||
dtype=torch.int64,
|
||||
device=device,
|
||||
)
|
||||
eps = 1e-6
|
||||
|
||||
actual_norm, actual_gate = fused_layernorm_select01(x, mod_params, index, eps)
|
||||
ref_norm, ref_gate = _reference_layernorm_select01(x, mod_params, index, eps)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
ref_norm, ref_residual, ref_gate = _reference_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_residual, ref_residual, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.parametrize(
|
||||
("dtype", "atol", "rtol"),
|
||||
[
|
||||
(torch.float32, 1e-5, 1e-5),
|
||||
(torch.bfloat16, 5e-2, 5e-2),
|
||||
],
|
||||
)
|
||||
def test_qwen_select01_triton_broadcasts_single_index_batch(dtype: torch.dtype, atol: float, rtol: float):
|
||||
device = torch.device("cuda:0")
|
||||
x, residual, residual_gate, mod_params, index = _make_inputs(2, 17, 256, dtype, device)
|
||||
index = index[:1]
|
||||
eps = 1e-6
|
||||
|
||||
actual_norm, actual_gate = fused_layernorm_select01(x, mod_params, index, eps)
|
||||
ref_norm, ref_gate = _reference_layernorm_select01(x, mod_params, index, eps)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
ref_norm, ref_residual, ref_gate = _reference_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate.expand_as(residual),
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_residual, ref_residual, atol=atol, rtol=rtol)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_qwen_select01_cuda_falls_back_without_triton(monkeypatch):
|
||||
device = torch.device("cuda:0")
|
||||
x, residual, residual_gate, mod_params, index = _make_inputs(2, 7, 256, torch.bfloat16, device)
|
||||
residual_gate = residual_gate.expand_as(residual)
|
||||
eps = 1e-6
|
||||
|
||||
def fail_if_launched(*args, **kwargs):
|
||||
pytest.fail("Triton launcher must not run when HAS_TRITON is false")
|
||||
|
||||
monkeypatch.setattr(qwen_select01_modulation, "HAS_TRITON", False)
|
||||
monkeypatch.setattr(qwen_select01_modulation, "_launch_layernorm_select01", fail_if_launched)
|
||||
monkeypatch.setattr(qwen_select01_modulation, "_launch_residual_layernorm_select01", fail_if_launched)
|
||||
|
||||
actual_norm, actual_gate = fused_layernorm_select01(x, mod_params, index, eps)
|
||||
ref_norm, ref_gate = _reference_layernorm_select01(x, mod_params, index, eps)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=0, rtol=0)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
|
||||
actual_norm, actual_residual, actual_gate = fused_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate,
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
ref_norm, ref_residual, ref_gate = _reference_residual_layernorm_select01(
|
||||
x,
|
||||
residual,
|
||||
residual_gate,
|
||||
mod_params,
|
||||
index,
|
||||
eps,
|
||||
)
|
||||
torch.testing.assert_close(actual_norm, ref_norm, atol=0, rtol=0)
|
||||
torch.testing.assert_close(actual_residual, ref_residual, atol=0, rtol=0)
|
||||
torch.testing.assert_close(actual_gate, ref_gate, atol=0, rtol=0)
|
||||
@@ -18,6 +18,7 @@ from vllm_omni.diffusion.lora.loader import (
|
||||
WanLoraLoaderMixin,
|
||||
_prepare_lora_delta,
|
||||
_remap_state_dict_keys,
|
||||
_resolve_lora_compute_device,
|
||||
)
|
||||
|
||||
HEAD_DIM = 24
|
||||
@@ -509,3 +510,93 @@ def test_lora_loader_mixin_lora_is_fused_property(mocker: MockerFixture):
|
||||
assert pipeline2.lora_is_fused is False
|
||||
pipeline2.lora_is_fused = True
|
||||
assert pipeline2.lora_is_fused is True
|
||||
|
||||
|
||||
def test_prepare_lora_delta_with_compute_device():
|
||||
base_key = "blocks.0.attn.to_q"
|
||||
state_dict = {
|
||||
f"{base_key}.lora_A.weight": torch.ones(RANK, HEAD_DIM),
|
||||
f"{base_key}.lora_B.weight": torch.ones(HEAD_DIM, RANK),
|
||||
}
|
||||
delta, used_keys = _prepare_lora_delta(state_dict, base_key, compute_device=torch.device("cpu"))
|
||||
assert delta is not None
|
||||
assert delta.shape == (HEAD_DIM, HEAD_DIM)
|
||||
assert len(used_keys) == 2
|
||||
|
||||
|
||||
def test_resolve_lora_compute_device_probes_unusable_accelerator(mocker: MockerFixture):
|
||||
"""cuda:N/npu:N may be reported without a physical device; the probe must reject it."""
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader.get_local_device",
|
||||
return_value=torch.device("cuda:999"),
|
||||
)
|
||||
assert _resolve_lora_compute_device() is None
|
||||
|
||||
|
||||
def test_load_lora_into_module_cpu_weights_match_cpu_reference(mocker: MockerFixture):
|
||||
"""Regression test: params on CPU used to force compute_device=None and skip the
|
||||
#7005 accelerator path. With an unusable accelerator, fusion must fall back to
|
||||
CPU and still produce base + lora_B @ lora_A."""
|
||||
pipeline = DummyPipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
lora_state_dict = _remap_state_dict_keys(lora_state_dict, [(".to_out.0.", ".to_out.")])
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader.get_local_device",
|
||||
return_value=torch.device("cuda:999"),
|
||||
)
|
||||
|
||||
used_keys = pipeline.load_lora_into_module(lora_state_dict, pipeline.transformer)
|
||||
assert len(used_keys) > 0
|
||||
assert not (set(lora_state_dict.keys()) - used_keys)
|
||||
|
||||
for name, param in pipeline.transformer.named_parameters():
|
||||
if not name.endswith(".weight"):
|
||||
continue
|
||||
base_key = name[: -len(".weight")]
|
||||
key_a = f"{base_key}.lora_A.weight"
|
||||
key_b = f"{base_key}.lora_B.weight"
|
||||
if key_a in lora_state_dict and key_b in lora_state_dict:
|
||||
expected = original_params[name] + (
|
||||
lora_state_dict[key_b].to(param.dtype) @ lora_state_dict[key_a].to(param.dtype)
|
||||
)
|
||||
assert_close(param, expected)
|
||||
|
||||
|
||||
def test_load_lora_into_module_forwards_usable_accel_device(mocker: MockerFixture):
|
||||
"""A usable accelerator must be forwarded as compute_device for CPU params.
|
||||
A spy forces the actual math back onto CPU so the test runs without a device."""
|
||||
fake_dev = torch.device("cuda:0")
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader._resolve_lora_compute_device",
|
||||
return_value=fake_dev,
|
||||
)
|
||||
|
||||
seen_devices = []
|
||||
original = _prepare_lora_delta
|
||||
|
||||
def spy(state_dict, base_key, *args, **kwargs):
|
||||
seen_devices.append(kwargs.get("compute_device"))
|
||||
kwargs["compute_device"] = None
|
||||
return original(state_dict, base_key, *args, **kwargs)
|
||||
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.lora.loader._prepare_lora_delta",
|
||||
side_effect=spy,
|
||||
)
|
||||
|
||||
pipeline = DummyPipeline(NUM_LAYERS, HEAD_DIM)
|
||||
original_params = {name: param.clone() for name, param in pipeline.transformer.named_parameters()}
|
||||
|
||||
lora_state_dict = make_lora_state_dict_for_module(pipeline.transformer)
|
||||
lora_state_dict = _remap_state_dict_keys(lora_state_dict, [(".to_out.0.", ".to_out.")])
|
||||
|
||||
used_keys = pipeline.load_lora_into_module(lora_state_dict, pipeline.transformer)
|
||||
|
||||
# Every CPU param with a LoRA delta must have received the accelerator device.
|
||||
assert seen_devices
|
||||
assert all(d == fake_dev for d in seen_devices)
|
||||
assert not (set(lora_state_dict.keys()) - used_keys)
|
||||
assert_params_not_equal(pipeline.transformer, original_params)
|
||||
@@ -411,6 +411,50 @@ def test_lora_manager_rolls_back_all_layers_when_activation_fails():
|
||||
assert len(first.set_calls) == first_calls_after_success + 2
|
||||
|
||||
|
||||
def test_lora_manager_rejects_adapter_that_binds_no_layer():
|
||||
"""An adapter whose target modules match nothing must fail, not silently no-op."""
|
||||
manager = DiffusionLoRAManager(
|
||||
pipeline=torch.nn.Module(),
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.bfloat16,
|
||||
max_cached_adapters=1,
|
||||
)
|
||||
layer = _DummyLoRALayer(n_slices=1, output_slices=(2,))
|
||||
manager._lora_modules = {"transformer.blocks.0.attn.to_q": layer}
|
||||
|
||||
# Adapter-side name the engine does not expose, e.g. a diffusers-style
|
||||
# checkpoint against a differently named engine layout.
|
||||
mismatched = LoRALayerWeights(
|
||||
module_name="unet.down_blocks.0.attn.to_q",
|
||||
rank=2,
|
||||
lora_alpha=2,
|
||||
lora_a=torch.ones((2, 2)),
|
||||
lora_b=torch.ones((2, 2)),
|
||||
)
|
||||
manager._registered_adapters = {
|
||||
3: type(
|
||||
"LM",
|
||||
(),
|
||||
{
|
||||
"id": 3,
|
||||
"loras": {"unet.down_blocks.0.attn.to_q": mismatched},
|
||||
"get_lora": lambda self, key: self.loras.get(key),
|
||||
},
|
||||
)()
|
||||
}
|
||||
|
||||
with pytest.raises(ValueError, match="applies to no layer") as excinfo:
|
||||
manager._activate_adapter(3, scale=1.0)
|
||||
|
||||
# The message must name what was received so the mismatch is diagnosable.
|
||||
assert "unet.down_blocks.0.attn.to_q" in str(excinfo.value)
|
||||
|
||||
# Nothing was bound and the adapter must not be left marked active.
|
||||
assert manager._active_adapter_id is None
|
||||
assert layer.set_calls == []
|
||||
assert layer.reset_calls >= 1
|
||||
|
||||
|
||||
def _dummy_lora_request(adapter_id: int) -> LoRARequest:
|
||||
return LoRARequest(
|
||||
lora_name=f"adapter_{adapter_id}",
|
||||
@@ -685,8 +729,10 @@ def test_lora_manager_discovers_bagel_component(monkeypatch):
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Pipeline with a 'bagel' component (no 'transformer')
|
||||
# Pipeline with a 'bagel' component (no 'transformer') declared via
|
||||
# _lora_components, matching how BagelPipeline opts in.
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline._lora_components = ["bagel"]
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
pipeline.bagel.language_model = torch.nn.Module()
|
||||
pipeline.bagel.language_model.qkv_proj = _FakeLinearBase()
|
||||
|
||||
@@ -6,9 +6,11 @@ Tests for the DiffusersPipelineLoader.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -260,17 +262,16 @@ def test_hwr_cold_publication_and_warm_restore_skip_ordinary_dit_loading(
|
||||
assert len(tuple((store_root / "source-digests-v1" / "entries").glob("*.json"))) == 1
|
||||
|
||||
|
||||
def test_maybe_fuse_distilled_lora_skips_when_hwr_warm_snapshot_present():
|
||||
def test_maybe_fuse_distilled_lora_skips_when_lora_path_unset():
|
||||
cfg = SimpleNamespace(
|
||||
lora_backend="distill",
|
||||
lora_path="/path/to/lora.safetensors",
|
||||
lora_path=None,
|
||||
lora_scale=1.0,
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=None,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False),
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), cfg)
|
||||
loader._hwr_state = {"warm_snapshot": {"transformer.weight": (1234, "hash")}}
|
||||
|
||||
model = nn.Module()
|
||||
model.load_lora_weights = MagicMock()
|
||||
@@ -278,7 +279,7 @@ def test_maybe_fuse_distilled_lora_skips_when_hwr_warm_snapshot_present():
|
||||
loader._maybe_fuse_distilled_lora(model)
|
||||
|
||||
model.load_lora_weights.assert_not_called()
|
||||
assert getattr(model, "lora_is_fused", False) is True
|
||||
assert getattr(model, "lora_is_fused", False) is False
|
||||
|
||||
|
||||
def test_maybe_fuse_distilled_lora_fuses_when_not_warm_snapshot():
|
||||
@@ -1053,6 +1054,34 @@ def test_dlo_plan_fallback_runs_ordinary_loader(monkeypatch):
|
||||
assert loader.take_host_weight_plan() is None
|
||||
|
||||
|
||||
def test_dlo_mmap_plan_with_distilled_lora_falls_back_to_ordinary_loader(monkeypatch):
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(use_hsdp=False, tensor_parallel_size=1),
|
||||
quantization_config=None,
|
||||
enable_distributed_layerwise_offload=True,
|
||||
dlo_use_allgather=False,
|
||||
lora_backend=LoRABackend.DISTILL,
|
||||
lora_path="/fake/lora.safetensors",
|
||||
model="unused",
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
model.load_lora_weights = MagicMock()
|
||||
calls: list[str] = []
|
||||
|
||||
loader._init_from_load_format = lambda *_args, **_kwargs: model # type: ignore[method-assign]
|
||||
loader.load_weights = lambda _model: calls.append("load") # type: ignore[method-assign]
|
||||
loader._process_weights_after_loading = lambda *_args: calls.append("process") # type: ignore[method-assign]
|
||||
loader._apply_skip_softmax_calibration = lambda _model: None # type: ignore[method-assign]
|
||||
|
||||
assert loader.load_model(load_device="cpu") is model
|
||||
assert calls == ["load", "process"]
|
||||
assert model.load_lora_weights.call_count == 1
|
||||
assert loader.take_host_weight_plan() is None
|
||||
|
||||
|
||||
def test_dlo_allgather_online_fp8_uses_ordinary_loader(monkeypatch):
|
||||
from vllm.model_executor.layers.quantization.online.fp8 import (
|
||||
Fp8PerTensorOnlineLinearMethod,
|
||||
@@ -1212,6 +1241,169 @@ def test_dlo_allgather_rejects_unvalidated_online_quant_method(monkeypatch):
|
||||
loader.load_model(load_device="cpu")
|
||||
|
||||
|
||||
def test_dlo_allgather_allows_unquantized_host_fallback():
|
||||
"""Host-loaded unquantized fallback layers are plain contiguous bf16 —
|
||||
the same runtime layout DLO already shards on the ordinary path — so the
|
||||
allowlist must not reject them."""
|
||||
from vllm_omni.quantization.int8_config import UnquantizedHostLinearMethod
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
model.transformer.quant_method = object.__new__(UnquantizedHostLinearMethod)
|
||||
|
||||
assert DiffusersPipelineLoader._unsupported_dlo_allgather_online_quant_methods(model) == ()
|
||||
|
||||
|
||||
def test_dlo_load_model_keeps_host_fallback_on_cpu_through_post_load_sweep(monkeypatch, mocker):
|
||||
"""Through load_model(): DLO + online quant must build the model inside
|
||||
load_unquantizable_fallback_on_cpu(), and the over-wide fallback weight
|
||||
must survive the post-load sweep (the _process_weights_after_loading pass
|
||||
plus model.to("cpu")) as the same host tensor it was loaded into — never
|
||||
bounced to the accelerator."""
|
||||
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
|
||||
from vllm_omni.quantization import int8_config
|
||||
from vllm_omni.quantization.int8_config import (
|
||||
NPU_QUANT_MATMUL_MAX_OUT_FEATURES,
|
||||
DiffusionInt8Config,
|
||||
NPUInt8OnlineLinearMethod,
|
||||
UnquantizedHostLinearMethod,
|
||||
)
|
||||
|
||||
# Same stand-in as TestHostFallbackLoading: the eager fallback delegates to
|
||||
# UnquantizedLinearMethod, which reads the TP group.
|
||||
mock_group = mocker.Mock()
|
||||
mock_group.rank_in_group = 0
|
||||
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_world_size", return_value=1)
|
||||
mocker.patch("vllm.model_executor.layers.linear.get_tensor_model_parallel_rank", return_value=0)
|
||||
mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_group)
|
||||
|
||||
od_config = _make_dlo_online_quant_config()
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
|
||||
out_features = NPU_QUANT_MATMUL_MAX_OUT_FEATURES + 1
|
||||
built_with_ctx: list[bool] = []
|
||||
|
||||
def copy_loader(param, loaded_weight, *args, **kwargs):
|
||||
param.data.copy_(loaded_weight)
|
||||
|
||||
def build_model(*_args, **_kwargs):
|
||||
built_with_ctx.append(int8_config._LOAD_UNQUANTIZABLE_FALLBACK_ON_CPU.get())
|
||||
quant_config = DiffusionInt8Config(is_checkpoint_int8_serialized=False, activation_scheme="dynamic")
|
||||
method = NPUInt8OnlineLinearMethod(quant_config)
|
||||
layer = nn.Module()
|
||||
layer.quant_method = method
|
||||
method.create_weights(
|
||||
layer,
|
||||
input_size_per_partition=8,
|
||||
output_partition_sizes=[out_features],
|
||||
input_size=8,
|
||||
output_size=out_features,
|
||||
params_dtype=torch.bfloat16,
|
||||
weight_loader=copy_loader,
|
||||
)
|
||||
model = nn.Module()
|
||||
model.transformer = layer
|
||||
return model
|
||||
|
||||
def load_weights(_model, **_kwargs):
|
||||
layer = _model.transformer
|
||||
loaded = torch.arange(out_features * 8, dtype=torch.float32).reshape(out_features, 8).to(torch.bfloat16)
|
||||
layer.weight.weight_loader(layer.weight, loaded)
|
||||
|
||||
loader._init_from_load_format = build_model # type: ignore[method-assign]
|
||||
loader.load_weights = load_weights # type: ignore[method-assign]
|
||||
loader._apply_skip_softmax_calibration = lambda _model: None # type: ignore[method-assign]
|
||||
monkeypatch.setattr(
|
||||
loader_mod,
|
||||
"build_checkpoint_mmap_plan",
|
||||
lambda *_args, **_kwargs: HostWeightPlanResult(
|
||||
None,
|
||||
"online quantization requires the ordinary loader",
|
||||
),
|
||||
)
|
||||
# Diffusion DiT models have no vLLM Attention layers, so the upstream
|
||||
# layerwise finalize is a no-op here; stub it to keep the real
|
||||
# _process_weights_after_loading sweep CPU-only.
|
||||
monkeypatch.setattr(
|
||||
"vllm.model_executor.model_loader.reload.layerwise.finalize_layerwise_processing",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
model = loader.load_model(load_device="cpu", device=torch.device("cpu"))
|
||||
|
||||
# Construction ran inside load_unquantizable_fallback_on_cpu(), so the
|
||||
# over-wide layer took the host-loading fallback...
|
||||
assert built_with_ctx == [True]
|
||||
assert type(model.transformer.quant_method) is UnquantizedHostLinearMethod
|
||||
# ...and the real post-load sweep skipped it via the fully-loaded flag and
|
||||
# model.to("cpu") left the loaded host tensor untouched.
|
||||
assert model.transformer._already_called_process_weights_after_loading
|
||||
assert model.transformer.weight.device.type == "cpu"
|
||||
expected = torch.arange(out_features * 8, dtype=torch.float32).reshape(out_features, 8).to(torch.bfloat16)
|
||||
assert torch.equal(model.transformer.weight, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("offload_after_quant", "ctx_expected"), [(True, True), (False, False)])
|
||||
def test_hsdp_enters_host_fallback_context_only_when_offloading_after_quant(mocker, offload_after_quant, ctx_expected):
|
||||
"""The HSDP path must apply the same host-fallback bound as the ordinary
|
||||
path: with a quant config it initializes on the accelerator, so the
|
||||
load_unquantizable_fallback_on_cpu() context is what keeps over-wide
|
||||
fallback weights off the device before sharding."""
|
||||
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
|
||||
from vllm_omni.diffusion.offloader.module_collector import PipelineModules
|
||||
from vllm_omni.quantization import int8_config
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(
|
||||
use_hsdp=True,
|
||||
hsdp_replicate_size=1,
|
||||
hsdp_shard_size=2,
|
||||
),
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
loader.quant_config = object()
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
ctx_seen: list[bool] = []
|
||||
|
||||
def build_model(*_args, **_kwargs):
|
||||
ctx_seen.append(int8_config._LOAD_UNQUANTIZABLE_FALLBACK_ON_CPU.get())
|
||||
return model
|
||||
|
||||
loader._init_from_load_format = build_model # type: ignore[method-assign]
|
||||
loader.load_weights = lambda _model: None # type: ignore[method-assign]
|
||||
loader._process_weights_after_loading = lambda *_args: None # type: ignore[method-assign]
|
||||
mocker.patch.object(
|
||||
loader_mod.ModuleDiscovery,
|
||||
"discover",
|
||||
return_value=PipelineModules(
|
||||
dits=[model.transformer],
|
||||
dit_names=["transformer"],
|
||||
vaes=[],
|
||||
encoders=[],
|
||||
encoder_names=[],
|
||||
resident_modules=[],
|
||||
resident_names=[],
|
||||
),
|
||||
)
|
||||
mocker.patch(
|
||||
"vllm_omni.diffusion.quantization.hsdp_fp8.prepare_fp8_layers_for_fsdp",
|
||||
side_effect=lambda _model: None,
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod,
|
||||
"apply_hsdp_to_model",
|
||||
side_effect=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
|
||||
loader._load_model_with_hsdp(torch.device("cpu"), offload_after_quant=offload_after_quant)
|
||||
|
||||
assert ctx_seen == [ctx_expected]
|
||||
|
||||
|
||||
def test_dlo_allgather_online_mxfp8_uses_ordinary_loader(monkeypatch):
|
||||
mxfp8_config = pytest.importorskip("vllm_omni.quantization.mxfp8_config")
|
||||
NPUMxfp8OnlineLinearMethod = mxfp8_config.NPUMxfp8OnlineLinearMethod
|
||||
@@ -1487,3 +1679,413 @@ def test_load_model(prefetch_helios_model, mock_tp_group):
|
||||
)
|
||||
model = loader.load_model(load_device="cpu")
|
||||
assert isinstance(model, HeliosPipeline)
|
||||
|
||||
|
||||
def test_hsdp_broadcast_weight_load_rank0(mocker):
|
||||
"""Ensure that on rank 0 with enable_broadcast_weight_load=True, weights are loaded, fused, and broadcast."""
|
||||
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
|
||||
from vllm_omni.diffusion.offloader.module_collector import PipelineModules
|
||||
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=4)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=0)
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(
|
||||
use_hsdp=True,
|
||||
hsdp_replicate_size=1,
|
||||
hsdp_shard_size=4,
|
||||
),
|
||||
enable_broadcast_weight_load=True,
|
||||
lora_backend=LoRABackend.DISTILL,
|
||||
lora_path="/fake/lora.safetensors",
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
events: list[str] = []
|
||||
|
||||
loader._init_from_load_format = mocker.Mock(return_value=model) # type: ignore[method-assign]
|
||||
loader.load_weights = mocker.Mock(side_effect=lambda _model: events.append("load")) # type: ignore[method-assign]
|
||||
loader._maybe_fuse_distilled_lora = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model: events.append("fuse")
|
||||
)
|
||||
loader._broadcast_model_weights = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, **_kwargs: events.append("broadcast")
|
||||
)
|
||||
loader._process_weights_after_loading = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, _device: events.append("process")
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod.ModuleDiscovery,
|
||||
"discover",
|
||||
return_value=PipelineModules(
|
||||
dits=[model.transformer],
|
||||
dit_names=["transformer"],
|
||||
vaes=[],
|
||||
encoders=[],
|
||||
encoder_names=[],
|
||||
resident_modules=[],
|
||||
resident_names=[],
|
||||
),
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod,
|
||||
"apply_hsdp_to_model",
|
||||
side_effect=lambda *_args, **_kwargs: events.append("shard"),
|
||||
)
|
||||
|
||||
loader._load_model_with_hsdp(torch.device("cpu"))
|
||||
|
||||
assert events == ["load", "fuse", "broadcast", "process", "shard"]
|
||||
|
||||
|
||||
def test_hsdp_broadcast_weight_load_rank_nonzero(mocker):
|
||||
"""Ensure that on rank > 0 with enable_broadcast_weight_load=True, loading is skipped and broadcast is received."""
|
||||
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
|
||||
from vllm_omni.diffusion.offloader.module_collector import PipelineModules
|
||||
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=4)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=1)
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(
|
||||
use_hsdp=True,
|
||||
hsdp_replicate_size=1,
|
||||
hsdp_shard_size=4,
|
||||
),
|
||||
enable_broadcast_weight_load=True,
|
||||
lora_backend=LoRABackend.DISTILL,
|
||||
lora_path="/fake/lora.safetensors",
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
model.load_lora_weights = mocker.Mock() # type: ignore[assignment]
|
||||
events: list[str] = []
|
||||
|
||||
loader._init_from_load_format = mocker.Mock(return_value=model) # type: ignore[method-assign]
|
||||
loader.load_weights = mocker.Mock(side_effect=lambda _model: events.append("load")) # type: ignore[method-assign]
|
||||
loader._maybe_fuse_distilled_lora = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model: events.append("fuse")
|
||||
)
|
||||
loader._broadcast_model_weights = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, **_kwargs: events.append("broadcast")
|
||||
)
|
||||
loader._process_weights_after_loading = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, _device: events.append("process")
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod.ModuleDiscovery,
|
||||
"discover",
|
||||
return_value=PipelineModules(
|
||||
dits=[model.transformer],
|
||||
dit_names=["transformer"],
|
||||
vaes=[],
|
||||
encoders=[],
|
||||
encoder_names=[],
|
||||
resident_modules=[],
|
||||
resident_names=[],
|
||||
),
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod,
|
||||
"apply_hsdp_to_model",
|
||||
side_effect=lambda *_args, **_kwargs: events.append("shard"),
|
||||
)
|
||||
|
||||
loader._load_model_with_hsdp(torch.device("cpu"))
|
||||
|
||||
# Rank 1 should NOT call load_weights or _maybe_fuse_distilled_lora from disk
|
||||
assert "load" not in events
|
||||
assert "fuse" not in events
|
||||
assert events == ["broadcast", "process", "shard"]
|
||||
assert getattr(model, "lora_is_fused", False) is True
|
||||
|
||||
|
||||
def test_broadcast_model_weights_invokes_dist_broadcast(mocker):
|
||||
broadcast_calls = []
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=2)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=0)
|
||||
mocker.patch("torch.distributed.get_backend", return_value="gloo")
|
||||
mocker.patch("torch.distributed.broadcast", side_effect=lambda tensor, src: broadcast_calls.append((tensor, src)))
|
||||
mocker.patch("torch.distributed.barrier")
|
||||
|
||||
model = nn.Module()
|
||||
model.linear = nn.Linear(2, 2)
|
||||
model.register_buffer("buf", torch.tensor([1.0, 2.0]))
|
||||
model.register_buffer("int_buf", torch.tensor([1, 2, 3], dtype=torch.long))
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(use_hsdp=True),
|
||||
enable_broadcast_weight_load=True,
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
loader._broadcast_model_weights(model, target_device=torch.device("cpu"), src_rank=0)
|
||||
|
||||
# float32 tensors (linear.weight, linear.bias, buf) are coalesced into 1 bucket,
|
||||
# and int64 tensor (int_buf) is in a separate bucket -> 2 coalesced broadcast calls
|
||||
assert len(broadcast_calls) == 2
|
||||
assert all(src == 0 for _, src in broadcast_calls)
|
||||
|
||||
|
||||
def test_broadcast_model_weights_receiver_copies_data(mocker):
|
||||
"""Ensure receiver rank copies broadcasted flat tensor data into its model parameters/buffers."""
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=2)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=1)
|
||||
mocker.patch("torch.distributed.get_backend", return_value="gloo")
|
||||
mocker.patch("torch.distributed.barrier")
|
||||
|
||||
# Simulate broadcast by filling the flat tensor with source values
|
||||
def mock_broadcast(tensor, src):
|
||||
if tensor.dtype == torch.float32:
|
||||
tensor.fill_(3.14)
|
||||
elif tensor.dtype == torch.long:
|
||||
tensor.fill_(42)
|
||||
|
||||
mocker.patch("torch.distributed.broadcast", side_effect=mock_broadcast)
|
||||
|
||||
model = nn.Module()
|
||||
model.linear = nn.Linear(2, 2)
|
||||
model.register_buffer("buf", torch.zeros(2))
|
||||
model.register_buffer("int_buf", torch.zeros(3, dtype=torch.long))
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(use_hsdp=True),
|
||||
enable_broadcast_weight_load=True,
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
loader._broadcast_model_weights(model, target_device=torch.device("cpu"), src_rank=0)
|
||||
|
||||
assert torch.allclose(model.linear.weight, torch.tensor(3.14))
|
||||
assert torch.allclose(model.linear.bias, torch.tensor(3.14))
|
||||
assert torch.allclose(model.buf, torch.tensor(3.14))
|
||||
assert torch.equal(model.int_buf, torch.tensor([42, 42, 42], dtype=torch.long))
|
||||
|
||||
|
||||
def test_broadcast_model_weights_mixed_cpu_and_accelerator_devices(mocker):
|
||||
"""Ensure that when some pipeline modules are on CPU (transformer) and others on accelerator (VAE/encoder),
|
||||
broadcasting normalizes tensors to CPU before concatenation and succeeds without device mismatch.
|
||||
"""
|
||||
dev = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=2)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=0)
|
||||
mocker.patch("torch.distributed.get_backend", return_value="nccl" if dev.type == "cuda" else "gloo")
|
||||
mocker.patch("torch.distributed.broadcast")
|
||||
mocker.patch("torch.distributed.barrier")
|
||||
|
||||
model = nn.Module()
|
||||
# Transformer parameters on CPU
|
||||
model.transformer = nn.Linear(4, 4)
|
||||
# VAE / Encoder parameters on accelerator
|
||||
model.vae = nn.Linear(4, 4).to(dev)
|
||||
model.text_encoder = nn.Linear(4, 4).to(dev)
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(use_hsdp=True),
|
||||
enable_broadcast_weight_load=True,
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
# Should not raise RuntimeError: Tensors must have same device
|
||||
loader._broadcast_model_weights(model, target_device=dev, src_rank=0)
|
||||
|
||||
|
||||
def test_broadcast_model_weights_mixed_devices_receiver(mocker):
|
||||
"""Ensure receiver rank with mixed CPU/accelerator tensors correctly receives broadcast data."""
|
||||
dev = torch.device("cuda:0") if torch.cuda.is_available() else torch.device("cpu")
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=2)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=1)
|
||||
mocker.patch("torch.distributed.get_backend", return_value="nccl" if dev.type == "cuda" else "gloo")
|
||||
mocker.patch("torch.distributed.barrier")
|
||||
|
||||
def mock_broadcast(tensor, src):
|
||||
tensor.fill_(7.0)
|
||||
|
||||
mocker.patch("torch.distributed.broadcast", side_effect=mock_broadcast)
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2) # CPU
|
||||
model.vae = nn.Linear(2, 2).to(dev) # Accelerator
|
||||
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(use_hsdp=True),
|
||||
enable_broadcast_weight_load=True,
|
||||
quantization_config=None,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
loader._broadcast_model_weights(model, target_device=dev, src_rank=0)
|
||||
|
||||
assert torch.allclose(model.transformer.weight, torch.tensor(7.0))
|
||||
assert torch.allclose(model.vae.weight, torch.tensor(7.0, device=dev))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("rank", [0, 1])
|
||||
def test_hsdp_broadcast_weight_load_falls_back_when_online_quantization_enabled(mocker, rank):
|
||||
"""Ensure that when online quantization is enabled, HSDP weight loading falls back to
|
||||
ordinary per-rank loading on all ranks rather than rank-0 broadcast.
|
||||
"""
|
||||
import vllm_omni.diffusion.model_loader.diffusers_loader as loader_mod
|
||||
from vllm_omni.diffusion.offloader.module_collector import PipelineModules
|
||||
|
||||
mocker.patch("torch.distributed.is_initialized", return_value=True)
|
||||
mocker.patch("torch.distributed.get_world_size", return_value=4)
|
||||
mocker.patch("torch.distributed.get_rank", return_value=rank)
|
||||
|
||||
mock_quant_config = SimpleNamespace(
|
||||
is_checkpoint_quantized=False,
|
||||
)
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(
|
||||
use_hsdp=True,
|
||||
hsdp_replicate_size=1,
|
||||
hsdp_shard_size=4,
|
||||
),
|
||||
enable_broadcast_weight_load=True,
|
||||
lora_backend=None,
|
||||
lora_path=None,
|
||||
quantization_config=mock_quant_config,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
|
||||
model = nn.Module()
|
||||
model.transformer = nn.Linear(2, 2, bias=False)
|
||||
events: list[str] = []
|
||||
|
||||
loader._init_from_load_format = mocker.Mock(return_value=model) # type: ignore[method-assign]
|
||||
loader.load_weights = mocker.Mock(side_effect=lambda _model: events.append("load")) # type: ignore[method-assign]
|
||||
loader._broadcast_model_weights = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, **_kwargs: events.append("broadcast")
|
||||
)
|
||||
loader._process_weights_after_loading = mocker.Mock( # type: ignore[method-assign]
|
||||
side_effect=lambda _model, _device: events.append("process")
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod.ModuleDiscovery,
|
||||
"discover",
|
||||
return_value=PipelineModules(
|
||||
dits=[model.transformer],
|
||||
dit_names=["transformer"],
|
||||
vaes=[],
|
||||
encoders=[],
|
||||
encoder_names=[],
|
||||
resident_modules=[],
|
||||
resident_names=[],
|
||||
),
|
||||
)
|
||||
mocker.patch.object(
|
||||
loader_mod,
|
||||
"apply_hsdp_to_model",
|
||||
side_effect=lambda *_args, **_kwargs: events.append("shard"),
|
||||
)
|
||||
|
||||
loader._load_model_with_hsdp(torch.device("cpu"))
|
||||
|
||||
# Both rank 0 and non-zero ranks MUST execute ordinary load_weights, not broadcast
|
||||
assert "load" in events
|
||||
assert "broadcast" not in events
|
||||
assert events == ["load", "process", "shard"]
|
||||
|
||||
|
||||
def _mp_worker_online_quant(rank: int, world_size: int, rendezvous: str, temp_dir: str) -> None:
|
||||
torch.set_num_threads(1)
|
||||
import torch.distributed as dist
|
||||
|
||||
from vllm_omni.diffusion.offloader.module_collector import PipelineModules
|
||||
|
||||
dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=world_size)
|
||||
try:
|
||||
|
||||
class _MockOnlineQuantModel(nn.Module):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.transformer = nn.Linear(4, 4)
|
||||
self.load_called = False
|
||||
|
||||
def load_weights(self, weights):
|
||||
self.load_called = True
|
||||
return {"transformer.weight", "transformer.bias"}
|
||||
|
||||
mock_quant_config = SimpleNamespace(is_checkpoint_quantized=False)
|
||||
od_config = SimpleNamespace(
|
||||
dtype=torch.float32,
|
||||
parallel_config=SimpleNamespace(
|
||||
use_hsdp=True,
|
||||
hsdp_replicate_size=1,
|
||||
hsdp_shard_size=world_size,
|
||||
),
|
||||
enable_broadcast_weight_load=True,
|
||||
lora_backend=None,
|
||||
lora_path=None,
|
||||
quantization_config=mock_quant_config,
|
||||
)
|
||||
loader = DiffusersPipelineLoader(LoadConfig(), od_config)
|
||||
model = _MockOnlineQuantModel()
|
||||
loader._init_from_load_format = lambda *args, **kwargs: model # type: ignore[method-assign]
|
||||
loader._process_weights_after_loading = lambda *args, **kwargs: None # type: ignore[method-assign]
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
loader_module.ModuleDiscovery,
|
||||
"discover",
|
||||
return_value=PipelineModules(
|
||||
dits=[model.transformer],
|
||||
dit_names=["transformer"],
|
||||
vaes=[],
|
||||
encoders=[],
|
||||
encoder_names=[],
|
||||
resident_modules=[],
|
||||
resident_names=[],
|
||||
),
|
||||
),
|
||||
patch.object(loader_module, "apply_hsdp_to_model", return_value=None),
|
||||
):
|
||||
loader._load_model_with_hsdp(torch.device("cpu"))
|
||||
|
||||
if model.load_called:
|
||||
with open(os.path.join(temp_dir, f"rank_{rank}_success.flag"), "w") as f:
|
||||
f.write("ok")
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not torch.distributed.is_available() or not torch.distributed.is_gloo_available(),
|
||||
reason="requires torch.distributed gloo",
|
||||
)
|
||||
def test_hsdp_broadcast_weight_load_online_quant_multiprocess():
|
||||
"""Multi-process regression test verifying that with online quantization and broadcast enabled,
|
||||
all ranks fall back to independent load_weights in parallel processes without hanging.
|
||||
"""
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
rendezvous = f"file://{os.path.join(temp_dir, 'gloo-rendezvous')}"
|
||||
world_size = 2
|
||||
with patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}):
|
||||
mp.spawn(
|
||||
_mp_worker_online_quant,
|
||||
args=(world_size, rendezvous, temp_dir),
|
||||
nprocs=world_size,
|
||||
join=True,
|
||||
)
|
||||
assert os.path.exists(os.path.join(temp_dir, "rank_0_success.flag"))
|
||||
assert os.path.exists(os.path.join(temp_dir, "rank_1_success.flag"))
|
||||
@@ -0,0 +1,2 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
@@ -0,0 +1,454 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from shutil import rmtree
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
|
||||
from tests.model_tests.diffusion.anima_builder import CHECKPOINT_FILENAME, tiny_anima_builder
|
||||
from vllm_omni.diffusion.attention import selector
|
||||
from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_qwenimage import DistributedAutoencoderKLQwenImage
|
||||
from vllm_omni.diffusion.models.anima import pipeline_anima
|
||||
from vllm_omni.diffusion.models.anima.anima_text_conditioner import AnimaTextConditioner
|
||||
from vllm_omni.diffusion.models.anima.anima_transformer import AnimaTransformer3DModel
|
||||
from vllm_omni.diffusion.models.anima.pipeline_anima import AnimaPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"offload_args",
|
||||
[
|
||||
{"enable_cpu_offload": True},
|
||||
{"enable_layerwise_offload": True},
|
||||
{"enable_distributed_layerwise_offload": True},
|
||||
{"diffusion_offload_config": {"mode": "module", "components": ["dit"]}},
|
||||
{
|
||||
"diffusion_offload_config": {
|
||||
"mode": "layer",
|
||||
"components": ["dit"],
|
||||
"layer_options": {"dit": {"weight_transfer": "rank-local"}},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_native_anima_rejects_offload(offload_args):
|
||||
config = OmniDiffusionConfig(model="anima.safetensors", **offload_args)
|
||||
with pytest.raises(NotImplementedError, match="offload"):
|
||||
AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
|
||||
|
||||
def test_native_anima_initializes_without_offload():
|
||||
config = OmniDiffusionConfig(model="anima.safetensors")
|
||||
AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
|
||||
|
||||
def test_anima_registration() -> None:
|
||||
"""The native Anima pipeline is registered and importable."""
|
||||
from vllm_omni.diffusion.registry import DiffusionModelRegistry
|
||||
|
||||
assert DiffusionModelRegistry._try_load_model_cls("AnimaPipeline") is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_class_name", ["AnimaPipeline", "AnimaModularPipeline"])
|
||||
def test_enrich_config_native_anima_checkpoint(tmp_path: Path, model_class_name: str) -> None:
|
||||
"""Native and reference Anima class names resolve to AnimaPipeline."""
|
||||
dummy_checkpoint = tmp_path / "model.safetensors"
|
||||
dummy_checkpoint.write_text("dummy")
|
||||
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(dummy_checkpoint),
|
||||
model_class_name=model_class_name,
|
||||
)
|
||||
config.enrich_config()
|
||||
|
||||
assert config.diffusion_load_format == "default"
|
||||
assert config.model_class_name == "AnimaPipeline"
|
||||
assert config.diffusers_pipeline_cls is None
|
||||
|
||||
|
||||
def test_native_anima_component_paths_use_custom_pipeline_args() -> None:
|
||||
"""Anima resolves shared and component-specific paths from native args."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
custom_pipeline_args={
|
||||
"components_path": "/tmp/anima-components",
|
||||
"vae_path": "/tmp/anima-vae",
|
||||
"text_encoder_model": "/tmp/anima-text-encoder",
|
||||
}
|
||||
)
|
||||
|
||||
assert pipeline._component_path("vae", "/tmp/default") == "/tmp/anima-vae"
|
||||
assert pipeline._component_path("text_encoder", "/tmp/default") == "/tmp/anima-text-encoder"
|
||||
assert pipeline._component_path("tokenizer", "/tmp/default") == "/tmp/default"
|
||||
|
||||
|
||||
def test_native_anima_converts_original_cosmos_transformer_keys() -> None:
|
||||
"""Original Cosmos checkpoint names map to native module names."""
|
||||
converted = AnimaPipeline._convert_original_transformer_state_dict(
|
||||
{
|
||||
"net.x_embedder.proj.1.weight": "patch",
|
||||
"net.blocks.0.self_attn.q_proj.weight": "q",
|
||||
"net.blocks.0.self_attn.q_norm.weight": "q_norm",
|
||||
"net.blocks.0.mlp.layer1.weight": "mlp",
|
||||
"net.final_layer.linear.weight": "out",
|
||||
"net.accum_iteration": "drop",
|
||||
}
|
||||
)
|
||||
|
||||
assert converted == {
|
||||
"patch_embed.proj.weight": "patch",
|
||||
"transformer_blocks.0.attn1.to_q.weight": "q",
|
||||
"transformer_blocks.0.attn1.norm_q.weight": "q_norm",
|
||||
"transformer_blocks.0.ff.net.0.proj.weight": "mlp",
|
||||
"proj_out.weight": "out",
|
||||
}
|
||||
|
||||
|
||||
def test_native_anima_resolves_vae_scale_factor_from_loaded_vae() -> None:
|
||||
"""The loaded VAE determines the spatial scale factor."""
|
||||
from vllm_omni.diffusion.models.anima.pipeline_anima import _anima_vae_scale_factor_from_vae
|
||||
|
||||
vae = SimpleNamespace(config=SimpleNamespace(spatial_compression_ratio=16))
|
||||
|
||||
assert _anima_vae_scale_factor_from_vae(vae) == 16
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_config_file", [False, True])
|
||||
def test_native_anima_loads_synthetic_checkpoint(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, use_config_file: bool
|
||||
) -> None:
|
||||
"""Native modules load exact weights from an Anima safetensors checkpoint."""
|
||||
tiny_transformer_config = {
|
||||
"in_channels": 1,
|
||||
"out_channels": 1,
|
||||
"num_attention_heads": 1,
|
||||
"attention_head_dim": 12,
|
||||
"num_layers": 1,
|
||||
"mlp_ratio": 1.0,
|
||||
"text_embed_dim": 4,
|
||||
"adaln_lora_dim": 3,
|
||||
"max_size": (1, 2, 2),
|
||||
"patch_size": (1, 1, 1),
|
||||
"rope_scale": (1.0, 1.0, 1.0),
|
||||
"concat_padding_mask": True,
|
||||
"extra_pos_embed_type": None,
|
||||
}
|
||||
tiny_text_conditioner_config = {
|
||||
"source_dim": 4,
|
||||
"target_dim": 4,
|
||||
"model_dim": 4,
|
||||
"num_layers": 1,
|
||||
"num_attention_heads": 1,
|
||||
"target_vocab_size": 8,
|
||||
"min_sequence_length": 4,
|
||||
}
|
||||
if use_config_file:
|
||||
(tmp_path / "anima.json").write_text(
|
||||
json.dumps({"transformer": tiny_transformer_config, "text_conditioner": tiny_text_conditioner_config})
|
||||
)
|
||||
else:
|
||||
monkeypatch.setattr(pipeline_anima, "ANIMA_TRANSFORMER_CONFIG", tiny_transformer_config)
|
||||
monkeypatch.setattr(pipeline_anima, "ANIMA_TEXT_CONDITIONER_CONFIG", tiny_text_conditioner_config)
|
||||
|
||||
monkeypatch.setattr(selector, "_cached_get_backend_cls", lambda *_args, **_kwargs: SDPABackend)
|
||||
transformer = AnimaTransformer3DModel(**tiny_transformer_config)
|
||||
text_conditioner = AnimaTextConditioner(**tiny_text_conditioner_config)
|
||||
transformer_state = {name: tensor.detach().clone() for name, tensor in transformer.state_dict().items()}
|
||||
text_conditioner_state = {name: tensor.detach().clone() for name, tensor in text_conditioner.state_dict().items()}
|
||||
checkpoint_state = {
|
||||
**{f"transformer.{name}": tensor for name, tensor in transformer_state.items()},
|
||||
**{f"text_conditioner.{name}": tensor for name, tensor in text_conditioner_state.items()},
|
||||
}
|
||||
|
||||
checkpoint_path = tmp_path / "anima.safetensors"
|
||||
save_file(checkpoint_state, str(checkpoint_path))
|
||||
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(model=str(checkpoint_path), dtype=torch.float32)
|
||||
pipeline.device = torch.device("cpu")
|
||||
|
||||
def assert_loaded(loaded_transformer, loaded_text_conditioner):
|
||||
for name, tensor in transformer_state.items():
|
||||
assert torch.equal(loaded_transformer.state_dict()[name], tensor)
|
||||
for name, tensor in text_conditioner_state.items():
|
||||
assert torch.equal(loaded_text_conditioner.state_dict()[name], tensor)
|
||||
|
||||
loaded_transformer, loaded_text_conditioner = pipeline._load_native_denoiser_components(dict(checkpoint_state))
|
||||
assert_loaded(loaded_transformer, loaded_text_conditioner)
|
||||
|
||||
loaded_transformer, loaded_text_conditioner = pipeline._load_native_denoiser_components()
|
||||
assert_loaded(loaded_transformer, loaded_text_conditioner)
|
||||
|
||||
if use_config_file:
|
||||
tiny_transformer_config["num_layers"] = 2
|
||||
(tmp_path / "anima.json").write_text(
|
||||
json.dumps({"transformer": tiny_transformer_config, "text_conditioner": tiny_text_conditioner_config})
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Missing key"):
|
||||
pipeline._load_native_denoiser_components()
|
||||
|
||||
|
||||
def test_tiny_anima_generates_images(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Load every saved component and exercise CFG, decoding, and multiple outputs on CPU."""
|
||||
monkeypatch.setattr(selector, "_cached_get_backend_cls", lambda *_args, **_kwargs: SDPABackend)
|
||||
monkeypatch.setattr(current_omni_platform, "is_available", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
DistributedAutoencoderKLQwenImage,
|
||||
"init_distributed",
|
||||
lambda self: setattr(self, "distributed_executor", SimpleNamespace(parallel_size=1)),
|
||||
)
|
||||
model_dir = tiny_anima_builder()
|
||||
try:
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(Path(model_dir) / CHECKPOINT_FILENAME), model_class_name="AnimaPipeline", dtype=torch.float32
|
||||
)
|
||||
pipeline = AnimaPipeline(od_config=config, device=torch.device("cpu"))
|
||||
pipeline.load_weights()
|
||||
pipeline.eval()
|
||||
outputs = []
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
request = OmniDiffusionRequest(
|
||||
prompt="Dummy prompt",
|
||||
request_id="tiny-anima",
|
||||
sampling_params=OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=32,
|
||||
num_inference_steps=2,
|
||||
guidance_scale=4.0,
|
||||
num_outputs_per_prompt=2,
|
||||
generator=torch.Generator(device="cpu").manual_seed(42),
|
||||
),
|
||||
)
|
||||
outputs.append(pipeline(DiffusionRequestBatch([request]))[0].output)
|
||||
assert outputs[0].shape == (2, 3, 32, 32)
|
||||
assert torch.isfinite(outputs[0]).all()
|
||||
assert torch.equal(outputs[0], outputs[1])
|
||||
assert not torch.equal(outputs[0][0], outputs[0][1])
|
||||
finally:
|
||||
rmtree(model_dir)
|
||||
|
||||
|
||||
def test_native_anima_profiler_setup_is_deferred_and_idempotent(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Profiler targets are wrapped once, after deferred components are loaded."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.od_config = SimpleNamespace(enable_diffusion_pipeline_profiler=True)
|
||||
pipeline._profiler_initialized = False
|
||||
pipeline.vae = SimpleNamespace(decode=lambda: "decoded")
|
||||
pipeline.text_encoder = SimpleNamespace(forward=lambda: "encoded")
|
||||
|
||||
monkeypatch.setattr(current_omni_platform, "is_available", lambda: False)
|
||||
pipeline._setup_profiler()
|
||||
wrapped_decode = pipeline.vae.decode
|
||||
wrapped_text_encoder = pipeline.text_encoder.forward
|
||||
pipeline._setup_profiler()
|
||||
|
||||
assert pipeline.vae.decode is wrapped_decode
|
||||
assert pipeline.text_encoder.forward is wrapped_text_encoder
|
||||
assert pipeline.vae.decode() == "decoded"
|
||||
assert pipeline.text_encoder.forward() == "encoded"
|
||||
assert "AnimaPipeline.vae.decode" in pipeline.stage_durations
|
||||
assert "AnimaPipeline.text_encoder.forward" in pipeline.stage_durations
|
||||
|
||||
|
||||
def _make_anima_forward_probe():
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.vae_scale_factor = 8
|
||||
pipeline.transformer = SimpleNamespace(dtype=torch.float32)
|
||||
pipeline.text_encoder = SimpleNamespace(dtype=torch.float32)
|
||||
pipeline._current_timestep = None
|
||||
pipeline._num_timesteps = 0
|
||||
pipeline._guidance_scale = 0.0
|
||||
captured = {}
|
||||
|
||||
def encode_prompt(**_kwargs):
|
||||
return {
|
||||
"qwen_prompt_embeds": torch.zeros(1, 2, 4),
|
||||
"qwen_attention_mask": torch.ones(1, 2),
|
||||
"t5_input_ids": torch.ones(1, 2, dtype=torch.long),
|
||||
"t5_attention_mask": torch.ones(1, 2),
|
||||
"negative_qwen_prompt_embeds": torch.zeros(1, 2, 4),
|
||||
"negative_qwen_attention_mask": torch.ones(1, 2),
|
||||
"negative_t5_input_ids": torch.ones(1, 2, dtype=torch.long),
|
||||
"negative_t5_attention_mask": torch.ones(1, 2),
|
||||
}
|
||||
|
||||
def condition_prompt_embeds(**_kwargs):
|
||||
return torch.zeros(1, 2, 4)
|
||||
|
||||
def prepare_latents(**kwargs):
|
||||
captured["prepare_latents"] = kwargs
|
||||
return torch.zeros(1, 16, 1, kwargs["height"] // 8, kwargs["width"] // 8)
|
||||
|
||||
def diffuse(**kwargs):
|
||||
captured["diffuse"] = kwargs
|
||||
return kwargs["latents"]
|
||||
|
||||
pipeline.encode_prompt = encode_prompt
|
||||
pipeline.condition_prompt_embeds = condition_prompt_embeds
|
||||
pipeline.prepare_latents = prepare_latents
|
||||
pipeline.prepare_timesteps = lambda **_kwargs: (torch.ones(1), 1)
|
||||
pipeline.diffuse = diffuse
|
||||
|
||||
def decode_latents(latents, output_type="pil"):
|
||||
captured["output_type"] = output_type
|
||||
return DiffusionOutput(output=latents)
|
||||
|
||||
pipeline.decode_latents = decode_latents
|
||||
return pipeline, captured
|
||||
|
||||
|
||||
def test_native_anima_forward_uses_official_default_resolution() -> None:
|
||||
"""Forward uses Anima's reference resolution when none is requested."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id="anima-defaults",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["prepare_latents"]["height"] == 1024
|
||||
assert captured["prepare_latents"]["width"] == 1024
|
||||
|
||||
|
||||
def test_native_anima_forward_honors_sampling_output_type() -> None:
|
||||
"""Request-level output type controls Anima decoding."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(output_type="latent"),
|
||||
request_id="anima-output-type",
|
||||
)
|
||||
|
||||
outputs = pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert len(outputs) == 1
|
||||
assert isinstance(outputs[0], DiffusionOutput)
|
||||
assert outputs[0].output.shape == (1, 16, 1, 128, 128)
|
||||
assert captured["output_type"] == "latent"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("request_count", [0, 2])
|
||||
def test_native_anima_forward_rejects_non_single_request_batches(request_count: int) -> None:
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
requests = [
|
||||
OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(),
|
||||
request_id=f"anima-{index}",
|
||||
)
|
||||
for index in range(request_count)
|
||||
]
|
||||
|
||||
with pytest.raises(ValueError, match="supports one request per forward"):
|
||||
pipeline.forward(DiffusionRequestBatch(requests))
|
||||
|
||||
assert captured == {}
|
||||
|
||||
|
||||
def test_native_anima_explicit_guidance_scale_drives_cfg_multiplier() -> None:
|
||||
"""An explicit guidance scale controls native classifier-free guidance."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(guidance_scale=5.0),
|
||||
request_id="anima-guidance",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["diffuse"]["do_true_cfg"] is True
|
||||
assert captured["diffuse"]["true_cfg_scale"] == 5.0
|
||||
|
||||
|
||||
def test_native_anima_true_cfg_scale_overrides_guidance_multiplier() -> None:
|
||||
"""An explicit true-CFG scale overrides the guidance-scale multiplier."""
|
||||
pipeline, captured = _make_anima_forward_probe()
|
||||
req = OmniDiffusionRequest(
|
||||
prompt="a red cube",
|
||||
sampling_params=OmniDiffusionSamplingParams(guidance_scale=5.0, true_cfg_scale=3.0),
|
||||
request_id="anima-true-cfg",
|
||||
)
|
||||
|
||||
pipeline.forward(DiffusionRequestBatch([req]))
|
||||
|
||||
assert captured["diffuse"]["do_true_cfg"] is True
|
||||
assert captured["diffuse"]["true_cfg_scale"] == 3.0
|
||||
|
||||
|
||||
def test_native_anima_cfg_equation() -> None:
|
||||
"""The denoising loop applies the standard true-CFG equation."""
|
||||
pipeline = AnimaPipeline.__new__(AnimaPipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline._interrupt = False
|
||||
|
||||
class MockProgressBar:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
pass
|
||||
|
||||
def update(self):
|
||||
pass
|
||||
|
||||
pipeline.progress_bar = lambda **_kwargs: MockProgressBar()
|
||||
|
||||
class MockTransformer:
|
||||
dtype = torch.float32
|
||||
|
||||
def __call__(self, hidden_states, timestep, encoder_hidden_states, padding_mask, return_dict=False):
|
||||
# Return double the encoder_hidden_states to simulate transformer output
|
||||
return (encoder_hidden_states * 2.0,)
|
||||
|
||||
pipeline.transformer = MockTransformer()
|
||||
|
||||
class MockScheduler:
|
||||
def set_begin_index(self, index):
|
||||
pass
|
||||
|
||||
def step(self, noise_pred, t, latents, return_dict=False):
|
||||
# Return the noise_pred itself to inspect it
|
||||
return (noise_pred,)
|
||||
|
||||
pipeline.scheduler = MockScheduler()
|
||||
|
||||
prompt_embeds = torch.tensor([[[2.0, 3.0]]])
|
||||
negative_prompt_embeds = torch.tensor([[[1.0, 2.0]]])
|
||||
latents = torch.zeros(1, 16, 1, 16, 16)
|
||||
padding_mask = torch.zeros(1, 1, 16, 16)
|
||||
timesteps = torch.tensor([500.0])
|
||||
|
||||
# Cond = prompt_embeds * 2.0 = [4.0, 6.0]
|
||||
# Uncond = negative_prompt_embeds * 2.0 = [2.0, 4.0]
|
||||
# Expected noise_pred = uncond + true_cfg_scale * (cond - uncond)
|
||||
# = [2.0, 4.0] + 4.0 * ([4.0, 6.0] - [2.0, 4.0])
|
||||
# = [2.0, 4.0] + 4.0 * [2.0, 2.0]
|
||||
# = [2.0, 4.0] + [8.0, 8.0] = [10.0, 12.0]
|
||||
out_latents = pipeline.diffuse(
|
||||
prompt_embeds=prompt_embeds,
|
||||
negative_prompt_embeds=negative_prompt_embeds,
|
||||
latents=latents,
|
||||
padding_mask=padding_mask,
|
||||
timesteps=timesteps,
|
||||
do_true_cfg=True,
|
||||
true_cfg_scale=4.0,
|
||||
)
|
||||
|
||||
expected = torch.tensor([[[10.0, 12.0]]])
|
||||
assert torch.allclose(out_latents, expected)
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env python
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK transformer parity: the correctness oracle for the in-tree port.
|
||||
|
||||
Checks that :class:`AuKTransformer` reproduces the upstream ``auk`` reference backbone
|
||||
on the released ``auk_base`` weights. Both models are built from the same
|
||||
state dict and fed the same inputs, under the reference's own inference
|
||||
configuration: fp32 weights with ``torch.autocast(bfloat16)``. That
|
||||
configuration matters. The reference keeps its rotary frequencies in a buffer,
|
||||
so casting its weights to bf16 would round both the frequencies and the
|
||||
integer positions and move the numbers for reasons unrelated to the port.
|
||||
|
||||
Cases:
|
||||
|
||||
* ``single`` - one conditional forward
|
||||
* ``cfg`` - the doubled ``cfg_infer`` forward, both branches
|
||||
* ``masked`` - batch of 2 with padded targets, references and text
|
||||
* ``rope`` - the local rotary implementation against ``x_transformers``, and
|
||||
its survival of a cast to bf16
|
||||
* ``sample`` - an 8-step Euler trajectory against the reference backbone
|
||||
driven by a transcription of upstream's ODE loop
|
||||
* ``generator`` - the sampler's two noise paths agreeing
|
||||
* ``cache`` - the text projection cache and ``clear_cache()``
|
||||
* ``mask flag`` - ``attn_mask_enabled=False`` reproducing the reference with
|
||||
its own flag off
|
||||
|
||||
This directory deliberately has no ``__init__.py``, unlike its siblings.
|
||||
pytest's prepend import mode puts the nearest non-package directory on
|
||||
``sys.path``, and an ``auk`` package here would shadow the upstream ``auk``
|
||||
distribution this test compares against.
|
||||
|
||||
Needs the released checkpoint and a GPU, so it runs locally rather than in
|
||||
CI. Point ``AUK_CKPT_DIR`` at a directory holding ``auk_base.safetensors`` and
|
||||
``config.yaml``; the test skips when that is unset or when the upstream
|
||||
``auk`` package is not importable::
|
||||
|
||||
AUK_CKPT_DIR=/path/to/ckpts/AuK CUDA_VISIBLE_DEVICES=6 \
|
||||
python -m pytest tests/diffusion/models/auk/test_auk_transformer_parity.py -v -s
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import yaml
|
||||
|
||||
from vllm_omni.diffusion.models.auk.auk_transformer import (
|
||||
AuKTransformer,
|
||||
Rotary,
|
||||
_apply_rope,
|
||||
dit_state_dict,
|
||||
sample_latents,
|
||||
)
|
||||
|
||||
CKPT_DIR = os.environ.get("AUK_CKPT_DIR")
|
||||
|
||||
# Tolerances. The port evaluates rotary embeddings in fp32 and skips the
|
||||
# reference's redundant uncond text projection, so the two graphs are not
|
||||
# bit-identical under autocast; they agree well inside bf16 noise.
|
||||
FORWARD_ATOL = 3e-2
|
||||
FORWARD_COS = 0.999
|
||||
SAMPLE_ATOL = 5e-2
|
||||
SAMPLE_COS = 0.999
|
||||
ROPE_ATOL = 1e-5
|
||||
|
||||
TARGET_FRAMES = 300
|
||||
REF_FRAMES = 150
|
||||
TEXT_TOKENS = 40
|
||||
SHORT_LENS = (220, 90, 25) # target, reference, text for the padded batch row
|
||||
TIME = 0.37
|
||||
SAMPLE_STEPS = 8
|
||||
SWAY_COEF = -1.0
|
||||
SEED = 1234
|
||||
DEVICE = "cuda:0"
|
||||
AUTOCAST_DTYPE = torch.bfloat16
|
||||
|
||||
|
||||
def _skip_reason() -> str | None:
|
||||
if CKPT_DIR is None:
|
||||
return "AUK_CKPT_DIR is not set"
|
||||
if not Path(CKPT_DIR, "auk_base.safetensors").is_file():
|
||||
return f"no auk_base.safetensors under {CKPT_DIR}"
|
||||
try:
|
||||
# Resolving the submodule, not just "auk", also catches the shadow an
|
||||
# __init__.py in this directory would create.
|
||||
upstream = importlib.util.find_spec("auk.model")
|
||||
except ModuleNotFoundError:
|
||||
upstream = None
|
||||
if upstream is None:
|
||||
return "the upstream 'auk' package is not importable or is shadowed by this directory"
|
||||
if not torch.cuda.is_available():
|
||||
return "no CUDA device"
|
||||
return None
|
||||
|
||||
|
||||
SKIP_REASON = _skip_reason()
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.local_model,
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.gpu,
|
||||
pytest.mark.cuda,
|
||||
pytest.mark.skipif(SKIP_REASON is not None, reason=str(SKIP_REASON)),
|
||||
]
|
||||
|
||||
|
||||
def _arch() -> dict:
|
||||
"""Read the backbone geometry out of the checkpoint's config."""
|
||||
config = yaml.safe_load(Path(CKPT_DIR, "config.yaml").read_text())["model"]
|
||||
return {
|
||||
"dim": config["arch"]["dim"],
|
||||
"heads": config["arch"]["heads"],
|
||||
"dim_head": config["arch"].get("dim_head", 64),
|
||||
"ff_mult": config["arch"]["ff_mult"],
|
||||
"text_hidden_dim": config["arch"]["text_hidden_dim"],
|
||||
"num_layers": config["arch"]["num_layers"],
|
||||
"num_single_layers": config["arch"]["num_single_layers"],
|
||||
"latent_dim": config["vae"]["latent_dim"],
|
||||
}
|
||||
|
||||
|
||||
def _stats(port: torch.Tensor, ref: torch.Tensor) -> tuple[float, float, float]:
|
||||
"""Return max abs diff, cosine similarity and the reference's RMS."""
|
||||
a = port.flatten().float()
|
||||
b = ref.flatten().float()
|
||||
return (
|
||||
(a - b).abs().max().item(),
|
||||
torch.nn.functional.cosine_similarity(a, b, dim=0).item(),
|
||||
b.pow(2).mean().sqrt().item(),
|
||||
)
|
||||
|
||||
|
||||
def _assert_close(name: str, port: torch.Tensor, ref: torch.Tensor, atol: float, min_cos: float) -> None:
|
||||
max_abs, cos, rms = _stats(port, ref)
|
||||
print(f"[{name}] max|Δ|={max_abs:.4e} cos={cos:.8f} ref_rms={rms:.4f} rel={max_abs / rms:.3e}")
|
||||
assert max_abs < atol, f"{name}: max|Δ|={max_abs:.4e} >= {atol:.1e} (reference rms {rms:.4f})"
|
||||
assert cos > min_cos, f"{name}: cos={cos:.8f} <= {min_cos}"
|
||||
|
||||
|
||||
def _autocast():
|
||||
return torch.autocast("cuda", dtype=AUTOCAST_DTYPE)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def models() -> tuple[torch.nn.Module, AuKTransformer]:
|
||||
"""The reference and the port, same weights, fp32 on the accelerator."""
|
||||
from auk.model.flux2_edit import Flux2Edit
|
||||
from safetensors.torch import load_file
|
||||
|
||||
arch = _arch()
|
||||
device = torch.device(DEVICE)
|
||||
state = dit_state_dict(load_file(str(Path(CKPT_DIR, "auk_base.safetensors")), device="cpu"))
|
||||
|
||||
reference = Flux2Edit(dropout=0.0, attn_backend="torch", attn_mask_enabled=True, **arch)
|
||||
reference.load_state_dict(state, strict=True)
|
||||
reference = reference.to(device).eval().requires_grad_(False)
|
||||
|
||||
port = AuKTransformer(**arch)
|
||||
# strict=True is the point of the port: every released tensor lands.
|
||||
port.load_state_dict(state, strict=True)
|
||||
port = port.to(device).eval().requires_grad_(False)
|
||||
|
||||
del state
|
||||
return reference, port
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def inputs() -> dict[str, torch.Tensor]:
|
||||
"""Fixed random inputs, as a batch of 2 whose second row is padded."""
|
||||
device = torch.device(DEVICE)
|
||||
generator = torch.Generator(device="cpu").manual_seed(SEED)
|
||||
arch = _arch()
|
||||
|
||||
def randn(*shape: int) -> torch.Tensor:
|
||||
return torch.randn(*shape, generator=generator, dtype=torch.float32).to(device)
|
||||
|
||||
def pad_mask(width: int, short: int) -> torch.Tensor:
|
||||
mask = torch.zeros(2, width, dtype=torch.bool, device=device)
|
||||
mask[0, :] = True
|
||||
mask[1, :short] = True
|
||||
return mask
|
||||
|
||||
mask2 = pad_mask(TARGET_FRAMES, SHORT_LENS[0])
|
||||
ref_mask2 = pad_mask(REF_FRAMES, SHORT_LENS[1])
|
||||
c_mask2 = pad_mask(TEXT_TOKENS, SHORT_LENS[2])
|
||||
|
||||
# Padding rows are all-zero in the text encoder's output, so keep the
|
||||
# inputs consistent with the masks.
|
||||
x2 = randn(2, TARGET_FRAMES, arch["latent_dim"]) * mask2.unsqueeze(-1)
|
||||
ref2 = randn(2, REF_FRAMES, arch["latent_dim"]) * ref_mask2.unsqueeze(-1)
|
||||
text2 = randn(2, TEXT_TOKENS, arch["text_hidden_dim"]) * c_mask2.unsqueeze(-1)
|
||||
|
||||
return {
|
||||
"x": x2[:1].contiguous(),
|
||||
"ref": ref2[:1].contiguous(),
|
||||
"text": text2[:1].contiguous(),
|
||||
"c_mask": c_mask2[:1].contiguous(),
|
||||
"x2": x2,
|
||||
"ref2": ref2,
|
||||
"text2": text2,
|
||||
"mask2": mask2,
|
||||
"ref_mask2": ref_mask2,
|
||||
"c_mask2": c_mask2,
|
||||
"time": torch.tensor(TIME, dtype=torch.float32, device=device),
|
||||
}
|
||||
|
||||
|
||||
def test_single_forward(models, inputs) -> None:
|
||||
"""One conditional forward: reference against port."""
|
||||
reference, port = models
|
||||
kwargs = dict(text=inputs["text"], time=inputs["time"], c_mask=inputs["c_mask"], ref=inputs["ref"])
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x"], **kwargs)
|
||||
got = port(inputs["x"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (1, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close("single", got, want, FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def test_cfg_forward(models, inputs) -> None:
|
||||
"""The doubled cfg_infer forward, cond branch first."""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text"],
|
||||
time=inputs["time"],
|
||||
c_mask=inputs["c_mask"],
|
||||
ref=inputs["ref"],
|
||||
cfg_infer=True,
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x"], **kwargs)
|
||||
got = port(inputs["x"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (2, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close("cfg-cond", got[:1], want[:1], FORWARD_ATOL, FORWARD_COS)
|
||||
_assert_close("cfg-uncond", got[1:], want[1:], FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def test_masked_batch_forward(models, inputs) -> None:
|
||||
"""Batch of 2 with padded targets, references and text.
|
||||
|
||||
Only valid target frames are compared. Padded frames are unconstrained:
|
||||
the reference leaves them to whatever masked attention emits there.
|
||||
"""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text2"],
|
||||
time=inputs["time"],
|
||||
mask=inputs["mask2"],
|
||||
c_mask=inputs["c_mask2"],
|
||||
ref=inputs["ref2"],
|
||||
ref_mask=inputs["ref_mask2"],
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
want = reference(x=inputs["x2"], **kwargs)
|
||||
got = port(inputs["x2"], **kwargs)
|
||||
|
||||
assert got.shape == want.shape == (2, TARGET_FRAMES, port.latent_dim)
|
||||
valid = inputs["mask2"].unsqueeze(-1).expand_as(got)
|
||||
_assert_close("masked", got[valid], want[valid], FORWARD_ATOL, FORWARD_COS)
|
||||
|
||||
|
||||
def _set_mask_flag(reference, port, enabled: bool) -> None:
|
||||
"""Flip attn_mask_enabled on both models in place, so no second copy is loaded."""
|
||||
for module in port.modules():
|
||||
if hasattr(module, "attn_mask_enabled"):
|
||||
module.attn_mask_enabled = enabled
|
||||
for blocks in (reference.transformer_blocks, reference.single_transformer_blocks):
|
||||
for block in blocks:
|
||||
block.attn.processor.attn_mask_enabled = enabled
|
||||
|
||||
|
||||
def test_mask_flag_disabled(models, inputs) -> None:
|
||||
"""``attn_mask_enabled=False`` must track the reference with its own flag off.
|
||||
|
||||
With the flag off both models run attention unmasked and still zero the
|
||||
padded outputs, so the padded batch is compared in full.
|
||||
"""
|
||||
reference, port = models
|
||||
kwargs = dict(
|
||||
text=inputs["text2"],
|
||||
time=inputs["time"],
|
||||
mask=inputs["mask2"],
|
||||
c_mask=inputs["c_mask2"],
|
||||
ref=inputs["ref2"],
|
||||
ref_mask=inputs["ref_mask2"],
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
masked = port(inputs["x2"], **kwargs)
|
||||
_set_mask_flag(reference, port, False)
|
||||
try:
|
||||
want = reference(x=inputs["x2"], **kwargs)
|
||||
got = port(inputs["x2"], **kwargs)
|
||||
finally:
|
||||
_set_mask_flag(reference, port, True)
|
||||
|
||||
_assert_close("mask-flag-off", got, want, FORWARD_ATOL, FORWARD_COS)
|
||||
# Guard against a vacuous pass: the flag has to actually change something.
|
||||
assert (got.float() - masked.float()).abs().max().item() > 1e-3
|
||||
|
||||
|
||||
def test_sampler_generator_matches_seed(models, inputs) -> None:
|
||||
"""A generator seeded with 0 must draw exactly what ``seed=0`` draws."""
|
||||
_, port = models
|
||||
common = dict(
|
||||
gen_frames=TARGET_FRAMES,
|
||||
nfe=2,
|
||||
cfg_strength=0.0,
|
||||
sway_sampling_coef=SWAY_COEF,
|
||||
text=inputs["text"],
|
||||
c_mask=inputs["c_mask"],
|
||||
ref=inputs["ref"],
|
||||
ref_mask=None,
|
||||
)
|
||||
with torch.no_grad(), _autocast():
|
||||
by_seed = sample_latents(port, seed=0, **common)
|
||||
by_generator = sample_latents(port, generator=torch.Generator(device=DEVICE).manual_seed(0), **common)
|
||||
|
||||
print(f"[generator] max|Δ|={(by_generator.float() - by_seed.float()).abs().max().item():.4e}")
|
||||
torch.testing.assert_close(by_generator, by_seed, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_cache_needs_clearing(models, inputs) -> None:
|
||||
"""The cached text projection is reused until ``clear_cache()``.
|
||||
|
||||
``sample_latents`` clears on the way out, so a pipeline that reuses the
|
||||
module across requests is safe without doing anything. A caller driving
|
||||
``forward(cache=True)`` itself has to clear when the text changes.
|
||||
"""
|
||||
_, port = models
|
||||
other_text = inputs["text"] * 0.5
|
||||
kwargs = dict(time=inputs["time"], c_mask=inputs["c_mask"], ref=inputs["ref"], cfg_infer=True)
|
||||
|
||||
port.clear_cache()
|
||||
with torch.no_grad(), _autocast():
|
||||
want = port(inputs["x"], inputs["text"], **kwargs)
|
||||
port(inputs["x"], other_text, cache=True, **kwargs)
|
||||
stale = port(inputs["x"], inputs["text"], cache=True, **kwargs)
|
||||
port.clear_cache()
|
||||
fresh = port(inputs["x"], inputs["text"], cache=True, **kwargs)
|
||||
port.clear_cache()
|
||||
|
||||
# The cache is real: the second call reused the first call's projection.
|
||||
assert (stale.float() - want.float()).abs().max().item() > 1e-3
|
||||
torch.testing.assert_close(fresh, want, rtol=0, atol=0)
|
||||
assert port.text_cond is None and port.text_uncond is None
|
||||
|
||||
|
||||
def test_rope_matches_x_transformers() -> None:
|
||||
"""The local rotary implementation against x_transformers, in fp32."""
|
||||
from x_transformers.x_transformers import RotaryEmbedding, apply_rotary_pos_emb
|
||||
|
||||
device = torch.device(DEVICE)
|
||||
dim_head, seq_len = 64, TEXT_TOKENS + REF_FRAMES + TARGET_FRAMES
|
||||
q = torch.randn(2, 24, seq_len, dim_head, dtype=torch.float32, device=device)
|
||||
|
||||
freqs = RotaryEmbedding(dim_head).to(device).forward_from_seq_len(seq_len)[0]
|
||||
want = apply_rotary_pos_emb(q, freqs)
|
||||
got = _apply_rope(q, Rotary(dim_head).to(device)(seq_len))
|
||||
|
||||
max_abs, cos, _ = _stats(got, want)
|
||||
print(f"[rope] max|Δ|={max_abs:.4e} cos={cos:.10f}")
|
||||
assert max_abs < ROPE_ATOL, f"rope: max|Δ|={max_abs:.4e} >= {ROPE_ATOL:.1e}"
|
||||
|
||||
|
||||
def test_rope_survives_half_precision_cast() -> None:
|
||||
"""Casting the module to bf16 must not round the rotary frequencies.
|
||||
|
||||
The reference stores them in a buffer, so ``.to(bfloat16)`` rounds both the
|
||||
frequencies and the integer positions and silently changes every rotation.
|
||||
The port recomputes them instead.
|
||||
"""
|
||||
dim_head, seq_len = 64, TEXT_TOKENS + REF_FRAMES + TARGET_FRAMES
|
||||
exact = Rotary(dim_head)(seq_len)
|
||||
got = Rotary(dim_head).to(torch.bfloat16)(seq_len)
|
||||
|
||||
assert got.dtype == torch.float32
|
||||
torch.testing.assert_close(got, exact, rtol=0, atol=0)
|
||||
|
||||
|
||||
def _reference_euler(reference, *, x, text, c_mask, ref, steps, cfg_strength, sway) -> torch.Tensor:
|
||||
"""Upstream's ODE loop, transcribed.
|
||||
|
||||
``CFMEdit.sample`` hands the backbone to ``torchdiffeq.odeint(method=
|
||||
"euler")``, which on a fixed grid is ``y += (t1 - t0) * f(t0, y)``.
|
||||
Constructing a real ``CFMEdit`` would drag in the Qwen-Omni text encoder,
|
||||
so only the backbone here comes from upstream.
|
||||
"""
|
||||
t = torch.linspace(0, 1, steps + 1, device=x.device, dtype=torch.float32)
|
||||
if sway is not None:
|
||||
t = t + sway * (torch.cos(math.pi / 2 * t) - 1 + t)
|
||||
|
||||
shared = dict(text=text, c_mask=c_mask, ref=ref, mask=None, ref_mask=None, cache=True)
|
||||
for i in range(steps):
|
||||
if cfg_strength < 1e-5:
|
||||
v = reference(x=x, time=t[i], **shared)
|
||||
else:
|
||||
pred = reference(x=x, time=t[i], cfg_infer=True, **shared)
|
||||
v_cond, v_uncond = torch.chunk(pred, 2, dim=0)
|
||||
v = v_cond + (v_cond - v_uncond) * cfg_strength
|
||||
x = x + (t[i + 1] - t[i]) * v
|
||||
reference.clear_cache()
|
||||
return x
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_strength", [0.0, 2.0])
|
||||
def test_euler_sample(models, inputs, cfg_strength: float) -> None:
|
||||
"""An 8-step Euler trajectory, with and without guidance."""
|
||||
reference, port = models
|
||||
shared = dict(text=inputs["text"], c_mask=inputs["c_mask"], ref=inputs["ref"])
|
||||
|
||||
with torch.no_grad(), _autocast():
|
||||
got = sample_latents(
|
||||
port,
|
||||
gen_frames=TARGET_FRAMES,
|
||||
ref_mask=None,
|
||||
nfe=SAMPLE_STEPS,
|
||||
cfg_strength=cfg_strength,
|
||||
sway_sampling_coef=SWAY_COEF,
|
||||
seed=SEED,
|
||||
**shared,
|
||||
)
|
||||
# Re-seeding reproduces the noise sample_latents drew for itself.
|
||||
torch.manual_seed(SEED)
|
||||
noise = torch.randn(
|
||||
TARGET_FRAMES, port.latent_dim, device=inputs["ref"].device, dtype=inputs["ref"].dtype
|
||||
).unsqueeze(0)
|
||||
want = _reference_euler(
|
||||
reference, x=noise, steps=SAMPLE_STEPS, cfg_strength=cfg_strength, sway=SWAY_COEF, **shared
|
||||
)
|
||||
|
||||
assert got.shape == want.shape == (1, TARGET_FRAMES, port.latent_dim)
|
||||
_assert_close(f"sample-cfg{cfg_strength:g}", got, want, SAMPLE_ATOL, SAMPLE_COS)
|
||||
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK VAE parity: the numeric oracle for the vendored BigVGAN-flow codec.
|
||||
|
||||
Checks ``AuKVAE`` against the AuK reference implementation on real speech, with
|
||||
both models loaded from the same released ``vae.safetensors``:
|
||||
|
||||
* encoder statistics (mean / log-std) agree,
|
||||
* ``encode(sample=False)`` reproduces the reference mean path,
|
||||
* ``encode(sample=True)`` reproduces ``encoding_and_normalization`` for a
|
||||
matching RNG seed,
|
||||
* ``decode`` reproduces ``denormalize`` + ``inference_from_latents``, in fp32
|
||||
and (reported, not gated tightly) under bf16 autocast,
|
||||
* the waveform survives a mean-path round trip.
|
||||
|
||||
Runs on GPU in fp32 and needs both the released checkpoint and the reference
|
||||
``auk`` package importable, so it is a local_model test rather than CI. Point
|
||||
``AUK_CKPT_DIR`` at the directory holding ``vae.safetensors`` and ``AUK_REF_WAV``
|
||||
at a speech clip (default ``$AUK_CKPT_DIR/ref.wav``)::
|
||||
|
||||
AUK_CKPT_DIR=... AUK_REF_WAV=... python -m pytest \
|
||||
tests/diffusion/models/auk/test_auk_vae_parity.py -v -s
|
||||
|
||||
Unlike its sibling directories this one carries no ``__init__.py`` on purpose: it would put
|
||||
``tests/diffusion/models`` on ``sys.path`` ahead of site-packages, and this directory's own name
|
||||
would then shadow the reference ``auk`` package the parity check imports.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torchaudio.functional as AF
|
||||
|
||||
from vllm_omni.diffusion.models.auk.auk_vae import AuKVAE
|
||||
|
||||
pytestmark = [pytest.mark.local_model, pytest.mark.diffusion, pytest.mark.gpu, pytest.mark.cuda]
|
||||
|
||||
# The released model.vae.model_init_kwargs, shared by both models under test.
|
||||
VAE_CONFIG = {
|
||||
"upsample_rates": [5, 4, 3, 2, 2, 2],
|
||||
"upsample_kernel_sizes": [10, 8, 6, 4, 4, 4],
|
||||
"upsample_initial_channel": 1536,
|
||||
"resblock_kernel_sizes": [3, 7, 11],
|
||||
"resblock_dilation_sizes": [[1, 3, 5], [1, 3, 5], [1, 3, 5]],
|
||||
"downsample_rates": [2, 2, 2, 3, 4, 5],
|
||||
"downsample_channels": [12, 24, 48, 96, 192, 384, 768],
|
||||
"snake_logscale": True,
|
||||
"latent_dim": 64,
|
||||
"use_vae": True,
|
||||
"causal": True,
|
||||
"flow_hidden_channels": 256,
|
||||
"act_causal": True,
|
||||
}
|
||||
|
||||
SAMPLE_RATE = 24000
|
||||
HOP_SIZE = 480
|
||||
LATENT_DIM = 64
|
||||
SEED = 1234
|
||||
# Keep the clip short: the parity run shares its GPU with other jobs.
|
||||
MAX_SECONDS = 4.0
|
||||
TOLERANCE = 1e-5
|
||||
DEVICE = "cuda"
|
||||
|
||||
_CKPT_DIR = os.environ.get("AUK_CKPT_DIR")
|
||||
_VAE_PATH = Path(_CKPT_DIR) / "vae.safetensors" if _CKPT_DIR else None
|
||||
_REF_WAV = Path(os.environ.get("AUK_REF_WAV") or (Path(_CKPT_DIR) / "ref.wav" if _CKPT_DIR else "unset"))
|
||||
|
||||
|
||||
def _reference_importable() -> bool:
|
||||
"""Resolve ``auk.model``, not ``auk``.
|
||||
|
||||
An ``__init__.py`` in this directory would make its own name resolve as ``auk`` and satisfy a
|
||||
top-level probe, leaving the fixtures to fail at setup instead of skipping. The submodule
|
||||
probe fails in that case, which is the honest answer.
|
||||
"""
|
||||
try:
|
||||
return importlib.util.find_spec("auk.model") is not None
|
||||
except (ImportError, ValueError):
|
||||
return False
|
||||
|
||||
|
||||
def _skip_reason() -> str | None:
|
||||
if not _CKPT_DIR:
|
||||
return "set AUK_CKPT_DIR to the directory holding vae.safetensors"
|
||||
if _VAE_PATH is None or not _VAE_PATH.is_file():
|
||||
return f"no vae.safetensors under {_CKPT_DIR}"
|
||||
if not _REF_WAV.is_file():
|
||||
return f"no reference clip at {_REF_WAV} (set AUK_REF_WAV)"
|
||||
if not _reference_importable():
|
||||
return "cannot import the reference auk.model package (an __init__.py here would shadow it)"
|
||||
if not torch.cuda.is_available():
|
||||
return "parity runs in fp32 on GPU"
|
||||
return None
|
||||
|
||||
|
||||
pytestmark.append(pytest.mark.skipif(_skip_reason() is not None, reason=_skip_reason() or ""))
|
||||
|
||||
|
||||
def _max_abs(lhs: torch.Tensor, rhs: torch.Tensor) -> float:
|
||||
return (lhs.float() - rhs.float()).abs().max().item()
|
||||
|
||||
|
||||
def _snr_db(signal: torch.Tensor, estimate: torch.Tensor) -> float:
|
||||
noise = (signal.float() - estimate.float()).pow(2).sum()
|
||||
return (10.0 * torch.log10(signal.float().pow(2).sum() / noise)).item()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def clip() -> torch.Tensor:
|
||||
"""Mono 24 kHz speech, trimmed to a whole number of latent frames."""
|
||||
audio, sample_rate = sf.read(str(_REF_WAV), dtype="float32", always_2d=True) # (T, C)
|
||||
wav = torch.from_numpy(audio).mean(dim=1, keepdim=True).transpose(0, 1)
|
||||
if sample_rate != SAMPLE_RATE:
|
||||
wav = AF.resample(wav, sample_rate, SAMPLE_RATE)
|
||||
samples = min(int(MAX_SECONDS * SAMPLE_RATE), wav.size(-1)) // HOP_SIZE * HOP_SIZE
|
||||
wav = wav[:, :samples].contiguous().to(DEVICE)
|
||||
print(f"\n[clip] {_REF_WAV.name}: {samples} samples, {samples / SAMPLE_RATE:.2f} s, {samples // HOP_SIZE} frames")
|
||||
return wav
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def reference(clip: torch.Tensor) -> dict[str, torch.Tensor]:
|
||||
"""Reference tensors from the upstream model, which is then released so one model fits at a time."""
|
||||
from auk.model.vae import load_vae_model
|
||||
from auk.model.vae.bigvgan_flow_vae import BigVGANFlowVAEConfig
|
||||
|
||||
model = load_vae_model(
|
||||
vae_name="BigVGANFlowVAE",
|
||||
vae_cfg=BigVGANFlowVAEConfig.from_dict(VAE_CONFIG),
|
||||
vae_ckpt=str(_VAE_PATH),
|
||||
map_location="cpu",
|
||||
)
|
||||
model = model.to(DEVICE).eval()
|
||||
model.requires_grad_(False)
|
||||
|
||||
with torch.no_grad():
|
||||
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
|
||||
scale = torch.sqrt(model.global_log_std.float())
|
||||
mean_latents = (mean.transpose(1, 2).float() - model.global_mean.float()) / scale
|
||||
torch.manual_seed(SEED)
|
||||
sampled_latents, _ = model.encoding_and_normalization(clip.unsqueeze(1))
|
||||
decoded = model.inference_from_latents(model.denormalize(mean_latents).permute(0, 2, 1))
|
||||
|
||||
tensors = {
|
||||
"mean": mean.clone(),
|
||||
"log_std": log_std.clone(),
|
||||
"mean_latents": mean_latents.clone(),
|
||||
"sampled_latents": sampled_latents.clone(),
|
||||
"decoded": decoded.squeeze(1).clone(),
|
||||
}
|
||||
del model
|
||||
torch.accelerator.empty_cache()
|
||||
return tensors
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def port(reference: dict[str, torch.Tensor]) -> tuple[AuKVAE, list[str], list[str]]:
|
||||
"""The vendored module, loaded from the same checkpoint. Depends on ``reference`` for load ordering.
|
||||
|
||||
Placed on the accelerator before loading so weight norm is folded there; see ``load_weights``.
|
||||
"""
|
||||
model = AuKVAE.from_config(VAE_CONFIG).to(DEVICE)
|
||||
missing, unexpected = model.load_weights(_VAE_PATH)
|
||||
model = model.eval()
|
||||
model.requires_grad_(False)
|
||||
return model, missing, unexpected
|
||||
|
||||
|
||||
def test_checkpoint_covers_every_inference_tensor(port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model, missing, unexpected = port
|
||||
print(f"[keys] missing={len(missing)} unexpected={len(unexpected)}")
|
||||
print(f"[keys] unexpected prefixes={sorted({key.split('.')[0] for key in unexpected})}")
|
||||
assert missing == []
|
||||
assert unexpected and all(key.startswith("flow.") for key in unexpected)
|
||||
assert (model.hop_size, model.sample_rate, model.latent_dim) == (HOP_SIZE, SAMPLE_RATE, LATENT_DIM)
|
||||
|
||||
|
||||
def test_encoder_statistics_match(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
mean, log_std = model.audio_encoder(clip.unsqueeze(1)).chunk(2, 1)
|
||||
mean_diff = _max_abs(mean, reference["mean"])
|
||||
log_std_diff = _max_abs(log_std, reference["log_std"])
|
||||
print(f"[encoder] max|dmean|={mean_diff:.3e} max|dlog_std|={log_std_diff:.3e}")
|
||||
assert mean_diff < TOLERANCE
|
||||
assert log_std_diff < TOLERANCE
|
||||
# Folded and hook-computed weights are bit-identical on one device, so the whole encoder is.
|
||||
assert (mean_diff, log_std_diff) == (0.0, 0.0)
|
||||
|
||||
|
||||
def test_encode_mean_path_matches(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
latents = model.encode(clip)
|
||||
assert latents.shape == (1, clip.size(-1) // HOP_SIZE, LATENT_DIM)
|
||||
diff = _max_abs(latents, reference["mean_latents"])
|
||||
print(f"[encode mean] shape={tuple(latents.shape)} max|d|={diff:.3e}")
|
||||
assert diff == 0.0
|
||||
|
||||
|
||||
def test_encode_sampled_matches_seeded_reference(
|
||||
clip: torch.Tensor, reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]
|
||||
) -> None:
|
||||
model = port[0]
|
||||
generator = torch.Generator(device=DEVICE)
|
||||
generator.manual_seed(SEED)
|
||||
with torch.no_grad():
|
||||
latents = model.encode(clip, sample=True, generator=generator)
|
||||
diff = _max_abs(latents, reference["sampled_latents"])
|
||||
spread = _max_abs(latents, reference["mean_latents"])
|
||||
print(f"[encode sample] max|d vs seeded reference|={diff:.3e} max|d vs mean path|={spread:.3e}")
|
||||
assert diff < TOLERANCE
|
||||
assert diff == 0.0 # a fresh device Generator replays the default one after torch.manual_seed
|
||||
assert spread > TOLERANCE # the draw actually moved the latents
|
||||
|
||||
|
||||
def test_decode_matches(reference: dict[str, torch.Tensor], port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model = port[0]
|
||||
latents = reference["mean_latents"]
|
||||
with torch.no_grad():
|
||||
wav = model.decode(latents)
|
||||
assert wav.shape == (1, latents.size(1) * HOP_SIZE)
|
||||
diff = _max_abs(wav, reference["decoded"])
|
||||
print(f"[decode fp32] shape={tuple(wav.shape)} max|d|={diff:.3e}")
|
||||
assert diff < TOLERANCE
|
||||
assert diff == 0.0
|
||||
|
||||
with torch.no_grad(), torch.autocast(device_type="cuda", dtype=torch.bfloat16):
|
||||
wav_bf16 = model.decode(latents)
|
||||
bf16_diff = _max_abs(wav_bf16, reference["decoded"])
|
||||
print(f"[decode bf16 autocast] max|d| vs fp32 reference={bf16_diff:.3e} snr={_snr_db(wav, wav_bf16):.2f} dB")
|
||||
assert torch.isfinite(wav_bf16).all()
|
||||
assert bf16_diff < 0.5 # sanity only: bf16 is reported, not held to fp32 parity
|
||||
|
||||
|
||||
def test_round_trip_snr(clip: torch.Tensor, port: tuple[AuKVAE, list[str], list[str]]) -> None:
|
||||
model = port[0]
|
||||
with torch.no_grad():
|
||||
wav = model.decode(model.encode(clip))
|
||||
assert wav.shape == clip.shape
|
||||
snr = _snr_db(clip, wav)
|
||||
print(f"[round trip] snr={snr:.2f} dB peak={wav.abs().max().item():.4f}")
|
||||
assert snr > 10.0
|
||||
@@ -0,0 +1,514 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""AuK stage-1 pipeline: request parsing on CPU, generation parity on a GPU.
|
||||
|
||||
The CPU tests stub the transformer, the codec and the ODE so they exercise only
|
||||
what the pipeline itself owns: how a request turns into a target length, a
|
||||
sampling schedule and a noise generator.
|
||||
|
||||
The parity test replays the saved upstream reference for the zero-shot TTS case
|
||||
and reports latent, per-frame and waveform distances rather than asserting bit
|
||||
equality, because two differences live in the conditioning rather than in the
|
||||
port: the saved text condition is an fp32 thinker forward while the reference
|
||||
generated under bf16 autocast, and the reference's stochastic VAE draw came from
|
||||
the process-global RNG seeded at 1234.
|
||||
|
||||
It replays with ``vae_sample=False``. That is the deliberate choice: the
|
||||
reference re-seeds the global RNG immediately before drawing the ODE noise, so a
|
||||
per-request generator reproduces that noise exactly (verified bit-identical) as
|
||||
long as nothing else has drawn from it. Sampling the VAE posterior consumes the
|
||||
generator first, which moves the noise and produces a different, equally valid
|
||||
realization; comparing latents then measures the noise, not the port. The
|
||||
posterior mean costs a reference latent that is 0.045 MSE (0.969 cosine) away
|
||||
from the draw the reference used, which is the smaller of the two errors.
|
||||
Generate the reference artifacts once with the upstream package installed
|
||||
(``tools/auk_parity_reference.py --auk-repo ... --ckpt-dir ... --qwen-dir ...
|
||||
--out /path/to/auk-parity``; it writes ``parity_ref/<variant>`` and
|
||||
``fusion_ref``), then run::
|
||||
|
||||
AUK_OMNI_CKPT_DIR=/path/to/auk-omni-base \
|
||||
AUK_PARITY_REF=/path/to/auk-parity/parity_ref/base \
|
||||
python -m pytest -s tests/diffusion/models/auk/test_pipeline_auk.py
|
||||
|
||||
``AUK_OMNI_CKPT_DIR`` is the assembled directory this pipeline loads, which is
|
||||
not the same thing as the released AuK snapshot that the transformer and codec
|
||||
parity tests in this directory read from ``AUK_CKPT_DIR``. ``AUK_CKPT_DIR`` is
|
||||
accepted as a fallback, and the test skips rather than fails when the directory
|
||||
it resolves to is not an assembled one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
import soundfile as sf
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import torchaudio
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.auk import pipeline_auk
|
||||
from vllm_omni.diffusion.models.auk.pipeline_auk import AuKPipeline
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
LATENT_DIM = 64
|
||||
HOP = 480
|
||||
SAMPLE_RATE = 24000
|
||||
TEXT_HIDDEN_DIM = 2048
|
||||
|
||||
# The distilled student's grid, exact values from the released checkpoint.
|
||||
FLASH_T_GRID = [0.0, 0.07612049579620361, 0.2928932309150696, 0.6173166036605835, 1.0]
|
||||
|
||||
CKPT_DIR = os.environ.get("AUK_OMNI_CKPT_DIR") or os.environ.get("AUK_CKPT_DIR")
|
||||
PARITY_REF = os.environ.get("AUK_PARITY_REF")
|
||||
# An assembled directory is the only thing this pipeline can load.
|
||||
IS_ASSEMBLED = bool(CKPT_DIR) and (Path(CKPT_DIR).expanduser() / "config.json").is_file()
|
||||
|
||||
# Measured on the released base checkpoint: mean frame cosine 0.961 to 0.968,
|
||||
# relative latent MSE 0.051 to 0.063, log-mel distance 0.34. That spread is
|
||||
# bf16 against fp32 DiT weights plus where the codec folds its weight norm,
|
||||
# neither of which is worth gating on. A port that has lost the conditioning
|
||||
# sits at 0.110 cosine, 1.82 relative MSE and 2.88 log-mel, so these gates
|
||||
# still separate the two by an order of magnitude.
|
||||
PARITY_MIN_FRAME_COSINE = 0.93
|
||||
PARITY_MAX_RELATIVE_MSE = 0.15
|
||||
PARITY_MAX_MEL_DISTANCE = 0.6
|
||||
|
||||
|
||||
class _StubTransformer(nn.Module):
|
||||
"""Records its construction arguments; owns one weight so strict loading runs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dim: int,
|
||||
heads: int,
|
||||
dim_head: int,
|
||||
ff_mult: float,
|
||||
latent_dim: int,
|
||||
text_hidden_dim: int,
|
||||
num_layers: int,
|
||||
num_single_layers: int,
|
||||
attn_mask_enabled: bool = True,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.init_kwargs = {
|
||||
"dim": dim,
|
||||
"heads": heads,
|
||||
"dim_head": dim_head,
|
||||
"ff_mult": ff_mult,
|
||||
"latent_dim": latent_dim,
|
||||
"text_hidden_dim": text_hidden_dim,
|
||||
"num_layers": num_layers,
|
||||
"num_single_layers": num_single_layers,
|
||||
"attn_mask_enabled": attn_mask_enabled,
|
||||
}
|
||||
self.latent_dim = latent_dim
|
||||
self.proj = nn.Linear(latent_dim, latent_dim, bias=False)
|
||||
|
||||
|
||||
class _StubVAE(nn.Module):
|
||||
"""Deterministic stand-in for the codec, one latent frame per hop."""
|
||||
|
||||
hop_size = HOP
|
||||
sample_rate = SAMPLE_RATE
|
||||
latent_dim = LATENT_DIM
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.encode_calls: list[dict[str, Any]] = []
|
||||
self.decode_calls = 0
|
||||
self.weights_path: str | None = None
|
||||
self.config: dict[str, Any] = {}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, cfg: dict[str, Any]) -> _StubVAE:
|
||||
vae = cls()
|
||||
vae.config = dict(cfg)
|
||||
return vae
|
||||
|
||||
def load_weights(self, path: str, **_: Any) -> tuple[list[str], list[str]]:
|
||||
self.weights_path = str(path)
|
||||
return [], []
|
||||
|
||||
def encode(
|
||||
self,
|
||||
wav: torch.Tensor,
|
||||
*,
|
||||
sample: bool = False,
|
||||
generator: torch.Generator | None = None,
|
||||
) -> torch.Tensor:
|
||||
frames = wav.shape[-1] // HOP
|
||||
self.encode_calls.append({"samples": int(wav.shape[-1]), "sample": sample, "generator": generator})
|
||||
if sample:
|
||||
return torch.randn(1, frames, LATENT_DIM, generator=generator, device=wav.device)
|
||||
return torch.zeros(1, frames, LATENT_DIM, device=wav.device)
|
||||
|
||||
def decode(self, latents: torch.Tensor) -> torch.Tensor:
|
||||
self.decode_calls += 1
|
||||
return torch.zeros(1, latents.shape[1] * HOP, device=latents.device)
|
||||
|
||||
|
||||
def _stub_sampler(calls: list[dict[str, Any]]):
|
||||
"""Record the ODE arguments and return noise drawn from the request generator."""
|
||||
|
||||
def sample_latents(dit: nn.Module, **kwargs: Any) -> torch.Tensor:
|
||||
calls.append(kwargs)
|
||||
shape = (1, kwargs["gen_frames"], kwargs["latent_dim"])
|
||||
generator = kwargs.get("generator")
|
||||
if generator is None:
|
||||
return torch.zeros(*shape, device=kwargs["device"], dtype=kwargs["dtype"])
|
||||
return torch.randn(*shape, generator=generator, device=kwargs["device"], dtype=kwargs["dtype"])
|
||||
|
||||
return sample_latents
|
||||
|
||||
|
||||
def _write_checkpoint(root: Path, variant: str) -> Path:
|
||||
"""Write a tiny assembled checkpoint the stubs can load."""
|
||||
|
||||
from safetensors.torch import save_file
|
||||
|
||||
model_dir = root / f"auk-{variant}"
|
||||
model_dir.mkdir(parents=True, exist_ok=True)
|
||||
config = {
|
||||
"model_type": "auk",
|
||||
"architectures": ["AuKForConditionalGeneration"],
|
||||
"dit": {
|
||||
"dim": 16,
|
||||
"heads": 2,
|
||||
"dim_head": 8,
|
||||
"ff_mult": 2,
|
||||
"text_hidden_dim": TEXT_HIDDEN_DIM,
|
||||
"num_layers": 1,
|
||||
"num_single_layers": 1,
|
||||
"attn_mask_enabled": True,
|
||||
},
|
||||
"vae": {
|
||||
"latent_dim": LATENT_DIM,
|
||||
"downsample_rate": HOP,
|
||||
"target_sample_rate": SAMPLE_RATE,
|
||||
"model_init_kwargs": {"latent_dim": LATENT_DIM},
|
||||
},
|
||||
"variant": variant,
|
||||
"flash_t_grid": FLASH_T_GRID,
|
||||
"defaults": {"nfe": 32, "cfg": 2.0, "sway": -1.0},
|
||||
}
|
||||
(model_dir / "config.json").write_text(json.dumps(config))
|
||||
save_file(
|
||||
{
|
||||
"transformer.proj.weight": torch.zeros(LATENT_DIM, LATENT_DIM),
|
||||
"layer_weights": torch.zeros(36),
|
||||
"layer_scale": torch.ones(1),
|
||||
},
|
||||
str(model_dir / "auk.safetensors"),
|
||||
)
|
||||
save_file({"global_mean": torch.zeros(LATENT_DIM)}, str(model_dir / "vae.safetensors"))
|
||||
return model_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def build_pipeline(tmp_path, monkeypatch):
|
||||
"""Build an AuKPipeline whose transformer, codec and ODE are stubs."""
|
||||
|
||||
monkeypatch.setattr(pipeline_auk, "get_local_device", lambda: torch.device("cpu"))
|
||||
monkeypatch.setattr(pipeline_auk, "AuKTransformer", _StubTransformer)
|
||||
monkeypatch.setattr(pipeline_auk, "AuKVAE", _StubVAE)
|
||||
|
||||
def _build(variant: str = "base") -> tuple[AuKPipeline, list[dict[str, Any]]]:
|
||||
calls: list[dict[str, Any]] = []
|
||||
monkeypatch.setattr(pipeline_auk, "sample_latents", _stub_sampler(calls))
|
||||
od_config = OmniDiffusionConfig(
|
||||
model=str(_write_checkpoint(tmp_path, variant)),
|
||||
dtype=torch.float32,
|
||||
model_class_name="AuKPipeline",
|
||||
)
|
||||
return AuKPipeline(od_config=od_config), calls
|
||||
|
||||
return _build
|
||||
|
||||
|
||||
def _prompt(
|
||||
*,
|
||||
tokens: int = 8,
|
||||
audio: Any = None,
|
||||
knobs: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the diffusion prompt the stage input processor would emit."""
|
||||
|
||||
prompt: dict[str, Any] = {
|
||||
"prompt": "",
|
||||
"prompt_embeds": torch.zeros(tokens, TEXT_HIDDEN_DIM),
|
||||
"additional_information": {"auk": dict(knobs or {})},
|
||||
}
|
||||
if audio is not None:
|
||||
prompt["multi_modal_data"] = {"audio": audio}
|
||||
return prompt
|
||||
|
||||
|
||||
def _batch(prompt: dict[str, Any], **sampling: Any) -> DiffusionRequestBatch:
|
||||
request = OmniDiffusionRequest(
|
||||
prompt=prompt,
|
||||
sampling_params=OmniDiffusionSamplingParams(**sampling),
|
||||
request_id="auk-test-0",
|
||||
)
|
||||
return DiffusionRequestBatch(requests=[request])
|
||||
|
||||
|
||||
def _silence(seconds: float, sample_rate: int = SAMPLE_RATE) -> tuple[np.ndarray, int]:
|
||||
return np.zeros(int(seconds * sample_rate), dtype=np.float32), sample_rate
|
||||
|
||||
|
||||
@pytest.mark.core_model
|
||||
@pytest.mark.cpu
|
||||
class TestRequestParsing:
|
||||
def test_pipeline_declares_audio_output(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
assert pipeline.support_audio_output is True
|
||||
assert pipeline.audio_sample_rate == SAMPLE_RATE
|
||||
assert pipeline.supports_request_batch is False
|
||||
# The warmup request cannot carry an encoder-stage text condition.
|
||||
assert pipeline.dummy_run_num_frames == 0
|
||||
|
||||
def test_config_reaches_the_transformer(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
assert pipeline.dit.init_kwargs["latent_dim"] == LATENT_DIM
|
||||
assert pipeline.dit.init_kwargs["text_hidden_dim"] == TEXT_HIDDEN_DIM
|
||||
assert pipeline.dit.init_kwargs["attn_mask_enabled"] is True
|
||||
|
||||
def test_gen_seconds_sets_the_target_length(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(_batch(_prompt(audio=_silence(2.0), knobs={"gen_seconds": 6.0}), seed=1))
|
||||
|
||||
assert calls[0]["gen_frames"] == math.ceil(6.0 * SAMPLE_RATE / HOP)
|
||||
assert calls[0]["ref"].shape == (1, 100, LATENT_DIM)
|
||||
assert calls[0]["ref_mask"].shape == (1, 100)
|
||||
assert calls[0]["c_mask"].shape == (1, 8)
|
||||
assert outputs[0].output.shape == (300 * HOP,)
|
||||
assert outputs[0].output.dtype is torch.float32
|
||||
|
||||
def test_target_length_falls_back_to_the_source_clip(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(2.0), knobs={"gen_seconds": None}), seed=1))
|
||||
|
||||
assert calls[0]["gen_frames"] == 100
|
||||
|
||||
def test_text_only_request_without_a_duration_is_rejected(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
with pytest.raises(ValueError, match="gen_seconds"):
|
||||
pipeline.forward(_batch(_prompt(knobs={"gen_seconds": None}), seed=1))
|
||||
assert calls == []
|
||||
|
||||
def test_text_only_request_gets_an_empty_reference(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(knobs={"gen_seconds": 3.5}), seed=1))
|
||||
|
||||
assert calls[0]["ref"].shape == (1, 0, LATENT_DIM)
|
||||
assert calls[0]["gen_frames"] == 175
|
||||
|
||||
def test_source_clip_is_resampled_to_the_codec_rate(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0, 16000), knobs={"gen_seconds": 1.0}), seed=1))
|
||||
|
||||
assert pipeline.vae.encode_calls[0]["samples"] == SAMPLE_RATE
|
||||
|
||||
def test_stereo_source_is_mixed_to_mono(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
stereo = np.zeros((2, SAMPLE_RATE), dtype=np.float32)
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=(stereo, SAMPLE_RATE), knobs={"gen_seconds": 1.0}), seed=1))
|
||||
|
||||
assert pipeline.vae.encode_calls[0]["samples"] == SAMPLE_RATE
|
||||
|
||||
def test_missing_text_condition_is_rejected(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
prompt = _prompt(knobs={"gen_seconds": 1.0})
|
||||
del prompt["prompt_embeds"]
|
||||
|
||||
with pytest.raises(ValueError, match="prompt_embeds"):
|
||||
pipeline.forward(_batch(prompt, seed=1))
|
||||
|
||||
def test_base_variant_uses_the_requested_schedule(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(
|
||||
_batch(
|
||||
_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": -1.0}),
|
||||
seed=1,
|
||||
num_inference_steps=16,
|
||||
guidance_scale=3.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert calls[0]["nfe"] == 16
|
||||
assert calls[0]["cfg_strength"] == 3.0
|
||||
assert calls[0]["sway_sampling_coef"] == -1.0
|
||||
assert calls[0]["t_grid"] is None
|
||||
|
||||
def test_schedule_defaults_come_from_the_checkpoint(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": None}), seed=1))
|
||||
|
||||
assert calls[0]["nfe"] == 32
|
||||
assert calls[0]["cfg_strength"] == 2.0
|
||||
assert calls[0]["sway_sampling_coef"] == -1.0
|
||||
|
||||
def test_flash_variant_locks_the_distilled_recipe(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline("flash")
|
||||
|
||||
pipeline.forward(
|
||||
_batch(
|
||||
_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "sway": -1.0}),
|
||||
seed=1,
|
||||
num_inference_steps=32,
|
||||
guidance_scale=2.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert calls[0]["nfe"] == 4
|
||||
assert calls[0]["cfg_strength"] == 0.0
|
||||
assert calls[0]["sway_sampling_coef"] is None
|
||||
assert calls[0]["t_grid"] == FLASH_T_GRID
|
||||
|
||||
def test_noise_comes_from_a_seeded_request_generator(self, build_pipeline):
|
||||
pipeline, calls = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(
|
||||
_batch(_prompt(knobs={"gen_seconds": 1.0}), seed=0, output_type="latent"),
|
||||
)
|
||||
|
||||
expected = torch.randn(1, 50, LATENT_DIM, generator=torch.Generator().manual_seed(0))
|
||||
assert torch.equal(outputs[0].output, expected)
|
||||
# The global RNG is never seeded on the pipeline's behalf.
|
||||
assert calls[0]["seed"] is None
|
||||
assert isinstance(calls[0]["generator"], torch.Generator)
|
||||
|
||||
def test_latent_output_type_skips_the_decoder(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
outputs = pipeline.forward(_batch(_prompt(knobs={"gen_seconds": 1.0}), seed=1, output_type="latent"))
|
||||
|
||||
assert outputs[0].output.shape == (1, 50, LATENT_DIM)
|
||||
assert pipeline.vae.decode_calls == 0
|
||||
|
||||
def test_vae_sample_draws_from_the_request_generator(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
|
||||
pipeline.forward(_batch(_prompt(audio=_silence(1.0), knobs={"gen_seconds": 1.0, "vae_sample": True}), seed=1))
|
||||
|
||||
call = pipeline.vae.encode_calls[0]
|
||||
assert call["sample"] is True
|
||||
assert isinstance(call["generator"], torch.Generator)
|
||||
|
||||
def test_one_request_per_forward(self, build_pipeline):
|
||||
pipeline, _ = build_pipeline()
|
||||
batch = _batch(_prompt(knobs={"gen_seconds": 1.0}), seed=1)
|
||||
batch.requests.append(batch.requests[0])
|
||||
|
||||
with pytest.raises(AssertionError, match="one request per forward"):
|
||||
pipeline.forward(batch)
|
||||
|
||||
|
||||
def _reference_audio_path(messages: list[dict[str, Any]]) -> str:
|
||||
for message in messages:
|
||||
for item in message.get("content") or ():
|
||||
if isinstance(item, dict) and item.get("type") == "audio":
|
||||
path = item.get("audio") or item.get("audio_url")
|
||||
if path:
|
||||
return str(path)
|
||||
raise AssertionError("the saved reference case carries no source clip")
|
||||
|
||||
|
||||
def _log_mel_distance(left: torch.Tensor, right: torch.Tensor, sample_rate: int) -> float:
|
||||
"""Mean absolute log-mel difference between two mono waveforms."""
|
||||
|
||||
mel = torchaudio.transforms.MelSpectrogram(sample_rate=sample_rate, n_fft=1024, hop_length=256, n_mels=80)
|
||||
length = min(left.shape[-1], right.shape[-1])
|
||||
spectra = [torch.log(mel(wav[..., :length].float()) + 1e-5) for wav in (left, right)]
|
||||
return float((spectra[0] - spectra[1]).abs().mean())
|
||||
|
||||
|
||||
@pytest.mark.local_model
|
||||
@pytest.mark.diffusion
|
||||
@pytest.mark.skipif(
|
||||
not (IS_ASSEMBLED and PARITY_REF),
|
||||
reason="needs AUK_PARITY_REF and AUK_OMNI_CKPT_DIR pointing at an assembled checkpoint directory",
|
||||
)
|
||||
def test_zero_shot_tts_tracks_the_upstream_reference():
|
||||
"""Replay the saved zero-shot TTS case and report the distances."""
|
||||
|
||||
parity_dir = Path(PARITY_REF)
|
||||
fusion_dir = Path(os.environ.get("AUK_FUSION_REF") or parity_dir.parent.parent / "fusion_ref")
|
||||
reference = torch.load(parity_dir / "zs_tts_en.pt", map_location="cpu", weights_only=False)
|
||||
fusion = torch.load(fusion_dir / "zs_tts_audio.pt", map_location="cpu", weights_only=False)
|
||||
|
||||
# soundfile rather than torchaudio.load: the repo's test convention, and
|
||||
# torchaudio's decoder needs an ffmpeg the port environment does not have.
|
||||
samples, source_rate = sf.read(_reference_audio_path(reference["messages"]), dtype="float32", always_2d=True)
|
||||
source = torch.from_numpy(samples).transpose(0, 1)
|
||||
knobs = {"gen_seconds": reference["gen_seconds"], "sway": -1.0, "t_grid": None, "vae_sample": False}
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
# The encoder stage emits bf16; the saved fusion is an fp32 forward.
|
||||
"prompt_embeds": fusion["fused"].to(torch.bfloat16),
|
||||
"multi_modal_data": {"audio": (source, int(source_rate))},
|
||||
"additional_information": {"auk": knobs},
|
||||
}
|
||||
od_config = OmniDiffusionConfig(model=CKPT_DIR, dtype=torch.bfloat16, model_class_name="AuKPipeline")
|
||||
pipeline = AuKPipeline(od_config=od_config)
|
||||
|
||||
def run(output_type: str) -> torch.Tensor:
|
||||
batch = _batch(
|
||||
dict(prompt),
|
||||
seed=reference["seed"],
|
||||
num_inference_steps=32,
|
||||
guidance_scale=2.0,
|
||||
output_type=output_type,
|
||||
)
|
||||
return pipeline.forward(batch)[0].output
|
||||
|
||||
# Two deterministic runs: the ODE is seeded, so the latents belong to the
|
||||
# waveform even though the decode happens in the second call.
|
||||
latents = run("latent")
|
||||
waveform = run("pt")
|
||||
|
||||
ref_frames = int(reference["ref_latent_lens"][0])
|
||||
expected = reference["generated"][:, ref_frames:, :].float()
|
||||
assert latents.shape == expected.shape
|
||||
assert torch.isfinite(latents).all()
|
||||
assert torch.isfinite(waveform).all()
|
||||
assert waveform.shape[-1] == expected.shape[1] * HOP
|
||||
|
||||
cosine = F.cosine_similarity(latents[0], expected[0], dim=-1)
|
||||
metrics = {
|
||||
"gen_frames": int(expected.shape[1]),
|
||||
"ref_frames": ref_frames,
|
||||
"latent_mse": float(torch.mean((latents - expected) ** 2)),
|
||||
"latent_relative_mse": float(torch.mean((latents - expected) ** 2) / expected.var()),
|
||||
"frame_cosine_mean": float(cosine.mean()),
|
||||
"frame_cosine_min": float(cosine.min()),
|
||||
"log_mel_distance": _log_mel_distance(waveform, reference["audio"].reshape(-1), int(reference["sr"])),
|
||||
"rms_port": float(waveform.pow(2).mean().sqrt()),
|
||||
"rms_reference": float(reference["audio"].reshape(-1).pow(2).mean().sqrt()),
|
||||
}
|
||||
print("\nAuK zero-shot TTS parity:", json.dumps(metrics, indent=1))
|
||||
|
||||
assert metrics["frame_cosine_mean"] > PARITY_MIN_FRAME_COSINE
|
||||
assert metrics["latent_relative_mse"] < PARITY_MAX_RELATIVE_MSE
|
||||
assert metrics["log_mel_distance"] < PARITY_MAX_MEL_DISTANCE
|
||||
@@ -122,8 +122,10 @@ class TestStage1DiTLoRA:
|
||||
lambda root, name, sub: fake_replace_submodule(root, name, sub, replace_calls),
|
||||
)
|
||||
|
||||
# Build pipeline with bagel component
|
||||
# Build pipeline with bagel component declared via _lora_components,
|
||||
# matching how BagelPipeline opts in.
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline._lora_components = ["bagel"]
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.stacked_params_mapping = [
|
||||
@@ -194,8 +196,11 @@ class TestBagelLoRARoundTrip:
|
||||
|
||||
monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", DummyBaseLayerWithLoRA)
|
||||
|
||||
# Build pipeline with bagel.language_model.foo (simple non-packed layer)
|
||||
# Build pipeline with bagel.language_model.foo (simple non-packed layer);
|
||||
# the bagel component is declared via _lora_components, matching how
|
||||
# BagelPipeline opts in.
|
||||
pipeline = torch.nn.Module()
|
||||
pipeline._lora_components = ["bagel"]
|
||||
pipeline.bagel = torch.nn.Module()
|
||||
lm = torch.nn.Module()
|
||||
lm.foo = _FakeLinearBase()
|
||||
|
||||
@@ -26,7 +26,9 @@ NORM_EPS = 1e-5
|
||||
def _init_distributed(request):
|
||||
"""Initialize the minimal single-rank distributed environment required by
|
||||
the vLLM parallel linear layers (tensor-parallel group must exist)."""
|
||||
if request.node.name.startswith("test_real_rotary_"):
|
||||
if request.node.name.startswith(
|
||||
("test_real_rotary_", "test_packed_rope_table", "test_fused_qk_norm_rope", "test_fallback_with_no")
|
||||
):
|
||||
yield
|
||||
return
|
||||
|
||||
@@ -438,29 +440,86 @@ def test_transformer_rejects_prompt_tuning():
|
||||
BooguImageTransformer2DModel(od_config=_tiny_od_config(prompt_tuning_configs={"use_prompt_tuning": True}))
|
||||
|
||||
|
||||
def _native_to_checkpoint_name(name: str) -> str:
|
||||
"""Inverse of ``load_weights`` remapping: native param name -> diffusers name.
|
||||
_QKV_FANOUT = {
|
||||
"to_qkv": ("to_q", "to_k", "to_v"),
|
||||
"img_to_qkv": ("img_to_q", "img_to_k", "img_to_v"),
|
||||
"instruct_to_qkv": ("instruct_to_q", "instruct_to_k", "instruct_to_v"),
|
||||
}
|
||||
|
||||
|
||||
def _native_to_checkpoint_weights(name: str, param: torch.Tensor) -> list[tuple[str, torch.Tensor]]:
|
||||
"""Inverse of ``load_weights`` remapping: native param -> diffusers weights.
|
||||
|
||||
The diffusers checkpoint stores fused projections as separate matrices, so a
|
||||
single merged native param (QKV or FFN gate/up) fans out into several
|
||||
checkpoint weights. Returns ``(diffusers_name, value)`` pairs whose values,
|
||||
when fed through ``load_weights``, reassemble into the original ``param``.
|
||||
|
||||
- ``.to_out.<suffix>`` -> ``.to_out.0.<suffix>`` (diffusers ModuleList wrap).
|
||||
- promoted joint-attention projections move back under ``.processor.``.
|
||||
"""
|
||||
q_size = NUM_HEADS * HEAD_DIM
|
||||
kv_size = NUM_KV_HEADS * HEAD_DIM
|
||||
|
||||
# Fused QKV projections split back into per-matrix q/k/v weights.
|
||||
for token, (q_name, k_name, v_name) in _QKV_FANOUT.items():
|
||||
marker = f".{token}."
|
||||
if marker in name:
|
||||
q, k, v = param[:q_size], param[q_size : q_size + kv_size], param[q_size + kv_size :]
|
||||
results = []
|
||||
for sub_name, value in ((q_name, q), (k_name, k), (v_name, v)):
|
||||
ckpt_name = name.replace(marker, f".{sub_name}.")
|
||||
if token != "to_qkv":
|
||||
# Joint-attention projections live under `.processor.` upstream.
|
||||
ckpt_name = ckpt_name.replace(".img_instruct_attn.", ".img_instruct_attn.processor.")
|
||||
results.append((ckpt_name, value))
|
||||
return results
|
||||
|
||||
# Fused FFN gate/up splits into linear_1 (gate) / linear_3 (input).
|
||||
if ".gate_up_proj." in name:
|
||||
inner = param.shape[0] // 2
|
||||
return [
|
||||
(name.replace(".gate_up_proj.", ".linear_1."), param[:inner]),
|
||||
(name.replace(".gate_up_proj.", ".linear_3."), param[inner:]),
|
||||
]
|
||||
|
||||
# Default: 1:1 with the existing diffusers name promotions.
|
||||
if ".to_out." in name:
|
||||
name = name.replace(".to_out.", ".to_out.0.")
|
||||
for proj in (
|
||||
"img_to_q",
|
||||
"img_to_k",
|
||||
"img_to_v",
|
||||
"instruct_to_q",
|
||||
"instruct_to_k",
|
||||
"instruct_to_v",
|
||||
"instruct_out",
|
||||
"img_out",
|
||||
):
|
||||
for proj in ("instruct_out", "img_out"):
|
||||
token = f".img_instruct_attn.{proj}."
|
||||
if token in name:
|
||||
name = name.replace(token, f".img_instruct_attn.processor.{proj}.")
|
||||
break
|
||||
return name
|
||||
return [(name, param)]
|
||||
|
||||
|
||||
def test_transformer_exposes_stacked_params_mapping():
|
||||
"""The packed -> sub-layer mapping must be discoverable from the module tree.
|
||||
|
||||
``diffusion/lora/loader.py`` and the quantized weight loaders read
|
||||
``stacked_params_mapping`` off the model, so it has to exist before (and
|
||||
independently of) ``load_weights``.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_BOOGU_STACKED_PARAMS_MAPPING,
|
||||
BooguImageTransformer2DModel,
|
||||
)
|
||||
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
|
||||
assert tuple(model.stacked_params_mapping) == _BOOGU_STACKED_PARAMS_MAPPING
|
||||
# A per-instance copy, so consumers cannot mutate the module constant.
|
||||
assert model.stacked_params_mapping is not _BOOGU_STACKED_PARAMS_MAPPING
|
||||
|
||||
# Every entry is a (param, shard, shard_id) triple.
|
||||
for param_name, shard_name, shard_id in model.stacked_params_mapping:
|
||||
assert param_name.startswith(".") and shard_name.startswith(".")
|
||||
assert shard_id in {"q", "k", "v", 0, 1}
|
||||
|
||||
# The mapping targets exactly the fused projections this port creates.
|
||||
mapped_leaves = {param.strip(".").split(".")[-1] for param, _, _ in model.stacked_params_mapping}
|
||||
assert mapped_leaves == {"to_qkv", "img_to_qkv", "instruct_to_qkv", "gate_up_proj"}
|
||||
|
||||
|
||||
def test_transformer_load_weights_round_trip():
|
||||
@@ -471,24 +530,59 @@ def test_transformer_load_weights_round_trip():
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
|
||||
# Build synthetic diffusers-named weights (one per native parameter).
|
||||
checkpoint_weights = {}
|
||||
# Build synthetic diffusers-named weights. Fused native projections fan out
|
||||
# into the separate matrices the diffusers checkpoint stores.
|
||||
expected: dict[str, torch.Tensor] = {}
|
||||
checkpoint_weights: dict[str, torch.Tensor] = {}
|
||||
for native_name, param in native_params.items():
|
||||
checkpoint_weights[_native_to_checkpoint_name(native_name)] = torch.randn_like(param)
|
||||
full = torch.randn_like(param)
|
||||
expected[native_name] = full
|
||||
for ckpt_name, value in _native_to_checkpoint_weights(native_name, full):
|
||||
checkpoint_weights[ckpt_name] = value
|
||||
|
||||
# The remapping must be a bijection over the parameter set.
|
||||
assert len(checkpoint_weights) == len(native_params)
|
||||
# Every checkpoint name is unique; merged params fan out to >=1 weight.
|
||||
assert len(checkpoint_weights) >= len(native_params)
|
||||
|
||||
loaded = model.load_weights(list(checkpoint_weights.items()))
|
||||
|
||||
# No missing / unexpected parameters.
|
||||
assert loaded == set(native_params.keys())
|
||||
|
||||
# Values landed on the right parameters (TP=1: weight_loader copies verbatim).
|
||||
# Values landed on the right parameters (TP=1: weight_loader copies verbatim;
|
||||
# fused shards are placed at their q/k/v / gate/up offsets).
|
||||
reloaded = dict(model.named_parameters())
|
||||
for native_name in native_params:
|
||||
expected = checkpoint_weights[_native_to_checkpoint_name(native_name)]
|
||||
assert torch.allclose(reloaded[native_name], expected)
|
||||
assert torch.allclose(reloaded[native_name], expected[native_name])
|
||||
|
||||
|
||||
def test_transformer_load_weights_rejects_partial_fused_params():
|
||||
"""A fused parameter must not count as loaded until all its shards arrive.
|
||||
|
||||
The loader compares parameter *names*, so reporting ``to_qkv`` complete
|
||||
after a single ``to_q`` would let a checkpoint carrying only ``to_q`` start
|
||||
up with the ``k``/``v`` slices left uninitialized.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
BooguImageTransformer2DModel,
|
||||
)
|
||||
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
|
||||
complete_name = "noise_refiner.0.attn.to_qkv.weight"
|
||||
partial_name = "noise_refiner.0.feed_forward.gate_up_proj.weight"
|
||||
assert complete_name in native_params
|
||||
assert partial_name in native_params
|
||||
|
||||
weights = list(_native_to_checkpoint_weights(complete_name, native_params[complete_name]))
|
||||
# Only the gate half of the FFN, so its fused parameter stays incomplete.
|
||||
gate_ckpt, gate_value = _native_to_checkpoint_weights(partial_name, native_params[partial_name])[0]
|
||||
weights.append((gate_ckpt, gate_value))
|
||||
|
||||
loaded = model.load_weights(weights)
|
||||
|
||||
assert complete_name in loaded
|
||||
assert partial_name not in loaded
|
||||
|
||||
|
||||
def test_transformer_load_weights_warns_for_unexpected_and_unloaded():
|
||||
@@ -511,12 +605,15 @@ def test_transformer_load_weights_warns_for_unexpected_and_unloaded():
|
||||
try:
|
||||
model = BooguImageTransformer2DModel(od_config=_tiny_od_config())
|
||||
native_params = dict(model.named_parameters())
|
||||
loaded_name = next(iter(native_params))
|
||||
checkpoint_name = _native_to_checkpoint_name(loaded_name)
|
||||
# A non-fused parameter, so one checkpoint entry loads it completely.
|
||||
loaded_name = "x_embedder.weight"
|
||||
assert loaded_name in native_params
|
||||
(checkpoint_name, checkpoint_value), *_ = _native_to_checkpoint_weights(loaded_name, native_params[loaded_name])
|
||||
checkpoint_value = torch.randn_like(checkpoint_value)
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
(checkpoint_name, torch.randn_like(native_params[loaded_name])),
|
||||
(checkpoint_name, checkpoint_value),
|
||||
("unexpected.weight", torch.ones(1)),
|
||||
]
|
||||
)
|
||||
@@ -600,3 +697,152 @@ def test_transformer_forward_ti2i_shape():
|
||||
|
||||
assert out.shape == (batch_size, model.out_channels, latent_h, latent_w)
|
||||
assert torch.isfinite(out).all()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The three tests below are for the fused qk-norm+RoPE kernel's wiring into
|
||||
# this transformer: the packed-table layout conversion, the fused path against
|
||||
# the previous eager chain, and the fallback when no packed table is present.
|
||||
# They need CUDA (the module-level marks are CPU; these add the cuda mark and
|
||||
# skip on CPU-only runners) and real Boogu geometry rather than the mock dims
|
||||
# above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_FUSED_HEAD_DIM = 120
|
||||
_FUSED_Q_HEADS = 28
|
||||
_FUSED_KV_HEADS = 7
|
||||
_FUSED_EPS = 1e-5
|
||||
|
||||
|
||||
def _fused_rotary_pair(tokens: int):
|
||||
theta = torch.randn(1, tokens, _FUSED_HEAD_DIM // 2, device="cuda", dtype=torch.float32)
|
||||
return (
|
||||
torch.cos(theta).repeat_interleave(2, dim=-1),
|
||||
torch.sin(theta).repeat_interleave(2, dim=-1),
|
||||
)
|
||||
|
||||
|
||||
def _fused_norms():
|
||||
from vllm.config import VllmConfig, set_current_vllm_config
|
||||
from vllm.model_executor.layers.layernorm import RMSNorm
|
||||
|
||||
with set_current_vllm_config(VllmConfig()):
|
||||
norm_q = RMSNorm(_FUSED_HEAD_DIM, eps=_FUSED_EPS).cuda().to(torch.bfloat16)
|
||||
norm_k = RMSNorm(_FUSED_HEAD_DIM, eps=_FUSED_EPS).cuda().to(torch.bfloat16)
|
||||
with torch.no_grad():
|
||||
norm_q.weight.copy_(torch.randn(_FUSED_HEAD_DIM))
|
||||
norm_k.weight.copy_(torch.randn(_FUSED_HEAD_DIM))
|
||||
return norm_q, norm_k
|
||||
|
||||
|
||||
def _operand_ulp_bound(x, weight, cos, sin):
|
||||
"""One bf16 rounding of a normalized operand, propagated through RoPE."""
|
||||
import torch.nn.functional as F
|
||||
|
||||
n = F.rms_norm(x, (_FUSED_HEAD_DIM,), weight, _FUSED_EPS).float().abs()
|
||||
c = cos[..., ::2].float().abs().unsqueeze(2)
|
||||
s = sin[..., ::2].float().abs().unsqueeze(2)
|
||||
even_mag = n[..., ::2] * c + n[..., 1::2] * s
|
||||
odd_mag = n[..., ::2] * s + n[..., 1::2] * c
|
||||
return 2.0**-6 * torch.stack((even_mag, odd_mag), dim=-1).flatten(-2) + 1e-6
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_packed_rope_table_layout(monkeypatch):
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS", raising=False)
|
||||
torch.manual_seed(0)
|
||||
tokens = 2311 # above _FUSED_MIN_TOKENS
|
||||
cos, sin = _fused_rotary_pair(tokens)
|
||||
out = _with_packed_rope_table((cos, sin))
|
||||
assert len(out) == 3
|
||||
assert out[0] is cos and out[1] is sin
|
||||
packed = out[2]
|
||||
assert packed.shape == (tokens, _FUSED_HEAD_DIM)
|
||||
assert packed.dtype == torch.float32 and packed.is_contiguous()
|
||||
half = _FUSED_HEAD_DIM // 2
|
||||
assert torch.equal(packed[:, :half], cos[0, :, ::2])
|
||||
assert torch.equal(packed[:, half:], sin[0, :, ::2])
|
||||
# Below _FUSED_MIN_TOKENS the tuple passes through unchanged (host-bound
|
||||
# regime: the fused path would cost more than it saves).
|
||||
short = _with_packed_rope_table(_fused_rotary_pair(17))
|
||||
assert len(short) == 2
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_fused_qk_norm_rope_matches_eager_chain():
|
||||
"""The fused path vs the previous eager chain at real Boogu shapes.
|
||||
|
||||
Every element must sit within one bf16 ulp of a normalized operand
|
||||
propagated through the rotation (the two paths legitimately differ by
|
||||
single- vs double-rounding of the norm's weight multiply).
|
||||
"""
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
if not HAS_TRITON:
|
||||
pytest.skip("Triton required")
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_qk_norm_rope,
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
torch.manual_seed(0)
|
||||
norm_q, norm_k = _fused_norms()
|
||||
q = torch.randn(1, 4139, _FUSED_Q_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn(1, 4139, _FUSED_KV_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
cos, sin = _fused_rotary_pair(4139)
|
||||
with_table = _with_packed_rope_table((cos, sin))
|
||||
|
||||
fused = _qk_norm_rope(q, k, norm_q, norm_k, with_table, torch.bfloat16)
|
||||
eager = _qk_norm_rope(q, k, norm_q, norm_k, (cos, sin), torch.bfloat16)
|
||||
for fused_t, eager_t, x, w in zip(fused, eager, (q, k), (norm_q.weight, norm_k.weight)):
|
||||
bound = _operand_ulp_bound(x, w, cos, sin)
|
||||
diff = (fused_t.float() - eager_t.float()).abs()
|
||||
assert (diff <= bound).all(), "fused path beyond one operand ulp of the eager chain"
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_fallback_with_no_packed_table():
|
||||
"""Without a packed table the helper is bit-exact to the old chain."""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_qk_norm_rope,
|
||||
apply_rotary_emb,
|
||||
)
|
||||
|
||||
torch.manual_seed(0)
|
||||
norm_q, norm_k = _fused_norms()
|
||||
q = torch.randn(1, 64, _FUSED_Q_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
k = torch.randn(1, 64, _FUSED_KV_HEADS, _FUSED_HEAD_DIM, device="cuda", dtype=torch.bfloat16)
|
||||
cos, sin = _fused_rotary_pair(64)
|
||||
|
||||
out_q, out_k = _qk_norm_rope(q, k, norm_q, norm_k, (cos, sin), torch.bfloat16)
|
||||
ref_q = apply_rotary_emb(norm_q(q), (cos, sin)).to(torch.bfloat16)
|
||||
ref_k = apply_rotary_emb(norm_k(k), (cos, sin)).to(torch.bfloat16)
|
||||
assert torch.equal(out_q, ref_q)
|
||||
assert torch.equal(out_k, ref_k)
|
||||
|
||||
|
||||
def test_packed_rope_table_env_override(monkeypatch):
|
||||
"""VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS steers the packing gate: 0 packs
|
||||
even a 43-token table, a huge value leaves a 4139-token table untouched,
|
||||
unset keeps the 2048 default routing."""
|
||||
from vllm_omni.diffusion.models.boogu_image.boogu_image_transformer import (
|
||||
_with_packed_rope_table,
|
||||
)
|
||||
|
||||
env = "VLLM_OMNI_FUSED_QK_NORM_ROPE_MIN_TOKENS"
|
||||
short, long = _identity_rotary_emb(1, 43), _identity_rotary_emb(1, 4139)
|
||||
|
||||
monkeypatch.setenv(env, "0")
|
||||
assert len(_with_packed_rope_table(short)) == 3
|
||||
monkeypatch.setenv(env, str(10**9))
|
||||
assert _with_packed_rope_table(long) is long
|
||||
monkeypatch.delenv(env)
|
||||
assert len(_with_packed_rope_table(long)) == 3
|
||||
assert _with_packed_rope_table(short) is short
|
||||
@@ -13,11 +13,13 @@ Two groups:
|
||||
CFG handling, and reshape logic can be verified numerically on CPU.
|
||||
"""
|
||||
|
||||
import os
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import Qwen3VLConfig
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig, TransformerConfig
|
||||
|
||||
@@ -56,9 +58,17 @@ def mock_dependencies(mocker, monkeypatch):
|
||||
f"{_MODULE}.FlowMatchEulerDiscreteScheduler.from_pretrained",
|
||||
lambda *a, **k: mock_scheduler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mllm_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLForConditionalGeneration.from_pretrained",
|
||||
lambda *a, **k: mllm_wrapper,
|
||||
return_value=mllm_wrapper,
|
||||
)
|
||||
mllm_config_loader = mocker.patch(
|
||||
f"{_MODULE}.Qwen3VLConfig.from_pretrained",
|
||||
return_value=Qwen3VLConfig(),
|
||||
)
|
||||
mllm_builder = mocker.patch(
|
||||
f"{_MODULE}.create_transformers_model_with_vllm_linears",
|
||||
return_value=nn.Linear(1, 1),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
f"{_MODULE}.Qwen3VLProcessor.from_pretrained",
|
||||
@@ -74,12 +84,17 @@ def mock_dependencies(mocker, monkeypatch):
|
||||
mock_transformer_cls.return_value = mock_transformer_instance
|
||||
monkeypatch.setattr(f"{_MODULE}.BooguImageTransformer2DModel", mock_transformer_cls)
|
||||
|
||||
# Treat the dummy model id as a local path: skips hub prefetch.
|
||||
mocker.patch("os.path.exists", return_value=True)
|
||||
# Treat only the dummy model id as local. Other filesystem checks (for
|
||||
# example lazy imports in the quantization registry) must remain real.
|
||||
path_exists = os.path.exists
|
||||
mocker.patch("os.path.exists", side_effect=lambda path: str(path).startswith("dummy-boogu") or path_exists(path))
|
||||
|
||||
return {
|
||||
"inner_encoder": inner_encoder,
|
||||
"mllm_wrapper": mllm_wrapper,
|
||||
"mllm_loader": mllm_loader,
|
||||
"mllm_config_loader": mllm_config_loader,
|
||||
"mllm_builder": mllm_builder,
|
||||
"processor": mock_processor,
|
||||
"vae": mock_vae,
|
||||
"scheduler": mock_scheduler,
|
||||
@@ -128,6 +143,9 @@ def test_constructor_wires_components(boogu_pipeline, mock_dependencies):
|
||||
assert boogu_pipeline.vae_scale_factor == 8
|
||||
assert boogu_pipeline.default_sample_size == 128
|
||||
assert hasattr(boogu_pipeline, "load_weights")
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
|
||||
|
||||
def test_constructor_strips_mllm_lm_head(boogu_pipeline, mock_dependencies):
|
||||
@@ -188,7 +206,7 @@ def test_constructor_weights_sources(boogu_pipeline):
|
||||
assert source.fall_back_to_pt is True
|
||||
|
||||
|
||||
def test_constructor_routes_only_transformer_config(mock_dependencies, mocker):
|
||||
def test_constructor_rejects_unsupported_mllm_quantization(mock_dependencies, mocker):
|
||||
from vllm.model_executor.layers.quantization.base_config import QuantizationConfig
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
@@ -204,10 +222,143 @@ def test_constructor_routes_only_transformer_config(mock_dependencies, mocker):
|
||||
{"transformer": transformer_config, "mllm": encoder_config, "vae": None}
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Boogu MLLM only supports FP8 quantization"):
|
||||
BooguImagePipeline(od_config=od_config)
|
||||
mock_dependencies["mllm_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("quantization_config", "quantize_mllm"),
|
||||
[
|
||||
pytest.param("fp8", True, id="global-fp8"),
|
||||
pytest.param({"mllm": {"method": "fp8"}, "transformer": {"method": "fp8"}}, True, id="mllm-and-dit-fp8"),
|
||||
pytest.param({"mllm": None, "transformer": "fp8"}, False, id="dit-only-fp8"),
|
||||
],
|
||||
)
|
||||
def test_constructor_routes_mllm_quantization(mock_dependencies, quantization_config, quantize_mllm):
|
||||
from transformers import AutoModel
|
||||
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
|
||||
from vllm.model_executor.layers.quantization.online.base import OnlineQuantizationConfig
|
||||
from vllm.model_executor.layers.quantization.utils.quant_utils import kFp8Static128BlockSym
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu",
|
||||
revision="test-revision",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=quantization_config,
|
||||
)
|
||||
pipeline = BooguImagePipeline(od_config=od_config)
|
||||
|
||||
if quantize_mllm:
|
||||
mock_dependencies["mllm_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_config_loader"].assert_called_once_with(
|
||||
"dummy-boogu", subfolder="mllm", local_files_only=True, revision="test-revision"
|
||||
)
|
||||
builder = mock_dependencies["mllm_builder"]
|
||||
builder.assert_called_once()
|
||||
auto_cls, hf_config, online_quant_config = builder.call_args.args
|
||||
assert auto_cls is AutoModel
|
||||
assert hf_config is mock_dependencies["mllm_config_loader"].return_value
|
||||
assert isinstance(online_quant_config, OnlineQuantizationConfig)
|
||||
assert online_quant_config.args.linear.weight == kFp8Static128BlockSym
|
||||
assert builder.call_args.kwargs == {
|
||||
"dtype": torch.bfloat16,
|
||||
"device": pipeline._execution_device,
|
||||
"prefix": "mllm",
|
||||
"skip_modules": ("mllm.visual",),
|
||||
}
|
||||
assert pipeline.mllm is builder.return_value
|
||||
assert not pipeline.mllm.training
|
||||
assert all(not parameter.requires_grad for parameter in pipeline.mllm.parameters())
|
||||
transformer_source, mllm_source = pipeline.weights_sources
|
||||
assert transformer_source.subfolder == "transformer"
|
||||
assert mllm_source.model_or_path == "dummy-boogu"
|
||||
assert mllm_source.subfolder == "mllm"
|
||||
assert mllm_source.prefix == "mllm."
|
||||
assert mllm_source.revision == "test-revision"
|
||||
else:
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
assert pipeline.mllm is mock_dependencies["inner_encoder"]
|
||||
mock_dependencies["mllm_config_loader"].assert_not_called()
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
assert [source.subfolder for source in pipeline.weights_sources] == ["transformer"]
|
||||
assert isinstance(mock_dependencies["transformer_cls"].call_args.kwargs["quant_config"], Fp8Config)
|
||||
|
||||
|
||||
def test_constructor_preserves_mllm_ignored_layers(mock_dependencies):
|
||||
from vllm.model_executor.layers.quantization.fp8 import Fp8Config
|
||||
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
quant_config = Fp8Config(
|
||||
ignored_layers=["mllm.language_model.layers.0.self_attn.q_proj", "transformer.blocks.0.attn.to_q"]
|
||||
)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config=quant_config,
|
||||
)
|
||||
BooguImagePipeline(od_config=od_config)
|
||||
kwargs = mock_dependencies["transformer_cls"].call_args.kwargs
|
||||
assert kwargs["quant_config"] is transformer_config
|
||||
assert kwargs["prefix"] == "transformer"
|
||||
|
||||
online_quant_config = mock_dependencies["mllm_builder"].call_args.args[2]
|
||||
assert online_quant_config.ignored_layers == quant_config.ignored_layers
|
||||
|
||||
|
||||
def test_constructor_preserves_serialized_mllm_quantization(mock_dependencies):
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
mock_dependencies["mllm_config_loader"].return_value = Qwen3VLConfig(
|
||||
quantization_config={"quant_method": "fp8", "modules_to_not_convert": ["model.visual"]}
|
||||
)
|
||||
od_config = OmniDiffusionConfig(
|
||||
model="dummy-boogu-fp8",
|
||||
tf_model_config=TransformerConfig(params={}),
|
||||
dtype=torch.bfloat16,
|
||||
quantization_config="fp8",
|
||||
)
|
||||
pipeline = BooguImagePipeline(od_config=od_config)
|
||||
|
||||
# No override: HF reads the serialized checkpoint's scales and skip list.
|
||||
assert "quantization_config" not in mock_dependencies["mllm_loader"].call_args.kwargs
|
||||
assert pipeline.mllm is mock_dependencies["inner_encoder"]
|
||||
mock_dependencies["mllm_builder"].assert_not_called()
|
||||
assert [source.subfolder for source in pipeline.weights_sources] == ["transformer"]
|
||||
|
||||
|
||||
def test_load_weights_maps_mllm_and_preserves_transformer():
|
||||
from vllm_omni.diffusion.models.boogu_image.pipeline_boogu_image import BooguImagePipeline
|
||||
|
||||
pipeline = object.__new__(BooguImagePipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.mllm = nn.ModuleDict(
|
||||
{
|
||||
"language_model": nn.Linear(2, 2, bias=False),
|
||||
"visual": nn.Linear(2, 2, bias=False),
|
||||
}
|
||||
)
|
||||
pipeline.transformer = nn.Linear(2, 2, bias=False)
|
||||
language_weight = torch.full((2, 2), 1.0)
|
||||
visual_weight = torch.full((2, 2), 2.0)
|
||||
transformer_weight = torch.full((2, 2), 3.0)
|
||||
|
||||
loaded = pipeline.load_weights(
|
||||
[
|
||||
("mllm.lm_head.weight", torch.full((2, 2), 9.0)),
|
||||
("mllm.model.language_model.weight", language_weight),
|
||||
("mllm.model.visual.weight", visual_weight),
|
||||
("transformer.weight", transformer_weight),
|
||||
]
|
||||
)
|
||||
|
||||
assert loaded == {"mllm.language_model.weight", "mllm.visual.weight", "transformer.weight"}
|
||||
torch.testing.assert_close(pipeline.mllm["language_model"].weight, language_weight)
|
||||
torch.testing.assert_close(pipeline.mllm["visual"].weight, visual_weight)
|
||||
torch.testing.assert_close(pipeline.transformer.weight, transformer_weight)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import find_spec
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
_FRAMEWORK_AVAILABLE = find_spec("cosmos_framework") is not None
|
||||
requires_cosmos_framework = pytest.mark.skipif(
|
||||
not _FRAMEWORK_AVAILABLE,
|
||||
reason="cosmos_framework source is not present on PYTHONPATH",
|
||||
)
|
||||
|
||||
|
||||
def test_framework_preflight_checks_every_policy_symbol(monkeypatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import utils as cosmos3_utils
|
||||
|
||||
imported = []
|
||||
|
||||
def record_import(module_name: str, symbol_name: str, error_message: str):
|
||||
imported.append((module_name, symbol_name, error_message))
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(cosmos3_utils, "lazy_import", record_import)
|
||||
|
||||
cosmos3_utils.preflight_cosmos3_action_framework_imports()
|
||||
|
||||
expected_symbols = {
|
||||
"ActionTransformPipeline",
|
||||
"FlowUniPCMultistepScheduler",
|
||||
"build_abs_pose_from_components",
|
||||
"convert_rotation",
|
||||
"get_domain_id",
|
||||
"pose_abs_to_rel",
|
||||
"pose_rel_to_abs",
|
||||
}
|
||||
assert {symbol_name for _, symbol_name, _ in imported} == expected_symbols
|
||||
assert [(module_name, symbol_name) for module_name, symbol_name, _ in imported] == list(
|
||||
cosmos3_utils._ROBOLAB_FRAMEWORK_IMPORTS.values()
|
||||
)
|
||||
|
||||
|
||||
def test_lazy_import_reports_framework_symbol_skew(monkeypatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import utils as cosmos3_utils
|
||||
|
||||
monkeypatch.setattr(cosmos3_utils, "import_module", lambda _module_name: object())
|
||||
|
||||
with pytest.raises(ImportError, match="MissingSymbol") as exc_info:
|
||||
cosmos3_utils.lazy_import(
|
||||
"cosmos_framework.fake_module",
|
||||
"MissingSymbol",
|
||||
"Cosmos Framework compatibility error.",
|
||||
)
|
||||
|
||||
assert isinstance(exc_info.value.__cause__, AttributeError)
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_action_domain_table_matches_current_framework_and_legacy_aliases() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.domain_utils import (
|
||||
EMBODIMENT_TO_DOMAIN_ID as FRAMEWORK_DOMAIN_IDS,
|
||||
)
|
||||
|
||||
from vllm_omni.diffusion.models.cosmos3.action import EMBODIMENT_TO_DOMAIN_ID as VLLM_DOMAIN_IDS
|
||||
from vllm_omni.diffusion.models.cosmos3.utils import _LEGACY_EMBODIMENT_ALIASES
|
||||
|
||||
canonical_vllm_domain_ids = {
|
||||
name: domain_id for name, domain_id in VLLM_DOMAIN_IDS.items() if name not in _LEGACY_EMBODIMENT_ALIASES
|
||||
}
|
||||
|
||||
assert canonical_vllm_domain_ids == FRAMEWORK_DOMAIN_IDS
|
||||
assert {alias: VLLM_DOMAIN_IDS[alias] for alias in _LEGACY_EMBODIMENT_ALIASES} == {
|
||||
alias: FRAMEWORK_DOMAIN_IDS[canonical_name] for alias, canonical_name in _LEGACY_EMBODIMENT_ALIASES.items()
|
||||
}
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_current_cosmos_framework_policy_modules_import_and_instantiate() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.domain_utils import get_domain_id
|
||||
from cosmos_framework.data.generator.action.utils.pose_utils import (
|
||||
build_abs_pose_from_components,
|
||||
convert_rotation,
|
||||
pose_abs_to_rel,
|
||||
pose_rel_to_abs,
|
||||
)
|
||||
from cosmos_framework.data.generator.action.utils.transforms import ActionTransformPipeline
|
||||
from cosmos_framework.model.generator.diffusion.samplers.fm_solvers_unipc import (
|
||||
FlowUniPCMultistepScheduler,
|
||||
)
|
||||
|
||||
transform = ActionTransformPipeline(
|
||||
max_action_dim=64,
|
||||
cfg_dropout_rate=0.0,
|
||||
format_prompt_as_json=True,
|
||||
)
|
||||
scheduler = FlowUniPCMultistepScheduler(
|
||||
num_train_timesteps=1000,
|
||||
shift=1.0,
|
||||
use_dynamic_shifting=False,
|
||||
)
|
||||
scheduler.set_timesteps(4, device=torch.device("cpu"), shift=5.0)
|
||||
|
||||
assert transform.prompt_json_formatter is not None
|
||||
assert len(scheduler.timesteps) == 4
|
||||
assert get_domain_id("droid_lerobot") == 8
|
||||
assert convert_rotation([0.0, 0.0, 0.0, 1.0], "quat_xyzw", "rot6d").shape == (6,)
|
||||
assert callable(pose_abs_to_rel)
|
||||
assert callable(pose_rel_to_abs)
|
||||
assert callable(build_abs_pose_from_components)
|
||||
|
||||
|
||||
@requires_cosmos_framework
|
||||
def test_real_action_transform_pipeline_matches_droid_policy_contract() -> None:
|
||||
from cosmos_framework.data.generator.action.utils.transforms import ActionTransformPipeline
|
||||
|
||||
transform = ActionTransformPipeline(
|
||||
max_action_dim=64,
|
||||
cfg_dropout_rate=0.0,
|
||||
format_prompt_as_json=True,
|
||||
)
|
||||
sample = {
|
||||
"ai_caption": "Pick up the cube.",
|
||||
"video": torch.zeros((3, 3, 192, 320), dtype=torch.uint8),
|
||||
"action": torch.zeros((3, 8), dtype=torch.float32),
|
||||
"conditioning_fps": torch.tensor(15, dtype=torch.long),
|
||||
"mode": "wam",
|
||||
"domain_id": torch.tensor(8, dtype=torch.long),
|
||||
"viewpoint": "concat_view",
|
||||
"additional_view_description": "Wrist view above two exterior views.",
|
||||
}
|
||||
|
||||
transformed = transform(sample, "256")
|
||||
|
||||
assert isinstance(transformed["ai_caption"], dict)
|
||||
assert transformed["action"].shape == (3, 64)
|
||||
assert int(transformed["raw_action_dim"].item()) == 8
|
||||
assert transformed["sequence_plan"].has_action is True
|
||||
assert transformed["mode"] == "wam"
|
||||
assert int(transformed["domain_id"].item()) == 8
|
||||
@@ -1,13 +1,16 @@
|
||||
# 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
|
||||
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -21,6 +24,20 @@ from vllm_omni.experimental.world_models.session_state import SessionStateManage
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("alias", "canonical_name"),
|
||||
[
|
||||
("galbot", "embodiment_b"),
|
||||
("agibot_gear_gripper", "embodiment_c_gripper"),
|
||||
("agibot_gear_gripper_ext", "embodiment_c_gripper_ext"),
|
||||
],
|
||||
)
|
||||
def test_action_domain_table_preserves_legacy_aliases(alias: str, canonical_name: str) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.action import resolve_domain_id
|
||||
|
||||
assert resolve_domain_id(domain_name=alias) == resolve_domain_id(domain_name=canonical_name)
|
||||
|
||||
|
||||
def test_pipeline_declares_layerwise_offload_components() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline
|
||||
|
||||
@@ -69,6 +86,7 @@ class StubScheduler:
|
||||
flow_shift: float = 1.0,
|
||||
) -> None:
|
||||
self.timesteps = torch.tensor(timesteps or [9, 3], dtype=torch.int64)
|
||||
self.sigmas = self.timesteps.float() / 10
|
||||
self.config = SimpleNamespace(
|
||||
num_train_timesteps=1000,
|
||||
flow_shift=flow_shift,
|
||||
@@ -97,6 +115,7 @@ class StubScheduler:
|
||||
else:
|
||||
assert num_inference_steps is not None
|
||||
self.timesteps = torch.arange(num_inference_steps, 0, -1, dtype=torch.int64, device=device)
|
||||
self.sigmas = self.timesteps.float()
|
||||
|
||||
def step(self, noise_pred: torch.Tensor, timestep: torch.Tensor, latents: torch.Tensor, **kwargs):
|
||||
del kwargs
|
||||
@@ -234,7 +253,7 @@ def passthrough_progress_bar(iterable):
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_cosmos3_guardrails(monkeypatch: pytest.MonkeyPatch):
|
||||
module = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module: Any = types.ModuleType("vllm_omni.diffusion.models.cosmos3.guardrails")
|
||||
module.is_guardrails_enabled = lambda od_config, sampling_params=None: False
|
||||
module.ensure_initialized = lambda od_config: None
|
||||
module.check_text_safety = lambda text: None
|
||||
@@ -267,6 +286,8 @@ def make_cosmos3_pipeline():
|
||||
pipeline.is_edge_model = False
|
||||
pipeline._guidance_scale = None
|
||||
pipeline._num_timesteps = None
|
||||
pipeline._current_step_index = None
|
||||
pipeline._current_sigma = None
|
||||
pipeline._cosmos3_branch_caches = None
|
||||
pipeline._cache_dit_requires_paired_cfg = False
|
||||
pipeline._sound_tokenizer = None
|
||||
@@ -284,7 +305,7 @@ def sequential_cfg_parallel(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
|
||||
def make_sampling_params(**overrides: Any) -> SimpleNamespace:
|
||||
values = {
|
||||
values: dict[str, Any] = {
|
||||
"height": None,
|
||||
"width": None,
|
||||
"num_frames": None,
|
||||
@@ -361,23 +382,31 @@ def _capture_tokenize_calls(pipeline: Any) -> list[dict[str, Any]]:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("provided", "value", "default", "is_distilled", "expected"),
|
||||
("provided", "value", "default", "is_distilled", "expected", "expected_warning"),
|
||||
[
|
||||
(False, 1.0, 7.0, False, 7.0),
|
||||
(True, 1.0, 7.0, False, 1.0),
|
||||
(True, 4.5, 7.0, False, 4.5),
|
||||
(False, 1.0, 7.0, True, 1.0),
|
||||
(True, 4.5, 7.0, True, 1.0),
|
||||
(False, 1.0, 7.0, False, 7.0, False),
|
||||
(True, 1.0, 7.0, False, 1.0, False),
|
||||
(True, 4.5, 7.0, False, 4.5, False),
|
||||
(False, 1.0, 7.0, True, 1.0, False),
|
||||
(True, 1.0, 7.0, True, 1.0, False),
|
||||
(True, 4.5, 7.0, True, 1.0, True),
|
||||
(True, 0.0, 7.0, True, 1.0, True),
|
||||
],
|
||||
)
|
||||
def test_resolve_guidance_scale(
|
||||
make_cosmos3_pipeline,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
provided: bool,
|
||||
value: float,
|
||||
default: float,
|
||||
is_distilled: bool,
|
||||
expected: float,
|
||||
expected_warning: bool,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
|
||||
|
||||
warning_once = Mock()
|
||||
monkeypatch.setattr(pipeline_cosmos3.logger, "warning_once", warning_once)
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.is_distilled_model = is_distilled
|
||||
sp = make_sampling_params(
|
||||
@@ -386,6 +415,12 @@ def test_resolve_guidance_scale(
|
||||
)
|
||||
|
||||
assert pipeline._resolve_guidance_scale(sp, default) == expected
|
||||
if expected_warning:
|
||||
warning_once.assert_called_once()
|
||||
assert "overridden to 1.0" in warning_once.call_args.args[0]
|
||||
assert "negative_prompt does not affect generation" in warning_once.call_args.args[0]
|
||||
else:
|
||||
warning_once.assert_not_called()
|
||||
|
||||
|
||||
def test_distilled_generation_accepts_t2i_and_i2v(make_cosmos3_pipeline) -> None:
|
||||
@@ -479,6 +514,69 @@ def test_forward_threads_request_id_to_robolab(make_cosmos3_pipeline) -> None:
|
||||
assert captured["session_id"] == "robolab-request-7"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("format_prompt_as_json", [False, True])
|
||||
def test_robolab_input_builder_threads_prompt_format_and_uses_wam(
|
||||
make_cosmos3_pipeline,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
format_prompt_as_json: bool,
|
||||
) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import pipeline_cosmos3
|
||||
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.transformer = StubCosmos3Transformer(action_gen=True, action_dim=64)
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_transform(sample, resolution):
|
||||
captured["sample_mode"] = sample["mode"]
|
||||
captured["resolution"] = resolution
|
||||
sample["sequence_plan"] = SimpleNamespace(
|
||||
condition_frame_indexes_action=[0],
|
||||
action_start_frame_offset=1,
|
||||
)
|
||||
sample["raw_action_dim"] = torch.tensor(8)
|
||||
sample["image_size"] = torch.tensor([16, 16, 16, 16])
|
||||
if format_prompt_as_json:
|
||||
sample["ai_caption"] = {"actions": {"instruction": sample["ai_caption"]}}
|
||||
return sample
|
||||
|
||||
def fake_get_transform(*, format_prompt_as_json: bool):
|
||||
captured["format_prompt_as_json"] = format_prompt_as_json
|
||||
return fake_transform
|
||||
|
||||
pipeline._get_robolab_transform = fake_get_transform
|
||||
monkeypatch.setattr(pipeline_cosmos3, "get_robolab_domain_id", lambda name: 8)
|
||||
obs = {
|
||||
"prompt": "Pick up the cube.",
|
||||
"observation/image": np.zeros((16, 16, 3), dtype=np.uint8),
|
||||
"observation/joint_position": np.zeros(7, dtype=np.float32),
|
||||
"observation/gripper_position": np.zeros(1, dtype=np.float32),
|
||||
}
|
||||
sampling_params = make_sampling_params(
|
||||
extra_args={
|
||||
"robot_obs": obs,
|
||||
"action_chunk_size": 2,
|
||||
"image_height": 16,
|
||||
"image_width": 16,
|
||||
"format_prompt_as_json": format_prompt_as_json,
|
||||
}
|
||||
)
|
||||
|
||||
inputs = pipeline._build_robolab_policy_inputs(sampling_params, request_id="request-1")
|
||||
|
||||
assert inputs is not None
|
||||
assert captured == {
|
||||
"sample_mode": "wam",
|
||||
"resolution": "480",
|
||||
"format_prompt_as_json": format_prompt_as_json,
|
||||
}
|
||||
assert inputs.domain_id == 8
|
||||
assert inputs.raw_action_dim == 8
|
||||
if format_prompt_as_json:
|
||||
assert json.loads(inputs.prompt) == {"actions": {"instruction": "Pick up the cube."}}
|
||||
else:
|
||||
assert inputs.prompt == "Pick up the cube."
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("prompt", "sampling_params", "message"),
|
||||
[
|
||||
@@ -657,6 +755,7 @@ def _make_od_config(
|
||||
custom_pipeline_args={},
|
||||
model_config=model_config or {},
|
||||
tf_model_config=tf_model_config,
|
||||
parallel_config=SimpleNamespace(cfg_parallel_size=1, ulysses_degree=1),
|
||||
)
|
||||
|
||||
|
||||
@@ -692,6 +791,7 @@ def test_pipeline_init_uses_flow_unipc_with_cosmos3_defaults(stub_real_pipeline_
|
||||
assert pipeline._engine_init_flow_shift == 2.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cfg_parallel_size,ulysses_degree", [(1, 1), (1, 2), (2, 1), (2, 2)])
|
||||
@pytest.mark.parametrize(
|
||||
("scheduler_class_name", "expected_distilled"),
|
||||
[
|
||||
@@ -705,6 +805,8 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
scheduler_class_name: str,
|
||||
expected_distilled: bool,
|
||||
cfg_parallel_size: int,
|
||||
ulysses_degree: int,
|
||||
) -> None:
|
||||
import json
|
||||
|
||||
@@ -717,7 +819,7 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
t_list = [1.0, 0.75, 0.5, 0.25]
|
||||
scheduler_dir = tmp_path / "scheduler"
|
||||
scheduler_dir.mkdir()
|
||||
scheduler_config = {"_class_name": scheduler_class_name}
|
||||
scheduler_config: dict[str, Any] = {"_class_name": scheduler_class_name}
|
||||
if expected_distilled:
|
||||
scheduler_config["fixed_step_sampler_config"] = {"sample_type": "sde", "t_list": t_list}
|
||||
(scheduler_dir / "scheduler_config.json").write_text(json.dumps(scheduler_config))
|
||||
@@ -753,6 +855,20 @@ def test_pipeline_resolves_scheduler_class_from_checkpoint_file(
|
||||
|
||||
od_config = _make_od_config(sound_gen=False)
|
||||
od_config.model = str(tmp_path)
|
||||
od_config.parallel_config.cfg_parallel_size = cfg_parallel_size
|
||||
od_config.parallel_config.ulysses_degree = ulysses_degree
|
||||
if expected_distilled and cfg_parallel_size > 1:
|
||||
monkeypatch.setattr(
|
||||
pipeline_cosmos3.AutoTokenizer,
|
||||
"from_pretrained",
|
||||
lambda *args, **kwargs: pytest.fail("component loading must not start for distilled CFG parallelism"),
|
||||
)
|
||||
with pytest.raises(ValueError, match="Set --cfg-parallel-size 1 and use --ulysses-degree"):
|
||||
Cosmos3OmniDiffusersPipeline(od_config=od_config)
|
||||
assert StubFlowMatchScheduler.from_config_calls == []
|
||||
assert StubFlowUniPCScheduler.from_config_calls == []
|
||||
return
|
||||
|
||||
pipeline = Cosmos3OmniDiffusersPipeline(od_config=od_config)
|
||||
|
||||
assert pipeline.is_distilled_model is expected_distilled
|
||||
@@ -1420,7 +1536,7 @@ def test_ir_op_priority_hook_preserves_platform_fields(monkeypatch: pytest.Monke
|
||||
fused_add_rms_norm: list[str]
|
||||
custom_op: list[str]
|
||||
|
||||
fake_kernel = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel: Any = types.ModuleType("vllm.config.kernel")
|
||||
fake_kernel.IrOpPriorityConfig = FakeIrOpPriorityConfig
|
||||
monkeypatch.setitem(sys.modules, fake_kernel.__name__, fake_kernel)
|
||||
|
||||
@@ -2043,6 +2159,47 @@ def test_diffuse_covers_cfg_i2v_and_multimodal_steps(make_cosmos3_pipeline) -> N
|
||||
torch.testing.assert_close(action_result, torch.full((), 44.0).expand_as(action_result))
|
||||
|
||||
|
||||
def test_diffuse_publishes_exact_seacache_metadata_and_cfg_contexts(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.91, 0.17])
|
||||
observations: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
observations.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
pipeline.diffuse(
|
||||
latents=torch.zeros(1, 2, 1, 1, 1),
|
||||
timesteps=torch.tensor([900, 100]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0},
|
||||
)
|
||||
|
||||
assert observations == [
|
||||
("cond", 0, pytest.approx(0.91), 2),
|
||||
("uncond", 0, pytest.approx(0.91), 2),
|
||||
("cond", 1, pytest.approx(0.17), 2),
|
||||
("uncond", 1, pytest.approx(0.17), 2),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_drops_session_when_progress_iteration_fails(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2102,6 +2259,53 @@ def test_diffuse_transfer_applies_control_cfg(make_cosmos3_pipeline, sequential_
|
||||
torch.testing.assert_close(result, torch.full_like(latents, 254.0))
|
||||
|
||||
|
||||
def test_diffuse_transfer_uses_named_seacache_contexts(make_cosmos3_pipeline, sequential_cfg_parallel) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline.scheduler.sigmas = torch.tensor([0.42])
|
||||
contexts: list[tuple[str, int | None, float | None, int | None]] = []
|
||||
|
||||
class RecordingHook:
|
||||
@contextmanager
|
||||
def cache_context(self, name: str):
|
||||
sigma = pipeline.current_sigma
|
||||
contexts.append(
|
||||
(
|
||||
name,
|
||||
pipeline.current_step_index,
|
||||
None if sigma is None else float(sigma),
|
||||
pipeline.num_timesteps,
|
||||
)
|
||||
)
|
||||
yield
|
||||
|
||||
pipeline._cache_context_factory = RecordingHook().cache_context
|
||||
latents = torch.zeros(1, 2, 1, 1, 1)
|
||||
velocity_mask = torch.ones(1, 1, 1, 1, 1)
|
||||
pipeline.diffuse_transfer(
|
||||
latents=latents,
|
||||
timesteps=torch.tensor([7]),
|
||||
cond_ids=_ids(2),
|
||||
cond_mask=_mask(),
|
||||
uncond_ids=_ids(1),
|
||||
uncond_mask=_mask(),
|
||||
guidance_scale=3.0,
|
||||
control_guidance=1.5,
|
||||
control_guidance_interval=None,
|
||||
control_latents=[torch.zeros_like(latents)],
|
||||
shared_kwargs={"video_shape": (1, 1, 1), "fps": 24.0, "noisy_frame_mask": velocity_mask},
|
||||
velocity_mask=velocity_mask,
|
||||
condition_latents=torch.zeros_like(latents),
|
||||
)
|
||||
|
||||
assert contexts == [
|
||||
("cond", 0, pytest.approx(0.42), 1),
|
||||
("cond_no_control", 0, pytest.approx(0.42), 1),
|
||||
("uncond", 0, pytest.approx(0.42), 1),
|
||||
]
|
||||
assert pipeline.current_step_index is None
|
||||
assert pipeline.current_sigma is None
|
||||
|
||||
|
||||
def test_diffuse_transfer_rejects_session_state_manager(make_cosmos3_pipeline) -> None:
|
||||
pipeline = make_cosmos3_pipeline()
|
||||
pipeline._use_session_state = True
|
||||
@@ -2472,7 +2676,7 @@ def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -
|
||||
|
||||
class TestForwardRouting:
|
||||
def _install_forward_stubs(self, pipeline):
|
||||
captured: dict[str, object] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
captured: dict[str, Any] = {"diffuse_calls": [], "prepare_calls": []}
|
||||
|
||||
def fake_format(
|
||||
prompt,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -513,6 +513,108 @@ def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> No
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_cache_execution_residual_spans_final_gen_norm(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.cache.teacache.extractors import extract_cosmos3_context
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class TrackingNorm(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return hidden_states + 5.0
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = TrackingNorm()
|
||||
model.norm_moe_gen = norm
|
||||
captured: dict[str, torch.Tensor] = {}
|
||||
|
||||
def run_gen_layers(hidden_gen: torch.Tensor, **kwargs) -> torch.Tensor:
|
||||
del kwargs
|
||||
captured["input"] = hidden_gen.detach().clone()
|
||||
return hidden_gen + 2.0
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", run_gen_layers)
|
||||
forward_kwargs = {
|
||||
"hidden_states": torch.zeros(1, 2, 1, 2, 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, 2, 2),
|
||||
"fps": 24.0,
|
||||
}
|
||||
|
||||
full_output = model(**forward_kwargs)
|
||||
|
||||
assert norm.calls == 1
|
||||
norm.calls = 0
|
||||
|
||||
ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
execution_input = ctx.hidden_states.detach().clone()
|
||||
execution_output = ctx.run_transformer_blocks()[0]
|
||||
residual = execution_output - execution_input
|
||||
extracted_output = ctx.postprocess(execution_output)
|
||||
|
||||
assert norm.calls == 1
|
||||
expected_residual = ((captured["input"] + 2.0) + 5.0) - captured["input"]
|
||||
assert torch.equal(residual, expected_residual)
|
||||
assert torch.equal(extracted_output, full_output)
|
||||
|
||||
norm.calls = 0
|
||||
|
||||
def fail_if_gen_layers_run(*args, **kwargs):
|
||||
del args, kwargs
|
||||
raise AssertionError("SeaCache hit unexpectedly executed GEN layers")
|
||||
|
||||
monkeypatch.setattr(model, "_run_gen_layers", fail_if_gen_layers_run)
|
||||
|
||||
cached_ctx = extract_cosmos3_context(model, **forward_kwargs)
|
||||
cached_output = cached_ctx.postprocess(cached_ctx.hidden_states + residual)
|
||||
|
||||
assert norm.calls == 0
|
||||
assert torch.equal(cached_output, full_output)
|
||||
for name in ("_seacache_skip", "_seacache_record", "_seacache_residual", "_seacache_last_residual"):
|
||||
assert not hasattr(model, name)
|
||||
|
||||
|
||||
def test_no_cache_still_runs_final_gen_norm_once(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3
|
||||
|
||||
class CountingNorm(nn.Module):
|
||||
def __init__(self, original: nn.Module) -> None:
|
||||
super().__init__()
|
||||
self.original = original
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
|
||||
self.calls += 1
|
||||
return self.original(hidden_states)
|
||||
|
||||
monkeypatch.setattr(transformer_cosmos3, "_get_ulysses_state", lambda: (1, 0, None))
|
||||
model = transformer_cosmos3.Cosmos3VFMTransformer(
|
||||
SimpleNamespace(tf_model_config=_tiny_cosmos3_config(), dtype=torch.float32)
|
||||
)
|
||||
norm = CountingNorm(model.norm_moe_gen)
|
||||
model.norm_moe_gen = norm
|
||||
|
||||
output = model(
|
||||
hidden_states=torch.zeros(1, 2, 1, 2, 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, 2, 2),
|
||||
fps=24.0,
|
||||
)
|
||||
|
||||
assert norm.calls == 1
|
||||
assert tuple(output.shape) == (1, 2, 1, 2, 2)
|
||||
|
||||
|
||||
def test_qwen_gen_mlp_remains_gated() -> None:
|
||||
from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GatedMLP, Cosmos3VFMTransformer
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from vllm.triton_utils import HAS_TRITON
|
||||
|
||||
from tests.helpers.mark import hardware_test
|
||||
from vllm_omni.diffusion.models.ernie_image.ernie_image_transformer import _apply_rotary_emb
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_fused_rope_state():
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
fused_rope._FAILED_KEYS.clear()
|
||||
yield
|
||||
fused_rope._FAILED_KEYS.clear()
|
||||
|
||||
|
||||
def _inputs(shape: tuple[int, int, int, int]):
|
||||
batch, sequence, _, head_dim = shape
|
||||
torch.manual_seed(17)
|
||||
query = torch.randn(shape, device="cuda", dtype=torch.bfloat16)
|
||||
key = torch.randn_like(query)
|
||||
freqs_cos = torch.randn(
|
||||
batch,
|
||||
sequence,
|
||||
head_dim // 2,
|
||||
device="cuda",
|
||||
dtype=torch.float32,
|
||||
)
|
||||
freqs_sin = torch.randn_like(freqs_cos)
|
||||
return query, key, freqs_cos, freqs_sin
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize(
|
||||
"shape",
|
||||
[
|
||||
(1, 1, 1, 128),
|
||||
(2, 257, 7, 128),
|
||||
(2, 4608, 32, 128),
|
||||
],
|
||||
)
|
||||
def test_fused_qk_rope_is_bit_exact(shape):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs(shape)
|
||||
with torch.inference_mode():
|
||||
expected_query = _apply_rotary_emb(query, freqs_cos, freqs_sin)
|
||||
expected_key = _apply_rotary_emb(key, freqs_cos, freqs_sin)
|
||||
actual = fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin)
|
||||
|
||||
assert actual is not None
|
||||
actual_query, actual_key = actual
|
||||
assert torch.equal(actual_query, expected_query)
|
||||
assert torch.equal(actual_key, expected_key)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_launch_failure_disables_runtime_key(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
launch_count = 0
|
||||
|
||||
def failing_launch(*args):
|
||||
nonlocal launch_count
|
||||
launch_count += 1
|
||||
raise RuntimeError("injected launch failure")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
failing_launch,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
for _ in range(2):
|
||||
assert fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin) is None
|
||||
assert launch_count == 1
|
||||
assert len(fused_rope._FAILED_KEYS) == 1
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1)
|
||||
def test_failed_runtime_key_cache_is_bounded():
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
for index in range(fused_rope._FAILED_KEYS_MAX_SIZE + 1):
|
||||
fused_rope._record_failed_runtime_key((index,))
|
||||
|
||||
assert len(fused_rope._FAILED_KEYS) == fused_rope._FAILED_KEYS_MAX_SIZE
|
||||
assert (0,) not in fused_rope._FAILED_KEYS
|
||||
assert (fused_rope._FAILED_KEYS_MAX_SIZE,) in fused_rope._FAILED_KEYS
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_compile_path_does_not_launch_fused_kernel(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: True)
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("compile path must remain native")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
with torch.inference_mode():
|
||||
assert fused_rope.try_fused_qk_rotary_emb(query, key, freqs_cos, freqs_sin) is None
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
@pytest.mark.parametrize("requires_grad_input", ["query", "key", "cos", "sin"])
|
||||
def test_gradient_inputs_do_not_launch_fused_kernel(monkeypatch, requires_grad_input):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
values = list(_inputs((1, 17, 3, 128)))
|
||||
input_index = {"query": 0, "key": 1, "cos": 2, "sin": 3}[requires_grad_input]
|
||||
values[input_index].requires_grad_(True)
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("autograd inputs must remain on the native path")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
assert (
|
||||
fused_rope.try_fused_qk_rotary_emb(
|
||||
*values,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=1)
|
||||
@pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="NVIDIA CUDA required")
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_mismatched_key_dtype_or_device_does_not_launch(monkeypatch):
|
||||
from vllm_omni.diffusion.models.ernie_image import fused_rope
|
||||
|
||||
query, key, freqs_cos, freqs_sin = _inputs((1, 17, 3, 128))
|
||||
|
||||
def unexpected_launch(*args):
|
||||
raise AssertionError("mismatched Q/K inputs must remain native")
|
||||
|
||||
monkeypatch.setattr(
|
||||
fused_rope,
|
||||
"_launch_fused_qk_rotary_emb",
|
||||
unexpected_launch,
|
||||
)
|
||||
for unsupported_key in (key.float(), key.cpu()):
|
||||
assert (
|
||||
fused_rope.try_fused_qk_rotary_emb(
|
||||
query,
|
||||
unsupported_key,
|
||||
freqs_cos,
|
||||
freqs_sin,
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -1,8 +1,12 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.ernie_image.ernie_image_transformer import (
|
||||
ErnieImageEmbedND3,
|
||||
_apply_qk_rotary_emb,
|
||||
_apply_rotary_emb,
|
||||
rope,
|
||||
)
|
||||
@@ -185,3 +189,30 @@ class TestErnieImageRopePositionEmbedding:
|
||||
out_diffusers = x * cos_ + rotated * sin_
|
||||
|
||||
assert torch.allclose(out_vllm, out_diffusers, atol=1e-6), "vllm-omni and diffusers outputs should match"
|
||||
|
||||
def test_apply_qk_rotary_emb_uses_native_fallback(self, monkeypatch):
|
||||
"""Unsupported routes preserve both existing eager calls exactly."""
|
||||
from vllm_omni.diffusion.models.ernie_image import (
|
||||
ernie_image_transformer,
|
||||
)
|
||||
|
||||
B, S, H, D = 2, 17, 3, 128
|
||||
query = torch.randn(B, S, H, D)
|
||||
key = torch.randn(B, S, H, D)
|
||||
freqs_cos = torch.randn(B, S, D // 2)
|
||||
freqs_sin = torch.randn(B, S, D // 2)
|
||||
monkeypatch.setattr(
|
||||
ernie_image_transformer,
|
||||
"try_fused_qk_rotary_emb",
|
||||
lambda *args: None,
|
||||
)
|
||||
|
||||
actual_query, actual_key = _apply_qk_rotary_emb(query, key, freqs_cos, freqs_sin)
|
||||
assert torch.equal(
|
||||
actual_query,
|
||||
_apply_rotary_emb(query, freqs_cos, freqs_sin),
|
||||
)
|
||||
assert torch.equal(
|
||||
actual_key,
|
||||
_apply_rotary_emb(key, freqs_cos, freqs_sin),
|
||||
)
|
||||
@@ -1,3 +1,6 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from pytest_mock import MockerFixture
|
||||
@@ -265,7 +268,7 @@ class TestFlux2TransformerLayerwiseOffload:
|
||||
]
|
||||
|
||||
def test_get_blocks_from_dit(self):
|
||||
from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend
|
||||
from vllm_omni.diffusion.offloader.block_discovery import get_blocks_from_dit
|
||||
|
||||
# Block discovery only needs the ModuleList structure, not production dims.
|
||||
model = Flux2Transformer2DModel(
|
||||
@@ -275,6 +278,6 @@ class TestFlux2TransformerLayerwiseOffload:
|
||||
attention_head_dim=4,
|
||||
joint_attention_dim=16,
|
||||
)
|
||||
attr_names, blocks = LayerWiseOffloadBackend.get_blocks_from_dit(model)
|
||||
attr_names, blocks = get_blocks_from_dit(model)
|
||||
assert attr_names == ["transformer_blocks", "single_transformer_blocks"]
|
||||
assert len(blocks) == 4
|
||||
@@ -399,15 +399,14 @@ class TestGlmImagePrepareModule:
|
||||
prepare = GlmImagePrepare(
|
||||
image_projector=projector,
|
||||
rope=rope,
|
||||
patch_size=2,
|
||||
)
|
||||
|
||||
# Test forward pass
|
||||
hidden_states = torch.randn(1, 16, 64, 64) # [B, C, H, W]
|
||||
result = prepare(hidden_states)
|
||||
|
||||
assert len(result) == 5
|
||||
hidden_out, rope_cos, rope_sin, height, width = result
|
||||
assert len(result) == 3
|
||||
hidden_out, rope_cos, rope_sin = result
|
||||
assert hidden_out.shape[0] == 1 # batch size
|
||||
assert hidden_out.shape[1] == 1024 # seq_len (32 * 32)
|
||||
assert hidden_out.shape[2] == 2560 # hidden_dim
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,258 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Two-process USP smoke test for the Helios transformer.
|
||||
|
||||
Pins the multi-rank mechanism that ``test_helios_usp_shard.py`` (unit tests
|
||||
of ``_sp_split_seq`` alone) cannot see:
|
||||
|
||||
* the manual ``_sp_shard_depth`` bump in ``forward()`` must make attn1 run
|
||||
the Ulysses all-to-all (not ``NoParallelAttention``);
|
||||
* attn2 cross-attention must stay rank-local (``skip_sequence_parallel=
|
||||
True``): if replicated text K/V were fed through Ulysses they would be
|
||||
ws-fold duplicated and outputs would diverge from the single-rank run;
|
||||
* end-to-end correctness: ws=2 output equals the ws=1 baseline for the same
|
||||
weights/inputs (divisible shapes, so the padding path is not involved).
|
||||
|
||||
Runs ``HeliosTransformer3DModel.forward`` with tiny random weights via
|
||||
``torch.multiprocessing.spawn``: first a baseline (world_size=1), then an
|
||||
SP run (world_size=2, ulysses_degree=2); rank-0 outputs are compared.
|
||||
|
||||
Requires >= 2 devices; skipped otherwise.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.helpers.mark import hardware_marks
|
||||
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,
|
||||
get_sp_plan_from_model,
|
||||
)
|
||||
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.helios.helios_transformer import HeliosTransformer3DModel
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
_L4_TWO_GPU = hardware_marks(res={"cuda": "L4"}, num_cards=2)
|
||||
pytestmark = [pytest.mark.full_model, pytest.mark.diffusion, *_L4_TWO_GPU]
|
||||
|
||||
RANDOM_SEED = 42
|
||||
DTYPE = torch.float32 # keep both runs on the SDPA fallback for tight tolerance
|
||||
|
||||
# Tiny but architecturally faithful config:
|
||||
# inner_dim = 4 * 32 = 128, rope_dim sums to head_dim (32),
|
||||
# current chunk 2x4x6 latent -> (1,2,2) patch -> 2*2*3 = 12 tokens,
|
||||
# short history 1x4x6 latent -> (1,2,2) patch -> 6 tokens.
|
||||
# All component lengths are even, so ws=2 needs no padding replicas.
|
||||
TINY_MODEL_KWARGS = dict(
|
||||
patch_size=(1, 2, 2),
|
||||
num_attention_heads=4,
|
||||
attention_head_dim=32,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
text_dim=64,
|
||||
freq_dim=32,
|
||||
ffn_dim=128,
|
||||
num_layers=2,
|
||||
rope_dim=(12, 10, 10),
|
||||
rope_theta=10000.0,
|
||||
guidance_cross_attn=True,
|
||||
zero_history_timestep=True,
|
||||
has_multi_term_memory_patch=True,
|
||||
is_amplify_history=False,
|
||||
)
|
||||
|
||||
BATCH = 1
|
||||
CUR_FRAMES, LAT_H, LAT_W = 2, 4, 6 # -> 12 current tokens
|
||||
HIST_FRAMES = 1 # short history -> 6 tokens
|
||||
TEXT_LEN = 5
|
||||
|
||||
|
||||
def _update_env(envs_dict: dict[str, str]) -> None:
|
||||
for k, v in envs_dict.items():
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _make_inputs(device: torch.device) -> dict[str, torch.Tensor]:
|
||||
torch.manual_seed(RANDOM_SEED + 1)
|
||||
return {
|
||||
"hidden_states": torch.randn(BATCH, 4, CUR_FRAMES, LAT_H, LAT_W, device=device, dtype=DTYPE),
|
||||
"timestep": torch.full((BATCH,), 500.0, device=device),
|
||||
"encoder_hidden_states": torch.randn(
|
||||
BATCH, TEXT_LEN, TINY_MODEL_KWARGS["text_dim"], device=device, dtype=DTYPE
|
||||
),
|
||||
"indices_latents_history_short": torch.tensor([[0]], device=device),
|
||||
"latents_history_short": torch.randn(BATCH, 4, HIST_FRAMES, LAT_H, LAT_W, device=device, dtype=DTYPE),
|
||||
}
|
||||
|
||||
|
||||
def _spy_strategies(model: HeliosTransformer3DModel) -> dict[str, list[str]]:
|
||||
"""Record the parallel strategy each Attention resolves to during forward."""
|
||||
seen: dict[str, list[str]] = {"attn1": [], "attn2": []}
|
||||
for name, attn in (("attn1", model.blocks[0].attn1.attn), ("attn2", model.blocks[0].attn2.attn)):
|
||||
original = attn._get_active_parallel_strategy
|
||||
|
||||
def spy(original=original, name=name):
|
||||
strategy = original()
|
||||
seen[name].append(getattr(strategy, "name", type(strategy).__name__))
|
||||
return strategy
|
||||
|
||||
attn._get_active_parallel_strategy = spy
|
||||
return seen
|
||||
|
||||
|
||||
def helios_forward_on_model(
|
||||
local_rank: int,
|
||||
world_size: int,
|
||||
ulysses_degree: int,
|
||||
output_file: str,
|
||||
model_state_file: str,
|
||||
is_baseline: bool,
|
||||
) -> None:
|
||||
device = torch.device(f"{current_omni_platform.device_type}:{local_rank}")
|
||||
current_omni_platform.set_device(device)
|
||||
torch.set_default_device(device)
|
||||
torch.set_default_dtype(DTYPE)
|
||||
|
||||
_update_env(
|
||||
{
|
||||
"RANK": str(local_rank),
|
||||
"LOCAL_RANK": str(local_rank),
|
||||
"WORLD_SIZE": str(world_size),
|
||||
"MASTER_ADDR": "localhost",
|
||||
"MASTER_PORT": "12361",
|
||||
}
|
||||
)
|
||||
init_distributed_environment()
|
||||
|
||||
parallel_config = DiffusionParallelConfig(
|
||||
pipeline_parallel_size=1,
|
||||
data_parallel_size=1,
|
||||
tensor_parallel_size=1,
|
||||
sequence_parallel_size=ulysses_degree,
|
||||
ulysses_degree=ulysses_degree,
|
||||
ring_degree=1,
|
||||
allgather_degree=1,
|
||||
cfg_parallel_size=1,
|
||||
)
|
||||
od_config = OmniDiffusionConfig.from_kwargs(
|
||||
model="test_model",
|
||||
dtype=DTYPE,
|
||||
parallel_config=parallel_config,
|
||||
diffusion_attention_backend="TORCH_SDPA",
|
||||
)
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=1,
|
||||
cfg_parallel_size=1,
|
||||
sequence_parallel_size=ulysses_degree,
|
||||
ulysses_degree=ulysses_degree,
|
||||
ring_degree=1,
|
||||
allgather_degree=1,
|
||||
tensor_parallel_size=1,
|
||||
pipeline_parallel_size=1,
|
||||
)
|
||||
|
||||
with set_forward_context(omni_diffusion_config=od_config), set_current_diffusion_config(od_config):
|
||||
torch.manual_seed(RANDOM_SEED)
|
||||
model = HeliosTransformer3DModel(**TINY_MODEL_KWARGS).to(device).to(DTYPE)
|
||||
|
||||
if is_baseline and local_rank == 0:
|
||||
with open(model_state_file, "wb") as f:
|
||||
torch.save({k: v.cpu() for k, v in model.state_dict().items()}, f)
|
||||
|
||||
if world_size > 1:
|
||||
torch.distributed.barrier()
|
||||
|
||||
with open(model_state_file, "rb") as f:
|
||||
model_state = torch.load(f)
|
||||
model.load_state_dict({k: v.to(device).to(DTYPE) for k, v in model_state.items()})
|
||||
|
||||
if not is_baseline:
|
||||
# Mirror registry._apply_sequence_parallel_if_enabled: install the
|
||||
# proj_out gather hook from the model's own _sp_plan and flip
|
||||
# sp_plan_hooks_applied so sp_active follows _sp_shard_depth
|
||||
# (i.e. the manual bump in Helios forward is what enables Ulysses).
|
||||
plan = get_sp_plan_from_model(model)
|
||||
assert plan is not None, "HeliosTransformer3DModel must define _sp_plan"
|
||||
apply_sequence_parallel(model, SequenceParallelConfig(ulysses_degree=ulysses_degree), plan)
|
||||
get_forward_context().sp_plan_hooks_applied = True
|
||||
|
||||
seen = _spy_strategies(model)
|
||||
inputs = _make_inputs(device)
|
||||
output = model(**inputs).sample
|
||||
|
||||
assert output.shape == (BATCH, 4, CUR_FRAMES, LAT_H, LAT_W), f"unexpected output shape {output.shape}"
|
||||
|
||||
if is_baseline:
|
||||
assert seen["attn1"] and all(n == "none" for n in seen["attn1"]), (
|
||||
f"baseline attn1 must use NoParallelAttention ('none'), saw {seen['attn1']}"
|
||||
)
|
||||
else:
|
||||
# attn1: SP communication must be active inside the block loop
|
||||
# (regression here = the manual _sp_shard_depth bump was lost).
|
||||
assert seen["attn1"] and all(n == "ulysses" for n in seen["attn1"]), (
|
||||
f"attn1 must run Ulysses under USP (manual _sp_shard_depth bump), saw {seen['attn1']}"
|
||||
)
|
||||
# attn2: replicated text K/V must stay rank-local
|
||||
# (regression here = the ws-fold duplicated-keys bug).
|
||||
assert seen["attn2"] and all(n == "none" for n in seen["attn2"]), (
|
||||
f"attn2 must skip sequence parallel (replicated text K/V), saw {seen['attn2']}"
|
||||
)
|
||||
|
||||
if local_rank == 0:
|
||||
with open(output_file, "wb") as f:
|
||||
torch.save(output.detach().cpu().float(), f)
|
||||
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
def test_helios_usp2_matches_single_rank():
|
||||
"""ws=2 (Ulysses) must reproduce the ws=1 output for identical weights/inputs."""
|
||||
available_devices = current_omni_platform.get_device_count()
|
||||
if available_devices < 2:
|
||||
pytest.skip(f"Test requires 2 devices but only {available_devices} available")
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
baseline_output_file = f.name
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
sp_output_file = f.name
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=".pkl") as f:
|
||||
model_state_file = f.name
|
||||
|
||||
try:
|
||||
torch.multiprocessing.spawn(
|
||||
helios_forward_on_model,
|
||||
args=(1, 1, baseline_output_file, model_state_file, True),
|
||||
nprocs=1,
|
||||
)
|
||||
torch.multiprocessing.spawn(
|
||||
helios_forward_on_model,
|
||||
args=(2, 2, sp_output_file, model_state_file, False),
|
||||
nprocs=2,
|
||||
)
|
||||
|
||||
with open(baseline_output_file, "rb") as f:
|
||||
baseline = torch.load(f)
|
||||
with open(sp_output_file, "rb") as f:
|
||||
sp = torch.load(f)
|
||||
|
||||
assert baseline.shape == sp.shape
|
||||
max_abs_diff = (baseline - sp).abs().max().item()
|
||||
assert torch.allclose(baseline, sp, atol=1e-3, rtol=1e-3), (
|
||||
f"USP=2 output diverges from single-rank baseline: max_abs_diff={max_abs_diff:.6e}"
|
||||
)
|
||||
finally:
|
||||
for path in [baseline_output_file, sp_output_file, model_state_file]:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
@@ -0,0 +1,225 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Unit tests for Helios per-component sequence splitting.
|
||||
|
||||
These tests verify the manual sharding logic that replaces the broken
|
||||
rope/blocks.0 hooks in the Helios ``_sp_plan``. They exercise the *real*
|
||||
``HeliosTransformer3DModel._sp_split_seq`` (the parallel-state getters it
|
||||
imports lazily inside the method body are monkeypatched), so the model-side
|
||||
helper cannot drift silently from what is tested. For the multi-rank
|
||||
mechanism around the helper (manual ``_sp_shard_depth`` bump, attn1/attn2
|
||||
parallel strategy, ws=2 vs ws=1 output equivalence) see
|
||||
``test_helios_usp_2rank.py``.
|
||||
|
||||
Coverage:
|
||||
1. USP disabled (ws == 1) returns the tensor unchanged.
|
||||
2. Divisible split gives each rank the expected slice.
|
||||
3. Non-divisible split pads with replicated tokens (no crash).
|
||||
4. Per-component split: history + current tokens are distributed so
|
||||
that *both* ranks receive some current tokens (no mosaic regression).
|
||||
5. Edge cases: 1-D tensor, empty seq, 4-D tensor.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.distributed.parallel_state as sp_parallel_state
|
||||
from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _fake_sp_state(ws: int, rank: int) -> Iterator[None]:
|
||||
"""Patch the parallel-state getters used by the real helper.
|
||||
|
||||
``_sp_split_seq`` imports these names from the module *inside* its
|
||||
function body, so replacing the module attributes takes effect.
|
||||
"""
|
||||
prev_ws = sp_parallel_state.get_sequence_parallel_world_size
|
||||
prev_rank = sp_parallel_state.get_sequence_parallel_rank
|
||||
sp_parallel_state.get_sequence_parallel_world_size = lambda: ws
|
||||
sp_parallel_state.get_sequence_parallel_rank = lambda: rank
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sp_parallel_state.get_sequence_parallel_world_size = prev_ws
|
||||
sp_parallel_state.get_sequence_parallel_rank = prev_rank
|
||||
|
||||
|
||||
def _sp_split_seq(x: torch.Tensor, ws: int, rank: int) -> torch.Tensor:
|
||||
"""Call the real model-side helper without constructing a model."""
|
||||
with _fake_sp_state(ws, rank):
|
||||
return HeliosTransformer3DModel._sp_split_seq(None, x)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSpSplitSeqDisabled:
|
||||
"""When USP is off (ws == 1) the helper must be a no-op."""
|
||||
|
||||
def test_ws1_returns_unchanged(self):
|
||||
x = torch.randn(2, 100, 64)
|
||||
out = _sp_split_seq(x, ws=1, rank=0)
|
||||
assert torch.equal(out, x)
|
||||
assert out is x
|
||||
|
||||
|
||||
class TestSpSplitSeqDivisible:
|
||||
"""Divisible sequences: each rank gets exactly seq_len / ws tokens."""
|
||||
|
||||
def test_each_rank_gets_correct_slice(self):
|
||||
for ws in [2, 4, 8]:
|
||||
seq_len = ws * 10
|
||||
x = torch.arange(seq_len * 4).float().reshape(1, seq_len, 4)
|
||||
for rank in range(ws):
|
||||
out = _sp_split_seq(x, ws=ws, rank=rank)
|
||||
n = seq_len // ws
|
||||
expected = x[:, rank * n : (rank + 1) * n, :]
|
||||
assert out.shape == (1, n, 4)
|
||||
assert torch.equal(out, expected)
|
||||
|
||||
def test_output_is_contiguous(self):
|
||||
x = torch.randn(1, 20, 8)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert out.is_contiguous()
|
||||
|
||||
|
||||
class TestSpSplitSeqNonDivisible:
|
||||
"""Non-divisible sequences are padded with replicated tokens."""
|
||||
|
||||
def test_non_divisible_pads_instead_of_crash(self):
|
||||
"""400x272 → 9*25*17 = 3825 tokens, USP=2 → not divisible.
|
||||
Should pad to 3826 and split, not crash."""
|
||||
x = torch.randn(1, 3825, 8)
|
||||
for rank in range(2):
|
||||
out = _sp_split_seq(x, ws=2, rank=rank)
|
||||
# 3825 → pad 1 → 3826 → /2 = 1913
|
||||
assert out.shape[1] == 1913
|
||||
|
||||
def test_non_divisible_pads_with_replicated_last_token(self):
|
||||
"""Verify the padding is the replicated last token, not zeros."""
|
||||
x = torch.randn(1, 7, 4) # 7 % 2 = 1, pad 1
|
||||
last_token = x[:, -1:, :].clone()
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
# rank 0 gets tokens [0:4] (first 4 of the padded 8)
|
||||
assert out.shape == (1, 4, 4)
|
||||
# rank 1 gets tokens [4:8] — token 7 (the padded one) = last token
|
||||
out1 = _sp_split_seq(x, ws=2, rank=1)
|
||||
assert out1.shape == (1, 4, 4)
|
||||
assert torch.equal(out1[:, -1:, :], last_token)
|
||||
|
||||
def test_non_divisible_7_4(self):
|
||||
"""7 tokens, ws=4 → pad 1 → 8 → /4 = 2 per rank."""
|
||||
x = torch.randn(1, 7, 8)
|
||||
for rank in range(4):
|
||||
out = _sp_split_seq(x, ws=4, rank=rank)
|
||||
assert out.shape[1] == 2
|
||||
|
||||
def test_non_divisible_15_4(self):
|
||||
"""15 tokens, ws=4 → pad 1 → 16 → /4 = 4 per rank."""
|
||||
x = torch.randn(1, 15, 8)
|
||||
for rank in range(4):
|
||||
out = _sp_split_seq(x, ws=4, rank=rank)
|
||||
assert out.shape[1] == 4
|
||||
|
||||
|
||||
class TestSpSplitSeqPerComponent:
|
||||
"""Simulate Helios forward: split current + history components so that
|
||||
*both* ranks receive some current tokens (no mosaic regression)."""
|
||||
|
||||
def test_both_ranks_get_current_tokens(self):
|
||||
"""history=2400, current=540, ws=2.
|
||||
After per-component split, each rank must have > 0 current tokens."""
|
||||
current = torch.randn(1, 540, 64)
|
||||
history = torch.randn(1, 2400, 64)
|
||||
|
||||
for rank in range(2):
|
||||
cur_local = _sp_split_seq(current, ws=2, rank=rank)
|
||||
hist_local = _sp_split_seq(history, ws=2, rank=rank)
|
||||
|
||||
assert cur_local.shape[1] > 0, f"Rank {rank} received 0 current tokens"
|
||||
assert cur_local.shape[1] == 270 # 540 / 2
|
||||
assert hist_local.shape[1] == 1200 # 2400 / 2
|
||||
|
||||
original_context_length = cur_local.shape[1]
|
||||
assert original_context_length > 0 # no mosaic regression
|
||||
|
||||
def test_gather_restores_full_current(self):
|
||||
"""After splitting current into 2 ranks, simulating proj_out
|
||||
gather must restore the full current sequence length so that
|
||||
the original unpatchify reshape succeeds."""
|
||||
current = torch.randn(1, 540, 64)
|
||||
local_chunks = [_sp_split_seq(current, ws=2, rank=rank) for rank in range(2)]
|
||||
gathered = torch.cat(local_chunks, dim=1)
|
||||
assert gathered.shape[1] == 540 # full current restored
|
||||
|
||||
def test_gather_with_padding_truncates_correctly(self):
|
||||
"""Non-divisible current (3825) → pad to 3826 → split →
|
||||
gather → 3826. Slice to 3825 for unpatchify."""
|
||||
current = torch.randn(1, 3825, 64)
|
||||
local_chunks = [_sp_split_seq(current, ws=2, rank=rank) for rank in range(2)]
|
||||
gathered = torch.cat(local_chunks, dim=1)
|
||||
assert gathered.shape[1] == 3826 # padded size
|
||||
# Simulate the slice before unpatchify
|
||||
expected_seq = 3825
|
||||
if gathered.shape[1] > expected_seq:
|
||||
gathered = gathered[:, :expected_seq, :]
|
||||
assert gathered.shape[1] == 3825 # correct after slice
|
||||
|
||||
def test_rotary_emb_aligned_with_hidden_states(self):
|
||||
"""rotary_emb and hidden_states must be split at the same
|
||||
positions so that tokens align inside the attention layer."""
|
||||
hidden = torch.randn(1, 540, 5120)
|
||||
rotary = torch.randn(1, 540, 256)
|
||||
for rank in range(2):
|
||||
h_local = _sp_split_seq(hidden, ws=2, rank=rank)
|
||||
r_local = _sp_split_seq(rotary, ws=2, rank=rank)
|
||||
assert h_local.shape[1] == r_local.shape[1]
|
||||
|
||||
def test_whole_split_causes_mosaic_but_per_component_does_not(self):
|
||||
"""Demonstrates why per-component split is needed:
|
||||
with whole split (simulated), rank 0 gets 0 current tokens;
|
||||
with per-component split, both ranks get current tokens."""
|
||||
history = torch.randn(1, 2400, 64)
|
||||
current = torch.randn(1, 540, 64)
|
||||
full = torch.cat([history, current], dim=1) # 2940
|
||||
|
||||
# Whole split (the OLD broken behavior)
|
||||
whole_n = full.shape[1] // 2 # 1470
|
||||
rank0_whole = full[:, :whole_n, :]
|
||||
rank0_current = rank0_whole.shape[1] - 2400 # negative → 0 current
|
||||
assert rank0_current <= 0, "Whole split should give rank 0 no current"
|
||||
|
||||
# Per-component split (the NEW correct behavior)
|
||||
for rank in range(2):
|
||||
cur_local = _sp_split_seq(current, ws=2, rank=rank)
|
||||
assert cur_local.shape[1] == 270 # both ranks have current
|
||||
|
||||
|
||||
class TestSpSplitSeqEdgeCases:
|
||||
"""Edge cases that should not crash or silently misbehave."""
|
||||
|
||||
def test_1d_tensor_skipped(self):
|
||||
"""1-D tensors must be returned unchanged (dim < 2 guard)."""
|
||||
x = torch.randn(100)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert torch.equal(out, x)
|
||||
|
||||
def test_empty_seq_skipped(self):
|
||||
"""Empty sequence dimension must be returned unchanged."""
|
||||
x = torch.randn(1, 0, 64)
|
||||
out = _sp_split_seq(x, ws=2, rank=0)
|
||||
assert out.shape == x.shape
|
||||
|
||||
def test_4d_tensor_split_on_dim1(self):
|
||||
"""4-D tensor [B, S, H, D] should split along dim=1."""
|
||||
x = torch.randn(1, 20, 8, 32)
|
||||
out = _sp_split_seq(x, ws=2, rank=1)
|
||||
assert out.shape == (1, 10, 8, 32)
|
||||
@@ -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
|
||||
|
||||
@@ -362,6 +362,20 @@ def test_prepared_model_inputs_match_local_tokenization(
|
||||
local_inputs = pipeline.prepare_model_inputs(**common_kwargs)
|
||||
prepared_inputs = pipeline.prepare_model_inputs(**common_kwargs, prepared_layout=prepared_layout)
|
||||
|
||||
if with_reference_image:
|
||||
# Preparation runs without a forward context. Coverage must come from
|
||||
# the scheduler metadata copied onto this request, including every CFG row.
|
||||
reused = pipeline.prepare_model_inputs(
|
||||
**common_kwargs, prepared_layout=prepared_layout, kv_computed_tokens=(5,) * len(prefix_lens)
|
||||
)
|
||||
assert reused["cond_vae_images"] is None and reused["cond_vit_images"] is None
|
||||
partial = pipeline.prepare_model_inputs(
|
||||
**common_kwargs,
|
||||
prepared_layout=prepared_layout,
|
||||
kv_computed_tokens=(5,) * (len(prefix_lens) - 1) + (4,),
|
||||
)
|
||||
assert partial["cond_vae_images"] is not None and partial["cond_vit_images"] is not None
|
||||
|
||||
comparable_fields = (
|
||||
"input_ids",
|
||||
"position_ids",
|
||||
@@ -474,3 +488,33 @@ def test_paged_preprocess_attaches_layout_and_scheduler_kv_requests(monkeypatch)
|
||||
(12, 17, 32)
|
||||
]
|
||||
assert len(tokenizer.calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("transfer_tokens, expected", [(9, [7, 5]), (4, [4, 4])])
|
||||
def test_cfg_rows_preserve_reusable_token_ids_and_native_transfer_boundary(transfer_tokens, expected):
|
||||
from vllm_omni.diffusion.diffusion_kv.kv_connector import prepare_kv_requests
|
||||
|
||||
tokenizer, image_processor = _components([12, 14])
|
||||
request = _request(guidance_scale=5.0)
|
||||
layout = _prepare(request, tokenizer, image_processor)
|
||||
layout.tokenizer_output.think_recaption_end_pos = [[7], [5]]
|
||||
layout.tokenizer_output.uncond_cfg_start_pos = [[None], [5]]
|
||||
rows = build_hunyuan_diffusion_kv_requests(request, layout)
|
||||
assert [row.prompt_token_ids for row in rows] == [
|
||||
layout.tokenizer_output.tokens[0, :7].tolist(),
|
||||
layout.tokenizer_output.tokens[1, :5].tolist(),
|
||||
]
|
||||
prepare_kv_requests(rows, {"num_transfer_tokens": transfer_tokens})
|
||||
assert [row.num_prompt_tokens for row in rows] == expected
|
||||
assert all(row.kv_transfer_params["num_transfer_tokens"] == transfer_tokens for row in rows)
|
||||
layout.tokenizer_output.uncond_cfg_start_pos = [[5]]
|
||||
with pytest.raises(ValueError, match="one boundary per CFG row"):
|
||||
build_hunyuan_diffusion_kv_requests(request, layout)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("computed, expected", [((8, 8), True), ((8, 5), False), ((0, 0), False), ((), False)])
|
||||
def test_native_image_reuse_requires_coverage_in_every_cfg_row(computed, expected):
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.request_layout import native_kv_covers_cond_images
|
||||
|
||||
output = TokenizerEncodeOutput(joint_image_slices=[[slice(2, 8)], [slice(2, 6)]])
|
||||
assert native_kv_covers_cond_images(output, computed) is 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
|
||||
"""Unit tests for HunyuanImage3 AR sampler logic (stage transitions,
|
||||
ratio restriction, comprehension blocking)."""
|
||||
|
||||
@@ -21,6 +21,10 @@ RATIO_END = 210
|
||||
RATIO_OTHER_START = 220
|
||||
RATIO_OTHER_END = 223
|
||||
TIMESTEP = 224
|
||||
CFG = 110
|
||||
IMG_TOK = 111
|
||||
JOINT_SEP = 112
|
||||
EOI = 113
|
||||
|
||||
|
||||
class FakeSamplerModel:
|
||||
@@ -47,10 +51,13 @@ class FakeSamplerModel:
|
||||
self._stage_transitions[END_OF_THINK] = [RECAPTION]
|
||||
self._stage_transitions[END_OF_RECAPTION] = [ANSWER, BOI, SIZE_TOKEN]
|
||||
|
||||
self._blocked_token_ids: set[int] = set()
|
||||
if is_comprehension:
|
||||
self._blocked_token_ids.update([BOI, SIZE_TOKEN])
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
# Mirrors the model: the blocklist is always built so generation-mode
|
||||
# engines can apply it to per-request comprehension rows (#6088).
|
||||
self._cfg_token_id = CFG
|
||||
self._blocked_token_ids: set[int] = {BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP}
|
||||
self._blocked_token_ids.update(self._all_ratio_ids)
|
||||
self._blocked_token_ids_tensor = None
|
||||
self._sampler = None
|
||||
|
||||
# Bind the real methods from the model class.
|
||||
from vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3 import (
|
||||
@@ -59,6 +66,8 @@ class FakeSamplerModel:
|
||||
|
||||
_get_forced_token = _Real._get_forced_token
|
||||
_apply_ratio_restriction = _Real._apply_ratio_restriction
|
||||
_row_is_comprehension = staticmethod(_Real._row_is_comprehension)
|
||||
sample = _Real.sample
|
||||
|
||||
|
||||
class FakeTokenEmbedding:
|
||||
@@ -250,3 +259,114 @@ class TestForceEosAfterRatio:
|
||||
assert logits[0, EOS].item() == 0
|
||||
non_eos_max = logits[0, :EOS].max().item()
|
||||
assert non_eos_max == min_score
|
||||
|
||||
|
||||
class _RecordingSampler:
|
||||
"""Stub sampler: returns the (mutated) logits so tests can assert on them."""
|
||||
|
||||
def __call__(self, *, logits, sampling_metadata):
|
||||
return logits
|
||||
|
||||
|
||||
def _metadata(output_token_ids):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(output_token_ids=output_token_ids)
|
||||
|
||||
|
||||
class TestPerRequestTaskMode:
|
||||
"""Generation-mode engines must honor per-request comprehension rows (#6088).
|
||||
|
||||
A text chat (I2T/T2T) served by an AR+DiT deployment is marked with
|
||||
extra_args={"ar_task_mode": "comprehension"}; its rows get the stateless
|
||||
image-token blocking instead of stage-transition forcing, while untagged
|
||||
rows keep the generation behavior.
|
||||
"""
|
||||
|
||||
VOCAB = 300
|
||||
|
||||
def _run(self, model, histories, per_req_extra_args):
|
||||
logits = torch.zeros(len(histories), self.VOCAB)
|
||||
logits[:, 50] = 7.0 # ordinary text token, must survive comprehension
|
||||
model._sampler = _RecordingSampler()
|
||||
out = model.sample(logits, _metadata(histories), per_req_extra_args=per_req_extra_args)
|
||||
return out
|
||||
|
||||
def test_comprehension_row_skips_forcing_and_blocks_image_tokens(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}, None],
|
||||
)
|
||||
|
||||
# Row 0 (comprehension): no </think> -> <recaption> forcing (ordinary
|
||||
# text token survives, which a force-fill would have erased) and every
|
||||
# DiT scaffold token is masked.
|
||||
assert logits[0, 50].item() == 7.0
|
||||
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP, RATIO_START, RATIO_OTHER_START):
|
||||
assert logits[0, tid].item() == min_score, f"token {tid} not blocked"
|
||||
|
||||
# Row 1 (generation): forced to <recaption> exactly as before.
|
||||
assert logits[1, RECAPTION].item() == 0
|
||||
assert logits[1, 50].item() == min_score
|
||||
|
||||
def test_no_extra_args_keeps_generation_behavior(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(model, histories=[[END_OF_THINK]], per_req_extra_args=None)
|
||||
|
||||
assert logits[0, RECAPTION].item() == 0
|
||||
assert logits[0, 50].item() == min_score
|
||||
|
||||
def test_empty_or_foreign_extra_args_keeps_generation_behavior(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{}, {"other_key": 1}],
|
||||
)
|
||||
|
||||
assert logits[0, RECAPTION].item() == 0
|
||||
assert logits[1, RECAPTION].item() == 0
|
||||
|
||||
def test_comprehension_row_without_trigger_history(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[1, 2, 3]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
|
||||
)
|
||||
|
||||
assert logits[0, 50].item() == 7.0
|
||||
assert logits[0, CFG].item() == min_score
|
||||
|
||||
def test_static_comprehension_engine_blocks_cfg(self):
|
||||
model = FakeSamplerModel(is_comprehension=True)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
logits = self._run(model, histories=[[1, 2]], per_req_extra_args=None)
|
||||
|
||||
for tid in (BOI, EOI, SIZE_TOKEN, CFG, IMG_TOK, TIMESTEP, JOINT_SEP):
|
||||
assert logits[0, tid].item() == min_score
|
||||
assert logits[0, 50].item() == 7.0
|
||||
|
||||
def test_extra_args_shorter_than_batch_defaults_to_generation(self):
|
||||
model = FakeSamplerModel(is_comprehension=False)
|
||||
|
||||
logits = self._run(
|
||||
model,
|
||||
histories=[[END_OF_THINK], [END_OF_THINK]],
|
||||
per_req_extra_args=[{"ar_task_mode": "comprehension"}],
|
||||
)
|
||||
min_score = torch.finfo(torch.float32).min
|
||||
|
||||
assert logits[0, 50].item() == 7.0 # tagged row: comprehension
|
||||
assert logits[1, RECAPTION].item() == 0 # missing entry: generation
|
||||
assert logits[1, 50].item() == min_score
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
@@ -143,6 +144,104 @@ def test_cache_manager_registers_attention_without_adding_dense_state() -> None:
|
||||
assert mgr.state_dict() == {}
|
||||
|
||||
|
||||
def test_cache_manager_initializes_with_quantization_skip_layers(monkeypatch):
|
||||
import vllm_omni.diffusion.attention.layer as layer
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer import ImageKVCacheManager
|
||||
|
||||
config = SimpleNamespace(
|
||||
diffusion_attention_config=None,
|
||||
parallel_config=SimpleNamespace(ring_degree=1),
|
||||
diffusion_kv_cache_dtype="auto",
|
||||
diffusion_kv_cache_skip_layer_indices={3},
|
||||
)
|
||||
monkeypatch.setattr(layer, "get_attn_backend_for_role", lambda **_: (layer.SDPABackend, None))
|
||||
monkeypatch.setattr(layer, "build_parallel_attention_strategy", lambda **_: object())
|
||||
with patched_mgr_env(), set_current_diffusion_config(config):
|
||||
# Keep the real Attention constructor and its skip-layer validation.
|
||||
with patch(f"{_TRANSFORMER_MODULE}.Attention", layer.Attention):
|
||||
mgr = ImageKVCacheManager(
|
||||
num_heads=NUM_HEADS,
|
||||
num_kv_heads=NUM_KV_HEADS,
|
||||
head_dim=HEAD_DIM,
|
||||
scaling=SCALING,
|
||||
prefix="model.layers.3.self_attn",
|
||||
)
|
||||
assert mgr.attn.layer_idx == 3
|
||||
assert not mgr.attn._should_apply_kv_cache_quant()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("first_step", [True, False])
|
||||
def test_native_connector_keeps_imported_prefix_in_paged_attention(first_step) -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
mgr = _make_cache_mgr()
|
||||
mgr.attn.paged_kv_active = True
|
||||
query_len, prefix_len = IMAGE_TOKEN_LEN, 19
|
||||
key, value = _make_known_kv(2 * query_len)
|
||||
spans = [[(prefix_len, prefix_len + query_len)]] * 2
|
||||
with (
|
||||
set_forward_context(),
|
||||
override_paged_kv_adapter(object()),
|
||||
patch.object(mgr, "_forward_dense_legacy", side_effect=AssertionError("dense KV path")),
|
||||
patch(f"{_TRANSFORMER_MODULE}.repeat_kv", side_effect=AssertionError("KV head expansion")),
|
||||
patch(f"{_TRANSFORMER_MODULE}.get_paged_kv_computed_tokens", return_value=(prefix_len, prefix_len)),
|
||||
):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
2,
|
||||
query_len,
|
||||
prefix_len + query_len,
|
||||
key,
|
||||
value,
|
||||
first_step=first_step,
|
||||
full_attn_spans=spans,
|
||||
)
|
||||
assert len(mgr.attn.paged_calls) == 1
|
||||
assert mgr.attn.paged_calls[0][1].shape == (2, query_len, NUM_KV_HEADS, HEAD_DIM)
|
||||
assert mgr.attn.paged_metadata[0].attn_mask is None
|
||||
assert mgr.attn.paged_metadata[0].full_attn_spans == spans
|
||||
assert mgr.image_kv_cache_map is None
|
||||
assert not hasattr(mgr, "_materialize_paged_prefix")
|
||||
|
||||
|
||||
def test_legacy_tensor_prefix_still_uses_dense_cache_and_reuse() -> None:
|
||||
mgr = _make_cache_mgr()
|
||||
prefix_len, prompt_len = 3, 2
|
||||
imported = _make_known_kv(prefix_len, base=10.0)
|
||||
mgr._injected_ar_kv = [imported]
|
||||
first_len = prompt_len + IMAGE_TOKEN_LEN
|
||||
key, value = _make_known_kv(first_len, base=20.0)
|
||||
with (
|
||||
patch.object(mgr, "_forward_paged", side_effect=AssertionError("legacy entered paged path")),
|
||||
):
|
||||
_call_mgr(
|
||||
mgr,
|
||||
1,
|
||||
first_len,
|
||||
prefix_len + first_len,
|
||||
key,
|
||||
value,
|
||||
first_step=True,
|
||||
gen_timestep_scatter_index=_gen_timestep_index(1, prompt_len),
|
||||
)
|
||||
assert mgr.image_kv_cache_map is not None
|
||||
assert mgr.image_kv_cache_map[0].shape[1] == prefix_len + prompt_len
|
||||
next_key, next_value = _make_known_kv(IMAGE_TOKEN_LEN, base=30.0)
|
||||
_call_mgr(
|
||||
mgr,
|
||||
1,
|
||||
IMAGE_TOKEN_LEN,
|
||||
prefix_len + first_len,
|
||||
next_key,
|
||||
next_value,
|
||||
position_ids=torch.arange(prefix_len + prompt_len, prefix_len + first_len).unsqueeze(0),
|
||||
)
|
||||
assert len(mgr.attn.calls) == 2
|
||||
assert not mgr.attn.paged_calls
|
||||
assert mgr.attn.calls[-1][1].shape == (1, prefix_len + first_len, NUM_HEADS, HEAD_DIM)
|
||||
|
||||
|
||||
def test_scheduler_paged_kv_runs_piecewise_for_first_and_later_steps() -> None:
|
||||
from vllm_omni.diffusion.forward_context import override_paged_kv_adapter, set_forward_context
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ from vllm_omni.diffusion.models.hunyuan_image3.prompt_utils import (
|
||||
HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS,
|
||||
available_bot_tasks,
|
||||
available_tasks,
|
||||
build_ar_prompt_inputs,
|
||||
build_prompt,
|
||||
build_prompt_tokens,
|
||||
resolve_stop_token_ids,
|
||||
validate_special_token_ids,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
@@ -296,19 +298,42 @@ def _repo_root() -> pathlib.Path:
|
||||
return pathlib.Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
def test_end2end_routes_through_shared_prompt_utils():
|
||||
end2end_path = _repo_root() / "examples" / "offline_inference" / "hunyuan_image3" / "end2end.py"
|
||||
tree = ast.parse(end2end_path.read_text(encoding="utf-8"))
|
||||
def test_example_routes_through_shared_seam():
|
||||
"""Neither the shared task examples nor the ``model_extras`` seam itself
|
||||
should duplicate AR prompt tokenization.
|
||||
|
||||
local_func_names = {n.name for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not (local_func_names & {"build_prompt", "build_prompt_tokens"})
|
||||
After the model_extras migration, t2i / image-editing run through the
|
||||
shared ``text_to_image.py`` / ``image_edit.py`` examples (both call
|
||||
``build_ar_stage_inputs``), and t2t / i2t run through the shared
|
||||
``x_to_text.py`` example (which calls ``build_x_to_text_prompt``). This
|
||||
test pins that both ``model_extras.hunyuan_image3`` seam functions
|
||||
delegate to ``prompt_utils.build_ar_prompt_inputs`` / ``build_ar_stage_inputs``
|
||||
rather than re-implementing tokenization inline.
|
||||
"""
|
||||
seam_path = _repo_root() / "vllm_omni" / "model_extras" / "hunyuan_image3.py"
|
||||
seam_tree = ast.parse(seam_path.read_text(encoding="utf-8"))
|
||||
|
||||
imported_from_prompt_utils: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
local_func_names = {n.name for n in ast.walk(seam_tree) if isinstance(n, ast.FunctionDef)}
|
||||
assert not (local_func_names & {"build_prompt", "build_prompt_tokens", "resolve_stop_token_ids"})
|
||||
|
||||
# build_x_to_text_prompt must route through build_ar_stage_inputs, not
|
||||
# re-derive the AR prefill / stop-token logic itself.
|
||||
build_x_to_text_prompt_node = next(
|
||||
n for n in ast.walk(seam_tree) if isinstance(n, ast.FunctionDef) and n.name == "build_x_to_text_prompt"
|
||||
)
|
||||
called_names = {
|
||||
node.func.id
|
||||
for node in ast.walk(build_x_to_text_prompt_node)
|
||||
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
|
||||
}
|
||||
assert "build_ar_stage_inputs" in called_names
|
||||
|
||||
# The model_extras seam itself must delegate to the shared prompt_utils.
|
||||
routed_from_prompt_utils: set[str] = set()
|
||||
for node in ast.walk(seam_tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module and node.module.endswith("hunyuan_image3.prompt_utils"):
|
||||
imported_from_prompt_utils.update(alias.name for alias in node.names)
|
||||
expected_imports = {"build_prompt_tokens", "resolve_stop_token_ids", "resolve_sys_type"}
|
||||
assert expected_imports <= imported_from_prompt_utils
|
||||
routed_from_prompt_utils.update(alias.name for alias in node.names)
|
||||
assert "build_ar_prompt_inputs" in routed_from_prompt_utils
|
||||
|
||||
|
||||
_HUNYUAN_MODEL_ID = "tencent/HunyuanImage-3.0-Instruct"
|
||||
@@ -331,3 +356,87 @@ def test_segment_tokenize_diverges_from_full_string_encode():
|
||||
full_ids = tok.encode(build_prompt(user_prompt, task="i2t", bot_task=None), add_special_tokens=False)
|
||||
assert seg_ids != full_ids
|
||||
assert len(seg_ids) >= len(full_ids)
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_tokenizer_path_matches_build_prompt_tokens():
|
||||
"""With a tokenizer, the helper delegates to build_prompt_tokens (parity)."""
|
||||
tok = FakeTokenizer()
|
||||
ar = build_ar_prompt_inputs("a red panda", task="t2i", bot_task="think", tokenizer=tok)
|
||||
expected = build_prompt_tokens("a red panda", FakeTokenizer(), task="t2i", bot_task="think")
|
||||
assert ar.prompt is None
|
||||
assert ar.prompt_token_ids == expected.token_ids
|
||||
assert ar.system_prompt_type == expected.system_prompt_type
|
||||
# think + no explicit size -> AR predicts ratio, so stop range is non-trivial.
|
||||
assert len(ar.stop_token_ids) > 1
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_string_fallback_without_tokenizer():
|
||||
"""Without a tokenizer, the helper returns the build_prompt string form."""
|
||||
ar = build_ar_prompt_inputs("a red panda", task="t2i", bot_task="recaption")
|
||||
assert ar.prompt_token_ids is None
|
||||
assert ar.prompt == build_prompt("a red panda", task="t2i", bot_task="recaption")
|
||||
assert ar.system_prompt_type
|
||||
assert ar.stop_token_ids
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_explicit_size_stops_at_terminator():
|
||||
"""Explicit image_size makes 'recaption' stop at </recaption> only."""
|
||||
ar = build_ar_prompt_inputs("x", task="t2i", bot_task="recaption", image_size="1024x1024")
|
||||
assert ar.stop_token_ids == [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["</recaption>"]]
|
||||
|
||||
|
||||
def test_build_ar_prompt_inputs_comprehension_stops_on_answer():
|
||||
"""Text-output tasks (t2t / i2t) stop on <answer>."""
|
||||
ar = build_ar_prompt_inputs("hi", task="t2t")
|
||||
assert ar.stop_token_ids == [HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS["<answer>"]]
|
||||
|
||||
|
||||
class _MatchingTokenizer:
|
||||
"""Reports exactly HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS -- a "real" tokenizer
|
||||
that hasn't drifted from the hardcoded table."""
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
class _DriftedTokenizer:
|
||||
"""Reports a different id for one token -- simulates a model/tokenizer
|
||||
revision bump shifting special-token ids out from under the hardcoded
|
||||
table."""
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
if tok == "<think>":
|
||||
return 999999
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
class _BaseCheckpointTokenizer:
|
||||
"""Simulates tencent/HunyuanImage-3.0 (base): reports the same
|
||||
model_class_name as -Instruct, but its vocab doesn't include the
|
||||
Instruct-only <img_ratio_33>-<img_ratio_36> tokens -- convert_tokens_to_ids
|
||||
falls back to unk_token_id for those, like a real HF tokenizer would."""
|
||||
|
||||
unk_token_id = 0
|
||||
_INSTRUCT_ONLY = {"<img_ratio_33>", "<img_ratio_36>"}
|
||||
|
||||
def convert_tokens_to_ids(self, tok: str) -> int:
|
||||
if tok in self._INSTRUCT_ONLY:
|
||||
return self.unk_token_id
|
||||
return HUNYUAN_IMAGE3_SPECIAL_TOKEN_IDS[tok]
|
||||
|
||||
|
||||
def test_validate_special_token_ids_passes_for_matching_tokenizer():
|
||||
validate_special_token_ids(_MatchingTokenizer()) # must not raise
|
||||
|
||||
|
||||
def test_validate_special_token_ids_raises_on_drift():
|
||||
with pytest.raises(ValueError, match="<think>.*expected 128023.*got 999999"):
|
||||
validate_special_token_ids(_DriftedTokenizer())
|
||||
|
||||
|
||||
def test_validate_special_token_ids_tolerates_base_checkpoint_missing_tokens():
|
||||
"""The base checkpoint's tokenizer not recognizing Instruct-only ratio
|
||||
tokens (unk_token_id, not a real drifted id) must not be flagged as a
|
||||
mismatch -- both checkpoints share model_class_name, so this hook fires
|
||||
for either one."""
|
||||
validate_special_token_ids(_BaseCheckpointTokenizer()) # must not raise
|
||||
@@ -0,0 +1,171 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.lance.lance_transformer import LanceBagel
|
||||
from vllm_omni.diffusion.models.lance.pipeline_lance import LancePipeline
|
||||
from vllm_omni.model_executor.models.utils import normalize_decoded_video_frames
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
class FramesWithMetadata(list[Image.Image]):
|
||||
def __init__(self, frames: list[Image.Image], **metadata: object) -> None:
|
||||
super().__init__(frames)
|
||||
for name, value in metadata.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
|
||||
class StopAfterVideoPreprocessError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_preserves_rgb_pixels_and_fps():
|
||||
red = Image.new("RGB", (3, 2), color=(255, 0, 0))
|
||||
rgba = Image.new("RGBA", (3, 2), color=(0, 255, 0, 64))
|
||||
rgba_bytes = rgba.tobytes()
|
||||
|
||||
video, fps = normalize_decoded_video_frames(FramesWithMetadata([red, rgba], fps=8), default_fps=24.0)
|
||||
|
||||
assert video.shape == (2, 2, 3, 3)
|
||||
assert video.dtype == np.uint8
|
||||
assert video.flags.c_contiguous
|
||||
np.testing.assert_array_equal(video[0, 0, 0], [255, 0, 0])
|
||||
np.testing.assert_array_equal(video[1, 0, 0], [0, 255, 0])
|
||||
assert fps == 8.0
|
||||
assert rgba.mode == "RGBA"
|
||||
assert rgba.tobytes() == rgba_bytes
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_frame_rate_when_fps_is_missing():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, frame_rate=12)
|
||||
|
||||
_, fps = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert fps == 12.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_prefers_valid_fps_over_frame_rate():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps=8, frame_rate=12)
|
||||
|
||||
_, fps = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert fps == 8.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_default_fps_for_plain_list_and_tuple():
|
||||
frames = [Image.new("RGB", (2, 2)), Image.new("RGB", (2, 2))]
|
||||
|
||||
assert normalize_decoded_video_frames(frames, default_fps=24.0)[1] == 24.0
|
||||
assert normalize_decoded_video_frames(tuple(frames), default_fps=24.0)[1] == 24.0
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_accepts_numeric_string_fps():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps="12.5")
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == 12.5
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("fps", "frame_rate", "expected"),
|
||||
[
|
||||
(float("nan"), 12, 12.0),
|
||||
(0, 15, 15.0),
|
||||
(-1, 8, 8.0),
|
||||
(float("inf"), 10, 10.0),
|
||||
("invalid", "6.5", 6.5),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_uses_valid_frame_rate_after_invalid_fps(fps, frame_rate, expected):
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps=fps, frame_rate=frame_rate)
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == expected
|
||||
|
||||
|
||||
def test_normalize_decoded_video_frames_uses_default_when_both_metadata_values_are_invalid():
|
||||
frames = FramesWithMetadata([Image.new("RGB", (2, 2))] * 2, fps="invalid", frame_rate=0)
|
||||
|
||||
_, frame_rate = normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
assert frame_rate == 24.0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frames", "match"),
|
||||
[
|
||||
([], "empty decoded video frame sequence"),
|
||||
([Image.new("RGB", (2, 2)), object()], "must be a PIL.Image.Image"),
|
||||
(
|
||||
[Image.new("RGB", (2, 2)), Image.new("RGB", (3, 2))],
|
||||
"must have identical dimensions",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_rejects_invalid_sequences(frames, match):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("frames", "match"),
|
||||
[
|
||||
([Image.new("RGB", (0, 16))], r"index 0.*0x16"),
|
||||
([Image.new("RGB", (16, 0))], r"index 0.*16x0"),
|
||||
([Image.new("RGB", (0, 0))], r"index 0.*0x0"),
|
||||
(
|
||||
[Image.new("RGB", (16, 16)), Image.new("RGB", (0, 16))],
|
||||
r"index 1.*0x16",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_normalize_decoded_video_frames_rejects_zero_dimensions(frames, match):
|
||||
with pytest.raises(ValueError, match=match):
|
||||
normalize_decoded_video_frames(frames, default_fps=24.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("video_input", "expected_fps"),
|
||||
[
|
||||
(FramesWithMetadata([Image.new("RGB", (3, 2))] * 2, fps=8), 8.0),
|
||||
(tuple([Image.new("RGBA", (3, 2))] * 2), 12.0),
|
||||
],
|
||||
)
|
||||
def test_lance_video_edit_normalizes_decoded_frame_sequences(monkeypatch, video_input, expected_fps):
|
||||
captured = {}
|
||||
|
||||
def capture_preprocessed_input(video, fps):
|
||||
captured["video"] = video
|
||||
captured["fps"] = fps
|
||||
raise StopAfterVideoPreprocessError
|
||||
|
||||
monkeypatch.setattr(LanceBagel, "_lance_video_preprocess", staticmethod(capture_preprocessed_input))
|
||||
request = SimpleNamespace(
|
||||
prompts=[
|
||||
{
|
||||
"modalities": ["video"],
|
||||
"user_text": "edit the video",
|
||||
"multi_modal_data": {"video": video_input},
|
||||
"extra_args": {"origin_fps": 12.0},
|
||||
}
|
||||
],
|
||||
sampling_params=SimpleNamespace(extra_args={}, num_inference_steps=1),
|
||||
)
|
||||
|
||||
with pytest.raises(StopAfterVideoPreprocessError):
|
||||
object.__new__(LancePipeline)._forward_video_edit(request)
|
||||
|
||||
video = captured["video"]
|
||||
assert isinstance(video, np.ndarray)
|
||||
assert video.shape == (2, 2, 3, 3)
|
||||
assert video.dtype == np.uint8
|
||||
assert video.flags.c_contiguous
|
||||
assert captured["fps"] == expected_fps
|
||||
@@ -12,6 +12,7 @@ from vllm_omni.diffusion.models.lingbot_world.actions import (
|
||||
LINGBOT_CAMERA_ACTION_SCHEMA,
|
||||
LINGBOT_CAMERA_TRAJECTORY_SCHEMA,
|
||||
LingBotCameraControlReducer,
|
||||
camera_trajectory_from_absolute_pose,
|
||||
integrate_lingbot_camera_actions,
|
||||
parse_lingbot_camera_action_frames,
|
||||
parse_lingbot_camera_action_script,
|
||||
@@ -212,6 +213,11 @@ def test_action_integrator_matches_lingbot_motion_and_calibration() -> None:
|
||||
torch.tensor([500.0, 500.0, 208.0, 120.0]),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="resolution must be positive"):
|
||||
camera_trajectory_from_absolute_pose(trajectory.poses[:1], width=0, height=480)
|
||||
with pytest.raises(ValueError, match=r"\[frames, 4, 4\]"):
|
||||
camera_trajectory_from_absolute_pose(torch.zeros(4, 4), width=832, height=480)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("data", "message"),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,7 @@ import importlib.util
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from types import ModuleType, SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
@@ -33,6 +33,10 @@ _STUBBED_MODULE_NAMES = (
|
||||
"vllm_omni.diffusion",
|
||||
"vllm_omni.diffusion.attention",
|
||||
"vllm_omni.diffusion.attention.layer",
|
||||
"vllm_omni.diffusion.distributed",
|
||||
"vllm_omni.diffusion.distributed.comm",
|
||||
"vllm_omni.diffusion.distributed.parallel_state",
|
||||
"vllm_omni.diffusion.distributed.sp_plan",
|
||||
"vllm_omni.diffusion.layers",
|
||||
"vllm_omni.diffusion.layers.norm",
|
||||
"vllm_omni.diffusion.layers.rope",
|
||||
@@ -58,6 +62,58 @@ def _install_vllm_stubs() -> None:
|
||||
distributed.get_tensor_model_parallel_world_size = lambda: 1
|
||||
distributed.tensor_model_parallel_all_reduce = lambda value: value
|
||||
|
||||
class _SeqAllToAll4D:
|
||||
@staticmethod
|
||||
def apply(group, value, scatter_idx, gather_idx, use_sync=False):
|
||||
del group, scatter_idx, gather_idx, use_sync
|
||||
return value
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.comm"],
|
||||
"SeqAllToAll4D",
|
||||
_SeqAllToAll4D,
|
||||
)
|
||||
|
||||
def _all_to_all_5D(value, scatter_idx=3, gather_idx=1, group=None, use_sync=False):
|
||||
# Single-rank stub: the fused q/k/v exchange is the identity when N == 1.
|
||||
del scatter_idx, gather_idx, group, use_sync
|
||||
return value
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.comm"],
|
||||
"all_to_all_5D",
|
||||
_all_to_all_5D,
|
||||
)
|
||||
|
||||
def get_sp_group():
|
||||
return SimpleNamespace(
|
||||
ulysses_world_size=1,
|
||||
ulysses_rank=0,
|
||||
ulysses_group=None,
|
||||
)
|
||||
|
||||
setattr(
|
||||
sys.modules["vllm_omni.diffusion.distributed.parallel_state"],
|
||||
"get_sp_group",
|
||||
get_sp_group,
|
||||
)
|
||||
|
||||
class _SequenceParallelInput:
|
||||
def __init__(self, split_dim, expected_dims=None, split_output=False, auto_pad=False):
|
||||
self.split_dim = split_dim
|
||||
self.expected_dims = expected_dims
|
||||
self.split_output = split_output
|
||||
self.auto_pad = auto_pad
|
||||
|
||||
class _SequenceParallelOutput:
|
||||
def __init__(self, gather_dim, expected_dims=None):
|
||||
self.gather_dim = gather_dim
|
||||
self.expected_dims = expected_dims
|
||||
|
||||
sp_plan = sys.modules["vllm_omni.diffusion.distributed.sp_plan"]
|
||||
setattr(sp_plan, "SequenceParallelInput", _SequenceParallelInput)
|
||||
setattr(sp_plan, "SequenceParallelOutput", _SequenceParallelOutput)
|
||||
|
||||
def set_weight_attrs(weight: torch.Tensor, attrs: dict) -> None:
|
||||
for name, value in attrs.items():
|
||||
setattr(weight, name, value)
|
||||
@@ -541,3 +597,51 @@ def test_tp_rmsnorm_weight_loader_selects_rank_shard(monkeypatch: pytest.MonkeyP
|
||||
norm.weight.weight_loader(norm.weight, torch.tensor([10.0, 20.0, 30.0, 40.0]))
|
||||
|
||||
torch.testing.assert_close(norm.weight, torch.tensor([30.0, 40.0]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attention_class", ["LingBotSelfAttention"])
|
||||
def test_ulysses_rejects_non_divisible_head_count(monkeypatch, attention_class):
|
||||
module = _load_module()
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"get_sp_group",
|
||||
lambda: SimpleNamespace(
|
||||
ulysses_world_size=3,
|
||||
ulysses_rank=0,
|
||||
ulysses_group=None,
|
||||
),
|
||||
)
|
||||
with pytest.raises(ValueError, match="heads must be divisible"):
|
||||
getattr(module, attention_class)(dim=8, num_heads=4)
|
||||
|
||||
|
||||
def test_supplied_camera_modulation_matches_computing_it_inline() -> None:
|
||||
"""Passing a prebuilt modulation must be arithmetically identical to letting the block build its own."""
|
||||
module = _load_module()
|
||||
torch.manual_seed(0)
|
||||
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
|
||||
camera = torch.randn(1, 3, 4)
|
||||
|
||||
scale, shift = block.camera_modulation(camera)
|
||||
inline_scale, inline_shift = block.camera_modulation(camera)
|
||||
|
||||
torch.testing.assert_close(scale, inline_scale, rtol=0, atol=0)
|
||||
torch.testing.assert_close(shift, inline_shift, rtol=0, atol=0)
|
||||
|
||||
|
||||
def test_the_camera_modulation_reads_only_the_camera_tokens() -> None:
|
||||
"""The reuse window is only sound because the modulation depends on nothing else that varies per forward."""
|
||||
module = _load_module()
|
||||
torch.manual_seed(0)
|
||||
block = module.LingBotAttentionBlock(dim=4, num_heads=2, prefix="blocks.0")
|
||||
camera = torch.randn(1, 3, 4)
|
||||
|
||||
first = block.camera_modulation(camera)
|
||||
# Same camera, different everything else a forward would carry: the modulation cannot move.
|
||||
second = block.camera_modulation(camera.clone())
|
||||
|
||||
torch.testing.assert_close(first[0], second[0], rtol=0, atol=0)
|
||||
torch.testing.assert_close(first[1], second[1], rtol=0, atol=0)
|
||||
# A different camera must move it, or the cache key would be hiding a real dependency.
|
||||
other = block.camera_modulation(camera + 1)
|
||||
assert not torch.equal(first[0], other[0])
|
||||
@@ -8,6 +8,7 @@ import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -20,7 +21,10 @@ _CAMERA_MODULE = importlib.util.module_from_spec(_SPEC)
|
||||
sys.modules[_SPEC.name] = _CAMERA_MODULE
|
||||
_SPEC.loader.exec_module(_CAMERA_MODULE)
|
||||
|
||||
CameraTrajectory = _CAMERA_MODULE.CameraTrajectory
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.diffusion.models.lingbot_world.camera import CameraTrajectory
|
||||
else:
|
||||
CameraTrajectory = _CAMERA_MODULE.CameraTrajectory
|
||||
build_plucker_embedding = _CAMERA_MODULE.build_plucker_embedding
|
||||
interpolate_camera_trajectory = _CAMERA_MODULE.interpolate_camera_trajectory
|
||||
load_camera_trajectory = _CAMERA_MODULE.load_camera_trajectory
|
||||
@@ -138,21 +142,20 @@ def test_load_camera_trajectory_rejects_object_dtype_before_materializing(tmp_pa
|
||||
load_camera_trajectory(_trusted(tmp_path))
|
||||
|
||||
|
||||
def test_load_camera_trajectory_accepts_official_length_and_materializes_request_prefix(tmp_path: Path) -> None:
|
||||
_write_trajectory(
|
||||
tmp_path,
|
||||
_identity_poses(269),
|
||||
np.arange(269 * 4, dtype=np.float32).reshape(269, 4),
|
||||
)
|
||||
@pytest.mark.parametrize("num_frames", [129, 269, 4096])
|
||||
def test_load_camera_trajectory_preserves_all_bounded_source_frames(tmp_path: Path, num_frames: int) -> None:
|
||||
poses = _identity_poses(num_frames)
|
||||
poses[:, 0, 3] = np.arange(num_frames)
|
||||
intrinsics = np.arange(num_frames * 4, dtype=np.float32).reshape(num_frames, 4)
|
||||
_write_trajectory(tmp_path, poses, intrinsics)
|
||||
|
||||
trajectory = load_camera_trajectory(_trusted(tmp_path))
|
||||
|
||||
assert trajectory.poses.shape == (117, 4, 4)
|
||||
assert trajectory.intrinsics.shape == (117, 4)
|
||||
np.testing.assert_array_equal(
|
||||
trajectory.intrinsics.numpy(),
|
||||
np.arange(269 * 4, dtype=np.float32).reshape(269, 4)[:117],
|
||||
)
|
||||
assert trajectory.poses.shape == (num_frames, 4, 4)
|
||||
assert trajectory.intrinsics.shape == (num_frames, 4)
|
||||
assert trajectory.poses.dtype == trajectory.intrinsics.dtype == torch.float32
|
||||
np.testing.assert_array_equal(trajectory.poses.numpy(), poses.astype(np.float32))
|
||||
np.testing.assert_array_equal(trajectory.intrinsics.numpy(), intrinsics)
|
||||
|
||||
|
||||
def test_load_camera_trajectory_rejects_unbounded_source_frame_count(tmp_path: Path) -> None:
|
||||
@@ -419,7 +422,17 @@ def test_build_plucker_embedding_uses_framewise_deltas_for_rotations_and_transla
|
||||
torch.testing.assert_close(result[2, 3:, 0, 1], expected_direction)
|
||||
|
||||
|
||||
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm() -> None:
|
||||
@pytest.mark.parametrize(
|
||||
("translation_scale", "expected_origins"),
|
||||
[
|
||||
(None, [[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]]),
|
||||
(0.05, [[0.0, 0.0, 0.0], [60.0, 80.0, 0.0], [120.0, 160.0, 0.0]]),
|
||||
],
|
||||
)
|
||||
def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm(
|
||||
translation_scale: float | None,
|
||||
expected_origins: list[list[float]],
|
||||
) -> None:
|
||||
poses = torch.eye(4).repeat(3, 1, 1)
|
||||
poses[:, :3, 3] = torch.tensor(
|
||||
[
|
||||
@@ -437,10 +450,37 @@ def test_build_plucker_embedding_normalizes_framewise_translation_by_max_norm()
|
||||
target_width=1,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.float32,
|
||||
translation_scale=translation_scale,
|
||||
)
|
||||
|
||||
expected_origins = torch.tensor([[0.0, 0.0, 0.0], [0.3, 0.4, 0.0], [0.6, 0.8, 0.0]])
|
||||
torch.testing.assert_close(result[:, :3, 0, 0], expected_origins)
|
||||
torch.testing.assert_close(result[:, :3, 0, 0], torch.tensor(expected_origins))
|
||||
|
||||
from vllm_omni.diffusion.models.lingbot_world.actions import LINGBOT_CONTROLLER_TRANSLATION_UNIT
|
||||
|
||||
if translation_scale != LINGBOT_CONTROLLER_TRANSLATION_UNIT:
|
||||
return
|
||||
# Fixed controller unit keeps relative speed; max-norm would collapse both to 1.
|
||||
slow = torch.eye(4).repeat(2, 1, 1)
|
||||
slow[1, 2, 3] = 0.005
|
||||
fast = torch.eye(4).repeat(2, 1, 1)
|
||||
fast[1, 2, 3] = 0.5
|
||||
kwargs = dict(
|
||||
height=1,
|
||||
width=1,
|
||||
target_height=1,
|
||||
target_width=1,
|
||||
device=torch.device("cpu"),
|
||||
dtype=torch.float32,
|
||||
translation_scale=LINGBOT_CONTROLLER_TRANSLATION_UNIT,
|
||||
)
|
||||
slow_origin = build_plucker_embedding(_trajectory(slow), **kwargs)[1, :3, 0, 0]
|
||||
fast_origin = build_plucker_embedding(_trajectory(fast), **kwargs)[1, :3, 0, 0]
|
||||
torch.testing.assert_close(slow_origin, torch.tensor([0.0, 0.0, 0.1]))
|
||||
torch.testing.assert_close(fast_origin, torch.tensor([0.0, 0.0, 10.0]))
|
||||
wasd = torch.eye(4).repeat(2, 1, 1)
|
||||
wasd[1, 2, 3] = LINGBOT_CONTROLLER_TRANSLATION_UNIT
|
||||
wasd_origin = build_plucker_embedding(_trajectory(wasd), **kwargs)[1, :3, 0, 0]
|
||||
torch.testing.assert_close(wasd_origin, torch.tensor([0.0, 0.0, 1.0]))
|
||||
|
||||
|
||||
def test_build_plucker_embedding_interpolates_raw_poses_before_framewise_conversion() -> 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
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,14 +7,17 @@ import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import math
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.lingbot_world import test_lingbot_world_attention as attention_tests
|
||||
from tests.helpers.mark import hardware_test
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
_FIXTURE_PATH = Path(__file__).with_name("fixtures") / "lingbot_world_weight_index_names.json.fixture"
|
||||
_SHAPE_FIXTURE_PATH = Path(__file__).with_name("fixtures") / "lingbot_world_official_shapes.json.fixture"
|
||||
@@ -32,11 +35,13 @@ def _tiny_model(
|
||||
num_layers: int = 2,
|
||||
num_frames_per_block: int = 1,
|
||||
sliding_window_num_frames: int = 3,
|
||||
attention_head_dim: int = 2,
|
||||
rope_max_seq_len: int = 16,
|
||||
):
|
||||
return module.CausalLingBotWorldTransformer3DModel(
|
||||
patch_size=(1, 2, 2),
|
||||
num_attention_heads=2,
|
||||
attention_head_dim=2,
|
||||
attention_head_dim=attention_head_dim,
|
||||
in_channels=36,
|
||||
out_channels=2,
|
||||
text_dim=6,
|
||||
@@ -45,7 +50,7 @@ def _tiny_model(
|
||||
num_layers=num_layers,
|
||||
cross_attn_norm=True,
|
||||
eps=1e-6,
|
||||
rope_max_seq_len=16,
|
||||
rope_max_seq_len=rope_max_seq_len,
|
||||
sink_size=1,
|
||||
num_frames_per_block=num_frames_per_block,
|
||||
sliding_window_num_frames=sliding_window_num_frames,
|
||||
@@ -117,7 +122,9 @@ def _assert_self_cache_unchanged(cache, snapshot) -> None:
|
||||
attention_tests._assert_cache_unchanged(layer_cache, layer_snapshot)
|
||||
|
||||
|
||||
def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera_path() -> None:
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("num_chunks", [4, 18])
|
||||
def test_tiny_transformer_runs_contiguous_chunks_with_explicit_cache_commit_and_camera_path(num_chunks) -> None:
|
||||
torch.manual_seed(7)
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module).eval()
|
||||
@@ -131,7 +138,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
assert model.patch_embedding.in_channels == 36
|
||||
assert model.patch_embedding_wancamctrl.in_features == 6 * 8 * 8 * 1 * 2 * 2
|
||||
|
||||
for start_frame in range(4):
|
||||
for start_frame in range(num_chunks):
|
||||
hidden_states = torch.randn(1, 36, 1, 4, 4)
|
||||
timestep = torch.tensor([float(start_frame + 1)])
|
||||
snapshot = _self_cache_snapshot(cache)
|
||||
@@ -165,7 +172,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
expected_token_end = (start_frame + 1) * 2 * 2
|
||||
assert all(layer_cache.absolute_end == expected_token_end for layer_cache in cache.self_attention)
|
||||
|
||||
assert len(outputs) == 4
|
||||
assert len(outputs) == num_chunks
|
||||
assert all(len(outputs) == 1 for outputs in cross_key_outputs)
|
||||
assert len(text_projection_outputs) == 1
|
||||
|
||||
@@ -191,6 +198,7 @@ def test_tiny_transformer_runs_four_chunks_with_explicit_cache_commit_and_camera
|
||||
assert not torch.equal(camera_output, alternate_output)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_partial_cross_attention_cache_projects_text_only_for_missing_layers() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=2).eval()
|
||||
@@ -229,6 +237,7 @@ def test_partial_cross_attention_cache_projects_text_only_for_missing_layers() -
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frames", [1, 6], ids=("partial", "multiple_blocks"))
|
||||
@pytest.mark.cpu
|
||||
def test_forward_rejects_chunks_that_do_not_equal_configured_block_size(frames: int) -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -257,6 +266,7 @@ def test_forward_rejects_chunks_that_do_not_equal_configured_block_size(frames:
|
||||
assert cache.cross_attention == [None]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_forward_accepts_exactly_one_configured_frame_block() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -279,6 +289,7 @@ def test_forward_accepts_exactly_one_configured_frame_block() -> None:
|
||||
assert output.shape == (1, 2, 3, 4, 4)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_allocates_request_cache_from_its_configured_geometry() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(
|
||||
@@ -303,6 +314,7 @@ def test_transformer_allocates_request_cache_from_its_configured_geometry() -> N
|
||||
assert all(layer.value.shape == (2, 6 * 4 * 6, 2, 2) for layer in cache.self_attention)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_video_patch_embedding_uses_temporal_height_width_token_order() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -323,6 +335,7 @@ def test_video_patch_embedding_uses_temporal_height_width_token_order() -> None:
|
||||
torch.testing.assert_close(tokens[0, :, 0], torch.tensor([1.0, 3.0, 9.0, 11.0]))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_unpatchify_restores_two_frame_channel_and_spatial_order() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -363,6 +376,7 @@ def test_unpatchify_restores_two_frame_channel_and_spatial_order() -> None:
|
||||
torch.testing.assert_close(output, expected)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_head_modulation_broadcasts_distinct_condition_per_frame() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, num_frames_per_block=2)
|
||||
@@ -381,6 +395,7 @@ def test_head_modulation_broadcasts_distinct_condition_per_frame() -> None:
|
||||
torch.testing.assert_close(output, expected)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_defaults_match_official_checkpoint_config() -> None:
|
||||
module = attention_tests._load_module()
|
||||
parameters = inspect.signature(module.CausalLingBotWorldTransformer3DModel.__init__).parameters
|
||||
@@ -406,6 +421,7 @@ def test_constructor_defaults_match_official_checkpoint_config() -> None:
|
||||
assert {name: parameters[name].default for name in expected} == expected
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_exposes_parameter_dtype_for_pipeline_runtime() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module)
|
||||
@@ -413,12 +429,14 @@ def test_transformer_exposes_parameter_dtype_for_pipeline_runtime() -> None:
|
||||
assert model.dtype == next(model.parameters()).dtype
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_transformer_declares_regional_compile_block() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
assert module.CausalLingBotWorldTransformer3DModel._repeated_blocks == ["LingBotAttentionBlock"]
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_lingbot_rms_norm_uses_global_tp_square_mean(monkeypatch) -> None:
|
||||
module = attention_tests._load_module()
|
||||
reduced_values: list[torch.Tensor] = []
|
||||
@@ -440,6 +458,7 @@ def test_lingbot_rms_norm_uses_global_tp_square_mean(monkeypatch) -> None:
|
||||
torch.testing.assert_close(reduced_values[0], torch.tensor([[25.0]]))
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_attention_rejects_heads_not_divisible_by_tp_size(monkeypatch) -> None:
|
||||
module = attention_tests._load_module()
|
||||
monkeypatch.setattr(module, "get_tensor_model_parallel_world_size", lambda: 3)
|
||||
@@ -448,6 +467,7 @@ def test_attention_rejects_heads_not_divisible_by_tp_size(monkeypatch) -> None:
|
||||
module.LingBotSelfAttention(dim=4, num_heads=2)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_unsupported_qk_norm_with_config_error() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -456,6 +476,7 @@ def test_constructor_rejects_unsupported_qk_norm_with_config_error() -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["image_dim", "added_kv_proj_dim", "pos_embed_seq_len"])
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -463,6 +484,7 @@ def test_constructor_rejects_non_null_image_embedding_fields(field: str) -> None
|
||||
module.CausalLingBotWorldTransformer3DModel(**{field: 4})
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -470,6 +492,7 @@ def test_constructor_rejects_unsupported_quantization_with_runtime_error() -> No
|
||||
module.CausalLingBotWorldTransformer3DModel(quant_config=object())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_accepts_diffusers_metadata_and_normalizes_patch_size() -> None:
|
||||
module = attention_tests._load_module()
|
||||
|
||||
@@ -522,6 +545,7 @@ def test_from_config_accepts_diffusers_metadata_and_normalizes_patch_size() -> N
|
||||
("sink_size", 0),
|
||||
],
|
||||
)
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_rejects_checkpoint_topology_drift(field: str, value: object) -> None:
|
||||
module = attention_tests._load_module()
|
||||
config = {
|
||||
@@ -554,6 +578,7 @@ def test_from_config_rejects_checkpoint_topology_drift(field: str, value: object
|
||||
module.CausalLingBotWorldTransformer3DModel.from_config(config)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_from_config_ignores_non_semantic_checkpoint_metadata() -> None:
|
||||
module = attention_tests._load_module()
|
||||
config = {
|
||||
@@ -569,6 +594,7 @@ def test_from_config_ignores_non_semantic_checkpoint_metadata() -> None:
|
||||
assert model.config.num_layers == 40
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_load_weights_uses_parameter_loaders_and_rejects_unknown_model_keys() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
@@ -606,6 +632,7 @@ def test_load_weights_uses_parameter_loaders_and_rejects_unknown_model_keys() ->
|
||||
model.load_weights([("unexpected_model.weight", torch.ones(1))])
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_load_weights_consumes_checkpoint_iterator_incrementally() -> None:
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
@@ -619,6 +646,7 @@ def test_load_weights_consumes_checkpoint_iterator_incrementally() -> None:
|
||||
assert model.load_weights(weights()) == {first_name}
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_real_auto_weights_loader_covers_fused_model_parameter_namespace() -> None:
|
||||
utils = pytest.importorskip("vllm.model_executor.models.utils")
|
||||
module = attention_tests._load_module()
|
||||
@@ -638,6 +666,7 @@ def test_real_auto_weights_loader_covers_fused_model_parameter_namespace() -> No
|
||||
assert set(dict(pipeline.named_parameters())) <= loaded
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_checkpoint_weight_index_fixture_matches_model_namespaces() -> None:
|
||||
module = attention_tests._load_module()
|
||||
fixture = json.loads(_FIXTURE_PATH.read_text())
|
||||
@@ -667,6 +696,7 @@ def test_checkpoint_weight_index_fixture_matches_model_namespaces() -> None:
|
||||
assert all(len(shard["parameter_names_sha256"]) == 64 for shard in fixture["shards"].values())
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_official_default_shapes_match_public_safetensors_header_fixture() -> None:
|
||||
module = attention_tests._load_module()
|
||||
fixture = json.loads(_SHAPE_FIXTURE_PATH.read_text())
|
||||
@@ -683,3 +713,377 @@ def test_official_default_shapes_match_public_safetensors_header_fixture() -> No
|
||||
shape, dtype = parameter_specs[name]
|
||||
assert tuple(shape) == tuple(expected_shape), name
|
||||
assert dtype == expected_dtype
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_timestep_expansion_only_runs_with_ulysses(monkeypatch):
|
||||
dtype = torch.bfloat16
|
||||
module = attention_tests._load_module()
|
||||
hidden = torch.randn(2, 12, 4).to(dtype)
|
||||
camera = torch.randn_like(hidden)
|
||||
table = torch.randn(2, 3, 6, 4).to(dtype)
|
||||
rotary = (torch.randn(12, 2), torch.randn(12, 2))
|
||||
prepare = module._LingBotSPPrepare()
|
||||
assert prepare(hidden, camera, table, rotary)[2] is table
|
||||
monkeypatch.setattr(module, "get_sp_group", lambda: SimpleNamespace(ulysses_world_size=2))
|
||||
expanded = prepare(hidden, camera, table, rotary)[2]
|
||||
assert expanded.shape == (2, 12, 6, 4)
|
||||
torch.testing.assert_close(expanded, table.repeat_interleave(4, dim=1), rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("unsharded", [None, 3], ids=["missing-hook", "unsharded-rope"])
|
||||
@pytest.mark.cpu
|
||||
def test_missing_or_partial_sp_split_fails_before_attention(monkeypatch, unsharded):
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_frames_per_block=3, sliding_window_num_frames=6)
|
||||
cache = _cache(module, model)
|
||||
for block in model.blocks:
|
||||
block.self_attn.ulysses_world_size = 2
|
||||
monkeypatch.setattr(module, "get_sp_group", lambda: SimpleNamespace(ulysses_world_size=2))
|
||||
original = model.sp_prepare.forward
|
||||
|
||||
def partial_split(*args):
|
||||
values = original(*args)
|
||||
if unsharded is None:
|
||||
return values
|
||||
return tuple(
|
||||
value if i == unsharded else value.chunk(2, dim=1 if i < 3 else 0)[0] for i, value in enumerate(values)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(model.sp_prepare, "forward", partial_split)
|
||||
|
||||
def forbidden(*args, **kwargs):
|
||||
raise AssertionError("attention/collectives must not run before shard validation")
|
||||
|
||||
monkeypatch.setattr(model.blocks[0], "forward", forbidden)
|
||||
with pytest.raises(RuntimeError, match="SP input hooks"):
|
||||
model(
|
||||
torch.randn(1, 36, 3, 4, 4),
|
||||
torch.tensor([1.0]),
|
||||
torch.randn(1, 3, 6),
|
||||
torch.randn(1, 384, 3, 4, 4),
|
||||
cache=cache,
|
||||
start_frame=0,
|
||||
update_cache=False,
|
||||
)
|
||||
|
||||
|
||||
# Real multi-rank regression: synthetic weights, native SP/TP and attention.
|
||||
_HEADS, _HEAD_DIM, _LAYERS = 8, 32, 2
|
||||
_FRAMES, _SIDE, _TOKENS_PER_FRAME = 3, 32, 256
|
||||
|
||||
|
||||
def _model(dtype):
|
||||
from vllm_omni.diffusion.models.lingbot_world.transformer import CausalLingBotWorldTransformer3DModel
|
||||
|
||||
model = CausalLingBotWorldTransformer3DModel(
|
||||
num_attention_heads=_HEADS,
|
||||
attention_head_dim=_HEAD_DIM,
|
||||
num_layers=_LAYERS,
|
||||
in_channels=4,
|
||||
out_channels=4,
|
||||
text_dim=8,
|
||||
freq_dim=16,
|
||||
ffn_dim=512,
|
||||
patch_size=(1, 2, 2),
|
||||
sink_size=1,
|
||||
num_frames_per_block=_FRAMES,
|
||||
sliding_window_num_frames=6,
|
||||
rope_max_seq_len=32,
|
||||
).eval()
|
||||
return model.to(device=torch.device("cuda", torch.accelerator.current_device_index()), dtype=dtype)
|
||||
|
||||
|
||||
def _checkpoint(model):
|
||||
weights = {}
|
||||
generator = torch.Generator().manual_seed(17)
|
||||
for name, param in sorted(model.named_parameters()):
|
||||
data = torch.randn(param.shape, generator=generator) * 0.02
|
||||
if "norm" in name and name.endswith("weight"):
|
||||
data.fill_(1)
|
||||
if name.endswith("modulation") and data.shape[-2] == 6:
|
||||
data[..., 2, :] = 1
|
||||
data[..., 5, :] = 1
|
||||
data = data.to(param.dtype)
|
||||
param.copy_(data)
|
||||
if ".self_attn.qkv." in name:
|
||||
for component, chunk in zip(("q", "k", "v"), data.chunk(3), strict=True):
|
||||
weights[name.replace(".self_attn.qkv.", f".self_attn.{component}.")] = chunk
|
||||
else:
|
||||
weights[name] = data
|
||||
return weights
|
||||
|
||||
|
||||
def _rollout(model, mode, dtype, batch):
|
||||
from vllm_omni.diffusion.models.lingbot_world.transformer import LingBotTransformerCache
|
||||
from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache import ARDiffusionKVCache, ARDiffusionKVConfig
|
||||
from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState
|
||||
|
||||
device = next(model.parameters()).device
|
||||
state = None
|
||||
if mode == "paged":
|
||||
kv = ARDiffusionKVCache(
|
||||
ARDiffusionKVConfig(enable=True, chunk_size=_TOKENS_PER_FRAME, window_chunks=5, sink_chunks=1),
|
||||
num_layers=_LAYERS,
|
||||
num_kv_heads=model.blocks[0].self_attn.num_sp_heads,
|
||||
head_size=_HEAD_DIM,
|
||||
dtype=dtype,
|
||||
block_size=_TOKENS_PER_FRAME,
|
||||
max_model_len=4096,
|
||||
available_bytes=1 << 27,
|
||||
kv_branches=(ARDiffusionKVBranchSpec("main", 0),),
|
||||
session_capacity=1,
|
||||
frames_per_block=_FRAMES,
|
||||
max_scratch_tokens_per_branch=_FRAMES * _TOKENS_PER_FRAME,
|
||||
cross_attention_lengths={"text": 5},
|
||||
# Cross-attention keeps every local head on every SP rank, so its pool is wider than the
|
||||
# self-attention share above.
|
||||
cross_attention_kv_heads={"text": model.blocks[0].cross_attn.num_local_heads},
|
||||
device=device,
|
||||
)
|
||||
state = ARDiffusionKVState(kv, "numeric", {"main": kv.begin_request("numeric")}, num_layers=_LAYERS)
|
||||
cache = LingBotTransformerCache(self_attention=[], cross_attention=[None] * _LAYERS)
|
||||
else:
|
||||
cache = model.allocate_cache(
|
||||
batch_size=batch, latent_height=_SIDE, latent_width=_SIDE, device=device, dtype=dtype
|
||||
)
|
||||
generator = torch.Generator().manual_seed(123)
|
||||
text = torch.randn(batch, 5, 8, generator=generator).to(device, dtype)
|
||||
outputs = []
|
||||
try:
|
||||
for start in range(0, 12, _FRAMES):
|
||||
latent = torch.randn(batch, 4, _FRAMES, _SIDE, _SIDE, generator=generator).to(device, dtype)
|
||||
camera = torch.randn(batch, 384, _FRAMES, _SIDE, _SIDE, generator=generator).to(device, dtype)
|
||||
for commit in (False, False, True):
|
||||
if state is not None:
|
||||
cache.self_attention = state.get_kv_caches(
|
||||
"main", seq_len=_FRAMES * _TOKENS_PER_FRAME, commit_current=commit
|
||||
)
|
||||
output = model(
|
||||
latent,
|
||||
torch.tensor([100.0, 400.0, 900.0], device=device).expand(batch, -1),
|
||||
text,
|
||||
camera,
|
||||
cache=cache,
|
||||
start_frame=start,
|
||||
update_cache=commit,
|
||||
)
|
||||
outputs.append(output.float().cpu())
|
||||
if state is not None:
|
||||
if not state.is_cross_attention_populated("main", "text"):
|
||||
state.populate_cross_attention(
|
||||
"main", "text", [(layer.key, layer.value) for layer in cache.cross_attention]
|
||||
)
|
||||
for layer, pool in zip(
|
||||
cache.cross_attention, state.get_cross_attention_kv("main", "text"), strict=True
|
||||
):
|
||||
layer.key, layer.value = pool["k"], pool["v"]
|
||||
state.commit_paged_context("main")
|
||||
cross = [
|
||||
(layer.key.detach().float().cpu(), layer.value.detach().float().cpu()) for layer in cache.cross_attention
|
||||
]
|
||||
return torch.stack(outputs), cross
|
||||
finally:
|
||||
if state is not None:
|
||||
state.close()
|
||||
|
||||
|
||||
def _worker(rank, world_size, sp_size, tp_size, mode, dtype, batch, rendezvous):
|
||||
from vllm.config import VllmConfig
|
||||
from vllm.config.vllm import set_current_vllm_config
|
||||
from vllm.distributed import get_tensor_model_parallel_rank
|
||||
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.distributed.parallel_state import (
|
||||
destroy_distributed_env,
|
||||
destroy_model_parallel,
|
||||
init_distributed_environment,
|
||||
initialize_model_parallel,
|
||||
)
|
||||
from vllm_omni.diffusion.forward_context import set_forward_context
|
||||
from vllm_omni.diffusion.registry import _apply_sequence_parallel_if_enabled
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
current_omni_platform.set_device(torch.device("cuda", rank))
|
||||
torch.backends.cuda.matmul.allow_tf32 = False
|
||||
torch.backends.cudnn.allow_tf32 = False
|
||||
torch.distributed.init_process_group(
|
||||
"nccl", init_method=f"file://{rendezvous}", world_size=world_size, rank=rank, timeout=timedelta(seconds=90)
|
||||
)
|
||||
init_distributed_environment(world_size=world_size, rank=rank, local_rank=rank, backend="nccl")
|
||||
try:
|
||||
with torch.inference_mode(), set_current_vllm_config(VllmConfig()):
|
||||
for baseline in (True, False):
|
||||
sp, tp = (1, 1) if baseline else (sp_size, tp_size)
|
||||
initialize_model_parallel(
|
||||
data_parallel_size=world_size // (sp * tp),
|
||||
sequence_parallel_size=sp,
|
||||
ulysses_degree=sp,
|
||||
tensor_parallel_size=tp,
|
||||
)
|
||||
config = OmniDiffusionConfig(
|
||||
model=str(Path(rendezvous).parent),
|
||||
dtype=dtype,
|
||||
enforce_eager=True,
|
||||
parallel_config=DiffusionParallelConfig(
|
||||
data_parallel_size=world_size // (sp * tp),
|
||||
sequence_parallel_size=sp,
|
||||
ulysses_degree=sp,
|
||||
tensor_parallel_size=tp,
|
||||
),
|
||||
diffusion_attention_config=AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")),
|
||||
)
|
||||
with set_current_diffusion_config(config), set_forward_context(omni_diffusion_config=config):
|
||||
model = _model(dtype)
|
||||
if baseline:
|
||||
weights = _checkpoint(model)
|
||||
else:
|
||||
model.load_weights(iter(weights.items()))
|
||||
_apply_sequence_parallel_if_enabled(SimpleNamespace(transformer=model), config)
|
||||
result, cross = _rollout(model, mode, dtype, batch)
|
||||
if baseline:
|
||||
expected, expected_cross = result, cross
|
||||
else:
|
||||
assert expected.abs().max() > 1e-3
|
||||
bound = 1e-5 if dtype == torch.float32 else 1e-2
|
||||
error = (result - expected).double().norm() / expected.double().norm()
|
||||
assert error <= bound, f"rank={rank}, TP{tp} SP{sp}: relative L2 {error.item():.3g} > {bound}"
|
||||
# Cross-attention K/V is replicated across the Ulysses group, not split over it, so
|
||||
# every SP rank holds its whole TP shard of heads. Only TP slices the cache now.
|
||||
local_heads = _HEADS // tp
|
||||
first = get_tensor_model_parallel_rank() * local_heads
|
||||
for (k, v), (ek, ev) in zip(cross, expected_cross, strict=True):
|
||||
tolerance = 1e-5 if dtype == torch.float32 else 2e-2
|
||||
torch.testing.assert_close(
|
||||
k, ek[:, :, first : first + local_heads], rtol=tolerance, atol=tolerance
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
v, ev[:, :, first : first + local_heads], rtol=tolerance, atol=tolerance
|
||||
)
|
||||
if rank == 0:
|
||||
print(f"{mode} {dtype} TP{tp} SP{sp}: relative L2={error.item():.3g}", flush=True)
|
||||
del model
|
||||
torch.distributed.barrier()
|
||||
destroy_model_parallel()
|
||||
finally:
|
||||
destroy_distributed_env()
|
||||
|
||||
|
||||
def _run(tmp_path: Path, sp, tp, mode, dtype, batch):
|
||||
if torch.accelerator.device_count() < sp * tp:
|
||||
pytest.skip(f"requires {sp * tp} GPUs")
|
||||
torch.multiprocessing.spawn(
|
||||
_worker, args=(sp * tp, sp, tp, mode, dtype, batch, str(tmp_path / "rendezvous")), nprocs=sp * tp
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=2)
|
||||
def test_sp2_direct_matches_sp1_fp32(tmp_path):
|
||||
_run(tmp_path, 2, 1, "direct", torch.float32, 2)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
def test_sp4_direct_matches_sp1_bf16(tmp_path):
|
||||
_run(tmp_path, 4, 1, "direct", torch.bfloat16, 1)
|
||||
|
||||
|
||||
@pytest.mark.parallel
|
||||
@hardware_test(res={"cuda": "L4"}, num_cards=4)
|
||||
def test_tp2_sp2_paged_matches_sp1_bf16(tmp_path):
|
||||
_run(tmp_path, 2, 2, "paged", torch.bfloat16, 1)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
@pytest.mark.parametrize("start_frame", [0, 1021, 1023, 1024, 21600])
|
||||
@pytest.mark.parametrize("dtype", [torch.float32, torch.bfloat16])
|
||||
def test_temporal_rope_matches_absolute_positions_without_growing_cache(start_frame, dtype):
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1, attention_head_dim=128, rope_max_seq_len=1024).to(dtype)
|
||||
buffers = {name: (value.data_ptr(), value.shape) for name, value in model.named_buffers()}
|
||||
cosine, sine = model._rotary_embedding(
|
||||
frames=3, height=2, width=3, start_frame=start_frame, dtype=dtype, device=torch.device("cpu")
|
||||
)
|
||||
# Independent complex-valued reference uses absolute (time, height, width)
|
||||
# coordinates, including blocks crossing the original precomputed table.
|
||||
grids = torch.meshgrid(
|
||||
torch.arange(start_frame, start_frame + 3, dtype=torch.float64),
|
||||
torch.arange(2, dtype=torch.float64),
|
||||
torch.arange(3, dtype=torch.float64),
|
||||
indexing="ij",
|
||||
)
|
||||
reference = []
|
||||
for positions, dim in zip(grids, (44, 42, 42), strict=True):
|
||||
frequencies = 1.0 / 10000 ** (torch.arange(0, dim, 2, dtype=torch.float64) / dim)
|
||||
phase = positions.flatten()[:, None] * frequencies[None, :]
|
||||
reference.append(torch.polar(torch.ones_like(phase), phase))
|
||||
expected = torch.cat(reference, dim=-1)
|
||||
torch.testing.assert_close(cosine, expected.real.float().to(dtype), rtol=0, atol=0)
|
||||
torch.testing.assert_close(sine, expected.imag.float().to(dtype), rtol=0, atol=0)
|
||||
assert {name: (value.data_ptr(), value.shape) for name, value in model.named_buffers()} == buffers
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_temporal_rope_extension_keeps_spatial_limit():
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1)
|
||||
with pytest.raises(ValueError, match="Spatial RoPE"):
|
||||
model._rotary_embedding(
|
||||
frames=3, height=17, width=1, start_frame=16, dtype=torch.float32, device=torch.device("cpu")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_the_camera_injector_runs_once_per_held_cache_and_gives_the_same_output() -> None:
|
||||
"""Two forwards sharing one caller-held cache: the injector runs once per block, outputs unchanged."""
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=2).eval()
|
||||
hidden_states = torch.randn(1, 36, 1, 4, 4)
|
||||
timestep = torch.tensor([1.0])
|
||||
encoder_hidden_states = torch.randn(1, 3, 6)
|
||||
camera_hidden_states = torch.randn(1, 6 * 8 * 8, 1, 4, 4)
|
||||
|
||||
def run(cache, camera_cache=None):
|
||||
return model(
|
||||
hidden_states,
|
||||
timestep,
|
||||
encoder_hidden_states,
|
||||
camera_hidden_states,
|
||||
cache=cache,
|
||||
start_frame=0,
|
||||
update_cache=False,
|
||||
camera_modulation_cache=camera_cache,
|
||||
)
|
||||
|
||||
# Baseline: no cache, so every forward rebuilds the injector in every block.
|
||||
uncached_first = run(_cache(module, model))
|
||||
uncached_second = run(_cache(module, model))
|
||||
injector_calls = [attention_tests._record_outputs(block.cam_injector_layer1) for block in model.blocks]
|
||||
held = module.CameraModulationCache()
|
||||
cached_first = run(_cache(module, model), held)
|
||||
cached_second = run(_cache(module, model), held)
|
||||
|
||||
# One build per block for both forwards, instead of one per block per forward.
|
||||
assert [len(calls) for calls in injector_calls] == [1, 1]
|
||||
torch.testing.assert_close(cached_first, uncached_first, rtol=0, atol=0)
|
||||
torch.testing.assert_close(cached_second, uncached_second, rtol=0, atol=0)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_timestep_projection_is_staged_into_one_static_buffer_per_shape() -> None:
|
||||
"""Two forwards' projections land in the same buffer; the blocks never see the fresh per-forward tensor."""
|
||||
module = attention_tests._load_module()
|
||||
model = _tiny_model(module, num_layers=1).eval()
|
||||
first = torch.randn(1, 8, 6, model.dim)
|
||||
second = torch.randn(1, 8, 6, model.dim)
|
||||
|
||||
staged_first = model._stage_timestep_projection(first)
|
||||
assert staged_first is not first and torch.equal(staged_first, first)
|
||||
staged_second = model._stage_timestep_projection(second)
|
||||
assert staged_second is staged_first and torch.equal(staged_second, second)
|
||||
# A different shape gets its own buffer; the first one is kept.
|
||||
other = model._stage_timestep_projection(torch.randn(1, 4, 6, model.dim))
|
||||
assert other is not staged_first and len(model._timestep_projection_buffers) == 2
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,239 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from diffusers.models.embeddings import apply_rotary_emb
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_qk_rope_signature_caches():
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
longcat._FAILED_QK_ROPE_SIGNATURES.clear()
|
||||
yield
|
||||
longcat._FAILED_QK_ROPE_SIGNATURES.clear()
|
||||
|
||||
|
||||
def _inputs(sequence: int = 5, head_dim: int = 8):
|
||||
q = torch.randn(2, sequence, 3, head_dim, dtype=torch.bfloat16)
|
||||
k = torch.randn_like(q)
|
||||
cos = torch.randn(sequence, head_dim)
|
||||
sin = torch.randn_like(cos)
|
||||
return q, k, (cos, sin)
|
||||
|
||||
|
||||
def _reference(q, k, rotary_emb):
|
||||
return (
|
||||
apply_rotary_emb(q, rotary_emb, sequence_dim=1),
|
||||
apply_rotary_emb(k, rotary_emb, sequence_dim=1),
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_rotary_emb_resolves_threshold_once_only_when_enabled(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
calls = 0
|
||||
|
||||
def resolve(default):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
assert default == 512
|
||||
return 37
|
||||
|
||||
monkeypatch.setattr(longcat, "fused_qk_norm_rope_min_tokens", resolve)
|
||||
txt_cos, txt_sin = torch.randn(2, 8), torch.randn(2, 8)
|
||||
img_cos, img_sin = torch.randn(3, 8), torch.randn(3, 8)
|
||||
native = longcat._prepare_rotary_emb(txt_cos, txt_sin, img_cos, img_sin, enable_fusion=False)
|
||||
fused = longcat._prepare_rotary_emb(txt_cos, txt_sin, img_cos, img_sin, enable_fusion=True)
|
||||
|
||||
assert calls == 1
|
||||
assert len(native) == 2
|
||||
assert len(fused) == 3 and fused[2] == 37
|
||||
assert torch.equal(native[0], fused[0])
|
||||
assert torch.equal(native[1], fused[1])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "sp_size"),
|
||||
[
|
||||
("unsupported", 1),
|
||||
("compile", 1),
|
||||
("grad", 1),
|
||||
("capture", 1),
|
||||
("sequence_parallel", 2),
|
||||
("below_threshold", 1),
|
||||
],
|
||||
)
|
||||
def test_qk_rope_non_eager_modes_use_exact_original_path(monkeypatch, mode, sp_size):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(1)
|
||||
q, k, rotary_emb = _inputs()
|
||||
expected = _reference(q, k, rotary_emb)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: mode == "compile")
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: mode == "capture")
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: mode != "unsupported")
|
||||
monkeypatch.setattr(
|
||||
longcat,
|
||||
"fused_qk_rope",
|
||||
lambda *args: (_ for _ in ()).throw(AssertionError("fallback called fused op")),
|
||||
)
|
||||
|
||||
context = nullcontext() if mode == "grad" else torch.no_grad()
|
||||
min_tokens = q.shape[0] * q.shape[1] + 1 if mode == "below_threshold" else 0
|
||||
with context:
|
||||
actual = longcat._apply_qk_rope(q, k, rotary_emb, sp_size, min_tokens)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
def test_qk_rope_eligible_path_skips_runtime_parity_check(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(2)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: True)
|
||||
fused_calls = 0
|
||||
|
||||
def fused(query, key, cos, sin):
|
||||
nonlocal fused_calls
|
||||
fused_calls += 1
|
||||
return query + 1, key + 2
|
||||
|
||||
monkeypatch.setattr(
|
||||
longcat,
|
||||
"_apply_qk_rope_reference",
|
||||
lambda *args: (_ for _ in ()).throw(AssertionError("eligible path ran the native parity check")),
|
||||
)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope", fused)
|
||||
|
||||
with torch.no_grad():
|
||||
q, k, rotary_emb = _inputs()
|
||||
actual = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
|
||||
assert fused_calls == 1
|
||||
assert torch.equal(actual[0], q + 1)
|
||||
assert torch.equal(actual[1], k + 2)
|
||||
|
||||
|
||||
def test_qk_rope_launch_failure_permanently_falls_back(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
torch.manual_seed(3)
|
||||
monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False)
|
||||
monkeypatch.setattr(torch.cuda, "is_current_stream_capturing", lambda: False)
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope_supported", lambda *args: True)
|
||||
fused_calls = 0
|
||||
|
||||
def broken_fused(query, key, cos, sin):
|
||||
nonlocal fused_calls
|
||||
fused_calls += 1
|
||||
raise RuntimeError("kernel failed")
|
||||
|
||||
monkeypatch.setattr(longcat, "fused_qk_rope", broken_fused)
|
||||
q, k, rotary_emb = _inputs()
|
||||
expected = _reference(q, k, rotary_emb)
|
||||
with torch.no_grad():
|
||||
first = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
second = longcat._apply_qk_rope(q, k, rotary_emb, 1, 0)
|
||||
|
||||
assert fused_calls == 1
|
||||
assert len(longcat._FAILED_QK_ROPE_SIGNATURES) == 1
|
||||
for actual in (first, second):
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
assert torch.equal(actual[1], expected[1])
|
||||
|
||||
|
||||
def test_failed_qk_rope_signature_cache_is_bounded():
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
for index in range(longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE + 1):
|
||||
longcat._record_failed_qk_rope_signature((index,))
|
||||
|
||||
assert len(longcat._FAILED_QK_ROPE_SIGNATURES) == longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE
|
||||
assert (0,) not in longcat._FAILED_QK_ROPE_SIGNATURES
|
||||
assert (longcat._FAILED_QK_ROPE_SIGNATURES_MAX_SIZE,) in longcat._FAILED_QK_ROPE_SIGNATURES
|
||||
|
||||
|
||||
class _FakeQKV(torch.nn.Module):
|
||||
def __init__(self, heads: int, head_dim: int) -> None:
|
||||
super().__init__()
|
||||
self.num_heads = heads
|
||||
self.num_kv_heads = heads
|
||||
self.width = heads * head_dim
|
||||
|
||||
def forward(self, x):
|
||||
return torch.cat((x, x + 1, x + 2), dim=-1), None
|
||||
|
||||
|
||||
class _AddNorm(torch.nn.Module):
|
||||
def __init__(self, value: float) -> None:
|
||||
super().__init__()
|
||||
self.value = value
|
||||
|
||||
def forward(self, x):
|
||||
return x + self.value
|
||||
|
||||
|
||||
class _CaptureAttention(torch.nn.Module):
|
||||
def forward(self, query, key, value, metadata=None):
|
||||
del key, value, metadata
|
||||
return query
|
||||
|
||||
|
||||
class _PassthroughLinear(torch.nn.Module):
|
||||
def forward(self, x):
|
||||
return x, None
|
||||
|
||||
|
||||
def test_dual_attention_keeps_native_norm_and_text_image_concat_order(monkeypatch):
|
||||
from vllm_omni.diffusion.models.longcat_image import longcat_image_transformer as longcat
|
||||
|
||||
attention = longcat.LongCatImageAttention.__new__(longcat.LongCatImageAttention)
|
||||
torch.nn.Module.__init__(attention)
|
||||
attention.parallel_config = SimpleNamespace(sequence_parallel_size=1)
|
||||
attention.head_dim = 4
|
||||
attention.added_kv_proj_dim = 8
|
||||
attention.to_qkv = _FakeQKV(2, 4)
|
||||
attention.add_kv_proj = _FakeQKV(2, 4)
|
||||
attention.norm_q = _AddNorm(10)
|
||||
attention.norm_k = _AddNorm(20)
|
||||
attention.norm_added_q = _AddNorm(100)
|
||||
attention.norm_added_k = _AddNorm(200)
|
||||
attention.attn = _CaptureAttention()
|
||||
attention.to_out = _PassthroughLinear()
|
||||
attention.to_add_out = _PassthroughLinear()
|
||||
|
||||
captured = []
|
||||
|
||||
def paired(query, key, rotary_emb, sp_size, min_tokens):
|
||||
captured.append((query.clone(), key.clone(), rotary_emb, sp_size, min_tokens))
|
||||
return query, key
|
||||
|
||||
monkeypatch.setattr(longcat, "_apply_qk_rope", paired)
|
||||
image = torch.randn(1, 3, 8)
|
||||
text = torch.randn(1, 2, 8)
|
||||
rotary_pair = (torch.randn(5, 4), torch.randn(5, 4))
|
||||
rotary_emb = (*rotary_pair, 0)
|
||||
image_out, text_out = attention(image, text, rotary_emb)
|
||||
|
||||
image_q = image.unflatten(-1, (2, 4))
|
||||
text_q = text.unflatten(-1, (2, 4))
|
||||
expected_q = torch.cat((text_q + 100, image_q + 10), dim=1)
|
||||
expected_k = torch.cat((text_q + 1 + 200, image_q + 1 + 20), dim=1)
|
||||
assert len(captured) == 1
|
||||
assert torch.equal(captured[0][0], expected_q)
|
||||
assert torch.equal(captured[0][1], expected_k)
|
||||
assert captured[0][2][0] is rotary_pair[0]
|
||||
assert captured[0][2][1] is rotary_pair[1]
|
||||
assert captured[0][3:] == (1, 0)
|
||||
assert image_out.shape == image.shape
|
||||
assert text_out.shape == text.shape
|
||||
@@ -233,6 +233,7 @@ def _fake_ltx_parallel_attention():
|
||||
|
||||
layer = object.__new__(ltx2_transformer._LTX2ParallelAttention)
|
||||
nn.Module.__init__(layer)
|
||||
layer.allow_fp32_fallback = False
|
||||
layer.attn_backend = Backend
|
||||
layer.attention = SimpleNamespace(forward=run("native"))
|
||||
layer.sdpa_fallback = SimpleNamespace(forward=run("sdpa"))
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def test_layer_regions_bracket_the_eager_kernels() -> None:
|
||||
moe_layer, dense_layer = _tiny_model().block.layers
|
||||
|
||||
assert moe_layer.region_methods == ("_attention_input", "_moe_input", "_moe_output")
|
||||
assert dense_layer.region_methods == ("_attention_input", "_dense_output")
|
||||
|
||||
|
||||
def test_compile_regions_fullgraph_matches_eager_bitwise() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
first = sampler.forward(steps[0])
|
||||
graphs = torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
second = sampler.forward(steps[1])
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip((first, second), expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
# pre adapter, post adapter, three MoE-layer regions, two dense-layer regions
|
||||
assert graphs == 7
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_forwards_config_dynamic() -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_dynamic=False,
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": False, "options": {"emulate_precision_casts": True}}]
|
||||
|
||||
|
||||
def test_pipeline_setup_compile_warns_when_granularity_is_not_regional(caplog: pytest.LogCaptureFixture) -> None:
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
class _Transformer(torch.nn.Module):
|
||||
def compile_regions(self, **compile_kwargs: object) -> None:
|
||||
calls.append(compile_kwargs)
|
||||
|
||||
pipeline = object.__new__(Magi2Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = OmniDiffusionConfig(
|
||||
model="sand-ai/MAGI-2-preview",
|
||||
model_class_name="Magi2Pipeline",
|
||||
diffusion_compile_granularity="full",
|
||||
)
|
||||
pipeline.transformer = _Transformer()
|
||||
|
||||
pipeline.setup_compile()
|
||||
|
||||
assert calls == [{"fullgraph": True, "dynamic": True, "options": {"emulate_precision_casts": True}}]
|
||||
assert "diffusion_compile_granularity='full'" in caplog.text
|
||||
|
||||
|
||||
def test_compiled_graphs_are_shared_across_layers_of_one_kind() -> None:
|
||||
model = _tiny_model(moe_layers=2)
|
||||
sampler = _tiny_sampler(model)
|
||||
step = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([900.0]), cfg_config=CFGConfig())
|
||||
expected = sampler.forward(step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = sampler.forward(step)
|
||||
|
||||
assert torch.equal(actual[0], expected[0])
|
||||
# Two MoE layers and two dense layers still compile one graph per region
|
||||
# kind: pre adapter, post adapter, three MoE regions, two dense regions.
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
|
||||
|
||||
def test_compiled_regions_survive_new_requests_without_recompiling() -> None:
|
||||
model = _tiny_model()
|
||||
sampler = _tiny_sampler(model)
|
||||
t = torch.tensor([900.0])
|
||||
first_request = sampler.prepare_model_input(**_sampler_tensors(0), t=t, cfg_config=CFGConfig())
|
||||
second_request = sampler.prepare_model_input(**_sampler_tensors(1), t=t, cfg_config=CFGConfig())
|
||||
longer_request = sampler.prepare_model_input(**_longer_text_tensors(2), t=t, cfg_config=CFGConfig())
|
||||
expected = [sampler.forward(request) for request in (first_request, second_request, longer_request)]
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
actual = [sampler.forward(first_request)]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
actual.append(sampler.forward(second_request))
|
||||
actual.append(sampler.forward(longer_request))
|
||||
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
assert torch.equal(video, video_ref)
|
||||
assert torch.equal(audio, audio_ref)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,78 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from tests.diffusion.models.magi2.test_native_packing import (
|
||||
_longer_text_tensors,
|
||||
_sampler_tensors,
|
||||
_tiny_model,
|
||||
_tiny_sampler,
|
||||
)
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.diffusion,
|
||||
pytest.mark.core_model,
|
||||
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
|
||||
pytest.mark.skipif(not current_omni_platform.is_cuda(), reason="requires CUDA"),
|
||||
]
|
||||
|
||||
|
||||
def _cuda_steps(sampler, tensors=_sampler_tensors):
|
||||
timesteps = (torch.tensor([900.0]), torch.tensor([600.0]), torch.tensor([300.0]))
|
||||
return [
|
||||
sampler.prepare_model_input(
|
||||
**{name: value.cuda() for name, value in tensors(seed).items()},
|
||||
t=t,
|
||||
cfg_config=CFGConfig(),
|
||||
)
|
||||
for seed, t in enumerate(timesteps)
|
||||
]
|
||||
|
||||
|
||||
def test_inductor_regions_match_eager_in_deterministic_mode(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("MAGI2_DETERMINISTIC", "1")
|
||||
model = _tiny_model(params_dtype=torch.bfloat16).cuda()
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _cuda_steps(sampler)
|
||||
expected = [sampler.forward(step) for step in steps]
|
||||
new_request_step = _cuda_steps(sampler)[0]
|
||||
longer_text_step = _cuda_steps(sampler, _longer_text_tensors)[0]
|
||||
expected_new_request = sampler.forward(new_request_step)
|
||||
expected_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
torch._dynamo.reset()
|
||||
graphs_before = torch._dynamo.utils.counters["stats"]["unique_graphs"]
|
||||
# The pipeline compiles with inductor emulating eager's intermediate
|
||||
# low-precision casts, so fused bf16 chains keep eager's rounding.
|
||||
model.compile_regions(fullgraph=True, dynamic=True, options={"emulate_precision_casts": True})
|
||||
first = sampler.forward(steps[0])
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
compiled = [first, sampler.forward(steps[1]), sampler.forward(steps[2])]
|
||||
repeated = [sampler.forward(step) for step in steps]
|
||||
# A second request repacks the same shapes; a third changes the
|
||||
# text length and therefore every token count.
|
||||
compiled_new_request = sampler.forward(new_request_step)
|
||||
compiled_longer_text = sampler.forward(longer_text_step)
|
||||
|
||||
# Emulated precision casts restore eager's rounding boundaries; what is
|
||||
# left is one-ulp transcendental noise that some Triton backends carry.
|
||||
for (video, audio), (video_ref, audio_ref) in zip(compiled, expected, strict=True):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
for (video, audio), (video_again, audio_again) in zip(compiled, repeated, strict=True):
|
||||
assert torch.equal(video, video_again)
|
||||
assert torch.equal(audio, audio_again)
|
||||
for (video, audio), (video_ref, audio_ref) in (
|
||||
(compiled_new_request, expected_new_request),
|
||||
(compiled_longer_text, expected_longer_text),
|
||||
):
|
||||
torch.testing.assert_close(video, video_ref, atol=1e-6, rtol=0)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=1e-6, rtol=0)
|
||||
assert torch._dynamo.utils.counters["stats"]["unique_graphs"] - graphs_before == 7
|
||||
@@ -0,0 +1,179 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
import torch.nn as nn
|
||||
from torch.distributed import init_device_mesh
|
||||
from torch.distributed.fsdp import MixedPrecisionPolicy
|
||||
|
||||
import vllm_omni.diffusion.offloader.distributed_layerwise_backend as dlo_module
|
||||
from tests.diffusion.models.magi2.test_native_distributed_parity import (
|
||||
_current_group,
|
||||
_new_groups,
|
||||
_patched_groups,
|
||||
)
|
||||
from tests.diffusion.models.magi2.test_native_packing import _tiny_config, _tiny_model, _tiny_sampler
|
||||
from tests.diffusion.offloader.helpers import patch_offload_runtime
|
||||
from vllm_omni.diffusion.distributed.hsdp import shard_model
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.parallel import Magi2ParallelGroup
|
||||
from vllm_omni.diffusion.models.magi2.pipeline_magi2 import Magi2Pipeline
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig
|
||||
from vllm_omni.diffusion.offloader.base import OffloadConfig
|
||||
from vllm_omni.diffusion.offloader.config import OffloadStrategy
|
||||
|
||||
_WORLD_SIZE = 4
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _cpu_offload_runtime():
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
patch_offload_runtime(monkeypatch, dlo_module.current_omni_platform, synchronize=True)
|
||||
yield
|
||||
|
||||
|
||||
def _enable_dlo(
|
||||
model: Magi2PreviewTransformer,
|
||||
*,
|
||||
dp_group: dist.ProcessGroup | None,
|
||||
dp_size: int,
|
||||
dp_rank: int,
|
||||
) -> None:
|
||||
pipeline = Magi2Pipeline.__new__(Magi2Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = model
|
||||
backend = dlo_module.DistributedLayerwiseOffloadBackend(
|
||||
OffloadConfig(
|
||||
strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE,
|
||||
pin_cpu_memory=False,
|
||||
dp_size=dp_size,
|
||||
dlo_use_allgather=dp_size > 1,
|
||||
),
|
||||
torch.device("cpu"),
|
||||
)
|
||||
backend.dp_group = dp_group
|
||||
backend.rank = dp_rank
|
||||
backend.enable(pipeline)
|
||||
assert backend.enabled
|
||||
|
||||
|
||||
def _enable_hsdp(model: Magi2PreviewTransformer) -> None:
|
||||
mesh = init_device_mesh("cpu", mesh_shape=(1, _WORLD_SIZE), mesh_dim_names=("replicate", "shard"))
|
||||
ignored = {
|
||||
parameter for name in model._hsdp_ignored_modules for parameter in model.get_submodule(name).parameters()
|
||||
}
|
||||
shard_model(
|
||||
model,
|
||||
mesh=mesh,
|
||||
mp_policy=MixedPrecisionPolicy(cast_forward_inputs=False),
|
||||
hsdp_shard_conditions=model._hsdp_shard_conditions,
|
||||
ignored_params=ignored,
|
||||
)
|
||||
model.requires_grad_(False)
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
# 14 tokens per CFG branch, so the packed sequence splits evenly across
|
||||
# two Ulysses ranks; gloo cannot gather uneven shards.
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _steps(sampler):
|
||||
return [
|
||||
sampler.prepare_model_input(**_sampler_tensors(seed), t=t, cfg_config=CFGConfig())
|
||||
for seed, t in enumerate((torch.tensor([900.0]), torch.tensor([450.0])))
|
||||
]
|
||||
|
||||
|
||||
def _run(model: Magi2PreviewTransformer, *, compile_regions: bool) -> list[tuple[torch.Tensor, torch.Tensor]]:
|
||||
sampler = _tiny_sampler(model)
|
||||
steps = _steps(sampler)
|
||||
if not compile_regions:
|
||||
return [sampler.forward(step) for step in steps]
|
||||
torch._dynamo.reset()
|
||||
model.compile_regions(fullgraph=True, backend="eager", dynamic=True)
|
||||
outputs = [sampler.forward(steps[0])]
|
||||
with torch._dynamo.config.patch(error_on_recompile=True):
|
||||
outputs.append(sampler.forward(steps[1]))
|
||||
return outputs
|
||||
|
||||
|
||||
def _assert_same(actual, expected, *, exact: bool, label: str) -> None:
|
||||
for (video, audio), (video_ref, audio_ref) in zip(actual, expected, strict=True):
|
||||
if exact:
|
||||
assert torch.equal(video, video_ref), label
|
||||
assert torch.equal(audio, audio_ref), label
|
||||
else:
|
||||
torch.testing.assert_close(video, video_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
torch.testing.assert_close(audio, audio_ref, atol=2e-5, rtol=2e-4, msg=label)
|
||||
|
||||
|
||||
def _distributed_worker(rank: int, rendezvous: str) -> None:
|
||||
torch.set_num_threads(1)
|
||||
dist.init_process_group("gloo", init_method=rendezvous, rank=rank, world_size=_WORLD_SIZE)
|
||||
try:
|
||||
singleton = Magi2ParallelGroup(None, world_size=1, rank=0)
|
||||
with _patched_groups(singleton, singleton):
|
||||
oracle = _tiny_model()
|
||||
checkpoint = [(name, value.detach().clone()) for name, value in oracle.state_dict().items()]
|
||||
expected = _run(oracle, compile_regions=False)
|
||||
|
||||
sp2_groups = _new_groups(((0, 1), (2, 3)))
|
||||
dp2_groups = _new_groups(((0, 2), (1, 3)))
|
||||
sp_group = _current_group(rank, sp2_groups)
|
||||
dp_ranks, dp_process_group = next((ranks, group) for ranks, group in dp2_groups if rank in ranks)
|
||||
|
||||
def dlo_rank_local(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=None, dp_size=1, dp_rank=0)
|
||||
|
||||
def dlo_allgather(model: Magi2PreviewTransformer) -> None:
|
||||
_enable_dlo(model, dp_group=dp_process_group, dp_size=len(dp_ranks), dp_rank=dp_ranks.index(rank))
|
||||
|
||||
variants = (
|
||||
("dlo_rank_local_sp2", sp_group, dlo_rank_local),
|
||||
("dlo_allgather_dp2sp2", sp_group, dlo_allgather),
|
||||
("hsdp4", singleton, _enable_hsdp),
|
||||
)
|
||||
for name, ulysses_group, enable in variants:
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(_patched_groups(singleton, ulysses_group))
|
||||
stack.enter_context(_cpu_offload_runtime())
|
||||
outputs = {}
|
||||
for compile_regions in (False, True):
|
||||
model = Magi2PreviewTransformer(_tiny_config())
|
||||
assert model.load_weights(checkpoint) == set(model.state_dict())
|
||||
enable(model)
|
||||
outputs[compile_regions] = _run(model, compile_regions=compile_regions)
|
||||
_assert_same(outputs[False], expected, exact=False, label=f"{name} eager vs single-rank oracle")
|
||||
_assert_same(outputs[True], outputs[False], exact=True, label=f"{name} compiled vs eager")
|
||||
dist.barrier()
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not dist.is_available() or not dist.is_gloo_available(),
|
||||
reason="requires torch.distributed gloo",
|
||||
)
|
||||
def test_compiled_regions_match_eager_under_dlo_and_hsdp() -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
rendezvous = f"file://{os.path.join(temp_dir, 'gloo-rendezvous')}"
|
||||
with patch.dict(os.environ, {"CUDA_VISIBLE_DEVICES": ""}):
|
||||
mp.spawn(_distributed_worker, args=(rendezvous,), nprocs=_WORLD_SIZE, join=True)
|
||||
@@ -36,6 +36,7 @@ from vllm_omni.diffusion.offloader.distributed_layerwise_backend import (
|
||||
DistributedLayerwiseOffloadBackend,
|
||||
DistributedLayerwiseOffloadHook,
|
||||
)
|
||||
from vllm_omni.diffusion.offloader.plan_resolver import ResolvedComponent
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -46,12 +47,6 @@ class _WeightSourceStub:
|
||||
prefix: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PipelineModulesStub:
|
||||
dits: list[nn.Module]
|
||||
dit_names: list[str]
|
||||
|
||||
|
||||
_WORLD_SIZE = 4
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
@@ -189,7 +184,7 @@ def _load_mmap_transform_and_reconstruct(
|
||||
backend._mmap_transforms_by_tensor_id = {}
|
||||
backend._load_weights_via_mmap(
|
||||
pipeline,
|
||||
_PipelineModulesStub(dits=[target], dit_names=["transformer"]),
|
||||
(ResolvedComponent(path="transformer", module=target, selected=True),),
|
||||
plan_result.plan,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.magi2.configuration_magi2 import (
|
||||
Magi2MHCConfig,
|
||||
Magi2MoEConfig,
|
||||
Magi2PreviewConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.layers import MultiModalityRMSNorm
|
||||
from vllm_omni.diffusion.models.magi2.mh_moe import Magi2MultiHeadMoE
|
||||
from vllm_omni.diffusion.models.magi2.modeling_magi2 import Magi2PreviewTransformer
|
||||
from vllm_omni.diffusion.models.magi2.preview_data_proxy import (
|
||||
Magi2DataProxy,
|
||||
Magi2PreviewDataProxyConfig,
|
||||
)
|
||||
from vllm_omni.diffusion.models.magi2.sampler_magi2 import CFGConfig, Magi2PreviewSampler
|
||||
|
||||
pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model]
|
||||
|
||||
|
||||
def _tiny_config(params_dtype: torch.dtype = torch.float32, *, moe_layers: int = 1) -> Magi2PreviewConfig:
|
||||
# The leading layers are multimodal with MoE, the rest single-modality
|
||||
# dense, so every layer belongs to one of two kinds.
|
||||
moe_indices = tuple(range(moe_layers))
|
||||
return Magi2PreviewConfig(
|
||||
num_layers=2 * moe_layers,
|
||||
hidden_size=16,
|
||||
head_dim=8,
|
||||
num_query_groups=2,
|
||||
video_in_channels=4,
|
||||
audio_in_channels=4,
|
||||
text_in_channels=4,
|
||||
intermediate_factor=2,
|
||||
multimodal_layers=moe_indices,
|
||||
params_dtype=params_dtype,
|
||||
mhc=Magi2MHCConfig(num_streams=2),
|
||||
moe=Magi2MoEConfig(
|
||||
num_heads=2,
|
||||
num_experts=4,
|
||||
top_k=2,
|
||||
expert_intermediate_size=8,
|
||||
shared_expert_intermediate_size=8,
|
||||
modality_shared_expert_intermediate_size=8,
|
||||
layers=moe_indices,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _tiny_model(
|
||||
seed: int = 11,
|
||||
params_dtype: torch.dtype = torch.float32,
|
||||
*,
|
||||
moe_layers: int = 1,
|
||||
) -> Magi2PreviewTransformer:
|
||||
model = Magi2PreviewTransformer(_tiny_config(params_dtype, moe_layers=moe_layers))
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
with torch.no_grad():
|
||||
for parameter in model.parameters():
|
||||
parameter.copy_(torch.randn(parameter.shape, generator=generator, dtype=parameter.dtype) * 0.02)
|
||||
for module in model.modules():
|
||||
if isinstance(module, MultiModalityRMSNorm):
|
||||
module.weight.zero_()
|
||||
elif isinstance(module, Magi2MultiHeadMoE):
|
||||
module.router.expert_bias.zero_()
|
||||
module.router.expert_bias_ema.zero_()
|
||||
return model
|
||||
|
||||
|
||||
def _tiny_sampler(model: torch.nn.Module) -> Magi2PreviewSampler:
|
||||
return Magi2PreviewSampler(model, Magi2DataProxy(Magi2PreviewDataProxyConfig(time_channel_dim=8)))
|
||||
|
||||
|
||||
def _sampler_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
return {
|
||||
"latent": torch.randn(1, 4, 2, 1, 3, generator=generator),
|
||||
"audio_latent": torch.randn(1, 5, 4, generator=generator),
|
||||
"txt_feat": torch.randn(1, 3, 4, generator=generator),
|
||||
"null_txt_feat": torch.randn(1, 2, 4, generator=generator),
|
||||
}
|
||||
|
||||
|
||||
def _longer_text_tensors(seed: int) -> dict[str, torch.Tensor]:
|
||||
tensors = _sampler_tensors(seed)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed + 100)
|
||||
tensors["txt_feat"] = torch.randn(1, 4, 4, generator=generator)
|
||||
return tensors
|
||||
|
||||
|
||||
def test_prepare_model_input_keeps_lengths_on_host() -> None:
|
||||
sampler = Magi2PreviewSampler(torch.nn.Identity())
|
||||
model_input = sampler.prepare_model_input(**_sampler_tensors(0), t=torch.tensor([500.0]), cfg_config=CFGConfig())
|
||||
|
||||
assert model_input.audio_feat_len == [5, 5]
|
||||
assert model_input.txt_feat_len == [3, 2]
|
||||
assert model_input.ref_audio_feat_len == [0, 0]
|
||||
assert model_input.ref_video_feat_len == [0, 0]
|
||||
|
||||
positive, negative = Magi2PreviewSampler._split_cfg_model_input(model_input)
|
||||
assert positive.txt_feat_len == [3]
|
||||
assert negative.txt_feat_len == [2]
|
||||
|
||||
|
||||
def test_pre_adapter_embeds_directly_in_checkpoint_dtype() -> None:
|
||||
model = _tiny_model(params_dtype=torch.bfloat16)
|
||||
packed = torch.randn(6, 4)
|
||||
indices = torch.tensor([0, 1]), torch.tensor([2, 3]), torch.tensor([4, 5])
|
||||
|
||||
with torch.no_grad():
|
||||
hidden = model.pre_adapter(packed, *indices)
|
||||
|
||||
assert hidden.dtype == torch.bfloat16
|
||||
assert hidden.shape == (6, model.config.virtual_width)
|
||||
@@ -90,6 +90,21 @@ def test_hsdp_policy_shards_individual_preview_layers():
|
||||
assert not condition("pre_adapter", model.pre_adapter)
|
||||
|
||||
|
||||
def test_layerwise_offload_contract() -> None:
|
||||
from vllm_omni.diffusion.offloader import get_blocks_from_dit
|
||||
|
||||
model = Magi2PreviewTransformer(_tiny_config(num_layers=2))
|
||||
|
||||
assert model._layerwise_offload_blocks_attrs == ["block"]
|
||||
assert "block" in dict(model.named_children())
|
||||
assert list(model.block) == list(model.block.layers)
|
||||
assert model.layers is model.block.layers
|
||||
|
||||
attr_names, blocks = get_blocks_from_dit(model)
|
||||
assert attr_names == ["block"]
|
||||
assert blocks == list(model.block.layers)
|
||||
|
||||
|
||||
def test_data_proxy_keeps_output_layout_request_scoped() -> None:
|
||||
proxy = Magi2DataProxy(Magi2PreviewDataProxyConfig(time_channel_dim=0))
|
||||
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,189 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""MammothModa2 DiT self-attention runs through the shared Omni attention layer.
|
||||
|
||||
``_reference_attention`` is the arithmetic the processor used before it was
|
||||
wired to the shared layer: SDPA over K/V replicated to the query head count,
|
||||
padded rows zeroed. Both sides read the same module weights, so any drift in
|
||||
the projections, the QK RMSNorm, the interleaved real RoPE, the softmax scale
|
||||
or the padding mask shows up as a mismatch here.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum
|
||||
from vllm_omni.diffusion.config import set_current_diffusion_config
|
||||
from vllm_omni.diffusion.data import OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import (
|
||||
TransformerBlock,
|
||||
apply_real_rotary_emb,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
# Small stand-in for the real 2520 / 21 / 7 (head_dim 120) layout.
|
||||
DIM, HEADS = 48, 6
|
||||
SEQ, BATCH = 37, 2
|
||||
|
||||
# The shared layer resolves its backend from the diffusion config at construction;
|
||||
# without one it takes the platform default, which on a CUDA host is FLASH_ATTN
|
||||
# and cannot run CPU tensors. Pin SDPA the way a deployment would.
|
||||
_SDPA_CONFIG = OmniDiffusionConfig(diffusion_attention_config={"default": {"backend": "TORCH_SDPA"}})
|
||||
|
||||
|
||||
def _block(kv_heads: int, modulation: bool = True) -> TransformerBlock:
|
||||
torch.manual_seed(0)
|
||||
with set_current_diffusion_config(_SDPA_CONFIG):
|
||||
block = TransformerBlock(
|
||||
DIM,
|
||||
HEADS,
|
||||
kv_heads,
|
||||
multiple_of=8,
|
||||
ffn_dim_multiplier=1.0,
|
||||
norm_eps=1e-5,
|
||||
modulation=modulation,
|
||||
)
|
||||
return block.eval()
|
||||
|
||||
|
||||
def _inputs(head_dim: int):
|
||||
torch.manual_seed(1)
|
||||
hidden = torch.randn(BATCH, SEQ, DIM)
|
||||
mask = torch.ones(BATCH, SEQ, dtype=torch.bool)
|
||||
mask[0, SEQ - 9 :] = False
|
||||
mask[1, SEQ - 2 :] = False
|
||||
angles = torch.rand(1, SEQ, head_dim) * 6.283
|
||||
rotary = (angles.cos(), angles.sin())
|
||||
temb = torch.randn(BATCH, min(DIM, 1024))
|
||||
return hidden, mask, rotary, temb
|
||||
|
||||
|
||||
def _reference_attention(attn, hidden, mask, rotary):
|
||||
"""The pre-change processor, kept verbatim as the oracle."""
|
||||
batch, seq, _ = hidden.shape
|
||||
query, key, value = attn.to_q(hidden), attn.to_k(hidden), attn.to_v(hidden)
|
||||
head_dim = query.shape[-1] // attn.heads
|
||||
kv_heads = key.shape[-1] // head_dim
|
||||
query = attn.norm_q(query.view(batch, seq, attn.heads, head_dim))
|
||||
key = attn.norm_k(key.view(batch, seq, kv_heads, head_dim))
|
||||
value = value.view(batch, seq, kv_heads, head_dim)
|
||||
query = apply_real_rotary_emb(query, rotary[0], rotary[1])
|
||||
key = apply_real_rotary_emb(key, rotary[0], rotary[1])
|
||||
query, key, value = query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2)
|
||||
if kv_heads < attn.heads:
|
||||
key = key.repeat_interleave(attn.heads // kv_heads, dim=1)
|
||||
value = value.repeat_interleave(attn.heads // kv_heads, dim=1)
|
||||
out = F.scaled_dot_product_attention(query, key, value, attn_mask=mask.view(batch, 1, 1, seq), scale=attn.scale)
|
||||
out = (out * mask[:, None, :, None]).transpose(1, 2).reshape(batch, seq, attn.heads * head_dim)
|
||||
return attn.to_out[1](attn.to_out[0](out))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kv_heads", [2, HEADS], ids=["gqa_3to1", "mha"])
|
||||
def test_processor_matches_previous_arithmetic(kv_heads):
|
||||
block = _block(kv_heads)
|
||||
hidden, mask, rotary, _ = _inputs(block.head_dim)
|
||||
with torch.no_grad():
|
||||
got = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=mask, image_rotary_emb=rotary
|
||||
)
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
torch.testing.assert_close(got, want, atol=1e-5, rtol=1e-5)
|
||||
assert torch.count_nonzero(got[~mask]) == 0, "padded rows must stay zero"
|
||||
|
||||
|
||||
def test_no_mask_equals_all_valid_mask():
|
||||
block = _block(2)
|
||||
hidden, _, rotary, _ = _inputs(block.head_dim)
|
||||
full = torch.ones(BATCH, SEQ, dtype=torch.bool)
|
||||
with torch.no_grad():
|
||||
unmasked = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=None, image_rotary_emb=rotary
|
||||
)
|
||||
masked = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=full, image_rotary_emb=rotary
|
||||
)
|
||||
torch.testing.assert_close(unmasked, masked, atol=1e-6, rtol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modulation", [True, False], ids=["noise_block", "context_refiner"])
|
||||
def test_block_forward_hands_native_kv_heads_to_shared_layer(monkeypatch, modulation):
|
||||
"""Through the block's real forward, on both of its branches (the modulated
|
||||
noise/refiner blocks and the unmodulated context refiner): the shared layer
|
||||
is what runs, and it receives K/V with their native head count -- no
|
||||
replication before the call."""
|
||||
block = _block(2, modulation=modulation)
|
||||
hidden, mask, rotary, temb = _inputs(block.head_dim)
|
||||
if not modulation:
|
||||
temb = None
|
||||
seen = []
|
||||
original = block.attn.omni_attn.forward
|
||||
|
||||
def spy(query, key, value, attn_metadata=None):
|
||||
seen.append((tuple(query.shape), tuple(key.shape), tuple(value.shape), attn_metadata))
|
||||
return original(query, key, value, attn_metadata)
|
||||
|
||||
monkeypatch.setattr(block.attn.omni_attn, "forward", spy)
|
||||
with torch.no_grad():
|
||||
out = block(hidden, mask, rotary, temb)
|
||||
assert out.shape == hidden.shape and torch.isfinite(out).all()
|
||||
assert len(seen) == 1
|
||||
q_shape, k_shape, v_shape, metadata = seen[0]
|
||||
assert q_shape == (BATCH, SEQ, HEADS, block.head_dim)
|
||||
assert k_shape == v_shape == (BATCH, SEQ, 2, block.head_dim)
|
||||
assert metadata is not None and metadata.attn_mask.dtype == torch.bool
|
||||
assert torch.equal(metadata.attn_mask, mask)
|
||||
|
||||
|
||||
def test_backend_selection_reaches_the_dit_layer():
|
||||
"""The diffusion config selects the backend when the DiT layer is constructed."""
|
||||
assert _block(2).attn.omni_attn.attn_backend is DiffusionAttentionBackendEnum.TORCH_SDPA.get_class()
|
||||
with set_current_diffusion_config(
|
||||
OmniDiffusionConfig(diffusion_attention_config={"default": {"backend": "FLASH_ATTN"}})
|
||||
):
|
||||
block = TransformerBlock(DIM, HEADS, 2, multiple_of=8, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
assert block.attn.omni_attn.attn_backend is DiffusionAttentionBackendEnum.FLASH_ATTN.get_class()
|
||||
|
||||
|
||||
def test_shared_layer_owns_no_parameters_and_keeps_checkpoint_keys():
|
||||
block = _block(2)
|
||||
assert sum(p.numel() for p in block.attn.omni_attn.parameters()) == 0
|
||||
assert not list(block.attn.omni_attn.buffers())
|
||||
keys = set(block.state_dict())
|
||||
assert not any("omni_attn" in key for key in keys)
|
||||
# The projections and QK norms the checkpoint provides are still where they were.
|
||||
for expected in (
|
||||
"attn.to_q.weight",
|
||||
"attn.to_k.weight",
|
||||
"attn.to_v.weight",
|
||||
"attn.to_out.0.weight",
|
||||
"attn.norm_q.weight",
|
||||
"attn.norm_k.weight",
|
||||
):
|
||||
assert expected in keys, expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("with_mask", [True, False], ids=["empty_mask", "no_mask"])
|
||||
def test_empty_text_stream_skips_the_kernel(monkeypatch, with_mask):
|
||||
"""CFG's unconditional branch carries zero text tokens (the pipeline's default
|
||||
negative_prompt_embeds has no rows), so the context refiner attends over an
|
||||
empty sequence. The flash-attention varlen fallback cannot take that
|
||||
(arange step 0), so the processor must not reach the shared layer at all."""
|
||||
block = _block(kv_heads=2, modulation=False)
|
||||
attn = block.attn
|
||||
|
||||
def _never(*args, **kwargs):
|
||||
raise AssertionError("shared layer called with an empty sequence")
|
||||
|
||||
monkeypatch.setattr(attn.omni_attn, "forward", _never)
|
||||
hidden = torch.randn(BATCH, 0, DIM)
|
||||
mask = torch.ones(BATCH, 0, dtype=torch.bool) if with_mask else None
|
||||
angles = torch.rand(1, 0, block.head_dim)
|
||||
out = attn(
|
||||
hidden_states=hidden,
|
||||
encoder_hidden_states=hidden,
|
||||
attention_mask=mask,
|
||||
image_rotary_emb=(angles.cos(), angles.sin()),
|
||||
)
|
||||
assert out.shape == (BATCH, 0, DIM)
|
||||
@@ -0,0 +1,151 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Real-shape check of the MammothModa2 DiT attention on CUDA: the shared
|
||||
backend (FLASH_ATTN by default, head_dim 120) against the pre-change arithmetic
|
||||
in bf16, with the padding mask the joint text+image sequence carries."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import vllm_omni.diffusion.attention.backends.sdpa as sdpa_backend
|
||||
from tests.helpers.mark import hardware_marks
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.mammothmoda2_dit_model import TransformerBlock
|
||||
|
||||
from .test_dit_attention import _reference_attention
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.core_model,
|
||||
pytest.mark.advanced_model,
|
||||
pytest.mark.cuda,
|
||||
*hardware_marks(res={"cuda": "L4"}, num_cards=1),
|
||||
]
|
||||
|
||||
# MammothModa2-Preview gen_dit_config: hidden 2520, 21 heads, 7 KV heads, head_dim 120.
|
||||
DIM, HEADS, KV_HEADS = 2520, 21, 7
|
||||
|
||||
|
||||
@pytest.mark.parametrize("seq", [77 + 4096, 512], ids=["t2i_1024", "short"])
|
||||
def test_real_shape_matches_previous_arithmetic_bf16(seq):
|
||||
torch.manual_seed(0)
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
.cuda()
|
||||
.to(torch.bfloat16)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(2, seq, DIM, device="cuda", dtype=torch.bfloat16)
|
||||
mask = torch.ones(2, seq, dtype=torch.bool, device="cuda")
|
||||
mask[0, seq - 300 :] = False
|
||||
mask[1, seq - 17 :] = False
|
||||
angles = torch.rand(1, seq, block.head_dim, device="cuda") * 6.283
|
||||
rotary = (angles.cos().to(torch.bfloat16), angles.sin().to(torch.bfloat16))
|
||||
with torch.no_grad():
|
||||
got = block.attn(
|
||||
hidden_states=hidden, encoder_hidden_states=hidden, attention_mask=mask, image_rotary_emb=rotary
|
||||
)
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
assert torch.isfinite(got).all()
|
||||
assert torch.count_nonzero(got[~mask]) == 0
|
||||
diff = (got.float() - want.float()).abs()[mask]
|
||||
# bf16 kernels differ in accumulation order; measured on A800: max 1.0e-3, mean 5e-5.
|
||||
assert diff.max().item() < 2e-2, diff.max().item()
|
||||
assert diff.mean().item() < 1e-3, diff.mean().item()
|
||||
|
||||
|
||||
def test_empty_text_stream_on_the_default_backend():
|
||||
"""The recipe's text-to-image request with text_guidance_scale > 1 runs the
|
||||
context refiner on a zero-token unconditional prompt. Before the guard this
|
||||
raised ``RuntimeError: step must be nonzero`` from the FA varlen fallback."""
|
||||
torch.manual_seed(0)
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5, modulation=False)
|
||||
.cuda()
|
||||
.to(torch.bfloat16)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(1, 0, DIM, device="cuda", dtype=torch.bfloat16)
|
||||
mask = torch.ones(1, 0, dtype=torch.bool, device="cuda")
|
||||
angles = torch.rand(1, 0, block.head_dim, device="cuda")
|
||||
with torch.no_grad():
|
||||
out = block.attn(
|
||||
hidden_states=hidden,
|
||||
encoder_hidden_states=hidden,
|
||||
attention_mask=mask,
|
||||
image_rotary_emb=(angles.cos().to(torch.bfloat16), angles.sin().to(torch.bfloat16)),
|
||||
)
|
||||
assert out.shape == (1, 0, DIM)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("seq", "force_gqa_fallback"),
|
||||
[(512, False), (77 + 4096, False), (32, True)],
|
||||
ids=["short", "t2i_1024", "unsupported_native_gqa"],
|
||||
)
|
||||
def test_fp32_falls_back_to_sdpa_and_matches_reference(monkeypatch, seq, force_gqa_fallback):
|
||||
"""FP32 keeps its dtype and reaches shared SDPA's GQA compatibility check.
|
||||
|
||||
Cover the long masked shape that otherwise falls back to quadratic math
|
||||
attention, and force the unsupported-GQA branch independently of hardware.
|
||||
"""
|
||||
torch.manual_seed(0)
|
||||
dt = torch.float32
|
||||
block = (
|
||||
TransformerBlock(DIM, HEADS, KV_HEADS, multiple_of=256, ffn_dim_multiplier=1.0, norm_eps=1e-5)
|
||||
.cuda()
|
||||
.to(dt)
|
||||
.eval()
|
||||
)
|
||||
hidden = torch.randn(1, seq, DIM, device="cuda", dtype=dt)
|
||||
mask = torch.ones(1, seq, dtype=torch.bool, device="cuda")
|
||||
mask[:, -13:] = False
|
||||
angles = torch.rand(1, seq, block.head_dim, device="cuda")
|
||||
rotary = (angles.cos().to(dt), angles.sin().to(dt))
|
||||
capabilities, calls, phases = [], [], []
|
||||
can_use_fused_gqa = sdpa_backend.can_sdpa_use_fused_gqa
|
||||
sdpa = torch.nn.functional.scaled_dot_product_attention
|
||||
strategy = block.attn.omni_attn._get_active_parallel_strategy()
|
||||
pre_attention, post_attention = strategy.pre_attention, strategy.post_attention
|
||||
|
||||
def prepare(*args):
|
||||
phases.append("pre")
|
||||
return pre_attention(*args)
|
||||
|
||||
def restore(*args):
|
||||
phases.append("post")
|
||||
return post_attention(*args)
|
||||
|
||||
def check_gqa(*args):
|
||||
supported = False if force_gqa_fallback else can_use_fused_gqa(*args)
|
||||
capabilities.append(supported)
|
||||
return supported
|
||||
|
||||
def record_sdpa(query, key, value, **kwargs):
|
||||
phases.append("sdpa")
|
||||
calls.append((query.shape, key.shape, value.shape, query.dtype, kwargs))
|
||||
return sdpa(query, key, value, **kwargs)
|
||||
|
||||
with torch.no_grad(), monkeypatch.context() as patch:
|
||||
patch.setattr(sdpa_backend, "can_sdpa_use_fused_gqa", check_gqa)
|
||||
patch.setattr(torch.nn.functional, "scaled_dot_product_attention", record_sdpa)
|
||||
patch.setattr(strategy, "pre_attention", prepare)
|
||||
patch.setattr(strategy, "post_attention", restore)
|
||||
got = block.attn(hidden, hidden, attention_mask=mask, image_rotary_emb=rotary)
|
||||
|
||||
assert phases == ["pre", "sdpa", "post"], "FP32 must preserve the shared parallel dispatch"
|
||||
assert len(capabilities) == len(calls) == 1, "FP32 must use shared SDPA's runtime GQA check"
|
||||
q_shape, k_shape, v_shape, dtype, kwargs = calls[0]
|
||||
expected_kv_heads = KV_HEADS if capabilities[0] else HEADS
|
||||
assert q_shape == (1, HEADS, seq, block.head_dim)
|
||||
assert k_shape == v_shape == (1, expected_kv_heads, seq, block.head_dim)
|
||||
assert dtype == got.dtype == torch.float32
|
||||
assert kwargs["enable_gqa"] == capabilities[0]
|
||||
assert kwargs["scale"] == block.attn.scale
|
||||
assert kwargs["is_causal"] is False
|
||||
assert torch.equal(kwargs["attn_mask"], mask[:, None, None, :])
|
||||
|
||||
with torch.no_grad():
|
||||
want = _reference_attention(block.attn, hidden, mask, rotary)
|
||||
assert torch.isfinite(got).all()
|
||||
assert torch.count_nonzero(got[~mask]) == 0
|
||||
diff = (got.float() - want.float()).abs()[mask]
|
||||
assert diff.max().item() < 1e-3, diff.max().item()
|
||||
@@ -0,0 +1,392 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch import nn
|
||||
|
||||
from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig, TransformerConfig
|
||||
from vllm_omni.diffusion.models.mammoth_moda2 import pipeline_mammothmoda2_dit
|
||||
from vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit import (
|
||||
MammothModa2DiTPipeline,
|
||||
_build_mammoth_config,
|
||||
_root_weight_source,
|
||||
)
|
||||
from vllm_omni.diffusion.request import OmniDiffusionRequest
|
||||
from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _raw_config() -> dict:
|
||||
return {
|
||||
"model_type": "mammothmoda2",
|
||||
"llm_config": {
|
||||
"model_type": "mammothmoda2_qwen2_5_vl",
|
||||
"text_config": {
|
||||
"model_type": "mammothmoda2_qwen2_5_vl_text",
|
||||
"hidden_size": 8,
|
||||
"gen_vocab_start_index": 100,
|
||||
},
|
||||
},
|
||||
"gen_vae_config": {"block_out_channels": [8, 8]},
|
||||
"gen_dit_config": {"hidden_size": 8, "in_channels": 4},
|
||||
}
|
||||
|
||||
|
||||
def _od_config() -> OmniDiffusionConfig:
|
||||
return OmniDiffusionConfig(
|
||||
model="/models/MammothModa2-Preview",
|
||||
model_class_name="MammothModa2DiTPipeline",
|
||||
tf_model_config=TransformerConfig.from_dict(_raw_config()),
|
||||
)
|
||||
|
||||
|
||||
def test_build_mammoth_config_uses_shared_transformer_projection() -> None:
|
||||
config = _build_mammoth_config(_od_config())
|
||||
assert config.model_type == "mammothmoda2"
|
||||
assert config.get_text_config().hidden_size == 8
|
||||
assert config.gen_dit_config == {"hidden_size": 8, "in_channels": 4}
|
||||
|
||||
|
||||
def test_build_mammoth_config_rejects_empty_shared_projection() -> None:
|
||||
config = _od_config()
|
||||
config.tf_model_config = TransformerConfig()
|
||||
with pytest.raises(ValueError, match="root checkpoint config"):
|
||||
_build_mammoth_config(config)
|
||||
|
||||
|
||||
def test_root_weight_source_loads_combined_checkpoint_once() -> None:
|
||||
source = _root_weight_source(_od_config())
|
||||
assert source.model_or_path == "/models/MammothModa2-Preview"
|
||||
assert source.subfolder is None
|
||||
assert source.prefix == ""
|
||||
assert source.fall_back_to_pt is True
|
||||
|
||||
|
||||
def test_root_weight_source_forwards_revision() -> None:
|
||||
config = _od_config()
|
||||
config.revision = "rev-7"
|
||||
assert _root_weight_source(config).revision == "rev-7"
|
||||
|
||||
|
||||
def test_pipeline_declares_native_components_and_single_request_mode_only() -> None:
|
||||
assert MammothModa2DiTPipeline._dit_modules == ["gen_transformer"]
|
||||
assert MammothModa2DiTPipeline._encoder_modules == ["gen_image_condition_refiner"]
|
||||
assert MammothModa2DiTPipeline._vae_modules == ["gen_vae"]
|
||||
assert MammothModa2DiTPipeline.supports_request_batch is False
|
||||
assert MammothModa2DiTPipeline.supports_step_execution is False
|
||||
|
||||
|
||||
def test_mammoth_postprocess_denormalizes_nonnegative_raw_vae_output() -> None:
|
||||
factory = getattr(pipeline_mammothmoda2_dit, "get_mammoth_moda2_post_process_func", None)
|
||||
assert factory is not None
|
||||
|
||||
images = factory(_od_config())(torch.zeros(1, 3, 2, 2))
|
||||
|
||||
assert len(images) == 1
|
||||
assert images[0].getpixel((0, 0)) == (128, 128, 128)
|
||||
|
||||
|
||||
def test_mammoth_postprocess_is_registered() -> None:
|
||||
from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS
|
||||
|
||||
assert _DIFFUSION_POST_PROCESS_FUNCS["MammothModa2DiTPipeline"] == "get_mammoth_moda2_post_process_func"
|
||||
|
||||
|
||||
def test_root_weight_source_rejects_missing_model_path() -> None:
|
||||
config = _od_config()
|
||||
config.model = None
|
||||
with pytest.raises(ValueError, match="model path"):
|
||||
_root_weight_source(config)
|
||||
|
||||
|
||||
def _pipeline_shell() -> MammothModa2DiTPipeline:
|
||||
pipeline = object.__new__(MammothModa2DiTPipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.config = _build_mammoth_config(_od_config())
|
||||
pipeline._llm_hidden_size = 8
|
||||
return pipeline
|
||||
|
||||
|
||||
def _batch(
|
||||
*,
|
||||
request_id: str = "req-7",
|
||||
prompt: object | None = None,
|
||||
sampling: OmniDiffusionSamplingParams | None = None,
|
||||
) -> DiffusionRequestBatch:
|
||||
if prompt is None:
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
"height": 32,
|
||||
"width": 48,
|
||||
"additional_information": {
|
||||
"full_hidden_states": torch.arange(32, dtype=torch.float32).reshape(4, 8),
|
||||
"full_token_ids": [10, 11, 100, 101],
|
||||
"answer_start_index": 2,
|
||||
},
|
||||
}
|
||||
if sampling is None:
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32,
|
||||
width=48,
|
||||
seed=42,
|
||||
guidance_scale=4.0,
|
||||
num_inference_steps=7,
|
||||
extra_args={"cfg_range": [0.2, 0.8]},
|
||||
)
|
||||
return DiffusionRequestBatch([OmniDiffusionRequest(prompt=prompt, sampling_params=sampling, request_id=request_id)])
|
||||
|
||||
|
||||
def test_parse_request_uses_standard_sampling_fields() -> None:
|
||||
parsed = _pipeline_shell()._parse_request(_batch())
|
||||
assert parsed.request_id == "req-7"
|
||||
assert (parsed.height, parsed.width) == (32, 48)
|
||||
assert parsed.num_inference_steps == 7
|
||||
assert parsed.text_guidance_scale == 4.0
|
||||
assert parsed.cfg_range == (0.2, 0.8)
|
||||
assert parsed.seed == 42
|
||||
assert parsed.answer_start_index == 2
|
||||
|
||||
|
||||
def test_parse_request_prefers_legacy_sampling_overrides() -> None:
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
guidance_scale=3.0,
|
||||
num_inference_steps=5,
|
||||
seed=9,
|
||||
extra_args={"text_guidance_scale": 6.0, "num_inference_steps": 11, "cfg_range": [0.0, 0.5]},
|
||||
)
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.text_guidance_scale == 6.0
|
||||
assert parsed.num_inference_steps == 11
|
||||
assert parsed.cfg_range == (0.0, 0.5)
|
||||
|
||||
|
||||
def test_parse_request_falls_back_to_request_level_sampling_values() -> None:
|
||||
prompt = _batch().prompts[0]
|
||||
prompt["additional_information"].update(
|
||||
text_guidance_scale=[1.5],
|
||||
num_inference_steps=[3],
|
||||
cfg_range=[0.25, 0.75],
|
||||
)
|
||||
|
||||
parsed = _pipeline_shell()._parse_request(_batch(prompt=prompt, sampling=OmniDiffusionSamplingParams()))
|
||||
|
||||
assert parsed.text_guidance_scale == 1.5
|
||||
assert parsed.num_inference_steps == 3
|
||||
assert parsed.cfg_range == (0.25, 0.75)
|
||||
|
||||
|
||||
def test_parse_request_standard_fields_precede_request_level_fallbacks() -> None:
|
||||
prompt = _batch().prompts[0]
|
||||
prompt["additional_information"].update(
|
||||
text_guidance_scale=[1.5],
|
||||
num_inference_steps=[3],
|
||||
)
|
||||
sampling = OmniDiffusionSamplingParams(guidance_scale=4.0, num_inference_steps=7)
|
||||
|
||||
parsed = _pipeline_shell()._parse_request(_batch(prompt=prompt, sampling=sampling))
|
||||
|
||||
assert parsed.text_guidance_scale == 4.0
|
||||
assert parsed.num_inference_steps == 7
|
||||
|
||||
|
||||
def test_parse_request_rejects_multiple_requests_and_outputs() -> None:
|
||||
pipeline = _pipeline_shell()
|
||||
batch = _batch()
|
||||
batch.requests.append(_batch(request_id="req-8").requests[0])
|
||||
with pytest.raises(ValueError, match="exactly one request"):
|
||||
pipeline._parse_request(batch)
|
||||
with pytest.raises(ValueError, match="num_outputs_per_prompt=1"):
|
||||
pipeline._parse_request(_batch(sampling=OmniDiffusionSamplingParams(num_outputs_per_prompt=2)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("cfg_range", "message"),
|
||||
[
|
||||
([0.5], "two values"),
|
||||
([-0.1, 0.5], "0 <= start <= end <= 1"),
|
||||
([0.7, 0.2], "0 <= start <= end <= 1"),
|
||||
([0.2, 1.1], "0 <= start <= end <= 1"),
|
||||
],
|
||||
)
|
||||
def test_parse_request_rejects_invalid_cfg_range(cfg_range, message) -> None:
|
||||
batch = _batch(sampling=OmniDiffusionSamplingParams(extra_args={"cfg_range": cfg_range}))
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
def test_parse_request_requires_real_ar_conditions() -> None:
|
||||
with pytest.raises(ValueError, match="req-missing"):
|
||||
_pipeline_shell()._parse_request(_batch(request_id="req-missing", prompt={"prompt": "draw a cat"}))
|
||||
|
||||
|
||||
def test_parse_request_rejects_hidden_state_token_count_mismatch() -> None:
|
||||
batch = _batch()
|
||||
batch.prompts[0]["additional_information"]["full_hidden_states"] = torch.zeros(3, 8)
|
||||
with pytest.raises(ValueError, match="hidden-state/token-count mismatch"):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("height", "width", "message"),
|
||||
[
|
||||
(0, 32, "Invalid image size"),
|
||||
(32, -1, "Invalid image size"),
|
||||
(30, 32, "multiples of 16"),
|
||||
(32, 31, "multiples of 16"),
|
||||
],
|
||||
)
|
||||
def test_parse_request_rejects_invalid_dimensions(height, width, message) -> None:
|
||||
batch = _batch()
|
||||
batch.prompts[0].update(height=height, width=width)
|
||||
with pytest.raises(ValueError, match=message):
|
||||
_pipeline_shell()._parse_request(batch)
|
||||
|
||||
|
||||
def test_parse_request_rejects_explicit_zero_steps() -> None:
|
||||
with pytest.raises(ValueError, match="num_inference_steps must be positive"):
|
||||
_pipeline_shell()._parse_request(_batch(sampling=OmniDiffusionSamplingParams(num_inference_steps=0)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("standard_steps", "expected_steps"), [(7, 7), (None, 50)])
|
||||
def test_parse_request_falls_through_null_legacy_step_count(standard_steps, expected_steps) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(num_inference_steps=standard_steps, extra_args={"num_inference_steps": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.num_inference_steps == expected_steps
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("standard_guidance", "expected_guidance"), [(4.0, 4.0), (None, 9.0)])
|
||||
def test_parse_request_falls_through_null_legacy_guidance(standard_guidance, expected_guidance) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(guidance_scale=standard_guidance, extra_args={"text_guidance_scale": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.text_guidance_scale == expected_guidance
|
||||
|
||||
|
||||
def test_parse_request_defaults_null_cfg_range() -> None:
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"cfg_range": None})
|
||||
parsed = _pipeline_shell()._parse_request(_batch(sampling=sampling))
|
||||
assert parsed.cfg_range == (0.0, 1.0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("generators", [[], [torch.Generator(), torch.Generator()]])
|
||||
def test_parse_request_rejects_invalid_generator_list_cardinality(generators) -> None:
|
||||
sampling = OmniDiffusionSamplingParams(generator=generators)
|
||||
with pytest.raises(ValueError, match="exactly one generator.*req-generators"):
|
||||
_pipeline_shell()._parse_request(_batch(request_id="req-generators", sampling=sampling))
|
||||
|
||||
|
||||
def test_parse_request_synthesizes_dummy_ar_conditions() -> None:
|
||||
batch = _batch(
|
||||
request_id="dummy_req_id",
|
||||
prompt={"prompt": "dummy run"},
|
||||
sampling=OmniDiffusionSamplingParams(height=512, width=512, seed=1, guidance_scale=0.0, num_inference_steps=2),
|
||||
)
|
||||
parsed = _pipeline_shell()._parse_request(batch)
|
||||
assert parsed.full_hidden_states.shape == (2, 8)
|
||||
assert parsed.full_token_ids == [0, 100]
|
||||
assert parsed.answer_start_index == 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTransformerConfig:
|
||||
in_channels: int = 4
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeTimeCaptionEmbed:
|
||||
image_embedder: nn.Module | None = None
|
||||
|
||||
|
||||
class _FakeTransformer(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(1))
|
||||
self.config = _FakeTransformerConfig()
|
||||
self.time_caption_embed = _FakeTimeCaptionEmbed()
|
||||
self.calls = 0
|
||||
|
||||
def forward(self, *, hidden_states, **kwargs):
|
||||
self.calls += 1
|
||||
return torch.zeros_like(hidden_states)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _FakeVaeConfig:
|
||||
scaling_factor: float | None = None
|
||||
shift_factor: float | None = None
|
||||
|
||||
|
||||
class _FakeVae(nn.Module):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(1))
|
||||
self.config = _FakeVaeConfig()
|
||||
|
||||
def decode(self, latents, return_dict=False):
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, 32, 48, dtype=latents.dtype),)
|
||||
|
||||
|
||||
class _FakeScheduler:
|
||||
def __init__(self) -> None:
|
||||
self.timesteps = torch.tensor([])
|
||||
self.requested_steps = None
|
||||
|
||||
def set_timesteps(self, *, num_inference_steps, device, num_tokens):
|
||||
self.requested_steps = num_inference_steps
|
||||
self.timesteps = torch.arange(num_inference_steps, device=device, dtype=torch.float32)
|
||||
|
||||
def step(self, model_pred, timestep, latents, return_dict=False):
|
||||
assert return_dict is False
|
||||
return (latents - model_pred,)
|
||||
|
||||
|
||||
def test_forward_returns_diffusion_output_with_request_sampling(mocker) -> None:
|
||||
pipeline = _pipeline_shell()
|
||||
pipeline.gen_transformer = _FakeTransformer()
|
||||
pipeline.gen_image_condition_refiner = None
|
||||
pipeline.gen_vae = _FakeVae()
|
||||
pipeline.gen_freqs_cis = torch.zeros(1)
|
||||
scheduler = _FakeScheduler()
|
||||
captured = {}
|
||||
|
||||
def fake_randn_tensor(shape, *, generator, device, dtype):
|
||||
captured["shape"] = shape
|
||||
captured["seed"] = generator.initial_seed()
|
||||
return torch.zeros(shape, device=device, dtype=dtype)
|
||||
|
||||
module = "vllm_omni.diffusion.models.mammoth_moda2.pipeline_mammothmoda2_dit"
|
||||
mocker.patch(f"{module}.FlowMatchEulerDiscreteScheduler", return_value=scheduler)
|
||||
mocker.patch(f"{module}.randn_tensor", side_effect=fake_randn_tensor)
|
||||
result = pipeline.forward(
|
||||
_batch(
|
||||
sampling=OmniDiffusionSamplingParams(
|
||||
height=32, width=48, seed=42, guidance_scale=1.0, num_inference_steps=2
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert isinstance(result, DiffusionOutput)
|
||||
assert result.output.shape == (1, 3, 32, 48)
|
||||
assert captured["shape"] == (1, 4, 4, 6)
|
||||
assert captured["seed"] == 42
|
||||
assert scheduler.requested_steps == 2
|
||||
assert pipeline.gen_transformer.calls == 2
|
||||
|
||||
|
||||
def test_forward_rejects_missing_visual_tokens_before_model_access() -> None:
|
||||
prompt = {
|
||||
"prompt": "",
|
||||
"additional_information": {
|
||||
"full_hidden_states": torch.zeros(3, 8),
|
||||
"full_token_ids": [10, 11, 12],
|
||||
"answer_start_index": 2,
|
||||
},
|
||||
}
|
||||
with pytest.raises(ValueError, match="no visual-token hidden states.*req-empty"):
|
||||
_pipeline_shell().forward(_batch(request_id="req-empty", prompt=prompt))
|
||||
@@ -0,0 +1,679 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
|
||||
from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, DiffusionParallelConfig, OmniDiffusionConfig
|
||||
from vllm_omni.diffusion.models.minimax_h3.fasth3_checkpoint import (
|
||||
FASTH3_BASE_MODEL,
|
||||
FASTH3_V2_BASE_SCHEDULE,
|
||||
FASTH3_V2_MODEL_ID,
|
||||
FastH3CheckpointSpec,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.minimax_h3_transformer import (
|
||||
MiniMaxH3DiTArchConfig,
|
||||
MiniMaxH3DiTModel,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
resolve_minimax_h3_diffusion_model_path,
|
||||
)
|
||||
from vllm_omni.diffusion.sched.sigma_schedule import DMD2SigmaSchedule
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.lora.request import LoRARequest
|
||||
from vllm_omni.model_executor.models.minimax_h3.checkpoint import (
|
||||
is_minimax_h3_modular,
|
||||
resolve_minimax_h3_model_root,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _fastvideo_metadata() -> dict[str, object]:
|
||||
"""Return the release-owned metadata read from fastvideo_inference.json."""
|
||||
return {
|
||||
"model_id": FASTH3_V2_MODEL_ID,
|
||||
"schema_version": "fasth3-inference-contract-v1",
|
||||
"dmd_denoising_steps": [999, 874, 749, 624, 500, 375, 250, 125],
|
||||
"transformer_forwards": 8,
|
||||
"video_scheduler_shift": 10.0,
|
||||
"audio_scheduler_shift": 3.0,
|
||||
"guidance_scale": 1.0,
|
||||
"attention_backend": "VIDEO_SPARSE_ATTN_H3",
|
||||
"vsa_sparsity": 0.8,
|
||||
"vsa_tile_size": 64,
|
||||
"task": "t2av",
|
||||
}
|
||||
|
||||
|
||||
def _sampling(
|
||||
*,
|
||||
num_inference_steps: int | str | None = 8,
|
||||
guidance_scale: float | None = 1.0,
|
||||
extra_args: dict[str, object] | None = None,
|
||||
lora_request: LoRARequest | None = None,
|
||||
timesteps: torch.Tensor | None = None,
|
||||
sigmas: list[float] | None = None,
|
||||
) -> OmniDiffusionSamplingParams:
|
||||
return OmniDiffusionSamplingParams(
|
||||
# Keep the string form to exercise malformed client input validation.
|
||||
num_inference_steps=num_inference_steps, # type: ignore[arg-type]
|
||||
guidance_scale=guidance_scale,
|
||||
extra_args=extra_args or {},
|
||||
lora_request=lora_request,
|
||||
timesteps=timesteps,
|
||||
sigmas=sigmas,
|
||||
)
|
||||
|
||||
|
||||
def _od_config(
|
||||
*,
|
||||
backend: str = "FASTVIDEO_VSA",
|
||||
topk: int | None = None,
|
||||
per_role: dict[str, AttentionSpec] | None = None,
|
||||
lora_path: str | None = None,
|
||||
ring_degree: int = 1,
|
||||
allgather_degree: int = 1,
|
||||
) -> OmniDiffusionConfig:
|
||||
attention_config = AttentionConfig(
|
||||
default=AttentionSpec(backend=backend, fastvideo_vsa_topk=topk),
|
||||
per_role=per_role or {},
|
||||
)
|
||||
return OmniDiffusionConfig(
|
||||
diffusion_attention_config=attention_config,
|
||||
lora_path=lora_path,
|
||||
parallel_config=DiffusionParallelConfig(ring_degree=ring_degree, allgather_degree=allgather_degree),
|
||||
)
|
||||
|
||||
|
||||
def test_v2_release_has_nine_sigma_nodes_and_eight_intervals():
|
||||
assert FASTH3_V2_BASE_SCHEDULE.base_schedule == (
|
||||
0.999,
|
||||
0.874,
|
||||
0.749,
|
||||
0.624,
|
||||
0.5,
|
||||
0.375,
|
||||
0.25,
|
||||
0.125,
|
||||
0.0,
|
||||
)
|
||||
assert len(FASTH3_V2_BASE_SCHEDULE.base_schedule) == 9
|
||||
assert FASTH3_V2_BASE_SCHEDULE.num_inference_steps == 8
|
||||
assert FASTH3_V2_BASE_SCHEDULE.shifted_sigmas(10.0) == pytest.approx(
|
||||
[
|
||||
0.9998999099189271,
|
||||
0.985788405143244,
|
||||
0.9675752486758817,
|
||||
0.94316807738815,
|
||||
0.9090909090909091,
|
||||
0.8571428571428571,
|
||||
0.7692307692307693,
|
||||
0.5882352941176471,
|
||||
0.0,
|
||||
],
|
||||
abs=1e-12,
|
||||
)
|
||||
assert FASTH3_V2_BASE_SCHEDULE.shifted_sigmas(3.0) == pytest.approx(
|
||||
[
|
||||
0.9996664442961973,
|
||||
0.9541484716157204,
|
||||
0.8995196156925539,
|
||||
0.8327402135231315,
|
||||
0.75,
|
||||
0.6428571428571429,
|
||||
0.5,
|
||||
0.3,
|
||||
0.0,
|
||||
],
|
||||
abs=1e-12,
|
||||
)
|
||||
|
||||
|
||||
def test_fastvideo_metadata_builds_release_sampling_metadata():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
|
||||
assert spec.vsa_sparsity == 0.8
|
||||
assert spec.release_metadata() == {
|
||||
"partition": "fl2va",
|
||||
"tasks": ["t2va"],
|
||||
"sigma_shift_scales": {"video": 10.0, "audio": 3.0},
|
||||
"base_schedule": list(FASTH3_V2_BASE_SCHEDULE.base_schedule),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
[
|
||||
("model_id", "FastVideo/FastH3-other"),
|
||||
("schema_version", "fasth3-inference-contract-v0"),
|
||||
("dmd_denoising_steps", [999, 749, 500, 250]),
|
||||
("transformer_forwards", 4),
|
||||
("video_scheduler_shift", 12.0),
|
||||
("audio_scheduler_shift", 4.0),
|
||||
("guidance_scale", 2.0),
|
||||
("attention_backend", "FLASH_ATTN"),
|
||||
("vsa_sparsity", 0.9),
|
||||
("vsa_tile_size", 32),
|
||||
("task", "t2va"),
|
||||
],
|
||||
)
|
||||
def test_fastvideo_metadata_rejects_contract_drift(field, value):
|
||||
metadata = _fastvideo_metadata()
|
||||
metadata[field] = value
|
||||
|
||||
with pytest.raises(ValueError, match=field):
|
||||
FastH3CheckpointSpec.from_metadata(metadata)
|
||||
|
||||
|
||||
def test_fastvideo_metadata_rejects_missing_contract_field():
|
||||
metadata = _fastvideo_metadata()
|
||||
metadata.pop("task")
|
||||
|
||||
with pytest.raises(ValueError, match="task"):
|
||||
FastH3CheckpointSpec.from_metadata(metadata)
|
||||
|
||||
|
||||
def test_legacy_h3_metadata_is_not_treated_as_fastvideo_release():
|
||||
with pytest.raises(ValueError, match="model_id"):
|
||||
FastH3CheckpointSpec.from_metadata(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"partition": "fl2va",
|
||||
"tasks": ["t2va", "fl2va"],
|
||||
"sigma_shift_scales": {"video": 12.0, "audio": 3.0},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_v2_serving_contract_accepts_backend_selected_for_h3_self_role():
|
||||
config = _od_config(
|
||||
backend="TORCH_SDPA",
|
||||
per_role={"self": AttentionSpec(backend="FASTVIDEO_VSA")},
|
||||
)
|
||||
FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).check_serving_contract(
|
||||
partition="fl2va", od_config=config
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "partition", "match"),
|
||||
[
|
||||
({}, "ref2va", "task-type fl2va"),
|
||||
({"lora_path": "/tmp/adapter"}, "fl2va", "LoRA"),
|
||||
({"backend": "TORCH_SDPA"}, "fl2va", "FASTVIDEO_VSA"),
|
||||
({"topk": 2}, "fl2va", "fixed fastvideo_vsa_topk"),
|
||||
({"ring_degree": 2}, "fl2va", "local attention"),
|
||||
({"allgather_degree": 2}, "fl2va", "local attention"),
|
||||
],
|
||||
)
|
||||
def test_v2_serving_contract_rejects_incompatible_runtime(kwargs, partition, match):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(ValueError, match=match):
|
||||
spec.check_serving_contract(partition=partition, od_config=_od_config(**kwargs))
|
||||
|
||||
|
||||
def test_v2_serving_contract_reads_a_per_role_fixed_topk_override():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
config = _od_config(
|
||||
per_role={"self": AttentionSpec(backend="FASTVIDEO_VSA", fastvideo_vsa_topk=1)},
|
||||
)
|
||||
with pytest.raises(ValueError, match="fixed fastvideo_vsa_topk"):
|
||||
spec.check_serving_contract(partition="fl2va", od_config=config)
|
||||
|
||||
|
||||
def test_v2_request_contract_accepts_omitted_steps_and_default_shifts():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
spec.check_request(_sampling(num_inference_steps=None))
|
||||
spec.check_request(_sampling(extra_args={"flow_shift": 10, "audio_flow_shift": 3}, guidance_scale=1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sampling",
|
||||
[
|
||||
_sampling(num_inference_steps=None),
|
||||
_sampling(timesteps=torch.tensor([0.9, 0.5, 0.0])),
|
||||
_sampling(sigmas=[1.0, 0.5, 0.0]),
|
||||
],
|
||||
)
|
||||
def test_v2_step_execution_requires_pinned_count_and_schedule(sampling):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(OmniClientError, match="step execution"):
|
||||
spec.check_request(sampling, step_execution=True)
|
||||
|
||||
|
||||
def test_v2_step_execution_accepts_only_explicit_pinned_count():
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
spec.check_request(_sampling(num_inference_steps=8), step_execution=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("kwargs", "match"),
|
||||
[
|
||||
({"num_inference_steps": 7}, "num_inference_steps=8"),
|
||||
({"num_inference_steps": 9}, "num_inference_steps=8"),
|
||||
({"num_inference_steps": "8"}, "num_inference_steps=8"),
|
||||
({"extra_args": {"flow_shift": 9}}, "flow_shift=10"),
|
||||
({"extra_args": {"audio_flow_shift": 4}}, "audio_flow_shift=3"),
|
||||
({"extra_args": {"flow_shift": "bad"}}, "flow_shift=10"),
|
||||
({"guidance_scale": 2.0}, "guidance_scale=1"),
|
||||
(
|
||||
{"lora_request": LoRARequest(lora_name="test", lora_int_id=1, lora_path="/tmp/test.safetensors")},
|
||||
"per-request LoRA",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_v2_request_contract_rejects_sampling_drift(kwargs, match):
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
with pytest.raises(OmniClientError, match=match):
|
||||
spec.check_request(_sampling(**kwargs))
|
||||
|
||||
|
||||
def test_resolve_native_vaes_uses_pinned_revision_and_only_vae_patterns(tmp_path, monkeypatch):
|
||||
model_root = tmp_path / "FastH3"
|
||||
model_root.mkdir()
|
||||
revision = "base-revision-123"
|
||||
(model_root / "provenance.json").write_text(
|
||||
json.dumps({"base_model": f"hf://{FASTH3_BASE_MODEL}@{revision}"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
snapshot = tmp_path / "hf-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"vllm_omni.diffusion.models.minimax_h3.fasth3_checkpoint.download_weights_from_hf_specific",
|
||||
fake_download,
|
||||
)
|
||||
|
||||
result = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).resolve_native_vaes(model_root)
|
||||
|
||||
assert result == snapshot / "FL2VA"
|
||||
assert calls == {
|
||||
"model_name_or_path": FASTH3_BASE_MODEL,
|
||||
"cache_dir": None,
|
||||
"allow_patterns": ["FL2VA/video_vae/**", "FL2VA/audio_vae/**"],
|
||||
"revision": revision,
|
||||
"require_all": True,
|
||||
}
|
||||
assert sorted(path.name for path in model_root.iterdir()) == ["provenance.json"]
|
||||
assert not (model_root / "model_index.json").exists()
|
||||
assert not (model_root / "conversion.json").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"base_model",
|
||||
[
|
||||
"MiniMaxAI/MiniMax-H3@main",
|
||||
"hf://MiniMaxAI/MiniMax-H3@",
|
||||
"hf://Other/MiniMax-H3@revision",
|
||||
"",
|
||||
None,
|
||||
],
|
||||
)
|
||||
def test_resolve_native_vaes_rejects_unpinned_provenance(tmp_path, base_model):
|
||||
model_root = tmp_path / "FastH3"
|
||||
model_root.mkdir()
|
||||
(model_root / "provenance.json").write_text(json.dumps({"base_model": base_model}), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="provenance"):
|
||||
FastH3CheckpointSpec.from_metadata(_fastvideo_metadata()).resolve_native_vaes(model_root)
|
||||
|
||||
|
||||
def test_modular_local_model_root_uses_text_encoder_without_partition_files(tmp_path):
|
||||
root = tmp_path / "FastH3"
|
||||
(root / "text_encoder").mkdir(parents=True)
|
||||
(root / "text_encoder" / "config.json").write_text("{}", encoding="utf-8")
|
||||
(root / "modular_model_index.json").write_text(
|
||||
json.dumps({"_class_name": "MiniMaxH3ModularPipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert is_minimax_h3_modular(str(root)) is True
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "fl2va") == str(root / "text_encoder")
|
||||
assert not (root / "FL2VA").exists()
|
||||
|
||||
|
||||
def test_modular_hub_model_root_downloads_only_text_encoder(monkeypatch, tmp_path):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import checkpoint as checkpoint_module
|
||||
|
||||
snapshot = tmp_path / "modular-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_index(model: str, *, revision: str | None = None) -> dict[str, str]:
|
||||
assert model == "FastVideo/FastVideo-FastH3-8-Step-V2"
|
||||
assert revision == "release-revision"
|
||||
return {"_class_name": "MiniMaxH3ModularPipeline"}
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(checkpoint_module, "get_diffusion_model_index", fake_index)
|
||||
monkeypatch.setattr(checkpoint_module, "download_weights_from_hf_specific", fake_download)
|
||||
|
||||
result = resolve_minimax_h3_model_root(
|
||||
"FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"release-revision",
|
||||
"fl2va",
|
||||
)
|
||||
|
||||
assert result == str(snapshot / "text_encoder")
|
||||
assert calls == {
|
||||
"model_name_or_path": "FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"cache_dir": None,
|
||||
"allow_patterns": ["text_encoder/**"],
|
||||
"revision": "release-revision",
|
||||
"require_all": True,
|
||||
}
|
||||
|
||||
|
||||
def test_native_hub_model_with_modular_class_keeps_partition_layout(monkeypatch):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import checkpoint as checkpoint_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
checkpoint_module,
|
||||
"get_diffusion_model_index",
|
||||
lambda _model, *, revision=None: {"_class_name": "MiniMaxH3ModularPipeline"},
|
||||
)
|
||||
|
||||
assert checkpoint_module.is_minimax_h3_modular("MiniMaxAI/MiniMax-H3") is False
|
||||
|
||||
|
||||
def test_native_local_model_root_keeps_partition_layout(tmp_path):
|
||||
root = tmp_path / "MiniMax-H3"
|
||||
(root / "FL2VA" / "text_encoder").mkdir(parents=True)
|
||||
(root / "FL2VA" / "text_encoder" / "config.json").write_text("{}", encoding="utf-8")
|
||||
|
||||
assert is_minimax_h3_modular(str(root)) is False
|
||||
assert resolve_minimax_h3_model_root(str(root), None, "fl2va") == str(root / "FL2VA" / "text_encoder")
|
||||
|
||||
|
||||
def test_diffusion_resolver_returns_local_modular_root_without_partition_files(tmp_path):
|
||||
root = tmp_path / "FastH3"
|
||||
root.mkdir()
|
||||
(root / "modular_model_index.json").write_text(
|
||||
json.dumps({"_class_name": "MiniMaxH3ModularPipeline"}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert resolve_minimax_h3_diffusion_model_path(str(root), None, "fl2va") == str(root)
|
||||
assert not (root / "FL2VA").exists()
|
||||
assert not (root / "Ref2VA").exists()
|
||||
|
||||
|
||||
def test_diffusion_resolver_hub_modular_downloads_transformer_contract_only(monkeypatch, tmp_path):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as pipeline_module
|
||||
|
||||
snapshot = tmp_path / "modular-snapshot"
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
def fake_modular(_model: str, _revision: str | None = None) -> bool:
|
||||
return True
|
||||
|
||||
def fake_download(**kwargs: object) -> str:
|
||||
calls.update(kwargs)
|
||||
snapshot.mkdir()
|
||||
return str(snapshot)
|
||||
|
||||
monkeypatch.setattr(pipeline_module, "is_minimax_h3_modular", fake_modular)
|
||||
monkeypatch.setattr(pipeline_module, "download_weights_from_hf_specific", fake_download)
|
||||
|
||||
result = resolve_minimax_h3_diffusion_model_path(
|
||||
"FastVideo/FastVideo-FastH3-8-Step-V2", "release-revision", "fl2va"
|
||||
)
|
||||
|
||||
assert result == str(snapshot)
|
||||
assert calls == {
|
||||
"model_name_or_path": "FastVideo/FastVideo-FastH3-8-Step-V2",
|
||||
"cache_dir": None,
|
||||
"allow_patterns": [
|
||||
"modular_model_index.json",
|
||||
"fastvideo_inference.json",
|
||||
"provenance.json",
|
||||
"transformer/**",
|
||||
],
|
||||
"revision": "release-revision",
|
||||
"require_all": True,
|
||||
}
|
||||
patterns = calls["allow_patterns"]
|
||||
assert isinstance(patterns, list)
|
||||
assert all("video_vae" not in pattern and "audio_vae" not in pattern for pattern in patterns)
|
||||
|
||||
|
||||
def test_diffusers_arch_config_normalizes_all_aliases():
|
||||
arch = MiniMaxH3DiTArchConfig.from_mapping(
|
||||
{
|
||||
"num_refiner_layers": 3,
|
||||
"ffn_dim": 12,
|
||||
"in_channels": 4,
|
||||
"audio_in_channels": 6,
|
||||
"freq_dim": 8,
|
||||
"time_embed_hidden_dim": 10,
|
||||
"rope_freq_dim": 5,
|
||||
"patch_size": [1, 2, 2],
|
||||
}
|
||||
)
|
||||
|
||||
assert arch.token_refiner_num_layers == 3
|
||||
assert arch.ffn_hidden_size == 12
|
||||
assert arch.latents_dim == 4
|
||||
assert arch.audio_latents_dim == 6
|
||||
assert arch.timestep_input_dim == 8
|
||||
assert arch.time_embed_hidden_size == 10
|
||||
assert arch.rope_inv_freq_len == 5
|
||||
assert arch.patch_size == (1, 2, 2)
|
||||
|
||||
|
||||
class _WeightTarget(nn.Module):
|
||||
def __init__(self, shape: tuple[int, ...], loader: Callable[..., None]) -> None:
|
||||
super().__init__()
|
||||
self.weight = nn.Parameter(torch.zeros(shape))
|
||||
self.weight.weight_loader = loader
|
||||
|
||||
|
||||
class _PipelineLoadTransformer(nn.Module):
|
||||
def __init__(self, loaded: set[str]) -> None:
|
||||
super().__init__()
|
||||
self.arch = MiniMaxH3DiTArchConfig(num_layers=1)
|
||||
self._loaded = loaded
|
||||
|
||||
def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
|
||||
list(weights)
|
||||
return self._loaded
|
||||
|
||||
def post_load_weights(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def test_pipeline_load_weights_rejects_missing_fast_h3_compression_gate():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = _PipelineLoadTransformer({"blocks.0.attn.qkv_proj.weight"})
|
||||
pipeline.video_vae = None
|
||||
pipeline.audio_vae = None
|
||||
pipeline._fasth3 = None
|
||||
pipeline._fasth3_checkpoint = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
|
||||
with pytest.raises(ValueError, match="missing compression gates"):
|
||||
pipeline.load_weights([("transformer.blocks.0.attn.qkv_proj.weight", torch.zeros(1))])
|
||||
|
||||
|
||||
def _load_fixture(
|
||||
*, diffusers_weights: bool
|
||||
) -> tuple[MiniMaxH3DiTModel, list[tuple[str | None, torch.Tensor]], list[tuple[int, torch.Tensor]]]:
|
||||
model = object.__new__(MiniMaxH3DiTModel)
|
||||
nn.Module.__init__(model)
|
||||
model.arch = MiniMaxH3DiTArchConfig(
|
||||
num_layers=1,
|
||||
hidden_size=3,
|
||||
num_attention_heads=2,
|
||||
attention_head_dim=2,
|
||||
ffn_hidden_size=6,
|
||||
rope_inv_freq_len=2,
|
||||
)
|
||||
model._diffusers_weights = diffusers_weights
|
||||
model._rope_theta = 10000.0
|
||||
qkv_calls: list[tuple[str | None, torch.Tensor]] = []
|
||||
fc1_calls: list[tuple[int, torch.Tensor]] = []
|
||||
|
||||
def qkv_loader(param: nn.Parameter, loaded_weight: torch.Tensor, shard_id: str | None = None) -> None:
|
||||
if shard_id is None:
|
||||
assert loaded_weight.shape == (12, 3)
|
||||
param.data.copy_(loaded_weight)
|
||||
qkv_calls.append((None, loaded_weight.clone()))
|
||||
return
|
||||
assert loaded_weight.shape == (4, 3)
|
||||
assert shard_id in {"q", "k", "v"}
|
||||
offset = {"q": 0, "k": 4, "v": 8}[shard_id]
|
||||
param.data[offset : offset + 4].copy_(loaded_weight)
|
||||
qkv_calls.append((shard_id, loaded_weight.clone()))
|
||||
|
||||
def fc1_loader(param: nn.Parameter, loaded_weight: torch.Tensor, shard_id: int) -> None:
|
||||
assert loaded_weight.shape == (3, 3)
|
||||
param.data[shard_id * 3 : (shard_id + 1) * 3].copy_(loaded_weight)
|
||||
fc1_calls.append((shard_id, loaded_weight.clone()))
|
||||
|
||||
model.blocks = nn.ModuleList([nn.Module()])
|
||||
model.blocks[0].attn = nn.Module()
|
||||
model.blocks[0].attn.qkv_proj = _WeightTarget((12, 3), qkv_loader)
|
||||
model.blocks[0].mlp = nn.Module()
|
||||
model.blocks[0].mlp.fc1 = _WeightTarget((6, 3), fc1_loader)
|
||||
model.rope = nn.Module()
|
||||
model.rope.register_buffer("inv_freq", torch.zeros(2))
|
||||
return model, qkv_calls, fc1_calls
|
||||
|
||||
|
||||
def test_diffusers_load_weights_uses_shape_aware_qkv_and_mlp_slices():
|
||||
model, qkv_calls, fc1_calls = _load_fixture(diffusers_weights=True)
|
||||
q = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 10
|
||||
k = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 100
|
||||
v = torch.arange(12, dtype=torch.float32).reshape(4, 3) + 200
|
||||
mlp = torch.arange(18, dtype=torch.float32).reshape(6, 3) + 300
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", q),
|
||||
("transformer_blocks.0.attn.to_k.weight", k),
|
||||
("transformer_blocks.0.attn.to_v.weight", v),
|
||||
("transformer_blocks.0.ff.net.0.proj.weight", mlp),
|
||||
]
|
||||
)
|
||||
|
||||
assert loaded == {
|
||||
"blocks.0.attn.qkv_proj.weight",
|
||||
"blocks.0.mlp.fc1.weight",
|
||||
"rope.inv_freq",
|
||||
}
|
||||
assert [shard_id for shard_id, _ in qkv_calls] == ["q", "k", "v"]
|
||||
assert [shard_id for shard_id, _ in fc1_calls] == [0, 1]
|
||||
torch.testing.assert_close(model.blocks[0].attn.qkv_proj.weight, torch.cat((q, k, v)))
|
||||
torch.testing.assert_close(model.blocks[0].mlp.fc1.weight, torch.cat((mlp[3:], mlp[:3])))
|
||||
torch.testing.assert_close(
|
||||
model.rope.inv_freq,
|
||||
1.0 / (10000.0 ** (torch.arange(0, 4, 2, dtype=torch.float32) / 4)),
|
||||
rtol=0,
|
||||
atol=0,
|
||||
)
|
||||
|
||||
|
||||
def test_native_load_weights_keeps_grouped_qkv_and_gate_first_layouts():
|
||||
model, qkv_calls, fc1_calls = _load_fixture(diffusers_weights=False)
|
||||
grouped_qkv = torch.arange(36, dtype=torch.float32).reshape(12, 3)
|
||||
fc1 = torch.arange(18, dtype=torch.float32).reshape(6, 3)
|
||||
|
||||
loaded = model.load_weights(
|
||||
[
|
||||
("blocks.0.attn.qkv_proj.weight", grouped_qkv),
|
||||
("blocks.0.mlp.fc1.weight", fc1),
|
||||
]
|
||||
)
|
||||
|
||||
expected_qkv = torch.cat(
|
||||
(
|
||||
grouped_qkv[:2],
|
||||
grouped_qkv[6:8],
|
||||
grouped_qkv[2:4],
|
||||
grouped_qkv[8:10],
|
||||
grouped_qkv[4:6],
|
||||
grouped_qkv[10:],
|
||||
)
|
||||
)
|
||||
assert loaded == {"blocks.0.attn.qkv_proj.weight", "blocks.0.mlp.fc1.weight"}
|
||||
assert [shard_id for shard_id, _ in qkv_calls] == [None]
|
||||
assert [shard_id for shard_id, _ in fc1_calls] == [0, 1]
|
||||
torch.testing.assert_close(model.blocks[0].attn.qkv_proj.weight, expected_qkv)
|
||||
torch.testing.assert_close(model.blocks[0].mlp.fc1.weight, fc1)
|
||||
assert torch.equal(model.rope.inv_freq, torch.zeros(2))
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_unknown_name():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="unsupported Diffusers H3 weight"):
|
||||
model.load_weights([("transformer_blocks.0.attn.not_a_projection.weight", torch.zeros(4, 3))])
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_duplicate_source_name():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
weight = torch.zeros(4, 3)
|
||||
|
||||
with pytest.raises(ValueError, match="duplicate Diffusers H3 weight"):
|
||||
model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", weight),
|
||||
("transformer_blocks.0.attn.to_q.weight", weight),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_incomplete_qkv_group():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="incomplete Diffusers H3 QKV"):
|
||||
model.load_weights(
|
||||
[
|
||||
("transformer_blocks.0.attn.to_q.weight", torch.zeros(4, 3)),
|
||||
("transformer_blocks.0.attn.to_k.weight", torch.zeros(4, 3)),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_diffusers_load_weights_rejects_missing_compression_gate_parameter():
|
||||
model, _, _ = _load_fixture(diffusers_weights=True)
|
||||
|
||||
with pytest.raises(ValueError, match="has no model parameter"):
|
||||
model.load_weights([("transformer_blocks.0.attn.to_gate_compress.weight", torch.zeros(4, 3))])
|
||||
|
||||
|
||||
def test_resolve_sigma_positions_uses_release_metadata_schedule():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
nn.Module.__init__(pipeline)
|
||||
pipeline._fasth3 = None
|
||||
pipeline._lora_sigma_schedules = {}
|
||||
spec = FastH3CheckpointSpec.from_metadata(_fastvideo_metadata())
|
||||
pipeline._base_schedule_by_partition = {
|
||||
"fl2va": DMD2SigmaSchedule.from_metadata(spec.release_metadata()),
|
||||
}
|
||||
|
||||
positions, steps = pipeline._resolve_sigma_positions("t2va", _sampling())
|
||||
assert positions == FASTH3_V2_BASE_SCHEDULE.base_schedule
|
||||
assert steps == 8 == len(positions) - 1
|
||||
with pytest.raises(OmniClientError, match="must be 8"):
|
||||
pipeline._resolve_sigma_positions("t2va", _sampling(num_inference_steps=9))
|
||||
@@ -0,0 +1,158 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import os
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.distributed as dist
|
||||
import torch.multiprocessing as mp
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def _collective_worker(rank, rendezvous, backend):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.platforms import current_omni_platform
|
||||
|
||||
torch.set_num_threads(1)
|
||||
device = torch.device("cuda", rank) if backend == "nccl" else torch.device("cpu")
|
||||
if backend == "nccl":
|
||||
current_omni_platform.set_device(device)
|
||||
dist.init_process_group(
|
||||
backend, init_method=f"file://{rendezvous}", rank=rank, world_size=2, timeout=timedelta(seconds=45)
|
||||
)
|
||||
try:
|
||||
module._dit_rank_world = lambda: (dist.group.WORLD, rank, 2)
|
||||
|
||||
class VisualCodec:
|
||||
fail = False
|
||||
|
||||
def is_distributed_enabled(self):
|
||||
return True
|
||||
|
||||
def collective(self):
|
||||
value = torch.ones(1, device=device)
|
||||
dist.all_reduce(value)
|
||||
assert value.item() == 2
|
||||
|
||||
def encode_image(self, image):
|
||||
self.collective()
|
||||
assert image.size == (32, 32)
|
||||
return torch.ones(1, 96)
|
||||
|
||||
def encode_video(self, frames):
|
||||
self.collective()
|
||||
assert frames.shape == (4, 32, 32, 3)
|
||||
if self.fail and rank == 1:
|
||||
raise ValueError("codec failed on rank 1")
|
||||
return torch.full((1, 96), 2.0), (1, 2, 2)
|
||||
|
||||
class AudioCodec:
|
||||
def encode_waveform(self, waveform, sample_rate):
|
||||
assert rank == 0
|
||||
assert sample_rate == 32000
|
||||
assert waveform.shape == (64000,)
|
||||
return torch.full((160, 32), 3.0), 80
|
||||
|
||||
model = object.__new__(module.MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.device = device
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model.video_vae = VisualCodec()
|
||||
model.audio_vae = AudioCodec()
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.text_encoder_tp_size = 2
|
||||
model.text_encoder_group = SimpleNamespace(
|
||||
world_size=2, ranks=[0, 1], rank_in_group=rank, device_group=dist.group.WORLD
|
||||
)
|
||||
model.processor = SimpleNamespace(image_processor=SimpleNamespace(merge_size=2))
|
||||
model.tokenizer = lambda text, **kwargs: {"input_ids": [1, 2, 3]}
|
||||
|
||||
def encode_text(ids, kwargs):
|
||||
assert ids.tolist() == [1, 2, 3]
|
||||
assert not kwargs
|
||||
hidden = torch.ones(3, 5120, device=device, dtype=torch.bfloat16)
|
||||
dist.all_reduce(hidden)
|
||||
return hidden
|
||||
|
||||
model._encode_text_hidden = encode_text
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, extra_args={"task": "t2va", "aspect_ratio": "1:1"}
|
||||
)
|
||||
text_conditioning, window_text = model._prepare_local_conditioning({"prompt": "test"}, sampling)
|
||||
assert window_text is None
|
||||
torch.testing.assert_close(text_conditioning.hidden_states, torch.full_like(text_conditioning.hidden_states, 2))
|
||||
assert text_conditioning.visual_condition is None
|
||||
|
||||
media = (
|
||||
MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va",
|
||||
height=32,
|
||||
width=32,
|
||||
num_frames=107,
|
||||
latent_t=32,
|
||||
audio_t=178,
|
||||
images=(torch.zeros(32, 32, 3, dtype=torch.uint8),),
|
||||
videos=(torch.zeros(4, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
audios=((torch.zeros(64000), 32000),),
|
||||
)
|
||||
if rank == 0
|
||||
else None
|
||||
)
|
||||
conditioning = model._encode_local_media(media)
|
||||
assert [block["kind"] for block in conditioning.ref_blocks] == ["image", "video", "audio"]
|
||||
torch.testing.assert_close(
|
||||
conditioning.visual_condition.cpu(), torch.cat([torch.ones(1, 96), torch.full((1, 96), 2.0)])
|
||||
)
|
||||
torch.testing.assert_close(conditioning.audio_condition.cpu(), torch.full((160, 32), 3.0))
|
||||
assert conditioning.audio_condition_lengths == (80,)
|
||||
|
||||
model.video_vae.fail = True
|
||||
with pytest.raises(OmniClientError, match="codec failed on rank 1"):
|
||||
model._encode_local_media(media)
|
||||
with pytest.raises(OmniClientError, match="non-empty prompt"):
|
||||
model._prepare_local_conditioning({"prompt": ""}, sampling)
|
||||
model.video_vae.fail = False
|
||||
assert model._encode_local_media(media).visual_condition_shapes == ((1, 2, 2), (1, 2, 2))
|
||||
finally:
|
||||
dist.destroy_process_group()
|
||||
|
||||
|
||||
def _run_collectives(tmp_path, backend):
|
||||
context = mp.spawn(_collective_worker, args=(str(tmp_path / "rendezvous"), backend), nprocs=2, join=False)
|
||||
deadline = time.monotonic() + 150
|
||||
try:
|
||||
while not context.join(timeout=1):
|
||||
if time.monotonic() > deadline:
|
||||
pytest.fail("MiniMax H3 collectives did not finish within 150 seconds")
|
||||
finally:
|
||||
for process in context.processes:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=5)
|
||||
|
||||
|
||||
@pytest.mark.cpu
|
||||
def test_two_rank_conditioning_and_errors_gloo(tmp_path):
|
||||
_run_collectives(tmp_path, "gloo")
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.parallel
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("VLLM_TEST_MINIMAX_H3_COLLECTIVES") != "1",
|
||||
reason="set VLLM_TEST_MINIMAX_H3_COLLECTIVES=1 with two reserved GPUs",
|
||||
)
|
||||
def test_two_rank_conditioning_and_errors_nccl(tmp_path):
|
||||
if torch.accelerator.device_count() < 2:
|
||||
pytest.skip("requires two GPUs")
|
||||
_run_collectives(tmp_path, "nccl")
|
||||
@@ -0,0 +1,231 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.continuation import (
|
||||
diffuse_continuation,
|
||||
plan_continuation_windows,
|
||||
resolve_continuation,
|
||||
)
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import minimax_h3_packed_sequence_ref2va_blocks
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("vary_prompts", [False, True])
|
||||
@pytest.mark.parametrize("total,window", [(1807, 277), (1807, 243), (124, 277)])
|
||||
def test_continuation_retains_prefix_and_global_stereo_timeline(total, window, vary_prompts):
|
||||
audio_t = round(total * 40 / 24)
|
||||
source = torch.arange(2 * audio_t * 32).reshape(2, audio_t, 32).float()
|
||||
calls = []
|
||||
plan = plan_continuation_windows(total, window, 22)
|
||||
texts = [(torch.full((3 + i, 8), float(i)), torch.ones(3 + i, dtype=torch.long)) for i in range(len(plan))]
|
||||
|
||||
def sample(**kw):
|
||||
calls.append(kw)
|
||||
t = kw["latent_t"]
|
||||
if vary_prompts:
|
||||
assert kw["text_embeddings"] is texts[len(calls) - 1][0]
|
||||
assert kw["text_tags"] is texts[len(calls) - 1][1]
|
||||
assert kw["media_time_origin"] == len(texts[-1][0])
|
||||
# Different constants expose replacement of an old prefix or an
|
||||
# accidental append of the hidden overlap.
|
||||
video = torch.full((1, 24, t, 2, 2), float(len(calls)))
|
||||
audio = kw["locked_audio_rows"].reshape(2, kw["audio_t"], 32).permute(0, 2, 1)
|
||||
if len(calls) > 1:
|
||||
assert torch.all(kw["visual_condition"] == len(calls) - 1)
|
||||
assert kw["ref_blocks"][-1]["kind"] == "latent_guide"
|
||||
return video, audio
|
||||
|
||||
kwargs = dict(
|
||||
num_frames=total,
|
||||
latent_t=(total - 5) // 17 * 5 + 2,
|
||||
latent_h=2,
|
||||
latent_w=2,
|
||||
audio_t=audio_t,
|
||||
locked_audio_rows=source.reshape(-1, 32),
|
||||
)
|
||||
video, audio = diffuse_continuation(
|
||||
sample, kwargs, window_frames=window, overlap_frames=22, text_conditioning=texts if vary_prompts else None
|
||||
)
|
||||
torch.testing.assert_close(audio, source.permute(0, 2, 1))
|
||||
plan = plan_continuation_windows(total, window, 22)
|
||||
assert [call["temporal_offset"] for call in calls] == pytest.approx([part.start * 40 / 24 for part in plan])
|
||||
offset = 0
|
||||
for i, part in enumerate(plan):
|
||||
new_frames = part.end - part.start - part.overlap
|
||||
count = (new_frames - 5) // 17 * 5 + 2 if i == 0 else new_frames // 17 * 5
|
||||
assert torch.all(video[:, :, offset : offset + count] == i + 1)
|
||||
offset += count
|
||||
assert offset == video.shape[2] == kwargs["latent_t"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("temporal_offset", [0.0, 425.0, 221 * 40 / 24])
|
||||
def test_latent_guide_shares_target_clock_and_remains_condition_only(temporal_offset):
|
||||
packed = minimax_h3_packed_sequence_ref2va_blocks(
|
||||
temporal_offset=temporal_offset,
|
||||
text_len=3,
|
||||
latent_t=12,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=68,
|
||||
ref_blocks=[
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "latent_guide", "latent_t": 7, "latent_h": 4, "latent_w": 4, "ref_audio_t": 37},
|
||||
],
|
||||
)
|
||||
pos = packed["img_position_ids"]
|
||||
visual = packed["img_pos"]
|
||||
target = visual[packed["update_mask"]]
|
||||
guide = visual[~packed["update_mask"]][4:]
|
||||
torch.testing.assert_close(pos[guide], pos[target[:28]])
|
||||
audio = packed["audio_pos"]
|
||||
guide_audio = audio[~packed["audio_update_mask"]].reshape(2, 37)
|
||||
target_audio = audio[packed["audio_update_mask"]].reshape(2, 68)
|
||||
torch.testing.assert_close(pos[guide_audio], pos[target_audio[:, :37]])
|
||||
assert pos[target[0], 0] == pytest.approx(4 + temporal_offset)
|
||||
torch.testing.assert_close(pos[:3, 0], torch.arange(3, dtype=torch.float64))
|
||||
assert torch.all(pos[3:7, 0] == 3) # static reference image stays fixed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra,step",
|
||||
[
|
||||
({"long_video_mode": "bad"}, False),
|
||||
({"long_video_mode": "continuation"}, True),
|
||||
({"long_video_mode": "continuation", "continuation_overlap_frames": 23}, False),
|
||||
({"long_video_mode": "continuation", "continuation_window_frames": True}, False),
|
||||
({"long_video_mode": "continuation", "continuation_overlap_frames": 277}, False),
|
||||
],
|
||||
)
|
||||
def test_invalid_continuation_options(extra, step):
|
||||
with pytest.raises(OmniClientError):
|
||||
resolve_continuation(extra, task="ref2va", step_execution=step)
|
||||
|
||||
|
||||
def test_global_offset_moves_temporal_references_but_not_images_or_padding():
|
||||
kwargs = dict(
|
||||
text_len=3,
|
||||
latent_t=12,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=68,
|
||||
ref_blocks=[
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "audio", "ref_audio_t": 3},
|
||||
{"kind": "video_audio", "ref_audio_t": 2, "latent_t": 2, "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "image", "latent_h": 4, "latent_w": 4},
|
||||
{"kind": "latent_guide", "ref_audio_t": 37, "latent_t": 7, "latent_h": 4, "latent_w": 4},
|
||||
],
|
||||
)
|
||||
base = minimax_h3_packed_sequence_ref2va_blocks(**kwargs)
|
||||
shifted = minimax_h3_packed_sequence_ref2va_blocks(**kwargs, temporal_offset=425.0)
|
||||
moving = base["image_mask"] | base["audio_mask"]
|
||||
moving[3:7] = False # first static image
|
||||
moving[25:29] = False # static image after temporal references
|
||||
delta = shifted["img_position_ids"] - base["img_position_ids"]
|
||||
torch.testing.assert_close(delta[:, 0], moving.double() * 425)
|
||||
assert torch.count_nonzero(delta[:, 1:]) == 0
|
||||
for key, value in base.items():
|
||||
if isinstance(value, torch.Tensor) and key != "img_position_ids":
|
||||
torch.testing.assert_close(shifted[key], value)
|
||||
|
||||
|
||||
def test_long_ref2va_defaults_to_continuation_with_explicit_full_opt_out():
|
||||
assert resolve_continuation({"long_video": True}, task="ref2va", step_execution=False) == (277, 22)
|
||||
assert resolve_continuation({}, task="ref2va", step_execution=False) is None
|
||||
assert (
|
||||
resolve_continuation({"long_video": True, "long_video_mode": "full"}, task="ref2va", step_execution=False)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_different_chunk_prompt_lengths_keep_the_same_media_clock():
|
||||
kwargs = dict(
|
||||
latent_t=7,
|
||||
latent_h=4,
|
||||
latent_w=4,
|
||||
audio_t=37,
|
||||
ref_blocks=[{"kind": "image", "latent_h": 4, "latent_w": 4}],
|
||||
media_time_origin=20,
|
||||
)
|
||||
first = minimax_h3_packed_sequence_ref2va_blocks(text_len=3, **kwargs)
|
||||
second = minimax_h3_packed_sequence_ref2va_blocks(text_len=15, temporal_offset=425, **kwargs)
|
||||
for key, mask in (("img_pos", "update_mask"), ("audio_pos", "audio_update_mask")):
|
||||
a = first["img_position_ids"][first[key][first[mask]]]
|
||||
b = second["img_position_ids"][second[key][second[mask]]]
|
||||
torch.testing.assert_close(b[:, 0] - a[:, 0], torch.full_like(a[:, 0], 425))
|
||||
torch.testing.assert_close(b[:, 1:], a[:, 1:])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prompt_count", [None, 7, 6])
|
||||
def test_request_encodes_only_window_prompts_and_prepares_references_once(monkeypatch, prompt_count):
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaConditioning
|
||||
|
||||
pipeline = object.__new__(module.MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.load_text_encoder = True
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.od_config = None
|
||||
pipeline._active_turbo_spec = lambda sampling: None
|
||||
pipeline._has_active_native_lora = lambda sampling: False
|
||||
pipeline._resolve_task = lambda *args, **kwargs: "ref2va"
|
||||
encoded, prepared_inputs, encoded_media = [], [], []
|
||||
prepare = module.prepare_encoder_inputs
|
||||
|
||||
def prepare_once(*args, **kwargs):
|
||||
result = prepare(*args, **kwargs)
|
||||
prepared_inputs.append(result)
|
||||
return result
|
||||
|
||||
def encode_text(prepared):
|
||||
encoded.append(prepared)
|
||||
return torch.full((3, 5120), len(encoded), dtype=torch.bfloat16), torch.zeros(3, dtype=torch.long)
|
||||
|
||||
def encode_media(media):
|
||||
encoded_media.append(media)
|
||||
return MiniMaxH3EncoderMediaConditioning(
|
||||
task=media.task,
|
||||
height=media.height,
|
||||
width=media.width,
|
||||
num_frames=media.num_frames,
|
||||
latent_t=media.latent_t,
|
||||
audio_t=media.audio_t,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module, "prepare_encoder_inputs", prepare_once)
|
||||
pipeline.encode_prompt = encode_text
|
||||
pipeline._encode_local_media = encode_media
|
||||
pipeline._prepare_encoder_conditioning_inputs = lambda conditioning, sampling: {
|
||||
"text_embeddings": conditioning.hidden_states,
|
||||
"continuation": (277, 22),
|
||||
"num_frames": conditioning.num_frames,
|
||||
"task": conditioning.task,
|
||||
}
|
||||
extra = {"task": "ref2va", "long_video": True, "duration": 75}
|
||||
if prompt_count is not None:
|
||||
extra["continuation_prompts"] = [f"window {i}" for i in range(prompt_count)]
|
||||
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
|
||||
request = {"prompt": "main prompt", "multi_modal_data": {"image": Image.new("RGB", (256, 256))}}
|
||||
if prompt_count == 6:
|
||||
with pytest.raises(OmniClientError, match="exactly 7"):
|
||||
pipeline._prepare_request_inputs(request, sampling)
|
||||
assert not encoded and not encoded_media
|
||||
return
|
||||
context = pipeline._prepare_request_inputs(request, sampling)
|
||||
assert [item.prompt for item in encoded] == extra.get("continuation_prompts", ["main prompt"])
|
||||
assert len(prepared_inputs) == len(encoded_media) == 1
|
||||
assert all(item.media is prepared_inputs[0].media for item in encoded)
|
||||
if prompt_count is not None:
|
||||
texts = context["continuation_text_conditioning"]
|
||||
assert len(texts) == 7
|
||||
assert texts[0][0] is context["text_embeddings"]
|
||||
assert [int(hidden[0, 0]) for hidden, _ in texts] == list(range(1, 8))
|
||||
else:
|
||||
assert "continuation_text_conditioning" not in context
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,133 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pipeline():
|
||||
model = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(model)
|
||||
model.load_text_encoder = True
|
||||
model.load_vae_encoder = True
|
||||
model.device = torch.device("cpu")
|
||||
model.partition = "combined"
|
||||
model.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"})
|
||||
model.default_video_shift = 12.0
|
||||
model.default_audio_shift = 3.0
|
||||
model.od_config = SimpleNamespace(enable_layerwise_offload=False)
|
||||
model._quality_policy = SimpleNamespace(resolve=Mock(return_value=SimpleNamespace(cache_dit=None)))
|
||||
model._cache_dit_runtime = SimpleNamespace(prepare=Mock())
|
||||
model.encode_prompt = Mock(
|
||||
return_value=(torch.ones(2, 5120, dtype=torch.bfloat16), torch.ones(2, dtype=torch.long))
|
||||
)
|
||||
model.video_vae = Mock()
|
||||
model.video_vae.is_distributed_enabled.return_value = False
|
||||
model.video_vae.encode_image.side_effect = lambda image: torch.ones((image.height // 32) * (image.width // 32), 96)
|
||||
model.audio_vae = Mock()
|
||||
model.transformer = torch.nn.Identity()
|
||||
model.diffuse = Mock(return_value=(torch.zeros(1), torch.zeros(1)))
|
||||
model.decode = Mock(return_value=(torch.zeros(1, 3, 1, 32, 32), torch.zeros(1)))
|
||||
return model
|
||||
|
||||
|
||||
def _request(task):
|
||||
prompt: dict[str, Any] = {"prompt": "A person waves."}
|
||||
if task != "t2va":
|
||||
prompt["multi_modal_data"] = {"image": Image.new("RGB", (256, 256))}
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
height=32, width=32, num_frames=96, num_inference_steps=2, extra_args={"task": task, "aspect_ratio": "1:1"}
|
||||
)
|
||||
return SimpleNamespace(prompts=[prompt], sampling_params=sampling)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
|
||||
def test_single_stage_encodes_without_stage_zero(pipeline, task):
|
||||
output = pipeline.forward(_request(task))
|
||||
pipeline.encode_prompt.assert_called_once()
|
||||
assert pipeline.video_vae.encode_image.call_count == int(task != "t2va")
|
||||
pipeline.diffuse.assert_called_once()
|
||||
assert pipeline.diffuse.call_args.kwargs["task"] == task
|
||||
video, audio = output.output
|
||||
torch.testing.assert_close(video, torch.zeros(1, 1, 32, 32, 3, dtype=torch.uint8))
|
||||
torch.testing.assert_close(audio, pipeline.decode.return_value[1])
|
||||
|
||||
|
||||
def test_mixed_reference_conditioning_matches_stage_zero_handoff(pipeline, monkeypatch):
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.data_entry_keys import flatten_payload
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3TextConditioning,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder import MiniMaxH3Encoder
|
||||
from vllm_omni.model_executor.stage_input_processors.minimax_h3 import encoder2diffusion, prepare_encoder_prompt
|
||||
|
||||
frames = np.zeros((48, 32, 32, 3), dtype=np.uint8)
|
||||
decode_frames = Mock(return_value=frames)
|
||||
sampler = Mock(wraps=processing.sample_reference_video_frames)
|
||||
monkeypatch.setattr(processing, "load_video_frames", decode_frames)
|
||||
monkeypatch.setattr(processing, "sample_reference_video_frames", sampler)
|
||||
monkeypatch.setattr(
|
||||
processing,
|
||||
"prepare_reference_videos",
|
||||
Mock(
|
||||
return_value=[{"prepared_path": "prepared.mp4", "original_path": "original.mp4", "input_has_audio": True}]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(processing, "load_video_audio", Mock(return_value=(torch.full((64000,), 3.0), 32000)))
|
||||
pipeline.video_vae.encode_video.return_value = (torch.full((1, 96), 2.0), (1, 2, 2))
|
||||
|
||||
def encode_audio(waveform, sample_rate):
|
||||
length = round(waveform.shape[-1] / sample_rate * 40)
|
||||
return torch.full((length * 2, 32), float(waveform[0])), length
|
||||
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_audio
|
||||
request = _request("ref2va")
|
||||
request.prompts[0]["multi_modal_data"].update(video="original.mp4", audio=(torch.full((192000,), 4.0), 32000))
|
||||
pipeline.forward(request)
|
||||
local_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
|
||||
transformed = prepare_encoder_prompt(request.prompts[0], [request.sampling_params])
|
||||
stage_zero = object.__new__(MiniMaxH3Encoder)
|
||||
torch.nn.Module.__init__(stage_zero)
|
||||
stage_zero._component_leader = True
|
||||
stage_zero.video_vae = pipeline.video_vae
|
||||
stage_zero.audio_vae = pipeline.audio_vae
|
||||
media = stage_zero._encode_media(stage_zero._media_input(transformed["additional_information"]))
|
||||
text = MiniMaxH3TextConditioning(*pipeline.encode_prompt.return_value)
|
||||
payload = MiniMaxH3EncoderConditioning.from_components(text, media).to_omni_payload()
|
||||
source = SimpleNamespace(finished=True, outputs=[SimpleNamespace(multimodal_output=flatten_payload(payload))])
|
||||
bridged = encoder2diffusion([source], request.prompts[0])
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.encode_prompt.reset_mock()
|
||||
pipeline.forward(SimpleNamespace(prompts=[bridged], sampling_params=request.sampling_params))
|
||||
external_kwargs = pipeline.diffuse.call_args.kwargs
|
||||
for key, value in local_kwargs.items():
|
||||
if isinstance(value, torch.Tensor):
|
||||
torch.testing.assert_close(value, external_kwargs[key])
|
||||
else:
|
||||
assert value == external_kwargs[key]
|
||||
pipeline.encode_prompt.assert_not_called()
|
||||
assert decode_frames.call_count == sampler.call_count == 2
|
||||
assert all(call.kwargs["decoded_frames"] is frames for call in sampler.call_args_list)
|
||||
assert [block["kind"] for block in local_kwargs["ref_blocks"]] == ["image", "video_audio", "audio"]
|
||||
assert local_kwargs["audio_condition_lengths"] == [80, round(107 / 24 * 40)]
|
||||
assert all(
|
||||
call.args[0].shape[-1] == round(107 / 24 * 32000)
|
||||
for call in pipeline.audio_vae.encode_waveform.call_args_list[1::2]
|
||||
)
|
||||
@@ -262,7 +262,10 @@ def test_a_vsa_variant_is_recognised_by_its_compression_gates(tmp_path):
|
||||
|
||||
def test_a_tensor_naming_no_h3_parameter_is_an_error(tmp_path):
|
||||
path = tmp_path / "fasth3" / "adapter_model.safetensors"
|
||||
_write_adapter(path, tensors={"transformer_blocks.0.attn.norm_q.lora_A.weight": torch.ones((_RANK, _HIDDEN))})
|
||||
_write_adapter(
|
||||
path,
|
||||
tensors={"transformer_blocks.0.attn.not_a_projection.lora_A.weight": torch.ones((_RANK, _HIDDEN))},
|
||||
)
|
||||
# Dropping it silently would load a model that is not the distilled student.
|
||||
with pytest.raises(FastH3AdapterError, match="name no known"):
|
||||
_load(path.parent)
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Pipeline-level MiniMax H3 latent initialization tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_prepare_edit_video_clones_the_last_frame_with_lossless_rgb(monkeypatch, tmp_path):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import reference_video
|
||||
|
||||
source = tmp_path / "source.webm"
|
||||
commands: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
reference_video,
|
||||
"_probe_video",
|
||||
lambda _path: {"frame_count": 12, "audio_codecs": ("opus",)},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reference_video.subprocess,
|
||||
"run",
|
||||
lambda command, **_kwargs: commands.append(command),
|
||||
)
|
||||
|
||||
prepared = reference_video.prepare_edit_video(
|
||||
source,
|
||||
target_width=448,
|
||||
target_height=256,
|
||||
target_frame_count=73,
|
||||
workdir=str(tmp_path / "prepared"),
|
||||
)
|
||||
|
||||
assert prepared["input_has_audio"] is True
|
||||
assert prepared["frame_count"] == 73
|
||||
command = commands[0]
|
||||
assert command[command.index("-vf") + 1].endswith("tpad=stop_mode=clone:stop=-1")
|
||||
assert command[command.index("-c:v") + 1] == "libx264rgb"
|
||||
assert command[command.index("-pix_fmt") + 1] == "rgb24"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source_audio", [None, "override.wav"])
|
||||
def test_encoder_preparation_routes_edit_sources_without_qwen_inputs(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
source_audio,
|
||||
):
|
||||
from vllm_omni.model_executor.models.minimax_h3 import encoder_processing as processing
|
||||
|
||||
latent_t, audio_t = 7, 37
|
||||
video_clean = torch.full((42, 96), 3.0)
|
||||
audio_source = torch.zeros(4, 32)
|
||||
audio_source[:, 0] = torch.tensor([1.0, 2.0, 11.0, 12.0])
|
||||
waveform = torch.zeros(3_200)
|
||||
frames = torch.zeros(22, 64, 96, 3, dtype=torch.uint8).numpy()
|
||||
calls: dict[str, Any] = {}
|
||||
|
||||
def prepare_video(value, **kwargs):
|
||||
calls["prepare"] = (value, kwargs)
|
||||
return {
|
||||
"original_path": "source.mov",
|
||||
"prepared_path": str(tmp_path / "prepared.mp4"),
|
||||
"input_has_audio": True,
|
||||
}
|
||||
|
||||
def load_video_audio(value, **kwargs):
|
||||
calls["video_audio"] = (value, kwargs)
|
||||
return waveform, 32_000
|
||||
|
||||
def load_audio_file(value):
|
||||
calls["audio_file"] = value
|
||||
return waveform, 32_000
|
||||
|
||||
class VideoVAE:
|
||||
def encode_video(self, value):
|
||||
calls["video_encode_shape"] = value.shape
|
||||
return video_clean.clone(), (latent_t, 4, 6)
|
||||
|
||||
class AudioVAE:
|
||||
def encode_waveform(self, value, sample_rate):
|
||||
calls["audio_encode"] = (value.shape, sample_rate)
|
||||
return audio_source.clone(), 2
|
||||
|
||||
monkeypatch.setattr(processing, "resolve_minimax_h3_shape", lambda *_args: (64, 96, 22, latent_t, audio_t))
|
||||
monkeypatch.setattr(processing, "prepare_edit_video", prepare_video)
|
||||
monkeypatch.setattr(processing, "load_video_frames", lambda _path: frames)
|
||||
monkeypatch.setattr(processing, "load_video_audio", load_video_audio)
|
||||
monkeypatch.setattr(processing, "load_audio_file", load_audio_file)
|
||||
|
||||
prepared = processing.prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "edit the clip",
|
||||
"multi_modal_data": {
|
||||
"source_video": "source.mov",
|
||||
"source_audio": source_audio,
|
||||
"video_noise_mask": torch.zeros(1, 1, latent_t, 4, 6),
|
||||
"audio_noise_mask": torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).reshape(1, 1, -1),
|
||||
},
|
||||
},
|
||||
SimpleNamespace(extra_args={"task": "t2va"}),
|
||||
)
|
||||
media = type(prepared.media).from_mm_tensors(
|
||||
prepared.media.to_mm_tensors(),
|
||||
prepared.media.to_metadata(),
|
||||
)
|
||||
conditioning = processing.encode_media(
|
||||
media,
|
||||
video_vae=VideoVAE(),
|
||||
audio_vae=AudioVAE(),
|
||||
emit_conditioning=True,
|
||||
)
|
||||
|
||||
assert prepared.images == []
|
||||
assert prepared.qwen_videos == []
|
||||
assert prepared.condition_labels == []
|
||||
assert prepared.media.images == ()
|
||||
assert prepared.media.videos == ()
|
||||
assert calls["prepare"][0] == "source.mov"
|
||||
assert calls["prepare"][1]["target_width"] == 96
|
||||
assert calls["prepare"][1]["target_height"] == 64
|
||||
assert calls["prepare"][1]["target_frame_count"] == 22
|
||||
if source_audio is None:
|
||||
assert calls["video_audio"] == (
|
||||
"source.mov",
|
||||
{"duration_seconds": pytest.approx(22 / 24)},
|
||||
)
|
||||
assert "audio_file" not in calls
|
||||
else:
|
||||
assert calls["audio_file"] == "override.wav"
|
||||
assert "video_audio" not in calls
|
||||
|
||||
assert conditioning is not None
|
||||
torch.testing.assert_close(conditioning.video_edit_clean_rows, video_clean)
|
||||
assert conditioning.video_edit_mask.shape == (latent_t, 4, 6)
|
||||
torch.testing.assert_close(conditioning.video_edit_mask, torch.zeros(latent_t, 4, 6))
|
||||
assert conditioning.audio_edit_source_t == 2
|
||||
assert conditioning.audio_edit_mask.shape == (2, audio_t)
|
||||
torch.testing.assert_close(
|
||||
conditioning.audio_edit_clean_rows[:, 0],
|
||||
torch.tensor([1.0, 2.0, *([0.0] * (audio_t - 2)), 11.0, 12.0, *([0.0] * (audio_t - 2))]),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
conditioning.audio_edit_mask,
|
||||
torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).repeat(2, 1),
|
||||
)
|
||||
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import (
|
||||
MiniMaxH3EncoderConditioning,
|
||||
MiniMaxH3TextConditioning,
|
||||
)
|
||||
|
||||
combined = MiniMaxH3EncoderConditioning.from_components(
|
||||
MiniMaxH3TextConditioning(
|
||||
torch.zeros(1, 5120, dtype=torch.bfloat16),
|
||||
torch.ones(1, dtype=torch.int64),
|
||||
),
|
||||
conditioning,
|
||||
)
|
||||
round_trip = MiniMaxH3EncoderConditioning.from_omni_payload(combined.to_omni_payload())
|
||||
torch.testing.assert_close(round_trip.video_edit_clean_rows, video_clean)
|
||||
torch.testing.assert_close(round_trip.video_edit_mask, torch.zeros(latent_t, 4, 6))
|
||||
torch.testing.assert_close(round_trip.audio_edit_clean_rows, conditioning.audio_edit_clean_rows)
|
||||
torch.testing.assert_close(
|
||||
round_trip.audio_edit_mask,
|
||||
torch.tensor([0.0, 0.5, *([0.0] * (audio_t - 2))]).repeat(2, 1),
|
||||
)
|
||||
assert round_trip.audio_edit_source_t == 2
|
||||
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.default_video_shift = 12.0
|
||||
pipeline.default_audio_shift = 3.0
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
pipeline._fasth3 = None
|
||||
pipeline._active_turbo_spec = lambda _sampling: None
|
||||
pipeline._has_active_native_lora = lambda _sampling: False
|
||||
pipeline._resolve_task = lambda task, **_kwargs: task
|
||||
pipeline._resolve_sigma_positions = lambda _task, _sampling: (None, 3)
|
||||
pipeline._quality_policy = SimpleNamespace(resolve=lambda **_kwargs: SimpleNamespace(cache_dit=None))
|
||||
pipeline._cache_dit_runtime = SimpleNamespace(prepare=lambda _plan: None)
|
||||
context = pipeline._prepare_encoder_conditioning_inputs(
|
||||
round_trip,
|
||||
SimpleNamespace(extra_args={}, quality=None, seed=17, num_outputs_per_prompt=1),
|
||||
)
|
||||
torch.testing.assert_close(context["video_edit_clean_rows"], video_clean)
|
||||
torch.testing.assert_close(context["video_edit_mask_rows"], torch.zeros(42))
|
||||
assert context["video_edit_restore_mask_rows"].shape == (42, 96)
|
||||
torch.testing.assert_close(
|
||||
context["audio_edit_mask_rows"],
|
||||
torch.tensor([0.0, 0.5, *([1.0] * (audio_t - 2))]).repeat(2),
|
||||
)
|
||||
torch.testing.assert_close(context["audio_edit_mask_rows"], context["audio_edit_restore_mask_rows"])
|
||||
|
||||
|
||||
def _build_inputs(*, video_mask: torch.Tensor, audio_mask: torch.Tensor):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
latent_t, latent_h, latent_w, audio_t = 2, 4, 6, 3
|
||||
video_clean = torch.linspace(-1.0, 1.0, latent_t * (latent_h // 2) * (latent_w // 2) * 96).reshape(-1, 96)
|
||||
audio_clean = torch.linspace(-0.5, 0.5, audio_t * 2 * 32).reshape(-1, 32)
|
||||
inputs = pipeline._build_denoise_inputs(
|
||||
task="t2va",
|
||||
text_embeddings=torch.zeros(2, 4),
|
||||
text_tags=torch.ones(2, dtype=torch.long),
|
||||
seed=17,
|
||||
latent_t=latent_t,
|
||||
latent_h=latent_h,
|
||||
latent_w=latent_w,
|
||||
audio_t=audio_t,
|
||||
num_frames=22,
|
||||
num_steps=3,
|
||||
video_shift=12.0,
|
||||
audio_shift=3.0,
|
||||
base_schedule=None,
|
||||
visual_condition=None,
|
||||
visual_condition_shape=None,
|
||||
audio_condition=None,
|
||||
ref_audio_t=None,
|
||||
video_edit_clean_rows=video_clean,
|
||||
video_edit_mask_rows=video_mask,
|
||||
video_edit_restore_mask_rows=video_mask,
|
||||
audio_edit_clean_rows=audio_clean,
|
||||
audio_edit_mask_rows=audio_mask,
|
||||
audio_edit_restore_mask_rows=audio_mask,
|
||||
)
|
||||
return pipeline, inputs, video_clean, audio_clean
|
||||
|
||||
|
||||
def test_build_denoise_inputs_uses_h3_source_anchors():
|
||||
pipeline, inputs, video_clean, audio_clean = _build_inputs(
|
||||
video_mask=torch.zeros(12),
|
||||
audio_mask=torch.zeros(6),
|
||||
)
|
||||
video_edit = inputs["video_edit"]
|
||||
audio_edit = inputs["audio_edit"]
|
||||
assert video_edit is not None and audio_edit is not None
|
||||
initial_video, _ = pipeline._initial_noise(
|
||||
seed=17,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
)
|
||||
torch.testing.assert_close(video_edit.clean_rows, video_clean)
|
||||
torch.testing.assert_close(video_edit.anchor_rows, 0.999 * video_clean + 0.001 * initial_video)
|
||||
torch.testing.assert_close(audio_edit.clean_rows, audio_clean)
|
||||
torch.testing.assert_close(audio_edit.anchor_rows, audio_clean)
|
||||
@@ -0,0 +1,81 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Focused CPU tests for MiniMax H3 latent-mask editing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_video_full_grid_quantization_and_cell_level_restore():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import minimax_h3_video_edit_masks
|
||||
|
||||
shape = dict(latent_t=2, latent_h=4, latent_w=6)
|
||||
full = torch.arange(48, dtype=torch.float32).reshape(2, 4, 6) / 48
|
||||
cells = full.reshape(2, 2, 2, 3, 2).permute(0, 1, 3, 2, 4)
|
||||
parsed = minimax_h3_video_edit_masks(full, **shape)
|
||||
expected_model = torch.ceil(cells.amax(dim=(3, 4)).reshape(-1) * 256) / 256
|
||||
expected_restore = cells.unsqueeze(3).expand(2, 2, 3, 24, 2, 2).reshape(12, 96)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, expected_model)
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, expected_restore)
|
||||
|
||||
|
||||
def test_audio_grid_uses_channel_major_order_and_preserves_raw_values():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import minimax_h3_audio_edit_masks
|
||||
|
||||
grid = torch.tensor([[0.0, 0.1, 1.0], [0.25, 0.5, 0.75]])
|
||||
expected = grid.flatten()
|
||||
parsed = minimax_h3_audio_edit_masks(grid, audio_t=3)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, torch.ceil(expected * 256) / 256)
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, expected)
|
||||
|
||||
|
||||
def test_zero_fractional_one_semantics_and_cell_level_restore():
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import (
|
||||
MiniMaxH3LatentEdit,
|
||||
minimax_h3_video_edit_masks,
|
||||
)
|
||||
|
||||
clean = torch.full((3, 2), 10.0)
|
||||
anchor = torch.tensor([[8.0, 8.0], [6.0, 6.0], [4.0, 4.0]])
|
||||
state = torch.tensor([[2.0, 2.0], [4.0, 4.0], [6.0, 6.0]])
|
||||
velocity = torch.full((3, 2), 2.0)
|
||||
mask = torch.tensor([0.0, 0.5, 1.0])
|
||||
edit = MiniMaxH3LatentEdit.from_rows(clean, anchor, mask, mask)
|
||||
assert edit is not None
|
||||
|
||||
model_rows, target_timesteps = edit.prepare(state, 0.25, 0.999, sigma=0.75)
|
||||
torch.testing.assert_close(model_rows, torch.lerp(anchor, state, mask[:, None]))
|
||||
torch.testing.assert_close(
|
||||
target_timesteps,
|
||||
torch.tensor([0.999, 0.625, 0.25]),
|
||||
)
|
||||
predicted = model_rows + 0.75 * velocity * mask[:, None]
|
||||
torch.testing.assert_close(edit.x0(model_rows, velocity, 0.25), torch.lerp(clean, predicted, mask[:, None]))
|
||||
|
||||
# A single active cell drives the full model token, while raw restoration
|
||||
# preserves the other three cells independently in every packed channel.
|
||||
parsed = minimax_h3_video_edit_masks(
|
||||
torch.tensor([[[1.0, 0.0], [0.0, 0.0]]]),
|
||||
latent_t=1,
|
||||
latent_h=2,
|
||||
latent_w=2,
|
||||
)
|
||||
restore = torch.tensor([1.0, 0.0, 0.0, 0.0]).repeat(24).reshape(1, 96)
|
||||
torch.testing.assert_close(parsed.model_mask_rows, torch.ones(1))
|
||||
torch.testing.assert_close(parsed.restore_mask_rows, restore)
|
||||
|
||||
cell_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full((1, 96), 10.0),
|
||||
torch.full((1, 96), 8.0),
|
||||
parsed.model_mask_rows,
|
||||
parsed.restore_mask_rows,
|
||||
)
|
||||
assert cell_edit is not None
|
||||
cell_state = torch.full((1, 96), 2.0)
|
||||
cell_velocity = torch.full((1, 96), 4.0)
|
||||
expected = torch.where(restore.bool(), cell_state + 0.75 * cell_velocity, cell_edit.clean_rows)
|
||||
torch.testing.assert_close(cell_edit.x0(cell_state, cell_velocity, 0.25), expected)
|
||||
@@ -0,0 +1,143 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
"""Duration limits apply before encoding and to transported encoder payloads."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from vllm_omni.errors import OmniClientError
|
||||
from vllm_omni.inputs.data import OmniDiffusionSamplingParams
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import (
|
||||
prepare_encoder_inputs,
|
||||
resolve_minimax_h3_shape,
|
||||
)
|
||||
from vllm_omni.model_executor.models.minimax_h3.long_video import validate_encoded_frame_limit
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode,limit", [("full", 30), ("continuation", 300), (None, 300)])
|
||||
@pytest.mark.parametrize("input_kind", ["duration", "duration_seconds", "target", "num_frames"])
|
||||
def test_long_video_limits_cover_all_duration_inputs(mode, limit, input_kind):
|
||||
extra: dict[str, Any] = {"long_video": True}
|
||||
if mode is not None:
|
||||
extra["long_video_mode"] = mode
|
||||
sampling = OmniDiffusionSamplingParams(width=64, height=64, fps=24, extra_args=extra)
|
||||
|
||||
def set_duration(seconds):
|
||||
if input_kind == "target":
|
||||
extra["target"] = {"duration_seconds": seconds}
|
||||
elif input_kind == "num_frames":
|
||||
sampling.num_frames = seconds * 24
|
||||
else:
|
||||
extra[input_kind] = seconds
|
||||
|
||||
set_duration(limit)
|
||||
_, _, frames, _, _ = resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
assert frames >= limit * 24
|
||||
validate_encoded_frame_limit(extra, "ref2va", frames)
|
||||
with pytest.raises(OmniClientError, match="request limit"):
|
||||
validate_encoded_frame_limit(extra, "ref2va", frames + 17)
|
||||
set_duration(limit + 1)
|
||||
with pytest.raises(OmniClientError, match="duration"):
|
||||
resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task", ["t2va", "fl2va", "ref2va"])
|
||||
def test_full_mode_rejects_300_seconds_and_accepts_comparison(task):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
fps=24,
|
||||
extra_args={"long_video": True, "long_video_mode": "full", "duration": 300, "aspect_ratio": "1:1"},
|
||||
)
|
||||
image = Image.new("RGB", (64, 64)) if task == "fl2va" else None
|
||||
with pytest.raises(OmniClientError, match="30"):
|
||||
resolve_minimax_h3_shape(task, sampling, image)
|
||||
sampling.extra_args["duration"] = 20
|
||||
assert resolve_minimax_h3_shape(task, sampling, image)[2] == 481
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["full", "continuation"])
|
||||
def test_mode_alone_does_not_bypass_long_video_opt_in(mode):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"long_video_mode": mode, "duration": 20},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="15"):
|
||||
resolve_minimax_h3_shape("ref2va", sampling, None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["video", "audio"])
|
||||
def test_continuation_edit_rejected_before_loading_source(kind):
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"long_video": True, "task": "ref2va", "duration": 20},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
|
||||
prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "test",
|
||||
"multi_modal_data": {
|
||||
"image": Image.new("RGB", (64, 64)),
|
||||
f"source_{kind}": "must-not-be-loaded",
|
||||
f"{kind}_noise_mask": 0.5,
|
||||
},
|
||||
},
|
||||
sampling,
|
||||
)
|
||||
|
||||
|
||||
def test_locked_audio_rejects_audio_edit_before_loading_source():
|
||||
sampling = OmniDiffusionSamplingParams(
|
||||
width=64,
|
||||
height=64,
|
||||
extra_args={"task": "t2va", "aspect_ratio": "1:1", "audio_mode": "lock_source"},
|
||||
)
|
||||
with pytest.raises(OmniClientError, match="cannot be combined"):
|
||||
prepare_encoder_inputs(
|
||||
{
|
||||
"prompt": "test",
|
||||
"multi_modal_data": {
|
||||
"audio": (torch.zeros(8000), 8000),
|
||||
"source_audio": "must-not-be-loaded",
|
||||
"audio_noise_mask": 0.5,
|
||||
},
|
||||
},
|
||||
sampling,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["video", "audio"])
|
||||
def test_external_encoder_cannot_bypass_continuation_edit_validation(kind):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
conditioning = SimpleNamespace(
|
||||
task="ref2va",
|
||||
num_frames=481,
|
||||
video_edit_clean_rows=torch.zeros(1) if kind == "video" else None,
|
||||
audio_edit_clean_rows=torch.zeros(1) if kind == "audio" else None,
|
||||
)
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True})
|
||||
with pytest.raises(OmniClientError, match="continuation does not support latent-mask editing"):
|
||||
pipeline._prepare_encoder_conditioning_inputs(conditioning, sampling)
|
||||
|
||||
|
||||
def test_external_encoder_cannot_bypass_full_duration_limit():
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(step_execution=False)
|
||||
sampling = OmniDiffusionSamplingParams(extra_args={"long_video": True, "long_video_mode": "full"})
|
||||
with pytest.raises(OmniClientError, match="30-second request limit"):
|
||||
pipeline._prepare_encoder_conditioning_inputs(SimpleNamespace(task="ref2va", num_frames=7213), sampling)
|
||||
@@ -6,10 +6,11 @@ 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 (
|
||||
from vllm_omni.diffusion.layers.indexed_modulation import (
|
||||
_MAX_1D_GRID_SIZE,
|
||||
_iter_row_chunks,
|
||||
_launch_row_chunks,
|
||||
_use_hopper_bf16_affine_semantics,
|
||||
indexed_gate,
|
||||
indexed_gate_rms_norm_scale_shift,
|
||||
indexed_scale_shift_,
|
||||
@@ -190,3 +191,55 @@ def test_fused_modulation_preserves_bf16_residual_boundary() -> None:
|
||||
)
|
||||
|
||||
assert torch.equal(modulated_out, expected)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
@pytest.mark.skipif(
|
||||
torch.cuda.is_available() and torch.cuda.get_device_capability()[0] != 9,
|
||||
reason="SM90-specific precision path",
|
||||
)
|
||||
@pytest.mark.skipif(not HAS_TRITON, reason="Triton required")
|
||||
def test_hopper_fused_modulation_matches_pytorch_reference() -> None:
|
||||
torch.manual_seed(42)
|
||||
rows, hidden_size, conditions = 128, 3072, 4
|
||||
residual = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
gate = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
branch = torch.randn(rows, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
weight = torch.randn(hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
shift = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
scale = torch.randn(conditions, hidden_size, device="cuda", dtype=torch.bfloat16)
|
||||
indices = torch.arange(rows, device="cuda") % conditions
|
||||
eps = 1e-6
|
||||
|
||||
reference_residual = (residual + gate.index_select(0, indices) * branch).to(torch.bfloat16)
|
||||
normalized = reference_residual.float()
|
||||
variance = normalized.pow(2).mean(-1, keepdim=True)
|
||||
normalized = (weight.float() * (normalized * torch.rsqrt(variance + eps))).to(torch.bfloat16)
|
||||
reference_modulated = (normalized * (1.0 + scale.index_select(0, indices)) + shift.index_select(0, indices)).to(
|
||||
torch.bfloat16
|
||||
)
|
||||
|
||||
actual_residual, actual_modulated = indexed_gate_rms_norm_scale_shift(
|
||||
residual,
|
||||
gate,
|
||||
branch,
|
||||
weight,
|
||||
shift,
|
||||
scale,
|
||||
indices,
|
||||
eps,
|
||||
)
|
||||
|
||||
torch.testing.assert_close(actual_residual, reference_residual, atol=5e-2, rtol=5e-2)
|
||||
torch.testing.assert_close(actual_modulated, reference_modulated, atol=5e-2, rtol=5e-2)
|
||||
|
||||
|
||||
@pytest.mark.cuda
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required")
|
||||
def test_hopper_precision_dispatch_is_architecture_scoped(monkeypatch) -> None:
|
||||
tensor = torch.empty(0, device="cuda")
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (9, 0))
|
||||
assert _use_hopper_bf16_affine_semantics(tensor)
|
||||
monkeypatch.setattr(torch.cuda, "get_device_capability", lambda _device: (10, 3))
|
||||
assert not _use_hopper_bf16_affine_semantics(tensor)
|
||||
@@ -1,82 +1,282 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from contextlib import contextmanager
|
||||
import weakref
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion]
|
||||
|
||||
|
||||
def test_h3_profiler_targets_cover_reference_encoders_and_vae_decoders():
|
||||
def test_h3_full_pipeline_profiler_includes_local_encoding():
|
||||
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
|
||||
assert targets == [
|
||||
"encode_prompt",
|
||||
"_encode_local_media",
|
||||
"diffuse",
|
||||
"decode",
|
||||
"video_vae.decode_latent",
|
||||
"audio_vae.decode_latent",
|
||||
"prepare_encode",
|
||||
"denoise_step",
|
||||
"post_decode",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
def test_decoded_output_is_unchanged_without_active_model_offload():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
output = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is output
|
||||
output.cpu.assert_not_called()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
def test_reply_rank_copies_decoded_output_to_host(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: True)
|
||||
output = Mock()
|
||||
host_output = Mock()
|
||||
output.cpu.return_value = host_output
|
||||
|
||||
assert pipeline._offload_model_cpu_stage_output(output) is host_output
|
||||
output.cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_non_reply_rank_drops_decoded_output_storage(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline._model_cpu_offload_modules = [Mock()]
|
||||
monkeypatch.setattr(pipeline, "_is_output_owner_rank", lambda: False)
|
||||
output = torch.ones((2, 3), dtype=torch.float16)
|
||||
|
||||
placeholder = pipeline._offload_model_cpu_stage_output(output)
|
||||
|
||||
assert placeholder.device.type == "meta"
|
||||
assert placeholder.shape == output.shape
|
||||
assert placeholder.dtype == output.dtype
|
||||
|
||||
|
||||
def test_decode_releases_only_audio_before_return(monkeypatch):
|
||||
"""Video is released by the callers of ``decode`` -- after quantization."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
video = torch.ones((1, 1, 4, 4))
|
||||
audio = torch.ones((1, 8))
|
||||
pipeline = SimpleNamespace(
|
||||
device=torch.device("cpu"),
|
||||
video_vae=Mock(decode_latent=Mock(return_value=video)),
|
||||
audio_vae=Mock(decode_latent=Mock(return_value=audio)),
|
||||
_component_on_device=lambda component: nullcontext(),
|
||||
_offload_model_cpu_stage_output=Mock(side_effect=lambda value: value),
|
||||
_release_stage_cache=Mock(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
module.current_omni_platform,
|
||||
"create_autocast_context",
|
||||
lambda **kwargs: nullcontext(),
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
actual_video, actual_audio = MiniMaxH3Pipeline.decode(
|
||||
pipeline,
|
||||
torch.zeros(1),
|
||||
torch.zeros(1),
|
||||
height=2,
|
||||
width=3,
|
||||
)
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
assert actual_video.shape[-2:] == (2, 3)
|
||||
assert actual_audio is audio
|
||||
calls = pipeline._offload_model_cpu_stage_output.call_args_list
|
||||
assert len(calls) == 1
|
||||
assert calls[0].args[0] is audio
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
def _recording_release(events, releases):
|
||||
"""Stand-in for ``_offload_model_cpu_stage_output`` that records what it got."""
|
||||
|
||||
def release(value):
|
||||
events.append("release")
|
||||
releases.append(value)
|
||||
return value
|
||||
|
||||
return release
|
||||
|
||||
|
||||
def test_forward_releases_video_after_quantization_and_before_next_seed(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
# MiniMaxH3VideoVAE.decode_latent ends in frames.float(), so this is what
|
||||
# the release hook would see if it ran before the quantizer.
|
||||
decoded_dtype = torch.float32
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
events.append(f"diffuse:{kwargs['seed']}")
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=decoded_dtype),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
output = MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
# The release must see the quantized frames, not the decoded floats.
|
||||
assert len(releases) == 2
|
||||
assert [tensor.dtype for tensor in releases] == [torch.uint8, torch.uint8]
|
||||
assert decoded_dtype is not torch.uint8
|
||||
# ... and it must still happen before the next seed reloads the DiT.
|
||||
assert events == ["diffuse:41", "decode", "release", "diffuse:42", "decode", "release"]
|
||||
assert output.output[0].dtype is torch.uint8
|
||||
|
||||
|
||||
def test_forward_drops_the_decoded_video_before_the_next_seed_diffuses(monkeypatch):
|
||||
"""Releasing the copy is not enough if a local still pins the original.
|
||||
|
||||
``_offload_model_cpu_stage_output`` hands back a *new* tensor, so the
|
||||
decoded frames stay alive for as long as anything still references them.
|
||||
The whole point of this branch is that they are gone before the next seed
|
||||
reloads the DiT, and ``diffuse`` is where that reload happens.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
decoded_refs: list[weakref.ref] = []
|
||||
alive_at_diffuse: list[int] = []
|
||||
|
||||
def diffuse(**kwargs):
|
||||
alive_at_diffuse.append(sum(1 for ref in decoded_refs if ref() is not None))
|
||||
return torch.zeros(1), torch.zeros(1)
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
video = torch.zeros((1, 3, 2, height, width), dtype=torch.float32)
|
||||
decoded_refs.append(weakref.ref(video))
|
||||
return video, torch.zeros((1, 8))
|
||||
|
||||
# ``forward`` indexes ``preencode_mp4`` (#7018): this test drives the
|
||||
# tensor-decode branch, which is the one the release hook wraps.
|
||||
context = {"num_outputs": 2, "seed": 41, "height": 4, "width": 4, "preencode_mp4": False}
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_extract_prompt=lambda raw: ("a prompt", {}),
|
||||
_extract_text_conditioning=lambda raw: None,
|
||||
_extract_prepared_reference_videos=lambda raw: None,
|
||||
_prepare_request_inputs=lambda *args, **kwargs: context,
|
||||
_denoise_kwargs=lambda ctx: {},
|
||||
diffuse=diffuse,
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=lambda value: value.clone(),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
request = SimpleNamespace(prompts=["a prompt"], sampling_params=SimpleNamespace())
|
||||
|
||||
MiniMaxH3Pipeline.forward(pipeline, request)
|
||||
|
||||
assert len(decoded_refs) == 2
|
||||
# Seed 1's diffuse sees nothing decoded yet; seed 2's must not see seed 1's
|
||||
# decoded frames still resident.
|
||||
assert alive_at_diffuse == [0, 0]
|
||||
|
||||
|
||||
def test_post_decode_releases_video_after_quantization(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
events: list[str] = []
|
||||
releases: list[torch.Tensor] = []
|
||||
|
||||
def decode(video_latent, audio_latent, *, height, width):
|
||||
events.append("decode")
|
||||
return (
|
||||
torch.zeros((1, 3, 2, height, width), dtype=torch.float16),
|
||||
torch.zeros((1, 8)),
|
||||
)
|
||||
|
||||
pipeline = SimpleNamespace(
|
||||
od_config=SimpleNamespace(),
|
||||
_unpack_denoised_rows=lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)),
|
||||
decode=decode,
|
||||
_offload_model_cpu_stage_output=_recording_release(events, releases),
|
||||
_release_stage_cache=lambda: None,
|
||||
)
|
||||
state = SimpleNamespace(
|
||||
latents=torch.zeros(1),
|
||||
extra={
|
||||
module._STEP_BRANCH: object(),
|
||||
module._STEP_AUDIO_ROWS: torch.zeros(1),
|
||||
module._STEP_SHAPE: {
|
||||
"latent_t": 1,
|
||||
"latent_h": 1,
|
||||
"latent_w": 1,
|
||||
"audio_t": 1,
|
||||
"height": 4,
|
||||
"width": 4,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
output = MiniMaxH3Pipeline.post_decode(pipeline, state)
|
||||
|
||||
assert events == ["decode", "release"]
|
||||
assert len(releases) == 1
|
||||
assert releases[0].dtype is torch.uint8
|
||||
assert output.output[0] is releases[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("available", "initialized", "rank", "expected"),
|
||||
[(False, False, 7, True), (True, False, 7, True), (True, True, 0, True), (True, True, 1, False)],
|
||||
)
|
||||
def test_output_owner_rank_matches_executor_contract(monkeypatch, available, initialized, rank, expected):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
monkeypatch.setattr(module.dist, "is_available", lambda: available)
|
||||
monkeypatch.setattr(module.dist, "is_initialized", lambda: initialized)
|
||||
monkeypatch.setattr(module.dist, "get_rank", lambda: rank)
|
||||
|
||||
assert MiniMaxH3Pipeline._is_output_owner_rank() is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("load_text_encoder", [True, False])
|
||||
def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch, load_text_encoder):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
@@ -84,9 +284,11 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.transformer = torch.nn.Linear(2, 2)
|
||||
pipeline.transformers_ref = torch.nn.Linear(2, 2)
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
pipeline.video_vae = torch.nn.Linear(2, 2)
|
||||
pipeline.audio_vae = torch.nn.Linear(2, 2)
|
||||
pipeline._encoder_modules = ["text_encoder"] if load_text_encoder else []
|
||||
if load_text_encoder:
|
||||
pipeline.text_encoder = torch.nn.Linear(2, 2)
|
||||
apply_offload = Mock()
|
||||
remove_offload = Mock()
|
||||
monkeypatch.setattr(module, "apply_sequential_offload", apply_offload)
|
||||
@@ -99,7 +301,7 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
)
|
||||
|
||||
dits = [pipeline.transformer, pipeline.transformers_ref]
|
||||
stages = [pipeline.text_encoder, pipeline.video_vae, pipeline.audio_vae]
|
||||
stages = [*([pipeline.text_encoder] if load_text_encoder else []), pipeline.video_vae, pipeline.audio_vae]
|
||||
apply_offload.assert_called_once_with(
|
||||
dit_modules=dits,
|
||||
encoder_modules=stages,
|
||||
@@ -114,6 +316,46 @@ def test_h3_model_cpu_offload_registers_direct_vae_stages(monkeypatch):
|
||||
remove_offload.assert_called_once_with([*dits, *stages])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
|
||||
|
||||
def test_h3_model_cpu_offload_keeps_unselected_vaes_resident(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
@@ -174,44 +416,20 @@ def test_h3_model_cpu_offload_rejects_unloaded_selected_encoder(monkeypatch):
|
||||
apply_offload.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decode_fails", [False, True])
|
||||
def test_h3_model_cpu_offload_scopes_direct_vae_call(monkeypatch, decode_fails):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as module
|
||||
def _encode_media(pipeline, **kwargs):
|
||||
from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3EncoderMediaInput
|
||||
from vllm_omni.model_executor.models.minimax_h3.encoder_processing import encode_media
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
component = torch.nn.Linear(2, 2)
|
||||
events: list[tuple[Any, ...]] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
events.append(("activate", value))
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
pipeline._model_cpu_offload_modules = [component]
|
||||
|
||||
def decode():
|
||||
with pipeline._component_on_device(component):
|
||||
events.append(("decode", component))
|
||||
if decode_fails:
|
||||
raise RuntimeError("decode failed")
|
||||
|
||||
if decode_fails:
|
||||
with pytest.raises(RuntimeError, match="decode failed"):
|
||||
decode()
|
||||
else:
|
||||
decode()
|
||||
|
||||
assert events == [
|
||||
("activate", component),
|
||||
("decode", component),
|
||||
("offload", component),
|
||||
]
|
||||
media = MiniMaxH3EncoderMediaInput(
|
||||
task="ref2va", height=32, width=32, num_frames=124, latent_t=31, audio_t=208, **kwargs
|
||||
)
|
||||
return encode_media(
|
||||
media,
|
||||
video_vae=getattr(pipeline, "video_vae", None),
|
||||
audio_vae=getattr(pipeline, "audio_vae", None),
|
||||
emit_conditioning=True,
|
||||
component_scope=pipeline._component_on_device,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("encode_fails", [False, True])
|
||||
@@ -221,7 +439,6 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
@@ -242,17 +459,15 @@ def test_h3_model_cpu_offload_batches_visual_reference_scope(monkeypatch, encode
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
images = (torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8))
|
||||
|
||||
if encode_fails:
|
||||
with pytest.raises(RuntimeError, match="encode failed"):
|
||||
pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
_encode_media(pipeline, images=images)
|
||||
else:
|
||||
rows, shapes = pipeline._encode_visual_conditions(images, None, video_count=0)
|
||||
assert rows.shape == (3, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2)]
|
||||
result = _encode_media(pipeline, images=images)
|
||||
assert result.visual_condition.shape == (3, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2))
|
||||
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
@@ -268,9 +483,7 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline.video_vae = Mock()
|
||||
pipeline.video_vae.is_distributed_enabled.return_value = False
|
||||
pipeline._model_cpu_offload_modules = [pipeline.video_vae]
|
||||
events = []
|
||||
|
||||
@@ -287,31 +500,26 @@ def test_h3_model_cpu_offload_shares_image_and_video_scope(monkeypatch):
|
||||
return torch.ones(1, 4)
|
||||
|
||||
def encode_video(frames):
|
||||
events.append(("video", frames))
|
||||
events.append(("video", frames.shape))
|
||||
return torch.ones(3, 4), (3, 2, 2)
|
||||
|
||||
pipeline.video_vae.encode_image.side_effect = encode_image
|
||||
pipeline.video_vae.encode_video.side_effect = encode_video
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
monkeypatch.setattr(module, "_dit_rank_world", lambda: (None, 0, 1))
|
||||
monkeypatch.setattr(module, "_broadcast_tensor", lambda value, **kwargs: value)
|
||||
monkeypatch.setattr(module, "load_video_frames", lambda path: f"frames:{path}")
|
||||
images = [Image.new("RGB", (16, 16)), Image.new("RGB", (32, 16))]
|
||||
prepared_videos = [{"prepared_path": "reference.mp4"}]
|
||||
|
||||
rows, shapes = pipeline._encode_visual_conditions(
|
||||
images,
|
||||
prepared_videos,
|
||||
video_count=1,
|
||||
result = _encode_media(
|
||||
pipeline,
|
||||
images=(torch.zeros(16, 16, 3, dtype=torch.uint8), torch.zeros(16, 32, 3, dtype=torch.uint8)),
|
||||
videos=(torch.zeros(12, 32, 32, 3, dtype=torch.uint8),),
|
||||
video_audios=(None,),
|
||||
)
|
||||
|
||||
assert rows.shape == (5, 4)
|
||||
assert shapes == [(1, 1, 1), (1, 1, 2), (3, 2, 2)]
|
||||
assert result.visual_condition.shape == (5, 4)
|
||||
assert result.visual_condition_shapes == ((1, 1, 1), (1, 1, 2), (3, 2, 2))
|
||||
assert events == [
|
||||
("activate", pipeline.video_vae),
|
||||
("image", (16, 16)),
|
||||
("image", (32, 16)),
|
||||
("video", "frames:reference.mp4"),
|
||||
("video", (12, 32, 32, 3)),
|
||||
("offload", pipeline.video_vae),
|
||||
]
|
||||
|
||||
@@ -325,7 +533,7 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.audio_vae = Mock()
|
||||
pipeline._model_cpu_offload_modules = [pipeline.audio_vae]
|
||||
events: list[tuple[Any, ...]] = []
|
||||
events: list[tuple] = []
|
||||
|
||||
@contextmanager
|
||||
def record_component(value):
|
||||
@@ -335,26 +543,20 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
finally:
|
||||
events.append(("offload", value))
|
||||
|
||||
def encode_embedded(*args, **kwargs):
|
||||
events.append(("embedded",))
|
||||
def encode_waveform(waveform, sample_rate):
|
||||
events.append(("audio", int(waveform[0]), sample_rate))
|
||||
if encode_fails:
|
||||
raise RuntimeError("audio encode failed")
|
||||
return torch.ones(1, 2), [1]
|
||||
return torch.ones(80, 32), 80
|
||||
|
||||
def encode_standalone(*args, **kwargs):
|
||||
events.append(("standalone",))
|
||||
return torch.ones(1, 2), [1]
|
||||
|
||||
pipeline._encode_video_audio_conditions_resident = encode_embedded
|
||||
pipeline._encode_audio_conditions_resident = encode_standalone
|
||||
pipeline.audio_vae.encode_waveform.side_effect = encode_waveform
|
||||
monkeypatch.setattr(module, "sequential_offload_component", record_component)
|
||||
|
||||
def call():
|
||||
return pipeline._encode_reference_audio_conditions(
|
||||
[{"input_has_audio": True}],
|
||||
has_audio=[True],
|
||||
standalone_audios=[(torch.ones(1), 16_000)],
|
||||
max_duration_seconds=1.0,
|
||||
return _encode_media(
|
||||
pipeline,
|
||||
video_audios=(),
|
||||
audios=((torch.ones(32_000), 16_000),),
|
||||
)
|
||||
|
||||
if encode_fails:
|
||||
@@ -362,10 +564,61 @@ def test_h3_model_cpu_offload_shares_embedded_and_standalone_audio_scope(monkeyp
|
||||
call()
|
||||
else:
|
||||
result = call()
|
||||
assert result[0].shape == (1, 2)
|
||||
assert result[2].shape == (1, 2)
|
||||
assert result.audio_condition.shape == (80, 32)
|
||||
assert result.audio_condition_lengths == (80,)
|
||||
assert events[1:-1] == [("audio", 1, 16_000)]
|
||||
|
||||
assert events[0] == ("activate", pipeline.audio_vae)
|
||||
assert events[-1] == ("offload", pipeline.audio_vae)
|
||||
assert events.count(("activate", pipeline.audio_vae)) == 1
|
||||
assert events.count(("offload", pipeline.audio_vae)) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distributed", [False, True])
|
||||
def test_manual_component_offload_wins_over_requested_model_offload(distributed):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=not distributed,
|
||||
enable_distributed_layerwise_offload=distributed,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
component = Mock()
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
with pipeline._component_on_device(component):
|
||||
component.load_to_device.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_not_called()
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.offload_to_cpu.assert_called_once_with()
|
||||
component.offload_to_cpu.assert_called_once_with()
|
||||
|
||||
|
||||
def test_requested_model_offload_without_backend_uses_resident_components():
|
||||
from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
torch.nn.Module.__init__(pipeline)
|
||||
pipeline.od_config = SimpleNamespace(
|
||||
enable_cpu_offload=True,
|
||||
enable_layerwise_offload=False,
|
||||
enable_distributed_layerwise_offload=False,
|
||||
)
|
||||
pipeline._model_cpu_offload_modules = []
|
||||
pipeline.text_encoder = Mock()
|
||||
expected = torch.ones(2, 3)
|
||||
pipeline.text_encoder.encode_ids.return_value = expected
|
||||
|
||||
actual = pipeline._encode_text_hidden(torch.tensor([1, 2]), {})
|
||||
|
||||
assert actual is expected
|
||||
pipeline.text_encoder.load_to_device.assert_called_once_with()
|
||||
pipeline.text_encoder.encode_ids.assert_called_once()
|
||||
@@ -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
|
||||
@@ -190,3 +190,90 @@ def test_condition_noise_accepts_a_reference_video_longer_than_the_target():
|
||||
noise_aug=0.5,
|
||||
)
|
||||
assert result.shape == rows.shape
|
||||
|
||||
|
||||
def test_explicit_seq_len_pins_one_shape_across_prompt_lengths():
|
||||
"""Prompts of different token counts must land on the same packed length.
|
||||
|
||||
Without the pin they fall into different 64-row buckets, and each bucket is
|
||||
a packed shape the compiled transformer has not seen before.
|
||||
"""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
)
|
||||
# used = text_len + 18 rows: 22 -> bucket 64; 118 -> bucket 128.
|
||||
short = minimax_h3_packed_sequence(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence(text_len=100, **common)
|
||||
assert int(short["seq_len"]) == 64
|
||||
assert int(long["seq_len"]) == 128
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
# The pin only adds padding rows; the used prefix is untouched.
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1]) == 22
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1]) == 118
|
||||
assert short_pinned["img_pos"].tolist() == short["img_pos"].tolist()
|
||||
|
||||
|
||||
def test_the_ref2va_packer_pins_one_shape_across_prompt_lengths_too():
|
||||
"""Both packers share one resolver, so both honour the pin."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
|
||||
common = dict(
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
)
|
||||
short = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, **common)
|
||||
long = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, **common)
|
||||
assert int(short["seq_len"]) != int(long["seq_len"])
|
||||
|
||||
short_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=4, seq_len=192, **common)
|
||||
long_pinned = minimax_h3_packed_sequence_ref2va_blocks(text_len=100, seq_len=192, **common)
|
||||
assert int(short_pinned["seq_len"]) == int(long_pinned["seq_len"]) == 192
|
||||
assert int(short_pinned["cu_seqlens"][1]) == int(short["cu_seqlens"][1])
|
||||
assert int(long_pinned["cu_seqlens"][1]) == int(long["cu_seqlens"][1])
|
||||
|
||||
|
||||
def test_explicit_seq_len_below_used_rows_is_a_client_error():
|
||||
"""Both packers reject an under-sized pin as a 4xx, not a server error."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.packed_sequence import (
|
||||
minimax_h3_packed_sequence,
|
||||
minimax_h3_packed_sequence_ref2va_blocks,
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
include_keyframe_cond=False,
|
||||
seq_len=8,
|
||||
)
|
||||
|
||||
with pytest.raises(OmniClientError, match="used rows"):
|
||||
minimax_h3_packed_sequence_ref2va_blocks(
|
||||
text_len=4,
|
||||
latent_t=2,
|
||||
latent_h=4,
|
||||
latent_w=6,
|
||||
audio_t=3,
|
||||
ref_blocks=[{"kind": "audio", "ref_audio_t": 1}],
|
||||
seq_len=8,
|
||||
)
|
||||
@@ -1,4 +1,5 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
@@ -41,7 +42,7 @@ class _FakeAttention(nn.Module):
|
||||
super().__init__()
|
||||
|
||||
|
||||
def _small_od_config():
|
||||
def _small_od_config(*, class_name=None):
|
||||
arch = {
|
||||
"num_layers": 1,
|
||||
"token_refiner_num_layers": 1,
|
||||
@@ -60,6 +61,8 @@ def _small_od_config():
|
||||
"final_adaln_out_features": 2 * 8,
|
||||
"rope_inv_freq_len": 2,
|
||||
}
|
||||
if class_name is not None:
|
||||
arch["_class_name"] = class_name
|
||||
return SimpleNamespace(
|
||||
tf_model_config=arch,
|
||||
parallel_config=SimpleNamespace(ulysses_degree=1),
|
||||
@@ -133,6 +136,24 @@ def test_fp8_scope_and_prefix_propagation(monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_native_weight_format_overrides_diffusers_class_name(monkeypatch):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import minimax_h3_transformer as h3
|
||||
|
||||
monkeypatch.setattr(h3, "ColumnParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "MergedColumnParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "QKVParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "RowParallelLinear", _FakeLinear)
|
||||
monkeypatch.setattr(h3, "Attention", _FakeAttention)
|
||||
monkeypatch.setattr(h3, "get_tensor_model_parallel_world_size", lambda: 1)
|
||||
|
||||
model = h3.MiniMaxH3DiTModel(
|
||||
_small_od_config(class_name="MiniMaxH3Transformer3DModel"),
|
||||
diffusers_weights=False,
|
||||
)
|
||||
|
||||
assert model._diffusers_weights is False
|
||||
|
||||
|
||||
class _WeightTarget(nn.Module):
|
||||
def __init__(self, loader):
|
||||
super().__init__()
|
||||
|
||||
@@ -175,7 +175,7 @@ def _generate_joint_output(omni, config: QualityTestConfig):
|
||||
pytest.param(
|
||||
_QUALITY_CONFIG,
|
||||
id=_QUALITY_CONFIG.id,
|
||||
marks=hardware_marks(res={"cuda": _QUALITY_CONFIG.gpu}, num_cards=2),
|
||||
marks=hardware_marks(res={"cuda": ["H100", "B200"]}, num_cards=2),
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -186,10 +186,15 @@ def test_minimax_h3_quantization_quality(config: QualityTestConfig):
|
||||
common_kwargs = {
|
||||
"model": model_ref,
|
||||
"enforce_eager": True,
|
||||
"tensor_parallel_size": 2,
|
||||
# The fused BF16 baseline only fits on H100-80GB with encoder TP.
|
||||
# This test loads the direct FL2VA diffusion pipeline (not the
|
||||
# disaggregated two-stage MiniMax-H3 pipeline), so configure both the
|
||||
# DiT world and the Qwen3-VL encoder TP explicitly.
|
||||
"tensor_parallel_size": 2,
|
||||
"text_encoder_tp_size": 2,
|
||||
"vae_use_tiling": True,
|
||||
# MiniMax H3's VAE is implemented by code shipped with the checkpoint.
|
||||
"trust_remote_code": True,
|
||||
}
|
||||
|
||||
omni_bl = Omni(**common_kwargs)
|
||||
|
||||
@@ -27,6 +27,9 @@ class _SegmentMeanModel:
|
||||
assignment shows up as a numeric difference instead of passing silently.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.calls: list[dict[str, torch.Tensor]] = []
|
||||
|
||||
def __call__(self, **kwargs):
|
||||
x = kwargs["x"][0]
|
||||
audio_x = kwargs["audio_x"][0]
|
||||
@@ -45,6 +48,14 @@ class _SegmentMeanModel:
|
||||
pooled_audio[start:stop] = audio_x[start:stop].mean(dim=0, keepdim=True)
|
||||
|
||||
row_timesteps = kwargs["unique_timesteps"][kwargs["inverse_indices"]].unsqueeze(-1)
|
||||
self.calls.append(
|
||||
{
|
||||
"video_rows": x[img_pos].clone(),
|
||||
"audio_rows": audio_x[audio_pos].clone(),
|
||||
"video_timesteps": row_timesteps[img_pos, 0].clone(),
|
||||
"audio_timesteps": row_timesteps[audio_pos, 0].clone(),
|
||||
}
|
||||
)
|
||||
video = (pooled_video + 0.5 * x + row_timesteps)[img_pos]
|
||||
audio = (pooled_audio + 0.5 * audio_x + row_timesteps)[audio_pos]
|
||||
if not kwargs.get("skip_mask_out_condition", False):
|
||||
@@ -110,6 +121,8 @@ def _step_pipeline(model, *, packed_batch_supported: bool = True):
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import MiniMaxH3Pipeline
|
||||
|
||||
pipeline = object.__new__(MiniMaxH3Pipeline)
|
||||
pipeline.load_text_encoder = False
|
||||
pipeline.load_vae_encoder = False
|
||||
pipeline.transformer = model
|
||||
pipeline.device = torch.device("cpu")
|
||||
pipeline._transformer_for_task = lambda task: model
|
||||
@@ -154,9 +167,77 @@ def test_step_execution_matches_request_mode_denoise_loop():
|
||||
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
|
||||
|
||||
|
||||
def test_batched_step_execution_matches_independent_requests():
|
||||
def test_step_execution_matches_request_mode_with_latent_edits():
|
||||
"""Masked model rows, row timesteps, and scheduler math match both paths."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import MiniMaxH3LatentEdit
|
||||
|
||||
model = _SegmentMeanModel()
|
||||
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=51)
|
||||
sigmas_video = _sigmas(5, 12.0)
|
||||
sigmas_audio = _sigmas(5, 3.0)
|
||||
video_clean = torch.linspace(-1.0, 1.0, video_rows.numel()).reshape_as(video_rows)
|
||||
audio_clean = torch.linspace(1.0, -1.0, audio_rows.numel()).reshape_as(audio_rows)
|
||||
video_mask = torch.linspace(0.0, 1.0, video_rows.shape[0])
|
||||
video_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
video_clean,
|
||||
0.999 * video_clean + 0.001 * video_rows,
|
||||
video_mask,
|
||||
video_mask,
|
||||
)
|
||||
audio_mask = torch.tensor([0.0, 0.2, 0.4, 0.6, 0.8, 1.0])
|
||||
audio_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
audio_clean,
|
||||
audio_clean,
|
||||
audio_mask,
|
||||
audio_mask,
|
||||
)
|
||||
|
||||
reference_video, reference_audio = minimax_h3_denoise_loop(
|
||||
model=model,
|
||||
positive=branch,
|
||||
initial_video_rows=video_rows,
|
||||
initial_audio_rows=audio_rows,
|
||||
keyframe_cond_rows=None,
|
||||
video_edit=video_edit,
|
||||
audio_edit=audio_edit,
|
||||
sigmas_video=sigmas_video,
|
||||
sigmas_audio=sigmas_audio,
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
request_first_call = model.calls[0]
|
||||
torch.testing.assert_close(request_first_call["video_rows"], video_edit.model_rows(video_rows))
|
||||
torch.testing.assert_close(request_first_call["audio_rows"], audio_edit.model_rows(audio_rows))
|
||||
torch.testing.assert_close(
|
||||
request_first_call["video_timesteps"],
|
||||
video_edit.target_timesteps(1.0 - sigmas_video[0], 0.999, sigma=sigmas_video[0]),
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
request_first_call["audio_timesteps"],
|
||||
audio_edit.target_timesteps(1.0 - sigmas_audio[0], 1.0, sigma=sigmas_audio[0]),
|
||||
)
|
||||
model.calls.clear()
|
||||
|
||||
state = _make_state("req-edit", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
state.extra[mod._STEP_VIDEO_EDIT] = video_edit
|
||||
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
|
||||
pipeline = _step_pipeline(model)
|
||||
while not state.denoise_completed:
|
||||
velocity = pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state])
|
||||
pipeline.step_scheduler(state, velocity)
|
||||
|
||||
for name, expected in request_first_call.items():
|
||||
torch.testing.assert_close(model.calls[0][name], expected)
|
||||
torch.testing.assert_close(state.latents, reference_video)
|
||||
torch.testing.assert_close(state.extra[mod._STEP_AUDIO_ROWS], reference_audio)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("lock_audio", [False, True])
|
||||
def test_batched_step_execution_matches_independent_requests(lock_audio):
|
||||
"""Two co-batched requests must land where they would have landed alone."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.diffusion.models.minimax_h3.latent_mask import MiniMaxH3LatentEdit
|
||||
|
||||
model = _SegmentMeanModel()
|
||||
specs = [
|
||||
@@ -169,20 +250,40 @@ def test_batched_step_execution_matches_independent_requests():
|
||||
|
||||
pipeline = _step_pipeline(model)
|
||||
|
||||
def make_state(request_id: str, index: int):
|
||||
branch, video_rows, audio_rows = _make_branch(**specs[index])
|
||||
sigmas_video, sigmas_audio = schedules[index]
|
||||
state = _make_state(request_id, model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
video_mask = torch.linspace(0.1 * index, 0.8 + 0.1 * index, video_rows.shape[0])
|
||||
video_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full_like(video_rows, 10.0 + index),
|
||||
torch.full_like(video_rows, 20.0 + index),
|
||||
video_mask,
|
||||
video_mask,
|
||||
)
|
||||
audio_mask = torch.linspace(0.2 - 0.1 * index, 1.0 - 0.1 * index, audio_rows.shape[0])
|
||||
audio_edit = MiniMaxH3LatentEdit.from_rows(
|
||||
torch.full_like(audio_rows, 30.0 + index),
|
||||
torch.full_like(audio_rows, 40.0 + index),
|
||||
audio_mask,
|
||||
audio_mask,
|
||||
)
|
||||
state.extra[mod._STEP_VIDEO_EDIT] = video_edit
|
||||
if lock_audio and index == 0:
|
||||
branch.locked_audio_rows = audio_rows.clone()
|
||||
else:
|
||||
state.extra[mod._STEP_AUDIO_EDIT] = audio_edit
|
||||
return state
|
||||
|
||||
alone: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||
for spec, (sigmas_video, sigmas_audio) in zip(specs, schedules):
|
||||
branch, video_rows, audio_rows = _make_branch(**spec)
|
||||
state = _make_state("solo", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio)
|
||||
for index in range(len(specs)):
|
||||
state = make_state("solo", index)
|
||||
while not state.denoise_completed:
|
||||
pipeline.step_scheduler(state, pipeline.denoise_step(SimpleNamespace(states=(state,)), states=[state]))
|
||||
alone.append((state.latents, state.extra[mod._STEP_AUDIO_ROWS]))
|
||||
|
||||
states = []
|
||||
for index, (spec, (sigmas_video, sigmas_audio)) in enumerate(zip(specs, schedules)):
|
||||
branch, video_rows, audio_rows = _make_branch(**spec)
|
||||
if index == 0:
|
||||
assert branch.used_len == branch.seq_len
|
||||
states.append(_make_state(f"req-{index}", model, branch, video_rows, audio_rows, sigmas_video, sigmas_audio))
|
||||
states = [make_state(f"req-{index}", index) for index in range(len(specs))]
|
||||
assert states[0].extra[mod._STEP_BRANCH].used_len == states[0].extra[mod._STEP_BRANCH].seq_len
|
||||
|
||||
active = list(states)
|
||||
while active:
|
||||
@@ -284,15 +385,20 @@ def test_mixed_step_batch_leaves_gated_attention_dense():
|
||||
assert recorder.denoise_timestep is None
|
||||
|
||||
|
||||
def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
@pytest.mark.parametrize("batch_frames", [1, 33])
|
||||
def test_prepare_encode_seeds_runner_visible_state(monkeypatch, batch_frames):
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
|
||||
branch, video_rows, audio_rows = _make_branch(text_len=9, latent_t=2, latent_h=4, latent_w=6, audio_t=3, seed=8)
|
||||
video_edit = object()
|
||||
audio_edit = object()
|
||||
sigmas_video = _sigmas(6, 12.0)
|
||||
sigmas_audio = _sigmas(6, 3.0)
|
||||
context = {
|
||||
"height": 96,
|
||||
"width": 64,
|
||||
"preencode_mp4": True,
|
||||
"preencode_batch_frames": batch_frames,
|
||||
"latent_t": 2,
|
||||
"latent_h": 4,
|
||||
"latent_w": 6,
|
||||
@@ -301,8 +407,17 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
}
|
||||
|
||||
pipeline = _step_pipeline(_SegmentMeanModel())
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_extract_prompt", staticmethod(lambda _: ("a prompt", {})))
|
||||
monkeypatch.setattr(mod.MiniMaxH3Pipeline, "_prepare_request_inputs", lambda self, **_: context)
|
||||
conditioning = object()
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_extract_encoder_conditioning",
|
||||
staticmethod(lambda _: conditioning),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_prepare_encoder_conditioning_inputs",
|
||||
lambda self, value, sampling: context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
mod.MiniMaxH3Pipeline,
|
||||
"_build_denoise_inputs",
|
||||
@@ -312,6 +427,8 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
"audio_rows": audio_rows,
|
||||
"cond_anchor": None,
|
||||
"audio_anchor": None,
|
||||
"video_edit": video_edit,
|
||||
"audio_edit": audio_edit,
|
||||
"sigmas_video": sigmas_video,
|
||||
"sigmas_audio": sigmas_audio,
|
||||
},
|
||||
@@ -333,8 +450,22 @@ def test_prepare_encode_seeds_runner_visible_state(monkeypatch):
|
||||
assert state.do_true_cfg is False
|
||||
torch.testing.assert_close(state.current_timestep, torch.tensor(1.0 - sigmas_video[0]))
|
||||
assert state.extra[mod._STEP_BRANCH] is branch
|
||||
assert state.extra[mod._STEP_VIDEO_EDIT] is video_edit
|
||||
assert state.extra[mod._STEP_AUDIO_EDIT] is audio_edit
|
||||
assert state.extra[mod._STEP_SHAPE]["height"] == 96
|
||||
|
||||
pipeline.od_config = SimpleNamespace()
|
||||
monkeypatch.setattr(pipeline, "_unpack_denoised_rows", lambda *args, **kwargs: (torch.zeros(1), torch.zeros(1)))
|
||||
calls = []
|
||||
|
||||
def decode_to_mp4(*args, **kwargs):
|
||||
calls.append(kwargs)
|
||||
return b"mp4"
|
||||
|
||||
monkeypatch.setattr(pipeline, "decode_to_mp4", decode_to_mp4)
|
||||
assert pipeline.post_decode(state).output == (b"mp4", None)
|
||||
assert calls[0]["batch_frames"] == batch_frames
|
||||
|
||||
|
||||
def test_prepare_encode_rejects_request_mode_only_features():
|
||||
"""Multi-output and DLO have no representation in the step contract."""
|
||||
@@ -426,48 +557,31 @@ def test_packed_batch_rejects_backends_that_cannot_isolate_requests(attention):
|
||||
assert MiniMaxH3Pipeline._packed_batch_supported(_FakeTransformer([attention])) is False
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_single_rank_reraises():
|
||||
"""Single-rank execution has no group; the helper just reraises."""
|
||||
from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import (
|
||||
_broadcast_rank0_exception,
|
||||
def test_locked_driving_audio_is_clean_and_unchanged_during_denoising():
|
||||
from vllm_omni.diffusion.models.minimax_h3.denoise_loop import minimax_h3_denoise_loop
|
||||
|
||||
branch, video, audio = _make_branch(text_len=3, latent_t=2, latent_h=2, latent_w=2, audio_t=3, seed=7)
|
||||
branch.locked_audio_rows = audio.clone()
|
||||
seen = []
|
||||
|
||||
def model(**kwargs):
|
||||
positions = kwargs["audio_pos_info"]["position_ids"]
|
||||
times = kwargs["unique_timesteps"][kwargs["inverse_indices"]]
|
||||
torch.testing.assert_close(times[positions], torch.ones_like(times[positions]))
|
||||
torch.testing.assert_close(kwargs["audio_x"][0, positions], audio)
|
||||
seen.append(True)
|
||||
return torch.ones_like(video), torch.ones_like(audio)
|
||||
|
||||
result_video, result_audio = minimax_h3_denoise_loop(
|
||||
model=model,
|
||||
positive=branch,
|
||||
initial_video_rows=video,
|
||||
initial_audio_rows=audio,
|
||||
keyframe_cond_rows=None,
|
||||
sigmas_video=[1.0, 0.5, 0.0],
|
||||
sigmas_audio=[1.0, 0.25, 0.0],
|
||||
device=torch.device("cpu"),
|
||||
)
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
_broadcast_rank0_exception(None)
|
||||
with pytest.raises(OmniClientError, match="bad ref"):
|
||||
_broadcast_rank0_exception(OmniClientError("bad ref"))
|
||||
|
||||
|
||||
def test_broadcast_rank0_exception_propagates_to_non_zero_ranks(monkeypatch):
|
||||
"""A rank-0 error becomes a matching client error on every other DiT rank."""
|
||||
from vllm_omni.diffusion.models.minimax_h3 import pipeline_minimax_h3 as mod
|
||||
from vllm_omni.errors import OmniClientError
|
||||
|
||||
def fake_rank_world(rank):
|
||||
return lambda: (object(), rank, 4)
|
||||
|
||||
def make_broadcast(rank0_payload):
|
||||
def fake_broadcast(payload_list, *, src, group):
|
||||
payload_list[0] = rank0_payload
|
||||
|
||||
return fake_broadcast
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(0))
|
||||
err = OmniClientError("invalid reference-video file", status_code=422, error_type="UnprocessableEntityError")
|
||||
rank0_payload = {
|
||||
"type": type(err).__name__,
|
||||
"message": str(err),
|
||||
"status_code": err.status_code,
|
||||
"error_type": err.error_type,
|
||||
}
|
||||
monkeypatch.setattr(mod.dist, "broadcast_object_list", make_broadcast(rank0_payload))
|
||||
with pytest.raises(OmniClientError) as rank0_info:
|
||||
mod._broadcast_rank0_exception(err)
|
||||
assert rank0_info.value is err
|
||||
|
||||
monkeypatch.setattr(mod, "_dit_rank_world", fake_rank_world(2))
|
||||
with pytest.raises(OmniClientError) as rank2_info:
|
||||
mod._broadcast_rank0_exception(None)
|
||||
assert rank2_info.value.status_code == 422
|
||||
assert rank2_info.value.error_type == "UnprocessableEntityError"
|
||||
assert "invalid reference-video file" in str(rank2_info.value)
|
||||
assert len(seen) == 2
|
||||
torch.testing.assert_close(result_audio, audio)
|
||||
assert not torch.equal(result_video, video)
|
||||
Loaded 100 of 1332 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user