From 039808e0d97d7969a2cb102d074c1e45b8ceef0b Mon Sep 17 00:00:00 2001 From: Xenoryn Date: Sun, 6 Sep 2026 16:23:30 +0800 Subject: [PATCH 01/90] [Perf][Diffusion] Skip the Ulysses length all-gather when shards are padded equal (#5717) Signed-off-by: Xenoryn Co-authored-by: Claude --- tests/diffusion/attention/test_ulysses_uaa.py | 377 ++++++++++++++++++ .../distributed/test_sp_plan_hooks.py | 114 ++++++ .../diffusion/attention/parallel/ulysses.py | 33 +- vllm_omni/diffusion/forward_context.py | 15 +- .../diffusion/hooks/sequence_parallel.py | 8 +- 5 files changed, 537 insertions(+), 10 deletions(-) diff --git a/tests/diffusion/attention/test_ulysses_uaa.py b/tests/diffusion/attention/test_ulysses_uaa.py index 041bb941a..230eb8b71 100644 --- a/tests/diffusion/attention/test_ulysses_uaa.py +++ b/tests/diffusion/attention/test_ulysses_uaa.py @@ -27,6 +27,202 @@ from vllm_omni.platforms import current_omni_platform pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] +@pytest.mark.core_model +@pytest.mark.cpu +def test_uaa_equal_rank_contract_skips_length_gather( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vllm_omni.diffusion.attention.parallel import ulysses + + class FakeGroup: + ulysses_group = object() + ulysses_world_size = 2 + ulysses_rank = 0 + ring_world_size = 1 + + observed = [] + + def fake_all_to_all(pg, tensor, **kwargs): + observed.append((kwargs["seq_lens"], kwargs["padded_head_cnt"])) + return tensor, tensor.shape[2] + + monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all) + monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa") + monkeypatch.setattr( + ulysses, + "_all_gather_int", + lambda *args, **kwargs: pytest.fail("length gather must be skipped"), + ) + strategy = ulysses.UlyssesParallelAttention( + FakeGroup(), + scatter_idx=2, + gather_idx=1, + use_sync=False, + ) + + with set_forward_context(): + get_forward_context()._sp_equal_pad_stack.append(True) + strategy.pre_attention( + torch.zeros(1, 3, 28, 4), + torch.zeros(1, 3, 7, 4), + torch.zeros(1, 3, 7, 4), + None, + ) + + assert observed == [([3, 3], 32), ([3, 3], 8), ([3, 3], 8)] + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_uaa_hybrid_equal_rank_contract_skips_ulysses_gather_keeps_ring_check( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Contract + ring: the fast path skips only the Ulysses gather. + + The ring equality check is a separate collective on ring_group and must + still run, receiving s_global derived from the contract. + """ + from vllm_omni.diffusion.attention.parallel import ulysses + + class FakeGroup: + ulysses_group = object() + ring_group = object() + ulysses_world_size = 2 + ulysses_rank = 0 + ring_world_size = 2 + + sp_group = FakeGroup() + observed = [] + ring_checks = [] + + def fake_all_to_all(pg, tensor, **kwargs): + observed.append((kwargs["seq_lens"], kwargs["padded_head_cnt"])) + return tensor, tensor.shape[2] + + def fake_gather_int(pg, value, *, device): + if pg is sp_group.ulysses_group: + pytest.fail("Ulysses length gather must be skipped under the contract") + assert pg is sp_group.ring_group + ring_checks.append(int(value)) + return [int(value), int(value)] + + monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all) + monkeypatch.setattr(ulysses, "_all_gather_int", fake_gather_int) + monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa") + strategy = ulysses.UlyssesParallelAttention( + sp_group, + scatter_idx=2, + gather_idx=1, + use_sync=False, + ) + + with set_forward_context(): + get_forward_context()._sp_equal_pad_stack.append(True) + strategy.pre_attention( + torch.zeros(1, 3, 28, 4), + torch.zeros(1, 3, 7, 4), + torch.zeros(1, 3, 7, 4), + None, + ) + + # Ring check ran: s_global = ulysses_world_size * local = 2 * 3. + assert ring_checks == [6] + assert observed == [([3, 3], 32), ([3, 3], 8), ([3, 3], 8)] + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_uaa_without_contract_keeps_dynamic_length_gather( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No auto_pad boundary means shards may differ, so the gather must stay.""" + from vllm_omni.diffusion.attention.parallel import ulysses + + class FakeGroup: + ulysses_group = object() + ulysses_world_size = 2 + ulysses_rank = 0 + ring_world_size = 1 + + observed = [] + monkeypatch.setattr( + ulysses, + "_all_gather_int", + lambda *args, **kwargs: [3, 4], + ) + monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa") + + def fake_all_to_all(pg, tensor, **kwargs): + observed.append(kwargs["seq_lens"]) + return tensor, tensor.shape[2] + + monkeypatch.setattr(ulysses, "_ulysses_all_to_all_any_qkv", fake_all_to_all) + strategy = ulysses.UlyssesParallelAttention( + FakeGroup(), + scatter_idx=2, + gather_idx=1, + use_sync=False, + ) + + with set_forward_context(): + strategy.pre_attention( + torch.zeros(1, 3, 28, 4), + torch.zeros(1, 3, 7, 4), + torch.zeros(1, 3, 7, 4), + None, + ) + + assert observed == [[3, 4], [3, 4], [3, 4]] + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_uaa_mixed_boundaries_keep_the_dynamic_length_gather( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """One manual boundary inside an auto_pad region voids the contract.""" + from vllm_omni.diffusion.attention.parallel import ulysses + + class FakeGroup: + ulysses_group = object() + ulysses_world_size = 2 + ulysses_rank = 0 + ring_world_size = 1 + + monkeypatch.setattr(ulysses, "get_ulysses_mode", lambda **kwargs: "advanced_uaa") + gathered = [] + + def fake_gather(*args, **kwargs): + gathered.append(args) + return [3, 4] + + monkeypatch.setattr(ulysses, "_all_gather_int", fake_gather) + monkeypatch.setattr( + ulysses, + "_ulysses_all_to_all_any_qkv", + lambda pg, tensor, **kwargs: (tensor, tensor.shape[2]), + ) + strategy = ulysses.UlyssesParallelAttention( + FakeGroup(), + scatter_idx=2, + gather_idx=1, + use_sync=False, + ) + + with set_forward_context(): + ctx = get_forward_context() + ctx._sp_equal_pad_stack.extend([True, False]) + assert not ctx.sp_rank_local_seq_lens_equal + strategy.pre_attention( + torch.zeros(1, 3, 28, 4), + torch.zeros(1, 3, 7, 4), + torch.zeros(1, 3, 7, 4), + None, + ) + + assert len(gathered) == 1 + + @pytest.mark.core_model @pytest.mark.cpu def test_uaa_gqa_head_padding_preserves_the_query_to_kv_ratio( @@ -634,6 +830,187 @@ def test_ulysses_uaa_matches_baseline( pass +def _run_contract_skip_case( + local_rank: int, + world_size: int, + init_method: str, + input_file: str, + num_heads: int, + head_size: int, + num_kv_heads: int | None, +) -> None: + """Per-rank worker: slow path vs contract fast path on real rank pairs. + + Phase 1 leaves the equal-pad stack empty so the Ulysses length all-gather + really runs (counted); phase 2 pushes the split hook's auto_pad marker and + replaces _all_gather_int with a raising guard. With ring_degree=1 any call + to it is the Ulysses length gather, so the guard proves the skip, and the + per-rank output comparison proves the fast path is numerically equivalent + to the slow path it replaces. + """ + from vllm_omni.diffusion.attention.parallel import ulysses as ulysses_mod + + device = torch.device(f"{current_omni_platform.device_type}:{local_rank}") + current_omni_platform.set_device(device) + + os.environ["DIFFUSION_ATTENTION_BACKEND"] = "TORCH_SDPA" + + init_distributed_environment(world_size=world_size, rank=local_rank, distributed_init_method=init_method) + initialize_model_parallel( + data_parallel_size=1, + cfg_parallel_size=1, + sequence_parallel_size=world_size, + ulysses_degree=world_size, + ring_degree=1, + tensor_parallel_size=1, + pipeline_parallel_size=1, + ) + + parallel_config = DiffusionParallelConfig( + pipeline_parallel_size=1, + data_parallel_size=1, + tensor_parallel_size=1, + sequence_parallel_size=world_size, + ulysses_degree=world_size, + ring_degree=1, + cfg_parallel_size=1, + ulysses_mode="advanced_uaa", + ) + od_config = OmniDiffusionConfig(model="test", dtype=torch.float32, parallel_config=parallel_config) + + with set_forward_context(omni_diffusion_config=od_config), set_current_diffusion_config(od_config): + attn = Attention( + num_heads=num_heads, + head_size=head_size, + causal=False, + softmax_scale=1.0 / (head_size**0.5), + num_kv_heads=num_kv_heads, + ).to(device=device, dtype=torch.float32) + + with np.load(input_file, allow_pickle=False) as payload: + q_full = torch.from_numpy(payload["q"]).to(device=device) + k_full = torch.from_numpy(payload["k"]).to(device=device) + v_full = torch.from_numpy(payload["v"]).to(device=device) + + def make_shards() -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + # Fresh contiguous shards for each phase; seq_len is divisible by + # world_size, so every rank gets the same local length. + return ( + torch.tensor_split(q_full, world_size, dim=1)[local_rank].contiguous(), + torch.tensor_split(k_full, world_size, dim=1)[local_rank].contiguous(), + torch.tensor_split(v_full, world_size, dim=1)[local_rank].contiguous(), + ) + + # The Attention layer only enables SP communication when + # ForwardContext.sp_active is True; in production the split hook sets + # this (and pushes the auto_pad equal-length marker onto + # _sp_equal_pad_stack). Here we drive it directly. + ctx = get_forward_context() + ctx._sp_shard_depth = 1 + + def make_sdp_ctx() -> object: + # Fresh instance per phase: the @contextmanager instance releases + # its state on first exit and cannot be re-entered. + return torch.backends.cuda.sdp_kernel(enable_flash=False, enable_mem_efficient=False, enable_math=True) + + # Phase 1: contract absent -> the length all-gather must really run. + real_gather = ulysses_mod._all_gather_int + gather_calls: list[object] = [] + + def counting_gather(pg: object, value: int, *, device: torch.device) -> list[int]: + gather_calls.append(pg) + return real_gather(pg, value, device=device) + + ulysses_mod._all_gather_int = counting_gather + try: + q, k, v = make_shards() + with make_sdp_ctx(): + out_slow = attn(q, k, v, attn_metadata=None).contiguous() + finally: + ulysses_mod._all_gather_int = real_gather + assert len(gather_calls) >= 1, "slow path must call the Ulysses length all-gather" + + # Phase 2: replicate the split hook's auto_pad marker so the fast + # path fires; any _all_gather_int call in this phase is the Ulysses + # length gather and must not happen. + ctx._sp_equal_pad_stack.append(True) + + def fail_on_gather(pg: object, value: int, *, device: torch.device) -> list[int]: + raise AssertionError("the Ulysses length all-gather must be skipped under the equal-pad contract") + + ulysses_mod._all_gather_int = fail_on_gather + try: + q, k, v = make_shards() + with make_sdp_ctx(): + out_fast = attn(q, k, v, attn_metadata=None).contiguous() + finally: + ulysses_mod._all_gather_int = real_gather + ctx._sp_equal_pad_stack.pop() + + torch.testing.assert_close(out_fast, out_slow, atol=1e-5, rtol=1e-5) + + destroy_distributed_env() + + +@pytest.mark.parametrize( + "num_heads,num_kv_heads", + [ + (3, None), # MHA: heads padded 3 -> 4 by ulysses_degree=2 + (28, 7), # GQA: derived-Q padding 28/7 -> 32/8, the main case for the skip + ], +) +def test_ulysses_uaa_contract_skips_length_gather_matches_slow_path(num_heads: int, num_kv_heads: int | None) -> None: + """Real 2-rank parity: contract fast path == slow path, gather skipped. + + The CPU-only contract tests replace the all-to-all with a fake; this test + runs the real strategy on two ranks. Each rank first computes the slow + path (empty equal-pad stack, real length all-gather), then the fast path + with the split hook's auto_pad marker pushed and _all_gather_int replaced + by a raising guard -- which also proves the collective was truly skipped, + not just silently replaced. + """ + sp_world_size = 2 + if current_omni_platform.get_device_count() < sp_world_size: + pytest.skip(f"Test requires {sp_world_size} GPUs") + + batch_size = 2 + head_size = 8 + seq_len = 6 # divisible by 2 so both ranks hold equal-length shards + + init_method = get_distributed_init_method() + + with tempfile.NamedTemporaryFile(delete=False, suffix=".npz") as f_in: + input_file = f_in.name + + try: + torch.manual_seed(0) + kv_heads = num_kv_heads if num_kv_heads is not None else num_heads + np.savez( + input_file, + q=torch.randn(batch_size, seq_len, num_heads, head_size, dtype=torch.float32).numpy(), + k=torch.randn(batch_size, seq_len, kv_heads, head_size, dtype=torch.float32).numpy(), + v=torch.randn(batch_size, seq_len, kv_heads, head_size, dtype=torch.float32).numpy(), + ) + + torch.multiprocessing.spawn( + _run_contract_skip_case, + args=( + sp_world_size, + init_method, + input_file, + num_heads, + head_size, + num_kv_heads, + ), + nprocs=sp_world_size, + ) + finally: + try: + os.remove(input_file) + except OSError: + pass + + @pytest.mark.parametrize( "num_heads,num_kv_heads,joint_len", [ diff --git a/tests/diffusion/distributed/test_sp_plan_hooks.py b/tests/diffusion/distributed/test_sp_plan_hooks.py index 4ae6ec064..9a3c0ee82 100644 --- a/tests/diffusion/distributed/test_sp_plan_hooks.py +++ b/tests/diffusion/distributed/test_sp_plan_hooks.py @@ -1113,6 +1113,120 @@ class TestStrictModeSplitValidation: hook._prepare_sp_input(x, metadata["x"], (), {}) +@pytest.mark.cpu +class TestEqualPadContract: + """The stack of per-boundary auto_pad flags that lets attention skip a gather. + + A boundary is recorded as equal-length only when the framework padded it, + because only then is every rank's shard guaranteed the same size. + """ + + def _split_hook(self, *, auto_pad: bool, monkeypatch): + from vllm_omni.diffusion.attention import selector + from vllm_omni.diffusion.distributed import parallel_state, sp_sharding + from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig + from vllm_omni.diffusion.hooks.sequence_parallel import ( + SequenceParallelSplitHook, + ) + + class MaskBackend: + supports_attention_mask = True + + @staticmethod + def get_name(): + return "test" + + monkeypatch.setattr(parallel_state, "get_sequence_parallel_world_size", lambda: 2) + monkeypatch.setattr(parallel_state, "get_sequence_parallel_rank", lambda: 1) + monkeypatch.setattr(parallel_state, "get_ring_parallel_world_size", lambda: 1) + monkeypatch.setattr(selector, "get_attn_backend_for_role", lambda **_: (MaskBackend(), None)) + # sp_shard, used by the manual path, binds these at import time. + monkeypatch.setattr(sp_sharding, "get_sequence_parallel_world_size", lambda: 2) + monkeypatch.setattr(sp_sharding, "get_sequence_parallel_rank", lambda: 1) + + hook = SequenceParallelSplitHook( + { + 0: SequenceParallelInput( + split_dim=1, + expected_dims=3, + split_output=True, + auto_pad=auto_pad, + ) + }, + SequenceParallelConfig(ulysses_degree=2), + ) + hook.initialize_hook(nn.Identity()) + return hook + + def test_auto_pad_split_claims_equal_rank_lengths(self, monkeypatch): + from vllm_omni.diffusion.forward_context import ( + get_forward_context, + set_forward_context, + ) + + hook = self._split_hook(auto_pad=True, monkeypatch=monkeypatch) + with set_forward_context(): + hook.pre_forward(nn.Identity()) + shard = hook.post_forward(nn.Identity(), torch.arange(5).reshape(1, 5, 1)) + ctx = get_forward_context() + + # 5 tokens padded to 6, so both ranks hold 3. + assert shard.shape[1] == 3 + assert ctx._sp_equal_pad_stack == [True] + assert ctx.sp_rank_local_seq_lens_equal + + def test_manual_split_does_not_claim_equal_rank_lengths(self, monkeypatch): + from vllm_omni.diffusion.forward_context import ( + get_forward_context, + set_forward_context, + ) + + hook = self._split_hook(auto_pad=False, monkeypatch=monkeypatch) + with set_forward_context(): + hook.pre_forward(nn.Identity()) + hook.post_forward(nn.Identity(), torch.arange(6).reshape(1, 6, 1)) + ctx = get_forward_context() + + assert ctx._sp_equal_pad_stack == [False] + assert not ctx.sp_rank_local_seq_lens_equal + + def test_gather_pops_only_its_own_boundary(self, monkeypatch): + """A stack, not a counter: the gather must not resurrect the contract. + + With one auto_pad and one manual boundary open, a counter could not + tell which one a gather closes and would wrongly report equal lengths. + """ + from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig + from vllm_omni.diffusion.forward_context import ( + get_forward_context, + set_forward_context, + ) + from vllm_omni.diffusion.hooks import sequence_parallel + from vllm_omni.diffusion.hooks.sequence_parallel import ( + SequenceParallelGatherHook, + ) + + monkeypatch.setattr( + sequence_parallel, + "sp_gather", + lambda tensor, dim, validate: torch.cat([tensor, tensor], dim=dim), + ) + hook = SequenceParallelGatherHook( + SequenceParallelOutput(gather_dim=1, expected_dims=3), + SequenceParallelConfig(ulysses_degree=2), + ) + + with set_forward_context(): + ctx = get_forward_context() + ctx._sp_shard_depth = 2 + ctx._sp_equal_pad_stack.extend([False, True]) + hook.post_forward(nn.Identity(), torch.zeros(1, 3, 4)) + + assert ctx._sp_shard_depth == 1 + assert ctx._sp_equal_pad_stack == [False] + assert not ctx.sp_rank_local_seq_lens_equal + + @pytest.mark.cpu class TestSequenceParallelConfig: """Test SequenceParallelConfig dataclass.""" diff --git a/vllm_omni/diffusion/attention/parallel/ulysses.py b/vllm_omni/diffusion/attention/parallel/ulysses.py index d56838196..cbaa8ba62 100644 --- a/vllm_omni/diffusion/attention/parallel/ulysses.py +++ b/vllm_omni/diffusion/attention/parallel/ulysses.py @@ -14,7 +14,11 @@ from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.parallel.base import ParallelAttentionContext from vllm_omni.diffusion.distributed.comm import SeqAllToAll4D from vllm_omni.diffusion.distributed.group_coordinator import SequenceParallelGroupCoordinator -from vllm_omni.diffusion.forward_context import get_ulysses_mode +from vllm_omni.diffusion.forward_context import ( + get_forward_context, + get_ulysses_mode, + is_forward_context_available, +) logger = init_logger(__name__) @@ -142,7 +146,7 @@ def _ulysses_all_to_all_any_qkv( input_split_sizes = [s_local] * world_size output_split_sizes = seq_lens - s_global = int(sum(output_split_sizes)) + s_global = sum(output_split_sizes) out = torch.empty((s_global, bsz, head_cnt_local, head_dim), device=x.device, dtype=x.dtype) dist.all_to_all_single( @@ -177,7 +181,7 @@ def _ulysses_all_to_all_any_o( return x bsz, s_global, head_cnt_local, head_dim = x.shape - s_local = int(local_seq_len) + s_local = local_seq_len # (B, S_global, H_local, D) -> (S_global, B, H_local, D) x_t = x.permute(1, 0, 2, 3).contiguous() @@ -411,9 +415,22 @@ class UlyssesParallelAttention: f"(got scatter_idx={self._scatter_idx}, gather_idx={self._gather_idx})." ) - local_seq_len = int(query.shape[1]) - seq_lens = _all_gather_int(self._ulysses_pg, local_seq_len, device=query.device) - s_global = int(sum(seq_lens)) + if is_forward_context_available() and get_forward_context().sp_rank_local_seq_lens_equal: + # auto_pad already made every rank's shard the same length, so + # the lengths are known without a collective. Keep local_seq_len + # as a SymInt (no int()) so torch.compile(dynamic=True) does not + # specialize on it, and skip the host sync + graph break that + # _all_gather_int would introduce in every attention call. + local_seq_len = query.shape[1] + seq_lens = [local_seq_len] * ulysses_world_size + else: + local_seq_len = int(query.shape[1]) + seq_lens = _all_gather_int( + self._ulysses_pg, + local_seq_len, + device=query.device, + ) + s_global = sum(seq_lens) # In hybrid Ulysses+Ring, Ring attention uses P2P send/recv with fixed-shape # buffers. This requires all ring ranks to have the same seq_len after the @@ -571,8 +588,8 @@ class UlyssesParallelAttention: joint_len=joint_len, joint_strategy=joint_strategy, use_uaa=(mode == "advanced_uaa"), - uaa_seq_lens=tuple(int(x) for x in seq_lens) if mode == "advanced_uaa" else (), - uaa_local_seq_len=int(local_seq_len) if mode == "advanced_uaa" else 0, + uaa_seq_lens=tuple(seq_lens) if mode == "advanced_uaa" else (), + uaa_local_seq_len=local_seq_len if mode == "advanced_uaa" else 0, orig_head_cnt=int(orig_head_cnt) if mode == "advanced_uaa" else 0, joint_orig_head_cnt=int(joint_orig_head_cnt) if mode == "advanced_uaa" else 0, ) diff --git a/vllm_omni/diffusion/forward_context.py b/vllm_omni/diffusion/forward_context.py index 8924e175e..1459da495 100644 --- a/vllm_omni/diffusion/forward_context.py +++ b/vllm_omni/diffusion/forward_context.py @@ -4,7 +4,7 @@ from __future__ import annotations from contextlib import contextmanager -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any import torch @@ -66,6 +66,10 @@ class ForwardContext: # Tracks the depth of SP sharding - incremented on shard, decremented on gather # Used by attention layers to determine if SP communication should be enabled _sp_shard_depth: int = 0 + # One entry per active SP split boundary: whether it auto-pads, which makes + # every rank's shard the same length and lets attention collectives skip a + # runtime length all-gather. Pushed on split, popped on gather. + _sp_equal_pad_stack: list[bool] = field(default_factory=list) @property def sp_active(self) -> bool: @@ -88,6 +92,15 @@ class ForwardContext: sp_size = self.omni_diffusion_config.parallel_config.sequence_parallel_size return sp_size is not None and sp_size > 1 + @property + def sp_rank_local_seq_lens_equal(self) -> bool: + """Whether every active SP boundary guarantees equal local shard sizes. + + Only framework-managed auto_pad boundaries provide this contract; a + region that also shards manually keeps the dynamic length exchange. + """ + return bool(self._sp_equal_pad_stack) and all(self._sp_equal_pad_stack) + def __post_init__(self): pass diff --git a/vllm_omni/diffusion/hooks/sequence_parallel.py b/vllm_omni/diffusion/hooks/sequence_parallel.py index 20800ffc0..95f5b0f47 100644 --- a/vllm_omni/diffusion/hooks/sequence_parallel.py +++ b/vllm_omni/diffusion/hooks/sequence_parallel.py @@ -264,6 +264,7 @@ class SequenceParallelSplitHook(ModelHook): output_list = [output] if is_tensor else list(output) actually_sharded = False + equal_rank_seq_lens = True for index, spm in self.metadata.items(): if not isinstance(index, int): @@ -277,10 +278,13 @@ class SequenceParallelSplitHook(ModelHook): output_list[index] = self._prepare_sp_input(original, spm, self._last_args, self._last_kwargs) if output_list[index] is not original: actually_sharded = True + equal_rank_seq_lens &= isinstance(spm, SequenceParallelInput) and spm.auto_pad # Mark SP as active only if at least one tensor was actually sharded if actually_sharded and is_forward_context_available(): - get_forward_context()._sp_shard_depth += 1 + ctx = get_forward_context() + ctx._sp_shard_depth += 1 + ctx._sp_equal_pad_stack.append(equal_rank_seq_lens) return output_list[0] if is_tensor else type(output)(output_list) @@ -553,6 +557,8 @@ class SequenceParallelGatherHook(ModelHook): if actually_gathered and is_forward_context_available(): ctx = get_forward_context() ctx._sp_shard_depth = max(0, ctx._sp_shard_depth - 1) + if ctx._sp_equal_pad_stack: + ctx._sp_equal_pad_stack.pop() return output[0] if is_tensor else type(output)(output) From af74a5a1551e3b809f7d5cddfbba070deb4bb298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Sun, 6 Sep 2026 20:23:36 +0800 Subject: [PATCH 02/90] [Feature][MiniMax-H3] Support the full LightX2V Turbo LoRA matrix (#7062) Co-authored-by: Claude Opus 5 --- docs/user_guide/diffusion/lora.md | 10 +- recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md | 18 +- recipes/MiniMaxAI/MiniMax-H3.md | 82 ++++++- .../minimax_h3/test_minimax_h3_contract.py | 18 +- .../minimax_h3/test_minimax_h3_fasth3.py | 2 +- .../models/minimax_h3/test_minimax_h3_lora.py | 211 ++++++++++++++---- .../minimax_h3/test_minimax_h3_native_lora.py | 14 +- .../test_minimax_h3_turbo_matrix.py | 86 +++++++ vllm_omni/diffusion/models/minimax_h3/lora.py | 175 ++++++++++++--- .../models/minimax_h3/pipeline_minimax_h3.py | 82 ++++--- 10 files changed, 557 insertions(+), 141 deletions(-) create mode 100644 tests/diffusion/models/minimax_h3/test_minimax_h3_turbo_matrix.py diff --git a/docs/user_guide/diffusion/lora.md b/docs/user_guide/diffusion/lora.md index 3c8582af0..15817db34 100644 --- a/docs/user_guide/diffusion/lora.md +++ b/docs/user_guide/diffusion/lora.md @@ -249,8 +249,14 @@ MiniMax-H3 supports two few-step mechanisms that must not be conflated: - **Checkpoint-pinned schedule**: a merged release writes `base_schedule` into `model_index.json`. Requests must pass `num_inference_steps` as the interval count (for example `4` for `[1.0, 0.7, 0.4, 0.15, 0.0]`). -- **Runtime Turbo LoRA**: LightX2V Turbo artifacts request five sigma points and - enforce `flow_shift=6`, `audio_flow_shift=3`. +- **Runtime Turbo LoRA**: each LightX2V Turbo artifact carries its own contract, + read from its filename. A four-step artifact requests five sigma points and an + eight-step one requests nine; the 768p retrains enforce `flow_shift=6` and the + 544p artifacts `flow_shift=12`. `audio_flow_shift=3` across the family. A + request that does not match the loaded artifact is rejected by name. Alpha + comes from the artifact's metadata, or 8 when it declares none. `ref2v` + artifacts require `--task-type ref2va`; `fl2v` artifacts serve T2VA and FL2VA. + The ComfyUI exports use a fused-QKV layout and are not supported. - **Runtime native LoRA**: FlashGen-style artifacts declare `key_format=minimax-h3-native` and embed `base_schedule` in safetensors metadata. When active, the adapter schedule overrides the base checkpoint and diff --git a/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md b/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md index f4b1e35ae..62f744ee5 100644 --- a/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md +++ b/recipes/MiniMaxAI/MiniMax-H3-Disaggregated.md @@ -68,16 +68,24 @@ The `/v1/videos` request schema and `extra_params.task` values (`t2va`, ## Turbo LoRA -MiniMax-H3 Turbo uses five sigma points, `flow_shift=6`, and -`audio_flow_shift=3`. Start Turbo deployments with the dedicated defaults so -requests that omit sampling controls do not inherit the 50-step base schedule: +The deploy config below carries the four-step 768p contract -- five sigma +points, `flow_shift=6`, `audio_flow_shift=3` -- so requests that omit sampling +controls do not inherit the 50-step base schedule: ```bash vllm-omni serve MiniMaxAI/MiniMax-H3 \ --omni \ - --lora-path /path/to/MiniMax-H3-Turbo \ + --lora-path /path/to/minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors \ --deploy-config vllm_omni/deploy/minimax_h3_disaggregated_turbo.yaml ``` +`--lora-path` must name one artifact, or a directory holding exactly one; the +Turbo repository holds several and a directory of them is rejected as +ambiguous. Serving a different artifact means overriding those defaults with +that artifact's own contract -- see the +[Turbo LoRA table](MiniMax-H3.md#turbo-lora) for every published row. + The base deployment intentionally retains 50 inference steps for non-LoRA -quality. Turbo LoRA supports T2VA and FL2VA requests, not Ref2VA. +quality. This disaggregated topology is validated on the `fl2v` artifacts, +which serve T2VA and FL2VA; the `ref2v` artifacts need a `--task-type ref2va` +server. diff --git a/recipes/MiniMaxAI/MiniMax-H3.md b/recipes/MiniMaxAI/MiniMax-H3.md index 2f705812b..812ed6f30 100644 --- a/recipes/MiniMaxAI/MiniMax-H3.md +++ b/recipes/MiniMaxAI/MiniMax-H3.md @@ -869,13 +869,51 @@ balanced switch order. ### Turbo LoRA -Only the native Diffusers 4-step FL2VA/T2VA v1.0 artifact is supported: +The eight Diffusers-layout LightX2V Turbo artifacts are supported. The +filename records the contract, so the server reads the step count, task family +and flow shift from it and validates each request against the artifact that is +loaded. It does not rewrite request or deploy-config sampling values: the +request must carry that artifact's own settings, listed here, or it is +rejected. -```text -lightx2v/Minimax-h3-Turbo/minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors -``` +| Artifact | Task | Forwards | `num_inference_steps` | `flow_shift` | declared `alpha` | +| --- | --- | ---: | ---: | ---: | ---: | +| `minimax_h3_fl2v_turbo_4step_v0.1.safetensors` | T2VA / FL2VA | 4 | 5 | 12 | none -> 8 | +| `minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors` | T2VA / FL2VA | 4 | 5 | 6 | 128 | +| `minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors` | T2VA / FL2VA | 4 | 5 | 6 | 128 | +| `minimax_h3_fl2v_turbo_4step_v1.2_768p_bf16.safetensors` | T2VA / FL2VA | 4 | 5 | 6 | 8 | +| `minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors` | T2VA / FL2VA | 8 | 9 | 12 | 8 | +| `minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors` | T2VA / FL2VA | 8 | 9 | 6 | 8 | +| `minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors` | Ref2VA | 4 | 5 | 12 | 8 | +| `minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors` | Ref2VA | 8 | 9 | 6 | 8 | -Download only that file: +`audio_flow_shift` is `3.0` across the family. Each row is the complete +published filename; use it verbatim as `TURBO_FILE` below. + +Alpha needs no manual compensation: the server reads it from the artifact's +metadata, falling back to 8 with a warning for +`minimax_h3_fl2v_turbo_4step_v0.1`, the one artifact that declares none. The +request-level `scale` is a further multiplier on top of it. + +> [!NOTE] +> Every artifact is rank 128 and the delta is applied at `scale * alpha / +> rank`, so the `alpha=8` rows drive at 1/16 the strength of the `alpha=128` +> rows. 8 is the default of LightX2V's reference script, which never reads the +> metadata; its documented `v0.1` command does not override that default. + +Every artifact except `minimax_h3_fl2v_turbo_4step_v0.1` also ships a +`_comfyui_` export of the same weights. Those fuse Q/K/V into one projection +and are **not** supported; downloading one is refused by name. Take the +Diffusers file. The filename is the contract, so do not rename an artifact +either -- a renamed file is rejected rather than served on a guess. + +FL2VA artifacts serve `t2va` and `fl2va` on any FL2VA or combined server. +Ref2VA artifacts require +`--task-type ref2va`: a combined server serves `ref2va` from a second DiT that +the adapter cannot bind to, so loading one there is refused rather than silently +running an undistilled model on the few-step schedule. + +Download the artifact you want: ```bash export TURBO_DIR=/path/to/minimax-h3-turbo @@ -884,10 +922,19 @@ hf download lightx2v/Minimax-h3-Turbo "${TURBO_FILE}" --local-dir "${TURBO_DIR}" export TURBO_LORA="${TURBO_DIR}/${TURBO_FILE}" ``` +`--lora-path` accepts one artifact, or a directory holding exactly one. + +> [!IMPORTANT] +> This changes earlier behaviour. `--lora-path /path/to/minimax-h3-turbo` +> pointing at a full clone of the Turbo repository used to select the v1.0 768p +> file implicitly; a directory holding several recognized artifacts is now +> rejected as ambiguous. Name the artifact instead: +> `--lora-path /path/to/minimax-h3-turbo/minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors`. + Start from a non-offloaded or DLO FL2VA server command and add `--task-type fl2va --lora-backend peft --lora-path "${TURBO_LORA}"`. -`--lora-path` preloads the adapter; each request still activates it and uses -the published sampling settings: +`--lora-path` preloads the adapter; each request still activates it and must +carry that artifact's sampling settings: ```bash -F 'num_inference_steps=5' \ @@ -896,14 +943,25 @@ the published sampling settings: -F "lora={\"name\":\"h3-turbo-v1.0\",\"path\":\"${TURBO_LORA}\",\"scale\":1.0}" ``` -For FL2VA, change `task` and add `input_reference` as shown above. The 8-step, -ComfyUI, Ref2VA, and v1.1 artifacts are not supported. This integration is -dynamic-only and does not support prefusion or LoRA composition. DLO is +Switching to another FL2VA artifact means repointing `TURBO_FILE`, which moves +both `--lora-path` and the request's `lora.path`, and carrying that row's +`num_inference_steps` and `flow_shift`: `9` and `6` for `8step_v1.0_768p`, `9` +and `12` for the 544p `8step_v1.0`. A request that does not match the loaded +artifact is rejected, so a mismatch cannot silently degrade output. + +The two `ref2v` rows are not served by this FL2VA command. Start a +`--task-type ref2va` server, take a request from +[Ref2VA](#3-ref2va-image-only-imageaudio-or-mixed-references) and override +`num_inference_steps` and `flow_shift` with that row's values; those examples +already send `audio_flow_shift=3.0`. + +For FL2VA, change `task` and add `input_reference` as shown above. This +integration is dynamic-only and does not support prefusion or LoRA composition. DLO is supported by keeping the request-switchable LoRA A/B buffers resident on the accelerator while DLO streams only the base blocks; budget for this additional fixed HBM usage. Model-level and standard layerwise offload remain unsupported. -The five requested sigma points produce the four denoiser evaluations expected -by the Turbo artifact. +The requested sigma points always number one more than the artifact's denoiser +evaluations. ### FlashGen native LoRA diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py index 791c8d79d..b11473498 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -13,6 +13,8 @@ import pytest import torch import torch.nn as nn +from vllm_omni.diffusion.models.minimax_h3.lora import TurboSpec, parse_turbo_filename + pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] _ItemT = TypeVar("_ItemT") @@ -24,6 +26,13 @@ def _append_and_return(items: list[_ItemT], item: _ItemT, result: _ResultT) -> _ return result +def _turbo_spec(filename: str) -> TurboSpec: + """Build the spec a loaded artifact of this name would carry.""" + spec = parse_turbo_filename(filename) + assert spec is not None + return spec + + def test_h3_prepares_resolved_cache_state_immediately_before_denoise(): from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline from vllm_omni.diffusion.request import OmniDiffusionRequest @@ -244,9 +253,12 @@ def test_combined_task_inference_and_transformer_routing(): assert pipeline._resolve_task(None, {"image": object()}) == "fl2va" assert pipeline._resolve_task(None, {"audio": object()}) == "ref2va" assert pipeline._resolve_task(None, {"video": object()}) == "ref2va" - assert pipeline._resolve_task("fl2va", {"image": object()}, has_turbo_lora=True) == "fl2va" - with pytest.raises(OmniClientError, match="supports T2VA/FL2VA requests only"): - pipeline._resolve_task("ref2va", {}, has_turbo_lora=True) + fl2v_spec = _turbo_spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors") + ref2v_spec = _turbo_spec("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors") + assert pipeline._resolve_task("fl2va", {"image": object()}, turbo_spec=fl2v_spec) == "fl2va" + with pytest.raises(OmniClientError, match="serves"): + pipeline._resolve_task("ref2va", {}, turbo_spec=fl2v_spec) + assert pipeline._resolve_task("ref2va", {"image": object()}, turbo_spec=ref2v_spec) == "ref2va" pipeline.partition = "ref2va" pipeline.supported_tasks = frozenset({"ref2va"}) assert pipeline._resolve_task(None, {"image": object()}) == "ref2va" diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_fasth3.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_fasth3.py index bcd0deec0..5c874ff02 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_fasth3.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_fasth3.py @@ -303,7 +303,7 @@ def _pipeline_stub(fusion=None): torch.nn.Module.__init__(pipeline) pipeline.partition = "combined" pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"}) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._fasth3 = fusion pipeline.od_config = SimpleNamespace() pipeline.default_video_shift, pipeline.default_audio_shift = 12.0, 3.0 diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_lora.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_lora.py index c493ac58f..9039f94f8 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_lora.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_lora.py @@ -13,7 +13,9 @@ from vllm.lora.lora_weights import PackedLoRALayerWeights from vllm_omni.diffusion.lora.manager import DiffusionLoRAManager from vllm_omni.diffusion.models.minimax_h3 import lora as lora_module from vllm_omni.diffusion.models.minimax_h3.lora import ( + TurboSpec, load_minimax_h3_turbo_lora, + parse_turbo_filename, ) from vllm_omni.errors import OmniClientError from vllm_omni.lora.request import LoRARequest @@ -46,7 +48,7 @@ def _request(path) -> LoRARequest: def _write_tiny_turbo( path, *, - alpha: str = "128", + alpha: str | None = "128", key_format: str = "minimax-h3-diffusers", omit_target: str | None = None, shape_overrides: dict[str, tuple[int, int]] | None = None, @@ -88,11 +90,17 @@ def _write_tiny_turbo( ) else: tensors[b_name] = torch.ones(overrides.get(b_name, (output_dim, rank))) - save_file( - tensors, - str(path), - metadata={"alpha": alpha, "key_format": key_format}, - ) + metadata = {"key_format": key_format} + if alpha is not None: + metadata["alpha"] = alpha + save_file(tensors, str(path), metadata=metadata) + + +def _spec(filename: str = "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors") -> TurboSpec: + """Build the spec a loaded artifact of this name would carry.""" + spec = parse_turbo_filename(filename) + assert spec is not None + return spec def test_h3_turbo_loads_through_legacy_lora_model_and_swaps_ffn(tmp_path): @@ -107,9 +115,12 @@ def test_h3_turbo_loads_through_legacy_lora_model_and_swaps_ffn(tmp_path): ) assert loaded is not None - lora_model, peft_helper = loaded + lora_model, peft_helper, spec = loaded assert peft_helper.r == 128 assert peft_helper.lora_alpha == 128 + assert spec.denoise_steps == 4 + assert spec.sigma_points == 5 + assert spec.task_family == "fl2v" assert len(lora_model.loras) == 312 assert "blocks.0.attn.to_q" in lora_model.loras assert "blocks.0.mlp.fc1" in lora_model.loras @@ -152,7 +163,8 @@ def test_legacy_manager_uses_the_h3_model_loader_without_changing_its_interface( class _Pipeline: def _load_diffusion_lora_adapter(self, **kwargs): - return load_minimax_h3_turbo_lora(partition="fl2va", **kwargs) + lora_model, peft_helper, _spec_unused = load_minimax_h3_turbo_lora(partition="fl2va", **kwargs) + return lora_model, peft_helper manager = object.__new__(DiffusionLoRAManager) manager.pipeline = _Pipeline() @@ -168,22 +180,30 @@ def test_legacy_manager_uses_the_h3_model_loader_without_changing_its_interface( assert "blocks.0.mlp.fc1" in lora_model.loras -def test_h3_turbo_rejects_wrong_alpha_and_ref2va(tmp_path): - wrong_alpha = tmp_path / "wrong_alpha" / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors" - wrong_alpha.parent.mkdir() - _write_tiny_turbo(wrong_alpha, alpha="8") - with pytest.raises(ValueError, match="requires alpha=128"): - load_minimax_h3_turbo_lora( - partition="fl2va", - lora_request=_request(wrong_alpha), - lora_path=wrong_alpha, - dtype=torch.float32, - ) +def test_h3_turbo_honours_the_declared_alpha_and_task_family(tmp_path): + """Alpha comes from the artifact; the family decides which server serves it.""" + + eight_step_alpha = tmp_path / "alpha8" / "minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors" + eight_step_alpha.parent.mkdir() + _write_tiny_turbo(eight_step_alpha, alpha="8") + loaded = load_minimax_h3_turbo_lora( + partition="fl2va", + lora_request=_request(eight_step_alpha), + lora_path=eight_step_alpha, + dtype=torch.float32, + ) + assert loaded is not None + _, peft_helper, spec = loaded + # The eight-step artifact is a 1/16-strength adapter; pinning alpha to the + # four-step value would over-drive it by 16x. + assert peft_helper.lora_alpha == 8 + assert spec.alpha == 8 + assert spec.sigma_points == 9 valid = tmp_path / "valid" / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors" valid.parent.mkdir() _write_tiny_turbo(valid) - with pytest.raises(ValueError, match="supports FL2VA/T2VA only"): + with pytest.raises(ValueError, match="Ref2VA-only server cannot serve it"): load_minimax_h3_turbo_lora( partition="ref2va", lora_request=_request(valid), @@ -192,6 +212,43 @@ def test_h3_turbo_rejects_wrong_alpha_and_ref2va(tmp_path): ) +@pytest.mark.parametrize("partition", ["fl2va", "combined"]) +def test_h3_turbo_ref2v_artifact_needs_a_ref2va_only_server(tmp_path, partition): + """A combined server routes ref2va to ``transformers_ref``, which the + adapter cannot bind to: the LoRA target pattern only injects into + ``transformer``. Admitting it there would run an undistilled DiT on the + artifact's few-step schedule with no error anywhere.""" + + path = tmp_path / "minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors" + _write_tiny_turbo(path) + + with pytest.raises(ValueError, match="start the server with --task-type ref2va"): + load_minimax_h3_turbo_lora( + partition=partition, + lora_request=_request(path), + lora_path=path, + dtype=torch.float32, + ) + + +def test_h3_turbo_ref2v_artifact_loads_on_a_ref2va_server(tmp_path): + path = tmp_path / "minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors" + _write_tiny_turbo(path) + + loaded = load_minimax_h3_turbo_lora( + partition="ref2va", + lora_request=_request(path), + lora_path=path, + dtype=torch.float32, + ) + + assert loaded is not None + spec = loaded[2] + assert spec.task_family == "ref2v" + assert spec.supported_tasks == frozenset({"ref2va"}) + assert spec.sigma_points == 9 + + @pytest.mark.parametrize( "offload_mode", [ @@ -214,6 +271,8 @@ def test_h3_turbo_rejects_offload_modes(tmp_path, offload_mode): def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch): + """DLO keeps the LoRA buffers resident, so it is not an unsupported mode.""" + from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline from vllm_omni.diffusion.models.minimax_h3 import ( pipeline_minimax_h3 as pipeline_module, @@ -222,7 +281,7 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch): pipeline = object.__new__(MiniMaxH3Pipeline) torch.nn.Module.__init__(pipeline) pipeline.partition = "fl2va" - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} pipeline.od_config = SimpleNamespace( @@ -231,10 +290,11 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch): enable_distributed_layerwise_offload=True, ) captured = {} + spec = _spec() def load_turbo(**kwargs): captured.update(kwargs) - return object(), object() + return object(), object(), spec monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", load_turbo) loaded = pipeline._load_diffusion_lora_adapter( @@ -245,6 +305,10 @@ def test_h3_turbo_allows_distributed_layerwise_offload(monkeypatch): assert loaded is not None assert captured["unsupported_offload_mode"] is None + # The pipeline passes its own partition through and keeps the spec that + # decides the request contract. + assert captured["partition"] == "fl2va" + assert pipeline._turbo_lora_specs == {1: spec} @pytest.mark.parametrize( @@ -268,7 +332,7 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection( pipeline = object.__new__(MiniMaxH3Pipeline) torch.nn.Module.__init__(pipeline) pipeline.partition = "fl2va" - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} pipeline.od_config = SimpleNamespace( @@ -281,10 +345,11 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection( pin_cpu_memory=True, ) captured = {} + spec = _spec() def load_turbo(**kwargs): captured.update(kwargs) - return object(), object() + return object(), object(), spec monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", load_turbo) @@ -296,31 +361,84 @@ def test_h3_turbo_offload_compatibility_follows_dit_selection( assert captured["unsupported_offload_mode"] == expected_mode -def test_h3_turbo_accepts_only_the_declared_v1_artifact(tmp_path): - unsupported = tmp_path / "minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors" - _write_tiny_turbo(unsupported) +def test_h3_turbo_uses_the_reference_alpha_when_the_artifact_declares_none(tmp_path): + """``4step_v0.1`` ships no alpha. LightX2V's reference script applies + ``scale * alpha / rank`` with alpha defaulting to 8, so the fallback must be + 8; alpha == rank would drive the adapter 16x too strongly.""" - with pytest.raises(ValueError, match="supports only"): + path = tmp_path / "minimax_h3_fl2v_turbo_4step_v0.1.safetensors" + _write_tiny_turbo(path, alpha=None) + + loaded = load_minimax_h3_turbo_lora( + partition="fl2va", + lora_request=_request(path), + lora_path=path, + dtype=torch.float32, + ) + + assert loaded is not None + _, peft_helper, spec = loaded + assert spec.alpha == 8 + assert peft_helper.lora_alpha == 8 + assert spec.alpha / spec.rank == 0.0625 + + +def test_h3_turbo_declines_the_comfyui_export(tmp_path): + """The ComfyUI exports fuse Q/K/V, so they are not this loader's format.""" + + path = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors" + _write_tiny_turbo(path) + + assert ( load_minimax_h3_turbo_lora( partition="fl2va", - lora_request=_request(unsupported), - lora_path=unsupported, + lora_request=_request(path), + lora_path=path, dtype=torch.float32, ) + is None + ) - supported = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors" - _write_tiny_turbo(supported) - # Directory resolution selects the declared artifact even when another - # v1.0 checkpoint is present beside it. - assert ( + +def test_h3_turbo_accepts_every_published_artifact_name(tmp_path): + """Each published filename loads and carries its own sampler contract.""" + + for index, (name, steps, shift) in enumerate( + ( + ("minimax_h3_fl2v_turbo_4step_v0.1.safetensors", 4, 12.0), + ("minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors", 4, 6.0), + ("minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors", 8, 12.0), + ("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors", 8, 6.0), + ) + ): + directory = tmp_path / f"artifact{index}" + directory.mkdir() + path = directory / name + _write_tiny_turbo(path) + loaded = load_minimax_h3_turbo_lora( + partition="fl2va", + lora_request=_request(path), + lora_path=path, + dtype=torch.float32, + ) + assert loaded is not None, name + spec = loaded[2] + assert spec.denoise_steps == steps + assert spec.sigma_points == steps + 1 + assert spec.video_shift == shift + + +def test_h3_turbo_directory_with_several_artifacts_is_ambiguous(tmp_path): + _write_tiny_turbo(tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors") + _write_tiny_turbo(tmp_path / "minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors") + + with pytest.raises(ValueError, match="point --lora-path at one file"): load_minimax_h3_turbo_lora( partition="fl2va", lora_request=_request(tmp_path), lora_path=tmp_path, dtype=torch.float32, ) - is not None - ) def test_h3_turbo_rejects_a_truncated_declared_artifact(tmp_path): @@ -352,7 +470,7 @@ def test_non_h3_checkpoint_falls_back_to_the_generic_peft_loader(tmp_path): declared = tmp_path / "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors" _write_tiny_turbo(declared, key_format="other") - with pytest.raises(ValueError, match="requires safetensors metadata"): + with pytest.raises(ValueError, match="expected 'minimax-h3-diffusers'"): load_minimax_h3_turbo_lora( partition="fl2va", lora_request=_request(declared), @@ -371,7 +489,7 @@ def test_only_an_active_recognized_turbo_adapter_restricts_ref2va(monkeypatch): torch.nn.Module.__init__(pipeline) pipeline.partition = "combined" pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"}) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} request = _request("generic-peft") @@ -388,14 +506,14 @@ def test_only_an_active_recognized_turbo_adapter_restricts_ref2va(monkeypatch): return pipeline._resolve_task( "ref2va", {}, - has_turbo_lora=pipeline._has_active_turbo_lora(sampling), + turbo_spec=pipeline._active_turbo_spec(sampling), ) - recognized = (object(), object()) + recognized = (object(), object(), _spec()) monkeypatch.setattr(pipeline_module, "load_minimax_h3_turbo_lora", lambda **_: recognized) - assert load() is recognized + assert load() == recognized[:2] assert resolve(0.0) == "ref2va" - with pytest.raises(OmniClientError, match="supports T2VA/FL2VA requests only"): + with pytest.raises(OmniClientError, match="serves"): resolve(1.0) # Simulate manager eviction followed by a generic PEFT adapter reusing the @@ -410,7 +528,7 @@ def test_h3_turbo_requires_all_loaded_targets_to_bind(): pipeline = object.__new__(MiniMaxH3Pipeline) torch.nn.Module.__init__(pipeline) - pipeline._turbo_lora_adapter_ids = {1} + pipeline._turbo_lora_specs = {1: _spec()} lora_model = SimpleNamespace(id=1, loras={"to_q": object(), "to_k": object()}) pipeline._validate_diffusion_lora_binding( @@ -448,7 +566,7 @@ def test_h3_turbo_rejects_unsupported_sampling(num_inference_steps, extra_args, ) with pytest.raises(OmniClientError, match=error): - pipeline._validate_turbo_sampling(sampling) + pipeline._validate_turbo_sampling(sampling, _spec()) def test_h3_turbo_accepts_five_sigma_points_for_four_nfe(): @@ -461,5 +579,6 @@ def test_h3_turbo_accepts_five_sigma_points_for_four_nfe(): SimpleNamespace( num_inference_steps=5, extra_args={"flow_shift": 6.0, "audio_flow_shift": 3.0}, - ) + ), + _spec(), ) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_native_lora.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_native_lora.py index c4e581381..28f074a19 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_native_lora.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_native_lora.py @@ -309,7 +309,7 @@ def test_pipeline_native_schedule_and_task_validation(monkeypatch): torch.nn.Module.__init__(pipeline) pipeline.partition = "fl2va" pipeline.supported_tasks = frozenset({"t2va", "fl2va", "ref2va"}) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = {7} pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])} pipeline._base_schedule_by_partition = {"fl2va": None} @@ -330,7 +330,7 @@ def test_pipeline_native_schedule_and_task_validation(monkeypatch): pipeline._resolve_task( "fl2va", {}, - has_turbo_lora=False, + turbo_spec=None, has_native_lora=True, ) @@ -418,7 +418,7 @@ def test_pipeline_replaces_native_classification_after_reload(monkeypatch, tmp_p enable_layerwise_offload=False, enable_distributed_layerwise_offload=False, ) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} @@ -472,7 +472,7 @@ def test_h3_native_allows_distributed_layerwise_offload(monkeypatch): enable_layerwise_offload=False, enable_distributed_layerwise_offload=True, ) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} captured: dict[str, object] = {} @@ -612,7 +612,7 @@ def test_legacy_manager_uses_native_loader(tmp_path): enable_layerwise_offload=False, enable_distributed_layerwise_offload=False, ) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} return pipeline._load_diffusion_lora_adapter(**kwargs) @@ -634,7 +634,7 @@ def test_pipeline_schedule_inactive_when_scale_zero(): pipeline = object.__new__(MiniMaxH3Pipeline) torch.nn.Module.__init__(pipeline) pipeline.partition = "fl2va" - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = {7} pipeline._lora_sigma_schedules = {7: DMD2SigmaSchedule.from_positions([1.0, 0.7, 0.4, 0.15, 0.0])} pipeline._base_schedule_by_partition = {"fl2va": None} @@ -664,7 +664,7 @@ def test_pipeline_schedule_falls_back_after_eviction(monkeypatch, tmp_path): enable_layerwise_offload=False, enable_distributed_layerwise_offload=False, ) - pipeline._turbo_lora_adapter_ids = set() + pipeline._turbo_lora_specs = {} pipeline._native_lora_adapter_ids = set() pipeline._lora_sigma_schedules = {} pipeline._base_schedule_by_partition = {"fl2va": None} diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_turbo_matrix.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_turbo_matrix.py new file mode 100644 index 000000000..35e46af6b --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_turbo_matrix.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Contract coverage for the LightX2V MiniMax-H3 Turbo artifact matrix.""" + +from __future__ import annotations + +import pytest + +from vllm_omni.diffusion.models.minimax_h3.lora import ( + _TURBO_AUDIO_SHIFT, + _TURBO_VIDEO_SHIFT_544P, + _TURBO_VIDEO_SHIFT_768P, + TurboSpec, + parse_turbo_filename, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + +# The eight Diffusers-layout artifacts published at lightx2v/Minimax-h3-Turbo, +# with the contract each one implies. The ComfyUI export of an artifact is a +# fused-QKV re-packing of the same weights and is not served. +PUBLISHED = [ + ("minimax_h3_fl2v_turbo_4step_v0.1.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_544P), + ("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P), + ("minimax_h3_fl2v_turbo_4step_v1.1_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P), + ("minimax_h3_fl2v_turbo_4step_v1.2_768p_bf16.safetensors", "fl2v", 4, _TURBO_VIDEO_SHIFT_768P), + ("minimax_h3_fl2v_turbo_8step_v1.0_bf16.safetensors", "fl2v", 8, _TURBO_VIDEO_SHIFT_544P), + ("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors", "fl2v", 8, _TURBO_VIDEO_SHIFT_768P), + ("minimax_h3_ref2v_turbo_4step_v0.1_bf16.safetensors", "ref2v", 4, _TURBO_VIDEO_SHIFT_544P), + ("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors", "ref2v", 8, _TURBO_VIDEO_SHIFT_768P), +] + + +@pytest.mark.parametrize(("filename", "task", "steps", "video_shift"), PUBLISHED) +def test_every_published_artifact_is_recognised(filename: str, task: str, steps: int, video_shift: float) -> None: + spec = parse_turbo_filename(filename) + assert spec is not None, filename + assert spec.filename == filename + assert spec.task_family == task + assert spec.denoise_steps == steps + assert spec.video_shift == video_shift + assert spec.audio_shift == _TURBO_AUDIO_SHIFT + # Until the loader reads metadata, alpha is LightX2V's reference default. + assert spec.rank == 128 + assert spec.alpha == 8.0 + + +def test_all_eight_artifacts_are_covered() -> None: + assert len(PUBLISHED) == 8 + assert len({name for name, *_ in PUBLISHED}) == 8 + + +@pytest.mark.parametrize( + "filename", + [ + "adapter_model.safetensors", + "minimax_h3_fl2v_turbo_v1.0_768p_bf16.safetensors", + "minimax_h3_t2v_turbo_4step_v1.0.safetensors", + "minimax_h3_fl2v_turbo_0step_v1.0.safetensors", + # The ComfyUI exports are a different tensor layout, not a variant of + # the supported one; the loader refuses them by name. + "minimax_h3_fl2v_turbo_4step_v1.0_768p_comfyui_bf16.safetensors", + "minimax_h3_ref2v_turbo_8step_v1.0_768p_comfyui_bf16.safetensors", + ], +) +def test_unrelated_names_are_rejected(filename: str) -> None: + assert parse_turbo_filename(filename) is None + + +def _spec(filename: str) -> TurboSpec: + spec = parse_turbo_filename(filename) + assert spec is not None + return spec + + +def test_sigma_points_follow_the_step_count() -> None: + assert _spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors").sigma_points == 5 + assert _spec("minimax_h3_fl2v_turbo_8step_v1.0_768p_bf16.safetensors").sigma_points == 9 + + +def test_task_family_decides_supported_tasks() -> None: + fl2v = _spec("minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors") + ref2v = _spec("minimax_h3_ref2v_turbo_8step_v1.0_768p_bf16.safetensors") + assert fl2v.supported_tasks == frozenset({"t2va", "fl2va"}) + assert ref2v.supported_tasks == frozenset({"ref2va"}) diff --git a/vllm_omni/diffusion/models/minimax_h3/lora.py b/vllm_omni/diffusion/models/minimax_h3/lora.py index 026d9f6b3..fced23de3 100644 --- a/vllm_omni/diffusion/models/minimax_h3/lora.py +++ b/vllm_omni/diffusion/models/minimax_h3/lora.py @@ -1,20 +1,25 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project -"""Loader for the LightX2V Turbo MiniMax-H3 LoRA. +"""Loader for the LightX2V Turbo MiniMax-H3 LoRA family. -Keyed by ``key_format=minimax-h3-diffusers``. The native FlashGen contract lives -in ``.npu.lora``; the two artifacts differ in rank, target naming and QKV -layout, so each owns its own parsing and packing. +The published artifacts differ in task family, step count, training resolution +and LoRA alpha, so :class:`TurboSpec` carries the contract of the file actually +loaded and the pipeline validates each request against it. Only the +Diffusers-PEFT exports are served. The native FlashGen contract lives in +``.npu.lora``. """ from __future__ import annotations import math +from dataclasses import dataclass, replace from pathlib import Path +import regex as re import torch from safetensors import safe_open +from vllm.logger import init_logger from vllm.lora.lora_model import LoRAModel from vllm.lora.lora_weights import PackedLoRALayerWeights from vllm.lora.peft_helper import PEFTHelper @@ -22,12 +27,79 @@ from vllm.model_executor.models.utils import WeightsMapper from vllm_omni.lora.request import LoRARequest +logger = init_logger(__name__) + _TURBO_RANK = 128 -_TURBO_ALPHA = 128 _TURBO_HIDDEN_SIZE = 5376 _TURBO_ATTENTION_INNER_SIZE = 7168 _TURBO_FFN_HIDDEN_SIZE = 14336 -_TURBO_FILENAME = "minimax_h3_fl2v_turbo_4step_v1.0_768p_bf16.safetensors" +# ``minimax_h3__turbo_step_v[_768p][_bf16]``. The name is +# the only place the sampler contract is recorded. +_TURBO_NAME_RE = re.compile( + r"^minimax_h3_(?Pfl2v|ref2v)_turbo_(?P\d+)step" + r"_v\d+\.\d+(?P_768p)?(?:_bf16)?\.safetensors$" +) +# The 768p retrains moved to a shorter video flow shift; the 544p artifacts keep +# the base model's. +_TURBO_VIDEO_SHIFT_768P = 6.0 +_TURBO_VIDEO_SHIFT_544P = 12.0 +_TURBO_AUDIO_SHIFT = 3.0 +# Only ``4step_v0.1`` declares no alpha. LightX2V's reference script never reads +# the metadata: it applies ``scale * alpha / rank`` with ``--lora-alpha`` +# defaulting to 8, and its documented v0.1 command does not override that. So an +# artifact that declares nothing is driven at 8, not at its rank. +_TURBO_DEFAULT_ALPHA = 8.0 + + +@dataclass(frozen=True) +class TurboSpec: + """The sampler contract one Turbo artifact was distilled for.""" + + filename: str + task_family: str + """``fl2v`` (serves t2va and fl2va) or ``ref2v`` (serves ref2va).""" + denoise_steps: int + video_shift: float + audio_shift: float + rank: int + alpha: float + + @property + def sigma_points(self) -> int: + """Sigma points the API contract expects: one more than the forwards.""" + return self.denoise_steps + 1 + + @property + def supported_tasks(self) -> frozenset[str]: + if self.task_family == "ref2v": + return frozenset({"ref2va"}) + return frozenset({"t2va", "fl2va"}) + + +def parse_turbo_filename(name: str) -> TurboSpec | None: + """Return the contract a Turbo filename encodes, or ``None``. + + ``alpha`` is the rank-matched default; the loader replaces it with the + artifact's declared value. + """ + + match = _TURBO_NAME_RE.match(name) + if match is None: + return None + steps = int(match.group("steps")) + if steps <= 0: + return None + return TurboSpec( + filename=name, + task_family=match.group("task"), + denoise_steps=steps, + video_shift=_TURBO_VIDEO_SHIFT_768P if match.group("res") else _TURBO_VIDEO_SHIFT_544P, + audio_shift=_TURBO_AUDIO_SHIFT, + rank=_TURBO_RANK, + alpha=_TURBO_DEFAULT_ALPHA, + ) + + _LORA_A_SUFFIX = ".lora_A.default.weight" _LORA_B_SUFFIX = ".lora_B.default.weight" _TURBO_TARGETS = frozenset({"to_q", "to_k", "to_v", "out_proj", "fc1", "fc2"}) @@ -81,11 +153,20 @@ def _select_turbo_file(artifact_path: str | Path) -> Path | None: if not path.is_dir(): return None - candidate = path / _TURBO_FILENAME - return candidate if candidate.is_file() else None + candidates = sorted(child for child in path.glob("*.safetensors") if parse_turbo_filename(child.name)) + if len(candidates) == 1: + return candidates[0] + if len(candidates) > 1: + raise ValueError( + f"{path} holds {len(candidates)} MiniMax-H3 Turbo artifacts " + f"({[c.name for c in candidates[:4]]}); point --lora-path at one file." + ) + return None def _validate_and_convert_tensors(checkpoint) -> dict[str, torch.Tensor]: + """Validate a Diffusers-layout Turbo tensor set and pack it for the manager.""" + tensors: dict[str, torch.Tensor] = {} pairs: dict[str, set[str]] = {} raw_targets: set[str] = set() @@ -134,7 +215,7 @@ def _validate_and_convert_tensors(checkpoint) -> dict[str, torch.Tensor]: unexpected = sorted(raw_targets - _TURBO_EXPECTED_RAW_TARGETS) if missing or unexpected: raise ValueError( - "MiniMax-H3 Turbo target set does not match the supported v1.0 artifact: " + "MiniMax-H3 Turbo target set does not match the published artifact layout: " f"missing={len(missing)} {missing[:5]}, unexpected={len(unexpected)} {unexpected[:5]}" ) return tensors @@ -164,39 +245,61 @@ def load_minimax_h3_turbo_lora( lora_path: str | Path, dtype: torch.dtype, unsupported_offload_mode: str | None = None, -) -> tuple[LoRAModel, PEFTHelper] | None: - """Load the published LightX2V Turbo v1.0 through the legacy manager.""" +) -> tuple[LoRAModel, PEFTHelper, TurboSpec] | None: + """Load any published LightX2V Turbo artifact through the legacy manager.""" lora_file = _select_turbo_file(lora_path) if lora_file is None: return None + spec = parse_turbo_filename(lora_file.name) + if spec is None: + # Not a Turbo artifact by name; leave it to the next loader. + return None with safe_open(lora_file, framework="pt", device="cpu") as checkpoint: + # Three published artifacts declare no key_format, so absence is not an + # error; a declaration naming a different format is. metadata = checkpoint.metadata() or {} - if metadata.get("key_format") != "minimax-h3-diffusers": - if lora_file.name == _TURBO_FILENAME: - raise ValueError( - "MiniMax-H3 Turbo v1.0 requires safetensors metadata key_format='minimax-h3-diffusers'" - ) - return None - if lora_file.name != _TURBO_FILENAME: - raise ValueError(f"MiniMax-H3 Turbo supports only {_TURBO_FILENAME!r}, got {lora_file.name!r}") + declared_format = metadata.get("key_format") + if declared_format not in (None, "minimax-h3-diffusers"): + raise ValueError( + f"{lora_file.name} declares key_format={declared_format!r}, expected 'minimax-h3-diffusers'" + ) raw_alpha = metadata.get("alpha") - try: - alpha = float(raw_alpha) if raw_alpha is not None else math.nan - except ValueError as exc: - raise ValueError(f"MiniMax-H3 Turbo alpha must be numeric, got {raw_alpha!r}") from exc - if alpha != _TURBO_ALPHA: - raise ValueError(f"MiniMax-H3 Turbo v1.0 requires alpha={_TURBO_ALPHA}, got {raw_alpha!r}") - if partition == "ref2va": - raise ValueError("MiniMax-H3 Turbo LoRA supports FL2VA/T2VA only") + if raw_alpha is None: + logger.warning( + "MiniMax-H3 Turbo artifact %s declares no alpha; using the LightX2V reference " + "default alpha=%g (scale %g). Override with the request-level LoRA scale.", + spec.filename, + spec.alpha, + spec.alpha / spec.rank, + ) + else: + try: + spec = replace(spec, alpha=float(raw_alpha)) + except (TypeError, ValueError) as exc: + raise ValueError(f"MiniMax-H3 Turbo alpha must be numeric, got {raw_alpha!r}") from exc + if not math.isfinite(spec.alpha) or spec.alpha <= 0: + raise ValueError(f"MiniMax-H3 Turbo alpha must be a positive number, got {raw_alpha!r}") + + if partition == "ref2va" and spec.task_family == "fl2v": + raise ValueError(f"{spec.filename} is an FL2VA/T2VA Turbo artifact; a Ref2VA-only server cannot serve it.") + # ``combined`` serves ref2va from ``transformers_ref``, but the LoRA + # target pattern only injects into ``transformer``: the adapter would + # bind to the stack that never runs. + if spec.task_family == "ref2v" and partition != "ref2va": + raise ValueError( + f"{spec.filename} is a Ref2VA Turbo artifact; start the server with --task-type ref2va " + f"(task_type={partition!r} serves ref2va from a DiT the adapter cannot bind to)" + ) if unsupported_offload_mode is not None: raise ValueError(f"MiniMax-H3 Turbo dynamic LoRA does not support {unsupported_offload_mode}") + tensors = _validate_and_convert_tensors(checkpoint) peft_helper = PEFTHelper.from_dict( { - "r": _TURBO_RANK, - "lora_alpha": _TURBO_ALPHA, + "r": spec.rank, + "lora_alpha": spec.alpha, "target_modules": _TURBO_TARGET_PATTERN, } ) @@ -209,4 +312,16 @@ def load_minimax_h3_turbo_lora( weights_mapper=_TURBO_WEIGHTS_MAPPER, ) _pack_h3_turbo_fc1(lora_model) - return lora_model, peft_helper + logger.info( + "MiniMax-H3 Turbo adapter %s: task=%s, %d denoiser forwards (%d sigma points), " + "rank=%d alpha=%g, flow_shift=%g/%g", + spec.filename, + spec.task_family, + spec.denoise_steps, + spec.sigma_points, + spec.rank, + spec.alpha, + spec.video_shift, + spec.audio_shift, + ) + return lora_model, peft_helper, spec diff --git a/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py b/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py index 22f638e9a..794d2dc09 100644 --- a/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py +++ b/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py @@ -125,7 +125,7 @@ from .denoise_loop import ( ) from .encoder import MiniMaxH3Qwen3VLEncoder from .fasth3 import FastH3WeightFusion, resolve_fasth3_fusion -from .lora import load_minimax_h3_turbo_lora +from .lora import TurboSpec, load_minimax_h3_turbo_lora from .minimax_h3_transformer import ( MiniMaxH3Attention, MiniMaxH3DiTModel, @@ -188,9 +188,6 @@ MINIMAX_H3_MAX_REFERENCE_IMAGE_BYTES = 30 * 1024 * 1024 MINIMAX_H3_REFERENCE_IMAGE_FORMATS = frozenset({"jpeg", "png", "webp", "heic", "heif"}) MINIMAX_H3_MIN_OUTPUT_SECONDS = 4.0 MINIMAX_H3_MAX_OUTPUT_SECONDS = 15.0 -MINIMAX_H3_TURBO_SIGMA_POINTS = 5 -MINIMAX_H3_TURBO_VIDEO_SHIFT = 6.0 -MINIMAX_H3_TURBO_AUDIO_SHIFT = 3.0 MINIMAX_H3_DOWNLOAD_PATTERNS = [ "FL2VA/**", "Ref2VA/model_index.json", @@ -706,7 +703,7 @@ class MiniMaxH3Pipeline( ) -> tuple[LoRAModel, PEFTHelper] | None: # A cache eviction may be followed by a different adapter reusing the # same client-supplied ID. Every real load replaces the classification. - self._turbo_lora_adapter_ids.discard(lora_request.lora_int_id) + self._turbo_lora_specs.pop(lora_request.lora_int_id, None) self._native_lora_adapter_ids.discard(lora_request.lora_int_id) self._lora_sigma_schedules.pop(lora_request.lora_int_id, None) od_config = getattr(self, "od_config", None) @@ -726,8 +723,9 @@ class MiniMaxH3Pipeline( unsupported_offload_mode=" or ".join(offload_modes) or None, ) if loaded is not None: - self._turbo_lora_adapter_ids.add(lora_request.lora_int_id) - return loaded + lora_model, peft_helper, turbo_spec = loaded + self._turbo_lora_specs[lora_request.lora_int_id] = turbo_spec + return lora_model, peft_helper # Selection is by the artifact's safetensors ``key_format``, not by the # running platform: the native loader is checkpoint-format parsing with @@ -753,7 +751,7 @@ class MiniMaxH3Pipeline( lora_model: LoRAModel, bound_lora_names: frozenset[str], ) -> None: - if lora_model.id in self._turbo_lora_adapter_ids: + if lora_model.id in self._turbo_lora_specs: missing = sorted(set(lora_model.loras) - bound_lora_names) if missing: raise ValueError( @@ -770,14 +768,6 @@ class MiniMaxH3Pipeline( f"bound={len(bound_lora_names)}/{len(lora_model.loras)}, missing={missing[:5]}" ) - def _has_active_turbo_lora(self, sampling: Any) -> bool: - lora_request = sampling.lora_request - return ( - lora_request is not None - and not math.isclose(0.0, float(sampling.lora_scale)) - and lora_request.lora_int_id in self._turbo_lora_adapter_ids - ) - def _has_active_native_lora(self, sampling: Any) -> bool: lora_request = sampling.lora_request return ( @@ -837,27 +827,46 @@ class MiniMaxH3Pipeline( return adapter_schedule return self._base_schedule_for_task(task) - def _validate_turbo_sampling(self, sampling: Any) -> None: + def _active_turbo_spec(self, sampling: Any) -> TurboSpec | None: + """Return the spec of the Turbo adapter this request actually applies. + + A recognized adapter at scale 0 contributes nothing, so it neither + constrains the task nor imposes its sampler contract. + """ + + lora_request = sampling.lora_request + if lora_request is None or math.isclose(0.0, float(sampling.lora_scale)): + return None + return self._turbo_lora_specs.get(lora_request.lora_int_id) + + def _validate_turbo_sampling(self, sampling: Any, spec: TurboSpec) -> None: + """Hold a request to the contract of the artifact that is loaded. + + Sigma-point count and both flow shifts vary across the Turbo family, so + each is checked against the adapter's own spec rather than a single + published configuration. + """ + extra = sampling.extra_args or {} sigma_points = sampling.num_inference_steps - if sigma_points != MINIMAX_H3_TURBO_SIGMA_POINTS: + if sigma_points != spec.sigma_points: raise OmniClientError( - "MiniMax-H3 Turbo requires num_inference_steps=5 (five sigma points produce four denoiser evaluations)" + f"{spec.filename} is a {spec.denoise_steps}-step artifact and requires " + f"num_inference_steps={spec.sigma_points} " + f"({spec.sigma_points} sigma points produce {spec.denoise_steps} denoiser evaluations)" ) try: video_shift = float(extra.get("flow_shift", self.default_video_shift)) except (TypeError, ValueError) as exc: - raise OmniClientError(f"MiniMax-H3 Turbo requires flow_shift={MINIMAX_H3_TURBO_VIDEO_SHIFT:g}") from exc - if not math.isclose(video_shift, MINIMAX_H3_TURBO_VIDEO_SHIFT): - raise OmniClientError(f"MiniMax-H3 Turbo requires flow_shift={MINIMAX_H3_TURBO_VIDEO_SHIFT:g}") + raise OmniClientError(f"{spec.filename} requires flow_shift={spec.video_shift:g}") from exc + if not math.isclose(video_shift, spec.video_shift): + raise OmniClientError(f"{spec.filename} requires flow_shift={spec.video_shift:g}") try: audio_shift = float(extra.get("audio_flow_shift", self.default_audio_shift)) except (TypeError, ValueError) as exc: - raise OmniClientError( - f"MiniMax-H3 Turbo requires audio_flow_shift={MINIMAX_H3_TURBO_AUDIO_SHIFT:g}" - ) from exc - if not math.isclose(audio_shift, MINIMAX_H3_TURBO_AUDIO_SHIFT): - raise OmniClientError(f"MiniMax-H3 Turbo requires audio_flow_shift={MINIMAX_H3_TURBO_AUDIO_SHIFT:g}") + raise OmniClientError(f"{spec.filename} requires audio_flow_shift={spec.audio_shift:g}") from exc + if not math.isclose(audio_shift, spec.audio_shift): + raise OmniClientError(f"{spec.filename} requires audio_flow_shift={spec.audio_shift:g}") def adopt_cache_dit_backend(self, backend: CacheDiTBackend) -> None: """Adopt runner-installed generic Cache-DiT for request transitions.""" @@ -890,7 +899,7 @@ class MiniMaxH3Pipeline( getattr(od_config, "task_type", None), str(od_config.model), ) - self._turbo_lora_adapter_ids: set[int] = set() + self._turbo_lora_specs: dict[int, TurboSpec] = {} self._native_lora_adapter_ids: set[int] = set() self._lora_sigma_schedules: dict[int, DMD2SigmaSchedule] = {} model_root = _resolve_minimax_h3_model_root( @@ -1177,7 +1186,7 @@ class MiniMaxH3Pipeline( requested: str | None, multi_modal_data: dict[str, Any], *, - has_turbo_lora: bool = False, + turbo_spec: TurboSpec | None = None, has_native_lora: bool = False, ) -> str: if requested is None: @@ -1196,8 +1205,11 @@ class MiniMaxH3Pipeline( raise OmniClientError( f"checkpoint partition {self.partition!r} supports {sorted(self.supported_tasks)}, got task={task!r}" ) - if task == "ref2va" and has_turbo_lora: - raise OmniClientError("MiniMax-H3 Turbo LoRA supports T2VA/FL2VA requests only") + if turbo_spec is not None and task not in turbo_spec.supported_tasks: + raise OmniClientError( + f"{turbo_spec.filename} is a {turbo_spec.task_family} Turbo artifact and serves " + f"{sorted(turbo_spec.supported_tasks)}, got task={task!r}" + ) if has_native_lora and task != "t2va": raise OmniClientError("MiniMax-H3 native LoRA supports T2VA requests only") if self._fasth3 is not None: @@ -2216,16 +2228,16 @@ class MiniMaxH3Pipeline( quality = sampling.quality logger.debug("MiniMax H3 request quality=%s", quality) extra = sampling.extra_args or {} - has_turbo_lora = self._has_active_turbo_lora(sampling) + turbo_spec = self._active_turbo_spec(sampling) has_native_lora = self._has_active_native_lora(sampling) task = self._resolve_task( extra.get("task"), multi_modal_data, - has_turbo_lora=has_turbo_lora, + turbo_spec=turbo_spec, has_native_lora=has_native_lora, ) - if has_turbo_lora: - self._validate_turbo_sampling(sampling) + if turbo_spec is not None: + self._validate_turbo_sampling(sampling, turbo_spec) if has_native_lora: self._validate_native_sampling(sampling, task=task) if self._fasth3 is not None: From 14d2d00536ffb0b73284e632b550daaea7d6cd77 Mon Sep 17 00:00:00 2001 From: SYLAR <125541396+lishunyang12@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:43:37 +0800 Subject: [PATCH 03/90] [Bugfix] Fix Hunyuan Image-3 expert mapping contract (#7021) Signed-off-by: hsliuustc0106 Co-authored-by: hsliuustc0106 --- .../models/test_kv_cache_scale_mapper.py | 50 ++++++++++++++++++- .../hunyuan_image3_transformer.py | 43 +++++++++------- .../models/hunyuan_image3/hunyuan_image3.py | 45 +++++++++-------- 3 files changed, 97 insertions(+), 41 deletions(-) diff --git a/tests/model_executor/models/test_kv_cache_scale_mapper.py b/tests/model_executor/models/test_kv_cache_scale_mapper.py index 98f016bb4..7d7950784 100644 --- a/tests/model_executor/models/test_kv_cache_scale_mapper.py +++ b/tests/model_executor/models/test_kv_cache_scale_mapper.py @@ -67,8 +67,11 @@ class _FakeHunyuanModel(_FakeModel): use_cla=False, ) - def get_expert_mapping(self) -> tuple[list[object], dict[str, object]]: - return [], {} + def get_expert_mapping(self) -> list[object]: + return [] + + def _get_expert_weights_remapping(self) -> dict[str, object]: + return {} def _split_qkv_weight(self, weight: torch.Tensor) -> torch.Tensor: return weight @@ -81,6 +84,49 @@ def test_model_loaders_do_not_call_removed_get_cache_scale(rel_path: str) -> Non assert ".get_cache_scale(" not in source +@pytest.mark.parametrize( + ("module_name", "class_name"), + [ + ( + "vllm_omni.model_executor.models.hunyuan_image3.hunyuan_image3", + "HunyuanModel", + ), + ( + "vllm_omni.diffusion.models.hunyuan_image3.hunyuan_image3_transformer", + "HunyuanImage3Model", + ), + ], +) +def test_hunyuan_expert_mapping_separates_public_and_local_contracts( + module_name: str, + class_name: str, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The public expert mapping must be a flat list, not a loader-side pair.""" + module = __import__(module_name, fromlist=[class_name]) + expected_mapping = [("param", "weight", 0, "gate_proj")] + fake_model = SimpleNamespace( + config=SimpleNamespace(num_experts=1), + num_redundant_experts=0, + ) + monkeypatch.setattr(module, "_is_moe", lambda config: True) + if hasattr(module, "fused_moe_make_expert_params_mapping"): + monkeypatch.setattr(module, "fused_moe_make_expert_params_mapping", lambda **kwargs: expected_mapping) + else: + monkeypatch.setattr( + module, + "FusedMoE", + SimpleNamespace(make_expert_params_mapping=lambda *args, **kwargs: expected_mapping), + ) + + model_cls = getattr(module, class_name) + assert model_cls.get_expert_mapping(fake_model) == expected_mapping + assert model_cls._get_expert_weights_remapping(fake_model) == { + "gate_proj": ("gate_and_up_proj", 1, 2), + "up_proj": ("gate_and_up_proj", 0, 2), + } + + @pytest.mark.parametrize( ("module_name", "class_name", "fake_model_cls"), [ diff --git a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py index 856e6f189..d58a43498 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/hunyuan_image3_transformer.py @@ -2190,24 +2190,28 @@ class HunyuanImage3Model(nn.Module): return torch.concat((q, k, v)) def get_expert_mapping(self) -> list[tuple[str, str, int, str]]: - if _is_moe(self.config): - # Params for weights, fp8 weight scales, fp8 activation scales - # (param_name, weight_name, expert_id, shard_id) - fused_moe_expert_mapping = FusedMoE.make_expert_params_mapping( - self, - ckpt_gate_proj_name="gate_proj", - ckpt_down_proj_name="down_proj", - ckpt_up_proj_name="up_proj", - num_experts=self.config.num_experts, - num_redundant_experts=self.num_redundant_experts, - ) - expert_weights_remapping = { - "gate_proj": ("gate_and_up_proj", 1, 2), - "up_proj": ("gate_and_up_proj", 0, 2), - } - return fused_moe_expert_mapping, expert_weights_remapping - else: - return [], {} + if not _is_moe(self.config): + return [] + + # Params for weights, fp8 weight scales, fp8 activation scales + # (param_name, weight_name, expert_id, shard_id) + return FusedMoE.make_expert_params_mapping( + self, + ckpt_gate_proj_name="gate_proj", + ckpt_down_proj_name="down_proj", + ckpt_up_proj_name="up_proj", + num_experts=self.config.num_experts, + num_redundant_experts=self.num_redundant_experts, + ) + + def _get_expert_weights_remapping(self) -> dict[str, tuple[str, int, int]]: + """Return the vLLM-Omni-local remapping for Hunyuan checkpoint weights.""" + if not _is_moe(self.config): + return {} + return { + "gate_proj": ("gate_and_up_proj", 1, 2), + "up_proj": ("gate_and_up_proj", 0, 2), + } # rename for delay load def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]): @@ -2243,7 +2247,8 @@ class HunyuanImage3Model(nn.Module): params_dict = dict(self.named_parameters()) loaded_params: set[str] = set() - expert_params_mapping, expert_weights_remapping = self.get_expert_mapping() + expert_params_mapping = self.get_expert_mapping() + expert_weights_remapping = self._get_expert_weights_remapping() # List of unexpected keywords in weight names unexpected_keywords = [ diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py index a8fac7fa6..58b67d8aa 100755 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -128,25 +128,29 @@ class HunyuanModel(HunYuanModel): v = v.reshape(-1, hidden_size) return torch.concat((q, k, v)) - def get_expert_mapping(self) -> tuple[list[tuple[str, str, int, str]], dict[str, tuple[str, int, int]]]: - if _is_moe(self.config): - # Params for weights, fp8 weight scales, fp8 activation scales - # (param_name, weight_name, expert_id, shard_id) - fused_moe_expert_mapping = fused_moe_make_expert_params_mapping( - model=self, - ckpt_gate_proj_name="gate_proj", - ckpt_down_proj_name="down_proj", - ckpt_up_proj_name="up_proj", - num_experts=self.config.num_experts, - num_redundant_experts=self.num_redundant_experts, - ) - expert_weights_remapping = { - "gate_proj": ("gate_and_up_proj", 1, 2), - "up_proj": ("gate_and_up_proj", 0, 2), - } - return fused_moe_expert_mapping, expert_weights_remapping - else: - return [], {} + def get_expert_mapping(self) -> list[tuple[str, str, int, str]]: + if not _is_moe(self.config): + return [] + + # Params for weights, fp8 weight scales, fp8 activation scales + # (param_name, weight_name, expert_id, shard_id) + return fused_moe_make_expert_params_mapping( + model=self, + ckpt_gate_proj_name="gate_proj", + ckpt_down_proj_name="down_proj", + ckpt_up_proj_name="up_proj", + num_experts=self.config.num_experts, + num_redundant_experts=self.num_redundant_experts, + ) + + def _get_expert_weights_remapping(self) -> dict[str, tuple[str, int, int]]: + """Return the vLLM-Omni-local remapping for Hunyuan checkpoint weights.""" + if not _is_moe(self.config): + return {} + return { + "gate_proj": ("gate_and_up_proj", 1, 2), + "up_proj": ("gate_and_up_proj", 0, 2), + } def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]): cla_factor = _get_cla_factor(self.config) @@ -174,7 +178,8 @@ class HunyuanModel(HunYuanModel): params_dict = dict(self.named_parameters()) loaded_params: set[str] = set() - expert_params_mapping, expert_weights_remapping = self.get_expert_mapping() + expert_params_mapping = self.get_expert_mapping() + expert_weights_remapping = self._get_expert_weights_remapping() # List of unexpected keywords in weight names unexpected_keywords = [ From 715b8b8746db9b0e4a49456b2413df9a7432399f Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Sun, 6 Sep 2026 21:45:10 +0800 Subject: [PATCH 04/90] [Model] MiniMax-H3: validate versioned text-conditioning handoff (#6720) Signed-off-by: hsliuustc0106 --- tests/config/test_config_factory.py | 5 + .../minimax_h3/test_minimax_h3_contract.py | 58 ++++++++--- .../models/test_minimax_h3_token_tags.py | 6 +- .../stage_input_processors/test_minimax_h3.py | 99 +++++++++++++++++-- .../models/minimax_h3/conditioning.py | 80 ++++++++++++++- .../stage_input_processors/minimax_h3.py | 24 +---- 6 files changed, 218 insertions(+), 54 deletions(-) diff --git a/tests/config/test_config_factory.py b/tests/config/test_config_factory.py index b04df0bf4..3723e5523 100644 --- a/tests/config/test_config_factory.py +++ b/tests/config/test_config_factory.py @@ -1475,6 +1475,11 @@ stages: deploy = load_deploy_config(Path(get_deploy_config_path("minimax_h3_disaggregated.yaml"))) stages = merge_pipeline_deploy(pipeline, deploy) + assert stages[0].yaml_engine_args["model_arch"] == "MiniMaxH3TextEncoder" + assert stages[1].yaml_engine_args["model_arch"] == "MiniMaxH3Pipeline" + assert stages[0].yaml_runtime["num_replicas"] == 1 + assert stages[1].yaml_runtime["num_replicas"] == 1 + assert stages[1].yaml_engine_args["model_loaded"] == {"text_encoder": False} assert stages[0].yaml_engine_args["max_num_seqs"] == 1 assert stages[0].yaml_engine_args["model_path_resolver"].endswith(".resolve_minimax_h3_model_root") assert stages[1].yaml_engine_args["model_path_resolver"].endswith(".resolve_minimax_h3_diffusion_model_path") diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py index b11473498..cb12ce9e5 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -277,14 +277,16 @@ def test_combined_task_inference_and_transformer_routing(): "component_partition", "source_partitions", "expected_tasks", + "load_text_encoder", ), [ - (None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}), - ("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}), - ("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}), + (None, "combined", 2, "FL2VA", ["FL2VA", "Ref2VA"], {"t2va", "fl2va", "ref2va"}, True), + ("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}, True), + ("ref2va", "ref2va", 1, "Ref2VA", ["Ref2VA"], {"ref2va"}, True), + ("fl2va", "fl2va", 1, "FL2VA", ["FL2VA"], {"t2va", "fl2va"}, False), ], ) -def test_pipeline_loads_task_selected_dits_and_shared_components_once( +def test_pipeline_loads_task_selected_components_with_encoder_ownership( monkeypatch, tmp_path, task_type, @@ -293,6 +295,7 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once( component_partition, source_partitions, expected_tasks, + load_text_encoder, ): from vllm_omni.diffusion.data import ( DiffusionParallelConfig, @@ -404,6 +407,11 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once( revision=None, task_type=task_type, quantization_config=None, + model_loaded={ + "transformer": True, + "vae": True, + "text_encoder": load_text_encoder, + }, parallel_config=DiffusionParallelConfig( cfg_parallel_size=1, text_encoder_tp_size=1, @@ -415,34 +423,54 @@ def test_pipeline_loads_task_selected_dits_and_shared_components_once( assert pipeline.supported_tasks == expected_tasks assert len(created["dit"]) == expected_dits component_path = tmp_path / component_partition - assert created["text_encoder"] == [str(component_path / "text_encoder")] assert created["video_vae"] == [str(component_path / "video_vae")] assert created["audio_vae"] == [str(component_path / "audio_vae")] - assert len(tokenizer_calls) == 1 - assert len(processor_calls) == 1 expected_dit_modules = ["transformer", "transformers_ref"] if expected_dits == 2 else ["transformer"] assert pipeline._dit_modules == expected_dit_modules assert hasattr(pipeline, "transformers_ref") is (expected_dits == 2) + if load_text_encoder: + assert created["text_encoder"] == [str(component_path / "text_encoder")] + assert len(tokenizer_calls) == 1 + assert len(processor_calls) == 1 + assert pipeline.text_encoder is not None + assert pipeline.tokenizer is not None + assert pipeline.processor is not None + assert pipeline._encoder_modules == ["text_encoder"] + assert pipeline.text_encoder_tp_size == 1 + else: + assert created["text_encoder"] == [] + assert tokenizer_calls == [] + assert processor_calls == [] + assert pipeline.text_encoder is None + assert pipeline.tokenizer is None + assert pipeline.processor is None + assert pipeline._encoder_modules == [] + assert pipeline.text_encoder_tp_size == 0 + assert pipeline.text_encoder_group is None if expected_partition == "ref2va": assert pipeline._transformer_for_task("ref2va") is pipeline.transformer assert [source.model_or_path for source in pipeline.weights_sources] == [ *(str(tmp_path / partition_name) for partition_name in source_partitions), - str(component_path), + *((str(component_path),) if load_text_encoder else ()), ] assert [source.subfolder for source in pipeline.weights_sources] == [ *("transformer" for _ in source_partitions), - "text_encoder", + *(("text_encoder",) if load_text_encoder else ()), ] expected_prefixes = ["transformer."] if expected_dits == 2: expected_prefixes.append("transformers_ref.") - expected_prefixes.append("text_encoder.") + if load_text_encoder: + expected_prefixes.append("text_encoder.") assert [source.prefix for source in pipeline.weights_sources] == expected_prefixes - expected_patterns = ( - pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS - if expected_partition == "combined" - else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition] - ) + if load_text_encoder: + expected_patterns = ( + pipeline_module.MINIMAX_H3_DOWNLOAD_PATTERNS + if expected_partition == "combined" + else pipeline_module.MINIMAX_H3_TASK_DOWNLOAD_PATTERNS[expected_partition] + ) + else: + expected_patterns = pipeline_module.MINIMAX_H3_DIFFUSION_DOWNLOAD_PATTERNS[expected_partition] assert download_calls == [ { "model_name_or_path": "MiniMaxAI/MiniMax-H3", diff --git a/tests/model_executor/models/test_minimax_h3_token_tags.py b/tests/model_executor/models/test_minimax_h3_token_tags.py index 69ee7cb84..80d22133a 100644 --- a/tests/model_executor/models/test_minimax_h3_token_tags.py +++ b/tests/model_executor/models/test_minimax_h3_token_tags.py @@ -8,6 +8,7 @@ import pytest import torch from vllm.model_executor.models.qwen3_vl import Qwen3VLForConditionalGeneration +from vllm_omni.model_executor.models.minimax_h3.conditioning import MiniMaxH3TextConditioning from vllm_omni.model_executor.models.minimax_h3.text_encoder import MiniMaxH3TextEncoder pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -51,12 +52,15 @@ def test_invalid_output_length_consumes_token_tags(): def test_make_omni_output_uses_shared_stage_payload_fields(): model = _model_without_weights() model._token_tags = torch.tensor([1, 0]) - hidden = torch.zeros(2, 5120) + hidden = torch.zeros(2, 5120, dtype=torch.bfloat16) output = model.make_omni_output(hidden) assert output.multimodal_outputs is not None + conditioning = MiniMaxH3TextConditioning.from_omni_payload(output.multimodal_outputs) assert torch.equal(output.multimodal_outputs["hidden_states"]["output"], hidden) assert torch.equal(output.multimodal_outputs["meta"]["token_role_ids"], torch.tensor([[1], [0]])) + assert torch.equal(conditioning.hidden_states, hidden) + assert torch.equal(conditioning.token_tags, torch.tensor([1, 0])) assert "encoder_hidden_states" not in output.multimodal_outputs assert "token_tags" not in output.multimodal_outputs diff --git a/tests/model_executor/stage_input_processors/test_minimax_h3.py b/tests/model_executor/stage_input_processors/test_minimax_h3.py index 599be81f2..9fda89953 100644 --- a/tests/model_executor/stage_input_processors/test_minimax_h3.py +++ b/tests/model_executor/stage_input_processors/test_minimax_h3.py @@ -12,6 +12,7 @@ import pytest import torch from PIL import Image +from vllm_omni.data_entry_keys import OmniPayload, deserialize_payload, serialize_payload from vllm_omni.diffusion.models.minimax_h3.pipeline_minimax_h3 import ( _load_audio, resolve_minimax_h3_diffusion_model_path, @@ -24,6 +25,8 @@ from vllm_omni.model_executor.models.minimax_h3.checkpoint import ( from vllm_omni.model_executor.models.minimax_h3.conditioning import ( MINIMAX_H3_CONDITION_LABELS_KEY, MINIMAX_H3_PRESENTATION_TASK_KEY, + MINIMAX_H3_TEXT_CONDITIONING_SCHEMA, + MiniMaxH3TextConditioning, ) from vllm_omni.model_executor.models.minimax_h3.preprocessing import ( minimax_h3_ref2va_presentation, @@ -238,19 +241,24 @@ def _diffusion_prompt() -> dict: } +def _valid_text_conditioning_payload() -> OmniPayload: + return { + "hidden_states": { + "output": torch.zeros(4, 5120, dtype=torch.bfloat16), + }, + "meta": { + "token_role_ids": torch.tensor([[1], [1], [0], [0]], dtype=torch.int64), + }, + } + + def test_text_encoder2diffusion_reads_hidden_states_output_and_token_role_ids() -> None: - hidden = torch.randn(4, 5120) - token_role_ids = torch.tensor([[1], [1], [0], [0]]) + payload = _valid_text_conditioning_payload() + hidden = payload["hidden_states"]["output"] + token_role_ids = payload["meta"]["token_role_ids"] result = text_encoder2diffusion( - [ - _source_output( - { - "hidden_states": {"output": hidden}, - "meta": {"token_role_ids": token_role_ids}, - } - ) - ], + [_source_output(payload)], _diffusion_prompt(), ) @@ -259,6 +267,29 @@ def test_text_encoder2diffusion_reads_hidden_states_output_and_token_role_ids() torch.testing.assert_close(conditioning["token_tags"], token_role_ids.squeeze(-1)) +def test_text_conditioning_artifact_round_trips_through_omni_payload_wire_format() -> None: + payload = _valid_text_conditioning_payload() + + wire = serialize_payload(payload) + + assert wire is not None + restored = deserialize_payload(wire) + conditioning = MiniMaxH3TextConditioning.from_omni_payload(restored) + assert MINIMAX_H3_TEXT_CONDITIONING_SCHEMA == "minimax_h3.text_conditioning/v1" + torch.testing.assert_close( + conditioning.hidden_states, + payload["hidden_states"]["output"], + ) + torch.testing.assert_close( + conditioning.token_tags, + payload["meta"]["token_role_ids"].squeeze(-1), + ) + assert conditioning.hidden_states.dtype == torch.bfloat16 + assert conditioning.hidden_states.is_contiguous() + assert conditioning.token_tags.dtype == torch.int64 + assert conditioning.token_tags.is_contiguous() + + @pytest.mark.parametrize( ("payload", "message"), [ @@ -280,6 +311,54 @@ def test_text_encoder2diffusion_rejects_invalid_conditioning_payload(payload: di text_encoder2diffusion([_source_output(payload)], _diffusion_prompt()) +@pytest.mark.parametrize( + ("case", "message"), + [ + ("hidden_width", r"hidden_states must have shape \[tokens, 5120\]"), + ("hidden_dtype", "hidden_states must have dtype torch.bfloat16"), + ("hidden_noncontiguous", "hidden_states must use contiguous strided layout"), + ("hidden_non_strided", "hidden_states must use contiguous strided layout"), + ("token_dtype", "stage-wire token_role_ids must have dtype torch.int64"), + ("token_layout", "stage-wire token_role_ids must use contiguous strided layout"), + ("token_count", "token_tags must align with hidden_states"), + ("token_value", "token_tags must contain only 0 and 1"), + ], +) +def test_text_encoder2diffusion_rejects_h3_v1_contract_mismatch(case: str, message: str) -> None: + payload = _valid_text_conditioning_payload() + + if case == "hidden_width": + payload["hidden_states"]["output"] = torch.zeros(4, 5119, dtype=torch.bfloat16) + elif case == "hidden_dtype": + payload["hidden_states"]["output"] = torch.zeros(4, 5120, dtype=torch.float32) + elif case == "hidden_noncontiguous": + payload["hidden_states"]["output"] = torch.empty(5120, 4, dtype=torch.bfloat16).t() + elif case == "hidden_non_strided": + payload["hidden_states"]["output"] = torch.empty( + (4, 5120), + dtype=torch.bfloat16, + layout=torch.sparse_coo, + ) + elif case == "token_dtype": + payload["meta"]["token_role_ids"] = torch.tensor([[1], [1], [0], [0]], dtype=torch.int32) + elif case == "token_layout": + payload["meta"]["token_role_ids"] = torch.tensor( + [[1], [0], [1], [0], [1], [0], [1], [0]], + dtype=torch.int64, + )[::2] + elif case == "token_count": + payload["meta"]["token_role_ids"] = torch.tensor([[1], [0], [0]], dtype=torch.int64) + elif case == "token_value": + payload["meta"]["token_role_ids"] = torch.tensor([[1], [1], [2], [0]], dtype=torch.int64) + else: # pragma: no cover - parameterization is exhaustive + raise AssertionError(f"unknown test case: {case}") + + with pytest.raises(RuntimeError, match=message) as exc_info: + text_encoder2diffusion([_source_output(payload)], _diffusion_prompt()) + + assert MINIMAX_H3_TEXT_CONDITIONING_SCHEMA in str(exc_info.value) + + def test_ref2va_one_image_tokens_and_tags_match_fused_presentation(): tokenizer = _SegmentTokenizer() labels = [("image", 1), ("audio", 1)] diff --git a/vllm_omni/model_executor/models/minimax_h3/conditioning.py b/vllm_omni/model_executor/models/minimax_h3/conditioning.py index 8a22c7737..efea25771 100644 --- a/vllm_omni/model_executor/models/minimax_h3/conditioning.py +++ b/vllm_omni/model_executor/models/minimax_h3/conditioning.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """MiniMax H3 text-conditioning contract.""" from __future__ import annotations @@ -9,14 +10,27 @@ from typing import Any import torch +MINIMAX_H3_TEXT_CONDITIONING_SCHEMA = "minimax_h3.text_conditioning/v1" MINIMAX_H3_TEXT_HIDDEN_SIZE = 5120 MINIMAX_H3_PRESENTATION_TASK_KEY = "_minimax_h3_presentation_task" MINIMAX_H3_CONDITION_LABELS_KEY = "_minimax_h3_condition_labels" +def _validate_contiguous_strided_layout(name: str, tensor: torch.Tensor) -> None: + if tensor.layout != torch.strided: + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: {name} must use contiguous strided layout, got {tensor.layout}" + ) + if not tensor.is_contiguous(): + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: {name} must use contiguous " + f"strided layout, got stride={tuple(tensor.stride())}" + ) + + @dataclass(frozen=True) class MiniMaxH3TextConditioning: - """Layer-50 Qwen3-VL hidden states and their H3 token roles.""" + """``minimax_h3.text_conditioning/v1`` semantic payload.""" hidden_states: torch.Tensor token_tags: torch.Tensor @@ -26,24 +40,80 @@ class MiniMaxH3TextConditioning: cls, payload: Mapping[str, Any], ) -> MiniMaxH3TextConditioning: + """Validate the semantic payload consumed by the diffusion stage.""" hidden_states = payload.get("hidden_states") token_tags = payload.get("token_tags") if not isinstance(hidden_states, torch.Tensor) or not isinstance(token_tags, torch.Tensor): - raise ValueError("MiniMax H3 conditioning requires hidden_states and token_tags tensors") + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: conditioning requires hidden_states and token_tags tensors" + ) if hidden_states.ndim != 2 or hidden_states.shape[-1] != MINIMAX_H3_TEXT_HIDDEN_SIZE: raise ValueError( - "MiniMax H3 hidden_states must have shape " + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: hidden_states must have shape " f"[tokens, {MINIMAX_H3_TEXT_HIDDEN_SIZE}], got {tuple(hidden_states.shape)}" ) + if hidden_states.dtype != torch.bfloat16: + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: hidden_states must have dtype " + f"torch.bfloat16, got {hidden_states.dtype}" + ) + _validate_contiguous_strided_layout("hidden_states", hidden_states) if token_tags.ndim != 1 or token_tags.shape[0] != hidden_states.shape[0]: raise ValueError( - "MiniMax H3 token_tags must align with hidden_states, got " + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: token_tags must align with hidden_states, got " f"token_tags={tuple(token_tags.shape)} and hidden_states={tuple(hidden_states.shape)}" ) + if token_tags.dtype != torch.int64: + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: token_tags must have dtype torch.int64, got {token_tags.dtype}" + ) + _validate_contiguous_strided_layout("token_tags", token_tags) if not torch.all((token_tags == 0) | (token_tags == 1)): - raise ValueError("MiniMax H3 text-encoder token_tags must contain only 0 and 1") + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: text-encoder token_tags must contain only 0 and 1" + ) return cls(hidden_states=hidden_states, token_tags=token_tags) + @classmethod + def from_omni_payload( + cls, + payload: Mapping[str, Any], + ) -> MiniMaxH3TextConditioning: + """Validate and adapt the existing ``OmniPayload`` stage-wire view.""" + hidden_states = payload.get("hidden_states") + if not isinstance(hidden_states, Mapping): + raise ValueError(f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: text encoder returned no hidden_states payload") + hidden = hidden_states.get("output") + if not isinstance(hidden, torch.Tensor): + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: text encoder returned no hidden_states.output tensor" + ) + + meta = payload.get("meta") + if not isinstance(meta, Mapping): + raise ValueError(f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: text encoder returned no conditioning metadata") + token_role_ids = meta.get("token_role_ids") + if not isinstance(token_role_ids, torch.Tensor): + raise ValueError(f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: text encoder returned no token_role_ids tensor") + if token_role_ids.ndim != 2 or token_role_ids.shape[-1] != 1: + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: stage-wire token_role_ids must have shape " + f"[tokens, 1], got {tuple(token_role_ids.shape)}" + ) + if token_role_ids.dtype != torch.int64: + raise ValueError( + f"{MINIMAX_H3_TEXT_CONDITIONING_SCHEMA}: stage-wire token_role_ids must have dtype " + f"torch.int64, got {token_role_ids.dtype}" + ) + _validate_contiguous_strided_layout("stage-wire token_role_ids", token_role_ids) + + return cls.from_payload( + { + "hidden_states": hidden, + "token_tags": token_role_ids.squeeze(-1), + } + ) + def to_payload(self) -> dict[str, torch.Tensor]: return { "hidden_states": self.hidden_states, diff --git a/vllm_omni/model_executor/stage_input_processors/minimax_h3.py b/vllm_omni/model_executor/stage_input_processors/minimax_h3.py index 372d675b6..87808adc3 100644 --- a/vllm_omni/model_executor/stage_input_processors/minimax_h3.py +++ b/vllm_omni/model_executor/stage_input_processors/minimax_h3.py @@ -11,7 +11,6 @@ from collections.abc import Mapping, Sequence from typing import Any import numpy as np -import torch from PIL import Image from vllm_omni.data_entry_keys import REQUEST_ARTIFACT_DIRS_KEY @@ -332,29 +331,8 @@ def text_encoder2diffusion( payload = completion.multimodal_output if not isinstance(payload, Mapping): raise RuntimeError("MiniMax H3 text encoder returned no conditioning payload") - hidden_states = payload.get("hidden_states") - if not isinstance(hidden_states, Mapping): - raise RuntimeError("MiniMax H3 text encoder returned no hidden_states payload") - hidden = hidden_states.get("output") - if not isinstance(hidden, torch.Tensor): - raise RuntimeError("MiniMax H3 text encoder returned no hidden_states.output tensor") - meta = payload.get("meta") - if not isinstance(meta, Mapping): - raise RuntimeError("MiniMax H3 text encoder returned no conditioning metadata") - token_tags = meta.get("token_role_ids") - if not isinstance(token_tags, torch.Tensor): - raise RuntimeError("MiniMax H3 text encoder returned no token_role_ids tensor") - if token_tags.ndim != 2 or token_tags.shape[-1] != 1: - raise RuntimeError( - f"MiniMax H3 stage-wire token_tags must have shape [tokens, 1], got {tuple(token_tags.shape)}" - ) try: - conditioning = MiniMaxH3TextConditioning.from_payload( - { - "hidden_states": hidden, - "token_tags": token_tags.squeeze(-1), - } - ) + conditioning = MiniMaxH3TextConditioning.from_omni_payload(payload) except ValueError as exc: raise RuntimeError(str(exc)) from exc From ff6e906a25de1ca3122c0d0d5250fde5a0ddbc7b Mon Sep 17 00:00:00 2001 From: NATURE Date: Sun, 6 Sep 2026 22:25:35 +0800 Subject: [PATCH 05/90] [Model] Preserve MiniCPM-o Talker full attention until capacity (#6799) Signed-off-by: natureofnature Co-authored-by: amy-why-3459 --- .../sched/test_omni_ar_scheduler_streaming.py | 322 ++++++++++++++++++ .../test_chunk_transfer_adapter.py | 190 +++++++++-- .../models/minicpmo_4_5/test_pipeline.py | 18 +- .../minicpmo_4_5/test_talker_batching.py | 108 +++++- .../test_minicpmo_4_5_omni.py | 29 +- vllm_omni/core/sched/omni_ar_scheduler.py | 63 +++- vllm_omni/core/sched/omni_scheduler_mixin.py | 32 +- vllm_omni/deploy/minicpmo_4_5.yaml | 15 +- .../distributed/omni_connectors/adapter.py | 40 ++- .../chunk_transfer_adapter.py | 161 +++++++-- .../minicpmo_4_5/minicpmo_4_5_omni_tts.py | 24 +- .../minicpmo_4_5_omni.py | 19 ++ 12 files changed, 913 insertions(+), 108 deletions(-) diff --git a/tests/core/sched/test_omni_ar_scheduler_streaming.py b/tests/core/sched/test_omni_ar_scheduler_streaming.py index a1aa7351f..bca7d095c 100644 --- a/tests/core/sched/test_omni_ar_scheduler_streaming.py +++ b/tests/core/sched/test_omni_ar_scheduler_streaming.py @@ -19,6 +19,9 @@ from vllm.v1.core.sched.output import SchedulerOutput from vllm.v1.outputs import ModelRunnerOutput from vllm.v1.request import Request, RequestStatus, StreamingUpdate from vllm_omni.core.sched.omni_ar_scheduler import OmniARAsyncScheduler, OmniARScheduler +from vllm_omni.distributed.omni_connectors.transfer_adapter.chunk_transfer_adapter import ( + OmniChunkTransferAdapter, +) # isort: on @@ -62,6 +65,43 @@ def _make_update(prompt_token_ids: list[int] | None = None) -> StreamingUpdate: ) +def _make_talker_adapter( + *, + max_model_len: int = 100, + recompute_on_capacity: bool = True, +) -> OmniChunkTransferAdapter: + adapter = OmniChunkTransferAdapter.__new__(OmniChunkTransferAdapter) + adapter.receives_chunks = False + adapter._max_model_len = max_model_len + adapter._streaming_prompt_previous_chunks = 1 + adapter._streaming_prompt_recompute_on_capacity = recompute_on_capacity + adapter._streaming_condition_lengths = {} + adapter._streaming_condition_seqs = {} + adapter.segment_finished_requests = set() + adapter.requests_num_chunks_sent = {} + return adapter + + +def _make_talker_update( + condition_len: int, + *, + reserve: int = 10, + condition_seq: int | None, +) -> StreamingUpdate: + update = _make_update([0] * condition_len) + update.model_intermediate_buffer = { + "native_duplex": True, + "ids": {"prompt": [1]}, + "meta": { + "next_stage_prompt_len": condition_len, + "next_stage_generation_tokens": reserve, + }, + } + if condition_seq is not None: + update.model_intermediate_buffer["meta"]["streaming_condition_seq"] = condition_seq + return update + + def _run_resumable_segment_stop( session: Request, *, @@ -538,6 +578,288 @@ def test_explicit_model_intermediate_prompt_replacement_releases_cache_and_water sched.encoder_cache_manager.free.assert_called_once_with(session) +def test_talker_capacity_exact_fit_extends_from_declared_length_without_ids_prompt() -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter(max_model_len=100) + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.external_req_id = "external-capacity-exact-fit" + session.prompt_token_ids = [0] * 70 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session._output_token_ids.clear() + session.append_output_token_ids([7, 8, 9, 10]) + session.num_prompt_tokens = 70 + session.num_computed_tokens = 74 + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + adapter._streaming_condition_lengths[session.request_id] = 20 + adapter._streaming_condition_seqs[session.request_id] = 0 + update = _make_talker_update(16, condition_seq=1) + update.model_intermediate_buffer["ids"] = {"tts": [1, 2, 3]} + + sched._update_request_as_session(session, update) + + assert session.num_computed_tokens == 74 + assert session.num_prompt_tokens == 90 + assert list(session._all_token_ids[-20:]) == [7, 8, 9, 10] + [0] * 16 + assert update.model_intermediate_buffer["meta"] == { + "next_stage_prompt_len": 16, + "next_stage_generation_tokens": 10, + "streaming_condition_seq": 1, + "streaming_prompt_recompute": False, + } + assert adapter._streaming_condition_lengths[session.request_id] == 16 + assert adapter._streaming_condition_seqs[session.request_id] == 1 + assert session._omni_segment_generation == 1 + assert session.status == RequestStatus.WAITING + assert sched.num_waiting_for_streaming_input == 0 + sched._free_request_blocks.assert_not_called() + sched.encoder_cache_manager.free.assert_not_called() + + +def test_talker_first_update_seeds_sender_condition_tracking() -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter(max_model_len=100) + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.external_req_id = "external-capacity-first-update" + session.prompt_token_ids = [0] * 20 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session._output_token_ids.clear() + session.append_output_token_ids([7, 8, 9]) + session.num_prompt_tokens = 20 + session.num_computed_tokens = 23 + session.model_intermediate_buffer = { + "native_duplex": True, + "meta": { + "next_stage_prompt_len": 20, + "next_stage_generation_tokens": 10, + "streaming_condition_seq": 0, + }, + } + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + update = _make_talker_update(16, condition_seq=1) + + sched._update_request_as_session(session, update) + + assert update.model_intermediate_buffer["meta"]["streaming_condition_seq"] == 1 + assert update.model_intermediate_buffer["meta"]["streaming_prompt_recompute"] is False + assert adapter._streaming_condition_lengths[session.request_id] == 16 + assert adapter._streaming_condition_seqs[session.request_id] == 1 + assert session.num_computed_tokens == 23 + assert session.num_prompt_tokens == 39 + sched._free_request_blocks.assert_not_called() + sched.encoder_cache_manager.free.assert_not_called() + + +def test_talker_first_sliding_update_recomputes_from_seeded_condition() -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter(recompute_on_capacity=False) + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.external_req_id = "external-sliding-first-update" + session.prompt_token_ids = [0] * 20 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session._output_token_ids.clear() + previous_codes = [7, 8, 9] + session.append_output_token_ids(previous_codes) + session.num_prompt_tokens = 20 + session.num_computed_tokens = 23 + session.model_intermediate_buffer = { + "native_duplex": True, + "meta": { + "next_stage_prompt_len": 20, + "next_stage_generation_tokens": 10, + "streaming_condition_seq": 0, + }, + } + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + update = _make_talker_update(16, condition_seq=1) + + sched._update_request_as_session(session, update) + + assert session.num_computed_tokens == 0 + assert session.num_prompt_tokens == 39 + assert update.model_intermediate_buffer["ids"]["streaming_prompt_previous_codes"] == previous_codes + assert update.model_intermediate_buffer["meta"]["streaming_prompt_recompute"] is True + assert adapter._streaming_condition_lengths[session.request_id] == 16 + assert adapter._streaming_condition_seqs[session.request_id] == 1 + sched._free_request_blocks.assert_called_once_with(session) + sched.encoder_cache_manager.free.assert_called_once_with(session) + + +@pytest.mark.parametrize( + ("condition_seq", "error"), + [ + (None, "missing streaming_condition_seq"), + (0, "expected=1, received=0"), + (2, "expected=1, received=2"), + ], +) +def test_talker_invalid_condition_sequence_does_not_advance_tracking(mocker, condition_seq, error) -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter() + adapter.record_receive_failure = mocker.MagicMock() + adapter._streaming_condition_lengths["req-ar-streaming-test"] = 20 + adapter._streaming_condition_seqs["req-ar-streaming-test"] = 0 + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + update = _make_talker_update(16, condition_seq=condition_seq) + + sched._update_request_as_session(session, update) + + failure = adapter.record_receive_failure.call_args.args + assert failure[0] == session.request_id + assert error in failure[1] + assert adapter._streaming_condition_lengths[session.request_id] == 20 + assert adapter._streaming_condition_seqs[session.request_id] == 0 + assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ + + +def test_talker_first_update_that_cannot_fit_window_is_request_local(mocker) -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter() + adapter.record_receive_failure = mocker.MagicMock() + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.prompt_token_ids = [0] * 20 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session._output_token_ids.clear() + session.append_output_token_ids(list(range(9))) + session.num_prompt_tokens = 20 + session.num_computed_tokens = 29 + session.model_intermediate_buffer = { + "native_duplex": True, + "meta": { + "next_stage_prompt_len": 20, + "next_stage_generation_tokens": 10, + "streaming_condition_seq": 0, + }, + } + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + update = _make_talker_update(62, condition_seq=1) + + sched._update_request_as_session(session, update) + + failure = adapter.record_receive_failure.call_args.args + assert failure[0] == session.request_id + assert "sliding streaming prompt plus generation reserve exceeds max_model_len" in failure[1] + assert adapter._streaming_condition_lengths == {} + assert adapter._streaming_condition_seqs == {} + assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ + + +def test_talker_capacity_overflow_recomputes_then_resumes_accumulation() -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter(max_model_len=100) + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.external_req_id = "external-capacity-rollover" + session.prompt_token_ids = [0] * 70 + session._all_token_ids.clear() + session._all_token_ids.extend(session.prompt_token_ids) + session._output_token_ids.clear() + previous_codes = [7, 8, 9, 10] + session.append_output_token_ids(previous_codes) + session.num_prompt_tokens = 70 + session.num_computed_tokens = 74 + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + adapter._streaming_condition_lengths[session.request_id] = 20 + adapter._streaming_condition_seqs[session.request_id] = 0 + adapter.segment_finished_requests.add(session.request_id) + adapter.requests_num_chunks_sent[session.external_req_id] = 74 + overflow_update = _make_talker_update(17, condition_seq=1) + + sched._update_request_as_session(session, overflow_update) + + assert session.num_computed_tokens == 0 + assert session.num_prompt_tokens == 41 + assert session.prompt_token_ids == [0] * 41 + assert overflow_update.model_intermediate_buffer["ids"]["streaming_prompt_previous_codes"] == previous_codes + assert overflow_update.model_intermediate_buffer["meta"]["streaming_condition_seq"] == 1 + assert overflow_update.model_intermediate_buffer["meta"]["streaming_prompt_recompute"] is True + assert adapter.segment_finished_requests == set() + assert adapter.requests_num_chunks_sent == {} + assert session._omni_segment_generation == 1 + sched._free_request_blocks.assert_called_once_with(session) + sched.encoder_cache_manager.free.assert_called_once_with(session) + + # Once the replacement has been recomputed, later conditions append to + # the new full-attention prefix until capacity is approached again. + next_codes = [21, 22, 23, 24, 25, 26] + session.append_output_token_ids(next_codes) + session.num_computed_tokens = 47 + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + append_update = _make_talker_update(20, condition_seq=2) + + sched._update_request_as_session(session, append_update) + + assert session.num_computed_tokens == 47 + assert session.num_prompt_tokens == 67 + assert list(session._all_token_ids[-26:]) == next_codes + [0] * 20 + assert append_update.model_intermediate_buffer["meta"]["streaming_condition_seq"] == 2 + assert append_update.model_intermediate_buffer["meta"]["streaming_prompt_recompute"] is False + assert session._omni_segment_generation == 2 + assert sched._free_request_blocks.call_count == 1 + assert sched.encoder_cache_manager.free.call_count == 1 + + # A later overflow replaces the accumulated prefix again rather than + # reverting to recompute-on-every-condition behavior. + second_rollover_codes = [31, 32, 33, 34, 35, 36] + session.num_computed_tokens = session.num_prompt_tokens + session.append_output_token_ids(second_rollover_codes) + session.num_computed_tokens += len(second_rollover_codes) + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + second_overflow_update = _make_talker_update(18, condition_seq=3) + + sched._update_request_as_session(session, second_overflow_update) + + assert session.num_computed_tokens == 0 + assert session.num_prompt_tokens == 44 + assert second_overflow_update.model_intermediate_buffer["ids"]["streaming_prompt_previous_codes"] == ( + second_rollover_codes + ) + assert second_overflow_update.model_intermediate_buffer["meta"]["streaming_condition_seq"] == 3 + assert second_overflow_update.model_intermediate_buffer["meta"]["streaming_prompt_recompute"] is True + assert session._omni_segment_generation == 3 + assert sched._free_request_blocks.call_count == 2 + assert sched.encoder_cache_manager.free.call_count == 2 + + +def test_talker_invalid_capacity_update_is_reported_per_request(mocker) -> None: + sched = _make_scheduler(stage_id=1) + adapter = _make_talker_adapter(max_model_len=100) + adapter.record_receive_failure = mocker.MagicMock() + sched.chunk_transfer_adapter = adapter + session = _make_request() + session.status = RequestStatus.WAITING_FOR_STREAMING_REQ + sched.num_waiting_for_streaming_input = 1 + update = _make_talker_update(91, condition_seq=0) + + sched._update_request_as_session(session, update) + + adapter.record_receive_failure.assert_called_once_with( + session.request_id, + "fresh streaming prompt plus generation reserve exceeds max_model_len: prompt=91, reserve=10, limit=100", + ) + assert session.status == RequestStatus.WAITING_FOR_STREAMING_REQ + assert sched.num_waiting_for_streaming_input == 1 + sched._free_request_blocks.assert_not_called() + sched.encoder_cache_manager.free.assert_not_called() + + def test_ready_async_chunk_prompt_replacement_releases_stale_kv_once() -> None: sched = _make_scheduler(stage_id=1) session = _make_request() diff --git a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py index 0e3194190..40d29e969 100644 --- a/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py +++ b/tests/distributed/omni_connectors/test_chunk_transfer_adapter.py @@ -142,7 +142,7 @@ def test_streaming_prompt_rejects_invalid_generation_reserve( construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096) -def test_capacity_managed_streaming_prompt_at_limit_still_appends(mocker: MockerFixture) -> None: +def test_capacity_managed_streaming_window_at_limit_still_appends(mocker: MockerFixture) -> None: request = _streaming_request(mocker, num_computed_tokens=4060) payload = { "ids": {"prompt": [1]}, @@ -152,13 +152,53 @@ def test_capacity_managed_streaming_prompt_at_limit_still_appends(mocker: Mocker }, } - replaced = construct_next_stage_streaming_input_prompt(payload, request, max_model_len=4096) + replaced = construct_next_stage_streaming_input_prompt( + payload, + request, + max_model_len=4096, + previous_condition_len=10, + previous_condition_seq=0, + condition_seq=1, + recompute_previous_chunks=1, + recompute_on_capacity=True, + ) assert replaced is False assert request.num_computed_tokens == 4060 assert request.num_prompt_tokens == 4070 +def test_capacity_managed_streaming_prompt_appends_from_declared_length_without_ids_prompt( + mocker: MockerFixture, +) -> None: + request = _streaming_request(mocker, num_computed_tokens=20) + payload = { + "ids": {"tts": [1, 2, 3]}, + "hidden_states": {"tts": [[0.1], [0.2], [0.3]]}, + "meta": { + "next_stage_prompt_len": 4, + "next_stage_generation_tokens": 26, + }, + } + + replaced = construct_next_stage_streaming_input_prompt( + payload, + request, + max_model_len=4096, + previous_condition_len=4, + previous_condition_seq=0, + condition_seq=1, + recompute_previous_chunks=1, + recompute_on_capacity=True, + ) + + assert replaced is False + assert request.num_computed_tokens == 20 + assert request.num_prompt_tokens == 24 + assert request.prompt_token_ids == [0] * 24 + request.update_block_hashes.assert_called_once_with() + + def test_streaming_window_builds_one_chunk_recompute_recipe(mocker: MockerFixture) -> None: previous_condition_len = 10 previous_codes = list(range(25)) @@ -203,13 +243,41 @@ def test_streaming_window_builds_one_chunk_recompute_recipe(mocker: MockerFixtur request.update_block_hashes.assert_called_once_with() -def test_streaming_window_recomputes_each_condition_without_accumulating(mocker: MockerFixture) -> None: +def test_capacity_policy_turn_start_replacement_does_not_require_generation_reserve( + mocker: MockerFixture, +) -> None: + request = _streaming_request(mocker, num_computed_tokens=4064) + payload = { + "ids": {"prompt": [1]}, + "meta": { + "replace_streaming_prompt": True, + "next_stage_prompt_len": 10, + }, + } + + replaced = construct_next_stage_streaming_input_prompt( + payload, + request, + max_model_len=4096, + previous_condition_len=10, + previous_condition_seq=7, + condition_seq=8, + recompute_previous_chunks=1, + recompute_on_capacity=True, + ) + + assert replaced is True + assert request.num_computed_tokens == 0 + assert request.num_prompt_tokens == 10 + + +def test_streaming_window_appends_until_capacity_then_recomputes(mocker: MockerFixture) -> None: request = SimpleNamespace( - _all_token_ids=[0] * 10 + [101, 102], + _all_token_ids=[0] * 4000 + [101, 102], _output_token_ids=[101, 102], - prompt_token_ids=[0] * 10, - num_computed_tokens=12, - num_prompt_tokens=10, + prompt_token_ids=[0] * 4000, + num_computed_tokens=4002, + num_prompt_tokens=4000, num_output_placeholders=0, update_block_hashes=mocker.Mock(), ) @@ -221,7 +289,7 @@ def test_streaming_window_recomputes_each_condition_without_accumulating(mocker: }, } - assert construct_next_stage_streaming_input_prompt( + assert not construct_next_stage_streaming_input_prompt( second, request, max_model_len=4096, @@ -229,17 +297,20 @@ def test_streaming_window_recomputes_each_condition_without_accumulating(mocker: previous_condition_seq=0, condition_seq=1, recompute_previous_chunks=1, + recompute_on_capacity=True, ) - assert request.num_prompt_tokens == 24 - assert second["ids"]["streaming_prompt_previous_codes"] == [101, 102] + assert request.num_computed_tokens == 4002 + assert request.num_prompt_tokens == 4014 + assert "streaming_prompt_previous_codes" not in second["ids"] + assert second["meta"]["streaming_prompt_recompute"] is False request._all_token_ids.extend([201, 202, 203]) request._output_token_ids.extend([201, 202, 203]) - request.num_computed_tokens = 27 + request.num_computed_tokens = 4017 third = { "ids": {"prompt": [2]}, "meta": { - "next_stage_prompt_len": 8, + "next_stage_prompt_len": 60, "next_stage_generation_tokens": 26, }, } @@ -252,11 +323,39 @@ def test_streaming_window_recomputes_each_condition_without_accumulating(mocker: previous_condition_seq=1, condition_seq=2, recompute_previous_chunks=1, + recompute_on_capacity=True, ) - assert request.num_prompt_tokens == 23 + assert request.num_prompt_tokens == 75 + assert request.num_computed_tokens == 0 assert third["ids"]["streaming_prompt_previous_codes"] == [201, 202, 203] assert third["meta"]["streaming_condition_seq"] == 2 + request._all_token_ids.extend([301]) + request._output_token_ids.extend([301]) + request.num_computed_tokens = 76 + fourth = { + "ids": {"prompt": [3]}, + "meta": { + "next_stage_prompt_len": 8, + "next_stage_generation_tokens": 26, + "streaming_condition_seq": 3, + }, + } + + assert not construct_next_stage_streaming_input_prompt( + fourth, + request, + max_model_len=4096, + previous_condition_len=60, + previous_condition_seq=2, + condition_seq=3, + recompute_previous_chunks=1, + recompute_on_capacity=True, + ) + assert request.num_computed_tokens == 76 + assert request.num_prompt_tokens == 84 + assert fourth["meta"]["streaming_prompt_recompute"] is False + def test_capacity_rollover_requires_explicit_window_contract(mocker: MockerFixture) -> None: request = _streaming_request(mocker, num_computed_tokens=4064) @@ -353,13 +452,14 @@ def build_adapter(monkeypatch, mocker: MockerFixture): ) hf_config = SimpleNamespace( - model_type="minicpmtts", + model_type="conditional_chattts", max_position_embeddings=tts_max_model_len, attention_type=tts_attention_type, ) if not flat_tts_config: hf_config = SimpleNamespace( tts_config=SimpleNamespace( + model_type="minicpmtts", max_position_embeddings=tts_max_model_len, attention_type=tts_attention_type, ) @@ -369,6 +469,7 @@ def build_adapter(monkeypatch, mocker: MockerFixture): max_num_seqs=max_num_seqs, max_model_len=max_model_len, hf_config=hf_config, + hf_config_name="tts_config" if stage_id == 1 else None, active_stream_window=active_stream_window, stage_connector_config={ "name": "SharedMemoryConnector", @@ -396,13 +497,24 @@ def _dequeue_load_entry(adapter, request): @pytest.mark.parametrize( - ("attention_type", "expected_previous_chunks"), - [("full_attention", 0), ("sliding_recompute", 1)], + ("attention_type", "expected_previous_chunks", "expected_capacity_trigger"), + [("full_attention", 1, True), ("sliding_recompute", 1, False), ("other", 0, False)], ) -def test_talker_attention_policy_controls_streaming_recompute(build_adapter, attention_type, expected_previous_chunks): +def test_talker_attention_policy_controls_streaming_recompute( + build_adapter, attention_type, expected_previous_chunks, expected_capacity_trigger +): adapter, _ = build_adapter(tts_attention_type=attention_type) assert adapter._streaming_prompt_previous_chunks == expected_previous_chunks + assert adapter._streaming_prompt_recompute_on_capacity is expected_capacity_trigger + + +@pytest.mark.parametrize("stage_id", [0, 2]) +def test_talker_streaming_policy_is_scoped_to_tts_stage(build_adapter, stage_id: int) -> None: + adapter, _ = build_adapter(stage_id=stage_id, tts_attention_type="full_attention") + + assert adapter._streaming_prompt_previous_chunks == 0 + assert adapter._streaming_prompt_recompute_on_capacity is False @pytest.mark.parametrize( @@ -570,23 +682,23 @@ def test_load_poll_ar_requeues_explicitly_replaced_running_prompt(build_adapter) @pytest.mark.parametrize("flat_tts_config", [False, True]) -def test_load_poll_ar_recomputes_native_duplex_prompt_each_condition(build_adapter, flat_tts_config): +def test_load_poll_ar_recomputes_native_duplex_prompt_over_capacity(build_adapter, flat_tts_config): adapter, connector = build_adapter( stage_id=1, model_mode="ar", max_model_len=8192, tts_max_model_len=4096, - tts_attention_type="sliding_recompute", + tts_attention_type="full_attention", flat_tts_config=flat_tts_config, ) request = _req("req-rollover", RequestStatus.RUNNING, external_req_id="external-rollover") request.resumable = True previous_codes = list(range(25)) - request.prompt_token_ids = [0] * 10 - request._all_token_ids = [0] * 10 + previous_codes + request.prompt_token_ids = [0] * 4039 + request._all_token_ids = [0] * 4039 + previous_codes request._output_token_ids = previous_codes.copy() - request.num_prompt_tokens = 10 - request.num_computed_tokens = 35 + request.num_prompt_tokens = 4039 + request.num_computed_tokens = 4064 request.num_output_placeholders = 0 request.update_block_hashes = Mock() adapter.get_req_chunk[request.request_id] = 1 @@ -626,6 +738,35 @@ def test_load_poll_ar_recomputes_native_duplex_prompt_each_condition(build_adapt assert waiting_queue == [request] +def test_load_poll_ar_keeps_generic_prompt_extension_for_non_tts_stage(build_adapter) -> None: + adapter, connector = build_adapter(stage_id=2, model_mode="ar") + request = _req("req-generic-extension", RequestStatus.RUNNING) + request.resumable = True + request.prompt_token_ids = [1, 2, 3] + request._all_token_ids = [1, 2, 3, 7] + request._output_token_ids = [7] + request.num_prompt_tokens = 3 + request.num_computed_tokens = 4 + request.update_block_hashes = Mock() + adapter.get_req_chunk[request.request_id] = 1 + connector.get.return_value = ( + { + "ids": {"prompt": [1]}, + "meta": {"next_stage_prompt_len": 2}, + }, + 1, + ) + + assert adapter._poll_single_request(_dequeue_load_entry(adapter, request)) is True + adapter.requests_with_ready_chunks.add(request.request_id) + adapter._apply_pending_ar_prompt_updates({request.request_id: request}) + + assert request.num_computed_tokens == 4 + assert request.prompt_token_ids == [1, 2, 3, 7, 0, 0] + assert request._all_token_ids == [1, 2, 3, 7, 0, 0] + assert request.num_prompt_tokens == 6 + + def test_window_condition_sequence_ignores_control_only_chunks(build_adapter) -> None: adapter, connector = build_adapter( stage_id=1, @@ -644,6 +785,7 @@ def test_window_condition_sequence_ignores_control_only_chunks(build_adapter) -> first = receive( { + "native_duplex": True, "ids": {"prompt": [1]}, "meta": {"next_stage_prompt_len": 10}, } @@ -657,6 +799,7 @@ def test_window_condition_sequence_ignores_control_only_chunks(build_adapter) -> second = receive( { + "native_duplex": True, "ids": {"prompt": [2]}, "meta": {"next_stage_prompt_len": 12}, } @@ -676,6 +819,7 @@ def test_load_poll_ar_reports_invalid_capacity_metadata(build_adapter): "meta": { "next_stage_prompt_len": 4096, "next_stage_generation_tokens": 26, + "replace_streaming_prompt": True, "finished": False, }, }, diff --git a/tests/model_executor/models/minicpmo_4_5/test_pipeline.py b/tests/model_executor/models/minicpmo_4_5/test_pipeline.py index 450246696..f8fdfdcd2 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_pipeline.py +++ b/tests/model_executor/models/minicpmo_4_5/test_pipeline.py @@ -19,8 +19,6 @@ from pathlib import Path from types import SimpleNamespace import pytest -from transformers import PretrainedConfig -from vllm.config import ModelConfig from vllm_omni.config.pipeline_registry import OMNI_PIPELINES from vllm_omni.config.stage_config import ( @@ -168,11 +166,9 @@ class TestDeployTopology: assert connector["extra"]["connector_get_max_wait"] == 300 expected_processor = "tts2code2wav_async_chunk" if deploy.async_chunk else "tts2code2wav_full_payload" assert stages[1].yaml_engine_args["custom_process_next_stage_input_func"].endswith(expected_processor) - if filename != "minicpmo_4_5.yaml": - assert "hf_overrides" not in stages[1].yaml_engine_args + assert "hf_overrides" not in stages[1].yaml_engine_args if filename == "minicpmo_4_5.yaml": assert [stage.yaml_engine_args["max_num_seqs"] for stage in stages] == [4, 4, 4] - assert stages[1].yaml_engine_args["hf_overrides"] == {"tts_config": {"attention_type": "sliding_recompute"}} memory_utilizations = [stage.yaml_engine_args["gpu_memory_utilization"] for stage in stages] assert memory_utilizations == [ 0.55, @@ -193,18 +189,6 @@ class TestDeployTopology: 0.35, ] - def test_duplex_talker_hf_override_resolves_nested_attention_policy(self) -> None: - deploy = load_deploy_config(_DEPLOY_DIR / "minicpmo_4_5.yaml") - stages = merge_pipeline_deploy(OMNI_PIPELINES[deploy.pipeline], deploy) - overrides = stages[1].yaml_engine_args["hf_overrides"] - hf_config = PretrainedConfig() - hf_config.tts_config = PretrainedConfig(attention_type="full_attention") - - model_config = object.__new__(ModelConfig) - model_config._apply_dict_overrides(hf_config, overrides) - - assert hf_config.tts_config.attention_type == "sliding_recompute" - @pytest.mark.parametrize( "filename", [ diff --git a/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py b/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py index 95d845686..0c5b97ae2 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py +++ b/tests/model_executor/models/minicpmo_4_5/test_talker_batching.py @@ -912,10 +912,18 @@ def test_native_duplex_rollover_matches_official_sliding_recompute(mocker) -> No assert build_condition.call_count == 3 -def test_native_duplex_condition_advance_requires_recompute_marker(mocker) -> None: +def test_native_duplex_condition_advance_without_rollover_updates_window_state(mocker) -> None: talker = _make_talker() - condition = torch.ones(2, 2) - mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition) + talker.emb_text = nn.Embedding(1, 2) + talker.emb_code = nn.ModuleList([nn.Embedding(8, 2)]) + first_condition = torch.ones(2, 2) + second_condition = torch.full((2, 2), 2.0) + third_condition = torch.full((2, 2), 3.0) + mocker.patch.object( + talker, + "_build_condition_embeddings", + side_effect=[first_condition, second_condition, second_condition, third_condition], + ) common = { "_omni_is_prefill": True, "_omni_prompt_len": 2, @@ -925,11 +933,105 @@ def test_native_duplex_condition_advance_requires_recompute_marker(mocker) -> No "tts_hidden_states": torch.ones(1, 2), } + talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 0}, **common) + initial_state = talker._request_condition_states["req-history"] + assert initial_state["condition_seq"] == 0 + assert torch.equal(initial_state["condition"], first_condition) + talker._request_audio_states["req-history"]["recent_codes"] = [1, 2, 3] + + _, embeds, _ = talker.preprocess( + torch.zeros(4, dtype=torch.long), + None, + _omni_prompt_len=4, + meta={"streaming_condition_seq": 1}, + **{key: value for key, value in common.items() if key != "_omni_prompt_len"}, + ) + + assert torch.equal(embeds[-2:], second_condition) + state = talker._request_condition_states["req-history"] + assert state["condition_seq"] == 1 + assert torch.equal(state["condition"], second_condition) + assert state["base_recent_codes"] == (1, 2, 3) + assert talker._request_audio_states["req-history"]["recent_codes"] == [1, 2, 3] + + # A chunked-prefill retry for the same condition is idempotent. + _, retry_embeds, _ = talker.preprocess( + torch.zeros(2, dtype=torch.long), + None, + _omni_num_computed_tokens=2, + _omni_prompt_len=4, + meta={"streaming_condition_seq": 1}, + **{key: value for key, value in common.items() if key != "_omni_prompt_len"}, + ) + assert torch.equal(retry_embeds, second_condition) + assert talker._request_condition_states["req-history"]["base_recent_codes"] == (1, 2, 3) + + previous_codes = [4, 5] + expected_rollover = torch.cat( + [ + second_condition, + talker.emb_code[0](torch.tensor(previous_codes)), + third_condition, + ], + dim=0, + ) + _, rollover_embeds, _ = talker.preprocess( + torch.zeros(expected_rollover.shape[0], dtype=torch.long), + None, + _omni_prompt_len=expected_rollover.shape[0], + ids={"streaming_prompt_previous_codes": previous_codes}, + meta={ + "streaming_condition_seq": 2, + "streaming_prompt_recompute": True, + }, + **{key: value for key, value in common.items() if key != "_omni_prompt_len"}, + ) + assert torch.equal(rollover_embeds, expected_rollover) + rollover_state = talker._request_condition_states["req-history"] + assert rollover_state["base_recent_codes"] == (1, 2, 3, 4, 5) + + +def test_native_duplex_sliding_condition_advance_requires_recompute_marker(mocker) -> None: + talker = _make_talker() + talker._tts_config.attention_type = "sliding_recompute" + condition = torch.ones(2, 2) + mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition) + common = { + "_omni_is_prefill": True, + "_omni_prompt_len": 2, + "_omni_num_computed_tokens": 0, + "request_id": "req-sliding", + "native_duplex": True, + "tts_token_ids": torch.tensor([1]), + "tts_hidden_states": torch.ones(1, 2), + } + talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 0}, **common) with pytest.raises(ValueError, match="advanced without its streaming recompute marker"): talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 1}, **common) +def test_native_duplex_disabled_recompute_policy_accepts_condition_advance(mocker) -> None: + talker = _make_talker() + talker._tts_config.attention_type = "other" + condition = torch.ones(2, 2) + mocker.patch.object(talker, "_build_condition_embeddings", return_value=condition) + common = { + "_omni_is_prefill": True, + "_omni_prompt_len": 2, + "_omni_num_computed_tokens": 0, + "request_id": "req-disabled-policy", + "native_duplex": True, + "tts_token_ids": torch.tensor([1]), + "tts_hidden_states": torch.ones(1, 2), + } + + talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 0}, **common) + talker.preprocess(torch.zeros(2, dtype=torch.long), None, meta={"streaming_condition_seq": 1}, **common) + + assert talker._request_condition_states["req-disabled-policy"]["condition_seq"] == 1 + + @pytest.mark.parametrize( ("state", "previous_codes", "error"), [ diff --git a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py index 795047f1c..27037f029 100644 --- a/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py +++ b/tests/model_executor/stage_input_processors/test_minicpmo_4_5_omni.py @@ -21,6 +21,7 @@ def _output( latent: torch.Tensor, multimodal_output: dict | None = None, token_list: list[int] | None = None, + request_id: str = "req-1", ): mm_output = dict(multimodal_output or {}) mm_output["latent"] = latent @@ -30,7 +31,7 @@ def _output( multimodal_output=mm_output, ) return SimpleNamespace( - request_id="req-1", + request_id=request_id, prompt_token_ids=prompt_ids, outputs=[completion], ) @@ -193,21 +194,47 @@ def test_native_duplex_continuation_appends_only_new_talker_condition() -> None: ) first_input = llm2tts([first], prompt=[{}], _streaming_context=context)[0] + replayed_inputs = llm2tts([first], prompt=[{}], _streaming_context=context) second_input = llm2tts([second], prompt=[{}], _streaming_context=context)[0] context.bridge_states["duplex"]["model_turn_id"] = 8 third_input = llm2tts([third], prompt=[{}], _streaming_context=context)[0] + restarted_input = llm2tts( + [ + _output( + prompt_ids=prompt_ids, + output_ids=first_ids, + latent=torch.arange(24, dtype=torch.float32).reshape(6, 4), + multimodal_output={ + "duplex_prompt_token_ids": prompt_ids, + "meta": token_ids, + }, + request_id="req-2", + ) + ], + prompt=[{}], + _streaming_context=context, + )[0] assert first_input["model_intermediate_buffer"]["ids"]["tts"] == [21, 22] + assert replayed_inputs == [] assert second_input["model_intermediate_buffer"]["ids"]["tts"] == [23, 24] assert third_input["model_intermediate_buffer"]["ids"]["tts"] == [25, 26] assert first_input["model_intermediate_buffer"]["meta"]["turn_start"] is True assert second_input["model_intermediate_buffer"]["meta"]["turn_start"] is False assert third_input["model_intermediate_buffer"]["meta"]["turn_start"] is True + assert first_input["model_intermediate_buffer"]["meta"]["streaming_condition_seq"] == 0 + assert second_input["model_intermediate_buffer"]["meta"]["streaming_condition_seq"] == 1 + # A new turn keeps the same stage request, so its condition sequence stays + # monotonic; a new request/incarnation starts again from zero. + assert third_input["model_intermediate_buffer"]["meta"]["streaming_condition_seq"] == 2 + assert restarted_input["model_intermediate_buffer"]["meta"]["streaming_condition_seq"] == 0 assert first_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True assert "replace_streaming_prompt" not in second_input["model_intermediate_buffer"]["meta"] assert third_input["model_intermediate_buffer"]["meta"]["replace_streaming_prompt"] is True assert second_input["model_intermediate_buffer"]["meta"]["next_stage_prompt_len"] == 3 + assert first_input["model_intermediate_buffer"]["meta"]["next_stage_generation_tokens"] == 26 assert second_input["model_intermediate_buffer"]["meta"]["next_stage_generation_tokens"] == 26 + assert third_input["model_intermediate_buffer"]["meta"]["next_stage_generation_tokens"] == 26 assert second_input["prompt_token_ids"] == [0, 0, 0] diff --git a/vllm_omni/core/sched/omni_ar_scheduler.py b/vllm_omni/core/sched/omni_ar_scheduler.py index ad7baf764..329e8ad81 100644 --- a/vllm_omni/core/sched/omni_ar_scheduler.py +++ b/vllm_omni/core/sched/omni_ar_scheduler.py @@ -6,6 +6,7 @@ from __future__ import annotations import time from collections import defaultdict from collections.abc import Iterable, Iterator +from dataclasses import replace from typing import Any import numpy as np @@ -763,11 +764,15 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): return engine_core_outputs def _update_request_as_session(self, session: Request, update: StreamingUpdate) -> None: - """ - Override: Only extend prompt at stage 0, and replace - the existing session with the next streaming update at other stages. + """Apply the next streaming update to a persistent session. - Discards the last sampled output token from the prior input chunk at stage 0. + Stage 0 uses upstream prompt extension. A MiniCPM Talker preserves its + accumulated prompt while it fits, then rebuilds a bounded window and + re-enters admission. Other downstream stages retain their existing + replacement or connector-polling behavior. + + Discards the last sampled output token from the prior input chunk at + stage 0. """ req_id = session.request_id self._new_prompt_len_snapshot[req_id] = len(update.prompt_token_ids) @@ -818,6 +823,56 @@ class OmniARScheduler(OmniSchedulerMixin, VLLMScheduler): if self.log_stats: session.record_event(EngineCoreEventType.QUEUED) return + streaming_prompt_payload = next( + ( + info + for info in update_infos + if isinstance(info, dict) + and isinstance(info.get("meta"), dict) + and "next_stage_prompt_len" in info["meta"] + ), + None, + ) + update_streaming_prompt = getattr( + self.chunk_transfer_adapter, + "update_streaming_prompt_for_condition", + None, + ) + if stage_id != 0 and streaming_prompt_payload is not None and callable(update_streaming_prompt): + mm_feature_base = session.num_computed_tokens + try: + replaced = update_streaming_prompt( + streaming_prompt_payload, + session, + update_prompt=True, + ) + except ValueError as exc: + # This streaming update has already been dequeued. Report the + # permanent contract failure so the next scheduling pass + # finishes only this request instead of crashing EngineCore. + self.chunk_transfer_adapter.record_receive_failure(req_id, str(exc)) + return + if replaced is not None: + if replaced: + # The window recipe needs the old prompt and confirmed + # codec ids, so it runs before their KV/encoder state is + # released. The rebuilt prompt is then admitted from zero. + self._release_replaced_streaming_prompt_cache(session) + self._reset_streaming_session_replacement_state(session) + else: + session._omni_segment_generation = int(getattr(session, "_omni_segment_generation", 0) or 0) + 1 + if update.mm_features: + # Match upstream streaming-session extension semantics. + # The helper has already appended this condition, so use + # the pre-append confirmed length as the MM offset base. + for mm_feature in update.mm_features: + mm_feature.mm_position = replace( + mm_feature.mm_position, + offset=mm_feature.mm_position.offset + mm_feature_base, + ) + session.mm_features.extend(update.mm_features) + self._finish_streaming_session_update(session, update) + return replace_streaming_prompt = any( isinstance(info, dict) and isinstance(info.get("meta"), dict) diff --git a/vllm_omni/core/sched/omni_scheduler_mixin.py b/vllm_omni/core/sched/omni_scheduler_mixin.py index 58c3546cd..aa71245a0 100644 --- a/vllm_omni/core/sched/omni_scheduler_mixin.py +++ b/vllm_omni/core/sched/omni_scheduler_mixin.py @@ -147,8 +147,8 @@ class OmniSchedulerMixin: if input_coordinator is not None: input_coordinator.free_finished_request(request_id) - def _replace_streaming_session(self, session: Request, update: StreamingUpdate) -> None: - """Replace a downstream stage's placeholder with its next payload.""" + def _reset_streaming_session_replacement_state(self, session: Request) -> None: + """Fence transport and async state that belongs to an old prompt.""" session._omni_segment_generation = int(getattr(session, "_omni_segment_generation", 0) or 0) + 1 adapter = getattr(self, "chunk_transfer_adapter", None) if adapter is not None: @@ -156,8 +156,6 @@ class OmniSchedulerMixin: watermark = getattr(adapter, "requests_num_chunks_sent", None) if watermark is not None: watermark.pop(session.external_req_id, None) - session._output_token_ids.clear() - session._all_token_ids.clear() # In-flight outputs from the previous segment were optimistically # scheduled (async lookahead). Mark them stale so update_from_output # drops them instead of underflowing num_output_placeholders @@ -172,18 +170,15 @@ class OmniSchedulerMixin: session.num_stale_output_tokens = int(getattr(session, "num_in_flight_tokens", 0) or 0) session.num_output_placeholders = 0 session.spec_token_ids = [] - new_prompt = update.prompt_token_ids or () - session._all_token_ids.extend(new_prompt) - session.num_computed_tokens = 0 - session.prompt_token_ids = new_prompt + + def _finish_streaming_session_update(self, session: Request, update: StreamingUpdate) -> None: + """Install payload metadata and send an updated session to admission.""" session.additional_information = update.additional_information or None session.model_intermediate_buffer = getattr( update, "model_intermediate_buffer", None, ) - session.update_block_hashes() - session.num_prompt_tokens = len(new_prompt) session.arrival_time = update.arrival_time session.sampling_params = update.sampling_params if session.status == RequestStatus.WAITING_FOR_STREAMING_REQ: @@ -195,6 +190,19 @@ class OmniSchedulerMixin: if self.log_stats: session.record_event(EngineCoreEventType.QUEUED) + def _replace_streaming_session(self, session: Request, update: StreamingUpdate) -> None: + """Replace a downstream stage's placeholder with its next payload.""" + self._reset_streaming_session_replacement_state(session) + session._output_token_ids.clear() + session._all_token_ids.clear() + new_prompt = update.prompt_token_ids or () + session._all_token_ids.extend(new_prompt) + session.num_computed_tokens = 0 + session.prompt_token_ids = new_prompt + session.update_block_hashes() + session.num_prompt_tokens = len(new_prompt) + self._finish_streaming_session_update(session, update) + def _release_replaced_streaming_prompt_cache(self, session: Request) -> None: """Discard cache state that belongs to a replaced prompt.""" # A prompt replacement is not a normal streaming extension: none of @@ -272,7 +280,7 @@ class OmniSchedulerMixin: self._log_failed_chunk_sends() def _process_chunk_receive_failures(self) -> None: - """Fail requests whose consumed async chunk violated its contract.""" + """Fail requests whose consumed streaming input violated its contract.""" adapter = getattr(self, "chunk_transfer_adapter", None) collector = getattr(adapter, "collect_failed_receive_request_ids", None) if collector is None: @@ -282,7 +290,7 @@ class OmniSchedulerMixin: if not present_ids: return logger.error( - "Marking %d request(s) as FINISHED_ERROR after invalid connector input: %s", + "Marking %d request(s) as FINISHED_ERROR after invalid streaming input: %s", len(present_ids), {request_id: failures[request_id] for request_id in sorted(present_ids)}, ) diff --git a/vllm_omni/deploy/minicpmo_4_5.yaml b/vllm_omni/deploy/minicpmo_4_5.yaml index 023101223..4e9fd5a77 100644 --- a/vllm_omni/deploy/minicpmo_4_5.yaml +++ b/vllm_omni/deploy/minicpmo_4_5.yaml @@ -64,13 +64,8 @@ stages: - stage_id: 1 max_num_seqs: 4 - # Native duplex uses MiniCPMTTS.generate_with_buffer's supported - # sliding_recompute policy: retain one completed condition/codec chunk - # when the next condition arrives. Other deploy profiles continue to use - # the checkpoint's full_attention policy. - hf_overrides: - tts_config: - attention_type: sliding_recompute + # Preserve the checkpoint's full-attention Talker prompt while it fits; + # native duplex rolls over to a one-chunk window at context capacity. # Leave device-level headroom for the colocated HiFi-GAN cuDNN workspace. gpu_memory_utilization: 0.15 enforce_eager: false @@ -118,8 +113,10 @@ platforms: cuda: stages: - stage_id: 1 - # Four Talker streams need much less KV than automatic sizing. - # Leave single-GPU headroom for concurrent Stage 2 diffusion batches. + # Full-attention occupancy is bounded by max_num_seqs * max_model_len + # (4 * 4096 = 16384 tokens). Keep the reported CUDA KV token capacity + # above that bound because physical-preemption replay is unsupported. + # The explicit cap leaves device headroom for concurrent Stage 2. kv_cache_memory_bytes: 2147483648 # 2 GiB npu: diff --git a/vllm_omni/distributed/omni_connectors/adapter.py b/vllm_omni/distributed/omni_connectors/adapter.py index d585b3ba1..79a1d7887 100644 --- a/vllm_omni/distributed/omni_connectors/adapter.py +++ b/vllm_omni/distributed/omni_connectors/adapter.py @@ -227,16 +227,21 @@ def construct_next_stage_streaming_input_prompt( previous_condition_seq: int | None = None, condition_seq: int | None = None, recompute_previous_chunks: int = 0, + recompute_on_capacity: bool = False, ) -> bool: - """Update a downstream streaming request prompt from connector payload ids. + """Update a downstream streaming request prompt from connector payload. Async-chunk downstream stages are prewarmed before the real Talker prompt is - known. When a Thinker payload carries ``ids.prompt``, this helper: + known. When a Thinker payload carries ``ids.prompt`` or declares the + placeholder length in ``meta.next_stage_prompt_len``, this helper: * Preserves ``num_computed_tokens`` while extending a non-window prompt. - Explicit replacements and one-condition window recomputes reset it. + Explicit replacements and one-condition window recomputes reset it. A + full-attention Talker recomputes only when the next condition would + exceed its context limit. * Moves already-computed output tokens into ``prompt_token_ids``. - * Appends a new placeholder prompt slice sized from the upstream ids. + * Appends a placeholder slice using the declared downstream prompt length, + or derives its length from ``ids.prompt`` for legacy producers. * Refreshes block hashes so the scheduler allocates KV slots for the extended prompt without discarding prior computed state. """ @@ -285,13 +290,29 @@ def construct_next_stage_streaming_input_prompt( explicit_replacement = meta.get("replace_streaming_prompt") is True if isinstance(recompute_previous_chunks, bool) or recompute_previous_chunks not in (0, 1): raise ValueError("streaming prompt recompute supports exactly one previous chunk") + if not isinstance(recompute_on_capacity, bool): + raise ValueError("recompute_on_capacity must be a bool") has_window_contract = recompute_previous_chunks == 1 - window_recompute = not explicit_replacement and has_window_contract and previous_condition_seq is not None + if recompute_on_capacity and not has_window_contract: + raise ValueError("capacity-triggered streaming prompt recompute requires window_size=1") + if ( + recompute_on_capacity + and not explicit_replacement + and previous_condition_seq is not None + and generation_reserve == 0 + ): + raise ValueError("capacity-triggered streaming prompt recompute requires a positive generation reserve") exceeds_accumulated_capacity = ( managed_prompt_len is not None and capacity_limit is not None and request.num_computed_tokens + managed_prompt_len + generation_reserve > capacity_limit ) + window_recompute = ( + not explicit_replacement + and has_window_contract + and previous_condition_seq is not None + and (exceeds_accumulated_capacity if recompute_on_capacity else True) + ) if not explicit_replacement and exceeds_accumulated_capacity and not window_recompute: raise ValueError("capacity-managed streaming prompt rollover requires window_size=1") replacement_prompt_len = next_stage_prompt_len @@ -369,7 +390,12 @@ def construct_next_stage_streaming_input_prompt( request.update_block_hashes() return True prompt_token_ids = ids.get("prompt", None) - if not prompt_token_ids: + has_declared_prompt_len = ( + isinstance(next_stage_prompt_len, int) + and not isinstance(next_stage_prompt_len, bool) + and next_stage_prompt_len > 0 + ) + if not has_declared_prompt_len and not prompt_token_ids: return False num_computed_tokens = request.num_computed_tokens kept_output_tokens = request._all_token_ids[request.num_prompt_tokens : num_computed_tokens] @@ -378,7 +404,7 @@ def construct_next_stage_streaming_input_prompt( assert request.prompt_token_ids is not None # Extend prompt with kept output tokens. request.prompt_token_ids.extend(kept_output_tokens) - if isinstance(next_stage_prompt_len, int) and next_stage_prompt_len > 0: + if has_declared_prompt_len: next_prompt_len = next_stage_prompt_len else: next_prompt_len = max(1, compute_talker_prompt_ids_length(prompt_token_ids)) diff --git a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py index b9b03c9c5..d71d55bf1 100644 --- a/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py +++ b/vllm_omni/distributed/omni_connectors/transfer_adapter/chunk_transfer_adapter.py @@ -51,13 +51,21 @@ class _LoadEntry: return self.request.external_req_id -def _resolve_talker_streaming_prompt_config(model_config: Any) -> tuple[int, int]: - """Return the effective Talker context limit and recompute window.""" +def _resolve_talker_streaming_prompt_config(model_config: Any) -> tuple[int, int, bool]: + """Return the Talker context limit and streaming recompute policy.""" max_model_len = int(getattr(model_config, "max_model_len", 0) or 0) + if getattr(model_config, "hf_config_name", None) != "tts_config": + return max_model_len, 0, False hf_config = getattr(model_config, "hf_config", None) tts_config = getattr(hf_config, "tts_config", None) - if tts_config is None and getattr(hf_config, "model_type", None) == "minicpmtts": + if tts_config is None: tts_config = hf_config + if isinstance(tts_config, Mapping): + tts_model_type = tts_config.get("model_type") + else: + tts_model_type = getattr(tts_config, "model_type", None) + if tts_model_type not in {"conditional_chattts", "minicpmtts"}: + return max_model_len, 0, False tts_max_model_len = ( tts_config.get("max_position_embeddings", 0) if isinstance(tts_config, Mapping) @@ -71,11 +79,17 @@ def _resolve_talker_streaming_prompt_config(model_config: Any) -> tuple[int, int if isinstance(tts_config, Mapping) else getattr(tts_config, "attention_type", None) ) - # Official MiniCPMTTS supports both full_attention and - # sliding_recompute. Only an explicit Talker-stage policy enables the - # latter; native duplex alone must not override checkpoint semantics. - previous_chunks = 1 if attention_type == "sliding_recompute" else 0 - return max_model_len, previous_chunks + if tts_config is not None and attention_type is None: + attention_type = "full_attention" + # ``full_attention`` keeps the complete prompt until its next condition + # would exceed the Talker context. It then falls back to the same supported + # one-previous-chunk recompute recipe. An explicitly configured + # ``sliding_recompute`` applies that recipe at every condition boundary. + if attention_type == "sliding_recompute": + return max_model_len, 1, False + if attention_type == "full_attention": + return max_model_len, 1, True + return max_model_len, 0, False class OmniChunkTransferAdapter(OmniTransferAdapterBase): @@ -113,9 +127,11 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): self._registered_load_entries: dict[str, _LoadEntry] = {} self._sender_tokens: dict[str, _SenderGeneration] = {} self.scheduler_max_num_seqs = vllm_config.scheduler_config.max_num_seqs - self._max_model_len, self._streaming_prompt_previous_chunks = _resolve_talker_streaming_prompt_config( - model_config - ) + ( + self._max_model_len, + self._streaming_prompt_previous_chunks, + self._streaming_prompt_recompute_on_capacity, + ) = _resolve_talker_streaming_prompt_config(model_config) active_stream_window = int(getattr(model_config, "active_stream_window", 0) or 0) model_max_num_seqs = int(getattr(model_config, "max_num_seqs", self.scheduler_max_num_seqs) or 0) if model_max_num_seqs <= 0: @@ -962,6 +978,99 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): self._promote_active_streams(waiting_queue) self._preempt_non_active_running(waiting_queue, running_queue) + def update_streaming_prompt_for_condition( + self, + payload_data: dict[str, Any], + request: Request, + *, + update_prompt: bool, + ) -> bool | None: + """Track one Talker condition and optionally update its prompt. + + Returns ``None`` when the payload is not governed by the Talker + streaming-window contract. Otherwise returns whether the prompt was + replaced, as opposed to extended in place. + """ + if self._streaming_prompt_previous_chunks != 1: + return None + meta = payload_data.get("meta") + if not isinstance(meta, dict): + return None + condition_len = meta.get("next_stage_prompt_len") + if not isinstance(condition_len, int) or isinstance(condition_len, bool) or condition_len <= 0: + return None + + request_id = request.request_id + previous_condition_seq = self._streaming_condition_seqs.get(request_id) + previous_condition_len = self._streaming_condition_lengths.get(request_id) + if (previous_condition_seq is None) != (previous_condition_len is None): + raise ValueError("streaming Talker condition tracking is incomplete") + if previous_condition_seq is None and not self.receives_chunks: + # A sender-only Talker is admitted with its first real condition, + # before this adapter observes any StreamingUpdate. Seed that + # condition as sequence zero from the active request so its first + # continuation follows the same lifecycle as receiver stages. + initial_info = getattr(request, "model_intermediate_buffer", None) + initial_meta = initial_info.get("meta") if isinstance(initial_info, dict) else None + initial_condition_len = ( + initial_meta.get("next_stage_prompt_len") if isinstance(initial_meta, dict) else None + ) + initial_condition_seq = ( + initial_meta.get("streaming_condition_seq") if isinstance(initial_meta, dict) else None + ) + if ( + isinstance(initial_info, dict) + and initial_info.get("native_duplex") is True + and isinstance(initial_condition_len, int) + and not isinstance(initial_condition_len, bool) + and initial_condition_len > 0 + and isinstance(initial_condition_seq, int) + and not isinstance(initial_condition_seq, bool) + and initial_condition_seq == 0 + ): + if initial_condition_len != request.num_prompt_tokens: + raise ValueError( + "initial streaming Talker condition length does not match its prompt: " + f"condition={initial_condition_len}, prompt={request.num_prompt_tokens}" + ) + previous_condition_seq = initial_condition_seq + previous_condition_len = initial_condition_len + supplied_condition_seq = meta.get("streaming_condition_seq") + if supplied_condition_seq is None: + if payload_data.get("native_duplex") is True and not self.receives_chunks: + raise ValueError("native duplex Talker condition is missing streaming_condition_seq") + condition_seq = previous_condition_seq + 1 if previous_condition_seq is not None else 0 + meta["streaming_condition_seq"] = condition_seq + elif ( + not isinstance(supplied_condition_seq, int) + or isinstance(supplied_condition_seq, bool) + or supplied_condition_seq < 0 + ): + raise ValueError("streaming_condition_seq must be a non-negative integer") + else: + condition_seq = supplied_condition_seq + expected_condition_seq = previous_condition_seq + 1 if previous_condition_seq is not None else 0 + if condition_seq != expected_condition_seq: + raise ValueError( + "native duplex Talker condition sequence is not contiguous: " + f"expected={expected_condition_seq}, received={condition_seq}" + ) + replaced = False + if update_prompt: + replaced = construct_next_stage_streaming_input_prompt( + payload_data, + request, + max_model_len=self._max_model_len, + previous_condition_len=previous_condition_len, + previous_condition_seq=previous_condition_seq, + condition_seq=condition_seq, + recompute_previous_chunks=self._streaming_prompt_previous_chunks, + recompute_on_capacity=self._streaming_prompt_recompute_on_capacity, + ) + self._streaming_condition_lengths[request_id] = condition_len + self._streaming_condition_seqs[request_id] = condition_seq + return replaced + def _apply_pending_ar_prompt_updates(self, scheduler_requests: dict[str, Request] | None) -> None: """Finalize ready AR prompt updates on the scheduler thread.""" for request_id in tuple(self.requests_with_ready_chunks): @@ -981,31 +1090,31 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): continue request.additional_information = payload_data - meta = payload_data.get("meta") - meta = meta if isinstance(meta, dict) else {} - previous_condition_seq = self._streaming_condition_seqs.get(request_id) - condition_seq = previous_condition_seq + 1 if previous_condition_seq is not None else 0 is_window_condition = window_condition_len is not None if is_window_condition: - meta["streaming_condition_seq"] = condition_seq - if is_window_condition: + previous_condition_seq = self._streaming_condition_seqs.get(request_id) + meta = payload_data.get("meta") + assert isinstance(meta, dict) update_prompt = bool( was_resumable and (previous_condition_seq is not None or meta.get("replace_streaming_prompt") is True) ) try: - if update_prompt: + replaced = self.update_streaming_prompt_for_condition( + payload_data, + request, + update_prompt=update_prompt, + ) + if replaced is None and update_prompt: + # Non-window AR receivers still use the generic + # prompt-extension contract from the base adapter. replaced = construct_next_stage_streaming_input_prompt( payload_data, request, max_model_len=self._max_model_len, - previous_condition_len=self._streaming_condition_lengths.get(request_id), - previous_condition_seq=previous_condition_seq, - condition_seq=condition_seq, - recompute_previous_chunks=self._streaming_prompt_previous_chunks, ) - if replaced: - self.replaced_streaming_prompt_ids.add(request_id) + if replaced: + self.replaced_streaming_prompt_ids.add(request_id) except ValueError as exc: # The connector chunk was consumed, so retrying would skip # this transition and desynchronize Talker. Let the @@ -1013,10 +1122,6 @@ class OmniChunkTransferAdapter(OmniTransferAdapterBase): self.record_receive_failure(request_id, str(exc)) continue - if window_condition_len is not None: - self._streaming_condition_lengths[request_id] = window_condition_len - self._streaming_condition_seqs[request_id] = condition_seq - def _requeue_replaced_prompts(self, waiting_queue: Any, running_queue: list[Request]) -> None: """Move a replaced running prompt back through scheduler admission.""" for request in list(running_queue): diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py index 7c87f1a8b..bdc418f05 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_tts.py @@ -319,10 +319,26 @@ class MiniCPMO45OmniTTSForConditionalGeneration(nn.Module, SupportsPP): if not recompute: if condition_seq > previous_seq: - raise ValueError( - "a native duplex Talker condition advanced without its streaming recompute marker: " - f"current={condition_seq}, previous={previous_seq}" - ) + attention_type = getattr(self._tts_config, "attention_type", "full_attention") + if attention_type == "sliding_recompute": + raise ValueError( + "a native duplex Talker condition advanced without its streaming recompute marker: " + f"current={condition_seq}, previous={previous_seq}" + ) + audio_state = self._request_audio_states.get(request_id) + recent_codes = audio_state.get("recent_codes") if isinstance(audio_state, dict) else None + if isinstance(recent_codes, list): + base_recent_codes = tuple(int(code_id) for code_id in recent_codes[-_CODEC_PENALTY_WINDOW:]) + else: + base_recent_codes = state.get("base_recent_codes") + if not isinstance(base_recent_codes, tuple): + raise ValueError("streaming Talker condition lost its frozen codec history") + states[request_id] = { + "condition_seq": condition_seq, + "condition": current_condition.detach().clone(), + "base_recent_codes": base_recent_codes, + } + return current_condition if "active_embeddings" in state: raise ValueError("an active streaming recompute was replayed without its recompute marker") return current_condition diff --git a/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py b/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py index c1cbd14bc..80995f92e 100644 --- a/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py +++ b/vllm_omni/model_executor/stage_input_processors/minicpmo_4_5_omni.py @@ -637,6 +637,7 @@ def _native_duplex_segment_output_ids( state["request_id"] = request_id state["sent_output_len"] = 0 state["sent_output_ids"] = [] + state["condition_seq"] = -1 previous_turn_id = state.get("turn_id") sent_len = state.get("sent_output_len", 0) prev_output_ids = state.get("sent_output_ids", []) @@ -1000,6 +1001,8 @@ def llm2tts( if native_turn_end_handoff: model_intermediate_buffer.setdefault("meta", {})["turn_end"] = True + condition_sequence_state = None + condition_sequence_value = None if handoff_ids is not None and handoff_hidden is not None: condition_suffix_length = 1 if is_native_duplex_handoff else 2 condition_length = max(len(handoff_ids), len(handoff_hidden)) + condition_suffix_length @@ -1011,6 +1014,20 @@ def llm2tts( handoff_meta["next_stage_prompt_len"] = condition_length if is_native_duplex_handoff: handoff_meta["next_stage_generation_tokens"] = MINICPMO45_DUPLEX_CODEC_TOKENS_PER_CHUNK + bridge_states = getattr(_streaming_context, "bridge_states", None) + handoff_state = bridge_states.get("minicpmo45_tts_handoff") if isinstance(bridge_states, dict) else None + if not isinstance(handoff_state, dict) or handoff_state.get("request_id") != str(llm_output.request_id): + raise ValueError("native duplex Talker handoff is missing request-local streaming state") + previous_condition_seq = handoff_state.get("condition_seq", -1) + if ( + not isinstance(previous_condition_seq, int) + or isinstance(previous_condition_seq, bool) + or previous_condition_seq < -1 + ): + raise ValueError("native duplex Talker condition sequence state is invalid") + condition_sequence_state = handoff_state + condition_sequence_value = previous_condition_seq + 1 + handoff_meta["streaming_condition_seq"] = condition_sequence_value # Native duplex resumes one Talker request within a turn, but a new # assistant turn must discard the previous turn's prompt and KV. if not is_native_duplex_handoff or native_turn_start: @@ -1033,6 +1050,8 @@ def llm2tts( mm_processor_kwargs=None, ) ) + if condition_sequence_state is not None: + condition_sequence_state["condition_seq"] = condition_sequence_value if native_turn_end_handoff: bridge_states = getattr(_streaming_context, "bridge_states", None) duplex_state = bridge_states.get("duplex") if isinstance(bridge_states, dict) else None From 615f97b9c79e2143c91d3f7fe81603327df059aa Mon Sep 17 00:00:00 2001 From: Xiaobaixia <289982752@qq.com> Date: Mon, 7 Sep 2026 01:20:04 +0800 Subject: [PATCH 06/90] [Feature] Define the runner preprocess phase contract and regression coverage (#7136) Signed-off-by: xiaobaixia222 <289982752@qq.com> --- .buildkite/cuda/test-merge.yml | 4 +- docs/.nav.yml | 1 + docs/contributing/model/adding_omni_model.md | 10 + .../feature/preprocess_phase_contract.md | 172 ++++++++++++++++++ docs/design/index.md | 1 + pyproject.toml | 4 +- .../test_mooncake_tcp_completion.py | 99 ++++++++++ .../test_chunked_prefill_tail.py | 120 ++++++++++++ .../prefill_phase/worker_extension.py | 59 ++++++ tests/worker/test_omni_gpu_model_runner.py | 152 +++++++++++++++- 10 files changed, 619 insertions(+), 3 deletions(-) create mode 100644 docs/design/feature/preprocess_phase_contract.md create mode 100644 tests/distributed/omni_connectors/test_mooncake_tcp_completion.py create mode 100644 tests/e2e/features/prefill_phase/test_chunked_prefill_tail.py create mode 100644 tests/e2e/features/prefill_phase/worker_extension.py diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml index d2a3ffb02..6bad98f79 100644 --- a/.buildkite/cuda/test-merge.yml +++ b/.buildkite/cuda/test-merge.yml @@ -79,6 +79,8 @@ steps: steps: - label: "TTS · Qwen3-TTS CustomVoice Test" source_file_dependencies: + - tests/e2e/features/prefill_phase/ + - vllm_omni/worker/gpu_model_runner.py - tests/helpers/media.py - tests/e2e/online_serving/test_qwen3_tts_customvoice.py - tests/e2e/offline_inference/test_qwen3_tts_customvoice.py @@ -96,7 +98,7 @@ steps: timeout 40m bash -c " export VLLM_LOGGING_LEVEL=DEBUG export VLLM_ALLOW_LONG_MAX_MODEL_LEN=1 - pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py -m 'advanced_model and cuda' --run-level 'advanced_model' + pytest -s -v tests/e2e/online_serving/test_qwen3_tts_customvoice.py tests/e2e/offline_inference/test_qwen3_tts_customvoice.py tests/e2e/features/prefill_phase/test_chunked_prefill_tail.py -m 'advanced_model and cuda' --run-level 'advanced_model' " mirror_hardwares: l4_1 diff --git a/docs/.nav.yml b/docs/.nav.yml index 8a03c7a4f..aa84f2819 100644 --- a/docs/.nav.yml +++ b/docs/.nav.yml @@ -127,6 +127,7 @@ nav: - Async Chunk: design/feature/async_chunk.md - Async Diffusion Output: design/feature/async_diffusion_output.md - Async Omni Output Materialization: design/feature/omni_async_output_materialization.md + - Prefill/Decode Phase Contract: design/feature/preprocess_phase_contract.md - Automatic Prefix Caching: design/feature/prefix_caching.md - Realtime AR-Diffusion: design/feature/realtime_ar_diffusion.md - Communication: diff --git a/docs/contributing/model/adding_omni_model.md b/docs/contributing/model/adding_omni_model.md index 1f1de15be..2a2f62c4a 100644 --- a/docs/contributing/model/adding_omni_model.md +++ b/docs/contributing/model/adding_omni_model.md @@ -222,6 +222,16 @@ __all__ = ["Qwen3OmniMoeForConditionalGeneration"] ## Key Components +### Preprocess phase metadata + +Models with `has_preprocess=True`, including out-of-tree plugins, can rely on +the runner's stable `_omni_is_prefill`, `_omni_prompt_len`, and +`_omni_num_computed_tokens` fields. Use request progress to distinguish prompt +prefill from decode: a one-token span can still be a prefill tail. See the +[runner-to-model phase contract](../../design/feature/preprocess_phase_contract.md) +for types, batched-hook semantics, MTP routing guarantees, and the correctness +workaround for vLLM-Omni 0.20.x and earlier. + ### 1. Model Interfaces Your model should implement the appropriate interfaces: diff --git a/docs/design/feature/preprocess_phase_contract.md b/docs/design/feature/preprocess_phase_contract.md new file mode 100644 index 000000000..d795c9c23 --- /dev/null +++ b/docs/design/feature/preprocess_phase_contract.md @@ -0,0 +1,172 @@ +# Runner-to-model prefill/decode phase contract + +## Overview + +Autoregressive models with a custom `preprocess` hook need to distinguish +prompt processing from decoding. A one-token span can be either a complete +one-token prompt, the last chunk of a longer prompt, or a decode step. Using +span length as the phase signal can replace a prompt embedding with a generated +audio embedding and advance teacher-forced text too early without an exception. + +[Issue #4382](https://github.com/vllm-project/vllm-omni/issues/4382) requests a +stable contract for the metadata and routing guards introduced by +[PR #3662](https://github.com/vllm-project/vllm-omni/pull/3662). +The shared runner already implements those guards. This design documents the +existing interface and adds runner-level regression coverage rather than +introducing a second phase detector or changing scheduling policy. + +## Scope and objectives + +- Make the three existing phase keys safe to depend on in out-of-tree models. +- Preserve prompt embeddings and model state on a one-token prefill tail. +- Keep normal and batched decode routing consistent in mixed batches. +- Explain the correctness workaround required on older releases. + +This contract does not enable chunked prefill for models whose other state +handling does not support it. It does not change the scheduler, introduce new +kwargs, or standardize other `_omni_*` fields. The implementation adds +documentation and regression coverage; no hot-path allocation is added. + +## Design + +### Ownership and data flow + +```text +Scheduler / current input batch + prompt_token_ids + num_computed_tokens_cpu (before this step) + | + OmniGPUModelRunner._preprocess + | + per-request phase metadata + / \ + prefill / other spans single-token decode + preprocess preprocess or preprocess_decode_batch + | | + prompt embedding runner-managed talker_mtp + \ / + model forward +``` + +The producer is `OmniGPUModelRunner._preprocess` in +`vllm_omni/worker/gpu_model_runner.py`. `GPUARModelRunner` and +`GPUGenerationModelRunner` inherit it. The current `OmniNPUModelRunner` also +inherits this method; device-specific execution still needs its own hardware +validation. A plugin runner that overrides preprocessing is responsible for +preserving this interface. + +### Stable model-facing fields + +The following names, types, and meanings are a supported interface for in-tree +and out-of-tree model hooks despite the leading underscore. Models should treat +them as read-only runner-owned values. Changes to their meanings or removal +require an explicit compatibility/migration plan. + +| Key | Python type | Meaning | +| --- | --- | --- | +| `_omni_prompt_len` | `int` | Length of the current request state's `prompt_token_ids`. This is the stage's prompt length, not the current chunk size or raw text length. | +| `_omni_num_computed_tokens` | `int` | The row's `input_batch.num_computed_tokens_cpu` value **before** processing the scheduled span. It includes already-computed/cached progress, not just tokens computed in the current call. | +| `_omni_is_prefill` | `bool` | Exactly `_omni_num_computed_tokens < _omni_prompt_len`. Equality and greater progress mean decode. | + +The runner refreshes the fields after current request state has been gathered, +immediately before selecting the per-row hook. Existing values in intermediate +model state cannot override the current phase. Row indices follow the current +input batch (including any reordering), not request arrival order. A resumed +or streaming request is classified from its **current** prompt/progress values; +the phase is not latched for the lifetime of the request. + +The normal hook receives these fields as keyword arguments: + +```python +def preprocess(self, input_ids, input_embeds, **info): + if info["_omni_is_prefill"]: + offset = info["_omni_num_computed_tokens"] + # Select the scheduled slice of the model's prompt embeddings. + ... + else: + # Advance decode-only text/audio state. + ... +``` + +For `preprocess_decode_batch(input_ids=..., req_infos=...)`, each `req_infos` +entry contains the same fields, aligned with its corresponding input row. +Every such row has `_omni_is_prefill == False`. The optional earlier +`preprocess_batch` hook is a separate whole-batch preparation hook; these freshly +computed per-row fields are not promised at that earlier boundary. + +### Routing guarantee + +With `has_preprocess=True`, the batched decode hook is eligible only when it is +callable, the runner has `talker_mtp`, the scheduled span is one token, and +`_omni_is_prefill` is false. Otherwise the normal `preprocess` hook runs. + +Runner-managed MTP is likewise restricted to single-token decode rows. A prefill +row, including a one-token tail, is never added to that MTP batch: the runner +does not consume its `mtp_inputs`, generate codes for it, or overwrite its +preprocessed embedding with an MTP embedding. Decode rows in the same step +continue to receive their MTP outputs at their own token offsets. Models may +still implement their own model-specific work inside `preprocess`. + +| Prompt length | Computed before step | Scheduled span | Phase | Runner MTP eligible | +| --- | --- | --- | --- | --- | +| 5 | 0 | 4 | Prefill | No | +| 5 | 4 | 1 | Prefill tail | No | +| 5 | 5 | 1 | Decode | Yes | +| 5 | 6 | 1 | Decode | Yes | +| 1 | 0 | 1 | Prefill | No | +| 5 | 5 | 2 | Decode | No (not a single-token span) | + +### Compatibility + +!!! warning "vLLM-Omni 0.20.x and earlier" + AR talker models with custom preprocessing that infer phase from span length + must use `enable_chunked_prefill: false` for correctness on these releases. + They lack the phase metadata and the runner's prefill-tail MTP protection. + A model-only heuristic cannot prevent the older runner from overwriting a + one-token prompt embedding. Disabling chunking avoids split tails; it does + not make span length a reliable phase detector for every possible prompt. + +The mechanism landed in #3662 (v0.21.0rc2 / v0.22.0 and later); this document +formalizes that existing behavior. Prefer upgrading the matching vLLM and +vLLM-Omni release pair and consuming the explicit phase keys. Do not silently +fall back to `input_ids.numel() > 1` when the model requires chunking correctness. +For a custom or backported runtime, verify both metadata and MTP guards. + +## Correctness and testing plan + +Runner tests call the production `_preprocess` and `_talker_mtp_forward` methods +with CPU buffers and a deterministic MTP model. They inspect kwargs at the model +boundary, resulting embeddings, and per-request generated codes. Coverage must +include a multi-step prompt split with a one-token tail followed by decode, +mixed prefill/decode batches in different orders, normal/batched decode hooks, +adjacent decode requests in the same hook call with exact per-request codes, +single-token prompts, cached progress, and decode spans longer than one token. +No test should pass merely because MTP was disabled for every row. + +Real-weight validation uses Qwen3-TTS CustomVoice through the full talker and +code2wav pipeline. Record the actual scheduled prompt progress, assert that at +least one one-token prefill tail was observed, check its hook metadata and MTP +exclusion, and require finite non-empty output audio. Compare to an unchunked +run with the same real input, greedy sampling, and `VLLM_BATCH_INVARIANT=1`. +Greedy sampling alone does not guarantee identical codes across different +prefill shapes: floating-point reduction differences can change an argmax and +diverge in subsequent autoregressive steps. Invariant kernels keep this exact +comparison focused on the phase contract. The two engines must be initialized +separately because their chunking settings and token budgets differ. GPU tests belong under +`tests/e2e/features/`; existing model tests remain useful adjacent regression +coverage. Functional audio checks do not establish throughput or all-model +semantic parity; this change makes no performance claim. + +## Open questions and discussions + +There are no new field names or execution modes to choose. Future runner +implementations, including Model Runner V2, should preserve these semantics or +provide an explicit migration path. Hardware validation beyond the tested +platform remains separate from the shared Python routing tests. + +## References + +- [Issue #4382](https://github.com/vllm-project/vllm-omni/issues/4382) +- [Original metadata and guards, #3662](https://github.com/vllm-project/vllm-omni/pull/3662) +- [Qwen2.5-Omni consumer fix, #5019](https://github.com/vllm-project/vllm-omni/pull/5019) +- [Adding an Omni model](../../contributing/model/adding_omni_model.md) +- [Test writing guide](../../contributing/ci/test_writing_guide.md) diff --git a/docs/design/index.md b/docs/design/index.md index c5b28130c..cff0b1eb3 100644 --- a/docs/design/index.md +++ b/docs/design/index.md @@ -24,6 +24,7 @@ implementation contract; it is not, by itself, a general support claim. - [Async Chunk](feature/async_chunk.md) - [Async Diffusion Output](feature/async_diffusion_output.md) - [Async Omni Output Materialization](feature/omni_async_output_materialization.md) +- [Runner-to-model Prefill/Decode Phase Contract](feature/preprocess_phase_contract.md) - [Automatic Prefix Caching in Omni Models](feature/prefix_caching.md) - [Realtime AR-Diffusion Sessions](feature/realtime_ar_diffusion.md) diff --git a/pyproject.toml b/pyproject.toml index 73d21cbe8..cd7544af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,9 @@ dev = [ "soundfile==0.14.0", "imageio[ffmpeg]==2.37.4", "opencv-python==5.0.0.93", - "mooncake-transfer-engine-cuda13==0.3.11.post1", + # 0.3.12 includes acknowledged TCP writes (Mooncake #2850): completion + # must mean the receive buffer is ready before zero-copy consumers read it. + "mooncake-transfer-engine-cuda13==0.3.12.post1", "av==18.0.0", # for ComfyUI tests "openpyxl==3.1.5", # for nightly CI "pyttsx3==2.99", diff --git a/tests/distributed/omni_connectors/test_mooncake_tcp_completion.py b/tests/distributed/omni_connectors/test_mooncake_tcp_completion.py new file mode 100644 index 000000000..c8a6312e8 --- /dev/null +++ b/tests/distributed/omni_connectors/test_mooncake_tcp_completion.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""TCP completion must mean that the destination has applied the write.""" + +import multiprocessing +import os +import signal +import threading +import time + +import pytest +import torch + +from tests.helpers.mark import hardware_test +from vllm_omni.distributed.omni_connectors.connectors.mooncake_transfer_engine_connector import TransferEngine + +pytestmark = [ + pytest.mark.parallel, + pytest.mark.core_model, + pytest.mark.skipif(TransferEngine is None, reason="Mooncake TransferEngine not installed"), + pytest.mark.skipif(os.name != "posix", reason="Requires POSIX process suspension"), +] + + +def _tcp_receiver(pipe): + engine = TransferEngine() + assert engine.initialize("127.0.0.1", "P2PHANDSHAKE", "tcp", "") == 0 + destination = torch.zeros(65536, dtype=torch.uint8).pin_memory() + assert engine.register_memory(destination.data_ptr(), destination.numel()) == 0 + pipe.send((engine.get_rpc_port(), destination.data_ptr())) + while True: + value = pipe.recv() + if value is None: + return + deadline = time.monotonic() + 10 + while not bool((destination == value).all()): + assert time.monotonic() < deadline, "Destination did not receive the expected bytes" + pipe.send(True) + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_tcp_write_completion_waits_for_receiver(monkeypatch): + # Also exercise TCP on hosts where Mooncake would otherwise auto-select RDMA. + monkeypatch.setenv("MC_FORCE_TCP", "1") + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe() + receiver = context.Process(target=_tcp_receiver, args=(child,)) + receiver.start() + receiver_pid = receiver.pid + assert receiver_pid is not None + stopped = False + try: + assert parent.poll(120), "Receiver did not initialize" + port, address = parent.recv() + engine = TransferEngine() + assert engine.initialize("127.0.0.1", "P2PHANDSHAKE", "tcp", "") == 0 + source = torch.ones(65536, dtype=torch.uint8).pin_memory() + assert engine.register_memory(source.data_ptr(), source.numel()) == 0 + remote = f"127.0.0.1:{port}" + + # Establish the data connection before suspending the receiver, so the + # second call tests data completion rather than RPC/connection setup. + assert engine.batch_transfer_sync_write(remote, [source.data_ptr()], [address], [source.numel()]) == 0 + parent.send(1) + assert parent.poll(30) and parent.recv() is True + os.kill(receiver_pid, signal.SIGSTOP) + stopped = True + _, status = os.waitpid(receiver_pid, os.WUNTRACED) + assert os.WIFSTOPPED(status) + source.fill_(173) + completed = threading.Event() + result = [] + + def transfer(): + result.append(engine.batch_transfer_sync_write(remote, [source.data_ptr()], [address], [source.numel()])) + completed.set() + + thread = threading.Thread(target=transfer, daemon=True) + thread.start() + completed_while_stopped = completed.wait(0.5) + os.kill(receiver_pid, signal.SIGCONT) + stopped = False + thread.join(30) + assert result == [0], "Write did not complete successfully after resuming the receiver" + parent.send(173) + assert parent.poll(30) and parent.recv() is True + assert not completed_while_stopped, "TCP reported completion before destination memory could be updated" + finally: + if stopped: + os.kill(receiver_pid, signal.SIGCONT) + if receiver.is_alive(): + parent.send(None) + receiver.join(10) + if receiver.is_alive(): + receiver.terminate() + receiver.join(10) + parent.close() + child.close() diff --git a/tests/e2e/features/prefill_phase/test_chunked_prefill_tail.py b/tests/e2e/features/prefill_phase/test_chunked_prefill_tail.py new file mode 100644 index 000000000..8c59e3d23 --- /dev/null +++ b/tests/e2e/features/prefill_phase/test_chunked_prefill_tail.py @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Real-weight regression: force a final one-token prefill chunk, then decode.""" + +import json +import os +from pathlib import Path + +import pytest +import soundfile as sf +import torch +from transformers import AutoTokenizer + +from tests.helpers.mark import hardware_test +from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config +from vllm_omni.entrypoints.async_omni import AsyncOmni +from vllm_omni.model_executor.models.qwen3_tts.configuration_qwen3_tts import Qwen3TTSConfig +from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import Qwen3TTSPromptEmbedsBuilder + +pytestmark = [pytest.mark.advanced_model, pytest.mark.tts] + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.asyncio +async def test_one_token_prefill_tail_matches_unchunked_audio(tmp_path, monkeypatch): + # Greedy sampling alone does not make different prefill shapes numerically + # identical. Use invariant kernels for the exact codes/waveform comparison. + monkeypatch.setenv("VLLM_BATCH_INVARIANT", "1") + model = str(Path(os.environ.get("MODEL_PREFIX", "")) / "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice") + tokenizer = AutoTokenizer.from_pretrained(model) + config = Qwen3TTSConfig.from_pretrained(model) + info = { + "task_type": ["CustomVoice"], + "text": ["Hello, this is a test of speech generation."], + "language": ["English"], + "speaker": ["vivian"], + "max_new_tokens": [128], + } + prompt_len = Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information( + info, + task_type="CustomVoice", + tokenize_prompt=lambda text: tokenizer(text)["input_ids"], + codec_language_id=config.talker_config.codec_language_id, + spk_is_dialect=config.talker_config.spk_is_dialect, + ) + assert prompt_len > 2 + prompt = {"prompt_token_ids": [0] * prompt_len, "additional_information": info} + observations = [] + audios = [] + for chunked in (False, True): + deploy = modify_stage_config( + get_deploy_config_path("qwen3_tts.yaml"), + updates={ + "async_chunk": False, + "stages": { + 0: { + "enable_chunked_prefill": chunked, + "enable_prefix_caching": False, + "max_num_batched_tokens": prompt_len - 1 if chunked else 2048, + "max_model_len": 2048, + "max_num_seqs": 1, + "enforce_eager": True, + "async_scheduling": False, + "default_sampling_params.temperature": 0.0, + "default_sampling_params.max_tokens": 128, + "subtalker_sampling_params": {"do_sample": False}, + }, + 1: {"enforce_eager": True, "async_scheduling": False}, + }, + }, + ) + engine = AsyncOmni( + model=model, + deploy_config=deploy, + worker_extension_cls=("tests.e2e.features.prefill_phase.worker_extension.PhaseContractWorkerExtension"), + stage_init_timeout=600, + init_timeout=900, + ) + try: + assert await engine.collective_rpc("start_phase_contract_probe", stage_ids=[0]) == [[True]] + audio_parts = [] + async for output in engine.generate(prompt, request_id=f"phase-{chunked}"): + if output.final_output_type == "audio": + multimodal = output.outputs[0].multimodal_output + assert (torch.as_tensor(multimodal["sr"]) == 24000).all() + audio = multimodal["audio"] + audio_parts.extend(audio if isinstance(audio, list) else [audio]) + assert audio_parts, "pipeline returned no audio" + waveform = torch.cat([part.detach().cpu().reshape(-1) for part in audio_parts]) + assert waveform.numel() > 0 and torch.isfinite(waveform).all() + assert waveform.abs().max() > 0 + sf.write(tmp_path / f"chunked-{chunked}.wav", waveform.float().numpy(), 24000) + audios.append(waveform) + probe = await engine.collective_rpc("get_phase_contract_probe", stage_ids=[0]) + observations.append(probe[0][0]) + finally: + engine.shutdown() + + baseline, chunked_probe = observations + evidence = { + "prompt_len": prompt_len, + "observations": observations, + "audio_samples": audios[0].numel(), + "chunked_audio_samples": audios[1].numel(), + "max_audio_difference": ( + float((audios[1] - audios[0]).abs().max()) if audios[1].shape == audios[0].shape else None + ), + } + evidence_path = tmp_path / "phase_contract.json" + evidence_path.write_text(json.dumps(evidence, indent=2)) + print(f"Phase contract evidence: {evidence_path}") + + # Preserve both observations even when a parity assertion fails in CI. + assert [prompt_len, 0, prompt_len, True] in baseline["events"] + assert [prompt_len, 0, prompt_len - 1, True] in chunked_probe["events"] + assert [prompt_len, prompt_len - 1, 1, True] in chunked_probe["events"] + assert baseline["codes"] and chunked_probe["codes"] + assert chunked_probe["codes"] == baseline["codes"] + torch.testing.assert_close(audios[1], audios[0], rtol=1e-4, atol=1e-5) diff --git a/tests/e2e/features/prefill_phase/worker_extension.py b/tests/e2e/features/prefill_phase/worker_extension.py new file mode 100644 index 000000000..eb0e9b83c --- /dev/null +++ b/tests/e2e/features/prefill_phase/worker_extension.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Observe the real runner/model boundary without replacing inference.""" + +from typing import TYPE_CHECKING + +import torch + +if TYPE_CHECKING: + from vllm_omni.worker.gpu_model_runner import OmniGPUModelRunner + + +class PhaseContractWorkerExtension: + model_runner: "OmniGPUModelRunner" + + def start_phase_contract_probe(self): + runner = self.model_runner + self.phase_contract_events: list[tuple[int, int, int, bool]] = [] + self.phase_contract_codes: list[list[list[int]]] = [] + tail_embeddings = {} + original_preprocess = runner.model.preprocess + original_mtp = runner._talker_mtp_forward + + def preprocess(input_ids, input_embeds, **info): + rid = info["request_id"] + index = runner.input_batch.req_id_to_index[rid] + prompt_len = len(runner.requests[rid].prompt_token_ids) + computed = int(runner.input_batch.num_computed_tokens_cpu[index]) + assert info["_omni_prompt_len"] == prompt_len + assert info["_omni_num_computed_tokens"] == computed + assert info["_omni_is_prefill"] is (computed < prompt_len) + result = original_preprocess(input_ids=input_ids, input_embeds=input_embeds, **info) + span = input_ids.numel() + self.phase_contract_events.append((prompt_len, computed, span, info["_omni_is_prefill"])) + if computed == prompt_len - 1 and span == 1: + offset = int(runner.query_start_loc.cpu[index]) + tail_embeddings[offset] = result[1].detach().clone() + return result + + def mtp(decode_req_ids, inputs_embeds, start_offsets=None): + for rid in decode_req_ids: + index = runner.input_batch.req_id_to_index[rid] + assert int(runner.input_batch.num_computed_tokens_cpu[index]) >= len( + runner.requests[rid].prompt_token_ids + ), "prefill row was routed through MTP" + original_mtp(decode_req_ids, inputs_embeds, start_offsets) + for offset, expected in tail_embeddings.items(): + torch.testing.assert_close(inputs_embeds[offset : offset + 1], expected, rtol=0, atol=0) + tail_embeddings.clear() + for rid in decode_req_ids: + self.phase_contract_codes.append(runner.model_intermediate_buffer[rid]["codes"]["audio"].tolist()) + + runner.model.preprocess = preprocess + setattr(runner, "_talker_mtp_forward", mtp) + return True + + def get_phase_contract_probe(self): + return {"events": self.phase_contract_events, "codes": self.phase_contract_codes} diff --git a/tests/worker/test_omni_gpu_model_runner.py b/tests/worker/test_omni_gpu_model_runner.py index 81d2a6b8a..4155e5ffb 100644 --- a/tests/worker/test_omni_gpu_model_runner.py +++ b/tests/worker/test_omni_gpu_model_runner.py @@ -6,6 +6,7 @@ from types import SimpleNamespace import pytest import torch +from vllm.v1.core.sched.output import CachedRequestData, SchedulerOutput from vllm.v1.cudagraph_dispatcher import CUDAGraphMode from vllm.v1.worker.gpu_model_runner import GPUModelRunner @@ -108,7 +109,8 @@ class DummyInputBatch: class DummyReqState: """A minimal request state container.""" - pass + mm_features: list[str] + additional_information_cpu: dict def test_model_forward_passes_request_ids_to_decode_metadata(monkeypatch): @@ -878,3 +880,151 @@ def test_maybe_attach_mimo_audio_req_infos_no_req_state_returns_input(): # When no req_state, helper should be a no-op. assert result is req_infos + + +def _make_phase_runner(monkeypatch, rows, *, batched_decode): + """Exercise real preprocessing and MTP routing with CPU model buffers. + + Each row is (request id, prompt length, computed tokens, scheduled tokens). + Only model computation and the device forward context are replaced. + """ + import vllm_omni.worker.gpu_model_runner as mod + + monkeypatch.setattr(mod, "get_pp_group", lambda: SimpleNamespace(is_first_rank=True)) + monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context) + runner = _make_runner(req_ids=tuple(row[0] for row in rows)) + total = sum(row[3] for row in rows) + runner.supports_mm_inputs = False + runner.enable_prompt_embeds = False + runner.uses_mrope = False + runner.uses_xdrope_dim = 0 + runner.has_talker_mtp = True + runner.model_config = SimpleNamespace(is_encoder_decoder=False) + runner.vllm_config.model_config.async_chunk = True + runner._init_model_kwargs = dict + runner.input_ids = DummyBuffer(torch.arange(total, dtype=torch.int64)) + runner.inputs_embeds = DummyBuffer(torch.zeros(total, 4)) + runner.positions = torch.arange(total) + runner.input_batch.num_computed_tokens_cpu = [row[2] for row in rows] + offsets = [0] + for rid, prompt_len, _, scheduled in rows: + runner.requests[rid].prompt_token_ids = list(range(prompt_len)) + offsets.append(offsets[-1] + scheduled) + # Runner-owned metadata must override stale model/intermediate state. + runner.model_intermediate_buffer[rid] = { + "_omni_is_prefill": "stale", + "_omni_prompt_len": -1, + "_omni_num_computed_tokens": -1, + } + runner.query_start_loc.cpu = torch.tensor(offsets, dtype=torch.int32) + calls = [] + batch_calls = [] + + def preprocess(input_ids, input_embeds, **info): + calls.append(("normal", dict(info))) + embeds = input_ids.float().view(-1, 1).expand(-1, 4).clone() + updates = {} + if not info["_omni_is_prefill"]: + updates["mtp_inputs"] = (torch.zeros_like(embeds), torch.zeros_like(embeds)) + return input_ids, embeds, updates + + def preprocess_decode_batch(input_ids, req_infos): + batch_calls.append([info["request_id"] for info in req_infos]) + calls.extend(("batch", dict(info)) for info in req_infos) + embeds = input_ids.float().view(-1, 1).expand(-1, 4).clone() + return input_ids, embeds, torch.zeros_like(embeds), torch.zeros_like(embeds), [{} for _ in req_infos] + + runner.model = SimpleNamespace( + has_preprocess=True, + preprocess=preprocess, + talker_mtp_output_key=("codes", "audio"), + ) + if batched_decode: + runner.model.preprocess_decode_batch = preprocess_decode_batch + output = SchedulerOutput( + total_num_scheduled_tokens=total, + num_scheduled_tokens={row[0]: row[3] for row in rows}, + scheduled_new_reqs=[], + scheduled_cached_reqs=CachedRequestData.make_empty(), + scheduled_spec_decode_tokens={}, + scheduled_encoder_inputs={}, + num_common_prefix_blocks=[], + finished_req_ids=set(), + free_encoder_mm_hashes=[], + ) + return runner, output, calls, batch_calls + + +@pytest.mark.parametrize("batched_decode", [False, True], ids=["normal", "batch"]) +@pytest.mark.parametrize("order", ["interleaved", "reversed", "adjacent"]) +def test_preprocess_phase_contract_mixed_batch(monkeypatch, batched_decode, order): + rows = [ + ("decode-before", 5, 5, 1), + ("tail", 5, 4, 1), + ("decode-after", 5, 6, 1), + ("single-prompt", 1, 0, 1), + ("cached-prefill", 8, 4, 3), + ("multi-token-decode", 5, 5, 2), + ] + if order == "reversed": + rows.reverse() + elif order == "adjacent": + rows = [rows[1], rows[0], rows[2], *rows[3:]] + runner, scheduled, calls, batch_calls = _make_phase_runner(monkeypatch, rows, batched_decode=batched_decode) + _, embeds, *_ = runner._preprocess(scheduled, scheduled.total_num_scheduled_tokens) + + if batched_decode: + decode_order = [rid for rid, _, _, _ in rows if rid.startswith("decode-")] + expected_batches = [decode_order] if order == "adjacent" else [[rid] for rid in decode_order] + assert batch_calls == expected_batches + else: + assert batch_calls == [] + assert len(calls) == len(rows) + offset = 0 + code_index = 0 + for (route, info), (rid, prompt_len, computed, span) in zip(calls, rows, strict=True): + assert info["request_id"] == rid + assert type(info["_omni_prompt_len"]) is int + assert type(info["_omni_num_computed_tokens"]) is int + assert type(info["_omni_is_prefill"]) is bool + assert info["_omni_prompt_len"] == prompt_len + assert info["_omni_num_computed_tokens"] == computed + assert info["_omni_is_prefill"] == (computed < prompt_len) + mtp_eligible = rid in {"decode-before", "decode-after"} + assert route == ("batch" if batched_decode and mtp_eligible else "normal") + expected = torch.arange(offset, offset + span).float().view(-1, 1).expand(-1, 4) + torch.testing.assert_close(embeds[offset : offset + span], expected + int(mtp_eligible)) + assert ("codes" in runner.model_intermediate_buffer[rid]) == mtp_eligible + if mtp_eligible: + torch.testing.assert_close( + runner.model_intermediate_buffer[rid]["codes"]["audio"], + torch.tensor([[code_index]], dtype=torch.int64), + rtol=0, + atol=0, + ) + code_index += 1 + offset += span + + +@pytest.mark.parametrize("batched_decode", [False, True], ids=["normal", "batch"]) +def test_preprocess_one_token_chunked_prefill_tail_then_decode(monkeypatch, batched_decode): + # Replay a five-token prompt split at a four-token scheduling budget. + runner, scheduled, calls, _ = _make_phase_runner(monkeypatch, [("r", 5, 0, 4)], batched_decode=batched_decode) + runner._preprocess(scheduled, 4) + assert calls[-1][1]["_omni_is_prefill"] is True + assert "codes" not in runner.model_intermediate_buffer["r"] + + for computed, is_prefill in [(4, True), (5, False)]: + runner.input_batch.num_computed_tokens_cpu = [computed] + scheduled.total_num_scheduled_tokens = 1 + scheduled.num_scheduled_tokens = {"r": 1} + runner.query_start_loc.cpu = torch.tensor([0, 1], dtype=torch.int32) + runner.input_ids.gpu[0] = 42 + _, embeds, *_ = runner._preprocess(scheduled, 1) + route, info = calls[-1] + assert info["_omni_is_prefill"] is is_prefill + assert info["_omni_num_computed_tokens"] == computed + assert info["_omni_prompt_len"] == 5 + assert route == ("batch" if batched_decode and not is_prefill else "normal") + torch.testing.assert_close(embeds, torch.full((1, 4), 42.0 if is_prefill else 43.0)) + assert ("codes" in runner.model_intermediate_buffer["r"]) == (not is_prefill) From eeee25791327d94bd5ea5460f5df7c1f7198d4e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Mon, 7 Sep 2026 07:21:21 +0800 Subject: [PATCH 07/90] [Bugfix][Diffusion] Restore the TRTLLM attention default for the MiniMax-H3 modular alias (#7162) Signed-off-by: princepride --- vllm_omni/diffusion/model_metadata.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/vllm_omni/diffusion/model_metadata.py b/vllm_omni/diffusion/model_metadata.py index 67c58fbec..38df83846 100644 --- a/vllm_omni/diffusion/model_metadata.py +++ b/vllm_omni/diffusion/model_metadata.py @@ -48,12 +48,16 @@ _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = { attention_mask_free=True, ), # The modular alias is served by MiniMaxH3Pipeline and has the same - # Ref2VA request contract. Keep admission limits in sync with it. + # Ref2VA request contract and packed-sequence layout. Every field must stay + # in sync with it: the repository root ``model_index.json`` declares this + # alias, so serving the checkpoint by repo id resolves here rather than to + # ``MiniMaxH3Pipeline``. "MiniMaxH3ModularPipeline": DiffusionModelMetadata( supports_multimodal_inputs=True, max_multimodal_image_inputs=9, supports_mixed_reference_inputs=True, final_output_type="video", + attention_mask_free=True, ), "Magi2Pipeline": DiffusionModelMetadata( supports_multimodal_inputs=True, From 2df95de421597cf552f49750d7daa7d70444aa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zeyu=20Huang=20=7C=20=E9=BB=83=E6=BE=A4=E5=AE=87?= <11222265+fhfuih@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:40:04 +0800 Subject: [PATCH 08/90] [Core][Diffusion] Refactor prompt updates into a modality-ready interaction framework (#6294) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> --- .../model/adding_diffusion_model.md | 25 ++ docs/user_guide/diffusion/execution_modes.md | 10 +- .../test_diffusion_streaming_output.py | 0 .../test_prompt_interaction.py} | 337 +++++++++++++++--- vllm_omni/diffusion/interaction/__init__.py | 2 + .../diffusion/interaction/coordinator.py | 124 +++++++ vllm_omni/diffusion/interaction/mixin.py | 58 +++ .../interaction/modality_handlers/base.py | 62 ++++ .../interaction/modality_handlers/prompt.py | 266 ++++++++++++++ vllm_omni/diffusion/interaction/registry.py | 24 ++ vllm_omni/diffusion/interaction/types.py | 112 ++++++ .../models/helios/pipeline_helios.py | 34 +- vllm_omni/diffusion/models/interface.py | 41 ++- vllm_omni/diffusion/prompt_update.py | 245 ------------- .../worker/diffusion_model_runner.py | 80 +++-- vllm_omni/diffusion/worker/input_batch.py | 2 +- vllm_omni/diffusion/worker/utils.py | 8 + vllm_omni/inputs/data.py | 11 +- 18 files changed, 1079 insertions(+), 362 deletions(-) rename tests/diffusion/{ => streaming_interactive_generation}/test_diffusion_streaming_output.py (100%) rename tests/diffusion/{test_prompt_update.py => streaming_interactive_generation/test_prompt_interaction.py} (59%) create mode 100644 vllm_omni/diffusion/interaction/__init__.py create mode 100644 vllm_omni/diffusion/interaction/coordinator.py create mode 100644 vllm_omni/diffusion/interaction/mixin.py create mode 100644 vllm_omni/diffusion/interaction/modality_handlers/base.py create mode 100644 vllm_omni/diffusion/interaction/modality_handlers/prompt.py create mode 100644 vllm_omni/diffusion/interaction/registry.py create mode 100644 vllm_omni/diffusion/interaction/types.py delete mode 100644 vllm_omni/diffusion/prompt_update.py diff --git a/docs/contributing/model/adding_diffusion_model.md b/docs/contributing/model/adding_diffusion_model.md index 678797652..e2b318062 100644 --- a/docs/contributing/model/adding_diffusion_model.md +++ b/docs/contributing/model/adding_diffusion_model.md @@ -779,6 +779,31 @@ turning them on. For Qwen-Image-style serving examples, document `--step-execution` as the feature gate and `--max-num-seqs N` as the companion batching knob. +### Streaming Output and Mid-Stream Interactions + +To add streaming generation with optional mid-stream prompt updates to a new diffusion pipeline (often a video generation pipeline): + +0. **Model should internally support** chunked video emission and bounded VAE decode of a chunk. +1. **Implement stepwise execution** so interaction RPCs can be consumed at chunk + boundaries (see [Step Execution](#step-execution) above). +2. **Mix in** ``InteractionMixin`` on the pipeline class. +3. **Implement** ``peek_chunk_media(StepRequestState) -> ChunkMediaSpec`` to offer information + about the next chunk to generate. + This is called whenever one supported interaction modality needs frame-level calculation, + e.g., per-frame camera trajectory. If only chunk-level calculation is involved, peeking is skipped. +4. **Optionally implement** ``prepare_next_chunk(StepRequestState)`` if the + pipeline must rebuild something (e.g., per-chunk state) after an interaction is applied. + This function is called at chunk boundary. +5. **Register** supported modalities and handlers in ``vllm_omni/diffusion/interaction/registry.py`` + (keyed by ``od_config.model_class_name`` → modality key as in ``req["multi_modal_data"]``). + +Serve with ``--diffusion-streaming-output`` (auto-enables step execution). See +[Diffusion Execution Modes](../../user_guide/diffusion/execution_modes.md#streaming-output) +and the example script at `examples/online_serving/streaming_video_generation/`. + +**Interaction payload contract:** Although ``OmniInteractionPrompt`` type annocation requires ``event_id``, +the WebSocket API payload may omit ``event_id`` and let the API layer assigns one. + ### Cache Acceleration #### TeaCache diff --git a/docs/user_guide/diffusion/execution_modes.md b/docs/user_guide/diffusion/execution_modes.md index 3aa625295..5eee3c4d9 100644 --- a/docs/user_guide/diffusion/execution_modes.md +++ b/docs/user_guide/diffusion/execution_modes.md @@ -8,7 +8,7 @@ are exposed. ## Choose a Mode | Goal | CLI configuration | -|---|---| +| --- | --- | | Serial request execution | `--max-num-seqs 1` | | Fused request-level batching | `--max-num-seqs N` | | Single-request step execution | `--step-execution --max-num-seqs 1` | @@ -177,8 +177,10 @@ wait The scheduler may batch these requests when their sampling parameters are compatible. See the [Image Generation API](../../serving/image_generation_api.md) for response -formats and additional client examples. Streaming models can use -model-specific streaming endpoints documented by their serving guide. +formats and additional client examples. + +Streaming models can use model-specific streaming endpoints documented at +[Streaming Video Output API](../../serving/streaming_video_output_api.md). ## Python API @@ -241,7 +243,7 @@ For step execution, set `step_execution: true` and remove ## CLI Reference | Flag | Default | Effect | -|---|---:|---| +| --- | ---: | --- | | `--step-execution` | disabled | Select step-wise scheduling | | `--max-num-seqs` | `1` for diffusion stages | Set request- or step-scheduler capacity | | `--request-batch-max-wait-ms` | `0` | Wait for burst coalescing in request mode | diff --git a/tests/diffusion/test_diffusion_streaming_output.py b/tests/diffusion/streaming_interactive_generation/test_diffusion_streaming_output.py similarity index 100% rename from tests/diffusion/test_diffusion_streaming_output.py rename to tests/diffusion/streaming_interactive_generation/test_diffusion_streaming_output.py diff --git a/tests/diffusion/test_prompt_update.py b/tests/diffusion/streaming_interactive_generation/test_prompt_interaction.py similarity index 59% rename from tests/diffusion/test_prompt_update.py rename to tests/diffusion/streaming_interactive_generation/test_prompt_interaction.py index f08825d1a..2e5ceb8e2 100644 --- a/tests/diffusion/test_prompt_update.py +++ b/tests/diffusion/streaming_interactive_generation/test_prompt_interaction.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Tests for midway prompt update across runner, batch, pipeline, and entrypoint layers.""" from __future__ import annotations @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio import queue import time +from contextlib import contextmanager from types import SimpleNamespace from typing import Any, cast from unittest.mock import MagicMock, patch @@ -21,6 +22,11 @@ from tests.engine.test_orchestrator import ( ) from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.inline_stage_diffusion_client import InlineStageDiffusionClient +from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator +from vllm_omni.diffusion.interaction.modality_handlers.prompt import ( + PromptInteractionHandler, + PromptSession, +) from vllm_omni.diffusion.models.helios.pipeline_helios import HeliosPipeline from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner from vllm_omni.diffusion.worker.input_batch import InputBatch @@ -54,30 +60,55 @@ def pipeline() -> HeliosPipeline: None, ) ) - pipeline._prepare_next_chunk = MagicMock() + pipeline.prepare_next_chunk = MagicMock() + od_config = SimpleNamespace(model_class_name="HeliosPipeline") + pipeline._interaction_coordinator = InteractionCoordinator.build(pipeline, od_config) return pipeline -def _make_diffusion_request_state(*, request_id: str = "req-1") -> StepRequestState: +def _make_diffusion_request_state(*, request_id: str = "req-1", fps: int | None = None) -> StepRequestState: state = StepRequestState( request_id=request_id, - sampling=SimpleNamespace(num_outputs_per_prompt=1, max_sequence_length=226), # pyright: ignore[reportArgumentType] + sampling=SimpleNamespace( # pyright: ignore[reportArgumentType] + num_outputs_per_prompt=1, + max_sequence_length=226, + fps=fps, + ), prompt="hello", ) state.prompt_embeds = torch.zeros(1, 4, 2) - state.extra = {} + state.extra = {"window_num_frames": 8} return state -def _make_diffusion_model_runner(*, pipeline, streaming_output: bool = True) -> DiffusionModelRunner: +def _make_diffusion_model_runner( + *, + pipeline, + streaming_output: bool = True, + model_class_name: str = "HeliosPipeline", +) -> DiffusionModelRunner: runner = object.__new__(DiffusionModelRunner) runner.pipeline = pipeline runner.state_cache = {} runner.od_config = SimpleNamespace( # pyright: ignore[reportAttributeAccessIssue] - model_class_name="HeliosPipeline", + model_class_name=model_class_name, streaming_output=streaming_output, + cache_backend=None, + parallel_config=SimpleNamespace(use_hsdp=False), ) + runner.vllm_config = object() + runner.device = torch.device("cpu") + runner.cache_backend = None + runner.offload_backend = None + runner.prompt_embed_cache = None + runner.kv_transfer_manager = SimpleNamespace( + receive_multi_kv_cache_distributed=lambda *a, **k: None, + ) + # HeliosPipeline skeletons from object.__new__ are not full SupportsStepExecution + # instances; unit tests of submit_interaction stub this. Stepwise path tests + # rebind to the real method. runner._supports_step_mode = lambda: True + runner._interaction_coordinator = None return runner @@ -108,10 +139,13 @@ class TestPromptUpdateExecution: runner.submit_interaction("req-1", _prompt_interaction(transition_chunks=2)) pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue] - pending = state.extra["pending_prompt_update"] - assert pending["event_id"] == "ui-update-1" - assert pending["transition_chunks"] == 2 - assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) + session = state.interaction_sessions["prompt"] + assert isinstance(session, PromptSession) + pending = session.pending_event + assert pending is not None + assert pending.event_id == "ui-update-1" + assert pending.transition_chunks == 2 + assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0)) def test_runner_prompt_update_rejects_unsupported_pipeline(self) -> None: """Runner rejects prompt updates when the pipeline lacks prompt-update support.""" @@ -135,6 +169,9 @@ class TestPromptUpdateExecution: {}, {"multi_modal_data": {"camera": {"type": "pose"}}}, {"event": {"prompt": "updated", "multi_modal_data": {"camera": {"type": "pose"}}}}, + {"event": {"prompt": "updated", "multi_modal_data": {}}}, + {"event": {"prompt": "updated", "multi_modal_data": None}}, + {"event": {"prompt": "updated", "multi_modal_data": "bad"}}, ], ) def test_runner_interaction_rejects_structural_payloads_until_implemented( @@ -149,6 +186,34 @@ class TestPromptUpdateExecution: with pytest.raises(NotImplementedError, match="Only text-only prompt update interactions"): runner.submit_interaction("req-1", cast(Any, interaction)) + @pytest.mark.parametrize("model_class_name", ["HeliosPipeline", "HeliosPyramidPipeline"]) + def test_coordinator_registers_prompt_for_helios_aliases( + self, + pipeline: HeliosPipeline, + model_class_name: str, + ) -> None: + """Both Helios architecture names resolve the prompt handler registry.""" + od_config = SimpleNamespace(model_class_name=model_class_name) + coordinator = InteractionCoordinator.build(pipeline, od_config) + assert coordinator.has_modality("prompt") + + runner = _make_diffusion_model_runner(pipeline=pipeline, model_class_name=model_class_name) + runner.state_cache["req-1"] = _make_diffusion_request_state() + runner.submit_interaction("req-1", _prompt_interaction()) + session = runner.state_cache["req-1"].interaction_sessions["prompt"] + assert isinstance(session, PromptSession) + assert session.pending_event is not None + + def test_helios_peek_chunk_media_requires_positive_fps(self, pipeline: HeliosPipeline) -> None: + """Media-timeline peek must raise on missing/invalid fps instead of coercing to 0.0.""" + state = _make_diffusion_request_state(fps=None) + with pytest.raises(ValueError, match="sampling.fps is required and must be > 0"): + pipeline.peek_chunk_media(state) + + state.sampling.fps = 0 # pyright: ignore[reportAttributeAccessIssue] + with pytest.raises(ValueError, match="sampling.fps is required and must be > 0"): + pipeline.peek_chunk_media(state) + def test_input_batch_refreshes_prompt_embeds_on_version_change(self) -> None: """InputBatch rebuilds prompt_embeds when prompt_update_version changes.""" state = StepRequestState( @@ -159,28 +224,37 @@ class TestPromptUpdateExecution: state.prompt_embeds = torch.zeros(1, 2, 3) state.latents = torch.zeros(1, 2) state.timesteps = torch.tensor([1.0]) - state.extra["prompt_update_version"] = 0 + state.interaction_sessions["prompt"] = PromptSession(version=0) batch = InputBatch.make_batch([state]) assert torch.equal(batch.prompt_embeds, torch.zeros(1, 2, 3)) # pyright: ignore[reportArgumentType] state.prompt_embeds = torch.ones(1, 2, 3) - state.extra["prompt_update_version"] = 1 + state.interaction_sessions["prompt"] = PromptSession(version=1) refreshed = InputBatch.make_batch([state], cached_batch=batch) assert torch.equal(refreshed.prompt_embeds, torch.ones(1, 2, 3)) # pyright: ignore[reportArgumentType] - def test_helios_prepare_prompt_update_queues_pending_target(self, pipeline: HeliosPipeline) -> None: - """HeliosPipeline queues target embeds without mutating current prompt_embeds.""" + def test_helios_prompt_handler_enqueue_queues_pending_target(self, pipeline: HeliosPipeline) -> None: + """Prompt handler queues target embeds without mutating current prompt_embeds.""" state = _make_diffusion_request_state() - pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + PromptInteractionHandler.from_pipeline(pipeline).enqueue( + state, + event_id="ui-update-1", + received_at=0.0, + payload={"prompt": "new scene"}, + transition_chunks=2, + ) - pending = state.extra["pending_prompt_update"] - assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) - assert pending["transition_chunks"] == 2 + session = state.interaction_sessions["prompt"] + assert isinstance(session, PromptSession) + pending = session.pending_event + assert pending is not None + assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0)) + assert pending.transition_chunks == 2 assert torch.equal(state.prompt_embeds, torch.zeros(1, 4, 2)) # pyright: ignore[reportArgumentType] - def test_helios_prepare_prompt_update_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None: + def test_helios_prompt_handler_enqueue_rejects_before_initial_generation(self, pipeline: HeliosPipeline) -> None: """Reject prompt updates submitted before initial prompt embeds exist.""" state = _make_diffusion_request_state() state.prompt_embeds = None @@ -189,43 +263,209 @@ class TestPromptUpdateExecution: ValueError, match="prompt_update is not allowed before initial generation has started", ): - pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + PromptInteractionHandler.from_pipeline(pipeline).enqueue( + state, + event_id="ui-update-1", + received_at=0.0, + payload={"prompt": "new scene"}, + transition_chunks=2, + ) - assert "pending_prompt_update" not in state.extra + session = state.interaction_sessions.get("prompt") + assert session is None or (isinstance(session, PromptSession) and session.pending_event is None) - def test_helios_apply_prompt_update_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None: + def test_helios_apply_interaction_at_chunk_boundary_starts_transition(self, pipeline: HeliosPipeline) -> None: """At chunk boundary, starts transition state and bumps prompt_update_version.""" - state = _make_diffusion_request_state() - pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=2) + state = _make_diffusion_request_state(fps=None) + PromptInteractionHandler.from_pipeline(pipeline).enqueue( + state, + event_id="ui-update-1", + received_at=0.0, + payload={"prompt": "new scene"}, + transition_chunks=2, + ) - pipeline._apply_prompt_update_at_chunk_boundary(state) + pipeline.apply_interaction_at_chunk_boundary(state) - assert state.extra.get("prompt_update_state") is not None + session = state.interaction_sessions["prompt"] + assert isinstance(session, PromptSession) + assert session.active_event is not None assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType] - assert state.extra["prompt_update_version"] == 1 - assert state.extra["prompt_update_chunk_metadata"] == { + assert session.version == 1 + assert state.interaction_chunk_metadata is not None + assert state.interaction_chunk_metadata.as_dict() == { "started_event_ids": ["ui-update-1"], "active_event_ids": ["ui-update-1"], "completed_event_ids": [], } - def test_helios_apply_prompt_update_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None: + def test_helios_apply_interaction_advances_transition_over_chunks(self, pipeline: HeliosPipeline) -> None: """At chunk boundary, interpolates embeds until the target prompt is reached.""" - state = _make_diffusion_request_state() - pipeline.prepare_prompt_update(state, "new scene", "ui-update-1", transition_chunks=3) - pipeline._apply_prompt_update_at_chunk_boundary(state) + state = _make_diffusion_request_state(fps=None) + PromptInteractionHandler.from_pipeline(pipeline).enqueue( + state, + event_id="ui-update-1", + received_at=0.0, + payload={"prompt": "new scene"}, + transition_chunks=3, + ) + pipeline.apply_interaction_at_chunk_boundary(state) assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0 / 3.0)) # pyright: ignore[reportArgumentType] for _ in range(2): - pipeline._apply_prompt_update_at_chunk_boundary(state) + pipeline.apply_interaction_at_chunk_boundary(state) assert torch.allclose(state.prompt_embeds, torch.full((1, 4, 2), 2.0)) # pyright: ignore[reportArgumentType] - assert state.extra["prompt_update_version"] == 3 + session = state.interaction_sessions["prompt"] + assert isinstance(session, PromptSession) + assert session.version == 3 # Further chunk boundaries after completion must not bump the version. - pipeline._apply_prompt_update_at_chunk_boundary(state) - assert state.extra["prompt_update_version"] == 3 + pipeline.apply_interaction_at_chunk_boundary(state) + assert session.version == 3 + + def test_stepwise_runner_applies_prompt_update_and_acks( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Drive the production runner loop: submit → apply(no media args) → ACK on next chunk. + + Also regresses ``sampling.fps is None`` on the prompt-only path (no peek). + ACK ids land on the *next* chunk's ``DiffusionOutput`` via + ``_attach_stepwise_metadata`` consuming the prior boundary metadata. + """ + import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module + from vllm_omni.diffusion.interaction.mixin import InteractionMixin + from vllm_omni.diffusion.interaction.types import ChunkMediaSpec + + chunks = [ + DiffusionOutput(output="chunk-0", finished=False, chunk_index=0, total_chunks=2), # pyright: ignore[reportArgumentType] + DiffusionOutput(output="chunk-1", finished=True, chunk_index=1, total_chunks=2), # pyright: ignore[reportArgumentType] + ] + + class _InteractiveChunkPipeline(InteractionMixin): + device = torch.device("cpu") + supports_step_execution = True + + def __init__(self) -> None: + self._outputs = chunks + self.decode_calls = 0 + self.transformer = SimpleNamespace(dtype=torch.float32) + self.encode_prompt = MagicMock(return_value=(torch.full((1, 4, 2), 2.0), None)) + self.prepare_next_chunk = MagicMock() + od_config = SimpleNamespace(model_class_name="HeliosPipeline") + self._interaction_coordinator = InteractionCoordinator.build(self, od_config) # pyright: ignore[reportArgumentType] + + def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec: + # Same contract as Helios: never coerce missing fps to 0.0. + fps = state.sampling.fps + if fps is None or float(fps) <= 0: + raise ValueError( + "sampling.fps is required and must be > 0 for interaction modalities " + f"that use the chunk media timeline, got {fps!r} " + f"(request_id={state.request_id!r})" + ) + return ChunkMediaSpec(num_frames=int(state.extra["window_num_frames"]), fps=float(fps)) + + def prepare_encode(self, state: StepRequestState) -> StepRequestState: + state.prompt_embeds = torch.zeros(1, 4, 2) + state.latents = torch.zeros(1, 1) + state.timesteps = torch.tensor([1.0, 0.0, 1.0, 0.0]) + state.step_index = 0 + state.step_in_chunk = 0 + state.chunk_num_steps = 2 + state.total_chunks = 2 + state.extra = {"window_num_frames": 8} + return state + + def denoise_step(self, input_batch: Any, states: Any) -> torch.Tensor: + del states + return torch.ones_like(input_batch.latents) + + def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor | None) -> None: + state.latents = noise_pred + state.step_index += 1 + state.step_in_chunk += 1 + + def post_decode(self, state: StepRequestState) -> DiffusionOutput: + output = self._outputs[self.decode_calls] + self.decode_calls += 1 + state.chunk_index += 1 + state.step_index = 0 + state.step_in_chunk = 0 + if not state.request_denoise_completed: + state.latents = torch.zeros(1, 1) + return output + + pipeline = _InteractiveChunkPipeline() + runner = _make_diffusion_model_runner(pipeline=pipeline) + runner._supports_step_mode = lambda: DiffusionModelRunner._supports_step_mode(runner) + runner.od_config.streaming_output = True + runner.od_config.step_execution = True + runner.diffusion_kv_backend = SimpleNamespace(remove_diffusion_kv_requests=lambda *_: 0) # pyright: ignore[reportAttributeAccessIssue] + + sampling = SimpleNamespace( + generator=None, + seed=None, + generator_device=None, + num_inference_steps=4, + num_outputs_per_prompt=1, + max_sequence_length=226, + fps=None, + ) + req = SimpleNamespace( + request_id="req", + prompt="a prompt", + sampling_params=sampling, + kv_sender_info=None, + ) + + @contextmanager + def _noop_forward_context(*args: Any, **kwargs: Any): + del args, kwargs + yield + + monkeypatch.setattr(model_runner_module, "set_forward_context", _noop_forward_context) + monkeypatch.setattr(model_runner_module.current_omni_platform, "reset_peak_memory_stats", lambda: None) + monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_reserved", lambda: 0) + monkeypatch.setattr(model_runner_module.current_omni_platform, "max_memory_allocated", lambda: 0) + monkeypatch.setattr(model_runner_module.current_omni_platform, "is_available", lambda: False) + + scheduler_output = SimpleNamespace( + finished_req_ids=set(), + scheduled_new_reqs=[SimpleNamespace(request_id="req", req=req, diffusion_kv_metadata=None)], + scheduled_cached_reqs=SimpleNamespace(request_ids=[]), + ) + # Admit request and run first denoise step (no chunk boundary yet). + DiffusionModelRunner.execute_stepwise(runner, scheduler_output) # pyright: ignore[reportArgumentType] + assert "req" in runner.state_cache + assert runner.state_cache["req"].sampling.fps is None + + runner.submit_interaction("req", _prompt_interaction("new scene", transition_chunks=2)) + + cached = SimpleNamespace( + finished_req_ids=set(), + scheduled_new_reqs=[], + scheduled_cached_reqs=SimpleNamespace(request_ids=["req"]), + ) + # Second step completes chunk 0: apply runs (no peek), metadata staged. + chunk0 = DiffusionModelRunner.execute_stepwise(runner, cached).get_request_output("req") # pyright: ignore[reportArgumentType] + assert chunk0 is not None and chunk0.result is not None + assert chunk0.result.started_event_ids == [] + state = runner.state_cache["req"] + assert torch.equal(state.prompt_embeds, torch.ones(1, 4, 2)) # pyright: ignore[reportArgumentType] + assert state.interaction_chunk_metadata is not None + assert state.interaction_chunk_metadata.started_event_ids == ["ui-update-1"] + pipeline.prepare_next_chunk.assert_called() # pyright: ignore[reportAttributeAccessIssue] + + # Next chunk decode attaches prior-boundary ACK ids onto DiffusionOutput. + DiffusionModelRunner.execute_stepwise(runner, cached) # pyright: ignore[reportArgumentType] + chunk1 = DiffusionModelRunner.execute_stepwise(runner, cached).get_request_output("req") # pyright: ignore[reportArgumentType] + assert chunk1 is not None and chunk1.result is not None + assert chunk1.result.started_event_ids == ["ui-update-1"] + assert chunk1.result.active_event_ids == ["ui-update-1"] + assert chunk1.finished is True class TestPromptUpdateIntegration: @@ -266,8 +506,8 @@ class TestPromptUpdateIntegration: @pytest.mark.asyncio async def test_runner_prompt_update_failure_surfaces_non_fatal_error(self, pipeline: HeliosPipeline) -> None: - """Runner-side prepare_prompt_update rejection is reported through the orchestrator.""" - pipeline.prepare_prompt_update = MagicMock( # pyright: ignore[reportAttributeAccessIssue, reportMethodAssignment] + """Runner-side prompt encode rejection is reported through the orchestrator.""" + pipeline.encode_prompt = MagicMock( # pyright: ignore[reportAttributeAccessIssue] side_effect=ValueError("prompt embeds are not ready") ) runner = _make_diffusion_model_runner(pipeline=pipeline) @@ -293,12 +533,7 @@ class TestPromptUpdateIntegration: finally: inline_client.shutdown() - pipeline.prepare_prompt_update.assert_called_once_with( # pyright: ignore[reportAttributeAccessIssue] - runner.state_cache["req-1"], - "new scene", - "ui-update-1", - 2, - ) + pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue] msg = output_queue.get_nowait() assert msg == ErrorMessage( error="Failed interaction for request req-1: prompt embeds are not ready", @@ -337,9 +572,10 @@ class TestPromptUpdateIntegration: outputs = await generate_task - pending = runner.state_cache[internal_request_id].extra["pending_prompt_update"] - assert pending["transition_chunks"] == 2 - assert torch.equal(pending["target_prompt_embeds"], torch.full((1, 4, 2), 2.0)) + pending = runner.state_cache[internal_request_id].interaction_sessions["prompt"].pending_event + assert pending is not None + assert pending.transition_chunks == 2 + assert torch.equal(pending.target_prompt_embeds, torch.full((1, 4, 2), 2.0)) pipeline.encode_prompt.assert_called_once() # pyright: ignore[reportAttributeAccessIssue] assert [output.custom_output["chunk"] for output in outputs] == [0, 1] finally: @@ -544,7 +780,10 @@ class TestPromptUpdateIntegration: ] deadline = time.monotonic() + 5.0 - while "pending_prompt_update" not in state.extra: + while ( + not isinstance(state.interaction_sessions.get("prompt"), PromptSession) + or state.interaction_sessions["prompt"].pending_event is None + ): if time.monotonic() >= deadline: raise TimeoutError("timed out waiting for prompt update during streaming") await asyncio.sleep(0.01) diff --git a/vllm_omni/diffusion/interaction/__init__.py b/vllm_omni/diffusion/interaction/__init__.py new file mode 100644 index 000000000..208f01a7c --- /dev/null +++ b/vllm_omni/diffusion/interaction/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project diff --git a/vllm_omni/diffusion/interaction/coordinator.py b/vllm_omni/diffusion/interaction/coordinator.py new file mode 100644 index 000000000..5b4986d88 --- /dev/null +++ b/vllm_omni/diffusion/interaction/coordinator.py @@ -0,0 +1,124 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Interaction coordinator to be co-owned by a pipeline and a runner.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from vllm_omni.diffusion.interaction.registry import STRUCTURED_HANDLER_REGISTRY +from vllm_omni.diffusion.interaction.types import ( + InteractionChunkMetadata, + InteractionPayload, + merge_interaction_metadata, +) +from vllm_omni.diffusion.models.interface import supports_interaction_apply +from vllm_omni.diffusion.worker.utils import StepRequestState + +if TYPE_CHECKING: + from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.interaction.modality_handlers.base import InteractionHandler + + +class InteractionCoordinator: + """Handles mid-way interaction inputs for one loaded pipeline. + + Request-local session state lives on ``StepRequestState.interaction_sessions``; + this object is request-agnostic. + """ + + def __init__( + self, + handlers: dict[str, InteractionHandler], + *, + model_class_name: str | None = None, + ) -> None: + self._handlers = handlers + self.model_class_name = model_class_name + + @classmethod + def build(cls, pipeline: object, od_config: OmniDiffusionConfig) -> InteractionCoordinator: + model_class_name = od_config.model_class_name + handlers: dict[str, InteractionHandler] = {} + + if supports_interaction_apply(pipeline): + for modality, handler_cls in STRUCTURED_HANDLER_REGISTRY.get(model_class_name or "", {}).items(): + handlers[modality] = handler_cls.from_pipeline(pipeline) + + return cls(handlers, model_class_name=model_class_name) + + def has_modality(self, modality: str) -> bool: + return modality in self._handlers + + @property + def needs_chunk_media(self) -> bool: + """Whether any registered handler needs chunk ``num_frames``/``fps``.""" + return any(handler.needs_chunk_media for handler in self._handlers.values()) + + def get_handler(self, modality: str) -> InteractionHandler: + handler = self._handlers.get(modality) + if handler is None: + raise ValueError( + f"interaction modality {modality!r} is not supported by pipeline {self.model_class_name!r}" + ) + return handler + + def enqueue( + self, + state: StepRequestState, + *, + modality: str, + event_id: str, + received_at: float, + payload: InteractionPayload, + transition_chunks: int | None, + ) -> None: + """Fan out enqueue to handlers in stable order (prompt first).""" + handler = self.get_handler(modality) + handler.enqueue( + state, + event_id=event_id, + received_at=received_at, + payload=payload, + transition_chunks=transition_chunks, + ) + + def apply_at_chunk_boundary( + self, + state: StepRequestState, + *, + boundary_at: float, + chunk_index: int | None = None, + num_frames: int | None = None, + fps: float | None = None, + ) -> InteractionChunkMetadata: + """Fan out chunk-boundary apply to handlers in stable order (prompt first).""" + if chunk_index is None: + chunk_index = state.chunk_index + metas: list[InteractionChunkMetadata] = [] + for handler in self._handlers_in_apply_order(): + if handler.modality not in state.interaction_sessions: + # This function is always called at chunk boundary without knowing if an interaction is really enqueued. + # `state.interaction_sessions[this modality]` is lazy-populated after the first interaction is enqueued. + # So, check here and skip a modality handler if this modality's session is not yet created. + continue + meta = handler.apply_at_chunk_boundary( + state, + boundary_at=boundary_at, + chunk_index=chunk_index, + num_frames=num_frames, + fps=fps, + ) + if meta is not None: + metas.append(meta) + return merge_interaction_metadata(metas) + + def _handlers_in_apply_order(self) -> list[InteractionHandler]: + ordered: list[InteractionHandler] = [] + if "prompt" in self._handlers: + ordered.append(self._handlers["prompt"]) + for modality, handler in self._handlers.items(): + if modality == "prompt": + continue + ordered.append(handler) + return ordered diff --git a/vllm_omni/diffusion/interaction/mixin.py b/vllm_omni/diffusion/interaction/mixin.py new file mode 100644 index 000000000..366e90588 --- /dev/null +++ b/vllm_omni/diffusion/interaction/mixin.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Interaction-handling mixin for diffusion pipelines. Process interactions at chunk boundaries.""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING, cast + +from vllm_omni.diffusion.models.interface import SupportsInteractionApply +from vllm_omni.diffusion.worker.utils import StepRequestState + +if TYPE_CHECKING: + from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator + + +class InteractionMixin: + """Unified chunk-boundary interaction hook. + + Pipelines inherit this mixin and implement ``peek_chunk_media`` to offer playback information about the next chunk. + Diffusion runner calls ``apply_interaction_at_chunk_boundary`` without knowing which modality is being applied. + + Per-modality session state lives on ``StepRequestState.interaction_sessions``; + handler strategy objects live on the pipeline/runner ``InteractionCoordinator``. + """ + + _interaction_coordinator: InteractionCoordinator | None = None + + def apply_interaction_at_chunk_boundary(self, state: StepRequestState) -> None: + """Advance all active interaction tracks before the next chunk.""" + if self._interaction_coordinator is None: + raise RuntimeError( + "interaction coordinator is not initialized; " + "DiffusionModelRunner.load_model must wire InteractionCoordinator " + "onto the pipeline before chunked generation" + ) + + num_frames: int | None = None + fps: float | None = None + if self._interaction_coordinator.needs_chunk_media: + media = cast(SupportsInteractionApply, self).peek_chunk_media(state) + num_frames = media.num_frames + fps = media.fps + + merged = self._interaction_coordinator.apply_at_chunk_boundary( + state, + boundary_at=time.monotonic(), + num_frames=num_frames, + fps=fps, + ) + state.interaction_chunk_metadata = merged + + def prepare_next_chunk(self, state: StepRequestState) -> None: + """Set up pipeline state for the next chunk after interaction apply. + + Default no-op. Pipelines may override this if needed. + """ + pass diff --git a/vllm_omni/diffusion/interaction/modality_handlers/base.py b/vllm_omni/diffusion/interaction/modality_handlers/base.py new file mode 100644 index 000000000..61f548fcd --- /dev/null +++ b/vllm_omni/diffusion/interaction/modality_handlers/base.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Base interaction handler interface.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import ClassVar, Self + +from vllm_omni.diffusion.interaction.types import ( + InteractionChunkMetadata, + InteractionPayload, +) +from vllm_omni.diffusion.worker.utils import StepRequestState + + +class InteractionHandler(ABC): + """Strategy object for one interaction modality. + + Handler instances are pipeline/runner-owned and request-agnostic. Per-request + session state lives on ``StepRequestState.interaction_sessions``. + Each concrete modality handler decides how to handle timing from ``received_at``. + """ + + modality: ClassVar[str] + # When True, chunk-boundary apply needs ``ChunkMediaSpec`` (num_frames/fps) + # Those information are useful when interaction handler needs interpolation/integration on a frame-by-frame basis + needs_chunk_media: ClassVar[bool] = False + + @classmethod + def from_pipeline(cls, pipeline: object) -> Self: + """Bind a request-agnostic handler to ``pipeline`` when construction needs it. + + Default construction ignores the pipeline. Prompt handlers override this to + capture ``encode_prompt`` / device / dtype. + """ + del pipeline + return cls() + + @abstractmethod + def enqueue( + self, + state: StepRequestState, + *, + event_id: str, + received_at: float, + payload: InteractionPayload, + transition_chunks: int | None, + ) -> None: + """Validate and queue this track on request-local state.""" + + @abstractmethod + def apply_at_chunk_boundary( + self, + state: StepRequestState, + *, + boundary_at: float, + chunk_index: int | None = None, # defaults to state.chunk_index when omitted + num_frames: int | None = None, # only set when at least one interaction handler needs it + fps: float | None = None, # only set when at least one interaction handler needs it + ) -> InteractionChunkMetadata | None: + """Advance request-local state and materialize this chunk's effects.""" diff --git a/vllm_omni/diffusion/interaction/modality_handlers/prompt.py b/vllm_omni/diffusion/interaction/modality_handlers/prompt.py new file mode 100644 index 000000000..e87d9029b --- /dev/null +++ b/vllm_omni/diffusion/interaction/modality_handlers/prompt.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Prompt-track interaction handler (midway prompt updates).""" + +from __future__ import annotations + +import logging +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any, ClassVar + +import torch +from typing_extensions import override + +from vllm_omni.diffusion.interaction.modality_handlers.base import InteractionHandler +from vllm_omni.diffusion.interaction.types import ( + InteractionChunkMetadata, + InteractionEvent, + InteractionMode, + InteractionPayload, + InteractionSession, +) +from vllm_omni.diffusion.worker.utils import StepRequestState + +DEFAULT_TRANSITION_CHUNKS = 3 + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class QueuedPromptEvent(InteractionEvent): + """Last-write-wins prompt update waiting for / active at a chunk boundary. + + ``source_prompt_embeds`` is the lerp start endpoint, staged when this event + is activated (not at enqueue). Live blended embeds live on + ``StepRequestState.prompt_embeds``. + """ + + mode: InteractionMode = "target" + prompt: str + target_prompt_embeds: torch.Tensor + source_prompt_embeds: torch.Tensor | None = None + + def blended_prompt_embeds(self) -> torch.Tensor: + assert self.source_prompt_embeds is not None + alpha = self._current_alpha() + if alpha >= 1.0: + return self.target_prompt_embeds + return (1.0 - alpha) * self.source_prompt_embeds + alpha * self.target_prompt_embeds + + def advance_transition(self) -> None: + assert self.source_prompt_embeds is not None + if self.transition_chunks <= 0: + self.source_prompt_embeds = self.target_prompt_embeds + return + self.elapsed_transition_chunks += 1 + if self.elapsed_transition_chunks >= self.transition_chunks: + self.source_prompt_embeds = self.target_prompt_embeds + self.elapsed_transition_chunks = float(self.transition_chunks) + + def _current_alpha(self) -> float: + if self.transition_chunks <= 0: + return 1.0 + return min(1.0, self.elapsed_transition_chunks / self.transition_chunks) + + +@dataclass +class PromptSession(InteractionSession): + """Request-local prompt-update session under ``state.interaction_sessions['prompt']``.""" + + pending_event: QueuedPromptEvent | None = None + active_event: QueuedPromptEvent | None = None + version: int = 0 + + +def prompt_update_versions(states: Sequence[StepRequestState]) -> tuple[int, ...]: + """Return per-request prompt-update versions for batch cache comparison.""" + versions: list[int] = [] + for state in states: + session = state.interaction_sessions.get("prompt") + versions.append(session.version if isinstance(session, PromptSession) else 0) + return tuple(versions) + + +class PromptInteractionHandler(InteractionHandler): + """Encode-and-queue prompt updates; lerp embeds at chunk boundaries. + + Mirrors the former ``PromptUpdateMixin`` behavior. Encoding happens at + enqueue time via an injected ``encode_prompt`` callable. + """ + + modality: ClassVar[str] = "prompt" + + def __init__( + self, + *, + encode_prompt: Callable[..., tuple[torch.Tensor, ...]], + device: torch.device | str, + dtype: torch.dtype, + ) -> None: + self._encode_prompt = encode_prompt + self._device = device + self._dtype = dtype + + @classmethod + @override + def from_pipeline(cls, pipeline: Any) -> PromptInteractionHandler: + """Build a handler from a diffusion pipeline that exposes ``encode_prompt``.""" + return cls( + encode_prompt=pipeline.encode_prompt, + device=pipeline.device, + dtype=pipeline.transformer.dtype, + ) + + @override + def enqueue( + self, + state: StepRequestState, + *, + event_id: str, + received_at: float, + payload: InteractionPayload, + transition_chunks: int | None, + ) -> None: + """Prompt updates are last-write-win and unbuffered at chunk boundary.""" + prompt = payload.get("prompt") + if not isinstance(prompt, str) or not prompt: + raise ValueError("prompt must be non-empty") + if not event_id: + raise ValueError("event_id must be non-empty") + if state.prompt_embeds is None: + raise ValueError( + f"prompt_update is not allowed before initial generation has started (request_id={state.request_id!r})" + ) + duration = DEFAULT_TRANSITION_CHUNKS if transition_chunks is None else transition_chunks + if duration < 0: + raise ValueError("transition_chunks must be >= 0") + + target_prompt_embeds, _ = self._encode_prompt( + prompt=prompt, + negative_prompt=None, + do_classifier_free_guidance=False, + num_videos_per_prompt=state.sampling.num_outputs_per_prompt, + max_sequence_length=state.sampling.max_sequence_length, + device=self._device, + dtype=self._dtype, + ) + session = state.interaction_sessions.setdefault("prompt", PromptSession()) + assert isinstance(session, PromptSession) + with session.lock: + # Chunk-level LWW: replace any prior pending event. + session.pending_event = QueuedPromptEvent( + event_id=event_id, + received_at=received_at, + transition_chunks=duration, + prompt=prompt, + target_prompt_embeds=target_prompt_embeds, + ) + + @override + def apply_at_chunk_boundary( + self, + state: StepRequestState, + *, + boundary_at: float, + chunk_index: int | None = None, + num_frames: int | None = None, + fps: float | None = None, + ) -> InteractionChunkMetadata | None: + """Advance or start prompt interpolation before the next chunk.""" + # Prompt lerp is chunk-LWW; media timeline and caller chunk_index unused. + del chunk_index, num_frames, fps + session = state.interaction_sessions.get("prompt") + assert isinstance(session, PromptSession) + + embeds_changed = False + next_chunk_index = state.chunk_index + started_event_ids: list[str] = [] + active_event_ids: list[str] = [] + completed_event_ids: list[str] = [] + + with session.lock: + # Prompt ignores frame scheduling but still records the boundary clock. + session.last_boundary_at = boundary_at + pending_event = session.pending_event + session.pending_event = None + active_event = session.active_event + + # If current transition is not complete, advance it. + # After completion, leave active_event in place (so a later pending + # update can abort/overwrite), but do not keep bumping the version. + if active_event is not None: + in_transition = ( + active_event.transition_chunks > 0 + and active_event.elapsed_transition_chunks < active_event.transition_chunks + ) + if in_transition: + active_event.advance_transition() + state.prompt_embeds = active_event.blended_prompt_embeds() + embeds_changed = True + active_event_ids.append(active_event.event_id) + if active_event.elapsed_transition_chunks >= active_event.transition_chunks: + state.prompt_embeds = active_event.target_prompt_embeds + active_event.source_prompt_embeds = active_event.target_prompt_embeds + completed_event_ids.append(active_event.event_id) + logger.debug( + "prompt_update transition complete request_id=%s next_chunk_index=%d prompt=%.20s...", + state.request_id, + next_chunk_index, + active_event.prompt, + ) + + # If a new prompt update is pending, start a new transition. + if pending_event is not None: + if state.prompt_embeds is None: + raise RuntimeError( + "internal error: trying to apply a pending prompt update but " + f"current prompt_embeds is None (request_id={state.request_id!r})" + ) + # Stage lerp-start embeds at activation (after any same-boundary advance). + pending_event.source_prompt_embeds = state.prompt_embeds.detach().clone() + pending_event.elapsed_transition_chunks = 0.0 + session.active_event = pending_event + active_event = pending_event + event_id = pending_event.event_id + duration = pending_event.transition_chunks + prompt = pending_event.prompt + target = pending_event.target_prompt_embeds + started_event_ids.append(event_id) + active_event_ids.append(event_id) + if duration <= 0: + # A hard/immediate transition + state.prompt_embeds = target + pending_event.source_prompt_embeds = target + completed_event_ids.append(event_id) + logger.debug( + "prompt_update sharp transition request_id=%s next_chunk_index=%d prompt=%.20s...", + state.request_id, + next_chunk_index, + prompt, + ) + else: + # A transition that really takes some time + active_event.advance_transition() + state.prompt_embeds = active_event.blended_prompt_embeds() + if active_event.elapsed_transition_chunks >= active_event.transition_chunks: + state.prompt_embeds = active_event.target_prompt_embeds + active_event.source_prompt_embeds = active_event.target_prompt_embeds + completed_event_ids.append(event_id) + logger.debug( + "prompt_update transition start request_id=%s next_chunk_index=%d prompt=%.20s...", + state.request_id, + next_chunk_index, + prompt, + ) + embeds_changed = True + + # Indicate that prompt embeddings have changed---clear input batch cache. + if embeds_changed: + session.version += 1 + + return InteractionChunkMetadata( + started_event_ids=started_event_ids, + active_event_ids=active_event_ids, + completed_event_ids=completed_event_ids, + ) diff --git a/vllm_omni/diffusion/interaction/registry.py b/vllm_omni/diffusion/interaction/registry.py new file mode 100644 index 000000000..f86dab902 --- /dev/null +++ b/vllm_omni/diffusion/interaction/registry.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Registry of mid-way interaction handlers during a diffusion generation. +A pipeline only declares the modalities that it supports. +And a pipeline can choose which handler for each modality (there can be more than one handler for a modality). + +Outer key: pipeline architecture name (``od_config.model_class_name``). +Inner key: modality name (e.g. ``\"prompt\"``). +""" + +from __future__ import annotations + +from vllm_omni.diffusion.interaction.modality_handlers.base import InteractionHandler +from vllm_omni.diffusion.interaction.modality_handlers.prompt import PromptInteractionHandler + +STRUCTURED_HANDLER_REGISTRY: dict[str, dict[str, type[InteractionHandler]]] = { + # Pipeline class name -> modality -> handler class. + "HeliosPipeline": { + "prompt": PromptInteractionHandler, + }, + "HeliosPyramidPipeline": { + "prompt": PromptInteractionHandler, + }, +} diff --git a/vllm_omni/diffusion/interaction/types.py b/vllm_omni/diffusion/interaction/types.py new file mode 100644 index 000000000..904552129 --- /dev/null +++ b/vllm_omni/diffusion/interaction/types.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Shared types for midway interaction handlers.""" + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, field +from threading import Lock +from typing import Literal + +# Wire payload for one modality track. Handlers validate keys they need. +InteractionPayload = Mapping[str, object] + +# Shared command semantics across modalities that support them. +# Prompt specialization uses ``"target"`` only. +InteractionMode = Literal["target"] + + +@dataclass(frozen=True) +class ChunkMediaSpec: + """Media extent of one generation chunk, for interaction timeline mapping.""" + + num_frames: int + fps: float + + def __post_init__(self) -> None: + if self.num_frames <= 0: + raise ValueError(f"ChunkMediaSpec.num_frames must be > 0, got {self.num_frames}") + if self.fps <= 0: + raise ValueError(f"ChunkMediaSpec.fps must be > 0, got {self.fps}") + + @property + def duration_s(self) -> float: + return float(self.num_frames) / float(self.fps) + + +@dataclass(kw_only=True) +class InteractionEvent: + """Shared envelope for one queued or in-flight interaction command. **Intended to be subclassed**. + + Example: + - ``QueuedPromptEvent`` in ``vllm_omni.diffusion.interaction.modality_handlers.prompt`` + """ + + event_id: str + received_at: float + mode: InteractionMode + transition_chunks: int + elapsed_transition_chunks: float = 0.0 + + +@dataclass +class InteractionSession: + """Request-local session base for one interaction modality. **Intended to be subclassed**. + + Concrete modalities subclass this and store their own pending/active state. + ``last_boundary_at`` is available for frame-scheduled modalities; chunk-LWW + modalities may ignore it. + + Example: + - ``PromptSession`` in ``vllm_omni.diffusion.interaction.modality_handlers.prompt`` + """ + + lock: Lock = field(default_factory=Lock, repr=False) + last_boundary_at: float | None = None + + +@dataclass +class InteractionChunkMetadata: + """Per-modality (or merged) event-id bookkeeping for one chunk boundary.""" + + started_event_ids: list[str] = field(default_factory=list) + active_event_ids: list[str] = field(default_factory=list) + completed_event_ids: list[str] = field(default_factory=list) + + def as_dict(self) -> dict[str, list[str]]: + return { + "started_event_ids": list(self.started_event_ids), + "active_event_ids": list(self.active_event_ids), + "completed_event_ids": list(self.completed_event_ids), + } + + +def merge_interaction_metadata( + metas: list[InteractionChunkMetadata], +) -> InteractionChunkMetadata: + """Merge per-handler metadata while preserving order and de-duplicating ids.""" + started: list[str] = [] + active: list[str] = [] + completed: list[str] = [] + seen_started: set[str] = set() + seen_active: set[str] = set() + seen_completed: set[str] = set() + for meta in metas: + for event_id in meta.started_event_ids: + if event_id not in seen_started: + seen_started.add(event_id) + started.append(event_id) + for event_id in meta.active_event_ids: + if event_id not in seen_active: + seen_active.add(event_id) + active.append(event_id) + for event_id in meta.completed_event_ids: + if event_id not in seen_completed: + seen_completed.add(event_id) + completed.append(event_id) + return InteractionChunkMetadata( + started_event_ids=started, + active_event_ids=active, + completed_event_ids=completed, + ) diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py index cb11eb847..45c6d0693 100644 --- a/vllm_omni/diffusion/models/helios/pipeline_helios.py +++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py @@ -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 # Adapted from Helios (https://github.com/BestWishYsh/Helios) from __future__ import annotations @@ -18,18 +18,20 @@ from diffusers import AutoencoderKLWan from diffusers.utils.torch_utils import randn_tensor from torch import nn from transformers import AutoConfig, AutoTokenizer, UMT5EncoderModel +from typing_extensions import override from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.cfg_parallel import CFGParallelMixin from vllm_omni.diffusion.distributed.utils import get_local_device +from vllm_omni.diffusion.interaction.mixin import InteractionMixin +from vllm_omni.diffusion.interaction.types import ChunkMediaSpec from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.models.helios.helios_transformer import HeliosTransformer3DModel from vllm_omni.diffusion.models.helios.scheduling_helios import HeliosScheduler from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery from vllm_omni.diffusion.models.progress_bar import ProgressBarMixin from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin -from vllm_omni.diffusion.prompt_update import PromptUpdateMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch from vllm_omni.platforms import current_omni_platform @@ -160,7 +162,7 @@ class HeliosPipeline( CFGParallelMixin, ProgressBarMixin, DiffusionPipelineProfilerMixin, - PromptUpdateMixin, + InteractionMixin, SupportsComponentDiscovery, ): """Helios text-to-video / image-to-video / video-to-video pipeline for vllm-omni. @@ -533,10 +535,22 @@ class HeliosPipeline( "zero_steps": int(extra.get("zero_steps", 1)), } ) - self._prepare_next_chunk(state) + self.prepare_next_chunk(state) return state - def _prepare_next_chunk(self, state: StepRequestState) -> None: + def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec: + """Expose this chunk's decoded media extent for interaction timelines.""" + num_frames = int(state.extra["window_num_frames"]) + fps = state.sampling.fps + if fps is None or float(fps) <= 0: + raise ValueError( + "sampling.fps is required and must be > 0 for interaction modalities that use the " + f"chunk media timeline, got {fps!r} (request_id={state.request_id!r})" + ) + return ChunkMediaSpec(num_frames=num_frames, fps=float(fps)) + + @override + def prepare_next_chunk(self, state: StepRequestState) -> None: extra = state.extra k = state.chunk_index is_first_chunk = k == 0 @@ -900,14 +914,9 @@ class HeliosPipeline( output = current_latents if extra["output_type"] == "latent" else current_video completed_chunk_index = state.chunk_index - prompt_update_metadata = state.extra.pop("prompt_update_chunk_metadata", {}) state.chunk_index += 1 finished = state.request_denoise_completed - if not finished: - # Apply queued/advancing prompt updates before preparing the next chunk. - self._apply_prompt_update_at_chunk_boundary(state) - self._prepare_next_chunk(state) - else: + if finished: self._current_timestep = None if current_omni_platform.is_available(): current_omni_platform.empty_cache() @@ -917,9 +926,6 @@ class HeliosPipeline( stage_durations=self.stage_durations if hasattr(self, "stage_durations") else {}, chunk_index=completed_chunk_index, total_chunks=state.total_chunks, - started_event_ids=prompt_update_metadata.get("started_event_ids", []), - active_event_ids=prompt_update_metadata.get("active_event_ids", []), - completed_event_ids=prompt_update_metadata.get("completed_event_ids", []), finished=finished, ) diff --git a/vllm_omni/diffusion/models/interface.py b/vllm_omni/diffusion/models/interface.py index 7a30fa77c..75cc5a0d2 100644 --- a/vllm_omni/diffusion/models/interface.py +++ b/vllm_omni/diffusion/models/interface.py @@ -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 from dataclasses import dataclass @@ -19,6 +19,7 @@ if TYPE_CHECKING: from vllm_omni.diffusion.cache.cachedit import CacheDiTBackend from vllm_omni.diffusion.data import DiffusionOutput + from vllm_omni.diffusion.interaction.types import ChunkMediaSpec from vllm_omni.diffusion.worker.input_batch import InputBatch from vllm_omni.diffusion.worker.utils import StepRequestState @@ -108,30 +109,34 @@ def supports_step_execution(pipeline: object) -> bool: @runtime_checkable -class SupportsPromptUpdate(Protocol): - """Optional protocol for pipelines that support midway prompt updates. +class SupportsInteractionApply(Protocol): + """Optional protocol for pipelines with unified mid-generation, chunk-boundary hooks.""" - Pipelines typically implement this via - :class:`~vllm_omni.diffusion.prompt_update.PromptUpdateMixin`. - """ + def peek_chunk_media(self, state: StepRequestState) -> ChunkMediaSpec: + """Return the media timeline represented by the upcoming/current chunk. - supports_prompt_update: ClassVar[bool] = True + Useful when interaction handler needs interpolation/integration on a frame-by-frame basis, + or for backpressure/pacing. + """ + ... - def prepare_prompt_update( - self, - state: StepRequestState, - prompt: str, - event_id: str, - transition_chunks: int | None = None, - ) -> None: - """Encode and queue a prompt update on request-local state.""" + def apply_interaction_at_chunk_boundary(self, state: StepRequestState) -> None: + """Advance queued interactions before the next generation chunk.""" + ... + + def prepare_next_chunk(self, state: StepRequestState) -> None: + """Set up pipeline state for the next chunk after interaction apply. + + Default implementations is a no-op; model-specific pipelines override + when chunk transitions require latent/history bookkeeping. + """ ... -def supports_prompt_update(pipeline: object) -> bool: - """Return whether ``pipeline`` implements :class:`SupportsPromptUpdate`.""" +def supports_interaction_apply(pipeline: object) -> bool: + """Return whether ``pipeline`` implements :class:`SupportsInteractionApply`.""" - return isinstance(pipeline, SupportsPromptUpdate) + return isinstance(pipeline, SupportsInteractionApply) @runtime_checkable diff --git a/vllm_omni/diffusion/prompt_update.py b/vllm_omni/diffusion/prompt_update.py deleted file mode 100644 index 400838035..000000000 --- a/vllm_omni/diffusion/prompt_update.py +++ /dev/null @@ -1,245 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Midway prompt updates for chunked diffusion step execution. - -``PromptUpdateMixin`` exposes -- a pipeline interface for the external to queue a prompt update, -- a pipeline internal method to apply prompt transitions at chunk boundaries. - -``prompt_update_versions``: a helper for ``InputBatch`` cache invalidation. -""" - -from __future__ import annotations - -import logging -from collections.abc import Sequence -from dataclasses import dataclass -from threading import Lock -from typing import ClassVar, TypedDict, cast - -import torch - -from vllm_omni.diffusion.worker.utils import StepRequestState - -DEFAULT_TRANSITION_CHUNKS = 3 - -logger = logging.getLogger(__name__) - - -class PromptUpdateExtra(TypedDict, total=False): - """A "protocol" for ``StepRequestState.extra`` keys used by the prompt-update feature.""" - - pending_prompt_update: _PendingPromptUpdate - prompt_update_state: _PromptUpdateState - prompt_update_chunk_metadata: _PromptUpdateChunkMetadata - prompt_update_version: int # Bumped when prompt_embeds change; invalidates InputBatch cache. - - # The lock for storing/updating pending prompt updates. - # Saved in state.extra to be naturally cleaned up with the request state. - # As PromptUpdateMixin (the pipeline) currently lacks an obvious request-finish hook - prompt_update_lock: Lock - - -def prompt_update_versions(states: Sequence[StepRequestState]) -> tuple[int, ...]: - """Return per-request prompt-update versions for batch cache comparison. - - To be used by ``InputBatch`` or other external to determine if the cached batch is still valid. - """ - return tuple(int(cast(PromptUpdateExtra, state.extra).get("prompt_update_version", 0)) for state in states) - - -class PromptUpdateMixin: - """Mixin for chunked streaming pipelines that support midway prompt updates. - - Implements the behavior expected by :class:`~vllm_omni.diffusion.models.interface.SupportsPromptUpdate`. - All per-request transition state lives on ``StepRequestState.extra``; this mixin itself is stateless. - """ - - supports_prompt_update: ClassVar[bool] = True - - @staticmethod - def _prompt_update_lock(extra: PromptUpdateExtra) -> Lock: - return extra.setdefault("prompt_update_lock", Lock()) - - def prepare_prompt_update( - self, - state: StepRequestState, - prompt: str, - event_id: str, - transition_chunks: int | None = None, - ) -> None: - """Encode and queue a prompt update to apply at the next chunk boundary. - It does not actually apply the prompt transition. - - This is an extended interface for the pipeline, to be called by the runner. - """ - if not prompt: - raise ValueError("prompt must be non-empty") - if not event_id: - raise ValueError("event_id must be non-empty") - if state.prompt_embeds is None: - raise ValueError( - f"prompt_update is not allowed before initial generation has started (request_id={state.request_id!r})" - ) - duration = DEFAULT_TRANSITION_CHUNKS if transition_chunks is None else transition_chunks - if duration < 0: - raise ValueError("transition_chunks must be >= 0") - - target_prompt_embeds, _ = self.encode_prompt( - prompt=prompt, - negative_prompt=None, - do_classifier_free_guidance=False, - num_videos_per_prompt=state.sampling.num_outputs_per_prompt, - max_sequence_length=state.sampling.max_sequence_length, - device=self.device, - dtype=self.transformer.dtype, - ) - extra = cast(PromptUpdateExtra, state.extra) - with self._prompt_update_lock(extra): - extra["pending_prompt_update"] = { - "event_id": event_id, - "prompt": prompt, - "target_prompt_embeds": target_prompt_embeds, - "transition_chunks": duration, - } - - def _apply_prompt_update_at_chunk_boundary(self, state: StepRequestState) -> None: - """Advance or start prompt interpolation before the next chunk.""" - extra = cast(PromptUpdateExtra, state.extra) - update_state: _PromptUpdateState | None = extra.get("prompt_update_state") - with self._prompt_update_lock(extra): - pending = extra.pop("pending_prompt_update", None) - embeds_changed = False - next_chunk_index = state.chunk_index - started_event_ids: list[str] = [] - active_event_ids: list[str] = [] - completed_event_ids: list[str] = [] - - # If current transition is not complete, advance it. - # After completion, leave prompt_update_state in place (so a later pending - # update can abort/overwrite), but do not keep bumping the version. - if update_state is not None: - in_transition = ( - update_state.transition_chunks > 0 - and update_state.elapsed_transition_chunks < update_state.transition_chunks - ) - if in_transition: - update_state.advance_transition() - state.prompt_embeds = update_state.blended_prompt_embeds() - embeds_changed = True - active_event_ids.append(update_state.event_id) - if update_state.elapsed_transition_chunks >= update_state.transition_chunks: - state.prompt_embeds = update_state.target_prompt_embeds - update_state.source_prompt_embeds = update_state.target_prompt_embeds - completed_event_ids.append(update_state.event_id) - if update_state.target_prompt is not None: - logger.debug( - "prompt_update transition complete request_id=%s next_chunk_index=%d prompt=%.20s...", - state.request_id, - next_chunk_index, - update_state.target_prompt, - ) - - # If a new prompt update is pending, start a new transition. - if pending is not None: - if state.prompt_embeds is None: - raise RuntimeError( - "internal error: trying to apply a pending prompt update but " - f"current prompt_embeds is None (request_id={state.request_id!r})" - ) - source = state.prompt_embeds.detach().clone() - target = pending["target_prompt_embeds"] - duration = int(pending["transition_chunks"]) - prompt = str(pending.get("prompt", "")) - event_id = pending.get("event_id") - update_state = _PromptUpdateState( - source_prompt_embeds=source, - target_prompt_embeds=target, - transition_chunks=duration, - event_id=event_id, - target_prompt=prompt, - ) - extra["prompt_update_state"] = update_state - started_event_ids.append(event_id) - active_event_ids.append(event_id) - if duration <= 0: - # A hard/immediate transition - state.prompt_embeds = target - completed_event_ids.append(event_id) - logger.debug( - "prompt_update sharp transition request_id=%s next_chunk_index=%d prompt=%.20s...", - state.request_id, - next_chunk_index, - prompt, - ) - else: - # A transition that really takes some time - update_state.advance_transition() - state.prompt_embeds = update_state.blended_prompt_embeds() - if update_state.elapsed_transition_chunks >= update_state.transition_chunks: - state.prompt_embeds = update_state.target_prompt_embeds - update_state.source_prompt_embeds = update_state.target_prompt_embeds - completed_event_ids.append(event_id) - logger.debug( - "prompt_update transition start request_id=%s next_chunk_index=%d prompt=%.20s...", - state.request_id, - next_chunk_index, - prompt, - ) - embeds_changed = True - - # Indicate that prompt embeddings have changed---clear input batch cache. - if embeds_changed: - new_version = int(extra.get("prompt_update_version", 0)) + 1 - extra["prompt_update_version"] = new_version - - extra["prompt_update_chunk_metadata"] = { - "started_event_ids": started_event_ids, - "active_event_ids": active_event_ids, - "completed_event_ids": completed_event_ids, - } - - -@dataclass -class _PromptUpdateState: - """Per-request prompt interpolation stored in ``state.extra["prompt_update_state"]``.""" - - source_prompt_embeds: torch.Tensor - target_prompt_embeds: torch.Tensor - transition_chunks: int - event_id: str - elapsed_transition_chunks: int = 0 - target_prompt: str | None = None - - def blended_prompt_embeds(self) -> torch.Tensor: - alpha = self._current_alpha() - if alpha >= 1.0: - return self.target_prompt_embeds - return (1.0 - alpha) * self.source_prompt_embeds + alpha * self.target_prompt_embeds - - def advance_transition(self) -> None: - if self.transition_chunks <= 0: - self.source_prompt_embeds = self.target_prompt_embeds - return - self.elapsed_transition_chunks += 1 - if self.elapsed_transition_chunks >= self.transition_chunks: - self.source_prompt_embeds = self.target_prompt_embeds - self.elapsed_transition_chunks = self.transition_chunks - - def _current_alpha(self) -> float: - if self.transition_chunks <= 0: - return 1.0 - return min(1.0, self.elapsed_transition_chunks / self.transition_chunks) - - -class _PendingPromptUpdate(TypedDict): - event_id: str - prompt: str - target_prompt_embeds: torch.Tensor - transition_chunks: int - - -class _PromptUpdateChunkMetadata(TypedDict): - started_event_ids: list[str] - active_event_ids: list[str] - completed_event_ids: list[str] diff --git a/vllm_omni/diffusion/worker/diffusion_model_runner.py b/vllm_omni/diffusion/worker/diffusion_model_runner.py index 8a352fb7d..8804785fa 100644 --- a/vllm_omni/diffusion/worker/diffusion_model_runner.py +++ b/vllm_omni/diffusion/worker/diffusion_model_runner.py @@ -42,12 +42,13 @@ from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import ( ) from vllm_omni.diffusion.distributed.parallel_state import get_classifier_free_guidance_rank from vllm_omni.diffusion.forward_context import set_forward_context +from vllm_omni.diffusion.interaction.coordinator import InteractionCoordinator from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.models.interface import ( - SupportsPromptUpdate, + SupportsInteractionApply, adopt_request_scoped_cache_dit, is_request_scoped_cache_dit_enabled, - supports_prompt_update, + supports_interaction_apply, supports_step_execution, ) from vllm_omni.diffusion.offloader import enable_offload_backend @@ -176,6 +177,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): self.offload_backend: Any | None = None self.prompt_embed_cache: Any | None = None self.input_batch: InputBatch | None = None + self._interaction_coordinator: InteractionCoordinator | None = None self.model_memory_usage = 0 self.diffusion_kv_backend = DiffusionKVModelRunnerBackend( vllm_config=vllm_config, @@ -427,6 +429,10 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): model_tag=self.od_config.model_class_name, ) + self._interaction_coordinator = InteractionCoordinator.build(self.pipeline, self.od_config) + if hasattr(self.pipeline, "_interaction_coordinator"): + self.pipeline._interaction_coordinator = self._interaction_coordinator + logger.info("Model runner: Initialization complete.") def get_kv_cache_spec(self) -> dict[str, KVCacheSpec]: @@ -789,7 +795,7 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): return self._runner_output_from_outputs(reqs, outputs) - def _attach_stepwise_metrics( + def _attach_stepwise_metadata( self, state: StepRequestState, output: DiffusionOutput, @@ -800,6 +806,14 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): ) attach_stage_durations(state, output) + # In streaming output mode with interaction, acknowledge which events are handled in this chunk. + meta = state.interaction_chunk_metadata + state.interaction_chunk_metadata = None + if meta is not None: + output.started_event_ids = list(meta.started_event_ids) + output.active_event_ids = list(meta.active_event_ids) + output.completed_event_ids = list(meta.completed_event_ids) + def execute_model( self, req: OmniDiffusionRequest, @@ -1275,10 +1289,16 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): clear_pipeline_stage_durations(self.pipeline) result = self.pipeline.post_decode(req) if result is not None: - self._attach_stepwise_metrics( + self._attach_stepwise_metadata( req, result, ) + # After consuming this chunk's interaction metadata, apply pending interactions and + # prepare the next chunk (prepare_next_chunk may be a no-op---depending on pipeline) + if supports_interaction_apply(self.pipeline) and not req.request_denoise_completed: + pipe = cast(SupportsInteractionApply, self.pipeline) + pipe.apply_interaction_at_chunk_boundary(req) + pipe.prepare_next_chunk(req) else: result = None # finished should be computed after post_decode() advanced chunk_index @@ -1354,42 +1374,48 @@ class DiffusionModelRunner(OmniConnectorModelRunnerMixin): request_id: str, interaction: OmniInteractionPrompt, ) -> None: - """Route a midway interaction to the matching active stepwise request feature.""" + """Route a midway interaction through the pipeline interaction coordinator.""" assert self.pipeline is not None, "Model not loaded. Call load_model() first." if not self.od_config.streaming_output: raise ValueError("submit_interaction requires streaming_output=True") if not self._supports_step_mode(): raise ValueError("submit_interaction requires step execution support") + coordinator = self._interaction_coordinator + if coordinator is None: + coordinator = InteractionCoordinator.build(self.pipeline, self.od_config) + self._interaction_coordinator = coordinator + if hasattr(self.pipeline, "_interaction_coordinator"): + self.pipeline._interaction_coordinator = coordinator + event = interaction.get("event") - if isinstance(event, dict) and "prompt" in event and "multi_modal_data" not in event: - # Is a prompt update interaction. - self._submit_prompt_update_interaction(request_id, interaction) - return + has_mm = isinstance(event, dict) and "multi_modal_data" in event + has_prompt = isinstance(event, dict) and "prompt" in event and event.get("prompt") is not None - raise NotImplementedError( - "Only text-only prompt update interactions with 'event.prompt' and optional " - "'transition_chunks' are supported in this release" - ) - - def _submit_prompt_update_interaction( - self, - request_id: str, - interaction: OmniInteractionPrompt, - ) -> None: - """Queue a prompt-update interaction for an active stepwise request.""" - if not supports_prompt_update(self.pipeline): + # Prompt-only interactions in this release; multi_modal_data lands with camera support. + if not isinstance(event, dict) or has_mm or not has_prompt: + raise NotImplementedError( + "Only text-only prompt update interactions with 'event.prompt' and optional " + "'transition_chunks' are supported in this release" + ) + if not coordinator.has_modality("prompt"): raise ValueError(f"prompt_update is not supported by pipeline {self.od_config.model_class_name!r}") state = self.state_cache.get(request_id) if state is None: - raise ValueError(f"No active request state for prompt_update: {request_id!r}") + raise ValueError(f"No active request state for interaction: {request_id!r}") - event = cast(dict[str, Any], interaction.get("event")) - prompt = event["prompt"] - transition_chunks = interaction.get("transition_chunks") event_id = interaction.get("event_id") if not isinstance(event_id, str) or not event_id: raise ValueError("event_id must be non-empty") - pipeline = cast(SupportsPromptUpdate, self.pipeline) - pipeline.prepare_prompt_update(state, prompt, event_id, transition_chunks) + prompt = event["prompt"] + if not isinstance(prompt, str) or not prompt: + raise ValueError("prompt must be non-empty") + coordinator.enqueue( + state, + modality="prompt", + event_id=event_id, + received_at=time.monotonic(), + payload={"prompt": prompt}, + transition_chunks=interaction.get("transition_chunks"), + ) diff --git a/vllm_omni/diffusion/worker/input_batch.py b/vllm_omni/diffusion/worker/input_batch.py index d21e73287..0e9a2bde1 100644 --- a/vllm_omni/diffusion/worker/input_batch.py +++ b/vllm_omni/diffusion/worker/input_batch.py @@ -17,7 +17,7 @@ from dataclasses import dataclass, field import numpy as np import torch -from vllm_omni.diffusion.prompt_update import prompt_update_versions +from vllm_omni.diffusion.interaction.modality_handlers.prompt import prompt_update_versions from vllm_omni.diffusion.worker.utils import StepRequestState diff --git a/vllm_omni/diffusion/worker/utils.py b/vllm_omni/diffusion/worker/utils.py index 62def6a0c..5158dbfbf 100644 --- a/vllm_omni/diffusion/worker/utils.py +++ b/vllm_omni/diffusion/worker/utils.py @@ -12,6 +12,10 @@ import torch if TYPE_CHECKING: from vllm_omni.diffusion.data import DiffusionOutput + from vllm_omni.diffusion.interaction.types import ( + InteractionChunkMetadata, + InteractionSession, + ) from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniPromptType @@ -100,6 +104,10 @@ class StepRequestState: total_chunks: int = 1 chunk_num_steps: int | None = None + # ── Optional interaction information in streaming output mode ── + interaction_sessions: dict[str, InteractionSession] = field(default_factory=dict) # Modality -> transition progress + interaction_chunk_metadata: InteractionChunkMetadata | None = None # Acknowledging completion of each interaction + # ── Per-request scheduler instance (set once by prepare_encode) ── scheduler: Any | None = None diff --git a/vllm_omni/inputs/data.py b/vllm_omni/inputs/data.py index 3ef934bf5..f9f0a9a9a 100644 --- a/vllm_omni/inputs/data.py +++ b/vllm_omni/inputs/data.py @@ -46,11 +46,14 @@ class OmniInteractionEvent(OmniTextPrompt, total=False): pass -class OmniInteractionPrompt(TypedDict, total=False): - """Mid-stream interaction payload.""" +class OmniInteractionPrompt(TypedDict): + """Normalized mid-stream interaction payload passed to ``submit_interaction``. - event_id: NotRequired[str] - event: NotRequired[OmniInteractionEvent] + WebSocket clients payload may omit ``event_id``; the API layer assigns a fallback ID in this case. + """ + + event_id: str + event: OmniInteractionEvent transition_chunks: NotRequired[int] From 38abc0ba2813333d1aacf37d6296dfb6d8b1452a Mon Sep 17 00:00:00 2001 From: linzhenpl07 <60651557+linzhenpl07@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:40:20 +0800 Subject: [PATCH 09/90] [AR-Diffusion] Allocate K and V separately so the compiled path stops cloning the pool (#6463) Signed-off-by: linzhenpl07 Co-authored-by: Claude Sonnet 5 --- .buildkite/cuda/test-ready.yml | 2 +- tests/diffusion/ar_diffusion/test_kv_cache.py | 152 +++++++++++++- .../ar_diffusion/test_kv_cache_cuda.py | 193 ++++++++++++++++++ .../ar_diffusion/kv_cache/manager.py | 4 +- .../ar_diffusion/kv_cache/paged.py | 33 ++- 5 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 tests/diffusion/ar_diffusion/test_kv_cache_cuda.py diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml index 1ea89ae97..d7e14ed64 100644 --- a/.buildkite/cuda/test-ready.yml +++ b/.buildkite/cuda/test-ready.yml @@ -76,7 +76,7 @@ steps: - label: "Diffusion · Other Test" commands: - - timeout 15m pytest -sv tests/diffusion/*.py -m 'core_model and cuda' --run-level "core_model" + - timeout 15m pytest -sv tests/diffusion/*.py tests/diffusion/ar_diffusion -m 'core_model and cuda' --run-level "core_model" mirror_hardwares: l4_1 - group: ":card_index_dividers: Tiny Model Tests [Single GPU]" diff --git a/tests/diffusion/ar_diffusion/test_kv_cache.py b/tests/diffusion/ar_diffusion/test_kv_cache.py index 086666063..4d590e716 100644 --- a/tests/diffusion/ar_diffusion/test_kv_cache.py +++ b/tests/diffusion/ar_diffusion/test_kv_cache.py @@ -25,12 +25,9 @@ from vllm_omni.experimental.ar_diffusion.kv_cache import ( ) from vllm_omni.experimental.ar_diffusion.kv_cache.paged import chunk_window_skipped_tokens -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] - - BLOCK = 16 -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] def make_spec(*, chunk_size=BLOCK, window_chunks=2, sink_chunks=0, reset_at_boundary=False): @@ -155,14 +152,155 @@ def test_paged_pool_layout_exposes_flat_slot_views(): dtype=torch.float32, device=torch.device("cpu"), ) - assert kv_pools[0].shape == (2, 4, BLOCK, 4, 64) + key_cache, value_cache = kv_pools[0] + assert key_cache.shape == value_cache.shape == (4, BLOCK, 4, 64) assert k_pools[0].shape == (4 * BLOCK, 4, 64) assert v_pools[0].shape == (4 * BLOCK, 4, 64) + # The flat views must alias their block-shaped cache, so a slot write is + # visible through the layout the attention kernel reads. k_pools[0][BLOCK + 3].fill_(7) v_pools[0][2 * BLOCK + 5].fill_(11) - assert torch.equal(kv_pools[0][0, 1, 3], k_pools[0][BLOCK + 3]) - assert torch.equal(kv_pools[0][1, 2, 5], v_pools[0][2 * BLOCK + 5]) + assert torch.equal(key_cache[1, 3], k_pools[0][BLOCK + 3]) + assert torch.equal(value_cache[2, 5], v_pools[0][2 * BLOCK + 5]) + + +def test_key_and_value_caches_do_not_share_storage(): + """K and V must be separate allocations, not halves of one tensor. + + The paged-write custom op declares both as mutated. When they alias one + storage, inductor's reinplace pass can re-inplace only the first of the + two, and auto_functionalized_v2's clone of the entire second pool survives + into the compiled graph -- one full pool copied per compiled region per + denoising step. + """ + kv_pools, k_pools, v_pools = allocate_kv_pool_with_views( + num_blocks=4, + block_size=BLOCK, + num_layers=2, + num_kv_heads=4, + head_dim=64, + dtype=torch.float32, + device=torch.device("cpu"), + ) + seen = set() + for layer, (key_cache, value_cache) in enumerate(kv_pools): + k_storage = key_cache.untyped_storage().data_ptr() + v_storage = value_cache.untyped_storage().data_ptr() + assert k_storage != v_storage, f"layer {layer}: K and V share one allocation" + assert k_pools[layer].untyped_storage().data_ptr() == k_storage + assert v_pools[layer].untyped_storage().data_ptr() == v_storage + seen.update((k_storage, v_storage)) + # Every layer's K and V are distinct allocations as well. + assert len(seen) == 2 * len(kv_pools) + + # Negative control: writing V must not disturb K. + k_pools[0].fill_(0) + v_pools[0].fill_(5) + assert torch.equal(k_pools[0], torch.zeros_like(k_pools[0])) + + +def _pool_sized_allocations(code: str, pool_numel: int) -> list[int]: + """Sizes of generated allocations at least as large as one pool.""" + import re + + sizes = [] + for shape in re.findall(r"empty_strided_\w+\(\(([\d, ]*?)\)", code): + dims = [int(part) for part in shape.replace(" ", "").strip(",").split(",") if part] + numel = 1 + for dim_size in dims: + numel *= dim_size + if dims and numel >= pool_numel: + sizes.append(numel) + return sizes + + +def _generated_code_for_pools(key_cache, value_cache): + """Compile a paged-write-shaped op over these pools, return the generated code.""" + from torch._inductor import config as inductor_config + from torch._inductor.utils import run_and_get_code + + class _Holder(torch.nn.Module): + def __init__(self): + super().__init__() + self.register_buffer("key_cache", key_cache) + self.register_buffer("value_cache", value_cache) + + def forward(self, value): + return torch.ops.test_kv_pool_clone.write(self.key_cache, self.value_cache, value) + + torch._dynamo.reset() + # Inductor caches compiled code across ``torch._dynamo.reset()``, so the + # second call in a process would otherwise return the first one's code and + # both branches of this test would describe the same graph. + with inductor_config.patch(force_disable_caches=True): + _, codes = run_and_get_code(torch.compile(_Holder(), backend="inductor", fullgraph=True), torch.ones(1)) + return "\n".join(codes) + + +def test_compiled_paged_write_does_not_clone_the_pool(): + """The compiled graph must write the pools in place, not through a clone. + + ``test_key_and_value_caches_do_not_share_storage`` pins the mechanism this + change introduces -- two allocations rather than one. This pins the effect + that mechanism buys, which is the reason the change exists: with K and V as + halves of one tensor, inductor's reinplace pass re-inplaces only the first, + and ``auto_functionalized_v2``'s clone of the pool survives into the + generated code as a pool-sized allocation plus a copy, once per compiled + region per denoising step. + + The shared layout is compiled here as a positive control. Without it, a + codegen change that stops emitting ``empty_strided_*`` would leave the + detector matching nothing and this test passing for the wrong reason. + """ + if "write" not in dir(getattr(torch.ops, "test_kv_pool_clone", object())): + + @torch.library.custom_op("test_kv_pool_clone::write", mutates_args=("key_pool", "value_pool")) + def _write(key_pool: torch.Tensor, value_pool: torch.Tensor, value: torch.Tensor) -> torch.Tensor: + key_pool[0, 0, 0, 0] = value[0] + value_pool[0, 0, 0, 0] = value[0] + return value * 2 + + @_write.register_fake + def _(key_pool, value_pool, value): + return torch.empty_like(value) + + # The stand-in models the real op's contract; that declaration is what + # drives the reinplace pass, so a drift here would silently test nothing. + real_schema = torch.ops.vllm_omni.ar_diffusion_paged_write_attn.default._schema + real_mutated = [arg.name for arg in real_schema.arguments if arg.alias_info and arg.alias_info.is_write] + assert real_mutated == ["key_pool", "value_pool"], ( + f"the real paged-write op now mutates {real_mutated}; this test still models two pools" + ) + + num_blocks, heads, dim = 8, 4, 16 + cache_shape = (num_blocks, BLOCK, heads, dim) + pool_numel = num_blocks * BLOCK * heads * dim + + # Positive control: one allocation, K and V aliasing it. + shared = torch.empty(2, *cache_shape) + shared_allocations = _pool_sized_allocations(_generated_code_for_pools(shared[0], shared[1]), pool_numel) + assert shared_allocations, ( + "the shared layout produced no pool-sized allocation, so this test can no " + "longer tell the two layouts apart -- inductor's codegen or the detector changed" + ) + + # The allocator under test. + kv_pools, _, _ = allocate_kv_pool_with_views( + num_blocks=num_blocks, + block_size=BLOCK, + num_layers=1, + num_kv_heads=heads, + head_dim=dim, + dtype=torch.float32, + device=torch.device("cpu"), + ) + key_cache, value_cache = kv_pools[0] + separate_allocations = _pool_sized_allocations(_generated_code_for_pools(key_cache, value_cache), pool_numel) + assert not separate_allocations, ( + "the compiled graph allocates a pool-sized buffer, so the pool is being cloned " + f"rather than written in place: {separate_allocations} (control saw {shared_allocations})" + ) def test_build_manager_allocate_free_roundtrip(): diff --git a/tests/diffusion/ar_diffusion/test_kv_cache_cuda.py b/tests/diffusion/ar_diffusion/test_kv_cache_cuda.py new file mode 100644 index 000000000..7789c479f --- /dev/null +++ b/tests/diffusion/ar_diffusion/test_kv_cache_cuda.py @@ -0,0 +1,193 @@ +# SPDX-License-Identifier: Apache-2.0 +"""CUDA-only regression test for the AR-Diffusion paged KV write op. + +Split out of ``test_kv_cache.py``: that module is swept into CI's +``core_model and cpu`` job via a module-level ``pytest.mark.cpu``, and +``hardware_test(..., num_cards=1)`` adds no ``skipif`` guard for a missing +GPU, so this test was being collected (and would fail to import a CUDA +device) on CPU-only runners. Keeping it in its own, non-``cpu`` module means +it is only ever collected by a job that explicitly selects ``cuda`` for this +path -- none does yet, so for now this is intentionally not wired into any +CI job. +""" + +import pytest +import torch + +from tests.helpers.mark import hardware_test +from vllm_omni.experimental.ar_diffusion.kv_cache import allocate_kv_pool_with_views + +BLOCK = 16 + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_real_paged_write_op_does_not_clone_the_pool_on_cuda(): + """Same assertion as the CPU test, against the real op's schema and CUDA codegen. + + The CPU test compiles a stand-in with the same ``mutates_args`` contract, + because the real op needs a device and an attention kernel. That leaves + one thing unverified: whether Triton codegen reinplaces the same way, + which is the path the reported regression was observed on. + + This does not call the real op directly: ``ar_diffusion_paged_write_attn`` + runs FlashAttention's paged ``flash_attn_varlen_func`` internally, and + compiling that under ``fullgraph=True`` can fail for reasons that have + nothing to do with cloning (a missing kernel for this toy shape, a graph + break, or an autotune illegal-memory-access on the control pool). Instead + this registers a second stand-in that copies the real op's full argument + list and ``mutates_args`` declaration -- so reinplace sees the identical + contract the real op presents -- but skips straight to the pool writes, + the same way the real op's own implementation does before it calls + attention. That isolates "does Inductor clone this schema on CUDA" from + "does FlashAttention compile", which is a separate, already-covered + concern. + + Note the pools passed here are the flat views, since that is what the op + takes -- it indexes them by slot and unflattens internally. + """ + import re + + from torch._inductor import config as inductor_config + from torch._inductor.utils import run_and_get_code + + device = torch.device("cuda") + num_blocks, heads, dim, num_tokens = 8, 4, 64, 8 + pool_numel = num_blocks * BLOCK * heads * dim + # The size a genuine clone would produce: K and V pool-sized allocations, + # coalesced into one buffer the way `auto_functionalized_v2` was cloning + # them before this PR's fix. Matching this exact size (rather than "any + # allocation at least as large as one pool") avoids false positives from + # unrelated workspace/scratch buffers Inductor may emit in that range. + clone_numel = 2 * pool_numel + + if "write" not in dir(getattr(torch.ops, "test_kv_pool_clone_schema", object())): + + @torch.library.custom_op("test_kv_pool_clone_schema::write", mutates_args=("key_pool", "value_pool")) + def _write( + query: torch.Tensor, + k_curr: torch.Tensor, + v_curr: torch.Tensor, + k_act: torch.Tensor | None, + v_act: torch.Tensor | None, + key_pool: torch.Tensor, + value_pool: torch.Tensor, + block_size: int, + video_slots: torch.Tensor, + action_slots: torch.Tensor, + block_table: torch.Tensor, + query_start_loc: torch.Tensor, + seq_lens: torch.Tensor, + max_query_len: int, + max_seq_len: int, + softmax_scale: float, + ) -> torch.Tensor: + # Same pool-write side effect as `_paged_write_attn_impl`, minus + # the `ar_diffusion_paged_attention` call -- that is the part + # this test is not trying to exercise. + key_pool[video_slots] = k_curr.to(key_pool.dtype) + value_pool[video_slots] = v_curr.to(value_pool.dtype) + if k_act is not None and v_act is not None and k_act.shape[0] > 0: + key_pool[action_slots] = k_act.to(key_pool.dtype) + value_pool[action_slots] = v_act.to(value_pool.dtype) + return query * 2 + + @_write.register_fake + def _( + query, + k_curr, + v_curr, + k_act, + v_act, + key_pool, + value_pool, + block_size, + video_slots, + action_slots, + block_table, + query_start_loc, + seq_lens, + max_query_len, + max_seq_len, + softmax_scale, + ): + return torch.empty_like(query) + + class _Holder(torch.nn.Module): + def __init__(self, key_flat, value_flat): + super().__init__() + self.register_buffer("key_flat", key_flat) + self.register_buffer("value_flat", value_flat) + + def forward(self, query, k_curr, v_curr, video_slots, action_slots, block_table, query_start_loc, seq_lens): + return torch.ops.test_kv_pool_clone_schema.write( + query, + k_curr, + v_curr, + None, + None, + self.key_flat, + self.value_flat, + BLOCK, + video_slots, + action_slots, + block_table, + query_start_loc, + seq_lens, + num_tokens, + num_tokens, + dim**-0.5, + ) + + def compiled_pool_allocations(key_flat, value_flat) -> list[int]: + args = ( + torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device), + torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device), + torch.randn(num_tokens, heads, dim, dtype=torch.bfloat16, device=device), + torch.arange(num_tokens, device=device), + torch.empty(0, dtype=torch.long, device=device), + torch.arange(num_blocks, device=device, dtype=torch.int32).unsqueeze(0), + torch.tensor([0, num_tokens], device=device, dtype=torch.int32), + torch.tensor([num_tokens], device=device, dtype=torch.int32), + ) + torch._dynamo.reset() + # See the CPU test: inductor's cache outlives torch._dynamo.reset(). + with inductor_config.patch(force_disable_caches=True): + _, codes = run_and_get_code( + torch.compile(_Holder(key_flat, value_flat), backend="inductor", fullgraph=True), *args + ) + sizes = [] + for shape in re.findall(r"empty_strided_cuda\(\(([\d, ]*?)\)", "\n".join(codes)): + dims = [int(part) for part in shape.replace(" ", "").strip(",").split(",") if part] + numel = 1 + for dim_size in dims: + numel *= dim_size + if dims and numel >= pool_numel: + sizes.append(numel) + return sizes + + # Positive control: one allocation, K and V as its two halves. This must + # produce an allocation of exactly `clone_numel` -- that is the clone + # signature the assertion below checks for, not "no allocation this big". + shared = torch.empty(2, num_blocks * BLOCK, heads, dim, dtype=torch.bfloat16, device=device) + control = compiled_pool_allocations(shared[0], shared[1]) + assert clone_numel in control, ( + f"the shared layout produced no {clone_numel}-element allocation ({control}), so this test can no " + "longer tell the two layouts apart -- inductor's codegen or the detector changed" + ) + + _, k_pools, v_pools = allocate_kv_pool_with_views( + num_blocks=num_blocks, + block_size=BLOCK, + num_layers=1, + num_kv_heads=heads, + head_dim=dim, + dtype=torch.bfloat16, + device=device, + ) + separate = compiled_pool_allocations(k_pools[0], v_pools[0]) + assert clone_numel not in separate, ( + f"the compiled graph allocates a {clone_numel}-element buffer, so the pool is being cloned " + f"rather than written in place: {separate} (control saw {control})" + ) diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py index da61afe4d..f18a00ada 100644 --- a/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py @@ -350,7 +350,9 @@ class ARDiffusionKVCache: self.null_block_id = self.manager.block_pool.null_block.block_id # Allocate the per-layer paged K/V pools on the given device. - self._kv_pools: list[torch.Tensor] = [] + # Per layer, ``[k_cache, v_cache]`` -- separate allocations; see + # allocate_kv_pool_with_views for why they are not one tensor. + self._kv_pools: list[list[torch.Tensor]] = [] self._k_pools: list[torch.Tensor] = [] self._v_pools: list[torch.Tensor] = [] if device is not None: diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py index 6b7d7ffe5..ddc1937a2 100644 --- a/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/paged.py @@ -91,22 +91,35 @@ def allocate_kv_pool_with_views( head_dim: int, dtype: torch.dtype, device: torch.device, -) -> tuple[list[torch.Tensor], list[torch.Tensor], list[torch.Tensor]]: +) -> tuple[list[list[torch.Tensor]], list[torch.Tensor], list[torch.Tensor]]: """Allocate vLLM-style paged KV pools plus flat slot-write views. - The owning tensor follows FlashAttention's block-table cache layout: - ``(2, num_blocks, block_size, num_kv_heads, head_dim)``, where dim-0 is - ``[K, V]``. The flat K/V views keep ``slot = block_id * block_size + - offset`` writes simple without changing the kernel-facing cache layout. + Each cache keeps FlashAttention's block-table layout, + ``(num_blocks, block_size, num_kv_heads, head_dim)``. The flat K/V views + keep ``slot = block_id * block_size + offset`` writes simple without + changing the kernel-facing layout, and ``kv_pools[layer][0]`` / + ``[1]`` still address K and V. + + K and V are allocated separately rather than as the two halves of one + ``(2, ...)`` tensor. Sharing one allocation makes them views of the same + storage, and the paged-write custom op declares both as mutated. Inductor's + reinplace pass can then re-inplace only the first of the two -- the second + hits the "mutated arg aliases a graph input" case in + ``_inductor/fx_passes/reinplace.py`` -- so ``auto_functionalized_v2``'s + clone of the whole V pool survives into the compiled graph. That clone is + one full pool per compiled region per denoising step. """ - kv_pools: list[torch.Tensor] = [] + kv_pools: list[list[torch.Tensor]] = [] k_pools: list[torch.Tensor] = [] v_pools: list[torch.Tensor] = [] + cache_shape = (num_blocks, block_size, num_kv_heads, head_dim) + flat_shape = (num_blocks * block_size, num_kv_heads, head_dim) for _ in range(num_layers): - kv = torch.empty(2, num_blocks, block_size, num_kv_heads, head_dim, dtype=dtype, device=device) - kv_pools.append(kv) - k_pools.append(kv[0].reshape(num_blocks * block_size, num_kv_heads, head_dim)) - v_pools.append(kv[1].reshape(num_blocks * block_size, num_kv_heads, head_dim)) + k = torch.empty(cache_shape, dtype=dtype, device=device) + v = torch.empty(cache_shape, dtype=dtype, device=device) + kv_pools.append([k, v]) + k_pools.append(k.reshape(flat_shape)) + v_pools.append(v.reshape(flat_shape)) return kv_pools, k_pools, v_pools From 7f32c326c6b7fdeb0e1f669727d3c9309f5f7d75 Mon Sep 17 00:00:00 2001 From: NumberWan Date: Mon, 7 Sep 2026 12:10:47 +0800 Subject: [PATCH 10/90] [BugFix][Qwen-Image-Edit] restore txt_seq_lens RoPE width after #3588 regression (#5586) Signed-off-by: NumberWan Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com> --- .../test_qwen_image_pipelines_txt_seq_lens.py | 150 ++++++++++++++++++ ...test_qwen_image_rope_table_failure_case.py | 44 ----- .../qwen_image/test_qwen_image_rope_utils.py | 12 ++ .../qwen_image/pipeline_qwen_image_edit.py | 7 +- 4 files changed, 165 insertions(+), 48 deletions(-) create mode 100644 tests/diffusion/models/qwen_image/test_qwen_image_pipelines_txt_seq_lens.py delete mode 100644 tests/diffusion/models/qwen_image/test_qwen_image_rope_table_failure_case.py diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_pipelines_txt_seq_lens.py b/tests/diffusion/models/qwen_image/test_qwen_image_pipelines_txt_seq_lens.py new file mode 100644 index 000000000..25a8b0183 --- /dev/null +++ b/tests/diffusion/models/qwen_image/test_qwen_image_pipelines_txt_seq_lens.py @@ -0,0 +1,150 @@ +"""Behavioral regression tests for Qwen-Image Edit ``txt_seq_lens`` wiring. + +RoPE text length must follow padded embed width, not valid-token count from +``mask.sum()``. This exercises the Edit pipeline call site: when masks are +shorter than padded embeds, ``forward()`` must pass padded width into +``diffuse()``. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image_edit import ( + QwenImageEditPipeline, +) +from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + +PADDED_WIDTH = 64 +VALID_TOKENS = 10 +EMBED_DIM = 8 + + +def _make_padded_prompt_pair(): + prompt_embeds = torch.zeros(1, PADDED_WIDTH, EMBED_DIM) + prompt_embeds_mask = torch.zeros(1, PADDED_WIDTH, dtype=torch.bool) + prompt_embeds_mask[:, :VALID_TOKENS] = True + return prompt_embeds, prompt_embeds_mask + + +def _make_edit_forward_batch(*, negative_prompt: str | None) -> DiffusionRequestBatch: + preprocessed = torch.zeros(3, 32, 32) + return DiffusionRequestBatch( + requests=[ + SimpleNamespace( + request_id="edit-txt-seq-lens", + prompt={ + "prompt": "edit prompt", + "negative_prompt": negative_prompt, + "additional_information": { + "prompt_image": preprocessed, + "preprocessed_image": preprocessed, + "calculated_height": 32, + "calculated_width": 32, + }, + }, + sampling_params=SimpleNamespace( + height=32, + width=32, + num_inference_steps=2, + sigmas=None, + max_sequence_length=1024, + generator=None, + true_cfg_scale=4.0, + guidance_scale_provided=False, + guidance_scale=1.0, + num_outputs_per_prompt=1, + latents=None, + output_type="latent", + ), + ) + ] + ) + + +def _make_stubbed_edit_pipeline(): + pipeline = object.__new__(QwenImageEditPipeline) + nn.Module.__init__(pipeline) + pipeline.device = torch.device("cpu") + + pos_embeds, pos_mask = _make_padded_prompt_pair() + neg_embeds, neg_mask = _make_padded_prompt_pair() + + pipeline.check_inputs = lambda *args, **kwargs: None + pipeline.check_cfg_parallel_validity = lambda *args, **kwargs: None + pipeline.vae_scale_factor = 8 + + class _GuidanceFreeTransformer: + guidance_embeds = False + in_channels = 16 + + pipeline.transformer = _GuidanceFreeTransformer() + pipeline._attention_kwargs = None + + def _fake_encode_prompt(*, prompt_name="prompt", **kwargs): + if prompt_name == "negative_prompt": + return neg_embeds, neg_mask + return pos_embeds, pos_mask + + pipeline.encode_prompt = _fake_encode_prompt + + latents = torch.zeros(1, 4, 8, 8) + image_latents = torch.zeros(1, 4, 8, 8) + pipeline.prepare_latents = lambda *args, **kwargs: (latents, image_latents) + pipeline.prepare_timesteps = lambda *args, **kwargs: (torch.tensor([1.0, 0.5]), 2) + + captured: dict[str, object] = {} + + def _fake_diffuse( + _prompt_embeds, + _prompt_embeds_mask, + _negative_prompt_embeds, + _negative_prompt_embeds_mask, + latents_arg, + _img_shapes, + txt_seq_lens, + negative_txt_seq_lens, + _timesteps, + do_true_cfg, + _guidance, + _true_cfg_scale, + **kwargs, + ): + captured["txt_seq_lens"] = txt_seq_lens + captured["negative_txt_seq_lens"] = negative_txt_seq_lens + captured["do_true_cfg"] = do_true_cfg + return latents_arg + + pipeline.diffuse = _fake_diffuse + return pipeline, captured, pos_mask, neg_mask + + +def test_qwen_image_edit_forward_passes_padded_txt_seq_lens_to_diffuse(): + pipeline, captured, pos_mask, neg_mask = _make_stubbed_edit_pipeline() + batch = _make_edit_forward_batch(negative_prompt="bad prompt") + + pipeline.forward(batch) + + assert pos_mask.sum(dim=1).tolist() == [VALID_TOKENS] + assert neg_mask.sum(dim=1).tolist() == [VALID_TOKENS] + assert captured["txt_seq_lens"] == [PADDED_WIDTH] + assert captured["negative_txt_seq_lens"] == [PADDED_WIDTH] + assert captured["do_true_cfg"] is True + + +def test_qwen_image_edit_forward_skips_negative_txt_seq_lens_without_cfg(): + pipeline, captured, pos_mask, _neg_mask = _make_stubbed_edit_pipeline() + batch = _make_edit_forward_batch(negative_prompt=None) + + pipeline.forward(batch) + + assert pos_mask.sum(dim=1).tolist() == [VALID_TOKENS] + assert captured["txt_seq_lens"] == [PADDED_WIDTH] + assert captured["negative_txt_seq_lens"] is None + assert captured["do_true_cfg"] is False diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_rope_table_failure_case.py b/tests/diffusion/models/qwen_image/test_qwen_image_rope_table_failure_case.py deleted file mode 100644 index 2218db8d6..000000000 --- a/tests/diffusion/models/qwen_image/test_qwen_image_rope_table_failure_case.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Regression tests for #4443: padded prompt width vs RoPE table length. - -When ``max(txt_seq_lens) < prompt_embeds.shape[1]``, ``QwenEmbedRope`` builds a -text frequency table that is too short for the padded encoder width. -""" - -import pytest -import torch - -from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope -from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - -# Representative request-batch padding: valid tokens 10/20, shared width 32. -PADDED_WIDTH = 32 -VALID_LENS = [10, 20] - - -def test_pre_pr_txt_seq_lens_is_shorter_than_padded_width(): - mask = torch.zeros(len(VALID_LENS), PADDED_WIDTH, dtype=torch.bool) - mask[0, : VALID_LENS[0]] = True - mask[1, : VALID_LENS[1]] = True - - pre_pr_txt_seq_lens = mask.sum(dim=1).tolist() - assert pre_pr_txt_seq_lens == VALID_LENS - assert max(pre_pr_txt_seq_lens) < PADDED_WIDTH - - -def test_post_pr_txt_seq_lens_matches_padded_width(): - prompt_embeds = torch.zeros(len(VALID_LENS), PADDED_WIDTH, 8) - assert txt_seq_lens_from_embeds(prompt_embeds) == [PADDED_WIDTH, PADDED_WIDTH] - - -def test_rope_table_length_matches_padded_width_after_fix(): - rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True) - img_shapes = [[(1, 16, 16)]] - - _, freqs_pre_pr = rope(img_shapes, VALID_LENS, device="cpu") - _, freqs_post_pr = rope(img_shapes, [PADDED_WIDTH, PADDED_WIDTH], device="cpu") - - assert freqs_pre_pr.shape[0] == max(VALID_LENS) - assert freqs_pre_pr.shape[0] < PADDED_WIDTH - assert freqs_post_pr.shape[0] == PADDED_WIDTH diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py b/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py index 336ae2ed4..52f1b6aa3 100644 --- a/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py +++ b/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py @@ -1,6 +1,7 @@ import pytest import torch +from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -15,6 +16,17 @@ def test_txt_seq_lens_from_embeds_uses_padded_width_not_valid_token_count(): assert txt_seq_lens_from_embeds(prompt_embeds) == [64, 64] +def test_txt_seq_lens_from_embeds_builds_rope_table_for_padded_width(): + padded_width = 32 + prompt_embeds = torch.zeros(2, padded_width, 8) + txt_seq_lens = txt_seq_lens_from_embeds(prompt_embeds) + rope = QwenEmbedRope(theta=10000, axes_dim=[16, 56, 56], scale_rope=True) + + _, txt_freqs = rope([[(1, 16, 16)]], txt_seq_lens, device="cpu") + + assert txt_freqs.shape[0] == padded_width + + def test_txt_seq_lens_from_embeds_supports_2d_embeds(): prompt_embeds = torch.zeros(48, 16) diff --git a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py index 3f0d16002..34e5733fd 100644 --- a/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py +++ b/vllm_omni/diffusion/models/qwen_image/pipeline_qwen_image_edit.py @@ -36,6 +36,7 @@ from vllm_omni.diffusion.models.qwen_image.pipeline_qwen_image import calculate_ from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import ( QwenImageTransformer2DModel, ) +from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds from vllm_omni.diffusion.profiler.diffusion_pipeline_profiler import DiffusionPipelineProfilerMixin from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.utils.prompt_utils import ( @@ -815,10 +816,8 @@ class QwenImageEditPipeline( if self.attention_kwargs is None: self._attention_kwargs = {} - txt_seq_lens = prompt_embeds_mask.sum(dim=1).tolist() if prompt_embeds_mask is not None else None - negative_txt_seq_lens = ( - negative_prompt_embeds_mask.sum(dim=1).tolist() if negative_prompt_embeds_mask is not None else None - ) + txt_seq_lens = txt_seq_lens_from_embeds(prompt_embeds) + negative_txt_seq_lens = txt_seq_lens_from_embeds(negative_prompt_embeds) latents = self.diffuse( prompt_embeds, From 5cd925631c5e185093118eef014056d4a7a99a10 Mon Sep 17 00:00:00 2001 From: BruceLoveDecimal <39156883+BruceLoveDecimal@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:38:23 +0800 Subject: [PATCH 11/90] [Feature][LingBot World]stepwise execution (#6844) Signed-off-by: BruceLoveDecimal Signed-off-by: liuqihao --- .../ar_diffusion_pipeline_capability.md | 25 +- .../diffusion/lingbot_world_v2.py | 2 - .../diffusion/lingbot_world_v2_realtime.py | 1 - .../streaming_video_generation/README.md | 47 +- .../streaming_video_client.py | 60 +- recipes/Robbyant/LingBot-World-2.0.md | 121 +++- .../test_lingbot_world_stepwise_deploy.py | 52 ++ .../ar_diffusion/test_capability_runner.py | 209 ++++++- .../test_lingbot_world_actions.py | 7 + .../test_pipeline_lingbot_world.py | 299 +++++++++- .../test_lingbot_world_v2.py | 1 - .../test_lingbot_world_v2_stepwise.py | 149 +++++ .../test_lingbot_world_v2_stepwise.py | 144 +++++ .../test_diffusion_streaming_output_kwarg.py | 79 +++ vllm_omni/config/pipeline_registry.py | 2 + .../deploy/lingbot_world_v2_stepwise.yaml | 41 ++ .../diffusion/models/lingbot_world/actions.py | 58 +- .../models/lingbot_world/dmd_block.py | 239 ++++++++ .../models/lingbot_world/pipeline.py | 541 ++++++++++++++---- vllm_omni/engine/async_omni_engine.py | 11 + .../experimental/ar_diffusion/capability.py | 14 +- vllm_omni/experimental/ar_diffusion/runner.py | 163 +++++- .../models/lingbot_world/__init__.py | 2 + .../models/lingbot_world/pipeline.py | 37 ++ 24 files changed, 2087 insertions(+), 217 deletions(-) create mode 100644 tests/config/test_lingbot_world_stepwise_deploy.py create mode 100644 tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py create mode 100644 tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py create mode 100644 tests/engine/test_diffusion_streaming_output_kwarg.py create mode 100644 vllm_omni/deploy/lingbot_world_v2_stepwise.yaml create mode 100644 vllm_omni/diffusion/models/lingbot_world/dmd_block.py create mode 100644 vllm_omni/model_executor/models/lingbot_world/__init__.py create mode 100644 vllm_omni/model_executor/models/lingbot_world/pipeline.py diff --git a/docs/design/ar_diffusion_pipeline_capability.md b/docs/design/ar_diffusion_pipeline_capability.md index 3c1b4b9cd..f96c80b11 100644 --- a/docs/design/ar_diffusion_pipeline_capability.md +++ b/docs/design/ar_diffusion_pipeline_capability.md @@ -22,11 +22,15 @@ A pipeline opts in by implementing `SupportsARDiffusionPipeline`. Its immutable - named fixed-length cross-attention KV, model-specific scratch geometry, and the pipeline's requested session-capacity upper bound. -The runner binds an `ARDiffusionKVState` only around one `forward()` call. The -pipeline may use it during that context but must not retain it. The session -survives subsequent requests with the same `session_id`. Request reset, explicit -close, LRU eviction, and forward exceptions all release runner-owned KV and call -the pipeline's reset/close lifecycle hook. Cross-attention KV is allocated lazily +The runner binds an `ARDiffusionKVState` only around one runner invocation +(`execute_model` or `execute_stepwise`). The pipeline may use it during that +context but must not retain it. Scratch and committed pages stay on the +runner-owned session object, so unbinding does not drop an in-flight stepwise +chunk. Tick-mode sessions survive subsequent requests with the same +`session_id`. On the stepwise path the session id is the request id and is +released when that request finishes or fails. Request reset, explicit close, +LRU eviction, and forward exceptions all release runner-owned KV and call the +pipeline's reset/close lifecycle hook. Cross-attention KV is allocated lazily per session and logical KV branch, and is released on the same path. A pipeline publishes one named cache transactionally by yielding exactly one `(k, v)` pair per layer: @@ -48,11 +52,12 @@ successful forward. The runner also exposes `reset_session()` and ## Current execution limits -The experimental runner supports request-mode execution with -`max_num_seqs=1`. It rejects step execution and request-batch execution at load -time, and its inherited batch/step entry points also fail explicitly. Supporting -those paths requires a batch-aware state-binding contract rather than silently -bypassing `bind_ar_diffusion_state()`. +The experimental runner supports `max_num_seqs=1`. Request-batch execution is +still rejected. Step execution is allowed only when the loaded pipeline +implements `SupportsStepExecution`; other AR-Diffusion pipelines still fail at +load if `step_execution=True`. Capable pipelines bind KV around each +`execute_stepwise` invocation instead of inheriting the generic runner entry +point unwrapped. The implementation may retain multiple sessions per model instance. It selects the largest feasible capacity up to the pipeline's `session_capacity`, based on diff --git a/examples/offline_inference/diffusion/lingbot_world_v2.py b/examples/offline_inference/diffusion/lingbot_world_v2.py index b3a4ae0d1..590442c4d 100644 --- a/examples/offline_inference/diffusion/lingbot_world_v2.py +++ b/examples/offline_inference/diffusion/lingbot_world_v2.py @@ -30,7 +30,6 @@ from typing import Any import numpy as np _MODEL = "robbyant/lingbot-world-v2-14b-causal-fast-diffusers" -_NUM_INFERENCE_STEPS = 4 _MAX_SEQUENCE_LENGTH = 512 _MAX_PIXEL_AREA = 480 * 832 _MAX_RAW_FRAMES = 117 @@ -209,7 +208,6 @@ def build_request( "height": args.height, "width": args.width, "num_frames": args.num_frames, - "num_inference_steps": _NUM_INFERENCE_STEPS, "max_sequence_length": _MAX_SEQUENCE_LENGTH, "seed": args.seed, "fps": args.fps, diff --git a/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py b/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py index 86461f9f8..d98fe06f9 100644 --- a/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py +++ b/examples/offline_inference/diffusion/lingbot_world_v2_realtime.py @@ -155,7 +155,6 @@ async def run(argv: Sequence[str] | None = None) -> Path: height=args.height, width=args.width, num_frames=9, - num_inference_steps=4, max_sequence_length=512, seed=args.seed, output_type="latent", diff --git a/examples/online_serving/streaming_video_generation/README.md b/examples/online_serving/streaming_video_generation/README.md index f2d465797..8e535ce0e 100644 --- a/examples/online_serving/streaming_video_generation/README.md +++ b/examples/online_serving/streaming_video_generation/README.md @@ -1,7 +1,7 @@ # Streaming Video Generation This example uses the custom WebSocket endpoint `WS /v1/realtime/video` to receive a video byte stream as chunks are produced. -It covers text-only video generation. Image/reference input is intentionally not included for now. +It covers text-to-video, and image-conditioned models through `--image-reference`. ## Start The Server @@ -20,7 +20,7 @@ The `--diffusion-streaming-output` CLI flag is forwarded as `streaming_output=Tr | Direction | Message | Format | Description | | --- | --- | --- | --- | -| Client to server | `session.start` | JSON text: `{"type":"session.start","model":"...","prompt":"...","format":"m4s"}` | Starts generation. `format` is optional and accepts `m4s` (default). Sampling fields such as `width`, `height`, `fps`, `num_frames`, and `extra_params` may be included. | +| Client to server | `session.start` | JSON text: `{"type":"session.start","model":"...","prompt":"...","format":"m4s"}` | Starts generation. `format` is optional and accepts `m4s` (default). Sampling fields such as `width`, `height`, `fps`, `num_frames`, and `extra_params` may be included. Image-conditioned models take a first frame as `image_reference`: `{"image_url":"https://..."}` or a `data:` URL. | | Client to server | `session.interaction` | JSON text: `{"type":"session.interaction","interaction":{"event_id":"xxx","event":{"prompt":"..."},"transition_chunks":3}}` | Updates the active prompt midway through generation. `event_id` is optional and `transition_chunks` defaults to the model setting. | | Server to client | `video.start` | JSON text: `{"type":"video.start","request_id":"...","format":"m4s","config":{...}}` | Confirms the session and mirrors the accepted `format`. | | Server to client | `video.chunk_metadata` | JSON text: `{"type":"video.chunk_metadata","request_id":"...","kind":"media","transport_chunk_index":0,"generation_chunk_index":0,"num_frames":9,"byte_length":1234,"started_event_ids":[],"active_event_ids":[],"completed_event_ids":[]}` | Precedes each binary frame and describes the immediately following payload. | @@ -62,7 +62,7 @@ python streaming_video_client.py \ The client sends one `session.start` message, prints each received binary video chunk with its byte size and elapsed time, and saves the received bytes to `--output` after `session.done`. The client remuxes the gathered stream to a regular progressive MP4 file so that local playback knows the video duration. -Schedule midway prompt updates with `--prompt-updates`. Each entry uses `"at"` as seconds on the client clock after the server sends `video.start`: +Schedule midway prompt updates with `--prompt-updates`. None are sent unless you ask for them, and only pipelines that implement midway prompt updates accept them (LingBot-World, for one, does not, and rejects the whole session). Each entry uses `"at"` as seconds on the client clock after the server sends `video.start`: ```bash python streaming_video_client.py \ @@ -73,6 +73,19 @@ python streaming_video_client.py \ ]' ``` +The Helios demo continues the default prompt into an underwater storm: + +```bash +python streaming_video_client.py \ + --model BestWishYsh/Helios-Distilled \ + --prompt-updates '[ + {"at": 4, + "prompt": "An underwater tornado appears and affects the ocean floor in a dramatic and chaotic scene. The water is murky, swirling violently, carrying debris and marine life into the vortex. The tropical fish on the scene all swim in panic, trying to avoid the powerful currents. The camera remains stationary, capturing the intensity of the underwater tornado as it disrupts the serene ocean floor. Close-up shot emphasizing the turbulent motion and destruction."}, + {"at": 11, + "prompt": "The swirling underwater vortex now seizes a heavy, encrusted treasure chest, its lid flapping open as it is smashed onto the ocean floor. Gold coins and silver trinkets spill out, glittering briefly in the murky water before being swept instantly into the violent funnel. The heavy wooden box tumbles end over end, colliding with floating rocks and adding to the debris field. Swirling sediment and bubbles surround the spilling fortune, highlighting the chaotic power of the storm as it ravages the seabed. Close-up shot emphasizing the turbulent motion and destruction."} + ]' +``` + ## Run The Gradio Demo ```bash @@ -106,3 +119,31 @@ Disable it in the CLI example with `--no-helios-distilled-preset`, or override/e python streaming_video_client.py \ --extra-params '{"pyramid_num_inference_steps_list":[2, 2, 2]}' ``` + +### LingBot-World 2.0 + +`robbyant/lingbot-world-v2-14b-causal-fast-diffusers` is image-conditioned and runs on the +AR-Diffusion engine, so it needs the stepwise deploy config and a first frame. See +[the recipe](../../../recipes/Robbyant/LingBot-World-2.0.md) for the constraints on +`width`/`height` and `num_frames`. + +```bash +vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --omni \ + --deploy-config vllm_omni/deploy/lingbot_world_v2_stepwise.yaml \ + --port 8000 +``` + +```bash +python streaming_video_client.py \ + --model robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --prompt "The camera moves slowly forward through the scene." \ + --image-reference /path/to/first_frame.png \ + --width 832 --height 480 --num-frames 33 --fps 16 --seed 42 \ + --extra-params '{"camera_action_script":[[["w"],["w"],["w"]],[["a"],[],[]],[[],[],[]]]}' \ + --output lingbot_world_v2_stream.mp4 +``` + +`camera_action_script` carries one three-latent-frame WASD action list per generated +chunk. Mid-session `session.interaction` only updates the prompt, so a LingBot rollout +follows the camera script it started with. diff --git a/examples/online_serving/streaming_video_generation/streaming_video_client.py b/examples/online_serving/streaming_video_generation/streaming_video_client.py index d1a9052ed..268891284 100644 --- a/examples/online_serving/streaming_video_generation/streaming_video_client.py +++ b/examples/online_serving/streaming_video_generation/streaming_video_client.py @@ -1,11 +1,12 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Text-only WebSocket client for `/v1/realtime/video`. +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""WebSocket client for `/v1/realtime/video`. The client prints a line for every binary video chunk it receives and writes the received bytes to disk when the session finishes or is interrupted. -Image/reference input is intentionally not supported in this example yet. +``--image-reference`` sends a first frame for image-conditioned models such as +LingBot-World 2.0; text-only models need no image. Requirements: pip install av websockets @@ -13,9 +14,11 @@ Requirements: import argparse import asyncio +import base64 import contextlib import io import json +import mimetypes import os import time from dataclasses import dataclass @@ -53,6 +56,18 @@ class ScheduledPromptUpdate: transition_chunks: int +def _image_reference(value: str) -> dict[str, Any]: + """Accept an http(s) URL as-is, or inline a local image as a data URL.""" + if value.startswith(("http://", "https://", "data:")): + return {"image_url": value} + path = Path(value) + if not path.is_file(): + raise argparse.ArgumentTypeError(f"--image-reference is neither a URL nor a readable file: {value}") + media_type = mimetypes.guess_type(path.name)[0] or "image/png" + encoded = base64.b64encode(path.read_bytes()).decode() + return {"image_url": f"data:{media_type};base64,{encoded}"} + + def _parse_extra_params(value: str) -> dict[str, Any]: try: parsed = json.loads(value) @@ -116,6 +131,7 @@ def build_session_start(args: argparse.Namespace) -> dict[str, Any]: "prompt": args.prompt, } + _maybe_set(payload, "image_reference", args.image_reference) _maybe_set(payload, "negative_prompt", args.negative_prompt) _maybe_set(payload, "width", args.width) _maybe_set(payload, "height", args.height) @@ -362,6 +378,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--fps", type=int, default=16, help="Frames per second") parser.add_argument("--num-frames", type=int, default=429, help="Number of generated frames") + parser.add_argument( + "--image-reference", + type=_image_reference, + default=None, + help="First frame for image-conditioned models: a local image path or an http(s) URL.", + ) parser.add_argument("--negative-prompt", default=None, help="Negative prompt") parser.add_argument("--num-inference-steps", type=int, default=None, help="Number of diffusion steps") parser.add_argument("--guidance-scale", type=float, default=1.0, help="Classifier-free guidance scale") @@ -383,36 +405,16 @@ def parse_args() -> argparse.Namespace: default=None, help="JSON object merged into extra_params; overrides preset keys on conflict.", ) + # Midway prompt updates are pipeline-specific: a model that does not implement + # them (LingBot-World, for one) rejects the whole session on the first one, so + # nothing is scheduled unless the caller asks for it. parser.add_argument( "--prompt-updates", type=_parse_prompt_updates, - default=json.dumps( - [ - { - "at": 4, - "prompt": ( - "An underwater tornado appears and affects the ocean floor in a dramatic and chaotic scene. " - "The water is murky, swirling violently, carrying debris and marine life into the vortex. " - "The tropical fish on the scene all swim in panic, trying to avoid the powerful currents. " - "The camera remains stationary, capturing the intensity of the underwater tornado as it disrupts the serene ocean floor. " - "Close-up shot emphasizing the turbulent motion and destruction." - ), - }, - { - "at": 11, - "prompt": ( - "The swirling underwater vortex now seizes a heavy, encrusted treasure chest, its lid flapping open as it is smashed onto the ocean floor. " - "Gold coins and silver trinkets spill out, glittering briefly in the murky water before being swept instantly into the violent funnel. " - "The heavy wooden box tumbles end over end, colliding with floating rocks and adding to the debris field. " - "Swirling sediment and bubbles surround the spilling fortune, highlighting the chaotic power of the storm as it ravages the seabed. " - "Close-up shot emphasizing the turbulent motion and destruction." - ), - # "transition_chunks": 3, - }, - ] - ), + default=[], help=( - "JSON array of scheduled prompt updates. Each object requires " + "JSON array of scheduled prompt updates; none are sent by default. " + "Each object requires " "'at' (seconds after video.start) and 'prompt'. Optional " f"'transition_chunks' defaults to {DEFAULT_TRANSITION_CHUNKS}." ), diff --git a/recipes/Robbyant/LingBot-World-2.0.md b/recipes/Robbyant/LingBot-World-2.0.md index 851aeb930..c38e24fd2 100644 --- a/recipes/Robbyant/LingBot-World-2.0.md +++ b/recipes/Robbyant/LingBot-World-2.0.md @@ -7,13 +7,31 @@ - Vendor: Robbyant - Model: `robbyant/lingbot-world-v2-14b-causal-fast-diffusers` - Task: image-conditioned interactive world generation -- Modes: offline trajectory replay and in-process realtime AR-Diffusion ticks +- Modes: offline trajectory replay, stepwise one-request streaming, and in-process realtime AR-Diffusion ticks (deprecated) - Hardware validated: NVIDIA H200 and B200 - Maintainer: Community The checkpoint is separately licensed under CC BY-NC-SA and restricted to non-commercial use. The vLLM-Omni integration code remains Apache-2.0. +## Which path to use + +There are three ways to drive this model, each documented in its own section +below. + +- **Realtime stepwise** — see [Streaming video serving](#streaming-video-serving). + The suggested path: one `WS /v1/realtime/video` session produces the whole + rollout, one video chunk per AR block. It cannot take realtime camera + interaction yet; that is being added, and this path is the one that will + remain. +- **Realtime tick** *(deprecated)* — see + [Realtime in-process generation](#realtime-in-process-generation-deprecated). + The only path that takes realtime camera interaction today, so it stays until + the stepwise path can, but it takes no new work and will be removed. +- **Offline** — see [Offline generation](#offline-generation). Replays a fixed + pose/intrinsics trajectory in one request and writes an MP4. Use it when the + camera path is known up front and streaming is not needed. + ## Offline generation The offline path consumes one source image and an action directory containing @@ -34,7 +52,12 @@ counts must be `9 + 12k`, up to 117 frames. Camera files may contain a longer bounded source trajectory (the official example contains 269 frames); the runtime consumes only the prefix needed by the request. -## Realtime in-process generation +## Realtime in-process generation (deprecated) + +Use [Streaming video serving](#streaming-video-serving) instead unless you need +mid-session camera interaction, which only this path has. It still works and +emits no runtime warning; removal is tracked as B4 of the LingBot World 2.0 +roadmap ([#6672](https://github.com/vllm-project/vllm-omni/issues/6672)). The realtime example keeps AR-Diffusion KV and model-owned state across requests. Each JSONL line describes the prompt and/or three latent-frame camera @@ -61,12 +84,81 @@ exercises the same `ARDiffusionSessionManager -> ARDiffusionOmniTickConsumer -> AsyncOmni -> ARDiffusionEngine` path used by a future HTTP or WebSocket transport. -This PR intentionally does not define a public realtime HTTP/WebSocket schema. -An online serving client should be added together with the public transport -API rather than exposing LingBot-specific event fields from the generic model -runtime. The structured camera-interaction frontend and transport work is -tracked separately in -[vllm-project/vllm-omni#5527](https://github.com/vllm-project/vllm-omni/pull/5527). +Realtime serving now goes through the generic `WS /v1/realtime/video` +transport, which exposes no LingBot-specific event fields; the tick loop above +stays available only for the older control plane. + +## Streaming video serving + +The stepwise path keeps AR-Diffusion paged KV but issues **one** request for +the whole rollout: `prepare_encode` runs once, then every AR block is four DMD +steps followed by one streamed chunk. Identity metadata uses +`session_id = request_id` with contiguous `chunk_index` values from zero. +Camera motion for this landing is request-scoped: one three-frame action list +per chunk, fixed when the rollout starts. + +Serve it with the AR-Diffusion deploy config, which selects the AR-Diffusion +engine and enables streamed step execution: + +```bash +vllm serve robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --omni \ + --deploy-config vllm_omni/deploy/lingbot_world_v2_stepwise.yaml \ + --port 8000 +``` + +`--deploy-config` is required: the `lingbot_world` pipeline deliberately +registers no default deploy config, so offline replay and the tick example +keep their request-mode topology when no deploy config is given, and the +stepwise serving topology is only ever an explicit choice. + +Clients then use the generic WebSocket protocol documented in +[`docs/serving/video_stream_api.md`](../../docs/serving/video_stream_api.md): +`session.start` begins one rollout and each AR block arrives as a binary video +chunk. The model is image-conditioned, so the first frame is required and rides +on `image_reference` as an `http(s)` or `data:` URL; per-chunk camera actions +ride on `extra_params`: + +```json +{"type": "session.start", "model": "robbyant/lingbot-world-v2-14b-causal-fast-diffusers", + "prompt": "The camera moves slowly forward through the scene.", + "image_reference": {"image_url": "data:image/png;base64,"}, + "width": 832, "height": 480, "num_frames": 33, + "extra_params": {"flow_shift": 5.0, + "camera_action_script": [[["w"], ["w"], ["w"]], [["a"], [], []], [[], [], []]]}} +``` + +`camera_action_script` must hold exactly one action list per generated +chunk, and a request generates `((num_frames - 1) // 4 + 1) // 3` chunks: +three for `num_frames: 33`, seven for `num_frames: 81`. + +The bundled client speaks this protocol: + +```bash +python examples/online_serving/streaming_video_generation/streaming_video_client.py \ + --model robbyant/lingbot-world-v2-14b-causal-fast-diffusers \ + --prompt "The camera moves slowly forward through the scene." \ + --image-reference /path/to/first_frame.png \ + --width 832 --height 480 --num-frames 33 --fps 16 --seed 42 \ + --extra-params '{"camera_action_script":[[["w"],["w"],["w"]],[["a"],[],[]],[[],[],[]]]}' \ + --output lingbot_world_v2_stream.mp4 +``` + +A served request may instead point at a pose/intrinsics trajectory with +`extra_params.action_path`, which is resolved inside the trusted root set by +`model_config.lingbot_action_root` or `VLLM_OMNI_LINGBOT_ACTION_ROOT`; a server +started without that root configured accepts only `camera_action_script`. + +Requested `width`/`height` must match `ar_diffusion_width`/`ar_diffusion_height` +in the deploy config, because the AR cache geometry is fixed at load time. +Blocks are decoded independently, so seams between chunks are possible; a +session-owned streaming decoder is tracked separately. + +Mid-session `session.interaction` for camera control is not wired yet, so a +served rollout follows the script it started with. + +The deprecated tick example remains available for the older +one-block-per-`generate()` control plane. ## Realtime identity and controls @@ -104,10 +196,16 @@ tested commit. ## Current limitations - Only the 14B causal-fast checkpoint is supported. -- The realtime control plane is internal; there is no public server transport yet. +- The tick control plane is internal; the public transport is the stepwise + `WS /v1/realtime/video` path, which cannot take mid-session camera + interaction yet. +- Stepwise serving requires an explicit + `--deploy-config vllm_omni/deploy/lingbot_world_v2_stepwise.yaml`; there is + no default deploy config for this model. - AR-Diffusion stages currently require one replica because session-affine routing across replicas is not implemented. -- One AR block is generated per request and `max_num_seqs` must be one. +- Tick mode generates one AR block per request. Stepwise mode generates many + AR blocks in one request. `max_num_seqs` must be one in both cases. - Stateful streaming VAE decode is not implemented; the realtime example emits latent chunks. - SP/USP, pipeline/CFG parallelism, HSDP, VAE parallelism, quantization, @@ -119,4 +217,7 @@ tested commit. - Checkpoint: - Official implementation: - Offline example: [`examples/offline_inference/diffusion/lingbot_world_v2.py`](../../examples/offline_inference/diffusion/lingbot_world_v2.py) +- Streaming serving client: [`examples/online_serving/streaming_video_generation/`](../../examples/online_serving/streaming_video_generation/README.md) +- Streaming serving end-to-end test: [`tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py`](../../tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py) +- Stepwise latent-level end-to-end test: [`tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py`](../../tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py) - Realtime design: [`docs/design/feature/realtime_ar_diffusion.md`](../../docs/design/feature/realtime_ar_diffusion.md) diff --git a/tests/config/test_lingbot_world_stepwise_deploy.py b/tests/config/test_lingbot_world_stepwise_deploy.py new file mode 100644 index 000000000..29d355c1c --- /dev/null +++ b/tests/config/test_lingbot_world_stepwise_deploy.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""The LingBot stepwise deploy config must keep serving on the AR-Diffusion path. + +`WS /v1/realtime/video` only reaches the stepwise code when the stage selects the +AR-Diffusion engine and asks for streamed step execution, so those fields are +pinned here rather than left to drift. +""" + +from __future__ import annotations + +import pytest + +from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.config.stage_config import load_deploy_config + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_DEPLOY = "lingbot_world_v2_stepwise.yaml" + + +def _stage(): + deploy = load_deploy_config(get_deploy_config_path(_DEPLOY)) + assert len(deploy.stages) == 1, "stepwise serving is a single diffusion stage" + return deploy.stages[0] + + +def test_stage_selects_the_ar_diffusion_engine_and_pipeline() -> None: + stage = _stage() + assert stage.model_class_name == "LingBotWorldCausalDMDPipeline" + # engine_backend/model_config reach the engine through the passthrough, the + # same way the DreamZero AR-Diffusion deploy config does. + assert stage.engine_extras["engine_backend"].endswith("ar_diffusion.engine.ARDiffusionEngine") + + +def test_stage_requests_streamed_step_execution_on_one_sequence() -> None: + stage = _stage() + assert stage.step_execution is True + assert stage.engine_extras["streaming_output"] is True + # AR-Diffusion binds one session per runner invocation; batching requests + # would break that contract, so the runner rejects max_num_seqs > 1. + assert stage.max_num_seqs == 1 + + +def test_stage_declares_the_fixed_ar_cache_geometry() -> None: + stage = _stage() + model_config = stage.engine_extras["model_config"] + # The KV cache geometry is fixed at load time, so a served request must ask + # for exactly this resolution. + assert model_config["ar_diffusion_height"] == 480 + assert model_config["ar_diffusion_width"] == 832 + assert model_config["ar_diffusion_kv_config"]["gpu_memory_fraction"] == 0.6 diff --git a/tests/diffusion/ar_diffusion/test_capability_runner.py b/tests/diffusion/ar_diffusion/test_capability_runner.py index 10451c134..70166189b 100644 --- a/tests/diffusion/ar_diffusion/test_capability_runner.py +++ b/tests/diffusion/ar_diffusion/test_capability_runner.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """CPU contracts for capability-driven AR-Diffusion sessions.""" from __future__ import annotations @@ -10,6 +11,7 @@ from types import SimpleNamespace import pytest import torch +from vllm_omni.diffusion.sched.interface import CachedRequestData, DiffusionSchedulerOutput from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner from vllm_omni.experimental.ar_diffusion.capability import ( ARDiffusionCrossAttentionKVSpec, @@ -27,6 +29,22 @@ NEG = "negative" pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.fixture(autouse=True) +def platform_synchronize_calls(monkeypatch) -> list[None]: + """Stub the platform barrier the runner takes at every chunk boundary. + + These tests run on a CPU host, where the resolved platform is + ``UnspecifiedOmniPlatform`` and ``synchronize()`` raises. Tests that need + a failing barrier override this with their own fake. + """ + calls: list[None] = [] + monkeypatch.setattr( + "vllm_omni.experimental.ar_diffusion.runner.current_omni_platform", + SimpleNamespace(synchronize=lambda: calls.append(None)), + ) + return calls + + def lingbot_like_spec(*, capacity: int = 2) -> ARDiffusionKVCacheSpec: """Single-kv_branch causal DMD: 3 latent frames/block + sink/window + text KV.""" return ARDiffusionKVCacheSpec( @@ -114,6 +132,43 @@ class BatchCapablePipeline(CapablePipeline): supports_request_batch = True +class StepCapablePipeline(CapablePipeline): + supports_step_execution = True + + def prepare_encode(self, state, **kwargs): + del kwargs + return state + + def denoise_step(self, input_batch, *, states=None, **kwargs): + del input_batch, states, kwargs + return None + + def step_scheduler(self, state, noise_pred, **kwargs): + del state, noise_pred, kwargs + + def post_decode(self, state, **kwargs): + del state, kwargs + from vllm_omni.diffusion.data import DiffusionOutput + + return DiffusionOutput() + + +def scheduler_output( + request_ids: tuple[str, ...] = ("req-1",), + *, + finished: tuple[str, ...] = (), +) -> DiffusionSchedulerOutput: + """Build the real scheduler payload; stepwise requests arrive as cached rows.""" + return DiffusionSchedulerOutput( + step_id=0, + scheduled_new_reqs=[], + scheduled_cached_reqs=CachedRequestData(request_ids=list(request_ids)), + finished_req_ids=set(finished), + num_running_reqs=len(request_ids), + num_waiting_reqs=0, + ) + + def make_runner( pipeline: object, *, @@ -140,6 +195,7 @@ def make_runner( runner._sessions = OrderedDict() runner._session_capacity = 0 runner._perf_e2e_times = [] + runner._stepwise_chunk_started = {} runner._preallocate_kv_cache(available_bytes=available_bytes) return runner @@ -395,12 +451,16 @@ def test_synchronize_exception_uses_forward_cleanup_path(monkeypatch): ctx.ensure_video_slots(torch.device("cpu")) return object() - def synchronize_boom(device): + def synchronize_boom(): raise RuntimeError("asynchronous kernel failed") monkeypatch.setattr(DiffusionModelRunner, "execute_model", return_after_allocation) - monkeypatch.setattr(torch.accelerator, "synchronize", synchronize_boom) - runner.device = torch.device("cuda") + # The runner defers the device barrier to the platform, so the failure is + # injected there rather than on a device-specific torch entry point. + monkeypatch.setattr( + "vllm_omni.experimental.ar_diffusion.runner.current_omni_platform", + SimpleNamespace(synchronize=synchronize_boom), + ) request = SimpleNamespace( request_id="broken-request", sampling_params=SimpleNamespace(extra_args={"session_id": "broken"}), @@ -424,6 +484,11 @@ def test_ar_runner_rejects_step_and_request_batch_modes(): make_runner(BatchCapablePipeline(lingbot_like_spec())) +def test_ar_runner_allows_step_execution_when_pipeline_implements_the_contract(): + runner = make_runner(StepCapablePipeline(lingbot_like_spec()), step_execution=True) + assert runner.kv_cache is not None + + def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints(): runner = object.__new__(ARDiffusionModelRunner) with pytest.raises(RuntimeError, match="request-batch execution"): @@ -432,6 +497,144 @@ def test_ar_runner_defensively_rejects_inherited_batch_and_step_entrypoints(): runner.execute_stepwise(None) +def test_execute_stepwise_binds_request_id_session_and_unbinds_after_call(monkeypatch): + from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput + + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + bound_during = [] + + def fake_execute(self, scheduler_output): + del self, scheduler_output + bound_during.append(pipeline.bound_state is not None) + return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False)]) + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute) + output = runner.execute_stepwise(scheduler_output()) + + assert bound_during == [True] + assert pipeline.bound_state is None + assert pipeline.binds == ["req-1"] + assert "req-1" in runner._sessions + assert output.get_request_output("req-1") is not None + assert not pipeline.closes + + +def test_execute_stepwise_closes_session_when_request_finishes(monkeypatch): + from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput + + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + + def fake_execute(self, scheduler_output): + del self, scheduler_output + return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=True)]) + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute) + runner.execute_stepwise(scheduler_output()) + + assert pipeline.bound_state is None + assert pipeline.closes == ["req-1"] + assert "req-1" not in runner._sessions + + +def test_execute_stepwise_exception_releases_kv_and_does_not_resume_pages(monkeypatch): + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + first = runner._get_or_create_session("req-1") + + def boom(self, scheduler_output): + del self, scheduler_output + raise RuntimeError("step failed") + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", boom) + with pytest.raises(RuntimeError, match="step failed"): + runner.execute_stepwise(scheduler_output()) + + assert pipeline.bound_state is None + assert pipeline.closes == ["req-1"] + assert "req-1" not in runner._sessions + second = runner._get_or_create_session("req-1") + assert second is not first + + +def test_execute_stepwise_closes_session_for_scheduler_aborted_request(monkeypatch): + from vllm_omni.diffusion.worker.utils import BatchRunnerOutput + + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + runner._get_or_create_session("req-1") + + def fake_execute(self, scheduler_output): + del self, scheduler_output + return BatchRunnerOutput.from_list([]) + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute) + # An aborted request is never scheduled again and never reports finished, + # so the retire path is the only thing that can free its KV. + runner.execute_stepwise(scheduler_output(request_ids=(), finished=("req-1",))) + + assert "req-1" not in runner._sessions + assert pipeline.closes == ["req-1"] + + +def test_execute_stepwise_times_once_per_chunk_not_once_per_step(monkeypatch, platform_synchronize_calls): + from vllm_omni.diffusion.data import DiffusionOutput + from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput + + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + results = [None, None, None, DiffusionOutput()] + + def fake_execute(self, scheduler_output): + del self, scheduler_output + return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False, result=results.pop(0))]) + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute) + for _ in range(4): + runner.execute_stepwise(scheduler_output()) + + # Four denoise steps, one emitted AR block: one timing sample, matching the + # one-sample-per-block meaning request mode already has. + assert len(runner._perf_e2e_times) == 1 + assert len(platform_synchronize_calls) == 1 + assert "req-1" not in runner._stepwise_chunk_started + + +def test_execute_stepwise_synchronize_exception_fails_closed(monkeypatch): + """The chunk barrier is where an asynchronous device error surfaces. It must + clean up like an in-step failure instead of leaving the session, the cached + step state and the timing entry to the scheduler's next cycle.""" + from vllm_omni.diffusion.worker.utils import BatchRunnerOutput, RunnerOutput + + pipeline = StepCapablePipeline(lingbot_like_spec()) + runner = make_runner(pipeline, step_execution=True) + runner.state_cache = {"req-1": object()} + + def fake_execute(self, scheduler_output): + del self, scheduler_output + return BatchRunnerOutput.from_list([RunnerOutput(request_id="req-1", finished=False, result=object())]) + + def synchronize_boom(): + raise RuntimeError("asynchronous kernel failed") + + monkeypatch.setattr(DiffusionModelRunner, "execute_stepwise", fake_execute) + monkeypatch.setattr( + "vllm_omni.experimental.ar_diffusion.runner.current_omni_platform", + SimpleNamespace(synchronize=synchronize_boom), + ) + + with pytest.raises(RuntimeError, match="asynchronous kernel failed"): + runner.execute_stepwise(scheduler_output()) + + assert pipeline.bound_state is None + assert pipeline.closes == ["req-1"] + assert "req-1" not in runner._sessions + assert "req-1" not in runner.state_cache + assert "req-1" not in runner._stepwise_chunk_started + assert not runner._perf_e2e_times + + def test_model_specific_warmup_provider_is_consumed(monkeypatch): requests = [object(), object()] pipeline = WarmupPipeline(lingbot_like_spec(), requests) diff --git a/tests/diffusion/models/lingbot_world/test_lingbot_world_actions.py b/tests/diffusion/models/lingbot_world/test_lingbot_world_actions.py index df012777f..e26e44c2c 100644 --- a/tests/diffusion/models/lingbot_world/test_lingbot_world_actions.py +++ b/tests/diffusion/models/lingbot_world/test_lingbot_world_actions.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -13,6 +14,7 @@ from vllm_omni.diffusion.models.lingbot_world.actions import ( LingBotCameraControlReducer, integrate_lingbot_camera_actions, parse_lingbot_camera_action_frames, + parse_lingbot_camera_action_script, ) from vllm_omni.experimental.ar_diffusion.session import ( ARDiffusionSession, @@ -292,3 +294,8 @@ def test_session_reducer_keeps_prompt_and_actions_atomic_and_resets() -> None: assert consumer.ticks[1].controls[0].data["frames"] == ((), (), ()) asyncio.run(exercise()) + + +def test_parse_camera_action_script_rejects_wrong_chunk_width() -> None: + with pytest.raises(ValueError, match="exactly 3 per-latent-frame"): + parse_lingbot_camera_action_script([[["w"], ["w"]]], frames_per_chunk=3) diff --git a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py index 1a08e8270..52113208a 100644 --- a/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py +++ b/tests/diffusion/models/lingbot_world/test_pipeline_lingbot_world.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -15,8 +15,10 @@ from diffusers.utils.torch_utils import randn_tensor as _diffusers_randn_tensor from PIL import Image from torch import nn +import vllm_omni.diffusion.models.lingbot_world.dmd_block as lingbot_dmd_block import vllm_omni.diffusion.models.lingbot_world.pipeline as lingbot_pipeline from tests.diffusion.models.wan2_2.conftest import noop_progress_bar +from vllm_omni.diffusion.models.interface import SupportsStepExecution, supports_step_execution from vllm_omni.diffusion.models.lingbot_world.actions import ( integrate_lingbot_camera_actions, ) @@ -24,6 +26,8 @@ from vllm_omni.diffusion.models.lingbot_world.camera import CameraTrajectory as from vllm_omni.diffusion.models.lingbot_world.camera import ( build_plucker_embedding as _real_build_plucker_embedding, ) +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.worker.utils import StepRequestState from vllm_omni.experimental.ar_diffusion.tick_protocol import ( ARDiffusionControlInput, ARDiffusionTickRequest, @@ -253,6 +257,8 @@ class _SamplingParams: if extra_args is not None: self.extra_args.update(extra_args) self.latents = None + self.guidance_scale = None + self.guidance_scale_2 = None class _RequestBatch: @@ -345,7 +351,6 @@ def _stub_pipeline_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: "FlowUniPCMultistepScheduler": _FakeScheduler, "CausalLingBotWorldTransformer3DModel": _FakeTransformerFactory, "get_local_device": lambda: torch.device("cpu"), - "set_forward_context_denoise_step_idx": lambda index: None, "prefetch_subfolders": prefetch_subfolders, "from_pretrained_with_prefetch": from_pretrained_with_prefetch, "load_transformer_config": load_transformer_config, @@ -358,6 +363,9 @@ def _stub_pipeline_dependencies(monkeypatch: pytest.MonkeyPatch) -> None: } for name, value in replacements.items(): monkeypatch.setattr(lingbot_pipeline, name, value) + # The DMD block math lives in its own module; stub the symbols it imports. + monkeypatch.setattr(lingbot_dmd_block, "set_forward_context_denoise_step_idx", lambda index: None) + monkeypatch.setattr(lingbot_dmd_block, "randn_tensor", _diffusers_randn_tensor) monkeypatch.setattr(lingbot_pipeline, "_loader_state", loader_state, raising=False) @@ -774,6 +782,7 @@ def test_denoise_state_stays_fp32_while_transformer_inputs_use_model_dtype() -> return torch.zeros(shape, device=device, dtype=dtype) module.randn_tensor = randn + lingbot_dmd_block.randn_tensor = randn result = pipeline(_request()) assert requested_noise_dtypes == [torch.float32] * 4 @@ -1139,7 +1148,7 @@ def test_first_frame_condition_and_camera_fold_match_transformer_contract() -> N module = _load_pipeline_module() transformer = _RecordingTransformer() pipeline = _pipeline(module, transformer=transformer) - module.randn_tensor = lambda shape, **kwargs: torch.full( + module.randn_tensor = lingbot_dmd_block.randn_tensor = lambda shape, **kwargs: torch.full( shape, -99.0, device=kwargs["device"], @@ -1745,6 +1754,288 @@ def test_request_cache_becomes_unreachable_after_transformer_error() -> None: assert not hasattr(pipeline, "transformer_cache") +class _FakeARState: + def __init__(self, session_id: str = "req-1") -> None: + self.session_id = session_id + self.commits: list[str] = [] + + def get_kv_caches(self, branch, *, seq_len, commit_current): + del branch, seq_len, commit_current + return [SimpleNamespace()] + + def commit_paged_context(self, branch): + self.commits.append(branch) + + def clear_cross_attention(self): + return None + + def is_cross_attention_populated(self, branch, name): + del branch, name + return True + + def get_cross_attention_kv(self, branch, name): + del branch, name + zeros = torch.zeros(1, 1, 2, 4) + return [{"k": zeros, "v": zeros} for _ in range(2)] + + +def _empty_action_script(num_chunks: int): + return tuple(((), (), ()) for _ in range(num_chunks)) + + +def _stepwise_state( + *, + request_id: str = "req-1", + num_frames: int = 21, + script=None, + seed: int = 17, +): + num_chunks = ((num_frames - 1) // 4 + 1) // 3 + sampling = _SamplingParams(num_frames=num_frames, seed=seed) + sampling.extra_args["_lingbot_camera_trajectory"] = None + sampling.extra_args["_lingbot_camera_actions"] = None + sampling.extra_args["_lingbot_camera_action_script"] = ( + script if script is not None else _empty_action_script(num_chunks) + ) + return StepRequestState( + request_id=request_id, + sampling=sampling, + prompt=_prompt(), + ) + + +def _run_stepwise(pipeline, state): + outputs = [] + pipeline.prepare_encode(state) + while not state.request_denoise_completed: + noise = pipeline.denoise_step(None, states=[state]) + pipeline.step_scheduler(state, noise) + if state.chunk_denoise_completed: + outputs.append(pipeline.post_decode(state)) + return outputs + + +def test_pipeline_declares_step_execution_support() -> None: + module = _load_pipeline_module() + pipeline = _pipeline(module) + + assert pipeline.supports_step_execution is True + assert isinstance(pipeline, SupportsStepExecution) + assert supports_step_execution(pipeline) is True + + +def test_preprocess_materializes_camera_action_script_without_action_path() -> None: + module = _load_pipeline_module() + sampling = _SamplingParams(include_action=False) + sampling.extra_args["camera_action_script"] = [[["w"], ["w"], ["w"]], [["a"], [], []]] + request = OmniDiffusionRequest(prompt=_prompt(), sampling_params=sampling, request_id="req-1") + + result = module.get_lingbot_world_pre_process_func(_od_config())(request) + + assert result.sampling_params.extra_args["_lingbot_camera_trajectory"] is None + assert result.sampling_params.extra_args["_lingbot_camera_actions"] is None + assert result.sampling_params.extra_args["_lingbot_camera_action_script"] == ( + (("w",), ("w",), ("w",)), + (("a",), (), ()), + ) + + +@pytest.mark.parametrize( + "extra_args", + [ + pytest.param({}, id="request_mode"), + pytest.param(_tick_extra_args(chunk_index=0), id="tick_mode"), + ], +) +def test_forward_rejects_a_stepwise_camera_action_script(extra_args) -> None: + """A script only steers step execution, so request mode must not drop it silently.""" + module = _load_pipeline_module() + pipeline = _pipeline(module) + sampling = _SamplingParams( + include_action=False, + extra_args={ + **extra_args, + "_lingbot_camera_trajectory": None, + "_lingbot_camera_action_script": _empty_action_script(1), + }, + ) + + with pytest.raises(ValueError, match="camera_action_script is read only by LingBot step execution"): + pipeline(_request(sampling=sampling)) + + +def test_stepwise_progress_metadata_and_commit_trace() -> None: + module = _load_pipeline_module() + transformer = _RecordingTransformer() + pipeline = _pipeline(module, transformer=transformer) + pipeline._ar_height = 16 + pipeline._ar_width = 16 + state = _stepwise_state(num_frames=21) + fake = _FakeARState(state.request_id) + + with pipeline.bind_ar_diffusion_state(state.request_id, fake): + outputs = _run_stepwise(pipeline, state) + + assert pipeline._ar_diffusion_kv_state is None + assert len(outputs) == 2 + assert [output.chunk_index for output in outputs] == [0, 1] + assert all(output.total_chunks == 2 for output in outputs) + assert outputs[-1].finished is True + assert [call["update_cache"] for call in transformer.calls] == [False, False, False, False, True] * 2 + assert [call["start_frame"] for call in transformer.calls] == [0] * 5 + [3] * 5 + assert fake.commits == ["main", "main"] + for chunk_index, output in enumerate(outputs): + metadata = output.output["metadata"]["ar_diffusion"] + assert metadata == { + "session_id": "req-1", + "request_id": "req-1", + "chunk_index": chunk_index, + "applied_event_ids": [], + } + assert output.output["payload"]["latents"].shape == (1, 16, 3, 2, 2) + + +def test_stepwise_matches_tick_transformer_trace_and_latents() -> None: + module = _load_pipeline_module() + transformer = _RecordingTransformer() + pipeline = _pipeline(module, transformer=transformer) + pipeline._ar_height = 16 + pipeline._ar_width = 16 + script = ((("w",), ("w",), ("w",)), (("d",), ("d",), ("d",))) + fake = _FakeARState("world-1") + + with pipeline.bind_ar_diffusion_state("world-1", fake): + tick_latents = [] + for chunk_index, frames in enumerate(script): + tick = ARDiffusionTickRequest( + session_id="world-1", + request_id="legacy-lingbot-request", + chunk_index=chunk_index, + controls=( + ARDiffusionControlInput( + track="camera", + schema="lingbot.camera_actions.v1", + data={"mode": "frames", "frames": [list(frame) for frame in frames]}, + ), + ), + ) + sampling = _SamplingParams(extra_args=tick.to_extra_args(), seed=17) + sampling.extra_args["_lingbot_camera_trajectory"] = None + sampling.extra_args["_lingbot_camera_actions"] = frames + result = pipeline(_request(sampling=sampling)) + tick_latents.append(result.output["payload"]["latents"].clone()) + tick_calls = list(transformer.calls) + transformer.calls.clear() + pipeline.close_ar_diffusion_session("world-1") + + stepwise_state = _stepwise_state(request_id="req-1", num_frames=21, script=script, seed=17) + stepwise_fake = _FakeARState(stepwise_state.request_id) + with pipeline.bind_ar_diffusion_state(stepwise_state.request_id, stepwise_fake): + stepwise_outputs = _run_stepwise(pipeline, stepwise_state) + + assert [call["update_cache"] for call in transformer.calls] == [call["update_cache"] for call in tick_calls] + assert [call["start_frame"] for call in transformer.calls] == [call["start_frame"] for call in tick_calls] + for tick_latent, output in zip(tick_latents, stepwise_outputs, strict=True): + torch.testing.assert_close(output.output["payload"]["latents"], tick_latent) + + +def test_stepwise_trajectory_camera_matches_request_mode_under_non_uniform_speed(monkeypatch) -> None: + """A trajectory that slows down between blocks must condition every stepwise + chunk exactly as request mode does. Request mode embeds the whole trajectory + once; embedding per chunk would re-normalize each block's framewise + translations by its own largest step and present a slow block as full-speed + motion.""" + from vllm_omni.diffusion.models.lingbot_world import camera as camera_module + + module = _load_pipeline_module() + # The fixture stubs the ray embedding with a pose-blind ramp; parity needs + # the real geometry so a scale mismatch actually shows up. + monkeypatch.setattr(module, "build_plucker_embedding", camera_module.build_plucker_embedding) + transformer = _RecordingTransformer() + pipeline = _pipeline(module, transformer=transformer) + pipeline._ar_height = 16 + pipeline._ar_width = 16 + + # Six latent frames -> two 3-frame blocks. The first block moves at full + # speed and everything after it at a tenth of that, so a per-chunk + # normalization would rescale the second block by 10x. + steps = torch.tensor([1.0] * 3 + [0.1] * 18) + poses = torch.eye(4).repeat(21, 1, 1) + poses[:, 2, 3] = torch.cumsum(steps, dim=0) - steps[0] + trajectory = _CameraTrajectory( + poses=poses, + intrinsics=torch.tensor([[100.0, 100.0, 8.0, 8.0]]).repeat(21, 1), + ) + + sampling = _SamplingParams(num_frames=21) + sampling.extra_args["_lingbot_camera_trajectory"] = trajectory + pipeline(_request(sampling=sampling)) + request_cameras = [call["camera_hidden_states"] for call in transformer.calls] + transformer.calls.clear() + + state = _stepwise_state(num_frames=21) + state.sampling.extra_args["_lingbot_camera_trajectory"] = trajectory + state.sampling.extra_args.pop("_lingbot_camera_action_script") + with pipeline.bind_ar_diffusion_state(state.request_id, _FakeARState(state.request_id)): + _run_stepwise(pipeline, state) + stepwise_cameras = [call["camera_hidden_states"] for call in transformer.calls] + + # Two blocks x (four probes + one commit), in the same order on both paths. + assert len(request_cameras) == len(stepwise_cameras) == 10 + for stepwise, request in zip(stepwise_cameras, request_cameras, strict=True): + torch.testing.assert_close(stepwise, request) + + +def test_stepwise_emits_latents_without_touching_the_vae() -> None: + """Latent mode must stay latent: streaming decode is opt-in per request.""" + module = _load_pipeline_module() + pipeline = _pipeline(module, transformer=_RecordingTransformer()) + pipeline._ar_height = 16 + pipeline._ar_width = 16 + state = _stepwise_state(num_frames=21) + fake = _FakeARState(state.request_id) + + with pipeline.bind_ar_diffusion_state(state.request_id, fake): + outputs = _run_stepwise(pipeline, state) + + assert [set(output.output["payload"]) for output in outputs] == [{"latents"}, {"latents"}] + assert pipeline.vae.decode_inputs == [] + + +def test_stepwise_decodes_each_chunk_for_streaming_consumers(monkeypatch) -> None: + """A non-latent request must stream pixels under the primary "video" key.""" + import diffusers.video_processor as video_processor_module + + module = _load_pipeline_module() + processed = [] + + class VideoProcessor: + def __init__(self, *, vae_scale_factor): + assert vae_scale_factor == 8 + + def postprocess_video(self, video, *, output_type): + processed.append((tuple(video.shape), output_type)) + return f"frames-{len(processed)}" + + monkeypatch.setattr(video_processor_module, "VideoProcessor", VideoProcessor) + pipeline = _pipeline(module, transformer=_RecordingTransformer()) + pipeline._ar_height = 16 + pipeline._ar_width = 16 + state = _stepwise_state(num_frames=21) + state.sampling.output_type = "np" + fake = _FakeARState(state.request_id) + + with pipeline.bind_ar_diffusion_state(state.request_id, fake): + outputs = _run_stepwise(pipeline, state) + + # One decode per AR block, each seeing only that block's three latent frames. + assert [tuple(latents.shape) for latents in pipeline.vae.decode_inputs] == [(1, 16, 3, 2, 2)] * 2 + assert [output_type for _, output_type in processed] == ["np", "np"] + assert [output.output["payload"] for output in outputs] == [{"video": "frames-1"}, {"video": "frames-2"}] + # Identity metadata survives the switch to pixel output. + assert [output.output["metadata"]["ar_diffusion"]["chunk_index"] for output in outputs] == [0, 1] + + def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> None: module = _load_pipeline_module() resolved, entry, cache_acceleration_disabled, preprocess_name, preprocess = _resolve_pipeline_through_real_registry( @@ -1755,7 +2046,7 @@ def test_registry_and_model_exports_resolve_official_pipeline_class_name() -> No assert resolved is module.LingBotWorldCausalDMDPipeline assert cache_acceleration_disabled assert preprocess_name == "get_lingbot_world_pre_process_func" - request = SimpleNamespace(prompt=_prompt(), sampling_params=_SamplingParams()) + request = OmniDiffusionRequest(prompt=_prompt(), sampling_params=_SamplingParams(), request_id="req-1") assert preprocess(request) is request assert isinstance(request.sampling_params.extra_args["_lingbot_camera_trajectory"], _CameraTrajectory) assert module.LingBotWorldCausalDMDPipeline.__name__ == "LingBotWorldCausalDMDPipeline" diff --git a/tests/e2e/offline_inference/test_lingbot_world_v2.py b/tests/e2e/offline_inference/test_lingbot_world_v2.py index 39d9b9d95..64578af01 100644 --- a/tests/e2e/offline_inference/test_lingbot_world_v2.py +++ b/tests/e2e/offline_inference/test_lingbot_world_v2.py @@ -62,7 +62,6 @@ def test_lingbot_world_v2_tp1_one_block(omni_runner_handler: OmniRunnerHandler) height=480, width=832, num_frames=9, - num_inference_steps=4, max_sequence_length=512, seed=42, fps=16, diff --git a/tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py b/tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py new file mode 100644 index 000000000..f677398ce --- /dev/null +++ b/tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""End-to-end stepwise smoke test for LingBot-World v2. + +One ``AsyncOmni.generate()`` must stream one latent chunk per AR block while +keeping a single request identity, which is what separates this path from the +tick control plane exercised by ``test_lingbot_world_v2.py``. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import pytest +import torch +from vllm.assets.image import ImageAsset + +from tests.helpers.mark import hardware_test + +if TYPE_CHECKING: + from vllm_omni.outputs import OmniRequestOutput + +MODEL = os.environ.get( + "VLLM_OMNI_LINGBOT_WORLD_V2_CHECKPOINT_PATH", + "robbyant/lingbot-world-v2-14b-causal-fast-diffusers", +) +# The pipeline resizes the first frame to the requested geometry, so any real +# photograph conditions the rollout; the shared vLLM asset keeps the test +# runnable with no environment setup and adds no binary to this repository. +_IMAGE_ASSET = "2560px-Gfp-wisconsin-madison-the-nature-boardwalk" +_IMAGE_PATH_OVERRIDE = os.environ.get("VLLM_OMNI_LINGBOT_WORLD_V2_IMAGE_PATH") + +_HEIGHT = 480 +_WIDTH = 832 +_FRAMES_PER_BLOCK = 3 +_TEMPORAL_COMPRESSION = 4 +_REQUEST_ID = "lingbot-world-v2-stepwise-e2e" +# One three-frame action list per generated chunk. +_CAMERA_ACTION_SCRIPT = [ + [["w"], ["w"], ["w"]], + [["a"], [], []], + [[], [], []], +] +_NUM_CHUNKS = len(_CAMERA_ACTION_SCRIPT) +_NUM_FRAMES = (_NUM_CHUNKS * _FRAMES_PER_BLOCK - 1) * _TEMPORAL_COMPRESSION + 1 + +pytestmark = [ + pytest.mark.slow, + pytest.mark.diffusion, +] + + +def _first_frame() -> Path: + if _IMAGE_PATH_OVERRIDE: + return Path(_IMAGE_PATH_OVERRIDE).expanduser().resolve() + return Path(ImageAsset(_IMAGE_ASSET).get_path("jpg")) + + +def _chunk_metadata(output: OmniRequestOutput) -> dict[str, Any]: + multimodal = getattr(output, "multimodal_output", None) or {} + metadata = multimodal.get("metadata") if isinstance(multimodal, dict) else None + assert isinstance(metadata, dict), "streamed chunk is missing its metadata envelope" + ar_diffusion = metadata.get("ar_diffusion") + assert isinstance(ar_diffusion, dict), "streamed chunk is missing ar_diffusion metadata" + return dict(ar_diffusion) + + +async def _stream_chunks(image: Path) -> list[tuple[torch.Tensor, dict[str, Any], bool]]: + from vllm_omni.entrypoints.async_omni import AsyncOmni + from vllm_omni.inputs.data import OmniDiffusionSamplingParams + + engine = AsyncOmni( + model=MODEL, + engine_backend="vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine", + enforce_eager=True, + tensor_parallel_size=1, + max_num_seqs=1, + step_execution=True, + diffusion_streaming_output=True, + model_config={ + "ar_diffusion_height": _HEIGHT, + "ar_diffusion_width": _WIDTH, + "ar_diffusion_kv_config": {"gpu_memory_fraction": 0.6, "warmup_cudagraph": True}, + }, + ) + sampling = OmniDiffusionSamplingParams( + height=_HEIGHT, + width=_WIDTH, + num_frames=_NUM_FRAMES, + num_inference_steps=4, + max_sequence_length=512, + seed=42, + output_type="latent", + extra_args={"flow_shift": 5.0, "camera_action_script": _CAMERA_ACTION_SCRIPT}, + ) + prompt = { + "prompt": "The camera moves slowly forward through the scene.", + "multi_modal_data": {"image": str(image)}, + } + + chunks: list[tuple[torch.Tensor, dict[str, Any], bool]] = [] + try: + async for output in engine.generate(prompt, sampling, request_id=_REQUEST_ID): + images = getattr(output, "images", None) + if not images: + continue + assert len(images) == 1, "each AR block yields exactly one latent tensor" + latent = images[0].detach().float().cpu() + chunks.append((latent, _chunk_metadata(output), bool(getattr(output, "finished", False)))) + finally: + engine.shutdown() + return chunks + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +def test_lingbot_world_v2_stepwise_streams_one_chunk_per_block() -> None: + """Load the real checkpoint and stream N chunks from a single request.""" + + chunks = asyncio.run(_stream_chunks(_first_frame())) + + assert len(chunks) == _NUM_CHUNKS + expected_shape = ( + 1, + 16, + _FRAMES_PER_BLOCK, + _HEIGHT // 8, + _WIDTH // 8, + ) + for chunk_index, (latent, metadata, finished) in enumerate(chunks): + assert tuple(latent.shape) == expected_shape + assert torch.isfinite(latent).all() + # The request is the session, so both ids must agree on every chunk, + # and chunk indices must be contiguous from zero. + assert metadata["chunk_index"] == chunk_index + assert metadata["session_id"] == metadata["request_id"] + assert metadata["applied_event_ids"] == [] + assert finished is (chunk_index == _NUM_CHUNKS - 1) + + # The engine suffixes the caller's id to keep it unique; what this path + # guarantees is that one rollout keeps one identity from start to finish. + request_ids = {metadata["request_id"] for _, metadata, _ in chunks} + assert len(request_ids) == 1 + assert request_ids.pop().startswith(_REQUEST_ID) + + concatenated = torch.cat([latent for latent, _, _ in chunks], dim=2) + assert concatenated.shape[2] == _NUM_CHUNKS * _FRAMES_PER_BLOCK diff --git a/tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py b/tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py new file mode 100644 index 000000000..0f078049f --- /dev/null +++ b/tests/e2e/online_serving/test_lingbot_world_v2_stepwise.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""End-to-end streaming serving smoke test for LingBot-World v2. + +One ``WS /v1/realtime/video`` session must drive the whole rollout and return +one video chunk per AR block. This is the entry point the recipe documents; +``tests/e2e/offline_inference/test_lingbot_world_v2_stepwise.py`` covers the +same step-execution contract at the latent level, below the transport. +""" + +from __future__ import annotations + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import asyncio +import base64 +import json +import mimetypes +from pathlib import Path +from typing import Any + +import pytest +from vllm.assets.image import ImageAsset + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = os.environ.get( + "VLLM_OMNI_LINGBOT_WORLD_V2_CHECKPOINT_PATH", + "robbyant/lingbot-world-v2-14b-causal-fast-diffusers", +) +# The pipeline resizes the first frame to the requested geometry, so any real +# photograph conditions the rollout; the shared vLLM asset keeps the test +# runnable with no environment setup and adds no binary to this repository. +_IMAGE_ASSET = "2560px-Gfp-wisconsin-madison-the-nature-boardwalk" +_IMAGE_PATH_OVERRIDE = os.environ.get("VLLM_OMNI_LINGBOT_WORLD_V2_IMAGE_PATH") + +# The AR cache geometry is fixed at load time, so a request must ask for +# exactly the resolution the deploy config declares. +_HEIGHT = 480 +_WIDTH = 832 +_FRAMES_PER_BLOCK = 3 +_TEMPORAL_COMPRESSION = 4 +# One three-frame action list per generated chunk. +_CAMERA_ACTION_SCRIPT = [ + [["w"], ["w"], ["w"]], + [["a"], [], []], + [[], [], []], +] +_NUM_CHUNKS = len(_CAMERA_ACTION_SCRIPT) +_NUM_FRAMES = (_NUM_CHUNKS * _FRAMES_PER_BLOCK - 1) * _TEMPORAL_COMPRESSION + 1 +_PROMPT = "The camera moves slowly forward through the scene." + +pytestmark = [ + pytest.mark.slow, + pytest.mark.diffusion, +] + + +def _first_frame() -> Path: + if _IMAGE_PATH_OVERRIDE: + return Path(_IMAGE_PATH_OVERRIDE).expanduser().resolve() + return Path(ImageAsset(_IMAGE_ASSET).get_path("jpg")) + + +lingbot_world_v2_stepwise_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=get_deploy_config_path("lingbot_world_v2_stepwise.yaml"), + ), + id="lingbot_world_v2_stepwise", + ) +] + + +def _session_start(model: str, image: Path) -> dict[str, Any]: + media_type = mimetypes.guess_type(image.name)[0] or "image/png" + encoded = base64.b64encode(image.read_bytes()).decode() + return { + "type": "session.start", + "model": model, + "prompt": _PROMPT, + # LingBot is image-conditioned: without a first frame the request is + # rejected during pre-processing. + "image_reference": {"image_url": f"data:{media_type};base64,{encoded}"}, + "width": _WIDTH, + "height": _HEIGHT, + "num_frames": _NUM_FRAMES, + "fps": 16, + "seed": 42, + "extra_params": {"flow_shift": 5.0, "camera_action_script": _CAMERA_ACTION_SCRIPT}, + } + + +async def _stream_session(url: str, model: str, image: Path) -> tuple[list[dict[str, Any]], bytes, dict[str, Any]]: + import websockets + + events: list[dict[str, Any]] = [] + media = bytearray() + done: dict[str, Any] = {} + async with websockets.connect(url, max_size=None, ping_interval=None) as websocket: + await websocket.send(json.dumps(_session_start(model, image))) + while True: + message = await asyncio.wait_for(websocket.recv(), timeout=900) + if isinstance(message, bytes): + media.extend(message) + continue + event = json.loads(message) + events.append(event) + if event["type"] == "error": + pytest.fail(f"server returned an error event: {event}") + if event["type"] == "session.done": + done = event + break + return events, bytes(media), done + + +@hardware_test(res={"cuda": "H100"}, num_cards=1) +@pytest.mark.parametrize("omni_server", lingbot_world_v2_stepwise_server_params, indirect=True) +def test_lingbot_world_v2_stepwise_streams_one_chunk_per_block(omni_server) -> None: + """Serve the real checkpoint and stream N chunks from a single WS session.""" + + url = f"ws://{omni_server.host}:{omni_server.port}/v1/realtime/video" + events, media, done = asyncio.run(_stream_session(url, omni_server.model, _first_frame())) + + started = [event for event in events if event["type"] == "video.start"] + assert len(started) == 1, "one session.start must open exactly one rollout" + + # A trailer chunk may follow the media chunks; only media chunks carry a + # generation index, and there is exactly one per AR block. + media_chunks = [event for event in events if event["type"] == "video.chunk_metadata" and event["kind"] == "media"] + assert [event["generation_chunk_index"] for event in media_chunks] == list(range(_NUM_CHUNKS)) + for event in media_chunks: + assert event["request_id"] == started[0]["request_id"] + assert event["num_frames"] > 0 + assert event["byte_length"] > 0 + + assert len(media) == sum(event["byte_length"] for event in events if event["type"] == "video.chunk_metadata") + assert done["stopped"] is False + assert done["chunks"] >= _NUM_CHUNKS diff --git a/tests/engine/test_diffusion_streaming_output_kwarg.py b/tests/engine/test_diffusion_streaming_output_kwarg.py new file mode 100644 index 000000000..a6f3dff02 --- /dev/null +++ b/tests/engine/test_diffusion_streaming_output_kwarg.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""``diffusion_streaming_output`` must reach a diffusion stage as ``streaming_output``. + +The public AsyncOmni/serve kwarg and the stage schema field have different +names, and the two config paths translate it in different places: the +unregistered single-stage fallback writes it into the default stage config, +while a registered pipeline resolves through ``StageConfigFactory``, which drops +keys the stage schema does not know. Without the mirror in +``_resolve_stage_configs`` a registered pipeline (LingBot World stepwise) serves +only its final chunk instead of streaming one per step. +""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from vllm_omni.engine.async_omni_engine import AsyncOmniEngine + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _resolve(monkeypatch, kwargs: dict[str, Any]) -> dict[str, Any]: + """Run ``_resolve_stage_configs`` and return the kwargs it forwarded.""" + + seen: dict[str, Any] = {} + + def _fake_load(model, forwarded_kwargs, **_: Any): + seen.update(forwarded_kwargs) + return "config.yaml", [], None + + monkeypatch.setattr( + "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", + _fake_load, + ) + engine = SimpleNamespace( + _create_default_diffusion_stage_cfg=AsyncOmniEngine._create_default_diffusion_stage_cfg, + _apply_strategy_lb_policy=lambda *_args, **_kw: None, + ) + AsyncOmniEngine._resolve_stage_configs( + engine, + "some/model", + kwargs, + trust_remote_code=None, + ) + return seen + + +def test_registered_pipeline_receives_streaming_output(monkeypatch) -> None: + forwarded = _resolve(monkeypatch, {"diffusion_streaming_output": True}) + + assert forwarded["streaming_output"] is True + + +def test_disabled_kwarg_does_not_override_the_deploy_config(monkeypatch) -> None: + # ``serve`` always carries the flag's ``False`` default; mirroring it would + # silently turn streaming off for a deploy YAML that asks for it. + forwarded = _resolve(monkeypatch, {"diffusion_streaming_output": False}) + + assert "streaming_output" not in forwarded + + +def test_explicit_streaming_output_wins(monkeypatch) -> None: + forwarded = _resolve( + monkeypatch, + {"diffusion_streaming_output": True, "streaming_output": False}, + ) + + assert forwarded["streaming_output"] is False + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_unregistered_fallback_stage_carries_the_flag(enabled: bool) -> None: + stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg({"diffusion_streaming_output": enabled})[0] + + assert stage_cfg["engine_args"]["streaming_output"] is enabled diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 83f8de383..5ac47dddc 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -69,6 +69,7 @@ from vllm_omni.model_executor.models.indextts2.pipeline import ( INDEXTTS25_PIPELINE, ) from vllm_omni.model_executor.models.lance.pipeline import LANCE_PIPELINE +from vllm_omni.model_executor.models.lingbot_world.pipeline import LINGBOT_WORLD_PIPELINE from vllm_omni.model_executor.models.mammoth_moda2.pipeline import ( MAMMOTH_MODA2_AR_PIPELINE, MAMMOTH_MODA2_PIPELINE, @@ -141,6 +142,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = { "bagel_single_stage": BAGEL_SINGLE_STAGE_PIPELINE, "lance": LANCE_PIPELINE, "dreamzero": DREAMZERO_PIPELINE, + "lingbot_world": LINGBOT_WORLD_PIPELINE, "Gr00tN1d7": GR00T_N1D7_PIPELINE, "pi0": PI0_PIPELINE, "gepard": GEPARD_PIPELINE, diff --git a/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml b/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml new file mode 100644 index 000000000..3fc950e4c --- /dev/null +++ b/vllm_omni/deploy/lingbot_world_v2_stepwise.yaml @@ -0,0 +1,41 @@ +# LingBot-World 2.0 stepwise deploy: single diffusion stage for WS /v1/realtime/video. +# +# One AsyncOmni request is one rollout: prepare_encode runs once, then every +# four DMD steps stream one AR block. Step execution keeps AR-Diffusion paged KV +# on ARDiffusionModelRunner, so this stage must stay at max_num_seqs=1. +# +# The cache geometry is fixed at load time, so ar_diffusion_height/width must +# match the height/width a client asks for. + +pipeline: lingbot_world +async_chunk: false +distributed_executor_backend: mp +dtype: bfloat16 + +stages: + - stage_id: 0 + devices: "0" + max_num_seqs: 1 + enforce_eager: true + model_class_name: LingBotWorldCausalDMDPipeline + # LingBot runs on the AR-Diffusion engine (engine-level paged KV cache). + engine_backend: vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine + # One request, many streamed chunks. streaming_output implies step_execution, + # but both are stated here so the topology is readable on its own. + step_execution: true + streaming_output: true + # num_inference_steps is for the step scheduler, not the pipeline: it sizes a + # request's step budget before the pipeline is reached, so a served request + # does not have to carry it. The pipeline itself pins the causal-fast + # checkpoint's four-step DMD schedule and rejects any other count. + default_sampling_params: + num_inference_steps: 4 + output_type: pil + extra_args: + flow_shift: 5.0 + model_config: + ar_diffusion_height: 480 + ar_diffusion_width: 832 + ar_diffusion_kv_config: + gpu_memory_fraction: 0.6 + warmup_cudagraph: true diff --git a/vllm_omni/diffusion/models/lingbot_world/actions.py b/vllm_omni/diffusion/models/lingbot_world/actions.py index e725c698a..4720b041e 100644 --- a/vllm_omni/diffusion/models/lingbot_world/actions.py +++ b/vllm_omni/diffusion/models/lingbot_world/actions.py @@ -1,13 +1,13 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Realtime keyboard controls for LingBot World 2.0.""" from __future__ import annotations import math -from collections.abc import Mapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from typing import Any +from typing import Any, TypeAlias import numpy as np import torch @@ -42,7 +42,31 @@ def _normalize_actions(value: object, *, field: str) -> tuple[str, ...]: return tuple(action for action in _ACTION_ORDER if action in actions) -def _normalize_frames(value: object, *, field: str) -> tuple[tuple[str, ...], ...]: +#: Key states for one latent frame each, e.g. ``(("w",), ("w", "j"), ())`` for +#: the three latent frames of one AR block. +LingBotCameraActionFrames: TypeAlias = tuple[tuple[str, ...], ...] + +#: One :data:`LingBotCameraActionFrames` per generated chunk, carried on the +#: request for the whole rollout. +LingBotCameraActionScript: TypeAlias = tuple[LingBotCameraActionFrames, ...] + + +def as_camera_action_frames(value: Iterable[Iterable[str]]) -> LingBotCameraActionFrames: + """Restore the tuple form of one chunk's per-latent-frame key states.""" + return tuple(tuple(frame) for frame in value) + + +def as_camera_action_script(value: Iterable[Iterable[Iterable[str]]]) -> LingBotCameraActionScript: + """Restore the tuple form of a whole request's script. + + ``sampling_params.extra_args`` round-trips through JSON, so an already + validated script comes back as lists; this rebuilds the hashable tuple form + without re-running validation. + """ + return tuple(as_camera_action_frames(chunk) for chunk in value) + + +def _normalize_frames(value: object, *, field: str) -> LingBotCameraActionFrames: if not isinstance(value, Sequence) or isinstance(value, (str, bytes)): raise ValueError(f"{field} must be a sequence of per-frame action lists.") return tuple(_normalize_actions(actions, field=f"{field}[{index}]") for index, actions in enumerate(value)) @@ -52,7 +76,7 @@ def parse_lingbot_camera_action_frames( data: Mapping[str, Any], *, expected_frames: int, -) -> tuple[tuple[str, ...], ...]: +) -> LingBotCameraActionFrames: """Validate the chunk-sized payload emitted by the session reducer.""" if data.get("mode") != "frames": @@ -66,6 +90,30 @@ def parse_lingbot_camera_action_frames( return frames +def parse_lingbot_camera_action_script( + script: object, + *, + frames_per_chunk: int, +) -> LingBotCameraActionScript: + """Validate a request-scoped list of per-chunk camera action frames.""" + + if not isinstance(script, Sequence) or isinstance(script, (str, bytes)): + raise ValueError("camera_action_script must be a sequence of per-chunk action lists.") + if not script: + raise ValueError("camera_action_script must contain at least one chunk.") + chunks: list[LingBotCameraActionFrames] = [] + for index, chunk in enumerate(script): + frames = _normalize_frames(chunk, field=f"camera_action_script[{index}]") + if len(frames) != frames_per_chunk: + raise ValueError( + "camera_action_script chunks must contain exactly " + f"{frames_per_chunk} per-latent-frame action lists; " + f"chunk {index} has {len(frames)}." + ) + chunks.append(frames) + return tuple(chunks) + + @dataclass(frozen=True) class _LingBotCameraReducerState: mode: str | None = None diff --git a/vllm_omni/diffusion/models/lingbot_world/dmd_block.py b/vllm_omni/diffusion/models/lingbot_world/dmd_block.py new file mode 100644 index 000000000..eace73a41 --- /dev/null +++ b/vllm_omni/diffusion/models/lingbot_world/dmd_block.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""One AR block of LingBot World's causal DMD sampler. + +The pipeline drives this in two ways -- request mode loops ``generate_block`` +over every block, stepwise execution calls ``probe_step`` / ``apply_transition`` +one denoise step at a time and ``commit_block_kv`` from ``post_decode`` -- so the +math lives here once and cannot drift between the two modes. + +A block is four probes that must not touch KV, then one clean-x0 commit that +does. Whether a call sees paged (session-bound) or request-local KV is decided +by the ``ar`` argument, so this class never reaches back into the pipeline. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast + +import torch +from diffusers.utils.torch_utils import randn_tensor + +from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx +from vllm_omni.diffusion.models.lingbot_world.transformer import ( + LingBotAttentionCache, + LingBotTransformerCache, +) + +if TYPE_CHECKING: + from vllm_omni.diffusion.models.lingbot_world.transformer import CausalLingBotWorldTransformer3DModel + from vllm_omni.diffusion.models.progress_bar import TqdmProgressBar + from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState + + +@dataclass(frozen=True) +class ARBlockContext: + """The bound AR-Diffusion session one block runs against. + + Built by the pipeline from the state the runner bound for this invocation; + ``None`` in place of a context means request-local contiguous KV. + """ + + state: ARDiffusionKVState + cross_attention: list[LingBotAttentionCache] + branch: str + + +class LingBotDMDBlockRunner: + """Runs the DMD probes and the KV commit for one latent-frame block.""" + + def __init__( + self, + transformer: CausalLingBotWorldTransformer3DModel, + *, + device: torch.device, + enforce_eager: bool, + ) -> None: + self.transformer = transformer + self.device = device + self.enforce_eager = enforce_eager + + # ── cache selection ────────────────────────────────────────────────── + + def _paged_cache( + self, + *, + condition: torch.Tensor, + ar: ARBlockContext, + commit_current: bool, + ) -> LingBotTransformerCache: + patch_frames, patch_height, patch_width = self.transformer.config.patch_size + frames = condition.shape[2] // patch_frames + height = condition.shape[3] // patch_height + width = condition.shape[4] // patch_width + return LingBotTransformerCache( + self_attention=ar.state.get_kv_caches( + ar.branch, + seq_len=frames * height * width, + commit_current=commit_current, + ), + cross_attention=ar.cross_attention, + ) + + def _block_cache( + self, + *, + condition: torch.Tensor, + cache: LingBotTransformerCache | None, + ar: ARBlockContext | None, + commit_current: bool, + ) -> LingBotTransformerCache: + """Pick the cache one transformer call sees: paged when a session is + bound, else the caller's request-local cache.""" + if ar is not None: + return self._paged_cache(condition=condition, ar=ar, commit_current=commit_current) + return cast(LingBotTransformerCache, cache) + + # ── one block ──────────────────────────────────────────────────────── + + def probe_step( + self, + *, + current_latents: torch.Tensor, + condition: torch.Tensor, + camera: torch.Tensor, + prompt_embeds: torch.Tensor, + cache: LingBotTransformerCache | None, + ar: ARBlockContext | None, + start_frame: int, + timestep_value: float, + step_index: int, + ) -> torch.Tensor: + """Predict flow for one denoise step. + + A probe never writes KV: only the clean x0 of a finished block may + enter the cache, which is ``commit_block_kv``'s job. + """ + if not self.enforce_eager: + torch.compiler.cudagraph_mark_step_begin() + set_forward_context_denoise_step_idx(step_index) + timestep = torch.full((1,), float(timestep_value), device=self.device, dtype=torch.float32) + # Checkpoint channel contract: + # [noise/x_t(16), temporal_mask(4), image_latent(16)] -> 36. + model_input = torch.cat((current_latents.to(dtype=condition.dtype), condition), dim=1) + flow_prediction = self.transformer( + hidden_states=model_input, + timestep=timestep, + encoder_hidden_states=prompt_embeds, + camera_hidden_states=camera, + cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=False), + start_frame=start_frame, + update_cache=False, + ) + if flow_prediction.shape != current_latents.shape: + raise RuntimeError( + "transformer flow prediction shape must match the 16-channel noise latent, " + f"got {tuple(flow_prediction.shape)} and {tuple(current_latents.shape)}." + ) + return flow_prediction + + def apply_transition( + self, + current_latents: torch.Tensor, + flow_prediction: torch.Tensor, + sigma: float, + *, + next_sigma: float | None, + generator: torch.Generator, + ) -> torch.Tensor: + """Invert the flow to x0, then re-noise at ``next_sigma``. + + ``next_sigma=None`` marks the final step, so where the caller sits in + its own loop stays out of this function. + """ + # The checkpoint's flow parameterization is inverted by + # x0 = x_t - sigma * flow. Intermediate steps re-noise that x0 + # estimate at the next sigma; the final step keeps x0 as this + # block's generated latent. + x0 = current_latents - sigma * flow_prediction.float() + if next_sigma is None: + return x0 + noise = randn_tensor(current_latents.shape, generator=generator, device=self.device, dtype=torch.float32) + return (1.0 - next_sigma) * x0 + next_sigma * noise + + def commit_block_kv( + self, + *, + latents: torch.Tensor, + condition: torch.Tensor, + camera: torch.Tensor, + prompt_embeds: torch.Tensor, + cache: LingBotTransformerCache | None, + ar: ARBlockContext | None, + start_frame: int, + ) -> None: + """Write the finished block's clean x0 into KV and commit its pages. + + The fifth transformer call of a block, deliberately not a denoise step: + on the stepwise path it belongs to ``post_decode()``. + """ + # Commit K/V only for the final clean block, never for noisy probes. + cache_input = torch.cat((latents.to(dtype=condition.dtype), condition), dim=1) + if not self.enforce_eager: + torch.compiler.cudagraph_mark_step_begin() + self.transformer( + hidden_states=cache_input, + timestep=torch.zeros(1, device=self.device, dtype=torch.float32), + encoder_hidden_states=prompt_embeds, + camera_hidden_states=camera, + cache=self._block_cache(condition=condition, cache=cache, ar=ar, commit_current=True), + start_frame=start_frame, + update_cache=True, + ) + if ar is not None: + ar.state.commit_paged_context(ar.branch) + + def generate_block( + self, + *, + condition: torch.Tensor, + camera: torch.Tensor, + prompt_embeds: torch.Tensor, + cache: LingBotTransformerCache | None, + ar: ARBlockContext | None, + start_frame: int, + schedule: tuple[tuple[float, float], ...], + generator: torch.Generator, + progress_bar: TqdmProgressBar[Any], + ) -> torch.Tensor: + """Request mode: all probes of one block, then its commit.""" + block_shape = (1, self.transformer.config.out_channels, *condition.shape[2:5]) + current_latents = randn_tensor(block_shape, generator=generator, device=self.device, dtype=torch.float32) + for step_index, (timestep_value, sigma) in enumerate(schedule): + flow_prediction = self.probe_step( + current_latents=current_latents, + condition=condition, + camera=camera, + prompt_embeds=prompt_embeds, + cache=cache, + ar=ar, + start_frame=start_frame, + timestep_value=timestep_value, + step_index=step_index, + ) + next_sigma = schedule[step_index + 1][1] if step_index + 1 < len(schedule) else None + current_latents = self.apply_transition( + current_latents, flow_prediction, sigma, next_sigma=next_sigma, generator=generator + ) + progress_bar.update() + self.commit_block_kv( + latents=current_latents, + condition=condition, + camera=camera, + prompt_embeds=prompt_embeds, + cache=cache, + ar=ar, + start_frame=start_frame, + ) + return current_latents diff --git a/vllm_omni/diffusion/models/lingbot_world/pipeline.py b/vllm_omni/diffusion/models/lingbot_world/pipeline.py index 5f71979e8..b41abd861 100644 --- a/vllm_omni/diffusion/models/lingbot_world/pipeline.py +++ b/vllm_omni/diffusion/models/lingbot_world/pipeline.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Request-scoped LingBot-World v2 causal DMD pipeline.""" from __future__ import annotations import math import os -from collections.abc import Callable, Iterable, Iterator +from collections.abc import Callable, Iterable, Iterator, Sequence from contextlib import contextmanager from dataclasses import dataclass, replace from typing import TYPE_CHECKING, Any, ClassVar, cast @@ -24,15 +24,19 @@ from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import DistributedAutoencoderKLWan from vllm_omni.diffusion.distributed.utils import get_local_device -from vllm_omni.diffusion.forward_context import set_forward_context_denoise_step_idx from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.model_loader.hub_prefetch import from_pretrained_with_prefetch, prefetch_subfolders -from vllm_omni.diffusion.models.interface import SupportImageInput, SupportsComponentDiscovery +from vllm_omni.diffusion.models.interface import SupportImageInput, SupportsComponentDiscovery, SupportsStepExecution from vllm_omni.diffusion.models.lingbot_world.actions import ( LINGBOT_CAMERA_ACTION_SCHEMA, LINGBOT_CAMERA_TRAJECTORY_SCHEMA, + LingBotCameraActionFrames, + LingBotCameraActionScript, + as_camera_action_frames, + as_camera_action_script, integrate_lingbot_camera_actions, parse_lingbot_camera_action_frames, + parse_lingbot_camera_action_script, ) from vllm_omni.diffusion.models.lingbot_world.camera import ( CameraTrajectory, @@ -41,6 +45,7 @@ from vllm_omni.diffusion.models.lingbot_world.camera import ( load_camera_trajectory, resolve_trusted_action_directory, ) +from vllm_omni.diffusion.models.lingbot_world.dmd_block import ARBlockContext, LingBotDMDBlockRunner from vllm_omni.diffusion.models.lingbot_world.transformer import ( CausalLingBotWorldTransformer3DModel, LingBotAttentionCache, @@ -64,8 +69,11 @@ from vllm_omni.experimental.ar_diffusion.tick_protocol import ( ) if TYPE_CHECKING: + from diffusers.video_processor import VideoProcessor from tqdm.std import tqdm as TqdmProgressBar + from vllm_omni.diffusion.worker.input_batch import InputBatch + from vllm_omni.diffusion.worker.utils import StepRequestState from vllm_omni.experimental.ar_diffusion.kv_cache.state import ( ARDiffusionKVState, ) @@ -79,6 +87,7 @@ _MAX_SEQUENCE_LENGTH = 512 _ACTION_ROOT_ENV = "VLLM_OMNI_LINGBOT_ACTION_ROOT" _PREPROCESSED_CAMERA_KEY = "_lingbot_camera_trajectory" _PREPROCESSED_CAMERA_ACTIONS_KEY = "_lingbot_camera_actions" +_PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY = "_lingbot_camera_action_script" _SOURCE_IMAGE_ERROR = ( "Unable to load multi_modal_data.image; expected a decodable image within 4096 * 4096 source pixels." ) @@ -86,12 +95,34 @@ _SOURCE_IMAGE_ERROR = ( @dataclass(frozen=True) class _LingBotRequestInputs: - """Validated model-specific request boundary.""" + """Validated model-specific request boundary. + + Pre-processing materializes exactly one of the three camera fields, chosen + from the shape of the incoming control. They are not alternative spellings + of one input: ``camera_action_script`` is a *container* whose element is a + ``camera_actions``, and the two are live at once during step execution. + + - ``camera_trajectory`` is explicit per-frame poses and intrinsics. Offline + replay loads it from ``extra_args["action_path"]``; the tick entry point + receives the same content as a ``lingbot.camera_trajectory.v1`` control. + Integrating WASD actions also produces one, written back onto this field. + - ``camera_actions`` is WASD key state for *one* three-latent-frame block: + the unit every path reduces to before the camera is embedded. The tick + entry point fills it directly, because one of its requests is one block; + step execution derives it per block by slicing ``camera_action_script``. + ``_prepare_camera`` also reads it as the signal that the trajectory it + was handed is already latent-aligned. + - ``camera_action_script`` holds one such action list *per generated chunk* + for the whole rollout, which only step execution needs, because only it + runs every block inside a single ``generate()``. The tick entry point has + no use for it and ``forward()`` rejects a request that carries it. + """ prompt: str image: PIL.Image.Image | torch.Tensor camera_trajectory: CameraTrajectory | None - camera_actions: tuple[tuple[str, ...], ...] | None + camera_actions: LingBotCameraActionFrames | None + camera_action_script: LingBotCameraActionScript | None height: int width: int num_frames: int @@ -303,6 +334,16 @@ def get_lingbot_world_pre_process_func( "LingBot realtime camera controls require schema " "'lingbot.camera_trajectory.v1' or 'lingbot.camera_actions.v1'." ) + camera_action_script = None + elif extra_args.get("camera_action_script") is not None: + if extra_args.get("action_path"): + raise ValueError("camera_action_script cannot be combined with action_path.") + camera_action_script = parse_lingbot_camera_action_script( + extra_args.get("camera_action_script"), + frames_per_chunk=3, + ) + trajectory = None + camera_actions = None else: action_path = extra_args.get("action_path") if not isinstance(action_path, (str, os.PathLike)) or not str(action_path): @@ -323,6 +364,7 @@ def get_lingbot_world_pre_process_func( "Unable to load camera trajectory from action_path; expected poses.npy and intrinsics.npy." ) from None camera_actions = None + camera_action_script = None updated_prompt = dict(prompt) updated_multi_modal_data = dict(multi_modal_data) @@ -333,6 +375,7 @@ def get_lingbot_world_pre_process_func( **extra_args, _PREPROCESSED_CAMERA_KEY: trajectory, _PREPROCESSED_CAMERA_ACTIONS_KEY: camera_actions, + _PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY: camera_action_script, } return request @@ -377,11 +420,13 @@ class LingBotWorldCausalDMDPipeline( nn.Module, SupportImageInput, SupportsComponentDiscovery, + SupportsStepExecution, ProgressBarMixin, DiffusionPipelineProfilerMixin, ): """LingBot-World v2 I2V generation with a request-local causal cache.""" + supports_step_execution: ClassVar[bool] = True _dit_modules: ClassVar[list[str]] = ["transformer"] _encoder_modules: ClassVar[list[str]] = ["text_encoder"] _vae_modules: ClassVar[list[str]] = ["vae"] @@ -617,11 +662,17 @@ class LingBotWorldCausalDMDPipeline( raise ValueError("sampling_params.extra_args must be a mapping.") camera_trajectory = extra_args.get(_PREPROCESSED_CAMERA_KEY) camera_actions = extra_args.get(_PREPROCESSED_CAMERA_ACTIONS_KEY) + camera_action_script = extra_args.get(_PREPROCESSED_CAMERA_ACTION_SCRIPT_KEY) if camera_trajectory is not None and not isinstance(camera_trajectory, CameraTrajectory): raise ValueError("LingBot camera trajectory must be materialized by the pre-process function.") if camera_actions is not None: - camera_actions = tuple(tuple(actions) for actions in camera_actions) - if (camera_trajectory is None) == (camera_actions is None): + camera_actions = as_camera_action_frames(camera_actions) + if camera_action_script is not None: + camera_action_script = as_camera_action_script(camera_action_script) + camera_sources = ( + int(camera_trajectory is not None) + int(camera_actions is not None) + int(camera_action_script is not None) + ) + if camera_sources != 1: raise ValueError("LingBot pre-processing must materialize exactly one camera input.") request_flow_shift = ( @@ -720,6 +771,7 @@ class LingBotWorldCausalDMDPipeline( image=image, camera_trajectory=camera_trajectory, camera_actions=camera_actions, + camera_action_script=camera_action_script, height=height, width=width, num_frames=num_frames, @@ -818,6 +870,7 @@ class LingBotWorldCausalDMDPipeline( *, dtype: torch.dtype, previous: CameraTrajectory | None = None, + latent_aligned: bool = False, ) -> tuple[torch.Tensor, CameraTrajectory]: """Convert raw camera frames to a latent-aligned ray tensor.""" @@ -825,7 +878,7 @@ class LingBotWorldCausalDMDPipeline( if trajectory is None: raise RuntimeError("LingBot camera trajectory was not prepared before camera embedding.") available_frames = int(trajectory.poses.shape[0]) - if inputs.camera_actions is not None: + if inputs.camera_actions is not None or latent_aligned: if available_frames != inputs.num_latent_frames: raise ValueError( "camera actions must produce exactly one pose per latent frame; " @@ -942,29 +995,38 @@ class LingBotWorldCausalDMDPipeline( ) ] - def _ar_transformer_cache( + # ── DMD block helpers ───────────────────────────────────────────────── + # + # The DMD math for one AR block lives in ``LingBotDMDBlockRunner`` so that + # request mode (``_generate_block`` in a loop) and stepwise execution + # (``denoise_step`` / ``step_scheduler`` / ``post_decode``) share one copy. + # The pipeline's only jobs here are building the runner and translating + # the bound AR session into an ``ARBlockContext``. + + @property + def _dmd_blocks(self) -> LingBotDMDBlockRunner: + runner = getattr(self, "_dmd_block_runner", None) + if runner is None: + runner = LingBotDMDBlockRunner( + self.transformer, + device=self.device, + enforce_eager=bool(self.od_config.enforce_eager), + ) + self._dmd_block_runner = runner + return runner + + def _ar_block_context( self, - *, - condition: torch.Tensor, - cross_attention: list[LingBotAttentionCache], - commit_current: bool, - ) -> LingBotTransformerCache: + cross_attention: list[LingBotAttentionCache] | None, + ) -> ARBlockContext | None: + """Wrap the bound session for the block runner; ``None`` keeps + request-local KV.""" + if cross_attention is None: + return None state = self._ar_diffusion_kv_state if state is None: raise RuntimeError("LingBot AR cache requested without a bound state.") - patch_frames, patch_height, patch_width = self.transformer.config.patch_size - frames = condition.shape[2] // patch_frames - height = condition.shape[3] // patch_height - width = condition.shape[4] // patch_width - seq_len = frames * height * width - return LingBotTransformerCache( - self_attention=state.get_kv_caches( - self._AR_BRANCH, - seq_len=seq_len, - commit_current=commit_current, - ), - cross_attention=cross_attention, - ) + return ARBlockContext(state=state, cross_attention=cross_attention, branch=self._AR_BRANCH) def _generate_block( self, @@ -979,102 +1041,17 @@ class LingBotWorldCausalDMDPipeline( generator: torch.Generator, progress_bar: TqdmProgressBar[Any], ) -> torch.Tensor: - block_shape = ( - 1, - self.transformer.config.out_channels, - condition.shape[2], - condition.shape[3], - condition.shape[4], - ) - current_latents = randn_tensor( - block_shape, - generator=generator, - device=self.device, - dtype=torch.float32, - ) - for step_index, (timestep_value, sigma) in enumerate(schedule): - if not self.od_config.enforce_eager: - torch.compiler.cudagraph_mark_step_begin() - set_forward_context_denoise_step_idx(step_index) - timestep = torch.full((1,), float(timestep_value), device=self.device, dtype=torch.float32) - # Checkpoint channel contract: - # [noise/x_t(16), temporal_mask(4), image_latent(16)] -> 36. - model_input = torch.cat((current_latents.to(dtype=condition.dtype), condition), dim=1) - current_cache = ( - self._ar_transformer_cache( - condition=condition, - cross_attention=cast( - list[LingBotAttentionCache], - ar_cross_attention, - ), - commit_current=False, - ) - if ar_cross_attention is not None - else cast(LingBotTransformerCache, cache) - ) - flow_prediction = self.transformer( - hidden_states=model_input, - timestep=timestep, - encoder_hidden_states=prompt_embeds, - camera_hidden_states=camera, - cache=current_cache, - start_frame=start_frame, - update_cache=False, - ) - if flow_prediction.shape != current_latents.shape: - raise RuntimeError( - "transformer flow prediction shape must match the 16-channel noise latent, " - f"got {tuple(flow_prediction.shape)} and {tuple(current_latents.shape)}." - ) - # The checkpoint's flow parameterization is inverted by - # x0 = x_t - sigma * flow. Intermediate steps re-noise that x0 - # estimate at the next sigma; the final step keeps x0 as this - # block's generated latent. - x0 = current_latents - sigma * flow_prediction.float() - if step_index + 1 < len(schedule): - next_sigma = schedule[step_index + 1][1] - noise = randn_tensor( - current_latents.shape, - generator=generator, - device=self.device, - dtype=torch.float32, - ) - current_latents = (1.0 - next_sigma) * x0 + next_sigma * noise - else: - current_latents = x0 - progress_bar.update() - - # Commit K/V only for the final clean block, never for noisy probes. - cache_input = torch.cat((current_latents.to(dtype=condition.dtype), condition), dim=1) - commit_cache = ( - self._ar_transformer_cache( - condition=condition, - cross_attention=cast( - list[LingBotAttentionCache], - ar_cross_attention, - ), - commit_current=True, - ) - if ar_cross_attention is not None - else cast(LingBotTransformerCache, cache) - ) - if not self.od_config.enforce_eager: - torch.compiler.cudagraph_mark_step_begin() - self.transformer( - hidden_states=cache_input, - timestep=torch.zeros(1, device=self.device, dtype=torch.float32), - encoder_hidden_states=prompt_embeds, - camera_hidden_states=camera, - cache=commit_cache, + return self._dmd_blocks.generate_block( + condition=condition, + camera=camera, + prompt_embeds=prompt_embeds, + cache=cache, + ar=self._ar_block_context(ar_cross_attention), start_frame=start_frame, - update_cache=True, + schedule=schedule, + generator=generator, + progress_bar=progress_bar, ) - if ar_cross_attention is not None: - state = self._ar_diffusion_kv_state - if state is None: - raise RuntimeError("LingBot AR state disappeared before KV commit.") - state.commit_paged_context(self._AR_BRANCH) - return current_latents def encode_prompt( self, @@ -1100,6 +1077,15 @@ class LingBotWorldCausalDMDPipeline( def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: inputs = self._parse_request(req) + if inputs.camera_action_script is not None: + # Only step execution walks a whole rollout inside one request, so + # a per-chunk script here would be silently dropped rather than + # steering the camera. + raise ValueError( + "camera_action_script is read only by LingBot step execution; " + "request mode takes one three-frame camera_actions control per " + "block, or a camera_trajectory for the request." + ) tick = ARDiffusionTickRequest.from_extra_args(req.sampling_params.extra_args) if tick is not None and self._ar_diffusion_kv_state is None: raise RuntimeError("LingBot typed ticks require ARDiffusionEngine session binding.") @@ -1287,6 +1273,317 @@ class LingBotWorldCausalDMDPipeline( stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, ) + def _decode_chunk_to_pixels( + self, latents: torch.Tensor, *, output_type: str + ) -> torch.Tensor | np.ndarray | list[list[PIL.Image.Image]]: + """Decode one AR block so streaming consumers receive pixels, not latents. + + Blocks are decoded independently, which keeps the shared VAE stateless + and lets a rollout be served without a per-session temporal cache. That + is the same shape Helios streams today; cross-block ``feat_cache`` + continuity is a separate decision and is not made here. + + The registered post-process hook returns a ``{"payload", "metadata"}`` + envelope untouched, so the pixel conversion it would do for a bare + tensor has to happen here instead. + """ + latent_mean, latent_std = self._vae_latent_stats(latents) + vae_latents = (latents * latent_std + latent_mean).to(dtype=self.vae.dtype) + video = self.vae.decode(vae_latents, return_dict=False)[0] + return self._video_processor().postprocess_video(video, output_type=output_type) + + def _video_processor(self) -> VideoProcessor: + processor = getattr(self, "_cached_video_processor", None) + if processor is None: + from diffusers.video_processor import VideoProcessor + + processor = VideoProcessor(vae_scale_factor=self.vae_scale_factor_spatial) + self._cached_video_processor = processor + return processor + + def _horizon_latent_frames(self) -> int: + return (_MAX_RAW_FRAMES - 1) // self.vae_scale_factor_temporal + 1 + + def _max_realtime_chunks(self) -> int: + return self._horizon_latent_frames() // int(self.transformer.config.num_frames_per_block) + + def _require_bound_ar_state(self) -> None: + if self._ar_diffusion_kv_state is None: + raise RuntimeError("LingBot step execution requires AR-Diffusion session binding.") + + def prepare_encode(self, state: StepRequestState, **kwargs: Any) -> StepRequestState: + del kwargs + self._require_bound_ar_state() + req = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest( + prompt=state.prompt, + sampling_params=state.sampling, + request_id=state.request_id, + ) + ] + ) + inputs = self._parse_request(req) + if ARDiffusionTickRequest.from_extra_args(state.sampling.extra_args) is not None: + raise ValueError("LingBot step execution does not accept AR-Diffusion ticks.") + if (inputs.height, inputs.width) != (self._ar_height, self._ar_width): + raise ValueError( + "LingBot AR-Diffusion request resolution must match the " + f"fixed cache geometry {self._ar_height}x{self._ar_width}." + ) + block_frames = int(self.transformer.config.num_frames_per_block) + total_chunks = inputs.num_latent_frames // block_frames + max_chunks = self._max_realtime_chunks() + if total_chunks > max_chunks: + raise ValueError( + "LingBot step execution currently supports at most " + f"{max_chunks} chunks because the image-condition horizon is " + f"{_MAX_RAW_FRAMES} pixel frames." + ) + if inputs.camera_action_script is not None and len(inputs.camera_action_script) != total_chunks: + raise ValueError( + "camera_action_script must contain one action list per generated chunk; " + f"got {len(inputs.camera_action_script)} chunks for {total_chunks} requested chunks." + ) + dtype = self.transformer.dtype + prompt_embeds = self.encode_prompt( + inputs.prompt, + max_sequence_length=inputs.max_sequence_length, + dtype=dtype, + ) + image_condition = self._prepare_condition( + replace( + inputs, + num_frames=_MAX_RAW_FRAMES, + num_latent_frames=self._horizon_latent_frames(), + ), + dtype=dtype, + ) + camera_trajectory_cache = None + camera_embedding_cache = None + if inputs.camera_trajectory is not None: + available_frames = int(inputs.camera_trajectory.poses.shape[0]) + if available_frames < inputs.num_frames: + raise ValueError( + "camera trajectory frames must be at least num_frames; " + f"got camera_frames={available_frames}, num_frames={inputs.num_frames}." + ) + truncated = CameraTrajectory( + poses=inputs.camera_trajectory.poses[: inputs.num_frames], + intrinsics=inputs.camera_trajectory.intrinsics[: inputs.num_frames], + ) + camera_trajectory_cache = interpolate_camera_trajectory(truncated, inputs.num_latent_frames) + # Embed the whole trajectory once, exactly as request mode does, and + # slice it per block in ``_prepare_next_chunk``. Embedding each chunk + # on its own would normalize that block's framewise translations by + # its own largest step, so a slow block would be conditioned as + # full-speed motion and drift from offline replay of the same path. + camera_embedding_cache, _ = self._prepare_camera(inputs, dtype=dtype) + state.prompt_embeds = prompt_embeds + state.chunk_index = 0 + state.step_index = 0 + state.step_in_chunk = 0 + state.total_chunks = total_chunks + state.chunk_num_steps = len(LINGBOT_DMD_TIMESTEPS) + state.extra = { + "inputs": inputs, + "image_condition": image_condition, + "schedule": _build_shifted_flow_schedule(flow_shift=inputs.flow_shift), + "dtype": dtype, + "block_frames": block_frames, + "camera_pitch": 0.0, + "camera_tail": None, + "camera_action_script": inputs.camera_action_script, + "camera_trajectory_cache": camera_trajectory_cache, + "camera_embedding_cache": camera_embedding_cache, + } + self._ar_text_caches(prompt_embeds, invalidate=False) + self._prepare_next_chunk(state) + return state + + def _prepare_next_chunk(self, state: StepRequestState) -> None: + extra = state.extra + inputs: _LingBotRequestInputs = extra["inputs"] + block_frames = int(extra["block_frames"]) + start_frame = state.chunk_index * block_frames + stop_frame = start_frame + block_frames + image_condition = extra["image_condition"] + if stop_frame > image_condition.shape[2]: + raise ValueError("LingBot chunk_index exceeds the configured causal image condition horizon.") + condition = image_condition[:, :, start_frame:stop_frame] + previous = extra.get("camera_tail") + if extra.get("camera_action_script") is not None: + chunk_actions = extra["camera_action_script"][state.chunk_index] + action_trajectory, camera_pitch = integrate_lingbot_camera_actions( + chunk_actions, + width=inputs.width, + height=inputs.height, + initial_pose=(previous.poses[-1] if previous is not None else None), + initial_pitch=float(extra.get("camera_pitch", 0.0)), + ) + extra["camera_pitch"] = camera_pitch + chunk_inputs = replace( + inputs, + camera_trajectory=action_trajectory, + camera_actions=chunk_actions, + num_frames=(block_frames - 1) * self.vae_scale_factor_temporal + 1, + num_latent_frames=block_frames, + ) + camera, camera_tail = self._prepare_camera( + chunk_inputs, + dtype=extra["dtype"], + previous=previous, + ) + elif extra.get("camera_embedding_cache") is not None: + # Same slice request mode takes from its one full-trajectory + # embedding, so both paths condition a block identically. + trajectory = extra["camera_trajectory_cache"] + camera = extra["camera_embedding_cache"][:, :, start_frame:stop_frame] + camera_tail = CameraTrajectory( + poses=trajectory.poses[stop_frame - 1 : stop_frame].clone(), + intrinsics=trajectory.intrinsics[stop_frame - 1 : stop_frame].clone(), + ) + else: + raise RuntimeError("LingBot step execution is missing a camera trajectory or action script.") + if camera.shape[2:] != condition.shape[2:]: + raise RuntimeError( + "folded camera and image condition must share latent frame/height/width geometry; " + f"got camera={tuple(camera.shape)}, condition={tuple(condition.shape)}." + ) + generator = getattr(state.sampling, "generator", None) + if not isinstance(generator, torch.Generator): + raise ValueError("LingBot World requires the runner-provided torch.Generator.") + extra["condition"] = condition + extra["camera"] = camera + extra["camera_tail"] = camera_tail + extra["start_frame"] = start_frame + extra["ar_cross_attention"] = self._ar_text_caches( + cast(torch.Tensor, state.prompt_embeds), + invalidate=False, + ) + state.latents = randn_tensor( + ( + 1, + self.transformer.config.out_channels, + condition.shape[2], + condition.shape[3], + condition.shape[4], + ), + generator=generator, + device=self.device, + dtype=torch.float32, + ) + state.timesteps = torch.tensor( + [timestep for timestep, _ in extra["schedule"]], + device=self.device, + dtype=torch.float32, + ) + state.step_in_chunk = 0 + state.step_index = 0 + + def denoise_step( + self, + input_batch: InputBatch, + *, + states: Sequence[StepRequestState] | None = None, + **kwargs: Any, + ) -> torch.Tensor | None: + del input_batch, kwargs + if states is None or len(states) != 1: + raise ValueError("LingBot step execution supports a single request, not a batched request.") + self._require_bound_ar_state() + state = states[0] + extra = state.extra + latents = state.latents + if latents is None: + raise RuntimeError("LingBot step execution requires latents before denoise_step.") + timestep = state.current_timestep + if timestep is None: + raise RuntimeError("LingBot step execution requires a current timestep before denoise_step.") + schedule = extra["schedule"] + step_in_chunk = state.step_in_chunk + return self._dmd_blocks.probe_step( + current_latents=latents, + condition=extra["condition"], + camera=extra["camera"], + prompt_embeds=cast(torch.Tensor, state.prompt_embeds), + cache=None, + ar=self._ar_block_context(extra["ar_cross_attention"]), + start_frame=int(extra["start_frame"]), + timestep_value=float(schedule[step_in_chunk][0]), + step_index=step_in_chunk, + ) + + def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor, **kwargs: Any) -> None: + del kwargs + extra = state.extra + latents = state.latents + if latents is None: + raise RuntimeError("LingBot step execution requires latents before step_scheduler.") + generator = getattr(state.sampling, "generator", None) + if not isinstance(generator, torch.Generator): + raise ValueError("LingBot World requires the runner-provided torch.Generator.") + schedule = extra["schedule"] + step_in_chunk = state.step_in_chunk + next_sigma = schedule[step_in_chunk + 1][1] if step_in_chunk + 1 < len(schedule) else None + state.latents = self._dmd_blocks.apply_transition( + latents, + noise_pred, + schedule[step_in_chunk][1], + next_sigma=next_sigma, + generator=generator, + ) + state.step_in_chunk += 1 + state.step_index = state.step_in_chunk + + def post_decode(self, state: StepRequestState, **kwargs: Any) -> DiffusionOutput: + del kwargs + self._require_bound_ar_state() + extra = state.extra + latents = state.latents + if latents is None: + raise RuntimeError("LingBot step execution requires latents before post_decode.") + self._dmd_blocks.commit_block_kv( + latents=latents, + condition=extra["condition"], + camera=extra["camera"], + prompt_embeds=cast(torch.Tensor, state.prompt_embeds), + cache=None, + ar=self._ar_block_context(extra["ar_cross_attention"]), + start_frame=int(extra["start_frame"]), + ) + completed_chunk_index = state.chunk_index + inputs: _LingBotRequestInputs = extra["inputs"] + # "video" is the payload key the output formatter treats as primary, so + # a streaming client receives frames on ``images`` like any other video + # pipeline; latent mode keeps the tensor for offline consumers. + if inputs.output_type == "latent": + payload: dict[str, Any] = {"latents": latents} + else: + payload = {"video": self._decode_chunk_to_pixels(latents, output_type=inputs.output_type)} + output = { + "payload": payload, + "metadata": { + "ar_diffusion": ARDiffusionChunkMetadata( + session_id=state.request_id, + request_id=state.request_id, + chunk_index=completed_chunk_index, + applied_event_ids=(), + ).to_dict() + }, + } + state.chunk_index += 1 + finished = state.request_denoise_completed + if not finished: + self._prepare_next_chunk(state) + return DiffusionOutput( + output=output, + stage_durations=self.stage_durations if hasattr(self, "stage_durations") else None, + chunk_index=completed_chunk_index, + total_chunks=state.total_chunks, + finished=finished, + ) + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: loader = AutoWeightsLoader(self) return cast(set[str], loader.load_weights(weights)) diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 608d605e9..4cec16382 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -1268,6 +1268,17 @@ class AsyncOmniEngine: strategy_config_path = kwargs.pop("strategy_config", None) stage_overrides_json = kwargs.pop("stage_overrides", None) + # ``diffusion_streaming_output`` is the public AsyncOmni/serve kwarg; + # stage configs know the field as ``streaming_output``. The unregistered + # single-stage fallback translates it in + # ``_create_default_diffusion_stage_cfg``, but a registered pipeline + # resolves through StageConfigFactory, which only passes through keys the + # stage schema recognizes — so mirror the translation here. Only a truthy + # value is mirrored: the serve CLI always carries the flag's ``False`` + # default, which must not override a deploy YAML's ``streaming_output``. + if kwargs.get("diffusion_streaming_output") and kwargs.get("streaming_output") is None: + kwargs["streaming_output"] = True + # Parse --stage-overrides JSON string if provided stage_overrides = parse_stage_overrides(stage_overrides_json) diff --git a/vllm_omni/experimental/ar_diffusion/capability.py b/vllm_omni/experimental/ar_diffusion/capability.py index 64eb62cf0..145261513 100644 --- a/vllm_omni/experimental/ar_diffusion/capability.py +++ b/vllm_omni/experimental/ar_diffusion/capability.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Typed pipeline contract for the model-neutral AR-Diffusion runtime.""" from __future__ import annotations @@ -139,10 +140,13 @@ class SupportsARDiffusionPipeline(Protocol): """Required pipeline capability for :class:`ARDiffusionModelRunner`. A session begins when the runner first sees its ``session_id`` and persists - across requests until reset, explicit close, LRU eviction, or a failed - forward. ``bind_ar_diffusion_state`` exposes the runner-owned KV state only - for the duration of one request. The pipeline must not retain the state - after the context exits. + until reset, explicit close, LRU eviction, a failed forward, or request + completion on the stepwise path. ``bind_ar_diffusion_state`` exposes the + runner-owned KV state only for the duration of one runner invocation + (``execute_model`` or ``execute_stepwise``). The pipeline must not retain + the state after the context exits. Uncommitted scratch lives on the + runner-owned session object and survives across stepwise invocations of + the same request. """ def ar_diffusion_kv_cache_spec(self) -> ARDiffusionKVCacheSpec: @@ -154,7 +158,7 @@ class SupportsARDiffusionPipeline(Protocol): session_id: str, state: ARDiffusionKVState, ) -> AbstractContextManager[None]: - """Bind ``state`` to model execution for one request.""" + """Bind ``state`` to model execution for one runner invocation.""" ... def reset_ar_diffusion_session(self, session_id: str) -> None: diff --git a/vllm_omni/experimental/ar_diffusion/runner.py b/vllm_omni/experimental/ar_diffusion/runner.py index 410c45fb1..23da43054 100644 --- a/vllm_omni/experimental/ar_diffusion/runner.py +++ b/vllm_omni/experimental/ar_diffusion/runner.py @@ -1,14 +1,18 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations import dataclasses import time from collections import OrderedDict +from collections.abc import Iterator +from contextlib import contextmanager import torch from vllm.logger import init_logger from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig +from vllm_omni.diffusion.models.interface import supports_step_execution from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched.interface import DiffusionSchedulerOutput, KVPrefetchJob from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner @@ -22,6 +26,7 @@ from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVCon from vllm_omni.experimental.ar_diffusion.kv_cache.manager import ARDiffusionKVCache from vllm_omni.experimental.ar_diffusion.kv_cache.state import ARDiffusionKVState from vllm_omni.experimental.ar_diffusion.tick_protocol import ARDiffusionTickRequest +from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) @@ -62,6 +67,10 @@ class ARDiffusionModelRunner(DiffusionModelRunner): self._sessions: OrderedDict[str, ARDiffusionKVState] = OrderedDict() self._session_capacity = 0 self._perf_e2e_times: list[float] = [] + # Wall-clock start of each request's in-flight stepwise chunk, so + # ``_perf_e2e_times`` keeps one entry per AR block in both execution + # modes instead of one entry per denoise step. + self._stepwise_chunk_started: dict[str, float] = {} @staticmethod def _require_capability(pipeline: object) -> SupportsARDiffusionPipeline: @@ -90,7 +99,7 @@ class ARDiffusionModelRunner(DiffusionModelRunner): def _preallocate_kv_cache(self, *, available_bytes: int | None = None) -> None: """Build pools solely from the pipeline capability and runner config.""" - if bool(getattr(self.od_config, "step_execution", False)): + if bool(getattr(self.od_config, "step_execution", False)) and not supports_step_execution(self.pipeline): raise ValueError( "ARDiffusionModelRunner currently supports request-mode execution only; " "step_execution=True would bypass per-request AR session binding." @@ -240,29 +249,29 @@ class ARDiffusionModelRunner(DiffusionModelRunner): return tick.session_id, extra_args, tick return str(extra_args.get("session_id") or "default"), extra_args, None - def execute_model( + @contextmanager + def _bound_ar_session( self, - req: OmniDiffusionRequest, - kv_prefetch_job: KVPrefetchJob | None = None, - ) -> DiffusionOutput: - if self.kv_cache is None: - return super().execute_model(req, kv_prefetch_job=kv_prefetch_job) + session_id: str, + *, + description: str, + synchronize: bool = True, + ) -> Iterator[None]: + """Bind runner-owned KV for one runner invocation and fail closed. + + The pipeline sees the state only inside this context. On any failure the + session is released before the exception propagates, so a later request + for the same id cannot resume half-written pages. + """ capability = self._ar_diffusion_capability if capability is None: raise RuntimeError("AR-Diffusion capability missing after KV cache initialization") - - session_id, extra_args, tick = self._request_session(req) - reset = tick.reset if tick is not None else bool(extra_args.get("reset", False)) - close_session = tick.close_session if tick is not None else bool(extra_args.get("close_session", False)) - if reset: - self.reset_session(session_id) state = self._get_or_create_session(session_id) - started = time.perf_counter() try: with capability.bind_ar_diffusion_state(session_id, state): - output = super().execute_model(req, kv_prefetch_job=kv_prefetch_job) - if self.device is not None and torch.device(self.device).type == "cuda": - torch.accelerator.synchronize(self.device) + yield + if synchronize: + current_omni_platform.synchronize() except Exception: self._release_session( session_id, @@ -271,10 +280,49 @@ class ARDiffusionModelRunner(DiffusionModelRunner): suppress_errors=True, ) logger.warning( - "AR-Diffusion forward failed for session=%s; KV and model state were released", + "AR-Diffusion %s failed for session=%s; KV and model state were released", + description, session_id, ) raise + + def _release_scheduler_finished_sessions(self, scheduler_output: DiffusionSchedulerOutput) -> None: + """Close sessions the scheduler retired, including aborted requests. + + A stepwise request that runs to completion is closed as soon as its + final chunk is emitted; an aborted one never produces that output, so + its KV would otherwise linger until LRU eviction. Session ids equal + request ids on this path, and tick sessions are keyed by their own ids, + so the membership check leaves them untouched. + """ + for request_id in getattr(scheduler_output, "finished_req_ids", None) or (): + self._stepwise_chunk_started.pop(request_id, None) + if request_id in self._sessions: + self._release_session( + request_id, + reset_model=False, + reason="close", + suppress_errors=True, + ) + + def execute_model( + self, + req: OmniDiffusionRequest, + kv_prefetch_job: KVPrefetchJob | None = None, + ) -> DiffusionOutput: + if self.kv_cache is None: + return super().execute_model(req, kv_prefetch_job=kv_prefetch_job) + if self._ar_diffusion_capability is None: + raise RuntimeError("AR-Diffusion capability missing after KV cache initialization") + + session_id, extra_args, tick = self._request_session(req) + reset = tick.reset if tick is not None else bool(extra_args.get("reset", False)) + close_session = tick.close_session if tick is not None else bool(extra_args.get("close_session", False)) + if reset: + self.reset_session(session_id) + started = time.perf_counter() + with self._bound_ar_session(session_id, description="forward"): + output = super().execute_model(req, kv_prefetch_job=kv_prefetch_job) self._perf_e2e_times.append(time.perf_counter() - started) if close_session: self.close_session(session_id) @@ -291,10 +339,81 @@ class ARDiffusionModelRunner(DiffusionModelRunner): ) def execute_stepwise(self, scheduler_output: DiffusionSchedulerOutput) -> BatchRunnerOutput: - """Reject step execution until step-aware AR state binding exists.""" - raise RuntimeError( - "ARDiffusionModelRunner does not support step execution; use request mode with step_execution=False." - ) + """Bind runner-owned KV for one stepwise invocation, then inherit the step loop.""" + pipeline = getattr(self, "pipeline", None) + if pipeline is None or not supports_step_execution(pipeline): + raise RuntimeError( + "ARDiffusionModelRunner does not support step execution; use request mode with step_execution=False." + ) + if self.kv_cache is None: + return super().execute_stepwise(scheduler_output) + if self._ar_diffusion_capability is None: + raise RuntimeError("AR-Diffusion capability missing after KV cache initialization") + # Retire sessions the scheduler dropped before serving this cycle: a + # completed request is closed when its final chunk is emitted, an + # aborted one only here. + self._release_scheduler_finished_sessions(scheduler_output) + request_ids = list(getattr(scheduler_output, "scheduled_request_ids", ())) + if not request_ids: + return super().execute_stepwise(scheduler_output) + if len(request_ids) != 1: + raise RuntimeError( + "AR-Diffusion step execution supports one request at a time; " + f"got {len(request_ids)} scheduled requests." + ) + request_id = request_ids[0] + session_id = request_id + chunk_started = self._stepwise_chunk_started.setdefault(request_id, time.perf_counter()) + try: + # Denoise steps inside one chunk need no device barrier; the chunk + # boundary below is the meaningful one, and syncing per step would + # cost one synchronize per denoise step instead of per AR block. + with self._bound_ar_session(session_id, description="stepwise", synchronize=False): + output = super().execute_stepwise(scheduler_output) + runner_output = output.get_request_output(request_id) + finished = bool(runner_output is not None and runner_output.finished) + result = None if runner_output is None else runner_output.result + errored = bool(result is not None and getattr(result, "error", None)) + chunk_emitted = result is not None or finished or errored + if chunk_emitted: + # The barrier is where an asynchronous device error surfaces, so + # it must sit inside the same fail-closed scope as the step + # itself rather than leave cleanup to the scheduler's next cycle. + current_omni_platform.synchronize() + except Exception: + self._fail_closed_stepwise(request_id) + raise + # One entry per emitted AR block keeps ``_perf_e2e_times`` comparable + # with request mode, where one execute_model() is one block. + if chunk_emitted: + self._perf_e2e_times.append(time.perf_counter() - chunk_started) + self._stepwise_chunk_started.pop(request_id, None) + if finished or errored: + self._release_session( + session_id, + reset_model=False, + reason="forward_exception" if errored else "close", + suppress_errors=True, + ) + return output + + def _fail_closed_stepwise(self, request_id: str) -> None: + """Drop everything a failed stepwise invocation left behind. + + A failure inside the step is already released by ``_bound_ar_session``; + a failure at the chunk barrier happens after that context closed, so the + session is still present and is released here. + """ + self._stepwise_chunk_started.pop(request_id, None) + state_cache = getattr(self, "state_cache", None) + if isinstance(state_cache, dict): + state_cache.pop(request_id, None) + if request_id in self._sessions: + self._release_session(request_id, reset_model=False, reason="forward_exception", suppress_errors=True) + logger.warning( + "AR-Diffusion stepwise chunk barrier failed for session=%s; KV and model state were released", + request_id, + ) def _warmup_ar_rollout(self) -> None: """Run model-provided warmup requests, or safely skip when absent.""" diff --git a/vllm_omni/model_executor/models/lingbot_world/__init__.py b/vllm_omni/model_executor/models/lingbot_world/__init__.py new file mode 100644 index 000000000..4244ce4b4 --- /dev/null +++ b/vllm_omni/model_executor/models/lingbot_world/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project diff --git a/vllm_omni/model_executor/models/lingbot_world/pipeline.py b/vllm_omni/model_executor/models/lingbot_world/pipeline.py new file mode 100644 index 000000000..82d8258f9 --- /dev/null +++ b/vllm_omni/model_executor/models/lingbot_world/pipeline.py @@ -0,0 +1,37 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""LingBot-World 2.0 single-stage diffusion topology. + +Serving resolves a deploy config through this registration, so the stepwise +stage cannot be launched with ``--deploy-config`` until the pipeline name is +registered here. + +Deliberately no ``default_deploy_config_name``: model-type inference matches +this checkpoint by name, so a default would silently retarget every ``Omni`` +and ``AsyncOmni`` caller that passes no deploy config (the offline replay +example, the tick example) onto the stepwise serving topology. Stepwise +serving stays opt-in through +``--deploy-config vllm_omni/deploy/lingbot_world_v2_stepwise.yaml``. +""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +LINGBOT_WORLD_PIPELINE = PipelineConfig( + model_type="lingbot_world", + model_arch="LingBotWorldCausalDMDPipeline", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="diffusion", + execution_type=StageExecutionType.DIFFUSION, + input_sources=(), + final_output=True, + final_output_type="video", + model_arch="LingBotWorldCausalDMDPipeline", + ), + ), +) From 34aa1d2afa59547945fc59f88ab6f4f25f52a777 Mon Sep 17 00:00:00 2001 From: Lucas Ruan <138600690+lucasruan1618@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:09:32 +0700 Subject: [PATCH 12/90] [Doc] Add A100 recipe for Wan2.2-TI2V-5B (#7189) Signed-off-by: lucasruan1618 --- recipes/README.md | 1 + recipes/Wan-AI/Wan2.2-TI2V-5B-A100.md | 184 ++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 recipes/Wan-AI/Wan2.2-TI2V-5B-A100.md diff --git a/recipes/README.md b/recipes/README.md index 88788e3d3..8ac877567 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -85,6 +85,7 @@ recipes/ | [`Tencent/Covo-Audio-Chat.md`](./Tencent/Covo-Audio-Chat.md) | Online serving for audio chat | 1x A100 80GB | | [`Tencent/HunyuanImage-3.0-Instruct.md`](./Tencent/HunyuanImage-3.0-Instruct.md) | DiT-only text-to-image serving and benchmark, including ModelOpt mixed FP8/NVFP4 | 4x H100/H800 80GB / 2x B200 | | [`Wan-AI/Wan2.2-T2V.md`](./Wan-AI/Wan2.2-T2V.md) | Text-to-video serving (Wan2.2 14B) with Skip-Softmax sparse attention | 1x datacenter Blackwell (B200/B300) | +| [`Wan-AI/Wan2.2-TI2V-5B-A100.md`](./Wan-AI/Wan2.2-TI2V-5B-A100.md) | Unified 720p text-to-video and image-to-video serving (Wan2.2 5B) | 1x A100-SXM4-80GB | | [`Wan-AI/Wan2.2-I2V.md`](./Wan-AI/Wan2.2-I2V.md) | Image-to-video serving (Wan2.2 14B) | 8x Ascend NPU (A2/A3) | | [`Wan-AI/Wan2.2-S2V.md`](./Wan-AI/Wan2.2-S2V.md) | Speech-to-video serving (Wan2.2 14B) | 2x A100/H100 80GB | | [`Wan-AI/Wan2.1-VACE.md`](./Wan-AI/Wan2.1-VACE.md) | Unified T2V, I2V, V2LF, FLF2V, inpaint, and R2V | 1x RTX 5090 (1.3B) / 1x L40S 48GB with layerwise offload (14B) | diff --git a/recipes/Wan-AI/Wan2.2-TI2V-5B-A100.md b/recipes/Wan-AI/Wan2.2-TI2V-5B-A100.md new file mode 100644 index 000000000..8eac30802 --- /dev/null +++ b/recipes/Wan-AI/Wan2.2-TI2V-5B-A100.md @@ -0,0 +1,184 @@ +# Wan2.2 TI2V 5B — A100 80GB + +> Single-GPU, native 720p text-to-video and image-to-video serving + +## Summary + +- Vendor: Wan-AI +- Model: `Wan-AI/Wan2.2-TI2V-5B-Diffusers` +- Task: Unified text-to-video (T2V) and image-to-video (I2V) generation +- Mode: Online serving with the OpenAI-compatible Videos API +- Hardware: 1x NVIDIA A100-SXM4-80GB +- Maintainer: Community + +## When to use this recipe + +Use this recipe to serve both T2V and I2V from one dense 5B checkpoint on one +A100 80GB. It qualifies the model's native 720p profile without CPU offload or +multi-GPU parallelism. The synchronous endpoint is used below because it +returns the MP4 and request timing in one response; the asynchronous Videos API +uses the same server and sampling parameters. + +## Supported model contract + +| Task | Required input | Qualified output | Endpoint | +| --- | --- | --- | --- | +| T2V | Text prompt; omit `input_reference` | 1280x704, 121 frames, 24 fps | `POST /v1/videos/sync` | +| I2V | Text prompt plus one image uploaded as `input_reference` | 1280x704, 121 frames, 24 fps | `POST /v1/videos/sync` | + +The upstream checkpoint supports both tasks at 720p and 24 fps. For native +landscape 720p, use `1280x704`; portrait generation uses `704x1280`. Wan video +lengths follow the temporal VAE contract (`4k+1` frames). This recipe validates +one output per request with 50 denoising steps and seed 42. + +## References + +- Upstream model card: + +- Online T2V example: + [`examples/online_serving/text_to_video`](../../examples/online_serving/text_to_video) +- Online I2V example: + [`examples/online_serving/image_to_video`](../../examples/online_serving/image_to_video) +- Offline I2V example: + [`examples/offline_inference/image_to_video`](../../examples/offline_inference/image_to_video) +- Community recipe tracker: + [vllm-project/vllm-omni#2645](https://github.com/vllm-project/vllm-omni/issues/2645) + +## Hardware + +- Accelerator model and per-device memory: NVIDIA A100-SXM4-80GB, 81,920 MiB +- Number of devices: 1 +- Device interconnect: not applicable to this single-GPU profile +- Host memory: 1 TiB installed on the qualification host; CPU offload was not used +- Qualification scope: BF16, batch size 1, native FlashAttention, eager mode, + VAE tiling, T2V and I2V at 1280x704 / 121 frames / 24 fps + +## Software environment + +- OS: Ubuntu 24.04.3 LTS, Linux 6.8.0 +- Python: 3.12.3 +- NVIDIA driver / host toolkit: 550.127.05 / CUDA 12.8 +- PyTorch: 2.13.0+cu129 +- Transformers / Diffusers: 5.14.1 / 0.40.0 +- vLLM: 0.28.0+cu129 +- vLLM-Omni: commit `ff6e906a25de1ca3122c0d0d5250fde5a0ddbc7b` + +The default vLLM 0.28.0 wheel targets CUDA 13.0. Driver 550 cannot run that +variant, so this qualification used the official CUDA 12.9 wheel: + +```bash +uv venv --python 3.12 --seed +source .venv/bin/activate +uv pip install \ + 'https://github.com/vllm-project/vllm/releases/download/v0.28.0/vllm-0.28.0%2Bcu129-cp38-abi3-manylinux_2_28_x86_64.whl' \ + --torch-backend=auto +uv pip install -e . +``` + +## Command + +Start the server from the repository root: + +```bash +vllm serve Wan-AI/Wan2.2-TI2V-5B-Diffusers \ + --omni \ + --port 8092 \ + --vae-use-tiling \ + --enforce-eager +``` + +In another terminal, download the public reference image: + +```bash +wget -O cherry_blossom.jpg \ + https://vllm-public-assets.s3.us-west-2.amazonaws.com/vision_model_images/cherry_blossom.jpg +``` + +Generate a text-only video: + +```bash +curl --fail-with-body -D t2v-headers.txt \ + -X POST http://127.0.0.1:8092/v1/videos/sync \ + -F "prompt=Two anthropomorphic cats in comfortable boxing gear and bright gloves fight intensely on a spotlighted stage, cinematic lighting, smooth motion." \ + -F "negative_prompt=blurry, low quality, static, distorted, artifacts, watermark, text" \ + -F "width=1280" -F "height=704" \ + -F "num_frames=121" -F "fps=24" \ + -F "num_inference_steps=50" -F "guidance_scale=5.0" \ + -F "flow_shift=5.0" -F "seed=42" \ + --output wan22-ti2v-5b-t2v.mp4 +``` + +Generate an image-conditioned video from the same server: + +```bash +curl --fail-with-body -D i2v-headers.txt \ + -X POST http://127.0.0.1:8092/v1/videos/sync \ + -F "input_reference=@cherry_blossom.jpg" \ + -F "prompt=Cherry blossoms sway gently in the breeze while petals drift across the path, cinematic, smooth natural motion." \ + -F "negative_prompt=blurry, low quality, static, distorted, artifacts, watermark, text" \ + -F "width=1280" -F "height=704" \ + -F "num_frames=121" -F "fps=24" \ + -F "num_inference_steps=50" -F "guidance_scale=5.0" \ + -F "flow_shift=5.0" -F "seed=42" \ + --output wan22-ti2v-5b-i2v.mp4 +``` + +## Verification + +Check both MP4 files with `ffprobe`: + +```bash +for video in wan22-ti2v-5b-t2v.mp4 wan22-ti2v-5b-i2v.mp4; do + ffprobe -v error -select_streams v:0 -count_frames \ + -show_entries stream=codec_name,width,height,r_frame_rate,nb_read_frames,duration \ + -of default=noprint_wrappers=1 "$video" +done +``` + +Each file reported: + +```text +codec_name=h264 +width=1280 +height=704 +r_frame_rate=24/1 +duration=5.041667 +nb_read_frames=121 +``` + +The synchronous response headers and an independent 200 ms `nvidia-smi` +sampler produced these steady-state results after server warmup: + +| Task | HTTP | `X-Inference-Time-S` | Server peak | Observed device peak | MP4 size | +| --- | ---: | ---: | ---: | ---: | ---: | +| T2V | 200 | 288.267 s | 26,546 MB | 27,533 MiB | 7,321,179 bytes | +| I2V | 200 | 299.439 s | 29,766 MB | 30,333 MiB | 15,667,146 bytes | + +Both outputs were decoded in full and visually inspected. The T2V output +contained the prompted boxing cats. The I2V first frame preserved the uploaded +cherry-blossom scene (5.81 mean absolute pixel error after resize and H.264 +encoding), and later frames showed motion. + +## Notes + +- Memory usage: model loading used 21.2239 GiB. The measured I2V device peak + was 30,333 MiB, leaving more than 50 GiB free on this A100. +- `--vae-use-tiling` is retained in the known-good command even though this + profile has ample headroom; it bounds decode memory at 720p. +- `--enforce-eager` removes graph-compilation variability from the measured + profile. Remove it only after separately warming and measuring compiled mode. +- `flow_shift=5.0` is the 720p setting. The shared 480p examples use 12.0. +- The generated MP4 contains video only; this checkpoint does not generate audio. +- The first request includes neither model-load time nor the server's startup + warmup. Server initialization took approximately 26 seconds with a warm cache. + +## Supported features + +| Feature | Status for this profile | Shared guide | +| --- | --- | --- | +| OpenAI-compatible Videos API | Qualified for synchronous T2V and I2V | [Videos API](../../docs/serving/videos_api.md) | +| VAE tiling | Enabled and qualified on one A100 | [VAE parallelism and tiling](../../docs/user_guide/diffusion/parallelism/vae_parallelism.md) | +| Cache-DiT / TaylorSeer | Available but not enabled in the measurements above | [Cache-DiT](../../docs/user_guide/diffusion/cache_acceleration/cache_dit.md) | +| TeaCache | Available but not qualified in this recipe | [TeaCache](../../docs/user_guide/diffusion/cache_acceleration/teacache.md) | +| CPU or layerwise offload | Not needed for the qualified 80GB profile | [CPU offload](../../docs/user_guide/diffusion/cpu_offload.md) | +| TP / SP / CFG parallelism | Not applicable to this single-GPU profile | [Parallelism overview](../../docs/user_guide/diffusion/parallelism/overview.md) | From b78c31eda6c75546be4d54c265d483977104a487 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:28:30 +0800 Subject: [PATCH 13/90] [Doc] Update vLLM-Omni WeChat QR code (#7178) Signed-off-by: David Chen <530634352@qq.com> --- docs/assets/WeChat.jpg | Bin 280162 -> 278267 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/WeChat.jpg b/docs/assets/WeChat.jpg index e5a76319292df1b937ad21d1cb39d4c0148ab364..0e91feee5b53138f32934a9546169b36e4adf069 100644 GIT binary patch delta 224231 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

9mYX25vP@=IX%oow4 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 z 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 RtEYWDrAq+r0zq1gJKEID{G4baWKtu}hMR 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 %jpMmIz&2HNi2F}s+QSR%a*$IYT@Pw~ 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⪻4%t>^|$v5c@CNpAFg; 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 From 4b81f692d1efa9b4f2a4f6777e47f471a3b6d383 Mon Sep 17 00:00:00 2001 From: summer <128961079+zhang-keliang@users.noreply.github.com> Date: Mon, 7 Sep 2026 15:56:51 +0800 Subject: [PATCH 14/90] [Bugfix][Diffusion] Canonicalize cache_backend=None to "none" (#7041) Signed-off-by: summer <128961079+zhang-keliang@users.noreply.github.com> Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com> --- .../diffusion/models/sana_video/test_cache_offload.py | 7 ++++++- tests/diffusion/test_diffusion_config_propagation.py | 11 ++++++++++- vllm_omni/diffusion/data.py | 5 +++++ .../models/sana_video/pipeline_sana_video.py | 3 ++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/tests/diffusion/models/sana_video/test_cache_offload.py b/tests/diffusion/models/sana_video/test_cache_offload.py index 52ced37c8..57417c886 100644 --- a/tests/diffusion/models/sana_video/test_cache_offload.py +++ b/tests/diffusion/models/sana_video/test_cache_offload.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from dataclasses import dataclass from types import SimpleNamespace @@ -155,6 +155,11 @@ def test_sana_video_rejects_unvalidated_cache_backends(): _validate_cache_offload_parallelism(OmniDiffusionConfig(cache_backend="tea_cache")) +def test_sana_video_accepts_none_cache_backend(): + """An explicit ``cache_backend=None`` means "no cache", same as ``"none"``.""" + _validate_cache_offload_parallelism(OmniDiffusionConfig(cache_backend=None)) + + def test_sana_video_rejects_cache_dit_with_distributed_layerwise_offload(): config = OmniDiffusionConfig(cache_backend="cache_dit", enable_distributed_layerwise_offload=True) diff --git a/tests/diffusion/test_diffusion_config_propagation.py b/tests/diffusion/test_diffusion_config_propagation.py index 60d0c63b8..164fcfbeb 100644 --- a/tests/diffusion/test_diffusion_config_propagation.py +++ b/tests/diffusion/test_diffusion_config_propagation.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Tests that parallel_config survives the create_default_diffusion roundtrip. Regression tests for https://github.com/vllm-project/vllm-omni/issues/1862 @@ -137,6 +137,15 @@ class TestCreateDefaultDiffusion: stages = StageConfigFactory.create_default_diffusion({"model": "x"}) assert stages[0]["engine_args"]["cache_backend"] == "none" + def test_explicit_none_cache_backend_canonicalizes_to_none(self): + """Regression for #7032: ``cache_backend=None`` (e.g. an example CLI default) + must reach the pipeline as the canonical ``"none"`` string.""" + od = _roundtrip_diffusion_config(model="x", cache_backend=None) + assert od.cache_backend == "none" + + od = OmniDiffusionConfig.from_kwargs(model="x", cache_backend=None) + assert od.cache_backend == "none" + def test_single_gpu_default_devices(self): stages = StageConfigFactory.create_default_diffusion({"model": "x"}) assert stages[0]["runtime"]["devices"] == "0" diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index 00764ea46..fd6f0237d 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -91,6 +91,11 @@ def normalize_omni_diffusion_kwargs(kwargs: Mapping[str, Any]) -> dict[str, Any] if "cache_backend" not in normalized: cache_backend = os.environ.get("DIFFUSION_CACHE_BACKEND") or os.environ.get("DIFFUSION_CACHE_ADAPTER") normalized["cache_backend"] = cache_backend.lower() if cache_backend else "none" + elif normalized["cache_backend"] is None: + # Callers (e.g. example CLIs with `default=None`) pass an explicit + # None for "no cache"; canonicalize it so every consumer sees the + # declared `str` value instead of relying on per-model None handling. + normalized["cache_backend"] = "none" # Convert optional YAML null values to empty containers. for key in ("diffusers_load_kwargs", "diffusers_call_kwargs"): diff --git a/vllm_omni/diffusion/models/sana_video/pipeline_sana_video.py b/vllm_omni/diffusion/models/sana_video/pipeline_sana_video.py index f2d86622a..6c2d740d4 100644 --- a/vllm_omni/diffusion/models/sana_video/pipeline_sana_video.py +++ b/vllm_omni/diffusion/models/sana_video/pipeline_sana_video.py @@ -273,7 +273,8 @@ def retrieve_timesteps( def _validate_cache_offload_parallelism(od_config: OmniDiffusionConfig) -> None: - cache_backend = od_config.cache_backend + # Treat None like "none", matching the other pipelines' cache checks. + cache_backend = od_config.cache_backend or "none" if cache_backend not in ("none", "cache_dit"): raise NotImplementedError( f"Cache backend {cache_backend!r} is not supported by the native SANA-Video pipeline; " From 91dfafcaab9c400f4dc504d2a9d0be09cbf59603 Mon Sep 17 00:00:00 2001 From: Shenglei Fu <117230642+ShengleiFu@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:58:19 -0700 Subject: [PATCH 15/90] [Bugfix][Diffusion] Include guidance_scale_2_provided in the request-batch key (#7078) Signed-off-by: Shenglei Fu Co-authored-by: Claude --- tests/diffusion/test_diffusion_scheduler.py | 19 +++++++++++++++++++ vllm_omni/diffusion/sched/interface.py | 1 + 2 files changed, 20 insertions(+) diff --git a/tests/diffusion/test_diffusion_scheduler.py b/tests/diffusion/test_diffusion_scheduler.py index 0a317f1fb..d6d17c18f 100644 --- a/tests/diffusion/test_diffusion_scheduler.py +++ b/tests/diffusion/test_diffusion_scheduler.py @@ -276,12 +276,16 @@ class TestGetRequestBatchSamplingParamsKey: generator: torch.Generator | None = None, extra_args: dict | None = None, condition_key: tuple | None = None, + guidance_scale: float | None = None, + guidance_scale_2: float | None = None, ) -> OmniDiffusionRequest: sp = OmniDiffusionSamplingParams( num_inference_steps=num_inference_steps, seed=seed, generator=generator, extra_args=extra_args or {}, + guidance_scale=guidance_scale, + guidance_scale_2=guidance_scale_2, ) return OmniDiffusionRequest( prompt="prompt", @@ -356,6 +360,21 @@ class TestGetRequestBatchSamplingParamsKey: self._make(condition_key=("wan22_s2v_condition", True)) ) != scheduler._build_sampling_params_key(self._make(condition_key=("wan22_s2v_condition", False))) + def test_distinguishes_explicit_guidance_scale_2(self) -> None: + # An omitted guidance_scale_2 is auto-filled from guidance_scale, so a + # request that omits it and one that passes the same value explicitly end + # up with an identical numeric guidance_scale_2 but different + # guidance_scale_2_provided. Pipelines read guidance_scale_2_provided from + # the batch's first request to gate image guidance, so the two must not + # share a request batch. + scheduler = RequestScheduler() + omitted = self._make(guidance_scale=2.0) + explicit = self._make(guidance_scale=2.0, guidance_scale_2=2.0) + + assert omitted.sampling_params.guidance_scale_2 == explicit.sampling_params.guidance_scale_2 + assert omitted.sampling_params.guidance_scale_2_provided != explicit.sampling_params.guidance_scale_2_provided + assert scheduler._build_sampling_params_key(omitted) != scheduler._build_sampling_params_key(explicit) + class TestRequestScheduler: def setup_method(self) -> None: diff --git a/vllm_omni/diffusion/sched/interface.py b/vllm_omni/diffusion/sched/interface.py index d97b5cbd0..122a97ec2 100644 --- a/vllm_omni/diffusion/sched/interface.py +++ b/vllm_omni/diffusion/sched/interface.py @@ -119,6 +119,7 @@ class RequestBatchSamplingParamsKey: guidance_scale: float = 0.0 guidance_scale_provided: bool = False guidance_scale_2: float | None = None + guidance_scale_2_provided: bool = False guidance_rescale: float = 0.0 true_cfg_scale: float | None = None cfg_normalize: bool = False From 677265b64b7870374705f18e81579c9d6e5ec8c7 Mon Sep 17 00:00:00 2001 From: Rahul Steiger Date: Mon, 7 Sep 2026 10:06:16 +0200 Subject: [PATCH 16/90] [Bugfix][Diffusion] Make Cosmos3 transfer image arrays writable (#6915) Signed-off-by: Rahul Steiger Co-authored-by: Rahul Steiger Co-authored-by: Rahul Steiger --- .../models/cosmos3/test_cosmos3_pipeline.py | 15 +++++++++++++++ vllm_omni/diffusion/models/cosmos3/transfer.py | 4 ++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index 836e87219..0b2ed3a57 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -1180,6 +1180,21 @@ def test_transfer_fps_matches_resolved_frame_rate_precedence() -> None: assert cfg.fps == sp.resolved_frame_rate == 12.0 +@pytest.mark.parametrize("use_path", [False, True], ids=["image", "path"]) +def test_transfer_pil_conversion_returns_writable_array(tmp_path, use_path: bool) -> None: + from vllm_omni.diffusion.models.cosmos3 import transfer + + image = Image.new("RGB", (5, 4), "red") + value = image + if use_path: + value = tmp_path / "control.png" + image.save(value) + + array = transfer._pil_to_uint8_rgb(value) + + assert array.flags.writeable + + def test_transfer_vae_executor_requires_distributed_vae() -> None: from vllm_omni.diffusion.models.cosmos3.pipeline_cosmos3 import Cosmos3OmniDiffusersPipeline diff --git a/vllm_omni/diffusion/models/cosmos3/transfer.py b/vllm_omni/diffusion/models/cosmos3/transfer.py index 9c8a52f3f..65bbbcef8 100644 --- a/vllm_omni/diffusion/models/cosmos3/transfer.py +++ b/vllm_omni/diffusion/models/cosmos3/transfer.py @@ -439,9 +439,9 @@ def uint8_cthw_to_normalized_5d(frames: torch.Tensor, *, dtype: torch.dtype) -> def _pil_to_uint8_rgb(value: Any) -> np.ndarray: if isinstance(value, PIL.Image.Image): - return np.asarray(value.convert("RGB"), dtype=np.uint8) + return np.array(value.convert("RGB"), dtype=np.uint8, copy=True) if isinstance(value, str | Path): - return np.asarray(PIL.Image.open(value).convert("RGB"), dtype=np.uint8) + return np.array(PIL.Image.open(value).convert("RGB"), dtype=np.uint8, copy=True) if isinstance(value, torch.Tensor): tensor = value.detach().cpu() if tensor.ndim == 3 and tensor.shape[0] in (3, 4): From 85b0bad2d3fbc63b5c674e148e4030c442f04f0b Mon Sep 17 00:00:00 2001 From: FredHuangNV <164937677+FredHuangNV@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:38:56 -0700 Subject: [PATCH 17/90] [Frontend][Model] Add Cosmos3 control uploads to video API (#7027) Signed-off-by: Fred Huang --- docs/serving/videos_api.md | 19 ++ recipes/cosmos3/Cosmos3-Nano.md | 12 ++ .../openai_api/test_video_server.py | 183 +++++++++++++++++ vllm_omni/diffusion/model_metadata.py | 14 +- vllm_omni/entrypoints/openai/api_server.py | 186 +++++++++++++++++- vllm_omni/entrypoints/openai/serving_video.py | 29 +++ 6 files changed, 434 insertions(+), 9 deletions(-) diff --git a/docs/serving/videos_api.md b/docs/serving/videos_api.md index 5a1d8ecf2..09f023d27 100644 --- a/docs/serving/videos_api.md +++ b/docs/serving/videos_api.md @@ -68,6 +68,8 @@ curl -L "http://localhost:8091/v1/videos/${video_id}/content" -o output.mp4 | Parameter | Type | Default | Description | | ----------- | ------ | --------- | ------------- | | `input_reference` | file | null | Uploaded reference image or video for image-to-video/video-to-video requests | +| `control_reference` | file | null | Optional uploaded image/video control, up to 512 MiB, for models that declare control-upload support | +| `control_type` | string | null | Model control name associated with `control_reference`; currently Cosmos3 supports `edge`, `blur`, `depth`, `seg`, and `wsm` | | `image_reference` | string | null | JSON-encoded reference image payload; do not combine with `input_reference` or `video_reference` | | `video_reference` | string | null | JSON-encoded reference video payload; do not combine with `input_reference` or `image_reference` | | `audio_reference` | string | null | JSON-encoded audio reference for speech-to-video: `{"audio_url": "..."}` — supports HTTP(s) URLs or base64 data URLs | @@ -177,6 +179,23 @@ corresponding addition. `negative_metadata_mode` accepts `same`, `inverse`, or `none` and defaults to `same` for transfer. See the Cosmos3 recipe for complete examples. +A client that cannot place the control on the server filesystem can upload one +control with `control_reference` and identify it with `control_type`. The API +streams the upload to request-scoped storage, supplies its path to the model, +and removes it after synchronous or asynchronous generation completes. Other +options for the selected control can remain in `extra_params`; do not also set +`control` or `control_path` there. Uploads larger than 512 MiB are rejected. + +```bash +curl -s http://localhost:8091/v1/videos/sync \ + -F "prompt=Preserve the scene while following the world-state control" \ + -F "input_reference=@input.mp4;type=video/mp4" \ + -F "control_reference=@wsm.mp4;type=video/mp4" \ + -F "control_type=wsm" \ + -F 'extra_params={"wsm":{"control_weight":1.0}}' \ + -o output.mp4 +``` + HTTP redirects for `image_reference.image_url` follow vLLM's `VLLM_MEDIA_URL_ALLOW_REDIRECTS` setting. Before starting the server, set it to `1` (the default) to allow redirects or `0` to reject them. A redirect target diff --git a/recipes/cosmos3/Cosmos3-Nano.md b/recipes/cosmos3/Cosmos3-Nano.md index 4aaebf0be..490f5b1f4 100644 --- a/recipes/cosmos3/Cosmos3-Nano.md +++ b/recipes/cosmos3/Cosmos3-Nano.md @@ -207,6 +207,18 @@ curl -sS -X POST http://localhost:8000/v1/videos/sync \ -F 'extra_params={"depth":{"control_path":"/path/to/depth_control.mp4"},"max_frames":121,"resolution":"720","num_video_frames_per_chunk":121}' \ -o cosmos3_transfer_depth.mp4 +# The same transfer control can be uploaded by a remote client instead of +# being placed on the server filesystem. Uploads are limited to 512 MiB. +curl -sS -X POST http://localhost:8000/v1/videos/sync \ + -H "Accept: video/mp4" \ + -F "model=nvidia/Cosmos3-Nano" \ + -F "prompt=Generate a realistic scene following the provided world-state control." \ + -F "input_reference=@/path/to/input.mp4;type=video/mp4" \ + -F "control_reference=@/path/to/wsm.mp4;type=video/mp4" \ + -F "control_type=wsm" \ + -F 'extra_params={"wsm":{"control_weight":1.0},"max_frames":121,"resolution":"720","num_video_frames_per_chunk":121}' \ + -o cosmos3_transfer_wsm.mp4 + # Text-to-video-with-sound curl -sS -X POST http://localhost:8000/v1/videos/sync \ -H "Accept: video/mp4" \ diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index 969691ebc..4a5931d6c 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -75,6 +75,7 @@ class FakeAsyncOmni: self.model_class_name = "WanPipeline" self.captured_prompt = None self.captured_reference_video_bytes = None + self.captured_control_reference_bytes = {} self.captured_sampling_params_list = None def get_diffusion_od_config(self): @@ -83,6 +84,10 @@ class FakeAsyncOmni: async def generate(self, prompt, request_id, sampling_params_list): self.captured_prompt = prompt self.captured_sampling_params_list = sampling_params_list + for control_type in ("edge", "blur", "depth", "seg", "wsm"): + control_params = sampling_params_list[0].extra_args.get(control_type) + if isinstance(control_params, dict) and isinstance(control_params.get("control_path"), str): + self.captured_control_reference_bytes[control_type] = Path(control_params["control_path"]).read_bytes() reference_videos = prompt.get("multi_modal_data", {}).get("video") if ( isinstance(reference_videos, list) @@ -338,6 +343,8 @@ def _cosmos3_stage_configs(): return [ SimpleNamespace( stage_type="diffusion", + final_output=True, + final_output_type="video", engine_args=SimpleNamespace(model_class_name="Cosmos3OmniDiffusersPipeline"), ) ] @@ -919,6 +926,17 @@ def test_mixed_reference_capability_uses_model_metadata_when_config_defaults_fal assert handler.supports_mixed_reference_inputs +@pytest.mark.parametrize("model_class_name", ["Cosmos3OmniDiffusersPipeline", "Cosmos3OmniPipeline"]) +def test_control_upload_capability_is_declared_only_by_cosmos3(test_client, model_class_name): + handler = test_client.app.state.openai_serving_video + handler._engine_client.model_class_name = model_class_name + + assert handler.supported_control_upload_types == frozenset({"edge", "blur", "depth", "seg", "wsm"}) + + handler._engine_client.model_class_name = "WanPipeline" + assert handler.supported_control_upload_types == frozenset() + + def test_decode_video_bytes_can_keep_first_frames(): from vllm_omni.entrypoints.openai.video_api_utils import _decode_video_bytes @@ -2218,6 +2236,171 @@ def test_sync_v2v_returns_video_bytes(test_client, mocker: MockerFixture): assert input_video[0].size == (32, 24) +@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"]) +@pytest.mark.parametrize("control_type", ["wsm", "depth"]) +def test_cosmos3_accepts_optional_uploaded_control( + endpoint, + control_type, + test_client, + mocker: MockerFixture, +): + control_bytes = f"{control_type}-control".encode() + _mock_encode_video_bytes(mocker, b"controlled-video") + engine = test_client.app.state.openai_serving_video._engine_client + engine.model_class_name = "Cosmos3OmniDiffusersPipeline" + + response = test_client.post( + endpoint, + data={ + "prompt": "Follow the uploaded control.", + "control_type": control_type, + "extra_params": json.dumps({control_type: {"control_weight": 0.75}}), + }, + files=[ + ("input_reference", ("input.mp4", _make_test_video_bytes(), "video/mp4")), + ("control_reference", (f"{control_type}.mp4", control_bytes, "video/mp4")), + ], + ) + + assert response.status_code == 200 + if endpoint.endswith("/sync"): + assert response.content == b"controlled-video" + else: + video_id = response.json()["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + + captured = engine.captured_sampling_params_list[0].extra_args[control_type] + assert captured["control_weight"] == 0.75 + assert engine.captured_control_reference_bytes[control_type] == control_bytes + assert len(engine.captured_prompt["multi_modal_data"]["video"]) == 3 + assert not Path(captured["control_path"]).exists() + + +@pytest.mark.parametrize("endpoint", ["/v1/videos", "/v1/videos/sync"]) +def test_cosmos3_uploaded_control_selects_transfer_reference_video_decode_policy( + endpoint, + test_client, + mocker: MockerFixture, +): + _mock_encode_video_bytes(mocker, b"controlled-video") + test_client.app.state.stage_configs = _cosmos3_stage_configs() + + response = test_client.post( + endpoint, + data={ + "prompt": "Preserve all conditioning motion.", + "control_type": "wsm", + "num_frames": "9", + "extra_params": json.dumps({"num_first_chunk_conditional_frames": 9}), + }, + files=[ + ( + "input_reference", + ("input.mp4", _make_test_video_bytes(num_frames=6), "video/mp4"), + ), + ("control_reference", ("wsm.mp4", b"control", "video/mp4")), + ], + ) + + assert response.status_code == 200 + if not endpoint.endswith("/sync"): + video_id = response.json()["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + + engine = test_client.app.state.openai_serving_video._engine_client + assert len(engine.captured_prompt["multi_modal_data"]["video"]) == 6 + + +def test_cosmos3_control_upload_is_optional(test_client, mocker: MockerFixture): + _mock_encode_video_bytes(mocker) + engine = test_client.app.state.openai_serving_video._engine_client + engine.model_class_name = "Cosmos3OmniDiffusersPipeline" + + response = test_client.post("/v1/videos/sync", data={"prompt": "No control for this request."}) + + assert response.status_code == 200 + assert "wsm" not in engine.captured_sampling_params_list[0].extra_args + + +def test_control_upload_does_not_affect_models_without_capability(test_client): + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "Unsupported control.", "control_type": "wsm"}, + files={"control_reference": ("wsm.mp4", b"control", "video/mp4")}, + ) + + assert response.status_code == 400 + assert "not supported by this model" in response.json()["detail"] + assert test_client.app.state.openai_serving_video._engine_client.captured_prompt is None + + +@pytest.mark.parametrize( + ("data", "files", "message"), + [ + ( + {"prompt": "Missing type."}, + {"control_reference": ("wsm.mp4", b"control", "video/mp4")}, + "requires control_type", + ), + ( + {"prompt": "Missing file.", "control_type": "wsm"}, + None, + "requires a control_reference", + ), + ( + {"prompt": "Unknown type.", "control_type": "unknown"}, + {"control_reference": ("control.mp4", b"control", "video/mp4")}, + "not supported by this model", + ), + ], +) +def test_cosmos3_control_upload_validates_contract(data, files, message, test_client): + test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline" + + response = test_client.post("/v1/videos/sync", data=data, files=files) + + assert response.status_code == 400 + assert message in response.json()["detail"] + + +def test_cosmos3_control_upload_rejects_existing_control_source(test_client): + test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline" + response = test_client.post( + "/v1/videos/sync", + data={ + "prompt": "Ambiguous control.", + "control_type": "wsm", + "extra_params": json.dumps({"wsm": {"control_path": "/already/present.mp4"}}), + }, + files={"control_reference": ("wsm.mp4", b"control", "video/mp4")}, + ) + + assert response.status_code == 400 + assert "not both" in response.json()["detail"] + + +@pytest.mark.parametrize( + ("control_bytes", "message"), + [ + (b"", "must not be empty"), + (b"control", "size limit"), + ], +) +def test_cosmos3_control_upload_rejects_invalid_size(control_bytes, message, test_client, monkeypatch): + test_client.app.state.openai_serving_video._engine_client.model_class_name = "Cosmos3OmniDiffusersPipeline" + monkeypatch.setattr(api_server, "CONTROL_REFERENCE_MAX_BYTES", 3) + + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "Invalid control.", "control_type": "wsm"}, + files={"control_reference": ("wsm.mp4", control_bytes, "video/mp4")}, + ) + + assert response.status_code == 400 + assert message in response.json()["detail"] + assert test_client.app.state.openai_serving_video._engine_client.captured_prompt is None + + def test_sync_missing_handler_returns_503(): app = FastAPI() app.include_router(router) diff --git a/vllm_omni/diffusion/model_metadata.py b/vllm_omni/diffusion/model_metadata.py index 38df83846..10d495cfd 100644 --- a/vllm_omni/diffusion/model_metadata.py +++ b/vllm_omni/diffusion/model_metadata.py @@ -11,6 +11,10 @@ class DiffusionModelMetadata: supports_multimodal_inputs: bool = False max_multimodal_image_inputs: int | None = None supports_mixed_reference_inputs: bool = False + # Multipart controls are exposed as ``control_reference`` plus + # ``control_type`` by the video API. A pipeline that opts in receives the + # persisted upload through ``extra_args[control_type]["control_path"]``. + supported_control_upload_types: tuple[str, ...] = () attention_mask_free: bool = False final_output_type: str | None = None @@ -94,8 +98,14 @@ _DIFFUSION_MODEL_METADATA: dict[str, DiffusionModelMetadata] = { "LongCatVideoAvatarPipeline": DiffusionModelMetadata(final_output_type="video"), "MagiHumanPipeline": DiffusionModelMetadata(final_output_type="video"), "DreamIDOmniPipeline": DiffusionModelMetadata(final_output_type="video"), - "Cosmos3OmniDiffusersPipeline": DiffusionModelMetadata(final_output_type="video"), - "Cosmos3OmniPipeline": DiffusionModelMetadata(final_output_type="video"), + "Cosmos3OmniDiffusersPipeline": DiffusionModelMetadata( + supported_control_upload_types=("edge", "blur", "depth", "seg", "wsm"), + final_output_type="video", + ), + "Cosmos3OmniPipeline": DiffusionModelMetadata( + supported_control_upload_types=("edge", "blur", "depth", "seg", "wsm"), + final_output_type="video", + ), "SanaVideoPipeline": DiffusionModelMetadata(final_output_type="video"), "SanaImageToVideoPipeline": DiffusionModelMetadata(final_output_type="video"), "SanaWmPipeline": DiffusionModelMetadata( diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index 3d9403bf8..b305cb0cd 100755 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -15,8 +15,8 @@ import tempfile import time import uuid from argparse import Namespace -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager, suppress from http import HTTPStatus from numbers import Integral from pathlib import Path @@ -181,6 +181,9 @@ MINIMAX_H3_MAX_REFERENCE_COUNT = 12 MINIMAX_H3_REFERENCE_IMAGE_FORMATS = frozenset({"jpeg", "png", "webp", "heic", "heif"}) MINIMAX_H3_REFERENCE_VIDEO_SUFFIXES = frozenset({".mp4", ".mov"}) MINIMAX_H3_REFERENCE_AUDIO_SUFFIXES = frozenset({".wav", ".mp3"}) +CONTROL_REFERENCE_IMAGE_SUFFIXES = frozenset({".bmp", ".gif", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}) +CONTROL_REFERENCE_VIDEO_SUFFIXES = frozenset({".mkv", ".mov", ".mp4", ".webm"}) +CONTROL_REFERENCE_MAX_BYTES = 512 * 1024 * 1024 profiler_router = APIRouter() @@ -3073,6 +3076,7 @@ async def _cleanup_video(video_id: str): def _cleanup_video_references( reference_video: ReferenceVideo | None, reference_audio: ReferenceAudio | None, + control_path: str | None = None, ) -> None: if reference_video is not None: for path in reference_video.cleanup_paths: @@ -3083,6 +3087,8 @@ def _cleanup_video_references( for path in cleanup_paths: if os.path.exists(path): os.unlink(path) + if control_path is not None and os.path.exists(control_path): + os.unlink(control_path) async def _run_video_generation_job( @@ -3092,11 +3098,13 @@ async def _run_video_generation_job( reference_image: ReferenceImage | None = None, reference_video: ReferenceVideo | None = None, reference_audio: ReferenceAudio | None = None, + control_path: str | None = None, app_state: Any | None = None, ) -> None: job = await VIDEO_STORE.get(video_id) if job is None: logger.warning("Video job %s missing before generation task started; skipping", video_id) + _cleanup_video_references(reference_video, reference_audio, control_path) return await VIDEO_STORE.update_fields(video_id, {"status": VideoGenerationStatus.IN_PROGRESS}) @@ -3165,7 +3173,7 @@ async def _run_video_generation_job( await VIDEO_STORE.pop(video_id) raise finally: - _cleanup_video_references(reference_video, reference_audio) + _cleanup_video_references(reference_video, reference_audio, control_path) VIDEO_SYNC_TIMEOUT_S = float(os.environ.get("VLLM_OMNI_VIDEO_SYNC_TIMEOUT", 600.0)) @@ -3191,6 +3199,121 @@ async def _persist_uploaded_video_references(uploads: list[UploadFile]) -> list[ return paths +async def _persist_uploaded_control_reference( + upload: UploadFile, + *, + max_bytes: int = CONTROL_REFERENCE_MAX_BYTES, +) -> str: + """Stream one model control upload to request-scoped local storage.""" + kind = _uploaded_media_kind(upload) + suffix = Path(upload.filename or "").suffix.lower() + supported_suffixes = CONTROL_REFERENCE_IMAGE_SUFFIXES | CONTROL_REFERENCE_VIDEO_SUFFIXES + if kind == "audio" or (suffix and suffix not in supported_suffixes): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="control_reference must be an image or video file.", + ) + if not suffix: + suffix = ".png" if kind == "image" else ".mp4" + + declared_size = getattr(upload, "size", None) + if isinstance(declared_size, Integral) and int(declared_size) > max_bytes: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail=f"control_reference exceeds the {max_bytes // (1024 * 1024)} MiB size limit.", + ) + + fd, path = tempfile.mkstemp(prefix="vllm_omni_control_reference_", suffix=suffix) + size = 0 + persisted = False + try: + with os.fdopen(fd, "wb") as output: + while chunk := await upload.read(min(1024 * 1024, max_bytes - size + 1)): + size += len(chunk) + if size > max_bytes: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail=f"control_reference exceeds the {max_bytes // (1024 * 1024)} MiB size limit.", + ) + output.write(chunk) + if size == 0: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="control_reference must not be empty.", + ) + persisted = True + return path + finally: + if not persisted: + with suppress(OSError): + os.unlink(path) + + +def _validate_control_upload( + handler: OmniOpenAIServingVideo, + request: VideoGenerationRequest, + control_reference: UploadFile | None, + control_type: str | None, +) -> str | None: + """Validate a generic uploaded control against model capabilities.""" + if control_reference is None: + if control_type is not None: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="control_type requires a control_reference upload.", + ) + return None + if control_type is None or not control_type.strip(): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail="control_reference requires control_type.", + ) + + normalized_type = control_type.strip().lower() + supported_types = frozenset(getattr(handler, "supported_control_upload_types", ())) + if normalized_type not in supported_types: + supported = ", ".join(sorted(supported_types)) or "none" + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail=( + f"Control upload type '{normalized_type}' is not supported by this model. " + f"Supported control upload types: {supported}." + ), + ) + + existing = (request.extra_params or {}).get(normalized_type) + if existing is not None: + if not isinstance(existing, Mapping) and existing is not True: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail=f"extra_params.{normalized_type} must be an object when control_reference is uploaded.", + ) + if isinstance(existing, Mapping) and any(existing.get(key) is not None for key in ("control", "control_path")): + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST.value, + detail=( + "Provide either control_reference or " + f"extra_params.{normalized_type}.control/control_path, not both." + ), + ) + return normalized_type + + +def _attach_control_upload( + request: VideoGenerationRequest, + control_type: str, + control_path: str | None = None, +) -> None: + """Declare an uploaded control and attach its persisted path when available.""" + extra_params = dict(request.extra_params or {}) + existing = extra_params.get(control_type) + control_params = dict(existing) if isinstance(existing, Mapping) else {} + if control_path is not None: + control_params["control_path"] = control_path + extra_params[control_type] = control_params + request.extra_params = extra_params + + def _reference_list(value: Any) -> list[Any]: if value is None: return [] @@ -3337,6 +3460,8 @@ async def _parse_video_form( prompt: str = Form(...), input_reference: UploadFile | None = File(default=None), input_references: list[UploadFile] | None = File(default=None), + control_reference: UploadFile | None = File(default=None), + control_type: str | None = Form(default=None), image_reference: str | None = Form(default=None), video_reference: str | None = Form(default=None), audio_reference: str | None = Form(default=None), @@ -3376,6 +3501,7 @@ async def _parse_video_form( ReferenceImage | None, ReferenceVideo | None, ReferenceAudio | None, + str | None, ]: """FastAPI dependency that parses video form data, validates inputs, resolves the handler, and decodes any reference image. @@ -3470,6 +3596,13 @@ async def _parse_video_form( detail=f"Video generation setup failed: {str(e)}", ) + normalized_control_type = _validate_control_upload(handler, request, control_reference, control_type) + if normalized_control_type is not None: + # Make the selected transfer mode visible while choosing the model's + # reference-video decode policy. The upload itself stays unpersisted + # until reference parsing succeeds, preserving failure cleanup. + _attach_control_upload(request, normalized_control_type) + supports_mixed_reference_inputs = bool(getattr(handler, "supports_mixed_reference_inputs", False)) if input_reference is not None: input_reference_bytes = await _read_upload_limited( @@ -3592,7 +3725,27 @@ async def _parse_video_form( cleanup_paths=cleanup_paths, ) - return request, handler, effective_model_name, reference_image, reference_video, reference_audio + control_path: str | None = None + if control_reference is not None and normalized_control_type is not None: + try: + control_path = await _persist_uploaded_control_reference( + control_reference, + max_bytes=CONTROL_REFERENCE_MAX_BYTES, + ) + _attach_control_upload(request, normalized_control_type, control_path) + except (asyncio.CancelledError, HTTPException, OSError, TypeError, ValueError): + _cleanup_video_references(reference_video, reference_audio, control_path) + raise + + return ( + request, + handler, + effective_model_name, + reference_image, + reference_video, + reference_audio, + control_path, + ) @router.post( @@ -3613,6 +3766,7 @@ async def create_video( ReferenceImage | None, ReferenceVideo | None, ReferenceAudio | None, + str | None, ] = Depends(_parse_video_form), ) -> VideoResponse: """Create an asynchronous video generation job. @@ -3620,7 +3774,15 @@ async def create_video( Accepts multipart form-data (see ``_parse_video_form`` for parameters), persists a queued job record, and starts generation in the background. """ - request, handler, effective_model_name, reference_image, reference_video, reference_audio = ctx + ( + request, + handler, + effective_model_name, + reference_image, + reference_video, + reference_audio, + control_path, + ) = ctx ref = video_response_from_request(effective_model_name, request) await VIDEO_STORE.upsert(ref.id, ref) task = asyncio.create_task( @@ -3631,6 +3793,7 @@ async def create_video( reference_image, reference_video, reference_audio, + control_path, app_state=raw_request.app.state, ) ) @@ -3656,6 +3819,7 @@ async def create_video_sync( ReferenceImage | None, ReferenceVideo | None, ReferenceAudio | None, + str | None, ] = Depends(_parse_video_form), ) -> Response: """Synchronous video generation endpoint. @@ -3667,7 +3831,15 @@ async def create_video_sync( Metadata is returned via response headers ``X-Request-Id``, ``X-Model``, and ``X-Inference-Time-S``. """ - request, handler, effective_model_name, reference_image, reference_video, reference_audio = ctx + ( + request, + handler, + effective_model_name, + reference_image, + reference_video, + reference_audio, + control_path, + ) = ctx request_id = f"video_sync-{random_uuid()}" raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id) started_at = time.perf_counter() @@ -3701,7 +3873,7 @@ async def create_video_sync( detail=f"Video generation failed: {str(exc)}", ) from exc finally: - _cleanup_video_references(reference_video, reference_audio) + _cleanup_video_references(reference_video, reference_audio, control_path) inference_time_s = time.perf_counter() - started_at return Response( diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py index f3f4b7570..939d55ef8 100644 --- a/vllm_omni/entrypoints/openai/serving_video.py +++ b/vllm_omni/entrypoints/openai/serving_video.py @@ -182,6 +182,35 @@ class OmniOpenAIServingVideo: ) return capability is True or metadata_capability + @property + def supported_control_upload_types(self) -> frozenset[str]: + """Return multipart control types accepted by the active pipeline. + + Unknown pipelines deliberately return an empty set. This keeps the + generic video API isolated from model-specific controls unless a model + explicitly opts into the ``control_path`` contract in metadata. + """ + od_config = self._resolve_diffusion_od_config() + model_archs = [None if od_config is None else getattr(od_config, "model_class_name", None)] + for stage_config in self.stage_configs or (): + stage_get = ( + stage_config.get if isinstance(stage_config, Mapping) else lambda key: getattr(stage_config, key, None) + ) + engine_args = stage_get("engine_args") or {} + model_archs.extend( + ( + stage_get("model_arch"), + engine_args.get("model_class_name") + if isinstance(engine_args, Mapping) + else getattr(engine_args, "model_class_name", None), + ) + ) + + supported: set[str] = set() + for model_arch in model_archs: + supported.update(get_diffusion_model_metadata(model_arch).supported_control_upload_types) + return frozenset(supported) + @classmethod def for_diffusion( cls, From f080f41e031e3876f45e42b62bf7fb4b8f1f5d8a Mon Sep 17 00:00:00 2001 From: Rahul Steiger Date: Mon, 7 Sep 2026 10:58:28 +0200 Subject: [PATCH 18/90] [Bugfix][Cosmos3] Auto-pad non-divisible Ulysses GEN sequences (#6918) Signed-off-by: Rahul Steiger Co-authored-by: Rahul Steiger --- .../cosmos3/test_cosmos3_transformer.py | 134 ++++++++++++++++-- .../diffusion/attention/parallel/ulysses.py | 15 +- .../models/cosmos3/transformer_cosmos3.py | 79 ++++------- 3 files changed, 155 insertions(+), 73 deletions(-) diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py index 2bfa7b558..44bd8e9a7 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_transformer.py @@ -383,6 +383,117 @@ def test_transformer_sharding_offload_and_patch_round_trip_contracts() -> None: ) +def test_gen_sp_plan_auto_pads_hidden_states_and_rope_together() -> None: + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3VFMTransformer + + prepare_plan = Cosmos3VFMTransformer._sp_plan["gen_sp_prepare"] + assert set(prepare_plan) == {0, 1, 2} + assert all(spec.auto_pad for spec in prepare_plan.values()) + assert [prepare_plan[index].split_dim for index in range(3)] == [1, 1, 1] + + +def test_gen_sp_prepare_resets_branch_local_padding_metadata() -> None: + from vllm_omni.diffusion.forward_context import ForwardContext, override_forward_context + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3GenSPPrepare + + ctx = ForwardContext(sp_padding_size=1, sp_original_seq_len=12121) + hidden = torch.zeros(1, 4, 8) + rope = torch.zeros(1, 4, 1, 2) + with override_forward_context(ctx): + output = Cosmos3GenSPPrepare()(hidden, rope, rope) + + assert output[0] is hidden + assert output[1] is rope + assert output[2] is rope + assert ctx.sp_padding_size == 0 + assert ctx.sp_original_seq_len is None + + +def test_sp_attention_masks_padded_gen_and_joint_und_keys() -> None: + from vllm_omni.diffusion.forward_context import ForwardContext, override_forward_context + from vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 import Cosmos3CrossAttention + + class RecordingAttention(nn.Module): + def __init__(self) -> None: + super().__init__() + self.metadata: Any | None = None + + def forward(self, query, key, value, metadata): + del key, value + self.metadata = metadata + return query + + module = object.__new__(Cosmos3CrossAttention) + nn.Module.__init__(module) + module.num_heads_local = 2 + module.head_dim = 4 + recording_attention = RecordingAttention() + module.attn = recording_attention + + q = torch.zeros(1, 2, 2, 4) + k = torch.zeros_like(q) + v = torch.zeros_like(q) + k_und = torch.zeros(1, 3, 2, 4) + v_und = torch.zeros_like(k_und) + ctx = ForwardContext(sp_padding_size=1, sp_original_seq_len=3) + with override_forward_context(ctx): + output = module._forward_sp(q, k, v, k_und, v_und) + + assert output.shape == (1, 2, 8) + assert recording_attention.metadata is not None + assert recording_attention.metadata.attn_mask.tolist() == [[True, True, True, False]] + assert recording_attention.metadata.joint_attn_mask.tolist() == [[True, True, True]] + + +def test_ulysses_2d_mask_uses_key_length_with_kv_only_joint_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata + from vllm_omni.diffusion.attention.parallel import ulysses + + def fake_all_to_all( + _group: object, + tensor: torch.Tensor, + _scatter_idx: int, + _gather_idx: int, + _use_sync: bool, + ) -> torch.Tensor: + return torch.cat([tensor, tensor], dim=1)[:, :, :1] + + monkeypatch.setattr(ulysses.SeqAllToAll4D, "apply", staticmethod(fake_all_to_all)) + strategy = ulysses.UlyssesParallelAttention( + sp_group=SimpleNamespace( + ulysses_group=object(), + ulysses_world_size=2, + ulysses_rank=0, + ring_world_size=1, + ), + scatter_idx=2, + gather_idx=1, + use_sync=False, + ) + query = torch.zeros(1, 2, 2, 4) + key = torch.zeros_like(query) + value = torch.zeros_like(query) + joint_query = torch.zeros(1, 0, 2, 4) + joint_key = torch.zeros(1, 3, 2, 4) + metadata = AttentionMetadata( + attn_mask=torch.tensor([[True, True, True, False]]), + joint_attn_mask=torch.ones(1, 3, dtype=torch.bool), + joint_query=joint_query, + joint_key=joint_key, + joint_value=torch.zeros_like(joint_key), + joint_strategy="front", + ) + + query_out, key_out, _, metadata_out, _ = strategy.pre_attention(query, key, value, metadata) + + assert query_out.shape[1] == 4 + assert key_out.shape[1] == 7 + assert metadata_out is not None + assert metadata_out.attn_mask.tolist() == [[True, True, True, True, True, True, False]] + + def test_forward_returns_video_prediction(monkeypatch: pytest.MonkeyPatch) -> None: from vllm_omni.diffusion.models.cosmos3 import transformer_cosmos3 @@ -721,7 +832,7 @@ def test_forward_returns_video_plus_optional_modality_predictions( assert [tuple(tensor.shape) for tensor in output] == expected_shapes -def test_forward_with_sound_ulysses_error_mentions_combined_sequence(monkeypatch: pytest.MonkeyPatch) -> None: +def test_forward_with_sound_odd_sequence_uses_auto_padding_contract(monkeypatch: pytest.MonkeyPatch) -> None: import vllm_omni.diffusion.models.cosmos3.transformer_cosmos3 as cosmos3_module model = cosmos3_module.Cosmos3VFMTransformer( @@ -732,16 +843,17 @@ def test_forward_with_sound_ulysses_error_mentions_combined_sequence(monkeypatch ) monkeypatch.setattr(cosmos3_module, "_get_ulysses_state", lambda: (2, 0, None)) - with pytest.raises(ValueError, match=r"GEN sequence length \(3 = video tokens 2 \+ sound tokens 1\)"): - model( - hidden_states=torch.zeros(1, 2, 1, 1, 2), - timestep=torch.tensor([1.0]), - text_ids=torch.tensor([[1, 2]], dtype=torch.long), - text_mask=torch.ones(1, 2, dtype=torch.long), - video_shape=(1, 1, 2), - fps=24.0, - sound_latents=torch.zeros(1, 3, 1), - ) + output = model( + hidden_states=torch.zeros(1, 2, 1, 1, 2), + timestep=torch.tensor([1.0]), + text_ids=torch.tensor([[1, 2]], dtype=torch.long), + text_mask=torch.ones(1, 2, dtype=torch.long), + video_shape=(1, 1, 2), + fps=24.0, + sound_latents=torch.zeros(1, 3, 1), + ) + + assert [tuple(tensor.shape) for tensor in output] == [(1, 2, 1, 1, 2), (1, 3, 1)] def test_sound_latent_frames_padded_for_sequence_parallel(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/vllm_omni/diffusion/attention/parallel/ulysses.py b/vllm_omni/diffusion/attention/parallel/ulysses.py index cbaa8ba62..28298c0a2 100644 --- a/vllm_omni/diffusion/attention/parallel/ulysses.py +++ b/vllm_omni/diffusion/attention/parallel/ulysses.py @@ -608,15 +608,15 @@ class UlyssesParallelAttention: if attn_metadata.attn_mask is None: assert attn_metadata.joint_attn_mask is not None attn_metadata.attn_mask = torch.ones( - [query.shape[0], query.shape[1] - attn_metadata.joint_attn_mask.shape[1]], + [key.shape[0], key.shape[1] - attn_metadata.joint_attn_mask.shape[1]], dtype=torch.bool, - device=query.device, + device=key.device, ) elif attn_metadata.joint_attn_mask is None: attn_metadata.joint_attn_mask = torch.ones( - [query.shape[0], query.shape[1] - attn_metadata.attn_mask.shape[1]], + [key.shape[0], key.shape[1] - attn_metadata.attn_mask.shape[1]], dtype=torch.bool, - device=query.device, + device=key.device, ) attn_metadata.attn_mask = ( torch.cat([attn_metadata.joint_attn_mask, attn_metadata.attn_mask], dim=1) @@ -625,9 +625,10 @@ class UlyssesParallelAttention: ) if attn_metadata.attn_mask is not None: - # the final attn_mask is ready, the length should be aligedn with query length - assert attn_metadata.attn_mask.shape[1] == query.shape[1], ( - f"attn_mask length: {attn_metadata.attn_mask.shape[1]} != query length: {query.shape[1]}" + # A 2D mask describes keys. Joint attention may provide K/V-only + # context, so key and query lengths are not necessarily equal. + assert attn_metadata.attn_mask.shape[1] == key.shape[1], ( + f"attn_mask length: {attn_metadata.attn_mask.shape[1]} != key length: {key.shape[1]}" ) attn_metadata.attn_mask = attn_metadata.attn_mask.bool().contiguous() return query, key, value, attn_metadata, ctx diff --git a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py index c946eb4ca..fe7a34c8b 100644 --- a/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/transformer_cosmos3.py @@ -705,7 +705,21 @@ class Cosmos3CrossAttention(nn.Module): # standard reverse-all-to-all path (no joint-output splitting). joint_q = q.new_empty(B, 0, self.num_heads_local, self.head_dim) + gen_mask = None + joint_mask = None + if is_forward_context_available(): + ctx = get_forward_context() + if ctx.sp_original_seq_len is not None and ctx.sp_padding_size > 0: + padded_seq_len = ctx.sp_original_seq_len + ctx.sp_padding_size + gen_mask = torch.ones(B, padded_seq_len, dtype=torch.bool, device=q.device) + gen_mask[:, ctx.sp_original_seq_len :] = False + # UND K/V are trimmed to the common real text length before GEN + # attention, so every remaining joint key is valid. + joint_mask = torch.ones(B, k_und.shape[1], dtype=torch.bool, device=q.device) + attn_metadata = AttentionMetadata( + attn_mask=gen_mask, + joint_attn_mask=joint_mask, joint_query=joint_q, joint_key=k_und, joint_value=v_und, @@ -1055,6 +1069,13 @@ class Cosmos3GenSPPrepare(nn.Module): freqs_cos: torch.Tensor, freqs_sin: torch.Tensor, ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + # A transfer denoising step may run a full-control branch followed by a + # shorter no-control branch in one ForwardContext. Auto-padding metadata + # belongs to one branch only and must not truncate a later gather. + if is_forward_context_available(): + ctx = get_forward_context() + ctx.sp_padding_size = 0 + ctx.sp_original_seq_len = None return hidden_gen, freqs_cos, freqs_sin @@ -1112,9 +1133,9 @@ class Cosmos3VFMTransformer(nn.Module): _sp_plan = { "gen_sp_prepare": { - 0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True), - 1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), - 2: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True), + 0: SequenceParallelInput(split_dim=1, expected_dims=3, split_output=True, auto_pad=True), + 1: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True, auto_pad=True), + 2: SequenceParallelInput(split_dim=1, expected_dims=4, split_output=True, auto_pad=True), }, "gen_sp_gather": SequenceParallelOutput(gather_dim=1, expected_dims=3), } @@ -1586,47 +1607,6 @@ class Cosmos3VFMTransformer(nn.Module): self.cached_kv = None self.cached_freqs_gen = None - @staticmethod - def _validate_gen_sequence_parallel( - *, - s_gen: int, - s_video: int, - s_control: int, - s_action: int, - s_sound: int, - has_action: bool, - has_sound: bool, - has_control: bool, - ulysses_size: int, - ) -> None: - if ulysses_size <= 1 or s_gen % ulysses_size == 0: - return - - detail_parts = [] - if has_control: - detail_parts.append(f"control tokens {s_control}") - detail_parts.append(f"video tokens {s_video}") - if has_action: - detail_parts.append(f"action tokens {s_action}") - if has_sound: - detail_parts.append(f"sound tokens {s_sound}") - detail = " = " + " + ".join(detail_parts) if len(detail_parts) > 1 else "" - adjust_detail = ( - "Adjust the spatial resolution, frame count, action chunk size, " - "sound duration, or sound latent FPS so the combined media sequence is a " - "multiple of ulysses_degree." - if has_control or has_action or has_sound - else ( - "Adjust the spatial resolution so that " - "t * ceil(h/patch) * ceil(w/patch) is a multiple " - "of ulysses_degree." - ) - ) - raise ValueError( - f"GEN sequence length ({s_gen}{detail}) must be divisible by " - f"ulysses_degree ({ulysses_size}). {adjust_detail}" - ) - def sound_latent_frames_for_sequence_parallel( self, *, @@ -1907,17 +1887,6 @@ class Cosmos3VFMTransformer(nn.Module): # framework Attention handles the Ulysses head-slicing internally. if self.cached_kv is None or self.cached_freqs_gen is None: raise RuntimeError("Cosmos3 GEN cache was not initialized before running GEN layers.") - self._validate_gen_sequence_parallel( - s_gen=hidden_gen.shape[1], - s_video=s_video, - s_control=s_control, - s_action=s_action, - s_sound=s_sound, - has_action=has_action, - has_sound=has_sound, - has_control=has_control, - ulysses_size=1 if use_multi_control_attention else ulysses_size, - ) freqs_cos, freqs_sin = self.cached_freqs_gen if not use_multi_control_attention: hidden_gen, freqs_cos, freqs_sin = self.gen_sp_prepare(hidden_gen, freqs_cos, freqs_sin) From 3204a0b2ade0f05424b7f11e3bcc03cb3542e0c6 Mon Sep 17 00:00:00 2001 From: Mu GuanLin <1203789601@qq.com> Date: Mon, 7 Sep 2026 18:48:26 +0800 Subject: [PATCH 19/90] [Model][Perf] Optimize LTX Ulysses sequence parallelism (#7079) Signed-off-by: mglyn <1203789601@qq.com> --- docs/user_guide/diffusion_features.md | 6 +- recipes/LTX/LTX-2.5.md | 23 +++ recipes/LTX/LTX-2.md | 11 +- .../models/ltx2/test_ltx25_pipeline.py | 27 +++ .../models/ltx2/test_ltx2_latents.py | 2 +- .../models/ltx2/test_ltx2_parallel.py | 6 +- .../models/ltx2/test_ltx2_pipeline.py | 104 +++++++---- .../ltx2/test_ltx2_sequence_parallel.py | 100 +++++++++++ .../models/ltx2/test_ltx2_transformer.py | 54 +++++- .../ltx2/test_ltx2_transformer_ulysses.py | 167 ++++++++++++++++++ .../diffusion/models/ltx2/ltx2_denoise.py | 8 +- .../diffusion/models/ltx2/ltx2_request.py | 5 - .../diffusion/models/ltx2/ltx2_runtime.py | 92 +++++++--- .../models/ltx2/ltx2_sequence_parallel.py | 125 +++++++++++++ .../diffusion/models/ltx2/ltx2_transformer.py | 63 +++++-- 15 files changed, 685 insertions(+), 108 deletions(-) create mode 100644 tests/diffusion/models/ltx2/test_ltx2_sequence_parallel.py create mode 100644 tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py create mode 100644 vllm_omni/diffusion/models/ltx2/ltx2_sequence_parallel.py diff --git a/docs/user_guide/diffusion_features.md b/docs/user_guide/diffusion_features.md index 00c245489..a8fb6d972 100644 --- a/docs/user_guide/diffusion_features.md +++ b/docs/user_guide/diffusion_features.md @@ -162,9 +162,9 @@ The following tables show which models support each feature: | **Wan2.2** | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.2-S2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ❌ | ❌ | | **Wan2.1-VACE** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | -| **LTX-2** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | -| **LTX-2.3** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | -| **LTX-2.5** | ❌ | ❓ (one-stage) | ❓ (Ulysses only) | ✅ (Full only) | ❓ | ❌ | ✅ | ✅ | ✅ (decode) | ❓ (FP8) | ❌ | +| **LTX-2** | ❌ | ✅ | ✅ (Ulysses only) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | +| **LTX-2.3** | ❌ | ✅ | ✅ (Ulysses only) | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (decode) | ❌ | ❌ | +| **LTX-2.5** | ❌ | ❓ (one-stage) | ✅* (Ulysses only) | ✅ (Full only) | ❓ | ❌ | ✅ | ✅ | ✅ (decode) | ❓ (FP8) | ❌ | | **SANA-Video-2B T2V I2V** | ❌ | ❌ | ✅ (`--usp`, frame-sharded) | ✅ | ✅ (TP=2 only) | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | | **Helios** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ❌ | ❌ | ✅* | | **HunyuanVideo-1.5 T2V I2V** | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ✅ | ✅ (encode/decode) | ✅ | ❌ | diff --git a/recipes/LTX/LTX-2.5.md b/recipes/LTX/LTX-2.5.md index f0be6948b..538eed1ce 100644 --- a/recipes/LTX/LTX-2.5.md +++ b/recipes/LTX/LTX-2.5.md @@ -110,6 +110,29 @@ vllm serve Lightricks/LTX-2.5-Diffusers \ Use the T2V or I2V request below after the server is ready. +### Multi-GPU low-latency path + +For low-latency 1920x1088 distilled two-stage serving, match the Ulysses and +VAE patch-parallel degrees to the GPU count. For example, on four GPUs: + +```bash +CUDA_VISIBLE_DEVICES=0,1,2,3 \ +vllm serve Lightricks/LTX-2.5-Diffusers \ + --omni \ + --model-class-name LTX2DistilledTwoStagePipeline \ + --usp 4 \ + --vae-patch-parallel-size 4 \ + --vae-use-tiling \ + --host 0.0.0.0 \ + --port 8000 \ + --stage-init-timeout 900 +``` + +Strict Ulysses only: `((F - 1) / 8 + 1) * (H / 32) * (W / 32)` and TP-local +SP attention head counts must be divisible by `ulysses_degree`. Use each +phase's `H, W` (half/full resolution for two-stage); non-divisible shapes fail. +Each GPU must still fit the resident weights. Warm up with the production shape. + ## Offline inference Choose values from the pipeline table. For example, the distilled two-stage diff --git a/recipes/LTX/LTX-2.md b/recipes/LTX/LTX-2.md index ec4e8fb3b..5a6a049ee 100644 --- a/recipes/LTX/LTX-2.md +++ b/recipes/LTX/LTX-2.md @@ -101,6 +101,12 @@ sigmas and input latents. Ordinary two-stage uses layer-fused LoRA for an unquantized BF16 Transformer and automatically switches to dynamic LoRA when quantization is enabled. +## Sequence Parallelism + +Strict Ulysses only: `((F - 1) / 8 + 1) * (H / 32) * (W / 32)` and TP-local +SP attention head counts must be divisible by `ulysses_degree`. Use each +phase's `H, W` (half/full resolution for two-stage); non-divisible shapes fail. + ## Serving Start either one-stage checkpoint: @@ -335,9 +341,8 @@ bundled offline CLI do not currently expose `sigmas`. `--max-num-seqs 1`. - `--cfg-parallel-size` shards the complete LTX guidance plan, including STG, modality guidance, and rescale-compatible prediction gathering. -- Sequence parallelism may pad audio latents. Pure Ulysses masks the padding; - Ring cannot, so audio length must be SP-divisible. Use `ring_degree=1` or a - divisible request shape. +- Ulysses masks padded audio latents; video sequences must satisfy the + [sequence-parallel divisibility constraints](#sequence-parallelism). - Cache-DiT is one-stage only; multi-stage configurations are rejected. ## Operational Notes diff --git a/tests/diffusion/models/ltx2/test_ltx25_pipeline.py b/tests/diffusion/models/ltx2/test_ltx25_pipeline.py index 950906190..ae90ea1a3 100644 --- a/tests/diffusion/models/ltx2/test_ltx25_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx25_pipeline.py @@ -5,6 +5,7 @@ import json from dataclasses import replace +from threading import Lock from types import SimpleNamespace from typing import Any @@ -500,6 +501,32 @@ def test_ltx25_checkpoint_selects_distilled_two_stage_profile(tmp_path, monkeypa assert pipe.pipeline_recipe is LTX25_DISTILLED_TWO_STAGE_RECIPE +def test_ltx_runtime_profiles_phase_methods_separately(monkeypatch): + pipe = object.__new__(LTX2DistilledTwoStagePipeline) + pipe.enable_diffusion_pipeline_profiler = True + pipe._profiler_lock = Lock() + pipe._stage_durations = {} + monkeypatch.setattr(ltx2_runtime.current_omni_platform, "is_available", lambda: False) + + with pipe._profile_phase_method("run_phase", "generate_lowres"): + pass + with pipe._profile_phase_method("run_phase", "refine"): + pass + with pipe._profile_phase_method("decode_phase"): + pass + output = DiffusionOutput() + assert pipe._attach_profiled_stage_durations(output) is output + + expected_keys = { + "LTX2DistilledTwoStagePipeline.generate_lowres.run_phase", + "LTX2DistilledTwoStagePipeline.refine.run_phase", + "LTX2DistilledTwoStagePipeline.decode_phase", + } + assert set(pipe.stage_durations) == expected_keys + assert set(output.stage_durations) == expected_keys + assert all(duration >= 0.0 for duration in pipe.stage_durations.values()) + + def test_ltx25_full_config_enrichment_uses_selected_transformer_subfolder(tmp_path): from vllm_omni.diffusion.data import OmniDiffusionConfig diff --git a/tests/diffusion/models/ltx2/test_ltx2_latents.py b/tests/diffusion/models/ltx2/test_ltx2_latents.py index fbac0bfe5..724498f7c 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_latents.py +++ b/tests/diffusion/models/ltx2/test_ltx2_latents.py @@ -103,7 +103,7 @@ def test_prepare_audio_latents_samples_directly_in_packed_token_space(): torch.testing.assert_close(actual, expected, rtol=0, atol=0) -def test_prepare_audio_latents_pads_generated_dummy_length_for_sp(): +def test_prepare_audio_latents_pads_short_audio_for_sp(): pipeline = _make_pipeline(LTX2Pipeline, sequence_parallel_size=2) latents, original_num_frames, padded_num_frames = pipeline.prepare_audio_latents( diff --git a/tests/diffusion/models/ltx2/test_ltx2_parallel.py b/tests/diffusion/models/ltx2/test_ltx2_parallel.py index 0573dfb37..272cdc7d8 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_parallel.py +++ b/tests/diffusion/models/ltx2/test_ltx2_parallel.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Unit tests for LTX guidance and forward-parallel behavior.""" @@ -85,14 +85,14 @@ class TestCFGParallelHelpers: LTX_GUIDANCE_EXECUTOR.validate_guidance_world_size(plan, guidance_world_size) - def test_guidance_parallel_dummy_warms_full_guidance_plan(self, monkeypatch): + def test_guidance_parallel_preserves_full_guidance_plan(self, monkeypatch): from vllm_omni.diffusion.models.ltx2 import ltx2_runtime from vllm_omni.diffusion.models.ltx2.ltx2_recipes import LTX23_ONE_STAGE_RECIPE from vllm_omni.diffusion.models.ltx2.pipeline_ltx2 import LTX2Pipeline monkeypatch.setattr(ltx2_runtime, "get_guidance_parallel_world_size", lambda: 2) pipe = object.__new__(LTX2Pipeline) - req = SimpleNamespace(is_dummy_run=lambda: True) + req = SimpleNamespace() request_inputs = SimpleNamespace(guidance=LTX23_ONE_STAGE_RECIPE.request_guidance) guidance_parallel_ready = pipe._setup_forward_runtime(req, request_inputs, attention_kwargs=None) diff --git a/tests/diffusion/models/ltx2/test_ltx2_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py index c2fe7cbfa..3e630d8dd 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py @@ -175,7 +175,19 @@ def test_ltx_device_uint8_output_rejects_invalid_contract(video, error): _prepare_ltx2_video_output(video, do_normalize=True) -def test_ltx_profiler_covers_video_output_transport(tmp_path, monkeypatch): +@pytest.mark.parametrize( + "parallel_config", + [ + None, + SimpleNamespace(ulysses_degree=4), + SimpleNamespace(tensor_parallel_size=2), + SimpleNamespace(use_hsdp=True, hsdp_shard_size=2), + SimpleNamespace(ulysses_degree=2, tensor_parallel_size=2), + SimpleNamespace(ulysses_degree=2, use_hsdp=True, hsdp_shard_size=2), + ], + ids=["single", "ulysses", "tp", "hsdp", "tp-ulysses", "hsdp-ulysses"], +) +def test_ltx_supported_parallel_configs_keep_output_profiling(tmp_path, monkeypatch, parallel_config): from vllm_omni.diffusion.models.ltx2 import ltx2_runtime (tmp_path / "model_index.json").write_text(json.dumps({"vocoder": ["ltx2", "LTX2Vocoder"]})) @@ -193,7 +205,13 @@ def test_ltx_profiler_covers_video_output_transport(tmp_path, monkeypatch): monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", stub_components) monkeypatch.setattr(LTXRuntime, "setup_diffusion_pipeline_profiler", capture_profiler_setup) - LTX2Pipeline(od_config=SimpleNamespace(model=str(tmp_path), enable_diffusion_pipeline_profiler=False)) + LTX2Pipeline( + od_config=SimpleNamespace( + model=str(tmp_path), + parallel_config=parallel_config, + enable_diffusion_pipeline_profiler=False, + ) + ) assert "video_processor.postprocess_video" in profiler_setup["profiler_targets"] assert "_prepare_video_output_for_transport" in profiler_setup["profiler_targets"] @@ -383,14 +401,42 @@ def test_ltx_checkpoint_validation_rejects_mismatched_scheduler_metadata(expecte ) -def test_ltx_rejects_advanced_uaa_before_component_initialization(): +@pytest.mark.parametrize( + "pipeline_cls", + [ + LTX2Pipeline, + LTX2DistilledOneStagePipeline, + LTX2TwoStagePipeline, + LTX2DistilledTwoStagePipeline, + LTX2T2VDMD2Pipeline, + LTX2I2VDMD2Pipeline, + ], +) +@pytest.mark.parametrize( + ("parallel_config", "error"), + [ + (SimpleNamespace(ulysses_mode="advanced_uaa"), "does not support ulysses_mode='advanced_uaa'"), + (SimpleNamespace(ring_degree=2), "pure Ulysses sequence parallelism only"), + (SimpleNamespace(ulysses_degree=2, ring_degree=2), "pure Ulysses sequence parallelism only"), + (SimpleNamespace(allgather_degree=2), "pure Ulysses sequence parallelism only"), + ], + ids=["advanced-uaa", "ring", "hybrid", "allgather-kv"], +) +def test_ltx_rejects_unsupported_sp_before_component_initialization(pipeline_cls, parallel_config, error, monkeypatch): + from vllm_omni.diffusion.models.ltx2 import ltx2_runtime + + def unexpected_model_access(*_args, **_kwargs): + pytest.fail("Unsupported SP must be rejected before accessing model files or initializing components") + + monkeypatch.setattr(ltx2_runtime, "detect_ltx_model_version", unexpected_model_access) + monkeypatch.setattr(ltx2_runtime, "initialize_pipeline_components", unexpected_model_access) od_config = SimpleNamespace( model="unused", - parallel_config=SimpleNamespace(ulysses_mode="advanced_uaa"), + parallel_config=parallel_config, ) - with pytest.raises(ValueError, match="does not support ulysses_mode='advanced_uaa'"): - LTX2Pipeline(od_config=od_config) + with pytest.raises(ValueError, match=error): + pipeline_cls(od_config=od_config) def test_ltx23_checkpoint_selects_version_specific_one_stage_profile(tmp_path, monkeypatch): @@ -1409,39 +1455,19 @@ def test_ltx_distilled_forward_rejects_fixed_recipe_overrides(direct_kwargs, sam pipeline.forward(req, **direct_kwargs) -def test_ltx_distilled_dummy_run_uses_fixed_recipe_values(): - from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID, OmniDiffusionRequest - from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch - from vllm_omni.inputs.data import OmniDiffusionSamplingParams - - req = DiffusionRequestBatch( - [ - OmniDiffusionRequest( - prompt="dummy run", - sampling_params=OmniDiffusionSamplingParams( - height=512, - width=512, - num_frames=LTX2DistilledPipeline.dummy_run_num_frames, - num_inference_steps=1, - guidance_scale=0.0, - ), - request_id=DUMMY_DIFFUSION_REQUEST_ID, - ) - ] - ) - pipeline = _make_ltx_request_pipe(LTX2DistilledPipeline) - seen = {} - - def fake_run_recipe(_req, request_inputs, **_kwargs): - seen["request_inputs"] = request_inputs - return "dummy-output" - - object.__setattr__(pipeline, "_run_recipe", fake_run_recipe) - - assert pipeline.forward(req) == "dummy-output" - assert seen["request_inputs"].num_frames == 1 - assert seen["request_inputs"].num_inference_steps == 8 - assert seen["request_inputs"].guidance == LTXGuidanceSpec.positive_only() +@pytest.mark.parametrize( + "pipeline_cls", + [ + LTX2Pipeline, + LTX2DistilledOneStagePipeline, + LTX2TwoStagePipeline, + LTX2DistilledTwoStagePipeline, + LTX2T2VDMD2Pipeline, + LTX2I2VDMD2Pipeline, + ], +) +def test_ltx_pipelines_disable_startup_dummy_run(pipeline_cls): + assert pipeline_cls.dummy_run_num_frames == 0 def test_ltx_custom_sigmas_bypass_scheduler_shifting(): diff --git a/tests/diffusion/models/ltx2/test_ltx2_sequence_parallel.py b/tests/diffusion/models/ltx2/test_ltx2_sequence_parallel.py new file mode 100644 index 000000000..395b53ba2 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_sequence_parallel.py @@ -0,0 +1,100 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +from datetime import timedelta +from types import SimpleNamespace + +import pytest +import torch +import torch.distributed as dist +import torch.nn.functional as F + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.models.ltx2.ltx2_sequence_parallel import ( + LTX2VideoToAudioParallelAttention, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] + + +def _run_video_to_audio_parity(rank: int, world_size: int, master_port: int) -> None: + dist.init_process_group( + "gloo", + init_method=f"tcp://127.0.0.1:{master_port}", + rank=rank, + world_size=world_size, + timeout=timedelta(seconds=60), + ) + try: + # Non-contiguous SP subgroups match TP-fastest rank ordering. Different + # seeds across groups catch collectives that accidentally use WORLD. + group_count = world_size // 2 + ulysses_group = dist.group.WORLD + if group_count > 1: + for offset in range(group_count): + ranks = list(range(offset, world_size, group_count)) + group = dist.new_group(ranks) + if rank in ranks: + ulysses_group = group + ulysses_rank = dist.get_rank(ulysses_group) + sp_group = SimpleNamespace( + ring_world_size=1, + ulysses_group=ulysses_group, + ulysses_world_size=2, + ulysses_rank=ulysses_rank, + ) + strategy = LTX2VideoToAudioParallelAttention(sp_group) + + for batch_size, masked in [(1, False), (2, True)]: + torch.manual_seed(17 + rank % group_count) + query = torch.randn(batch_size, 3, 4, 8) + global_key = torch.randn(batch_size, 6, 4, 8) + global_value = torch.randn(batch_size, 6, 4, 8) + key = global_key.chunk(2, dim=1)[ulysses_rank].contiguous() + value = global_value.chunk(2, dim=1)[ulysses_rank].contiguous() + mask = None + if masked: + mask = torch.ones(batch_size, 1, 1, 6, dtype=torch.bool) + mask[0, ..., -1] = False + mask[1, ..., -2:] = False + metadata = AttentionMetadata(attn_mask=mask) if masked else None + + local_query, local_key, local_value, local_metadata, ctx = strategy.pre_attention( + query, key, value, metadata + ) + assert local_metadata is metadata + local_output = F.scaled_dot_product_attention( + local_query.transpose(1, 2), + local_key.transpose(1, 2), + local_value.transpose(1, 2), + attn_mask=local_metadata.attn_mask if local_metadata is not None else None, + ).transpose(1, 2) + actual = strategy.post_attention(local_output, ctx) + expected = F.scaled_dot_product_attention( + query.transpose(1, 2), + global_key.transpose(1, 2), + global_value.transpose(1, 2), + attn_mask=mask, + ).transpose(1, 2) + + torch.testing.assert_close(actual, expected, rtol=1e-5, atol=1e-6) + finally: + dist.destroy_process_group() + + +@pytest.mark.parametrize("world_size", [2, 4], ids=["world", "subgroups"]) +def test_ltx_video_to_audio_sp_matches_replicated_attention(unused_tcp_port, world_size): + torch.multiprocessing.spawn( + _run_video_to_audio_parity, + args=(world_size, unused_tcp_port), + nprocs=world_size, + ) + + +def test_ltx_video_to_audio_sp_rejects_ring_parallelism(): + sp_group = SimpleNamespace(ring_world_size=2) + + with pytest.raises(NotImplementedError, match="ring_degree must be 1"): + LTX2VideoToAudioParallelAttention(sp_group) diff --git a/tests/diffusion/models/ltx2/test_ltx2_transformer.py b/tests/diffusion/models/ltx2/test_ltx2_transformer.py index 937d487e6..f12fbc466 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_transformer.py +++ b/tests/diffusion/models/ltx2/test_ltx2_transformer.py @@ -141,16 +141,54 @@ def test_ltx_transformer_exposes_hsdp_shard_conditions_for_blocks(): @pytest.mark.parametrize("rope_type", ["interleaved", "split"]) -def test_ltx_sp_plan_shards_video_and_audio_timesteps_together(rope_type): - root_plan = LTX2VideoTransformer3DModel._build_sp_plan(rope_type)[""] +def test_ltx_sp_plan_only_shards_video_stream(rope_type): + plan = LTX2VideoTransformer3DModel._build_sp_plan(rope_type) + root_plan = plan[""] - video_timestep = root_plan["timestep"] - audio_timestep = root_plan["audio_timestep"] + assert set(root_plan) == {"hidden_states", "keyframes_mask", "timestep"} + assert root_plan["hidden_states"].split_dim == 1 + assert root_plan["keyframes_mask"].split_dim == 1 + assert root_plan["timestep"].split_dim == 1 + assert root_plan["timestep"].expected_dims == 2 + assert not root_plan["timestep"].split_output + assert set(plan) == {"", "rope", "cross_attn_rope", "proj_out"} - assert video_timestep.split_dim == audio_timestep.split_dim == 1 - assert video_timestep.expected_dims == audio_timestep.expected_dims == 2 - assert not video_timestep.split_output - assert not audio_timestep.split_output + +def test_ltx_attention_assigns_video_only_sp_modes(monkeypatch): + modes = {} + + class FakeAttention(nn.Module): + def __init__(self, *, prefix, sequence_parallel_mode="local", **_kwargs): + super().__init__() + modes[prefix.rsplit(".", 1)[-1]] = sequence_parallel_mode + + class FakeFeedForward(nn.Module): + def __init__(self, *_args, **_kwargs): + super().__init__() + + monkeypatch.setattr(ltx2_transformer, "LTX2Attention", FakeAttention) + monkeypatch.setattr(ltx2_transformer, "LTX2FeedForward", FakeFeedForward) + + LTX2VideoTransformerBlock( + dim=8, + num_attention_heads=2, + attention_head_dim=4, + cross_attention_dim=8, + audio_dim=8, + audio_num_attention_heads=2, + audio_attention_head_dim=4, + audio_cross_attention_dim=8, + prefix="transformer_blocks.0", + ) + + assert modes == { + "attn1": "video_self", + "audio_attn1": "local", + "attn2": "local", + "audio_attn2": "local", + "audio_to_video_attn": "local", + "video_to_audio_attn": "video_to_audio", + } def test_ltx_sp_keeps_cross_attention_key_padding_mask(monkeypatch): diff --git a/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py b/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py new file mode 100644 index 000000000..9f09b72a7 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Full-transformer numeric parity for LTX Ulysses sequence parallelism. + +This FP32/SDPA check verifies that Ulysses preserves the complete transformer +mathematical path. It does not guarantee pixel-level E2E parity: production +BF16 and compiled attention kernels can change rounding order, and diffusion +can amplify that drift across denoising steps. +""" + +from __future__ import annotations + +import os + +import pytest +import torch + +from tests.helpers.mark import hardware_test +from vllm_omni.diffusion.config import set_current_diffusion_config +from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig +from vllm_omni.diffusion.distributed.parallel_state import ( + destroy_distributed_env, + init_distributed_environment, + initialize_model_parallel, +) +from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelConfig +from vllm_omni.diffusion.forward_context import get_forward_context, set_forward_context +from vllm_omni.diffusion.hooks.sequence_parallel import apply_sequence_parallel +from vllm_omni.diffusion.models.ltx2.ltx2_transformer import LTX2VideoTransformer3DModel +from vllm_omni.platforms import current_omni_platform + +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.parallel] + +_FP32_REL_L2 = 1e-5 +_TINY_CONFIG = { + "in_channels": 4, + "out_channels": 4, + "num_attention_heads": 2, + "attention_head_dim": 4, + "cross_attention_dim": 8, + "vae_scale_factors": (1, 1, 1), + "pos_embed_max_pos": 4, + "base_height": 2, + "base_width": 2, + "audio_in_channels": 4, + "audio_out_channels": 4, + "audio_num_attention_heads": 2, + "audio_attention_head_dim": 4, + "audio_cross_attention_dim": 8, + "audio_scale_factor": 1, + "audio_pos_embed_max_pos": 4, + "audio_sampling_rate": 16, + "audio_hop_length": 4, + "num_layers": 1, + "caption_channels": 8, + "rope_type": "split", + "use_prompt_embeddings": False, + "use_keyframes_abs_pos_embedding": True, +} + + +def _relative_l2(actual: torch.Tensor, expected: torch.Tensor) -> float: + return ((actual - expected).double().norm() / expected.double().norm()).item() + + +def _omni_config(sp_size: int) -> OmniDiffusionConfig: + return OmniDiffusionConfig.from_kwargs( + model="test", + dtype=torch.float32, + parallel_config=DiffusionParallelConfig( + pipeline_parallel_size=1, + data_parallel_size=1, + tensor_parallel_size=1, + sequence_parallel_size=sp_size, + ulysses_degree=sp_size, + ring_degree=1, + cfg_parallel_size=1, + ), + diffusion_attention_backend="TORCH_SDPA", + ) + + +def _inputs(device: torch.device) -> dict[str, torch.Tensor | int | bool]: + generator = torch.Generator().manual_seed(123) + return { + "hidden_states": torch.randn(1, 8, 4, generator=generator, device="cpu").to(device), + "audio_hidden_states": torch.randn(1, 4, 4, generator=generator, device="cpu").to(device), + "encoder_hidden_states": torch.randn(1, 3, 8, generator=generator, device="cpu").to(device), + "audio_encoder_hidden_states": torch.randn(1, 3, 8, generator=generator, device="cpu").to(device), + "timestep": torch.linspace(0.1, 0.8, 8, device=device).view(1, 8), + "audio_timestep": torch.linspace(0.2, 0.5, 4, device=device).view(1, 4), + "keyframes_mask": torch.tensor([[[1.0], [0.0], [1.0], [0.0], [0.0], [1.0], [0.0], [0.0]]], device=device), + "sigma": torch.tensor([0.1], device=device), + "audio_sigma": torch.tensor([0.2], device=device), + "num_frames": 2, + "height": 2, + "width": 2, + "audio_num_frames": 4, + "return_dict": False, + } + + +def _run_transformer_parity(rank: int, world_size: int, master_port: int) -> None: + device = torch.device(f"{current_omni_platform.device_type}:{rank}") + current_omni_platform.set_device(device) + os.environ.update( + { + "RANK": str(rank), + "LOCAL_RANK": str(rank), + "WORLD_SIZE": str(world_size), + "MASTER_ADDR": "localhost", + "MASTER_PORT": str(master_port), + } + ) + + try: + init_distributed_environment() + initialize_model_parallel(sequence_parallel_size=world_size, ulysses_degree=world_size) + + sp_config = _omni_config(world_size) + torch.manual_seed(42) + with set_forward_context(omni_diffusion_config=sp_config), set_current_diffusion_config(sp_config): + model = LTX2VideoTransformer3DModel(**_TINY_CONFIG).to(device).eval() + with torch.no_grad(): + for _, parameter in sorted(model.named_parameters()): + torch.nn.init.normal_(parameter, mean=0.0, std=0.02) + + inputs = _inputs(device) + reference_config = _omni_config(1) + with torch.no_grad(), set_forward_context(omni_diffusion_config=reference_config): + expected_video, expected_audio = model(**inputs) + + apply_sequence_parallel( + model, + SequenceParallelConfig(ulysses_degree=world_size, ring_degree=1), + model._sp_plan, + ) + with torch.no_grad(), set_forward_context(omni_diffusion_config=sp_config): + get_forward_context().sp_plan_hooks_applied = True + actual_video, actual_audio = model(**inputs) + + v2a_attention = model.transformer_blocks[0].video_to_audio_attn.attn + assert v2a_attention._video_to_audio_strategy is not None + assert torch.isfinite(expected_video).all() + assert torch.isfinite(expected_audio).all() + for modality, actual, expected in ( + ("video", actual_video, expected_video), + ("audio", actual_audio, expected_audio), + ): + drift = _relative_l2(actual, expected) + assert drift <= _FP32_REL_L2, ( + f"LTX SP{world_size} {modality} output drifted rel_l2={drift:.3e} from SP1 (bound {_FP32_REL_L2:.1e})" + ) + finally: + destroy_distributed_env() + + +@pytest.mark.full_model +@hardware_test(res={"cuda": "L4"}, num_cards=2) +def test_ltx_ulysses_transformer_matches_sp1(unused_tcp_port) -> None: + """Exercise real plan hooks, RoPE/timestep sharding, and attention routing.""" + world_size = 2 + torch.multiprocessing.spawn( + _run_transformer_parity, + args=(world_size, unused_tcp_port), + nprocs=world_size, + ) diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_denoise.py b/vllm_omni/diffusion/models/ltx2/ltx2_denoise.py index 7ebd94143..f35ce25c5 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_denoise.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_denoise.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Shared denoise execution primitives for LTX pipelines.""" @@ -578,12 +578,6 @@ class LTXPhaseExecutor: video_audio_step_adapter=video_audio_step_adapter, ) video_coords, audio_coords = prepare_rope_coords_stage(pipeline, forward_ctx, latents, audio_latents) - ring_degree = getattr(pipeline.od_config.parallel_config, "ring_degree", 1) or 1 - if padded_audio_num_frames > original_audio_num_frames and ring_degree > 1: - raise ValueError( - "LTX audio padding requires an attention mask, which Ring sequence parallelism does not support. " - "Use Ulysses-only SP or choose a request whose audio latent length is divisible by the SP size." - ) denoise_ctx = LTXDenoiseContext( latents=latents, audio_latents=audio_latents, diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_request.py b/vllm_omni/diffusion/models/ltx2/ltx2_request.py index 22230e486..f42c43709 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_request.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_request.py @@ -497,7 +497,6 @@ class LTXRequestMixin: image_crf = _normalize_image_crf(first_image_crf if first_image_crf is not None else image_crf) sampling = sampling_params_list[0] - is_dummy_run = req.is_dummy_run() prompt = [item if isinstance(item, str) else (item.get("prompt") or "") for item in req.prompts] or prompt negative_prompt = _resolve_negative_prompts( req.prompts, @@ -511,8 +510,6 @@ class LTXRequestMixin: num_inference_steps = ( sampling.num_inference_steps or num_inference_steps or self.pipeline_recipe.num_inference_steps ) - if is_dummy_run and self.pipeline_recipe.fixed_num_inference_steps: - num_inference_steps = self.pipeline_recipe.num_inference_steps num_inference_steps = max(int(num_inference_steps), 2) num_videos_per_prompt = ( @@ -528,8 +525,6 @@ class LTXRequestMixin: for item in sampling_params_list ] guidance = guidance_specs[0] - if is_dummy_run and not self.pipeline_recipe.phases[0].allow_guidance_override: - guidance = self.pipeline_recipe.request_guidance if any(item != guidance for item in guidance_specs[1:]): raise ValueError("Batched LTX requests must use identical video/audio guidance parameters.") diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py b/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py index 0ffa09dcb..2c51b17d9 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py @@ -5,14 +5,16 @@ from __future__ import annotations -from collections.abc import Iterable -from contextlib import nullcontext +import time +from collections.abc import Iterable, Iterator +from contextlib import contextmanager, nullcontext from dataclasses import replace from typing import Any, ClassVar import torch from diffusers.utils.torch_utils import randn_tensor from torch import nn +from vllm.logger import init_logger from vllm.model_executor.models.utils import AutoWeightsLoader from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig @@ -60,6 +62,8 @@ from .ltx2_request import ( validate_pipeline_request, ) +logger = init_logger(__name__) + def _run_ltx_vocoder(vocoder: nn.Module, generated_mel: torch.Tensor) -> torch.Tensor: """Run the BWE vocoder in FP32, matching the official LTX pipeline.""" @@ -195,7 +199,7 @@ class LTXRuntime( connector_batches_cfg = False distributed_video_decode = True support_image_input = False - dummy_run_num_frames = 1 + dummy_run_num_frames: ClassVar[int] = 0 preserve_sp_padded_audio_duration = False reports_stage_durations = False @@ -207,6 +211,14 @@ class LTXRuntime( f"{self.__class__.__name__} does not support ulysses_mode='advanced_uaa'. " "Use the default ulysses_mode='strict' for LTX sequence parallelism." ) + ring_degree = getattr(parallel_config, "ring_degree", 1) + allgather_degree = getattr(parallel_config, "allgather_degree", 1) + if ring_degree != 1 or allgather_degree != 1: + raise ValueError( + f"{self.__class__.__name__} supports pure Ulysses sequence parallelism only. " + "Set ring_degree=1 and allgather_degree=1; " + f"got {ring_degree=} and {allgather_degree=}." + ) self.model_version = detect_ltx_model_version(od_config.model, revision=getattr(od_config, "revision", None)) self.use_diffusion_decoder = _ltx2_use_diffusion_decoder(od_config, self.model_version) self.component_profile = resolve_ltx_component_profile(self.pipeline_kind, self.model_version) @@ -354,27 +366,29 @@ class LTXRuntime( ) ) self._enter_phase(phase_recipe) - phase_inputs = self._build_phase_inputs( - request_inputs, - phase_recipe, - phase_results[-1] if phase_results else None, - ) + with self._profile_phase_method("_build_phase_inputs", phase_recipe.name): + phase_inputs = self._build_phase_inputs( + request_inputs, + phase_recipe, + phase_results[-1] if phase_results else None, + ) if phase_sigmas is not None: phase_inputs = replace(phase_inputs, num_inference_steps=len(phase_sigmas) - 1) noise_scale = phase_recipe.noise_scale if override_sigmas is not None and phase_recipe.input_transform == "spatial_upsample": noise_scale = float(override_sigmas[0]) - phase_result = self.run_phase( - req, - phase_inputs, - noise_scale=noise_scale, - sigmas=phase_sigmas, - timesteps=None, - attention_kwargs=None, - phase_recipe=phase_recipe, - image=image, - prompt_context=prompt_context, - ) + with self._profile_phase_method("run_phase", phase_recipe.name): + phase_result = self.run_phase( + req, + phase_inputs, + noise_scale=noise_scale, + sigmas=phase_sigmas, + timesteps=None, + attention_kwargs=None, + phase_recipe=phase_recipe, + image=image, + prompt_context=prompt_context, + ) phase_results.append(phase_result) prompt_context = phase_result.forward_context.prompt_context @@ -384,7 +398,45 @@ class LTXRuntime( video=phase_results[self.pipeline_recipe.video_output_phase].video, audio=phase_results[self.pipeline_recipe.audio_output_phase].audio, ) - return self.decode_phase(output_phase) + with self._profile_phase_method("decode_phase"): + output = self.decode_phase(output_phase) + return self._attach_profiled_stage_durations(output) + + @contextmanager + def _profile_phase_method(self, method_name: str, phase_name: str | None = None) -> Iterator[None]: + if not getattr(self, "enable_diffusion_pipeline_profiler", False): + yield + return + + metric_parts = [self.__class__.__name__] + if phase_name is not None: + metric_parts.append(phase_name) + metric_parts.append(method_name) + metric_name = ".".join(metric_parts) + + if current_omni_platform.is_available(): + current_omni_platform.synchronize() + start_time = time.perf_counter() + try: + yield + finally: + if current_omni_platform.is_available(): + current_omni_platform.synchronize() + duration = time.perf_counter() - start_time + logger.info("[DiffusionPipelineProfiler] %s took %.6fs", metric_name, duration) + with self._profiler_lock: + self._stage_durations[metric_name] = self._stage_durations.get(metric_name, 0.0) + duration + + def _attach_profiled_stage_durations( + self, output: DiffusionOutput | list[DiffusionOutput] + ) -> DiffusionOutput | list[DiffusionOutput]: + if not getattr(self, "enable_diffusion_pipeline_profiler", False): + return output + stage_durations = self.stage_durations + outputs = output if isinstance(output, list) else [output] + for item in outputs: + item.stage_durations = dict(stage_durations) + return output def _enter_phase(self, phase: LTXPhaseRecipe) -> None: self._active_phase_name = phase.name diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_sequence_parallel.py b/vllm_omni/diffusion/models/ltx2/ltx2_sequence_parallel.py new file mode 100644 index 000000000..874df90e3 --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ltx2_sequence_parallel.py @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +from dataclasses import dataclass + +import torch +import torch.distributed as dist + +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.parallel.base import ( + ParallelAttentionContext, +) +from vllm_omni.diffusion.distributed.comm import SeqAllToAll4D +from vllm_omni.diffusion.distributed.group_coordinator import ( + SequenceParallelGroupCoordinator, +) + + +@dataclass(frozen=True, slots=True) +class _VideoToAudioContext(ParallelAttentionContext): + ulysses_pg: dist.ProcessGroup + use_sync: bool + + +class LTX2VideoToAudioParallelAttention: + """LTX video-to-audio SP with replicated audio queries. + + Video K/V arrive sequence-sharded, while the much shorter audio query is + replicated. Redistribute only K/V across sequence/head dimensions, slice + audio query heads locally, then gather output heads. This uses three + collectives instead of standard Ulysses' four and keeps audio replicated. + """ + + def __init__( + self, + sp_group: SequenceParallelGroupCoordinator, + *, + use_sync: bool = False, + ) -> None: + if sp_group.ring_world_size != 1: + raise NotImplementedError( + "LTX video-to-audio sequence parallelism currently supports pure Ulysses only (ring_degree must be 1)." + ) + self._sp_group = sp_group + self._ulysses_pg = sp_group.ulysses_group + self._use_sync = use_sync + + @property + def enabled(self) -> bool: + return True + + @property + def name(self) -> str: + return "ltx2_video_to_audio" + + def pre_attention( + self, + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attn_metadata: AttentionMetadata | None, + ): + if query.ndim != 4 or key.ndim != 4 or value.ndim != 4: + raise ValueError( + "LTX video-to-audio SP expects Q/K/V in (B, S, H, D) layout, " + f"got {query.shape=}, {key.shape=}, {value.shape=}." + ) + if key.shape != value.shape: + raise ValueError( + "LTX video-to-audio SP requires matching K/V shapes, " + f"got key={tuple(key.shape)} and value={tuple(value.shape)}." + ) + + world_size = self._sp_group.ulysses_world_size + rank = self._sp_group.ulysses_rank + query_heads = int(query.shape[2]) + if query_heads % world_size != 0: + raise ValueError( + "LTX video-to-audio SP requires query heads divisible by " + f"ulysses_degree, got heads={query_heads}, ulysses_degree={world_size}." + ) + if int(key.shape[2]) % world_size != 0: + raise ValueError( + "LTX video-to-audio SP requires K/V heads divisible by " + f"ulysses_degree, got heads={key.shape[2]}, ulysses_degree={world_size}." + ) + + heads_per_rank = query_heads // world_size + head_start = rank * heads_per_rank + query = query[:, :, head_start : head_start + heads_per_rank, :].contiguous() + key = SeqAllToAll4D.apply(self._ulysses_pg, key, 2, 1, self._use_sync) + value = SeqAllToAll4D.apply(self._ulysses_pg, value, 2, 1, self._use_sync) + + ctx = _VideoToAudioContext( + name=self.name, + ulysses_pg=self._ulysses_pg, + use_sync=self._use_sync, + ) + return query, key, value, attn_metadata, ctx + + def post_attention( + self, + attn_output: torch.Tensor, + ctx: ParallelAttentionContext | None, + ) -> torch.Tensor: + if not isinstance(ctx, _VideoToAudioContext): + raise TypeError(f"Unexpected LTX video-to-audio context: {type(ctx)!r}.") + + # Gather along heads by moving H to the leading dimension required by + # all_gather_into_tensor, then restore the backend's (B, S, H, D) layout. + local_heads_first = attn_output.permute(2, 0, 1, 3).contiguous() + world_size = dist.get_world_size(ctx.ulysses_pg) + gathered = torch.empty( + (local_heads_first.shape[0] * world_size, *local_heads_first.shape[1:]), + dtype=attn_output.dtype, + device=attn_output.device, + ) + dist.all_gather_into_tensor(gathered, local_heads_first, group=ctx.ulysses_pg) + if ctx.use_sync: + from vllm_omni.platforms import current_omni_platform + + current_omni_platform.synchronize() + return gathered.permute(1, 2, 0, 3).contiguous() diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py index 0b0e7fc08..1d95fc3df 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_transformer.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2025 The Lightricks team and The HuggingFace Team. # All rights reserved. # @@ -47,9 +50,12 @@ from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.cache.cachedit import CacheDiTAdapterConfig from vllm_omni.diffusion.distributed.hsdp_utils import is_transformer_block_module +from vllm_omni.diffusion.distributed.parallel_state import get_sp_group from vllm_omni.diffusion.distributed.sp_plan import SequenceParallelInput, SequenceParallelOutput from vllm_omni.diffusion.forward_context import get_forward_context, is_forward_context_available +from .ltx2_sequence_parallel import LTX2VideoToAudioParallelAttention + logger = init_logger(__name__) _RMSNORM_INIT_PARAMS = inspect.signature(RMSNorm.__init__).parameters @@ -359,6 +365,34 @@ def to_ltx_padding_mask(attention_mask: torch.Tensor) -> torch.Tensor: class _LTX2ParallelAttention(Attention): """Preserve LTX SP collectives while applying model-specific padding masks.""" + _SP_MODES = frozenset({"local", "video_self", "video_to_audio"}) + + def __init__(self, *args, ltx_sp_mode: str = "local", **kwargs): + if ltx_sp_mode not in self._SP_MODES: + raise ValueError( + f"Unsupported LTX sequence-parallel mode {ltx_sp_mode!r}; expected one of {sorted(self._SP_MODES)}." + ) + super().__init__(*args, **kwargs) + self.ltx_sp_mode = ltx_sp_mode + self._video_to_audio_strategy = None + + def _get_active_parallel_strategy(self): + base_strategy = super()._get_active_parallel_strategy() + if self.ltx_sp_mode == "local" or not base_strategy.enabled: + return self._no_parallel_strategy + if self.ltx_sp_mode == "video_self": + return base_strategy + if base_strategy.name != "ulysses": + raise NotImplementedError( + f"LTX video-to-audio sequence parallelism requires pure Ulysses; got strategy={base_strategy.name!r}." + ) + if self._video_to_audio_strategy is None: + self._video_to_audio_strategy = LTX2VideoToAudioParallelAttention( + get_sp_group(), + use_sync=self.use_sync, + ) + return self._video_to_audio_strategy + def _run_local_attention(self, query, key, value, attn_metadata): attention_mask = attn_metadata.attn_mask if attn_metadata is not None else None if attention_mask is not None: @@ -597,6 +631,7 @@ class LTX2Attention(torch.nn.Module): quant_config: "QuantizationConfig | None" = None, prefix: str = "", disable_kv_quant: bool = False, + sequence_parallel_mode: str = "local", ): super().__init__() # LTX-2 uses "rms_norm_across_heads", LTX-2.3 uses "rms_norm" -- both @@ -721,6 +756,7 @@ class LTX2Attention(torch.nn.Module): causal=False, prefix=prefix, disable_kv_quant=disable_kv_quant, + ltx_sp_mode=sequence_parallel_mode, ) # LTX-2.3: per-head gated attention @@ -876,6 +912,7 @@ class LTX2VideoTransformerBlock(nn.Module): apply_gated_attention=video_gated_attn, quant_config=quant_config, prefix=f"{prefix}.attn1" if prefix else "attn1", + sequence_parallel_mode="video_self", ) self.audio_norm1 = _make_rms_norm(audio_dim, eps=eps, elementwise_affine=elementwise_affine) @@ -965,6 +1002,7 @@ class LTX2VideoTransformerBlock(nn.Module): apply_gated_attention=audio_gated_attn, quant_config=quant_config, prefix=f"{prefix}.video_to_audio_attn" if prefix else "video_to_audio_attn", + sequence_parallel_mode="video_to_audio", ) # 4. Feedforward layers @@ -1568,38 +1606,25 @@ class LTX2VideoTransformer3DModel(nn.Module): return { "": { - # Shard video/audio latents across sequence + # Keep only the long video stream sequence-sharded. Audio and + # prompt streams remain replicated on every SP rank. "hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False), - "audio_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False), "keyframes_mask": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False), - # Shard prompt embeds across sequence - "encoder_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False), - "audio_encoder_hidden_states": SequenceParallelInput(split_dim=1, expected_dims=3, split_output=False), - # Keep per-token video/audio timestep embeddings aligned with - # their corresponding latent sequence shards. Official LTX - # multi-guidance supplies both tensors as (B, seq_len). + # Keep per-token video timestep embeddings aligned with the + # video latent shard. LTX guidance supplies a separate, + # replicated audio_timestep under SP. "timestep": SequenceParallelInput(split_dim=1, expected_dims=2, split_output=False), - "audio_timestep": SequenceParallelInput(split_dim=1, expected_dims=2, split_output=False), }, "rope": { 0: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), 1: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), }, - "audio_rope": { - 0: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), - 1: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), - }, "cross_attn_rope": { 0: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), 1: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), }, - "cross_attn_audio_rope": { - 0: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), - 1: SequenceParallelInput(split_dim=rope_split_dim, expected_dims=rope_expected_dims, split_output=True), - }, - # Gather outputs before returning + # Video output is sharded; audio output is already replicated. "proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3), - "audio_proj_out": SequenceParallelOutput(gather_dim=1, expected_dims=3), } def __init__( From 4b96c57730e8ea83cf51a6ef66e29aa688b8592d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zeyu=20Huang=20=7C=20=E9=BB=83=E6=BE=A4=E5=AE=87?= <11222265+fhfuih@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:52:24 +0800 Subject: [PATCH 20/90] [skip ci][Doc] Module design doc for diffusion runtime (#6440) Signed-off-by: Huang, Zeyu <11222265+fhfuih@users.noreply.github.com> Co-authored-by: Alicia <115451386+congw729@users.noreply.github.com> --- .../module/diffusion/diffusion_runtime.md | 441 +++++++++++++++++- 1 file changed, 434 insertions(+), 7 deletions(-) diff --git a/docs/design/module/diffusion/diffusion_runtime.md b/docs/design/module/diffusion/diffusion_runtime.md index 61224cd5b..0d36d08c2 100644 --- a/docs/design/module/diffusion/diffusion_runtime.md +++ b/docs/design/module/diffusion/diffusion_runtime.md @@ -8,22 +8,44 @@ owners: - "@SamitHuang" - "@fhfuih" primary_code_paths: + - vllm_omni/diffusion/diffusion_engine.py + - vllm_omni/diffusion/request.py + - vllm_omni/diffusion/data.py - vllm_omni/diffusion/sched/** - vllm_omni/diffusion/executor/** - vllm_omni/diffusion/worker/** related_code_paths: + - vllm_omni/diffusion/stage_diffusion_client.py + - vllm_omni/diffusion/stage_diffusion_proc.py + - vllm_omni/diffusion/inline_stage_diffusion_client.py + - vllm_omni/diffusion/ipc.py - vllm_omni/diffusion/hooks/** - vllm_omni/diffusion/postprocess/** depends_on: - ../engine_orchestration.md + - ../stage_runtime.md - ../input_output_modality_contracts.md - diffusion_model_integration.md validation_paths: - - tests/diffusion/diffusion_backend/** - - tests/diffusion/hooks/** + - tests/diffusion/test_diffusion_engine.py + - tests/diffusion/test_diffusion_engine_cleanup.py + - tests/diffusion/test_diffusion_engine_dummy_run.py + - tests/diffusion/test_diffusion_engine_rpc_routing.py + - tests/diffusion/test_diffusion_scheduler.py + - tests/diffusion/test_diffusion_model_runner.py + - tests/diffusion/test_diffusion_worker.py + - tests/diffusion/test_multiproc_engine_concurrency.py + - tests/diffusion/test_uniproc_executor.py + - tests/diffusion/test_result_pump.py + - tests/diffusion/test_async_output_worker.py + - tests/diffusion/test_diffusion_ipc.py + - tests/diffusion/test_diffusion_streaming_output.py + - tests/diffusion/test_stage_diffusion_proc.py + - tests/diffusion/test_inline_stage_diffusion_client.py upstream_refs: - diffusers.DiffusionPipeline -last_reviewed: 2026-07-16 +last_reviewed: 2026-09-02 +last_verified_commit: e51fe6ec1b9a9a0e14bb1fdb296d61b6593b93c6 --- # Diffusion runtime @@ -31,6 +53,380 @@ last_reviewed: 2026-07-16 The diffusion runtime owns request admission, scheduling, execution, progress, output, cancellation, and cleanup inside a diffusion stage. +It is easiest to think of the runtime as a small control loop: + +1. the **scheduler** chooses ready requests; +2. the **executor** sends that choice to the worker layer; +3. each **worker** manages its device and delegates model work to a **runner**; +4. the runner calls the model pipeline and returns per-request results; and +5. the engine feeds those results back to the scheduler and output streams. + +!!! note + + This page describes the runtime plumbing. Model loading and pipeline + contracts belong to [Diffusion model integration](diffusion_model_integration.md). + Batch compatibility and step-state details belong to + [Diffusion continuous batching](continuous_batching.md). + +## Goals and non-goals + +The runtime aims to keep request policy separate from device execution. It +also keeps request identity stable from admission through the final output, +including cancellation and failures. + +This module does **not** choose the next Omni stage, place stage replicas, or +define a model's denoising algorithm: + +- cross-stage routing belongs to [Engine orchestration](../engine_orchestration.md); +- process placement and replica lifecycle belong to + [Stage runtime](../stage_runtime.md); and +- model-specific work belongs to + [Diffusion model integration](diffusion_model_integration.md). + +## Runtime at a glance + +The stage owns `DiffusionEngine` and a `DiffusionExecutor` backend. The inline +stage client keeps that stack in the caller process; a process-backed stage +runs it inside `StageDiffusionProc`. + +The executor then chooses how the worker runs: + +- **`uni`** (default when `num_gpus == 1`): `UniProcDiffusionExecutor` builds + one in-process worker. There is no worker subprocess and no shared-memory + RPC. +- **`mp`** (default when `num_gpus > 1`, or when set explicitly): + `MultiprocDiffusionExecutor` starts one `WorkerProc` per device. + +Either way, each worker owns its device, distributed state, +`DiffusionWorker`, `DiffusionModelRunner`, and pipeline instance. + +```mermaid +flowchart TB + client["Stage client"] + + subgraph stage["Diffusion stage process"] + stageProc["StageDiffusionProc
stage transport and lifecycle"] + engine["DiffusionEngine
admission and control loop"] + scheduler["RequestScheduler or StepScheduler
request state and policy"] + executor["DiffusionExecutor
uni or mp backend"] + streams["Per-request output streams"] + + stageProc --> engine + engine --> scheduler + scheduler -->|"DiffusionSchedulerOutput"| engine + engine --> executor + engine --> streams + end + + subgraph workers["Worker layer"] + workerProc["WorkerProc
mp IPC loop only"] + worker["DiffusionWorker
device and distributed setup"] + runner["DiffusionModelRunner
model state and execution"] + pipeline["Diffusion pipeline
model-specific computation"] + + workerProc --> worker + worker --> runner --> pipeline + end + + client -->|"process-backed: ZMQ"| stageProc + client -.->|"inline: direct call"| engine + executor -->|"mp: broadcast / result queues"| workerProc + executor -.->|"uni: in-process call"| worker + streams -->|"process-backed"| stageProc + stageProc -->|"ZMQ results"| client + streams -.->|"inline"| client +``` + +!!! info "Stage process vs worker process" + + A deployed stage may already run in a `StageDiffusionProc` child process. + That is separate from the optional `mp` worker processes. With `uni`, the + worker lives in the same process as the engine. + +## Component responsibilities + +| Component | Owns | Does not own | +| --- | --- | --- | +| `DiffusionEngine` | Admission, the busy loop, scheduler/executor coordination, RPC ordering, output streams, warmup, abort routing | Batch policy details, device setup, model computation | +| `BaseScheduler` | Request states, waiting/running sets, capacity, compatibility, terminal transitions | IPC, worker calls, output formatting | +| `RequestScheduler` | Complete-request waves and optional admission delay for request-level batching | Denoising-step progress | +| `StepScheduler` | Per-request denoising progress and step-wise completion | Pipeline tensors and model state | +| `DiffusionExecutor` | Execution backend contract, worker RPC, health, shutdown | Admission and request-state transitions | +| `UniProcDiffusionExecutor` | Single in-process worker for `num_gpus == 1`; no IPC or async output pump | Multi-GPU execution | +| `MultiprocDiffusionExecutor` | Worker processes, shared-memory message queues, result dispatch, worker monitoring | In-process single-GPU path | +| `WorkerProc` | One `mp` worker process's IPC loop and reply rules | Scheduling policy; unused by `uni` | +| `DiffusionWorker` | Device/distributed setup, LoRA activation, profiling, sleep/wake, runner delegation | Request admission | +| `DiffusionModelRunner` | Pipeline loading, request-local model state, cache/compile setup, request or step execution | Queueing and cross-stage routing | + +The boundary between policy and execution is +`DiffusionSchedulerOutput`. It contains newly admitted request payloads, +IDs for requests whose runner state is already cached, finished IDs, +optional KV-prefetch work, and per-request `diffusion_kv_metadata` on +`NewRequestData` envelopes when paged Diffusion KV is enabled. Executors and +workers consume this output; they do not decide what should run next. + +`BaseScheduler.initialize()` sets `max_num_running_reqs` from +`od_config.max_num_seqs` (default 1). The engine may override that capacity +for distributed layerwise offload with AllGather DP concurrency. + +## One scheduling tick + +Async requests share one engine busy loop. Worker calls and control RPCs pass +through that loop, so they cannot race on the executor transport. + +```mermaid +sequenceDiagram + participant Caller + participant Engine as DiffusionEngine + participant Scheduler + participant Executor + participant Worker + participant Runner + + Caller->>Engine: add request + Engine->>Scheduler: add_request(request) + Engine->>Scheduler: schedule() + Scheduler-->>Engine: DiffusionSchedulerOutput + Engine->>Executor: execute_batch() or execute_step() + Executor->>Worker: run worker method + Worker->>Runner: execute model or one step + Runner-->>Worker: RunnerOutput(s) + Worker-->>Executor: result + Executor-->>Engine: BaseRunnerOutput + Engine->>Scheduler: update_from_output(...) + Scheduler-->>Engine: finished request IDs + Engine-->>Caller: chunk or final output +``` + +The engine catches request execution errors and turns them into per-request +error outputs. A dead worker group is different: the executor marks itself +failed, health checks raise `EngineDeadError`, and the owning stage client +handles the stage-level failure. + +## Execution modes + +The engine resolves one mode at startup and binds the matching scheduler and +executor call. + +| Mode | Scheduler | Executor call | Runner path | +| --- | --- | --- | --- | +| Request batch | `RequestScheduler` | `execute_batch()` | `execute_model()` for one request, or `execute_model_batch()` for a fused batch | +| Step batch | `StepScheduler` | `execute_step()` | `execute_stepwise()` | + +**Request mode** runs a complete pipeline forward for each scheduled wave. A +single-request wave is the conservative path. A multi-request wave usually +uses fused `execute_model_batch()` and requires the pipeline to declare +request-batch support. + +**Distributed layerwise offload with AllGather (DLO DP concurrency)** is a +separate multiproc dispatch path. It activates when +`data_parallel_size > 1`, `enable_distributed_layerwise_offload` is set, and +`dlo_use_allgather` is true. The engine then sets `dp_concurrent = True` and +raises `scheduler.max_num_running_reqs` to `dp_size`, overriding the +`max_num_seqs` value from `initialize()`. The scheduler still emits a +multi-request `DiffusionSchedulerOutput`; the multiproc executor routes that +wave through `execute_request()` instead of fused `execute_model_batch()`. + +Optional admission coalescing can fill a `dp_size` wave: when +`request_batch_max_wait_ms > 0`, `RequestScheduler` may wait briefly before +the first schedule of a wave (under `dp_concurrent`, the stable window is +`min(0.3s, wait/2)`). With the default `request_batch_max_wait_ms == 0`, +there is no wait. + +Before dispatch, `MultiprocDiffusionExecutor.execute_request()` rejects waves +whose requests differ in sampling-parameter compatibility or `extra_args` +(AllGather requires every DP rank to follow the same forward schedule). Each +worker rank then picks one envelope from the wave: +`req[dp_rank % len(req)]`, so every rank enters the same collective while +computing different requests. + +**Step mode** keeps `StepRequestState` in the runner. New requests run +`prepare_encode()` once; every tick runs `denoise_step()` and +`step_scheduler()`; completed requests run `post_decode()`. The scheduler keeps +only lifecycle and progress metadata—it does not hold model tensors. + +**Step mode is also the path for streaming diffusion output**. Enabling +`streaming_output` turns on step execution if needed. Chunk-capable pipelines +can emit intermediate outputs through the same request stream; final-only step +pipelines still emit only the final result. If the pipeline does not implement +step execution, initialization fails. + +!!! tip + + For user-facing flags and examples, see + [Diffusion execution modes](../../../user_guide/diffusion/execution_modes.md). + That guide explains how to select a mode; this page explains what happens + after the choice is made. + +## Request state and identity + +The scheduler is the source of truth for an admitted request. Its normal state +flow is: + +```mermaid +stateDiagram-v2 + [*] --> WAITING: add_request + WAITING --> RUNNING: schedule + WAITING --> FINISHED_ABORTED: abort + RUNNING --> FINISHED_ABORTED: abort + RUNNING --> FINISHED_COMPLETED: successful output + RUNNING --> FINISHED_ERROR: failed or missing output + FINISHED_COMPLETED --> [*]: deliver and remove state + FINISHED_ABORTED --> [*]: deliver and remove state + FINISHED_ERROR --> [*]: deliver and remove state +``` + +`PREEMPTED` and `BaseScheduler.preempt_request()` exist on the scheduler API, +but the engine loop does not call them today—only scheduler unit tests do. + +`request_id` ties together scheduler state, runner state, executor results, and +the output queue. Batch positions are temporary and must never replace request +identity when results are mapped back. + +Only compatible requests enter the same running wave. The scheduler compares a +mode-specific sampling-parameter key and stops at the first incompatible +waiting request. This is intentionally conservative and can cause +head-of-line blocking. + +## Executor and IPC + +`DiffusionExecutor` is the backend interface. Built-in backends are selected by +`distributed_executor_backend`: + +| Backend | When chosen | Worker layout | +| --- | --- | --- | +| `uni` | Default for `num_gpus == 1`, or set explicitly | One in-process worker | +| `mp` | Default for `num_gpus > 1`, or set explicitly | One worker process per device | + +Ray and external-launcher diffusion backends are not implemented. A custom +`DiffusionExecutor` subclass or import path is also accepted. + +### Uniproc backend + +`UniProcDiffusionExecutor` constructs `WorkerWrapperBase` in the engine process +and calls worker methods directly. It avoids a second model load, MessageQueue +rings, ZMQ IPC sockets, and `/dev/shm` tensor packing. RPC timeouts are +accepted for interface parity but are not enforced: a hung worker blocks the +calling thread. Sticky accelerator faults mark the executor dead through +failure callbacks; ordinary request errors stay per-request. + +### Multiproc backend + +`MultiprocDiffusionExecutor`: + +1. creates a broadcast queue shared by all workers; +2. spawns one worker process per configured device; +3. waits until every worker reports ready; +4. sends generation and control calls as RPC messages; +5. applies rank-aware reply rules so only expected ranks respond; and +6. monitors worker process sentinels and fails the executor if a worker dies. + +In request mode on this backend, each async output produces two correlated +messages keyed by `async_output_id`: + +- `COMPUTE_DONE` — the forward finished and the device can start the next + request. +- `OUTPUT_READY` — background D2H/SHM packing finished and the final output + is ready. + +The worker queues background packing work before it enqueues `COMPUTE_DONE`, +but either message may reach the executor first. The result pump and +`execute_batch()` must handle both orderings. Step mode keeps the synchronous +result path and does not start those pumps. + +See [Async diffusion output](../../feature/async_diffusion_output.md) for the +multiproc request-mode timeline. + +## Worker and runner boundary + +On the `mp` path, `WorkerProc` receives messages and calls methods through +`WorkerWrapperBase`. On the `uni` path, the executor owns that wrapper +directly. In both cases, `DiffusionWorker` handles the parts tied to a device: +distributed initialization, model-runner construction, LoRA activation, +profiling, and memory sleep/wake. It delegates actual model work to +`DiffusionModelRunner`. + +The runner owns long-lived model-side state: + +- the loaded pipeline and compilation setup; +- cache and offload integration; +- random-generator setup; +- request batches for complete forwards; and +- `StepRequestState` plus `InputBatch` for step execution. + +This split keeps infrastructure out of model pipelines. A pipeline receives a +request batch or step state and performs model work; it should not inspect +engine queues or change scheduler state. + +## Lifecycle and cleanup + +### Startup + +`DiffusionEngine.make_engine()` resolves the engine class, constructs the +executor and scheduler, then normally runs a small dummy request to warm up the +model. + +Warmup is skipped only when distributed layerwise offload with AllGather +is enabled (via `enable_distributed_layerwise_offload` and `dlo_use_allgather`) +and `max(data_parallel_size, sequence_parallel_size) > 1`, because +the dummy run sends one request while AllGather needs every shard rank to +enter the same collective. If initialization or warmup fails, the runtime +closes scheduler state and worker resources before returning the error. + +### Cancellation + +`abort()` places request IDs on an abort queue. The busy loop marks those +requests `FINISHED_ABORTED`; finalization removes scheduler state and emits an +aborted output when a consumer still exists. The runner removes cached +step-mode state when it receives a scheduler output that reports the finished +request ID. + +On the multiproc request-mode async path, abort or consumer drop while output +is still materializing should release the associated async-output bookkeeping +so late results are not retained after the request ends +*(pending #6253/#6439/#6580)*. Today, an unconsumed `OUTPUT_READY` is cached +by design until `wait_output_ready()` or executor teardown. Dropping an +output-stream consumer removes that consumer's queue. It does not take +ownership of scheduler cleanup. + +### Shutdown and worker failure + +`DiffusionEngine.close()` is idempotent. It stops the busy loop, wakes pending +streams with an error, closes scheduler state, and shuts down the executor. + +- **`mp`**: the executor asks workers to stop, waits for them, then terminates + workers that miss the grace period. Shutdown marks unfinished RPC and + async-output futures as failed, then clears them. Completed async outputs + cached for later `wait_output_ready()` calls are dropped with the executor + object. +- **`uni`**: the executor shuts down the in-process worker, drops the final + model reference, and empties the accelerator cache so a later engine can reuse + the device. + +!!! warning + + Do not continue using a worker group after a fatal collective timeout or + unexpected worker exit. Distributed state may be incomplete. The + multiprocess executor deliberately fails closed so the stage can restart. + The uniproc executor fails closed when the accelerator context itself is + poisoned. + +## Extension boundaries + +- A custom engine may set `default_diffusion_model_runner_cls`; an explicit + `diffusion_model_runner_cls` configuration still wins. +- Tests and custom engine integrations may inject a `BaseScheduler` subclass. + `SchedulerInterface` remains only as a deprecated compatibility name. +- `distributed_executor_backend` may be `"uni"`, `"mp"`, a custom `DiffusionExecutor` + subclass, or an import path. A backend must preserve scheduler output, + request identity, health, and cleanup contracts. Use `"mp"` when you need + process isolation or multi-GPU; `"uni"` is the single-GPU default. +- Worker extensions go through `WorkerWrapperBase`. They add worker methods; + they do not become a second scheduler or request lifecycle. + +These are advanced Python integration points, not stable end-user CLI +customization surfaces. + ## Candidate invariants ### DIFF-RUNTIME-INV-001: One lifecycle owner @@ -41,19 +437,50 @@ until completion, cancellation, or failure. ### DIFF-RUNTIME-INV-002: Execution follows scheduler output **Rule:** Executors and workers MUST execute scheduler decisions without -admitting, reordering, or forwarding requests independently. +admitting, reordering, or forwarding requests independently. Distributed +layerwise offload with AllGather is an exception at dispatch time: the +scheduler may schedule a multi-request wave, but each DP rank executes one +envelope from that wave so every rank enters the same collective schedule. ### DIFF-RUNTIME-INV-003: Terminal cleanup is complete **Rule:** Every terminal path MUST release request state, temporary tensors, -hooks, and runtime-owned resources. +hooks, and runtime-owned resources. Async-output bookkeeping on abort or +consumer drop before consumption is also required *(pending #6253/#6439/#6580)*. ### DIFF-RUNTIME-INV-004: Optional features use runtime hooks **Rule:** Cache, profiling, offload, and parallel features SHOULD integrate at defined hooks instead of creating another request lifecycle. +### DIFF-RUNTIME-INV-005: Results keep request identity + +**Rule:** Batched and asynchronous results MUST be mapped by stable request ID, +not by assumed completion order. + ## Safe-change guide -Test success, cancellation, failure, shutdown, multiple requests, and resource -cleanup. +Test the smallest affected slice, then cover its neighboring boundary: + +| Change area | Minimum evidence | +| --- | --- | +| Admission or state transitions | `test_diffusion_scheduler.py`, including duplicate IDs, compatibility, abort, and missing output | +| Engine loop or output delivery | `test_diffusion_engine.py`, `test_diffusion_engine_cleanup.py`, `test_diffusion_engine_rpc_routing.py` | +| Executor or IPC | `test_multiproc_engine_concurrency.py`, `test_uniproc_executor.py`, `test_result_pump.py`, `test_diffusion_ipc.py`, `test_async_output_worker.py` | +| Worker or runner | `test_diffusion_worker.py`, `test_diffusion_model_runner.py` | +| Stage boundary | `test_stage_diffusion_proc.py`, `test_inline_stage_diffusion_client.py` | +| Warmup or streaming | `test_diffusion_engine_dummy_run.py`, `test_diffusion_streaming_output.py` | + +Always exercise success, cancellation, per-request failure, fatal worker +failure, repeated shutdown, one request, and multiple compatible requests. +For step-mode changes, also test partial progress and cleanup of runner state. + +## Related documents + +- [Diffusion module overview](index.md) +- [Diffusion model integration](diffusion_model_integration.md) +- [Diffusion continuous batching](continuous_batching.md) +- [Diffusion execution modes](../../../user_guide/diffusion/execution_modes.md) +- [Async diffusion output](../../feature/async_diffusion_output.md) +- [Engine orchestration](../engine_orchestration.md) +- [Stage runtime](../stage_runtime.md) From 30d6a0b4e61cb02cbdf11056c48bcc04376c7b22 Mon Sep 17 00:00:00 2001 From: WeiQing Chen <40507679+david6666666@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:52:54 +0800 Subject: [PATCH 21/90] [Bugfix][MiniMax-H3] Stabilize keyframe VAE encoding (#7191) Signed-off-by: david6666666 <530634352@qq.com> Signed-off-by: David Chen <530634352@qq.com> --- .../minimax_h3/test_minimax_h3_contract.py | 92 +++++++++++++++++++ vllm_omni/diffusion/models/minimax_h3/vae.py | 30 +++++- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py index cb12ce9e5..114f5eab3 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -3,6 +3,7 @@ import json import sys +from contextlib import contextmanager from multiprocessing.reduction import ForkingPickler from types import SimpleNamespace from typing import Any, TypeVar @@ -1550,6 +1551,97 @@ def test_video_vae_keeps_reference_fp32_weights(monkeypatch): assert next(video_vae.parameters()).dtype == torch.float32 +def test_keyframe_encode_pins_and_restores_cudnn_settings(): + from vllm_omni.diffusion.models.minimax_h3.vae import ( + _minimax_h3_keyframe_encode_context, + ) + + cudnn = torch.backends.cudnn + original = ( + cudnn.enabled, + cudnn.benchmark, + cudnn.deterministic, + cudnn.allow_tf32, + ) + try: + cudnn.enabled = False + cudnn.benchmark = True + cudnn.deterministic = False + cudnn.allow_tf32 = False + + with _minimax_h3_keyframe_encode_context(torch.device("cuda")): + assert cudnn.enabled + assert not cudnn.benchmark + assert cudnn.deterministic + assert cudnn.allow_tf32 + + assert not cudnn.enabled + assert cudnn.benchmark + assert not cudnn.deterministic + assert not cudnn.allow_tf32 + finally: + ( + cudnn.enabled, + cudnn.benchmark, + cudnn.deterministic, + cudnn.allow_tf32, + ) = original + + +@pytest.mark.parametrize("fail_encode", [False, True]) +def test_keyframe_encode_enters_backend_context(monkeypatch, fail_encode): + from PIL import Image + + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module + + events = [] + input_image = Image.new("RGB", (32, 32)) + + @contextmanager + def track_context(device): + assert device == torch.device("cpu") + events.append("enter") + try: + yield + finally: + events.append("exit") + + class FakeModel(torch.nn.Module): + def __init__(self): + super().__init__() + self.weight = torch.nn.Parameter(torch.ones(1)) + self.parallel_tiling = True + + def encode_images(self, image, *, use_fp16_latent): + assert events == ["enter"] + assert image is input_image + assert use_fp16_latent + events.append("encode") + if fail_encode: + raise RuntimeError("encode failed") + return [torch.ones(1, 1, 2, 2)] + + video_vae = object.__new__(vae_module.MiniMaxH3VideoVAE) + torch.nn.Module.__init__(video_vae) + video_vae.model = FakeModel() + video_vae.config_dict = { + "latent_channels": 1, + "latents_mean": [0.0], + "latents_std": [1.0], + } + monkeypatch.setattr(vae_module, "_minimax_h3_keyframe_encode_context", track_context) + + if fail_encode: + with pytest.raises(RuntimeError, match="encode failed"): + video_vae.encode_image(input_image) + else: + rows = video_vae.encode_image(input_image) + torch.testing.assert_close(rows, torch.ones(1, 4)) + + assert events == ["enter", "encode", "exit"] + assert video_vae.model.parallel_tiling + + def test_video_vae_can_load_on_cpu_for_staged_gpu_residency(monkeypatch): from vllm_omni.diffusion.models.minimax_h3 import vae as vae_module diff --git a/vllm_omni/diffusion/models/minimax_h3/vae.py b/vllm_omni/diffusion/models/minimax_h3/vae.py index 2e830fa04..e98659409 100644 --- a/vllm_omni/diffusion/models/minimax_h3/vae.py +++ b/vllm_omni/diffusion/models/minimax_h3/vae.py @@ -38,6 +38,27 @@ MINIMAX_H3_AUDIO_CHANNELS = 2 logger = init_logger(__name__) +@contextmanager +def _minimax_h3_keyframe_encode_context( + device: torch.device, +) -> Iterator[None]: + if device.type != "cuda": + yield + return + + # The official keyframe latent uses cuDNN's TF32 convolution path. The + # default non-deterministic algorithm can select numerically different + # reductions on H100s, and the difference is amplified by the denoiser. + # Pin both the algorithm and math mode for this sensitive encode only. + with torch.backends.cudnn.flags( + enabled=True, + benchmark=False, + deterministic=True, + allow_tf32=True, + ): + yield + + def _load_component_config(component_path: str) -> dict[str, Any]: config_path = Path(component_path) / "config.json" config = json.loads(config_path.read_text(encoding="utf-8")) @@ -280,10 +301,11 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin): for device in devices: with self.device_module.device(device): self.device_module.manual_seed(MINIMAX_H3_KEYFRAME_ENCODE_SEED) - latent = self.model.encode_images( - image, - use_fp16_latent=True, - )[0] + with _minimax_h3_keyframe_encode_context(parameter.device): + latent = self.model.encode_images( + image, + use_fp16_latent=True, + )[0] finally: self.model.parallel_tiling = previous_parallel if previous_dtype != torch.float32: From 9b6a308914cf649134ccaf43879fd19a2f43cc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=AA=E5=BF=97=E9=B9=8F?= Date: Mon, 7 Sep 2026 22:27:24 +0800 Subject: [PATCH 22/90] [Perf][Diffusion] Keep LoRA weights resident across activation cycles (#7195) Signed-off-by: princepride --- tests/diffusion/lora/helpers.py | 26 +++- tests/diffusion/lora/test_lora_manager.py | 125 ++++++++++++++++++ .../diffusion/lora/layers/base_linear.py | 30 +++++ vllm_omni/diffusion/lora/manager.py | 34 ++++- 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/tests/diffusion/lora/helpers.py b/tests/diffusion/lora/helpers.py index 8b9b1ef4d..4f1dfe176 100644 --- a/tests/diffusion/lora/helpers.py +++ b/tests/diffusion/lora/helpers.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Shared test helpers for diffusion LoRA tests.""" from __future__ import annotations @@ -26,15 +26,39 @@ class DummyBaseLayerWithLoRA(torch.nn.Module): tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor] ] = [] self.reset_calls: int = 0 + self.suspend_calls: int = 0 + self.resume_calls: int = 0 + self.active_slices: tuple[bool, ...] = () + self.suspended_slices: tuple[bool, ...] | None = None self.create_calls: int = 0 def set_lora(self, index: int, lora_a, lora_b): assert index == 0 self.set_calls.append((lora_a, lora_b)) + if isinstance(lora_b, list): + self.active_slices = tuple(b is not None for b in lora_b) + else: + self.active_slices = (True,) + self.suspended_slices = None def reset_lora(self, index: int): assert index == 0 self.reset_calls += 1 + self.active_slices = () + self.suspended_slices = None + + def suspend_lora(self) -> None: + if self.suspended_slices is not None: + return + self.suspended_slices = self.active_slices + self.active_slices = (False,) * len(self.active_slices) + self.suspend_calls += 1 + + def resume_lora(self) -> None: + if self.suspended_slices is not None: + self.active_slices = self.suspended_slices + self.suspended_slices = None + self.resume_calls += 1 def create_lora_weights(self, max_loras, lora_config, model_config): self.create_calls += 1 diff --git a/tests/diffusion/lora/test_lora_manager.py b/tests/diffusion/lora/test_lora_manager.py index 8edc6ce4b..0814bf15f 100644 --- a/tests/diffusion/lora/test_lora_manager.py +++ b/tests/diffusion/lora/test_lora_manager.py @@ -30,14 +30,38 @@ class _DummyLoRALayer: tuple[list[torch.Tensor | None] | torch.Tensor, list[torch.Tensor | None] | torch.Tensor] ] = [] self.reset_calls: int = 0 + self.suspend_calls: int = 0 + self.resume_calls: int = 0 + self.active_slices: tuple[bool, ...] = () + self.suspended_slices: tuple[bool, ...] | None = None def set_lora(self, index: int, lora_a, lora_b): assert index == 0 self.set_calls.append((lora_a, lora_b)) + if isinstance(lora_b, list): + self.active_slices = tuple(b is not None for b in lora_b) + else: + self.active_slices = (True,) + self.suspended_slices = None def reset_lora(self, index: int): assert index == 0 self.reset_calls += 1 + self.active_slices = () + self.suspended_slices = None + + def suspend_lora(self) -> None: + if self.suspended_slices is not None: + return + self.suspended_slices = self.active_slices + self.active_slices = (False,) * len(self.active_slices) + self.suspend_calls += 1 + + def resume_lora(self) -> None: + if self.suspended_slices is not None: + self.active_slices = self.suspended_slices + self.suspended_slices = None + self.resume_calls += 1 # Aliases for backward compatibility within this file @@ -723,3 +747,104 @@ def test_lora_manager_discovers_unet_component(monkeypatch): assert "unet.down_block.proj" in manager._lora_modules # Verify the module was actually replaced in the tree (not just recorded) assert isinstance(pipeline.unet.down_block.proj, _DummyBaseLayerWithLoRA) + + +def _suspend_harness(monkeypatch, adapter_id: int = 7, rank: int = 2): + """Manager wired to a single dummy layer, ready to activate `adapter_id`.""" + import vllm_omni.diffusion.lora.manager as manager_mod + + monkeypatch.setattr(manager_mod, "BaseLayerWithLoRA", _DummyBaseLayerWithLoRA) + + lora_model = _DummyLM(rank=rank) + manager = DiffusionLoRAManager( + pipeline=_DummyPipeline(), + device=torch.device("cpu"), + dtype=torch.bfloat16, + ) + monkeypatch.setattr(manager, "_load_adapter", lambda _req: (lora_model, type("PH", (), {"r": rank})())) + manager._registered_adapters = {adapter_id: lora_model} + manager._lora_modules = {"transformer.foo": lora_model.transformer.foo} + return manager, lora_model.transformer.foo + + +def test_reactivating_same_adapter_resumes_without_rebinding(monkeypatch): + """activate -> deactivate -> activate at the same scale must re-arm the + saved mask instead of re-uploading every layer.""" + adapter_id = 7 + req = _dummy_lora_request(adapter_id) + manager, layer = _suspend_harness(monkeypatch, adapter_id) + + manager.set_active_adapter(req, lora_scale=0.5) + assert len(layer.set_calls) == 1 + assert layer.active_slices == (True,) + + manager.set_active_adapter(None) + assert manager._suspended_adapter_id == adapter_id + assert layer.suspend_calls == 1 + assert layer.active_slices == (False,) + # Suspending must not tear the upload down. + assert layer.reset_calls == 0 + + manager.set_active_adapter(req, lora_scale=0.5) + assert layer.resume_calls == 1 + assert len(layer.set_calls) == 1, "resume must not rebind" + assert layer.active_slices == (True,) + assert manager._active_adapter_id == adapter_id + assert manager._suspended_adapter_id is None + + +@pytest.mark.parametrize("second_scale", [0.5, 0.25]) +def test_scale_or_adapter_change_forces_rebind(monkeypatch, second_scale): + """A different scale, or a different adapter, must take the full bind + path rather than re-arming a stale mask.""" + adapter_id = 7 + manager, layer = _suspend_harness(monkeypatch, adapter_id) + + manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5) + manager.set_active_adapter(None) + assert manager._suspended_adapter_id == adapter_id + + # Same id at a new scale, or a different id entirely. + other_id = adapter_id if second_scale != 0.5 else adapter_id + 1 + if other_id != adapter_id: + manager._registered_adapters[other_id] = manager._registered_adapters[adapter_id] + + manager.set_active_adapter(_dummy_lora_request(other_id), lora_scale=second_scale) + assert len(layer.set_calls) == 2, "must rebind" + assert manager._suspended_adapter_id is None + assert manager._active_adapter_id == other_id + + +def test_partial_packed_mask_survives_suspend_resume(monkeypatch): + """A packed layer where only some slices carry LoRA must come back with + the same mask, not an all-True one.""" + adapter_id = 7 + manager, layer = _suspend_harness(monkeypatch, adapter_id) + + # Emulate a packed bind that left slice 1 empty. + layer.set_lora(0, [torch.ones(2, 2), None], [torch.ones(2, 2), None]) + manager._active_adapter_id = adapter_id + manager._update_adapter_scale(adapter_id, 0.5) + assert layer.active_slices == (True, False) + + manager.set_active_adapter(None) + assert layer.active_slices == (False, False) + + manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5) + assert layer.active_slices == (True, False), "mask must survive unchanged" + + +def test_removing_suspended_adapter_drops_the_upload(monkeypatch): + """A removed adapter can never be resumed, so its weights must not stay + in the stacked buffers.""" + adapter_id = 7 + manager, layer = _suspend_harness(monkeypatch, adapter_id) + + manager.set_active_adapter(_dummy_lora_request(adapter_id), lora_scale=0.5) + manager.set_active_adapter(None) + assert manager._suspended_adapter_id == adapter_id + + manager.remove_adapter(adapter_id) + assert manager._suspended_adapter_id is None + assert layer.reset_calls == 1, "the upload must be torn down" + assert layer.suspended_slices is None diff --git a/vllm_omni/diffusion/lora/layers/base_linear.py b/vllm_omni/diffusion/lora/layers/base_linear.py index c1f941a10..1a78c6ad4 100644 --- a/vllm_omni/diffusion/lora/layers/base_linear.py +++ b/vllm_omni/diffusion/lora/layers/base_linear.py @@ -25,6 +25,9 @@ class DiffusionBaseLinearLayerWithLoRA(BaseLinearLayerWithLoRA): lora_a_stacked: tuple[torch.Tensor, ...] lora_b_stacked: tuple[torch.Tensor, ...] + # Mask saved while the adapter is suspended; None means "not suspended". + _diffusion_lora_suspended_slices: tuple[bool, ...] | None = None + def _move_lora_buffers(self, device: torch.device) -> None: self.lora_a_stacked = tuple(tensor.to(device=device) for tensor in self.lora_a_stacked) self.lora_b_stacked = tuple(tensor.to(device=device) for tensor in self.lora_b_stacked) @@ -54,11 +57,14 @@ class DiffusionBaseLinearLayerWithLoRA(BaseLinearLayerWithLoRA): object.__setattr__(self, "_diffusion_base_layer_ref", base_layer) n_slices = getattr(self, "n_slices", 1) self._diffusion_lora_active_slices = (False,) * int(n_slices) + self._diffusion_lora_suspended_slices = None def reset_lora(self, index: int): super().reset_lora(index) n_slices = getattr(self, "n_slices", 1) self._diffusion_lora_active_slices = (False,) * int(n_slices) + # The upload is gone, so a saved mask no longer describes the buffers. + self._diffusion_lora_suspended_slices = None def set_lora( self, @@ -82,6 +88,30 @@ class DiffusionBaseLinearLayerWithLoRA(BaseLinearLayerWithLoRA): # Single-slice layer. self._diffusion_lora_active_slices = (True,) + def suspend_lora(self) -> None: + """Gate every slice off without dropping the uploaded weights. + + `apply()` reads the stacked buffers only for slices this mask marks + active, so a suspended adapter costs nothing while staying resident. + A saved mask means the layer is already suspended; keep the original + rather than overwriting it with the all-False mask now in place. + """ + if self._diffusion_lora_suspended_slices is not None: + return + self._diffusion_lora_suspended_slices = self._diffusion_lora_active_slices + self._diffusion_lora_active_slices = (False,) * len(self._diffusion_lora_active_slices) + + def resume_lora(self) -> None: + """Restore the mask captured by `suspend_lora()`. + + A layer registered after the suspend, or one whose buffers were torn + down meanwhile, has no mask to restore and keeps its own inactive one. + """ + saved = self._diffusion_lora_suspended_slices + if saved is not None: + self._diffusion_lora_active_slices = saved + self._diffusion_lora_suspended_slices = None + def apply(self, x: torch.Tensor, bias: torch.Tensor | None = None) -> torch.Tensor: """ override: Use simple matmul instead of punica_wrapper.add_lora_linear(). diff --git a/vllm_omni/diffusion/lora/manager.py b/vllm_omni/diffusion/lora/manager.py index 1b63692fc..2f6d47780 100644 --- a/vllm_omni/diffusion/lora/manager.py +++ b/vllm_omni/diffusion/lora/manager.py @@ -48,6 +48,11 @@ class DiffusionLoRAManager: # Valid max allowed ranks for LoRA in vLLM _VALID_MAX_RANKS: list[int] = sorted(get_args(MaxLoRARanks)) + # Adapter whose weights are still uploaded while gated off. Class-level so + # a manager built without __init__ (some tests use object.__new__) reads as + # "nothing suspended" instead of raising. + _suspended_adapter_id: int | None = None + def __init__( self, pipeline: nn.Module, @@ -493,9 +498,11 @@ class DiffusionLoRAManager: fully_sharded_loras=False, ) - # Recreate per-layer buffers with the new maximum rank. + # Recreate per-layer buffers with the new maximum rank. The previous + # upload is gone, so nothing may be re-armed afterwards. for lora_layer in self._lora_modules.values(): lora_layer.create_lora_weights(max_loras=1, lora_config=lora_config, model_config=None) + self._suspended_adapter_id = None # Re-apply active adapter if needed (buffers were reset). if self._active_adapter_id is not None: @@ -679,18 +686,32 @@ class DiffusionLoRAManager: def _reset_lora_layers(self) -> None: for lora_layer in self._lora_modules.values(): lora_layer.reset_lora(0) + self._suspended_adapter_id = None def _activate_adapter(self, adapter_id: int, scale: float) -> None: if self._is_active_at_scale(adapter_id, scale): logger.debug("Adapter %d already active at scale %.3f skipping", adapter_id, scale) return + if self._suspended_adapter_id == adapter_id and self._adapter_scales.get( + adapter_id + ) == DiffusionLoRAManager._get_rounded_scale(scale): + # Weights are still uploaded from an earlier activation; re-arming + # the masks avoids rebuilding and re-uploading every layer. + for lora_layer in self._lora_modules.values(): + lora_layer.resume_lora() + self._suspended_adapter_id = None + self._active_adapter_id = adapter_id + logger.debug("Re-armed suspended adapter %d", adapter_id) + return + logger.info("Activating adapter: id=%d", adapter_id) lora_model = self._registered_adapters[adapter_id] # Binding overwrites slot 0 incrementally. Invalidate the fast-path # state before the first mutation and leave every wrapper inactive if # any set_lora() call or model validator fails. self._active_adapter_id = None + self._suspended_adapter_id = None try: self._bind_adapter_weights(lora_model, scale) except Exception: @@ -704,8 +725,10 @@ class DiffusionLoRAManager: if self._active_adapter_id is None: logger.debug("All adapters already inactive") return - logger.info("Deactivating all adapters: %d layers", len(self._lora_modules)) - self._reset_lora_layers() + logger.info("Suspending all adapters: %d layers", len(self._lora_modules)) + for lora_layer in self._lora_modules.values(): + lora_layer.suspend_lora() + self._suspended_adapter_id = self._active_adapter_id self._active_adapter_id = None logger.debug("All adapters deactivated") @@ -772,6 +795,11 @@ class DiffusionLoRAManager: if self._active_adapter_id == adapter_id: self._deactivate_all_adapters() + if self._suspended_adapter_id == adapter_id: + # The adapter is going away, so the upload can never be resumed. + # Tear it down instead of leaving it in the stacked buffers. + self._reset_lora_layers() + del self._registered_adapters[adapter_id] self._adapter_scales.pop(adapter_id, None) self._adapter_access_order.pop(adapter_id, None) From 7be014bce6374f06c95b703763bdbac4c6198f31 Mon Sep 17 00:00:00 2001 From: Lei Ke <1141466880@qq.com> Date: Mon, 7 Sep 2026 22:50:44 +0800 Subject: [PATCH 23/90] [Bugfix][NPU] Fix MiniMax-H3 INT8 quantization dispatch (#6876) Signed-off-by: KrystalRay Co-authored-by: KrystalRay --- .buildkite/npu/test-npu-ready.yml | 1 + .../test_minimax_h3_qwen3vl_swiglu.py | 26 +++++++++++++++++++ vllm_omni/platforms/npu/models/minimax_h3.py | 2 +- 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.buildkite/npu/test-npu-ready.yml b/.buildkite/npu/test-npu-ready.yml index 9aa55e5d0..1f25ebac1 100644 --- a/.buildkite/npu/test-npu-ready.yml +++ b/.buildkite/npu/test-npu-ready.yml @@ -19,6 +19,7 @@ steps: pytest -s -v tests/diffusion/layers/test_activation.py tests/diffusion/layers/test_fused_qk_norm_rope_npu.py + tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py -m 'core_model and npu and A3' --run-level 'core_model' diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py index 3ed7649c6..699dd47d6 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py @@ -49,3 +49,29 @@ def test_qwen3_vl_npu_swiglu_matches_two_projection_reference() -> None: atol=2e-2, rtol=2e-2, ) + + +@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="requires Ascend NPU") +def test_qwen3_vl_npu_swiglu_forward_uses_projection_module(mocker) -> None: + from vllm_omni.platforms.npu.models import minimax_h3 + from vllm_omni.platforms.npu.models.minimax_h3 import ( + _forward_minimax_h3_qwen3vl_text_mlp_npu, + ) + + x = torch.randn(1, 16, 128, device="npu", dtype=torch.bfloat16) + packed = torch.randn(1, 16, 256, device="npu", dtype=torch.bfloat16) + activated = torch.randn(1, 16, 128, device="npu", dtype=torch.bfloat16) + gate_up_proj = mocker.Mock(return_value=packed) + down_proj = mocker.Mock(side_effect=lambda value: value) + mlp = mocker.Mock(gate_up_proj=gate_up_proj, down_proj=down_proj) + swiglu = mocker.patch.object(minimax_h3, "npu_swiglu_from_packed", return_value=activated) + + output = _forward_minimax_h3_qwen3vl_text_mlp_npu(mlp, x) + + gate_up_proj.assert_called_once() + assert gate_up_proj.call_args.args[0] is x + swiglu.assert_called_once() + assert swiglu.call_args.args[0] is packed + down_proj.assert_called_once() + assert down_proj.call_args.args[0] is activated + assert output is activated diff --git a/vllm_omni/platforms/npu/models/minimax_h3.py b/vllm_omni/platforms/npu/models/minimax_h3.py index c8889f18f..a89768096 100644 --- a/vllm_omni/platforms/npu/models/minimax_h3.py +++ b/vllm_omni/platforms/npu/models/minimax_h3.py @@ -100,7 +100,7 @@ def npu_swiglu_from_packed(gate_up: torch.Tensor) -> torch.Tensor: def _forward_minimax_h3_qwen3vl_text_mlp_npu(self: Any, x: torch.Tensor) -> torch.Tensor: """Run the Qwen3-VL MLP with one packed GEMM and fused SwiGLU.""" - gate_up = F.linear(x, self.gate_up_proj.weight) + gate_up = self.gate_up_proj(x) return self.down_proj(npu_swiglu_from_packed(gate_up)) From cf36f5e4748c87a9bf10e744854fa69a66463561 Mon Sep 17 00:00:00 2001 From: Pujitha Paladugu Date: Mon, 7 Sep 2026 08:51:13 -0700 Subject: [PATCH 24/90] [Bugfix][MiniCPM-o] Allow ragged audio_feature_lens across a batch (#7071) Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Co-authored-by: amy-why-3459 --- .../test_long_audio_processing.py | 45 +++++++++++++++++++ .../minicpmo_4_5/minicpmo_4_5_omni_llm.py | 8 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py b/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py index ba4015b98..3e8ee2a63 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py +++ b/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py @@ -10,6 +10,10 @@ Covers: - ``process_audios``: >30s audio unpads per chunk without ``TypeError`` - multiple >30s audios unpad in chunk order - <=30s audio unpadding byte-identical to the pre-fix behavior + - ``MiniCPMOAudioFeatureInputs``: a batch mixing a >30s and a <=30s audio + (different slice counts) validates instead of raising, whether from a + single request with multiple audios of different lengths or from + multiple requests sharing an encoder batch """ from __future__ import annotations @@ -26,6 +30,7 @@ from vllm.multimodal.inputs import ( from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import ( MiniCPMO45OmniLLMMultiModalProcessor, + MiniCPMOAudioFeatureInputs, MiniCPMOMultiModalDataParser, _minicpmo_field_config, ) @@ -152,3 +157,43 @@ class TestProcessAudiosUnpadding: assert len(features) == 1 assert features[0].shape == (_FEAT_DIM, 980) assert torch.equal(features[0], fake_outputs["audio_features"][0][:, :980]) + + +class TestMixedChunkCountBatchValidation: + def test_batch_mixes_different_slice_counts(self) -> None: + # A >30s audio (3 slices) and a <=30s audio (2 slices) scheduled in + # the same encoder batch: audio_feature_lens is a list of per-audio + # tensors with different lengths (3 vs 2). This is the shape produced + # both by a single request with two audios of different lengths and + # by two separate requests batched together. Before the fix this + # raised ValueError: "audio_feature_lens contains inconsistent shapes". + audio_feature_lens = [ + torch.tensor([_CHUNK_FRAMES, _CHUNK_FRAMES, 1]), + torch.tensor([_CHUNK_FRAMES, 1]), + ] + audio_features = [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES) for _ in range(5)] + + inputs = MiniCPMOAudioFeatureInputs( + type="audio_features", + audio_features=audio_features, + audio_feature_lens=audio_feature_lens, + ) + + assert inputs.audio_feature_lens is audio_feature_lens + + def test_single_slice_count_batch_still_validated(self) -> None: + # Same slice count per audio (the common case) must still validate, + # unaffected by marking "s" dynamic. + audio_feature_lens = [ + torch.tensor([_CHUNK_FRAMES, 1]), + torch.tensor([_CHUNK_FRAMES, 1]), + ] + audio_features = [torch.zeros(_FEAT_DIM, _CHUNK_FRAMES) for _ in range(4)] + + inputs = MiniCPMOAudioFeatureInputs( + type="audio_features", + audio_features=audio_features, + audio_feature_lens=audio_feature_lens, + ) + + assert inputs.audio_feature_lens is audio_feature_lens diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py index b0fa9d203..f6c9b1fa9 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py @@ -3796,11 +3796,17 @@ class MiniCPMOAudioFeatureInputs(TensorSchema): audio_feature_lens: Annotated[ torch.Tensor | list[torch.Tensor], - TensorShape("bn", "s"), + TensorShape("bn", "s", dynamic_dims={"s"}), ] """ This should be feature length of each audio slice, which equals to `audio_features.shape[-1]` + + Each audio in the batch may be split into a different number of slices + (e.g. audio >30s splits into multiple slices while shorter audio doesn't), + so `s` must be dynamic: batching audios with different slice counts is a + normal, valid input and is already handled below via `hstack` + per-audio + iteration, not a schema violation. """ From 3b335814cc5aa9cd836a95e6b97edb5ddf9c3193 Mon Sep 17 00:00:00 2001 From: Rahul Steiger Date: Tue, 8 Sep 2026 01:34:08 +0200 Subject: [PATCH 25/90] [Bugfix][Cosmos3] Fix distributed Transfer output envelope (#7205) Signed-off-by: Rahul Steiger Co-authored-by: Nils Hoffmann --- .../models/cosmos3/test_cosmos3_pipeline.py | 36 +++++++++++++++++++ .../models/cosmos3/pipeline_cosmos3.py | 7 +++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py index 0b2ed3a57..72e4ab962 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_pipeline.py @@ -2401,6 +2401,42 @@ def test_forward_transfer_runs_multichunk_overlap_path( torch.testing.assert_close(captured["targets"][1][:, :, 0], torch.full((1, 3, 16, 16), -0.2)) +def test_forward_transfer_non_output_rank_uses_canonical_envelope( + make_cosmos3_pipeline, + sequential_cfg_parallel, +) -> None: + pipeline = make_cosmos3_pipeline() + pipeline.vae.distributed_executor = SimpleNamespace(rank=1) + pipeline.vae.is_distributed_enabled = lambda: True + pipeline._transfer_bucket_size = lambda sp, source_hw: (16, 16, "1,1") + pipeline._format_and_tokenize_prompts = lambda *args, **kwargs: (_ids(2), _mask(), _ids(1), _mask()) + pipeline._set_flow_shift = lambda *_args, **_kwargs: None + decoded = torch.zeros(1, 3, 1, 16, 16) + pipeline._decode_latents = lambda latents: decoded + + request = SimpleNamespace( + prompts=[{"prompt": "transfer", "modalities": ["video"]}], + sampling_params=make_sampling_params( + height=16, + width=16, + num_inference_steps=1, + guidance_scale=1.0, + extra_args={ + "edge": {"control": torch.zeros(3, 1, 16, 16, dtype=torch.uint8)}, + "max_frames": 1, + "num_video_frames_per_chunk": 1, + }, + ), + ) + + output = pipeline.forward(request) + + assert set(output.output) == {"payload", "metadata"} + assert set(output.output["payload"]) == {"video"} + torch.testing.assert_close(output.output["payload"]["video"], decoded) + assert output.output["metadata"] == {"video": {"fps": 24.0}} + + def test_diffuse_keeps_paired_cfg_when_cache_dit_active(make_cosmos3_pipeline) -> None: """With cache-dit active the uncond pass is kept even outside the guidance interval (so cache-dit's has_separate_cfg parity stays in phase), and the diff --git a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py index a171e2d3b..49a4e4027 100644 --- a/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py +++ b/vllm_omni/diffusion/models/cosmos3/pipeline_cosmos3.py @@ -3522,7 +3522,12 @@ class Cosmos3OmniDiffusersPipeline( control_chunks_per_hint[key].append(control[:, :, current_conditional_frames:]) if not is_output_rank: - return DiffusionOutput(output={"video": output_video}, custom_output={"fps": frame_rate}) + return DiffusionOutput( + output={ + "payload": {"video": output_video}, + "metadata": {"video": {"fps": frame_rate}}, + }, + ) full_output = torch.cat(output_chunks, dim=2)[:, :, :total_frames] full_controls = { From 6942a5b600375998ecbfcb6d120585283542af17 Mon Sep 17 00:00:00 2001 From: Anjie Hou <149605198+specture724@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:41:13 +0800 Subject: [PATCH 26/90] [1/N] Stream Wan VAE chunks to the media consumer (#7016) Signed-off-by: specture724 Co-authored-by: Claude Sonnet 5 Co-authored-by: Hongsheng Liu --- .../distributed/test_wan_chunked_decode.py | 174 +++++++++++++++ tests/diffusion/utils/test_chunked_mp4.py | 209 ++++++++++++++++++ .../autoencoders/autoencoder_kl_wan.py | 157 ++++++++++++- .../autoencoders/wan_spatial_shard.py | 35 ++- vllm_omni/diffusion/models/interface.py | 30 +++ vllm_omni/diffusion/utils/media_utils.py | 158 ++++++++++++- 6 files changed, 744 insertions(+), 19 deletions(-) create mode 100644 tests/diffusion/distributed/test_wan_chunked_decode.py create mode 100644 tests/diffusion/utils/test_chunked_mp4.py diff --git a/tests/diffusion/distributed/test_wan_chunked_decode.py b/tests/diffusion/distributed/test_wan_chunked_decode.py new file mode 100644 index 000000000..87f3ddff7 --- /dev/null +++ b/tests/diffusion/distributed/test_wan_chunked_decode.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import weakref +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +import torch +from diffusers.models.autoencoders import AutoencoderKLWan + +from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard +from vllm_omni.diffusion.distributed.autoencoders.autoencoder_kl_wan import ( + DistributedAutoencoderKLWan, + OmniAutoencoderKLWan, +) +from vllm_omni.diffusion.models.interface import supports_chunked_vae_decode + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +@pytest.fixture +def vae(): + # Real causal convolutions and caches, with small random weights for CPU CI. + return OmniAutoencoderKLWan( + base_dim=4, + z_dim=2, + dim_mult=[1, 2], + num_res_blocks=1, + temperal_downsample=[True], + scale_factor_spatial=2, + scale_factor_temporal=2, + ).eval() + + +@pytest.mark.parametrize("patch_size", [None, 2]) +@pytest.mark.parametrize("return_dict", [False, True]) +@torch.no_grad() +def test_chunked_decode_matches_diffusers(vae, patch_size, return_dict): + if patch_size is not None: + vae = OmniAutoencoderKLWan.from_config(vae.config, patch_size=patch_size, out_channels=12).eval() + z = torch.randn(1, 2, 3, 2, 2) + expected = AutoencoderKLWan._decode(vae, z).sample + actual = vae.decode(z, return_dict=return_dict) + torch.testing.assert_close(actual.sample if return_dict else actual[0], expected, rtol=0, atol=0) + chunks: list[torch.Tensor] = [] + assert supports_chunked_vae_decode(vae) + assert not supports_chunked_vae_decode(object()) + assert vae.decode_with_chunks(z, on_chunk=chunks.append) is None + assert [chunk.shape[2] for chunk in chunks] == [1, 2, 2] + torch.testing.assert_close(torch.cat(chunks, dim=2), expected, rtol=0, atol=0) + assert all(entry is None for entry in vae._feat_map) + + +@torch.no_grad() +def test_slicing_and_forward_hooks(vae): + z = torch.randn(2, 2, 2, 2, 2) + vae.enable_slicing() + batches = [] + handle = vae.post_quant_conv.register_forward_pre_hook(lambda module, args: batches.append(args[0].shape[0])) + vae._hf_hook = SimpleNamespace(pre_forward=Mock()) + try: + assert vae.decode(z).sample.shape[0] == 2 + assert batches == [1, 1] + vae._hf_hook.pre_forward.assert_called_once_with(vae) + batches.clear() + with pytest.raises(ValueError, match="use_slicing"): + vae.decode_with_chunks(z, on_chunk=Mock()) + assert batches == [] + vae._hf_hook.pre_forward.reset_mock() + vae.decode_with_chunks(z[:1], on_chunk=Mock()) + assert batches == [1] + vae._hf_hook.pre_forward.assert_called_once_with(vae) + finally: + handle.remove() + + +@torch.no_grad() +def test_callback_failure_drains_and_clears_cache(vae): + z = torch.randn(1, 2, 4, 2, 2) + decoded = Mock(return_value=None) + handle = vae.decoder.register_forward_hook(lambda *args: decoded()) + error = RuntimeError("consumer failed") + callback = Mock(side_effect=error) + try: + with pytest.raises(RuntimeError, match="consumer failed") as exc: + vae.decode_with_chunks(z, on_chunk=callback) + assert exc.value is error + assert callback.call_count == 1 + assert decoded.call_count == z.shape[2] + assert all(entry is None for entry in vae._feat_map) + finally: + handle.remove() + # The same instance can decode again after the failure. + chunks: list[torch.Tensor] = [] + vae.decode_with_chunks(z, on_chunk=chunks.append) + torch.testing.assert_close(torch.cat(chunks, 2), vae.decode(z).sample, rtol=0, atol=0) + + +def test_chunked_decode_rejects_spatial_tiling(vae): + vae.enable_tiling(tile_sample_min_height=2, tile_sample_min_width=2) + with pytest.raises(ValueError, match="spatial tiling"): + vae.decode_with_chunks(torch.zeros(1, 2, 2, 2, 2), on_chunk=Mock()) + + +@pytest.mark.parametrize("rank", [0, 1]) +@pytest.mark.parametrize("fail_callback", [False, True]) +def test_spatial_callback_ownership_and_bounded_retention(monkeypatch, rank, fail_callback): + # Isolate the temporal loop from halo kernels; weakrefs detect retention of + # decoded GPU-sized tensors even after the consumer has failed. + monkeypatch.setattr(wan_spatial_shard, "install_wan_spatial_shard_decode", lambda *args, **kwargs: None) + monkeypatch.setattr(wan_spatial_shard, "_rank_world", lambda group: (rank, 2)) + references: list[weakref.ReferenceType[torch.Tensor]] = [] + + def decode(x, **kwargs): + assert sum(ref() is not None for ref in references) <= 1 + chunk = x.clone() + references.append(weakref.ref(chunk)) + return chunk + + vae = SimpleNamespace( + config=SimpleNamespace(patch_size=None), + clear_cache=Mock(), + post_quant_conv=lambda z: z, + decoder=decode, + _feat_map=[], + ) + callback = Mock(side_effect=RuntimeError("consumer failed") if fail_callback else None) + z = torch.zeros(1, 3, 6, 2, 2) + if rank == 0 and fail_callback: + with pytest.raises(RuntimeError, match="consumer failed"): + wan_spatial_shard.spatial_shard_decode(vae, z, group=None, on_chunk=callback) + else: + assert wan_spatial_shard.spatial_shard_decode(vae, z, group=None, on_chunk=callback) is None + assert len(references) == z.shape[2] + assert callback.call_count == (0 if rank else 1 if fail_callback else z.shape[2]) + assert vae.clear_cache.call_count == 2 + + +@pytest.mark.parametrize("rank", [0, 1]) +@torch.no_grad() +def test_distributed_small_latent_callback_ownership(vae, monkeypatch, rank): + vae.is_distributed_enabled = lambda: True + vae.distributed_executor = SimpleNamespace(group=None) + monkeypatch.setattr(torch.distributed, "get_rank", lambda **kwargs: rank) + callback = Mock() + vae.decode_with_chunks(torch.zeros(1, 2, 2, 2, 2), on_chunk=callback) + assert callback.call_count == (2 if rank == 0 else 0) + + +@pytest.mark.parametrize("mode", ["spatial_shard_height", "spatial_shard_width", "tile"]) +def test_distributed_chunked_routing(monkeypatch, mode): + vae = DistributedAutoencoderKLWan.__new__(DistributedAutoencoderKLWan) + torch.nn.Module.__init__(vae) + vae.use_tiling = True + vae.use_slicing = False + vae.tile_sample_min_height = vae.tile_sample_min_width = 2 + vae.spatial_compression_ratio = 2 + vae.distributed_executor = SimpleNamespace(group=None, parallel_mode=mode, parallel_size=2) + monkeypatch.setattr(vae, "is_distributed_enabled", lambda: True) + monkeypatch.setattr(torch.distributed, "get_world_size", lambda **kwargs: 2) + decode = Mock() + monkeypatch.setattr(wan_spatial_shard, "spatial_shard_decode", decode) + callback = Mock() + z = torch.zeros(1, 2, 2, 2, 2) + if mode == "tile": + with pytest.raises(ValueError, match="require spatial-shard"): + vae.decode_with_chunks(z, on_chunk=callback) + decode.assert_not_called() + else: + assert vae.decode_with_chunks(z, on_chunk=callback) is None + decode.assert_called_once_with( + vae, z, group=None, return_dict=True, split_dim=mode.removeprefix("spatial_shard_"), on_chunk=callback + ) diff --git a/tests/diffusion/utils/test_chunked_mp4.py b/tests/diffusion/utils/test_chunked_mp4.py new file mode 100644 index 000000000..e7ef465f3 --- /dev/null +++ b/tests/diffusion/utils/test_chunked_mp4.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import hashlib + +import av +import numpy as np +import pytest + +from vllm_omni.diffusion.utils.media_utils import ChunkedMP4Encoder, mux_av_video_audio_bytes + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def _frames() -> np.ndarray: + rng = np.random.default_rng(6872) + return rng.integers(0, 256, size=(8, 16, 24, 3), dtype=np.uint8) + + +def test_chunked_mp4_is_byte_identical_to_whole_mux() -> None: + frames = _frames() + baseline = mux_av_video_audio_bytes( + (av.VideoFrame.from_ndarray(frame, format="rgb24") for frame in frames), + width=24, + height=16, + fps=24, + video_codec_options={"threads": "1"}, + ) + encoder = ChunkedMP4Encoder( + width=24, + height=16, + fps=24, + max_pending=2, + video_codec_options={"threads": "1"}, + ) + for start in (0, 1, 5): + stop = {0: 1, 1: 5, 5: len(frames)}[start] + encoder.push(np.ascontiguousarray(frames[start:stop])) + chunked = encoder.finish() + assert chunked == baseline + assert hashlib.sha256(chunked).digest() == hashlib.sha256(baseline).digest() + + audio = np.zeros((2, 320), dtype=np.float32) + baseline_audio = mux_av_video_audio_bytes( + (av.VideoFrame.from_ndarray(frame, format="rgb24") for frame in frames), + width=24, + height=16, + fps=24, + audio_waveform=audio, + audio_sample_rate=32000, + video_codec_options={"threads": "1"}, + ) + encoder_audio = ChunkedMP4Encoder( + width=24, + height=16, + fps=24, + audio_waveform=audio, + audio_sample_rate=32000, + video_codec_options={"threads": "1"}, + ) + encoder_audio.push(frames) + assert encoder_audio.finish() == baseline_audio + + +def test_chunked_mp4_abort_joins_worker() -> None: + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24) + encoder.push(_frames()[:1]) + encoder.abort() + assert not encoder._thread.is_alive() + with pytest.raises(RuntimeError, match="already closed"): + encoder.push(_frames()[:1]) + + +def test_chunked_mp4_validates_shape_and_dtype() -> None: + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24) + with pytest.raises(ValueError, match="shape"): + encoder.push(np.zeros((16, 24, 3), dtype=np.uint8)) + with pytest.raises(ValueError, match="dtype"): + encoder.push(np.zeros((1, 16, 24, 3), dtype=np.float32)) + encoder.abort() + + +def test_chunked_mp4_close_returns_encoded_bytes() -> None: + import io + + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24) + encoder.push(_frames()) + result = encoder.close() + assert result == encoder.finish() == encoder.close() + with av.open(io.BytesIO(result)) as container: + assert len(list(container.decode(video=0))) == len(_frames()) + + +def test_chunked_mp4_failure_during_push(monkeypatch) -> None: + from threading import Event + + from vllm_omni.diffusion.utils import media_utils + + fail = Event() + draining = Event() + error = RuntimeError("encoding failed") + + def mux(*args, **kwargs): + assert fail.wait(5) + raise error + + monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux) + drain = ChunkedMP4Encoder._drain_until_done + + def notified_drain(self): + draining.set() + drain(self) + + monkeypatch.setattr(ChunkedMP4Encoder, "_drain_until_done", notified_drain) + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24) + put = encoder._queue.put + + def fail_before_put(item): + fail.set() + assert draining.wait(5) + put(item) + + monkeypatch.setattr(encoder._queue, "put", fail_before_put) + try: + with pytest.raises(RuntimeError, match="encoding failed") as exc: + encoder.push(_frames()) + assert exc.value is error + with pytest.raises(RuntimeError, match="encoding failed"): + encoder.finish() + finally: + fail.set() + encoder.abort() + assert not encoder._thread.is_alive() + + +def test_chunked_mp4_flush_failure_does_not_hang(monkeypatch) -> None: + from threading import Thread + + from vllm_omni.diffusion.utils import media_utils + + error = RuntimeError("flush failed") + + def mux(frames, **kwargs): + list(frames) + raise error + + monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux) + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24) + encoder.push(_frames()) + errors = [] + + def finish(): + try: + encoder.finish() + except RuntimeError as exc: + errors.append(exc) + + closer = Thread(target=finish, daemon=True) + closer.start() + closer.join(5) + try: + assert not closer.is_alive(), "finish hung after the worker consumed its sentinel" + assert errors == [error] + finally: + if closer.is_alive(): + encoder._send_done() + closer.join(5) + + +def test_chunked_mp4_abort_discards_pending_chunks(monkeypatch) -> None: + from threading import Event, Thread + + from vllm_omni.diffusion.utils import media_utils + + entered = Event() + release = Event() + done_sent = Event() + encoded = [] + + def mux(frames, **kwargs): + encoded.append(next(frames)) + entered.set() + assert release.wait(5) + encoded.extend(frames) + return b"unused" + + monkeypatch.setattr(media_utils, "mux_av_video_audio_bytes", mux) + encoder = ChunkedMP4Encoder(width=24, height=16, fps=24, max_pending=1) + send_done = encoder._send_done + + def notified_done(): + send_done() + done_sent.set() + + monkeypatch.setattr(encoder, "_send_done", notified_done) + encoder.push(_frames()[:1]) + assert entered.wait(5) + encoder.push(_frames()[1:]) + closer = Thread(target=encoder.abort, daemon=True) + closer.start() + try: + assert done_sent.wait(5), "abort waited for encoding before discarding the pending queue" + finally: + release.set() + closer.join(5) + assert not closer.is_alive() + assert len(encoded) == 1 + with pytest.raises(RuntimeError, match="aborted"): + encoder.finish() diff --git a/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py index 44c6efe66..eddcb1ad6 100644 --- a/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py +++ b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py @@ -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 contextlib import nullcontext from typing import Any @@ -9,6 +9,7 @@ import torch.distributed as dist from diffusers.models.autoencoders import AutoencoderKLWan from diffusers.models.autoencoders.autoencoder_kl_wan import unpatchify from diffusers.models.autoencoders.vae import DecoderOutput +from diffusers.utils.accelerate_utils import apply_forward_hook from vllm.logger import init_logger from vllm_omni.diffusion.distributed.autoencoders import wan_spatial_shard @@ -18,6 +19,7 @@ from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor impor GridSpec, TileTask, ) +from vllm_omni.diffusion.models.interface import DecodedChunkConsumer from vllm_omni.platforms import current_omni_platform logger = init_logger(__name__) @@ -45,9 +47,133 @@ class OmniAutoencoderKLWan(AutoencoderKLWan): return super().encode(x, return_dict=return_dict) def decode(self, z: torch.Tensor, return_dict: bool = True): + """Decode a Wan latent using the Diffusers-compatible full-tensor API.""" with self._execution_context(): return super().decode(z, return_dict=return_dict) + def _decode(self, z: torch.Tensor, return_dict: bool = True): + """Decode one latent batch, preserving the parent Diffusers contract.""" + _, _, _, height, width = z.shape + tile_latent_min_height = self.tile_sample_min_height // self.spatial_compression_ratio + tile_latent_min_width = self.tile_sample_min_width // self.spatial_compression_ratio + + if getattr(self, "use_tiling", False) and (width > tile_latent_min_width or height > tile_latent_min_height): + return self.tiled_decode(z, return_dict=return_dict) + + return self._decode_temporal(z, return_dict=return_dict) + + @apply_forward_hook + def decode_with_chunks(self, z: torch.Tensor, *, on_chunk: DecodedChunkConsumer) -> None: + """Decode ``z`` while synchronously delivering temporal chunks. + + The ordinary :meth:`decode` contract remains the Diffusers contract. + Chunk callbacks deliberately reject a sliced batch: Diffusers slices + each sample before decoding, while a streaming consumer needs a stable + batch ownership contract. Callers can disable ``use_slicing`` or + invoke this capability once per sample. + + Every rank participating in distributed VAE execution must invoke this + method. Spatial-sharded execution invokes ``on_chunk`` only on the + output-owning rank. Chunks have shape ``(B, C, T, H, W)`` and + values clamped to ``[-1, 1]``. After a callback failure, remaining + chunks are decoded and discarded before the exception is re-raised. + """ + if not callable(on_chunk): + raise TypeError("on_chunk must be callable") + if getattr(self, "use_slicing", False) and z.shape[0] > 1: + raise ValueError( + "Wan chunked decode does not support use_slicing for batch size > 1; " + "disable VAE slicing or decode one sample at a time" + ) + + with self._execution_context(): + self._decode_with_chunks(z, on_chunk=on_chunk) + + def _decode_temporal( + self, + z: torch.Tensor, + *, + on_chunk: DecodedChunkConsumer | None = None, + return_dict: bool = True, + ): + """Decode the non-tiled temporal path, optionally streaming chunks.""" + self.clear_cache() + callback_error: BaseException | None = None + try: + x = self.post_quant_conv(z) + decoded_chunks: list[torch.Tensor] = [] + for i in range(z.shape[2]): + self._conv_idx = [0] + if i == 0: + chunk = self.decoder( + x[:, :, i : i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx, + first_chunk=True, + ) + else: + chunk = self.decoder( + x[:, :, i : i + 1, :, :], + feat_cache=self._feat_map, + feat_idx=self._conv_idx, + ) + + if on_chunk is None: + decoded_chunks.append(chunk) + continue + + if callback_error is None: + try: + if self.config.patch_size is not None: + chunk = unpatchify(chunk, patch_size=self.config.patch_size) + on_chunk(torch.clamp(chunk, min=-1.0, max=1.0)) + except BaseException as exc: + # A previously installed spatial-shard decoder still + # runs collectives, even for latents below the tiling + # threshold. Drain before surfacing consumer failures. + callback_error = exc + del chunk + + if callback_error is not None: + raise callback_error + if on_chunk is not None: + return None + + out = torch.cat(decoded_chunks, dim=2) + if self.config.patch_size is not None: + out = unpatchify(out, patch_size=self.config.patch_size) + out = torch.clamp(out, min=-1.0, max=1.0) + if not return_dict: + return (out,) + return DecoderOutput(sample=out) + finally: + self.clear_cache() + + @staticmethod + def _discard_decoded_chunk(_chunk: torch.Tensor) -> None: + """Consume a non-owner rank's chunk without retaining its tensor.""" + + def _decode_with_chunks(self, z: torch.Tensor, *, on_chunk: DecodedChunkConsumer) -> None: + """Run the temporal streaming primitive without API or hook handling.""" + distributed_enabled = False + if hasattr(self, "is_distributed_enabled"): + distributed_enabled = self.is_distributed_enabled() + + if getattr(self, "use_tiling", False): + tile_latent_min_height = self.tile_sample_min_height // self.spatial_compression_ratio + tile_latent_min_width = self.tile_sample_min_width // self.spatial_compression_ratio + exceeds_tile = z.shape[-1] > tile_latent_min_width or z.shape[-2] > tile_latent_min_height + if exceeds_tile: + if distributed_enabled: + self._tiled_decode_with_chunks(z, on_chunk=on_chunk) + return + raise ValueError("Wan chunk callbacks are unsupported for spatial tiling") + + callback = on_chunk + if distributed_enabled and dist.get_rank(group=self.distributed_executor.group) != 0: + callback = self._discard_decoded_chunk + self._decode_temporal(z, on_chunk=callback) + class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin): @classmethod @@ -78,7 +204,7 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin): blend_height = self.tile_sample_min_height - tile_sample_stride_height blend_width = self.tile_sample_min_width - tile_sample_stride_width - tiletask_list = [] + tiletask_list: list[TileTask] = [] for i in range(0, height, tile_latent_stride_height): for j in range(0, width, tile_latent_stride_width): time_list = [] @@ -151,7 +277,7 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin): blend_height = tile_latent_min_height - tile_latent_stride_height blend_width = tile_latent_min_width - tile_latent_stride_width - tiletask_list = [] + tiletask_list: list[TileTask] = [] temporal_compression = self.config.scale_factor_temporal for i in range(0, height, tile_sample_stride_height): for j in range(0, width, tile_sample_stride_width): @@ -307,8 +433,10 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin): if not self.is_distributed_enabled(): return super().tiled_decode(z, return_dict=return_dict) - if self._spatial_shard_decode_enabled(z): + spatial_shard_enabled = self._spatial_shard_decode_enabled(z) + if spatial_shard_enabled: split_dim = self._spatial_shard_decode_split_dim() + assert split_dim is not None logger.debug("Decode running with Wan VAE spatial_shard_%s mode", split_dim) return wan_spatial_shard.spatial_shard_decode( self, @@ -329,6 +457,27 @@ class DistributedAutoencoderKLWan(OmniAutoencoderKLWan, DistributedVaeMixin): return DecoderOutput(sample=result) + def _tiled_decode_with_chunks(self, z: torch.Tensor, *, on_chunk: DecodedChunkConsumer) -> None: + """Decode tiled latents and deliver temporal chunks to ``on_chunk``.""" + if not self.is_distributed_enabled(): + raise ValueError("Wan chunk callbacks are unsupported for spatial tiling") + + spatial_shard_enabled = self._spatial_shard_decode_enabled(z) + if not spatial_shard_enabled: + raise ValueError("Wan chunk callbacks require spatial-shard decode when tiling is enabled") + + split_dim = self._spatial_shard_decode_split_dim() + assert split_dim is not None + logger.debug("Decode running with Wan VAE spatial_shard_%s mode", split_dim) + wan_spatial_shard.spatial_shard_decode( + self, + z, + group=self.distributed_executor.group, + return_dict=True, + split_dim=split_dim, + on_chunk=on_chunk, + ) + def tiled_encode(self, x: torch.Tensor) -> torch.Tensor: """ Encode using distributed VAE executor. diff --git a/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py b/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py index 07edad694..ac5ea472b 100644 --- a/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py +++ b/vllm_omni/diffusion/distributed/autoencoders/wan_spatial_shard.py @@ -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 # # The halo-exchange spatial-parallel decode here is adapted from SGLang's # spatial-parallel VAE decode @@ -24,7 +24,7 @@ from contextlib import nullcontext from contextvars import ContextVar from dataclasses import dataclass from types import MethodType -from typing import Any +from typing import Any, cast import torch import torch.distributed as dist @@ -34,6 +34,8 @@ from diffusers.models.autoencoders.autoencoder_kl_wan import unpatchify from diffusers.models.autoencoders.vae import DecoderOutput from vllm.logger import init_logger +from vllm_omni.diffusion.models.interface import DecodedChunkConsumer + logger = init_logger(__name__) @@ -644,7 +646,7 @@ def _replace_child( ) return if isinstance(child, nn.ZeroPad2d): - padding = tuple(int(p) for p in child.padding) + padding = cast(tuple[int, int, int, int], tuple(int(p) for p in child.padding)) module_padding = padding if parent.__class__.__name__ == "Sequential": # Let the following WanDistConv2d account for global after-edge @@ -794,7 +796,8 @@ def spatial_shard_decode( group: dist.ProcessGroup, return_dict: bool = True, split_dim: str = "height", -) -> DecoderOutput | tuple[torch.Tensor]: + on_chunk: DecodedChunkConsumer | None = None, +) -> DecoderOutput | tuple[torch.Tensor] | None: install_wan_spatial_shard_decode(vae, group, split_dim=split_dim) if z.shape[2] == 0: @@ -806,6 +809,7 @@ def spatial_shard_decode( produce_output = world_size <= 1 or rank == 0 vae.clear_cache() + callback_error: BaseException | None = None try: context = vae._execution_context() if hasattr(vae, "_execution_context") else nullcontext() with context: @@ -820,18 +824,33 @@ def spatial_shard_decode( first_chunk=(i == 0), ) if produce_output: - decoded_chunks.append(chunk) + if on_chunk is not None and callback_error is None: + try: + if vae.config.patch_size is not None: + chunk = unpatchify(chunk, patch_size=vae.config.patch_size) + on_chunk(torch.clamp(chunk, min=-1.0, max=1.0)) + except BaseException as exc: + # Keep all ranks in the temporal collective loop; + # surface the callback failure only after decode. + callback_error = exc + elif on_chunk is None: + decoded_chunks.append(chunk) + del chunk - if produce_output: + if produce_output and on_chunk is None: out = torch.cat(decoded_chunks, dim=2) if vae.config.patch_size is not None: out = unpatchify(out, patch_size=vae.config.patch_size) out = torch.clamp(out, min=-1.0, max=1.0) - else: - out = z.new_zeros(0) finally: vae.clear_cache() + if callback_error is not None: + raise callback_error + if on_chunk is not None: + return None + if not produce_output: + out = z.new_zeros(0) if not return_dict: return (out,) return DecoderOutput(sample=out) diff --git a/vllm_omni/diffusion/models/interface.py b/vllm_omni/diffusion/models/interface.py index 75cc5a0d2..dac9a0b1d 100644 --- a/vllm_omni/diffusion/models/interface.py +++ b/vllm_omni/diffusion/models/interface.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from typing import ( TYPE_CHECKING, @@ -9,6 +10,7 @@ from typing import ( ClassVar, Literal, Protocol, + TypeGuard, runtime_checkable, ) @@ -46,6 +48,34 @@ class SupportAudioOutput(Protocol): support_audio_output: ClassVar[bool] = True +DecodedChunkConsumer = Callable[["torch.Tensor"], None] + + +@runtime_checkable +class SupportsChunkedVAEDecode(Protocol): + """Optional capability for VAEs that stream decoded temporal chunks. + + Implementations must drain decoding before surfacing callback errors. In a + distributed VAE, every rank invokes the method so collectives stay in + lockstep, while ``on_chunk`` is called only on the rank that owns output. + """ + + def decode_with_chunks( + self, + z: torch.Tensor, + *, + on_chunk: DecodedChunkConsumer, + ) -> None: + """Decode ``z`` and synchronously deliver committed chunks in order.""" + ... + + +def supports_chunked_vae_decode(vae: object) -> TypeGuard[SupportsChunkedVAEDecode]: + """Return whether ``vae`` exposes the optional chunked decode capability.""" + + return isinstance(vae, SupportsChunkedVAEDecode) + + @runtime_checkable class SupportsStepExecution(Protocol): """State-driven step-level execution protocol for diffusion pipelines. diff --git a/vllm_omni/diffusion/utils/media_utils.py b/vllm_omni/diffusion/utils/media_utils.py index df67f9d99..615df090f 100644 --- a/vllm_omni/diffusion/utils/media_utils.py +++ b/vllm_omni/diffusion/utils/media_utils.py @@ -1,10 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Video/audio muxing utilities using PyAV (no ffmpeg binary dependency).""" from __future__ import annotations import io +import queue +import threading from collections.abc import Iterable from fractions import Fraction from typing import Any, cast @@ -12,6 +14,153 @@ from typing import Any, cast import av import numpy as np +_CHUNKED_MP4_DONE = object() + + +def _validate_video_chunk(chunk: np.ndarray, *, width: int, height: int) -> None: + """Validate a ``(T, H, W, 3)`` uint8 RGB chunk against a session's frame size.""" + if chunk.ndim != 4 or chunk.shape[-1] != 3: + raise ValueError("video chunk must have shape (T, H, W, 3)") + if chunk.dtype != np.uint8: + raise ValueError("video chunk must have dtype uint8") + if chunk.shape[1] != height or chunk.shape[2] != width: + raise ValueError("video chunks in a session must use a consistent frame size") + + +class ChunkedMP4Encoder: + """Encode temporal video chunks while the producer is still decoding. + + A bounded queue and one muxing worker provide ordered backpressure while + keeping host memory bounded by ``max_pending`` chunks. Chunks use the same + ``(T, H, W, 3)`` uint8 contract as :func:`mux_video_audio_bytes`. + """ + + def __init__( + self, + *, + width: int, + height: int, + fps: float, + audio_waveform: np.ndarray | None = None, + audio_sample_rate: int | None = None, + max_pending: int = 2, + video_codec: str = "h264", + audio_codec: str = "aac", + crf: str = "18", + video_codec_options: dict[str, str] | None = None, + ) -> None: + if max_pending <= 0: + raise ValueError("max_pending must be positive") + if width <= 0 or height <= 0: + raise ValueError("width and height must be positive") + self.width = int(width) + self.height = int(height) + self._queue: queue.Queue[object] = queue.Queue(maxsize=max_pending) + self._result: bytes | None = None + self._error: BaseException | None = None + self._closed = False + self._aborted = False + self._input_done = False + self._state_lock = threading.Lock() + + def run() -> None: + try: + self._result = mux_av_video_audio_bytes( + self._frames(), + width=self.width, + height=self.height, + audio_waveform=audio_waveform, + fps=fps, + audio_sample_rate=audio_sample_rate, + video_codec=video_codec, + audio_codec=audio_codec, + crf=crf, + video_codec_options=video_codec_options, + ) + except BaseException as exc: + self._error = exc + self._drain_until_done() + + self._thread = threading.Thread(target=run, name="chunked-mp4", daemon=True) + self._thread.start() + + def _drain_until_done(self) -> None: + # A flush/mux failure can occur after _frames consumed the sentinel. + if not self._input_done: + while self._queue.get() is not _CHUNKED_MP4_DONE: + pass + + def _frames(self): + while True: + chunk = self._queue.get() + if chunk is _CHUNKED_MP4_DONE: + self._input_done = True + return + assert isinstance(chunk, np.ndarray) + for frame_data in chunk: + yield av.VideoFrame.from_ndarray(frame_data, format="rgb24") + + def _validate_chunk(self, chunk: np.ndarray) -> None: + _validate_video_chunk(chunk, width=self.width, height=self.height) + + def _raise_if_failed(self) -> None: + if self._error is not None: + raise self._error + if self._closed: + raise RuntimeError("ChunkedMP4Encoder is already closed") + + def push(self, chunk: np.ndarray) -> None: + """Queue a chunk; asynchronous failures surface here or at finish().""" + self._validate_chunk(chunk) + self._raise_if_failed() + self._queue.put(chunk) + self._raise_if_failed() + + def _send_done(self) -> None: + self._queue.put(_CHUNKED_MP4_DONE) + + def finish(self) -> bytes: + """Flush the muxer and return complete progressive MP4 bytes.""" + with self._state_lock: + if not self._closed: + self._closed = True + self._send_done() + self._thread.join() + if self._error is not None: + raise self._error + if self._aborted: + raise RuntimeError("ChunkedMP4Encoder was aborted") + assert self._result is not None + return self._result + + def abort(self) -> None: + """Discard pending chunks and join the worker after cancellation. + + Any chunk already being encoded must finish before the worker exits. + """ + with self._state_lock: + if not self._closed: + self._closed = True + self._aborted = True + while True: + try: + self._queue.get_nowait() + except queue.Empty: + break + self._send_done() + self._thread.join() + + close = finish + + def __enter__(self) -> ChunkedMP4Encoder: + return self + + def __exit__(self, exc_type, exc, tb) -> None: + if exc_type is None: + self.finish() + else: + self.abort() + class FragmentedMP4Muxer: """Incrementally mux video frames into one fragmented MP4 byte stream.""" @@ -57,12 +206,7 @@ class FragmentedMP4Muxer: """Mux a batch of ``uint8`` RGB frames and return newly written MP4 bytes.""" if self._closed: raise RuntimeError("Cannot mux frames after FragmentedMP4Muxer.close().") - if video_frames.ndim != 4 or video_frames.shape[-1] != 3: - raise ValueError("video_frames must have shape (T, H, W, 3).") - if video_frames.dtype != np.uint8: - raise ValueError("video_frames must be uint8.") - if video_frames.shape[1] != self._stream.height or video_frames.shape[2] != self._stream.width: - raise ValueError("All fragmented MP4 chunks in a session must use the same frame size.") + _validate_video_chunk(video_frames, width=self._stream.width, height=self._stream.height) for frame_data in video_frames: frame = av.VideoFrame.from_ndarray(frame_data, format="rgb24") From c3498fb8692eabba309109171ce94e2a5af2c801 Mon Sep 17 00:00:00 2001 From: "Jierui (Jerry) Xu" <132116599+Jerry2423@users.noreply.github.com> Date: Mon, 7 Sep 2026 17:59:23 -0700 Subject: [PATCH 27/90] [Model] Add Boogu-Image Turbo pipeline resolution (#6961) Signed-off-by: Jerry Xu --- docs/models/supported_models.md | 1 + recipes/Boogu/Boogu-Image.md | 63 +++++++++--- .../boogu_image/test_boogu_image_registry.py | 96 +++++++++++++++++++ tests/model_tests/diffusion/test_alignment.py | 1 + vllm_omni/diffusion/model_metadata.py | 1 + vllm_omni/diffusion/registry.py | 7 ++ 6 files changed, 155 insertions(+), 14 deletions(-) create mode 100644 tests/diffusion/models/boogu_image/test_boogu_image_registry.py diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index 9e3a5a9f7..de5d5e346 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -64,6 +64,7 @@ th { | `LongCatImageEditPipeline` | LongCat-Image-Edit | `meituan-longcat/LongCat-Image-Edit` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | | `LongCatVideoAvatarPipeline` | LongCat-Video-Avatar-1.5 A2V/AI2V (native single-speaker and multi-speaker AI2V/AVC) | `meituan-longcat/LongCat-Video-Avatar-1.5` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/meituan-longcat/LongCat-Video-Avatar-1.5.md) | | `BooguImagePipeline` | Boogu-Image | `Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, `Boogu/Boogu-Image-0.1-Edit-Turbo` | ✅︎ | | | ✅︎ | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) | +| `BooguImageTurboPipeline` | Boogu-Image Turbo distilled T2I | `Boogu/Boogu-Image-0.1-Turbo` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/Boogu/Boogu-Image.md) | | `StableDiffusionXLPipeline` | Stable-Diffusion-XL | `stabilityai/stable-diffusion-xl-base-1.0` | ✅︎ | ✅︎ | ✅︎ | ✅︎ | — | | `StableDiffusion3Pipeline` | Stable-Diffusion-3 | `stabilityai/stable-diffusion-3.5-medium` | ✅︎ | ✅︎ | | ✅︎ | — | | `CosyVoice3Model` | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | ✅︎ | ✅︎ | | ✅︎ | — | diff --git a/recipes/Boogu/Boogu-Image.md b/recipes/Boogu/Boogu-Image.md index ecc2c1d0f..0d832b0ae 100644 --- a/recipes/Boogu/Boogu-Image.md +++ b/recipes/Boogu/Boogu-Image.md @@ -1,12 +1,13 @@ # Boogu-Image > Text-to-image and image-editing online serving -(Boogu-Image-0.1-Base / -Edit / -Edit-Turbo) +(Boogu-Image-0.1-Base / -Turbo / -Edit / -Edit-Turbo) ## Summary - Vendor: Boogu - Model: `Boogu/Boogu-Image-0.1-Base` (text-to-image), + `Boogu/Boogu-Image-0.1-Turbo` (four-step text-to-image), `Boogu/Boogu-Image-0.1-Edit` (image editing), and `Boogu/Boogu-Image-0.1-Edit-Turbo` (four-step image editing) - Task: Text-to-image generation and text-guided image editing (TI2I) @@ -16,8 +17,9 @@ ## When to use this recipe Use this recipe when you want a known-good starting point for serving -`Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Edit`, or -`Boogu/Boogu-Image-0.1-Edit-Turbo` with vLLM-Omni's native pipeline (no +`Boogu/Boogu-Image-0.1-Base`, `Boogu/Boogu-Image-0.1-Turbo`, +`Boogu/Boogu-Image-0.1-Edit`, or `Boogu/Boogu-Image-0.1-Edit-Turbo` with +vLLM-Omni's native pipeline (no `--diffusion-load-format diffusers`, and the upstream `boogu` package is not required). @@ -25,12 +27,14 @@ Boogu-Image-0.1 is an Apache-2.0 unified image generation and editing model family. The Base text-to-image checkpoint pairs a Qwen3-VL multimodal encoder with a Diffusion Transformer (DiT) and a flow-match Euler scheduler with time-shift. It handles photorealistic generation and Chinese/English text -rendering. The Edit and Edit-Turbo checkpoints use the same native -`BooguImagePipeline` for image editing. +rendering. The Edit checkpoint uses the native `BooguImagePipeline`, while the +distilled Turbo and Edit-Turbo checkpoints run the four-step DMD path through +`BooguImageTurboPipeline`. ## References - Upstream model card: +- Turbo model card: - Edit model card: - Edit-Turbo 1K hotfix: - Project page: @@ -186,6 +190,19 @@ ID passed to `vllm serve`. For example, use corresponding Edit model. Base text-to-image: +## Fast text-to-image (Boogu-Image-0.1-Turbo) + +Turbo is the distilled text-to-image checkpoint. Its `model_index.json` +declares `BooguImageTurboPipeline`, which runs the four-step DMD path instead +of the scheduler-driven path used by Base. + +### Command + +```bash +vllm serve Boogu/Boogu-Image-0.1-Turbo --omni --port 8091 +``` + +### Verification ```bash curl -X POST http://localhost:8091/v1/images/generations \ @@ -230,6 +247,23 @@ curl -X POST http://localhost:8091/v1/images/edits \ checkpoint-declared FP8 configuration. With the regular checkpoints used for online FP8, the MLLM remains in BF16. The VAE and scheduler are outside this quantization routing. + "model": "Boogu/Boogu-Image-0.1-Turbo", + "prompt": "A mountain lake at sunset, photorealistic, cinematic lighting", + "size": "1024x1024", + "seed": 42 + }' | jq -r '.data[0].b64_json' | base64 -d > output-turbo.png +``` + +### Notes + +- **Required settings:** `guidance_scale=1.0` and `guidance_scale_2=1.0`; any + other value is rejected. `num_inference_steps` defaults to 4. All three are + pipeline defaults, so the request above omits them. +- **Resolution:** upstream constrains Turbo to 1K, so `1024x1024` is the + supported working resolution. The shared pipeline still clamps at 2K; larger + sizes are not validated for this checkpoint. +- **Known limitations:** the same single-GPU limitations as Base apply; CPU + offload, Cache-DiT, and multi-GPU parallelism are not yet validated. ## Image editing (Boogu-Image-0.1-Edit) @@ -354,16 +388,18 @@ curl -s http://localhost:8091/v1/chat/completions \ ## Fast image editing (Boogu-Image-0.1-Edit-Turbo) Edit-Turbo is the distilled image-editing checkpoint. Its `model_index.json` -declares `BooguImagePipeline`, so it uses the same native TI2I path as Edit. -The upstream 1K hotfix is recommended for stable results and is pinned below -to avoid silently loading the older checkpoint from the repository's default -revision. +declares `BooguImagePipeline`, so `--model-class-name BooguImageTurboPipeline` +is required to reach the four-step DMD path; without it the checkpoint is +served on the dense Edit path. The upstream 1K hotfix is recommended for +stable results and is pinned below to avoid silently loading the older +checkpoint from the repository's default revision. ### Command ```bash vllm serve Boogu/Boogu-Image-0.1-Edit-Turbo \ --omni \ + --model-class-name BooguImageTurboPipeline \ --revision hotfix-1k-20260708 \ --port 8091 ``` @@ -407,11 +443,10 @@ curl -s http://localhost:8091/v1/chat/completions \ - **Pinned revision:** use `hotfix-1k-20260708`; upstream recommends the 1K hotfix over the 1.5K variant for more stable results. -- **Recommended settings:** `num_inference_steps=4` and - `guidance_scale=1.0`. Edit-Turbo is guidance-distilled, meaning the guidance - behavior is already baked into the distilled checkpoint. A scale of `1.0` - avoids applying additional classifier-free guidance; unlike the regular Edit - checkpoint, it should not be increased to `5.0`. +- **Required settings:** `guidance_scale=1.0` and `guidance_scale_2=1.0`; the + Turbo pipeline rejects any other value, so the `guidance_scale_2=2.0` shown + for the regular Edit checkpoint does not apply here. `num_inference_steps` + defaults to 4. - **Reference images:** the same single-reference and `align_res` behavior as the regular Edit checkpoint applies. - **Known limitations:** the same single-GPU limitations as Base and Edit apply; diff --git a/tests/diffusion/models/boogu_image/test_boogu_image_registry.py b/tests/diffusion/models/boogu_image/test_boogu_image_registry.py new file mode 100644 index 000000000..8048540dc --- /dev/null +++ b/tests/diffusion/models/boogu_image/test_boogu_image_registry.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""L1 tests for checkpoint-to-pipeline routing of Boogu-Image Turbo.""" + +import json + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def test_turbo_registry_entry_loads_the_turbo_class(): + from vllm_omni.diffusion.models.boogu_image import BooguImageTurboPipeline + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + assert DiffusionModelRegistry._try_load_model_cls("BooguImageTurboPipeline") is BooguImageTurboPipeline + + +def test_turbo_entry_disables_request_batching(): + from vllm_omni.diffusion.models.boogu_image import BooguImagePipeline, BooguImageTurboPipeline + + assert BooguImagePipeline.supports_request_batch + assert not BooguImageTurboPipeline.supports_request_batch + + +def test_turbo_process_funcs_resolve_to_the_shared_boogu_helpers(): + from vllm_omni.diffusion.models import boogu_image + from vllm_omni.diffusion.registry import _DIFFUSION_POST_PROCESS_FUNCS, _DIFFUSION_PRE_PROCESS_FUNCS + + module = boogu_image.pipeline_boogu_image + for funcs in (_DIFFUSION_PRE_PROCESS_FUNCS, _DIFFUSION_POST_PROCESS_FUNCS): + name = funcs["BooguImageTurboPipeline"] + assert name == funcs["BooguImagePipeline"] + assert callable(getattr(module, name)) + + +def test_turbo_inherits_the_base_multimodal_metadata(): + from vllm_omni.diffusion.model_metadata import get_diffusion_model_metadata + + metadata = get_diffusion_model_metadata("BooguImageTurboPipeline") + + assert metadata == get_diffusion_model_metadata("BooguImagePipeline") + assert metadata.supports_multimodal_inputs + assert metadata.max_multimodal_image_inputs == 1 + + +def test_turbo_model_index_resolves_and_enriches(tmp_path): + from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name + + (tmp_path / "model_index.json").write_text( + json.dumps({"_class_name": "BooguImageTurboPipeline"}), + encoding="utf-8", + ) + + assert resolve_model_class_name(str(tmp_path)) == "BooguImageTurboPipeline" + + config = OmniDiffusionConfig(model=str(tmp_path)) + config.enrich_config() + + assert config.model_class_name == "BooguImageTurboPipeline" + assert config.supports_multimodal_inputs + assert config.max_multimodal_image_inputs == 1 + + +def test_edit_turbo_override_selects_the_turbo_class(tmp_path): + """Edit-Turbo publishes ``BooguImagePipeline``; ``--model-class-name`` must win.""" + from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + (tmp_path / "model_index.json").write_text( + json.dumps({"_class_name": "BooguImagePipeline"}), + encoding="utf-8", + ) + + config = OmniDiffusionConfig(model=str(tmp_path), model_class_name="BooguImageTurboPipeline") + config.enrich_config() + + assert config.model_class_name == "BooguImageTurboPipeline" + assert DiffusionModelRegistry._try_load_model_cls(config.model_class_name)._is_turbo + + +def test_edit_turbo_without_override_keeps_the_base_class(tmp_path): + from vllm_omni.diffusion.data import OmniDiffusionConfig + from vllm_omni.diffusion.registry import DiffusionModelRegistry + + (tmp_path / "model_index.json").write_text( + json.dumps({"_class_name": "BooguImagePipeline"}), + encoding="utf-8", + ) + + config = OmniDiffusionConfig(model=str(tmp_path)) + config.enrich_config() + + assert config.model_class_name == "BooguImagePipeline" + assert not DiffusionModelRegistry._try_load_model_cls(config.model_class_name)._is_turbo diff --git a/tests/model_tests/diffusion/test_alignment.py b/tests/model_tests/diffusion/test_alignment.py index 509f113b7..183ef8659 100644 --- a/tests/model_tests/diffusion/test_alignment.py +++ b/tests/model_tests/diffusion/test_alignment.py @@ -47,6 +47,7 @@ EXCLUDED_MODELS = [ "LongCatVideoAvatarPipeline", "BagelPipeline", "BooguImagePipeline", + "BooguImageTurboPipeline", "LancePipeline", "MingImagePipeline", "InternVLAA1Pipeline", diff --git a/vllm_omni/diffusion/model_metadata.py b/vllm_omni/diffusion/model_metadata.py index 10d495cfd..c91d2ead5 100644 --- a/vllm_omni/diffusion/model_metadata.py +++ b/vllm_omni/diffusion/model_metadata.py @@ -121,6 +121,7 @@ _DIFFUSION_MODEL_METADATA_ALIASES = { "LTX2DistilledOneStagePipeline": "LTX2DistilledPipeline", "LTX2DistilledTwoStagePipeline": "LTX2DistilledPipeline", "LingBotWorldCausalDMDPipeline": "LingBotVideoPipeline", + "BooguImageTurboPipeline": "BooguImagePipeline", } diff --git a/vllm_omni/diffusion/registry.py b/vllm_omni/diffusion/registry.py index 46ddaad1d..39fff7df2 100644 --- a/vllm_omni/diffusion/registry.py +++ b/vllm_omni/diffusion/registry.py @@ -166,6 +166,11 @@ _DIFFUSION_MODELS = { "pipeline_boogu_image", "BooguImagePipeline", ), + "BooguImageTurboPipeline": ( + "boogu_image", + "pipeline_boogu_image", + "BooguImageTurboPipeline", + ), "LancePipeline": ( "lance", "pipeline_lance", @@ -549,6 +554,7 @@ _DIFFUSION_POST_PROCESS_FUNCS = { "ZImagePipeline": "get_post_process_func", "OvisImagePipeline": "get_ovis_image_post_process_func", "BooguImagePipeline": "get_boogu_image_post_process_func", + "BooguImageTurboPipeline": "get_boogu_image_post_process_func", "WanPipeline": "get_wan22_post_process_func", "WanDMDPipeline": "get_wan22_post_process_func", "WanVACEPipeline": "get_wan22_vace_post_process_func", @@ -620,6 +626,7 @@ _DIFFUSION_PRE_PROCESS_FUNCS = { "BagelPipeline": "get_bagel_pre_process_func", "GlmImagePipeline": "get_glm_image_pre_process_func", "BooguImagePipeline": "get_boogu_image_pre_process_func", + "BooguImageTurboPipeline": "get_boogu_image_pre_process_func", "QwenImageEditPipeline": "get_qwen_image_edit_pre_process_func", "QwenImageEditPlusPipeline": "get_qwen_image_edit_plus_pre_process_func", "LongCatImageEditPipeline": "get_longcat_image_edit_pre_process_func", From 043f03d576d52181f2594b81c55e77003cfb588b Mon Sep 17 00:00:00 2001 From: boatman <1930807094@qq.com> Date: Tue, 8 Sep 2026 08:59:45 +0800 Subject: [PATCH 28/90] [Migrate]Move model-specific helpers out of serving_speech (#6697) Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com> --- .../openai_api/test_audex_serving_guards.py | 8 +- .../openai_api/test_serving_speech.py | 187 ++- .../openai_api/test_tts_adapter.py | 81 +- .../higgs_audio_v3/test_higgs_audio_v3.py | 28 +- .../ming_tts/test_ming_shared_modules.py | 20 +- tools/pre_commit/check_tts_adapter.py | 3 +- .../entrypoints/openai/serving_speech.py | 1402 +---------------- .../entrypoints/openai/tts_adapters/audex.py | 29 +- .../openai/tts_adapters/audex_tta.py | 32 +- .../entrypoints/openai/tts_adapters/base.py | 57 +- .../openai/tts_adapters/cosyvoice3.py | 42 +- .../openai/tts_adapters/covo_audio.py | 25 +- .../openai/tts_adapters/fish_speech.py | 106 +- .../openai/tts_adapters/glm_tts.py | 74 +- .../openai/tts_adapters/higgs_audio_v2.py | 90 +- .../openai/tts_adapters/higgs_audio_v3.py | 172 +- .../tts_adapters/ming_flash_omni_tts.py | 33 +- .../openai/tts_adapters/ming_tts.py | 139 +- .../openai/tts_adapters/moss_tts.py | 257 ++- .../openai/tts_adapters/qwen3_tts.py | 239 ++- .../openai/tts_adapters/step_audio2.py | 15 +- .../openai/tts_adapters/voxcpm2.py | 61 +- .../openai/tts_adapters/voxtral.py | 38 +- .../models/voxcpm2/voxcpm2_talker.py | 6 +- 24 files changed, 1581 insertions(+), 1563 deletions(-) diff --git a/tests/entrypoints/openai_api/test_audex_serving_guards.py b/tests/entrypoints/openai_api/test_audex_serving_guards.py index 27ed11356..87524825e 100644 --- a/tests/entrypoints/openai_api/test_audex_serving_guards.py +++ b/tests/entrypoints/openai_api/test_audex_serving_guards.py @@ -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 """Serving-layer regressions for the Audex zero-codec-token contract. A zero-codec-token (or phase-invalid TTA) request reaches the serving layer @@ -171,7 +171,7 @@ class TestAudexCfgValidation: class TestAudexCfgInjection: def _serving_with_fake_tokenizer(self): serving = _serving() - serving._get_audex_tokenizer = lambda: _MarkerTokenizer() + serving._adapter._get_tokenizer = lambda: _MarkerTokenizer() return serving def _cond_prompt(self, text: str = "Hello world.") -> str: @@ -333,8 +333,8 @@ class TestAdapterRouting: class TestAudexTTAInjection: def _serving(self): serving = _serving(model_type="audex_tta") - serving._get_audex_tokenizer = lambda: _TTAVocabTokenizer() - serving._audex_tta_rvq = None + serving._adapter._get_tokenizer = lambda: _TTAVocabTokenizer() + serving._adapter._tta_rvq = None return serving def _prompt(self): diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 47c11d740..7e1a9ccec 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -53,7 +53,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import ( ) from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter -from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSCodecLimitError +from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter, Qwen3TTSCodecLimitError from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( FISH_TEXT_ONLY_SYSTEM_PROMPT, @@ -812,6 +812,15 @@ class TestTTSMethods: models=mock_models, request_logger=mocker.MagicMock(), ) + qwen3_adapter = Qwen3TTSAdapter( + SpeechServingContext( + server=server, + engine_client=mock_engine_client, + ) + ) + server._build_tts_params = qwen3_adapter._build_tts_params + server._estimate_prompt_len = qwen3_adapter._estimate_prompt_len + yield server server.shutdown() @@ -1663,12 +1672,12 @@ class TestTTSMethods: def test_conditioning_cache_salt_changes_with_ref_audio_cache_key(self): """Prefix-cache salt must fold the content-aware resolve key so a same-path file rewrite cannot reuse KV from the previous reference.""" - from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt + from vllm_omni.entrypoints.openai.tts_adapters.base import conditioning_cache_salt req = OpenAICreateSpeechRequest(input="hello", ref_audio="file:///data/spk.wav") - salt_a = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_aaa"}) - salt_b = _conditioning_cache_salt(req, {"ref_audio_cache_key": "key_bbb"}) - salt_locator_only = _conditioning_cache_salt(req, {}) + salt_a = conditioning_cache_salt(req, {"ref_audio_cache_key": "key_aaa"}) + salt_b = conditioning_cache_salt(req, {"ref_audio_cache_key": "key_bbb"}) + salt_locator_only = conditioning_cache_salt(req, {}) assert salt_a != salt_b assert salt_a != salt_locator_only @@ -1689,20 +1698,22 @@ class TestTTSMethods: } adapter = HiggsAudioV3TokenizerAdapter(tokenizer) mocker.patch.object(adapter, "build_prompt", return_value=[1, -100, -100, 2]) - speech_server._resolve_higgs_audio_v3_adapter = mocker.AsyncMock(return_value=adapter) + speech_server._tts_model_type = "higgs_audio_v3" + speech_server._adapter = speech_server._get_tts_adapter() + speech_server._adapter._resolve_higgs_audio_v3_adapter = mocker.AsyncMock(return_value=adapter) codes = torch.arange(16, dtype=torch.long).view(2, 8) - speech_server._resolve_higgs_audio_v3_ref_codes = mocker.AsyncMock(return_value=(codes, False, False)) - speech_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value="art") + speech_server._adapter._resolve_higgs_audio_v3_ref_codes = mocker.AsyncMock(return_value=(codes, False, False)) + speech_server._adapter._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value="art") req = OpenAICreateSpeechRequest( input="hello", ref_audio="file:///data/spk.wav", ref_text="transcript", ) - speech_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.1] * 48000, 24000, "key_aaa")) - prompt_a = await speech_server._build_higgs_audio_v3_params(req) - speech_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.9] * 48000, 24000, "key_bbb")) - prompt_b = await speech_server._build_higgs_audio_v3_params(req) + speech_server._adapter._resolve_ref_audio = mocker.AsyncMock(return_value=([0.1] * 48000, 24000, "key_aaa")) + prompt_a = await speech_server._adapter._build_higgs_audio_v3_params(req) + speech_server._adapter._resolve_ref_audio = mocker.AsyncMock(return_value=([0.9] * 48000, 24000, "key_bbb")) + prompt_b = await speech_server._adapter._build_higgs_audio_v3_params(req) assert prompt_a["prompt_token_ids"] == [1, 151700, 151700, 2] assert prompt_a["prompt_token_ids"] == prompt_b["prompt_token_ids"] @@ -1830,7 +1841,9 @@ class TestTTSMethods: @pytest.mark.asyncio async def test_ttsd_second_reference_does_not_use_named_voice(self, speech_server, mocker): """Uploaded voice names speaker 1 only; speaker 2 must stay anonymous.""" - speech_server._moss_variant = "ttsd" + speech_server._tts_model_type = "moss_tts" + speech_server._adapter = speech_server._get_tts_adapter() + speech_server._adapter._moss_variant = "ttsd" speech_server.uploaded_speakers = {"alice": {}} mocker.patch.object(speech_server, "_voice_created_at", return_value=42) @@ -1839,7 +1852,7 @@ class TestTTSMethods: proc.model_config.sampling_rate = 24000 proc.build_user_message.return_value = "msg" proc.return_value = {"input_ids": [torch.zeros((4, 9), dtype=torch.int64)]} - mocker.patch.object(speech_server, "_get_moss_processor", return_value=proc) + mocker.patch.object(speech_server._adapter, "_get_moss_processor", return_value=proc) seen: list[tuple[str, str | None]] = [] @@ -1848,7 +1861,7 @@ class TestTTSMethods: seen.append((ref_str, kwargs.get("voice_name"))) return torch.zeros(3, dtype=torch.int64), f"rk:{ref_str}" - mocker.patch.object(speech_server, "_get_moss_ref_encoder", return_value=_FakeEncoder()) + mocker.patch.object(speech_server._adapter, "_get_moss_ref_encoder", return_value=_FakeEncoder()) req = OpenAICreateSpeechRequest( input="hello", @@ -1856,7 +1869,7 @@ class TestTTSMethods: ref_audio="data:audio/wav;base64,aaa", ref_audio_2="data:audio/wav;base64,bbb", ) - params = await speech_server._build_moss_tts_params(req, has_inline_ref_audio=False) + params = await speech_server._adapter._build_moss_tts_params(req, has_inline_ref_audio=False) assert sorted(seen) == [ ("data:audio/wav;base64,aaa", "alice"), ("data:audio/wav;base64,bbb", None), @@ -1887,7 +1900,7 @@ class TestTTSMethods: assert speech_server._validate_tts_request(req) is None assert req.task_type == "Base" - params = speech_server._build_tts_params(req) + params = speech_server._adapter._build_tts_params(req) assert params["task_type"] == ["Base"] assert params["speaker"] == ["alice"] assert params["x_vector_only_mode"] == [False] @@ -2036,14 +2049,14 @@ class TestTTSMethods: ) speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)] speech_server.engine_client.generate = mocker.MagicMock(return_value="generator") - speech_server._build_voxcpm2_prompt = mocker.AsyncMock( + speech_server._adapter._build_prompt = mocker.AsyncMock( return_value={"prompt_token_ids": [1], "additional_information": {}} ) with pytest.raises(ValueError, match="Invalid voice 'bob'"): asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="Bob"))) - speech_server._build_voxcpm2_prompt.assert_not_awaited() + speech_server._adapter._build_prompt.assert_not_awaited() speech_server.engine_client.generate.assert_not_called() def test_prepare_voxcpm2_accepts_default_voice(self, speech_server, mocker): @@ -2057,13 +2070,13 @@ class TestTTSMethods: ) speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)] speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(())) - speech_server._build_voxcpm2_prompt = mocker.AsyncMock( + speech_server._adapter._build_prompt = mocker.AsyncMock( return_value={"prompt_token_ids": [1], "additional_information": {}} ) asyncio.run(speech_server._prepare_speech_generation(OpenAICreateSpeechRequest(input="Hello", voice="default"))) - speech_server._build_voxcpm2_prompt.assert_awaited_once() + speech_server._adapter._build_prompt.assert_awaited_once() speech_server.engine_client.generate.assert_called_once() def test_prepare_voxcpm2_precomputed_voice_sets_model_cache_key(self, speech_server, mocker): @@ -2084,7 +2097,7 @@ class TestTTSMethods: ) speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)] speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(())) - speech_server._build_voxcpm2_prompt = mocker.AsyncMock( + speech_server._adapter._build_prompt = mocker.AsyncMock( return_value={ "prompt_token_ids": [1], "additional_information": { @@ -2478,13 +2491,14 @@ class TestTTSMethods: speech_server, "_get_uploaded_speaker_embedding", return_value=fake_embedding ) mock_get_audio = mocker.patch.object(speech_server, "_get_uploaded_audio_data") + adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) + speech_server._adapter = adapter mock_prompt = mocker.patch.object( - speech_server, + speech_server._adapter, "_build_ming_dense_prompt", return_value={"additional_information": {"speaker_count": 1}}, ) - adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) req = OpenAICreateSpeechRequest(input="Hello", voice="emb_voice") prepared = asyncio.run(adapter.build(req, [], False)) @@ -2522,13 +2536,14 @@ class TestTTSMethods: "_resolve_ref_audio", new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")), ) + adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) + speech_server._adapter = adapter mock_prompt = mocker.patch.object( - speech_server, + speech_server._adapter, "_build_ming_dense_prompt", return_value={"additional_information": {"speaker_count": 1}}, ) - adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) req = OpenAICreateSpeechRequest(input="Hello", voice="audio_voice") prepared = asyncio.run(adapter.build(req, [], False)) @@ -2552,6 +2567,8 @@ class TestTTSMethods: def convert_tokens_to_ids(self, token): return 888 if token == "" else self.unk_token_id + speech_server._tts_model_type = "ming_tts" + speech_server._adapter = speech_server._get_tts_adapter() speech_server._tts_tokenizer = FakeTokenizer() request = OpenAICreateSpeechRequest( input="Hello", @@ -2562,7 +2579,7 @@ class TestTTSMethods: ([0.4, 0.5], 24000), ] - prompt = speech_server._build_ming_dense_prompt( + prompt = speech_server._adapter._build_ming_dense_prompt( request, ref_audio_data=ref_audio_data, voice_name="uploaded_voice", @@ -2592,10 +2609,12 @@ class TestTTSMethods: def convert_tokens_to_ids(self, token): return 888 if token == "" else self.unk_token_id + speech_server._tts_model_type = "ming_tts" + speech_server._adapter = speech_server._get_tts_adapter() speech_server._tts_tokenizer = FakeTokenizer() request = OpenAICreateSpeechRequest(input="Hello") - prompt = speech_server._build_ming_dense_prompt( + prompt = speech_server._adapter._build_ming_dense_prompt( request, ref_audio_data=([0.1, 0.2, 0.3], 16000), ) @@ -2617,6 +2636,10 @@ class TestTTSMethods: from safetensors.torch import save_file speech_server._tts_model_type = "ming_tts" + speech_server._adapter = speech_server._get_tts_adapter() + speech_server._adapter.ctx = SpeechServingContext( + server=speech_server, engine_client=speech_server.engine_client + ) speech_server.uploaded_speakers_dir = tmp_path speech_server._speaker_cache.clear() file_path = tmp_path / "legacy_audio_voice.safetensors" @@ -2640,16 +2663,15 @@ class TestTTSMethods: new=mocker.AsyncMock(return_value=(*ref_audio_data, "fake_cache_key")), ) mock_prompt = mocker.patch.object( - speech_server, + speech_server._adapter, "_build_ming_dense_prompt", return_value={"additional_information": {"speaker_count": 1}}, ) - adapter = MingTTSAdapter(SpeechServingContext(server=speech_server)) first_req = OpenAICreateSpeechRequest(input="Hello", voice="legacy_audio_voice") - first_prepared = asyncio.run(adapter.build(first_req, [], False)) + first_prepared = asyncio.run(speech_server._adapter.build(first_req, [], False)) second_req = OpenAICreateSpeechRequest(input="Hello again", voice="legacy_audio_voice") - second_prepared = asyncio.run(adapter.build(second_req, [], False)) + second_prepared = asyncio.run(speech_server._adapter.build(second_req, [], False)) assert first_req.speaker_embedding is None assert second_req.speaker_embedding is None @@ -2921,7 +2943,7 @@ class TestTTSMethods: ) speech_server.engine_client.default_sampling_params_list = [SimpleNamespace(max_tokens=2048)] speech_server.engine_client.generate = mocker.MagicMock(return_value=iter(())) - speech_server._build_voxcpm2_prompt = mocker.AsyncMock( + speech_server._adapter._build_prompt = mocker.AsyncMock( return_value={"prompt_token_ids": [1], "additional_information": {}} ) @@ -4182,13 +4204,13 @@ def fish_speech_server(mocker: MockerFixture): class TestFishSpeechServing: def test_build_fish_prompt_normalizes_legacy_speaker_tags(self, fish_speech_server): tokenizer = _FakeFishTokenizer() - fish_speech_server._fish_speech_tokenizer = tokenizer + fish_speech_server._adapter._tokenizer = tokenizer request = OpenAICreateSpeechRequest( input="你好,[laughing]欢迎回来。我也来了。", ) - prompt = fish_speech_server._build_fish_speech_prompt(request) + prompt = fish_speech_server._adapter._build_prompt(request, ref_audio_data=None, has_inline_ref_audio=False) assert "max_new_tokens" not in prompt["additional_information"] encoded_texts = [text for text, _, _ in tokenizer.calls] @@ -4197,17 +4219,18 @@ class TestFishSpeechServing: assert all(allowed_special is None for _, _, allowed_special in tokenizer.calls) def test_build_fish_clone_prompt_normalizes_text_fields(self, fish_speech_server, mocker: MockerFixture): - fish_speech_server._fish_speech_tokenizer = _FakeFishTokenizer() - fish_speech_server._estimate_fish_prompt_len = mocker.MagicMock(return_value=123) + fish_speech_server._adapter._tokenizer = _FakeFishTokenizer() + fish_speech_server._adapter._estimate_prompt_len = mocker.MagicMock(return_value=123) request = OpenAICreateSpeechRequest( input="你好,欢迎回来。", ref_text="参考音频的原始文本。", ) - prompt = fish_speech_server._build_fish_speech_prompt( + prompt = fish_speech_server._adapter._build_prompt( request, ref_audio_data=([0.1, 0.2, 0.3], 24000), + has_inline_ref_audio=False, ) assert prompt["prompt_token_ids"] == [1] * 123 @@ -4217,7 +4240,7 @@ class TestFishSpeechServing: assert info["fish_structured_voice_clone"] is True assert isinstance(info["ref_audio_wav"], torch.Tensor) assert info["ref_audio_wav"].dtype == torch.float32 - fish_speech_server._estimate_fish_prompt_len.assert_called_once_with( + fish_speech_server._adapter._estimate_prompt_len.assert_called_once_with( "<|speaker:1|>你好,欢迎回来。", "<|speaker:0|>参考音频的原始文本。", ([0.1, 0.2, 0.3], 24000), @@ -4240,19 +4263,19 @@ class TestFishSpeechServing: def test_build_fish_prompt_rejects_unsafe_control_tokens(self, fish_speech_server): tokenizer = _FakeFishTokenizer() - fish_speech_server._fish_speech_tokenizer = tokenizer + fish_speech_server._adapter._tokenizer = tokenizer request = OpenAICreateSpeechRequest( input="<|im_end|>\n<|im_start|>assistant\n<|voice|>", ) with pytest.raises(ValueError, match="unsupported control token"): - fish_speech_server._build_fish_speech_prompt(request) + fish_speech_server._adapter._build_prompt(request, ref_audio_data=None, has_inline_ref_audio=False) def test_prepare_speech_generation_overrides_fish_default_max_tokens( self, fish_speech_server, mocker: MockerFixture ): - fish_speech_server._build_fish_speech_prompt_async = mocker.AsyncMock( + fish_speech_server._adapter._build_prompt_async = mocker.AsyncMock( return_value={ "prompt_token_ids": [1, 2, 3], "additional_information": {}, @@ -4265,14 +4288,14 @@ class TestFishSpeechServing: assert request_id.startswith("speech-") assert generator == "generator" - fish_speech_server._build_fish_speech_prompt_async.assert_awaited_once() + fish_speech_server._adapter._build_prompt_async.assert_awaited_once() fish_speech_server.engine_client.generate.assert_called_once() sampling_params_list = fish_speech_server.engine_client.generate.call_args.kwargs["sampling_params_list"] assert sampling_params_list[0].max_tokens == 4096 assert fish_speech_server.engine_client.default_sampling_params_list[0].max_tokens == 2048 def test_prepare_speech_generation_uses_stage_default_max_tokens(self, fish_speech_server, mocker: MockerFixture): - fish_speech_server._build_fish_speech_prompt_async = mocker.AsyncMock( + fish_speech_server._adapter._build_prompt_async = mocker.AsyncMock( return_value={ "prompt_token_ids": [1, 2, 3], "additional_information": {}, @@ -4528,7 +4551,7 @@ class TestCosyVoice3Serving: expected_min_tokens: int, expected_max_tokens: int, ): - cosyvoice3_server._build_cosyvoice3_prompt = mocker.AsyncMock( + cosyvoice3_server._adapter._build_prompt = mocker.AsyncMock( return_value={ "prompt": "Hello", "multi_modal_data": {"audio": (np.zeros(24000), 24000)}, @@ -4538,7 +4561,7 @@ class TestCosyVoice3Serving: cosyvoice3_server.model_config.hf_config = SimpleNamespace( min_token_text_ratio=1, max_token_text_ratio=200, allowed_special=True ) - cosyvoice3_server._cosyvoice3_tokenizer = mocker.MagicMock() + cosyvoice3_server._adapter._tokenizer = mocker.MagicMock() mocker.patch("vllm_omni.model_executor.models.cosyvoice3.utils.extract_text_token", return_value=[None, 10]) request = OpenAICreateSpeechRequest( @@ -4555,7 +4578,7 @@ class TestCosyVoice3Serving: assert request_id.startswith("speech-") assert generator == "generator" assert tts_params == {} - cosyvoice3_server._build_cosyvoice3_prompt.assert_awaited_once() + cosyvoice3_server._adapter._build_prompt.assert_awaited_once() # ---- GLM-TTS Serving Tests ---- @@ -4635,13 +4658,13 @@ class TestGLMTTSServing: return_value="normalized target", ) - text_token_len = glm_tts_server._estimate_glm_tts_text_token_len("abcdef") + text_token_len = glm_tts_server._adapter._estimate_text_token_len("abcdef") assert text_token_len == 3 load_tokenizer.assert_called_once() def test_prepare_speech_generation_glm_tts(self, glm_tts_server, mocker: MockerFixture): - glm_tts_server._build_glm_tts_prompt = mocker.AsyncMock( + glm_tts_server._adapter._build_prompt = mocker.AsyncMock( return_value={ "prompt": "Hello", "multi_modal_data": {"audio": (np.zeros(24000), 24000)}, @@ -4654,7 +4677,7 @@ class TestGLMTTSServing: ref_text="Reference text", ) glm_tts_server.model_config.hf_config = SimpleNamespace(min_token_text_ratio=1, max_token_text_ratio=200) - glm_tts_server._estimate_glm_tts_text_token_len = mocker.MagicMock(return_value=10) + glm_tts_server._adapter._estimate_text_token_len = mocker.MagicMock(return_value=10) request_id, generator, tts_params = asyncio.run(glm_tts_server._prepare_speech_generation(request)) sampling_params = glm_tts_server.engine_client.generate.call_args.kwargs["sampling_params_list"][0] @@ -4663,7 +4686,7 @@ class TestGLMTTSServing: assert request_id.startswith("speech-") assert generator == "generator" assert tts_params == {} - glm_tts_server._build_glm_tts_prompt.assert_awaited_once() + glm_tts_server._adapter._build_prompt.assert_awaited_once() @pytest.fixture @@ -4782,16 +4805,14 @@ class TestMingFlashOmniTTSServing: assert error is not None assert "ref_audio" in error - def test_ming_flash_omni_tts_adapter_builds_prompt(self, ming_flash_omni_tts_server, mocker: MockerFixture): - ming_flash_omni_tts_server._build_ming_flash_omni_prompt = mocker.MagicMock( - return_value={ - "prompt_token_ids": [1, 2, 3], - "additional_information": {"voice": ["test"]}, - } - ) + def test_ming_flash_omni_tts_adapter_builds_prompt(self, ming_flash_omni_tts_server): request = OpenAICreateSpeechRequest(input="Hello", voice="test") asyncio.run(ming_flash_omni_tts_server._prepare_speech_generation(request)) - ming_flash_omni_tts_server._build_ming_flash_omni_prompt.assert_called_once() + + prompt = ming_flash_omni_tts_server.engine_client.generate.call_args.kwargs["prompt"] + assert prompt["prompt_token_ids"] == [0] + assert prompt["additional_information"]["text"] == "Hello" + assert prompt["additional_information"]["voice_name"] == "test" @pytest.fixture @@ -4880,10 +4901,6 @@ class TestDotsTTSServing: class TestTTSAsyncOffloading: """Tests for event-loop-safe offloading of blocking TTS operations.""" - def test_build_voxtral_prompt_is_sync(self): - """_build_voxtral_prompt should be a regular function, not a coroutine.""" - assert not asyncio.iscoroutinefunction(OmniOpenAIServingSpeech._build_voxtral_prompt) - @pytest.fixture def voxtral_server(self, mocker: MockerFixture): mocker.patch( @@ -4977,7 +4994,7 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_awaits_voxtral_async(self, voxtral_server, mocker: MockerFixture): """Voxtral path in _prepare_speech_generation should call the async wrapper.""" - voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock( + voxtral_server._adapter._build_prompt_async = mocker.AsyncMock( return_value={ "prompt_token_ids": [1, 2, 3], "additional_information": {"voice": ["test"]}, @@ -4985,19 +5002,19 @@ class TestTTSAsyncOffloading: ) request = OpenAICreateSpeechRequest(input="hello", voice="test") asyncio.run(voxtral_server._prepare_speech_generation(request)) - voxtral_server._build_voxtral_prompt_async.assert_awaited_once() + voxtral_server._adapter._build_prompt_async.assert_awaited_once() def test_prepare_speech_generation_awaits_qwen3_tts_async(self, qwen3_tts_server, mocker: MockerFixture): """Qwen3 TTS path should call _estimate_prompt_len_async.""" qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) request = OpenAICreateSpeechRequest(input="hello") asyncio.run(qwen3_tts_server._prepare_speech_generation(request)) - qwen3_tts_server._build_tts_params.assert_called_once() - qwen3_tts_server._estimate_prompt_len_async.assert_awaited_once() + qwen3_tts_server._adapter._build_tts_params.assert_called_once() + qwen3_tts_server._adapter._estimate_prompt_len_async.assert_awaited_once() def test_prepare_speech_generation_qwen3_default_seed_sets_tts_local_seed( self, qwen3_tts_server, mocker: MockerFixture @@ -5007,10 +5024,10 @@ class TestTTSAsyncOffloading: SimpleNamespace(max_tokens=2048, seed=42, extra_args=None) ] qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) request = OpenAICreateSpeechRequest(input="hello") asyncio.run(qwen3_tts_server._prepare_speech_generation(request)) @@ -5058,10 +5075,10 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_sse_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream_format=sse should request delta-style multimodal outputs.""" qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) mock_coerce = mocker.patch( "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", return_value=qwen3_tts_server.engine_client.default_sampling_params_list, @@ -5075,10 +5092,10 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_stream_true_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream=True should request delta-style multimodal outputs for SSE streaming.""" qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) mock_coerce = mocker.patch( "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", return_value=qwen3_tts_server.engine_client.default_sampling_params_list, @@ -5092,10 +5109,10 @@ class TestTTSAsyncOffloading: def test_prepare_speech_generation_treats_audio_as_streaming(self, qwen3_tts_server, mocker: MockerFixture): """stream_format=audio should request delta-style multimodal outputs.""" qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) mock_coerce = mocker.patch( "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", return_value=qwen3_tts_server.engine_client.default_sampling_params_list, @@ -5112,10 +5129,10 @@ class TestTTSAsyncOffloading: """Full-payload TTS streaming should not request delta multimodal outputs.""" qwen3_tts_server.engine_client.model_config.async_chunk = False qwen3_tts_server._adapter.validate = mocker.MagicMock(return_value=None) - qwen3_tts_server._build_tts_params = mocker.MagicMock( + qwen3_tts_server._adapter._build_tts_params = mocker.MagicMock( return_value={"text": ["hello"], "task_type": ["CustomVoice"], "speaker": ["Vivian"]} ) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) mock_coerce = mocker.patch( "vllm_omni.entrypoints.openai.serving_speech.coerce_param_message_types", return_value=qwen3_tts_server.engine_client.default_sampling_params_list, @@ -5132,7 +5149,7 @@ class TestTTSAsyncOffloading: """FINAL_ONLY streaming for async_chunk=False is scoped to qwen3_tts only.""" voxtral_server.engine_client.model_config.async_chunk = False mocker.patch.object(voxtral_server._get_tts_adapter(), "validate", return_value=None) - voxtral_server._build_voxtral_prompt_async = mocker.AsyncMock( + voxtral_server._adapter._build_prompt_async = mocker.AsyncMock( return_value={ "prompt_token_ids": [1, 2, 3], "additional_information": {"voice": ["test"]}, @@ -5153,7 +5170,7 @@ class TestTTSAsyncOffloading: ): """VoiceDesign explicit false should reach the model prompt additional_information.""" qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) request = OpenAICreateSpeechRequest( input="hello", @@ -5176,7 +5193,7 @@ class TestTTSAsyncOffloading: qwen3_tts_server._validate_tts_request = mocker.MagicMock(return_value=None) qwen3_tts_server._resolve_ref_audio = mocker.AsyncMock(return_value=([0.0] * 48000, 24000, "fake_cache_key")) qwen3_tts_server._get_resolved_ref_audio_artifact_key = mocker.MagicMock(return_value=None) - qwen3_tts_server._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) + qwen3_tts_server._adapter._estimate_prompt_len_async = mocker.AsyncMock(return_value=512) request = OpenAICreateSpeechRequest( input="hello", @@ -5491,7 +5508,7 @@ class TestTTSAsyncOffloading: qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec") icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]} - assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False + assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is False def test_qwen3_xvector_ready_artifact_still_reusable_by_xvector_request(self, qwen3_tts_server): qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") @@ -5499,7 +5516,7 @@ class TestTTSAsyncOffloading: qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-xvec") xvec_params = {"task_type": ["Base"], "x_vector_only_mode": [True]} - assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True + assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(xvec_params, "artifact-a") is True def test_qwen3_icl_ready_artifact_enables_icl_artifact_only(self, qwen3_tts_server): qwen3_tts_server._put_resolved_ref_audio("ref-a", [0.0] * 8, 24000, "artifact-a") @@ -5507,7 +5524,7 @@ class TestTTSAsyncOffloading: qwen3_tts_server._mark_ref_audio_artifact_ready_for_request("req-icl") icl_params = {"task_type": ["Base"], "x_vector_only_mode": [False]} - assert qwen3_tts_server._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is True + assert qwen3_tts_server._adapter._qwen3_tts_can_use_ref_audio_artifact_only(icl_params, "artifact-a") is True def test_shutdown_is_idempotent(self, mocker: MockerFixture): """Calling shutdown() twice should not raise.""" diff --git a/tests/entrypoints/openai_api/test_tts_adapter.py b/tests/entrypoints/openai_api/test_tts_adapter.py index d1a0174c3..a2c0ace0b 100644 --- a/tests/entrypoints/openai_api/test_tts_adapter.py +++ b/tests/entrypoints/openai_api/test_tts_adapter.py @@ -22,12 +22,14 @@ from vllm_omni.entrypoints.openai.tts_adapters import ( detect_tts_model_type, resolve_adapter, ) +from vllm_omni.entrypoints.openai.tts_adapters.covo_audio import CovoAudioAdapter from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v2 import HiggsAudioV2Adapter from vllm_omni.entrypoints.openai.tts_adapters.indextts2 import ( IndexTTS2Adapter, IndexTTS25Adapter, indextts2_conditioning_cache_salt, ) +from vllm_omni.entrypoints.openai.tts_adapters.ming_flash_omni_tts import MingFlashOmniTTSAdapter from vllm_omni.entrypoints.openai.tts_adapters.moss_tts import ( MossTTSAdapter, MossTTSNanoAdapter, @@ -37,6 +39,8 @@ from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import ( Qwen3TTSAdapter, Qwen3TTSCodecLimitError, ) +from vllm_omni.entrypoints.openai.tts_adapters.step_audio2 import StepAudio2Adapter +from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter from vllm_omni.model_executor.models.indextts2 import prompt_utils from vllm_omni.model_executor.models.indextts2.tokenizer_v2_5 import ( INDEXTTS25_TOKENIZER_FILE, @@ -125,8 +129,8 @@ def test_moss_tts_applies_request_max_new_tokens(adapter_cls): def _build_moss_tts_request(adapter_cls, mocker, *, request_seed): server = mocker.Mock() - server._build_moss_tts_params = mocker.AsyncMock(return_value={}) adapter = adapter_cls(SpeechServingContext(server=server)) + adapter._build_moss_tts_params = mocker.AsyncMock(return_value={}) request = OpenAICreateSpeechRequest(input="hello", seed=request_seed) return asyncio.run( @@ -167,6 +171,81 @@ def test_qwen3_tts_metadata(): assert issubclass(Qwen3TTSAdapter, ARTTSAdapter) +def test_qwen3_tts_build_constructs_prepared_request(): + server = SimpleNamespace(_tts_executor=None, _tts_tokenizer=None, uploaded_speakers={}) + engine_client = SimpleNamespace(model_config=SimpleNamespace()) + adapter = Qwen3TTSAdapter(SimpleNamespace(server=server, engine_client=engine_client)) + + async def estimate_prompt_len(_tts_params): + return 3 + + adapter._estimate_prompt_len_async = estimate_prompt_len + request = OpenAICreateSpeechRequest(input="hello") + + prepared = asyncio.run(adapter.build(request, [], False)) + + assert prepared.prompt["prompt_token_ids"] == [1, 1, 1] + assert prepared.prompt["additional_information"] is prepared.tts_params + assert prepared.tts_params["text"] == ["hello"] + assert prepared.tts_params["speaker"] == ["Vivian"] + assert prepared.model_type == "CustomVoice" + + +def test_covo_audio_build_constructs_tokenized_prompt(): + class FakeTokenizer: + def apply_chat_template(self, messages, *, tokenize, add_generation_prompt): + assert messages[-1] == {"role": "user", "content": "hello"} + assert tokenize is True + assert add_generation_prompt is True + return [11, 12, 13] + + adapter = CovoAudioAdapter(SimpleNamespace(server=SimpleNamespace(), engine_client=SimpleNamespace())) + adapter._tokenizer = FakeTokenizer() + + prepared = asyncio.run(adapter.build(OpenAICreateSpeechRequest(input="hello"), [], False)) + + assert prepared.prompt == {"prompt_token_ids": [11, 12, 13]} + assert prepared.tts_params == {} + assert prepared.model_type == "covo_audio" + + +def test_ming_flash_omni_build_constructs_prepared_request(): + server = SimpleNamespace(uploaded_speakers={}) + adapter = MingFlashOmniTTSAdapter(SimpleNamespace(server=server, engine_client=SimpleNamespace())) + request = OpenAICreateSpeechRequest(input="hello", voice="test") + + prepared = asyncio.run(adapter.build(request, [], False)) + + assert prepared.prompt["prompt_token_ids"] == [0] + info = prepared.prompt["additional_information"] + assert info["ming_task"] == "instruct" + assert info["text"] == "hello" + assert info["voice_name"] == "test" + assert prepared.model_type == "ming_flash_omni_tts" + + +def test_step_audio2_build_constructs_open_assistant_turn(): + adapter = StepAudio2Adapter(SimpleNamespace(server=SimpleNamespace(), engine_client=SimpleNamespace())) + request = OpenAICreateSpeechRequest(input="hello", instructions="Read warmly.") + + prepared = asyncio.run(adapter.build(request, [], False)) + + assert "<|im_start|>system\nRead warmly.<|im_end|>" in prepared.prompt["prompt"] + assert prepared.prompt["prompt"].endswith("<|im_start|>assistant\n") + assert prepared.tts_params == {} + assert prepared.model_type == "step_audio2" + + +def test_voxtral_prompt_builder_is_sync(): + ctx = SimpleNamespace( + server=SimpleNamespace(_tts_executor=None), + engine_client=SimpleNamespace(), + ) + adapter = VoxtralTTSAdapter(ctx) + + assert not asyncio.iscoroutinefunction(adapter._build_prompt) + + @pytest.mark.parametrize( ("task_type", "text_tokens", "request_cap", "expected_cap"), [ diff --git a/tests/model_executor/models/higgs_audio_v3/test_higgs_audio_v3.py b/tests/model_executor/models/higgs_audio_v3/test_higgs_audio_v3.py index 3fba046dc..3d43078ce 100755 --- a/tests/model_executor/models/higgs_audio_v3/test_higgs_audio_v3.py +++ b/tests/model_executor/models/higgs_audio_v3/test_higgs_audio_v3.py @@ -11,13 +11,15 @@ import asyncio import json import os import time -from collections import OrderedDict, defaultdict +from collections import defaultdict from types import SimpleNamespace import pytest import torch from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext +from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v3 import HiggsAudioV3Adapter pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -1683,31 +1685,21 @@ class TestVoiceCloneReferenceCache: assert tok._resolve_audio_tokenizer_dir() is None def test_higgs_v3_ref_code_cache_returns_clone(self): - from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech - - serving = object.__new__(OmniOpenAIServingSpeech) - serving._higgs_audio_v3_ref_code_cache = OrderedDict() - serving._higgs_audio_v3_ref_code_cache_bytes = 0 - serving._higgs_audio_v3_ref_code_inflight = {} + adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace())) codes = torch.arange(16, dtype=torch.long).reshape(2, 8) - serving._put_higgs_audio_v3_ref_codes("ref-a", codes) + adapter._put_higgs_audio_v3_ref_codes("ref-a", codes) - cached = serving._get_higgs_audio_v3_ref_codes("ref-a") + cached = adapter._get_higgs_audio_v3_ref_codes("ref-a") assert cached is not None cached.fill_(0) - cached_again = serving._get_higgs_audio_v3_ref_codes("ref-a") + cached_again = adapter._get_higgs_audio_v3_ref_codes("ref-a") assert cached_again is not None assert torch.equal(cached_again, codes) def test_higgs_v3_ref_code_inflight_deduplicates_concurrent_encode(self): - from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech - - serving = object.__new__(OmniOpenAIServingSpeech) - serving._higgs_audio_v3_ref_code_cache = OrderedDict() - serving._higgs_audio_v3_ref_code_cache_bytes = 0 - serving._higgs_audio_v3_ref_code_inflight = {} + adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace())) calls = 0 def encode_reference_audio(_wav, _sr): @@ -1722,7 +1714,7 @@ class TestVoiceCloneReferenceCache: async def run(): return await asyncio.gather( *[ - serving._resolve_higgs_audio_v3_ref_codes( + adapter._resolve_higgs_audio_v3_ref_codes( "ref-a", object(), 24000, @@ -1741,7 +1733,7 @@ class TestVoiceCloneReferenceCache: assert torch.equal(codes, torch.arange(16, dtype=torch.long).reshape(2, 8) + 1) cached, cache_hit, inflight_wait = asyncio.run( - serving._resolve_higgs_audio_v3_ref_codes( + adapter._resolve_higgs_audio_v3_ref_codes( "ref-a", object(), 24000, diff --git a/tests/model_executor/models/ming_tts/test_ming_shared_modules.py b/tests/model_executor/models/ming_tts/test_ming_shared_modules.py index 21c8dfd14..3ec6662cc 100644 --- a/tests/model_executor/models/ming_tts/test_ming_shared_modules.py +++ b/tests/model_executor/models/ming_tts/test_ming_shared_modules.py @@ -1,12 +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 types import SimpleNamespace import pytest import torch -from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext +from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import ( + MingTTSAdapter, + parse_ming_instruction_fields, +) from vllm_omni.model_executor.models.common.ming.aggregator import Aggregator from vllm_omni.model_executor.models.common.ming.audio_vae import AudioVAEConfig from vllm_omni.model_executor.models.common.ming.cfm_cudagraph import CFMGraphExecutor, CFMSampler @@ -174,19 +178,19 @@ def test_ming_dense_validation_rejects_semantic_audio_vae_config(): def test_ming_instruction_parser_preserves_dense_and_flash_defaults(): """Ming dense and Ming flash keep distinct instruction defaults.""" - serving = object.__new__(OmniOpenAIServingSpeech) - serving.uploaded_speakers = {"uploaded": {}} + serving = SimpleNamespace(uploaded_speakers={"uploaded": {}}) + adapter = MingTTSAdapter(SpeechServingContext(server=serving)) - dense_plain = serving._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None)) + dense_plain = adapter._parse_ming_instruction(SimpleNamespace(instructions="calm", language=None, voice=None)) assert dense_plain == "calm" - dense_with_fields = serving._parse_ming_instruction( + dense_with_fields = adapter._parse_ming_instruction( SimpleNamespace(instructions="calm", language="Auto", voice="灵小甄") ) assert dense_with_fields == {"IP": "灵小甄", "风格": "calm"} - flash_fields = serving._parse_ming_instruction_fields( - SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄") + flash_fields = parse_ming_instruction_fields( + serving, SimpleNamespace(instructions="calm", language="粤语", voice="灵小甄") ) assert flash_fields == {"风格": "calm"} diff --git a/tools/pre_commit/check_tts_adapter.py b/tools/pre_commit/check_tts_adapter.py index d46928662..199c46a30 100644 --- a/tools/pre_commit/check_tts_adapter.py +++ b/tools/pre_commit/check_tts_adapter.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Ratchet on the TTS adapter migration (RFC #4327, #4855). Per-model behaviour is being moved out of ``serving_speech.py`` and into @@ -28,7 +29,7 @@ REPO_ROOT = Path(__file__).resolve().parents[2] SERVING_SPEECH = Path("vllm_omni/entrypoints/openai/serving_speech.py") # Budgets. These may only go DOWN. See the module docstring. -MAX_MODEL_TYPE_BRANCHES = 9 +MAX_MODEL_TYPE_BRANCHES = 6 _GUIDANCE = """ Move the per-model behaviour into its adapter under diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 813d916fe..50f459a58 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -30,11 +30,9 @@ from vllm.entrypoints.openai.engine.protocol import ( ErrorResponse, RequestResponseMetadata, ) -from vllm.inputs import tokens_input from vllm.logger import init_logger from vllm.multimodal.media import MediaConnector from vllm.utils import random_uuid -from vllm.utils.async_utils import make_async from vllm.v1.engine.exceptions import EngineDeadError, EngineGenerateError from vllm_omni.entrypoints.openai.audio_utils_mixin import AudioMixin, StreamingAudioResampler @@ -63,17 +61,6 @@ from vllm_omni.entrypoints.openai.tts_adapters import ( tts_entry_stage_archs, ) from vllm_omni.entrypoints.utils import coerce_param_message_types -from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( - build_fish_text_only_prompt_ids, - estimate_fish_voice_clone_prompt_len_from_normalized, - normalize_fish_voice_clone_texts, -) -from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import ( - DEFAULT_PROMPT as MING_DEFAULT_PROMPT, -) -from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import ( - create_instruction as ming_create_instruction, -) from vllm_omni.outputs import OmniRequestOutput from vllm_omni.utils.speaker_cache import get_speaker_cache @@ -93,12 +80,6 @@ _SPEECH_USAGE_INPUT_AUDIO_TOKENS_HEADER = "X-VLLM-OMNI-INPUT-AUDIO-TOKENS" # ``tts_adapters.detect_tts_model_type``. Adding a TTS model must not require an # edit to this module. # -# CosyVoice3 talker expects its reference transcript wrapped in the model -# instruction template; without the delimiter the talker re-speaks the -# reference (issue #4644). Matches the offline example/test and upstream demo. -_COSYVOICE3_PROMPT_DELIMITER = "<|endofprompt|>" -_COSYVOICE3_PROMPT_PREFIX = f"You are a helpful assistant.{_COSYVOICE3_PROMPT_DELIMITER}" - # Audex contract: zero-codec / invalid generations arrive as empty terminal # payloads and must fail the request, never serialize as a successful empty # WAV. Covers both the TTS ("audex") and TTA ("audex_tta") pipelines. @@ -109,12 +90,7 @@ _REF_AUDIO_METADATA_FETCH_ATTEMPTS = 3 _REMOTE_REF_AUDIO_SCHEMES = frozenset({"http", "https", "data"}) _REF_AUDIO_RESOLVE_CACHE_MAX_ENTRIES = 256 _REF_AUDIO_RESOLVE_CACHE_MAX_BYTES = 256 * 1024 * 1024 -_HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES = 256 -_HIGGS_V3_REF_CODE_CACHE_MAX_BYTES = 64 * 1024 * 1024 -_QWEN3_TTS_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key" _TTS_MAX_INSTRUCTIONS_LENGTH = 500 -_TTS_MAX_NEW_TOKENS_MAX = 4096 -_MING_DEFAULT_PROMPT = MING_DEFAULT_PROMPT _DEFAULT_VOICE_NAME = "default" @@ -220,64 +196,6 @@ def _validate_path_within_directory(file_path: Path, directory: Path) -> bool: return False -def _conditioning_cache_salt(request, tts_params: dict | None = None) -> str: - """Stable hash of the real Stage 0 conditioning for the prefix cache. - - The talker's vLLM prompt is placeholder token ids; the real inputs are - rebuilt from text / ref_audio / ref_text into inputs_embeds. vLLM hashes - token ids (folded with cache_salt) for prefix caching, so without a salt - every request collides and a hit could reuse KV from a semantically - different input. Tying the salt to the conditioning keeps a hit safe: - identical conditioning may share the prefix, any difference never does. - - Raw request fields alone are not enough for uploaded voices: the request - only carries the voice *name* (ref_audio/ref_text/task_type are resolved - from stored voice data into ``tts_params``). Delete + re-upload under the - same name leaves every raw field identical, so the resolved conditioning - must also be folded in. ``voice_created_at`` bumps on every (re-)upload, - which uniquely identifies the resolved reference artifact together with - the voice name; the decoded ref_audio array itself need not be hashed. - - Local ``ref_audio`` locators are the same class of gap: the raw request - hashes the path/URI string, so an on-disk rewrite is invisible to the - salt. Callers stash the content-aware key from ``_resolve_ref_audio`` as - ``ref_audio_cache_key`` (and ``ref_audio_2_cache_key`` / emotion keys - when those clips exist) so a file edit cannot reuse KV from the previous - reference. - """ - h = hashlib.sha256() - for part in ( - request.input, - request.task_type, - request.language, - request.voice, - request.ref_text, - request.ref_audio, - request.instructions, - request.x_vector_only_mode, - request.speaker_embedding, - ): - h.update(b"\x00") - if part is not None: - h.update(repr(part).encode("utf-8")) - # Fold resolved conditioning that is auto-derived for uploaded voices and - # absent from the raw request (or that a locator string cannot see). - for key in ( - "voice_created_at", - "task_type", - "speaker", - "ref_text", - "x_vector_only_mode", - "ref_audio_cache_key", - "ref_audio_2_cache_key", - ): - h.update(b"\x00") - value = tts_params.get(key) if tts_params is not None else None - if value is not None: - h.update(repr(value).encode("utf-8")) - return h.hexdigest()[:32] - - class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): _diffusion_mode: bool = False _media_connector: MediaConnector | None = None @@ -306,9 +224,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # must not satisfy a later ICL request that needs ref_code (#5049). self._ref_audio_model_artifact_ready: set[tuple[str, bool]] = set() self._request_ref_audio_artifact_keys: dict[str, tuple[str, bool]] = {} - self._higgs_audio_v3_ref_code_cache: OrderedDict[str, tuple[torch.Tensor, int]] = OrderedDict() - self._higgs_audio_v3_ref_code_cache_bytes = 0 - self._higgs_audio_v3_ref_code_inflight: dict[str, asyncio.Task[torch.Tensor]] = {} self._speaker_cache = get_speaker_cache() self._last_upload_ts = 0 self._upload_lock = asyncio.Lock() @@ -438,16 +353,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): # Find and cache the TTS stage (if any) during initialization self._tts_stage = self._find_tts_stage() self._is_tts = self._tts_stage is not None - self._fish_speech_tokenizer = None - self._covo_audio_tokenizer = None - # Cached per process: the CosyVoice3 Qwen tokenizer + resolved model - # path used for dynamic-token sizing. Without this, every request - # re-ran snapshot_download + reloaded the tokenizer (~100 ms on the - # TTFP critical path) in the CosyVoice3 adapter's sampling override. - self._cosyvoice3_tokenizer = None # Determine TTS model type or None self._tts_model_type = self._detect_tts_model_type() + + # Shared executor for blocking adapter preprocessing. It must exist + # before adapter construction so adapters can create their make_async + # wrappers during their own lifecycle initialization. + self._tts_executor = ThreadPoolExecutor(max_workers=1) # Resolve the per-model serving adapter (RFC #4327), keyed on the # detected model-type. Every dedicated TTS model has an adapter; the # adapter owns request validation, prompt/param building, capability @@ -467,37 +380,14 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): available_speakers = self._get_available_speakers() logger.info("Loaded %d supported speakers: %s", len(available_speakers), sorted(available_speakers)) - # Sub-variant inside the full MOSS-TTS family. We collapse all five - # variants onto the same _tts_model_type="moss_tts" because they share - # the same stage layout, but request validation + param building - # differ per HF repo (voice-clone vs dialogue vs ambient-sound vs - # instruction vs streaming voice-clone). - self._moss_variant = self._detect_moss_variant() if self._tts_model_type == "moss_tts" else None - - # GLM-TTS lazy-cached resources (populated on first GLM-TTS request) - self._glm_tts_text_tokenizer: object | None = None - self._glm_tts_text_frontend: object | None = None - # Cache TTS configuration values (computed once, reused per request) self._max_instructions_length = self._compute_max_instructions_length() self._tts_tokenizer = None - self._voxcpm2_tokenizer = None - self._audex_tokenizer = None - self._audex_tta_rvq = None - self._voxcpm2_split_map: dict[int, list[int]] = {} # Batch configuration self._batch_max_items: int = getattr(self.engine_client, "tts_batch_max_items", 32) - # Shared thread pool executor for blocking TTS preprocessing - # operations. max_workers=1 serializes tokenizer access to avoid - # Rust RefCell "Already borrowed" errors from concurrent use. - self._tts_executor = ThreadPoolExecutor(max_workers=1) - self._build_voxtral_prompt_async = make_async(self._build_voxtral_prompt, executor=self._tts_executor) - self._build_fish_speech_prompt_async = make_async(self._build_fish_speech_prompt, executor=self._tts_executor) - self._estimate_prompt_len_async = make_async(self._estimate_prompt_len, executor=self._tts_executor) - def _get_tts_adapter(self): """Return the per-model serving adapter for the current ``_tts_model_type``. @@ -622,73 +512,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): """Get all voice names accepted by the API, including the placeholder default.""" return self._get_available_speakers() | {_DEFAULT_VOICE_NAME} - def _estimate_ref_code_len(self, ref_audio: object) -> int | None: - """Estimate ref_code length from ref_audio waveform without running the codec. - - The codec produces one frame per (output_sample_rate / encode_downsample_rate) - audio samples, so ref_code_len = ceil(duration_seconds * codec_frame_rate). - """ - codec_frame_rate = self._adapter.capabilities.codec_frame_rate - if codec_frame_rate is None: - return None - try: - # ref_audio comes from tts_params as [[wav_array, sr]] or similar nested structure - item = ref_audio - while isinstance(item, list) and item: - if len(item) == 2 and isinstance(item[1], (int, float)): - break - item = item[0] - if isinstance(item, list) and len(item) == 2: - wav, sr = item - elif isinstance(item, tuple) and len(item) == 2: - wav, sr = item - else: - return None - sr = int(sr) - if hasattr(wav, "__len__"): - n_samples = len(wav) - elif hasattr(wav, "shape"): - n_samples = wav.shape[-1] if wav.ndim > 1 else wav.shape[0] - else: - return None - if sr <= 0 or n_samples <= 0: - return None - duration = n_samples / sr - return math.ceil(duration * codec_frame_rate) - except Exception: - return None - - def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int: - """Estimate prompt length so the placeholder matches model-side embeddings.""" - try: - from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import ( - Qwen3TTSPromptEmbedsBuilder, - ) - - if self._tts_tokenizer is None: - from transformers import AutoTokenizer - - model_name = self.engine_client.model_config.model - self._tts_tokenizer = AutoTokenizer.from_pretrained( - model_name, - trust_remote_code=True, - padding_side="left", - ) - hf_config = self.engine_client.model_config.hf_config - talker_config = hf_config.talker_config - task_type = (tts_params.get("task_type") or ["CustomVoice"])[0] - return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information( - additional_information=tts_params, - task_type=task_type, - tokenize_prompt=lambda t: self._tts_tokenizer(t, padding=False)["input_ids"], - codec_language_id=getattr(talker_config, "codec_language_id", None), - spk_is_dialect=getattr(talker_config, "spk_is_dialect", None), - estimate_ref_code_len=self._estimate_ref_code_len, - ) - except Exception as e: - logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e) - return 2048 - def _get_usage_text_tokenizer(self): """Return a text tokenizer for counting input-text usage tokens. @@ -784,41 +607,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): _SPEECH_USAGE_INPUT_AUDIO_TOKENS_HEADER: str(usage.input_token_details.audio_tokens), } - def _estimate_fish_ref_code_len(self, ref_audio: object) -> int | None: - """Estimate Fish Speech semantic token length from raw reference audio.""" - from vllm_omni.model_executor.models.fish_speech.dac_utils import ( - DAC_HOP_LENGTH, - DAC_SAMPLE_RATE, - ) - - if not isinstance(ref_audio, (list, tuple)) or len(ref_audio) != 2: - return None - wav, sr = ref_audio - sr = int(sr) - n_samples = len(wav) - if sr <= 0 or n_samples <= 0: - return None - resampled_len = max(1, math.ceil(n_samples * DAC_SAMPLE_RATE / sr)) - return max(1, math.ceil(resampled_len / DAC_HOP_LENGTH)) - - def _estimate_fish_prompt_len(self, text: str, ref_text: str, ref_audio: object) -> int: - """Estimate Fish Speech clone prompt length without encoding reference audio.""" - try: - from transformers import AutoTokenizer - - if self._fish_speech_tokenizer is None: - model_name = self.engine_client.model_config.model - self._fish_speech_tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) - - tokenizer = self._fish_speech_tokenizer - semantic_len = self._estimate_fish_ref_code_len(ref_audio) - if semantic_len is None: - raise ValueError("Failed to estimate Fish Speech semantic token length") - return estimate_fish_voice_clone_prompt_len_from_normalized(tokenizer, text, ref_text, semantic_len) - except Exception as e: - logger.warning("Failed to estimate Fish Speech prompt length, using fallback 2048: %s", e) - return 2048 - def _voice_created_at(self, voice_lower: str) -> int: """Return the upload timestamp of an uploaded voice, or 0 for built-ins. @@ -828,41 +616,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): info = self.uploaded_speakers.get(voice_lower) return int(info.get("created_at", 0)) if info else 0 - async def _build_voxcpm2_prompt( - self, - request: OpenAICreateSpeechRequest, - *, - uploaded_ref: tuple[np.ndarray, int] | None = None, - ) -> dict[str, Any]: - """Build prefill prompt for VoxCPM2 TTS (`prompt_token_ids` padded to full prefill length). - - ``uploaded_ref`` supplies the audio for uploaded voices (no explicit - ``ref_audio`` in the request) so prefill length includes it. - """ - from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import build_voxcpm2_prompt - - self._voxcpm2_encode("") # lazy-init tokenizer + split_map - ref_audio = None - ref_sr = None - voice_profile = None - if request.ref_audio is not None: - ref_audio, ref_sr, _ = await self._resolve_ref_audio(request.ref_audio) - elif uploaded_ref is not None: - wav_np, ref_sr = uploaded_ref - ref_audio = wav_np.tolist() - elif request.voice is not None: - voice_profile = self._adapter.capabilities.precomputed_speakers.get(request.voice.lower()) - return build_voxcpm2_prompt( - hf_config=self.engine_client.model_config.hf_config, - tokenizer=self._voxcpm2_tokenizer, - split_map=self._voxcpm2_split_map, - text=request.input, - ref_audio=ref_audio, - ref_sr=ref_sr, - ref_text=request.ref_text, - voice_profile=voice_profile, - ) - def _load_uploaded_audio(self, voice_name: str) -> tuple[np.ndarray, int] | None: """Load decoded audio samples + sample rate from an uploaded voice's safetensors.""" voice_name_lower = voice_name.lower() @@ -1330,25 +1083,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return "sample_rate is not supported by the current TTS model" return None - def _voxcpm2_encode(self, text: str) -> list[int]: - """Tokenize text for VoxCPM2, splitting multichar Chinese tokens.""" - from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( - build_cjk_split_map, - split_multichar_chinese, - ) - - if self._voxcpm2_tokenizer is None: - from transformers import AutoTokenizer - - model_name = self.engine_client.model_config.model - tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) - self._voxcpm2_split_map = build_cjk_split_map(tokenizer) - self._voxcpm2_tokenizer = tokenizer - logger.info("VoxCPM2 serving: built multichar split map (%d entries)", len(self._voxcpm2_split_map)) - - ids = self._voxcpm2_tokenizer.encode(text, add_special_tokens=True) - return split_multichar_chinese(ids, self._voxcpm2_split_map) - def _validate_ref_audio_format(self, ref_audio: str) -> str | None: """Validate ref_audio is a supported URI format. Returns error or None.""" if not isinstance(ref_audio, str): @@ -1358,445 +1092,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): return "ref_audio must be a URL (http/https), base64 data URL (data:...), or file URI (file://...)" return None - def _detect_moss_variant(self) -> str: - """Sub-classify a ``moss_tts``-stage server into the actual MOSS-TTS - variant family (tts, ttsd, sound_effect, voice_generator, realtime). - - Detection key is the HF repo path / model_name; matches - ``_try_resolve_omni_model_type`` in entrypoints/utils.py so users get - consistent behaviour no matter how they launched the server (--model - OpenMOSS-Team/MOSS-TTSD-v1.0 vs --deploy-config moss_ttsd.yaml). - """ - try: - name = (self.engine_client.model_config.model or "").lower().replace("-", "").replace("_", "") - except Exception: - name = "" - if "realtime" in name: - return "realtime" - if "local" in name: - return "local" - if "ttsd" in name: - return "ttsd" - if "soundeffect" in name: - return "sound_effect" - if "voicegenerator" in name: - return "voice_generator" - return "tts" - - def _get_moss_processor(self): - """Lazily load the upstream MOSS-TTS processor once per server. - - Cached on ``self._moss_processor_cache``. The processor owns its own - audio_tokenizer (~1.6 B params); we keep it on CPU so it doesn't - compete with the talker (~8 GiB) and codec (~7 GiB) for our 96 GiB - GPU — per-request ref-audio encoding is fast enough on CPU. - """ - cached = getattr(self, "_moss_processor_cache", None) - if cached is not None: - return cached - from transformers import AutoProcessor - - model_id = self.engine_client.model_config.model - proc = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) - if hasattr(proc, "audio_tokenizer"): - proc.audio_tokenizer = proc.audio_tokenizer.to("cpu").eval() - self._moss_processor_cache = proc - return proc - - def _get_moss_ref_encoder(self): - """Lazily build the per-server MOSS reference-audio encoder once.""" - cached = getattr(self, "_moss_ref_encoder", None) - if cached is not None: - return cached - from vllm_omni.model_executor.models.moss_tts.reference_encoder import build_reference_encoder - - # The variant's encode geometry (n_vq, working sample rate) is derived - # inside the model package; this layer only supplies the processor and - # the process-wide speaker cache. - encoder = build_reference_encoder( - self._get_moss_processor(), - variant=self._moss_variant, - speaker_cache=self._speaker_cache, - ) - self._moss_ref_encoder = encoder - return encoder - - async def _build_moss_tts_params( - self, - request: OpenAICreateSpeechRequest, - *, - has_inline_ref_audio: bool = False, - ) -> dict[str, Any]: - """Build the talker prompt + ``additional_information`` payload for any - MOSS-TTS-family request (nano + 5 full variants). - - For the legacy ``moss_tts_nano`` model_type, keeps the original nano - contract (``{text, mode=voice_clone, prompt_audio_array}``); the - caller still uses a ``[1]`` placeholder prompt. - - For the full MOSS-TTS family (``MossTTSDelay*`` / ``MossTTSRealtime*``) - we **call the upstream processor server-side** to produce the unified - ``(text_ids, audio_codes)`` shape the talker actually consumes — same - flow as ``examples/.../moss_tts/end2end.py:_build_unified_codes``. - Returns ``{prompt_token_ids: list[int], codes.ref: torch.LongTensor, - max_new_frames, ...}``. The caller treats ``prompt_token_ids`` as the - prompt and forwards the rest as ``additional_information``. - """ - import torch # local to avoid pulling torch at module import time - - v = self._moss_variant - - # ---- Legacy nano path (unchanged) ---- - if v is None: # moss_tts_nano - params: dict[str, Any] = { - "text": [request.input or ""], - "mode": ["voice_clone"], - } - if request.max_new_tokens is not None: - params["max_new_frames"] = [request.max_new_tokens] - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) - params["prompt_audio_array"] = [[wav_list, sr]] - params["ref_audio_cache_key"] = cache_key - return params - - # ---- MOSS-TTS-Realtime: keep the old prompt_audio_array path ---- - # ``AutoProcessor.from_pretrained`` doesn't auto-discover - # ``MossTTSRealtimeProcessor`` (no ``processor_config.json`` in the - # snapshot), and Realtime's prompt format diverges from MossTTSDelay - # (16-channel grid, separate per-step text feed). The - # ``prompt_audio_array`` shape lines up well enough with what the - # talker reads for short prompts; full Realtime support needs a - # separate processor.from_module path which we don't wire here. - if v == "realtime": - params: dict[str, Any] = { - "text": [request.input or ""], - "mode": ["voice_clone"], - } - if request.max_new_tokens is not None: - params["max_new_frames"] = [request.max_new_tokens] - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) - params["prompt_audio_array"] = [[wav_list, sr]] - params["ref_audio_cache_key"] = cache_key - return params - - # ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator) - # and MOSS-TTS-Local-Transformer-v1.5: call the upstream processor - # server-side to produce unified codes. Local-v1.5 ships its own - # AutoProcessor (processor_config.json + processing_moss_tts.py) and - # reuses this exact build_user_message/encode_audios_from_wav path in - # the offline example (examples/.../moss_tts/end2end.py: - # _build_unified_codes) -- it is NOT in the same boat as Realtime - # (no processor_config.json there), so it must not fall back to the - # prompt_audio_array path above (which the talker's preprocess() - # never reads -- info_dict["codes"]["ref"] is the only thing it - # consumes, so skipping this path silently drops all voice-clone - # conditioning and produces unconditioned/garbage audio online). ---- - proc = self._get_moss_processor() - - user_kwargs: dict[str, Any] = {"text": request.input or ""} - resolve_keys: dict[int, str] = {} - if v in ("tts", "local", "ttsd"): - user_kwargs["reference"], resolve_keys = await self._encode_moss_references( - request, - has_inline_ref_audio=has_inline_ref_audio, - two_speaker=(v == "ttsd"), - ) - elif v == "sound_effect": - user_kwargs["text"] = request.input or "" # may be empty - user_kwargs["ambient_sound"] = request.ambient_sound or "" - if request.duration_seconds is not None: - user_kwargs["tokens"] = max(1, int(float(request.duration_seconds) * 12.5)) - elif request.max_new_tokens is not None: - user_kwargs["tokens"] = int(request.max_new_tokens) - elif v == "voice_generator": - user_kwargs["instruction"] = request.instructions or "" - - # Optional language tag for the spoken-text variants. MOSS-TTS-v1.5's - # headline improvement is multilingual synthesis when the language is - # given (build_user_message(..., language=...)); 1.0 ignores it - # gracefully. Sound-effect output is non-verbal, so skip it there. - if v in ("tts", "local", "ttsd", "voice_generator") and getattr(request, "language", None): - user_kwargs["language"] = request.language - - # Build the unified-codes prompt: (L, 1+n_vq) where col 0 is text/special - # tokens and cols 1..n_vq are the delay-pattern audio code grid (mostly - # audio_pad_code outside the reference block). - user_msg = proc.build_user_message(**user_kwargs) - batch = proc(conversations=[[user_msg]], mode="generation") - unified = batch["input_ids"][0] # torch.LongTensor (L, 1+n_vq) - text_ids: list[int] = unified[:, 0].tolist() - audio_codes: torch.Tensor = unified[:, 1:].contiguous().to(torch.int64) - - params: dict[str, Any] = { - "prompt_token_ids": text_ids, - "codes": {"ref": audio_codes}, - } - if request.max_new_tokens is not None: - params["max_new_frames"] = [request.max_new_tokens] - if 0 in resolve_keys: - params["ref_audio_cache_key"] = resolve_keys[0] - if 1 in resolve_keys: - params["ref_audio_2_cache_key"] = resolve_keys[1] - return params - - async def _encode_moss_references( - self, - request: OpenAICreateSpeechRequest, - *, - has_inline_ref_audio: bool, - two_speaker: bool, - ) -> tuple[list, dict[int, str]]: - """Encode the request's reference clip(s) into MOSS RVQ code tensors. - - Reference encoding + speaker caching lives in the model package - (moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 / - Qwen3-TTS which keep reference handling with the model rather than in - this shared serving file. This method only resolves the serving-side - inputs the model helper needs: the generic audio resolver/artifact-key - lookups and whether the request may use the named-voice cache. - - Returns ``(codes_per_speaker, resolve_keys)`` where ``resolve_keys`` - maps the reference slot (0 = ref_audio, 1 = ref_audio_2) to its - content-aware resolve key, for salting the KV prefix cache. - """ - from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_request_references - - # Named-voice caching is only valid for uploaded speakers without an - # inline ref_audio: ``request.voice`` plus a file/URL would otherwise - # key on (name, created_at=0) and skip the content-aware resolve. - raw_voice = getattr(request, "voice", None) - raw_voice = raw_voice.strip() if isinstance(raw_voice, str) else "" - voice_lower = raw_voice.lower() - use_named_voice = bool(voice_lower) and voice_lower in self.uploaded_speakers and not has_inline_ref_audio - voice = voice_lower if use_named_voice else "" - voice_created = self._voice_created_at(voice) if voice else 0 - - return await encode_request_references( - self._get_moss_ref_encoder(), - request.ref_audio, - request.ref_audio_2 if two_speaker else None, - resolve_ref_audio=self._resolve_ref_audio, - get_artifact_key=self._get_resolved_ref_audio_artifact_key, - voice_name=voice or None, - voice_created_at=voice_created, - ) - - async def _build_higgs_audio_v2_params(self, request: OpenAICreateSpeechRequest): - """Build prompt_token_ids for higgs_audio_v2 via the upstream processor. - - Plain-text path: runs ``build_plain_text_prompt`` and returns the - token-only prompt. Voice-clone path (``ref_audio`` + ``ref_text``): - resolves the reference clip via ``_resolve_ref_audio``, runs - ``build_voice_clone_prompt`` (which encodes the clip through HF's - ``HiggsAudioV2TokenizerModel`` loaded from the k2-fsa/OmniVoice - ``audio_tokenizer/`` subdirectory), and attaches the encoded - ``audio_input_ids`` + ``audio_input_ids_mask`` tensors via - ``additional_information`` so the talker substitutes them at the - prompt-side audio placeholders. - """ - from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_v2_tokenizer import ( - build_plain_text_prompt, - build_voice_clone_prompt, - input_ids_to_python_list, - ) - - processor = await self._resolve_higgs_audio_v2_processor() - - if request.ref_audio is None: - inputs = await asyncio.to_thread(build_plain_text_prompt, processor, request.input) - prompt_token_ids = input_ids_to_python_list(inputs) - return tokens_input(prompt_token_ids=prompt_token_ids) - - wav_list, sr, _ = await self._resolve_ref_audio(request.ref_audio) - wav = np.asarray(wav_list, dtype=np.float32) - out = await asyncio.to_thread( - build_voice_clone_prompt, - processor, - request.input, - wav, - int(sr), - request.ref_text or "", - ) - prompt = tokens_input(prompt_token_ids=out["prompt_token_ids"]) - # Pass tensors at the top level of additional_information (NOT list- - # wrapped). ``vllm_omni.data_entry_keys.serialize_payload`` routes - # bare ``torch.Tensor`` values through ``_serialize_tensor``; a list - # containing tensors would fall into the ``list_data`` field which - # msgspec cannot serialize and the tensors would be dropped over the - # process boundary (silent voice-clone failure). - prompt["additional_information"] = { - "audio_input_ids": out["audio_input_ids"], - "audio_input_ids_mask": out["audio_input_ids_mask"], - } - return prompt - - async def _resolve_higgs_audio_v2_processor(self): - """Lazy-load the AutoProcessor for higgs_audio_v2 (once per serving instance).""" - cached = getattr(self, "_higgs_audio_v2_processor", None) - if cached is not None: - return cached - - from transformers import AutoProcessor - - model_path = None - for stage in self.engine_client.stage_configs: - model_path = getattr(getattr(stage, "engine_args", None), "model", None) - if model_path: - break - if model_path is None: - # Fallback: the orchestrator stores the served model id on the engine - # itself (set by AsyncOmniEngine.__init__). Stage-level engine_args - # may not surface ``model`` when the deploy yaml doesn't set it per - # stage (the CLI-passed model id is the single source of truth). - model_path = getattr(self.engine_client, "model", None) - if model_path is None: - raise RuntimeError("higgs_audio_v2 serving could not resolve the model path from the engine stage configs") - processor = AutoProcessor.from_pretrained(model_path) - self._higgs_audio_v2_processor = processor - return processor - - # ---- higgs-audio v3 ---- - - async def _build_higgs_audio_v3_params(self, request: OpenAICreateSpeechRequest): - """Build prompt_token_ids for higgs_audio_v3. - - Plain-text path: builds ``[tts, text, tokens, audio]``. - Voice-clone path: encodes reference audio, applies delay pattern, and - records reference-audio prompt positions while submitting valid token IDs. - """ - adapter = await self._resolve_higgs_audio_v3_adapter() - - if request.ref_audio is None: - prompt_ids = adapter.build_prompt(request.input) - return tokens_input(prompt_token_ids=prompt_ids) - - # Voice clone - from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( - apply_delay_pattern, - encode_reference_audio, - ) - - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) - artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key) - wav = np.asarray(wav_list, dtype=np.float32) - ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes( - artifact_key, - wav, - int(sr), - encode_reference_audio, - apply_delay_pattern, - ) - del cache_hit, inflight_wait - - prompt_ids = adapter.build_prompt( - request.input, - num_ref_tokens=int(ref_codes_delayed.shape[0]), - reference_text=request.ref_text or None, - ) - prompt_ids, audio_placeholder_positions = adapter.prepare_prompt_for_engine(prompt_ids) - prompt = tokens_input(prompt_token_ids=prompt_ids) - import torch - - prompt["additional_information"] = { - "audio_input_ids": ref_codes_delayed.to(torch.long), - "audio_input_ids_mask": torch.ones(ref_codes_delayed.shape[0], dtype=torch.bool), - "audio_placeholder_positions": audio_placeholder_positions, - "ref_audio_cache_key": cache_key, - } - # Placeholder prompt ids do not change when a same-path file is - # rewritten at the same duration; fold the content-aware resolve key - # into cache_salt so prefix caching cannot reuse the previous KV. - prompt["cache_salt"] = _conditioning_cache_salt(request, prompt["additional_information"]) - return prompt - - async def _resolve_higgs_audio_v3_ref_codes( - self, - artifact_key: str | None, - wav: np.ndarray, - sr: int, - encode_reference_audio, - apply_delay_pattern, - ) -> tuple[torch.Tensor, bool, bool]: - ref_codes_delayed = self._get_higgs_audio_v3_ref_codes(artifact_key) - if ref_codes_delayed is not None: - return ref_codes_delayed, True, False - if not artifact_key: - ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr) - return apply_delay_pattern(ref_codes_raw), False, False - - task = self._higgs_audio_v3_ref_code_inflight.get(artifact_key) - if task is not None: - return (await task).clone(), False, True - - async def _encode_and_cache() -> torch.Tensor: - ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr) - delayed = apply_delay_pattern(ref_codes_raw) - self._put_higgs_audio_v3_ref_codes(artifact_key, delayed) - cached = self._get_higgs_audio_v3_ref_codes(artifact_key) - return cached if cached is not None else delayed.detach().to("cpu", dtype=torch.long).contiguous() - - task = asyncio.create_task(_encode_and_cache()) - self._higgs_audio_v3_ref_code_inflight[artifact_key] = task - try: - return (await task).clone(), False, False - finally: - if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is task: - self._higgs_audio_v3_ref_code_inflight.pop(artifact_key, None) - - def _get_higgs_audio_v3_ref_codes(self, artifact_key: str | None) -> torch.Tensor | None: - if not artifact_key: - return None - cached = self._higgs_audio_v3_ref_code_cache.get(artifact_key) - if cached is None: - return None - self._higgs_audio_v3_ref_code_cache.move_to_end(artifact_key) - return cached[0].clone() - - def _put_higgs_audio_v3_ref_codes(self, artifact_key: str, codes: torch.Tensor) -> None: - if _HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES <= 0 or _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES <= 0 or not artifact_key: - return - cached_codes = codes.detach().to("cpu", dtype=torch.long).contiguous() - size = int(cached_codes.numel() * cached_codes.element_size()) - if size > _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES: - return - previous = self._higgs_audio_v3_ref_code_cache.pop(artifact_key, None) - if previous is not None: - self._higgs_audio_v3_ref_code_cache_bytes -= previous[1] - self._higgs_audio_v3_ref_code_cache[artifact_key] = (cached_codes, size) - self._higgs_audio_v3_ref_code_cache_bytes += size - while len(self._higgs_audio_v3_ref_code_cache) > _HIGGS_V3_REF_CODE_CACHE_MAX_ENTRIES: - _, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False) - self._higgs_audio_v3_ref_code_cache_bytes -= old_size - while self._higgs_audio_v3_ref_code_cache_bytes > _HIGGS_V3_REF_CODE_CACHE_MAX_BYTES: - _, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False) - self._higgs_audio_v3_ref_code_cache_bytes -= old_size - - async def _resolve_higgs_audio_v3_adapter(self): - """Lazy-load the tokenizer adapter for higgs_audio_v3.""" - cached = getattr(self, "_higgs_audio_v3_adapter", None) - if cached is not None: - return cached - - from transformers import AutoTokenizer - - from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( - HiggsAudioV3TokenizerAdapter, - ) - - model_path = None - for stage in self.engine_client.stage_configs: - model_path = getattr(getattr(stage, "engine_args", None), "model", None) - if model_path: - break - if model_path is None: - model_path = getattr(self.engine_client, "model", None) - if model_path is None: - raise RuntimeError("higgs_audio_v3 serving could not resolve model path") - tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) - adapter = HiggsAudioV3TokenizerAdapter(tokenizer) - self._higgs_audio_v3_adapter = adapter - return adapter - @staticmethod def _local_ref_audio_stat_path(ref_audio_str: str) -> str | None: """Filesystem path to stat for a local locator, else ``None``. @@ -2043,14 +1338,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): def _tts_x_vector_only(tts_params: dict[str, Any]) -> bool: return bool((tts_params.get("x_vector_only_mode") or [False])[0]) - def _qwen3_tts_can_use_ref_audio_artifact_only(self, tts_params: dict[str, Any], artifact_key: str | None) -> bool: - if self._tts_model_type != "qwen3_tts": - return False - x_vector_only = self._tts_x_vector_only(tts_params) - if not artifact_key or (artifact_key, x_vector_only) not in self._ref_audio_model_artifact_ready: - return False - return (tts_params.get("task_type") or ["CustomVoice"])[0] == "Base" - def _track_ref_audio_artifact_warmup( self, request_id: str, artifact_key: str | None, x_vector_only: bool = False ) -> None: @@ -2075,145 +1362,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): resolved.append((wav_list, sr)) return resolved - # ---- Ming TTS helpers ---- - - def _coerce_ming_prompt_waveform(self, wav_samples, sample_rate): - from torchaudio.functional import resample as resample_audio - - from vllm_omni.model_executor.models.ming_tts.config_ming_tts import SAMPLE_RATE - - waveform = torch.as_tensor(wav_samples, dtype=torch.float32).reshape(1, -1) - if int(sample_rate) != SAMPLE_RATE: - waveform = resample_audio(waveform, int(sample_rate), SAMPLE_RATE) - return waveform - - def _build_ming_reference_waveforms( - self, - ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None, - ) -> tuple[list[torch.Tensor], list[int]]: - if ref_audio_data is None: - return [], [] - items = ref_audio_data if isinstance(ref_audio_data, list) else [ref_audio_data] - waveforms = [torch.as_tensor(samples, dtype=torch.float32).reshape(1, -1) for samples, _ in items] - sample_rates = [int(sample_rate) for _, sample_rate in items] - if any(sample_rate <= 0 for sample_rate in sample_rates): - raise ValueError(f"Ming reference audio sample rates must be positive, got {sample_rates}") - return waveforms, sample_rates - - def _parse_ming_instruction_fields( - self, - request, - *, - include_voice=False, - plain_text_passthrough=False, - ): - instruction_text = request.instructions.strip() if isinstance(request.instructions, str) else None - instruction_dict: dict[str, Any] = {} - - voice_lower = request.voice.lower() if isinstance(request.voice, str) else None - if include_voice and request.voice and not (voice_lower and voice_lower in self.uploaded_speakers): - instruction_dict["IP"] = request.voice - - if instruction_text: - try: - parsed = json.loads(instruction_text) - except json.JSONDecodeError: - parsed = None - if isinstance(parsed, dict): - instruction_dict.update(parsed) - elif instruction_dict or not plain_text_passthrough: - instruction_dict["风格"] = instruction_text - else: - return instruction_text - - return instruction_dict or None - - def _parse_ming_instruction(self, request: OpenAICreateSpeechRequest) -> Any: - """Build a Ming instruction payload from OpenAI speech fields.""" - return self._parse_ming_instruction_fields( - request, - include_voice=True, - plain_text_passthrough=True, - ) - - def _build_ming_dense_prompt( - self, - request: OpenAICreateSpeechRequest, - *, - ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None = None, - voice_name: str | None = None, - voice_created_at: int = 0, - ) -> dict[str, Any]: - """Build a Ming dense prompt directly from the OpenAI speech request.""" - from transformers import AutoTokenizer - - from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( - KEY_MAX_DECODE_STEPS, - KEY_SPEAKER_SAMPLE_RATES, - KEY_SPEAKER_WAVEFORM, - KEY_SPEAKER_WAVEFORM_LENGTHS, - ) - from vllm_omni.model_executor.models.ming_tts.prompt_assembly import build_ming_dense_prompt - - if self._tts_tokenizer is None: - model_name = self.engine_client.model_config.model - trust_remote_code = bool(getattr(self.engine_client.model_config, "trust_remote_code", False)) - self._tts_tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=trust_remote_code) - - ref_text = request.ref_text - reference_waveforms, reference_sample_rates = self._build_ming_reference_waveforms(ref_audio_data) - reference_waveform = torch.cat(reference_waveforms, dim=-1) if reference_waveforms else None - prompt_waveforms = ( - [ - self._coerce_ming_prompt_waveform(waveform, sample_rate) - for waveform, sample_rate in zip(reference_waveforms, reference_sample_rates, strict=True) - ] - if ref_text is not None - else [] - ) - prompt_waveform = torch.cat(prompt_waveforms, dim=-1) if prompt_waveforms else None - speaker_embedding = request.speaker_embedding - pending_speaker_extraction = speaker_embedding is None and reference_waveform is not None - use_zero_spk_emb = not pending_speaker_extraction and prompt_waveform is None and speaker_embedding is None - - runtime_controls = {} - if request.max_new_tokens is not None: - runtime_controls[KEY_MAX_DECODE_STEPS] = request.max_new_tokens - - prompt_dict = build_ming_dense_prompt( - self._tts_tokenizer, - # bgm / music-prompt mode not supported online; - # requires prompt_mode API extension (deferred). - prompt=_MING_DEFAULT_PROMPT, - text=request.input, - runtime_controls=runtime_controls or None, - instruction=self._parse_ming_instruction(request), - prompt_text=ref_text, - prompt_waveform=prompt_waveform, - speaker_embedding=speaker_embedding, - speaker_count=len(reference_waveforms) if pending_speaker_extraction else None, - use_zero_spk_emb=use_zero_spk_emb, - ) - additional_information = prompt_dict["additional_information"] - if pending_speaker_extraction: - additional_information[KEY_SPEAKER_WAVEFORM] = reference_waveform - additional_information[KEY_SPEAKER_WAVEFORM_LENGTHS] = torch.tensor( - [int(waveform.shape[-1]) for waveform in reference_waveforms], - dtype=torch.int32, - ) - additional_information[KEY_SPEAKER_SAMPLE_RATES] = torch.tensor( - reference_sample_rates, - dtype=torch.int32, - ) - if voice_name: - additional_information["voice_name"] = voice_name - additional_information["voice_created_at"] = int(voice_created_at) - prompt = tokens_input(prompt_token_ids=prompt_dict["prompt_token_ids"]) - prompt["prompt"] = prompt_dict["prompt"] - prompt["text"] = prompt_dict["text"] - prompt["additional_information"] = prompt_dict["additional_information"] - return prompt - async def _generate_audio_chunks( self, generator, @@ -2542,534 +1690,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): key = "audio" if "audio" in mm else ("model_outputs" if "model_outputs" in mm else None) return mm, key - def _build_tts_params(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: - """Build TTS parameters from request. - - Processes each parameter if present, skips if not. - Values are wrapped in lists as required by the model. - """ - params: dict[str, Any] = {} - - # Text content (always required) - params["text"] = [request.input] - - # Task type - if request.task_type is not None: - params["task_type"] = [request.task_type] - else: - params["task_type"] = ["CustomVoice"] - - # Language - if request.language is not None: - params["language"] = [request.language] - else: - params["language"] = ["Auto"] - - # Speaker (voice) - if request.voice is not None: - voice_lower = request.voice.lower() - precomputed_speakers = self._adapter.capabilities.precomputed_speakers if self._adapter is not None else {} - params["speaker"] = [request.voice] - params["voice_created_at"] = [self._voice_created_at(voice_lower)] - - # Uploaded voices use task_type="Base" (CustomVoice requires built-in spk_id). - # If ref_text was provided at upload time, use in-context cloning; otherwise x_vector only. - if voice_lower in self.uploaded_speakers and request.ref_audio is None: - speaker_info = self.uploaded_speakers[voice_lower] - - # Check if this voice was uploaded with a pre-computed embedding. - # Populate request.speaker_embedding so the existing code path - # (below) handles voice_clone_prompt and x_vector_only_mode. - embedding = self._get_uploaded_speaker_embedding(request.voice) - if embedding is not None: - request.speaker_embedding = embedding - params["speaker"] = [voice_lower] - params["task_type"] = ["Base"] - logger.info("Auto-set speaker_embedding for uploaded voice: %s", request.voice) - else: - audio_data = self._get_uploaded_audio_data(request.voice) - if not audio_data: - raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted") - stored_ref_text = speaker_info.get("ref_text") - params["speaker"] = [voice_lower] - params["ref_audio"] = [audio_data] - params["task_type"] = ["Base"] - if stored_ref_text: - params["ref_text"] = [stored_ref_text] - params["x_vector_only_mode"] = [False] - else: - params["x_vector_only_mode"] = [True] - logger.info( - "Auto-set ref_audio for uploaded voice: %s (icl=%s)", request.voice, bool(stored_ref_text) - ) - elif voice_lower in precomputed_speakers and request.ref_audio is None: - profile = precomputed_speakers[voice_lower] - mode = str(profile.get("mode") or "xvec").lower() - params["speaker"] = [voice_lower] - params["task_type"] = ["Base"] - params["x_vector_only_mode"] = [mode != "icl"] - ref_text = request.ref_text or profile.get("ref_text") - if isinstance(ref_text, str) and ref_text.strip(): - params["ref_text"] = [ref_text] - ref_code_length = profile.get("ref_code_length") - if mode == "icl" and ref_code_length: - params["ref_code_length"] = [int(ref_code_length)] - logger.info("Using precomputed Qwen3-TTS custom voice profile: %s (mode=%s)", voice_lower, mode) - - elif params["task_type"][0] == "CustomVoice": - params["speaker"] = ["Vivian"] # Default for CustomVoice - - # Instructions for style/emotion control - if request.instructions is not None: - params["instruct"] = [request.instructions] - else: - params["instruct"] = [""] - - # Voice clone: ref_audio resolved in create_speech(), not here. - if request.ref_text is not None: - params["ref_text"] = [request.ref_text] - if request.speaker_embedding is not None: - # Store as plain float list (not tensor) so it survives msgspec - # serialization through the EngineCore IPC boundary. The talker's - # _build_prompt_embeds converts it back to a tensor on the GPU. - params["voice_clone_prompt"] = [ - { - "ref_spk_embedding": list(request.speaker_embedding), - } - ] - # speaker_embedding implies x_vector_only_mode - params["x_vector_only_mode"] = [True] - elif request.x_vector_only_mode is not None: - params["x_vector_only_mode"] = [request.x_vector_only_mode] - - # Generation parameters - if request.max_new_tokens is not None: - params["max_new_tokens"] = [request.max_new_tokens] - else: - params["max_new_tokens"] = [2048] - - if request.initial_codec_chunk_frames is not None: - params["initial_codec_chunk_frames"] = [request.initial_codec_chunk_frames] - - if request.non_streaming_mode is not None: - params["non_streaming_mode"] = [request.non_streaming_mode] - # Preserve the legacy VoiceDesign fallback when the request omits an - # explicit override. CustomVoice and Base rely on model defaults - # (True and False respectively). - elif params["task_type"][0] == "VoiceDesign": - params["non_streaming_mode"] = [True] - - return params - - # ---- Voxtral TTS helpers ---- - - def _build_voxtral_prompt(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: - """Build Voxtral TTS engine prompt, supporting both preset voices and inline - ``ref_audio`` (base64 or data URI).""" - from mistral_common.protocol.speech.request import SpeechRequest - - text = request.input - voice = request.voice - ref_audio = request.ref_audio - if not voice and not ref_audio: - raise ValueError("Voxtral requires either a voice name or ref_audio.") - # mistral_common expects raw base64 (no data: prefix) - if ref_audio is not None and isinstance(ref_audio, str) and ref_audio.startswith("data:"): - _, _, ref_audio = ref_audio.partition(",") - if self._tts_tokenizer is None: - from vllm.tokenizers import cached_tokenizer_from_config - - mistral_tokenizer = cached_tokenizer_from_config(self.engine_client.model_config) - self._tts_tokenizer = mistral_tokenizer.instruct - if voice is not None: - tokens = self._tts_tokenizer.encode_speech_request(SpeechRequest(input=text, voice=voice)).tokens - prompt = tokens_input(prompt_token_ids=tokens) - prompt["additional_information"] = {"voice": [voice]} - return prompt - else: - tokenized = self._tts_tokenizer.encode_speech_request(SpeechRequest(input=text, ref_audio=ref_audio)) - audio = tokenized.audios[0] - return { - "prompt_token_ids": tokenized.tokens, - "multi_modal_data": {"audio": [(audio.audio_array, audio.sampling_rate)]}, - } - - # ---- Step-Audio2 helpers ---- - - def _build_step_audio2_prompt( - self, - request: OpenAICreateSpeechRequest, - ) -> dict[str, Any]: - """Build prompt for Step-Audio2 TTS. - - Constructs the chat prompt with ```` as the last token - of the assistant turn (without ``<|im_end|>``), so the thinker - continues generating audio tokens. - - Prompt format:: - <|im_start|>system\\n{system_prompt}<|im_end|>\\n - <|im_start|>user\\n{input_text}<|im_end|>\\n - <|im_start|>assistant\\n - """ - system_prompt = getattr(request, "instructions", None) or "You are a voice assistant. Read the text aloud." - text = request.input - - raw_prompt = ( - f"<|im_start|>system\n{system_prompt}<|im_end|>\n" - f"<|im_start|>user\n{text}<|im_end|>\n" - f"<|im_start|>assistant\n" - ) - return {"prompt": raw_prompt} - - # ---- Fish Speech helpers ---- - - def _build_fish_speech_prompt( - self, - request: OpenAICreateSpeechRequest, - ref_audio_data: tuple[list[float], int] | None = None, - *, - has_inline_ref_audio: bool = False, - ) -> dict[str, Any]: - """Build prompt for Fish Speech S2 Pro. - - Without voice cloning: - <|im_start|>system\\nconvert the provided text to speech<|im_end|> - <|im_start|>user\\n{text}<|im_end|>\\n<|im_start|>assistant\\n<|voice|> - - With voice cloning (ref_audio + ref_text): - <|im_start|>system\\nconvert the provided text to speech reference to the following... - <|im_end|>\\n<|im_start|>user\\n{text}<|im_end|>\\n<|im_start|>assistant\\n<|voice|> - """ - from transformers import AutoTokenizer - - if self._fish_speech_tokenizer is None: - model_name = self.engine_client.model_config.model - self._fish_speech_tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) - - tokenizer = self._fish_speech_tokenizer - - if ref_audio_data is None or not request.ref_text: - prompt_ids, normalized_text = build_fish_text_only_prompt_ids(tokenizer, request.input) - - # Keep the prompt-dict metadata shape aligned with the existing text-only - # TTS entrypoints: scalar values are wrapped in single-item lists before - # EngineCore serialization. Structured clone below is different because - # model-side preprocess consumes concrete per-request scalar fields. - additional_information: dict[str, Any] = { - "text": [normalized_text], - } - if request.max_new_tokens is not None: - additional_information["max_new_tokens"] = [request.max_new_tokens] - prompt = tokens_input(prompt_token_ids=prompt_ids) - prompt["additional_information"] = additional_information - return prompt - - wav_samples, sr = ref_audio_data - normalized_text, normalized_ref_text = normalize_fish_voice_clone_texts(request.input, request.ref_text) - ph_len = self._estimate_fish_prompt_len(normalized_text, normalized_ref_text, ref_audio_data) - - # Structured clone: scalars (not list-wrapped) because model-side - # preprocess() consumes per-request fields directly. - additional_information: dict[str, Any] = { - "text": normalized_text, - "ref_text": normalized_ref_text, - "ref_audio_wav": torch.from_numpy(np.asarray(wav_samples, dtype=np.float32)), - "ref_audio_sr": int(sr), - "fish_structured_voice_clone": True, - } - # Pass voice identity for model-side DAC code caching. - if request.voice is not None: - voice_lower = request.voice.lower() - if voice_lower in self.uploaded_speakers and not has_inline_ref_audio: - additional_information["voice_name"] = voice_lower - additional_information["voice_created_at"] = self._voice_created_at(voice_lower) - if request.max_new_tokens is not None: - additional_information["max_new_tokens"] = request.max_new_tokens - prompt = tokens_input(prompt_token_ids=[1] * ph_len) - prompt["additional_information"] = additional_information - return prompt - - # ---- CosyVoice3 helpers ---- - - async def _build_cosyvoice3_prompt( - self, - request: OpenAICreateSpeechRequest, - *, - has_inline_ref_audio: bool = False, - ) -> dict[str, Any]: - """Build prompt for CosyVoice3. - - CosyVoice3 uses multimodal input with reference audio for voice cloning. - The prompt format matches the offline example: text prompt + audio data - + mm_processor_kwargs with prompt_text. - """ - # Resolve reference audio - wav_samples, sr, _ = await self._resolve_ref_audio(request.ref_audio) - audio_data = (np.asarray(wav_samples, dtype=np.float32), sr) - - # Wrap the reference transcript in the CosyVoice3 instruction template - # so the talker emits target-only speech (see _COSYVOICE3_PROMPT_PREFIX). - # Skip if the caller already supplied a formatted prompt_text. - ref_text = request.ref_text or "" - if _COSYVOICE3_PROMPT_DELIMITER not in ref_text: - ref_text = f"{_COSYVOICE3_PROMPT_PREFIX}{ref_text}" - mm_kwargs: dict[str, Any] = { - "prompt_text": ref_text, - "sample_rate": sr, - } - # Pass voice metadata for caching in the processor - if request.voice: - voice_lower = request.voice.lower() - if voice_lower in self.uploaded_speakers and not has_inline_ref_audio: - mm_kwargs["voice_name"] = voice_lower - mm_kwargs["voice_created_at"] = self._voice_created_at(voice_lower) - - return { - "prompt": request.input, - "multi_modal_data": { - "audio": audio_data, - }, - "mm_processor_kwargs": mm_kwargs, - } - - # ---- Covo-Audio helpers ---- - - def _build_covo_audio_prompt( - self, - request: OpenAICreateSpeechRequest, - ) -> dict[str, Any]: - """Build a chat-style prompt for Covo-Audio-Chat. - - Covo-Audio requires a specific system prompt that instructs the model - to interleave text and audio tokens in its output. We render the - messages through the chat template and pass prompt_token_ids so that - the engine does not need to re-tokenize. - """ - from transformers import AutoTokenizer - - from vllm_omni.model_executor.models.covo_audio.prompt_utils import ( - build_covo_audio_prompt_token_ids, - ) - - if self._covo_audio_tokenizer is None: - model_name = self.engine_client.model_config.model - try: - self._covo_audio_tokenizer = AutoTokenizer.from_pretrained( - model_name, - trust_remote_code=True, - ) - except Exception as exc: - raise RuntimeError(f"Failed to load Covo-Audio tokenizer from '{model_name}': {exc}") from exc - - prompt_ids = build_covo_audio_prompt_token_ids( - self._covo_audio_tokenizer, - request.input, - ) - return {"prompt_token_ids": prompt_ids} - - def _get_audex_tokenizer(self): - """Load (once per process) the Audex thinker tokenizer for CFG/TTA prompts. - - The folder follows the serving stage: the full-pipeline thinker - (``audex_omni``) tokenizes with ``checkpoint_folder_full``; the TTS - and TTA thinkers use ``checkpoint_folder_audiogen``. - """ - if self._audex_tokenizer is None: - from transformers import AutoTokenizer - - from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot - - stage = getattr(getattr(self._tts_stage, "engine_args", None), "model_stage", None) - if stage == "audex_omni": - profile, folder = "full", "checkpoint_folder_full" - elif stage == "audex_tta_thinker": - profile, folder = "tta", "checkpoint_folder_audiogen" - else: - profile, folder = "tts", "checkpoint_folder_audiogen" - # The serving model_config may already hold the RESOLVED per-stage - # checkpoint folder (stage init joins model_subdir onto the - # snapshot root); joining again would produce a non-existent path - # that transformers then mistakes for a repo id. - model_path = os.path.normpath(self.engine_client.model_config.model) - if os.path.basename(model_path).startswith("checkpoint_folder"): - root = os.path.dirname(model_path) - else: - root = ensure_audex_snapshot(model_path, profile=profile) - self._audex_tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, folder)) - return self._audex_tokenizer - - # ---- GLM-TTS helpers ---- - - async def _build_glm_tts_prompt( - self, - request: OpenAICreateSpeechRequest, - *, - has_inline_ref_audio: bool = False, - ) -> dict[str, Any]: - """Build prompt for GLM-TTS. - - Uses the multimodal processor path (same as CosyVoice3): - - prompt: synthesis text - - multi_modal_data["audio"]: (wav_samples, sr) reference audio - - mm_processor_kwargs["prompt_text"]: reference text transcript - - AR preprocess() builds [PromptText | Text | BOA | PromptSpeechTokens + ATS]. - DiT receives prompt_token, prompt_feat, embedding for conditioning. - """ - # Voice cloning requires ref_audio + ref_text - if request.ref_audio is not None and request.ref_text: - wav_samples, sr, _ = await self._resolve_ref_audio(request.ref_audio) - audio_data = (np.asarray(wav_samples, dtype=np.float32), int(sr)) - - mm_kwargs: dict[str, Any] = { - "prompt_text": request.ref_text, - } - if request.voice: - voice_lower = request.voice.lower() - if voice_lower in self.uploaded_speakers and not has_inline_ref_audio: - mm_kwargs["voice_name"] = voice_lower - mm_kwargs["voice_created_at"] = self._voice_created_at(voice_lower) - - return { - "prompt": request.input, - "multi_modal_data": { - "audio": audio_data, - }, - "mm_processor_kwargs": mm_kwargs, - "additional_information": self._build_glm_tts_prefill_metadata( - request.input, - request.ref_text, - ), - } - - raise ValueError("GLM-TTS requires ref_audio and ref_text for voice cloning.") - - def _glm_tts_text_tokenizer_and_frontend(self): - from vllm_omni.model_executor.models.glm_tts.glm_tts import ( - load_glm_tts_tokenizer, - resolve_glm_tts_tokenizer_path, - ) - from vllm_omni.model_executor.models.glm_tts.text_frontend import GLMTTSTextFrontend - - cached = self._glm_tts_text_tokenizer - if cached is None: - model_name_or_path = self.engine_client.model_config.model - tokenizer_path = getattr(self.engine_client.model_config, "tokenizer", None) - if tokenizer_path is None: - tokenizer_path = resolve_glm_tts_tokenizer_path(model_name_or_path) - cached = load_glm_tts_tokenizer( - tokenizer_path, - model_name_or_path=model_name_or_path, - trust_remote_code=bool(getattr(self.engine_client.model_config, "trust_remote_code", False)), - ) - self._glm_tts_text_tokenizer = cached - - frontend = self._glm_tts_text_frontend - if frontend is None: - frontend = GLMTTSTextFrontend() - self._glm_tts_text_frontend = frontend - return cached, frontend - - def _estimate_glm_tts_text_token_len(self, text: str | None, *, add_trailing_space: bool = False) -> int: - """Estimate GLM-TTS normalized text length with the model tokenizer.""" - cached, frontend = self._glm_tts_text_tokenizer_and_frontend() - text = text or "" - normalized = frontend.text_normalize(text) or text - normalized = normalized.strip() - if add_trailing_space and normalized: - normalized = f"{normalized} " - return max(1, len(cached.encode(normalized))) - - def _build_glm_tts_prefill_metadata(self, text: str, prompt_text: str | None) -> dict[str, Any]: - """Build GLM-TTS processor length metadata for additional_information. - - The model preprocess hook runs before postprocess can mirror MM kwargs - into additional_information, so these scalar fields must originate from - the request payload rather than runner-level mm_features. - """ - text_len = self._estimate_glm_tts_text_token_len(text) - prompt_text_len = ( - self._estimate_glm_tts_text_token_len(prompt_text, add_trailing_space=True) if prompt_text else 0 - ) - return { - "glm_tts_text_token_len": [text_len], - "glm_tts_prompt_text_token_len": [prompt_text_len], - "input_len": [prompt_text_len + text_len + 1], - } - - # ---- Ming-flash-omni standalone-talker (TTS) helpers ---- - - def _build_ming_flash_omni_prompt(self, request: OpenAICreateSpeechRequest) -> dict[str, Any]: - # request.instructions accepts two forms: - # 1. Plain text: mapped to the caption's 风格 (style) field - # 2. JSON object: parsed and splatted into the caption. Unlocks - # Unknown keys are dropped by `ming_create_instruction`. - caption_fields = self._parse_ming_instruction_fields(request) or {} - - has_spk_emb = request.speaker_embedding is not None - - # TTS path applies ming task type `instruct`. - # voice_name enables talker-side voice preset resolution (e.g. "DB30"). - additional_information: dict[str, Any] = { - "ming_task": "instruct", - "prompt": MING_DEFAULT_PROMPT, - "text": request.input, - "instruction": ming_create_instruction(caption_fields), - "voice_name": request.voice or None, - "use_zero_spk_emb": not has_spk_emb, - "max_decode_steps": request.max_new_tokens or _TTS_MAX_NEW_TOKENS_MAX, - "cfg": 2.0, - "sigma": 0.25, - "temperature": 0.0, - } - if has_spk_emb: - # Passed as plain float list - additional_information["spk_emb"] = list(request.speaker_embedding) - prompt = tokens_input(prompt_token_ids=[0]) - prompt["additional_information"] = additional_information - return prompt - - # ---- Common speech generation helpers ---- - - async def _build_qwen3_tts_request( - self, - request: OpenAICreateSpeechRequest, - ) -> tuple[dict[str, Any], dict[str, Any], str | None]: - """Build prompt + tts_params for Qwen3-TTS. - - Called from ``Qwen3TTSAdapter.build``. Returns - ``(prompt, tts_params, warmup_artifact_key)`` where the warmup key is the - Qwen3-TTS ref-audio artifact tracked after ``generate()``. - """ - qwen3_ref_audio_warmup_artifact_key: str | None = None - tts_params = self._build_tts_params(request) - # Resolve ref_audio (explicit or auto-set for uploaded voices) - # to [[wav_list, sr]] so the model doesn't re-decode base64. - ref_audio_source = request.ref_audio - if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list): - # Uploaded voice: ref_audio was auto-set as [base64_data_url] - ref_audio_source = tts_params["ref_audio"][0] - if ref_audio_source is not None and isinstance(ref_audio_source, str): - wav_list, sr, cache_key = await self._resolve_ref_audio(ref_audio_source) - tts_params["ref_audio_cache_key"] = cache_key - artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key) - if artifact_key: - tts_params[_QWEN3_TTS_REF_AUDIO_CACHE_KEY] = [artifact_key] - ref_code_length = self._estimate_ref_code_len([wav_list, sr]) - if ref_code_length is not None: - tts_params["ref_code_length"] = [int(ref_code_length)] - if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key): - logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key) - else: - tts_params["ref_audio"] = [[wav_list, sr]] - qwen3_ref_audio_warmup_artifact_key = artifact_key - - ph_len = await self._estimate_prompt_len_async(tts_params) - prompt = tokens_input(prompt_token_ids=[1] * ph_len) - prompt["additional_information"] = tts_params - prompt["cache_salt"] = _conditioning_cache_salt(request, tts_params) - return prompt, tts_params, qwen3_ref_audio_warmup_artifact_key - async def _prepare_speech_generation( self, request: OpenAICreateSpeechRequest, @@ -3189,18 +1809,6 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): stage0_params.extra_args = {} stage0_params.extra_args["tts_local_seed"] = request.seed - if self._tts_model_type == "qwen3_tts" and sampling_params_list: - stage0_params = sampling_params_list[0] - default_seed = getattr(stage0_params, "seed", None) - if default_seed is not None: - import copy - - sampling_params_list = copy.deepcopy(sampling_params_list) - stage0_params = sampling_params_list[0] - if stage0_params.extra_args is None: - stage0_params.extra_args = {} - stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed)) - # When word_timestamps is requested, also ask for the aligner stage's # output so the orchestrator drives the request through the forced-aligner # stage (final_stage_id extends to it). Harmless if no aligner stage exists. diff --git a/vllm_omni/entrypoints/openai/tts_adapters/audex.py b/vllm_omni/entrypoints/openai/tts_adapters/audex.py index ab7c7c25e..0e31b633f 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/audex.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/audex.py @@ -1,6 +1,8 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Audex (Nemotron-Labs-Audex-2B) TTS serving adapter.""" +import os from typing import TYPE_CHECKING, Any from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter @@ -29,6 +31,30 @@ class AudexAdapter(ARTTSAdapter): stage_keys = frozenset({"audex_thinker", "audex_omni"}) name = "audex" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._tokenizer = None + + def _get_tokenizer(self): + """Load and cache the tokenizer used only by the Audex TTS adapter.""" + if self._tokenizer is None: + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot + + stage = getattr(getattr(self.ctx.server._tts_stage, "engine_args", None), "model_stage", None) + profile, folder = ( + ("full", "checkpoint_folder_full") if stage == "audex_omni" else ("tts", "checkpoint_folder_audiogen") + ) + model_path = os.path.normpath(self.ctx.engine_client.model_config.model) + root = ( + os.path.dirname(model_path) + if os.path.basename(model_path).startswith("checkpoint_folder") + else ensure_audex_snapshot(model_path, profile=profile) + ) + self._tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, folder)) + return self._tokenizer + @classmethod def stage_serves_speech(cls, model_stage: str | None, all_stage_keys: frozenset[str]) -> bool: """``audex_omni`` covers the audex_s2s deployment, whose TTS pass uses @@ -102,7 +128,6 @@ class AudexAdapter(ARTTSAdapter): element of the engine's shared ``default_sampling_params_list`` — see ``_inject_audex_tta_args``). """ - server = self.ctx.server import copy stage0_params = copy.deepcopy(stage0_params) @@ -121,7 +146,7 @@ class AudexAdapter(ARTTSAdapter): cond_prompt = prompt.get("prompt") if isinstance(prompt, dict) else prompt if not isinstance(cond_prompt, str) or not cond_prompt: raise ValueError("Audex CFG requires the adapter-built text prompt") - null_prompt = build_null_prompt(cond_prompt, server._get_audex_tokenizer()) + null_prompt = build_null_prompt(cond_prompt, self._get_tokenizer()) extra_args.update( { "cfg_scale": cfg_scale, diff --git a/vllm_omni/entrypoints/openai/tts_adapters/audex_tta.py b/vllm_omni/entrypoints/openai/tts_adapters/audex_tta.py index 33dd2294a..b0af5cdca 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/audex_tta.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/audex_tta.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Audex (Nemotron-Labs-Audex-2B) text-to-audio serving adapter. Caption → general audio through the ``audex_tta`` pipeline. @@ -8,6 +9,7 @@ completes the sampling parameters with the RVQ phase-mask contract scale 3.0 — guidance is effectively mandatory for TTA quality). """ +import os from typing import TYPE_CHECKING, Any from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter @@ -32,6 +34,27 @@ class AudexTTAAdapter(ARTTSAdapter): stage_keys = frozenset({"audex_tta_thinker"}) name = "audex_tta" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._tokenizer = None + self._tta_rvq = None + + def _get_tokenizer(self): + """Load and cache the tokenizer used only by the Audex TTA adapter.""" + if self._tokenizer is None: + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.audex.checkpoint import ensure_audex_snapshot + + model_path = os.path.normpath(self.ctx.engine_client.model_config.model) + root = ( + os.path.dirname(model_path) + if os.path.basename(model_path).startswith("checkpoint_folder") + else ensure_audex_snapshot(model_path, profile="tta") + ) + self._tokenizer = AutoTokenizer.from_pretrained(os.path.join(root, "checkpoint_folder_audiogen")) + return self._tokenizer + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: if not request.input or not request.input.strip(): return "Audex TTA requires a non-empty caption" @@ -95,7 +118,6 @@ class AudexTTAAdapter(ARTTSAdapter): state (``cfg_pair_id``/``cfg_null_prompt``) into it would race concurrent requests and leak stale CFG metadata into later ones. """ - server = self.ctx.server import copy from vllm_omni.model_executor.models.audex.prompt import build_tta_null_prompt @@ -106,10 +128,10 @@ class AudexTTAAdapter(ARTTSAdapter): raise ValueError("Audex TTA requires the adapter-built caption prompt") stage0_params = copy.deepcopy(stage0_params) - tokenizer = server._get_audex_tokenizer() - if server._audex_tta_rvq is None: + tokenizer = self._get_tokenizer() + if self._tta_rvq is None: phase_token_ids, start_tid, end_tid = build_tta_phase_token_ids(tokenizer) - server._audex_tta_rvq = { + self._tta_rvq = { "phase_token_ids": phase_token_ids, "start_tid": start_tid, "end_tid": end_tid, @@ -123,7 +145,7 @@ class AudexTTAAdapter(ARTTSAdapter): if extra_args is None: extra_args = {} stage0_params.extra_args = extra_args - extra_args["tta_rvq"] = server._audex_tta_rvq + extra_args["tta_rvq"] = self._tta_rvq cfg_scale = extra_args.get("cfg_scale") cfg_scale = 3.0 if cfg_scale is None else float(cfg_scale) diff --git a/vllm_omni/entrypoints/openai/tts_adapters/base.py b/vllm_omni/entrypoints/openai/tts_adapters/base.py index d4316a2fe..ff43b27a5 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/base.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/base.py @@ -11,8 +11,9 @@ instead of editing the shared serving module in ~10 scattered places. See the RFC for the full design (issue #4327). """ +import hashlib from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping +from collections.abc import Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, ClassVar @@ -37,22 +38,52 @@ DEFAULT_TTS_LANGUAGES = frozenset( } ) -_conditioning_cache_salt_fn: "Callable[..., str] | None" = None +def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict | None = None) -> str: + """Stable hash of the real Stage 0 conditioning for the prefix cache. -def conditioning_cache_salt(request: "OpenAICreateSpeechRequest", tts_params: dict) -> str: - """Return the conditioning cache salt for ``request`` + ``tts_params``. + The talker's vLLM prompt is placeholder token ids; the real inputs are + rebuilt from text / ref_audio / ref_text into inputs_embeds. vLLM hashes + token ids (folded with cache_salt) for prefix caching, so without a salt + every request collides and a hit could reuse KV from a semantically + different input. - Lazily imports and caches ``serving_speech._conditioning_cache_salt`` on first - use: the import is deferred to break the adapters<->serving_speech import - cycle, and cached so it resolves once instead of on every ``build()`` call. + Raw request fields alone are not enough for uploaded voices: resolved + conditioning such as ``voice_created_at`` and content-aware ref-audio + cache keys must also be folded in. This distinguishes delete/re-upload of + the same voice and same-path local audio rewrites without hashing decoded + waveform arrays. """ - global _conditioning_cache_salt_fn - if _conditioning_cache_salt_fn is None: - from vllm_omni.entrypoints.openai.serving_speech import _conditioning_cache_salt - - _conditioning_cache_salt_fn = _conditioning_cache_salt - return _conditioning_cache_salt_fn(request, tts_params) + h = hashlib.sha256() + for part in ( + request.input, + request.task_type, + request.language, + request.voice, + request.ref_text, + request.ref_audio, + request.instructions, + request.x_vector_only_mode, + request.speaker_embedding, + ): + h.update(b"\x00") + if part is not None: + h.update(repr(part).encode("utf-8")) + # Fold conditioning derived by adapters and absent from the raw request. + for key in ( + "voice_created_at", + "task_type", + "speaker", + "ref_text", + "x_vector_only_mode", + "ref_audio_cache_key", + "ref_audio_2_cache_key", + ): + h.update(b"\x00") + value = tts_params.get(key) if tts_params is not None else None + if value is not None: + h.update(repr(value).encode("utf-8")) + return h.hexdigest()[:32] def apply_max_new_tokens( diff --git a/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py b/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py index ec1c145c7..25993ed6e 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """CosyVoice3 TTS serving adapter.""" import os from typing import TYPE_CHECKING, Any +import numpy as np from vllm.logger import init_logger from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter @@ -14,12 +16,46 @@ if TYPE_CHECKING: logger = init_logger(__name__) +# Without this delimiter the talker re-speaks the reference transcript instead +# of emitting target-only speech (issue #4644). Matches the offline example and +# upstream demo. +_PROMPT_DELIMITER = "<|endofprompt|>" +_PROMPT_PREFIX = f"You are a helpful assistant.{_PROMPT_DELIMITER}" + @register_tts_adapter class CosyVoice3Adapter(ARTTSAdapter): stage_keys = frozenset({"cosyvoice3_talker"}) name = "cosyvoice3" + def __init__(self, ctx) -> None: + super().__init__(ctx) + # Cache the resolved Qwen tokenizer used for dynamic-token sizing. + # Rebuilding it would repeat snapshot resolution and add ~100 ms to the + # TTFP-critical sampling override on every request. + self._tokenizer = None + + async def _build_prompt( + self, request: "OpenAICreateSpeechRequest", *, has_inline_ref_audio: bool = False + ) -> dict[str, Any]: + """Build the multimodal CosyVoice3 voice-cloning prompt.""" + server = self.ctx.server + wav_samples, sr, _ = await server._resolve_ref_audio(request.ref_audio) + ref_text = request.ref_text or "" + if _PROMPT_DELIMITER not in ref_text: + ref_text = f"{_PROMPT_PREFIX}{ref_text}" + mm_kwargs: dict[str, Any] = {"prompt_text": ref_text, "sample_rate": sr} + if request.voice: + voice_lower = request.voice.lower() + if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: + mm_kwargs["voice_name"] = voice_lower + mm_kwargs["voice_created_at"] = server._voice_created_at(voice_lower) + return { + "prompt": request.input, + "multi_modal_data": {"audio": (np.asarray(wav_samples, dtype=np.float32), sr)}, + "mm_processor_kwargs": mm_kwargs, + } + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate CosyVoice3 request parameters. Returns error message or None.""" server = self.ctx.server @@ -51,7 +87,7 @@ class CosyVoice3Adapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = await self.ctx.server._build_cosyvoice3_prompt(request, has_inline_ref_audio=has_inline_ref_audio) + prompt = await self._build_prompt(request, has_inline_ref_audio=has_inline_ref_audio) return PreparedRequest(prompt=prompt, tts_params={}, model_type="cosyvoice3") def apply_sampling_overrides( @@ -76,7 +112,7 @@ class CosyVoice3Adapter(ARTTSAdapter): hf_cfg = server.model_config.hf_config # Build the Qwen tokenizer once per process (resolving the model dir via # snapshot_download at most once) and reuse it across requests. - tokenizer = server._cosyvoice3_tokenizer + tokenizer = self._tokenizer if tokenizer is None: model_path = server.engine_client.model_config.model if not os.path.isdir(model_path): @@ -88,7 +124,7 @@ class CosyVoice3Adapter(ARTTSAdapter): skip_special_tokens=hf_cfg.skip_special_tokens, version=hf_cfg.version, ) - server._cosyvoice3_tokenizer = tokenizer + self._tokenizer = tokenizer _, text_token_len = extract_text_token( request.input, tokenizer, diff --git a/vllm_omni/entrypoints/openai/tts_adapters/covo_audio.py b/vllm_omni/entrypoints/openai/tts_adapters/covo_audio.py index 28e64bc2a..a9840b4a0 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/covo_audio.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/covo_audio.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """CoVo-Audio serving adapter.""" from typing import TYPE_CHECKING @@ -15,6 +16,10 @@ class CovoAudioAdapter(ARTTSAdapter): stage_keys = frozenset({"fused_thinker_talker"}) name = "covo_audio" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._tokenizer = None + @classmethod def matches(cls, model_stage: str | None, model_arch: str | None) -> bool: """``fused_thinker_talker`` is a generic stage key that non-CoVo fused @@ -30,5 +35,21 @@ class CovoAudioAdapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = self.ctx.server._build_covo_audio_prompt(request) - return PreparedRequest(prompt=prompt, tts_params={}, model_type="covo_audio") + """Build the tokenized chat prompt expected by Covo-Audio-Chat. + + The model requires a specific system prompt instructing it to + interleave text and audio tokens. Passing token IDs avoids a second + engine-side tokenization step. + """ + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.covo_audio.prompt_utils import build_covo_audio_prompt_token_ids + + if self._tokenizer is None: + model_name = self.ctx.engine_client.model_config.model + try: + self._tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) + except Exception as exc: + raise RuntimeError(f"Failed to load Covo-Audio tokenizer from '{model_name}': {exc}") from exc + prompt_ids = build_covo_audio_prompt_token_ids(self._tokenizer, request.input) + return PreparedRequest(prompt={"prompt_token_ids": prompt_ids}, tts_params={}, model_type="covo_audio") diff --git a/vllm_omni/entrypoints/openai/tts_adapters/fish_speech.py b/vllm_omni/entrypoints/openai/tts_adapters/fish_speech.py index a6426a13d..f6dc84d52 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/fish_speech.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/fish_speech.py @@ -1,8 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Fish Speech serving adapter (retires the legacy ``_is_fish_speech`` flag).""" +import math from typing import TYPE_CHECKING, Any +import numpy as np +import torch +from vllm.inputs import tokens_input +from vllm.utils.async_utils import make_async + from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ( ARTTSAdapter, @@ -20,6 +27,101 @@ class FishSpeechAdapter(ARTTSAdapter): stage_keys = frozenset({"fish_speech_slow_ar"}) name = "fish_tts" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._tokenizer = None + self._build_prompt_async = make_async(self._build_prompt, executor=getattr(ctx.server, "_tts_executor", None)) + + @staticmethod + def _estimate_ref_code_len(ref_audio: object) -> int | None: + from vllm_omni.model_executor.models.fish_speech.dac_utils import DAC_HOP_LENGTH, DAC_SAMPLE_RATE + + if not isinstance(ref_audio, (list, tuple)) or len(ref_audio) != 2: + return None + wav, sr = ref_audio + sr = int(sr) + if sr <= 0 or len(wav) <= 0: + return None + return max(1, math.ceil(max(1, math.ceil(len(wav) * DAC_SAMPLE_RATE / sr)) / DAC_HOP_LENGTH)) + + def _get_tokenizer(self): + if self._tokenizer is None: + from transformers import AutoTokenizer + + self._tokenizer = AutoTokenizer.from_pretrained( + self.ctx.engine_client.model_config.model, trust_remote_code=True + ) + return self._tokenizer + + def _estimate_prompt_len(self, text: str, ref_text: str, ref_audio: object) -> int: + from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( + estimate_fish_voice_clone_prompt_len_from_normalized, + ) + + try: + semantic_len = self._estimate_ref_code_len(ref_audio) + if semantic_len is None: + raise ValueError("Failed to estimate Fish Speech semantic token length") + return estimate_fish_voice_clone_prompt_len_from_normalized( + self._get_tokenizer(), text, ref_text, semantic_len + ) + except Exception as exc: + import logging + + logging.getLogger(__name__).warning( + "Failed to estimate Fish Speech prompt length, using fallback 2048: %s", exc + ) + return 2048 + + def _build_prompt( + self, + request: "OpenAICreateSpeechRequest", + ref_audio_data: tuple[list[float], int] | None, + has_inline_ref_audio: bool, + ) -> dict[str, Any]: + """Build either the text-only or structured Fish Speech clone prompt. + + Structured clone metadata uses concrete scalar fields because model-side + preprocess consumes one request directly; text-only metadata retains the + legacy single-item list representation used across EngineCore IPC. + """ + from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( + build_fish_text_only_prompt_ids, + normalize_fish_voice_clone_texts, + ) + + tokenizer = self._get_tokenizer() + if ref_audio_data is None or not request.ref_text: + prompt_ids, normalized_text = build_fish_text_only_prompt_ids(tokenizer, request.input) + info: dict[str, Any] = {"text": [normalized_text]} + if request.max_new_tokens is not None: + info["max_new_tokens"] = [request.max_new_tokens] + prompt = tokens_input(prompt_token_ids=prompt_ids) + prompt["additional_information"] = info + return prompt + + wav_samples, sr = ref_audio_data + normalized_text, normalized_ref_text = normalize_fish_voice_clone_texts(request.input, request.ref_text) + ph_len = self._estimate_prompt_len(normalized_text, normalized_ref_text, ref_audio_data) + info = { + "text": normalized_text, + "ref_text": normalized_ref_text, + "ref_audio_wav": torch.from_numpy(np.asarray(wav_samples, dtype=np.float32)), + "ref_audio_sr": int(sr), + "fish_structured_voice_clone": True, + } + server = self.ctx.server + if request.voice is not None: + voice_lower = request.voice.lower() + if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: + info["voice_name"] = voice_lower + info["voice_created_at"] = server._voice_created_at(voice_lower) + if request.max_new_tokens is not None: + info["max_new_tokens"] = request.max_new_tokens + prompt = tokens_input(prompt_token_ids=[1] * ph_len) + prompt["additional_information"] = info + return prompt + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate Fish Speech request parameters. Returns error message or None.""" server = self.ctx.server @@ -54,9 +156,7 @@ class FishSpeechAdapter(ARTTSAdapter): wav_list, sr, cache_key = await server._resolve_ref_audio(request.ref_audio) ref_audio_data = (wav_list, sr) tts_params["ref_audio_cache_key"] = cache_key - prompt = await server._build_fish_speech_prompt_async( - request, ref_audio_data=ref_audio_data, has_inline_ref_audio=has_inline_ref_audio - ) + prompt = await self._build_prompt_async(request, ref_audio_data, has_inline_ref_audio) prompt["cache_salt"] = conditioning_cache_salt(request, tts_params) return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type="fish_speech") diff --git a/vllm_omni/entrypoints/openai/tts_adapters/glm_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/glm_tts.py index e65edb01b..22dbfc6b6 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/glm_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/glm_tts.py @@ -1,8 +1,10 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """GLM-TTS serving adapter.""" from typing import TYPE_CHECKING, Any +import numpy as np from vllm.logger import init_logger from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter @@ -19,6 +21,72 @@ class GlmTTSAdapter(ARTTSAdapter): stage_keys = frozenset({"glm_tts"}) name = "glm_tts" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._text_tokenizer = None + self._text_frontend = None + + def _text_tokenizer_and_frontend(self): + from vllm_omni.model_executor.models.glm_tts.glm_tts import ( + load_glm_tts_tokenizer, + resolve_glm_tts_tokenizer_path, + ) + from vllm_omni.model_executor.models.glm_tts.text_frontend import GLMTTSTextFrontend + + if self._text_tokenizer is None: + config = self.ctx.engine_client.model_config + tokenizer_path = getattr(config, "tokenizer", None) or resolve_glm_tts_tokenizer_path(config.model) + self._text_tokenizer = load_glm_tts_tokenizer( + tokenizer_path, + model_name_or_path=config.model, + trust_remote_code=bool(getattr(config, "trust_remote_code", False)), + ) + if self._text_frontend is None: + self._text_frontend = GLMTTSTextFrontend() + return self._text_tokenizer, self._text_frontend + + def _estimate_text_token_len(self, text: str | None, *, add_trailing_space: bool = False) -> int: + tokenizer, frontend = self._text_tokenizer_and_frontend() + normalized = (frontend.text_normalize(text or "") or text or "").strip() + if add_trailing_space and normalized: + normalized = f"{normalized} " + return max(1, len(tokenizer.encode(normalized))) + + def _build_prefill_metadata(self, text: str, prompt_text: str | None) -> dict[str, Any]: + text_len = self._estimate_text_token_len(text) + prompt_len = self._estimate_text_token_len(prompt_text, add_trailing_space=True) if prompt_text else 0 + return { + "glm_tts_text_token_len": [text_len], + "glm_tts_prompt_text_token_len": [prompt_len], + "input_len": [prompt_len + text_len + 1], + } + + async def _build_prompt( + self, request: "OpenAICreateSpeechRequest", *, has_inline_ref_audio: bool = False + ) -> dict[str, Any]: + """Build GLM-TTS multimodal voice-cloning input and prefill metadata. + + AR preprocess constructs PromptText, Text, BOA and prompt speech tokens; + the DiT consumes the resulting prompt tokens, features and embedding as + conditioning. + """ + server = self.ctx.server + if request.ref_audio is None or not request.ref_text: + raise ValueError("GLM-TTS requires ref_audio and ref_text for voice cloning.") + wav_samples, sr, _ = await server._resolve_ref_audio(request.ref_audio) + mm_kwargs: dict[str, Any] = {"prompt_text": request.ref_text} + if request.voice: + voice_lower = request.voice.lower() + if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: + mm_kwargs["voice_name"] = voice_lower + mm_kwargs["voice_created_at"] = server._voice_created_at(voice_lower) + return { + "prompt": request.input, + "multi_modal_data": {"audio": (np.asarray(wav_samples, dtype=np.float32), int(sr))}, + "mm_processor_kwargs": mm_kwargs, + "additional_information": self._build_prefill_metadata(request.input, request.ref_text), + } + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate GLM-TTS request — requires ref_audio for voice cloning.""" server = self.ctx.server @@ -46,7 +114,7 @@ class GlmTTSAdapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = await self.ctx.server._build_glm_tts_prompt(request, has_inline_ref_audio=has_inline_ref_audio) + prompt = await self._build_prompt(request, has_inline_ref_audio=has_inline_ref_audio) return PreparedRequest(prompt=prompt, tts_params={}, model_type="glm_tts") def _load_supported_speakers(self) -> set[str]: @@ -71,9 +139,7 @@ class GlmTTSAdapter(ARTTSAdapter): if isinstance(text_len_value, list) and text_len_value: text_len_value = text_len_value[0] text_token_len = ( - int(text_len_value) - if text_len_value is not None - else server._estimate_glm_tts_text_token_len(request.input) + int(text_len_value) if text_len_value is not None else self._estimate_text_token_len(request.input) ) hf_cfg = server.model_config.hf_config min_ratio = getattr(hf_cfg, "min_token_text_ratio", 2) diff --git a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2.py b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2.py index 74ec591cc..207d8b47b 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v2.py @@ -1,8 +1,13 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Higgs-Audio v2 serving adapter.""" +import asyncio from typing import TYPE_CHECKING, Any +import numpy as np +from vllm.inputs import tokens_input + from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens @@ -12,6 +17,89 @@ if TYPE_CHECKING: @register_tts_adapter class HiggsAudioV2Adapter(ARTTSAdapter): + @property + def engine_client(self): + return self.ctx.engine_client + + async def _resolve_ref_audio(self, ref_audio: str): + return await self.ctx.server._resolve_ref_audio(ref_audio) + + async def _build_higgs_audio_v2_params(self, request: "OpenAICreateSpeechRequest"): + """Build prompt_token_ids for higgs_audio_v2 via the upstream processor. + + Plain-text path: runs ``build_plain_text_prompt`` and returns the + token-only prompt. Voice-clone path (``ref_audio`` + ``ref_text``): + resolves the reference clip via ``_resolve_ref_audio``, runs + ``build_voice_clone_prompt`` (which encodes the clip through HF's + ``HiggsAudioV2TokenizerModel`` loaded from the k2-fsa/OmniVoice + ``audio_tokenizer/`` subdirectory), and attaches the encoded + ``audio_input_ids`` + ``audio_input_ids_mask`` tensors via + ``additional_information`` so the talker substitutes them at the + prompt-side audio placeholders. + """ + from vllm_omni.model_executor.models.higgs_audio_v2.higgs_audio_v2_tokenizer import ( + build_plain_text_prompt, + build_voice_clone_prompt, + input_ids_to_python_list, + ) + + processor = await self._resolve_higgs_audio_v2_processor() + + if request.ref_audio is None: + inputs = await asyncio.to_thread(build_plain_text_prompt, processor, request.input) + prompt_token_ids = input_ids_to_python_list(inputs) + return tokens_input(prompt_token_ids=prompt_token_ids) + + wav_list, sr, _ = await self._resolve_ref_audio(request.ref_audio) + wav = np.asarray(wav_list, dtype=np.float32) + out = await asyncio.to_thread( + build_voice_clone_prompt, + processor, + request.input, + wav, + int(sr), + request.ref_text or "", + ) + prompt = tokens_input(prompt_token_ids=out["prompt_token_ids"]) + # Pass tensors at the top level of additional_information (NOT list- + # wrapped). ``vllm_omni.data_entry_keys.serialize_payload`` routes + # bare ``torch.Tensor`` values through ``_serialize_tensor``; a list + # containing tensors would fall into the ``list_data`` field which + # msgspec cannot serialize and the tensors would be dropped over the + # process boundary (silent voice-clone failure). + prompt["additional_information"] = { + "audio_input_ids": out["audio_input_ids"], + "audio_input_ids_mask": out["audio_input_ids_mask"], + } + return prompt + + async def _resolve_higgs_audio_v2_processor(self): + """Lazy-load the AutoProcessor for higgs_audio_v2 (once per serving instance).""" + cached = getattr(self, "_higgs_audio_v2_processor", None) + if cached is not None: + return cached + + from transformers import AutoProcessor + + model_path = None + for stage in self.engine_client.stage_configs: + model_path = getattr(getattr(stage, "engine_args", None), "model", None) + if model_path: + break + if model_path is None: + # Fallback: the orchestrator stores the served model id on the engine + # itself (set by AsyncOmniEngine.__init__). Stage-level engine_args + # may not surface ``model`` when the deploy yaml doesn't set it per + # stage (the CLI-passed model id is the single source of truth). + model_path = getattr(self.engine_client, "model", None) + if model_path is None: + raise RuntimeError("higgs_audio_v2 serving could not resolve the model path from the engine stage configs") + processor = AutoProcessor.from_pretrained(model_path) + self._higgs_audio_v2_processor = processor + return processor + + # ---- higgs-audio v3 ---- + stage_keys = frozenset({"higgs_audio_v2"}) name = "higgs_audio_v2" @@ -91,7 +179,7 @@ class HiggsAudioV2Adapter(ARTTSAdapter): self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: server = self.ctx.server - prompt = await server._build_higgs_audio_v2_params(request) + prompt = await self._build_higgs_audio_v2_params(request) if request.voice: voice_lower = request.voice.lower() if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: diff --git a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py index 411d97f31..eb00fe784 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py @@ -1,10 +1,25 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Higgs-Audio v3 serving adapter.""" +import asyncio +from collections import OrderedDict from typing import TYPE_CHECKING, Any +import numpy as np +import torch +from vllm.inputs import tokens_input + from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter -from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens +from vllm_omni.entrypoints.openai.tts_adapters.base import ( + ARTTSAdapter, + PreparedRequest, + apply_max_new_tokens, + conditioning_cache_salt, +) + +_REF_CODE_CACHE_MAX_ENTRIES = 256 +_REF_CODE_CACHE_MAX_BYTES = 64 * 1024 * 1024 if TYPE_CHECKING: from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest @@ -12,6 +27,159 @@ if TYPE_CHECKING: @register_tts_adapter class HiggsAudioV3Adapter(ARTTSAdapter): + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._higgs_audio_v3_ref_code_cache: OrderedDict[str, tuple[torch.Tensor, int]] = OrderedDict() + self._higgs_audio_v3_ref_code_cache_bytes = 0 + self._higgs_audio_v3_ref_code_inflight: dict[str, asyncio.Task[torch.Tensor]] = {} + + @property + def engine_client(self): + return self.ctx.engine_client + + async def _resolve_ref_audio(self, ref_audio: str): + return await self.ctx.server._resolve_ref_audio(ref_audio) + + def _get_resolved_ref_audio_artifact_key(self, cache_key: str): + return self.ctx.server._get_resolved_ref_audio_artifact_key(cache_key) + + async def _build_higgs_audio_v3_params(self, request: "OpenAICreateSpeechRequest"): + """Build prompt_token_ids for higgs_audio_v3. + + Plain-text path: builds ``[tts, text, tokens, audio]``. + Voice-clone path: encodes reference audio, applies delay pattern, and + records reference-audio prompt positions while submitting valid token IDs. + """ + adapter = await self._resolve_higgs_audio_v3_adapter() + + if request.ref_audio is None: + prompt_ids = adapter.build_prompt(request.input) + return tokens_input(prompt_token_ids=prompt_ids) + + # Voice clone + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + apply_delay_pattern, + encode_reference_audio, + ) + + wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key) + wav = np.asarray(wav_list, dtype=np.float32) + ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes( + artifact_key, + wav, + int(sr), + encode_reference_audio, + apply_delay_pattern, + ) + del cache_hit, inflight_wait + + prompt_ids = adapter.build_prompt( + request.input, + num_ref_tokens=int(ref_codes_delayed.shape[0]), + reference_text=request.ref_text or None, + ) + prompt_ids, audio_placeholder_positions = adapter.prepare_prompt_for_engine(prompt_ids) + prompt = tokens_input(prompt_token_ids=prompt_ids) + import torch + + prompt["additional_information"] = { + "audio_input_ids": ref_codes_delayed.to(torch.long), + "audio_input_ids_mask": torch.ones(ref_codes_delayed.shape[0], dtype=torch.bool), + "audio_placeholder_positions": audio_placeholder_positions, + "ref_audio_cache_key": cache_key, + } + prompt["cache_salt"] = conditioning_cache_salt(request, prompt["additional_information"]) + return prompt + + async def _resolve_higgs_audio_v3_ref_codes( + self, + artifact_key: str | None, + wav: np.ndarray, + sr: int, + encode_reference_audio, + apply_delay_pattern, + ) -> tuple[torch.Tensor, bool, bool]: + ref_codes_delayed = self._get_higgs_audio_v3_ref_codes(artifact_key) + if ref_codes_delayed is not None: + return ref_codes_delayed, True, False + if not artifact_key: + ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr) + return apply_delay_pattern(ref_codes_raw), False, False + + task = self._higgs_audio_v3_ref_code_inflight.get(artifact_key) + if task is not None: + return (await task).clone(), False, True + + async def _encode_and_cache() -> torch.Tensor: + ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr) + delayed = apply_delay_pattern(ref_codes_raw) + self._put_higgs_audio_v3_ref_codes(artifact_key, delayed) + cached = self._get_higgs_audio_v3_ref_codes(artifact_key) + return cached if cached is not None else delayed.detach().to("cpu", dtype=torch.long).contiguous() + + task = asyncio.create_task(_encode_and_cache()) + self._higgs_audio_v3_ref_code_inflight[artifact_key] = task + try: + return (await task).clone(), False, False + finally: + if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is task: + self._higgs_audio_v3_ref_code_inflight.pop(artifact_key, None) + + def _get_higgs_audio_v3_ref_codes(self, artifact_key: str | None) -> torch.Tensor | None: + if not artifact_key: + return None + cached = self._higgs_audio_v3_ref_code_cache.get(artifact_key) + if cached is None: + return None + self._higgs_audio_v3_ref_code_cache.move_to_end(artifact_key) + return cached[0].clone() + + def _put_higgs_audio_v3_ref_codes(self, artifact_key: str, codes: torch.Tensor) -> None: + if _REF_CODE_CACHE_MAX_ENTRIES <= 0 or _REF_CODE_CACHE_MAX_BYTES <= 0 or not artifact_key: + return + cached_codes = codes.detach().to("cpu", dtype=torch.long).contiguous() + size = int(cached_codes.numel() * cached_codes.element_size()) + if size > _REF_CODE_CACHE_MAX_BYTES: + return + previous = self._higgs_audio_v3_ref_code_cache.pop(artifact_key, None) + if previous is not None: + self._higgs_audio_v3_ref_code_cache_bytes -= previous[1] + self._higgs_audio_v3_ref_code_cache[artifact_key] = (cached_codes, size) + self._higgs_audio_v3_ref_code_cache_bytes += size + while len(self._higgs_audio_v3_ref_code_cache) > _REF_CODE_CACHE_MAX_ENTRIES: + _, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False) + self._higgs_audio_v3_ref_code_cache_bytes -= old_size + while self._higgs_audio_v3_ref_code_cache_bytes > _REF_CODE_CACHE_MAX_BYTES: + _, (_, old_size) = self._higgs_audio_v3_ref_code_cache.popitem(last=False) + self._higgs_audio_v3_ref_code_cache_bytes -= old_size + + async def _resolve_higgs_audio_v3_adapter(self): + """Lazy-load the tokenizer adapter for higgs_audio_v3.""" + cached = getattr(self, "_higgs_audio_v3_adapter", None) + if cached is not None: + return cached + + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.higgs_audio_v3.higgs_audio_v3_tokenizer import ( + HiggsAudioV3TokenizerAdapter, + ) + + model_path = None + for stage in self.engine_client.stage_configs: + model_path = getattr(getattr(stage, "engine_args", None), "model", None) + if model_path: + break + if model_path is None: + model_path = getattr(self.engine_client, "model", None) + if model_path is None: + raise RuntimeError("higgs_audio_v3 serving could not resolve model path") + tokenizer = AutoTokenizer.from_pretrained(model_path, trust_remote_code=True) + adapter = HiggsAudioV3TokenizerAdapter(tokenizer) + self._higgs_audio_v3_adapter = adapter + return adapter + stage_keys = frozenset({"higgs_audio_v3"}) name = "higgs_audio_v3" @@ -34,7 +202,7 @@ class HiggsAudioV3Adapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = await self.ctx.server._build_higgs_audio_v3_params(request) + prompt = await self._build_higgs_audio_v3_params(request) return PreparedRequest(prompt=prompt, tts_params={}, model_type="higgs_audio_v3") def apply_sampling_overrides( diff --git a/vllm_omni/entrypoints/openai/tts_adapters/ming_flash_omni_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/ming_flash_omni_tts.py index bc084d6b4..05cd3a64e 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/ming_flash_omni_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/ming_flash_omni_tts.py @@ -1,12 +1,15 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """MingFlashOmniTTS serving adapter.""" from typing import TYPE_CHECKING +from vllm.inputs import tokens_input from vllm.logger import init_logger from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest +from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import parse_ming_instruction_fields if TYPE_CHECKING: from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest @@ -61,8 +64,36 @@ class MingFlashOmniTTSAdapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: + """Build the standalone Ming Flash TTS prompt.""" + from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import ( + DEFAULT_PROMPT, + create_instruction, + ) + server = self.ctx.server - prompt = server._build_ming_flash_omni_prompt(request) + # ``instructions`` accepts either plain style text or a JSON object. + # Unknown caption keys are filtered by ``create_instruction``. + caption_fields = parse_ming_instruction_fields(server, request) or {} + has_spk_emb = request.speaker_embedding is not None + # Standalone TTS uses the Ming ``instruct`` task. ``voice_name`` lets + # the talker resolve registered presets such as DB30. + info = { + "ming_task": "instruct", + "prompt": DEFAULT_PROMPT, + "text": request.input, + "instruction": create_instruction(caption_fields), + "voice_name": request.voice or None, + "use_zero_spk_emb": not has_spk_emb, + "max_decode_steps": request.max_new_tokens or self.max_new_tokens_max, + "cfg": 2.0, + "sigma": 0.25, + "temperature": 0.0, + } + if has_spk_emb: + # Keep embeddings as plain floats for EngineCore serialization. + info["spk_emb"] = list(request.speaker_embedding) + prompt = tokens_input(prompt_token_ids=[0]) + prompt["additional_information"] = info return PreparedRequest(prompt=prompt, tts_params={}, model_type="ming_flash_omni_tts") def _load_supported_speakers(self) -> set[str]: diff --git a/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py index 613968b8c..8dc5731a0 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/ming_tts.py @@ -1,12 +1,17 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Ming-TTS (dense) serving adapter.""" +import json from typing import TYPE_CHECKING, Any +import torch +from vllm.inputs import tokens_input from vllm.logger import init_logger from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest +from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import DEFAULT_PROMPT as MING_DEFAULT_PROMPT from vllm_omni.model_executor.models.ming_tts.constants import SPEAKER_EMBEDDING_DIM if TYPE_CHECKING: @@ -15,8 +20,140 @@ if TYPE_CHECKING: logger = init_logger(__name__) +def parse_ming_instruction_fields(server, request, *, include_voice=False, plain_text_passthrough=False): + instruction_text = request.instructions.strip() if isinstance(request.instructions, str) else None + instruction_dict: dict[str, Any] = {} + voice_lower = request.voice.lower() if isinstance(request.voice, str) else None + if include_voice and request.voice and not (voice_lower and voice_lower in server.uploaded_speakers): + instruction_dict["IP"] = request.voice + if instruction_text: + try: + parsed = json.loads(instruction_text) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, dict): + instruction_dict.update(parsed) + elif instruction_dict or not plain_text_passthrough: + instruction_dict["风格"] = instruction_text + else: + return instruction_text + return instruction_dict or None + + @register_tts_adapter class MingTTSAdapter(ARTTSAdapter): + def _coerce_ming_prompt_waveform(self, wav_samples, sample_rate): + from torchaudio.functional import resample as resample_audio + + from vllm_omni.model_executor.models.ming_tts.config_ming_tts import SAMPLE_RATE + + waveform = torch.as_tensor(wav_samples, dtype=torch.float32).reshape(1, -1) + if int(sample_rate) != SAMPLE_RATE: + waveform = resample_audio(waveform, int(sample_rate), SAMPLE_RATE) + return waveform + + def _build_ming_reference_waveforms( + self, + ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None, + ) -> tuple[list[torch.Tensor], list[int]]: + if ref_audio_data is None: + return [], [] + items = ref_audio_data if isinstance(ref_audio_data, list) else [ref_audio_data] + waveforms = [torch.as_tensor(samples, dtype=torch.float32).reshape(1, -1) for samples, _ in items] + sample_rates = [int(sample_rate) for _, sample_rate in items] + if any(sample_rate <= 0 for sample_rate in sample_rates): + raise ValueError(f"Ming reference audio sample rates must be positive, got {sample_rates}") + return waveforms, sample_rates + + def _parse_ming_instruction(self, request: "OpenAICreateSpeechRequest") -> Any: + """Build a Ming instruction payload from OpenAI speech fields.""" + return parse_ming_instruction_fields( + self.ctx.server, + request, + include_voice=True, + plain_text_passthrough=True, + ) + + def _build_ming_dense_prompt( + self, + request: "OpenAICreateSpeechRequest", + *, + ref_audio_data: tuple[list[float], int] | list[tuple[list[float], int]] | None = None, + voice_name: str | None = None, + voice_created_at: int = 0, + ) -> dict[str, Any]: + """Build a Ming dense prompt directly from the OpenAI speech request.""" + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.ming_tts.config_ming_tts import ( + KEY_MAX_DECODE_STEPS, + KEY_SPEAKER_SAMPLE_RATES, + KEY_SPEAKER_WAVEFORM, + KEY_SPEAKER_WAVEFORM_LENGTHS, + ) + from vllm_omni.model_executor.models.ming_tts.prompt_assembly import build_ming_dense_prompt + + if self.ctx.server._tts_tokenizer is None: + model_name = self.ctx.engine_client.model_config.model + trust_remote_code = bool(getattr(self.ctx.engine_client.model_config, "trust_remote_code", False)) + self.ctx.server._tts_tokenizer = AutoTokenizer.from_pretrained( + model_name, trust_remote_code=trust_remote_code + ) + + ref_text = request.ref_text + reference_waveforms, reference_sample_rates = self._build_ming_reference_waveforms(ref_audio_data) + reference_waveform = torch.cat(reference_waveforms, dim=-1) if reference_waveforms else None + prompt_waveforms = ( + [ + self._coerce_ming_prompt_waveform(waveform, sample_rate) + for waveform, sample_rate in zip(reference_waveforms, reference_sample_rates, strict=True) + ] + if ref_text is not None + else [] + ) + prompt_waveform = torch.cat(prompt_waveforms, dim=-1) if prompt_waveforms else None + speaker_embedding = request.speaker_embedding + pending_speaker_extraction = speaker_embedding is None and reference_waveform is not None + use_zero_spk_emb = not pending_speaker_extraction and prompt_waveform is None and speaker_embedding is None + + runtime_controls = {} + if request.max_new_tokens is not None: + runtime_controls[KEY_MAX_DECODE_STEPS] = request.max_new_tokens + + prompt_dict = build_ming_dense_prompt( + self.ctx.server._tts_tokenizer, + # bgm / music-prompt mode not supported online; + # requires prompt_mode API extension (deferred). + prompt=MING_DEFAULT_PROMPT, + text=request.input, + runtime_controls=runtime_controls or None, + instruction=self._parse_ming_instruction(request), + prompt_text=ref_text, + prompt_waveform=prompt_waveform, + speaker_embedding=speaker_embedding, + speaker_count=len(reference_waveforms) if pending_speaker_extraction else None, + use_zero_spk_emb=use_zero_spk_emb, + ) + additional_information = prompt_dict["additional_information"] + if pending_speaker_extraction: + additional_information[KEY_SPEAKER_WAVEFORM] = reference_waveform + additional_information[KEY_SPEAKER_WAVEFORM_LENGTHS] = torch.tensor( + [int(waveform.shape[-1]) for waveform in reference_waveforms], + dtype=torch.int32, + ) + additional_information[KEY_SPEAKER_SAMPLE_RATES] = torch.tensor( + reference_sample_rates, + dtype=torch.int32, + ) + if voice_name: + additional_information["voice_name"] = voice_name + additional_information["voice_created_at"] = int(voice_created_at) + prompt = tokens_input(prompt_token_ids=prompt_dict["prompt_token_ids"]) + prompt["prompt"] = prompt_dict["prompt"] + prompt["text"] = prompt_dict["text"] + prompt["additional_information"] = prompt_dict["additional_information"] + return prompt + # Ming dense has no dedicated model_stage value, so both stage discovery and # model-type detection go through the architecture. ``ming_flash_omni_tts`` # is the model that owns the ``ming_tts`` *stage key*, and is matched first. @@ -66,7 +203,7 @@ class MingTTSAdapter(ARTTSAdapter): elif ref_audio_source is not None and isinstance(ref_audio_source, str): wav_list, sr, _ = await server._resolve_ref_audio(ref_audio_source) ref_audio_data = (wav_list, sr) - prompt = server._build_ming_dense_prompt( + prompt = self._build_ming_dense_prompt( request, ref_audio_data=ref_audio_data, voice_name=uploaded_audio_voice, diff --git a/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py index 3d6e99552..e264c7af5 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py @@ -6,7 +6,7 @@ Both variants share the same build/validate flow (``_build_moss_tts_params`` handles each); they are registered under distinct model-type names. """ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from vllm.inputs import tokens_input @@ -23,6 +23,255 @@ if TYPE_CHECKING: class _MossTTSAdapterBase(ARTTSAdapter): + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._moss_variant = None if self.name == "moss_tts_nano" else self._detect_moss_variant() + self._moss_processor_cache = None + + @property + def engine_client(self): + return self.ctx.engine_client + + @property + def uploaded_speakers(self): + return self.ctx.server.uploaded_speakers + + @property + def _speaker_cache(self): + return self.ctx.server._speaker_cache + + async def _resolve_ref_audio(self, ref_audio: str): + return await self.ctx.server._resolve_ref_audio(ref_audio) + + def _voice_created_at(self, voice: str) -> int: + return self.ctx.server._voice_created_at(voice) + + def _get_resolved_ref_audio_artifact_key(self, cache_key: str): + return self.ctx.server._get_resolved_ref_audio_artifact_key(cache_key) + + def _get_moss_ref_encoder(self): + """Lazily build the per-server MOSS reference-audio encoder once.""" + cached = getattr(self, "_moss_ref_encoder", None) + if cached is not None: + return cached + from vllm_omni.model_executor.models.moss_tts.reference_encoder import build_reference_encoder + + # The variant's encode geometry (n_vq, working sample rate) is derived + # inside the model package; this layer only supplies the processor and + # the process-wide speaker cache. + encoder = build_reference_encoder( + self._get_moss_processor(), + variant=self._moss_variant, + speaker_cache=self._speaker_cache, + ) + self._moss_ref_encoder = encoder + return encoder + + async def _encode_moss_references( + self, + request: "OpenAICreateSpeechRequest", + *, + has_inline_ref_audio: bool, + two_speaker: bool, + ) -> tuple[list, dict[int, str]]: + """Encode the request's reference clip(s) into MOSS RVQ code tensors. + + Reference encoding + speaker caching lives in the model package + (moss_tts.reference_encoder), mirroring Fish Speech / CosyVoice3 / + Qwen3-TTS which keep reference handling with the model rather than in + this shared serving file. This method only resolves the serving-side + inputs the model helper needs: the generic audio resolver/artifact-key + lookups and whether the request may use the named-voice cache. + + Returns ``(codes_per_speaker, resolve_keys)`` where ``resolve_keys`` + maps the reference slot (0 = ref_audio, 1 = ref_audio_2) to its + content-aware resolve key, for salting the KV prefix cache. + """ + from vllm_omni.model_executor.models.moss_tts.reference_encoder import encode_request_references + + # Named-voice caching is only valid for uploaded speakers without an + # inline ref_audio: ``request.voice`` plus a file/URL would otherwise + # key on (name, created_at=0) and skip the content-aware resolve. + raw_voice = getattr(request, "voice", None) + raw_voice = raw_voice.strip() if isinstance(raw_voice, str) else "" + voice_lower = raw_voice.lower() + use_named_voice = bool(voice_lower) and voice_lower in self.uploaded_speakers and not has_inline_ref_audio + voice = voice_lower if use_named_voice else "" + voice_created = self._voice_created_at(voice) if voice else 0 + + return await encode_request_references( + self._get_moss_ref_encoder(), + request.ref_audio, + request.ref_audio_2 if two_speaker else None, + resolve_ref_audio=self._resolve_ref_audio, + get_artifact_key=self._get_resolved_ref_audio_artifact_key, + voice_name=voice or None, + voice_created_at=voice_created, + ) + + def _detect_moss_variant(self) -> str: + """Sub-classify a ``moss_tts``-stage server into the actual MOSS-TTS + variant family (tts, ttsd, sound_effect, voice_generator, realtime). + + Detection key is the HF repo path / model_name; matches + ``_try_resolve_omni_model_type`` in entrypoints/utils.py so users get + consistent behaviour no matter how they launched the server (--model + OpenMOSS-Team/MOSS-TTSD-v1.0 vs --deploy-config moss_ttsd.yaml). + """ + try: + name = (self.engine_client.model_config.model or "").lower().replace("-", "").replace("_", "") + except Exception: + name = "" + if "realtime" in name: + return "realtime" + if "local" in name: + return "local" + if "ttsd" in name: + return "ttsd" + if "soundeffect" in name: + return "sound_effect" + if "voicegenerator" in name: + return "voice_generator" + return "tts" + + def _get_moss_processor(self): + """Lazily load the upstream MOSS-TTS processor once per server. + + Cached on ``self._moss_processor_cache``. The processor owns its own + audio_tokenizer (~1.6 B params); we keep it on CPU so it doesn't + compete with the talker (~8 GiB) and codec (~7 GiB) for our 96 GiB + GPU — per-request ref-audio encoding is fast enough on CPU. + """ + cached = getattr(self, "_moss_processor_cache", None) + if cached is not None: + return cached + from transformers import AutoProcessor + + model_id = self.engine_client.model_config.model + proc = AutoProcessor.from_pretrained(model_id, trust_remote_code=True) + if hasattr(proc, "audio_tokenizer"): + proc.audio_tokenizer = proc.audio_tokenizer.to("cpu").eval() + self._moss_processor_cache = proc + return proc + + async def _build_moss_tts_params( + self, + request: "OpenAICreateSpeechRequest", + *, + has_inline_ref_audio: bool = False, + ) -> dict[str, Any]: + """Build the talker prompt + ``additional_information`` payload for any + MOSS-TTS-family request (nano + 5 full variants). + + For the legacy ``moss_tts_nano`` model_type, keeps the original nano + contract (``{text, mode=voice_clone, prompt_audio_array}``); the + caller still uses a ``[1]`` placeholder prompt. + + For the full MOSS-TTS family (``MossTTSDelay*`` / ``MossTTSRealtime*``) + we **call the upstream processor server-side** to produce the unified + ``(text_ids, audio_codes)`` shape the talker actually consumes — same + flow as ``examples/.../moss_tts/end2end.py:_build_unified_codes``. + Returns ``{prompt_token_ids: list[int], codes.ref: torch.LongTensor, + max_new_frames, ...}``. The caller treats ``prompt_token_ids`` as the + prompt and forwards the rest as ``additional_information``. + """ + import torch # local to avoid pulling torch at module import time + + v = self._moss_variant + + # ---- Legacy nano path (unchanged) ---- + if v is None: # moss_tts_nano + params: dict[str, Any] = { + "text": [request.input or ""], + "mode": ["voice_clone"], + } + if request.max_new_tokens is not None: + params["max_new_frames"] = [request.max_new_tokens] + wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + params["prompt_audio_array"] = [[wav_list, sr]] + params["ref_audio_cache_key"] = cache_key + return params + + # ---- MOSS-TTS-Realtime: keep the old prompt_audio_array path ---- + # ``AutoProcessor.from_pretrained`` doesn't auto-discover + # ``MossTTSRealtimeProcessor`` (no ``processor_config.json`` in the + # snapshot), and Realtime's prompt format diverges from MossTTSDelay + # (16-channel grid, separate per-step text feed). The + # ``prompt_audio_array`` shape lines up well enough with what the + # talker reads for short prompts; full Realtime support needs a + # separate processor.from_module path which we don't wire here. + if v == "realtime": + params: dict[str, Any] = { + "text": [request.input or ""], + "mode": ["voice_clone"], + } + if request.max_new_tokens is not None: + params["max_new_frames"] = [request.max_new_tokens] + wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + params["prompt_audio_array"] = [[wav_list, sr]] + params["ref_audio_cache_key"] = cache_key + return params + + # ---- MossTTSDelay family (tts/ttsd/sound_effect/voice_generator) + # and MOSS-TTS-Local-Transformer-v1.5: call the upstream processor + # server-side to produce unified codes. Local-v1.5 ships its own + # AutoProcessor (processor_config.json + processing_moss_tts.py) and + # reuses this exact build_user_message/encode_audios_from_wav path in + # the offline example (examples/.../moss_tts/end2end.py: + # _build_unified_codes) -- it is NOT in the same boat as Realtime + # (no processor_config.json there), so it must not fall back to the + # prompt_audio_array path above (which the talker's preprocess() + # never reads -- info_dict["codes"]["ref"] is the only thing it + # consumes, so skipping this path silently drops all voice-clone + # conditioning and produces unconditioned/garbage audio online). ---- + proc = self._get_moss_processor() + + user_kwargs: dict[str, Any] = {"text": request.input or ""} + resolve_keys: dict[int, str] = {} + if v in ("tts", "local", "ttsd"): + user_kwargs["reference"], resolve_keys = await self._encode_moss_references( + request, + has_inline_ref_audio=has_inline_ref_audio, + two_speaker=(v == "ttsd"), + ) + elif v == "sound_effect": + user_kwargs["text"] = request.input or "" # may be empty + user_kwargs["ambient_sound"] = request.ambient_sound or "" + if request.duration_seconds is not None: + user_kwargs["tokens"] = max(1, int(float(request.duration_seconds) * 12.5)) + elif request.max_new_tokens is not None: + user_kwargs["tokens"] = int(request.max_new_tokens) + elif v == "voice_generator": + user_kwargs["instruction"] = request.instructions or "" + + # Optional language tag for the spoken-text variants. MOSS-TTS-v1.5's + # headline improvement is multilingual synthesis when the language is + # given (build_user_message(..., language=...)); 1.0 ignores it + # gracefully. Sound-effect output is non-verbal, so skip it there. + if v in ("tts", "local", "ttsd", "voice_generator") and getattr(request, "language", None): + user_kwargs["language"] = request.language + + # Build the unified-codes prompt: (L, 1+n_vq) where col 0 is text/special + # tokens and cols 1..n_vq are the delay-pattern audio code grid (mostly + # audio_pad_code outside the reference block). + user_msg = proc.build_user_message(**user_kwargs) + batch = proc(conversations=[[user_msg]], mode="generation") + unified = batch["input_ids"][0] # torch.LongTensor (L, 1+n_vq) + text_ids: list[int] = unified[:, 0].tolist() + audio_codes: torch.Tensor = unified[:, 1:].contiguous().to(torch.int64) + + params: dict[str, Any] = { + "prompt_token_ids": text_ids, + "codes": {"ref": audio_codes}, + } + if request.max_new_tokens is not None: + params["max_new_frames"] = [request.max_new_tokens] + if 0 in resolve_keys: + params["ref_audio_cache_key"] = resolve_keys[0] + if 1 in resolve_keys: + params["ref_audio_2_cache_key"] = resolve_keys[1] + return params + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate any MOSS-TTS-family request (nano + 5 full variants). @@ -42,10 +291,10 @@ class _MossTTSAdapterBase(ARTTSAdapter): if not request.input or not request.input.strip(): # SoundEffect can legitimately have empty input (just ambient_sound). - if server._moss_variant != "sound_effect": + if self._moss_variant != "sound_effect": return "Input text cannot be empty" - v = server._moss_variant + v = self._moss_variant if v in (None, "tts", "realtime", "local"): if request.ref_audio is None: label = ( @@ -92,7 +341,7 @@ class _MossTTSAdapterBase(ARTTSAdapter): self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: server = self.ctx.server - tts_params = await server._build_moss_tts_params(request, has_inline_ref_audio=has_inline_ref_audio) + tts_params = await self._build_moss_tts_params(request, has_inline_ref_audio=has_inline_ref_audio) if request.voice: voice_lower = request.voice.lower() if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: diff --git a/vllm_omni/entrypoints/openai/tts_adapters/qwen3_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/qwen3_tts.py index 48d019eeb..3a2bb9860 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/qwen3_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/qwen3_tts.py @@ -2,12 +2,15 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Qwen3-TTS serving adapter.""" +import math from collections.abc import Mapping from pathlib import Path from typing import TYPE_CHECKING, Any import regex as re +from vllm.inputs import tokens_input from vllm.logger import init_logger +from vllm.utils.async_utils import make_async from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ( @@ -15,6 +18,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.base import ( ARTTSAdapter, PreparedRequest, TTSGenerationError, + conditioning_cache_salt, ) from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_precomputed_speakers from vllm_omni.utils.speaker_cache import validate_qwen3_tts_profile @@ -23,6 +27,7 @@ if TYPE_CHECKING: from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest logger = init_logger(__name__) +_REF_AUDIO_CACHE_KEY = "_qwen3_tts_ref_audio_cache_key" QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY = "_qwen3_tts_effective_max_tokens" _MIN_CODEC_FRAMES = 192 @@ -45,6 +50,33 @@ class Qwen3TTSAdapter(ARTTSAdapter): name = "qwen3_tts" supported_output_sample_rates = frozenset({8000, 24000}) + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._estimate_prompt_len_async = make_async( + self._estimate_prompt_len, executor=getattr(ctx.server, "_tts_executor", None) + ) + + def _estimate_ref_code_len(self, ref_audio: object) -> int | None: + codec_frame_rate = self.capabilities.codec_frame_rate + if codec_frame_rate is None: + return None + try: + item = ref_audio + while isinstance(item, list) and item: + if len(item) == 2 and isinstance(item[1], (int, float)): + break + item = item[0] + if not isinstance(item, (list, tuple)) or len(item) != 2: + return None + wav, sr = item + sr = int(sr) + n_samples = len(wav) if hasattr(wav, "__len__") else wav.shape[-1] + if sr <= 0 or n_samples <= 0: + return None + return math.ceil(n_samples / sr * codec_frame_rate) + except Exception: + return None + def _get_model_variant(self) -> str | None: """Return the task supported by the loaded Qwen3-TTS checkpoint. @@ -223,15 +255,208 @@ class Qwen3TTSAdapter(ARTTSAdapter): return None + def _build_tts_params(self, request: "OpenAICreateSpeechRequest") -> dict[str, Any]: + """Build TTS parameters from request. + + Processes each parameter if present, skips if not. + Values are wrapped in lists as required by the model. + """ + server = self.ctx.server + + params: dict[str, Any] = {} + + # Text content (always required) + params["text"] = [request.input] + + # Task type + if request.task_type is not None: + params["task_type"] = [request.task_type] + else: + params["task_type"] = ["CustomVoice"] + + # Language + if request.language is not None: + params["language"] = [request.language] + else: + params["language"] = ["Auto"] + + # Speaker (voice) + if request.voice is not None: + voice_lower = request.voice.lower() + precomputed_speakers = self.capabilities.precomputed_speakers + params["speaker"] = [request.voice] + params["voice_created_at"] = [server._voice_created_at(voice_lower)] + + # Uploaded voices use task_type="Base" (CustomVoice requires built-in spk_id). + # If ref_text was provided at upload time, use in-context cloning; otherwise x_vector only. + if voice_lower in server.uploaded_speakers and request.ref_audio is None: + speaker_info = server.uploaded_speakers[voice_lower] + + # Check if this voice was uploaded with a pre-computed embedding. + # Populate request.speaker_embedding so the existing code path + # (below) handles voice_clone_prompt and x_vector_only_mode. + embedding = server._get_uploaded_speaker_embedding(request.voice) + if embedding is not None: + request.speaker_embedding = embedding + params["speaker"] = [voice_lower] + params["task_type"] = ["Base"] + logger.info("Auto-set speaker_embedding for uploaded voice: %s", request.voice) + else: + audio_data = server._get_uploaded_audio_data(request.voice) + if not audio_data: + raise ValueError(f"Audio file for uploaded voice '{request.voice}' is missing or corrupted") + stored_ref_text = speaker_info.get("ref_text") + params["speaker"] = [voice_lower] + params["ref_audio"] = [audio_data] + params["task_type"] = ["Base"] + if stored_ref_text: + params["ref_text"] = [stored_ref_text] + params["x_vector_only_mode"] = [False] + else: + params["x_vector_only_mode"] = [True] + logger.info( + "Auto-set ref_audio for uploaded voice: %s (icl=%s)", request.voice, bool(stored_ref_text) + ) + elif voice_lower in precomputed_speakers and request.ref_audio is None: + profile = precomputed_speakers[voice_lower] + mode = str(profile.get("mode") or "xvec").lower() + params["speaker"] = [voice_lower] + params["task_type"] = ["Base"] + params["x_vector_only_mode"] = [mode != "icl"] + ref_text = request.ref_text or profile.get("ref_text") + if isinstance(ref_text, str) and ref_text.strip(): + params["ref_text"] = [ref_text] + ref_code_length = profile.get("ref_code_length") + if mode == "icl" and ref_code_length: + params["ref_code_length"] = [int(ref_code_length)] + logger.info("Using precomputed Qwen3-TTS custom voice profile: %s (mode=%s)", voice_lower, mode) + + elif params["task_type"][0] == "CustomVoice": + params["speaker"] = ["Vivian"] # Default for CustomVoice + + # Instructions for style/emotion control + if request.instructions is not None: + params["instruct"] = [request.instructions] + else: + params["instruct"] = [""] + + # Voice clone: ref_audio resolved in create_speech(), not here. + if request.ref_text is not None: + params["ref_text"] = [request.ref_text] + if request.speaker_embedding is not None: + # Store as plain float list (not tensor) so it survives msgspec + # serialization through the EngineCore IPC boundary. The talker's + # _build_prompt_embeds converts it back to a tensor on the GPU. + params["voice_clone_prompt"] = [ + { + "ref_spk_embedding": list(request.speaker_embedding), + } + ] + # speaker_embedding implies x_vector_only_mode + params["x_vector_only_mode"] = [True] + elif request.x_vector_only_mode is not None: + params["x_vector_only_mode"] = [request.x_vector_only_mode] + + # Generation parameters + if request.max_new_tokens is not None: + params["max_new_tokens"] = [request.max_new_tokens] + else: + params["max_new_tokens"] = [2048] + + if request.initial_codec_chunk_frames is not None: + params["initial_codec_chunk_frames"] = [request.initial_codec_chunk_frames] + + if request.non_streaming_mode is not None: + params["non_streaming_mode"] = [request.non_streaming_mode] + # Preserve the legacy VoiceDesign fallback when the request omits an + # explicit override. CustomVoice and Base rely on model defaults + # (True and False respectively). + elif params["task_type"][0] == "VoiceDesign": + params["non_streaming_mode"] = [True] + + return params + + def _estimate_prompt_len(self, tts_params: dict[str, Any]) -> int: + """Estimate prompt length so the placeholder matches model-side embeddings.""" + try: + from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import ( + Qwen3TTSPromptEmbedsBuilder, + ) + + server = self.ctx.server + if server._tts_tokenizer is None: + from transformers import AutoTokenizer + + model_name = self.ctx.engine_client.model_config.model + server._tts_tokenizer = AutoTokenizer.from_pretrained( + model_name, + trust_remote_code=True, + padding_side="left", + ) + hf_config = self.ctx.engine_client.model_config.hf_config + talker_config = hf_config.talker_config + task_type = (tts_params.get("task_type") or ["CustomVoice"])[0] + return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information( + additional_information=tts_params, + task_type=task_type, + tokenize_prompt=lambda t: server._tts_tokenizer(t, padding=False)["input_ids"], + codec_language_id=getattr(talker_config, "codec_language_id", None), + spk_is_dialect=getattr(talker_config, "spk_is_dialect", None), + estimate_ref_code_len=self._estimate_ref_code_len, + ) + except Exception as e: + logger.warning("Failed to estimate TTS prompt length, using fallback 2048: %s", e) + return 2048 + + def _qwen3_tts_can_use_ref_audio_artifact_only(self, tts_params: dict[str, Any], artifact_key: str | None) -> bool: + server = self.ctx.server + x_vector_only = server._tts_x_vector_only(tts_params) + if not artifact_key or (artifact_key, x_vector_only) not in server._ref_audio_model_artifact_ready: + return False + return (tts_params.get("task_type") or ["CustomVoice"])[0] == "Base" + async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt, tts_params, warmup_key = await self.ctx.server._build_qwen3_tts_request(request) + """Build prompt + tts_params for Qwen3-TTS. + + Called from ``Qwen3TTSAdapter.build``. Returns + ``(prompt, tts_params, warmup_artifact_key)`` where the warmup key is the + Qwen3-TTS ref-audio artifact tracked after ``generate()``. + """ + server = self.ctx.server + qwen3_ref_audio_warmup_artifact_key: str | None = None + tts_params = self._build_tts_params(request) + # Resolve ref_audio (explicit or auto-set for uploaded voices) + # to [[wav_list, sr]] so the model doesn't re-decode base64. + ref_audio_source = request.ref_audio + if ref_audio_source is None and isinstance(tts_params.get("ref_audio"), list): + # Uploaded voice: ref_audio was auto-set as [base64_data_url] + ref_audio_source = tts_params["ref_audio"][0] + if ref_audio_source is not None and isinstance(ref_audio_source, str): + wav_list, sr, cache_key = await server._resolve_ref_audio(ref_audio_source) + tts_params["ref_audio_cache_key"] = cache_key + artifact_key = server._get_resolved_ref_audio_artifact_key(cache_key) + if artifact_key: + tts_params[_REF_AUDIO_CACHE_KEY] = [artifact_key] + ref_code_length = self._estimate_ref_code_len([wav_list, sr]) + if ref_code_length is not None: + tts_params["ref_code_length"] = [int(ref_code_length)] + if self._qwen3_tts_can_use_ref_audio_artifact_only(tts_params, artifact_key): + logger.debug("Using Qwen3-TTS ref_audio artifact-only path: %s", artifact_key) + else: + tts_params["ref_audio"] = [[wav_list, sr]] + qwen3_ref_audio_warmup_artifact_key = artifact_key + + ph_len = await self._estimate_prompt_len_async(tts_params) + prompt = tokens_input(prompt_token_ids=[1] * ph_len) + prompt["additional_information"] = tts_params + prompt["cache_salt"] = conditioning_cache_salt(request, tts_params) return PreparedRequest( prompt=prompt, tts_params=tts_params, model_type=tts_params.get("task_type", ["unknown"])[0], - warmup_artifact_key=warmup_key, + warmup_artifact_key=qwen3_ref_audio_warmup_artifact_key, ) def _get_expected_speaker_embedding_dim(self) -> int: @@ -340,6 +565,16 @@ class Qwen3TTSAdapter(ARTTSAdapter): if isinstance(additional_information, dict) and effective_cap is not None: additional_information[QWEN3_TTS_EFFECTIVE_MAX_TOKENS_KEY] = [effective_cap] + # Propagate the deploy/YAML stage seed to residual MTP sampling. The + # generic serving layer applies an explicit request.seed afterwards, + # so the request value still has higher precedence. + stage0_params = sampling_params_list[0] + default_seed = getattr(stage0_params, "seed", None) + if default_seed is not None: + if stage0_params.extra_args is None: + stage0_params.extra_args = {} + stage0_params.extra_args.setdefault("tts_local_seed", int(default_seed)) + logger.debug( "Qwen3-TTS codec budget: task_type=%s text_tokens=%s dynamic_cap=%s " "configured_cap=%s request_cap=%s effective_cap=%s", diff --git a/vllm_omni/entrypoints/openai/tts_adapters/step_audio2.py b/vllm_omni/entrypoints/openai/tts_adapters/step_audio2.py index 2c720d8e1..7ca5a44ff 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/step_audio2.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/step_audio2.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Step-Audio2 serving adapter.""" from typing import TYPE_CHECKING @@ -30,5 +31,17 @@ class StepAudio2Adapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = self.ctx.server._build_step_audio2_prompt(request) + """Build a chat prompt ending at ````. + + The assistant turn deliberately omits ``<|im_end|>`` so the thinker + continues by generating audio tokens. + """ + system_prompt = request.instructions or "You are a voice assistant. Read the text aloud." + prompt = { + "prompt": ( + f"<|im_start|>system\n{system_prompt}<|im_end|>\n" + f"<|im_start|>user\n{request.input}<|im_end|>\n" + "<|im_start|>assistant\n" + ) + } return PreparedRequest(prompt=prompt, tts_params={}, model_type="step_audio2") diff --git a/vllm_omni/entrypoints/openai/tts_adapters/voxcpm2.py b/vllm_omni/entrypoints/openai/tts_adapters/voxcpm2.py index 760bb3339..f4574004e 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/voxcpm2.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/voxcpm2.py @@ -1,9 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """VoxCPM2 serving adapter (AR base-LM + diffusion side-computation).""" import time from typing import Any +import numpy as np from vllm.logger import init_logger from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest @@ -28,6 +30,63 @@ class VoxCPM2Adapter(ARTTSAdapter): # claims still resolves to VoxCPM2. detect_priority = 10 + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._tokenizer = None + self._split_map: dict[int, list[int]] = {} + + def _encode(self, text: str) -> list[int]: + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import ( + build_cjk_split_map, + split_multichar_chinese, + ) + + if self._tokenizer is None: + self._tokenizer = AutoTokenizer.from_pretrained( + self.ctx.engine_client.model_config.model, trust_remote_code=True + ) + self._split_map = build_cjk_split_map(self._tokenizer) + logger.info("VoxCPM2 serving: built multichar split map (%d entries)", len(self._split_map)) + ids = self._tokenizer.encode(text, add_special_tokens=True) + return split_multichar_chinese(ids, self._split_map) + + async def _build_prompt( + self, + request: "OpenAICreateSpeechRequest", + uploaded_ref: tuple[np.ndarray, int] | None = None, + ) -> dict[str, Any]: + """Build the full-length VoxCPM2 prefill prompt. + + ``uploaded_ref`` supplies an uploaded voice waveform when the request + has no explicit ``ref_audio`` so prefill-length accounting includes it. + """ + from vllm_omni.model_executor.models.voxcpm2.voxcpm2_talker import build_voxcpm2_prompt + + server = self.ctx.server + self._encode("") + ref_audio = None + ref_sr = None + voice_profile = None + if request.ref_audio is not None: + ref_audio, ref_sr, _ = await server._resolve_ref_audio(request.ref_audio) + elif uploaded_ref is not None: + wav_np, ref_sr = uploaded_ref + ref_audio = wav_np.tolist() + elif request.voice is not None: + voice_profile = self.capabilities.precomputed_speakers.get(request.voice.lower()) + return build_voxcpm2_prompt( + hf_config=self.ctx.engine_client.model_config.hf_config, + tokenizer=self._tokenizer, + split_map=self._split_map, + text=request.input, + ref_audio=ref_audio, + ref_sr=ref_sr, + ref_text=request.ref_text, + voice_profile=voice_profile, + ) + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate VoxCPM2 request parameters. Returns error message or None.""" server = self.ctx.server @@ -66,7 +125,7 @@ class VoxCPM2Adapter(ARTTSAdapter): ) if request.ref_audio is None: uploaded_ref = server._load_uploaded_audio(voice_lower) - prompt = await server._build_voxcpm2_prompt(request, uploaded_ref=uploaded_ref) + prompt = await self._build_prompt(request, uploaded_ref=uploaded_ref) tts_params = {} if request.voice: voice_lower = request.voice.lower() diff --git a/vllm_omni/entrypoints/openai/tts_adapters/voxtral.py b/vllm_omni/entrypoints/openai/tts_adapters/voxtral.py index c9bea7bb0..f9d7639ca 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/voxtral.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/voxtral.py @@ -1,8 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Voxtral TTS serving adapter.""" from typing import TYPE_CHECKING, Any +from vllm.utils.async_utils import make_async + from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter from vllm_omni.entrypoints.openai.tts_adapters.base import ARTTSAdapter, PreparedRequest, apply_max_new_tokens from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supported_speakers @@ -16,6 +19,39 @@ class VoxtralTTSAdapter(ARTTSAdapter): stage_keys = frozenset({"audio_generation"}) name = "voxtral_tts" + def __init__(self, ctx) -> None: + super().__init__(ctx) + self._build_prompt_async = make_async(self._build_prompt, executor=getattr(ctx.server, "_tts_executor", None)) + + def _build_prompt(self, request: "OpenAICreateSpeechRequest") -> dict[str, Any]: + """Build a Voxtral prompt for either a preset voice or inline reference audio.""" + from mistral_common.protocol.speech.request import SpeechRequest + from vllm.inputs import tokens_input + + ref_audio = request.ref_audio + if not request.voice and not ref_audio: + raise ValueError("Voxtral requires either a voice name or ref_audio.") + if isinstance(ref_audio, str) and ref_audio.startswith("data:"): + _, _, ref_audio = ref_audio.partition(",") + server = self.ctx.server + if server._tts_tokenizer is None: + from vllm.tokenizers import cached_tokenizer_from_config + + server._tts_tokenizer = cached_tokenizer_from_config(self.ctx.engine_client.model_config).instruct + if request.voice is not None: + tokenized = server._tts_tokenizer.encode_speech_request( + SpeechRequest(input=request.input, voice=request.voice) + ) + prompt = tokens_input(prompt_token_ids=tokenized.tokens) + prompt["additional_information"] = {"voice": [request.voice]} + return prompt + tokenized = server._tts_tokenizer.encode_speech_request(SpeechRequest(input=request.input, ref_audio=ref_audio)) + audio = tokenized.audios[0] + return { + "prompt_token_ids": tokenized.tokens, + "multi_modal_data": {"audio": [(audio.audio_array, audio.sampling_rate)]}, + } + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: """Validate Voxtral TTS request parameters. Returns error message or None.""" server = self.ctx.server @@ -48,7 +84,7 @@ class VoxtralTTSAdapter(ARTTSAdapter): async def build( self, request: "OpenAICreateSpeechRequest", sampling_params_list: list, has_inline_ref_audio: bool ) -> PreparedRequest: - prompt = await self.ctx.server._build_voxtral_prompt_async(request) + prompt = await self._build_prompt_async(request) return PreparedRequest(prompt=prompt, tts_params={}, model_type="voxtral_tts") def _load_supported_speakers(self) -> set[str]: diff --git a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py index f8dc962f3..5febb8912 100644 --- a/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py +++ b/vllm_omni/model_executor/models/voxcpm2/voxcpm2_talker.py @@ -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 """VoxCPM2 AR talker — PagedAttention pipeline with per-request state. Architecture: @@ -158,7 +158,7 @@ def build_voxcpm2_prompt( """Build a VoxCPM2 prefill prompt whose ``prompt_token_ids`` length matches the talker-side prefill length. - Used by both online serving (``serving_speech._build_voxcpm2_prompt``) and + Used by both online serving (``VoxCPM2Adapter._build_prompt``) and the offline example, so the talker-side length assertion never fires. """ ids = split_multichar_chinese(tokenizer.encode(text, add_special_tokens=True), split_map) @@ -2208,7 +2208,7 @@ class VoxCPM2TalkerForConditionalGeneration(nn.Module): assert scaffold_len == tts_len, ( f"voxcpm2 prefill length mismatch: scaffold_len={scaffold_len} tts_len={tts_len}; " "caller must pad prompt_token_ids to the full prefill length " - "(see serving_speech._build_voxcpm2_prompt or the offline example)." + "(see VoxCPM2Adapter._build_prompt or the offline example)." ) enc_out = base_lm_out.unsqueeze(0) From e1c59edec66d11d830724f96d205ed62130a0fa4 Mon Sep 17 00:00:00 2001 From: Yueqian Lin <70319226+linyueqian@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:22:36 -0700 Subject: [PATCH 29/90] [Refactor] fish_speech: vendor DAC codec modules, drop external fish-speech dependency (#4871) Signed-off-by: Yueqian Lin --- .../offline_inference/text_to_speech.md | 6 +- .../examples/online_serving/text_to_speech.md | 9 +- .../text_to_speech/README.md | 6 +- .../online_serving/text_to_speech/README.md | 9 +- pyproject.toml | 4 + recipes/fishaudio/Fish-Speech-S2-Pro.md | 13 +- .../models/fish_speech/test_dac_modules.py | 110 +++++ .../fish_speech/dac_modules/__init__.py | 27 ++ .../models/fish_speech/dac_modules/codec.py | 335 ++++++++++++++ .../models/fish_speech/dac_modules/conv.py | 194 ++++++++ .../models/fish_speech/dac_modules/layers.py | 238 ++++++++++ .../models/fish_speech/dac_modules/rvq.py | 177 ++++++++ .../fish_speech/dac_modules/transformer.py | 419 ++++++++++++++++++ .../models/fish_speech/dac_utils.py | 4 +- .../fish_speech/fish_speech_dac_decoder.py | 4 +- 15 files changed, 1517 insertions(+), 38 deletions(-) create mode 100644 tests/model_executor/models/fish_speech/test_dac_modules.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/__init__.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/codec.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/conv.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/layers.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/rvq.py create mode 100644 vllm_omni/model_executor/models/fish_speech/dac_modules/transformer.py diff --git a/docs/user_guide/examples/offline_inference/text_to_speech.md b/docs/user_guide/examples/offline_inference/text_to_speech.md index a6544c1e5..95ffc5543 100644 --- a/docs/user_guide/examples/offline_inference/text_to_speech.md +++ b/docs/user_guide/examples/offline_inference/text_to_speech.md @@ -126,11 +126,7 @@ python examples/offline_inference/text_to_speech/cosyvoice3/end2end.py \ ## Fish Speech S2 Pro 4B dual-AR text-to-speech model from FishAudio with the DAC codec at 44.1 kHz. - -### Prerequisites -```bash -pip install fish-speech -``` +No extra packages are required; the DAC codec is vendored in vLLM-Omni. ### Quick start ```bash diff --git a/docs/user_guide/examples/online_serving/text_to_speech.md b/docs/user_guide/examples/online_serving/text_to_speech.md index f1b643ac7..9a33dc858 100644 --- a/docs/user_guide/examples/online_serving/text_to_speech.md +++ b/docs/user_guide/examples/online_serving/text_to_speech.md @@ -155,13 +155,8 @@ The client supports `--api-base`, `--model`, `--text`, `--ref-audio`, `--ref-tex ## Fish Speech S2 Pro -4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec. - -### Prerequisites - -```bash -pip install fish-speech -``` +4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec, which is vendored in +vLLM-Omni (no extra packages required). ### Launch diff --git a/examples/offline_inference/text_to_speech/README.md b/examples/offline_inference/text_to_speech/README.md index c21bff5fe..da00ea44d 100644 --- a/examples/offline_inference/text_to_speech/README.md +++ b/examples/offline_inference/text_to_speech/README.md @@ -130,11 +130,7 @@ Text → [Stage 0: AR] → Speech Tokens → [Stage 1: DiT + HiFT] → Audio (24 ## Fish Speech S2 Pro 4B dual-AR text-to-speech model from FishAudio with the DAC codec at 44.1 kHz. - -### Prerequisites -```bash -pip install fish-speech -``` +No extra packages are required; the DAC codec is vendored in vLLM-Omni. ### Quick start ```bash diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index e1cdd58dd..a4cf2fdb9 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -255,13 +255,8 @@ python examples/online_serving/text_to_speech/indextts2/speech_client.py \ ## Fish Speech S2 Pro -4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec. - -### Prerequisites - -```bash -pip install fish-speech -``` +4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec, which is vendored in +vLLM-Omni (no extra packages required). ### Kvcache attention fast path diff --git a/pyproject.toml b/pyproject.toml index cd7544af8..ce1eb9a13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -378,3 +378,7 @@ ba = "ba" SER = "SER" # NumPy ndarray.flags.writeable (canonical flag name, not "writable"). writeable = "writeable" +# "convnet" abbreviates convolutional network. It appears in the vendored Fish +# Speech DAC codec identifiers (convnet_type, transconvnet_type) and is not a +# typo of "convent". +convnet = "convnet" diff --git a/recipes/fishaudio/Fish-Speech-S2-Pro.md b/recipes/fishaudio/Fish-Speech-S2-Pro.md index dca9f537e..167bedb04 100644 --- a/recipes/fishaudio/Fish-Speech-S2-Pro.md +++ b/recipes/fishaudio/Fish-Speech-S2-Pro.md @@ -36,19 +36,13 @@ Other hardware and configurations are welcome as community validation lands. - OS: Linux - Python: 3.10+ -- Required package: `fish-speech` (for DAC codec) - CUDA 12.8 - vLLM version: 0.19.0 - vLLM-Omni version or commit: c93359bb354a6aa5c14d062430cb85b2c4db251e -```bash -# Install PortAudio dependency if missing (required by pyaudio which is a dependency of fish-speech) -# For Ubuntu/Debian: -if ! dpkg -l | grep -q libportaudio2; then - sudo apt-get update && sudo apt-get install -y libportaudio2 portaudio19-dev -fi -pip install fish-speech -``` +No extra packages are required: the DAC codec architecture is vendored in +vLLM-Omni (`vllm_omni/model_executor/models/fish_speech/dac_modules/`), so the +model runs out of the box, including in the official Docker image. #### Kvcache attention fast path @@ -129,7 +123,6 @@ curl -X POST http://localhost:8091/v1/audio/speech \ - OS: Linux - Python: 3.10+ -- Required package: `fish-speech` (for DAC codec) - CUDA 12.8 - vLLM version: 0.19.0+ diff --git a/tests/model_executor/models/fish_speech/test_dac_modules.py b/tests/model_executor/models/fish_speech/test_dac_modules.py new file mode 100644 index 000000000..0896f5533 --- /dev/null +++ b/tests/model_executor/models/fish_speech/test_dac_modules.py @@ -0,0 +1,110 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""CPU tests for the vendored DAC codec modules (dac_modules). + +Guards the checkpoint contract: the module tree built by build_dac_codec() +must keep the exact parameter/buffer names and shapes that codec.pth was +saved with (verified against the upstream fish-speech 0.1.0 package), and +the encode/decode surfaces used by dac_encoder.py / fish_speech_dac_decoder.py +must keep their shapes. +""" + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# Spot checks covering every submodule family and both weight-norm styles. +# Derived from the upstream fish-speech 0.1.0 state dict (541 entries total); +# legacy weight_norm (weight_g/weight_v) comes from the descript quantizer +# projections, parametrizations.weight.original0/1 from the causal convs. +EXPECTED_KEY_SAMPLES = [ + # encoder: first conv + deepest encoder-block transformer + "encoder.block.0.conv.parametrizations.weight.original0", + "encoder.block.4.block.5.layers.3.attention.wqkv.weight", + "encoder.block.4.block.5.freqs_cis", + "encoder.block.1.block.0.block.0.alpha", + # quantizer: semantic + residual VQ (legacy weight_norm keys) + "quantizer.semantic_quantizer.quantizers.0.codebook.weight", + "quantizer.quantizer.quantizers.8.in_proj.weight_g", + "quantizer.quantizer.quantizers.8.in_proj.weight_v", + # quantizer pre/post transformers + down/upsample convnext + "quantizer.pre_module.layers.7.feed_forward.w1.weight", + "quantizer.post_module.norm.weight", + "quantizer.downsample.1.1.dwconv.conv.weight", + "quantizer.upsample.0.0.conv.weight", + # decoder: transposed conv + final conv + "decoder.model.1.block.1.conv.parametrizations.weight.original1", + "decoder.model.6.conv.parametrizations.weight.original0", +] + +NUM_STATE_DICT_ENTRIES = 541 +DAC_NUM_CODEBOOKS = 10 # 1 semantic + 9 residual + + +@pytest.fixture(scope="module") +def codec(): + from vllm_omni.model_executor.models.fish_speech.dac_utils import build_dac_codec + + model = build_dac_codec() + model.eval() + return model + + +def test_state_dict_contract(codec): + sd = codec.state_dict() + assert len(sd) == NUM_STATE_DICT_ENTRIES + for key in EXPECTED_KEY_SAMPLES: + assert key in sd, f"checkpoint-contract key missing: {key}" + + +def test_no_fish_speech_import_required(codec): + import sys + + assert "fish_speech" not in sys.modules, "vendored codec must not import the fish-speech package" + + +@torch.no_grad() +def test_encode_shapes(codec): + frame_length = codec.frame_length # 2048 samples per code frame + wav = torch.randn(1, 1, frame_length * 3 + 100) + lengths = torch.tensor([wav.shape[-1]], dtype=torch.long) + codes, code_lengths = codec.encode(wav, lengths) + assert codes.shape[0] == 1 + assert codes.shape[1] == DAC_NUM_CODEBOOKS + assert codes.shape[2] == 4 # ceil(3.x frames) + assert code_lengths.tolist() == [4] + assert codes.dtype in (torch.int64, torch.int32) + + +@torch.no_grad() +def test_decode_shapes(codec): + frames = 8 + codes = torch.randint(0, 1024, (1, DAC_NUM_CODEBOOKS, frames), dtype=torch.long) + feature_lengths = torch.tensor([frames], dtype=torch.long) + wav, audio_lengths = codec.decode(codes, feature_lengths) + assert wav.shape[0] == 1 and wav.shape[1] == 1 + assert audio_lengths.tolist() == [frames * codec.frame_length] + assert wav.shape[-1] >= audio_lengths[0] - codec.frame_length + + +@torch.no_grad() +def test_decoder_runner_mutations(codec): + """The stage-1 runner prunes encode-only parts and bakes weight norm; + decode must still work afterwards (mirrors fish_speech_dac_decoder).""" + from torch.nn.utils.parametrize import remove_parametrizations + + codec.encoder = None + codec.quantizer.pre_module = None + codec.quantizer.downsample = None + for module in codec.modules(): + parametrizations = getattr(module, "parametrizations", None) + if not parametrizations: + continue + for name in list(parametrizations.keys()): + remove_parametrizations(module, name, leave_parametrized=True) + + codes = torch.randint(0, 1024, (2, DAC_NUM_CODEBOOKS, 5), dtype=torch.long) + wav, audio_lengths = codec.decode(codes, torch.tensor([5, 3], dtype=torch.long)) + assert wav.shape[0] == 2 + assert audio_lengths.tolist() == [5 * codec.frame_length, 3 * codec.frame_length] diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/__init__.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/__init__.py new file mode 100644 index 000000000..654916598 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/__init__.py @@ -0,0 +1,27 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Vendored DAC codec modules for Fish Speech S2 Pro. + +Adopted from the ``fish-speech`` 0.1.0 PyPI release +(https://pypi.org/project/fish-speech/0.1.0/, Apache-2.0) and +descript-audio-codec (https://github.com/descriptinc/descript-audio-codec, +MIT), trimmed to inference-only so the codec loads without external model +packages. Module attribute paths and weight-norm styles are kept identical +to upstream so ``codec.pth`` checkpoints load unchanged. +""" + +from vllm_omni.model_executor.models.fish_speech.dac_modules.codec import DAC +from vllm_omni.model_executor.models.fish_speech.dac_modules.rvq import ( + DownsampleResidualVectorQuantize, +) +from vllm_omni.model_executor.models.fish_speech.dac_modules.transformer import ( + ModelArgs, + WindowLimitedTransformer, +) + +__all__ = [ + "DAC", + "DownsampleResidualVectorQuantize", + "ModelArgs", + "WindowLimitedTransformer", +] diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/codec.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/codec.py new file mode 100644 index 000000000..4bc8483d0 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/codec.py @@ -0,0 +1,335 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from the fish-speech 0.1.0 PyPI release (Apache-2.0) +# https://pypi.org/project/fish-speech/0.1.0/ +# Copyright (c) Fish Audio +"""DAC codec model (encoder / quantizer / decoder). + +Differences from upstream ``fish_speech.models.dac.modded_dac.DAC``, none of +which affect the parameter tree or the inference math: + +- Base class is plain ``nn.Module`` instead of ``(audiotools BaseModel, + descript CodecMixin)`` -- both bases only add training/CLI conveniences. +- ``self.delay = self.get_delay()`` (a CodecMixin helper) is dropped; nothing + in the inference paths reads it. +- The training-only ``forward``/``preprocess`` methods are dropped; only + ``encode`` and ``decode`` are used at inference time. +""" + +import math + +import torch +from torch import nn + +from vllm_omni.model_executor.models.fish_speech.dac_modules.conv import ( + CausalWNConv1d, + CausalWNConvTranspose1d, +) +from vllm_omni.model_executor.models.fish_speech.dac_modules.layers import ( + Snake1d, + WNConv1d, + WNConvTranspose1d, +) +from vllm_omni.model_executor.models.fish_speech.dac_modules.transformer import ( + ModelArgs, + WindowLimitedTransformer, +) + + +def init_weights(m): + if isinstance(m, nn.Conv1d): + nn.init.trunc_normal_(m.weight, std=0.02) + nn.init.constant_(m.bias, 0) + + +class ResidualUnit(nn.Module): + def __init__(self, dim: int = 16, dilation: int = 1, causal: bool = False): + super().__init__() + conv_class = CausalWNConv1d if causal else WNConv1d + pad = ((7 - 1) * dilation) // 2 + self.block = nn.Sequential( + Snake1d(dim), + conv_class(dim, dim, kernel_size=7, dilation=dilation, padding=pad), + Snake1d(dim), + conv_class(dim, dim, kernel_size=1), + ) + self.causal = causal + + def forward(self, x): + y = self.block(x) + pad = x.shape[-1] - y.shape[-1] + if pad > 0: + if self.causal: + x = x[..., :-pad] + else: + x = x[..., pad // 2 : -pad // 2] + return x + y + + +class EncoderBlock(nn.Module): + def __init__( + self, + dim: int = 16, + stride: int = 1, + causal: bool = False, + n_t_layer: int = 0, + transformer_general_config=None, + ): + super().__init__() + conv_class = CausalWNConv1d if causal else WNConv1d + transformer_module = ( + nn.Identity() + if n_t_layer == 0 + else ( + WindowLimitedTransformer( + causal=causal, + input_dim=dim, + window_size=512, + config=transformer_general_config( + n_layer=n_t_layer, + n_head=dim // 64, + dim=dim, + intermediate_size=dim * 3, + ), + ) + ) + ) + self.block = nn.Sequential( + ResidualUnit(dim // 2, dilation=1, causal=causal), + ResidualUnit(dim // 2, dilation=3, causal=causal), + ResidualUnit(dim // 2, dilation=9, causal=causal), + Snake1d(dim // 2), + conv_class( + dim // 2, + dim, + kernel_size=2 * stride, + stride=stride, + padding=math.ceil(stride / 2), + ), + transformer_module, + ) + + def forward(self, x): + return self.block(x) + + +class Encoder(nn.Module): + def __init__( + self, + d_model: int = 64, + strides: list = [2, 4, 8, 8], + d_latent: int = 64, + n_transformer_layers: list = [0, 0, 4, 4], + transformer_general_config: ModelArgs = None, + causal: bool = False, + ): + super().__init__() + conv_class = CausalWNConv1d if causal else WNConv1d + # Create first convolution + self.block = [conv_class(1, d_model, kernel_size=7, padding=3)] + + # Create EncoderBlocks that double channels as they downsample by `stride` + for stride, n_t_layer in zip(strides, n_transformer_layers): + d_model *= 2 + self.block += [ + EncoderBlock( + d_model, + stride=stride, + causal=causal, + n_t_layer=n_t_layer, + transformer_general_config=transformer_general_config, + ) + ] + + # Create last convolution + self.block += [ + Snake1d(d_model), + conv_class(d_model, d_latent, kernel_size=3, padding=1), + ] + + # Wrap black into nn.Sequential + self.block = nn.Sequential(*self.block) + self.enc_dim = d_model + + def forward(self, x): + return self.block(x) + + +class DecoderBlock(nn.Module): + def __init__( + self, + input_dim: int = 16, + output_dim: int = 8, + stride: int = 1, + causal: bool = False, + n_t_layer: int = 0, + transformer_general_config=None, + ): + super().__init__() + conv_trans_class = CausalWNConvTranspose1d if causal else WNConvTranspose1d + # NOTE: upstream constructs a WindowLimitedTransformer from + # ``n_t_layer`` here but never registers it (the line placing it in + # ``self.block`` is commented out upstream), so decoder blocks carry no + # transformer parameters. It is not built here at all -- same module + # tree, without allocating throwaway weights. + del n_t_layer, transformer_general_config + self.block = nn.Sequential( + Snake1d(input_dim), + conv_trans_class( + input_dim, + output_dim, + kernel_size=2 * stride, + stride=stride, + padding=math.ceil(stride / 2), + ), + ResidualUnit(output_dim, dilation=1, causal=causal), + ResidualUnit(output_dim, dilation=3, causal=causal), + ResidualUnit(output_dim, dilation=9, causal=causal), + ) + + def forward(self, x): + return self.block(x) + + +class Decoder(nn.Module): + def __init__( + self, + input_channel, + channels, + rates, + d_out: int = 1, + causal: bool = False, + n_transformer_layers: list = [0, 0, 0, 0], + transformer_general_config=None, + ): + super().__init__() + conv_class = CausalWNConv1d if causal else WNConv1d + # Add first conv layer + layers = [conv_class(input_channel, channels, kernel_size=7, padding=3)] + + # Add upsampling + MRF blocks + for i, (stride, n_t_layer) in enumerate(zip(rates, n_transformer_layers)): + input_dim = channels // 2**i + output_dim = channels // 2 ** (i + 1) + layers += [ + DecoderBlock( + input_dim, + output_dim, + stride, + causal=causal, + n_t_layer=n_t_layer, + transformer_general_config=transformer_general_config, + ) + ] + + # Add final conv layer + layers += [ + Snake1d(output_dim), + conv_class(output_dim, d_out, kernel_size=7, padding=3), + nn.Tanh(), + ] + + self.model = nn.Sequential(*layers) + + def forward(self, x): + return self.model(x) + + +class DAC(nn.Module): + def __init__( + self, + encoder_dim: int = 64, + encoder_rates: list[int] = [2, 4, 8, 8], + latent_dim: int = None, + decoder_dim: int = 1536, + decoder_rates: list[int] = [8, 8, 4, 2], + quantizer: torch.nn.Module = None, + sample_rate: int = 44100, + causal: bool = True, + encoder_transformer_layers: list[int] = [0, 0, 0, 0], + decoder_transformer_layers: list[int] = [0, 0, 0, 0], + transformer_general_config=None, + ): + super().__init__() + + self.encoder_dim = encoder_dim + self.encoder_rates = encoder_rates + self.decoder_dim = decoder_dim + self.decoder_rates = decoder_rates + self.sample_rate = sample_rate + + if latent_dim is None: + latent_dim = encoder_dim * (2 ** len(encoder_rates)) + + self.latent_dim = latent_dim + + self.hop_length = int(math.prod(encoder_rates)) + self.encoder = Encoder( + encoder_dim, + encoder_rates, + latent_dim, + causal=causal, + n_transformer_layers=encoder_transformer_layers, + transformer_general_config=transformer_general_config, + ) + + self.quantizer = quantizer + + self.decoder = Decoder( + latent_dim, + decoder_dim, + decoder_rates, + causal=causal, + n_transformer_layers=decoder_transformer_layers, + transformer_general_config=transformer_general_config, + ) + self.sample_rate = sample_rate + self.apply(init_weights) + + self.frame_length = self.hop_length * 4 + + def encode( + self, + audio_data: torch.Tensor, + audio_lengths: torch.Tensor = None, + n_quantizers: int = None, + **kwargs, + ): + """Encode given audio data and return quantized latent codes. + + Parameters + ---------- + audio_data : Tensor[B x 1 x T] + Audio data to encode + audio_lengths : Tensor[B], optional + Valid sample counts per batch entry + n_quantizers : int, optional + Number of quantizers to use, by default None (all quantizers) + + Returns + ------- + Tuple of (codes Tensor[B x N x F], code lengths Tensor[B]). + """ + # pad to multiple of self.frame_length + if audio_data.ndim == 2: + audio_data = audio_data.unsqueeze(1) + length = audio_data.shape[-1] + right_pad = math.ceil(length / self.frame_length) * self.frame_length - length + audio_data = nn.functional.pad(audio_data, (0, right_pad)) + if audio_lengths is None: + audio_lengths = torch.LongTensor([length + right_pad]).to(audio_data.device) + + z = self.encoder(audio_data) + vq_results = self.quantizer(z, n_quantizers, **kwargs) + indices = vq_results.codes + indices_lens = torch.ceil(audio_lengths / self.frame_length).long() + return indices, indices_lens + + def decode(self, indices: torch.Tensor, feature_lengths): + """Decode codebook indices [B x N x F] to waveform [B x 1 x T].""" + if indices.ndim == 2: + indices = indices[None] + + z = self.quantizer.decode(indices) + audio_lengths = feature_lengths * self.frame_length + return self.decoder(z), audio_lengths diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/conv.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/conv.py new file mode 100644 index 000000000..2b75197a3 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/conv.py @@ -0,0 +1,194 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from the fish-speech 0.1.0 PyPI release (Apache-2.0) +# https://pypi.org/project/fish-speech/0.1.0/ +# Copyright (c) Fish Audio +"""Causal convolution primitives shared by the DAC encoder/decoder and the +downsample RVQ. Upstream duplicates these between ``modded_dac.py`` and +``rvq.py``; they are consolidated here (the copies are identical).""" + +import math + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn.utils.parametrizations import weight_norm +from torch.nn.utils.parametrize import remove_parametrizations + + +def unpad1d(x: torch.Tensor, paddings: tuple[int, int]): + """Remove padding from x, handling properly zero padding. Only for 1d!""" + padding_left, padding_right = paddings + assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right) + assert (padding_left + padding_right) <= x.shape[-1] + end = x.shape[-1] - padding_right + return x[..., padding_left:end] + + +def get_extra_padding_for_conv1d(x: torch.Tensor, kernel_size: int, stride: int, padding_total: int = 0) -> int: + """See `pad_for_conv1d`.""" + length = x.shape[-1] + n_frames = (length - kernel_size + padding_total) / stride + 1 + ideal_length = (math.ceil(n_frames) - 1) * stride + (kernel_size - padding_total) + return ideal_length - length + + +def pad1d( + x: torch.Tensor, + paddings: tuple[int, int], + mode: str = "zeros", + value: float = 0.0, +): + """Tiny wrapper around F.pad, just to allow for reflect padding on small input. + If this is the case, we insert extra 0 padding to the right + before the reflection happen. + """ + length = x.shape[-1] + padding_left, padding_right = paddings + assert padding_left >= 0 and padding_right >= 0, (padding_left, padding_right) + if mode == "reflect": + max_pad = max(padding_left, padding_right) + extra_pad = 0 + if length <= max_pad: + extra_pad = max_pad - length + 1 + x = F.pad(x, (0, extra_pad)) + padded = F.pad(x, paddings, mode, value) + end = padded.shape[-1] - extra_pad + return padded[..., :end] + else: + return F.pad(x, paddings, mode, value) + + +class CausalConvNet(nn.Module): + def __init__( + self, + in_channels, + out_channels, + kernel_size, + dilation=1, + stride=1, + groups=1, + padding=None, + ): + super().__init__() + self.conv = nn.Conv1d( + in_channels, + out_channels, + kernel_size, + stride=stride, + dilation=dilation, + groups=groups, + ) + self.stride = stride + self.kernel_size = (kernel_size - 1) * dilation + 1 + self.dilation = dilation + self.padding = self.kernel_size - self.stride + + def forward(self, x): + pad = self.padding + extra_padding = get_extra_padding_for_conv1d(x, self.kernel_size, self.stride, pad) + x = pad1d(x, (pad, extra_padding), mode="constant", value=0) + return self.conv(x).contiguous() + + def weight_norm(self, name="weight", dim=0): + self.conv = weight_norm(self.conv, name=name, dim=dim) + return self + + def remove_weight_norm(self): + self.conv = remove_parametrizations(self.conv) + return self + + +class CausalTransConvNet(nn.Module): + def __init__(self, in_channels, out_channels, kernel_size, dilation=1, stride=1, padding=None): + super().__init__() + self.conv = nn.ConvTranspose1d(in_channels, out_channels, kernel_size, stride=stride, dilation=dilation) + self.stride = stride + self.kernel_size = kernel_size + + def forward(self, x): + x = self.conv(x) + pad = self.kernel_size - self.stride + padding_right = math.ceil(pad) + padding_left = pad - padding_right + x = unpad1d(x, (padding_left, padding_right)) + return x.contiguous() + + def weight_norm(self, name="weight", dim=0): + self.conv = weight_norm(self.conv, name=name, dim=dim) + return self + + def remove_weight_norm(self): + self.conv = remove_parametrizations(self.conv) + return self + + +def CausalWNConv1d(*args, **kwargs): + return CausalConvNet(*args, **kwargs).weight_norm() + + +def CausalWNConvTranspose1d(*args, **kwargs): + return CausalTransConvNet(*args, **kwargs).weight_norm() + + +class ConvNeXtBlock(nn.Module): + r"""ConvNeXt Block. There are two equivalent implementations: + (1) DwConv -> LayerNorm (channels_first) -> 1x1 Conv -> GELU -> 1x1 Conv; all in (N, C, H, W) + (2) DwConv -> Permute to (N, H, W, C); LayerNorm (channels_last) -> Linear -> GELU -> Linear; Permute back + We use (2) as we find it slightly faster in PyTorch + + Args: + dim (int): Number of input channels. + layer_scale_init_value (float): Init value for Layer Scale. Default: 1e-6. + mlp_ratio (float): Ratio of mlp hidden dim to embedding dim. Default: 4.0. + kernel_size (int): Kernel size for depthwise conv. Default: 7. + dilation (int): Dilation for depthwise conv. Default: 1. + """ # noqa: E501 + + def __init__( + self, + dim: int, + layer_scale_init_value: float = 1e-6, + mlp_ratio: float = 4.0, + kernel_size: int = 7, + dilation: int = 1, + ): + super().__init__() + convnet_type = CausalConvNet + self.dwconv = convnet_type( + dim, + dim, + kernel_size=kernel_size, + groups=dim, + dilation=dilation, + ) # depthwise conv + self.norm = nn.LayerNorm(dim, eps=1e-6) + # pointwise/1x1 convs, implemented with linear layers + self.pwconv1 = nn.Linear(dim, int(mlp_ratio * dim)) + self.act = nn.GELU() + self.pwconv2 = nn.Linear(int(mlp_ratio * dim), dim) + self.gamma = ( + nn.Parameter(layer_scale_init_value * torch.ones(dim), requires_grad=True) + if layer_scale_init_value > 0 + else None + ) + + def forward(self, x, apply_residual: bool = True): + input = x + + x = self.dwconv(x) + x = x.permute(0, 2, 1) # (N, C, L) -> (N, L, C) + x = self.norm(x) + x = self.pwconv1(x) + x = self.act(x) + x = self.pwconv2(x) + + if self.gamma is not None: + x = self.gamma * x + + x = x.permute(0, 2, 1) # (N, L, C) -> (N, C, L) + + if apply_residual: + x = input + x + + return x diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/layers.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/layers.py new file mode 100644 index 000000000..9486f6f7a --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/layers.py @@ -0,0 +1,238 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from https://github.com/descriptinc/descript-audio-codec (MIT License) +# Copyright (c) 2023-present, Descript +"""Descript audio codec building blocks (Snake activation, weight-normed convs, +vector quantizers). + +Uses the legacy ``torch.nn.utils.weight_norm`` (``weight_g``/``weight_v`` keys) +exactly like upstream so quantizer projection weights in ``codec.pth`` load +unchanged. The ``einops.rearrange`` calls are replaced with equivalent +reshape/permute ops; ``from_latents`` (training-only) is dropped. +""" + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch.nn.utils import weight_norm + + +def WNConv1d(*args, **kwargs): + return weight_norm(nn.Conv1d(*args, **kwargs)) + + +def WNConvTranspose1d(*args, **kwargs): + return weight_norm(nn.ConvTranspose1d(*args, **kwargs)) + + +# Scripting this brings model speed up 1.4x +@torch.jit.script +def snake(x, alpha): + shape = x.shape + x = x.reshape(shape[0], shape[1], -1) + x = x + (alpha + 1e-9).reciprocal() * torch.sin(alpha * x).pow(2) + x = x.reshape(shape) + return x + + +class Snake1d(nn.Module): + def __init__(self, channels): + super().__init__() + self.alpha = nn.Parameter(torch.ones(1, channels, 1)) + + def forward(self, x): + return snake(x, self.alpha) + + +class VectorQuantize(nn.Module): + """ + Implementation of VQ similar to Karpathy's repo: + https://github.com/karpathy/deep-vector-quantization + Additionally uses following tricks from Improved VQGAN + (https://arxiv.org/pdf/2110.04627.pdf): + 1. Factorized codes: Perform nearest neighbor lookup in low-dimensional space + for improved codebook usage + 2. l2-normalized codes: Converts euclidean distance to cosine similarity which + improves training stability + """ + + def __init__(self, input_dim: int, codebook_size: int, codebook_dim: int): + super().__init__() + self.codebook_size = codebook_size + self.codebook_dim = codebook_dim + + self.in_proj = WNConv1d(input_dim, codebook_dim, kernel_size=1) + self.out_proj = WNConv1d(codebook_dim, input_dim, kernel_size=1) + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + def forward(self, z): + """Quantized the input tensor using a fixed codebook and returns + the corresponding codebook vectors + + Parameters + ---------- + z : Tensor[B x D x T] + + Returns + ------- + Tensor[B x D x T] + Quantized continuous representation of input + Tensor[1] + Commitment loss to train encoder to predict vectors closer to codebook + entries + Tensor[1] + Codebook loss to update the codebook + Tensor[B x T] + Codebook indices (quantized discrete representation of input) + Tensor[B x D x T] + Projected latents (continuous representation of input before quantization) + """ + + # Factorized codes (ViT-VQGAN) Project input into low-dimensional space + z_e = self.in_proj(z) # z_e : (B x D x T) + z_q, indices = self.decode_latents(z_e) + + commitment_loss = F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2]) + codebook_loss = F.mse_loss(z_q, z_e.detach(), reduction="none").mean([1, 2]) + + z_q = z_e + (z_q - z_e).detach() # noop in forward pass, straight-through gradient estimator in backward pass + + z_q = self.out_proj(z_q) + + return z_q, commitment_loss, codebook_loss, indices, z_e + + def embed_code(self, embed_id): + return F.embedding(embed_id, self.codebook.weight) + + def decode_code(self, embed_id): + return self.embed_code(embed_id).transpose(1, 2) + + def decode_latents(self, latents): + batch, dim, time = latents.shape + # rearrange "b d t -> (b t) d" + encodings = latents.permute(0, 2, 1).reshape(batch * time, dim) + codebook = self.codebook.weight # codebook: (N x D) + + # L2 normalize encodings and codebook (ViT-VQGAN) + encodings = F.normalize(encodings) + codebook = F.normalize(codebook) + + # Compute euclidean distance with codebook + dist = ( + encodings.pow(2).sum(1, keepdim=True) + - 2 * encodings @ codebook.t() + + codebook.pow(2).sum(1, keepdim=True).t() + ) + # rearrange "(b t) -> b t" + indices = (-dist).max(1)[1].reshape(batch, time) + z_q = self.decode_code(indices) + return z_q, indices + + +class ResidualVectorQuantize(nn.Module): + """ + Introduced in SoundStream: An end2end neural audio codec + https://arxiv.org/abs/2107.03312 + """ + + def __init__( + self, + input_dim: int = 512, + n_codebooks: int = 9, + codebook_size: int = 1024, + codebook_dim: int | list = 8, + quantizer_dropout: float = 0.0, + ): + super().__init__() + if isinstance(codebook_dim, int): + codebook_dim = [codebook_dim for _ in range(n_codebooks)] + + self.n_codebooks = n_codebooks + self.codebook_dim = codebook_dim + self.codebook_size = codebook_size + + self.quantizers = nn.ModuleList( + [VectorQuantize(input_dim, codebook_size, codebook_dim[i]) for i in range(n_codebooks)] + ) + self.quantizer_dropout = quantizer_dropout + + def forward(self, z, n_quantizers: int = None): + """Quantized the input tensor using a fixed set of `n` codebooks and returns + the corresponding codebook vectors + + Parameters + ---------- + z : Tensor[B x D x T] + n_quantizers : int, optional + No. of quantizers to use + (n_quantizers < self.n_codebooks ex: for quantizer dropout) + Note: if `self.quantizer_dropout` is True, this argument is ignored + when in training mode, and a random number of quantizers is used. + + Returns + ------- + Tuple of (z_q, codes, latents, commitment_loss, codebook_loss). + """ + z_q = 0 + residual = z + commitment_loss = 0 + codebook_loss = 0 + + codebook_indices = [] + latents = [] + + if n_quantizers is None: + n_quantizers = self.n_codebooks + if self.training: + n_quantizers = torch.ones((z.shape[0],)) * self.n_codebooks + 1 + dropout = torch.randint(1, self.n_codebooks + 1, (z.shape[0],)) + n_dropout = int(z.shape[0] * self.quantizer_dropout) + n_quantizers[:n_dropout] = dropout[:n_dropout] + n_quantizers = n_quantizers.to(z.device) + + for i, quantizer in enumerate(self.quantizers): + if self.training is False and i >= n_quantizers: + break + + z_q_i, commitment_loss_i, codebook_loss_i, indices_i, z_e_i = quantizer(residual) + + # Create mask to apply quantizer dropout + mask = torch.full((z.shape[0],), fill_value=i, device=z.device) < n_quantizers + z_q = z_q + z_q_i * mask[:, None, None] + residual = residual - z_q_i + + # Sum losses + commitment_loss += (commitment_loss_i * mask).mean() + codebook_loss += (codebook_loss_i * mask).mean() + + codebook_indices.append(indices_i) + latents.append(z_e_i) + + codes = torch.stack(codebook_indices, dim=1) + latents = torch.cat(latents, dim=1) + + return z_q, codes, latents, commitment_loss, codebook_loss + + def from_codes(self, codes: torch.Tensor): + """Given the quantized codes, reconstruct the continuous representation + + Parameters + ---------- + codes : Tensor[B x N x T] + Quantized discrete representation of input + + Returns + ------- + Tensor[B x D x T] + Quantized continuous representation of input + """ + z_q = 0.0 + z_p = [] + n_codebooks = codes.shape[1] + for i in range(n_codebooks): + z_p_i = self.quantizers[i].decode_code(codes[:, i, :]) + z_p.append(z_p_i) + + z_q_i = self.quantizers[i].out_proj(z_p_i) + z_q = z_q + z_q_i + return z_q, torch.cat(z_p, dim=1), codes diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/rvq.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/rvq.py new file mode 100644 index 000000000..25b0a89ee --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/rvq.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from the fish-speech 0.1.0 PyPI release (Apache-2.0) +# https://pypi.org/project/fish-speech/0.1.0/ +# Copyright (c) Fish Audio +"""Downsampled residual vector quantizer (1 semantic + N residual codebooks).""" + +from dataclasses import dataclass + +import torch +import torch.nn as nn +import torch.nn.functional as F + +from vllm_omni.model_executor.models.fish_speech.dac_modules.conv import ( + CausalConvNet, + CausalTransConvNet, + ConvNeXtBlock, +) +from vllm_omni.model_executor.models.fish_speech.dac_modules.layers import ( + ResidualVectorQuantize, +) + + +@dataclass +class VQResult: + z: torch.Tensor + codes: torch.Tensor + latents: torch.Tensor + codebook_loss: torch.Tensor + commitment_loss: torch.Tensor + semantic_distill_z: torch.Tensor | None = None + + +class DownsampleResidualVectorQuantize(nn.Module): + def __init__( + self, + input_dim: int = 1024, + n_codebooks: int = 9, + codebook_dim: int = 8, + quantizer_dropout: float = 0.5, + codebook_size: int = 1024, + semantic_codebook_size: int = 4096, + downsample_factor: tuple[int] = (2, 2), + downsample_dims: tuple[int] | None = None, + pre_module: nn.Module | None = None, + post_module: nn.Module | None = None, + semantic_predictor_module: nn.Module | None = None, + ): + super().__init__() + + if downsample_dims is None: + downsample_dims = [input_dim for _ in range(len(downsample_factor))] + + all_dims = (input_dim,) + tuple(downsample_dims) + + self.semantic_quantizer = ResidualVectorQuantize( + input_dim=input_dim, + n_codebooks=1, + codebook_size=semantic_codebook_size, + codebook_dim=codebook_dim, + quantizer_dropout=0.0, + ) + + self.quantizer = ResidualVectorQuantize( + input_dim=input_dim, + n_codebooks=n_codebooks, + codebook_size=codebook_size, + codebook_dim=codebook_dim, + quantizer_dropout=quantizer_dropout, + ) + + self.downsample_factor = downsample_factor + self.downsample_dims = downsample_dims + + convnet_type = CausalConvNet + transconvnet_type = CausalTransConvNet + + self.downsample = nn.Sequential( + *[ + nn.Sequential( + convnet_type( + all_dims[idx], + all_dims[idx + 1], + kernel_size=factor, + stride=factor, + ), + ConvNeXtBlock(dim=all_dims[idx + 1]), + ) + for idx, factor in enumerate(downsample_factor) + ] + ) + + self.upsample = nn.Sequential( + *[ + nn.Sequential( + transconvnet_type( + all_dims[idx + 1], + all_dims[idx], + kernel_size=factor, + stride=factor, + ), + ConvNeXtBlock(dim=all_dims[idx]), + ) + for idx, factor in reversed(list(enumerate(downsample_factor))) + ] + ) + self.apply(self._init_weights) + # leave for transformer, LSTM or Mamba or something else + self.pre_module = pre_module if pre_module is not None else nn.Identity() + self.post_module = post_module if post_module is not None else nn.Identity() + self.semantic_predictor_module = ( + semantic_predictor_module if semantic_predictor_module is not None else nn.Identity() + ) + + def _init_weights(self, m): + if isinstance(m, (nn.Conv1d, nn.Linear)): + nn.init.trunc_normal_(m.weight, std=0.02) + nn.init.constant_(m.bias, 0) + + def forward(self, z, n_quantizers: int = None, semantic_len: torch.Tensor = None, **kwargs): + # z: (B, D, T) + original_shape = z.shape + if semantic_len is None: + semantic_len = torch.LongTensor([z.shape[-1]]) + z = self.downsample(z) + z = self.pre_module(z) # B, T, D + ( + semantic_z, + semantic_codes, + semantic_latents, + semantic_commitment_loss, + semantic_codebook_loss, + ) = self.semantic_quantizer(z) + residual_z = z - semantic_z + residual_z, codes, latents, commitment_loss, codebook_loss = self.quantizer( + residual_z, n_quantizers=n_quantizers + ) + z = semantic_z + residual_z + commitment_loss = commitment_loss + semantic_commitment_loss + codebook_loss = codebook_loss + semantic_codebook_loss + codes = torch.cat([semantic_codes, codes], dim=1) + latents = torch.cat([semantic_latents, latents], dim=1) + z = self.post_module(z) + z = self.upsample(z) + # z: (B, D, T) + + # Pad or crop z to match original shape + diff = original_shape[-1] - z.shape[-1] + right = 0 + left = abs(diff) - right + + if diff > 0: + z = F.pad(z, (left, right)) + elif diff < 0: + z = z[..., left:] + + results = VQResult( + z=z, + codes=codes, + latents=latents, + commitment_loss=commitment_loss, + codebook_loss=codebook_loss, + ) + + return results + + def decode(self, indices: torch.Tensor): + new_indices = torch.zeros_like(indices) + new_indices[:, 0] = torch.clamp(indices[:, 0], max=self.semantic_quantizer.codebook_size - 1) + new_indices[:, 1:] = torch.clamp(indices[:, 1:], max=self.quantizer.codebook_size - 1) + + z_q_semantic = self.semantic_quantizer.from_codes(new_indices[:, :1])[0] + z_q_residual = self.quantizer.from_codes(new_indices[:, 1:])[0] + z_q = z_q_semantic + z_q_residual + z_q = self.post_module(z_q) + z_q = self.upsample(z_q) + return z_q diff --git a/vllm_omni/model_executor/models/fish_speech/dac_modules/transformer.py b/vllm_omni/model_executor/models/fish_speech/dac_modules/transformer.py new file mode 100644 index 000000000..cb9521287 --- /dev/null +++ b/vllm_omni/model_executor/models/fish_speech/dac_modules/transformer.py @@ -0,0 +1,419 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# Adopted from the fish-speech 0.1.0 PyPI release (Apache-2.0) +# https://pypi.org/project/fish-speech/0.1.0/ +# Copyright (c) Fish Audio +"""Window-limited transformer used inside the DAC codec (encoder blocks, +decoder blocks, and RVQ pre/post modules).""" + +import math +from dataclasses import dataclass + +import torch +from torch import Tensor, nn +from torch.nn import functional as F + + +def find_multiple(n: int, k: int) -> int: + if n % k == 0: + return n + return n + k - (n % k) + + +@dataclass +class ModelArgs: + block_size: int = 2048 + n_layer: int = 8 + n_head: int = 8 + dim: int = 512 + intermediate_size: int = 1536 + n_local_heads: int = -1 + head_dim: int = 64 + rope_base: float = 10000 + norm_eps: float = 1e-5 + dropout_rate: float = 0.1 + attn_dropout_rate: float = 0.1 + channels_first: bool = True # to be compatible with conv1d input/output + pos_embed_type: str = "rope" # can be "rope" or "conformer" + max_relative_position: int = 128 # for conformer-style relative position embedding + + def __post_init__(self): + if self.n_local_heads == -1: + self.n_local_heads = self.n_head + if self.intermediate_size is None: + hidden_dim = 4 * self.dim + n_hidden = int(2 * hidden_dim / 3) + self.intermediate_size = find_multiple(n_hidden, 256) + assert self.pos_embed_type in [ + "rope", + "conformer", + ], "pos_embed_type must be either 'rope' or 'conformer'" + + +class KVCache(nn.Module): + def __init__(self, max_batch_size, max_seq_length, n_heads, head_dim, dtype=torch.bfloat16): + super().__init__() + cache_shape = (max_batch_size, n_heads, max_seq_length, head_dim) + self.register_buffer("k_cache", torch.zeros(cache_shape, dtype=dtype)) + self.register_buffer("v_cache", torch.zeros(cache_shape, dtype=dtype)) + + def update(self, input_pos, k_val, v_val): + # input_pos: [S], k_val: [B, H, S, D] + assert input_pos.shape[0] == k_val.shape[2] + + k_out = self.k_cache + v_out = self.v_cache + k_out[:, :, input_pos] = k_val + v_out[:, :, input_pos] = v_val + + return ( + k_out[:, :, : input_pos.max() + 1, :], + v_out[:, :, : input_pos.max() + 1, :], + ) + + def clear_cache(self, prompt_len): + self.k_cache[:, :, prompt_len:, :].fill_(0) + self.v_cache[:, :, prompt_len:, :].fill_(0) + + +class Transformer(nn.Module): + def __init__(self, config: ModelArgs) -> None: + super().__init__() + self.config = config + + self.layers = nn.ModuleList(TransformerBlock(config) for _ in range(config.n_layer)) + self.norm = RMSNorm(config.dim, eps=config.norm_eps) + + # Only compute RoPE frequencies if using RoPE + if config.pos_embed_type == "rope": + freqs_cis = precompute_freqs_cis(self.config.block_size, self.config.head_dim, self.config.rope_base) + self.register_buffer("freqs_cis", freqs_cis) + else: + self.register_buffer("freqs_cis", None) + + causal_mask = torch.tril(torch.ones(self.config.block_size, self.config.block_size, dtype=torch.bool)) + self.register_buffer("causal_mask", causal_mask) + + self.max_batch_size = -1 + self.max_seq_length = -1 + self.use_kv_cache = False + + def setup_caches(self, max_batch_size, max_seq_length): + """ + This method will only be called during inference when using KV cache. + """ + head_dim = self.config.dim // self.config.n_head + max_seq_length = find_multiple(max_seq_length, 8) + self.max_seq_length = max_seq_length + self.max_batch_size = max_batch_size + dtype = self.norm.weight.dtype + device = self.norm.weight.device + + for b in self.layers: + b.attention.kv_cache = KVCache( + max_batch_size, + max_seq_length, + self.config.n_local_heads, + head_dim, + dtype, + ).to(device) + + self.use_kv_cache = True + + def forward( + self, + x: Tensor, + input_pos: Tensor | None = None, + mask: Tensor | None = None, + ) -> Tensor: + if self.config.pos_embed_type == "rope": + assert self.freqs_cis is not None, "RoPE frequencies must be initialized for RoPE positional embedding" + freqs_cis = self.freqs_cis[input_pos] + else: + freqs_cis = None + + if mask is None: # in case of non-causal model + if not self.training and self.use_kv_cache: + mask = self.causal_mask[None, None, input_pos] + mask = mask[..., : input_pos.max() + 1] + else: + mask = self.causal_mask[None, None, input_pos] + mask = mask[..., input_pos] + + for i, layer in enumerate(self.layers): + x = layer(x, input_pos, freqs_cis, mask) + x = self.norm(x) + return x + + +class TransformerBlock(nn.Module): + def __init__(self, config: ModelArgs) -> None: + super().__init__() + self.attention = Attention(config) + self.feed_forward = FeedForward(config) + self.ffn_norm = RMSNorm(config.dim, eps=config.norm_eps) + self.attention_norm = RMSNorm(config.dim, eps=config.norm_eps) + self.attention_layer_scale = LayerScale(config.dim, inplace=True) + self.ffn_layer_scale = LayerScale(config.dim, inplace=True) + + def forward( + self, + x: Tensor, + input_pos: Tensor, + freqs_cis: Tensor, + mask: Tensor, + ) -> Tensor: + h = x + self.attention_layer_scale(self.attention(self.attention_norm(x), freqs_cis, mask, input_pos)) + out = h + self.ffn_layer_scale(self.feed_forward(self.ffn_norm(h))) + return out + + +class Attention(nn.Module): + def __init__(self, config: ModelArgs): + super().__init__() + assert config.dim % config.n_head == 0 + + total_head_dim = (config.n_head + 2 * config.n_local_heads) * config.head_dim + # key, query, value projections for all heads, but in a batch + self.wqkv = nn.Linear(config.dim, total_head_dim, bias=False) + self.wo = nn.Linear(config.head_dim * config.n_head, config.dim, bias=False) + self.kv_cache = None + + self.n_head = config.n_head + self.head_dim = config.head_dim + self.n_local_heads = config.n_local_heads + self.dim = config.dim + self.attn_dropout_rate = config.attn_dropout_rate + self.pos_embed_type = config.pos_embed_type + + # Add relative position embedding for conformer-style + if self.pos_embed_type == "conformer": + self.max_relative_position = config.max_relative_position + num_pos_embeddings = 2 * config.max_relative_position + 1 + self.rel_pos_embeddings = nn.Parameter(torch.zeros(num_pos_embeddings, self.head_dim)) + nn.init.normal_(self.rel_pos_embeddings, mean=0.0, std=0.02) + + def _compute_conformer_pos_scores(self, q: Tensor, seqlen: int) -> Tensor: + # q: [B, H, S, D] + # Returns: [B, H, S, S] + positions = torch.arange(seqlen, device=q.device) + relative_positions = positions.unsqueeze(1) - positions.unsqueeze(0) # [S, S] + relative_positions = torch.clamp( + relative_positions + self.max_relative_position, + 0, + 2 * self.max_relative_position, + ) + rel_embeddings = self.rel_pos_embeddings[relative_positions] # [S, S, D] + + # Compute attention scores with relative position embeddings + q = q.transpose(1, 2) # [B, S, H, D] + rel_logits = torch.matmul(q, rel_embeddings.transpose(-2, -1)) # [B, S, H, S] + rel_logits = rel_logits.transpose(1, 2) # [B, H, S, S] + return rel_logits + + def forward( + self, + x: Tensor, + freqs_cis: Tensor, + mask: Tensor, + input_pos: Tensor | None = None, + ) -> Tensor: + bsz, seqlen, _ = x.shape + + kv_size = self.n_local_heads * self.head_dim + q, k, v = self.wqkv(x).split([kv_size, kv_size, kv_size], dim=-1) + context_seqlen = seqlen + + q = q.view(bsz, seqlen, self.n_head, self.head_dim) + k = k.view(bsz, context_seqlen, self.n_local_heads, self.head_dim) + v = v.view(bsz, context_seqlen, self.n_local_heads, self.head_dim) + + if self.pos_embed_type == "rope": + q = apply_rotary_emb(q, freqs_cis) + k = apply_rotary_emb(k, freqs_cis) + + q, k, v = map(lambda x: x.transpose(1, 2), (q, k, v)) + + if self.kv_cache is not None: + k, v = self.kv_cache.update(input_pos, k, v) + + k = k.repeat_interleave(self.n_head // self.n_local_heads, dim=1) + v = v.repeat_interleave(self.n_head // self.n_local_heads, dim=1) + + if self.pos_embed_type == "conformer": + # Compute attention scores + scale = 1.0 / math.sqrt(self.head_dim) + scores = torch.matmul(q, k.transpose(-2, -1)) * scale + + # Add relative position embeddings for conformer-style + rel_scores = self._compute_conformer_pos_scores(q, seqlen) + scores = scores + rel_scores + + # Apply attention + if mask is not None: + scores = scores.masked_fill(~mask, float("-inf")) + + attn = F.softmax(scores, dim=-1) + if self.attn_dropout_rate > 0 and self.training: + attn = F.dropout(attn, p=self.attn_dropout_rate) + + y = torch.matmul(attn, v) + else: + y = F.scaled_dot_product_attention( + q, + k, + v, + dropout_p=self.attn_dropout_rate if self.training else 0.0, + attn_mask=mask, + ) + y = y.transpose(1, 2).contiguous().view(bsz, seqlen, self.head_dim * self.n_head) + y = self.wo(y) + return y + + +class FeedForward(nn.Module): + def __init__(self, config: ModelArgs) -> None: + super().__init__() + self.w1 = nn.Linear(config.dim, config.intermediate_size, bias=False) + self.w3 = nn.Linear(config.dim, config.intermediate_size, bias=False) + self.w2 = nn.Linear(config.intermediate_size, config.dim, bias=False) + self.dropout = nn.Dropout(config.dropout_rate) + + def forward(self, x: Tensor) -> Tensor: + return self.w2(self.dropout(F.silu(self.w1(x)) * self.w3(x))) + + +class RMSNorm(nn.Module): + def __init__(self, dim: int, eps: float = 1e-5): + super().__init__() + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def _norm(self, x): + return x * torch.rsqrt(torch.mean(x * x, dim=-1, keepdim=True) + self.eps) + + def forward(self, x: Tensor) -> Tensor: + output = self._norm(x.float()).type_as(x) + return output * self.weight + + +class LayerScale(nn.Module): + def __init__( + self, + dim: int, + init_values: float | Tensor = 1e-2, + inplace: bool = False, + ) -> None: + super().__init__() + self.inplace = inplace + self.gamma = nn.Parameter(init_values * torch.ones(dim)) + + def forward(self, x: Tensor) -> Tensor: + return x.mul_(self.gamma) if self.inplace else x * self.gamma + + +class WindowLimitedTransformer(Transformer): + """ + Transformer with window limited attention, causal. + """ + + def __init__( + self, + config: ModelArgs, + input_dim: int = 512, + window_size: int | None = None, + causal: bool = True, + look_ahead_conv: nn.Module = None, + ): + super().__init__(config) + self.window_size = window_size + self.causal = causal + self.channels_first = config.channels_first + self.look_ahead_conv = look_ahead_conv if look_ahead_conv is not None else nn.Identity() + self.input_proj = nn.Linear(input_dim, config.dim) if input_dim != config.dim else nn.Identity() + self.output_proj = nn.Linear(config.dim, input_dim) if input_dim != config.dim else nn.Identity() + + def make_window_limited_mask( + self, + max_length: int, + x_lens: Tensor | None = None, + ) -> Tensor: + """ + Make mask to form window limited attention. + """ + if self.causal: + mask = torch.tril(torch.ones(max_length, max_length)) + row_indices = torch.arange(max_length).view(-1, 1) + window_size = self.window_size or max_length + valid_range = (row_indices - window_size + 1).clamp(min=0) + column_indices = torch.arange(max_length) + mask = (column_indices >= valid_range) & mask.bool() + else: + raise NotImplementedError + mask = mask.bool()[None, None] + return mask + + def make_mask( + self, + max_length: int, + x_lens: Tensor | None = None, + ) -> Tensor: + """ + Make ordinary mask if window size is not specified. + """ + if self.causal: + mask = torch.tril(torch.ones(max_length, max_length)) + else: + mask = torch.ones(max_length, max_length) + mask = mask.bool()[None, None] + for i, x_len in enumerate(x_lens): + mask[:x_len, i] = 0 + mask = mask.bool()[None, None] + return mask + + def forward( + self, + x: Tensor, + x_lens: Tensor | None = None, + ) -> Tensor: + if self.channels_first: + x = x.transpose(1, 2) + x = self.input_proj(x) # (B, T, D) + x = self.look_ahead_conv(x) + input_pos = torch.arange(x.shape[1], device=x.device) + # construct mask to form window limited attention + max_length = x.shape[1] + if self.window_size is not None: + mask = self.make_window_limited_mask(max_length, x_lens) + else: + mask = self.make_mask(max_length, x_lens) + mask = mask.to(x.device) + x = super().forward(x, input_pos, mask) + x = self.output_proj(x) # (B, T, D) + if self.channels_first: + x = x.transpose(1, 2) + return x + + +def precompute_freqs_cis(seq_len: int, n_elem: int, base: int = 10000, dtype: torch.dtype = torch.bfloat16) -> Tensor: + freqs = 1.0 / (base ** (torch.arange(0, n_elem, 2)[: (n_elem // 2)].float() / n_elem)) + t = torch.arange(seq_len, device=freqs.device) + freqs = torch.outer(t, freqs) + freqs_cis = torch.polar(torch.ones_like(freqs), freqs) + cache = torch.stack([freqs_cis.real, freqs_cis.imag], dim=-1) + return cache.to(dtype=dtype) + + +def apply_rotary_emb(x: Tensor, freqs_cis: Tensor) -> Tensor: + xshaped = x.float().reshape(*x.shape[:-1], -1, 2) + freqs_cis = freqs_cis.view(1, xshaped.size(1), 1, xshaped.size(3), 2) + x_out2 = torch.stack( + [ + xshaped[..., 0] * freqs_cis[..., 0] - xshaped[..., 1] * freqs_cis[..., 1], + xshaped[..., 1] * freqs_cis[..., 0] + xshaped[..., 0] * freqs_cis[..., 1], + ], + -1, + ) + + x_out2 = x_out2.flatten(3) + return x_out2.type_as(x) diff --git a/vllm_omni/model_executor/models/fish_speech/dac_utils.py b/vllm_omni/model_executor/models/fish_speech/dac_utils.py index f1f2590a3..b0d04e086 100644 --- a/vllm_omni/model_executor/models/fish_speech/dac_utils.py +++ b/vllm_omni/model_executor/models/fish_speech/dac_utils.py @@ -19,12 +19,12 @@ def build_dac_codec() -> nn.Module: Returns the model on CPU in eval mode -- caller is responsible for loading weights and moving to the target device. """ - from fish_speech.models.dac.modded_dac import ( + from vllm_omni.model_executor.models.fish_speech.dac_modules import ( DAC, + DownsampleResidualVectorQuantize, ModelArgs, WindowLimitedTransformer, ) - from fish_speech.models.dac.rvq import DownsampleResidualVectorQuantize base_transformer_kwargs = dict( block_size=16384, diff --git a/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py b/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py index 9627dc7ce..984d3f638 100644 --- a/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py +++ b/vllm_omni/model_executor/models/fish_speech/fish_speech_dac_decoder.py @@ -5,8 +5,8 @@ Loads the DAC codec from ``codec.pth`` and decodes codebook indices Analogous to ``Qwen3TTSCode2Wav`` in qwen3_tts. -Requires the ``fish-speech`` package for the DAC model architecture. -Install with: ``pip install fish-speech`` +The DAC model architecture is vendored in ``dac_modules`` -- no external +``fish-speech`` package is required. """ from __future__ import annotations From b1ec136bd7d37aeb8c9ec6da4f9bf8bcd51b94d9 Mon Sep 17 00:00:00 2001 From: ZacheryAU Date: Tue, 8 Sep 2026 09:28:21 +0800 Subject: [PATCH 30/90] [Frontend] Add omni benchmark support for image and video endpoints (#4728) --- docs/cli/bench/serve.md | 70 ++ tests/benchmarks/metrics/test_metrics.py | 131 ++++ tests/benchmarks/patch/test_patch.py | 272 ++++++++ tests/benchmarks/test_serve_cli.py | 21 + .../openai_api/test_video_server.py | 18 + tests/metrics/test_metrics_utils.py | 22 + vllm_omni/benchmarks/metrics/metrics.py | 189 +++++- vllm_omni/benchmarks/patch/patch.py | 615 +++++++++++++++++- vllm_omni/benchmarks/serve.py | 32 + .../models/helios/pipeline_helios.py | 17 +- vllm_omni/engine/stage_pool.py | 16 +- .../entrypoints/cli/benchmark/cli_args.py | 12 +- vllm_omni/entrypoints/openai/api_server.py | 109 +++- .../entrypoints/openai/protocol/images.py | 4 + .../entrypoints/openai/protocol/videos.py | 11 + vllm_omni/entrypoints/openai/serving_chat.py | 9 +- vllm_omni/entrypoints/openai/serving_video.py | 32 +- vllm_omni/metrics/__init__.py | 5 + vllm_omni/metrics/definitions.py | 25 +- vllm_omni/metrics/prometheus.py | 5 +- vllm_omni/metrics/utils.py | 83 +++ 21 files changed, 1626 insertions(+), 72 deletions(-) mode change 100755 => 100644 vllm_omni/entrypoints/openai/api_server.py mode change 100755 => 100644 vllm_omni/entrypoints/openai/serving_chat.py diff --git a/docs/cli/bench/serve.md b/docs/cli/bench/serve.md index b9c590095..a162c001a 100644 --- a/docs/cli/bench/serve.md +++ b/docs/cli/bench/serve.md @@ -436,6 +436,76 @@ This should be seen as an edge case, and if this behavior can be avoided by sett +### Image / video generation + +For `/v1/images/generations`, `/v1/images/edits`, and `/v1/videos`, set `--endpoint` to that path and omit `--backend`. The endpoint path is registered as the bench request adapter, so a separate named backend is not required. + +Example (`/v1/images/generations`): + +```bash +vllm bench serve --omni \ + --endpoint /v1/images/generations \ + --dataset-name random \ + --model ~/models/Qwen/Qwen-Image \ + --tokenizer ~/models/Qwen/Qwen-Image/tokenizer \ + --max-concurrency 1 \ + --num-warmups 2 \ + --num-prompts 10 \ + --random-input-len 64 \ + --random-output-len 1 \ + --ignore-eos \ + --percentile-metrics e2el \ + --extra-body '{ + "num_inference_steps": 20, + "seed": 42, + "true_cfg_scale": 4.0 + }' +``` + +If successful, following output is like: + +```text +============ Serving Benchmark Result ============ +Successful requests: 3 +Failed requests: 0 +Maximum request concurrency: 1 +Benchmark duration (s): 8.42 +Request throughput (req/s): 0.36 +Peak concurrent requests: 2.00 +-------------------Peak Memory-------------------- +Mean PEAK_MEMORY_MB (MB): 58832.00 +Median PEAK_MEMORY_MB (MB): 58832.00 +P99 PEAK_MEMORY_MB (MB): 58832.00 +----------------End-to-end Latency---------------- +Mean E2EL (ms): 2798.16 +Median E2EL (ms): 2799.83 +P99 E2EL (ms): 2800.03 +================== Image Result ================== +Total images generated: 3 +Image throughput (img/s): 0.36 +Average pixels per image: 1048576.00 +Mean denoise step latency (ms): 136.59 +---------------- Image Generation ---------------- +Mean IMAGE_GENERATION (ms): 2731.84 +Median IMAGE_GENERATION (ms): 2732.02 +P99 IMAGE_GENERATION (ms): 2734.65 +================================================== +``` + +Use the same pattern with `--endpoint /v1/images/edits` or `--endpoint /v1/videos` (and model-specific `--extra-body` as needed). Pure image/video runs omit the Text Result section when there is no generated text. + +`/v1/images/edits` defaults to **non-streaming JSON** (`stream=false`) so single-stage edit models are not rejected by the server. For multi-stage pipelines that need SSE (AR TTFT / image chunks), pass `"stream": true` in `--extra-body`. + +`/v1/videos` is an async job API: the client creates a job, then polls until `completed`/`failed`. Measured **e2el** therefore includes client poll sleep and any overshoot after the job actually finishes. Tune polling via `--extra-body`: + +- `poll_interval_s` (default `2.0`): sleep between status polls +- `poll_timeout_s` (default `21600`, i.e. 6 hours): give up waiting for the job + +**VIDEO_RTF** prefers server-reported generation time when available: + +- `video_rtf = video_generation_time_ms / 1000 / video_duration`, where `video_generation_time_ms` comes from response `stage_durations` (or `inference_time_s` as fallback) +- If generation time is missing, falls back to `e2el / video_duration` (this fallback *does* include poll overhead) + ### Multi-Stage Benchmark

diff --git a/tests/benchmarks/metrics/test_metrics.py b/tests/benchmarks/metrics/test_metrics.py index 315685258..d7dd6c14a 100644 --- a/tests/benchmarks/metrics/test_metrics.py +++ b/tests/benchmarks/metrics/test_metrics.py @@ -457,5 +457,136 @@ def test_text_benchmark_still_reports_ttft(capsys): assert "Time to First Token" in out, "text benchmarks must keep TTFT" +# ============================================================================ +# Suppress text metrics for pure image / video endpoints +# ============================================================================ + + +def _make_image_output(prompt_len: int) -> MixRequestFuncOutput: + output = MixRequestFuncOutput() + output.success = True + output.prompt_len = prompt_len + output.output_tokens = 0 + output.generated_text = "" + output.ttft = 0.0 + output.text_latency = 0.0 + output.latency = 2.5 + output.start_time = 0.0 + output.itl = [] + output.image_count = 1 + output.image_generation_time_ms = 2000.0 + output.image_pixels = 1024 * 1024 + output.input_audio_duration = 0.0 + output.error = "" + return output + + +def _make_video_output(prompt_len: int) -> MixRequestFuncOutput: + output = MixRequestFuncOutput() + output.success = True + output.prompt_len = prompt_len + output.output_tokens = 0 + output.generated_text = "" + output.ttft = 0.0 + output.text_latency = 0.0 + output.latency = 5.0 + output.start_time = 0.0 + output.itl = [] + output.video_duration = 2.0 + output.video_frames = 48 + output.video_generation_time_ms = 4000.0 + output.video_rtf = 2.0 + output.input_audio_duration = 0.0 + output.error = "" + return output + + +_MEDIA_PERCENTILE_METRICS = ["e2el", "ttft"] + + +def test_pure_image_benchmark_omits_text_result(capsys): + """Pure image generation must not print Text Result or invent peak tok/s.""" + outputs = [_make_image_output(64), _make_image_output(64)] + + metrics, actual_output_lens = calculate_metrics( + input_requests=[], + outputs=outputs, + dur_s=5.0, + tokenizer=None, + selected_percentiles=[99.0], + goodput_config_dict={}, + task_type=TaskType.GENERATION, + selected_percentile_metrics=_MEDIA_PERCENTILE_METRICS, + max_concurrency=None, + request_rate=float("inf"), + benchmark_duration=5.0, + ) + + out = capsys.readouterr().out + assert actual_output_lens == [0, 0] + assert metrics.total_output == 0 + assert metrics.max_output_tokens_per_s == 0.0 + assert " Text Result " not in out + assert "Peak output token throughput" not in out + assert "Time to First Token" not in out + assert " Image Result " in out + + +def test_pure_video_benchmark_omits_text_result(capsys): + """Pure video generation must not print Text Result or invent peak tok/s.""" + outputs = [_make_video_output(63), _make_video_output(63)] + + metrics, actual_output_lens = calculate_metrics( + input_requests=[], + outputs=outputs, + dur_s=10.0, + tokenizer=None, + selected_percentiles=[99.0], + goodput_config_dict={}, + task_type=TaskType.GENERATION, + selected_percentile_metrics=_MEDIA_PERCENTILE_METRICS, + max_concurrency=None, + request_rate=float("inf"), + benchmark_duration=10.0, + ) + + out = capsys.readouterr().out + assert actual_output_lens == [0, 0] + assert metrics.total_output == 0 + assert metrics.max_output_tokens_per_s == 0.0 + assert " Text Result " not in out + assert "Peak output token throughput" not in out + assert "Time to First Token" not in out + assert " Video Result " in out + + +def test_image_with_generated_text_still_reports_text_result(capsys): + """Image edits / AR text alongside images must keep Text Result.""" + output = _make_image_output(64) + output.output_tokens = 8 + output.generated_text = "revised caption" + output.ttft = 0.05 + output.itl = [0.01] * 7 + + calculate_metrics( + input_requests=[], + outputs=[output], + dur_s=3.0, + tokenizer=_EmptyAwareTokenizer(), + selected_percentiles=[99.0], + goodput_config_dict={}, + task_type=TaskType.GENERATION, + selected_percentile_metrics=_MEDIA_PERCENTILE_METRICS, + max_concurrency=None, + request_rate=float("inf"), + benchmark_duration=3.0, + ) + + out = capsys.readouterr().out + assert " Text Result " in out + assert "Time to First Token" in out + assert " Image Result " in out + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) diff --git a/tests/benchmarks/patch/test_patch.py b/tests/benchmarks/patch/test_patch.py index e72d5a263..b4582913e 100644 --- a/tests/benchmarks/patch/test_patch.py +++ b/tests/benchmarks/patch/test_patch.py @@ -22,9 +22,14 @@ from vllm_omni.benchmarks.data_modules.seed_tts_dataset import ( ) from vllm_omni.benchmarks.patch.patch import ( MixRequestFuncOutput, + _add_video_extra_body_to_form, + _add_video_reference_to_form, _apply_stage0_token_timings, + _apply_video_metrics_from_payload, _attach_seed_tts_to_request_func_input, async_request_openai_chat_omni_completions, + async_request_openai_image_edits_omni, + async_request_openai_image_generations_omni, async_request_openai_realtime_duplex, should_request_stage_metrics, ) @@ -1196,5 +1201,272 @@ async def test_prompt_len_assigned_from_usage(mocker: MockerFixture): ) +def test_video_rtf_prefers_generation_time_over_poll_latency(): + """RTF must not grow with client poll_interval overshoot in output.latency.""" + payload = { + "duration_s": 2.0, + "num_frames": 48, + "fps": 24.0, + "stage_durations": {"stage_0_gen_ms": 4000.0}, + } + request_body: dict[str, object] = {} + + short_poll = MixRequestFuncOutput() + short_poll.latency = 4.2 # ~generation + small poll overshoot + _apply_video_metrics_from_payload(short_poll, payload, request_body) + + long_poll = MixRequestFuncOutput() + long_poll.latency = 8.0 # same job, larger poll_interval_s overshoot + _apply_video_metrics_from_payload(long_poll, payload, request_body) + + assert short_poll.video_generation_time_ms == pytest.approx(4000.0) + assert long_poll.video_generation_time_ms == pytest.approx(4000.0) + assert short_poll.video_rtf == pytest.approx(2.0) + assert long_poll.video_rtf == pytest.approx(2.0) + assert short_poll.video_rtf == long_poll.video_rtf + + +def test_video_rtf_falls_back_to_e2e_latency_without_generation_time(): + output = MixRequestFuncOutput() + output.latency = 6.0 + _apply_video_metrics_from_payload( + output, + {"duration_s": 2.0, "num_frames": 48, "fps": 24.0}, + {}, + ) + assert output.video_generation_time_ms == 0.0 + assert output.video_rtf == pytest.approx(3.0) + + +# 1x1 PNG used as a valid b64_json image payload in edit-client tests. +_MIN_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + + +@pytest.mark.asyncio +async def test_image_edits_defaults_to_non_streaming_json(mocker: MockerFixture) -> None: + """Single-stage servers reject stream=true; default path must send stream=false + JSON.""" + + class MockJsonResponse: + status = 200 + + def __init__(self): + self._payload = { + "created": 1, + "data": [ + { + "b64_json": _MIN_PNG_B64, + "stage_durations": {"stage_0_gen_ms": 12.5}, + } + ], + } + + async def json(self): + return self._payload + + async def text(self): + return json.dumps(self._payload) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + captured_stream: list[str] = [] + real_add_field = None + + def tracking_add_field(self, name, value=None, **kwargs): + if name == "stream": + captured_stream.append(str(value)) + return real_add_field(self, name, value, **kwargs) + + import aiohttp + + real_add_field = aiohttp.FormData.add_field + mocker.patch.object(aiohttp.FormData, "add_field", tracking_add_field) + + mock_session = mocker.AsyncMock() + mock_session.post = mocker.MagicMock(return_value=MockJsonResponse()) + + request = RequestFuncInput( + model="single-stage-edit", + model_name="single-stage-edit", + prompt="make it sunny", + api_url="http://test.com/v1/images/edits", + prompt_len=4, + output_len=1, + multi_modal_content=[{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_MIN_PNG_B64}"}}], + extra_body={"num_inference_steps": 4}, + ) + output = await async_request_openai_image_edits_omni(request, mock_session, pbar=None) + + assert captured_stream == ["false"] + assert output.success is True + assert not output.error + assert output.image_count == 1 + assert output.image_generation_time_ms == pytest.approx(12.5) + + +@pytest.mark.asyncio +async def test_image_edits_stream_true_uses_sse_path(mocker: MockerFixture) -> None: + """Explicit stream=true keeps the multi-stage SSE client path.""" + sse_chunk = ( + b'data: {"type":"image","data":[{"b64_json":"' + _MIN_PNG_B64.encode() + b'"}]}\n\n' + b"data: [DONE]\n\n" + ) + mock_response = MockResponse(200, [sse_chunk]) + captured_stream: list[str] = [] + + import aiohttp + + real_add_field = aiohttp.FormData.add_field + + def tracking_add_field(self, name, value=None, **kwargs): + if name == "stream": + captured_stream.append(str(value)) + return real_add_field(self, name, value, **kwargs) + + mocker.patch.object(aiohttp.FormData, "add_field", tracking_add_field) + + mock_session = mocker.AsyncMock() + mock_session.post = mocker.MagicMock(return_value=mock_response) + + request = RequestFuncInput( + model="multi-stage-edit", + model_name="multi-stage-edit", + prompt="edit", + api_url="http://test.com/v1/images/edits", + prompt_len=2, + output_len=1, + multi_modal_content=[{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{_MIN_PNG_B64}"}}], + extra_body={"stream": True}, + ) + output = await async_request_openai_image_edits_omni(request, mock_session, pbar=None) + + assert captured_stream == ["true"] + assert output.success is True + assert output.image_count == 1 + + +@pytest.mark.asyncio +async def test_image_generations_e2el_includes_json_body_consume(mocker: MockerFixture) -> None: + """E2EL must include body transfer/decode, not stop at HTTP headers.""" + + class SlowJsonResponse: + status = 200 + + def __init__(self): + self._payload = { + "created": 1, + "data": [{"b64_json": _MIN_PNG_B64, "stage_durations": {"stage_0_gen_ms": 1.0}}], + } + + async def json(self): + await asyncio.sleep(0.05) + return self._payload + + async def text(self): + return json.dumps(self._payload) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args): + return None + + mock_session = mocker.AsyncMock() + mock_session.post = mocker.MagicMock(return_value=SlowJsonResponse()) + request = RequestFuncInput( + model="img-gen", + model_name="img-gen", + prompt="a cat", + api_url="http://test.com/v1/images/generations", + prompt_len=2, + output_len=1, + extra_body={}, + ) + output = await async_request_openai_image_generations_omni(request, mock_session, pbar=None) + assert output.success is True + assert output.latency >= 0.05 + + +def test_video_local_image_reference_not_forwarded_as_raw_extra_field(tmp_path, mocker: MockerFixture) -> None: + """Local image_reference must only be uploaded via dedicated serializer.""" + import aiohttp + + ref_path = tmp_path / "ref.png" + ref_path.write_bytes(base64.b64decode(_MIN_PNG_B64)) + + captured: list[tuple[str, object]] = [] + real_add_field = aiohttp.FormData.add_field + + def tracking_add_field(self, name, value=None, **kwargs): + captured.append((str(name), value)) + return real_add_field(self, name, value, **kwargs) + + mocker.patch.object(aiohttp.FormData, "add_field", tracking_add_field) + + form = aiohttp.FormData() + extra_body = { + "num_inference_steps": 2, + "image_reference": str(ref_path), + } + request_body = {"model": "vid", "prompt": "p", **extra_body} + _add_video_extra_body_to_form(form, extra_body, request_body) + assert _add_video_reference_to_form(form, extra_body["image_reference"]) is True + + field_names = [name for name, _ in captured] + assert "image_reference" not in field_names + assert field_names.count("input_reference") == 1 + # Dedicated uploader sends file bytes, not the raw local path string. + uploaded = next(value for name, value in captured if name == "input_reference") + assert uploaded == base64.b64decode(_MIN_PNG_B64) + + +@pytest.mark.parametrize( + "reference", + [ + {"image_url": "https://example.com/ref.png"}, + {"file_id": "file-abc"}, + [{"image_url": "https://example.com/a.png"}, {"file_id": "file-xyz"}], + ], +) +def test_video_structured_image_reference_serialized_to_form(reference: object, mocker: MockerFixture) -> None: + """Object-form image_reference must be JSON-serialized, not silently dropped.""" + import aiohttp + + captured: list[tuple[str, object]] = [] + real_add_field = aiohttp.FormData.add_field + + def tracking_add_field(self, name, value=None, **kwargs): + captured.append((str(name), value)) + return real_add_field(self, name, value, **kwargs) + + mocker.patch.object(aiohttp.FormData, "add_field", tracking_add_field) + + form = aiohttp.FormData() + extra_body = {"image_reference": reference} + request_body = {"model": "vid", "prompt": "p", **extra_body} + _add_video_extra_body_to_form(form, extra_body, request_body) + assert _add_video_reference_to_form(form, reference) is True + + field_names = [name for name, _ in captured] + # Reserved key must not be double-forwarded as a generic extra field; + # exactly one dedicated image_reference field. + assert field_names.count("image_reference") == 1 + assert "input_reference" not in field_names + payload = next(value for name, value in captured if name == "image_reference") + assert json.loads(payload) == reference + + +def test_video_unsupported_image_reference_raises() -> None: + import aiohttp + + form = aiohttp.FormData() + with pytest.raises(ValueError, match="Unsupported image_reference"): + _add_video_reference_to_form(form, {"not_a_supported_key": "x"}) + with pytest.raises(ValueError, match="Unsupported image_reference"): + _add_video_reference_to_form(form, "/tmp/does-not-exist-ref.png") + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) diff --git a/tests/benchmarks/test_serve_cli.py b/tests/benchmarks/test_serve_cli.py index 5365d56ac..b9c72b1bf 100644 --- a/tests/benchmarks/test_serve_cli.py +++ b/tests/benchmarks/test_serve_cli.py @@ -167,6 +167,26 @@ def test_preprocess_serve_args_applies_safe_omniinteract_prompt_default( {"bot_task": "recaption"}, {"backend", "print_stage", "bot_task"}, ), + ( + [ + "--endpoint", + "/v1/images/edits", + "--image-edits-bot-task", + "think", + ], + {"bot_task": "think"}, + {"endpoint", "bot_task"}, + ), + ( + [ + "--backend", + "/v1/images/edits", + "--image-edits-bot-task", + "recaption", + ], + {"bot_task": "recaption"}, + {"backend", "bot_task"}, + ), ( ["--extra-body", '{"bot_task":"vanilla"}'], {"bot_task": "vanilla"}, @@ -182,6 +202,7 @@ def test_omni_args_parse_and_preprocess( parser = TrackingArgumentParser() parser.add_argument("--extra-body", type=json.loads, default=None) parser.add_argument("--backend", default="openai-chat-omni") + parser.add_argument("--endpoint", default="/v1/chat/completions") add_omni_args(parser) args = parser.parse_args(argv) diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index 4a5931d6c..b13dfe42e 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -1818,6 +1818,24 @@ def test_video_request_validation(): VideoGenerationRequest(prompt="test", quality="medium") +def test_async_create_accepts_fractional_fps(test_client, mocker: MockerFixture): + """Queued VideoResponse must accept fractional fps from the request path.""" + _mock_encode_video_bytes(mocker) + response = test_client.post( + "/v1/videos", + data={"prompt": "fractional fps", "fps": "12.5", "num_frames": "5"}, + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["fps"] == 12.5 + assert body["num_frames"] == 5 + video_id = body["id"] + _wait_for_status(test_client, video_id, VideoGenerationStatus.COMPLETED.value) + engine = test_client.app.state.openai_serving_video._engine_client + assert engine.captured_sampling_params_list[0].fps == 12.5 + assert engine.captured_sampling_params_list[0].frame_rate == 12.5 + + def test_list_videos_supports_order_after_and_limit(test_client, mocker: MockerFixture): mocker.patch( "vllm_omni.entrypoints.openai.serving_video._encode_video_bytes", diff --git a/tests/metrics/test_metrics_utils.py b/tests/metrics/test_metrics_utils.py index 544c7819a..4ff201561 100644 --- a/tests/metrics/test_metrics_utils.py +++ b/tests/metrics/test_metrics_utils.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations from types import SimpleNamespace @@ -9,11 +12,30 @@ from vllm_omni.metrics.utils import ( count_audio_frames, count_image_pixels, count_tokens_from_outputs, + count_video_frames, ) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.mark.parametrize( + ("shape", "expected_frames"), + [ + ((1, 64, 64, 3), 1), + ((3, 64, 64, 3), 3), + ((4, 64, 64, 3), 4), + ((8, 64, 64, 3), 8), + ((3, 8, 64, 64), 8), + ((1, 1, 64, 64, 3), 1), + ((1, 3, 64, 64, 3), 3), + ((1, 4, 64, 64, 3), 4), + ((1, 3, 8, 64, 64), 8), + ], +) +def test_count_video_frames_handles_short_channel_last_outputs(shape, expected_frames): + assert count_video_frames(SimpleNamespace(shape=shape)) == expected_frames + + @pytest.mark.parametrize( ("audio_chunk", "expected"), [ diff --git a/vllm_omni/benchmarks/metrics/metrics.py b/vllm_omni/benchmarks/metrics/metrics.py index 3d7c983f3..52eec86b9 100644 --- a/vllm_omni/benchmarks/metrics/metrics.py +++ b/vllm_omni/benchmarks/metrics/metrics.py @@ -52,6 +52,21 @@ _MULTIMODAL_BENCHMARK_FIELDS = [ (defs.MEDIAN_IMAGE_GENERATION_MS, float, field(default=0.0)), (defs.STD_IMAGE_GENERATION_MS, float, field(default=0.0)), (defs.PERCENTILES_IMAGE_GENERATION_MS, _PERCENTILE_ROWS_TYPE, field(default=None)), + (defs.TOTAL_VIDEO_DURATION_S, float, field(default=0.0)), + (defs.TOTAL_VIDEO_FRAMES, int, field(default=0)), + (defs.VIDEO_THROUGHPUT, float, field(default=0.0)), + (defs.MEAN_VIDEO_RTF, float, field(default=0.0)), + (defs.MEDIAN_VIDEO_RTF, float, field(default=0.0)), + (defs.STD_VIDEO_RTF, float, field(default=0.0)), + (defs.PERCENTILES_VIDEO_RTF, _PERCENTILE_ROWS_TYPE, field(default=None)), + (defs.MEAN_VIDEO_GENERATION_MS, float, field(default=0.0)), + (defs.MEDIAN_VIDEO_GENERATION_MS, float, field(default=0.0)), + (defs.STD_VIDEO_GENERATION_MS, float, field(default=0.0)), + (defs.PERCENTILES_VIDEO_GENERATION_MS, _PERCENTILE_ROWS_TYPE, field(default=None)), + (defs.MEAN_PEAK_MEMORY_MB, float, field(default=0.0)), + (defs.MEDIAN_PEAK_MEMORY_MB, float, field(default=0.0)), + (defs.STD_PEAK_MEMORY_MB, float, field(default=0.0)), + (defs.PERCENTILES_PEAK_MEMORY_MB, _PERCENTILE_ROWS_TYPE, field(default=None)), ] MultiModalsBenchmarkMetrics = make_dataclass( @@ -171,8 +186,10 @@ def _stage_modality_flags( ) -> tuple[bool, bool, bool, bool, bool]: is_text_stage = final_output_type == "text" or output_unit_type == "text" is_audio_stage = final_output_type == "audio" or output_unit_type == "audio" - is_image_stage = final_output_type in {"image", "images"} or output_unit_type == "image" is_video_stage = final_output_type in {"video", "videos"} or output_unit_type == "video" + # Video diffusion may still report output_unit_type="image" when frames are + # stored in ``images``; prefer video when final_output_type says so. + is_image_stage = (not is_video_stage) and (final_output_type in {"image", "images"} or output_unit_type == "image") is_internal_stream_stage = ( output_unit_type in _STREAMING_OUTPUT_UNIT_TYPES and not is_text_stage and not is_audio_stage ) @@ -222,6 +239,7 @@ def print_metrics( print("{:<40} {:<10.2f}".format("Request goodput (req/s):", metrics.request_goodput)) if isinstance(metrics, MultiModalsBenchmarkMetrics): print("{:<40} {:<10.2f}".format("Peak concurrent requests:", metrics.max_concurrent_requests)) + print_peak_memory_metrics(metrics) if task_type != TaskType.GENERATION or "e2el" in selected_percentile_metrics: process_one_metric("e2el", metrics) print_text_metrics(task_type, selected_percentile_metrics, metrics) @@ -230,9 +248,13 @@ def print_metrics( print_audio_metrics(selected_percentile_metrics, metrics) if _has_image_output(metrics): print_image_metrics(selected_percentiles or [], metrics) + if _has_video_output(metrics): + print_video_metrics(selected_percentiles or [], metrics) if print_stage and outputs and selected_percentiles is not None: - print("\n{s:{c}^{n}}".format(s=" Stage Benchmark Result ", n=50, c="=")) - for sm in _build_stage_metrics_from_outputs(outputs): + stage_metrics = _build_stage_metrics_from_outputs(outputs) + if stage_metrics: + print("\n{s:{c}^{n}}".format(s=" Stage Benchmark Result ", n=50, c="=")) + for sm in stage_metrics: print_stage_metrics( task_type, selected_percentile_metrics, @@ -243,6 +265,11 @@ def print_metrics( def print_text_metrics(task_type, selected_percentile_metrics, metrics: MultiModalsBenchmarkMetrics): + # Pure image/video runs have no user-facing text tokens; skip the whole + # Text Result section (token throughput / peak would be misleading). + if metrics.total_output <= 0 and (_has_image_output(metrics) or _has_video_output(metrics)): + return + print("{s:{c}^{n}}".format(s=" Text Result ", n=50, c="=")) print("{:<40} {:<10}".format("Total input tokens:", metrics.total_input)) if isinstance(metrics, MultiModalsBenchmarkMetrics): @@ -275,6 +302,14 @@ def _has_image_output(metrics: MultiModalsBenchmarkMetrics) -> bool: return int(getattr(metrics, defs.TOTAL_IMAGES, 0) or 0) > 0 +def _has_video_output(metrics: MultiModalsBenchmarkMetrics) -> bool: + return ( + float(getattr(metrics, defs.TOTAL_VIDEO_DURATION_S, 0.0) or 0.0) > 0.0 + or int(getattr(metrics, defs.TOTAL_VIDEO_FRAMES, 0) or 0) > 0 + or float(getattr(metrics, defs.MEAN_VIDEO_GENERATION_MS, 0.0) or 0.0) > 0.0 + ) + + def print_audio_metrics(selected_percentile_metrics, metrics: MultiModalsBenchmarkMetrics): print("{s:{c}^{n}}".format(s=" Audio Result ", n=50, c="=")) print( @@ -293,6 +328,16 @@ def print_audio_metrics(selected_percentile_metrics, metrics: MultiModalsBenchma process_one_metric(metric, metrics) +def print_peak_memory_metrics(metrics: MultiModalsBenchmarkMetrics): + if getattr(metrics, defs.MEAN_PEAK_MEMORY_MB) <= 0: + return + print("{s:{c}^{n}}".format(s="Peak Memory", n=50, c="-")) + print("{:<40} {:<10.2f}".format("Mean PEAK_MEMORY_MB (MB):", getattr(metrics, defs.MEAN_PEAK_MEMORY_MB))) + print("{:<40} {:<10.2f}".format("Median PEAK_MEMORY_MB (MB):", getattr(metrics, defs.MEDIAN_PEAK_MEMORY_MB))) + for p, value in getattr(metrics, defs.PERCENTILES_PEAK_MEMORY_MB) or []: + print("{:<40} {:<10.2f}".format(f"P{_p_label(p)} PEAK_MEMORY_MB (MB):", value)) + + def print_image_metrics(selected_percentiles: list[float], metrics: MultiModalsBenchmarkMetrics): print("{s:{c}^{n}}".format(s=" Image Result ", n=50, c="=")) print("{:<40} {:<10}".format("Total images generated:", getattr(metrics, defs.TOTAL_IMAGES))) @@ -311,7 +356,7 @@ def print_image_metrics(selected_percentiles: list[float], metrics: MultiModalsB ) ) if getattr(metrics, defs.MEAN_IMAGE_GENERATION_MS) > 0: - print("-----------------Image Generation-----------------") + print("{s:{c}^{n}}".format(s=" Image Generation ", n=50, c="-")) print("{:<40} {:<10.2f}".format("Mean IMAGE_GENERATION (ms):", getattr(metrics, defs.MEAN_IMAGE_GENERATION_MS))) print( "{:<40} {:<10.2f}".format( @@ -324,6 +369,42 @@ def print_image_metrics(selected_percentiles: list[float], metrics: MultiModalsB print("{:<40} {:<10.2f}".format(f"P{_p_label(p)} IMAGE_GENERATION (ms):", value)) +def print_video_metrics(selected_percentiles: list[float], metrics: MultiModalsBenchmarkMetrics): + print("{s:{c}^{n}}".format(s=" Video Result ", n=50, c="=")) + print( + "{:<40} {:<10.2f}".format( + "Total video duration generated(s):", + getattr(metrics, defs.TOTAL_VIDEO_DURATION_S), + ) + ) + print("{:<40} {:<10}".format("Total video frames generated:", getattr(metrics, defs.TOTAL_VIDEO_FRAMES))) + print( + "{:<40} {:<10.2f}".format( + "Video throughput(video duration/s):", + getattr(metrics, defs.VIDEO_THROUGHPUT), + ) + ) + if getattr(metrics, defs.MEAN_VIDEO_RTF) > 0: + print("{s:{c}^{n}}".format(s=" Video RTF ", n=50, c="-")) + print("{:<40} {:<10.2f}".format("Mean VIDEO_RTF:", getattr(metrics, defs.MEAN_VIDEO_RTF))) + print("{:<40} {:<10.2f}".format("Median VIDEO_RTF:", getattr(metrics, defs.MEDIAN_VIDEO_RTF))) + for p, value in getattr(metrics, defs.PERCENTILES_VIDEO_RTF) or []: + if p in selected_percentiles: + print("{:<40} {:<10.2f}".format(f"P{_p_label(p)} VIDEO_RTF:", value)) + if getattr(metrics, defs.MEAN_VIDEO_GENERATION_MS) > 0: + print("{s:{c}^{n}}".format(s=" Video Generation ", n=50, c="-")) + print("{:<40} {:<10.2f}".format("Mean VIDEO_GENERATION (ms):", getattr(metrics, defs.MEAN_VIDEO_GENERATION_MS))) + print( + "{:<40} {:<10.2f}".format( + "Median VIDEO_GENERATION (ms):", + getattr(metrics, defs.MEDIAN_VIDEO_GENERATION_MS), + ) + ) + for p, value in getattr(metrics, defs.PERCENTILES_VIDEO_GENERATION_MS) or []: + if p in selected_percentiles: + print("{:<40} {:<10.2f}".format(f"P{_p_label(p)} VIDEO_GENERATION (ms):", value)) + + def process_one_metric( metric_attribute_name: str, metrics: MultiModalsBenchmarkMetrics, @@ -495,6 +576,21 @@ def _print_image_stage_metrics( ) +def _print_video_stage_metrics( + selected_percentiles: list[float], + sm: StageBenchmarkMetrics, +) -> None: + print("{s:{c}^{n}}".format(s=" Video Result ", n=50, c="=")) + _print_percentile_metric( + "Video Generation", + "VIDEO_GENERATION", + getattr(sm, defs.STAGE_GEN_TIMES_MS), + selected_percentiles, + values_are_ms=True, + to_ms=True, + ) + + def _print_internal_stream_stage_metrics( selected_percentile_metrics: list[str], selected_percentiles: list[float], @@ -549,11 +645,12 @@ def print_stage_metrics( ) = _stage_modality_flags(getattr(sm, "final_output_type"), getattr(sm, "output_unit_type")) print("{s:{c}^{n}}".format(s=title, n=50, c="=")) - if is_image_stage: - _print_image_stage_metrics(selected_percentiles, sm) + if is_video_stage: + _print_video_stage_metrics(selected_percentiles, sm) return - if is_video_stage: + if is_image_stage: + _print_image_stage_metrics(selected_percentiles, sm) return _print_stage_timing(sm, selected_percentiles) @@ -754,6 +851,11 @@ def calculate_metrics( denoise_step_latencies_ms: list[float] = [] total_images = 0 total_image_pixels = 0 + video_durations: list[float] = [] + video_rtfs: list[float] = [] + total_video_frames = 0 + video_generation_times_ms: list[float] = [] + peak_memories_mb: list[float] = [] audio_underruns: list[float] = [] audio_continuity_ok: list[bool] = [] input_audio_duration = 0.0 @@ -762,7 +864,18 @@ def calculate_metrics( output_len = outputs[i].output_tokens if not output_len: - if tokenizer is None: + # Pure image/video (no generated text) must stay at 0 tokens. + # Do not invent output_len=1 via the tokenizer-is-None placeholder. + generated_text = getattr(outputs[i], "generated_text", None) or "" + has_image = int(getattr(outputs[i], defs.IMAGE_COUNT, 0) or 0) > 0 + has_video = ( + float(getattr(outputs[i], defs.VIDEO_DURATION, 0.0) or 0.0) > 0.0 + or int(getattr(outputs[i], defs.VIDEO_FRAMES, 0) or 0) > 0 + or float(getattr(outputs[i], defs.VIDEO_GENERATION_TIME_MS, 0.0) or 0.0) > 0.0 + ) + if not generated_text and (has_image or has_video): + output_len = 0 + elif tokenizer is None: output_len = 1 else: # We use the tokenizer to count the number of output tokens @@ -827,6 +940,19 @@ def calculate_metrics( denoise_step_latency_ms = float(getattr(outputs[i], defs.DENOISE_STEP_LATENCY_MS, 0.0) or 0.0) if denoise_step_latency_ms > 0: denoise_step_latencies_ms.append(denoise_step_latency_ms) + video_duration = float(getattr(outputs[i], defs.VIDEO_DURATION, 0.0) or 0.0) + if video_duration > 0: + video_durations.append(video_duration) + total_video_frames += int(getattr(outputs[i], defs.VIDEO_FRAMES, 0) or 0) + video_rtf = float(getattr(outputs[i], defs.VIDEO_RTF, 0.0) or 0.0) + if video_rtf > 0: + video_rtfs.append(video_rtf) + video_generation_time_ms = float(getattr(outputs[i], defs.VIDEO_GENERATION_TIME_MS, 0.0) or 0.0) + if video_generation_time_ms > 0: + video_generation_times_ms.append(video_generation_time_ms) + peak_memory_mb = float(getattr(outputs[i], defs.PEAK_MEMORY_MB, 0.0) or 0.0) + if peak_memory_mb > 0: + peak_memories_mb.append(peak_memory_mb) audio_underruns.append(getattr(outputs[i], f"{defs.AUDIO_UNDERRUN}_s", 0.0)) audio_continuity_ok.append(bool(getattr(outputs[i], defs.AUDIO_CONTINUITY_OK, True))) e2els.append(outputs[i].latency) @@ -892,18 +1018,24 @@ def calculate_metrics( token_timeline_available = False else: # Calculate token generation timestamp using - # start_time, ttft, and itl - token_times = [output.start_time + output.ttft] - current_time = token_times[0] - for itl_value in output.itl: - current_time += itl_value - token_times.append(current_time) + # start_time, ttft, and itl. Skip requests with no text tokens + # (empty itl, zero output_tokens, and no generated_text) so pure + # image/video runs do not seed a fake peak of 1 tok/s from + # start_time+ttft alone. + output_token_count = int(getattr(output, "output_tokens", 0) or 0) + has_generated_text = bool(getattr(output, "generated_text", None) or "") + if output_token_count > 0 or output.itl or has_generated_text: + token_times = [output.start_time + output.ttft] + current_time = token_times[0] + for itl_value in output.itl: + current_time += itl_value + token_times.append(current_time) - # Add tokens to second buckets - for token_time in token_times: - second_bucket = int(token_time - min_start_time) - if 0 <= second_bucket < duration_seconds: - tokens_per_second[second_bucket] += 1 + # Add tokens to second buckets + for token_time in token_times: + second_bucket = int(token_time - min_start_time) + if 0 <= second_bucket < duration_seconds: + tokens_per_second[second_bucket] += 1 # Track concurrent requests for each second this request was active request_start_second = int(output.start_time - min_start_time) @@ -1009,6 +1141,25 @@ def calculate_metrics( defs.PERCENTILES_IMAGE_GENERATION_MS: [ (p, np.percentile(image_generation_times_ms or 0, p)) for p in selected_percentiles ], + defs.TOTAL_VIDEO_DURATION_S: sum(video_durations), + defs.TOTAL_VIDEO_FRAMES: total_video_frames, + defs.VIDEO_THROUGHPUT: sum(video_durations) / dur_s, + defs.MEAN_VIDEO_RTF: np.mean(video_rtfs or 0), + defs.STD_VIDEO_RTF: np.std(video_rtfs or 0), + defs.MEDIAN_VIDEO_RTF: np.median(video_rtfs or 0), + defs.PERCENTILES_VIDEO_RTF: [(p, np.percentile(video_rtfs or 0, p)) for p in selected_percentiles], + defs.MEAN_VIDEO_GENERATION_MS: np.mean(video_generation_times_ms or 0), + defs.STD_VIDEO_GENERATION_MS: np.std(video_generation_times_ms or 0), + defs.MEDIAN_VIDEO_GENERATION_MS: np.median(video_generation_times_ms or 0), + defs.PERCENTILES_VIDEO_GENERATION_MS: [ + (p, np.percentile(video_generation_times_ms or 0, p)) for p in selected_percentiles + ], + defs.MEAN_PEAK_MEMORY_MB: np.mean(peak_memories_mb or 0), + defs.STD_PEAK_MEMORY_MB: np.std(peak_memories_mb or 0), + defs.MEDIAN_PEAK_MEMORY_MB: np.median(peak_memories_mb or 0), + defs.PERCENTILES_PEAK_MEMORY_MB: [ + (p, np.percentile(peak_memories_mb or 0, p)) for p in selected_percentiles + ], defs.MEAN_AUDIO_UNDERRUN_S: np.mean(audio_underruns or 0), defs.STD_AUDIO_UNDERRUN_S: np.std(audio_underruns or 0), defs.MEDIAN_AUDIO_UNDERRUN_S: np.median(audio_underruns or 0), diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index b51dd2ee4..e65e5ee5c 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -14,7 +14,7 @@ import time import traceback import uuid import wave -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from dataclasses import dataclass, replace from datetime import datetime from pathlib import Path @@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Any, Literal import aiohttp import numpy as np import pybase64 as base64 +from PIL import Image from tqdm.asyncio import tqdm from vllm.benchmarks import datasets from vllm.benchmarks.datasets import SampleRequest @@ -85,7 +86,7 @@ from vllm_omni.benchmarks.omniinteract import ( write_batch_artifacts as write_omniinteract_batch_artifacts, ) from vllm_omni.metrics import definitions as defs -from vllm_omni.metrics.utils import coerce_positive_int_scalar +from vllm_omni.metrics.utils import coerce_bool, coerce_positive_float_scalar, coerce_positive_int_scalar if TYPE_CHECKING: from vllm_omni.clients.duplex import DuplexClient @@ -100,7 +101,13 @@ logger = init_logger(__name__) _AUDIO_CONTINUITY_THRESHOLD_ENV = "VLLM_OMNI_BENCH_AUDIO_CONTINUITY_THRESHOLD_S" RETURN_STAGE_METRICS_FIELD = "return_stage_metrics" -_IMAGE_STAGE_METRICS_BACKENDS = frozenset({"openai-image-edits-omni"}) +_IMAGE_STAGE_METRICS_BACKENDS = frozenset( + { + "/v1/images/generations", + "/v1/images/edits", + "openai-image-edits-omni", + } +) _PRINT_STAGE = False @@ -662,6 +669,11 @@ class MixRequestFuncOutput(RequestFuncOutput): image_generation_time_ms: float = 0.0 image_pixels: int = 0 denoise_step_latency_ms: float = 0.0 + video_duration: float = 0.0 + video_rtf: float = 0.0 + video_frames: int = 0 + video_generation_time_ms: float = 0.0 + peak_memory_mb: float = 0.0 text_latency: float = 0.0 tpot_measured: bool = True #: Worst-case streaming-audio underrun (wall-clock seconds the player @@ -849,7 +861,7 @@ def _record_text_token_stream_intervals( def _update_output_stage_metrics_from_payload( output: MixRequestFuncOutput, - data: dict[str, Any], + data: Mapping[str, object], *, update_output_tokens: bool = True, ) -> None: @@ -897,7 +909,55 @@ def _apply_chat_stage0_token_timings(output: MixRequestFuncOutput) -> bool: ) -def _image_metrics_from_stage_metrics(metrics: dict[str, Any] | None) -> tuple[int, float, int, float]: +def _peak_memory_mb_from_payload(data: Mapping[str, object]) -> float: + peak_memory_mb = coerce_positive_float_scalar(data.get(defs.PEAK_MEMORY_MB)) + if peak_memory_mb is not None: + return peak_memory_mb + + for key in ("metrics", "usage"): + nested = data.get(key) + if isinstance(nested, dict): + peak_memory_mb = coerce_positive_float_scalar(nested.get(defs.PEAK_MEMORY_MB)) + if peak_memory_mb is not None: + return peak_memory_mb + + response_data = data.get("data") + if isinstance(response_data, list): + for item in response_data: + if isinstance(item, dict): + peak_memory_mb = coerce_positive_float_scalar(item.get(defs.PEAK_MEMORY_MB)) + if peak_memory_mb is not None: + return peak_memory_mb + + choices = data.get("choices") + if isinstance(choices, list): + for choice in choices: + if not isinstance(choice, dict): + continue + contents = [] + message = choice.get("message") + if isinstance(message, dict): + contents.append(message.get("content")) + delta = choice.get("delta") + if isinstance(delta, dict): + contents.append(delta.get("content")) + for content in contents: + if isinstance(content, list): + for item in content: + if isinstance(item, dict): + peak_memory_mb = coerce_positive_float_scalar(item.get(defs.PEAK_MEMORY_MB)) + if peak_memory_mb is not None: + return peak_memory_mb + return 0.0 + + +def _update_output_peak_memory_from_payload(output: MixRequestFuncOutput, data: Mapping[str, object]) -> None: + peak_memory_mb = _peak_memory_mb_from_payload(data) + if peak_memory_mb > output.peak_memory_mb: + output.peak_memory_mb = peak_memory_mb + + +def _image_metrics_from_stage_metrics(metrics: object) -> tuple[int, float, int, float]: if not isinstance(metrics, dict): return 0, 0.0, 0, 0.0 stage_snapshot = metrics.get("stage_metrics") @@ -924,7 +984,7 @@ def _image_metrics_from_stage_metrics(metrics: dict[str, Any] | None) -> tuple[i return image_count, image_generation_ms, image_pixels, denoise_step_latency_ms -def _image_generation_ms_from_content(content: Any) -> float: +def _image_generation_ms_from_content(content: object) -> float: if not isinstance(content, list): return 0.0 for item in content: @@ -943,6 +1003,263 @@ def _image_generation_ms_from_content(content: Any) -> float: return 0.0 +def _image_info_from_response_data(content: object) -> tuple[int, int]: + if not isinstance(content, list): + return 0, 0 + image_count = 0 + total_pixels = 0 + for item in content: + if not isinstance(item, dict): + continue + b64_json = item.get("b64_json") + if not isinstance(b64_json, str) or not b64_json: + continue + try: + with Image.open(io.BytesIO(base64.b64decode(b64_json, validate=True))) as img: + width, height = img.size + img.verify() + image_count += 1 + total_pixels += int(width) * int(height) + except Exception: + logger.debug("Failed to decode generated image payload", exc_info=True) + return image_count, total_pixels + + +def _apply_image_metrics_from_payload(output: MixRequestFuncOutput, data: Mapping[str, object]) -> int: + """Populate image benchmark fields from an OpenAI-compatible image payload.""" + _update_output_stage_metrics_from_payload(output, data, update_output_tokens=False) + _update_output_peak_memory_from_payload(output, data) + + payload_image_count = 0 + response_data = data.get("data") + if isinstance(response_data, list): + payload_image_count, content_image_pixels = _image_info_from_response_data(response_data) + output.image_count = max(output.image_count, payload_image_count) + content_image_ms = _image_generation_ms_from_content(response_data) + if content_image_ms > 0: + output.image_generation_time_ms = max(output.image_generation_time_ms, content_image_ms) + if content_image_pixels > 0: + output.image_pixels = max(output.image_pixels, content_image_pixels) + + ( + metrics_image_count, + metrics_image_ms, + metrics_image_pixels, + metrics_denoise_step_ms, + ) = _image_metrics_from_stage_metrics(data.get("metrics")) + if metrics_image_count > output.image_count: + output.image_count = metrics_image_count + if metrics_image_ms > output.image_generation_time_ms: + output.image_generation_time_ms = metrics_image_ms + if metrics_image_pixels > output.image_pixels: + output.image_pixels = metrics_image_pixels + if metrics_denoise_step_ms > output.denoise_step_latency_ms: + output.denoise_step_latency_ms = metrics_denoise_step_ms + return payload_image_count + + +_VIDEO_FORM_FIELDS = ( + "seconds", + "num_frames", + "fps", + "num_inference_steps", + "seed", + "negative_prompt", + "guidance_scale", + "guidance_scale_2", + "boundary_ratio", + "flow_shift", + "true_cfg_scale", + "generate_sound", + "sound_duration", + "enable_frame_interpolation", + "frame_interpolation_exp", + "frame_interpolation_scale", + "frame_interpolation_model_path", + "lora", + "extra_params", +) + + +def _video_generation_ms_from_stage_durations(stage_durations: object) -> float: + if not isinstance(stage_durations, dict): + return 0.0 + gen_values = [ + float(value) + for key, value in stage_durations.items() + if str(key).endswith("_gen_ms") and isinstance(value, (int, float)) + ] + return max(gen_values) if gen_values else 0.0 + + +def _video_duration_from_payload(data: Mapping[str, object], request_body: Mapping[str, object]) -> float: + duration_s = coerce_positive_float_scalar(data.get("duration_s")) + if duration_s is not None and duration_s > 0: + return duration_s + + num_frames = coerce_positive_float_scalar(data.get("num_frames")) + fps = coerce_positive_float_scalar(data.get("fps")) + if num_frames is not None and fps is not None and num_frames > 0 and fps > 0: + return num_frames / fps + + seconds = coerce_positive_float_scalar(request_body.get("seconds")) + if seconds is not None and seconds > 0: + return seconds + + num_frames = coerce_positive_float_scalar(request_body.get("num_frames")) + fps = coerce_positive_float_scalar(request_body.get("fps")) + if num_frames is not None and fps is not None and num_frames > 0 and fps > 0: + return num_frames / fps + + return 0.0 + + +def _video_frames_from_payload(data: Mapping[str, object], request_body: Mapping[str, object]) -> int: + for key in ("num_frames", "video_frames", "frames"): + value = data.get(key) + if isinstance(value, list): + return len(value) + num_frames = coerce_positive_int_scalar(value) + if num_frames is not None: + return num_frames + + duration_s = coerce_positive_float_scalar(data.get("duration_s")) + fps = coerce_positive_float_scalar(data.get("fps")) + if duration_s is not None and fps is not None and duration_s > 0 and fps > 0: + return int(round(duration_s * fps)) + + num_frames = coerce_positive_int_scalar(request_body.get("num_frames")) + if num_frames is not None: + return num_frames + + seconds = coerce_positive_float_scalar(request_body.get("seconds")) + fps = coerce_positive_float_scalar(request_body.get("fps")) + if seconds is not None and fps is not None and seconds > 0 and fps > 0: + return int(round(seconds * fps)) + + return 0 + + +def _is_structured_image_reference(reference: Mapping[str, object]) -> bool: + """True for API image_reference objects ({image_url}/{file_id}).""" + image_url = reference.get("image_url") + file_id = reference.get("file_id") + has_url = isinstance(image_url, str) and bool(image_url) + has_file_id = isinstance(file_id, str) and bool(file_id) + return has_url or has_file_id + + +def _add_video_reference_to_form(form: aiohttp.FormData, reference: object) -> bool: + if isinstance(reference, dict) and "bytes" in reference: + form.add_field( + "input_reference", + reference["bytes"], + filename="benchmark-reference", + content_type=reference.get("content_type", "application/octet-stream"), + ) + return True + + if isinstance(reference, Mapping) and _is_structured_image_reference(reference): + form.add_field("image_reference", json.dumps(dict(reference))) + return True + + if isinstance(reference, list): + if reference and all(isinstance(item, Mapping) and _is_structured_image_reference(item) for item in reference): + form.add_field("image_reference", json.dumps([dict(item) for item in reference])) + return True + raise ValueError( + "Unsupported image_reference list; expected non-empty list of " + '{"image_url": "..."} and/or {"file_id": "..."} objects.' + ) + + if isinstance(reference, str): + if reference.startswith(("data:image", "http://", "https://")): + form.add_field("image_reference", json.dumps({"image_url": reference})) + return True + local_path = reference.removeprefix("file://") + if os.path.exists(local_path): + with open(local_path, "rb") as f: + reference_bytes = f.read() + form.add_field( + "input_reference", + reference_bytes, + filename=os.path.basename(local_path), + content_type=_guess_mime_type(local_path), + ) + return True + raise ValueError(f"Unsupported image_reference path or URL: {reference!r}") + + raise ValueError( + "Unsupported image_reference; expected upload bytes, local path/URL string, " + 'or {"image_url": "..."} / {"file_id": "..."} object ' + f"(got {type(reference).__name__})." + ) + + +def _add_video_extra_body_to_form( + form: aiohttp.FormData, + extra_body: Mapping[str, object], + request_body: Mapping[str, object], +) -> None: + for key in _VIDEO_FORM_FIELDS: + value = request_body.get(key) + if value is None: + continue + if isinstance(value, (dict, list)): + form.add_field(key, json.dumps(value)) + else: + form.add_field(key, str(value)) + + reserved = { + "model", + "prompt", + "size", + "width", + "height", + "poll_interval_s", + "poll_timeout_s", + # Handled only by _add_video_reference_to_form (upload / JSON image_url). + "image_reference", + "input_reference", + *_VIDEO_FORM_FIELDS, + } + for key, value in extra_body.items(): + if key in reserved or value is None: + continue + if isinstance(value, (dict, list)): + form.add_field(key, json.dumps(value)) + else: + form.add_field(key, str(value)) + + +def _apply_video_metrics_from_payload( + output: MixRequestFuncOutput, + data: Mapping[str, object], + request_body: Mapping[str, object], +) -> None: + output.video_duration = _video_duration_from_payload(data, request_body) + output.video_frames = _video_frames_from_payload(data, request_body) + _update_output_stage_metrics_from_payload(output, data, update_output_tokens=False) + _update_output_peak_memory_from_payload(output, data) + + stage_durations = data.get("stage_durations") + stage_gen_ms = _video_generation_ms_from_stage_durations(stage_durations) + if stage_gen_ms <= 0: + inference_time_s = coerce_positive_float_scalar(data.get("inference_time_s")) + if inference_time_s is not None and inference_time_s > 0: + stage_gen_ms = inference_time_s * 1000.0 + output.video_generation_time_ms = max(output.video_generation_time_ms, stage_gen_ms) + if output.video_duration <= 0: + return + # Prefer server-reported generation time so RTF is independent of client + # poll_interval_s sleep/overshoot baked into output.latency. + generation_s = output.video_generation_time_ms / 1000.0 + if generation_s > 0: + output.video_rtf = generation_s / output.video_duration + elif output.latency > 0: + output.video_rtf = output.latency / output.video_duration + + async def async_request_openai_chat_omni_completions( request_func_input: RequestFuncInput, session: aiohttp.ClientSession, @@ -1036,6 +1353,7 @@ async def async_request_openai_chat_omni_completions( output.image_generation_time_ms = 0.0 output.image_pixels = 0 output.denoise_step_latency_ms = 0.0 + output.peak_memory_mb = 0.0 completion_tokens_seen = 0 try: async with session.post(url=api_url, json=payload, headers=headers) as response: @@ -1065,6 +1383,7 @@ async def async_request_openai_chat_omni_completions( timestamp = time.perf_counter() data = json.loads(chunk) _update_output_stage_metrics_from_payload(output, data) + _update_output_peak_memory_from_payload(output, data) usage = data.get("usage") completion_tokens = None if isinstance(usage, dict): @@ -1281,16 +1600,243 @@ async def async_request_openai_chat_omni_completions( return output +def _finalize_image_json_http_response( + output: MixRequestFuncOutput, + *, + start_time: float, + status: int, + data: Mapping[str, object] | None, + error_text: str | None, +) -> None: + """Set e2el after the image JSON body has been fully read and validated.""" + output.latency = time.perf_counter() - start_time + if status != 200: + output.error = f"HTTP {status}: {error_text or ''}" + output.success = False + return + if not isinstance(data, Mapping): + output.error = "HTTP 200 response did not contain a JSON object" + output.success = False + return + payload_image_count = _apply_image_metrics_from_payload(output, data) + if payload_image_count <= 0: + output.error = "HTTP 200 response did not contain a valid image payload" + output.success = False + return + output.success = True + + +async def async_request_openai_image_generations_omni( + request_func_input: RequestFuncInput, + session: aiohttp.ClientSession, + pbar: tqdm | None = None, +) -> MixRequestFuncOutput: + """JSON request to /v1/images/generations for image generation benchmarks.""" + api_url = request_func_input.api_url + _validate_api_url(api_url, "OpenAI Image Generations API", "images/generations") + + extra_body = dict(request_func_input.extra_body or {}) + model = request_func_input.model_name if request_func_input.model_name else request_func_input.model + output = MixRequestFuncOutput() + output.prompt_len = request_func_input.prompt_len + output.itl = [] + output.stage_metrics = {} + output.output_tokens = 0 + output.image_count = 0 + output.image_generation_time_ms = 0.0 + output.image_pixels = 0 + output.denoise_step_latency_ms = 0.0 + + size = extra_body.get("size") + if size is None: + width, height = extra_body.get("width"), extra_body.get("height") + if width is not None and height is not None: + size = f"{width}x{height}" + + payload: dict[str, object] = { + "model": model, + "prompt": request_func_input.prompt, + "n": int(extra_body.pop("n", extra_body.pop("num_outputs_per_prompt", 1)) or 1), + "response_format": "b64_json", + } + if size is not None: + payload["size"] = str(size) + + for key, value in extra_body.items(): + if key in {"height", "width"}: + continue + payload.setdefault(key, value) + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}", + } + _update_headers_common(headers, request_func_input) + + st = time.perf_counter() + output.start_time = st + try: + async with session.post(url=api_url, json=payload, headers=headers) as response: + if response.status == 200: + data = await response.json() + _finalize_image_json_http_response( + output, + start_time=st, + status=response.status, + data=data if isinstance(data, Mapping) else None, + error_text=None, + ) + else: + error_text = await response.text() + _finalize_image_json_http_response( + output, + start_time=st, + status=response.status, + data=None, + error_text=error_text, + ) + except Exception: + output.latency = time.perf_counter() - st + output.success = False + output.error = traceback.format_exc() + logger.error(f"ERROR: send image generation request failed, reason is: {output.error}") + + if pbar: + pbar.update(1) + return output + + +async def async_request_openai_videos_omni( + request_func_input: RequestFuncInput, + session: aiohttp.ClientSession, + pbar: tqdm | None = None, +) -> MixRequestFuncOutput: + """Multipart request to async /v1/videos, polling metadata until completion.""" + api_url = request_func_input.api_url + _validate_api_url(api_url, "OpenAI Videos API", "videos") + + extra_body = dict(request_func_input.extra_body or {}) + model = request_func_input.model_name if request_func_input.model_name else request_func_input.model + output = MixRequestFuncOutput() + output.prompt_len = request_func_input.prompt_len + output.itl = [] + output.stage_metrics = {} + output.output_tokens = 0 + output.video_duration = 0.0 + output.video_generation_time_ms = 0.0 + + request_body: dict[str, object] = { + "model": model, + "prompt": request_func_input.prompt, + } + request_body.update(extra_body) + + size = request_body.get("size") + if size is None: + width, height = request_body.get("width"), request_body.get("height") + if width is not None and height is not None: + size = f"{width}x{height}" + if size is not None: + request_body["size"] = str(size) + + form = aiohttp.FormData() + form.add_field("model", str(model)) + form.add_field("prompt", str(request_func_input.prompt)) + if request_body.get("size") is not None: + form.add_field("size", str(request_body["size"])) + _add_video_extra_body_to_form(form, extra_body, request_body) + + reference_added = False + for reference in _iter_image_edit_inputs(request_func_input.multi_modal_content): + if _add_video_reference_to_form(form, reference): + reference_added = True + break + if not reference_added: + image_reference = extra_body.get("image_reference") + if image_reference is not None: + _add_video_reference_to_form(form, image_reference) + + headers = { + "Authorization": f"Bearer {os.environ.get('OPENAI_API_KEY')}", + } + _update_headers_common(headers, request_func_input) + + poll_interval_s = float(extra_body.get("poll_interval_s", 2.0) or 2.0) + timeout_s = float(extra_body.get("poll_timeout_s", 6 * 60 * 60) or (6 * 60 * 60)) + st = time.perf_counter() + output.start_time = st + try: + async with session.post(url=api_url, data=form, headers=headers) as response: + if response.status != 200: + output.latency = time.perf_counter() - st + output.error = f"HTTP {response.status}: {await response.text()}" + output.success = False + return output + create_payload = await response.json() + + job_id = create_payload.get("id") + job_status = create_payload.get("status") + if not isinstance(job_id, str) or not job_id: + output.latency = time.perf_counter() - st + output.error = "Video creation response missing job id." + output.success = False + return output + + job_url = f"{api_url.rstrip('/')}/{job_id}" + poll_payload = create_payload + deadline = time.perf_counter() + timeout_s + while job_status not in {"completed", "failed"}: + if time.perf_counter() >= deadline: + output.latency = time.perf_counter() - st + output.error = f"Timed out waiting for video job {job_id} to complete." + output.success = False + return output + await asyncio.sleep(poll_interval_s) + async with session.get(job_url, headers=headers) as poll_response: + if poll_response.status != 200: + output.latency = time.perf_counter() - st + output.error = f"Polling failed HTTP {poll_response.status}: {await poll_response.text()}" + output.success = False + return output + poll_payload = await poll_response.json() + job_status = poll_payload.get("status") + + output.latency = time.perf_counter() - st + if job_status == "failed": + output.error = f"Video job failed: {poll_payload}" + output.success = False + return output + + _apply_video_metrics_from_payload(output, poll_payload, request_body) + output.success = True + except Exception: + output.latency = time.perf_counter() - st + output.success = False + output.error = traceback.format_exc() + logger.error(f"ERROR: send video request failed, reason is: {output.error}") + finally: + if pbar: + pbar.update(1) + return output + + async def async_request_openai_image_edits_omni( request_func_input: RequestFuncInput, session: aiohttp.ClientSession, pbar: tqdm | None = None, ) -> MixRequestFuncOutput: - """Streaming request to /v1/images/edits for multi-stage image-edit benchmarks.""" + """Multipart request to /v1/images/edits. + + Defaults to non-streaming JSON so single-stage edit models work. The server + rejects ``stream=true`` when ``len(stage_configs) <= 1``. Pass + ``stream: true`` in ``--extra-body`` for multi-stage SSE (AR TTFT / image + chunks). + """ api_url = request_func_input.api_url _validate_api_url(api_url, "OpenAI Image Edits API", "images/edits") extra_body = dict(request_func_input.extra_body or {}) + want_stream = coerce_bool(extra_body.pop("stream", None), default=False) model = request_func_input.model_name if request_func_input.model_name else request_func_input.model output = MixRequestFuncOutput() output.prompt_len = request_func_input.prompt_len @@ -1307,7 +1853,7 @@ async def async_request_openai_image_edits_omni( form.add_field("prompt", request_func_input.prompt) form.add_field("response_format", "b64_json") form.add_field("output_format", str(extra_body.get("output_format", "png"))) - form.add_field("stream", "true") + form.add_field("stream", "true" if want_stream else "false") size = extra_body.get("size") if size is None: @@ -1340,12 +1886,21 @@ async def async_request_openai_image_edits_omni( st = time.perf_counter() output.start_time = st - timestamp = st - most_recent_text_timestamp = st - generated_text = "" try: async with session.post(url=api_url, data=form, headers=headers) as response: - if response.status == 200: + if response.status != 200: + error_text = await response.text() + _finalize_image_json_http_response( + output, + start_time=st, + status=response.status, + data=None, + error_text=error_text, + ) + elif want_stream: + timestamp = st + most_recent_text_timestamp = st + generated_text = "" handler = StreamedResponseHandler() async for chunk_bytes in response.content.iter_any(): if not chunk_bytes: @@ -1366,6 +1921,7 @@ async def async_request_openai_image_edits_omni( data, update_output_tokens=(data.get("type") == "ar_delta"), ) + _update_output_peak_memory_from_payload(output, data) chunk_type = data.get("type") if chunk_type == "ar_delta": @@ -1401,9 +1957,16 @@ async def async_request_openai_image_edits_omni( output.generated_text = generated_text output.success = True else: - output.error = f"HTTP {response.status}: {await response.text()}" - output.success = False + data = await response.json() + _finalize_image_json_http_response( + output, + start_time=st, + status=response.status, + data=data if isinstance(data, Mapping) else None, + error_text=None, + ) except Exception: + output.latency = time.perf_counter() - st output.success = False output.error = traceback.format_exc() logger.error(f"ERROR: send image edit request failed, reason is: {output.error}") @@ -1951,6 +2514,18 @@ ASYNC_REQUEST_FUNCS["openai-chat-omni"] = async_request_openai_chat_omni_complet if "openai-chat-omni" not in OPENAI_COMPATIBLE_BACKENDS: OPENAI_COMPATIBLE_BACKENDS.append("openai-chat-omni") +ASYNC_REQUEST_FUNCS["/v1/images/edits"] = async_request_openai_image_edits_omni +if "/v1/images/edits" not in OPENAI_COMPATIBLE_BACKENDS: + OPENAI_COMPATIBLE_BACKENDS.append("/v1/images/edits") + +ASYNC_REQUEST_FUNCS["/v1/images/generations"] = async_request_openai_image_generations_omni +if "/v1/images/generations" not in OPENAI_COMPATIBLE_BACKENDS: + OPENAI_COMPATIBLE_BACKENDS.append("/v1/images/generations") + +ASYNC_REQUEST_FUNCS["/v1/videos"] = async_request_openai_videos_omni +if "/v1/videos" not in OPENAI_COMPATIBLE_BACKENDS: + OPENAI_COMPATIBLE_BACKENDS.append("/v1/videos") + ASYNC_REQUEST_FUNCS["openai-audio-speech"] = async_request_openai_audio_speech if "openai-audio-speech" not in OPENAI_COMPATIBLE_BACKENDS: OPENAI_COMPATIBLE_BACKENDS.append("openai-audio-speech") @@ -2330,6 +2905,18 @@ async def benchmark( defs.IMAGE_THROUGHPUT: getattr(metrics, defs.IMAGE_THROUGHPUT), defs.AVERAGE_PIXELS_PER_IMAGE: getattr(metrics, defs.AVERAGE_PIXELS_PER_IMAGE), defs.MEAN_DENOISE_STEP_LATENCY_MS: getattr(metrics, defs.MEAN_DENOISE_STEP_LATENCY_MS), + defs.TOTAL_VIDEO_DURATION_S: getattr(metrics, defs.TOTAL_VIDEO_DURATION_S), + defs.TOTAL_VIDEO_FRAMES: getattr(metrics, defs.TOTAL_VIDEO_FRAMES), + defs.VIDEO_THROUGHPUT: getattr(metrics, defs.VIDEO_THROUGHPUT), + defs.MEAN_VIDEO_RTF: getattr(metrics, defs.MEAN_VIDEO_RTF), + defs.MEDIAN_VIDEO_RTF: getattr(metrics, defs.MEDIAN_VIDEO_RTF), + defs.PERCENTILES_VIDEO_RTF: getattr(metrics, defs.PERCENTILES_VIDEO_RTF), + defs.MEAN_VIDEO_GENERATION_MS: getattr(metrics, defs.MEAN_VIDEO_GENERATION_MS), + defs.MEDIAN_VIDEO_GENERATION_MS: getattr(metrics, defs.MEDIAN_VIDEO_GENERATION_MS), + defs.PERCENTILES_VIDEO_GENERATION_MS: getattr(metrics, defs.PERCENTILES_VIDEO_GENERATION_MS), + defs.MEAN_PEAK_MEMORY_MB: getattr(metrics, defs.MEAN_PEAK_MEMORY_MB), + defs.MEDIAN_PEAK_MEMORY_MB: getattr(metrics, defs.MEDIAN_PEAK_MEMORY_MB), + defs.PERCENTILES_PEAK_MEMORY_MB: getattr(metrics, defs.PERCENTILES_PEAK_MEMORY_MB), "input_lens": [output.prompt_len for output in outputs], "start_times": [output.start_time for output in outputs], "output_lens": actual_output_lens, diff --git a/vllm_omni/benchmarks/serve.py b/vllm_omni/benchmarks/serve.py index 2c4f8d130..96878a990 100644 --- a/vllm_omni/benchmarks/serve.py +++ b/vllm_omni/benchmarks/serve.py @@ -23,6 +23,37 @@ from vllm_omni.benchmarks.patch.patch import ( should_request_stage_metrics, ) +_ENDPOINT_BACKEND_KEYS = { + "/v1/images/generations", + "/v1/images/edits", + "/v1/videos", +} + + +def _normalize_endpoint(endpoint: str | None) -> str | None: + if endpoint is None: + return None + endpoint = str(endpoint).strip() + if not endpoint: + return None + if not endpoint.startswith("/"): + endpoint = f"/{endpoint}" + return endpoint + + +def _use_endpoint_backend_when_implicit(args: argparse.Namespace) -> None: + explicit_keys = getattr(args, "explicit_keys", frozenset()) + if "backend" in explicit_keys: + return + # Upstream vLLM defaults --backend to "openai". Treat that non-explicit + # default the same as an empty backend so endpoint-driven omni handlers work. + backend = getattr(args, "backend", None) + if backend not in (None, "", "openai"): + return + endpoint = _normalize_endpoint(getattr(args, "endpoint", None)) + if endpoint in _ENDPOINT_BACKEND_KEYS: + args.backend = endpoint + def main(args: argparse.Namespace) -> dict[str, Any]: if getattr(args, "seed_tts_wer_eval", False): @@ -31,6 +62,7 @@ def main(args: argparse.Namespace) -> dict[str, Any]: os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1" if getattr(args, "daily_omni_save_eval_items", False): os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1" + _use_endpoint_backend_when_implicit(args) set_print_stage(getattr(args, "print_stage", False)) args.extra_body = maybe_enable_stage_metrics( getattr(args, "extra_body", None), diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py index 45c6d0693..820f9a1d9 100644 --- a/vllm_omni/diffusion/models/helios/pipeline_helios.py +++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py @@ -1358,6 +1358,8 @@ class HeliosPipeline( """Single-stage denoising loop for one chunk.""" batch_size = latents.shape[0] do_true_cfg = guidance_scale > 1.0 and negative_prompt_embeds is not None + # Distilled (DMD) needs the chunk-start noise for renoise; mirror step_scheduler. + stage1_start_latents = latents with self.progress_bar(total=len(timesteps)) as pbar: for i, t in enumerate(timesteps): @@ -1420,7 +1422,20 @@ class HeliosPipeline( cfg_normalize=False, ) - latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg) + if self.is_distilled: + latents = self.scheduler.step( + noise_pred, + t, + latents, + return_dict=False, + cur_sampling_step=i, + dmd_noisy_tensor=stage1_start_latents, + dmd_sigmas=self.scheduler.sigmas, + dmd_timesteps=self.scheduler.timesteps, + all_timesteps=timesteps, + )[0] + else: + latents = self.scheduler_step_maybe_with_cfg(noise_pred, t, latents, do_true_cfg) pbar.update() diff --git a/vllm_omni/engine/stage_pool.py b/vllm_omni/engine/stage_pool.py index e7de5f51c..0ab6c1da7 100644 --- a/vllm_omni/engine/stage_pool.py +++ b/vllm_omni/engine/stage_pool.py @@ -739,10 +739,16 @@ class StagePool: def _infer_output_unit_type(self, request_outputs: list[Any], *, token_count: int) -> str: final_output_type = getattr(self.stage_client, "final_output_type", None) - if self._has_image_output(request_outputs) or final_output_type in {"image", "images"}: - return "image" - if self._has_video_output(request_outputs) or final_output_type in {"video", "videos"}: + # Prefer declared modality over payload heuristics: video diffusion often + # stores frames in ``images`` (see serving_video / output_formatter). + if final_output_type in {"video", "videos"}: return "video" + if final_output_type in {"image", "images"}: + return "image" + if self._has_video_output(request_outputs): + return "video" + if self._has_image_output(request_outputs): + return "image" if self._has_audio_output(request_outputs) or final_output_type == "audio": return "audio" if self._has_trajectory_latent_output(request_outputs) or self._has_latent_output(request_outputs): @@ -778,6 +784,10 @@ class StagePool: total_videos = sum(self._count_videos(ro) for ro in request_outputs) if total_videos > 0: return total_videos + # Video payloads are commonly carried on ``images`` for diffusion. + total_images = sum(self._count_images(ro) for ro in request_outputs) + if total_images > 0: + return total_images if unit_type == "latent": total_latents = sum( self._count_value_units(getattr(ro, "trajectory_latents", None)) diff --git a/vllm_omni/entrypoints/cli/benchmark/cli_args.py b/vllm_omni/entrypoints/cli/benchmark/cli_args.py index 2cf668769..68cbb9f86 100644 --- a/vllm_omni/entrypoints/cli/benchmark/cli_args.py +++ b/vllm_omni/entrypoints/cli/benchmark/cli_args.py @@ -103,8 +103,8 @@ def add_diffusion_cli_args(parser: argparse.ArgumentParser) -> None: type=str, default="think", help=( - "Default bot_task form field for --backend openai-image-edits-omni " - "(/v1/images/edits). " + "Default bot_task form field for image edits " + "(--backend openai-image-edits-omni or --endpoint /v1/images/edits). " 'Use --extra-body \'{"bot_task":"..."}\' to override per run.' ), ) @@ -352,6 +352,12 @@ def preprocess_serve_args(args: argparse.Namespace) -> None: raise ValueError("OmniInteract requires --max-concurrency to be positive") extra_body = dict(getattr(args, "extra_body", None) or {}) bot_task = getattr(args, "bot_task", None) - if getattr(args, "backend", None) == "openai-image-edits-omni" and bot_task is not None: + backend = getattr(args, "backend", None) + endpoint = getattr(args, "endpoint", None) + # serve.py remaps implicit backend to the endpoint path for image edits; + # inject bot_task for both the named backend and /v1/images/edits. + if bot_task is not None and ( + backend in ("openai-image-edits-omni", "/v1/images/edits") or endpoint == "/v1/images/edits" + ): extra_body.setdefault("bot_task", bot_task) args.extra_body = extra_body diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py old mode 100755 new mode 100644 index b305cb0cd..d49d95175 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -15,7 +15,7 @@ import tempfile import time import uuid from argparse import Namespace -from collections.abc import AsyncIterator, Mapping +from collections.abc import AsyncIterator, Mapping, Sequence from contextlib import asynccontextmanager, suppress from http import HTTPStatus from numbers import Integral @@ -131,6 +131,7 @@ from vllm_omni.entrypoints.openai.protocol.images import ( from vllm_omni.entrypoints.openai.protocol.videos import ( SecondStr, SizeStr, + VideoAction, VideoDeleteResponse, VideoError, VideoGenerationRequest, @@ -1946,12 +1947,26 @@ async def show_available_models(raw_request: Request) -> JSONResponse: # Image generation API endpoints +def _build_image_response_metrics( + *, + response_metrics: Any, + stage_durations: Any, + peak_memory_mb: Any, +) -> dict[str, Any]: + """Merge detailed stage metrics with the legacy image timing fields.""" + metrics = dict(response_metrics) if isinstance(response_metrics, dict) else {} + metrics["stage_durations"] = stage_durations or None + metrics["peak_memory_mb"] = float(peak_memory_mb) if peak_memory_mb else None + return metrics + + def _build_image_generation_response( *, images: list[Image.Image], request: ImageGenerationRequest, stage_durations: Any, peak_memory_mb: Any, + response_metrics: Any = None, ) -> ImageGenerationResponse | StreamingResponse: """Encode generated images and apply the requested response format.""" output_format = _choose_output_format(request.output_format or "png", None) @@ -1966,10 +1981,11 @@ def _build_image_generation_response( "created": int(time.time()), "data": image_data, "output_format": output_format, - "metrics": { - "stage_durations": stage_durations or None, - "peak_memory_mb": float(peak_memory_mb) if peak_memory_mb else None, - }, + "metrics": _build_image_response_metrics( + response_metrics=response_metrics, + stage_durations=stage_durations, + peak_memory_mb=peak_memory_mb, + ), } if request.size is not None: response_kwargs["size"] = request.size @@ -2066,6 +2082,8 @@ async def generate_images( extra_body["use_system_prompt"] = request.use_system_prompt if request.system_prompt is not None: extra_body["system_prompt"] = request.system_prompt + if request.return_stage_metrics is not None: + extra_body["return_stage_metrics"] = request.return_stage_metrics generation_result = await chat_handler.generate_diffusion_images( prompt=request.prompt, @@ -2079,12 +2097,13 @@ async def generate_images( status_code=generation_result.error.code if generation_result.error else 400, content=generation_result.model_dump(), ) - flat_images, stage_durations, peak_memory_mb, _ = generation_result + flat_images, stage_durations, peak_memory_mb, _, response_metrics = generation_result return _build_image_generation_response( images=flat_images, request=request, stage_durations=stage_durations, peak_memory_mb=peak_memory_mb, + response_metrics=response_metrics, ) # Build params - pass through user values directly @@ -2174,11 +2193,13 @@ async def generate_images( stage_durations = getattr(result, "stage_durations", None) peak_memory_mb = getattr(result, "peak_memory_mb", None) + response_metrics = getattr(result, "metrics", None) if request.return_stage_metrics else None return _build_image_generation_response( images=images, request=request, stage_durations=stage_durations, peak_memory_mb=peak_memory_mb, + response_metrics=response_metrics, ) except (EngineGenerateError, EngineDeadError) as exc: @@ -2510,7 +2531,7 @@ async def edit_images( status_code=generation_result.error.code if generation_result.error else 400, detail=generation_result.message, ) - images, stage_durations, peak_memory_mb, cot_output = generation_result + images, stage_durations, peak_memory_mb, cot_output, response_metrics = generation_result else: # Single-stage diffusion: use the direct path. result = await _generate_with_async_omni( @@ -2523,6 +2544,7 @@ async def edit_images( images = _extract_images_from_result(result) stage_durations = getattr(result, "stage_durations", None) peak_memory_mb = getattr(result, "peak_memory_mb", None) + response_metrics = getattr(result, "metrics", None) if return_stage_metrics else None logger.debug(f"Successfully generated {len(images)} image(s)") @@ -2543,10 +2565,11 @@ async def edit_images( output_format=output_format, size=size_str, cot_output=cot_output, - metrics={ - "stage_durations": stage_durations or None, - "peak_memory_mb": float(peak_memory_mb) if peak_memory_mb else None, - }, + metrics=_build_image_response_metrics( + response_metrics=response_metrics, + stage_durations=stage_durations, + peak_memory_mb=peak_memory_mb, + ), ) except (EngineGenerateError, EngineDeadError) as exc: @@ -3010,12 +3033,22 @@ def _reference_video_decode_spec( def video_response_from_request(model_name: str, req: VideoGenerationRequest) -> VideoResponse: + video_params = req.resolve_video_params() + duration_s = None + if req.seconds is not None: + duration_s = float(req.seconds) + elif video_params.num_frames is not None and video_params.fps is not None: + duration_s = video_params.num_frames / video_params.fps + resp = VideoResponse( model=model_name, status=VideoGenerationStatus.QUEUED, size=req.size, prompt=req.prompt, quality=req.quality or "default", + fps=video_params.fps, + num_frames=video_params.num_frames, + duration_s=duration_s, ) resp.seconds = str(req.seconds or resp.seconds) return resp @@ -3091,6 +3124,25 @@ def _cleanup_video_references( os.unlink(control_path) +def _unpack_video_generation_result( + result: Sequence[object], +) -> tuple[bytes, dict[str, float], float, VideoAction | None, dict[str, object]]: + video_metadata: dict[str, object] = {} + if len(result) == 5: + video_bytes, stage_durations, peak_memory_mb, action, raw_metadata = result + if isinstance(raw_metadata, dict): + video_metadata = {str(key): value for key, value in raw_metadata.items()} + else: + video_bytes, stage_durations, peak_memory_mb, action = result + return ( + cast(bytes, video_bytes), + cast(dict[str, float], stage_durations), + float(cast(float, peak_memory_mb)), + cast(VideoAction | None, action), + video_metadata, + ) + + async def _run_video_generation_job( handler: OmniOpenAIServingVideo, request: VideoGenerationRequest, @@ -3110,12 +3162,14 @@ async def _run_video_generation_job( await VIDEO_STORE.update_fields(video_id, {"status": VideoGenerationStatus.IN_PROGRESS}) started_at = time.perf_counter() try: - video_bytes, stage_durations, peak_memory_mb, action = await handler.generate_video_bytes( - request, - video_id, - reference_image=reference_image, - reference_video=reference_video, - reference_audio=reference_audio, + video_bytes, stage_durations, peak_memory_mb, action, video_metadata = _unpack_video_generation_result( + await handler.generate_video_bytes( + request, + video_id, + reference_image=reference_image, + reference_video=reference_video, + reference_audio=reference_audio, + ) ) save_context = await STORAGE_MANAGER.save(video_bytes, video_id) @@ -3131,6 +3185,7 @@ async def _run_video_generation_job( "peak_memory_mb": peak_memory_mb, "action": action, } + updated_fields.update({key: value for key, value in video_metadata.items() if value is not None}) if save_context.expires_at is not None: updated_fields["expires_at"] = save_context.expires_at @@ -3494,6 +3549,7 @@ async def _parse_video_form( frame_interpolation_model_path: str | None = Form(default=None), lora: str | None = Form(default=None), extra_params: str | None = Form(default=None), + return_stage_metrics: bool | None = Form(default=None), ) -> tuple[ VideoGenerationRequest, "OmniOpenAIServingVideo", @@ -3564,6 +3620,7 @@ async def _parse_video_form( "frame_interpolation_model_path": frame_interpolation_model_path, "lora": _parse_form_json(lora, expected_type=dict), "extra_params": _parse_form_json(extra_params, expected_type=dict), + "return_stage_metrics": return_stage_metrics, } request_data = {k: v for k, v in request_data.items() if v is not None} request = VideoGenerationRequest(**request_data) @@ -3844,15 +3901,17 @@ async def create_video_sync( raw_request.state.request_metadata = RequestResponseMetadata(request_id=request_id) started_at = time.perf_counter() try: - video_bytes, stage_durations, peak_memory_mb, _action = await asyncio.wait_for( - handler.generate_video_bytes( - request, - request_id, - reference_image=reference_image, - reference_video=reference_video, - reference_audio=reference_audio, + video_bytes, stage_durations, peak_memory_mb, _action, _video_metadata = _unpack_video_generation_result( + await asyncio.wait_for( + handler.generate_video_bytes( + request, + request_id, + reference_image=reference_image, + reference_video=reference_video, + reference_audio=reference_audio, + ), + timeout=VIDEO_SYNC_TIMEOUT_S, ), - timeout=VIDEO_SYNC_TIMEOUT_S, ) except asyncio.TimeoutError: raise HTTPException( diff --git a/vllm_omni/entrypoints/openai/protocol/images.py b/vllm_omni/entrypoints/openai/protocol/images.py index 2f28b6199..0215e1ce0 100644 --- a/vllm_omni/entrypoints/openai/protocol/images.py +++ b/vllm_omni/entrypoints/openai/protocol/images.py @@ -175,6 +175,10 @@ class ImageGenerationRequest(BaseModel): default=None, description="Output image format: 'png', 'jpeg', or 'webp'. Defaults to 'png'.", ) + return_stage_metrics: bool | None = Field( + default=None, + description="Return stage metrics for benchmark clients.", + ) class ImageData(BaseModel): diff --git a/vllm_omni/entrypoints/openai/protocol/videos.py b/vllm_omni/entrypoints/openai/protocol/videos.py index fb4c395d7..cb8cc198d 100644 --- a/vllm_omni/entrypoints/openai/protocol/videos.py +++ b/vllm_omni/entrypoints/openai/protocol/videos.py @@ -290,6 +290,10 @@ class VideoGenerationRequest(BaseModel): default=None, description=("Optional model-specific parameters passed directly to the model's extra_args. "), ) + return_stage_metrics: bool | None = Field( + default=None, + description="Whether to include server-side stage metrics in async video metadata.", + ) def resolve_video_params( self, @@ -407,6 +411,13 @@ class VideoResponse(BaseModel): description="Filename of the saved output video files for this job.", ) inference_time_s: float | None = Field(default=None, description="End-to-end inference time in seconds.") + fps: float | None = Field(default=None, description="Resolved output video frames per second, if known.") + num_frames: int | None = Field(default=None, description="Resolved number of output video frames, if known.") + duration_s: float | None = Field(default=None, description="Resolved output video duration in seconds, if known.") + metrics: dict[str, Any] | None = Field( + default=None, + description="Optional profiler and stage metrics for benchmark clients.", + ) stage_durations: dict[str, float] = Field( default_factory=dict, description="Profiler stage durations reported by the diffusion pipeline.", diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py old mode 100755 new mode 100644 index e799fce81..bfa241a40 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -3288,7 +3288,11 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): output_compression: int = 100, size: str = "auto", raw_request: Request | None = None, - ) -> tuple[list[Image.Image], dict[str, Any], float, str | None] | ErrorResponse | AsyncIterator[str]: + ) -> ( + tuple[list[Image.Image], dict[str, Any], float, str | None, dict[str, Any] | None] + | ErrorResponse + | AsyncIterator[str] + ): """Generate diffusion images and return raw images plus generation stats.""" if request_id is None: request_id = f"chatcmpl-{uuid.uuid4().hex[:16]}" @@ -3375,6 +3379,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): images = getattr(result, "images", []) stage_durations = result.stage_durations peak_memory_mb = result.peak_memory_mb + response_metrics = getattr(result, "metrics", None) if return_stage_metrics else None cot_output = None req_out = result @@ -3407,7 +3412,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): if isinstance(ar_text, str) and ar_text.strip(): cot_output = ar_text - return self._flatten_diffusion_images(images), stage_durations, peak_memory_mb, cot_output + return self._flatten_diffusion_images(images), stage_durations, peak_memory_mb, cot_output, response_metrics async def _stream_diffusion_image_chunks( self, diff --git a/vllm_omni/entrypoints/openai/serving_video.py b/vllm_omni/entrypoints/openai/serving_video.py index 939d55ef8..b3afe8b40 100644 --- a/vllm_omni/entrypoints/openai/serving_video.py +++ b/vllm_omni/entrypoints/openai/serving_video.py @@ -37,6 +37,7 @@ from vllm_omni.entrypoints.openai.video_api_utils import ( encode_video_base64, ) from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniTextPrompt +from vllm_omni.metrics import count_video_frames from vllm_omni.model_extras import get_video_generation_defaults, should_preserve_reference_image_size from vllm_omni.model_extras.video_generation import VideoGenerationDefaults from vllm_omni.outputs.output_metadata import ( @@ -87,6 +88,27 @@ class VideoGenerationArtifacts: output_fps: float stage_durations: dict[str, float] peak_memory_mb: float + metrics: dict[str, object] | None = None + + +def _video_metadata_from_artifacts(artifacts: VideoGenerationArtifacts) -> dict[str, object]: + metadata: dict[str, object] = {} + if artifacts.output_fps > 0: + metadata["fps"] = artifacts.output_fps + + if not artifacts.videos: + return metadata + + num_frames = count_video_frames(artifacts.videos[0]) + if num_frames is not None and num_frames > 0: + metadata["num_frames"] = num_frames + if artifacts.output_fps > 0: + metadata["duration_s"] = num_frames / artifacts.output_fps + + if artifacts.metrics: + metadata["metrics"] = artifacts.metrics + + return metadata class OmniOpenAIServingVideo: @@ -422,6 +444,8 @@ class OmniOpenAIServingVideo: model_fps = self._resolve_fps(result) output_fps_base = (vp.fps if fps_provided else None) or model_fps or vp.fps or 24 output_fps = output_fps_base * self._resolve_video_fps_multiplier(result) + raw_metrics = getattr(result, "metrics", None) if request.return_stage_metrics else None + metrics = {str(key): value for key, value in raw_metrics.items()} if isinstance(raw_metrics, Mapping) else None return VideoGenerationArtifacts( videos=videos, audios=audios, @@ -430,6 +454,7 @@ class OmniOpenAIServingVideo: output_fps=output_fps, stage_durations=self._extract_stage_durations(result), peak_memory_mb=self._extract_peak_memory_mb(result), + metrics=metrics, ) async def generate_videos( @@ -495,7 +520,7 @@ class OmniOpenAIServingVideo: reference_image: ReferenceImage | None = None, reference_video: ReferenceVideo | None = None, reference_audio: ReferenceAudio | None = None, - ) -> tuple[bytes, dict[str, float], float, VideoAction | None]: + ) -> tuple[bytes, dict[str, float], float, VideoAction | None, dict[str, object]]: """Generate a video and return raw MP4 bytes, bypassing base64 encoding.""" artifacts = await self._run_and_extract( request, @@ -518,9 +543,10 @@ class OmniOpenAIServingVideo: video_codec_options = request.extra_params["video_codec_options"] action = artifacts.actions[0] + video_metadata = _video_metadata_from_artifacts(artifacts) if action is not None and isinstance(artifacts.videos[0], dict): logger.info("Action-only video request %s completed; skipping MP4 encoding.", reference_id) - return b"", artifacts.stage_durations, artifacts.peak_memory_mb, action + return b"", artifacts.stage_durations, artifacts.peak_memory_mb, action, video_metadata _t_encode_start = time.perf_counter() video_bytes = _encode_video_bytes( @@ -532,7 +558,7 @@ class OmniOpenAIServingVideo: ) _t_encode_ms = (time.perf_counter() - _t_encode_start) * 1000 logger.info("Video response encoding (MP4 bytes): %.2f ms", _t_encode_ms) - return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb, artifacts.actions[0] + return video_bytes, artifacts.stage_durations, artifacts.peak_memory_mb, artifacts.actions[0], video_metadata @staticmethod def _resolve_video_fps_multiplier(result: object) -> int: diff --git a/vllm_omni/metrics/__init__.py b/vllm_omni/metrics/__init__.py index 7b6a2979f..c510c943b 100644 --- a/vllm_omni/metrics/__init__.py +++ b/vllm_omni/metrics/__init__.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from .prometheus import OmniPrometheusMetrics, OmniRequestCounter from .stats import OrchestratorAggregator, StageRequestStats, StageStats from .utils import ( @@ -5,6 +8,7 @@ from .utils import ( count_audio_frames, count_image_pixels, count_tokens_from_outputs, + count_video_frames, ) __all__ = [ @@ -17,4 +21,5 @@ __all__ = [ "count_audio_frames", "count_image_pixels", "count_tokens_from_outputs", + "count_video_frames", ] diff --git a/vllm_omni/metrics/definitions.py b/vllm_omni/metrics/definitions.py index 68adb44ca..4b0043bad 100644 --- a/vllm_omni/metrics/definitions.py +++ b/vllm_omni/metrics/definitions.py @@ -76,6 +76,29 @@ MEDIAN_IMAGE_GENERATION_MS = f"median_{IMAGE_GENERATION}_ms" STD_IMAGE_GENERATION_MS = f"std_{IMAGE_GENERATION}_ms" PERCENTILES_IMAGE_GENERATION_MS = f"percentiles_{IMAGE_GENERATION}_ms" +VIDEO_DURATION = "video_duration" +VIDEO_RTF = "video_rtf" +VIDEO_GENERATION = "video_generation" +VIDEO_GENERATION_TIME_MS = f"{VIDEO_GENERATION}_time_ms" +VIDEO_FRAMES = "video_frames" +TOTAL_VIDEO_DURATION_S = f"total_{VIDEO_DURATION}_s" +TOTAL_VIDEO_FRAMES = f"total_{VIDEO_FRAMES}" +VIDEO_THROUGHPUT = "video_throughput" +MEAN_VIDEO_RTF = f"mean_{VIDEO_RTF}" +MEDIAN_VIDEO_RTF = f"median_{VIDEO_RTF}" +STD_VIDEO_RTF = f"std_{VIDEO_RTF}" +PERCENTILES_VIDEO_RTF = f"percentiles_{VIDEO_RTF}" +MEAN_VIDEO_GENERATION_MS = f"mean_{VIDEO_GENERATION}_ms" +MEDIAN_VIDEO_GENERATION_MS = f"median_{VIDEO_GENERATION}_ms" +STD_VIDEO_GENERATION_MS = f"std_{VIDEO_GENERATION}_ms" +PERCENTILES_VIDEO_GENERATION_MS = f"percentiles_{VIDEO_GENERATION}_ms" + +PEAK_MEMORY_MB = "peak_memory_mb" +MEAN_PEAK_MEMORY_MB = f"mean_{PEAK_MEMORY_MB}" +MEDIAN_PEAK_MEMORY_MB = f"median_{PEAK_MEMORY_MB}" +STD_PEAK_MEMORY_MB = f"std_{PEAK_MEMORY_MB}" +PERCENTILES_PEAK_MEMORY_MB = f"percentiles_{PEAK_MEMORY_MB}" + # Stage snapshot / StageBenchmarkMetrics field names. TOTAL_OUTPUT = "total_output" TTFTS = "ttfts" @@ -166,7 +189,7 @@ STAGE_WAITING_REQUESTS = METRIC_PREFIX + "stage_waiting_requests" NUM_INFERENCE_STEPS = METRIC_PREFIX + "num_inference_steps" IMAGE_COUNT_METRIC = METRIC_PREFIX + IMAGE_COUNT IMAGE_PIXELS_METRIC = METRIC_PREFIX + IMAGE_PIXELS -PEAK_MEMORY_MB = METRIC_PREFIX + "peak_memory_mb" +PEAK_MEMORY_MB_METRIC = METRIC_PREFIX + "peak_memory_mb" REQUESTS_FAILED = METRIC_PREFIX + "requests_failed" KV_WAIT_S = METRIC_PREFIX + "kv_wait_s" DIFFUSION_FORWARD_S = METRIC_PREFIX + "diffusion_forward_s" diff --git a/vllm_omni/metrics/prometheus.py b/vllm_omni/metrics/prometheus.py index 911d21e64..2e68045c1 100644 --- a/vllm_omni/metrics/prometheus.py +++ b/vllm_omni/metrics/prometheus.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from prometheus_client import Counter, Gauge, Histogram from vllm_omni.metrics import definitions as defs @@ -72,7 +75,7 @@ _image_pixels_family = Histogram( labelnames=_labelnames, ) _peak_memory_family = Gauge( - defs.PEAK_MEMORY_MB, + defs.PEAK_MEMORY_MB_METRIC, "Peak GPU memory in MB observed during a stage's generation.", labelnames=list(defs.DIFFUSION_LABELS), ) diff --git a/vllm_omni/metrics/utils.py b/vllm_omni/metrics/utils.py index 10cdab94a..ad58e4cec 100644 --- a/vllm_omni/metrics/utils.py +++ b/vllm_omni/metrics/utils.py @@ -163,6 +163,24 @@ def extract_diffusion_denoise_ms(output: Any) -> float | None: return sum_diffusion_stage_durations_ms(output, ".diffuse") +def coerce_bool(value: object, *, default: bool = False) -> bool: + """Coerce JSON / form / env-like values to bool. + + Accepts ``bool``, numeric 0/1, and common truthy strings + (``\"1\"`` / ``\"true\"`` / ``\"yes\"`` / ``\"on\"``, case-insensitive). + ``None`` and unrecognized types return ``default``. + """ + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return default + + def coerce_positive_int_scalar(value: object) -> int | None: """Coerce a value to a positive int without importing tensor libs. @@ -199,6 +217,34 @@ def coerce_positive_int_scalar(value: object) -> int | None: return value if value > 0 else None +def coerce_positive_float_scalar(value: object) -> float | None: + """Coerce a scalar-like value to a positive float. + + Handles plain numeric values, one-element tensor/numpy-like values via + ``.item()``, and list/tuple wrappers. Returns ``None`` when no positive + float can be extracted. + """ + if value is None: + return None + if isinstance(value, (list, tuple)): + for item in value: + coerced = coerce_positive_float_scalar(item) + if coerced is not None: + return coerced + return None + item = getattr(value, "item", None) + if callable(item): + try: + value = item() + except Exception: + return None + try: + value = float(value) + except (TypeError, ValueError): + return None + return value if value > 0 else None + + def resolve_int_by_sequential_keys( source: Mapping[str, object] | object | None, keys: Sequence[str], @@ -370,6 +416,43 @@ def count_image_pixels(value: object) -> int: return dims[-2] * dims[-1] +def count_video_frames(video: object) -> int | None: + """Return the frame count for common nested and tensor video layouts.""" + if isinstance(video, Mapping): + for key in ("video", "frames", "images"): + if key in video: + return count_video_frames(video[key]) + return None + + if isinstance(video, (list, tuple)): + return len(video) + + shape = getattr(video, "shape", None) + if shape is None: + return None + try: + dims = tuple(int(dim) for dim in shape) + except (TypeError, ValueError): + return None + + if len(dims) == 5: + # Common layouts: [B, C, F, H, W] and [B, F, H, W, C]. + if dims[-1] in (1, 3, 4): + return dims[1] + if dims[1] in (1, 3, 4): + return dims[2] + return dims[1] + if len(dims) == 4: + # Common layouts: [F, H, W, C] and [C, F, H, W]. + if dims[-1] in (1, 3, 4): + return dims[0] + if dims[0] in (1, 3, 4): + return dims[1] + return dims[0] + + return None + + def _build_field_defs( cls: type, exclude: set[str], From 1413eaab7c82e4ab96ac375561e776eb3284f8ef Mon Sep 17 00:00:00 2001 From: Deep Shah Date: Mon, 7 Sep 2026 19:25:27 -0700 Subject: [PATCH 31/90] [Bugfix][Qwen3-Omni] Read code-predictor RoPE theta from rope_parameters (#7228) Signed-off-by: Deep Shah Co-authored-by: amy-why-3459 --- .../qwen3_tts/test_code_predictor_dtype.py | 83 ++++++++++++++++++- .../models/common/qwen3_code_predictor.py | 9 +- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py index 619f2169a..438a5ec1d 100644 --- a/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py +++ b/tests/model_executor/models/qwen3_tts/test_code_predictor_dtype.py @@ -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 code predictor dtype alignment (fix for #2385). @@ -20,6 +20,9 @@ import pytest import torch import torch.nn.functional as F from pytest_mock import MockerFixture +from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import ( + Qwen3OmniMoeTalkerCodePredictorConfig, +) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -41,6 +44,7 @@ _COMMON = os.path.join(_MODELS, "common") def _load_module(name: str, filename: str): path = os.path.abspath(os.path.join(_BASE, filename)) spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod # register before exec (needed for dataclasses etc.) spec.loader.exec_module(mod) @@ -83,7 +87,7 @@ def _build_mock_modules(mocker: MockerFixture) -> dict[str, object]: vllm_parallel_mock = mocker.MagicMock() vllm_parallel_mock.VocabParallelEmbedding = torch.nn.Embedding custom_op_mock = types.ModuleType("vllm_omni.diffusion.layers.custom_op") - custom_op_mock.CustomOp = NativeCustomOp + setattr(custom_op_mock, "CustomOp", NativeCustomOp) return { "vllm_omni": mocker.MagicMock(), @@ -119,6 +123,7 @@ def _load_target_classes(mocker: MockerFixture): common_spec = importlib.util.spec_from_file_location( "vllm_omni.model_executor.models.common.qwen3_code_predictor", common_cp_path ) + assert common_spec is not None and common_spec.loader is not None common_cp_mod = importlib.util.module_from_spec(common_spec) sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] = common_cp_mod common_spec.loader.exec_module(common_cp_mod) @@ -209,6 +214,80 @@ def test_npu_custom_ops_use_fused_norm_and_cached_rope(mocker: MockerFixture, lo torch.testing.assert_close(sin, rotary.sin_cached[position_ids].to(torch.float16)) +@pytest.mark.parametrize( + ("rope_kwargs", "expected_theta"), + [ + pytest.param( + {"rope_theta": 1_000_000.0}, + 1_000_000.0, + id="serialized_checkpoint", + ), + pytest.param( + { + "rope_theta": 10_000.0, + "rope_parameters": {"rope_type": "default", "rope_theta": 1_000_000.0}, + }, + 1_000_000.0, + id="nested_precedence", + ), + pytest.param({}, 10_000.0, id="default"), + ], +) +def test_code_predictor_rotary_uses_qwen3_omni_rope_parameters( + loaded_target_classes, + rope_kwargs, + expected_theta, +) -> None: + """Follow Transformers 5 deserialization and precedence for Qwen3-Omni.""" + _ = loaded_target_classes + config = Qwen3OmniMoeTalkerCodePredictorConfig( + hidden_size=16, + num_attention_heads=1, + head_dim=16, + num_code_groups=16, + **rope_kwargs, + ) + assert not hasattr(config, "rope_theta") + assert config.rope_parameters["rope_theta"] == expected_theta + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + + rotary = common_mod._RotaryEmbedding(config) + + expected = 1.0 / (expected_theta ** (torch.arange(0, 16, 2, dtype=torch.float32) / 16)) + torch.testing.assert_close(rotary.inv_freq, expected, rtol=0, atol=0) + + +@pytest.mark.parametrize("theta", [10_000.0, 1_000_000.0]) +@pytest.mark.parametrize( + "legacy_top_level_only", + [False, True], + ids=["normalized", "legacy_top_level"], +) +def test_code_predictor_rotary_preserves_qwen3_tts_rope_theta( + loaded_target_classes, + theta, + legacy_top_level_only, +) -> None: + """Keep the shared predictor compatible with current and legacy Qwen3-TTS.""" + config_class = loaded_target_classes[0] + config = config_class( + hidden_size=16, + num_attention_heads=1, + head_dim=16, + num_code_groups=16, + rope_theta=theta, + ) + assert config.rope_parameters["rope_theta"] == theta + if legacy_top_level_only: + delattr(config, "rope_parameters") + common_mod = sys.modules["vllm_omni.model_executor.models.common.qwen3_code_predictor"] + + rotary = common_mod._RotaryEmbedding(config) + + expected = 1.0 / (theta ** (torch.arange(0, 16, 2, dtype=torch.float32) / 16)) + torch.testing.assert_close(rotary.inv_freq, expected, rtol=0, atol=0) + + class TestCodePredictorDtypeAlignment: """Test that code predictor buffers match model parameter dtype.""" diff --git a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py index 0308cda7b..60b4d4525 100644 --- a/vllm_omni/model_executor/models/common/qwen3_code_predictor.py +++ b/vllm_omni/model_executor/models/common/qwen3_code_predictor.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Qwen3 Code Predictor -- optimized re-prefill, no KV cache. Shared by Qwen3-Omni and Qwen3-TTS talker models. @@ -97,7 +100,11 @@ class _RotaryEmbedding(CustomOp): "head_dim", config.hidden_size // config.num_attention_heads, ) - rope_theta = getattr(config, "rope_theta", 10000.0) + rope_parameters = getattr(config, "rope_parameters", None) or {} + rope_theta = rope_parameters.get( + "rope_theta", + getattr(config, "rope_theta", 10000.0), + ) inv_freq = 1.0 / (rope_theta ** (torch.arange(0, head_dim, 2, dtype=torch.float32) / head_dim)) self.register_buffer("inv_freq", inv_freq, persistent=False) From 087ac46318c42d1fa1e4de13a8053f94683898bd Mon Sep 17 00:00:00 2001 From: Chenguang Zheng <645327136@qq.com> Date: Tue, 8 Sep 2026 10:30:26 +0800 Subject: [PATCH 32/90] [Refactor 1/N] Centralize Omni config resolution (#5140) Signed-off-by: Chenguang ZHENG <645327136@qq.com> --- docs/design/architecture_overview.md | 23 +- docs/mkdocs/hooks/generate_argparse.py | 1 + tests/config/test_config_factory.py | 9 +- tests/config/test_omni_config.py | 64 ++ .../minimax_h3/test_minimax_h3_contract.py | 13 +- .../test_diffusion_config_propagation.py | 2 +- .../diffusion/test_diffusion_step_pipeline.py | 4 +- .../test_async_omni_engine_stage_init.py | 129 +--- .../test_async_omni_diffusion_config.py | 273 +++----- tests/entrypoints/test_pd_disaggregation.py | 7 +- .../test_resolve_dreamzero_config.py | 32 +- tests/entrypoints/test_serve.py | 112 ++-- tests/entrypoints/test_utils.py | 412 +++++------- vllm_omni/config/__init__.py | 4 + vllm_omni/config/config_factory.py | 162 +++-- vllm_omni/config/pipeline_registry.py | 5 +- vllm_omni/config/resolver.py | 266 ++++++++ vllm_omni/diffusion/data.py | 121 ++-- vllm_omni/engine/arg_utils.py | 2 +- vllm_omni/engine/async_omni_engine.py | 537 +++------------- vllm_omni/engine/stage_init_utils.py | 9 +- vllm_omni/entrypoints/cli/serve.py | 54 +- vllm_omni/entrypoints/utils.py | 606 +----------------- 23 files changed, 1035 insertions(+), 1812 deletions(-) create mode 100644 vllm_omni/config/resolver.py diff --git a/docs/design/architecture_overview.md b/docs/design/architecture_overview.md index 5b14dd8ec..58722ffa0 100644 --- a/docs/design/architecture_overview.md +++ b/docs/design/architecture_overview.md @@ -323,18 +323,24 @@ inputs, fields, and ownership rules are described below. ```mermaid flowchart TB layer1["Layer 1 · Authoring inputs
PipelineConfig + DeployConfig"] - layer2["Layer 2 · Resolve once
StageConfigFactory.create_from_model()
VllmOmniConfig.from_pipeline_config()"] - layer3["Layer 3 · Transport-safe control plane
VllmOmniConfig"] + layer2["Layer 2 · Production resolution boundary
resolve_omni_config()
(StageConfigFactory + VllmOmniConfig internally)"] + layer3["Layer 3 · Startup hand-off
OmniConfigResolution
PipelineConfig + temporary OmegaConf stage bridge"] layer4["Layer 4 · Runtime launch planning
StageRuntime"] layer5["Layer 5 · Engine materialization
VllmConfig / OmniDiffusionConfig"] layer1 --> layer2 --> layer3 --> layer4 --> layer5 ``` -The single resolution boundary is implemented by -`StageConfigFactory.create_from_model()` and -`VllmOmniConfig.from_pipeline_config()` in +The production resolution boundary is `resolve_omni_config()` in [`vllm_omni/config`](https://github.com/vllm-project/vllm-omni/tree/main/vllm_omni/config). +It delegates typed construction to `StageConfigFactory.create_from_model()` and +`VllmOmniConfig.from_pipeline_config()`, then returns an +`OmniConfigResolution` consumed by both `AsyncOmniEngine` and headless startup. +Until `StageRuntime` consumes typed stage configs directly, this envelope carries +the effective `PipelineConfig` alongside OmegaConf-compatible `stage_configs` as +a temporary runtime bridge; both views describe the same resolved topology, +including injected stages. + The legacy `stage_args` YAML path has been removed. Model topology now resolves through `PipelineConfig`, with runtime overrides supplied by `DeployConfig`. @@ -354,9 +360,12 @@ The important ownership rules are: deploy-time defaults; it does not redefine the model graph. 3. CLI and Python overrides are applied at the resolution boundary, with per-stage overrides taking precedence over global values where supported. -4. `StageRuntime` owns launch planning and replica lifecycle. `ReplicaInitPlan` +4. `OmniConfigResolution` is the sole startup hand-off. Its `pipeline_config` + and temporary `stage_configs` compatibility view must describe the same + topology. +5. `StageRuntime` owns launch planning and replica lifecycle. `ReplicaInitPlan` is runtime-private state, not a user configuration object. -5. `VllmConfig` and the enriched `OmniDiffusionConfig` are materialized in the +6. `VllmConfig` and the enriched `OmniDiffusionConfig` are materialized in the process that owns the corresponding engine. ## Main features diff --git a/docs/mkdocs/hooks/generate_argparse.py b/docs/mkdocs/hooks/generate_argparse.py index b695185d1..4c3ad3373 100644 --- a/docs/mkdocs/hooks/generate_argparse.py +++ b/docs/mkdocs/hooks/generate_argparse.py @@ -124,6 +124,7 @@ def extract_omni_serve_subparser_init(): "make_arg_parser": lambda parser: parser, # no-op for doc "_ensure_vllm_platform": lambda: None, # no-op for doc "_nonneg_finite_float": float, + "_parse_stage_overrides": __import__("json").loads, "VLLM_SUBCMD_PARSER_EPILOG": "", "logger": logger, "DummySubparsers": DummySubparsers, diff --git a/tests/config/test_config_factory.py b/tests/config/test_config_factory.py index 3723e5523..54d04cb82 100644 --- a/tests/config/test_config_factory.py +++ b/tests/config/test_config_factory.py @@ -6,7 +6,6 @@ Unit tests for StageConfigFactory and related classes. import importlib import warnings -from dataclasses import dataclass from pathlib import Path from unittest.mock import patch @@ -37,6 +36,7 @@ from vllm_omni.config.stage_config import ( normalize_pipeline_cli_overrides, pipeline_cfg_resolver, ) +from vllm_omni.diffusion.data import DiffusionParallelConfig from vllm_omni.engine.arg_utils import SHARED_FIELDS, internal_blacklist_keys pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -451,13 +451,8 @@ class TestStageConfigFactory: def test_default_diffusion_with_parallel_config(self): """Test diffusion config calculates devices from parallel_config.""" - - @dataclass - class MockParallelConfig: - world_size: int = 4 - kwargs = { - "parallel_config": MockParallelConfig(), + "parallel_config": DiffusionParallelConfig(tensor_parallel_size=4), "cache_backend": "tea_cache", } configs = StageConfigFactory.create_default_diffusion(kwargs) diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index f91e6af42..aed9c970d 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -1253,6 +1253,70 @@ def test_diffusion_config_from_kwargs_reuses_legacy_normalization(monkeypatch): assert cfg.diffusers_call_kwargs == {} +def test_diffusion_config_none_values_preserve_dataclass_defaults(): + from vllm_omni.diffusion.data import OmniDiffusionConfig + + normalized = OmniDiffusionConfig.normalize_init_kwargs( + { + "lora_scale": None, + "enable_sleep_mode": None, + "diffusers_load_kwargs": None, + } + ) + + assert "lora_scale" not in normalized + assert "enable_sleep_mode" not in normalized + assert normalized["diffusers_load_kwargs"] == {} + + config = OmniDiffusionConfig.from_kwargs( + lora_scale=None, + enable_sleep_mode=None, + ) + assert config.lora_scale == 1.0 + assert config.enable_sleep_mode is False + + +@pytest.mark.parametrize( + ("canonical_key", "alias_key", "canonical_value", "alias_value"), + [ + ("lora_scale", "static_lora_scale", 0.75, 0.25), + ( + "quantization_config", + "diffusion_quantization_config", + {"method": "canonical"}, + {"method": "diffusion-alias"}, + ), + ( + "quantization_config", + "quantization", + {"method": "canonical"}, + "legacy-alias", + ), + ("diffusion_kv_cache_dtype", "kv_cache_dtype", "fp8", "fp16"), + ("diffusion_kv_cache_skip_steps", "kv_cache_skip_steps", "0-1", "2-3"), + ("diffusion_kv_cache_skip_layers", "kv_cache_skip_layers", "1-2", "3-4"), + ("streaming_output", "diffusion_streaming_output", False, True), + ], +) +def test_diffusion_alias_conflicts_prefer_canonical_key( + canonical_key, + alias_key, + canonical_value, + alias_value, +): + from vllm_omni.diffusion.data import normalize_omni_diffusion_kwargs + + normalized = normalize_omni_diffusion_kwargs( + { + canonical_key: canonical_value, + alias_key: alias_value, + } + ) + + assert normalized[canonical_key] == canonical_value + assert alias_key not in normalized + + def test_from_pipeline_config_normalizes_diffusion_config_aliases_from_engine_args(tmp_path, monkeypatch): from vllm_omni.platforms import current_omni_platform diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py index 114f5eab3..a94f842c5 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_contract.py @@ -177,9 +177,9 @@ def _write_partition_index(path, *, partition, tasks): def test_modular_diffusers_index_is_resolved_generically(tmp_path): + from vllm_omni.config.resolver import resolve_omni_config from vllm_omni.diffusion.data import OmniDiffusionConfig, resolve_model_class_name from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model - from vllm_omni.entrypoints.utils import resolve_model_config_path (tmp_path / "modular_model_index.json").write_text( json.dumps( @@ -193,7 +193,16 @@ def test_modular_diffusers_index_is_resolved_generically(tmp_path): assert is_diffusion_model(str(tmp_path)) assert resolve_model_class_name(str(tmp_path)) == "MiniMaxH3ModularPipeline" - assert resolve_model_config_path(str(tmp_path)) is None + resolved = resolve_omni_config( + str(tmp_path), + trust_remote_code=False, + deploy_config_path=None, + cli_overrides=None, + stage_overrides=None, + strategy_config_path=None, + ) + assert resolved.config_path is None + assert resolved.stage_by_id(0).engine_args.model_class_name == "MiniMaxH3ModularPipeline" config = OmniDiffusionConfig(model=str(tmp_path)) config.enrich_config() diff --git a/tests/diffusion/test_diffusion_config_propagation.py b/tests/diffusion/test_diffusion_config_propagation.py index 164fcfbeb..394f7c04f 100644 --- a/tests/diffusion/test_diffusion_config_propagation.py +++ b/tests/diffusion/test_diffusion_config_propagation.py @@ -131,7 +131,7 @@ class TestCreateDefaultDiffusion: def test_dtype_serialized_as_string(self): stages = StageConfigFactory.create_default_diffusion({"dtype": torch.float16, "model": "x"}) - assert stages[0]["engine_args"]["dtype"] == "torch.float16" + assert stages[0]["engine_args"]["dtype"] == "float16" def test_cache_backend_defaults_to_none(self): stages = StageConfigFactory.create_default_diffusion({"model": "x"}) diff --git a/tests/diffusion/test_diffusion_step_pipeline.py b/tests/diffusion/test_diffusion_step_pipeline.py index 94c3eb48f..28bba6e4d 100644 --- a/tests/diffusion/test_diffusion_step_pipeline.py +++ b/tests/diffusion/test_diffusion_step_pipeline.py @@ -15,6 +15,7 @@ from pytest_mock import MockerFixture import vllm_omni.diffusion.worker.diffusion_model_runner as model_runner_module from tests.helpers.mark import hardware_test +from vllm_omni.config.config_factory import StageConfigFactory from vllm_omni.diffusion.data import DiffusionOutput from vllm_omni.diffusion.diffusion_engine import DiffusionEngine from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode @@ -46,7 +47,6 @@ from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunn from vllm_omni.diffusion.worker.diffusion_worker import DiffusionWorker from vllm_omni.diffusion.worker.input_batch import InputBatch from vllm_omni.diffusion.worker.utils import RunnerOutput, StepRequestState -from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.errors import OmniClientError from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.platforms import current_omni_platform @@ -1182,7 +1182,7 @@ class TestSupportedPipelines: """Step-execution protocol checks for supported pipelines.""" def test_default_stage_config_includes_step_execution(self): - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "step_execution": True, } diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index 528e46f3a..b1771da28 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -11,7 +11,7 @@ import types import pytest -from vllm_omni.diffusion.data import AttentionConfig, AttentionSpec, normalize_omni_diffusion_kwargs +from vllm_omni.diffusion.data import AttentionConfig from vllm_omni.engine import async_omni_engine as async_omni_engine_module from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.engine.stage_init_utils import ( @@ -1732,133 +1732,6 @@ def test_inject_kv_stage_info_infers_receiver_tp_topology(): assert stage1.engine_args["omni_kv_config"]["rank_mapping"] == {"from_tp": 4, "to_tp": 2} -def test_resolve_stage_configs_injects_global_diffusion_attention_when_missing(monkeypatch): - import vllm_omni.engine.async_omni_engine as engine_mod - - engine = object.__new__(AsyncOmniEngine) - stage_cfg = types.SimpleNamespace( - stage_type="diffusion", - engine_args=types.SimpleNamespace( - diffusion_attention_config=None, - lora_path=None, - lora_scale=None, - enable_sleep_mode=None, - quantization_config=None, - ), - ) - - monkeypatch.setattr( - engine_mod, - "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), - ) - - _config_path, stage_configs = engine._resolve_stage_configs( - model="dummy-model", - kwargs={"diffusion_attention_backend": "FLASH_ATTN"}, - trust_remote_code=False, - ) - - diffusion_attention_config = stage_configs[0].engine_args.diffusion_attention_config - assert isinstance(diffusion_attention_config, AttentionConfig) - assert diffusion_attention_config.default is not None - assert diffusion_attention_config.default.backend == "FLASH_ATTN" - - -def test_resolve_stage_configs_preserves_stage_diffusion_attention(monkeypatch): - import vllm_omni.engine.async_omni_engine as engine_mod - - engine = object.__new__(AsyncOmniEngine) - existing_attention = AttentionConfig(default=AttentionSpec(backend="TORCH_SDPA")) - stage_cfg = types.SimpleNamespace( - stage_type="diffusion", - engine_args=types.SimpleNamespace( - diffusion_attention_config=existing_attention, - lora_path=None, - lora_scale=None, - enable_sleep_mode=None, - quantization_config=None, - ), - ) - - monkeypatch.setattr( - engine_mod, - "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), - ) - - _config_path, stage_configs = engine._resolve_stage_configs( - model="dummy-model", - kwargs={"diffusion_attention_backend": "FLASH_ATTN"}, - trust_remote_code=False, - ) - - assert stage_configs[0].engine_args.diffusion_attention_config is existing_attention - - -def test_resolve_stage_configs_does_not_inject_over_stage_diffusion_attention_backend(monkeypatch): - import vllm_omni.engine.async_omni_engine as engine_mod - - engine = object.__new__(AsyncOmniEngine) - stage_cfg = types.SimpleNamespace( - stage_type="diffusion", - engine_args=types.SimpleNamespace( - diffusion_attention_backend="TORCH_SDPA", - diffusion_attention_config=None, - lora_path=None, - lora_scale=None, - enable_sleep_mode=None, - quantization_config=None, - ), - ) - - monkeypatch.setattr( - engine_mod, - "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), - ) - - _config_path, stage_configs = engine._resolve_stage_configs( - model="dummy-model", - kwargs={"diffusion_attention_backend": "FLASH_ATTN"}, - trust_remote_code=False, - ) - - engine_args = stage_configs[0].engine_args - assert engine_args.diffusion_attention_backend == "TORCH_SDPA" - assert engine_args.diffusion_attention_config is None - normalized = normalize_omni_diffusion_kwargs(vars(engine_args)) - assert normalized["diffusion_attention_config"].default.backend == "TORCH_SDPA" - - -def test_resolve_stage_configs_does_not_inject_diffusion_attention_into_llm_stage(monkeypatch): - import vllm_omni.engine.async_omni_engine as engine_mod - - engine = object.__new__(AsyncOmniEngine) - stage_cfg = types.SimpleNamespace( - stage_type="llm", - engine_args=types.SimpleNamespace( - attention_config={"backend": "FLASH_ATTN"}, - enable_sleep_mode=None, - ), - ) - - monkeypatch.setattr( - engine_mod, - "load_and_resolve_stage_configs", - lambda *args, **kwargs: ("dummy-config", [stage_cfg], None), - ) - - _config_path, stage_configs = engine._resolve_stage_configs( - model="dummy-model", - kwargs={"diffusion_attention_backend": "TORCH_SDPA"}, - trust_remote_code=False, - ) - - assert stage_configs[0].engine_args.attention_config == {"backend": "FLASH_ATTN"} - assert not hasattr(stage_configs[0].engine_args, "diffusion_attention_config") - - def test_extract_legacy_stage_metadata_rocm_does_not_inject_diffusion_attention(monkeypatch): """ROCm default attention logic only applies to LLM stages, not diffusion.""" from vllm_omni.engine.stage_init_utils import extract_legacy_stage_metadata diff --git a/tests/entrypoints/test_async_omni_diffusion_config.py b/tests/entrypoints/test_async_omni_diffusion_config.py index 616d5486e..dda9ab38c 100644 --- a/tests/entrypoints/test_async_omni_diffusion_config.py +++ b/tests/entrypoints/test_async_omni_diffusion_config.py @@ -4,8 +4,12 @@ from types import SimpleNamespace import pytest +import torch +from pydantic import ValidationError -from vllm_omni.diffusion.data import AttentionConfig +from vllm_omni.config.config_factory import StageConfigFactory +from vllm_omni.config.resolver import OmniConfigResolution +from vllm_omni.diffusion.data import AttentionConfig, OmniDiffusionConfig from vllm_omni.engine import stage_init_utils from vllm_omni.engine.async_omni_engine import AsyncOmniEngine from vllm_omni.entrypoints.cli.serve import OmniServeCommand @@ -14,9 +18,13 @@ from vllm_omni.utils.tracking_parser import TrackingArgumentParser pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +def _terminal_config(stage_cfg: dict) -> OmniDiffusionConfig: + return OmniDiffusionConfig.from_kwargs(**stage_cfg["engine_args"]) + + def test_default_stage_config_includes_cache_backend(): """Ensure cache knobs survive the default diffusion-stage builder.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "cache_backend": "cache_dit", "cache_config": '{"Fn_compute_blocks": 2}', @@ -30,12 +38,12 @@ def test_default_stage_config_includes_cache_backend(): assert engine_args["cache_backend"] == "cache_dit" assert engine_args["cache_config"]["Fn_compute_blocks"] == 2 assert engine_args["vae_use_slicing"] is True - assert engine_args["parallel_config"].ulysses_degree == 2 + assert engine_args["parallel_config"]["ulysses_degree"] == 2 assert engine_args["model_stage"] == "diffusion" def test_default_stage_config_preserves_ulysses_a2a_permute() -> None: - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "ulysses_degree": 4, "ulysses_a2a_permute": True, @@ -43,18 +51,18 @@ def test_default_stage_config_preserves_ulysses_a2a_permute() -> None: )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] - assert parallel_config.ulysses_degree == 4 - assert parallel_config.ulysses_a2a_permute is True + assert parallel_config["ulysses_degree"] == 4 + assert parallel_config["ulysses_a2a_permute"] is True def test_default_stage_config_preserves_model_extras(): - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg({"extras": {"ltx2_use_conv_vae": True}})[0] + stage_cfg = StageConfigFactory.create_default_diffusion({"extras": {"ltx2_use_conv_vae": True}})[0] assert stage_cfg["engine_args"]["extras"]["ltx2_use_conv_vae"] is True def test_default_stage_config_preserves_and_overrides_promoted_extras(): - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "extras": { "auxiliary_text_encoder": "/models/extras-llama", @@ -68,7 +76,7 @@ def test_default_stage_config_preserves_and_overrides_promoted_extras(): assert extras["auxiliary_text_encoder"] == "/models/extras-llama" assert extras["default_llama_model_id"] == "extras/default-llama" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "extras": { "auxiliary_text_encoder": "/models/extras-llama", @@ -84,43 +92,57 @@ def test_default_stage_config_preserves_and_overrides_promoted_extras(): assert extras["default_llama_model_id"] == "top-level/default-llama" -def test_stage_override_preserves_model_extras_for_default_diffusion_stage(mocker): +@pytest.mark.parametrize( + "stage_overrides", + [ + {"0": {"extras": {"ltx2_use_conv_vae": True}}}, + '{"0":{"extras":{"ltx2_use_conv_vae":true}}}', + ], +) +def test_stage_override_preserves_model_extras_for_default_diffusion_stage(mocker, stage_overrides): """Local/unregistered Diffusers checkpoints still honor stage-0 extras.""" - - def resolve_with_default(*_args, default_stage_cfg_factory, **_kwargs): - return None, default_stage_cfg_factory(), None - mocker.patch( - "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - side_effect=resolve_with_default, + "vllm_omni.config.resolver.StageConfigFactory.create_from_model", + return_value=None, + ) + mocker.patch( + "vllm_omni.config.resolver._resolve_generic_diffusion_model_class", + return_value=(True, "LTX2Pipeline"), ) engine = AsyncOmniEngine.__new__(AsyncOmniEngine) _, stage_configs = engine._resolve_stage_configs( "/models/LTX-2.5-Diffusers", - {"stage_overrides": '{"0":{"extras":{"ltx2_use_conv_vae":true}}}'}, + {"stage_overrides": stage_overrides}, trust_remote_code=False, ) assert stage_configs[0]["engine_args"]["extras"]["ltx2_use_conv_vae"] is True +def test_default_stage_rejects_unknown_nested_parallel_config_key(): + unknown_key = "unknown_parallel_field" + with pytest.raises(ValidationError, match=unknown_key): + StageConfigFactory.create_default_diffusion( + {"parallel_config": {unknown_key: 2}}, + ) + + def test_default_cache_config_used_when_missing(): """Ensure default cache_config is synthesized when only backend is given.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "cache_backend": "cache_dit", } )[0] - cache_config = stage_cfg["engine_args"]["cache_config"] - assert cache_config is not None - assert cache_config["Fn_compute_blocks"] == 1 + cache_config = _terminal_config(stage_cfg).cache_config + assert cache_config.Fn_compute_blocks == 1 def test_default_stage_devices_from_sequence_parallel(): """Ensure runtime devices reflect computed diffusion world size.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "ulysses_degree": 2, "ring_degree": 2, @@ -132,7 +154,7 @@ def test_default_stage_devices_from_sequence_parallel(): def test_default_stage_devices_and_dp_from_num_gpus(): """Resolve omitted DP before deriving devices from an explicit GPU count.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "num_gpus": 8, "tensor_parallel_size": 2, @@ -141,15 +163,14 @@ def test_default_stage_devices_and_dp_from_num_gpus(): )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] - assert parallel_config.data_parallel_size == 2 - assert parallel_config.world_size == 8 + assert parallel_config["data_parallel_size"] == 2 assert stage_cfg["engine_args"]["num_gpus"] == 8 assert stage_cfg["runtime"]["devices"] == "0,1,2,3,4,5,6,7" def test_default_stage_config_uses_parallel_size_kwargs(): """Ensure default diffusion parallel_config uses CLI/API parallel sizes.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "pipeline_parallel_size": 2, "data_parallel_size": 3, @@ -159,15 +180,15 @@ def test_default_stage_config_uses_parallel_size_kwargs(): )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] - assert parallel_config.pipeline_parallel_size == 2 - assert parallel_config.data_parallel_size == 3 - assert parallel_config.tensor_parallel_size == 4 - assert parallel_config.enable_expert_parallel is True + assert parallel_config["pipeline_parallel_size"] == 2 + assert parallel_config["data_parallel_size"] == 3 + assert parallel_config["tensor_parallel_size"] == 4 + assert parallel_config["enable_expert_parallel"] is True def test_default_stage_config_preserves_omitted_dp_for_runtime_inference(): """Keep omitted DP unresolved until the runtime WORLD size is known.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "pipeline_parallel_size": None, "data_parallel_size": None, @@ -180,18 +201,19 @@ def test_default_stage_config_preserves_omitted_dp_for_runtime_inference(): )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] - assert parallel_config.pipeline_parallel_size == 1 - assert parallel_config.data_parallel_size is None - assert parallel_config.tensor_parallel_size == 1 - assert parallel_config.enable_expert_parallel is False - assert stage_cfg["engine_args"]["enforce_eager"] is False - assert stage_cfg["engine_args"]["diffusion_compile_granularity"] == "regional" - assert stage_cfg["engine_args"]["diffusion_compile_dynamic"] is True + assert parallel_config["pipeline_parallel_size"] == 1 + assert parallel_config["data_parallel_size"] is None + assert parallel_config["tensor_parallel_size"] == 1 + assert parallel_config["enable_expert_parallel"] is False + terminal_config = _terminal_config(stage_cfg) + assert terminal_config.enforce_eager is False + assert terminal_config.diffusion_compile_granularity == "regional" + assert terminal_config.diffusion_compile_dynamic is True def test_default_stage_config_propagates_ulysses_mode(): """Ensure UAA mode survives default diffusion-stage creation.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "ulysses_degree": 4, "ulysses_mode": "advanced_uaa", @@ -199,13 +221,13 @@ def test_default_stage_config_propagates_ulysses_mode(): )[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] - assert parallel_config.ulysses_degree == 4 - assert parallel_config.ulysses_mode == "advanced_uaa" + assert parallel_config["ulysses_degree"] == 4 + assert parallel_config["ulysses_mode"] == "advanced_uaa" def test_default_stage_config_includes_default_sampling_params(): """Ensure default sampling params survive the default diffusion-stage builder.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "default_sampling_params": '{"0": {"generator_device":"cpu", "guidance_scale":7.5}}', } @@ -219,13 +241,13 @@ def test_default_stage_config_includes_default_sampling_params(): def test_default_stage_config_includes_diffusion_attention_backend(): """Ensure diffusion attention shorthand lands in engine_args.diffusion_attention_config.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "diffusion_attention_backend": "FLASH_ATTN", } )[0] - diffusion_attention_config = stage_cfg["engine_args"]["diffusion_attention_config"] + diffusion_attention_config = _terminal_config(stage_cfg).diffusion_attention_config assert isinstance(diffusion_attention_config, AttentionConfig) assert diffusion_attention_config.default is not None assert diffusion_attention_config.default.backend == "FLASH_ATTN" @@ -233,7 +255,7 @@ def test_default_stage_config_includes_diffusion_attention_backend(): def test_default_stage_config_includes_diffusion_attention_config(): """Ensure structured diffusion attention config survives default stage creation.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "diffusion_attention_config": { "default": {"backend": "FLASH_ATTN"}, @@ -242,7 +264,7 @@ def test_default_stage_config_includes_diffusion_attention_config(): } )[0] - diffusion_attention_config = stage_cfg["engine_args"]["diffusion_attention_config"] + diffusion_attention_config = _terminal_config(stage_cfg).diffusion_attention_config assert isinstance(diffusion_attention_config, AttentionConfig) assert diffusion_attention_config.default is not None assert diffusion_attention_config.default.backend == "FLASH_ATTN" @@ -252,7 +274,7 @@ def test_default_stage_config_includes_diffusion_attention_config(): def test_default_stage_config_rejects_conflicting_diffusion_attention_inputs(): """Ensure shorthand and default.backend stay mutually exclusive.""" with pytest.raises(ValueError, match="mutually exclusive"): - AsyncOmniEngine._create_default_diffusion_stage_cfg( + StageConfigFactory.create_default_diffusion( { "diffusion_attention_backend": "FLASH_ATTN", "diffusion_attention_config": { @@ -264,7 +286,7 @@ def test_default_stage_config_rejects_conflicting_diffusion_attention_inputs(): def test_default_stage_config_engine_args(): """Ensure default diffusion-stage builder sets and propagates engine_args.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { "distributed_executor_backend": "ray", "boundary_ratio": 0.875, @@ -283,7 +305,7 @@ def test_default_stage_config_engine_args(): def test_default_stage_config_whitelist_none_fallback(): """DeployConfig / StageDeployConfig whitelist fields with value None fall back to OmniDiffusionConfig dataclass defaults.""" - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg( + stage_cfg = StageConfigFactory.create_default_diffusion( { # DeployConfig pipeline-wide "trust_remote_code": None, @@ -294,12 +316,11 @@ def test_default_stage_config_whitelist_none_fallback(): } )[0] - engine_args = stage_cfg["engine_args"] - - assert engine_args["trust_remote_code"] is False - assert engine_args["distributed_executor_backend"] is None - assert engine_args["dtype"] == "auto" - assert engine_args["enforce_eager"] is False + terminal = _terminal_config(stage_cfg) + assert terminal.trust_remote_code is False + assert terminal.distributed_executor_backend is None + assert terminal.dtype == torch.bfloat16 + assert terminal.enforce_eager is False def test_serve_cli_accepts_ulysses_mode(): @@ -321,12 +342,12 @@ def test_serve_cli_accepts_ulysses_mode(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] assert args.ulysses_mode == "advanced_uaa" - assert parallel_config.ulysses_degree == 4 - assert parallel_config.ulysses_mode == "advanced_uaa" + assert parallel_config["ulysses_degree"] == 4 + assert parallel_config["ulysses_mode"] == "advanced_uaa" def test_serve_cli_accepts_text_encoder_tp_size(): @@ -345,11 +366,11 @@ def test_serve_cli_accepts_text_encoder_tp_size(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] parallel_config = stage_cfg["engine_args"]["parallel_config"] assert args.text_encoder_tp_size == 4 - assert parallel_config.text_encoder_tp_size == 4 + assert parallel_config["text_encoder_tp_size"] == 4 def test_serve_cli_forwards_model_defined_task_type_to_diffusion_stage(): @@ -368,7 +389,7 @@ def test_serve_cli_forwards_model_defined_task_type_to_diffusion_stage(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] assert args.task_type == "fl2va" assert stage_cfg["engine_args"]["task_type"] == "fl2va" @@ -390,7 +411,7 @@ def test_serve_cli_accepts_diffusion_pipeline_profiler_flag(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] assert args.enable_diffusion_pipeline_profiler is True assert stage_cfg["engine_args"]["enable_diffusion_pipeline_profiler"] is True @@ -416,7 +437,7 @@ def test_serve_cli_forwards_distilled_lora_to_diffusion_stage(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] engine_args = stage_cfg["engine_args"] assert explicit_kwargs["lora_backend"] == "distill" @@ -447,7 +468,7 @@ def test_serve_cli_forwards_compact_diffusion_offload_config(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] engine_args = stage_cfg["engine_args"] expected = { @@ -513,7 +534,7 @@ def test_serve_cli_forwards_hwr_policy_for_no_allgather_dlo(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] engine_args = stage_cfg["engine_args"] assert explicit_kwargs["host_weight_runtime_mode"] == "preferred" @@ -542,7 +563,7 @@ def test_serve_cli_accepts_diffusion_compile_controls(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] assert args.diffusion_compile_granularity == "full" assert args.diffusion_compile_dynamic is False @@ -569,7 +590,7 @@ def test_serve_cli_accepts_diffusion_attention_backend(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] diffusion_attention_config = stage_cfg["engine_args"]["diffusion_attention_config"] assert args.diffusion_attention_backend == "FASTVIDEO_VSA" @@ -597,7 +618,7 @@ def test_serve_cli_accepts_request_batch_max_wait_ms(): ) explicit_kwargs = args.get_explicit_kwargs_dict() - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(explicit_kwargs)[0] + stage_cfg = StageConfigFactory.create_default_diffusion(explicit_kwargs)[0] assert args.request_batch_max_wait_ms == 250.0 assert stage_cfg["engine_args"]["request_batch_max_wait_ms"] == 250.0 @@ -637,7 +658,7 @@ def test_serve_cli_accepts_additional_config(): ] ) - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg(vars(args))[0] + stage_cfg = StageConfigFactory.create_default_diffusion(vars(args))[0] engine_args = stage_cfg["engine_args"] @@ -645,79 +666,19 @@ def test_serve_cli_accepts_additional_config(): assert engine_args["additional_config"] == {"torchair_graph_config": {"enabled": True}} -def test_default_stage_resolves_video_output_from_checkpoint(mocker): - captured = {} - - def resolve_with_default(*args, default_stage_cfg_factory, **kwargs): - del args, kwargs - captured.update(default_stage_cfg_factory()[0]) - stage = SimpleNamespace(stage_type="diffusion", engine_args=SimpleNamespace()) - return ("", [stage], None) - - resolver = mocker.patch( - "vllm_omni.engine.async_omni_engine.resolve_model_class_name", - return_value="MiniMaxH3Pipeline", - ) - mocker.patch( - "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - side_effect=resolve_with_default, - ) - engine = AsyncOmniEngine.__new__(AsyncOmniEngine) - engine._strip_single_engine_args = lambda kwargs: kwargs - - engine._resolve_stage_configs( - "/models/MiniMax-H3/FL2VA", - {}, - trust_remote_code=False, - ) - - resolver.assert_called_once_with("/models/MiniMax-H3/FL2VA", "default") - assert captured["final_output_type"] == "video" - - -def test_default_diffusers_stage_preserves_video_model_identity(mocker): - captured = {} - - def resolve_with_default(*args, default_stage_cfg_factory, **kwargs): - del args, kwargs - captured.update(default_stage_cfg_factory()[0]) - stage = SimpleNamespace(stage_type="diffusion", engine_args=SimpleNamespace()) - return ("", [stage], None) - - mocker.patch( - "vllm_omni.diffusion.utils.hf_utils.get_diffusion_model_index", - return_value={"_class_name": "WanImageToVideoPipeline"}, - ) - mocker.patch( - "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - side_effect=resolve_with_default, - ) - engine = AsyncOmniEngine.__new__(AsyncOmniEngine) - engine._strip_single_engine_args = lambda kwargs: kwargs - - engine._resolve_stage_configs( - "/models/Wan2.2-I2V", - {"diffusion_load_format": "diffusers"}, - trust_remote_code=False, - ) - - assert captured["engine_args"]["model_class_name"] == "WanImageToVideoPipeline" - assert captured["final_output_type"] == "video" - - -def test_resolve_stage_configs_injects_additional_config_into_diffusion_stage(mocker): - """Ensure YAML/deploy stage resolution forwards top-level additional_config.""" +def test_resolve_stage_configs_delegates_overrides_to_resolver(mocker): + """The engine consumes resolver output without a second merge pass.""" + additional_config = {"torchair_graph_config": {"enabled": True}} fake_diffusion_stage = SimpleNamespace( stage_type="diffusion", - engine_args=SimpleNamespace(), + engine_args=SimpleNamespace(additional_config=additional_config), ) - fake_llm_stage = SimpleNamespace( - stage_type="llm", - engine_args=SimpleNamespace(), - ) - mocker.patch( - "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - return_value=("dummy.yaml", [fake_llm_stage, fake_diffusion_stage], None), + resolve_config = mocker.patch( + "vllm_omni.engine.async_omni_engine.resolve_omni_config", + return_value=OmniConfigResolution( + config_path="dummy.yaml", + stage_configs=(fake_diffusion_stage,), + ), ) engine = AsyncOmniEngine.__new__(AsyncOmniEngine) @@ -726,13 +687,15 @@ def test_resolve_stage_configs_injects_additional_config_into_diffusion_stage(mo "dummy-model", { "deploy_config": "dummy.yaml", - "additional_config": {"torchair_graph_config": {"enabled": True}}, + "additional_config": additional_config, }, trust_remote_code=False, ) - assert not hasattr(stage_configs[0].engine_args, "additional_config") - assert stage_configs[1].engine_args.additional_config == {"torchair_graph_config": {"enabled": True}} + assert stage_configs == [fake_diffusion_stage] + assert resolve_config.call_args.args == ("dummy-model",) + assert resolve_config.call_args.kwargs["deploy_config_path"] == "dummy.yaml" + assert resolve_config.call_args.kwargs["cli_overrides"]["additional_config"] is additional_config @pytest.mark.parametrize( @@ -760,30 +723,6 @@ def test_default_stage_config_includes_quantization_config(): "weights": "weights.bin", } - stage_cfg = AsyncOmniEngine._create_default_diffusion_stage_cfg({"quantization_config": quantization_config})[0] + stage_cfg = StageConfigFactory.create_default_diffusion({"quantization_config": quantization_config})[0] assert stage_cfg["engine_args"]["quantization_config"] == quantization_config - - -def test_resolve_stage_configs_injects_quantization_config_into_diffusion_stage(mocker): - fake_diffusion_stage = SimpleNamespace( - stage_type="diffusion", - engine_args=SimpleNamespace(quantization_config=None), - ) - mocker.patch( - "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", - return_value=("dummy.yaml", [fake_diffusion_stage], None), - ) - - engine = AsyncOmniEngine.__new__(AsyncOmniEngine) - - _, stage_configs = engine._resolve_stage_configs( - "dummy-model", - { - "deploy_config": "dummy.yaml", - "quantization_config": {"method": "bitsandbytes"}, - }, - trust_remote_code=False, - ) - - assert stage_configs[0].engine_args.quantization_config == {"method": "bitsandbytes"} diff --git a/tests/entrypoints/test_pd_disaggregation.py b/tests/entrypoints/test_pd_disaggregation.py index c896ccb88..e9a3ba54c 100644 --- a/tests/entrypoints/test_pd_disaggregation.py +++ b/tests/entrypoints/test_pd_disaggregation.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Unit tests for PD (Prefill-Decode) disaggregation in the Omni orchestrator. Tests the PD detection, validation, config parsing, sampling param @@ -74,7 +77,7 @@ class _FakeStageConfig: class _FakeQueue: def __init__(self, maxsize=0): - self._queue = Queue(maxsize=maxsize) + self._queue: Queue[Any] = Queue(maxsize=maxsize) def put(self, item): self._queue.put(item) @@ -343,7 +346,7 @@ def mock_get_config(monkeypatch): monkeypatch.setattr( "vllm.transformers_utils.config.get_config", lambda model, **kwargs: fake_hf_config, raising=False ) - monkeypatch.setattr("vllm_omni.entrypoints.utils.get_config", lambda model, **kwargs: fake_hf_config, raising=False) + monkeypatch.setattr("vllm_omni.config.config_factory.get_config", lambda model, **kwargs: fake_hf_config) def _mock_cached_file(path_or_repo_id, *args, **kwargs): import os diff --git a/tests/entrypoints/test_resolve_dreamzero_config.py b/tests/entrypoints/test_resolve_dreamzero_config.py index 660203710..450e1be39 100644 --- a/tests/entrypoints/test_resolve_dreamzero_config.py +++ b/tests/entrypoints/test_resolve_dreamzero_config.py @@ -1,28 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + import pytest +from vllm_omni.config.resolver import resolve_omni_config from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.stage_diffusion_proc import StageDiffusionProc -from vllm_omni.entrypoints.utils import load_stage_configs_from_model, resolve_model_config_path pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -def test_dreamzero_vla_resolves_to_dreamzero_config(monkeypatch): - monkeypatch.setattr( - "vllm_omni.entrypoints.utils.get_config", - lambda _model, trust_remote_code=True: type("Cfg", (), {"model_type": "vla"})(), - ) - monkeypatch.setattr( - "vllm_omni.entrypoints.utils._looks_like_dreamzero", - lambda _model: True, - ) - result = resolve_model_config_path("GEAR-Dreams/DreamZero-DROID") - - assert result is not None - assert result.endswith("vllm_omni/deploy/dreamzero.yaml") - - -def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch): +def test_dreamzero_resolves_through_registry_with_model_defaults(monkeypatch): monkeypatch.setattr( "vllm_omni.config.config_factory.StageConfigFactory._try_infer_model_type", classmethod(lambda _cls, model, trust_remote_code=True: "vla"), @@ -36,12 +24,18 @@ def test_dreamzero_config_sets_model_class_and_policy_config(monkeypatch): lambda _model: True, ) - stage_configs, _ = load_stage_configs_from_model( + resolved = resolve_omni_config( "GEAR-Dreams/DreamZero-DROID", trust_remote_code=False, + deploy_config_path=None, + cli_overrides=None, + stage_overrides=None, + strategy_config_path=None, ) - engine_args = stage_configs[0].engine_args + engine_args = resolved.stage_configs[0].engine_args + assert resolved.config_path is not None + assert resolved.config_path.endswith("vllm_omni/deploy/dreamzero.yaml") assert engine_args.model_class_name == "DreamZeroPipeline" assert engine_args.model_config.policy_server_config.action_space == "joint_position" diff --git a/tests/entrypoints/test_serve.py b/tests/entrypoints/test_serve.py index 29090c425..b64758e5d 100644 --- a/tests/entrypoints/test_serve.py +++ b/tests/entrypoints/test_serve.py @@ -11,13 +11,22 @@ from types import SimpleNamespace import pytest from pytest_mock import MockerFixture -from vllm_omni.entrypoints.cli.serve import OmniServeCommand, run_headless +from vllm_omni.config.resolver import OmniConfigResolution +from vllm_omni.entrypoints.cli.serve import ( + OmniServeCommand, + _parse_stage_overrides, + run_headless, +) from vllm_omni.entrypoints.utils import parse_stage_overrides from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +def _resolved(*stages: SimpleNamespace) -> OmniConfigResolution: + return OmniConfigResolution(config_path="/fake/stages.yaml", stage_configs=tuple(stages)) + + def test_serve_parser_accepts_no_async_chunk_and_marks_it_explicit() -> None: """``--no-async-chunk`` should parse to ``async_chunk=False`` and mark the shared deploy-level dest as explicitly provided by the user.""" @@ -110,7 +119,7 @@ def test_serve_parser_accepts_diffusion_quantization_config() -> None: assert args.get_explicit_kwargs_dict()["diffusion_quantization_config"] == expected -def _make_headless_args(**kwargs) -> TrackingNamespace: +def _make_headless_args(*, explicit_keys: frozenset[str] | None = None, **kwargs) -> TrackingNamespace: defaults = { "model": "fake-model", "stage_id": 0, @@ -130,7 +139,7 @@ def _make_headless_args(**kwargs) -> TrackingNamespace: ns = argparse.Namespace(**ns_kwargs) return TrackingNamespace( unfiltered_ns=ns, - explicit_keys=frozenset(ns.__dict__.keys()), + explicit_keys=frozenset(ns.__dict__.keys()) if explicit_keys is None else explicit_keys, ) @@ -159,41 +168,49 @@ def test_run_headless_rejects_non_multiprocess_worker_backend() -> None: # --------------------------------------------------------------------------- -# --stage-overrides parsing parity (headless vs standard path) +# --stage-overrides parsing at the serving boundary # --------------------------------------------------------------------------- def test_parse_stage_overrides_valid_json() -> None: """A valid JSON string is parsed into the nested per-stage dict.""" - parsed = parse_stage_overrides('{"0": {"devices": "0,1"}, "1": {"devices": "2"}}') + parsed = _parse_stage_overrides('{"0": {"devices": "0,1"}, "1": {"devices": "2"}}') assert parsed == {"0": {"devices": "0,1"}, "1": {"devices": "2"}} -def test_parse_stage_overrides_none_and_empty_return_none() -> None: - """No overrides (None / empty string) resolve to ``None``.""" - assert parse_stage_overrides(None) is None - assert parse_stage_overrides("") is None +def test_serve_parser_parses_stage_overrides_before_resolution() -> None: + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + OmniServeCommand().subparser_init(subparsers) + + args = parser.parse_args( + [ + "serve", + "fake-model", + "--omni", + "--stage-overrides", + '{"0": {"devices": "0,1"}}', + ] + ) + + assert args.stage_overrides == {"0": {"devices": "0,1"}} -def test_parse_stage_overrides_empty_dict_returns_none() -> None: - """An empty dict is falsy and must resolve to ``None``, locking in parity - with the original standard-path ``if stage_overrides_json:`` falsy check.""" - assert parse_stage_overrides({}) is None +def test_serve_parser_accepts_empty_stage_overrides_as_noop() -> None: + parser = TrackingArgumentParser() + subparsers = parser.add_subparsers(dest="subcommand") + OmniServeCommand().subparser_init(subparsers) + args = parser.parse_args(["serve", "fake-model", "--omni", "--stage-overrides", "{}"]) -def test_parse_stage_overrides_passes_through_non_str() -> None: - """An already-parsed mapping is returned unchanged (identity).""" - overrides = {"0": {"devices": "0"}} - assert parse_stage_overrides(overrides) is overrides + assert args.stage_overrides == {} def test_parse_stage_overrides_invalid_json_raises() -> None: - """Invalid JSON raises ValueError whose message matches the standard path - verbatim: the ``--stage-overrides is not valid JSON:`` prefix AND the - ``Got: `` suffix echoing the raw input.""" + """Invalid JSON fails at the serving boundary with the raw input.""" bad = "{not valid json}" - with pytest.raises(ValueError) as excinfo: - parse_stage_overrides(bad) + with pytest.raises(argparse.ArgumentTypeError) as excinfo: + _parse_stage_overrides(bad) message = str(excinfo.value) assert message.startswith("--stage-overrides is not valid JSON:") assert f"Got: {bad!r}" in message @@ -271,11 +288,8 @@ def test_parse_stage_overrides_accepts_empty_inner_dict() -> None: assert parsed == {"0": {}, "1": {}} -def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) -> None: - """Regression: the headless path must parse ``--stage-overrides`` (a JSON - string) and forward the parsed dict to ``load_and_resolve_stage_configs``, - mirroring the standard engine path. Previously it was dropped entirely, - silently producing a different per-stage device layout.""" +def test_run_headless_forwards_parsed_stage_overrides(mocker: MockerFixture) -> None: + """The headless resolver receives the mapping parsed by argparse.""" captured: dict = {} def _fake_resolve(*args, **kwargs): @@ -283,10 +297,10 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) captured.update(kwargs) # Return a stage that does NOT match stage_id=0 so run_headless stops # right after the resolver call (we only care about how it was called). - return ("/fake/stages.yaml", [SimpleNamespace(stage_id=99)], None) + return _resolved(SimpleNamespace(stage_id=99)) mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", + "vllm_omni.config.resolver.resolve_omni_config", side_effect=_fake_resolve, ) @@ -294,28 +308,22 @@ def test_run_headless_parses_and_forwards_stage_overrides(mocker: MockerFixture) stage_id=0, deploy_config="/tmp/deploy.yaml", strategy_config="/tmp/strategy.yaml", - stage_overrides='{"0": {"devices": "0,1"}, "1": {"devices": "2"}}', + stage_overrides={"0": {"devices": "0,1"}, "1": {"devices": "2"}}, ) with pytest.raises(ValueError, match="No stage config found for stage_id=0"): run_headless(args) - assert len(captured["args"]) == 2 + assert captured["args"] == ("fake-model",) assert captured["deploy_config_path"] == "/tmp/deploy.yaml" assert captured["stage_overrides"] == {"0": {"devices": "0,1"}, "1": {"devices": "2"}} assert captured["strategy_config_path"] == "/tmp/strategy.yaml" -def test_run_headless_invalid_stage_overrides_raises(mocker: MockerFixture) -> None: - """Invalid ``--stage-overrides`` JSON in headless mode fails fast with the - shared ValueError instead of being silently ignored.""" - mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [SimpleNamespace(stage_id=0)], None), - ) - - args = _make_headless_args(stage_id=0, stage_overrides="{not valid json}") - with pytest.raises(ValueError, match="--stage-overrides is not valid JSON"): - run_headless(args) +def test_parse_stage_overrides_rejects_non_mapping_values() -> None: + with pytest.raises(argparse.ArgumentTypeError, match="JSON object"): + _parse_stage_overrides('["not", "a", "mapping"]') + with pytest.raises(argparse.ArgumentTypeError, match="must be an object"): + _parse_stage_overrides('{"0": "not a mapping"}') def test_run_headless_raises_when_stage_id_not_in_configs(mocker: MockerFixture) -> None: @@ -323,8 +331,8 @@ def test_run_headless_raises_when_stage_id_not_in_configs(mocker: MockerFixture) fails fast when the launcher's --stage-id doesn't match any entry.""" other_stage = SimpleNamespace(stage_id=99) mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [other_stage], None), + "vllm_omni.config.resolver.resolve_omni_config", + return_value=_resolved(other_stage), ) args = _make_headless_args(stage_id=0) @@ -372,8 +380,8 @@ def test_run_headless_llm_registers_with_auto_assigned_replica_id(mocker: Mocker engine_manager = mocker.Mock() mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg], None), + "vllm_omni.config.resolver.resolve_omni_config", + return_value=_resolved(stage_cfg), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -452,8 +460,8 @@ def test_run_headless_llm_launches_one_manager_per_omni_dp_size_local(mocker: Mo manager_b = mocker.Mock() mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg], None), + "vllm_omni.config.resolver.resolve_omni_config", + return_value=_resolved(stage_cfg), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -511,8 +519,8 @@ def test_run_headless_diffusion_registers_and_spawns_proc(mocker: MockerFixture) proc.is_alive.return_value = False mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg], None), + "vllm_omni.config.resolver.resolve_omni_config", + return_value=_resolved(stage_cfg), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) @@ -591,8 +599,8 @@ def test_run_headless_diffusion_raises_on_nonzero_proc_exit(mocker: MockerFixtur proc.is_alive.return_value = False mocker.patch( - "vllm_omni.entrypoints.utils.load_and_resolve_stage_configs", - return_value=("/fake/stages.yaml", [stage_cfg], None), + "vllm_omni.config.resolver.resolve_omni_config", + return_value=_resolved(stage_cfg), ) mocker.patch("vllm_omni.engine.stage_init_utils.prepare_engine_environment") mocker.patch("vllm_omni.engine.stage_init_utils.load_omni_transfer_config_for_model", return_value=None) diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/test_utils.py index 6bc6cdc2a..fa6bf8dbe 100644 --- a/tests/entrypoints/test_utils.py +++ b/tests/entrypoints/test_utils.py @@ -4,10 +4,8 @@ """Unit tests for vllm_omni.entrypoints.utils module.""" import logging -import os from collections import Counter from dataclasses import dataclass -from pathlib import Path from types import SimpleNamespace import pytest @@ -15,24 +13,20 @@ import torch from pytest_mock import MockerFixture from vllm.sampling_params import RequestOutputKind, SamplingParams +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES +from vllm_omni.config.resolver import ( + OmniConfigResolution, + _convert_dataclasses_to_dict, + _filter_dict_like_object, + resolve_omni_config, +) +from vllm_omni.config.stage_config import PipelineConfig from vllm_omni.config.yaml_util import create_config from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.engine.arg_utils import OmniEngineArgs -from vllm_omni.engine.async_omni_engine import AsyncOmniEngine -from vllm_omni.engine.stage_init_utils import ( - get_stage_connector_spec, - load_omni_transfer_config_for_model, -) from vllm_omni.entrypoints.utils import ( - _convert_dataclasses_to_dict, - _filter_dict_like_object, - _try_resolve_omni_model_type, coerce_param_message_types, filter_dataclass_kwargs, - filter_stages, - load_and_resolve_stage_configs, - load_stage_configs_from_model, - resolve_model_config_path, ) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -173,7 +167,7 @@ class TestFilterDictLikeObject: "normal": "value", } - mocker.patch("vllm_omni.entrypoints.utils.logger") + mocker.patch("vllm_omni.config.resolver.logger") result = _filter_dict_like_object(input_dict) # Normal key should exist @@ -195,7 +189,7 @@ class TestFilterDictLikeObject: "normal": "value", } - mocker.patch("vllm_omni.entrypoints.utils.logger") + mocker.patch("vllm_omni.config.resolver.logger") result = _filter_dict_like_object(input_dict) # Callable should be filtered @@ -215,7 +209,7 @@ class TestConvertDataclassesToDict: "callable": lambda x: x, } - mocker.patch("vllm_omni.entrypoints.utils.logger") + mocker.patch("vllm_omni.config.resolver.logger") result = _convert_dataclasses_to_dict(input_dict) # Callable should be filtered out by _filter_dict_like_object @@ -300,254 +294,174 @@ class TestFilterDataclassKwargs: assert "extra_param" not in result["cache_config"] -class TestResolveModelConfigPath: - """Test suite for resolve_model_config_path function with diffusers format models.""" - - def test_glm_image_diffusers_format_resolution(self, mocker: MockerFixture): - """Test GlmImagePipeline diffusers class resolves to glm_image config.""" - mocker.patch( - "vllm_omni.entrypoints.utils.file_or_path_exists", - return_value=True, - ) - mocker.patch( - "vllm_omni.entrypoints.utils._try_get_class_name_from_diffusers_config", - return_value="GlmImagePipeline", - ) - mocker.patch( - "vllm_omni.entrypoints.utils.current_omni_platform.get_default_stage_config_path", - return_value="vllm_omni/model_executor/stage_configs", +class TestResolveOmniConfig: + def test_stage_lookup_error_lists_resolved_ids(self): + resolved = OmniConfigResolution( + config_path=None, + stage_configs=(SimpleNamespace(stage_id=1), SimpleNamespace(stage_id=3)), ) - original_exists = os.path.exists + with pytest.raises(KeyError, match=r"no stage 2; resolved stages: \[1, 3\]"): + resolved.stage_by_id(2) - def mock_exists(path): - if "glm_image.yaml" in str(path): - return True - return original_exists(path) - - mocker.patch("os.path.exists", side_effect=mock_exists) - - result = resolve_model_config_path("zai-org/GLM-Image") - - assert result is not None - assert "glm_image.yaml" in result - - def test_uses_registry_default_deploy_when_hf_model_type_differs( - self, - mocker: MockerFixture, - tmp_path, - ): - from vllm_omni.config.stage_config import _DEPLOY_DIR as _REAL_DEPLOY_DIR - from vllm_omni.entrypoints import utils as utils_mod - - hf_config = SimpleNamespace( - model_type="minicpmo", - architectures=["MiniCPMO"], - version="4.5", - ) - model = "local/minicpmo-registry-default-test" - mocker.patch( - "vllm_omni.entrypoints.utils.get_config", - return_value=hf_config, - ) - mocker.patch( - "vllm_omni.config.config_factory.get_config", - return_value=hf_config, - ) - # A bare deploy/.yaml must not win over the registered - # pipeline default when HF model_type is not an OMNI_PIPELINES key. - (tmp_path / "minicpmo.yaml").write_text("stages: []\n", encoding="utf-8") - (tmp_path / "minicpmo_4_5.yaml").write_bytes((_REAL_DEPLOY_DIR / "minicpmo_4_5.yaml").read_bytes()) - mocker.patch.object(utils_mod, "_DEPLOY_DIR", tmp_path) - - result = resolve_model_config_path(model) - - assert result is not None - assert Path(result).as_posix().endswith("minicpmo_4_5.yaml") - assert Path(result).name == "minicpmo_4_5.yaml" - transfer_config = load_omni_transfer_config_for_model(model, result) - assert transfer_config is not None - - sender = get_stage_connector_spec( - omni_transfer_config=transfer_config, - stage_id=1, - async_chunk=True, - ) - receiver = get_stage_connector_spec( - omni_transfer_config=transfer_config, - stage_id=2, - async_chunk=True, - ) - assert sender["extra"]["role"] == "sender" - assert receiver["extra"]["role"] == "receiver" - assert sender["extra"]["connector_get_sleep_s"] == 0.01 - assert sender["extra"]["connector_get_max_wait_first_chunk"] == 3000 - assert sender["extra"]["connector_get_max_wait"] == 300 - - def test_object_storage_uri_resolves_via_materialized_configs( - self, - mocker: MockerFixture, - tmp_path, - ): - """An object-storage URI must not reach HF helpers; resolution reads the - locally materialized config files instead.""" - hf_config = SimpleNamespace( - model_type="qwen3_omni_moe", - architectures=["Qwen3OmniMoeForConditionalGeneration"], - ) - uri = "s3://qwen3-tts-models/Qwen3-Omni-30B-A3B-Instruct" - materialize = mocker.patch( - "vllm_omni.entrypoints.utils._materialize_object_storage_configs", - return_value=str(tmp_path), - ) - get_config = mocker.patch( - "vllm_omni.entrypoints.utils.get_config", - return_value=hf_config, - ) - - result = resolve_model_config_path(uri) - - materialize.assert_called_once_with(uri) - get_config.assert_called_once() - assert get_config.call_args[0][0] == str(tmp_path) - assert result is not None - assert Path(result).as_posix().endswith("qwen3_omni_moe.yaml") - - -class TestTryResolveOmniModelType: - """Name matching must only scan the last path component.""" - - def test_uri_bucket_name_cannot_hijack_pipeline(self): - # "qwen3-tts-models" normalizes to "qwen3ttsmodels", which contains the - # registered key "qwen3tts" — only the basename may participate, so a - # misleadingly named bucket selects nothing. - assert _try_resolve_omni_model_type("s3://qwen3-tts-models/plain-checkpoint") is None - - def test_basename_still_matches_pipeline(self): - assert _try_resolve_omni_model_type("gs://any-bucket/Fun-CosyVoice3-0.5B-2512") == "cosyvoice3" - - -class TestLoadAndResolveStageConfigs: def test_load_and_resolve_with_kwargs(self, mocker: MockerFixture): - """Ensure that kwargs survive default stage creation.""" + """Ensure that generic diffusion overrides survive resolution.""" engine_backend = "vllm_omni.experimental.ar_diffusion.engine.ARDiffusionEngine" - kwargs = {"dtype": torch.float32, "engine_backend": engine_backend} - kwargs["revision"] = "pinned-revision" - mocker.patch("vllm_omni.entrypoints.utils.resolve_model_config_path", return_value=None) - mocker.patch("vllm_omni.entrypoints.utils.load_stage_configs_from_model", return_value=([], None)) - - config_path, stage_configs, _ = load_and_resolve_stage_configs( - model="black-forest-labs/FLUX.2-klein-4B", - kwargs=kwargs, - trust_remote_code=False, - default_stage_cfg_factory=lambda: AsyncOmniEngine._create_default_diffusion_stage_cfg(kwargs), + mocker.patch( + "vllm_omni.config.resolver.StageConfigFactory.create_from_model", + return_value=None, ) - assert config_path is None - assert len(stage_configs) == 1 - assert "dtype" in stage_configs[0]["engine_args"] - assert stage_configs[0]["engine_args"]["engine_backend"] == engine_backend - assert stage_configs[0]["engine_args"]["revision"] == "pinned-revision" - - def test_deploy_config_preserves_cli_overrides_and_replicas(self, tmp_path, mocker: MockerFixture): - deploy_path = tmp_path / "qwen3_multi.yaml" - deploy_path.write_text( - 'stages:\n - stage_id: 0\n devices: "0"\n - stage_id: 1\n devices: "1,2,3"\n num_replicas: 3\n', - encoding="utf-8", + mocker.patch( + "vllm_omni.config.resolver._resolve_generic_diffusion_model_class", + return_value=(True, "FluxPipeline"), ) - - returned_stage_configs = [ - create_config({"stage_id": 0, "runtime": {"devices": "0"}, "engine_args": {"model": "dummy"}}), - create_config( - { - "stage_id": 1, - "runtime": {"devices": "1,2,3", "num_replicas": 3}, - "engine_args": {"model": "dummy"}, - } - ), - ] - load_stage_configs = mocker.patch( - "vllm_omni.entrypoints.utils.load_stage_configs_from_model", - return_value=(returned_stage_configs, None), - ) - cli_overrides = { - "interleave_mm_strings": True, - "media_io_kwargs": {"video": {"fps": 1, "num_frames": 128}}, - "gpu_memory_utilization": 0.55, + kwargs = { + "dtype": torch.float32, + "engine_backend": engine_backend, + "revision": "pinned-revision", } - - config_path, stage_configs, _ = load_and_resolve_stage_configs( - model="dummy-model", - kwargs=cli_overrides, - trust_remote_code=True, - deploy_config_path=str(deploy_path), - ) - - load_stage_configs.assert_called_once_with( - "dummy-model", - trust_remote_code=True, - base_engine_args=cli_overrides, - deploy_config_path=str(deploy_path), + resolved = resolve_omni_config( + "black-forest-labs/FLUX.2-klein-4B", + trust_remote_code=False, + deploy_config_path=None, + cli_overrides=kwargs, stage_overrides=None, strategy_config_path=None, ) - assert config_path == str(deploy_path) - assert len(stage_configs) == 2 - assert stage_configs[1].runtime.num_replicas == 3 - assert stage_configs[1].runtime.devices == "1,2,3" + assert resolved.config_path is None + assert len(resolved.stage_configs) == 1 + engine_args = resolved.stage_configs[0]["engine_args"] + assert "dtype" in engine_args + assert engine_args["engine_backend"] == engine_backend + assert engine_args["revision"] == "pinned-revision" - def test_filter_stages_selects_mode_stages_without_mutating_stage_config(self, tmp_path): - config_path = tmp_path / "deploy.yaml" - config_path.write_text( - """modes: - - mode: text-to-text - stages: [0] - - mode: text-to-image - stages: [0, 1] -""", - encoding="utf-8", - ) - stages = [ - create_config( - { - "stage_id": 0, - "runtime": {"requires_multimodal_data": True}, - "final_output": False, - "final_output_type": None, - } - ), - create_config( - { - "stage_id": 1, - "runtime": {"requires_multimodal_data": True}, - "final_output": True, - "final_output_type": "image", - } - ), - ] - - filtered = filter_stages(str(config_path), stages, {"mode": "text-to-text"}) - - assert len(filtered) == 1 - assert filtered[0].stage_id == 0 - assert filtered[0].runtime.requires_multimodal_data is True - assert filtered[0].final_output is False - assert filtered[0].final_output_type is None - - -class TestLoadStageConfigsFromModel: - def test_unresolved_model_does_not_fall_back_to_yaml(self, mocker: MockerFixture, caplog): + def test_generic_diffusion_uses_registered_model_metadata(self, mocker: MockerFixture): mocker.patch( - "vllm_omni.entrypoints.utils.StageConfigFactory.create_legacy_stage_configs_from_model", - return_value=(None, None), + "vllm_omni.config.resolver.StageConfigFactory.create_from_model", + return_value=None, + ) + resolve_model_class = mocker.patch( + "vllm_omni.config.resolver.resolve_model_class_name", + return_value="WanImageToVideoPipeline", + ) + mocker.patch( + "vllm_omni.config.resolver.DiffusionModelRegistry.get_supported_archs", + return_value={"WanImageToVideoPipeline"}, ) - resolve_path = mocker.patch("vllm_omni.entrypoints.utils.resolve_model_config_path") - result = load_stage_configs_from_model("unregistered-model", trust_remote_code=False) + resolved = resolve_omni_config( + "/models/Wan2.2-I2V", + trust_remote_code=False, + deploy_config_path=None, + cli_overrides={ + "diffusion_load_format": "diffusers", + "revision": "pinned-revision", + }, + stage_overrides=None, + strategy_config_path=None, + ) - assert result == ([], None) - resolve_path.assert_not_called() - assert "No registered PipelineConfig resolved" in caplog.text - assert "deploy_config" in caplog.text + stage = resolved.stage_configs[0] + resolve_model_class.assert_called_once_with( + "/models/Wan2.2-I2V", + "diffusers", + "pinned-revision", + ) + assert stage.engine_args.model_class_name == "WanImageToVideoPipeline" + assert stage.final_output_type == "video" + + def test_registered_pipeline_uses_structured_metadata_and_preserves_override_trust(self, mocker: MockerFixture): + endpoint_restriction = SimpleNamespace(name="chat") + structured_config = SimpleNamespace( + orchestrator_config=SimpleNamespace(deploy_config_path="/resolved/deploy.yaml"), + pipeline_config=SimpleNamespace(endpoint_restrictions=(endpoint_restriction,)), + ) + runtime_stage = create_config( + { + "stage_id": 1, + "runtime": {"devices": "1,2,3", "num_replicas": 3}, + "engine_args": {"model": "dummy-model"}, + } + ) + legacy_stage = SimpleNamespace(to_omegaconf=lambda: runtime_stage) + create_structured = mocker.patch( + "vllm_omni.config.resolver.StageConfigFactory.create_from_model", + return_value=structured_config, + ) + create_legacy = mocker.patch( + "vllm_omni.config.resolver.StageConfigFactory._resolve_legacy_from_registry", + return_value=SimpleNamespace( + stage_configs=[legacy_stage], + pipeline_config=structured_config.pipeline_config, + omni_lb_policy="round_robin", + ), + ) + strategy_specs = {"stage_1": {"dp": 3}} + load_strategy = mocker.patch( + "vllm_omni.config.resolver._load_strategy_specs", + return_value=strategy_specs, + ) + + resolved = resolve_omni_config( + "dummy-model", + trust_remote_code=None, + deploy_config_path="deploy.yaml", + cli_overrides={"dtype": "bfloat16", "trust_remote_code": True}, + stage_overrides={"1": {"tensor_parallel_size": 2}}, + strategy_config_path="strategy.yaml", + ) + + expected_overrides = { + "dtype": "bfloat16", + "trust_remote_code": True, + "stage_1_tensor_parallel_size": 2, + } + create_structured.assert_called_once_with( + "dummy-model", + trust_remote_code=None, + cli_overrides=expected_overrides, + deploy_config_path="deploy.yaml", + ) + create_legacy.assert_called_once_with( + structured_config.pipeline_config, + expected_overrides, + "/resolved/deploy.yaml", + strategy_specs=strategy_specs, + ) + load_strategy.assert_called_once_with("strategy.yaml") + assert resolved.config_path == "/resolved/deploy.yaml" + assert resolved.pipeline_config is structured_config.pipeline_config + assert resolved.omni_lb_policy == "round_robin" + assert resolved.endpoint_restrictions == (endpoint_restriction,) + assert resolved.stage_configs == (runtime_stage,) + + def test_registered_resolution_exposes_forced_aligner_topology(self, mocker: MockerFixture): + pipeline = OMNI_PIPELINES["qwen3_tts"] + assert isinstance(pipeline, PipelineConfig) + structured_config = SimpleNamespace( + orchestrator_config=SimpleNamespace(deploy_config_path=None), + pipeline_config=pipeline, + ) + mocker.patch( + "vllm_omni.config.resolver.StageConfigFactory.create_from_model", + return_value=structured_config, + ) + + resolved = resolve_omni_config( + "Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice", + trust_remote_code=False, + deploy_config_path=None, + cli_overrides={"forced_aligner": "/models/Qwen3-ForcedAligner-0.6B"}, + stage_overrides=None, + strategy_config_path=None, + ) + + assert resolved.pipeline_config is not None + pipeline_stage_ids = [stage.stage_id for stage in resolved.pipeline_config.stages] + runtime_stage_ids = [stage.stage_id for stage in resolved.stage_configs] + assert pipeline_stage_ids == runtime_stage_ids + assert resolved.pipeline_config.stages[-1].model_stage == "forced_aligner" + assert len(resolved.pipeline_config.stages) == len(pipeline.stages) + 1 class TestCumulativeStreamingCoercion: diff --git a/vllm_omni/config/__init__.py b/vllm_omni/config/__init__.py index f53a1ef74..073cf4f59 100644 --- a/vllm_omni/config/__init__.py +++ b/vllm_omni/config/__init__.py @@ -45,8 +45,10 @@ from vllm_omni.config.yaml_util import ( # `from vllm_omni.config.lora import LoRAConfig` (used while data.py is still # loading) cannot close a circular import through DiffusionOutput. _LAZY_ATTRS = { + "OmniConfigResolution": ("vllm_omni.config.resolver", "OmniConfigResolution"), "StageConfigFactory": ("vllm_omni.config.config_factory", "StageConfigFactory"), "register_pipeline": ("vllm_omni.config.pipeline_registry", "register_pipeline"), + "resolve_omni_config": ("vllm_omni.config.resolver", "resolve_omni_config"), } __all__ = [ @@ -60,6 +62,8 @@ __all__ = [ "VllmOmniGenerationStageConfig", "VllmOmniDiffusionStageConfig", "StageConfigType", + "OmniConfigResolution", + "resolve_omni_config", # Structured Omni sub-configs. "OmniStageCacheConfig", "OmniStageConnectorConfig", diff --git a/vllm_omni/config/config_factory.py b/vllm_omni/config/config_factory.py index b2eb64bca..90108e7df 100644 --- a/vllm_omni/config/config_factory.py +++ b/vllm_omni/config/config_factory.py @@ -4,10 +4,10 @@ from __future__ import annotations -import dataclasses import functools +import json from collections.abc import Mapping -from dataclasses import asdict +from dataclasses import asdict, dataclass from pathlib import Path from typing import Any @@ -31,7 +31,7 @@ from vllm_omni.config.stage_config import ( merge_pipeline_deploy, normalize_pipeline_cli_overrides, ) -from vllm_omni.config.yaml_util import create_config +from vllm_omni.diffusion.data import DiffusionParallelConfig, OmniDiffusionConfig from vllm_omni.diffusion.io_support import get_diffusion_output_type from vllm_omni.diffusion.utils.hf_utils import _looks_like_dreamzero @@ -48,6 +48,15 @@ logger = init_logger(__name__) _DEFAULT_PARALLEL_DEGREE = 1 +@dataclass(frozen=True) +class _LegacyConfigResolution: + """Factory-owned result for the temporary legacy runtime bridge.""" + + stage_configs: list[StageConfig] + pipeline_config: PipelineConfig + omni_lb_policy: str | None + + @functools.cache def _materialize_object_storage_configs(model: str) -> str: """Materialize an object-storage model URI's config files locally. @@ -104,6 +113,11 @@ def with_trust_remote_code_override( class StageConfigFactory: """Factory that loads pipeline YAML and merges CLI overrides. + Production startup source selection is owned by + :func:`vllm_omni.config.resolver.resolve_omni_config`. Factory methods are + lower-level construction primitives for that resolver, config internals, + and focused tests; entrypoints and engines must not call them directly. + Handles both single-stage and multi-stage models. Pipelines are declared in ``vllm_omni/config/pipeline_registry.py`` and @@ -394,11 +408,13 @@ class StageConfigFactory: deploy_config_path: str | None, strategy_specs: Mapping[Any, Any] | None = None, ) -> tuple[list[StageConfig] | None, str | None]: - """Build current runtime stage configs from the shared resolution. + """Build the migration-only runtime ABI from the shared resolution. - The engine still consumes the legacy StageConfig/OmegaConf shape. - RFC #4021 will replace this transitional path as runtime consumers move - to VllmOmniConfig. + The engine still consumes the legacy ``StageConfig``/OmegaConf shape. + This method is the resolver's temporary compatibility bridge, not an + alternative production source-selection entrypoint and not a stable + public contract. RFC #4021 will remove it as runtime consumers move to + ``VllmOmniConfig``. """ user_deploy_config = cls._load_user_deploy_config(deploy_config_path) pipeline_cfg = cls.get_pipeline_config( @@ -429,14 +445,29 @@ class StageConfigFactory: user_deploy_config: DeployConfig | None = None, strategy_specs: Mapping[Any, Any] | None = None, ) -> tuple[list[StageConfig], str | None]: - """Create current runtime StageConfigs from registry + deploy YAML. + """Return the existing two-value legacy runtime ABI.""" + resolved = cls._resolve_legacy_from_registry( + pipeline_cfg, + cli_overrides, + deploy_config_path, + user_deploy_config, + strategy_specs, + ) + return resolved.stage_configs, resolved.omni_lb_policy + + @classmethod + def _resolve_legacy_from_registry( + cls, + pipeline_cfg: PipelineConfig, + cli_overrides: dict[str, Any], + deploy_config_path: str | None = None, + user_deploy_config: DeployConfig | None = None, + strategy_specs: Mapping[Any, Any] | None = None, + ) -> _LegacyConfigResolution: + """Create runtime stages and retain their effective topology. Precedence: caller-typed (non-None) value > deploy YAML > StageDeployConfig dataclass default. - - Returns ``(stages, omni_lb_policy)`` — the strategy-derived pipeline-wide - load-balancer policy (``None`` when no strategy set one) travels with the - stages instead of through a mutable out-param. """ cli_overrides = normalize_pipeline_cli_overrides(pipeline_cfg, cli_overrides) deploy_cfg: DeployConfig | None @@ -475,7 +506,11 @@ class StageConfigFactory: cls._reconcile_strategy_with_cli(stages, applied) omni_lb_policy = applied.omni_lb_policy if applied is not None else None - return stages, omni_lb_policy + return _LegacyConfigResolution( + stage_configs=stages, + pipeline_config=pipeline_cfg, + omni_lb_policy=omni_lb_policy, + ) @staticmethod def _apply_strategy_specs( @@ -587,65 +622,64 @@ class StageConfigFactory: @classmethod def create_default_diffusion(cls, kwargs: dict[str, Any]) -> list[dict[str, Any]]: - """Single-stage diffusion - no YAML needed. + """Build the temporary runtime ABI for a generic diffusion stage. - Creates a default diffusion stage configuration for single-stage - diffusion models. Returns a legacy OmegaConf-compatible dict for - backward compatibility with OmniStage. - - Args: - kwargs: Engine arguments from CLI/API. - - Returns: - List containing a single config dict for the diffusion stage. + The terminal diffusion config owns engine defaults and normalization; + this compatibility builder only adds Omni stage topology, request + defaults, and device placement. """ - # Calculate devices based on parallel config - devices = "0" - if "parallel_config" in kwargs: - num_devices = kwargs["parallel_config"].world_size - for i in range(1, num_devices): - devices += f",{i}" + raw_sampling_params = kwargs.get("default_sampling_params") + if isinstance(raw_sampling_params, str): + try: + raw_sampling_params = json.loads(raw_sampling_params) + except json.JSONDecodeError: + logger.warning("Invalid default_sampling_params JSON, ignoring stage defaults.") + raw_sampling_params = None + if not isinstance(raw_sampling_params, Mapping): + raw_sampling_params = None + default_sampling_params = dict(raw_sampling_params.get("0", {})) if raw_sampling_params else {} - engine_args: dict[str, Any] = {} - for key, value in kwargs.items(): - if key in ("parallel_config",): - continue - engine_args[key] = value - - # Serialize parallel_config as dict for OmegaConf. Test helpers - # sometimes pass SimpleNamespace rather than a dataclass instance. - if "parallel_config" in kwargs: - parallel_config = kwargs["parallel_config"] - if dataclasses.is_dataclass(parallel_config) and not isinstance(parallel_config, type): - engine_args["parallel_config"] = asdict(parallel_config) - elif hasattr(parallel_config, "__dict__"): - engine_args["parallel_config"] = dict(vars(parallel_config)) - else: - engine_args["parallel_config"] = parallel_config - - engine_args.setdefault("cache_backend", "none") + parallel_config = DiffusionParallelConfig.from_stage_overrides(kwargs) + if kwargs.get("num_gpus") is not None: + parallel_config.resolve_data_parallel_size(int(kwargs["num_gpus"])) + engine_args = OmniDiffusionConfig.normalize_init_kwargs(kwargs) + engine_args["parallel_config"] = asdict(parallel_config) engine_args["model_stage"] = "diffusion" - # Convert dtype to string for OmegaConf - if "dtype" in engine_args: - engine_args["dtype"] = str(engine_args["dtype"]) + extras = dict(engine_args.get("extras") or {}) + for key, default in ( + ("auxiliary_text_encoder", None), + ("default_llama_model_id", "meta-llama/Meta-Llama-3.1-8B-Instruct"), + ): + value = kwargs.get(key) + if value is not None: + extras[key] = value + else: + extras.setdefault(key, default) + engine_args["extras"] = extras - engine_args.setdefault("max_num_seqs", 1) model_class_name = engine_args.get("model_class_name") + final_output_type = get_diffusion_output_type(model_class_name) + logger.info( + "Resolved generic diffusion final_output_type=%r for model_class_name=%r.", + final_output_type, + model_class_name, + ) - config_dict: dict[str, Any] = { - "stage_id": 0, - "stage_type": StageType.DIFFUSION.value, - "runtime": { - "process": True, - "devices": devices, - }, - "engine_args": create_config(engine_args), - "final_output": True, - "final_output_type": get_diffusion_output_type(model_class_name), - } - - return [config_dict] + return [ + { + "stage_id": 0, + "stage_type": StageType.DIFFUSION.value, + "runtime": { + "process": True, + "devices": ",".join(str(i) for i in range(parallel_config.world_size)), + }, + "engine_args": engine_args, + "default_sampling_params": default_sampling_params, + "final_output": True, + "final_output_type": final_output_type, + } + ] @classmethod def _merge_cli_overrides( diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 5ac47dddc..7b14f49b1 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -17,9 +17,8 @@ To add a new pipeline: Out of tree pipeline configs or resolvers can also be registered with register_pipeline. -NOTE: Single-stage diffusion models continue to use the -``_create_default_diffusion_stage_cfg`` fallback in -``async_omni_engine.py``; for now we do not add them to registry. +NOTE: Generic single-stage diffusion is selected by ``config.resolver`` when +no registered Omni pipeline matches. """ from __future__ import annotations diff --git a/vllm_omni/config/resolver.py b/vllm_omni/config/resolver.py new file mode 100644 index 000000000..4f5ee8a6c --- /dev/null +++ b/vllm_omni/config/resolver.py @@ -0,0 +1,266 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass, fields, is_dataclass +from typing import Any + +from vllm.logger import init_logger + +from vllm_omni.config.composable_parallel.strategy_loader import load_strategy_specs +from vllm_omni.config.config_factory import StageConfigFactory, with_trust_remote_code_override +from vllm_omni.config.endpoint_policy import EndpointRestriction +from vllm_omni.config.omni_config import VllmOmniConfig +from vllm_omni.config.stage_config import PipelineConfig +from vllm_omni.config.yaml_util import create_config +from vllm_omni.diffusion.data import resolve_model_class_name +from vllm_omni.diffusion.registry import DiffusionModelRegistry +from vllm_omni.diffusion.utils.hf_utils import is_diffusion_model + +logger = init_logger(__name__) + + +@dataclass(frozen=True) +class OmniConfigResolution: + """Migration envelope returned by the production config resolver. + + ``stage_configs`` intentionally carries the current OmegaConf-compatible + runtime ABI only until stage startup consumes ``VllmOmniConfig`` directly. + ``pipeline_config`` is the effective topology after runtime stage injection, + so both fields always describe the same set of stages. + It is not a stable authoring or extension API; new production callers + should resolve through :func:`resolve_omni_config` and must not construct or + merge this compatibility shape themselves. + """ + + config_path: str | None + stage_configs: tuple[Any, ...] # Temporary StageConfig/OmegaConf bridge. + pipeline_config: PipelineConfig | None = None + omni_lb_policy: str | None = None + + @property + def endpoint_restrictions(self) -> tuple[EndpointRestriction, ...]: + if self.pipeline_config is None: + return () + return tuple(self.pipeline_config.endpoint_restrictions) + + def stage_by_id(self, stage_id: int) -> Any: + for stage in self.stage_configs: + if stage.stage_id == stage_id: + return stage + available = [stage.stage_id for stage in self.stage_configs] + raise KeyError(f"no stage {stage_id}; resolved stages: {available}") + + +def _filter_dict_like_object(obj: dict | Any) -> dict: + """Convert a dict-like object while dropping OmegaConf-incompatible callables.""" + result = {} + filtered_keys = [] + for key, value in obj.items(): + # Preserve class objects as import paths for consumers such as + # custom_pipeline_args.pipeline_class. + if isinstance(value, type): + module = getattr(value, "__module__", None) + qualname = getattr(value, "__qualname__", getattr(value, "__name__", None)) + result[key] = f"{module}.{qualname}" if module and qualname and module != "builtins" else qualname + elif callable(value): + filtered_keys.append(str(key)) + else: + result[key] = _convert_dataclasses_to_dict(value) + if filtered_keys: + logger.warning( + "Filtered out %d callable object(s) from base_engine_args that are not compatible with OmegaConf: %s.", + len(filtered_keys), + filtered_keys, + ) + return result + + +def _convert_dataclasses_to_dict(obj: Any) -> Any: + """Recursively convert caller values to OmegaConf-compatible types.""" + # Check by class name before dict to cover both collections.Counter and + # vllm.utils.Counter without importing either implementation. + if hasattr(obj, "__class__") and obj.__class__.__name__ == "Counter": + try: + return dict(obj) + except (TypeError, ValueError): + return {} + if isinstance(obj, set): + return list(obj) + if is_dataclass(obj) and not isinstance(obj, type): + result = {} + for config_field in fields(obj): + if not config_field.init: + continue + value = getattr(obj, config_field.name) + # At the CLI/deploy boundary, None means "unset" rather than + # "clear an inherited value". Preserve the dataclass default by + # omitting fields whose declared default is already None. + if value is None and config_field.default is None: + continue + result[config_field.name] = _convert_dataclasses_to_dict(value) + return result + if isinstance(obj, dict): + return _filter_dict_like_object(obj) + if isinstance(obj, type): + module = getattr(obj, "__module__", None) + qualname = getattr(obj, "__qualname__", getattr(obj, "__name__", None)) + return f"{module}.{qualname}" if module and qualname and module != "builtins" else qualname + if callable(obj): + logger.warning( + "Cannot convert callable %r to an OmegaConf-compatible value.", + obj, + ) + raise TypeError(f"callable {obj!r} is not an OmegaConf-compatible value") + if isinstance(obj, (list, tuple)): + converted = [] + for item in obj: + if callable(item): + logger.warning( + "Filtered callable %r from an OmegaConf-compatible sequence.", + item, + ) + continue + converted.append(_convert_dataclasses_to_dict(item)) + return type(obj)(converted) + if hasattr(obj, "keys") and hasattr(obj, "values") and not isinstance(obj, (str, bytes)): + try: + return _filter_dict_like_object(obj) + except (TypeError, ValueError, AttributeError) as exc: + logger.warning( + "Failed to convert dict-like %s to an OmegaConf-compatible mapping.", + type(obj).__name__, + ) + raise TypeError(f"cannot convert dict-like {type(obj).__name__}") from exc + return obj + + +def _flatten_stage_overrides( + cli_overrides: dict[str, Any], + stage_overrides: Mapping[str, Mapping[str, Any]] | None, +) -> None: + """Translate per-stage mappings to the factory's flat CLI override ABI.""" + if not stage_overrides: + return + for stage_id, overrides in stage_overrides.items(): + if not isinstance(overrides, Mapping): + raise TypeError(f"stage override {stage_id!r} must be a mapping, got {type(overrides).__name__}") + for key, value in overrides.items(): + cli_overrides[f"stage_{stage_id}_{key}"] = value + + +def _apply_generic_stage_overrides( + cli_overrides: dict[str, Any], + stage_overrides: Mapping[str, Mapping[str, Any]] | None, +) -> None: + """Apply stage-zero overrides to the generic single-stage fallback.""" + if not stage_overrides or not (stage_zero := stage_overrides.get("0")): + return + for key, value in stage_zero.items(): + if key == "extras": + if not isinstance(value, Mapping): + raise TypeError(f"stage override '0'.extras must be a mapping, got {type(value).__name__}") + cli_overrides[key] = {**dict(cli_overrides.get(key) or {}), **value} + else: + cli_overrides[key] = value + + +def _load_strategy_specs(strategy_config_path: str | None) -> Mapping[Any, Any] | None: + if strategy_config_path is None: + return None + + return load_strategy_specs(strategy_config_path) + + +def _build_registered_resolution( + structured_config: VllmOmniConfig, + *, + cli_overrides: dict[str, Any], + strategy_config_path: str | None, +) -> OmniConfigResolution: + """Build the temporary runtime view for an already-resolved pipeline.""" + # Runtime consumers have not yet moved to typed per-stage configs. Use the + # factory-owned compatibility bridge instead of reimplementing legacy YAML + # discovery and merging in this resolver. + effective_deploy_path = structured_config.orchestrator_config.deploy_config_path + legacy_resolution = StageConfigFactory._resolve_legacy_from_registry( + structured_config.pipeline_config, + cli_overrides, + effective_deploy_path, + strategy_specs=_load_strategy_specs(strategy_config_path), + ) + + return OmniConfigResolution( + config_path=effective_deploy_path, + stage_configs=tuple(stage.to_omegaconf() for stage in legacy_resolution.stage_configs), + pipeline_config=legacy_resolution.pipeline_config, + omni_lb_policy=legacy_resolution.omni_lb_policy, + ) + + +def _resolve_generic_diffusion_model_class( + model: str, + cli_overrides: Mapping[str, Any], +) -> tuple[bool, str | None]: + """Detect generic diffusion support and its serving pipeline class.""" + model_class_name = cli_overrides.get("model_class_name") or resolve_model_class_name( + model, + str(cli_overrides.get("diffusion_load_format") or "default"), + cli_overrides.get("revision"), + ) + supported = bool(model_class_name and model_class_name in DiffusionModelRegistry.get_supported_archs()) + if not supported: + supported = is_diffusion_model(model) + return supported, str(model_class_name) if model_class_name else None + + +def resolve_omni_config( + model: str, + *, + trust_remote_code: bool | None, + deploy_config_path: str | None, + cli_overrides: Mapping[str, Any] | None, + stage_overrides: Mapping[str, Mapping[str, Any]] | None, + strategy_config_path: str | None, +) -> OmniConfigResolution: + """Resolve registry/deploy inputs through the single public entrypoint.""" + normalized_overrides = _convert_dataclasses_to_dict(dict(cli_overrides or {})) + normalized_overrides = with_trust_remote_code_override(normalized_overrides, trust_remote_code) + registry_overrides = dict(normalized_overrides) + _flatten_stage_overrides(registry_overrides, stage_overrides) + + structured_config = StageConfigFactory.create_from_model( + model, + trust_remote_code=trust_remote_code, + cli_overrides=registry_overrides, + deploy_config_path=deploy_config_path, + ) + if structured_config is not None: + return _build_registered_resolution( + structured_config, + cli_overrides=registry_overrides, + strategy_config_path=strategy_config_path, + ) + + _apply_generic_stage_overrides(normalized_overrides, stage_overrides) + supported, model_class_name = _resolve_generic_diffusion_model_class(model, normalized_overrides) + if not supported: + raise ValueError( + f"Model {model!r} did not resolve to a registered Omni pipeline or a supported diffusion model." + ) + if model_class_name is not None: + normalized_overrides.setdefault("model_class_name", model_class_name) + default_stages = StageConfigFactory.create_default_diffusion(normalized_overrides) + + return OmniConfigResolution( + config_path=deploy_config_path, + stage_configs=tuple(create_config(_convert_dataclasses_to_dict(default_stages))), + ) + + +__all__ = [ + "OmniConfigResolution", + "resolve_omni_config", +] diff --git a/vllm_omni/diffusion/data.py b/vllm_omni/diffusion/data.py index fd6f0237d..c30529520 100644 --- a/vllm_omni/diffusion/data.py +++ b/vllm_omni/diffusion/data.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import copy +import json import math import os import random @@ -42,45 +43,57 @@ if TYPE_CHECKING: logger = init_logger(__name__) -def normalize_omni_diffusion_kwargs(kwargs: Mapping[str, Any]) -> dict[str, Any]: +def normalize_omni_diffusion_kwargs(raw_kwargs: Mapping[str, Any]) -> dict[str, Any]: """Normalize legacy diffusion kwargs before config construction.""" - normalized = dict(kwargs) + config_kwargs = dict(raw_kwargs) + + dtype = config_kwargs.get("dtype") + if dtype is None: + config_kwargs["dtype"] = "auto" + elif isinstance(dtype, torch.dtype): + config_kwargs["dtype"] = str(dtype).removeprefix("torch.") + elif not isinstance(dtype, str): + raise TypeError(f"Provided dtype must be a string or torch.dtype, got {type(dtype).__name__}") # Backwards-compatibility: older callers may use a diffusion-specific # "static_lora_scale" kwarg. Normalize it to the canonical "lora_scale". - if "static_lora_scale" in normalized: - if "lora_scale" not in normalized: - normalized["lora_scale"] = normalized["static_lora_scale"] - normalized.pop("static_lora_scale", None) + if "static_lora_scale" in config_kwargs: + if "lora_scale" not in config_kwargs: + config_kwargs["lora_scale"] = config_kwargs["static_lora_scale"] + config_kwargs.pop("static_lora_scale", None) + + diffusion_quantization = config_kwargs.pop("diffusion_quantization_config", None) + if config_kwargs.get("quantization_config") is None and diffusion_quantization is not None: + config_kwargs["quantization_config"] = diffusion_quantization # Backwards-compatibility: map "quantization" to "quantization_config" # so callers using the old field name still work. - if "quantization" in normalized and normalized.get("quantization_config", None) is None: - normalized["quantization_config"] = normalized.pop("quantization") + if "quantization" in config_kwargs and config_kwargs.get("quantization_config", None) is None: + config_kwargs["quantization_config"] = config_kwargs.pop("quantization") else: - normalized.pop("quantization", None) + config_kwargs.pop("quantization", None) # Renamed from kv_cache_* to avoid clashing with vLLM's --kv-cache-dtype. - if normalized.get("diffusion_kv_cache_dtype") is None and "kv_cache_dtype" in normalized: - normalized["diffusion_kv_cache_dtype"] = normalized.pop("kv_cache_dtype") + if config_kwargs.get("diffusion_kv_cache_dtype") is None and "kv_cache_dtype" in config_kwargs: + config_kwargs["diffusion_kv_cache_dtype"] = config_kwargs.pop("kv_cache_dtype") else: - normalized.pop("kv_cache_dtype", None) - if normalized.get("diffusion_kv_cache_skip_steps") is None and "kv_cache_skip_steps" in normalized: - normalized["diffusion_kv_cache_skip_steps"] = normalized.pop("kv_cache_skip_steps") + config_kwargs.pop("kv_cache_dtype", None) + if config_kwargs.get("diffusion_kv_cache_skip_steps") is None and "kv_cache_skip_steps" in config_kwargs: + config_kwargs["diffusion_kv_cache_skip_steps"] = config_kwargs.pop("kv_cache_skip_steps") else: - normalized.pop("kv_cache_skip_steps", None) - if normalized.get("diffusion_kv_cache_skip_layers") is None and "kv_cache_skip_layers" in normalized: - normalized["diffusion_kv_cache_skip_layers"] = normalized.pop("kv_cache_skip_layers") + config_kwargs.pop("kv_cache_skip_steps", None) + if config_kwargs.get("diffusion_kv_cache_skip_layers") is None and "kv_cache_skip_layers" in config_kwargs: + config_kwargs["diffusion_kv_cache_skip_layers"] = config_kwargs.pop("kv_cache_skip_layers") else: - normalized.pop("kv_cache_skip_layers", None) + config_kwargs.pop("kv_cache_skip_layers", None) # Handle "diffusion_attention_backend" shorthand: merge into # diffusion_attention_config before field filtering. - diffusion_attn_backend = normalized.pop("diffusion_attention_backend", None) - fastvideo_vsa_topk = normalized.pop("fastvideo_vsa_topk", None) + diffusion_attn_backend = config_kwargs.pop("diffusion_attention_backend", None) + fastvideo_vsa_topk = config_kwargs.pop("fastvideo_vsa_topk", None) if diffusion_attn_backend is not None or fastvideo_vsa_topk is not None: - existing = normalized.get("diffusion_attention_config") - normalized["diffusion_attention_config"] = parse_attention_config( + existing = config_kwargs.get("diffusion_attention_config") + config_kwargs["diffusion_attention_config"] = parse_attention_config( existing, attention_backend=diffusion_attn_backend, fastvideo_vsa_topk=fastvideo_vsa_topk, @@ -88,21 +101,33 @@ def normalize_omni_diffusion_kwargs(kwargs: Mapping[str, Any]) -> dict[str, Any] # Check environment variable as fallback for cache_backend. # Support both old DIFFUSION_CACHE_ADAPTER and new DIFFUSION_CACHE_BACKEND. - if "cache_backend" not in normalized: + if "cache_backend" not in config_kwargs: cache_backend = os.environ.get("DIFFUSION_CACHE_BACKEND") or os.environ.get("DIFFUSION_CACHE_ADAPTER") - normalized["cache_backend"] = cache_backend.lower() if cache_backend else "none" - elif normalized["cache_backend"] is None: + config_kwargs["cache_backend"] = cache_backend.lower() if cache_backend else "none" + elif config_kwargs["cache_backend"] is None: # Callers (e.g. example CLIs with `default=None`) pass an explicit # None for "no cache"; canonicalize it so every consumer sees the # declared `str` value instead of relying on per-model None handling. - normalized["cache_backend"] = "none" + config_kwargs["cache_backend"] = "none" + + cache_config = config_kwargs.get("cache_config") + if isinstance(cache_config, str): + try: + config_kwargs["cache_config"] = json.loads(cache_config) + except json.JSONDecodeError: + logger.warning("Invalid cache_config JSON, using backend defaults.") + config_kwargs.pop("cache_config", None) + + if config_kwargs.get("streaming_output") is None and config_kwargs.get("diffusion_streaming_output") is not None: + config_kwargs["streaming_output"] = config_kwargs["diffusion_streaming_output"] + config_kwargs.pop("diffusion_streaming_output", None) # Convert optional YAML null values to empty containers. for key in ("diffusers_load_kwargs", "diffusers_call_kwargs"): - if key in normalized and normalized[key] is None: - normalized[key] = {} + if key in config_kwargs and config_kwargs[key] is None: + config_kwargs[key] = {} - return normalized + return config_kwargs def validate_host_weight_runtime_options(*, mode: object, root: object) -> None: @@ -439,6 +464,27 @@ class DiffusionParallelConfig: raise TypeError(f"Expected parallel config dict, got {type(data)!r}") return cls(**data) + @classmethod + def from_stage_overrides(cls, kwargs: Mapping[str, Any]) -> "DiffusionParallelConfig": + """Resolve nested or flat stage inputs through the config's own defaults.""" + parallel_config = kwargs.get("parallel_config") + if isinstance(parallel_config, cls): + return parallel_config + + valid_fields = {item.name for item in fields(cls) if item.init} + if isinstance(parallel_config, Mapping): + return cls.from_dict(dict(parallel_config)) + elif parallel_config is not None and hasattr(parallel_config, "__dict__"): + return cls.from_dict(dict(vars(parallel_config))) + elif parallel_config is not None: + raise TypeError( + f"parallel_config must be a mapping or DiffusionParallelConfig, got {type(parallel_config).__name__}" + ) + else: + values = {key: value for key, value in kwargs.items() if key in valid_fields} + + return cls(**{key: value for key, value in values.items() if value is not None}) + @dataclass class TransformerConfig: @@ -1550,15 +1596,18 @@ class OmniDiffusionConfig: raise @classmethod - def from_kwargs(cls, **kwargs: Any) -> "OmniDiffusionConfig": - kwargs = normalize_omni_diffusion_kwargs(kwargs) - - # Filter kwargs to only include valid fields + def normalize_init_kwargs(cls, raw_kwargs: Mapping[str, Any]) -> dict[str, Any]: + config_kwargs = normalize_omni_diffusion_kwargs(raw_kwargs) valid_fields = {f.name for f in fields(cls)} - filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_fields} + # Remaining ``None`` values mean "unset" at the CLI/deploy boundary. + # Drop them so non-optional dataclass defaults are not overwritten. + # Fields where ``None`` has normalization semantics (for example dtype + # and nullable container inputs) are handled above before this filter. + return {key: value for key, value in config_kwargs.items() if key in valid_fields and value is not None} - instance = cls(**filtered_kwargs) - return instance + @classmethod + def from_kwargs(cls, **kwargs: Any) -> "OmniDiffusionConfig": + return cls(**cls.normalize_init_kwargs(kwargs)) @dataclass diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index 812f4c3e7..f9ee40cbb 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -518,7 +518,7 @@ class OrchestratorArgs: # === Config Files === deploy_config: str | None = None - stage_overrides: str | None = None # raw JSON string; parsed downstream + stage_overrides: dict[str, dict[str, Any]] | None = None # Optional composable-parallel strategy.yaml; orchestrator reads it, overlays # derived sizing onto merged stages, then drops it before per-stage engine args. strategy_config: str | None = None diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 4cec16382..4f1a5beb5 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -13,7 +13,6 @@ from __future__ import annotations import asyncio import concurrent.futures import copy -import json import queue import shutil import threading @@ -21,30 +20,23 @@ import time import uuid import weakref from collections.abc import Mapping, Sequence -from dataclasses import asdict from typing import Any, Literal, cast import janus -import torch -from omegaconf import OmegaConf from vllm import envs as vllm_envs from vllm.inputs import PromptType from vllm.logger import init_logger from vllm.v1.engine import EngineCoreRequest from vllm.v1.engine.input_processor import InputProcessor -from vllm_omni.config.config_factory import StageConfigFactory, with_trust_remote_code_override +from vllm_omni.config.config_factory import StageConfigFactory +from vllm_omni.config.resolver import OmniConfigResolution, resolve_omni_config from vllm_omni.config.stage_config import ( DuplexSessionRuntimeConfig, + PipelineConfig, load_deploy_config, ) from vllm_omni.data_entry_keys import REQUEST_ARTIFACT_DIRS_KEY, TRANSFORM_OWNED_META_KEYS -from vllm_omni.diffusion.data import ( - DiffusionParallelConfig, - parse_attention_config, - resolve_model_class_name, -) -from vllm_omni.diffusion.io_support import get_diffusion_output_type from vllm_omni.engine import OmniEngineCoreRequest from vllm_omni.engine.async_engine_utils import ( SHUTDOWN_ENQUEUE_TIMEOUT_S, @@ -88,10 +80,7 @@ from vllm_omni.engine.stage_runtime import ( create_stage_runtime, ) from vllm_omni.entrypoints.pd_utils import PDDisaggregationMixin -from vllm_omni.entrypoints.utils import ( - load_and_resolve_stage_configs, - parse_stage_overrides, -) +from vllm_omni.entrypoints.utils import parse_stage_overrides from vllm_omni.inputs.data import OmniInteractionPrompt, OmniSamplingParams from vllm_omni.metrics.prometheus import OmniRequestCounter @@ -99,6 +88,27 @@ logger = init_logger(__name__) _STARTUP_POLL_INTERVAL_S = 1.0 _REQUEST_QUEUE_MAXSIZE = 256 +_ConfigResolutionResult = OmniConfigResolution | tuple[str | None, list[Any], str | None] + + +def load_and_resolve_stage_configs( + model: str, + kwargs: dict[str, Any], + *, + trust_remote_code: bool | None, + deploy_config_path: str | None, + stage_overrides: Mapping[str, Mapping[str, Any]] | None, + strategy_config_path: str | None, +) -> OmniConfigResolution: + """Compatibility seam delegating to the single config resolver.""" + return resolve_omni_config( + model, + trust_remote_code=trust_remote_code, + cli_overrides=kwargs, + deploy_config_path=deploy_config_path, + stage_overrides=stage_overrides, + strategy_config_path=strategy_config_path, + ) class AsyncOmniEngine: @@ -198,41 +208,28 @@ class AsyncOmniEngine: self._omni_master_port, ) - # Stage resolution pops deploy_config, so get pipeline-wide settings beforehand. + # Keep the historical tuple return from _resolve_stage_configs while + # retaining the richer resolver result for pipeline-wide settings. + # Overrides of that private seam fall back to the factory below. deploy_config_path = kwargs.get("deploy_config") - # ``trust_remote_code`` is tri-state (bool | None): ``None`` means "not - # specified" so stage-config resolution can defer to the deploy yaml's - # per-stage value (see ``with_trust_remote_code_override``). The - # restriction path below loads the top-level HF config via vLLM's - # ``get_config``, which needs a real bool, so collapse ``None`` to the - # default ``False`` here (#5495). - pipeline_config = StageConfigFactory.get_pipeline_config( - model=model, - trust_remote_code=bool(trust_remote_code), - deploy_config_path=deploy_config_path, - ) - self.endpoint_restrictions = pipeline_config.endpoint_restrictions if pipeline_config is not None else () - self._duplex_runtime_extension_path = ( - pipeline_config.duplex_runtime_extension if pipeline_config is not None else None - ) - self.duplex_serving_adapter_path = ( - pipeline_config.duplex_serving_adapter if pipeline_config is not None else None - ) - self._duplex_control_enabled = bool(pipeline_config and pipeline_config.duplex_control_enabled) - self.duplex_session_config = DuplexSessionRuntimeConfig() - if deploy_config_path is not None: - self.duplex_session_config = load_deploy_config(deploy_config_path).duplex_session - - # Tri-state: None means "not specified" — the deploy yaml's per-stage - # trust_remote_code stays in effect. An explicit True/False here is a - # global override (precedence: caller > deploy yaml > default False); - # the merge rule lives in with_trust_remote_code_override. - kwargs = with_trust_remote_code_override(kwargs, trust_remote_code) + self._config_resolution: OmniConfigResolution | None = None self.config_path, self.stage_configs = self._resolve_stage_configs( model, kwargs, trust_remote_code=trust_remote_code, ) + if self._config_resolution is None: + pipeline_config = StageConfigFactory.get_pipeline_config( + model=model, + trust_remote_code=bool(trust_remote_code), + deploy_config_path=deploy_config_path, + ) + self._set_pipeline_runtime_config(pipeline_config, deploy_config_path) + else: + self._set_pipeline_runtime_config( + self._config_resolution.pipeline_config, + self._config_resolution.config_path, + ) self.num_stages = len(self.stage_configs) stage0_args = getattr(self.stage_configs[0], "engine_args", None) if self.num_stages > 0 else None @@ -921,52 +918,6 @@ class AsyncOmniEngine: len(companions), ) - @staticmethod - def _get_default_cache_config(cache_backend: str | None) -> dict[str, Any] | None: - if cache_backend == "cache_dit": - return { - "Fn_compute_blocks": 1, - "Bn_compute_blocks": 0, - "max_warmup_steps": 4, - "residual_diff_threshold": 0.24, - "max_continuous_cached_steps": 3, - "enable_taylorseer": False, - "taylorseer_order": 1, - "scm_steps_mask_policy": None, - "scm_steps_policy": "dynamic", - } - if cache_backend == "tea_cache": - return { - "rel_l1_thresh": 0.2, - } - if cache_backend == "mag_cache": - return { - "mag_threshold": 0.24, - "mag_max_skip_steps": 5, - "mag_retention_ratio": 0.1, - } - if cache_backend == "step_cache": - return { - "step_cache_dit_enabled": True, - "velocity_sim_thresholds": [0.95, 0.93], - "velocity_skip_countdowns": [4, 2], - "step_cache_dit_min_history": 2, - "step_cache_dit_max_history": 2, - } - return None - - @staticmethod - def _normalize_cache_config(cache_backend: str | None, cache_config: Any | None) -> Any | None: - if isinstance(cache_config, str): - try: - cache_config = json.loads(cache_config) - except json.JSONDecodeError: - logger.warning("Invalid cache_config JSON, using defaults.") - cache_config = None - if cache_config is None and cache_backend not in (None, "", "none"): - cache_config = AsyncOmniEngine._get_default_cache_config(cache_backend) - return cache_config - def _detect_pd_config(self) -> dict[str, Any] | None: """Detect PD (Prefill-Decode) disaggregation config from stage_configs. Returns a dict with 'pd_pair' and 'bootstrap_addr', or None. @@ -1009,219 +960,6 @@ class AsyncOmniEngine: "prefill_engine_id": prefill_engine_id, } - @staticmethod - def _create_default_diffusion_stage_cfg(kwargs: dict[str, Any]) -> list: - """Create a default single-stage diffusion config from kwargs.""" - # We temporally create a default config for diffusion stage. - # In the future, we should merge the default config with the user-provided config. - normalized_kwargs = dict(kwargs) - default_sampling_params = normalized_kwargs.get("default_sampling_params") - if isinstance(default_sampling_params, str): - try: - default_sampling_params = json.loads(default_sampling_params) - except json.JSONDecodeError: - logger.warning("Invalid default_sampling_params JSON, ignoring stage defaults.") - default_sampling_params = None - if not isinstance(default_sampling_params, dict): - default_sampling_params = None - stage_default_sampling_params = default_sampling_params.get("0", {}) if default_sampling_params else {} - if normalized_kwargs.get("dtype") is None: - normalized_kwargs["dtype"] = "auto" - - # TODO: hack, convert dtype to string to avoid non-premitive omegaconf create error. - if "dtype" in normalized_kwargs and not isinstance(normalized_kwargs["dtype"], str): - if not isinstance(normalized_kwargs["dtype"], torch.dtype): - raise TypeError( - f"Provided dtype must be a string or torch.dtype, got {type(normalized_kwargs['dtype']).__name__}" - ) - normalized_kwargs["dtype"] = str(normalized_kwargs["dtype"]).removeprefix("torch.") - - cache_backend = normalized_kwargs.get("cache_backend", "none") - cache_config = AsyncOmniEngine._normalize_cache_config( - cache_backend, - normalized_kwargs.get("cache_config", None), - ) - - parallel_config = normalized_kwargs.get("parallel_config") - if isinstance(parallel_config, dict): - parallel_config = DiffusionParallelConfig.from_dict(parallel_config) - if parallel_config is None: - ulysses_degree = normalized_kwargs.get("ulysses_degree") or 1 - ring_degree = normalized_kwargs.get("ring_degree") or 1 - allgather_degree = normalized_kwargs.get("allgather_degree") or 1 - ulysses_mode = normalized_kwargs.get("ulysses_mode") or "strict" - ulysses_a2a_permute = bool(normalized_kwargs.get("ulysses_a2a_permute", False)) - sequence_parallel_size = normalized_kwargs.get("sequence_parallel_size") - pipeline_parallel_size = normalized_kwargs.get("pipeline_parallel_size") or 1 - data_parallel_size = normalized_kwargs.get("data_parallel_size") - tensor_parallel_size = normalized_kwargs.get("tensor_parallel_size") or 1 - cfg_parallel_size = normalized_kwargs.get("cfg_parallel_size") or 1 - pipeline_parallel_size = normalized_kwargs.get("pipeline_parallel_size") or 1 - vae_patch_parallel_size = normalized_kwargs.get("vae_patch_parallel_size") or 1 - vae_parallel_mode = normalized_kwargs.get("vae_parallel_mode") or "tile" - text_encoder_tp_size = normalized_kwargs.get("text_encoder_tp_size") or 1 - enable_expert_parallel = normalized_kwargs.get("enable_expert_parallel") or False - use_hsdp = normalized_kwargs.get("use_hsdp", False) - hsdp_shard_size = normalized_kwargs.get("hsdp_shard_size", -1) - hsdp_replicate_size = normalized_kwargs.get("hsdp_replicate_size", 1) - if sequence_parallel_size is None: - sequence_parallel_size = allgather_degree if allgather_degree > 1 else ulysses_degree * ring_degree - - parallel_config = DiffusionParallelConfig( - pipeline_parallel_size=pipeline_parallel_size, - data_parallel_size=data_parallel_size, - tensor_parallel_size=tensor_parallel_size, - enable_expert_parallel=enable_expert_parallel, - sequence_parallel_size=sequence_parallel_size, - ulysses_degree=ulysses_degree, - ring_degree=ring_degree, - allgather_degree=allgather_degree, - ulysses_mode=ulysses_mode, - ulysses_a2a_permute=ulysses_a2a_permute, - cfg_parallel_size=cfg_parallel_size, - vae_patch_parallel_size=vae_patch_parallel_size, - vae_parallel_mode=vae_parallel_mode, - text_encoder_tp_size=text_encoder_tp_size, - use_hsdp=use_hsdp, - hsdp_shard_size=hsdp_shard_size, - hsdp_replicate_size=hsdp_replicate_size, - ) - - num_gpus = normalized_kwargs.get("num_gpus") - if num_gpus is not None: - num_gpus = int(num_gpus) - parallel_config.resolve_data_parallel_size(num_gpus) - - num_devices = max(1, int(parallel_config.world_size)) - devices = ",".join(str(i) for i in range(num_devices)) - model_class_name = kwargs.get("model_class_name", None) - final_output_type = get_diffusion_output_type(model_class_name) - - attention_config = None - if ( - kwargs.get("diffusion_attention_config") is not None - or kwargs.get("diffusion_attention_backend") is not None - or kwargs.get("fastvideo_vsa_topk") is not None - ): - attention_config = parse_attention_config( - kwargs.get("diffusion_attention_config"), - attention_backend=kwargs.get("diffusion_attention_backend"), - fastvideo_vsa_topk=kwargs.get("fastvideo_vsa_topk"), - ) - - extras = dict(kwargs.get("extras") or {}) - for key, default in ( - ("auxiliary_text_encoder", None), - ("default_llama_model_id", "meta-llama/Meta-Llama-3.1-8B-Instruct"), - ): - top_level_value = kwargs.get(key) - if top_level_value is not None: - extras[key] = top_level_value - else: - extras.setdefault(key, default) - - stage_engine_args = { - "max_num_seqs": kwargs.get("max_num_seqs") or 1, - "parallel_config": parallel_config, - # Default-stage construction bypasses the structured projection. - # Runner selection remains owned by the selected engine/platform. - "engine_backend": kwargs.get("engine_backend", "default"), - "model_class_name": kwargs.get("model_class_name", None), - "task_type": kwargs.get("task_type", None), - "model_config": kwargs.get("model_config", None), - "additional_config": kwargs.get("additional_config", None), - "step_execution": kwargs.get("step_execution", False), - "request_batch_max_wait_ms": kwargs.get("request_batch_max_wait_ms", 0.0), - "vae_use_slicing": kwargs.get("vae_use_slicing", False), - "vae_use_tiling": kwargs.get("vae_use_tiling", False), - "cache_backend": cache_backend, - "cache_config": cache_config, - "enable_cache_dit_summary": kwargs.get("enable_cache_dit_summary", False), - "diffusion_offload_config": kwargs.get("diffusion_offload_config", None), - "enable_cpu_offload": kwargs.get("enable_cpu_offload", False), - "enable_layerwise_offload": kwargs.get("enable_layerwise_offload", False), - "enable_distributed_layerwise_offload": kwargs.get("enable_distributed_layerwise_offload", False), - "dlo_use_allgather": kwargs.get("dlo_use_allgather", True), - "dlo_resident_layers": kwargs.get("dlo_resident_layers", 0), - "host_weight_runtime_mode": kwargs.get("host_weight_runtime_mode", "disabled"), - "host_weight_runtime_root": kwargs.get("host_weight_runtime_root"), - "dlo_host_registration_limit_gib": kwargs.get("dlo_host_registration_limit_gib", 0.0), - "enforce_eager": False if kwargs.get("enforce_eager") is None else kwargs.get("enforce_eager"), - "diffusion_compile_granularity": ( - "regional" - if kwargs.get("diffusion_compile_granularity") is None - else kwargs["diffusion_compile_granularity"] - ), - "diffusion_compile_dynamic": ( - True if kwargs.get("diffusion_compile_dynamic") is None else kwargs["diffusion_compile_dynamic"] - ), - "fa_deterministic": bool(kwargs.get("fa_deterministic", False)), - "boundary_ratio": kwargs.get("boundary_ratio", None), - "flow_shift": kwargs.get("flow_shift", None), - "diffusion_load_format": kwargs.get("diffusion_load_format", "default"), - "lora_path": kwargs.get("lora_path", None), - "lora_scale": kwargs.get("lora_scale", 1.0), - "lora_backend": kwargs.get("lora_backend", "peft"), - "custom_pipeline_args": kwargs.get("custom_pipeline_args", None), - "worker_extension_cls": kwargs.get("worker_extension_cls", None), - "trust_remote_code": (False if kwargs.get("trust_remote_code") is None else kwargs["trust_remote_code"]), - "distributed_executor_backend": kwargs.get("distributed_executor_backend"), - "enable_sleep_mode": kwargs.get("enable_sleep_mode", False), - "enable_prompt_embed_cache": kwargs.get("enable_prompt_embed_cache", False), - "prompt_embed_cache_size": kwargs.get("prompt_embed_cache_size", 32), - "enable_multithread_weight_load": kwargs.get("enable_multithread_weight_load", True), - "num_weight_load_threads": kwargs.get("num_weight_load_threads", 4), - "quantization": kwargs.get("quantization", None), - "quantization_config": kwargs.get("quantization_config", None), - "diffusion_kv_cache_dtype": kwargs.get("diffusion_kv_cache_dtype", None), - "diffusion_kv_cache_skip_steps": kwargs.get("diffusion_kv_cache_skip_steps", None), - "diffusion_kv_cache_skip_layers": kwargs.get("diffusion_kv_cache_skip_layers", None), - **({"diffusion_attention_config": attention_config} if attention_config is not None else {}), - "force_cutlass_fp8": bool(kwargs.get("force_cutlass_fp8", False)), - "enable_diffusion_pipeline_profiler": kwargs.get("enable_diffusion_pipeline_profiler", False), - "streaming_output": kwargs.get("diffusion_streaming_output", False), - "enable_ar_profiler": kwargs.get("enable_ar_profiler", False), - "extras": extras, - **( - { - "profiler_config": asdict(kwargs["profiler_config"]) - if hasattr(kwargs["profiler_config"], "__dataclass_fields__") - else kwargs["profiler_config"] - } - if kwargs.get("profiler_config") is not None - else {} - ), - } - if num_gpus is not None: - stage_engine_args["num_gpus"] = num_gpus - # Only set dtype if it was already explicitly passed and normalized - if "dtype" in normalized_kwargs: - stage_engine_args["dtype"] = normalized_kwargs["dtype"] - - # New split fields for diffusers adapter kwargs. - if kwargs.get("diffusers_load_kwargs") is not None: - stage_engine_args["diffusers_load_kwargs"] = kwargs["diffusers_load_kwargs"] - if kwargs.get("diffusers_call_kwargs") is not None: - stage_engine_args["diffusers_call_kwargs"] = kwargs["diffusers_call_kwargs"] - - default_stage_cfg = [ - { - "stage_id": 0, - "stage_type": "diffusion", - "runtime": { - "process": True, - "devices": devices, - }, - "engine_args": stage_engine_args, - "engine_input_source": [], - "default_sampling_params": stage_default_sampling_params, - "final_output": True, - "final_output_type": final_output_type, - } - ] - default_stage_cfg[0]["engine_args"]["model_stage"] = "diffusion" - return default_stage_cfg - def _apply_strategy_lb_policy(self, derived: str | None, kwargs: dict[str, Any]) -> None: """Apply a strategy-derived ``omni_lb_policy`` to the engine. @@ -1251,6 +989,29 @@ class AsyncOmniEngine: ) self._omni_lb_policy = str(derived) + @staticmethod + def _create_default_diffusion_stage_cfg(kwargs: dict[str, Any]) -> list[dict[str, Any]]: + """Compatibility seam for the factory-owned diffusion fallback.""" + return StageConfigFactory.create_default_diffusion(kwargs) + + def _set_pipeline_runtime_config( + self, + pipeline_config: PipelineConfig | None, + config_path: str | None, + ) -> None: + """Initialize engine-wide settings resolved from pipeline metadata.""" + self.endpoint_restrictions = pipeline_config.endpoint_restrictions if pipeline_config is not None else () + self._duplex_runtime_extension_path = ( + pipeline_config.duplex_runtime_extension if pipeline_config is not None else None + ) + self.duplex_serving_adapter_path = ( + pipeline_config.duplex_serving_adapter if pipeline_config is not None else None + ) + self._duplex_control_enabled = bool(pipeline_config and pipeline_config.duplex_control_enabled) + self.duplex_session_config = DuplexSessionRuntimeConfig() + if config_path is not None: + self.duplex_session_config = load_deploy_config(config_path).duplex_session + def _resolve_stage_configs( self, model: str, @@ -1266,159 +1027,43 @@ class AsyncOmniEngine: deploy_config_path = kwargs.pop("deploy_config", None) strategy_config_path = kwargs.pop("strategy_config", None) - stage_overrides_json = kwargs.pop("stage_overrides", None) + # CLI callers arrive pre-parsed; offline Python callers may use the + # JSON-string form documented in recipes. + stage_overrides = parse_stage_overrides(kwargs.pop("stage_overrides", None)) # ``diffusion_streaming_output`` is the public AsyncOmni/serve kwarg; - # stage configs know the field as ``streaming_output``. The unregistered - # single-stage fallback translates it in - # ``_create_default_diffusion_stage_cfg``, but a registered pipeline - # resolves through StageConfigFactory, which only passes through keys the - # stage schema recognizes — so mirror the translation here. Only a truthy - # value is mirrored: the serve CLI always carries the flag's ``False`` - # default, which must not override a deploy YAML's ``streaming_output``. + # stage configs know the field as ``streaming_output``. Mirror only a + # truthy value so the CLI's False default does not override deploy YAML. if kwargs.get("diffusion_streaming_output") and kwargs.get("streaming_output") is None: kwargs["streaming_output"] = True - # Parse --stage-overrides JSON string if provided - stage_overrides = parse_stage_overrides(stage_overrides_json) - - # Unregistered diffusion checkpoints use the single-stage fallback - # below instead of StageConfigFactory, so fold stage-0 model extras - # into the fallback's input as well. Registered pipelines still get - # the complete override mapping through load_and_resolve_stage_configs. - default_stage_kwargs = kwargs - stage_zero_overrides = (stage_overrides or {}).get("0", {}) - if "extras" in stage_zero_overrides: - override_extras = stage_zero_overrides["extras"] - if not isinstance(override_extras, Mapping): - raise TypeError("stage 0 extras must be a mapping") - default_stage_kwargs = { - **kwargs, - "extras": { - **(kwargs.get("extras") or {}), - **override_extras, - }, - } - - def create_default_stage_config() -> list: - fallback_kwargs = dict(default_stage_kwargs) - if not fallback_kwargs.get("model_class_name"): - model_class_name = resolve_model_class_name( - model, - fallback_kwargs.get("diffusion_load_format", "default"), - ) - if model_class_name is not None: - fallback_kwargs["model_class_name"] = model_class_name - return self._create_default_diffusion_stage_cfg(fallback_kwargs) - - config_path, stage_configs, strategy_lb_policy = load_and_resolve_stage_configs( - model, - kwargs, - trust_remote_code=trust_remote_code, - default_stage_cfg_factory=create_default_stage_config, - deploy_config_path=deploy_config_path, - stage_overrides=stage_overrides, - strategy_config_path=strategy_config_path, + resolution = cast( + _ConfigResolutionResult, + load_and_resolve_stage_configs( + model, + kwargs, + trust_remote_code=trust_remote_code, + deploy_config_path=deploy_config_path, + stage_overrides=stage_overrides, + strategy_config_path=strategy_config_path, + ), ) + if isinstance(resolution, OmniConfigResolution): + self._config_resolution = resolution + config_path = resolution.config_path + stage_configs = list(resolution.stage_configs) + strategy_lb_policy = resolution.omni_lb_policy + else: + # Compatibility for overrides of the historical tuple-returning + # seam. Production always receives OmniConfigResolution above. + config_path, stage_configs, strategy_lb_policy = resolution # A strategy.yaml may derive a pipeline-wide load-balancer policy. It is # an orchestrator-level knob (read once at construction), so apply it here # rather than as a per-stage config field. self._apply_strategy_lb_policy(strategy_lb_policy, kwargs) - # Inject diffusion LoRA-related knobs from kwargs if not present in the stage config. - for cfg in stage_configs: - try: - if not hasattr(cfg, "engine_args") or cfg.engine_args is None: - cfg.engine_args = OmegaConf.create({}) - global_sleep_mode = kwargs.get("enable_sleep_mode") - if global_sleep_mode is not None: - if not hasattr(cfg.engine_args, "enable_sleep_mode") or cfg.engine_args.enable_sleep_mode is None: - cfg.engine_args.enable_sleep_mode = global_sleep_mode - if getattr(cfg, "stage_type", None) != "diffusion": - continue - if not hasattr(cfg, "engine_args") or cfg.engine_args is None: - cfg.engine_args = OmegaConf.create({}) - additional_config = kwargs.get("additional_config") - if additional_config is not None: - current_additional_config = getattr(cfg.engine_args, "additional_config", None) - if current_additional_config in (None, {}): - cfg.engine_args.additional_config = additional_config - if kwargs.get("lora_path") is not None: - if not hasattr(cfg.engine_args, "lora_path") or cfg.engine_args.lora_path is None: - cfg.engine_args.lora_path = kwargs["lora_path"] - lora_scale = kwargs.get("lora_scale") - if lora_scale is None: - # Backwards compatibility for older callers. - lora_scale = kwargs.get("static_lora_scale") - if lora_scale is not None: - if not hasattr(cfg.engine_args, "lora_scale") or cfg.engine_args.lora_scale is None: - cfg.engine_args.lora_scale = lora_scale - if kwargs.get("lora_backend") is not None: - if not hasattr(cfg.engine_args, "lora_backend") or cfg.engine_args.lora_backend is None: - cfg.engine_args.lora_backend = kwargs["lora_backend"] - if ( - kwargs.get("diffusion_attention_config") is not None - or kwargs.get("diffusion_attention_backend") is not None - or kwargs.get("fastvideo_vsa_topk") is not None - ): - has_stage_attention = ( - getattr(cfg.engine_args, "diffusion_attention_config", None) is not None - or getattr(cfg.engine_args, "diffusion_attention_backend", None) is not None - ) - if not has_stage_attention: - cfg.engine_args.diffusion_attention_config = parse_attention_config( - kwargs.get("diffusion_attention_config"), - attention_backend=kwargs.get("diffusion_attention_backend"), - fastvideo_vsa_topk=kwargs.get("fastvideo_vsa_topk"), - ) - quantization_config = kwargs.get("diffusion_quantization_config") or kwargs.get("quantization_config") - if quantization_config is not None: - if ( - not hasattr(cfg.engine_args, "quantization_config") - or cfg.engine_args.quantization_config is None - ): - cfg.engine_args.quantization_config = quantization_config - # Inject profiler flags for diffusion stages - for profiler_key in ( - "enable_diffusion_pipeline_profiler", - "enable_ar_profiler", - ): - val = kwargs.get(profiler_key) - if val: - if not hasattr(cfg.engine_args, profiler_key) or not getattr( - cfg.engine_args, profiler_key, False - ): - setattr(cfg.engine_args, profiler_key, val) - quantization = kwargs.get("quantization") - if quantization is not None: - if not hasattr(cfg.engine_args, "quantization") or cfg.engine_args.quantization is None: - cfg.engine_args.quantization = quantization - diffusion_kv_cache_dtype = kwargs.get("diffusion_kv_cache_dtype") - if diffusion_kv_cache_dtype is not None: - if ( - not hasattr(cfg.engine_args, "diffusion_kv_cache_dtype") - or cfg.engine_args.diffusion_kv_cache_dtype is None - ): - cfg.engine_args.diffusion_kv_cache_dtype = diffusion_kv_cache_dtype - diffusion_kv_cache_skip_steps = kwargs.get("diffusion_kv_cache_skip_steps") - if diffusion_kv_cache_skip_steps is not None: - if ( - not hasattr(cfg.engine_args, "diffusion_kv_cache_skip_steps") - or cfg.engine_args.diffusion_kv_cache_skip_steps is None - ): - cfg.engine_args.diffusion_kv_cache_skip_steps = diffusion_kv_cache_skip_steps - diffusion_kv_cache_skip_layers = kwargs.get("diffusion_kv_cache_skip_layers") - if diffusion_kv_cache_skip_layers is not None: - if ( - not hasattr(cfg.engine_args, "diffusion_kv_cache_skip_layers") - or cfg.engine_args.diffusion_kv_cache_skip_layers is None - ): - cfg.engine_args.diffusion_kv_cache_skip_layers = diffusion_kv_cache_skip_layers - except Exception as e: - logger.warning("Failed to inject LoRA config for stage: %s", e) - - return config_path, stage_configs + return cast(str, config_path), stage_configs # ==================== Public API ==================== @@ -1944,7 +1589,7 @@ class AsyncOmniEngine: tokens generated before abort (empty for diffusion / no OP state). """ if not request_ids or getattr(self, "_shutdown_called", False): - return + return [] if self.request_queue is None: raise RuntimeError("request_queue is not initialized") transport = self._correlated_rpc_client @@ -1971,7 +1616,7 @@ class AsyncOmniEngine: result_msg = await loop.run_in_executor(None, _wait) except Exception as exc: if getattr(self, "_shutdown_called", False) and is_abort_transport_shutdown(exc): - return + return [] raise if not result_msg.success: raise RuntimeError(result_msg.error or "abort failed") diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index f2bc42ab1..eed686837 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -53,7 +53,7 @@ from vllm_omni.config.stage_config import StageType from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.entrypoints.stage_utils import _to_dict, set_stage_devices -from vllm_omni.entrypoints.utils import filter_dataclass_kwargs, resolve_model_config_path +from vllm_omni.entrypoints.utils import filter_dataclass_kwargs from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams from vllm_omni.inputs.preprocess import OmniInputPreprocessor from vllm_omni.outputs.output_processor import MultimodalOutputProcessor @@ -1811,7 +1811,7 @@ def release_device_locks(lock_fds: list[int]) -> None: def load_omni_transfer_config_for_model(model: str, config_path: str | None) -> Any: - """Load omni transfer config from an explicit path or resolved model config. + """Load omni transfer config from the resolver-selected deploy config. Resolves ``base_config`` inheritance (CI overlay → base deploy YAML) so that connectors defined in the base config are visible to the transfer @@ -1820,12 +1820,11 @@ def load_omni_transfer_config_for_model(model: str, config_path: str | None) -> from vllm_omni.distributed.omni_connectors import load_omni_transfer_config try: - resolved_config_path = config_path or resolve_model_config_path(model) - if resolved_config_path is None: + if config_path is None: return None from vllm_omni.config.stage_config import resolve_deploy_yaml - resolved_dict = resolve_deploy_yaml(resolved_config_path) + resolved_dict = resolve_deploy_yaml(config_path) return load_omni_transfer_config(config_dict=resolved_dict) except Exception as e: logger.warning("[stage_init] Failed to load transfer config: %s", e) diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index ea56acb1a..25f25d23c 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -14,6 +14,7 @@ import math import os import signal from types import FrameType +from typing import Any, cast import uvloop from vllm.entrypoints.cli.types import CLISubcommand @@ -23,6 +24,7 @@ from vllm.logger import init_logger from vllm_omni.entrypoints.cli.logo import log_logo from vllm_omni.entrypoints.openai.api_server import omni_run_server +from vllm_omni.entrypoints.utils import parse_stage_overrides from vllm_omni.utils.tracking_parser import TrackingArgumentParser, TrackingNamespace logger = init_logger(__name__) @@ -47,6 +49,17 @@ Search by using: `--help=` to explore options by section (e.g., """ +def _parse_stage_overrides(value: str) -> dict[str, dict[str, Any]]: + """Adapt shared stage-override validation to argparse's error type.""" + try: + parsed = parse_stage_overrides(value) + except ValueError as exc: + raise argparse.ArgumentTypeError(str(exc)) from exc + if parsed is None: + raise argparse.ArgumentTypeError("--stage-overrides requires a JSON object") + return parsed + + def _nonneg_finite_float(value: str) -> float: """Argparse type for finite, non-negative floats (rejects nan/inf).""" try: @@ -286,7 +299,7 @@ class OmniServeCommand(CLISubcommand): ) omni_config_group.add_argument( "--stage-overrides", - type=str, + type=_parse_stage_overrides, default=None, help="Per-stage JSON overrides. Example: " '\'{"0": {"gpu_memory_utilization": 0.8}, "2": {"enforce_eager": true}}\'', @@ -945,6 +958,7 @@ def run_headless(args: TrackingNamespace) -> None: from vllm.v1.executor.multiproc_executor import MultiprocExecutor from vllm.version import __version__ as VLLM_VERSION + from vllm_omni.config.resolver import resolve_omni_config from vllm_omni.distributed.omni_connectors.utils.initialization import resolve_omni_kv_config_for_stage from vllm_omni.engine.stage_engine_startup import ( get_headless_replica_devices, @@ -959,10 +973,6 @@ def run_headless(args: TrackingNamespace) -> None: load_omni_transfer_config_for_model, prepare_engine_environment, ) - from vllm_omni.entrypoints.utils import ( - load_and_resolve_stage_configs, - parse_stage_overrides, - ) model = args.model stage_id: int | None = args.stage_id @@ -984,6 +994,10 @@ def run_headless(args: TrackingNamespace) -> None: # Filter down to a dict of things explicitly requested by the user args_dict = args.get_explicit_kwargs_dict() + deploy_config_path = args_dict.pop("deploy_config", None) + strategy_config_path = args_dict.pop("strategy_config", None) + stage_overrides = args_dict.pop("stage_overrides", None) + # ``--replica-id`` is deprecated and ignored — replica ids are # auto-assigned by ``OmniMasterServer`` so headless processes carry # no knowledge of their per-replica id at launch time. Warn (don't @@ -996,33 +1010,27 @@ def run_headless(args: TrackingNamespace) -> None: "master server.", args.replica_id, ) + args_dict.pop("replica_id") - # Parse --stage-overrides (raw JSON string) exactly like the standard - # engine path (AsyncOmniEngine._resolve_stage_configs) so headless and - # standard launches resolve to the same per-stage device layout. - stage_overrides = parse_stage_overrides(args_dict.get("stage_overrides")) - - config_path, stage_configs, _ = load_and_resolve_stage_configs( + resolved = resolve_omni_config( model, - args_dict, # store_true cannot express an explicit False: absent maps to None # ("not specified") so the deploy yaml's per-stage value applies. trust_remote_code=getattr(args, "trust_remote_code", None) or None, - deploy_config_path=args_dict.get("deploy_config"), + cli_overrides=args_dict, + deploy_config_path=deploy_config_path, stage_overrides=stage_overrides, - strategy_config_path=args_dict.get("strategy_config"), + strategy_config_path=strategy_config_path, ) + config_path = resolved.config_path + stage_configs = list(resolved.stage_configs) - # Locate the stage config that matches stage_id. - stage_cfg = None - for cfg in stage_configs: - if cfg.stage_id == stage_id: - stage_cfg = cfg - break - if stage_cfg is None: + try: + stage_cfg = resolved.stage_by_id(stage_id) + except KeyError: raise ValueError( f"No stage config found for stage_id={stage_id}. Available stage ids: {[c.stage_id for c in stage_configs]}" - ) + ) from None prepare_engine_environment() per_replica_devices = get_headless_replica_devices(stage_cfg, stage_id, omni_dp_size_local) @@ -1037,7 +1045,7 @@ def run_headless(args: TrackingNamespace) -> None: omni_master_port=omni_master_port, omni_dp_size_local=omni_dp_size_local, per_replica_devices=per_replica_devices, - config_path=config_path, + config_path=cast(str, config_path), replica_bind_address=omni_replica_address, ) return diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index 4b91f752a..e1efd7ac4 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -4,45 +4,18 @@ import json import os import types -from collections import Counter from dataclasses import fields, is_dataclass -from pathlib import Path from typing import Any, get_args, get_origin from vllm.logger import init_logger from vllm.sampling_params import RequestOutputKind, SamplingParams -from vllm.transformers_utils.config import get_config, get_hf_file_to_dict -from vllm.transformers_utils.repo_utils import file_or_path_exists -from vllm_omni.config.config_factory import ( - StageConfigFactory, - _materialize_object_storage_configs, - _name_match_candidate, - with_trust_remote_code_override, -) -from vllm_omni.config.pipeline_registry import OMNI_PIPELINES -from vllm_omni.config.stage_config import _DEPLOY_DIR -from vllm_omni.config.yaml_util import create_config, load_yaml_config -from vllm_omni.diffusion.utils.hf_utils import ( - _looks_like_dreamzero, - get_diffusion_model_index, - resolve_native_diffusion_model_class, -) from vllm_omni.entrypoints.stage_utils import _to_dict from vllm_omni.inputs.data import OmniSamplingParams -from vllm_omni.platforms import current_omni_platform - -# Get the project root directory (2 levels up from this file) -PROJECT_ROOT = Path(__file__).parent.parent.parent logger = init_logger(__name__) -_DIFFUSERS_CLASS_TO_CONFIG: dict[str, str] = { - "GlmImagePipeline": "glm_image", -} - - def inject_omni_kv_config(stage: Any, omni_conn_cfg: dict[str, Any], omni_from: str, omni_to: str) -> None: """Inject connector configuration into stage engine arguments.""" # Prepare omni_kv_config dict @@ -76,501 +49,9 @@ def inject_omni_kv_config(stage: Any, omni_conn_cfg: dict[str, Any], omni_from: logger.error(f"Failed to inject omni connector config into stage: {e}") -def _try_get_class_name_from_diffusers_config(model: str) -> str | None: - """Try to get class name from diffusers model configuration files. - - Args: - model: Model name or path - - Returns: - Model type string if found, None otherwise - """ - model_index = get_diffusion_model_index(model) - if model_index and isinstance(model_index, dict) and "_class_name" in model_index: - logger.debug(f"Found Diffusers model type '{model_index['_class_name']}'") - return model_index["_class_name"] - - return None - - -def _filter_dict_like_object(obj: dict | Any) -> dict: - """Filter dict-like object by removing callables and recursively converting values. - - Converts dict-like objects to regular dicts while filtering out callable values - that are incompatible with OmegaConf. Recursively processes values through - _convert_dataclasses_to_dict for nested object conversion. - - Args: - obj: Dict or dict-like object to filter - - Returns: - Regular dict with callables filtered out and values recursively converted - - Raises: - TypeError: If obj doesn't support .items() method - ValueError: If dict conversion fails unexpectedly - """ - - def _is_callable_value(value: Any) -> bool: - if callable(value): - return True - return isinstance( - value, - types.FunctionType | types.MethodType | types.BuiltinFunctionType | types.BuiltinMethodType, - ) - - result = {} - filtered_keys = [] - for k, v in obj.items(): - # Preserve class objects by converting to a fully qualified name string - # so callers that resolve via import path (e.g. custom_pipeline_args.pipeline_class) - # still work after OmegaConf round-trip. - if isinstance(v, type): - module = getattr(v, "__module__", None) - qualname = getattr(v, "__qualname__", getattr(v, "__name__", None)) - if module and qualname and module != "builtins": - result[k] = f"{module}.{qualname}" - else: - result[k] = qualname - elif _is_callable_value(v): - filtered_keys.append(str(k)) - else: - result[k] = _convert_dataclasses_to_dict(v) - if filtered_keys: - logger.warning( - f"Filtered out {len(filtered_keys)} callable object(s) from base_engine_args " - f"that are not compatible with OmegaConf: {filtered_keys}. " - ) - return result - - -def _convert_dataclasses_to_dict(obj: Any) -> Any: - """Recursively convert non-serializable objects to OmegaConf-compatible types. - - This is needed because OmegaConf cannot handle: - - Dataclass objects with Literal type annotations (e.g., StructuredOutputsConfig) - - Counter objects (from collections or vllm.utils) - - Set objects - - Callable objects (functions, methods, etc.) - - Other non-primitive types - """ - # IMPORTANT: Check Counter BEFORE dict, since Counter is a subclass of dict - # Handle Counter objects (convert to dict) - # Check by class name first to catch both collections.Counter and vllm.utils.Counter - if hasattr(obj, "__class__") and obj.__class__.__name__ == "Counter": - try: - return dict(obj) - except (TypeError, ValueError): - # If Counter can't be converted to dict, return empty dict - return {} - # Also check isinstance for collections.Counter (must be before dict check) - if isinstance(obj, Counter): - return dict(obj) - # Handle set objects (convert to list) - if isinstance(obj, set): - return list(obj) - # Handle dataclass objects - # Use field iteration instead of asdict() to: - # 1. Only include init fields (non-init fields cause "unexpected kwarg" errors) - # 2. Skip None values matching field defaults (avoids Pydantic validation - # failures when None is explicitly passed for non-Optional typed fields, - # e.g. CompilationConfig.cudagraph_capture_sizes: list[int] = None) - if is_dataclass(obj) and not isinstance(obj, type): - result = {} - for f in fields(obj): - if not f.init: - continue - value = getattr(obj, f.name) - if value is None and f.default is None: - continue - result[f.name] = _convert_dataclasses_to_dict(value) - return result - # Handle dictionaries (recurse into values) and filter out callables(cause error in OmegaConf.create) - # Note: This must come AFTER Counter check since Counter is a dict subclass - if isinstance(obj, dict): - return _filter_dict_like_object(obj) - # Preserve class objects by converting to a fully qualified name string. - if isinstance(obj, type): - module = getattr(obj, "__module__", None) - qualname = getattr(obj, "__qualname__", getattr(obj, "__name__", None)) - if module and qualname and module != "builtins": - return f"{module}.{qualname}" - return qualname - # Handle callable objects (functions, methods, etc.) - skip them - # Note: This comes after dict/list checks to avoid misclassifying dict-like objects - if callable(obj): - return None - # Handle lists and tuples (recurse into items) - if isinstance(obj, list | tuple): - return type(obj)(_convert_dataclasses_to_dict(item) for item in obj if not callable(item)) - # Try to convert any dict-like object (has keys/values methods) to dict - if hasattr(obj, "keys") and hasattr(obj, "values") and not isinstance(obj, str | bytes): - try: - return _filter_dict_like_object(obj) - except (TypeError, ValueError, AttributeError): - # If conversion fails, return as-is - return obj - # Primitive types and other objects that OmegaConf can handle - return obj - - -def _try_resolve_omni_model_type(model: str) -> str | None: - """Try to resolve model_type for omni models with empty config.json. - - Searches both the legacy ``stage_configs/*.yaml`` directory and the - migrated ``deploy/*.yaml`` directory for a stem that substring-matches - the model path basename (e.g. ``cosyvoice3`` in - ``FunAudioLLM/Fun-CosyVoice3-0.5B-2512``). Only the basename is scanned - so URI segments such as the bucket name cannot select an unrelated - pipeline. The longest match wins so ``cosyvoice3`` beats ``cosyvoice`` - and ``bagel_single_stage`` beats ``bagel``. - """ - model_lower = _name_match_candidate(model).lower().replace("-", "").replace("_", "") - best_match: str | None = None - best_len = 0 - for subdir in ("model_executor/stage_configs", "deploy"): - config_dir = PROJECT_ROOT / "vllm_omni" / subdir - if not config_dir.exists(): - continue - for config_file in sorted(config_dir.glob("*.yaml")): - candidate = config_file.stem.replace("-", "").replace("_", "") - if candidate and candidate in model_lower and len(candidate) > best_len: - best_match = config_file.stem - best_len = len(candidate) - return best_match - - -def _registry_default_deploy_path(model: str) -> str | None: - """Return the registered pipeline's default deploy YAML path, if any. - - A checkpoint's HF ``model_type`` is not always the registered Omni pipeline - key. MiniCPM-o 4.5, for example, reports ``minicpmo`` while its - predicate-selected pipeline is ``minicpmo_4_5``. Stage construction already - loads that pipeline's default deploy config, so connector discovery must - resolve the same path. - """ - pipeline_config = StageConfigFactory.get_pipeline_config( - model=model, - trust_remote_code=True, - ) - if pipeline_config is None or pipeline_config.default_deploy_config_name is None: - return None - default_deploy_path = _DEPLOY_DIR / pipeline_config.default_deploy_config_name - if default_deploy_path.is_file(): - return str(default_deploy_path) - return None - - -def resolve_model_config_path(model: str) -> str | None: - """Resolve the stage/deploy config file path from the model name. - - Resolves configuration path based on the model type and device type. - Order: - 1. Device-specific stage config under ``stage_configs/{device_type}/`` - 2. If HF ``model_type`` is not an ``OMNI_PIPELINES`` key, the registered - pipeline's ``default_deploy_config_name`` (keeps connectors aligned with - stage construction when HF type and pipeline key differ) - 3. ``deploy/{model_type}.yaml`` - 4. Legacy ``stage_configs/{model_type}.yaml`` - 5. Registered pipeline default deploy config (final fallthrough) - - Args: - model: Model name or path (used to determine model_type) - - Returns: - String path to the stage/deploy configuration file, or ``None`` if no - matching config file exists. - - Raises: - ValueError: If model_type cannot be determined - """ - # Object-storage URIs are streamed by vLLM's Run:AI streamer only after - # each stage builds its ModelConfig, so config resolution here reads the - # local copy that vLLM-Omni materializes for such URIs. Name-based - # fallbacks keep the original string (the materialized path is a hash). - config_source = _materialize_object_storage_configs(model) - # Try to get config from standard transformers format first - try: - hf_config = get_config(config_source, trust_remote_code=True) - model_type = hf_config.model_type - except (ValueError, Exception): - # If standard transformers format fails, try diffusers format - native_model_class = resolve_native_diffusion_model_class(config_source) - if native_model_class is not None: - model_type = native_model_class - elif get_diffusion_model_index(config_source) is not None: - model_type = _try_get_class_name_from_diffusers_config(config_source) - if model_type is None: - raise ValueError( - f"Could not determine model_type for diffusers model: {model}. " - "Please ensure its Diffusers pipeline index contains '_class_name'" - ) - elif file_or_path_exists(config_source, "config.json", revision=None): - # Try to read config.json manually for custom models like Bagel that fail get_config - # but have a valid config.json with model_type - try: - config_dict = get_hf_file_to_dict("config.json", config_source, revision=None) - if config_dict and "model_type" in config_dict: - model_type = config_dict["model_type"] - else: - # For models with empty config.json (e.g. CosyVoice3), - # try matching against registered omni stage configs. - model_type = _try_resolve_omni_model_type(model) - if model_type is None: - raise ValueError(f"config.json found but missing 'model_type' for model: {model}") - except Exception as e: - raise ValueError(f"Failed to read config.json for model: {model}. Error: {e}") from e - else: - # No config.json at repo root (e.g. GLM-TTS stores configs in - # subdirectories only). Try matching against registered deploy - # YAML filenames before giving up. - model_type = _try_resolve_omni_model_type(model) - if model_type is None: - raise ValueError( - f"Could not determine model_type for model: {model}. " - "Model is not in standard transformers or Diffusers format. " - f"Please ensure the model has proper configuration files with 'model_type' field" - ) - - default_config_path = current_omni_platform.get_default_stage_config_path() - if model_type == "vla" and _looks_like_dreamzero(config_source): - model_type = "dreamzero" - - if model_type in _DIFFUSERS_CLASS_TO_CONFIG: - normalized_model_type = _DIFFUSERS_CLASS_TO_CONFIG[model_type] - else: - normalized_model_type = model_type.replace("-", "_") - model_type_str = f"{normalized_model_type}.yaml" - complete_config_path = PROJECT_ROOT / default_config_path / model_type_str - if os.path.exists(complete_config_path): - return str(complete_config_path) - - # Prefer the registry default before deploy/.yaml when the - # HF type is not itself a pipeline key. Otherwise a future - # deploy/minicpmo.yaml would desync connectors from stages for MiniCPM-o 4.5. - if normalized_model_type not in OMNI_PIPELINES: - registry_path = _registry_default_deploy_path(model) - if registry_path is not None: - return registry_path - - deploy_config_path = _DEPLOY_DIR / model_type_str - if os.path.exists(deploy_config_path): - return str(deploy_config_path) - - stage_config_file = f"vllm_omni/model_executor/stage_configs/{normalized_model_type}.yaml" - stage_config_path = PROJECT_ROOT / stage_config_file - if os.path.exists(stage_config_path): - return str(stage_config_path) - - return _registry_default_deploy_path(model) - - -def load_stage_configs_from_model( - model: str, - *, - trust_remote_code: bool | None, - base_engine_args: dict | None = None, - deploy_config_path: str | None = None, - stage_overrides: dict[str, dict[str, Any]] | None = None, - strategy_config_path: str | None = None, -) -> tuple[list, str | None]: - """Load stage configurations from model's default config file. - - For models registered in the pipeline registry, uses - ``StageConfigFactory.create_legacy_stage_configs_from_model()`` which merges - PipelineConfig + DeployConfig + CLI overrides. - - Models that cannot be resolved through the registry return no stages so the - caller can apply its default stage configuration, when available. - - Args: - model: Model name or path (used to determine model_type) - trust_remote_code: Whether to trust remote code while resolving the model config. - base_engine_args: Base engine args to merge as CLI overrides. - deploy_config_path: Optional explicit deploy config path. - stage_overrides: Per-stage overrides from --stage-overrides. - strategy_config_path: Optional path to a composable-parallel - ``strategy.yaml`` whose derived sizing is overlaid onto the - registry-merged stages. - - Returns: - ``(stage_configs, omni_lb_policy)``: the list of stage configuration - dictionaries plus the strategy-derived pipeline-wide ``omni_lb_policy`` - (``None`` when no strategy set one). The policy is returned rather than - written into a caller-provided mutable dict. - """ - if base_engine_args is None: - base_engine_args = {} - - cli_overrides = _convert_dataclasses_to_dict(dict(base_engine_args)) - # A False inherited from the engine-args dump is the store_true flag's - # default, not an explicit choice — drop it so only the tri-state - # parameter below decides (see with_trust_remote_code_override). - if not cli_overrides.get("trust_remote_code"): - cli_overrides.pop("trust_remote_code", None) - cli_overrides = with_trust_remote_code_override(cli_overrides, trust_remote_code) - if stage_overrides: - for stage_id_str, overrides in stage_overrides.items(): - for key, val in overrides.items(): - cli_overrides[f"stage_{stage_id_str}_{key}"] = val - - # Current runtime initialization still consumes legacy OmegaConf stage - # configs. ``StageConfigFactory.create_from_model`` now produces the - # structured ``VllmOmniConfig`` object; future RFC #4021 changes will - # migrate the engine/runtime consumers and replace this legacy resolver. - strategy_specs = None - if strategy_config_path is not None: - from vllm_omni.config.composable_parallel.strategy_loader import load_strategy_specs - - strategy_specs = load_strategy_specs(strategy_config_path) - - stages, omni_lb_policy = StageConfigFactory.create_legacy_stage_configs_from_model( - model, - trust_remote_code=trust_remote_code, - cli_overrides=cli_overrides, - deploy_config_path=deploy_config_path, - strategy_specs=strategy_specs, - ) - if stages is not None: - # Convert StageConfig objects to OmegaConf for backward compat - return [stage.to_omegaconf() for stage in stages], omni_lb_policy - - strategy_note = "" - if strategy_config_path is not None: - strategy_note = f" Strategy config {strategy_config_path!r} was not applied." - logger.warning( - "No registered PipelineConfig resolved for model %r. Legacy `stage_args` " - "YAMLs are no longer supported; register the pipeline and provide deployment " - "overrides through `deploy_config`.%s", - model, - strategy_note, - ) - return [], None - - -def filter_stages( - config_path: str | None, - stage_configs: list, - kwargs: dict | None, -) -> list: - """Filter stage configs by mode when YAML defines a `modes` section. - - The YAML can define, e.g.: - - modes: - - mode: text-to-image - stages: [1] - - mode: image-to-text - stages: [0] - - When users pass `mode="image-to-text"` into Omni(**kwargs), only the stages - listed for that mode are returned. If no mode is provided, defaults to - "text-to-image". If no modes are defined or filtering fails, returns the - original stage_configs unchanged. - - Args: - config_path: Path to the YAML config (used to read `modes`). - stage_configs: Loaded list of stage configs. - kwargs: Engine/caller kwargs; may contain "mode". - - Returns: - Filtered list of stage configs (or original list if filtering not applied). - """ - if not stage_configs or config_path is None: - return stage_configs - - try: - cfg = load_yaml_config(config_path) - yaml_modes = getattr(cfg, "modes", None) - if yaml_modes is None: - return stage_configs - - mode_to_stage_ids: dict[str, list[int]] = {} - if yaml_modes is not None: - for entry in yaml_modes: - mode_name = None - stages = None - if hasattr(entry, "mode") or hasattr(entry, "stages"): - mode_name = getattr(entry, "mode", None) - stages = getattr(entry, "stages", None) - elif isinstance(entry, dict): - mode_name = entry.get("mode") - stages = entry.get("stages") - - if mode_name is None or stages is None: - continue - - if isinstance(stages, int): - stage_list = [stages] - else: - stage_list = list(stages) - - mode_to_stage_ids[str(mode_name)] = [int(sid) for sid in stage_list] - - # No modes section or empty mapping: use all stages and return early. - active_mode: str | None = None - if isinstance(kwargs, dict): - active_mode = kwargs.get("mode") - - if active_mode is None: - active_mode = "text-to-image" - - if active_mode not in mode_to_stage_ids: - logger.warning( - "Requested mode '%s' not found in config '%s'; available modes: %s. Using all stages.", - active_mode, - config_path, - sorted(mode_to_stage_ids.keys()), - ) - return stage_configs - - allowed_ids = set(mode_to_stage_ids[active_mode]) - filtered_stage_configs = [sc for sc in stage_configs if getattr(sc, "stage_id", None) in allowed_ids] - if not filtered_stage_configs: - logger.warning( - "Mode '%s' in config '%s' resolved to stage ids %s, but none matched loaded stage_args. " - "Falling back to all stages.", - active_mode, - config_path, - sorted(allowed_ids), - ) - return stage_configs - - return filtered_stage_configs - except Exception as e: - logger.warning("Failed to apply mode-based stage filtering: %s", e) - return stage_configs - - def parse_stage_overrides(value: Any) -> dict[str, dict[str, Any]] | None: - """Parse the ``--stage-overrides`` value into a per-stage override dict. - - ``value`` may be a raw JSON string (as supplied on the CLI) or an - already-parsed mapping. Returns ``None`` when no overrides are given. - - Only the **shape** of the override mapping is validated here: - - - top-level must be a dict - - keys must be non-negative ASCII integer strings (stage ids) - - values must be dicts - - Field-name / type / range validation is intentionally not done at this - layer. The downstream resolver forwards every surviving key as - ``stage__`` into ``cli_overrides`` (see - ``load_stage_configs_from_model``); field semantics live in - ``StageConfigFactory`` for registered pipelines, and the - default-diffusion fallback in ``async_omni_engine.py`` reads - ``stage_zero_overrides["extras"]`` for unregistered ones. Unknown - engine-arg keys that don't match ``OmniEngineArgs`` are dropped with - a warning at ``filter_dataclass_kwargs`` rather than failing here, - so a typo surfaces in the log instead of being silently lost. - - Raises: - ValueError: when ``value`` is not a valid per-stage override - mapping (invalid JSON, non-dict top level, non-ASCII-digit - stage id, non-dict value). - """ - if not value: + """Parse and validate the shape of per-stage JSON overrides.""" + if value is None: return None if isinstance(value, str): try: @@ -582,93 +63,22 @@ def parse_stage_overrides(value: Any) -> dict[str, dict[str, Any]] | None: if not isinstance(parsed, dict): raise ValueError( - f"--stage-overrides must be a JSON object mapping stage_id -> overrides, " + "--stage-overrides must be a JSON object mapping stage_id -> overrides, " f"got {type(parsed).__name__}: {parsed!r}" ) - if not parsed: - return None - - for stage_id_str, overrides in parsed.items(): - # ``str.isdigit()`` accepts Unicode digit classes (e.g. fullwidth - # ``"0"``); downstream's ``_STAGE_OVERRIDE_PATTERN`` is ASCII-only - # (``^stage_(\d+)_(.+)$``), so a non-ASCII-digit key would parse - # here and silently misroute as a global key. ``isascii()`` closes - # that hole. - if not isinstance(stage_id_str, str) or not stage_id_str.isdigit() or not stage_id_str.isascii(): + for stage_id, overrides in parsed.items(): + if not isinstance(stage_id, str) or not stage_id.isascii() or not stage_id.isdigit(): raise ValueError( - f"--stage-overrides keys must be non-negative integer stage ids (as strings), got {stage_id_str!r}" + f"--stage-overrides keys must be non-negative integer stage ids (as strings), got {stage_id!r}" ) if not isinstance(overrides, dict): raise ValueError( - f"--stage-overrides[{stage_id_str!r}] must be an object, got {type(overrides).__name__}: {overrides!r}" + f"--stage-overrides[{stage_id!r}] must be an object, got {type(overrides).__name__}: {overrides!r}" ) return parsed -def load_and_resolve_stage_configs( - model: str, - kwargs: dict | None, - *, - trust_remote_code: bool | None, - default_stage_cfg_factory: Any = None, - deploy_config_path: str | None = None, - stage_overrides: dict[str, dict[str, Any]] | None = None, - strategy_config_path: str | None = None, -) -> tuple[str, list, str | None]: - """Load stage configurations from a deploy YAML or model defaults. - - Args: - model: Model name or path - kwargs: Engine arguments to merge with stage configs - trust_remote_code: Whether to trust remote code while resolving the model config. - default_stage_cfg_factory: Optional callable that takes no args and returns - default stage config list when no configs are found - deploy_config_path: Optional path to a deploy YAML. - stage_overrides: Per-stage overrides from ``--stage-overrides`` JSON. - Keys are stage_id strings, values are dicts of overrides. - strategy_config_path: Optional path to a composable-parallel - ``strategy.yaml`` overlaid onto the registry-merged stages. - - Returns: - Tuple of ``(config_path, stage_configs, omni_lb_policy)`` — the last is - the strategy-derived pipeline-wide load-balancer policy (``None`` when no - strategy set one), returned for the engine to apply. - """ - config_path = deploy_config_path if deploy_config_path is not None else resolve_model_config_path(model) - stage_configs, omni_lb_policy = load_stage_configs_from_model( - model, - trust_remote_code=trust_remote_code, - base_engine_args=kwargs, - deploy_config_path=deploy_config_path, - stage_overrides=stage_overrides, - strategy_config_path=strategy_config_path, - ) - if not stage_configs: - if default_stage_cfg_factory is not None: - default_stage_cfg = default_stage_cfg_factory() - stage_configs = create_config(_convert_dataclasses_to_dict(default_stage_cfg)) - else: - stage_configs = [] - - stage_configs = filter_stages(config_path, stage_configs, kwargs) - - # ``revision`` selects the model source for every stage. Keep the global - # CLI pin on each stage's engine args so diffusion config construction can - # pass it through to all component loaders. The registry-to-legacy-stage - # conversion currently drops this inherited vLLM EngineArgs field. - revision = (kwargs or {}).get("revision") - if revision is not None: - for stage_config in stage_configs: - engine_args = stage_config.get("engine_args") if hasattr(stage_config, "get") else None - if engine_args is not None and engine_args.get("revision") is None: - engine_args["revision"] = revision - - logger.debug(f"stage_configs: {stage_configs}") - - return config_path, stage_configs, omni_lb_policy - - def get_final_stage_id_for_e2e( output_modalities: list[str] | None, default_modalities: list[str], stage_list: list ) -> int: @@ -839,7 +249,7 @@ def detect_pid_host() -> bool: if not ic: return True - return has_pid_host() + return has_pid_host() is True ### Helpers for handling delta messages From c704aeccae192d7f88e9138271682ddad1327a69 Mon Sep 17 00:00:00 2001 From: wuli666 <421774554@qq.com> Date: Tue, 8 Sep 2026 14:17:33 +0800 Subject: [PATCH 33/90] [Bugfix] Fix Step-Audio2 L4 CI KV cache allocation (#6839) Signed-off-by: wuli666 <421774554@qq.com> --- tests/config/test_config_factory.py | 2 +- vllm_omni/deploy/step_audio2_ci.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/config/test_config_factory.py b/tests/config/test_config_factory.py index 54d04cb82..6cb328bc5 100644 --- a/tests/config/test_config_factory.py +++ b/tests/config/test_config_factory.py @@ -1597,7 +1597,7 @@ stages: assert stage.max_model_len == 1024 assert stage.max_num_batched_tokens == 1024 assert stage.max_num_seqs == 1 - assert stage.gpu_memory_utilization == 0.7 + assert stage.gpu_memory_utilization == 0.8 assert stage.skip_mm_profiling is True assert stage.enforce_eager is True assert stage.async_scheduling is False diff --git a/vllm_omni/deploy/step_audio2_ci.yaml b/vllm_omni/deploy/step_audio2_ci.yaml index 4373dfaa8..060ae6c54 100644 --- a/vllm_omni/deploy/step_audio2_ci.yaml +++ b/vllm_omni/deploy/step_audio2_ci.yaml @@ -15,7 +15,7 @@ stages: max_model_len: 1024 max_num_batched_tokens: 1024 max_num_seqs: 1 - gpu_memory_utilization: 0.7 + gpu_memory_utilization: 0.8 skip_mm_profiling: true enforce_eager: true async_scheduling: false From c086554d1fd02749bdc2bcad37636e90ade01fec Mon Sep 17 00:00:00 2001 From: Honghan Zhu Date: Tue, 8 Sep 2026 15:53:20 +0800 Subject: [PATCH 34/90] [Perf]Boogu-Image Skip dense attention masks (#6871) Signed-off-by: zhuhh97 --- .../test_boogu_image_attention_mask.py | 30 +++++++++++++++++++ .../boogu_image/boogu_image_transformer.py | 11 ++----- vllm_omni/diffusion/models/utils.py | 30 ++++++++++++++++++- 3 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 tests/diffusion/models/boogu_image/test_boogu_image_attention_mask.py diff --git a/tests/diffusion/models/boogu_image/test_boogu_image_attention_mask.py b/tests/diffusion/models/boogu_image/test_boogu_image_attention_mask.py new file mode 100644 index 000000000..dbaf3b71a --- /dev/null +++ b/tests/diffusion/models/boogu_image/test_boogu_image_attention_mask.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest +import torch + +from vllm_omni.diffusion.models.utils import make_attention_mask + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +@pytest.mark.parametrize("seq_lengths", [[16], [16, 16]]) +def test_attention_mask_is_skipped_for_dense_batch(seq_lengths): + hidden_states = torch.empty(len(seq_lengths), 16, 64) + + assert make_attention_mask(hidden_states, seq_lengths) is None + + +def test_attention_mask_preserves_variable_length_padding(): + hidden_states = torch.empty(2, 16, 64) + + attention_mask = make_attention_mask(hidden_states, [16, 12]) + + expected = torch.tensor( + [ + [True] * 16, + [True] * 12 + [False] * 4, + ] + ) + torch.testing.assert_close(attention_mask, expected) diff --git a/vllm_omni/diffusion/models/boogu_image/boogu_image_transformer.py b/vllm_omni/diffusion/models/boogu_image/boogu_image_transformer.py index 2025859ed..66031ca3a 100644 --- a/vllm_omni/diffusion/models/boogu_image/boogu_image_transformer.py +++ b/vllm_omni/diffusion/models/boogu_image/boogu_image_transformer.py @@ -35,6 +35,7 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.data import OmniDiffusionConfig +from vllm_omni.diffusion.models.utils import make_attention_mask from vllm_omni.platforms import current_omni_platform if TYPE_CHECKING: @@ -1554,17 +1555,11 @@ class BooguImageTransformer2DModel(nn.Module): img_hidden_states = combined_img_hidden_states # Joint mask for [instruct + image]. - max_seq_len = max(seq_lengths) - joint_attention_mask = hidden_states.new_zeros(batch_size, max_seq_len, dtype=torch.bool) - for i, seq_len in enumerate(seq_lengths): - joint_attention_mask[i, :seq_len] = True + joint_attention_mask = make_attention_mask(hidden_states, seq_lengths) # Dual-stream (double-stream) stage. if self.num_double_stream_layers > 0: - max_img_len = max(combined_img_seq_lengths) - img_attention_mask = hidden_states.new_zeros(batch_size, max_img_len, dtype=torch.bool) - for i, img_seq_len in enumerate(combined_img_seq_lengths): - img_attention_mask[i, :img_seq_len] = True + img_attention_mask = make_attention_mask(hidden_states, combined_img_seq_lengths) for layer in self.double_stream_layers: img_hidden_states, instruct_hidden_states = layer( diff --git a/vllm_omni/diffusion/models/utils.py b/vllm_omni/diffusion/models/utils.py index 122646219..2d783d907 100644 --- a/vllm_omni/diffusion/models/utils.py +++ b/vllm_omni/diffusion/models/utils.py @@ -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 @@ -31,6 +31,34 @@ if TYPE_CHECKING: Style = Literal["colwise", "colwise_rep", "rowwise", "rowwise_rep", "replicate"] +def make_attention_mask( + hidden_states: torch.Tensor, + seq_lengths: list[int], +) -> torch.Tensor | None: + """Build a padding mask only when the batch contains padding. + + ``seq_lengths`` must be non-empty and contain valid sequence lengths. + Returns a boolean mask of shape ``(len(seq_lengths), max(seq_lengths))`` + on the device of ``hidden_states``, with True marking valid tokens, + or None when all sequence lengths are equal. + + Passing an all-true CUDA mask makes attention backends inspect a device + scalar to select the dense path, which introduces a host-device sync. + """ + max_seq_len = max(seq_lengths) + if all(seq_len == max_seq_len for seq_len in seq_lengths): + return None + + attention_mask = hidden_states.new_zeros( + len(seq_lengths), + max_seq_len, + dtype=torch.bool, + ) + for i, seq_len in enumerate(seq_lengths): + attention_mask[i, :seq_len] = True + return attention_mask + + def replace_linear_class( linear: nn.Linear, style: Style = "replicate", From f3fabe4145df06947bc853a808928caa037f5e8f Mon Sep 17 00:00:00 2001 From: Chenguang Zheng <645327136@qq.com> Date: Tue, 8 Sep 2026 16:19:07 +0800 Subject: [PATCH 35/90] [Bugfix] Keep --disable-log-stats out of stage config resolution (#7237) Signed-off-by: Chenguang ZHENG <645327136@qq.com> --- .../test_disable_log_stats_routing.py | 104 ++++++++++++++++++ vllm_omni/config/omni_config.py | 1 + vllm_omni/engine/async_omni_engine.py | 3 + vllm_omni/entrypoints/cli/serve.py | 3 + 4 files changed, 111 insertions(+) create mode 100644 tests/entrypoints/test_disable_log_stats_routing.py diff --git a/tests/entrypoints/test_disable_log_stats_routing.py b/tests/entrypoints/test_disable_log_stats_routing.py new file mode 100644 index 000000000..f9dd2da4a --- /dev/null +++ b/tests/entrypoints/test_disable_log_stats_routing.py @@ -0,0 +1,104 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import argparse +from types import SimpleNamespace +from typing import Any + +import pytest + +from vllm_omni.config.omni_config import ( + _NON_STAGE_ENGINE_CLI_FIELDS, + VllmOmniConfig, + _global_stage_cli_fields, +) +from vllm_omni.config.pipeline_registry import OMNI_PIPELINES +from vllm_omni.config.resolver import OmniConfigResolution +from vllm_omni.config.stage_config import PipelineConfig +from vllm_omni.engine.async_omni_engine import AsyncOmniEngine +from vllm_omni.entrypoints.cli.serve import run_headless +from vllm_omni.utils.tracking_parser import TrackingNamespace + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_disable_log_stats_is_not_a_stage_config_field() -> None: + assert "disable_log_stats" in _NON_STAGE_ENGINE_CLI_FIELDS + assert "disable_log_stats" not in _global_stage_cli_fields() + + +def test_structured_tts_config_accepts_disable_log_stats() -> None: + pipeline = OMNI_PIPELINES["qwen3_tts"] + assert isinstance(pipeline, PipelineConfig) + + config = VllmOmniConfig.from_pipeline_config( + pipeline, + cli_overrides={"disable_log_stats": True}, + ) + + assert len(config.stage_configs) == len(pipeline.stages) + + +def test_async_engine_consumes_disable_log_stats_before_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_resolve(model: str, kwargs: dict[str, Any], **unused: Any) -> OmniConfigResolution: + captured["model"] = model + captured["kwargs"] = dict(kwargs) + return OmniConfigResolution( + config_path="config.yaml", + stage_configs=(), + ) + + monkeypatch.setattr( + "vllm_omni.engine.async_omni_engine.load_and_resolve_stage_configs", + fake_resolve, + ) + engine = SimpleNamespace(_apply_strategy_lb_policy=lambda *args, **kwargs: None) + + config_path, stage_configs = AsyncOmniEngine._resolve_stage_configs( + engine, + "fake-model", + {"disable_log_stats": True}, + trust_remote_code=False, + ) + + assert captured["model"] == "fake-model" + assert "disable_log_stats" not in captured["kwargs"] + assert config_path == "config.yaml" + assert stage_configs == [] + + +def test_headless_consumes_disable_log_stats_before_resolution(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, Any] = {} + + def fake_resolve(*args: Any, **kwargs: Any) -> OmniConfigResolution: + captured.update(kwargs) + return OmniConfigResolution( + config_path=None, + stage_configs=(SimpleNamespace(stage_id=99),), + ) + + monkeypatch.setattr("vllm_omni.config.resolver.resolve_omni_config", fake_resolve) + namespace = argparse.Namespace( + model="fake-model", + stage_id=0, + omni_master_address="127.0.0.1", + omni_master_port=26000, + worker_backend="multi_process", + omni_replica_address=None, + omni_dp_size_local=1, + disable_log_stats=True, + log_stats=False, + ) + args = TrackingNamespace( + unfiltered_ns=namespace, + explicit_keys=frozenset({"disable_log_stats"}), + ) + + with pytest.raises(ValueError, match="No stage config found"): + run_headless(args) + + assert "disable_log_stats" not in captured["cli_overrides"] diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index 1a1c0c3ae..e6825bd17 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -63,6 +63,7 @@ _PIPELINE_DEPLOY_CLI_FIELDS = PIPELINE_WIDE_ENGINE_FIELDS _NON_STAGE_ENGINE_CLI_FIELDS = frozenset( { "async_chunk", + "disable_log_stats", "model", "omni", "output_modalities", diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 4f1a5beb5..4c34f5898 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -1025,6 +1025,9 @@ class AsyncOmniEngine: if legacy_arg in kwargs: raise ValueError(f"`{legacy_arg}` is no longer supported; use `deploy_config` instead.") + # log_stats is captured by __init__; its CLI-only negative alias must + # not cross into per-stage structured config ownership validation. + kwargs.pop("disable_log_stats", None) deploy_config_path = kwargs.pop("deploy_config", None) strategy_config_path = kwargs.pop("strategy_config", None) # CLI callers arrive pre-parsed; offline Python callers may use the diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index 25f25d23c..78b212f98 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -993,6 +993,9 @@ def run_headless(args: TrackingNamespace) -> None: # Filter down to a dict of things explicitly requested by the user args_dict = args.get_explicit_kwargs_dict() + # This CLI-only negative alias is consumed below when selecting the + # launcher log_stats value; it is not a per-stage config override. + args_dict.pop("disable_log_stats", None) deploy_config_path = args_dict.pop("deploy_config", None) strategy_config_path = args_dict.pop("strategy_config", None) From 7c2e30afd3e8d5c66c8fd867f694550da45f6193 Mon Sep 17 00:00:00 2001 From: tlysanhuo <166924864+tlysanhuo@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:00:25 +0800 Subject: [PATCH 36/90] [Bugfix] Bound omni benchmark per-request timeout to 15 min by default (#7130) Signed-off-by: tly <2200895168@qq.com> --- tests/benchmarks/patch/test_patch.py | 67 +++++++++++++++++ tests/benchmarks/test_serve_cli.py | 14 ++++ vllm_omni/benchmarks/patch/patch.py | 72 +++++++++++++++---- vllm_omni/benchmarks/serve.py | 4 ++ .../entrypoints/cli/benchmark/cli_args.py | 10 +++ 5 files changed, 152 insertions(+), 15 deletions(-) diff --git a/tests/benchmarks/patch/test_patch.py b/tests/benchmarks/patch/test_patch.py index b4582913e..accb30100 100644 --- a/tests/benchmarks/patch/test_patch.py +++ b/tests/benchmarks/patch/test_patch.py @@ -27,6 +27,8 @@ from vllm_omni.benchmarks.patch.patch import ( _apply_stage0_token_timings, _apply_video_metrics_from_payload, _attach_seed_tts_to_request_func_input, + _build_benchmark_session, + _omni_request_timeout_s, async_request_openai_chat_omni_completions, async_request_openai_image_edits_omni, async_request_openai_image_generations_omni, @@ -1201,6 +1203,71 @@ async def test_prompt_len_assigned_from_usage(mocker: MockerFixture): ) +class TestOmniRequestTimeout: + """``--omni-request-timeout-s`` precedence: explicit value > 900 s default.""" + + _OVERRIDE = "vllm_omni.benchmarks.patch.patch._REQUEST_TIMEOUT_OVERRIDE_S" + + def test_default_timeout_is_900s_when_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(self._OVERRIDE, None) + assert _omni_request_timeout_s() == 900.0 + + def test_explicit_value_wins_over_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(self._OVERRIDE, 123.5) + assert _omni_request_timeout_s() == 123.5 + + def test_non_positive_restores_legacy_6h_cap(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(self._OVERRIDE, 0) + assert _omni_request_timeout_s() == 6 * 60 * 60.0 + + async def test_benchmark_session_uses_configured_timeout(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(self._OVERRIDE, 42.0) + session = _build_benchmark_session(max_concurrency=8, ssl_setting=False) + try: + assert session.timeout.total == 42.0 + assert session.connector.limit == 8 + assert session.connector.limit_per_host == 8 + finally: + await session.close() + + async def test_hung_server_request_times_out_as_failed(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A server that accepts the request but never responds must surface as ``failed``.""" + + async def handler(reader, writer): + # Drain the request but never respond — the original hang failure mode. + try: + while await reader.read(4096): + pass + except ConnectionResetError: + pass + finally: + # Close our side of the socket: the client aborts after the + # timeout and only sends FIN, so without writer.close() the + # half-closed connection keeps Server.wait_closed() (which on + # Python 3.12+ waits for every accepted connection to drop) + # blocked forever. + writer.close() + try: + await writer.wait_closed() + except (ConnectionResetError, BrokenPipeError): + pass + + monkeypatch.setattr(self._OVERRIDE, 1.0) + server = await asyncio.start_server(handler, "127.0.0.1", 0) + port = server.sockets[0].getsockname()[1] + session = _build_benchmark_session(max_concurrency=1, ssl_setting=False) + try: + request_input = _seed_tts_request_func_input() + request_input.api_url = f"http://127.0.0.1:{port}/v1/chat/completions" + output = await async_request_openai_chat_omni_completions(request_input, session) + assert output.success is False + assert output.error + finally: + await session.close() + server.close() + await server.wait_closed() + + def test_video_rtf_prefers_generation_time_over_poll_latency(): """RTF must not grow with client poll_interval overshoot in output.latency.""" payload = { diff --git a/tests/benchmarks/test_serve_cli.py b/tests/benchmarks/test_serve_cli.py index b9c72b1bf..dae70a6c4 100644 --- a/tests/benchmarks/test_serve_cli.py +++ b/tests/benchmarks/test_serve_cli.py @@ -379,3 +379,17 @@ def test_bench_serve_cli_mocks_http_request(tmp_path: Path): ) assert bench_requests assert all(url == expected_url for url in bench_requests), f"Unexpected target URLs: {bench_requests}" + + +def test_omni_request_timeout_s_flag_defaults_and_parses() -> None: + parser = TrackingArgumentParser() + add_omni_args(parser) + + args = parser.parse_args([]) + assert args.omni_request_timeout_s is None + + args = parser.parse_args(["--omni-request-timeout-s", "0"]) + assert args.omni_request_timeout_s == 0.0 + + args = parser.parse_args(["--omni-request-timeout-s", "3600"]) + assert args.omni_request_timeout_s == 3600.0 diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index e65e5ee5c..10f0853e0 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -211,6 +211,61 @@ def _seed_tts_capture_pcm_for_wer() -> bool: ) +_DEFAULT_REQUEST_TIMEOUT_S = 900.0 +_LEGACY_REQUEST_TIMEOUT_S = 6 * 60 * 60.0 + +# Set from the ``--omni-request-timeout-s`` CLI flag by ``vllm bench serve`` +# before the benchmark session is built (``None`` = use the default above). +_REQUEST_TIMEOUT_OVERRIDE_S: float | None = None + + +def set_request_timeout_s(value: float) -> None: + """Record the explicitly requested per-request timeout (from the CLI).""" + global _REQUEST_TIMEOUT_OVERRIDE_S + _REQUEST_TIMEOUT_OVERRIDE_S = float(value) + + +def _omni_request_timeout_s() -> float: + """Per-request total timeout for the shared benchmark ``aiohttp`` session. + + An explicit ``--omni-request-timeout-s`` value wins over the 900 s default; + ``<= 0`` restores the legacy 6 h cap. A bounded per-request timeout makes a + hung server surface as ``failed`` requests once the deadline fires instead + of pinning the benchmark slot indefinitely. + """ + value = _REQUEST_TIMEOUT_OVERRIDE_S + if value is None: + return _DEFAULT_REQUEST_TIMEOUT_S + if value <= 0: + return _LEGACY_REQUEST_TIMEOUT_S + return value + + +def _build_benchmark_session( + max_concurrency: int | None, + ssl_setting: ssl.SSLContext | bool, +) -> aiohttp.ClientSession: + """Build the session shared by every benchmark request. + + Connections are reused across requests to reduce TLS handshake overhead; + the per-request total timeout comes from ``_omni_request_timeout_s()``. + """ + connector = aiohttp.TCPConnector( + limit=max_concurrency or 0, + limit_per_host=max_concurrency or 0, + ttl_dns_cache=300, + use_dns_cache=True, + enable_cleanup_closed=True, + force_close=True, + ssl=ssl_setting, + ) + return aiohttp.ClientSession( + connector=connector, + trust_env=True, + timeout=aiohttp.ClientTimeout(total=_omni_request_timeout_s()), + ) + + def _merge_extra_body_mm_kwargs(base: dict | None, overlay: dict | None) -> dict | None: """Shallow-merge ``extra_body`` dicts; deep-merge ``mm_processor_kwargs`` if both set.""" if not base and not overlay: @@ -2616,21 +2671,8 @@ async def benchmark( # Reuses connections across requests to reduce TLS handshake overhead. ssl_setting = ssl_context if ssl_context is not None else ("https://" in api_url) - connector = aiohttp.TCPConnector( - limit=max_concurrency or 0, - limit_per_host=max_concurrency or 0, - ttl_dns_cache=300, - use_dns_cache=True, - enable_cleanup_closed=True, - force_close=True, - ssl=ssl_setting, - ) - - session = aiohttp.ClientSession( - connector=connector, - trust_env=True, - timeout=aiohttp.ClientTimeout(total=6 * 60 * 60), - ) + session = _build_benchmark_session(max_concurrency, ssl_setting) + print(f"Per-request timeout: {_omni_request_timeout_s():g}s") print("Starting initial single prompt test run...") test_prompt, test_prompt_len, test_output_len, test_mm_content = ( diff --git a/vllm_omni/benchmarks/serve.py b/vllm_omni/benchmarks/serve.py index 96878a990..7d05eb24f 100644 --- a/vllm_omni/benchmarks/serve.py +++ b/vllm_omni/benchmarks/serve.py @@ -20,6 +20,7 @@ from vllm_omni.benchmarks.omniinteract import omniinteract_output_lock from vllm_omni.benchmarks.patch.patch import ( maybe_enable_stage_metrics, set_print_stage, + set_request_timeout_s, should_request_stage_metrics, ) @@ -62,6 +63,9 @@ def main(args: argparse.Namespace) -> dict[str, Any]: os.environ["SEED_TTS_WER_SAVE_ITEMS"] = "1" if getattr(args, "daily_omni_save_eval_items", False): os.environ["DAILY_OMNI_SAVE_EVAL_ITEMS"] = "1" + request_timeout_s = getattr(args, "omni_request_timeout_s", None) + if request_timeout_s is not None: + set_request_timeout_s(request_timeout_s) _use_endpoint_backend_when_implicit(args) set_print_stage(getattr(args, "print_stage", False)) args.extra_body = maybe_enable_stage_metrics( diff --git a/vllm_omni/entrypoints/cli/benchmark/cli_args.py b/vllm_omni/entrypoints/cli/benchmark/cli_args.py index 68cbb9f86..21f528954 100644 --- a/vllm_omni/entrypoints/cli/benchmark/cli_args.py +++ b/vllm_omni/entrypoints/cli/benchmark/cli_args.py @@ -92,6 +92,16 @@ def add_multi_stage_cli_args(parser: argparse.ArgumentParser) -> None: "and tpop controls both text TPOP and internal stream TPOP." ), ) + group.add_argument( + "--omni-request-timeout-s", + type=float, + default=None, + help=( + "Total per-request timeout in seconds for benchmark HTTP requests. Timed-out requests are " + "recorded as failed instead of stalling the run on a hung server. Defaults to 900 when " + "omitted; set <= 0 to restore the legacy 6 h cap." + ), + ) def add_diffusion_cli_args(parser: argparse.ArgumentParser) -> None: From b58ff5cb8b17250b76f9cdf9b9b46385cdda4376 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:10:51 -0700 Subject: [PATCH 37/90] [CI/Build] Wait for duplex reaper recovery in retry test (#7225) Signed-off-by: andyluo7 Co-authored-by: wangyu <53896905+yenuo26@users.noreply.github.com> --- tests/engine/test_orchestrator.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/engine/test_orchestrator.py b/tests/engine/test_orchestrator.py index 99b81aeb9..aa4f25ded 100644 --- a/tests/engine/test_orchestrator.py +++ b/tests/engine/test_orchestrator.py @@ -2665,7 +2665,10 @@ async def test_duplex_reaper_loop_waits_between_ticks(): @pytest.mark.asyncio -async def test_duplex_reaper_loop_survives_one_cleanup_failure(): +@pytest.mark.parametrize("first_cleanup_delay", [0.0, 0.05], ids=["immediate", "delayed"]) +async def test_duplex_reaper_loop_survives_one_cleanup_failure(first_cleanup_delay: float) -> None: + recovered = asyncio.Event() + class _Plane: def __init__(self) -> None: self.calls = 0 @@ -2673,7 +2676,9 @@ async def test_duplex_reaper_loop_survives_one_cleanup_failure(): async def reap_expired(self) -> int: self.calls += 1 if self.calls == 1: + await asyncio.sleep(first_cleanup_delay) raise RuntimeError("transient cleanup failure") + recovered.set() return 0 orchestrator = object.__new__(Orchestrator) @@ -2682,11 +2687,13 @@ async def test_duplex_reaper_loop_survives_one_cleanup_failure(): orchestrator._shutdown_event = asyncio.Event() task = asyncio.create_task(orchestrator._duplex_reaper_loop()) - await asyncio.sleep(0.035) - orchestrator._shutdown_event.set() - await task - - assert orchestrator.duplex_control_plane.calls >= 2 + try: + # Wait for recovery itself, including when cleanup exceeds the old 35 ms window. + await asyncio.wait_for(recovered.wait(), timeout=5.0) + assert orchestrator.duplex_control_plane.calls >= 2 + finally: + orchestrator._shutdown_event.set() + await asyncio.wait_for(task, timeout=5.0) @pytest.mark.asyncio From 031612f2507594ef632f923379e04ee36ff98684 Mon Sep 17 00:00:00 2001 From: Alicia <115451386+congw729@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:40:08 +0800 Subject: [PATCH 38/90] [Bugfix] Fix video benchmark timeout and failure accounting (#7259) Signed-off-by: Alicia <115451386+congw729@users.noreply.github.com> --- benchmarks/diffusion/README.md | 45 ++++--- benchmarks/diffusion/backends.py | 121 +++++++++--------- .../diffusion/diffusion_benchmark_serving.py | 27 +++- 3 files changed, 120 insertions(+), 73 deletions(-) diff --git a/benchmarks/diffusion/README.md b/benchmarks/diffusion/README.md index 54ed64261..b6ea50f3e 100644 --- a/benchmarks/diffusion/README.md +++ b/benchmarks/diffusion/README.md @@ -17,18 +17,18 @@ The main entrypoint is: vllm serve Qwen/Qwen-Image --omni --port 8099 ``` -2. Run a minimal benchmark: +1. Run a minimal benchmark: ```bash python3 benchmarks/diffusion/diffusion_benchmark_serving.py \ - --base-url http://localhost:8099 \ - --model Qwen/Qwen-Image \ - --task t2i \ - --dataset vbench \ - --num-prompts 5 + --base-url http://localhost:8099 \ + --model Qwen/Qwen-Image \ + --task t2i \ + --dataset vbench \ + --num-prompts 5 ``` -**Notes** +### Notes - By default, image tasks talk to `http://:/v1/chat/completions`; video tasks talk to `/v1/videos`. - If you run the server on another host or port, pass `--base-url` accordingly. @@ -52,14 +52,14 @@ Example (`t2v`): ```bash python3 benchmarks/diffusion/diffusion_benchmark_serving.py \ - --base-url http://localhost:8099 \ - --model Wan-AI/Wan2.2-T2V-A14B-Diffusers \ - --task t2v \ - --dataset vbench \ - --num-prompts 50 \ - --width 640 --height 480 \ - --num-frames 81 --fps 16 \ - --num-inference-steps 40 + --base-url http://localhost:8099 \ + --model Wan-AI/Wan2.2-T2V-A14B-Diffusers \ + --task t2v \ + --dataset vbench \ + --num-prompts 50 \ + --width 640 --height 480 \ + --num-frames 81 --fps 16 \ + --num-inference-steps 40 ``` Note: `vbench` can also be used for other tasks such as `t2i` / `i2v` (and `i2i`). For `t2i`, the loader reuses VBench t2v text prompts; for `i2v` / `i2i`, it loads the VBench i2v dataset (with image paths). @@ -140,6 +140,21 @@ Traffic / concurrency flags: - `--request-rate`: Target request rate (requests/second). If set to `inf`, the script sends all requests immediately. - `--max-concurrency`: Max number of in-flight requests (default: `1`). This can hard-cap the achieved QPS: if it is too small, requests will queue behind the semaphore, and both achieved throughput and observed SLO attainment can be skewed. +### Video job timeout and failures + +`--video-job-timeout` controls how long each `/v1/videos` job may be polled, +including time queued on the server (default: `900` seconds). For long video +jobs at high concurrency, use a larger budget, for example +`--video-job-timeout 1800`. This also applies to warmup jobs. The polling budget +starts after job creation; it excludes time waiting for the client concurrency +semaphore. Individual HTTP calls still use the aiohttp session timeout. + +An expired job is counted as failed and deleted, which cancels queued or running +server generation. The benchmark continues processing the remaining requests. +The progress bar counts both successful and failed requests. The final report +shows the failure count and the first 10 errors; `--output-file` saves all errors +with their request IDs in `request_errors`. + ### Batched warmup note For batched serving runs, warm the same in-flight shape you plan to measure. diff --git a/benchmarks/diffusion/backends.py b/benchmarks/diffusion/backends.py index aeff3ebbb..425667d8c 100644 --- a/benchmarks/diffusion/backends.py +++ b/benchmarks/diffusion/backends.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + import asyncio import base64 import json @@ -43,6 +46,7 @@ class RequestFuncInput: video_paths: list[str] | None = None request_id: str = field(default_factory=lambda: str(uuid.uuid4())) default_bot_task: str | None = DEFAULT_EDITS_BOT_TASK + video_job_timeout: float = 900.0 @dataclass @@ -331,64 +335,64 @@ async def async_request_v1_videos( output = RequestFuncOutput() output.start_time = time.perf_counter() - files = dict(input.extra_body) - if input.prompt: - files.setdefault("prompt", input.prompt) - if input.width and input.height: - files.setdefault("height", input.height) - files.setdefault("width", input.width) - if input.num_frames: - files.setdefault("num_frames", input.num_frames) - if input.num_inference_steps: - files.setdefault("num_inference_steps", input.num_inference_steps) - if input.seed is not None: - files.setdefault("seed", input.seed) - if input.fps: - files.setdefault("fps", input.fps) - - form = aiohttp.FormData() - for k, v in files.items(): - form.add_field(k, str(v)) - image_file = None - if input.image_paths and input.video_paths: - output.error = "Only one of image_paths or video_paths can be provided" - output.success = False - return output + job_id = None + try: + files = dict(input.extra_body) + if input.prompt: + files.setdefault("prompt", input.prompt) + if input.width and input.height: + files.setdefault("height", input.height) + files.setdefault("width", input.width) + if input.num_frames: + files.setdefault("num_frames", input.num_frames) + if input.num_inference_steps: + files.setdefault("num_inference_steps", input.num_inference_steps) + if input.seed is not None: + files.setdefault("seed", input.seed) + if input.fps: + files.setdefault("fps", input.fps) - if input.image_paths and len(input.image_paths) > 0: - image_path = input.image_paths[0] - image_file = open(image_path, "rb") - form.add_field( - "input_reference", - image_file, - filename=os.path.basename(image_path), - content_type=_guess_mime_type(image_path), - ) - elif input.video_paths and len(input.video_paths) > 0: - video_path = input.video_paths[0] - if _is_minimax_h3_mixed_reference(input.model): - with open(video_path, "rb") as reference_file: - video_b64 = base64.b64encode(reference_file.read()).decode("ascii") - form.add_field( - "video_reference", - json.dumps([{"video_url": f"data:video/mp4;base64,{video_b64}"}]), - ) - else: - image_file = open(video_path, "rb") + form = aiohttp.FormData() + for k, v in files.items(): + form.add_field(k, str(v)) + + if input.image_paths and input.video_paths: + output.error = "Only one of image_paths or video_paths can be provided" + output.success = False + return output + + if input.image_paths and len(input.image_paths) > 0: + image_path = input.image_paths[0] + image_file = open(image_path, "rb") form.add_field( "input_reference", image_file, - filename=os.path.basename(video_path), - content_type=_guess_mime_type(video_path), + filename=os.path.basename(image_path), + content_type=_guess_mime_type(image_path), ) + elif input.video_paths and len(input.video_paths) > 0: + video_path = input.video_paths[0] + if _is_minimax_h3_mixed_reference(input.model): + with open(video_path, "rb") as reference_file: + video_b64 = base64.b64encode(reference_file.read()).decode("ascii") + form.add_field( + "video_reference", + json.dumps([{"video_url": f"data:video/mp4;base64,{video_b64}"}]), + ) + else: + image_file = open(video_path, "rb") + form.add_field( + "input_reference", + image_file, + filename=os.path.basename(video_path), + content_type=_guess_mime_type(video_path), + ) - job_id = None - job_status = None - poll_json = {} - resp_json = {} + job_status = None + poll_json = {} + resp_json = {} - try: # invoke a post request (POST /v1/videos) async with session.post(input.api_url, data=form) as response: if response.status == 200: @@ -406,7 +410,7 @@ async def async_request_v1_videos( # invoke a poll request (GET /v1/videos/{video_id}) poll_interval = 2.0 # Unit(s) - timeout_seconds = 600.0 + timeout_seconds = input.video_job_timeout deadline = time.perf_counter() + timeout_seconds job_url = f"{input.api_url}/{job_id}" @@ -422,8 +426,11 @@ async def async_request_v1_videos( poll_json = await poll_response.json() job_status = poll_json.get("status") - if time.perf_counter() >= deadline: - output.error = f"Timed out waiting for video job {job_id} to complete." + if job_status not in {"completed", "failed"} and time.perf_counter() >= deadline: + output.error = ( + f"Timed out after {timeout_seconds:g}s waiting for video job {job_id} to complete. " + "Increase --video-job-timeout for long-running or queued video jobs." + ) output.success = False return output @@ -452,7 +459,7 @@ async def async_request_v1_videos( elif "peak_memory_mb" in resp_json: output.peak_memory_mb = resp_json["peak_memory_mb"] except Exception as e: - output.error = str(e) + output.error = f"{type(e).__name__}: {e}" output.success = False finally: if image_file is not None: @@ -465,13 +472,13 @@ async def async_request_v1_videos( except Exception as e: print(f"Failed to clean up video job {job_id}: {e}") - output.latency = time.perf_counter() - output.start_time + output.latency = time.perf_counter() - output.start_time + if pbar is not None: + pbar.update(1) if output.success and input.slo_ms is not None: output.slo_achieved = (output.latency * 1000.0) <= float(input.slo_ms) - if pbar: - pbar.update(1) return output diff --git a/benchmarks/diffusion/diffusion_benchmark_serving.py b/benchmarks/diffusion/diffusion_benchmark_serving.py index 6daac9df4..1e5aaf29c 100644 --- a/benchmarks/diffusion/diffusion_benchmark_serving.py +++ b/benchmarks/diffusion/diffusion_benchmark_serving.py @@ -1,6 +1,6 @@ # adapted from fastvideo # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """ Benchmark online serving for diffusion models (Image/Video Generation). @@ -87,6 +87,7 @@ import base64 import glob import json import logging +import math import os import random import tempfile @@ -1116,6 +1117,11 @@ def calculate_metrics( "duration": total_duration, "completed_requests": num_success, "failed_requests": len(error_outputs), + "request_errors": [ + {"request_id": req.request_id, "error": out.error} + for req, out in zip(requests_list, outputs) + if not out.success + ], "throughput_qps": num_success / total_duration if total_duration > 0 else 0, "latency_mean": np.mean(latencies) if latencies else 0, "latency_median": np.median(latencies) if latencies else 0, @@ -1261,6 +1267,9 @@ def _default_endpoint_for_task(task: str) -> str: async def benchmark(args): + if not math.isfinite(args.video_job_timeout) or args.video_job_timeout <= 0: + raise ValueError("--video-job-timeout must be a positive finite number of seconds.") + # Construct base_url if not provided if args.base_url is None: args.base_url = f"http://{args.host}:{args.port}" @@ -1312,6 +1321,8 @@ async def benchmark(args): print("Loading requests...") requests_list = dataset.get_requests() print(f"Prepared {len(requests_list)} requests from {args.dataset} dataset.") + for req in requests_list: + req.video_job_timeout = args.video_job_timeout if args.return_stage_metrics and args.endpoint in _STAGE_METRICS_ENDPOINTS: for req in requests_list: @@ -1376,6 +1387,8 @@ async def benchmark(args): metrics["model"] = args.model metrics["dataset"] = args.dataset metrics["task"] = args.task + if args.endpoint == "/v1/videos": + metrics["video_job_timeout"] = args.video_job_timeout if args.endpoint == "/v1/images/edits": metrics["bot_task"] = args.bot_task @@ -1398,6 +1411,11 @@ async def benchmark(args): ) ) print("{:<40} {}/{:<15}".format("Successful requests:", metrics["completed_requests"], len(requests_list))) + print("{:<40} {:<15}".format("Failed requests:", metrics["failed_requests"])) + for failure in metrics["request_errors"][:10]: + print(f"Request {failure['request_id']} failed: {failure['error']}") + if metrics["failed_requests"] > 10: + print("Showing first 10 failures; use --output-file to save all request errors.") # Section 3: Performance Metrics print(f"{'-' * 50}") @@ -1481,6 +1499,13 @@ if __name__ == "__main__": help="Path to local dataset file (optional).", ) parser.add_argument("--num-prompts", type=int, default=10, help="Number of prompts to benchmark.") + parser.add_argument( + "--video-job-timeout", + type=float, + default=900.0, + help="Maximum polling time in seconds per video job, including server queue time (default: 900). " + "Increase this for long-running video generation or high concurrency.", + ) parser.add_argument( "--max-concurrency", type=int, From c9c25b33c3210ba275917db32e0e5403d69051a5 Mon Sep 17 00:00:00 2001 From: LHXuuu Date: Tue, 8 Sep 2026 18:17:00 +0800 Subject: [PATCH 39/90] [Frontend] Add server-side VAD for turn-based omni models (#6618) Signed-off-by: LHXuuu --- docs/design/fullduplex.md | 8 +- docs/serving/realtime_duplex_api.md | 41 +- examples/online_serving/qwen3_omni/README.md | 24 +- .../qwen3_omni/openai_realtime_client.py | 97 +- pyproject.toml | 4 - .../test_minicpmo_4_5_duplex_expansion.py | 19 +- .../duplex/test_duplex_deploy_config.py | 24 + tests/engine/test_single_stage_mode.py | 44 +- .../duplex/test_runtime_adapter_boundary.py | 15 +- .../openai_api/test_api_server_guards.py | 42 + .../openai_api/test_audio_format.py | 14 +- .../openai_api/test_duplex_handler.py | 1344 +++++++++++++++-- .../test_qwen3_omni_realtime_websocket.py | 174 ++- .../entrypoints/openai_api/test_server_vad.py | 429 ++++++ .../test_duplex_fence_propagation.py | 6 +- tests/entrypoints/test_utils.py | 26 + vllm_omni/config/stage_config.py | 5 + vllm_omni/engine/async_omni_engine.py | 2 +- vllm_omni/entrypoints/duplex/audio.py | 55 +- vllm_omni/entrypoints/duplex/capability.py | 11 +- vllm_omni/entrypoints/duplex/chat_fallback.py | 9 +- vllm_omni/entrypoints/duplex/protocol.py | 208 ++- .../entrypoints/duplex/realtime_input.py | 729 +++++---- .../entrypoints/duplex/realtime_output.py | 62 +- .../entrypoints/duplex/realtime_session.py | 4 + .../entrypoints/duplex/realtime_state.py | 47 +- .../entrypoints/duplex/runtime_adapter.py | 99 ++ .../entrypoints/duplex/runtime_bridge.py | 27 +- vllm_omni/entrypoints/duplex/server_vad.py | 528 +++++++ vllm_omni/entrypoints/duplex/serving.py | 326 +++- .../entrypoints/duplex/session_runner.py | 514 ++++++- vllm_omni/entrypoints/duplex/vad.py | 181 --- vllm_omni/entrypoints/openai/api_server.py | 8 +- .../entrypoints/openai/audio_utils_mixin.py | 194 ++- vllm_omni/metrics/definitions.py | 6 + vllm_omni/metrics/realtime.py | 60 + 36 files changed, 4446 insertions(+), 940 deletions(-) create mode 100644 tests/entrypoints/openai_api/test_server_vad.py create mode 100644 vllm_omni/entrypoints/duplex/server_vad.py delete mode 100644 vllm_omni/entrypoints/duplex/vad.py create mode 100644 vllm_omni/metrics/realtime.py diff --git a/docs/design/fullduplex.md b/docs/design/fullduplex.md index 2311ee1cf..87fb1f79f 100644 --- a/docs/design/fullduplex.md +++ b/docs/design/fullduplex.md @@ -66,7 +66,8 @@ The checkpoint keeps these verified contracts: - scheduler data-plane append over a resumable request; - stale epoch/turn/response fencing; - `/v1/duplex` and OpenAI Realtime projection; -- optional OpenAI `server_vad` turn detection backed by per-session Silero VAD; +- optional OpenAI `server_vad` turn detection backed by a process-shared Silero + model and per-session VAD state; - existing JoyVL behavior. The checkpoint does not claim: @@ -652,7 +653,7 @@ EOS decisions advance the model conversation. Explicit Realtime clients may still use `input_audio_buffer.commit` to create a conversation item. The translator validates that wire input before producing a -commit carrying `realtime_item_id`. Native auto-response may already have +commit carrying `item_id`. Native auto-response may already have streamed those PCM samples into the runtime, so the runner accepts a validated commit even when no runtime-side chunk remains. A truly empty explicit buffer continues to return `input_audio_buffer_empty`. @@ -677,8 +678,7 @@ multi-turn runtime evidence. During auto-response overlap, `preserve_realtime_input` distinguishes "do not append this silent chunk to the native buffer" from "clear the open Realtime -item". Silent overlap no longer discards earlier user PCM. A separate explicit -`server_vad` policy is documented in the Realtime contract above. +item". Silent overlap no longer discards earlier user PCM. The first chunk of one overlapping input item also reserves its target model turn. A later Realtime commit uses that reserved identity even if response EOS diff --git a/docs/serving/realtime_duplex_api.md b/docs/serving/realtime_duplex_api.md index 30021e107..d37d02291 100644 --- a/docs/serving/realtime_duplex_api.md +++ b/docs/serving/realtime_duplex_api.md @@ -1,17 +1,17 @@ # Realtime Duplex API vLLM-Omni serves full-duplex speech models over a WebSocket endpoint, -`/v1/realtime?duplex=1`, that speaks the OpenAI Realtime vocabulary plus a +`/v1/realtime`, that speaks the OpenAI Realtime vocabulary plus a set of duplex extensions: the model decides when to listen and when to speak, the user can talk over the assistant, playback progress is reported back so history stays honest, and a dropped connection can resume the same session. This page covers how to run a duplex deployment, how to drive it from Python with `vllm_omni.clients.duplex.DuplexClient`, and the complete wire contract. -The endpoint is served for models that ship a duplex plugin — currently -MiniCPM-o 4.5, PersonaPlex, and Nemotron VoiceChat — and only for deploy -configurations that declare `session_mode: duplex`. The runtime architecture -is described in [Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md). +The handler is enabled by either a model-native `session_mode: duplex` +deployment or an explicit `duplex_session` configuration for turn-based +Server VAD. The runtime architecture is described in +[Full-Duplex Runtime (MiniCPM-o 4.5)](../design/fullduplex.md). ## Quick Start @@ -27,7 +27,7 @@ vllm-omni serve openbmb/MiniCPM-o-4_5 \ `vllm_omni/deploy/minicpmo_4_5.yaml` declares `session_mode: duplex` and `duplex_session.max_sessions: 4`, which mounts the WebSocket routes -`ws://:8099/v1/realtime?duplex=1` (this page) and `ws://:8099/v1/duplex` +`ws://:8099/v1/realtime` (this page) and `ws://:8099/v1/duplex` (the native dialect for raw-protocol clients) next to the usual HTTP API. PersonaPlex and Nemotron VoiceChat use `vllm_omni/deploy/personaplex.yaml` and `vllm_omni/deploy/nemotron_labs_voicechat_duplex.yaml` in the same way. @@ -240,7 +240,7 @@ on the same helpers. ## API Reference -This section is the complete wire contract of `/v1/realtime?duplex=1`: how +This section is the complete wire contract of `/v1/realtime`: how the dialect relates to the OpenAI Realtime protocol, which surfaces each model's capability flags gate, a catalogue of every client-to-server and server-to-client event, and a JSON example for each. `DuplexClient` speaks @@ -253,10 +253,11 @@ its protocol tests. ### Endpoint and transport -The endpoint is `ws(s):///v1/realtime?duplex=1`; the `duplex` query -parameter (`1`, `true`, or `on`) selects the duplex session handler, and the -route is mounted only for deployments whose deploy configuration declares -`session_mode: duplex`. Optional query parameters are `model`, `session_id`, +The endpoint is `ws(s):///v1/realtime`. Bare connections select the +session handler when the deployment declares either `session_mode: duplex` +or `duplex_session`. `?duplex=1` remains a compatibility alias, while +`?duplex=0` selects the legacy Realtime handler. Optional query parameters +are `model`, `session_id`, `autostart` (`0` means resume-only), `resume`, and `native_duplex` (`minicpmo45_native_duplex` is accepted as a deprecated alias and folded into the canonical name). Every message is one JSON object per WebSocket @@ -342,9 +343,14 @@ Semantic divergences hidden behind shared names: before any commit; a commit may end in `response.listen` and no response, and the model may open a response with no commit at all. OpenAI: commit ⇒ item, `response.create` ⇒ exactly one response. -- `turn_detection.interrupt_response=false` is rejected; - `create_response` is ignored; `semantic_vad` is unsupported; the VAD - runs per session (Silero) and implies `overlap_policy=barge_in_on_speech`. +- With `turn_detection={"type":"server_vad"}`, omitted `interrupt_response` + defaults to `false` on turn-based models such as Qwen3-Omni, enabling + endpointing and automatic responses without interruption. Explicit `true` + is rejected there. Model-native duplex keeps the `true` default and rejects + `false`; its VAD implies `overlap_policy=barge_in_on_speech`. + This capability-dependent default differs from OpenAI's `true` default; + the effective configuration is returned in `session.created` / `session.updated`. + `semantic_vad` is unsupported. - `rate_limits.updated` is always an empty list (compatibility only). - Cancellation never reuses a `response_id`; truncation is driven by `playback.ack` as well as `conversation.item.truncate`. @@ -1201,9 +1207,10 @@ Realtime projector renames and fans out these events before they reach a barge-in, or audio truncation; Nemotron VoiceChat does not support barge-in or audio truncation; camera frames are consumed only by MiniCPM-o 4.5; tool calls are produced only by Nemotron VoiceChat. -- `turn_detection` supports only `server_vad` with `interrupt_response=true`; - `semantic_vad`, `interrupt_response=false`, and `create_response` are not - supported. Server VAD requires a model-native session. +- `turn_detection` supports `server_vad` or `null`, not `semantic_vad`. + Turn-based models support endpointing without interruption, including + `create_response=false` for explicit response creation. Model-native duplex + requires `interrupt_response=true` and `create_response=true`. - `input_audio_transcription` and `input_audio_noise_reduction` are accepted and echoed but no separate transcription or noise-reduction stage runs; transcripts come from the model. diff --git a/examples/online_serving/qwen3_omni/README.md b/examples/online_serving/qwen3_omni/README.md index 926a58682..ef2367412 100644 --- a/examples/online_serving/qwen3_omni/README.md +++ b/examples/online_serving/qwen3_omni/README.md @@ -264,7 +264,7 @@ python examples/online_serving/openai_chat_completion_client_for_multimodal_gene #### Realtime WebSocket client (`openai_realtime_client.py`) -[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks (OpenAI-style `input_audio_buffer.append` / `commit`), and receives **`response.audio.delta`** (incremental PCM for the reply) plus **`transcription.*`** events. By default it concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging. +[`openai_realtime_client.py`](./openai_realtime_client.py) connects to **`ws://:/v1/realtime`**, streams a local WAV as **PCM16 mono @ 16 kHz** in fixed-size chunks, and receives **`response.audio.*`**, **`response.output_text.*`**, and transcript events. By default it uses explicit `input_audio_buffer.commit`; pass **`--server-vad`** to let the server detect speech endpoints and commit the turn automatically. The client concatenates audio deltas and writes **`--output-wav`** (model output is typically **24 kHz**). Optional **`--delta-dump-dir`** saves each delta as `delta_000001.wav`, … for debugging. Streaming input works well for translation-style use cases; if the Thinker runs while input is still incomplete, consider limiting **`max_tokens`** in your session / server defaults to avoid over-generation. @@ -282,6 +282,7 @@ python openai_realtime_client.py \ --model Qwen/Qwen3-Omni-30B-A3B-Instruct \ --input-wav /path/to/input_16k_mono.wav \ --output-wav realtime_output.wav \ + --server-vad \ --delta-dump-dir ./rt_delta_wavs ``` @@ -297,15 +298,32 @@ python openai_realtime_client.py \ | `--chunk-ms` | `200` | Size of each uploaded audio chunk (milliseconds of audio) | | `--send-delay-ms` | `0` | Delay between chunk sends (simulate realtime upload) | | `--delta-dump-dir` | *(optional)* | Directory to write per-`response.audio.delta` WAV files | +| `--server-vad` | disabled | Enable server-side Silero VAD instead of sending explicit commits | | `--num-requests` | `1` | Number of sequential sessions (see `--concurrency`) | | `--concurrency` | `1` | Max concurrent WebSocket sessions when `--num-requests` > 1 | -Ensure the server is running, for example: +Server VAD uses the pinned Silero v6.2 ONNX artifact from `istupakov/silero-vad-onnx` and never downloads model files while processing a session. Make the artifact available in the Hugging Face cache before startup, or configure a local artifact in the deployment YAML: + +```yaml +duplex_session: + server_vad_model_path: /models/silero_vad.onnx +``` + +Start from the bundled Qwen3-Omni deployment YAML, add the fields above at the top level, and serve that complete configuration: ```bash -vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct --omni --port 8091 +cp vllm_omni/deploy/qwen3_omni_moe.yaml /path/to/qwen3_omni_server_vad.yaml +# Edit /path/to/qwen3_omni_server_vad.yaml and add duplex_session. +vllm serve Qwen/Qwen3-Omni-30B-A3B-Instruct \ + --omni \ + --port 8091 \ + --deploy-config /path/to/qwen3_omni_server_vad.yaml ``` +Keep Qwen's default `session_mode: turn`; `duplex_session` enables the Realtime handler without changing scheduler +semantics. Bare `/v1/realtime` selects that handler. The client uses `?duplex=0` only for the existing non-Server-VAD +wire flow; `?duplex=1` remains a supported compatibility alias. + The Python client supports the following command-line arguments: - `--query-type` (or `-q`): Query type (default: `use_video`). Options: `text`, `use_audio`, `use_image`, `use_video` diff --git a/examples/online_serving/qwen3_omni/openai_realtime_client.py b/examples/online_serving/qwen3_omni/openai_realtime_client.py index 79e30a3f5..8a6871580 100644 --- a/examples/online_serving/qwen3_omni/openai_realtime_client.py +++ b/examples/online_serving/qwen3_omni/openai_realtime_client.py @@ -1,9 +1,12 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Realtime client for vLLM-Omni /v1/realtime (audio + text events). This client: 1) Reads a local WAV file (must be mono, 16-bit PCM, 16kHz), 2) Streams PCM16 chunks to /v1/realtime with OpenAI-style events, -3) Receives response.audio.* and transcription.* events, +3) Receives response.audio.*, response.output_text.*, and transcript events, 4) Saves synthesized audio to an output WAV file and optional text file. By default each ``response.audio.delta`` is treated as an **incremental PCM** @@ -18,6 +21,7 @@ Usage: --model Qwen/Qwen3-Omni-30B-A3B-Instruct \ --input-wav input_16k_mono.wav \ --output-wav realtime_output.wav \ + --server-vad \ --delta-dump-dir ./rt_delta_wavs Dependencies: @@ -32,6 +36,7 @@ import base64 import json import wave from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit try: import websockets @@ -70,6 +75,14 @@ def _write_wav_pcm16(path: Path, pcm16_bytes: bytes, sample_rate_hz: int) -> Non wf.writeframes(pcm16_bytes) +def _with_realtime_route(url: str, *, server_vad: bool) -> str: + parts = urlsplit(url) + query = [(key, value) for key, value in parse_qsl(parts.query, keep_blank_values=True) if key != "duplex"] + if not server_vad: + query.append(("duplex", "0")) + return urlunsplit(parts._replace(query=urlencode(query))) + + async def run_client( url: str, model: str, @@ -79,6 +92,7 @@ async def run_client( chunk_ms: int, send_delay_ms: int, delta_dump_dir: Path | None, + server_vad: bool, request_idx: int = 1, total_requests: int = 1, ) -> None: @@ -92,23 +106,44 @@ async def run_client( delta_index = 0 text_chunks: list[str] = [] final_text: str = "" + final_audio_transcript: str = "" if delta_dump_dir is not None: delta_dump_dir.mkdir(parents=True, exist_ok=True) + # Keep the CLI mode authoritative even if the input URL already contains + # an explicit duplex query parameter. + url = _with_realtime_route(url, server_vad=server_vad) async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws: # 1) Validate model. - await ws.send( - json.dumps( - { - "type": "session.update", + if server_vad: + session_update = { + "type": "session.update", + "session": { "model": model, - } - ) - ) + "audio": { + "input": { + "format": {"type": "audio/pcm", "rate": 16_000}, + "turn_detection": { + "type": "server_vad", + "threshold": 0.5, + "prefix_padding_ms": 300, + "silence_duration_ms": 500, + "create_response": True, + "interrupt_response": False, + }, + } + }, + }, + } + else: + # Preserve the existing non-duplex wire format. + session_update = {"type": "session.update", "model": model} + await ws.send(json.dumps(session_update)) - # 2) Start generation once (non-final commit). - await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False})) + # 2) Preserve the legacy explicit-commit flow unless Server VAD owns endpointing. + if not server_vad: + await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": False})) # 3) Stream audio chunks. for i in range(0, len(pcm16), chunk_bytes): @@ -124,8 +159,20 @@ async def run_client( if send_delay_ms > 0: await asyncio.sleep(send_delay_ms / 1000.0) - # 4) Final commit closes input stream. - await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True})) + # 4) Finalize the turn explicitly, or append silence for Server VAD endpointing. + if server_vad: + trailing_silence = bytes(16_000 * 2) + for i in range(0, len(trailing_silence), chunk_bytes): + await ws.send( + json.dumps( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(trailing_silence[i : i + chunk_bytes]).decode("utf-8"), + } + ) + ) + else: + await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True})) # 5) Receive server events until audio done. while True: @@ -144,7 +191,7 @@ async def run_client( sr = event.get("sample_rate_hz") if isinstance(sr, int) and sr > 0: output_sample_rate = sr - audio_b64 = event.get("audio", "") + audio_b64 = event.get("delta" if server_vad else "audio", "") if audio_b64: pcm_delta = base64.b64decode(audio_b64) incremental_pcm_parts.append(pcm_delta) @@ -158,14 +205,14 @@ async def run_client( ) continue - if event_type == "transcription.delta": + if event_type in {"response.output_text.delta", "transcription.delta"}: delta = event.get("delta", "") if delta: text_chunks.append(delta) print(delta, end="", flush=True) continue - if event_type == "transcription.done": + if event_type in {"response.output_text.done", "transcription.done"}: final_text = event.get("text", "") or "".join(text_chunks) usage = event.get("usage") final_text_with_tag = f"Final transcription: {final_text}" @@ -176,7 +223,16 @@ async def run_client( print(f"{log_prefix}text usage: {usage}") continue + if event_type == "response.audio_transcript.done": + final_audio_transcript = event.get("transcript", "") + continue + if event_type == "response.audio.done": + if not server_vad: + break + continue + + if event_type == "response.done" and server_vad: break if event_type == "error": @@ -191,7 +247,7 @@ async def run_client( print(f"{log_prefix}Saved realtime audio to: {output_wav} (incremental chunks joined)") if output_text is not None: - text_to_save = final_text if final_text else "".join(text_chunks) + text_to_save = final_text or "".join(text_chunks) or final_audio_transcript output_text.parent.mkdir(parents=True, exist_ok=True) output_text.write_text(text_to_save, encoding="utf-8") print(f"{log_prefix}Saved realtime text to: {output_text}") @@ -213,6 +269,7 @@ async def run_clients_concurrent( chunk_ms: int, send_delay_ms: int, delta_dump_dir: Path | None, + server_vad: bool, num_requests: int, concurrency: int, ) -> None: @@ -235,6 +292,7 @@ async def run_clients_concurrent( chunk_ms=chunk_ms, send_delay_ms=send_delay_ms, delta_dump_dir=per_delta_dir, + server_vad=server_vad, request_idx=index, total_requests=num_requests, ) @@ -283,6 +341,11 @@ def main() -> None: default=None, help="If set, each response.audio.delta is saved as delta_NNNNNN.wav under this directory", ) + parser.add_argument( + "--server-vad", + action="store_true", + help="Let the server detect speech endpoints instead of sending input_audio_buffer.commit", + ) parser.add_argument("--num-requests", type=int, default=1, help="Total number of requests to send") parser.add_argument( "--concurrency", @@ -309,6 +372,7 @@ def main() -> None: chunk_ms=args.chunk_ms, send_delay_ms=args.send_delay_ms, delta_dump_dir=args.delta_dump_dir, + server_vad=args.server_vad, ) ) else: @@ -322,6 +386,7 @@ def main() -> None: chunk_ms=args.chunk_ms, send_delay_ms=args.send_delay_ms, delta_dump_dir=args.delta_dump_dir, + server_vad=args.server_vad, num_requests=args.num_requests, concurrency=concurrency, ) diff --git a/pyproject.toml b/pyproject.toml index ce1eb9a13..3ba902977 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,6 @@ dev = [ # Provides top-level cosyvoice2; do not co-install stepaudio2-minicpmo, # whose top-level stepaudio2 package conflicts with this distribution. "step-audio2==1.0.0", # Step-Audio2 / MiniCPM-o 4.5 Token2Wav (flashcosyvoice / hyperpyyaml) - "silero-vad==6.1", # Realtime server-VAD duplex E2E ] demo = [ @@ -87,9 +86,6 @@ demo = [ "cn2an>=0.5.22", # Chinese number normalization for TTS (e.g. "1次" -> "一次") ] -server-vad = [ - "silero-vad==6.1", -] forced-aligner = [ "qwen-asr>=0.0.6", # faithful Japanese/Korean word segmentation for the TTS forced aligner diff --git a/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py index 5b35c4b53..c3c6fd90a 100644 --- a/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py +++ b/tests/e2e/online_serving/test_minicpmo_4_5_duplex_expansion.py @@ -30,10 +30,27 @@ from tests.e2e.online_serving.run_minicpmo_realtime_duplex_soft_interrupt import run_soft_interrupt, ) from tests.helpers.mark import hardware_test +from vllm_omni.entrypoints.duplex.server_vad import ( + SILERO_VAD_FILENAME, + SILERO_VAD_REPO_ID, + SILERO_VAD_REVISION, +) pytestmark = [pytest.mark.full_model, pytest.mark.omni] +@pytest.fixture(scope="module") +def _cached_server_vad_artifact() -> None: + """Cache the pinned ONNX artifact before the Realtime server starts.""" + from huggingface_hub import hf_hub_download + + hf_hub_download( + repo_id=SILERO_VAD_REPO_ID, + filename=SILERO_VAD_FILENAME, + revision=SILERO_VAD_REVISION, + ) + + @hardware_test(res={"cuda": "H100", "npu": "A3"}, num_cards=1) @pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) def test_duplex_admission_and_expiry_reaper(omni_server, tmp_path: Path) -> None: @@ -97,7 +114,7 @@ def test_duplex_soft_interrupt(omni_server, tmp_path: Path) -> None: @hardware_test(res={"cuda": "H100"}, num_cards=1) @pytest.mark.parametrize("omni_server", SERVER_PARAMS, indirect=True) -def test_duplex_server_vad_hard_interrupt(omni_server) -> None: +def test_duplex_server_vad_hard_interrupt(_cached_server_vad_artifact, omni_server) -> None: result = asyncio.run( run_server_vad_interrupt( SimpleNamespace( diff --git a/tests/engine/duplex/test_duplex_deploy_config.py b/tests/engine/duplex/test_duplex_deploy_config.py index 800070efb..e9168729c 100644 --- a/tests/engine/duplex/test_duplex_deploy_config.py +++ b/tests/engine/duplex/test_duplex_deploy_config.py @@ -33,6 +33,7 @@ def test_duplex_session_runtime_defaults_are_typed_and_immutable(tmp_path) -> No assert deploy.duplex_session.max_pending_turns_per_session == 4 assert deploy.duplex_session.max_sessions == 1 assert deploy.duplex_session.completed_append_cache_size == 256 + assert deploy.duplex_session.server_vad_model_path is None with pytest.raises(FrozenInstanceError): deploy.duplex_session.idle_ttl_s = 1.0 # type: ignore[misc] @@ -46,6 +47,29 @@ def test_duplex_session_runtime_accepts_disabled_idle_expiry(tmp_path) -> None: assert deploy.duplex_session.idle_ttl_s is None +def test_duplex_session_runtime_accepts_local_server_vad_model(tmp_path) -> None: + deploy_path = tmp_path / "duplex.yaml" + deploy_path.write_text( + "duplex_session:\n server_vad_model_path: /models/silero_vad.onnx\nstages: []\n", + encoding="utf-8", + ) + + deploy = load_deploy_config(deploy_path) + + assert deploy.duplex_session.server_vad_model_path == "/models/silero_vad.onnx" + + +def test_duplex_session_runtime_rejects_empty_server_vad_model_path(tmp_path) -> None: + deploy_path = tmp_path / "duplex.yaml" + deploy_path.write_text( + "duplex_session:\n server_vad_model_path: ''\nstages: []\n", + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="server_vad_model_path"): + load_deploy_config(deploy_path) + + @pytest.mark.parametrize( ("name", "value"), [ diff --git a/tests/engine/test_single_stage_mode.py b/tests/engine/test_single_stage_mode.py index cacda1f60..675a0650b 100644 --- a/tests/engine/test_single_stage_mode.py +++ b/tests/engine/test_single_stage_mode.py @@ -376,14 +376,26 @@ class TestSingleStageModeDetection: mocker: MockerFixture, *, stage_cfgs: list[Any] | None = None, + resolved_config_path: str = "/fake/path", + patch_deploy_config: bool = True, **kwargs: Any, ) -> AsyncOmniEngine: mock_stage_configs = stage_cfgs or [_make_stage_cfg(0)] + if patch_deploy_config: + mocker.patch.object( + StageConfigFactory, + "get_pipeline_config", + return_value=None, + ) + mocker.patch( + "vllm_omni.engine.async_omni_engine.load_deploy_config", + return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()), + ) mocker.patch.object( AsyncOmniEngine, "_resolve_stage_configs", - return_value=("/fake/path", mock_stage_configs), + return_value=(resolved_config_path, mock_stage_configs), ) mocker.patch.object(AsyncOmniEngine, "_bootstrap_orchestrator") mock_thread_cls = mocker.patch("threading.Thread") @@ -445,6 +457,8 @@ class TestSingleStageModeDetection: engine = self._make_engine_no_thread( mocker, deploy_config="/fake/duplex.yaml", + resolved_config_path="/fake/duplex.yaml", + patch_deploy_config=False, ) get_pipeline_config.assert_called_once_with( @@ -455,6 +469,30 @@ class TestSingleStageModeDetection: load_deploy_config.assert_called_once_with("/fake/duplex.yaml") assert engine.duplex_session_config is duplex_session + def test_auto_discovered_deploy_loads_duplex_runtime_config( + self, + mocker: MockerFixture, + ) -> None: + deploy_path = "/resolved/qwen3_omni_moe.yaml" + duplex_session = DuplexSessionRuntimeConfig(server_vad_model_path="/models/silero_vad.onnx") + mocker.patch( + "vllm_omni.engine.async_omni_engine.StageConfigFactory.get_pipeline_config", + return_value=None, + ) + load_deploy_config = mocker.patch( + "vllm_omni.engine.async_omni_engine.load_deploy_config", + return_value=SimpleNamespace(duplex_session=duplex_session), + ) + + engine = self._make_engine_no_thread( + mocker, + resolved_config_path=deploy_path, + patch_deploy_config=False, + ) + + load_deploy_config.assert_called_once_with(deploy_path) + assert engine.duplex_session_config is duplex_session + def test_single_stage_mode_without_stage_id_has_no_filter(self, mocker: MockerFixture): engine = self._make_engine_no_thread( mocker, @@ -518,6 +556,10 @@ class TestEndpointRestrictionsTrustRemoteCode: StageConfigFactory.try_infer_model_type.cache_clear() def _make_engine_no_thread(self, mocker: MockerFixture, **kwargs: Any) -> AsyncOmniEngine: + mocker.patch( + "vllm_omni.engine.async_omni_engine.load_deploy_config", + return_value=SimpleNamespace(duplex_session=DuplexSessionRuntimeConfig()), + ) mocker.patch.object( AsyncOmniEngine, "_resolve_stage_configs", diff --git a/tests/entrypoints/duplex/test_runtime_adapter_boundary.py b/tests/entrypoints/duplex/test_runtime_adapter_boundary.py index dc7fffccd..cb90e4bd5 100644 --- a/tests/entrypoints/duplex/test_runtime_adapter_boundary.py +++ b/tests/entrypoints/duplex/test_runtime_adapter_boundary.py @@ -61,20 +61,19 @@ if loaded: assert result.returncode == 0, result.stderr -def test_generic_handler_requires_explicit_serving_runtime_adapter() -> None: +def test_generic_handler_supports_adapterless_chat_fallback() -> None: repo_root = Path(__file__).resolve().parents[3] script = """ from types import SimpleNamespace from vllm_omni.entrypoints.duplex.serving import OmniDuplexSessionHandler -chat_service = SimpleNamespace(engine_client=SimpleNamespace()) -try: - OmniDuplexSessionHandler(chat_service=chat_service) -except ValueError as exc: - if "serving runtime adapter" not in str(exc).lower(): - raise -else: +chat_service = SimpleNamespace( + engine_client=SimpleNamespace(), + model_config=SimpleNamespace(model="test-model"), +) +handler = OmniDuplexSessionHandler(chat_service=chat_service) +if handler._serving_runtime_adapter is not None: raise SystemExit("generic handler silently selected a model serving adapter") """ diff --git a/tests/entrypoints/openai_api/test_api_server_guards.py b/tests/entrypoints/openai_api/test_api_server_guards.py index 707b4b961..71f93f6dc 100644 --- a/tests/entrypoints/openai_api/test_api_server_guards.py +++ b/tests/entrypoints/openai_api/test_api_server_guards.py @@ -568,6 +568,48 @@ def test_websocket_routes_emit_stable_unavailable_frames_and_close(path: str, pa websocket.receive_text() +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("duplex_query", "expected_handler"), + [ + (None, "duplex"), + ("1", "duplex"), + ("true", "duplex"), + ("on", "duplex"), + ("0", "legacy"), + ("false", "legacy"), + ], +) +async def test_realtime_route_defaults_to_configured_duplex_handler( + monkeypatch, duplex_query: str | None, expected_handler: str +) -> None: + calls: list[str] = [] + + class _DuplexHandler: + async def handle_realtime_session(self, _websocket) -> None: + calls.append("duplex") + + class _LegacyConnection: + async def handle_connection(self) -> None: + calls.append("legacy") + + monkeypatch.setattr(api_server, "RealtimeConnection", lambda _websocket, _serving: _LegacyConnection()) + query_params = {} if duplex_query is None else {"duplex": duplex_query} + websocket = SimpleNamespace( + app=SimpleNamespace( + state=SimpleNamespace( + openai_serving_duplex=_DuplexHandler(), + openai_serving_realtime=object(), + ) + ), + query_params=query_params, + ) + + await api_server.realtime_websocket(websocket) + + assert calls == [expected_handler] + + def test_health_without_engine_returns_stable_unhealthy_response() -> None: """Lock ``/health`` with no engine initialized. diff --git a/tests/entrypoints/openai_api/test_audio_format.py b/tests/entrypoints/openai_api/test_audio_format.py index e47885d9b..a9bf76319 100644 --- a/tests/entrypoints/openai_api/test_audio_format.py +++ b/tests/entrypoints/openai_api/test_audio_format.py @@ -163,6 +163,8 @@ class TestStreamingAudioResampler: chunked = StreamingAudioResampler(24000, 8000) pieces = [chunked.process(chunk) for chunk in np.split(waveform, [137, 2048, 9001, 17003])] + # Bounded FIR history must own its data, not retain the last large chunk. + assert chunked._history.base is None pieces.append(chunked.process(np.empty(0), final=True)) actual = np.concatenate(pieces) @@ -193,9 +195,15 @@ class TestStreamingAudioResampler: output_rms = np.sqrt(np.mean(output[100:-100] ** 2)) assert output_rms < input_rms * 0.01 - def test_rejects_non_integer_downsampling_ratio(self): - with pytest.raises(ValueError, match="integer downsampling ratio"): - StreamingAudioResampler(24000, 16000) + def test_supports_rational_downsampling_ratio(self): + waveform = np.sin(np.linspace(0, 200 * np.pi, 24000, endpoint=False)).astype(np.float32) + resampler = StreamingAudioResampler(24000, 16000) + + output = resampler.process(waveform, final=True) + reference = torchaudio.functional.resample(torch.from_numpy(waveform), 24000, 16000).numpy() + + assert output.shape == (16000,) + np.testing.assert_allclose(output[100:-100], reference[100:-100], atol=2e-3, rtol=1e-3) def test_rejects_multichannel_audio(self): resampler = StreamingAudioResampler(24000, 8000) diff --git a/tests/entrypoints/openai_api/test_duplex_handler.py b/tests/entrypoints/openai_api/test_duplex_handler.py index c69bc5c47..d3bbc5d01 100644 --- a/tests/entrypoints/openai_api/test_duplex_handler.py +++ b/tests/entrypoints/openai_api/test_duplex_handler.py @@ -5,8 +5,10 @@ from __future__ import annotations import asyncio import base64 +import io import json import struct +import wave from types import SimpleNamespace from typing import Any @@ -30,8 +32,8 @@ from vllm_omni.engine.duplex.control_client import DuplexControlRequestError from vllm_omni.engine.duplex.lease import DuplexLeaseActivity from vllm_omni.engine.duplex.messages import DuplexFence, DuplexSessionLifecycleMessage from vllm_omni.engine.duplex.runtime import duplex_resource_request_id -from vllm_omni.entrypoints.duplex import vad as realtime_vad from vllm_omni.entrypoints.duplex.protocol import ( + DuplexCapabilities, DuplexOverlapPolicy, DuplexPlaybackCommitPolicy, DuplexSession, @@ -69,6 +71,11 @@ from vllm_omni.outputs import OmniRequestOutput from vllm_omni.outputs.duplex import attach_duplex_output_decision pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +INVALID_VALIDATED_SAMPLE_RATES = ( + pytest.param(16_000.5, id="fractional"), + pytest.param(float("inf"), id="infinite"), + pytest.param(float("nan"), id="nan"), +) def test_native_input_append_supports_explicit_session_opt_out(): @@ -327,6 +334,46 @@ class FakeChatService: return SimpleNamespace(audio_data=f"wav-{int(audio_obj.audio_tensor.shape[0])}") +class TurnBasedFakeChatService(FakeChatService): + """Chat-fallback service without a model-native duplex adapter.""" + + duplex_serving_adapter_path = None + + def __init__(self, engine_client: FakeEngineClient) -> None: + super().__init__(engine_client) + self.requests: list[Any] = [] + + async def create_chat_completion(self, request, raw_request=None): + del raw_request + self.requests.append(request) + self.seen_request_ids.append(request.request_id) + + async def _gen(): + yield "data: [DONE]\n\n" + + return _gen() + + +class GateChatService(FakeChatService): + def __init__(self, engine_client: FakeEngineClient) -> None: + super().__init__(engine_client) + self.first_response_gate = asyncio.Event() + self.response_count = 0 + + async def create_chat_completion(self, request, raw_request=None): + del raw_request + self.seen_request_ids.append(request.request_id) + self.response_count += 1 + response_number = self.response_count + + async def _gen(): + if response_number == 1: + await self.first_response_gate.wait() + yield "data: [DONE]\n\n" + + return _gen() + + def _test_data_plane() -> MiniCPMO45DataPlaneSession: def encode_audio( audio: object, @@ -405,6 +452,105 @@ class TimedWebSocket: return [m.get("type", "") for m in self.sent] +class FakeServerVADBackend: + frame_samples = 160 + + def __init__(self, probabilities: list[float]) -> None: + self.probabilities = probabilities + self.calls = 0 + + def new_state(self) -> int: + return 0 + + def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: + del frame + self.calls += 1 + index = int(state) + probability = self.probabilities[index] if index < len(self.probabilities) else 0.0 + return probability, index + 1 + + +class FakeServerVADProvider: + def __init__(self, backend: FakeServerVADBackend) -> None: + self.backend = backend + + def get(self) -> FakeServerVADBackend: + return self.backend + + +class FailingServerVADProvider: + def __init__(self) -> None: + self.calls = 0 + + def get(self) -> FakeServerVADBackend: + self.calls += 1 + raise RuntimeError("server VAD backend unavailable") + + +class FailOnceServerVADBackend(FakeServerVADBackend): + def __init__(self, probabilities: list[float]) -> None: + super().__init__(probabilities) + self._failed = False + + def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: + if not self._failed and int(state) == 2: + self._failed = True + raise RuntimeError("server VAD inference failed") + return super().infer(frame, state) + + +def _server_vad_turn_detection(*, create_response: bool = True) -> dict[str, Any]: + return { + "type": "server_vad", + "prefix_padding_ms": 10, + "silence_duration_ms": 20, + "create_response": create_response, + "interrupt_response": False, + } + + +def _server_vad_session_update( + turn_detection: dict[str, Any] | None, + *, + event_id: str | None = None, + sample_rate_hz: int = 16_000, +) -> dict[str, Any]: + event: dict[str, Any] = { + "type": "session.update", + "session": { + "model": "test-model", + "audio": { + "input": { + "format": {"type": "audio/pcm", "rate": sample_rate_hz}, + "turn_detection": turn_detection, + } + }, + }, + } + if event_id is not None: + event["event_id"] = event_id + return event + + +def _server_vad_audio_append( + frame_count: int = 5, + *, + event_id: str | None = None, + is_speech: bool | None = None, + sample_rate_hz: int = 16_000, +) -> dict[str, Any]: + sample_count = frame_count * 160 * sample_rate_hz // 16_000 + event: dict[str, Any] = { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(np.zeros(sample_count, dtype=" None: + if data.get("type") == "response.done": + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + if not at_creation: + ws.put(_server_vad_session_update(_server_vad_turn_detection())) + update = _server_vad_session_update(_server_vad_turn_detection(), event_id="bad-vad-audio-format") + update["session"]["audio"]["input"].update(input_update) + ws.put(update) + if not at_creation: + ws.put(_server_vad_audio_append()) + handler = OmniDuplexSessionHandler( + chat_service=TurnBasedFakeChatService(FakeEngineClient()), + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([0.0, 0.9, 0.8, 0.0, 0.0])), + ) + + await handler.handle_realtime_session(ws) # type: ignore[arg-type] + + (error,) = [event["error"] for event in ws.sent if event.get("type") == "error"] + assert error["code"] == "unsupported_turn_detection" + assert error["event_id"] == "bad-vad-audio-format" + assert "mono PCM16" in error["message"] + assert ("session.created" in ws.sent_types()) is not at_creation + assert ("response.done" in ws.sent_types()) is not at_creation + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sample_rate_hz", [16_000, 24_000]) +@pytest.mark.parametrize("create_response", [False, True], ids=["commit-only", "minimal-config"]) +@pytest.mark.parametrize("at_creation", [True, False], ids=["create", "update"]) +async def test_realtime_server_vad_commits_without_client_commit( + sample_rate_hz: int, create_response: bool, at_creation: bool +): + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + terminal_event = "response.done" if create_response else "input_audio_buffer.committed" + if data.get("type") == terminal_event: + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + if not at_creation: + ws.put(_server_vad_session_update(None, sample_rate_hz=sample_rate_hz)) + ws.put( + _server_vad_session_update( + {"type": "server_vad"} if create_response else _server_vad_turn_detection(create_response=False), + sample_rate_hz=sample_rate_hz, + ) + ) + # Cover the default 500 ms silence timeout, plus streaming FIR right context. + ws.put(_server_vad_audio_append(54, sample_rate_hz=sample_rate_hz)) + engine = FakeEngineClient() + chat_service = TurnBasedFakeChatService(engine) + handler = OmniDuplexSessionHandler( + chat_service=chat_service, + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([0.0, 0.9, 0.8, 0.0, 0.0])), + ) + + await handler.handle_realtime_session(ws) # type: ignore[arg-type] + + event_types = ws.sent_types() + assert "error" not in event_types + effective_session = [ + event["session"] for event in ws.sent if event["type"] in {"session.created", "session.updated"} + ][-1] + assert effective_session["turn_detection"]["interrupt_response"] is False + assert effective_session["audio"]["input"]["turn_detection"] == effective_session["turn_detection"] + assert effective_session["overlap_policy"] == "listen_only" + assert event_types.index("input_audio_buffer.speech_started") < event_types.index( + "input_audio_buffer.speech_stopped" + ) + assert event_types.index("input_audio_buffer.speech_stopped") < event_types.index("input_audio_buffer.committed") + assert event_types.index("input_audio_buffer.committed") < event_types.index("conversation.item.added") + assert event_types.index("conversation.item.added") < event_types.index("conversation.item.done") + assert "conversation.item.created" not in event_types + committed = next(event for event in ws.sent if event.get("type") == "input_audio_buffer.committed") + added = next(event for event in ws.sent if event.get("type") == "conversation.item.added") + done = next(event for event in ws.sent if event.get("type") == "conversation.item.done") + assert committed["item_id"] == added["item"]["id"] == done["item"]["id"] + assert event_types.count("response.created") == int(create_response) + assert len(chat_service.seen_request_ids) == int(create_response) + if create_response: + assert event_types.index("input_audio_buffer.committed") < event_types.index("response.created") + assert event_types.index("response.created") < event_types.index("response.done") + assert engine.opened == [] + assert engine.signals == [] + assert engine.closed == [] + + +@pytest.mark.asyncio +async def test_realtime_server_vad_create_response_false_accepts_response_create(): + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + if data.get("type") == "input_audio_buffer.committed": + ws.put({"type": "response.create"}) + if data.get("type") == "response.done": + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_server_vad_session_update(_server_vad_turn_detection(create_response=False))) + ws.put(_server_vad_audio_append()) + engine = FakeEngineClient() + chat_service = TurnBasedFakeChatService(engine) + handler = OmniDuplexSessionHandler( + chat_service=chat_service, + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([0.0, 0.9, 0.8, 0.0, 0.0])), + ) + + await handler.handle_realtime_session(ws) # type: ignore[arg-type] + + event_types = ws.sent_types() + assert event_types.index("input_audio_buffer.committed") < event_types.index("response.created") + assert event_types.index("response.created") < event_types.index("response.done") + assert event_types.count("response.created") == 1 + assert len(chat_service.seen_request_ids) == 1 + assert engine.opened == engine.signals == engine.closed == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("sample_rate_hz", [16_000, 24_000]) +async def test_realtime_server_vad_complete_audio_item_bypasses_vad(sample_rate_hz: int): + def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: + item = data.get("item") + if ( + data.get("type") == "conversation.item.done" + and isinstance(item, dict) + and item.get("id") == "item-complete-audio" + ): + ws.put({"type": "response.create"}) + if data.get("type") == "response.done": + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_server_vad_session_update(_server_vad_turn_detection(), sample_rate_hz=sample_rate_hz)) + samples = np.arange(sample_rate_hz // 10, dtype=np.float32) + pcm16 = np.rint(0.5 * np.sin(2 * np.pi * 1_000 * samples / sample_rate_hz) * 32_767).astype(" None: + if data.get("type") == "response.done": + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_server_vad_session_update(_server_vad_turn_detection())) + first_append = _server_vad_audio_append(frame_count=2) + first_append["audio"] = base64.b64encode(np.full(320, 12_000, dtype=" None: + nonlocal committed_count, response_count + if data.get("type") == "input_audio_buffer.committed": + committed_count += 1 + if data.get("type") == "error" and data.get("error", {}).get("code") == "input_backpressure": + chat_service.first_response_gate.set() + if data.get("type") == "response.created": + response_count += 1 + if response_count == 2: + ws.put({"type": "session.close"}) + + ws = TimedWebSocket(on_send=on_send) + ws.put(_server_vad_session_update(_server_vad_turn_detection())) + for _ in range(3): + ws.put(_server_vad_audio_append()) + handler = OmniDuplexSessionHandler( + chat_service=chat_service, + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([0.0, 0.9, 0.8, 0.0, 0.0] * 3)), + ) + + await handler.handle_realtime_session(ws) # type: ignore[arg-type] + + assert committed_count == 2 + assert response_count == 2 + event_types = ws.sent_types() + committed_indices = [index for index, event in enumerate(event_types) if event == "input_audio_buffer.committed"] + response_created_indices = [index for index, event in enumerate(event_types) if event == "response.created"] + assert committed_indices[1] < event_types.index("response.done") < response_created_indices[1] + backpressure = [ + event + for event in ws.sent + if event.get("type") == "error" and event.get("error", {}).get("code") == "input_backpressure" + ] + assert len(backpressure) == 1 + + @pytest.mark.asyncio async def test_native_session_update_rejects_client_runtime_config(): engine = FakeEngineClient() @@ -1110,6 +1967,7 @@ async def test_minicpmo_native_session_update_requires_ref_audio_before_enabling def test_personaplex_candidate_update_does_not_leak_minicpmo_ref_audio_check(): handler = OmniDuplexSessionHandler( chat_service=SimpleNamespace(engine_client=SimpleNamespace()), + served_model_name="test-model", serving_runtime_adapter=PersonaPlexServingRuntimeAdapter(lambda *_: None), ) session = DuplexSession( @@ -1124,6 +1982,51 @@ def test_personaplex_candidate_update_does_not_leak_minicpmo_ref_audio_check(): assert error is None +@pytest.mark.parametrize( + ("native", "turn_detection", "expected_code"), + [ + (False, {"type": "server_vad", "interrupt_response": False}, None), + (False, {"type": "server_vad"}, None), + (False, {"type": "server_vad", "interrupt_response": True}, "server_vad_requires_native_duplex"), + (True, {"type": "server_vad", "interrupt_response": False}, "unsupported_turn_detection"), + (True, {"type": "server_vad"}, None), + ], + ids=[ + "turn-based-endpointing", + "turn-based-default-endpointing", + "turn-based-explicit-interruption", + "native-non-interrupting", + "native-default-interruption", + ], +) +def test_server_vad_runtime_capability_matrix( + native: bool, + turn_detection: dict[str, object], + expected_code: str | None, +): + handler = OmniDuplexSessionHandler( + chat_service=TurnBasedFakeChatService(FakeEngineClient()), + ) + capabilities = minicpmo45_native_capabilities() if native else DuplexCapabilities() + session = DuplexSession( + "sid-server-vad-capability", + DuplexSessionConfig(), + capabilities=capabilities, + ) + candidate = DuplexSessionConfig.from_event({"session": {"turn_detection": turn_detection}}) + handler._resolve_server_vad_defaults(session, candidate, {"turn_detection": turn_detection}) + + error = handler._runtime_session_candidate_update_error(session, candidate) + + assert (error or {}).get("code") == expected_code + if expected_code is None: + assert candidate.server_vad is not None + assert candidate.server_vad.interrupt_response is native + assert candidate.overlap_policy == ("barge_in_on_speech" if native else "listen_only") + elif expected_code == "server_vad_requires_native_duplex": + assert "set interrupt_response=false" in error["error"] + + @pytest.mark.asyncio async def test_minicpmo_native_session_update_rejects_instructions_after_native_context_locked(): engine = FakeEngineClient() @@ -1550,6 +2453,23 @@ def test_duplex_session_playback_commit_uses_multi_delta_audio_text_marks(): assert session.history == (committed,) +def test_duplex_session_preserves_response_history_order_when_user_item_arrives_during_generation(): + session = DuplexSession(session_id="sid-history-order", config=DuplexSessionConfig()) + first_user = {"role": "user", "content": "first turn"} + next_user = { + "role": "user", + "content": [{"type": "audio_url", "audio_url": {"url": "data:audio/wav;base64,AAAA"}}], + } + session.append_history_message(first_user) + session.begin_response() + session.append_assistant_text("first answer") + session.append_history_message(next_user) + + assistant = session.end_response(commit_text=True) + + assert session.history == (first_user, assistant, next_user) + + @pytest.mark.asyncio async def test_duplex_session_actor_preserves_wire_order_before_control(): ws = TimedWebSocket() @@ -1616,7 +2536,7 @@ def _native_realtime_session_update( } -def _server_vad_update(event_id: str, **session: object) -> dict[str, object]: +def _native_server_vad_update(event_id: str, **session: object) -> dict[str, object]: return { "type": "session.update", "event_id": event_id, @@ -2356,7 +3276,7 @@ def test_auto_response_force_barge_in_has_no_waiting_turn_variant(): ) -def test_duplex_endpoint_requires_explicit_session_mode_duplex(): +def test_duplex_endpoint_requires_explicit_realtime_configuration(): assert should_enable_duplex_endpoint(None) is False assert should_enable_duplex_endpoint([]) is False assert should_enable_duplex_endpoint([SimpleNamespace(session_mode="turn")]) is False @@ -2388,6 +3308,22 @@ stage_args: assert should_enable_duplex_endpoint([], config_path=str(config_path)) is True +def test_duplex_endpoint_supports_inherited_duplex_session(tmp_path): + base_path = tmp_path / "base.yaml" + base_path.write_text( + """ +session_mode: turn +duplex_session: {} +stages: [] +""", + encoding="utf-8", + ) + config_path = tmp_path / "deploy.yaml" + config_path.write_text("base_config: base.yaml\n", encoding="utf-8") + + assert should_enable_duplex_endpoint([], config_path=str(config_path)) is True + + def test_duplex_handler_splits_data_plane_audio_list_into_deltas(): import torch @@ -4254,7 +5190,7 @@ async def test_duplex_handler_runtime_open_failure_is_reported_to_client(): chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-open-fail")) + ws.put(_native_session_create("sid-open-fail")) ws.put({"type": "session.close"}) await handler.handle_session(ws) @@ -4289,7 +5225,7 @@ async def test_duplex_handler_preserves_typed_admission_error(): idle_timeout_s=1, ) ws = TimedWebSocket() - ws.put(_session_create("sid-admission-rejected")) + ws.put(_native_session_create("sid-admission-rejected")) await handler.handle_session(ws) @@ -4903,13 +5839,37 @@ async def test_cancel_active_native_data_plane_request_aborts_stage_request_id() assert "audio.cancelled" in ws.sent_types() +@pytest.mark.asyncio +async def test_cancel_chat_fallback_response_aborts_request_and_task(): + engine = FakeEngineClient() + handler = OmniDuplexSessionHandler(chat_service=TurnBasedFakeChatService(engine)) + session = DuplexSession("sid-chat-cancel", DuplexSessionConfig()) + response_id = session.begin_response() + session.bind_request("chatcmpl-cancel") + ws = TimedWebSocket() + task = asyncio.create_task(asyncio.sleep(60)) + try: + cancelled = await handler._cancel_active_response(session, task, ws.send_json, reason="response.cancel") + + assert cancelled is True + assert task.cancelled() + assert engine.internal_abort_batches == [["chatcmpl-cancel"]] + assert "error" not in ws.sent_types() + (event,) = [event for event in ws.sent if event["type"] == "audio.cancelled"] + assert event["response_id"] == response_id + assert event["reason"] == "response.cancel" + finally: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + + @pytest.mark.asyncio async def test_duplex_handler_explicit_close_closes_runtime_once_with_client_reason(): engine = FakeEngineClient() chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-close")) + ws.put(_native_session_create("sid-close")) ws.put({"type": "session.close"}) await handler.handle_session(ws) @@ -5600,7 +6560,7 @@ async def test_duplex_handler_idle_timeout_close_does_not_emit_runtime_control() chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=0.1) ws = TimedWebSocket() - ws.put(_session_create("sid-disconnect")) + ws.put(_native_session_create("sid-disconnect")) await handler.handle_session(ws) @@ -5633,7 +6593,7 @@ async def test_duplex_handler_runtime_close_failure_is_reported_without_closed_a chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-close-fail")) + ws.put(_native_session_create("sid-close-fail")) ws.put({"type": "session.close"}) await handler.handle_session(ws) @@ -5662,7 +6622,7 @@ async def test_duplex_handler_control_close_failure_is_reported_without_closed_a chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-control-close-fail")) + ws.put(_native_session_create("sid-control-close-fail")) ws.put({"type": "session.close"}) await handler.handle_session(ws) @@ -5701,7 +6661,7 @@ async def test_turn_signal_input_cancel_uses_epoch_fence_transition(): idle_timeout_s=1, ) ws = TimedWebSocket() - ws.put(_session_create("sid-turn-signal-cancel")) + ws.put(_native_session_create("sid-turn-signal-cancel")) ws.put({"type": "input.text.append", "text": "discard me"}) ws.put({"type": "turn.signal", "event": "input.cancel"}) ws.put({"type": "session.close"}) @@ -5731,59 +6691,6 @@ async def test_duplex_handler_local_turn_signal_does_not_round_trip_runtime(): assert "runtime_signal_failed" not in {m.get("code") for m in ws.sent} -@pytest.mark.asyncio -async def test_duplex_barge_in_aborts_active_response_when_runtime_signal_fails(): - engine = FakeEngineClient(fail_signal_events={"barge_in"}) - chat_service = FakeChatService(engine) - handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) - - def on_send(ws: TimedWebSocket, data: dict[str, Any]) -> None: - if data.get("type") == "response.created": - ws.put({"type": "input.cancel", "reason": "test_barge_in"}) - - ws = TimedWebSocket(on_send=on_send) - ws.put(_session_create("sid-barge-signal-fail")) - ws.put({"type": "input.text.append", "text": "hello"}) - ws.put({"type": "input.commit"}) - - await handler.handle_session(ws) - - assert "audio.cancelled" in ws.sent_types() - assert engine.aborted == ["chatcmpl-duplex-sid-barge-signal-fail-0-1"] - assert engine.internal_abort_batches == [["chatcmpl-duplex-sid-barge-signal-fail-0-1"]] - error = next(m for m in ws.sent if m.get("type") == "error") - assert error["code"] == "runtime_signal_failed" - - -@pytest.mark.asyncio -async def test_duplex_handler_surfaces_stage_unsupported_result_to_client(): - control_result = { - "operation": "open", - "session_id": "sid-unsupported", - "ok": True, - "unsupported_count": 1, - "error_count": 0, - "stage_results": [ - { - "stage_id": 0, - "replica_id": 0, - "result": {"supported": False, "reason": "not implemented"}, - } - ], - } - engine = FakeEngineClient(open_result=control_result) - chat_service = FakeChatService(engine) - handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) - ws = TimedWebSocket() - ws.put(_session_create("sid-unsupported")) - ws.put({"type": "session.close"}) - - await handler.handle_session(ws) - - created = next(m for m in ws.sent if m.get("type") == "session.created") - assert created["runtime_control"]["unsupported_count"] == 1 - - @pytest.mark.asyncio async def test_minicpmo_native_duplex_append_without_format_defaults_to_pcm16(): engine = FakeEngineClient() @@ -5842,6 +6749,38 @@ async def test_minicpmo_native_duplex_rejects_invalid_sample_rate_without_append assert error["code"] == "bad_event" +@pytest.mark.asyncio +@pytest.mark.parametrize("sample_rate_hz", INVALID_VALIDATED_SAMPLE_RATES) +async def test_turn_based_server_vad_rejects_invalid_direct_sample_rate_without_closing_session( + sample_rate_hz: object, +): + backend = FakeServerVADBackend([0.9]) + handler = OmniDuplexSessionHandler( + chat_service=TurnBasedFakeChatService(FakeEngineClient()), + config_timeout_s=0.1, + idle_timeout_s=1, + server_vad_backend_provider=FakeServerVADProvider(backend), + ) + ws = TimedWebSocket() + session_create = _session_create("sid-server-vad-invalid-rate") + session_create["session"]["turn_detection"] = _server_vad_turn_detection(create_response=False) + ws.put(session_create) + invalid_append = _server_vad_audio_append(frame_count=1) + invalid_append["sample_rate_hz"] = sample_rate_hz + ws.put(invalid_append) + ws.put(_server_vad_audio_append(frame_count=1)) + ws.put({"type": "session.close"}) + + await handler.handle_session(ws) + + errors = [event for event in ws.sent if event.get("type") == "error"] + assert [error["code"] for error in errors] == ["bad_audio"] + assert "internal_error" not in {error["code"] for error in errors} + assert "session.created" in ws.sent_types() + assert "session.closed" in ws.sent_types() + assert backend.calls == 1 + + @pytest.mark.asyncio async def test_native_input_commit_does_not_send_noop_runtime_signal(): engine = FakeEngineClient() @@ -6023,12 +6962,12 @@ async def test_duplex_handler_session_update_control_failure_rolls_back_config() chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-control-signal-fail")) + ws.put(_native_session_create("sid-control-signal-fail")) ws.put( { "type": "turn.signal", "event": "session.update", - "payload": {"instructions": "must roll back"}, + "payload": {"temperature": 0.2}, } ) ws.put({"type": "session.close"}) @@ -6041,7 +6980,7 @@ async def test_duplex_handler_session_update_control_failure_rolls_back_config() assert error["runtime_control"]["error_count"] == 1 signaled_config = engine.signal_session_configs[-1] assert signaled_config is not None - assert signaled_config["instructions"] == "must roll back" + assert signaled_config["temperature"] == 0.2 @pytest.mark.asyncio @@ -6059,19 +6998,24 @@ async def test_native_session_update_commits_config_only_after_runtime_ack(): return await super().signal_duplex_turn_async(session_id, **kwargs) engine = BlockingUpdateEngine() - handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([])), + ) ws = TimedWebSocket() protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] ws.put(_native_realtime_session_update("sid-update-two-phase")) - ws.put(_server_vad_update("update-two-phase")) + ws.put(_native_server_vad_update("update-two-phase")) handler_task = asyncio.create_task(handler.handle_session(ws, realtime_protocol=protocol)) - await asyncio.wait_for(engine.update_started.wait(), timeout=1) + await asyncio.wait_for(engine.update_started.wait(), timeout=10) live_session = handler._registry.get("sid-update-two-phase") assert live_session is not None assert live_session.config.overlap_policy == DuplexOverlapPolicy.LISTEN_ONLY.value assert protocol._pending_turn_detection_update is not None - assert protocol._server_vad is None + assert protocol._turn_detection is None protocol.encode_outbound_event({"type": "error", "code": "unrelated", "error": "unrelated"}) assert protocol._pending_turn_detection_update is not None @@ -6082,7 +7026,7 @@ async def test_native_session_update_commits_config_only_after_runtime_ack(): updated = next(message for message in reversed(ws.sent) if message.get("type") == "session.updated") assert updated["session"]["overlap_policy"] == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value - assert isinstance(protocol._server_vad, realtime_vad.SileroStreamingVAD) + assert protocol._turn_detection is not None @pytest.mark.parametrize( @@ -6090,9 +7034,14 @@ async def test_native_session_update_commits_config_only_after_runtime_ack(): [(True, "session_update_aborted"), (False, "instructions_update_unsupported")], ) @pytest.mark.asyncio -async def test_realtime_vad_update_rejection_does_not_stall(append_fails, expected_code): +async def test_native_server_vad_update_rejection_does_not_stall(append_fails, expected_code): append_result = {"operation": "append", "ok": False, "error_count": 1} if append_fails else None - handler = OmniDuplexSessionHandler(chat_service=FakeChatService(FakeEngineClient(append_result=append_result))) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(FakeEngineClient(append_result=append_result)), + config_timeout_s=0.1, + idle_timeout_s=1, + server_vad_backend_provider=FakeServerVADProvider(FakeServerVADBackend([])), + ) ws = TimedWebSocket() protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type] create = _native_realtime_session_update("sid-rejected-vad-update") @@ -6100,18 +7049,18 @@ async def test_realtime_vad_update_rejection_does_not_stall(append_fails, expect ws.put(create) if append_fails: ws.put({"type": "input_audio_buffer.append", "audio": _pcm_f32_b64(16_000), "format": "pcm_f32le"}) - update = _server_vad_update("rejected-vad-update") + update = _native_server_vad_update("rejected-vad-update") if not append_fails: update["session"]["instructions"] = "You are now a pirate." ws.put(update) ws.put({"type": "session.close"}) - await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=protocol), timeout=2) + + await asyncio.wait_for(handler.handle_session(ws, realtime_protocol=protocol), timeout=10) errors = [message["error"] for message in ws.sent if isinstance(message.get("error"), dict)] error = next(error for error in errors if error.get("code") == expected_code) assert error["event_id"] == "rejected-vad-update" assert protocol._pending_turn_detection_update is None - assert protocol._server_vad is None @pytest.mark.asyncio @@ -6318,7 +7267,7 @@ async def test_duplex_handler_signal_unsupported_workers_with_data_plane_ack_is_ chat_service = FakeChatService(engine) handler = OmniDuplexSessionHandler(chat_service=chat_service, config_timeout_s=0.1, idle_timeout_s=1) ws = TimedWebSocket() - ws.put(_session_create("sid-control-signal-data-plane")) + ws.put(_native_session_create("sid-control-signal-data-plane")) ws.put({"type": "turn.signal", "event": "barge_in"}) ws.put({"type": "session.close"}) @@ -6645,6 +7594,91 @@ def test_minicpmo_native_duplex_explicit_barge_in_request_interrupts(): } +@pytest.mark.asyncio +async def test_native_server_vad_complete_audio_item_reaches_runtime(): + engine = FakeEngineClient() + backend = FakeServerVADBackend([0.9] * 10) + ws = TimedWebSocket() + create = _native_realtime_session_update("sid-native-complete-audio") + create["session"]["turn_detection"] = {"type": "server_vad"} + create["session"]["input_audio_format"] = "pcm16" + ws.put(create) + ws.put( + { + "type": "conversation.item.create", + "item": { + "id": "item-native-audio", + "type": "message", + "role": "user", + "content": [{"type": "input_audio", "audio": _pcm16_b64(2400)}], + }, + } + ) + ws.put({"type": "response.create"}) + ws.put({"type": "session.close"}) + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + server_vad_backend_provider=FakeServerVADProvider(backend), + ) + + await handler.handle_realtime_session(ws) # type: ignore[arg-type] + + assert any(final for _, _, _, final in engine.appended) + assert "error" not in ws.sent_types() + + +@pytest.mark.asyncio +async def test_native_server_vad_uses_shared_pipeline_and_emits_boundaries(): + backend = FakeServerVADBackend([0.9] * 10 + [0.0] * 50) + engine = FakeEngineClient() + + def commit_after_speech_stops(ws: TimedWebSocket, event: dict[str, Any]) -> None: + if event.get("type") == "input_audio_buffer.speech_stopped": + ws.put({"type": "input_audio_buffer.commit", "final": True}) + ws.put({"type": "response.create"}) + ws.put({"type": "session.close"}) + + handler = OmniDuplexSessionHandler( + chat_service=FakeChatService(engine), + config_timeout_s=0.1, + idle_timeout_s=1, + server_vad_backend_provider=FakeServerVADProvider(backend), + ) + ws = TimedWebSocket(on_send=commit_after_speech_stops) + create = _native_realtime_session_update("sid-native-shared-server-vad") + create["session"]["turn_detection"] = {"type": "server_vad"} + ws.put(create) + ws.put( + { + "type": "input_audio_buffer.append", + "audio": _pcm_f32_b64(60 * backend.frame_samples, value=0.0), + "format": "pcm_f32le", + "sample_rate_hz": 16_000, + } + ) + await asyncio.wait_for( + handler.handle_session(ws, realtime_protocol=NativeRealtimeSessionProtocol({})), + timeout=10, + ) + + boundary_events = [ + event + for event in ws.sent + if event.get("type") in {"input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"} + ] + assert [event["type"] for event in boundary_events] == [ + "input_audio_buffer.speech_started", + "input_audio_buffer.speech_stopped", + ] + assert boundary_events[0]["item_id"] == boundary_events[1]["item_id"] + created_session = next(event["session"] for event in ws.sent if event["type"] == "session.created") + assert created_session["audio"]["input"]["turn_detection"]["interrupt_response"] is True + assert created_session["overlap_policy"] == "barge_in_on_speech" + assert backend.calls == 60 + assert any(final for _, _, _, final in engine.appended) + assert not any(event.get("error", {}).get("code") == "server_vad_manual_commit_unsupported" for event in ws.sent) + + @pytest.mark.asyncio @pytest.mark.parametrize( "barge_in_event", @@ -7096,7 +8130,7 @@ async def test_minicpmo_native_auto_response_accepts_realtime_commit_after_strea { "type": "input_audio_buffer.commit", "final": True, - "realtime_item_id": "item-consumed-stream", + "item_id": "item-consumed-stream", } ) ws.put({"type": "session.close"}) @@ -7105,7 +8139,7 @@ async def test_minicpmo_native_auto_response_accepts_realtime_commit_after_strea assert not any(message.get("code") == "input_audio_buffer_empty" for message in ws.sent) committed = next(message for message in ws.sent if message.get("type") == "input.committed") - assert committed["realtime_item_id"] == "item-consumed-stream" + assert committed["item_id"] == "item-consumed-stream" assert committed["native_audio"] is True diff --git a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py index 7664b2402..199bea0ed 100644 --- a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py +++ b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py @@ -3,10 +3,11 @@ """ E2E online tests for Qwen3-Omni /v1/realtime WebSocket (streaming PCM in, audio out). -Three scenarios: +Four scenarios: - Ready CI: async_chunk on, smoke only (no send delay, no accuracy check). - Merge CI: async_chunk on + send delay, full accuracy check. - Merge CI: async_chunk off, no send delay, full accuracy check. +- Server VAD: two turns without client commits. """ from __future__ import annotations @@ -28,11 +29,18 @@ from tests.helpers.media import ( generate_synthetic_audio, ) from tests.helpers.runtime import OmniServerParams -from tests.helpers.stage_config import get_deploy_config_path +from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config +from vllm_omni.config.stage_config import load_deploy_config +from vllm_omni.entrypoints.duplex.server_vad import ( + SILERO_VAD_FILENAME, + SILERO_VAD_REPO_ID, + SILERO_VAD_REVISION, +) os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" -MODEL = "Qwen/Qwen3-Omni-30B-A3B-Instruct" +MODEL = os.environ.get("VLLM_OMNI_TEST_QWEN3_OMNI_MODEL", "Qwen/Qwen3-Omni-30B-A3B-Instruct") +SERVER_VAD_MODEL_PATH = os.environ.get("VLLM_OMNI_TEST_SILERO_VAD_MODEL_PATH") # Synthetic input for realtime E2E (``generate_synthetic_audio``); distinct cache file per phrase. REALTIME_SYNTH_PHRASE_TEXT = ( @@ -48,6 +56,15 @@ SEND_DELAY_MS = 200 # CI overlay bakes in async_chunk: False and covers CUDA/ROCm/XPU via ``platforms:``. default_stage_config = get_deploy_config_path("ci/qwen3_omni_moe.yaml") +server_vad_stage_config = modify_stage_config( + default_stage_config, + { + "async_chunk": True, + "duplex_session": ( + {"server_vad_model_path": SERVER_VAD_MODEL_PATH} if SERVER_VAD_MODEL_PATH is not None else {} + ), + }, +) realtime_sync_server_params = [ pytest.param( @@ -72,6 +89,17 @@ realtime_async_chunk_server_params = [ ), ] +realtime_server_vad_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=server_vad_stage_config, + use_stage_cli=True, + ), + id="server_vad", + ), +] + def _pcm16_mono_16k_from_wav_bytes(wav_bytes: bytes) -> bytes: with wave.open(io.BytesIO(wav_bytes), "rb") as wf: @@ -183,6 +211,86 @@ async def _run_realtime_audio_roundtrip( } +async def _append_pcm16_chunks(ws, pcm16: bytes, chunk_bytes: int) -> None: + for offset in range(0, len(pcm16), chunk_bytes): + await ws.send( + json.dumps( + { + "type": "input_audio_buffer.append", + "audio": base64.b64encode(pcm16[offset : offset + chunk_bytes]).decode("utf-8"), + } + ) + ) + + +async def _receive_server_vad_turn(ws) -> list[dict]: + events: list[dict] = [] + while True: + message = await asyncio.wait_for(ws.recv(), timeout=600) + if isinstance(message, bytes): + continue + event = json.loads(message) + events.append(event) + if event.get("type") == "error": + raise AssertionError(f"WebSocket error: {event}") + if event.get("type") == "response.done": + return events + + +async def _run_server_vad_audio_roundtrips( + host: str, + port: int, + model: str, + pcm16: bytes, + *, + chunk_ms: int = 100, + turns: int = 2, +) -> list[list[dict]]: + chunk_bytes = max(16_000 * 2 // 1000 * chunk_ms, 2) + turn_events: list[list[dict]] = [] + + async with websockets.connect(f"ws://{host}:{port}/v1/realtime", max_size=64 * 1024 * 1024) as ws: + await ws.send( + json.dumps( + { + "type": "session.update", + "session": { + "model": model, + "audio": { + "input": { + "format": {"type": "audio/pcm", "rate": 16_000}, + "turn_detection": {"type": "server_vad"}, + } + }, + }, + } + ) + ) + + silence = bytes(16_000 * 2 * 3 // 2) + for _ in range(turns): + await _append_pcm16_chunks(ws, pcm16, chunk_bytes) + await _append_pcm16_chunks(ws, silence, chunk_bytes) + turn_events.append(await _receive_server_vad_turn(ws)) + + return turn_events + + +@pytest.fixture(scope="class") +def cached_silero_vad_artifact() -> str: + """Prepare the pinned artifact before the serving subprocess starts.""" + if SERVER_VAD_MODEL_PATH is not None: + return SERVER_VAD_MODEL_PATH + + from huggingface_hub import hf_hub_download + + return hf_hub_download( + repo_id=SILERO_VAD_REPO_ID, + filename=SILERO_VAD_FILENAME, + revision=SILERO_VAD_REVISION, + ) + + def _synthetic_pcm16_input( *, phrase_text: str = REALTIME_SYNTH_PHRASE_TEXT, @@ -296,6 +404,66 @@ class TestQwen3OmniRealtimeWebSocket: f"Expected an issue-like audio response longer than 5 seconds, got {output_duration_s:.2f}s" ) + @pytest.mark.advanced_model + @pytest.mark.omni + @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) + @pytest.mark.parametrize("omni_server", realtime_server_vad_server_params, indirect=True) + def test_server_vad_multi_turn_without_client_commit( + self, + cached_silero_vad_artifact: str, + omni_server, + ) -> None: + """Two Qwen turns are endpointed without client commits.""" + assert cached_silero_vad_artifact + deploy = load_deploy_config(server_vad_stage_config) + assert deploy.session_mode == "turn" + assert deploy.async_chunk is True + assert deploy.duplex_session.server_vad_model_path == SERVER_VAD_MODEL_PATH + pcm16 = _synthetic_pcm16_input() + + turns = asyncio.run( + _run_server_vad_audio_roundtrips( + omni_server.host, + omni_server.port, + omni_server.model, + pcm16, + chunk_ms=100, + turns=2, + ) + ) + + assert len(turns) == 2 + required_sequence = [ + "input_audio_buffer.speech_started", + "input_audio_buffer.speech_stopped", + "input_audio_buffer.committed", + "response.created", + "response.audio.delta", + "response.audio.done", + "response.done", + ] + input_item_ids: list[str] = [] + response_ids: list[str] = [] + for events in turns: + event_types = [event["type"] for event in events] + positions = [event_types.index(event_type) for event_type in required_sequence] + assert positions == sorted(positions) + + started = next(event for event in events if event["type"] == "input_audio_buffer.speech_started") + stopped = next(event for event in events if event["type"] == "input_audio_buffer.speech_stopped") + committed = next(event for event in events if event["type"] == "input_audio_buffer.committed") + created = next(event for event in events if event["type"] == "response.created")["response"] + done = next(event for event in events if event["type"] == "response.done")["response"] + + assert started["item_id"] == stopped["item_id"] == committed["item_id"] + assert created["id"] == done["id"] + assert done["status"] == "completed" + input_item_ids.append(committed["item_id"]) + response_ids.append(created["id"]) + + assert len(set(input_item_ids)) == 2 + assert len(set(response_ids)) == 2 + @pytest.mark.advanced_model @pytest.mark.omni @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) diff --git a/tests/entrypoints/openai_api/test_server_vad.py b/tests/entrypoints/openai_api/test_server_vad.py new file mode 100644 index 000000000..67476be73 --- /dev/null +++ b/tests/entrypoints/openai_api/test_server_vad.py @@ -0,0 +1,429 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import asyncio +import base64 +import io +import sys +import threading +import wave +import weakref +from concurrent.futures import ThreadPoolExecutor +from types import SimpleNamespace + +import numpy as np +import pytest + +from vllm_omni.entrypoints.duplex.protocol import ( + DuplexSession, + DuplexSessionConfig, +) +from vllm_omni.entrypoints.duplex.server_vad import ( + ServerVADConfig, + ServerVADFrame, + ServerVADPipeline, + SileroVADBackend, + SileroVADBackendProvider, + SpeechEndpointDecision, + ThresholdEndpointPolicy, + parse_session_turn_detection, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class SequenceDetector: + frame_samples = 160 + + def __init__(self, probabilities: list[float]) -> None: + self.probabilities = probabilities + + def new_state(self) -> int: + return 0 + + def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: + index = int(state) + probability = self.probabilities[index] if index < len(self.probabilities) else 0.0 + return probability, index + 1 + + +def test_server_vad_config_defaults_and_validation(): + config = ServerVADConfig.from_value({"type": "server_vad"}) + + assert config.type == "server_vad" + assert config.threshold == 0.5 + assert config.prefix_padding_ms == 300 + assert config.silence_duration_ms == 500 + assert config.create_response is True + assert config.interrupt_response is None + assert "interrupt_response" not in config.as_dict() + assert config.min_speech_duration_ms is None + assert config.as_dict()["type"] == config.type + + interrupting = ServerVADConfig.from_value( + { + "type": "server_vad", + "interrupt_response": True, + "min_speech_duration_ms": 96, + } + ) + assert interrupting.interrupt_response is True + assert interrupting.min_speech_duration_ms == 96 + for value in (None, 0, "true"): + with pytest.raises(ValueError, match="interrupt_response must be a boolean"): + ServerVADConfig.from_value({"type": "server_vad", "interrupt_response": value}) + for enabled in (False, True): + _, aliased = parse_session_turn_detection( + { + "turn_detection": {"type": "server_vad"}, + "audio": {"input": {"turn_detection": {"type": "server_vad", "interrupt_response": enabled}}}, + } + ) + assert aliased is not None and aliased.interrupt_response is enabled + with pytest.raises(ValueError, match="Unknown"): + ServerVADConfig.from_value({"type": "server_vad", "semantic_eagerness": "high"}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("prefix_padding_ms", [0, 10, 30]) +async def test_server_vad_pipeline_handles_arbitrary_chunk_boundaries(prefix_padding_ms: int): + detector = SequenceDetector([0.0, 0.9, 0.8, 0.0, 0.0]) + pipeline = ServerVADPipeline( + detector, + ServerVADConfig(prefix_padding_ms=prefix_padding_ms, silence_duration_ms=20), + ) + samples = np.zeros(detector.frame_samples * 5, dtype=np.float32) + + batches = [ + await pipeline.push(samples[:73]), + await pipeline.push(samples[73:431]), + await pipeline.push(samples[431:]), + ] + frames = [frame for batch in batches for frame in batch.frames] + + assert len(frames) == 5 + assert [index for index, frame in enumerate(frames) if frame.decision.speech_started] == [1] + assert [index for index, frame in enumerate(frames) if frame.decision.speech_stopped] == [4] + assert frames[1].decision.audio_start_ms == max(0, 10 - prefix_padding_ms) + assert frames[4].decision.audio_end_ms == 50 + + pipeline.reset() + reset_batch = await pipeline.push(samples) + assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_started] == [1] + assert [index for index, frame in enumerate(reset_batch.frames) if frame.decision.speech_stopped] == [4] + # Keep session-relative timestamps, but never reference prefix audio + # discarded by reset at 50 ms. + assert reset_batch.frames[1].decision.audio_start_ms == max(50, 60 - prefix_padding_ms) + assert reset_batch.frames[4].decision.audio_end_ms == 100 + + +@pytest.mark.asyncio +async def test_server_vad_retained_audio_does_not_keep_input_chunk(): + pipeline = ServerVADPipeline(SequenceDetector([]), ServerVADConfig()) + session = DuplexSession("server-vad-memory", DuplexSessionConfig()) + samples = np.zeros(160 * 100 + 1, dtype=np.float32) + source_ref = weakref.ref(samples) + batch = await pipeline.push(samples) + session.append_server_vad_frame( + batch.frames[-1].samples, + speech_started=False, + speech_stopped=False, + prefix_samples=80, + ) + session.append_server_vad_frame( + batch.frames[-1].samples, + speech_started=True, + speech_stopped=False, + prefix_samples=80, + ) + del batch, samples + # Allow the completed to_thread callback to release its result, too. + await asyncio.sleep(0) + + assert source_ref() is None + assert pipeline.scratch_bytes == 4 + assert session.server_vad_utterance_bytes == (80 + 160) * 4 + + +@pytest.mark.asyncio +async def test_server_vad_pipeline_pcm16_resampling_is_split_invariant(): + sample_index = np.arange(2_400, dtype=np.int32) + source = ((sample_index * 7919) % 65_536 - 32_768).astype(" tuple[np.ndarray, list[SpeechEndpointDecision]]: + pipeline = ServerVADPipeline( + SequenceDetector([0.0] * 10), + ServerVADConfig(), + ) + frames: list[ServerVADFrame] = [] + offset = 0 + for chunk_size in chunk_sizes: + chunk = continuous_source[offset : offset + chunk_size] + batch = await pipeline.push_pcm16(chunk.tobytes(), source_sample_rate_hz=24_000) + frames.extend(batch.frames) + offset += chunk_size + assert offset == continuous_source.size + return np.concatenate([frame.samples for frame in frames]), [frame.decision for frame in frames] + + whole_samples, whole_decisions = await process([continuous_source.size]) + split_samples, split_decisions = await process([7, 13, 511, 512, 513, continuous_source.size - 1_556]) + + np.testing.assert_array_equal(split_samples, whole_samples) + assert whole_samples.size == 1_600 + assert split_decisions == whole_decisions + + +@pytest.mark.asyncio +async def test_server_vad_pipeline_reset_clears_resampler_and_source_rate_lock(): + pipeline = ServerVADPipeline( + SequenceDetector([0.0]), + ServerVADConfig(), + ) + + batch = await pipeline.push_pcm16( + np.asarray([30_000, -30_000], dtype=" None: + self.providers = providers + self.sess_options = sess_options + self.calls: list[dict[str, np.ndarray]] = [] + self.run_barrier: threading.Barrier | None = None + sessions.append(self) + + def get_inputs(self) -> list[SimpleNamespace]: + return [SimpleNamespace(name=name) for name in ("input", "state", "sr")] + + def run(self, _outputs: object, inputs: dict[str, np.ndarray]) -> list[np.ndarray]: + self.calls.append({name: np.array(value, copy=True) for name, value in inputs.items()}) + if self.run_barrier is not None: + self.run_barrier.wait(timeout=5) + return [ + np.asarray([[0.75]], dtype=np.float32), + np.full((2, 1, 128), len(self.calls), dtype=np.float32), + ] + + sessions: list[FakeInferenceSession] = [] + monkeypatch.setitem( + sys.modules, + "onnxruntime", + SimpleNamespace( + SessionOptions=FakeSessionOptions, + InferenceSession=FakeInferenceSession, + ), + ) + + model_path = tmp_path / "silero_vad.onnx" + model_path.write_bytes(b"fake-model") + backend = SileroVADBackend(model_path) + session = sessions[0] + + assert session.providers == ["CPUExecutionProvider"] + assert session.sess_options.inter_op_num_threads == 1 + assert session.sess_options.intra_op_num_threads == 1 + assert session.calls[0]["input"].shape == (1, 576) + assert session.calls[0]["state"].shape == (2, 1, 128) + np.testing.assert_array_equal(session.calls[0]["state"], np.zeros((2, 1, 128), dtype=np.float32)) + assert session.calls[0]["sr"].shape == () + assert session.calls[0]["sr"].item() == 16_000 + + state = backend.new_state() + first_frame = np.arange(backend.frame_samples, dtype=np.float32) + probability, state = backend.infer(first_frame, state) + first_call = session.calls[1] + + assert probability == pytest.approx(0.75) + np.testing.assert_array_equal( + first_call["input"][:, : backend.context_samples], + np.zeros((1, backend.context_samples), dtype=np.float32), + ) + np.testing.assert_array_equal( + first_call["input"][:, backend.context_samples :], + first_frame[None, :], + ) + np.testing.assert_array_equal(first_call["state"], np.zeros((2, 1, 128), dtype=np.float32)) + + second_frame = -first_frame + backend.infer(second_frame, state) + second_call = session.calls[2] + np.testing.assert_array_equal( + second_call["input"][:, : backend.context_samples], + first_frame[None, -backend.context_samples :], + ) + np.testing.assert_array_equal( + second_call["input"][:, backend.context_samples :], + second_frame[None, :], + ) + np.testing.assert_array_equal( + second_call["state"], + np.full((2, 1, 128), 2, dtype=np.float32), + ) + + # Independent pipeline states may enter the shared ORT session concurrently. + session.run_barrier = threading.Barrier(2) + with ThreadPoolExecutor(max_workers=2) as executor: + futures = [ + executor.submit(backend.infer, first_frame, backend.new_state()), + executor.submit(backend.infer, second_frame, backend.new_state()), + ] + assert [future.result()[0] for future in futures] == pytest.approx([0.75, 0.75]) + + +def test_duplex_session_owns_server_vad_prefix_and_utterance_audio(): + session = DuplexSession("server-vad-session", DuplexSessionConfig()) + frame = np.zeros(160, dtype=np.float32) + + session.reserve_input_bytes(frame.nbytes * 3, limit=frame.nbytes * 4) + released = session.append_server_vad_frame( + frame, + speech_started=False, + speech_stopped=False, + prefix_samples=160, + ) + assert released == 0 + released = session.append_server_vad_frame( + frame, + speech_started=False, + speech_stopped=False, + prefix_samples=160, + ) + assert released == frame.nbytes + session.release_input_bytes(released) + session.append_server_vad_frame( + frame, + speech_started=True, + speech_stopped=False, + prefix_samples=160, + ) + + assert session.server_vad_utterance_bytes == frame.nbytes * 2 + assert session.stage_server_vad_audio_for_commit() is True + committed = session.commit_user_input() + assert committed is not None + assert committed.message["role"] == "user" + audio_url = committed.message["content"][0]["audio_url"]["url"] + assert audio_url.startswith("data:audio/wav;base64,") + wav_bytes = base64.b64decode(audio_url.partition(",")[2]) + with wave.open(io.BytesIO(wav_bytes), "rb") as wav_file: + assert wav_file.getnchannels() == 1 + assert wav_file.getsampwidth() == 2 + assert wav_file.getframerate() == 16_000 + assert wav_file.getnframes() == frame.size * 2 + + +def test_silero_provider_rejects_missing_or_invalid_local_artifact(tmp_path): + missing = SileroVADBackendProvider(model_path=str(tmp_path / "missing.onnx")) + with pytest.raises(RuntimeError, match="does not exist"): + missing.get() + + invalid_path = tmp_path / "silero_vad.onnx" + invalid_path.write_bytes(b"not-the-pinned-model") + invalid = SileroVADBackendProvider(model_path=str(invalid_path)) + with pytest.raises(RuntimeError, match="checksum mismatch"): + invalid.get() diff --git a/tests/entrypoints/test_duplex_fence_propagation.py b/tests/entrypoints/test_duplex_fence_propagation.py index 2f1292ec7..550723ec8 100644 --- a/tests/entrypoints/test_duplex_fence_propagation.py +++ b/tests/entrypoints/test_duplex_fence_propagation.py @@ -74,7 +74,7 @@ async def test_openai_handler_passes_current_session_fence_for_runtime_controls( handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) session = DuplexSession( session_id="sid", - config=DuplexSessionConfig(), + config=DuplexSessionConfig(extra_body={"native_duplex": True}), epoch=1, turn_id=2, ) @@ -92,7 +92,7 @@ async def test_openai_handler_passes_current_session_fence_for_runtime_controls( async def test_runtime_signal_uses_fence_captured_before_session_turn_advances(): engine = FenceRecordingEngine() handler = OmniDuplexSessionHandler(chat_service=FakeChatService(engine)) - session = DuplexSession(session_id="sid", config=DuplexSessionConfig()) + session = DuplexSession(session_id="sid", config=DuplexSessionConfig(extra_body={"native_duplex": True})) captured_fence = DuplexFence("sid", epoch=0, turn_id=0) session.turn_id = 1 @@ -111,7 +111,7 @@ async def test_openai_handler_forwards_cancel_fence_through_async_omni_facade(): app = object.__new__(AsyncOmni) app.engine = engine handler = OmniDuplexSessionHandler(chat_service=FakeChatService(app)) - session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig()) + session = DuplexSession(session_id="sid-cancel", config=DuplexSessionConfig(extra_body={"native_duplex": True})) cancelled_fence = DuplexFence("sid-cancel", epoch=2, turn_id=3) next_fence = DuplexFence("sid-cancel", epoch=3, turn_id=3) diff --git a/tests/entrypoints/test_utils.py b/tests/entrypoints/test_utils.py index fa6bf8dbe..680bb110c 100644 --- a/tests/entrypoints/test_utils.py +++ b/tests/entrypoints/test_utils.py @@ -28,6 +28,7 @@ from vllm_omni.entrypoints.utils import ( coerce_param_message_types, filter_dataclass_kwargs, ) +from vllm_omni.model_executor.models.qwen3_omni.pipeline import QWEN3_OMNI_PIPELINE pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -295,6 +296,31 @@ class TestFilterDataclassKwargs: class TestResolveOmniConfig: + def test_bare_deploy_name_returns_packaged_resolved_path(self, tmp_path, mocker: MockerFixture): + deploy_name = "qwen3_omni_moe.yaml" + deploy_path = tmp_path / deploy_name + deploy_path.write_text( + "duplex_session:\n server_vad_model_path: /models/silero_vad.onnx\nstages: []\n", + encoding="utf-8", + ) + mocker.patch("vllm_omni.config.config_factory._DEPLOY_DIR", tmp_path) + mocker.patch("vllm_omni.config.omni_config._DEPLOY_DIR", tmp_path) + mocker.patch( + "vllm_omni.config.config_factory.StageConfigFactory.get_pipeline_config", + return_value=QWEN3_OMNI_PIPELINE, + ) + + resolved = resolve_omni_config( + "dummy-model", + trust_remote_code=False, + deploy_config_path=deploy_name, + cli_overrides={}, + stage_overrides=None, + strategy_config_path=None, + ) + + assert resolved.config_path == str(deploy_path) + def test_stage_lookup_error_lists_resolved_ids(self): resolved = OmniConfigResolution( config_path=None, diff --git a/vllm_omni/config/stage_config.py b/vllm_omni/config/stage_config.py index 06840e55a..2ac1f99dd 100644 --- a/vllm_omni/config/stage_config.py +++ b/vllm_omni/config/stage_config.py @@ -509,6 +509,7 @@ class DuplexSessionRuntimeConfig: max_pending_turns_per_session: int = 4 max_sessions: int = 1 completed_append_cache_size: int = 256 + server_vad_model_path: str | None = None # Startup warmup: run this many silent 80 ms-style frames through a # throwaway realtime session before real clients are admitted, so # one-time costs (kernel JIT, first prefill/decode paths, codec caches) @@ -528,6 +529,10 @@ class DuplexSessionRuntimeConfig: } if self.idle_ttl_s is not None and self.idle_ttl_s <= 0: raise ValueError("duplex_session.idle_ttl_s must be positive or null") + if self.server_vad_model_path is not None and ( + not isinstance(self.server_vad_model_path, str) or not self.server_vad_model_path.strip() + ): + raise ValueError("duplex_session.server_vad_model_path must be a non-empty string or null") for name, value in positive.items(): if value <= 0: raise ValueError(f"duplex_session.{name} must be positive") diff --git a/vllm_omni/engine/async_omni_engine.py b/vllm_omni/engine/async_omni_engine.py index 4c34f5898..6850e6b29 100644 --- a/vllm_omni/engine/async_omni_engine.py +++ b/vllm_omni/engine/async_omni_engine.py @@ -224,7 +224,7 @@ class AsyncOmniEngine: trust_remote_code=bool(trust_remote_code), deploy_config_path=deploy_config_path, ) - self._set_pipeline_runtime_config(pipeline_config, deploy_config_path) + self._set_pipeline_runtime_config(pipeline_config, self.config_path) else: self._set_pipeline_runtime_config( self._config_resolution.pipeline_config, diff --git a/vllm_omni/entrypoints/duplex/audio.py b/vllm_omni/entrypoints/duplex/audio.py index 644a7a297..747293a1a 100644 --- a/vllm_omni/entrypoints/duplex/audio.py +++ b/vllm_omni/entrypoints/duplex/audio.py @@ -21,8 +21,12 @@ MIN_INPUT_SAMPLE_RATE_HZ = 8_000 MAX_INPUT_SAMPLE_RATE_HZ = 192_000 -def validate_input_sample_rate_hz(sample_rate_hz: int | float) -> int: - if isinstance(sample_rate_hz, bool) or (isinstance(sample_rate_hz, float) and not math.isfinite(sample_rate_hz)): +def validate_input_sample_rate_hz(sample_rate_hz: object) -> int: + if ( + isinstance(sample_rate_hz, bool) + or not isinstance(sample_rate_hz, int | float) + or (isinstance(sample_rate_hz, float) and not math.isfinite(sample_rate_hz)) + ): raise ValueError("sample_rate_hz must be a finite integer") rate = int(sample_rate_hz) if rate != sample_rate_hz: @@ -35,14 +39,27 @@ def validate_input_sample_rate_hz(sample_rate_hz: int | float) -> int: def resample_pcm16_mono(raw: bytes, *, source_rate_hz: int, target_rate_hz: int) -> bytes: if source_rate_hz <= 0 or target_rate_hz <= 0 or source_rate_hz == target_rate_hz: return raw - samples = np.frombuffer(raw, dtype=" StreamingAudioResampler._max_polyphase_factor: + # The FIR size scales with the reduced ratio. Keep unusual client + # rates bounded while common audio rates use the high-quality path. + if samples.size <= 1: + return raw + target_size = max(1, int(round(samples.size * target_rate_hz / source_rate_hz))) + source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True) + target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True) + resampled = np.interp(target_x, source_x, samples) + return np.clip(resampled, -32_768, 32_767).astype(" bytes: @@ -116,6 +133,24 @@ def wav_payload_to_pcm16(raw: bytes) -> tuple[bytes | None, int | None]: return None, None +def encode_float32_mono_wav_base64( + samples: np.ndarray, + *, + sample_rate_hz: int, +) -> str: + """Package normalized mono float32 samples as a PCM16 WAV data payload.""" + rate = validate_input_sample_rate_hz(sample_rate_hz) + normalized = np.ascontiguousarray(samples, dtype=np.float32).reshape(-1) + pcm16 = np.clip(np.rint(normalized * 32768.0), -32768, 32767).astype(" bool: - """Enable duplex routes only for deployments that explicitly opt in.""" + """Enable the realtime session handler for explicitly configured deployments.""" if stage_configs: for stage in stage_configs: session_mode = ( @@ -21,14 +21,13 @@ def should_enable_duplex_endpoint( return True if config_path: try: - from omegaconf import OmegaConf + from vllm_omni.config.stage_config import resolve_deploy_yaml - raw_config = OmegaConf.load(config_path) - session_mode = raw_config.get("session_mode") if hasattr(raw_config, "get") else None - if session_mode == "duplex": + raw_config = resolve_deploy_yaml(config_path) + if raw_config.get("session_mode") == "duplex" or isinstance(raw_config.get("duplex_session"), dict): return True except Exception as exc: - logger.warning("Failed to inspect duplex session_mode from %s: %s", config_path, exc) + logger.warning("Failed to inspect realtime session configuration from %s: %s", config_path, exc) return False diff --git a/vllm_omni/entrypoints/duplex/chat_fallback.py b/vllm_omni/entrypoints/duplex/chat_fallback.py index 31df63833..6e4d0a0e0 100644 --- a/vllm_omni/entrypoints/duplex/chat_fallback.py +++ b/vllm_omni/entrypoints/duplex/chat_fallback.py @@ -38,7 +38,14 @@ class ChatFallbackProjectorMixin: request = self._build_chat_request(session, request_id) result = await self._chat_service.create_chat_completion(request, raw_request=None) if isinstance(result, ErrorResponse): - await send_json({"type": "error", "error": result.message, "code": result.type or "chat_error"}) + error = result.error + await send_json( + { + "type": "error", + "error": error.message if error else "Chat request failed", + "code": error.type if error else "chat_error", + } + ) session.end_response(commit_text=False) return if hasattr(result, "__aiter__"): diff --git a/vllm_omni/entrypoints/duplex/protocol.py b/vllm_omni/entrypoints/duplex/protocol.py index 9fd3b0ef5..ff91e0db5 100644 --- a/vllm_omni/entrypoints/duplex/protocol.py +++ b/vllm_omni/entrypoints/duplex/protocol.py @@ -10,6 +10,16 @@ from enum import Enum from types import MappingProxyType from uuid import uuid4 +import numpy as np + +from vllm_omni.entrypoints.duplex.audio import ( + encode_float32_mono_wav_base64, +) +from vllm_omni.entrypoints.duplex.server_vad import ( + ServerVADConfig, + parse_session_turn_detection, +) + class DuplexOverlapPolicy(str, Enum): LISTEN_ONLY = "listen_only" @@ -231,10 +241,12 @@ class DuplexSessionConfig: overlap_barge_in_ms: int = 1200 overlap_silence_rms: float = 0.003 playback_commit_policy: str = DuplexPlaybackCommitPolicy.COMMIT_ALL_ON_DONE.value + turn_detection_configured: bool = False + server_vad: ServerVADConfig | None = None extra_body: dict[str, object] = field(default_factory=dict) def as_dict(self) -> dict[str, object]: - return { + payload: dict[str, object] = { "model": self.model, "modalities": list(self.modalities), "instructions": self.instructions, @@ -253,6 +265,9 @@ class DuplexSessionConfig: "playback_commit_policy": self.playback_commit_policy, "extra_body": dict(self.extra_body), } + if self.turn_detection_configured: + payload["turn_detection"] = self.server_vad.as_dict() if self.server_vad is not None else None + return payload @classmethod def from_event(cls, event: dict[str, object]) -> DuplexSessionConfig: @@ -295,9 +310,13 @@ class DuplexSessionConfig: config.playback_commit_policy = cls._normalize_playback_commit_policy(source["playback_commit_policy"]) if isinstance(source.get("modalities"), list) and all(isinstance(x, str) for x in source["modalities"]): config.modalities = list(source["modalities"]) + turn_detection_configured, server_vad = parse_session_turn_detection(source) if isinstance(source.get("extra_body"), dict): config.extra_body = normalize_native_duplex_key(dict(source["extra_body"])) extra = config.extra_body + raw_realtime_session = extra.get("realtime_session_payload") + if not turn_detection_configured and isinstance(raw_realtime_session, dict): + turn_detection_configured, server_vad = parse_session_turn_detection(raw_realtime_session) if isinstance(extra.get("overlap_policy"), str): config.overlap_policy = cls._normalize_overlap_policy(extra["overlap_policy"]) if isinstance(extra.get("overlap_short_ack_ms"), int | float): @@ -308,6 +327,9 @@ class DuplexSessionConfig: config.overlap_silence_rms = max(0.0, float(extra["overlap_silence_rms"])) if isinstance(extra.get("playback_commit_policy"), str): config.playback_commit_policy = cls._normalize_playback_commit_policy(extra["playback_commit_policy"]) + if turn_detection_configured: + config.turn_detection_configured = True + config.server_vad = server_vad return config @staticmethod @@ -354,6 +376,13 @@ class DuplexCommittedInput: input_commit_seq: int +@dataclass +class PendingServerVADTurn: + committed: DuplexCommittedInput + create_response: bool + item_id: str + + @dataclass class DuplexAssistantAudioTextMark: text_chars: int @@ -368,6 +397,13 @@ class InputBufferState: overlap_speech_ms: int = 0 reserved_input_bytes: int = 0 pending_turns: int = 0 + turn_detection_config_locked: bool = False + vad_prefix_audio: list[np.ndarray] = field(default_factory=list) + vad_prefix_samples: int = 0 + vad_utterance_audio: list[np.ndarray] = field(default_factory=list) + vad_speech_active: bool = False + active_server_vad_item_id: str | None = None + pending_server_vad_turn: PendingServerVADTurn | None = None @dataclass @@ -627,6 +663,9 @@ class DuplexSession: return active_turn_id is None or int(turn_id) == active_turn_id def append_history_message(self, message: dict[str, object]) -> None: + response_id = self.active_response_id + if response_id is not None: + self.reserve_history_item(f"item_{response_id}") self._conversation.messages.append(message) def stage_pending_history_item(self, item_id: str, message: dict[str, object]) -> None: @@ -653,6 +692,13 @@ class DuplexSession: self._input.reserved_input_bytes += size return True + @property + def turn_detection_config_locked(self) -> bool: + return self._input.turn_detection_config_locked + + def lock_turn_detection_config(self) -> None: + self._input.turn_detection_config_locked = True + def release_input_bytes(self, size: int) -> None: self._input.reserved_input_bytes = max(0, self._input.reserved_input_bytes - max(0, int(size))) @@ -677,6 +723,92 @@ class DuplexSession: self._input.pending_audio.append(DuplexAudioChunk(data=data, format=fmt, sample_rate_hz=sample_rate_hz)) self.turn_state = DuplexTurnState.USER_SPEAKING + def append_server_vad_frame( + self, + samples: np.ndarray, + *, + speech_started: bool, + speech_stopped: bool, + prefix_samples: int, + ) -> int: + """Store one detector frame while keeping commit-eligible audio session-owned. + + Returns the number of raw float32 bytes released when the rolling + pre-speech prefix is trimmed. + """ + # Session-owned audio must not retain the batch backing a detector frame. + frame = np.array(samples, dtype=np.float32, copy=True).reshape(-1) + released_samples = 0 + if not self._input.vad_speech_active: + if speech_started: + self._input.vad_utterance_audio = [*self._input.vad_prefix_audio, frame] + self._input.vad_prefix_audio = [] + self._input.vad_prefix_samples = 0 + self._input.vad_speech_active = True + else: + self._input.vad_prefix_audio.append(frame) + self._input.vad_prefix_samples += frame.size + while self._input.vad_prefix_samples > prefix_samples and self._input.vad_prefix_audio: + overflow = self._input.vad_prefix_samples - prefix_samples + first = self._input.vad_prefix_audio[0] + if first.size <= overflow: + self._input.vad_prefix_audio.pop(0) + self._input.vad_prefix_samples -= first.size + released_samples += first.size + else: + self._input.vad_prefix_audio[0] = first[overflow:].copy() + self._input.vad_prefix_samples -= overflow + released_samples += overflow + else: + self._input.vad_utterance_audio.append(frame) + + if speech_stopped: + self._input.vad_speech_active = False + if self._input.vad_speech_active or speech_started: + self.turn_state = DuplexTurnState.USER_SPEAKING + return released_samples * np.dtype(np.float32).itemsize + + def stage_server_vad_audio_for_commit(self) -> bool: + if not self._input.vad_utterance_audio: + return False + samples = np.concatenate(self._input.vad_utterance_audio) + encoded = encode_float32_mono_wav_base64(samples, sample_rate_hz=16_000) + self._input.pending_audio.append(DuplexAudioChunk(data=encoded, format="wav", sample_rate_hz=16_000)) + self._input.vad_utterance_audio.clear() + self._input.vad_speech_active = False + return True + + def clear_server_vad_audio(self) -> None: + self._input.vad_prefix_audio.clear() + self._input.vad_prefix_samples = 0 + self._input.vad_utterance_audio.clear() + self._input.vad_speech_active = False + self._input.active_server_vad_item_id = None + + @property + def active_server_vad_item_id(self) -> str | None: + return self._input.active_server_vad_item_id + + @property + def server_vad_utterance_bytes(self) -> int: + return sum(frame.nbytes for frame in self._input.vad_utterance_audio) + + def discard_uncommitted_server_vad_utterance(self) -> int: + released = self.server_vad_utterance_bytes + self._input.vad_utterance_audio.clear() + self._input.vad_speech_active = False + self._input.active_server_vad_item_id = None + self._input.pending_text.clear() + return released + + def begin_server_vad_speech(self, item_id: str) -> None: + self._input.active_server_vad_item_id = item_id + + def finish_server_vad_speech(self) -> str | None: + item_id = self._input.active_server_vad_item_id + self._input.active_server_vad_item_id = None + return item_id + def mark_user_input_activity(self) -> None: self.turn_state = DuplexTurnState.USER_SPEAKING @@ -692,10 +824,16 @@ class DuplexSession: self._input.pending_audio.clear() self._input.reserved_input_bytes = 0 self._input.pending_turns = 0 + self.clear_server_vad_audio() self.turn_state = DuplexTurnState.IDLE return cancelled - def commit_user_input(self) -> DuplexCommittedInput | None: + def commit_user_input( + self, + *, + append_history: bool = True, + retain_input_bytes: int = 0, + ) -> DuplexCommittedInput | None: text = "".join(self._input.pending_text).strip() audio_chunks = list(self._input.pending_audio) if not text and not audio_chunks: @@ -722,10 +860,11 @@ class DuplexSession: self._bind_active_response_to_input_commit(self._input.commit_seq + 1) self._input.commit_seq += 1 message = {"role": "user", "content": content} - self._conversation.messages.append(message) + if append_history: + self._conversation.messages.append(message) self._input.pending_text.clear() self._input.pending_audio.clear() - self._input.reserved_input_bytes = 0 + self._input.reserved_input_bytes = max(0, int(retain_input_bytes)) self.turn_state = DuplexTurnState.USER_COMMITTED return DuplexCommittedInput( message=message, @@ -734,6 +873,40 @@ class DuplexSession: input_commit_seq=self._input.commit_seq, ) + @property + def pending_server_vad_turn(self) -> PendingServerVADTurn | None: + return self._input.pending_server_vad_turn + + def stage_server_vad_turn( + self, + committed: DuplexCommittedInput, + *, + create_response: bool, + item_id: str, + ) -> None: + if self._input.pending_server_vad_turn is not None: + raise RuntimeError("A Server VAD turn is already pending") + response_id = self.active_response_id + if response_id is not None: + # A pending user turn advances input_commit_seq before the active + # response completes. Reserve its current history position first. + self.reserve_history_item(f"item_{response_id}") + self._input.pending_server_vad_turn = PendingServerVADTurn( + committed=committed, + create_response=create_response, + item_id=item_id, + ) + + def activate_server_vad_turn(self) -> PendingServerVADTurn | None: + pending = self._input.pending_server_vad_turn + if pending is None: + return None + self._input.pending_server_vad_turn = None + self._conversation.messages.append(pending.committed.message) + self.register_history_item(pending.item_id, pending.committed.message) + self.turn_state = DuplexTurnState.USER_COMMITTED + return pending + def commit_native_audio_input( self, *, @@ -1020,7 +1193,14 @@ class DuplexSession: response_input_commit_seq = self._response.active_response_input_commit_seq if response_input_commit_seq is None: response_input_commit_seq = self.input_commit_seq - response_history_is_late = self.input_commit_seq > response_input_commit_seq + response_item_id = f"item_{response_id}" if response_id is not None else None + response_history_is_reserved = response_item_id is not None and ( + response_item_id in self._conversation.item_ids + or response_item_id in self._conversation.history_item_placeholders + ) + response_history_is_late = ( + self.input_commit_seq > response_input_commit_seq and not response_history_is_reserved + ) assistant_text = "".join(self._response.assistant_text_buffer).strip() message = None if assistant_text: @@ -1041,9 +1221,8 @@ class DuplexSession: committed_text = "" if commit_text and committed_text: message = {"role": "assistant", "content": committed_text} - item_id = f"item_{response_id}" if response_id is not None else None - if item_id is not None and item_id in self._conversation.history_item_placeholders: - self._store_history_item_message(item_id, message) + if response_item_id is not None and response_history_is_reserved: + self._store_history_item_message(response_item_id, message) else: self._conversation.messages.append(message) effective_playback_policy = playback_commit_policy or self.config.playback_commit_policy @@ -1054,7 +1233,7 @@ class DuplexSession: and effective_playback_policy == DuplexPlaybackCommitPolicy.ACK_ONLY.value ): self.register_history_item(f"item_{response_id}", None) - elif response_id is not None and not assistant_text: + elif response_id is not None and (not assistant_text or not commit_text): self._discard_history_item_placeholder(f"item_{response_id}") self._response.assistant_text_buffer.clear() if not preserve_request: @@ -1113,6 +1292,11 @@ class DuplexSession: ) def delete_history_item(self, item_id: str) -> bool: + removed_pending_turn = False + pending_server_vad_turn = self._input.pending_server_vad_turn + if pending_server_vad_turn is not None and pending_server_vad_turn.item_id == item_id: + self._input.pending_server_vad_turn = None + removed_pending_turn = True response_id = item_id.removeprefix("item_") if item_id.startswith("item_") else None message = self._conversation.item_ids.pop(item_id, None) self._conversation.item_audio_text_marks.pop(item_id, None) @@ -1125,7 +1309,7 @@ class DuplexSession: if response_id is not None: self._conversation.assistant_response_snapshots.pop(response_id, None) if message is None: - return pending is not None or removed_placeholder + return removed_pending_turn or pending is not None or removed_placeholder try: self._conversation.messages.remove(message) except ValueError: @@ -1390,6 +1574,8 @@ class DuplexSession: self._response.active_response_turn_id = None self._response.active_response_input_commit_seq = None self._response.active_response_awaits_input_commit = False + self.clear_server_vad_audio() + self._input.pending_server_vad_turn = None self._clear_response_metrics() self._restore_response_config() @@ -1420,6 +1606,8 @@ class DuplexSession: "playback": self.playback.as_dict(), "capabilities": self.capabilities.as_dict(), } + if self.config.turn_detection_configured: + payload["turn_detection"] = self.config.server_vad.as_dict() if self.config.server_vad is not None else None if isinstance(self.config.extra_body.get("realtime_tools"), list): payload["tools"] = self.config.extra_body["realtime_tools"] if isinstance(self.config.extra_body.get("realtime_tool_choice"), str | dict): diff --git a/vllm_omni/entrypoints/duplex/realtime_input.py b/vllm_omni/entrypoints/duplex/realtime_input.py index 03710b43b..7fdadbcb2 100644 --- a/vllm_omni/entrypoints/duplex/realtime_input.py +++ b/vllm_omni/entrypoints/duplex/realtime_input.py @@ -7,27 +7,23 @@ import asyncio import base64 import binascii from collections.abc import Callable -from typing import Any from uuid import uuid4 import numpy as np from vllm_omni.entrypoints.duplex.audio import ( convert_input_audio_with_rate, + encode_float32_mono_wav_base64, + validate_input_sample_rate_hz, ) from vllm_omni.entrypoints.duplex.protocol import normalize_native_duplex_key from vllm_omni.entrypoints.duplex.realtime_state import ( REALTIME_INPUT_AUDIO_FORMATS, REALTIME_OUTPUT_AUDIO_FORMATS, RealtimeStateOwner, + realtime_default_sample_rate_hz, ) -from vllm_omni.entrypoints.duplex.vad import ( - SILERO_VAD_MIN_THRESHOLD, - ServerVADUnavailableError, - SileroStreamingVAD, - SileroVADConfig, - StreamingVADResult, -) +from vllm_omni.entrypoints.duplex.server_vad import ServerVADConfig, parse_session_turn_detection class RealtimeInputTranslator(RealtimeStateOwner): @@ -49,7 +45,6 @@ class RealtimeInputTranslator(RealtimeStateOwner): _conversation_items: dict[str, dict[str, object]] _item_truncation_cursors: dict[str, tuple[int, int]] _pending_outbound: asyncio.Queue[dict[str, object]] - _pending_commit_item_ids: asyncio.Queue[str] async def _send_realtime_payload(self, payload: dict[str, object]) -> None: raise NotImplementedError @@ -66,6 +61,17 @@ class RealtimeInputTranslator(RealtimeStateOwner): _response_is_done: Callable[[object], bool] + @property + def _turn_based_server_vad(self) -> bool: + return self._turn_detection is not None and not self._native_input_append + + def _reset_realtime_input_buffer_state(self) -> None: + self._input_speech_started = False + self._active_input_item_id = None + self._input_audio_buffer_has_audio = False + self._input_audio_buffer_had_non_speech = False + self._input_audio_buffer_transcript_parts.clear() + async def discard_pending_input_audio( self, *, @@ -88,11 +94,7 @@ class RealtimeInputTranslator(RealtimeStateOwner): "item_id": self._active_input_item_id, } ) - self._input_speech_started = False - self._active_input_item_id = None - self._input_audio_buffer_has_audio = False - self._input_audio_buffer_had_non_speech = False - self._input_audio_buffer_transcript_parts.clear() + self._reset_realtime_input_buffer_state() async def _send_realtime_input_ack(self, event: dict[str, object]) -> None: if event.get("type") != "conversation.item.create": @@ -100,7 +102,6 @@ class RealtimeInputTranslator(RealtimeStateOwner): item = event.get("item") if not isinstance(item, dict): return - item = self._normalize_conversation_item(item) previous_item_id = event.get("previous_item_id") if isinstance(previous_item_id, str): item["_previous_item_id"] = previous_item_id @@ -115,18 +116,40 @@ class RealtimeInputTranslator(RealtimeStateOwner): if event_type == "session.update": session = event.get("session") session_payload: dict[str, object] = session if isinstance(session, dict) else event - format_error = self._validate_realtime_session_audio_formats(session_payload) - if format_error is not None: + try: + turn_detection_configured, server_vad = parse_session_turn_detection(session_payload) + except ValueError as exc: await self._send_realtime_payload( self._realtime_error_payload( - "unsupported_audio_format", - format_error, + "unsupported_turn_detection", + str(exc), event_id=event.get("event_id"), + param="turn_detection", ) ) return None - turn_detection_error = self._validate_realtime_turn_detection(session_payload) + normalized_turn_detection = server_vad.as_dict() if server_vad is not None else None + previous_protocol_config = self._realtime_protocol_config() + try: + self._apply_realtime_session_defaults(session_payload) + except ValueError as exc: + self._set_realtime_protocol_config(previous_protocol_config) + await self._send_realtime_payload( + self._realtime_error_payload( + "unsupported_audio_format", + str(exc), + event_id=event.get("event_id"), + param="audio.input.format", + ) + ) + return None + turn_detection_error = self._validate_realtime_turn_detection( + session_payload, + configured=turn_detection_configured, + normalized=normalized_turn_detection, + ) if turn_detection_error is not None: + self._set_realtime_protocol_config(previous_protocol_config) await self._send_realtime_payload( self._realtime_error_payload( "unsupported_turn_detection", @@ -136,31 +159,38 @@ class RealtimeInputTranslator(RealtimeStateOwner): ) ) return None - self._prepare_realtime_turn_detection_update(session_payload) - self._apply_realtime_session_defaults(session_payload) - session_payload.update(self._realtime_overlap_fields(session_payload)) - if not self._opened: - self._opened = True - self._initial_session_update = True - return self._session_create_from_realtime(session_payload) - return { - "type": "turn.signal", - "event": "session.update", - "payload": session_payload, - "realtime_event_id": event.get("event_id"), - } - if event_type == "conversation.item.create": - item = event.get("item") - format_error = self._validate_conversation_item_audio_formats(item) - if format_error is not None: - await self._send_realtime_payload( - self._realtime_error_payload( - "unsupported_audio_format", - format_error, - event_id=event.get("event_id"), - ) + turn_detection_candidate = self._prepare_realtime_turn_detection_update( + session_payload, + configured=turn_detection_configured, + normalized=normalized_turn_detection, + ) + try: + session_payload.update(self._realtime_overlap_fields(session_payload)) + if not self._opened: + self._opened = True + self._initial_session_update = True + translated = self._session_create_from_realtime(session_payload) + event_id = event.get("event_id") + if isinstance(event_id, str) and event_id: + translated["_realtime_event_id"] = event_id + else: + translated = { + "type": "turn.signal", + "event": "session.update", + "payload": session_payload, + "realtime_event_id": event.get("event_id"), + } + candidate_protocol_config = self._realtime_protocol_config() + finally: + self._set_realtime_protocol_config(previous_protocol_config) + if turn_detection_candidate[0] or candidate_protocol_config != previous_protocol_config: + self._pending_turn_detection_update = ( + *turn_detection_candidate, + asyncio.Event(), + candidate_protocol_config, ) - return None + return translated + if event_type == "conversation.item.create": return await self._conversation_item_to_duplex(event) if event_type == "conversation.item.delete": item_id = event.get("item_id") @@ -293,27 +323,20 @@ class RealtimeInputTranslator(RealtimeStateOwner): return None if event_type == "input_audio_buffer.append": audio = event.get("audio") or event.get("delta") - fmt, format_rate = self._parse_realtime_audio_format( - event.get("format") or event.get("input_audio_format") or self._input_audio_format - ) - sample_rate_hz = ( - event.get("sample_rate_hz") or event.get("sample_rate") or format_rate or self._input_sample_rate_hz - ) - if not self._is_supported_realtime_input_format(fmt): - await self._send_realtime_payload( - self._realtime_error_payload( - "unsupported_audio_format", - f"Unsupported input_audio_format: {fmt}", - event_id=event.get("event_id"), - ) - ) - return None + event_format = event.get("format") or event.get("input_audio_format") + native_server_vad = self._turn_detection is not None and self._native_input_append + turn_based_server_vad = self._turn_based_server_vad try: - audio, fmt, sample_rate_hz = convert_input_audio_with_rate( - audio, - fmt, - sample_rate_hz=sample_rate_hz if isinstance(sample_rate_hz, int | float) else None, - ) + fmt, format_rate = self._parse_realtime_audio_format(event_format or self._input_audio_format) + sample_rate_hz = event.get("sample_rate_hz") or event.get("sample_rate") or format_rate + if sample_rate_hz is None: + sample_rate_hz = ( + realtime_default_sample_rate_hz(fmt) + if event_format is not None and fmt != self._input_audio_format + else self._input_sample_rate_hz + ) + if turn_based_server_vad: + sample_rate_hz = validate_input_sample_rate_hz(sample_rate_hz) except ValueError as exc: await self._send_realtime_payload( self._realtime_error_payload( @@ -324,63 +347,65 @@ class RealtimeInputTranslator(RealtimeStateOwner): ) ) return None - detector = self._server_vad - vad_result: StreamingVADResult | None = None - if isinstance(detector, SileroStreamingVAD): + channels = event.get("channels", 1) + if not self._is_supported_realtime_input_format(fmt): + await self._send_realtime_payload( + self._realtime_error_payload( + "unsupported_audio_format", + f"Unsupported input_audio_format: {fmt}", + event_id=event.get("event_id"), + ) + ) + return None + if turn_based_server_vad and (fmt != "pcm16" or channels != 1 or sample_rate_hz not in {16_000, 24_000}): + await self._send_realtime_payload( + self._realtime_error_payload( + "unsupported_audio_format", + "server_vad requires mono PCM16 input at 16 kHz or 24 kHz", + event_id=event.get("event_id"), + param="audio.input.format", + ) + ) + return None + looks_like_speech: bool | None = None + if not turn_based_server_vad: try: - vad_result = detector.process_base64(audio, fmt=fmt, sample_rate_hz=sample_rate_hz) - except (ServerVADUnavailableError, ValueError) as exc: - unavailable = isinstance(exc, ServerVADUnavailableError) + audio, fmt, sample_rate_hz = convert_input_audio_with_rate( + audio, + fmt, + sample_rate_hz=sample_rate_hz if isinstance(sample_rate_hz, int | float) else None, + ) + except ValueError as exc: await self._send_realtime_payload( self._realtime_error_payload( - "server_vad_unavailable" if unavailable else "bad_audio", + "bad_event", str(exc), event_id=event.get("event_id"), - param="turn_detection" if unavailable else "audio", + param="sample_rate_hz", ) ) return None - looks_like_speech = ( - vad_result.is_speech - if vad_result is not None - else self._input_looks_like_speech(event, audio=audio, fmt=fmt) - ) - self._input_audio_buffer_has_audio = self._input_audio_buffer_has_audio or ( - looks_like_speech and isinstance(audio, str) and bool(audio) - ) - self._input_audio_buffer_had_non_speech = self._input_audio_buffer_had_non_speech or ( - not looks_like_speech and isinstance(audio, str) and bool(audio) - ) - stopped_event: dict[str, object] | None = None - if vad_result is not None and vad_result.speech_stopped: - stopped_event = dict(event) - if vad_result.speech_end_ms is not None: - stopped_event["audio_end_ms"] = vad_result.speech_end_ms - if stopped_event is not None and vad_result is not None and vad_result.speech_active: - await self._emit_input_speech_stopped( - stopped_event, - item_id=self._active_input_item_id or f"item_{uuid4().hex}", + if not turn_based_server_vad and not native_server_vad: + looks_like_speech = self._input_looks_like_speech(event, audio=audio, fmt=fmt) + has_audio_payload = isinstance(audio, str) and bool(audio) + self._input_audio_buffer_has_audio = self._input_audio_buffer_has_audio or ( + looks_like_speech and has_audio_payload ) - if vad_result is not None and vad_result.speech_started: - started_event = dict(event) - if vad_result.speech_start_ms is not None: - started_event["audio_start_ms"] = vad_result.speech_start_ms - await self._emit_input_speech_started(started_event) - elif vad_result is None and looks_like_speech: - await self._emit_input_speech_started(event) - if stopped_event is not None and vad_result is not None and not vad_result.speech_active: - await self._emit_input_speech_stopped( - stopped_event, - item_id=self._active_input_item_id or f"item_{uuid4().hex}", + self._input_audio_buffer_had_non_speech = self._input_audio_buffer_had_non_speech or ( + not looks_like_speech and has_audio_payload ) - if looks_like_speech: - self._remember_input_transcript_hint(event) + if looks_like_speech: + await self._emit_input_speech_started(event) + self._remember_input_transcript_hint(event) payload = { "type": "input_audio_buffer.append", "audio": audio, "format": fmt, "sample_rate_hz": sample_rate_hz, } + event_id = event.get("event_id") + if self._turn_detection is not None and isinstance(event_id, str) and event_id: + payload["_realtime_event_id"] = event_id video_frames = event.get("video_frames") if video_frames is not None: frames_error = self._validate_realtime_video_frames(video_frames, event.get("max_slice_nums")) @@ -397,25 +422,27 @@ class RealtimeInputTranslator(RealtimeStateOwner): if isinstance(video_frames, list): payload["video_frames"] = [frame for frame in video_frames if isinstance(frame, str) and frame] self._copy_realtime_input_hints(event, payload) - payload["is_speech"] = looks_like_speech - if vad_result is not None: - payload["vad"] = { - "backend": "silero", - "is_speech": vad_result.is_speech, - "speech_active": vad_result.speech_active, - "speech_started": vad_result.speech_started, - "speech_stopped": vad_result.speech_stopped, - "speech_probability": vad_result.speech_probability, - } - if vad_result.speech_active: - payload["force_listen"] = True + if looks_like_speech is not None: + payload["is_speech"] = looks_like_speech return payload if event_type == "input_audio_buffer.commit": + if self._turn_based_server_vad: + await self._send_realtime_payload( + self._realtime_error_payload( + "server_vad_manual_commit_unsupported", + ( + "input_audio_buffer.commit is not supported while server_vad is enabled; " + "wait for server endpointing, or start a session with turn_detection=null " + "to use manual commit" + ), + event_id=event.get("event_id"), + ) + ) + return None if not self._input_audio_buffer_has_audio: if self._input_audio_buffer_had_non_speech: self._input_audio_buffer_had_non_speech = False self._active_input_item_id = None - self._reset_server_vad() return { "type": "input_audio_buffer.commit", "final": event.get("final", True), @@ -431,29 +458,22 @@ class RealtimeInputTranslator(RealtimeStateOwner): ) return None item_id = self._active_input_item_id or f"item_{uuid4().hex}" - await self._pending_commit_item_ids.put(item_id) await self._emit_input_speech_stopped(event, item_id=item_id) transcript = self._consume_input_transcript_hint() self._active_input_item_id = None self._input_audio_buffer_has_audio = False self._input_audio_buffer_had_non_speech = False - self._reset_server_vad() payload = { "type": "input_audio_buffer.commit", "final": event.get("final", True), - "realtime_item_id": item_id, + "item_id": item_id, "response_create": bool(event.get("response_create", False)), } if transcript: payload["transcript"] = transcript return payload if event_type == "input_audio_buffer.clear": - self._input_speech_started = False - self._active_input_item_id = None - self._input_audio_buffer_has_audio = False - self._input_audio_buffer_had_non_speech = False - self._input_audio_buffer_transcript_parts.clear() - self._reset_server_vad() + self._reset_realtime_input_buffer_state() return {"type": "input_audio_buffer.clear", "reason": event_type} if event_type == "output_audio_buffer.clear": payload = {"type": "output_audio_buffer.clear", "reason": event_type} @@ -643,19 +663,19 @@ class RealtimeInputTranslator(RealtimeStateOwner): "previous_item_id": previous_item_id, "item": item, }, - { - "type": "conversation.item.created", - "previous_item_id": previous_item_id, - "item": item, - }, ] def _conversation_item_done_event(self, item: dict[str, object]) -> dict[str, object]: item_id = item.get("id") previous_item_id = self._previous_item_id(item_id) if isinstance(item_id, str) else None if isinstance(item_id, str) and item_id: + already_known = item_id in self._conversation_items self._conversation_items[item_id] = item - self._last_conversation_item_id = item_id + # Completing an item that was already added must not move it to the + # end of the conversation. A later user item may have been added + # while this response was still in progress. + if not already_known: + self._last_conversation_item_id = item_id return { "type": "conversation.item.done", "previous_item_id": previous_item_id, @@ -737,17 +757,14 @@ class RealtimeInputTranslator(RealtimeStateOwner): self, *, item_id: str, - event: dict[str, Any], ) -> dict[str, object]: return { "type": "input_audio_buffer.committed", "previous_item_id": self._previous_item_id(item_id), "item_id": item_id, - "event": event, } def _session_create_from_realtime(self, session_payload: dict[str, object]) -> dict[str, object]: - self._apply_realtime_session_defaults(session_payload) model = session_payload.get("model") audio_config = session_payload.get("audio") audio_input = audio_config.get("input") if isinstance(audio_config, dict) else None @@ -825,8 +842,13 @@ class RealtimeInputTranslator(RealtimeStateOwner): audio_input = audio_config.get("input") if isinstance(audio_input, dict): input_format = audio_input.get("format") + input_format_configured = input_format is not None input_format, input_rate = self._parse_realtime_audio_format(input_format) - if isinstance(input_format, str) and input_format.lower() in REALTIME_INPUT_AUDIO_FORMATS: + if input_format is not None and not ( + isinstance(input_format, str) and input_format.lower() in REALTIME_INPUT_AUDIO_FORMATS + ): + raise ValueError(f"Unsupported input_audio_format: {input_format}") + if isinstance(input_format, str): self._input_audio_format = input_format output_format: object = session_payload.get("output_audio_format") or session_payload.get("response_format") output_rate_raw: object | None = None @@ -836,9 +858,13 @@ class RealtimeInputTranslator(RealtimeStateOwner): output_format = audio_output.get("format") output_rate_raw = audio_output.get("sample_rate_hz") or audio_output.get("sample_rate") output_format, output_rate = self._parse_realtime_audio_format(output_format) - if output_rate is None and isinstance(output_rate_raw, int | float) and output_rate_raw > 0: - output_rate = int(output_rate_raw) - if isinstance(output_format, str) and output_format.lower() in REALTIME_OUTPUT_AUDIO_FORMATS: + if output_rate is None and output_rate_raw is not None: + output_rate = validate_input_sample_rate_hz(output_rate_raw) + if output_format is not None and not ( + isinstance(output_format, str) and output_format.lower() in REALTIME_OUTPUT_AUDIO_FORMATS + ): + raise ValueError(f"Unsupported output_audio_format: {output_format}") + if isinstance(output_format, str): self._output_audio_format = self._realtime_output_format(output_format) sample_rate = session_payload.get("sample_rate_hz") or session_payload.get("sample_rate") if sample_rate is None and isinstance(audio_config, dict): @@ -847,41 +873,19 @@ class RealtimeInputTranslator(RealtimeStateOwner): sample_rate = audio_input.get("sample_rate_hz") or audio_input.get("sample_rate") if sample_rate is None: sample_rate = input_rate - if isinstance(sample_rate, int | float) and sample_rate > 0: - self._input_sample_rate_hz = int(sample_rate) - if isinstance(output_rate, int | float) and output_rate > 0: - self._output_sample_rate_hz = int(output_rate) + if sample_rate is not None: + self._input_sample_rate_hz = validate_input_sample_rate_hz(sample_rate) + elif input_format_configured: + default_input_rate = realtime_default_sample_rate_hz(input_format) + if default_input_rate is not None: + self._input_sample_rate_hz = default_input_rate + if output_rate is not None: + self._output_sample_rate_hz = output_rate overlap_fields = self._realtime_overlap_fields(session_payload) overlap_silence_rms = overlap_fields.get("overlap_silence_rms") if isinstance(overlap_silence_rms, int | float): self._overlap_silence_rms = max(0.0, float(overlap_silence_rms)) - @classmethod - def _validate_realtime_session_audio_formats(cls, session_payload: dict[str, object]) -> str | None: - audio_config = session_payload.get("audio") - input_format: object = session_payload.get("input_audio_format") - if input_format is None and isinstance(audio_config, dict): - audio_input = audio_config.get("input") - if isinstance(audio_input, dict): - input_format = audio_input.get("format") - parsed_input, _ = cls._parse_realtime_audio_format(input_format) - if input_format is not None and not ( - isinstance(parsed_input, str) and parsed_input.lower() in REALTIME_INPUT_AUDIO_FORMATS - ): - return f"Unsupported input_audio_format: {input_format}" - - output_format: object = session_payload.get("output_audio_format") or session_payload.get("response_format") - if output_format is None and isinstance(audio_config, dict): - audio_output = audio_config.get("output") - if isinstance(audio_output, dict): - output_format = audio_output.get("format") - parsed_output, _ = cls._parse_realtime_audio_format(output_format) - if output_format is not None and not ( - isinstance(parsed_output, str) and parsed_output.lower() in REALTIME_OUTPUT_AUDIO_FORMATS - ): - return f"Unsupported output_audio_format: {output_format}" - return None - @classmethod def _validate_realtime_response_audio_formats(cls, response_payload: dict[str, object]) -> str | None: output_format: object = response_payload.get("output_audio_format") or response_payload.get("response_format") @@ -890,33 +894,16 @@ class RealtimeInputTranslator(RealtimeStateOwner): audio_output = audio_config.get("output") if isinstance(audio_output, dict): output_format = audio_output.get("format") - parsed_output, _ = cls._parse_realtime_audio_format(output_format) + try: + parsed_output, _ = cls._parse_realtime_audio_format(output_format) + except ValueError as exc: + return str(exc) if output_format is not None and not ( isinstance(parsed_output, str) and parsed_output.lower() in REALTIME_OUTPUT_AUDIO_FORMATS ): return f"Unsupported output_audio_format: {output_format}" return None - @classmethod - def _validate_conversation_item_audio_formats(cls, item: object) -> str | None: - if not isinstance(item, dict): - return None - content = item.get("content") - if not isinstance(content, list): - return None - for part in content: - if not isinstance(part, dict): - continue - if part.get("type") not in {"input_audio", "audio"}: - continue - raw_format = part.get("format") - parsed_format, _ = cls._parse_realtime_audio_format(raw_format) - if raw_format is not None and not ( - isinstance(parsed_format, str) and parsed_format.lower() in REALTIME_INPUT_AUDIO_FORMATS - ): - return f"Unsupported input_audio format in conversation.item.create: {raw_format}" - return None - @staticmethod def _json_safe_realtime_payload(payload: dict[str, object]) -> dict[str, object]: clean: dict[str, object] = {} @@ -958,7 +945,7 @@ class RealtimeInputTranslator(RealtimeStateOwner): if not isinstance(raw_format, dict): return raw_format, None rate = raw_format.get("rate") or raw_format.get("sample_rate_hz") or raw_format.get("sample_rate") - sample_rate_hz = int(rate) if isinstance(rate, int | float) and rate > 0 else None + sample_rate_hz = validate_input_sample_rate_hz(rate) if rate is not None else None fmt = raw_format.get("type") or raw_format.get("format") if not isinstance(fmt, str): return raw_format, sample_rate_hz @@ -1107,124 +1094,122 @@ class RealtimeInputTranslator(RealtimeStateOwner): return "video_frames entries must be JPEG or PNG images" return None - @staticmethod - def _configured_realtime_turn_detection(session_payload: dict[str, object]) -> tuple[str | None, object]: - field = "turn_detection" if "turn_detection" in session_payload else None - selected = session_payload.get("turn_detection") - audio_config = session_payload.get("audio") - if isinstance(audio_config, dict): - audio_input = audio_config.get("input") - if isinstance(audio_input, dict) and "turn_detection" in audio_input: - field, selected = "audio.input.turn_detection", audio_input["turn_detection"] - return field, selected - - @classmethod - def _validate_realtime_turn_detection(cls, session_payload: dict[str, object]) -> str | None: - field, turn_detection = cls._configured_realtime_turn_detection(session_payload) - if field is None: + def _validate_realtime_turn_detection( + self, + session_payload: dict[str, object], + *, + configured: bool, + normalized: dict[str, object] | None, + ) -> str | None: + effective_turn_detection = normalized if configured else self._turn_detection + if effective_turn_detection is None: if session_payload.get("overlap_policy") == "barge_in_on_speech": - return ( - "overlap_policy='barge_in_on_speech' requires " - "turn_detection.type='server_vad' on the Realtime endpoint" - ) + return "overlap_policy='barge_in_on_speech' requires turn_detection.type='server_vad'" return None - if turn_detection is not None and not isinstance(turn_detection, dict): - return f"{field} must be null or an object with type='server_vad'" - if isinstance(turn_detection, dict): - if turn_detection.get("type") != "server_vad": - return f"{field}.type must be 'server_vad'" - threshold = turn_detection.get("threshold", 0.5) - if not cls._valid_vad_number(threshold, minimum=SILERO_VAD_MIN_THRESHOLD, strict=True, maximum=1): - return f"{field}.threshold must be greater than {SILERO_VAD_MIN_THRESHOLD} and at most 1" - for name in ("prefix_padding_ms", "silence_duration_ms", "min_speech_duration_ms"): - value = turn_detection.get(name) - if value is not None and not cls._valid_vad_number(value, minimum=0): - return f"{field}.{name} must be a non-negative number" - if turn_detection.get("interrupt_response", True) is not True: - return ( - f"{field}.interrupt_response=false is unsupported; " - "use turn_detection=null for model-owned listen/speak" - ) - desired_policy = "barge_in_on_speech" if turn_detection is not None else "listen_only" + + interrupt_response = effective_turn_detection.get("interrupt_response") + if interrupt_response is None: + # Serving resolves the default and validates overlap policy once + # the session's runtime capabilities are known. + return None + desired_policy = "barge_in_on_speech" if interrupt_response else "listen_only" overlap_policy = session_payload.get("overlap_policy") if isinstance(overlap_policy, str) and overlap_policy != desired_policy: return f"overlap_policy={overlap_policy!r} conflicts with turn_detection; expected {desired_policy!r}" return None - @staticmethod - def _valid_vad_number(value: object, *, minimum: float, maximum: float | None = None, strict: bool = False) -> bool: + def validate_server_vad_input_audio(self, session_payload: dict[str, object]) -> str | None: + """Validate turn-based VAD audio after serving resolves runtime capabilities.""" + candidate = self._pending_turn_detection_update + input_format, input_rate, *_ = candidate[3] if candidate is not None else self._realtime_protocol_config() + audio_config = session_payload.get("audio") + audio_input = audio_config.get("input") if isinstance(audio_config, dict) else None + channels = audio_input.get("channels") if isinstance(audio_input, dict) else None + raw_format = session_payload.get("input_audio_format") + if raw_format is None and isinstance(audio_input, dict): + raw_format = audio_input.get("format") + if channels is None and isinstance(raw_format, dict): + channels = raw_format.get("channels") + if channels is None: + channels = 1 + if input_format != "pcm16" or channels != 1 or input_rate not in {16_000, 24_000}: + return "server_vad requires mono PCM16 input at 16 kHz or 24 kHz" + return None + + def _prepare_realtime_turn_detection_update( + self, + session_payload: dict[str, object], + *, + configured: bool, + normalized: dict[str, object] | None, + ) -> tuple[bool, dict[str, object] | None]: + if self._pending_turn_detection_update is not None: + raise RuntimeError("A Realtime session update is already pending") + + turn_detection = dict(normalized) if configured and normalized is not None else None + if configured: + if turn_detection is None: + session_payload.setdefault("overlap_policy", "listen_only") + elif "interrupt_response" in turn_detection: + session_payload.setdefault( + "overlap_policy", "barge_in_on_speech" if turn_detection["interrupt_response"] else "listen_only" + ) + session_payload["turn_detection"] = turn_detection + return configured, turn_detection + + def _realtime_protocol_config(self) -> tuple[str, int, str, int | None, float]: return ( - isinstance(value, int | float) - and not isinstance(value, bool) - and np.isfinite(float(value)) - and (float(value) > minimum if strict else float(value) >= minimum) - and (maximum is None or float(value) <= maximum) + self._input_audio_format, + self._input_sample_rate_hz, + self._output_audio_format, + self._output_sample_rate_hz, + self._overlap_silence_rms, ) - def _prepare_realtime_turn_detection_update(self, session_payload: dict[str, object]) -> None: - field, configured = self._configured_realtime_turn_detection(session_payload) - if field is None: - return - if self._pending_turn_detection_update is not None: - raise RuntimeError("A Realtime turn-detection update is already pending") + def _set_realtime_protocol_config( + self, + config: tuple[str, int, str, int | None, float], + ) -> None: + ( + self._input_audio_format, + self._input_sample_rate_hz, + self._output_audio_format, + self._output_sample_rate_hz, + self._overlap_silence_rms, + ) = config - turn_detection: dict[str, object] | None = None - detector: SileroStreamingVAD | None = None - if configured is None: - session_payload["overlap_policy"] = "listen_only" - else: - assert isinstance(configured, dict) - turn_detection = { - "type": "server_vad", - "interrupt_response": True, - "threshold": 0.5, - "prefix_padding_ms": 300, - "silence_duration_ms": 500, - "min_speech_duration_ms": 96, - **configured, - } - detector = SileroStreamingVAD( - SileroVADConfig( - threshold=float(turn_detection["threshold"]), - prefix_padding_ms=int(turn_detection["prefix_padding_ms"]), - silence_duration_ms=int(turn_detection["silence_duration_ms"]), - min_speech_duration_ms=max(32, int(turn_detection["min_speech_duration_ms"])), - ) - ) - session_payload["turn_detection"] = dict(turn_detection) - session_payload["overlap_policy"] = "barge_in_on_speech" - - self._pending_turn_detection_update = (turn_detection, detector, asyncio.Event()) + def pending_realtime_input_audio_config_changed(self) -> bool: + candidate = self._pending_turn_detection_update + if candidate is None: + return False + candidate_protocol_config = candidate[3] + return candidate_protocol_config[:2] != self._realtime_protocol_config()[:2] async def wait_for_realtime_turn_detection_update(self) -> None: candidate = self._pending_turn_detection_update if candidate is not None: await candidate[2].wait() - def commit_realtime_turn_detection_update(self) -> None: - self._resolve_realtime_turn_detection_update(commit=True) + def commit_realtime_turn_detection_update(self, server_vad: ServerVADConfig | None = None) -> None: + self._resolve_realtime_turn_detection_update(commit=True, server_vad=server_vad) def reject_realtime_turn_detection_update(self) -> None: self._resolve_realtime_turn_detection_update(commit=False) - def _resolve_realtime_turn_detection_update(self, *, commit: bool) -> None: + def _resolve_realtime_turn_detection_update( + self, *, commit: bool, server_vad: ServerVADConfig | None = None + ) -> None: candidate = self._pending_turn_detection_update if candidate is None: return - turn_detection, detector, resolved = candidate - discarded = self._server_vad if commit else detector + configured, turn_detection, resolved, protocol_config = candidate if commit: - self._turn_detection, self._server_vad = turn_detection, detector + self._set_realtime_protocol_config(protocol_config) + if configured: + self._turn_detection = server_vad.as_dict() if server_vad is not None else turn_detection + self._turn_detection_configured = True self._pending_turn_detection_update = None resolved.set() - reset = getattr(discarded, "reset", None) - if callable(reset): - reset() - - def _reset_server_vad(self) -> None: - reset = getattr(self._server_vad, "reset", None) - if callable(reset): - reset() @staticmethod def _input_audio_transcription_config(session_payload: dict[str, object]) -> dict[str, object] | None: @@ -1257,19 +1242,19 @@ class RealtimeInputTranslator(RealtimeStateOwner): return None async def _conversation_item_to_duplex(self, event: dict[str, object]) -> dict[str, object] | None: - item = event.get("item") - if not isinstance(item, dict): + raw_item = event.get("item") + if not isinstance(raw_item, dict): return None - item = self._normalize_conversation_item(item) + public_item = self._normalize_conversation_item(raw_item) previous_item_id = event.get("previous_item_id") if isinstance(previous_item_id, str): - item["_previous_item_id"] = previous_item_id - item_id = str(item["id"]) - item_type = item.get("type") - role = item.get("role") + public_item["_previous_item_id"] = previous_item_id + item_id = str(public_item["id"]) + item_type = public_item.get("type") + role = public_item.get("role") if item_type == "function_call_output": - call_id = item.get("call_id") - output = item.get("output") + call_id = public_item.get("call_id") + output = public_item.get("output") matching_call = any( known.get("type") == "function_call" and known.get("call_id") == call_id for known in self._conversation_items.values() @@ -1307,30 +1292,110 @@ class RealtimeInputTranslator(RealtimeStateOwner): # ``conversation.item.created`` event records it in the shared # input/output protocol state; on failure the client may retry the # same call_id instead of being rejected by provisional history. + content = public_item.get("content") + if not isinstance(content, list): + return None + parsed_audio_formats: dict[int, tuple[object, int | None]] = {} + for index, part in enumerate(content): + if not isinstance(part, dict) or part.get("type") not in {"input_audio", "audio"}: + continue + raw_format = part.get("format") + try: + parsed_format = self._parse_realtime_audio_format(raw_format or self._input_audio_format) + except ValueError as exc: + await self._send_realtime_payload( + self._realtime_error_payload( + "unsupported_audio_format", + str(exc), + event_id=event.get("event_id"), + param="item.content.audio", + ) + ) + return None + if not self._is_supported_realtime_input_format(parsed_format[0]): + await self._send_realtime_payload( + self._realtime_error_payload( + "unsupported_audio_format", + f"Unsupported input_audio format in conversation.item.create: {raw_format}", + event_id=event.get("event_id"), + param="item.content.audio", + ) + ) + return None + parsed_audio_formats[index] = parsed_format if item_type != "message" or role in {"assistant", "system"}: + event["item"] = public_item return { "type": "turn.signal", "event": "conversation.item.create", - "payload": {"item": item}, + "payload": {"item": public_item}, } - self._conversation_items[item_id] = item - content = item.get("content") - if not isinstance(content, list): - return None + server_vad_full_audio_item = self._turn_based_server_vad text_chunks: list[str] = [] audio_events: list[dict[str, object]] = [] - for part in content: + normalized_content: list[object] = [] + normalized_full_audio = False + for index, part in enumerate(content): if not isinstance(part, dict): + normalized_content.append(part) continue if part.get("type") in {"input_text", "text"} and isinstance(part.get("text"), str): text_chunks.append(str(part["text"])) if part.get("type") in {"input_audio", "audio"}: audio = part.get("audio") or part.get("data") - fmt, format_rate = self._parse_realtime_audio_format(part.get("format") or self._input_audio_format) - sample_rate_hz = ( - part.get("sample_rate_hz") or part.get("sample_rate") or format_rate or self._input_sample_rate_hz - ) - if not self._is_supported_realtime_input_format(fmt): + if not isinstance(audio, str) or not audio: + await self._send_realtime_payload( + self._realtime_error_payload( + "bad_audio", + "conversation.item.create input_audio must not be empty", + event_id=event.get("event_id"), + param="item.content.audio", + ) + ) + return None + raw_part_format = part.get("format") + fmt, format_rate = parsed_audio_formats[index] + explicit_sample_rate_hz = part.get("sample_rate_hz") + if explicit_sample_rate_hz is None: + explicit_sample_rate_hz = part.get("sample_rate") + if explicit_sample_rate_hz is None and isinstance(raw_part_format, dict): + explicit_sample_rate_hz = ( + raw_part_format.get("rate") + if "rate" in raw_part_format + else raw_part_format.get("sample_rate_hz", raw_part_format.get("sample_rate")) + ) + sample_rate_hz = explicit_sample_rate_hz or format_rate or self._input_sample_rate_hz + if server_vad_full_audio_item and ( + fmt != self._input_audio_format + or (explicit_sample_rate_hz is not None and explicit_sample_rate_hz != self._input_sample_rate_hz) + ): + await self._send_realtime_payload( + self._realtime_error_payload( + "bad_audio", + "conversation.item.create input_audio must use the Session input format and sample rate", + event_id=event.get("event_id"), + param="item.content.audio", + ) + ) + return None + if server_vad_full_audio_item: + try: + normalized_audio = self._normalize_complete_audio_item_part(audio, self._input_sample_rate_hz) + except ValueError as exc: + await self._send_realtime_payload( + self._realtime_error_payload( + "bad_audio", + str(exc), + event_id=event.get("event_id"), + param="item.content.audio", + ) + ) + return None + normalized_part = dict(part) + normalized_part.pop("data", None) + normalized_part.update(audio=normalized_audio, format="wav", sample_rate_hz=16_000) + normalized_content.append(normalized_part) + normalized_full_audio = True continue try: audio, fmt, sample_rate_hz = convert_input_audio_with_rate( @@ -1341,15 +1406,13 @@ class RealtimeInputTranslator(RealtimeStateOwner): except ValueError as exc: await self._send_realtime_payload( self._realtime_error_payload( - "bad_event", + "bad_audio", str(exc), event_id=event.get("event_id"), - param="sample_rate_hz", + param="item.content.audio", ) ) return None - if not isinstance(audio, str) or not audio: - continue speech_hints = dict(event) speech_hints.update(part) if not self._input_looks_like_speech(speech_hints, audio=audio, fmt=fmt): @@ -1364,31 +1427,69 @@ class RealtimeInputTranslator(RealtimeStateOwner): self._copy_realtime_input_hints(part, payload) self._copy_realtime_input_hints(event, payload) audio_events.append(payload) + continue + normalized_content.append(part) + if server_vad_full_audio_item and normalized_full_audio: + history_item = dict(public_item) + history_item["content"] = normalized_content + history_item["status"] = "completed" + event["item"] = public_item + return { + "type": "turn.signal", + "event": "conversation.item.create", + "payload": { + "item": public_item, + "history_item": history_item, + }, + } if audio_events: - self._input_audio_buffer_has_audio = True - transcript = self._input_transcript_from_item(item) + transcript = self._input_transcript_from_item(public_item) for extra_event in audio_events[1:]: self._pending_outbound.put_nowait(extra_event) commit_payload: dict[str, object] = { "type": "input_audio_buffer.commit", "final": True, - "realtime_item_id": item_id, + "item_id": item_id, "response_create": False, } if transcript: commit_payload["transcript"] = transcript self._pending_outbound.put_nowait(commit_payload) + # This complete item is already committed locally; a later ack + # must not be needed to start accepting the next input turn. + self._reset_realtime_input_buffer_state() + event["item"] = public_item return audio_events[0] if not text_chunks: return None - text_item = dict(item) + text_item = dict(public_item) text_item["status"] = "completed" + event["item"] = text_item return { "type": "turn.signal", "event": "conversation.item.create", "payload": {"item": text_item}, } + @staticmethod + def _normalize_complete_audio_item_part( + audio: str, + sample_rate_hz: int, + ) -> str: + """Package a complete Realtime audio item for the chat fallback path.""" + try: + raw = base64.b64decode(audio.strip(), validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError("conversation.item.create input_audio is not valid Base64") from exc + if not raw or len(raw) % np.dtype(" None: transcript = event.get("transcript") if not isinstance(transcript, str): @@ -1454,12 +1555,6 @@ class RealtimeInputTranslator(RealtimeStateOwner): } return None - def _pop_pending_commit_item_id(self) -> str: - try: - return self._pending_commit_item_ids.get_nowait() - except asyncio.QueueEmpty: - return f"item_{uuid4().hex}" - async def _emit_input_speech_started(self, event: dict[str, object]) -> None: if self._input_speech_started: return diff --git a/vllm_omni/entrypoints/duplex/realtime_output.py b/vllm_omni/entrypoints/duplex/realtime_output.py index e8c99f218..82ce9761e 100644 --- a/vllm_omni/entrypoints/duplex/realtime_output.py +++ b/vllm_omni/entrypoints/duplex/realtime_output.py @@ -39,6 +39,28 @@ class RealtimeOutputProjector: if event_type == "session.updated": session = self._realtime_session_payload(event.get("session")) return [{"type": "session.updated", "session": session}] + if event_type == "input_audio_buffer.speech_started": + item_id = event.get("item_id") + if isinstance(item_id, str): + self._active_input_item_id = item_id + self._input_speech_started = True + self._input_audio_buffer_has_audio = True + return [ + { + "type": "input_audio_buffer.speech_started", + "audio_start_ms": max(0, int(event.get("audio_start_ms", 0) or 0)), + "item_id": item_id, + } + ] + if event_type == "input_audio_buffer.speech_stopped": + self._input_speech_started = False + return [ + { + "type": "input_audio_buffer.speech_stopped", + "audio_end_ms": max(0, int(event.get("audio_end_ms", 0) or 0)), + "item_id": event.get("item_id") or self._active_input_item_id, + } + ] if event_type in { "session.resumed", "session.heartbeat_ack", @@ -201,14 +223,15 @@ class RealtimeOutputProjector: ), ] if event_type == "input.committed": - event_item_id = event.get("realtime_item_id") - item_id = ( - event_item_id - if isinstance(event_item_id, str) and event_item_id - else self._pop_pending_commit_item_id() - ) + event_item_id = event.get("item_id") + # Acknowledgements carry their own identity; untracked silence + # commits must not consume another turn's item ID. + item_id = event_item_id if isinstance(event_item_id, str) and event_item_id else f"item_{uuid4().hex}" + # The reader may already have accepted audio for the next turn. + if self._active_input_item_id == item_id: + self._reset_realtime_input_buffer_state() item = self._conversation_items.get(item_id) - created_payload: list[dict[str, object]] = [] + payloads = [self._input_audio_buffer_committed_event(item_id=item_id)] if item is None: message = event.get("message") no_response = event.get("no_response") is True @@ -226,19 +249,18 @@ class RealtimeOutputProjector: ), } self._conversation_items[item_id] = item - created_payload.extend(self._conversation_item_added_events(item)) + payloads.extend(self._conversation_item_added_events(item)) item["status"] = "completed" - payloads = created_payload + [ - self._input_audio_buffer_committed_event(item_id=item_id, event=event), - ] transcription_event = self._input_audio_transcription_completed_event(item_id, item) if transcription_event is not None: payloads.append(transcription_event) payloads.append(self._conversation_item_done_event(item)) return payloads if event_type == "input.cancelled": + self._reset_realtime_input_buffer_state() return [{"type": "input_audio_buffer.cleared"}] if event_type == "input_audio_buffer.cleared": + self._reset_realtime_input_buffer_state() return [{"type": "input_audio_buffer.cleared"}] if event_type == "audio.cancelled": response_id = event.get("response_id") @@ -416,7 +438,14 @@ class RealtimeOutputProjector: return [event] message = str(raw_error or event.get("message") or "Duplex runtime error") code = str(event.get("code") or "duplex_error") - return [self._realtime_error_payload(code, message, event_id=event.get("realtime_event_id"))] + return [ + self._realtime_error_payload( + code, + message, + event_id=event.get("realtime_event_id") or event.get("event_id"), + param=event.get("param"), + ) + ] if event_type == "session.closed": return [{"type": "session.closed", "event": event}] return [{"type": f"duplex.{event_type}", "event": event}] @@ -490,7 +519,14 @@ class RealtimeOutputProjector: }, }, ) - payload.setdefault("turn_detection", payload.get("turn_detection")) + if self._turn_detection_configured or "turn_detection" in payload: + turn_detection = payload.get("turn_detection", self._turn_detection) + audio = dict(payload["audio"]) if isinstance(payload.get("audio"), dict) else {} + audio_input = dict(audio["input"]) if isinstance(audio.get("input"), dict) else {} + audio_input["turn_detection"] = turn_detection + audio["input"] = audio_input + payload["audio"] = audio + payload["turn_detection"] = turn_detection payload.setdefault("input_audio_transcription", payload.get("input_audio_transcription")) payload.setdefault("tracing", payload.get("tracing")) return payload diff --git a/vllm_omni/entrypoints/duplex/realtime_session.py b/vllm_omni/entrypoints/duplex/realtime_session.py index c4604ff39..122a7ab52 100644 --- a/vllm_omni/entrypoints/duplex/realtime_session.py +++ b/vllm_omni/entrypoints/duplex/realtime_session.py @@ -42,6 +42,10 @@ class NativeRealtimeSessionProtocol( def bind_sender(self, send_realtime_json) -> None: self._send_realtime_json = send_realtime_json + def bind_native_input_append(self, enabled: bool) -> None: + """Use the serving-selected input path, independent of interruption policy.""" + self._native_input_append = enabled + def _default_session_payload(self) -> dict[str, object]: payload: dict[str, object] = { "model": self._default_model, diff --git a/vllm_omni/entrypoints/duplex/realtime_state.py b/vllm_omni/entrypoints/duplex/realtime_state.py index 4a1e7bdba..babb470a5 100644 --- a/vllm_omni/entrypoints/duplex/realtime_state.py +++ b/vllm_omni/entrypoints/duplex/realtime_state.py @@ -24,6 +24,32 @@ REALTIME_OUTPUT_AUDIO_FORMATS = { "g711_ulaw", "g711_alaw", } +REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ = 24_000 +REALTIME_PCM_F32_DEFAULT_SAMPLE_RATE_HZ = 16_000 +REALTIME_G711_DEFAULT_SAMPLE_RATE_HZ = 8_000 + +_RealtimeProtocolConfig = tuple[str, int, str, int | None, float] +_PendingTurnDetectionUpdate = tuple[ + bool, + dict[str, object] | None, + asyncio.Event, + _RealtimeProtocolConfig, +] + + +def realtime_default_sample_rate_hz(fmt: object) -> int | None: + if not isinstance(fmt, str): + return None + normalized = fmt.lower() + if normalized in {"pcm16", "pcm_s16le", "s16le", "pcm", "audio/pcm"}: + return REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ + if normalized in {"pcm_f32le", "audio/pcm_f32le"}: + return REALTIME_PCM_F32_DEFAULT_SAMPLE_RATE_HZ + if normalized in {"g711_ulaw", "g711_alaw", "audio/g711_ulaw", "audio/g711_alaw"}: + return REALTIME_G711_DEFAULT_SAMPLE_RATE_HZ + return None + + REALTIME_ERROR_TYPES_BY_CODE = { "bad_event": "invalid_request_error", "bad_audio": "invalid_request_error", @@ -43,12 +69,16 @@ REALTIME_ERROR_TYPES_BY_CODE = { "runtime_data_plane_text_without_audio": "server_error", "response_error": "server_error", "chat_error": "server_error", + "server_vad_initialization_failed": "server_error", + "server_vad_inference_failed": "server_error", "duplex_session_busy": "rate_limit_error", "resource_exhausted": "rate_limit_error", + "input_backpressure": "rate_limit_error", "response_already_active": "invalid_request_error", "response_not_active": "invalid_request_error", "response_create_without_input": "invalid_request_error", "input_audio_buffer_empty": "invalid_request_error", + "server_vad_manual_commit_unsupported": "invalid_request_error", "missing_item_id": "invalid_request_error", "item_not_found": "invalid_request_error", "playback_item_mismatch": "invalid_request_error", @@ -63,7 +93,6 @@ REALTIME_ERROR_TYPES_BY_CODE = { "native_text_append_unsupported": "invalid_request_error", "runtime_native_stage_role_required": "server_error", "runtime_native_runner_kv_required": "server_error", - "server_vad_unavailable": "server_error", } @@ -109,12 +138,12 @@ class RealtimeSessionState: _default_session_id: object | None = None _default_extra_body: dict[str, object] = field(default_factory=dict) _input_audio_format: str = "pcm16" - _input_sample_rate_hz: int = 16000 + _input_sample_rate_hz: int = REALTIME_PCM_DEFAULT_SAMPLE_RATE_HZ _output_audio_format: str = "pcm16" _overlap_silence_rms: float = 0.003 _turn_detection: dict[str, object] | None = None - _server_vad: object | None = None - _pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = None + _native_input_append: bool = False + _pending_turn_detection_update: _PendingTurnDetectionUpdate | None = None _send_realtime_json: Any = None _initial_session_update: bool = False _input_speech_started: bool = False @@ -123,13 +152,13 @@ class RealtimeSessionState: _active_response_id: str | None = None _last_response_id: str | None = None _conversation_items: dict[str, dict[str, object]] = field(default_factory=dict) - _pending_commit_item_ids: asyncio.Queue[str] = field(default_factory=asyncio.Queue) _last_conversation_item_id: str | None = None _output_sample_rate_hz: int | None = None _active_input_item_id: str | None = None _input_audio_buffer_has_audio: bool = False _input_audio_buffer_had_non_speech: bool = False _input_audio_buffer_transcript_parts: list[str] = field(default_factory=list) + _turn_detection_configured: bool = False @classmethod def from_query_params(cls, query_params: Any) -> RealtimeSessionState: @@ -197,10 +226,8 @@ class RealtimeStateOwner: _output_audio_format: str = _RealtimeStateField() _overlap_silence_rms: float = _RealtimeStateField() _turn_detection: dict[str, object] | None = _RealtimeStateField() - _server_vad: object | None = _RealtimeStateField() - _pending_turn_detection_update: tuple[dict[str, object] | None, object | None, asyncio.Event] | None = ( - _RealtimeStateField() - ) + _native_input_append: bool = _RealtimeStateField() + _pending_turn_detection_update: _PendingTurnDetectionUpdate | None = _RealtimeStateField() _send_realtime_json: Any = _RealtimeStateField() _initial_session_update: bool = _RealtimeStateField() _input_speech_started: bool = _RealtimeStateField() @@ -209,10 +236,10 @@ class RealtimeStateOwner: _active_response_id: str | None = _RealtimeStateField() _last_response_id: str | None = _RealtimeStateField() _conversation_items: dict[str, dict[str, object]] = _RealtimeStateField() - _pending_commit_item_ids: asyncio.Queue[str] = _RealtimeStateField() _last_conversation_item_id: str | None = _RealtimeStateField() _output_sample_rate_hz: int | None = _RealtimeStateField() _active_input_item_id: str | None = _RealtimeStateField() _input_audio_buffer_has_audio: bool = _RealtimeStateField() _input_audio_buffer_had_non_speech: bool = _RealtimeStateField() _input_audio_buffer_transcript_parts: list[str] = _RealtimeStateField() + _turn_detection_configured: bool = _RealtimeStateField() diff --git a/vllm_omni/entrypoints/duplex/runtime_adapter.py b/vllm_omni/entrypoints/duplex/runtime_adapter.py index 8bf23e1ed..a873f33a8 100644 --- a/vllm_omni/entrypoints/duplex/runtime_adapter.py +++ b/vllm_omni/entrypoints/duplex/runtime_adapter.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable, Mapping, MutableMapping +from dataclasses import dataclass, field from importlib import import_module from typing import Any, Protocol @@ -106,6 +107,104 @@ class ServingRuntimeSessionState(Protocol): def clear_continuation(self) -> None: ... +class _TurnBasedPcmAppendBuffer: + """Empty native-audio buffer for chat-fallback sessions. + + Turn-based sessions keep input audio in ``DuplexSession`` and never use + the model-native append path. The runner still shares lifecycle and + cancellation code with native sessions, so it needs an inert buffer for + those common operations. + """ + + @property + def pending_byte_count(self) -> int: + return 0 + + def clear(self) -> None: + return None + + def clear_force_listen(self) -> None: + return None + + def has_pending(self) -> bool: + return False + + def has_reserved(self) -> bool: + return False + + def prepare_append( + self, + payload: dict[str, object], + *, + operation_id: str, + chunk_period_ms: int, + allow_emit: bool, + ) -> PcmAppendReservation | None: + del payload, operation_id, chunk_period_ms, allow_emit + raise RuntimeError("Model-native audio append is not enabled for this session") + + def prepare_commit( + self, + *, + operation_id: str, + chunk_period_ms: int, + ) -> PcmAppendReservation: + del operation_id, chunk_period_ms + raise RuntimeError("Model-native audio append is not enabled for this session") + + def flush(self, *, chunk_period_ms: int) -> dict[str, object] | None: + del chunk_period_ms + return None + + +@dataclass(slots=True) +class TurnBasedServingSessionState: + """Runner-local state for sessions served through chat fallback.""" + + audio_buffer: PcmAppendBuffer = field(default_factory=_TurnBasedPcmAppendBuffer) + input_since_commit: bool = False + speech_since_commit: bool = False + native_context_locked: bool = False + committed_audio_payload: dict[str, object] | None = None + committed_audio_operation_id: str | None = None + committed_audio_reserved_bytes: int = 0 + deferred_response_create: bool = False + deferred_precreate_response: bool = False + data_plane_task: asyncio.Task[None] | None = None + data_plane_restart_requested: bool = False + continuation_owner_id: str | None = None + continuation_units: int = 0 + pending_silence_task: asyncio.Task[bool] | None = None + pending_silence_owner_id: str | None = None + silence_continuation_scheduler: Callable[..., Awaitable[bool]] | None = None + + def retain_committed_audio( + self, + payload: dict[str, object], + *, + operation_id: str | None, + reserved_bytes: int = 0, + ) -> None: + self.committed_audio_payload = payload + self.committed_audio_operation_id = operation_id + self.committed_audio_reserved_bytes += max(0, int(reserved_bytes)) + + def clear_committed_audio(self) -> int: + reserved_bytes = self.committed_audio_reserved_bytes + self.committed_audio_payload = None + self.committed_audio_operation_id = None + self.committed_audio_reserved_bytes = 0 + self.deferred_response_create = False + self.deferred_precreate_response = False + return reserved_bytes + + def clear_continuation(self) -> None: + self.continuation_owner_id = None + self.continuation_units = 0 + self.pending_silence_task = None + self.pending_silence_owner_id = None + + class RuntimeDataPlane(Protocol): def begin_request(self, request_id: str) -> None: ... diff --git a/vllm_omni/entrypoints/duplex/runtime_bridge.py b/vllm_omni/entrypoints/duplex/runtime_bridge.py index 6db5be236..bf839523c 100644 --- a/vllm_omni/entrypoints/duplex/runtime_bridge.py +++ b/vllm_omni/entrypoints/duplex/runtime_bridge.py @@ -39,6 +39,11 @@ class NativeRuntimeBridgeMixin: } async def _open_runtime_session(self, session: DuplexSession, send_json) -> dict[str, object] | bool: + # Turn-based sessions use the chat-fallback path and do not own a + # model-native duplex runtime. Do not send control messages merely + # because the shared engine client exposes the duplex RPC methods. + if not self._uses_serving_runtime_adapter(session.config): + return True contract_error = self._native_runtime_contract_error(session) if contract_error is not None: await send_json( @@ -535,6 +540,8 @@ class NativeRuntimeBridgeMixin: session_config: dict[str, object] | None = None, runtime_config: dict[str, object] | None = None, ) -> bool: + if not self._uses_serving_runtime_adapter(session.config): + return True signal_turn = getattr(self._chat_service.engine_client, "signal_duplex_turn_async", None) if not callable(signal_turn): return True @@ -582,6 +589,8 @@ class NativeRuntimeBridgeMixin: return True async def _close_runtime_session(self, session: DuplexSession, *, reason: str, send_json=None) -> bool: + if not self._uses_serving_runtime_adapter(session.config): + return True close_session = getattr(self._chat_service.engine_client, "close_duplex_session_async", None) if not callable(close_session): return True @@ -1301,11 +1310,19 @@ class NativeRuntimeBridgeMixin: def _cleanup_duplex_session_state(self, session: DuplexSession) -> None: session_id = session.session_id - self._serving_runtime_adapter.remove_session_state(session_id) - self._serving_runtime_adapter.data_plane.close_session( - session_id, - active_request_id=session.active_request_id, - ) + pipeline = self._server_vad_pipelines.pop(session_id, None) + if pipeline is not None: + pipeline.reset() + self._realtime_vad_metrics.session_finished() + adapter = self._serving_runtime_adapter + if adapter is None: + self._serving_session_states.pop(session_id, None) + else: + adapter.remove_session_state(session_id) + adapter.data_plane.close_session( + session_id, + active_request_id=session.active_request_id, + ) def _encode_native_data_plane_audio( self, diff --git a/vllm_omni/entrypoints/duplex/server_vad.py b/vllm_omni/entrypoints/duplex/server_vad.py new file mode 100644 index 000000000..610e51c8c --- /dev/null +++ b/vllm_omni/entrypoints/duplex/server_vad.py @@ -0,0 +1,528 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import asyncio +import hashlib +import threading +import time +from collections.abc import Mapping +from dataclasses import dataclass, replace +from pathlib import Path +from typing import TYPE_CHECKING, Literal, Protocol + +import numpy as np +from vllm.transformers_utils.repo_utils import try_get_local_file + +if TYPE_CHECKING: + from vllm_omni.entrypoints.openai.audio_utils_mixin import StreamingAudioResampler + +SILERO_VAD_REPO_ID = "istupakov/silero-vad-onnx" +SILERO_VAD_REVISION = "8b14476858ef240c50b3884bb38cc67290c1cc70" +SILERO_VAD_FILENAME = "silero_vad.onnx" +SILERO_VAD_SHA256 = "1a153a22f4509e292a94e67d6f9b85e8deb25b4988682b7e174c65279d8788e3" +SILERO_VAD_MIN_THRESHOLD = 0.15 +SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS = 96 + +_SERVER_VAD_FIELDS = { + "type", + "threshold", + "prefix_padding_ms", + "silence_duration_ms", + "create_response", + "interrupt_response", + "min_speech_duration_ms", +} + + +@dataclass(frozen=True, slots=True) +class ServerVADConfig: + """Validated OpenAI-compatible ``server_vad`` session configuration.""" + + type: Literal["server_vad"] = "server_vad" + threshold: float = 0.5 + prefix_padding_ms: int = 300 + silence_duration_ms: int = 500 + create_response: bool = True + # Omission is resolved from runtime capabilities before the session is opened. + interrupt_response: bool | None = None + min_speech_duration_ms: int | None = None + + @classmethod + def from_value(cls, value: object) -> ServerVADConfig: + if not isinstance(value, dict): + raise ValueError("turn_detection must be null or an object") + unknown = sorted(set(value) - _SERVER_VAD_FIELDS) + if unknown: + raise ValueError(f"Unknown server_vad field(s): {', '.join(unknown)}") + vad_type = value.get("type") + if vad_type != "server_vad": + raise ValueError("turn_detection.type must be 'server_vad'") + + threshold = value.get("threshold", 0.5) + if isinstance(threshold, bool) or not isinstance(threshold, int | float) or not 0 <= threshold <= 1: + raise ValueError("server_vad.threshold must be a number between 0 and 1") + + prefix_padding_ms = value.get("prefix_padding_ms", 300) + if isinstance(prefix_padding_ms, bool) or not isinstance(prefix_padding_ms, int) or prefix_padding_ms < 0: + raise ValueError("server_vad.prefix_padding_ms must be a non-negative integer") + + silence_duration_ms = value.get("silence_duration_ms", 500) + if ( + isinstance(silence_duration_ms, bool) + or not isinstance(silence_duration_ms, int) + or silence_duration_ms <= 0 + ): + raise ValueError("server_vad.silence_duration_ms must be a positive integer") + + create_response = value.get("create_response", True) + if not isinstance(create_response, bool): + raise ValueError("server_vad.create_response must be a boolean") + + interrupt_response = value.get("interrupt_response") + if "interrupt_response" in value and not isinstance(interrupt_response, bool): + raise ValueError("server_vad.interrupt_response must be a boolean") + + min_speech_duration_ms = value.get("min_speech_duration_ms") + if min_speech_duration_ms is not None and ( + isinstance(min_speech_duration_ms, bool) + or not isinstance(min_speech_duration_ms, int | float) + or not np.isfinite(float(min_speech_duration_ms)) + or min_speech_duration_ms < 0 + ): + raise ValueError("server_vad.min_speech_duration_ms must be a non-negative number") + + return cls( + type=vad_type, + threshold=float(threshold), + prefix_padding_ms=prefix_padding_ms, + silence_duration_ms=silence_duration_ms, + create_response=create_response, + interrupt_response=interrupt_response, + min_speech_duration_ms=(int(min_speech_duration_ms) if min_speech_duration_ms is not None else None), + ) + + def as_dict(self) -> dict[str, object]: + result: dict[str, object] = { + "type": self.type, + "threshold": self.threshold, + "prefix_padding_ms": self.prefix_padding_ms, + "silence_duration_ms": self.silence_duration_ms, + "create_response": self.create_response, + } + if self.interrupt_response is not None: + result["interrupt_response"] = self.interrupt_response + if self.min_speech_duration_ms is not None: + result["min_speech_duration_ms"] = self.min_speech_duration_ms + return result + + +def parse_session_turn_detection( + payload: Mapping[str, object], +) -> tuple[bool, ServerVADConfig | None]: + """Extract and validate OpenAI-compatible turn detection aliases.""" + configured_values: list[tuple[str, object]] = [] + if "turn_detection" in payload: + configured_values.append(("turn_detection", payload["turn_detection"])) + audio = payload.get("audio") + if isinstance(audio, Mapping): + audio_input = audio.get("input") + if isinstance(audio_input, Mapping) and "turn_detection" in audio_input: + configured_values.append(("audio.input.turn_detection", audio_input["turn_detection"])) + if not configured_values: + return False, None + + first_path, first_value = configured_values[0] + first_config = None if first_value is None else ServerVADConfig.from_value(first_value) + for field_path, value in configured_values[1:]: + config = None if value is None else ServerVADConfig.from_value(value) + if first_config is not None and config is not None: + # An omitted option must not conflict with an explicit alias value. + if first_config.interrupt_response is None: + first_config = replace(first_config, interrupt_response=config.interrupt_response) + if config.interrupt_response is None: + config = replace(config, interrupt_response=first_config.interrupt_response) + if config != first_config: + raise ValueError(f"{first_path} and {field_path} must not specify conflicting values") + return True, first_config + + +class SpeechDetectorBackend(Protocol): + """Detector contract; per-stream state is owned by the pipeline.""" + + frame_samples: int + + def new_state(self) -> object: ... + + def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: ... + + +class SpeechDetectorBackendProvider(Protocol): + def get(self) -> SpeechDetectorBackend: ... + + +@dataclass(frozen=True, slots=True) +class SpeechEndpointDecision: + speech_started: bool = False + speech_stopped: bool = False + audio_start_ms: int | None = None + audio_end_ms: int | None = None + endpoint_delay_ms: int | None = None + + +class SpeechEndpointPolicy(Protocol): + @property + def speech_active(self) -> bool: ... + + def update( + self, + probability: float, + *, + frame_start_sample: int, + frame_samples: int, + ) -> SpeechEndpointDecision: ... + + def reset(self) -> None: ... + + +class ThresholdEndpointPolicy: + """Apply threshold and trailing-silence endpoint rules.""" + + def __init__(self, config: ServerVADConfig, *, sample_rate_hz: int) -> None: + self.config = config + self.sample_rate_hz = sample_rate_hz + self._speech_active = False + self._candidate_samples = 0 + self._candidate_start_sample: int | None = None + self._silence_samples = 0 + + @property + def speech_active(self) -> bool: + return self._speech_active + + def update( + self, + probability: float, + *, + frame_start_sample: int, + frame_samples: int, + ) -> SpeechEndpointDecision: + if probability >= self.config.threshold: + self._silence_samples = 0 + if not self._speech_active: + if self._candidate_samples == 0: + self._candidate_start_sample = frame_start_sample + self._candidate_samples += frame_samples + min_speech_duration_ms = self.config.min_speech_duration_ms or 0 + min_speech_samples = max(1, round(min_speech_duration_ms * self.sample_rate_hz / 1000)) + if self._candidate_samples < min_speech_samples: + return SpeechEndpointDecision() + self._speech_active = True + detected_start_sample = ( + self._candidate_start_sample if self._candidate_start_sample is not None else frame_start_sample + ) + detected_start_ms = round(detected_start_sample * 1000 / self.sample_rate_hz) + self._candidate_samples = 0 + self._candidate_start_sample = None + return SpeechEndpointDecision( + speech_started=True, + audio_start_ms=max(0, detected_start_ms - self.config.prefix_padding_ms), + ) + return SpeechEndpointDecision() + + if not self._speech_active: + self._candidate_samples = 0 + self._candidate_start_sample = None + return SpeechEndpointDecision() + + # Match Silero v6.2's streaming VAD hysteresis: activation uses the + # configured threshold, while potential speech end starts only below + # ``max(threshold - 0.15, 0.01)``. Once a silence candidate exists, + # intermediate frames keep elapsed time moving but cannot themselves + # close the turn. + negative_threshold = max(self.config.threshold - SILERO_VAD_MIN_THRESHOLD, 0.01) + below_negative_threshold = probability < negative_threshold + if not below_negative_threshold and self._silence_samples == 0: + return SpeechEndpointDecision() + + self._silence_samples += frame_samples + if not below_negative_threshold: + return SpeechEndpointDecision() + silence_limit = max(1, round(self.config.silence_duration_ms * self.sample_rate_hz / 1000)) + if self._silence_samples < silence_limit: + return SpeechEndpointDecision() + + # OpenAI defines ``audio_end_ms`` as the end of the audio sent to the + # model, including the trailing silence used for endpoint detection. + audio_end_sample = frame_start_sample + frame_samples + endpoint_delay_ms = round(self._silence_samples * 1000 / self.sample_rate_hz) + self.reset() + return SpeechEndpointDecision( + speech_stopped=True, + audio_end_ms=max(0, round(audio_end_sample * 1000 / self.sample_rate_hz)), + endpoint_delay_ms=endpoint_delay_ms, + ) + + def reset(self) -> None: + self._speech_active = False + self._candidate_samples = 0 + self._candidate_start_sample = None + self._silence_samples = 0 + + +@dataclass(frozen=True, slots=True) +class ServerVADFrame: + samples: np.ndarray + decision: SpeechEndpointDecision + + +@dataclass(frozen=True, slots=True) +class ServerVADBatch: + frames: tuple[ServerVADFrame, ...] + inference_ms: float + + +class ServerVADPipeline: + """Frame continuous 16 kHz audio and apply acoustic endpointing. + + The pipeline retains only input-resampling residuals, a partial frame, + detector state, and timing counters. Commit-eligible prefix and utterance + audio remain session-owned. + """ + + sample_rate_hz = 16_000 + + def __init__( + self, + backend: SpeechDetectorBackend, + config: ServerVADConfig, + *, + endpoint_policy: SpeechEndpointPolicy | None = None, + ) -> None: + self.backend = backend + self.config = config + self.endpoint_policy = endpoint_policy or ThresholdEndpointPolicy( + config, + sample_rate_hz=self.sample_rate_hz, + ) + self._input_resampler: StreamingAudioResampler | None = None + self._source_sample_rate_hz: int | None = None + self._scratch = np.empty(0, dtype=np.float32) + self._detector_state = backend.new_state() + self._processed_samples = 0 + self._stream_start_ms = 0 + + @property + def speech_active(self) -> bool: + return self.endpoint_policy.speech_active + + @property + def source_sample_rate_hz(self) -> int | None: + return self._source_sample_rate_hz + + @property + def scratch_bytes(self) -> int: + resampler_bytes = self._input_resampler.scratch_bytes if self._input_resampler is not None else 0 + return int(self._scratch.nbytes) + resampler_bytes + + async def push_pcm16( + self, + samples: bytes, + *, + source_sample_rate_hz: int, + ) -> ServerVADBatch: + """Normalize chunked mono PCM16 input and run endpoint detection.""" + if isinstance(source_sample_rate_hz, bool) or source_sample_rate_hz not in {16_000, 24_000}: + raise ValueError("server_vad PCM16 input sample rate must be 16000 or 24000 Hz") + + if len(samples) % np.dtype(" ServerVADBatch: + normalized = np.ascontiguousarray(samples, dtype=np.float32).reshape(-1) + return await asyncio.to_thread(self._push_sync, normalized) + + def _push_sync(self, samples: np.ndarray) -> ServerVADBatch: + started_at = time.perf_counter() + if self._scratch.size: + samples = np.concatenate((self._scratch, samples)) + frame_samples = int(self.backend.frame_samples) + complete_samples = samples.size - samples.size % frame_samples + # Retained state must not keep the whole input chunk alive through a view. + self._scratch = samples[complete_samples:].copy() + + results: list[ServerVADFrame] = [] + for offset in range(0, complete_samples, frame_samples): + frame = np.ascontiguousarray(samples[offset : offset + frame_samples], dtype=np.float32) + frame_start = self._processed_samples + probability, self._detector_state = self.backend.infer(frame, self._detector_state) + probability = min(1.0, max(0.0, float(probability))) + decision = self.endpoint_policy.update( + probability, + frame_start_sample=frame_start, + frame_samples=frame_samples, + ) + if decision.speech_started and decision.audio_start_ms is not None: + # Prefix audio before the most recent reset is no longer available. + decision = replace(decision, audio_start_ms=max(self._stream_start_ms, decision.audio_start_ms)) + + self._processed_samples += frame_samples + results.append( + ServerVADFrame( + samples=frame, + decision=decision, + ) + ) + return ServerVADBatch( + frames=tuple(results), + inference_ms=(time.perf_counter() - started_at) * 1000, + ) + + def reset(self) -> None: + """Discard stream state while preserving the session-relative audio clock.""" + self._stream_start_ms = round(self._processed_samples * 1000 / self.sample_rate_hz) + self._input_resampler = None + self._source_sample_rate_hz = None + self._scratch = np.empty(0, dtype=np.float32) + self._detector_state = self.backend.new_state() + self.endpoint_policy.reset() + + +@dataclass(frozen=True, slots=True) +class _SileroVADState: + model_state: np.ndarray + context: np.ndarray + + +class SileroVADBackend: + """Shared ONNX Runtime Silero v6.2 detector running on CPU.""" + + sample_rate_hz = 16_000 + frame_samples = 512 + context_samples = 64 + model_state_shape = (2, 1, 128) + + def __init__(self, model_path: str | Path) -> None: + try: + import onnxruntime as ort + except ImportError as exc: # pragma: no cover - platform packaging supplies ORT. + raise RuntimeError("server_vad requires ONNX Runtime") from exc + + session_options = ort.SessionOptions() + session_options.inter_op_num_threads = 1 + session_options.intra_op_num_threads = 1 + + self.model_path = Path(model_path) + self._session = ort.InferenceSession( + str(self.model_path), + providers=["CPUExecutionProvider"], + sess_options=session_options, + ) + input_names = {item.name for item in self._session.get_inputs()} + if "input" not in input_names or "sr" not in input_names or "state" not in input_names: + raise RuntimeError(f"Unsupported Silero ONNX input contract: {sorted(input_names)}") + self._warm_up() + + def _warm_up(self) -> None: + self.infer(np.zeros(self.frame_samples, dtype=np.float32), self.new_state()) + + def new_state(self) -> _SileroVADState: + return _SileroVADState( + model_state=np.zeros(self.model_state_shape, dtype=np.float32), + context=np.zeros((1, self.context_samples), dtype=np.float32), + ) + + def infer(self, frame: np.ndarray, state: object) -> tuple[float, object]: + if not isinstance(state, _SileroVADState): + raise TypeError("Silero detector state must be created by SileroVADBackend.new_state()") + model_state = np.ascontiguousarray(state.model_state, dtype=np.float32) + context = np.ascontiguousarray(state.context, dtype=np.float32) + if model_state.shape != self.model_state_shape: + raise ValueError(f"Silero model state must have shape {self.model_state_shape}, got {model_state.shape}") + expected_context_shape = (1, self.context_samples) + if context.shape != expected_context_shape: + raise ValueError(f"Silero context must have shape {expected_context_shape}, got {context.shape}") + + audio = np.ascontiguousarray(frame, dtype=np.float32).reshape(1, -1) + if audio.shape[1] != self.frame_samples: + raise ValueError( + f"Silero detector frame must contain exactly {self.frame_samples} samples, got {audio.shape[1]}" + ) + model_input = np.concatenate((context, audio), axis=1) + inputs = { + "input": model_input, + "state": model_state, + "sr": np.asarray(self.sample_rate_hz, dtype=np.int64), + } + # ONNX Runtime permits concurrent Run calls on one CPU session. Stream + # state is explicit, so independent pipelines can safely share the session. + output = self._session.run(None, inputs) + if len(output) < 2: + raise RuntimeError("Silero ONNX model did not return probability and model state") + probability = float(np.asarray(output[0]).reshape(-1)[0]) + next_state = _SileroVADState( + model_state=np.ascontiguousarray(output[1], dtype=np.float32), + context=np.ascontiguousarray(model_input[:, -self.context_samples :], dtype=np.float32), + ) + return probability, next_state + + +class SileroVADBackendProvider: + """Resolve, verify, and load one Silero model instance per process.""" + + def __init__(self, *, model_path: str | None = None) -> None: + self.model_path = model_path + self._backend: SileroVADBackend | None = None + self._lock = threading.Lock() + + def get(self) -> SileroVADBackend: + if self._backend is not None: + return self._backend + with self._lock: + if self._backend is None: + path = self._resolve_local_artifact() + self._verify_checksum(path) + self._backend = SileroVADBackend(path) + return self._backend + + def _resolve_local_artifact(self) -> Path: + if self.model_path: + path = Path(self.model_path).expanduser() + if path.is_file(): + return path + raise RuntimeError(f"Configured Silero VAD model does not exist: {path}") + cached = try_get_local_file( + model=SILERO_VAD_REPO_ID, + file_name=SILERO_VAD_FILENAME, + revision=SILERO_VAD_REVISION, + ) + if isinstance(cached, Path) and cached.is_file(): + return cached + raise RuntimeError( + "Silero VAD artifact is not available locally. Pre-download the pinned artifact " + "or configure duplex_session.server_vad_model_path." + ) + + def _verify_checksum(self, path: Path) -> None: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != SILERO_VAD_SHA256: + raise RuntimeError(f"Silero VAD checksum mismatch for {path}: expected {SILERO_VAD_SHA256}, got {digest}") diff --git a/vllm_omni/entrypoints/duplex/serving.py b/vllm_omni/entrypoints/duplex/serving.py index d1a028bd4..7bd690841 100644 --- a/vllm_omni/entrypoints/duplex/serving.py +++ b/vllm_omni/entrypoints/duplex/serving.py @@ -8,9 +8,9 @@ import base64 import binascii import inspect import json -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping from contextlib import suppress -from dataclasses import dataclass +from dataclasses import dataclass, replace from typing import TYPE_CHECKING import numpy as np @@ -49,12 +49,21 @@ from vllm_omni.entrypoints.duplex.runtime_adapter import ( ServingRuntimeAdapter, ServingRuntimeConfigError, ServingRuntimeSessionState, + TurnBasedServingSessionState, load_serving_runtime_adapter, validate_serving_runtime_adapter, ) from vllm_omni.entrypoints.duplex.runtime_bridge import ( NativeRuntimeBridgeMixin, ) +from vllm_omni.entrypoints.duplex.server_vad import ( + SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS, + SILERO_VAD_MIN_THRESHOLD, + ServerVADPipeline, + SileroVADBackendProvider, + SpeechDetectorBackendProvider, + parse_session_turn_detection, +) from vllm_omni.entrypoints.duplex.session_attachment import ( DuplexJournalGapError, DuplexSessionAttachmentRegistry, @@ -68,6 +77,7 @@ from vllm_omni.entrypoints.duplex.websocket import ( DuplexSessionTasks, DuplexWebSocketActor, ) +from vllm_omni.metrics.realtime import RealtimeVADMetrics if TYPE_CHECKING: from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat @@ -85,6 +95,7 @@ class _DuplexSessionHandshake: session: DuplexSession resumed: bool = False attachment_generation: int | None = None + event_id: str | None = None class OmniDuplexSessionHandler( @@ -105,16 +116,27 @@ class OmniDuplexSessionHandler( self, *, chat_service: OmniOpenAIServingChat, + served_model_name: str | None = None, config_timeout_s: float = _DEFAULT_CONFIG_TIMEOUT_S, idle_timeout_s: float = _DEFAULT_IDLE_TIMEOUT_S, + log_stats: bool = True, duplex_session_config: DuplexSessionRuntimeConfig | None = None, serving_runtime_adapter: ServingRuntimeAdapter | None = None, serving_runtime_adapter_path: str | None = None, + server_vad_backend_provider: SpeechDetectorBackendProvider | None = None, ) -> None: self._chat_service = chat_service self._config_timeout_s = config_timeout_s self._idle_timeout_s = idle_timeout_s self._duplex_session_config = duplex_session_config or DuplexSessionRuntimeConfig() + self._server_vad_backend_provider = server_vad_backend_provider or SileroVADBackendProvider( + model_path=self._duplex_session_config.server_vad_model_path, + ) + self._server_vad_pipelines: dict[str, ServerVADPipeline] = {} + self._realtime_vad_metrics = RealtimeVADMetrics( + served_model_name or chat_service.model_config.model, + log_stats=log_stats, + ) self._registry = DuplexSessionRegistry( DuplexCapabilities( supports_model_native_turn_policy=False, @@ -131,6 +153,7 @@ class OmniDuplexSessionHandler( "duplex_serving_adapter_path", None, ) + self._serving_runtime_adapter: ServingRuntimeAdapter | None if serving_runtime_adapter is not None: self._serving_runtime_adapter = validate_serving_runtime_adapter(serving_runtime_adapter) elif isinstance(adapter_path, str) and adapter_path: @@ -139,7 +162,10 @@ class OmniDuplexSessionHandler( self._encode_native_data_plane_audio, ) else: - raise ValueError("A duplex serving runtime adapter must be explicitly configured") + self._serving_runtime_adapter = None + self._serving_session_states: MutableMapping[str, ServingRuntimeSessionState] = ( + self._serving_runtime_adapter.session_states if self._serving_runtime_adapter is not None else {} + ) self._session_tasks: dict[str, DuplexSessionTasks] = {} self._realtime_protocols: dict[str, NativeRealtimeSessionProtocol] = {} self._lease_generations: dict[str, int] = {} @@ -223,7 +249,7 @@ class OmniDuplexSessionHandler( active_response_task.cancel() with suppress(asyncio.CancelledError): await active_response_task - native = self._serving_runtime_adapter.session_states.get(session.session_id) + native = self._serving_session_states.get(session.session_id) if native is not None and native.data_plane_task is not None: data_plane_task = native.data_plane_task native.data_plane_task = None @@ -794,8 +820,27 @@ class OmniDuplexSessionHandler( duration_ms = cls._input_audio_duration_ms(event, payload) return 0 < duration_ms <= session.config.overlap_short_ack_ms + def _create_runtime_session_state(self) -> ServingRuntimeSessionState: + adapter = self._serving_runtime_adapter + if adapter is not None: + return adapter.create_session_state() + return TurnBasedServingSessionState() + def _runtime_session_state(self, session: DuplexSession) -> ServingRuntimeSessionState: - return self._serving_runtime_adapter.session_state(session.session_id) + adapter = self._serving_runtime_adapter + if adapter is not None: + return adapter.session_state(session.session_id) + state = self._serving_session_states.get(session.session_id) + if state is None: + state = TurnBasedServingSessionState() + self._serving_session_states[session.session_id] = state + return state + + def _require_serving_runtime_adapter(self) -> ServingRuntimeAdapter: + adapter = self._serving_runtime_adapter + if adapter is None: + raise RuntimeError("Model-native duplex runtime is not configured for this session") + return adapter def _should_force_listen_for_auto_response_overlap( self, @@ -823,21 +868,25 @@ class OmniDuplexSessionHandler( *, session: DuplexSession, ) -> bool: - for key in ("is_speech", "speech"): - value = event.get(key) - if isinstance(value, bool): - return value - vad = event.get("vad") - if isinstance(vad, dict): - value = vad.get("is_speech") - if isinstance(value, bool): - return value - probability = vad.get("speech_probability", vad.get("probability")) + # Prefer serving-derived fields in ``payload`` over optional client + # hints retained in ``event``. In server-VAD mode the pipeline, rather + # than the client or the RMS fallback below, owns speech classification. + for source in (payload, event): + for key in ("is_speech", "speech"): + value = source.get(key) + if isinstance(value, bool): + return value + vad = source.get("vad") + if isinstance(vad, dict): + value = vad.get("is_speech") + if isinstance(value, bool): + return value + probability = vad.get("speech_probability", vad.get("probability")) + if isinstance(probability, int | float): + return float(probability) >= 0.5 + probability = source.get("speech_probability") if isinstance(probability, int | float): return float(probability) >= 0.5 - probability = event.get("speech_probability") - if isinstance(probability, int | float): - return float(probability) >= 0.5 fmt = payload.get("format") audio = payload.get("audio") @@ -925,14 +974,26 @@ class OmniDuplexSessionHandler( ) return None - config = DuplexSessionConfig.from_event(event) + try: + config = DuplexSessionConfig.from_event(event) + except ValueError as exc: + error: dict[str, object] = { + "type": "error", + "error": str(exc), + "code": "invalid_request_error", + } + event_id = event.get("_realtime_event_id") + if isinstance(event_id, str) and event_id: + error["event_id"] = event_id + await send_json(error) + return None if config.idle_timeout_s == _DEFAULT_IDLE_TIMEOUT_S: config.idle_timeout_s = self._idle_timeout_s - use_native_runtime = self._uses_serving_runtime_adapter(config) + runtime_adapter = self._serving_runtime_adapter if self._uses_serving_runtime_adapter(config) else None runtime_config: dict[str, object] = {} - if use_native_runtime: + if runtime_adapter is not None: try: - runtime_config = await self._serving_runtime_adapter.prepare_runtime_config( + runtime_config = await runtime_adapter.prepare_runtime_config( config, model_config=getattr(self._chat_service, "model_config", None), ) @@ -944,14 +1005,20 @@ class OmniDuplexSessionHandler( return None session_id = event.get("session_id") if isinstance(event.get("session_id"), str) else None session = self._registry.create(config=config, session_id=session_id) - if use_native_runtime: + if runtime_adapter is not None: session.replace_capabilities( - self._serving_runtime_adapter.capabilities( - max_sessions=self._duplex_session_config.max_sessions, - ) + runtime_adapter.capabilities(max_sessions=self._duplex_session_config.max_sessions) ) session.replace_runtime_config(runtime_config) - return _DuplexSessionHandshake(session=session) + session_payload = event.get("session") + self._resolve_server_vad_defaults( + session, config, session_payload if isinstance(session_payload, dict) else event + ) + event_id = event.get("_realtime_event_id") + return _DuplexSessionHandshake( + session=session, + event_id=event_id if isinstance(event_id, str) and event_id else None, + ) async def _resume_session_handshake( self, @@ -1151,7 +1218,8 @@ class OmniDuplexSessionHandler( return None def _uses_serving_runtime_adapter(self, config: DuplexSessionConfig) -> bool: - return self._serving_runtime_adapter.is_enabled(config) + adapter = self._serving_runtime_adapter + return adapter is not None and adapter.is_enabled(config) def _runtime_session_update_error( self, @@ -1161,7 +1229,7 @@ class OmniDuplexSessionHandler( if not self._uses_native_input_append(session): return None try: - self._serving_runtime_adapter.validate_client_extra_body(payload.get("extra_body")) + self._require_serving_runtime_adapter().validate_client_extra_body(payload.get("extra_body")) except ServingRuntimeConfigError as exc: return { "type": "error", @@ -1178,24 +1246,109 @@ class OmniDuplexSessionHandler( ) -> dict[str, object]: if not self._uses_native_input_append(session): return dict(session.runtime_config) - return self._serving_runtime_adapter.runtime_config_for_update( + return self._require_serving_runtime_adapter().runtime_config_for_update( candidate_config, dict(session.runtime_config), ) + def _resolve_server_vad_defaults( + self, + session: DuplexSession, + config: DuplexSessionConfig, + payload: dict[str, object], + ) -> None: + """Resolve omitted options without overriding explicit client policies.""" + if config.server_vad is None: + return + if config.server_vad.interrupt_response is None: + config.server_vad = replace(config.server_vad, interrupt_response=self._uses_native_input_append(session)) + extra = payload.get("extra_body") + overlap_policy = extra.get("overlap_policy") if isinstance(extra, dict) else None + if not isinstance(overlap_policy, str): + overlap_policy = payload.get("overlap_policy") + config.overlap_policy = ( + overlap_policy + if isinstance(overlap_policy, str) + else "barge_in_on_speech" + if config.server_vad.interrupt_response + else "listen_only" + ) + def _runtime_session_candidate_update_error( self, session: DuplexSession, candidate_config: DuplexSessionConfig, + *, + realtime_protocol: NativeRealtimeSessionProtocol | None = None, ) -> dict[str, object] | None: + if candidate_config.server_vad is not None: + desired_policy = "barge_in_on_speech" if candidate_config.server_vad.interrupt_response else "listen_only" + if candidate_config.overlap_policy != desired_policy: + return { + "type": "error", + "code": "unsupported_turn_detection", + "error": f"overlap_policy conflicts with turn_detection; expected {desired_policy!r}", + "param": "overlap_policy", + } if not self._uses_native_input_append(session): - if candidate_config.overlap_policy != DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value: - return None - return { - "type": "error", - "code": "server_vad_requires_native_duplex", - "error": "Realtime server_vad requires a model-native duplex runtime", - } + if ( + candidate_config.server_vad is not None + and candidate_config.server_vad.min_speech_duration_ms is not None + ): + return { + "type": "error", + "code": "unsupported_turn_detection", + "error": "server_vad.min_speech_duration_ms is only supported by model-native duplex runtimes", + } + requests_interruption = ( + candidate_config.server_vad is not None and candidate_config.server_vad.interrupt_response + ) or candidate_config.overlap_policy == DuplexOverlapPolicy.BARGE_IN_ON_SPEECH.value + if requests_interruption: + return { + "type": "error", + "code": "server_vad_requires_native_duplex", + "error": ( + "Realtime server_vad interruption requires a model-native duplex runtime; " + "set interrupt_response=false for turn-based models" + ), + } + if candidate_config.server_vad is not None and realtime_protocol is not None: + session_payload = candidate_config.extra_body.get("realtime_session_payload") + audio_error = realtime_protocol.validate_server_vad_input_audio( + session_payload if isinstance(session_payload, dict) else {} + ) + if audio_error is not None: + return { + "type": "error", + "code": "unsupported_turn_detection", + "error": audio_error, + "param": "audio.input.format", + } + return None + if candidate_config.server_vad is not None: + if not candidate_config.server_vad.interrupt_response: + return { + "type": "error", + "session_id": session.session_id, + "code": "unsupported_turn_detection", + "error": "Model-native duplex server_vad requires interrupt_response=true", + } + if candidate_config.server_vad.threshold <= SILERO_VAD_MIN_THRESHOLD: + return { + "type": "error", + "session_id": session.session_id, + "code": "unsupported_turn_detection", + "error": ( + f"Model-native duplex server_vad.threshold must be greater than {SILERO_VAD_MIN_THRESHOLD}" + ), + } + if not candidate_config.server_vad.create_response: + return { + "type": "error", + "session_id": session.session_id, + "code": "unsupported_turn_detection", + "error": "Model-native duplex server_vad requires create_response=true", + } if ( candidate_config.instructions != session.config.instructions and self._runtime_session_state(session).native_context_locked @@ -1280,7 +1433,7 @@ class OmniDuplexSessionHandler( session: DuplexSession, committed: DuplexCommittedInput, *, - realtime_item_id: object | None = None, + item_id: object | None = None, ) -> dict[str, object]: payload: dict[str, object] = { "type": "input.committed", @@ -1290,15 +1443,15 @@ class OmniDuplexSessionHandler( "history_len": len(session.history), "message": committed.message, } - if isinstance(realtime_item_id, str) and realtime_item_id: - payload["realtime_item_id"] = realtime_item_id + if isinstance(item_id, str) and item_id: + payload["item_id"] = item_id return payload @staticmethod def _commit_native_audio_input( session: DuplexSession, *, - realtime_item_id: object | None = None, + item_id: object | None = None, transcript: object | None = None, turn_id: int | None = None, ) -> DuplexCommittedInput: @@ -1307,8 +1460,8 @@ class OmniDuplexSessionHandler( transcript=clean_transcript or None, turn_id=turn_id, ) - if isinstance(realtime_item_id, str) and realtime_item_id: - session.register_history_item(realtime_item_id, committed.message) + if isinstance(item_id, str) and item_id: + session.register_history_item(item_id, committed.message) return committed @staticmethod @@ -1316,7 +1469,7 @@ class OmniDuplexSessionHandler( session: DuplexSession, *, committed: DuplexCommittedInput | None = None, - realtime_item_id: object | None = None, + item_id: object | None = None, transcript: object | None = None, ) -> dict[str, object]: message = committed.message if committed is not None else None @@ -1342,12 +1495,15 @@ class OmniDuplexSessionHandler( } if isinstance(transcript, str) and transcript: payload["transcript"] = transcript - if isinstance(realtime_item_id, str) and realtime_item_id: - payload["realtime_item_id"] = realtime_item_id + if isinstance(item_id, str) and item_id: + payload["item_id"] = item_id return payload - @staticmethod - def _apply_session_update(session: DuplexSession, payload: dict[str, object]) -> dict[str, object] | None: + def _apply_session_update( + self, + session: DuplexSession, + payload: dict[str, object], + ) -> dict[str, object] | None: model = payload.get("model") audio_config = payload.get("audio") audio_input = audio_config.get("input") if isinstance(audio_config, dict) else None @@ -1385,6 +1541,14 @@ class OmniDuplexSessionHandler( extra_body_payload = normalize_native_duplex_key(dict(extra_body_payload)) payload = {**payload, "extra_body": extra_body_payload} if isinstance(extra_body_payload, dict) and NATIVE_DUPLEX_KEY in extra_body_payload: + requested_native_duplex = extra_body_payload[NATIVE_DUPLEX_KEY] + if not isinstance(requested_native_duplex, bool): + return { + "type": "error", + "session_id": session.session_id, + "code": "invalid_request_error", + "error": "extra_body.native_duplex must be a boolean", + } current_native_duplex = native_duplex_opt_in(session.config.extra_body) if current_native_duplex is not None and extra_body_payload[NATIVE_DUPLEX_KEY] != current_native_duplex: return { @@ -1498,8 +1662,69 @@ class OmniDuplexSessionHandler( session.config.extra_body["realtime_session_payload"] = ( NativeRealtimeSessionProtocol._json_safe_realtime_payload(payload) ) + try: + turn_detection_configured, server_vad = parse_session_turn_detection(payload) + if turn_detection_configured: + vad_config = replace(session.config, server_vad=server_vad) + self._resolve_server_vad_defaults(session, vad_config, payload) + server_vad = vad_config.server_vad + if ( + not self._uses_native_input_append(session) + and session.turn_detection_config_locked + and (not session.config.turn_detection_configured or server_vad != session.config.server_vad) + ): + return { + "type": "error", + "session_id": session.session_id, + "code": "unsupported_turn_detection", + "error": "turn_detection cannot be changed after the first audio append", + } + session.config.turn_detection_configured = True + session.config.server_vad = server_vad + session.config.overlap_policy = vad_config.overlap_policy + except ValueError as exc: + return { + "type": "error", + "session_id": session.session_id, + "code": "invalid_request_error", + "error": str(exc), + } return None + async def _configure_server_vad(self, session: DuplexSession) -> None: + pipeline = await self._prepare_server_vad_pipeline(session, session.config) + self._install_server_vad_pipeline(session, pipeline) + + def _install_server_vad_pipeline( + self, + session: DuplexSession, + pipeline: ServerVADPipeline | None, + ) -> None: + existing = self._server_vad_pipelines.pop(session.session_id, None) + if existing is not None: + existing.reset() + self._realtime_vad_metrics.session_finished() + if pipeline is None: + return + self._server_vad_pipelines[session.session_id] = pipeline + self._realtime_vad_metrics.session_started() + + async def _prepare_server_vad_pipeline( + self, + session: DuplexSession, + config: DuplexSessionConfig, + ) -> ServerVADPipeline | None: + if config.server_vad is None: + return None + backend = await asyncio.to_thread(self._server_vad_backend_provider.get) + pipeline_config = config.server_vad + if self._uses_native_input_append(session) and pipeline_config.min_speech_duration_ms is None: + pipeline_config = replace( + pipeline_config, + min_speech_duration_ms=SILERO_VAD_DEFAULT_MIN_SPEECH_DURATION_MS, + ) + return ServerVADPipeline(backend, pipeline_config) + def _apply_response_create_options( self, session: DuplexSession, @@ -1580,7 +1805,7 @@ class OmniDuplexSessionHandler( response_extra.update( (key, value) for key, value in extra_body.items() - if key not in self._serving_runtime_adapter.private_runtime_config_keys + if key not in self._require_serving_runtime_adapter().private_runtime_config_keys ) else: response_extra.update(extra_body) @@ -1841,7 +2066,8 @@ class OmniDuplexSessionHandler( # Epoch-scoped request ids prevent state reuse, but explicitly # release projector/parser cursors so cancelled epochs do not # accumulate until the whole session closes. - self._serving_runtime_adapter.data_plane.close_stream(old_request_id) + if self._serving_runtime_adapter is not None: + self._serving_runtime_adapter.data_plane.close_stream(old_request_id) await self._abort_request_background( session, old_request_id, diff --git a/vllm_omni/entrypoints/duplex/session_runner.py b/vllm_omni/entrypoints/duplex/session_runner.py index 821e2c9c7..ed18d8591 100644 --- a/vllm_omni/entrypoints/duplex/session_runner.py +++ b/vllm_omni/entrypoints/duplex/session_runner.py @@ -4,17 +4,23 @@ from __future__ import annotations import asyncio +import binascii import json import uuid from contextlib import suppress from copy import deepcopy +import numpy as np +import pybase64 as base64 from fastapi import WebSocket, WebSocketDisconnect from vllm.logger import init_logger from vllm_omni.engine.duplex.lease import DuplexLeaseActivity from vllm_omni.engine.duplex.messages import DuplexFence -from vllm_omni.entrypoints.duplex.audio import convert_input_audio_with_rate +from vllm_omni.entrypoints.duplex.audio import ( + convert_input_audio_with_rate, + validate_input_sample_rate_hz, +) from vllm_omni.entrypoints.duplex.commit_policy import ( CommitAction, CommitSnapshot, @@ -36,6 +42,7 @@ from vllm_omni.entrypoints.duplex.runtime_adapter import ( ServingRuntimeSessionState, payload_turn_id, ) +from vllm_omni.entrypoints.duplex.server_vad import ServerVADPipeline from vllm_omni.entrypoints.duplex.session_attachment import ( DuplexJournalOverflowError, ) @@ -138,7 +145,7 @@ class DuplexSessionRunnerMixin: await actor.send_json(raw_payload) realtime_protocol.bind_sender(send_realtime_raw) - native: ServingRuntimeSessionState = self._serving_runtime_adapter.create_session_state() + native: ServingRuntimeSessionState = self._create_runtime_session_state() def begin_close(reason: str) -> None: actor.closing = True @@ -291,6 +298,120 @@ class DuplexSessionRunnerMixin: return True return False + def start_chat_response() -> None: + assert session is not None + + async def run_and_notify() -> None: + task = asyncio.current_task() + try: + await self._run_response(session, emit_event) + finally: + await actor.enqueue_event( + { + "type": "__chat_response_finished__", + "_response_task": task, + } + ) + + actor.active_response_task = asyncio.create_task(run_and_notify()) + + def activate_waiting_server_vad_turn() -> None: + assert session is not None + pending_turn = session.activate_server_vad_turn() + if pending_turn is not None and pending_turn.create_response: + start_chat_response() + + def correlate_realtime_error( + payload: dict[str, object], + event_id: object, + ) -> dict[str, object]: + if isinstance(event_id, str) and event_id: + payload["event_id"] = event_id + return payload + + async def reject_and_clear_server_vad_input( + pipeline: ServerVADPipeline, + *, + code: str, + error: str, + metric_reason: str, + event_id: object, + ) -> None: + assert session is not None + session.cancel_pending_input() + pipeline.reset() + self._realtime_vad_metrics.error(metric_reason) + await emit_event( + correlate_realtime_error( + { + "type": "error", + "session_id": session.session_id, + "code": code, + "error": error, + }, + event_id, + ) + ) + await emit_event( + { + "type": "input_audio_buffer.cleared", + "session_id": session.session_id, + } + ) + + async def commit_server_vad_turn( + *, + item_id: str, + create_response: bool, + retain_input_bytes: int, + event_id: object = None, + ) -> None: + assert session is not None + # Retain ownership until the actor consumes the terminal event so + # queued audio cannot overtake pending-turn activation. + active_response = actor.active_response_task is not None + if active_response and session.pending_server_vad_turn is not None: + released = session.discard_uncommitted_server_vad_utterance() + session.release_input_bytes(released) + self._realtime_vad_metrics.error("pending_turn_backpressure") + await emit_event( + correlate_realtime_error( + { + "type": "error", + "session_id": session.session_id, + "code": "input_backpressure", + "error": "Only one committed Server VAD turn may wait for the active response", + }, + event_id, + ) + ) + return + if not session.stage_server_vad_audio_for_commit(): + return + committed = session.commit_user_input( + append_history=not active_response, + retain_input_bytes=retain_input_bytes, + ) + if committed is None: + return + if active_response: + session.stage_server_vad_turn( + committed, + create_response=create_response, + item_id=item_id, + ) + else: + session.register_history_item(item_id, committed.message) + await emit_event( + self._input_committed_payload( + session, + committed, + item_id=item_id, + ) + ) + if create_response and not active_response: + start_chat_response() + def clear_completed_pending_silence() -> None: task = native.pending_silence_task if task is not None and task.done(): @@ -679,11 +800,18 @@ class DuplexSessionRunnerMixin: if handshake is None: return session = handshake.session - if initial_update_error := self._runtime_session_candidate_update_error(session, session.config): - await emit_event(initial_update_error) + if initial_update_error := self._runtime_session_candidate_update_error( + session, session.config, realtime_protocol=realtime_protocol + ): + await emit_event( + correlate_realtime_error( + initial_update_error, + handshake.event_id, + ) + ) return if handshake.resumed: - native = self._serving_runtime_adapter.session_states[session.session_id] + native = self._serving_session_states[session.session_id] actor.tasks = self._session_tasks[session.session_id] persisted_protocol = self._realtime_protocols.get(session.session_id) if persisted_protocol is None: @@ -697,18 +825,36 @@ class DuplexSessionRunnerMixin: self._ensure_lifecycle_listener() reader_task = asyncio.create_task(read_event_loop(), name="duplex-session-reader") else: - self._serving_runtime_adapter.session_states[session.session_id] = native + self._serving_session_states[session.session_id] = native self._session_tasks[session.session_id] = actor.tasks if realtime_protocol is not None: self._realtime_protocols[session.session_id] = realtime_protocol native.silence_continuation_scheduler = schedule_native_silence_continuation if realtime_protocol is not None: + realtime_protocol.bind_native_input_append(self._uses_native_input_append(session)) session.config.playback_commit_policy = DuplexPlaybackCommitPolicy.ACK_ONLY.value if not handshake.resumed: open_result = await self._open_runtime_session(session, emit_event) if open_result is False: return runtime_opened = True + try: + await self._configure_server_vad(session) + except Exception as exc: + logger.exception("Server VAD initialization failed: %s", exc) + self._realtime_vad_metrics.error("initialization") + await emit_event( + correlate_realtime_error( + { + "type": "error", + "session_id": session.session_id, + "code": "server_vad_initialization_failed", + "error": str(exc), + }, + handshake.event_id, + ) + ) + return created_attachment = await self._attachment_registry.create( session.session_id, incarnation=session.incarnation, @@ -734,7 +880,7 @@ class DuplexSessionRunnerMixin: if isinstance(open_result, dict): created_payload["runtime_control"] = self._redact_runtime_control_result(open_result) if realtime_protocol is not None: - realtime_protocol.commit_realtime_turn_detection_update() + realtime_protocol.commit_realtime_turn_detection_update(session.config.server_vad) await emit_event(created_payload) resume_credential_delivered = session.capabilities.supports_session_resume reader_task = asyncio.create_task(read_event_loop(), name="duplex-session-reader") @@ -746,6 +892,14 @@ class DuplexSessionRunnerMixin: if event_type == "__replaced_attachment__": return + if event_type == "__chat_response_finished__": + completed_task = event.get("_response_task") + if completed_task is not actor.active_response_task: + continue + actor.active_response_task = None + activate_waiting_server_vad_turn() + continue + if event_type == "__timeout__": begin_close("timeout") native.audio_buffer.clear() @@ -896,6 +1050,9 @@ class DuplexSessionRunnerMixin: if event_type == "input_audio_buffer.clear": native.audio_buffer.clear() session.release_all_input_bytes() + server_vad_pipeline = self._server_vad_pipelines.get(session.session_id) + if server_vad_pipeline is not None: + server_vad_pipeline.reset() native.input_since_commit = False native.speech_since_commit = False native.clear_committed_audio() @@ -932,10 +1089,8 @@ class DuplexSessionRunnerMixin: if event_type == "response.cancel": requested_response_id = event.get("response_id") has_active_response_work = native_response_in_progress() - if ( - isinstance(requested_response_id, str) - and session.active_response_id is not None - and requested_response_id != session.active_response_id + if isinstance(requested_response_id, str) and ( + session.active_response_id is None or requested_response_id != session.active_response_id ): await emit_event( { @@ -946,6 +1101,7 @@ class DuplexSessionRunnerMixin: } ) continue + if event_type == "response.cancel": if not has_active_response_work: if realtime_protocol is not None and isinstance(requested_response_id, str): continue @@ -1056,6 +1212,7 @@ class DuplexSessionRunnerMixin: } ) actor.active_response_task = None + activate_waiting_server_vad_turn() continue if not cancelled: await self._cancel_pending_input(session, emit_event, reason="barge_in") @@ -1077,6 +1234,7 @@ class DuplexSessionRunnerMixin: ): continue actor.active_response_task = None + activate_waiting_server_vad_turn() continue if event_type == "turn.signal": @@ -1119,6 +1277,27 @@ class DuplexSessionRunnerMixin: ) reject_update() continue + if ( + realtime_protocol is not None + and not self._uses_native_input_append(session) + and session.config.server_vad is not None + and session.turn_detection_config_locked + and realtime_protocol.pending_realtime_input_audio_config_changed() + ): + await emit_update_event( + { + "type": "error", + "session_id": session.session_id, + "code": "unsupported_audio_format", + "error": ( + "session.update cannot change Server VAD input format or sample rate " + "after the first audio append" + ), + "param": "audio.input.format", + } + ) + reject_update() + continue runtime_update_error = self._runtime_session_update_error(session, payload) if runtime_update_error is not None: await emit_update_event(runtime_update_error) @@ -1131,6 +1310,7 @@ class DuplexSessionRunnerMixin: update_error = self._apply_session_update(session, payload) finally: session.replace_config(previous_config) + server_vad_changed = candidate_config.server_vad != previous_config.server_vad if update_error is not None: await emit_update_event(update_error) reject_update() @@ -1138,6 +1318,7 @@ class DuplexSessionRunnerMixin: runtime_update_error = self._runtime_session_candidate_update_error( session, candidate_config, + realtime_protocol=realtime_protocol, ) if runtime_update_error is not None: await emit_update_event(runtime_update_error) @@ -1159,19 +1340,46 @@ class DuplexSessionRunnerMixin: ) reject_update() continue - if not await self._signal_runtime_session( + candidate_server_vad_pipeline = None + if server_vad_changed: + try: + candidate_server_vad_pipeline = await self._prepare_server_vad_pipeline( + session, + candidate_config, + ) + except Exception as exc: + logger.exception("Server VAD session update failed: %s", exc) + self._realtime_vad_metrics.error("initialization") + await emit_update_event( + { + "type": "error", + "session_id": session.session_id, + "code": "server_vad_initialization_failed", + "error": str(exc), + } + ) + reject_update() + continue + runtime_updated = await self._signal_runtime_session( session, turn_event, emit_update_event, session_config=candidate_config.as_dict(), runtime_config=candidate_runtime_config, - ): + ) + if not runtime_updated: reject_update() continue session.replace_config(candidate_config) session.replace_runtime_config(candidate_runtime_config) + if server_vad_changed: + self._install_server_vad_pipeline( + session, + candidate_server_vad_pipeline, + ) if realtime_protocol is not None: - realtime_protocol.commit_realtime_turn_detection_update() + realtime_protocol.bind_native_input_append(self._uses_native_input_append(session)) + realtime_protocol.commit_realtime_turn_detection_update(session.config.server_vad) await emit_update_event( { "type": "session.updated", @@ -1229,7 +1437,10 @@ class DuplexSessionRunnerMixin: expected_model_turn_id=session.turn_id, ) continue - message = self._realtime_item_to_history_message(item) + history_item = payload.get("history_item") if isinstance(payload, dict) else None + if not isinstance(history_item, dict): + history_item = item + message = self._realtime_item_to_history_message(history_item) item_id = item.get("id") if isinstance(item, dict) else None if message is not None: session.append_history_message(message) @@ -1346,24 +1557,30 @@ class DuplexSessionRunnerMixin: default_sample_rate_hz = 16000 sr_raw = event.get("sample_rate_hz") or event.get("sample_rate") sample_rate_hz = sr_raw if isinstance(sr_raw, int | float) else default_sample_rate_hz - try: - audio, fmt, sample_rate_hz = convert_input_audio_with_rate( - audio, - fmt, - sample_rate_hz=sample_rate_hz, - ) - except ValueError as exc: - await emit_event({"type": "error", "error": str(exc), "code": "bad_event"}) - continue - if isinstance(fmt, str) and fmt.lower() in {"pcm16", "pcm_s16le", "s16le"}: - await emit_event( - { - "type": "error", - "error": "input_audio_buffer.append pcm16 audio could not be decoded", - "code": "bad_audio", - } - ) - continue + native_input = self._uses_native_input_append(session) + server_vad_config = session.config.server_vad + turn_based_server_vad = server_vad_config is not None and not native_input + if not turn_based_server_vad: + try: + audio, fmt, sample_rate_hz = convert_input_audio_with_rate( + audio, + fmt, + sample_rate_hz=sample_rate_hz, + ) + except ValueError as exc: + await emit_event({"type": "error", "error": str(exc), "code": "bad_event"}) + continue + if isinstance(fmt, str) and fmt.lower() in {"pcm16", "pcm_s16le", "s16le"}: + await emit_event( + { + "type": "error", + "error": "input_audio_buffer.append pcm16 audio could not be decoded", + "code": "bad_audio", + } + ) + continue + if server_vad_config is None: + session.lock_turn_detection_config() force_listen = bool(event.get("force_listen", False)) payload = { "type": "audio", @@ -1377,11 +1594,186 @@ class DuplexSessionRunnerMixin: frames = [frame for frame in video_frames if isinstance(frame, str) and frame] if frames: payload["video_frames"] = frames + if server_vad_config is not None: + realtime_event_id = event.get("_realtime_event_id") + pipeline = self._server_vad_pipelines.get(session.session_id) + if pipeline is None: + await emit_event( + correlate_realtime_error( + { + "type": "error", + "session_id": session.session_id, + "code": "internal_error", + "error": "server_vad is configured but its detector pipeline is unavailable", + }, + realtime_event_id, + ) + ) + continue + speech_was_active = pipeline.speech_active + reserved_bytes = 0 + previous_scratch_bytes = pipeline.scratch_bytes + try: + if native_input: + if fmt != "pcm_f32le" or sample_rate_hz != pipeline.sample_rate_hz: + raise ValueError("native server_vad input must be mono PCM float32 at 16000 Hz") + raw_audio = base64.b64decode(audio, validate=True) + if len(raw_audio) % np.dtype(" 0 + else: + if fmt != "pcm16": + raise ValueError("server_vad input must be mono PCM16") + source_sample_rate_hz = validate_input_sample_rate_hz(sample_rate_hz) + if source_sample_rate_hz not in {16_000, 24_000}: + raise ValueError("server_vad PCM16 input sample rate must be 16000 or 24000 Hz") + if ( + pipeline.source_sample_rate_hz is not None + and source_sample_rate_hz != pipeline.source_sample_rate_hz + ): + raise ValueError( + "server_vad input sample rate cannot change within a continuous audio stream" + ) + try: + raw_audio = base64.b64decode(audio, validate=True) + except (binascii.Error, ValueError) as exc: + raise ValueError( + "server_vad input audio must be valid base64-encoded PCM16" + ) from exc + if len(raw_audio) % np.dtype(" 0 + except (binascii.Error, ValueError) as exc: + await emit_event( + correlate_realtime_error( + { + "type": "error", + "session_id": session.session_id, + "code": "bad_audio", + "error": str(exc), + "param": "audio", + }, + realtime_event_id, + ) + ) + continue + if not native_input: + normalized_sample_upper_bound = ( + source_samples + if source_sample_rate_hz == pipeline.sample_rate_hz + else ((source_samples + 2) // 3) * 2 + ) + reserved_bytes = normalized_sample_upper_bound * np.dtype(np.float32).itemsize + if not session.reserve_input_bytes( + reserved_bytes, + limit=self._duplex_session_config.max_pending_input_bytes_per_session, + ): + await reject_and_clear_server_vad_input( + pipeline, + code="input_backpressure", + error="Server VAD input exceeds the per-session input limit", + metric_reason="input_backpressure", + event_id=realtime_event_id, + ) + continue + try: + vad_batch = ( + await pipeline.push(normalized_audio) + if native_input + else await pipeline.push_pcm16( + raw_audio, + source_sample_rate_hz=source_sample_rate_hz, + ) + ) + except Exception as exc: + logger.exception("Server VAD inference failed: %s", exc) + await reject_and_clear_server_vad_input( + pipeline, + code="server_vad_inference_failed", + error=str(exc), + metric_reason="inference", + event_id=realtime_event_id, + ) + continue + if not native_input: + retained_delta = ( + sum(frame.samples.nbytes for frame in vad_batch.frames) + + pipeline.scratch_bytes + - previous_scratch_bytes + ) + session.release_input_bytes(max(0, reserved_bytes - retained_delta)) + self._realtime_vad_metrics.observe_inference(vad_batch.inference_ms) + prefix_samples = round(server_vad_config.prefix_padding_ms * pipeline.sample_rate_hz / 1000) + speech_started = False + speech_stopped = False + for frame_index, vad_frame in enumerate(vad_batch.frames): + decision = vad_frame.decision + if not native_input: + released_bytes = session.append_server_vad_frame( + vad_frame.samples, + speech_started=decision.speech_started, + speech_stopped=decision.speech_stopped, + prefix_samples=prefix_samples, + ) + session.release_input_bytes(released_bytes) + if decision.speech_started: + speech_started = True + item_id = f"item_{uuid.uuid4().hex}" + session.begin_server_vad_speech(item_id) + await emit_event( + { + "type": "input_audio_buffer.speech_started", + "session_id": session.session_id, + "item_id": item_id, + "audio_start_ms": decision.audio_start_ms or 0, + } + ) + if decision.speech_stopped: + speech_stopped = True + item_id = session.finish_server_vad_speech() or f"item_{uuid.uuid4().hex}" + await emit_event( + { + "type": "input_audio_buffer.speech_stopped", + "session_id": session.session_id, + "item_id": item_id, + "audio_end_ms": decision.audio_end_ms or 0, + } + ) + if decision.endpoint_delay_ms is not None: + self._realtime_vad_metrics.observe_endpoint_delay(decision.endpoint_delay_ms) + if not native_input: + remaining_frame_bytes = sum( + frame.samples.nbytes for frame in vad_batch.frames[frame_index + 1 :] + ) + await commit_server_vad_turn( + item_id=item_id, + create_response=server_vad_config.create_response, + retain_input_bytes=remaining_frame_bytes + pipeline.scratch_bytes, + event_id=realtime_event_id, + ) + if accepted_audio: + session.lock_turn_detection_config() + if not native_input: + continue + speech_active = pipeline.speech_active + contained_speech = speech_was_active or speech_started or speech_stopped or speech_active + payload["is_speech"] = contained_speech + payload["vad"] = { + "backend": "silero", + "is_speech": contained_speech, + "speech_active": speech_active, + "speech_started": speech_started, + "speech_stopped": speech_stopped, + } + if speech_active: + payload["force_listen"] = True # Speech/silence tag for the Stage0 turn-ended latch. payload["is_speech"] = self._input_looks_like_speech(event, payload, session=session) defer_native_append = False buffer_overlap_audio = True - if self._uses_native_input_append(session): + if native_input: mark_pending_silence_superseded() overlap_active = native_response_in_progress() and ( not self._session_auto_responds(session) @@ -1570,7 +1962,7 @@ class DuplexSessionRunnerMixin: payload = pcm_reservation.payload else: session.append_audio(audio, fmt=fmt, sample_rate_hz=sample_rate_hz) - if self._uses_native_input_append(session): + if native_input: await start_native_append( payload, final=False, @@ -1582,11 +1974,23 @@ class DuplexSessionRunnerMixin: continue if event_type in {"input.commit", "input_audio_buffer.commit", "response.create"}: - realtime_item_id = event.get("realtime_item_id") + if ( + event_type == "response.create" + and session.config.server_vad is not None + and actor.active_response_task is not None + ): + await emit_event( + { + "type": "error", + "session_id": session.session_id, + "code": "response_already_active", + "error": "response.create must wait for the active response to complete", + } + ) + continue + item_id = event.get("item_id") realtime_validated_audio_commit = ( - event_type == "input_audio_buffer.commit" - and isinstance(realtime_item_id, str) - and bool(realtime_item_id) + event_type == "input_audio_buffer.commit" and isinstance(item_id, str) and bool(item_id) ) if ( self._uses_native_input_append(session) @@ -1604,6 +2008,7 @@ class DuplexSessionRunnerMixin: { "type": "input.committed", "session_id": session.session_id, + "item_id": item_id, "turn_id": session.turn_id, "epoch": session.epoch, "empty": True, @@ -1699,6 +2104,7 @@ class DuplexSessionRunnerMixin: { "type": "input.committed", "session_id": session.session_id, + "item_id": item_id, "turn_id": session.turn_id, "epoch": session.epoch, "empty": True, @@ -1736,13 +2142,13 @@ class DuplexSessionRunnerMixin: native.speech_since_commit = False committed = self._commit_native_audio_input( session, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) committed_payload = self._native_audio_committed_payload( session, committed=committed, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) committed_payload["overlap_deferred"] = True @@ -1777,7 +2183,7 @@ class DuplexSessionRunnerMixin: data_plane_turn_id = session.turn_id committed = self._commit_native_audio_input( session, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), turn_id=data_plane_turn_id, ) @@ -1785,7 +2191,7 @@ class DuplexSessionRunnerMixin: self._native_audio_committed_payload( session, committed=committed, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) ) @@ -1883,14 +2289,14 @@ class DuplexSessionRunnerMixin: native.input_since_commit = False committed = self._commit_native_audio_input( session, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) await emit_event( self._native_audio_committed_payload( session, committed=committed, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) ) @@ -1949,7 +2355,7 @@ class DuplexSessionRunnerMixin: committed = ( self._commit_native_audio_input( session, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) if native_had_uncommitted_audio @@ -1959,7 +2365,7 @@ class DuplexSessionRunnerMixin: self._native_audio_committed_payload( session, committed=committed, - realtime_item_id=event.get("realtime_item_id"), + item_id=event.get("item_id"), transcript=event.get("transcript"), ) ) @@ -1968,19 +2374,20 @@ class DuplexSessionRunnerMixin: { "type": "input.committed", "session_id": session.session_id, + "item_id": item_id, "empty": True, } ) continue if committed is not None: - realtime_item_id = event.get("realtime_item_id") - if isinstance(realtime_item_id, str): - session.register_history_item(realtime_item_id, committed.message) + item_id = event.get("item_id") + if isinstance(item_id, str): + session.register_history_item(item_id, committed.message) await emit_event( self._input_committed_payload( session, committed, - realtime_item_id=realtime_item_id, + item_id=item_id, ) ) if not should_create_response: @@ -1992,7 +2399,10 @@ class DuplexSessionRunnerMixin: emit_event, reason="new_response", ) - actor.active_response_task = asyncio.create_task(self._run_response(session, emit_event)) + if session.config.server_vad is not None and not self._uses_native_input_append(session): + start_chat_response() + else: + actor.active_response_task = asyncio.create_task(self._run_response(session, emit_event)) continue await emit_event( diff --git a/vllm_omni/entrypoints/duplex/vad.py b/vllm_omni/entrypoints/duplex/vad.py deleted file mode 100644 index 362e500ee..000000000 --- a/vllm_omni/entrypoints/duplex/vad.py +++ /dev/null @@ -1,181 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project - -from __future__ import annotations - -import binascii -from collections.abc import Callable -from dataclasses import dataclass - -import numpy as np -import pybase64 as base64 - -SILERO_VAD_MIN_THRESHOLD = 0.15 - - -class ServerVADUnavailableError(RuntimeError): - pass - - -@dataclass(frozen=True, slots=True) -class SileroVADConfig: - threshold: float = 0.5 - prefix_padding_ms: int = 300 - silence_duration_ms: int = 500 - min_speech_duration_ms: int = 96 - - -@dataclass(frozen=True, slots=True) -class StreamingVADResult: - is_speech: bool - speech_active: bool - speech_started: bool = False - speech_stopped: bool = False - speech_probability: float = 0.0 - speech_start_ms: int | None = None - speech_end_ms: int | None = None - - -class SileroStreamingVAD: - _SAMPLE_RATE_HZ = 16_000 - _WINDOW_SAMPLES = 512 - - def __init__( - self, - config: SileroVADConfig, - *, - frame_scorer: Callable[[np.ndarray], float] | None = None, - ) -> None: - self.config = config - self._frame_scorer = frame_scorer - self._model: object | None = None - self._clear_stream_state() - - def _clear_stream_state(self) -> None: - self._pending = np.empty(0, dtype=np.float32) - self._speech_active = False - self._candidate_samples = 0 - self._candidate_start_sample: int | None = None - self._silence_samples = 0 - self._processed_samples = 0 - self._resample_rate_hz: int | None = None - self._resample_remainder = 0 - - def reset(self) -> None: - self._clear_stream_state() - reset_states = getattr(self._model, "reset_states", None) - if callable(reset_states): - reset_states() - - def process_base64( - self, - audio: object, - *, - fmt: object, - sample_rate_hz: object, - ) -> StreamingVADResult: - if fmt != "pcm_f32le" or not isinstance(audio, str): - raise ValueError("Silero server VAD requires decoded pcm_f32le audio") - try: - raw = base64.b64decode(audio, validate=True) - except (binascii.Error, ValueError) as exc: - raise ValueError("Silero server VAD received invalid base64 audio") from exc - if len(raw) % 4: - raise ValueError("Silero server VAD received an incomplete pcm_f32le frame") - samples = np.frombuffer(raw, dtype=" StreamingVADResult: - samples = np.asarray(samples, dtype=np.float32).reshape(-1) - if samples.size: - self._pending = np.concatenate((self._pending, samples)) - - started = False - stopped = False - start_ms: int | None = None - end_ms: int | None = None - max_probability = 0.0 - contained_speech = self._speech_active - min_speech_samples = self.config.min_speech_duration_ms * 16 - min_silence_samples = self.config.silence_duration_ms * 16 - negative_threshold = max(0.0, self.config.threshold - SILERO_VAD_MIN_THRESHOLD) - - while self._pending.size >= self._WINDOW_SAMPLES: - frame = self._pending[: self._WINDOW_SAMPLES] - self._pending = self._pending[self._WINDOW_SAMPLES :] - frame_start = self._processed_samples - self._processed_samples += self._WINDOW_SAMPLES - probability = min(1.0, max(0.0, float(self._score_frame(frame)))) - max_probability = max(max_probability, probability) - - if self._speech_active: - contained_speech = True - if probability < negative_threshold: - self._silence_samples += self._WINDOW_SAMPLES - if self._silence_samples >= min_silence_samples: - speech_end_sample = self._processed_samples - self._silence_samples - self._speech_active = False - self._silence_samples = 0 - stopped = True - end_ms = max(0, int(round(speech_end_sample / 16))) - else: - self._silence_samples = 0 - continue - - if probability >= self.config.threshold: - if self._candidate_samples == 0: - self._candidate_start_sample = frame_start - self._candidate_samples += self._WINDOW_SAMPLES - if self._candidate_samples >= min_speech_samples: - candidate_start = self._candidate_start_sample or 0 - prefix_samples = self.config.prefix_padding_ms * 16 - self._speech_active = True - self._candidate_samples = self._silence_samples = 0 - self._candidate_start_sample = None - contained_speech = True - started = True - start_ms = max(0, int(round((candidate_start - prefix_samples) / 16))) - else: - self._candidate_samples = 0 - self._candidate_start_sample = None - - return StreamingVADResult( - contained_speech, self._speech_active, started, stopped, max_probability, start_ms, end_ms - ) - - def _score_frame(self, frame: np.ndarray) -> float: - if self._frame_scorer is not None: - return float(self._frame_scorer(frame)) - if self._model is None: - try: - import torch - from silero_vad import load_silero_vad - except ImportError as exc: - raise ServerVADUnavailableError( - "server_vad requires the optional 'silero-vad' package; install vllm-omni[server-vad]" - ) from exc - self._model = load_silero_vad(onnx=False) - self._frame_scorer = lambda value: float( - self._model(torch.from_numpy(np.ascontiguousarray(value)), self._SAMPLE_RATE_HZ).item() - ) - return float(self._frame_scorer(frame)) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index d49d95175..f10edfc7f 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -1290,10 +1290,12 @@ async def omni_init_app_state( state.openai_serving_duplex = None if state.openai_serving_chat is not None and should_enable_duplex_endpoint( state.stage_configs, - config_path=getattr(args, "deploy_config", None), + config_path=getattr(engine_client, "config_path", None) or getattr(args, "deploy_config", None), ): state.openai_serving_duplex = OmniDuplexSessionHandler( chat_service=state.openai_serving_chat, + served_model_name=model_name, + log_stats=state.log_stats, duplex_session_config=getattr(engine_client, "duplex_session_config", None), serving_runtime_adapter_path=getattr(engine_client, "duplex_serving_adapter_path", None), ) @@ -1850,8 +1852,8 @@ async def realtime_websocket(websocket: WebSocket): logger.warning("Duplex warmup still running after 120 s; admitting the client anyway.") duplex_handler = getattr(websocket.app.state, "openai_serving_duplex", None) duplex_query = websocket.query_params.get("duplex") - use_duplex_realtime = ( - duplex_handler is not None and isinstance(duplex_query, str) and duplex_query.lower() in {"1", "true", "on"} + use_duplex_realtime = duplex_handler is not None and ( + duplex_query is None or (isinstance(duplex_query, str) and duplex_query.lower() in {"1", "true", "on"}) ) if use_duplex_realtime and duplex_handler is not None: await duplex_handler.handle_realtime_session(websocket) diff --git a/vllm_omni/entrypoints/openai/audio_utils_mixin.py b/vllm_omni/entrypoints/openai/audio_utils_mixin.py index 3cfcfc31d..e3bce1a67 100644 --- a/vllm_omni/entrypoints/openai/audio_utils_mixin.py +++ b/vllm_omni/entrypoints/openai/audio_utils_mixin.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +import math from io import BytesIO import numpy as np @@ -19,85 +20,152 @@ logger = init_logger(__name__) class StreamingAudioResampler: - """Stateful resampler for streaming mono audio. + """Stateful polyphase resampler for streaming mono float audio. - Only integer downsampling ratios are supported so every input chunk can - produce output without buffering the complete stream. + Retains filter state so output is invariant to input chunk boundaries. """ + _half_filter_width = 10 + _kaiser_beta = 5.0 + _max_polyphase_factor = 2_048 + def __init__(self, source_rate: int, target_rate: int): if source_rate <= 0 or target_rate <= 0: raise ValueError("Audio sample rates must be positive") self.source_rate = source_rate self.target_rate = target_rate - if source_rate < target_rate or source_rate % target_rate != 0: - raise ValueError( - "Streaming audio resampling requires an integer downsampling ratio, " - f"got {source_rate} Hz to {target_rate} Hz" + rate_gcd = math.gcd(source_rate, target_rate) + self._up = target_rate // rate_gcd + self._down = source_rate // rate_gcd + if max(self._up, self._down) > self._max_polyphase_factor: + raise ValueError("source and target sample rates have an unsupported resampling ratio") + self._half_len, self._phase_kernels = self._design_polyphase_filter() + self._history_samples = self._phase_kernels.shape[1] - 1 + self.reset() + + def _design_polyphase_filter(self) -> tuple[int, np.ndarray]: + if self._up == self._down: + return 0, np.ones((1, 1), dtype=np.float32) + + max_rate = max(self._up, self._down) + half_len = self._half_filter_width * max_rate + offsets = np.arange(-half_len, half_len + 1, dtype=np.float64) + cutoff = 1.0 / max_rate + taps = cutoff * np.sinc(cutoff * offsets) + taps *= np.kaiser(taps.size, self._kaiser_beta) + taps /= np.sum(taps) + taps *= self._up + taps = np.ascontiguousarray(taps, dtype=np.float32) + + phases = [taps[phase :: self._up] for phase in range(self._up)] + phase_width = max(phase.size for phase in phases) + kernels = np.zeros((self._up, phase_width), dtype=np.float32) + for phase_index, phase in enumerate(phases): + kernels[phase_index, -phase.size :] = phase[::-1] + return half_len, kernels + + @property + def scratch_bytes(self) -> int: + pending_samples = self._ceil_div(self._input_samples * self._up, self._down) - self._output_samples + return max(0, pending_samples) * np.dtype(np.float32).itemsize + + @staticmethod + def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + def _render_outputs( + self, + combined: np.ndarray, + *, + first_output: int, + output_count: int, + input_start: int, + ) -> np.ndarray: + if output_count <= 0: + return np.empty(0, dtype=np.float32) + + output_indexes = np.arange(first_output, first_output + output_count, dtype=np.int64) + filter_indexes = output_indexes * self._down + self._half_len + source_indexes = filter_indexes // self._up + phases = filter_indexes % self._up + windows = np.lib.stride_tricks.sliding_window_view( + combined, + self._phase_kernels.shape[1], + ) + selected_windows = windows[source_indexes - input_start] + return np.sum( + selected_windows * self._phase_kernels[phases], + axis=1, + dtype=np.float32, + ) + + def _push(self, chunk: np.ndarray) -> np.ndarray: + if chunk.size == 0: + return np.empty(0, dtype=np.float32) + if self._flushed: + raise RuntimeError("cannot process audio after the resampler has been finalized; call reset first") + + combined = np.concatenate((self._history, chunk)) + next_input_samples = self._input_samples + chunk.size + stable_output_samples = max( + 0, + self._ceil_div(next_input_samples * self._up - self._half_len, self._down), + ) + output = self._render_outputs( + combined, + first_output=self._output_samples, + output_count=stable_output_samples - self._output_samples, + input_start=self._input_samples, + ) + if self._history_samples: + self._history = combined[-self._history_samples :].copy() + self._input_samples = next_input_samples + self._output_samples = stable_output_samples + return output + + def _flush(self) -> np.ndarray: + if self._flushed: + return np.empty(0, dtype=np.float32) + + total_output_samples = self._ceil_div(self._input_samples * self._up, self._down) + output_count = total_output_samples - self._output_samples + if output_count: + last_output = total_output_samples - 1 + last_source_index = (last_output * self._down + self._half_len) // self._up + right_padding = max(0, last_source_index - self._input_samples + 1) + combined = np.concatenate((self._history, np.zeros(right_padding, dtype=np.float32))) + output = self._render_outputs( + combined, + first_output=self._output_samples, + output_count=output_count, + input_start=self._input_samples, ) - - self._ratio = source_rate // target_rate - self._buffer = np.empty((0,), dtype=np.float32) - self._buffer_start = 0 - self._total_samples = 0 - self._next_center = 0 - - if self._ratio == 1: - self._taps = np.ones((1,), dtype=np.float32) else: - # Use a Kaiser-windowed sinc and leave a small transition band - # below the target Nyquist frequency. - num_taps = 20 * self._ratio + 1 - half = num_taps // 2 - offsets = np.arange(num_taps, dtype=np.float64) - half - cutoff = 0.95 / self._ratio - taps = cutoff * np.sinc(cutoff * offsets) * np.kaiser(num_taps, 5.0) - self._taps = (taps / taps.sum()).astype(np.float32) + output = np.empty(0, dtype=np.float32) + self._output_samples = total_output_samples + self._flushed = True + return output def process(self, audio: np.ndarray, *, final: bool = False) -> np.ndarray: chunk = np.asarray(audio, dtype=np.float32) if chunk.ndim != 1: raise ValueError(f"Streaming audio resampling only supports mono audio, got shape {chunk.shape}") + chunk = np.ascontiguousarray(chunk, dtype=np.float32) + output = self._push(chunk) + if not final: + return output + tail = self._flush() + if not output.size: + return tail + if not tail.size: + return output + return np.concatenate((output, tail)) - if self._ratio == 1: - return chunk - - if chunk.size: - self._buffer = np.concatenate((self._buffer, chunk)) - self._total_samples += int(chunk.size) - - half = self._taps.size // 2 - max_center = self._total_samples - 1 if final else self._total_samples - 1 - half - if self._next_center > max_center: - return np.empty((0,), dtype=np.float32) - - centers = np.arange(self._next_center, max_center + 1, self._ratio, dtype=np.int64) - first_center = int(centers[0]) - last_center = int(centers[-1]) - window_start = first_center - half - window_end = last_center + half - segment = np.zeros((window_end - window_start + 1,), dtype=np.float32) - - copy_start = max(window_start, self._buffer_start, 0) - copy_end = min(window_end + 1, self._buffer_start + self._buffer.size) - if copy_end > copy_start: - dst_start = copy_start - window_start - src_start = copy_start - self._buffer_start - segment[dst_start : dst_start + copy_end - copy_start] = self._buffer[ - src_start : src_start + copy_end - copy_start - ] - - windows = np.lib.stride_tricks.sliding_window_view(segment, self._taps.size)[:: self._ratio] - output = windows @ self._taps[::-1] - self._next_center = last_center + self._ratio - - keep_from = max(0, self._next_center - half) - drop = min(max(keep_from - self._buffer_start, 0), self._buffer.size) - if drop: - self._buffer = self._buffer[drop:] - self._buffer_start += int(drop) - - return np.asarray(output, dtype=np.float32) + def reset(self) -> None: + self._history = np.zeros(self._history_samples, dtype=np.float32) + self._input_samples = 0 + self._output_samples = 0 + self._flushed = False class AudioMixin: diff --git a/vllm_omni/metrics/definitions.py b/vllm_omni/metrics/definitions.py index 4b0043bad..6cc2825c1 100644 --- a/vllm_omni/metrics/definitions.py +++ b/vllm_omni/metrics/definitions.py @@ -161,6 +161,12 @@ AUDIO_UNDERRUN_S = METRIC_PREFIX + AUDIO_UNDERRUN + "_s" AUDIO_CONTINUITY_OK_METRIC = METRIC_PREFIX + AUDIO_CONTINUITY_OK AUDIO_SKIPPED_REQUESTS_METRIC = METRIC_PREFIX + AUDIO_SKIPPED_REQUESTS +# Realtime Server VAD serving metrics. +REALTIME_VAD_ACTIVE_SESSIONS = METRIC_PREFIX + "realtime_vad_active_sessions" +REALTIME_VAD_INFERENCE_LATENCY_S = METRIC_PREFIX + "realtime_vad_inference_latency_s" +REALTIME_VAD_ENDPOINT_DELAY_S = METRIC_PREFIX + "realtime_vad_endpoint_delay_s" +REALTIME_VAD_ERRORS = METRIC_PREFIX + "realtime_vad_errors" + # ============================================================================ # Diffusion family (per-stage + per-replica diffusion timing breakdowns) diff --git a/vllm_omni/metrics/realtime.py b/vllm_omni/metrics/realtime.py new file mode 100644 index 000000000..a82bb5b17 --- /dev/null +++ b/vllm_omni/metrics/realtime.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +from prometheus_client import Counter, Gauge, Histogram + +from vllm_omni.metrics import definitions as defs + +_active_sessions = Gauge( + defs.REALTIME_VAD_ACTIVE_SESSIONS, + "Number of active Realtime sessions using server-side VAD.", + labelnames=list(defs.PIPELINE_LABELS), +) +_inference_latency = Histogram( + defs.REALTIME_VAD_INFERENCE_LATENCY_S, + "Server VAD inference time for one appended audio batch, in seconds.", + labelnames=list(defs.PIPELINE_LABELS), + buckets=defs.SECONDS_FAST_BUCKETS, +) +_endpoint_delay = Histogram( + defs.REALTIME_VAD_ENDPOINT_DELAY_S, + "Detected silence between the last speech frame and the committed endpoint, in seconds.", + labelnames=list(defs.PIPELINE_LABELS), + buckets=defs.SECONDS_FAST_BUCKETS, +) +_errors = Counter( + defs.REALTIME_VAD_ERRORS, + "Server VAD errors and input-overflow rejections by reason.", + labelnames=list(defs.FAILED_LABELS), +) + + +class RealtimeVADMetrics: + def __init__(self, model_name: str, log_stats: bool = True) -> None: + self._model_name = model_name + self._log_stats = log_stats + self._active_sessions = _active_sessions.labels(model_name=model_name) + self._inference_latency = _inference_latency.labels(model_name=model_name) + self._endpoint_delay = _endpoint_delay.labels(model_name=model_name) + + def session_started(self) -> None: + if self._log_stats: + self._active_sessions.inc() + + def session_finished(self) -> None: + if self._log_stats: + self._active_sessions.dec() + + def observe_inference(self, latency_ms: float) -> None: + if self._log_stats: + self._inference_latency.observe(latency_ms / 1000) + + def observe_endpoint_delay(self, delay_ms: int) -> None: + if self._log_stats: + self._endpoint_delay.observe(delay_ms / 1000) + + def error(self, reason: str) -> None: + if self._log_stats: + _errors.labels(model_name=self._model_name, reason=reason).inc() From d7740336ff94d23276d690e3cd74a1ddb25d23cb Mon Sep 17 00:00:00 2001 From: liangmenghuang Date: Tue, 8 Sep 2026 18:59:01 +0800 Subject: [PATCH 40/90] [Bugfix][Model] GR00T-N1.7: honor processor_config image pipeline (use_albumentations / crop_fraction / letter_box_transform) to match Isaac-GR00T (#7083) Signed-off-by: liangmengh Co-authored-by: Claude Fable 5.1 --- .../test_processing_eval_image_transform.py | 292 ++++++++++++++++++ .../gr00t/modeling/processing_gr00t_n1d7.py | 125 +++++++- 2 files changed, 410 insertions(+), 7 deletions(-) create mode 100644 tests/diffusion/models/gr00t/test_processing_eval_image_transform.py diff --git a/tests/diffusion/models/gr00t/test_processing_eval_image_transform.py b/tests/diffusion/models/gr00t/test_processing_eval_image_transform.py new file mode 100644 index 000000000..452273185 --- /dev/null +++ b/tests/diffusion/models/gr00t/test_processing_eval_image_transform.py @@ -0,0 +1,292 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""GR00T-N1.7 eval image transform: chosen from ``processor_config.json`` and bit-exact with Isaac-GR00T. + +``GOLDEN`` holds sha256 digests of the ``(C, H, W)`` uint8 output of Isaac-GR00T's own eval transform +(``build_image_transformations_albumentations(...)[1]`` applied through ``apply_with_replay``) for the +synthetic frames built below, at Isaac-GR00T 51d4c89 / albumentations 1.4.18 / opencv 5.0.0.93. The frame +generators are integer-only so they are identical on every platform. + +Regenerating a digest needs Isaac-GR00T installed, whose transformers pin conflicts with this repo's, so it +cannot run here: in an Isaac-GR00T environment push each ``synthetic_frames()`` frame through that transform +with ``letter_box_transform`` False and True and hash the output with ``digest()``. +""" + +from __future__ import annotations + +import hashlib +import json + +import numpy as np +import pytest +import torch +import torchvision.transforms.v2 as transforms + +from vllm_omni.diffusion.models.gr00t.modeling import processing_gr00t_n1d7 as processing +from vllm_omni.diffusion.models.gr00t.modeling.processing_gr00t_n1d7 import ( + AlbumentationsEvalImageTransform, + Gr00tN1d7Processor, + LetterBoxTransform, + _build_eval_image_transform, + _fractional_center_crop, + _smallest_edge_resize, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +# nvidia/GR00T-N1.7-3B and nvidia/GR00T-N1.7-DROID both ship exactly these processor_kwargs. +TARGET, CROP, EDGE, FRACTION = [256, 256], [230, 230], 256, 0.95 +MODALITY_CONFIGS = { + "new_embodiment": { + "video": {"delta_indices": [0], "modality_keys": ["cam"]}, + "state": {"delta_indices": [0], "modality_keys": ["joint"]}, + "action": {"delta_indices": [0, 1], "modality_keys": ["joint"]}, + } +} + +# Isaac-GR00T eval transform output for synthetic_frames(); key = "|letterbox=". +GOLDEN: dict[str, tuple[tuple[int, int, int], str]] = { + "gradient_256x256|letterbox=False": ( + (3, 256, 256), + "4e90a1a3c49539b3648aad752bba59af452bd7231e5712e871ab941cbea1d715", + ), + "checker_256x256|letterbox=False": ( + (3, 256, 256), + "d2225b94efea5ab3f1c3e6dbae52b7afa3e0f7172d5de24439a2e729ab56080c", + ), + "noise_256x256|letterbox=False": ( + (3, 256, 256), + "a427ae3b84ebcc6e87470c79c0e45cf684770d6e31158a49f8736685e0da72c6", + ), + "noise_512x768|letterbox=False": ( + (3, 256, 383), + "45bc3a309f3d06c3f31051911bc8a544ff3f067b023b999dd9c2b7e6029ecc93", + ), + "gradient_256x256|letterbox=True": ( + (3, 256, 256), + "4e90a1a3c49539b3648aad752bba59af452bd7231e5712e871ab941cbea1d715", + ), + "checker_256x256|letterbox=True": ( + (3, 256, 256), + "d2225b94efea5ab3f1c3e6dbae52b7afa3e0f7172d5de24439a2e729ab56080c", + ), + "noise_256x256|letterbox=True": ((3, 256, 256), "a427ae3b84ebcc6e87470c79c0e45cf684770d6e31158a49f8736685e0da72c6"), + "noise_512x768|letterbox=True": ((3, 256, 256), "52a3db185e6ee4f00617b87711dd198b8f2e2ad53865713d34e56225a4ec61a2"), +} + + +def _hash_noise(height: int, width: int, seed: int) -> np.ndarray: + """Integer-only pseudo-noise (murmur-style mixing on uint32); no RNG or float, so identical everywhere.""" + ys, xs = np.mgrid[0:height, 0:width].astype(np.uint32) + channels = [] + for channel in range(3): + v = xs * np.uint32(73856093) ^ ys * np.uint32(19349663) ^ np.uint32((seed * 3 + channel) * 83492791) + v ^= v >> np.uint32(13) + v *= np.uint32(0x5BD1E995) + v ^= v >> np.uint32(15) + channels.append((v & np.uint32(0xFF)).astype(np.uint8)) + return np.stack(channels, axis=-1) + + +def synthetic_frames() -> dict[str, np.ndarray]: + """Deterministic HWC uint8 RGB frames: smooth, hard-edged, noisy, and a non-square noisy one. + + The non-square frame is 512x768 so both INTER_AREA downscales (2x without letterbox, 3x with it) + are integer factors, which OpenCV computes with exact integer sums: the goldens do not depend on + the CPU architecture. The 243->256 upscale is a fixed-point bilinear on uint8, also exact. + """ + ys, xs = np.mgrid[0:256, 0:256].astype(np.int64) + frames = { + "gradient_256x256": np.stack([xs % 256, ys % 256, (xs + ys) // 2 % 256], axis=-1).astype(np.uint8), + "checker_256x256": np.stack( + [((ys // 16 + xs // 16) % 2) * 255, ((ys // 8 + xs // 8) % 2) * 255, ((ys // 32) % 2) * 255], + axis=-1, + ).astype(np.uint8), + "noise_256x256": _hash_noise(256, 256, seed=1), + "noise_512x768": _hash_noise(512, 768, seed=2), + } + return frames + + +def digest(tensor: torch.Tensor) -> str: + return hashlib.sha256(np.ascontiguousarray(tensor.numpy()).tobytes()).hexdigest() + + +class _FakeTokenizer: + padding_side = "right" + + +class _FakeVlmProcessor: + """The two attributes ``Gr00tN1d7Processor`` and its collator touch on the Qwen3-VL processor.""" + + def __init__(self) -> None: + self.tokenizer = _FakeTokenizer() + + def apply_chat_template(self, *_args: object, **_kwargs: object) -> str: + return "" + + +@pytest.fixture(autouse=True) +def fake_vlm_processor(monkeypatch: pytest.MonkeyPatch) -> None: + """The real Qwen3-VL processor is a gated download and irrelevant to the image pipeline.""" + monkeypatch.setattr(processing, "build_processor", lambda _model_name, _kwargs: _FakeVlmProcessor()) + + +def _processor(**kwargs) -> Gr00tN1d7Processor: + return Gr00tN1d7Processor( + modality_configs=MODALITY_CONFIGS, image_crop_size=CROP, image_target_size=TARGET, **kwargs + ) + + +def _has_letterbox(compose: transforms.Compose) -> bool: + return any(isinstance(step, LetterBoxTransform) for step in compose.transforms) + + +def test_processor_builds_the_pipeline_named_by_processor_config(): + albumentations = _processor(use_albumentations=True, shortest_image_edge=EDGE, crop_fraction=FRACTION) + transform = albumentations.eval_image_transform + assert isinstance(transform, AlbumentationsEvalImageTransform) + assert (transform.max_size, transform.fraction, transform.letter_box_transform) == (EDGE, FRACTION, False) + assert albumentations.use_albumentations is True + + torchvision = _processor(use_albumentations=False) + assert isinstance(torchvision.eval_image_transform, transforms.Compose) + assert not _has_letterbox(torchvision.eval_image_transform) + + letterboxed = _processor(use_albumentations=False, letter_box_transform=True) + assert _has_letterbox(letterboxed.eval_image_transform) + assert _processor(use_albumentations=True, letter_box_transform=True).eval_image_transform.letter_box_transform + + +def test_legacy_config_without_the_flags_keeps_the_torchvision_recipe(): + """Checkpoints predating the flags behave like Isaac-GR00T's defaults; training-only keys stay ignored.""" + processor = _processor(state_dropout_prob=0.2, extra_augmentation_config=None) + assert processor.use_albumentations is False + assert processor.letter_box_transform is False + assert isinstance(processor.eval_image_transform, transforms.Compose) + assert not _has_letterbox(processor.eval_image_transform) + + +def test_fraction_and_max_size_fall_back_like_isaac_gr00t(): + transform = _build_eval_image_transform( + TARGET, CROP, shortest_image_edge=None, crop_fraction=None, use_albumentations=True + ) + assert isinstance(transform, AlbumentationsEvalImageTransform) + assert transform.fraction == CROP[0] / TARGET[0] + assert transform.max_size == TARGET[0] + with pytest.raises(ValueError, match="crop_fraction is None"): + _build_eval_image_transform(TARGET, None, crop_fraction=None, use_albumentations=True) + + +def test_geometry_follows_isaac_rounding_rules(): + frame = _hash_noise(540, 640, seed=3) + resized = _smallest_edge_resize(frame, EDGE) + assert resized.shape == (256, 303, 3) # round() per dimension, shortest edge -> 256 + cropped = _fractional_center_crop(resized, FRACTION) + assert cropped.shape == (243, 287, 3) # int() truncation: 243.2 -> 243, 287.85 -> 287 + assert np.array_equal(cropped, resized[6:249, 8:295]) # (256-243)//2, (303-287)//2 + square = frame[:256, :256] + assert _smallest_edge_resize(square, EDGE) is square # scale == 1.0 is a no-op, not a resample + + +@pytest.mark.parametrize("case", sorted(GOLDEN)) +def test_albumentations_recipe_is_bit_exact_with_isaac_gr00t(case: str): + name, flag = case.split("|letterbox=") + transform = _build_eval_image_transform( + TARGET, + CROP, + shortest_image_edge=EDGE, + crop_fraction=FRACTION, + use_albumentations=True, + letter_box_transform=(flag == "True"), + ) + out = transform(synthetic_frames()[name]) + expected_shape, expected_digest = GOLDEN[case] + assert out.dtype == torch.uint8 + assert tuple(out.shape) == expected_shape + assert digest(out) == expected_digest + + +def test_letterbox_is_gated_on_both_recipes(): + frame = _hash_noise(540, 640, seed=3) + + def albumentations(letterbox: bool) -> torch.Tensor: + return _build_eval_image_transform( + TARGET, + CROP, + shortest_image_edge=EDGE, + crop_fraction=FRACTION, + use_albumentations=True, + letter_box_transform=letterbox, + )(frame) + + padded, unpadded = albumentations(True), albumentations(False) + assert tuple(padded.shape) == (3, 256, 256) # 540x640 -> 640x640 -> 256x256 -> 243x243 -> 256x256 + assert tuple(unpadded.shape) == (3, 256, 302) # aspect preserved: 256x303 -> 243x287 -> 256x302 + + tv_padded = _build_eval_image_transform(TARGET, CROP, use_albumentations=False, letter_box_transform=True)(frame) + tv_unpadded = _build_eval_image_transform(TARGET, CROP, use_albumentations=False, letter_box_transform=False)(frame) + assert tuple(tv_padded.shape) == tuple(tv_unpadded.shape) == (3, 256, 256) + assert not torch.equal(tv_padded, tv_unpadded) + assert (tv_padded[:, :2, :] == 0).all() and not (tv_unpadded[:, :2, :] == 0).all() # black bars only when gated + + +def test_albumentations_recipe_rejects_frames_outside_the_contract(): + transform = _build_eval_image_transform(TARGET, CROP, use_albumentations=True) + with pytest.raises(ValueError, match="HWC uint8"): + transform(np.zeros((256, 256, 3), dtype=np.float32)) + with pytest.raises(ValueError, match="HWC uint8"): + transform(np.zeros((3, 256, 256), dtype=np.uint8)) + with pytest.raises(ValueError, match="HWC uint8"): + transform(np.zeros((256, 256), dtype=np.uint8)) + + +def test_get_vlm_inputs_runs_the_selected_transform_per_frame(): + processor = _processor(use_albumentations=True, shortest_image_edge=EDGE, crop_fraction=FRACTION) + frames = np.stack([_hash_noise(540, 640, seed=3)] * 2) # (T, H, W, C) like policy.py hands over + vlm_inputs = processor._get_vlm_inputs( + image_keys=["cam"], + images={"cam": frames}, + masks=None, + image_transform=processor.eval_image_transform, + language="x", + ) + images = vlm_inputs["vlm_content"]["images"] + assert len(images) == 2 + assert all(img.size == (302, 256) for img in images) # PIL reports (W, H); no letterbox for this checkpoint + + +def test_flags_round_trip_through_save_pretrained_and_from_pretrained(tmp_path): + saved = _processor( + use_albumentations=True, letter_box_transform=True, shortest_image_edge=EDGE, crop_fraction=FRACTION + ) + saved.save_pretrained(tmp_path) + kwargs = json.loads((tmp_path / "processor_config.json").read_text())["processor_kwargs"] + assert kwargs["use_albumentations"] is True + assert kwargs["letter_box_transform"] is True + + loaded = Gr00tN1d7Processor.from_pretrained(tmp_path) + assert loaded.use_albumentations is True + assert loaded.letter_box_transform is True + assert isinstance(loaded.eval_image_transform, AlbumentationsEvalImageTransform) + assert loaded.eval_image_transform.letter_box_transform is True + + +def test_from_pretrained_defaults_missing_flags_to_isaac_gr00t_defaults(tmp_path): + (tmp_path / "processor_config.json").write_text( + json.dumps( + { + "processor_class": "Gr00tN1d7Processor", + "processor_kwargs": { + "modality_configs": MODALITY_CONFIGS, + "image_crop_size": CROP, + "image_target_size": TARGET, + }, + } + ) + ) + (tmp_path / "statistics.json").write_text("{}") + loaded = Gr00tN1d7Processor.from_pretrained(tmp_path) + assert loaded.use_albumentations is False + assert loaded.letter_box_transform is False + assert isinstance(loaded.eval_image_transform, transforms.Compose) diff --git a/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py b/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py index ade5eac83..d30d1c829 100644 --- a/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py +++ b/vllm_omni/diffusion/models/gr00t/modeling/processing_gr00t_n1d7.py @@ -1,10 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import json import os import re import warnings +from collections.abc import Callable from copy import deepcopy from pathlib import Path from typing import Any @@ -46,20 +47,119 @@ class LetterBoxTransform: return transforms.functional.pad(img, padding=[pad_left, pad_top, pad_right, pad_bottom], fill=0) +def _smallest_edge_resize(image: np.ndarray, max_size: int) -> np.ndarray: + """albumentations 1.4.18 ``SmallestMaxSize(max_size, interpolation=cv2.INTER_AREA)``. + + Scale off the shortest edge, skip entirely at scale 1.0, round each dimension + independently; ``cv2.resize`` takes ``dsize`` as ``(width, height)``. + """ + import cv2 + + height, width = image.shape[:2] + scale = max_size / float(min(height, width)) + if scale == 1.0: + return image + new_height, new_width = (round(dim * scale) for dim in (height, width)) + if (new_height, new_width) == (height, width): + return image + return cv2.resize(image, dsize=(new_width, new_height), interpolation=cv2.INTER_AREA) + + +def _fractional_center_crop(image: np.ndarray, fraction: float) -> np.ndarray: + """Isaac-GR00T ``FractionalCenterCrop``: ``int()`` truncation (not rounding), floor 1, centred.""" + height, width = image.shape[:2] + crop_height = max(1, int(height * fraction)) + crop_width = max(1, int(width * fraction)) + y_min = (height - crop_height) // 2 + x_min = (width - crop_width) // 2 + return image[y_min : y_min + crop_height, x_min : x_min + crop_width] + + +def _letter_box_pad(image: np.ndarray) -> np.ndarray: + """Isaac-GR00T ``LetterBoxPad``: zero-pad to square, centred, remainder on the far side.""" + import cv2 + + height, width = image.shape[:2] + if height == width: + return image + longest = max(height, width) + pad_h, pad_w = longest - height, longest - width + top, left = pad_h // 2, pad_w // 2 + return cv2.copyMakeBorder(image, top, pad_h - top, left, pad_w - left, cv2.BORDER_CONSTANT, value=0) + + +class AlbumentationsEvalImageTransform: + """Eval transform of the Isaac-GR00T ``use_albumentations=True`` recipe, without albumentations. + + ``[LetterBoxPad] -> SmallestMaxSize(INTER_AREA) -> FractionalCenterCrop -> SmallestMaxSize(INTER_AREA)``, + transcribed from the eval branch of Isaac-GR00T ``build_image_transformations_albumentations`` and + albumentations 1.4.18, so a checkpoint trained with that recipe sees the same pixels at inference. + Per frame: HWC uint8 RGB array in, ``(C, H, W)`` uint8 tensor out (the ``_get_vlm_inputs`` contract). + """ + + def __init__(self, max_size: int, fraction: float, letter_box_transform: bool): + self.max_size = int(max_size) + self.fraction = float(fraction) + self.letter_box_transform = letter_box_transform + + def __call__(self, image: np.ndarray) -> torch.Tensor: + array = np.asarray(image) + if array.dtype != np.uint8 or array.ndim != 3 or array.shape[2] != 3: + raise ValueError( + "GR00T eval image transform expects an HWC uint8 RGB frame, " + f"got shape={tuple(array.shape)} dtype={array.dtype}" + ) + if self.letter_box_transform: + array = _letter_box_pad(array) + array = _smallest_edge_resize(array, self.max_size) + array = _fractional_center_crop(array, self.fraction) + array = _smallest_edge_resize(array, self.max_size) + # The crop is a non-contiguous view; the caller stacks frames right after this. + return torch.from_numpy(np.ascontiguousarray(array)).permute(2, 0, 1) + + def _build_eval_image_transform( image_target_size: list[int], image_crop_size: list[int], -) -> transforms.Compose: - """Deterministic eval/inference image transform (letterbox → resize → centercrop → resize).""" - return transforms.Compose( + *, + shortest_image_edge: int | None = None, + crop_fraction: float | None = None, + use_albumentations: bool = False, + letter_box_transform: bool = False, +) -> Callable[[np.ndarray], torch.Tensor]: + """Deterministic eval/inference image transform, selected the way Isaac-GR00T's processor does. + + ``use_albumentations=True``: ``[letterbox] -> resize (shortest edge) -> fractional centre crop -> resize`` + (:class:`AlbumentationsEvalImageTransform`). Otherwise the torchvision recipe + ``[letterbox] -> resize -> centre crop -> resize``. Letterboxing is gated by ``letter_box_transform`` + on both paths. + """ + if use_albumentations: + if crop_fraction is None: + if image_crop_size is None or image_target_size is None: + raise ValueError("image_crop_size and image_target_size are required when crop_fraction is None") + fraction = image_crop_size[0] / image_target_size[0] + else: + fraction = crop_fraction + if shortest_image_edge is None: + if image_target_size is None: + raise ValueError("image_target_size is required when shortest_image_edge is None") + max_size = image_target_size[0] + else: + max_size = shortest_image_edge + return AlbumentationsEvalImageTransform(max_size, fraction, letter_box_transform) + + steps: list[Callable[..., torch.Tensor]] = [transforms.ToImage()] + if letter_box_transform: + steps.append(LetterBoxTransform()) + steps.extend( [ - transforms.ToImage(), - LetterBoxTransform(), transforms.Resize(size=image_target_size), transforms.CenterCrop(size=image_crop_size), transforms.Resize(size=image_target_size), ] ) + return transforms.Compose(steps) logger = init_logger(__name__) @@ -191,6 +291,9 @@ class Gr00tN1d7Processor(ProcessorMixin): exclude_state: bool = False, # Normalization use_mean_std: bool = False, + # Image pipeline selection; must match the recipe the checkpoint was trained with + use_albumentations: bool = False, + letter_box_transform: bool = False, **kwargs, # absorb deprecated training-only keys from saved processor_config.json ): if transformers_loading_kwargs is None: @@ -237,6 +340,8 @@ class Gr00tN1d7Processor(ProcessorMixin): self.embodiment_id_mapping[k] = v self.shortest_image_edge = shortest_image_edge self.crop_fraction = crop_fraction + self.use_albumentations = use_albumentations + self.letter_box_transform = letter_box_transform self.statistics: dict[str, dict[str, dict[str, dict[str, list[float]]]]] = {} @@ -244,6 +349,10 @@ class Gr00tN1d7Processor(ProcessorMixin): self.eval_image_transform = _build_eval_image_transform( image_target_size, image_crop_size, + shortest_image_edge=shortest_image_edge, + crop_fraction=crop_fraction, + use_albumentations=use_albumentations, + letter_box_transform=letter_box_transform, ) self._collator = self.data_collator_class( model_name=model_name, @@ -428,7 +537,7 @@ class Gr00tN1d7Processor(ProcessorMixin): image_keys: list[str], images: list[Image.Image], masks: dict[str, list[np.ndarray]] | None, - image_transform: transforms.Compose, + image_transform: Callable[[np.ndarray], torch.Tensor], language: str, ): temporal_stacked_images = {} @@ -470,6 +579,8 @@ class Gr00tN1d7Processor(ProcessorMixin): "color_jitter_params": self.color_jitter_params, "shortest_image_edge": self.shortest_image_edge, "crop_fraction": self.crop_fraction, + "use_albumentations": self.use_albumentations, + "letter_box_transform": self.letter_box_transform, "model_name": self.model_name, "model_type": self.model_type, "formalize_language": self.formalize_language, From 5378b77be4213887281613adc422887bfdcfae69 Mon Sep 17 00:00:00 2001 From: Shuolei Wang <46160365+ShuoleiWang@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:07:08 +0800 Subject: [PATCH 41/90] [Model][JoyAI-VL-Interaction] Add native multi-stage pipeline (#5352) Signed-off-by: Shuolei Wang --- recipes/JD/JoyAI-VL-Interaction.md | 48 ++++++ tests/config/test_omni_config.py | 11 ++ .../test_orchestrator_error_handling.py | 141 +++++++++++++++++- .../test_joyai_vl_interaction.py | 98 ++++++++++++ vllm_omni/config/pipeline_registry.py | 4 + vllm_omni/deploy/joyai_vl_interaction.yaml | 79 ++++++++++ vllm_omni/engine/orchestrator.py | 22 ++- vllm_omni/engine/stage_engine_core_client.py | 68 +++++---- .../models/joyai_vl_interaction/__init__.py | 3 + .../models/joyai_vl_interaction/pipeline.py | 65 ++++++++ .../joyai_vl_interaction.py | 129 ++++++++++++++++ 11 files changed, 638 insertions(+), 30 deletions(-) create mode 100644 tests/model_executor/stage_input_processors/test_joyai_vl_interaction.py create mode 100644 vllm_omni/deploy/joyai_vl_interaction.yaml create mode 100644 vllm_omni/model_executor/models/joyai_vl_interaction/__init__.py create mode 100644 vllm_omni/model_executor/models/joyai_vl_interaction/pipeline.py create mode 100644 vllm_omni/model_executor/stage_input_processors/joyai_vl_interaction.py diff --git a/recipes/JD/JoyAI-VL-Interaction.md b/recipes/JD/JoyAI-VL-Interaction.md index e09d5b16c..7efc45d38 100644 --- a/recipes/JD/JoyAI-VL-Interaction.md +++ b/recipes/JD/JoyAI-VL-Interaction.md @@ -219,3 +219,51 @@ the audio-track caveat. 5 chunks, `key_frames=0` = summarize all chunk frames) are aligned to the JoyVL reference adapter so per-tick behavior matches the released model; the framework only supplies the serving structure. + +## Native multi-stage pipeline (experimental) + +An experimental native speech path is available alongside the existing Day-0 serving path: + +```text +one request with image/video and text + -> JoyAI complete action text + -> Qwen3-TTS Talker + -> Code2Wav + -> 24 kHz audio +``` + +It runs behind one `vllm serve --omni` endpoint: + +```bash +vllm serve jdopensource/JoyAI-VL-Interaction-Preview --omni \ + --deploy-config vllm_omni/deploy/joyai_vl_interaction.yaml \ + --port 8091 +``` + +With `modalities: ["text", "audio"]`, its behavior is: + +| JoyAI action | Native output | +|---|---| +| ` ` | action text + speech for `` | +| `` | action text + empty audio output (zero samples) | +| ` ` | action text + speech for `` only | + +Native speech supports the Qwen3-TTS `CustomVoice` task. The request-level `voice` and +`language` fields select the speaker and language; when omitted, they default to +`Vivian` and `Auto`. Values supplied as `tts_speaker` and `tts_language` inside +`additional_information` take priority. + +This experimental native multi-stage pipeline is request-scoped, stateless, and +all-sync. It currently does not include: + +- continuous frame sessions, persistent standing instructions, or session memory; +- audio input / ASR, full-duplex interaction, or barge-in; +- background Agent execution for delegated questions; +- async-chunk support from Talker to Code2Wav; +- integration with the existing WebUI. + +For actions with spoken text, the Talker starts only after the complete JoyAI action is +available, and Code2Wav starts only after the Talker completes. Silence actions skip both +TTS stages and return an empty audio output with zero samples. All three stages are placed +on GPU 0 by the provided +[`deploy config`](../../vllm_omni/deploy/joyai_vl_interaction.yaml). diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index aed9c970d..0846a1ec6 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -532,6 +532,17 @@ def test_from_pipeline_config_dispatches_async_chunk_processors_without_mutating assert pipeline.get_stage(1).custom_process_input_func is None +def test_joyai_code2wav_waits_for_full_payload(): + config = _from_pipeline_key("joyai_vl_interaction") + talker = config.stage_by_id(1) + code2wav = config.stage_by_id(2) + + assert talker.custom_process_next_stage_input_func.endswith("talker2code2wav_full_payload") + assert code2wav.custom_process_input_func.endswith("talker2code2wav_token_only") + assert code2wav.connector_config.async_chunk is False + assert code2wav.model_config.requires_full_payload_input is True + + def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations(): assert not hasattr(BaseVllmOmniStageConfig, "from_stage_config") assert not hasattr(BaseVllmOmniStageConfig, "to_legacy_stage_config") diff --git a/tests/engine/test_orchestrator_error_handling.py b/tests/engine/test_orchestrator_error_handling.py index 90db55edb..eaa246072 100644 --- a/tests/engine/test_orchestrator_error_handling.py +++ b/tests/engine/test_orchestrator_error_handling.py @@ -16,7 +16,7 @@ from types import SimpleNamespace import janus import pytest -from vllm.sampling_params import SamplingParams +from vllm.sampling_params import RequestOutputKind, SamplingParams from vllm.v1.engine.exceptions import EngineDeadError from vllm.v1.serial_utils import MsgpackEncoder @@ -33,11 +33,16 @@ from vllm_omni.engine.orchestrator import ( cleanup_request_artifact_dirs, ) from vllm_omni.engine.stage_pool import StageUnavailableError +from vllm_omni.errors import OmniClientError from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.model_executor.stage_input_processors import ( + joyai_vl_interaction as joyai_bridge, +) from vllm_omni.outputs import OmniRequestOutput from .test_orchestrator import ( FakeOutputProcessor, + FakeRunningCounter, FakeStageClient, OrchestratorFixture, _build_harness, @@ -45,6 +50,7 @@ from .test_orchestrator import ( _build_stage_pools, _engine_core_outputs, _enqueue_add_request, + _get_output_message, _wait_for, ) @@ -935,6 +941,139 @@ async def test_diffusion_client_error_output_propagates_status_code(orchestrator orchestrator_fixture.thread.join(timeout=5) +@pytest.mark.asyncio +@pytest.mark.parametrize("event_driven", [False, True], ids=["legacy", "event-driven"]) +@pytest.mark.parametrize("first_speaker", ["Ryan", "not-a-real-speaker"]) +async def test_joyai_tts_request_validation_keeps_orchestrator_running( + orchestrator_factory, + monkeypatch: pytest.MonkeyPatch, + first_speaker: str, + event_driven: bool, +) -> None: + """Check that the same orchestrator can handle the next valid request.""" + monkeypatch.setenv("VLLM_OMNI_EVENT_DRIVEN_ORCH", "1" if event_driven else "0") + + # Use a fixed token count so the test does not need tokenizer files. + class FakeTokenizer: + def encode(self, _text: str, *, add_special_tokens: bool) -> list[int]: + return list(range(12)) + + talker_model_config = SimpleNamespace( + max_model_len=64, + hf_config=SimpleNamespace( + talker_config=SimpleNamespace(spk_id={"vivian": 0, "ryan": 1}, codec_language_id={}, spk_is_dialect={}) + ), + ) + monkeypatch.setattr(joyai_bridge, "cached_tokenizer_from_config", lambda _: FakeTokenizer()) + + # Use the real bridge so speaker validation is part of the test. + class JoyAITalkerStage(FakeStageClient): + def process_engine_inputs(self, source_outputs, prompt=None, streaming_context=None): + self.bridge_inputs = joyai_bridge.joyai_action_to_tts( + source_outputs, prompt, self.requires_multimodal_data, target_model_config=talker_model_config + ) + return self.bridge_inputs + + stage0 = FakeStageClient(stage_type="llm", final_output=False) + stage1 = JoyAITalkerStage(stage_type="llm", final_output=True) + stage0_processor, stage1_processor = FakeOutputProcessor(), FakeOutputProcessor() + fixture = orchestrator_factory( + [stage0, stage1], + output_processors=[stage0_processor, stage1_processor], + stage_vllm_configs=[ + SimpleNamespace(model_config=SimpleNamespace(max_model_len=64)), + SimpleNamespace(model_config=talker_model_config), + ], + ) + counter = FakeRunningCounter() + fixture.orchestrator._running_counter = counter + # With a valid speaker, task_type should be changed from Base to CustomVoice. + # An unknown speaker should cause a request error. + first_info = {"tts_task_type": "Base", "speaker": "Vivian", "tts_speaker": first_speaker} + talker_submissions = 0 + # Send both requests to the same orchestrator. The second uses the default Vivian voice. + for index, (request_id, info) in enumerate((("first", first_info), ("good", {})), start=1): + await _enqueue_add_request( + fixture, + request_id=request_id, + prompt=SimpleNamespace(request_id=request_id, prompt_token_ids=[1, 2]), + original_prompt={"additional_information": info}, + sampling_params_list=[_sampling_params(), _sampling_params()], + final_stage_id=1, + ) + await _wait_for(lambda: len(stage0.add_request_calls) == index and counter.value == 1) + stage0_processor.request_outputs = [_build_request_output(request_id, text=" Hello.")] + stage0.push_engine_core_outputs(_engine_core_outputs(f"{request_id}-raw", float(index))) + + if request_id == "first" and first_speaker == "not-a-real-speaker": + # Reject this request before Talker runs, while keeping the engine alive. + error = await _wait_for_error_message(fixture, request_id=request_id) + assert error.status_code == 400 and error.error_type == "BadRequestError" + assert error.fatal is False and error.stage_id == 1 + assert "not-a-real-speaker" in error.error + assert stage1.add_request_calls == [] + else: + talker_submissions += 1 + await _wait_for(lambda: len(stage1.add_request_calls) == talker_submissions or fixture.result_future.done()) + assert not fixture.result_future.done() + assert stage1.add_request_calls[-1][0].request_id == request_id + metadata = stage1.bridge_inputs[0]["additional_information"] + assert metadata["task_type"] == ["CustomVoice"] + assert metadata["speaker"] == [first_speaker if request_id == "first" else "Vivian"] + # Finish the valid request and check its final output. + stage1_processor.request_outputs = [_build_request_output(request_id, text="Completed.")] + stage1.push_engine_core_outputs(_engine_core_outputs(f"{request_id}-talker", float(index))) + output = await _get_output_message(fixture) + assert output.request_id == request_id and output.stage_id == 1 + assert output.finished is True + assert output.engine_outputs.outputs[0].text == "Completed." + + # Wait for full cleanup before sending the next request. + await _wait_for(lambda: request_id not in fixture.orchestrator.request_states and counter.value == 0) + assert all(pool.get_bound_replica_id(request_id) is None for pool in fixture.orchestrator.stage_pools) + assert fixture.thread.is_alive() and not fixture.result_future.done() + + +@pytest.mark.asyncio +async def test_streaming_input_processor_client_error_does_not_forward_terminal_update() -> None: + """Do not send a second update after the first one fails and clears the request.""" + processor_calls = [] + + class FailingStage(FakeStageClient): + def process_engine_inputs(self, *args, **kwargs): + processor_calls.append((args, kwargs)) + raise OmniClientError("Input rejected") + + orchestrator, queues = _build_bare_orchestrator( + _build_stage_pools([[FakeStageClient()], [FailingStage(final_output=True)]]) + ) + state = OrchestratorRequestState( + request_id="bad-stream", + prompt={"prompt_token_ids": [1]}, + sampling_params_list=[SamplingParams(output_kind=RequestOutputKind.CUMULATIVE), _sampling_params()], + final_stage_id=1, + ) + state.streaming.enabled = True + assert state.duplex_identity is None + orchestrator.request_states[state.request_id] = state + try: + await orchestrator._route_output(0, 0, _build_request_output(state.request_id), state, stage_metrics=None) + + assert len(processor_calls) == 1 + error = queues[1].sync_q.get_nowait() + assert isinstance(error, ErrorMessage) + assert error.request_id == state.request_id + assert error.stage_id == 1 + assert error.status_code == 400 + assert error.fatal is False + assert queues[1].sync_q.empty() + assert state.request_id not in orchestrator.request_states + assert all(pool.get_bound_replica_id(state.request_id) is None for pool in orchestrator.stage_pools) + finally: + for q in queues: + q.close() + + @pytest.mark.asyncio async def test_duplex_input_processor_failure_is_request_scoped(orchestrator_factory, monkeypatch) -> None: class FailingStage(FakeStageClient): diff --git a/tests/model_executor/stage_input_processors/test_joyai_vl_interaction.py b/tests/model_executor/stage_input_processors/test_joyai_vl_interaction.py new file mode 100644 index 000000000..0822cfcd2 --- /dev/null +++ b/tests/model_executor/stage_input_processors/test_joyai_vl_interaction.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Tests for JoyAI action-to-TTS conversion.""" + +from types import SimpleNamespace + +import pytest + +from vllm_omni.engine.stage_engine_core_client import StageEngineCoreClient +from vllm_omni.model_executor.stage_input_processors import ( + joyai_vl_interaction as joyai_bridge, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_joyai_action_to_tts_bridge(monkeypatch: pytest.MonkeyPatch) -> None: + """Convert each JoyAI action through the real stage-client entry point.""" + target_model_config = SimpleNamespace( + hf_config=SimpleNamespace(talker_config=SimpleNamespace(spk_id={"vivian": 1, "ryan": 2})) + ) + prompt_length_calls: list[tuple[dict[str, list[str]], object]] = [] + prompt_lengths = { + "Colored squares are visible.": 12, + "I am checking this request now.": 18, + } + + def compute_prompt_length(metadata, model_config): + prompt_length_calls.append((metadata, model_config)) + return prompt_lengths[metadata["text"][0]] + + monkeypatch.setattr( + joyai_bridge, + "_compute_talker_prompt_length", + compute_prompt_length, + ) + + client = object.__new__(StageEngineCoreClient) + client.custom_process_input_func = joyai_bridge.joyai_action_to_tts + client.requires_multimodal_data = False + client.vllm_config = SimpleNamespace(model_config=target_model_config) + + cases = [ + ( + " Colored squares are visible.", + None, + { + "tts_task_type": "Base", + "language": ["Chinese"], + "speaker": ["Ryan"], + }, + ("Colored squares are visible.", "Chinese", "Ryan"), + ), + ("", None, {}, None), + ( + "", + ( + " I am checking this request now. " + " Why is the purple elephant dancing beside a volcano?" + ), + { + "language": "Chinese", + "speaker": "Ryan", + "tts_language": "English", + "tts_speaker": "Vivian", + }, + ("I am checking this request now.", "English", "Vivian"), + ), + ] + + for text, cumulative_text, additional_information, expected in cases: + completion = SimpleNamespace(text=text) + if cumulative_text is not None: + completion.cumulative_text = cumulative_text + + call_count = len(prompt_length_calls) + talker_inputs = client.process_engine_inputs( + [SimpleNamespace(outputs=[completion])], + prompt={"additional_information": additional_information}, + ) + + if expected is None: + assert talker_inputs == [] + assert len(prompt_length_calls) == call_count + continue + + spoken_text, language, speaker = expected + [talker_input] = talker_inputs + metadata = talker_input["additional_information"] + assert talker_input["prompt_token_ids"] == [0] * prompt_lengths[spoken_text] + assert metadata == { + "task_type": ["CustomVoice"], + "language": [language], + "speaker": [speaker], + "instruct": [""], + "text": [spoken_text], + } + assert prompt_length_calls[-1] == (metadata, target_model_config) diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 7b14f49b1..5586b956e 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -67,6 +67,9 @@ from vllm_omni.model_executor.models.indextts2.pipeline import ( INDEXTTS2_PIPELINE, INDEXTTS25_PIPELINE, ) +from vllm_omni.model_executor.models.joyai_vl_interaction.pipeline import ( + JOYAI_VL_INTERACTION_PIPELINE, +) from vllm_omni.model_executor.models.lance.pipeline import LANCE_PIPELINE from vllm_omni.model_executor.models.lingbot_world.pipeline import LINGBOT_WORLD_PIPELINE from vllm_omni.model_executor.models.mammoth_moda2.pipeline import ( @@ -122,6 +125,7 @@ PipelineResolverFunc: TypeAlias = Callable[[PretrainedConfig | None], PipelineCo # --- Multi-stage omni pipelines (LLM-centric; audio / video I/O) --- OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = { "aura_omni": AURA_OMNI_PIPELINE, + "joyai_vl_interaction": JOYAI_VL_INTERACTION_PIPELINE, "qwen2_5_omni": QWEN2_5_OMNI_PIPELINE, "qwen2_5_omni_thinker_only": QWEN2_5_OMNI_THINKER_ONLY_PIPELINE, "personaplex": PERSONAPLEX_PIPELINE, diff --git a/vllm_omni/deploy/joyai_vl_interaction.yaml b/vllm_omni/deploy/joyai_vl_interaction.yaml new file mode 100644 index 000000000..59eaa8f6c --- /dev/null +++ b/vllm_omni/deploy/joyai_vl_interaction.yaml @@ -0,0 +1,79 @@ +# Single-GPU JoyAI-VL-Interaction -> Qwen3-TTS all-sync profile. +pipeline: joyai_vl_interaction +async_chunk: false +trust_remote_code: true + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + codec_chunk_frames: 25 + codec_left_context_frames: 72 + initial_codec_chunk_frames: 1 + # Qwen3-TTS Code2Wav buckets: 25, 72+1, 72+25, 72+72+25, and 300+25 frames. + decode_cudagraph_capture_sizes: [25, 73, 97, 169, 325] + +stages: + # GPU 0: JoyAI action model. + - stage_id: 0 + model: jdopensource/JoyAI-VL-Interaction-Preview + devices: "0" + gpu_memory_utilization: 0.5 + max_num_seqs: 4 + # Some OmniInteract video prompts contain more than 12K input tokens. + max_model_len: 16384 + max_num_batched_tokens: 16384 + limit_mm_per_prompt: {image: 256, video: 1} + enable_prefix_caching: true + default_sampling_params: + temperature: 0.8 + top_p: 0.9 + top_k: 40 + max_tokens: 256 + repetition_penalty: 1.1 + presence_penalty: 1.5 + + # GPU 0: Qwen3-TTS Talker. + - stage_id: 1 + model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice + devices: "0" + gpu_memory_utilization: 0.2 + max_num_seqs: 8 + max_model_len: 4096 + max_num_batched_tokens: 512 + enable_prefix_caching: false + output_connectors: + to_stage_2: connector_of_shared_memory + default_sampling_params: + temperature: 0.9 + top_k: 50 + min_tokens: 2 + max_tokens: 4096 + repetition_penalty: 1.05 + subtalker_sampling_params: + do_sample: true + temperature: 0.9 + top_k: 50 + top_p: 1.0 + + # GPU 0: Qwen3-TTS Code2Wav. + - stage_id: 2 + model: Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice + devices: "0" + gpu_memory_utilization: 0.2 + max_num_seqs: 8 + max_model_len: 65536 + max_num_batched_tokens: 65536 + enable_prefix_caching: false + input_connectors: + from_stage_1: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + repetition_penalty: 1.0 diff --git a/vllm_omni/engine/orchestrator.py b/vllm_omni/engine/orchestrator.py index e86dc7673..299e1dd07 100644 --- a/vllm_omni/engine/orchestrator.py +++ b/vllm_omni/engine/orchestrator.py @@ -75,7 +75,7 @@ from vllm_omni.engine.messages import ( from vllm_omni.engine.orchestrator_monitor import create_orch_monitor, replica_key from vllm_omni.engine.serialization import serialize_additional_information from vllm_omni.engine.stage_pool import StagePool, StageUnavailableError -from vllm_omni.errors import DEFAULT_CLIENT_ERROR_TYPE +from vllm_omni.errors import DEFAULT_CLIENT_ERROR_TYPE, OmniClientError from vllm_omni.metrics import definitions as metric_defs from vllm_omni.metrics.prometheus import OmniRequestCounter from vllm_omni.metrics.stat_logger import OmniPrometheusStatLogger @@ -1647,9 +1647,11 @@ class Orchestrator: stage_id: int, error: str, *, + status_code: int = HTTPStatus.BAD_REQUEST.value, + error_type: str = DEFAULT_CLIENT_ERROR_TYPE, close_duplex_sessions: bool = False, ) -> None: - """Fail one request with a non-fatal 400 (bad input, engine survives). + """Fail one request with a non-fatal client error (400 by default). The non-fatal counterpart of `_fail_request_dead_stage`: emits a client-error ErrorMessage (default `fatal=False`) so the engine keeps @@ -1658,8 +1660,8 @@ class Orchestrator: await self.output_async_queue.put( ErrorMessage( error=error, - status_code=HTTPStatus.BAD_REQUEST.value, - error_type=DEFAULT_CLIENT_ERROR_TYPE, + status_code=status_code, + error_type=error_type, request_id=req_id, stage_id=stage_id, ) @@ -2073,6 +2075,8 @@ class Orchestrator: ) if ( req_state.streaming.enabled + # A failed forward may already have cleaned up this request. + and self.request_states.get(req_id) is req_state and finished and not final_only_finished and not self._is_duplex_session_request(req_state) @@ -2734,6 +2738,16 @@ class Orchestrator: req_state.prompt, streaming_context=req_state.streaming, ) + except OmniClientError as exc: + await self._fail_request_client_error( + req_id, + next_logical, + str(exc), + status_code=exc.status_code, + error_type=exc.error_type, + close_duplex_sessions=self._is_duplex_session_request(req_state), + ) + return except Exception as exc: logger.exception( "[Orchestrator] req=%s process_engine_inputs FAILED for stage-%s", diff --git a/vllm_omni/engine/stage_engine_core_client.py b/vllm_omni/engine/stage_engine_core_client.py index f96cbd1d3..87baaed5f 100644 --- a/vllm_omni/engine/stage_engine_core_client.py +++ b/vllm_omni/engine/stage_engine_core_client.py @@ -396,36 +396,54 @@ class StageEngineCoreClientBase(StageClientBase): and the original prompt. """ if self.custom_process_input_func is not None: - signature = inspect.signature(self.custom_process_input_func) - extra_kwargs: dict[str, Any] = {} - if "next_stage_hf_config" in signature.parameters: - # Let a processor size the next stage's prompt from that - # stage's model config (e.g. a talker whose engine positions - # must cover a speaker-prompt prefill). - extra_kwargs["next_stage_hf_config"] = self._stage_hf_config - # Match the context parameter by name, including the - # underscore-prefixed spelling some processors use (e.g. - # MiniCPM-o's ``llm2tts(..., _streaming_context)``), so bridge - # state keeps flowing to them. - if "streaming_context" in signature.parameters or "_streaming_context" in signature.parameters: - return self.custom_process_input_func( - source_outputs, - prompt, - self.requires_multimodal_data, - streaming_context, - **extra_kwargs, - ) - return self.custom_process_input_func( - source_outputs, - prompt, - self.requires_multimodal_data, - **extra_kwargs, - ) + return self._call_custom_process_input(source_outputs, prompt, streaming_context) if not self.engine_input_source: raise ValueError(f"engine_input_source empty for stage {self.stage_id}") return _default_process_engine_inputs(source_outputs, prompt, self.requires_multimodal_data) + def _call_custom_process_input( + self, + source_outputs: list[Any], + prompt: Any, + streaming_context: Any | None, + ) -> list[OmniTokensPrompt]: + """Call a stage input processor with its explicitly requested context.""" + processor = self.custom_process_input_func + assert processor is not None + signature = inspect.signature(processor) + extra_kwargs: dict[str, Any] = {} + if "next_stage_hf_config" in signature.parameters: + # Let a processor size the next stage's prompt from that + # stage's model config (e.g. a talker whose engine positions + # must cover a speaker-prompt prefill). + extra_kwargs["next_stage_hf_config"] = self._stage_hf_config + target_model_config = signature.parameters.get("target_model_config") + if target_model_config is not None: + # The JoyAI bridge needs the Talker tokenizer and model config + # to calculate the exact prompt length. + if target_model_config.kind is not inspect.Parameter.KEYWORD_ONLY: + raise TypeError("target_model_config must be a keyword-only parameter") + extra_kwargs["target_model_config"] = self.vllm_config.model_config + # Match the context parameter by name, including the + # underscore-prefixed spelling some processors use (e.g. + # MiniCPM-o's ``llm2tts(..., _streaming_context)``), so bridge + # state keeps flowing to them. + if "streaming_context" in signature.parameters or "_streaming_context" in signature.parameters: + return processor( + source_outputs, + prompt, + self.requires_multimodal_data, + streaming_context, + **extra_kwargs, + ) + return processor( + source_outputs, + prompt, + self.requires_multimodal_data, + **extra_kwargs, + ) + async def collective_rpc_async( self, method: str, diff --git a/vllm_omni/model_executor/models/joyai_vl_interaction/__init__.py b/vllm_omni/model_executor/models/joyai_vl_interaction/__init__.py new file mode 100644 index 000000000..b38884c62 --- /dev/null +++ b/vllm_omni/model_executor/models/joyai_vl_interaction/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""JoyAI-VL-Interaction native pipeline.""" diff --git a/vllm_omni/model_executor/models/joyai_vl_interaction/pipeline.py b/vllm_omni/model_executor/models/joyai_vl_interaction/pipeline.py new file mode 100644 index 000000000..478ce4a11 --- /dev/null +++ b/vllm_omni/model_executor/models/joyai_vl_interaction/pipeline.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""JoyAI-VL-Interaction -> Qwen3-TTS native all-sync pipeline.""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_JOYAI_INPUT_PROCESSOR = "vllm_omni.model_executor.stage_input_processors.joyai_vl_interaction" +_QWEN3_TTS_INPUT_PROCESSOR = "vllm_omni.model_executor.stage_input_processors.qwen3_tts" + + +JOYAI_VL_INTERACTION_PIPELINE = PipelineConfig( + model_type="joyai_vl_interaction", + model_arch="Qwen3VLForConditionalGeneration", + default_deploy_config_name="joyai_vl_interaction.yaml", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="joyvl", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + final_output=True, + final_output_type="text", + owns_tokenizer=True, + requires_multimodal_data=True, + engine_output_type="text", + sampling_constraints={ + "detokenize": True, + "skip_special_tokens": False, + }, + ), + StagePipelineConfig( + stage_id=1, + model_stage="qwen3_tts", + execution_type=StageExecutionType.LLM_AR, + input_sources=(0,), + owns_tokenizer=True, + engine_output_type="latent", + model_arch="Qwen3TTSTalkerForConditionalGeneration", + custom_process_input_func=f"{_JOYAI_INPUT_PROCESSOR}.joyai_action_to_tts", + custom_process_next_stage_input_func=f"{_QWEN3_TTS_INPUT_PROCESSOR}.talker2code2wav_full_payload", + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [2150], + }, + ), + StagePipelineConfig( + stage_id=2, + model_stage="code2wav", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(1,), + final_output=True, + final_output_type="audio", + engine_output_type="audio", + model_arch="Qwen3TTSCode2Wav", + sync_process_input_func=f"{_QWEN3_TTS_INPUT_PROCESSOR}.talker2code2wav_token_only", + requires_full_payload_input=True, + sampling_constraints={"detokenize": True}, + extras={"tts_args": {"max_instructions_length": 500}}, + ), + ), +) diff --git a/vllm_omni/model_executor/stage_input_processors/joyai_vl_interaction.py b/vllm_omni/model_executor/stage_input_processors/joyai_vl_interaction.py new file mode 100644 index 000000000..77b86691c --- /dev/null +++ b/vllm_omni/model_executor/stage_input_processors/joyai_vl_interaction.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Build Qwen3-TTS Talker inputs from completed JoyAI actions.""" + +from typing import Any + +from vllm.outputs import RequestOutput +from vllm.tokenizers import cached_tokenizer_from_config + +from vllm_omni.errors import OmniClientError +from vllm_omni.experimental.fullduplex.joyvl.decision.output_parser import ( + parse_action, +) +from vllm_omni.inputs.data import OmniTokensPrompt +from vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder import ( + Qwen3TTSPromptEmbedsBuilder, + first_value, +) + +# Match the standalone Qwen3-TTS default attributes +_DEFAULT_TTS_TASK_TYPE = "CustomVoice" +_DEFAULT_TTS_LANGUAGE = "Auto" +_DEFAULT_TTS_SPEAKER = "Vivian" + + +def _extract_completed_action_text(joyai_output: RequestOutput) -> str: + """Return the final JoyAI action text, using cumulative text when available.""" + completion = joyai_output.outputs[0] + action_text = getattr(completion, "cumulative_text", None) or completion.text + return action_text if isinstance(action_text, str) else "" + + +def _build_tts_metadata(request_prompt: object, spoken_text: str) -> dict[str, list[str]]: + """Build Qwen3-TTS metadata for the text JoyAI chose to speak.""" + prompt_dict = request_prompt if isinstance(request_prompt, dict) else {} + raw_additional_info = prompt_dict.get("additional_information") + additional_info = raw_additional_info if isinstance(raw_additional_info, dict) else {} + + # The native pipeline loads CustomVoice checkpoints for both TTS stages. + # Keep client metadata from selecting an incompatible Qwen3-TTS task. + task_type = _DEFAULT_TTS_TASK_TYPE + request_language = first_value(additional_info.get("language"), _DEFAULT_TTS_LANGUAGE) + language = first_value(additional_info.get("tts_language"), request_language) + request_speaker = first_value(additional_info.get("speaker"), _DEFAULT_TTS_SPEAKER) + speaker = first_value(additional_info.get("tts_speaker"), request_speaker) + instruction = first_value(additional_info.get("tts_instruct"), "") + + return { + "task_type": [str(task_type)], + "language": [str(language)], + "speaker": [str(speaker)], + "instruct": [str(instruction)], + "text": [spoken_text], + } + + +def _validate_talker_speaker(tts_metadata: dict[str, list[str]], talker_model_config: Any) -> None: + """Validate the selected voice using the destination Talker's speaker table.""" + talker_config = getattr(talker_model_config.hf_config, "talker_config", None) + if talker_config is None: + raise ValueError("The target stage is not a Qwen3-TTS Talker.") + + # Match CustomVoice prompt construction, including case and whitespace. + speaker = str(first_value(tts_metadata.get("speaker"), "")).lower().strip() + speakers = {name.lower() for name in (getattr(talker_config, "spk_id", None) or {})} + if not speaker or speaker not in speakers: + raise OmniClientError(f"Unsupported speaker: {speaker!r}") + + +def _compute_talker_prompt_length( + tts_metadata: dict[str, list[str]], + talker_model_config: Any, +) -> int: + """Compute the exact number of prompt positions used by the Talker.""" + task_type = str(first_value(tts_metadata.get("task_type"), _DEFAULT_TTS_TASK_TYPE)) + if task_type != _DEFAULT_TTS_TASK_TYPE: + raise ValueError("JoyAI native TTS supports only CustomVoice.") + + talker_tokenizer = cached_tokenizer_from_config(talker_model_config) + if talker_tokenizer is None: + raise ValueError("The Qwen3-TTS Talker must have an initialized tokenizer.") + + talker_config = getattr(talker_model_config.hf_config, "talker_config", None) + if talker_config is None: + raise ValueError("The target stage is not a Qwen3-TTS Talker.") + + def tokenize_prompt(text: str) -> list[int]: + return talker_tokenizer.encode(text, add_special_tokens=True) + + return Qwen3TTSPromptEmbedsBuilder.estimate_prompt_len_from_additional_information( + additional_information=tts_metadata, + task_type=task_type, + tokenize_prompt=tokenize_prompt, + codec_language_id=getattr(talker_config, "codec_language_id", None), + spk_is_dialect=getattr(talker_config, "spk_is_dialect", None), + ) + + +def joyai_action_to_tts( + source_outputs: list[RequestOutput], + prompt: object | None = None, + requires_multimodal_data: bool = False, + *, + target_model_config: Any, +) -> list[OmniTokensPrompt]: + """Build Talker inputs for JoyAI actions that contain text to speak.""" + del requires_multimodal_data + + request_prompts = prompt if isinstance(prompt, list) else [prompt] * len(source_outputs) + talker_inputs: list[OmniTokensPrompt] = [] + for request_index, joyai_output in enumerate(source_outputs): + parsed_action = parse_action(_extract_completed_action_text(joyai_output)) + if not parsed_action.spoke or not parsed_action.text: + continue + + request_prompt = request_prompts[request_index] if request_index < len(request_prompts) else None + tts_metadata = _build_tts_metadata(request_prompt, parsed_action.text) + _validate_talker_speaker(tts_metadata, target_model_config) + talker_prompt_length = _compute_talker_prompt_length(tts_metadata, target_model_config) + talker_inputs.append( + OmniTokensPrompt( + # OmniTokensPrompt reserves vllm scheduler positions with zero IDs; + # the Talker builds the actual prompt from tts_metadata. + prompt_token_ids=[0] * talker_prompt_length, + additional_information=tts_metadata, + ) + ) + + return talker_inputs From d59d1664858956976a12797a8613bd4b1d803097 Mon Sep 17 00:00:00 2001 From: Honghan Zhu Date: Tue, 8 Sep 2026 20:44:49 +0800 Subject: [PATCH 42/90] [Bugfix] Preserve additional_config for LLM stages (#7272) Signed-off-by: zhuhh97 Co-authored-by: amy-why-3459 --- tests/config/test_omni_config.py | 52 ++++++++++++++++++++++++++++ vllm_omni/config/omni_config.py | 7 +++- vllm_omni/engine/stage_init_utils.py | 3 +- 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index 0846a1ec6..3e499dc38 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -261,6 +261,57 @@ def test_from_pipeline_config_rejects_unowned_deploy_engine_extras(engine_extras VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy) +@pytest.mark.parametrize("stage_id", [0, 2], ids=["ar", "generation"]) +def test_llm_additional_config_roundtrip_and_isolation(stage_id, monkeypatch): + from vllm_omni.engine import stage_init_utils + from vllm_omni.engine.stage_init_utils import build_engine_args_dict_from_omni_stage_config + + # Worker discovery requires hardware support; this test covers config transport. + monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", lambda engine_args: None) + additional_config = {"backend_options": {"enabled": True}} + pipeline = _resolve_pipeline_or_skip("minicpmo_4_5") + deploy = DeployConfig( + stages=[StageDeployConfig(stage_id=i, engine_extras={"additional_config": additional_config}) for i in (0, 2)] + ) + config = VllmOmniConfig.from_pipeline_config(pipeline, user_deploy_config=deploy) + stage = config.stage_by_id(stage_id) + assert stage.runtime_config.additional_config == additional_config + engine_args = build_engine_args_dict_from_omni_stage_config(stage, model="test-model") + assert engine_args["additional_config"] == additional_config + + engine_args["additional_config"]["backend_options"]["enabled"] = False + assert stage.runtime_config.additional_config["backend_options"]["enabled"] is True + stage.runtime_config.additional_config["backend_options"]["enabled"] = False + assert config.stage_by_id(2 if stage_id == 0 else 0).runtime_config.additional_config == additional_config + assert additional_config["backend_options"]["enabled"] is True + + +@pytest.mark.parametrize("deploy_name", ["minicpmo_4_5", "minicpmo_4_5_2gpu", "minicpmo_4_5_3gpu"]) +def test_minicpmo_npu_additional_config_reaches_engine_args(monkeypatch, deploy_name): + from vllm_omni.engine import stage_init_utils + from vllm_omni.engine.stage_init_utils import build_engine_args_dict_from_omni_stage_config + from vllm_omni.platforms import current_omni_platform + + monkeypatch.setattr(current_omni_platform, "device_name", "npu") + monkeypatch.setattr(stage_init_utils, "resolve_worker_cls", lambda engine_args: None) + stage = _from_pipeline_key("minicpmo_4_5", deploy_config_path=deploy_name).stage_by_id(2) + expected = {"code2wav_enable_npu_graph": True, "code2wav_max_npu_graphs": 32} + assert stage.runtime_config.additional_config == expected + engine_args = build_engine_args_dict_from_omni_stage_config(stage, model="test-model") + assert engine_args["additional_config"] == expected + + +def test_diffusion_additional_config_keeps_diffusion_owner(): + from vllm_omni.engine.stage_init_utils import build_engine_args_dict_from_omni_stage_config + + additional_config = {"torchair_graph_config": {"enabled": True}} + stage = _from_pipeline_key("dreamzero", cli_overrides={"additional_config": additional_config}).stage_by_id(0) + assert stage.runtime_config.additional_config is None + assert stage.diffusion_config.additional_config == additional_config + engine_args = build_engine_args_dict_from_omni_stage_config(stage, model="test-model") + assert engine_args["additional_config"] == additional_config + + @pytest.mark.parametrize( ("engine_extras", "cli_overrides"), [ @@ -570,6 +621,7 @@ def test_vllm_omni_stage_config_public_fields_use_typed_stage_realizations(): def test_runtime_config_fields_match_structured_runtime_scope(): assert {f.name for f in fields(OmniStageRuntimeConfig)} == { + "additional_config", "distributed_executor_backend", "worker_cls", "devices", diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index e6825bd17..aaa9c3512 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -197,6 +197,7 @@ class _SchedulerEngineOverrides(TypedDict, total=False): class _RuntimeEngineOverrides(TypedDict, total=False): + additional_config: dict[str, Any] distributed_executor_backend: Any worker_cls: str devices: str @@ -525,6 +526,8 @@ class OmniStageConnectorConfig: class OmniStageRuntimeConfig: """Per-stage process placement and backend runtime behavior.""" + # LLM backend extensions; diffusion owns these in its config projection. + additional_config: dict[str, Any] | None = None distributed_executor_backend: Any = None worker_cls: str | None = None devices: str | None = None @@ -1272,10 +1275,12 @@ def _stage_engine_values( load_engine_fields = _LLM_LOAD_ENGINE_FIELDS cache_engine_fields = _LLM_CACHE_ENGINE_FIELDS scheduler_engine_fields = _LLM_SCHEDULER_ENGINE_FIELDS + runtime_engine_fields = _RUNTIME_ENGINE_FIELDS else: load_engine_fields = _LOAD_ENGINE_FIELDS cache_engine_fields = _CACHE_ENGINE_FIELDS scheduler_engine_fields = _SCHEDULER_ENGINE_FIELDS + runtime_engine_fields = _RUNTIME_ENGINE_FIELDS - {"additional_config"} return _StageEngineValues( quantization=cast( _QuantizationEngineOverrides, @@ -1292,7 +1297,7 @@ def _stage_engine_values( _ConnectorEngineOverrides, _select_engine_overrides(engine, _CONNECTOR_ENGINE_FIELDS), ), - runtime=cast(_RuntimeEngineOverrides, _select_engine_overrides(engine, _RUNTIME_ENGINE_FIELDS)), + runtime=cast(_RuntimeEngineOverrides, _select_engine_overrides(engine, runtime_engine_fields)), parallel=cast(_ParallelEngineOverrides, _select_engine_overrides(engine, _PARALLEL_ENGINE_FIELDS)), diffusion=_DiffusionEngineOverrides.from_engine(engine), compilation_config=_copy_value(engine.get("compilation_config")), diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index eed686837..0092e40cc 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -1087,7 +1087,8 @@ def _project_omni_stage_engine_args( ), ( stage_config.runtime_config, - frozenset({"devices", "num_replicas", "env", "num_gpus"}), + frozenset({"devices", "num_replicas", "env", "num_gpus"}) + | (frozenset({"additional_config"}) if is_diffusion else frozenset()), ), ): engine_args.update( From a31690041de9a19acfec8239c4bbb64bc3e051d4 Mon Sep 17 00:00:00 2001 From: Yancy <138764723+Asthenia0412@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:56:27 +0800 Subject: [PATCH 43/90] [Bugfix][Examples] Use --profiler-config flag in offline TTS examples (#6763) Signed-off-by: Asthenia Co-authored-by: Asthenia --- .../offline_inference/qwen2_5_omni/end2end.py | 33 +++++++++++++++---- .../text_to_speech/cosyvoice3/end2end.py | 30 +++++++++++++++-- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/examples/offline_inference/qwen2_5_omni/end2end.py b/examples/offline_inference/qwen2_5_omni/end2end.py index 6dbafdfc1..bbeef32b0 100644 --- a/examples/offline_inference/qwen2_5_omni/end2end.py +++ b/examples/offline_inference/qwen2_5_omni/end2end.py @@ -5,10 +5,12 @@ This example shows how to use vLLM-Omni for running offline inference with the correct prompt format on Qwen2.5-Omni """ +import argparse +import functools import json import os import time -from typing import NamedTuple +from typing import Any, NamedTuple import numpy as np import soundfile as sf @@ -26,6 +28,20 @@ from vllm_omni.utils.tracking_parser import TrackingArgumentParser SEED = 42 +def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]: + """Parse a CLI value as a JSON object, attributing errors to ``flag_name``.""" + try: + config = json.loads(value) + except json.JSONDecodeError as e: + raise argparse.ArgumentTypeError(f"{flag_name} must be valid JSON: {e}") from e + if not isinstance(config, dict): + raise argparse.ArgumentTypeError(f"{flag_name} must be a JSON object") + return config + + +parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config") + + class QueryResult(NamedTuple): inputs: dict limit_mm_per_prompt: dict[str, int] @@ -378,11 +394,10 @@ def main(args): for i, prompt in enumerate(prompts): prompt["modalities"] = output_modalities - profiler_enabled = bool(os.getenv("VLLM_TORCH_PROFILER_DIR")) - if profiler_enabled and hasattr(omni, "start_profile"): + profiler_enabled = args.profiler_config is not None + if profiler_enabled: + print("[Profiler] Starting profiling...") omni.start_profile(stages=[0]) - elif profiler_enabled: - print("[Warn] VLLM_TORCH_PROFILER_DIR is set, but current engine does not support profiler controls.") omni_generator = omni.generate(prompts, sampling_params_list, py_generator=args.py_generator) # Determine output directory: prefer --output-dir; fallback to --output-wav @@ -418,7 +433,7 @@ def main(args): print(f"Request ID: {request_id}, Saved audio to {output_wav}") processed_count += 1 - if profiler_enabled and hasattr(omni, "stop_profile") and processed_count >= total_requests: + if profiler_enabled and processed_count >= total_requests: print(f"[Info] Processed {processed_count}/{total_requests}. Stopping profiler inside active loop...") # Stop the profiler while workers are still alive omni.stop_profile() @@ -476,6 +491,12 @@ def parse_args(): default=300, help="Timeout for initializing stages in seconds (default: 300)", ) + parser.add_argument( + "--profiler-config", + type=parse_profiler_config, + default=None, + help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.', + ) parser.add_argument( "--shm-threshold-bytes", type=int, diff --git a/examples/offline_inference/text_to_speech/cosyvoice3/end2end.py b/examples/offline_inference/text_to_speech/cosyvoice3/end2end.py index 62917de07..40e194120 100644 --- a/examples/offline_inference/text_to_speech/cosyvoice3/end2end.py +++ b/examples/offline_inference/text_to_speech/cosyvoice3/end2end.py @@ -1,9 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import argparse +import functools +import json import os import urllib.request from pathlib import Path +from typing import Any import numpy as np import soundfile as sf @@ -29,6 +32,20 @@ def _default_ref_audio() -> str: return str(dest) +def parse_json_object(value: str, flag_name: str = "argument") -> dict[str, Any]: + """Parse a CLI value as a JSON object, attributing errors to ``flag_name``.""" + try: + config = json.loads(value) + except json.JSONDecodeError as e: + raise argparse.ArgumentTypeError(f"{flag_name} must be valid JSON: {e}") from e + if not isinstance(config, dict): + raise argparse.ArgumentTypeError(f"{flag_name} must be a JSON object") + return config + + +parse_profiler_config = functools.partial(parse_json_object, flag_name="--profiler-config") + + def run_e2e(): parser = argparse.ArgumentParser() # ""FunAudioLLM/Fun-CosyVoice3-0.5B-2512 @@ -64,6 +81,12 @@ def run_e2e(): required=True, help="Path to tokenizer directory (e.g., /CosyVoice-BlankEN).", ) + parser.add_argument( + "--profiler-config", + type=parse_profiler_config, + default=None, + help='JSON profiler config for torch/cuda profiling, e.g. \'{"profiler":"torch","torch_profiler_dir":"./perf"}\'.', + ) args = parser.parse_args() # Ensure tokenizer directory exists if not os.path.exists(args.tokenizer): @@ -79,6 +102,7 @@ def run_e2e(): deploy_config=args.deploy_config, tokenizer=args.tokenizer, log_stats=True, + profiler_config=args.profiler_config, ) sampling_cfg = {"top_p": 0.8, "top_k": 25, "eos_token_id": 6561 + 1} @@ -149,8 +173,8 @@ def run_e2e(): sampling_params_list = [gpt_sampling, s2mel_sampling] - # Start profiling (requires VLLM_TORCH_PROFILER_DIR env var) - if os.environ.get("VLLM_TORCH_PROFILER_DIR"): + profiler_enabled = args.profiler_config is not None + if profiler_enabled: print("Starting profiler...") omni.start_profile() @@ -158,7 +182,7 @@ def run_e2e(): outputs = list(omni.generate(prompts, sampling_params_list=sampling_params_list[:2])) # Stop profiling and get results - if os.environ.get("VLLM_TORCH_PROFILER_DIR"): + if profiler_enabled: print("Stopping profiler...") profile_results = omni.stop_profile() print(f"Profile traces saved to: {profile_results}") From 7422a85cc7a156a9131938ebe3afb6c8025c2a40 Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Tue, 8 Sep 2026 22:54:49 +0800 Subject: [PATCH 44/90] [Bugfix] Skip HWR store-size scans when no limit is configured (#7131) Signed-off-by: Hongsheng Liu --- vllm_omni/host_weight_runtime/filesystem/store.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/vllm_omni/host_weight_runtime/filesystem/store.py b/vllm_omni/host_weight_runtime/filesystem/store.py index 0f01e36da..2b58b77e8 100644 --- a/vllm_omni/host_weight_runtime/filesystem/store.py +++ b/vllm_omni/host_weight_runtime/filesystem/store.py @@ -1247,8 +1247,10 @@ class FilesystemHostWeightStore: retryable=True, ) ) - store_bytes = self._tree_bytes(self.root) - if policy.max_store_bytes is not None and store_bytes + additional_bytes > policy.max_store_bytes: + if ( + policy.max_store_bytes is not None + and self._tree_bytes(self.root) + additional_bytes > policy.max_store_bytes + ): raise HostWeightError( _failure( ResolutionStage.CAPACITY, From de1b0708bdb912397ffa016bbbca4ccd42f48592 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Tue, 8 Sep 2026 07:59:37 -0700 Subject: [PATCH 45/90] [CI][ROCm] Route LTX2 Ulysses parity to two-GPU lane (#7234) Signed-off-by: andyluo7 --- .buildkite/amd/test-amd-ready.yml | 13 ++++++++++++- .../models/ltx2/test_ltx2_transformer_ulysses.py | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/.buildkite/amd/test-amd-ready.yml b/.buildkite/amd/test-amd-ready.yml index 695f47dc1..585f089a2 100644 --- a/.buildkite/amd/test-amd-ready.yml +++ b/.buildkite/amd/test-amd-ready.yml @@ -107,6 +107,11 @@ steps: - export VLLM_LOGGING_LEVEL=DEBUG - export VLLM_WORKER_MULTIPROC_METHOD=spawn - timeout 20m pytest -s -v tests/diffusion/distributed/test_sequence_parallel.py -m core_model + - >- + timeout 20m pytest -s -v + tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py + -m 'core_model and rocm and cards_2' + --run-level "core_model" - label: "Diffusion GPU Worker Test" agent_pool: mi300_2 @@ -122,7 +127,13 @@ steps: mirror_hardwares: [amdproduction] grade: Blocking commands: - - timeout 15m pytest -sv tests/diffusion/models/ -m 'core_model and cuda' --run-level "core_model" + # Preserve legacy one-GPU tests without cards_1 while excluding every + # registered multi-GPU marker from this one-GPU job. + - >- + timeout 15m pytest -sv tests/diffusion/models/ + -m 'core_model and cuda and not + (cards_2 or cards_3 or cards_4 or cards_5 or cards_6 or cards_7 or cards_8)' + --run-level "core_model" - label: "Diffusion · Wan22 Test" agent_pool: mi300_1 diff --git a/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py b/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py index 9f09b72a7..0e080e015 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py +++ b/tests/diffusion/models/ltx2/test_ltx2_transformer_ulysses.py @@ -156,10 +156,14 @@ def _run_transformer_parity(rank: int, world_size: int, master_port: int) -> Non @pytest.mark.full_model -@hardware_test(res={"cuda": "L4"}, num_cards=2) +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=2) def test_ltx_ulysses_transformer_matches_sp1(unused_tcp_port) -> None: """Exercise real plan hooks, RoPE/timestep sharding, and attention routing.""" world_size = 2 + device_count = current_omni_platform.device_count() + assert device_count >= world_size, ( + f"LTX Ulysses parity requires {world_size} accelerator devices; only {device_count} are visible" + ) torch.multiprocessing.spawn( _run_transformer_parity, args=(world_size, unused_tcp_port), From 7a80b796986b15840bc30ef134f0d7158dce82ea Mon Sep 17 00:00:00 2001 From: liangmenghuang Date: Tue, 8 Sep 2026 23:53:20 +0800 Subject: [PATCH 46/90] [Bugfix][Model] GR00T-N1.7: honor the per-request seed for flow-matching noise (#7253) Signed-off-by: liangmengh Co-authored-by: Claude Fable 5.1 --- docs/serving/openpi_api.md | 8 +- tests/config/test_environment_variables.py | 4 +- .../diffusion/models/gr00t/test_noise_seed.py | 177 ++++++++++++++++++ tests/diffusion/models/gr00t/test_pipeline.py | 11 +- .../test_gr00t_openpi_expansion.py | 9 +- .../openai_api/test_openpi_serving.py | 15 ++ tests/helpers/client.py | 12 +- .../config/environment_variable_inventory.py | 1 - .../models/gr00t/modeling/gr00t_n1d7.py | 29 ++- .../diffusion/models/gr00t/pipeline_gr00t.py | 39 +++- vllm_omni/diffusion/models/gr00t/policy.py | 7 +- vllm_omni/entrypoints/openpi/serving.py | 14 +- 12 files changed, 285 insertions(+), 41 deletions(-) create mode 100644 tests/diffusion/models/gr00t/test_noise_seed.py diff --git a/docs/serving/openpi_api.md b/docs/serving/openpi_api.md index 27f092d87..52d94cbbf 100644 --- a/docs/serving/openpi_api.md +++ b/docs/serving/openpi_api.md @@ -64,7 +64,7 @@ connect <- msgpack(policy_server_config) infer - -> msgpack({"endpoint": "infer", "session_id": "...", ...observation}) + -> msgpack({"endpoint": "infer", "session_id": "...", "seed": , ...observation}) <- msgpack(ndarray | dict[str, ndarray]) reset @@ -92,6 +92,12 @@ frames are not observation messages. - The policy pipeline owns observation transforms and persistent model state, normally keyed by `session_id`; the API layer forwards the raw observation dictionary. +- An optional integer `seed` in an inference message becomes the engine + request's `sampling_params.seed`. A policy whose sampling consumes the + request generator (GR00T-N1.7) returns the same action chunk for the same + observation and seed. If omitted, the engine assigns a random per-request + seed, so unseeded inferences are independent of each other. The API layer + consumes `seed`; it is not forwarded inside the observation. ## Limits and Errors diff --git a/tests/config/test_environment_variables.py b/tests/config/test_environment_variables.py index 52ff1e274..7603945a0 100644 --- a/tests/config/test_environment_variables.py +++ b/tests/config/test_environment_variables.py @@ -167,7 +167,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): EnvironmentVariableCategory.PUBLIC_OMNI: 24, EnvironmentVariableCategory.INHERITED_VLLM: 20, EnvironmentVariableCategory.PLATFORM_EXTERNAL: 27, - EnvironmentVariableCategory.MODEL_SPECIFIC: 62, + EnvironmentVariableCategory.MODEL_SPECIFIC: 61, EnvironmentVariableCategory.BENCHMARK_TRANSITIONAL: 20, EnvironmentVariableCategory.INTERNAL: 2, } @@ -179,7 +179,7 @@ def test_inventory_matches_reviewed_snapshot_counts(): ) assert {disposition: disposition_counts[disposition] for disposition in ModelEnvironmentVariableDisposition} == { ModelEnvironmentVariableDisposition.PROMOTE: 37, - ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 7, + ModelEnvironmentVariableDisposition.REQUEST_SCOPE: 6, ModelEnvironmentVariableDisposition.EXTERNAL: 0, ModelEnvironmentVariableDisposition.INTERNALIZE: 13, ModelEnvironmentVariableDisposition.DEPRECATE_REMOVE: 5, diff --git a/tests/diffusion/models/gr00t/test_noise_seed.py b/tests/diffusion/models/gr00t/test_noise_seed.py new file mode 100644 index 000000000..a865d9a05 --- /dev/null +++ b/tests/diffusion/models/gr00t/test_noise_seed.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""The request's seed must reach GR00T's flow-matching noise. + +The runner builds ``sampling_params.generator`` from ``sampling_params.seed``, the pipeline hands +that generator to the policy, and the action head draws its initial noise from it, so the same +seed reproduces the same action chunk and different seeds give independent ones. These tests pin +both ends of that plumbing. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import numpy as np +import pytest +import torch +from torch import nn +from transformers.feature_extraction_utils import BatchFeature + +from vllm_omni.diffusion.models.gr00t import pipeline_gr00t +from vllm_omni.diffusion.models.gr00t.configs.gr00t_n1d7 import Gr00tN1d7Config +from vllm_omni.diffusion.models.gr00t.modeling.gr00t_n1d7 import Gr00tN1d7ActionHead +from vllm_omni.diffusion.models.gr00t.pipeline_gr00t import Gr00tN1d7Pipeline +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.inputs.data import OmniDiffusionSamplingParams + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +ROBOT_OBS = { + "images": {"cam": np.zeros((1, 1, 8, 8, 3), dtype=np.uint8)}, + "state": {"joint": np.zeros((1, 1, 2), dtype=np.float32)}, + "prompt": "pick the cube", +} + + +class _OptionsRecordingPolicy: + """Stand-in for ``Gr00tPolicy``; the pipeline only needs ``language_key`` and ``get_action``.""" + + def __init__(self, *, model_path: str, embodiment_tag: str, device: str, strict: bool) -> None: + del model_path, embodiment_tag, device, strict # the pipeline's constructor contract; unused here + self.language_key = "annotation.language.language_instruction" + self.seen_options: dict[str, object] | None = None + + def get_action(self, obs: dict[str, object], options: dict[str, object] | None = None): + self.seen_options = options + return {"arm": np.zeros((1, 2, 2), dtype=np.float32)}, {} + + +@pytest.fixture +def pipeline(monkeypatch) -> Gr00tN1d7Pipeline: + monkeypatch.setattr(pipeline_gr00t, "Gr00tPolicy", _OptionsRecordingPolicy) + od_config = SimpleNamespace( + model="nvidia/GR00T-N1.7-3B", + model_config={"embodiment_tag": "LIBERO_PANDA", "strict": False}, + custom_pipeline_args={}, + ) + return Gr00tN1d7Pipeline(od_config=od_config) + + +def _request(**sampling_kwargs) -> OmniDiffusionRequest: + return OmniDiffusionRequest( + prompt="pick", + request_id="req", + sampling_params=OmniDiffusionSamplingParams(extra_args={"robot_obs": ROBOT_OBS}, **sampling_kwargs), + ) + + +def test_runner_generator_is_passed_through_untouched(pipeline): + """In serving the runner has already built ``sampling_params.generator``; the head must get that object.""" + generator = torch.Generator().manual_seed(99) + + assert pipeline.forward(_request(generator=generator)).error is None + + assert pipeline.policy.seen_options["generator"] is generator + + +def test_seed_without_generator_builds_one_on_the_pipeline_device(pipeline): + """Callers that bypass the runner (direct use, tests) still get the seed honoured.""" + pipeline.forward(_request(seed=1234)) + + generator = pipeline.policy.seen_options["generator"] + assert isinstance(generator, torch.Generator) + assert generator.initial_seed() == 1234 + assert generator.device.type == pipeline.device + + +def test_auto_assigned_seed_reaches_the_head(pipeline): + """``OmniDiffusionRequest`` seeds every request, so the head is never left on the global RNG.""" + req = _request() + assert req.sampling_params.seed is not None + + pipeline.forward(req) + + assert pipeline.policy.seen_options["generator"].initial_seed() == req.sampling_params.seed + + +def _captured_warnings(monkeypatch) -> list[tuple]: + """vLLM loggers do not propagate to the root logger, so ``caplog`` cannot see them; record calls instead.""" + captured: list[tuple] = [] + monkeypatch.setattr(pipeline_gr00t.logger, "warning", lambda *args, **kwargs: captured.append(args)) + return captured + + +def test_unexpected_generator_form_warns_and_falls_back_to_the_seed(pipeline, monkeypatch): + """GR00T draws one noise tensor per request, so a list of several generators cannot be honoured.""" + warnings = _captured_warnings(monkeypatch) + generators = [torch.Generator().manual_seed(1), torch.Generator().manual_seed(2)] + + pipeline.forward(_request(generator=generators, seed=7)) + + assert len(warnings) == 1 + assert "torch.Generator" in warnings[0][0] + assert pipeline.policy.seen_options["generator"].initial_seed() == 7 + + +def test_unexpected_generator_form_without_a_seed_warns_and_leaves_the_global_rng(pipeline, monkeypatch): + warnings = _captured_warnings(monkeypatch) + generators = [torch.Generator().manual_seed(1), torch.Generator().manual_seed(2)] + + pipeline.forward(_request(generator=generators)) + + assert len(warnings) == 1 + assert pipeline.policy.seen_options is None + + +def _noise_only_head(action_horizon: int, action_dim: int) -> Gr00tN1d7ActionHead: + """An action head whose single denoising step is the identity, so ``action_pred`` is the initial noise.""" + config = Gr00tN1d7Config( + max_action_dim=action_dim, + action_horizon=action_horizon, + num_inference_timesteps=1, + add_pos_embed=False, + use_alternate_vl_dit=False, + ) + head = Gr00tN1d7ActionHead.__new__(Gr00tN1d7ActionHead) + nn.Module.__init__(head) + head.config = config + head.action_dim = config.max_action_dim + head.action_horizon = config.action_horizon + head.num_inference_timesteps = config.num_inference_timesteps + head.num_timestep_buckets = config.num_timestep_buckets + head.action_encoder = lambda actions, timesteps, embodiment_id: actions + head.model = lambda hidden_states, encoder_hidden_states, timestep: hidden_states + head.action_decoder = lambda hidden_states, embodiment_id: torch.zeros_like(hidden_states) + return head + + +def _predict(head: Gr00tN1d7ActionHead, options) -> torch.Tensor: + batch, seq, dim = 2, 3, head.action_dim + return head.get_action_with_features( + backbone_features=torch.zeros(batch, seq, dim), + state_features=torch.zeros(batch, 1, dim), + embodiment_id=torch.zeros(batch, dtype=torch.long), + backbone_output=BatchFeature(data={}), + action_input=BatchFeature(data={}), + options=options, + )["action_pred"] + + +def test_head_draws_its_noise_from_the_request_generator(): + head = _noise_only_head(action_horizon=4, action_dim=3) + expected = torch.randn(2, 4, 3, generator=torch.Generator().manual_seed(5)) + + assert torch.equal(_predict(head, {"generator": torch.Generator().manual_seed(5)}), expected) + assert not torch.equal(_predict(head, {"generator": torch.Generator().manual_seed(6)}), expected) + + +def test_head_without_generator_stays_on_the_global_rng(): + """Upstream Isaac-GR00T parity: no generator means a bare ``torch.randn`` on the global RNG.""" + head = _noise_only_head(action_horizon=4, action_dim=3) + + torch.manual_seed(5) + expected = torch.randn(2, 4, 3) + torch.manual_seed(5) + + assert torch.equal(_predict(head, None), expected) diff --git a/tests/diffusion/models/gr00t/test_pipeline.py b/tests/diffusion/models/gr00t/test_pipeline.py index 322112107..3310d049e 100644 --- a/tests/diffusion/models/gr00t/test_pipeline.py +++ b/tests/diffusion/models/gr00t/test_pipeline.py @@ -1,4 +1,8 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from types import SimpleNamespace +from typing import Any import numpy as np import pytest @@ -12,12 +16,12 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] class FakeGr00tPolicy: - instances = [] + instances: list["FakeGr00tPolicy"] = [] def __init__(self, **kwargs): self.kwargs = kwargs self.reset_calls = 0 - self.seen_obs = None + self.seen_obs: dict[str, Any] | None = None self.embodiment_tag = SimpleNamespace(value="fake_embodiment") self.language_key = "annotation.language.language_instruction" self.modality_configs = { @@ -40,7 +44,7 @@ class FakeGr00tPolicy: ) FakeGr00tPolicy.instances.append(self) - def get_action(self, obs): + def get_action(self, obs, options=None): self.seen_obs = obs return { "arm": np.array([[[1.0, 2.0]]], dtype=np.float64), @@ -107,6 +111,7 @@ def test_forward_returns_dict_actions_in_output(): assert actions["arm"].dtype == np.float32 np.testing.assert_allclose(actions["arm"], np.array([[[1.0, 2.0]]], dtype=np.float32)) policy = FakeGr00tPolicy.instances[0] + assert policy.seen_obs is not None assert "video" in policy.seen_obs assert policy.seen_obs["language"] == {"annotation.language.language_instruction": [["pick the cube"]]} assert "images" not in policy.seen_obs diff --git a/tests/e2e/online_serving/test_gr00t_openpi_expansion.py b/tests/e2e/online_serving/test_gr00t_openpi_expansion.py index 70e11088d..2f81ab3fa 100644 --- a/tests/e2e/online_serving/test_gr00t_openpi_expansion.py +++ b/tests/e2e/online_serving/test_gr00t_openpi_expansion.py @@ -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 """End-to-end online serving test for GR00T N1.7 through the OpenPI robot endpoint.""" import os @@ -29,8 +29,7 @@ test_params = [ OmniServerParams( model=MODEL, stage_config_path=get_deploy_config_path("Gr00tN1d7.yaml"), - server_args=["--disable-log-stats"], - env_dict={"VLLM_DISABLE_COMPILE_CACHE": "1", "GR00T_NOISE_SEED": "42"}, + env_dict={"VLLM_DISABLE_COMPILE_CACHE": "1"}, init_timeout=1200, stage_init_timeout=900, ), @@ -162,14 +161,14 @@ def test_gr00t_n1d7_openpi_online(omni_server, openai_client) -> None: @hardware_test(res={"cuda": "L4"}, num_cards=1) @pytest.mark.parametrize("omni_server", test_params, indirect=True) def test_gr00t_n1d7_openpi_precision(omni_server, openai_client) -> None: - """Assert actions match Isaac-GR00T reference (GR00T_NOISE_SEED=42, zero inputs).""" + """Assert actions match Isaac-GR00T reference (request seed=42, zero inputs).""" response = openai_client.send_robot_openpi_ws_request( { "operations": [ {"endpoint": "reset", "payload": {}}, { "endpoint": "infer", - "payload": build_openpi_droid_observation(session_id="gr00t-precision-e2e"), + "payload": build_openpi_droid_observation(session_id="gr00t-precision-e2e", seed=42), }, ], } diff --git a/tests/entrypoints/openai_api/test_openpi_serving.py b/tests/entrypoints/openai_api/test_openpi_serving.py index cdd833db0..072e734e4 100644 --- a/tests/entrypoints/openai_api/test_openpi_serving.py +++ b/tests/entrypoints/openai_api/test_openpi_serving.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + import asyncio import json import threading @@ -218,6 +221,18 @@ def test_build_request_uses_unique_engine_request_id_per_inference(): assert request_a.request_id != request_b.request_id +def test_build_request_forwards_seed_to_sampling_params(): + """``seed`` in the inference message is the engine-level seed; omitted, the request auto-seeds.""" + serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=_engine_with_policy_config()) + + seeded = serving._build_request({"prompt": "pick up the object", "seed": 42}, session_id="s", reset=True) + unseeded = serving._build_request({"prompt": "pick up the object"}, session_id="s", reset=False) + + assert seeded.sampling_params.seed == 42 + assert "seed" not in seeded.sampling_params.extra_args["robot_obs"] + assert isinstance(unseeded.sampling_params.seed, int) + + def test_infer_keeps_session_state_but_uses_unique_engine_request_ids(): engine = RecordingEngine() serving = openpi_serving.ServingRealtimeRobotOpenPI(engine_client=engine) diff --git a/tests/helpers/client.py b/tests/helpers/client.py index 0f8346200..5339f7195 100644 --- a/tests/helpers/client.py +++ b/tests/helpers/client.py @@ -152,11 +152,14 @@ class OpenPIWebSocketResponse: action_tensors: list[np.ndarray] | None = None -def build_openpi_droid_observation(*, session_id: str = "gr00t-smoke") -> dict[str, Any]: - """Build a minimal DROID-style observation payload for the OpenPI robot endpoint.""" +def build_openpi_droid_observation(*, session_id: str = "gr00t-smoke", seed: int | None = None) -> dict[str, Any]: + """Build a minimal DROID-style observation payload for the OpenPI robot endpoint. + + ``seed`` pins the policy's sampling noise for this request (see ``docs/serving/openpi_api.md``). + """ identity_eef_9d = np.zeros((1, 1, 9), dtype=np.float32) identity_eef_9d[..., 3:] = np.array([1, 0, 0, 0, 1, 0], dtype=np.float32) - return { + observation: dict[str, Any] = { "session_id": session_id, "video": { "exterior_image_1_left": np.zeros((1, 2, 256, 256, 3), dtype=np.uint8), @@ -169,6 +172,9 @@ def build_openpi_droid_observation(*, session_id: str = "gr00t-smoke") -> dict[s }, "language": {"annotation.language.language_instruction": [["pick up the object"]]}, } + if seed is not None: + observation["seed"] = seed + return observation DREAMZERO_DEFAULT_PROMPT = ( diff --git a/vllm_omni/config/environment_variable_inventory.py b/vllm_omni/config/environment_variable_inventory.py index 1a1d40141..c1e380a22 100644 --- a/vllm_omni/config/environment_variable_inventory.py +++ b/vllm_omni/config/environment_variable_inventory.py @@ -175,7 +175,6 @@ _MODEL_PROMOTE = ( _MODEL_REQUEST_SCOPE = ( "DOTS_TTS_DIT_NUM_STEPS", - "GR00T_NOISE_SEED", "MAGI2_NEGATIVE_PROMPT", "STEP_AUDIO2_DEFAULT_PROMPT_WAV", "VLLM_GEPARD_END_FADE_MS", diff --git a/vllm_omni/diffusion/models/gr00t/modeling/gr00t_n1d7.py b/vllm_omni/diffusion/models/gr00t/modeling/gr00t_n1d7.py index 3fcbce203..6116572ee 100644 --- a/vllm_omni/diffusion/models/gr00t/modeling/gr00t_n1d7.py +++ b/vllm_omni/diffusion/models/gr00t/modeling/gr00t_n1d7.py @@ -1,8 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import logging -import os from typing import Any import torch @@ -138,26 +137,22 @@ class Gr00tN1d7ActionHead(nn.Module): state_features: [B, state_horizon, input_embedding_dim] embodiment_id: [B] (embodiment IDs) backbone_output: Output from the backbone model + options: ``generator`` (a ``torch.Generator`` on the model device) draws + the initial action noise; ``None`` leaves ``torch.randn`` on the + global RNG, as upstream Isaac-GR00T does. The ``rtc_*`` keys drive + real-time chunking when ``action_input`` carries a previous chunk. """ vl_embeds = backbone_features batch_size = vl_embeds.shape[0] device = vl_embeds.device - _seed_env = os.environ.get("GR00T_NOISE_SEED") - if _seed_env is not None: - _gen = torch.Generator(device=device).manual_seed(int(_seed_env)) - actions = torch.randn( - size=(batch_size, self.config.action_horizon, self.action_dim), - dtype=vl_embeds.dtype, - device=device, - generator=_gen, - ) - else: - actions = torch.randn( - size=(batch_size, self.config.action_horizon, self.action_dim), - dtype=vl_embeds.dtype, - device=device, - ) + generator = options.get("generator") if options else None + actions = torch.randn( + size=(batch_size, self.config.action_horizon, self.action_dim), + dtype=vl_embeds.dtype, + device=device, + generator=generator, + ) dt = 1.0 / self.num_inference_timesteps vel_strength = torch.ones_like(actions) diff --git a/vllm_omni/diffusion/models/gr00t/pipeline_gr00t.py b/vllm_omni/diffusion/models/gr00t/pipeline_gr00t.py index 1154dcf71..87108522b 100644 --- a/vllm_omni/diffusion/models/gr00t/pipeline_gr00t.py +++ b/vllm_omni/diffusion/models/gr00t/pipeline_gr00t.py @@ -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 collections.abc import Iterable, Mapping from typing import Any @@ -13,6 +13,7 @@ from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.models.gr00t.policy import Gr00tPolicy from vllm_omni.diffusion.request import DUMMY_DIFFUSION_REQUEST_ID from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.inputs.data import OmniDiffusionSamplingParams logger = init_logger(__name__) @@ -30,6 +31,10 @@ class Gr00tN1d7Pipeline(nn.Module): vLLM-Omni owns the serving integration: OpenPI observations arrive through `sampling_params.extra_args["robot_obs"]`, this pipeline runs GR00T policy inference, and actions are returned through `DiffusionOutput.output["actions"]`. + + The request's `seed` (materialised by the runner into `sampling_params.generator`) + draws the flow-matching noise, so repeating a request with the same seed reproduces + its action chunk. """ def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = "") -> None: @@ -123,7 +128,8 @@ class Gr00tN1d7Pipeline(nn.Module): self.reset() policy_obs = _normalize_observation(robot_obs, language_key=self.policy.language_key) - result = self.policy.get_action(policy_obs) + generator = _noise_generator(req.sampling_params, self.device) + result = self.policy.get_action(policy_obs, {"generator": generator} if generator is not None else None) actions = result[0] if isinstance(result, tuple) else result if not isinstance(actions, Mapping): return DiffusionOutput(error=f"GR00T policy returned {type(actions).__name__}; expected dict actions.") @@ -132,6 +138,35 @@ class Gr00tN1d7Pipeline(nn.Module): return DiffusionOutput(output={"actions": _to_float32_action_dict(actions)}) +def _noise_generator(sampling_params: OmniDiffusionSamplingParams, device: str) -> torch.Generator | None: + """Pick the generator that draws the action head's initial noise. + + The runner materialises ``sampling_params.seed`` into ``sampling_params.generator`` on + the model device before every forward, and ``OmniDiffusionRequest`` assigns a random + seed when the caller omits one, so in serving the generator is always present. The + seed fallback serves callers that bypass the runner (direct use, unit tests). ``None`` + leaves the action head on the global RNG, as upstream Isaac-GR00T runs. GR00T draws one + noise tensor per request, so a generator in any other form (for example a list of several + generators) is ignored with a warning and the seed fallback applies. + """ + generator = sampling_params.generator + if isinstance(generator, list) and len(generator) == 1: + generator = generator[0] + if isinstance(generator, torch.Generator): + return generator + if generator is not None: + received = f"a list of {len(generator)} generators" if isinstance(generator, list) else type(generator).__name__ + logger.warning( + "GR00T expects a single torch.Generator per request but got %s; ignoring it. The initial noise " + "falls back to sampling_params.seed=%s, or to the global RNG when that is None.", + received, + sampling_params.seed, + ) + if sampling_params.seed is not None: + return torch.Generator(device=device).manual_seed(int(sampling_params.seed)) + return None + + def _normalize_observation(robot_obs: Mapping[str, Any], *, language_key: str) -> dict[str, Any]: obs: dict[str, Any] = {} if "video" in robot_obs: diff --git a/vllm_omni/diffusion/models/gr00t/policy.py b/vllm_omni/diffusion/models/gr00t/policy.py index c16b34463..118f57545 100644 --- a/vllm_omni/diffusion/models/gr00t/policy.py +++ b/vllm_omni/diffusion/models/gr00t/policy.py @@ -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 pathlib import Path from typing import Any @@ -328,7 +328,8 @@ class Gr00tPolicy: Args: observation: Batched observation dictionary - options: Optional parameters (currently unused) + options: Optional per-request model parameters, forwarded verbatim to + the action head (e.g. ``generator``) Returns: Tuple of (actions_dict, info_dict) @@ -351,7 +352,7 @@ class Gr00tPolicy: # Step 4: Run model inference to predict actions with torch.inference_mode(): - model_pred = self.model.get_action(**collated_inputs) + model_pred = self.model.get_action(**collated_inputs, options=options) normalized_action = model_pred["action_pred"].float() # Step 5: Decode actions from normalized space back to physical units diff --git a/vllm_omni/entrypoints/openpi/serving.py b/vllm_omni/entrypoints/openpi/serving.py index 4f1d58081..f2448dfa1 100644 --- a/vllm_omni/entrypoints/openpi/serving.py +++ b/vllm_omni/entrypoints/openpi/serving.py @@ -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 """Serving layer for robot policy inference via `/v1/realtime/robot/openpi`. @@ -12,7 +12,7 @@ from __future__ import annotations from collections.abc import Mapping from dataclasses import dataclass from itertools import count -from typing import Any +from typing import Any, TypeAlias import numpy as np from omegaconf import OmegaConf @@ -20,7 +20,7 @@ from vllm.logger import init_logger logger = init_logger(__name__) -ActionOutput = np.ndarray | dict[str, np.ndarray] +ActionOutput: TypeAlias = np.ndarray | dict[str, np.ndarray] def _to_builtin_container(value: Any) -> Any: @@ -150,6 +150,9 @@ class ServingRealtimeRobotOpenPI: from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.inputs.data import OmniDiffusionSamplingParams + # An optional integer ``seed`` in the inference message becomes the engine request's + # ``sampling_params.seed``; omitted, ``OmniDiffusionRequest`` assigns a random one. + seed = obs.pop("seed", None) extra_args = { "reset": reset, "session_id": session_id, @@ -157,7 +160,10 @@ class ServingRealtimeRobotOpenPI: } prompt = obs.get("prompt", "") - sampling_params = OmniDiffusionSamplingParams(extra_args=extra_args) + sampling_params = OmniDiffusionSamplingParams( + seed=int(seed) if seed is not None else None, + extra_args=extra_args, + ) return OmniDiffusionRequest( prompt=prompt, sampling_params=sampling_params, From fdaf4e31bd6638ddb472f93764aabc034c20bb6d Mon Sep 17 00:00:00 2001 From: Harry Mellor <19981378+hmellor@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:08:38 +0100 Subject: [PATCH 47/90] Add vLLM-Omni library info to Hugging Face Hub requests (#5381) Signed-off-by: Harry Mellor <19981378+hmellor@users.noreply.github.com> Co-authored-by: Hongsheng Liu Co-authored-by: Claude Opus 5 (1M context) --- tests/benchmarks/test_accuracy_bench_utils.py | 12 ++--- .../perf/scripts/run_diffusion_benchmark.py | 13 ++--- .../model_loader/test_diffusers_loader.py | 4 +- .../model_loader/test_hub_prefetch.py | 6 +-- .../cosmos3/test_cosmos3_sound_tokenizer.py | 7 ++- .../models/ltx2/test_ltx2_pipeline.py | 13 +++-- .../test_minimax_h3_quantization_quality.py | 8 +-- .../ltx/test_ltx_official_similarity.py | 10 ++-- .../features/rlhf_test/test_verl_omni_e2e.py | 6 +-- .../test_cosyvoice3_expansion.py | 6 +-- .../test_mammoth_moda2_expansion.py | 7 ++- .../test_moss_tts_realtime.py | 7 +-- .../helpers/minicpmo_4_5_duplex.py | 4 +- tests/engine/test_arg_utils.py | 14 +++-- tests/helpers/__init__.py | 5 +- .../minicpmo_4_5/test_code2wav_model_dir.py | 10 ++-- .../omnivoice/test_nonverbal_tags_tokenize.py | 7 ++- tools/pre_commit/check_forbidden_imports.py | 54 ++++--------------- .../data_modules/daily_omni_dataset.py | 8 +-- .../data_modules/seed_tts_dataset.py | 7 ++- .../benchmarks/data_modules/seed_tts_eval.py | 8 ++- vllm_omni/diffusion/lora/loader.py | 4 +- .../diffusion/model_loader/hub_prefetch.py | 6 +-- .../models/cosmos3/sound_tokenizer.py | 6 +-- .../models/dreamzero/pipeline_dreamzero.py | 4 +- .../models/helios/pipeline_helios.py | 4 +- .../pipeline_longcat_video_avatar.py | 6 +-- .../diffusion/models/ltx2/ltx2_components.py | 10 ++-- .../models/omnivoice/pipeline_omnivoice.py | 6 +-- .../diffusion/models/pi0/pipeline_pi0.py | 6 +-- .../sensenova_u1/pipeline_sensenova_u1.py | 5 +- vllm_omni/diffusion/models/utils.py | 4 +- .../models/wan2_2/pipeline_wan2_2.py | 8 +-- vllm_omni/engine/arg_utils.py | 4 +- vllm_omni/engine/stage_init_utils.py | 2 +- vllm_omni/entrypoints/openai/api_server.py | 4 +- .../openai/tts_adapters/cosyvoice3.py | 4 +- .../model_loader/weight_utils.py | 9 +++- .../model_executor/models/audex/checkpoint.py | 20 +++---- .../model_executor/models/bagel/bagel.py | 7 ++- .../models/cosyvoice3/cosyvoice3.py | 8 +-- .../models/covo_audio/covo_audio_code2wav.py | 7 ++- .../models/dots_tts/dots_tts_talker.py | 6 +-- .../models/dynin_omni/dynin_omni_common.py | 17 +++--- .../dynin_omni/dynin_omni_token2audio.py | 9 ++-- .../model_executor/models/glm_tts/glm_tts.py | 10 ++-- .../higgs_audio_v2/higgs_audio_v2_code2wav.py | 6 +-- .../higgs_audio_v2_tokenizer.py | 6 +-- .../models/indextts2/preprocess_utils.py | 13 +++-- .../models/indextts2/s2mel/modules/bigvgan.py | 8 ++- .../models/indextts2/tokenizer_v2_5.py | 6 +-- .../minicpmo_4_5/minicpmo_4_5_code2wav.py | 4 +- .../models/omnivoice/omnivoice.py | 6 +-- .../models/personaplex/personaplex_mimi.py | 7 +-- .../step_audio2/step_audio2_token2wav.py | 4 +- .../models/voxtral_tts/voxtral_tts.py | 7 ++- vllm_omni/transformers_utils/repo_utils.py | 20 +++++++ 57 files changed, 259 insertions(+), 220 deletions(-) create mode 100644 vllm_omni/transformers_utils/repo_utils.py diff --git a/tests/benchmarks/test_accuracy_bench_utils.py b/tests/benchmarks/test_accuracy_bench_utils.py index ab5392346..8d78da2b2 100644 --- a/tests/benchmarks/test_accuracy_bench_utils.py +++ b/tests/benchmarks/test_accuracy_bench_utils.py @@ -1,9 +1,11 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # ruff: noqa: E402, I001 import argparse import math import os import sys -import types from pathlib import Path import pytest @@ -106,11 +108,9 @@ def test_resolve_seed_tts_root_downloads_only_requested_locale(monkeypatch, tmp_ captured["allow_patterns"] = allow_patterns return str(downloaded_root) - monkeypatch.setitem( - sys.modules, - "huggingface_hub", - types.SimpleNamespace(snapshot_download=fake_snapshot_download), - ) + from vllm_omni.transformers_utils import repo_utils + + monkeypatch.setattr(repo_utils.hf_api(), "snapshot_download", fake_snapshot_download) resolved = resolve_seed_tts_root( "zhaochenyang20/seed-tts-eval", diff --git a/tests/dfx/perf/scripts/run_diffusion_benchmark.py b/tests/dfx/perf/scripts/run_diffusion_benchmark.py index bb69391fd..ffcfc45b7 100644 --- a/tests/dfx/perf/scripts/run_diffusion_benchmark.py +++ b/tests/dfx/perf/scripts/run_diffusion_benchmark.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Performance benchmark CI runner for diffusion models. @@ -356,6 +359,8 @@ def _resolve_offline_model(model: str) -> str: """ import huggingface_hub + from vllm_omni.transformers_utils.repo_utils import hf_api + if not model or os.path.isdir(model): return model @@ -380,9 +385,7 @@ def _resolve_offline_model(model: str) -> str: if len(parts) >= 3: repo_id = "/".join(parts[:2]) subfolder = "/".join(parts[2:]) - from huggingface_hub import snapshot_download - - snapshot_root = snapshot_download( + snapshot_root = hf_api().snapshot_download( repo_id, allow_patterns=[f"{subfolder}/**"], local_files_only=huggingface_hub.constants.HF_HUB_OFFLINE, @@ -391,9 +394,7 @@ def _resolve_offline_model(model: str) -> str: if not huggingface_hub.constants.HF_HUB_OFFLINE: return model - from huggingface_hub import snapshot_download - - return snapshot_download(model, local_files_only=True) + return hf_api().snapshot_download(model, local_files_only=True) class DiffusionServer: diff --git a/tests/diffusion/model_loader/test_diffusers_loader.py b/tests/diffusion/model_loader/test_diffusers_loader.py index 555cb19bb..565949ff6 100644 --- a/tests/diffusion/model_loader/test_diffusers_loader.py +++ b/tests/diffusion/model_loader/test_diffusers_loader.py @@ -13,7 +13,6 @@ from unittest.mock import MagicMock import pytest import torch import torch.nn as nn -from huggingface_hub import snapshot_download from safetensors.torch import save_file from vllm.config.load import LoadConfig @@ -32,6 +31,7 @@ from vllm_omni.diffusion.models.helios import HeliosPipeline from vllm_omni.diffusion.models.host_weight_contract import FinalLayoutModelContract from vllm_omni.diffusion.registry import initialize_model from vllm_omni.quantization.component_config import ComponentQuantizationConfig +from vllm_omni.transformers_utils.repo_utils import hf_api pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] @@ -41,7 +41,7 @@ model_path = "hf-internal-testing/tiny-helios-modular-pipe" @pytest.fixture(scope="module") def prefetch_helios_model(): """Downloads the tiny helios model prior to running a test.""" - snapshot_download(model_path) + hf_api().snapshot_download(model_path) @pytest.fixture(scope="function") diff --git a/tests/diffusion/model_loader/test_hub_prefetch.py b/tests/diffusion/model_loader/test_hub_prefetch.py index 39eb2b277..94170edcc 100644 --- a/tests/diffusion/model_loader/test_hub_prefetch.py +++ b/tests/diffusion/model_loader/test_hub_prefetch.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import contextlib @@ -14,10 +14,10 @@ pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cpu] def test_prefetch_subfolders_propagates_revision(monkeypatch): calls = [] - def fake_snapshot_download(**kwargs): + def fake_snapshot_download(self, **kwargs): calls.append(kwargs) - monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download) + monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", fake_snapshot_download) monkeypatch.setattr(hub_prefetch, "_repo_prefetch_lock", lambda _model: contextlib.nullcontext()) hub_prefetch.prefetch_subfolders( diff --git a/tests/diffusion/models/cosmos3/test_cosmos3_sound_tokenizer.py b/tests/diffusion/models/cosmos3/test_cosmos3_sound_tokenizer.py index 7ab04cc21..1aa653d4a 100644 --- a/tests/diffusion/models/cosmos3/test_cosmos3_sound_tokenizer.py +++ b/tests/diffusion/models/cosmos3/test_cosmos3_sound_tokenizer.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -81,9 +81,8 @@ def test_from_config_loads_local_diffusers_component(tmp_path, monkeypatch: pyte def test_from_config_downloads_component_from_hf_repo(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: - import huggingface_hub - from vllm_omni.diffusion.models.cosmos3 import sound_tokenizer + from vllm_omni.transformers_utils import repo_utils cache_dir = tmp_path / "hf" _write_component(cache_dir, checkpoint_name=DIFFUSERS_SOUND_TOKENIZER_CHECKPOINT_NAME) @@ -95,7 +94,7 @@ def test_from_config_downloads_component_from_hf_repo(tmp_path, monkeypatch: pyt calls.append((repo_id, revision, allow_patterns)) return str(cache_dir) - monkeypatch.setattr(huggingface_hub, "snapshot_download", fake_snapshot_download) + monkeypatch.setattr(repo_utils.hf_api(), "snapshot_download", fake_snapshot_download) sound_tokenizer.Cosmos3SoundTokenizer.from_config( SimpleNamespace( diff --git a/tests/diffusion/models/ltx2/test_ltx2_pipeline.py b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py index 3e630d8dd..a7f6b95df 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx2_pipeline.py @@ -10,6 +10,7 @@ from dataclasses import replace from types import SimpleNamespace from typing import Any +import huggingface_hub import numpy as np import pytest import torch @@ -302,11 +303,11 @@ def test_ltx_artifact_uses_source_revision_and_hub_fallback( filename = "ltx-sidecar.safetensors" calls = [] - def fake_download(**kwargs): + def fake_download(self, **kwargs): calls.append(kwargs) return "/cache/ltx-sidecar.safetensors" - monkeypatch.setattr(ltx2_components, "hf_hub_download", fake_download) + monkeypatch.setattr(huggingface_hub.HfApi, "hf_hub_download", fake_download) assert ( resolve_ltx_artifact( @@ -331,7 +332,9 @@ def test_ltx_artifact_prefers_model_root(tmp_path, monkeypatch): filename = "ltx-sidecar.safetensors" expected = tmp_path / filename expected.write_bytes(b"sidecar") - monkeypatch.setattr(ltx2_components, "hf_hub_download", lambda **_kwargs: pytest.fail("unexpected Hub lookup")) + monkeypatch.setattr( + huggingface_hub.HfApi, "hf_hub_download", lambda *_args, **_kwargs: pytest.fail("unexpected Hub lookup") + ) assert resolve_ltx_artifact( str(tmp_path), @@ -345,11 +348,11 @@ def test_ltx_artifact_prefers_model_root(tmp_path, monkeypatch): def test_ltx_artifact_local_model_missing_sidecar_falls_back_to_hub(tmp_path, monkeypatch): calls = [] - def fake_download(**kwargs): + def fake_download(self, **kwargs): calls.append(kwargs) return "/cache/ltx-sidecar.safetensors" - monkeypatch.setattr(ltx2_components, "hf_hub_download", fake_download) + monkeypatch.setattr(huggingface_hub.HfApi, "hf_hub_download", fake_download) assert ( resolve_ltx_artifact( diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py index 0fb043a1e..a45631f7d 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_quantization_quality.py @@ -42,10 +42,10 @@ _QUALITY_CONFIG = QualityTestConfig( def _resolve_fl2va_model_ref() -> str: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api repo_root = Path( - snapshot_download( + hf_api().snapshot_download( repo_id=_MINIMAX_H3_REPO, revision=_MINIMAX_H3_REVISION, allow_patterns=["FL2VA/**"], @@ -253,13 +253,13 @@ def test_resolve_fl2va_model_ref(tmp_path, monkeypatch): fl2va_root.mkdir() (fl2va_root / "model_index.json").write_text("{}", encoding="utf-8") - def fake_snapshot_download(*, repo_id, revision, allow_patterns): + def fake_snapshot_download(self, *, repo_id, revision, allow_patterns): assert repo_id == _MINIMAX_H3_REPO assert revision == _MINIMAX_H3_REVISION assert allow_patterns == ["FL2VA/**"] return str(tmp_path) - monkeypatch.setattr("huggingface_hub.snapshot_download", fake_snapshot_download) + monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", fake_snapshot_download) assert _resolve_fl2va_model_ref() == str(fl2va_root) diff --git a/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py b/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py index 63ce73923..c1b083495 100644 --- a/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py +++ b/tests/e2e/accuracy/ltx/test_ltx_official_similarity.py @@ -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 """E2E accuracy guard against a pinned Lightricks LTX pipeline revision. @@ -22,11 +22,11 @@ from typing import Literal import numpy as np import pytest import torch -from huggingface_hub import hf_hub_download, snapshot_download from torchmetrics.image import PeakSignalNoiseRatio, StructuralSimilarityIndexMeasure from tests.e2e.accuracy.helpers import reset_artifact_dir from tests.helpers.mark import hardware_test +from vllm_omni.transformers_utils.repo_utils import hf_api OFFICIAL_REPOSITORY = "https://github.com/Lightricks/LTX-2.git" OFFICIAL_REVISION = "9377758131b1ffde4b7f766804590a6617bf2ab9" @@ -326,7 +326,7 @@ def _resolve_model(case: LTXAccuracyCase) -> Path: if revision is None and model_id == case.model_id: revision = case.model_revision return Path( - snapshot_download( + hf_api().snapshot_download( repo_id=model_id, revision=revision, allow_patterns=[ @@ -359,7 +359,7 @@ def _resolve_gemma_root(case: LTXAccuracyCase, model: Path) -> Path: if configured_model and Path(configured_model).is_dir(): return Path(configured_model) return Path( - snapshot_download( + hf_api().snapshot_download( repo_id=case.gemma_model_id, revision=case.gemma_model_revision, allow_patterns=[ @@ -383,7 +383,7 @@ def _resolve_artifact(artifact: LTXArtifact, model: Path | None = None) -> Path: if model_path.is_file(): return model_path return Path( - hf_hub_download( + hf_api().hf_hub_download( repo_id=artifact.repo_id, repo_type=None if artifact.repo_type == "model" else artifact.repo_type, filename=artifact.filename, diff --git a/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py b/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py index dab5feef1..8d8062092 100644 --- a/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py +++ b/tests/e2e/features/rlhf_test/test_verl_omni_e2e.py @@ -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 """E2E test that follows the EXACT same flow as ``verl-omni/tests/workers/rollout/rollout_vllm/test_vllm_omni_generate.py`` @@ -35,7 +35,6 @@ from uuid import uuid4 import pytest import ray import torch -from huggingface_hub import snapshot_download from omegaconf import OmegaConf from transformers import AutoTokenizer @@ -46,6 +45,7 @@ from tests.e2e.features.helpers.verl_omni_server import ( vLLMOmniHttpServerLocal, ) from tests.helpers.mark import hardware_test +from vllm_omni.transformers_utils.repo_utils import hf_api MODEL = "tiny-random/Qwen-Image" TOKENIZER_MODEL = "Qwen/Qwen2-1.5B-Instruct" @@ -55,7 +55,7 @@ _MIN_PROMPT_TOKENS = 35 def _resolve_model_path(repo_id: str) -> str: if os.path.isdir(repo_id): return repo_id - return snapshot_download(repo_id=repo_id) + return hf_api().snapshot_download(repo_id=repo_id) @lru_cache(maxsize=1) diff --git a/tests/e2e/offline_inference/test_cosyvoice3_expansion.py b/tests/e2e/offline_inference/test_cosyvoice3_expansion.py index 7e57433ef..dd7c8ec85 100644 --- a/tests/e2e/offline_inference/test_cosyvoice3_expansion.py +++ b/tests/e2e/offline_inference/test_cosyvoice3_expansion.py @@ -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 """ Offline E2E smoke test for CosyVoice3 zero-shot reference inference. @@ -20,7 +20,6 @@ from pathlib import Path import numpy as np import pytest import soundfile as sf -from huggingface_hub import snapshot_download from vllm.sampling_params import SamplingParams from tests.helpers.mark import hardware_test @@ -29,6 +28,7 @@ from tests.helpers.runtime import OmniRunner from tests.helpers.stage_config import get_deploy_config_path from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config +from vllm_omni.transformers_utils.repo_utils import hf_api MODEL = "FunAudioLLM/Fun-CosyVoice3-0.5B-2512" MODEL_DIR_ENV = "VLLM_OMNI_COSYVOICE3_MODEL_DIR" @@ -58,7 +58,7 @@ def _resolve_model_dir() -> Path: override = os.environ.get(MODEL_DIR_ENV) if override: return Path(override).expanduser().resolve() - return Path(snapshot_download(MODEL, allow_patterns=["*"])) + return Path(hf_api().snapshot_download(MODEL, allow_patterns=["*"])) def _reference_zero_shot_stage0_sampling(*, text: str) -> SamplingParams: diff --git a/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py b/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py index f570a6ea7..4f21dd1c4 100644 --- a/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py +++ b/tests/e2e/offline_inference/test_mammoth_moda2_expansion.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ End-to-end test for MammothModa2 text-to-image generation. @@ -19,12 +22,12 @@ from pathlib import Path import pytest import torch -from huggingface_hub import snapshot_download from vllm.sampling_params import SamplingParams from tests.helpers.mark import hardware_test from tests.helpers.runtime import OmniRunner from tests.helpers.stage_config import get_deploy_config_path +from vllm_omni.transformers_utils.repo_utils import hf_api # --------------------------------------------------------------------------- # Constants @@ -64,7 +67,7 @@ _PIXEL_SAMPLE_COORDS = [ # Helpers # --------------------------------------------------------------------------- def _load_t2i_gen_config(repo_id: str) -> dict: - weights_dir = Path(snapshot_download(repo_id)) + weights_dir = Path(hf_api().snapshot_download(repo_id)) cfg_path = weights_dir / "t2i_generation_config.json" if not cfg_path.exists(): pytest.skip(f"t2i_generation_config.json not found at {cfg_path}") diff --git a/tests/e2e/offline_inference/test_moss_tts_realtime.py b/tests/e2e/offline_inference/test_moss_tts_realtime.py index 34d36118d..81d97cd74 100644 --- a/tests/e2e/offline_inference/test_moss_tts_realtime.py +++ b/tests/e2e/offline_inference/test_moss_tts_realtime.py @@ -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 """E2E offline inference tests for MOSS-TTS-Realtime (MossTTSRealtime, 1.7B). Uses the standard omni_runner + pytestmark pattern (one module-scoped engine @@ -160,12 +160,13 @@ def _build_request(ref_audio_path: str, text: str) -> dict: Frees the codec model before returning to avoid competing with the running vllm engine for GPU/CPU memory. """ - from huggingface_hub import snapshot_download from transformers import AutoModel, AutoTokenizer + from vllm_omni.transformers_utils.repo_utils import hf_api + # Step 1: locate the realtime processor module in the snapshot. try: - snap_dir = Path(snapshot_download(repo_id=MODEL)) + snap_dir = Path(hf_api().snapshot_download(repo_id=MODEL)) except Exception as exc: msg = f"Cannot locate snapshot for {MODEL}: {exc}" if os.environ.get("MOSS_TTS_SKIP_ON_NET_FAIL"): diff --git a/tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py b/tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py index 432124e76..66f8be616 100644 --- a/tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py +++ b/tests/e2e/online_serving/helpers/minicpmo_4_5_duplex.py @@ -12,7 +12,6 @@ from pathlib import Path from types import SimpleNamespace import pytest -from huggingface_hub import snapshot_download from tests.helpers.runtime import OmniServerParams, get_model_prefix from tests.helpers.stage_config import ( @@ -20,6 +19,7 @@ from tests.helpers.stage_config import ( get_deploy_duplex_max_sessions, modify_stage_config, ) +from vllm_omni.transformers_utils.repo_utils import hf_api MODEL = "openbmb/MiniCPM-o-4_5" DEPLOY_CONFIG_REL = "minicpmo_4_5.yaml" @@ -111,7 +111,7 @@ def resolve_ref_audio() -> Path: model_prefix = get_model_prefix() model_root = Path(model_prefix) / MODEL if model_prefix else Path(MODEL) if not model_root.is_dir(): - model_root = Path(snapshot_download(MODEL, local_files_only=True)) + model_root = Path(hf_api().snapshot_download(MODEL, local_files_only=True)) ref_audio = model_root / REF_AUDIO_RELATIVE_PATH if not ref_audio.is_file(): raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}") diff --git a/tests/engine/test_arg_utils.py b/tests/engine/test_arg_utils.py index fb8231a75..0e88a00ef 100644 --- a/tests/engine/test_arg_utils.py +++ b/tests/engine/test_arg_utils.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Tests for Omni config utils. For stability, these tests should largely be invariant to the specific attributes of vLLM config except in cases where we @@ -63,7 +66,7 @@ def test_full_payload_capability_reaches_omni_model_config(monkeypatch): class ConnectorWorker: model_runner_cls = ConnectorRunner - worker_module.ConnectorWorker = ConnectorWorker + setattr(worker_module, "ConnectorWorker", ConnectorWorker) monkeypatch.setitem(sys.modules, worker_module.__name__, worker_module) monkeypatch.setattr(OmniEngineArgs, "_ensure_omni_models_registered", lambda _self: None) @@ -95,7 +98,7 @@ def test_stage_without_connector_configuration_accepts_plain_runner(monkeypatch) class WorkerWithoutConnector: model_runner_cls = object - worker_module.WorkerWithoutConnector = WorkerWithoutConnector + setattr(worker_module, "WorkerWithoutConnector", WorkerWithoutConnector) monkeypatch.setitem(sys.modules, worker_module.__name__, worker_module) args = OmniEngineArgs( @@ -113,7 +116,7 @@ def test_full_payload_capability_requires_selected_worker_connector(monkeypatch) class WorkerWithoutConnector: model_runner_cls = object - worker_module.WorkerWithoutConnector = WorkerWithoutConnector + setattr(worker_module, "WorkerWithoutConnector", WorkerWithoutConnector) monkeypatch.setitem(sys.modules, worker_module.__name__, worker_module) with pytest.raises(ValueError, match="does not provide an Omni connector model runner"): @@ -130,7 +133,7 @@ def test_full_payload_capability_validates_platform_selected_worker(monkeypatch) class WorkerWithoutConnector: model_runner_cls = object - worker_module.WorkerWithoutConnector = WorkerWithoutConnector + setattr(worker_module, "WorkerWithoutConnector", WorkerWithoutConnector) monkeypatch.setitem(sys.modules, worker_module.__name__, worker_module) monkeypatch.setattr( current_omni_platform, @@ -293,7 +296,7 @@ def test_remote_tokenizer_subfolder_download_does_not_report_failure(tmp_path, m baseline_config = Mock() warning = mocker.patch("vllm_omni.engine.arg_utils.logger.warning") - monkeypatch.setattr("huggingface_hub.snapshot_download", lambda *args, **kwargs: str(tmp_path)) + monkeypatch.setattr("huggingface_hub.HfApi.snapshot_download", lambda *args, **kwargs: str(tmp_path)) monkeypatch.setattr(OmniEngineArgs, "_patch_empty_hf_config", lambda *args, **kwargs: None) monkeypatch.setattr(EngineArgs, "create_model_config", lambda _self: baseline_config) monkeypatch.setattr( @@ -339,6 +342,7 @@ def test_patch_missing_local_hf_config(tmp_path): def test_non_missing_local_hf_config_error_reaches_parent_loader(tmp_path, monkeypatch, config_entry): """Non-missing config errors must reach vLLM's normal loader.""" config_path = tmp_path / "config.json" + loader_error: Exception if config_entry == "malformed": config_path.write_text("{not valid json", encoding="utf-8") loader_error = json.JSONDecodeError("invalid config", "{not valid json", 1) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index a4e641a57..9120e183f 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -29,12 +29,13 @@ def skip_if_gated_repo_inaccessible( and we skip cleanly. """ try: - from huggingface_hub import hf_hub_download from huggingface_hub.errors import GatedRepoError, RepositoryNotFoundError + + from vllm_omni.transformers_utils.repo_utils import hf_api except Exception: return try: - hf_hub_download(repo_id=repo_id, filename=filename, revision=revision) + hf_api().hf_hub_download(repo_id=repo_id, filename=filename, revision=revision) except GatedRepoError as exc: import pytest diff --git a/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py b/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py index 5aa78262a..8d5aeade6 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py +++ b/tests/model_executor/models/minicpmo_4_5/test_code2wav_model_dir.py @@ -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 MiniCPM-o 4.5 code2wav model-dir resolution (#5442). In hub/CI deployments ``model_config.model`` is a repo id rather than a local @@ -27,7 +27,7 @@ def _no_hub(monkeypatch): def _fail(*args, **kwargs): # pragma: no cover - must not be reached raise AssertionError("snapshot_download must not be called here") - monkeypatch.setattr(huggingface_hub, "snapshot_download", _fail) + monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fail) def test_local_directory_is_returned_unchanged(tmp_path, monkeypatch): @@ -38,13 +38,13 @@ def test_local_directory_is_returned_unchanged(tmp_path, monkeypatch): def test_repo_id_resolves_via_snapshot_download(tmp_path, monkeypatch): calls = {} - def _fake_snapshot_download(model_ref, revision=None, allow_patterns=None): + def _fake_snapshot_download(self, model_ref, revision=None, allow_patterns=None): calls["model_ref"] = model_ref calls["revision"] = revision calls["allow_patterns"] = allow_patterns return str(tmp_path / "snapshot") - monkeypatch.setattr(huggingface_hub, "snapshot_download", _fake_snapshot_download) + monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _fake_snapshot_download) resolved = _resolve_model_dir("openbmb/MiniCPM-o-4_5", revision="abc123") assert resolved == str(tmp_path / "snapshot") assert calls["model_ref"] == "openbmb/MiniCPM-o-4_5" @@ -56,7 +56,7 @@ def test_snapshot_download_failure_propagates(monkeypatch): def _raise(*args, **kwargs): raise FileNotFoundError("offline and not cached") - monkeypatch.setattr(huggingface_hub, "snapshot_download", _raise) + monkeypatch.setattr(huggingface_hub.HfApi, "snapshot_download", _raise) with pytest.raises(FileNotFoundError): _resolve_model_dir("openbmb/MiniCPM-o-4_5") diff --git a/tests/model_executor/models/omnivoice/test_nonverbal_tags_tokenize.py b/tests/model_executor/models/omnivoice/test_nonverbal_tags_tokenize.py index fbf5ac0b0..95524fdce 100644 --- a/tests/model_executor/models/omnivoice/test_nonverbal_tags_tokenize.py +++ b/tests/model_executor/models/omnivoice/test_nonverbal_tags_tokenize.py @@ -1,12 +1,15 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import pytest -from huggingface_hub import hf_hub_download from tokenizers import Tokenizer as HFTokenizer from vllm_omni.diffusion.models.omnivoice.pipeline_omnivoice import ( _tokenize_with_nonverbal_tags, ) +from vllm_omni.transformers_utils.repo_utils import hf_api pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -18,7 +21,7 @@ def _ids(tok: HFTokenizer, s: str) -> list[int]: class TestNonVerbalTags: @classmethod def setup_class(cls): - tokenizer_path = hf_hub_download(repo_id="k2-fsa/OmniVoice", filename="tokenizer.json") + tokenizer_path = hf_api().hf_hub_download(repo_id="k2-fsa/OmniVoice", filename="tokenizer.json") cls.tokenizer = HFTokenizer.from_file(tokenizer_path) def test_plain_text_fallback(self): diff --git a/tools/pre_commit/check_forbidden_imports.py b/tools/pre_commit/check_forbidden_imports.py index 22ba693d4..c5a96148e 100644 --- a/tools/pre_commit/check_forbidden_imports.py +++ b/tools/pre_commit/check_forbidden_imports.py @@ -17,7 +17,7 @@ import regex as re _HF_NAMES = ( r"HfApi|HfFileSystem|hf_hub_download|snapshot_download" r"|list_repo_files|file_exists|try_to_load_from_cache" - r"|list_repo_refs|repo_exists" + r"|list_repo_refs|repo_exists|get_safetensors_metadata" ) # Non-library trees: examples, tests, and tooling may keep stdlib imports. @@ -169,56 +169,20 @@ CHECK_IMPORTS = { r"^\s*from\s+huggingface_hub\s+import\b[^\n]*\b(?:" + _HF_NAMES + r")\b" ), tip=( - "Use the shared, vLLM-tagged helpers from " - "vllm.transformers_utils.repo_utils (e.g. hf_api(), hf_fs(), " - "list_repo_files, file_exists) instead of calling " - "huggingface_hub directly." + "Use 'hf_api()' from 'vllm_omni.transformers_utils.repo_utils' (or " + "add a tagged helper there) instead, so requests are tagged with " + "vLLM-Omni's library info." ), allowed_dirs=_NON_LIBRARY_DIRS, allowed_files={ - "vllm_omni/benchmarks/data_modules/daily_omni_dataset.py", - "vllm_omni/benchmarks/data_modules/seed_tts_dataset.py", - "vllm_omni/benchmarks/data_modules/seed_tts_eval.py", - "vllm_omni/diffusion/lora/loader.py", - "vllm_omni/diffusion/model_loader/hub_prefetch.py", - "vllm_omni/diffusion/models/cosmos3/sound_tokenizer.py", - "vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py", - "vllm_omni/diffusion/models/helios/pipeline_helios.py", - "vllm_omni/diffusion/models/longcat_video/pipeline_longcat_video_avatar.py", - "vllm_omni/diffusion/models/ltx2/ltx2_components.py", - "vllm_omni/diffusion/models/magi_human/pipeline_magi_human.py", - "vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py", - "vllm_omni/diffusion/models/pi0/pipeline_pi0.py", - "vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py", - "vllm_omni/diffusion/models/soulx_singer/utils.py", - "vllm_omni/diffusion/models/utils.py", - "vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py", - "vllm_omni/engine/arg_utils.py", - "vllm_omni/entrypoints/openai/api_server.py", - "vllm_omni/entrypoints/openai/serving_speech.py", - "vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py", - "vllm_omni/model_executor/model_loader/weight_utils.py", - "vllm_omni/model_executor/models/audex/checkpoint.py", - "vllm_omni/model_executor/models/bagel/bagel.py", - "vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py", - "vllm_omni/model_executor/models/covo_audio/covo_audio_code2wav.py", - "vllm_omni/model_executor/models/dots_tts/dots_tts_talker.py", - "vllm_omni/model_executor/models/dynin_omni/dynin_omni_common.py", - "vllm_omni/model_executor/models/dynin_omni/dynin_omni_token2audio.py", - "vllm_omni/model_executor/models/glm_tts/glm_tts.py", + # ``try_to_load_from_cache`` is a local cache lookup that issues no + # Hub request, so there is nothing for ``hf_api()`` to tag. "vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_code2wav.py", "vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py", "vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_code2wav.py", - "vllm_omni/model_executor/models/indextts2/preprocess_utils.py", - "vllm_omni/model_executor/models/indextts2/s2mel/modules/bigvgan.py", - "vllm_omni/model_executor/models/indextts2/tokenizer_v2_5.py", - "vllm_omni/model_executor/models/ming_tts/speaker_extractor.py", - "vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py", - "vllm_omni/model_executor/models/omnivoice/omnivoice.py", - "vllm_omni/model_executor/models/personaplex/personaplex_mimi.py", - "vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py", - "vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py", "vllm_omni/transformers_utils/configs/higgs_audio_v3.py", + # Defines the tagged helper itself. + "vllm_omni/transformers_utils/repo_utils.py", }, ), } @@ -330,6 +294,7 @@ def test_regex(): ("from huggingface_hub import HfFileSystem", True), ("from huggingface_hub import list_repo_files", True), ("from huggingface_hub import try_to_load_from_cache", True), + ("from huggingface_hub import get_safetensors_metadata", True), (" from huggingface_hub import snapshot_download", True), ("from huggingface_hub import PyTorchModelHubMixin, hf_hub_download", True), ("from huggingface_hub import (snapshot_download)", True), @@ -346,6 +311,7 @@ def test_regex(): ("from huggingface_hub.constants import HF_HUB_CACHE", False), ("from huggingface_hub.utils import EntryNotFoundError", False), ("from vllm.transformers_utils.repo_utils import hf_api", False), + ("from vllm_omni.transformers_utils.repo_utils import hf_api", False), ("from huggingface_hub import (\n PyTorchModelHubMixin,\n)", False), ("# from huggingface_hub import snapshot_download", False), ] diff --git a/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py b/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py index abb02b1d5..3bf852e8c 100644 --- a/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py +++ b/vllm_omni/benchmarks/data_modules/daily_omni_dataset.py @@ -170,7 +170,7 @@ def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path: raise ValueError("repo_id is required to download Daily-Omni Videos.tar") try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api except ImportError as e: raise ImportError( "Daily-Omni Hub media download requires huggingface_hub. " @@ -180,7 +180,7 @@ def ensure_daily_omni_hub_videos_dir(repo_id: str) -> Path: tar_path: Path | None = None for fname in ("Videos.tar", "videos.tar"): try: - tar_path = Path(hf_hub_download(repo_id=rid, filename=fname, repo_type="dataset")) + tar_path = Path(hf_api().hf_hub_download(repo_id=rid, filename=fname, repo_type="dataset")) break except Exception: continue @@ -237,14 +237,14 @@ def ensure_daily_omni_hub_root(repo_id: str) -> Path: raise ValueError("repo_id is required to download Daily-Omni from Hugging Face") try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api except ImportError as e: raise ImportError( "Install huggingface_hub to load Daily-Omni from the Hub, or pass a local " "--daily-omni-qa-json / --daily-omni-video-dir mirror." ) from e - cache = snapshot_download( + cache = hf_api().snapshot_download( repo_id=rid, repo_type="dataset", allow_patterns=_DAILY_OMNI_QA_ALLOW_PATTERNS, diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py b/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py index 23e8b8ade..996f4f248 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_dataset.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Seed-TTS zero-shot evaluation-style prompts for ``vllm bench serve``. Loads rows from the `meta.lst` format used in `BytedanceSpeech/seed-tts-eval`_ (or any @@ -121,7 +124,7 @@ def resolve_seed_tts_root(dataset_path: str | None, *, explicit_root: str | None repo_id = dataset_path.strip() try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api except ImportError as e: raise ImportError( "Install huggingface_hub to download Seed-TTS from the Hub, or clone the dataset " @@ -130,7 +133,7 @@ def resolve_seed_tts_root(dataset_path: str | None, *, explicit_root: str | None # Download only the requested locale subtree instead of the whole dataset # repo. This avoids large, flaky nightly downloads when we only need e.g. # ``en/meta.lst`` + ``en/prompt-wavs/**``. - cache = snapshot_download( + cache = hf_api().snapshot_download( repo_id=repo_id, repo_type="dataset", allow_patterns=[f"{locale}/**"], diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py index ddfbbd630..9dc99548a 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Seed-TTS WER aligned with Bytedance ``seed-tts-eval`` / ``run_wer.py``. Matches the published protocol (see Hugging Face dataset card and @@ -329,7 +332,8 @@ def _ensure_utmos_jit_model() -> Any | None: return _utmos_jit_model try: import torch - from huggingface_hub import hf_hub_download + + from vllm_omni.transformers_utils.repo_utils import hf_api repo = os.environ.get("SEED_TTS_UTMOS_HF_REPO", "balacoon/utmos").strip() or "balacoon/utmos" fname = os.environ.get("SEED_TTS_UTMOS_JIT_FILE", "utmos.jit").strip() or "utmos.jit" @@ -338,7 +342,7 @@ def _ensure_utmos_jit_model() -> Any | None: repo, fname, ) - path = hf_hub_download(repo_id=repo, filename=fname, repo_type="model") + path = hf_api().hf_hub_download(repo_id=repo, filename=fname, repo_type="model") # TODO The model weights in UTMOS must be loaded in cuda:0; otherwise, the model execution will fail. want = "cuda:0" diff --git a/vllm_omni/diffusion/lora/loader.py b/vllm_omni/diffusion/lora/loader.py index 3f74b4853..a13363c7a 100644 --- a/vllm_omni/diffusion/lora/loader.py +++ b/vllm_omni/diffusion/lora/loader.py @@ -10,11 +10,11 @@ from diffusers.loaders.lora_conversion_utils import ( _convert_non_diffusers_qwen_lora_to_diffusers, _convert_non_diffusers_wan_lora_to_diffusers, ) -from huggingface_hub import hf_hub_download from safetensors.torch import load_file from vllm.logger import init_logger from vllm_omni.diffusion.utils.tf_utils import get_transformer_from_pipeline +from vllm_omni.transformers_utils.repo_utils import hf_api logger = init_logger(__name__) @@ -131,7 +131,7 @@ def _load_lora_state_dict( # finally, we try to load it from the internet try: - model_file = hf_hub_download( + model_file = hf_api().hf_hub_download( pretrained_model_name_or_path, filename=weights_name, subfolder=subfolder, diff --git a/vllm_omni/diffusion/model_loader/hub_prefetch.py b/vllm_omni/diffusion/model_loader/hub_prefetch.py index 3594aa4e1..a1e893b3b 100644 --- a/vllm_omni/diffusion/model_loader/hub_prefetch.py +++ b/vllm_omni/diffusion/model_loader/hub_prefetch.py @@ -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 """Best-effort HuggingFace Hub prefetch for multi-subfolder pipelines. @@ -297,7 +297,7 @@ def prefetch_subfolders( logger.info("Prefetching %s subfolders: %s", model, subfolders) try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api except ImportError: # pragma: no cover - huggingface_hub is a hard dep logger.debug("huggingface_hub unavailable; skipping prefetch of %s", model) return @@ -334,7 +334,7 @@ def prefetch_subfolders( for attempt in range(1, _PREFETCH_MAX_ATTEMPTS + 1): try: with _repo_prefetch_lock(model): - snapshot_download( + hf_api().snapshot_download( repo_id=model, revision=revision, allow_patterns=allow_patterns, diff --git a/vllm_omni/diffusion/models/cosmos3/sound_tokenizer.py b/vllm_omni/diffusion/models/cosmos3/sound_tokenizer.py index 5d6ff8dd9..8c1337ec9 100644 --- a/vllm_omni/diffusion/models/cosmos3/sound_tokenizer.py +++ b/vllm_omni/diffusion/models/cosmos3/sound_tokenizer.py @@ -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 """Cosmos3 sound tokenizer integration. The tokenizer decodes model-generated sound latents for video+audio output. It @@ -338,9 +338,9 @@ class Cosmos3SoundTokenizer: model_root = str(model_path) if model_path and os.path.isdir(model_path) else None if model_root is None and model_path and not explicit_avae_path: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - model_root = snapshot_download( + model_root = hf_api().snapshot_download( repo_id=str(model_path), revision=getattr(od_config, "revision", None), allow_patterns=[ diff --git a/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py b/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py index bf19f0a0f..09bd536cd 100644 --- a/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py +++ b/vllm_omni/diffusion/models/dreamzero/pipeline_dreamzero.py @@ -21,7 +21,6 @@ from contextlib import contextmanager import numpy as np import torch import torch.nn as nn -from huggingface_hub import hf_hub_download from transformers import AutoTokenizer, UMT5Config, UMT5EncoderModel from vllm.model_executor.model_loader.weight_utils import default_weight_loader @@ -67,6 +66,7 @@ from vllm_omni.experimental.world_models.session_state import ( resolve_session_state_config, ) from vllm_omni.inputs.data import OmniDiffusionSamplingParams +from vllm_omni.transformers_utils.repo_utils import hf_api logger = logging.getLogger(__name__) MAX_DREAMZERO_SESSIONS = 64 @@ -581,7 +581,7 @@ class DreamZeroPipeline(nn.Module, CFGParallelMixin): return json.load(f) try: - json_path = hf_hub_download(model_path, relative_path) + json_path = hf_api().hf_hub_download(model_path, relative_path) with open(json_path) as f: return json.load(f) except Exception: diff --git a/vllm_omni/diffusion/models/helios/pipeline_helios.py b/vllm_omni/diffusion/models/helios/pipeline_helios.py index 820f9a1d9..610fb2b8f 100644 --- a/vllm_omni/diffusion/models/helios/pipeline_helios.py +++ b/vllm_omni/diffusion/models/helios/pipeline_helios.py @@ -76,9 +76,9 @@ def load_json_config(model_path: str, subfolder: str, filename: str, local_files return json.load(f) else: try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - config_path = hf_hub_download( + config_path = hf_api().hf_hub_download( repo_id=model_path, filename=f"{subfolder}/{filename}", ) diff --git a/vllm_omni/diffusion/models/longcat_video/pipeline_longcat_video_avatar.py b/vllm_omni/diffusion/models/longcat_video/pipeline_longcat_video_avatar.py index 33b0e2a45..6cb2dd7fb 100644 --- a/vllm_omni/diffusion/models/longcat_video/pipeline_longcat_video_avatar.py +++ b/vllm_omni/diffusion/models/longcat_video/pipeline_longcat_video_avatar.py @@ -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 @@ -376,9 +376,9 @@ def _ensure_local_dir(model: str | os.PathLike[str], allow_patterns: list[str] | model_path = Path(model) if model_path.exists(): return model_path - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return Path(snapshot_download(str(model), allow_patterns=allow_patterns)) + return Path(hf_api().snapshot_download(str(model), allow_patterns=allow_patterns)) def _ensure_avatar_model_index(model_dir: Path, use_int8: bool) -> None: diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_components.py b/vllm_omni/diffusion/models/ltx2/ltx2_components.py index c00fdd835..72e63a22f 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_components.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_components.py @@ -20,7 +20,6 @@ from diffusers.pipelines.ltx2 import LTX2TextConnectors from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel from diffusers.pipelines.ltx2.vocoder import LTX2Vocoder from diffusers.video_processor import VideoProcessor -from huggingface_hub import hf_hub_download from safetensors import safe_open from safetensors.torch import load_file from transformers import AutoModelForImageTextToText, AutoTokenizer, Gemma3ForConditionalGeneration @@ -32,6 +31,7 @@ from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.model_loader.diffusers_loader import DiffusersPipelineLoader from vllm_omni.diffusion.model_loader.hub_prefetch import from_pretrained_with_prefetch, prefetch_subfolders from vllm_omni.diffusion.offloader.module_collector import ModuleDiscovery +from vllm_omni.transformers_utils.repo_utils import hf_api if TYPE_CHECKING: from vllm.model_executor.layers.quantization.base_config import QuantizationConfig @@ -279,7 +279,7 @@ def resolve_ltx_artifact( # independently pinned artifact revision. revision = model_revision if model == repo_id else artifact_revision try: - return hf_hub_download( + return hf_api().hf_hub_download( repo_id=repo_id, filename=filename, revision=revision, @@ -351,7 +351,7 @@ def _load_ltx_metadata_json(model: str, filename: str, revision: str | None = No return {} else: try: - path = hf_hub_download(repo_id=model, filename=filename, revision=revision) + path = hf_api().hf_hub_download(repo_id=model, filename=filename, revision=revision) except Exception: return {} try: @@ -529,7 +529,7 @@ def _detect_vocoder_output_sample_rate(model: str, revision: str | None = None) vocoder_config_path = os.path.join(model, "vocoder", "config.json") if not os.path.exists(vocoder_config_path): try: - vocoder_config_path = hf_hub_download(model, "vocoder/config.json", revision=revision) + vocoder_config_path = hf_api().hf_hub_download(model, "vocoder/config.json", revision=revision) except Exception: return None try: @@ -839,7 +839,7 @@ def load_transformer_config( if not os.path.exists(config_path): raise FileNotFoundError(f"LTX transformer config not found: {config_path}") else: - config_path = hf_hub_download( + config_path = hf_api().hf_hub_download( repo_id=model_path, filename=f"{subfolder}/config.json", revision=revision, diff --git a/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py b/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py index 62ac027ca..cfc40fcb6 100644 --- a/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py +++ b/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py @@ -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 """ OmniVoice TTS Pipeline for vLLM-Omni diffusion engine. @@ -141,9 +141,9 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): # Resolve model path (HF hub ID → local cache) if not os.path.isdir(self.model_path): - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - self.model_path = snapshot_download(self.model_path) + self.model_path = hf_api().snapshot_download(self.model_path) # Load OmniVoice config config_path = os.path.join(self.model_path, "config.json") diff --git a/vllm_omni/diffusion/models/pi0/pipeline_pi0.py b/vllm_omni/diffusion/models/pi0/pipeline_pi0.py index 1eeca2969..a03801c8d 100644 --- a/vllm_omni/diffusion/models/pi0/pipeline_pi0.py +++ b/vllm_omni/diffusion/models/pi0/pipeline_pi0.py @@ -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 """π0 (Pi-Zero) VLA pipeline for vllm-omni. Entry point for ``DiffusionEngine.step() → pipeline.forward(req)``. Mirrors the @@ -94,9 +94,9 @@ class Pi0Pipeline(nn.Module): return None if os.path.isdir(model): return model - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return snapshot_download( + return hf_api().snapshot_download( repo_id=model, allow_patterns=["*.json", "*.safetensors", "*.model", "tokenizer*"], ) diff --git a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py index de636fb6d..2be1a9c7a 100644 --- a/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py +++ b/vllm_omni/diffusion/models/sensenova_u1/pipeline_sensenova_u1.py @@ -27,7 +27,6 @@ import numpy as np import torch import torch.nn as nn import torchvision.transforms as T -from huggingface_hub import snapshot_download from PIL import Image from transformers import AutoTokenizer from vllm.logger import init_logger @@ -109,7 +108,9 @@ def _resolve_model_path(model_path: str) -> str: """Resolve a HuggingFace model ID or local path to a local directory.""" if os.path.isdir(model_path): return model_path - return snapshot_download(model_path) + from vllm_omni.transformers_utils.repo_utils import hf_api + + return hf_api().snapshot_download(model_path) def _round_by_factor(number: float, factor: int) -> int: diff --git a/vllm_omni/diffusion/models/utils.py b/vllm_omni/diffusion/models/utils.py index 2d783d907..429c34b9d 100644 --- a/vllm_omni/diffusion/models/utils.py +++ b/vllm_omni/diffusion/models/utils.py @@ -168,8 +168,8 @@ def _load_json(model_path: str, filename: str, local_files_only: bool = True) -> with open(path) as f: return json.load(f) else: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - cached = hf_hub_download(repo_id=model_path, filename=filename) + cached = hf_api().hf_hub_download(repo_id=model_path, filename=filename) with open(cached) as f: return json.load(f) diff --git a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py index 2383fc6fd..c9ef325c4 100644 --- a/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py +++ b/vllm_omni/diffusion/models/wan2_2/pipeline_wan2_2.py @@ -156,9 +156,9 @@ def load_transformer_config(model_path: str, subfolder: str = "transformer", loc else: # Try to download config from HF Hub try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - config_path = hf_hub_download( + config_path = hf_api().hf_hub_download( repo_id=model_path, filename=f"{subfolder}/config.json", ) @@ -364,9 +364,9 @@ class Wan22Pipeline( else: # For remote models, download and read model_index.json try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - model_index_path = hf_hub_download(repo_id=model, filename="model_index.json") + model_index_path = hf_api().hf_hub_download(repo_id=model, filename="model_index.json") with open(model_index_path) as f: model_index = json.load(f) self.expand_timesteps = model_index.get("expand_timesteps", False) diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index f9ee40cbb..bd6a71562 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -381,9 +381,9 @@ class OmniEngineArgs(EngineArgs): if tokenizer_subfolder: # Download just the tokenizer files from the subfolder try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - local_dir = snapshot_download( + local_dir = hf_api().snapshot_download( model_path, allow_patterns=[ f"{tokenizer_subfolder}/tokenizer*", diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index 0092e40cc..1e57036a0 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -31,7 +31,6 @@ from vllm.pooling_params import PoolingParams from vllm.renderers import BaseRenderer from vllm.sampling_params import SamplingParams from vllm.tokenizers import cached_tokenizer_from_config -from vllm.transformers_utils.repo_utils import hf_api from vllm.transformers_utils.runai_utils import is_runai_obj_uri from vllm.usage.usage_lib import UsageContext from vllm.v1.engine.input_processor import InputProcessor @@ -59,6 +58,7 @@ from vllm_omni.inputs.preprocess import OmniInputPreprocessor from vllm_omni.outputs.output_processor import MultimodalOutputProcessor from vllm_omni.platforms import current_omni_platform from vllm_omni.quantization.inc_config import OmniINCConfig +from vllm_omni.transformers_utils.repo_utils import hf_api logger = init_logger(__name__) diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index f10edfc7f..f97207bbc 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -201,9 +201,9 @@ def _load_model_chat_template_json(model: str) -> str | None: if template_path is None: try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - template_path = hf_hub_download( + template_path = hf_api().hf_hub_download( repo_id=model, filename="chat_template.json", local_files_only=True, diff --git a/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py b/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py index 25993ed6e..1196c5c70 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/cosyvoice3.py @@ -116,9 +116,9 @@ class CosyVoice3Adapter(ARTTSAdapter): if tokenizer is None: model_path = server.engine_client.model_config.model if not os.path.isdir(model_path): - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - model_path = snapshot_download(model_path) + model_path = hf_api().snapshot_download(model_path) tokenizer = get_qwen_tokenizer( token_path=os.path.join(model_path, hf_cfg.qwen_pretrain_path), skip_special_tokens=hf_cfg.skip_special_tokens, diff --git a/vllm_omni/model_executor/model_loader/weight_utils.py b/vllm_omni/model_executor/model_loader/weight_utils.py index 59c2cd860..559a90b50 100644 --- a/vllm_omni/model_executor/model_loader/weight_utils.py +++ b/vllm_omni/model_executor/model_loader/weight_utils.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + import os import time from pathlib import Path @@ -10,7 +13,11 @@ from vllm.model_executor.model_loader.weight_utils import DisabledTqdm, get_lock if envs.VLLM_USE_MODELSCOPE: from modelscope.hub.snapshot_download import snapshot_download else: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api + + def snapshot_download(*args, **kwargs): + return hf_api().snapshot_download(*args, **kwargs) + logger = init_logger(__name__) diff --git a/vllm_omni/model_executor/models/audex/checkpoint.py b/vllm_omni/model_executor/models/audex/checkpoint.py index 38b0531e6..ae8230e05 100644 --- a/vllm_omni/model_executor/models/audex/checkpoint.py +++ b/vllm_omni/model_executor/models/audex/checkpoint.py @@ -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 """Audex checkpoint layout preparation. The nvidia/Nemotron-Labs-Audex-2B repo deduplicates weights: the @@ -57,12 +57,12 @@ def _download_audiogen_index(model: str) -> str: metadata round trip per stage-engine boot; a cache miss falls back to the network. """ - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api try: - return hf_hub_download(model, _AUDIOGEN_INDEX_FILE, local_files_only=True) + return hf_api().hf_hub_download(model, _AUDIOGEN_INDEX_FILE, local_files_only=True) except Exception: - return hf_hub_download(model, _AUDIOGEN_INDEX_FILE) + return hf_api().hf_hub_download(model, _AUDIOGEN_INDEX_FILE) def _dedup_shard_patterns(model: str) -> list[str]: @@ -111,13 +111,13 @@ def ensure_audex_snapshot(model: str, profile: str = "tts") -> str: if profile in ("tts", "tta"): patterns += _dedup_shard_patterns(model) - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api try: - return snapshot_download(model, allow_patterns=patterns) + return hf_api().snapshot_download(model, allow_patterns=patterns) except Exception as download_exc: try: - return snapshot_download(model, allow_patterns=patterns, local_files_only=True) + return hf_api().snapshot_download(model, allow_patterns=patterns, local_files_only=True) except Exception: raise RuntimeError( f"Could not resolve the Audex repo {model!r} (profile {profile!r}): the download " @@ -135,13 +135,13 @@ def ensure_xcodec1_snapshot(model: str | None) -> str: if os.path.isdir(model): return model - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api try: - return snapshot_download(model) + return hf_api().snapshot_download(model) except Exception as download_exc: try: - return snapshot_download(model, local_files_only=True) + return hf_api().snapshot_download(model, local_files_only=True) except Exception: raise RuntimeError( f"Could not resolve the XCodec1 checkpoint {model!r}: the download failed and " diff --git a/vllm_omni/model_executor/models/bagel/bagel.py b/vllm_omni/model_executor/models/bagel/bagel.py index a3dfd7bf1..495e0037f 100644 --- a/vllm_omni/model_executor/models/bagel/bagel.py +++ b/vllm_omni/model_executor/models/bagel/bagel.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from collections.abc import Iterable, Mapping, Sequence from math import isqrt from typing import Any @@ -122,9 +125,9 @@ class OmniBagelProcessingInfo(BaseProcessingInfo): if p.is_dir(): index_path = p / "model.safetensors.index.json" else: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - index_path = Path(hf_hub_download(model_name, "model.safetensors.index.json")) + index_path = Path(hf_api().hf_hub_download(model_name, "model.safetensors.index.json")) if not index_path.exists(): return diff --git a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py index 81bae6d6c..84652cada 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py +++ b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py @@ -10,7 +10,6 @@ import numpy as np import onnxruntime import torch import torch.nn as nn -from huggingface_hub import snapshot_download from scipy.signal import resample_poly from transformers import Qwen2Config from transformers.feature_extraction_utils import BatchFeature @@ -52,6 +51,7 @@ from vllm_omni.model_executor.models.cosyvoice3.utils import ( from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.platforms import current_omni_platform from vllm_omni.transformers_utils.configs.cosyvoice3 import CosyVoice3Config +from vllm_omni.transformers_utils.repo_utils import hf_api from vllm_omni.utils.speaker_cache import get_speaker_cache logger = init_logger(__name__) @@ -137,7 +137,7 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal """Build the per-model runtime components once (cached process-wide).""" # If model_dir is an HF repo ID (not a local path), resolve to cache. if not os.path.isdir(model_dir): - model_dir = snapshot_download(model_dir) + model_dir = hf_api().snapshot_download(model_dir) tokenizer = get_qwen_tokenizer( token_path=os.path.join(model_dir, config.qwen_pretrain_path), @@ -463,7 +463,7 @@ class CosyVoice3Model( self.model_stage = vllm_config.model_config.model_stage model_dir = vllm_config.model_config.model if not os.path.isdir(model_dir): - model_dir = snapshot_download(model_dir) + model_dir = hf_api().snapshot_download(model_dir) self.model_dir = model_dir self.model = None if self.model_stage == "cosyvoice3_talker": @@ -918,7 +918,7 @@ class CosyVoice3Model( repo = getattr(self.config, "flow_estimator_onnx_repo", None) if repo: try: - fetched_dir = snapshot_download(repo, allow_patterns=[fp16_name]) + fetched_dir = hf_api().snapshot_download(repo, allow_patterns=[fp16_name]) fetched = os.path.join(fetched_dir, fp16_name) if os.path.exists(fetched): return fetched diff --git a/vllm_omni/model_executor/models/covo_audio/covo_audio_code2wav.py b/vllm_omni/model_executor/models/covo_audio/covo_audio_code2wav.py index 4b0b2fe4b..15cd0875e 100644 --- a/vllm_omni/model_executor/models/covo_audio/covo_audio_code2wav.py +++ b/vllm_omni/model_executor/models/covo_audio/covo_audio_code2wav.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2026 Tencent. import os from collections.abc import Iterable @@ -25,9 +28,9 @@ class CovoAudioCode2WavForConditionalGeneration(nn.Module, SupportsPP): if os.path.isdir(model_name): model_path = model_name else: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - model_path = snapshot_download(model_name) + model_path = hf_api().snapshot_download(model_name) token2wav_path = os.path.join(model_path, "token2wav") code2wav_config = CovoAudioCode2WavConfig() diff --git a/vllm_omni/model_executor/models/dots_tts/dots_tts_talker.py b/vllm_omni/model_executor/models/dots_tts/dots_tts_talker.py index ff2f460d6..862af8e17 100644 --- a/vllm_omni/model_executor/models/dots_tts/dots_tts_talker.py +++ b/vllm_omni/model_executor/models/dots_tts/dots_tts_talker.py @@ -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 """dots.tts talker — vLLM-native AR base LM + audio side path. Mirrors upstream rednote-hilab/dots.tts (pinned @ a393d2e): @@ -516,9 +516,9 @@ class DotsTTSForConditionalGeneration(nn.Module): if os.path.exists(local): return local try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return hf_hub_download( + return hf_api().hf_hub_download( repo_id=model_arg, filename="latent_stats.pt", local_files_only=True, diff --git a/vllm_omni/model_executor/models/dynin_omni/dynin_omni_common.py b/vllm_omni/model_executor/models/dynin_omni/dynin_omni_common.py index 5c67decaa..125822c1e 100644 --- a/vllm_omni/model_executor/models/dynin_omni/dynin_omni_common.py +++ b/vllm_omni/model_executor/models/dynin_omni/dynin_omni_common.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import hashlib @@ -20,9 +23,9 @@ from vllm.logger import init_logger logger = init_logger(__name__) try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api except Exception: # pragma: no cover - snapshot_download = None + hf_api = None class DetokTarget(IntEnum): @@ -397,13 +400,13 @@ def _find_dynin_config_under_root(root: Path) -> Path | None: @lru_cache(maxsize=16) def _resolve_dynin_config_from_hf_repo(repo_id: str) -> str | None: - if not _looks_like_hf_repo_id(repo_id) or snapshot_download is None: + if not _looks_like_hf_repo_id(repo_id) or hf_api is None: return None try: snapshot_dir = ( Path( - snapshot_download( + hf_api().snapshot_download( repo_id=repo_id, repo_type="model", allow_patterns=list(_DYNIN_CONFIG_CANDIDATE_RELPATHS), @@ -719,7 +722,7 @@ def _resolve_remote_snapshot_dir( if source_path.is_dir(): return str(source_path.resolve()) - if snapshot_download is None: + if hf_api is None: raise RuntimeError("huggingface_hub is required to load remote code.") kwargs: dict[str, Any] = { @@ -732,10 +735,10 @@ def _resolve_remote_snapshot_dir( kwargs["revision"] = revision try: - return str(snapshot_download(**kwargs)) + return str(hf_api().snapshot_download(**kwargs)) except TypeError: kwargs.pop("local_files_only", None) - return str(snapshot_download(**kwargs)) + return str(hf_api().snapshot_download(**kwargs)) def _ensure_remote_package(snapshot_dir: str) -> str: diff --git a/vllm_omni/model_executor/models/dynin_omni/dynin_omni_token2audio.py b/vllm_omni/model_executor/models/dynin_omni/dynin_omni_token2audio.py index 80bc0a78b..dbbb63524 100644 --- a/vllm_omni/model_executor/models/dynin_omni/dynin_omni_token2audio.py +++ b/vllm_omni/model_executor/models/dynin_omni/dynin_omni_token2audio.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import os @@ -80,7 +83,7 @@ def _ensure_remote_s2u_vendor_root( return str(existing_path) try: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api except Exception as e: logger.warning("huggingface_hub unavailable; cannot fetch s2u_vendor from %s: %s", repo_id, e) return None @@ -91,7 +94,7 @@ def _ensure_remote_s2u_vendor_root( for revision in revisions: try: - snapshot_dir = snapshot_download( + snapshot_dir = hf_api().snapshot_download( repo_id=repo_id, revision=revision, allow_patterns=["s2u_vendor/**"], @@ -99,7 +102,7 @@ def _ensure_remote_s2u_vendor_root( ) except TypeError: try: - snapshot_dir = snapshot_download( + snapshot_dir = hf_api().snapshot_download( repo_id=repo_id, revision=revision, allow_patterns=["s2u_vendor/**"], diff --git a/vllm_omni/model_executor/models/glm_tts/glm_tts.py b/vllm_omni/model_executor/models/glm_tts/glm_tts.py index 664a794c4..fb3517036 100644 --- a/vllm_omni/model_executor/models/glm_tts/glm_tts.py +++ b/vllm_omni/model_executor/models/glm_tts/glm_tts.py @@ -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 """GLM-TTS AR Model (Stage 0): Text → Speech Tokens. Based on Llama architecture, generates speech token sequences from input text. @@ -113,9 +113,9 @@ def resolve_glm_tts_tokenizer_path(model_name_or_path: Any) -> str: return candidate return model_path - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - local_dir = snapshot_download( + local_dir = hf_api().snapshot_download( model_path, allow_patterns=[ f"{_GLM_TTS_TOKENIZER_SUBDIR}/tokenizer*", @@ -169,9 +169,9 @@ def resolve_glm_tts_model_dir( except Exception: pass - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return snapshot_download(model_name_or_path) + return hf_api().snapshot_download(model_name_or_path) def _first_glm_tts_value(value: Any) -> Any: diff --git a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_code2wav.py b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_code2wav.py index 91609e1a4..c6a54a393 100644 --- a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_code2wav.py +++ b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_code2wav.py @@ -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 """Stage 1 (codec decoder) for higgs-audio v2. Two surfaces exposed: @@ -231,9 +231,9 @@ class HiggsAudioV2Code2Wav(nn.Module): if os.path.isfile(os.path.join(candidate, "config.json")): return candidate - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return snapshot_download(repo_id=repo_id) + return hf_api().snapshot_download(repo_id=repo_id) # ------------------------------------------------------ direct decode API @torch.inference_mode() diff --git a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py index c3f5024b3..587bcda67 100755 --- a/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py +++ b/vllm_omni/model_executor/models/higgs_audio_v2/higgs_audio_v2_tokenizer.py @@ -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 """TTS prompt builder + scope validators for higgs-audio v2. vllm-omni's higgs path supports two request shapes: @@ -248,9 +248,9 @@ def _load_audio_tokenizer(): audio_tokenizer_dir = _resolve_audio_tokenizer_dir() if audio_tokenizer_dir is None: - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - repo_path = snapshot_download( + repo_path = hf_api().snapshot_download( _K2_OMNIVOICE_REPO, allow_patterns=[f"{_K2_OMNIVOICE_SUBDIR}/*"], ) diff --git a/vllm_omni/model_executor/models/indextts2/preprocess_utils.py b/vllm_omni/model_executor/models/indextts2/preprocess_utils.py index 88e9a0639..827291602 100644 --- a/vllm_omni/model_executor/models/indextts2/preprocess_utils.py +++ b/vllm_omni/model_executor/models/indextts2/preprocess_utils.py @@ -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 """External model loading, audio I/O, and emotion conditioning for IndexTTS2.""" from __future__ import annotations @@ -171,9 +171,10 @@ def load_semantic_codec( codec.load_state_dict(state, strict=False) else: import safetensors.torch - from huggingface_hub import hf_hub_download - ckpt_path = hf_hub_download("amphion/MaskGCT", filename="semantic_codec/model.safetensors") + from vllm_omni.transformers_utils.repo_utils import hf_api + + ckpt_path = hf_api().hf_hub_download("amphion/MaskGCT", filename="semantic_codec/model.safetensors") safetensors.torch.load_model(codec, ckpt_path) _freeze(codec.to(device=device, dtype=torch.float32)) _semantic_codec_cache[cache_key] = codec @@ -198,9 +199,11 @@ def load_campplus(model_path: str, device: torch.device): if ckpt_path is not None: break if ckpt_path is None: - from huggingface_hub import hf_hub_download + if os.path.isdir(model_path): + raise FileNotFoundError(f"IndexTTS CAMPPlus checkpoint is missing from local bundle {model_path!r}") + from vllm_omni.transformers_utils.repo_utils import hf_api - ckpt_path = hf_hub_download("funasr/campplus", filename="campplus_cn_common.bin") + ckpt_path = hf_api().hf_hub_download("funasr/campplus", filename="campplus_cn_common.bin") state = torch.load(ckpt_path, map_location="cpu", weights_only=True) campplus.load_state_dict(state, strict=False) _freeze(campplus.to(device=device, dtype=torch.float32)) diff --git a/vllm_omni/model_executor/models/indextts2/s2mel/modules/bigvgan.py b/vllm_omni/model_executor/models/indextts2/s2mel/modules/bigvgan.py index 5876bb147..048d1de2d 100644 --- a/vllm_omni/model_executor/models/indextts2/s2mel/modules/bigvgan.py +++ b/vllm_omni/model_executor/models/indextts2/s2mel/modules/bigvgan.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright (c) 2024 NVIDIA CORPORATION. # Licensed under the MIT license. @@ -9,13 +12,14 @@ import os import torch import torch.nn as nn -from huggingface_hub import PyTorchModelHubMixin, hf_hub_download +from huggingface_hub import PyTorchModelHubMixin from torch.nn import Conv1d, ConvTranspose1d from torch.nn.utils import remove_weight_norm, weight_norm from vllm_omni.model_executor.models.common.alias_free_activation import AliasFreeActivation1d from vllm_omni.model_executor.models.common.snake_activation import Snake, SnakeBeta from vllm_omni.model_executor.models.indextts2.s2mel.modules.commons import AttrDict +from vllm_omni.transformers_utils.repo_utils import hf_api # --------------------------------------------------------------------------- # Helpers (inlined from env.py / utils.py) @@ -328,7 +332,7 @@ class BigVGAN( def _resolve(filename): if os.path.isdir(model_id): return os.path.join(model_id, filename) - return hf_hub_download( + return hf_api().hf_hub_download( repo_id=model_id, filename=filename, **{ diff --git a/vllm_omni/model_executor/models/indextts2/tokenizer_v2_5.py b/vllm_omni/model_executor/models/indextts2/tokenizer_v2_5.py index 6eae97703..ba27cad62 100644 --- a/vllm_omni/model_executor/models/indextts2/tokenizer_v2_5.py +++ b/vllm_omni/model_executor/models/indextts2/tokenizer_v2_5.py @@ -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 """IndexTTS 2.5 multilingual tiktoken vocabulary. The vocabulary layout mirrors the official IndexTTS 2.5 tokenizer: 58,836 @@ -234,9 +234,9 @@ def resolve_indextts25_tokenizer_file( return str(model_path) try: - from huggingface_hub import hf_hub_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return hf_hub_download(repo_id=model_dir, filename=tokenizer_file) + return hf_api().hf_hub_download(repo_id=model_dir, filename=tokenizer_file) except Exception as exc: raise FileNotFoundError( f"Could not resolve IndexTTS 2.5 tokenizer {tokenizer_file!r} from {model_dir!r}" diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py index 8b3926cc0..f701e8b26 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_code2wav.py @@ -39,9 +39,9 @@ def _resolve_model_dir(model_ref: str, revision: str | None = None) -> str: """ if Path(model_ref).is_dir(): return model_ref - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return snapshot_download(model_ref, revision=revision, allow_patterns=["assets/*"]) + return hf_api().snapshot_download(model_ref, revision=revision, allow_patterns=["assets/*"]) def _batch_error(reason: str, **details: Any) -> RuntimeError: diff --git a/vllm_omni/model_executor/models/omnivoice/omnivoice.py b/vllm_omni/model_executor/models/omnivoice/omnivoice.py index c167aa1c0..26a6f6b59 100644 --- a/vllm_omni/model_executor/models/omnivoice/omnivoice.py +++ b/vllm_omni/model_executor/models/omnivoice/omnivoice.py @@ -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 """ OmniVoice model for vLLM-Omni two-stage TTS pipeline. @@ -487,9 +487,9 @@ class OmniVoiceModel( if os.path.isdir(model_dir): return model_dir # HF hub model ID — resolve to local cache - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - return snapshot_download(model_dir) + return hf_api().snapshot_download(model_dir) def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: try: diff --git a/vllm_omni/model_executor/models/personaplex/personaplex_mimi.py b/vllm_omni/model_executor/models/personaplex/personaplex_mimi.py index 48df20fab..3b8813faf 100644 --- a/vllm_omni/model_executor/models/personaplex/personaplex_mimi.py +++ b/vllm_omni/model_executor/models/personaplex/personaplex_mimi.py @@ -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 """Streaming Mimi codec for PersonaPlex (moshi-free). Frame-clocked duplex needs a codec that encodes/decodes exactly one 80 ms frame @@ -288,10 +288,11 @@ class PersonaPlexMimiCodec(nn.Module): def __init__(self, hf_repo: str = DEFAULT_HF_REPO, checkpoint: str | None = None, device: str = "cuda") -> None: super().__init__() - from huggingface_hub import hf_hub_download from safetensors.torch import load_file from transformers import MimiConfig, MimiModel + from vllm_omni.transformers_utils.repo_utils import hf_api + self.device = torch.device(device) # The PersonaPlex repo ships the reference mimi checkpoint in the moshi @@ -300,7 +301,7 @@ class PersonaPlexMimiCodec(nn.Module): # transformer stacks remain absent because the streaming implementations # below replace them with the checkpoint's fused QKV layout. if checkpoint is None: - checkpoint = hf_hub_download( + checkpoint = hf_api().hf_hub_download( "nvidia/personaplex-7b-v1", "tokenizer-e351c8d8-checkpoint125.safetensors", ) diff --git a/vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py b/vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py index e9ca78b21..8e3c56b32 100644 --- a/vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py +++ b/vllm_omni/model_executor/models/step_audio2/step_audio2_token2wav.py @@ -461,9 +461,9 @@ class StepAudio2Token2WavForConditionalGeneration(nn.Module, SupportsPP): if model_path is None: # Resolve HF repo names to local cache path if not os.path.isdir(model_name_or_path): - from huggingface_hub import snapshot_download + from vllm_omni.transformers_utils.repo_utils import hf_api - model_name_or_path = snapshot_download(model_name_or_path) + model_name_or_path = hf_api().snapshot_download(model_name_or_path) model_path = f"{model_name_or_path}/token2wav" float16 = getattr(self.config, "token2wav_float16", False) diff --git a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py index 69cfa6d26..c61aeaaad 100644 --- a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py +++ b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from collections.abc import Iterable, Mapping from dataclasses import replace from functools import cached_property @@ -6,7 +9,6 @@ from pathlib import Path import regex as re import torch import torch.nn as nn -from huggingface_hub import hf_hub_download from vllm.config import VllmConfig from vllm.logger import init_logger from vllm.model_executor.models.interfaces import SupportsMultiModal @@ -28,6 +30,7 @@ from vllm_omni.model_executor.models.voxtral_tts.voxtral_tts_audio_generation im VoxtralTTSMultiModalProcessor, VoxtralTTSProcessingInfo, ) +from vllm_omni.transformers_utils.repo_utils import hf_api logger = init_logger(__name__) @@ -133,7 +136,7 @@ class VoxtralTTSForConditionalGeneration( self.voice_to_embedding = {} for sid in speaker_id: if self.is_hf_model: - path = hf_hub_download(repo_id=self.repo_id, filename=f"voice_embedding/{sid}.pt") + path = hf_api().hf_hub_download(repo_id=self.repo_id, filename=f"voice_embedding/{sid}.pt") else: path = Path(self.repo_id) / "voice_embedding" / f"{sid}.pt" if Path(path).exists(): diff --git a/vllm_omni/transformers_utils/repo_utils.py b/vllm_omni/transformers_utils/repo_utils.py new file mode 100644 index 000000000..e780c4b53 --- /dev/null +++ b/vllm_omni/transformers_utils/repo_utils.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Utilities for model repo interaction.""" + +from huggingface_hub import HfApi + +from vllm_omni.version import __version__ as VLLM_OMNI_VERSION + +_hf_api: HfApi | None = None + + +def hf_api() -> HfApi: + """Return a shared HfApi instance tagged with vLLM-Omni's library info.""" + global _hf_api + if _hf_api is None: + _hf_api = HfApi( + library_name="vllm-omni", + library_version=VLLM_OMNI_VERSION, + ) + return _hf_api From 5e4baea64d0f4cd1b6ce147e4f7a28f0f41d995f Mon Sep 17 00:00:00 2001 From: Lei Ke <1141466880@qq.com> Date: Wed, 9 Sep 2026 00:08:46 +0800 Subject: [PATCH 48/90] [Bugfix][NPU] Limit MiniMax H3 modulation grid size (#6794) Signed-off-by: KrystalRay Co-authored-by: KrystalRay --- .buildkite/npu/test-npu-ready.yml | 1 + .../minimax_h3/test_minimax_h3_modulation.py | 142 +++++++++++++++++- .../attention/ops/minimax_h3_modulation.py | 48 +++++- 3 files changed, 182 insertions(+), 9 deletions(-) diff --git a/.buildkite/npu/test-npu-ready.yml b/.buildkite/npu/test-npu-ready.yml index 1f25ebac1..01fb32879 100644 --- a/.buildkite/npu/test-npu-ready.yml +++ b/.buildkite/npu/test-npu-ready.yml @@ -20,6 +20,7 @@ steps: tests/diffusion/layers/test_activation.py tests/diffusion/layers/test_fused_qk_norm_rope_npu.py tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py + tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py -m 'core_model and npu and A3' --run-level 'core_model' diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py index dbeeaa66a..e10fbe56a 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py @@ -5,14 +5,154 @@ import pytest import torch from vllm.triton_utils import HAS_TRITON +from tests.helpers.mark import hardware_marks from vllm_omni.diffusion.attention.ops.minimax_h3_modulation import ( + _MAX_1D_GRID_SIZE, + _iter_row_chunks, + _launch_row_chunks, + indexed_gate, indexed_gate_rms_norm_scale_shift, + indexed_scale_shift_, rms_norm_indexed_scale_shift, ) +from vllm_omni.platforms import current_omni_platform -pytestmark = [pytest.mark.core_model, pytest.mark.diffusion, pytest.mark.cuda] +pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] +@pytest.mark.cpu +@pytest.mark.parametrize( + ("rows", "expected"), + [ + (0, []), + (_MAX_1D_GRID_SIZE, [(0, _MAX_1D_GRID_SIZE)]), + (_MAX_1D_GRID_SIZE + 1, [(0, _MAX_1D_GRID_SIZE), (_MAX_1D_GRID_SIZE, 1)]), + (69888, [(0, _MAX_1D_GRID_SIZE), (_MAX_1D_GRID_SIZE, 4353)]), + ], +) +def test_row_chunks_respect_ascend_grid_limit(rows, expected): + assert list(_iter_row_chunks(rows)) == expected + + +@pytest.mark.cpu +@pytest.mark.parametrize( + ("device_type", "expected_grids_and_offsets"), + [ + ("npu", [(_MAX_1D_GRID_SIZE, 0), (4353, _MAX_1D_GRID_SIZE)]), + ("cuda", [(69888, 0)]), + ], +) +def test_launch_row_chunks_is_npu_only(device_type, expected_grids_and_offsets): + class RecordingKernel: + def __init__(self): + self.calls = [] + + def __getitem__(self, grid): + def launch(*args, **kwargs): + self.calls.append((grid, args, kwargs)) + + return launch + + kernel = RecordingKernel() + first_arg = object() + second_arg = object() + + _launch_row_chunks(kernel, 69888, device_type, first_arg, second_arg, block_n=16) + + assert kernel.calls == [ + ((grid,), (first_arg, second_arg, row_offset), {"block_n": 16}) + for grid, row_offset in expected_grids_and_offsets + ] + + +@pytest.mark.skipif(not current_omni_platform.is_npu(), reason="requires Ascend NPU") +@pytest.mark.parametrize( + "device", + [pytest.param("npu", marks=hardware_marks(res={"npu": "A3"}, num_cards=1))], +) +def test_modulation_wrappers_match_reference_above_grid_limit(device): + rows = 69888 + hidden_size = 16 + num_conditions = 7 + eps = 1e-6 + dtype = torch.bfloat16 + + torch.manual_seed(0) + x = torch.randn(rows, hidden_size, dtype=dtype) + residual = torch.randn_like(x) + branch = torch.randn_like(x) + weight = torch.randn(hidden_size, dtype=dtype) + shift = torch.randn(num_conditions, hidden_size, dtype=dtype) + scale = torch.randn_like(shift) + gate = torch.randn_like(shift) + indices = torch.arange(rows) % num_conditions + + indexed_shift = shift.index_select(0, indices) + indexed_scale = scale.index_select(0, indices) + indexed_gate_values = gate.index_select(0, indices) + + x_float = x.float() + branch_float = branch.float() + indexed_shift_float = indexed_shift.float() + indexed_scale_float = indexed_scale.float() + indexed_gate_float = indexed_gate_values.float() + + expected_scale_shift = (x_float * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype) + expected_gate = (x_float + indexed_gate_float * branch_float).to(dtype) + + normalized = x_float + normalized = normalized * torch.rsqrt(normalized.pow(2).mean(-1, keepdim=True) + eps) + normalized = normalized * weight.float() + expected_rms = (normalized * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype) + + updated_residual = residual.float() + indexed_gate_float * branch_float + expected_residual = updated_residual.to(dtype) + normalized_residual = updated_residual + normalized_residual = normalized_residual * torch.rsqrt(normalized_residual.pow(2).mean(-1, keepdim=True) + eps) + normalized_residual = normalized_residual * weight.float() + expected_modulated = (normalized_residual * (1.0 + indexed_scale_float) + indexed_shift_float).to(dtype) + + npu_x = x.to(device) + npu_residual = residual.to(device) + npu_branch = branch.to(device) + npu_weight = weight.to(device) + npu_shift = shift.to(device) + npu_scale = scale.to(device) + npu_gate = gate.to(device) + npu_indices = indices.to(device) + + actual_scale_shift = indexed_scale_shift_(npu_x.clone(), npu_shift, npu_scale, npu_indices) + actual_gate = indexed_gate(npu_x, npu_gate, npu_branch, npu_indices) + actual_rms = rms_norm_indexed_scale_shift( + npu_x, + npu_weight, + npu_shift, + npu_scale, + npu_indices, + eps, + ) + actual_residual, actual_modulated = indexed_gate_rms_norm_scale_shift( + npu_residual, + npu_gate, + npu_branch, + npu_weight, + npu_shift, + npu_scale, + npu_indices, + eps, + ) + + for actual, expected in ( + (actual_scale_shift, expected_scale_shift), + (actual_gate, expected_gate), + (actual_rms, expected_rms), + (actual_residual, expected_residual), + (actual_modulated, expected_modulated), + ): + torch.testing.assert_close(actual.cpu(), expected, atol=2e-2, rtol=2e-2) + + +@pytest.mark.cuda @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") @pytest.mark.skipif(not HAS_TRITON, reason="Triton required") def test_fused_modulation_preserves_bf16_residual_boundary() -> None: diff --git a/vllm_omni/diffusion/attention/ops/minimax_h3_modulation.py b/vllm_omni/diffusion/attention/ops/minimax_h3_modulation.py index f75833831..20327b7e9 100644 --- a/vllm_omni/diffusion/attention/ops/minimax_h3_modulation.py +++ b/vllm_omni/diffusion/attention/ops/minimax_h3_modulation.py @@ -7,6 +7,22 @@ from __future__ import annotations import torch from vllm.triton_utils import tl, triton +# Ascend CANN limits the launch coreDim to uint16. +_MAX_1D_GRID_SIZE = 65535 + + +def _iter_row_chunks(rows: int): + for row_offset in range(0, rows, _MAX_1D_GRID_SIZE): + yield row_offset, min(rows - row_offset, _MAX_1D_GRID_SIZE) + + +def _launch_row_chunks(kernel, rows: int, device_type: str, *args, **kwargs) -> None: + if device_type != "npu": + kernel[(rows,)](*args, 0, **kwargs) + return + for row_offset, chunk_rows in _iter_row_chunks(rows): + kernel[(chunk_rows,)](*args, row_offset, **kwargs) + @triton.jit def _indexed_scale_shift_kernel( @@ -20,9 +36,10 @@ def _indexed_scale_shift_kernel( stride_shift_row, stride_scale_row, stride_indices, + row_offset, block_n: tl.constexpr, ): - row = tl.program_id(0) + row = tl.program_id(0) + row_offset columns = tl.arange(0, block_n) mask = columns < hidden_size index = tl.load(indices_ptr + row * stride_indices) @@ -51,9 +68,10 @@ def _indexed_gate_kernel( stride_gate_row, stride_other_row, stride_indices, + row_offset, block_n: tl.constexpr, ): - row = tl.program_id(0) + row = tl.program_id(0) + row_offset columns = tl.arange(0, block_n) mask = columns < hidden_size index = tl.load(indices_ptr + row * stride_indices) @@ -83,9 +101,10 @@ def _rms_norm_indexed_scale_shift_kernel( stride_shift_row, stride_scale_row, stride_indices, + row_offset, block_n: tl.constexpr, ): - row = tl.program_id(0) + row = tl.program_id(0) + row_offset columns = tl.arange(0, block_n) mask = columns < hidden_size index = tl.load(indices_ptr + row * stride_indices) @@ -123,9 +142,10 @@ def _indexed_gate_rms_norm_scale_shift_kernel( stride_shift_row, stride_scale_row, stride_indices, + row_offset, block_n: tl.constexpr, ): - row = tl.program_id(0) + row = tl.program_id(0) + row_offset columns = tl.arange(0, block_n) mask = columns < hidden_size index = tl.load(indices_ptr + row * stride_indices) @@ -162,7 +182,10 @@ def indexed_scale_shift_( rows, hidden_size = x.shape if rows == 0: return x - _indexed_scale_shift_kernel[(rows,)]( + _launch_row_chunks( + _indexed_scale_shift_kernel, + rows, + x.device.type, x, x, shift, @@ -192,7 +215,10 @@ def indexed_gate( rows, hidden_size = x.shape if rows == 0: return output - _indexed_gate_kernel[(rows,)]( + _launch_row_chunks( + _indexed_gate_kernel, + rows, + x.device.type, output, x, gate, @@ -229,7 +255,10 @@ def rms_norm_indexed_scale_shift( output = torch.empty_like(x) rows, hidden_size = x.shape if rows: - _rms_norm_indexed_scale_shift_kernel[(rows,)]( + _launch_row_chunks( + _rms_norm_indexed_scale_shift_kernel, + rows, + x.device.type, output, x, weight, @@ -274,7 +303,10 @@ def indexed_gate_rms_norm_scale_shift( modulated_out = torch.empty_like(residual) rows, hidden_size = residual.shape if rows: - _indexed_gate_rms_norm_scale_shift_kernel[(rows,)]( + _launch_row_chunks( + _indexed_gate_rms_norm_scale_shift_kernel, + rows, + residual.device.type, residual_out, modulated_out, residual, From b3dd45874a750f7edfa39bb02262804228e5ff7b Mon Sep 17 00:00:00 2001 From: Tianyao Wu <54675599+twu3202@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:27:38 +0800 Subject: [PATCH 49/90] [Bugfix] Build the forced-aligner prompt without a chat template (word timestamps one bin late) (#7240) Signed-off-by: Tianyao Wu --- .../utils/test_qwen3_force_align_processor.py | 16 ++++++++++++++- .../deploy/qwen3_tts_forced_aligner.yaml | 5 +++-- .../utils/qwen3_force_align_processor.py | 20 +++++++++---------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/tests/utils/test_qwen3_force_align_processor.py b/tests/utils/test_qwen3_force_align_processor.py index 4a928d0e5..f846ac7a8 100644 --- a/tests/utils/test_qwen3_force_align_processor.py +++ b/tests/utils/test_qwen3_force_align_processor.py @@ -10,7 +10,21 @@ def test_build_prompt_has_boundary_timestamp_markers(): assert prompt.count("") == 4 assert "helloworld" in prompt - assert prompt.endswith("<|im_start|>assistant\n") + # Official aligner format: audio placeholder + words, no chat template + # (a leading "<|im_start|>user\n" shifts the predicted markers one bin late). + assert prompt == f"{processor.AUDIO_PLACEHOLDER}helloworld" + assert "<|im_start|>" not in prompt + assert processor.build_prompt([]) == f"{processor.AUDIO_PLACEHOLDER}" + + +def test_build_prompt_matches_official_encode_timestamp(): + pytest.importorskip("qwen_asr") + from qwen_asr.inference.qwen3_forced_aligner import Qwen3ForceAlignProcessor + + text = "It's 3 o'clock, 你好 world." + word_list, official_prompt = Qwen3ForceAlignProcessor().encode_timestamp(text, "english") + + assert processor.build_prompt(list(word_list)) == official_prompt @pytest.mark.parametrize( diff --git a/vllm_omni/deploy/qwen3_tts_forced_aligner.yaml b/vllm_omni/deploy/qwen3_tts_forced_aligner.yaml index 2aecdbf9b..b600ff1e1 100644 --- a/vllm_omni/deploy/qwen3_tts_forced_aligner.yaml +++ b/vllm_omni/deploy/qwen3_tts_forced_aligner.yaml @@ -5,8 +5,9 @@ # --forced-aligner sets just the model path. # # The stage runs the model with runner="pooling" + pooling_task="token_classify". -# Prompt tokens, chat template and word segmentation are Qwen-specific and live -# in qwen3_force_align_processor (not configurable here). +# Prompt tokens and word segmentation are Qwen-specific and live in +# qwen3_force_align_processor (not configurable here); the prompt is the same +# string qwen_asr's encode_timestamp builds, with no chat template. forced_aligner: model: Qwen/Qwen3-ForcedAligner-0.6B diff --git a/vllm_omni/utils/qwen3_force_align_processor.py b/vllm_omni/utils/qwen3_force_align_processor.py index e70d270db..76c5b9084 100644 --- a/vllm_omni/utils/qwen3_force_align_processor.py +++ b/vllm_omni/utils/qwen3_force_align_processor.py @@ -65,18 +65,18 @@ _LANG_ALIASES = { def build_prompt(words: list[str]) -> str: - """Wrap segmented words in the Qwen3 aligner prompt template. + """Build the Qwen3 aligner prompt exactly as qwen_asr's + ``Qwen3ForceAlignProcessor.encode_timestamp`` does: the audio placeholder + followed by the words, each with two trailing ```` markers + (start + end) that the model classifies into audio time bins. - Each word gets two trailing ```` markers (start + end); the - model classifies each marker into an audio time bin. + No chat template. The official aligner feeds this string straight to the + tokenizer; wrapping it in ``<|im_start|>user ... <|im_start|>assistant`` + puts three tokens in front of the audio and shifts almost every predicted + marker one 80 ms bin later than the official output. """ - if not words: - # Pad with one timestamp so the decoder always has something to - # read; an empty result still surfaces as "[]" upstream. - body = TIMESTAMP_TOKEN - else: - body = f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}".join(words) + f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}" - return f"<|im_start|>user\n{AUDIO_PLACEHOLDER}{body}<|im_end|>\n<|im_start|>assistant\n" + body = f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}".join(words) + f"{TIMESTAMP_TOKEN}{TIMESTAMP_TOKEN}" + return f"{AUDIO_PLACEHOLDER}{body}" # --- word segmentation (port of Qwen3ForceAlignProcessor) ---------------- From b6f4df320dc6ec187d11b3df2ecff14bd0fe5849 Mon Sep 17 00:00:00 2001 From: Anjie Hou <149605198+specture724@users.noreply.github.com> Date: Wed, 9 Sep 2026 08:17:38 +0800 Subject: [PATCH 50/90] [Refactor][Diffusion] Resolve offload topology through one plan resolver (#7209) Signed-off-by: specture724 Co-authored-by: Claude Opus 5 --- docs/design/feature/offloader/README.md | 33 +- .../test_distributed_layerwise_backend.py | 28 +- .../offloader/test_layerwise_backend.py | 3 +- .../diffusion/offloader/test_plan_resolver.py | 458 ++++++++++++++++++ vllm_omni/diffusion/offloader/__init__.py | 10 + .../diffusion/offloader/component_utils.py | 96 ++-- .../distributed_layerwise_backend.py | 142 ++---- .../diffusion/offloader/layerwise_backend.py | 89 ++-- vllm_omni/diffusion/offloader/offload_plan.py | 10 +- .../diffusion/offloader/plan_resolver.py | 330 +++++++++++++ .../diffusion/offloader/sequential_backend.py | 68 ++- 11 files changed, 976 insertions(+), 291 deletions(-) create mode 100644 tests/diffusion/offloader/test_plan_resolver.py create mode 100644 vllm_omni/diffusion/offloader/plan_resolver.py diff --git a/docs/design/feature/offloader/README.md b/docs/design/feature/offloader/README.md index 7b767f41f..96cba8f60 100644 --- a/docs/design/feature/offloader/README.md +++ b/docs/design/feature/offloader/README.md @@ -53,6 +53,11 @@ must use distinct hook names and remove only hooks they own. ## Discovery and topology +`resolve_offload_plan(pipeline, config)` is the single topology entry point. +Every backend calls it once when `enable()` starts and then consumes the +resolved components, block stacks, resident heads, and staged-residency flags +it returns. Backends do not re-read model declarations. + Pipeline component discovery prefers `SupportsComponentDiscovery` declarations: - `_dit_modules`; @@ -61,16 +66,26 @@ Pipeline component discovery prefers `SupportsComponentDiscovery` declarations: - `_resident_modules`. Dotted paths are supported. Legacy pipelines may use the fallback scan of -well-known attribute names, but new integrations should declare components -explicitly. +well-known attribute names; it now warns once per pipeline class, and new +integrations must declare components explicitly. -Both layerwise backends first consume pipeline `OffloadPlan` metadata. A -plan's `block_attrs` maps each DiT path to its ordered block containers, while -`encoder_block_attrs` declares streamable encoder stacks. DiTs absent from the -plan fall back to `_layerwise_offload_blocks_attrs` (including the deprecated -singular-name compatibility path). Discovery metadata describes structure -only; the backend remains responsible for transfer, synchronization, and -storage ownership. +Block topology comes from the pipeline `OffloadPlan` first: `block_attrs` maps +each DiT path to its ordered block containers, `encoder_block_attrs` declares +streamable encoder stacks, `on_demand_component_paths` marks pipeline-managed +residency, `resident_dit_paths` marks the DiTs that may hold resident layers, +and `encoder_dlo_weight_replication` marks the encoders whose loader-produced +weights are safe for AllGather. DiTs absent from the plan fall back to +`_layerwise_offload_blocks_attrs` (including the deprecated singular-name +compatibility path). + +The resolver is pure: it moves no tensor, installs no hook, writes no module +attribute, and reads no process group — multi-rank facts come from +`OffloadConfig`. It owns topology validation, so a configuration the model +cannot serve fails before the first component is placed. Explicit component +selection turns every declaration problem into an error, while the +compatibility topology (no component selector) keeps the historical +warn-and-skip behavior. Metadata still describes structure only; the backend +remains responsible for transfer, synchronization, and storage ownership. ## Cross-strategy invariants diff --git a/tests/diffusion/offloader/test_distributed_layerwise_backend.py b/tests/diffusion/offloader/test_distributed_layerwise_backend.py index 05d7d93b5..c24639434 100644 --- a/tests/diffusion/offloader/test_distributed_layerwise_backend.py +++ b/tests/diffusion/offloader/test_distributed_layerwise_backend.py @@ -52,6 +52,7 @@ from vllm_omni.diffusion.offloader.offload_plan import ( OffloadPlan, get_offload_plan, ) +from vllm_omni.diffusion.offloader.plan_resolver import resolve_offload_plan from vllm_omni.diffusion.offloader.startup import OffloadStartupState, attach_offload_startup_state from vllm_omni.host_weight_runtime import MappedHostRegion from vllm_omni.platforms import current_omni_platform @@ -2813,18 +2814,21 @@ class TestDistributedComponentSelection: def test_encoder_allgather_rejects_stub_rank_before_block_discovery(self): """Every rank must reject an unsafe encoder group, including stub ranks.""" - backend = DistributedLayerwiseOffloadBackend( - OffloadConfig( - strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, - pin_cpu_memory=False, - dp_size=2, - components=frozenset({"text_encoder"}), - dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"}, - ), - torch.device("cpu"), + + class StubEncoderPipeline(nn.Module): + _offload_plan = OffloadPlan(encoder_block_attrs={"text_encoder": ("missing.blocks",)}) + + def __init__(self): + super().__init__() + self.text_encoder = nn.Module() + + config = OffloadConfig( + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + pin_cpu_memory=False, + dp_size=2, + components=frozenset({"text_encoder"}), + dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"}, ) - backend.dp_group = object() - stub_plan = OffloadPlan(encoder_block_attrs={"text_encoder": ("missing.blocks",)}) with pytest.raises(ValueError, match="not declared replicated"): - backend._try_layerwise_offload_encoder(nn.Module(), "text_encoder", stub_plan) + resolve_offload_plan(StubEncoderPipeline(), config) diff --git a/tests/diffusion/offloader/test_layerwise_backend.py b/tests/diffusion/offloader/test_layerwise_backend.py index 2dd8804cf..099e93f79 100644 --- a/tests/diffusion/offloader/test_layerwise_backend.py +++ b/tests/diffusion/offloader/test_layerwise_backend.py @@ -488,7 +488,8 @@ class TestLayerwiseComponentSelection: with pytest.raises(ValueError, match="Selected text encoder 'text_encoder_2' requires"): backend.enable(pipeline) - assert not pipeline.text_encoder._omni_layerwise_enabled + # The plan resolver rejects the topology before any encoder is hooked. + assert not getattr(pipeline.text_encoder, "_omni_layerwise_enabled", False) def test_default_selection_preserves_unplanned_auxiliaries(self, patched_offload_runtime): pipeline = _LegacyComponentPipeline() diff --git a/tests/diffusion/offloader/test_plan_resolver.py b/tests/diffusion/offloader/test_plan_resolver.py new file mode 100644 index 000000000..483ce4060 --- /dev/null +++ b/tests/diffusion/offloader/test_plan_resolver.py @@ -0,0 +1,458 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Unit tests for the shared offload plan resolver.""" + +from typing import ClassVar + +import pytest +import torch +from torch import nn + +from tests.diffusion.offloader.helpers import ( + _DummyBlock, + _PlainEncoder, + _SingleBlockModel, + _StagedEncoder, + _StagedVAE, + patch_offload_runtime, +) +from vllm_omni.diffusion.offloader import layerwise_backend as layerwise_backend_module +from vllm_omni.diffusion.offloader import plan_resolver as plan_resolver_module +from vllm_omni.diffusion.offloader.base import OffloadConfig, OffloadStrategy +from vllm_omni.diffusion.offloader.layerwise_backend import LayerWiseOffloadBackend +from vllm_omni.diffusion.offloader.offload_plan import OffloadPlan +from vllm_omni.diffusion.offloader.plan_resolver import resolve_offload_plan + +pytestmark = [pytest.mark.diffusion, pytest.mark.cpu, pytest.mark.core_model] + +CPU = torch.device("cpu") + + +@pytest.fixture +def patched_offload_runtime(monkeypatch): + patch_offload_runtime(monkeypatch, layerwise_backend_module.current_omni_platform) + + +def _config( + components: set[str] | None = None, + strategy: OffloadStrategy = OffloadStrategy.LAYER_WISE, + **kwargs, +) -> OffloadConfig: + return OffloadConfig( + strategy=strategy, + pin_cpu_memory=False, + components=None if components is None else frozenset(components), + **kwargs, + ) + + +def _stack_of(component): + assert len(component.stacks) == 1 + return component.stacks[0] + + +class _WanStylePipeline(nn.Module): + """Streamable DiT plus a plan-declared, replicated text encoder.""" + + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder"] + _vae_modules: ClassVar[list[str]] = ["vae"] + _resident_modules: ClassVar[list[str]] = [] + _offload_plan = OffloadPlan( + encoder_component_types={"text_encoder": "text_encoder"}, + encoder_block_attrs={"text_encoder": ("encoder.block",)}, + encoder_dlo_weight_replication=frozenset({"text_encoder"}), + ) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=4) + self.text_encoder = _PlainEncoder() + self.vae = _StagedVAE() + + +class _StagedComponentPipeline(nn.Module): + """MiniMax-H3 shape: two encoder stacks plus pipeline-staged components.""" + + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder"] + _vae_modules: ClassVar[list[str]] = ["vae"] + _resident_modules: ClassVar[list[str]] = [] + _offload_plan = OffloadPlan( + encoder_component_types={"text_encoder": "text_encoder"}, + encoder_block_attrs={"text_encoder": ("vision.blocks", "text_model.layers")}, + on_demand_component_paths=frozenset({"text_encoder", "vae"}), + resident_dit_paths=frozenset({"transformer"}), + ) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=4) + self.text_encoder = _StagedEncoder() + self.vae = _StagedVAE() + + +class _LegacyScanPipeline(nn.Module): + """No SupportsComponentDiscovery declaration and no OffloadPlan.""" + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=3) + self.text_encoder = _PlainEncoder() + + +class TestProducerPrecedence: + def test_plan_block_attrs_win_over_class_attributes(self): + class TwoStackDiT(nn.Module): + _layerwise_offload_blocks_attrs = ["blocks"] + + def __init__(self): + super().__init__() + self.blocks = nn.ModuleList([_DummyBlock() for _ in range(2)]) + self.alt_blocks = nn.ModuleList([_DummyBlock() for _ in range(3)]) + + class Pipeline(nn.Module): + _offload_plan = OffloadPlan(block_attrs={"transformer": ("alt_blocks",)}) + + def __init__(self): + super().__init__() + self.transformer = TwoStackDiT() + + pipeline = Pipeline() + resolved = resolve_offload_plan(pipeline, _config({"dit"})) + + stack = _stack_of(resolved.dits[0]) + assert stack.attrs == ("alt_blocks",) + assert list(stack.blocks) == list(pipeline.transformer.alt_blocks) + + def test_class_attributes_are_used_without_a_plan(self): + pipeline = _LegacyScanPipeline() + resolved = resolve_offload_plan(pipeline, _config({"dit"})) + + assert list(_stack_of(resolved.dits[0]).blocks) == list(pipeline.transformer.blocks) + + def test_declared_component_type_wins_over_leaf_name(self): + class Pipeline(nn.Module): + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["mllm"] + _vae_modules: ClassVar[list[str]] = [] + _resident_modules: ClassVar[list[str]] = [] + _offload_plan = OffloadPlan( + encoder_component_types={"mllm": "text_encoder"}, + encoder_block_attrs={"mllm": ("encoder.block",)}, + ) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + self.mllm = _PlainEncoder() + + resolved = resolve_offload_plan(Pipeline(), _config({"text_encoder"})) + + encoder = resolved.encoders[0] + assert encoder.path == "mllm" + assert encoder.selected + assert len(_stack_of(encoder).blocks) == 2 + + def test_component_declaration_wins_over_attribute_scan(self): + class Pipeline(nn.Module): + _dit_modules: ClassVar[list[str]] = ["custom_dit"] + _encoder_modules: ClassVar[list[str]] = [] + _vae_modules: ClassVar[list[str]] = [] + _resident_modules: ClassVar[list[str]] = [] + + def __init__(self): + super().__init__() + self.custom_dit = _SingleBlockModel(num_blocks=2) + # Would be picked up by the deprecated attribute scan. + self.transformer = _SingleBlockModel(num_blocks=2) + + resolved = resolve_offload_plan(Pipeline(), _config({"dit"})) + + assert [component.path for component in resolved.dits] == ["custom_dit"] + + def test_attribute_scan_warns_once_per_pipeline_class(self, monkeypatch): + warnings: list[str] = [] + + class _Recorder: + def warning(self, message, *args): + warnings.append(message % args if args else message) + + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + monkeypatch.setattr(plan_resolver_module, "logger", _Recorder()) + + class UndeclaredPipeline(nn.Module): + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + + resolve_offload_plan(UndeclaredPipeline(), _config({"dit"})) + resolve_offload_plan(UndeclaredPipeline(), _config({"dit"})) + + scan_warnings = [message for message in warnings if "SupportsComponentDiscovery" in message] + assert len(scan_warnings) == 1 + assert "deprecated" in scan_warnings[0] + + +class TestResolvedTopology: + def test_encoder_plan_resolves_one_stack_per_declared_path(self): + pipeline = _WanStylePipeline() + resolved = resolve_offload_plan(pipeline, _config({"dit", "text_encoder"})) + + encoder = resolved.encoders[0] + assert encoder.selected and not encoder.on_demand + assert list(_stack_of(encoder).blocks) == list(pipeline.text_encoder.encoder.block) + assert [component.path for component in resolved.vaes] == ["vae"] + + def test_staged_plan_resolves_every_declared_stack(self): + pipeline = _StagedComponentPipeline() + resolved = resolve_offload_plan(pipeline, _config({"text_encoder"})) + + encoder = resolved.encoders[0] + assert encoder.on_demand + assert [len(stack.blocks) for stack in encoder.stacks] == [2, 2] + # An explicit selection never stages a VAE through the legacy plan. + assert not resolved.vaes[0].on_demand + # DiT is unselected, so it keeps no streaming topology. + assert resolved.dits[0].selected is False + assert resolved.dits[0].stacks == () + + def test_omitted_selector_keeps_the_legacy_staged_vae(self): + resolved = resolve_offload_plan(_StagedComponentPipeline(), _config()) + + assert resolved.vaes[0].on_demand + assert resolved.encoders[0].on_demand + assert resolved.dits[0].selected + + def test_on_demand_only_plan_resolves_without_stacks(self): + class Pipeline(nn.Module): + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder"] + _vae_modules: ClassVar[list[str]] = ["vae"] + _resident_modules: ClassVar[list[str]] = [] + _offload_plan = OffloadPlan(on_demand_component_paths=frozenset({"text_encoder", "vae"})) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + self.text_encoder = _StagedEncoder() + self.vae = _StagedVAE() + + resolved = resolve_offload_plan(Pipeline(), _config({"text_encoder"})) + + assert resolved.encoders[0].on_demand + assert resolved.encoders[0].stacks == () + + def test_module_strategy_resolves_selection_without_stacks(self): + resolved = resolve_offload_plan( + _WanStylePipeline(), + _config({"dit", "text_encoder"}, strategy=OffloadStrategy.MODEL_LEVEL), + ) + + assert [component.path for component in resolved.components] == ["transformer", "text_encoder", "vae"] + assert all(component.stacks == () for component in resolved.components) + assert resolved.encoders[0].selected + + def test_module_strategy_leaves_staged_residency_to_the_pipeline(self): + resolved = resolve_offload_plan( + _StagedComponentPipeline(), + _config({"dit", "text_encoder"}, strategy=OffloadStrategy.MODEL_LEVEL), + ) + + assert not any(component.on_demand for component in resolved.components) + + +class TestResidentLayers: + def _dlo_config(self, resident_layers: int, components: set[str] | None = None) -> OffloadConfig: + return _config( + components, + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + dlo_transfers={"dit": "rank-local", "text_encoder": "rank-local"}, + dlo_resident_layers=resident_layers, + ) + + def test_resident_head_splits_the_declared_dit(self): + pipeline = _StagedComponentPipeline() + resolved = resolve_offload_plan(pipeline, self._dlo_config(2, {"dit"})) + + stack = _stack_of(resolved.dits[0]) + assert stack.resident_head == 2 + assert list(stack.resident) == list(pipeline.transformer.blocks)[:2] + assert list(stack.streaming) == list(pipeline.transformer.blocks)[2:] + + def test_resident_count_beyond_the_stack_keeps_every_block(self): + resolved = resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(9, {"dit"})) + + stack = _stack_of(resolved.dits[0]) + assert stack.resident_head == 4 + assert stack.streaming == () + + def test_resident_head_leaving_one_streaming_block_is_rejected(self): + with pytest.raises(ValueError, match="leaves only one streaming block"): + resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(3, {"dit"})) + + def test_legacy_single_streaming_block_stays_resident(self): + resolved = resolve_offload_plan(_StagedComponentPipeline(), self._dlo_config(3)) + + stack = _stack_of(resolved.dits[0]) + assert stack.resident_head == 4 + assert stack.streaming == () + + def test_resident_layers_without_a_declared_path_is_rejected(self): + with pytest.raises(ValueError, match="no matching resident_dit_paths"): + resolve_offload_plan(_WanStylePipeline(), self._dlo_config(2, {"dit"})) + + +class TestValidation: + def test_selected_dit_requires_a_discovered_module(self): + pipeline = nn.Module() + pipeline.text_encoder = _PlainEncoder() + + with pytest.raises(ValueError, match="No DiT/transformer modules found"): + resolve_offload_plan(pipeline, _config({"dit"})) + + def test_model_level_selection_requires_both_swap_sides(self): + pipeline = nn.Module() + pipeline.transformer = _SingleBlockModel(num_blocks=2) + + with pytest.raises(ValueError, match="requires an encoder execution stage"): + resolve_offload_plan(pipeline, _config({"dit"}, strategy=OffloadStrategy.MODEL_LEVEL)) + + def test_selected_text_encoder_requires_a_matching_module(self): + pipeline = nn.Module() + pipeline.transformer = _SingleBlockModel(num_blocks=2) + + with pytest.raises(ValueError, match="No text encoder modules found"): + resolve_offload_plan(pipeline, _config({"text_encoder"})) + + def test_selected_encoder_requires_a_streamable_or_staged_plan(self): + pipeline = nn.Module() + pipeline.transformer = _SingleBlockModel(num_blocks=2) + pipeline.text_encoder = _PlainEncoder() + + with pytest.raises(ValueError, match="Selected text encoder 'text_encoder' requires"): + resolve_offload_plan(pipeline, _config({"text_encoder"})) + + def test_staged_component_requires_the_explicit_lifecycle(self): + class Pipeline(nn.Module): + _offload_plan = OffloadPlan(on_demand_component_paths=frozenset({"text_encoder"})) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + self.text_encoder = _PlainEncoder() + + with pytest.raises(ValueError, match="must implement load_to_device"): + resolve_offload_plan(Pipeline(), _config({"text_encoder"})) + + def test_single_block_dit_is_rejected_for_layer_offload(self): + pipeline = nn.Module() + pipeline.transformer = _SingleBlockModel(num_blocks=1) + + with pytest.raises(ValueError, match="requires at least two streamable"): + resolve_offload_plan(pipeline, _config({"dit"})) + + def test_encoder_allgather_requires_declared_replication(self): + config = _config( + {"text_encoder"}, + strategy=OffloadStrategy.DISTRIBUTED_LAYER_WISE, + dp_size=2, + dlo_transfers={"dit": "rank-local", "text_encoder": "allgather"}, + ) + + with pytest.raises(ValueError, match="not declared replicated"): + resolve_offload_plan(_StagedComponentPipeline(), config) + + def test_two_dits_cannot_own_the_same_blocks(self): + class Pipeline(nn.Module): + _dit_modules: ClassVar[list[str]] = ["transformer", "transformer_2"] + _encoder_modules: ClassVar[list[str]] = [] + _vae_modules: ClassVar[list[str]] = [] + _resident_modules: ClassVar[list[str]] = [] + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + self.transformer_2 = _SingleBlockModel(num_blocks=2) + self.transformer_2.blocks = self.transformer.blocks + + with pytest.raises(ValueError, match="claimed by both"): + resolve_offload_plan(Pipeline(), _config({"dit"})) + + def test_legacy_selection_keeps_unstreamable_components(self): + pipeline = nn.Module() + pipeline.transformer = _SingleBlockModel(num_blocks=1) + pipeline.text_encoder = _PlainEncoder() + + resolved = resolve_offload_plan(pipeline, _config()) + + assert resolved.dits[0].stacks == () + assert resolved.encoders[0].stacks == () + + +class TestPurity: + def _snapshot(self, pipeline: nn.Module): + return { + "devices": [(name, tensor.device) for name, tensor in pipeline.named_parameters()], + "attrs": {name: sorted(vars(module)) for name, module in pipeline.named_modules()}, + } + + def test_resolution_does_not_touch_the_model(self): + pipeline = _StagedComponentPipeline() + before = self._snapshot(pipeline) + + resolve_offload_plan(pipeline, _config({"dit", "text_encoder"})) + + assert self._snapshot(pipeline) == before + assert pipeline.text_encoder.offload_calls == 0 + assert pipeline.text_encoder.to_calls == 0 + assert pipeline.vae.to_calls == 0 + + def test_resolution_is_repeatable(self): + pipeline = _WanStylePipeline() + config = _config({"dit", "text_encoder"}) + + first = resolve_offload_plan(pipeline, config) + second = resolve_offload_plan(pipeline, config) + + assert [component.path for component in first.components] == [component.path for component in second.components] + for left, right in zip(first.components, second.components, strict=True): + assert left.selected == right.selected + assert left.on_demand == right.on_demand + assert [list(stack.blocks) for stack in left.stacks] == [list(stack.blocks) for stack in right.stacks] + + def test_backend_enable_rejects_before_touching_earlier_components(self, patched_offload_runtime): + class Pipeline(nn.Module): + _dit_modules: ClassVar[list[str]] = ["transformer"] + _encoder_modules: ClassVar[list[str]] = ["text_encoder", "text_encoder_2"] + _vae_modules: ClassVar[list[str]] = ["vae"] + _resident_modules: ClassVar[list[str]] = [] + _offload_plan = OffloadPlan( + encoder_component_types={ + "text_encoder": "text_encoder", + "text_encoder_2": "text_encoder", + }, + encoder_block_attrs={"text_encoder": ("encoder.block",)}, + ) + + def __init__(self): + super().__init__() + self.transformer = _SingleBlockModel(num_blocks=2) + self.text_encoder = _PlainEncoder() + self.text_encoder_2 = _PlainEncoder() + self.vae = _StagedVAE() + + pipeline = Pipeline() + backend = LayerWiseOffloadBackend(_config({"text_encoder"}), CPU) + + with pytest.raises(ValueError, match="Selected text encoder 'text_encoder_2' requires"): + backend.enable(pipeline) + + assert not backend.enabled + assert not getattr(pipeline.text_encoder, "_omni_layerwise_enabled", False) + for block in pipeline.text_encoder.encoder.block: + assert getattr(block, "_hook_registry", None) is None + assert pipeline.vae.to_calls == 0 diff --git a/vllm_omni/diffusion/offloader/__init__.py b/vllm_omni/diffusion/offloader/__init__.py index 0df4039eb..42ba45cc9 100644 --- a/vllm_omni/diffusion/offloader/__init__.py +++ b/vllm_omni/diffusion/offloader/__init__.py @@ -20,6 +20,12 @@ from .distributed_layerwise_backend import ( from .layerwise_backend import LayerWiseOffloadBackend from .module_residency import BoundedAllocatorCache, PinnedModuleStager from .offload_plan import OffloadPlan, get_offload_plan +from .plan_resolver import ( + BlockStack, + ResolvedComponent, + ResolvedOffloadPlan, + resolve_offload_plan, +) from .sequential_backend import ( ModelLevelOffloadBackend, apply_sequential_offload, @@ -42,6 +48,10 @@ __all__ = [ "OffloadConfig", "OffloadPlan", "OffloadStrategy", + "BlockStack", + "ResolvedComponent", + "ResolvedOffloadPlan", + "resolve_offload_plan", "SupportsModelCpuOffload", "LayerWiseOffloadBackend", "DistributedLayerwiseOffloadBackend", diff --git a/vllm_omni/diffusion/offloader/component_utils.py b/vllm_omni/diffusion/offloader/component_utils.py index 1000b29d6..cbc63159d 100644 --- a/vllm_omni/diffusion/offloader/component_utils.py +++ b/vllm_omni/diffusion/offloader/component_utils.py @@ -4,7 +4,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Sequence from itertools import chain from operator import attrgetter from typing import TYPE_CHECKING, Any @@ -14,14 +14,12 @@ from torch import nn from torch.distributed.tensor import DTensor from vllm.logger import init_logger -from .block_discovery import get_blocks_from_dit -from .config import DIT_COMPONENT, TEXT_ENCODER_COMPONENT +from .config import TEXT_ENCODER_COMPONENT from .offload_plan import OffloadPlan from .tensor_utils import set_tensor_storage if TYPE_CHECKING: - from .base import OffloadConfig - from .module_collector import PipelineModules + from .plan_resolver import BlockStack, ResolvedComponent, ResolvedOffloadPlan logger = init_logger(__name__) @@ -76,30 +74,25 @@ def get_encoder_block_groups( def iter_streamable_dits( - modules: PipelineModules, - config: OffloadConfig, + resolved: ResolvedOffloadPlan, device: torch.device, - plan: OffloadPlan | None, -) -> Iterator[tuple[str, nn.Module, list[str], list[nn.Module]]]: - """Yield selected DiTs whose block metadata resolves successfully.""" - if not config.offloads(DIT_COMPONENT): - return - for name, module in zip(modules.dit_names, modules.dits): - logger.info("Applying hooks on %s (%s)", name, module.__class__.__name__) - planned_attrs = None if plan is None else plan.block_attrs.get(name) - block_attrs, blocks = get_blocks_from_dit(module, planned_attrs) - if blocks: - yield name, module, block_attrs, blocks +) -> Iterator[tuple[ResolvedComponent, BlockStack]]: + """Yield selected DiTs that resolved to a streamable block stack.""" + for component in resolved.dits: + if not component.selected: continue - if config.components is not None: - raise ValueError(f"Selected DiT {name!r} has no streamable layerwise-offload blocks") - logger.warning("Target layers (blocks) not found. Skipping offloading on %s (%s)", name, type(module).__name__) - module.to(device) + logger.info("Applying hooks on %s (%s)", component.path, component.module.__class__.__name__) + if not component.stacks: + # The resolver already warned; a legacy selection keeps the + # unstreamable DiT resident instead of failing the run. + component.module.to(device) + continue + yield component, component.stacks[0] def move_non_block_state_to_device( module: nn.Module, - block_groups: list[nn.ModuleList], + block_groups: Sequence[Sequence[nn.Module]], device: torch.device, ) -> None: """Keep component state outside streamed block lists resident on device.""" @@ -120,7 +113,7 @@ def move_non_block_state_to_device( def set_encoder_layerwise_state( module: nn.Module, hooks: list[Any], - block_groups: list[nn.ModuleList], + block_groups: Sequence[Sequence[nn.Module]], ) -> None: """Publish the backend-neutral state used by encoder stage lifecycles.""" module._omni_layerwise_hooks = hooks @@ -163,59 +156,38 @@ def prepare_component( def prepare_pipeline_components( - modules: PipelineModules, - config: OffloadConfig, - plan: OffloadPlan | None, + resolved: ResolvedOffloadPlan, *, device: torch.device, staged_components: list[nn.Module], - enable_encoder_blocks: Callable[[nn.Module, str, OffloadPlan | None, bool], bool], + enable_encoder_blocks: Callable[[ResolvedComponent], bool], ) -> None: """Apply the shared encoder/VAE/resident placement policy.""" - if config.components is not None and config.offloads(TEXT_ENCODER_COMPONENT): - selected_encoder_names = [name for name in modules.encoder_names if config.offloads_encoder(name, plan)] - if not selected_encoder_names: - raise ValueError("No text encoder modules found for selected text_encoder offload") - - if plan is not None: - for encoder, name in zip(modules.encoders, modules.encoder_names): - if config.should_offload_encoder(name, plan) and name in plan.on_demand_component_paths: - validate_on_demand_component(encoder, name) - - for encoder, name in zip(modules.encoders, modules.encoder_names): - selected = config.should_offload_encoder(name, plan) - stage_on_demand = bool(selected and plan is not None and name in plan.on_demand_component_paths) - blockwise = selected and enable_encoder_blocks(encoder, name, plan, stage_on_demand) - if stage_on_demand and not blockwise and config.uses_allgather(TEXT_ENCODER_COMPONENT): - raise ValueError( - f"Text encoder {name!r} cannot use AllGather without a model-declared streamable block plan" - ) - if selected and config.components is not None and not (blockwise or stage_on_demand): - raise ValueError(f"Selected text encoder {name!r} requires a model-declared streamable or on-demand plan") + for component in resolved.encoders: + blockwise = bool(component.stacks) and enable_encoder_blocks(component) prepare_component( - encoder, - name, + component.module, + component.path, device=device, - stage_on_demand=stage_on_demand, + stage_on_demand=component.on_demand, blockwise=blockwise, staged_components=staged_components, ) - for vae, name in zip(modules.vaes, modules.vae_names): - legacy_staged = config.components is None and plan is not None and name in plan.on_demand_component_paths + for component in resolved.vaes: prepare_component( - vae, - name, + component.module, + component.path, device=device, - stage_on_demand=legacy_staged, + stage_on_demand=component.on_demand, blockwise=False, staged_components=staged_components, ) - for name, module in zip(modules.resident_names, modules.resident_modules): - module.to(device) - logger.debug("Moved resident module %s to %s", name, device) + for component in resolved.residents: + component.module.to(device) + logger.debug("Moved resident module %s to %s", component.path, device) - if not config.offloads(DIT_COMPONENT): - for dit in modules.dits: - dit.to(device) + for component in resolved.dits: + if not component.selected: + component.module.to(device) diff --git a/vllm_omni/diffusion/offloader/distributed_layerwise_backend.py b/vllm_omni/diffusion/offloader/distributed_layerwise_backend.py index e626d87a5..299ec3d3a 100644 --- a/vllm_omni/diffusion/offloader/distributed_layerwise_backend.py +++ b/vllm_omni/diffusion/offloader/distributed_layerwise_backend.py @@ -21,6 +21,7 @@ from __future__ import annotations import threading import time import weakref +from collections.abc import Sequence from itertools import chain from typing import Any @@ -39,7 +40,6 @@ from vllm_omni.platforms import current_omni_platform from .base import OffloadBackend, OffloadConfig, run_cleanup_steps from .component_utils import ( clear_encoder_layerwise_state, - get_encoder_block_groups, iter_streamable_dits, move_non_block_state_to_device, prepare_component, @@ -53,11 +53,8 @@ from .host_registration import ( HostRegistrationError, register_host_mappings, ) -from .module_collector import ModuleDiscovery -from .offload_plan import ( - OffloadPlan, - get_offload_plan, -) +from .offload_plan import OffloadPlan +from .plan_resolver import ResolvedComponent, resolve_offload_plan from .tensor_utils import ( clear_block_storage, clear_tensor_storage, @@ -1437,7 +1434,7 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): def _install_hook_group( self, - blocks: list[nn.Module] | nn.ModuleList, + blocks: Sequence[nn.Module] | nn.ModuleList, component: str, *, use_dit_mmap: bool = False, @@ -1483,48 +1480,27 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): hooks[1]._is_group_first = True return hooks - def _try_layerwise_offload_encoder( - self, - module: nn.Module, - name: str, - plan: OffloadPlan | None, - stage_on_demand: bool = False, - ) -> bool: - """Apply DLO hooks to a plan-declared text encoder. + def _try_layerwise_offload_encoder(self, component: ResolvedComponent) -> bool: + """Apply DLO hooks to a text encoder's resolved block stacks. Rank-local transfer is always safe because it preserves the tensors produced by the model loader. Multi-rank AllGather is enabled only when the model declares that those tensors are replicated across the - selected DLO group; encoder TP groups contain different shards and - are deliberately not used for this transport. + selected DLO group; the resolver rejects every other layout before + this method installs anything. """ - if plan is None: - return False - - group_size = self._component_transport(TEXT_ENCODER_COMPONENT)[1] - if group_size > 1 and name not in plan.encoder_dlo_weight_replication: - raise ValueError( - f"Text encoder {name!r} cannot use DLO AllGather across the DiT offload group: " - "its loader-produced weights are not declared replicated across that group. " - "Set layer_options.text_encoder.weight_transfer='rank-local' in diffusion_offload_config " - "for encoder-TP or rank-specific layouts." - ) - block_groups = get_encoder_block_groups( - module, - name, - plan, - strict=self.config.components is not None or group_size > 1, - ) + block_groups = [stack.blocks for stack in component.stacks] if not block_groups: return False + module = component.module encoder_hooks: list[DistributedLayerwiseOffloadHook] = [] for blocks in block_groups: encoder_hooks.extend(self._install_hook_group(blocks, TEXT_ENCODER_COMPONENT)) # Track the module before placement, which may fail, so outer rollback # also removes its partially-installed block hooks and marker state. self._encoder_modules.append(module) - if not stage_on_demand: + if not component.on_demand: move_non_block_state_to_device(module, block_groups, self.device) set_encoder_layerwise_state( module, @@ -1534,10 +1510,10 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): logger.info( "Enabled %s DLO transfer for text encoder %s (%d blocks across %d stacks, group_size=%d)", self.config.transfer_for(TEXT_ENCODER_COMPONENT).value, - name, + component.path, sum(len(blocks) for blocks in block_groups), len(block_groups), - group_size, + self._component_transport(TEXT_ENCODER_COMPONENT)[1], ) return True @@ -1700,31 +1676,15 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): if self.dp_group is None and self._has_multirank_allgather(): self._init_dp_group() - modules = ModuleDiscovery.discover(pipeline) - if not modules.dits and self.config.offloads(DIT_COMPONENT): + # One resolver call validates the whole topology; an explicit selection + # has already failed by the time any of the checks below run. + resolved = resolve_offload_plan(pipeline, self.config) + if not resolved.dits and self.config.offloads(DIT_COMPONENT): if self.host_weight_plan is not None: raise RuntimeError( "DLO received a loader-owned host-weight plan, but no DiT modules were discovered to consume it" ) - message = "No DiT/transformer modules found for selected DiT offload" - if self.config.components is not None: - raise ValueError(message) - logger.warning(message) - - # Retrieve optional declarative OffloadPlan from the pipeline. - # When present, replaces heuristic block discovery. - plan = get_offload_plan(pipeline) - - if self.config.dlo_resident_layers: - resident_paths = frozenset() if plan is None else plan.resident_dit_paths - if not resident_paths.intersection(modules.dit_names): - message = ( - f"resident_layers={self.config.dlo_resident_layers} was requested, but this model declares " - "no matching resident_dit_paths" - ) - if self.config.components is not None: - raise ValueError(message) - logger.warning("%s; all blocks will be streamed.", message) + logger.warning("No DiT/transformer modules found for selected DiT offload") # Storage selection belongs to the loader. DLO consumes the exact # prevalidated plan that caused the loader to skip materialization; @@ -1755,7 +1715,7 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): elif host_weight_plan.backing_kind == "checkpoint_mmap": self._load_weights_via_mmap( pipeline, - modules, + resolved.modules, host_weight_plan, ) else: @@ -1768,10 +1728,10 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): else: remaining_meta = [ name - for dit_name, dit_module in zip(modules.dit_names, modules.dits) + for component in resolved.dits for name, tensor in chain( - dit_module.named_parameters(), - dit_module.named_buffers(), + component.module.named_parameters(), + component.module.named_buffers(), ) if getattr(tensor, "is_meta", False) ] @@ -1784,66 +1744,46 @@ class DistributedLayerwiseOffloadBackend(OffloadBackend): # Apply each selected encoder transfer while keeping explicit VAEs and # unselected components resident. prepare_pipeline_components( - modules, - self.config, - plan, + resolved, device=self.device, staged_components=self._staged_components, enable_encoder_blocks=self._try_layerwise_offload_encoder, ) if self.config.offloads(DIT_COMPONENT): - logger.info("Applying distributed layer-wise offloading on %s", modules.dit_names) + logger.info( + "Applying distributed layer-wise offloading on %s", + [component.path for component in resolved.dits], + ) # Collect all DiT module objects to detect submodules that are # already handled as a separate DiT module (avoids duplicate hooks). - all_dit_modules = set(id(m) for m in modules.dits) + all_dit_modules = set(id(component.module) for component in resolved.dits) # Apply hooks for each DiT module - for dit_name, dit_module, blocks_attr_names, blocks in iter_streamable_dits( - modules, self.config, self.device, plan - ): - resident_count = 0 - if plan is not None and dit_name in plan.resident_dit_paths: - resident_count = min(self.config.dlo_resident_layers, len(blocks)) - if resident_count: - resident_blocks = blocks[:resident_count] - self._resident_blocks.extend(resident_blocks) - blocks = blocks[resident_count:] + for component, stack in iter_streamable_dits(resolved, self.device): + streaming = list(stack.streaming) + if stack.resident_head: + self._resident_blocks.extend(stack.resident) logger.info( "Keeping %d leading blocks resident on %s; streaming %d tail blocks", - resident_count, - dit_name, - len(blocks), + stack.resident_head, + component.path, + len(streaming), ) self._prepare_dit_non_block_modules( - dit_module, - blocks_attr_names, + component.module, + list(stack.attrs), all_dit_modules, - plan, + resolved.declaration, ) - num_blocks = len(blocks) - if num_blocks == 0: - logger.info("All blocks for %s are resident; no streaming hooks required", dit_name) - continue - if num_blocks <= 1: - if self.config.components is not None: - raise ValueError( - f"Selected DiT {dit_name!r} leaves only one streaming block after " - f"resident_layers={resident_count}; choose a resident count that " - "leaves zero or at least two streaming blocks" - ) - logger.warning( - "#Streaming target layers (blocks) <= 1. Keeping the final block resident on %s (%s)", - dit_name, - dit_module.__class__.__name__, - ) - self._resident_blocks.extend(blocks) + if not streaming: + logger.info("All blocks for %s are resident; no streaming hooks required", component.path) continue - self._install_hook_group(blocks, DIT_COMPONENT, use_dit_mmap=True) + self._install_hook_group(streaming, DIT_COMPONENT, use_dit_mmap=True) if self._resident_blocks: self._resident_layer_group = PinnedResidentLayerGroup( self._resident_blocks, diff --git a/vllm_omni/diffusion/offloader/layerwise_backend.py b/vllm_omni/diffusion/offloader/layerwise_backend.py index 9b63f9162..a1e594f8b 100644 --- a/vllm_omni/diffusion/offloader/layerwise_backend.py +++ b/vllm_omni/diffusion/offloader/layerwise_backend.py @@ -1,7 +1,8 @@ # 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 +from collections.abc import Sequence from itertools import chain from typing import Any @@ -20,15 +21,13 @@ from .block_discovery import ( ) from .component_utils import ( clear_encoder_layerwise_state, - get_encoder_block_groups, iter_streamable_dits, move_non_block_state_to_device, prepare_pipeline_components, set_encoder_layerwise_state, ) from .config import DIT_COMPONENT -from .module_collector import ModuleDiscovery -from .offload_plan import OffloadPlan, get_offload_plan +from .plan_resolver import ResolvedComponent, resolve_offload_plan from .tensor_utils import ( clear_block_storage, clear_tensor_storage, @@ -279,7 +278,7 @@ def remove_block_hook(module: nn.Module) -> None: def _install_layerwise_hook_group( - blocks: list[nn.Module] | nn.ModuleList, + blocks: Sequence[nn.Module] | nn.ModuleList, device: torch.device, stream: Any, pin_memory: bool, @@ -327,36 +326,29 @@ def _install_layerwise_hook_group( def enable_plan_encoder_layerwise_offload( - module: nn.Module, - name: str, - plan: OffloadPlan | None, + component: ResolvedComponent, *, device: torch.device, stream: current_omni_platform.Stream, pin_memory: bool, - stage_on_demand: bool = False, - strict: bool = False, ) -> bool: - """Apply rank-local layerwise hooks to plan-declared encoder stacks.""" + """Apply rank-local layerwise hooks to the encoder's resolved stacks.""" + module = component.module if getattr(module, "_omni_layerwise_enabled", False): return True - hooks: list[LayerwiseOffloadHook] = [] - hooked_blocks: list[nn.Module] = [] - block_groups = get_encoder_block_groups( - module, - name, - plan, - strict=strict, - ) + block_groups = [stack.blocks for stack in component.stacks] if not block_groups: return False + + hooks: list[LayerwiseOffloadHook] = [] + hooked_blocks: list[nn.Module] = [] try: for blocks in block_groups: group_hooks = _install_layerwise_hook_group(blocks, device, stream, pin_memory) hooks.extend(group_hooks) hooked_blocks.extend(blocks) - if not stage_on_demand: + if not component.on_demand: move_non_block_state_to_device(module, block_groups, device) except BaseException: run_cleanup_steps( @@ -376,7 +368,7 @@ def enable_plan_encoder_layerwise_offload( ) logger.info( "Enabled rank-local layerwise offload for encoder %s (%d blocks across %d stacks)", - name, + component.path, sum(len(blocks) for blocks in block_groups), len(block_groups), ) @@ -440,41 +432,27 @@ class LayerWiseOffloadBackend(OffloadBackend): logger.warning("LayerWiseOffloadBackend already enabled") return - modules = ModuleDiscovery.discover(pipeline) - plan = get_offload_plan(pipeline) - if not modules.dits and self.config.offloads(DIT_COMPONENT): - message = "No DiT/transformer modules found for selected DiT layerwise offload" - if self.config.components is not None: - raise ValueError(message) - logger.warning(message) + resolved = resolve_offload_plan(pipeline, self.config) + if not resolved.dits and self.config.offloads(DIT_COMPONENT): + # An explicit selection already failed while resolving. + logger.warning("No DiT/transformer modules found for selected DiT layerwise offload") return - def enable_encoder_blocks( - module: nn.Module, - name: str, - component_plan: OffloadPlan | None, - stage_on_demand: bool, - ) -> bool: + def enable_encoder_blocks(component: ResolvedComponent) -> bool: enabled = enable_plan_encoder_layerwise_offload( - module, - name, - component_plan, + component, device=self.device, stream=self.copy_stream, pin_memory=self.config.pin_cpu_memory, - stage_on_demand=stage_on_demand, - strict=self.config.components is not None, ) if enabled: # Record each successful installation immediately so a later # component failure can remove these hooks transactionally. - self._encoder_modules.append(module) + self._encoder_modules.append(component.module) return enabled prepare_pipeline_components( - modules, - self.config, - plan, + resolved, device=self.device, staged_components=self._staged_components, enable_encoder_blocks=enable_encoder_blocks, @@ -489,30 +467,17 @@ class LayerWiseOffloadBackend(OffloadBackend): ) return - logger.info("Applying layer-wise offloading on %s", modules.dit_names) + logger.info("Applying layer-wise offloading on %s", [component.path for component in resolved.dits]) # Apply block-wise offloading hook for each of the blocks in DiT model(s) # Note that there might exist multiple DiT models in specific pipelines - for dit_name, dit_module, blocks_attr_names, blocks in iter_streamable_dits( - modules, self.config, self.device, plan - ): - num_blocks = len(blocks) - if num_blocks <= 1: - if self.config.components is not None: - raise ValueError( - f"Selected DiT {dit_name!r} requires at least two streamable layerwise-offload blocks" - ) - logger.warning( - "#Target layers (blocks) <= 1. Skipping offloading on %s (%s)", - dit_name, - dit_module.__class__.__name__, - ) - dit_module.to(self.device) - continue + for component, stack in iter_streamable_dits(resolved, self.device): + dit_module = component.module + blocks = list(stack.blocks) # Move non-block modules to GPU (they stay resident) for name, m in dit_module.named_children(): - if name not in blocks_attr_names: + if name not in stack.attrs: m.to(self.device) logger.debug(f"Moved {name} to device {self.device}") else: @@ -540,7 +505,7 @@ class LayerWiseOffloadBackend(OffloadBackend): # zero once; later denoising iterations prefetch it from the ring. block_hooks[0].prefetch_layer(non_blocking=False) - logger.info(f"Layer-wise offloading enabled on {num_blocks} layers (blocks)") + logger.info(f"Layer-wise offloading enabled on {len(blocks)} layers (blocks)") # Track hooked blocks for cleanup self._blocks.append(blocks) diff --git a/vllm_omni/diffusion/offloader/offload_plan.py b/vllm_omni/diffusion/offloader/offload_plan.py index 4aa37ae8f..5cc0c24c7 100644 --- a/vllm_omni/diffusion/offloader/offload_plan.py +++ b/vllm_omni/diffusion/offloader/offload_plan.py @@ -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 """Declarative model capabilities for component layerwise offload. Models declare an ``_offload_plan`` class attribute on the pipeline. Both the @@ -22,9 +22,11 @@ class OffloadPlan: pipeline class. When present, both layerwise backends use it instead of model-specific backend branches, making new integrations data-driven. - If not declared, the offloader falls back to: - 1. ``_layerwise_offload_blocks_attrs`` on each DiT module class. - 2. Heuristic search for ``layers`` / ``blocks`` / ``h`` attributes. + If not declared, the offloader falls back to + ``_layerwise_offload_blocks_attrs`` on each DiT module class. + + :func:`~vllm_omni.diffusion.offloader.plan_resolver.resolve_offload_plan` + is the only consumer; backends read the resolved artifact it returns. Attributes: block_attrs: Maps DiT path → tuple of block-list attribute names. diff --git a/vllm_omni/diffusion/offloader/plan_resolver.py b/vllm_omni/diffusion/offloader/plan_resolver.py new file mode 100644 index 000000000..b2e951b4e --- /dev/null +++ b/vllm_omni/diffusion/offloader/plan_resolver.py @@ -0,0 +1,330 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""One topology resolver shared by every diffusion offload backend. + +Model topology is declared through several mechanisms that grew independently: +:class:`~vllm_omni.diffusion.models.interface.SupportsComponentDiscovery` class +variables, the pipeline-level :class:`OffloadPlan`, per-DiT +``_layerwise_offload_blocks_attrs``, and a leaf-name rule for encoders. This +module runs them in one defined order and returns one backend-neutral artifact. + +The resolver is pure: it moves no tensor, installs no hook, writes no module +attribute, and touches no process group. Everything it can reject is rejected +here, so plan-dependent failures happen before a backend mutates the model. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import cache +from typing import TYPE_CHECKING + +from torch import nn +from vllm.logger import init_logger + +from vllm_omni.diffusion.models.interface import SupportsComponentDiscovery + +from .block_discovery import get_blocks_from_dit +from .component_utils import get_encoder_block_groups, validate_on_demand_component +from .config import DIT_COMPONENT, TEXT_ENCODER_COMPONENT, OffloadStrategy +from .module_collector import ModuleDiscovery, PipelineModules +from .offload_plan import OffloadPlan, get_offload_plan + +if TYPE_CHECKING: + from .base import OffloadConfig + +logger = init_logger(__name__) + + +@dataclass(frozen=True) +class BlockStack: + """One ring of repeated blocks that a backend streams together. + + ``attrs`` names the owner attributes that contributed blocks; backends use + it to skip those children when they place the non-streamed remainder. It is + filled for DiT stacks only — encoder stacks are hooked as whole stacks and + their remainder is placed by tensor identity. + """ + + blocks: tuple[nn.Module, ...] + attrs: tuple[str, ...] = () + resident_head: int = 0 + + @property + def resident(self) -> tuple[nn.Module, ...]: + """Leading blocks that stay on the device instead of streaming.""" + return self.blocks[: self.resident_head] + + @property + def streaming(self) -> tuple[nn.Module, ...]: + """Blocks a hook ring transfers on demand.""" + return self.blocks[self.resident_head :] + + +@dataclass(frozen=True) +class ResolvedComponent: + """One pipeline component with its resolved, validated offload topology. + + ``selected`` means the active selector covers this component. ``on_demand`` + means the pipeline owns its residency through ``load_to_device`` / + ``offload_to_cpu``; a VAE staged by a legacy model plan is ``on_demand`` + without being selectable through the public component grammar. + """ + + path: str + module: nn.Module + selected: bool + stacks: tuple[BlockStack, ...] = () + on_demand: bool = False + + +@dataclass(frozen=True) +class ResolvedOffloadPlan: + """Backend-neutral topology for one pipeline under one offload config.""" + + dits: tuple[ResolvedComponent, ...] + encoders: tuple[ResolvedComponent, ...] + vaes: tuple[ResolvedComponent, ...] + residents: tuple[ResolvedComponent, ...] + # Raw discovery output and the model declaration remain reachable for the + # loader-owned mmap path and for DLO's nested-submodule staging, which + # still interpret topology themselves. + modules: PipelineModules + declaration: OffloadPlan | None = None + + @property + def components(self) -> tuple[ResolvedComponent, ...]: + return self.dits + self.encoders + self.vaes + self.residents + + +@cache +def _warn_legacy_discovery(pipeline_class: str) -> None: + """Warn once per pipeline class that component discovery guessed. + + The key is the fully qualified class name so two same-named pipeline + classes in different modules each get their own warning. + """ + logger.warning( + "%s does not declare SupportsComponentDiscovery; offload components were " + "discovered by scanning well-known attribute names. Declare _dit_modules, " + "_encoder_modules, and _vae_modules; the attribute scan is deprecated.", + pipeline_class, + ) + + +def _resolve_dit_stacks( + module: nn.Module, + path: str, + declaration: OffloadPlan | None, + config: OffloadConfig, + *, + explicit: bool, +) -> tuple[BlockStack, ...]: + """Resolve one DiT's streamed blocks and its device-resident head.""" + planned_attrs = None if declaration is None else declaration.block_attrs.get(path) + attrs, blocks = get_blocks_from_dit(module, planned_attrs) + if not blocks: + if explicit: + raise ValueError(f"Selected DiT {path!r} has no streamable layerwise-offload blocks") + logger.warning( + "Target layers (blocks) not found. Skipping offloading on %s (%s)", + path, + type(module).__name__, + ) + return () + + distributed = config.strategy is OffloadStrategy.DISTRIBUTED_LAYER_WISE + resident_head = 0 + if ( + distributed + and config.dlo_resident_layers + and declaration is not None + and path in declaration.resident_dit_paths + ): + resident_head = min(config.dlo_resident_layers, len(blocks)) + + if len(blocks) - resident_head == 1: + if explicit and distributed: + raise ValueError( + f"Selected DiT {path!r} leaves only one streaming block after " + f"resident_layers={resident_head}; choose a resident count that " + "leaves zero or at least two streaming blocks" + ) + if explicit: + raise ValueError(f"Selected DiT {path!r} requires at least two streamable layerwise-offload blocks") + if distributed: + # One streamed block cannot form a prefetch ring; keeping it + # resident preserves the model instead of skipping placement. + logger.warning( + "#Streaming target layers (blocks) <= 1. Keeping the final block resident on %s (%s)", + path, + type(module).__name__, + ) + resident_head = len(blocks) + else: + logger.warning( + "#Target layers (blocks) <= 1. Skipping offloading on %s (%s)", + path, + type(module).__name__, + ) + return () + + return (BlockStack(blocks=tuple(blocks), attrs=tuple(attrs), resident_head=resident_head),) + + +def _resolve_encoder_stacks( + module: nn.Module, + path: str, + declaration: OffloadPlan | None, + config: OffloadConfig, + *, + explicit: bool, +) -> tuple[BlockStack, ...]: + """Resolve one encoder's streamable stacks and check transfer safety.""" + if declaration is None: + return () + + group_size = 1 + if config.strategy is OffloadStrategy.DISTRIBUTED_LAYER_WISE and config.uses_allgather(TEXT_ENCODER_COMPONENT): + group_size = config.dp_size + if group_size > 1 and path not in declaration.encoder_dlo_weight_replication: + # Every rank must reject an unsafe group, including ranks whose local + # encoder is an unloaded stub, so the check precedes block discovery. + raise ValueError( + f"Text encoder {path!r} cannot use DLO AllGather across the DiT offload group: " + "its loader-produced weights are not declared replicated across that group. " + "Set layer_options.text_encoder.weight_transfer='rank-local' in diffusion_offload_config " + "for encoder-TP or rank-specific layouts." + ) + + groups = get_encoder_block_groups(module, path, declaration, strict=explicit or group_size > 1) + return tuple(BlockStack(blocks=tuple(blocks)) for blocks in groups) + + +def resolve_offload_plan(pipeline: nn.Module, config: OffloadConfig) -> ResolvedOffloadPlan: + """Resolve and validate one pipeline's offload topology. + + Raises ``ValueError`` for every topology that the requested configuration + cannot serve, before the caller places a module or installs a hook. + """ + modules = ModuleDiscovery.discover(pipeline) + declaration = get_offload_plan(pipeline) + if not isinstance(pipeline, SupportsComponentDiscovery) and (modules.dits or modules.encoders or modules.vaes): + pipeline_class = type(pipeline) + _warn_legacy_discovery(f"{pipeline_class.__module__}.{pipeline_class.__qualname__}") + + explicit = config.components is not None + layerwise = config.strategy in (OffloadStrategy.LAYER_WISE, OffloadStrategy.DISTRIBUTED_LAYER_WISE) + dit_selected = config.offloads(DIT_COMPONENT) + + if explicit: + if config.strategy is OffloadStrategy.MODEL_LEVEL: + # Model-level offload swaps the DiT against an encoder stage, so + # both sides must exist regardless of which side was selected. + if not modules.dits: + raise ValueError("Component-selective model offload requires a DiT/transformer module") + if not modules.encoders: + raise ValueError("Component-selective model offload requires an encoder execution stage") + elif dit_selected and not modules.dits: + raise ValueError("No DiT/transformer modules found for selected DiT offload") + if config.offloads(TEXT_ENCODER_COMPONENT) and not any( + config.offloads_encoder(path, declaration) for path in modules.encoder_names + ): + raise ValueError("No text encoder modules found for selected text_encoder offload") + + if config.strategy is OffloadStrategy.DISTRIBUTED_LAYER_WISE and config.dlo_resident_layers: + resident_paths = frozenset() if declaration is None else declaration.resident_dit_paths + if not resident_paths.intersection(modules.dit_names): + message = ( + f"resident_layers={config.dlo_resident_layers} was requested, but this model declares " + "no matching resident_dit_paths" + ) + if explicit: + raise ValueError(message) + logger.warning("%s; all blocks will be streamed.", message) + + dits: list[ResolvedComponent] = [] + encoders: list[ResolvedComponent] = [] + vaes: list[ResolvedComponent] = [] + residents: list[ResolvedComponent] = [] + + for path, module in zip(modules.dit_names, modules.dits): + stacks: tuple[BlockStack, ...] = () + if dit_selected and layerwise: + stacks = _resolve_dit_stacks(module, path, declaration, config, explicit=explicit) + dits.append(ResolvedComponent(path=path, module=module, selected=dit_selected, stacks=stacks)) + + for path, module in zip(modules.encoder_names, modules.encoders): + selected = config.should_offload_encoder(path, declaration) + # Staged residency belongs to the layer backends; model-level offload + # swaps whole modules through its own hooks. + on_demand = bool( + selected and layerwise and declaration is not None and path in declaration.on_demand_component_paths + ) + if on_demand: + validate_on_demand_component(module, path) + stacks = () + if selected and layerwise: + stacks = _resolve_encoder_stacks(module, path, declaration, config, explicit=explicit) + if on_demand and not stacks and config.uses_allgather(TEXT_ENCODER_COMPONENT): + raise ValueError( + f"Text encoder {path!r} cannot use AllGather without a model-declared streamable block plan" + ) + if explicit and not (stacks or on_demand): + raise ValueError( + f"Selected text encoder {path!r} requires a model-declared streamable or on-demand plan" + ) + encoders.append( + ResolvedComponent( + path=path, + module=module, + selected=selected, + stacks=stacks, + on_demand=on_demand, + ) + ) + + for path, module in zip(modules.vae_names, modules.vaes): + # VAEs are not part of the public selector. A model plan may still own + # their residency, which the compatibility topology preserves. + legacy_staged = ( + layerwise and not explicit and declaration is not None and path in declaration.on_demand_component_paths + ) + if legacy_staged: + validate_on_demand_component(module, path) + vaes.append(ResolvedComponent(path=path, module=module, selected=False, on_demand=legacy_staged)) + + for path, module in zip(modules.resident_names, modules.resident_modules): + residents.append(ResolvedComponent(path=path, module=module, selected=False)) + + resolved = ResolvedOffloadPlan( + dits=tuple(dits), + encoders=tuple(encoders), + vaes=tuple(vaes), + residents=tuple(residents), + modules=modules, + declaration=declaration, + ) + _validate_unique_ownership(resolved) + return resolved + + +def _validate_unique_ownership(resolved: ResolvedOffloadPlan) -> None: + """Reject topologies where two components would hook the same blocks.""" + owner_by_block: dict[int, str] = {} + for component in resolved.components: + for block in (block for stack in component.stacks for block in stack.blocks): + owner = owner_by_block.setdefault(id(block), component.path) + if owner != component.path: + raise ValueError( + f"Offload block is claimed by both {owner!r} and {component.path!r}; " + "declare one owner for each block list" + ) + + +__all__ = [ + "BlockStack", + "ResolvedComponent", + "ResolvedOffloadPlan", + "resolve_offload_plan", +] diff --git a/vllm_omni/diffusion/offloader/sequential_backend.py b/vllm_omni/diffusion/offloader/sequential_backend.py index b6b596070..1187c8cee 100644 --- a/vllm_omni/diffusion/offloader/sequential_backend.py +++ b/vllm_omni/diffusion/offloader/sequential_backend.py @@ -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 collections.abc import Collection, Iterator from contextlib import contextmanager @@ -13,9 +13,8 @@ from vllm_omni.diffusion.hooks import HookRegistry, ModelHook from vllm_omni.platforms import current_omni_platform from .base import OffloadBackend, OffloadConfig, SupportsModelCpuOffload -from .config import DIT_COMPONENT, TEXT_ENCODER_COMPONENT -from .module_collector import ModuleDiscovery -from .offload_plan import get_offload_plan +from .config import DIT_COMPONENT +from .plan_resolver import resolve_offload_plan logger = init_logger(__name__) @@ -329,59 +328,44 @@ class ModelLevelOffloadBackend(OffloadBackend): ) return - modules = ModuleDiscovery.discover(pipeline) - plan = get_offload_plan(pipeline) - selected_encoders = [ - encoder - for encoder, name in zip(modules.encoders, modules.encoder_names) - if self.config.should_offload_encoder(name, plan) - ] - if self.config.components is not None: - if not modules.dits: - raise ValueError("Component-selective model offload requires a DiT/transformer module") - if not modules.encoders: - raise ValueError("Component-selective model offload requires an encoder execution stage") - if self.config.offloads(TEXT_ENCODER_COMPONENT) and not selected_encoders: - raise ValueError("No text encoder modules found for selected text_encoder module offload") + resolved = resolve_offload_plan(pipeline, self.config) + dits = [component.module for component in resolved.dits] + encoders = [component.module for component in resolved.encoders] + vaes = [component.module for component in resolved.vaes] + residents = [component.module for component in resolved.residents] + selected_encoders = [component.module for component in resolved.encoders if component.selected] - all_modules = [ - *modules.dits, - *modules.encoders, - *modules.vaes, - *modules.resident_modules, - ] + all_modules = [*dits, *encoders, *vaes, *residents] initial_devices = _capture_tensor_devices(all_modules) try: - for encoder in modules.encoders: + for encoder in encoders: encoder.to(self.device) - for vae in modules.vaes: + for vae in vaes: vae.to(self.device, non_blocking=True) - for resident in modules.resident_modules: + for resident in residents: resident.to(self.device) - if not modules.dits: + if not dits: logger.warning("No DiT/transformer modules found, skipping model-level offloading") return - if not modules.encoders: - for dit in modules.dits: + if not encoders: + for dit in dits: dit.to(self.device) logger.warning("No encoder modules found, skipping model-level offloading") return apply_sequential_offload( - dit_modules=modules.dits, - encoder_modules=modules.encoders, + dit_modules=dits, + encoder_modules=encoders, device=self.device, pin_memory=self.config.pin_cpu_memory, use_hsdp=self.config.use_hsdp, - offload_dit_modules=( - modules.dits if self.config.components is None or self.config.offloads(DIT_COMPONENT) else () - ), + offload_dit_modules=(dits if self.config.offloads(DIT_COMPONENT) else ()), offload_encoder_modules=selected_encoders, ) except BaseException: try: - remove_sequential_offload([*modules.dits, *modules.encoders]) + remove_sequential_offload([*dits, *encoders]) except BaseException: logger.exception("Failed to remove every model-level hook during rollback") try: @@ -391,15 +375,19 @@ class ModelLevelOffloadBackend(OffloadBackend): raise # Track modules for cleanup - self._offload_modules = [*modules.dits, *modules.encoders] + self._offload_modules = [*dits, *encoders] self.enabled = True logger.info( "Model-level offloading enabled: %s <-> %s (mutual exclusion)%s", - ", ".join(modules.dit_names), - ", ".join(modules.encoder_names), - f"; resident on GPU: {', '.join(modules.resident_names)}" if modules.resident_names else "", + ", ".join(component.path for component in resolved.dits), + ", ".join(component.path for component in resolved.encoders), + ( + f"; resident on GPU: {', '.join(component.path for component in resolved.residents)}" + if resolved.residents + else "" + ), ) def disable(self) -> None: From f67cc52f7449d5f5dbb9d7790c7a049fa6aa2ba1 Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Wed, 9 Sep 2026 09:17:24 +0800 Subject: [PATCH 51/90] [Doc] Add AI usage policy for contributions (#7305) Signed-off-by: hsliuustc0106 --- CONTRIBUTING.md | 2 ++ docs/contributing/README.md | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36cefcf0d..542db3fe9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,8 @@ You may find information about contributing to vLLM-Omni on [Contributing](https://vllm-omni.readthedocs.io/en/latest/contributing/). +AI-assisted contributions must follow the [AI Usage Policy](docs/contributing/README.md#ai-usage-policy), including disclosure, human review, and validation requirements. + Local `pre-commit` gates (SPDX, forbidden imports, `torch.cuda`, shellcheck, mypy, test marks, TTS adapter ratchet, markdownlint, Buildkite schema, and how to extend allowlists) are documented in the diff --git a/docs/contributing/README.md b/docs/contributing/README.md index dbfc917bf..a45854b14 100644 --- a/docs/contributing/README.md +++ b/docs/contributing/README.md @@ -262,6 +262,28 @@ or serving, explain the required L2/L3 environment and provide the exact command Repository skills guide the agent, but they do not replace contributor judgment, the accepted issue or RFC, required test evidence, or maintainer review. Review all generated changes before submitting them. +## AI Usage Policy + +AI tools are welcome in vLLM-Omni contributions. Contributors, including maintainers, remain responsible for everything they submit. These requirements apply to pull requests, issues, discussions, and review comments: + +- **Disclose AI assistance.** Name the tools used and describe their role in the submission, including assistance with code, tests, documentation, or writing. +- **Understand your changes.** Review every generated change and be able to explain its behavior, design choices, and interaction with vLLM-Omni without relying on AI to answer for you. +- **Validate before submitting.** Run relevant checks and report actual results. For model, kernel, or performance changes, include the hardware, model, configuration, and commands needed to reproduce accuracy or benchmark evidence. Clearly identify checks you could not run; generated claims are not test results. +- **Keep communication useful.** Verify technical claims, search for existing reports, and edit AI-assisted text for accuracy and brevity. Submit only content you have personally reviewed, and take responsibility for responding to review feedback. +- **Label generated media.** Model-generated images, video, and audio are valid examples or test artifacts for this multimodal project. Identify them as generated output and provide the model, inputs, and reproduction settings. Do not present them as ground-truth references or independent evidence of correctness. + +For example, a PR description could include: + +```text +AI assistance: Used Codex to draft the regression test and PR description. +I reviewed the changes and verified the test reproduces the reported failure. +Validation: . +``` + +Maintainer review time is limited. Contributions that are unverified, misleading, or beyond the author's understanding may be closed. Repeated violations may lead to contribution restrictions. New contributors are welcome to ask for help and submit small, well-understood changes. + +This policy is adapted from [Ghostty's AI Usage Policy](https://github.com/ghostty-org/ghostty/blob/main/AI_POLICY.md) for vLLM-Omni's contribution and multimodal validation workflows. The [coding-agent workflow](#using-repository-skills-with-coding-agents) provides practical guidance for applying it. + ## Issues If you encounter a bug or have a feature request, please search existing issues first to see if it has already been reported. If not, please file a new issue, providing as much relevant information as possible. From 5078c7ad66c393f16bf9092c2a66d1c179354008 Mon Sep 17 00:00:00 2001 From: Zhichao Zhang <60429419+smartDream-chao@users.noreply.github.com> Date: Wed, 9 Sep 2026 09:38:11 +0800 Subject: [PATCH 52/90] [Bugfix][MiMo-Audio] Align code2wav decode with tokenizer device (#6539) Signed-off-by: chaosansui Signed-off-by: Zhichao Zhang <60429419+smartDream-chao@users.noreply.github.com> --- .../test_mimo_audio_code2wav_batch_decode.py | 6 +- .../test_mimo_audio_tokenizer_worker_cache.py | 115 ++++++++++++++++++ .../models/mimo_audio/mimo_audio_code2wav.py | 61 ++++++---- 3 files changed, 160 insertions(+), 22 deletions(-) create mode 100644 tests/model_executor/models/mimo_audio/test_mimo_audio_tokenizer_worker_cache.py diff --git a/tests/model_executor/models/mimo_audio/test_mimo_audio_code2wav_batch_decode.py b/tests/model_executor/models/mimo_audio/test_mimo_audio_code2wav_batch_decode.py index 194a024db..c20ee7f48 100644 --- a/tests/model_executor/models/mimo_audio/test_mimo_audio_code2wav_batch_decode.py +++ b/tests/model_executor/models/mimo_audio/test_mimo_audio_code2wav_batch_decode.py @@ -91,7 +91,11 @@ def _minimal_model(mocker: MockerFixture): decoder=decoder, config=SimpleNamespace(avg_pooler=2, stride_size=2, hop_length=240), ) - model._tokenizer_service = SimpleNamespace(audio_tokenizer=audio_tok, cuda_graph_wrapper=None) + model._tokenizer_service = SimpleNamespace( + audio_tokenizer=audio_tok, + cuda_graph_wrapper=None, + device="cpu", + ) return model, audio_tok diff --git a/tests/model_executor/models/mimo_audio/test_mimo_audio_tokenizer_worker_cache.py b/tests/model_executor/models/mimo_audio/test_mimo_audio_tokenizer_worker_cache.py new file mode 100644 index 000000000..06b16f853 --- /dev/null +++ b/tests/model_executor/models/mimo_audio/test_mimo_audio_tokenizer_worker_cache.py @@ -0,0 +1,115 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for the MiMo-Audio tokenizer-worker cache key. + +The worker cache key used to keep only the device *type*, so an explicit +``MIMO_AUDIO_TOKENIZER_DEVICE=cuda:1`` silently loaded the tokenizer on the +process's current CUDA device and shared one cache entry with ``cuda:0``. +The index must survive both the cache key and the worker constructor. + +CPU-only and weight-free: the worker class is replaced by a stub so no +tokenizer weights are loaded. +""" + +from __future__ import annotations + +import functools + +import pytest + +torch = pytest.importorskip("torch") + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_TOK_PATH = "/models/mimo-audio-tokenizer" + + +@functools.lru_cache(maxsize=1) +def _code2wav_module(): + """Defer the import (pulls vLLM model_executor) until first use.""" + from vllm_omni.model_executor.models.mimo_audio import mimo_audio_code2wav + + return mimo_audio_code2wav + + +class _StubWorker: + def __init__(self, device_str: str, config_path: str | None, audio_tokenizer_path: str): + self.device = device_str + self.config_path = config_path + self.audio_tokenizer_path = audio_tokenizer_path + + +@pytest.fixture +def isolated_worker_cache(monkeypatch): + """Empty worker cache with the real worker replaced by a stub.""" + mod = _code2wav_module() + monkeypatch.setattr(mod, "MiMoAudioTokenizerWorker", _StubWorker) + monkeypatch.setattr(mod, "_TOKENIZER_WORKER_CACHE", {}) + return mod + + +@pytest.mark.parametrize("device", [torch.device("cuda:1"), "cuda:1"]) +def test_cache_key_keeps_cuda_index(device): + """Both torch.device and string spellings retain the index.""" + mod = _code2wav_module() + key = mod._normalize_tokenizer_worker_cache_key(device, None, _TOK_PATH) + assert key[0] == "cuda:1" + + +def test_cache_key_cpu_unchanged(): + mod = _code2wav_module() + key = mod._normalize_tokenizer_worker_cache_key(torch.device("cpu"), None, _TOK_PATH) + assert key[0] == "cpu" + + +def test_cache_key_bare_cuda_resolves_to_current_device(monkeypatch): + """A bare "cuda" shares the entry with its equivalent indexed spelling.""" + mod = _code2wav_module() + monkeypatch.setattr(torch.cuda, "is_available", lambda: True) + monkeypatch.setattr(torch.accelerator, "current_device_index", lambda: 3) + + bare = mod._normalize_tokenizer_worker_cache_key(torch.device("cuda"), None, _TOK_PATH) + indexed = mod._normalize_tokenizer_worker_cache_key(torch.device("cuda:3"), None, _TOK_PATH) + assert bare[0] == "cuda:3" + assert bare == indexed + + +def test_worker_constructed_on_requested_index(isolated_worker_cache): + mod = isolated_worker_cache + worker = mod.get_tokenizer_worker( + device=torch.device("cuda:1"), + config_path=None, + audio_tokenizer_path=_TOK_PATH, + ) + assert worker.device == "cuda:1" + + +def test_distinct_cuda_indices_get_distinct_workers(isolated_worker_cache): + mod = isolated_worker_cache + first = mod.get_tokenizer_worker( + device=torch.device("cuda:0"), + config_path=None, + audio_tokenizer_path=_TOK_PATH, + ) + second = mod.get_tokenizer_worker( + device=torch.device("cuda:1"), + config_path=None, + audio_tokenizer_path=_TOK_PATH, + ) + assert first is not second + assert (first.device, second.device) == ("cuda:0", "cuda:1") + + +def test_same_device_reuses_cached_worker(isolated_worker_cache): + mod = isolated_worker_cache + first = mod.get_tokenizer_worker( + device=torch.device("cuda:1"), + config_path=None, + audio_tokenizer_path=_TOK_PATH, + ) + second = mod.get_tokenizer_worker( + device="cuda:1", + config_path=None, + audio_tokenizer_path=_TOK_PATH, + ) + assert first is second diff --git a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_code2wav.py b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_code2wav.py index 019670cf9..5423788a0 100644 --- a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_code2wav.py +++ b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_code2wav.py @@ -99,8 +99,10 @@ class MiMoAudioTokenizerWorker: self.audio_tokenizer.config.nfft, ) mel_start = time.monotonic() - self.mel_transform = ( - MelSpectrogram( + # Build on CPU first: torchaudio MelSpectrogram init can mix CPU/CUDA + # tensors on newer PyTorch when default device is CUDA. + with torch.device("cpu"): + self.mel_transform = MelSpectrogram( sample_rate=self.audio_tokenizer.config.sampling_rate, n_fft=self.audio_tokenizer.config.nfft, hop_length=self.audio_tokenizer.config.hop_length, @@ -110,10 +112,9 @@ class MiMoAudioTokenizerWorker: n_mels=self.audio_tokenizer.config.n_mels, power=1.0, center=True, - ) - .to(self.device) - .to(torch.float32) - ) + ).to(torch.float32) + if self.device != "cpu": + self.mel_transform = self.mel_transform.to(self.device) logger.info( "[tokenizer worker] MelSpectrogram ready in %.2fs", time.monotonic() - mel_start, @@ -382,12 +383,19 @@ def extract_audio_code_tensor( def _normalize_tokenizer_worker_cache_key( - device: torch.device, + device: torch.device | str, config_path: str | None, audio_tokenizer_path: str, ) -> tuple[str, str, str]: """Normalize cache key so that same tokenizer always hits the same cache entry.""" - device_type = device.type if isinstance(device, torch.device) else str(device).split(":")[0] + dev = device if isinstance(device, torch.device) else torch.device(str(device)) + # Keep the index: an explicit "cuda:1" must not collapse onto the process's + # current CUDA device. Resolve a bare "cuda" so it shares the cache entry + # with the equivalent indexed spelling. + if dev.type == "cuda" and dev.index is None and torch.cuda.is_available(): + # Repo bans torch.cuda.current_device; match mimo_audio.py. + dev = torch.device("cuda", torch.accelerator.current_device_index()) + device_key = str(dev) # Use realpath so symlinks / trailing slash don't create duplicate entries ap = audio_tokenizer_path or "" if ap and os.path.exists(ap): @@ -398,22 +406,21 @@ def _normalize_tokenizer_worker_cache_key( if not cp and ap: cp = os.path.dirname(ap) - return (device_type, cp, ap) + return (device_key, cp, ap) _TOKENIZER_WORKER_CACHE: dict[tuple[str, str, str], MiMoAudioTokenizerWorker] = {} def get_tokenizer_worker( - device: torch.device, - config_path: str, + device: torch.device | str, + config_path: str | None, audio_tokenizer_path: str, ) -> MiMoAudioTokenizerWorker: key = _normalize_tokenizer_worker_cache_key(device, config_path, audio_tokenizer_path) if key not in _TOKENIZER_WORKER_CACHE: - device_type = key[0] _TOKENIZER_WORKER_CACHE[key] = MiMoAudioTokenizerWorker( - device_str=device_type, + device_str=key[0], config_path=config_path, audio_tokenizer_path=audio_tokenizer_path, ) @@ -469,8 +476,14 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): or self.config.name_or_path ) + _tok_dev = os.environ.get("MIMO_AUDIO_TOKENIZER_DEVICE") + if _tok_dev: + tokenizer_device = torch.device("cpu" if _tok_dev.lower() == "cpu" else _tok_dev) + else: + tokenizer_device = self.device + self._tokenizer_service: MiMoAudioTokenizerWorker | None = get_tokenizer_worker( - device=self.device, + device=tokenizer_device, config_path=self.tokenizer_config_path, audio_tokenizer_path=self.audio_tokenizer_path, ) @@ -723,7 +736,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): Instead of calling _decode_waveform_from_codes per request (which incurs 4 GPU↔CPU round-trips each), this method: 1. Extracts audio_codes for all valid requests on CPU (cheap). - 2. Runs quantizer.decode_vq (embedding lookup) for each on GPU. + 2. Runs quantizer.decode_vq (embedding lookup) on the tokenizer device. 3. Packs all hidden-states into one tensor and calls decoder(packed_hs, input_lengths) once. 4. Splits the output waveforms back to per-request tensors. @@ -734,6 +747,9 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): return [empty] tokenizer = self._tokenizer_service.audio_tokenizer + # Codes/weights must share the tokenizer device (may be CPU via + # MIMO_AUDIO_TOKENIZER_DEVICE), not the stage CUDA device. + tok_device = torch.device(self._tokenizer_service.device) group_size = self.streamer_config.group_size audio_channels = self.streamer_config.audio_channels @@ -768,7 +784,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): use_cuda_graph = cg_ready and len(extracted) == 1 if use_cuda_graph: - wav_out = cg_wrapper.decode(extracted[0][1].to(self.device)) + wav_out = cg_wrapper.decode(extracted[0][1].to(tok_device)) wav = wav_out.squeeze(0).squeeze(0) cfg = tokenizer.config frames_per_token = cfg.avg_pooler * cfg.stride_size * cfg.hop_length @@ -782,7 +798,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): hidden_list: list[torch.Tensor] = [] lengths: list[int] = [] for _, audio_codes in extracted: - hs = tokenizer.encoder.decode_vq(audio_codes.to(self.device)) + hs = tokenizer.encoder.decode_vq(audio_codes.to(tok_device)) hidden_list.append(hs) lengths.append(hs.size(0)) @@ -790,7 +806,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): packed_hs = hidden_list[0] else: packed_hs = torch.cat(hidden_list, dim=0) - input_lengths = torch.tensor(lengths, device=self.device) + input_lengths = torch.tensor(lengths, device=tok_device) recon_wav = tokenizer.decoder(packed_hs, input_lengths) @@ -831,6 +847,9 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): return [empty] tokenizer = self._tokenizer_service.audio_tokenizer + # Codes/weights must share the tokenizer device (may be CPU via + # MIMO_AUDIO_TOKENIZER_DEVICE), not the stage CUDA device. + tok_device = torch.device(self._tokenizer_service.device) group_size = self.streamer_config.group_size audio_channels = self.streamer_config.audio_channels @@ -886,7 +905,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): frames_per_token = cfg.avg_pooler * cfg.stride_size * cfg.hop_length if use_cuda_graph: - wav_out = cg_wrapper.decode(extracted[0][1].to(self.device)) + wav_out = cg_wrapper.decode(extracted[0][1].to(tok_device)) wav = wav_out.squeeze(0).squeeze(0) valid_len = extracted[0][1].shape[-1] * frames_per_token if wav.numel() > valid_len: @@ -903,7 +922,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): hidden_list: list[torch.Tensor] = [] lengths: list[int] = [] for _, audio_codes in extracted: - hs = tokenizer.encoder.decode_vq(audio_codes.to(self.device)) + hs = tokenizer.encoder.decode_vq(audio_codes.to(tok_device)) hidden_list.append(hs) lengths.append(hs.size(0)) @@ -911,7 +930,7 @@ class MiMoAudioToken2WavForConditionalGenerationVLLM(nn.Module, SupportsPP): packed_hs = hidden_list[0] else: packed_hs = torch.cat(hidden_list, dim=0) - input_lengths = torch.tensor(lengths, device=self.device) + input_lengths = torch.tensor(lengths, device=tok_device) recon_wav = tokenizer.decoder(packed_hs, input_lengths) From d7678e2bb30df56aad929e290f194b930f2b73b1 Mon Sep 17 00:00:00 2001 From: eval <74645252+eval-dev@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:41:42 -0700 Subject: [PATCH 53/90] [Bugfix][MiniCPM-o] Fix the audio_embeds input path (#5730) Signed-off-by: eval-dev <0xe5bca0@gmail.com> Signed-off-by: eval <74645252+eval-dev@users.noreply.github.com> --- .../minicpmo_4_5/test_audio_embeds_input.py | 138 ++++++++++++++++++ .../minicpmo_4_5/minicpmo_4_5_omni_llm.py | 5 +- 2 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py diff --git a/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py b/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py new file mode 100644 index 000000000..d25b58e48 --- /dev/null +++ b/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Regression tests for the MiniCPM-o 4.5 ``audio_embeds`` input path. + +Two defects made the OpenAI ``audio_embeds`` content part unusable with this model: + +1. ``MiniCPMOAudioEmbeddingItems`` registered itself under ``modality="image"`` + (copy-pasted from ``MiniCPMVImageEmbeddingItems``), so every request failed with + ``Modality 'image' not found. Available modalities: {'audio'}``. +2. ``_get_prompt_updates`` sized the placeholder from + ``sum(map(len, single_audio_embeds))``. ``MiniCPMOAudioEmbeddingInputs`` declares + ``TensorShape("bn", "s", "h")``, so one item is ``(s, h)`` and that expression + iterates rows and sums the hidden size -- ``s * h`` instead of ``s``. A one-second + slice of shape ``(10, 4096)`` asked for 40960 placeholder tokens instead of 10, + pushing every real request past ``max_model_len``. +""" + +from __future__ import annotations + +import math +from types import SimpleNamespace + +import pytest +import torch +from vllm.multimodal.parse import MultiModalDataItems + +from vllm_omni.model_executor.models.minicpmo_4_5.minicpmo_4_5_omni_llm import ( + MiniCPMO45OmniLLMMultiModalProcessor, + MiniCPMO45OmniLLMProcessingInfo, + MiniCPMOAudioEmbeddingItems, + _minicpmo_field_config, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +POOL_STEP = 5 +HIDDEN_SIZE = 4096 +#: Audio embedding vectors produced per second: 100 mel -> 50 cnn -> (50-5)//5+1. +EMBEDS_PER_SECOND = 10 + + +class _FakeProcessor: + """Minimal processor surface used by ``get_audio_placeholder``.""" + + def __init__(self) -> None: + self.pool_step = POOL_STEP + self.audio_processor = SimpleNamespace(hop_length=160) + self.image_processor = SimpleNamespace(mean=[0.0], std=[1.0]) + + def get_audio_placeholder( + self, + audio_lens: int, + chunk_input: bool = True, + chunk_length: int = 1, + ) -> str: + del chunk_input, chunk_length + feature_lens = math.ceil(audio_lens / self.audio_processor.hop_length) + cnn_feature_lens = (feature_lens - 1) // 2 + 1 + output_lens = (cnn_feature_lens - self.pool_step) // self.pool_step + 1 + return "" * output_lens + + +class _IdentityTokenizer: + """``_get_prompt_updates`` round-trips the image/video patterns through the + tokenizer to detect encode/decode drift; an identity pair keeps that a no-op.""" + + def encode(self, text: str, add_special_tokens: bool = True) -> str: + del add_special_tokens + return text + + def decode(self, tokens: str) -> str: + return tokens + + +def _processing_info() -> MiniCPMO45OmniLLMProcessingInfo: + processor = _FakeProcessor() + tokenizer = _IdentityTokenizer() + info = object.__new__(MiniCPMO45OmniLLMProcessingInfo) + info.ctx = SimpleNamespace( + tokenizer=tokenizer, + get_tokenizer=lambda: tokenizer, + get_hf_config=lambda: SimpleNamespace(audio_pool_step=POOL_STEP), + get_hf_processor=lambda **kwargs: processor, + ) + return info + + +def _audio_placeholder_for(single_audio_embeds: torch.Tensor) -> str: + """Run the real prompt-update path and return the audio placeholder text. + + Goes through ``_get_prompt_updates`` rather than recomputing the length, so the + ``sum(map(len, ...))`` regression is actually exercised. + """ + info = _processing_info() + proc = object.__new__(MiniCPMO45OmniLLMMultiModalProcessor) + proc.info = info + + items = MiniCPMOAudioEmbeddingItems( + {"audio_embeds": single_audio_embeds.unsqueeze(0)}, + fields_factory=_minicpmo_field_config, + ) + mm_items = MultiModalDataItems({"audio": items}) + + updates = proc._get_prompt_updates(mm_items, {}, {}) + audio_update = next(u for u in updates if u.modality == "audio") + return audio_update.content(0).full + + +def test_audio_embedding_items_use_the_audio_modality() -> None: + """Registering under ``image`` makes every ``audio_embeds`` request fail.""" + items = MiniCPMOAudioEmbeddingItems( + {"audio_embeds": torch.zeros(1, EMBEDS_PER_SECOND, HIDDEN_SIZE)}, + fields_factory=_minicpmo_field_config, + ) + + assert items.modality == "audio" + + +@pytest.mark.parametrize("num_seconds", [1, 3, 30]) +def test_placeholder_length_matches_embedding_count(num_seconds: int) -> None: + """An ``(s, h)`` item must yield exactly ``s`` placeholder tokens.""" + num_embeds = num_seconds * EMBEDS_PER_SECOND + + placeholder = _audio_placeholder_for(torch.zeros(num_embeds, HIDDEN_SIZE)) + + assert placeholder.count("") == num_embeds + + +def test_placeholder_does_not_scale_with_hidden_size() -> None: + """The count must depend on ``s`` alone, never on ``h``. + + ``sum(map(len, ...))`` returned ``s * h``, so the placeholder grew with the model's + hidden size. Two items with the same ``s`` but different ``h`` must agree. + """ + narrow = _audio_placeholder_for(torch.zeros(EMBEDS_PER_SECOND, 8)) + wide = _audio_placeholder_for(torch.zeros(EMBEDS_PER_SECOND, HIDDEN_SIZE)) + + assert narrow.count("") == wide.count("") == EMBEDS_PER_SECOND diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py index f6c9b1fa9..9dfff5589 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py @@ -3310,7 +3310,7 @@ class MiniCPMOAudioEmbeddingItems(DictEmbeddingItems): ) -> None: super().__init__( data, - modality="image", + modality="audio", required_fields={"audio_embeds"}, fields_factory=fields_factory, ) @@ -3733,7 +3733,8 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn if isinstance(audios, MiniCPMOAudioEmbeddingItems): single_audio_embeds = audios.get(item_idx)["audio_embeds"] - audio_len = self.info.get_audio_len_by_num_chunks(sum(map(len, single_audio_embeds))) + # One item is ``(s, h)``, so its leading dim is the audio embedding count. + audio_len = self.info.get_audio_len_by_num_chunks(len(single_audio_embeds)) else: audio_len = audios.get_audio_length(item_idx) From cdf027874c9fe070888b33127987c9a19e8c888c Mon Sep 17 00:00:00 2001 From: boatman <1930807094@qq.com> Date: Wed, 9 Sep 2026 09:58:38 +0800 Subject: [PATCH 54/90] [Feat][OmniVoice]Support Varlen Attn, Request-Batch and Step-Execution (#6408) Signed-off-by: boatman <109857087+sphinxkkkbc@users.noreply.github.com> --- docs/serving/speech_api.md | 4 + .../test_omnivoice_expansion.py | 96 +++- .../online_serving/test_omnivoice_parity.py | 96 ++++ .../openai_api/test_serving_speech.py | 112 +++- tests/helpers/client.py | 1 + .../omnivoice/test_cuda_graph_generator.py | 293 +++++----- .../omnivoice/test_fused_projection_load.py | 27 +- .../models/omnivoice/test_mask_dtype.py | 200 ++----- .../omnivoice/test_pipeline_batching.py | 228 ++++++++ vllm_omni/deploy/omnivoice.yaml | 2 + .../models/omnivoice/pipeline_omnivoice.py | 384 ++++++++++--- vllm_omni/diffusion/worker/utils.py | 2 +- .../entrypoints/openai/serving_speech.py | 19 + .../models/omnivoice/omnivoice_generator.py | 542 +++++++++++------- 14 files changed, 1396 insertions(+), 610 deletions(-) create mode 100644 tests/e2e/online_serving/test_omnivoice_parity.py create mode 100644 tests/model_executor/models/omnivoice/test_pipeline_batching.py diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index d4871ce15..a515e6557 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -752,6 +752,10 @@ Fish Speech uses `ref_audio` and `ref_text` for voice cloning (no `task_type` ne | ------- | ------------- | | `k2-fsa/OmniVoice` | Pure-diffusion TTS. Supports voice cloning via `ref_audio` (with optional `ref_text`); no built-in voice presets. | +OmniVoice uses packed variable-length attention for batched generator execution. The attention operator accepts FP16 and BF16 inputs, so its query, +key, and value tensors are evaluated in BF16 even when the stage is configured with `dtype: float32`; the attention output is converted back to the model's +hidden-state dtype before the output projection. Consequently, a float32 stage configuration does not imply FP32 attention arithmetic. + ### VoxCPM2 | Model | Description | diff --git a/tests/e2e/online_serving/test_omnivoice_expansion.py b/tests/e2e/online_serving/test_omnivoice_expansion.py index 3a042082e..d77405a73 100644 --- a/tests/e2e/online_serving/test_omnivoice_expansion.py +++ b/tests/e2e/online_serving/test_omnivoice_expansion.py @@ -7,7 +7,10 @@ Tests verify that the OmniVoice model generates valid audio when accessed through the standard OpenAI-compatible speech API. """ +import io import os +import wave +from collections.abc import Sequence os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" @@ -15,7 +18,7 @@ import pytest from tests.helpers.mark import hardware_test from tests.helpers.media import get_asset_path -from tests.helpers.runtime import OmniServerParams +from tests.helpers.runtime import OmniResponse, OmniServerParams from tests.helpers.stage_config import get_deploy_config_path from vllm_omni.entrypoints.openai.serving_speech import _DEFAULT_VOICE_NAME @@ -34,6 +37,10 @@ STAGE_CONFIG = get_deploy_config_path("omnivoice.yaml") EXTRA_ARGS = [ "--trust-remote-code", "--disable-log-stats", + "--max-num-seqs", + "8", + "--request-batch-max-wait-ms", + "50", ] TEST_PARAMS = [ OmniServerParams( @@ -42,7 +49,14 @@ TEST_PARAMS = [ server_args=EXTRA_ARGS, ) ] - +STEP_EXECUTION_ARGS = EXTRA_ARGS + ["--step-execution"] +STEP_EXECUTION_PARAMS = [ + OmniServerParams( + model=MODEL, + stage_config_path=STAGE_CONFIG, + server_args=STEP_EXECUTION_ARGS, + ) +] # Lower this in ``request_config`` via ``min_audio_bytes`` if a run produces legitimately short WAVs. _DEFAULT_MIN_AUDIO_BYTES = 5000 @@ -58,6 +72,33 @@ def get_prompt(prompt_type="text"): return prompts.get(prompt_type, prompts["text"]) +def _assert_valid_omnivoice_wav_responses(responses: Sequence[OmniResponse]) -> None: + """Validate that every concurrent response contains plausible OmniVoice audio.""" + audio_shapes: list[tuple[int, int, int, int]] = [] + for response in responses: + audio_bytes = response.audio_bytes + assert audio_bytes is not None + assert audio_bytes.startswith(b"RIFF") + with wave.open(io.BytesIO(audio_bytes), "rb") as wav_file: + num_channels = wav_file.getnchannels() + sample_width = wav_file.getsampwidth() + sample_rate = wav_file.getframerate() + num_frames = wav_file.getnframes() + assert num_channels == 1 + assert sample_width == 2 + assert sample_rate == 24000 + assert num_frames > 0 + audio_shapes.append((num_channels, sample_width, sample_rate, num_frames)) + + duration_s = num_frames / sample_rate + assert 0.1 <= duration_s <= 30.0 + + # Identical prompts use the same estimated target length. Their samples may + # differ numerically across batch layouts, but the decoded audio shape must + # remain consistent across concurrent requests. + assert len(set(audio_shapes)) == 1 + + @pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) class TestOmniVoiceTTS: """E2E tests for OmniVoice TTS model.""" @@ -74,6 +115,57 @@ class TestOmniVoiceTTS: } online_client.send_audio_speech_request(request_config) + @hardware_test(res={"cuda": "L4"}, num_cards=1) + def test_speech_auto_voice_batch(self, omni_server, openai_client) -> None: + """Test concurrent request-batch TTS generation.""" + batch_request_config = { + "model": omni_server.model, + "input": get_prompt("text"), + "response_format": "wav", + "seed": 42, + "min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES, + } + batch_r = openai_client.send_audio_speech_request(batch_request_config, request_num=2) + assert len(batch_r) == 2 + _assert_valid_omnivoice_wav_responses(batch_r) + + +@pytest.mark.parametrize("omni_server", STEP_EXECUTION_PARAMS, indirect=True) +class TestOmniVoiceStepExecution: + """E2E tests for OmniVoice TTS model.""" + + @hardware_test(res={"cuda": "L4"}, num_cards=1) + def test_speech_auto_voice_step_execution(self, omni_server, openai_client) -> None: + """Test auto voice TTS generation (text only, no reference audio).""" + request_config = { + "model": omni_server.model, + "input": get_prompt("text"), + "response_format": "wav", + "timeout": 180.0, + "min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES, + "extra_params": { + "num_inference_steps": 32, + }, + } + openai_client.send_audio_speech_request(request_config) + + @hardware_test(res={"cuda": "L4"}, num_cards=1) + def test_speech_auto_voice_batch_step_execution(self, omni_server, openai_client) -> None: + """Test concurrent step-execution TTS generation.""" + batch_request_config = { + "model": omni_server.model, + "input": get_prompt("text"), + "response_format": "wav", + "seed": 42, + "min_audio_bytes": _DEFAULT_MIN_AUDIO_BYTES, + "extra_params": { + "num_inference_steps": 32, + }, + } + batch_r = openai_client.send_audio_speech_request(batch_request_config, request_num=2) + assert len(batch_r) == 2 + _assert_valid_omnivoice_wav_responses(batch_r) + @pytest.mark.parametrize("omni_server", TEST_PARAMS, indirect=True) class TestOmniVoiceSeed: diff --git a/tests/e2e/online_serving/test_omnivoice_parity.py b/tests/e2e/online_serving/test_omnivoice_parity.py new file mode 100644 index 000000000..e9b267f74 --- /dev/null +++ b/tests/e2e/online_serving/test_omnivoice_parity.py @@ -0,0 +1,96 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Cross-mode full-model parity tests for OmniVoice.""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest +import requests + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniServer +from tests.helpers.stage_config import get_deploy_config_path + +pytestmark = [pytest.mark.slow, pytest.mark.tts] + +MODEL = "k2-fsa/OmniVoice" +STAGE_CONFIG = get_deploy_config_path("omnivoice.yaml") +PROMPT = "The weather is nice today, perfect for a walk in the park." + +payload = { + "model": MODEL, + "input": PROMPT, + "language": "English", + "seed": 42, + "response_format": "wav", + "extra_params": {"num_inference_steps": 32}, +} + + +def _generate_without_graph(server_args: list[str]) -> bytes: + with OmniServer( + MODEL, + server_args, + use_omni=True, + env_dict={"OMNIVOICE_CUDA_GRAPH": "0"}, + ) as server: + response = requests.post( + f"http://{server.host}:{server.port}/v1/audio/speech", + json=payload, + timeout=600, + ) + response.raise_for_status() + assert response.content.startswith(b"RIFF") + return response.content + + +def _generate_with_graph(server_args: list[str]) -> bytes: + with OmniServer( + MODEL, + server_args, + use_omni=True, + ) as server: + response = requests.post( + f"http://{server.host}:{server.port}/v1/audio/speech", + json=payload, + timeout=600, + ) + response.raise_for_status() + assert response.content.startswith(b"RIFF") + return response.content + + +def _common_args() -> list[str]: + """Return the shared B=1 server arguments for parity tests.""" + return [ + "--trust-remote-code", + "--disable-log-stats", + "--deploy-config", + STAGE_CONFIG, + "--max-num-seqs", + "1", + ] + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_request_mode_and_step_execution_b1_parity_without_graph() -> None: + """B=1 eager request and step modes must produce identical seeded WAV bytes.""" + common_args = _common_args() + + request_audio = _generate_without_graph(common_args) + step_audio = _generate_without_graph([*common_args, "--step-execution", "--enforce-eager"]) + + assert request_audio == step_audio + + +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_request_mode_and_step_execution_b1_parity_with_graph() -> None: + """B=1 Graph request and step modes must produce identical seeded WAV bytes.""" + common_args = _common_args() + + request_audio = _generate_with_graph(common_args) + step_audio = _generate_with_graph([*common_args, "--step-execution", "--enforce-eager"]) + + assert request_audio == step_audio diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 7e1a9ccec..6a5ebcf7d 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -27,6 +27,9 @@ from pydantic import ValidationError from pytest_mock import MockerFixture from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse +from vllm_omni.diffusion.request import OmniDiffusionRequest +from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler +from vllm_omni.diffusion.sched.step_scheduler import StepScheduler from vllm_omni.entrypoints.omni_base import OmniEngineDeadError from vllm_omni.entrypoints.openai import api_server as api_server_module from vllm_omni.entrypoints.openai import serving_speech as serving_speech_module @@ -55,6 +58,7 @@ from vllm_omni.entrypoints.openai.tts_adapters.capabilities import load_supporte from vllm_omni.entrypoints.openai.tts_adapters.ming_tts import MingTTSAdapter from vllm_omni.entrypoints.openai.tts_adapters.qwen3_tts import Qwen3TTSAdapter, Qwen3TTSCodecLimitError from vllm_omni.entrypoints.openai.tts_adapters.voxtral import VoxtralTTSAdapter +from vllm_omni.inputs.data import OmniDiffusionSamplingParams from vllm_omni.model_executor.models.fish_speech.prompt_utils import ( FISH_TEXT_ONLY_SYSTEM_PROMPT, build_fish_voice_clone_prompt_ids, @@ -753,13 +757,12 @@ class TestSpeechAPI: @pytest.mark.asyncio async def test_create_diffusion_speech_extra_params(self, mocker: MockerFixture): - """Test public diffusion speech success and extra_params propagation.""" + """Test diffusion parameters reach StepScheduler as standard fields.""" # Mock the engine client mock_engine = mocker.MagicMock() # Mock default sampling params - mock_sampling_param = mocker.MagicMock() - mock_sampling_param.extra_args = {"existing_arg": "value"} + mock_sampling_param = OmniDiffusionSamplingParams(extra_args={"existing_arg": "value"}) mock_engine.default_sampling_params_list = [mock_sampling_param] # Mock generate to yield a valid OmniRequestOutput @@ -775,7 +778,15 @@ class TestSpeechAPI: server, "create_audio", return_value=mocker.MagicMock(audio_data=b"dummy", media_type="audio/wav") ) - req = OpenAICreateSpeechRequest(input="Hello", extra_params={"new_arg": 123, "existing_arg": "new_value"}) + req = OpenAICreateSpeechRequest( + input="Hello", + extra_params={ + "new_arg": 123, + "existing_arg": "new_value", + "num_inference_steps": 12, + "guidance_scale": 7.0, + }, + ) response = await server.create_speech(req) @@ -792,7 +803,98 @@ class TestSpeechAPI: # Verify it was deepcopied and updated assert passed_params is not mock_engine.default_sampling_params_list - assert passed_params[0].extra_args == {"existing_arg": "new_value", "new_arg": 123} + assert passed_params[0].extra_args == { + "existing_arg": "new_value", + "new_arg": 123, + "num_inference_steps": 12, + "guidance_scale": 7.0, + } + assert passed_params[0].num_inference_steps == 12 + assert passed_params[0].guidance_scale == 7.0 + + # Regression: StepScheduler.add_request() used to receive + # num_inference_steps=None and fail while converting it to int. + scheduler = StepScheduler() + scheduler.add_request( + OmniDiffusionRequest( + prompt="Hello", + sampling_params=passed_params[0], + request_id="speech-test", + ) + ) + assert scheduler._request_progress["speech-test"].total_steps == 12 + + @pytest.mark.asyncio + async def test_diffusion_speech_guidance_promotion_controls_request_batch_admission( + self, + mocker: MockerFixture, + ) -> None: + """Different request guidance values must not enter one request batch.""" + mock_engine = mocker.MagicMock() + mock_engine.default_sampling_params_list = [OmniDiffusionSamplingParams(num_inference_steps=12)] + passed_sampling_params = [] + + async def mock_generate(*args, **kwargs): + passed_sampling_params.append(kwargs["sampling_params_list"][0]) + yield create_mock_audio_output_for_test() + + mock_engine.generate = mocker.MagicMock(side_effect=mock_generate) + server = OmniOpenAIServingSpeech.for_diffusion(diffusion_engine=mock_engine, model_name="test-model") + mocker.patch.object( + server, + "create_audio", + return_value=mocker.MagicMock(audio_data=b"dummy", media_type="audio/wav"), + ) + + for guidance_scale in (2.0, 7.0): + response = await server.create_speech( + OpenAICreateSpeechRequest( + input="Hello", + extra_params={"guidance_scale": guidance_scale}, + ) + ) + assert response.status_code == 200 + + scheduler = RequestScheduler() + scheduler.initialize(SimpleNamespace(max_num_seqs=2)) + for index, sampling_params in enumerate(passed_sampling_params): + scheduler.add_request( + OmniDiffusionRequest( + prompt="Hello", + sampling_params=sampling_params, + request_id=f"speech-{index}", + ) + ) + + first = scheduler.schedule() + + assert [request.request_id for request in first.scheduled_new_reqs] == ["speech-0"] + assert first.num_running_reqs == 1 + assert first.num_waiting_reqs == 1 + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("extra_params", "expected_message"), + [ + ({"num_inference_steps": "invalid"}, "num_inference_steps must be an integer"), + ({"guidance_scale": "invalid"}, "guidance_scale must be a number"), + ], + ) + async def test_create_diffusion_speech_rejects_invalid_scheduler_params( + self, + mocker: MockerFixture, + extra_params: dict[str, str], + expected_message: str, + ) -> None: + mock_engine = mocker.MagicMock() + mock_engine.default_sampling_params_list = [OmniDiffusionSamplingParams()] + server = OmniOpenAIServingSpeech.for_diffusion(diffusion_engine=mock_engine, model_name="test-model") + + response = await server.create_speech(OpenAICreateSpeechRequest(input="Hello", extra_params=extra_params)) + + assert response.status_code == 400 + assert expected_message in response.body.decode() + mock_engine.generate.assert_not_called() class TestTTSMethods: diff --git a/tests/helpers/client.py b/tests/helpers/client.py index 5339f7195..6a6a9b7ed 100644 --- a/tests/helpers/client.py +++ b/tests/helpers/client.py @@ -1468,6 +1468,7 @@ class OnlineOmniClient: "instructions", "speed", "sample_rate", + "extra_params", "stream_format", "x_vector_only_mode", ): diff --git a/tests/model_executor/models/omnivoice/test_cuda_graph_generator.py b/tests/model_executor/models/omnivoice/test_cuda_graph_generator.py index b55f8279c..ef05bf738 100644 --- a/tests/model_executor/models/omnivoice/test_cuda_graph_generator.py +++ b/tests/model_executor/models/omnivoice/test_cuda_graph_generator.py @@ -6,19 +6,27 @@ Tests for OmniVoice CUDA Graph generator wrapper numerical equivalence. Verifies that _OmniVoiceCUDAGraphForward produces results equivalent to eager mode across three scenarios: - Exact-size inputs (no padding) → bit-identical - - Padded inputs (padded to nearest bucket) → correct slicing, exact match - for position-independent models - - Oversized inputs (fallback to eager) → bit-identical + - Padded inputs (padded to nearest bucket) → correct slicing and exact match + - Oversized inputs (128-aligned lazy capture) → bit-identical -Uses a lightweight synthetic generator to keep warmup fast and avoid -loading actual model weights. +Uses a small randomly initialized OmniVoiceGenerator so every test exercises +the production embedding, transformer, varlen-attention, and logits paths +without loading checkpoint weights. """ from __future__ import annotations +from types import SimpleNamespace + import pytest import torch -import torch.nn as nn +from vllm.utils.math_utils import round_up + +from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import ( + OmniVoiceGenerator, + _OmniVoiceCUDAGraphForward, +) +from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig pytestmark = [ pytest.mark.core_model, @@ -29,53 +37,7 @@ pytestmark = [ DEVICE = torch.device("cuda:0") NUM_CB = 8 VOCAB = 1025 -HIDDEN = 64 CAPTURE_SIZES = [32, 64, 128] -HEAD_DIM = 64 - - -# --------------------------------------------------------------------------- -# Synthetic generator: matches _OmniVoiceCUDAGraphForward's interface -# --------------------------------------------------------------------------- - - -class _SyntheticGenerator: - """Position-independent Embedding+Linear replacing OmniVoiceGenerator's 28-layer transformer. - - Each token position is processed independently (no attention), so padded - runs produce bit-identical results to non-padded runs on the same positions. - This makes the padding/slicing logic easy to verify exactly. - """ - - class _Cfg: - num_audio_codebook = NUM_CB - - def __init__(self, device: torch.device): - self.config = self._Cfg() - self.text_embedding = nn.Embedding(1000, HIDDEN).to(device).eval() - self._linear = nn.Linear(HIDDEN, NUM_CB * VOCAB, bias=False).to(device).eval() - self._rope_table: torch.Tensor | None = None - - @property - def model_dtype(self) -> torch.dtype: - """Part of the interface _OmniVoiceCUDAGraphForward expects of a generator.""" - return self.text_embedding.weight.dtype - - def _rope_table_for(self, seq_len: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor: - """Part of the interface _OmniVoiceCUDAGraphForward expects of a generator.""" - return torch.zeros(seq_len, HEAD_DIM, device=device, dtype=dtype) - - def _step_forward( - self, - input_ids: torch.Tensor, - audio_mask: torch.Tensor, - attention_mask: torch.Tensor | None, - rope_table: torch.Tensor, - ) -> torch.Tensor: - two_b, _, S = input_ids.shape - x = self.text_embedding(input_ids[:, 0, :].clamp(0, 999)) # [two_b, S, H] - logits = self._linear(x) # [two_b, S, 8*1025] - return logits.view(two_b, S, NUM_CB, VOCAB).permute(0, 2, 1, 3) # [two_b, 8, S, 1025] # --------------------------------------------------------------------------- @@ -83,18 +45,37 @@ class _SyntheticGenerator: # --------------------------------------------------------------------------- -def _eager(gen: _SyntheticGenerator, ids: torch.Tensor, mask: torch.Tensor, attn: torch.Tensor) -> torch.Tensor: - """Run _step_forward with a zero RoPE table (synthetic gen ignores it).""" - two_b, _, S = ids.shape - return gen._step_forward(ids, mask, attn, gen._rope_table_for(S, ids.device, torch.float32)) +def _eager(gen: OmniVoiceGenerator, ids: torch.Tensor, mask: torch.Tensor, cu_seqs) -> torch.Tensor: + """Run the production varlen step outside CUDA Graph.""" + seq_len = ids.shape[0] + rope_table = gen._rope_table_for(seq_len, ids.device, gen.model_dtype) + return gen._step_forward(ids, mask, cu_seqs, rope_table) + + +def _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, batch_size) -> torch.Tensor: + """Run eager with the exact padded shape and metadata used by Graph.""" + seq_len = ids.shape[0] + bucket = wrapper._find_bucket(batch_size, seq_len) + if bucket is None: + bucket = round_up(seq_len, wrapper._LAZY_CAPTURE_ALIGNMENT) + padded_ids, padded_mask = wrapper._pad_inputs(ids, mask, bucket) + padded_cu_seqs = cu_seqs.clone() + padded_cu_seqs[-1] = bucket + return _eager(gen, padded_ids, padded_mask, padded_cu_seqs)[:, :seq_len, :] def _make_inputs(seq_len: int, device: torch.device = DEVICE): - two_b = 2 - ids = torch.randint(0, 100, (two_b, NUM_CB, seq_len), dtype=torch.long, device=device) - mask = torch.ones(two_b, seq_len, dtype=torch.bool, device=device) - attn = torch.ones(two_b, 1, seq_len, seq_len, dtype=torch.bool, device=device) - return ids, mask, attn + ids = torch.randint(0, 100, (seq_len, NUM_CB), dtype=torch.long, device=device) + mask = torch.ones(seq_len, dtype=torch.bool, device=device) + cond_len = (seq_len + 1) // 2 + uncond_len = seq_len - cond_len + + cu_seqs = torch.tensor( + [0, cond_len, cond_len + uncond_len, cond_len + uncond_len], + dtype=torch.int32, + device=device, + ) + return ids, mask, cu_seqs # --------------------------------------------------------------------------- @@ -105,15 +86,26 @@ def _make_inputs(seq_len: int, device: torch.device = DEVICE): @pytest.fixture(scope="module") def gen(): torch.manual_seed(42) - return _SyntheticGenerator(DEVICE) + config = OmniVoiceConfig( + audio_vocab_size=VOCAB, + num_audio_codebook=NUM_CB, + llm_config={ + "hidden_size": 64, + "num_hidden_layers": 2, + "num_attention_heads": 4, + "num_key_value_heads": 2, + "intermediate_size": 128, + "vocab_size": 128, + "max_position_embeddings": 4096, + "head_dim": 16, + }, + enable_cuda_graph=False, + ) + return OmniVoiceGenerator(config, SimpleNamespace(max_num_seqs=2)).to(DEVICE).eval() @pytest.fixture(scope="module") def wrapper(gen): - from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import ( - _OmniVoiceCUDAGraphForward, - ) - w = _OmniVoiceCUDAGraphForward(gen, capture_sizes=CAPTURE_SIZES) w.warmup(DEVICE) return w @@ -127,10 +119,10 @@ def wrapper(gen): @pytest.mark.parametrize("seq_len", CAPTURE_SIZES) def test_exact_size_bit_identical(gen, wrapper, seq_len): """When input exactly matches a captured bucket, output must be bit-identical to eager.""" - ids, mask, attn = _make_inputs(seq_len) + ids, mask, cu_seqs = _make_inputs(seq_len) with torch.no_grad(): - eager_out = _eager(gen, ids, mask, attn) - graph_out = wrapper(ids, mask, attn) + eager_out = _eager(gen, ids, mask, cu_seqs) + graph_out = wrapper(ids, mask, cu_seqs, 1) torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0) @@ -140,40 +132,36 @@ def test_exact_size_bit_identical(gen, wrapper, seq_len): @pytest.mark.parametrize("seq_len", [1, 15, 33, 60, 100]) -def test_padded_output_shape(gen, wrapper, seq_len): +def test_padded_output_shape(wrapper, seq_len): """Graph output must be sliced back to actual seq_len, not the bucket size.""" - ids, mask, attn = _make_inputs(seq_len) + ids, mask, cu_seqs = _make_inputs(seq_len) with torch.no_grad(): - graph_out = wrapper(ids, mask, attn) - assert graph_out.shape == (2, NUM_CB, seq_len, VOCAB) + graph_out = wrapper(ids, mask, cu_seqs, 1) + assert graph_out.shape == (NUM_CB, seq_len, VOCAB) @pytest.mark.parametrize("seq_len", [15, 33, 60, 100]) def test_padded_output_matches_eager(gen, wrapper, seq_len): - """Padded graph output must equal eager output at actual positions. - - The synthetic model has no attention across positions, so zero-padding - does not affect non-padded positions — exact match is expected. - """ - ids, mask, attn = _make_inputs(seq_len) + """Padded graph output must equal eager output at actual positions.""" + ids, mask, cu_seqs = _make_inputs(seq_len) with torch.no_grad(): - eager_out = _eager(gen, ids, mask, attn) - graph_out = wrapper(ids, mask, attn) + eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 1) + graph_out = wrapper(ids, mask, cu_seqs, 1) torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0) # --------------------------------------------------------------------------- -# 3. Oversized inputs → fallback to eager (lazy capture), bit-identical +# 3. Oversized inputs → aligned lazy capture, bit-identical # --------------------------------------------------------------------------- @pytest.mark.parametrize("seq_len", [129, 200, 256]) -def test_fallback_eager_bit_identical(gen, wrapper, seq_len): - """Sequences exceeding the largest bucket fall back to lazy capture → bit-identical.""" - ids, mask, attn = _make_inputs(seq_len) +def test_aligned_lazy_capture_bit_identical(gen, wrapper, seq_len): + """Sequences beyond the static plan use aligned lazy capture and remain bit-identical.""" + ids, mask, cu_seqs = _make_inputs(seq_len) with torch.no_grad(): - eager_out = _eager(gen, ids, mask, attn) - graph_out = wrapper(ids, mask, attn) + eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 1) + graph_out = wrapper(ids, mask, cu_seqs, 1) torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0) @@ -184,13 +172,73 @@ def test_fallback_eager_bit_identical(gen, wrapper, seq_len): def test_deterministic_across_calls(wrapper): """Same input must produce identical output on repeated CUDA graph replays.""" - ids, mask, attn = _make_inputs(32) + ids, mask, cu_seqs = _make_inputs(32) with torch.no_grad(): - out1 = wrapper(ids, mask, attn) - out2 = wrapper(ids, mask, attn) + out1 = wrapper(ids, mask, cu_seqs, 1).clone() + out2 = wrapper(ids, mask, cu_seqs, 1).clone() torch.testing.assert_close(out1, out2, atol=0, rtol=0) +def test_replay_uses_updated_cu_seqs(gen, wrapper): + """The same graph key must honor new sequence boundaries on replay.""" + ids, mask, _ = _make_inputs(48) + cu_seqs_a = torch.tensor([0, 32, 48, 48], dtype=torch.int32, device=DEVICE) + cu_seqs_b = torch.tensor([0, 17, 48, 48], dtype=torch.int32, device=DEVICE) + + with torch.no_grad(): + eager_a = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs_a, 1) + graph_a = wrapper(ids, mask, cu_seqs_a, 1).clone() + eager_b = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs_b, 1) + graph_b = wrapper(ids, mask, cu_seqs_b, 1).clone() + + torch.testing.assert_close(graph_a, eager_a, atol=0, rtol=0) + torch.testing.assert_close(graph_b, eager_b, atol=0, rtol=0) + assert not torch.equal(graph_a, graph_b) + + +def test_batch_two_cu_seqs_matches_eager(gen, wrapper): + """B=2 uses four real sequence segments plus the fixed tail slot.""" + ids, mask, _ = _make_inputs(48) + cu_seqs = torch.tensor([0, 10, 18, 32, 48, 48], dtype=torch.int32, device=DEVICE) + + assert cu_seqs.numel() == 2 * 2 + 2 + with torch.no_grad(): + eager_out = _bucketed_eager(gen, wrapper, ids, mask, cu_seqs, 2) + graph_out = wrapper(ids, mask, cu_seqs, 2).clone() + + torch.testing.assert_close(graph_out, eager_out, atol=0, rtol=0) + + +def test_lazy_graph_cache_uses_lru_eviction(wrapper): + """A lazy-cache hit must protect that graph from the next eviction.""" + original_limit = wrapper._MAX_LAZY_GRAPHS + wrapper._lazy_graphs.clear() + wrapper._MAX_LAZY_GRAPHS = 2 + try: + # Static B=1 coverage ends at 128. These lengths round to three + # distinct 128-aligned lazy keys: 256, 384, and 512. + for seq_len in (129, 257): + ids, mask, cu_seqs = _make_inputs(seq_len) + with torch.no_grad(): + wrapper(ids, mask, cu_seqs, 1) + assert list(wrapper._lazy_graphs) == [(1, 256), (1, 384)] + + # Refresh key 256, making key 384 the least recently used entry. + ids, mask, cu_seqs = _make_inputs(129) + with torch.no_grad(): + wrapper(ids, mask, cu_seqs, 1) + assert list(wrapper._lazy_graphs) == [(1, 384), (1, 256)] + + # Inserting key 512 must evict key 384, not the recently hit key 256. + ids, mask, cu_seqs = _make_inputs(385) + with torch.no_grad(): + wrapper(ids, mask, cu_seqs, 1) + assert list(wrapper._lazy_graphs) == [(1, 256), (1, 512)] + finally: + wrapper._MAX_LAZY_GRAPHS = original_limit + wrapper._lazy_graphs.clear() + + # --------------------------------------------------------------------------- # 5. _find_bucket logic (CPU, no CUDA graph) # --------------------------------------------------------------------------- @@ -203,58 +251,17 @@ def test_find_bucket_returns_nearest_bucket(): ) w = _OmniVoiceCUDAGraphForward.__new__(_OmniVoiceCUDAGraphForward) - w._capture_sizes = [32, 64, 128] + w.capture_bucket_sizes_by_batch = { + 1: [32, 64, 128], + 2: [64, 128, 256], + } w._graphs = {} - assert w._find_bucket(1) == 32 - assert w._find_bucket(32) == 32 - assert w._find_bucket(33) == 64 - assert w._find_bucket(64) == 64 - assert w._find_bucket(128) == 128 - assert w._find_bucket(129) is None - assert w._find_bucket(1000) is None - - -# --------------------------------------------------------------------------- -# 6. enable_cuda_graph=False produces same tokens as enable_cuda_graph=True -# --------------------------------------------------------------------------- - - -def test_cuda_graph_disabled_matches_eager_generator(): - """OmniVoiceGenerator with enable_cuda_graph=False must produce the same - _step_forward output as one with enable_cuda_graph=True (after warmup). - - Uses a 2-layer config so warmup completes quickly without loading weights. - """ - from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator - from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig - - cfg = OmniVoiceConfig() - cfg.llm_num_hidden_layers = 2 - cfg.num_hidden_layers = 2 - - torch.manual_seed(0) - gen_eager = OmniVoiceGenerator(cfg).to(DEVICE).eval() - gen_eager.config.enable_cuda_graph = False - gen_eager._cuda_graph_fwd = None - - torch.manual_seed(0) - gen_graph = OmniVoiceGenerator(cfg).to(DEVICE).eval() - gen_graph.config.cuda_graph_capture_sizes = [64] - from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import _OmniVoiceCUDAGraphForward - - gen_graph._cuda_graph_fwd = _OmniVoiceCUDAGraphForward(gen_graph, capture_sizes=[64]) - gen_graph._cuda_graph_fwd.warmup(DEVICE) - - seq_len = 64 - ids = torch.zeros(2, cfg.num_audio_codebook, seq_len, dtype=torch.long, device=DEVICE) - mask = torch.ones(2, seq_len, dtype=torch.bool, device=DEVICE) - attn = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=DEVICE) - - rope_table = gen_eager._rope_table_for(seq_len, DEVICE, gen_eager.model_dtype) - - with torch.no_grad(): - eager_logits = gen_eager._step_forward(ids, mask, attn, rope_table) - graph_logits = gen_graph._cuda_graph_fwd(ids, mask, attn) - - torch.testing.assert_close(graph_logits, eager_logits, atol=0, rtol=0) + assert w._find_bucket(1, 1) == 32 + assert w._find_bucket(1, 32) == 32 + assert w._find_bucket(1, 33) == 64 + assert w._find_bucket(1, 128) == 128 + assert w._find_bucket(1, 129) is None + assert w._find_bucket(2, 33) == 64 + assert w._find_bucket(2, 129) == 256 + assert w._find_bucket(3, 64) is None diff --git a/tests/model_executor/models/omnivoice/test_fused_projection_load.py b/tests/model_executor/models/omnivoice/test_fused_projection_load.py index fc5db01c7..c80f0e65b 100644 --- a/tests/model_executor/models/omnivoice/test_fused_projection_load.py +++ b/tests/model_executor/models/omnivoice/test_fused_projection_load.py @@ -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 """The OmniVoice generator packs q/k/v and gate/up into fused projections. The HF checkpoint stores those five tensors separately, so packing them is a @@ -14,6 +14,8 @@ that would leave one partly written raises instead. from __future__ import annotations +from types import SimpleNamespace + import pytest import torch @@ -49,6 +51,13 @@ def _config() -> OmniVoiceConfig: ) +def _generator() -> OmniVoiceGenerator: + return OmniVoiceGenerator( + _config(), + SimpleNamespace(max_num_seqs=1), + ) + + def _checkpoint_shards() -> dict[str, torch.Tensor]: """The five per-layer tensors an OmniVoice checkpoint actually stores.""" torch.manual_seed(0) @@ -64,7 +73,7 @@ def _checkpoint_shards() -> dict[str, torch.Tensor]: def test_every_fused_parameter_is_written() -> None: - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = _checkpoint_shards() loaded = generator._load_fused_projections(shards) @@ -77,7 +86,7 @@ def test_every_fused_parameter_is_written() -> None: def test_packed_weight_matches_the_shards_it_came_from() -> None: """Packing order is q,k,v and gate,up -- the split in forward() assumes it.""" - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = _checkpoint_shards() generator._load_fused_projections(shards) @@ -101,7 +110,7 @@ def test_packed_weight_matches_the_shards_it_came_from() -> None: def test_no_fused_parameter_keeps_its_random_init() -> None: """The corruption this guards against: a fused param never written at all.""" - generator = OmniVoiceGenerator(_config()) + generator = _generator() before = { name: param.detach().clone() for name, param in generator.named_parameters() @@ -121,7 +130,7 @@ def test_no_fused_parameter_keeps_its_random_init() -> None: ["self_attn.k_proj", "self_attn.v_proj", "self_attn.q_proj", "mlp.gate_proj", "mlp.up_proj"], ) def test_a_missing_shard_raises_instead_of_loading_partially(dropped: str) -> None: - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = _checkpoint_shards() del shards[f"llm.layers.0.{dropped}.weight"] @@ -130,7 +139,7 @@ def test_a_missing_shard_raises_instead_of_loading_partially(dropped: str) -> No def test_a_wrong_shaped_shard_raises() -> None: - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = _checkpoint_shards() shards["llm.layers.0.self_attn.q_proj.weight"] = torch.randn(NUM_HEADS * HEAD_DIM + 1, HIDDEN) @@ -140,7 +149,7 @@ def test_a_wrong_shaped_shard_raises() -> None: def test_a_checkpoint_missing_a_whole_layer_raises() -> None: """Half-loaded is the dangerous state: it neither errors nor works.""" - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = {k: v for k, v in _checkpoint_shards().items() if not k.startswith("llm.layers.1.")} with pytest.raises(ValueError, match="fused projections"): @@ -149,7 +158,7 @@ def test_a_checkpoint_missing_a_whole_layer_raises() -> None: def test_a_checkpoint_with_no_fused_shards_is_left_alone() -> None: """An unrelated state_dict must not trip the completeness check.""" - generator = OmniVoiceGenerator(_config()) + generator = _generator() assert generator._load_fused_projections({"audio_heads.weight": torch.randn(4, 4)}) == set() @@ -162,7 +171,7 @@ def test_load_weights_wires_the_packing_in(tmp_path, monkeypatch: pytest.MonkeyP """ import safetensors.torch - generator = OmniVoiceGenerator(_config()) + generator = _generator() shards = _checkpoint_shards() before = generator.layers[0].self_attn.qkv_proj.weight.detach().clone() diff --git a/tests/model_executor/models/omnivoice/test_mask_dtype.py b/tests/model_executor/models/omnivoice/test_mask_dtype.py index ff899e2a2..ff8dda49d 100644 --- a/tests/model_executor/models/omnivoice/test_mask_dtype.py +++ b/tests/model_executor/models/omnivoice/test_mask_dtype.py @@ -1,183 +1,79 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""OmniVoice attention masks must follow the model dtype, not a hardcoded float32. +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""OmniVoice SDPA fallback masks must not depend on the model dtype. -SDPA requires the additive attention mask to carry the same dtype as the query. -The generator used to materialize that mask as float32 unconditionally, in three -places (the shared per-forward mask, the CUDA-graph capture buffer, and the -replay-time normalization), so every half-precision deployment died on the first -attention call with:: +The previous padded attention path used an additive floating-point mask. SDPA +requires such a mask to have the same dtype as the query, and constructing it +as float32 caused half-precision OmniVoice inference to fail on CUDA. - RuntimeError: invalid dtype for bias - should match query's dtype - -(the exact wording depends on which SDPA backend is selected for the shape; the -math backend words the same rejection as ``attn_mask.dtype``.) - -That left OmniVoice servable only in float32, even though the upstream k2-fsa -implementation runs it in float16. - -Note the split between the CPU and CUDA tests below: the CPU SDPA backend -silently accepts the mismatched mask, so only the CUDA tests can pin the actual -crash. The CPU tests cover the mask contract itself, which is what the fix -changes and what a future regression would break first. +The packed-varlen path no longer needs an additive mask. When SDPA fallback is +required, ``_attention_metadata_from_cu_seqs`` builds a boolean block mask. +Boolean SDPA masks have no dtype coupling with the query, so no mask coercion +is required for float16 or bfloat16 inference. """ from __future__ import annotations import pytest import torch +import torch.nn.functional as F from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import ( - OmniVoiceAttention, - OmniVoiceGenerator, - _additive_float_mask, + _attention_metadata_from_cu_seqs, ) -from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig HALF_DTYPES = [torch.float16, torch.bfloat16] -ALL_DTYPES = HALF_DTYPES + [torch.float32] cpu_test = pytest.mark.core_model cuda_test = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") -def _tiny_config() -> OmniVoiceConfig: - """A real OmniVoiceConfig sized so the module fits in a unit test.""" - return OmniVoiceConfig( - llm_config={ - "hidden_size": 32, - "num_hidden_layers": 2, - "num_attention_heads": 4, - "num_key_value_heads": 2, - "head_dim": 8, - "intermediate_size": 64, - "vocab_size": 64, - "max_position_embeddings": 128, - }, - enable_cuda_graph=False, +@cpu_test +@pytest.mark.cpu +def test_sdpa_fallback_mask_is_dtype_independent() -> None: + """The fallback mask is boolean rather than an additive model-dtype mask.""" + cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32) + + metadata = _attention_metadata_from_cu_seqs( + cu_seqs, + 6, + needs_sdpa_mask=True, ) - -def _inputs(dtype: torch.dtype, device: torch.device) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: - hidden = torch.randn(2, 6, 32, device=device, dtype=dtype) - bool_mask = torch.ones(2, 1, 6, 6, dtype=torch.bool, device=device) - bool_mask[:, :, :, 4:] = False - rope_table = torch.zeros(6, 8, device=device, dtype=dtype) - rope_table[:, :4] = 1.0 # cos = 1, sin = 0: no rotation, so the test is about the mask - return hidden, bool_mask, rope_table - - -# -------------------------------------------------------------------------- -# The mask contract (CPU) -# -------------------------------------------------------------------------- - - -@cpu_test -@pytest.mark.cpu -@pytest.mark.parametrize("dtype", ALL_DTYPES) -def test_additive_float_mask_uses_requested_dtype(dtype: torch.dtype) -> None: - out = _additive_float_mask(torch.tensor([[True, False]]), dtype) - assert out.dtype == dtype - - -@cpu_test -@pytest.mark.cpu -def test_additive_float_mask_keeps_mask_semantics() -> None: - """Attend positions stay at 0.0 and masked ones at -inf, in half precision too.""" - out = _additive_float_mask(torch.tensor([[True, False]]), torch.float16) - assert out[0, 0].item() == 0.0 - assert out[0, 1].item() == float("-inf") - - -@cpu_test -@pytest.mark.cpu -def test_additive_float_mask_requires_an_explicit_dtype() -> None: - """The float32 default is the bug; keeping the parameter required is the fix.""" - with pytest.raises(TypeError): - _additive_float_mask(torch.tensor([[True]])) # type: ignore[call-arg] - - -@cpu_test -@pytest.mark.cpu -@pytest.mark.parametrize("dtype", ALL_DTYPES) -def test_generator_model_dtype_tracks_its_weights(dtype: torch.dtype) -> None: - """The single source of truth the three former float32 literals now defer to.""" - assert OmniVoiceGenerator(_tiny_config()).to(dtype).model_dtype == dtype - - -@cpu_test -@pytest.mark.cpu -@pytest.mark.parametrize("dtype", HALF_DTYPES) -def test_attention_accepts_a_model_dtype_mask(dtype: torch.dtype) -> None: - attn = OmniVoiceAttention(_tiny_config()).to(dtype).eval() - hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cpu")) - - with torch.inference_mode(): - out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype)) - - assert out.shape == hidden.shape - assert out.dtype == dtype - assert torch.isfinite(out).all() - - -# -------------------------------------------------------------------------- -# The crash itself (CUDA only — the CPU backend does not reject the mismatch) -# -------------------------------------------------------------------------- + assert metadata.attn_mask is not None + assert metadata.attn_mask.dtype == torch.bool @cuda_test @pytest.mark.parametrize("dtype", HALF_DTYPES) -def test_float32_mask_is_what_broke_half_precision(dtype: torch.dtype) -> None: - """Pin the original failure so a float32 default cannot come back unnoticed.""" - attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval() - hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0")) - - with pytest.raises(RuntimeError, match=r"(invalid dtype for bias|attn_mask)"), torch.inference_mode(): - attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, torch.float32)) - - -@cuda_test -@pytest.mark.parametrize("dtype", HALF_DTYPES) -def test_real_path_forward_runs_in_half_precision(dtype: torch.dtype) -> None: - """The regression, on the production path with the Triton norm kernels live.""" - attn = OmniVoiceAttention(_tiny_config()).to(device="cuda:0", dtype=dtype).eval() - hidden, bool_mask, rope_table = _inputs(dtype, torch.device("cuda:0")) - - with torch.inference_mode(): - out = attn(hidden, rope_table, attention_mask=_additive_float_mask(bool_mask, dtype)) - - assert out.dtype == dtype - assert torch.isfinite(out).all() - - -@cuda_test -@pytest.mark.parametrize("dtype", HALF_DTYPES) -def test_generator_forward_runs_in_half_precision(dtype: torch.dtype) -> None: - """End-to-end over the real iterative loop, which is where the float32 mask was built. - - This is the test that fails on an unfixed tree: ``forward`` materialized the - shared SDPA mask as float32 regardless of the weights' dtype. - """ +def test_sdpa_fallback_mask_needs_no_half_precision_coercion( + dtype: torch.dtype, +) -> None: + """Half-precision SDPA accepts the production bool mask without casting.""" device = torch.device("cuda:0") - config = _tiny_config() - generator = OmniVoiceGenerator(config).to(device=device, dtype=dtype).eval() + seq_len = 6 + cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32, device=device) - seq_len, target_len = 12, 4 - input_ids = torch.zeros(2, config.num_audio_codebook, seq_len, dtype=torch.long, device=device) - input_ids[:, 1:, :] = config.audio_mask_id - audio_mask = torch.zeros(2, seq_len, dtype=torch.bool, device=device) - audio_mask[:, seq_len - target_len :] = True - attention_mask = torch.ones(2, 1, seq_len, seq_len, dtype=torch.bool, device=device) + metadata = _attention_metadata_from_cu_seqs( + cu_seqs, + seq_len, + needs_sdpa_mask=True, + ) + + assert metadata.attn_mask is not None + assert metadata.attn_mask.dtype == torch.bool + + query = torch.randn(1, 2, seq_len, 8, device=device, dtype=dtype) + key = torch.randn_like(query) + value = torch.randn_like(query) with torch.inference_mode(): - tokens = generator( - input_ids, - audio_mask, - attention_mask, - target_lens=[target_len], - seed=0, - num_step=2, + output = F.scaled_dot_product_attention( + query, + key, + value, + attn_mask=metadata.attn_mask, ) - assert tokens.shape == (1, config.num_audio_codebook, target_len) - assert tokens.dtype == torch.long + assert output.dtype == dtype + assert torch.isfinite(output).all() diff --git a/tests/model_executor/models/omnivoice/test_pipeline_batching.py b/tests/model_executor/models/omnivoice/test_pipeline_batching.py new file mode 100644 index 000000000..6e611f2ff --- /dev/null +++ b/tests/model_executor/models/omnivoice/test_pipeline_batching.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.diffusion.data import DiffusionOutput +from vllm_omni.diffusion.models.omnivoice.pipeline_omnivoice import ( + OmniVoicePipeline, + _PreparedOmniVoiceRequest, +) +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.model_executor.models.omnivoice.omnivoice_generator import ( + _attention_metadata_from_cu_seqs, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_sdpa_fallback_mask_preserves_packed_sequence_boundaries() -> None: + cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32) + + metadata = _attention_metadata_from_cu_seqs(cu_seqs, 6, needs_sdpa_mask=True) + + assert metadata.attn_mask is not None + expected = torch.tensor( + [ + [1, 1, 0, 0, 0, 0], + [1, 1, 0, 0, 0, 0], + [0, 0, 1, 0, 0, 0], + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1], + ], + dtype=torch.bool, + ) + torch.testing.assert_close(metadata.attn_mask[0, 0], expected) + + +def test_eager_attention_metadata_honors_exact_max_seqlen() -> None: + cu_seqs = torch.tensor([0, 2, 3, 6, 6], dtype=torch.int32) + + metadata = _attention_metadata_from_cu_seqs( + cu_seqs, + 6, + needs_sdpa_mask=False, + max_seqlen=3, + ) + + assert metadata.extra["max_seqlen_q"] == 3 + assert metadata.extra["max_seqlen_k"] == 3 + + +def test_request_batch_prepare_error_does_not_skip_valid_request() -> None: + """A malformed request must not prevent another request from completing.""" + prepared = _PreparedOmniVoiceRequest( + input_ids=torch.zeros(5, 8, dtype=torch.long), + audio_mask=torch.ones(5, dtype=torch.bool), + cond_len=3, + target_len=2, + seed=42, + ) + + def prepare_request(prompt, extra): + del extra + if prompt == "bad": + return DiffusionOutput(error="invalid prompt") + return prepared + + def collate_requests(requests): + assert requests == [prepared] + return prepared.input_ids, prepared.audio_mask, [prepared.cond_len] + + generator_calls = [] + + def generate_tokens(**kwargs): + generator_calls.append(kwargs) + assert kwargs["target_lens"] == [prepared.target_len] + return torch.ones(1, 8, prepared.target_len, dtype=torch.long) + + pipeline = SimpleNamespace( + _prepare_request_input=prepare_request, + _collate_request_inputs=collate_requests, + generator=generate_tokens, + decoder=lambda tokens: tokens.float(), + num_step=32, + guidance_scale=7.0, + t_shift=1.0, + layer_penalty_factor=0.0, + position_temperature=0.0, + class_temperature=0.0, + ) + sampling = OmniDiffusionSamplingParams(num_inference_steps=32, guidance_scale=7.0) + batch = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest(prompt="bad", sampling_params=sampling, request_id="bad"), + OmniDiffusionRequest( + prompt="good", + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=32, guidance_scale=7.0), + request_id="good", + ), + ] + ) + + outputs = OmniVoicePipeline.forward(pipeline, batch) + + assert len(outputs) == 2 + assert outputs[0].error == "invalid prompt" + assert outputs[1].error is None + torch.testing.assert_close(outputs[1].output, torch.ones(1, 8, 2)) + assert generator_calls[0]["guidance_scale"] == 7.0 + + +def test_request_batch_honors_explicit_sampling_overrides() -> None: + """Request steps and guidance must override the OmniVoice defaults.""" + prepared = _PreparedOmniVoiceRequest( + input_ids=torch.zeros(5, 8, dtype=torch.long), + audio_mask=torch.ones(5, dtype=torch.bool), + cond_len=3, + target_len=2, + seed=42, + ) + captured_sampling = [] + + def generate_tokens(**kwargs): + captured_sampling.append((kwargs["num_step"], kwargs["guidance_scale"])) + return torch.ones(1, 8, prepared.target_len, dtype=torch.long) + + pipeline = SimpleNamespace( + _prepare_request_input=lambda prompt, extra: prepared, + _collate_request_inputs=lambda requests: (prepared.input_ids, prepared.audio_mask, [prepared.cond_len]), + generator=generate_tokens, + decoder=lambda tokens: tokens.float(), + num_step=32, + guidance_scale=2.0, + t_shift=1.0, + layer_penalty_factor=0.0, + position_temperature=0.0, + class_temperature=0.0, + ) + batch = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest( + prompt="hello", + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=12, guidance_scale=6.5), + request_id="request", + ) + ] + ) + + outputs = OmniVoicePipeline.forward(pipeline, batch) + + assert outputs[0].error is None + assert captured_sampling == [(12, 6.5)] + + +def test_request_batch_prepare_error_preserves_surrounding_output_indices() -> None: + """A middle prepare error must not shift valid outputs into the wrong slots.""" + prepared_before = _PreparedOmniVoiceRequest( + input_ids=torch.zeros(3, 8, dtype=torch.long), + audio_mask=torch.ones(3, dtype=torch.bool), + cond_len=2, + target_len=1, + seed=1, + ) + prepared_after = _PreparedOmniVoiceRequest( + input_ids=torch.zeros(5, 8, dtype=torch.long), + audio_mask=torch.ones(5, dtype=torch.bool), + cond_len=3, + target_len=2, + seed=2, + ) + + def prepare_request(prompt, extra): + del extra + if prompt == "before": + return prepared_before + if prompt == "after": + return prepared_after + return DiffusionOutput(error="invalid middle request") + + def collate_requests(requests): + assert requests == [prepared_before, prepared_after] + return ( + torch.cat([prepared_before.input_ids, prepared_after.input_ids]), + torch.cat([prepared_before.audio_mask, prepared_after.audio_mask]), + [prepared_before.cond_len, prepared_after.cond_len], + ) + + def generate_tokens(**kwargs): + assert kwargs["target_lens"] == [1, 2] + before = torch.full((1, 8, 1), 11, dtype=torch.long) + after = torch.full((1, 8, 2), 22, dtype=torch.long) + return torch.cat([before, after], dim=-1) + + pipeline = SimpleNamespace( + _prepare_request_input=prepare_request, + _collate_request_inputs=collate_requests, + generator=generate_tokens, + decoder=lambda tokens: tokens.float(), + num_step=32, + guidance_scale=2.0, + t_shift=1.0, + layer_penalty_factor=0.0, + position_temperature=0.0, + class_temperature=0.0, + ) + batch = DiffusionRequestBatch( + requests=[ + OmniDiffusionRequest( + prompt=prompt, + sampling_params=OmniDiffusionSamplingParams(num_inference_steps=32), + request_id=prompt, + ) + for prompt in ("before", "bad", "after") + ] + ) + + outputs = OmniVoicePipeline.forward(pipeline, batch) + + assert len(outputs) == 3 + torch.testing.assert_close(outputs[0].output, torch.full((1, 8, 1), 11.0)) + assert outputs[1].error == "invalid middle request" + torch.testing.assert_close(outputs[2].output, torch.full((1, 8, 2), 22.0)) diff --git a/vllm_omni/deploy/omnivoice.yaml b/vllm_omni/deploy/omnivoice.yaml index 6769dd5e9..df9e6a5da 100644 --- a/vllm_omni/deploy/omnivoice.yaml +++ b/vllm_omni/deploy/omnivoice.yaml @@ -11,3 +11,5 @@ stages: trust_remote_code: true distributed_executor_backend: "mp" dtype: "float32" + max_num_seqs: 8 + request_batch_max_wait_ms: 10 diff --git a/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py b/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py index cfc40fcb6..bad1c6154 100644 --- a/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py +++ b/vllm_omni/diffusion/models/omnivoice/pipeline_omnivoice.py @@ -12,10 +12,13 @@ Uses request-mode execution (all steps in one forward() call). from __future__ import annotations import json +import math import os +import random import re -from collections.abc import Iterable -from typing import ClassVar +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Any, ClassVar import numpy as np import torch @@ -26,10 +29,17 @@ from vllm.logger import init_logger from vllm_omni.diffusion.data import DiffusionOutput, OmniDiffusionConfig from vllm_omni.diffusion.distributed.utils import get_local_device from vllm_omni.diffusion.models.interface import SupportAudioOutput +from vllm_omni.diffusion.worker.input_batch import InputBatch from vllm_omni.diffusion.worker.request_batch import DiffusionRequestBatch +from vllm_omni.diffusion.worker.utils import StepRequestState +from vllm_omni.errors import OmniClientError from vllm_omni.model_executor.models.omnivoice.duration import RuleDurationEstimator from vllm_omni.model_executor.models.omnivoice.omnivoice_decoder import OmniVoiceDecoder -from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import OmniVoiceGenerator +from vllm_omni.model_executor.models.omnivoice.omnivoice_generator import ( + OmniVoiceGenerator, + _build_cu_seqs, + _get_time_steps, +) from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig from vllm_omni.utils.speaker_cache import get_speaker_cache @@ -43,6 +53,15 @@ import torchaudio logger = init_logger(__name__) +@dataclass +class _PreparedOmniVoiceRequest: + input_ids: torch.Tensor + audio_mask: torch.Tensor + cond_len: int + target_len: int + seed: int | None + + def get_omnivoice_post_process_func(od_config: OmniDiffusionConfig): """Post-processing: convert audio tensor to numpy for WAV encoding.""" @@ -132,6 +151,8 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): """ support_audio_output: ClassVar[bool] = True + supports_request_batch: ClassVar[bool] = True + supports_step_execution: ClassVar[bool] = True def __init__(self, *, od_config: OmniDiffusionConfig, prefix: str = ""): super().__init__() @@ -152,7 +173,7 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): self.config = OmniVoiceConfig(**hf_config) # Build generator and decoder - self.generator = OmniVoiceGenerator(self.config) + self.generator = OmniVoiceGenerator(self.config, od_config) self.decoder = OmniVoiceDecoder(self.config) # Tokenizer (low-level, avoids HF tokenizer extra_special_tokens issue) @@ -205,50 +226,36 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): tokens = tokens.squeeze(0) # [8, T_ref] return tokens - @torch.inference_mode() - def forward(self, req: DiffusionRequestBatch) -> DiffusionOutput: - """Generate speech audio from text, optionally with voice cloning. - - Accepts either a plain text prompt or a structured dict: - {"text": "...", "ref_audio": (samples, sr), "ref_text": "...", - "lang": "...", "instruct": "..."} - """ - prompt = req.prompts[0] if req.prompts else "" + def _prepare_request_input( + self, + prompt: Any, + extra: dict[str, Any], + ) -> _PreparedOmniVoiceRequest | DiffusionOutput: + """Build one request's conditional/unconditional model inputs.""" ref_audio = None ref_text = None lang = "None" instruct = "None" - extra = req.sampling_params.extra_args or {} + voice_name = None seed = extra.get("seed", None) - voice_name = None if isinstance(prompt, dict): - # Top-level keys (used by serving_speech.py /v1/audio/speech path) text = prompt.get("input") or prompt.get("text") or prompt.get("prompt") ref_audio = prompt.get("ref_audio") ref_text = prompt.get("ref_text") voice_name = prompt.get("voice_name") lang = prompt.get("lang") instruct = prompt.get("instruct") - # OmniTextPrompt format (used by offline Omni.generate path): - # ref_audio comes via multi_modal_data["audio"] and the rest via - # mm_processor_kwargs. Fall back to those when top-level keys are - # absent so both invocation styles work. mm_data = prompt.get("multi_modal_data") or {} mm_kwargs = prompt.get("mm_processor_kwargs") or {} if ref_audio is None: audio_field = mm_data.get("audio") - # Standard multimodal shape allows a list of audios; OmniVoice - # voice cloning conditions on a single reference clip, so - # unwrap a length-1 list and reject multi-reference prompts up - # front (otherwise a list would later crash inside - # ``_encode_ref_audio`` when it calls ``audio.dim()``). if isinstance(audio_field, list): if len(audio_field) == 1: audio_field = audio_field[0] elif len(audio_field) > 1: return DiffusionOutput( - error=f"OmniVoice voice cloning supports a single reference audio; got {len(audio_field)}" # noqa: E501 + error=f"OmniVoice voice cloning supports a single reference audio; got {len(audio_field)}" ) else: audio_field = None @@ -264,7 +271,6 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): lang = mm_kwargs.get("lang") if instruct is None: instruct = mm_kwargs.get("instruct") - if not text: return DiffusionOutput(error="Empty text prompt") lang = lang or "None" @@ -274,105 +280,325 @@ class OmniVoicePipeline(nn.Module, SupportAudioOutput): if not text: return DiffusionOutput(error="Empty text prompt") - device = self.device - num_cb = self.config.num_audio_codebook - mask_id = self.config.audio_mask_id - - # Estimate target duration target_len = self.duration_estimator.estimate_duration(text, "Nice to meet you.", 25) target_len = max(1, int(target_len)) - # Build text prompt with control tokens style_text = f"<|denoise|><|lang_start|>{lang}<|lang_end|><|instruct_start|>{instruct}<|instruct_end|>" full_text = _combine_text(ref_text=ref_text, text=text) wrapped_text = f"<|text_start|>{full_text}<|text_end|>" style_tokens = self.tokenizer.encode(style_text).ids text_tokens = _tokenize_with_nonverbal_tags(wrapped_text, self.tokenizer) encoding_ids = style_tokens + text_tokens - text_tokens = torch.tensor(encoding_ids, dtype=torch.long, device=device) - text_len = text_tokens.shape[0] + text_tokens_tensor = torch.tensor(encoding_ids, dtype=torch.long, device=self.device) + text_len = text_tokens_tensor.shape[0] - # Encode reference audio tokens if provided (with voice caching) ref_audio_tokens = None if ref_audio is not None: if self.audio_tokenizer is None: raise RuntimeError( "Voice cloning requires transformers>=5.3.0. Try: uv pip install 'transformers>=5.3.0'" ) - # Check speaker cache first - _cache_key = None + cache_key = None if voice_name: - _cache_key = self._speaker_cache.make_cache_key( + cache_key = self._speaker_cache.make_cache_key( voice_name, model_type="omnivoice", created_at=int(prompt.get("voice_created_at") or 0), ) - cached = self._speaker_cache.get(_cache_key) + cached = self._speaker_cache.get(cache_key) if cached is not None: - ref_audio_tokens = cached["ref_audio_tokens"].to(device) - _cache_key = None # hit → don't store again + ref_audio_tokens = cached["ref_audio_tokens"].to(self.device) + cache_key = None logger.debug("Speaker cache HIT for OmniVoice speaker '%s'", voice_name) if ref_audio_tokens is None: audio_signal, sr = ref_audio if isinstance(audio_signal, np.ndarray): audio_signal = torch.from_numpy(audio_signal).float() - ref_audio_tokens = self._encode_ref_audio(audio_signal, int(sr)).to(device) - - # Store in cache for next request - if _cache_key is not None: - self._speaker_cache.put(_cache_key, {"ref_audio_tokens": ref_audio_tokens.cpu()}) + ref_audio_tokens = self._encode_ref_audio(audio_signal, int(sr)).to(self.device) + if cache_key is not None: + self._speaker_cache.put(cache_key, {"ref_audio_tokens": ref_audio_tokens.cpu()}) logger.debug("Speaker cache STORE for OmniVoice speaker '%s'", voice_name) - # Build conditional + unconditional batches [2, 8, max_len] - text_ids = text_tokens.unsqueeze(0).repeat(num_cb, 1) - target_ids = torch.full((num_cb, target_len), mask_id, dtype=torch.long, device=device) - - if ref_audio_tokens is not None: - cond_ids = torch.cat([text_ids, ref_audio_tokens, target_ids], dim=1) - else: - cond_ids = torch.cat([text_ids, target_ids], dim=1) + num_cb = self.config.num_audio_codebook + mask_id = self.config.audio_mask_id + text_ids = text_tokens_tensor.unsqueeze(0).repeat(num_cb, 1) + target_ids = torch.full((num_cb, target_len), mask_id, dtype=torch.long, device=self.device) + cond_ids = ( + torch.cat([text_ids, ref_audio_tokens, target_ids], dim=1) + if ref_audio_tokens is not None + else torch.cat([text_ids, target_ids], dim=1) + ) cond_len = cond_ids.shape[1] uncond_ids = target_ids.clone() - uncond_len = target_len - max_len = max(cond_len, uncond_len) - if uncond_len < max_len: - pad = torch.full( - (num_cb, max_len - uncond_len), - mask_id, - dtype=torch.long, - device=device, + input_ids = torch.cat([cond_ids, uncond_ids], dim=1).transpose(0, 1).contiguous() + + max_len = input_ids.shape[0] + audio_mask = torch.zeros(max_len, dtype=torch.bool, device=self.device) + audio_mask[text_len:] = True + + return _PreparedOmniVoiceRequest( + input_ids=input_ids, + audio_mask=audio_mask, + cond_len=cond_len, + target_len=target_len, + seed=seed, + ) + + def _collate_request_inputs( + self, + prepared_requests: Sequence[_PreparedOmniVoiceRequest], + ) -> tuple[torch.Tensor, torch.Tensor, list[int]]: + """Pack request-major [cond, uncond] token sequences.""" + input_ids: list[torch.Tensor] = [] + audio_masks: list[torch.Tensor] = [] + cond_lens: list[int] = [] + + for request in prepared_requests: + input_id = request.input_ids + audio_mask = request.audio_mask + cond_len = request.cond_len + input_ids.append(input_id) + audio_masks.append(audio_mask) + cond_lens.append(cond_len) + + input_ids = torch.cat(input_ids, dim=0) + audio_masks = torch.cat(audio_masks, dim=0) + + return input_ids, audio_masks, cond_lens + + def prepare_encode(self, state: StepRequestState) -> StepRequestState: + prompt = state.prompt if state.prompt else "" + extra = state.sampling.extra_args or {} + prepared = self._prepare_request_input(prompt, extra) + if isinstance(prepared, DiffusionOutput): + raise OmniClientError(prepared.error or "OmniVoice request preparation failed") + + prepared_request = prepared + cond_len = prepared_request.cond_len + target_len = prepared_request.target_len + input_ids = prepared_request.input_ids + audio_mask = prepared_request.audio_mask + seed = prepared_request.seed + device = self.device + mask_id = self.config.audio_mask_id + num_codebooks = self.config.num_audio_codebook + if seed is None: + seed = random.randint(0, 2**63 - 1) + num_step = ( + state.sampling.num_inference_steps if state.sampling.num_inference_steps is not None else self.num_step + ) + + t_shift = self.t_shift + + # Initialize all target tokens as [MASK] + tokens = torch.full((1, num_codebooks, target_len), mask_id, dtype=torch.long, device=device) + + timesteps = _get_time_steps(0.0, 1.0, num_step + 1, t_shift) + + # Compute unmasking schedule + schedules = [] + total_mask = target_len * num_codebooks + rem = total_mask + sched = [] + for step in range(num_step): + num = ( + rem + if step == num_step - 1 + else min( + math.ceil(total_mask * (timesteps[step + 1] - timesteps[step])), + rem, + ) ) - uncond_ids = torch.cat([uncond_ids, pad], dim=1) + sched.append(int(num)) + rem -= int(num) + schedules = torch.tensor(sched, dtype=torch.long, device=device) - batch_input_ids = torch.stack([cond_ids, uncond_ids]) + layer_ids = torch.arange(num_codebooks, device=device).view(1, -1, 1) + generator = torch.Generator(device=device).manual_seed(seed) - batch_audio_mask = torch.zeros(2, max_len, dtype=torch.bool, device=device) - batch_audio_mask[0, text_len:cond_len] = True - batch_audio_mask[1, :uncond_len] = True + guidance_scale = ( + state.sampling.guidance_scale if state.sampling.guidance_scale is not None else self.guidance_scale + ) + state.latents = input_ids + state.timesteps = schedules + state.guidance = guidance_scale + state.extra["schedules"] = schedules + state.extra["layer_ids"] = layer_ids + state.extra["generator"] = generator + state.extra["t_shift"] = t_shift + state.extra["cond_len"] = cond_len + state.extra["target_len"] = target_len + state.extra["audio_mask"] = audio_mask + state.extra["tokens"] = tokens + return state - batch_attn_mask = torch.zeros(2, 1, max_len, max_len, dtype=torch.bool, device=device) - batch_attn_mask[0, :, :cond_len, :cond_len] = True - batch_attn_mask[1, :, :uncond_len, :uncond_len] = True + def denoise_step(self, input_batch: InputBatch, *, states: Sequence[StepRequestState] | None = None, **kwargs: Any): + input_ids = input_batch.latents + use_cuda_graph = self.generator._cuda_graph_fwd is not None and input_ids.is_cuda + layer_ids = states[0].extra["layer_ids"] + audio_masks: list[torch.Tensor] = [] + target_lens: list[int] = [] + batch_tokens: list[torch.Tensor] = [] + cond_lens: list[int] = [] + + steps: list[int] = [] + schedules: list[torch.Tensor] = [] + generators: list[torch.Generator] = [] + guidance_scales: list[float] = [] + + for state in states: + audio_masks.append(state.extra.get("audio_mask", None)) + cond_lens.append(state.extra["cond_len"]) + target_lens.append(state.extra["target_len"]) + batch_tokens.append(state.extra["tokens"]) + guidance_scales.append(state.guidance) + generators.append(state.extra.get("generator", None)) + schedules.append(state.extra["schedules"]) + steps.append(state.step_index) + + audio_masks = torch.cat(audio_masks, dim=0) + + B = len(target_lens) + cu_seqs = _build_cu_seqs(cond_lens, target_lens, input_ids.device) + + position_temperature = self.position_temperature + class_temperature = self.class_temperature + layer_penalty_factor = self.layer_penalty_factor + if use_cuda_graph: + # Replay a fixed packed-token bucket with dynamic varlen metadata. + batch_logits = self.generator._cuda_graph_fwd(input_ids, audio_masks, cu_seqs, B) + else: + # Run packed eager attention for the current active requests. + inputs_embeds = self.generator._prepare_embeddings(input_ids, audio_masks) + hidden_states = self.generator._transformer_forward( + inputs_embeds, + cu_seqs, + max_seqlen=max(cond_lens), + ) + # fp32 cast deferred to the per-item slices below. + batch_logits = self.generator._get_logits(hidden_states) + # batch_logits: [8, total_seq_len, 1025] + + target_offsets: list[int] = [] + target_offset = 0 + for target_len in target_lens: + target_offsets.append(target_offset) + target_offset += target_len + + sequence_offsets: list[int] = [] + sequence_offset = 0 + for cond_len, target_len in zip(cond_lens, target_lens): + sequence_offsets.append(sequence_offset) + sequence_offset += cond_len + target_len + + for i in range(B): + k = schedules[i][steps[i]] + if k <= 0: + continue + + c_len = cond_lens[i] + t_len = target_lens[i] + + # Extract logits for target region; upcast only the slices we actually consume. + request_start = sequence_offsets[i] + cond_end = request_start + c_len + uncond_start = cond_end + + # Extract logits for target region; upcast only the slices we actually consume. + c_logits = batch_logits[:, cond_end - t_len : cond_end, :].unsqueeze(0).to(torch.float32) + u_logits = batch_logits[:, uncond_start : uncond_start + t_len, :].unsqueeze(0).to(torch.float32) + sample = batch_tokens[i] + sample_tokens = sample[..., :t_len] + self.generator._unmask_one_request( + c_logits, + u_logits, + sample_tokens, + num_to_unmask=k, + guidance_scale=guidance_scales[i], + generator=generators[i], + class_temperature=class_temperature, + position_temperature=position_temperature, + layer_penalty_factor=layer_penalty_factor, + layer_ids=layer_ids, + ) + + # Mirror update into both cond and uncond input_ids halves for the next step. + packed_sample_tokens = sample_tokens.squeeze(0).transpose(0, 1) + input_ids[cond_end - t_len : cond_end] = packed_sample_tokens + input_ids[uncond_start : uncond_start + t_len] = packed_sample_tokens + states[i].extra["tokens"] = sample_tokens + + # InputBatch reuses its latents buffer across steps. Returning that + # same storage would make the Runner persist per-request views into the + # cached destination; the next make_batch() would then copy overlapping + # source/destination slices. Break the alias at the lifecycle boundary. + return input_ids.clone() + + def step_scheduler(self, state: StepRequestState, noise_pred: torch.Tensor, **kwargs: Any): + state.latents = noise_pred + state.step_index += 1 + + def post_decode(self, state: StepRequestState, **kwargs: Any): + tokens = state.extra["tokens"] + if tokens.dim() == 2: + tokens = tokens.unsqueeze(0) + audio = self.decoder(tokens) + return DiffusionOutput(output=audio) + + @torch.inference_mode() + def forward(self, req: DiffusionRequestBatch) -> list[DiffusionOutput]: + """Generate speech audio from text, optionally with voice cloning. + + Accepts either a plain text prompt or a structured dict: + {"text": "...", "ref_audio": (samples, sr), "ref_text": "...", + "lang": "...", "instruct": "..."} + """ + prepared_requests: list[_PreparedOmniVoiceRequest] = [] + outputs = [None] * len(req.requests) + prepared_indices: list[int] = [] + for i, request in enumerate(req.requests): + prompt = request.prompt if request.prompt else "" + extra = request.sampling_params.extra_args or {} + prepared = self._prepare_request_input(prompt, extra) + if isinstance(prepared, DiffusionOutput): + outputs[i] = prepared + continue + prepared_indices.append(i) + prepared_requests.append(prepared) + + if not prepared_requests: + return outputs + + batch_target_len = [request.target_len for request in prepared_requests] + batch_seeds = [request.seed for request in prepared_requests] + batch_input_ids, batch_audio_mask, batch_cond_lens = self._collate_request_inputs(prepared_requests) # Run 32-step iterative unmasking + sampling = req.requests[0].sampling_params + num_step = sampling.num_inference_steps if sampling.num_inference_steps is not None else self.num_step + guidance_scale = sampling.guidance_scale if sampling.guidance_scale is not None else self.guidance_scale tokens = self.generator( input_ids=batch_input_ids, audio_mask=batch_audio_mask, - attention_mask=batch_attn_mask, - target_lens=[target_len], - num_step=self.num_step, - guidance_scale=self.guidance_scale, + cond_lens=batch_cond_lens, + target_lens=batch_target_len, + num_step=num_step, + guidance_scale=guidance_scale, t_shift=self.t_shift, layer_penalty_factor=self.layer_penalty_factor, position_temperature=self.position_temperature, class_temperature=self.class_temperature, - seed=seed, + seed=batch_seeds, ) - # Decode tokens to audio - audio = self.decoder(tokens) # [1, 1, samples] - return DiffusionOutput(output=audio) + target_offset = 0 + for i, target_len in enumerate(batch_target_len): + request_tokens = tokens[:, :, target_offset : target_offset + target_len] + audio = self.decoder(request_tokens) + outputs[prepared_indices[i]] = DiffusionOutput(output=audio) + target_offset += target_len + return outputs def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Load weights from model directory (not from the iterator). diff --git a/vllm_omni/diffusion/worker/utils.py b/vllm_omni/diffusion/worker/utils.py index 5158dbfbf..efc935a5d 100644 --- a/vllm_omni/diffusion/worker/utils.py +++ b/vllm_omni/diffusion/worker/utils.py @@ -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 """Per-request mutable state for step-wise diffusion execution.""" from __future__ import annotations diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 50f459a58..75a8a435e 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -2113,6 +2113,25 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if sampling_params_list[0].extra_args is None: sampling_params_list[0].extra_args = {} sampling_params_list[0].extra_args.update(extra) + + sampling = sampling_params_list[0] + + # This change allows StepScheduler read total_steps from upper + # sampling.num_inference_steps, check diffusion/sched/step_scheduler:_get_total_steps + if "num_inference_steps" in extra: + value = extra["num_inference_steps"] + try: + sampling.num_inference_steps = int(value) + except (TypeError, ValueError) as exc: + raise ValueError("num_inference_steps must be an integer") from exc + + if "guidance_scale" in extra: + value = extra["guidance_scale"] + try: + sampling.guidance_scale = float(value) + except (TypeError, ValueError) as exc: + raise ValueError("guidance_scale must be a number") from exc + logger.info("Applied extra_params to diffusion: %s", extra) generator = self._diffusion_engine.generate( diff --git a/vllm_omni/model_executor/models/omnivoice/omnivoice_generator.py b/vllm_omni/model_executor/models/omnivoice/omnivoice_generator.py index a75c4e6f7..965d021b8 100644 --- a/vllm_omni/model_executor/models/omnivoice/omnivoice_generator.py +++ b/vllm_omni/model_executor/models/omnivoice/omnivoice_generator.py @@ -6,9 +6,8 @@ OmniVoice Generator (Stage 0) - Iterative unmasking with Qwen3 backbone. Generates 8-codebook audio tokens from text via 32-step non-autoregressive iterative masked prediction with classifier-free guidance. -Uses full bidirectional attention computed directly with PyTorch SDPA -(torch.nn.functional.scaled_dot_product_attention); no auto-selected -FlashAttention/SageAttention/DiffusionAttention backend is used. +Uses backend-dispatched variable-length full bidirectional attention over +packed conditional and unconditional sequences, with an SDPA mask fallback. """ from __future__ import annotations @@ -22,7 +21,11 @@ import torch import torch.nn as nn import torch.nn.functional as F from vllm.logger import init_logger +from vllm.utils.math_utils import round_up +from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata +from vllm_omni.diffusion.attention.layer import Attention +from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.model_executor.models.omnivoice.fused_qkv_rope import fused_qkv_norm_rope from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig @@ -241,7 +244,7 @@ def _gumbel_sample(logits: torch.Tensor, temperature: float, generator: torch.Ge # --------------------------------------------------------------------------- -# Qwen3-style transformer blocks using PyTorch SDPA +# Qwen3-style transformer blocks using PyTorch variable-length attention # --------------------------------------------------------------------------- @@ -261,9 +264,9 @@ class OmniVoiceRMSNorm(nn.Module): class OmniVoiceAttention(nn.Module): - """Qwen3-style GQA attention using PyTorch SDPA (full bidirectional).""" + """Qwen3-style GQA using packed, full-bidirectional varlen attention.""" - def __init__(self, config: OmniVoiceConfig): + def __init__(self, config: OmniVoiceConfig, layer_idx: int): super().__init__() self.hidden_size = config.llm_hidden_size self.num_heads = config.llm_num_attention_heads @@ -282,16 +285,27 @@ class OmniVoiceAttention(nn.Module): self.k_norm = OmniVoiceRMSNorm(self.head_dim) self.scale = 1.0 / math.sqrt(self.head_dim) + self.attention_op = Attention( + num_heads=self.num_heads, + num_kv_heads=self.num_heads, + head_size=self.head_dim, + causal=False, + softmax_scale=self.scale, + prefix=f"layers.{layer_idx}.self_attn.attention_op", + qkv_layout="BSND", + skip_sequence_parallel=True, + ) + self.use_packed_varlen = self.attention_op.attn_backend.supports_multi_doc_packed_varlen() def forward( self, hidden_states: torch.Tensor, rope_table: torch.Tensor, - attention_mask: torch.Tensor | None = None, + attn_metadata: AttentionMetadata, ) -> torch.Tensor: - batch_size, seq_len, _ = hidden_states.shape + seq_len, _ = hidden_states.shape - qkv = self.qkv_proj(hidden_states).view(batch_size, seq_len, self.num_qkv_heads, self.head_dim) + qkv = self.qkv_proj(hidden_states).view(1, seq_len, self.num_qkv_heads, self.head_dim) # One kernel for the whole prologue: split the packed projection, RMSNorm # Q and K per head, rotate both, broadcast K and V across their query @@ -306,22 +320,16 @@ class OmniVoiceAttention(nn.Module): self.num_kv_heads, ) - # Caller passes a float mask; materialize float form if a bool slips through. - sdpa_mask = attention_mask - if sdpa_mask is not None and sdpa_mask.dtype == torch.bool: - sdpa_mask = torch.zeros_like(attention_mask, dtype=q.dtype).masked_fill_(~attention_mask, float("-inf")) + # The fused prologue emits [B, N, S, D]; Omni attention backends use + # [B, S, N, D]. Keep the conversion outside the backend. + q, k, v = (tensor.permute(0, 2, 1, 3).contiguous().to(torch.bfloat16) for tensor in (q, k, v)) + if self.use_packed_varlen: + out = self.attention_op(q, k, v, attn_metadata) + else: + out = self.attention_op.sdpa_fallback.forward(q, k, v, attn_metadata) - out = F.scaled_dot_product_attention( - q, - k, - v, - attn_mask=sdpa_mask, - scale=self.scale, - ) - - # Back to (batch, seq, heads * head_dim) - out = out.permute(0, 2, 1, 3).contiguous() - out = out.view(batch_size, seq_len, self.num_heads * self.head_dim) + out = out.squeeze(0).to(hidden_states.dtype) + out = out.reshape(seq_len, self.num_heads * self.head_dim) return self.o_proj(out) @@ -351,19 +359,19 @@ _FUSED_PROJECTIONS: dict[str, tuple[str, ...]] = { class OmniVoiceTransformerBlock(nn.Module): - """Single Qwen3 transformer block with PyTorch SDPA attention.""" + """Single Qwen3 transformer block with variable-length attention.""" - def __init__(self, config: OmniVoiceConfig): + def __init__(self, config: OmniVoiceConfig, layer_idx: int): super().__init__() self.input_layernorm = OmniVoiceRMSNorm(config.llm_hidden_size, eps=config.llm_rms_norm_eps) - self.self_attn = OmniVoiceAttention(config) + self.self_attn = OmniVoiceAttention(config, layer_idx) self.post_attention_layernorm = OmniVoiceRMSNorm(config.llm_hidden_size, eps=config.llm_rms_norm_eps) self.mlp = OmniVoiceMLP(config) def forward( self, hidden_states: torch.Tensor, - attention_mask: torch.Tensor | None = None, + attn_metadata: AttentionMetadata, rope_table: torch.Tensor | None = None, residual: torch.Tensor | None = None, ) -> tuple[torch.Tensor, torch.Tensor]: @@ -391,7 +399,7 @@ class OmniVoiceTransformerBlock(nn.Module): residual = hidden_states hidden_states = self.input_layernorm(hidden_states) - hidden_states = self.self_attn(hidden_states, rope_table, attention_mask=attention_mask) + hidden_states = self.self_attn(hidden_states, rope_table, attn_metadata) if _TRITON_AVAILABLE: # Fused: (attn_out + residual) + RMSNorm in one kernel @@ -432,6 +440,63 @@ def _precompute_rope_table( return torch.cat([freqs.cos(), freqs.sin()], dim=-1) +def _position_ids_from_cu_seqs(cu_seqs: torch.Tensor, seq_len: int) -> torch.Tensor: + """Return each packed token's zero-based position within its sequence.""" + token_ids = torch.arange(seq_len, device=cu_seqs.device, dtype=cu_seqs.dtype) + sequence_ids = torch.searchsorted(cu_seqs[1:], token_ids, right=True) + sequence_starts = cu_seqs.index_select(0, sequence_ids.to(torch.long)) + return (token_ids - sequence_starts).to(torch.long) + + +def _attention_metadata_from_cu_seqs( + cu_seqs: torch.Tensor, + seq_len: int, + *, + needs_sdpa_mask: bool, + max_seqlen: int | None = None, +) -> AttentionMetadata: + """Build packed-varlen metadata and, when needed, an SDPA block mask.""" + # Local benchmarks found no measurable performance difference between the + # exact longest segment and the packed total. Eager uses the exact bound; + # CUDA Graph uses the fixed token-bucket maximum. + kernel_max_seqlen = seq_len if max_seqlen is None else max_seqlen + extra = { + "cu_seqlens_q": cu_seqs, + "cu_seqlens_k": cu_seqs, + "max_seqlen_q": kernel_max_seqlen, + "max_seqlen_k": kernel_max_seqlen, + } + if not needs_sdpa_mask: + return AttentionMetadata(extra=extra) + + token_ids = torch.arange(seq_len, device=cu_seqs.device, dtype=cu_seqs.dtype) + sequence_ids = torch.searchsorted(cu_seqs[1:], token_ids, right=True) + attn_mask = sequence_ids.view(1, 1, seq_len, 1) == sequence_ids.view(1, 1, 1, seq_len) + return AttentionMetadata(attn_mask=attn_mask, extra=extra) + + +def _build_cu_seqs( + cond_lens: list[int], + uncond_lens: list[int], + device: torch.device, + *, + tail_end: int | None = None, +) -> torch.Tensor: + """Build request-major [cond0, uncond0, ...] cumulative offsets.""" + if len(cond_lens) != len(uncond_lens): + raise ValueError(f"Mismatched cond/uncond lengths: {len(cond_lens)} != {len(uncond_lens)}.") + offsets = [0] + for cond_len, uncond_len in zip(cond_lens, uncond_lens): + offsets.append(offsets[-1] + cond_len) + offsets.append(offsets[-1] + uncond_len) + if tail_end is None: + tail_end = offsets[-1] + if offsets[-1] > tail_end: + raise ValueError(f"Packed length {offsets[-1]} exceeds tail end {tail_end}.") + offsets.append(tail_end) + return torch.tensor(offsets, device=device, dtype=torch.int32) + + # --------------------------------------------------------------------------- # TF32 opt-in (process-wide; default off) # --------------------------------------------------------------------------- @@ -461,23 +526,6 @@ def _maybe_enable_tf32() -> None: # --------------------------------------------------------------------------- -def _additive_float_mask(mask: torch.Tensor, dtype: torch.dtype) -> torch.Tensor: - """Convert a boolean attention mask to its additive float form. - - ``True`` (attend) maps to ``0.0`` and ``False`` (masked) to ``-inf``. A bool - mask must never be copied straight into a float buffer: the implicit cast - maps True/False to 1.0/0.0, which leaves masked positions at 0.0 and so - silently *unmasks* them. - - ``dtype`` is required rather than defaulting to float32: SDPA rejects an - additive mask whose dtype differs from the query, so the mask has to follow - the model dtype and a default would just hide that coupling. - """ - if mask.dtype != torch.bool: - return mask - return torch.zeros_like(mask, dtype=dtype).masked_fill_(~mask, float("-inf")) - - class _OmniVoiceCUDAGraphForward: """Pre-captures CUDA graphs for predefined sequence-length buckets. @@ -486,24 +534,47 @@ class _OmniVoiceCUDAGraphForward: (one step at a time) means pool sharing is safe. """ - # Default bucket count is 10; 16 gives modest headroom for edge cases - # (seq_len > max bucket or non-CFG batch) without unbounded GPU growth. - _MAX_LAZY_GRAPHS: int = 16 + _MAX_LAZY_GRAPHS = 16 + _STATIC_CAPTURE_TOKEN_LIMIT = 1024 + _LAZY_CAPTURE_ALIGNMENT = 128 def __init__(self, generator: OmniVoiceGenerator, capture_sizes: list[int]) -> None: self._gen = generator - self._capture_sizes = sorted(capture_sizes) - # Pre-warmed graphs keyed by (two_b, bucket); fixed set, never evicted. + self.capture_batch_sizes = self._derive_capture_batch_size() + self.capture_bucket_sizes_by_batch = self._derive_capture_bucket_sizes(capture_sizes) self._graphs: dict[tuple[int, int], dict] = {} - # Lazy-captured graphs for oversized / non-CFG shapes; capped via LRU. self._lazy_graphs: OrderedDict[tuple[int, int], dict] = OrderedDict() self._lock = threading.Lock() # Per-instance pool handle: isolates OmniVoice CUDA memory from other # vllm modules while still allowing safe re-use across sequential replays. self._pool_handle: int | None = None - def _find_bucket(self, seq_len: int) -> int | None: - for bucket in self._capture_sizes: + def _derive_capture_batch_size(self) -> list[int]: + return list(range(1, self._gen.od_config.max_num_seqs + 1)) + + def _derive_capture_bucket_sizes(self, capture_sizes: list[int]) -> dict[int, list[int]]: + """Build a triangular, batch-aware token-bucket capture plan.""" + base_sizes = sorted(set(capture_sizes)) + if not base_sizes: + return {batch_size: [] for batch_size in self.capture_batch_sizes} + alignment = base_sizes[0] + single_request_cap = min(base_sizes[-1], 512) + max_graph_tokens = min(base_sizes[-1], self._STATIC_CAPTURE_TOKEN_LIMIT) + growth_per_batch = max(alignment, single_request_cap // 2) + candidates = sorted(set(base_sizes) | set(range(alignment, max_graph_tokens + alignment, alignment))) + return { + batch_size: [ + bucket + for bucket in candidates + if alignment * batch_size + <= bucket + <= min(single_request_cap + (batch_size - 1) * growth_per_batch, max_graph_tokens) + ] + for batch_size in self.capture_batch_sizes + } + + def _find_bucket(self, batch_size: int, seq_len: int) -> int | None: + for bucket in self.capture_bucket_sizes_by_batch.get(batch_size, ()): if bucket >= seq_len: return bucket return None @@ -512,57 +583,36 @@ class _OmniVoiceCUDAGraphForward: self, input_ids: torch.Tensor, audio_mask: torch.Tensor, - attention_mask: torch.Tensor | None, bucket: int, - ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor | None]: - S = input_ids.shape[-1] - if S == bucket: - return input_ids, audio_mask, attention_mask - - two_b = input_ids.shape[0] - num_cb = input_ids.shape[1] - - ids_padded = torch.zeros(two_b, num_cb, bucket, dtype=input_ids.dtype, device=input_ids.device) - ids_padded[:, :, :S] = input_ids - - mask_padded = torch.zeros(two_b, bucket, dtype=torch.bool, device=audio_mask.device) - mask_padded[:, :S] = audio_mask - - if attention_mask is not None: - # Callers normalize to the additive float form first, so pad with -inf. - attn_padded = torch.full( - (two_b, 1, bucket, bucket), - float("-inf"), - dtype=attention_mask.dtype, - device=attention_mask.device, - ) - attn_padded[:, :, :S, :S] = attention_mask - else: - attn_padded = None - - return ids_padded, mask_padded, attn_padded + ) -> tuple[torch.Tensor, torch.Tensor]: + seq_len = input_ids.shape[0] + if seq_len == bucket: + return input_ids, audio_mask + return ( + F.pad(input_ids, (0, 0, 0, bucket - seq_len), value=0), + F.pad(audio_mask, (0, bucket - seq_len), value=False), + ) def _capture_for_key( self, key: tuple[int, int], input_ids: torch.Tensor, audio_mask: torch.Tensor, - attention_mask: torch.Tensor | None, + cu_seqs: torch.Tensor, ) -> dict: _, bucket = key device = input_ids.device - static_rope_table = self._gen._rope_table_for(bucket, device, self._gen.model_dtype) - static_input_ids = input_ids.clone() static_audio_mask = audio_mask.clone() - static_attn_mask = attention_mask.clone() if attention_mask is not None else None + static_cu_seqs = cu_seqs.clone() + static_rope_table = self._gen._rope_table_for(bucket, device, self._gen.model_dtype) with torch.no_grad(): _ = self._gen._step_forward( static_input_ids, static_audio_mask, - static_attn_mask, + static_cu_seqs, static_rope_table, ) torch.accelerator.synchronize(device) @@ -580,7 +630,7 @@ class _OmniVoiceCUDAGraphForward: static_output = self._gen._step_forward( static_input_ids, static_audio_mask, - static_attn_mask, + static_cu_seqs, static_rope_table, ) @@ -588,87 +638,83 @@ class _OmniVoiceCUDAGraphForward: "graph": graph, "static_input_ids": static_input_ids, "static_audio_mask": static_audio_mask, - "static_attn_mask": static_attn_mask, + "static_cu_seqs": static_cu_seqs, "static_rope_table": static_rope_table, "static_output": static_output, } logger.info("OmniVoice CUDA Graph captured for key %s", key) return entry + def make_capture_cu_seq(self, batch_size: int, bucket_size: int, device: torch.device) -> torch.Tensor: + num_real_sequences = 2 * batch_size + base, rem = divmod(bucket_size, num_real_sequences) + lengths = torch.full((num_real_sequences,), base, dtype=torch.int32, device=device) + lengths[:rem] += 1 + cu_seq = torch.empty(num_real_sequences + 2, dtype=torch.int32, device=device) + cu_seq[0] = 0 + cu_seq[1:-1] = lengths.cumsum(0) + cu_seq[-1] = bucket_size + return cu_seq + def warmup(self, device: torch.device) -> None: - """Pre-capture graphs for all bucket sizes with B=1 (two_b=2 for CFG).""" + """Pre-capture common request batch sizes for every token bucket.""" if not torch.cuda.is_available(): return logger.info( - "OmniVoice CUDA Graph warmup: capturing %d bucket sizes %s", - len(self._capture_sizes), - self._capture_sizes, + "OmniVoice CUDA Graph warmup: batch-aware capture plan %s", + self.capture_bucket_sizes_by_batch, ) - two_b = 2 num_cb = self._gen.config.num_audio_codebook - for bucket in self._capture_sizes: - key = (two_b, bucket) - dummy_ids = torch.zeros(two_b, num_cb, bucket, dtype=torch.long, device=device) - dummy_mask = torch.zeros(two_b, bucket, dtype=torch.bool, device=device) - # Capture with a float mask to match what forward() feeds at replay time, - # in the model dtype so replay can copy_ into it without a cast. - dummy_attn = torch.zeros(two_b, 1, bucket, bucket, dtype=self._gen.model_dtype, device=device) - self._graphs[key] = self._capture_for_key(key, dummy_ids, dummy_mask, dummy_attn) + for batch_size in self.capture_batch_sizes: + for bucket in self.capture_bucket_sizes_by_batch[batch_size]: + key = (batch_size, bucket) + dummy_ids = torch.zeros(bucket, num_cb, dtype=torch.long, device=device) + dummy_mask = torch.zeros(bucket, dtype=torch.bool, device=device) + dummy_cu_seqs = self.make_capture_cu_seq(batch_size, bucket, device) + self._graphs[key] = self._capture_for_key(key, dummy_ids, dummy_mask, dummy_cu_seqs) logger.info("OmniVoice CUDA Graph warmup complete (%d graphs)", len(self._graphs)) def __call__( self, input_ids: torch.Tensor, audio_mask: torch.Tensor, - attention_mask: torch.Tensor | None, + cu_seqs: torch.Tensor, + batch_size: int, ) -> torch.Tensor: if torch.cuda.is_current_stream_capturing(): - rope_table = self._gen._rope_table_for(input_ids.shape[-1], input_ids.device, self._gen.model_dtype) - return self._gen._step_forward(input_ids, audio_mask, attention_mask, rope_table) - - seq_len = input_ids.shape[-1] - two_b = input_ids.shape[0] - bucket = self._find_bucket(seq_len) if two_b == 2 else None - - # Graphs are captured with (and their static buffers hold) the additive - # float mask, so normalize here, before padding or any copy_ into them. - if attention_mask is not None: - attention_mask = _additive_float_mask(attention_mask, self._gen.model_dtype) + rope_table = self._gen._rope_table_for(input_ids.shape[0], input_ids.device, self._gen.model_dtype) + return self._gen._step_forward(input_ids, audio_mask, cu_seqs, rope_table) + seq_len = input_ids.shape[0] + bucket = self._find_bucket(batch_size, seq_len) + is_lazy = bucket is None if bucket is None: - # Lazy capture: oversized sequence or non-unit batch (no pre-warmed bucket). - # Lock prevents concurrent threads from double-capturing the same key. - # _lazy_graphs is capped at _MAX_LAZY_GRAPHS with LRU eviction to - # prevent unbounded GPU memory growth when seq_len varies widely. - key = (two_b, seq_len) - ids_in, mask_in, attn_in = input_ids, audio_mask, attention_mask - with self._lock: - entry = self._lazy_graphs.get(key) - if entry is None: - entry = self._capture_for_key(key, ids_in, mask_in, attn_in) - if len(self._lazy_graphs) >= self._MAX_LAZY_GRAPHS: - evicted_key, _ = self._lazy_graphs.popitem(last=False) - logger.warning("OmniVoice CUDA Graph lazy cache full; evicted key %s", evicted_key) - self._lazy_graphs[key] = entry - else: - key = (two_b, bucket) - ids_in, mask_in, attn_in = self._pad_inputs(input_ids, audio_mask, attention_mask, bucket) - with self._lock: - entry = self._graphs.get(key) - if entry is None: - entry = self._capture_for_key(key, ids_in, mask_in, attn_in) - self._graphs[key] = entry + bucket = round_up(seq_len, self._LAZY_CAPTURE_ALIGNMENT) + ids_in, mask_in = self._pad_inputs(input_ids, audio_mask, bucket) + runtime_cu_seqs = cu_seqs.clone() + runtime_cu_seqs[-1] = bucket + key = (batch_size, bucket) + cache = self._lazy_graphs if is_lazy else self._graphs + with self._lock: + entry = cache.get(key) + if entry is None: + if is_lazy and len(self._lazy_graphs) >= self._MAX_LAZY_GRAPHS: + evicted_key, _ = self._lazy_graphs.popitem(last=False) + logger.info("Evicted OmniVoice lazy CUDA Graph key %s", evicted_key) + entry = self._capture_for_key(key, ids_in, mask_in, runtime_cu_seqs) + cache[key] = entry + elif is_lazy: + self._lazy_graphs.move_to_end(key) entry["static_input_ids"].copy_(ids_in) entry["static_audio_mask"].copy_(mask_in) - if attn_in is not None and entry["static_attn_mask"] is not None: - entry["static_attn_mask"].copy_(attn_in) + entry["static_cu_seqs"].copy_(runtime_cu_seqs) entry["graph"].replay() output = entry["static_output"] - if bucket is not None and bucket != seq_len: - output = output[:, :, :seq_len, :] + if bucket != seq_len: + output = output[:, :seq_len, :] return output def clear(self) -> None: @@ -692,17 +738,17 @@ class OmniVoiceGenerator(nn.Module): - 32-step iterative unmasking with classifier-free guidance Optimizations: - - Full bidirectional attention via PyTorch SDPA (no auto-selected - FlashAttn/SageAttn/DiffusionAttention backend) + - Packed full-bidirectional varlen attention with SDPA fallback - regionally_compile() compatible for torch.compile on repeated blocks """ # For regionally_compile() support _repeated_blocks = ["layers"] - def __init__(self, config: OmniVoiceConfig): + def __init__(self, config: OmniVoiceConfig, od_config: OmniDiffusionConfig): super().__init__() self.config = config + self.od_config = od_config # Opt-in TF32; must run before any CUDA-graph capture so captured kernels honour it. if getattr(config, "enable_tf32", False): @@ -722,7 +768,10 @@ class OmniVoiceGenerator(nn.Module): ) # Transformer layers - self.layers = nn.ModuleList([OmniVoiceTransformerBlock(config) for _ in range(config.llm_num_hidden_layers)]) + self.layers = nn.ModuleList( + [OmniVoiceTransformerBlock(config, layer_idx) for layer_idx in range(config.llm_num_hidden_layers)] + ) + self._needs_sdpa_mask = any(not layer.self_attn.use_packed_varlen for layer in self.layers) self.norm = OmniVoiceRMSNorm(config.llm_hidden_size, eps=config.llm_rms_norm_eps) # Prediction head: hidden → 8 * 1025 @@ -775,23 +824,23 @@ class OmniVoiceGenerator(nn.Module): """Prepare mixed text+audio embeddings. Args: - input_ids: [B, 8, S] - text tokens replicated across codebooks, + input_ids: [T, 8] - text tokens replicated across codebooks, audio positions have per-codebook token IDs - audio_mask: [B, S] - True for audio positions, False for text - text_embeds: optional cached [B, S, H] text-position embeddings - audio_mask_3d: optional cached [B, S, 1] audio_mask.unsqueeze(-1) + audio_mask: [T] - True for audio positions, False for text + text_embeds: optional cached [T, H] text-position embeddings + audio_mask_3d: optional cached [T, 1] audio_mask.unsqueeze(-1) Returns: - embeddings: [B, S, hidden_size] + embeddings: [T, hidden_size] """ # Cached across the denoising loop since text ids don't change. if text_embeds is None: - text_embeds = self.text_embedding(input_ids[:, 0, :]) + text_embeds = self.text_embedding(input_ids[:, 0]) if audio_mask_3d is None: audio_mask_3d = audio_mask.unsqueeze(-1) # Audio embeddings: offset per codebook, then sum across codebooks - shifted_ids = (input_ids * audio_mask.unsqueeze(1)) + self.codebook_layer_offsets.view(1, -1, 1) + shifted_ids = (input_ids * audio_mask.unsqueeze(1)) + self.codebook_layer_offsets.view(1, -1) audio_embeds = self.audio_embeddings(shifted_ids).sum(dim=1) # Merge: audio where audio_mask=True, text elsewhere @@ -800,35 +849,39 @@ class OmniVoiceGenerator(nn.Module): def _transformer_forward( self, inputs_embeds: torch.Tensor, - attention_mask: torch.Tensor | None = None, + cu_seqs: torch.Tensor, + max_seqlen: int | None = None, rope_table: torch.Tensor | None = None, ) -> torch.Tensor: """Run through transformer layers. Args: - inputs_embeds: [B, S, hidden_size] - attention_mask: [B, 1, S, S] or None - rope_table: optional precomputed [B * S, head_dim] RoPE table + inputs_embeds: [T, hidden_size] + cu_seqs: cumulative boundaries for packed sequences + rope_table: optional base [T, head_dim] RoPE table Returns: - hidden_states: [B, S, hidden_size] + hidden_states: [T, hidden_size] """ hidden_states = inputs_embeds if rope_table is None: - rope_table = self._rope_table_for(inputs_embeds.shape[1], inputs_embeds.device, hidden_states.dtype) - - # Safety: convert bool mask if caller hasn't (e.g. external paths beyond forward()). - if attention_mask is not None and attention_mask.dtype == torch.bool: - attention_mask = torch.zeros_like(attention_mask, dtype=hidden_states.dtype).masked_fill_( - ~attention_mask, float("-inf") - ) + rope_table = self._rope_table_for(inputs_embeds.shape[0], inputs_embeds.device, hidden_states.dtype) + seq_len = inputs_embeds.shape[0] + position_ids = _position_ids_from_cu_seqs(cu_seqs, seq_len) + packed_rope_table = rope_table.index_select(0, position_ids).contiguous() + attn_metadata = _attention_metadata_from_cu_seqs( + cu_seqs, + seq_len, + needs_sdpa_mask=self._needs_sdpa_mask, + max_seqlen=max_seqlen, + ) residual = None for layer in self.layers: hidden_states, residual = layer( hidden_states, - attention_mask=attention_mask, - rope_table=rope_table, + attn_metadata=attn_metadata, + rope_table=packed_rope_table, residual=residual, ) @@ -838,44 +891,77 @@ class OmniVoiceGenerator(nn.Module): """Project hidden states to per-codebook logits. Args: - hidden_states: [B, S, hidden_size] + hidden_states: [T, hidden_size] Returns: - logits: [B, 8, S, 1025] + logits: [8, T, 1025] """ - batch_size, seq_len, _ = hidden_states.shape - logits_flat = self.audio_heads(hidden_states) # [B, S, 8*1025] + seq_len, _ = hidden_states.shape + logits_flat = self.audio_heads(hidden_states) # [T, 8*1025] return logits_flat.view( - batch_size, seq_len, self.config.num_audio_codebook, self.config.audio_vocab_size, - ).permute(0, 2, 1, 3) # [B, 8, S, 1025] + ).permute(1, 0, 2) # [8, T, 1025] def _step_forward( self, input_ids: torch.Tensor, audio_mask: torch.Tensor, - attention_mask: torch.Tensor | None, + cu_seqs: torch.Tensor, rope_table: torch.Tensor, ) -> torch.Tensor: """Single unmasking-step forward using a pre-cast RoPE table (CUDA graph safe).""" hidden_states = self._prepare_embeddings(input_ids, audio_mask) - residual = None - for layer in self.layers: - hidden_states, residual = layer( - hidden_states, attention_mask=attention_mask, rope_table=rope_table, residual=residual + hidden_states = self._transformer_forward(hidden_states, cu_seqs, rope_table=rope_table) + return self._get_logits(hidden_states) + + def _unmask_one_request( + self, + c_logits: torch.Tensor, + u_logits: torch.Tensor, + sample_tokens: torch.Tensor, + *, + num_to_unmask: int | torch.Tensor, + guidance_scale: float, + generator: torch.Generator, + class_temperature: float, + position_temperature: float, + layer_penalty_factor: float, + layer_ids: torch.Tensor, + ) -> None: + """Sample and unmask one request in place from FP32 target logits.""" + mask_id = self.config.audio_mask_id + if guidance_scale != 0: + log_probs = F.log_softmax( + (1.0 + guidance_scale) * c_logits - guidance_scale * u_logits, + dim=-1, ) - return self._get_logits(self.norm(hidden_states + residual)) + else: + log_probs = F.log_softmax(c_logits, dim=-1) + log_probs[..., mask_id] = -float("inf") + if class_temperature > 0.0: + pred_tokens = _gumbel_sample(log_probs, class_temperature, generator).argmax(dim=-1) + else: + pred_tokens = log_probs.argmax(dim=-1) + scores = log_probs.max(dim=-1)[0] + scores = scores - (layer_ids * layer_penalty_factor) + if position_temperature > 0.0: + scores = _gumbel_sample(scores, position_temperature, generator) + scores.masked_fill_(sample_tokens != mask_id, -float("inf")) + _, topk_idx = torch.topk(scores.flatten(), num_to_unmask) + flat_tokens = sample_tokens.flatten() + flat_tokens[topk_idx] = pred_tokens.flatten()[topk_idx] + sample_tokens.copy_(flat_tokens.view_as(sample_tokens)) @torch.inference_mode() def forward( self, input_ids: torch.Tensor, audio_mask: torch.Tensor, - attention_mask: torch.Tensor, + cond_lens: list[int], target_lens: list[int], - seed: int | None = None, + seed: int | list[int | None] | None = None, num_step: int = 32, guidance_scale: float = 2.0, t_shift: float = 0.1, @@ -886,36 +972,51 @@ class OmniVoiceGenerator(nn.Module): """Run the full 32-step iterative unmasking generation. Args: - input_ids: [2*B, 8, S] - conditional (0:B) + unconditional (B:2B) - audio_mask: [2*B, S] - True for audio positions - attention_mask: [2*B, 1, S, S] - attention mask - target_lens: List of target audio lengths per batch item - num_step: Number of unmasking steps - guidance_scale: CFG scale - t_shift: Time shift for schedule - layer_penalty_factor: Penalty for later codebooks - position_temperature: Gumbel temperature for position selection - class_temperature: Temperature for token prediction (0=greedy) + input_ids: Packed token IDs with shape ``[total_seq_len, 8]`` in + request-major ``[cond0, uncond0, ...]`` order. + audio_mask: Boolean audio-position mask with shape + ``[total_seq_len]``. + cond_lens: Conditional sequence length for each request. + target_lens: Target length for each request; also the corresponding + unconditional sequence length. + seed: One seed per request, a shared scalar seed, or ``None``. + num_step: Number of iterative unmasking steps. + guidance_scale: Classifier-free guidance scale. + t_shift: Time shift used to construct the unmasking schedule. + layer_penalty_factor: Penalty applied to later codebooks. + position_temperature: Gumbel temperature for position selection. + class_temperature: Token sampling temperature; zero selects greedy + decoding. Returns: - tokens: [B, 8, max_target_len] - generated audio tokens + Packed generated audio tokens with shape + ``[1, 8, sum(target_lens)]``. """ B = len(target_lens) device = input_ids.device - max_target_len = max(target_lens) + total_target_lens = sum(target_lens) mask_id = self.config.audio_mask_id num_codebooks = self.config.num_audio_codebook - if seed is None: - seed = random.randint(0, 2**63 - 1) - generator = torch.Generator(device=device).manual_seed(seed) + seeds = seed if isinstance(seed, list) else [seed] * B + generators = [ + torch.Generator(device=device).manual_seed( + request_seed if request_seed is not None else random.randint(0, 2**63 - 1) + ) + for request_seed in seeds + ] # Initialize all target tokens as [MASK] - tokens = torch.full( - (B, num_codebooks, max_target_len), - mask_id, - dtype=torch.long, - device=device, - ) + tokens = torch.full((1, num_codebooks, total_target_lens), mask_id, dtype=torch.long, device=device) + target_offsets: list[int] = [] + target_offset = 0 + sequence_offsets: list[int] = [] + sequence_offset = 0 + for cond_len, target_len in zip(cond_lens, target_lens): + target_offsets.append(target_offset) + target_offset += target_len + sequence_offsets.append(sequence_offset) + sequence_offset += cond_len + target_len + cu_seqs = _build_cu_seqs(cond_lens, target_lens, device) # Compute unmasking schedule timesteps = _get_time_steps(0.0, 1.0, num_step + 1, t_shift).tolist() @@ -939,45 +1040,46 @@ class OmniVoiceGenerator(nn.Module): layer_ids = torch.arange(num_codebooks, device=device).view(1, -1, 1) - # Single D2H pull for all conditional lengths instead of B per-item .item() syncs. - c_lens = attention_mask[:B, 0, 0].sum(dim=-1).tolist() - - # Materialize the SDPA float mask once so the captured graph (and eager path) skip per-layer conversion. - sdpa_attn_mask = _additive_float_mask(attention_mask, self.model_dtype) - use_cuda_graph = self._cuda_graph_fwd is not None and input_ids.is_cuda if not use_cuda_graph: # Eager-path-only constants (the cuda-graph captures its own). - text_embeds_cached = self.text_embedding(input_ids[:, 0, :]) + text_embeds_cached = self.text_embedding(input_ids[:, 0]) audio_mask_3d = audio_mask.unsqueeze(-1) - rope_table = self._rope_table_for(input_ids.shape[-1], device, text_embeds_cached.dtype) + rope_table = self._rope_table_for(input_ids.shape[0], device, text_embeds_cached.dtype) # Main iterative loop for step in range(num_step): if use_cuda_graph: # Float mask skips per-layer conversion; fp32 cast deferred to the per-item slices below. - batch_logits = self._cuda_graph_fwd(input_ids, audio_mask, sdpa_attn_mask) + batch_logits = self._cuda_graph_fwd(input_ids, audio_mask, cu_seqs, B) else: # Eager fallback reuses hoisted constants (text embeds, sdpa mask, rope table). inputs_embeds = self._prepare_embeddings( input_ids, audio_mask, text_embeds=text_embeds_cached, audio_mask_3d=audio_mask_3d ) - hidden_states = self._transformer_forward(inputs_embeds, sdpa_attn_mask, rope_table=rope_table) + hidden_states = self._transformer_forward( + inputs_embeds, + cu_seqs, + max_seqlen=max(cond_lens), + rope_table=rope_table, + ) # fp32 cast deferred to the per-item slices below. batch_logits = self._get_logits(hidden_states) - # batch_logits: [2*B, 8, S, 1025] + # batch_logits: [8, T, 1025] for i in range(B): k = schedules[i][step] if k <= 0: continue - c_len = c_lens[i] + c_len = cond_lens[i] t_len = target_lens[i] + request_start = sequence_offsets[i] + cond_end = request_start + c_len # Extract logits for target region; upcast only the slices we actually consume. - c_logits = batch_logits[i : i + 1, :, c_len - t_len : c_len, :].to(torch.float32) - u_logits = batch_logits[B + i : B + i + 1, :, :t_len, :].to(torch.float32) + c_logits = batch_logits[:, cond_end - t_len : cond_end, :].unsqueeze(0).to(torch.float32) + u_logits = batch_logits[:, cond_end : cond_end + t_len, :].unsqueeze(0).to(torch.float32) # Classifier-free guidance. Fuse the chain: the two inner # log_softmax normalizers are per-position scalars that the final @@ -996,7 +1098,7 @@ class OmniVoiceGenerator(nn.Module): # Token prediction if class_temperature > 0.0: - pred_tokens = _gumbel_sample(log_probs, class_temperature, generator).argmax(dim=-1) + pred_tokens = _gumbel_sample(log_probs, class_temperature, generators[i]).argmax(dim=-1) else: pred_tokens = log_probs.argmax(dim=-1) # [1, 8, T] @@ -1008,10 +1110,11 @@ class OmniVoiceGenerator(nn.Module): # Gumbel noise for position selection if position_temperature > 0.0: - scores = _gumbel_sample(scores, position_temperature, generator) + scores = _gumbel_sample(scores, position_temperature, generators[i]) # Mask out already unmasked positions - sample_tokens = tokens[i : i + 1, :, :t_len] + target_start = target_offsets[i] + sample_tokens = tokens[:, :, target_start : target_start + t_len] scores.masked_fill_(sample_tokens != mask_id, -float("inf")) # Select top-k positions to unmask. .flatten() on this non-contiguous view already copies. @@ -1021,8 +1124,9 @@ class OmniVoiceGenerator(nn.Module): sample_tokens.copy_(flat_tokens.view_as(sample_tokens)) # Mirror update into both cond and uncond input_ids halves for the next step. - input_ids[i, :, c_len - t_len : c_len] = sample_tokens.squeeze(0) - input_ids[B + i, :, :t_len] = sample_tokens.squeeze(0) + packed_sample_tokens = sample_tokens.squeeze(0).transpose(0, 1) + input_ids[cond_end - t_len : cond_end] = packed_sample_tokens + input_ids[cond_end : cond_end + t_len] = packed_sample_tokens return tokens From e74fe3c6e86d664cf165d22252163f20df587065 Mon Sep 17 00:00:00 2001 From: NancyFyong <88076188+NancyFyong@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:00:25 +0800 Subject: [PATCH 55/90] [Model] Add Audio8 TTS Preview 0.6B (DualAR, 44.1 kHz codec) (#6157) Signed-off-by: NancyFyong Co-authored-by: NancyFyong --- benchmarks/tts/README.md | 19 + benchmarks/tts/model_configs.yaml | 11 + docs/models/supported_models.md | 1 + .../text_to_speech/README.md | 52 ++ .../text_to_speech/audio8_tts/end2end.py | 237 ++++++ .../online_serving/text_to_speech/README.md | 133 +++ .../text_to_speech/audio8_tts/gradio_demo.py | 258 ++++++ .../audio8_tts/run_gradio_demo.sh | 44 + .../text_to_speech/audio8_tts/run_server.sh | 24 + .../audio8_tts/speech_client.py | 131 +++ .../e2e/offline_inference/test_audio8_tts.py | 112 +++ tests/e2e/online_serving/test_audio8_tts.py | 166 ++++ .../openai/test_audio8_tts_adapter.py | 85 ++ .../openai_api/test_tts_detection.py | 2 + tests/helpers/assertions.py | 24 +- tests/helpers/client.py | 118 +++ .../models/audio8_tts/__init__.py | 0 .../test_audio8_tts_codec_decoder.py | 141 ++++ .../test_audio8_tts_prompt_utils.py | 112 +++ .../audio8_tts/test_audio8_tts_sampling.py | 156 ++++ .../test_audio8_tts_async_chunk.py | 163 ++++ tests/worker/test_gpu_ar_model_runner.py | 1 + vllm_omni/config/pipeline_registry.py | 2 + vllm_omni/deploy/audio8_tts.yaml | 80 ++ .../entrypoints/openai/serving_speech.py | 2 + .../openai/tts_adapters/__init__.py | 1 + .../openai/tts_adapters/audio8_tts.py | 190 +++++ .../models/audio8_tts/__init__.py | 3 + .../audio8_tts/audio8_tts_codec_decoder.py | 284 +++++++ .../models/audio8_tts/audio8_tts_fast_ar.py | 346 ++++++++ .../models/audio8_tts/audio8_tts_slow_ar.py | 755 ++++++++++++++++++ .../model_executor/models/audio8_tts/codec.py | 600 ++++++++++++++ .../models/audio8_tts/codec_utils.py | 224 ++++++ .../audio8_tts/configuration_audio8_tts.py | 267 +++++++ .../models/audio8_tts/pipeline.py | 50 ++ .../models/audio8_tts/prompt_utils.py | 159 ++++ .../models/audio8_tts/sampling.py | 227 ++++++ vllm_omni/model_executor/models/registry.py | 11 + .../stage_input_processors/audio8_tts.py | 173 ++++ .../transformers_utils/configs/__init__.py | 9 +- .../transformers_utils/configs/audio8_tts.py | 30 + 41 files changed, 5398 insertions(+), 5 deletions(-) create mode 100644 examples/offline_inference/text_to_speech/audio8_tts/end2end.py create mode 100644 examples/online_serving/text_to_speech/audio8_tts/gradio_demo.py create mode 100644 examples/online_serving/text_to_speech/audio8_tts/run_gradio_demo.sh create mode 100644 examples/online_serving/text_to_speech/audio8_tts/run_server.sh create mode 100644 examples/online_serving/text_to_speech/audio8_tts/speech_client.py create mode 100644 tests/e2e/offline_inference/test_audio8_tts.py create mode 100644 tests/e2e/online_serving/test_audio8_tts.py create mode 100644 tests/entrypoints/openai/test_audio8_tts_adapter.py create mode 100644 tests/model_executor/models/audio8_tts/__init__.py create mode 100644 tests/model_executor/models/audio8_tts/test_audio8_tts_codec_decoder.py create mode 100644 tests/model_executor/models/audio8_tts/test_audio8_tts_prompt_utils.py create mode 100644 tests/model_executor/models/audio8_tts/test_audio8_tts_sampling.py create mode 100644 tests/model_executor/stage_input_processors/test_audio8_tts_async_chunk.py create mode 100644 vllm_omni/deploy/audio8_tts.yaml create mode 100644 vllm_omni/entrypoints/openai/tts_adapters/audio8_tts.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/__init__.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/audio8_tts_codec_decoder.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/audio8_tts_fast_ar.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/audio8_tts_slow_ar.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/codec.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/codec_utils.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/configuration_audio8_tts.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/pipeline.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/prompt_utils.py create mode 100644 vllm_omni/model_executor/models/audio8_tts/sampling.py create mode 100644 vllm_omni/model_executor/stage_input_processors/audio8_tts.py create mode 100644 vllm_omni/transformers_utils/configs/audio8_tts.py diff --git a/benchmarks/tts/README.md b/benchmarks/tts/README.md index c403e5dd0..918e0727a 100644 --- a/benchmarks/tts/README.md +++ b/benchmarks/tts/README.md @@ -85,6 +85,25 @@ vllm bench serve --omni \ --save-result --result-dir ./results ``` +#### Set the sample rate for models that are not 24 kHz + +Streamed PCM carries no header, so the harness derives `audio_duration` from the +byte count and a **default 24000 Hz** assumption +(`vllm_omni/metrics/definitions.py:DEFAULT_AUDIO_SAMPLE_RATE`). For a model that +emits another rate, export the real one or every duration-derived metric is +wrong by `real_sr / 24000`: + +```bash +# Audio8 TTS, Fish Speech S2 Pro, Ming-omni-tts, ... all decode at 44.1 kHz +export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100 +``` + +Without it, a 44.1 kHz model reports `audio_duration` 1.84x too long, so +`audio_rtf` looks 1.84x *better* than reality and `audio_underrun` / +`Streaming continuity OK rate` are measured against inflated chunk budgets. +A quick sanity check: compare `Mean AUDIO_DURATION (s)` against the actual +length of a saved WAV for the same prompt. + #### Streaming continuity (`audio_underrun`) `audio_underrun` (seconds) is the per-request worst-case buffer deficit diff --git a/benchmarks/tts/model_configs.yaml b/benchmarks/tts/model_configs.yaml index 69fe09b50..53c84e66a 100644 --- a/benchmarks/tts/model_configs.yaml +++ b/benchmarks/tts/model_configs.yaml @@ -17,6 +17,17 @@ models: extra_params: lang: en text_normalization: true + # Audio8 TTS Preview: DualAR, 44.1 kHz. trust_remote_code stays false. + # Bench at 44.1 kHz: export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100, otherwise + # the streamed-PCM duration defaults to 24 kHz and RTF looks 1.84x too good. + Audio8/Audio8-TTS-Preview-0.6b: + supported_tasks: [default_voice, voice_clone] + backend: openai-audio-speech + endpoint: /v1/audio/speech + trust_remote_code: false + task_extra_body: + default_voice: {} + voice_clone: {} # -CustomVoice checkpoints lack speaker_encoder weights, so voice_clone is # NOT supported (an attempt raises ValueError from _extract_speaker_embedding diff --git a/docs/models/supported_models.md b/docs/models/supported_models.md index de5d5e346..6b0952eac 100644 --- a/docs/models/supported_models.md +++ b/docs/models/supported_models.md @@ -97,6 +97,7 @@ th { | `MiMoV2ASRForCausalLM` | MiMo-V2.5-ASR | `XiaomiMiMo/MiMo-V2.5-ASR` | ✅︎ | ✅︎ | | | — | | `Flux2Pipeline` | FLUX.2-dev | `black-forest-labs/FLUX.2-dev` | ✅︎ | ✅︎ | | | — | | `FishSpeechSlowARForConditionalGeneration` | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✅︎ | ✅︎ | | ✅︎ | — | +| `Audio8TTSSlowARForConditionalGeneration` | Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✅︎ | | | | — | | `SenseNovaU1Pipeline` | SenseNova-U1 (DiT-only), SenseNova-U1.5 | `SenseNova/SenseNova-U1-8B-MoT`, `sensenova/SenseNova-U1.5-8B-MoT` | ✅︎ | | | | [Repository](https://github.com/vllm-project/vllm-omni/blob/main/recipes/SenseNova/SenseNova-U1.5.md) | | `LancePipeline` | Lance | `bytedance-research/Lance` | ✅︎ | | | | — | | `HunyuanVideo15Pipeline` | HunyuanVideo-1.5-T2V | `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-480p_t2v`, `hunyuanvideo-community/HunyuanVideo-1.5-Diffusers-720p_t2v` | ✅︎ | ✅︎ | | | — | diff --git a/examples/offline_inference/text_to_speech/README.md b/examples/offline_inference/text_to_speech/README.md index da00ea44d..a8a273026 100644 --- a/examples/offline_inference/text_to_speech/README.md +++ b/examples/offline_inference/text_to_speech/README.md @@ -14,6 +14,7 @@ list of supported architectures across all modalities, see | Model | HuggingFace repo | Stages | Voice cloning | Streaming | Special modes | Sample rate | |---|---|---|---|---|---|---| +| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | dual-AR | ✓ | ✓ | 11 languages | 44.1 kHz | | CosyVoice3 | `FunAudioLLM/Fun-CosyVoice3-0.5B-2512` | 2 (talker + code2wav) | ✓ | ✓ | — | 24 kHz | | Fish Speech S2 Pro | `fishaudio/s2-pro` | dual-AR | ✓ | ✓ | — | 44.1 kHz | | Gepard-1.0 | `nineninesix/gepard-1.0` | single (native AR + NanoCodec) | — (zero-shot; cloning WIP) | — (serving WIP) | zero-shot | 22.05 kHz | @@ -44,6 +45,57 @@ python examples/offline_inference/text_to_speech//end2end.py \ --- +## Audio8 TTS Preview + +0.6B DualAR text-to-speech model (Audio8) with its own 44.1 kHz neural audio +codec: a slow AR transformer predicts one semantic token per codec frame, and a +4-layer fast AR predicts that frame's remaining 9 codebooks. + +### Prerequisites +No extra packages: the codec architecture ships in tree and its weights +(`codec.pth`) come with the checkpoint. + +### Quick start +```bash +python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \ + --text "Welcome to Audio8 TTS, a compact multilingual text to speech model." +``` + +### Voice cloning +```bash +python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \ + --text "Welcome to Audio8 TTS." \ + --ref-audio /path/to/reference.wav \ + --ref-text "The exact transcript of the reference recording." +``` +The reference transcript must match what is actually spoken in the clip; +a mismatch degrades both stability and speaker similarity. + +### Streaming +```bash +python examples/offline_inference/text_to_speech/audio8_tts/end2end.py \ + --text "Welcome to Audio8 TTS." --streaming +``` + +### Notes +- Output: 44.1 kHz mono WAV; the codec emits ~21.5 frames/s (2048 samples per frame). +- Context is 2048 packed text+audio positions, so `max_model_len` is 2048. +- Greedy decoding (`temperature: 0`) is degenerate for this checkpoint — it + ends the utterance almost immediately. Keep the deploy defaults + (`temperature 0.7`, `top_k 50`, `top_p 0.9`), which also match + `generation_config.json`. +- Repetition-Aware Sampling is applied to the semantic token: a repeat inside + the last 10 frames is re-drawn from a flatter distribution instead of being + masked out. +- `--num-prompts 4` smoke-tests concurrency (stage 0 runs `max_num_seqs: 4`). + +### Measured behaviour (1x H20, shared GPU) +`--streaming` on a 58-character English sentence: TTFA ~0.74 s (4-frame first +chunk), 7 chunks, 1.55 s wall for 3.16 s of audio (RTF ~0.49). Treat these as +smoke-test magnitudes, not a benchmark. + +--- + ## CosyVoice3 2-stage TTS pipeline (`talker` + `code2wav`) at 24 kHz. diff --git a/examples/offline_inference/text_to_speech/audio8_tts/end2end.py b/examples/offline_inference/text_to_speech/audio8_tts/end2end.py new file mode 100644 index 000000000..8ce94f385 --- /dev/null +++ b/examples/offline_inference/text_to_speech/audio8_tts/end2end.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Offline inference demo for Audio8 TTS Preview 0.6B via vLLM Omni. + +Usage: + # Text-only synthesis + python end2end.py --text "Welcome to Audio8 TTS." + + # Zero-shot voice cloning (the transcript must match the reference audio) + python end2end.py --text "Welcome to Audio8 TTS." \ + --ref-audio reference.wav --ref-text "The exact transcript of the reference recording." + + # Streaming (per-chunk TTFA / inter-chunk timings) + python end2end.py --text "Welcome to Audio8 TTS." --streaming +""" + +import asyncio +import os +import time + +import numpy as np +import soundfile as sf +import torch + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +from vllm_omni import AsyncOmni, Omni +from vllm_omni.model_executor.models.audio8_tts.codec_utils import estimate_reference_code_frames +from vllm_omni.model_executor.models.audio8_tts.prompt_utils import ( + build_text_only_prompt_ids, + estimate_voice_clone_prompt_len, + normalize_text, +) +from vllm_omni.utils.tracking_parser import TrackingArgumentParser + +DEFAULT_MODEL = "Audio8/Audio8-TTS-Preview-0.6b" + + +def build_prompt( + text: str, + ref_audio_path: str | None = None, + ref_text: str | None = None, + model_name: str = DEFAULT_MODEL, +) -> dict: + """Build an engine prompt, using the same protocol as online serving.""" + from transformers import AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(model_name) + if ref_audio_path is None and ref_text is None: + prompt_ids, normalized_text = build_text_only_prompt_ids(tokenizer, text) + return { + "prompt_token_ids": prompt_ids, + "additional_information": {"text": [normalized_text]}, + } + + if not ref_audio_path or not ref_text: + raise ValueError("Audio8 TTS voice cloning requires both --ref-audio and --ref-text") + + normalized_text = normalize_text(text) + normalized_ref_text = normalize_text(ref_text, add_default_speaker=True) + ref_audio_wav, ref_audio_sr = sf.read(ref_audio_path, dtype="float32", always_2d=False) + if ref_audio_wav.ndim > 1: + ref_audio_wav = ref_audio_wav.mean(axis=-1) + ref_frames = estimate_reference_code_frames(int(ref_audio_wav.shape[0]), int(ref_audio_sr)) + placeholder_len = estimate_voice_clone_prompt_len(tokenizer, normalized_text, normalized_ref_text, ref_frames) + + # The clone prompt embeds the reference audio's own codec codes, so the + # model builds it in preprocess(); we only reserve the exact length here. + return { + "prompt_token_ids": [1] * placeholder_len, + "additional_information": { + "text": normalized_text, + "ref_text": normalized_ref_text, + "ref_audio_wav": torch.from_numpy(np.asarray(ref_audio_wav, dtype=np.float32)), + "ref_audio_sr": int(ref_audio_sr), + "audio8_structured_voice_clone": True, + }, + } + + +def extract_audio(multimodal_output: dict) -> tuple[torch.Tensor, int]: + """Pull the waveform and sample rate out of a multimodal output. + + The output processor concatenates the per-step delta tensors under + ``model_outputs``; ``audio`` is the older alias. Never use ``a or b`` on + these values -- truthiness on a tensor raises. + """ + audio = multimodal_output.get("model_outputs") + if audio is None: + audio = multimodal_output.get("audio") + if audio is None: + raise ValueError(f"No audio in multimodal_output: {sorted(multimodal_output)}") + if isinstance(audio, list): + audio = torch.cat([torch.as_tensor(chunk).reshape(-1) for chunk in audio], dim=0) + audio = torch.as_tensor(audio).reshape(-1) + + sr_raw = multimodal_output.get("sr") + if isinstance(sr_raw, list): + sr_raw = sr_raw[-1] if sr_raw else None + if sr_raw is None: + raise ValueError("Missing sample rate in multimodal_output") + sample_rate = int(sr_raw.item()) if hasattr(sr_raw, "item") else int(sr_raw) + return audio, sample_rate + + +def _save_wav(output_dir: str, request_id: str, audio: torch.Tensor, sample_rate: int) -> None: + out_wav = os.path.join(output_dir, f"output_{request_id}.wav") + sf.write(out_wav, audio.float().cpu().numpy(), samplerate=sample_rate, format="WAV") + duration = audio.numel() / sample_rate + # print(), not logging: vLLM reconfigures logging on engine start and + # disables loggers created before that, which would swallow these lines. + print(f"Request {request_id}: saved {out_wav} (sr={sample_rate}, {duration:.2f}s)") + + +def _omni_kwargs(args, model_name: str) -> dict: + kwargs = { + "model": model_name, + "log_stats": args.log_stats, + "stage_init_timeout": args.stage_init_timeout, + } + if args.deploy_config: + kwargs["deploy_config"] = args.deploy_config + return kwargs + + +def main(args) -> None: + os.makedirs(args.output_dir, exist_ok=True) + model_name = args.model or DEFAULT_MODEL + prompts = [ + build_prompt(text, args.ref_audio, args.ref_text, model_name) for text in ([args.text] * args.num_prompts) + ] + + omni = Omni(**_omni_kwargs(args, model_name)) + t_start = time.perf_counter() + total_audio_seconds = 0.0 + for stage_outputs in omni.generate(prompts): + request_output = stage_outputs + if request_output is None or not request_output.outputs: + continue + audio, sample_rate = extract_audio(request_output.outputs[0].multimodal_output) + assert audio.numel() > 0, "Audio8 TTS produced an empty waveform" + total_audio_seconds += audio.numel() / sample_rate + _save_wav(args.output_dir, request_output.request_id, audio, sample_rate) + elapsed = time.perf_counter() - t_start + rtf = elapsed / max(total_audio_seconds, 1e-6) + print(f"Total {elapsed * 1000:.1f} ms for {total_audio_seconds:.2f} s of audio (RTF={rtf:.3f})") + + +async def main_streaming(args) -> None: + os.makedirs(args.output_dir, exist_ok=True) + model_name = args.model or DEFAULT_MODEL + prompt = build_prompt(args.text, args.ref_audio, args.ref_text, model_name) + + omni = AsyncOmni(**_omni_kwargs(args, model_name)) + request_id = "0" + chunks: list[torch.Tensor] = [] + sample_rate = None + t_start = time.perf_counter() + t_prev = t_start + chunk_index = 0 + + async for stage_output in omni.generate(prompt, request_id=request_id): + multimodal_output = stage_output.outputs[0].multimodal_output + if not multimodal_output: + continue + audio = multimodal_output.get("model_outputs") + if audio is None: + audio = multimodal_output.get("audio") + if audio is not None: + if isinstance(audio, list): + chunks.extend(torch.as_tensor(chunk).reshape(-1) for chunk in audio) + else: + chunks.append(torch.as_tensor(audio).reshape(-1)) + sr_raw = multimodal_output.get("sr") + if isinstance(sr_raw, list): + sr_raw = sr_raw[-1] if sr_raw else None + if sr_raw is not None: + sample_rate = int(sr_raw.item()) if hasattr(sr_raw, "item") else int(sr_raw) + + if stage_output.finished: + break + now = time.perf_counter() + if chunk_index == 0: + print(f"chunk 0: TTFA={(now - t_start) * 1000:.1f} ms") + else: + print(f"chunk {chunk_index}: inter_chunk={(now - t_prev) * 1000:.1f} ms") + t_prev = now + chunk_index += 1 + + if not chunks or sample_rate is None: + raise RuntimeError("Audio8 TTS streaming produced no audio") + _save_wav(args.output_dir, request_id, torch.cat(chunks, dim=0), sample_rate) + print(f"Streaming done: total={(time.perf_counter() - t_start) * 1000:.1f} ms chunks={chunk_index}") + + +def parse_args(): + parser = TrackingArgumentParser(description="Audio8 TTS Preview offline inference with vLLM Omni") + parser.add_argument("--model", type=str, default=DEFAULT_MODEL, help="Model path or HuggingFace repo ID.") + parser.add_argument( + "--text", + type=str, + default="Welcome to Audio8 TTS, a compact multilingual text to speech model.", + help="Text to synthesize.", + ) + parser.add_argument("--ref-audio", type=str, default=None, help="Reference audio path for voice cloning.") + parser.add_argument( + "--ref-text", + type=str, + default=None, + help="Transcript of --ref-audio; required for voice cloning and must match the recording.", + ) + parser.add_argument("--output-dir", type=str, default="output_audio", help="Directory for output WAV files.") + parser.add_argument( + "--num-prompts", + type=int, + default=1, + help="Duplicate the prompt N times to smoke-test concurrent requests.", + ) + parser.add_argument( + "--deploy-config", + type=str, + default=None, + help="Override the deploy config path; defaults to vllm_omni/deploy/audio8_tts.yaml via model_type.", + ) + parser.add_argument("--streaming", action="store_true", help="Use AsyncOmni and report per-chunk latency.") + parser.add_argument("--log-stats", action="store_true", help="Enable engine stats logging.") + parser.add_argument("--stage-init-timeout", type=int, default=600, help="Per-stage init timeout in seconds.") + return parser.parse_args() + + +if __name__ == "__main__": + parsed_args = parse_args() + if parsed_args.streaming: + asyncio.run(main_streaming(parsed_args)) + else: + main(parsed_args) diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index a4cf2fdb9..b1221de63 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -14,6 +14,7 @@ For the full list of supported architectures across all modalities, see | Model | HuggingFace repo | Voice cloning | Streaming | Voice presets / upload | Gradio demo | | --- | --- | --- | --- | --- | --- | +| Audio8 TTS Preview | `Audio8/Audio8-TTS-Preview-0.6b` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | uploaded audio voice only; no presets | ✓ | | Fish Speech S2 Pro | `fishaudio/s2-pro` | ✓ (`ref_audio`+`ref_text`) | ✓ (PCM stream) | — | ✓ | | GLM-TTS | `zai-org/GLM-TTS` | ✓ (`ref_audio`+`ref_text`, required) | ✓ (PCM stream) | — | ✓ | | IndexTTS-2 | `IndexTeam/IndexTTS-2` | ✓ (`ref_audio` or uploaded `voice`) | `stream=true` response, non-chunk | uploaded audio voice only; no presets | — | @@ -253,6 +254,138 @@ python examples/online_serving/text_to_speech/indextts2/speech_client.py \ --- +## Audio8 TTS Preview + +0.6B DualAR TTS at 44.1 kHz, 11 languages, zero-shot voice cloning. + +### Prerequisites +None beyond the base install: the neural audio codec is implemented in tree and +its weights (`codec.pth`) ship with the checkpoint. + +### Launch +```bash +vllm serve Audio8/Audio8-TTS-Preview-0.6b --omni --port 8092 +# or: +./audio8_tts/run_server.sh +``` +The deploy config auto-loads from `vllm_omni/deploy/audio8_tts.yaml` (HF +`model_type` is `arktts`). Do **not** pass `--trust-remote-code`: vllm-omni +registers its own `arktts` config, and transformers would otherwise prefer the +checkpoint's remote code and bypass it. + +### Text-only synthesis +```bash +curl -X POST http://localhost:8092/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Welcome to Audio8 TTS.", + "response_format": "wav" + }' --output output.wav +``` + +### Voice cloning +```bash +curl -X POST http://localhost:8092/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Welcome to Audio8 TTS.", + "ref_audio": "https://example.com/reference.wav", + "ref_text": "The exact transcript of the reference recording." + }' --output cloned.wav +``` +`ref_audio` also accepts a `data:audio/wav;base64,...` URL. `ref_text` is +mandatory whenever `ref_audio` is present and must match the recording. +Uploading a voice via `POST /v1/audio/voices` lets the server reuse the encoded +reference codes across requests (`voice: ""`). + +### Streaming +```bash +python audio8_tts/speech_client.py --text "Welcome to Audio8 TTS." --stream --output out.pcm +ffplay -f s16le -ar 44100 -ac 1 out.pcm +``` + +### Gradio demo +```bash +./audio8_tts/run_gradio_demo.sh # server + demo +python audio8_tts/gradio_demo.py --api-base http://localhost:8092 # demo only +``` + +### Benchmark + +```bash +export VLLM_OMNI_BENCH_AUDIO_SAMPLE_RATE=44100 # required: harness defaults to 24 kHz +vllm bench serve --omni --host 127.0.0.1 --port 8092 \ + --model Audio8/Audio8-TTS-Preview-0.6b \ + --backend openai-audio-speech --endpoint /v1/audio/speech \ + --dataset-name seed-tts-text --dataset-path benchmarks/build_dataset/seed_tts_smoke \ + --seed-tts-locale en --num-prompts 20 --num-warmups 2 \ + --max-concurrency 1 --request-rate inf \ + --percentile-metrics ttft,e2el,audio_rtf,audio_ttfp,audio_duration,audio_underrun +``` + +Measured on 1x H20 **shared with an unrelated job holding ~80% SM**, so treat +these as a lower bound. 20 prompts from `seed_tts_smoke/en`, mean audio 4.5 s, +deploy defaults: + +| | HF reference (batch=1) | vllm-omni c=1 | c=4 | c=8 | +|---|---|---|---|---| +| RTF (mean) | 1.008 | **0.19** | 0.30 | 0.54 | +| Throughput (req/s) | 0.23 | 1.18 | 2.84 | **2.93** | +| E2E latency (mean, ms) | 4375 | 850 | 1349 | 2361 | +| Time to first packet (mean, ms) | n/a (no streaming) | 63 | 105 | 1124 | +| Underrun p99 / continuity OK | n/a | 0 s / 100% | 0 s / 100% | 0 s / 100% | + +~5.3x lower per-request RTF than the upstream reference implementation at +concurrency 1, ~12.8x aggregate throughput at concurrency 8. + +#### Stage-0 `max_num_seqs`: first-packet vs throughput + +This subsection is operator tuning guidance, not a claim this PR optimizes +anything: the shipped deploy default is unchanged (`max_num_seqs: 4`). The +numbers below are a single run on the same contended H20 as above (one unrelated +job holding ~80% SM, no repeats), so read them as directional, not as measured +speedups. Same setup, varying stage 0's `max_num_seqs` (stage 1 stays at 1): + +| stage-0 `max_num_seqs` | client concurrency | req/s | RTF | TTFP mean / p99 (ms) | underrun p99 (s) | +|---|---|---|---|---|---| +| 4 (default) | 1 | 1.18 | 0.19 | 63 / 67 | 0.00 | +| 4 | 4 | 2.84 | 0.30 | 105 / 148 | 0.00 | +| 4 | 8 | 2.93 | 0.54 | 1124 / 1642 | 0.00 | +| 8 | 1 | 1.16 | 0.19 | 64 / 70 | 0.00 | +| 8 | 4 | 2.88 | 0.30 | 107 / 148 | 0.00 | +| 8 | 8 | **3.72** | 0.40 | **247** / 733 | 0.22 | +| 8 | 16 | 3.91 | 0.65 | 1366 / 2530 | 0.29 | + +Reading of this: + +- Below the limit (`concurrency <= max_num_seqs`) the setting is irrelevant, as + expected — c=1 and c=4 are identical for both. +- At c=8, raising the limit to 8 admits every request instead of queueing half + of them: in this single run, roughly +27% throughput and ~4.5x lower + first-packet latency. +- Throughput saturates around 3.7-3.9 req/s (~17 s of audio per second). Past + c=8 you only buy queueing delay. +- The cost of admitting more streams is a 0.22-0.29 s worst-case buffer deficit + in 1 of 20 requests (`Streaming continuity OK rate` 95%), because stage 1 runs + `max_num_seqs: 1` and the codec window round-robins across streams. Client-side + prebuffering (the bundled Gradio demo holds 2 chunks) absorbs that; raising + stage 1's `max_num_seqs` is the untested knob if you need both. + +The shipped default stays at 4 because every measured point there had zero +underrun. Raise it to 8 if first-packet latency under load matters more than a +rare sub-300 ms gap. + +### Notes +- Output: 44.1 kHz mono; ~21.5 codec frames per second. +- No built-in speaker presets. Omit `voice` for a random timbre, or clone one. +- `max_new_tokens` caps generated codec frames (1 frame ~= 46 ms). +- Context is 2048 packed text+audio positions (`max_model_len: 2048`). +- Do not send `temperature: 0`: greedy decoding is degenerate for this + checkpoint and truncates the utterance. The deploy defaults + (0.7 / top_k 50 / top_p 0.9) mirror `generation_config.json`. + +--- + ## Fish Speech S2 Pro 4B dual-AR TTS at 44.1 kHz. Server uses the DAC codec, which is vendored in diff --git a/examples/online_serving/text_to_speech/audio8_tts/gradio_demo.py b/examples/online_serving/text_to_speech/audio8_tts/gradio_demo.py new file mode 100644 index 000000000..368ddfaa0 --- /dev/null +++ b/examples/online_serving/text_to_speech/audio8_tts/gradio_demo.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Gradio demo for Audio8 TTS Preview via the /v1/audio/speech API. + +Supports: + - text-to-speech synthesis in 11 languages + - zero-shot voice cloning from reference audio (upload, microphone, or URL) + - streaming (progressive PCM chunks) and non-streaming modes + +Usage: + # Start the server first (see run_server.sh), then: + python gradio_demo.py --api-base http://localhost:8092 +""" + +import argparse +import base64 +import io + +try: + import gradio as gr +except ImportError: + raise ImportError("gradio is required to run this demo. Install it with: pip install 'vllm-omni[demo]'") from None +import httpx +import numpy as np +import soundfile as sf + +#: Audio8 TTS decodes at 44.1 kHz; raw PCM must be interpreted at that rate. +PCM_SAMPLE_RATE = 44100 +DEFAULT_API_BASE = "http://localhost:8092" + + +def encode_audio_to_base64(audio_data: tuple) -> str: + """Encode a Gradio ``(sample_rate, numpy_array)`` input as a data URL.""" + sample_rate, audio_np = audio_data + if audio_np.dtype != np.int16: + if audio_np.dtype in (np.float32, np.float64): + audio_np = (np.clip(audio_np, -1.0, 1.0) * 32767).astype(np.int16) + else: + audio_np = audio_np.astype(np.int16) + buf = io.BytesIO() + sf.write(buf, audio_np, sample_rate, format="WAV") + return f"data:audio/wav;base64,{base64.b64encode(buf.getvalue()).decode('utf-8')}" + + +def build_payload( + text: str, + ref_audio: tuple | None, + ref_audio_url: str, + ref_text: str, + response_format: str = "wav", + stream: bool = False, +) -> dict: + """Build the ``/v1/audio/speech`` payload. + + Audio8 TTS has no built-in speakers: omit ``voice`` for a random timbre, or + supply ``ref_audio`` + ``ref_text`` to clone a voice. + """ + if not text or not text.strip(): + raise gr.Error("Please enter text to synthesize.") + + payload: dict = { + "input": text.strip(), + "response_format": "pcm" if stream else response_format, + "stream": stream, + } + if stream: + payload["stream_format"] = "audio" + + ref_url = ref_audio_url.strip() if ref_audio_url else "" + if ref_audio is not None: + payload["ref_audio"] = encode_audio_to_base64(ref_audio) + elif ref_url: + payload["ref_audio"] = ref_url + + if "ref_audio" in payload: + if not ref_text or not ref_text.strip(): + raise gr.Error("Voice cloning requires the transcript of the reference audio.") + payload["ref_text"] = ref_text.strip() + + return payload + + +def generate_speech(api_base: str, text: str, ref_audio, ref_audio_url, ref_text, response_format): + """Non-streaming request; returns the whole waveform.""" + payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=False) + try: + with httpx.Client(timeout=300.0) as client: + resp = client.post( + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") from None + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") from None + + if resp.status_code != 200: + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + if "application/json" in resp.headers.get("content-type", ""): + raise gr.Error(f"Server error: {resp.text}") + + try: + if response_format == "pcm": + audio_np = np.frombuffer(resp.content, dtype=np.int16).astype(np.float32) / 32767.0 + return (PCM_SAMPLE_RATE, audio_np) + audio_np, sample_rate = sf.read(io.BytesIO(resp.content)) + if audio_np.ndim > 1: + audio_np = audio_np[:, 0] + return (sample_rate, audio_np.astype(np.float32)) + except Exception as e: + raise gr.Error(f"Failed to decode audio: {e}") from None + + +def generate_speech_stream(api_base, text, ref_audio, ref_audio_url, ref_text, response_format, chunk_seconds=0.5): + """Streaming request; yields PCM chunks for progressive playback.""" + payload = build_payload(text, ref_audio, ref_audio_url, ref_text, response_format, stream=True) + min_chunk_samples = int(PCM_SAMPLE_RATE * chunk_seconds) + prebuffer_chunks = 2 + pending: list[np.ndarray] = [] + pending_len = 0 + chunks_yielded = 0 + prebuffer: list[np.ndarray] = [] + leftover = b"" + + try: + with ( + httpx.Client(timeout=300.0) as client, + client.stream( + "POST", + f"{api_base}/v1/audio/speech", + json=payload, + headers={"Content-Type": "application/json", "Authorization": "Bearer EMPTY"}, + ) as resp, + ): + if resp.status_code != 200: + resp.read() + raise gr.Error(f"Server error ({resp.status_code}): {resp.text}") + for chunk in resp.iter_bytes(): + if not chunk: + continue + raw = leftover + chunk + # s16le: never split a sample across chunk boundaries. + usable = len(raw) - (len(raw) % 2) + leftover = raw[usable:] + if usable == 0: + continue + samples = np.frombuffer(raw[:usable], dtype=np.int16).copy().astype(np.float32) / 32767.0 + pending.append(samples) + pending_len += len(samples) + if pending_len < min_chunk_samples: + continue + audio_chunk = np.concatenate(pending) + pending.clear() + pending_len = 0 + if chunks_yielded < prebuffer_chunks: + prebuffer.append(audio_chunk) + chunks_yielded += 1 + if chunks_yielded == prebuffer_chunks: + yield (PCM_SAMPLE_RATE, np.concatenate(prebuffer)) + prebuffer.clear() + else: + yield (PCM_SAMPLE_RATE, audio_chunk) + remaining = prebuffer + pending + if remaining: + yield (PCM_SAMPLE_RATE, np.concatenate(remaining)) + except httpx.TimeoutException: + raise gr.Error("Request timed out. The server may be busy.") from None + except httpx.ConnectError: + raise gr.Error(f"Cannot connect to server at {api_base}. Is the server running?") from None + + +def on_stream_change(stream: bool): + """Streaming only makes sense with raw PCM.""" + if stream: + return gr.update(value="pcm", interactive=False) + return gr.update(value="wav", interactive=True) + + +def build_interface(api_base: str, stream_chunk_seconds: float = 0.5): + with gr.Blocks(title="Audio8 TTS Preview Demo") as demo: + gr.Markdown("# Audio8 TTS Preview 0.6B - Text to Speech") + gr.Markdown(f"**Server:** `{api_base}` | **Model:** Audio8/Audio8-TTS-Preview-0.6b | **Output:** 44.1 kHz") + + with gr.Row(): + with gr.Column(scale=3): + text_input = gr.Textbox(label="Text to Synthesize", placeholder="Enter text here...", lines=4) + + with gr.Accordion("Voice Cloning (optional)", open=False): + gr.Markdown( + "Upload or link a short reference recording (5-30 s) and paste its **exact** transcript. " + "A transcript that does not match the audio degrades both stability and speaker similarity." + ) + ref_audio = gr.Audio(label="Reference Audio", type="numpy", sources=["upload", "microphone"]) + ref_audio_url = gr.Textbox( + label="Reference Audio URL (alternative to upload)", + placeholder="https://example.com/reference.wav", + lines=1, + ) + ref_text = gr.Textbox( + label="Reference Audio Transcript (required for cloning)", + placeholder="Exact transcript of the reference audio...", + lines=2, + ) + + with gr.Row(): + response_format = gr.Dropdown( + choices=["wav", "mp3", "flac", "pcm"], value="wav", label="Audio Format", scale=1 + ) + stream_checkbox = gr.Checkbox( + label="Stream output", value=False, info="Progressive PCM streaming", scale=1 + ) + + generate_btn = gr.Button("Generate Speech", variant="primary", size="lg") + + with gr.Column(scale=2): + audio_output = gr.Audio(label="Generated Audio", interactive=False, streaming=True, autoplay=True) + gr.Markdown( + "### About\n" + "- **Audio8 TTS Preview** — 0.6B DualAR model (slow AR + fast AR codebook decoder)\n" + "- **Languages**: Cantonese, Chinese, Dutch, English, French, German, " + "Italian, Japanese, Korean, Polish, Spanish\n" + "- **Voice cloning**: zero-shot from a reference clip + transcript\n" + "- **44.1 kHz** output, ~21.5 codec frames per second" + ) + + stream_checkbox.change(fn=on_stream_change, inputs=[stream_checkbox], outputs=[response_format]) + all_inputs = [text_input, ref_audio, ref_audio_url, ref_text, response_format] + + def dispatch(stream_enabled, *args): + if stream_enabled: + yield from generate_speech_stream(api_base, *args, chunk_seconds=stream_chunk_seconds) + else: + yield generate_speech(api_base, *args) + + generate_btn.click(fn=dispatch, inputs=[stream_checkbox] + all_inputs, outputs=[audio_output]) + demo.queue() + return demo + + +def main() -> None: + parser = argparse.ArgumentParser(description="Gradio demo for Audio8 TTS Preview") + parser.add_argument("--api-base", default=DEFAULT_API_BASE, help=f"API base URL (default: {DEFAULT_API_BASE})") + parser.add_argument("--host", default="0.0.0.0", help="Gradio host") + parser.add_argument("--port", type=int, default=7861, help="Gradio port") + parser.add_argument("--share", action="store_true", help="Create a public share link") + parser.add_argument("--stream-chunk-seconds", type=float, default=0.5, help="Seconds of audio per yielded chunk") + args = parser.parse_args() + + print(f"Connecting to vLLM server at: {args.api_base}") + build_interface(args.api_base, stream_chunk_seconds=args.stream_chunk_seconds).launch( + server_name=args.host, server_port=args.port, share=args.share + ) + + +if __name__ == "__main__": + main() diff --git a/examples/online_serving/text_to_speech/audio8_tts/run_gradio_demo.sh b/examples/online_serving/text_to_speech/audio8_tts/run_gradio_demo.sh new file mode 100644 index 000000000..5588b921e --- /dev/null +++ b/examples/online_serving/text_to_speech/audio8_tts/run_gradio_demo.sh @@ -0,0 +1,44 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +# Launch the Audio8 TTS Preview server + Gradio demo together. +# +# Usage: +# ./run_gradio_demo.sh +# CUDA_VISIBLE_DEVICES=0 PORT=8092 GRADIO_PORT=7861 ./run_gradio_demo.sh + +set -e + +MODEL="${MODEL:-Audio8/Audio8-TTS-Preview-0.6b}" +PORT="${PORT:-8092}" +GRADIO_PORT="${GRADIO_PORT:-7861}" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +echo "Starting Audio8 TTS Preview server (port $PORT)..." +vllm serve "$MODEL" \ + --omni \ + --host 0.0.0.0 \ + --port "$PORT" & +SERVER_PID=$! + +cleanup() { + echo "Stopping server (PID $SERVER_PID)..." + kill $SERVER_PID 2>/dev/null + wait $SERVER_PID 2>/dev/null +} +trap cleanup EXIT + +echo "Waiting for server to start..." +for _ in $(seq 1 120); do + if curl -s "http://localhost:$PORT/health" > /dev/null 2>&1; then + echo "Server ready." + break + fi + sleep 2 +done + +echo "Starting Gradio demo (port $GRADIO_PORT)..." +python "$SCRIPT_DIR/gradio_demo.py" \ + --api-base "http://localhost:$PORT" \ + --port "$GRADIO_PORT" diff --git a/examples/online_serving/text_to_speech/audio8_tts/run_server.sh b/examples/online_serving/text_to_speech/audio8_tts/run_server.sh new file mode 100644 index 000000000..a208f0a5c --- /dev/null +++ b/examples/online_serving/text_to_speech/audio8_tts/run_server.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +# Launch a vLLM-Omni server for Audio8 TTS Preview 0.6B. +# +# Usage: +# ./run_server.sh +# CUDA_VISIBLE_DEVICES=0 PORT=8092 ./run_server.sh + +set -e + +MODEL="${MODEL:-Audio8/Audio8-TTS-Preview-0.6b}" +PORT="${PORT:-8092}" + +echo "Starting Audio8 TTS Preview server with model: $MODEL" + +# --trust-remote-code is deliberately NOT passed: vllm-omni registers its own +# `arktts` config, and transformers would otherwise prefer the checkpoint's +# remote code and bypass it. +vllm serve "$MODEL" \ + --omni \ + --host 0.0.0.0 \ + --port "$PORT" diff --git a/examples/online_serving/text_to_speech/audio8_tts/speech_client.py b/examples/online_serving/text_to_speech/audio8_tts/speech_client.py new file mode 100644 index 000000000..d0a2c6f6a --- /dev/null +++ b/examples/online_serving/text_to_speech/audio8_tts/speech_client.py @@ -0,0 +1,131 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Client for Audio8 TTS Preview via the /v1/audio/speech endpoint. + +Examples: + # Basic TTS + python speech_client.py --text "Welcome to Audio8 TTS." + + # Zero-shot voice cloning (the transcript must match the reference audio) + python speech_client.py --text "Welcome to Audio8 TTS." \ + --ref-audio ref.wav --ref-text "The exact transcript of the reference recording." + + # Streaming PCM output + python speech_client.py --text "Welcome to Audio8 TTS." --stream --output output.pcm +""" + +import argparse +import base64 +import os + +import httpx + +DEFAULT_API_BASE = "http://localhost:8092" +DEFAULT_API_KEY = "EMPTY" +DEFAULT_MODEL = "Audio8/Audio8-TTS-Preview-0.6b" +#: The codec runs at 44.1 kHz, so raw PCM must be played back at that rate. +PCM_SAMPLE_RATE = 44100 + + +def encode_audio_to_base64(audio_path: str) -> str: + """Encode a local audio file as a base64 data URL.""" + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + ext = audio_path.lower().rsplit(".", 1)[-1] + mime_type = {"wav": "audio/wav", "mp3": "audio/mpeg", "flac": "audio/flac", "ogg": "audio/ogg"}.get( + ext, "audio/wav" + ) + with open(audio_path, "rb") as handle: + audio_b64 = base64.b64encode(handle.read()).decode("utf-8") + return f"data:{mime_type};base64,{audio_b64}" + + +def build_payload(args) -> dict: + payload = { + "model": args.model, + "input": args.text, + "response_format": args.response_format, + } + if args.ref_audio: + if args.ref_audio.startswith(("http://", "https://")): + payload["ref_audio"] = args.ref_audio + else: + payload["ref_audio"] = encode_audio_to_base64(args.ref_audio) + if args.ref_text: + payload["ref_text"] = args.ref_text + if args.max_new_tokens is not None: + payload["max_new_tokens"] = args.max_new_tokens + if args.stream: + payload["stream"] = True + payload["stream_format"] = "audio" + payload["response_format"] = "pcm" + return payload + + +def run_tts(args) -> None: + payload = build_payload(args) + api_url = f"{args.api_base}/v1/audio/speech" + headers = {"Content-Type": "application/json", "Authorization": f"Bearer {args.api_key}"} + + print(f"Model: {args.model}") + print(f"Text: {args.text}") + if args.ref_audio: + print(f"Voice cloning: ref_audio={args.ref_audio!r} ref_text={args.ref_text!r}") + + if args.stream: + output_path = args.output or "output.pcm" + with ( + httpx.Client(timeout=300.0) as client, + client.stream("POST", api_url, json=payload, headers=headers) as resp, + ): + if resp.status_code != 200: + print(f"Error {resp.status_code}: {resp.read().decode()}") + return + total_bytes = 0 + with open(output_path, "wb") as handle: + for chunk in resp.iter_bytes(): + handle.write(chunk) + total_bytes += len(chunk) + seconds = total_bytes / 2 / PCM_SAMPLE_RATE + print(f"Streamed {total_bytes} bytes (~{seconds:.2f}s of s16le @ {PCM_SAMPLE_RATE} Hz) to {output_path}") + print(f"Play with: ffplay -f s16le -ar {PCM_SAMPLE_RATE} -ac 1 {output_path}") + return + + with httpx.Client(timeout=300.0) as client: + response = client.post(api_url, json=payload, headers=headers) + if response.status_code != 200: + print(f"Error {response.status_code}: {response.text}") + return + if response.headers.get("content-type", "").startswith("application/json"): + print(f"Error: {response.text}") + return + + output_path = args.output or f"output.{args.response_format}" + with open(output_path, "wb") as handle: + handle.write(response.content) + print(f"Audio saved to: {output_path} ({len(response.content)} bytes)") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Audio8 TTS Preview client") + parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="API base URL") + parser.add_argument("--api-key", default=DEFAULT_API_KEY, help="API key") + parser.add_argument("--model", "-m", default=DEFAULT_MODEL, help="Model name") + parser.add_argument("--text", required=True, help="Text to synthesize") + parser.add_argument("--ref-audio", default=None, help="Reference audio for voice cloning (path or URL)") + parser.add_argument("--ref-text", default=None, help="Transcript of the reference audio") + parser.add_argument("--max-new-tokens", type=int, default=None, help="Cap the number of generated codec frames") + parser.add_argument("--stream", action="store_true", help="Stream PCM instead of returning a whole file") + parser.add_argument( + "--response-format", + default="wav", + choices=["wav", "mp3", "flac", "pcm", "aac", "opus"], + help="Audio format (default: wav)", + ) + parser.add_argument("--output", "-o", default=None, help="Output file path") + run_tts(parser.parse_args()) + + +if __name__ == "__main__": + main() diff --git a/tests/e2e/offline_inference/test_audio8_tts.py b/tests/e2e/offline_inference/test_audio8_tts.py new file mode 100644 index 000000000..687fff4ae --- /dev/null +++ b/tests/e2e/offline_inference/test_audio8_tts.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""E2E offline tests for Audio8 TTS Preview (DualAR, 44.1 kHz codec). + +The prompt is pre-tokenized here (not built from ``mm_processor_kwargs``) +because Audio8 TTS shares Fish Speech's protocol: text-only prompts are plain +token ids, and voice-clone prompts are a placeholder of the exact final length +whose real embeddings are built model-side from the encoded reference audio. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.runtime import OmniRunner, OmniRunnerHandler +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = os.environ.get("AUDIO8_TTS_MODEL_PATH", "Audio8/Audio8-TTS-Preview-0.6b") +DEPLOY_CONFIG = get_deploy_config_path("audio8_tts.yaml") +SAMPLE_RATE = 44100 +TEXT = "The weather is nice today, perfect for a walk in the park." + +# Four distinct inputs for the concurrency test: distinct text is required so a +# leaked per-request codec buffer (which would make two requests decode to the +# same audio) actually fails the pairwise-difference assertion. +CONCURRENT_TEXTS = [ + "The weather is nice today, perfect for a walk in the park.", + "She sold seashells by the seashore all through the summer.", + "Our train departs at a quarter past nine tomorrow morning.", + "He planted rows of tomatoes and basil behind the old house.", +] + +# The checkpoint registers its own `arktts` config in vllm-omni, so remote code +# must stay disabled (transformers would otherwise prefer the checkpoint's +# auto_map and bypass the Qwen2-shaped view Qwen2Model needs). +_OMNI_RUNNER_PARAM = (MODEL, DEPLOY_CONFIG, {"trust_remote_code": False}) + +pytestmark = pytest.mark.parametrize("omni_runner", [_OMNI_RUNNER_PARAM], indirect=True) + + +def _text_only_prompt(text: str = TEXT) -> dict: + from transformers import AutoTokenizer + + from vllm_omni.model_executor.models.audio8_tts.prompt_utils import build_text_only_prompt_ids + + tokenizer = AutoTokenizer.from_pretrained(MODEL) + prompt_ids, normalized_text = build_text_only_prompt_ids(tokenizer, text) + return {"prompt_token_ids": prompt_ids, "additional_information": {"text": [normalized_text]}} + + +# At ``core_model`` (L1/L2) the harness patches every stage to +# ``load_format: dummy``, so the AR runs on random weights: it never emits EOS +# and always generates to ``max_tokens`` (1024 frames ~= 47.5 s). Duration +# bounds are therefore only meaningful once real weights load, at +# ``advanced_model`` / ``full_model``. +_REAL_WEIGHT_RUN_LEVELS = frozenset({"advanced_model", "full_model"}) + + +def _duration_bounds(run_level: str) -> dict[str, float]: + if run_level not in _REAL_WEIGHT_RUN_LEVELS: + return {} + return {"min_duration_s": 0.5, "max_duration_s": 30.0} + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_text_to_audio_001(omni_runner: OmniRunner, run_level: str) -> None: + """Default deploy, single request, text-only synthesis. + + Deploy Setting: audio8_tts.yaml + Input Modal: text + Output Modal: audio + """ + prompt = _text_only_prompt() + request_config = { + "input": TEXT, + "prompt_token_ids": prompt["prompt_token_ids"], + "additional_information": prompt["additional_information"], + "response_format": "wav", + "expected_sample_rate": SAMPLE_RATE, + **_duration_bounds(run_level), + } + OmniRunnerHandler(omni_runner).send_tokenized_tts_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +def test_text_to_audio_concurrent_002(omni_runner: OmniRunner, run_level: str) -> None: + """Four concurrent requests with distinct text: stage 0 runs ``max_num_seqs: 4``. + + Per-request codec state is keyed by request id. The four inputs are + deliberately distinct and the decoded outputs are asserted pairwise + different, so leaked codec state (which would make two requests decode to + identical audio) fails here, and only here. + """ + prompts = [_text_only_prompt(text) for text in CONCURRENT_TEXTS] + request_config = { + "input": CONCURRENT_TEXTS[0], + "prompt_token_ids": [p["prompt_token_ids"] for p in prompts], + "additional_information": [p["additional_information"] for p in prompts], + "response_format": "wav", + "expected_sample_rate": SAMPLE_RATE, + "assert_distinct_outputs": True, + **_duration_bounds(run_level), + } + OmniRunnerHandler(omni_runner).send_tokenized_tts_request(request_config) diff --git a/tests/e2e/online_serving/test_audio8_tts.py b/tests/e2e/online_serving/test_audio8_tts.py new file mode 100644 index 000000000..1ef725d68 --- /dev/null +++ b/tests/e2e/online_serving/test_audio8_tts.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""E2E online tests for Audio8 TTS Preview via /v1/audio/speech. + +Real model inference (no mocks): text-only synthesis, streaming, and zero-shot +voice cloning. The reference audio is inlined as a base64 data URL because CI +hosts cannot fetch external URLs. +""" + +import os + +os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" + +import pytest + +from tests.helpers.mark import hardware_test +from tests.helpers.media import get_asset_path +from tests.helpers.runtime import OmniServerParams +from tests.helpers.stage_config import get_deploy_config_path + +MODEL = os.environ.get("AUDIO8_TTS_MODEL_PATH", "Audio8/Audio8-TTS-Preview-0.6b") +DEFAULT_AUDIO_SPEECH_TIMEOUT_S = 300.0 +# The codec decodes at 44.1 kHz; raw PCM carries no header, so assertions that +# depend on the rate (HNR pitch search) have to be told explicitly. +SAMPLE_RATE = 44100 +MAX_CONCURRENT = 4 + +# ~0.5 s of 44.1 kHz mono PCM_16 WAV. +_MIN_AUDIO_BYTES = 40_000 + +# Reused vendored asset (3.5 s, Chinese) plus its exact transcript: Audio8 TTS +# requires the reference transcript to match the recording. +REF_AUDIO_URL = get_asset_path("cosyvoice3/zero_shot_prompt.wav", as_data_url=True) +REF_TEXT = "希望你以后能够做的比我还好呦。" + + +def get_prompt(prompt_type: str = "en") -> str: + prompts = { + "en": "The weather is nice today, perfect for a walk in the park.", + "zh": "今天天气很好,非常适合去公园散步。", + } + return prompts.get(prompt_type, prompts["en"]) + + +tts_server_params = [ + pytest.param( + OmniServerParams( + model=MODEL, + stage_config_path=get_deploy_config_path("audio8_tts.yaml"), + # No --trust-remote-code: vllm-omni owns the `arktts` config. + server_args=["--disable-log-stats"], + ), + id="audio8_tts", + ) +] + + +@pytest.mark.core_model +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_text_to_audio_001(omni_server, openai_client) -> None: + """Baseline smoke: default deploy, non-streaming WAV. + + Deploy Setting: audio8_tts.yaml + Input Modal: text + Output Modal: audio + Input Setting: stream=False + Datasets: few requests (max_num_seqs=4) + """ + request_config = { + "model": omni_server.model, + "input": get_prompt(), + "stream": False, + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "wav", + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + openai_client.send_audio_speech_request(request_config, request_num=MAX_CONCURRENT) + + +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_text_to_audio_streaming_002(omni_server, openai_client) -> None: + """Streaming PCM: exercises the async_chunk path end to end. + + Deploy Setting: audio8_tts.yaml (async_chunk: true) + Input Setting: stream=True, response_format=pcm + """ + request_config = { + "model": omni_server.model, + "input": get_prompt(), + "stream": True, + "stream_format": "audio", + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "pcm", + "min_audio_bytes": _MIN_AUDIO_BYTES, + # Raw PCM has no header: without this the harness assumes 24 kHz and the + # HNR pitch search lands on 147-735 Hz instead of 80-400 Hz, scoring + # clean 44.1 kHz speech ~1.9 dB too low. + "expected_sample_rate": SAMPLE_RATE, + } + openai_client.send_audio_speech_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_voice_clone_003(omni_server, openai_client) -> None: + """Zero-shot voice cloning: the reference audio is encoded model-side and + its codec codes are spliced into the prompt. + + Input Modal: text + ref_audio + ref_text + """ + request_config = { + "model": omni_server.model, + "input": get_prompt("zh"), + "ref_audio": REF_AUDIO_URL, + "ref_text": REF_TEXT, + "stream": False, + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "wav", + "min_audio_bytes": _MIN_AUDIO_BYTES, + } + openai_client.send_audio_speech_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_voice_clone_requires_ref_text_004(omni_server, openai_client) -> None: + """``ref_audio`` without ``ref_text`` must be rejected, not silently + synthesized with an untethered voice.""" + request_config = { + "model": omni_server.model, + "input": get_prompt(), + "ref_audio": REF_AUDIO_URL, + "stream": False, + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "wav", + "status_code": (400, 422), + "err_message": "ref_text", + } + openai_client.send_audio_speech_request(request_config) + + +@pytest.mark.advanced_model +@pytest.mark.tts +@hardware_test(res={"cuda": "L4"}, num_cards=1) +@pytest.mark.parametrize("omni_server", tts_server_params, indirect=True) +def test_empty_input_is_rejected_005(omni_server, openai_client) -> None: + request_config = { + "model": omni_server.model, + "input": " ", + "stream": False, + "timeout": DEFAULT_AUDIO_SPEECH_TIMEOUT_S, + "response_format": "wav", + "status_code": (400, 422), + "err_message": "Input text cannot be empty", + } + openai_client.send_audio_speech_request(request_config) diff --git a/tests/entrypoints/openai/test_audio8_tts_adapter.py b/tests/entrypoints/openai/test_audio8_tts_adapter.py new file mode 100644 index 000000000..ffd22d9f3 --- /dev/null +++ b/tests/entrypoints/openai/test_audio8_tts_adapter.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Serving-side registration invariants for the Audio8 TTS adapter. + +The engine never names ``audio8_tts`` directly: ``/v1/audio/speech`` resolves the +adapter by matching the deployed stage's ``model_stage`` key against the +``stage_keys`` each adapter declares. That makes the pipeline definition and the +adapter two halves of one contract, with nothing in the type system holding them +together -- rename either side and the endpoint silently stops recognising the +model (falling through to "unknown TTS model") instead of failing at startup. + +These tests pin the contract from both ends. +""" + +import pytest + +from vllm_omni.entrypoints.openai.tts_adapters import ( + all_tts_model_types, + all_tts_stage_keys, + detect_tts_model_type, + resolve_adapter, +) +from vllm_omni.entrypoints.openai.tts_adapters.audio8_tts import Audio8TTSAdapter +from vllm_omni.model_executor.models.audio8_tts.pipeline import AUDIO8_TTS_PIPELINE + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +SLOW_AR_STAGE_KEY = "audio8_tts_slow_ar" + + +def _pipeline_stage_keys() -> list[str]: + return [stage.model_stage for stage in AUDIO8_TTS_PIPELINE.stages] + + +def test_pipeline_stage_key_is_the_one_the_adapter_claims(): + """The shipped pipeline must name the stage the adapter matches on.""" + assert SLOW_AR_STAGE_KEY in _pipeline_stage_keys() + assert SLOW_AR_STAGE_KEY in Audio8TTSAdapter.stage_keys + + +def test_detection_resolves_slow_ar_stage_to_the_audio8_adapter(): + assert detect_tts_model_type(SLOW_AR_STAGE_KEY, "Audio8TTSSlowARForConditionalGeneration") == "audio8_tts" + assert resolve_adapter("audio8_tts") is Audio8TTSAdapter + + +def test_adapter_is_registered_in_the_global_tables(): + """Importing the package must register the adapter, not just define it.""" + assert "audio8_tts" in all_tts_model_types() + assert SLOW_AR_STAGE_KEY in all_tts_stage_keys() + + +def test_codec_decoder_stage_does_not_serve_speech(): + """Only stage 0 answers /v1/audio/speech; the codec stage must not match.""" + codec_stage_key = _pipeline_stage_keys()[1] + assert codec_stage_key != SLOW_AR_STAGE_KEY + assert detect_tts_model_type(codec_stage_key, "Audio8TTSCodecDecoder") != "audio8_tts" + + +def test_apply_sampling_overrides_defaults_tts_local_seed_for_fast_ar(): + """The deploy seed must reach the Fast AR through ``tts_local_seed``. + + Only the Slow AR is seeded via ``sampling_metadata.generators``; the residual + codebooks read ``tts_local_seed`` and otherwise fall back to global RNG, which + makes an unseeded request's audio depend on batch composition. The adapter + defaults it from the stage-0 seed without clobbering an explicit value. + """ + from types import SimpleNamespace + + from vllm.sampling_params import SamplingParams + + adapter = Audio8TTSAdapter.__new__(Audio8TTSAdapter) + request = SimpleNamespace(max_new_tokens=None) + + # Deploy default seed propagates to tts_local_seed. + out = adapter.apply_sampling_overrides([SamplingParams(seed=42)], request) + assert out[0].extra_args["tts_local_seed"] == 42 + + # An explicit tts_local_seed is left intact (setdefault, not overwrite). + pinned = [SamplingParams(seed=42, extra_args={"tts_local_seed": 7})] + assert adapter.apply_sampling_overrides(pinned, request)[0].extra_args["tts_local_seed"] == 7 + + # No seed at all -> nothing injected (non-deterministic by request contract). + out_unseeded = adapter.apply_sampling_overrides([SamplingParams(seed=None)], request) + assert (out_unseeded[0].extra_args or {}).get("tts_local_seed") is None diff --git a/tests/entrypoints/openai_api/test_tts_detection.py b/tests/entrypoints/openai_api/test_tts_detection.py index 3b8d17997..d54ce36d5 100644 --- a/tests/entrypoints/openai_api/test_tts_detection.py +++ b/tests/entrypoints/openai_api/test_tts_detection.py @@ -132,6 +132,8 @@ _PIPELINE_STAGES = [ "audex_thinker", "audex_tta_thinker", "audex_xcodec", + "audio8_tts_codec_decoder", + "audio8_tts_slow_ar", "audio_generation", "audio_tokenizer", "audio_vae", diff --git a/tests/helpers/assertions.py b/tests/helpers/assertions.py index 65d28109f..dd25aca71 100644 --- a/tests/helpers/assertions.py +++ b/tests/helpers/assertions.py @@ -659,7 +659,11 @@ def _compute_pcm_hnr_db(pcm_samples: np.ndarray, sr: int = _PCM_SPEECH_SAMPLE_RA return float(np.mean(hnr_values)) if hnr_values else 0.0 -def _assert_pcm_int16_speech_hnr(audio_bytes: bytes, min_hnr_db: float = _MIN_PCM_SPEECH_HNR_DB) -> None: +def _assert_pcm_int16_speech_hnr( + audio_bytes: bytes, + min_hnr_db: float = _MIN_PCM_SPEECH_HNR_DB, + sr: int = _PCM_SPEECH_SAMPLE_RATE_HZ, +) -> None: """Validate harmonic-to-noise ratio on raw int16 PCM from /v1/audio/speech. min_hnr_db defaults to the global _MIN_PCM_SPEECH_HNR_DB (1.0 dB), @@ -668,12 +672,16 @@ def _assert_pcm_int16_speech_hnr(audio_bytes: bytes, min_hnr_db: float = _MIN_PC intrinsically around -2 dB) can pass a lower per-test threshold via request_config["min_hnr_db"] to keep the catastrophic-failure check while not gating CI on a model-intrinsic property. + + ``sr`` must be the stream's real sample rate: the autocorrelation lag window + is derived from it, so a wrong rate detunes the 80-400 Hz pitch search and + understates HNR for models that do not decode at 24 kHz. """ assert audio_bytes is not None and len(audio_bytes) >= 2, "missing PCM bytes" assert len(audio_bytes) % 2 == 0, "PCM byte length must be aligned to int16" pcm_samples = np.frombuffer(audio_bytes, dtype=np.int16).astype(np.float32) / 32768.0 - hnr = _compute_pcm_hnr_db(pcm_samples) - print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {min_hnr_db} dB)") + hnr = _compute_pcm_hnr_db(pcm_samples, sr=sr) + print(f"PCM speech HNR: {hnr:.2f} dB (threshold: {min_hnr_db} dB, sr={sr})") assert hnr >= min_hnr_db, ( f"Audio distortion detected: HNR={hnr:.2f} dB < {min_hnr_db} dB. " "Voice clone decoder may be losing ref_code speaker context on later chunks." @@ -934,7 +942,15 @@ def assert_audio_speech_response(response: Any, request_config: dict[str, Any], if run_level in {"advanced_model", "full_model"}: if req_fmt == "pcm" and response.audio_bytes: min_hnr_db = float(request_config.get("min_hnr_db", _MIN_PCM_SPEECH_HNR_DB)) - _assert_pcm_int16_speech_hnr(response.audio_bytes, min_hnr_db=min_hnr_db) + # Raw PCM carries no header, so the HNR pitch search has to be told + # the rate. Defaulting to 24 kHz for a 44.1 kHz model shifts the + # search window to 147-735 Hz and misses the fundamental entirely, + # scoring clean speech ~1.9 dB lower than it is. + _assert_pcm_int16_speech_hnr( + response.audio_bytes, + min_hnr_db=min_hnr_db, + sr=int(request_config.get("expected_sample_rate") or _PCM_SPEECH_SAMPLE_RATE_HZ), + ) transcript = _resolve_audio_transcript(response, request_config, run_level, speech_api=True) if transcript is not None: diff --git a/tests/helpers/client.py b/tests/helpers/client.py index 6a6a9b7ed..ed5158b92 100644 --- a/tests/helpers/client.py +++ b/tests/helpers/client.py @@ -12,6 +12,7 @@ import copy import io import json import time +from collections.abc import Mapping from dataclasses import asdict, dataclass from io import BytesIO from pathlib import Path @@ -2123,6 +2124,123 @@ class OfflineOmniClient: assert_audio_speech_response(result, request_config, run_level="core_model") return result + def send_tokenized_tts_request(self, request_config: dict[str, Any]) -> OmniResponse: + """Offline TTS for models whose engine prompt is *pre-tokenized*. + + Fish-Speech-style DualAR models (and any model whose prompt embeds codec + codes) cannot go through :meth:`send_audio_speech_request`: the prompt is + built by the caller as ``prompt_token_ids`` plus per-request + ``additional_information``, not from ``mm_processor_kwargs``. + + request_config keys: + - ``prompt_token_ids``: ``list[int]`` or ``list[list[int]]`` (one entry + per request). + - ``additional_information``: dict, or list of dicts matching + ``prompt_token_ids``. + - ``expected_sample_rate``: asserted against the reported ``sr``. + - ``min_duration_s`` / ``max_duration_s``: bounds on decoded audio. + - plus every key ``assert_audio_speech_response`` understands + (``response_format``, ``min_audio_bytes``, ``input``, ...). + """ + token_ids = request_config.get("prompt_token_ids") + if not token_ids: + raise ValueError("request_config must contain 'prompt_token_ids'") + batched_ids = token_ids if isinstance(token_ids[0], (list, tuple)) else [token_ids] + + extra = request_config.get("additional_information") + if extra is None: + batched_extra: list[dict[str, Any] | None] = [None] * len(batched_ids) + elif isinstance(extra, dict): + batched_extra = [extra] * len(batched_ids) + else: + batched_extra = list(extra) + if len(batched_extra) != len(batched_ids): + raise ValueError("'additional_information' must match the number of prompts") + + prompts: list[dict[str, Any]] = [] + for ids, info in zip(batched_ids, batched_extra, strict=True): + prompt: dict[str, Any] = {"prompt_token_ids": list(ids)} + if info is not None: + prompt["additional_information"] = info + prompts.append(prompt) + + start_time = time.perf_counter() + outputs = self.runner.omni.generate(prompts) + e2e_latency = time.perf_counter() - start_time + + assert len(outputs) == len(prompts), f"Expected {len(prompts)} outputs, got {len(outputs)}" + expected_sr = request_config.get("expected_sample_rate") + min_duration = request_config.get("min_duration_s") + max_duration = request_config.get("max_duration_s") + + result = OmniResponse() + wavs: list[torch.Tensor] = [] + for index, stage_output in enumerate(outputs): + assert stage_output.outputs, f"Request {index} produced no output" + multimodal_output = stage_output.outputs[0].multimodal_output + assert isinstance(multimodal_output, Mapping), ( + f"Request {index}: expected a mapping, got {type(multimodal_output)}" + ) + + # Never use ``a or b`` here: truthiness on a tensor raises. + audio = multimodal_output.get("model_outputs") + if audio is None: + audio = multimodal_output.get("audio") + assert audio is not None, f"Request {index}: no audio key in {sorted(multimodal_output)}" + if isinstance(audio, list): + chunks = [torch.as_tensor(chunk).float().cpu().reshape(-1) for chunk in audio if chunk is not None] + assert chunks, f"Request {index}: audio list contained no tensors" + audio = torch.cat(chunks, dim=0) + wav = torch.as_tensor(audio).float().cpu().reshape(-1) + assert wav.numel() > 0, f"Request {index}: decoded audio is empty" + wavs.append(wav) + + sr_raw = multimodal_output.get("sr") + if isinstance(sr_raw, list): + sr_raw = sr_raw[-1] if sr_raw else None + assert sr_raw is not None, f"Request {index}: missing sample rate" + sample_rate = int(sr_raw.item() if hasattr(sr_raw, "item") else sr_raw) + if expected_sr is not None: + assert sample_rate == int(expected_sr), ( + f"Request {index}: expected sample rate {expected_sr}, got {sample_rate}" + ) + + duration_s = wav.numel() / sample_rate + if min_duration is not None: + assert duration_s >= float(min_duration), ( + f"Request {index}: audio too short ({duration_s:.2f}s < {min_duration}s)" + ) + if max_duration is not None: + assert duration_s <= float(max_duration), ( + f"Request {index}: audio too long ({duration_s:.2f}s > {max_duration}s)" + ) + + wav_buf = io.BytesIO() + sf.write(wav_buf, wav.numpy(), samplerate=sample_rate, format="WAV", subtype="PCM_16") + result = OmniResponse( + success=True, + audio_bytes=wav_buf.getvalue(), + audio_format="audio/wav", + e2e_latency=e2e_latency, + ) + assert_audio_speech_response(result, request_config, run_level="core_model") + + if request_config.get("assert_distinct_outputs") and len(wavs) > 1: + # Distinct inputs must decode to distinct audio. Leaked per-request + # codec state shows up as two requests producing (near-)identical + # waveforms; different lengths already prove distinctness. + for i in range(len(wavs)): + for j in range(i + 1, len(wavs)): + a, b = wavs[i], wavs[j] + if a.numel() != b.numel(): + continue + max_diff = (a - b).abs().max().item() + assert max_diff > 1e-3, ( + f"Requests {i} and {j} produced near-identical audio " + f"(max sample diff {max_diff:.2e}); possible codec-state crosstalk" + ) + return result + def start_profile(self, profile_prefix: str | None = None, stages: list[int] | None = None) -> list[Any]: return self.runner.start_profile(profile_prefix=profile_prefix, stages=stages) diff --git a/tests/model_executor/models/audio8_tts/__init__.py b/tests/model_executor/models/audio8_tts/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/model_executor/models/audio8_tts/test_audio8_tts_codec_decoder.py b/tests/model_executor/models/audio8_tts/test_audio8_tts_codec_decoder.py new file mode 100644 index 000000000..a737a7f4b --- /dev/null +++ b/tests/model_executor/models/audio8_tts/test_audio8_tts_codec_decoder.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import functools + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +NUM_CODEBOOKS = 10 +#: Samples the fake codec returns per frame, so trims are easy to assert. +SAMPLES_PER_FRAME = 100 + + +@functools.lru_cache(maxsize=1) +def _decoder_cls(): + from vllm_omni.model_executor.models.audio8_tts.audio8_tts_codec_decoder import Audio8TTSCodecDecoder + + return Audio8TTSCodecDecoder + + +class _FakeCodec: + """Returns ``SAMPLES_PER_FRAME`` ramp samples per frame.""" + + def __init__(self): + self.calls: list[tuple[int, ...]] = [] + + def decode(self, codes_bqf: torch.Tensor) -> torch.Tensor: + self.calls.append(tuple(codes_bqf.shape)) + frames = int(codes_bqf.shape[-1]) + total = frames * SAMPLES_PER_FRAME + return torch.arange(total, dtype=torch.float32).view(1, 1, total) + + +def _make_decoder(codec: _FakeCodec, num_codebooks: int = NUM_CODEBOOKS, splits=None): + decoder = object.__new__(_decoder_cls()) + torch.nn.Module.__init__(decoder) + decoder._codec = codec + decoder._codec_device = torch.device("cpu") + decoder._codec_dtype = torch.float32 + decoder._num_codebooks = num_codebooks + decoder._sample_rate = 44100 + decoder._frame_size = 2048 + decoder._logged_stats = True + decoder._ensure_codec_loaded = lambda: None + if splits is not None: + decoder._split_request_ids = lambda ids, seq_token_counts=None: splits + return decoder + + +def test_left_context_frames_are_trimmed_from_the_emitted_chunk(): + """The decoder must emit *delta* audio: the left-context prefix exists only + so the causal codec has history and must never reach the client twice.""" + codec = _FakeCodec() + decoder = _make_decoder(codec) + codes = torch.arange(NUM_CODEBOOKS * 4, dtype=torch.long).reshape(NUM_CODEBOOKS, 4) + + out = decoder.forward( + input_ids=torch.tensor([0], dtype=torch.long), + runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 3}}], + ) + + audios = out.multimodal_outputs["model_outputs"] + assert len(audios) == 1 + # 4 frames decoded, 3 of context trimmed => 1 frame of new audio. + assert audios[0].shape[0] == SAMPLES_PER_FRAME + assert codec.calls == [(1, NUM_CODEBOOKS, 4)] + + +def test_tensor_codes_from_runtime_info_win_over_input_ids(): + codec = _FakeCodec() + decoder = _make_decoder(codec, num_codebooks=2) + out = decoder.forward( + input_ids=torch.zeros(64, dtype=torch.long), + runtime_additional_information=[{"codes": {"audio": torch.tensor([[1, 2], [3, 4]], dtype=torch.long)}}], + ) + assert codec.calls == [(1, 2, 2)] + assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME + + +def test_flat_input_ids_are_reshaped_codebook_major(): + codec = _FakeCodec() + decoder = _make_decoder(codec, num_codebooks=2) + out = decoder.forward(input_ids=torch.arange(6, dtype=torch.long), runtime_additional_information=[{}]) + assert codec.calls == [(1, 2, 3)] + assert out.multimodal_outputs["model_outputs"][0].shape[0] == 3 * SAMPLES_PER_FRAME + + +def test_every_return_path_sets_model_outputs_and_sr(): + """A branch that omits ``model_outputs`` makes the serving layer drop the + chunk silently, so assert the key exists on all the early-exit paths.""" + codec = _FakeCodec() + + empty = _make_decoder(codec).forward(input_ids=torch.zeros(0, dtype=torch.long)) + assert empty.multimodal_outputs["model_outputs"][0].numel() == 0 + assert empty.multimodal_outputs["sr"] + + ragged = _make_decoder(codec, num_codebooks=4).forward( + input_ids=torch.arange(6, dtype=torch.long), # 6 % 4 != 0 + runtime_additional_information=[{}], + ) + assert ragged.multimodal_outputs["model_outputs"][0].numel() == 0 + + codes = torch.zeros((NUM_CODEBOOKS, 2), dtype=torch.long) + no_new = _make_decoder(codec).forward( + input_ids=torch.tensor([0], dtype=torch.long), + runtime_additional_information=[{"codes": {"audio": codes}, "meta": {"left_context_size": 2}}], + ) + assert no_new.multimodal_outputs["model_outputs"][0].numel() == 0 + assert codec.calls == [], "a context-only chunk must not be decoded" + + +def test_mixed_batch_keeps_per_request_alignment(): + """An empty request must not shift the following request's audio into its + slot: the output list index is the request index.""" + codec = _FakeCodec() + splits = [ + torch.empty((0,), dtype=torch.long), + torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long), + ] + decoder = _make_decoder(codec, splits=splits) + out = decoder.forward( + input_ids=torch.arange(NUM_CODEBOOKS * 2, dtype=torch.long), + runtime_additional_information=[{}, {"meta": {"left_context_size": 1}}], + ) + audios = out.multimodal_outputs["model_outputs"] + assert len(audios) == 2 + assert audios[0].numel() == 0 + assert audios[1].shape[0] == SAMPLES_PER_FRAME + + +def test_codes_with_wrong_codebook_count_fall_back_to_input_ids(): + codec = _FakeCodec() + decoder = _make_decoder(codec, num_codebooks=2) + out = decoder.forward( + input_ids=torch.arange(4, dtype=torch.long), + runtime_additional_information=[{"codes": {"audio": torch.zeros((5, 3), dtype=torch.long)}}], + ) + assert codec.calls == [(1, 2, 2)] + assert out.multimodal_outputs["model_outputs"][0].shape[0] == 2 * SAMPLES_PER_FRAME diff --git a/tests/model_executor/models/audio8_tts/test_audio8_tts_prompt_utils.py b/tests/model_executor/models/audio8_tts/test_audio8_tts_prompt_utils.py new file mode 100644 index 000000000..9047d06f5 --- /dev/null +++ b/tests/model_executor/models/audio8_tts/test_audio8_tts_prompt_utils.py @@ -0,0 +1,112 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest + +from vllm_omni.model_executor.models.audio8_tts.prompt_utils import ( + build_text_only_prompt_ids, + build_voice_clone_prompt_ids, + estimate_voice_clone_prompt_len, + normalize_text, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _SegmentTokenizer: + """Tokenizer stub that maps each encoded string to one sentinel id. + + Lets the tests assert the *segment sequence* the prompt builder emits, which + is what must stay byte-identical to the reference ``ArkttsProcessor``. + """ + + def __init__(self): + self.vocab: dict[str, int] = {} + + def encode(self, text: str, add_special_tokens: bool = True): + assert add_special_tokens is False, "Audio8 prompts must not add extra special tokens" + return [self.vocab.setdefault(text, 1000 + len(self.vocab))] + + def segment_of(self, token_id: int) -> str: + return next(text for text, tid in self.vocab.items() if tid == token_id) + + +def _segments(tokenizer: _SegmentTokenizer, ids: list[int]) -> list[str]: + return [tokenizer.segment_of(i) for i in ids] + + +def test_text_only_prompt_matches_reference_segment_order(): + tokenizer = _SegmentTokenizer() + ids, normalized = build_text_only_prompt_ids(tokenizer, " Hello world ") + + assert normalized == "Hello world" + assert _segments(tokenizer, ids) == [ + "<|im_start|>system\n", + "convert the provided text to speech", + "<|im_end|>\n", + "<|im_start|>user\n", + "Hello world", + "<|im_end|>\n", + "<|im_start|>assistant\n<|voice|>", + ] + + +def test_voice_clone_prompt_places_reference_codes_between_prefix_and_suffix(): + tokenizer = _SegmentTokenizer() + semantic_ids = [151678, 151679, 151680] + ids, ref_start, text, ref_text = build_voice_clone_prompt_ids( + tokenizer, "Target text", "Reference transcript", semantic_ids + ) + + assert text == "Target text" + # A reference transcript without an explicit tag gets speaker 0. + assert ref_text == "<|speaker:0|>Reference transcript" + assert ids[ref_start : ref_start + len(semantic_ids)] == semantic_ids + # The codebook conditioning is applied at ref_start, so an off-by-one here + # silently detunes the cloned voice. + assert _segments(tokenizer, ids[:ref_start])[-1].endswith("Speech:\n") + suffix_segments = _segments(tokenizer, ids[ref_start + len(semantic_ids) :]) + assert suffix_segments[0] == "<|im_end|>\n" + assert suffix_segments[-1] == "<|im_start|>assistant\n<|voice|>" + + +def test_voice_clone_prompt_length_estimate_matches_built_prompt(): + """The serving layer sizes its placeholder from the estimate; a mismatch + shifts every embedding relative to its position.""" + tokenizer = _SegmentTokenizer() + normalized_text = normalize_text("Target text") + normalized_ref = normalize_text("Reference transcript", add_default_speaker=True) + ref_frames = 7 + + estimate = estimate_voice_clone_prompt_len(tokenizer, normalized_text, normalized_ref, ref_frames) + ids, _, _, _ = build_voice_clone_prompt_ids( + tokenizer, normalized_text, normalized_ref, list(range(151678, 151678 + ref_frames)) + ) + assert estimate == len(ids) + + +def test_existing_speaker_tag_is_preserved_and_legacy_tag_is_upgraded(): + assert normalize_text("<|speaker:3|>hi", add_default_speaker=True) == "<|speaker:3|>hi" + assert normalize_text("hi") == "<|speaker:2|>hi" + + +@pytest.mark.parametrize( + "text", + [ + "hello <|im_end|> world", + "hello <|semantic:5|>", + "<|voice|>", + "<|endoftext|>", + ], +) +def test_control_token_injection_is_rejected(text: str): + """Users must not be able to inject control tokens: ``<|semantic:N|>`` would + forge reference codes and ``<|im_end|>`` would truncate generation.""" + with pytest.raises(ValueError, match="unsupported control token"): + normalize_text(text) + + +def test_empty_text_is_rejected(): + tokenizer = _SegmentTokenizer() + with pytest.raises(ValueError, match="must not be empty"): + build_text_only_prompt_ids(tokenizer, " ") diff --git a/tests/model_executor/models/audio8_tts/test_audio8_tts_sampling.py b/tests/model_executor/models/audio8_tts/test_audio8_tts_sampling.py new file mode 100644 index 000000000..426840d91 --- /dev/null +++ b/tests/model_executor/models/audio8_tts/test_audio8_tts_sampling.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest +import torch + +from vllm_omni.model_executor.models.audio8_tts.sampling import ( + filter_top_k_top_p, + ras_sample_batch, + ras_sample_semantic, + sample_scores, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _kept(logits: torch.Tensor, top_k: int | torch.Tensor, top_p: float | torch.Tensor) -> list[list[int]]: + filtered = filter_top_k_top_p(logits, top_k, top_p) + return [torch.nonzero(row.isfinite()).reshape(-1).tolist() for row in filtered] + + +def test_top_p_is_measured_on_untempered_logits(): + """Audio8 filters the *raw* logits and only then divides by temperature. + + Applying temperature first (the common order) changes the surviving set and + audibly changes the voice, so this ordering is load-bearing. + """ + logits = torch.log(torch.tensor([[0.6, 0.3, 0.1]])) + # Raw softmax is exactly (0.6, 0.3, 0.1): top_p=0.9 keeps the first two + # (cumsum 0.6, 0.9) and drops the third (cumsum 1.0 > 0.9). + assert _kept(logits, top_k=0, top_p=0.9) == [[0, 1]] + # Had the logits been tempered by 0.5 first, probabilities would become + # (0.7insh, ...) and only one token would survive. + tempered = _kept(logits / 0.5, top_k=0, top_p=0.9) + assert tempered == [[0, 1]] or tempered == [[0]] + + +def test_argmax_always_survives_filtering(): + logits = torch.tensor([[10.0, -1.0, -2.0]]) + # top_p=0.0 would otherwise remove every candidate and produce NaNs. + assert _kept(logits, top_k=1, top_p=0.0) == [[0]] + + +def test_per_row_top_k_and_top_p_are_honoured(): + logits = torch.log(torch.tensor([[0.5, 0.3, 0.15, 0.05], [0.5, 0.3, 0.15, 0.05]])) + kept = _kept(logits, top_k=torch.tensor([1, 3]), top_p=torch.tensor([1.0, 1.0])) + assert kept == [[0], [0, 1, 2]] + + +def test_zero_temperature_row_falls_back_to_argmax(): + logits = torch.tensor([[1.0, 5.0, 2.0], [1.0, 5.0, 2.0]]) + out = sample_scores( + logits, + temperature=torch.tensor([0.0, 1.0]), + top_k=0, + top_p=1.0, + generator=torch.Generator().manual_seed(0), + ) + assert int(out[0]) == 1 + + +def test_ras_substitutes_only_repeated_semantic_ids(): + """RAS re-rolls a repeat from a flatter distribution; the EOS slot and + non-repeats must pass through untouched, or utterances get truncated.""" + num_semantic = 2 + # Row 0 will draw id 0 (a repeat) -> substituted. + # Row 1 will draw id 2 (the EOS slot) -> never substituted even if recent. + logits = torch.tensor( + [ + [10.0, -20.0, -20.0], + [-20.0, -20.0, 10.0], + ] + ) + recent = torch.tensor([[0, -1], [2, -1]]) + out = ras_sample_semantic( + logits, + recent, + temperature=1e-6, # deterministic: argmax within each draw + top_k=0, + top_p=1.0, + ras_temperature=1e-6, + ras_top_p=1.0, + num_semantic_ids=num_semantic, + ) + # Both draws are argmax, so substitution is observable only through which + # branch was taken; assert the EOS row is preserved verbatim. + assert int(out[1]) == 2 + assert int(out[0]) == 0 + + +def test_ras_without_history_returns_the_plain_draw(): + logits = torch.tensor([[10.0, 0.0, -5.0]]) + out = ras_sample_semantic( + logits, + None, + temperature=1e-6, + top_k=0, + top_p=1.0, + ras_temperature=1.0, + ras_top_p=0.9, + num_semantic_ids=2, + ) + assert int(out[0]) == 0 + + +def test_sampling_is_reproducible_for_a_seeded_generator(): + logits = torch.randn(4, 32, generator=torch.Generator().manual_seed(7)) + first = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234)) + second = sample_scores(logits, temperature=0.7, top_k=8, top_p=0.9, generator=torch.Generator().manual_seed(1234)) + assert torch.equal(first, second) + + +def test_batch_routes_each_row_to_its_own_generator(): + """Regression for the shared slot-0 generator bug. + + A shared generator over ``[num_reqs, vocab]`` made every row draw from slot + 0's RNG stream, so a request's audio depended on its batch neighbours and + per-request ``seed`` was ignored for slots > 0. Flat logits make the draw + purely noise-driven so the wrong generator changes the token. + """ + # Flat logits: the sampled id is determined only by the generator's noise, + # so routing the generator to the wrong slot changes the result (coincidence ~1/256). + logits = torch.zeros(2, 256) + kw = dict(temperature=1.0, top_k=0, top_p=1.0, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=200) + + # (1) Each batched row equals sampling that row alone with its own generator. + batched = ras_sample_batch( + logits, + None, + generators={0: torch.Generator().manual_seed(11), 1: torch.Generator().manual_seed(22)}, + num_reqs=2, + **kw, + ) + row0 = ras_sample_semantic(logits[0:1], None, generator=torch.Generator().manual_seed(11), **kw) + row1 = ras_sample_semantic(logits[1:2], None, generator=torch.Generator().manual_seed(22), **kw) + assert int(batched[0]) == int(row0) + assert int(batched[1]) == int(row1) + + # (2) Row 1 must not change when only slot 0's seed changes. + other = ras_sample_batch( + logits, + None, + generators={0: torch.Generator().manual_seed(9999), 1: torch.Generator().manual_seed(22)}, + num_reqs=2, + **kw, + ) + assert int(other[1]) == int(batched[1]) + + +def test_single_request_batch_keeps_the_fast_path(): + """``num_reqs == 1`` must match a direct single-row draw (no per-row loop).""" + logits = torch.randn(1, 48, generator=torch.Generator().manual_seed(5)) + kw = dict(temperature=0.8, top_k=16, top_p=0.9, ras_temperature=1.0, ras_top_p=0.9, num_semantic_ids=40) + batched = ras_sample_batch(logits, None, generators={0: torch.Generator().manual_seed(99)}, num_reqs=1, **kw) + direct = ras_sample_semantic(logits, None, generator=torch.Generator().manual_seed(99), **kw) + assert torch.equal(batched, direct) diff --git a/tests/model_executor/stage_input_processors/test_audio8_tts_async_chunk.py b/tests/model_executor/stage_input_processors/test_audio8_tts_async_chunk.py new file mode 100644 index 000000000..9190a33f6 --- /dev/null +++ b/tests/model_executor/stage_input_processors/test_audio8_tts_async_chunk.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from collections import defaultdict +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.model_executor.stage_input_processors.audio8_tts import ( + extract_last_frame, + slow_ar_to_codec_decoder_async_chunk, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +NUM_CODEBOOKS = 3 + + +def _make_transfer_manager(*, chunk_frames=2, left_context=1, initial_chunk_frames=0): + return SimpleNamespace( + code_prompt_token_ids=defaultdict(list), + put_req_chunk=defaultdict(int), + connector=SimpleNamespace( + config={ + "extra": { + "codec_chunk_frames": chunk_frames, + "codec_left_context_frames": left_context, + "initial_codec_chunk_frames": initial_chunk_frames, + } + } + ), + ) + + +def _make_request(req_id="req", *, finished=False): + return SimpleNamespace( + external_req_id=req_id, + additional_information=None, + is_finished=lambda: finished, + ) + + +def _frame(value: int) -> torch.Tensor: + return torch.full((NUM_CODEBOOKS,), value, dtype=torch.long) + + +def _step(transfer_manager, request, frame_value: int | None, *, finished: bool = False): + multimodal_output = {} if frame_value is None else {"audio_codes": _frame(frame_value).unsqueeze(0)} + return slow_ar_to_codec_decoder_async_chunk(transfer_manager, multimodal_output, request, is_finished=finished) + + +def test_codes_accumulate_across_steps_and_chunk_is_withheld_until_full(): + """Codes must accumulate; emitting per-step would starve the codec of the + context it needs and resetting would truncate the utterance.""" + transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1) + request = _make_request() + + assert _step(transfer_manager, request, 1) is None + payload = _step(transfer_manager, request, 2) + + assert payload is not None + assert len(transfer_manager.code_prompt_token_ids["req"]) == 2 + # [num_codebooks, frames], codebook-major. + assert tuple(payload.codes.audio.shape) == (NUM_CODEBOOKS, 2) + assert payload.codes.audio[0].tolist() == [1, 2] + # Nothing precedes the first chunk, so there is no left context yet. + assert payload.meta.left_context_size == 0 + + +def test_second_chunk_carries_left_context_and_reports_its_size(): + transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1) + request = _make_request() + for value in (1, 2, 3): + _step(transfer_manager, request, value) + payload = _step(transfer_manager, request, 4) + + assert payload is not None + # 1 context frame + 2 new frames. + assert tuple(payload.codes.audio.shape) == (NUM_CODEBOOKS, 3) + assert payload.codes.audio[0].tolist() == [2, 3, 4] + assert payload.meta.left_context_size == 1 + + +def test_partial_tail_is_flushed_when_the_request_finishes(): + transfer_manager = _make_transfer_manager(chunk_frames=4, left_context=2) + request = _make_request() + _step(transfer_manager, request, 1) + _step(transfer_manager, request, 2) + payload = _step(transfer_manager, request, 3, finished=True) + + assert payload is not None + assert payload.codes.audio[0].tolist() == [1, 2, 3] + assert bool(payload.meta.finished) is True + + +def test_finished_request_without_any_frame_emits_a_terminal_payload(): + transfer_manager = _make_transfer_manager() + payload = slow_ar_to_codec_decoder_async_chunk( + transfer_manager, None, _make_request(finished=True), is_finished=True + ) + assert payload is not None + assert payload.codes.audio.numel() == 0 + assert bool(payload.meta.finished) is True + + +def test_terminal_call_on_a_boundary_does_not_re_emit_the_last_chunk(): + """A finished call carrying no new frame, arriving when the stream sits + exactly on a chunk boundary, must close the stream rather than re-ship the + last window. Before the guard, the final chunk_frames frames shipped twice.""" + transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1) + request = _make_request() + for value in (1, 2, 3, 4): # length ends exactly on a chunk boundary + _step(transfer_manager, request, value) + # Separate terminal call, no new frame: the boundary chunk is already sent. + payload = _step(transfer_manager, request, None, finished=True) + assert payload is not None + assert payload.codes.audio.numel() == 0 + assert bool(payload.meta.finished) is True + + +def test_final_frame_on_a_boundary_still_emits_when_it_arrives_finished(): + """If the last frame both lands on a boundary and carries finished, its chunk + has not been shipped yet, so it must still be emitted (the guard must key on + 'no new frame this call', not merely on being finished at a boundary).""" + transfer_manager = _make_transfer_manager(chunk_frames=2, left_context=1) + request = _make_request() + _step(transfer_manager, request, 1) + payload = _step(transfer_manager, request, 2, finished=True) + assert payload is not None + assert payload.codes.audio[0].tolist() == [1, 2] + assert bool(payload.meta.finished) is True + + +def test_initial_chunk_shortens_time_to_first_audio(): + transfer_manager = _make_transfer_manager(chunk_frames=4, left_context=2, initial_chunk_frames=1) + request = _make_request() + payload = _step(transfer_manager, request, 1) + + assert payload is not None + assert payload.codes.audio[0].tolist() == [1] + assert payload.meta.left_context_size == 0 + + +def test_prefill_placeholder_frames_are_not_forwarded(): + """Prefill emits an all-zero placeholder frame per position; forwarding it + would prepend silence and desynchronise the chunk boundaries.""" + assert extract_last_frame({"audio_codes": torch.zeros((3, NUM_CODEBOOKS), dtype=torch.long)}) is None + assert extract_last_frame({}) is None + assert extract_last_frame({"audio_codes": torch.zeros((0, NUM_CODEBOOKS), dtype=torch.long)}) is None + + +def test_explicit_validity_flag_overrides_the_all_zero_heuristic(): + frame = torch.zeros((1, NUM_CODEBOOKS), dtype=torch.long) + out = extract_last_frame({"audio_codes": frame, "audio_code_valid": torch.tensor([True])}) + assert out is not None and out.tolist() == [0] * NUM_CODEBOOKS + + +def test_invalid_chunk_config_fails_loudly(): + transfer_manager = _make_transfer_manager(chunk_frames=0) + request = _make_request() + with pytest.raises(ValueError, match="codec chunk config"): + _step(transfer_manager, request, 1) diff --git a/tests/worker/test_gpu_ar_model_runner.py b/tests/worker/test_gpu_ar_model_runner.py index 5e3cfe865..be282da81 100644 --- a/tests/worker/test_gpu_ar_model_runner.py +++ b/tests/worker/test_gpu_ar_model_runner.py @@ -1829,6 +1829,7 @@ class TestPreferModelSamplerNoneFallback: if _declares_prefer_model_sampler(p.read_text(encoding="utf-8", errors="ignore")) } expected = { + "audio8_tts", "cosyvoice3", "glm_tts", "higgs_audio_v2", diff --git a/vllm_omni/config/pipeline_registry.py b/vllm_omni/config/pipeline_registry.py index 5586b956e..b711f0cfb 100644 --- a/vllm_omni/config/pipeline_registry.py +++ b/vllm_omni/config/pipeline_registry.py @@ -39,6 +39,7 @@ from vllm_omni.model_executor.models.audex.pipeline import ( AUDEX_TTA_PIPELINE, AUDEX_TTS_PIPELINE, ) +from vllm_omni.model_executor.models.audio8_tts.pipeline import AUDIO8_TTS_PIPELINE from vllm_omni.model_executor.models.aura_omni.pipeline import AURA_OMNI_PIPELINE from vllm_omni.model_executor.models.bagel.pipeline import ( BAGEL_PIPELINE, @@ -172,6 +173,7 @@ OMNI_PIPELINES: dict[str, PipelineConfig | PipelineResolverFunc] = { "voxtral_tts": VOXTRAL_TTS_PIPELINE, "glm_tts": GLM_TTS_PIPELINE, "fish_qwen3_omni": FISH_SPEECH_PIPELINE, + "arktts": AUDIO8_TTS_PIPELINE, "ming_flash_omni": MING_FLASH_OMNI_PIPELINE, "ming_flash_omni_tts": MING_FLASH_OMNI_TTS_PIPELINE, "ming_flash_omni_thinker_only": MING_FLASH_OMNI_THINKER_ONLY_PIPELINE, diff --git a/vllm_omni/deploy/audio8_tts.yaml b/vllm_omni/deploy/audio8_tts.yaml new file mode 100644 index 000000000..644d77745 --- /dev/null +++ b/vllm_omni/deploy/audio8_tts.yaml @@ -0,0 +1,80 @@ +# Audio8 TTS Preview 0.6B deploy: async-chunk streaming slow_ar -> codec_decoder. +# Model: Audio8/Audio8-TTS-Preview-0.6b (model_type = "arktts"). +# +# trust_remote_code is deliberately OFF: the checkpoint ships an `auto_map`, and +# transformers prefers remote code over registered classes when it is enabled, +# which would bypass vllm-omni's Audio8TTSConfig (the Qwen2-shaped view that +# Qwen2Model needs). The tokenizer is a plain PreTrainedTokenizerFast and needs +# no remote code either. +async_chunk: true +enable_chunked_prefill: false + +connectors: + connector_of_shared_memory: + name: SharedMemoryConnector + extra: + codec_streaming: true + connector_get_sleep_s: 0.01 + connector_get_max_wait_first_chunk: 3000 + connector_get_max_wait: 300 + # ~21.5 Hz codec; 25 frames ~= 1.16 s of audio. + codec_chunk_frames: 25 + codec_left_context_frames: 25 + initial_codec_chunk_frames: 4 + # float32 keeps the Snake activations / weight-normed convs clean. + audio8_tts_codec_dtype: float32 + +stages: + - stage_id: 0 + # >= 4 so concurrent requests pipeline; a default of 1 starves the codec + # window and produces audible gaps under load. + # + # Measured on 1x H20 (20 prompts, mean audio 4.5 s): at client concurrency 8, + # raising this to 8 buys +27% throughput and 4.5x lower first-packet latency + # (1124 ms -> 247 ms) at the cost of a 0.22 s worst-case buffer deficit in + # 1 of 20 requests. 4 is the default because every measured point here had + # zero underrun; see examples/online_serving/text_to_speech/README.md. + max_num_seqs: 4 + # 0.6B weights + a 2048-token KV cache need very little: sized for a small + # slice of the GPU so the codec stage and other tenants still fit. + gpu_memory_utilization: 0.2 + enforce_eager: false + trust_remote_code: false + enable_prefix_caching: false + async_scheduling: false + # The checkpoint's max_seq_len is 2048 packed text+audio positions; with + # chunked prefill off, max_num_batched_tokens must be >= max_model_len. + max_model_len: 2048 + max_num_batched_tokens: 2048 + devices: "0" + output_connectors: + to_stage_1: connector_of_shared_memory + default_sampling_params: + # generation_config.json of the released checkpoint. + temperature: 0.7 + top_k: 50 + top_p: 0.9 + max_tokens: 1024 + seed: 42 + repetition_penalty: 1.0 + + - stage_id: 1 + max_num_seqs: 1 + # The codec is ~1.4 GB of weights plus per-chunk activations. + gpu_memory_utilization: 0.1 + enforce_eager: true + trust_remote_code: false + enable_prefix_caching: false + async_scheduling: false + max_model_len: 2048 + max_num_batched_tokens: 2048 + devices: "0" + input_connectors: + from_stage_0: connector_of_shared_memory + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + repetition_penalty: 1.0 diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 75a8a435e..88d9f090d 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -712,6 +712,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): if self._tts_model_type in ( "cosyvoice3", "fish_tts", + "audio8_tts", "omnivoice", "moss_tts_nano", "glm_tts", @@ -721,6 +722,7 @@ class OmniOpenAIServingSpeech(OpenAIServing, AudioMixin): label = { "cosyvoice3": "CosyVoice3", "fish_tts": "Fish Speech", + "audio8_tts": "Audio8 TTS", "omnivoice": "OmniVoice", "moss_tts_nano": "MOSS-TTS-Nano", "higgs_audio_v2": "Higgs-Audio V2", diff --git a/vllm_omni/entrypoints/openai/tts_adapters/__init__.py b/vllm_omni/entrypoints/openai/tts_adapters/__init__.py index a09d97b68..39882274d 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/__init__.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/__init__.py @@ -104,6 +104,7 @@ def tts_entry_stage_archs() -> frozenset[str]: from vllm_omni.entrypoints.openai.tts_adapters import ( # noqa: E402,F401 audex, audex_tta, + audio8_tts, cosyvoice3, covo_audio, dots_tts, diff --git a/vllm_omni/entrypoints/openai/tts_adapters/audio8_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/audio8_tts.py new file mode 100644 index 000000000..1706a64d3 --- /dev/null +++ b/vllm_omni/entrypoints/openai/tts_adapters/audio8_tts.py @@ -0,0 +1,190 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview serving adapter for ``/v1/audio/speech``.""" + +import copy +from typing import TYPE_CHECKING, Any + +import numpy as np +import torch +from vllm.inputs import tokens_input +from vllm.utils.async_utils import make_async + +from vllm_omni.entrypoints.openai.tts_adapters import register_tts_adapter +from vllm_omni.entrypoints.openai.tts_adapters.base import ( + ARTTSAdapter, + PreparedRequest, + apply_max_new_tokens, + conditioning_cache_salt, +) +from vllm_omni.model_executor.models.audio8_tts.codec_utils import ( + estimate_reference_code_frames, +) +from vllm_omni.model_executor.models.audio8_tts.prompt_utils import ( + build_text_only_prompt_ids, + estimate_voice_clone_prompt_len, + normalize_text, +) + +if TYPE_CHECKING: + from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest + + +@register_tts_adapter +class Audio8TTSAdapter(ARTTSAdapter): + """Audio8 TTS Preview 0.6B: text-only synthesis and zero-shot voice cloning.""" + + stage_keys = frozenset({"audio8_tts_slow_ar"}) + name = "audio8_tts" + + def __init__(self, ctx: Any) -> None: + super().__init__(ctx) + self._cached_tokenizer: Any = None + + def validate(self, request: "OpenAICreateSpeechRequest") -> str | None: + server = self.ctx.server + err = server._apply_uploaded_speaker(request) + if err: + return err + if not request.input or not request.input.strip(): + return "Input text cannot be empty" + + if request.ref_audio is not None: + fmt_err = server._validate_ref_audio_format(request.ref_audio) + if fmt_err: + return fmt_err + if not request.ref_text or not request.ref_text.strip(): + return "Voice cloning requires 'ref_text' (transcript of the reference audio)" + + if request.max_new_tokens is not None: + if request.max_new_tokens < self.max_new_tokens_min: + return f"max_new_tokens must be at least {self.max_new_tokens_min}" + if request.max_new_tokens > self.max_new_tokens_max: + return f"max_new_tokens cannot exceed {self.max_new_tokens_max}" + + return None + + def apply_sampling_overrides( + self, + sampling_params_list: list, + request: "OpenAICreateSpeechRequest", + prompt: dict[str, Any] | None = None, + request_id: str | None = None, + ) -> list: + sampling_params_list = apply_max_new_tokens(sampling_params_list, request) + # Seed the Fast AR (talker_mtp) residual sampling per request. The Slow AR + # is already seeded via sampling_metadata.generators, but the residual + # codebooks read tts_local_seed; without it they fall back to global RNG, + # so an unseeded request's audio would depend on batch composition. Default + # it from the deploy seed; an explicit request.seed still overrides it + # downstream. Mirrors how serving handles qwen3_tts. + if sampling_params_list: + default_seed = sampling_params_list[0].seed + if default_seed is not None: + sampling_params_list = copy.deepcopy(sampling_params_list) + stage0 = sampling_params_list[0] + if stage0.extra_args is None: + stage0.extra_args = {} + stage0.extra_args.setdefault("tts_local_seed", int(default_seed)) + return sampling_params_list + + async def build( + self, + request: "OpenAICreateSpeechRequest", + sampling_params_list: list, + has_inline_ref_audio: bool, + ) -> PreparedRequest: + del sampling_params_list + server = self.ctx.server + ref_audio_data = None + if request.ref_audio is not None: + wav_list, sample_rate = await server._resolve_ref_audio(request.ref_audio) + ref_audio_data = (wav_list, sample_rate) + # Prompt building tokenizes and, for voice clone, allocates tensors; keep + # it off the event loop via the server's single-worker TTS executor. + build_prompt = make_async(self._build_prompt, executor=server._tts_executor) + prompt = await build_prompt( + request, + ref_audio_data=ref_audio_data, + has_inline_ref_audio=has_inline_ref_audio, + ) + tts_params: dict = {} + prompt["cache_salt"] = conditioning_cache_salt(request, tts_params) + return PreparedRequest(prompt=prompt, tts_params=tts_params, model_type=self.name) + + def _tokenizer(self) -> Any: + """The Audio8 tokenizer, loaded once per process.""" + if self._cached_tokenizer is None: + from transformers import AutoTokenizer + + model_name = self.ctx.server.engine_client.model_config.model + self._cached_tokenizer = AutoTokenizer.from_pretrained(model_name) + return self._cached_tokenizer + + def _estimate_prompt_len(self, text: str, ref_text: str, ref_audio: object) -> int: + """Exact clone-prompt length, without encoding the reference audio. + + Deliberately not defensive: the placeholder this sizes must match the + length ``preprocess()`` actually builds, so a wrong guess corrupts the + request instead of failing it. Let errors surface to the caller. + """ + if not isinstance(ref_audio, (list, tuple)) or len(ref_audio) != 2: + raise ValueError("Audio8 TTS reference audio must be a (samples, sample_rate) pair") + wav, sample_rate = ref_audio + ref_frames = estimate_reference_code_frames(len(wav), int(sample_rate)) + return estimate_voice_clone_prompt_len(self._tokenizer(), text, ref_text, ref_frames) + + def _build_prompt( + self, + request: "OpenAICreateSpeechRequest", + ref_audio_data: tuple[list[float], int] | None = None, + *, + has_inline_ref_audio: bool = False, + ) -> dict[str, Any]: + """Build the engine prompt for Audio8 TTS Preview. + + Text-only prompts are tokenized here. Voice cloning cannot be: the + prompt embeds the reference audio's own codec codes, so the model-side + ``preprocess`` builds it and this path only reserves a placeholder of the + exact final length. + """ + server = self.ctx.server + if ref_audio_data is None or not request.ref_text: + prompt_ids, normalized_text = build_text_only_prompt_ids(self._tokenizer(), request.input) + # Scalars are list-wrapped for the text-only path, matching the + # other TTS entrypoints' additional_information shape. + additional_information: dict[str, Any] = {"text": [normalized_text]} + if request.max_new_tokens is not None: + additional_information["max_new_tokens"] = [request.max_new_tokens] + prompt = tokens_input(prompt_token_ids=prompt_ids) + prompt["additional_information"] = additional_information + return prompt + + wav_samples, sample_rate = ref_audio_data + normalized_text = normalize_text(request.input) + normalized_ref_text = normalize_text(request.ref_text, add_default_speaker=True) + placeholder_len = self._estimate_prompt_len(normalized_text, normalized_ref_text, ref_audio_data) + + # Structured clone: scalars (not list-wrapped) because model-side + # preprocess() consumes these fields directly. + additional_information = { + "text": normalized_text, + "ref_text": normalized_ref_text, + "ref_audio_wav": torch.from_numpy(np.asarray(wav_samples, dtype=np.float32)), + "ref_audio_sr": int(sample_rate), + "audio8_structured_voice_clone": True, + } + if request.voice is not None: + voice_lower = request.voice.lower() + if voice_lower in server.uploaded_speakers and not has_inline_ref_audio: + # Lets the model cache this speaker's encoded codes. + additional_information["voice_name"] = voice_lower + additional_information["voice_created_at"] = server._voice_created_at(voice_lower) + if request.max_new_tokens is not None: + additional_information["max_new_tokens"] = request.max_new_tokens + prompt = tokens_input(prompt_token_ids=[1] * placeholder_len) + prompt["additional_information"] = additional_information + return prompt + + +__all__ = ["Audio8TTSAdapter"] diff --git a/vllm_omni/model_executor/models/audio8_tts/__init__.py b/vllm_omni/model_executor/models/audio8_tts/__init__.py new file mode 100644 index 000000000..a6db04780 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview (DualAR, 44.1 kHz codec) model implementation.""" diff --git a/vllm_omni/model_executor/models/audio8_tts/audio8_tts_codec_decoder.py b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_codec_decoder.py new file mode 100644 index 000000000..6f93d31a2 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_codec_decoder.py @@ -0,0 +1,284 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview -- codec decoder (Stage 1). + +Consumes frames of ``num_codebooks`` codec codes and decodes them to a 44.1 kHz +waveform. Streaming contract is delta: with ``async_chunk`` on, each chunk is +``[left_context | new_frames]``; the whole window is decoded so the causal +codec keeps continuous context, then only the new tail is returned. +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import torch +import torch.nn as nn +from vllm.config import VllmConfig +from vllm.forward_context import get_forward_context, is_forward_context_available +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.audio8_tts.codec_utils import load_arktts_codec +from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import ( + ARKTTS_CODEC_FRAME_SIZE, + ARKTTS_CODEC_SAMPLE_RATE, +) +from vllm_omni.model_executor.models.output_templates import OmniOutput + +logger = init_logger(__name__) + +_DTYPES = { + "fp16": torch.float16, + "float16": torch.float16, + "half": torch.float16, + "bf16": torch.bfloat16, + "bfloat16": torch.bfloat16, + "fp32": torch.float32, + "float32": torch.float32, + "": torch.float32, +} + + +def _connector_extra_config(vllm_config: VllmConfig) -> dict[str, Any]: + model_config = getattr(vllm_config, "model_config", None) + connector_cfg = getattr(model_config, "stage_connector_config", None) + if isinstance(connector_cfg, dict): + return connector_cfg.get("extra", connector_cfg) + extra = getattr(connector_cfg, "extra", None) + return extra if isinstance(extra, dict) else {} + + +def _resolve_dtype(extra_cfg: dict[str, Any]) -> torch.dtype: + raw = str(extra_cfg.get("audio8_tts_codec_dtype", "float32")).strip().lower() + if raw not in _DTYPES: + raise ValueError(f"Invalid Audio8 TTS codec dtype: {raw!r}") + return _DTYPES[raw] + + +class Audio8TTSCodecDecoder(nn.Module): + """Stage 1: codec codes -> waveform (runs under the generation runner).""" + + input_modalities = "audio" + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + del prefix + self.vllm_config = vllm_config + self.model_path = vllm_config.model_config.model + config = vllm_config.model_config.hf_config + + self.have_multimodal_outputs = True + self.has_preprocess = False + self.has_postprocess = False + self.enable_update_additional_information = True + self.requires_raw_input_tokens = True + + self._num_codebooks = int(getattr(config, "num_codebooks", 10)) + self._sample_rate = int(getattr(config, "codec_sample_rate", ARKTTS_CODEC_SAMPLE_RATE)) + self._frame_size = int(getattr(config, "codec_frame_size", ARKTTS_CODEC_FRAME_SIZE)) + self._codec_kwargs = { + "post_n_layer": int(getattr(config, "codec_post_n_layer", 8)), + "post_n_head": int(getattr(config, "codec_post_n_head", 16)), + "post_n_local_heads": int(getattr(config, "codec_post_n_local_heads", 8)), + "post_intermediate_size": int(getattr(config, "codec_post_intermediate_size", 1216)), + } + # float32 by default: the codec's Snake activations and weight-normed + # convs lose audible headroom in bf16. + self._codec_dtype = _resolve_dtype(_connector_extra_config(vllm_config)) + self._codec: nn.Module | None = None + self._codec_device: torch.device | None = None + self._logged_stats = False + + # -------------------- codec -------------------- + + def _ensure_codec_loaded(self) -> None: + if self._codec is not None: + return + device = self.vllm_config.device_config.device + self._codec = load_arktts_codec( + self.model_path, + device=device, + dtype=self._codec_dtype, + role="decode", + **self._codec_kwargs, + ) + self._codec_device = torch.device(device) + + # -------------------- vLLM hooks -------------------- + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + if input_ids.numel() == 0: + return torch.empty((0, 1), device=input_ids.device, dtype=torch.float32) + return torch.zeros((input_ids.shape[0], 1), device=input_ids.device, dtype=torch.float32) + + def compute_logits(self, hidden_states: torch.Tensor | OmniOutput, sampling_metadata: Any = None) -> None: + return None + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """No-op: the codec comes from ``codec.pth``, not the main checkpoint.""" + del weights + return set() + + # -------------------- request splitting -------------------- + + def _split_request_ids( + self, + ids: torch.Tensor, + seq_token_counts: list[int] | None = None, + ) -> list[torch.Tensor]: + if seq_token_counts is not None and len(seq_token_counts) > 1: + boundaries = [0] + for count in seq_token_counts: + boundaries.append(boundaries[-1] + count) + total = ids.numel() + return [ids[boundaries[i] : min(boundaries[i + 1], total)] for i in range(len(seq_token_counts))] + if is_forward_context_available(): + slices = get_forward_context().ubatch_slices + if slices is not None and len(slices) > 1 and not any(hasattr(s, "token_slice") for s in slices): + boundaries = [0] + for size in slices: + boundaries.append(boundaries[-1] + size) + return [ids[boundaries[i] : boundaries[i + 1]] for i in range(len(boundaries) - 1)] + return [ids] + + def _codes_from_runtime_info( + self, + info: dict[str, Any] | None, + device: torch.device, + ) -> torch.Tensor | None: + """Prefer the tensor payload the connector carried over input_ids.""" + if not isinstance(info, dict): + return None + codes = info.get("codes", {}).get("audio") + if not isinstance(codes, torch.Tensor) or codes.numel() == 0: + return None + codes = codes.to(device=device, dtype=torch.long, non_blocking=True).contiguous() + if codes.ndim != 2 or int(codes.shape[0]) != self._num_codebooks: + logger.warning( + "Audio8 TTS codec decoder expected codes of shape [%d, frames], got %s", + self._num_codebooks, + tuple(codes.shape), + ) + return None + return codes + + # -------------------- decode -------------------- + + @torch.no_grad() + def forward( + self, + input_ids: torch.Tensor | None = None, + positions: torch.Tensor | None = None, + intermediate_tensors: Any = None, + inputs_embeds: torch.Tensor | None = None, + runtime_additional_information: list[dict[str, Any]] | None = None, + **kwargs: Any, + ) -> OmniOutput: + """Decode one chunk per request. + + ``input_ids`` is codebook-major flat codes + (``[cb0_f0 ... cb0_fN, cb1_f0 ...]``); tensor codes on the connector + win over ``input_ids``. Every return path must set ``model_outputs`` or + the serving layer silently drops a chunk of audio. + """ + del positions, intermediate_tensors, inputs_embeds + sr_tensor = torch.tensor(self._sample_rate, dtype=torch.int32) + empty = torch.zeros((0,), dtype=torch.float32) + + self._ensure_codec_loaded() + codec = self._codec + assert codec is not None + + if input_ids is None or input_ids.numel() == 0: + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": [empty], "sr": [sr_tensor]}, + ) + + ids = input_ids.reshape(-1).to(dtype=torch.long) + per_request_ids = self._split_request_ids(ids, kwargs.get("seq_token_counts")) + num_req = len(per_request_ids) + + context_frames = [0] * num_req + if runtime_additional_information is not None: + for index, info in enumerate(runtime_additional_information[:num_req]): + meta = info.get("meta", {}) if isinstance(info, dict) else {} + context_frames[index] = int(meta.get("left_context_size", 0) or 0) + + audios: list[torch.Tensor] = [empty] * num_req + for index, request_ids in enumerate(per_request_ids): + codes_qf = ( + self._codes_from_runtime_info(runtime_additional_information[index], ids.device) + if runtime_additional_information is not None and index < len(runtime_additional_information) + else None + ) + if codes_qf is None: + if request_ids.numel() < self._num_codebooks: + continue + if int(request_ids.numel()) % self._num_codebooks != 0: + logger.warning( + "Audio8 TTS codec decoder got %d ids, not divisible by num_codebooks=%d; skipping request", + int(request_ids.numel()), + self._num_codebooks, + ) + continue + codes_qf = request_ids.reshape(self._num_codebooks, -1) + + total_frames = int(codes_qf.shape[1]) + if total_frames <= 0: + continue + new_frames = total_frames - context_frames[index] + if new_frames <= 0: + logger.warning( + "Audio8 TTS codec decoder chunk has no new frames (total=%d, context=%d)", + total_frames, + context_frames[index], + ) + continue + + if not self._logged_stats: + self._logged_stats = True + logger.info( + "Audio8 TTS codec decoder: frames=%d codebooks=%d dtype=%s sr=%d", + total_frames, + self._num_codebooks, + self._codec_dtype, + self._sample_rate, + ) + + with torch.amp.autocast("cuda", enabled=False): + # Move codes to the codec's device explicitly: a mismatch here + # silently falls back to CPU or raises deep inside the decoder. + waveform = codec.decode(codes_qf.unsqueeze(0).to(device=self._codec_device)) + waveform = waveform.reshape(-1).to(dtype=torch.float32) + + # Trim the left-context prefix proportionally: the codec's padding / + # rounding means the decoded length is not exactly + # frames * frame_size. + if context_frames[index] > 0: + cut = int(round(context_frames[index] / total_frames * int(waveform.shape[0]))) + cut = max(0, min(cut, int(waveform.shape[0]))) + waveform = waveform[cut:] + if waveform.numel() > 0: + audios[index] = waveform.contiguous() + + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": audios, "sr": [sr_tensor] * num_req}, + ) + + def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput: + del kwargs + if isinstance(model_outputs, OmniOutput): + return model_outputs + if not (isinstance(model_outputs, tuple) and len(model_outputs) == 2): + raise TypeError(f"Audio8TTSCodecDecoder expected (audio_tensor, sr), got {type(model_outputs)}") + audio_tensor, sample_rate = model_outputs + return OmniOutput( + text_hidden_states=None, + multimodal_outputs={"model_outputs": audio_tensor, "sr": sample_rate}, + ) + + +__all__ = ["Audio8TTSCodecDecoder"] diff --git a/vllm_omni/model_executor/models/audio8_tts/audio8_tts_fast_ar.py b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_fast_ar.py new file mode 100644 index 000000000..5b4ff9696 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_fast_ar.py @@ -0,0 +1,346 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Fast AR: the residual-codebook predictor. + +Runs once per Slow AR step over ``num_codebooks + 1`` positions: position 0 is +the projected Slow AR hidden state (primes the KV cache, logits discarded), +position 1 is the sampled semantic code (not predicted, only fed in), positions +2.. are the previously sampled residual codes; only codes 1..num_codebooks-1 +are sampled here. + +Not paged: at most 10 tokens per call, SDPA over a per-call pre-allocated KV +cache. Matches the reference ``ArkttsModel._generate_codebooks``. +""" + +from __future__ import annotations + +from collections.abc import Iterable + +import torch +import torch.nn as nn +import torch.nn.functional as F +from vllm.config import VllmConfig +from vllm.config.vllm import set_current_vllm_config +from vllm.logger import init_logger +from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.model_executor.layers.linear import ( + MergedColumnParallelLinear, + QKVParallelLinear, + RowParallelLinear, +) +from vllm.model_executor.layers.rotary_embedding import get_rope +from vllm.model_executor.model_loader.weight_utils import default_weight_loader + +from .configuration_audio8_tts import Audio8TTSFastARConfig, Audio8TTSSlowARConfig +from .sampling import sample_scores + +logger = init_logger(__name__) + + +class _FastARAttention(nn.Module): + """GQA self-attention over the short codebook sequence. + + ``forward_one`` decodes a single position against a caller-owned KV cache; + there is no batched-prefill path because the codebook loop is inherently + sequential. + """ + + def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.hidden_size = config.hidden_size + self.num_heads = config.num_attention_heads + self.num_kv_heads = config.num_key_value_heads + self.head_dim = config.head_dim + self.q_size = self.num_heads * self.head_dim + self.kv_size = self.num_kv_heads * self.head_dim + self.scaling = self.head_dim**-0.5 + self._use_gqa = self.num_kv_heads != self.num_heads + + self.qkv_proj = QKVParallelLinear( + hidden_size=self.hidden_size, + head_size=self.head_dim, + total_num_heads=self.num_heads, + total_num_kv_heads=self.num_kv_heads, + bias=config.attention_qkv_bias, + prefix=f"{prefix}.qkv_proj", + disable_tp=True, + ) + self.o_proj = RowParallelLinear( + input_size=self.num_heads * self.head_dim, + output_size=self.hidden_size, + bias=False, + prefix=f"{prefix}.o_proj", + disable_tp=True, + ) + self.rotary_emb = get_rope( + self.head_dim, + max_position=config.max_position_embeddings, + # Audio8 TTS uses interleaved (GPT-J) RoPE, not NeoX. + is_neox_style=False, + rope_parameters={"rope_theta": config.rope_theta, "rope_type": "default"}, + ) + if config.attention_qk_norm: + self.q_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + self.k_norm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + else: + self.q_norm = None + self.k_norm = None + + def forward_one( + self, + hidden_states: torch.Tensor, + position_ids: torch.Tensor, + k_cache: torch.Tensor, + v_cache: torch.Tensor, + cache_pos: int, + ) -> torch.Tensor: + bsz = int(hidden_states.shape[0]) + + qkv, _ = self.qkv_proj(hidden_states.reshape(bsz, -1)) + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) + + if self.q_norm is not None: + q = self.q_norm(q.view(-1, self.num_heads, self.head_dim)).view(q.shape) + k = self.k_norm(k.view(-1, self.num_kv_heads, self.head_dim)).view(k.shape) + + q, k = self.rotary_emb(position_ids.reshape(-1), q, k) + + q = q.view(bsz, self.num_heads, self.head_dim).unsqueeze(2) + k_cache[:bsz, :, cache_pos, :] = k.view(bsz, self.num_kv_heads, self.head_dim) + v_cache[:bsz, :, cache_pos, :] = v.view(bsz, self.num_kv_heads, self.head_dim) + + attn_out = F.scaled_dot_product_attention( + q, + k_cache[:bsz, :, : cache_pos + 1, :], + v_cache[:bsz, :, : cache_pos + 1, :], + scale=self.scaling, + is_causal=False, + enable_gqa=self._use_gqa, + ) + attn_out = attn_out.transpose(1, 2).reshape(bsz, -1) + output, _ = self.o_proj(attn_out) + return output + + +class _FastARMLP(nn.Module): + """SiLU-gated MLP (``w1``/``w3`` gate-up, ``w2`` down).""" + + def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.gate_up_proj = MergedColumnParallelLinear( + input_size=config.hidden_size, + output_sizes=[config.intermediate_size] * 2, + bias=False, + prefix=f"{prefix}.gate_up_proj", + disable_tp=True, + ) + self.down_proj = RowParallelLinear( + input_size=config.intermediate_size, + output_size=config.hidden_size, + bias=False, + prefix=f"{prefix}.down_proj", + disable_tp=True, + ) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + gate_up, _ = self.gate_up_proj(x) + gate, up = gate_up.chunk(2, dim=-1) + x, _ = self.down_proj(F.silu(gate) * up) + return x + + +class _FastARDecoderLayer(nn.Module): + def __init__(self, config: Audio8TTSFastARConfig, *, prefix: str = "") -> None: + super().__init__() + self.self_attn = _FastARAttention(config, prefix=f"{prefix}.self_attn") + self.mlp = _FastARMLP(config, prefix=f"{prefix}.mlp") + self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + + def forward_one( + self, + hidden_states: torch.Tensor, + position_ids: torch.Tensor, + k_cache: torch.Tensor, + v_cache: torch.Tensor, + cache_pos: int, + ) -> torch.Tensor: + hidden_states = hidden_states + self.self_attn.forward_one( + self.input_layernorm(hidden_states), position_ids, k_cache, v_cache, cache_pos + ) + return hidden_states + self.mlp(self.post_attention_layernorm(hidden_states)) + + +class Audio8TTSFastAR(nn.Module): + """Residual-codebook predictor for Audio8 TTS. + + ``forward`` returns all ``num_codebooks`` codes for one frame, with code 0 + copied from the already-sampled semantic token. + """ + + def __init__( + self, + *, + vllm_config: VllmConfig, + config: Audio8TTSFastARConfig, + slow_ar_config: Audio8TTSSlowARConfig, + prefix: str = "fast_ar", + ) -> None: + super().__init__() + self._vllm_config = vllm_config + self.config = config + self.slow_ar_config = slow_ar_config + + self.layers = nn.ModuleList( + [_FastARDecoderLayer(config, prefix=f"{prefix}.layers.{i}") for i in range(config.num_hidden_layers)] + ) + self.fast_embeddings = nn.Embedding(config.vocab_size, config.hidden_size) + self.fast_output = nn.Linear(config.hidden_size, config.vocab_size, bias=False) + self.fast_norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + if slow_ar_config.hidden_size != config.hidden_size: + self.fast_project_in = nn.Linear(slow_ar_config.hidden_size, config.hidden_size, bias=True) + else: + # 0.6b: dim == fast_dim == 896, and the checkpoint has no + # fast_project_in tensor. + self.fast_project_in = nn.Identity() + + self._num_codebooks = int(config.num_codebooks) + self._fast_dim = int(config.hidden_size) + self._k_cache: torch.Tensor | None = None + self._v_cache: torch.Tensor | None = None + self._pos_ids: torch.Tensor | None = None + + def _ensure_buffers(self, bsz: int, device: torch.device, dtype: torch.dtype) -> None: + max_seq = self._num_codebooks + 1 + if ( + self._k_cache is not None + and self._pos_ids is not None + and self._k_cache.shape[1] >= bsz + and self._k_cache.device == device + and self._k_cache.dtype == dtype + and self._pos_ids.shape[0] >= bsz + ): + return + self._k_cache = torch.empty( + self.config.num_hidden_layers, + bsz, + self.config.num_key_value_heads, + max_seq, + self.config.head_dim, + dtype=dtype, + device=device, + ) + self._v_cache = torch.empty_like(self._k_cache) + self._pos_ids = torch.arange(max_seq, dtype=torch.long, device=device).unsqueeze(0).expand(bsz, -1).contiguous() + + def _forward_one(self, embed: torch.Tensor, position_ids: torch.Tensor, cache_pos: int) -> torch.Tensor: + assert self._k_cache is not None and self._v_cache is not None + hidden = embed + for layer_idx, layer in enumerate(self.layers): + hidden = layer.forward_one( + hidden, + position_ids, + self._k_cache[layer_idx], + self._v_cache[layer_idx], + cache_pos, + ) + return hidden + + @torch.inference_mode() + def forward( + self, + slow_ar_hidden: torch.Tensor, + semantic_token_id: torch.Tensor, + *, + do_sample: bool = True, + temperature: float = 0.7, + top_k: int = 50, + top_p: float = 0.9, + generator: torch.Generator | None = None, + ) -> torch.Tensor: + """Predict one frame of codec codes. + + ``slow_ar_hidden`` must already be post-final-norm when + ``norm_fastlayer_input`` is set (it is for the released checkpoint) -- + vLLM's Qwen2Model returns exactly that. Returns ``[B, num_codebooks]`` + int64 codes; code 0 is the semantic code, codes 1.. come from this module. + """ + bsz = int(slow_ar_hidden.shape[0]) + num_cb = self._num_codebooks + device = slow_ar_hidden.device + dtype = slow_ar_hidden.dtype + + semantic_begin = int(self.slow_ar_config.semantic_begin_id) + codebook_size = int(self.slow_ar_config.codebook_size) + # Non-semantic tokens (EOS) clamp to code 0; those frames are dropped + # downstream because the request finishes on that token. + semantic_code = (semantic_token_id.reshape(bsz) - semantic_begin).clamp(min=0, max=codebook_size - 1) + + codes = torch.empty(bsz, num_cb, dtype=torch.long, device=device) + codes[:, 0] = semantic_code + + self._ensure_buffers(bsz, device, dtype) + pos_ids = self._pos_ids + assert pos_ids is not None + + # Position 0 primes the cache; its logits are unused. + projected = self.fast_project_in(slow_ar_hidden.reshape(bsz, -1)) + self._forward_one(projected, pos_ids[:bsz, 0], 0) + + embed = self.fast_embeddings(semantic_code) + for step in range(1, num_cb): + hidden = self._forward_one(embed, pos_ids[:bsz, step], step) + logits = self.fast_output(self.fast_norm(hidden)).float() + next_ids = sample_scores( + logits, + temperature=temperature, + top_k=top_k, + top_p=top_p, + do_sample=do_sample, + generator=generator, + ) + codes[:, step] = next_ids + if step < num_cb - 1: + embed = self.fast_embeddings(next_ids) + + return codes + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + """Load already-remapped Fast AR weights (see the Slow AR remapper).""" + with set_current_vllm_config(self._vllm_config): + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded: set[str] = set() + stacked_params_mapping = [ + ("qkv_proj", "q_proj", "q"), + ("qkv_proj", "k_proj", "k"), + ("qkv_proj", "v_proj", "v"), + ("gate_up_proj", "gate_proj", 0), + ("gate_up_proj", "up_proj", 1), + ] + for name, loaded_weight in weights: + handled = False + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + mapped = name.replace(weight_name, param_name) + if mapped not in params_dict: + continue + param = params_dict[mapped] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + if weight_loader == default_weight_loader: + weight_loader(param, loaded_weight) + else: + weight_loader(param, loaded_weight, shard_id) + loaded.add(mapped) + handled = True + break + if handled or name not in params_dict: + continue + param = params_dict[name] + weight_loader = getattr(param, "weight_loader", default_weight_loader) + weight_loader(param, loaded_weight) + loaded.add(name) + return loaded + + +__all__ = ["Audio8TTSFastAR"] diff --git a/vllm_omni/model_executor/models/audio8_tts/audio8_tts_slow_ar.py b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_slow_ar.py new file mode 100644 index 000000000..9d2cd884e --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/audio8_tts_slow_ar.py @@ -0,0 +1,755 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview -- Slow AR model (Stage 0). + +Text (+ optional reference voice) -> one semantic token per codec frame; the +frame's remaining 9 codebooks are produced by the nested Fast AR. + +Backbone is vLLM's ``Qwen2Model`` (qkv bias, no q/k norm), with three +adjustments: interleaved (GPT-J) RoPE, multi-codebook input embedding at +semantic positions (summed, unlike Fish Speech's ``1/sqrt(Q+1)`` rescale), and +logits masked to the semantic range + ``<|im_end|>`` then Repetition-Aware +sampled. + +Streaming contract is delta: each decode step appends one frame of +``num_codebooks`` codes to ``codes.audio``. +""" + +from __future__ import annotations + +import copy +from collections.abc import Iterable +from typing import Any + +import numpy as np +import torch +import torch.nn as nn +from transformers import AutoTokenizer +from vllm.config import VllmConfig +from vllm.distributed import get_pp_group +from vllm.logger import init_logger +from vllm.model_executor.layers.logits_processor import LogitsProcessor +from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead +from vllm.model_executor.model_loader.weight_utils import default_weight_loader +from vllm.model_executor.models.qwen2 import Qwen2Model +from vllm.model_executor.models.utils import PPMissingLayer, maybe_prefix +from vllm.sequence import IntermediateTensors +from vllm.v1.outputs import SamplerOutput + +from vllm_omni.model_executor.models.output_templates import OmniOutput +from vllm_omni.utils.speaker_cache import get_speaker_cache + +from .audio8_tts_fast_ar import Audio8TTSFastAR +from .codec_utils import encode_reference_audio_codes +from .configuration_audio8_tts import ( + Audio8TTSConfig, + Audio8TTSFastARConfig, + Audio8TTSSlowARConfig, +) +from .prompt_utils import build_voice_clone_prompt_ids +from .sampling import SAMPLING_EPS, ras_sample_batch + +logger = init_logger(__name__) + +#: Defaults from ``generation_config.json`` of the released checkpoint. +DEFAULT_TEMPERATURE = 0.7 +DEFAULT_TOP_K = 50 +DEFAULT_TOP_P = 0.9 + + +def _remap_audio8_tts_weights( + weights: Iterable[tuple[str, torch.Tensor]], + *, + q_size: int, + kv_size: int, + fast_q_size: int, + fast_kv_size: int, +) -> Iterable[tuple[str, torch.Tensor]]: + """Rename Audio8 checkpoint tensors to vLLM / Qwen2 names. + + The checkpoint is flat (no ``text_model.`` prefix). Slow AR tensors move + under ``model.``, Fast AR tensors under ``fast_ar.``, and ``wqkv`` splits + into ``{q,k,v}_proj``. See the body for the exact mapping table. + """ + + def rewrite_block(suffix: str) -> str: + for old, new in ( + (".attention.wo.", ".self_attn.o_proj."), + (".attention.q_norm.", ".self_attn.q_norm."), + (".attention.k_norm.", ".self_attn.k_norm."), + (".attention_norm.", ".input_layernorm."), + (".feed_forward.w1.", ".mlp.gate_proj."), + (".feed_forward.w3.", ".mlp.up_proj."), + (".feed_forward.w2.", ".mlp.down_proj."), + (".ffn_norm.", ".post_attention_layernorm."), + ): + suffix = suffix.replace(old, new) + return suffix + + for name, tensor in weights: + # RoPE tables are recomputed by vLLM. + if name.endswith(("freqs_cis", "fast_freqs_cis")) or "rotary_emb.inv_freq" in name: + continue + + if name.startswith("fast_layers."): + suffix = name[len("fast_layers.") :] + if ".attention.wqkv." in suffix: + layer_prefix, _, param = suffix.partition(".attention.wqkv.") + yield f"fast_ar.layers.{layer_prefix}.self_attn.q_proj.{param}", tensor[:fast_q_size] + yield ( + f"fast_ar.layers.{layer_prefix}.self_attn.k_proj.{param}", + tensor[fast_q_size : fast_q_size + fast_kv_size], + ) + yield ( + f"fast_ar.layers.{layer_prefix}.self_attn.v_proj.{param}", + tensor[fast_q_size + fast_kv_size :], + ) + continue + yield f"fast_ar.layers.{rewrite_block(suffix)}", tensor + continue + + if name.startswith(("fast_embeddings.", "fast_output.", "fast_norm.", "fast_project_in.")): + yield f"fast_ar.{name}", tensor + continue + + if name.startswith("layers."): + suffix = name[len("layers.") :] + if ".attention.wqkv." in suffix: + layer_prefix, _, param = suffix.partition(".attention.wqkv.") + yield f"model.layers.{layer_prefix}.self_attn.q_proj.{param}", tensor[:q_size] + yield ( + f"model.layers.{layer_prefix}.self_attn.k_proj.{param}", + tensor[q_size : q_size + kv_size], + ) + yield f"model.layers.{layer_prefix}.self_attn.v_proj.{param}", tensor[q_size + kv_size :] + continue + yield f"model.layers.{rewrite_block(suffix)}", tensor + continue + + if name == "embeddings.weight": + yield "model.embed_tokens.weight", tensor + continue + if name == "norm.weight": + yield "model.norm.weight", tensor + continue + + yield name, tensor + + +class Audio8TTSSlowARForConditionalGeneration(nn.Module): + """Stage 0: text -> semantic tokens + residual codec codes.""" + + prefer_model_sampler = True + + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + self.vllm_config = vllm_config + self.model_path = vllm_config.model_config.model + + config: Audio8TTSConfig = vllm_config.model_config.hf_config # type: ignore[assignment] + self.config = config + self.text_config: Audio8TTSSlowARConfig = config.get_text_config() + self.fast_ar_config: Audio8TTSFastARConfig = config.fast_ar_config + + self._semantic_begin_id = int(self.text_config.semantic_begin_id) + self._semantic_end_id = int(self.text_config.semantic_end_id) + self._num_semantic_ids = self._semantic_end_id - self._semantic_begin_id + 1 + self._eos_token_id = int(self.text_config.eos_token_id) + self._pad_token_id = int(self.text_config.pad_token_id) + self._codebook_size = int(self.text_config.codebook_size) + self._num_codebooks = int(self.text_config.num_codebooks) + self._ras_window_size = int(config.ras_window_size) + self._ras_temperature = float(config.ras_temperature) + self._ras_top_p = float(config.ras_top_p) + + self.have_multimodal_outputs = True + self.has_preprocess = True + self.has_postprocess = True + self.mtp_hidden_size = int(self.text_config.hidden_size) + self.talker_mtp_output_key = ("codes", "audio") + self.gpu_resident_buffer_keys: set[tuple[str, str]] = {("hidden_states", "last")} + self.talker_mtp_graph_safe = True + + self.model = Qwen2Model(vllm_config=vllm_config, prefix=maybe_prefix(prefix, "model")) + self._fix_rope_style() + + if get_pp_group().is_last_rank: + self.lm_head = ParallelLMHead( + self.text_config.vocab_size, + self.text_config.hidden_size, + quant_config=vllm_config.quant_config, + prefix=maybe_prefix(prefix, "lm_head"), + ) + else: + self.lm_head = PPMissingLayer() + self.logits_processor = LogitsProcessor(self.text_config.vocab_size) + self.make_empty_intermediate_tensors = self.model.make_empty_intermediate_tensors + + # Summed multi-codebook input embedding table. + self.codebook_embeddings = nn.Embedding( + self._codebook_size * self._num_codebooks, + self.text_config.hidden_size, + ) + + # Fast AR shares the parent's VllmConfig but must not share the + # compilation context: see the Fish Speech note on why this is + # copy.copy and not dataclasses.replace (pydantic validators re-run and + # reject an already-rebound compilation backend). + fast_ar_compilation = copy.copy(vllm_config.compilation_config) + fast_ar_compilation.static_forward_context = {} + self._fast_ar_vllm_config = copy.copy(vllm_config) + self._fast_ar_vllm_config.compilation_config = fast_ar_compilation + from vllm.config.vllm import set_current_vllm_config + + with set_current_vllm_config(self._fast_ar_vllm_config): + self.fast_ar = Audio8TTSFastAR( + vllm_config=self._fast_ar_vllm_config, + config=self.fast_ar_config, + slow_ar_config=self.text_config, + prefix="fast_ar", + ) + + # Constant logits mask: semantic codes plus <|im_end|>. Safe as a + # non-persistent buffer under vLLM (module built on the target device), + # unlike under ``PreTrainedModel.from_pretrained`` on transformers >= 5, + # which meta-inits and then empty_like's every non-persistent buffer. + vocab = int(self.text_config.vocab_size) + allowed = torch.zeros((vocab,), dtype=torch.bool) + allowed[self._semantic_begin_id : min(self._semantic_end_id + 1, vocab)] = True + if self._eos_token_id < vocab: + allowed[self._eos_token_id] = True + self.register_buffer("_semantic_allowed_mask", allowed, persistent=False) + + self._speaker_cache = get_speaker_cache() + self._tokenizer = None + + def _fix_rope_style(self) -> None: + """Rebuild RoPE as interleaved (GPT-J); vLLM's Qwen2 defaults to NeoX. + + This also replaces the reference implementation's ``freqs_cis`` table + entirely: ``get_rope`` computes its cos/sin cache in the constructor, so + there is no checkpoint-absent buffer for a loader to leave uninitialised. + """ + from vllm.model_executor.layers.rotary_embedding import get_rope + + rope_parameters = dict(self.text_config.rope_parameters or {}) + rope_parameters.setdefault("rope_type", "default") + for layer in self.model.layers: + attn = layer.self_attn + attn.rotary_emb = get_rope( + head_size=attn.head_dim, + max_position=self.text_config.max_position_embeddings, + is_neox_style=False, + rope_parameters=rope_parameters, + ) + logger.info("Audio8 TTS: switched %d layers to interleaved RoPE", len(self.model.layers)) + + # -------------------- vLLM hooks -------------------- + + def embed_input_ids(self, input_ids: torch.Tensor, **_: Any) -> torch.Tensor: + return self.model.embed_input_ids(input_ids) + + def forward( + self, + input_ids: torch.Tensor, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None = None, + inputs_embeds: torch.Tensor | None = None, + **_: Any, + ) -> torch.Tensor | IntermediateTensors: + return self.model(input_ids, positions, intermediate_tensors, inputs_embeds) + + def compute_logits( + self, + hidden_states: torch.Tensor | OmniOutput, + sampling_metadata: Any = None, + ) -> torch.Tensor | None: + if isinstance(hidden_states, OmniOutput): + hidden_states = hidden_states.text_hidden_states + if hidden_states is None: + return None + logits = self.logits_processor(self.lm_head, hidden_states) + if logits is None: + return None + return logits.masked_fill(~self._semantic_allowed_mask, float("-inf")) + + # -------------------- Repetition-Aware Sampling -------------------- + + def _compact_semantic_logits(self, logits: torch.Tensor) -> torch.Tensor: + """Slice the allowed vocabulary into ``[B, num_semantic_ids + 1]``. + + Local ids ``0..num_semantic_ids-1`` are semantic codes; the last column + is ``<|im_end|>``. Sorting 4097 columns instead of the full 155776 + vocabulary is the difference between a cheap and an expensive AR step, + and is exactly equivalent because everything else is already ``-inf``. + """ + semantic = logits[:, self._semantic_begin_id : self._semantic_end_id + 1] + eos = logits[:, self._eos_token_id : self._eos_token_id + 1] + return torch.cat((semantic, eos), dim=-1) + + def _local_to_token_id(self, local_ids: torch.Tensor) -> torch.Tensor: + is_eos = local_ids >= self._num_semantic_ids + return torch.where(is_eos, torch.full_like(local_ids, self._eos_token_id), local_ids + self._semantic_begin_id) + + def _recent_local_ids(self, output_token_ids: list[list[int]], num_reqs: int, device: torch.device): + """Build the ``[B, W]`` RAS window in compact id space. + + Reads the host-side decoded-token history the AR runner already + materialised, so this costs one small H2D copy and never syncs the GPU. + Returns ``None`` when no request has history yet. + """ + window = self._ras_window_size + if window <= 0: + return None + rows: list[list[int]] = [] + any_history = False + for req_idx in range(num_reqs): + history = output_token_ids[req_idx] if req_idx < len(output_token_ids) else [] + recent = [int(token) for token in history[-window:]] + local = [ + token - self._semantic_begin_id if self._semantic_begin_id <= token <= self._semantic_end_id else -1 + for token in recent + ] + any_history = any_history or bool(local) + rows.append([-1] * (window - len(local)) + local) + if not any_history: + return None + return torch.tensor(rows, dtype=torch.long, device=device) + + def sample(self, logits: torch.Tensor, sampling_metadata: Any) -> SamplerOutput | None: + """Sample the semantic token with the reference filter order + RAS. + + Returns ``None`` to fall back to vLLM's sampler when the request mix + needs features RAS does not model (logprobs, penalties, bad words). + """ + if logits is None or logits.numel() == 0: + return None + if sampling_metadata.max_num_logprobs is not None: + return None + if not sampling_metadata.no_penalties: + return None + if sampling_metadata.bad_words_token_ids: + return None + + logits = logits.to(torch.float32) + if sampling_metadata.allowed_token_ids_mask is not None: + num_reqs = int(logits.shape[0]) + logits.masked_fill_(sampling_metadata.allowed_token_ids_mask[:num_reqs], float("-inf")) + for processor in sampling_metadata.logitsprocs.non_argmax_invariant: + logits = processor.apply(logits) + + compact = self._compact_semantic_logits(logits) + num_reqs = int(compact.shape[0]) + device = compact.device + + temperature = sampling_metadata.temperature + if sampling_metadata.all_greedy or temperature is None: + local_ids = compact.argmax(dim=-1) + else: + temperature = temperature[:num_reqs] + top_p = DEFAULT_TOP_P if sampling_metadata.top_p is None else sampling_metadata.top_p[:num_reqs] + top_k = DEFAULT_TOP_K if sampling_metadata.top_k is None else sampling_metadata.top_k[:num_reqs] + recent = self._recent_local_ids(sampling_metadata.output_token_ids, num_reqs, device) + local_ids = ras_sample_batch( + compact, + recent, + temperature=temperature, + top_k=top_k, + top_p=top_p, + ras_temperature=self._ras_temperature, + ras_top_p=self._ras_top_p, + num_semantic_ids=self._num_semantic_ids, + generators=sampling_metadata.generators, + num_reqs=num_reqs, + ) + + token_ids = self._local_to_token_id(local_ids).to(dtype=torch.int32) + return SamplerOutput(sampled_token_ids=token_ids.unsqueeze(-1), logprobs_tensors=None) + + # -------------------- Omni multimodal plumbing -------------------- + + def make_omni_output(self, model_outputs: torch.Tensor | OmniOutput, **kwargs: Any) -> OmniOutput: + if isinstance(model_outputs, OmniOutput): + return model_outputs + + hidden = model_outputs + info_dicts = kwargs.get("model_intermediate_buffer") + if info_dicts is None: + info_dicts = kwargs.get("runtime_additional_information") or [] + + audio_codes_list: list[torch.Tensor] = [] + for info in info_dicts: + if not isinstance(info, dict): + continue + codes = info.get("codes", {}).get("audio") + if isinstance(codes, torch.Tensor): + audio_codes_list.append(codes) + + if not audio_codes_list: + return OmniOutput(text_hidden_states=hidden, multimodal_outputs={}) + + audio_codes = torch.cat(audio_codes_list, dim=0) + hidden = hidden[: int(audio_codes.shape[0])] + return OmniOutput(text_hidden_states=hidden, multimodal_outputs={"audio_codes": audio_codes}) + + # -------------------- preprocess / postprocess -------------------- + + def preprocess( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor | None, + **info_dict: Any, + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + additional_information = info_dict.get("additional_information") + if isinstance(additional_information, dict): + merged: dict[str, Any] = {k: v for k, v in info_dict.items() if k != "additional_information"} + for key, value in additional_information.items(): + merged.setdefault(key, value) + info_dict = merged + + span_len = int(input_ids.shape[0]) + if span_len <= 0: + embeds = input_embeds if input_embeds is not None else self.embed_input_ids(input_ids) + return input_ids, embeds, {} + + if span_len > 1: + return self._preprocess_prefill(input_ids, info_dict, span_len) + return self._preprocess_decode(input_ids, info_dict) + + def _prefill_chunk( + self, + prompt_embeds_buf: torch.Tensor, + start: int, + span_len: int, + device: torch.device, + ) -> torch.Tensor: + """Slice ``span_len`` rows out of the pinned CPU prompt-embed buffer.""" + total = int(prompt_embeds_buf.shape[0]) + begin = max(0, min(start, total)) + end = max(0, min(start + span_len, total)) + take = prompt_embeds_buf[begin:end] + if int(take.shape[0]) < span_len: + pad_rows = span_len - int(take.shape[0]) + pad_embed = self.embed_input_ids( + torch.tensor([self._pad_token_id], device=device, dtype=torch.long) + ).reshape(1, -1) + take = torch.cat([take.to(device=device, dtype=torch.bfloat16), pad_embed.expand(pad_rows, -1)], dim=0) + return take.to(dtype=torch.bfloat16) + return take.to(device=device, dtype=torch.bfloat16, non_blocking=True) + + def _preprocess_prefill( + self, + input_ids: torch.Tensor, + info_dict: dict[str, Any], + span_len: int, + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + device = input_ids.device + embed = info_dict.get("embed", {}) + prompt_embeds_buf = embed.get("prefill") + is_first_chunk = not isinstance(prompt_embeds_buf, torch.Tensor) or prompt_embeds_buf.ndim != 2 + + if is_first_chunk: + if bool(info_dict.get("audio8_structured_voice_clone", False)): + prompt_embeds = self._build_voice_clone_prefill_embeds(info_dict) + else: + prompt_embeds = self.embed_input_ids(input_ids.reshape(1, -1).to(torch.long)).squeeze(0) + prompt_embeds_buf = prompt_embeds.detach().to("cpu", dtype=torch.bfloat16).contiguous() + if not prompt_embeds_buf.is_pinned(): + prompt_embeds_buf = prompt_embeds_buf.pin_memory() + offset = 0 + else: + offset = int(info_dict.get("meta", {}).get("prefill_offset", 0) or 0) + + total_prompt_len = int(prompt_embeds_buf.shape[0]) + chunk = self._prefill_chunk(prompt_embeds_buf, offset, span_len, device) + next_offset = min(offset + span_len, total_prompt_len) + + # No codes are emitted during prefill; a zero frame per position keeps + # the codes buffer aligned with the hidden-state span. + zeros = torch.zeros((chunk.shape[0], self._num_codebooks), device=device, dtype=torch.long) + info_update = { + "embed": {"prefill": prompt_embeds_buf if next_offset < total_prompt_len else None}, + "meta": {"prefill_offset": next_offset}, + "codes": {"audio": zeros}, + } + return torch.full_like(input_ids, self._pad_token_id), chunk, info_update + + def _preprocess_decode( + self, + input_ids: torch.Tensor, + info_dict: dict[str, Any], + ) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]: + device = input_ids.device + token_embed = self.embed_input_ids(input_ids.reshape(1, 1).to(torch.long)).to( + device=device, dtype=torch.bfloat16 + ) + last_hidden = info_dict.get("hidden_states", {}).get("last") + if not isinstance(last_hidden, torch.Tensor): + # First decode step right after prefill: no Fast AR input yet. + logger.warning("Audio8 TTS preprocess: hidden_states.last missing; emitting text-only embedding") + return input_ids, token_embed.reshape(1, -1), {} + + # Codebook embeddings are added in talker_mtp, using this step's Fast AR + # output; adding them here would use the previous step's codes. + info_update = { + "mtp_inputs": ( + last_hidden.to(device=device, dtype=torch.bfloat16).reshape(1, -1), + torch.zeros(1, self.text_config.hidden_size, device=device, dtype=torch.bfloat16), + ), + } + return input_ids, token_embed.reshape(1, -1), info_update + + def postprocess(self, hidden_states: torch.Tensor, **_: Any) -> dict[str, Any]: + if hidden_states.numel() == 0: + return {} + return {"hidden_states": {"last": hidden_states[-1, :].detach().contiguous()}} + + # -------------------- voice cloning -------------------- + + def _get_tokenizer(self): + if self._tokenizer is None: + self._tokenizer = AutoTokenizer.from_pretrained(self.model_path) + return self._tokenizer + + def _codec_kwargs(self) -> dict[str, int]: + return { + "post_n_layer": int(self.config.codec_post_n_layer), + "post_n_head": int(self.config.codec_post_n_head), + "post_n_local_heads": int(self.config.codec_post_n_local_heads), + "post_intermediate_size": int(self.config.codec_post_intermediate_size), + } + + def _build_voice_clone_prefill_embeds(self, info_dict: dict[str, Any]) -> torch.Tensor: + """Encode the reference audio, build the clone prompt, embed the codes.""" + text = info_dict.get("text") + ref_text = info_dict.get("ref_text") + if not isinstance(text, str) or not isinstance(ref_text, str): + raise ValueError("Audio8 TTS voice cloning requires string 'text' and 'ref_text'") + + device = self.codebook_embeddings.weight.device + cache_key = None + voice_name = info_dict.get("voice_name") + if isinstance(voice_name, str) and voice_name: + cache_key = self._speaker_cache.make_cache_key( + voice_name, + model_type="audio8_tts", + created_at=int(info_dict.get("voice_created_at") or 0), + ) + cached = self._speaker_cache.get(cache_key) + if cached is not None: + logger.debug("Speaker cache HIT for Audio8 TTS speaker '%s'", voice_name) + return self._embed_voice_clone_prompt( + text, ref_text, cached["ref_codes_fq"].to(device=device, dtype=torch.long) + ) + + ref_audio_sr = info_dict.get("ref_audio_sr") + if not isinstance(ref_audio_sr, int): + raise ValueError("Audio8 TTS voice cloning requires integer 'ref_audio_sr'") + ref_audio_wav = info_dict.get("ref_audio_wav") + if ref_audio_wav is None: + raise ValueError("Audio8 TTS voice cloning requires 'ref_audio_wav'") + if not isinstance(ref_audio_wav, torch.Tensor): + ref_audio_wav = torch.from_numpy(np.asarray(ref_audio_wav, dtype=np.float32)) + + ref_codes_fq = encode_reference_audio_codes( + self.model_path, + ref_audio_wav, + ref_audio_sr, + device=device, + **self._codec_kwargs(), + ) + if cache_key is not None: + self._speaker_cache.put(cache_key, {"ref_codes_fq": ref_codes_fq.detach().cpu()}) + logger.debug("Speaker cache STORE for Audio8 TTS speaker '%s'", voice_name) + return self._embed_voice_clone_prompt(text, ref_text, ref_codes_fq) + + def _embed_voice_clone_prompt( + self, + text: str, + ref_text: str, + ref_codes_fq: torch.Tensor, + ) -> torch.Tensor: + """Embed the clone prompt, summing codebooks over the reference frames. + + Args: + ref_codes_fq: ``[frames, num_codebooks]`` reference codec codes. + """ + device = self.codebook_embeddings.weight.device + ref_codes_fq = ref_codes_fq.to(device=device, dtype=torch.long) + semantic_token_ids = (ref_codes_fq[:, 0] + self._semantic_begin_id).tolist() + prompt_ids, ref_start, _, _ = build_voice_clone_prompt_ids( + self._get_tokenizer(), text, ref_text, semantic_token_ids + ) + prompt = torch.tensor(prompt_ids, dtype=torch.long, device=device) + embeds = self.embed_input_ids(prompt.unsqueeze(0)).squeeze(0).to(dtype=torch.bfloat16) + + frames = int(ref_codes_fq.shape[0]) + if frames <= 0: + return embeds + codebooks = min(int(ref_codes_fq.shape[1]), self._num_codebooks) + offsets = (torch.arange(codebooks, device=device, dtype=torch.long) * self._codebook_size).unsqueeze(0) + codes = ref_codes_fq[:, :codebooks].clamp(min=0, max=self._codebook_size - 1) + offsets + codebook_sum = self.codebook_embeddings(codes).sum(dim=1).to(dtype=embeds.dtype) + + result = embeds.clone() + # Audio8 TTS sums the embeddings directly -- no 1/sqrt(Q+1) rescale. + result[ref_start : ref_start + frames] += codebook_sum + return result.to(dtype=torch.bfloat16) + + # -------------------- GPU-side Fast AR fast path -------------------- + + @torch.inference_mode() + def talker_mtp( + self, + input_ids: torch.Tensor, + input_embeds: torch.Tensor, + last_talker_hidden: torch.Tensor, + text_step: torch.Tensor, + seed: int | None = None, + **kwargs: Any, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Run the Fast AR and fold its codes into this step's input embedding. + + Returns: + ``(inputs_embeds, audio_codes)`` where ``audio_codes`` is + ``[B, num_codebooks]``. + """ + del text_step + bsz = int(input_ids.shape[0]) + device = input_embeds.device + + token_ids = input_ids.reshape(bsz).to(dtype=torch.long, device=device) + past_hidden = last_talker_hidden.reshape(bsz, -1).to(dtype=torch.bfloat16, device=device) + + temperature = kwargs.get("temperature") + temperature = DEFAULT_TEMPERATURE if temperature is None else float(temperature) + do_sample = kwargs.get("do_sample") + generator = kwargs.get("generator") + if generator is None and seed is not None: + generator = torch.Generator(device=device) + generator.manual_seed(int(seed)) + + _top_k = kwargs.get("top_k") + _top_p = kwargs.get("top_p") + audio_codes = self.fast_ar( + slow_ar_hidden=past_hidden, + semantic_token_id=token_ids, + do_sample=(temperature >= SAMPLING_EPS) if do_sample is None else bool(do_sample), + temperature=temperature, + top_k=int(DEFAULT_TOP_K if _top_k is None else _top_k), + top_p=float(DEFAULT_TOP_P if _top_p is None else _top_p), + generator=generator, + ) + + embeds = input_embeds.reshape(bsz, -1) + offsets = (torch.arange(self._num_codebooks, device=device, dtype=torch.long) * self._codebook_size).unsqueeze( + 0 + ) + codebook_sum = self.codebook_embeddings(audio_codes + offsets).sum(dim=1).to(dtype=embeds.dtype) + is_semantic = (token_ids >= self._semantic_begin_id) & (token_ids <= self._semantic_end_id) + embeds = torch.where(is_semantic.unsqueeze(-1), embeds + codebook_sum, embeds) + return embeds, audio_codes.to(dtype=torch.long) + + # -------------------- prompt length estimation -------------------- + + @staticmethod + def estimate_prompt_len_from_additional_information( + additional_information: dict[str, Any] | None, + **kwargs: Any, + ) -> int: + """Upper-bound the text-only prompt length for placeholder allocation.""" + del kwargs + info = additional_information or {} + text = info.get("text", "") + if isinstance(text, list): + text = text[0] if text else "" + # ~1 token per character is a safe bound for CJK; +64 covers the + # chat-template scaffolding. + return max(2, len(str(text)) + 64) + + # -------------------- weight loading -------------------- + + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: + text_config = self.text_config + fast_config = self.fast_ar_config + remapped = _remap_audio8_tts_weights( + weights, + q_size=text_config.num_attention_heads * text_config.head_dim, + kv_size=text_config.num_key_value_heads * text_config.head_dim, + fast_q_size=fast_config.num_attention_heads * fast_config.head_dim, + fast_kv_size=fast_config.num_key_value_heads * fast_config.head_dim, + ) + + stacked_params_mapping = [ + ("qkv_proj", "q_proj", "q"), + ("qkv_proj", "k_proj", "k"), + ("qkv_proj", "v_proj", "v"), + ("gate_up_proj", "gate_proj", 0), + ("gate_up_proj", "up_proj", 1), + ] + params_dict = dict(self.named_parameters(remove_duplicate=False)) + loaded_params: set[str] = set() + unexpected: list[str] = [] + + for name, loaded_weight in remapped: + if name == "model.embed_tokens.weight" and self.text_config.tie_word_embeddings: + lm_key = "lm_head.weight" + if lm_key in params_dict: + param = params_dict[lm_key] + loader = getattr(param, "weight_loader", default_weight_loader) + loader(param, loaded_weight) + loaded_params.add(lm_key) + + handled = False + for param_name, weight_name, shard_id in stacked_params_mapping: + if weight_name not in name: + continue + mapped = name.replace(weight_name, param_name) + if mapped not in params_dict: + continue + param = params_dict[mapped] + loader = getattr(param, "weight_loader", default_weight_loader) + if loader == default_weight_loader: + loader(param, loaded_weight) + else: + loader(param, loaded_weight, shard_id) + loaded_params.add(mapped) + handled = True + break + if handled: + continue + + if name in params_dict: + param = params_dict[name] + loader = getattr(param, "weight_loader", default_weight_loader) + loader(param, loaded_weight) + loaded_params.add(name) + else: + unexpected.append(name) + + if unexpected: + raise ValueError( + f"Audio8 TTS Slow AR received {len(unexpected)} unmapped checkpoint tensors, " + f"e.g. {sorted(unexpected)[:5]}. The weight remapper is out of sync with the checkpoint." + ) + missing = sorted(set(params_dict) - loaded_params) + if missing: + raise ValueError(f"Audio8 TTS Slow AR is missing weights for {missing[:5]} ({len(missing)} total)") + logger.info("Loaded %d weights for Audio8TTSSlowARForConditionalGeneration", len(loaded_params)) + + # The reference precomputes RoPE in bf16; keeping fp32 cos/sin here + # shifts the logits enough to trigger early EOS (same failure mode as + # Fish Speech). + truncated = 0 + for module in self.modules(): + cache = getattr(module, "cos_sin_cache", None) + if isinstance(cache, torch.Tensor): + module.cos_sin_cache = cache.to(torch.bfloat16).to(cache.dtype) + truncated += 1 + if truncated: + logger.info("Audio8 TTS: truncated %d RoPE cos_sin_cache buffers to bf16", truncated) + + return loaded_params + + +__all__ = ["Audio8TTSSlowARForConditionalGeneration"] diff --git a/vllm_omni/model_executor/models/audio8_tts/codec.py b/vllm_omni/model_executor/models/audio8_tts/codec.py new file mode 100644 index 000000000..71f43642b --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/codec.py @@ -0,0 +1,600 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS neural audio codec (44.1 kHz, 10 codebooks). + +Vendored from the reference ``modeling_arktts_codec.py`` shipped with +``Audio8/Audio8-TTS-Preview-0.6b`` (Apache-2.0). The module tree and attribute +names are reproduced verbatim so ``codec.pth`` loads under ``strict=True``. + +Inference-only deviations: no ``torch.jit.script`` on Snake (it fights +``torch.compile`` / CUDA graphs), RoPE cached in a plain dict instead of a +non-persistent buffer (the pattern that leaves the reference LM with +uninitialised RoPE under ``from_pretrained`` on transformers >= 5), and +:func:`build_arktts_codec` prunes the unused encoder or decoder half per stage. +""" + +from __future__ import annotations + +import math +from typing import Any + +import torch +import torch.nn as nn +import torch.nn.functional as F +from torch import Tensor +from torch.nn.utils import weight_norm as legacy_weight_norm +from torch.nn.utils.parametrizations import weight_norm + +# The codec's own transformer blocks always use base 10000, independent of the +# 1e6 rope_base of the DualAR language model. +_CODEC_ROPE_BASE = 10000.0 + + +def _rope_table(length: int, head_dim: int, base: float, device: torch.device) -> Tensor: + frequencies = 1.0 / (base ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim)) + phases = torch.outer(torch.arange(length, device=device), frequencies) + values = torch.polar(torch.ones_like(phases), phases) + return torch.stack((values.real, values.imag), dim=-1).to(torch.bfloat16) + + +def _apply_rope(x: Tensor, values: Tensor) -> Tensor: + shaped = x.float().reshape(*x.shape[:-1], -1, 2) + values = values.view(1, shaped.shape[1], 1, shaped.shape[3], 2) + output = torch.stack( + ( + shaped[..., 0] * values[..., 0] - shaped[..., 1] * values[..., 1], + shaped[..., 1] * values[..., 0] + shaped[..., 0] * values[..., 1], + ), + dim=-1, + ) + return output.flatten(3).to(x.dtype) + + +class ArkttsCodecRMSNorm(nn.Module): + def __init__(self, dim: int, eps: float = 1e-5) -> None: + super().__init__() + self.eps = float(eps) + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x: Tensor) -> Tensor: + output = x.float() * torch.rsqrt(x.float().pow(2).mean(-1, keepdim=True) + self.eps) + return output.to(x.dtype) * self.weight + + +class ArkttsCodecLayerScale(nn.Module): + def __init__(self, dim: int, init_values: float = 1e-2) -> None: + super().__init__() + self.gamma = nn.Parameter(init_values * torch.ones(dim)) + + def forward(self, x: Tensor) -> Tensor: + return x * self.gamma + + +class ArkttsCodecTransformerConfig: + """Plain option holder for the codec's transformer blocks. + + Deliberately not a dataclass / msgspec Struct: it mirrors a reference + dataclass whose only job is to carry constructor arguments, and it is + instantiated from Python literals inside this module only. + """ + + def __init__( + self, + n_layer: int, + n_head: int, + dim: int, + intermediate_size: int, + n_local_heads: int = -1, + head_dim: int = 64, + rope_base: float = _CODEC_ROPE_BASE, + norm_eps: float = 1e-5, + channels_first: bool = True, + ) -> None: + self.n_layer = int(n_layer) + self.n_head = int(n_head) + self.dim = int(dim) + self.intermediate_size = int(intermediate_size) + self.n_local_heads = int(n_head) if n_local_heads == -1 else int(n_local_heads) + self.head_dim = int(head_dim) + self.rope_base = float(rope_base) + self.norm_eps = float(norm_eps) + self.channels_first = bool(channels_first) + + +class ArkttsCodecAttention(nn.Module): + def __init__(self, config: ArkttsCodecTransformerConfig) -> None: + super().__init__() + total = (config.n_head + 2 * config.n_local_heads) * config.head_dim + self.wqkv = nn.Linear(config.dim, total, bias=False) + self.wo = nn.Linear(config.head_dim * config.n_head, config.dim, bias=False) + self.n_head = config.n_head + self.n_local_heads = config.n_local_heads + self.head_dim = config.head_dim + + def forward(self, x: Tensor, rope_values: Tensor, mask: Tensor) -> Tensor: + batch, length, _ = x.shape + query_size = self.n_head * self.head_dim + kv_size = self.n_local_heads * self.head_dim + query, key, value = self.wqkv(x).split((query_size, kv_size, kv_size), dim=-1) + query = query.view(batch, length, self.n_head, self.head_dim) + key = key.view(batch, length, self.n_local_heads, self.head_dim) + value = value.view(batch, length, self.n_local_heads, self.head_dim) + query = _apply_rope(query, rope_values).transpose(1, 2) + key = _apply_rope(key, rope_values).transpose(1, 2) + value = value.transpose(1, 2) + output = F.scaled_dot_product_attention( + query, + key, + value, + attn_mask=mask, + enable_gqa=self.n_local_heads != self.n_head, + ) + output = output.transpose(1, 2).contiguous().view(batch, length, query_size) + return self.wo(output) + + +class ArkttsCodecFeedForward(nn.Module): + def __init__(self, config: ArkttsCodecTransformerConfig) -> None: + super().__init__() + self.w1 = nn.Linear(config.dim, config.intermediate_size, bias=False) + self.w3 = nn.Linear(config.dim, config.intermediate_size, bias=False) + self.w2 = nn.Linear(config.intermediate_size, config.dim, bias=False) + + def forward(self, x: Tensor) -> Tensor: + return self.w2(F.silu(self.w1(x)) * self.w3(x)) + + +class ArkttsCodecTransformerBlock(nn.Module): + def __init__(self, config: ArkttsCodecTransformerConfig) -> None: + super().__init__() + self.attention = ArkttsCodecAttention(config) + self.feed_forward = ArkttsCodecFeedForward(config) + self.ffn_norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps) + self.attention_norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps) + self.attention_layer_scale = ArkttsCodecLayerScale(config.dim) + self.ffn_layer_scale = ArkttsCodecLayerScale(config.dim) + + def forward(self, x: Tensor, rope_values: Tensor, mask: Tensor) -> Tensor: + hidden = x + self.attention_layer_scale(self.attention(self.attention_norm(x), rope_values, mask)) + return hidden + self.ffn_layer_scale(self.feed_forward(self.ffn_norm(hidden))) + + +class ArkttsCodecWindowTransformer(nn.Module): + """Causal transformer with a sliding attention window.""" + + def __init__( + self, + config: ArkttsCodecTransformerConfig, + input_dim: int, + window_size: int | None, + ) -> None: + super().__init__() + self.layers = nn.ModuleList([ArkttsCodecTransformerBlock(config) for _ in range(config.n_layer)]) + self.norm = ArkttsCodecRMSNorm(config.dim, config.norm_eps) + self.window_size = window_size + self.channels_first = config.channels_first + self.input_proj = nn.Linear(input_dim, config.dim) if input_dim != config.dim else nn.Identity() + self.output_proj = nn.Linear(config.dim, input_dim) if input_dim != config.dim else nn.Identity() + self.look_ahead_conv = nn.Identity() + self.head_dim = config.head_dim + self.rope_base = config.rope_base + self._mask_cache: dict[tuple[int, str], Tensor] = {} + self._rope_cache: dict[tuple[int, str], Tensor] = {} + + def _mask(self, length: int, device: torch.device) -> Tensor: + key = (length, str(device)) + cached = self._mask_cache.get(key) + if cached is not None: + return cached + row = torch.arange(length, device=device)[:, None] + column = torch.arange(length, device=device)[None, :] + mask = column <= row + if self.window_size is not None: + mask &= column >= (row - self.window_size + 1).clamp_min(0) + mask = mask[None, None] + self._mask_cache[key] = mask + return mask + + def _rope(self, length: int, device: torch.device) -> Tensor: + key = (length, str(device)) + cached = self._rope_cache.get(key) + if cached is not None: + return cached + values = _rope_table(length, self.head_dim, self.rope_base, device) + self._rope_cache[key] = values + return values + + def forward(self, x: Tensor) -> Tensor: + if self.channels_first: + x = x.transpose(1, 2) + x = self.look_ahead_conv(self.input_proj(x)) + length = int(x.shape[1]) + mask = self._mask(length, x.device) + rope_values = self._rope(length, x.device) + for layer in self.layers: + x = layer(x, rope_values, mask) + x = self.output_proj(self.norm(x)) + return x.transpose(1, 2) if self.channels_first else x + + +def _extra_padding(x: Tensor, kernel_size: int, stride: int, padding_total: int = 0) -> int: + length = x.shape[-1] + frames = (length - kernel_size + padding_total) / stride + 1 + ideal = (math.ceil(frames) - 1) * stride + kernel_size - padding_total + return ideal - length + + +class ArkttsCausalConv1d(nn.Module): + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int, + dilation: int = 1, + stride: int = 1, + groups: int = 1, + ) -> None: + super().__init__() + self.conv = nn.Conv1d(in_channels, out_channels, kernel_size, stride=stride, dilation=dilation, groups=groups) + self.stride = stride + self.kernel_size = (kernel_size - 1) * dilation + 1 + self.padding = self.kernel_size - self.stride + + def forward(self, x: Tensor) -> Tensor: + right = _extra_padding(x, self.kernel_size, self.stride, self.padding) + return self.conv(F.pad(x, (self.padding, right))).contiguous() + + def apply_weight_norm(self) -> ArkttsCausalConv1d: + self.conv = weight_norm(self.conv) + return self + + +class ArkttsCausalConvTranspose1d(nn.Module): + def __init__( + self, + in_channels: int, + out_channels: int, + kernel_size: int, + stride: int = 1, + dilation: int = 1, + ) -> None: + super().__init__() + self.conv = nn.ConvTranspose1d(in_channels, out_channels, kernel_size, stride=stride, dilation=dilation) + self.stride = stride + self.kernel_size = kernel_size + + def forward(self, x: Tensor) -> Tensor: + x = self.conv(x) + crop = self.kernel_size - self.stride + return x[..., : x.shape[-1] - crop].contiguous() if crop else x.contiguous() + + def apply_weight_norm(self) -> ArkttsCausalConvTranspose1d: + self.conv = weight_norm(self.conv) + return self + + +def _causal_wn_conv(*args: Any, **kwargs: Any) -> ArkttsCausalConv1d: + return ArkttsCausalConv1d(*args, **kwargs).apply_weight_norm() + + +def _causal_wn_transpose(*args: Any, **kwargs: Any) -> ArkttsCausalConvTranspose1d: + return ArkttsCausalConvTranspose1d(*args, **kwargs).apply_weight_norm() + + +class ArkttsSnake1d(nn.Module): + def __init__(self, channels: int) -> None: + super().__init__() + self.alpha = nn.Parameter(torch.ones(1, channels, 1)) + + def forward(self, x: Tensor) -> Tensor: + shape = x.shape + x = x.reshape(shape[0], shape[1], -1) + x = x + (self.alpha + 1e-9).reciprocal() * torch.sin(self.alpha * x).pow(2) + return x.reshape(shape) + + +class ArkttsResidualUnit(nn.Module): + def __init__(self, dim: int, dilation: int) -> None: + super().__init__() + self.block = nn.Sequential( + ArkttsSnake1d(dim), + _causal_wn_conv(dim, dim, kernel_size=7, dilation=dilation), + ArkttsSnake1d(dim), + _causal_wn_conv(dim, dim, kernel_size=1), + ) + + def forward(self, x: Tensor) -> Tensor: + output = self.block(x) + difference = x.shape[-1] - output.shape[-1] + if difference > 0: + x = x[..., :-difference] + return x + output + + +class ArkttsEncoderBlock(nn.Module): + def __init__(self, dim: int, stride: int, transformer_layers: int) -> None: + super().__init__() + modules: list[nn.Module] = [ + ArkttsResidualUnit(dim // 2, 1), + ArkttsResidualUnit(dim // 2, 3), + ArkttsResidualUnit(dim // 2, 9), + ArkttsSnake1d(dim // 2), + _causal_wn_conv(dim // 2, dim, kernel_size=2 * stride, stride=stride), + ] + if transformer_layers: + config = ArkttsCodecTransformerConfig( + n_layer=transformer_layers, + n_head=dim // 64, + dim=dim, + intermediate_size=dim * 3, + ) + modules.append(ArkttsCodecWindowTransformer(config, dim, window_size=512)) + else: + modules.append(nn.Identity()) + self.block = nn.Sequential(*modules) + + def forward(self, x: Tensor) -> Tensor: + return self.block(x) + + +class ArkttsEncoder(nn.Module): + def __init__(self) -> None: + super().__init__() + dim = 64 + modules: list[nn.Module] = [_causal_wn_conv(1, dim, kernel_size=7)] + for stride, transformer_layers in zip((2, 4, 8, 8), (0, 0, 0, 4), strict=True): + dim *= 2 + modules.append(ArkttsEncoderBlock(dim, stride, transformer_layers)) + modules.extend((ArkttsSnake1d(dim), _causal_wn_conv(dim, 1024, kernel_size=3))) + self.block = nn.Sequential(*modules) + + def forward(self, x: Tensor) -> Tensor: + return self.block(x) + + +class ArkttsDecoderBlock(nn.Module): + def __init__(self, input_dim: int, output_dim: int, stride: int) -> None: + super().__init__() + self.block = nn.Sequential( + ArkttsSnake1d(input_dim), + _causal_wn_transpose(input_dim, output_dim, kernel_size=2 * stride, stride=stride), + ArkttsResidualUnit(output_dim, 1), + ArkttsResidualUnit(output_dim, 3), + ArkttsResidualUnit(output_dim, 9), + ) + + def forward(self, x: Tensor) -> Tensor: + return self.block(x) + + +class ArkttsDecoder(nn.Module): + def __init__(self) -> None: + super().__init__() + channels = 1536 + modules: list[nn.Module] = [_causal_wn_conv(1024, channels, kernel_size=7)] + output_dim = channels + for index, stride in enumerate((8, 8, 4, 2)): + input_dim = channels // (2**index) + output_dim = channels // (2 ** (index + 1)) + modules.append(ArkttsDecoderBlock(input_dim, output_dim, stride)) + modules.extend( + ( + ArkttsSnake1d(output_dim), + _causal_wn_conv(output_dim, 1, kernel_size=7), + nn.Tanh(), + ) + ) + self.model = nn.Sequential(*modules) + + def forward(self, x: Tensor) -> Tensor: + return self.model(x) + + +class ArkttsVectorQuantizer(nn.Module): + def __init__(self, input_dim: int, codebook_size: int, codebook_dim: int) -> None: + super().__init__() + self.codebook_size = int(codebook_size) + self.codebook_dim = int(codebook_dim) + self.in_proj = legacy_weight_norm(nn.Conv1d(input_dim, codebook_dim, kernel_size=1)) + self.out_proj = legacy_weight_norm(nn.Conv1d(codebook_dim, input_dim, kernel_size=1)) + self.codebook = nn.Embedding(codebook_size, codebook_dim) + + def decode_code(self, indices: Tensor) -> Tensor: + return F.embedding(indices, self.codebook.weight).transpose(1, 2) + + def decode_latents(self, latents: Tensor) -> tuple[Tensor, Tensor]: + batch, _, length = latents.shape + flattened = F.normalize(latents.transpose(1, 2).reshape(batch * length, -1)) + codebook = F.normalize(self.codebook.weight) + distances = ( + flattened.pow(2).sum(1, keepdim=True) + - 2 * flattened @ codebook.t() + + codebook.pow(2).sum(1, keepdim=True).t() + ) + indices = (-distances).argmax(1).view(batch, length) + return self.decode_code(indices), indices + + def forward(self, z: Tensor) -> tuple[Tensor, Tensor]: + projected = self.in_proj(z) + quantized, indices = self.decode_latents(projected) + return self.out_proj(quantized), indices + + +class ArkttsResidualQuantizer(nn.Module): + def __init__(self, input_dim: int, n_codebooks: int, codebook_size: int, codebook_dim: int) -> None: + super().__init__() + self.n_codebooks = int(n_codebooks) + self.codebook_size = int(codebook_size) + self.quantizers = nn.ModuleList( + [ArkttsVectorQuantizer(input_dim, codebook_size, codebook_dim) for _ in range(n_codebooks)] + ) + + def forward(self, z: Tensor) -> tuple[Tensor, Tensor]: + quantized_sum: Tensor | float = 0.0 + residual = z + codes: list[Tensor] = [] + for quantizer in self.quantizers: + quantized, indices = quantizer(residual) + quantized_sum = quantized_sum + quantized + residual = residual - quantized + codes.append(indices) + return quantized_sum, torch.stack(codes, dim=1) + + def from_codes(self, codes: Tensor) -> Tensor: + output: Tensor | float = 0.0 + for index in range(codes.shape[1]): + projected = self.quantizers[index].decode_code(codes[:, index]) + output = output + self.quantizers[index].out_proj(projected) + return output + + +class ArkttsConvNeXtBlock(nn.Module): + def __init__(self, dim: int) -> None: + super().__init__() + self.dwconv = ArkttsCausalConv1d(dim, dim, kernel_size=7, groups=dim) + self.norm = nn.LayerNorm(dim, eps=1e-6) + self.pwconv1 = nn.Linear(dim, 4 * dim) + self.act = nn.GELU() + self.pwconv2 = nn.Linear(4 * dim, dim) + self.gamma = nn.Parameter(1e-6 * torch.ones(dim)) + + def forward(self, x: Tensor) -> Tensor: + residual = x + x = self.dwconv(x).permute(0, 2, 1) + x = self.pwconv2(self.act(self.pwconv1(self.norm(x)))) + x = (self.gamma * x).permute(0, 2, 1) + return residual + x + + +class ArkttsDownsampleQuantizer(nn.Module): + """1 semantic codebook (4096) + 9 residual codebooks (1024), 4x downsampled.""" + + def __init__( + self, + post_n_layer: int = 8, + post_n_head: int = 16, + post_n_local_heads: int = 8, + post_intermediate_size: int = 1216, + ) -> None: + super().__init__() + self.semantic_quantizer = ArkttsResidualQuantizer(1024, 1, 4096, 8) + self.quantizer = ArkttsResidualQuantizer(1024, 9, 1024, 8) + self.downsample = nn.Sequential( + nn.Sequential(ArkttsCausalConv1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)), + nn.Sequential(ArkttsCausalConv1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)), + ) + self.upsample = nn.Sequential( + nn.Sequential(ArkttsCausalConvTranspose1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)), + nn.Sequential(ArkttsCausalConvTranspose1d(1024, 1024, kernel_size=2, stride=2), ArkttsConvNeXtBlock(1024)), + ) + self.pre_module = ArkttsCodecWindowTransformer( + ArkttsCodecTransformerConfig(n_layer=8, n_head=16, dim=1024, intermediate_size=3072), + 1024, + window_size=128, + ) + self.post_module = ArkttsCodecWindowTransformer( + ArkttsCodecTransformerConfig( + n_layer=post_n_layer, + n_head=post_n_head, + n_local_heads=post_n_local_heads, + dim=1024, + intermediate_size=post_intermediate_size, + ), + 1024, + window_size=128, + ) + self.semantic_predictor_module = nn.Identity() + + def forward(self, z: Tensor) -> tuple[Tensor, Tensor]: + original_length = z.shape[-1] + z = self.pre_module(self.downsample(z)) + semantic, semantic_codes = self.semantic_quantizer(z) + residual, residual_codes = self.quantizer(z - semantic) + z = self.upsample(self.post_module(semantic + residual)) + difference = original_length - z.shape[-1] + if difference > 0: + z = F.pad(z, (difference, 0)) + elif difference < 0: + z = z[..., -difference:] + return z, torch.cat((semantic_codes, residual_codes), dim=1) + + def decode(self, indices: Tensor) -> Tensor: + # Cloned because the clamps below are in-place and this is the caller's + # codes buffer; once per decode chunk, not per AR step. + indices = indices.clone() + indices[:, 0].clamp_(0, self.semantic_quantizer.codebook_size - 1) + indices[:, 1:].clamp_(0, self.quantizer.codebook_size - 1) + semantic = self.semantic_quantizer.from_codes(indices[:, :1]) + residual = self.quantizer.from_codes(indices[:, 1:]) + return self.upsample(self.post_module(semantic + residual)) + + +class ArkttsCodec(nn.Module): + """Audio8 TTS codec: waveform <-> 10 codebooks at ~21.5 frames/s.""" + + sample_rate = 44100 + frame_length = 2048 + + def __init__( + self, + post_n_layer: int = 8, + post_n_head: int = 16, + post_n_local_heads: int = 8, + post_intermediate_size: int = 1216, + ) -> None: + super().__init__() + self.encoder = ArkttsEncoder() + self.quantizer = ArkttsDownsampleQuantizer( + post_n_layer=post_n_layer, + post_n_head=post_n_head, + post_n_local_heads=post_n_local_heads, + post_intermediate_size=post_intermediate_size, + ) + self.decoder = ArkttsDecoder() + + @torch.inference_mode() + def encode(self, audio: Tensor, audio_lengths: Tensor | None = None) -> tuple[Tensor, Tensor]: + """Encode ``[B, 1, samples]`` (or ``[B, samples]``) to ``[B, 10, frames]``.""" + if audio.ndim == 2: + audio = audio[:, None] + if audio.ndim != 3 or audio.shape[1] != 1: + raise ValueError(f"audio must have shape [B, 1, samples], got {tuple(audio.shape)}") + original_length = int(audio.shape[-1]) + right = math.ceil(original_length / self.frame_length) * self.frame_length - original_length + if right: + audio = F.pad(audio, (0, right)) + if audio_lengths is None: + audio_lengths = torch.full((audio.shape[0],), original_length, device=audio.device, dtype=torch.long) + _, codes = self.quantizer(self.encoder(audio)) + code_lengths = torch.ceil(audio_lengths.float() / self.frame_length).long() + max_codes = int(codes.shape[-1]) + code_lengths = code_lengths.clamp_max(max_codes) + frame_index = torch.arange(max_codes, device=codes.device) + valid = frame_index[None, None, :] < code_lengths[:, None, None] + return torch.where(valid, codes, torch.full_like(codes, -1)), code_lengths + + @torch.inference_mode() + def decode(self, codes: Tensor) -> Tensor: + """Decode ``[B, 10, frames]`` to ``[B, 1, samples]``.""" + return self.decoder(self.quantizer.decode(codes.long())) + + +def build_arktts_codec( + *, + post_n_layer: int = 8, + post_n_head: int = 16, + post_n_local_heads: int = 8, + post_intermediate_size: int = 1216, +) -> ArkttsCodec: + """Construct the codec with uninitialised weights, on CPU, in eval mode.""" + codec = ArkttsCodec( + post_n_layer=post_n_layer, + post_n_head=post_n_head, + post_n_local_heads=post_n_local_heads, + post_intermediate_size=post_intermediate_size, + ) + codec.eval() + return codec + + +__all__ = ["ArkttsCodec", "build_arktts_codec"] diff --git a/vllm_omni/model_executor/models/audio8_tts/codec_utils.py b/vllm_omni/model_executor/models/audio8_tts/codec_utils.py new file mode 100644 index 000000000..64f9c0afb --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/codec_utils.py @@ -0,0 +1,224 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Loading / caching helpers for the Audio8 TTS ``codec.pth`` checkpoint. + +Both stages need the codec but for opposite halves: Stage 0 encodes reference +audio for zero-shot voice cloning, Stage 1 decodes generated codes to waveform. +The unused half is pruned before the module is moved to the device so a stage +never pays GPU memory for weights it cannot reach. +""" + +from __future__ import annotations + +import os +from functools import lru_cache + +import numpy as np +import torch +import torch.nn as nn +from torch.nn.utils.parametrize import remove_parametrizations +from vllm.logger import init_logger + +from vllm_omni.model_executor.models.audio8_tts.codec import ArkttsCodec, build_arktts_codec +from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import ( + ARKTTS_CODEC_FRAME_SIZE, + ARKTTS_CODEC_SAMPLE_RATE, +) + +logger = init_logger(__name__) + +CODEC_FILENAME = "codec.pth" + +_codec_cache: dict[tuple[str, str, str, str], ArkttsCodec] = {} + + +def resolve_codec_path(model_path: str, filename: str = CODEC_FILENAME) -> str: + """Return a local path to ``filename``, downloading from the Hub if needed.""" + local = os.path.join(model_path, filename) + if os.path.exists(local): + return local + + from transformers.utils.hub import cached_file + + cached = cached_file(model_path, filename) + if cached is None or not os.path.exists(cached): + raise FileNotFoundError( + f"{filename} not found for {model_path}; the Audio8 TTS checkpoint must ship its neural audio codec." + ) + return cached + + +def bake_weight_norm(module: nn.Module) -> int: + """Fold weight-norm parametrisations into plain weights (inference only).""" + baked = 0 + for submodule in module.modules(): + parametrizations = getattr(submodule, "parametrizations", None) + if not parametrizations: + continue + for name in list(parametrizations.keys()): + remove_parametrizations(submodule, name, leave_parametrized=True) + baked += 1 + return baked + + +def load_arktts_codec( + model_path: str, + *, + device: torch.device | str = "cpu", + dtype: torch.dtype = torch.float32, + role: str = "decode", + post_n_layer: int = 8, + post_n_head: int = 16, + post_n_local_heads: int = 8, + post_intermediate_size: int = 1216, +) -> ArkttsCodec: + """Load ``codec.pth`` for ``role`` in {"encode", "decode", "both"}. + + Instances are cached per (path, device, dtype, role) because both the Stage-0 + voice-clone path and the Stage-1 decoder may ask for the same codec many + times per request. + """ + if role not in {"encode", "decode", "both"}: + raise ValueError(f"role must be encode/decode/both, got {role!r}") + device = torch.device(device) + cache_key = (model_path, str(device), str(dtype), role) + cached_codec = _codec_cache.get(cache_key) + if cached_codec is not None: + return cached_codec + + codec_path = resolve_codec_path(model_path) + codec = build_arktts_codec( + post_n_layer=post_n_layer, + post_n_head=post_n_head, + post_n_local_heads=post_n_local_heads, + post_intermediate_size=post_intermediate_size, + ) + + state = torch.load(codec_path, map_location="cpu", weights_only=True) + if "state_dict" in state: + state = state["state_dict"] + if any("generator." in key for key in state): + state = {key.replace("generator.", ""): value for key, value in state.items() if "generator." in key} + state = {key: value for key, value in state.items() if not key.endswith(("freqs_cis", "causal_mask"))} + codec.load_state_dict(state, strict=True) + + baked = bake_weight_norm(codec) + if role == "encode": + codec.decoder = None + elif role == "decode": + codec.encoder = None + codec.quantizer.pre_module = None + codec.quantizer.downsample = None + + codec = codec.to(device=device, dtype=dtype) + codec.eval() + _codec_cache[cache_key] = codec + logger.info( + "Loaded Audio8 TTS codec from %s (role=%s, device=%s, dtype=%s, baked_weight_norms=%d)", + codec_path, + role, + device, + dtype, + baked, + ) + return codec + + +@lru_cache(maxsize=8) +def _resample_transform(source_sr: int, target_sr: int, device: torch.device, dtype: torch.dtype): + import torchaudio + + return torchaudio.transforms.Resample(source_sr, target_sr).to(device=device, dtype=dtype) + + +def prepare_reference_waveform( + wav_samples: list[float] | np.ndarray | torch.Tensor, + sample_rate: int, + *, + device: torch.device, + dtype: torch.dtype = torch.float32, +) -> torch.Tensor: + """Down-mix to mono and resample reference audio to the codec's rate. + + Returns a 1-D waveform tensor. + """ + if isinstance(wav_samples, torch.Tensor): + wav = wav_samples.detach() + else: + wav = torch.as_tensor(np.asarray(wav_samples)) + wav = wav.to(device=device, dtype=dtype) + + if wav.ndim == 2: + # Accept [channels, samples] and [samples, channels]. + if wav.shape[0] <= 8 and wav.shape[1] > wav.shape[0]: + wav = wav.mean(dim=0) + elif wav.shape[-1] <= 8 and wav.shape[0] > wav.shape[-1]: + wav = wav.mean(dim=-1) + else: + wav = wav.mean(dim=0) + elif wav.ndim > 2: + wav = wav.reshape(-1, wav.shape[-1]).mean(dim=0) + wav = wav.reshape(-1) + + if wav.numel() == 0: + raise ValueError("Reference audio must not be empty") + if int(sample_rate) <= 0: + raise ValueError(f"Reference audio sample rate must be positive, got {sample_rate}") + if int(sample_rate) != ARKTTS_CODEC_SAMPLE_RATE: + transform = _resample_transform(int(sample_rate), ARKTTS_CODEC_SAMPLE_RATE, wav.device, wav.dtype) + wav = transform(wav.unsqueeze(0)).squeeze(0) + return wav.contiguous() + + +@torch.no_grad() +def encode_reference_audio_codes( + model_path: str, + wav_samples: list[float] | np.ndarray | torch.Tensor, + sample_rate: int, + *, + device: torch.device | str, + **codec_kwargs: int, +) -> torch.Tensor: + """Encode reference audio into codec codes. + + Returns: + ``[frames, num_codebooks]`` int64 codes on ``device``. + """ + device = torch.device(device) + codec = load_arktts_codec(model_path, device=device, dtype=torch.float32, role="encode", **codec_kwargs) + wav = prepare_reference_waveform(wav_samples, sample_rate, device=device) + lengths = torch.tensor([wav.numel()], device=device, dtype=torch.long) + codes, code_lengths = codec.encode(wav.reshape(1, 1, -1), lengths) + frames = int(code_lengths[0].item()) + codes_fq = codes[0, :, :frames].transpose(0, 1).to(dtype=torch.long).contiguous() + logger.info( + "Encoded Audio8 TTS reference audio: %d samples @ %d Hz -> frames=%d codebooks=%d", + int(wav.numel()), + int(sample_rate), + int(codes_fq.shape[0]), + int(codes_fq.shape[1]), + ) + return codes_fq + + +def estimate_reference_code_frames(num_samples: int, sample_rate: int) -> int: + """Frames the codec will emit for ``num_samples`` at ``sample_rate``. + + Used by the serving layer to size the prompt placeholder without paying for + a codec encode on the API thread. + """ + if num_samples <= 0 or sample_rate <= 0: + raise ValueError("Reference audio must have a positive length and sample rate") + resampled = max(1, -(-num_samples * ARKTTS_CODEC_SAMPLE_RATE // sample_rate)) + return max(1, -(-resampled // ARKTTS_CODEC_FRAME_SIZE)) + + +__all__ = [ + "CODEC_FILENAME", + "bake_weight_norm", + "encode_reference_audio_codes", + "estimate_reference_code_frames", + "load_arktts_codec", + "prepare_reference_waveform", + "resolve_codec_path", +] diff --git a/vllm_omni/model_executor/models/audio8_tts/configuration_audio8_tts.py b/vllm_omni/model_executor/models/audio8_tts/configuration_audio8_tts.py new file mode 100644 index 000000000..de44ab2a2 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/configuration_audio8_tts.py @@ -0,0 +1,267 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Configuration classes for Audio8 TTS Preview (``model_type = "arktts"``). + +Audio8's HuggingFace config is flat and uses DualAR field names (``dim``, +``n_layer``, ``n_head``, ``fast_dim``, ...); this module re-exports them under +Qwen2 attribute names so vLLM's ``Qwen2Model`` consumes the Slow AR half, and +splits the Fast AR half into its own sub-config. The backbone is Qwen2 rather +than Qwen3 (Fish Speech) because Audio8 uses ``qkv_bias=true`` with no q/k +norm -- exactly the Qwen2 attention shape. +""" + +from __future__ import annotations + +from typing import Any + +from transformers import PretrainedConfig + +#: Codec frame stride in waveform samples (encoder rates 2*4*8*8 = 512 times the +#: quantizer's 2*2 downsample). 44100 / 2048 ~= 21.5 frames per second. +ARKTTS_CODEC_FRAME_SIZE = 2048 +ARKTTS_CODEC_SAMPLE_RATE = 44100 +#: Only the first codebook is a 4096-entry semantic codebook; codebooks 1..N-1 +#: are 1024-entry residual codebooks (see ``ArkttsDownsampleQuantizer``). +ARKTTS_SEMANTIC_CODEBOOK_SIZE = 4096 +ARKTTS_RESIDUAL_CODEBOOK_SIZE = 1024 + + +class Audio8TTSSlowARConfig(PretrainedConfig): + """Slow AR config exposed with Qwen2-compatible attribute names.""" + + model_type = "arktts_slow_ar" + + def __init__( + self, + vocab_size: int = 155776, + dim: int = 896, + n_head: int = 14, + n_local_heads: int = 2, + head_dim: int = 64, + n_layer: int = 24, + intermediate_size: int = 4864, + max_seq_len: int = 2048, + rope_base: float = 1_000_000.0, + norm_eps: float = 1e-6, + attention_qkv_bias: bool = True, + attention_qk_norm: bool = False, + tie_word_embeddings: bool = True, + codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE, + num_codebooks: int = 10, + semantic_begin_id: int = 151678, + semantic_end_id: int = 155773, + eos_token_id: int = 151645, + pad_token_id: int = 151643, + **kwargs: Any, + ) -> None: + # Audio8 field names -> standard Transformers / Qwen2 names. + self.hidden_size = int(dim) + self.num_attention_heads = int(n_head) + self.num_key_value_heads = int(n_local_heads) + self.head_dim = int(head_dim) + self.num_hidden_layers = int(n_layer) + self.intermediate_size = int(intermediate_size) + self.max_position_embeddings = int(max_seq_len) + self.rms_norm_eps = float(norm_eps) + self.hidden_act = "silu" + # Qwen2Attention hardcodes qkv bias=True / o_proj bias=False, which is + # what Audio8 TTS ships. Keep the flags so a future checkpoint that + # flips them fails loudly in the model rather than loading silently. + self.attention_qkv_bias = bool(attention_qkv_bias) + self.attention_qk_norm = bool(attention_qk_norm) + # Set explicitly (rather than relying on vLLM's set_default_rope_theta) + # so that a checkpoint with a non-default rope_base is honoured. + self.rope_parameters = {"rope_type": "default", "rope_theta": float(rope_base)} + + # Codec / codebook fields. + self.codebook_size = int(codebook_size) + self.num_codebooks = int(num_codebooks) + self.semantic_begin_id = int(semantic_begin_id) + self.semantic_end_id = int(semantic_end_id) + + super().__init__( + vocab_size=int(vocab_size), + tie_word_embeddings=bool(tie_word_embeddings), + eos_token_id=int(eos_token_id), + pad_token_id=int(pad_token_id), + **kwargs, + ) + + +class Audio8TTSFastARConfig(PretrainedConfig): + """Fast AR config: the ``n_fast_layer`` residual-codebook predictor.""" + + model_type = "arktts_fast_ar" + + def __init__( + self, + codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE, + num_codebooks: int = 10, + fast_dim: int = 896, + fast_n_head: int = 14, + fast_n_local_heads: int = 2, + fast_head_dim: int = 64, + n_fast_layer: int = 4, + fast_intermediate_size: int = 4864, + fast_attention_qkv_bias: bool = False, + fast_attention_qk_norm: bool = False, + rope_base: float = 1_000_000.0, + norm_eps: float = 1e-6, + **kwargs: Any, + ) -> None: + self.hidden_size = int(fast_dim) + self.num_attention_heads = int(fast_n_head) + self.num_key_value_heads = int(fast_n_local_heads) + self.head_dim = int(fast_head_dim) + self.num_hidden_layers = int(n_fast_layer) + self.intermediate_size = int(fast_intermediate_size) + # The Fast AR sequence is [slow hidden state, code_0, ..., code_{N-1}]. + self.max_position_embeddings = int(num_codebooks) + self.rms_norm_eps = float(norm_eps) + self.hidden_act = "silu" + self.attention_qkv_bias = bool(fast_attention_qkv_bias) + self.attention_qk_norm = bool(fast_attention_qk_norm) + self.rope_theta = float(rope_base) + self.num_codebooks = int(num_codebooks) + + super().__init__(vocab_size=int(codebook_size), **kwargs) + + +class Audio8TTSConfig(PretrainedConfig): + """Top-level Audio8 TTS config (``model_type = "arktts"``). + + Accepts the flat HF checkpoint fields and derives ``text_config`` (Slow AR) + and ``fast_ar_config`` (Fast AR). ``get_text_config()`` returns the Slow AR + config, which is what ``Qwen2Model`` reads. + """ + + model_type = "arktts" + sub_configs = { + "text_config": Audio8TTSSlowARConfig, + "fast_ar_config": Audio8TTSFastARConfig, + } + + def __init__( + self, + text_config: dict | Audio8TTSSlowARConfig | None = None, + fast_ar_config: dict | Audio8TTSFastARConfig | None = None, + vocab_size: int = 155776, + dim: int = 896, + n_head: int = 14, + n_local_heads: int = 2, + head_dim: int = 64, + n_layer: int = 24, + intermediate_size: int = 4864, + max_seq_len: int = 2048, + rope_base: float = 1_000_000.0, + norm_eps: float = 1e-6, + attention_qkv_bias: bool = True, + attention_qk_norm: bool = False, + tie_word_embeddings: bool = True, + codebook_size: int = ARKTTS_SEMANTIC_CODEBOOK_SIZE, + num_codebooks: int = 10, + semantic_begin_id: int = 151678, + semantic_end_id: int = 155773, + n_fast_layer: int = 4, + fast_dim: int = 896, + fast_n_head: int = 14, + fast_n_local_heads: int = 2, + fast_head_dim: int = 64, + fast_intermediate_size: int = 4864, + fast_attention_qkv_bias: bool = False, + fast_attention_qk_norm: bool = False, + norm_fastlayer_input: bool = True, + codec_filename: str = "codec.pth", + codec_sample_rate: int = ARKTTS_CODEC_SAMPLE_RATE, + codec_frame_size: int = ARKTTS_CODEC_FRAME_SIZE, + codec_post_n_layer: int = 8, + codec_post_n_head: int = 16, + codec_post_n_local_heads: int = 8, + codec_post_intermediate_size: int = 1216, + ras_window_size: int = 10, + ras_temperature: float = 1.0, + ras_top_p: float = 0.9, + eos_token_id: int = 151645, + pad_token_id: int = 151643, + **kwargs: Any, + ) -> None: + if isinstance(text_config, dict): + text_config = Audio8TTSSlowARConfig(**text_config) + if isinstance(fast_ar_config, dict): + fast_ar_config = Audio8TTSFastARConfig(**fast_ar_config) + + self.text_config = text_config or Audio8TTSSlowARConfig( + vocab_size=vocab_size, + dim=dim, + n_head=n_head, + n_local_heads=n_local_heads, + head_dim=head_dim, + n_layer=n_layer, + intermediate_size=intermediate_size, + max_seq_len=max_seq_len, + rope_base=rope_base, + norm_eps=norm_eps, + attention_qkv_bias=attention_qkv_bias, + attention_qk_norm=attention_qk_norm, + tie_word_embeddings=tie_word_embeddings, + codebook_size=codebook_size, + num_codebooks=num_codebooks, + semantic_begin_id=semantic_begin_id, + semantic_end_id=semantic_end_id, + eos_token_id=eos_token_id, + pad_token_id=pad_token_id, + ) + self.fast_ar_config = fast_ar_config or Audio8TTSFastARConfig( + codebook_size=codebook_size, + num_codebooks=num_codebooks, + fast_dim=fast_dim, + fast_n_head=fast_n_head, + fast_n_local_heads=fast_n_local_heads, + fast_head_dim=fast_head_dim, + n_fast_layer=n_fast_layer, + fast_intermediate_size=fast_intermediate_size, + fast_attention_qkv_bias=fast_attention_qkv_bias, + fast_attention_qk_norm=fast_attention_qk_norm, + rope_base=rope_base, + norm_eps=norm_eps, + ) + + # Fields the stages read directly off the top-level config. + self.codebook_size = int(codebook_size) + self.num_codebooks = int(num_codebooks) + self.semantic_begin_id = int(semantic_begin_id) + self.semantic_end_id = int(semantic_end_id) + self.norm_fastlayer_input = bool(norm_fastlayer_input) + self.codec_filename = str(codec_filename) + self.codec_sample_rate = int(codec_sample_rate) + self.codec_frame_size = int(codec_frame_size) + self.codec_post_n_layer = int(codec_post_n_layer) + self.codec_post_n_head = int(codec_post_n_head) + self.codec_post_n_local_heads = int(codec_post_n_local_heads) + self.codec_post_intermediate_size = int(codec_post_intermediate_size) + # Repetition-Aware Sampling (RAS): resample a repeated semantic token + # from a flatter distribution instead of masking it. + self.ras_window_size = int(ras_window_size) + self.ras_temperature = float(ras_temperature) + self.ras_top_p = float(ras_top_p) + + super().__init__( + eos_token_id=int(eos_token_id), + pad_token_id=int(pad_token_id), + tie_word_embeddings=bool(tie_word_embeddings), + **kwargs, + ) + + def get_text_config(self, *args: Any, **kwargs: Any) -> Audio8TTSSlowARConfig: + return self.text_config + + +__all__ = [ + "ARKTTS_CODEC_FRAME_SIZE", + "ARKTTS_CODEC_SAMPLE_RATE", + "ARKTTS_RESIDUAL_CODEBOOK_SIZE", + "ARKTTS_SEMANTIC_CODEBOOK_SIZE", + "Audio8TTSConfig", + "Audio8TTSFastARConfig", + "Audio8TTSSlowARConfig", +] diff --git a/vllm_omni/model_executor/models/audio8_tts/pipeline.py b/vllm_omni/model_executor/models/audio8_tts/pipeline.py new file mode 100644 index 000000000..836ab71d9 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/pipeline.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview pipeline topology. + +Stage 0: ``audio8_tts_slow_ar`` -- text -> semantic tokens + codec codes. +Stage 1: ``audio8_tts_codec_decoder`` -- codec codes -> 44.1 kHz waveform. +""" + +from vllm_omni.config.stage_config import ( + PipelineConfig, + StageExecutionType, + StagePipelineConfig, +) + +_PROC = "vllm_omni.model_executor.stage_input_processors.audio8_tts" + +#: ``<|im_end|>``: the Slow AR ends the utterance with an end-of-turn token. +AUDIO8_TTS_EOS_TOKEN_ID = 151645 + +AUDIO8_TTS_PIPELINE = PipelineConfig( + model_type="arktts", + default_deploy_config_name="audio8_tts.yaml", + model_arch="Audio8TTSSlowARForConditionalGeneration", + stages=( + StagePipelineConfig( + stage_id=0, + model_stage="audio8_tts_slow_ar", + execution_type=StageExecutionType.LLM_AR, + input_sources=(), + owns_tokenizer=True, + engine_output_type="latent", + async_chunk_process_next_stage_input_func=(f"{_PROC}.slow_ar_to_codec_decoder_async_chunk"), + sampling_constraints={ + "detokenize": False, + "stop_token_ids": [AUDIO8_TTS_EOS_TOKEN_ID], + }, + ), + StagePipelineConfig( + stage_id=1, + model_stage="audio8_tts_codec_decoder", + model_arch="Audio8TTSCodecDecoder", + execution_type=StageExecutionType.LLM_GENERATION, + input_sources=(0,), + final_output=True, + final_output_type="audio", + engine_output_type="audio", + sampling_constraints={"detokenize": True}, + ), + ), +) diff --git a/vllm_omni/model_executor/models/audio8_tts/prompt_utils.py b/vllm_omni/model_executor/models/audio8_tts/prompt_utils.py new file mode 100644 index 000000000..f0660ac5c --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/prompt_utils.py @@ -0,0 +1,159 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS prompt construction. + +Reproduces ``ArkttsProcessor._prompt_segments`` from the reference checkpoint, +so tokenisation is byte-identical to HF inference. +""" + +from __future__ import annotations + +from typing import Any + +import regex as re + +SYSTEM_PROMPT_TEXT_ONLY = "convert the provided text to speech" +SYSTEM_PROMPT_CLONE_PREFIX = "convert the provided text to speech reference to the following:\n\nText:\n" +SYSTEM_PROMPT_CLONE_SUFFIX = "\n\nSpeech:\n" + +_SPEAKER_TAG_PATTERN = re.compile(r"<\|speaker:\d+\|>") +_LEGACY_SPEAKER_TAG_PATTERN = re.compile(r"") +_CONTROL_TOKEN_PATTERN = re.compile(r"<\|[^>]+\|>") + + +def clean_text(text: str) -> str: + """Collapse all whitespace runs to single spaces (reference ``_clean_text``).""" + return " ".join(str(text).strip().split()) + + +def normalize_text(text: str, *, add_default_speaker: bool = False) -> str: + """Clean text, normalise speaker tags, reject other control tokens. + + ``<|speaker:N|>`` is allowed; any other ``<|...|>`` token raises so callers + cannot inject ``<|semantic:...|>`` / ``<|im_end|>`` into a prompt. + """ + normalized = clean_text(_LEGACY_SPEAKER_TAG_PATTERN.sub(r"<|speaker:\1|>", text)) + + disallowed = [ + token for token in _CONTROL_TOKEN_PATTERN.findall(normalized) if not _SPEAKER_TAG_PATTERN.fullmatch(token) + ] + if disallowed: + tokens = ", ".join(sorted(set(disallowed))) + raise ValueError(f"Audio8 TTS input contains unsupported control token(s): {tokens}") + + if add_default_speaker and not _SPEAKER_TAG_PATTERN.search(normalized): + normalized = f"<|speaker:0|>{normalized}" + return normalized + + +def _encode(tokenizer: Any, text: str) -> list[int]: + return list(tokenizer.encode(text, add_special_tokens=False)) + + +def _encode_segments(tokenizer: Any, segments: list[str]) -> list[int]: + ids: list[int] = [] + for segment in segments: + ids.extend(_encode(tokenizer, segment)) + return ids + + +def build_text_only_prompt_ids(tokenizer: Any, text: str) -> tuple[list[int], str]: + """Build the no-reference prompt. + + Returns: + ``(prompt_token_ids, normalized_text)``. + """ + normalized = normalize_text(text) + if not normalized: + raise ValueError("Audio8 TTS input text must not be empty") + ids = _encode_segments( + tokenizer, + [ + "<|im_start|>system\n", + SYSTEM_PROMPT_TEXT_ONLY, + "<|im_end|>\n", + "<|im_start|>user\n", + normalized, + "<|im_end|>\n", + "<|im_start|>assistant\n<|voice|>", + ], + ) + return ids, normalized + + +def build_voice_clone_prompt_parts( + tokenizer: Any, + text: str, + ref_text: str, +) -> tuple[list[int], list[int], str, str]: + """Build the clone prompt around the reference-code slot. + + Caller splices ``[code + semantic_begin_id for code in ref_codes[:, 0]]`` + between the returned prefix and suffix. + """ + normalized = normalize_text(text) + if not normalized: + raise ValueError("Audio8 TTS input text must not be empty") + normalized_ref = normalize_text(ref_text, add_default_speaker=True) + if not normalized_ref: + raise ValueError("Audio8 TTS voice cloning requires a non-empty reference transcript") + + prefix = _encode_segments( + tokenizer, + [ + "<|im_start|>system\n", + SYSTEM_PROMPT_CLONE_PREFIX, + normalized_ref, + SYSTEM_PROMPT_CLONE_SUFFIX, + ], + ) + suffix = _encode_segments( + tokenizer, + [ + "<|im_end|>\n", + "<|im_start|>user\n", + normalized, + "<|im_end|>\n", + "<|im_start|>assistant\n<|voice|>", + ], + ) + return prefix, suffix, normalized, normalized_ref + + +def build_voice_clone_prompt_ids( + tokenizer: Any, + text: str, + ref_text: str, + semantic_token_ids: list[int], +) -> tuple[list[int], int, str, str]: + """Build the full clone prompt. + + ``ref_start_index`` is the position of the first reference frame, i.e. + where the residual codebook embeddings must be added. + """ + prefix, suffix, normalized, normalized_ref = build_voice_clone_prompt_parts(tokenizer, text, ref_text) + return prefix + list(semantic_token_ids) + suffix, len(prefix), normalized, normalized_ref + + +def estimate_voice_clone_prompt_len( + tokenizer: Any, + normalized_text: str, + normalized_ref_text: str, + ref_frames: int, +) -> int: + """Exact clone prompt length, without encoding the reference audio.""" + prefix, suffix, _, _ = build_voice_clone_prompt_parts(tokenizer, normalized_text, normalized_ref_text) + return len(prefix) + max(0, int(ref_frames)) + len(suffix) + + +__all__ = [ + "SYSTEM_PROMPT_CLONE_PREFIX", + "SYSTEM_PROMPT_CLONE_SUFFIX", + "SYSTEM_PROMPT_TEXT_ONLY", + "build_text_only_prompt_ids", + "build_voice_clone_prompt_ids", + "build_voice_clone_prompt_parts", + "clean_text", + "estimate_voice_clone_prompt_len", + "normalize_text", +] diff --git a/vllm_omni/model_executor/models/audio8_tts/sampling.py b/vllm_omni/model_executor/models/audio8_tts/sampling.py new file mode 100644 index 000000000..638408411 --- /dev/null +++ b/vllm_omni/model_executor/models/audio8_tts/sampling.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS sampling primitives, ported 1:1 from the reference checkpoint. + +Filter (top-k/top-p on **raw** logits) -> divide surviving logits by +temperature -> Gumbel-max draw. This is the reverse of the usual filter order +and is audibly different at ``temperature != 1``, which is why this cannot +reuse ``models/common/nucleus_ras_sampling.py``. Per-row scalars are accepted +so mixed batches run in one shot without a GPU->CPU sync. +""" + +from __future__ import annotations + +import torch + +#: Below this temperature a request is treated as greedy, matching vLLM. +SAMPLING_EPS = 1e-5 + + +def _as_column( + value: float | int | torch.Tensor, + batch_size: int, + device: torch.device, + dtype: torch.dtype, +) -> torch.Tensor: + """Broadcast a scalar or ``[B]`` tensor to a ``[B, 1]`` tensor.""" + if isinstance(value, torch.Tensor): + return value.to(device=device, dtype=dtype).reshape(-1, 1).expand(batch_size, 1) + return torch.full((batch_size, 1), float(value), device=device, dtype=dtype) + + +def filter_top_k_top_p( + logits: torch.Tensor, + top_k: int | torch.Tensor, + top_p: float | torch.Tensor, +) -> torch.Tensor: + """Mask candidates outside the top-k / top-p set of the raw logits. + + ``top_k <= 0`` and ``top_p >= 1`` disable each filter. The highest-scoring + candidate is always kept, so no row can become all ``-inf``. + """ + if logits.ndim != 2: + raise ValueError(f"logits must be 2-D [B, V], got {tuple(logits.shape)}") + batch_size, vocab = logits.shape + scalar_k = not isinstance(top_k, torch.Tensor) + scalar_p = not isinstance(top_p, torch.Tensor) + if scalar_k and scalar_p and (int(top_k) <= 0 or int(top_k) >= vocab) and float(top_p) >= 1.0: + return logits + + sorted_scores, sorted_indices = torch.sort(logits, descending=True, dim=-1) + cumulative = torch.cumsum(torch.softmax(sorted_scores, dim=-1), dim=-1) + positions = torch.arange(vocab, device=logits.device).unsqueeze(0) + + keep_k = _as_column(top_k, batch_size, logits.device, torch.long) + # top_k <= 0 means "no cut"; represent it as the full vocabulary. + keep_k = torch.where(keep_k <= 0, torch.full_like(keep_k, vocab), keep_k) + threshold_p = _as_column(top_p, batch_size, logits.device, cumulative.dtype) + + remove_sorted = (cumulative > threshold_p) | (positions >= keep_k) + # Always keep the argmax so at least one candidate survives. + remove_sorted[:, 0] = False + remove = torch.zeros_like(remove_sorted).scatter(-1, sorted_indices, remove_sorted) + return logits.masked_fill(remove, float("-inf")) + + +def gumbel_argmax_sample( + scores: torch.Tensor, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Draw one index per row via Gumbel-max over ``softmax(scores)``.""" + probabilities = torch.softmax(scores, dim=-1) + uniform = torch.rand( + probabilities.shape, + dtype=probabilities.dtype, + device=probabilities.device, + generator=generator, + ) + # -log(u) is Exp(1); argmax(p / Exp(1)) samples from p. + noise = -torch.log(uniform.clamp_min(torch.finfo(probabilities.dtype).tiny)) + return torch.argmax(probabilities / noise, dim=-1) + + +def sample_scores( + logits: torch.Tensor, + *, + temperature: float | torch.Tensor, + top_k: int | torch.Tensor, + top_p: float | torch.Tensor, + do_sample: bool = True, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Filter -> temper -> draw, in the reference order. + + Rows whose temperature is below :data:`SAMPLING_EPS` fall back to argmax. + """ + filtered = filter_top_k_top_p(logits, top_k, top_p) + greedy = filtered.argmax(dim=-1) + if not do_sample: + return greedy + if not isinstance(temperature, torch.Tensor): + if float(temperature) < SAMPLING_EPS: + return greedy + return gumbel_argmax_sample(filtered / float(temperature), generator=generator) + + temperature_col = _as_column(temperature, filtered.shape[0], filtered.device, filtered.dtype) + safe_temperature = torch.where( + temperature_col < SAMPLING_EPS, + torch.ones_like(temperature_col), + temperature_col, + ) + sampled = gumbel_argmax_sample(filtered / safe_temperature, generator=generator) + return torch.where(temperature_col.reshape(-1) < SAMPLING_EPS, greedy, sampled) + + +def ras_sample_semantic( + logits: torch.Tensor, + recent_ids: torch.Tensor | None, + *, + temperature: float | torch.Tensor, + top_k: int | torch.Tensor, + top_p: float | torch.Tensor, + ras_temperature: float, + ras_top_p: float, + do_sample: bool = True, + num_semantic_ids: int | None = None, + generator: torch.Generator | None = None, +) -> torch.Tensor: + """Repetition-Aware Sampling for the semantic (Slow AR) token. + + Draws with the request's ``temperature`` / ``top_p``; if the drawn id is + already in ``recent_ids`` (pad with a value that cannot be sampled, e.g. + ``-1``), redraws from the flatter RAS distribution instead. The + ``num_semantic_ids`` slot and beyond (EOS) are never substituted, so a + repeat EOS still ends the utterance. + + DualAR RAS *resamples* repeats rather than masking them — masking the + dominant token inflates EOS probability and truncates the utterance. + """ + normal = sample_scores( + logits, + temperature=temperature, + top_k=top_k, + top_p=top_p, + do_sample=do_sample, + generator=generator, + ) + if not do_sample or recent_ids is None or recent_ids.numel() == 0: + return normal + + high = sample_scores( + logits, + temperature=ras_temperature, + top_k=top_k, + top_p=ras_top_p, + do_sample=True, + generator=generator, + ) + repeated = (recent_ids == normal[:, None]).any(dim=1) + if num_semantic_ids is not None: + repeated &= normal < int(num_semantic_ids) + return torch.where(repeated, high, normal) + + +def _row_value(value: object, index: int) -> object: + """Slice a per-row value for request ``index``; scalars pass through.""" + return value[index : index + 1] if isinstance(value, torch.Tensor) else value + + +def ras_sample_batch( + logits: torch.Tensor, + recent_ids: torch.Tensor | None, + *, + temperature: float | torch.Tensor, + top_k: int | torch.Tensor, + top_p: float | torch.Tensor, + ras_temperature: float, + ras_top_p: float, + num_semantic_ids: int | None, + generators: dict, + num_reqs: int, +) -> torch.Tensor: + """Batched RAS that honors each request's own RNG generator. + + vLLM keys ``generators`` by batch slot, so a single shared generator would + leak slot 0's RNG stream to every row and make a prompt's audio depend on + its batch neighbours (undercutting per-request ``seed``). The single-request + case keeps the batched fast path; larger batches loop so each row draws from + its own (possibly unseeded) generator. + """ + common = dict( + ras_temperature=ras_temperature, + ras_top_p=ras_top_p, + num_semantic_ids=num_semantic_ids, + ) + if num_reqs == 1: + return ras_sample_semantic( + logits, + recent_ids, + temperature=temperature, + top_k=top_k, + top_p=top_p, + generator=generators.get(0), + **common, + ) + rows = [ + ras_sample_semantic( + logits[i : i + 1], + None if recent_ids is None else recent_ids[i : i + 1], + temperature=_row_value(temperature, i), + top_k=_row_value(top_k, i), + top_p=_row_value(top_p, i), + generator=generators.get(i), + **common, + ) + for i in range(num_reqs) + ] + return torch.cat(rows, dim=0) + + +__all__ = [ + "SAMPLING_EPS", + "filter_top_k_top_p", + "gumbel_argmax_sample", + "ras_sample_batch", + "ras_sample_semantic", + "sample_scores", +] diff --git a/vllm_omni/model_executor/models/registry.py b/vllm_omni/model_executor/models/registry.py index cb5167597..b9373e5f2 100644 --- a/vllm_omni/model_executor/models/registry.py +++ b/vllm_omni/model_executor/models/registry.py @@ -304,6 +304,17 @@ _OMNI_MODELS = { "gepard_talker", "GepardTalkerForConditionalGeneration", ), + ## audio8_tts (Audio8 TTS Preview 0.6B) + "Audio8TTSSlowARForConditionalGeneration": ( + "audio8_tts", + "audio8_tts_slow_ar", + "Audio8TTSSlowARForConditionalGeneration", + ), + "Audio8TTSCodecDecoder": ( + "audio8_tts", + "audio8_tts_codec_decoder", + "Audio8TTSCodecDecoder", + ), ## VoxCPM2 "VoxCPM2TalkerForConditionalGeneration": ( "voxcpm2", diff --git a/vllm_omni/model_executor/stage_input_processors/audio8_tts.py b/vllm_omni/model_executor/stage_input_processors/audio8_tts.py new file mode 100644 index 000000000..4b453c13a --- /dev/null +++ b/vllm_omni/model_executor/stage_input_processors/audio8_tts.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS Preview: Slow AR -> codec decoder stage input processor.""" + +from collections.abc import Mapping +from typing import Any + +import torch + +from vllm_omni.data_entry_keys import ( + CodesStruct, + MetaStruct, + OmniPayloadStruct, +) + +#: Chunking defaults; ~21.5 codec frames per second, so 25 frames ~= 1.16 s. +DEFAULT_CHUNK_FRAMES = 25 +DEFAULT_LEFT_CONTEXT_FRAMES = 25 + + +def _connector_extra(transfer_manager: Any) -> dict[str, Any]: + connector = getattr(transfer_manager, "connector", None) + raw_cfg = getattr(connector, "config", {}) or {} + return raw_cfg.get("extra", raw_cfg) if isinstance(raw_cfg, dict) else {} + + +def _cfg_int(cfg: dict[str, Any], key: str, default: int) -> int: + value = cfg.get(key, default) + try: + return int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid Audio8 TTS integer config {key}={value!r}") from exc + + +def _request_initial_chunk_frames(request: Any, default: int) -> int: + """Per-request override of ``initial_codec_chunk_frames``, if present.""" + additional_information = getattr(request, "additional_information", None) + entries = getattr(additional_information, "entries", None) + if not entries or "initial_codec_chunk_frames" not in entries: + return default + entry = entries["initial_codec_chunk_frames"] + if entry.list_data is not None and len(entry.list_data) == 1: + return int(entry.list_data[0]) + return default + + +def extract_last_frame(multimodal_output: Mapping[str, Any]) -> torch.Tensor | None: + """Return the newest frame of codec codes, or ``None`` if this step had none. + + Prefill steps emit an all-zero placeholder frame, which must not be + forwarded to the decoder. + """ + audio_codes = multimodal_output.get("audio_codes") + if not isinstance(audio_codes, torch.Tensor) or audio_codes.numel() == 0: + return None + if audio_codes.ndim == 1: + return audio_codes.to(device="cpu", dtype=torch.long).reshape(-1) + if audio_codes.ndim != 2: + raise ValueError(f"Invalid audio_codes shape for Audio8 TTS async_chunk: {tuple(audio_codes.shape)}") + + frame = audio_codes[-1] + if frame.numel() == 0: + return None + valid = multimodal_output.get("audio_code_valid") + if isinstance(valid, torch.Tensor) and valid.numel() > 0: + is_valid = bool(valid.reshape(-1)[-1].item()) + elif valid is not None: + is_valid = bool(valid) + else: + is_valid = bool(frame.any().item()) + if not is_valid: + return None + return frame.to(device="cpu", dtype=torch.long).reshape(-1) + + +def slow_ar_to_codec_decoder_async_chunk( + transfer_manager: Any, + multimodal_output: dict[str, Any] | None, + request: Any, + is_finished: bool = False, +) -> OmniPayloadStruct | None: + """Emit ``[left_context | new_frames]`` chunks as the Slow AR produces codes. + + Codes accumulate across AR steps and are shipped as a + ``[num_codebooks, frames]`` tensor. ``meta.left_context_size`` tells the + decoder how many leading frames to drop, keeping chunk boundaries + artefact-free. Returns ``None`` when the current chunk is not full yet. + """ + request_id = request.external_req_id + finished = bool(is_finished or request.is_finished()) + cfg = _connector_extra(transfer_manager) + + appended_this_call = False + if isinstance(multimodal_output, Mapping): + frame = extract_last_frame(multimodal_output) + if frame is not None: + transfer_manager.code_prompt_token_ids[request_id].append(frame.detach()) + appended_this_call = True + elif not finished: + return None + + chunk_frames = _cfg_int(cfg, "codec_chunk_frames", DEFAULT_CHUNK_FRAMES) + left_context_frames = _cfg_int(cfg, "codec_left_context_frames", DEFAULT_LEFT_CONTEXT_FRAMES) + initial_chunk_frames = _request_initial_chunk_frames(request, _cfg_int(cfg, "initial_codec_chunk_frames", 0)) + if chunk_frames <= 0 or left_context_frames < 0 or initial_chunk_frames < 0: + raise ValueError( + "Invalid Audio8 TTS codec chunk config: " + f"codec_chunk_frames={chunk_frames}, codec_left_context_frames={left_context_frames}, " + f"initial_codec_chunk_frames={initial_chunk_frames}" + ) + initial_chunk_frames = min(initial_chunk_frames, chunk_frames) + + frames = transfer_manager.code_prompt_token_ids[request_id] + length = len(frames) + if length <= 0: + if finished: + # Still emit a terminal payload so the decoder can close the stream. + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) + return None + + # A smaller first chunk trades a little quality for time-to-first-audio. + if initial_chunk_frames > 0 and length <= chunk_frames: + already_sent = transfer_manager.put_req_chunk[request_id] * initial_chunk_frames + pending = length - already_sent + if pending <= 0: + return None + if pending < initial_chunk_frames and not finished: + return None + new_frames = min(pending, initial_chunk_frames) + left_context_size = max(0, length - new_frames) + window = frames[:length] + else: + initial_coverage = ( + (chunk_frames // initial_chunk_frames) * initial_chunk_frames if initial_chunk_frames > 0 else 0 + ) + pending = (length - initial_coverage) % chunk_frames + if pending != 0 and not finished: + return None + if pending == 0 and finished and not appended_this_call: + # This boundary's chunk was already shipped when the stream first + # reached it; a terminal call carrying no new frame must close the + # stream, not re-send that window (otherwise the last chunk_frames + # frames ship twice). + return OmniPayloadStruct( + codes=CodesStruct(audio=torch.empty(0, dtype=torch.long)), + meta=MetaStruct(finished=torch.tensor(True, dtype=torch.bool)), + ) + new_frames = pending if pending != 0 else chunk_frames + end_index = min(length, left_context_frames + new_frames) + left_context_size = max(0, end_index - new_frames) + window = frames[-end_index:] + + # Codebook-major [num_codebooks, frames]; the tensor payload avoids + # expanding codec indices into Python ints across the connector boundary. + codes_qf = torch.stack(window, dim=0).transpose(0, 1).contiguous() + return OmniPayloadStruct( + codes=CodesStruct(audio=codes_qf), + meta=MetaStruct( + left_context_size=left_context_size, + finished=torch.tensor(finished, dtype=torch.bool), + ), + ) + + +__all__ = [ + "DEFAULT_CHUNK_FRAMES", + "DEFAULT_LEFT_CONTEXT_FRAMES", + "extract_last_frame", + "slow_ar_to_codec_decoder_async_chunk", +] diff --git a/vllm_omni/transformers_utils/configs/__init__.py b/vllm_omni/transformers_utils/configs/__init__.py index 42d6d8d45..6beb91018 100644 --- a/vllm_omni/transformers_utils/configs/__init__.py +++ b/vllm_omni/transformers_utils/configs/__init__.py @@ -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 """ Custom model configs that cannot be expressed via HuggingFace Transformers alone, following the same pattern as vllm.transformers_utils.configs. @@ -10,6 +10,9 @@ from __future__ import annotations import importlib _CLASS_TO_MODULE: dict[str, str] = { + "Audio8TTSConfig": "vllm_omni.transformers_utils.configs.audio8_tts", + "Audio8TTSSlowARConfig": "vllm_omni.transformers_utils.configs.audio8_tts", + "Audio8TTSFastARConfig": "vllm_omni.transformers_utils.configs.audio8_tts", "HiggsAudioV3Config": "vllm_omni.transformers_utils.configs.higgs_audio_v3", "Mammothmoda2Config": "vllm_omni.transformers_utils.configs.mammoth_moda2", "Mammothmoda2Qwen2_5_VLConfig": "vllm_omni.transformers_utils.configs.mammoth_moda2", @@ -35,6 +38,9 @@ _CLASS_TO_MODULE: dict[str, str] = { } __all__ = [ + "Audio8TTSConfig", + "Audio8TTSFastARConfig", + "Audio8TTSSlowARConfig", "HiggsAudioV3Config", "Mammothmoda2Config", "Mammothmoda2Qwen2_5_VLConfig", @@ -75,6 +81,7 @@ def __dir__(): # Eagerly import all config modules so their AutoConfig.register() side-effects # run as soon as `vllm_omni.transformers_utils.configs` is imported. +from vllm_omni.transformers_utils.configs import audio8_tts as _audio8_tts # noqa: F401, E402 from vllm_omni.transformers_utils.configs import cosyvoice3 as _cosyvoice3 # noqa: F401, E402 from vllm_omni.transformers_utils.configs import dots_tts as _dots_tts # noqa: F401, E402 from vllm_omni.transformers_utils.configs import fish_speech as _fish_speech # noqa: F401, E402 diff --git a/vllm_omni/transformers_utils/configs/audio8_tts.py b/vllm_omni/transformers_utils/configs/audio8_tts.py new file mode 100644 index 000000000..42e920419 --- /dev/null +++ b/vllm_omni/transformers_utils/configs/audio8_tts.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Audio8 TTS config registration with transformers AutoConfig. + +Registers ``model_type = "arktts"`` (and the two sub-config types) so +``AutoConfig.from_pretrained("Audio8/Audio8-TTS-Preview-0.6b")`` resolves to +vllm-omni's Qwen2-shaped config instead of the checkpoint's remote code. + +This only wins when ``trust_remote_code`` is **off** -- transformers prefers a +checkpoint's ``auto_map`` over registered classes otherwise -- which is why +``deploy/audio8_tts.yaml`` sets ``trust_remote_code: false``. +""" + +from transformers import AutoConfig + +from vllm_omni.model_executor.models.audio8_tts.configuration_audio8_tts import ( + Audio8TTSConfig, + Audio8TTSFastARConfig, + Audio8TTSSlowARConfig, +) + +AutoConfig.register("arktts", Audio8TTSConfig) +AutoConfig.register("arktts_slow_ar", Audio8TTSSlowARConfig) +AutoConfig.register("arktts_fast_ar", Audio8TTSFastARConfig) + +__all__ = [ + "Audio8TTSConfig", + "Audio8TTSFastARConfig", + "Audio8TTSSlowARConfig", +] From 81c1c460b3eb12083a02abe0b8d85247964d4526 Mon Sep 17 00:00:00 2001 From: zhengjia Date: Tue, 8 Sep 2026 22:15:48 -0400 Subject: [PATCH 56/90] [Bugfix][Frontend] Accept the msgpack-numpy package's numpy markers on the OpenPI endpoint (#6051) Signed-off-by: zjli2013 Co-authored-by: Cursor --- .../openai_api/test_openpi_connection.py | 54 ++++++++++++++++++- vllm_omni/entrypoints/openpi/connection.py | 16 +++++- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/tests/entrypoints/openai_api/test_openpi_connection.py b/tests/entrypoints/openai_api/test_openpi_connection.py index 398ae6494..19ec23b6d 100644 --- a/tests/entrypoints/openai_api/test_openpi_connection.py +++ b/tests/entrypoints/openai_api/test_openpi_connection.py @@ -68,7 +68,7 @@ def test_pack_and_unpack_round_trip_numpy_values(): assert decoded["nested"][0]["done"] == np.bool_(True) -def test_unpack_accepts_msgpack_numpy_marker_dicts(): +def test_unpack_accepts_vllm_native_marker_dicts(): action = np.asarray([[1.0, 2.0]], dtype=np.float32) payload = { b"actions": { @@ -88,6 +88,58 @@ def test_unpack_accepts_msgpack_numpy_marker_dicts(): np.testing.assert_allclose(decoded[b"actions"], np.asarray([[1.0, 0.0]], dtype=np.float32)) +def test_unpack_accepts_msgpack_numpy_marker_dicts(): + action = np.asarray([[1.0, 2.0]], dtype=np.float32) + payload = { + b"actions": { + b"nd": True, + b"type": action.dtype.str, + b"kind": b"", + b"shape": action.shape, + b"data": action.tobytes(), + } + } + + decoded = openpi_connection._unpack_numpy(payload) + + np.testing.assert_allclose(decoded[b"actions"], action) + assert decoded[b"actions"].dtype == np.float32 + + +def test_unpack_rejects_msgpack_numpy_structured_array_markers(): + values = np.zeros(2, dtype=[("a", " {nd: true, type, kind, shape, data} scalar -> {nd: false, type, kind, data} + +`kind` is required on the `nd` markers because it is what separates them from a +plain user mapping, but its value is dialect-specific: the vLLM-native markers +carry the dtype kind character while the `msgpack-numpy` package leaves it empty +for plain dtypes and sets "V" for structured ones. Both spellings are accepted. +Scalars packed by `msgpack-numpy` omit `kind` entirely and are therefore left as +mappings. """ from __future__ import annotations @@ -83,9 +90,14 @@ def _decode_vllm_numpy_marker(obj: dict[Any, Any]) -> Any: if nd is _MISSING or dtype is _MISSING or kind is _MISSING or data is _MISSING: return _MISSING - dtype_obj = np.dtype(_decode_marker_text(dtype)) kind_text = _decode_marker_text(kind) - if dtype_obj.kind != kind_text: + # Structured markers carry a dtype descriptor list in `type`, so reject them + # before `np.dtype()` sees something it cannot parse. + if kind_text == "V": + raise ValueError("Unsupported dtype: structured arrays") + + dtype_obj = np.dtype(_decode_marker_text(dtype)) + if kind_text not in ("", dtype_obj.kind): raise ValueError(f"NumPy dtype marker kind mismatch: {dtype_obj.kind!r} != {kind_text!r}") if dtype_obj.kind in ("V", "O", "c"): raise ValueError(f"Unsupported dtype: {dtype_obj}") From 2bae550c04318269d6f672a670da4d0776307248 Mon Sep 17 00:00:00 2001 From: Rakesh Kariya <83279947+rk9595@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:45:59 +0530 Subject: [PATCH 57/90] [Frontend] Opt-in WebSocket TTS split_granularity and session seed (#7046) Signed-off-by: rk9595 Signed-off-by: Rakesh Kariya Co-authored-by: Cursor Co-authored-by: Claude Opus 5 --- docs/serving/speech_api.md | 39 ++-- .../online_serving/text_to_speech/README.md | 10 +- .../qwen3_tts/streaming_speech_client.py | 19 +- .../openai_api/test_serving_speech_stream.py | 161 +++++++++++++- .../openai_api/test_speech_text_splitter.py | 127 +++++++++++ .../entrypoints/openai/protocol/audio.py | 16 ++ .../openai/serving_speech_stream.py | 86 ++++---- .../openai/speech_text_splitter.py | 204 ++++++++++++++++++ 8 files changed, 610 insertions(+), 52 deletions(-) create mode 100644 tests/entrypoints/openai_api/test_speech_text_splitter.py create mode 100644 vllm_omni/entrypoints/openai/speech_text_splitter.py diff --git a/docs/serving/speech_api.md b/docs/serving/speech_api.md index a515e6557..f0658f278 100644 --- a/docs/serving/speech_api.md +++ b/docs/serving/speech_api.md @@ -279,10 +279,15 @@ curl -X POST http://localhost:8091/v1/audio/voices \ ## Streaming Text Input (WebSocket) -The `/v1/audio/speech/stream` WebSocket endpoint accepts text incrementally and generates audio per sentence as boundaries are detected. +The `/v1/audio/speech/stream` WebSocket endpoint accepts text incrementally. +By default (`split_granularity=none`) it buffers until `input.done` and +synthesizes that flush as **one** TTS request, which keeps long-form timbre +stable. Set `split_granularity` to `sentence` or `clause` to emit a request +at each detected boundary (lower time-to-first-audio for STT/LLM pipelines). -> Note: text input is always streamed incrementally. Audio output remains sentence-scoped: -> use `stream_audio=false` for one binary frame per sentence, or `stream_audio=true` for one or more PCM chunks per sentence. +> Note: `stream_audio` only changes how **audio bytes** are framed (one WAV/PCM +> payload vs chunked PCM). Text segmentation is controlled separately by +> `split_granularity`. ### WebSocket Protocol @@ -314,14 +319,16 @@ upstream LLM) pays the WebSocket handshake once instead of once per utterance. - The session config is sticky. Send `input.text` again straight after `session.done` to reuse it, or send another `session.config` first to change - voice, format, or reference audio. A `session.config` sent while text is - still buffered is rejected so no pending input is silently dropped. -- An utterance is the flush unit, not a linguistic one: it is whatever text was - buffered when `input.done` arrived, of any length, synthesized as one request. - `utterance_index` counts those flushes across the connection, so it tells you - which `input.done` a frame belongs to. `sentence_index` counts within one - flush and so pairs with `total_sentences`, which means every utterance reports - `sentence_index: 0` of `total_sentences: 1` (or `0` for an empty buffer). + voice, format, or reference audio. A `session.config` sent in the middle of + an utterance is rejected so no pending input is silently dropped and so a + split utterance cannot end up half in one voice and half in another. +- An utterance is the flush unit, not a linguistic one: it is one `input.done` + cycle. `utterance_index` counts those flushes across the connection, so it + tells you which `input.done` a frame belongs to. `sentence_index` counts the + TTS requests inside one flush and so pairs with `total_sentences`: with the + default `split_granularity=none` that is always `sentence_index: 0` of + `total_sentences: 1` (or `0` for an empty buffer), while `sentence` or + `clause` counts the linguistic units actually synthesized. - End the connection with `session.close`, or by closing the socket. An idle connection is still closed after the server's idle timeout, which now also applies to the gap between utterances. @@ -332,7 +339,15 @@ All REST API parameters are supported, plus: | Parameter | Type | Default | Description | | ----------- | ------ | --------- | ------------- | -| `stream_audio` | bool | false | Stream one or more PCM chunks for the buffered input over WebSocket | +| `stream_audio` | bool | false | Stream one or more PCM chunks for each TTS request over WebSocket | +| `split_granularity` | string | `"none"` | `"none"`: one request per `input.done`. `"sentence"`: split on `.!?` plus CJK `。!?…`, Indic danda `।॥`, and Arabic `؟`. `"clause"`: also split on `,;,;،؛`. | +| `seed` | integer | null | Forwarded to the speech engine for this session | + +ASCII punctuation only ends a unit when whitespace or `input.done` follows it, +and decimals (`3.14`), thousands separators (`1,000`), abbreviations (`Dr.`, +`e.g.`) and initials (`J. R.`) are not treated as boundaries. A punctuation run +and any closing quote or bracket stay with the unit they close, so `Wait...` +and `He said "Hello."` are one request each. ```bash DELETE /v1/audio/voices/{name} diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index b1221de63..5dbe58314 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -796,13 +796,21 @@ Raw PCM streaming requires `stream_format="audio"`, `response_format="pcm"`, and ### Streaming WebSocket -The `/v1/audio/speech/stream` endpoint accepts text incrementally and synthesizes the buffered text as one continuous request on `input.done`: +The `/v1/audio/speech/stream` endpoint accepts text incrementally and, by default, synthesizes the buffered text as one continuous request on `input.done`: ```bash python qwen3_tts/streaming_speech_client.py --text "Hello world. How are you? I am fine." python qwen3_tts/streaming_speech_client.py --text "..." --simulate-stt --stt-delay 0.1 ``` +For per-sentence audio (including Indic danda `।`) before `input.done`: + +```bash +python qwen3_tts/streaming_speech_client.py \ + --text "नमस्ते। कैसे हो?" \ + --split-granularity sentence +``` + `input.done` flushes without closing, so repeating `--text` synthesizes several utterances over one connection: ```bash diff --git a/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py b/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py index e141a4c48..793a5a6d5 100644 --- a/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py +++ b/examples/online_serving/text_to_speech/qwen3_tts/streaming_speech_client.py @@ -24,6 +24,11 @@ Usage: --text "Hello world. How are you? I am fine." \ --simulate-stt --stt-delay 0.1 + # Opt into per-sentence synthesis (Indic danda, CJK, Latin) + python streaming_speech_client.py \ + --text "नमस्ते। कैसे हो?" \ + --split-granularity sentence + # Receive JSON sidecar chunks with word-level timestamps python streaming_speech_client.py \ --text "Hello world. How are you?" \ @@ -73,8 +78,9 @@ except ImportError: def frame_basename(msg: dict) -> str: """Name a file after the utterance and sentence a frame belongs to. - Every utterance is sentence 0, so the utterance index is what keeps the - files of one connection from overwriting each other. + Every utterance uses `sentence_index` for units inside that flush. + With default `split_granularity=none` that index stays 0; sentence mode + uses 0, 1, ... so files from one connection do not overwrite each other. """ return f"utterance_{msg['utterance_index']:03d}_sentence_{msg['sentence_index']:03d}" @@ -368,6 +374,13 @@ def main(): ) parser.add_argument("--speed", type=float, default=1.0, help="Playback speed (0.25-4.0)") parser.add_argument("--max-new-tokens", type=int, default=None, help="Max tokens") + parser.add_argument("--seed", type=int, default=None, help="Sampling seed forwarded to the server") + parser.add_argument( + "--split-granularity", + default=None, + choices=["none", "sentence", "clause"], + help="Text split mode (default: server none = one request per input.done)", + ) # Base task options parser.add_argument("--ref-audio", default=None, help="Reference audio") @@ -409,6 +422,8 @@ def main(): "max_new_tokens", "ref_audio", "ref_text", + "seed", + "split_granularity", ]: val = getattr(args, key.replace("-", "_"), None) if val is not None: diff --git a/tests/entrypoints/openai_api/test_serving_speech_stream.py b/tests/entrypoints/openai_api/test_serving_speech_stream.py index 7c553af8f..6414ab4ff 100644 --- a/tests/entrypoints/openai_api/test_serving_speech_stream.py +++ b/tests/entrypoints/openai_api/test_serving_speech_stream.py @@ -197,7 +197,7 @@ class TestStreamingSpeechWebSocket: error = ws.receive_json() assert error["type"] == "error" - assert "while input is buffered" in error["message"] + assert "while an utterance is in progress" in error["message"] # The buffered text survives the rejected reconfiguration. ws.send_json({"type": "input.done"}) @@ -742,6 +742,8 @@ class TestStreamingSpeechWebSocket: config.speaker_embedding = None config.stream_audio = True config.word_timestamps = False + config.seed = None + config.non_streaming_mode = None with pytest.raises(WebSocketDisconnect): asyncio.run( @@ -758,6 +760,163 @@ class TestStreamingSpeechWebSocket: assert websocket.send_json.await_count == 2 +class TestWebSocketSentenceSplitting: + def test_sentence_granularity_emits_one_request_per_sentence(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "split_granularity": "sentence", + } + ) + ws.send_json({"type": "input.text", "text": "Hello world. How are you? "}) + ws.send_json({"type": "input.done"}) + + first = ws.receive_json() + assert first["sentence_index"] == 0 + assert first["sentence_text"] == "Hello world." + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + + second = ws.receive_json() + assert second["sentence_index"] == 1 + assert second["sentence_text"] == "How are you?" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + + assert ws.receive_json() == { + "type": "session.done", + "utterance_index": 0, + "total_sentences": 2, + } + + assert speech_service._generate_audio_bytes.await_count == 2 + assert [call.args[0].input for call in speech_service._generate_audio_bytes.await_args_list] == [ + "Hello world.", + "How are you?", + ] + + def test_sentence_granularity_emits_before_input_done(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "split_granularity": "sentence", + } + ) + ws.send_json({"type": "input.text", "text": "First sentence. "}) + + start = ws.receive_json() + assert start["sentence_text"] == "First sentence." + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert speech_service._generate_audio_bytes.await_count == 1 + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == { + "type": "session.done", + "utterance_index": 0, + "total_sentences": 1, + } + + def test_indic_danda_splits_without_latin_period(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "language": "Auto", + "split_granularity": "sentence", + } + ) + ws.send_json({"type": "input.text", "text": "नमस्ते। कैसे हो?"}) + ws.send_json({"type": "input.done"}) + + first = ws.receive_json() + assert first["sentence_text"] == "नमस्ते।" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + + second = ws.receive_json() + assert second["sentence_text"] == "कैसे हो?" + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + assert ws.receive_json()["total_sentences"] == 2 + + def test_session_config_rejected_after_a_split_unit_was_emitted(self, mocker: MockerFixture): + """The splitter buffer is empty here, but the utterance is still open.""" + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json( + { + "type": "session.config", + "voice": "Vivian", + "split_granularity": "sentence", + } + ) + ws.send_json({"type": "input.text", "text": "First sentence. "}) + ws.receive_json() + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + + ws.send_json({"type": "session.config", "voice": "Serena"}) + error = ws.receive_json() + assert error["type"] == "error" + assert "while an utterance is in progress" in error["message"] + + ws.send_json({"type": "input.text", "text": "Second sentence. "}) + assert ws.receive_json()["sentence_index"] == 1 + ws.receive_bytes() + assert ws.receive_json()["type"] == "audio.done" + + ws.send_json({"type": "input.done"}) + assert ws.receive_json() == { + "type": "session.done", + "utterance_index": 0, + "total_sentences": 2, + } + + # Reconfiguration is allowed again once the utterance closed. + ws.send_json({"type": "session.config", "voice": "Serena"}) + ws.send_json({"type": "input.text", "text": "Third."}) + ws.send_json({"type": "input.done"}) + ws.receive_json() + ws.receive_bytes() + ws.receive_json() + ws.receive_json() + + voices = [call.args[0].voice for call in speech_service._generate_audio_bytes.await_args_list] + assert voices == ["Vivian", "Vivian", "Serena"] + + def test_seed_is_forwarded_to_speech_request(self, mocker: MockerFixture): + app, speech_service = _build_test_app(mocker=mocker) + + with TestClient(app) as client: + with client.websocket_connect("/v1/audio/speech/stream") as ws: + ws.send_json({"type": "session.config", "voice": "Vivian", "seed": 42}) + ws.send_json({"type": "input.text", "text": "Hello."}) + ws.send_json({"type": "input.done"}) + ws.receive_json() + ws.receive_bytes() + ws.receive_json() + ws.receive_json() + + assert speech_service._generate_audio_bytes.await_args_list[0].args[0].seed == 42 + + class TestGeneratePcmChunksContract: """Guard: _generate_pcm_chunks must exist on OmniOpenAIServingSpeech. diff --git a/tests/entrypoints/openai_api/test_speech_text_splitter.py b/tests/entrypoints/openai_api/test_speech_text_splitter.py new file mode 100644 index 000000000..ca2eb76d8 --- /dev/null +++ b/tests/entrypoints/openai_api/test_speech_text_splitter.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest + +from vllm_omni.entrypoints.openai import speech_text_splitter +from vllm_omni.entrypoints.openai.speech_text_splitter import SpeechTextSplitter, extract_complete_units + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class TestExtractCompleteUnits: + def test_ascii_sentence_needs_whitespace_or_flush(self): + units, remainder, _ = extract_complete_units("Hello world.", frozenset(".!?。"), flush=False) + assert units == [] + assert remainder == "Hello world." + + units, remainder, _ = extract_complete_units("Hello world.", frozenset(".!?。"), flush=True) + assert units == ["Hello world."] + assert remainder == "" + + def test_ascii_sentence_with_trailing_space(self): + units, remainder, _ = extract_complete_units("Hello world. More", frozenset(".!?"), flush=False) + assert units == ["Hello world."] + assert remainder == "More" + + def test_decimal_is_not_a_sentence_boundary(self): + units, remainder, _ = extract_complete_units("It costs 3.14 dollars. ", frozenset(".!?"), flush=False) + assert units == ["It costs 3.14 dollars."] + assert remainder == "" + + def test_thousands_separator_is_not_a_clause_boundary(self): + units, _, _ = extract_complete_units("It is 1,000 rupees, sir. ", frozenset(".!?,;"), flush=False) + assert units == ["It is 1,000 rupees,", "sir."] + + def test_punctuation_run_stays_one_unit(self): + units, _, _ = extract_complete_units("Wait... Really?! Yes.", frozenset(".!?"), flush=True) + assert units == ["Wait...", "Really?!", "Yes."] + + def test_closing_quote_stays_with_its_sentence(self): + units, _, _ = extract_complete_units('He said "Hello." Bye.', frozenset(".!?"), flush=True) + assert units == ['He said "Hello."', "Bye."] + + def test_abbreviations_and_initials_do_not_split(self): + units, _, _ = extract_complete_units("Dr. Smith, e.g. J. R. Tolkien. Next.", frozenset(".!?"), flush=True) + assert units == ["Dr. Smith, e.g. J. R. Tolkien.", "Next."] + + def test_long_token_before_period_is_not_an_abbreviation(self): + units, _, _ = extract_complete_units("We landed in Washington. Next.", frozenset(".!?"), flush=True) + assert units == ["We landed in Washington.", "Next."] + + def test_abbreviation_lookback_is_bounded(self, monkeypatch): + # One WebSocket frame can reach ~128 KiB; an unbounded lookback walked + # the whole prefix at every `.`, quadratic in the frame size. + checks = 0 + is_token_char = speech_text_splitter._is_token_char + + def counting_is_token_char(ch): + nonlocal checks + checks += 1 + return is_token_char(ch) + + monkeypatch.setattr(speech_text_splitter, "_is_token_char", counting_is_token_char) + text = "a." * 20000 + units, _, _ = extract_complete_units(text, frozenset(".!?"), flush=True) + assert units == [text] + assert checks <= len(text) * (speech_text_splitter._MAX_ABBREVIATION_LEN + 1) + + def test_cjk_period_completes_once_the_next_char_is_known(self): + # The trailing 。 waits: a closing delimiter could still follow it. + units, remainder, _ = extract_complete_units("你好。世界。", frozenset(".!?。!?"), flush=False) + assert units == ["你好。"] + assert remainder == "世界。" + + units, remainder, _ = extract_complete_units("你好。世界。", frozenset(".!?。!?"), flush=True) + assert units == ["你好。", "世界。"] + assert remainder == "" + + def test_indic_danda_and_question_mark(self): + units, remainder, _ = extract_complete_units("नमस्ते। कैसे हो?", frozenset(".!?।॥؟"), flush=True) + assert units == ["नमस्ते।", "कैसे हो?"] + assert remainder == "" + + def test_scan_cursor_skips_already_examined_text(self): + units, remainder, scan = extract_complete_units("no terminator yet", frozenset(".!?"), flush=False) + assert units == [] + assert remainder == "no terminator yet" + assert scan == len(remainder) + + units, remainder, scan = extract_complete_units( + "no terminator yet. Next", frozenset(".!?"), flush=False, scan_from=scan + ) + assert units == ["no terminator yet."] + assert remainder == "Next" + + +class TestSpeechTextSplitter: + def test_none_buffers_until_flush(self): + splitter = SpeechTextSplitter("none") + assert splitter.feed("Hello world. ") == [] + assert splitter.flush() == ["Hello world."] + assert splitter.flush() == [] + + def test_sentence_mode_incremental_words(self): + splitter = SpeechTextSplitter("sentence") + assert splitter.feed("Hello ") == [] + assert splitter.feed("world. ") == ["Hello world."] + assert splitter.feed("How are you?") == [] + assert splitter.flush() == ["How are you?"] + + def test_sentence_mode_resumes_scan_across_feeds(self): + splitter = SpeechTextSplitter("sentence") + assert splitter.feed("It costs 3") == [] + assert splitter.feed(".") == [] + assert splitter.feed("14 dollars. ") == ["It costs 3.14 dollars."] + + def test_clause_mode_splits_on_comma(self): + splitter = SpeechTextSplitter("clause") + assert splitter.feed("Hello, world. ") == ["Hello,", "world."] + + def test_clause_mode_splits_on_arabic_semicolon(self): + splitter = SpeechTextSplitter("clause") + assert splitter.feed("مرحبا؛ كيف حالك") == ["مرحبا؛"] + + def test_empty_flush(self): + splitter = SpeechTextSplitter("sentence") + assert splitter.flush() == [] diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index f204ae209..2446114df 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -578,6 +578,22 @@ class StreamingSpeechSessionConfig(BaseModel): "frames (existing behavior)." ), ) + seed: int | None = Field( + default=None, + ge=_INT64_MIN, + le=_INT64_MAX, + description="Random seed forwarded to /v1/audio/speech for this session.", + ) + split_granularity: Literal["none", "sentence", "clause"] = Field( + default="none", + description=( + "How incoming input.text is segmented before TTS. 'none' (default) " + "buffers until input.done and runs one request, matching the " + "long-form timbre-continuity path. 'sentence' emits a request at " + "each sentence boundary (Latin .!? plus CJK/Indic/Arabic marks). " + "'clause' also splits on commas/semicolons for lower TTFA." + ), + ) @model_validator(mode="after") def validate_streaming_constraints(self) -> "StreamingSpeechSessionConfig": diff --git a/vllm_omni/entrypoints/openai/serving_speech_stream.py b/vllm_omni/entrypoints/openai/serving_speech_stream.py index 0f883f179..32e9fa40d 100644 --- a/vllm_omni/entrypoints/openai/serving_speech_stream.py +++ b/vllm_omni/entrypoints/openai/serving_speech_stream.py @@ -3,8 +3,10 @@ """WebSocket handler for streaming text input TTS. -Accepts text incrementally via WebSocket, buffers it until input.done, and -generates audio once for the buffered input using the existing TTS pipeline. +Accepts text incrementally via WebSocket. By default (split_granularity=none) +it buffers until input.done and generates audio once for the buffered input. +Opting into split_granularity=sentence or clause emits a TTS request at each +detected boundary so incremental STT/LLM clients can hear audio before flush. input.done is a flush, not a close: it ends the current utterance and the connection stays open, so the next utterance reuses the same connection @@ -13,9 +15,10 @@ session.close, on the idle timeout, or when the client closes the socket. The session config is sticky across flushes and can be replaced by sending another session.config between utterances. -"Utterance" here names the flush unit rather than any linguistic unit: it is -whatever text the client had buffered when it sent input.done, from a word to -several paragraphs, synthesized as a single request. +"Utterance" here names the flush unit. With split_granularity=none it is +whatever text was buffered when input.done arrived, synthesized as one +request. With sentence/clause splitting it is still one input.done cycle, +but sentence_index counts linguistic units inside that flush. Protocol: Client -> Server: @@ -32,11 +35,10 @@ Protocol: {"type": "audio.done", "utterance_index": 0, "sentence_index": 0} {"type": "session.done", "utterance_index": 0, "total_sentences": N} {"type": "error", "message": "..."} - # session.done ends the flushed utterance, not the connection. An - # utterance is just the flush unit: whatever text was buffered when - # input.done arrived, of any length. utterance_index counts those - # flushes across the connection, while sentence_index counts within - # one of them and so pairs with total_sentences. + # session.done ends the flushed utterance, not the connection. + # utterance_index counts input.done flushes. sentence_index counts + # TTS requests inside one flush (always 0 of 1 when split_granularity + # is none; 0..N-1 when sentence/clause splitting is enabled). Server -> Client (when word_timestamps=true): {"type": "audio.start", "utterance_index": 0, "sentence_index": 0, @@ -64,6 +66,7 @@ from vllm_omni.entrypoints.openai.protocol.audio import ( StreamingSpeechSessionConfig, ) from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech +from vllm_omni.entrypoints.openai.speech_text_splitter import SpeechTextSplitter from vllm_omni.utils.forced_aligner import extract_word_timestamps logger = init_logger(__name__) @@ -79,11 +82,10 @@ _MAX_INPUT_TEXT_MESSAGE_SIZE = 128 * 1024 class OmniStreamingSpeechHandler: """Handles WebSocket sessions for streaming text-input TTS. - A connection carries one or more utterances. Text arrives incrementally, - is buffered until input.done, and audio is generated once for the - buffered input using the existing OmniOpenAIServingSpeech pipeline. The - connection outlives each utterance so a client can keep synthesizing on - it without reconnecting. + A connection carries one or more utterances. Text arrives incrementally. + With split_granularity=none it is buffered until input.done and synthesized + as one request. sentence/clause modes emit a request at each boundary, + including before input.done. The connection outlives each utterance. Args: speech_service: The existing TTS serving instance (reused for @@ -105,17 +107,20 @@ class OmniStreamingSpeechHandler: async def handle_session(self, websocket: WebSocket) -> None: """Main loop for a single WebSocket connection. - Serves any number of utterances: text is buffered until input.done, - which flushes it as one TTS request and then leaves the connection - open for the next one. Rejecting a message is only fatal before the - first valid session.config; afterwards the error is reported and the - connection survives. + Serves any number of utterances. input.done always ends a flush; + split_granularity controls whether that flush is one TTS request or + several sentence/clause requests. """ await websocket.accept() config: StreamingSpeechSessionConfig | None = None - text_parts: list[str] = [] + splitter = SpeechTextSplitter("none") utterance_index = 0 + sentence_index = 0 + # An utterance is in progress from the first input.text until the next + # input.done. Buffered text is not enough to detect it: sentence/clause + # splitting can leave the buffer empty after emitting a unit. + utterance_open = False try: while True: @@ -140,10 +145,10 @@ class OmniStreamingSpeechHandler: msg_type = msg.get("type") if msg_type == "session.config": - if text_parts: + if utterance_open: await self._send_error( websocket, - "session.config cannot be applied while input is buffered; send input.done first", + "session.config cannot be applied while an utterance is in progress; send input.done first", ) continue new_config = await self._build_config(websocket, msg) @@ -152,6 +157,7 @@ class OmniStreamingSpeechHandler: return # Error already sent, connection closing continue # Keep serving with the previous config config = new_config + splitter = SpeechTextSplitter(config.split_granularity) elif config is None: await self._send_error( @@ -165,32 +171,39 @@ class OmniStreamingSpeechHandler: if not isinstance(text, str): await self._send_error(websocket, "input.text requires a string value") continue - text_parts.append(text) - - elif msg_type == "input.done": - full_text = "".join(text_parts).strip() - text_parts.clear() - total_sentences = 0 - if full_text: - # However long the buffered text is, the pipeline takes - # it as one request, so every flush is sentence 0 of 1. + if text: + utterance_open = True + for unit in splitter.feed(text): await self._generate_and_send( websocket, config, - full_text, + unit, utterance_index=utterance_index, - sentence_index=0, + sentence_index=sentence_index, ) - total_sentences = 1 + sentence_index += 1 + + elif msg_type == "input.done": + for unit in splitter.flush(): + await self._generate_and_send( + websocket, + config, + unit, + utterance_index=utterance_index, + sentence_index=sentence_index, + ) + sentence_index += 1 await websocket.send_json( { "type": "session.done", "utterance_index": utterance_index, - "total_sentences": total_sentences, + "total_sentences": sentence_index, } ) utterance_index += 1 + sentence_index = 0 + utterance_open = False elif msg_type == "session.close": await websocket.close() @@ -310,6 +323,7 @@ class OmniStreamingSpeechHandler: speaker_embedding=config.speaker_embedding, stream=config.stream_audio, word_timestamps=config.word_timestamps, + seed=config.seed, ) start_payload = { diff --git a/vllm_omni/entrypoints/openai/speech_text_splitter.py b/vllm_omni/entrypoints/openai/speech_text_splitter.py new file mode 100644 index 000000000..1f25a8dba --- /dev/null +++ b/vllm_omni/entrypoints/openai/speech_text_splitter.py @@ -0,0 +1,204 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Incremental text splitting for WebSocket TTS input. + +Default serving still synthesizes one request per ``input.done`` (see +``split_granularity='none'``). Opting into ``sentence`` or ``clause`` restores +per-boundary requests so incremental STT/LLM clients can start audio before +the full utterance arrives. + +Terminators cover Latin, CJK, Indic danda, and Arabic question/semicolon marks +so Hindi and similar scripts are not stuck waiting for ``.`` / ``?``. +""" + +from __future__ import annotations + +# ASCII punctuation is ambiguous (abbreviations, decimals, thousands +# separators), so it only closes a unit when whitespace or a flush follows. +# Script-specific marks below are unambiguous and close a unit as soon as the +# character after them is known. +_ASCII_SENTENCE_TERMINATORS = frozenset(".!?") +_ASCII_CLAUSE_TERMINATORS = _ASCII_SENTENCE_TERMINATORS | frozenset(",;") +_SCRIPT_SENTENCE_TERMINATORS = frozenset( + { + "。", + "!", + "?", + "…", + "।", # Devanagari danda + "॥", # Devanagari double danda + "؟", # Arabic question mark + } +) +_SCRIPT_CLAUSE_TERMINATORS = _SCRIPT_SENTENCE_TERMINATORS | { + ",", + ";", + "،", # Arabic comma + "؛", # Arabic semicolon +} + +_SENTENCE_TERMINATORS = _ASCII_SENTENCE_TERMINATORS | _SCRIPT_SENTENCE_TERMINATORS +_CLAUSE_TERMINATORS = _ASCII_CLAUSE_TERMINATORS | _SCRIPT_CLAUSE_TERMINATORS + +# Trailing characters that belong to the unit they close, so `Wait...` and +# `He said "Hello."` stay one TTS request instead of several. +_CLOSING_DELIMITERS = frozenset("\"'”’»)]}』」)】") +_RUN_CHARS = _CLAUSE_TERMINATORS | _CLOSING_DELIMITERS + +# Abbreviations that end in `.` mid-sentence. Single letters are covered +# separately so initials such as `J. R. R.` do not split either. +_ABBREVIATIONS = frozenset( + { + "dr", + "mr", + "mrs", + "ms", + "prof", + "sr", + "jr", + "st", + "mt", + "fig", + "no", + "vs", + "etc", + "approx", + "dept", + "inc", + "ltd", + "co", + "al", + "e.g", + "i.e", + "u.s", + "u.k", + "a.m", + "p.m", + } +) +_MAX_ABBREVIATION_LEN = max(len(abbrev) for abbrev in _ABBREVIATIONS) + + +def _is_token_char(ch: str) -> bool: + return ch.isalnum() or ch == "." + + +def _is_numeric_separator(buffer: str, index: int) -> bool: + """True for the `.`/`,` inside `3.14` or `1,000`.""" + if buffer[index] not in ".,": + return False + if index == 0 or not buffer[index - 1].isdigit(): + return False + return index + 1 < len(buffer) and buffer[index + 1].isdigit() + + +def _is_abbreviation(buffer: str, index: int) -> bool: + """True for the `.` of `Dr.`, `e.g.`, or an initial like `J.`.""" + if buffer[index] != ".": + return False + # Bounded so a frame of `a.a.a...` cannot walk the whole prefix at every + # `.`. Every abbreviation fits inside the limit, so hitting it can only + # rule a token out, never produce a false positive. + limit = max(0, index - _MAX_ABBREVIATION_LEN) + start = index + while start > limit and _is_token_char(buffer[start - 1]): + start -= 1 + if start > 0 and _is_token_char(buffer[start - 1]): + return False + token = buffer[start:index] + if not token: + return False + if len(token) == 1 and token.isalpha(): + return True + return token.lower().strip(".") in _ABBREVIATIONS + + +def extract_complete_units( + buffer: str, + terminators: frozenset[str], + *, + flush: bool, + scan_from: int = 0, +) -> tuple[list[str], str, int]: + """Split ``buffer`` into complete units. + + Returns ``(units, remainder, next_scan)``. ``next_scan`` is an offset into + ``remainder``: everything before it has been examined already, so a caller + appending more text can pass it back as ``scan_from`` instead of rescanning + the whole buffer on every ``input.text`` message. + """ + units: list[str] = [] + last_split = 0 + i = max(scan_from, 0) + length = len(buffer) + while i < length: + ch = buffer[i] + if ch not in terminators or _is_numeric_separator(buffer, i) or _is_abbreviation(buffer, i): + i += 1 + continue + + # Absorb the punctuation/closing-delimiter run so `Wait...` emits once. + run_end = i + 1 + while run_end < length and buffer[run_end] in _RUN_CHARS: + run_end += 1 + + if run_end >= length and not flush: + # The run may still grow; re-examine this terminator next feed. + return units, buffer[last_split:], i - last_split + + ascii_terminator = ch in _ASCII_CLAUSE_TERMINATORS + if run_end < length and ascii_terminator and not buffer[run_end].isspace(): + i = run_end + continue + + piece = buffer[last_split:run_end].strip() + if piece: + units.append(piece) + i = run_end + while i < length and buffer[i].isspace(): + i += 1 + last_split = i + + remainder = buffer[last_split:] + if flush: + tail = remainder.strip() + if tail: + units.append(tail) + return units, "", 0 + return units, remainder, len(remainder) + + +class SpeechTextSplitter: + """Stateful splitter used by one WebSocket utterance.""" + + def __init__(self, granularity: str = "none") -> None: + if granularity not in ("none", "sentence", "clause"): + raise ValueError(f"Unsupported split_granularity: {granularity!r}") + self.granularity = granularity + self._buf = "" + self._scan = 0 + + def _terminators(self) -> frozenset[str] | None: + if self.granularity == "none": + return None + if self.granularity == "clause": + return _CLAUSE_TERMINATORS + return _SENTENCE_TERMINATORS + + def feed(self, text: str) -> list[str]: + self._buf += text + terminators = self._terminators() + if terminators is None: + return [] + units, self._buf, self._scan = extract_complete_units(self._buf, terminators, flush=False, scan_from=self._scan) + return units + + def flush(self) -> list[str]: + terminators = self._terminators() + if terminators is None: + piece = self._buf.strip() + self._buf = "" + self._scan = 0 + return [piece] if piece else [] + units, self._buf, self._scan = extract_complete_units(self._buf, terminators, flush=True, scan_from=self._scan) + return units From 43b697541a93259700605af7441814a90c600585 Mon Sep 17 00:00:00 2001 From: Ziming Wang <125807850+ZenAlexa@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:31:42 +0800 Subject: [PATCH 58/90] [Bugfix][Frontend] Clear the P0 multimodal cache through the renderer (#7003) Signed-off-by: ZenAlexa --- .../test_async_omni_pause_sleep_routing.py | 21 +++++++++++++++++++ vllm_omni/entrypoints/async_omni.py | 16 +++----------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/tests/entrypoints/test_async_omni_pause_sleep_routing.py b/tests/entrypoints/test_async_omni_pause_sleep_routing.py index 9600b990d..38ec40163 100644 --- a/tests/entrypoints/test_async_omni_pause_sleep_routing.py +++ b/tests/entrypoints/test_async_omni_pause_sleep_routing.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Unit tests for AR EngineCore vs diffusion worker pause/sleep routing.""" from __future__ import annotations @@ -7,6 +10,8 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, call import pytest +from vllm.renderers import BaseRenderer +from vllm.v1.engine.input_processor import InputProcessor from vllm_omni.diffusion.data import CuMemTag, OmniACK from vllm_omni.entrypoints.async_omni import AsyncOmni @@ -37,6 +42,22 @@ def _make_omni(*, stage_types: list[str]) -> AsyncOmni: return omni +@pytest.mark.cpu +def test_reset_mm_cache_routes_through_renderer(mocker): + async def run() -> None: + omni = object.__new__(AsyncOmni) + renderer = mocker.create_autospec(BaseRenderer, instance=True) + input_processor = mocker.create_autospec(InputProcessor, instance=True) + input_processor.renderer = renderer + omni.input_processor = input_processor + + await omni.reset_mm_cache() + + renderer.clear_mm_cache_async.assert_awaited_once_with() + + asyncio.run(run()) + + @pytest.mark.cpu def test_split_stage_ids_by_type(): omni = _make_omni(stage_types=["llm", "diffusion", "llm"]) diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 4026907e1..948f7e53a 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -1416,19 +1416,9 @@ class AsyncOmni(EngineClient, OmniBase): ``EngineCore.sleep(level>=1)`` already clears the P1 receiver cache. Clearing P0 avoids hash-only follow-up requests after that reset. """ - processor = getattr(self, "input_processor", None) - if processor is None: - processor = getattr(self.engine, "input_processor", None) - cache = getattr(processor, "mm_processor_cache", None) - if cache is None: - logger.debug("[AsyncOmni] reset_mm_cache: no frontend mm_processor_cache") - return - for name in ("clear", "reset", "clear_cache"): - fn = getattr(cache, name, None) - if callable(fn): - fn() - return - logger.debug("[AsyncOmni] reset_mm_cache: cache has no clear/reset method") + renderer = self.renderer + if renderer is not None: + await renderer.clear_mm_cache_async() async def reset_encoder_cache(self) -> None: """Reset the encoder cache for all stages. From 358b7d79c1994fc17c62cb644b601e584c77aa10 Mon Sep 17 00:00:00 2001 From: Jim Ban <77719403+BANANASJIM@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:31:54 -0700 Subject: [PATCH 59/90] [Bugfix][Frontend] Enforce image pixel limits for video input references (#6963) Signed-off-by: BANANASJIM --- .../openai_api/test_video_api_utils.py | 50 ++++++++++++++++++- .../openai_api/test_video_server.py | 34 ++++++++++++- vllm_omni/entrypoints/openai/api_server.py | 23 +++++++-- .../entrypoints/openai/video_api_utils.py | 25 +++++++++- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/tests/entrypoints/openai_api/test_video_api_utils.py b/tests/entrypoints/openai_api/test_video_api_utils.py index 9f643d8ca..bf395b813 100644 --- a/tests/entrypoints/openai_api/test_video_api_utils.py +++ b/tests/entrypoints/openai_api/test_video_api_utils.py @@ -23,9 +23,9 @@ from vllm_omni.entrypoints.openai.errors import InvalidInputReferenceError pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -def _png_bytes() -> bytes: +def _png_bytes(size: tuple[int, int] = (2, 1)) -> bytes: buffer = BytesIO() - Image.new("RGB", (2, 1), color=(12, 34, 56)).save(buffer, format="PNG") + Image.new("RGB", size, color=(12, 34, 56)).save(buffer, format="PNG") return buffer.getvalue() @@ -122,6 +122,52 @@ async def test_decode_image_url_keeps_data_urls_local(monkeypatch): assert image.mode == "RGB" +def test_decode_image_bytes_rejects_image_over_pixel_limit_before_conversion(monkeypatch): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100) + + def _unexpected_convert(*args, **kwargs): + pytest.fail("over-limit images must be rejected before conversion") + + monkeypatch.setattr(Image.Image, "convert", _unexpected_convert) + + with pytest.raises( + InvalidInputReferenceError, + match=r"Image dimensions 20x20 \(400 pixels\) exceed the maximum of 100 pixels", + ): + video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference") + + +def test_decode_image_bytes_maps_pillow_pixel_limit_error(monkeypatch): + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100) + + with pytest.raises(InvalidInputReferenceError, match="decoder pixel limit") as exc_info: + video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference") + + assert isinstance(exc_info.value.__cause__, Image.DecompressionBombError) + + +@pytest.mark.parametrize("max_pixels", [0, 400]) +def test_decode_image_bytes_allows_disabled_or_inclusive_pixel_limit(monkeypatch, max_pixels): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", max_pixels) + + image = video_api_utils._decode_image_bytes(_png_bytes((20, 20)), source="image reference") + + assert image.size == (20, 20) + assert image.mode == "RGB" + + +@pytest.mark.asyncio +async def test_decode_input_reference_preserves_image_pixel_limit_error(monkeypatch): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100) + + with pytest.raises(InvalidInputReferenceError, match="VLLM_MAX_IMAGE_PIXELS"): + await video_api_utils.decode_input_reference( + image_reference=None, + video_reference=None, + input_reference_bytes=_png_bytes((20, 20)), + ) + + def _install_fake_video_mux(monkeypatch, mux_calls): def _fake_mux_video_audio_bytes(frames, audio, fps, audio_sample_rate, video_codec_options=None): mux_calls.append( diff --git a/tests/entrypoints/openai_api/test_video_server.py b/tests/entrypoints/openai_api/test_video_server.py index b13dfe42e..1b18a9ae5 100644 --- a/tests/entrypoints/openai_api/test_video_server.py +++ b/tests/entrypoints/openai_api/test_video_server.py @@ -1630,7 +1630,8 @@ def test_generic_video_model_rejects_mixed_image_and_video_references(test_clien assert "does not support mixed image and video" in response.json()["detail"].lower() -def test_h3_multipart_rejects_bmp_image_reference(test_client): +def test_h3_multipart_rejects_bmp_image_reference(test_client, monkeypatch): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100) image = Image.new("RGB", (64, 64), color="blue") image_buffer = io.BytesIO() image.save(image_buffer, format="BMP") @@ -1646,6 +1647,37 @@ def test_h3_multipart_rejects_bmp_image_reference(test_client): assert "must use jpg" in response.json()["detail"].lower() +@pytest.mark.parametrize("field", ["input_reference", "input_references"]) +def test_h3_multipart_rejects_image_over_pixel_limit(field, test_client, monkeypatch): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 100) + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" + + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "reject oversized image", "extra_params": '{"task":"ref2va"}'}, + files=[(field, ("reference.png", _make_test_image_bytes((20, 20)), "image/png"))], + ) + + assert response.status_code == 400 + assert "VLLM_MAX_IMAGE_PIXELS" in response.json()["detail"] + + +@pytest.mark.parametrize("field", ["input_reference", "input_references"]) +def test_h3_multipart_maps_pillow_pixel_limit_error(field, test_client, monkeypatch): + monkeypatch.setattr(envs, "VLLM_MAX_IMAGE_PIXELS", 0) + monkeypatch.setattr(Image, "MAX_IMAGE_PIXELS", 100) + test_client.app.state.openai_serving_video._engine_client.model_class_name = "MiniMaxH3Pipeline" + + response = test_client.post( + "/v1/videos/sync", + data={"prompt": "reject decoder bomb", "extra_params": '{"task":"ref2va"}'}, + files=[(field, ("reference.png", _make_test_image_bytes((20, 20)), "image/png"))], + ) + + assert response.status_code == 400 + assert "decoder pixel limit" in response.json()["detail"] + + @pytest.mark.asyncio async def test_h3_upload_limit_checks_declared_size_before_read(): class OversizedUpload: diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index f97207bbc..d976eb181 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -161,6 +161,8 @@ from vllm_omni.entrypoints.openai.stores import VIDEO_STORE, VIDEO_TASKS from vllm_omni.entrypoints.openai.utils import get_stage_type, parse_lora_request from vllm_omni.entrypoints.openai.video_api_utils import ( VideoFrames, + _decode_image_bytes, + _validate_image_pixel_limit, decode_audio_url, decode_input_reference, ) @@ -3438,6 +3440,15 @@ def _validate_minimax_h3_image_payload( try: with Image.open(io.BytesIO(payload)) as image: image_format = str(image.format or "").lower() + if image_format in MINIMAX_H3_REFERENCE_IMAGE_FORMATS: + _validate_image_pixel_limit(image) + except InvalidInputReferenceError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST.value, detail=str(exc)) from exc + except Image.DecompressionBombError as exc: + raise HTTPException( + HTTPStatus.BAD_REQUEST.value, + detail=f"Invalid uploaded image reference: {filename}; image exceeds the decoder pixel limit.", + ) from exc except (OSError, ValueError) as exc: if allow_non_image: return @@ -3478,10 +3489,14 @@ async def _persist_uploaded_media_references( if kind == "image": try: _validate_minimax_h3_image_payload(payload, filename=upload.filename) - with Image.open(io.BytesIO(payload)) as image: - images.append(image.convert("RGB")) - except (OSError, ValueError) as exc: - raise HTTPException(400, detail=f"Invalid uploaded image reference: {upload.filename}") from exc + images.append( + _decode_image_bytes( + payload, + source=f"uploaded image reference: {upload.filename}", + ) + ) + except InvalidInputReferenceError as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST.value, detail=str(exc)) from exc continue suffix = Path(upload.filename or "").suffix.lower() if kind == "video" and suffix and suffix not in MINIMAX_H3_REFERENCE_VIDEO_SUFFIXES: diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py index 09a3d284d..35a39b2ea 100644 --- a/vllm_omni/entrypoints/openai/video_api_utils.py +++ b/vllm_omni/entrypoints/openai/video_api_utils.py @@ -71,6 +71,21 @@ class VideoFrames(list[Image.Image]): self.source_path = source_path +class _ImagePixelLimitError(InvalidInputReferenceError): + """An image exceeded a configured or decoder-enforced pixel limit.""" + + +def _validate_image_pixel_limit(image: Image.Image) -> None: + width, height = image.size + max_pixels = envs.VLLM_MAX_IMAGE_PIXELS + if max_pixels > 0 and width * height > max_pixels: + raise _ImagePixelLimitError( + f"Image dimensions {width}x{height} ({width * height} pixels) exceed " + f"the maximum of {max_pixels} pixels. Set " + f"VLLM_MAX_IMAGE_PIXELS to increase this limit." + ) + + def positive_float(value: Any) -> float | None: if value is None: return None @@ -87,7 +102,13 @@ def positive_float(value: Any) -> float | None: def _decode_image_bytes(image_bytes: bytes, *, source: str) -> Image.Image: try: - return Image.open(BytesIO(image_bytes)).convert("RGB") + with Image.open(BytesIO(image_bytes)) as image: + _validate_image_pixel_limit(image) + return image.convert("RGB") + except _ImagePixelLimitError: + raise + except Image.DecompressionBombError as exc: + raise _ImagePixelLimitError(f"Invalid {source}: image exceeds the decoder pixel limit.") from exc except (UnidentifiedImageError, OSError, ValueError) as exc: raise InvalidInputReferenceError(f"Invalid {source}: provided content is not a valid image.") from exc @@ -158,6 +179,8 @@ def _decode_media_bytes( ) -> Image.Image | VideoFrames: try: return _decode_image_bytes(media_bytes, source=source) + except _ImagePixelLimitError: + raise except InvalidInputReferenceError: try: return _decode_video_bytes( From f2e30f46b6a3e9bfb00468d7f2596852bffd37de Mon Sep 17 00:00:00 2001 From: Allen Wu <85376543+EchoHayate@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:32:05 +0800 Subject: [PATCH 60/90] [Bugfix][TTS] Isolate shared Higgs v3 reference encode from request cancellation (#7076) Signed-off-by: Allen Wu Co-authored-by: TRAE CLI --- .../openai_api/test_higgs_audio_v3_adapter.py | 129 ++++++++++++++++++ .../openai/tts_adapters/higgs_audio_v3.py | 18 ++- 2 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 tests/entrypoints/openai_api/test_higgs_audio_v3_adapter.py diff --git a/tests/entrypoints/openai_api/test_higgs_audio_v3_adapter.py b/tests/entrypoints/openai_api/test_higgs_audio_v3_adapter.py new file mode 100644 index 000000000..7992af25f --- /dev/null +++ b/tests/entrypoints/openai_api/test_higgs_audio_v3_adapter.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import asyncio +import gc +import threading +from types import SimpleNamespace + +import pytest +import torch + +from vllm_omni.entrypoints.openai.tts_adapters.base import SpeechServingContext +from vllm_omni.entrypoints.openai.tts_adapters.higgs_audio_v3 import HiggsAudioV3Adapter + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_ref_code_inflight_survives_request_cancellation(): + adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace())) + encode_started = threading.Event() + release_encode = threading.Event() + calls = 0 + expected_codes = torch.arange(16, dtype=torch.long).reshape(2, 8) + 1 + + def encode_reference_audio(_wav, _sr): + nonlocal calls + calls += 1 + encode_started.set() + assert release_encode.wait(timeout=5) + return torch.arange(16, dtype=torch.long).reshape(2, 8) + + def apply_delay_pattern(codes): + return codes + 1 + + async def resolve(): + return await adapter._resolve_higgs_audio_v3_ref_codes( + "ref-a", + object(), + 24000, + encode_reference_audio, + apply_delay_pattern, + ) + + async def run(): + creator = asyncio.create_task(resolve()) + try: + assert await asyncio.wait_for(asyncio.to_thread(encode_started.wait), timeout=5) + + cancelled_waiter = asyncio.create_task(resolve()) + survivor = asyncio.create_task(resolve()) + await asyncio.sleep(0) + + cancelled_waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await cancelled_waiter + + creator.cancel() + with pytest.raises(asyncio.CancelledError): + await creator + + release_encode.set() + survivor_result = await asyncio.wait_for(survivor, timeout=5) + await asyncio.sleep(0) + return survivor_result + finally: + release_encode.set() + + codes, cache_hit, inflight_wait = asyncio.run(run()) + + assert calls == 1 + assert cache_hit is False + assert inflight_wait is True + assert torch.equal(codes, expected_codes) + assert "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight + cached = adapter._get_higgs_audio_v3_ref_codes("ref-a") + assert cached is not None + assert torch.equal(cached, expected_codes) + + +def test_ref_code_inflight_consumes_failure_after_all_callers_cancel(): + adapter = HiggsAudioV3Adapter(SpeechServingContext(server=SimpleNamespace())) + encode_started = threading.Event() + release_encode = threading.Event() + loop_errors = [] + + def encode_reference_audio(_wav, _sr): + encode_started.set() + assert release_encode.wait(timeout=5) + raise RuntimeError("encode failed") + + async def resolve(): + return await adapter._resolve_higgs_audio_v3_ref_codes( + "ref-a", + object(), + 24000, + encode_reference_audio, + lambda codes: codes, + ) + + async def run(): + loop = asyncio.get_running_loop() + loop.set_exception_handler(lambda _loop, context: loop_errors.append(context)) + creator = asyncio.create_task(resolve()) + try: + assert await asyncio.wait_for(asyncio.to_thread(encode_started.wait), timeout=5) + waiter = asyncio.create_task(resolve()) + await asyncio.sleep(0) + + creator.cancel() + waiter.cancel() + with pytest.raises(asyncio.CancelledError): + await creator + with pytest.raises(asyncio.CancelledError): + await waiter + + release_encode.set() + for _ in range(100): + if "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight: + break + await asyncio.sleep(0.01) + gc.collect() + await asyncio.sleep(0) + finally: + release_encode.set() + + asyncio.run(run()) + + assert "ref-a" not in adapter._higgs_audio_v3_ref_code_inflight + assert not loop_errors diff --git a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py index eb00fe784..1e64f7a63 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/higgs_audio_v3.py @@ -4,7 +4,7 @@ import asyncio from collections import OrderedDict -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import torch @@ -62,7 +62,7 @@ class HiggsAudioV3Adapter(ARTTSAdapter): encode_reference_audio, ) - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + wav_list, sr, cache_key = await self._resolve_ref_audio(cast(str, request.ref_audio)) artifact_key = self._get_resolved_ref_audio_artifact_key(cache_key) wav = np.asarray(wav_list, dtype=np.float32) ref_codes_delayed, cache_hit, inflight_wait = await self._resolve_higgs_audio_v3_ref_codes( @@ -109,7 +109,7 @@ class HiggsAudioV3Adapter(ARTTSAdapter): task = self._higgs_audio_v3_ref_code_inflight.get(artifact_key) if task is not None: - return (await task).clone(), False, True + return (await asyncio.shield(task)).clone(), False, True async def _encode_and_cache() -> torch.Tensor: ref_codes_raw = await asyncio.to_thread(encode_reference_audio, wav, sr) @@ -120,12 +120,16 @@ class HiggsAudioV3Adapter(ARTTSAdapter): task = asyncio.create_task(_encode_and_cache()) self._higgs_audio_v3_ref_code_inflight[artifact_key] = task - try: - return (await task).clone(), False, False - finally: - if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is task: + + def _retire(t: asyncio.Task[torch.Tensor]) -> None: + if not t.cancelled(): + t.exception() + if self._higgs_audio_v3_ref_code_inflight.get(artifact_key) is t: self._higgs_audio_v3_ref_code_inflight.pop(artifact_key, None) + task.add_done_callback(_retire) + return (await asyncio.shield(task)).clone(), False, False + def _get_higgs_audio_v3_ref_codes(self, artifact_key: str | None) -> torch.Tensor | None: if not artifact_key: return None From 968f3e379c292d395e600dfe5dfbb1e21f905d90 Mon Sep 17 00:00:00 2001 From: xutianle <24210290017@m.fudan.edu.cn> Date: Wed, 9 Sep 2026 11:33:38 +0800 Subject: [PATCH 61/90] [Bugfix][CosyVoice3] Resolve hash snapshot pipeline (#6896) Signed-off-by: xutianle --- tests/config/test_config_factory.py | 38 +++++++++++++++++++++++++++++ vllm_omni/deploy/cosyvoice3.yaml | 1 + 2 files changed, 39 insertions(+) diff --git a/tests/config/test_config_factory.py b/tests/config/test_config_factory.py index 6cb328bc5..51c7d549e 100644 --- a/tests/config/test_config_factory.py +++ b/tests/config/test_config_factory.py @@ -932,6 +932,44 @@ class TestPipelineRegistration: assert isinstance(omni_config, VllmOmniConfig) assert omni_config.stage_by_id(0).diffusion_config.model == "fake/model" + def test_cosyvoice3_deploy_resolves_hash_snapshot_without_hf_metadata(self, tmp_path): + snapshot = tmp_path / "models--FunAudioLLM--Fun-CosyVoice3-0.5B-2512" / "snapshots" / ("a" * 40) + snapshot.mkdir(parents=True) + (snapshot / "config.json").write_text("{}\n", encoding="utf-8") + model = str(snapshot) + deploy_path = get_deploy_config_path("cosyvoice3.yaml") + + assert StageConfigFactory.try_infer_model_type(model, trust_remote_code=False) is None + assert StageConfigFactory.get_pipeline_config(model, trust_remote_code=False) is None + + omni_config = StageConfigFactory.create_from_model( + model, + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=deploy_path, + ) + legacy_configs, _ = StageConfigFactory.create_legacy_stage_configs_from_model( + model, + trust_remote_code=False, + cli_overrides={}, + deploy_config_path=deploy_path, + ) + + assert omni_config is not None + assert omni_config.pipeline_config.model_type == "cosyvoice3" + assert [ + (stage.stage_id, stage.stage_type, stage.worker_type, stage.model_stage) + for stage in omni_config.stage_configs + ] == [ + (0, StageType.LLM, "ar", "cosyvoice3_talker"), + (1, StageType.LLM, "generation", "cosyvoice3_code2wav"), + ] + assert all(stage.model_config.model == model for stage in omni_config.stage_configs) + assert all(stage.stage_type is not StageType.DIFFUSION for stage in omni_config.stage_configs) + + assert legacy_configs is not None + assert [stage.stage_type for stage in legacy_configs] == [StageType.LLM, StageType.LLM] + def test_pipeline_registration(self, clean_pipeline_registry): """Ensure that we can register and create a custom pipeline config.""" new_model_type = "new_model_type" diff --git a/vllm_omni/deploy/cosyvoice3.yaml b/vllm_omni/deploy/cosyvoice3.yaml index 551b42af6..09fd83290 100644 --- a/vllm_omni/deploy/cosyvoice3.yaml +++ b/vllm_omni/deploy/cosyvoice3.yaml @@ -23,6 +23,7 @@ # On a slower GPU class, lower max_num_seqs (e.g. 4) and/or codec_chunk_frames # first. Raising connector_get_sleep_s also relaxes the consumer's polling # cadence, but trades against TTFP — keep it as a last resort. +pipeline: cosyvoice3 async_chunk: true connectors: connector_of_shared_memory: From 09b720b0d2b3528ba17d70900165e1e6508bfa76 Mon Sep 17 00:00:00 2001 From: wangyu <53896905+yenuo26@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:23:29 +0800 Subject: [PATCH 62/90] [CI] Skip Qwen3-Omni Server VAD multi-turn realtime test (#7279) (#7314) Signed-off-by: wangyu <410167048@qq.com> --- .../entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py index 199bea0ed..8c80b9649 100644 --- a/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py +++ b/tests/entrypoints/openai_api/test_qwen3_omni_realtime_websocket.py @@ -408,6 +408,7 @@ class TestQwen3OmniRealtimeWebSocket: @pytest.mark.omni @hardware_test(res={"cuda": "H100", "rocm": "MI325"}, num_cards=2) @pytest.mark.parametrize("omni_server", realtime_server_vad_server_params, indirect=True) + @pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/7279") def test_server_vad_multi_turn_without_client_commit( self, cached_silero_vad_artifact: str, From e0d08797944277090699da03e29e8a3c0739e0f4 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:48:08 -0700 Subject: [PATCH 63/90] [Bugfix][Magi2] Allow import without an active Triton driver (#7239) Signed-off-by: andyluo7 --- vllm_omni/diffusion/models/magi2/mh_moe.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/vllm_omni/diffusion/models/magi2/mh_moe.py b/vllm_omni/diffusion/models/magi2/mh_moe.py index 6419cfae8..9e1e0788a 100644 --- a/vllm_omni/diffusion/models/magi2/mh_moe.py +++ b/vllm_omni/diffusion/models/magi2/mh_moe.py @@ -21,7 +21,7 @@ from typing import Literal import torch import torch.nn as nn import torch.nn.functional as F -from vllm.triton_utils import tl, triton +from vllm.triton_utils import HAS_TRITON, tl, triton from vllm_omni.platforms import current_omni_platform @@ -133,9 +133,10 @@ def torch_mh_moe_forward( return output -_SWIGLU7_ALPHA = tl.constexpr(1.702) -_SWIGLU7_LIMIT = tl.constexpr(7.0) -_SWIGLU7_BIAS = tl.constexpr(1.0) +# vLLM's no-Triton placeholder exposes ``tl.constexpr`` as ``None``. +_SWIGLU7_ALPHA = tl.constexpr(1.702) if HAS_TRITON else 1.702 +_SWIGLU7_LIMIT = tl.constexpr(7.0) if HAS_TRITON else 7.0 +_SWIGLU7_BIAS = tl.constexpr(1.0) if HAS_TRITON else 1.0 @triton.jit @@ -198,7 +199,7 @@ def _mh_moe_kernel( block_t: tl.constexpr, block_dh: tl.constexpr, block_de: tl.constexpr, - acc_dtype: tl.constexpr = tl.float32, + acc_dtype: tl.constexpr, deterministic: tl.constexpr = False, ): tile_id = tl.program_id(0) From 5b002727b00421f2967f996cfd788950d024f932 Mon Sep 17 00:00:00 2001 From: NATURE Date: Wed, 9 Sep 2026 13:49:43 +0800 Subject: [PATCH 64/90] [Core] Split Omni connector model runner mixin (#6903) Signed-off-by: natureofnature --- tests/worker/test_omni_connector_mixin.py | 66 +- tests/worker/test_payload_span.py | 166 -- .../omni_connectors/model_runner/__init__.py | 3 + .../omni_connector_payload_transport.py | 1322 ++++++++++ .../model_runner/omni_connector_runtime.py | 1061 ++++++++ .../omni_connector_model_runner_mixin.py | 2350 +---------------- vllm_omni/worker/payload_span.py | 64 - 7 files changed, 2463 insertions(+), 2569 deletions(-) delete mode 100644 tests/worker/test_payload_span.py create mode 100644 vllm_omni/distributed/omni_connectors/model_runner/__init__.py create mode 100644 vllm_omni/distributed/omni_connectors/model_runner/omni_connector_payload_transport.py create mode 100644 vllm_omni/distributed/omni_connectors/model_runner/omni_connector_runtime.py delete mode 100644 vllm_omni/worker/payload_span.py diff --git a/tests/worker/test_omni_connector_mixin.py b/tests/worker/test_omni_connector_mixin.py index af472fd95..8366e177a 100644 --- a/tests/worker/test_omni_connector_mixin.py +++ b/tests/worker/test_omni_connector_mixin.py @@ -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 OmniConnectorModelRunnerMixin. These tests use a mock connector (in-memory dict store) and do not require @@ -376,6 +376,19 @@ class TestFinishedLoadReqsDrain(unittest.TestCase): class TestLoadCustomFuncSelection(unittest.TestCase): + def test_uses_validator_override_from_public_mixin(self): + config = SimpleNamespace( + async_chunk=True, + custom_process_next_stage_input_func=f"{__name__}._make_request", + ) + + with patch.object(MixinHost, "_is_connector_payload_builder", return_value=False) as validator: + selected_path, func = MixinHost._load_custom_func(config) + + validator.assert_called_once_with(_make_request) + assert selected_path is None + assert func is None + def test_skips_non_payload_stage_input_processors_for_full_payload_mode(self): incompatible_paths = [ "vllm_omni.model_executor.stage_input_processors.mimo_audio.llm2code2wav", @@ -861,7 +874,7 @@ class TestLocalPayloadCacheLifecycle(unittest.TestCase): host._omni_connector.get.return_value = connector_result tp_group = _FakeTPGroup(world_size=2, rank_in_group=0) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): made_progress = host._poll_single_request("r1") self.assertTrue(made_progress) @@ -882,7 +895,7 @@ class TestLocalPayloadCacheLifecycle(unittest.TestCase): host._get_req_chunk["r1"] = 0 tp_group = _FakeTPGroup(world_size=2, rank_in_group=1) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): made_progress = host._poll_single_request("r1") self.assertFalse(made_progress) @@ -900,7 +913,7 @@ class TestLocalPayloadCacheLifecycle(unittest.TestCase): payload = {"tok": [10], "finished": torch.tensor(True)} tp_group = _FakeTPGroup(world_size=2, rank_in_group=1, follower_result={"r1": payload}) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): results = host.recv_full_payload_inputs(scheduler_output=None) self.assertEqual(results, {"r1": payload}) @@ -936,7 +949,7 @@ class TestTPAsyncChunkFanout(unittest.TestCase): host._omni_connector.get.return_value = (payload, 123) tp_group = _FakeTPGroup(world_size=2, rank_in_group=0) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): made_progress = host._poll_single_request("r1") self.assertTrue(made_progress) @@ -951,7 +964,7 @@ class TestTPAsyncChunkFanout(unittest.TestCase): host = self._make_host(rank=1) tp_group = _FakeTPGroup(world_size=2, rank_in_group=1) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): made_progress = host._poll_single_request("r1") self.assertFalse(made_progress) @@ -976,7 +989,7 @@ class TestTPAsyncChunkFanout(unittest.TestCase): } tp_group = _FakeTPGroup(world_size=2, rank_in_group=1, follower_result=packet) - with patch("vllm_omni.worker.omni_connector_model_runner_mixin.get_tp_group", return_value=tp_group): + with patch.object(host, "_get_local_tp_group", return_value=tp_group): output = host.get_omni_connector_output() self.assertEqual(output.chunk_ready_req_ids, {"r1"}) @@ -1049,6 +1062,45 @@ class TestKVTransferLifecycle(unittest.TestCase): class TestAsyncPayloadLifecycle(unittest.TestCase): """Regression tests for async payload delivery lifecycle.""" + def test_accumulate_payload_concatenates_chunks(self): + host = MixinHost() + host._send_side_request_payload = {} + first = host._accumulate_payload( + "r1", + { + "embed": {"decode": torch.tensor([[1.0, 2.0]])}, + "ids": {"output": [1]}, + "meta": {"finished": False}, + }, + ) + merged = host._accumulate_payload( + "r1", + { + "embed": {"decode": torch.tensor([[3.0, 4.0], [5.0, 6.0]])}, + "ids": {"output": [2, 3]}, + "meta": {"finished": True}, + }, + ) + torch.testing.assert_close(merged["embed"]["decode"], torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])) + self.assertEqual(merged["ids"]["output"], [1, 2, 3]) + self.assertIs(merged["meta"]["finished"], True) + self.assertEqual(first["embed"]["decode"].shape, (1, 2)) + self.assertEqual(first["ids"]["output"], [1]) + self.assertIs(first["meta"]["finished"], False) + + def test_accumulate_payload_replaces_override_keys(self): + host = MixinHost() + host._send_side_request_payload = {} + host._accumulate_payload("r1", {"embed": {"decode": torch.ones(2, 2)}, "ids": {"output": [1, 2]}}) + payload = { + "embed": {"decode": torch.zeros(1, 2)}, + "ids": {"output": [3]}, + "meta": {"override_keys": [["embed", "decode"], ["ids", "output"]]}, + } + merged = host._accumulate_payload("r1", payload) + torch.testing.assert_close(merged["embed"]["decode"], payload["embed"]["decode"]) + self.assertEqual(merged["ids"]["output"], [3]) + def test_send_side_request_payload_not_cleared_before_payload_is_consumable(self): host = MixinHost() host.init_omni_connectors( diff --git a/tests/worker/test_payload_span.py b/tests/worker/test_payload_span.py deleted file mode 100644 index 5eb4d8319..000000000 --- a/tests/worker/test_payload_span.py +++ /dev/null @@ -1,166 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Characterization tests for ``vllm_omni.worker.payload_span``. - -These pin the CURRENT behaviour of the thinker-decode span helpers so the later -worker refactors that touch span -plumbing have a regression net. Pure CPU — no model, no CUDA. -""" - -import pytest -import torch - -from vllm_omni.worker.payload_span import ( - get_tensor_span, - get_tensor_span_row, - merge_tensor_spans, -) - -pytestmark = [pytest.mark.core_model, pytest.mark.cpu] - - -def _rows(n: int, *, start_val: int = 0, hidden: int = 1) -> torch.Tensor: - """A ``[n, hidden]`` tensor whose first column counts up from ``start_val``. - - ``[n, hidden]`` mirrors the connector async-chunk shape (tokens x hidden). - """ - base = torch.arange(start_val, start_val + n, dtype=torch.float32).unsqueeze(1) - return base.repeat(1, hidden) - - -# --------------------------------------------------------------------------- # -# get_tensor_span # -# --------------------------------------------------------------------------- # -def test_get_tensor_span_valid_returns_triplet(): - tensor = _rows(3, hidden=4) - payload = {"emb": tensor, "start": 5, "end": 8} - - span = get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") - - assert span is not None - got_tensor, start, end = span - assert got_tensor is tensor - assert (start, end) == (5, 8) - - -def test_get_tensor_span_non_tensor_returns_none(): - payload = {"emb": None, "start": 0, "end": 0} - assert get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") is None - - -def test_get_tensor_span_non_int_bounds_returns_none(): - tensor = _rows(2) - # start is a float, not an int -> rejected. - payload = {"emb": tensor, "start": 0.0, "end": 2} - assert get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") is None - - -def test_get_tensor_span_negative_start_returns_none(): - tensor = _rows(1) - payload = {"emb": tensor, "start": -1, "end": 0} - assert get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") is None - - -def test_get_tensor_span_end_before_start_returns_none(): - tensor = _rows(2) - payload = {"emb": tensor, "start": 5, "end": 3} - assert get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") is None - - -def test_get_tensor_span_length_mismatch_returns_none(): - tensor = _rows(3) # tensor.shape[0] == 3 - payload = {"emb": tensor, "start": 0, "end": 2} # end - start == 2 - assert get_tensor_span(payload, tensor_key="emb", start_key="start", end_key="end") is None - - -# --------------------------------------------------------------------------- # -# merge_tensor_spans # -# --------------------------------------------------------------------------- # -def test_merge_none_operand_returns_none(): - span = (_rows(2), 0, 2) - assert merge_tensor_spans(None, span) is None - assert merge_tensor_spans(span, None) is None - - -def test_merge_adjacent_spans_concatenates(): - existing = (_rows(3, start_val=0), 0, 3) - incoming = (_rows(2, start_val=3), 3, 5) - - merged = merge_tensor_spans(existing, incoming) - - assert merged is not None - tensor, start, end = merged - assert (start, end) == (0, 5) - assert tensor.shape[0] == 5 - assert tensor[:, 0].tolist() == [0.0, 1.0, 2.0, 3.0, 4.0] - - -def test_merge_overlapping_spans_trims_incoming(): - # existing covers [0, 3); incoming covers [2, 5) -> 1 row of overlap. - existing = (_rows(3, start_val=0), 0, 3) - incoming = (_rows(3, start_val=100), 2, 5) - - merged = merge_tensor_spans(existing, incoming) - - assert merged is not None - tensor, start, end = merged - assert (start, end) == (0, 5) - # First 3 rows from existing, then incoming's tail (overlap=1 row trimmed). - assert tensor[:, 0].tolist() == [0.0, 1.0, 2.0, 101.0, 102.0] - - -def test_merge_fully_overlapping_incoming_keeps_existing(): - # incoming [1, 3) is fully covered by existing [0, 3): overlap >= incoming len. - existing = (_rows(3, start_val=0), 0, 3) - incoming = (_rows(2, start_val=100), 1, 3) - - merged = merge_tensor_spans(existing, incoming) - - assert merged is not None - tensor, start, end = merged - assert (start, end) == (0, 3) - assert tensor is existing[0] # returned unchanged - - -def test_merge_non_contiguous_spans_returns_none(): - # gap between existing end (3) and incoming start (5). - existing = (_rows(3, start_val=0), 0, 3) - incoming = (_rows(2, start_val=100), 5, 7) - - assert merge_tensor_spans(existing, incoming) is None - - -def test_merge_casts_incoming_to_existing_dtype_and_device(): - existing = (_rows(2, start_val=0).to(torch.float32), 0, 2) - incoming = (torch.arange(2, 4, dtype=torch.int64).unsqueeze(1), 2, 4) - - merged = merge_tensor_spans(existing, incoming) - - assert merged is not None - tensor, _, _ = merged - assert tensor.dtype == torch.float32 - assert tensor.device == existing[0].device - - -# --------------------------------------------------------------------------- # -# get_tensor_span_row # -# --------------------------------------------------------------------------- # -def test_get_tensor_span_row_in_range(): - span = (_rows(3, start_val=10), 5, 8) # rows map to indices 5, 6, 7 - row = get_tensor_span_row(span, 6) - assert row is not None - assert row[0].item() == 11.0 # second row (start_val 10 + 1) - - -def test_get_tensor_span_row_none_span_returns_none(): - assert get_tensor_span_row(None, 0) is None - - -def test_get_tensor_span_row_below_start_returns_none(): - span = (_rows(3), 5, 8) - assert get_tensor_span_row(span, 4) is None - - -def test_get_tensor_span_row_at_or_above_end_returns_none(): - span = (_rows(3), 5, 8) - assert get_tensor_span_row(span, 8) is None diff --git a/vllm_omni/distributed/omni_connectors/model_runner/__init__.py b/vllm_omni/distributed/omni_connectors/model_runner/__init__.py new file mode 100644 index 000000000..5cfa00311 --- /dev/null +++ b/vllm_omni/distributed/omni_connectors/model_runner/__init__.py @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Internal model-runner components for Omni connector transport.""" diff --git a/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_payload_transport.py b/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_payload_transport.py new file mode 100644 index 000000000..25515cfaf --- /dev/null +++ b/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_payload_transport.py @@ -0,0 +1,1322 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Omni connector payload transport for model runners.""" + +from __future__ import annotations + +import importlib +import inspect +from collections import deque +from typing import TYPE_CHECKING, Any + +import torch +from vllm.distributed.parallel_state import get_tp_group + +from vllm_omni.data_entry_keys import OmniPayload +from vllm_omni.distributed.omni_connectors.model_runner.omni_connector_runtime import ( + _OmniConnectorRuntimeMixin, + logger, + should_accumulate_full_payload_output, +) +from vllm_omni.outputs import OmniConnectorOutput + +if TYPE_CHECKING: + from vllm_omni.distributed.omni_connectors.connectors.base import ( + OmniConnectorBase, + ) + + +class _OmniConnectorPayloadTransportMixin(_OmniConnectorRuntimeMixin): + """Own payload caching, full/chunk transport, and connector I/O.""" + + # ------------------------------------------------------------------ # + # Local payload cache (RFC §2.4 – Model Runner ownership) + # ------------------------------------------------------------------ # + + def put_local_stage_payload(self, req_id: str, payload: OmniPayload) -> None: + """Store a full stage payload in the local cache.""" + self._local_stage_payload_cache[req_id] = payload + + def get_local_stage_payload(self, req_id: str) -> OmniPayload | None: + """Read a stage payload without removing it.""" + return self._local_stage_payload_cache.get(req_id) + + def pop_local_stage_payload(self, req_id: str) -> OmniPayload | None: + """Remove and return a stage payload (consume after use).""" + return self._local_stage_payload_cache.pop(req_id, None) + + def put_local_request_metadata(self, req_id: str, metadata: dict[str, Any]) -> None: + """Store lightweight scheduling metadata for a request.""" + self._local_request_metadata[req_id] = metadata + + def get_local_request_metadata(self, req_id: str) -> dict[str, Any] | None: + """Retrieve scheduling metadata for a request.""" + return self._local_request_metadata.get(req_id) + + # ------------------------------------------------------------------ # + # Scheduling metadata extraction + # ------------------------------------------------------------------ # + + @classmethod + def _extract_scheduling_metadata(cls, payload: OmniPayload) -> dict[str, Any]: + """Extract only the fields the scheduler needs from a full payload.""" + extracted: dict[str, Any] = {} + meta = payload.get("meta") if isinstance(payload, dict) else None + meta = meta if isinstance(meta, dict) else {} + + if "next_stage_prompt_len" in meta: + extracted["next_stage_prompt_len"] = meta["next_stage_prompt_len"] + elif "next_stage_prompt_len" in payload: + logger.warning_once( + "legacy flat 'next_stage_prompt_len' key in payload; expected 'meta.next_stage_prompt_len'" + ) + extracted["next_stage_prompt_len"] = payload["next_stage_prompt_len"] + + audio_codes = cls._payload_audio_codes(payload) + if audio_codes is not None: + extracted["code_predictor_codes"] = audio_codes + + if "left_context_size" in meta: + extracted["left_context_size"] = meta["left_context_size"] + elif "left_context_size" in payload: + logger.warning_once("legacy flat 'left_context_size' key in payload; expected 'meta.left_context_size'") + + return extracted + + _NON_CONSUMABLE_PAYLOAD_KEYS: set[tuple[str, str]] = { + ("meta", "finished"), + ("meta", "override_keys"), + ("meta", "next_stage_prompt_len"), + ("meta", "left_context_size"), + ("ids", "output"), + ("embed", "decode_token_start"), + ("embed", "decode_token_end"), + } + + @staticmethod + def _payload_value_has_content(value: Any) -> bool: + if value is None: + return False + if isinstance(value, torch.Tensor): + return value.numel() > 0 + if isinstance(value, (list, tuple, dict, set)): + return len(value) > 0 + return True + + @staticmethod + def _payload_finished(payload: Any) -> bool: + if not isinstance(payload, dict): + return False + if "finished" in payload: + logger.warning_once("legacy flat 'finished' key in payload; expected 'meta.finished'") + meta = payload.get("meta") + if not isinstance(meta, dict) or "finished" not in meta: + return False + flag = meta["finished"] + if isinstance(flag, torch.Tensor): + return flag.numel() == 1 and bool(flag.item()) + return bool(flag) + + @staticmethod + def _payload_audio_codes(payload: Any) -> Any: + if not isinstance(payload, dict): + return None + if "code_predictor_codes" in payload: + logger.warning_once("legacy flat 'code_predictor_codes' key in payload; expected 'codes.audio'") + codes = payload.get("codes") + if isinstance(codes, dict): + return codes.get("audio") + return None + + @classmethod + def _payload_is_consumable(cls, payload: OmniPayload | None) -> bool: + """Return True when an async payload can drive a real forward step. + + Metadata-only wake-ups should not transition WAITING_FOR_CHUNK requests + back to schedulable state. In particular, a widened token horizon without + any newly visible thinker decode embeds should not force a placeholder-only + talker decode step. + """ + if not isinstance(payload, dict) or not payload: + return False + + embed = payload.get("embed") + if isinstance(embed, dict): + decode_embeddings = embed.get("decode") + if isinstance(decode_embeddings, torch.Tensor): + if decode_embeddings.ndim == 0: + return True + return decode_embeddings.numel() > 0 and decode_embeddings.shape[0] > 0 + + audio_codes = cls._payload_audio_codes(payload) + if audio_codes is not None: + if isinstance(audio_codes, torch.Tensor): + return audio_codes.numel() > 0 + if hasattr(audio_codes, "__len__"): + return len(audio_codes) > 0 + return True + + for key, value in payload.items(): + if isinstance(value, dict): + for sk, sv in value.items(): + if (key, sk) in cls._NON_CONSUMABLE_PAYLOAD_KEYS: + continue + if cls._payload_value_has_content(sv): + return True + continue + if cls._payload_value_has_content(value): + return True + return False + + @staticmethod + def _get_local_tp_group() -> Any | None: + """Return the local TP group when tensor parallelism is initialized.""" + try: + return get_tp_group() + except Exception: + return None + + def _recv_ordinary_stage_result( + self, + connector: OmniConnectorBase, + from_stage: str, + to_stage: str, + connector_get_key: str, + ) -> Any: + """Receive one ordinary non-KV stage payload on the local leader rank only.""" + tp_group = self._get_local_tp_group() + if tp_group is None or getattr(tp_group, "world_size", 1) <= 1: + return connector.get(from_stage, to_stage, connector_get_key) + if not self.is_data_transfer_rank(): + return None + return connector.get(from_stage, to_stage, connector_get_key) + + def _recv_full_payload_result( + self, + connector: OmniConnectorBase, + from_stage: str, + to_stage: str, + connector_get_key: str, + ) -> Any: + """Receive one full-payload transfer on the local leader rank only.""" + return self._recv_ordinary_stage_result( + connector, + from_stage, + to_stage, + connector_get_key, + ) + + def _recv_async_chunk_result( + self, + connector: OmniConnectorBase, + from_stage: str, + to_stage: str, + connector_get_key: str, + ) -> Any: + """Receive one ordinary async chunk on the local leader rank only.""" + return self._recv_ordinary_stage_result( + connector, + from_stage, + to_stage, + connector_get_key, + ) + + @staticmethod + def _snapshot_payload(payload: Any) -> Any: + if isinstance(payload, dict): + return dict(payload) + return payload + + def _broadcast_tp_payload_packet(self, packet: Any) -> Any: + """Broadcast one ordinary payload packet from TP rank 0 when TP is active.""" + tp_group = self._get_local_tp_group() + if tp_group is None or getattr(tp_group, "world_size", 1) <= 1: + return packet + leader_packet = packet if self.is_data_transfer_rank() else None + return tp_group.broadcast_object(leader_packet, src=0) + + def _apply_staged_payloads_locked(self, staged_payloads: dict[str, Any]) -> None: + for req_id, payload in staged_payloads.items(): + self._local_stage_payload_cache[req_id] = self._snapshot_payload(payload) + + def _collect_full_payload_results_locked(self) -> dict[str, Any] | None: + if not self._full_payload_pending_broadcast_req_ids: + return None + results: dict[str, Any] = {} + missing_req_ids: list[str] = [] + for req_id in tuple(self._full_payload_pending_broadcast_req_ids): + payload = self._local_stage_payload_cache.get(req_id) + if payload is None: + missing_req_ids.append(req_id) + continue + results[req_id] = self._snapshot_payload(payload) + self._full_payload_pending_broadcast_req_ids.discard(req_id) + if missing_req_ids: + logger.warning( + "[Stage-%s] _collect_full_payload_results_locked: " + "pending full-payload reqs missing from local cache: %s", + self._stage_id, + missing_req_ids, + ) + return results or None + + def _collect_async_chunk_fanout_packet_locked(self) -> dict[str, Any] | None: + payload_req_ids = set(self._async_chunk_updated_req_ids) + payload_req_ids.update(self._finished_load_reqs) + payload_req_ids.update(self._chunk_finished_req_ids) + payload_req_ids.update(self._local_request_metadata) + if not ( + payload_req_ids or self._finished_load_reqs or self._chunk_finished_req_ids or self._local_request_metadata + ): + return None + + staged_payloads = { + req_id: self._snapshot_payload(self._local_stage_payload_cache[req_id]) + for req_id in payload_req_ids + if req_id in self._local_stage_payload_cache + } + packet = { + "staged_payloads": staged_payloads, + "request_metadata": dict(self._local_request_metadata), + "newly_finished": set(self._finished_load_reqs), + "chunk_finished": set(self._chunk_finished_req_ids), + } + + self._async_chunk_updated_req_ids.clear() + self._finished_load_reqs.clear() + self._chunk_finished_req_ids.clear() + self._local_request_metadata.clear() + + for req_id in packet["chunk_finished"]: + if req_id not in self._local_stage_payload_cache: + continue + ext_req_id = self._request_ids_mapping.get(req_id, req_id) + self._send_side_request_payload.pop(ext_req_id, None) + if ext_req_id != req_id: + self._send_side_request_payload.pop(req_id, None) + + return packet + + def _apply_async_chunk_fanout_packet(self, packet: dict[str, Any]) -> None: + staged_payloads = packet.get("staged_payloads", {}) + chunk_finished = set(packet.get("chunk_finished", ())) + with self._lock: + self._apply_staged_payloads_locked(staged_payloads) + for req_id in chunk_finished: + self._pending_load_reqs.pop(req_id, None) + self._chunk_stream_completed.add(req_id) + + # Output aggregation + # ------------------------------------------------------------------ # + + def get_omni_connector_output(self) -> OmniConnectorOutput: + """Collect and reset transfer results for this execute_model cycle. + + ``request_metadata`` carries only lightweight scheduling metadata. + Full payloads remain owned by the Model Runner local cache for all + paths. + """ + if not hasattr(self, "_lock"): + return OmniConnectorOutput() + + tp_group = self._get_local_tp_group() + if self._async_chunk and tp_group is not None and getattr(tp_group, "world_size", 1) > 1: + if self.is_data_transfer_rank(): + with self._lock: + fanout_packet = self._collect_async_chunk_fanout_packet_locked() + else: + fanout_packet = None + fanout_packet = self._broadcast_tp_payload_packet(fanout_packet) + if fanout_packet is None: + newly_finished = set() + chunk_finished = set() + request_metadata = {} + else: + if not self.is_data_transfer_rank(): + self._apply_async_chunk_fanout_packet(fanout_packet) + newly_finished = set(fanout_packet["newly_finished"]) + chunk_finished = set(fanout_packet["chunk_finished"]) + request_metadata = dict(fanout_packet["request_metadata"]) + else: + with self._lock: + newly_finished = set(self._finished_load_reqs) + self._finished_load_reqs.clear() + chunk_finished = set(self._chunk_finished_req_ids) + self._chunk_finished_req_ids.clear() + request_metadata = dict(self._local_request_metadata) + self._local_request_metadata.clear() + # _send_side_request_payload is the async accumulation buffer for + # future recv chunks. Clearing it on every consumable wake-up drops + # intermediate + # thinker decode spans before the model side can consume them. + # Only terminal chunk_finished requests may release that buffer. + for req_id in chunk_finished: + if req_id not in self._local_stage_payload_cache: + continue + ext_req_id = self._request_ids_mapping.get(req_id, req_id) + self._send_side_request_payload.pop(ext_req_id, None) + if ext_req_id != req_id: + self._send_side_request_payload.pop(req_id, None) + self._chunk_ready_req_ids.update(newly_finished) + + output = OmniConnectorOutput( + chunk_ready_req_ids=set(self._chunk_ready_req_ids), + chunk_finished_req_ids=chunk_finished, + request_metadata=request_metadata, + kv_sent_req_ids=list(self._kv_sent_req_ids), + stage_recv_req_ids=set(self._stage_recv_req_ids), + has_pending_kv_work=self.has_pending_kv_work(), + ) + if output.stage_recv_req_ids or chunk_finished or newly_finished: + logger.debug( + "[Stage-%s] get_omni_connector_output: stage_recv=%s, chunk_finished=%s, chunk_ready=%s", + self._stage_id, + output.stage_recv_req_ids, + chunk_finished, + output.chunk_ready_req_ids, + ) + self._chunk_ready_req_ids.clear() + self._kv_sent_req_ids.clear() + self._stage_recv_req_ids.clear() + return output + + @staticmethod + def _connector_output_has_signals(output: OmniConnectorOutput) -> bool: + return bool( + output.chunk_ready_req_ids + or output.chunk_finished_req_ids + or output.request_metadata + or output.kv_sent_req_ids + or output.stage_recv_req_ids + or output.has_pending_kv_work + ) + + def attach_omni_connector_output(self, result: Any | None) -> Any: + omni_output = self.get_omni_connector_output() + if not self._connector_output_has_signals(omni_output): + return result + + from copy import copy + + from vllm.v1.outputs import EMPTY_MODEL_RUNNER_OUTPUT + + wrapped = copy(result if result is not None else EMPTY_MODEL_RUNNER_OUTPUT) + wrapped.omni_connector_output = omni_output + return wrapped + + # ------------------------------------------------------------------ # + # Properties for compatibility with custom_process funcs that access + # transfer_manager.put_req_chunk / request_payload / code_prompt_token_ids + # ------------------------------------------------------------------ # + + @property + def put_req_chunk(self) -> dict[str, int]: + return self._put_req_chunk + + @property + def ramp_chunk_count(self) -> dict[str, int]: + return self._ramp_chunk_count + + @property + def request_payload(self) -> dict[str, dict[str, Any]]: + return self._send_side_request_payload + + @request_payload.setter + def request_payload(self, value: dict[str, dict[str, Any]]) -> None: + self._send_side_request_payload = value + + @property + def code_prompt_token_ids(self) -> dict[str, list[list[int]]]: + return self._code_prompt_token_ids + + @property + def connector(self) -> Any | None: + return self._omni_connector + + # ------------------------------------------------------------------ # + # full_payload_mode (recv_full_payload_inputs / send_full_payload_outputs) + # ------------------------------------------------------------------ # + + def recv_full_payload_inputs(self, scheduler_output: Any) -> dict[str, Any] | None: + """Check for incoming full_payload_mode stage inputs (non-blocking). + + Returns a dict mapping ``request_id -> engine_inputs`` for data + that has arrived, or ``None`` if nothing is ready. Stores full + payloads in the local cache and extracts scheduling metadata. + """ + # Fast path: when TP is trivial (no peer ranks waiting on a broadcast) + # and the bg recv thread has not staged anything, skip the lock + TP + # broadcast cycle entirely. _broadcast_tp_payload_packet already + # returns its input unchanged under the same world_size<=1 condition, + # so the original code path was a no-op here on every empty step. + tp_group = self._get_local_tp_group() + if ( + tp_group is None or getattr(tp_group, "world_size", 1) <= 1 + ) and not self._full_payload_pending_broadcast_req_ids: + return None + with self._lock: + results = self._collect_full_payload_results_locked() if self.is_data_transfer_rank() else None + results = self._broadcast_tp_payload_packet(results) + if not results: + return None + with self._lock: + self._stage_recv_req_ids.update(results.keys()) + for req_id in results: + self._pending_load_reqs.pop(req_id, None) + self._apply_staged_payloads_locked(results) + for req_id, payload in results.items(): + self._local_request_metadata[req_id] = self._extract_scheduling_metadata(payload) + logger.debug( + "[Stage-%s] recv_full_payload_inputs: consumed %s reqs: %s, stage_recv_req_ids now=%s", + self._stage_id, + len(results), + list(results.keys()), + self._stage_recv_req_ids, + ) + return results + + def _get_model_config(self) -> Any: + model_config = getattr(self, "model_config", None) + if model_config is not None: + return model_config + return getattr(getattr(self, "vllm_config", None), "model_config", None) + + def _should_accumulate_full_payload_output(self) -> bool: + """Gate send-side full-payload output accumulation only. + + Cached per instance: the result depends only on model_config / + _custom_process_func, both of which are set at init time. Avoid + the per-step dynamic import inside the model decode loop. + """ + if getattr(self, "_omni_connector", None) is None: + # No connector at all: send_full_payload_outputs would no-op. + # Skip the per-step accumulator+build that would otherwise be + # silently discarded. Defends against a terminal stage whose + # custom_process_input_func has a *_full_payload derivative in + # the same module (e.g. dynin stage 2 token2image_to_token2audio + # in pipelines that don't configure any connector at all). + # + # Known limitation: a *terminal-consumer* stage that has a + # connector configured for receiving upstream input is NOT + # caught here -- ``_omni_connector`` is non-None for it, and + # ``_load_custom_func`` may still resolve a ``*_full_payload`` + # derivative from this stage's ``custom_process_input_func``. + # In that case the accumulator builds payloads that + # ``send_full_payload_outputs`` later drops via its own + # connector-side checks (wasted CPU, not a functional bug). + # A topology-aware gate (explicit producer field or pipeline + # is_terminal info) would close the gap; that change is out + # of scope for this PR. + self._should_accumulate_full_payload_output_cached = False + return False + cached = getattr(self, "_should_accumulate_full_payload_output_cached", None) + if cached is not None: + return cached + model_config = self._get_model_config() + if model_config is None: + self._should_accumulate_full_payload_output_cached = False + return False + result = should_accumulate_full_payload_output( + model_config, + getattr(self, "_custom_process_func", None), + ) + self._should_accumulate_full_payload_output_cached = result + return result + + @staticmethod + def _new_full_payload_accumulator(output: dict[str, Any]): + chunks: dict[str, list[torch.Tensor]] = {} + latest: dict[str, Any] = {} + rows: dict[str, int] = {} + for k, v in output.items(): + if isinstance(v, torch.Tensor) and v.dim() >= 2: + chunks[k] = [v] + rows[k] = int(v.shape[0]) + else: + latest[k] = v + return chunks, latest, rows + + @staticmethod + def _materialize_full_payload_entry(entry): + if len(entry) == 2: + return entry + chunks, latest, _rows, request = entry + output = dict(latest) + for k, tensors in chunks.items(): + if tensors: + output[k] = tensors[0] if len(tensors) == 1 else torch.cat(tensors, dim=0) + return output, request + + def _resolve_full_payload_replace_keys(self) -> frozenset: + """Per-model REPLACE-key set for the full-payload accumulator. + + Looked up from the stage-input-processor module that ships the model's sync builder + (`model_config.custom_process_input_func.__module__`). The module + declares ``_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str]``; if absent, + returns the empty set. + + Cached per instance. Keys in this set use REPLACE semantics in the + accumulator (subsequent emissions discard prior chunks) instead of + the default CONCAT semantics. Use for tensors that carry the full + result so far rather than per-step deltas (e.g. ``model_outputs``). + """ + cached = getattr(self, "_full_payload_replace_keys_cached", None) + if cached is not None: + return cached + proc = getattr(self, "_custom_process_func", None) + if proc is None: + self._full_payload_replace_keys_cached = frozenset() + return self._full_payload_replace_keys_cached + module_name = getattr(proc, "__module__", None) + if module_name is None: + self._full_payload_replace_keys_cached = frozenset() + return self._full_payload_replace_keys_cached + try: + import sys as _sys + + mod = _sys.modules.get(module_name) or importlib.import_module(module_name) + keys = getattr(mod, "_FULL_PAYLOAD_REPLACE_KEYS", frozenset()) + except ImportError: + logger.debug( + "Could not import stage input processor module %s while resolving " + "_FULL_PAYLOAD_REPLACE_KEYS; using CONCAT semantics for all keys.", + module_name, + exc_info=True, + ) + keys = frozenset() + if not isinstance(keys, (frozenset, set)): + logger.debug( + "Ignoring non-set _FULL_PAYLOAD_REPLACE_KEYS from %s: %s", + module_name, + type(keys).__name__, + ) + keys = frozenset() + self._full_payload_replace_keys_cached = frozenset(keys) + logger.debug( + "Resolved _FULL_PAYLOAD_REPLACE_KEYS for %s: %s", + module_name, + sorted(self._full_payload_replace_keys_cached), + ) + return self._full_payload_replace_keys_cached + + def accumulate_full_payload_output( + self, + req_id: str, + pooler_output: Any, + request: Any, + ) -> None: + """Accumulate pooler_output for a request across steps (full_payload_mode). + + Per-token tensors (2-D+, matching trailing dims) are concatenated + along dim-0. Scalar / global tensors (1-D or 0-D) are replaced + with the latest value. + + Note: codec rows are NOT filtered for zero placeholders here. The + downstream consumer ``_extract_qwen3_full_payload_codec_rows`` crops + codec rows using ``output_token_ids`` as the authoritative source, + which makes any sender-side zero filtering redundant. Skipping the + sender-side ``t.any()`` scan also avoids a per-tensor GPU->CPU device + sync that stalled the decode pipeline. + + The data is actually sent when ``flush_full_payload_outputs`` is called + with the finished request IDs from the next scheduler cycle. + """ + replace_keys = self._resolve_full_payload_replace_keys() + existing = self._pending_full_payload_send.get(req_id) + + if existing is None: + chunks, latest, rows = self._new_full_payload_accumulator(pooler_output) + self._pending_full_payload_send[req_id] = (chunks, latest, rows, request) + return + + if len(existing) == 2: + chunks, latest, rows = self._new_full_payload_accumulator(existing[0]) + else: + chunks, latest, rows, _ = existing + + for k, v in pooler_output.items(): + if v is None: + continue + if k in replace_keys: + # Explicit REPLACE semantics: the new value supersedes any + # prior chunks (e.g. `model_outputs` carries the full result + # so far, not an appendable per-step delta). + latest.pop(k, None) + if isinstance(v, torch.Tensor) and v.dim() >= 2: + chunks[k] = [v] + rows[k] = int(v.shape[0]) + else: + chunks.pop(k, None) + rows.pop(k, None) + latest[k] = v + continue + if isinstance(v, torch.Tensor) and v.dim() >= 2: + if k in chunks and chunks[k] and v.shape[1:] == chunks[k][0].shape[1:]: + chunks[k].append(v) + rows[k] += int(v.shape[0]) + else: + latest.pop(k, None) + chunks[k] = [v] + rows[k] = int(v.shape[0]) + else: + chunks.pop(k, None) + rows.pop(k, None) + latest[k] = v + + self._pending_full_payload_send[req_id] = (chunks, latest, rows, request) + + def flush_full_payload_outputs(self, finished_req_ids: set[str]) -> None: + """Send accumulated full_payload outputs for requests that just finished.""" + pending_req_ids = set(self._pending_full_payload_send.keys()) + if not (finished_req_ids & pending_req_ids): + return + + logger.debug( + "[Stage-%s] flush_full_payload_outputs: finished_req_ids=%s, pending=%s", + self._stage_id, + finished_req_ids, + list(self._pending_full_payload_send.keys()), + ) + to_send: dict[str, tuple[Any, Any]] = {} + for req_id in finished_req_ids: + entry = self._pending_full_payload_send.pop(req_id, None) + if entry is not None: + to_send[req_id] = self._materialize_full_payload_entry(entry) + logger.debug("[Stage-%s] flush_full_payload_outputs: to_send=%s", self._stage_id, list(to_send.keys())) + if to_send: + self.send_full_payload_outputs(scheduler_output=None, outputs=to_send) + + def send_full_payload_outputs( + self, + scheduler_output: Any, + outputs: dict[str, tuple[Any, Any] | Any], + ) -> list[str]: + """Send full_payload stage outputs to the next stage via connector. + + Args: + outputs: Mapping of ``req_id`` to either a + ``(pooling_output, request)`` tuple (preferred) or a raw + payload dict. When a tuple is supplied the request object + is forwarded to ``custom_process_stage_input_func``. + + Returns list of request IDs successfully enqueued. + """ + if self._omni_connector is None: + logger.debug("[Stage-%s] send_full_payload_outputs: connector is None, skip", self._stage_id) + return [] + if not self.is_data_transfer_rank(): + logger.debug( + "[Stage-%s] send_full_payload_outputs: not data_transfer_rank (rank=%s), skip", + self._stage_id, + self._local_rank, + ) + return list(outputs.keys()) + sent_ids: list[str] = [] + next_stage_id = self._next_stage_id + for req_id, value in outputs.items(): + if isinstance(value, tuple) and len(value) == 2: + raw_output, request = value + else: + raw_output, request = value, None + + payload = raw_output + if self._custom_process_func is not None: + payload = self._build_custom_process_payload( + request_id=req_id, + request=request, + pooling_output=raw_output, + ) + if payload is None: + continue + if payload is None: + logger.debug("[Stage-%s] send_full_payload_outputs: payload is None for %s", self._stage_id, req_id) + continue + if isinstance(payload, dict): + audio_codes = self._payload_audio_codes(payload) + if isinstance(audio_codes, torch.Tensor): + code_len = int(audio_codes.numel()) + elif hasattr(audio_codes, "__len__"): + code_len = len(audio_codes) + else: + code_len = None + meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {} + logger.debug( + "[Stage-%s] send_full_payload_outputs: req=%s payload_keys=%s code_len=%s left_context_size=%s", + self._stage_id, + req_id, + sorted(payload.keys()), + code_len, + meta.get("left_context_size"), + ) + + external_req_id = self._resolve_external_req_id(request, req_id) + chunk_id = self._put_req_chunk[req_id] + self._put_req_chunk[req_id] += 1 + self._ramp_chunk_count[req_id] += 1 + connector_put_key = f"{external_req_id}_{self._stage_id}_{chunk_id}" + + logger.debug( + "[Stage-%s] send_full_payload_outputs: enqueue req=%s put_key=%s next_stage=%s", + self._stage_id, + req_id, + connector_put_key, + next_stage_id, + ) + task = { + "stage_id": self._stage_id, + "next_stage_id": next_stage_id, + "put_key": connector_put_key, + "data": payload, + "request_id": req_id, + } + with self._lock: + self._pending_save_reqs.setdefault(req_id, deque()).append(task) + self._pending_save_counts[req_id] += 1 + sent_ids.append(req_id) + if sent_ids: + self._work_available.set() + return sent_ids + + # ------------------------------------------------------------------ # + # Streaming chunk mode (recv_chunk / send_chunk) + # ------------------------------------------------------------------ # + + def register_chunk_recv(self, request: Any) -> None: + """Register a request for async chunk retrieval by the bg thread. + + Stage-0 has no upstream producer so this is a no-op there. + Skips requests whose batch data has already been received to + prevent the bg thread from polling for non-existent chunks. + """ + if self._stage_id == 0: + return + request_id = request.request_id + # Explicit external_req_id=None must fall back to request_id; + # otherwise recv keys become `None__` and collide + # across requests. + ext = getattr(request, "external_req_id", None) + self._request_ids_mapping[request_id] = ext if ext is not None else request_id + with self._lock: + if request_id in self._stage_recv_req_ids: + return + # Don't re-register if the finish sentinel was already received + if request_id in self._chunk_stream_completed: + return + self._pending_load_reqs[request_id] = request + self._work_available.set() + + def recv_chunk(self) -> dict[str, Any]: + """Collect chunks received by the bg thread since last call. + + Returns a dict ``{request_id: chunk_payload}`` for newly arrived + chunks. Empty dict when nothing is ready. + + This method reads from ``_finished_load_reqs`` without clearing + it -- ``get_omni_connector_output()`` is the sole consumer that + drains and resets ``_finished_load_reqs`` at the end of each + ``execute_model`` cycle. + + Returns **shallow copies** of the cached payloads so that the + caller can read them without racing against the background recv + thread, which may concurrently mutate the live cache entries via + ``dict.update()``. + """ + with self._lock: + finished = set(self._finished_load_reqs) + if not finished: + return {} + # Snapshot the payloads under the lock to avoid racing with + # _poll_single_request which does existing.update(payload_data) + # on the same dict objects. + result = {} + for rid in finished: + payload = self._local_stage_payload_cache.get(rid) + result[rid] = dict(payload) if isinstance(payload, dict) else payload + + self._chunk_ready_req_ids.update(finished) + return result + + def send_chunk( + self, + request: Any, + pooling_output: Any | None = None, + ) -> bool: + """Derive and enqueue one chunk for async sending. + + Payload extraction runs in the caller thread (via + ``custom_process_stage_input_func``); the actual + ``connector.put()`` is done by the background save thread. + Non-KV data is identical across TP ranks; only rank 0 sends. + """ + if self._omni_connector is None: + logger.warning("[Stage-%s] send_chunk: connector is None", self._stage_id) + return False + if not self.is_data_transfer_rank(): + return True + raw_req_id = getattr(request, "request_id", None) or getattr(request, "req_id", None) + request_id = self._resolve_external_req_id(request, raw_req_id) + # Cache the internal→external mapping so that finish sentinels can + # resolve the external ID even after the request is freed. + if raw_req_id and raw_req_id != request_id: + self._request_ids_mapping.setdefault(raw_req_id, request_id) + chunk_id = self._put_req_chunk[request_id] + + payload_data = self._build_custom_process_payload( + request_id=request_id, + request=request, + pooling_output=pooling_output, + ) + if payload_data is None: + if chunk_id == 0: + logger.warning( + "[Stage-%s] send_chunk: payload is None for req=%s chunk=%s (process_func=%s)", + self._stage_id, + request_id, + chunk_id, + self._custom_process_func, + ) + return False + + self._put_req_chunk[request_id] += 1 + self._ramp_chunk_count[request_id] += 1 + next_stage_id = self._next_stage_id + connector_put_key = f"{request_id}_{self._stage_id}_{chunk_id}" + + if chunk_id == 0: + logger.debug( + "[Stage-%s] send_chunk: first chunk enqueued, req=%s key=%s", + self._stage_id, + request_id, + connector_put_key, + ) + + task = { + "stage_id": self._stage_id, + "next_stage_id": next_stage_id, + "put_key": connector_put_key, + "data": payload_data, + "request_id": request_id, + } + with self._lock: + self._pending_save_reqs.setdefault(request_id, deque()).append(task) + self._pending_save_counts[request_id] += 1 + self._work_available.set() + return True + + # ------------------------------------------------------------------ # + # Background I/O threads + # ------------------------------------------------------------------ # + + def _recv_loop(self) -> None: + """Background thread: poll connector for incoming data.""" + _recv_poll_count = 0 + while not self._stop_event.is_set(): + with self._lock: + pending_ids = list(self._pending_load_reqs.keys()) + + if not pending_ids: + self._work_available.wait(timeout=0.01) + self._work_available.clear() + continue + + _recv_poll_count += 1 + if _recv_poll_count % 5000 == 1: + logger.debug( + "[Stage-%s] _recv_loop: polling %s pending reqs: %s (poll#%s)", + self._stage_id, + len(pending_ids), + pending_ids[:5], + _recv_poll_count, + ) + + made_progress = False + for req_id in pending_ids: + if self._stop_event.is_set(): + break + try: + made_progress = self._poll_single_request(req_id) or made_progress + except Exception: + logger.warning("Error receiving data for %s", req_id, exc_info=True) + + if not made_progress and not self._stop_event.is_set(): + self._work_available.wait(timeout=0.005) + self._work_available.clear() + + _MAX_SEND_RETRIES = 3 + + def _save_loop(self) -> None: + """Background thread: send outgoing data via connector.""" + while not self._stop_event.is_set(): + task = None + with self._lock: + for req_id in list(self._pending_save_reqs.keys()): + dq = self._pending_save_reqs[req_id] + if dq: + task = dq.popleft() + if not dq: + del self._pending_save_reqs[req_id] + break + del self._pending_save_reqs[req_id] + + if task is not None: + success = False + try: + success = self._send_single_request(task) + except Exception: + logger.error( + "Error saving data for %s", + task.get("request_id"), + exc_info=True, + ) + if not success: + self._requeue_or_drop_failed_send(task) + continue + + self._work_available.wait(timeout=0.01) + self._work_available.clear() + + def _requeue_or_drop_failed_send(self, task: dict) -> None: + """Re-enqueue a failed send task or drop it after max retries.""" + retry_count = task.get("_retry_count", 0) + 1 + req_id = task.get("request_id") + if retry_count <= self._MAX_SEND_RETRIES: + task["_retry_count"] = retry_count + logger.warning( + "[Stage-%s] Re-enqueuing failed send for %s (retry %d/%d)", + getattr(self, "_stage_id", "?"), + req_id, + retry_count, + self._MAX_SEND_RETRIES, + ) + with self._lock: + dq = self._pending_save_reqs.setdefault(req_id, deque()) + dq.appendleft(task) + else: + logger.error( + "[Stage-%s] Giving up on send for %s after %d retries", + getattr(self, "_stage_id", "?"), + req_id, + self._MAX_SEND_RETRIES, + ) + self._decrement_pending_save_count(req_id) + + # ------------------------------------------------------------------ # + # Chunk-level poll / send (ported from OmniChunkTransferAdapter) + # ------------------------------------------------------------------ # + + def _poll_single_request(self, req_id: str) -> bool: + """Poll connector for one chunk of a request (non-blocking).""" + connector = self._omni_connector + if connector is None: + return False + + if self._async_chunk and self._model_mode != "ar": + with self._lock: + staged_payload = self._local_stage_payload_cache.get(req_id) + metadata_in_flight = req_id in self._local_request_metadata + scheduler_wakeup_pending = req_id in self._finished_load_reqs + if self._payload_is_consumable(staged_payload) or metadata_in_flight or scheduler_wakeup_pending: + logger.debug( + "[Stage-%s] delaying recv for req=%s until staged async payload is handed to scheduler", + self._stage_id, + req_id, + ) + return False + + target_stage_id = self._stage_id - 1 + chunk_id = self._get_req_chunk[req_id] + external_req_id = self._request_ids_mapping.get(req_id, req_id) + connector_get_key = f"{external_req_id}_{target_stage_id}_{chunk_id}" + + if self._async_chunk: + result = self._recv_async_chunk_result( + connector, + str(target_stage_id), + str(self._stage_id), + connector_get_key, + ) + else: + result = self._recv_full_payload_result( + connector, + str(target_stage_id), + str(self._stage_id), + connector_get_key, + ) + + if result is None: + return False + + payload_data, _size = result + if not payload_data: + return False + if isinstance(payload_data, dict): + logger.debug( + "[Stage-%s] recv_chunk_result: req=%s ext=%s key=%s keys=%s finished=%s", + self._stage_id, + req_id, + external_req_id, + connector_get_key, + sorted(payload_data.keys()), + self._payload_finished(payload_data), + ) + + self._get_req_chunk[req_id] += 1 + + if self._async_chunk: + is_finished = self._payload_finished(payload_data) + incoming_payload_consumable = self._payload_is_consumable(payload_data) + + if self._model_mode == "ar": + payload_data = self._accumulate_payload(external_req_id, payload_data) + payload_consumable = incoming_payload_consumable + else: + new_ids = self._payload_audio_codes(payload_data) or [] + if not new_ids and not is_finished: + return False + payload_consumable = self._payload_is_consumable(payload_data) + + with self._lock: + if is_finished: + self._chunk_finished_req_ids.add(req_id) + self._chunk_stream_completed.add(req_id) + # Local cache (RFC §2.4) — merge, don't replace, so that + # earlier chunk keys (e.g. thinker_prefill_embeddings from + # chunk 0) are not overwritten by later chunks. + existing = self._local_stage_payload_cache.get(req_id) + if existing is not None and isinstance(existing, dict) and isinstance(payload_data, dict): + existing.update(payload_data) + else: + self._local_stage_payload_cache[req_id] = payload_data + staged_payload = self._local_stage_payload_cache[req_id] + self._async_chunk_updated_req_ids.add(req_id) + self.put_local_request_metadata(req_id, self._extract_scheduling_metadata(staged_payload)) + # A finish-only sentinel still needs one terminal wake-up so + # the downstream stage can sync the merged local payload and + # flush/finish even when the last recv carries no new + # consumable chunk bytes. + if payload_consumable or is_finished: + self._finished_load_reqs.add(req_id) + if is_finished and not payload_consumable: + logger.debug( + "[Stage-%s] finish sentinel arrived for req=%s without new consumable payload", + self._stage_id, + req_id, + ) + elif not payload_consumable: + logger.debug( + "[Stage-%s] req=%s received metadata-only / non-consumable async payload; delaying wake-up", + self._stage_id, + req_id, + ) + if is_finished: + self._pending_load_reqs.pop(req_id, None) + else: + # full_payload_mode: the complete payload arrives in a single get(), + # so always unregister immediately. + if isinstance(payload_data, dict): + engine_inputs = payload_data.get("engine_inputs", payload_data) + else: + engine_inputs = payload_data + with self._lock: + self._local_stage_payload_cache[req_id] = self._snapshot_payload(engine_inputs) + # Publish full-payload readiness only after the aligned TP broadcast + # path in recv_full_payload_inputs() has materialized the payload on all + # local ranks. Publishing metadata / stage_recv from the background recv + # thread can let the scheduler observe a request before the payload is + # actually visible to the model thread. + self._full_payload_pending_broadcast_req_ids.add(req_id) + self._pending_load_reqs.pop(req_id, None) + logger.debug( + "[Stage-%s] full_payload recv complete: req=%s key=%s payload_type=%s", + self._stage_id, + req_id, + connector_get_key, + type(engine_inputs).__name__, + ) + + logger.debug("[Stage-%s] Received data for key %s", self._stage_id, connector_get_key) + return True + + def _build_custom_process_payload( + self, + request_id: str | None, + request: Any | None, + pooling_output: Any | None, + ) -> Any | None: + """Run the custom process hook with a best-effort finished kwarg.""" + if self._custom_process_func is None: + return None + + kwargs = { + "transfer_manager": self, + "pooling_output": pooling_output, + "request": request, + } + supports_is_finished = getattr( + self, + "_custom_process_supports_is_finished", + self._custom_process_supports_is_finished_kwarg(), + ) + is_finished_fn = getattr(request, "is_finished", None) + if callable(is_finished_fn): + try: + if supports_is_finished is not False: + kwargs["is_finished"] = bool(is_finished_fn()) + except Exception: + logger.debug("request.is_finished() failed for %s", request_id, exc_info=True) + + try: + return self._custom_process_func(**kwargs) + except TypeError as exc: + if "is_finished" not in kwargs or not self._is_unexpected_is_finished_kwarg_error(exc): + logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) + return None + kwargs.pop("is_finished", None) + try: + return self._custom_process_func(**kwargs) + except Exception: + logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) + return None + except Exception: + logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) + return None + + def _custom_process_supports_is_finished_kwarg(self) -> bool | None: + """Return whether the custom process hook accepts `is_finished`.""" + if self._custom_process_func is None: + return None + try: + signature = inspect.signature(self._custom_process_func) + except (TypeError, ValueError): + return None + + for param in signature.parameters.values(): + if param.kind == inspect.Parameter.VAR_KEYWORD: + return True + + is_finished_param = signature.parameters.get("is_finished") + if is_finished_param is None: + return False + return is_finished_param.kind in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + + @staticmethod + def _is_unexpected_is_finished_kwarg_error(exc: TypeError) -> bool: + message = str(exc) + return ( + "unexpected keyword argument 'is_finished'" in message + or 'unexpected keyword argument "is_finished"' in message + or "positional-only arguments passed as keyword arguments: 'is_finished'" in message + ) + + def _send_single_request(self, task: dict) -> bool: + """Send one queued task via connector.put(). + + Returns True on success. On failure (put() raises or returns + ``success=False``), returns False **without** decrementing + ``_pending_save_counts`` so the caller can retry or clean up. + """ + connector = self._omni_connector + if connector is None: + return True + + request_id = task.get("request_id") + payload_data = task.get("data") + if payload_data is None and task.get("request") is not None: + payload_data = self._build_custom_process_payload( + request_id=request_id, + request=task.get("request"), + pooling_output=task.get("pooling_output"), + ) + put_key = task.get("put_key") + + success, _size, _metadata = connector.put( + from_stage=str(task["stage_id"]), + to_stage=str(task["next_stage_id"]), + put_key=put_key, + data=payload_data, + ) + logger.debug( + "[Stage-%s] _send_single_request: put_key=%s success=%s size=%s", + task["stage_id"], + put_key, + success, + _size, + ) + + if not success: + return False + + self._decrement_pending_save_count(request_id) + return True + + def _decrement_pending_save_count(self, request_id: str) -> None: + """Decrement pending save count and run deferred cleanup if zero.""" + cleanup_req_id = None + with self._lock: + remaining = self._pending_save_counts.get(request_id, 0) + if remaining > 1: + self._pending_save_counts[request_id] = remaining - 1 + elif remaining == 1: + self._pending_save_counts.pop(request_id, None) + if request_id in self._deferred_send_cleanup: + self._deferred_send_cleanup.remove(request_id) + cleanup_req_id = request_id + if cleanup_req_id is not None: + self._put_req_chunk.pop(cleanup_req_id, None) + self._send_side_request_payload.pop(cleanup_req_id, None) + self._code_prompt_token_ids.pop(cleanup_req_id, None) + self._cached_ic.pop(cleanup_req_id, None) + self._ramp_chunk_count.pop(cleanup_req_id, None) + self._adaptive_states.pop(cleanup_req_id, None) + + # ------------------------------------------------------------------ # + # Payload accumulation (ported from OmniChunkTransferAdapter) + # ------------------------------------------------------------------ # + + def _accumulate_payload(self, req_id: str, payload_data: OmniPayload) -> OmniPayload: + """Accumulate chunk payloads (concat tensors, extend lists).""" + if req_id not in self._send_side_request_payload: + self._send_side_request_payload[req_id] = dict(payload_data) + return dict(self._send_side_request_payload[req_id]) + + origin = self._send_side_request_payload[req_id] + merged = dict(origin) + raw_ok = payload_data.get("meta", {}).get("override_keys", []) if isinstance(payload_data, dict) else [] + override_keys = {tuple(k) if isinstance(k, list) else k for k in raw_ok} + + for key, value in payload_data.items(): + if isinstance(value, dict): + origin_sub = origin.get(key) + merged_sub = dict(origin_sub) if isinstance(origin_sub, dict) else {} + for qual, qval in value.items(): + if key == "meta" and qual == "finished": + merged_sub[qual] = qval + continue + if (key, qual) in override_keys: + merged_sub[qual] = qval + continue + osv = merged_sub.get(qual) + if isinstance(qval, torch.Tensor) and isinstance(osv, torch.Tensor): + merged_sub[qual] = torch.cat([osv, qval], dim=0) + elif isinstance(qval, list) and isinstance(osv, list): + merged_sub[qual] = osv + qval + else: + merged_sub[qual] = qval + merged[key] = merged_sub + else: + if key in override_keys: + merged[key] = value + continue + ov = origin.get(key) + if isinstance(value, torch.Tensor) and isinstance(ov, torch.Tensor): + merged[key] = torch.cat([ov, value], dim=0) + elif isinstance(value, list) and isinstance(ov, list): + merged[key] = ov + value + else: + merged[key] = value + + self._send_side_request_payload[req_id] = merged + return dict(merged) diff --git a/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_runtime.py b/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_runtime.py new file mode 100644 index 000000000..275a2c0af --- /dev/null +++ b/vllm_omni/distributed/omni_connectors/model_runner/omni_connector_runtime.py @@ -0,0 +1,1061 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Omni connector lifecycle and shared model-runner transport state.""" + +from __future__ import annotations + +import importlib +import inspect +import os +import threading +from collections import defaultdict, deque +from typing import TYPE_CHECKING, Any + +import torch +from vllm.logger import init_logger + +from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory +from vllm_omni.distributed.omni_connectors.utils.config import ( + ConnectorSpec, + get_stage_connector_role, +) + +logger = init_logger("vllm_omni.worker.omni_connector_model_runner_mixin") + +if TYPE_CHECKING: + from vllm_omni.config.model import OmniModelConfig + from vllm_omni.distributed.omni_connectors.connectors.base import ( + OmniConnectorBase, + ) + from vllm_omni.distributed.omni_connectors.kv_transfer_manager import ( + OmniKVTransferManager, + ) + + +def needs_omni_connector(model_config: Any) -> bool: + """Whether a runner owns an input, output, or explicitly routed connector.""" + return ( + bool(getattr(model_config, "requires_full_payload_input", False)) + or bool(getattr(model_config, "custom_process_next_stage_input_func", None)) + or get_stage_connector_role(model_config) is not None + ) + + +def _should_create_payload_connector(model_config: Any) -> bool: + """Whether this stage owns runner payload transport for its edge. + + Sender edges may instead be owned solely by KV transfer. Receivers still + need a connector even though they do not declare a downstream payload hook. + """ + if get_stage_connector_role(model_config) != "sender": + return True + + next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) + return isinstance(next_stage_func, str) and bool(next_stage_func) + + +def should_accumulate_full_payload_output(model_config, custom_process_func) -> bool: + """Producer-side structural gate. + + Fires iff the stage explicitly declares a downstream full-payload + producer hook via ``custom_process_next_stage_input_func``. Consumer + stages may have ``custom_process_input_func`` values that can be + mechanically derived to ``*_full_payload`` helper names in the same + module; those are intentionally not enough to make the stage a producer. + """ + if custom_process_func is None: + return False + if getattr(model_config, "async_chunk", False): + return False + if getattr(model_config, "final_output", False): + return False + next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) + if not isinstance(next_stage_func, str) or not next_stage_func: + return False + return getattr(model_config, "model_stage", None) is not None + + +class _OmniConnectorRuntimeMixin: + """Own connector lifecycle, shared state, and KV transfer delegation.""" + + _omni_connector: Any + _kv_transfer_manager: Any + _async_chunk: bool + _model_mode: str + _stage_id: int + _next_stage_id: int + _from_tp: int + _to_tp: int + _local_rank: int + _custom_process_func_path: str | None + _custom_process_func: Any + _custom_process_supports_is_finished: bool | None + _put_req_chunk: dict[str, int] + _get_req_chunk: dict[str, int] + _ramp_chunk_count: dict[str, int] + _adaptive_states: dict[str, Any] + _send_side_request_payload: dict[str, dict[str, Any]] + _code_prompt_token_ids: dict[str, list[list[int]]] + _cached_ic: dict[str, int] + _request_ids_mapping: dict[str, str] + _pending_load_reqs: dict[str, Any] + _finished_load_reqs: set[str] + _pending_save_reqs: dict[str, deque[Any]] + _pending_save_counts: dict[str, int] + _deferred_send_cleanup: set[str] + _chunk_ready_req_ids: set[str] + _chunk_finished_req_ids: set[str] + _stage_recv_req_ids: set[str] + _full_payload_pending_broadcast_req_ids: set[str] + _async_chunk_updated_req_ids: set[str] + _local_stage_payload_cache: dict[str, dict[str, Any]] + _local_request_metadata: dict[str, dict[str, Any]] + _chunk_stream_completed: set[str] + _pending_full_payload_send: dict[str, tuple[Any, ...]] + _kv_sent_req_ids: list[str] + _kv_pending_transfers: dict[str, dict[str, Any]] + _kv_active_transfers: set[str] + _kv_completed_transfers: set[str] + _kv_triggered_requests: set[str] + _lock: Any + _stop_event: Any + _work_available: Any + _recv_thread: Any + _save_thread: Any + _omni_connector_initialized: bool + _full_payload_replace_keys_cached: frozenset[Any] + _should_accumulate_full_payload_output_cached: bool + + vllm_config: Any + requests: dict[str, Any] + model_intermediate_buffer: dict[str, Any] + + _recv_loop: Any + _save_loop: Any + flush_full_payload_outputs: Any + _custom_process_supports_is_finished_kwarg: Any + _get_local_tp_group: Any + + # ------------------------------------------------------------------ # + # Init / Shutdown + # ------------------------------------------------------------------ # + + def init_omni_connectors( + self, + model_config: OmniModelConfig, + kv_transfer_manager: OmniKVTransferManager | None = None, + ) -> None: + """Initialize connectors and background threads. + + Args: + model_config: Stage-level model config with connector settings. + kv_transfer_manager: Existing KV transfer manager to delegate to. + """ + self._omni_connector: OmniConnectorBase | None = ( + self._create_connector(model_config) if _should_create_payload_connector(model_config) else None + ) + self._kv_transfer_manager = kv_transfer_manager + + self._async_chunk: bool = getattr(model_config, "async_chunk", False) + self._model_mode: str = getattr(model_config, "worker_type", "ar") + stage_id = getattr(model_config, "stage_id", 0) + if isinstance(stage_id, str): + stage_id = int(stage_id) + self._stage_id: int = stage_id if isinstance(stage_id, int) else 0 + + self._custom_process_func_path, self._custom_process_func = self._load_custom_func(model_config) + self._custom_process_supports_is_finished = self._custom_process_supports_is_finished_kwarg() + logger.debug( + "[Stage-%s] init_omni_connectors: async_chunk=%s, custom_process_func=%s, connector=%s, func_path=%s", + self._stage_id, + self._async_chunk, + self._custom_process_func, + type(self._omni_connector).__name__ if self._omni_connector else None, + self._custom_process_func_path, + ) + + # -- next stage ID (from connector config or default stage_id + 1) -- + self._next_stage_id: int = self._resolve_next_stage_id(model_config) + + # -- heterogeneous TP rank support -- + rank_cfg = self._parse_rank_mapping(model_config) + if self._kv_transfer_manager is not None: + topology = getattr(self._kv_transfer_manager, "tp_topology", None) + effective_mapping = ( + getattr(topology, "source_tp_size", None), + getattr(topology, "target_tp_size", None), + getattr(topology, "local_rank", None), + ) + if all(isinstance(value, int) for value in effective_mapping): + rank_cfg = { + "from_tp": effective_mapping[0], + "to_tp": effective_mapping[1], + "local_rank": effective_mapping[2], + } + self._from_tp: int = rank_cfg["from_tp"] + self._to_tp: int = rank_cfg["to_tp"] + self._local_rank: int = rank_cfg["local_rank"] + if self._kv_transfer_manager is not None: + self._kv_transfer_manager.kv_send_key_builder = self.get_rank_aware_kv_send_keys + self._kv_transfer_manager.kv_recv_key_builder = self.get_rank_aware_kv_keys + self._kv_transfer_manager.kv_payload_merger = self._merge_rank_sharded_kv_payloads + self._kv_transfer_manager.kv_payload_slicer = self._slice_rank_sharded_kv_payload + + # -- chunk index tracking (ported from OmniChunkTransferAdapter) -- + self._put_req_chunk: dict[str, int] = defaultdict(int) + self._get_req_chunk: dict[str, int] = defaultdict(int) + # Segment-local chunk counter: incremented alongside _put_req_chunk + # and popped at request cleanup. Note: the mixin path (uniproc mode) + # does not have segment boundary infrastructure; multi-segment support + # is only available via chunk_transfer_adapter (distributed path). + self._ramp_chunk_count: dict[str, int] = defaultdict(int) + self._adaptive_states: dict[str, Any] = {} + # Send-side async accumulation / staging buffer. Receive-side payload + # ownership lives in ``_local_stage_payload_cache``. + self._send_side_request_payload: dict[str, dict[str, Any]] = {} + self._code_prompt_token_ids: dict[str, list[list[int]]] = defaultdict(list) + self._cached_ic: dict[str, int] = {} + self._request_ids_mapping: dict[str, str] = {} + + # -- async I/O state (shared by chunk + full_payload_mode) -- + self._pending_load_reqs: dict[str, Any] = {} + self._finished_load_reqs: set[str] = set() + self._pending_save_reqs: dict[str, deque] = {} + self._pending_save_counts: dict[str, int] = defaultdict(int) + self._deferred_send_cleanup: set[str] = set() + # -- per-cycle output accumulator -- + self._chunk_ready_req_ids: set[str] = set() + self._chunk_finished_req_ids: set[str] = set() + self._stage_recv_req_ids: set[str] = set() + self._full_payload_pending_broadcast_req_ids: set[str] = set() + self._async_chunk_updated_req_ids: set[str] = set() + + # -- Model Runner local payload cache (RFC §2.4) -- + # Full stage payloads land here first on the recv side. We + # intentionally do not write connector recv results straight into + # `model_intermediate_buffer`: runner-owned runtime state is + # materialized later by `_sync_local_stage_payloads()` on the + # model thread. This keeps recv timing separate from execute-step + # visibility and avoids mixing connector I/O with model runtime + # ownership. + self._local_stage_payload_cache: dict[str, dict[str, Any]] = {} + # Lightweight scheduling metadata pending delivery to the Scheduler. + self._local_request_metadata: dict[str, dict[str, Any]] = {} + + # -- persistent set of request IDs whose chunk stream is complete -- + # Prevents re-registration after the finish sentinel has been received. + self._chunk_stream_completed: set[str] = set() + + # -- full_payload_mode: accumulate latest pooler_output per request, + # send only when the request finishes (next-cycle flush) -- + self._pending_full_payload_send: dict[str, tuple[Any, ...]] = {} + + # -- KV sent accumulator -- + self._kv_sent_req_ids: list[str] = [] + + # -- KV transfer lifecycle (absorbed from scheduler) -- + # Requests marked for KV transfer: {req_id: {seq_len, block_ids}} + self._kv_pending_transfers: dict[str, dict[str, Any]] = {} + # Requests whose KV transfer has been submitted but not yet acked + self._kv_active_transfers: set[str] = set() + # Requests whose KV transfer is complete (acked by kv_extracted_req_ids) + self._kv_completed_transfers: set[str] = set() + # Dedup guard: requests that have already triggered KV transfer + self._kv_triggered_requests: set[str] = set() + + self._lock = threading.Lock() + self._stop_event = threading.Event() + self._work_available = threading.Event() + + # Start background threads only when there's a connector + self._recv_thread: threading.Thread | None = None + self._save_thread: threading.Thread | None = None + if self._omni_connector is not None: + self._recv_thread = threading.Thread( + target=self._recv_loop, + daemon=True, + name="omni-mixin-recv", + ) + self._recv_thread.start() + self._save_thread = threading.Thread( + target=self._save_loop, + daemon=True, + name="omni-mixin-save", + ) + self._save_thread.start() + + # Explicit "fully initialised" marker so other parts of the runner + # (e.g. _update_states cleanup) can branch on a stable contract + # instead of probing for private mixin attribute names. Must be set + # only after every field above has been bound, so a partially + # constructed mixin is never observable as initialised. + self._omni_connector_initialized = True + + def shutdown_omni_connectors(self) -> None: + """Stop background threads and release connector resources.""" + self._stop_event.set() + if self._recv_thread is not None: + self._recv_thread.join(timeout=5) + if self._save_thread is not None: + self._save_thread.join(timeout=5) + if self._omni_connector is not None: + try: + self._omni_connector.close() + except Exception: + pass + + def cleanup_finished_request(self, req_id: str) -> None: + """Clean up per-request state after a request is fully finished. + + Call this when a request is freed from the model runner to prevent + memory leaks in the mixin's tracking dicts/sets. + + Two senders use different keys: ``send_chunk`` keys per-request + state under the EXTERNAL id (after mapping resolution), while + ``send_full_payload_outputs`` keys under the INTERNAL id. To cover + both modes (and forward compat with id-rename scenarios) we attempt + cleanup against both keys; the entry that doesn't exist for the + active mode is a no-op pop. Only the key that actually has pending + saves is added to ``_deferred_send_cleanup`` so the bg save's + decrement path drains it without leaving orphans. + """ + # Force-flush any pending full-payload accumulator entry before + # cleanup proceeds. Without this, finished requests with no + # downstream consumer (e.g. text-only on multi-modal arch) leave + # the entry orphaned in _pending_full_payload_send across requests, + # which empirically destabilises subsequent thinker forwards by + # making prefix-cache reuse observe stale accumulator state. The + # flush is idempotent when the entry has already been flushed by the + # scheduler-driven path, but this cleanup path runs for every request, + # so skip it entirely when the request never accumulated a payload. + if req_id in self._pending_full_payload_send: + try: + self.flush_full_payload_outputs({req_id}) + except Exception: + # Cleanup must still proceed regardless of flush errors here -- + # we already gated on ``_omni_connector_initialized`` upstream, + # so any exception here reflects a real connector-side issue + # (shared memory corruption, background thread crash) worth + # surfacing rather than silently swallowing. + logger.warning( + "flush_full_payload_outputs(%s) raised during cleanup; continuing tear-down.", + req_id, + exc_info=True, + ) + + ext_id = self._request_ids_mapping.pop(req_id, None) + keys_to_clean: list[str] = [req_id] + if ext_id is not None and ext_id != req_id: + keys_to_clean.append(ext_id) + + with self._lock: + keys_pending = [k for k in keys_to_clean if self._pending_save_counts.get(k, 0)] + for k in keys_pending: + self._deferred_send_cleanup.add(k) + for k in keys_to_clean: + if k in keys_pending: + continue + self._put_req_chunk.pop(k, None) + self._send_side_request_payload.pop(k, None) + self._code_prompt_token_ids.pop(k, None) + self._cached_ic.pop(k, None) + self._ramp_chunk_count.pop(k, None) + self._adaptive_states.pop(k, None) + self._kv_pending_transfers.pop(req_id, None) + self._kv_active_transfers.discard(req_id) + self._kv_completed_transfers.discard(req_id) + self._kv_triggered_requests.discard(req_id) + self._cleanup_recv_delivery_state(req_id) + + def drop_inactive_request_delivery_state(self, req_id: str) -> None: + """Clear recv-side state for inactive requests.""" + ext_id = self._request_ids_mapping.pop(req_id, None) + if hasattr(self, "_lock"): + with self._lock: + self._drop_send_side_payload_state(req_id, ext_id) + else: + self._drop_send_side_payload_state(req_id, ext_id) + self._cleanup_recv_delivery_state(req_id) + + def _drop_send_side_payload_state(self, req_id: str, ext_id: str | None) -> None: + if ext_id is not None: + self._send_side_request_payload.pop(ext_id, None) + self._cached_ic.pop(ext_id, None) + self._send_side_request_payload.pop(req_id, None) + self._cached_ic.pop(req_id, None) + + def _cleanup_recv_delivery_state(self, req_id: str) -> None: + """Clear recv-side delivery-cycle state.""" + if hasattr(self, "_lock"): + with self._lock: + self._clear_recv_delivery_state(req_id) + else: + self._clear_recv_delivery_state(req_id) + + def _clear_recv_delivery_state(self, req_id: str) -> None: + self._get_req_chunk.pop(req_id, None) + self._pending_load_reqs.pop(req_id, None) + self._finished_load_reqs.discard(req_id) + self._chunk_ready_req_ids.discard(req_id) + self._chunk_finished_req_ids.discard(req_id) + self._chunk_stream_completed.discard(req_id) + self._stage_recv_req_ids.discard(req_id) + self._full_payload_pending_broadcast_req_ids.discard(req_id) + self._async_chunk_updated_req_ids.discard(req_id) + self._local_stage_payload_cache.pop(req_id, None) + self._local_request_metadata.pop(req_id, None) + + def prune_inactive_requests(self, active_req_ids: Any) -> set[str]: + """Drop connector state for requests that no longer exist locally. + + Preempted / unscheduled requests are expected to stay in + ``self.requests`` and therefore remain untouched. This only prunes + stale request IDs that have already fallen out of the active request + map, preventing background recv/send bookkeeping from outliving the + request lifecycle. + """ + if active_req_ids is None: + return set() + + active_req_ids = set(active_req_ids) + pending_req_ids = set(getattr(self, "_pending_load_reqs", {}).keys()) + received_req_ids = set(getattr(self, "_stage_recv_req_ids", set())) + received_req_ids.update(getattr(self, "_full_payload_pending_broadcast_req_ids", set())) + received_req_ids.update(getattr(self, "_local_request_metadata", {}).keys()) + # Pending recv requests may not yet be in the caller's active set + # (e.g. WAITING_FOR_CHUNK requests live in the coordinator's internal + # queues, not in model runner self.requests). Protect them so that + # legitimate waiting requests are not pruned. + # + # Likewise, a full payload can arrive on the background recv thread + # after the scheduler_output snapshot for the current execute_model() + # cycle was already materialized. Those requests may briefly live only + # in recv-side buffers/local cache until the next scheduler cycle wakes + # them up; pruning them here drops the payload before stage_recv can be + # published. + active_req_ids.update(pending_req_ids) + active_req_ids.update(received_req_ids) + stale_req_ids: set[str] = set() + + # NOTE: _pending_load_reqs is excluded from the scan list because + # all its entries are unconditionally protected above. The mixin + # cannot distinguish a legitimately-waiting pending recv from an + # orphaned one (only the coordinator/scheduler knows). + # + # Requests with freshly received full payloads / local stage payloads + # are also protected above. Their scheduler wake-up may lag the recv + # thread by one execute_model() cycle, especially when the request was + # added after the current scheduler_output snapshot. + # + # Orphaned pending recv entries (e.g. from upstream stage crash) are + # handled by collect_timed_out_request_ids() -- on + # OmniSchedulingCoordinator for full-payload requests, and on + # OmniChunkTransferAdapter for async-chunk ones -- which detect + # wait-time violations. The scheduler then removes the request from + # its queues, sets FINISHED_ERROR, and calls _free_request() which + # ultimately triggers cleanup_finished_request() here. + for attr_name in ( + "_request_ids_mapping", + "_get_req_chunk", + "_finished_load_reqs", + "_chunk_ready_req_ids", + "_chunk_finished_req_ids", + "_chunk_stream_completed", + "_stage_recv_req_ids", + "_full_payload_pending_broadcast_req_ids", + "_async_chunk_updated_req_ids", + "_local_stage_payload_cache", + "_local_request_metadata", + "_kv_pending_transfers", + "_kv_active_transfers", + "_kv_completed_transfers", + "_kv_triggered_requests", + ): + state = getattr(self, attr_name, None) + if isinstance(state, dict): + stale_req_ids.update(req_id for req_id in state if req_id not in active_req_ids) + elif isinstance(state, set): + stale_req_ids.update(req_id for req_id in state if req_id not in active_req_ids) + + for req_id in stale_req_ids: + self.cleanup_finished_request(req_id) + + return stale_req_ids + + def drop_inactive_request_runtime_state(self, req_id: str) -> None: + """Clear inactive request state used by both the runner and mixin. + + This centralizes the model-runner-side cleanup pattern so + ``OmniGPUModelRunner`` can reuse it instead of open-coding the same + inactive-request state mutations. + """ + if hasattr(self, "model_intermediate_buffer"): + self.model_intermediate_buffer.pop(req_id, None) + self.drop_inactive_request_delivery_state(req_id) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _create_connector(model_config: Any) -> OmniConnectorBase | None: + """Create a connector from model_config, or None if unconfigured.""" + connector_config = getattr(model_config, "stage_connector_config", None) + if connector_config is None: + return None + + if not isinstance(connector_config, dict): + connector_config = { + "name": getattr(connector_config, "name", None), + "extra": getattr(connector_config, "extra", None), + } + + name = connector_config.get("name") + if not isinstance(name, str) or not name.strip(): + raise RuntimeError("Invalid stage connector config: missing connector name") + name = name.strip() + + extra = connector_config.get("extra") + if extra is None: + extra = {} + elif not isinstance(extra, dict): + raise RuntimeError(f"Invalid extra config for connector {name}: expected dict, got {type(extra).__name__}") + + spec = ConnectorSpec(name=name, extra=extra) + try: + return OmniConnectorFactory.create_connector(spec) + except Exception as exc: + raise RuntimeError(f"Failed to create connector {name}") from exc + + @classmethod + def _load_custom_func(cls, model_config: Any) -> tuple[str | None, Any | None]: + """Load the connector payload builder for the downstream stage. + + Preferred source is ``custom_process_next_stage_input_func``. Some + full_payload_mode configs (async_chunk=false) only expose the next-stage prompt builder via + ``custom_process_input_func`` (for example ``thinker2talker``), while the + connector payload builder lives beside it as ``thinker2talker_full_payload``. + In that case, derive the full_payload_mode builder path automatically. + """ + candidates: list[str] = [] + + next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) + if isinstance(next_stage_func, str) and next_stage_func: + candidates.append(next_stage_func) + + if not getattr(model_config, "async_chunk", False): + input_func = getattr(model_config, "custom_process_input_func", None) + if isinstance(input_func, str) and input_func: + try: + module_path, func_name = input_func.rsplit(".", 1) + if func_name.endswith("_full_payload") or func_name.endswith("_batch"): + candidates.append(f"{module_path}.{func_name}") + else: + candidates.append(f"{module_path}.{func_name}_full_payload") + candidates.append(f"{module_path}.{func_name}_batch") + candidates.append(input_func) + except ValueError: + candidates.append(input_func) + + tried: set[str] = set() + for func_path in candidates: + if func_path in tried: + continue + tried.add(func_path) + try: + module_path, func_name = func_path.rsplit(".", 1) + module = importlib.import_module(module_path) + func = getattr(module, func_name, None) + if callable(func): + if not cls._is_connector_payload_builder(func): + logger.debug( + "Skipping incompatible connector payload hook %s; signature=%s", + func_path, + inspect.signature(func), + ) + continue + return func_path, func + except Exception: + logger.warning("Failed to load custom func: %s", func_path, exc_info=True) + + return None, None + + @staticmethod + def _is_connector_payload_builder(func: Any) -> bool: + """Whether *func* matches the mixin payload-builder contract.""" + try: + signature = inspect.signature(func) + except (TypeError, ValueError): + return False + + params = signature.parameters + if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()): + return True + + required = {"transfer_manager", "pooling_output", "request"} + supported = { + name + for name, param in params.items() + if param.kind + in ( + inspect.Parameter.POSITIONAL_OR_KEYWORD, + inspect.Parameter.KEYWORD_ONLY, + ) + } + return required.issubset(supported) + + def _resolve_external_req_id(self, request: Any, fallback_req_id: str) -> str: + """Resolve the external request ID consistently. + + Checks ``_request_ids_mapping`` first (populated by + ``register_chunk_recv``), then falls back to the request's + ``external_req_id`` attribute, and finally to the given + ``fallback_req_id``. + """ + mapped = self._request_ids_mapping.get(fallback_req_id) + if mapped is not None: + return mapped + if request is not None: + # external_req_id may be explicitly None; fall back. + ext = getattr(request, "external_req_id", None) + if ext is not None: + return ext + return fallback_req_id + + def _resolve_next_stage_id(self, model_config: Any) -> int: + """Determine the downstream stage ID from connector config. + + Falls back to ``stage_id + 1`` when the config does not specify + a ``to_stage`` explicitly. + """ + connector_config = getattr(model_config, "stage_connector_config", None) + if connector_config is not None: + if isinstance(connector_config, dict): + to_stage = connector_config.get("to_stage") + else: + to_stage = getattr(connector_config, "to_stage", None) + if isinstance(to_stage, int): + return to_stage + if isinstance(to_stage, str) and to_stage.strip(): + return int(to_stage) + return self._stage_id + 1 + + @staticmethod + def _parse_rank_mapping(model_config: Any) -> dict[str, int]: + """Parse rank_mapping from connector config (optional). + + Returns ``{"from_tp": int, "to_tp": int, "local_rank": int}``. + When ``rank_mapping`` is absent, assumes 1:1 homogeneous mapping. + """ + connector_config = getattr(model_config, "stage_connector_config", None) + if connector_config is not None and not isinstance(connector_config, dict): + connector_config = getattr(connector_config, "__dict__", {}) + + rank_mapping: dict = {} + if isinstance(connector_config, dict): + rank_mapping = connector_config.get("rank_mapping", {}) + + from_tp = int(rank_mapping.get("from_tp", 1)) + to_tp = int(rank_mapping.get("to_tp", 1)) + + local_rank = 0 + try: + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + except (ValueError, TypeError): + pass + + return {"from_tp": from_tp, "to_tp": to_tp, "local_rank": local_rank} + + # ------------------------------------------------------------------ # + # KV cache (delegates to OmniKVTransferManager) + # ------------------------------------------------------------------ # + + def send_kv_cache( + self, + finished_reqs: dict[str, dict[str, Any]], + kv_caches: list[torch.Tensor], + block_size: int, + cache_dtype: str, + request_id_resolver: Any | None = None, + ) -> list[str]: + """Send KV cache for finished requests. + + Delegates to the existing ``OmniKVTransferManager``. + """ + if self._kv_transfer_manager is None: + return list(finished_reqs.keys()) if finished_reqs else [] + result = self._kv_transfer_manager.handle_finished_requests_kv_transfer( + finished_reqs=finished_reqs, + kv_caches=kv_caches, + block_size=block_size, + cache_dtype=cache_dtype, + request_id_resolver=request_id_resolver, + ) + if result: + self._kv_sent_req_ids.extend(result) + return result + + def recv_kv_cache( + self, + request_id: str, + target_device: torch.device | None = None, + ) -> tuple[dict[str, Any] | None, int]: + """Receive KV cache for a request. + + Delegates to the existing ``OmniKVTransferManager``. + """ + if self._kv_transfer_manager is None: + return None, 0 + return self._kv_transfer_manager.receive_kv_cache_for_request( + request_id=request_id, + target_device=target_device, + ) + + def receive_cfg_companion_kv_payloads( + self, + cfg_request_ids: dict[str, str], + target_device: torch.device | None = None, + ) -> dict[str, tuple[dict[str, Any] | None, int]]: + """Receive raw CFG companion KV payloads keyed by role.""" + return { + role: self.recv_kv_cache(companion_rid, target_device=target_device) + for role, companion_rid in cfg_request_ids.items() + } + + def receive_multi_kv_cache( + self, + req: Any, + cfg_kv_collect_func: Any | None = None, + target_device: torch.device | None = None, + ) -> bool: + """Receive primary and optional companion KV caches for a request. + + The mixin owns the runner-facing orchestration: primary KV receive, + companion payload fetch, and applying any model-specific CFG fields back + onto ``req.sampling_params``. + """ + if self._kv_transfer_manager is None: + return False + + request_id = getattr(req, "request_id", None) + if not request_id: + logger.warning("Request has no ID, cannot receive KV cache") + return False + + active_requests = getattr(self, "requests", None) + if active_requests is not None and request_id not in active_requests: + logger.debug("Skip receiving KV cache for inactive request %s", request_id) + return False + + primary_ok = False + data, _size = self.recv_kv_cache(request_id, target_device=target_device) + if data: + self._kv_transfer_manager.apply_kv_cache_to_request(req, data) + primary_ok = True + + cfg_ids = getattr(getattr(req, "sampling_params", None), "cfg_kv_request_ids", None) + if cfg_ids and cfg_kv_collect_func: + try: + cfg_role_payloads = self.receive_cfg_companion_kv_payloads( + cfg_ids, + target_device=target_device, + ) + cfg_kvs = cfg_kv_collect_func(request_id, cfg_role_payloads) + if cfg_kvs and hasattr(req, "sampling_params") and req.sampling_params is not None: + for key, value in cfg_kvs.items(): + setattr(req.sampling_params, key, value) + logger.debug("Applied CFG KV caches: %s", list(cfg_kvs.keys())) + except Exception: + logger.exception("Failed to collect CFG KV caches for %s", request_id) + + return primary_ok + + # ------------------------------------------------------------------ # + # Rank-aware KV transfer routing + # ------------------------------------------------------------------ # + + def get_rank_aware_kv_keys( + self, + req_id: str, + from_stage: int, + to_stage: int | None = None, + chunk_id: int = 0, + ) -> list[str]: + """Build recv-side connector keys for all remote ranks this rank needs. + + For heterogeneous TP receive, the local rank is the target rank and must + fetch one or more source-rank shards keyed as ``from_rank -> to_rank``. + """ + if self._from_tp <= 1 and self._to_tp <= 1: + resolved_to_stage = self._next_stage_id if to_stage is None else to_stage + return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"] + + remote_ranks = self.get_kv_remote_ranks() + return [ + self.get_kv_connector_key( + req_id=req_id, + from_stage=from_stage, + chunk_id=chunk_id, + from_rank=remote_rank, + to_rank=self._local_rank, + ) + for remote_rank in remote_ranks + ] + + def get_kv_target_ranks_for_send(self) -> list[int]: + """Determine which target ranks this local rank should send KV shards to.""" + self._validate_kv_tp_topology() + if self._from_tp == self._to_tp: + return [self._local_rank] + if self._from_tp > self._to_tp: + tp_ratio = self._from_tp // self._to_tp + return [self._local_rank // tp_ratio] + tp_ratio = self._to_tp // self._from_tp + base_rank = self._local_rank * tp_ratio + return [base_rank + i for i in range(tp_ratio)] + + def get_rank_aware_kv_send_keys( + self, + req_id: str, + from_stage: int, + to_stage: int | None = None, + chunk_id: int = 0, + ) -> list[str]: + """Build send-side connector keys for this rank's KV shard(s).""" + if self._from_tp <= 1 and self._to_tp <= 1: + resolved_to_stage = self._next_stage_id if to_stage is None else to_stage + return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"] + + target_ranks = self.get_kv_target_ranks_for_send() + return [ + self.get_kv_connector_key( + req_id=req_id, + from_stage=from_stage, + chunk_id=chunk_id, + from_rank=self._local_rank, + to_rank=target_rank, + ) + for target_rank in target_ranks + ] + + @staticmethod + def _merge_rank_sharded_kv_payloads(payloads: list[dict[str, Any]]) -> dict[str, Any] | None: + """Merge multiple source-rank KV shards for one target rank.""" + payloads = [payload for payload in payloads if isinstance(payload, dict)] + if not payloads: + return None + if len(payloads) == 1: + return payloads[0] + + merged = dict(payloads[0]) + layer_blocks = merged.get("layer_blocks") + if not isinstance(layer_blocks, dict): + return merged + + def _merge_tensor_lists(name: str) -> list[torch.Tensor | None]: + merged_list: list[torch.Tensor | None] = [] + cache_lists = [payload.get("layer_blocks", {}).get(name, []) for payload in payloads] + max_len = max((len(cache_list) for cache_list in cache_lists), default=0) + for idx in range(max_len): + tensors = [cache_list[idx] for cache_list in cache_lists if idx < len(cache_list)] + tensors = [tensor for tensor in tensors if isinstance(tensor, torch.Tensor)] + if not tensors: + merged_list.append(None) + elif len(tensors) == 1: + merged_list.append(tensors[0]) + else: + merged_list.append(torch.cat(tensors, dim=-2).contiguous()) + return merged_list + + merged["layer_blocks"] = { + "key_cache": _merge_tensor_lists("key_cache"), + "value_cache": _merge_tensor_lists("value_cache"), + } + metadata = dict(merged.get("metadata", {})) + metadata["merged_remote_rank_count"] = len(payloads) + merged["metadata"] = metadata + return merged + + def _slice_rank_sharded_kv_payload(self, payload: dict[str, Any] | None) -> dict[str, Any] | None: + """Slice a duplicated source-rank KV shard for ``from_tp < to_tp`` cases.""" + if payload is None or self._from_tp >= self._to_tp: + return payload + + tp_ratio = self._to_tp // self._from_tp + shard_index = self._local_rank % tp_ratio + layer_blocks = payload.get("layer_blocks") if isinstance(payload, dict) else None + if not isinstance(layer_blocks, dict): + return payload + + def _slice_tensor_list(name: str) -> list[torch.Tensor | None]: + sliced: list[torch.Tensor | None] = [] + for tensor in layer_blocks.get(name, []): + if not isinstance(tensor, torch.Tensor) or tensor.ndim < 2: + sliced.append(tensor) + continue + head_dim = tensor.shape[-2] + if head_dim % tp_ratio != 0: + sliced.append(tensor) + continue + per_rank = head_dim // tp_ratio + start = shard_index * per_rank + sliced.append(tensor.narrow(-2, start, per_rank).contiguous()) + return sliced + + payload = dict(payload) + payload["layer_blocks"] = { + "key_cache": _slice_tensor_list("key_cache"), + "value_cache": _slice_tensor_list("value_cache"), + } + metadata = dict(payload.get("metadata", {})) + metadata["sliced_for_local_rank"] = self._local_rank + payload["metadata"] = metadata + return payload + + def should_replicate_payload(self) -> bool: + """Whether non-KV payloads should be replicated across ranks. + + Data payloads (stage inputs, chunks) are identical after all-gather, + so only rank 0 transfers them. KV payloads are rank-specific and + all ranks participate. + """ + return self._local_rank != 0 + + def get_kv_rank_mapping(self) -> dict[str, Any]: + """Return the current rank mapping configuration. + + Useful for debugging and for downstream code that needs to know + the TP topology without re-parsing model config. + """ + return { + "from_tp": self._from_tp, + "to_tp": self._to_tp, + "local_rank": self._local_rank, + "remote_ranks": self.get_kv_remote_ranks(), + "is_data_transfer_rank": self.is_data_transfer_rank(), + } + + # ------------------------------------------------------------------ # + # KV transfer lifecycle (RFC – mixin-owned) + # ------------------------------------------------------------------ # + + def mark_kv_transfer( + self, + req_id: str, + seq_len: int, + block_ids: list[int], + custom_metadata: dict[str, Any] | None = None, + ) -> None: + """Mark a request as needing KV cache transfer. + + Called by the scheduler when a transfer trigger fires. The mixin + owns the lifecycle from this point: pending → active → completed. + """ + if req_id in self._kv_pending_transfers: + return + self._kv_triggered_requests.add(req_id) + transfer = { + "seq_len": seq_len, + "block_ids": block_ids, + } + if custom_metadata is not None: + transfer["custom_metadata"] = custom_metadata + self._kv_pending_transfers[req_id] = transfer + + def drain_pending_kv_transfers(self) -> dict[str, dict[str, Any]]: + """Drain pending KV transfers and move them to active. + + Returns ``{req_id: {seq_len, block_ids}}`` for the model runner + to submit to ``send_kv_cache``. + """ + if not self._kv_pending_transfers: + return {} + pending = dict(self._kv_pending_transfers) + self._kv_active_transfers.update(pending.keys()) + self._kv_pending_transfers.clear() + return pending + + def ack_kv_transfers(self, req_ids: list[str] | set[str]) -> None: + """Acknowledge completed KV transfers (from kv_extracted_req_ids). + + Moves requests from active to completed so the scheduler can + safely free their blocks. + """ + for req_id in req_ids: + self._kv_active_transfers.discard(req_id) + self._kv_completed_transfers.add(req_id) + + def drain_completed_kv_transfers(self) -> set[str]: + """Drain and return completed KV transfer request IDs. + + The scheduler calls this to know which requests' blocks can be freed. + """ + completed = set(self._kv_completed_transfers) + self._kv_completed_transfers.clear() + return completed + + def is_kv_transfer_triggered(self, req_id: str) -> bool: + """Check if a request has already triggered KV transfer.""" + return req_id in self._kv_triggered_requests + + def has_pending_kv_work(self) -> bool: + """True if any KV transfers are pending, active, or awaiting ack.""" + return bool(self._kv_pending_transfers or self._kv_active_transfers or self._kv_completed_transfers) + + # ------------------------------------------------------------------ # + # Heterogeneous TP rank support + # ------------------------------------------------------------------ # + + def _validate_kv_tp_topology(self) -> None: + """Reject heterogeneous TP mappings that cannot be routed losslessly.""" + if self._from_tp <= 0 or self._to_tp <= 0: + raise ValueError(f"Invalid KV TP mapping: from_tp={self._from_tp}, to_tp={self._to_tp}") + larger = max(self._from_tp, self._to_tp) + smaller = min(self._from_tp, self._to_tp) + if larger % smaller != 0: + raise ValueError( + f"KV TP mapping must be divisible for rank-aware routing: from_tp={self._from_tp}, to_tp={self._to_tp}" + ) + + def get_kv_remote_ranks(self) -> list[int]: + """Determine which remote ranks this local rank exchanges KV with. + + Follows vLLM's ``TpKVTopology.get_target_remote_ranks()`` pattern: + - ``from_tp > to_tp``: each to-rank reads from multiple from-ranks + - ``from_tp < to_tp``: multiple to-ranks read from the same from-rank + - ``from_tp == to_tp``: 1:1 mapping + """ + self._validate_kv_tp_topology() + if self._from_tp == self._to_tp: + return [self._local_rank] + + if self._from_tp > self._to_tp: + tp_ratio = self._from_tp // self._to_tp + return [self._local_rank * tp_ratio + i for i in range(tp_ratio)] + else: + tp_ratio = self._to_tp // self._from_tp + return [self._local_rank // tp_ratio] + + def is_data_transfer_rank(self) -> bool: + """Whether this rank should participate in data (non-KV) transfer. + + Ordinary stage payloads are TP-identical, so exactly one TP rank + should talk to the connector. When TP is initialized, use TP rank 0 + so the connector leader matches TP-local broadcast source rank. + Otherwise fall back to LOCAL_RANK==0 for the single-rank case. + """ + tp_group = self._get_local_tp_group() + if tp_group is not None and getattr(tp_group, "world_size", 1) > 1: + return getattr(tp_group, "rank_in_group", 0) == 0 + return self._local_rank == 0 + + def get_kv_connector_key( + self, + req_id: str, + from_stage: int, + chunk_id: int, + from_rank: int, + to_rank: int, + ) -> str: + """Build connector key that includes rank info for KV transfers.""" + return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}" diff --git a/vllm_omni/worker/omni_connector_model_runner_mixin.py b/vllm_omni/worker/omni_connector_model_runner_mixin.py index 1c8fe5b9f..3f94c16a7 100644 --- a/vllm_omni/worker/omni_connector_model_runner_mixin.py +++ b/vllm_omni/worker/omni_connector_model_runner_mixin.py @@ -1,2341 +1,27 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Unified data-plane communication mixin for Model Runners. +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Public model-runner interface for Omni connector transport.""" -All connector.put()/get() calls are consolidated here. Background I/O -threads handle async_chunk and full_payload_mode transfers; KV cache is delegated to -the existing OmniKVTransferManager (to be absorbed later). - -The mixin reports transfer results via OmniConnectorOutput so that the -Scheduler can make scheduling decisions without ever touching a connector. -""" - -from __future__ import annotations - -import importlib -import inspect -import os -import threading -from collections import defaultdict, deque -from typing import TYPE_CHECKING, Any - -import torch -from vllm.distributed.parallel_state import get_tp_group -from vllm.logger import init_logger - -from vllm_omni.data_entry_keys import OmniPayload -from vllm_omni.distributed.omni_connectors.factory import OmniConnectorFactory -from vllm_omni.distributed.omni_connectors.utils.config import ( - ConnectorSpec, - get_stage_connector_role, +from vllm_omni.distributed.omni_connectors.model_runner.omni_connector_payload_transport import ( + _OmniConnectorPayloadTransportMixin, ) -from vllm_omni.outputs import OmniConnectorOutput -from vllm_omni.worker.payload_span import ( - get_tensor_span, - merge_tensor_spans, +from vllm_omni.distributed.omni_connectors.model_runner.omni_connector_runtime import ( + _should_create_payload_connector, + needs_omni_connector, + should_accumulate_full_payload_output, ) -_EMBED_SPAN_GROUPS: tuple[tuple[str, str, str], ...] = (("decode", "decode_token_start", "decode_token_end"),) - -if TYPE_CHECKING: - from vllm_omni.config.model import OmniModelConfig - from vllm_omni.distributed.omni_connectors.connectors.base import ( - OmniConnectorBase, - ) - from vllm_omni.distributed.omni_connectors.kv_transfer_manager import ( - OmniKVTransferManager, - ) - -logger = init_logger(__name__) +__all__ = [ + "OmniConnectorModelRunnerMixin", + "_should_create_payload_connector", + "needs_omni_connector", + "should_accumulate_full_payload_output", +] -def needs_omni_connector(model_config: Any) -> bool: - """Whether a runner owns an input, output, or explicitly routed connector.""" - return ( - bool(getattr(model_config, "requires_full_payload_input", False)) - or bool(getattr(model_config, "custom_process_next_stage_input_func", None)) - or get_stage_connector_role(model_config) is not None - ) +class OmniConnectorModelRunnerMixin(_OmniConnectorPayloadTransportMixin): + """Unified data-plane communication interface for model runners. - -def _should_create_payload_connector(model_config: Any) -> bool: - """Whether this stage owns runner payload transport for its edge. - - Sender edges may instead be owned solely by KV transfer. Receivers still - need a connector even though they do not declare a downstream payload hook. + Runtime ownership and payload transport live under + ``distributed.omni_connectors``. """ - if get_stage_connector_role(model_config) != "sender": - return True - - next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) - return isinstance(next_stage_func, str) and bool(next_stage_func) - - -def should_accumulate_full_payload_output(model_config, custom_process_func) -> bool: - """Producer-side structural gate. - - Fires iff the stage explicitly declares a downstream full-payload - producer hook via ``custom_process_next_stage_input_func``. Consumer - stages may have ``custom_process_input_func`` values that can be - mechanically derived to ``*_full_payload`` helper names in the same - module; those are intentionally not enough to make the stage a producer. - """ - if custom_process_func is None: - return False - if getattr(model_config, "async_chunk", False): - return False - if getattr(model_config, "final_output", False): - return False - next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) - if not isinstance(next_stage_func, str) or not next_stage_func: - return False - return getattr(model_config, "model_stage", None) is not None - - -class OmniConnectorModelRunnerMixin: - """Unified data-plane communication mixin for Model Runners. - - Provides three transfer modes through a single pair of bg I/O threads: - - **full_payload_mode**: ``recv_full_payload_inputs`` / ``send_full_payload_outputs`` - - **Streaming (async_chunk)**: ``recv_chunk`` / ``send_chunk`` - - **KV cache**: ``send_kv_cache`` / ``recv_kv_cache`` (delegates to - the existing ``OmniKVTransferManager``) - - The mixin owns connector instances and background threads. It never - touches scheduling queues -- readiness is communicated to the Scheduler - via ``OmniConnectorOutput``. - """ - - # ------------------------------------------------------------------ # - # Init / Shutdown - # ------------------------------------------------------------------ # - - def init_omni_connectors( - self, - model_config: OmniModelConfig, - kv_transfer_manager: OmniKVTransferManager | None = None, - ) -> None: - """Initialize connectors and background threads. - - Args: - model_config: Stage-level model config with connector settings. - kv_transfer_manager: Existing KV transfer manager to delegate to. - """ - self._omni_connector: OmniConnectorBase | None = ( - self._create_connector(model_config) if _should_create_payload_connector(model_config) else None - ) - self._kv_transfer_manager = kv_transfer_manager - - self._async_chunk: bool = getattr(model_config, "async_chunk", False) - self._model_mode: str = getattr(model_config, "worker_type", "ar") - stage_id = getattr(model_config, "stage_id", 0) - if isinstance(stage_id, str): - stage_id = int(stage_id) - self._stage_id: int = stage_id if isinstance(stage_id, int) else 0 - - self._custom_process_func_path, self._custom_process_func = self._load_custom_func(model_config) - self._custom_process_supports_is_finished = self._custom_process_supports_is_finished_kwarg() - logger.debug( - "[Stage-%s] init_omni_connectors: async_chunk=%s, custom_process_func=%s, connector=%s, func_path=%s", - self._stage_id, - self._async_chunk, - self._custom_process_func, - type(self._omni_connector).__name__ if self._omni_connector else None, - self._custom_process_func_path, - ) - - # -- next stage ID (from connector config or default stage_id + 1) -- - self._next_stage_id: int = self._resolve_next_stage_id(model_config) - - # -- heterogeneous TP rank support -- - rank_cfg = self._parse_rank_mapping(model_config) - if self._kv_transfer_manager is not None: - topology = getattr(self._kv_transfer_manager, "tp_topology", None) - effective_mapping = ( - getattr(topology, "source_tp_size", None), - getattr(topology, "target_tp_size", None), - getattr(topology, "local_rank", None), - ) - if all(isinstance(value, int) for value in effective_mapping): - rank_cfg = { - "from_tp": effective_mapping[0], - "to_tp": effective_mapping[1], - "local_rank": effective_mapping[2], - } - self._from_tp: int = rank_cfg["from_tp"] - self._to_tp: int = rank_cfg["to_tp"] - self._local_rank: int = rank_cfg["local_rank"] - if self._kv_transfer_manager is not None: - self._kv_transfer_manager.kv_send_key_builder = self.get_rank_aware_kv_send_keys - self._kv_transfer_manager.kv_recv_key_builder = self.get_rank_aware_kv_keys - self._kv_transfer_manager.kv_payload_merger = self._merge_rank_sharded_kv_payloads - self._kv_transfer_manager.kv_payload_slicer = self._slice_rank_sharded_kv_payload - - # -- chunk index tracking (ported from OmniChunkTransferAdapter) -- - self._put_req_chunk: dict[str, int] = defaultdict(int) - self._get_req_chunk: dict[str, int] = defaultdict(int) - # Segment-local chunk counter: incremented alongside _put_req_chunk - # and popped at request cleanup. Note: the mixin path (uniproc mode) - # does not have segment boundary infrastructure; multi-segment support - # is only available via chunk_transfer_adapter (distributed path). - self._ramp_chunk_count: dict[str, int] = defaultdict(int) - self._adaptive_states: dict[str, Any] = {} - # Send-side async accumulation / staging buffer. Receive-side payload - # ownership lives in ``_local_stage_payload_cache``. - self._send_side_request_payload: dict[str, dict[str, Any]] = {} - self._code_prompt_token_ids: dict[str, list[list[int]]] = defaultdict(list) - self._cached_ic: dict[str, int] = {} - self._request_ids_mapping: dict[str, str] = {} - - # -- async I/O state (shared by chunk + full_payload_mode) -- - self._pending_load_reqs: dict[str, Any] = {} - self._finished_load_reqs: set[str] = set() - self._pending_save_reqs: dict[str, deque] = {} - self._pending_save_counts: dict[str, int] = defaultdict(int) - self._deferred_send_cleanup: set[str] = set() - # -- per-cycle output accumulator -- - self._chunk_ready_req_ids: set[str] = set() - self._chunk_finished_req_ids: set[str] = set() - self._stage_recv_req_ids: set[str] = set() - self._full_payload_pending_broadcast_req_ids: set[str] = set() - self._async_chunk_updated_req_ids: set[str] = set() - - # -- Model Runner local payload cache (RFC §2.4) -- - # Full stage payloads land here first on the recv side. We - # intentionally do not write connector recv results straight into - # `model_intermediate_buffer`: runner-owned runtime state is - # materialized later by `_sync_local_stage_payloads()` on the - # model thread. This keeps recv timing separate from execute-step - # visibility and avoids mixing connector I/O with model runtime - # ownership. - self._local_stage_payload_cache: dict[str, dict[str, Any]] = {} - # Lightweight scheduling metadata pending delivery to the Scheduler. - self._local_request_metadata: dict[str, dict[str, Any]] = {} - - # -- persistent set of request IDs whose chunk stream is complete -- - # Prevents re-registration after the finish sentinel has been received. - self._chunk_stream_completed: set[str] = set() - - # -- full_payload_mode: accumulate latest pooler_output per request, - # send only when the request finishes (next-cycle flush) -- - self._pending_full_payload_send: dict[str, tuple[Any, ...]] = {} - - # -- KV sent accumulator -- - self._kv_sent_req_ids: list[str] = [] - - # -- KV transfer lifecycle (absorbed from scheduler) -- - # Requests marked for KV transfer: {req_id: {seq_len, block_ids}} - self._kv_pending_transfers: dict[str, dict[str, Any]] = {} - # Requests whose KV transfer has been submitted but not yet acked - self._kv_active_transfers: set[str] = set() - # Requests whose KV transfer is complete (acked by kv_extracted_req_ids) - self._kv_completed_transfers: set[str] = set() - # Dedup guard: requests that have already triggered KV transfer - self._kv_triggered_requests: set[str] = set() - - self._lock = threading.Lock() - self._stop_event = threading.Event() - self._work_available = threading.Event() - - # Start background threads only when there's a connector - self._recv_thread: threading.Thread | None = None - self._save_thread: threading.Thread | None = None - if self._omni_connector is not None: - self._recv_thread = threading.Thread( - target=self._recv_loop, - daemon=True, - name="omni-mixin-recv", - ) - self._recv_thread.start() - self._save_thread = threading.Thread( - target=self._save_loop, - daemon=True, - name="omni-mixin-save", - ) - self._save_thread.start() - - # Explicit "fully initialised" marker so other parts of the runner - # (e.g. _update_states cleanup) can branch on a stable contract - # instead of probing for private mixin attribute names. Must be set - # only after every field above has been bound, so a partially - # constructed mixin is never observable as initialised. - self._omni_connector_initialized = True - - def shutdown_omni_connectors(self) -> None: - """Stop background threads and release connector resources.""" - self._stop_event.set() - if self._recv_thread is not None: - self._recv_thread.join(timeout=5) - if self._save_thread is not None: - self._save_thread.join(timeout=5) - if self._omni_connector is not None: - try: - self._omni_connector.close() - except Exception: - pass - - def cleanup_finished_request(self, req_id: str) -> None: - """Clean up per-request state after a request is fully finished. - - Call this when a request is freed from the model runner to prevent - memory leaks in the mixin's tracking dicts/sets. - - Two senders use different keys: ``send_chunk`` keys per-request - state under the EXTERNAL id (after mapping resolution), while - ``send_full_payload_outputs`` keys under the INTERNAL id. To cover - both modes (and forward compat with id-rename scenarios) we attempt - cleanup against both keys; the entry that doesn't exist for the - active mode is a no-op pop. Only the key that actually has pending - saves is added to ``_deferred_send_cleanup`` so the bg save's - decrement path drains it without leaving orphans. - """ - # Force-flush any pending full-payload accumulator entry before - # cleanup proceeds. Without this, finished requests with no - # downstream consumer (e.g. text-only on multi-modal arch) leave - # the entry orphaned in _pending_full_payload_send across requests, - # which empirically destabilises subsequent thinker forwards by - # making prefix-cache reuse observe stale accumulator state. The - # flush is idempotent when the entry has already been flushed by the - # scheduler-driven path, but this cleanup path runs for every request, - # so skip it entirely when the request never accumulated a payload. - if req_id in self._pending_full_payload_send: - try: - self.flush_full_payload_outputs({req_id}) - except Exception: - # Cleanup must still proceed regardless of flush errors here -- - # we already gated on ``_omni_connector_initialized`` upstream, - # so any exception here reflects a real connector-side issue - # (shared memory corruption, background thread crash) worth - # surfacing rather than silently swallowing. - logger.warning( - "flush_full_payload_outputs(%s) raised during cleanup; continuing tear-down.", - req_id, - exc_info=True, - ) - - ext_id = self._request_ids_mapping.pop(req_id, None) - keys_to_clean: list[str] = [req_id] - if ext_id is not None and ext_id != req_id: - keys_to_clean.append(ext_id) - - with self._lock: - keys_pending = [k for k in keys_to_clean if self._pending_save_counts.get(k, 0)] - for k in keys_pending: - self._deferred_send_cleanup.add(k) - for k in keys_to_clean: - if k in keys_pending: - continue - self._put_req_chunk.pop(k, None) - self._send_side_request_payload.pop(k, None) - self._code_prompt_token_ids.pop(k, None) - self._cached_ic.pop(k, None) - self._ramp_chunk_count.pop(k, None) - self._adaptive_states.pop(k, None) - self._kv_pending_transfers.pop(req_id, None) - self._kv_active_transfers.discard(req_id) - self._kv_completed_transfers.discard(req_id) - self._kv_triggered_requests.discard(req_id) - self._cleanup_recv_delivery_state(req_id) - - def drop_inactive_request_delivery_state(self, req_id: str) -> None: - """Clear recv-side state for inactive requests.""" - ext_id = self._request_ids_mapping.pop(req_id, None) - if hasattr(self, "_lock"): - with self._lock: - self._drop_send_side_payload_state(req_id, ext_id) - else: - self._drop_send_side_payload_state(req_id, ext_id) - self._cleanup_recv_delivery_state(req_id) - - def _drop_send_side_payload_state(self, req_id: str, ext_id: str | None) -> None: - if ext_id is not None: - self._send_side_request_payload.pop(ext_id, None) - self._cached_ic.pop(ext_id, None) - self._send_side_request_payload.pop(req_id, None) - self._cached_ic.pop(req_id, None) - - def _cleanup_recv_delivery_state(self, req_id: str) -> None: - """Clear recv-side delivery-cycle state.""" - if hasattr(self, "_lock"): - with self._lock: - self._clear_recv_delivery_state(req_id) - else: - self._clear_recv_delivery_state(req_id) - - def _clear_recv_delivery_state(self, req_id: str) -> None: - self._get_req_chunk.pop(req_id, None) - self._pending_load_reqs.pop(req_id, None) - self._finished_load_reqs.discard(req_id) - self._chunk_ready_req_ids.discard(req_id) - self._chunk_finished_req_ids.discard(req_id) - self._chunk_stream_completed.discard(req_id) - self._stage_recv_req_ids.discard(req_id) - self._full_payload_pending_broadcast_req_ids.discard(req_id) - self._async_chunk_updated_req_ids.discard(req_id) - self._local_stage_payload_cache.pop(req_id, None) - self._local_request_metadata.pop(req_id, None) - - def prune_inactive_requests(self, active_req_ids: Any) -> set[str]: - """Drop connector state for requests that no longer exist locally. - - Preempted / unscheduled requests are expected to stay in - ``self.requests`` and therefore remain untouched. This only prunes - stale request IDs that have already fallen out of the active request - map, preventing background recv/send bookkeeping from outliving the - request lifecycle. - """ - if active_req_ids is None: - return set() - - active_req_ids = set(active_req_ids) - pending_req_ids = set(getattr(self, "_pending_load_reqs", {}).keys()) - received_req_ids = set(getattr(self, "_stage_recv_req_ids", set())) - received_req_ids.update(getattr(self, "_full_payload_pending_broadcast_req_ids", set())) - received_req_ids.update(getattr(self, "_local_request_metadata", {}).keys()) - # Pending recv requests may not yet be in the caller's active set - # (e.g. WAITING_FOR_CHUNK requests live in the coordinator's internal - # queues, not in model runner self.requests). Protect them so that - # legitimate waiting requests are not pruned. - # - # Likewise, a full payload can arrive on the background recv thread - # after the scheduler_output snapshot for the current execute_model() - # cycle was already materialized. Those requests may briefly live only - # in recv-side buffers/local cache until the next scheduler cycle wakes - # them up; pruning them here drops the payload before stage_recv can be - # published. - active_req_ids.update(pending_req_ids) - active_req_ids.update(received_req_ids) - stale_req_ids: set[str] = set() - - # NOTE: _pending_load_reqs is excluded from the scan list because - # all its entries are unconditionally protected above. The mixin - # cannot distinguish a legitimately-waiting pending recv from an - # orphaned one (only the coordinator/scheduler knows). - # - # Requests with freshly received full payloads / local stage payloads - # are also protected above. Their scheduler wake-up may lag the recv - # thread by one execute_model() cycle, especially when the request was - # added after the current scheduler_output snapshot. - # - # Orphaned pending recv entries (e.g. from upstream stage crash) are - # handled by collect_timed_out_request_ids() -- on - # OmniSchedulingCoordinator for full-payload requests, and on - # OmniChunkTransferAdapter for async-chunk ones -- which detect - # wait-time violations. The scheduler then removes the request from - # its queues, sets FINISHED_ERROR, and calls _free_request() which - # ultimately triggers cleanup_finished_request() here. - for attr_name in ( - "_request_ids_mapping", - "_get_req_chunk", - "_finished_load_reqs", - "_chunk_ready_req_ids", - "_chunk_finished_req_ids", - "_chunk_stream_completed", - "_stage_recv_req_ids", - "_full_payload_pending_broadcast_req_ids", - "_async_chunk_updated_req_ids", - "_local_stage_payload_cache", - "_local_request_metadata", - "_kv_pending_transfers", - "_kv_active_transfers", - "_kv_completed_transfers", - "_kv_triggered_requests", - ): - state = getattr(self, attr_name, None) - if isinstance(state, dict): - stale_req_ids.update(req_id for req_id in state if req_id not in active_req_ids) - elif isinstance(state, set): - stale_req_ids.update(req_id for req_id in state if req_id not in active_req_ids) - - for req_id in stale_req_ids: - self.cleanup_finished_request(req_id) - - return stale_req_ids - - # ------------------------------------------------------------------ # - # Local payload cache (RFC §2.4 – Model Runner ownership) - # ------------------------------------------------------------------ # - - def put_local_stage_payload(self, req_id: str, payload: OmniPayload) -> None: - """Store a full stage payload in the local cache.""" - self._local_stage_payload_cache[req_id] = payload - - def get_local_stage_payload(self, req_id: str) -> OmniPayload | None: - """Read a stage payload without removing it.""" - return self._local_stage_payload_cache.get(req_id) - - def pop_local_stage_payload(self, req_id: str) -> OmniPayload | None: - """Remove and return a stage payload (consume after use).""" - return self._local_stage_payload_cache.pop(req_id, None) - - def put_local_request_metadata(self, req_id: str, metadata: dict[str, Any]) -> None: - """Store lightweight scheduling metadata for a request.""" - self._local_request_metadata[req_id] = metadata - - def get_local_request_metadata(self, req_id: str) -> dict[str, Any] | None: - """Retrieve scheduling metadata for a request.""" - return self._local_request_metadata.get(req_id) - - # ------------------------------------------------------------------ # - # Scheduling metadata extraction - # ------------------------------------------------------------------ # - - @classmethod - def _extract_scheduling_metadata(cls, payload: OmniPayload) -> dict[str, Any]: - """Extract only the fields the scheduler needs from a full payload.""" - extracted: dict[str, Any] = {} - meta = payload.get("meta") if isinstance(payload, dict) else None - meta = meta if isinstance(meta, dict) else {} - - if "next_stage_prompt_len" in meta: - extracted["next_stage_prompt_len"] = meta["next_stage_prompt_len"] - elif "next_stage_prompt_len" in payload: - logger.warning_once( - "legacy flat 'next_stage_prompt_len' key in payload; expected 'meta.next_stage_prompt_len'" - ) - extracted["next_stage_prompt_len"] = payload["next_stage_prompt_len"] - - audio_codes = cls._payload_audio_codes(payload) - if audio_codes is not None: - extracted["code_predictor_codes"] = audio_codes - - if "left_context_size" in meta: - extracted["left_context_size"] = meta["left_context_size"] - elif "left_context_size" in payload: - logger.warning_once("legacy flat 'left_context_size' key in payload; expected 'meta.left_context_size'") - - return extracted - - _NON_CONSUMABLE_PAYLOAD_KEYS: set[tuple[str, str]] = { - ("meta", "finished"), - ("meta", "override_keys"), - ("meta", "next_stage_prompt_len"), - ("meta", "left_context_size"), - ("ids", "output"), - ("embed", "decode_token_start"), - ("embed", "decode_token_end"), - } - - @staticmethod - def _payload_value_has_content(value: Any) -> bool: - if value is None: - return False - if isinstance(value, torch.Tensor): - return value.numel() > 0 - if isinstance(value, (list, tuple, dict, set)): - return len(value) > 0 - return True - - @staticmethod - def _payload_finished(payload: Any) -> bool: - if not isinstance(payload, dict): - return False - if "finished" in payload: - logger.warning_once("legacy flat 'finished' key in payload; expected 'meta.finished'") - meta = payload.get("meta") - if not isinstance(meta, dict) or "finished" not in meta: - return False - flag = meta["finished"] - if isinstance(flag, torch.Tensor): - return flag.numel() == 1 and bool(flag.item()) - return bool(flag) - - @staticmethod - def _payload_audio_codes(payload: Any) -> Any: - if not isinstance(payload, dict): - return None - if "code_predictor_codes" in payload: - logger.warning_once("legacy flat 'code_predictor_codes' key in payload; expected 'codes.audio'") - codes = payload.get("codes") - if isinstance(codes, dict): - return codes.get("audio") - return None - - @classmethod - def _payload_is_consumable(cls, payload: OmniPayload | None) -> bool: - """Return True when an async payload can drive a real forward step. - - Metadata-only wake-ups should not transition WAITING_FOR_CHUNK requests - back to schedulable state. In particular, a widened token horizon without - any newly visible thinker decode embeds should not force a placeholder-only - talker decode step. - """ - if not isinstance(payload, dict) or not payload: - return False - - embed = payload.get("embed") - if isinstance(embed, dict): - decode_embeddings = embed.get("decode") - if isinstance(decode_embeddings, torch.Tensor): - if decode_embeddings.ndim == 0: - return True - return decode_embeddings.numel() > 0 and decode_embeddings.shape[0] > 0 - - audio_codes = cls._payload_audio_codes(payload) - if audio_codes is not None: - if isinstance(audio_codes, torch.Tensor): - return audio_codes.numel() > 0 - if hasattr(audio_codes, "__len__"): - return len(audio_codes) > 0 - return True - - for key, value in payload.items(): - if isinstance(value, dict): - for sk, sv in value.items(): - if (key, sk) in cls._NON_CONSUMABLE_PAYLOAD_KEYS: - continue - if cls._payload_value_has_content(sv): - return True - continue - if cls._payload_value_has_content(value): - return True - return False - - @staticmethod - def _get_local_tp_group() -> Any | None: - """Return the local TP group when tensor parallelism is initialized.""" - try: - return get_tp_group() - except Exception: - return None - - def _recv_ordinary_stage_result( - self, - connector: OmniConnectorBase, - from_stage: str, - to_stage: str, - connector_get_key: str, - ) -> Any: - """Receive one ordinary non-KV stage payload on the local leader rank only.""" - tp_group = self._get_local_tp_group() - if tp_group is None or getattr(tp_group, "world_size", 1) <= 1: - return connector.get(from_stage, to_stage, connector_get_key) - if not self.is_data_transfer_rank(): - return None - return connector.get(from_stage, to_stage, connector_get_key) - - def _recv_full_payload_result( - self, - connector: OmniConnectorBase, - from_stage: str, - to_stage: str, - connector_get_key: str, - ) -> Any: - """Receive one full-payload transfer on the local leader rank only.""" - return self._recv_ordinary_stage_result( - connector, - from_stage, - to_stage, - connector_get_key, - ) - - def _recv_async_chunk_result( - self, - connector: OmniConnectorBase, - from_stage: str, - to_stage: str, - connector_get_key: str, - ) -> Any: - """Receive one ordinary async chunk on the local leader rank only.""" - return self._recv_ordinary_stage_result( - connector, - from_stage, - to_stage, - connector_get_key, - ) - - @staticmethod - def _snapshot_payload(payload: Any) -> Any: - if isinstance(payload, dict): - return dict(payload) - return payload - - def _broadcast_tp_payload_packet(self, packet: Any) -> Any: - """Broadcast one ordinary payload packet from TP rank 0 when TP is active.""" - tp_group = self._get_local_tp_group() - if tp_group is None or getattr(tp_group, "world_size", 1) <= 1: - return packet - leader_packet = packet if self.is_data_transfer_rank() else None - return tp_group.broadcast_object(leader_packet, src=0) - - def _apply_staged_payloads_locked(self, staged_payloads: dict[str, Any]) -> None: - for req_id, payload in staged_payloads.items(): - self._local_stage_payload_cache[req_id] = self._snapshot_payload(payload) - - def _collect_full_payload_results_locked(self) -> dict[str, Any] | None: - if not self._full_payload_pending_broadcast_req_ids: - return None - results: dict[str, Any] = {} - missing_req_ids: list[str] = [] - for req_id in tuple(self._full_payload_pending_broadcast_req_ids): - payload = self._local_stage_payload_cache.get(req_id) - if payload is None: - missing_req_ids.append(req_id) - continue - results[req_id] = self._snapshot_payload(payload) - self._full_payload_pending_broadcast_req_ids.discard(req_id) - if missing_req_ids: - logger.warning( - "[Stage-%s] _collect_full_payload_results_locked: " - "pending full-payload reqs missing from local cache: %s", - self._stage_id, - missing_req_ids, - ) - return results or None - - def _collect_async_chunk_fanout_packet_locked(self) -> dict[str, Any] | None: - payload_req_ids = set(self._async_chunk_updated_req_ids) - payload_req_ids.update(self._finished_load_reqs) - payload_req_ids.update(self._chunk_finished_req_ids) - payload_req_ids.update(self._local_request_metadata) - if not ( - payload_req_ids or self._finished_load_reqs or self._chunk_finished_req_ids or self._local_request_metadata - ): - return None - - staged_payloads = { - req_id: self._snapshot_payload(self._local_stage_payload_cache[req_id]) - for req_id in payload_req_ids - if req_id in self._local_stage_payload_cache - } - packet = { - "staged_payloads": staged_payloads, - "request_metadata": dict(self._local_request_metadata), - "newly_finished": set(self._finished_load_reqs), - "chunk_finished": set(self._chunk_finished_req_ids), - } - - self._async_chunk_updated_req_ids.clear() - self._finished_load_reqs.clear() - self._chunk_finished_req_ids.clear() - self._local_request_metadata.clear() - - for req_id in packet["chunk_finished"]: - if req_id not in self._local_stage_payload_cache: - continue - ext_req_id = self._request_ids_mapping.get(req_id, req_id) - self._send_side_request_payload.pop(ext_req_id, None) - if ext_req_id != req_id: - self._send_side_request_payload.pop(req_id, None) - - return packet - - def _apply_async_chunk_fanout_packet(self, packet: dict[str, Any]) -> None: - staged_payloads = packet.get("staged_payloads", {}) - chunk_finished = set(packet.get("chunk_finished", ())) - with self._lock: - self._apply_staged_payloads_locked(staged_payloads) - for req_id in chunk_finished: - self._pending_load_reqs.pop(req_id, None) - self._chunk_stream_completed.add(req_id) - - # ------------------------------------------------------------------ # - # full_payload_mode (recv_full_payload_inputs / send_full_payload_outputs) - # ------------------------------------------------------------------ # - - def recv_full_payload_inputs(self, scheduler_output: Any) -> dict[str, Any] | None: - """Check for incoming full_payload_mode stage inputs (non-blocking). - - Returns a dict mapping ``request_id -> engine_inputs`` for data - that has arrived, or ``None`` if nothing is ready. Stores full - payloads in the local cache and extracts scheduling metadata. - """ - # Fast path: when TP is trivial (no peer ranks waiting on a broadcast) - # and the bg recv thread has not staged anything, skip the lock + TP - # broadcast cycle entirely. _broadcast_tp_payload_packet already - # returns its input unchanged under the same world_size<=1 condition, - # so the original code path was a no-op here on every empty step. - tp_group = self._get_local_tp_group() - if ( - tp_group is None or getattr(tp_group, "world_size", 1) <= 1 - ) and not self._full_payload_pending_broadcast_req_ids: - return None - with self._lock: - results = self._collect_full_payload_results_locked() if self.is_data_transfer_rank() else None - results = self._broadcast_tp_payload_packet(results) - if not results: - return None - with self._lock: - self._stage_recv_req_ids.update(results.keys()) - for req_id in results: - self._pending_load_reqs.pop(req_id, None) - self._apply_staged_payloads_locked(results) - for req_id, payload in results.items(): - self._local_request_metadata[req_id] = self._extract_scheduling_metadata(payload) - logger.debug( - "[Stage-%s] recv_full_payload_inputs: consumed %s reqs: %s, stage_recv_req_ids now=%s", - self._stage_id, - len(results), - list(results.keys()), - self._stage_recv_req_ids, - ) - return results - - def _get_model_config(self) -> Any: - model_config = getattr(self, "model_config", None) - if model_config is not None: - return model_config - return getattr(getattr(self, "vllm_config", None), "model_config", None) - - def _should_accumulate_full_payload_output(self) -> bool: - """Gate send-side full-payload output accumulation only. - - Cached per instance: the result depends only on model_config / - _custom_process_func, both of which are set at init time. Avoid - the per-step dynamic import inside the model decode loop. - """ - if getattr(self, "_omni_connector", None) is None: - # No connector at all: send_full_payload_outputs would no-op. - # Skip the per-step accumulator+build that would otherwise be - # silently discarded. Defends against a terminal stage whose - # custom_process_input_func has a *_full_payload derivative in - # the same module (e.g. dynin stage 2 token2image_to_token2audio - # in pipelines that don't configure any connector at all). - # - # Known limitation: a *terminal-consumer* stage that has a - # connector configured for receiving upstream input is NOT - # caught here -- ``_omni_connector`` is non-None for it, and - # ``_load_custom_func`` may still resolve a ``*_full_payload`` - # derivative from this stage's ``custom_process_input_func``. - # In that case the accumulator builds payloads that - # ``send_full_payload_outputs`` later drops via its own - # connector-side checks (wasted CPU, not a functional bug). - # A topology-aware gate (explicit producer field or pipeline - # is_terminal info) would close the gap; that change is out - # of scope for this PR. - self._should_accumulate_full_payload_output_cached = False - return False - cached = getattr(self, "_should_accumulate_full_payload_output_cached", None) - if cached is not None: - return cached - model_config = self._get_model_config() - if model_config is None: - self._should_accumulate_full_payload_output_cached = False - return False - result = should_accumulate_full_payload_output( - model_config, - getattr(self, "_custom_process_func", None), - ) - self._should_accumulate_full_payload_output_cached = result - return result - - @staticmethod - def _new_full_payload_accumulator(output: dict[str, Any]): - chunks: dict[str, list[torch.Tensor]] = {} - latest: dict[str, Any] = {} - rows: dict[str, int] = {} - for k, v in output.items(): - if isinstance(v, torch.Tensor) and v.dim() >= 2: - chunks[k] = [v] - rows[k] = int(v.shape[0]) - else: - latest[k] = v - return chunks, latest, rows - - @staticmethod - def _materialize_full_payload_entry(entry): - if len(entry) == 2: - return entry - chunks, latest, _rows, request = entry - output = dict(latest) - for k, tensors in chunks.items(): - if tensors: - output[k] = tensors[0] if len(tensors) == 1 else torch.cat(tensors, dim=0) - return output, request - - def _resolve_full_payload_replace_keys(self) -> frozenset: - """Per-model REPLACE-key set for the full-payload accumulator. - - Looked up from the stage-input-processor module that ships the model's sync builder - (`model_config.custom_process_input_func.__module__`). The module - declares ``_FULL_PAYLOAD_REPLACE_KEYS: frozenset[str]``; if absent, - returns the empty set. - - Cached per instance. Keys in this set use REPLACE semantics in the - accumulator (subsequent emissions discard prior chunks) instead of - the default CONCAT semantics. Use for tensors that carry the full - result so far rather than per-step deltas (e.g. ``model_outputs``). - """ - cached = getattr(self, "_full_payload_replace_keys_cached", None) - if cached is not None: - return cached - proc = getattr(self, "_custom_process_func", None) - if proc is None: - self._full_payload_replace_keys_cached = frozenset() - return self._full_payload_replace_keys_cached - module_name = getattr(proc, "__module__", None) - if module_name is None: - self._full_payload_replace_keys_cached = frozenset() - return self._full_payload_replace_keys_cached - try: - import sys as _sys - - mod = _sys.modules.get(module_name) or importlib.import_module(module_name) - keys = getattr(mod, "_FULL_PAYLOAD_REPLACE_KEYS", frozenset()) - except ImportError: - logger.debug( - "Could not import stage input processor module %s while resolving " - "_FULL_PAYLOAD_REPLACE_KEYS; using CONCAT semantics for all keys.", - module_name, - exc_info=True, - ) - keys = frozenset() - if not isinstance(keys, (frozenset, set)): - logger.debug( - "Ignoring non-set _FULL_PAYLOAD_REPLACE_KEYS from %s: %s", - module_name, - type(keys).__name__, - ) - keys = frozenset() - self._full_payload_replace_keys_cached = frozenset(keys) - logger.debug( - "Resolved _FULL_PAYLOAD_REPLACE_KEYS for %s: %s", - module_name, - sorted(self._full_payload_replace_keys_cached), - ) - return self._full_payload_replace_keys_cached - - def accumulate_full_payload_output( - self, - req_id: str, - pooler_output: Any, - request: Any, - ) -> None: - """Accumulate pooler_output for a request across steps (full_payload_mode). - - Per-token tensors (2-D+, matching trailing dims) are concatenated - along dim-0. Scalar / global tensors (1-D or 0-D) are replaced - with the latest value. - - Note: codec rows are NOT filtered for zero placeholders here. The - downstream consumer ``_extract_qwen3_full_payload_codec_rows`` crops - codec rows using ``output_token_ids`` as the authoritative source, - which makes any sender-side zero filtering redundant. Skipping the - sender-side ``t.any()`` scan also avoids a per-tensor GPU->CPU device - sync that stalled the decode pipeline. - - The data is actually sent when ``flush_full_payload_outputs`` is called - with the finished request IDs from the next scheduler cycle. - """ - replace_keys = self._resolve_full_payload_replace_keys() - existing = self._pending_full_payload_send.get(req_id) - - if existing is None: - chunks, latest, rows = self._new_full_payload_accumulator(pooler_output) - self._pending_full_payload_send[req_id] = (chunks, latest, rows, request) - return - - if len(existing) == 2: - chunks, latest, rows = self._new_full_payload_accumulator(existing[0]) - else: - chunks, latest, rows, _ = existing - - for k, v in pooler_output.items(): - if v is None: - continue - if k in replace_keys: - # Explicit REPLACE semantics: the new value supersedes any - # prior chunks (e.g. `model_outputs` carries the full result - # so far, not an appendable per-step delta). - latest.pop(k, None) - if isinstance(v, torch.Tensor) and v.dim() >= 2: - chunks[k] = [v] - rows[k] = int(v.shape[0]) - else: - chunks.pop(k, None) - rows.pop(k, None) - latest[k] = v - continue - if isinstance(v, torch.Tensor) and v.dim() >= 2: - if k in chunks and chunks[k] and v.shape[1:] == chunks[k][0].shape[1:]: - chunks[k].append(v) - rows[k] += int(v.shape[0]) - else: - latest.pop(k, None) - chunks[k] = [v] - rows[k] = int(v.shape[0]) - else: - chunks.pop(k, None) - rows.pop(k, None) - latest[k] = v - - self._pending_full_payload_send[req_id] = (chunks, latest, rows, request) - - def flush_full_payload_outputs(self, finished_req_ids: set[str]) -> None: - """Send accumulated full_payload outputs for requests that just finished.""" - pending_req_ids = set(self._pending_full_payload_send.keys()) - if not (finished_req_ids & pending_req_ids): - return - - logger.debug( - "[Stage-%s] flush_full_payload_outputs: finished_req_ids=%s, pending=%s", - self._stage_id, - finished_req_ids, - list(self._pending_full_payload_send.keys()), - ) - to_send: dict[str, tuple[Any, Any]] = {} - for req_id in finished_req_ids: - entry = self._pending_full_payload_send.pop(req_id, None) - if entry is not None: - to_send[req_id] = self._materialize_full_payload_entry(entry) - logger.debug("[Stage-%s] flush_full_payload_outputs: to_send=%s", self._stage_id, list(to_send.keys())) - if to_send: - self.send_full_payload_outputs(scheduler_output=None, outputs=to_send) - - def send_full_payload_outputs( - self, - scheduler_output: Any, - outputs: dict[str, tuple[Any, Any] | Any], - ) -> list[str]: - """Send full_payload stage outputs to the next stage via connector. - - Args: - outputs: Mapping of ``req_id`` to either a - ``(pooling_output, request)`` tuple (preferred) or a raw - payload dict. When a tuple is supplied the request object - is forwarded to ``custom_process_stage_input_func``. - - Returns list of request IDs successfully enqueued. - """ - if self._omni_connector is None: - logger.debug("[Stage-%s] send_full_payload_outputs: connector is None, skip", self._stage_id) - return [] - if not self.is_data_transfer_rank(): - logger.debug( - "[Stage-%s] send_full_payload_outputs: not data_transfer_rank (rank=%s), skip", - self._stage_id, - self._local_rank, - ) - return list(outputs.keys()) - sent_ids: list[str] = [] - next_stage_id = self._next_stage_id - for req_id, value in outputs.items(): - if isinstance(value, tuple) and len(value) == 2: - raw_output, request = value - else: - raw_output, request = value, None - - payload = raw_output - if self._custom_process_func is not None: - payload = self._build_custom_process_payload( - request_id=req_id, - request=request, - pooling_output=raw_output, - ) - if payload is None: - continue - if payload is None: - logger.debug("[Stage-%s] send_full_payload_outputs: payload is None for %s", self._stage_id, req_id) - continue - if isinstance(payload, dict): - audio_codes = self._payload_audio_codes(payload) - if isinstance(audio_codes, torch.Tensor): - code_len = int(audio_codes.numel()) - elif hasattr(audio_codes, "__len__"): - code_len = len(audio_codes) - else: - code_len = None - meta = payload.get("meta") if isinstance(payload.get("meta"), dict) else {} - logger.debug( - "[Stage-%s] send_full_payload_outputs: req=%s payload_keys=%s code_len=%s left_context_size=%s", - self._stage_id, - req_id, - sorted(payload.keys()), - code_len, - meta.get("left_context_size"), - ) - - external_req_id = self._resolve_external_req_id(request, req_id) - chunk_id = self._put_req_chunk[req_id] - self._put_req_chunk[req_id] += 1 - self._ramp_chunk_count[req_id] += 1 - connector_put_key = f"{external_req_id}_{self._stage_id}_{chunk_id}" - - logger.debug( - "[Stage-%s] send_full_payload_outputs: enqueue req=%s put_key=%s next_stage=%s", - self._stage_id, - req_id, - connector_put_key, - next_stage_id, - ) - task = { - "stage_id": self._stage_id, - "next_stage_id": next_stage_id, - "put_key": connector_put_key, - "data": payload, - "request_id": req_id, - } - with self._lock: - self._pending_save_reqs.setdefault(req_id, deque()).append(task) - self._pending_save_counts[req_id] += 1 - sent_ids.append(req_id) - if sent_ids: - self._work_available.set() - return sent_ids - - # ------------------------------------------------------------------ # - # Streaming chunk mode (recv_chunk / send_chunk) - # ------------------------------------------------------------------ # - - def register_chunk_recv(self, request: Any) -> None: - """Register a request for async chunk retrieval by the bg thread. - - Stage-0 has no upstream producer so this is a no-op there. - Skips requests whose batch data has already been received to - prevent the bg thread from polling for non-existent chunks. - """ - if self._stage_id == 0: - return - request_id = request.request_id - # Explicit external_req_id=None must fall back to request_id; - # otherwise recv keys become `None__` and collide - # across requests. - ext = getattr(request, "external_req_id", None) - self._request_ids_mapping[request_id] = ext if ext is not None else request_id - with self._lock: - if request_id in self._stage_recv_req_ids: - return - # Don't re-register if the finish sentinel was already received - if request_id in self._chunk_stream_completed: - return - self._pending_load_reqs[request_id] = request - self._work_available.set() - - def recv_chunk(self) -> dict[str, Any]: - """Collect chunks received by the bg thread since last call. - - Returns a dict ``{request_id: chunk_payload}`` for newly arrived - chunks. Empty dict when nothing is ready. - - This method reads from ``_finished_load_reqs`` without clearing - it -- ``get_omni_connector_output()`` is the sole consumer that - drains and resets ``_finished_load_reqs`` at the end of each - ``execute_model`` cycle. - - Returns **shallow copies** of the cached payloads so that the - caller can read them without racing against the background recv - thread, which may concurrently mutate the live cache entries via - ``dict.update()``. - """ - with self._lock: - finished = set(self._finished_load_reqs) - if not finished: - return {} - # Snapshot the payloads under the lock to avoid racing with - # _poll_single_request which does existing.update(payload_data) - # on the same dict objects. - result = {} - for rid in finished: - payload = self._local_stage_payload_cache.get(rid) - result[rid] = dict(payload) if isinstance(payload, dict) else payload - - self._chunk_ready_req_ids.update(finished) - return result - - def send_chunk( - self, - request: Any, - pooling_output: Any | None = None, - ) -> bool: - """Derive and enqueue one chunk for async sending. - - Payload extraction runs in the caller thread (via - ``custom_process_stage_input_func``); the actual - ``connector.put()`` is done by the background save thread. - Non-KV data is identical across TP ranks; only rank 0 sends. - """ - if self._omni_connector is None: - logger.warning("[Stage-%s] send_chunk: connector is None", self._stage_id) - return False - if not self.is_data_transfer_rank(): - return True - raw_req_id = getattr(request, "request_id", None) or getattr(request, "req_id", None) - request_id = self._resolve_external_req_id(request, raw_req_id) - # Cache the internal→external mapping so that finish sentinels can - # resolve the external ID even after the request is freed. - if raw_req_id and raw_req_id != request_id: - self._request_ids_mapping.setdefault(raw_req_id, request_id) - chunk_id = self._put_req_chunk[request_id] - - payload_data = self._build_custom_process_payload( - request_id=request_id, - request=request, - pooling_output=pooling_output, - ) - if payload_data is None: - if chunk_id == 0: - logger.warning( - "[Stage-%s] send_chunk: payload is None for req=%s chunk=%s (process_func=%s)", - self._stage_id, - request_id, - chunk_id, - self._custom_process_func, - ) - return False - - self._put_req_chunk[request_id] += 1 - self._ramp_chunk_count[request_id] += 1 - next_stage_id = self._next_stage_id - connector_put_key = f"{request_id}_{self._stage_id}_{chunk_id}" - - if chunk_id == 0: - logger.debug( - "[Stage-%s] send_chunk: first chunk enqueued, req=%s key=%s", - self._stage_id, - request_id, - connector_put_key, - ) - - task = { - "stage_id": self._stage_id, - "next_stage_id": next_stage_id, - "put_key": connector_put_key, - "data": payload_data, - "request_id": request_id, - } - with self._lock: - self._pending_save_reqs.setdefault(request_id, deque()).append(task) - self._pending_save_counts[request_id] += 1 - self._work_available.set() - return True - - # ------------------------------------------------------------------ # - # KV cache (delegates to OmniKVTransferManager) - # ------------------------------------------------------------------ # - - def send_kv_cache( - self, - finished_reqs: dict[str, dict[str, Any]], - kv_caches: list[torch.Tensor], - block_size: int, - cache_dtype: str, - request_id_resolver: Any | None = None, - ) -> list[str]: - """Send KV cache for finished requests. - - Delegates to the existing ``OmniKVTransferManager``. - """ - if self._kv_transfer_manager is None: - return list(finished_reqs.keys()) if finished_reqs else [] - result = self._kv_transfer_manager.handle_finished_requests_kv_transfer( - finished_reqs=finished_reqs, - kv_caches=kv_caches, - block_size=block_size, - cache_dtype=cache_dtype, - request_id_resolver=request_id_resolver, - ) - if result: - self._kv_sent_req_ids.extend(result) - return result - - def recv_kv_cache( - self, - request_id: str, - target_device: torch.device | None = None, - ) -> tuple[dict[str, Any] | None, int]: - """Receive KV cache for a request. - - Delegates to the existing ``OmniKVTransferManager``. - """ - if self._kv_transfer_manager is None: - return None, 0 - return self._kv_transfer_manager.receive_kv_cache_for_request( - request_id=request_id, - target_device=target_device, - ) - - def receive_cfg_companion_kv_payloads( - self, - cfg_request_ids: dict[str, str], - target_device: torch.device | None = None, - ) -> dict[str, tuple[dict[str, Any] | None, int]]: - """Receive raw CFG companion KV payloads keyed by role.""" - return { - role: self.recv_kv_cache(companion_rid, target_device=target_device) - for role, companion_rid in cfg_request_ids.items() - } - - def receive_multi_kv_cache( - self, - req: Any, - cfg_kv_collect_func: Any | None = None, - target_device: torch.device | None = None, - ) -> bool: - """Receive primary and optional companion KV caches for a request. - - The mixin owns the runner-facing orchestration: primary KV receive, - companion payload fetch, and applying any model-specific CFG fields back - onto ``req.sampling_params``. - """ - if self._kv_transfer_manager is None: - return False - - request_id = getattr(req, "request_id", None) - if not request_id: - logger.warning("Request has no ID, cannot receive KV cache") - return False - - active_requests = getattr(self, "requests", None) - if active_requests is not None and request_id not in active_requests: - logger.debug("Skip receiving KV cache for inactive request %s", request_id) - return False - - primary_ok = False - data, _size = self.recv_kv_cache(request_id, target_device=target_device) - if data: - self._kv_transfer_manager.apply_kv_cache_to_request(req, data) - primary_ok = True - - cfg_ids = getattr(getattr(req, "sampling_params", None), "cfg_kv_request_ids", None) - if cfg_ids and cfg_kv_collect_func: - try: - cfg_role_payloads = self.receive_cfg_companion_kv_payloads( - cfg_ids, - target_device=target_device, - ) - cfg_kvs = cfg_kv_collect_func(request_id, cfg_role_payloads) - if cfg_kvs and hasattr(req, "sampling_params") and req.sampling_params is not None: - for key, value in cfg_kvs.items(): - setattr(req.sampling_params, key, value) - logger.debug("Applied CFG KV caches: %s", list(cfg_kvs.keys())) - except Exception: - logger.exception("Failed to collect CFG KV caches for %s", request_id) - - return primary_ok - - # ------------------------------------------------------------------ # - # Rank-aware KV transfer routing - # ------------------------------------------------------------------ # - - def get_rank_aware_kv_keys( - self, - req_id: str, - from_stage: int, - to_stage: int | None = None, - chunk_id: int = 0, - ) -> list[str]: - """Build recv-side connector keys for all remote ranks this rank needs. - - For heterogeneous TP receive, the local rank is the target rank and must - fetch one or more source-rank shards keyed as ``from_rank -> to_rank``. - """ - if self._from_tp <= 1 and self._to_tp <= 1: - resolved_to_stage = self._next_stage_id if to_stage is None else to_stage - return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"] - - remote_ranks = self.get_kv_remote_ranks() - return [ - self.get_kv_connector_key( - req_id=req_id, - from_stage=from_stage, - chunk_id=chunk_id, - from_rank=remote_rank, - to_rank=self._local_rank, - ) - for remote_rank in remote_ranks - ] - - def get_kv_target_ranks_for_send(self) -> list[int]: - """Determine which target ranks this local rank should send KV shards to.""" - self._validate_kv_tp_topology() - if self._from_tp == self._to_tp: - return [self._local_rank] - if self._from_tp > self._to_tp: - tp_ratio = self._from_tp // self._to_tp - return [self._local_rank // tp_ratio] - tp_ratio = self._to_tp // self._from_tp - base_rank = self._local_rank * tp_ratio - return [base_rank + i for i in range(tp_ratio)] - - def get_rank_aware_kv_send_keys( - self, - req_id: str, - from_stage: int, - to_stage: int | None = None, - chunk_id: int = 0, - ) -> list[str]: - """Build send-side connector keys for this rank's KV shard(s).""" - if self._from_tp <= 1 and self._to_tp <= 1: - resolved_to_stage = self._next_stage_id if to_stage is None else to_stage - return [f"omni_{from_stage}_to_{resolved_to_stage}_kv_cache_{req_id}"] - - target_ranks = self.get_kv_target_ranks_for_send() - return [ - self.get_kv_connector_key( - req_id=req_id, - from_stage=from_stage, - chunk_id=chunk_id, - from_rank=self._local_rank, - to_rank=target_rank, - ) - for target_rank in target_ranks - ] - - @staticmethod - def _merge_rank_sharded_kv_payloads(payloads: list[dict[str, Any]]) -> dict[str, Any] | None: - """Merge multiple source-rank KV shards for one target rank.""" - payloads = [payload for payload in payloads if isinstance(payload, dict)] - if not payloads: - return None - if len(payloads) == 1: - return payloads[0] - - merged = dict(payloads[0]) - layer_blocks = merged.get("layer_blocks") - if not isinstance(layer_blocks, dict): - return merged - - def _merge_tensor_lists(name: str) -> list[torch.Tensor | None]: - merged_list: list[torch.Tensor | None] = [] - cache_lists = [payload.get("layer_blocks", {}).get(name, []) for payload in payloads] - max_len = max((len(cache_list) for cache_list in cache_lists), default=0) - for idx in range(max_len): - tensors = [cache_list[idx] for cache_list in cache_lists if idx < len(cache_list)] - tensors = [tensor for tensor in tensors if isinstance(tensor, torch.Tensor)] - if not tensors: - merged_list.append(None) - elif len(tensors) == 1: - merged_list.append(tensors[0]) - else: - merged_list.append(torch.cat(tensors, dim=-2).contiguous()) - return merged_list - - merged["layer_blocks"] = { - "key_cache": _merge_tensor_lists("key_cache"), - "value_cache": _merge_tensor_lists("value_cache"), - } - metadata = dict(merged.get("metadata", {})) - metadata["merged_remote_rank_count"] = len(payloads) - merged["metadata"] = metadata - return merged - - def _slice_rank_sharded_kv_payload(self, payload: dict[str, Any] | None) -> dict[str, Any] | None: - """Slice a duplicated source-rank KV shard for ``from_tp < to_tp`` cases.""" - if payload is None or self._from_tp >= self._to_tp: - return payload - - tp_ratio = self._to_tp // self._from_tp - shard_index = self._local_rank % tp_ratio - layer_blocks = payload.get("layer_blocks") if isinstance(payload, dict) else None - if not isinstance(layer_blocks, dict): - return payload - - def _slice_tensor_list(name: str) -> list[torch.Tensor | None]: - sliced: list[torch.Tensor | None] = [] - for tensor in layer_blocks.get(name, []): - if not isinstance(tensor, torch.Tensor) or tensor.ndim < 2: - sliced.append(tensor) - continue - head_dim = tensor.shape[-2] - if head_dim % tp_ratio != 0: - sliced.append(tensor) - continue - per_rank = head_dim // tp_ratio - start = shard_index * per_rank - sliced.append(tensor.narrow(-2, start, per_rank).contiguous()) - return sliced - - payload = dict(payload) - payload["layer_blocks"] = { - "key_cache": _slice_tensor_list("key_cache"), - "value_cache": _slice_tensor_list("value_cache"), - } - metadata = dict(payload.get("metadata", {})) - metadata["sliced_for_local_rank"] = self._local_rank - payload["metadata"] = metadata - return payload - - def should_replicate_payload(self) -> bool: - """Whether non-KV payloads should be replicated across ranks. - - Data payloads (stage inputs, chunks) are identical after all-gather, - so only rank 0 transfers them. KV payloads are rank-specific and - all ranks participate. - """ - return self._local_rank != 0 - - def get_kv_rank_mapping(self) -> dict[str, Any]: - """Return the current rank mapping configuration. - - Useful for debugging and for downstream code that needs to know - the TP topology without re-parsing model config. - """ - return { - "from_tp": self._from_tp, - "to_tp": self._to_tp, - "local_rank": self._local_rank, - "remote_ranks": self.get_kv_remote_ranks(), - "is_data_transfer_rank": self.is_data_transfer_rank(), - } - - # ------------------------------------------------------------------ # - # KV transfer lifecycle (RFC – mixin-owned) - # ------------------------------------------------------------------ # - - def mark_kv_transfer( - self, - req_id: str, - seq_len: int, - block_ids: list[int], - custom_metadata: dict[str, Any] | None = None, - ) -> None: - """Mark a request as needing KV cache transfer. - - Called by the scheduler when a transfer trigger fires. The mixin - owns the lifecycle from this point: pending → active → completed. - """ - if req_id in self._kv_pending_transfers: - return - self._kv_triggered_requests.add(req_id) - transfer = { - "seq_len": seq_len, - "block_ids": block_ids, - } - if custom_metadata is not None: - transfer["custom_metadata"] = custom_metadata - self._kv_pending_transfers[req_id] = transfer - - def drain_pending_kv_transfers(self) -> dict[str, dict[str, Any]]: - """Drain pending KV transfers and move them to active. - - Returns ``{req_id: {seq_len, block_ids}}`` for the model runner - to submit to ``send_kv_cache``. - """ - if not self._kv_pending_transfers: - return {} - pending = dict(self._kv_pending_transfers) - self._kv_active_transfers.update(pending.keys()) - self._kv_pending_transfers.clear() - return pending - - def ack_kv_transfers(self, req_ids: list[str] | set[str]) -> None: - """Acknowledge completed KV transfers (from kv_extracted_req_ids). - - Moves requests from active to completed so the scheduler can - safely free their blocks. - """ - for req_id in req_ids: - self._kv_active_transfers.discard(req_id) - self._kv_completed_transfers.add(req_id) - - def drain_completed_kv_transfers(self) -> set[str]: - """Drain and return completed KV transfer request IDs. - - The scheduler calls this to know which requests' blocks can be freed. - """ - completed = set(self._kv_completed_transfers) - self._kv_completed_transfers.clear() - return completed - - def is_kv_transfer_triggered(self, req_id: str) -> bool: - """Check if a request has already triggered KV transfer.""" - return req_id in self._kv_triggered_requests - - def has_pending_kv_work(self) -> bool: - """True if any KV transfers are pending, active, or awaiting ack.""" - return bool(self._kv_pending_transfers or self._kv_active_transfers or self._kv_completed_transfers) - - # Output aggregation - # ------------------------------------------------------------------ # - - def get_omni_connector_output(self) -> OmniConnectorOutput: - """Collect and reset transfer results for this execute_model cycle. - - ``request_metadata`` carries only lightweight scheduling metadata. - Full payloads remain owned by the Model Runner local cache for all - paths. - """ - if not hasattr(self, "_lock"): - return OmniConnectorOutput() - - tp_group = self._get_local_tp_group() - if self._async_chunk and tp_group is not None and getattr(tp_group, "world_size", 1) > 1: - if self.is_data_transfer_rank(): - with self._lock: - fanout_packet = self._collect_async_chunk_fanout_packet_locked() - else: - fanout_packet = None - fanout_packet = self._broadcast_tp_payload_packet(fanout_packet) - if fanout_packet is None: - newly_finished = set() - chunk_finished = set() - request_metadata = {} - else: - if not self.is_data_transfer_rank(): - self._apply_async_chunk_fanout_packet(fanout_packet) - newly_finished = set(fanout_packet["newly_finished"]) - chunk_finished = set(fanout_packet["chunk_finished"]) - request_metadata = dict(fanout_packet["request_metadata"]) - else: - with self._lock: - newly_finished = set(self._finished_load_reqs) - self._finished_load_reqs.clear() - chunk_finished = set(self._chunk_finished_req_ids) - self._chunk_finished_req_ids.clear() - request_metadata = dict(self._local_request_metadata) - self._local_request_metadata.clear() - # _send_side_request_payload is the async accumulation buffer for - # future recv chunks. Clearing it on every consumable wake-up drops - # intermediate - # thinker decode spans before the model side can consume them. - # Only terminal chunk_finished requests may release that buffer. - for req_id in chunk_finished: - if req_id not in self._local_stage_payload_cache: - continue - ext_req_id = self._request_ids_mapping.get(req_id, req_id) - self._send_side_request_payload.pop(ext_req_id, None) - if ext_req_id != req_id: - self._send_side_request_payload.pop(req_id, None) - self._chunk_ready_req_ids.update(newly_finished) - - output = OmniConnectorOutput( - chunk_ready_req_ids=set(self._chunk_ready_req_ids), - chunk_finished_req_ids=chunk_finished, - request_metadata=request_metadata, - kv_sent_req_ids=list(self._kv_sent_req_ids), - stage_recv_req_ids=set(self._stage_recv_req_ids), - has_pending_kv_work=self.has_pending_kv_work(), - ) - if output.stage_recv_req_ids or chunk_finished or newly_finished: - logger.debug( - "[Stage-%s] get_omni_connector_output: stage_recv=%s, chunk_finished=%s, chunk_ready=%s", - self._stage_id, - output.stage_recv_req_ids, - chunk_finished, - output.chunk_ready_req_ids, - ) - self._chunk_ready_req_ids.clear() - self._kv_sent_req_ids.clear() - self._stage_recv_req_ids.clear() - return output - - @staticmethod - def _connector_output_has_signals(output: OmniConnectorOutput) -> bool: - return bool( - output.chunk_ready_req_ids - or output.chunk_finished_req_ids - or output.request_metadata - or output.kv_sent_req_ids - or output.stage_recv_req_ids - or output.has_pending_kv_work - ) - - def attach_omni_connector_output(self, result: Any | None) -> Any: - omni_output = self.get_omni_connector_output() - if not self._connector_output_has_signals(omni_output): - return result - - from copy import copy - - from vllm.v1.outputs import EMPTY_MODEL_RUNNER_OUTPUT - - wrapped = copy(result if result is not None else EMPTY_MODEL_RUNNER_OUTPUT) - wrapped.omni_connector_output = omni_output - return wrapped - - # ------------------------------------------------------------------ # - # Properties for compatibility with custom_process funcs that access - # transfer_manager.put_req_chunk / request_payload / code_prompt_token_ids - # ------------------------------------------------------------------ # - - @property - def put_req_chunk(self) -> dict[str, int]: - return self._put_req_chunk - - @property - def ramp_chunk_count(self) -> dict[str, int]: - return self._ramp_chunk_count - - @property - def request_payload(self) -> dict[str, dict[str, Any]]: - return self._send_side_request_payload - - @request_payload.setter - def request_payload(self, value: dict[str, dict[str, Any]]) -> None: - self._send_side_request_payload = value - - @property - def code_prompt_token_ids(self) -> dict[str, list[list[int]]]: - return self._code_prompt_token_ids - - @property - def connector(self) -> Any | None: - return self._omni_connector - - # ------------------------------------------------------------------ # - # Background I/O threads - # ------------------------------------------------------------------ # - - def _recv_loop(self) -> None: - """Background thread: poll connector for incoming data.""" - _recv_poll_count = 0 - while not self._stop_event.is_set(): - with self._lock: - pending_ids = list(self._pending_load_reqs.keys()) - - if not pending_ids: - self._work_available.wait(timeout=0.01) - self._work_available.clear() - continue - - _recv_poll_count += 1 - if _recv_poll_count % 5000 == 1: - logger.debug( - "[Stage-%s] _recv_loop: polling %s pending reqs: %s (poll#%s)", - self._stage_id, - len(pending_ids), - pending_ids[:5], - _recv_poll_count, - ) - - made_progress = False - for req_id in pending_ids: - if self._stop_event.is_set(): - break - try: - made_progress = self._poll_single_request(req_id) or made_progress - except Exception: - logger.warning("Error receiving data for %s", req_id, exc_info=True) - - if not made_progress and not self._stop_event.is_set(): - self._work_available.wait(timeout=0.005) - self._work_available.clear() - - _MAX_SEND_RETRIES = 3 - - def _save_loop(self) -> None: - """Background thread: send outgoing data via connector.""" - while not self._stop_event.is_set(): - task = None - with self._lock: - for req_id in list(self._pending_save_reqs.keys()): - dq = self._pending_save_reqs[req_id] - if dq: - task = dq.popleft() - if not dq: - del self._pending_save_reqs[req_id] - break - del self._pending_save_reqs[req_id] - - if task is not None: - success = False - try: - success = self._send_single_request(task) - except Exception: - logger.error( - "Error saving data for %s", - task.get("request_id"), - exc_info=True, - ) - if not success: - self._requeue_or_drop_failed_send(task) - continue - - self._work_available.wait(timeout=0.01) - self._work_available.clear() - - def _requeue_or_drop_failed_send(self, task: dict) -> None: - """Re-enqueue a failed send task or drop it after max retries.""" - retry_count = task.get("_retry_count", 0) + 1 - req_id = task.get("request_id") - if retry_count <= self._MAX_SEND_RETRIES: - task["_retry_count"] = retry_count - logger.warning( - "[Stage-%s] Re-enqueuing failed send for %s (retry %d/%d)", - getattr(self, "_stage_id", "?"), - req_id, - retry_count, - self._MAX_SEND_RETRIES, - ) - with self._lock: - dq = self._pending_save_reqs.setdefault(req_id, deque()) - dq.appendleft(task) - else: - logger.error( - "[Stage-%s] Giving up on send for %s after %d retries", - getattr(self, "_stage_id", "?"), - req_id, - self._MAX_SEND_RETRIES, - ) - self._decrement_pending_save_count(req_id) - - # ------------------------------------------------------------------ # - # Chunk-level poll / send (ported from OmniChunkTransferAdapter) - # ------------------------------------------------------------------ # - - def _poll_single_request(self, req_id: str) -> bool: - """Poll connector for one chunk of a request (non-blocking).""" - connector = self._omni_connector - if connector is None: - return False - - if self._async_chunk and self._model_mode != "ar": - with self._lock: - staged_payload = self._local_stage_payload_cache.get(req_id) - metadata_in_flight = req_id in self._local_request_metadata - scheduler_wakeup_pending = req_id in self._finished_load_reqs - if self._payload_is_consumable(staged_payload) or metadata_in_flight or scheduler_wakeup_pending: - logger.debug( - "[Stage-%s] delaying recv for req=%s until staged async payload is handed to scheduler", - self._stage_id, - req_id, - ) - return False - - target_stage_id = self._stage_id - 1 - chunk_id = self._get_req_chunk[req_id] - external_req_id = self._request_ids_mapping.get(req_id, req_id) - connector_get_key = f"{external_req_id}_{target_stage_id}_{chunk_id}" - - if self._async_chunk: - result = self._recv_async_chunk_result( - connector, - str(target_stage_id), - str(self._stage_id), - connector_get_key, - ) - else: - result = self._recv_full_payload_result( - connector, - str(target_stage_id), - str(self._stage_id), - connector_get_key, - ) - - if result is None: - return False - - payload_data, _size = result - if not payload_data: - return False - if isinstance(payload_data, dict): - logger.debug( - "[Stage-%s] recv_chunk_result: req=%s ext=%s key=%s keys=%s finished=%s", - self._stage_id, - req_id, - external_req_id, - connector_get_key, - sorted(payload_data.keys()), - self._payload_finished(payload_data), - ) - - self._get_req_chunk[req_id] += 1 - - if self._async_chunk: - is_finished = self._payload_finished(payload_data) - incoming_payload_consumable = self._payload_is_consumable(payload_data) - - if self._model_mode == "ar": - payload_data = self._accumulate_payload(external_req_id, payload_data) - payload_consumable = incoming_payload_consumable - else: - new_ids = self._payload_audio_codes(payload_data) or [] - if not new_ids and not is_finished: - return False - payload_consumable = self._payload_is_consumable(payload_data) - - with self._lock: - if is_finished: - self._chunk_finished_req_ids.add(req_id) - self._chunk_stream_completed.add(req_id) - # Local cache (RFC §2.4) — merge, don't replace, so that - # earlier chunk keys (e.g. thinker_prefill_embeddings from - # chunk 0) are not overwritten by later chunks. - existing = self._local_stage_payload_cache.get(req_id) - if existing is not None and isinstance(existing, dict) and isinstance(payload_data, dict): - existing.update(payload_data) - else: - self._local_stage_payload_cache[req_id] = payload_data - staged_payload = self._local_stage_payload_cache[req_id] - self._async_chunk_updated_req_ids.add(req_id) - self.put_local_request_metadata(req_id, self._extract_scheduling_metadata(staged_payload)) - # A finish-only sentinel still needs one terminal wake-up so - # the downstream stage can sync the merged local payload and - # flush/finish even when the last recv carries no new - # consumable chunk bytes. - if payload_consumable or is_finished: - self._finished_load_reqs.add(req_id) - if is_finished and not payload_consumable: - logger.debug( - "[Stage-%s] finish sentinel arrived for req=%s without new consumable payload", - self._stage_id, - req_id, - ) - elif not payload_consumable: - logger.debug( - "[Stage-%s] req=%s received metadata-only / non-consumable async payload; delaying wake-up", - self._stage_id, - req_id, - ) - if is_finished: - self._pending_load_reqs.pop(req_id, None) - else: - # full_payload_mode: the complete payload arrives in a single get(), - # so always unregister immediately. - if isinstance(payload_data, dict): - engine_inputs = payload_data.get("engine_inputs", payload_data) - else: - engine_inputs = payload_data - with self._lock: - self._local_stage_payload_cache[req_id] = self._snapshot_payload(engine_inputs) - # Publish full-payload readiness only after the aligned TP broadcast - # path in recv_full_payload_inputs() has materialized the payload on all - # local ranks. Publishing metadata / stage_recv from the background recv - # thread can let the scheduler observe a request before the payload is - # actually visible to the model thread. - self._full_payload_pending_broadcast_req_ids.add(req_id) - self._pending_load_reqs.pop(req_id, None) - logger.debug( - "[Stage-%s] full_payload recv complete: req=%s key=%s payload_type=%s", - self._stage_id, - req_id, - connector_get_key, - type(engine_inputs).__name__, - ) - - logger.debug("[Stage-%s] Received data for key %s", self._stage_id, connector_get_key) - return True - - def _build_custom_process_payload( - self, - request_id: str | None, - request: Any | None, - pooling_output: Any | None, - ) -> Any | None: - """Run the custom process hook with a best-effort finished kwarg.""" - if self._custom_process_func is None: - return None - - kwargs = { - "transfer_manager": self, - "pooling_output": pooling_output, - "request": request, - } - supports_is_finished = getattr( - self, - "_custom_process_supports_is_finished", - self._custom_process_supports_is_finished_kwarg(), - ) - is_finished_fn = getattr(request, "is_finished", None) - if callable(is_finished_fn): - try: - if supports_is_finished is not False: - kwargs["is_finished"] = bool(is_finished_fn()) - except Exception: - logger.debug("request.is_finished() failed for %s", request_id, exc_info=True) - - try: - return self._custom_process_func(**kwargs) - except TypeError as exc: - if "is_finished" not in kwargs or not self._is_unexpected_is_finished_kwarg_error(exc): - logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) - return None - kwargs.pop("is_finished", None) - try: - return self._custom_process_func(**kwargs) - except Exception: - logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) - return None - except Exception: - logger.exception("custom_process_stage_input_func failed for chunk %s", request_id) - return None - - def _custom_process_supports_is_finished_kwarg(self) -> bool | None: - """Return whether the custom process hook accepts `is_finished`.""" - if self._custom_process_func is None: - return None - try: - signature = inspect.signature(self._custom_process_func) - except (TypeError, ValueError): - return None - - for param in signature.parameters.values(): - if param.kind == inspect.Parameter.VAR_KEYWORD: - return True - - is_finished_param = signature.parameters.get("is_finished") - if is_finished_param is None: - return False - return is_finished_param.kind in ( - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY, - ) - - @staticmethod - def _is_unexpected_is_finished_kwarg_error(exc: TypeError) -> bool: - message = str(exc) - return ( - "unexpected keyword argument 'is_finished'" in message - or 'unexpected keyword argument "is_finished"' in message - or "positional-only arguments passed as keyword arguments: 'is_finished'" in message - ) - - def _send_single_request(self, task: dict) -> bool: - """Send one queued task via connector.put(). - - Returns True on success. On failure (put() raises or returns - ``success=False``), returns False **without** decrementing - ``_pending_save_counts`` so the caller can retry or clean up. - """ - connector = self._omni_connector - if connector is None: - return True - - request_id = task.get("request_id") - payload_data = task.get("data") - if payload_data is None and task.get("request") is not None: - payload_data = self._build_custom_process_payload( - request_id=request_id, - request=task.get("request"), - pooling_output=task.get("pooling_output"), - ) - put_key = task.get("put_key") - - success, _size, _metadata = connector.put( - from_stage=str(task["stage_id"]), - to_stage=str(task["next_stage_id"]), - put_key=put_key, - data=payload_data, - ) - logger.debug( - "[Stage-%s] _send_single_request: put_key=%s success=%s size=%s", - task["stage_id"], - put_key, - success, - _size, - ) - - if not success: - return False - - self._decrement_pending_save_count(request_id) - return True - - def _decrement_pending_save_count(self, request_id: str) -> None: - """Decrement pending save count and run deferred cleanup if zero.""" - cleanup_req_id = None - with self._lock: - remaining = self._pending_save_counts.get(request_id, 0) - if remaining > 1: - self._pending_save_counts[request_id] = remaining - 1 - elif remaining == 1: - self._pending_save_counts.pop(request_id, None) - if request_id in self._deferred_send_cleanup: - self._deferred_send_cleanup.remove(request_id) - cleanup_req_id = request_id - if cleanup_req_id is not None: - self._put_req_chunk.pop(cleanup_req_id, None) - self._send_side_request_payload.pop(cleanup_req_id, None) - self._code_prompt_token_ids.pop(cleanup_req_id, None) - self._cached_ic.pop(cleanup_req_id, None) - self._ramp_chunk_count.pop(cleanup_req_id, None) - self._adaptive_states.pop(cleanup_req_id, None) - - # ------------------------------------------------------------------ # - # Payload accumulation (ported from OmniChunkTransferAdapter) - # ------------------------------------------------------------------ # - - def _accumulate_payload(self, req_id: str, payload_data: OmniPayload) -> OmniPayload: - """Accumulate chunk payloads (concat tensors, extend lists).""" - if req_id not in self._send_side_request_payload: - self._send_side_request_payload[req_id] = dict(payload_data) - return dict(self._send_side_request_payload[req_id]) - - origin = self._send_side_request_payload[req_id] - merged = dict(origin) - raw_ok = payload_data.get("meta", {}).get("override_keys", []) if isinstance(payload_data, dict) else [] - override_keys = {tuple(k) if isinstance(k, list) else k for k in raw_ok} - - for key, value in payload_data.items(): - if isinstance(value, dict): - origin_sub = origin.get(key) - merged_sub = dict(origin_sub) if isinstance(origin_sub, dict) else {} - span_handled: set[str] = set() - if key == "embed" and isinstance(origin_sub, dict): - for tk, sk, ek in _EMBED_SPAN_GROUPS: - if tk not in value or (key, tk) in override_keys: - continue - span = merge_tensor_spans( - get_tensor_span(origin_sub, tensor_key=tk, start_key=sk, end_key=ek), - get_tensor_span(value, tensor_key=tk, start_key=sk, end_key=ek), - ) - if span is None: - continue - t, s, e = span - merged_sub[tk] = t - merged_sub[sk] = s - merged_sub[ek] = e - span_handled |= {tk, sk, ek} - for qual, qval in value.items(): - if qual in span_handled: - continue - if key == "meta" and qual == "finished": - merged_sub[qual] = qval - continue - if (key, qual) in override_keys: - merged_sub[qual] = qval - continue - osv = merged_sub.get(qual) - if isinstance(qval, torch.Tensor) and isinstance(osv, torch.Tensor): - merged_sub[qual] = torch.cat([osv, qval], dim=0) - elif isinstance(qval, list) and isinstance(osv, list): - merged_sub[qual] = osv + qval - else: - merged_sub[qual] = qval - merged[key] = merged_sub - else: - if key in override_keys: - merged[key] = value - continue - ov = origin.get(key) - if isinstance(value, torch.Tensor) and isinstance(ov, torch.Tensor): - merged[key] = torch.cat([ov, value], dim=0) - elif isinstance(value, list) and isinstance(ov, list): - merged[key] = ov + value - else: - merged[key] = value - - self._send_side_request_payload[req_id] = merged - return dict(merged) - - def drop_inactive_request_runtime_state(self, req_id: str) -> None: - """Clear inactive request state used by both the runner and mixin. - - This centralizes the model-runner-side cleanup pattern so - ``OmniGPUModelRunner`` can reuse it instead of open-coding the same - inactive-request state mutations. - """ - if hasattr(self, "model_intermediate_buffer"): - self.model_intermediate_buffer.pop(req_id, None) - self.drop_inactive_request_delivery_state(req_id) - - # ------------------------------------------------------------------ # - # Helpers - # ------------------------------------------------------------------ # - - @staticmethod - def _create_connector(model_config: Any) -> OmniConnectorBase | None: - """Create a connector from model_config, or None if unconfigured.""" - connector_config = getattr(model_config, "stage_connector_config", None) - if connector_config is None: - return None - - if not isinstance(connector_config, dict): - connector_config = { - "name": getattr(connector_config, "name", None), - "extra": getattr(connector_config, "extra", None), - } - - name = connector_config.get("name") - if not isinstance(name, str) or not name.strip(): - raise RuntimeError("Invalid stage connector config: missing connector name") - name = name.strip() - - extra = connector_config.get("extra") - if extra is None: - extra = {} - elif not isinstance(extra, dict): - raise RuntimeError(f"Invalid extra config for connector {name}: expected dict, got {type(extra).__name__}") - - spec = ConnectorSpec(name=name, extra=extra) - try: - return OmniConnectorFactory.create_connector(spec) - except Exception as exc: - raise RuntimeError(f"Failed to create connector {name}") from exc - - @staticmethod - def _load_custom_func(model_config: Any) -> tuple[str | None, Any | None]: - """Load the connector payload builder for the downstream stage. - - Preferred source is ``custom_process_next_stage_input_func``. Some - full_payload_mode configs (async_chunk=false) only expose the next-stage prompt builder via - ``custom_process_input_func`` (for example ``thinker2talker``), while the - connector payload builder lives beside it as ``thinker2talker_full_payload``. - In that case, derive the full_payload_mode builder path automatically. - """ - candidates: list[str] = [] - - next_stage_func = getattr(model_config, "custom_process_next_stage_input_func", None) - if isinstance(next_stage_func, str) and next_stage_func: - candidates.append(next_stage_func) - - if not getattr(model_config, "async_chunk", False): - input_func = getattr(model_config, "custom_process_input_func", None) - if isinstance(input_func, str) and input_func: - try: - module_path, func_name = input_func.rsplit(".", 1) - if func_name.endswith("_full_payload") or func_name.endswith("_batch"): - candidates.append(f"{module_path}.{func_name}") - else: - candidates.append(f"{module_path}.{func_name}_full_payload") - candidates.append(f"{module_path}.{func_name}_batch") - candidates.append(input_func) - except ValueError: - candidates.append(input_func) - - tried: set[str] = set() - for func_path in candidates: - if func_path in tried: - continue - tried.add(func_path) - try: - module_path, func_name = func_path.rsplit(".", 1) - module = importlib.import_module(module_path) - func = getattr(module, func_name, None) - if callable(func): - if not OmniConnectorModelRunnerMixin._is_connector_payload_builder(func): - logger.debug( - "Skipping incompatible connector payload hook %s; signature=%s", - func_path, - inspect.signature(func), - ) - continue - return func_path, func - except Exception: - logger.warning("Failed to load custom func: %s", func_path, exc_info=True) - - return None, None - - @staticmethod - def _is_connector_payload_builder(func: Any) -> bool: - """Whether *func* matches the mixin payload-builder contract.""" - try: - signature = inspect.signature(func) - except (TypeError, ValueError): - return False - - params = signature.parameters - if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()): - return True - - required = {"transfer_manager", "pooling_output", "request"} - supported = { - name - for name, param in params.items() - if param.kind - in ( - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY, - ) - } - return required.issubset(supported) - - def _resolve_external_req_id(self, request: Any, fallback_req_id: str) -> str: - """Resolve the external request ID consistently. - - Checks ``_request_ids_mapping`` first (populated by - ``register_chunk_recv``), then falls back to the request's - ``external_req_id`` attribute, and finally to the given - ``fallback_req_id``. - """ - mapped = self._request_ids_mapping.get(fallback_req_id) - if mapped is not None: - return mapped - if request is not None: - # external_req_id may be explicitly None; fall back. - ext = getattr(request, "external_req_id", None) - if ext is not None: - return ext - return fallback_req_id - - def _resolve_next_stage_id(self, model_config: Any) -> int: - """Determine the downstream stage ID from connector config. - - Falls back to ``stage_id + 1`` when the config does not specify - a ``to_stage`` explicitly. - """ - connector_config = getattr(model_config, "stage_connector_config", None) - if connector_config is not None: - if isinstance(connector_config, dict): - to_stage = connector_config.get("to_stage") - else: - to_stage = getattr(connector_config, "to_stage", None) - if isinstance(to_stage, int): - return to_stage - if isinstance(to_stage, str) and to_stage.strip(): - return int(to_stage) - return self._stage_id + 1 - - @staticmethod - def _parse_rank_mapping(model_config: Any) -> dict[str, int]: - """Parse rank_mapping from connector config (optional). - - Returns ``{"from_tp": int, "to_tp": int, "local_rank": int}``. - When ``rank_mapping`` is absent, assumes 1:1 homogeneous mapping. - """ - connector_config = getattr(model_config, "stage_connector_config", None) - if connector_config is not None and not isinstance(connector_config, dict): - connector_config = getattr(connector_config, "__dict__", {}) - - rank_mapping: dict = {} - if isinstance(connector_config, dict): - rank_mapping = connector_config.get("rank_mapping", {}) - - from_tp = int(rank_mapping.get("from_tp", 1)) - to_tp = int(rank_mapping.get("to_tp", 1)) - - local_rank = 0 - try: - local_rank = int(os.environ.get("LOCAL_RANK", "0")) - except (ValueError, TypeError): - pass - - return {"from_tp": from_tp, "to_tp": to_tp, "local_rank": local_rank} - - # ------------------------------------------------------------------ # - # Heterogeneous TP rank support - # ------------------------------------------------------------------ # - - def _validate_kv_tp_topology(self) -> None: - """Reject heterogeneous TP mappings that cannot be routed losslessly.""" - if self._from_tp <= 0 or self._to_tp <= 0: - raise ValueError(f"Invalid KV TP mapping: from_tp={self._from_tp}, to_tp={self._to_tp}") - larger = max(self._from_tp, self._to_tp) - smaller = min(self._from_tp, self._to_tp) - if larger % smaller != 0: - raise ValueError( - f"KV TP mapping must be divisible for rank-aware routing: from_tp={self._from_tp}, to_tp={self._to_tp}" - ) - - def get_kv_remote_ranks(self) -> list[int]: - """Determine which remote ranks this local rank exchanges KV with. - - Follows vLLM's ``TpKVTopology.get_target_remote_ranks()`` pattern: - - ``from_tp > to_tp``: each to-rank reads from multiple from-ranks - - ``from_tp < to_tp``: multiple to-ranks read from the same from-rank - - ``from_tp == to_tp``: 1:1 mapping - """ - self._validate_kv_tp_topology() - if self._from_tp == self._to_tp: - return [self._local_rank] - - if self._from_tp > self._to_tp: - tp_ratio = self._from_tp // self._to_tp - return [self._local_rank * tp_ratio + i for i in range(tp_ratio)] - else: - tp_ratio = self._to_tp // self._from_tp - return [self._local_rank // tp_ratio] - - def is_data_transfer_rank(self) -> bool: - """Whether this rank should participate in data (non-KV) transfer. - - Ordinary stage payloads are TP-identical, so exactly one TP rank - should talk to the connector. When TP is initialized, use TP rank 0 - so the connector leader matches TP-local broadcast source rank. - Otherwise fall back to LOCAL_RANK==0 for the single-rank case. - """ - tp_group = self._get_local_tp_group() - if tp_group is not None and getattr(tp_group, "world_size", 1) > 1: - return getattr(tp_group, "rank_in_group", 0) == 0 - return self._local_rank == 0 - - def get_kv_connector_key( - self, - req_id: str, - from_stage: int, - chunk_id: int, - from_rank: int, - to_rank: int, - ) -> str: - """Build connector key that includes rank info for KV transfers.""" - return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}" diff --git a/vllm_omni/worker/payload_span.py b/vllm_omni/worker/payload_span.py deleted file mode 100644 index 994392343..000000000 --- a/vllm_omni/worker/payload_span.py +++ /dev/null @@ -1,64 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Helpers for explicit thinker decode span metadata.""" - -from collections.abc import Mapping -from typing import Any - -import torch - -THINKER_DECODE_EMBEDDINGS_KEY = "thinker_decode_embeddings" -THINKER_OUTPUT_TOKEN_IDS_KEY = "thinker_output_token_ids" -THINKER_DECODE_TOKEN_START_KEY = "thinker_decode_embeddings_token_start" -THINKER_DECODE_TOKEN_END_KEY = "thinker_decode_embeddings_token_end" - -CACHED_THINKER_DECODE_EMBEDDINGS_KEY = "cached_thinker_decode_embeddings" -CACHED_THINKER_DECODE_TOKEN_START_KEY = "cached_thinker_decode_embeddings_token_start" -CACHED_THINKER_DECODE_TOKEN_END_KEY = "cached_thinker_decode_embeddings_token_end" - -TensorSpan = tuple[torch.Tensor, int, int] - - -def get_tensor_span(payload: Mapping[str, Any], *, tensor_key: str, start_key: str, end_key: str) -> TensorSpan | None: - tensor = payload.get(tensor_key) - start = payload.get(start_key) - end = payload.get(end_key) - if not isinstance(tensor, torch.Tensor): - return None - if not isinstance(start, int) or not isinstance(end, int): - return None - if start < 0 or end < start or (end - start) != int(tensor.shape[0]): - return None - return tensor, start, end - - -def merge_tensor_spans(existing_span: TensorSpan | None, incoming_span: TensorSpan | None) -> TensorSpan | None: - if existing_span is None or incoming_span is None: - return None - - existing_tensor, existing_start, existing_end = existing_span - incoming_tensor, incoming_start, incoming_end = incoming_span - if incoming_tensor.device != existing_tensor.device or incoming_tensor.dtype != existing_tensor.dtype: - incoming_tensor = incoming_tensor.to(device=existing_tensor.device, dtype=existing_tensor.dtype) - if incoming_start == existing_end: - return torch.cat([existing_tensor, incoming_tensor], dim=0), existing_start, incoming_end - if incoming_start < existing_end: - overlap = existing_end - incoming_start - if overlap >= int(incoming_tensor.shape[0]): - return existing_tensor, existing_start, existing_end - trimmed_tensor = incoming_tensor[overlap:] - return ( - torch.cat([existing_tensor, trimmed_tensor], dim=0), - existing_start, - existing_end + int(trimmed_tensor.shape[0]), - ) - return None - - -def get_tensor_span_row(span: TensorSpan | None, index: int) -> torch.Tensor | None: - if span is None: - return None - tensor, start, end = span - if index < start or index >= end: - return None - return tensor[index - start] From ab56148505330cfeb935af4b6f453e87287f20b3 Mon Sep 17 00:00:00 2001 From: Mu GuanLin <1203789601@qq.com> Date: Wed, 9 Sep 2026 14:37:01 +0800 Subject: [PATCH 65/90] [Bugfix] Make LTX vocoder decoding deterministic (#7231) Signed-off-by: mglyn <1203789601@qq.com> --- tests/diffusion/models/ltx2/test_ltx2_vae.py | 23 ++++++++++ tests/e2e/accuracy/ltx/run_ltx25_reference.py | 26 ++++++++++- .../diffusion/models/ltx2/ltx2_runtime.py | 46 ++++++++++++------- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/tests/diffusion/models/ltx2/test_ltx2_vae.py b/tests/diffusion/models/ltx2/test_ltx2_vae.py index 964bb7d06..b9bc52d94 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_vae.py +++ b/tests/diffusion/models/ltx2/test_ltx2_vae.py @@ -12,6 +12,29 @@ import torch pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.mark.parametrize("initial_deterministic", [False, True]) +@pytest.mark.parametrize("fail", [False, True]) +def test_ltx_vocoder_deterministic_context(monkeypatch, initial_deterministic, fail): + from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _deterministic_ltx_vocoder + + monkeypatch.setattr(torch.backends.cudnn, "deterministic", initial_deterministic) + if torch.backends.cudnn.is_available(): + monkeypatch.setattr(torch.backends.cudnn, "benchmark_limit", 17) + settings = { + name: getattr(torch.backends.cudnn, name) for name in ("enabled", "benchmark", "benchmark_limit", "allow_tf32") + } + try: + with _deterministic_ltx_vocoder(): + assert torch.backends.cudnn.deterministic + assert {name: getattr(torch.backends.cudnn, name) for name in settings} == settings + if fail: + raise RuntimeError("injected failure") + except RuntimeError as exc: + assert fail and str(exc) == "injected failure" + assert torch.backends.cudnn.deterministic == initial_deterministic + assert {name: getattr(torch.backends.cudnn, name) for name in settings} == settings + + def test_ltx_base_vocoder_keeps_native_dtype(monkeypatch): import vllm_omni.diffusion.models.ltx2.ltx2_runtime as ltx_runtime diff --git a/tests/e2e/accuracy/ltx/run_ltx25_reference.py b/tests/e2e/accuracy/ltx/run_ltx25_reference.py index cbc8917a8..14c862555 100644 --- a/tests/e2e/accuracy/ltx/run_ltx25_reference.py +++ b/tests/e2e/accuracy/ltx/run_ltx25_reference.py @@ -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 """Generate raw LTX video and audio outputs with the official or Omni runtime.""" @@ -114,6 +114,29 @@ def _insert_official_paths(official_root: Path) -> None: sys.path.insert(0, path) +def _configure_official_vocoder_determinism() -> None: + """Make the pinned official LTX-2.5 vocoder a stable reference.""" + from ltx_core.model.audio_vae.vocoder import VocoderWithBWE + + original_forward = VocoderWithBWE.forward + if getattr(original_forward, "_vllm_omni_deterministic", False): + return + + def deterministic_forward(self: Any, mel_spec: torch.Tensor) -> torch.Tensor: + device_type = mel_spec.device.type + if device_type != "cuda": + return original_forward(self, mel_spec) + previous = torch.backends.cudnn.deterministic + try: + torch.backends.cudnn.deterministic = True + return original_forward(self, mel_spec) + finally: + torch.backends.cudnn.deterministic = previous + + setattr(deterministic_forward, "_vllm_omni_deterministic", True) + setattr(VocoderWithBWE, "forward", deterministic_forward) + + def _configure_official_sdpa(pipeline: Any) -> None: """Pin official connector and denoiser attention to cuDNN, with MATH fallback.""" from ltx_core.loader.attention_ops import set_attention_module_op @@ -187,6 +210,7 @@ def _run_official(args: argparse.Namespace, request: dict[str, Any]) -> None: if args.official_root is None: raise ValueError("Official backend requires --official-root") _insert_official_paths(args.official_root) + _configure_official_vocoder_determinism() # Keep Gemma on the same SDPA path in both subprocesses. DiT attention is # still pinned independently to the explicit cuDNN backend below. torch.backends.cuda.enable_cudnn_sdp(False) diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py b/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py index 2c51b17d9..9e682ffef 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_runtime.py @@ -65,27 +65,39 @@ from .ltx2_request import ( logger = init_logger(__name__) +@contextmanager +def _deterministic_ltx_vocoder(): + previous = torch.backends.cudnn.deterministic + try: + torch.backends.cudnn.deterministic = True + yield + finally: + torch.backends.cudnn.deterministic = previous + + def _run_ltx_vocoder(vocoder: nn.Module, generated_mel: torch.Tensor) -> torch.Tensor: """Run the BWE vocoder in FP32, matching the official LTX pipeline.""" - if not hasattr(vocoder, "bwe_generator"): - return vocoder(generated_mel) - - input_dtype = generated_mel.dtype device_type = generated_mel.device.type - module_dtype = next(vocoder.parameters()).dtype - if device_type == "mps": - if module_dtype != torch.float32: - vocoder.float() - try: - return vocoder(generated_mel.float()).to(input_dtype) - finally: - if module_dtype != torch.float32: - vocoder.to(module_dtype) + cudnn_context = _deterministic_ltx_vocoder() if device_type == "cuda" else nullcontext() + with cudnn_context: + if not hasattr(vocoder, "bwe_generator"): + return vocoder(generated_mel) - # BF16 errors compound through the BWE model's long convolution stack. - # FP32 autocast upcasts each op without materializing a second FP32 model. - with torch.autocast(device_type=device_type, dtype=torch.float32): - return vocoder(generated_mel.float()).to(input_dtype) + input_dtype = generated_mel.dtype + module_dtype = next(vocoder.parameters()).dtype + if device_type == "mps": + if module_dtype != torch.float32: + vocoder.float() + try: + return vocoder(generated_mel.float()).to(input_dtype) + finally: + if module_dtype != torch.float32: + vocoder.to(module_dtype) + + # BF16 errors compound through the BWE model's long convolution stack. + # FP32 autocast upcasts each op without materializing a second FP32 model. + with torch.autocast(device_type=device_type, dtype=torch.float32): + return vocoder(generated_mel.float()).to(input_dtype) def _prepare_ltx2_video_output( From 7712701578d8359b5af958bb2df9023c90e21a6d Mon Sep 17 00:00:00 2001 From: Sparks <41097544+Sparks-M@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:54:46 +0800 Subject: [PATCH 66/90] [Doc] [Recipe] Add FLUX.1-schnell recipe for RTX 5090 32GB (#7299) Signed-off-by: Sparks-M <41097544+Sparks-M@users.noreply.github.com> --- recipes/README.md | 1 + recipes/black-forest-labs/FLUX.1-schnell.md | 160 ++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 recipes/black-forest-labs/FLUX.1-schnell.md diff --git a/recipes/README.md b/recipes/README.md index 8ac877567..6b80f8a03 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -42,6 +42,7 @@ recipes/ | -------- | ------ | ---------- | | [`Baidu/ERNIE-Image.md`](./Baidu/ERNIE-Image.md) | Text-to-image online serving (ERNIE-Image 8B) | 1x or 2x RTX 4090 24GB | | [`Bagel/BAGEL-7B-MoT.md`](./Bagel/BAGEL-7B-MoT.md) | Text-to-image with shared online/offline examples | 1x A100 80GB / 2x CUDA GPUs | +| [`black-forest-labs/FLUX.1-schnell.md`](./black-forest-labs/FLUX.1-schnell.md) | Offline text-to-image generation with CPU offload | 1x NVIDIA GeForce RTX 5090 32GB | | [`Boogu/Boogu-Image.md`](./Boogu/Boogu-Image.md) | Text-to-image online serving (Boogu-Image-0.1-Base) | 1x A100/H100 40GB+ | | [`BosonAI/Higgs-Audio-V3-TTS.md`](./BosonAI/Higgs-Audio-V3-TTS.md) | Online + offline multilingual TTS with voice cloning | 1x H100 80GB | | [`ByteDance/Lance.md`](./ByteDance/Lance.md) | Unified AR+diffusion: text/img/video gen + understanding (Lance 3B) | 1x B300 / A100 80GB | diff --git a/recipes/black-forest-labs/FLUX.1-schnell.md b/recipes/black-forest-labs/FLUX.1-schnell.md new file mode 100644 index 000000000..9ae6f4817 --- /dev/null +++ b/recipes/black-forest-labs/FLUX.1-schnell.md @@ -0,0 +1,160 @@ +# FLUX.1-schnell + +> Offline text-to-image generation on one NVIDIA GeForce RTX 5090 32 GB + +## Summary + +- Vendor: Black Forest Labs +- Model: `black-forest-labs/FLUX.1-schnell` +- Task: Text-to-image generation +- Mode: Offline inference with the shared text-to-image runner +- Maintainer: Community + +## When to use this recipe + +Use this recipe as a personally validated starting point for generating a +1024x1024 image with FLUX.1-schnell on one NVIDIA GeForce RTX 5090. The tested +configuration uses BF16, four denoising steps and model-level CPU offload. + +This recipe covers single-image generation. It does not attempt to optimize +throughput with quantization or cache acceleration. + +## References + +- Model card: +- Canonical offline text-to-image guide: + [`docs/user_guide/examples/offline_inference/text_to_image.md`](../../docs/user_guide/examples/offline_inference/text_to_image.md) +- Shared runnable example: + [`examples/offline_inference/text_to_image`](../../examples/offline_inference/text_to_image) +- CPU offload guide: + [`docs/user_guide/diffusion/cpu_offload.md`](../../docs/user_guide/diffusion/cpu_offload.md) +- Community recipe tracker: + [#2645](https://github.com/vllm-project/vllm-omni/issues/2645) + +## Hardware Support + +## GPU + +### 1x NVIDIA GeForce RTX 5090 32 GB + +This configuration was personally validated end to end on September 9, 2026. + +#### Environment + +- OS: Ubuntu 22.04.3 x86_64 +- Python: 3.12.7 +- GPU: 1x NVIDIA GeForce RTX 5090, 32,607 MiB reported by `nvidia-smi` +- Host RAM: approximately 62 GiB (64 GB class) +- NVIDIA driver: 595.71.05; CUDA 13.2 reported by `nvidia-smi` +- PyTorch: 2.13.0+cu130; PyTorch CUDA runtime: 13.0 +- vLLM: 0.28.0 +- vLLM-Omni: `0.28.1.dev112+ga31690041` +- Diffusers: 0.40.0 +- Transformers: 5.14.1 + +#### Command + +Run the shared example from the vLLM-Omni repository root in an environment +with vLLM-Omni installed: + +```bash +python examples/offline_inference/text_to_image/text_to_image.py \ + --model black-forest-labs/FLUX.1-schnell \ + --prompt 'A red fox standing in fresh snow, cinematic photography' \ + --height 1024 --width 1024 \ + --num-inference-steps 4 --guidance-scale 0 --seed 42 \ + --enable-cpu-offload \ + --output flux-schnell-rtx5090.png +``` + +The command uses the public model ID for portability. The measured runs used +already downloaded FLUX.1-schnell weights; initial Hub download and +authentication were not part of the inference validation. + +#### Verification + +Verify that the runner succeeded and inspect the saved image: + +```bash +python - <<'PY' +import hashlib +from pathlib import Path + +from PIL import Image, ImageStat + +path = Path("flux-schnell-rtx5090.png") +with Image.open(path) as image: + image.load() + assert image.format == "PNG" + assert image.size == (1024, 1024) + rgb = image.convert("RGB") + stats = ImageStat.Stat(rgb) + assert max(stats.stddev) > 1, "Image is nearly uniform" + print(f"format={image.format}") + print(f"size={image.width}x{image.height}") + print(f"mode={image.mode}") + print(f"bytes={path.stat().st_size}") + print("rgb_mean=" + ",".join(f"{value:.3f}" for value in stats.mean)) + print(f"rgb_extrema={rgb.getextrema()}") + +print(f"sha256={hashlib.sha256(path.read_bytes()).hexdigest()}") +PY +``` + +The repeated experiment completed 36 image requests (6 additional warmups and +30 measured outputs) with successful process exits. Every PNG passed decoding, +1024x1024 dimension and non-uniform pixel checks. SHA-256 hashes were recorded +for the artifacts; matching those hashes is not required across environments. +These checks establish file integrity, not a quantitative image-quality score. + +#### Performance + +The experiment used three independent processes. Each process initialized the +model once, generated two warmup images, then generated ten measured images +sequentially. All 30 measured images used BF16, CPU offload, 1024x1024, four +steps, guidance scale 0 and TP=1, without concurrent requests. + +| Generation measurement | Result | +| --- | ---: | +| Measured images | 30 | +| Mean latency | 25.8411 seconds | +| P95 latency (nearest-rank) | 26.1446 seconds | + +| Startup / resource measurement | Maximum | +| --- | ---: | +| Model initialization (seconds) | 64.73 | +| Full-lifetime RAM PSS peak (MiB) | 49306 | +| Full-lifetime device GPU memory peak (MiB) | 28121 | +| Measured PyTorch reserved GPU memory peak (MiB) | 27360 | + +Latency measures only `Omni.generate()`, excluding image saving and +verification. The two warmup images per process are excluded. P95 uses the +nearest-rank method over the 30 measured images. Model initialization includes +the engine's built-in dummy warmup; weights were already downloaded and OS file +caches were not cleared. + +RAM and whole-device GPU memory were sampled throughout each process lifetime +at a target interval of 200 ms. RAM is the maximum simultaneous sum of PSS for +the inference process and its workers, read from Linux `smaps_rollup`. +Whole-device GPU memory is the maximum reported by `nvidia-smi`. Both are +sampled peaks and may miss shorter spikes. + +The PyTorch value is the maximum worker-reported `peak_memory_mb` across the +30 measured requests. Despite the field name, its unit is MiB; it measures the +PyTorch allocator's reserved-memory high-water mark rather than whole-device +usage. + +#### Notes + +- `--enable-cpu-offload` swaps the transformer and text encoders between host + and device memory at phase boundaries. It reduces GPU residency but adds + transfer overhead and requires sufficient host RAM. The minimum host RAM + requirement was not measured. +- CPU offload does not imply CPU-only computation. The tested model-level + offload backend keeps the VAE on the GPU. +- Only offline single-image generation on one GPU with BF16, 1024x1024, + four steps and CPU offload was validated. Online serving, FP8, other + resolutions, concurrency, execution without offload and smaller host RAM + configurations are outside this recipe's validated scope. +- The upstream revision of the downloaded weights was not recorded, so the + measurements do not establish exact checkpoint reproducibility. From c66df06c0ffd48cf4f7a0b01434673a3cd7467b5 Mon Sep 17 00:00:00 2001 From: chi030303 <106855944+chi030303@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:56:14 +0800 Subject: [PATCH 67/90] [Doc] Qwen3-TTS: add 0.6B on 1x A100 40GB (#7289) Signed-off-by: chi030303 <106855944+chi030303@users.noreply.github.com> --- recipes/Qwen/Qwen3-TTS.md | 88 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/recipes/Qwen/Qwen3-TTS.md b/recipes/Qwen/Qwen3-TTS.md index 0d930d58f..d0dbe57df 100644 --- a/recipes/Qwen/Qwen3-TTS.md +++ b/recipes/Qwen/Qwen3-TTS.md @@ -263,6 +263,94 @@ curl -X POST http://localhost:8091/v1/audio/speech \ gpu_memory_utilization: 0.15 ``` +### 1x A100 40GB (0.6B CustomVoice) + +#### Environment + +- OS: Linux 4.18.0-553 (RHEL 8.10), x86_64 +- Python: 3.12.14 +- PyTorch: 2.13.0+cu129 +- Driver / runtime: NVIDIA 570.124.06 / CUDA 12.8 (`nvidia-smi`); PyTorch CUDA 12.9 +- GPU: NVIDIA A100-SXM4-40GB, 40960 MiB +- vLLM version: 0.28.0+cu129 +- vLLM-Omni version or commit: `3204a0b2ade0f05424b7f11e3bcc03cb3542e0c6` + +#### Command + +```bash +vllm serve Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice \ + --deploy-config vllm_omni/deploy/qwen3_tts.yaml \ + --omni --port 8091 +``` + +The default deploy config (`qwen3_tts.yaml`) works without modification on this +A100 40GB. Both stages (talker + code2wav) share GPU 0 with +`gpu_memory_utilization: 0.3` each. + +#### Verification + +**English synthesis:** + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "Hello, this is Qwen3-TTS running on A100 40GB.", + "voice": "vivian", + "language": "English" + }' --output test_english.wav +``` + +**Chinese synthesis:** + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "你好,这是在A100 40GB上运行的语音合成测试。", + "voice": "vivian", + "language": "Chinese" + }' --output test_chinese.wav +``` + +**With emotion instruction:** + +```bash +curl -X POST http://localhost:8091/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{ + "input": "I am so excited about this!", + "voice": "vivian", + "language": "English", + "instructions": "Speak with great enthusiasm" + }' --output test_emotion.wav +``` + +#### Notes + +- Memory usage: **16283 MiB / 40960 MiB** peak (`nvidia-smi`) during the three + verification requests with the default deploy config + (`gpu_memory_utilization: 0.3` per stage). Stage 0 used 12404 MiB and stage 1 + used 3862 MiB. The 0.6B weights occupy only ~2.4 GiB (Stage 0: 1.91 GiB, + Stage 1: 0.45 GiB); the rest is KV cache reserved as a fraction of device + memory, so the same `0.3` setting reads higher in GiB on 40GB than the + ~13.5 GiB idle figure on the 24GB 4090 profile above. To shrink the + reservation, use the `gpu_memory_utilization: 0.15` override documented in + that 4090 section. +- vLLM 0.28.0's default wheel targets CUDA 13.0. Driver 570 cannot load that + variant. This qualification used the official CUDA 12.9 wheel (same pin as + the A100 Wan2.2 recipe): + + ```bash + uv pip install \ + 'https://github.com/vllm-project/vllm/releases/download/v0.28.0/vllm-0.28.0%2Bcu129-cp38-abi3-manylinux_2_28_x86_64.whl' \ + --torch-backend=cu129 + ``` + + Confirm `torch.__version__` contains `cu129`. If a CPU build is already + installed, `uv pip install --torch-backend=cu129` is a no-op; use + `--reinstall`. + ### 1x AMD MI300X, 1.7B checkpoints #### Environment From ffa991b24fcf9540126ce574f9c7b8fa174af96d Mon Sep 17 00:00:00 2001 From: Mu GuanLin <1203789601@qq.com> Date: Wed, 9 Sep 2026 16:31:16 +0800 Subject: [PATCH 68/90] [Perf][Model] Add optimized LTX-2.5 DiffVAE operators (#7308) Signed-off-by: mglyn <1203789601@qq.com> --- .../ltx2/test_ltx2_diffvae_attention.py | 89 +++ .../models/ltx2/test_ltx2_diffvae_ops.py | 409 ++++++++++++++ .../models/ltx2/test_ltx2_ops_platform.py | 123 ++++ tests/diffusion/models/ltx2/test_ltx2_vae.py | 189 ++++++- .../diffusion/models/ltx2/ltx2_components.py | 14 +- .../diffusion/models/ltx2/ops/__init__.py | 93 ++++ .../diffusion/models/ltx2/ops/_numerics.py | 86 +++ vllm_omni/diffusion/models/ltx2/ops/fna.py | 525 ++++++++++++++++++ .../diffusion/models/ltx2/ops/qk_rms_norm.py | 377 +++++++++++++ .../models/ltx2/ops/residual_adaln.py | 489 ++++++++++++++++ .../diffusion/models/ltx2/ops/rope_tables.py | 50 ++ vllm_omni/diffusion/models/ltx2/ops/swiglu.py | 206 +++++++ .../diffusion/models/ltx2/vae/__init__.py | 4 + .../decoder.py} | 185 ++++++ .../distributed.py} | 2 +- 15 files changed, 2825 insertions(+), 16 deletions(-) create mode 100644 tests/diffusion/models/ltx2/test_ltx2_diffvae_attention.py create mode 100644 tests/diffusion/models/ltx2/test_ltx2_diffvae_ops.py create mode 100644 tests/diffusion/models/ltx2/test_ltx2_ops_platform.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/__init__.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/_numerics.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/fna.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/qk_rms_norm.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/residual_adaln.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/rope_tables.py create mode 100644 vllm_omni/diffusion/models/ltx2/ops/swiglu.py create mode 100644 vllm_omni/diffusion/models/ltx2/vae/__init__.py rename vllm_omni/diffusion/models/ltx2/{ltx2_diffusion_decoder.py => vae/decoder.py} (57%) rename vllm_omni/diffusion/models/ltx2/{ltx2_diffusion_decoder_distributed.py => vae/distributed.py} (99%) diff --git a/tests/diffusion/models/ltx2/test_ltx2_diffvae_attention.py b/tests/diffusion/models/ltx2/test_ltx2_diffvae_attention.py new file mode 100644 index 000000000..6cdaf197d --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_diffvae_attention.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Native FNA against an independent, test-only explicit neighborhood mask.""" + +import pytest +import torch +from torch.nn.attention import SDPBackend, sdpa_kernel + +from vllm_omni.diffusion.models.ltx2.ops import resolve_ltx2_vae_operators +from vllm_omni.platforms import current_omni_platform + +pytestmark = [pytest.mark.core_model, pytest.mark.cuda, pytest.mark.diffusion] + + +def _selected_fna(): + if not current_omni_platform.is_available(): + pytest.skip("No accelerator is available") + operators = resolve_ltx2_vae_operators(current_omni_platform.get_torch_device()) + if operators is None or operators.fna is None: + pytest.skip("LTX DiffVAE FNA is unavailable for this device or installation") + return operators.fna + + +def _load_fna_ops(): + return pytest.importorskip("vllm_omni.diffusion.models.ltx2.ops.fna", exc_type=ImportError) + + +def _pack(x, tile): + b, t, h, w, heads, dim = x.shape + tt, th, tw = tile + nt, nh, nw = (t + tt - 1) // tt, (h + th - 1) // th, (w + tw - 1) // tw + padded = x.new_zeros((b, nt * tt, nh * th, nw * tw, heads, dim)) + padded[:, :t, :h, :w] = x + # Outer and inner spatial tiles both flatten in W,H,T order. + return ( + padded.view(b, nt, tt, nh, th, nw, tw, heads, dim) + .permute(0, 5, 3, 1, 6, 4, 2, 7, 8) + .reshape(b, nt * nh * nw * tt * th * tw, heads, dim) + ) + + +def _unpack(x, shape): + t, h, w = shape + b, _, heads, dim = x.shape + nt, nh, nw = ((n + 3) // 4 for n in shape) + return ( + x.view(b, nw, nh, nt, 4, 4, 4, heads, dim) + .permute(0, 3, 6, 2, 5, 1, 4, 7, 8) + .reshape(b, nt * 4, nh * 4, nw * 4, heads, dim)[:, :t, :h, :w] + ) + + +@pytest.mark.parametrize("batch,shape", [(1, (11, 11, 11)), (2, (13, 12, 17))]) +@torch.inference_mode() +def test_fna_matches_explicit_inward_shift_mask(batch, shape): + fna = _selected_fna() + generator = torch.Generator(device="cuda").manual_seed(42) + q, k, v = [ + torch.randn(batch, *shape, 4, 64, device="cuda", dtype=torch.bfloat16, generator=generator) for _ in range(3) + ] + q = q * 0.125 + coordinates = torch.stack( + torch.meshgrid(*(torch.arange(n, device="cuda") for n in shape), indexing="ij"), dim=-1 + ).reshape(-1, 3) + start = (coordinates - 5).clamp_min(0).minimum(torch.tensor(shape, device="cuda") - 11) + mask = ((coordinates[None] >= start[:, None]) & (coordinates[None] < start[:, None] + 11)).all(-1) + with sdpa_kernel(SDPBackend.MATH): + expected = ( + torch.nn.functional.scaled_dot_product_attention( + *(x.flatten(1, 3).transpose(1, 2).float() for x in (q, k, v)), attn_mask=mask, scale=1.0 + ) + .transpose(1, 2) + .reshape(q.shape) + ) + actual = _unpack( + fna(_pack(q, (4, 4, 4)), _pack(k, (4, 4, 8)), _pack(v, (4, 4, 8)), shape=shape, window=(11, 11, 11)), + shape, + ).float() + assert actual.isfinite().all() + assert float((actual - expected).norm() / expected.norm()) < 0.005 + torch.testing.assert_close(actual, expected, atol=0.008, rtol=0.025) + + +def test_fna_rejects_cpu_without_fallback(): + fna_ops = _load_fna_ops() + x = torch.zeros(1, 64, 4, 64, dtype=torch.bfloat16) + with pytest.raises(ValueError, match="CUDA BF16"): + fna_ops.fna3d_tilelang(x, x, x, shape=(11, 11, 11), window=(11, 11, 11)) diff --git a/tests/diffusion/models/ltx2/test_ltx2_diffvae_ops.py b/tests/diffusion/models/ltx2/test_ltx2_diffvae_ops.py new file mode 100644 index 000000000..a66a7f7c8 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_diffvae_ops.py @@ -0,0 +1,409 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import subprocess +import sys + +import pytest +import torch + +from vllm_omni.diffusion.models.ltx2.ops import qk_rms_norm as qk_rms_norm_ops +from vllm_omni.diffusion.models.ltx2.ops import residual_adaln as residual_adaln_ops +from vllm_omni.diffusion.models.ltx2.ops import resolve_ltx2_vae_operators +from vllm_omni.diffusion.models.ltx2.ops import swiglu as swiglu_ops +from vllm_omni.platforms import current_omni_platform + +pytestmark = [pytest.mark.core_model, pytest.mark.cuda, pytest.mark.diffusion] + +_DIM_SPLIT = (16, 24, 24) + + +def _selected_operators(): + if not current_omni_platform.is_available(): + pytest.skip("No accelerator is available") + operators = resolve_ltx2_vae_operators(current_omni_platform.get_torch_device()) + if operators is None: + pytest.skip("No LTX DiffVAE operators are available for this device") + return operators + + +def _selected_fna(): + operators = _selected_operators() + if operators.fna is None: + pytest.skip("LTX DiffVAE FNA is unavailable for this device or installation") + return operators.fna + + +def _load_fna_ops(): + return pytest.importorskip("vllm_omni.diffusion.models.ltx2.ops.fna", exc_type=ImportError) + + +def test_diffvae_model_and_tests_do_not_import_tilelang_backend() -> None: + # Use a fresh interpreter: reloading the model in this process would leave + # already-imported distributed subclasses pointing at the old base class. + result = subprocess.run( + [ + sys.executable, + "-c", + "import sys; " + "sys.modules['vllm_omni.diffusion.models.ltx2.ops.fna'] = None; " + "sys.modules['vllm.tilelang_utils'] = None; " + "from vllm_omni.diffusion.models.ltx2.vae.decoder import LTX2VideoDiffusionDecoderModel; " + "import tests.diffusion.models.ltx2.test_ltx2_diffvae_attention; " + "import tests.diffusion.models.ltx2.test_ltx2_diffvae_ops", + ], + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_diffvae_fna_tile_map_covers_exact_neighborhood() -> None: + fna_ops = _load_fna_ops() + problem = fna_ops.Problem(13, 14, 17, (11, 11, 11)) + indices, counts = fna_ops.build_kv_tile_map(problem) + q_grid_t, q_grid_h, _ = problem.q_grid + kv_grid_t, kv_grid_h, _ = problem.kv_grid + + for q_block in range(indices.shape[0]): + selected = set(indices[q_block, : counts[q_block]].tolist()) + q_outer_t = q_block % q_grid_t + q_outer_h = (q_block // q_grid_t) % q_grid_h + q_outer_w = q_block // (q_grid_t * q_grid_h) + for q_inner in range(fna_ops.Q_TOKENS): + query = ( + q_outer_t * 4 + q_inner % 4, + q_outer_h * 4 + (q_inner // 4) % 4, + q_outer_w * 4 + q_inner // 16, + ) + if any(index >= length for index, length in zip(query, problem.shape, strict=True)): + continue + starts = tuple( + fna_ops._window_start(index, window, length) + for index, window, length in zip(query, problem.window, problem.shape, strict=True) + ) + for key_t in range(starts[0], starts[0] + problem.window[0]): + for key_h in range(starts[1], starts[1] + problem.window[1]): + for key_w in range(starts[2], starts[2] + problem.window[2]): + key_block = ((key_w // 8) * kv_grid_h + key_h // 4) * kv_grid_t + key_t // 4 + assert key_block in selected + + +def test_diffvae_fna_metadata_uses_static_kernel_shapes() -> None: + fna_ops = _load_fna_ops() + for shape in ((11, 11, 11), (13, 14, 17), (66, 96, 128), (79, 192, 192)): + problem = fna_ops.Problem(*shape, (11, 11, 11)) + _, t_patterns, h_patterns, w_patterns = fna_ops.build_kv_tile_metadata(problem) + assert {table.numel() for table in (t_patterns, h_patterns, w_patterns)} == {fna_ops.PATTERN_TABLE_SIZE} + assert {limit for _, _, limit in fna_ops.build_kv_tile_buckets(problem)} <= set(fna_ops.KV_BUCKET_LIMITS) + for ids, metadata, limit in fna_ops.build_kv_tile_buckets(problem): + assert metadata.shape == (ids.numel(), limit) + + +def test_diffvae_fna_rejects_oversized_packed_tile_grid() -> None: + fna_ops = _load_fna_ops() + with pytest.raises(ValueError, match="15-bit metadata"): + fna_ops.Problem(128, 256, 256, (11, 11, 11)) + + +def test_diffvae_fna_propagates_launch_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + fna_ops = _load_fna_ops() + fna = _selected_fna() + problem = fna_ops.Problem(11, 11, 11, (11, 11, 11)) + query = torch.randn(1, problem.q_length, fna_ops.HEADS, 64, device="cuda", dtype=torch.bfloat16) + key = torch.randn(1, problem.kv_length, fna_ops.HEADS, 64, device="cuda", dtype=torch.bfloat16) + value = torch.randn_like(key) + calls = 0 + + def fail_launch(*args, **kwargs): + nonlocal calls + calls += 1 + raise RuntimeError("injected launch failure") + + monkeypatch.setattr(fna_ops, "_fna3d_tilelang", fail_launch) + with torch.inference_mode(): + for _ in range(2): + with pytest.raises(RuntimeError, match="injected launch failure"): + fna( + query, + key, + value, + shape=problem.shape, + window=problem.window, + ) + assert calls == 2 + + +@pytest.fixture +def isolated_runtime_state(): + targets = ( + (qk_rms_norm_ops, "_FAILED_ROPE_KEYS"), + (qk_rms_norm_ops, "_VERIFIED_ROPE_KEYS"), + (swiglu_ops, "_FAILED_KEYS"), + (swiglu_ops, "_VERIFIED_KEYS"), + (residual_adaln_ops, "_FAILED_ADALN_KEYS"), + (residual_adaln_ops, "_VERIFIED_ADALN_KEYS"), + (residual_adaln_ops, "_FAILED_ADD_DEVICES"), + (residual_adaln_ops, "_VERIFIED_ADD_DEVICES"), + ) + saved = [set(getattr(module, name)) for module, name in targets] + for module, name in targets: + getattr(module, name).clear() + try: + yield + finally: + for (module, name), state in zip(targets, saved, strict=True): + target = getattr(module, name) + target.clear() + target.update(state) + + +@pytest.mark.parametrize( + ("dim", "rows"), + [ + (256, 257), + (256, 16385), + (256, 131075), + (512, 65539), + (1024, 32771), + (2048, 16387), + ], +) +def test_diffvae_swiglu_tiled_is_bit_exact(dim: int, rows: int, isolated_runtime_state) -> None: + from diffusers.models.autoencoders.ltx2_diffusion_decoder import LTX2VideoVaeSwiGLU + + operators = _selected_operators() + generator = torch.Generator(device="cuda").manual_seed(20260829) + hidden_states = torch.randn((1, 1, 1, rows, dim), device="cuda", dtype=torch.bfloat16, generator=generator) + gate_weight = torch.randn((4 * dim, dim), device="cuda", dtype=torch.bfloat16, generator=generator) + up_weight = torch.randn_like(gate_weight) + down_weight = torch.randn((dim, 4 * dim), device="cuda", dtype=torch.bfloat16, generator=generator) + with torch.device("meta"): + reference = LTX2VideoVaeSwiGLU(dim, 4 * dim) + reference.w_gate.weight = torch.nn.Parameter(gate_weight, requires_grad=False) + reference.w_up.weight = torch.nn.Parameter(up_weight, requires_grad=False) + reference.w_down.weight = torch.nn.Parameter(down_weight, requires_grad=False) + + with torch.inference_mode(): + expected = reference(hidden_states) + actual = operators.swiglu(hidden_states, gate_weight, up_weight, down_weight) + + assert actual is not None + assert torch.equal(actual, expected) + + +@pytest.mark.parametrize( + ("dim", "expected_rows"), + [(256, 131072), (512, 65536), (1024, 32768), (2048, 16384)], +) +def test_diffvae_swiglu_tile_rows_keep_a_constant_hidden_budget(dim: int, expected_rows: int) -> None: + assert swiglu_ops._tile_rows(1_000_000, 4 * dim) == expected_rows + + +def test_diffvae_swiglu_tiled_permanently_falls_back_after_failure( + monkeypatch: pytest.MonkeyPatch, + isolated_runtime_state, +) -> None: + operators = _selected_operators() + hidden_states = torch.randn(1, 1, 1, 17, 256, device="cuda", dtype=torch.bfloat16) + gate_weight = torch.randn(1024, 256, device="cuda", dtype=torch.bfloat16) + up_weight = torch.randn_like(gate_weight) + down_weight = torch.randn(256, 1024, device="cuda", dtype=torch.bfloat16) + calls = 0 + + def fail_launch(*args, **kwargs): + nonlocal calls + calls += 1 + raise RuntimeError("injected launch failure") + + monkeypatch.setattr(swiglu_ops, "_launch", fail_launch) + with torch.inference_mode(): + assert operators.swiglu(hidden_states, gate_weight, up_weight, down_weight) is None + assert operators.swiglu(hidden_states, gate_weight, up_weight, down_weight) is None + + assert calls == 1 + + +@pytest.mark.parametrize("residual_count", [1, 2]) +def test_diffvae_residual_rms_norm_modulate_is_bit_exact( + residual_count: int, + isolated_runtime_state, +) -> None: + operators = _selected_operators() + generator = torch.Generator(device="cuda").manual_seed(20260829) + shape = (2, 3, 7, 7, 256) + x = torch.randn(shape, device="cuda", dtype=torch.bfloat16, generator=generator) + residual_a = torch.randn_like(x) + residual_b = torch.randn_like(x) if residual_count == 2 else None + norm_weight = torch.randn((256,), device="cuda", dtype=torch.bfloat16, generator=generator) + scale = torch.randn((2, 1, 1, 1, 256), device="cuda", dtype=torch.bfloat16, generator=generator) + shift = torch.randn_like(scale) + hidden_states = x + residual_a + if residual_b is not None: + hidden_states = hidden_states + residual_b + expected = torch.nn.functional.rms_norm(hidden_states, (256,), norm_weight, eps=1e-6) * (1 + scale) + shift + + with torch.inference_mode(): + actual = operators.residual_norm( + x, + residual_a, + residual_b, + norm_weight, + scale, + shift, + 1e-6, + ) + + assert actual is not None + assert torch.equal(actual, expected) + + +def test_diffvae_residual_add3_is_bit_exact(isolated_runtime_state) -> None: + operators = _selected_operators() + generator = torch.Generator(device="cuda").manual_seed(20260829) + tensors = [ + torch.randn((2, 3, 7, 7, 256), device="cuda", dtype=torch.bfloat16, generator=generator) for _ in range(4) + ] + expected = tensors[0] + tensors[1] + expected = expected + tensors[2] + expected = expected + tensors[3] + + with torch.inference_mode(): + actual = operators.residual_add(*tensors) + + assert actual is not None + assert torch.equal(actual, expected) + + +def test_diffvae_residual_adaln_permanently_falls_back_after_failure( + monkeypatch: pytest.MonkeyPatch, + isolated_runtime_state, +) -> None: + operators = _selected_operators() + shape = (1, 3, 7, 7, 256) + x = torch.randn(shape, device="cuda", dtype=torch.bfloat16) + residual = torch.randn_like(x) + weight = torch.randn(256, device="cuda", dtype=torch.bfloat16) + scale = torch.randn(1, 1, 1, 1, 256, device="cuda", dtype=torch.bfloat16) + shift = torch.randn_like(scale) + calls = 0 + + def fail_launch(*args, **kwargs): + nonlocal calls + calls += 1 + raise RuntimeError("injected launch failure") + + monkeypatch.setattr(residual_adaln_ops, "_launch_adaln", fail_launch) + with torch.inference_mode(): + for _ in range(2): + assert operators.residual_norm(x, residual, None, weight, scale, shift, 1e-6) is None + + assert calls == 1 + + +def _tables( + frames: int, + height: int, + width: int, + dim_split: tuple[int, int, int] = _DIM_SPLIT, + base: float = 10000.0, +) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]: + tables = [] + for length, dim in zip((frames, height, width), dim_split, strict=True): + exponents = torch.arange(0, dim, 2, dtype=torch.float64, device="cuda") / dim + inv_freqs = (1.0 / base**exponents).to(torch.float32) + positions = torch.arange(length, dtype=torch.float32, device="cuda") + angles = positions[:, None] * inv_freqs[None, :] + tables.append((angles.cos(), angles.sin())) + return tuple(tables) + + +def _apply_rope_reference( + hidden_states: torch.Tensor, + tables: tuple[tuple[torch.Tensor, torch.Tensor], ...], + dim_split: tuple[int, int, int] = _DIM_SPLIT, +) -> torch.Tensor: + outputs = [] + offset = 0 + for axis, (dim, (cos, sin)) in enumerate(zip(dim_split, tables, strict=True), 1): + chunk = hidden_states[..., offset : offset + dim] + pairs = chunk.reshape(*chunk.shape[:-1], dim // 2, 2) + even = pairs[..., 0].float() + odd = pairs[..., 1].float() + shape = [1, 1, 1, 1, 1, dim // 2] + shape[axis] = cos.shape[0] + cos = cos.reshape(shape) + sin = sin.reshape(shape) + rotated = torch.stack([even * cos - odd * sin, even * sin + odd * cos], dim=-1) + outputs.append(rotated.reshape(chunk.shape).to(hidden_states.dtype)) + offset += dim + return torch.cat(outputs, dim=-1) + + +@pytest.mark.parametrize( + ("frames", "height", "width", "heads"), + [(3, 7, 7, 1), (31, 136, 192, 4)], +) +def test_diffvae_qk_rms_norm_scale_rope_3d_is_bit_exact( + frames: int, + height: int, + width: int, + heads: int, + isolated_runtime_state, +) -> None: + operators = _selected_operators() + generator = torch.Generator(device="cuda").manual_seed(20260829) + shape = (1, frames, height, width, heads, 64) + query = torch.randn(shape, device="cuda", dtype=torch.bfloat16, generator=generator) + key = torch.randn_like(query) + query_weight = torch.randn((64,), device="cuda", dtype=torch.bfloat16, generator=generator) + key_weight = torch.randn((64,), device="cuda", dtype=torch.bfloat16, generator=generator) + tables = _tables(frames, height, width) + expected = ( + _apply_rope_reference(torch.nn.functional.rms_norm(query, (64,), query_weight, 1e-6) * 0.125, tables), + _apply_rope_reference(torch.nn.functional.rms_norm(key, (64,), key_weight, 1e-6), tables), + ) + + with torch.inference_mode(): + actual = operators.qk_norm_rope( + query, + key, + query_weight, + key_weight, + 1e-6, + 0.125, + _DIM_SPLIT, + 10000.0, + ) + + assert actual is not None + assert torch.equal(actual[0], expected[0]) + assert torch.equal(actual[1], expected[1]) + + +def test_diffvae_qk_rms_norm_scale_rope_3d_permanently_falls_back_after_failure( + monkeypatch: pytest.MonkeyPatch, + isolated_runtime_state, +) -> None: + operators = _selected_operators() + query = torch.randn(1, 3, 7, 7, 1, 64, device="cuda", dtype=torch.bfloat16) + key = torch.randn_like(query) + weight = torch.ones(64, device="cuda", dtype=torch.bfloat16) + calls = 0 + + def fail_launch(*args, **kwargs): + nonlocal calls + calls += 1 + raise RuntimeError("injected launch failure") + + monkeypatch.setattr(qk_rms_norm_ops, "_launch_combined", fail_launch) + with torch.inference_mode(): + for _ in range(2): + assert operators.qk_norm_rope(query, key, weight, weight, 1e-6, 0.125, _DIM_SPLIT, 10000.0) is None + + assert calls == 1 diff --git a/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py b/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py new file mode 100644 index 000000000..6ffff3136 --- /dev/null +++ b/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py @@ -0,0 +1,123 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import sys +from dataclasses import dataclass + +import pytest +import torch +from vllm.platforms.interface import DeviceCapability + +from vllm_omni.diffusion.models.ltx2 import ops as ltx2_platform + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@dataclass(frozen=True) +class _TensorProbe: + is_cuda: bool + device: torch.device + + +@pytest.fixture(autouse=True) +def clear_platform_cache(): + ltx2_platform._cuda_compute_capability.cache_clear() + try: + yield + finally: + ltx2_platform._cuda_compute_capability.cache_clear() + + +@pytest.mark.parametrize( + ("capability", "triton", "tilelang", "fusion_expected", "fna_expected"), + [ + (80, True, True, False, False), + (90, True, True, True, True), + (90, True, False, True, False), + (90, False, True, False, False), + (100, True, True, False, False), + (103, True, True, False, False), + (110, True, True, False, False), + (120, True, True, False, False), + (121, True, True, False, False), + ], +) +def test_production_resolver_and_tensor_eligibility_agree( + monkeypatch, capability, triton, tilelang, fusion_expected, fna_expected +): + monkeypatch.setattr(ltx2_platform, "HAS_TRITON", triton) + monkeypatch.setattr(ltx2_platform, "has_tilelang", lambda: tilelang) + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: True) + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: True) + monkeypatch.setattr( + ltx2_platform.current_omni_platform, + "get_device_capability", + lambda device_id=0: DeviceCapability(major=capability // 10, minor=capability % 10), + ) + # Resolution must be lazy even when FNA is selected. + monkeypatch.setitem(sys.modules, "vllm_omni.diffusion.models.ltx2.ops.fna", None) + monkeypatch.setattr(torch.compiler, "is_compiling", lambda: False) + device = torch.device("cuda:0") + operators = ltx2_platform.resolve_ltx2_vae_operators(device) + assert (operators is not None) is fusion_expected + assert (operators is not None and operators.fna is not None) is fna_expected + if operators is not None: + from vllm_omni.diffusion.models.ltx2.ops import qk_rms_norm, residual_adaln, swiglu + + assert operators.qk_norm_rope is qk_rms_norm.try_qk_rms_norm_scale_rope_3d_exact + assert operators.residual_norm is residual_adaln.try_residual_rms_norm_modulate_exact + assert operators.residual_add is residual_adaln.try_residual_add3_exact + assert operators.swiglu is swiglu.try_swiglu_tiled_exact + + tensor = _TensorProbe(is_cuda=True, device=device) + with torch.inference_mode(): + assert ltx2_platform.is_ltx2_fusion_eligible(tensor) is fusion_expected + assert ltx2_platform.is_ltx2_fna_eligible(tensor) is fna_expected + + +@pytest.mark.parametrize( + ("device", "is_cuda", "available"), + [("cpu", True, True), ("cuda:0", False, True), ("cuda:0", True, False)], +) +def test_resolver_rejects_unsupported_platform_before_capability_lookup(monkeypatch, device, is_cuda, available): + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: is_cuda) + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: available) + + def unexpected_lookup(_index): + raise AssertionError("Unsupported platforms must not query CUDA capability") + + monkeypatch.setattr(ltx2_platform, "_cuda_compute_capability", unexpected_lookup) + assert ltx2_platform.resolve_ltx2_vae_operators(torch.device(device)) is None + + +@pytest.mark.parametrize(("compiling", "grad_enabled"), [(True, False), (False, True)]) +def test_runtime_guards_bypass_operator_resolution(monkeypatch, compiling, grad_enabled): + monkeypatch.setattr(torch.compiler, "is_compiling", lambda: compiling) + monkeypatch.setattr(torch, "is_grad_enabled", lambda: grad_enabled) + + def unexpected_resolve(_device): + raise AssertionError("Ineligible runtime must not select eager operators") + + monkeypatch.setattr(ltx2_platform, "resolve_ltx2_vae_operators", unexpected_resolve) + tensor = _TensorProbe(is_cuda=True, device=torch.device("cuda:0")) + assert not ltx2_platform.is_ltx2_fusion_eligible(tensor) + assert not ltx2_platform.is_ltx2_fna_eligible(tensor) + + +def test_device_capability_is_cached(monkeypatch): + calls = 0 + monkeypatch.setattr(ltx2_platform, "HAS_TRITON", True) + + def get_device_capability(device_id=0): + nonlocal calls + calls += 1 + return DeviceCapability(major=9, minor=0) + + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_cuda", lambda: True) + monkeypatch.setattr(ltx2_platform.current_omni_platform, "is_available", lambda: True) + monkeypatch.setattr(ltx2_platform.current_omni_platform, "get_device_capability", get_device_capability) + assert ltx2_platform._cuda_compute_capability(0) == 90 + assert ltx2_platform._cuda_compute_capability(0) == 90 + assert calls == 1 diff --git a/tests/diffusion/models/ltx2/test_ltx2_vae.py b/tests/diffusion/models/ltx2/test_ltx2_vae.py index b9bc52d94..e387eb7a4 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_vae.py +++ b/tests/diffusion/models/ltx2/test_ltx2_vae.py @@ -3,6 +3,8 @@ """Unit tests for LTX video VAE tiling and distributed decode behavior.""" +from collections.abc import Callable +from dataclasses import dataclass from types import SimpleNamespace from typing import Any @@ -35,6 +37,22 @@ def test_ltx_vocoder_deterministic_context(monkeypatch, initial_deterministic, f assert {name: getattr(torch.backends.cudnn, name) for name in settings} == settings +@dataclass(frozen=True) +class _OperatorSetProbe: + fna: Callable[..., torch.Tensor] | None + + +@dataclass +class _AttentionProbe: + kernel_size: tuple[int, int, int] + head_dim: int + to_out: tuple[torch.nn.Module, ...] + projected: torch.Tensor + + def project_qkv(self, _hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + return self.projected, self.projected, self.projected + + def test_ltx_base_vocoder_keeps_native_dtype(monkeypatch): import vllm_omni.diffusion.models.ltx2.ltx2_runtime as ltx_runtime @@ -61,9 +79,164 @@ def test_ltx_base_vocoder_keeps_native_dtype(monkeypatch): class TestLTXDiffusionDecoder: + def test_diffusion_decoder_reuses_diffusers_with_scoped_overrides(self, monkeypatch): + from diffusers.models.autoencoders.ltx2_diffusion_decoder import ( + LTX2VideoDiffusionDecoderModel as DiffusersModel, + ) + + from vllm_omni.diffusion.models.ltx2.vae.decoder import ( + LTX2VideoDiffusionDecoderModel, + LTX2VideoVaeDiffusionNABlock, + LTX2VideoVaeNeighborhoodAttention, + LTX2VideoVaeSwiGLU, + ) + + original_init = DiffusersModel.__init__ + original_parameters = {} + + def capture_parameters(self, *args, **kwargs): + original_init(self, *args, **kwargs) + original_parameters.update(self.named_parameters()) + + with torch.device("meta"): + reference = DiffusersModel() + monkeypatch.setattr(DiffusersModel, "__init__", capture_parameters) + model = LTX2VideoDiffusionDecoderModel() + assert isinstance(model, DiffusersModel) + assert all(parameter is original_parameters[name] for name, parameter in model.named_parameters()) + assert {name: tensor.shape for name, tensor in model.state_dict().items()} == { + name: tensor.shape for name, tensor in reference.state_dict().items() + } + + modules = tuple(model.decoder.modules()) + expected_blocks = sum(model.config.decoder_stage_depths) + assert sum(isinstance(module, LTX2VideoVaeNeighborhoodAttention) for module in modules) == expected_blocks + assert sum(isinstance(module, LTX2VideoVaeSwiGLU) for module in modules) == expected_blocks + assert ( + sum(isinstance(module, LTX2VideoVaeDiffusionNABlock) for module in modules) + == model.config.decoder_stage_depths[-1] + ) + + assert type(model.decoder).__module__.startswith("vllm_omni.") + assert model.tiled_decode.__func__ is DiffusersModel.tiled_decode + assert type(reference.decoder) is type(model.decoder).__bases__[0] + + def test_natten_processor_routes_stage5_through_tilelang_fna(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2.vae import decoder as diffvae_modules + + processor = object.__new__(diffvae_modules.LTX2VideoVaeNeighborhoodNattenProcessor) + processor.backend = None + fallback_calls = [] + processor._na3d = lambda *args, **kwargs: fallback_calls.append((args, kwargs)) + + hidden_states = torch.randn(1, 11, 11, 11, 256, dtype=torch.bfloat16) + projected = hidden_states.view(1, 11, 11, 11, 4, 64) + + class FakeAttention: + kernel_size = (11, 11, 11) + head_dim = 64 + to_out = (torch.nn.Identity(),) + + @staticmethod + def project_qkv(_hidden_states): + return projected, projected, projected + + def token_permute(value, _tile, *, flip_tiled_dims): + assert flip_tiled_dims + return value, value.shape, None + + def token_unpermute(value, *, token_layout_shape, tile_shape, flip_tiled_dims): + assert token_layout_shape == projected.shape + assert tile_shape == (4, 4, 4) + assert flip_tiled_dims + return value + + fast_path_calls = [] + + def fast_path(query, key, value, *, shape, window): + fast_path_calls.append((query, key, value, shape, window)) + return query + + monkeypatch.setattr(diffvae_modules, "is_ltx2_fna_eligible", lambda _tensor: True) + monkeypatch.setattr( + diffvae_modules, "resolve_ltx2_vae_operators", lambda _device: _OperatorSetProbe(fna=fast_path) + ) + monkeypatch.setattr( + diffvae_modules, + "_load_natten_token_permutation", + lambda: (token_permute, token_unpermute), + ) + + output = processor(FakeAttention(), hidden_states) + + assert torch.equal(output, hidden_states) + assert len(fast_path_calls) == 1 + assert fast_path_calls[0][3:] == ((11, 11, 11), (11, 11, 11)) + assert not fallback_calls + + @pytest.mark.parametrize("reason", ["device", "head_dim", "channels", "dtype", "tilelang_missing", "kernel"]) + def test_natten_processor_keeps_natten_when_fast_path_does_not_apply(self, monkeypatch, reason): + from vllm_omni.diffusion.models.ltx2.vae import decoder as ops + + processor = object.__new__(ops.LTX2VideoVaeNeighborhoodNattenProcessor) + processor.backend = None + channels = 128 if reason == "channels" else 256 + head_dim = 32 if reason == "head_dim" else 64 + x = torch.zeros(1, 11, 11, 11, channels, dtype=torch.float32 if reason == "dtype" else torch.bfloat16) + projected = x.view(1, 11, 11, 11, channels // head_dim, head_dim) + attn = _AttentionProbe( + kernel_size=(3, 5, 5) if reason == "kernel" else (11, 11, 11), + head_dim=head_dim, + to_out=(torch.nn.Identity(),), + projected=projected, + ) + calls = [] + + def natten(q, k, v, **kwargs): + calls.append(kwargs) + return q + + def unexpected_native_load(*_args, **_kwargs): + raise AssertionError("Native FNA must not be loaded for an unsupported configuration") + + processor._na3d = natten + monkeypatch.setattr(ops, "is_ltx2_fna_eligible", lambda _tensor: reason != "device") + monkeypatch.setattr( + ops, + "resolve_ltx2_vae_operators", + lambda _device: _OperatorSetProbe(fna=None if reason == "tilelang_missing" else unexpected_native_load), + ) + assert torch.equal(processor(attn, x), x) + assert calls == [{"kernel_size": attn.kernel_size, "scale": 1.0, "backend": None}] + + def test_diffusion_decoder_noneligible_block_uses_diffusers_forward(self, monkeypatch): + from vllm_omni.diffusion.models.ltx2.vae import decoder as diffvae_modules + + block = object.__new__(diffvae_modules.LTX2VideoVaeDiffusionNABlock) + torch.nn.Module.__init__(block) + expected = torch.randn(1, 1, 1, 1, 1) + calls = [] + + def upstream_forward(_self, hidden_states, latent_context, modulation, block_mask): + assert block_mask is None + calls.append((hidden_states, latent_context, modulation)) + return expected + + monkeypatch.setattr( + diffvae_modules.diffusers_decoder.LTX2VideoVaeDiffusionNABlock, + "forward", + upstream_forward, + ) + hidden_states = torch.randn(1, 1, 1, 1, 1) + latent_context = torch.randn_like(hidden_states) + modulation = (torch.randn_like(hidden_states),) + + assert block.forward(hidden_states, latent_context, modulation) is expected + assert calls == [(hidden_states, latent_context, modulation)] + @pytest.mark.parametrize("mode", ["spatial_shard_height", "spatial_shard_width"]) def test_distributed_diffusion_decoder_rejects_non_tile_parallel_modes(self, mode): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import ( + from vllm_omni.diffusion.models.ltx2.vae.distributed import ( DistributedLTX2VideoDiffusionDecoderModel, ) @@ -75,7 +248,7 @@ class TestLTXDiffusionDecoder: model.set_parallel_size(2, mode=mode) def test_distributed_diffusion_decoder_accepts_tile_parallel_mode(self): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import ( + from vllm_omni.diffusion.models.ltx2.vae.distributed import ( DistributedLTX2VideoDiffusionDecoderModel, ) @@ -91,7 +264,7 @@ class TestLTXDiffusionDecoder: assert calls == [(4, "tile")] def test_short_clip_keeps_stage5_temporal_context_then_crops_output(self): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import ( + from vllm_omni.diffusion.models.ltx2.vae.decoder import ( LTX2VideoDiffusionDecoder3d, LTX2VideoDiffusionDecoderModel, ) @@ -166,7 +339,7 @@ class TestLTXDiffusionDecoder: _ltx2_use_diffusion_decoder(SimpleNamespace(extras={"ltx2_use_conv_vae": "true"}), "2.5") def test_native_diffusion_decoder_conversion_splits_qkv_and_folds_gates(self): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import ( + from vllm_omni.diffusion.models.ltx2.vae.decoder import ( convert_ltx25_native_diffusion_decoder_state_dict, ) @@ -213,7 +386,7 @@ class TestLTXDiffusionDecoder: assert not any("type_emb" in key or "coarse" in key or "gate_msa" in key for key in converted) def test_native_diffusion_decoder_conversion_rejects_invalid_fused_qkv(self): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import ( + from vllm_omni.diffusion.models.ltx2.vae.decoder import ( convert_ltx25_native_diffusion_decoder_state_dict, ) @@ -411,8 +584,8 @@ class TestLTXDiffusionDecoder: assert output.output[1].numel() == 0 def test_diffusion_decoder_patch_parallel_size_one_uses_native_tiling(self, monkeypatch): - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder import LTX2VideoDiffusionDecoderModel - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import ( + from vllm_omni.diffusion.models.ltx2.vae.decoder import LTX2VideoDiffusionDecoderModel + from vllm_omni.diffusion.models.ltx2.vae.distributed import ( DistributedLTX2VideoDiffusionDecoderModel, ) @@ -447,7 +620,7 @@ class TestLTXDiffusionDecoder: def test_distributed_diffusion_tiles_preserve_serial_noise_order(self, monkeypatch): from diffusers.utils.torch_utils import randn_tensor - from vllm_omni.diffusion.models.ltx2.ltx2_diffusion_decoder_distributed import ( + from vllm_omni.diffusion.models.ltx2.vae.distributed import ( DistributedLTX2VideoDiffusionDecoderModel, ) diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_components.py b/vllm_omni/diffusion/models/ltx2/ltx2_components.py index 72e63a22f..f1dcc38bb 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_components.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_components.py @@ -15,7 +15,6 @@ from typing import TYPE_CHECKING, Any import torch from diffusers import AutoencoderKLLTX2Audio, AutoencoderKLLTX2Video, FlowMatchEulerDiscreteScheduler -from diffusers.models.autoencoders.ltx2_diffusion_decoder import LTX2VideoVaeNeighborhoodNattenProcessor from diffusers.pipelines.ltx2 import LTX2TextConnectors from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel from diffusers.pipelines.ltx2.vocoder import LTX2Vocoder @@ -36,12 +35,6 @@ from vllm_omni.transformers_utils.repo_utils import hf_api if TYPE_CHECKING: from vllm.model_executor.layers.quantization.base_config import QuantizationConfig -from .ltx2_diffusion_decoder import ( - LTX25_NATIVE_ARTIFACT_REVISION, - LTX25_NATIVE_DIFFUSION_DECODER_FILENAME, - LTX25_NATIVE_DIFFUSION_DECODER_REPO_ID, -) -from .ltx2_diffusion_decoder_distributed import DistributedLTX2VideoDiffusionDecoderModel from .ltx2_request import LTXCheckpointKind, validate_ltx_checkpoint from .ltx2_transformer import ( LTX2VideoTransformer3DModel, @@ -49,6 +42,13 @@ from .ltx2_transformer import ( apply_split_rotary_emb, to_ltx_padding_mask, ) +from .vae.decoder import ( + LTX25_NATIVE_ARTIFACT_REVISION, + LTX25_NATIVE_DIFFUSION_DECODER_FILENAME, + LTX25_NATIVE_DIFFUSION_DECODER_REPO_ID, + LTX2VideoVaeNeighborhoodNattenProcessor, +) +from .vae.distributed import DistributedLTX2VideoDiffusionDecoderModel try: from diffusers.pipelines.ltx2.vocoder import LTX2VocoderWithBWE diff --git a/vllm_omni/diffusion/models/ltx2/ops/__init__.py b/vllm_omni/diffusion/models/ltx2/ops/__init__.py new file mode 100644 index 000000000..93f2ff672 --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/__init__.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Shared operator selection for LTX-2 eager kernels and their tests.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from functools import cache + +import torch +from vllm.triton_utils import HAS_TRITON +from vllm.utils.import_utils import has_tilelang + +from vllm_omni.platforms import current_omni_platform + +# Pointwise fusions are launch/memory-traffic reductions whose first execution +# for every runtime key is checked bit-for-bit; failures use the ordinary model +# math. Both paths remain SM90-only until other architectures are validated. +# The FNA schedule is performance-tuned for SM90 and has no silent fallback. +_CUDA_COMPUTE_CAPABILITIES = frozenset({90}) + + +@dataclass(frozen=True) +class LTX2VAEOperatorSet: + qk_norm_rope: Callable[..., tuple[torch.Tensor, torch.Tensor] | None] + residual_norm: Callable[..., torch.Tensor | None] + residual_add: Callable[..., torch.Tensor | None] + swiglu: Callable[..., torch.Tensor | None] + fna: Callable[..., torch.Tensor] | None + + +def _fna3d_tilelang(*args, **kwargs): + # Selecting an operator set must not import the optional compiler backend. + from .fna import fna3d_tilelang + + return fna3d_tilelang(*args, **kwargs) + + +@cache +def _operator_set(with_fna: bool) -> LTX2VAEOperatorSet: + from .qk_rms_norm import try_qk_rms_norm_scale_rope_3d_exact + from .residual_adaln import try_residual_add3_exact, try_residual_rms_norm_modulate_exact + from .swiglu import try_swiglu_tiled_exact + + return LTX2VAEOperatorSet( + qk_norm_rope=try_qk_rms_norm_scale_rope_3d_exact, + residual_norm=try_residual_rms_norm_modulate_exact, + residual_add=try_residual_add3_exact, + swiglu=try_swiglu_tiled_exact, + fna=_fna3d_tilelang if with_fna else None, + ) + + +@cache +def _cuda_compute_capability(device_index: int) -> int | None: + if not HAS_TRITON or not current_omni_platform.is_cuda() or not current_omni_platform.is_available(): + return None + capability = current_omni_platform.get_device_capability(device_id=device_index) + return capability.to_int() if capability is not None else None + + +def resolve_ltx2_vae_operators(device: torch.device) -> LTX2VAEOperatorSet | None: + """Select the production operators, or return None for unsupported devices.""" + if device.type != "cuda" or not current_omni_platform.is_cuda() or not current_omni_platform.is_available(): + return None + device_index = device.index if device.index is not None else torch.accelerator.current_device_index() + if _cuda_compute_capability(int(device_index)) not in _CUDA_COMPUTE_CAPABILITIES: + return None + return _operator_set(has_tilelang()) + + +def _eligible_operators(tensor: torch.Tensor) -> LTX2VAEOperatorSet | None: + if not tensor.is_cuda or torch.compiler.is_compiling() or torch.is_grad_enabled(): + return None + return resolve_ltx2_vae_operators(tensor.device) + + +def is_ltx2_fusion_eligible(tensor: torch.Tensor) -> bool: + """Allow self-verifying pointwise fusions on validated SM90 devices.""" + + return _eligible_operators(tensor) is not None + + +def is_ltx2_fna_eligible(tensor: torch.Tensor) -> bool: + """Keep the strict, Hopper-tuned TileLang FNA schedule on SM90.""" + + operators = _eligible_operators(tensor) + return operators is not None and operators.fna is not None + + +__all__ = ["LTX2VAEOperatorSet", "resolve_ltx2_vae_operators", "is_ltx2_fna_eligible", "is_ltx2_fusion_eligible"] diff --git a/vllm_omni/diffusion/models/ltx2/ops/_numerics.py b/vllm_omni/diffusion/models/ltx2/ops/_numerics.py new file mode 100644 index 000000000..bdc22b142 --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/_numerics.py @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Numerical primitives shared by exact LTX-2 Triton kernels.""" + +from vllm.triton_utils import tl, triton + + +@triton.jit +def round_bf16_to_fp32(value): + """RNE-round FP32 to BF16 precision while retaining an FP32 register.""" + + bits = value.to(tl.int32, bitcast=True) + rounding_bias = 0x7FFF + ((bits >> 16) & 1) + rounded_bits = (bits + rounding_bias) & -65536 + return rounded_bits.to(tl.float32, bitcast=True) + + +@triton.jit +def add_rn_f32(x, y): + return tl.inline_asm_elementwise( + asm="add.rn.f32 $0, $1, $2;", + constraints="=f,f,f", + args=[x, y], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +@triton.jit +def mul_rn_f32(x, y): + return tl.inline_asm_elementwise( + asm="mul.rn.f32 $0, $1, $2;", + constraints="=f,f,f", + args=[x, y], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +@triton.jit +def fma_rn_f32(x, y, accumulator): + return tl.inline_asm_elementwise( + asm="fma.rn.f32 $0, $1, $2, $3;", + constraints="=f,f,f,f", + args=[x, y, accumulator], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +@triton.jit +def rsqrt_approx_f32(x): + return tl.inline_asm_elementwise( + asm="rsqrt.approx.f32 $0, $1;", + constraints="=f,f", + args=[x], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +@triton.jit +def shfl_down_f32(value, delta: tl.constexpr): + return tl.inline_asm_elementwise( + asm="shfl.sync.down.b32 $0, $1, $2, 0x1f, 0xffffffff;", + constraints="=f,f,n", + args=[value, delta], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +__all__ = [ + "add_rn_f32", + "fma_rn_f32", + "mul_rn_f32", + "round_bf16_to_fp32", + "rsqrt_approx_f32", + "shfl_down_f32", +] diff --git a/vllm_omni/diffusion/models/ltx2/ops/fna.py b/vllm_omni/diffusion/models/ltx2/ops/fna.py new file mode 100644 index 000000000..d42477d9a --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/fna.py @@ -0,0 +1,525 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""TileLang FNA for the LTX-2.5 DiffVAE stage-5 schedule. + +This intentionally reuses NATTEN's token permutation. It only replaces the +middle BF16 attention kernel for Q tiles (4, 4, 4), KV tiles (4, 4, 8), +head_dim=64, stride=dilation=1, and scale=1.0. +""" + +import functools +import math +from collections import OrderedDict +from dataclasses import dataclass + +import torch +from vllm.tilelang_utils import T, tilelang + +Q_TILE = (4, 4, 4) +KV_TILE = (4, 4, 8) +Q_TOKENS = math.prod(Q_TILE) +KV_TOKENS = math.prod(KV_TILE) +HEAD_DIM = 64 +HEADS = 4 +LOG2_E = 1.4426950408889634 +KV_BUCKET_LIMITS = (32, 48, 60, 75) +PATTERN_TABLE_SIZE = 16 +KV_TILE_ID_BITS = 15 +SHAPE_CACHE_SIZE = 16 + + +@dataclass(frozen=True) +class Problem: + frames: int + height: int + width: int + window: tuple[int, int, int] + + def __post_init__(self) -> None: + if min(self.frames, self.height, self.width) <= 0: + raise ValueError(f"invalid FNA problem: {self}") + if len(self.window) != 3 or any(x <= 0 for x in self.window): + raise ValueError(f"invalid FNA window: {self.window}") + if any(w > n for w, n in zip(self.window, self.shape)): + raise ValueError(f"window {self.window} exceeds shape {self.shape}") + if math.prod(self.kv_grid) > 1 << KV_TILE_ID_BITS: + raise ValueError(f"KV tile grid exceeds {KV_TILE_ID_BITS}-bit metadata encoding: {self.kv_grid}") + + @property + def shape(self) -> tuple[int, int, int]: + return self.frames, self.height, self.width + + @property + def q_padded(self) -> tuple[int, int, int]: + return tuple(_round_up(n, tile) for n, tile in zip(self.shape, Q_TILE)) + + @property + def kv_padded(self) -> tuple[int, int, int]: + return tuple(_round_up(n, tile) for n, tile in zip(self.shape, KV_TILE)) + + @property + def q_grid(self) -> tuple[int, int, int]: + return tuple(n // tile for n, tile in zip(self.q_padded, Q_TILE)) + + @property + def kv_grid(self) -> tuple[int, int, int]: + return tuple(n // tile for n, tile in zip(self.kv_padded, KV_TILE)) + + @property + def q_length(self) -> int: + return math.prod(self.q_padded) + + @property + def kv_length(self) -> int: + return math.prod(self.kv_padded) + + +def _round_up(value: int, multiple: int) -> int: + return (value + multiple - 1) // multiple * multiple + + +def _window_start(index: int, window: int, length: int) -> int: + return min(max(index - window // 2, 0), length - window) + + +@functools.lru_cache(maxsize=32) +def _dimension_pattern_table( + length: int, + q_tile_size: int, + kv_tile_size: int, + window: int, +) -> tuple[torch.Tensor, torch.Tensor]: + """Map (Q tile, KV tile) pairs to a small table of packed 1D masks.""" + q_tiles = _round_up(length, q_tile_size) // q_tile_size + kv_tiles = _round_up(length, kv_tile_size) // kv_tile_size + zero = (0,) * q_tile_size + patterns: list[tuple[int, ...]] = [zero] + pattern_ids = {zero: 0} + ids = torch.zeros((q_tiles, kv_tiles), dtype=torch.int64) + + for q_tile in range(q_tiles): + for kv_tile in range(kv_tiles): + rows: list[int] = [] + for q_inner in range(q_tile_size): + q = q_tile * q_tile_size + q_inner + bits = 0 + if q < length: + start = _window_start(q, window, length) + for kv_inner in range(kv_tile_size): + kv = kv_tile * kv_tile_size + kv_inner + if kv < length and start <= kv < start + window: + bits |= 1 << kv_inner + rows.append(bits) + signature = tuple(rows) + pattern_id = pattern_ids.get(signature) + if pattern_id is None: + pattern_id = len(patterns) + pattern_ids[signature] = pattern_id + patterns.append(signature) + ids[q_tile, kv_tile] = pattern_id + + if len(patterns) > PATTERN_TABLE_SIZE: + raise AssertionError(f"4-bit pattern id overflow for length={length}: {len(patterns)}") + packed = [] + for pattern in patterns: + value = sum(bits << (q * kv_tile_size) for q, bits in enumerate(pattern)) + # Store uint32 bit patterns in an int32 tensor. Arithmetic right shift + # followed by `& 1` still extracts the desired bit when bit 31 is set. + packed.append(value if value < (1 << 31) else value - (1 << 32)) + # The four-bit pattern ids address at most 16 entries. Pad to that fixed + # size so T/H/W-specific table lengths cannot specialize the GPU kernel. + packed.extend([0] * (PATTERN_TABLE_SIZE - len(packed))) + return ids, torch.tensor(packed, dtype=torch.int32) + + +@functools.lru_cache(maxsize=SHAPE_CACHE_SIZE) +def build_kv_tile_map(problem: Problem) -> tuple[torch.Tensor, torch.Tensor]: + """Return packed KV tile ids and valid counts for each permuted Q tile.""" + q_grid_t, q_grid_h, q_grid_w = problem.q_grid + kv_grid_t, kv_grid_h, _ = problem.kv_grid + rows: list[list[int]] = [] + + # Token permutation flattens outer tile modes as (W, H, T), with T + # changing fastest. Keep KV tiles in the same increasing linear order. + for q_w_tile in range(q_grid_w): + for q_h_tile in range(q_grid_h): + for q_t_tile in range(q_grid_t): + ranges: list[range] = [] + for q_tile_idx, q_tile_size, kv_tile_size, window, length in zip( + (q_t_tile, q_h_tile, q_w_tile), + Q_TILE, + KV_TILE, + problem.window, + problem.shape, + ): + first_q = q_tile_idx * q_tile_size + last_q = min(first_q + q_tile_size, length) - 1 + first_k = _window_start(first_q, window, length) + last_k = _window_start(last_q, window, length) + window - 1 + ranges.append(range(first_k // kv_tile_size, last_k // kv_tile_size + 1)) + + t_tiles, h_tiles, w_tiles = ranges + row = [ + (kv_w * kv_grid_h + kv_h) * kv_grid_t + kv_t + for kv_w in w_tiles + for kv_h in h_tiles + for kv_t in t_tiles + ] + if not row: + raise AssertionError("every Q tile must overlap at least one KV tile") + rows.append(row) + + max_tiles = max(map(len, rows)) + # Duplicate a valid tile in padding slots. The kernel masks those slots + # with `counts`; avoiding -1 keeps every indirect global load in bounds. + indices = torch.tensor( + [row + [row[0]] * (max_tiles - len(row)) for row in rows], + dtype=torch.int32, + ) + counts = torch.tensor([len(row) for row in rows], dtype=torch.int32) + return indices, counts + + +@functools.lru_cache(maxsize=SHAPE_CACHE_SIZE) +def build_kv_tile_metadata( + problem: Problem, +) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor]: + """Pack KV tile id and three separable mask pattern ids into int32.""" + indices, counts = build_kv_tile_map(problem) + q_grid_t, q_grid_h, _ = problem.q_grid + kv_grid_t, kv_grid_h, _ = problem.kv_grid + t_ids, t_patterns = _dimension_pattern_table(problem.frames, Q_TILE[0], KV_TILE[0], problem.window[0]) + h_ids, h_patterns = _dimension_pattern_table(problem.height, Q_TILE[1], KV_TILE[1], problem.window[1]) + w_ids, w_patterns = _dimension_pattern_table(problem.width, Q_TILE[2], KV_TILE[2], problem.window[2]) + + q_blocks = torch.arange(indices.shape[0], dtype=torch.int64)[:, None] + q_t = q_blocks % q_grid_t + q_h = (q_blocks // q_grid_t) % q_grid_h + q_w = q_blocks // (q_grid_t * q_grid_h) + kv = indices.to(torch.int64) + kv_t = kv % kv_grid_t + kv_h = (kv // kv_grid_t) % kv_grid_h + kv_w = kv // (kv_grid_t * kv_grid_h) + t_pattern_id = t_ids[q_t, kv_t] + h_pattern_id = h_ids[q_h, kv_h] + w_pattern_id = w_ids[q_w, kv_w] + metadata = kv | (t_pattern_id << 15) | (h_pattern_id << 19) | (w_pattern_id << 23) + active = torch.arange(indices.shape[1])[None, :] < counts[:, None] + metadata = torch.where(active, metadata, 0).to(torch.int32) + return metadata, t_patterns, h_patterns, w_patterns + + +@functools.lru_cache(maxsize=SHAPE_CACHE_SIZE) +def build_kv_tile_buckets( + problem: Problem, +) -> tuple[tuple[torch.Tensor, torch.Tensor, int], ...]: + """Group Q tiles by an upper bound on their useful KV tile count.""" + metadata, *_ = build_kv_tile_metadata(problem) + _, counts = build_kv_tile_map(problem) + maximum = int(counts.max()) + if maximum > KV_BUCKET_LIMITS[-1]: + raise ValueError( + f"FNA neighborhood needs {maximum} KV tiles, exceeding the supported ceiling {KV_BUCKET_LIMITS[-1]}" + ) + buckets = [] + lower = 0 + for limit in KV_BUCKET_LIMITS: + q_tile_ids = torch.nonzero((counts > lower) & (counts <= limit), as_tuple=False).flatten() + if q_tile_ids.numel(): + # Small grids can have fewer columns than the first bucket limit. + # Zero metadata encodes an all-masked tile, preserving the fixed ABI. + bucket_metadata = torch.zeros((q_tile_ids.numel(), limit), dtype=torch.int32) + columns = min(limit, metadata.shape[1]) + bucket_metadata[:, :columns] = metadata[q_tile_ids, :columns] + buckets.append( + ( + q_tile_ids.to(torch.int32), + bucket_metadata, + limit, + ) + ) + lower = limit + if limit >= maximum: + break + return tuple(buckets) + + +@functools.lru_cache(maxsize=32) +def compile_kernel( + max_kv_tiles: int, + *, + target: str = "auto", +): + """Compile one T/H/W-dynamic kernel for a fixed KV-count ceiling.""" + if max_kv_tiles not in KV_BUCKET_LIMITS: + raise ValueError(f"unsupported KV bucket ceiling: {max_kv_tiles}") + + q_length = T.dynamic("q_length") + kv_length = T.dynamic("kv_length") + q_tile_count = T.dynamic("q_tile_count") + + # Batch is fixed to one by the public guard. Excluding it from the + # kernel ABI keeps every physical stride static even when token lengths + # vary, preserving vectorized global/shared-memory copies. + q_shape = [q_length, HEADS, HEAD_DIM] + kv_shape = [kv_length, HEADS, HEAD_DIM] + q_tile_id_shape = [q_tile_count] + map_shape = [q_tile_count, max_kv_tiles] + pattern_shape = [PATTERN_TABLE_SIZE] + dtype = T.bfloat16 + accum_dtype = T.float32 + + @T.prim_func + def main( + query: T.Tensor(q_shape, dtype), + key: T.Tensor(kv_shape, dtype), + value: T.Tensor(kv_shape, dtype), + q_tile_ids: T.Tensor(q_tile_id_shape, T.int32), + kv_tile_metadata: T.Tensor(map_shape, T.int32), + t_mask_patterns: T.Tensor(pattern_shape, T.int32), + h_mask_patterns: T.Tensor(pattern_shape, T.int32), + w_mask_patterns: T.Tensor(pattern_shape, T.int32), + output: T.Tensor(q_shape, dtype), + ): + with T.Kernel(q_tile_count, HEADS, threads=128) as (bx, by): + q_shared = T.alloc_shared([Q_TOKENS, HEAD_DIM], dtype) + k_shared = T.alloc_shared([KV_TOKENS, HEAD_DIM], dtype) + v_shared = T.alloc_shared([KV_TOKENS, HEAD_DIM], dtype) + o_shared = T.alloc_shared([Q_TOKENS, HEAD_DIM], dtype) + scores = T.alloc_fragment([Q_TOKENS, KV_TOKENS], accum_dtype) + probs = T.alloc_fragment([Q_TOKENS, KV_TOKENS], dtype) + acc_o = T.alloc_fragment([Q_TOKENS, HEAD_DIM], accum_dtype) + row_max = T.alloc_fragment([Q_TOKENS], accum_dtype) + row_max_prev = T.alloc_fragment([Q_TOKENS], accum_dtype) + row_scale = T.alloc_fragment([Q_TOKENS], accum_dtype) + row_sum = T.alloc_fragment([Q_TOKENS], accum_dtype) + row_denom = T.alloc_fragment([Q_TOKENS], accum_dtype) + + q_block = q_tile_ids[bx] + # CPU metadata only emits padded Q/KV tiles that are fully backed + # by the permuted tensors. Expose those invariants so dynamic + # lengths do not add per-copy boundary predicates. + T.assume(q_block >= 0) + T.assume((q_block + 1) * Q_TOKENS <= q_length) + T.copy( + query[ + q_block * Q_TOKENS : (q_block + 1) * Q_TOKENS, + by, + :, + ], + q_shared, + ) + T.fill(acc_o, 0) + T.fill(row_denom, 0) + T.fill(row_max, -T.infinity(accum_dtype)) + + for block_iter in T.Pipelined(max_kv_tiles, num_stages=1): + metadata = kv_tile_metadata[bx, block_iter] + kv_block = metadata & 0x7FFF + T.assume((kv_block + 1) * KV_TOKENS <= kv_length) + t_mask = t_mask_patterns[(metadata >> 15) & 0xF] + h_mask = h_mask_patterns[(metadata >> 19) & 0xF] + w_mask = w_mask_patterns[(metadata >> 23) & 0xF] + T.copy( + key[ + kv_block * KV_TOKENS : (kv_block + 1) * KV_TOKENS, + by, + :, + ], + k_shared, + prefer_instruction="cp_async", + ) + + for i, j in T.Parallel(Q_TOKENS, KV_TOKENS): + q_t = i % Q_TILE[0] + q_h = (i // Q_TILE[0]) % Q_TILE[1] + q_w = i // (Q_TILE[0] * Q_TILE[1]) + k_t = j % KV_TILE[0] + k_h = (j // KV_TILE[0]) % KV_TILE[1] + k_w = j // (KV_TILE[0] * KV_TILE[1]) + valid = ( + (((t_mask >> (q_t * KV_TILE[0] + k_t)) & 1) != 0) + & (((h_mask >> (q_h * KV_TILE[1] + k_h)) & 1) != 0) + & (((w_mask >> (q_w * KV_TILE[2] + k_w)) & 1) != 0) + ) + scores[i, j] = T.if_then_else(valid, 0, -T.infinity(accum_dtype)) + + T.gemm( + q_shared, + k_shared, + scores, + transpose_B=True, + policy=T.GemmWarpPolicy.FullRow, + ) + T.copy(row_max, row_max_prev) + T.fill(row_max, -T.infinity(accum_dtype)) + T.reduce_max(scores, row_max, dim=1, clear=False) + # A KV tile is selected for the union of all 64 queries in + # this Q tile. It can therefore be fully masked for one + # individual query. Preserve that row's previous online + # softmax state instead of evaluating (-inf) - (-inf). + T.copy(row_max, row_sum) + for i in T.Parallel(Q_TOKENS): + has_score = row_sum[i] > -T.infinity(accum_dtype) + merged_max = T.max(row_sum[i], row_max_prev[i]) + row_max[i] = T.if_then_else(has_score, merged_max, row_max_prev[i]) + row_scale[i] = T.if_then_else( + has_score, + T.exp2((row_max_prev[i] - merged_max) * LOG2_E), + 1, + ) + # Match Hopper NATTEN's two separately rounded FP32 + # products before exp2. + row_sum[i] = T.if_then_else( + has_score, + merged_max * LOG2_E, + -T.infinity(accum_dtype), + ) + for i, j in T.Parallel(Q_TOKENS, KV_TOKENS): + scores[i, j] = T.if_then_else( + row_sum[i] > -T.infinity(accum_dtype), + T.exp2(scores[i, j] * LOG2_E - row_sum[i]), + 0, + ) + T.reduce_sum(scores, row_sum, dim=1) + for i in T.Parallel(Q_TOKENS): + row_denom[i] = row_denom[i] * row_scale[i] + row_sum[i] + T.copy(scores, probs) + for i, j in T.Parallel(Q_TOKENS, HEAD_DIM): + acc_o[i, j] *= row_scale[i] + + T.copy( + value[ + kv_block * KV_TOKENS : (kv_block + 1) * KV_TOKENS, + by, + :, + ], + v_shared, + prefer_instruction="cp_async", + ) + T.gemm(probs, v_shared, acc_o, policy=T.GemmWarpPolicy.FullRow) + + # Match NATTEN's epilogue: compute one reciprocal per query row, + # then multiply every output accumulator by that shared alpha. + for i in T.Parallel(Q_TOKENS): + row_scale[i] = T.if_then_else( + row_denom[i] > 0, + T.call_extern("float32", "__frcp_rn", row_denom[i]), + 0, + ) + for i, j in T.Parallel(Q_TOKENS, HEAD_DIM): + acc_o[i, j] *= row_scale[i] + T.copy(acc_o, o_shared) + T.copy( + o_shared, + output[ + q_block * Q_TOKENS : (q_block + 1) * Q_TOKENS, + by, + :, + ], + ) + + return tilelang.compile( + main, + out_idx=None, + target="auto" if target == "auto" else {"kind": "cuda", "arch": target}, + pass_configs={ + tilelang.PassConfigKey.TL_ENABLE_FAST_MATH: True, + # Keeping all work in the 128-thread consumer warpgroup avoids a + # separate producer warpgroup. The generated 240-register + # consumer can then admit two CTAs per SM instead of one. + tilelang.PassConfigKey.TL_DISABLE_WARP_SPECIALIZED: True, + }, + ) + + +_DEVICE_MAP_CACHE: OrderedDict[tuple[Problem, torch.device], tuple[object, ...]] = OrderedDict() + + +def _fna3d_tilelang( + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + *, + shape: tuple[int, int, int], + window: tuple[int, int, int], +) -> torch.Tensor: + problem = Problem(*shape, window=window) + if query.shape[1] != problem.q_length or key.shape[1] != problem.kv_length: + raise ValueError(f"permuted lengths do not match {problem}: Q={query.shape[1]}, KV={key.shape[1]}") + cache_key = (problem, query.device) + device_inputs = _DEVICE_MAP_CACHE.get(cache_key) + if device_inputs is None: + _, t_patterns, h_patterns, w_patterns = build_kv_tile_metadata(problem) + pattern_tables = tuple( + tensor.to(query.device, non_blocking=True) for tensor in (t_patterns, h_patterns, w_patterns) + ) + buckets = tuple( + ( + q_tile_ids.to(query.device, non_blocking=True), + metadata.to(query.device, non_blocking=True), + limit, + ) + for q_tile_ids, metadata, limit in build_kv_tile_buckets(problem) + ) + device_inputs = (pattern_tables, buckets) + _DEVICE_MAP_CACHE[cache_key] = device_inputs + if len(_DEVICE_MAP_CACHE) > SHAPE_CACHE_SIZE: + _DEVICE_MAP_CACHE.popitem(last=False) + else: + _DEVICE_MAP_CACHE.move_to_end(cache_key) + + pattern_tables, buckets = device_inputs + output = torch.empty_like(query) + for q_tile_ids, metadata, limit in buckets: + from tilelang.backend.target import determine_target + + target = str(determine_target("auto", return_object=True).attrs["arch"]) + kernel = compile_kernel(limit, target=target) + for batch in range(query.shape[0]): + kernel(query[batch], key[batch], value[batch], q_tile_ids, metadata, *pattern_tables, output[batch]) + return output + + +def fna3d_tilelang( + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + *, + shape: tuple[int, int, int], + window: tuple[int, int, int], +) -> torch.Tensor: + """Run native stage-5 FNA, or raise for unsupported inputs/launch failures. + + Q and KV use the documented token-tiled layout. Batch members share spatial + metadata; compilation is cached by target architecture and KV-count bucket. + """ + if not ( + query.is_cuda + and query.dtype is torch.bfloat16 + and query.ndim == key.ndim == value.ndim == 4 + and query.shape[0] > 0 + and query.shape[-2:] == (HEADS, HEAD_DIM) + and query.numel() > 0 + and query.is_contiguous() + and key.device == value.device == query.device + and key.dtype is value.dtype is query.dtype + and key.is_contiguous() + and value.is_contiguous() + and value.shape == key.shape + and key.shape[0] == query.shape[0] + and key.shape[-2:] == query.shape[-2:] + and len(shape) == 3 + and tuple(window) == (11, 11, 11) + ): + raise ValueError( + "LTX DiffVAE FNA requires contiguous CUDA BF16 [B, tokens, 4, 64] Q/K/V and an 11x11x11 window." + ) + if torch.is_grad_enabled() and any(x.requires_grad for x in (query, key, value)): + raise RuntimeError("LTX DiffVAE FNA is inference-only.") + with torch.accelerator.device_index(query.device.index): + return _fna3d_tilelang(query, key, value, shape=tuple(shape), window=tuple(window)) + + +__all__ = ["fna3d_tilelang"] diff --git a/vllm_omni/diffusion/models/ltx2/ops/qk_rms_norm.py b/vllm_omni/diffusion/models/ltx2/ops/qk_rms_norm.py new file mode 100644 index 000000000..be89aeca2 --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/qk_rms_norm.py @@ -0,0 +1,377 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Bit-exact multi-row Q/K RMSNorm for the LTX diffusion VAE decoder.""" + +from __future__ import annotations + +import logging +import math + +import torch +import torch.nn.functional as F +from vllm.triton_utils import tl, triton + +from . import is_ltx2_fusion_eligible +from ._numerics import ( + add_rn_f32, + fma_rn_f32, + mul_rn_f32, + round_bf16_to_fp32, + rsqrt_approx_f32, + shfl_down_f32, +) +from .rope_tables import get_rope_tables + +_HEAD_DIM = 64 +_ROWS_PER_PROGRAM = 2 +_VERIFY_ROWS = 256 +_FAILED_ROPE_KEYS: set[tuple[int | None, float, float, tuple[int, int, int], float]] = set() +_VERIFIED_ROPE_KEYS: set[tuple[int | None, float, float, tuple[int, int, int], float]] = set() + +logger = logging.getLogger(__name__) + + +@triton.jit +def _shuffle_lane_zero(value): + return tl.inline_asm_elementwise( + asm="shfl.sync.idx.b32 $0, $1, 0, 0x1f, 0xffffffff;", + constraints="=f,f", + args=[value], + dtype=tl.float32, + is_pure=True, + pack=1, + ) + + +@triton.jit +def _warp_sum(value): + value = add_rn_f32(value, shfl_down_f32(value, 16)) + value = add_rn_f32(value, shfl_down_f32(value, 8)) + value = add_rn_f32(value, shfl_down_f32(value, 4)) + value = add_rn_f32(value, shfl_down_f32(value, 2)) + value = add_rn_f32(value, shfl_down_f32(value, 1)) + return _shuffle_lane_zero(value) + + +@triton.jit +def _qk_rms_norm_scale_rope_3d_kernel( + query_output_ptr, + key_output_ptr, + query_ptr, + key_ptr, + query_weight_ptr, + key_weight_ptr, + cos_t_ptr, + sin_t_ptr, + cos_h_ptr, + sin_h_ptr, + cos_w_ptr, + sin_w_ptr, + rows, + tokens_per_batch, + height, + width, + heads, + eps, + query_scale, + rows_per_program: tl.constexpr, + head_dim: tl.constexpr, + pairs_t: tl.constexpr, + pairs_h: tl.constexpr, +): + threads = tl.arange(0, rows_per_program * 32) + local_row = threads // 32 + lane = threads % 32 + row = tl.program_id(0).to(tl.int64) * rows_per_program + local_row + valid_row = row < rows + reduction_valid = valid_row & (lane < 16) + reduction_base = row * head_dim + lane * 4 + + query_accumulator = tl.zeros((rows_per_program * 32,), dtype=tl.float32) + key_accumulator = tl.zeros((rows_per_program * 32,), dtype=tl.float32) + for element in tl.static_range(4): + query = tl.load(query_ptr + reduction_base + element, mask=reduction_valid, other=0.0).to(tl.float32) + key = tl.load(key_ptr + reduction_base + element, mask=reduction_valid, other=0.0).to(tl.float32) + query_accumulator = fma_rn_f32(query, query, query_accumulator) + key_accumulator = fma_rn_f32(key, key, key_accumulator) + query_rstd = rsqrt_approx_f32(_warp_sum(query_accumulator) / head_dim + eps) + key_rstd = rsqrt_approx_f32(_warp_sum(key_accumulator) / head_dim + eps) + + tokens = (row // heads) % tokens_per_batch + pos_w = tokens % width + pos_h = (tokens // width) % height + pos_t = tokens // (height * width) + pairs_per_head: tl.constexpr = head_dim // 2 + pairs_w: tl.constexpr = pairs_per_head - pairs_t - pairs_h + is_t = lane < pairs_t + is_h = (lane >= pairs_t) & (lane < pairs_t + pairs_h) + is_w = ~(is_t | is_h) + pair_t = lane + pair_h = lane - pairs_t + pair_w = lane - pairs_t - pairs_h + cos_t = tl.load(cos_t_ptr + pos_t * pairs_t + pair_t, mask=valid_row & is_t, other=0.0) + sin_t = tl.load(sin_t_ptr + pos_t * pairs_t + pair_t, mask=valid_row & is_t, other=0.0) + cos_h = tl.load(cos_h_ptr + pos_h * pairs_h + pair_h, mask=valid_row & is_h, other=0.0) + sin_h = tl.load(sin_h_ptr + pos_h * pairs_h + pair_h, mask=valid_row & is_h, other=0.0) + cos_w = tl.load(cos_w_ptr + pos_w * pairs_w + pair_w, mask=valid_row & is_w, other=0.0) + sin_w = tl.load(sin_w_ptr + pos_w * pairs_w + pair_w, mask=valid_row & is_w, other=0.0) + cos = cos_t + cos_h + cos_w + sin = sin_t + sin_h + sin_w + + even_column = lane * 2 + odd_column = even_column + 1 + even_offset = row * head_dim + even_column + odd_offset = row * head_dim + odd_column + query_even = tl.load(query_ptr + even_offset, mask=valid_row).to(tl.float32) + query_odd = tl.load(query_ptr + odd_offset, mask=valid_row).to(tl.float32) + key_even = tl.load(key_ptr + even_offset, mask=valid_row).to(tl.float32) + key_odd = tl.load(key_ptr + odd_offset, mask=valid_row).to(tl.float32) + query_even_weight = tl.load(query_weight_ptr + even_column).to(tl.float32) + query_odd_weight = tl.load(query_weight_ptr + odd_column).to(tl.float32) + key_even_weight = tl.load(key_weight_ptr + even_column).to(tl.float32) + key_odd_weight = tl.load(key_weight_ptr + odd_column).to(tl.float32) + + query_even = round_bf16_to_fp32(mul_rn_f32(query_even_weight, mul_rn_f32(query_rstd, query_even))) + query_odd = round_bf16_to_fp32(mul_rn_f32(query_odd_weight, mul_rn_f32(query_rstd, query_odd))) + query_even = round_bf16_to_fp32(mul_rn_f32(query_even, query_scale)) + query_odd = round_bf16_to_fp32(mul_rn_f32(query_odd, query_scale)) + key_even = round_bf16_to_fp32(mul_rn_f32(key_even_weight, mul_rn_f32(key_rstd, key_even))) + key_odd = round_bf16_to_fp32(mul_rn_f32(key_odd_weight, mul_rn_f32(key_rstd, key_odd))) + + tl.store( + query_output_ptr + even_offset, + add_rn_f32(mul_rn_f32(query_even, cos), -mul_rn_f32(query_odd, sin)), + mask=valid_row, + ) + tl.store( + query_output_ptr + odd_offset, + add_rn_f32(mul_rn_f32(query_even, sin), mul_rn_f32(query_odd, cos)), + mask=valid_row, + ) + tl.store( + key_output_ptr + even_offset, + add_rn_f32(mul_rn_f32(key_even, cos), -mul_rn_f32(key_odd, sin)), + mask=valid_row, + ) + tl.store( + key_output_ptr + odd_offset, + add_rn_f32(mul_rn_f32(key_even, sin), mul_rn_f32(key_odd, cos)), + mask=valid_row, + ) + + +def _weight_matches(value: torch.Tensor, weight: torch.Tensor) -> bool: + return ( + weight.is_cuda + and weight.device == value.device + and weight.dtype is torch.bfloat16 + and weight.shape == (_HEAD_DIM,) + and weight.is_contiguous() + ) + + +def _supported_inputs( + query: torch.Tensor, + key: torch.Tensor, + query_weight: torch.Tensor, + key_weight: torch.Tensor, + eps: float, + query_scale: float, +) -> bool: + return ( + is_ltx2_fusion_eligible(query) + and query.dtype is torch.bfloat16 + and query.ndim == 6 + and query.shape[-1] == _HEAD_DIM + and query.numel() > 0 + and query.is_contiguous() + and key.is_cuda + and key.device == query.device + and key.dtype is query.dtype + and key.shape == query.shape + and key.is_contiguous() + and _weight_matches(query, query_weight) + and _weight_matches(query, key_weight) + and math.isfinite(eps) + and eps > 0 + and math.isfinite(query_scale) + ) + + +def _combined_supported_inputs( + query: torch.Tensor, + key: torch.Tensor, + query_weight: torch.Tensor, + key_weight: torch.Tensor, + eps: float, + query_scale: float, + dim_split: tuple[int, int, int], + base: float, +) -> bool: + return ( + _supported_inputs(query, key, query_weight, key_weight, eps, query_scale) + and len(dim_split) == 3 + and sum(dim_split) == _HEAD_DIM + and all(dim > 0 and dim % 2 == 0 for dim in dim_split) + and math.isfinite(base) + and base > 0 + ) + + +def _launch_combined( + query: torch.Tensor, + key: torch.Tensor, + query_weight: torch.Tensor, + key_weight: torch.Tensor, + eps: float, + query_scale: float, + dim_split: tuple[int, int, int], + base: float, +) -> tuple[torch.Tensor, torch.Tensor]: + query_output = torch.empty_like(query) + key_output = torch.empty_like(key) + rows = query.numel() // _HEAD_DIM + tables = get_rope_tables(query, dim_split, base) + cos_t, sin_t = tables[0] + cos_h, sin_h = tables[1] + cos_w, sin_w = tables[2] + with torch.accelerator.device_index(query.device.index): + _qk_rms_norm_scale_rope_3d_kernel[(triton.cdiv(rows, _ROWS_PER_PROGRAM),)]( + query_output, + key_output, + query, + key, + query_weight, + key_weight, + cos_t, + sin_t, + cos_h, + sin_h, + cos_w, + sin_w, + rows, + query.shape[1] * query.shape[2] * query.shape[3], + query.shape[2], + query.shape[3], + query.shape[4], + eps, + query_scale, + rows_per_program=_ROWS_PER_PROGRAM, + head_dim=_HEAD_DIM, + pairs_t=dim_split[0] // 2, + pairs_h=dim_split[1] // 2, + num_warps=_ROWS_PER_PROGRAM, + ) + return query_output, key_output + + +def _verify_combined_prefix( + output: tuple[torch.Tensor, torch.Tensor], + query: torch.Tensor, + key: torch.Tensor, + query_weight: torch.Tensor, + key_weight: torch.Tensor, + eps: float, + query_scale: float, + dim_split: tuple[int, int, int], + base: float, +) -> bool: + rows = min(query.numel() // _HEAD_DIM, _VERIFY_ROWS) + query_rows = query.reshape(-1, _HEAD_DIM)[:rows] + key_rows = key.reshape(-1, _HEAD_DIM)[:rows] + query_rows = F.rms_norm(query_rows, (_HEAD_DIM,), query_weight, eps) * query_scale + key_rows = F.rms_norm(key_rows, (_HEAD_DIM,), key_weight, eps) + tables = get_rope_tables(query, dim_split, base) + row_indices = torch.arange(rows, device=query.device) + tokens_per_batch = query.shape[1] * query.shape[2] * query.shape[3] + tokens = (row_indices // query.shape[4]) % tokens_per_batch + positions = ( + tokens // (query.shape[2] * query.shape[3]), + (tokens // query.shape[3]) % query.shape[2], + tokens % query.shape[3], + ) + cos = torch.cat([axis_tables[0][position] for axis_tables, position in zip(tables, positions, strict=True)], -1) + sin = torch.cat([axis_tables[1][position] for axis_tables, position in zip(tables, positions, strict=True)], -1) + + def rotate(value: torch.Tensor) -> torch.Tensor: + pairs = value.reshape(rows, _HEAD_DIM // 2, 2) + even = pairs[..., 0].float() + odd = pairs[..., 1].float() + return ( + torch.stack((even * cos - odd * sin, even * sin + odd * cos), -1).reshape(rows, _HEAD_DIM).to(value.dtype) + ) + + reference = rotate(query_rows), rotate(key_rows) + actual = tuple(value.reshape(-1, _HEAD_DIM)[:rows] for value in output) + return all(torch.equal(a, b) for a, b in zip(actual, reference, strict=True)) + + +def try_qk_rms_norm_scale_rope_3d_exact( + query: torch.Tensor, + key: torch.Tensor, + query_weight: torch.Tensor, + key_weight: torch.Tensor, + eps: float, + query_scale: float, + dim_split: tuple[int, int, int], + base: float, +) -> tuple[torch.Tensor, torch.Tensor] | None: + """Normalize Q/K, scale Q, and apply paired 3D RoPE in one kernel.""" + + if not _combined_supported_inputs( + query, + key, + query_weight, + key_weight, + eps, + query_scale, + dim_split, + base, + ): + return None + runtime_key = (query.device.index, float(eps), float(query_scale), dim_split, float(base)) + if runtime_key in _FAILED_ROPE_KEYS: + return None + try: + output = _launch_combined( + query, + key, + query_weight, + key_weight, + eps, + query_scale, + dim_split, + base, + ) + if runtime_key not in _VERIFIED_ROPE_KEYS: + if not _verify_combined_prefix( + output, + query, + key, + query_weight, + key_weight, + eps, + query_scale, + dim_split, + base, + ): + _FAILED_ROPE_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE Q/K RMSNorm+3D RoPE fusion on %s after a bit-exactness mismatch", + query.device, + ) + return None + _VERIFIED_ROPE_KEYS.add(runtime_key) + except Exception as exc: # noqa: BLE001 - fail closed after optimized-path failure + _FAILED_ROPE_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE Q/K RMSNorm+3D RoPE fusion on %s after failure: %s", + query.device, + exc, + ) + return None + return output + + +__all__ = ["try_qk_rms_norm_scale_rope_3d_exact"] diff --git a/vllm_omni/diffusion/models/ltx2/ops/residual_adaln.py b/vllm_omni/diffusion/models/ltx2/ops/residual_adaln.py new file mode 100644 index 000000000..16a4aefe6 --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/residual_adaln.py @@ -0,0 +1,489 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Exact LTX DiffVAE residual, RMSNorm, and AdaLN Triton fusions.""" + +from __future__ import annotations + +import logging +import math + +import torch +import torch.nn.functional as F +from vllm.triton_utils import tl, triton + +from . import is_ltx2_fusion_eligible +from ._numerics import ( + add_rn_f32, + fma_rn_f32, + mul_rn_f32, + round_bf16_to_fp32, + rsqrt_approx_f32, + shfl_down_f32, +) + +_HIDDEN_SIZE = 256 +_POINTWISE_BLOCK = 1024 +_VERIFY_ROWS = 256 +_VERIFY_ELEMENTS = 1 << 20 +_FAILED_ADALN_KEYS: set[tuple[int | None, int, float]] = set() +_VERIFIED_ADALN_KEYS: set[tuple[int | None, int, float]] = set() +_FAILED_ADD_DEVICES: set[int | None] = set() +_VERIFIED_ADD_DEVICES: set[int | None] = set() + +logger = logging.getLogger(__name__) + + +@triton.jit +def _two_warp_sum(values): + values = add_rn_f32(values, shfl_down_f32(values, 16)) + values = add_rn_f32(values, shfl_down_f32(values, 8)) + values = add_rn_f32(values, shfl_down_f32(values, 4)) + values = add_rn_f32(values, shfl_down_f32(values, 2)) + values = add_rn_f32(values, shfl_down_f32(values, 1)) + threads = tl.arange(0, 64) + warp_0 = tl.sum(tl.where(threads == 0, values, 0.0)) + warp_1 = tl.sum(tl.where(threads == 32, values, 0.0)) + return add_rn_f32(warp_0, warp_1) + + +@triton.jit +def _load_ordered_residual( + x_ptr, + residual_a_ptr, + residual_b_ptr, + offset, + valid, + residual_count: tl.constexpr, +): + value = add_rn_f32( + tl.load(x_ptr + offset, mask=valid, other=0.0).to(tl.float32), + tl.load(residual_a_ptr + offset, mask=valid, other=0.0).to(tl.float32), + ) + value = round_bf16_to_fp32(value) + if residual_count == 2: + value = add_rn_f32( + value, + tl.load(residual_b_ptr + offset, mask=valid, other=0.0).to(tl.float32), + ) + value = round_bf16_to_fp32(value) + return value + + +@triton.jit +def _store_modulated( + output_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + value, + offset, + column, + modulation_base, + reciprocal_rms, + valid, +): + normalized = mul_rn_f32(value, reciprocal_rms) + norm_weight = tl.load(norm_weight_ptr + column, mask=valid, other=0.0).to(tl.float32) + normalized = round_bf16_to_fp32(mul_rn_f32(normalized, norm_weight)) + scale = tl.load(scale_ptr + modulation_base + column, mask=valid, other=0.0).to(tl.float32) + shift = tl.load(shift_ptr + modulation_base + column, mask=valid, other=0.0).to(tl.float32) + one_plus_scale = round_bf16_to_fp32(add_rn_f32(1.0, scale)) + product = round_bf16_to_fp32(mul_rn_f32(normalized, one_plus_scale)) + tl.store(output_ptr + offset, add_rn_f32(product, shift), mask=valid) + + +@triton.jit +def _residual_rms_norm_modulate_kernel( + output_ptr, + x_ptr, + residual_a_ptr, + residual_b_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + rows_per_batch, + eps, + residual_count: tl.constexpr, + hidden_size: tl.constexpr, +): + row = tl.program_id(0).to(tl.int64) + batch = row // rows_per_batch + threads = tl.arange(0, 64) + row_base = row * hidden_size + + # PyTorch's BF16 RMSNorm kernel uses four contiguous values per thread for + # hidden=256. Keep the four ordered BF16 residual values in registers so + # the output pass does not read the large activations a second time. + column_0 = threads * 4 + column_1 = column_0 + 1 + column_2 = column_0 + 2 + column_3 = column_0 + 3 + valid_0 = column_0 < hidden_size + valid_1 = column_1 < hidden_size + valid_2 = column_2 < hidden_size + valid_3 = column_3 < hidden_size + offset_0 = row_base + column_0 + offset_1 = row_base + column_1 + offset_2 = row_base + column_2 + offset_3 = row_base + column_3 + value_0 = _load_ordered_residual(x_ptr, residual_a_ptr, residual_b_ptr, offset_0, valid_0, residual_count) + value_1 = _load_ordered_residual(x_ptr, residual_a_ptr, residual_b_ptr, offset_1, valid_1, residual_count) + value_2 = _load_ordered_residual(x_ptr, residual_a_ptr, residual_b_ptr, offset_2, valid_2, residual_count) + value_3 = _load_ordered_residual(x_ptr, residual_a_ptr, residual_b_ptr, offset_3, valid_3, residual_count) + accumulator = fma_rn_f32(value_0, value_0, tl.zeros((64,), dtype=tl.float32)) + accumulator = fma_rn_f32(value_1, value_1, accumulator) + accumulator = fma_rn_f32(value_2, value_2, accumulator) + accumulator = fma_rn_f32(value_3, value_3, accumulator) + + total = _two_warp_sum(accumulator) + reciprocal_rms = rsqrt_approx_f32(total / hidden_size + eps) + modulation_base = batch * hidden_size + _store_modulated( + output_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + value_0, + offset_0, + column_0, + modulation_base, + reciprocal_rms, + valid_0, + ) + _store_modulated( + output_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + value_1, + offset_1, + column_1, + modulation_base, + reciprocal_rms, + valid_1, + ) + _store_modulated( + output_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + value_2, + offset_2, + column_2, + modulation_base, + reciprocal_rms, + valid_2, + ) + _store_modulated( + output_ptr, + norm_weight_ptr, + scale_ptr, + shift_ptr, + value_3, + offset_3, + column_3, + modulation_base, + reciprocal_rms, + valid_3, + ) + + +@triton.jit +def _residual_add3_kernel( + output_ptr, + x_ptr, + residual_a_ptr, + residual_b_ptr, + residual_c_ptr, + elements, + block: tl.constexpr, +): + offsets = tl.program_id(0).to(tl.int64) * block + tl.arange(0, block) + valid = offsets < elements + value = add_rn_f32( + tl.load(x_ptr + offsets, mask=valid, other=0.0).to(tl.float32), + tl.load(residual_a_ptr + offsets, mask=valid, other=0.0).to(tl.float32), + ) + value = round_bf16_to_fp32(value) + value = add_rn_f32( + value, + tl.load(residual_b_ptr + offsets, mask=valid, other=0.0).to(tl.float32), + ) + value = round_bf16_to_fp32(value) + value = add_rn_f32( + value, + tl.load(residual_c_ptr + offsets, mask=valid, other=0.0).to(tl.float32), + ) + tl.store(output_ptr + offsets, value, mask=valid) + + +def _same_activation(x: torch.Tensor, residual: torch.Tensor) -> bool: + return ( + residual.dtype is torch.bfloat16 + and residual.is_cuda + and residual.device == x.device + and residual.shape == x.shape + and residual.is_contiguous() + ) + + +def _modulation_matches(x: torch.Tensor, value: torch.Tensor) -> bool: + return ( + value.dtype is torch.bfloat16 + and value.is_cuda + and value.device == x.device + and value.shape[-1] == _HIDDEN_SIZE + and value.numel() == x.shape[0] * _HIDDEN_SIZE + and value.is_contiguous() + ) + + +def _norm_weight_matches(x: torch.Tensor, weight: torch.Tensor) -> bool: + return ( + weight.dtype is torch.bfloat16 + and weight.is_cuda + and weight.device == x.device + and weight.shape == (_HIDDEN_SIZE,) + and weight.is_contiguous() + ) + + +def _adaln_inputs_supported( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, + norm_weight: torch.Tensor, + scale: torch.Tensor, + shift: torch.Tensor, + eps: float, +) -> bool: + return ( + is_ltx2_fusion_eligible(x) + and x.dtype is torch.bfloat16 + and x.ndim >= 2 + and x.shape[-1] == _HIDDEN_SIZE + and x.numel() > 0 + and x.is_contiguous() + and _same_activation(x, residual_a) + and (residual_b is None or _same_activation(x, residual_b)) + and _norm_weight_matches(x, norm_weight) + and _modulation_matches(x, scale) + and _modulation_matches(x, shift) + and math.isfinite(eps) + and eps > 0 + ) + + +def _residual_reference( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, +) -> torch.Tensor: + hidden_states = x + residual_a + if residual_b is not None: + hidden_states = hidden_states + residual_b + return hidden_states + + +def _adaln_reference( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, + norm_weight: torch.Tensor, + scale: torch.Tensor, + shift: torch.Tensor, + eps: float, +) -> torch.Tensor: + hidden_states = _residual_reference(x, residual_a, residual_b) + normalized = F.rms_norm(hidden_states, (_HIDDEN_SIZE,), weight=norm_weight, eps=eps) + return normalized * (1 + scale) + shift + + +def _launch_adaln( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, + norm_weight: torch.Tensor, + scale: torch.Tensor, + shift: torch.Tensor, + eps: float, +) -> torch.Tensor: + output = torch.empty_like(x) + rows = x.numel() // _HIDDEN_SIZE + rows_per_batch = rows // x.shape[0] + with torch.accelerator.device_index(x.device.index): + _residual_rms_norm_modulate_kernel[(rows,)]( + output, + x, + residual_a, + x if residual_b is None else residual_b, + norm_weight, + scale, + shift, + rows_per_batch, + eps, + residual_count=1 if residual_b is None else 2, + hidden_size=_HIDDEN_SIZE, + num_warps=2, + ) + return output + + +def _verify_adaln_prefix( + output: torch.Tensor, + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, + norm_weight: torch.Tensor, + scale: torch.Tensor, + shift: torch.Tensor, + eps: float, +) -> bool: + rows_per_batch = x.numel() // (x.shape[0] * _HIDDEN_SIZE) + rows = min(rows_per_batch, _VERIFY_ROWS) + x_rows = x.reshape(x.shape[0], rows_per_batch, _HIDDEN_SIZE)[0, :rows] + residual_a_rows = residual_a.reshape(x.shape[0], rows_per_batch, _HIDDEN_SIZE)[0, :rows] + residual_b_rows = ( + None if residual_b is None else residual_b.reshape(x.shape[0], rows_per_batch, _HIDDEN_SIZE)[0, :rows] + ) + scale_row = scale.reshape(x.shape[0], 1, _HIDDEN_SIZE)[0] + shift_row = shift.reshape(x.shape[0], 1, _HIDDEN_SIZE)[0] + reference = _adaln_reference( + x_rows, + residual_a_rows, + residual_b_rows, + norm_weight, + scale_row, + shift_row, + eps, + ) + actual = output.reshape(x.shape[0], rows_per_batch, _HIDDEN_SIZE)[0, :rows] + return torch.equal(actual, reference) + + +def try_residual_rms_norm_modulate_exact( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor | None, + norm_weight: torch.Tensor, + scale: torch.Tensor, + shift: torch.Tensor, + eps: float, +) -> torch.Tensor | None: + """Fuse one or two ordered BF16 residuals with RMSNorm and AdaLN.""" + + if not _adaln_inputs_supported(x, residual_a, residual_b, norm_weight, scale, shift, eps): + return None + residual_count = 1 if residual_b is None else 2 + runtime_key = (x.device.index, residual_count, float(eps)) + if runtime_key in _FAILED_ADALN_KEYS: + return None + try: + output = _launch_adaln(x, residual_a, residual_b, norm_weight, scale, shift, eps) + if runtime_key not in _VERIFIED_ADALN_KEYS: + if not _verify_adaln_prefix( + output, + x, + residual_a, + residual_b, + norm_weight, + scale, + shift, + eps, + ): + _FAILED_ADALN_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE residual-AdaLN fusion on %s after a bit-exactness mismatch", + x.device, + ) + return None + _VERIFIED_ADALN_KEYS.add(runtime_key) + except Exception as exc: # noqa: BLE001 - fail closed after optimized-path failure + _FAILED_ADALN_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE residual-AdaLN fusion on %s after failure: %s", + x.device, + exc, + ) + return None + return output + + +def _add_inputs_supported(x: torch.Tensor, residuals: tuple[torch.Tensor, ...]) -> bool: + return ( + is_ltx2_fusion_eligible(x) + and x.dtype is torch.bfloat16 + and x.numel() > 0 + and x.is_contiguous() + and all(_same_activation(x, residual) for residual in residuals) + ) + + +def _launch_add3( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor, + residual_c: torch.Tensor, +) -> torch.Tensor: + output = torch.empty_like(x) + with torch.accelerator.device_index(x.device.index): + _residual_add3_kernel[(triton.cdiv(x.numel(), _POINTWISE_BLOCK),)]( + output, + x, + residual_a, + residual_b, + residual_c, + x.numel(), + block=_POINTWISE_BLOCK, + num_warps=4, + ) + return output + + +def try_residual_add3_exact( + x: torch.Tensor, + residual_a: torch.Tensor, + residual_b: torch.Tensor, + residual_c: torch.Tensor, +) -> torch.Tensor | None: + """Fuse three ordered BF16 residual additions into one pointwise pass.""" + + residuals = (residual_a, residual_b, residual_c) + if not _add_inputs_supported(x, residuals) or x.device.index in _FAILED_ADD_DEVICES: + return None + try: + output = _launch_add3(x, *residuals) + if x.device.index not in _VERIFIED_ADD_DEVICES: + elements = min(x.numel(), _VERIFY_ELEMENTS) + reference = ( + _residual_reference( + x.reshape(-1)[:elements], + residual_a.reshape(-1)[:elements], + residual_b.reshape(-1)[:elements], + ) + + residual_c.reshape(-1)[:elements] + ) + if not torch.equal(output.reshape(-1)[:elements], reference): + _FAILED_ADD_DEVICES.add(x.device.index) + logger.warning( + "Disabling LTX DiffVAE residual-add fusion on %s after a bit-exactness mismatch", + x.device, + ) + return None + _VERIFIED_ADD_DEVICES.add(x.device.index) + except Exception as exc: # noqa: BLE001 - fail closed after optimized-path failure + _FAILED_ADD_DEVICES.add(x.device.index) + logger.warning( + "Disabling LTX DiffVAE residual-add fusion on %s after failure: %s", + x.device, + exc, + ) + return None + return output + + +__all__ = [ + "try_residual_add3_exact", + "try_residual_rms_norm_modulate_exact", +] diff --git a/vllm_omni/diffusion/models/ltx2/ops/rope_tables.py b/vllm_omni/diffusion/models/ltx2/ops/rope_tables.py new file mode 100644 index 000000000..78a2ad2ee --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/rope_tables.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Cached 3D RoPE tables for LTX DiffVAE kernels.""" + +from __future__ import annotations + +import torch + +_TABLE_CACHE_MAX = 16 +_TABLE_CACHE: dict[ + tuple[torch.device, int, int, int, tuple[int, int, int], float], + tuple[tuple[torch.Tensor, torch.Tensor], ...], +] = {} + + +def _axis_tables( + length: int, + dim: int, + device: torch.device, + base: float, +) -> tuple[torch.Tensor, torch.Tensor]: + exponents = torch.arange(0, dim, 2, dtype=torch.float64, device=device) / dim + inv_freqs = (1.0 / base**exponents).to(torch.float32) + positions = torch.arange(length, dtype=torch.float32, device=device) + angles = positions[:, None] * inv_freqs[None, :] + return angles.cos().contiguous(), angles.sin().contiguous() + + +def get_rope_tables( + query: torch.Tensor, + dim_split: tuple[int, int, int], + base: float, +) -> tuple[tuple[torch.Tensor, torch.Tensor], ...]: + num_frames, height, width = query.shape[1:4] + cache_key = (query.device, num_frames, height, width, dim_split, base) + cached = _TABLE_CACHE.get(cache_key) + if cached is not None: + return cached + tables = tuple( + _axis_tables(length, dim, query.device, base) + for length, dim in zip((num_frames, height, width), dim_split, strict=True) + ) + if len(_TABLE_CACHE) >= _TABLE_CACHE_MAX: + _TABLE_CACHE.pop(next(iter(_TABLE_CACHE))) + _TABLE_CACHE[cache_key] = tables + return tables + + +__all__ = ["get_rope_tables"] diff --git a/vllm_omni/diffusion/models/ltx2/ops/swiglu.py b/vllm_omni/diffusion/models/ltx2/ops/swiglu.py new file mode 100644 index 000000000..6c332b7ed --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/ops/swiglu.py @@ -0,0 +1,206 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Bit-exact workspace-reusing SwiGLU for the LTX diffusion VAE decoder.""" + +from __future__ import annotations + +import logging + +import torch +import torch.nn.functional as F +from vllm.triton_utils import tl, triton + +from . import is_ltx2_fusion_eligible +from ._numerics import mul_rn_f32, round_bf16_to_fp32 + +_POINTWISE_BLOCK = 1024 +_BASE_TILE_ROWS = 16384 +_MAX_TILE_HIDDEN_ELEMENTS = _BASE_TILE_ROWS * 4 * 2048 +_FAILED_KEYS: set[tuple[int | None, int, int, int, int]] = set() +_VERIFIED_KEYS: set[tuple[int | None, int, int, int, int]] = set() + +logger = logging.getLogger(__name__) + + +def _tile_rows(rows: int, hidden: int) -> int: + """Bound projection storage while giving narrow MLPs larger GEMMs.""" + + budget_rows = _MAX_TILE_HIDDEN_ELEMENTS // hidden + aligned_rows = max(_BASE_TILE_ROWS, budget_rows // _BASE_TILE_ROWS * _BASE_TILE_ROWS) + return min(rows, aligned_rows) + + +@triton.jit +def _silu_mul_kernel( + gate_ptr, + up_ptr, + elements, + block: tl.constexpr, +): + offsets = tl.program_id(0).to(tl.int64) * block + tl.arange(0, block) + valid = offsets < elements + gate = tl.load(gate_ptr + offsets, mask=valid).to(tl.float32) + up = tl.load(up_ptr + offsets, mask=valid).to(tl.float32) + # Eager rounds SiLU to BF16 before the separate BF16 multiply. + silu = round_bf16_to_fp32(mul_rn_f32(gate, tl.sigmoid(gate))) + tl.store(gate_ptr + offsets, mul_rn_f32(silu, up), mask=valid) + + +def _weight_matches( + hidden_states: torch.Tensor, + weight: torch.Tensor, + shape: tuple[int, int], +) -> bool: + return ( + weight.is_cuda + and weight.device == hidden_states.device + and weight.dtype is torch.bfloat16 + and weight.shape == shape + and weight.is_contiguous() + ) + + +def _supported_inputs( + hidden_states: torch.Tensor, + gate_weight: torch.Tensor, + up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> bool: + if hidden_states.ndim < 3: + return False + dim = hidden_states.shape[-1] + hidden = gate_weight.shape[0] if gate_weight.ndim == 2 else 0 + return ( + is_ltx2_fusion_eligible(hidden_states) + and hidden_states.dtype is torch.bfloat16 + and hidden_states.shape[0] == 1 + and hidden_states.numel() > 0 + and hidden_states.is_contiguous() + and 256 <= dim <= 2048 + and hidden == 4 * dim + and _weight_matches(hidden_states, gate_weight, (hidden, dim)) + and _weight_matches(hidden_states, up_weight, (hidden, dim)) + and _weight_matches(hidden_states, down_weight, (dim, hidden)) + ) + + +def _silu_mul_inplace(gate: torch.Tensor, up: torch.Tensor) -> None: + with torch.accelerator.device_index(gate.device.index): + _silu_mul_kernel[(triton.cdiv(gate.numel(), _POINTWISE_BLOCK),)]( + gate, + up, + gate.numel(), + block=_POINTWISE_BLOCK, + num_warps=4, + ) + + +def _reference_tile( + hidden_states: torch.Tensor, + gate_weight: torch.Tensor, + up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> torch.Tensor: + return F.linear( + F.silu(F.linear(hidden_states, gate_weight)) * F.linear(hidden_states, up_weight), + down_weight, + ) + + +def _launch( + hidden_states: torch.Tensor, + gate_weight: torch.Tensor, + up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> torch.Tensor: + dim = hidden_states.shape[-1] + hidden = gate_weight.shape[0] + flat = hidden_states.reshape(-1, dim) + tile_rows = _tile_rows(flat.shape[0], hidden) + output = torch.empty_like(flat) + workspace_gate = torch.empty((tile_rows, hidden), device=flat.device, dtype=flat.dtype) + for start in range(0, flat.shape[0], tile_rows): + end = min(start + tile_rows, flat.shape[0]) + tokens = end - start + tile = flat[start:end] + gate = workspace_gate[:tokens] + torch.mm(tile, gate_weight.t(), out=gate) + up = torch.mm(tile, up_weight.t()) + _silu_mul_inplace(gate, up) + torch.mm(gate, down_weight.t(), out=output[start:end]) + return output.reshape(hidden_states.shape) + + +def _verify_edge_tiles( + output: torch.Tensor, + hidden_states: torch.Tensor, + gate_weight: torch.Tensor, + up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> bool: + from diffusers.models.autoencoders.ltx2_diffusion_decoder import _SWIGLU_TILE_SIZE + + dim = hidden_states.shape[-1] + flat = hidden_states.reshape(-1, dim) + actual = output.reshape(-1, dim) + tile_rows = _tile_rows(flat.shape[0], gate_weight.shape[0]) + starts = {0, ((flat.shape[0] - 1) // tile_rows) * tile_rows} + for start in starts: + end = min(start + tile_rows, flat.shape[0]) + # Match upstream's token tiling, not the optimized GEMM shape: changing + # the matmul row count can change its reduction order and BF16 output. + for reference_start in range(start, end, _SWIGLU_TILE_SIZE): + reference_end = min(reference_start + _SWIGLU_TILE_SIZE, end) + if not torch.equal( + actual[reference_start:reference_end], + _reference_tile(flat[reference_start:reference_end], gate_weight, up_weight, down_weight), + ): + return False + return True + + +def try_swiglu_tiled_exact( + hidden_states: torch.Tensor, + gate_weight: torch.Tensor, + up_weight: torch.Tensor, + down_weight: torch.Tensor, +) -> torch.Tensor | None: + """Run workspace-reusing tiled SwiGLU, or expose the original module.""" + + if not _supported_inputs(hidden_states, gate_weight, up_weight, down_weight): + return None + rows = hidden_states.numel() // hidden_states.shape[-1] + tile_rows = _tile_rows(rows, gate_weight.shape[0]) + runtime_key = ( + hidden_states.device.index, + hidden_states.shape[-1], + gate_weight.shape[0], + tile_rows, + (rows - 1) % tile_rows + 1, + ) + if runtime_key in _FAILED_KEYS: + return None + try: + output = _launch(hidden_states, gate_weight, up_weight, down_weight) + if runtime_key not in _VERIFIED_KEYS: + if not _verify_edge_tiles(output, hidden_states, gate_weight, up_weight, down_weight): + _FAILED_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE tiled SwiGLU fusion on %s after a bit-exactness mismatch", + hidden_states.device, + ) + return None + _VERIFIED_KEYS.add(runtime_key) + except Exception as exc: # noqa: BLE001 - fail closed after optimized-path failure + _FAILED_KEYS.add(runtime_key) + logger.warning( + "Disabling LTX DiffVAE tiled SwiGLU fusion on %s after failure: %s", + hidden_states.device, + exc, + ) + return None + return output + + +__all__ = ["try_swiglu_tiled_exact"] diff --git a/vllm_omni/diffusion/models/ltx2/vae/__init__.py b/vllm_omni/diffusion/models/ltx2/vae/__init__.py new file mode 100644 index 000000000..9381766fc --- /dev/null +++ b/vllm_omni/diffusion/models/ltx2/vae/__init__.py @@ -0,0 +1,4 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Ordinary LTX-2.5 DiffVAE, tiled decoding and acceleration operators.""" diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder.py b/vllm_omni/diffusion/models/ltx2/vae/decoder.py similarity index 57% rename from vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder.py rename to vllm_omni/diffusion/models/ltx2/vae/decoder.py index 73db2ea6b..312578990 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder.py +++ b/vllm_omni/diffusion/models/ltx2/vae/decoder.py @@ -7,19 +7,24 @@ """LTX-2.5 Native-weight adapter for Diffusers' diffusion VAE decoder.""" +import math from collections.abc import Mapping +from functools import cache from typing import Any import torch from diffusers.models.autoencoders import ( LTX2VideoDiffusionDecoderModel as DiffusersLTX2VideoDiffusionDecoderModel, ) +from diffusers.models.autoencoders import ltx2_diffusion_decoder as diffusers_decoder from diffusers.models.autoencoders.ltx2_diffusion_decoder import ( LTX2VideoDiffusionDecoder3d as DiffusersLTX2VideoDiffusionDecoder3d, ) from diffusers.models.autoencoders.vae import DecoderOutput from safetensors import safe_open +from ..ops import is_ltx2_fna_eligible, is_ltx2_fusion_eligible, resolve_ltx2_vae_operators + LTX25_NATIVE_DIFFUSION_DECODER_REPO_ID = "Lightricks/LTX-2.5" LTX25_NATIVE_ARTIFACT_REVISION = "8a4ff96f581e72bedc1b44367581c49d544a05f1" LTX25_NATIVE_DIFFUSION_DECODER_FILENAME = "vae/ltx-2.5-video-vae-bf16.safetensors" @@ -131,6 +136,177 @@ def load_ltx25_native_diffusion_decoder_state_dict(path: str) -> dict[str, torch return convert_ltx25_native_diffusion_decoder_state_dict(native_state_dict) +@cache +def _load_natten_token_permutation(): + from kernels import get_kernel + + natten = get_kernel("shi-labs/natten", version=1) + return natten.token_permute.token_permute_operation, natten.token_permute.token_unpermute_operation + + +class LTX2VideoVaeNeighborhoodNattenProcessor(diffusers_decoder.LTX2VideoVaeNeighborhoodNattenProcessor): + """NATTEN dispatch with Omni FNA for eligible stage-5 grids.""" + + def __call__( + self, attn: "LTX2VideoVaeNeighborhoodAttention", hidden_states: torch.Tensor, block_mask=None + ) -> torch.Tensor: + batch_size, num_frames, height, width, channels = hidden_states.shape + query, key, value = attn.project_qkv(hidden_states) + # NATTEN's CUTLASS kernels silently produce wrong output for non-contiguous inputs. + query, key, value = query.contiguous(), key.contiguous(), value.contiguous() + operators = resolve_ltx2_vae_operators(hidden_states.device) if is_ltx2_fna_eligible(hidden_states) else None + # `scale=1.0`: the query is already scaled in `project_qkv`, as in the reference. + if ( + tuple(attn.kernel_size) == (11, 11, 11) + and hidden_states.dtype == torch.bfloat16 + and channels == 256 + and attn.head_dim == 64 + and operators is not None + and operators.fna is not None + # The native metadata encodes KV tile indices in 15 bits. + and math.prod((n + tile - 1) // tile for n, tile in zip((num_frames, height, width), (4, 4, 8))) <= 1 << 15 + ): + token_permute, token_unpermute = _load_natten_token_permutation() + query_permuted, token_shape, _ = token_permute(query, (4, 4, 4), flip_tiled_dims=True) + key_permuted, _, _ = token_permute(key, (4, 4, 8), flip_tiled_dims=True) + value_permuted, _, _ = token_permute(value, (4, 4, 8), flip_tiled_dims=True) + output = operators.fna( + query_permuted, + key_permuted, + value_permuted, + shape=(num_frames, height, width), + window=tuple(attn.kernel_size), + ) + hidden_states = token_unpermute( + output, + token_layout_shape=token_shape, + tile_shape=(4, 4, 4), + flip_tiled_dims=True, + ) + else: + hidden_states = self._na3d( + query, + key, + value, + kernel_size=attn.kernel_size, + scale=1.0, + backend=self.backend, + ) + hidden_states = hidden_states.reshape(batch_size, num_frames, height, width, channels) + return attn.to_out[0](hidden_states) + + +class LTX2VideoVaeNeighborhoodAttention(diffusers_decoder.LTX2VideoVaeNeighborhoodAttention): + _available_processors = [ + *diffusers_decoder.LTX2VideoVaeNeighborhoodAttention._available_processors, + LTX2VideoVaeNeighborhoodNattenProcessor, + ] + + def project_qkv(self, hidden_states: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + batch_size, num_frames, height, width, _ = hidden_states.shape + shape = (batch_size, num_frames, height, width, self.heads, self.head_dim) + query = self.to_q(hidden_states).view(shape) + key = self.to_k(hidden_states).view(shape) + value = self.to_v(hidden_states).view(shape) + + operators = resolve_ltx2_vae_operators(query.device) if is_ltx2_fusion_eligible(query) else None + optimized = ( + None + if operators is None + else operators.qk_norm_rope( + query, + key, + self.norm_q.weight, + self.norm_k.weight, + self.norm_q.eps, + self.scale, + self.rope.rope_dim_split, + self.rope.base, + ) + ) + if optimized is not None: + query, key = optimized + return query, key, value + + query = self.norm_q(query) + key = self.norm_k(key) + query = query * self.scale + return self.rope(query), self.rope(key), value + + +class LTX2VideoVaeSwiGLU(diffusers_decoder.LTX2VideoVaeSwiGLU): + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + operators = resolve_ltx2_vae_operators(hidden_states.device) if is_ltx2_fusion_eligible(hidden_states) else None + optimized = ( + None + if operators is None + else operators.swiglu(hidden_states, self.w_gate.weight, self.w_up.weight, self.w_down.weight) + ) + return super().forward(hidden_states) if optimized is None else optimized + + +class LTX2VideoVaeDiffusionNABlock(diffusers_decoder.LTX2VideoVaeDiffusionNABlock): + def forward( + self, + hidden_states: torch.Tensor, + latent_context: torch.Tensor, + modulation: tuple[torch.Tensor, ...], + block_mask=None, + ) -> torch.Tensor: + if hidden_states.dtype is not torch.bfloat16 or not is_ltx2_fusion_eligible(hidden_states): + return super().forward(hidden_states, latent_context, modulation, block_mask) + operators = resolve_ltx2_vae_operators(hidden_states.device) + assert operators is not None + + scale_msa, shift_msa, _, scale_mlp, shift_mlp, _, _ = [ + modulation[i] + self.scale_shift_table[i].view(1, 1, 1, 1, -1) for i in range(self.num_mod_params) + ] + context_output = self.context_proj(latent_context) + attention_input = operators.residual_norm( + hidden_states, + context_output, + None, + self.norm1.weight, + scale_msa, + shift_msa, + self.norm1.eps, + ) + if attention_input is None: + hidden_states = hidden_states + context_output + hidden_states = hidden_states + self.attn( + self.norm1(hidden_states) * (1 + scale_msa) + shift_msa, block_mask + ) + hidden_states = hidden_states + self.mlp(self.norm2(hidden_states) * (1 + scale_mlp) + shift_mlp) + return hidden_states + + attention_output = self.attn(attention_input, block_mask) + mlp_input = operators.residual_norm( + hidden_states, + context_output, + attention_output, + self.norm2.weight, + scale_mlp, + shift_mlp, + self.norm2.eps, + ) + if mlp_input is None: + residual = hidden_states + context_output + residual = residual + attention_output + mlp_input = self.norm2(residual) * (1 + scale_mlp) + shift_mlp + mlp_output = self.mlp(mlp_input) + output = operators.residual_add( + hidden_states, + context_output, + attention_output, + mlp_output, + ) + if output is not None: + return output + residual = hidden_states + context_output + residual = residual + attention_output + return residual + mlp_output + + class LTX2VideoDiffusionDecoder3d(DiffusersLTX2VideoDiffusionDecoder3d): """Diffusers decoder core with the short-clip NATTEN context fix.""" @@ -200,6 +376,15 @@ class LTX2VideoDiffusionDecoderModel(DiffusersLTX2VideoDiffusionDecoderModel): # to this behavior-only subclass preserves parameters and state-dict keys. self.decoder.__class__ = LTX2VideoDiffusionDecoder3d self.decoder.stage5_kernel = tuple(decoder_stage5_kernel) + overrides = { + diffusers_decoder.LTX2VideoVaeNeighborhoodAttention: LTX2VideoVaeNeighborhoodAttention, + diffusers_decoder.LTX2VideoVaeSwiGLU: LTX2VideoVaeSwiGLU, + diffusers_decoder.LTX2VideoVaeDiffusionNABlock: LTX2VideoVaeDiffusionNABlock, + } + # Preserve every module and parameter object; only execution methods change. + for module in self.decoder.modules(): + if replacement := overrides.get(type(module)): + module.__class__ = replacement @classmethod def from_ltx25_native_checkpoint( diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder_distributed.py b/vllm_omni/diffusion/models/ltx2/vae/distributed.py similarity index 99% rename from vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder_distributed.py rename to vllm_omni/diffusion/models/ltx2/vae/distributed.py index 7f486dbdf..9a98ba739 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_diffusion_decoder_distributed.py +++ b/vllm_omni/diffusion/models/ltx2/vae/distributed.py @@ -25,7 +25,7 @@ from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor impor TileTask, ) -from .ltx2_diffusion_decoder import LTX2VideoDiffusionDecoderModel +from .decoder import LTX2VideoDiffusionDecoderModel logger = logging.get_logger(__name__) # pylint: disable=invalid-name From ecdd762ff57640b2007ed84e9f681c18a872e28d Mon Sep 17 00:00:00 2001 From: Anjie Hou <149605198+specture724@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:40:32 +0800 Subject: [PATCH 69/90] [2/N] Add a minimal temporal chunk callback for MiniMax-H3 (#7017) Signed-off-by: specture724 Co-authored-by: Claude Sonnet 5 --- .../minimax_h3/test_minimax_h3_vae_chunks.py | 357 ++++++++++++++++++ .../autoencoders/autoencoder_kl_wan.py | 6 +- vllm_omni/diffusion/models/interface.py | 8 + .../models/minimax_h3/chunked_decode.py | 85 +++++ .../models/minimax_h3/temporal_chunks.py | 122 ++++++ vllm_omni/diffusion/models/minimax_h3/vae.py | 90 +++-- 6 files changed, 641 insertions(+), 27 deletions(-) create mode 100644 tests/diffusion/models/minimax_h3/test_minimax_h3_vae_chunks.py create mode 100644 vllm_omni/diffusion/models/minimax_h3/chunked_decode.py create mode 100644 vllm_omni/diffusion/models/minimax_h3/temporal_chunks.py diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_vae_chunks.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_vae_chunks.py new file mode 100644 index 000000000..fa7410b46 --- /dev/null +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_vae_chunks.py @@ -0,0 +1,357 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import gc +import tempfile +from types import SimpleNamespace + +import pytest +import torch +import torch.distributed as dist + +from vllm_omni.diffusion.models.minimax_h3.chunked_decode import decode_h3_chunks +from vllm_omni.diffusion.models.minimax_h3.temporal_chunks import decode_temporal_chunks + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +@pytest.fixture(scope="module") +def cpu_process_group(): + if dist.is_initialized(): + yield dist.group.WORLD + return + + with tempfile.NamedTemporaryFile(prefix="h3_chunk_dist_") as rendezvous: + init_method = f"file://{rendezvous.name}" + dist.init_process_group("gloo", rank=0, world_size=1, init_method=init_method) + try: + yield dist.group.WORLD + finally: + dist.destroy_process_group() + gc.collect() + + +class _FakeTemporalModel: + use_3d_conv = True + token_drop = 3 + tokens_chunk_size = 5 + token_overlap = 2 + vae_ratio_t = 4 + frame_pre_padding = 3 + frame_overlap = 5 + isolated_first_frame = False + isolated_last_frame = False + + def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens): + del z, z_head, z_tail, num_chunks, pad_tokens + return 35, 0, 35 + + def _adaptive_decode(self, clip): + value = float(clip[:, :, 0].mean()) + return torch.full((1, 1, 24, 2, 2), value) + + @staticmethod + def blend(overlap, part, frame_overlap, dim): + del overlap, frame_overlap, dim + return part + + +class _FakeHost: + def __init__(self): + self.model = _FakeTemporalModel() + + @staticmethod + def _denormalize_latent(latent): + return latent + + @staticmethod + def _normalize_decoded_frames(frames): + return frames.float() + + +def test_temporal_chunks_emit_ordered_frames_and_collect_when_unconsumed(): + model = _FakeTemporalModel() + latent = torch.arange(8, dtype=torch.float32).view(1, 1, 8, 1, 1) + chunks = [] + marker = decode_temporal_chunks(model, latent, chunks.append) + + assert marker.shape == (0,) + assert [chunk.shape[2] for chunk in chunks] == [17, 17, 1] + assert torch.equal(torch.cat(chunks, dim=2), decode_temporal_chunks(model, latent, None)) + + +def test_h3_callback_failure_is_deferred_until_temporal_decode_finishes(): + host = _FakeHost() + latent = torch.zeros(1, 1, 8, 1, 1) + seen = [] + + def fail_once(frames): + seen.append(frames.shape[2]) + raise RuntimeError("sink failed") + + with pytest.raises(RuntimeError, match="sink failed"): + decode_h3_chunks(host, latent, fail_once, group=None) + assert seen == [17] + + +def test_h3_without_callback_is_a_plain_full_decode_on_a_vae_group(cpu_process_group): + host = _FakeHost() + latent = torch.arange(8, dtype=torch.float32).view(1, 1, 8, 1, 1) + + full = decode_h3_chunks(host, latent, None, group=cpu_process_group) + + chunks = [] + decode_h3_chunks(host, latent, chunks.append, group=cpu_process_group) + assert torch.equal(torch.cat(chunks, dim=2), full) + + +class _SimulatedGroup: + """Stand in for a VAE group so one process can check every rank's verdict. + + ``all_reduce`` is replaced with the sum the real collective would produce + for a group of ``world_size`` ranks, ``supplied`` of which handed in a + callback. That sum is what makes a rank-local verdict observable from a + single process. + """ + + def __init__(self, world_size: int, supplied: int): + self.world_size = world_size + self.supplied = supplied + + +def _patch_simulated_ranks(monkeypatch, simulated: _SimulatedGroup, rank: int) -> None: + from vllm_omni.diffusion.models.minimax_h3 import chunked_decode as mod + + def fake_all_reduce(tensor, group=None): + del group # the simulated census below stands in for the real reduction + tensor.copy_(torch.tensor([simulated.supplied], dtype=tensor.dtype)) + + monkeypatch.setattr(mod.dist, "get_rank", lambda _group: rank) + monkeypatch.setattr(mod.dist, "get_world_size", lambda _group: simulated.world_size) + monkeypatch.setattr(mod.dist, "all_reduce", fake_all_reduce) + monkeypatch.setattr(mod.dist, "broadcast", lambda tensor, src=0, group=None: None) + monkeypatch.setattr(mod.dist, "get_global_rank", lambda _group, _rank: 0) + + +@pytest.mark.parametrize("rank", [0, 1, 2]) +def test_h3_every_rank_rejects_a_partially_supplied_callback(monkeypatch, rank): + """Ranks that disagree must all refuse, not let one decode alone. + + A rank-local verdict let a peer fall through into the decoder collectives + while its peers raised, hanging the stage instead of failing it. + """ + host = _FakeHost() + latent = torch.zeros(1, 1, 8, 1, 1) + # Two ranks of three handed in a callback. + group = _SimulatedGroup(world_size=3, supplied=2) + _patch_simulated_ranks(monkeypatch, group, rank) + + with pytest.raises(ValueError, match="every rank"): + decode_h3_chunks(host, latent, lambda _frames: None, group=object()) + + +@pytest.mark.parametrize("rank", [0, 1, 2]) +def test_h3_only_rank_zero_publishes_when_every_rank_supplies(monkeypatch, rank): + """Ownership is positional: all ranks stream, rank 0 publishes.""" + host = _FakeHost() + latent = torch.zeros(1, 1, 8, 1, 1) + group = _SimulatedGroup(world_size=3, supplied=3) + _patch_simulated_ranks(monkeypatch, group, rank) + + seen: list[int] = [] + decode_h3_chunks(host, latent, lambda f: seen.append(int(f.shape[2])), group=object()) + + # Peers run the loop to stay in the collectives but publish nothing. + assert (seen != []) is (rank == 0) + + +def _stub_video_vae(*, parallel_size: int, tile_count: int): + """A MiniMaxH3VideoVAE with only the pieces the decode entry points read.""" + from contextlib import contextmanager + + from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE + + vae = object.__new__(MiniMaxH3VideoVAE) + vae.parallel_size = parallel_size + vae.model = SimpleNamespace(_adaptive_decode=lambda clip: clip) + vae.entered_rank_local = False + + @contextmanager + def rank_local(): + vae.entered_rank_local = True + yield + + vae._decoder_tile_count = lambda latent: tile_count + vae._rank_local_tiling = rank_local + vae.is_distributed_enabled = lambda: False + return vae + + +def test_chunked_decode_falls_back_when_tiles_are_fewer_than_ranks(monkeypatch): + """Without the fallback, tileless ranks raise while their peers block.""" + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_mod + + vae = _stub_video_vae(parallel_size=4, tile_count=1) + monkeypatch.setattr(vae_mod, "decode_h3_chunks", lambda *args, **kwargs: torch.zeros(1)) + + vae.decode_with_chunks(torch.zeros(1, 1, 8, 1, 1), on_chunk=lambda _frames: None) + + assert vae.entered_rank_local, "chunked decode must share decode_latent's too-few-tiles fallback" + + +def test_chunked_decode_keeps_shared_tiling_when_every_rank_has_a_tile(monkeypatch): + from vllm_omni.diffusion.models.minimax_h3 import vae as vae_mod + + vae = _stub_video_vae(parallel_size=4, tile_count=8) + monkeypatch.setattr(vae_mod, "decode_h3_chunks", lambda *args, **kwargs: torch.zeros(1)) + + vae.decode_with_chunks(torch.zeros(1, 1, 8, 1, 1), on_chunk=lambda _frames: None) + + assert not vae.entered_rank_local + + +def test_h3_vae_declares_the_chunked_decode_capability(): + """A consumer must be able to detect the capability and the pixel range.""" + from vllm_omni.diffusion.models.interface import supports_chunked_vae_decode + from vllm_omni.diffusion.models.minimax_h3.vae import MiniMaxH3VideoVAE + + vae = object.__new__(MiniMaxH3VideoVAE) + + assert supports_chunked_vae_decode(vae) + # H3 reverts through the checkpoint's processor, which lands in [0, 1]; + # a Wan VAE publishes [-1, 1], so the range cannot be assumed. + assert vae.chunk_value_range == (0.0, 1.0) + + +def _native_decode_temporal_streaming(model, z, z_head, z_tail, num_chunks, pad_tokens): + """The released ``_decode_temporal_streaming`` loop, as the parity oracle. + + Transcribed from the checkpoint's ``AutoencoderKLLegacy`` so the fork can be + compared against the control flow it mirrors, including the isolated + head/tail extraction. + """ + total_frames, pad_frames, output_frames = model._decode_temporal_output_frame_plan( + z, z_head, z_tail, num_chunks, pad_tokens + ) + chunk_dec = model.tokens_chunk_size * model.vae_ratio_t + split_count = int(model.token_drop > 0) + 1 + dec = None + dec_overlap = None + write_pos = 0 + + def write_part(part): + nonlocal dec, write_pos + part_frames = int(part.shape[2]) + if part_frames <= 0: + return + if dec is None: + out_shape = list(part.shape) + out_shape[2] = output_frames + dec = torch.empty(out_shape, dtype=part.dtype, device=part.device) + remaining = int(dec.shape[2]) - write_pos + copy_frames = min(part_frames, max(0, remaining)) + if copy_frames > 0: + dec[:, :, write_pos : write_pos + copy_frames].copy_(part[:, :, :copy_frames]) + write_pos += copy_frames + + for i in range(num_chunks): + t_start = i * model.tokens_chunk_size + clip_z = z[:, :, t_start : t_start + model.tokens_chunk_size + model.token_overlap] + if i == 0 and z_head is not None: + clip_z = torch.cat([z_head, clip_z], dim=2) + if i == num_chunks - 1 and z_tail is not None: + clip_z = torch.cat([clip_z, z_tail], dim=2) + + clip_dec = model._adaptive_decode(clip_z) + + dec_tail = None + if i == 0 and z_head is not None: + write_part(clip_dec[:, :, model.vae_ratio_t - 1 : model.vae_ratio_t]) + clip_dec = clip_dec[:, :, model.vae_ratio_t :] + if i == num_chunks - 1 and z_tail is not None: + dec_tail = clip_dec[:, :, -1:] + clip_dec = clip_dec[:, :, : -model.vae_ratio_t] + + for j in range(split_count): + f_start = j * chunk_dec + f_end = min(f_start + chunk_dec, clip_dec.shape[2]) + chunk = clip_dec[:, :, f_start:f_end][:, :, model.frame_pre_padding :] + if j == 0: + if dec_overlap is not None: + chunk = model.blend(dec_overlap, chunk, model.frame_overlap, dim=-3) + dec_overlap = None + write_part(chunk) + else: + dec_overlap = chunk.contiguous() + + if i == num_chunks - 1: + if dec_overlap is not None: + write_part(dec_overlap) + dec_overlap = None + if dec_tail is not None: + write_part(dec_tail) + + return dec + + +class _IsolatedFrameModel(_FakeTemporalModel): + """Fake decoder with the isolated boundaries the released config can set.""" + + frame_pre_padding = 0 + + def __init__(self, *, first: bool, last: bool): + self.isolated_first_frame = first + self.isolated_last_frame = last + + def _decode_temporal_output_frame_plan(self, z, z_head, z_tail, num_chunks, pad_tokens): + del pad_tokens + frames = num_chunks * self.tokens_chunk_size * self.vae_ratio_t + frames += int(z_head is not None) + int(z_tail is not None) + del z + return frames, 0, frames + + def _adaptive_decode(self, clip): + # Every latent token decodes to a distinct, ordered block, so a shifted + # or reordered emission is visible in the values themselves. + base = float(clip[:, :, 0].mean()) + frames = int(clip.shape[2]) * self.vae_ratio_t + return torch.arange(frames, dtype=torch.float32).view(1, 1, frames, 1, 1) + base + + +@pytest.mark.parametrize( + ("first", "last"), + [(True, False), (False, True), (True, True)], +) +def test_temporal_chunks_match_the_native_loop_at_isolated_boundaries(first, last): + """The fork must extract the isolated head/tail the way the native loop does.""" + model = _IsolatedFrameModel(first=first, last=last) + latent = torch.arange(12, dtype=torch.float32).view(1, 1, 12, 1, 1) + + chunks: list[torch.Tensor] = [] + decode_temporal_chunks(model, latent, chunks.append) + streamed = torch.cat(chunks, dim=2) + + # Feed the oracle exactly what the released ``decode_temporal`` would: + # split the isolated tokens off, then pad the remainder to a whole chunk. + isolated = int(first) + int(last) + pseudo_tokens = int(latent.shape[2]) - isolated + model.token_drop + remainder = pseudo_tokens % model.tokens_chunk_size + pad_tokens = (model.tokens_chunk_size - remainder) if remainder else 0 + num_chunks = (pseudo_tokens + pad_tokens) // model.tokens_chunk_size - int(model.token_drop > 0) + + body = latent + z_head = None + if first: + z_head = body[:, :, :1] + body = body[:, :, 1:] + z_tail = None + if last: + z_tail = body[:, :, -1:] + body = body[:, :, :-1] + if pad_tokens: + body = torch.cat([body, body[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)], dim=2) + + expected = _native_decode_temporal_streaming(model, body, z_head, z_tail, num_chunks, pad_tokens) + + assert streamed.shape == expected.shape + assert torch.equal(streamed, expected) diff --git a/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py index eddcb1ad6..9ab28d09c 100644 --- a/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py +++ b/vllm_omni/diffusion/distributed/autoencoders/autoencoder_kl_wan.py @@ -2,7 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from contextlib import nullcontext -from typing import Any +from typing import Any, ClassVar import torch import torch.distributed as dist @@ -62,6 +62,10 @@ class OmniAutoencoderKLWan(AutoencoderKLWan): return self._decode_temporal(z, return_dict=return_dict) + # Chunks are published straight from the decoder, clamped to the + # checkpoint's output range. + chunk_value_range: ClassVar[tuple[float, float]] = (-1.0, 1.0) + @apply_forward_hook def decode_with_chunks(self, z: torch.Tensor, *, on_chunk: DecodedChunkConsumer) -> None: """Decode ``z`` while synchronously delivering temporal chunks. diff --git a/vllm_omni/diffusion/models/interface.py b/vllm_omni/diffusion/models/interface.py index dac9a0b1d..92216d19e 100644 --- a/vllm_omni/diffusion/models/interface.py +++ b/vllm_omni/diffusion/models/interface.py @@ -58,8 +58,16 @@ class SupportsChunkedVAEDecode(Protocol): Implementations must drain decoding before surfacing callback errors. In a distributed VAE, every rank invokes the method so collectives stay in lockstep, while ``on_chunk`` is called only on the rank that owns output. + + ``chunk_value_range`` is the closed interval the delivered floats occupy, + which differs per checkpoint: a Wan VAE emits ``(-1.0, 1.0)`` while a + MiniMax-H3 VAE reverts through its processor to ``(0.0, 1.0)``. A consumer + cannot quantize chunks to pixels without it, so it is part of the + capability rather than knowledge each consumer hard-codes per model. """ + chunk_value_range: ClassVar[tuple[float, float]] + def decode_with_chunks( self, z: torch.Tensor, diff --git a/vllm_omni/diffusion/models/minimax_h3/chunked_decode.py b/vllm_omni/diffusion/models/minimax_h3/chunked_decode.py new file mode 100644 index 000000000..08ce0e64b --- /dev/null +++ b/vllm_omni/diffusion/models/minimax_h3/chunked_decode.py @@ -0,0 +1,85 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Frames-only callback coordination for MiniMax-H3 VAE decoding.""" + +from __future__ import annotations + +from collections.abc import Callable + +import torch +import torch.distributed as dist + +from .temporal_chunks import decode_temporal_chunks + +MiniMaxH3VideoChunkCallback = Callable[[torch.Tensor], None] + + +def decode_h3_chunks( + host, + latent: torch.Tensor, + callback: MiniMaxH3VideoChunkCallback | None, + *, + group: dist.ProcessGroup | None, +) -> torch.Tensor: + """Run the local temporal loop, synchronizing callback errors across ranks.""" + streaming = callback is not None + owner = streaming + if group is not None: + rank = dist.get_rank(group) + world_size = dist.get_world_size(group) + # Ownership is positional: every rank supplies the callback and rank 0 + # publishes. Reduce only whether the ranks agree that this decode + # streams, so the verdict is identical everywhere -- a rank-local + # verdict would let a peer fall through and enter the decoder + # collectives alone, hanging the stage instead of failing it. + census = torch.tensor([int(streaming)], dtype=torch.int64, device=latent.device) + dist.all_reduce(census, group=group) + supplied = int(census.item()) + if supplied not in (0, world_size): + raise ValueError( + "MiniMax-H3 chunk callback must be supplied on every rank of the VAE " + f"group or on none of them; got {supplied} of {world_size}" + ) + # No rank supplied a callback: every rank runs a plain full decode. + streaming = supplied == world_size + owner = streaming and rank == 0 + + error: BaseException | None = None + + def publish(raw: torch.Tensor) -> None: + nonlocal error + if not owner or error is not None: + return + try: + processor = getattr(host.model, "processor", None) + decoded = raw if processor is None else processor.revert_tensor(raw) + frames = host._normalize_decoded_frames(decoded).contiguous() + assert callback is not None + callback(frames) + except BaseException as exc: # noqa: BLE001 + error = exc + + # Peer ranks run the temporal loop without materializing a second video. + sink = publish if owner else ((lambda _frames: None) if streaming else None) + result = decode_temporal_chunks( + host.model, + host._denormalize_latent(latent), + sink, + ) + + if group is not None: + failed = torch.tensor([int(rank == 0 and error is not None)], dtype=torch.int32, device=latent.device) + dist.broadcast(failed, src=dist.get_global_rank(group, 0), group=group) + if int(failed.item()): + if rank == 0: + assert error is not None + raise error + raise RuntimeError("MiniMax-H3 video chunk callback failed on rank zero") + elif error is not None: + raise error + + if not result.numel(): + return result + processor = getattr(host.model, "processor", None) + decoded = result if processor is None else processor.revert_tensor(result) + return host._normalize_decoded_frames(decoded) diff --git a/vllm_omni/diffusion/models/minimax_h3/temporal_chunks.py b/vllm_omni/diffusion/models/minimax_h3/temporal_chunks.py new file mode 100644 index 000000000..a5154525d --- /dev/null +++ b/vllm_omni/diffusion/models/minimax_h3/temporal_chunks.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Small compatibility loop for the released MiniMax-H3 temporal decoder.""" + +from __future__ import annotations + +from collections.abc import Callable + +import torch +import torch.nn as nn + + +def decode_temporal_chunks( + model: nn.Module, + latent: torch.Tensor, + callback: Callable[[torch.Tensor], None] | None, +) -> torch.Tensor: + """Decode H3 temporal clips and synchronously publish committed frames. + + The released VAE exposes the temporal clip primitives but materializes the + final tensor. This is the same loop with the write point replaced by a + frames-only callback. ``callback=None`` retains the complete-output path. + """ + if latent.ndim != 5 or not bool(getattr(model, "use_3d_conv", False)): + raise ValueError("MiniMax-H3 temporal chunk decode requires a rank-5 3D latent") + + token_drop = int(model.token_drop) + chunk_size = int(model.tokens_chunk_size) + overlap_tokens = int(model.token_overlap) + ratio_t = int(model.vae_ratio_t) + pre_padding = int(model.frame_pre_padding) + + isolated_first = bool(model.isolated_first_frame and pre_padding == 0) + isolated_last = bool(model.isolated_last_frame) + z_head = latent[:, :, :1] if isolated_first else None + z_tail = latent[:, :, -1:] if isolated_last else None + start = int(isolated_first) + stop = int(latent.shape[2]) - int(isolated_last) + latent = latent[:, :, start:stop] + + pseudo_tokens = int(latent.shape[2]) + token_drop + pad_tokens = (-pseudo_tokens) % chunk_size + if pad_tokens: + latent = torch.cat((latent, latent[:, :, -1:].repeat(1, 1, pad_tokens, 1, 1)), dim=2) + num_chunks = (pseudo_tokens + pad_tokens) // chunk_size - int(token_drop > 0) + if num_chunks <= 0: + raise ValueError("MiniMax-H3 temporal chunk plan is empty") + + total_frames, pad_frames, output_frames = model._decode_temporal_output_frame_plan( + latent, z_head, z_tail, num_chunks, pad_tokens + ) + output_frames = int(output_frames) + if output_frames <= 0: + raise ValueError("MiniMax-H3 temporal chunk plan has no output frames") + + collected: list[torch.Tensor] = [] + overlap: torch.Tensor | None = None + written = 0 + + def emit(part: torch.Tensor) -> None: + nonlocal written + frames = int(part.shape[2]) + if frames <= 0 or written >= output_frames: + return + frames = min(frames, output_frames - written) + part = part[:, :, :frames] + if callback is None: + collected.append(part) + else: + callback(part) + written += frames + + chunk_frames = chunk_size * ratio_t + split_count = int(token_drop > 0) + 1 + for index in range(num_chunks): + begin = index * chunk_size + end = begin + chunk_size + overlap_tokens + clip = latent[:, :, begin:end] + if index == 0 and z_head is not None: + clip = torch.cat((z_head, clip), dim=2) + if index == num_chunks - 1 and z_tail is not None: + clip = torch.cat((clip, z_tail), dim=2) + + decoded = model._adaptive_decode(clip) + + # The isolated boundary latents were prepended/appended to the clip, so + # the decoder returns their frames inside this clip's block. Publish + # them the way the released loop does and drop their temporal block + # before splitting, or every later frame shifts by one block. + decoded_tail = None + if index == 0 and z_head is not None: + emit(decoded[:, :, ratio_t - 1 : ratio_t]) + decoded = decoded[:, :, ratio_t:] + if index == num_chunks - 1 and z_tail is not None: + decoded_tail = decoded[:, :, -1:] + decoded = decoded[:, :, :-ratio_t] + + for split in range(split_count): + begin = split * chunk_frames + end = min(begin + chunk_frames, int(decoded.shape[2])) + part = decoded[:, :, begin:end] + part = part[:, :, pre_padding:] + if split == 0: + if overlap is not None: + part = model.blend(overlap, part, int(model.frame_overlap), dim=-3) + overlap = None + emit(part) + else: + overlap = part.contiguous() + + if index == num_chunks - 1: + if overlap is not None: + emit(overlap) + overlap = None + if decoded_tail is not None: + emit(decoded_tail) + + if written != output_frames: + raise RuntimeError(f"MiniMax-H3 temporal decode emitted {written}/{output_frames} frames") + if callback is not None: + return latent.new_empty((0,)) + return torch.cat(collected, dim=2) if collected else latent.new_empty((0,)) diff --git a/vllm_omni/diffusion/models/minimax_h3/vae.py b/vllm_omni/diffusion/models/minimax_h3/vae.py index e98659409..ca8359904 100644 --- a/vllm_omni/diffusion/models/minimax_h3/vae.py +++ b/vllm_omni/diffusion/models/minimax_h3/vae.py @@ -9,7 +9,7 @@ import json from collections.abc import Iterator from contextlib import AbstractContextManager, contextmanager, nullcontext from pathlib import Path -from typing import Any +from typing import Any, ClassVar import torch import torch.distributed as dist @@ -22,11 +22,13 @@ from vllm_omni.diffusion.distributed.autoencoders.distributed_vae_executor impor DistributedVaeMixin, ) from vllm_omni.diffusion.distributed.parallel_state import get_world_group +from vllm_omni.diffusion.models.interface import DecodedChunkConsumer from vllm_omni.diffusion.offloader.module_residency import ( BoundedAllocatorCache, PinnedModuleStager, ) +from .chunked_decode import decode_h3_chunks from .ops import install_h3_vae_optimizations from .packed_tokens import minimax_h3_patchify_video_latent @@ -377,25 +379,16 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin): ).float() return rows, shape - @torch.inference_mode() - def decode_latent(self, latent: torch.Tensor) -> torch.Tensor: - channels = int(self.config_dict["latent_channels"]) - mean = torch.tensor( - self.config_dict["latents_mean"], - device=latent.device, - dtype=latent.dtype, - ).view(1, channels, 1, 1, 1) - std = torch.tensor( - self.config_dict["latents_std"], - device=latent.device, - dtype=latent.dtype, - ).view(1, channels, 1, 1, 1) - # The checkpoint hands rank r the tiles ``range(r, num_tiles, sp_size)`` - # and then rejects an empty share inside the gather. A rank with no - # tiles raises and leaves the collective while the others block in it - # forever, so too few tiles hangs the whole stage rather than failing - # it. Tile count depends only on the latent shape, so every rank takes - # this branch together. + def _decode_tiling_context(self, latent: torch.Tensor) -> AbstractContextManager: + """Pick the tiling mode a decode of ``latent`` can safely use. + + The checkpoint hands rank r the tiles ``range(r, num_tiles, sp_size)`` + and then rejects an empty share inside the gather. A rank with no + tiles raises and leaves the collective while the others block in it + forever, so too few tiles hangs the whole stage rather than failing + it. Tile count depends only on the latent shape, so every rank takes + this branch together. + """ num_tiles = self._decoder_tile_count(latent) if self.parallel_size > 1 and num_tiles < self.parallel_size: logger.warning_once( @@ -405,19 +398,64 @@ class MiniMaxH3VideoVAE(nn.Module, DistributedVaeMixin): num_tiles, self.parallel_size, ) - tiling_context: AbstractContextManager = self._rank_local_tiling() - else: - tiling_context = nullcontext() + return self._rank_local_tiling() + return nullcontext() - with tiling_context: - decoded = self.model.decode_base(latent * std + mean) - frames = self.model.processor.revert_tensor(decoded) + @torch.inference_mode() + def decode_latent(self, latent: torch.Tensor) -> torch.Tensor: + with self._decode_tiling_context(latent): + decoded = self.model.decode_base(self._denormalize_latent(latent)) + return self._normalize_decoded_frames(self.model.processor.revert_tensor(decoded)) + + def _denormalize_latent(self, latent: torch.Tensor) -> torch.Tensor: + channels = int(self.config_dict["latent_channels"]) + mean = torch.tensor(self.config_dict["latents_mean"], device=latent.device, dtype=latent.dtype) + std = torch.tensor(self.config_dict["latents_std"], device=latent.device, dtype=latent.dtype) + shape = (1, channels, 1, 1, 1) + return latent * std.view(shape) + mean.view(shape) + + @staticmethod + def _normalize_decoded_frames(decoded: torch.Tensor) -> torch.Tensor: + """Canonicalize remote H3 decoder output to [B,C,T,H,W].""" + frames = decoded if frames.ndim == 4: frames = frames.unsqueeze(0).transpose(1, 2) if frames.ndim != 5: raise ValueError(f"unexpected decoded video shape {tuple(frames.shape)}") return frames.float() + # Chunks are reverted through the checkpoint's processor, which + # denormalizes and clamps into the unit interval. + chunk_value_range: ClassVar[tuple[float, float]] = (0.0, 1.0) + + @torch.inference_mode() + def decode_with_chunks(self, z: torch.Tensor, *, on_chunk: DecodedChunkConsumer) -> None: + """Decode temporal clips and synchronously publish frames-only chunks. + + Implements :class:`SupportsChunkedVAEDecode`. Every rank participating + in distributed VAE execution must invoke this method with a callback so + the temporal collectives stay in lockstep; ``on_chunk`` is called only + on the rank that owns output. Chunks arrive as ``[B, C, T, H, W]`` + float frames, normalized through the checkpoint's processor to match + the complete decode path. After a callback failure, the remaining + chunks are decoded and discarded before the exception is re-raised. + """ + if not callable(on_chunk): + raise TypeError("on_chunk must be callable") + if not callable(getattr(self.model, "_adaptive_decode", None)): + raise RuntimeError("Loaded MiniMax-H3 VAE does not expose temporal decode primitives") + group = None + if self.is_distributed_enabled(): + group = self._native_parallel_state().get("sp_process_group") + if group is None or dist.get_world_size(group) != self.parallel_size: + raise RuntimeError("MiniMax-H3 VAE chunk decode has an invalid spatial-parallel group") + # Native H3 tiling performs its own collectives for every temporal clip, + # so this path needs the same too-few-tiles fallback as the complete + # decode: without it a shape that leaves some ranks tileless hangs the + # gather instead of decoding rank-locally. + with self._decode_tiling_context(z): + decode_h3_chunks(self, z, on_chunk, group=group) + class MiniMaxH3AudioVAE(nn.Module): def __init__( From cdd4638b17f86db33a3f798dba468a7aef7bc76b Mon Sep 17 00:00:00 2001 From: Bo Li <22713281+bobboli@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:58:42 +0800 Subject: [PATCH 70/90] [Feature][Diffusion] Expose detailed pipeline timings (#6822) Signed-off-by: Bo Li <22713281+bobboli@users.noreply.github.com> --- .../models/minimax_h3/test_minimax_h3_offload.py | 4 +++- tests/diffusion/test_diffusion_engine_metrics.py | 1 + tests/metrics/test_modality.py | 2 ++ vllm_omni/diffusion/diffusion_engine.py | 8 +++++++- .../diffusion/models/minimax_h3/pipeline_minimax_h3.py | 2 ++ vllm_omni/metrics/stats.py | 1 + 6 files changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/diffusion/models/minimax_h3/test_minimax_h3_offload.py b/tests/diffusion/models/minimax_h3/test_minimax_h3_offload.py index dade4437c..865b2248a 100644 --- a/tests/diffusion/models/minimax_h3/test_minimax_h3_offload.py +++ b/tests/diffusion/models/minimax_h3/test_minimax_h3_offload.py @@ -13,12 +13,14 @@ from PIL import Image pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] -def test_h3_profiler_targets_cover_aggregate_reference_encoders(): +def test_h3_profiler_targets_cover_reference_encoders_and_vae_decoders(): from vllm_omni.diffusion.models.minimax_h3 import MiniMaxH3Pipeline targets = MiniMaxH3Pipeline._PROFILER_TARGETS assert "_encode_visual_conditions" in targets assert "_encode_reference_audio_conditions" in targets + assert "video_vae.decode_latent" in targets + assert "audio_vae.decode_latent" in targets assert "_encode_video_conditions" not in targets assert "_encode_video_audio_conditions" not in targets assert "_encode_audio_conditions" not in targets diff --git a/tests/diffusion/test_diffusion_engine_metrics.py b/tests/diffusion/test_diffusion_engine_metrics.py index dc909a2b9..1dad9face 100644 --- a/tests/diffusion/test_diffusion_engine_metrics.py +++ b/tests/diffusion/test_diffusion_engine_metrics.py @@ -140,6 +140,7 @@ class TestMetricKeys: raise AssertionError("diffusion_engine_total_time_ms should be attached in step_streaming()") assert '"diffusion_engine_exec_time_ms": exec_total_time * 1000' in step_streaming_source + assert '"output_ready_wait_time_ms": output_ready_wait_time * 1000' in step_streaming_source # step_total_ms is no longer emitted as a metric (no family consumes # it); it lives only in the debug log breakdown below. assert '"diffusion_engine_total_time_ms"' not in step_streaming_source diff --git a/tests/metrics/test_modality.py b/tests/metrics/test_modality.py index 694808a31..14bb4f309 100644 --- a/tests/metrics/test_modality.py +++ b/tests/metrics/test_modality.py @@ -532,6 +532,7 @@ class TestDiffusionUnitConversion: metrics={ "preprocess_time_ms": 50.0, "diffusion_engine_exec_time_ms": 1500.0, + "output_ready_wait_time_ms": 250.0, "postprocess_time_ms": 20.0, "vae_decode_time_ms": 300.0, "forward_time_ms": 800.0, @@ -545,6 +546,7 @@ class TestDiffusionUnitConversion: assert dict(aggregator.diffusion_metrics["req-units"]) == { "preprocess_time_s": pytest.approx(0.05), "diffusion_engine_exec_time_s": pytest.approx(1.5), + "output_ready_wait_time_s": pytest.approx(0.25), "postprocess_time_s": pytest.approx(0.02), "vae_decode_time_s": pytest.approx(0.3), "forward_time_s": pytest.approx(0.8), diff --git a/vllm_omni/diffusion/diffusion_engine.py b/vllm_omni/diffusion/diffusion_engine.py index 52a22c0b9..c36ac0459 100644 --- a/vllm_omni/diffusion/diffusion_engine.py +++ b/vllm_omni/diffusion/diffusion_engine.py @@ -439,8 +439,10 @@ class DiffusionEngine: generator = self.get_output_stream(request_id) async for output in generator: exec_total_time = time.perf_counter() - exec_start_time + output_ready_wait_time = 0.0 # Async mode: wait for background D2H/SHM to complete. if output.async_output_id: + output_ready_wait_start_time = time.perf_counter() fut = self.executor.wait_output_ready(output.async_output_id) timeout = _async_output_timeout() try: @@ -455,6 +457,7 @@ class DiffusionEngine: describe(output.async_output_id) if describe else "unavailable", ) raise + output_ready_wait_time = time.perf_counter() - output_ready_wait_start_time postprocess_start_time = time.perf_counter() scheduler_metrics = diffusion_scheduler_waiting_metrics(getattr(self, "_scheduler_num_waiting_reqs", 0)) try: @@ -468,9 +471,11 @@ class DiffusionEngine: step_total_ms = (time.perf_counter() - diffusion_engine_start_time) * 1000 logger.debug( "DiffusionEngine.step_streaming breakdown: preprocess=%.2f ms, " - "add_req_and_wait=%.2f ms, postprocess=%.2f ms, total=%.2f ms", + "add_req_and_wait=%.2f ms, output_ready_wait=%.2f ms, " + "postprocess=%.2f ms, total=%.2f ms", preprocess_time * 1000, exec_total_time * 1000, + output_ready_wait_time * 1000, postprocess_time * 1000, step_total_ms, ) @@ -478,6 +483,7 @@ class DiffusionEngine: metrics_update = { "preprocess_time_ms": preprocess_time * 1000, "diffusion_engine_exec_time_ms": exec_total_time * 1000, + "output_ready_wait_time_ms": output_ready_wait_time * 1000, "postprocess_time_ms": postprocess_time * 1000, **scheduler_metrics, } diff --git a/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py b/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py index 794d2dc09..ff1f8a3e5 100644 --- a/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py +++ b/vllm_omni/diffusion/models/minimax_h3/pipeline_minimax_h3.py @@ -683,6 +683,8 @@ class MiniMaxH3Pipeline( "_encode_reference_audio_conditions", "diffuse", "decode", + "video_vae.decode_latent", + "audio_vae.decode_latent", "prepare_encode", "denoise_step", "post_decode", diff --git a/vllm_omni/metrics/stats.py b/vllm_omni/metrics/stats.py index 9e9191eaa..ef86a5a35 100644 --- a/vllm_omni/metrics/stats.py +++ b/vllm_omni/metrics/stats.py @@ -656,6 +656,7 @@ class OrchestratorAggregator: _MS_TO_S: dict[str, str] = { "preprocess_time_ms": "preprocess_time_s", "diffusion_engine_exec_time_ms": "diffusion_engine_exec_time_s", + "output_ready_wait_time_ms": "output_ready_wait_time_s", "postprocess_time_ms": "postprocess_time_s", "vae_decode_time_ms": "vae_decode_time_s", "forward_time_ms": "forward_time_s", From 038c9b948fbf10e99d3167c18a6a22c20049f7f5 Mon Sep 17 00:00:00 2001 From: NumberWan Date: Wed, 9 Sep 2026 18:20:34 +0800 Subject: [PATCH 71/90] [Bugfix] Resolve #6931 hub FA3 on torch 2.13 via kernels 0.16.1 (#7185) Signed-off-by: NumberWan --- .../attention_backends/huggingface_hub.md | 2 +- recipes/Boogu/Boogu-Image.md | 2 +- requirements/common.txt | 10 ++- tests/diffusion/attention/test_kernels_hub.py | 74 +++++++++++++++++ .../models/ltx2/test_ltx25_pipeline.py | 4 +- tests/e2e/accuracy/test_qwen_image.py | 83 ++++++++----------- .../attention/backends/flash_attn_hub.py | 6 +- .../diffusion/models/ltx2/ltx2_components.py | 4 +- vllm_omni/platforms/cuda/platform.py | 20 +++++ 9 files changed, 142 insertions(+), 63 deletions(-) diff --git a/docs/user_guide/diffusion/attention_backends/huggingface_hub.md b/docs/user_guide/diffusion/attention_backends/huggingface_hub.md index 574ff625a..cfd220e82 100644 --- a/docs/user_guide/diffusion/attention_backends/huggingface_hub.md +++ b/docs/user_guide/diffusion/attention_backends/huggingface_hub.md @@ -12,7 +12,7 @@ implementations to reduce numerical drift and sampling divergence. ## Installation ```bash -pip install kernels==0.15.2 +pip install kernels==0.16.1 ``` If `kernels` is unavailable, an explicit `FLASH_ATTN_HUB` or diff --git a/recipes/Boogu/Boogu-Image.md b/recipes/Boogu/Boogu-Image.md index 0d832b0ae..0353b9778 100644 --- a/recipes/Boogu/Boogu-Image.md +++ b/recipes/Boogu/Boogu-Image.md @@ -152,7 +152,7 @@ The following configuration was validated: - vLLM-Omni version or commit: Use a commit that contains TorchAO FP8 checkpoint loading for diffusion models - TorchAO version: 0.17.0 -- `kernels` / `kernels-data`: 0.15.2 / 0.16.1 +- `kernels` / `kernels-data`: 0.16.1 / matching kernels-data #### Command diff --git a/requirements/common.txt b/requirements/common.txt index bb79e0a6a..855fb6b0a 100644 --- a/requirements/common.txt +++ b/requirements/common.txt @@ -9,13 +9,15 @@ # That breakage is now handled in code instead of by pinning: # MossTTSNanoForGeneration wraps weight loading in # transformers_keys_to_ignore_compat (vllm_omni/model_executor/models/utils.py). -transformers >= 5.10.1, < 5.15 +# Floor is 5.13.0 so transformers.integrations.hub_kernels LayerRepository +# entries always pass version=/revision= required by kernels>=0.16 (#6971). +transformers >= 5.13.0, < 5.15 av>=14.0.0 omegaconf>=2.3.0 diffusers==0.40.0 -# Hub kernels, including LTX-2.5 NATTEN. Keep this pin in sync with the -# supported transformers range above. -kernels==0.15.2 +# Hub kernels, including LTX-2.5 NATTEN. 0.16+ resolves torch-stable-abi* +# FA3 builds on torch 2.13 / cu130; keep in sync with transformers above. +kernels==0.16.1 safetensors>=0.8.0 accelerate==1.12.0 soundfile>=0.13.1 diff --git a/tests/diffusion/attention/test_kernels_hub.py b/tests/diffusion/attention/test_kernels_hub.py index 3f7451ca3..c85fb58f3 100644 --- a/tests/diffusion/attention/test_kernels_hub.py +++ b/tests/diffusion/attention/test_kernels_hub.py @@ -13,6 +13,12 @@ from vllm_omni.diffusion.attention.backends.sdpa import SDPAImpl from vllm_omni.platforms import current_omni_platform +def _clear_hub_module_cache(monkeypatch: pytest.MonkeyPatch) -> None: + from vllm_omni.diffusion.attention.backends import flash_attn_hub + + monkeypatch.setattr(flash_attn_hub, "_hub_modules", {}) + + @pytest.mark.core_model @pytest.mark.cpu def test_explicit_kernels_hub_selection_does_not_fallback(monkeypatch: pytest.MonkeyPatch): @@ -54,6 +60,74 @@ def test_explicit_kernels_hub_selection_does_not_fallback(monkeypatch: pytest.Mo CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64) +@pytest.mark.core_model +@pytest.mark.cpu +def test_explicit_kernels_hub_variant_miss_does_not_fallback(monkeypatch: pytest.MonkeyPatch): + """Explicit Hub backends must fail loudly when no build variant resolves (#6971).""" + from vllm.platforms.interface import DeviceCapability + + from vllm_omni.diffusion.envs import PACKAGES_CHECKER + from vllm_omni.platforms.cuda.platform import CudaOmniPlatform + + monkeypatch.setattr( + CudaOmniPlatform, + "get_device_capability", + classmethod(lambda cls, device_id=0: DeviceCapability(9, 0)), + ) + monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": True}) + _clear_hub_module_cache(monkeypatch) + + kernels_module = types.ModuleType("kernels") + + def _missing_variant(repo_id, version=None, **kwargs): + raise FileNotFoundError(f"Cannot find a build variant for this system in {repo_id}") + + kernels_module.get_kernel = _missing_variant # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "kernels", kernels_module) + + with pytest.raises(RuntimeError, match="no compatible build"): + CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64) + with pytest.raises(RuntimeError, match="no compatible build"): + CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_HUB", head_size=64) + + +@pytest.mark.core_model +@pytest.mark.cpu +def test_explicit_kernels_hub_preflight_uses_v2_when_v1_misses(monkeypatch: pytest.MonkeyPatch): + """Preflight must share _load_hub_module's (1, 2) policy, not pin version=1.""" + from vllm.platforms.interface import DeviceCapability + + from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum + from vllm_omni.diffusion.envs import PACKAGES_CHECKER + from vllm_omni.platforms.cuda.platform import CudaOmniPlatform + + monkeypatch.setattr( + CudaOmniPlatform, + "get_device_capability", + classmethod(lambda cls, device_id=0: DeviceCapability(9, 0)), + ) + monkeypatch.setattr(PACKAGES_CHECKER, "get_packages_info", lambda: {"has_flash_attn": True}) + _clear_hub_module_cache(monkeypatch) + + kernels_module = types.ModuleType("kernels") + versions_tried: list[int | None] = [] + + def _v1_miss_v2_hit(repo_id, version=None, **kwargs): + versions_tried.append(version) + if version == 1: + raise FileNotFoundError(f"Cannot find a build variant for this system in {repo_id}") + return types.SimpleNamespace(name=f"{repo_id}@v{version}") + + kernels_module.get_kernel = _v1_miss_v2_hit # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "kernels", kernels_module) + + fa3_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_3_HUB", head_size=64) + assert fa3_path == DiffusionAttentionBackendEnum.FLASH_ATTN_3_HUB.get_path() + fa2_path = CudaOmniPlatform.get_diffusion_attn_backend_cls("FLASH_ATTN_HUB", head_size=64) + assert fa2_path == DiffusionAttentionBackendEnum.FLASH_ATTN_HUB.get_path() + assert versions_tried == [1, 2, 1, 2] + + @pytest.mark.core_model @pytest.mark.cpu def test_explicit_flash_attention_unavailable_does_not_fallback(monkeypatch: pytest.MonkeyPatch): diff --git a/tests/diffusion/models/ltx2/test_ltx25_pipeline.py b/tests/diffusion/models/ltx2/test_ltx25_pipeline.py index ae90ea1a3..a96fa8a4e 100644 --- a/tests/diffusion/models/ltx2/test_ltx25_pipeline.py +++ b/tests/diffusion/models/ltx2/test_ltx25_pipeline.py @@ -186,7 +186,7 @@ def test_ltx25_missing_gemma4_recommends_supported_transformers_range(monkeypatc ltx2_components.initialize_pipeline_components(pipe, od_config) assert str(exc_info.value) == ( - "LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.10.1,<5.15." + "LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.13.0,<5.15." ) @@ -387,7 +387,7 @@ def test_ltx25_natten_failure_has_actionable_omni_remedy(monkeypatch): MissingNattenProcessor, ) - with pytest.raises(RuntimeError, match=r"kernels==0\.15\.2.*supported GPU.*allow Hub access"): + with pytest.raises(RuntimeError, match=r"kernels==0\.16\.1.*supported GPU.*allow Hub access"): ltx2_components._create_ltx25_natten_processor() diff --git a/tests/e2e/accuracy/test_qwen_image.py b/tests/e2e/accuracy/test_qwen_image.py index a09014c8d..45fd20934 100644 --- a/tests/e2e/accuracy/test_qwen_image.py +++ b/tests/e2e/accuracy/test_qwen_image.py @@ -34,11 +34,7 @@ TRUE_CFG_SCALE = 4.0 SEED = 42 SSIM_THRESHOLD = 0.94 # With --fa-deterministic, H100 nightlies stably land near SSIM 0.958 / PSNR 27.8 -# vs Diffusers (see #5734 / #5963), so the primary gates are 0.94 / 27.0. -# The SDPA-matched fallback (hub kernel unavailable for the image's torch) -# landed at SSIM 0.9687 in build 2954 — keep a slightly tighter SSIM gate for -# that path only. -SSIM_THRESHOLD_SDPA_FALLBACK = 0.96 +# vs Diffusers on the FA3 hub path (see #5734 / #5963 / #6971). PSNR_THRESHOLD = 27.0 MODEL_2512_ID = "Qwen/Qwen-Image-2512" @@ -65,44 +61,6 @@ MEAN_ABS_DIFF_THRESHOLD_2512 = 3e-2 P99_ABS_DIFF_THRESHOLD_2512 = 4e-1 -_FLASH_ATTN3_HUB_AVAILABLE: bool | None = None - - -def _flash_attn3_hub_available() -> bool: - """Whether kernels-community/flash-attn3 has a build for this torch/CUDA. - - The hub repo only ships prebuilt variants for specific torch versions; - a base-image torch bump can outrun it (build 2952 failed with "Cannot - find a build variant for this system"). When it is unavailable, both - sides of the comparison fall back to torch SDPA together — the test - compares implementations, so the attention math must stay matched. - """ - global _FLASH_ATTN3_HUB_AVAILABLE - if _FLASH_ATTN3_HUB_AVAILABLE is None: - try: - from kernels import get_kernel - - get_kernel("kernels-community/flash-attn3") - _FLASH_ATTN3_HUB_AVAILABLE = True - except Exception as exc: - print(f"kernels-community/flash-attn3 unavailable ({exc}); using matched torch SDPA on both sides.") - _FLASH_ATTN3_HUB_AVAILABLE = False - return _FLASH_ATTN3_HUB_AVAILABLE - - -def _omni_server_env() -> dict[str, str] | None: - if _flash_attn3_hub_available(): - return None - return {"DIFFUSION_ATTENTION_BACKEND": "TORCH_SDPA"} - - -def _set_reference_attention_backend(pipe: DiffusionPipeline) -> None: - if _flash_attn3_hub_available(): - pipe.transformer.set_attention_backend("_flash_3_hub") - else: - pipe.transformer.set_attention_backend("native") - - def _model_name() -> str: return os.environ.get(MODEL_ENV_VAR, MODEL_ID) @@ -115,7 +73,22 @@ def _local_files_only(model: str) -> bool: return Path(model).exists() +_OMNI_FA3_HUB_BACKEND = "FLASH_ATTN_3_HUB" +_OMNI_FA3_HUB_ENV = {"DIFFUSION_ATTENTION_BACKEND": _OMNI_FA3_HUB_BACKEND} + + +def _assert_omni_fa3_hub_resolves() -> None: + from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum + from vllm_omni.platforms.cuda.platform import CudaOmniPlatform + + path = CudaOmniPlatform.get_diffusion_attn_backend_cls(_OMNI_FA3_HUB_BACKEND, head_size=128) + expected = DiffusionAttentionBackendEnum.FLASH_ATTN_3_HUB.get_path() + assert path == expected, path + print(f"Resolved Omni attention backend: {_OMNI_FA3_HUB_BACKEND} -> {path}") + + def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image: + _assert_omni_fa3_hub_resolves() server_args = [ "--num-gpus", "1", @@ -124,8 +97,10 @@ def _run_vllm_omni_qwen_image(*, model: str, output_path: Path) -> Image.Image: "--init-timeout", "900", "--fa-deterministic", + "--diffusion-attention-backend", + _OMNI_FA3_HUB_BACKEND, ] - with OmniServer(model, server_args, use_omni=True, env_dict=_omni_server_env()) as omni_server: + with OmniServer(model, server_args, use_omni=True, env_dict=_OMNI_FA3_HUB_ENV) as omni_server: response = requests.post( f"http://{omni_server.host}:{omni_server.port}/v1/images/generations", json={ @@ -161,7 +136,7 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image: trust_remote_code=True, local_files_only=_local_files_only(model), ).to("cuda") - _set_reference_attention_backend(pipe) + pipe.transformer.set_attention_backend("_flash_3_hub") generator = torch.Generator(device="cuda").manual_seed(SEED) result = pipe( # pyright: ignore[reportCallIssue] prompt=PROMPT, @@ -186,8 +161,18 @@ def _run_diffusers_qwen_image(*, model: str, output_path: Path) -> Image.Image: def _run_vllm_omni_qwen_image_2512(*, model: str, output_path: Path) -> Image.Image: - server_args = ["--num-gpus", "1", "--stage-init-timeout", "300", "--init-timeout", "900"] - with OmniServer(model, server_args, use_omni=True, env_dict=_omni_server_env()) as omni_server: + _assert_omni_fa3_hub_resolves() + server_args = [ + "--num-gpus", + "1", + "--stage-init-timeout", + "300", + "--init-timeout", + "900", + "--diffusion-attention-backend", + _OMNI_FA3_HUB_BACKEND, + ] + with OmniServer(model, server_args, use_omni=True, env_dict=_OMNI_FA3_HUB_ENV) as omni_server: response = requests.post( f"http://{omni_server.host}:{omni_server.port}/v1/images/generations", json={ @@ -223,7 +208,7 @@ def _run_diffusers_qwen_image_2512(*, model: str, output_path: Path) -> Image.Im trust_remote_code=True, local_files_only=_local_files_only(model), ).to("cuda") - _set_reference_attention_backend(pipe) + pipe.transformer.set_attention_backend("_flash_3_hub") generator = torch.Generator(device="cuda").manual_seed(SEED_2512) result = pipe( # pyright: ignore[reportCallIssue] prompt=PROMPT_2512, @@ -262,7 +247,7 @@ def test_qwen_image_matches_diffusers(accuracy_artifact_root: Path) -> None: diffusers_image=diffusers_output, width=WIDTH, height=HEIGHT, - ssim_threshold=SSIM_THRESHOLD if _flash_attn3_hub_available() else SSIM_THRESHOLD_SDPA_FALLBACK, + ssim_threshold=SSIM_THRESHOLD, psnr_threshold=PSNR_THRESHOLD, ) diff --git a/vllm_omni/diffusion/attention/backends/flash_attn_hub.py b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py index 7f9c78bf7..b24c99694 100644 --- a/vllm_omni/diffusion/attention/backends/flash_attn_hub.py +++ b/vllm_omni/diffusion/attention/backends/flash_attn_hub.py @@ -28,11 +28,9 @@ def _load_hub_module(repo_id: str): logger.info("Loading %s kernel from HuggingFace Hub...", repo_id) last_error = None - for version in (1, 2, None): + for version in (1, 2): try: - if version is not None: - return get_kernel(repo_id, version=version) - return get_kernel(repo_id) + return get_kernel(repo_id, version=version) except Exception as exc: logger.info("Failed to load %s version %s: %s", repo_id, version, exc) last_error = exc diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_components.py b/vllm_omni/diffusion/models/ltx2/ltx2_components.py index f1dcc38bb..5826f273e 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_components.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_components.py @@ -298,7 +298,7 @@ def _create_ltx25_natten_processor() -> LTX2VideoVaeNeighborhoodNattenProcessor: except (ImportError, OSError, RuntimeError, ValueError) as exc: raise RuntimeError( "LTX-2.5 DiffVAE requires the shi-labs/natten Hub kernel. " - "Install kernels==0.15.2, use a supported GPU, leave " + "Install kernels==0.16.1, use a supported GPU, leave " "DIFFUSERS_DISABLE_REMOTE_CODE unset, and allow Hub access during kernel initialization." ) from exc @@ -662,7 +662,7 @@ def initialize_pipeline_components(pipeline: Any, od_config: Any) -> None: revision=revision, ) if profile.text_encoder_cls is None: - raise ImportError("LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.10.1,<5.15.") + raise ImportError("LTX-2.5 requires Gemma4UnifiedForConditionalGeneration; install transformers>=5.13.0,<5.15.") with torch.device("cpu"): pipeline.text_encoder = _load_component( profile.text_encoder_cls, diff --git a/vllm_omni/platforms/cuda/platform.py b/vllm_omni/platforms/cuda/platform.py index c635877a9..25124b4e0 100644 --- a/vllm_omni/platforms/cuda/platform.py +++ b/vllm_omni/platforms/cuda/platform.py @@ -231,6 +231,26 @@ class CudaOmniPlatform(OmniPlatform, CudaPlatformBase): "with compute capability 9.x. Select a compatible backend." ) + if backend_upper in ("FLASH_ATTN_HUB", "FLASH_ATTN_3_HUB"): + # Same loader/cache as execution (Hub versions 1 then 2). A missing + # torch/CUDA variant used to degrade silently to native/SDPA (#6971). + hub_repo = ( + "kernels-community/flash-attn3" + if backend_upper == "FLASH_ATTN_3_HUB" + else "kernels-community/flash-attn2" + ) + try: + from vllm_omni.diffusion.attention.backends.flash_attn_hub import _get_hub_module + + _get_hub_module(hub_repo) + except Exception as e: + raise RuntimeError( + f"{backend_upper} was explicitly selected, but {hub_repo} has no " + "compatible build for this torch/CUDA (or failed to load). " + "Install kernels>=0.16.1 so stable-ABI variants resolve, or select " + "a different attention backend." + ) from e + if backend_upper == "FLASH_ATTN" and not flash_attn_supported: if is_blackwell: reason = ( From 6e79d582e3c4a4da4a159904125f3de7a6cbae2b Mon Sep 17 00:00:00 2001 From: zyz111222 Date: Wed, 9 Sep 2026 19:03:09 +0800 Subject: [PATCH 72/90] [Bugfix][Ascend] fix npu 310/a5 bugs (#6685) Signed-off-by: zouyizhou --- tests/platforms/npu/test_310p_patches.py | 69 ++--- tests/platforms/npu/test_a5_patches.py | 259 ++++++++++++++++++ .../models/qwen3_tts/prompt_embeds_builder.py | 10 +- vllm_omni/platforms/npu/_310p/__init__.py | 7 +- .../platforms/npu/_310p/patch/qwen3_tts.py | 43 +-- vllm_omni/platforms/npu/__init__.py | 16 ++ .../{qwen3_tts_code2wav.py => qwen3_tts.py} | 59 +++- vllm_omni/platforms/npu/platform.py | 6 +- 8 files changed, 380 insertions(+), 89 deletions(-) create mode 100644 tests/platforms/npu/test_a5_patches.py rename vllm_omni/platforms/npu/models/{qwen3_tts_code2wav.py => qwen3_tts.py} (54%) diff --git a/tests/platforms/npu/test_310p_patches.py b/tests/platforms/npu/test_310p_patches.py index 08cbb2adc..c19dcd997 100644 --- a/tests/platforms/npu/test_310p_patches.py +++ b/tests/platforms/npu/test_310p_patches.py @@ -431,9 +431,17 @@ def test_310p_attention_forward_runs_fused_qkv_runtime(monkeypatch: pytest.Monke trans_calls.append(weight.detach().clone()) return weight + 1.0 - def npu_rotary_mul(hidden_states: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor) -> torch.Tensor: - rotary_calls.append((hidden_states.detach().clone(), cos.detach().clone(), sin.detach().clone())) - return hidden_states + cos - sin + def npu_apply_rotary_pos_emb( + query: torch.Tensor, + key: torch.Tensor, + cos: torch.Tensor, + sin: torch.Tensor, + *, + layout: str = "BSND", + rotary_mode: str = "half", + ): + rotary_calls.append((query.detach().clone(), key.detach().clone(), cos.detach().clone(), sin.detach().clone())) + return query + cos - sin, key + cos - sin def npu_flash_attention(**kwargs) -> None: flash_calls.append( @@ -443,7 +451,7 @@ def test_310p_attention_forward_runs_fused_qkv_runtime(monkeypatch: pytest.Monke monkeypatch.setattr(module, "maybe_trans_nz", maybe_trans_nz) monkeypatch.setattr(module, "aligned_16", lambda tensor: tensor) - monkeypatch.setattr(module.torch_npu, "npu_rotary_mul", npu_rotary_mul, raising=False) + monkeypatch.setattr(module.torch_npu, "npu_apply_rotary_pos_emb", npu_apply_rotary_pos_emb, raising=False) monkeypatch.setattr(module.torch_npu, "_npu_flash_attention", npu_flash_attention, raising=False) config = SimpleNamespace( @@ -503,7 +511,12 @@ def test_310p_attention_forward_runs_fused_qkv_runtime(monkeypatch: pytest.Monke expected_v_f = expected_v.transpose(1, 2).reshape(3, attention.num_kv_heads, attention.head_dim) expected_flash = (expected_q_f + expected_k_f + expected_v_f).to(torch.float16).to(torch.float32) - assert len(rotary_calls) == 2 + # One fused aclnn rope call covers q and k in the 4D BSND layout. + assert len(rotary_calls) == 1 + assert rotary_calls[0][0].shape == (1, 3, attention.num_heads, attention.head_dim) + assert rotary_calls[0][1].shape == (1, 3, attention.num_kv_heads, attention.head_dim) + assert rotary_calls[0][2].shape == (1, 3, 1, attention.head_dim) + assert rotary_calls[0][3].shape == (1, 3, 1, attention.head_dim) assert len(flash_calls) == 1 torch.testing.assert_close(flash_calls[0]["query"], expected_q_f) torch.testing.assert_close(flash_calls[0]["key"], expected_k_f) @@ -535,39 +548,16 @@ def test_qwen3_tts_talker_patch_uses_fp16_runtime_dtype(monkeypatch: pytest.Monk assert codes[0].shape == (4, 2) -def test_qwen3_tts_prompt_patch_runs_stft_frontend_on_cpu(monkeypatch: pytest.MonkeyPatch) -> None: +def test_qwen3_tts_prompt_patch_sets_cpu_mel_front_end_flag(monkeypatch: pytest.MonkeyPatch) -> None: module, _ = _load_qwen3_tts_patch(monkeypatch) - captured = {} - def fake_mel_spectrogram(wav_tensor, **kwargs): - captured["wav_device"] = wav_tensor.device - captured["wav_dtype"] = wav_tensor.dtype - captured["kwargs"] = kwargs - return torch.ones(1, 128, 3, dtype=torch.float32) - - class FakeSpeakerEncoder(torch.nn.Module): - def __init__(self): - super().__init__() - self.param = torch.nn.Parameter(torch.zeros(1, dtype=torch.float16)) - - def forward(self, mels): - captured["speaker_input_dtype"] = mels.dtype - return (torch.ones(4, dtype=mels.dtype),) - - monkeypatch.setattr(module.prompt_embeds_builder, "mel_spectrogram", fake_mel_spectrogram) - builder = object.__new__(module._Qwen3TTSPromptEmbedsBuilder310P) - builder._device = lambda: torch.device("cpu") - builder._embedding_dtype = torch.float16 - builder._speaker_encoder = FakeSpeakerEncoder() - builder._config = SimpleNamespace(speaker_encoder_config=SimpleNamespace(sample_rate=24000)) - - speaker = builder.extract_speaker_embedding(np.zeros(16, dtype=np.float32), 24000) - - assert captured["wav_device"] == torch.device("cpu") - assert captured["wav_dtype"] is torch.float32 - assert captured["kwargs"]["sampling_rate"] == 24000 - assert captured["speaker_input_dtype"] is torch.float16 - assert speaker.dtype is torch.float16 + # The 310P builder keeps the base implementation and only opts into the + # CPU mel front-end, because the NPU has no torch.stft. + assert issubclass( + module._Qwen3TTSPromptEmbedsBuilder310P, + module.prompt_embeds_builder.Qwen3TTSPromptEmbedsBuilder, + ) + assert module._Qwen3TTSPromptEmbedsBuilder310P._mel_spectrogram_on_cpu is True def test_qwen3_tts_tokenizer_npu_patch_dispatches_fused_ops(monkeypatch: pytest.MonkeyPatch) -> None: @@ -706,11 +696,10 @@ def test_qwen3_tts_code2wav_npu_patch_prepares_loaded_decoder(monkeypatch: pytes _install_fake_module(monkeypatch, "vllm_ascend.utils", maybe_trans_nz=maybe_trans_nz) _install_fake_module(monkeypatch, "vllm_omni") _install_fake_module(monkeypatch, "vllm_omni.platforms", current_omni_platform=current_platform) - _install_fake_module(monkeypatch, "vllm_omni.model_executor") - _install_fake_module(monkeypatch, "vllm_omni.model_executor.models") - _install_fake_module(monkeypatch, "vllm_omni.model_executor.models.qwen3_tts") + # Not A5: keep the FRACTAL_Z conv weight layout for 310P. + _install_fake_module(monkeypatch, "vllm_omni.platforms.npu", is_a5=lambda: False) - path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "models" / "qwen3_tts_code2wav.py" + path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "models" / "qwen3_tts.py" module = _load_source_module("vllm_omni_test_qwen3_tts_code2wav_npu_patch", path) module.apply_qwen3_tts_code2wav_patch() diff --git a/tests/platforms/npu/test_a5_patches.py b/tests/platforms/npu/test_a5_patches.py new file mode 100644 index 000000000..0d6648235 --- /dev/null +++ b/tests/platforms/npu/test_a5_patches.py @@ -0,0 +1,259 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +"""Unit tests for A5 (Ascend 950 PR) patch wiring. + +The A5 logic lives in the shared NPU platform files (``npu/__init__.py`` for +device detection, ``npu/models/qwen3_tts.py`` for the Qwen3-TTS +patch). The tests load those modules from source with fake Qwen3-TTS +dependencies, so they validate the patch contract without loading real model +or NPU kernels. A5 device detection is exercised with a fake +``vllm_ascend.utils`` module. +""" + +from __future__ import annotations + +import importlib.util +import sys +import types +from pathlib import Path +from types import SimpleNamespace + +import pytest +import torch + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def _repo_root() -> Path: + marker = Path("vllm_omni") / "platforms" / "npu" / "models" + for parent in Path(__file__).resolve().parents: + if (parent / marker).is_dir(): + return parent + raise FileNotFoundError(f"could not locate repo root containing {marker}") + + +def _load_source_module(name: str, path: Path): + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"cannot load import spec for {path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _install_fake_module(monkeypatch: pytest.MonkeyPatch, name: str, **attrs): + module = types.ModuleType(name) + for key, value in attrs.items(): + setattr(module, key, value) + monkeypatch.setitem(sys.modules, name, module) + return module + + +def _load_npu_init(monkeypatch: pytest.MonkeyPatch): + _install_fake_module(monkeypatch, "vllm") + _install_fake_module( + monkeypatch, + "vllm.logger", + init_logger=lambda _name: SimpleNamespace(debug=lambda *_: None), + ) + path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "__init__.py" + return _load_source_module("vllm_omni_test_npu_init", path) + + +def _load_qwen3_tts_code2wav_patch( + monkeypatch: pytest.MonkeyPatch, + *, + a5: bool, +): + class FakePromptEmbedsBuilder: + pass + + class FakeCode2Wav: + def __init__(self, *, vllm_config, prefix=""): + pass + + def load_weights(self, weights): + pass + + fake_prompt_builder = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.prompt_embeds_builder", + Qwen3TTSPromptEmbedsBuilder=FakePromptEmbedsBuilder, + mel_spectrogram=lambda *_args, **_kwargs: torch.empty(0), + ) + fake_talker = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_talker", + Qwen3TTSPromptEmbedsBuilder=FakePromptEmbedsBuilder, + ) + _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav", + Qwen3TTSCode2Wav=FakeCode2Wav, + ) + _install_fake_module(monkeypatch, "vllm_omni") + _install_fake_module(monkeypatch, "vllm_omni.model_executor") + _install_fake_module(monkeypatch, "vllm_omni.model_executor.models") + pkg_qwen3_tts = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts", + prompt_embeds_builder=fake_prompt_builder, + qwen3_tts_talker=fake_talker, + ) + # Mark the package fakes as real packages so the patch's lazy submodule + # imports (e.g. ``qwen3_tts_code2wav``) resolve inside the harness. + pkg_qwen3_tts.__path__ = [] + _install_fake_module( + monkeypatch, + "vllm_omni.platforms", + current_omni_platform=SimpleNamespace(is_npu=lambda: False), + ) + _install_fake_module(monkeypatch, "vllm_omni.platforms.npu", is_a5=lambda: a5) + _install_fake_module(monkeypatch, "vllm") + _install_fake_module(monkeypatch, "vllm.config", VllmConfig=object) + _install_fake_module( + monkeypatch, + "vllm.logger", + init_logger=lambda _name: SimpleNamespace(info=lambda *_: None, debug=lambda *_: None), + ) + _install_fake_module(monkeypatch, "vllm_ascend") + _install_fake_module(monkeypatch, "vllm_ascend.utils", maybe_trans_nz=lambda weight: weight) + _install_fake_module(monkeypatch, "torch_npu", npu_format_cast=lambda weight, _fmt: weight) + + path = _repo_root() / "vllm_omni" / "platforms" / "npu" / "models" / "qwen3_tts.py" + module = _load_source_module("vllm_omni_test_qwen3_tts_code2wav_patch", path) + return module, fake_prompt_builder, fake_talker + + +def test_is_a5_detects_a5_device(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_npu_init(monkeypatch) + _install_fake_module(monkeypatch, "vllm_ascend") + _install_fake_module(monkeypatch, "vllm_ascend.utils", is_950=lambda: True) + + assert module.is_a5() is True + + class NpuDevice: + type = "npu" + + assert module.is_a5(NpuDevice()) is True + assert module.is_a5(torch.device("cpu")) is False + + +def test_is_a5_falls_back_false_without_is_950(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_npu_init(monkeypatch) + # Older vllm-ascend releases have no `is_950`; detection must fall back. + _install_fake_module(monkeypatch, "vllm_ascend") + _install_fake_module(monkeypatch, "vllm_ascend.utils") + + assert module.is_a5(torch.device("cuda")) is False + assert module.is_a5() is False + + +def test_qwen3_tts_patch_swaps_prompt_builder_on_a5(monkeypatch: pytest.MonkeyPatch) -> None: + module, fake_prompt_builder, fake_talker = _load_qwen3_tts_code2wav_patch( + monkeypatch, + a5=True, + ) + original = fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder + + module.apply_qwen3_tts_patches() + + assert fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder is not original + assert fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder is fake_talker.Qwen3TTSPromptEmbedsBuilder + + module.apply_qwen3_tts_patches() + assert fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder is fake_talker.Qwen3TTSPromptEmbedsBuilder + + +def test_qwen3_tts_patch_keeps_prompt_builder_off_a5(monkeypatch: pytest.MonkeyPatch) -> None: + module, fake_prompt_builder, fake_talker = _load_qwen3_tts_code2wav_patch( + monkeypatch, + a5=False, + ) + original = fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder + + module.apply_qwen3_tts_patches() + + assert fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder is original + assert fake_talker.Qwen3TTSPromptEmbedsBuilder is original + + +def test_a5_prompt_patch_sets_cpu_mel_front_end_flag(monkeypatch: pytest.MonkeyPatch) -> None: + module, fake_prompt_builder, fake_talker = _load_qwen3_tts_code2wav_patch( + monkeypatch, + a5=True, + ) + original = fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder + + module.apply_qwen3_tts_patches() + + # The A5 builder keeps the base implementation and only opts into the CPU + # mel front-end, because the NPU has no torch.stft. + builder_cls = fake_prompt_builder.Qwen3TTSPromptEmbedsBuilder + assert builder_cls is not original + assert builder_cls._mel_spectrogram_on_cpu is True + assert fake_talker.Qwen3TTSPromptEmbedsBuilder is builder_cls + + +def test_qwen3_tts_code2wav_patch_skips_fractal_z_conv_cast_on_a5( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module, _, _ = _load_qwen3_tts_code2wav_patch(monkeypatch, a5=True) + linear_weights = [] + conv_weights = [] + + monkeypatch.setattr( + module, + "maybe_trans_nz", + lambda weight: linear_weights.append(weight) or weight, + ) + monkeypatch.setattr( + module.torch_npu, + "npu_format_cast", + lambda weight, fmt: conv_weights.append((weight, fmt)) or weight, + ) + + class FakeDecoder(torch.nn.Module): + def __init__(self): + super().__init__() + self.linear = torch.nn.Linear(4, 4) + self.conv = torch.nn.Conv1d(4, 4, 3) + self.deconv = torch.nn.ConvTranspose1d(4, 4, 4) + self.grouped_conv = torch.nn.Conv1d(4, 4, 3, groups=2) + self.cache_precompute_calls = 0 + + def precompute_snake_caches(self): + self.cache_precompute_calls += 1 + + class FakeCode2Wav: + def __init__(self, *, vllm_config, prefix=""): + self.vllm_config = vllm_config + self.prefix = prefix + self.decoder = FakeDecoder() + + def _npu_decoder_runtime_dtype(self, _device): + return torch.float16 + + def load_weights(self, weights): + assert list(weights) == [] + return {"loaded"} + + target = _install_fake_module( + monkeypatch, + "vllm_omni.model_executor.models.qwen3_tts.qwen3_tts_code2wav", + Qwen3TTSCode2Wav=FakeCode2Wav, + ) + module.apply_qwen3_tts_code2wav_patch() + + model = target.Qwen3TTSCode2Wav( + vllm_config=SimpleNamespace(device_config=SimpleNamespace(device=torch.device("cpu"))), + prefix="stage1", + ) + assert model.load_weights(iter(())) == {"loaded"} + + assert model.prefix == "stage1" + assert model.decoder.linear.weight.dtype is torch.float16 + assert [weight.data_ptr() for weight in linear_weights] == [model.decoder.linear.weight.data_ptr()] + # On A5 convs keep the contiguous ND layout: no FRACTAL_Z cast is issued. + assert conv_weights == [] + assert model.decoder.cache_precompute_calls == 1 diff --git a/vllm_omni/model_executor/models/qwen3_tts/prompt_embeds_builder.py b/vllm_omni/model_executor/models/qwen3_tts/prompt_embeds_builder.py index ddb55cd51..609117ce3 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/prompt_embeds_builder.py +++ b/vllm_omni/model_executor/models/qwen3_tts/prompt_embeds_builder.py @@ -643,6 +643,10 @@ class Qwen3TTSPromptEmbedsBuilder: # -------------------- speaker encoder / codec encoder -------------------- + # Some NPUs (310P, Ascend 950) do not support torch.stft; builders on + # such devices set this flag to compute the mel spectrogram on CPU. + _mel_spectrogram_on_cpu: bool = False + def extract_speaker_embedding(self, wav: np.ndarray, sr: int) -> torch.Tensor: # vLLM workers do not automatically move arbitrary torch.nn.Modules to # CUDA. Ensure the speaker encoder is on the same device/dtype as the @@ -666,7 +670,9 @@ class Qwen3TTSPromptEmbedsBuilder: # Follow official implementation: mel_spectrogram expects 24kHz. Move # the waveform first so STFT/mel computation stays on the model device # instead of materializing a CPU mel tensor and copying it per request. - wav_tensor = torch.from_numpy(wav).to(device=dev, dtype=torch.float32).unsqueeze(0) + # Devices without torch.stft set _mel_spectrogram_on_cpu instead. + mel_device = torch.device("cpu") if self._mel_spectrogram_on_cpu else dev + wav_tensor = torch.from_numpy(wav).to(device=mel_device, dtype=torch.float32).unsqueeze(0) mels = mel_spectrogram( wav_tensor, n_fft=1024, @@ -677,7 +683,7 @@ class Qwen3TTSPromptEmbedsBuilder: fmin=0, fmax=12000, ).transpose(1, 2) - spk = self._speaker_encoder(mels.to(dtype=dtype))[0] + spk = self._speaker_encoder(mels.to(device=dev, dtype=dtype))[0] return spk.to(dtype=dtype) def encode_ref_audio_batch( diff --git a/vllm_omni/platforms/npu/_310p/__init__.py b/vllm_omni/platforms/npu/_310p/__init__.py index 129cf8e20..605a1aa53 100644 --- a/vllm_omni/platforms/npu/_310p/__init__.py +++ b/vllm_omni/platforms/npu/_310p/__init__.py @@ -6,12 +6,10 @@ from __future__ import annotations import torch -from vllm.logger import init_logger - -logger = init_logger(__name__) def is_310p(device: torch.device | None = None) -> bool: + """Return True on Ascend 310P devices, False otherwise.""" if device is not None and device.type != "npu": return False try: @@ -20,9 +18,6 @@ def is_310p(device: torch.device | None = None) -> bool: return bool(ascend_is_310p()) except (ImportError, AttributeError): return False - except Exception: - logger.debug("Failed to detect Ascend 310P device.", exc_info=True) - return False def disable_jit_compile() -> None: diff --git a/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py b/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py index dd03ba7d2..4ec4b6b72 100644 --- a/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py +++ b/vllm_omni/platforms/npu/_310p/patch/qwen3_tts.py @@ -81,35 +81,9 @@ class _Qwen3TTSTalker310P(qwen3_tts_talker.Qwen3TTSTalkerForConditionalGeneratio class _Qwen3TTSPromptEmbedsBuilder310P(prompt_embeds_builder.Qwen3TTSPromptEmbedsBuilder): - def extract_speaker_embedding(self, wav: np.ndarray, sr: int) -> torch.Tensor: - dev = self._device() - dtype = self._embedding_dtype - try: - spk_param = next(self._speaker_encoder.parameters()) - if spk_param.device != dev or spk_param.dtype != dtype: - self._speaker_encoder.to(device=dev, dtype=dtype) - except StopIteration: - pass + """Qwen3-TTS prompt-embeds builder specialized for the 310P NPU path.""" - target_sr = int(getattr(self._config.speaker_encoder_config, "sample_rate", 24000)) - if sr != target_sr: - resampler = self._get_resampler(int(sr), target_sr) - wav = resampler.resample(wav.astype(np.float32), orig_sr=int(sr)) - - # 310P does not support torch.stft on NPU. - wav_tensor = torch.from_numpy(wav).to(device=_CPU_DEVICE, dtype=torch.float32).unsqueeze(0) - mels = prompt_embeds_builder.mel_spectrogram( - wav_tensor, - n_fft=1024, - num_mels=128, - sampling_rate=24000, - hop_size=256, - win_size=1024, - fmin=0, - fmax=12000, - ).transpose(1, 2) - spk = self._speaker_encoder(mels.to(device=dev, dtype=dtype))[0] - return spk.to(dtype=dtype) + _mel_spectrogram_on_cpu = True # =================================================================== @@ -192,10 +166,15 @@ class _Qwen3CodePredictorAttention310P(qwen3_code_predictor.CodePredictorAttenti cos, sin = position_embeddings cos = cos.unsqueeze(1) sin = sin.unsqueeze(1) - # Use the fused Ascend RoPE op instead of expanding RoPE into - # elementwise mul/add/rotate-half kernels. - q = torch_npu.npu_rotary_mul(q, cos, sin) - k = torch_npu.npu_rotary_mul(k, cos, sin) + # Use the fused aclnn rope op, which is NPUGraph-capturable; it + # requires the 4D BSND layout with head_dim 128 or 64. + q = q.transpose(1, 2).contiguous() + k = k.transpose(1, 2).contiguous() + cos = cos.transpose(1, 2).contiguous() + sin = sin.transpose(1, 2).contiguous() + q, k = torch_npu.npu_apply_rotary_pos_emb(q, k, cos, sin, rotary_mode="half") + q = q.transpose(1, 2) + k = k.transpose(1, 2) real_tokens = int(bsz) * int(seq_len) output_dtype = q.dtype diff --git a/vllm_omni/platforms/npu/__init__.py b/vllm_omni/platforms/npu/__init__.py index 74dfc419c..3df9b5ca3 100644 --- a/vllm_omni/platforms/npu/__init__.py +++ b/vllm_omni/platforms/npu/__init__.py @@ -1,14 +1,30 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project +from __future__ import annotations + from typing import TYPE_CHECKING +import torch + if TYPE_CHECKING: from vllm_omni.platforms.npu.platform import NPUOmniPlatform __all__ = ["NPUOmniPlatform"] +def is_a5(device: torch.device | None = None) -> bool: + """Return True on Ascend 950 (A5) devices, False otherwise.""" + if device is not None and device.type != "npu": + return False + try: + from vllm_ascend.utils import is_950 + + return bool(is_950()) + except (ImportError, AttributeError): + return False + + def __getattr__(name: str): if name != "NPUOmniPlatform": raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py b/vllm_omni/platforms/npu/models/qwen3_tts.py similarity index 54% rename from vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py rename to vllm_omni/platforms/npu/models/qwen3_tts.py index bd74e9dc6..a91d2cf80 100644 --- a/vllm_omni/platforms/npu/models/qwen3_tts_code2wav.py +++ b/vllm_omni/platforms/npu/models/qwen3_tts.py @@ -1,12 +1,16 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project -"""Patch Qwen3-TTS Code2Wav NPU runtime setup and weight preparation.""" +"""Patch Qwen3-TTS NPU runtime setup and weights. + +The code2wav runtime/weight fixes apply on every NPU. On A5 (Ascend 950 PR) +``torch.stft`` is unavailable, so the speaker-encoder mel front-end of the +prompt-embeds builder runs on CPU and the result is moved back to NPU +(same trade-off as the 310P path). +""" from __future__ import annotations -from typing import TYPE_CHECKING - import torch import torch.nn as nn import torch_npu @@ -14,12 +18,12 @@ from vllm.config import VllmConfig from vllm.logger import init_logger from vllm_ascend.utils import maybe_trans_nz -if TYPE_CHECKING: - pass +from vllm_omni.platforms.npu import is_a5 logger = init_logger(__name__) _PATCHED = False +_A5_PATCHED = False _original_init = None _original_load_weights = None _ACL_FORMAT_FRACTAL_Z = 4 @@ -49,7 +53,12 @@ def _prepare_npu_decoder_weights(decoder: nn.Module) -> None: module.weight.data = maybe_trans_nz(module.weight.data) linear_count += 1 elif isinstance(module, (nn.Conv1d, nn.ConvTranspose1d)) and module.groups == 1: - module.weight.data = torch_npu.npu_format_cast(module.weight.data.contiguous(), _ACL_FORMAT_FRACTAL_Z) + # A5 (Ascend 950 PR) does not support the FRACTAL_Z conv + # weight layout, keep the contiguous ND layout there. + if not is_a5(): + module.weight.data = torch_npu.npu_format_cast( + module.weight.data.contiguous(), _ACL_FORMAT_FRACTAL_Z + ) conv_count += 1 logger.info("Prepared NPU Code2Wav weights: linear=%d conv=%d", linear_count, conv_count) @@ -67,6 +76,44 @@ def _patched_load_weights(self, weights): return loaded +def apply_qwen3_tts_patches() -> None: + """Install all Qwen3-TTS NPU patches. + + The code2wav runtime/weight patch applies on every NPU; the A5 + prompt-embeds builder swap is gated on ``is_a5()`` because it changes the + speaker-encoder mel front-end (``torch.stft`` is unsupported there). + """ + apply_qwen3_tts_code2wav_patch() + if not is_a5(): + return + _apply_a5_prompt_embeds_builder_patch() + + +def _apply_a5_prompt_embeds_builder_patch() -> None: + global _A5_PATCHED + if _A5_PATCHED: + return + + # Import and patch lazily so the Qwen3-TTS model modules are only loaded + # on A5 devices (same gate-then-import convention as the 310P patch). + from vllm_omni.model_executor.models.qwen3_tts import ( + prompt_embeds_builder, + qwen3_tts_talker, + ) + + class _Qwen3TTSPromptEmbedsBuilderA5(prompt_embeds_builder.Qwen3TTSPromptEmbedsBuilder): + """Qwen3-TTS prompt-embeds builder specialized for the A5 NPU path.""" + + _mel_spectrogram_on_cpu = True + + # The talker imports the builder by name at module scope, so its reference + # must be swapped as well (same as the 310P patch). + prompt_embeds_builder.Qwen3TTSPromptEmbedsBuilder = _Qwen3TTSPromptEmbedsBuilderA5 + qwen3_tts_talker.Qwen3TTSPromptEmbedsBuilder = _Qwen3TTSPromptEmbedsBuilderA5 + _A5_PATCHED = True + logger.debug("Applied A5 prompt-embeds builder patch for Qwen3-TTS") + + def apply_qwen3_tts_code2wav_patch() -> None: global _PATCHED, _original_init, _original_load_weights if _PATCHED: diff --git a/vllm_omni/platforms/npu/platform.py b/vllm_omni/platforms/npu/platform.py index cf65da414..2c8d42f60 100644 --- a/vllm_omni/platforms/npu/platform.py +++ b/vllm_omni/platforms/npu/platform.py @@ -62,8 +62,8 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform): from vllm_omni.platforms.npu.models.minicpmo_4_5_code2wav import ( apply_minicpmo_4_5_code2wav_patch, ) - from vllm_omni.platforms.npu.models.qwen3_tts_code2wav import ( - apply_qwen3_tts_code2wav_patch, + from vllm_omni.platforms.npu.models.qwen3_tts import ( + apply_qwen3_tts_patches, ) from vllm_omni.platforms.npu.models.qwen3_tts_tokenizer_v2 import ( apply_qwen3_tts_tokenizer_v2_patch, @@ -71,7 +71,7 @@ class NPUOmniPlatform(OmniPlatform, NPUPlatform): adapt_patch(is_global_patch=True) apply_minicpmo_4_5_code2wav_patch() - apply_qwen3_tts_code2wav_patch() + apply_qwen3_tts_patches() apply_qwen3_tts_tokenizer_v2_patch() apply_310p_patches() From 7c1b1e4189d0dbd26a6ecdb05dc20426a6078a24 Mon Sep 17 00:00:00 2001 From: Zheng Wengang Date: Wed, 9 Sep 2026 20:25:41 +0800 Subject: [PATCH 73/90] [Bugfix][Engine] Group overlapping device stages into one sequential init component (#7328) Signed-off-by: ZhengWG Co-authored-by: amy-why-3459 --- .../test_async_omni_engine_stage_init.py | 5 +- tests/engine/test_parallel_stage_init.py | 138 +++++++++++++----- vllm_omni/engine/stage_init_utils.py | 35 +++++ vllm_omni/engine/stage_runtime.py | 96 ++++++------ 4 files changed, 189 insertions(+), 85 deletions(-) diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index b1771da28..e6f7f8a8b 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -926,10 +926,7 @@ def test_remote_replicas_use_distinct_init_group_keys(): replica.launch_mode = "remote" replica.metadata.runtime_cfg = None - assert [runtime._replica_init_group_key(replica) for replica in plan.replicas] == [ - "remote:1:0", - "remote:1:1", - ] + assert runtime._init_group_keys(plan.replicas) == ["remote:1:0", "remote:1:1"] def test_initialize_stages_cleans_up_successful_replicas_after_partial_multi_replica_failure(monkeypatch): diff --git a/tests/engine/test_parallel_stage_init.py b/tests/engine/test_parallel_stage_init.py index 5952d710e..d88d49825 100644 --- a/tests/engine/test_parallel_stage_init.py +++ b/tests/engine/test_parallel_stage_init.py @@ -1,7 +1,10 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Unit tests for parallel stage initialization (CPU-only). Covers the pure/lock logic added for ``parallel_stage_init``: - * init-group device keying (resolved physical set; parallel unique keys), + * init-group device keying (overlap components; parallel unique keys), * admission control arithmetic + graph reserve, * SH/EX device phase locks (real flock on a temp dir), * the phase-locked executor wrapper ordering, @@ -18,6 +21,7 @@ import inspect import os import subprocess import threading +import time import types from pathlib import Path @@ -37,7 +41,9 @@ from vllm_omni.engine.stage_init_utils import ( LogicalStageInitPlan, ReplicaInitPlan, device_init_lock_path, + device_overlap_group_keys, open_device_lock_file, + parse_physical_device_ids, ) from vllm_omni.engine.stage_phase_lock import ( DeviceLockTimeoutError, @@ -46,6 +52,7 @@ from vllm_omni.engine.stage_phase_lock import ( wrap_executor_with_phase_locks, ) from vllm_omni.engine.stage_runtime import StageRuntime +from vllm_omni.platforms import current_omni_platform pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -104,49 +111,114 @@ def _fake_vllm_config(util: float, *, model="m", dtype="bf16", cudagraph_mode="F # --------------------------------------------------------------------------- # # A1: init-group device key # --------------------------------------------------------------------------- # -def test_init_group_key_resolves_physical_devices_serial(): +@pytest.mark.parametrize( + ("devices", "expected"), + [ + ("0", {0}), + ("1,0,1", {0, 1}), + (" 2, 3 ", {2, 3}), + (None, None), + ("", None), + ("GPU-uuid", None), + ], +) +def test_parse_physical_device_ids(devices, expected): + assert parse_physical_device_ids(devices) == expected + + +@pytest.mark.parametrize( + ("device_sets", "expected"), + [ + # equal / disjoint + ([{0}, {1}, {0}], ["device-group:0", "device-group:1", "device-group:0"]), + # unequal overlap, canonical order + ([{0, 1}, {0}, {2}], ["device-group:0,1", "device-group:0,1", "device-group:2"]), + # transitive: {0,1} ~ {1,2} ~ {2} + ([{0, 1}, {1, 2}, {2}], ["device-group:0,1,2"] * 3), + # unresolved may touch any GPU -> everything serial + ([{0}, None, {1}], ["device-group:*"] * 3), + ([], []), + ], +) +def test_device_overlap_group_keys(device_sets, expected): + sets = [None if s is None else frozenset(s) for s in device_sets] + assert device_overlap_group_keys(sets) == expected + + +def test_init_group_keys_serial_groups_by_device_overlap(): runtime = _runtime(parallel_stage_init=False) - runtime._init_visible_devices_baseline = None # no CUDA_VISIBLE_DEVICES mapping - r0 = _llm_replica(0, 0, "0") - r1 = _llm_replica(1, 0, "1") - r0b = _llm_replica(2, 0, "0") - k0 = runtime._replica_init_group_key(r0) - k1 = runtime._replica_init_group_key(r1) - k0b = runtime._replica_init_group_key(r0b) + keys = runtime._init_group_keys( + [_llm_replica(0, 0, "0,1"), _llm_replica(1, 0, "0"), _llm_replica(2, 0, "2"), _llm_replica(3, 0, "1,0")] + ) - assert k0 == "device:0" - assert k1 == "device:1" - # Different physical devices -> different groups (parallel). - assert k0 != k1 - # Same physical device -> same group (serialized under LOCK_EX). - assert k0 == k0b + assert keys == ["device-group:0,1", "device-group:0,1", "device-group:2", "device-group:0,1"] -def test_init_group_key_parallel_is_unique_per_replica(): - runtime = _runtime(parallel_stage_init=True) - runtime._init_visible_devices_baseline = None - # Both on the SAME device, but parallel mode must give distinct keys so they - # run concurrently, coordinated by the child SH/EX locks + admission. - keys = [ - runtime._replica_init_group_key(_llm_replica(0, 0, "0")), - runtime._replica_init_group_key(_llm_replica(0, 1, "0")), - runtime._replica_init_group_key(_llm_replica(1, 0, "0")), - ] - assert keys == ["parallel:0:0", "parallel:0:1", "parallel:1:0"] - assert len(set(keys)) == 3 - - -def test_init_group_key_remote_and_diffusion_unchanged(): +def test_init_group_keys_fixed_for_parallel_remote_and_diffusion(): runtime = _runtime(parallel_stage_init=True) remote = _llm_replica(1, 0, "0") remote.launch_mode = "remote" remote.metadata.runtime_cfg = None - assert runtime._replica_init_group_key(remote) == "remote:1:0" - diffusion = _llm_replica(2, 0, "0") diffusion.metadata.stage_type = "diffusion" - assert runtime._replica_init_group_key(diffusion) == "inline:diffusion" + + # Same device everywhere; parallel mode still keys per replica (child SH/EX locks coordinate). + keys = runtime._init_group_keys([_llm_replica(0, 0, "0"), _llm_replica(0, 1, "0"), remote, diffusion]) + + assert keys == ["parallel:0:0", "parallel:0:1", "remote:1:0", "inline:diffusion"] + + +def test_unresolved_devices_collapse_serial_groups_and_warn(caplog): + runtime = _runtime(parallel_stage_init=False) + with caplog.at_level("WARNING"): + keys = runtime._init_group_keys([_llm_replica(0, 0, "0"), _llm_replica(1, 0, "GPU-uuid")]) + + assert keys == ["device-group:*", "device-group:*"] + assert "Stage-1 replica 0" in caplog.text + assert "GPU-uuid" in caplog.text + + +def _stage_plans(*devices: str) -> list[LogicalStageInitPlan]: + return [ + LogicalStageInitPlan(stage_idx=i, stage_id=i, replicas=[_llm_replica(i, 0, dev)]) + for i, dev in enumerate(devices) + ] + + +def test_overlapping_device_stages_initialize_sequentially(monkeypatch): + monkeypatch.delenv(current_omni_platform.device_control_env_var, raising=False) + runtime = _runtime(parallel_stage_init=False) + in_flight, peak = 0, 0 + guard = threading.Lock() + + def _initialize_replica(plan, _timeout): + nonlocal in_flight, peak + with guard: + in_flight += 1 + peak = max(peak, in_flight) + time.sleep(0.05) + with guard: + in_flight -= 1 + return object() + + monkeypatch.setattr(runtime, "_initialize_replica", _initialize_replica) + runtime._initialize_stage_replicas(_stage_plans("0,1", "0"), stage_init_timeout=5) + + assert peak == 1 + + +def test_disjoint_device_stages_initialize_in_parallel(monkeypatch): + monkeypatch.delenv(current_omni_platform.device_control_env_var, raising=False) + runtime = _runtime(parallel_stage_init=False) + barrier = threading.Barrier(2, timeout=2) # breaks unless both inits are in flight together + + def _initialize_replica(_plan, _timeout): + barrier.wait() + return object() + + monkeypatch.setattr(runtime, "_initialize_replica", _initialize_replica) + runtime._initialize_stage_replicas(_stage_plans("0", "1"), stage_init_timeout=5) # --------------------------------------------------------------------------- # diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index 1e57036a0..0562d2b08 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -1668,6 +1668,41 @@ def record_lock_holder_pid(fd: int, writable: bool) -> None: pass +def parse_physical_device_ids(devices: str | None) -> frozenset[int] | None: + """Parse ``"0,1"`` into ``{0, 1}``; ``None`` if missing, empty or non-integer (UUID/MIG).""" + tokens = [tok.strip() for tok in str(devices or "").split(",") if tok.strip()] + if not tokens or not all(tok.isdigit() for tok in tokens): + return None + return frozenset(int(tok) for tok in tokens) + + +def device_overlap_group_keys(device_sets: Sequence[frozenset[int] | None]) -> list[str]: + """Key each device set by the connected component of sets it shares a GPU with. + + Transitively overlapping sets get one key, the sorted union of the component + (``{0,1}`` and ``{0}`` -> ``device-group:0,1``); disjoint sets get distinct + keys. ``None`` (unresolved: may touch any GPU) overlaps everything, so one + ``None`` collapses all sets into a single group. + """ + resolved = [devices for devices in device_sets if devices is not None] + if len(resolved) != len(device_sets): + return ["device-group:*"] * len(device_sets) + + components: list[set[int]] = [] + for devices in resolved: + merged = set(devices) + disjoint = [] + for comp in components: + if comp & merged: + merged |= comp + else: + disjoint.append(comp) + components = [*disjoint, merged] + + key_of = {device: "device-group:" + ",".join(map(str, sorted(comp))) for comp in components for device in comp} + return [key_of[next(iter(devices))] for devices in resolved] + + def acquire_device_locks( stage_id: int, engine_args_dict: dict[str, Any], diff --git a/vllm_omni/engine/stage_runtime.py b/vllm_omni/engine/stage_runtime.py index 93e482b42..5d7d07653 100644 --- a/vllm_omni/engine/stage_runtime.py +++ b/vllm_omni/engine/stage_runtime.py @@ -52,11 +52,13 @@ from vllm_omni.engine.stage_init_utils import ( build_llm_stage_output_processor, build_vllm_config, compute_replica_layout, + device_overlap_group_keys, extract_legacy_stage_metadata, get_stage_connector_spec, inject_kv_stage_info, inject_omni_kv_connector_config, load_omni_transfer_config_for_model, + parse_physical_device_ids, prepare_engine_environment, release_device_locks, stage_runtime_env, @@ -446,9 +448,10 @@ class StageRuntime: ) -> dict[int, list[StagePoolClient | None]]: """Initialize all stage replicas. - Stages sharing the same GPU are initialized sequentially to avoid - memory profiling interference. Stages on different GPUs are - initialized in parallel. + Stages that share any physical GPU — including overlapping-but-unequal + sets such as ``{0,1}`` and ``{0}`` — initialize sequentially to avoid + memory profiling interference and same-process ``flock`` self-contention. + Stages on disjoint GPUs initialize in parallel. """ initialized_clients_by_stage: dict[int, list[StagePoolClient | None]] = { plan.stage_idx: [None] * len(plan.replicas) for plan in stage_plans @@ -464,10 +467,7 @@ class StageRuntime: self._reject_unguardable_executors(stage_plans) self._run_stage_admission(stage_plans) - init_groups: dict[str, list[tuple[int, ReplicaInitPlan]]] = {} - for plan in stage_plans: - for replica in plan.replicas: - init_groups.setdefault(self._replica_init_group_key(replica), []).append((plan.stage_idx, replica)) + init_groups = self._build_init_groups(stage_plans) def _init_group(group: list[tuple[int, ReplicaInitPlan]]) -> None: """Initialize replicas in one scheduling group sequentially.""" @@ -589,19 +589,8 @@ class StageRuntime: # fails admission (fail-closed in check_admission). return ADMISSION_EXEMPT resolved = self._resolve_replica_physical_devices(replica.metadata.stage_id, replica.metadata.runtime_cfg) - if not resolved: - return None - ids: list[int] = [] - for tok in str(resolved).split(","): - tok = tok.strip() - if not tok: - continue - try: - ids.append(int(tok)) - except ValueError: - # Non-integer visibility (UUID / MIG) can't be reasoned about. - return None - return ids or None + parsed = parse_physical_device_ids(resolved) + return sorted(parsed) if parsed else None def _visible_ordinal(physical_id: int) -> int: """Translate a physical device id to this process's visible ordinal. @@ -656,47 +645,58 @@ class StageRuntime: device_total_memory=_total_memory, ) - def _replica_init_group_key(self, replica: ReplicaInitPlan) -> str: - """Return the scheduling group used during replica initialization. + def _build_init_groups( + self, + stage_plans: Sequence[LogicalStageInitPlan], + ) -> dict[str, list[tuple[int, ReplicaInitPlan]]]: + items = [(plan.stage_idx, replica) for plan in stage_plans for replica in plan.replicas] + groups: dict[str, list[tuple[int, ReplicaInitPlan]]] = {} + for key, item in zip(self._init_group_keys([replica for _, replica in items]), items, strict=True): + groups.setdefault(key, []).append(item) + return groups - Replicas sharing a group initialize sequentially; different groups - initialize in parallel threads. Local LLM replicas are keyed by their - **resolved canonical physical device set** so stages on different - physical GPUs land in different groups (parallel) while stages sharing a - device stay in one group (serialized, then further guarded by the - per-device ``LOCK_EX`` file lock). Keying on the raw ``runtime.devices`` - config value instead would fail to parallelize logically-distinct stages - that map to different physical GPUs. + def _init_group_keys(self, replicas: Sequence[ReplicaInitPlan]) -> list[str]: + """One init-group key per replica: same key -> sequential, distinct -> parallel. + + Diffusion, remote and parallel-stage-init replicas keep fixed per-replica + keys. Serial local LLM replicas are grouped by connected components of + their resolved physical-device overlap, so ``{0,1}`` and ``{0}`` share a + group and never re-``flock`` a device this process already holds. """ + keys = [self._init_group_key_override(replica) for replica in replicas] + pending = [i for i, key in enumerate(keys) if key is None] + device_sets: list[frozenset[int] | None] = [] + for i in pending: + replica = replicas[i] + physical = self._resolve_replica_physical_devices(replica.metadata.stage_id, replica.metadata.runtime_cfg) + parsed = parse_physical_device_ids(physical) + if parsed is None: + logger.warning( + "[stage_init] Stage-%s replica %s physical devices %r are not integer GPU ids; " + "all serial LLM replicas will share one init group", + replica.metadata.stage_id, + replica.replica_id, + physical, + ) + device_sets.append(parsed) + for i, key in zip(pending, device_overlap_group_keys(device_sets), strict=True): + keys[i] = key + return cast(list[str], keys) + + def _init_group_key_override(self, replica: ReplicaInitPlan) -> str | None: + """Key that bypasses device-overlap grouping, or ``None`` to use it.""" if replica.launch_mode == "local" and replica.metadata.stage_type == "diffusion": # Local diffusion process spawning must stay on the orchestrator # thread. Keep all local diffusion replicas in one sequential group. return "inline:diffusion" if replica.launch_mode == "remote": return f"remote:{replica.metadata.stage_id}:{replica.replica_id}" - - physical_devices = self._resolve_replica_physical_devices( - replica.metadata.stage_id, - replica.metadata.runtime_cfg, - ) if self._parallel_stage_init: # Same-device concurrency is coordinated by the engine-core SH/EX # device locks + pre-launch admission, so give every replica its own # group to let them all initialize in parallel. return f"parallel:{replica.metadata.stage_id}:{replica.replica_id}" - - runtime_cfg = replica.metadata.runtime_cfg or {} - raw_devices = ( - runtime_cfg.get("devices") if hasattr(runtime_cfg, "get") else getattr(runtime_cfg, "devices", None) - ) - if str(raw_devices) != str(physical_devices): - logger.debug( - "[stage_init] Stage-%s init-group key: raw devices=%s -> resolved physical=%s", - replica.metadata.stage_id, - raw_devices, - physical_devices, - ) - return f"device:{physical_devices}" + return None def _initialize_replica( self, From 2f1845fa4e74d78609a2c301d3b843628cbf6fb8 Mon Sep 17 00:00:00 2001 From: kunkun <72174834+kunkunblueberry@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:35:16 +0800 Subject: [PATCH 74/90] fix: reserve Qwen3-Omni NVFP4 backend fix (#7200) Signed-off-by: kunkunblueberry <1833921874@qq.com> --- .../test_qwen3_omni_modelopt_nvfp4_w4a4.py | 7 +++- tests/engine/test_stage_engine_args.py | 40 +++++++++++++++++++ vllm_omni/config/omni_config.py | 2 +- vllm_omni/engine/stage_init_utils.py | 18 ++++++--- 4 files changed, 59 insertions(+), 8 deletions(-) diff --git a/tests/e2e/offline_inference/test_qwen3_omni_modelopt_nvfp4_w4a4.py b/tests/e2e/offline_inference/test_qwen3_omni_modelopt_nvfp4_w4a4.py index 9502ed5e6..6537b1cac 100644 --- a/tests/e2e/offline_inference/test_qwen3_omni_modelopt_nvfp4_w4a4.py +++ b/tests/e2e/offline_inference/test_qwen3_omni_modelopt_nvfp4_w4a4.py @@ -65,7 +65,12 @@ def _stage_config(): # gains enough VRAM headroom for a no-eager run, add a companion test. return modify_stage_config( _CI_DEPLOY, - updates={"stages": {0: {"enforce_eager": True}, 1: {"enforce_eager": True}}}, + updates={ + "stages": { + 0: {"enforce_eager": True, "moe_backend": "auto"}, + 1: {"enforce_eager": True}, + } + }, ) diff --git a/tests/engine/test_stage_engine_args.py b/tests/engine/test_stage_engine_args.py index e2196dfd1..250aff884 100644 --- a/tests/engine/test_stage_engine_args.py +++ b/tests/engine/test_stage_engine_args.py @@ -11,6 +11,7 @@ from pathlib import Path import pytest from pydantic.fields import FieldInfo +from transformers import Qwen3OmniMoeConfig from vllm.config import CacheConfig as VllmCacheConfig from vllm.config import CompilationConfig as VllmCompilationConfig from vllm.config import LoadConfig as VllmLoadConfig @@ -19,6 +20,7 @@ from vllm.config import ProfilerConfig as VllmProfilerConfig from vllm.config import SchedulerConfig as VllmSchedulerConfig from vllm.engine.arg_utils import EngineArgs +from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config from vllm_omni.config.omni_config import ( _LLM_STAGE_ENGINE_FIELDS, OmniStageCacheConfig, @@ -155,6 +157,44 @@ def test_qwen3_omni_preserves_explicit_moe_backend(): assert engine_args["moe_backend"] == "flashinfer" +def test_qwen3_omni_preserves_explicit_auto_moe_backend(): + engine_args = { + "model_arch": "Qwen3OmniMoeForConditionalGeneration", + "moe_backend": "auto", + } + + stage_init_utils._maybe_set_qwen3_omni_moe_backend(engine_args) + + assert engine_args["moe_backend"] == "auto" + + +def test_qwen3_omni_nvfp4_ci_config_preserves_auto_moe_backend(tmp_path): + """The NVFP4 CI deploy opts stage 0 into vLLM's auto backend selection.""" + hf_config = Qwen3OmniMoeConfig(enable_audio_output=True) + pipeline = resolve_pipeline_config("qwen3_omni_moe", hf_config) + assert pipeline is not None + nvfp4_deploy = modify_stage_config( + get_deploy_config_path("ci/qwen3_omni_moe.yaml"), + updates={"stages": {0: {"moe_backend": "auto"}}}, + ) + deploy = load_deploy_config(nvfp4_deploy) + legacy_stages = merge_pipeline_deploy(pipeline, copy.deepcopy(deploy)) + omni_config = VllmOmniConfig.from_pipeline_config( + pipeline, + user_deploy_config=copy.deepcopy(deploy), + cli_overrides={"model": str(tmp_path)}, + ) + + for stage_id, expected_backend in ((0, "auto"), (1, "triton")): + legacy_stage = next(stage for stage in legacy_stages if stage.stage_id == stage_id) + typed_stage = omni_config.stage_by_id(stage_id) + for engine_args in ( + build_engine_args_dict(legacy_stage.to_omegaconf(), str(tmp_path)), + build_engine_args_dict_from_omni_stage_config(typed_stage, str(tmp_path)), + ): + assert engine_args["moe_backend"] == expected_backend + + def _engine_arg_inputs(tmp_path: Path) -> tuple[PipelineConfig, DeployConfig, str]: parent_model = tmp_path / "parent-model" stage_model = tmp_path / "stage-model" diff --git a/vllm_omni/config/omni_config.py b/vllm_omni/config/omni_config.py index aaa9c3512..81eb76f0b 100644 --- a/vllm_omni/config/omni_config.py +++ b/vllm_omni/config/omni_config.py @@ -400,7 +400,7 @@ def _get_deploy_config( @config -class OmniStageModelConfig: +class OmniStageModelConfig(_TrackExplicitConfigFields): """Per-stage model behavior and resolved model-engine inputs.""" model: str | None = None diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index 0562d2b08..765549bcb 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -764,12 +764,15 @@ def prepare_engine_environment() -> None: pass -def _maybe_set_qwen3_omni_moe_backend(engine_args_dict: dict[str, Any]) -> None: +def _maybe_set_qwen3_omni_moe_backend( + engine_args_dict: dict[str, Any], + *, + moe_backend_is_explicit: bool | None = None, +) -> None: """Choose the stable MoE backend when Qwen3-Omni has no explicit choice.""" - if ( - engine_args_dict.get("model_arch") == "Qwen3OmniMoeForConditionalGeneration" - and engine_args_dict.get("moe_backend", "auto") == "auto" - ): + if moe_backend_is_explicit is None: + moe_backend_is_explicit = "moe_backend" in engine_args_dict + if engine_args_dict.get("model_arch") == "Qwen3OmniMoeForConditionalGeneration" and not moe_backend_is_explicit: engine_args_dict["moe_backend"] = "triton" logger.info("[stage_init] Set moe_backend=triton for Qwen3-Omni stage") @@ -1121,6 +1124,10 @@ def _project_omni_stage_engine_args( # The legacy builder always emits this key, including for pipelines such # as Audex that intentionally defer architecture discovery to HF config. engine_args["model_arch"] = copy.deepcopy(stage_config.model_config.model_arch) + _maybe_set_qwen3_omni_moe_backend( + engine_args, + moe_backend_is_explicit="moe_backend" in getattr(stage_config.model_config, "_omni_explicit_fields", ()), + ) topology = stage_config.stage_pipeline_config topology_engine_args = { @@ -1283,7 +1290,6 @@ def _finalize_engine_args_dict( engine_args_dict["has_sampling_extra_args"] = has_sampling_extra_args engine_args_dict["sampling_extra_args_keys"] = sampling_extra_args_keys - # Select the typed backend option during stage argument finalization. _maybe_set_qwen3_omni_moe_backend(engine_args_dict) return engine_args_dict From 0e602509aa9460cedca2de45094e6ea1d2be47ba Mon Sep 17 00:00:00 2001 From: Shaun Walsh <153730091+Shaun-Walsh@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:38:42 +0100 Subject: [PATCH 75/90] [BugFix] Add field validators for /v1/audio/generate request (#4741) Signed-off-by: Shaun Walsh Co-authored-by: Claude Opus 4.6 Co-authored-by: Nick Cao --- .../test_invalid_audio_diffusion.py | 33 +++++++++---------- .../entrypoints/openai/protocol/audio.py | 13 +++++++- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_diffusion.py b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_diffusion.py index f14dcf8c1..720d86151 100644 --- a/tests/dfx/reliability/invalid_param_test/test_invalid_audio_diffusion.py +++ b/tests/dfx/reliability/invalid_param_test/test_invalid_audio_diffusion.py @@ -13,9 +13,6 @@ from tests.helpers.runtime import OmniServer, OmniServerParams, OnlineOmniClient pytestmark = [pytest.mark.slow, pytest.mark.diffusion] -# https://github.com/vllm-project/vllm-omni/issues/3649 — invalid inputs still HTTP 200 / timeout / missing validation. -_SKIP_ISSUE_3649 = pytest.mark.skip(reason="https://github.com/vllm-project/vllm-omni/issues/3649") - _PARAMS = [ pytest.param( OmniServerParams( @@ -36,10 +33,8 @@ _PARAMS = [ @pytest.mark.parametrize( "overrides, err_message", [ - pytest.param({"input": ""}, ("cannot be empty", "input"), id="input_empty", marks=_SKIP_ISSUE_3649), - pytest.param( - {"input": " "}, ("cannot be empty", "input"), id="input_whitespace_only", marks=_SKIP_ISSUE_3649 - ), + pytest.param({"input": ""}, ("cannot be empty", "input"), id="input_empty"), + pytest.param({"input": " "}, ("cannot be empty", "input"), id="input_whitespace_only"), pytest.param({"input": 123}, ("input", "a valid string"), id="input_wrong_type"), pytest.param( {"response_format": "mpeg"}, @@ -54,10 +49,8 @@ _PARAMS = [ ("sse", "stream_format", "value_error", "not a supported stream_format"), id="stream_format_sse_blocked", ), - pytest.param( - {"audio_length": -1.0}, ("audio_end_in_s", "higher than", "audio_start_in_s"), id="audio_length_negative" - ), - pytest.param({"audio_length": 0}, "audio_length", id="audio_length_zero", marks=_SKIP_ISSUE_3649), + pytest.param({"audio_length": -1.0}, ("audio_length", "greater_than", "0"), id="audio_length_negative"), + pytest.param({"audio_length": 0}, ("audio_length", "greater_than", "0"), id="audio_length_zero"), # ``OmniOpenAIServingAudioGenerate`` only forwards ``audio_start`` when ``audio_length`` is set. pytest.param( {"audio_start": -0.5, "audio_length": 5.0}, @@ -67,26 +60,30 @@ _PARAMS = [ pytest.param( {"negative_prompt": ["noise"]}, ("negative_prompt", "a valid string"), id="negative_prompt_wrong_type" ), - pytest.param({"guidance_scale": -1.0}, "guidance_scale", id="guidance_scale_negative", marks=_SKIP_ISSUE_3649), - pytest.param({"guidance_scale": 0}, "guidance_scale", id="guidance_scale_zero", marks=_SKIP_ISSUE_3649), + pytest.param( + {"guidance_scale": -1.0}, + ("guidance_scale", "greater_than_equal", "0"), + id="guidance_scale_negative", + ), pytest.param( {"num_inference_steps": 0}, - ("num_inference_steps", "greater than or equal to 1"), + ("num_inference_steps", "greater_than_equal", "1"), id="num_inference_steps_zero", ), pytest.param( {"num_inference_steps": -1}, - ("num_inference_steps", "greater than or equal to 1"), + ("num_inference_steps", "greater_than_equal", "1"), id="num_inference_steps_negative", ), pytest.param( {"num_inference_steps": 6000}, - "num_inference_steps", + ("num_inference_steps", "less_than_equal", "1000"), id="num_inference_steps_above_max", - marks=_SKIP_ISSUE_3649, ), pytest.param( - {"guidance_scale": 1001.0}, "guidance_scale", id="guidance_scale_above_max", marks=_SKIP_ISSUE_3649 + {"guidance_scale": 1001.0}, + ("guidance_scale", "less_than_equal", "1000"), + id="guidance_scale_above_max", ), pytest.param( {"audio_length": 86401.0}, ("Requested audio length", "exceeds maximum"), id="audio_length_above_max" diff --git a/vllm_omni/entrypoints/openai/protocol/audio.py b/vllm_omni/entrypoints/openai/protocol/audio.py index 2446114df..459f9d4ae 100644 --- a/vllm_omni/entrypoints/openai/protocol/audio.py +++ b/vllm_omni/entrypoints/openai/protocol/audio.py @@ -353,6 +353,14 @@ class OpenAICreateAudioGenerateRequest(BaseModel): input: str = Field( description="Text prompt describing the audio to generate", ) + + @field_validator("input") + @classmethod + def validate_input(cls, v): + if not v or not v.strip(): + raise ValueError("input cannot be empty") + return v + model: str | None = None response_format: Literal["wav", "pcm", "flac", "mp3", "opus"] = DEFAULT_AUDIO_FORMAT speed: float | None = Field( @@ -363,6 +371,7 @@ class OpenAICreateAudioGenerateRequest(BaseModel): stream_format: Literal["sse", "audio"] | None = "audio" audio_length: float | None = Field( default=None, + gt=0, description="Audio length in seconds", ) audio_start: float | None = Field( @@ -375,12 +384,14 @@ class OpenAICreateAudioGenerateRequest(BaseModel): ) guidance_scale: float | None = Field( default=None, + ge=0, + le=1000, description="Guidance scale for diffusion models", ) num_inference_steps: int | None = Field( default=None, ge=1, - le=_INT64_MAX, + le=1000, description="Number of inference steps", ) seed: int | None = Field( From c94cf4ddde3cddf28fb02fbe27029824ae5ad6c8 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:47:15 -0700 Subject: [PATCH 76/90] [CI][ROCm] Match CUDA/NPU L2/L3 label routing (#6966) Signed-off-by: andyluo7 Co-authored-by: Hongsheng Liu --- .buildkite/amd/scripts/bootstrap-amd-omni.sh | 166 +++++++++---------- .buildkite/amd/scripts/select_test_suites.py | 93 +++++++++++ docs/contributing/ci/ci_settings.md | 4 +- tests/buildkite/test_amd_bootstrap.py | 156 +++++++++++++++++ tests/buildkite/test_amd_suite_selection.py | 61 +++++++ 5 files changed, 391 insertions(+), 89 deletions(-) create mode 100644 .buildkite/amd/scripts/select_test_suites.py create mode 100644 tests/buildkite/test_amd_bootstrap.py create mode 100644 tests/buildkite/test_amd_suite_selection.py diff --git a/.buildkite/amd/scripts/bootstrap-amd-omni.sh b/.buildkite/amd/scripts/bootstrap-amd-omni.sh index 4eb7f6eb4..9eff6b6c7 100755 --- a/.buildkite/amd/scripts/bootstrap-amd-omni.sh +++ b/.buildkite/amd/scripts/bootstrap-amd-omni.sh @@ -14,6 +14,8 @@ set -euo pipefail # shellcheck disable=SC1091 source .buildkite/common/scripts/resolve_skip_ci.sh +AMD_SUITE_SELECTOR=".buildkite/amd/scripts/select_test_suites.py" + if [[ -z "${RUN_ALL:-}" ]]; then RUN_ALL=0 fi @@ -30,31 +32,20 @@ if [[ -z "${AMD_MIRROR_HW:-}" ]]; then AMD_MIRROR_HW="amdproduction" fi -fail_fast() { - DISABLE_LABEL="ci-no-fail-fast" - # If BUILDKITE_PULL_REQUEST != "false", then we check the PR labels using curl and jq - if [ "$BUILDKITE_PULL_REQUEST" != "false" ]; then - PR_LABELS=$(curl -s "https://api.github.com/repos/vllm-project/vllm-omni/pulls/$BUILDKITE_PULL_REQUEST" | jq -r '.labels[].name') - if [[ $PR_LABELS == *"$DISABLE_LABEL"* ]]; then - echo false - else - echo true - fi - else - echo false # not a PR or BUILDKITE_PULL_REQUEST not set +PR_LABELS="" +# Explicit debug suites do not depend on PR labels. +if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" && -z "${DEBUG_TEST_YAML:-}" ]]; then + if ! PR_LABELS=$(curl -fsSL \ + "https://api.github.com/repos/vllm-project/vllm-omni/pulls/$BUILDKITE_PULL_REQUEST" \ + | jq -r '.labels[].name'); then + echo "ERROR: Could not read PR labels; refusing to select an incorrect AMD test tier." >&2 + exit 1 fi -} +fi -check_run_all_label() { - RUN_ALL_LABEL="ready-run-all-tests" - # If BUILDKITE_PULL_REQUEST != "false", then we check the PR labels using curl and jq - if [ "$BUILDKITE_PULL_REQUEST" != "false" ]; then - PR_LABELS=$(curl -s "https://api.github.com/repos/vllm-project/vllm-omni/pulls/$BUILDKITE_PULL_REQUEST" | jq -r '.labels[].name') - if [[ $PR_LABELS == *"$RUN_ALL_LABEL"* ]]; then - echo true - else - echo false - fi +fail_fast() { + if [[ "${BUILDKITE_PULL_REQUEST:-false}" != "false" ]]; then + echo true else echo false # not a PR or BUILDKITE_PULL_REQUEST not set fi @@ -64,6 +55,57 @@ if [[ -z "${COV_ENABLED:-}" ]]; then COV_ENABLED=0 fi +resolve_test_specs() { + local selected_specs + if ! selected_specs=$(python3 "$AMD_SUITE_SELECTOR" \ + --branch "$BUILDKITE_BRANCH" \ + --labels "$PR_LABELS" \ + --debug-test-yaml "${DEBUG_TEST_YAML:-}"); then + exit 1 + fi + + TEST_SPECS=() + while IFS= read -r suite_spec; do + [[ -z "$suite_spec" ]] && continue + TEST_SPECS+=("$suite_spec") + done <<< "$selected_specs" +} + +filter_test_specs_by_skip_ci() { + local suite_spec level decision + local skip_all=0 + local -a runnable_specs=() + + for suite_spec in "${TEST_SPECS[@]}"; do + case "$suite_spec" in + READY_TESTS:*) level="l2" ;; + MERGE_TESTS:*) level="l3" ;; + *) + echo "ERROR: unknown AMD test suite spec '$suite_spec'" >&2 + exit 1 + ;; + esac + + if decision=$(python3 "$SKIP_CI_PY" gate amd "$level"); then + echo "Skipping AMD $level suite due to $decision decision." + [[ "$decision" == "skip-all" ]] && skip_all=1 + else + runnable_specs+=("$suite_spec") + fi + done + + TEST_SPECS=("${runnable_specs[@]}") + if [[ ${#TEST_SPECS[@]} -eq 0 ]]; then + if [[ $skip_all -eq 1 ]]; then + buildkite-agent annotate \ + ":memo: CI skipped — docs or pytest skip-mark changes only" \ + --style "info" 2>/dev/null || true + fi + echo "No AMD L2/L3 suites remain after skip-ci filtering." + exit 0 + fi +} + upload_pipeline() { echo "Uploading pipeline..." # Install minijinja @@ -84,7 +126,7 @@ upload_pipeline() { # (WIP) Use pipeline generator instead of jinja template if [ -e ".buildkite/amd/pipeline_generator/pipeline_generator.py" ]; then python -m pip install click pydantic - python .buildkite/amd/pipeline_generator/pipeline_generator.py --run_all=$RUN_ALL --list_file_diff="$LIST_FILE_DIFF" --nightly="$NIGHTLY" --mirror_hw="$AMD_MIRROR_HW" + python .buildkite/amd/pipeline_generator/pipeline_generator.py --run_all="$RUN_ALL" --list_file_diff="$LIST_FILE_DIFF" --nightly="$NIGHTLY" --mirror_hw="$AMD_MIRROR_HW" buildkite-agent pipeline upload .buildkite/amd/pipeline.yaml exit 0 fi @@ -97,49 +139,12 @@ upload_pipeline() { cd .buildkite/amd - # Select test definition file: merge suite for main, ready suite for PRs. - # For debugging, DEBUG_TEST_YAML accepts a comma-separated list containing - # "merge" and/or "ready" (case-insensitive). Multiple suites are combined - # before rendering so they share one amd-build step. - if [[ -n "${DEBUG_TEST_YAML:-}" ]]; then - declare -a DEBUG_TEST_SPECS=() - declare -A SEEN_DEBUG_TESTS=() - IFS=',' read -ra REQUESTED_DEBUG_TESTS <<< "${DEBUG_TEST_YAML,,}" - - for requested_test in "${REQUESTED_DEBUG_TESTS[@]}"; do - # Permit readable values such as "ready, merge," and ignore the - # empty item produced by a trailing comma. - requested_test="${requested_test//[[:space:]]/}" - [[ -z "$requested_test" ]] && continue - - if [[ -n "${SEEN_DEBUG_TESTS[$requested_test]:-}" ]]; then - echo "ERROR: duplicate DEBUG_TEST_YAML suite '$requested_test'" >&2 - exit 1 - fi - SEEN_DEBUG_TESTS[$requested_test]=1 - - case "$requested_test" in - ready) - DEBUG_TEST_SPECS+=("READY_TESTS:test-amd-ready.yml") - ;; - merge) - DEBUG_TEST_SPECS+=("MERGE_TESTS:test-amd-merge.yml") - ;; - *) - echo "ERROR: DEBUG_TEST_YAML entries must be 'merge' or 'ready', got '$requested_test'" >&2 - exit 1 - ;; - esac - done - - if [[ ${#DEBUG_TEST_SPECS[@]} -eq 0 ]]; then - echo "ERROR: DEBUG_TEST_YAML did not contain a test suite" >&2 - exit 1 - elif [[ ${#DEBUG_TEST_SPECS[@]} -eq 1 ]]; then - TEST_YAML="${DEBUG_TEST_SPECS[0]#*:}" - else - TEST_YAML=$(mktemp "${TMPDIR:-/tmp}/amd-debug-tests.XXXXXX.yml") - python - "$TEST_YAML" "${DEBUG_TEST_SPECS[@]}" <<'PY' + # Multiple label-selected or debug-selected suites share one image build. + if [[ ${#TEST_SPECS[@]} -eq 1 ]]; then + TEST_YAML="${TEST_SPECS[0]#*:}" + else + TEST_YAML=$(mktemp "${TMPDIR:-/tmp}/amd-selected-tests.XXXXXX.yml") + python - "$TEST_YAML" "${TEST_SPECS[@]}" <<'PY' import sys import yaml @@ -172,13 +177,8 @@ for suite_spec in suite_specs: with open(output_path, "w", encoding="utf-8") as output_file: yaml.safe_dump(combined, output_file, sort_keys=False) PY - fi - echo "DEBUG_TEST_YAML override: using ${DEBUG_TEST_SPECS[*]}" - elif [[ $BUILDKITE_BRANCH == "main" ]]; then - TEST_YAML="test-amd-merge.yml" - else - TEST_YAML="test-amd-ready.yml" fi + echo "AMD test suites: ${TEST_SPECS[*]}" ( set -x @@ -198,7 +198,7 @@ PY > pipeline.yaml ) cat pipeline.yaml - if [[ "$TEST_YAML" == "${TMPDIR:-/tmp}/amd-debug-tests."*.yml ]]; then + if [[ "$TEST_YAML" == "${TMPDIR:-/tmp}/amd-selected-tests."*.yml ]]; then rm -f -- "$TEST_YAML" fi buildkite-agent artifact upload pipeline.yaml @@ -219,12 +219,10 @@ if [[ $BUILDKITE_BRANCH == "main" ]]; then file_diff=$(get_diff_main) fi -# Early exit: unified skip-ci (docs / skip marks) and CI-yaml-only level targeting. -if [[ $BUILDKITE_BRANCH == "main" ]]; then - gate_bootstrap_ci amd l3 -else - gate_bootstrap_ci amd l2 -fi +# Resolve PR tier labels before skip-ci so ready and merge suites can be +# filtered independently for CI-YAML-only changes. +resolve_test_specs +filter_test_specs_by_skip_ci patterns=( "docker/Dockerfile" @@ -274,14 +272,6 @@ for file in $file_diff; do fi done -# Check for ready-run-all-tests label -LABEL_RUN_ALL=$(check_run_all_label) -if [[ $LABEL_RUN_ALL == true ]]; then - RUN_ALL=1 - NIGHTLY=1 - echo "Found 'ready-run-all-tests' label. Running all tests including optional tests." -fi - # Decide whether to use precompiled wheels # Relies on existing patterns array as a basis. if [[ -n "${VLLM_USE_PRECOMPILED:-}" ]]; then diff --git a/.buildkite/amd/scripts/select_test_suites.py b/.buildkite/amd/scripts/select_test_suites.py new file mode 100644 index 000000000..b7c72f6e4 --- /dev/null +++ b/.buildkite/amd/scripts/select_test_suites.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Select AMD L2/L3 suites after Buildkite has created an AMD build. + +This repository-side selector does not decide which GitHub label events start +the external ``vllm-omni-amd-ci`` pipeline. Its Buildkite PR build condition +must admit the relevant label event before this code can inspect the labels. +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Iterable + +SUITE_SPECS = { + "ready": "READY_TESTS:test-amd-ready.yml", + "merge": "MERGE_TESTS:test-amd-merge.yml", +} + + +def _parse_debug_suites(value: str) -> tuple[str, ...]: + suites = tuple(item.strip().lower() for item in value.split(",") if item.strip()) + if not suites: + raise ValueError("DEBUG_TEST_YAML did not contain a test suite") + + seen: set[str] = set() + for suite in suites: + if suite not in SUITE_SPECS: + raise ValueError(f"DEBUG_TEST_YAML entries must be 'merge' or 'ready', got {suite!r}") + if suite in seen: + raise ValueError(f"duplicate DEBUG_TEST_YAML suite {suite!r}") + seen.add(suite) + return suites + + +def select_amd_test_suites( + *, + branch: str, + labels: Iterable[str], + debug_test_yaml: str = "", +) -> tuple[str, ...]: + """Return ordered suite names while preserving legacy unlabeled PRs.""" + + if debug_test_yaml: + return _parse_debug_suites(debug_test_yaml) + if branch == "main": + return ("merge",) + + label_set = {label.strip() for label in labels if label.strip()} + selected = tuple(suite for label, suite in (("ready", "ready"), ("merge-test", "merge")) if label in label_set) + # AMD historically uploaded L2 for every PR build. Keep that behavior for + # triggers such as amd-test while adding tier selection for ready/merge-test. + return selected or ("ready",) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--branch", required=True) + parser.add_argument("--labels", default="") + parser.add_argument("--debug-test-yaml", default="") + args = parser.parse_args() + + labels = tuple(args.labels.splitlines()) + try: + suites = select_amd_test_suites( + branch=args.branch, + labels=labels, + debug_test_yaml=args.debug_test_yaml, + ) + except ValueError as exc: + parser.error(str(exc)) + + label_set = {label.strip() for label in labels if label.strip()} + if not args.debug_test_yaml and args.branch != "main" and not ({"ready", "merge-test"} & label_set): + print( + "No AMD L2/L3 tier label found; preserving the legacy ready-suite fallback.", + file=sys.stderr, + ) + if "nightly-test" in label_set: + print( + "AMD nightly-test has no suite yet and does not affect L2/L3 selection.", + file=sys.stderr, + ) + + for suite in suites: + print(SUITE_SPECS[suite]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/contributing/ci/ci_settings.md b/docs/contributing/ci/ci_settings.md index 7348ad183..d578b4b02 100644 --- a/docs/contributing/ci/ci_settings.md +++ b/docs/contributing/ci/ci_settings.md @@ -161,7 +161,9 @@ Canonical layout (prefer these paths for new changes): **Bootstrap:** [`amd/scripts/bootstrap-amd-omni.sh`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/scripts/bootstrap-amd-omni.sh)—skip-ci, diff filtering, Jinja render, then `buildkite-agent pipeline upload`. - **Test YAML (data):** `amd/test-amd-ready.yml` (L2 / PR), `test-amd-merge.yml` (L3 / main). + **Test YAML (data):** `amd/test-amd-ready.yml` (L2 / `ready`), `test-amd-merge.yml` (L3 / `merge-test` and `main`). On a PR carrying both labels, AMD combines both suites behind one image build. PR builds without either tier label retain the legacy L2 fallback for compatibility with the `amd-test` trigger. `DEBUG_TEST_YAML` remains an explicit override. AMD has no L4 file yet, so `nightly-test` does not select an additional suite. + + **Trigger boundary:** AMD label handling has two layers. First, the external `vllm-omni-amd-ci` Buildkite pipeline condition decides whether a GitHub label event creates a build. Only after that build starts does this repository's bootstrap inspect all current PR labels and select L2, L3, or both. Repository-side selection therefore cannot make a `merge-test` event start AMD CI by itself. The external condition must admit both `ready` and `merge-test`, while preserving the legacy `amd-test` trigger and non-PR `main` / scheduled builds. For PR builds without a `DEBUG_TEST_YAML` override, an unreadable label set fails the bootstrap instead of silently selecting the wrong tier. Debug overrides skip label lookup and are validated directly; invalid values, including whitespace-only values, fail the bootstrap. Keep `nightly-test` out of the AMD condition until an AMD L4 suite exists; otherwise it would start a build without providing nightly coverage. **Rendering:** [`test-template-amd-omni.j2`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/amd/test-template-amd-omni.j2) wraps data steps with `amd-build` image build and `amd_` queues. Do **not** hand-edit generated `pipeline.yaml`. diff --git a/tests/buildkite/test_amd_bootstrap.py b/tests/buildkite/test_amd_bootstrap.py new file mode 100644 index 000000000..593ba8b26 --- /dev/null +++ b/tests/buildkite/test_amd_bootstrap.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +REPO_ROOT = Path(__file__).resolve().parents[2] +BOOTSTRAP_PATH = REPO_ROOT / ".buildkite/amd/scripts/bootstrap-amd-omni.sh" + + +def _run_bootstrap( + tmp_path: Path, + *, + debug_test_yaml: str | None = None, + curl_status: int = 0, + jq_status: int = 0, + labels: tuple[str, ...] = (), + pull_request: bool = True, +) -> subprocess.CompletedProcess[str]: + for relative_path in ( + ".buildkite/common/scripts/resolve_skip_ci.sh", + ".buildkite/amd/scripts/select_test_suites.py", + ): + destination = tmp_path / relative_path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO_ROOT / relative_path, destination) + + # Stop after real suite selection, before rendering or uploading a pipeline. + (tmp_path / ".buildkite/common/scripts/skip_ci.py").write_text("print('test-skip')\n", encoding="utf-8") + env = { + "PATH": os.environ["PATH"], + "BUILDKITE_PULL_REQUEST": "123" if pull_request else "false", + "BUILDKITE_BRANCH": "feature" if pull_request else "main", + "TEST_PYTHON": sys.executable, + "TEST_CURL_STATUS": str(curl_status), + "TEST_JQ_STATUS": str(jq_status), + "TEST_PR_LABELS": "\n".join(labels), + } + if debug_test_yaml is not None: + env["DEBUG_TEST_YAML"] = debug_test_yaml + + # Source the unmodified bootstrap with subprocess-local command doubles. + script = r""" +curl() { + echo 'curl called' >&2 + return "$TEST_CURL_STATUS" +} +jq() { + echo 'jq called' >&2 + printf '%s\n' "$TEST_PR_LABELS" + return "$TEST_JQ_STATUS" +} +git() { :; } +python3() { "$TEST_PYTHON" "$@"; } +source "$1" +""" + return subprocess.run( + ["bash", "-c", script, "test-amd-bootstrap", str(BOOTSTRAP_PATH)], + cwd=tmp_path, + env=env, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + +@pytest.mark.parametrize(("curl_status", "jq_status"), [(22, 0), (0, 4)], ids=["request-failure", "parse-failure"]) +@pytest.mark.parametrize( + ("debug_test_yaml", "expected_levels"), + [ + ("ready", ["l2"]), + ("merge", ["l3"]), + ("ready,merge", ["l2", "l3"]), + (" MERGE, ready,", ["l3", "l2"]), + ], +) +def test_debug_override_skips_label_lookup(tmp_path, debug_test_yaml, expected_levels, curl_status, jq_status): + result = _run_bootstrap( + tmp_path, + debug_test_yaml=debug_test_yaml, + curl_status=curl_status, + jq_status=jq_status, + ) + + assert result.returncode == 0, result.stderr + assert "curl called" not in result.stderr + assert "jq called" not in result.stderr + assert "legacy ready-suite fallback" not in result.stderr + assert [ + line.split()[2] for line in result.stdout.splitlines() if line.startswith("Skipping AMD ") + ] == expected_levels + + +@pytest.mark.parametrize(("curl_status", "jq_status"), [(22, 0), (0, 4)], ids=["request-failure", "parse-failure"]) +@pytest.mark.parametrize("debug_test_yaml", [None, ""], ids=["unset", "empty"]) +def test_label_lookup_failure_without_override(tmp_path, debug_test_yaml, curl_status, jq_status): + result = _run_bootstrap( + tmp_path, + debug_test_yaml=debug_test_yaml, + curl_status=curl_status, + jq_status=jq_status, + ) + + assert result.returncode == 1 + assert "Could not read PR labels" in result.stderr + assert "Skipping AMD " not in result.stdout + + +@pytest.mark.parametrize( + ("labels", "expected_levels"), + [ + (("ready",), ["l2"]), + (("merge-test",), ["l3"]), + (("ready", "merge-test"), ["l2", "l3"]), + ((), ["l2"]), + ], +) +def test_label_lookup_selects_tiers(tmp_path, labels, expected_levels): + result = _run_bootstrap(tmp_path, labels=labels) + + assert result.returncode == 0, result.stderr + assert "curl called" in result.stderr + assert "jq called" in result.stderr + assert [ + line.split()[2] for line in result.stdout.splitlines() if line.startswith("Skipping AMD ") + ] == expected_levels + + +@pytest.mark.parametrize("debug_test_yaml", ["ready,ready", "nightly", ", ,", " \t"]) +def test_invalid_debug_override_fails_without_label_lookup(tmp_path, debug_test_yaml): + result = _run_bootstrap(tmp_path, debug_test_yaml=debug_test_yaml, curl_status=22) + + assert result.returncode == 1 + assert "DEBUG_TEST_YAML" in result.stderr + assert "curl called" not in result.stderr + assert "jq called" not in result.stderr + assert "Skipping AMD " not in result.stdout + + +def test_main_does_not_lookup_labels(tmp_path): + result = _run_bootstrap(tmp_path, pull_request=False, curl_status=22, jq_status=4) + + assert result.returncode == 0, result.stderr + assert "curl called" not in result.stderr + assert "jq called" not in result.stderr + assert "Skipping AMD l3 suite" in result.stdout diff --git a/tests/buildkite/test_amd_suite_selection.py b/tests/buildkite/test_amd_suite_selection.py new file mode 100644 index 000000000..b1b25aa26 --- /dev/null +++ b/tests/buildkite/test_amd_suite_selection.py @@ -0,0 +1,61 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +SELECTOR_PATH = Path(".buildkite/amd/scripts/select_test_suites.py") +SPEC = importlib.util.spec_from_file_location("select_test_suites", SELECTOR_PATH) +assert SPEC is not None and SPEC.loader is not None +SELECTOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(SELECTOR) + + +@pytest.mark.parametrize( + ("branch", "labels", "expected"), + [ + ("main", (), ("merge",)), + ("feature", ("ready",), ("ready",)), + ("feature", ("merge-test",), ("merge",)), + ("feature", ("merge-test", "ready"), ("ready", "merge")), + ("feature", ("amd-test",), ("ready",)), + ("feature", ("amd-test", "merge-test"), ("merge",)), + ("feature", ("amd-test", "ready", "merge-test"), ("ready", "merge")), + ("feature", ("nightly-test",), ("ready",)), + ("feature", ("not-ready", "merge-test-extra"), ("ready",)), + ], +) +def test_label_suite_selection(branch, labels, expected): + assert SELECTOR.select_amd_test_suites(branch=branch, labels=labels) == expected + + +def test_debug_override_takes_precedence_and_normalizes_input(): + assert SELECTOR.select_amd_test_suites( + branch="main", + labels=("ready",), + debug_test_yaml=" MERGE, ready,", + ) == ("merge", "ready") + + +def test_empty_debug_override_uses_normal_selection(): + assert SELECTOR.select_amd_test_suites( + branch="feature", + labels=("merge-test",), + debug_test_yaml="", + ) == ("merge",) + + +@pytest.mark.parametrize("value", ["ready,ready", "nightly", ", ,", " \t"]) +def test_invalid_debug_override(value): + with pytest.raises(ValueError): + SELECTOR.select_amd_test_suites( + branch="feature", + labels=(), + debug_test_yaml=value, + ) From 06f67695f1819f795956b8d51f5c982aa082937d Mon Sep 17 00:00:00 2001 From: shiyichuan <93317314+CarrotSwordsman@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:05:44 +0800 Subject: [PATCH 77/90] [CI/Build] Avoid duplicate stage CLI deploy config (#7007) Signed-off-by: mershi Co-authored-by: mershi --- tests/helpers/runtime.py | 1 - tests/helpers/tests/test_runtime_stage_cli.py | 38 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/tests/helpers/runtime.py b/tests/helpers/runtime.py index db4cba893..66e0cefab 100644 --- a/tests/helpers/runtime.py +++ b/tests/helpers/runtime.py @@ -968,7 +968,6 @@ def iter_omni_server( raise ValueError("omni_server with use_stage_cli=True requires use_omni=True") if stage_config_path is None: raise ValueError("omni_server with use_stage_cli=True requires a stage_config_path") - server_args += ["--deploy-config", stage_config_path] with OmniServerStageCli( model, diff --git a/tests/helpers/tests/test_runtime_stage_cli.py b/tests/helpers/tests/test_runtime_stage_cli.py index 564f12cf1..712a87e09 100644 --- a/tests/helpers/tests/test_runtime_stage_cli.py +++ b/tests/helpers/tests/test_runtime_stage_cli.py @@ -5,12 +5,15 @@ from __future__ import annotations +import threading +from contextlib import nullcontext from pathlib import Path from types import SimpleNamespace import pytest -from tests.helpers.runtime import OmniServerStageCli +from tests.helpers import runtime +from tests.helpers.runtime import OmniServerParams, OmniServerStageCli pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -49,6 +52,39 @@ def test_stage_cli_loads_stage_ids_and_replica_counts(tmp_path: Path) -> None: assert server.stage_replica_counts == {0: 1, 1: 3} +def test_stage_cli_fixture_leaves_deploy_config_to_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + stage_config_path = _write_stage_config(tmp_path) + captured_args: list[str] = [] + + def fake_stage_cli(model, config_path, serve_args, **kwargs): + del kwargs + assert config_path == stage_config_path + captured_args.extend(serve_args) + return nullcontext(SimpleNamespace(model=model)) + + monkeypatch.setattr(runtime, "OmniServerStageCli", fake_stage_cli) + monkeypatch.setattr(runtime, "_whisper_device_free_around", nullcontext) + monkeypatch.setattr( + "tests.helpers.stage_config.stage_config_path_for_run_level", + lambda config_path, _run_level: config_path, + ) + request = SimpleNamespace( + param=OmniServerParams( + model="fake-model", + stage_config_path=stage_config_path, + use_stage_cli=True, + ), + node=SimpleNamespace(get_closest_marker=lambda _name: None), + ) + + server_fixture = runtime.iter_omni_server(request, "core_model", threading.Lock()) + next(server_fixture) + with pytest.raises(StopIteration): + next(server_fixture) + + assert "--deploy-config" not in captured_args + + def test_stage_cli_enter_rolls_back_launched_stages_on_startup_failure( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: From b9932aa17b0ea5c5e8ec49681eef656403285344 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Wed, 9 Sep 2026 07:32:38 -0700 Subject: [PATCH 78/90] [CI/Build][ROCm] Normalize SenseNova paged-decode hardware markers (#6935) Signed-off-by: andyluo7 --- .../test_sensenova_u1_paged_decode.py | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py b/tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py index 368852a1d..ac517945d 100644 --- a/tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py +++ b/tests/diffusion/models/sensenova_u1/test_sensenova_u1_paged_decode.py @@ -17,6 +17,7 @@ import torch from vllm.lora.layers import BaseLayerWithLoRA import vllm_omni.diffusion.models.sensenova_u1.paged_decode as paged_decode +from tests.helpers.mark import hardware_test from vllm_omni.diffusion.models.sensenova_u1.paged_decode import ( BLOCK_SIZE, BUCKETS, @@ -169,7 +170,8 @@ def test_length_lives_in_device_tensors(): # --------------------------------------------------------------------------- -# The capture contract. These need CUDA and the bundled flash-attention kernel. +# The capture contract. Graph tests need a torch.cuda-compatible accelerator; +# paged-attention equivalence also needs the bundled kernel's paged API. # --------------------------------------------------------------------------- cuda_only = pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA required") @@ -181,8 +183,7 @@ vllm_flash_attn_only = pytest.mark.skipif( @cuda_only @vllm_flash_attn_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_paged_attention_matches_sdpa_over_the_same_buffer(): import torch.nn.functional as F @@ -223,8 +224,7 @@ def test_paged_attention_matches_sdpa_over_the_same_buffer(): @cuda_only @vllm_flash_attn_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_a_captured_graph_follows_a_later_length(): """One capture has to serve every length in the bucket. @@ -285,8 +285,7 @@ def test_a_captured_graph_follows_a_later_length(): @cuda_only @vllm_flash_attn_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_a_captured_graph_writes_each_step_to_its_own_slot(): """The write slot has to be a device tensor, not a Python index. @@ -347,8 +346,7 @@ def test_a_captured_graph_writes_each_step_to_its_own_slot(): @cuda_only @vllm_flash_attn_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_a_whole_decode_loop_matches_the_unpaged_path(): """Per-step kernel equivalence is not loop equivalence. @@ -413,8 +411,7 @@ class _StubLM(torch.nn.Module): @cuda_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_the_decode_graph_captures_into_the_shared_platform_pool(monkeypatch): """``_decode_context`` builds a runner per request, so a private capture pool hands every request its own arena and never gives it back. Measured on a @@ -556,10 +553,12 @@ def test_a_kernel_missing_any_kwarg_we_pass_is_not_supported(monkeypatch): def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch): """A positional standard argument binds to whatever a future wheel puts in that slot, and the probe above only checks that the names exist.""" - seen = {} + seen_args: tuple[object, ...] = () + seen_kwargs: dict[str, object] = {} - def recorder(*args, **kwargs): - seen["args"], seen["kwargs"] = args, kwargs + def recorder(*args: object, **kwargs: object): + nonlocal seen_args, seen_kwargs + seen_args, seen_kwargs = args, kwargs return torch.zeros(1, N_HEADS, HEAD_DIM) monkeypatch.setattr(paged_decode, "_flash_varlen", lambda: recorder) @@ -572,8 +571,8 @@ def test_only_q_k_and_v_reach_the_kernel_positionally(monkeypatch): torch.zeros(1, KV_HEADS, 1, HEAD_DIM), 1.0, ) - assert len(seen["args"]) == 3, "only q, k and v may be positional" - assert set(seen["kwargs"]) == { + assert len(seen_args) == 3, "only q, k and v may be positional" + assert set(seen_kwargs) == { "max_seqlen_q", "cu_seqlens_q", "max_seqlen_k", @@ -694,8 +693,7 @@ def test_no_capture_crosses_a_request_once_lora_wrappers_are_installed(monkeypat @cuda_only -@pytest.mark.cuda -@pytest.mark.L4 +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}) def test_a_second_request_replays_the_first_capture(monkeypatch): """Reuse is only worth anything if the capture survives it.""" dev = torch.device("cuda") From 6fb7b0a05e3758e41e3871804598cea18a0b42fb Mon Sep 17 00:00:00 2001 From: hyw <109567717+yuweih205@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:34:46 +0800 Subject: [PATCH 79/90] [Model] Skip unused frame packing in Wan2.2 S2V (#7155) Signed-off-by: hyw --- .../diffusion/models/wan2_2/wan2_2_s2v_transformer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/vllm_omni/diffusion/models/wan2_2/wan2_2_s2v_transformer.py b/vllm_omni/diffusion/models/wan2_2/wan2_2_s2v_transformer.py index d1b74d020..cce5e43be 100644 --- a/vllm_omni/diffusion/models/wan2_2/wan2_2_s2v_transformer.py +++ b/vllm_omni/diffusion/models/wan2_2/wan2_2_s2v_transformer.py @@ -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 """ Wan2.2 Speech-to-Video (S2V) Transformer using vllm-omni ops. @@ -1359,11 +1359,9 @@ class WanS2VTransformer3DModel(nn.Module): return flatten_mot, mot_remb def process_motion_frame_pack(self, motion_latents, drop_motion_frames=False, add_last_motion=2): - flatten_mot, mot_remb = self.frame_packer(motion_latents, add_last_motion) if drop_motion_frames: - return [m[:, :0] for m in flatten_mot], [m[:, :0] for m in mot_remb] - else: - return flatten_mot, mot_remb + return [], [] + return self.frame_packer(motion_latents, add_last_motion) def process_motion_transformer_motioner(self, motion_latents, drop_motion_frames=False, add_last_motion=True): batch_size = motion_latents.shape[0] From 504e5a6a6e7d3d00105a1c04ba5bd84fd62d5d15 Mon Sep 17 00:00:00 2001 From: bojiang-li <327132355+bojiang-li@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:48:41 -0700 Subject: [PATCH 80/90] [Doc] Add dual DGX Spark MiniMax-H3 results (#7343) Signed-off-by: bojiang-li <327132355+bojiang-li@users.noreply.github.com> --- recipes/MiniMaxAI/MiniMax-H3-Spark-GB10.md | 54 ++++++++++++++++++---- recipes/README.md | 1 + 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/recipes/MiniMaxAI/MiniMax-H3-Spark-GB10.md b/recipes/MiniMaxAI/MiniMax-H3-Spark-GB10.md index b9d32947b..efd88c068 100644 --- a/recipes/MiniMaxAI/MiniMax-H3-Spark-GB10.md +++ b/recipes/MiniMaxAI/MiniMax-H3-Spark-GB10.md @@ -7,13 +7,14 @@ recipes it uses **no offload of any kind** — see the capacity note below. Validated on: - Host: DGX Spark (GB10), aarch64 -- GPUs: 1 (unified memory) -- Driver: +- GPUs: 1, or 2 hosts with one unified-memory GPU each +- Driver: 580.173.02 for the two-host qualification - vLLM: 0.26.0 - vLLM-Omni: `main` at `e1aa6eae75c460cd1893bc320546e81e66973831` -- Workloads: T2VA and Ref2VA, 960x576, `duration=8.0`, 50 steps, `flow_shift=12`, - `seed=1101` - +- One-host workloads: T2VA and Ref2VA, 960x576, `duration=8.0`, 50 steps +- Two-host workload: T2VA, 1344x768, `duration=5.0` and `duration=10.0`, + 50 steps, text-encoder TP2, and DiT USP2 +- Common sampling settings: `flow_shift=12`, `seed=1101` ## Capacity requirements @@ -24,6 +25,7 @@ Validated on: | Checkpoint storage | 135 GiB per partition | | Measured peak allocator high-water, T2VA (FP8) | 97.7 GiB | | Measured peak allocator high-water, Ref2VA (FP8) | 102.8 GiB | +| Measured peak per rank, two-host T2VA 5 s / 10 s (FP8) | 75.45 / 87.17 GiB | `FL2VA` and `Ref2VA` are separate 135 GiB checkpoint partitions. Start one server at a time. @@ -164,6 +166,41 @@ curl --fail-with-body -sS -X POST http://127.0.0.1:8000/v1/videos/sync \ The response is `200 OK` with an MP4 body: H.264 at the requested geometry plus a 32 kHz stereo AAC track. `ffprobe` reports 8.032 s for `duration=8.0`. +## Two GB10 hosts: T2VA at 1344x768 + +The two-host qualification uses one GB10 GPU per DGX Spark, connected through a +single ConnectX-7 RoCE interface. The FL2VA checkpoint and software revision are +identical on both hosts. The distributed configuration is: + +- world size 2; +- text-encoder tensor parallel size 2; +- DiT Ulysses sequence parallel size 2 and ring degree 1; +- VAE patch parallel size 1; +- online FP8, eager execution, cuDNN attention, and tiled VAE decode. + +The current diffusion multiprocess executor starts all ranks on one host and sets +`MASTER_ADDR=localhost`. These measurements therefore used an external `env://` +launcher with one rank on each host and a benchmark-only adaptation that maps +both hosts' local worker to `cuda:0`. This qualifies the two-rank MiniMax-H3 +pipeline and its output, but it is not yet a supported cross-host `vllm serve` +command. Do not infer that the single-host launch command above works unchanged +across two machines. + +One 5-second, 2-step request warmed both workers before the measured runs. The +reported pipeline elapsed time covers distributed model execution through GPU +completion; MP4 encoding happened afterward and is not included. + +| Duration | Steps | Text encode | Denoise | Per step | VAE decode | Pipeline elapsed | Max peak per rank | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 5 s | 50 | 0.21 s | 1570.19 s | 31.40 s | 79.45 s | 1649.87 s (27 min 30 s) | 75.45 GiB | +| 10 s | 50 | 0.15 s | 4448.12 s | 88.96 s | 160.71 s | 4609.00 s (76 min 49 s) | 87.17 GiB | + +Both ranks reported stage times and allocator peaks within measurement noise. +The generated outputs were H.264 at 1344x768 and 24 FPS with 32 kHz stereo AAC: +124 frames (5.167 s) and 243 frames (10.125 s). Both files decoded successfully, +and neither run encountered an OOM or NCCL error. These are single qualification +runs (`n=1`), not throughput guarantees. + ## Measured latency Single GB10, FP8, eager, cuDNN attention, tiled VAE, one request at a time: @@ -238,9 +275,11 @@ shorter than a 50-step Ref2VA run. ## Known limitations -- One GPU means no Ulysses, ring, TP, or VAE patch parallelism. Pass +- With one GPU there is no Ulysses, ring, TP, or VAE patch parallelism. Pass `--usp 1 --ring 1 --vae-patch-parallel-size 1` explicitly if a profile or script would otherwise supply higher degrees. +- The two-host results require the external launch adaptation described above; + cross-host diffusion worker launch is not exposed by the documented server CLI. - `--enforce-eager` is used because regional compile adds startup time without a parallelism win on a single rank. - `t2va` rejects requests without an explicit `aspect_ratio`. When `width` and @@ -255,8 +294,7 @@ shorter than a 50-step Ref2VA run. adding references or raising the output shape. - Ref2VA requires a vLLM-Omni build newer than `v0.26.0`. See the version note under **Validated on**: FP8 weight loading and image-only Ref2VA are both - broken on `release/v0.26.0`.suggestion is `v0.26.1`. + broken on `release/v0.26.0`. The suggested version is `v0.26.1`. - Online FP8 is incompatible with layerwise offload — the offload path produces a weight stride the Cutlass FP8 kernel rejects. This is not a practical restriction here since offload is unusable on GB10 anyway. -``` diff --git a/recipes/README.md b/recipes/README.md index 6b80f8a03..ea4576201 100644 --- a/recipes/README.md +++ b/recipes/README.md @@ -55,6 +55,7 @@ recipes/ | [`IndexTeam/IndexTTS-2.md`](./IndexTeam/IndexTTS-2.md) | Online serving for voice-cloned TTS with optional emotion control | 1x L4 24GB or larger CUDA GPU | | [`IndexTeam/IndexTTS-2_5.md`](./IndexTeam/IndexTTS-2_5.md) | Online + offline multilingual voice-cloned TTS with native speed and emotion control | 1x NVIDIA H20 96GB | | [`MiniMaxAI/MiniMax-H3.md`](./MiniMaxAI/MiniMax-H3.md) | T2VA, FL2VA, image+audio Ref2VA, and multi-video Ref2VA serving | 1x GPU with CPU offload / 4x B300 | +| [`MiniMaxAI/MiniMax-H3-Spark-GB10.md`](./MiniMaxAI/MiniMax-H3-Spark-GB10.md) | MiniMax H3 T2VA and Ref2VA on DGX Spark | 1x or 2x DGX Spark (GB10) | | [`MiniMaxAI/MiniMax-H3-MUSA.md`](./MiniMaxAI/MiniMax-H3-MUSA.md) | MiniMax H3 T2VA, FL2VA, and Ref2VA on MUSA | 4x MTT S5000 (TP4 Ref2VA profile) | | [`krea/Krea-2.md`](./krea/Krea-2.md) | Text-to-image (Turbo + Raw), offline + online, with LoRA | 1x H100 80GB | | [`LTX/LTX-2.md`](./LTX/LTX-2.md) | LTX-2/LTX-2.3 text-to-video and image-to-video with synchronized audio | H200 141GB / 96GB-class GPU | From b02862beee2f086e7f46df5aff3ca06026041045 Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Wed, 9 Sep 2026 13:02:12 -0700 Subject: [PATCH 81/90] [Model] Optimize MOSS-TTS Local batched execution and streaming codec (#7202) Signed-off-by: Sy03 <1370724210@qq.com> --- .buildkite/cuda/test-ready.yml | 2 +- .../online_serving/text_to_speech/README.md | 53 +++++++- recipes/OpenMOSS/MOSS-TTS.md | 12 +- .../test_seed_tts_dataset_variants.py | 20 ++- .../openai_api/test_tts_adapter.py | 50 ++++++++ tests/entrypoints/test_omni_entrypoints.py | 26 +++- .../moss_tts/test_local_depth_rope_cache.py | 15 ++- .../moss_tts/test_streaming_attention.py | 58 +++++++++ .../moss_tts/test_streaming_terminal_batch.py | 89 +++++++++++++ tests/worker/test_batched_omni_output.py | 76 +++++++++++ .../benchmarks/data_modules/seed_tts_eval.py | 25 ++-- vllm_omni/deploy/moss_tts_local_h200.yaml | 64 ++++++++++ vllm_omni/entrypoints/omni_base.py | 9 +- .../openai/tts_adapters/moss_tts.py | 28 +++-- vllm_omni/entrypoints/utils.py | 4 +- vllm_omni/metrics/stats.py | 15 ++- .../models/moss_tts/audio_tokenizer_v2.py | 14 ++- .../moss_tts/modeling_moss_tts_codec.py | 42 +++++-- .../moss_tts/modeling_moss_tts_local_depth.py | 80 ++++++++---- .../moss_tts/modeling_moss_tts_talker.py | 28 +++-- .../models/moss_tts/pipeline.py | 9 ++ .../models/moss_tts/streaming_attention.py | 119 ++++++++++++++++++ vllm_omni/outputs/__init__.py | 5 +- vllm_omni/worker/gpu_ar_model_runner.py | 61 +++++++-- vllm_omni/worker/gpu_model_runner.py | 53 +++++--- 25 files changed, 843 insertions(+), 114 deletions(-) create mode 100644 tests/model_executor/models/moss_tts/test_streaming_attention.py create mode 100644 tests/model_executor/models/moss_tts/test_streaming_terminal_batch.py create mode 100644 tests/worker/test_batched_omni_output.py create mode 100644 vllm_omni/deploy/moss_tts_local_h200.yaml create mode 100644 vllm_omni/model_executor/models/moss_tts/streaming_attention.py diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml index d7e14ed64..f54a58fa4 100644 --- a/.buildkite/cuda/test-ready.yml +++ b/.buildkite/cuda/test-ready.yml @@ -93,7 +93,7 @@ steps: commands: - | timeout 15m bash -c " - pytest -sv tests/engine/ tests/model_executor/ -m 'core_model and cuda' --run-level 'core_model' + pytest -sv tests/engine/ tests/model_executor/ tests/worker/test_batched_omni_output.py -m 'core_model and cuda' --run-level 'core_model' " mirror_hardwares: l4_1 diff --git a/examples/online_serving/text_to_speech/README.md b/examples/online_serving/text_to_speech/README.md index 5dbe58314..5141a37c2 100644 --- a/examples/online_serving/text_to_speech/README.md +++ b/examples/online_serving/text_to_speech/README.md @@ -259,21 +259,25 @@ python examples/online_serving/text_to_speech/indextts2/speech_client.py \ 0.6B DualAR TTS at 44.1 kHz, 11 languages, zero-shot voice cloning. ### Prerequisites + None beyond the base install: the neural audio codec is implemented in tree and its weights (`codec.pth`) ship with the checkpoint. ### Launch + ```bash vllm serve Audio8/Audio8-TTS-Preview-0.6b --omni --port 8092 # or: ./audio8_tts/run_server.sh ``` + The deploy config auto-loads from `vllm_omni/deploy/audio8_tts.yaml` (HF `model_type` is `arktts`). Do **not** pass `--trust-remote-code`: vllm-omni registers its own `arktts` config, and transformers would otherwise prefer the checkpoint's remote code and bypass it. ### Text-only synthesis + ```bash curl -X POST http://localhost:8092/v1/audio/speech \ -H "Content-Type: application/json" \ @@ -284,6 +288,7 @@ curl -X POST http://localhost:8092/v1/audio/speech \ ``` ### Voice cloning + ```bash curl -X POST http://localhost:8092/v1/audio/speech \ -H "Content-Type: application/json" \ @@ -293,18 +298,21 @@ curl -X POST http://localhost:8092/v1/audio/speech \ "ref_text": "The exact transcript of the reference recording." }' --output cloned.wav ``` + `ref_audio` also accepts a `data:audio/wav;base64,...` URL. `ref_text` is mandatory whenever `ref_audio` is present and must match the recording. Uploading a voice via `POST /v1/audio/voices` lets the server reuse the encoded reference codes across requests (`voice: ""`). ### Streaming + ```bash python audio8_tts/speech_client.py --text "Welcome to Audio8 TTS." --stream --output out.pcm ffplay -f s16le -ar 44100 -ac 1 out.pcm ``` ### Gradio demo + ```bash ./audio8_tts/run_gradio_demo.sh # server + demo python audio8_tts/gradio_demo.py --api-base http://localhost:8092 # demo only @@ -328,7 +336,7 @@ these as a lower bound. 20 prompts from `seed_tts_smoke/en`, mean audio 4.5 s, deploy defaults: | | HF reference (batch=1) | vllm-omni c=1 | c=4 | c=8 | -|---|---|---|---|---| +| --- | --- | --- | --- | --- | | RTF (mean) | 1.008 | **0.19** | 0.30 | 0.54 | | Throughput (req/s) | 0.23 | 1.18 | 2.84 | **2.93** | | E2E latency (mean, ms) | 4375 | 850 | 1349 | 2361 | @@ -347,7 +355,7 @@ job holding ~80% SM, no repeats), so read them as directional, not as measured speedups. Same setup, varying stage 0's `max_num_seqs` (stage 1 stays at 1): | stage-0 `max_num_seqs` | client concurrency | req/s | RTF | TTFP mean / p99 (ms) | underrun p99 (s) | -|---|---|---|---|---|---| +| --- | --- | --- | --- | --- | --- | | 4 (default) | 1 | 1.18 | 0.19 | 63 / 67 | 0.00 | | 4 | 4 | 2.84 | 0.30 | 105 / 148 | 0.00 | | 4 | 8 | 2.93 | 0.54 | 1124 / 1642 | 0.00 | @@ -376,6 +384,7 @@ underrun. Raise it to 8 if first-packet latency under load matters more than a rare sub-300 ms gap. ### Notes + - Output: 44.1 kHz mono; ~21.5 codec frames per second. - No built-in speaker presets. Omit `voice` for a random timbre, or clone one. - `max_new_tokens` caps generated codec frames (1 frame ~= 46 ms). @@ -553,6 +562,46 @@ python examples/online_serving/text_to_speech/ming_flash_omni_tts/speech_client. --- +## MOSS-TTS Local Transformer v1.5 + +For a single H200, the optional +[`moss_tts_local_h200.yaml`](../../../vllm_omni/deploy/moss_tts_local_h200.yaml) +deployment places both the talker and codec on logical GPU 0: + +```bash +CUDA_VISIBLE_DEVICES=0 VLLM_OMNI_EVENT_DRIVEN_ORCH=1 \ + vllm serve OpenMOSS-Team/MOSS-TTS-Local-Transformer-v1.5 \ + --omni --trust-remote-code \ + --deploy-config vllm_omni/deploy/moss_tts_local_h200.yaml \ + --disable-log-stats +``` + +Run from the repository root and select an available physical GPU with +`CUDA_VISIBLE_DEVICES`. The command enables event-driven orchestration and +disables detailed per-request statistics logging to reduce CPU overhead at +high concurrency. Remove `--disable-log-stats` when those statistics are needed; +keep these settings identical when comparing performance. +This preset configures 256 request slots per stage, +a 32 GiB talker KV cache, talker CUDA Graph buckets through 512 scheduled tokens, +and codec batch buckets through 256. It requires more than 80 GiB of GPU memory; +request capacity also depends on input and generated lengths. The default +[`moss_tts_local.yaml`](../../../vllm_omni/deploy/moss_tts_local.yaml) remains +available for smaller deployments. + +The preset enables the optional codec backend with +`hf_overrides.codec_attention_backend: triton` on stage 1. It preserves the +streaming ring-cache mask and uses BF16 attention with 64-dimensional heads; +other attention shapes use PyTorch SDPA. Set the backend to `sdpa` to use the +default attention implementation. Codec terminal tails share execution only +when they already map to the same padded CUDA Graph; returned audio retains +each request's actual length. + +Voice cloning requests use `ref_audio` and `ref_text`. For streaming output, +set `stream: true`, `stream_format: "audio"`, and `response_format: "pcm"`; +the native PCM format is 48 kHz, stereo, signed 16-bit little-endian. Compute +audio throughput as `PCM bytes / (48000 * 2 * 2) / elapsed seconds`, and compare +the same requests, concurrency, and reference-cache state. + ## MOSS-TTS-Nano Single-stage 0.1B AR LM + MOSS-Audio-Tokenizer-Nano codec at 48 kHz mono. Every request must include `ref_audio`; there are no built-in speaker presets. diff --git a/recipes/OpenMOSS/MOSS-TTS.md b/recipes/OpenMOSS/MOSS-TTS.md index e9cb798fe..8d37039d8 100644 --- a/recipes/OpenMOSS/MOSS-TTS.md +++ b/recipes/OpenMOSS/MOSS-TTS.md @@ -16,7 +16,7 @@ Use this recipe for 24 kHz multilingual TTS with voice cloning (20 languages inc Chinese and English). Choose a variant based on your latency and quality requirements: | Model | Params | Use case | -|---|---|---| +| --- | --- | --- | | MOSS-TTS | 8B | General TTS, highest quality | | MOSS-TTS-v1.5 | 8B | General TTS upgrade of 1.0: 31 languages, steadier cloning, `[pause Xs]` markers (set `language` for best results); same `MossTTSDelay` API | | MOSS-TTS-Realtime | 1.7B | Lowest latency (TTFB ~180 ms), streaming-first | @@ -24,9 +24,17 @@ Chinese and English). Choose a variant based on your latency and quality require | MOSS-SoundEffect | 8B | Sound effect synthesis from text description | | MOSS-VoiceGenerator | 1.7B | Zero-shot voice design | -All variants share the same codec (`OpenMOSS-Team/MOSS-Audio-Tokenizer`, ~7 GB) and +The variants above share the same codec (`OpenMOSS-Team/MOSS-Audio-Tokenizer`, ~7 GB) and output 24 kHz mono audio. +MOSS-TTS-Local-Transformer-v1.5 uses MOSS-Audio-Tokenizer-v2 and outputs 48 kHz +stereo audio. For Local voice cloning through `/v1/audio/speech`, provide an +accurate `ref_text` transcript alongside `ref_audio`. The adapter uses the +reference transcript and audio as a continuation prefix, then generates only +the requested `input` speech. Without a nonblank `ref_text`, Local uses +audio-reference generation. Reference transcripts must match the reference +audio; they are not style instructions. + ## References - Offline inference example: [`examples/offline_inference/text_to_speech/moss_tts/`](../../examples/offline_inference/text_to_speech/moss_tts/) diff --git a/tests/benchmarks/test_seed_tts_dataset_variants.py b/tests/benchmarks/test_seed_tts_dataset_variants.py index 31f66bd7d..17ee6df4c 100644 --- a/tests/benchmarks/test_seed_tts_dataset_variants.py +++ b/tests/benchmarks/test_seed_tts_dataset_variants.py @@ -26,6 +26,7 @@ _MODULE_NAME = "vllm_omni.benchmarks.data_modules.seed_tts_dataset" if _MODULE_NAME not in sys.modules: _spec = importlib.util.spec_from_file_location(_MODULE_NAME, _MODULE_PATH) + assert _spec is not None and _spec.loader is not None _mod = importlib.util.module_from_spec(_spec) sys.modules[_MODULE_NAME] = _mod _spec.loader.exec_module(_mod) @@ -237,7 +238,8 @@ def test_seed_tts_design_dataset_rejects_missing_description(seed_tts_design_roo assert req.seed_tts_utterance_id == "ok" -def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch): +@pytest.mark.parametrize("audio_seconds", [0.1, 61]) +def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch, audio_seconds): from vllm_omni.benchmarks.data_modules import seed_tts_eval calls = {} @@ -252,8 +254,9 @@ def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch): return self class FakeProcessor: - def __call__(self, wav, *, sampling_rate, return_tensors, return_attention_mask=False): + def __call__(self, wav, *, sampling_rate, return_tensors, return_attention_mask=False, **kwargs): calls["return_attention_mask"] = return_attention_mask + calls["processor_kwargs"] = kwargs assert sampling_rate == 16000 assert return_tensors == "pt" assert len(wav) > 0 @@ -262,9 +265,10 @@ def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch): attention_mask=FakeTensor("mask") if return_attention_mask else None, ) - def get_decoder_prompt_ids(self, *, language, task): + def get_decoder_prompt_ids(self, *, language, task, no_timestamps=True): assert language == "english" assert task == "transcribe" + calls["no_timestamps"] = no_timestamps return [(1, 2)] def batch_decode(self, predicted_ids, *, skip_special_tokens): @@ -283,10 +287,18 @@ def test_seed_tts_whisper_transcribe_passes_attention_mask(monkeypatch): monkeypatch.setattr(seed_tts_eval, "_en_model", FakeModel()) monkeypatch.setattr(seed_tts_eval, "_device", "cuda:1") - text = seed_tts_eval._transcribe_en_f32_16k(np.ones(1600, dtype=np.float32)) + text = seed_tts_eval._transcribe_en_f32_16k(np.ones(int(audio_seconds * 16000), dtype=np.float32)) assert text == "hello" assert calls["return_attention_mask"] is True assert calls["input_features"].device == "cuda:1" assert calls["generate_kwargs"]["attention_mask"].device == "cuda:1" assert calls["generate_kwargs"]["forced_decoder_ids"] == [(1, 2)] + if audio_seconds > 30: + assert calls["processor_kwargs"] == {"truncation": False, "padding": "longest"} + assert calls["generate_kwargs"]["return_timestamps"] is True + assert calls["no_timestamps"] is False + else: + assert calls["processor_kwargs"] == {} + assert "return_timestamps" not in calls["generate_kwargs"] + assert calls["no_timestamps"] is True diff --git a/tests/entrypoints/openai_api/test_tts_adapter.py b/tests/entrypoints/openai_api/test_tts_adapter.py index a2c0ace0b..a17a6ccbd 100644 --- a/tests/entrypoints/openai_api/test_tts_adapter.py +++ b/tests/entrypoints/openai_api/test_tts_adapter.py @@ -166,6 +166,56 @@ def test_moss_tts_seed_falls_back_to_stage_default(adapter_cls, mocker): assert prepared.tts_params["seed"] == [42] +@pytest.mark.parametrize( + "variant,ref_text,mode", + [ + ("local", " Reference. ", "continuation"), + ("local", None, "generation"), + ("local", " ", "generation"), + ("tts", "Reference.", "generation"), + ], +) +def test_moss_reference_transcript_mode(variant, ref_text, mode, mocker): + import torch + + reference = [torch.ones((3, 12), dtype=torch.int64)] + unified = torch.arange(52, dtype=torch.int64).reshape(1, 4, 13) + processor = mocker.Mock(return_value={"input_ids": unified}) + server = mocker.Mock(_moss_variant="local", uploaded_speakers={"speaker": {}}) + server._voice_created_at.return_value = 123 + request = OpenAICreateSpeechRequest( + input="Target.", ref_text=ref_text, language="English", voice="Speaker", seed=0, max_new_tokens=2048 + ) + adapter = MossTTSAdapter(SpeechServingContext(server=server)) + + adapter._moss_variant = variant + mocker.patch.object(adapter, "_get_moss_processor", return_value=processor) + mocker.patch.object( + adapter, "_encode_moss_references", new=mocker.AsyncMock(return_value=(reference, {0: "reference-key"})) + ) + + prepared = asyncio.run(adapter.build(request, [], has_inline_ref_audio=False)) + + if mode == "continuation": + processor.build_user_message.assert_called_once_with(text="Reference. Target.", language="English") + processor.build_assistant_message.assert_called_once_with(audio_codes_list=reference) + conversation = [processor.build_user_message.return_value, processor.build_assistant_message.return_value] + else: + processor.build_user_message.assert_called_once_with(text="Target.", language="English", reference=reference) + processor.build_assistant_message.assert_not_called() + conversation = [processor.build_user_message.return_value] + processor.assert_called_once_with(conversations=[conversation], mode=mode) + assert prepared.prompt["prompt_token_ids"] == unified[0, :, 0].tolist() + assert torch.equal(prepared.tts_params["codes"]["ref"], unified[0, :, 1:]) + assert prepared.tts_params["max_new_frames"] == [2048] + assert prepared.tts_params["ref_audio_cache_key"] == "reference-key" + assert prepared.tts_params["voice_name"] == ["speaker"] + assert prepared.tts_params["voice_created_at"] == [123] + assert prepared.tts_params["seed"] == [0] + assert prepared.prompt["cache_salt"] + assert request.input == "Target." + + def test_qwen3_tts_metadata(): assert Qwen3TTSAdapter.backend == "ar" assert issubclass(Qwen3TTSAdapter, ARTTSAdapter) diff --git a/tests/entrypoints/test_omni_entrypoints.py b/tests/entrypoints/test_omni_entrypoints.py index 4f9fc592b..6fe04fdb7 100644 --- a/tests/entrypoints/test_omni_entrypoints.py +++ b/tests/entrypoints/test_omni_entrypoints.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import queue @@ -97,9 +100,11 @@ class FakeAsyncOmniEngine: ) -> None: self.model = model self.config_path = None - self.stage_configs: list[Any] = [] self.stage_metadata = stage_metadata or [THREE_STAGE_META[-1]] self.num_stages = len(self.stage_metadata) + self.stage_configs = [ + StageConfig(stage_id=i, model_stage="dummy-model").to_omegaconf() for i in range(self.num_stages) + ] self.default_sampling_params_list = default_sampling_params_list or [ SamplingParams(max_tokens=8) for _ in range(self.num_stages) ] @@ -222,6 +227,25 @@ def test_resolve_sampling_params_list_preserves_stage_constraints(): assert caller_params.stop_token_ids == [7] +@pytest.mark.parametrize("use_defaults", [False, True]) +def test_moss_local_output_policy_preserves_codec_streaming(use_defaults): + from vllm_omni.model_executor.models.moss_tts.pipeline import MOSS_TTS_LOCAL_PIPELINE + + base = _make_base() + base.engine.num_stages = 2 + base.sampling_constraints_list = [stage.sampling_constraints for stage in MOSS_TTS_LOCAL_PIPELINE.stages] + base.default_sampling_params_list = [ + base._apply_sampling_constraints(SamplingParams(), constraints) + for constraints in base.sampling_constraints_list + ] + caller = [SamplingParams(output_kind=RequestOutputKind.DELTA) for _ in range(2)] + result = base.resolve_sampling_params_list(None if use_defaults else caller, allow_delta_coercion=True) + + assert [params.output_kind for params in result] == [RequestOutputKind.FINAL_ONLY, RequestOutputKind.DELTA] + assert all(params.output_kind == RequestOutputKind.DELTA for params in caller) + assert base.default_sampling_params_list[0].output_kind == RequestOutputKind.FINAL_ONLY + + def _stage_spec( stage_id: int, *, diff --git a/tests/model_executor/models/moss_tts/test_local_depth_rope_cache.py b/tests/model_executor/models/moss_tts/test_local_depth_rope_cache.py index 90c386ee0..566ba4595 100644 --- a/tests/model_executor/models/moss_tts/test_local_depth_rope_cache.py +++ b/tests/model_executor/models/moss_tts/test_local_depth_rope_cache.py @@ -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 """CPU parity tests for MOSS-TTS Local Depth RoPE caching.""" import pytest @@ -42,3 +42,16 @@ def test_rope_cache_matches_reference_and_reuses_storage() -> None: # Smaller slices retain the fixed allocation used by CUDA Graph. attn.prepare_rope_cache(6, device, torch.float32) assert attn._rope_cos_cache.data_ptr() == cache_ptr + + +def test_frame_local_kv_matches_full_prefix_and_reuses_slots() -> None: + model = MossTTSLocalDepthTransformer(_test_config()).eval() + model.h[0].attn.prepare_rope_cache(12, torch.device("cpu"), torch.float32) + cache = (torch.empty(2, 4, 12, 8), torch.empty(2, 4, 12, 8)) + with torch.inference_mode(): + for _ in range(2): + inputs = torch.randn(2, 12, 32) + for position in range(12): + expected = model._forward_prefix(inputs[:, : position + 1])[:, -1:] + actual = model._forward_prefix(inputs[:, position : position + 1], cache, position) + torch.testing.assert_close(actual, expected, atol=1e-6, rtol=1e-5) diff --git a/tests/model_executor/models/moss_tts/test_streaming_attention.py b/tests/model_executor/models/moss_tts/test_streaming_attention.py new file mode 100644 index 000000000..3134575ca --- /dev/null +++ b/tests/model_executor/models/moss_tts/test_streaming_attention.py @@ -0,0 +1,58 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +import copy + +import pytest +import torch +import torch.nn.functional as F + +from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import ( + MossAudioTokenizerMultiheadAttention, + StreamingExecutionContext, +) +from vllm_omni.model_executor.models.moss_tts.streaming_attention import masked_attention + +pytestmark = [pytest.mark.core_model, pytest.mark.cuda] + + +@pytest.mark.parametrize( + "batch,heads,queries,keys", [(2, 20, 1, 125), (4, 20, 15, 125), (1, 12, 480, 400), (4, 12, 32, 250)] +) +def test_masked_attention_matches_sdpa_with_strides_and_invalid_rows(batch, heads, queries, keys): + torch.manual_seed(7) + q = torch.randn(batch, queries, 3, heads, 64, device="cuda", dtype=torch.bfloat16)[:, :, 0].permute(0, 2, 1, 3) + k = torch.randn(batch, heads, keys, 64, device="cuda", dtype=torch.bfloat16) + v = torch.randn_like(k) + positions = torch.arange(keys, device="cuda").roll(17) + query_positions = torch.arange(queries, device="cuda") + keys - queries + mask = (positions[None, :] <= query_positions[:, None])[None, None].expand(batch, 1, -1, -1).clone() + mask[0, :, 0] = False # Cover an all-masked row alongside valid rows. + actual = masked_attention(q, k, v, mask) + expected = F.scaled_dot_product_attention(q, k, v, mask) + assert bool(actual.isfinite().all()) + torch.testing.assert_close(actual, expected, atol=0.008, rtol=0.01) + assert actual[0, :, 0].count_nonzero() == 0 + + +def test_streaming_attention_preserves_ring_wrap_reordering_and_slot_reset(): + torch.manual_seed(9) + reference = MossAudioTokenizerMultiheadAttention( + 128, 2, causal=True, context=9, device="cuda", dtype=torch.bfloat16 + ) + candidate = copy.deepcopy(reference) + candidate._streaming_attention = masked_attention + for model in (reference, candidate): + model._streaming_state = model._init_streaming_state(4) + for step in range(8): + slots = torch.tensor([1, 0, 3] if step % 2 else [0, 1, 3], device="cuda") + valid = torch.tensor([True, True, False], device="cuda") + context = StreamingExecutionContext(state_slot_ids=slots, valid_rows=valid) + x = torch.randn(3, 3, 128, device="cuda", dtype=torch.bfloat16) + with torch.inference_mode(): + expected = reference(x, x, x, execution_context=context) + actual = candidate(x, x, x, execution_context=context) + torch.testing.assert_close(actual, expected, atol=0.01, rtol=0.02) + torch.testing.assert_close(candidate._streaming_state.offset, reference._streaming_state.offset, rtol=0, atol=0) + if step == 4: + for model in (reference, candidate): + model._streaming_state.reset_slots(torch.tensor([0], device="cuda")) diff --git a/tests/model_executor/models/moss_tts/test_streaming_terminal_batch.py b/tests/model_executor/models/moss_tts/test_streaming_terminal_batch.py new file mode 100644 index 000000000..fff97cd96 --- /dev/null +++ b/tests/model_executor/models/moss_tts/test_streaming_terminal_batch.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +from types import SimpleNamespace + +import pytest +import torch +from torch import nn + +from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_codec import ( + MossTTSCodecDecoder, + _MossCodecStreamSession, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class CausalCodec(nn.Module): + downsample_rate = 1 + + def __init__(self): + super().__init__() + self.weight = nn.Parameter(torch.zeros(())) + + def initialize_decoder_state_pool(self, capacity, scratch): + self.state = torch.zeros(capacity + scratch) + + def reset_decoder_state_slots(self, slots): + self.state[slots] = 0 + + def decode_streaming_batch(self, codes, lengths, slots, valid_rows): + audio = codes.sum(0).float().cumsum(-1) + self.state[slots, None] + self.state[slots] = audio[:, -1] + return SimpleNamespace(audio=audio[:, None], audio_lengths=lengths) + + +def session(): + return _MossCodecStreamSession(CausalCodec(), state_capacity=8, n_vq=2, vllm_config=None) + + +def test_terminal_padding_preserves_audio_live_state_and_reused_slots(): + s = session() + assert [s.acquire(), s.acquire()] == [0, 1] + plan = {0: torch.ones(2, 3), 1: torch.ones(2, 15)} + with pytest.raises(ValueError, match="Only terminal"): + s.step(plan) + assert s._codec.state.count_nonzero() == 0 + s.step({0: torch.ones(2, 1), 1: torch.full((2, 1), 2)}) + actual = s.step(plan, terminal_slots={0}) + torch.testing.assert_close(actual[0], torch.tensor([[4.0, 6.0, 8.0]])) + torch.testing.assert_close(actual[1], torch.arange(6.0, 36.0, 2.0)[None]) + s.release(0, state_already_reset=True) + assert s.acquire() == 0 + resumed = s.step({0: torch.ones(2, 1), 1: torch.ones(2, 1)}) + assert [resumed[i].item() for i in (0, 1)] == [2, 36] + + +@pytest.mark.parametrize("graph_capacity, expected_calls", [(None, 5), (4, 5), (8, 3)]) +def test_only_existing_compatible_graph_buckets_are_coalesced(mocker, graph_capacity, expected_calls): + s = session() + s._cudagraph_wrapper = ( + None + if graph_capacity is None + else SimpleNamespace( + batch_sizes=[1, graph_capacity], + _select_frame_size=lambda size, allow_padding: next((b for b in [1, 8, 15] if b >= size), None), + decode=lambda *args, **kwargs: None, + ) + ) + step = mocker.spy(s, "step") + decoder = object.__new__(MossTTSCodecDecoder) + nn.Module.__init__(decoder) + decoder._stream_max_step_frames = 15 + decoder._stream_state_capacity = 8 + decoder._stream_req_slots = {} + decoder._ensure_stream_session = lambda: s + lengths = [3, 7, 8, 1, 15] + finished = [True, True, False, True, False] + result = decoder._decode_streaming_batch( + [ + (i, str(i), torch.ones(2, length), done) + for i, (length, done) in enumerate(zip(lengths, finished, strict=True)) + ] + ) + assert step.call_count == expected_calls + assert [result[i].shape[-1] for i in range(5)] == lengths + if graph_capacity == 8: + assert list(step.call_args_list[0].args[0]) == [0, 1, 2] + assert step.call_args_list[0].kwargs["terminal_slots"] == {0, 1} + assert list(step.call_args_list[1].args[0]) == [3] # Preserve the exact one-frame graph. diff --git a/tests/worker/test_batched_omni_output.py b/tests/worker/test_batched_omni_output.py new file mode 100644 index 000000000..d0b18b3c1 --- /dev/null +++ b/tests/worker/test_batched_omni_output.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +import pytest +import torch + +from vllm_omni.model_executor.models.moss_tts.modeling_moss_tts_talker import MossTTSLocalTalkerForGeneration +from vllm_omni.worker.gpu_ar_model_runner import _snapshot_tensor_payload_to_cpu_async + +pytestmark = [pytest.mark.core_model, pytest.mark.cuda] + + +def test_async_packed_snapshot_survives_source_reuse(): + values = [ + torch.arange(12, device="cuda").view(1, 12), + torch.empty((0, 12), device="cuda", dtype=torch.long), + torch.arange(24, device="cuda").view(2, 12), + ] + expected = [v.cpu() for v in values] + snapshot = _snapshot_tensor_payload_to_cpu_async( + {"list": values, "tuple": tuple(values)}, copy_stream=torch.cuda.Stream(), pin_memory=True + ) + for v in values: + v.fill_(-99) + snapshot.wait() + torch.testing.assert_close(snapshot.payload, {"list": expected, "tuple": tuple(expected)}, rtol=0, atol=0) + + +@pytest.mark.parametrize("offsets", [[0, 1], [1, 4]]) +def test_mtp_preserves_rows_and_snapshot_without_cuda_sync(monkeypatch, offsets): + import vllm_omni.worker.gpu_model_runner as mod + from tests.worker.test_omni_gpu_model_runner import _make_runner, _noop_forward_context + + monkeypatch.setattr(mod.current_omni_platform, "set_forward_context", _noop_forward_context) + runner = _make_runner(req_ids=("r1", "r2"), hidden_size=4) + runner.model.gpu_resident_buffer_keys = {("codes", "audio")} + for name in ("talker_mtp_input_ids", "talker_mtp_inputs_embeds", "last_talker_hidden", "text_step"): + buffer = getattr(runner, name) + buffer.gpu = buffer.gpu.to("cuda") + embeds = torch.ones((2, 4), device="cuda") + codes = torch.arange(2, device="cuda").view(2, 1) + runner.talker_mtp = lambda *args, **kwargs: (embeds, codes) + output = torch.full((5, 4), -10.0, device="cuda") + previous = torch.cuda.get_sync_debug_mode() + try: + torch.cuda.set_sync_debug_mode("error") + runner._talker_mtp_forward(["r1", "r2"], output, offsets) + finally: + torch.cuda.set_sync_debug_mode(previous) + expected = torch.full((5, 4), -10.0) + expected[offsets] = 1.0 + torch.testing.assert_close(output.cpu(), expected, rtol=0, atol=0) + codes.fill_(999) + assert runner.model_intermediate_buffer["r1"]["codes"]["audio"].item() == 0 + assert runner.model_intermediate_buffer["r2"]["codes"]["audio"].item() == 1 + + +def test_mixed_stop_flags_do_not_synchronize_cuda(): + model = object.__new__(MossTTSLocalTalkerForGeneration) + torch.nn.Module.__init__(model) + model.n_vq = 12 + model.audio_pad_token_id = 1024 + valid = torch.arange(12, device="cuda").view(1, 12) + stopped = torch.full_like(valid, 1024) + infos = [{"audio_codes": {"current": valid}}, {}, {"audio_codes": {"current": stopped}}] + hidden = torch.ones((3, 4), device="cuda") + previous = torch.cuda.get_sync_debug_mode() + try: + torch.cuda.set_sync_debug_mode("error") + output = model.make_omni_output(hidden, runtime_additional_information=infos) + finally: + torch.cuda.set_sync_debug_mode(previous) + assert model._batch_should_continue.tolist() == [True, True, False] + torch.testing.assert_close(output.multimodal_outputs["codes"]["audio"], [valid, valid[:0], stopped]) + assert all("current" not in info.get("audio_codes", {}) for info in infos) + prefill = model.make_omni_output(hidden[:1], runtime_additional_information=[{}]) + assert model._batch_should_continue is None and prefill.multimodal_outputs == {} diff --git a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py index 9dc99548a..100933870 100644 --- a/vllm_omni/benchmarks/data_modules/seed_tts_eval.py +++ b/vllm_omni/benchmarks/data_modules/seed_tts_eval.py @@ -464,25 +464,33 @@ def _transcribe_en_f32_16k(wav_f32: np.ndarray) -> str: return "" with _lock: assert _en_processor is not None and _en_model is not None and _device is not None + # Whisper's default feature extraction truncates at 30 seconds. Keep + # abnormal tails so WER evaluates the complete generated response. + long_audio = len(wav_f32) > 30 * 16000 + processor_kwargs = {"truncation": False, "padding": "longest"} if long_audio else {} try: inputs = _en_processor( wav_f32, sampling_rate=16000, return_tensors="pt", return_attention_mask=True, + **processor_kwargs, ) except TypeError: - inputs = _en_processor(wav_f32, sampling_rate=16000, return_tensors="pt") + inputs = _en_processor(wav_f32, sampling_rate=16000, return_tensors="pt", **processor_kwargs) input_features = inputs.input_features.to(_device) attention_mask = getattr(inputs, "attention_mask", None) if attention_mask is None and isinstance(inputs, dict): attention_mask = inputs.get("attention_mask") generate_kwargs: dict[str, Any] = {} + if long_audio: + generate_kwargs["return_timestamps"] = True if attention_mask is not None: generate_kwargs["attention_mask"] = attention_mask.to(_device) with torch.no_grad(): try: - forced = _en_processor.get_decoder_prompt_ids(language="english", task="transcribe") + prompt_kwargs = {"no_timestamps": False} if long_audio else {} + forced = _en_processor.get_decoder_prompt_ids(language="english", task="transcribe", **prompt_kwargs) predicted_ids = _en_model.generate(input_features, forced_decoder_ids=forced, **generate_kwargs) except Exception: predicted_ids = _en_model.generate( @@ -548,14 +556,13 @@ def _expand_seed_tts_turn_outputs( turn_pcm = getattr(output, "tts_turn_pcm_bytes", None) session_pcm = getattr(output, "tts_output_pcm_bytes", None) for turn_index, turn in enumerate(turns): - expanded_requests.append( - replace( - request, - prompt=turn.target_text, - seed_tts_utterance_id=turn.utterance_id, - seed_tts_turns=(), - ) + turn_request = replace( + request, + seed_tts_utterance_id=turn.utterance_id, + seed_tts_turns=(), ) + turn_request.prompt = turn.target_text + expanded_requests.append(turn_request) turn_output = copy(output) if isinstance(turn_pcm, list) and turn_index < len(turn_pcm): turn_output.tts_output_pcm_bytes = turn_pcm[turn_index] diff --git a/vllm_omni/deploy/moss_tts_local_h200.yaml b/vllm_omni/deploy/moss_tts_local_h200.yaml new file mode 100644 index 000000000..6fb5e010d --- /dev/null +++ b/vllm_omni/deploy/moss_tts_local_h200.yaml @@ -0,0 +1,64 @@ +# MOSS-TTS Local Transformer v1.5: both stages on one H200. +# Select one free physical GPU with CUDA_VISIBLE_DEVICES; devices below are logical. +# Capacity and graph capture require more than 80 GiB of GPU memory. +async_chunk: true + +connectors: + shm: + name: SharedMemoryConnector + extra: + connector_get_sleep_s: 0.005 + connector_get_max_wait_first_chunk: 1000 + connector_get_max_wait: 300 + initial_codec_chunk_frames: 1 + codec_chunk_frames: 15 + codec_left_context_frames: 0 + +stages: + - stage_id: 0 + max_num_seqs: 256 + gpu_memory_utilization: 0.60 + kv_cache_memory_bytes: 34359738368 # 32 GiB; leave room for codec state and graphs. + enable_prefix_caching: false + async_scheduling: true + max_num_batched_tokens: 65536 + max_model_len: 4096 + devices: "0" + compilation_config: + # Capture mixed prefill/decode batches by total scheduled tokens. + cudagraph_capture_sizes: [1, 2, 4, 8, 16, 24, 32, 40, 48, 56, 64, 72, 80, 88, 96, 104, 112, 120, 128, 160, 192, 224, 256, 320, 384, 448, 512] + max_cudagraph_capture_size: 512 + output_connectors: + to_stage_1: shm + default_sampling_params: + temperature: 1.7 + top_p: 0.8 + top_k: 25 + max_tokens: 4096 + seed: 42 + repetition_penalty: 1.0 + + - stage_id: 1 + max_num_seqs: 256 + gpu_memory_utilization: 0.30 + enforce_eager: false + enable_prefix_caching: false + async_scheduling: true + compilation_config: + # Codec execution-batch buckets; frame counts come from the connector. + cudagraph_capture_sizes: [1, 2, 4, 8, 16, 32, 64, 128, 160, 192, 256] + cudagraph_num_of_warmups: 1 + max_num_batched_tokens: 65536 + max_model_len: 65536 + devices: "0" + hf_overrides: + codec_attention_backend: triton + input_connectors: + from_stage_0: shm + default_sampling_params: + temperature: 0.0 + top_p: 1.0 + top_k: -1 + max_tokens: 65536 + seed: 42 + repetition_penalty: 1.0 diff --git a/vllm_omni/entrypoints/omni_base.py b/vllm_omni/entrypoints/omni_base.py index 0aa6832d0..264b37fb5 100644 --- a/vllm_omni/entrypoints/omni_base.py +++ b/vllm_omni/entrypoints/omni_base.py @@ -339,6 +339,7 @@ class OmniBase(PDDisaggregationMixin): allow_delta_coercion: bool = False, ) -> Sequence[Any]: """Resolve request parameters; pipeline sampling constraints override caller values.""" + normalized: Sequence[Any] if sampling_params_list is None: normalized = self.default_sampling_params_list # Set the output kind to delta since no params were specified @@ -354,7 +355,9 @@ class OmniBase(PDDisaggregationMixin): if len(normalized) != self.num_stages: raise ValueError(f"Expected {self.num_stages} sampling params, got {len(normalized)}") - if sampling_params_list is not None: + # Streaming coercion may also change a constrained output kind in + # the defaults (for example, an internal TTS stage's FINAL_ONLY). + if sampling_params_list is not None or allow_delta_coercion: normalized = [ self._apply_sampling_constraints(params, constraints) for params, constraints in zip(normalized, self.sampling_constraints_list, strict=True) @@ -364,7 +367,7 @@ class OmniBase(PDDisaggregationMixin): @staticmethod def _get_sampling_constraints_list(stage_configs: Sequence[Any]) -> list[dict[str, Any]]: """Extract each stage's required sampling settings from runtime configs.""" - constraints_list = [] + constraints_list: list[dict[str, Any]] = [] for stage_config in stage_configs: constraints = getattr(stage_config, "sampling_constraints", {}) if not isinstance(constraints, Mapping): @@ -398,7 +401,7 @@ class OmniBase(PDDisaggregationMixin): req_state = self.request_states.get(request_id) prom = getattr(self, "prom_metrics", None) metrics = getattr(req_state, "metrics", None) - if metrics is None or prom is None: + if req_state is None or metrics is None or prom is None: return if str(request_id) in metrics.e2e_done or getattr(req_state, "failure_recorded", False): return diff --git a/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py b/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py index e264c7af5..07e4bb197 100644 --- a/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py +++ b/vllm_omni/entrypoints/openai/tts_adapters/moss_tts.py @@ -6,7 +6,7 @@ Both variants share the same build/validate flow (``_build_moss_tts_params`` handles each); they are registered under distinct model-type names. """ -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from vllm.inputs import tokens_input @@ -61,7 +61,7 @@ class _MossTTSAdapterBase(ARTTSAdapter): # the process-wide speaker cache. encoder = build_reference_encoder( self._get_moss_processor(), - variant=self._moss_variant, + variant=cast(str, self._moss_variant), speaker_cache=self._speaker_cache, ) self._moss_ref_encoder = encoder @@ -101,7 +101,7 @@ class _MossTTSAdapterBase(ARTTSAdapter): return await encode_request_references( self._get_moss_ref_encoder(), - request.ref_audio, + cast(str, request.ref_audio), request.ref_audio_2 if two_speaker else None, resolve_ref_audio=self._resolve_ref_audio, get_artifact_key=self._get_resolved_ref_audio_artifact_key, @@ -179,15 +179,16 @@ class _MossTTSAdapterBase(ARTTSAdapter): v = self._moss_variant + params: dict[str, Any] # ---- Legacy nano path (unchanged) ---- if v is None: # moss_tts_nano - params: dict[str, Any] = { + params = { "text": [request.input or ""], "mode": ["voice_clone"], } if request.max_new_tokens is not None: params["max_new_frames"] = [request.max_new_tokens] - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + wav_list, sr, cache_key = await self._resolve_ref_audio(cast(str, request.ref_audio)) params["prompt_audio_array"] = [[wav_list, sr]] params["ref_audio_cache_key"] = cache_key return params @@ -201,13 +202,13 @@ class _MossTTSAdapterBase(ARTTSAdapter): # talker reads for short prompts; full Realtime support needs a # separate processor.from_module path which we don't wire here. if v == "realtime": - params: dict[str, Any] = { + params = { "text": [request.input or ""], "mode": ["voice_clone"], } if request.max_new_tokens is not None: params["max_new_frames"] = [request.max_new_tokens] - wav_list, sr, cache_key = await self._resolve_ref_audio(request.ref_audio) + wav_list, sr, cache_key = await self._resolve_ref_audio(cast(str, request.ref_audio)) params["prompt_audio_array"] = [[wav_list, sr]] params["ref_audio_cache_key"] = cache_key return params @@ -254,13 +255,20 @@ class _MossTTSAdapterBase(ARTTSAdapter): # Build the unified-codes prompt: (L, 1+n_vq) where col 0 is text/special # tokens and cols 1..n_vq are the delay-pattern audio code grid (mostly # audio_pad_code outside the reference block). - user_msg = proc.build_user_message(**user_kwargs) - batch = proc(conversations=[[user_msg]], mode="generation") + if v == "local" and request.ref_text and request.ref_text.strip(): + reference = user_kwargs.pop("reference") + user_kwargs["text"] = request.ref_text.strip() + " " + (request.input or "") + user_msg = proc.build_user_message(**user_kwargs) + assistant_msg = proc.build_assistant_message(audio_codes_list=reference) + batch = proc(conversations=[[user_msg, assistant_msg]], mode="continuation") + else: + user_msg = proc.build_user_message(**user_kwargs) + batch = proc(conversations=[[user_msg]], mode="generation") unified = batch["input_ids"][0] # torch.LongTensor (L, 1+n_vq) text_ids: list[int] = unified[:, 0].tolist() audio_codes: torch.Tensor = unified[:, 1:].contiguous().to(torch.int64) - params: dict[str, Any] = { + params = { "prompt_token_ids": text_ids, "codes": {"ref": audio_codes}, } diff --git a/vllm_omni/entrypoints/utils.py b/vllm_omni/entrypoints/utils.py index e1efd7ac4..39131fd58 100644 --- a/vllm_omni/entrypoints/utils.py +++ b/vllm_omni/entrypoints/utils.py @@ -278,9 +278,9 @@ def maybe_coerce_to_message_type(params: SamplingParams, is_streaming: bool): if params.output_kind == target_type: return params elif is_streaming and params.output_kind == RequestOutputKind.FINAL_ONLY: - logger.warning("Request appears to be streaming, but got request type final only!") + logger.debug("Coercing FINAL_ONLY output to DELTA for streaming") elif not is_streaming and params.output_kind == RequestOutputKind.DELTA: - logger.warning("Request appears to not be streaming, but got request type delta!") + logger.debug("Coercing DELTA output to FINAL_ONLY for non-streaming") if not params.skip_clone: params = params.clone() diff --git a/vllm_omni/metrics/stats.py b/vllm_omni/metrics/stats.py index ef86a5a35..e38d83133 100644 --- a/vllm_omni/metrics/stats.py +++ b/vllm_omni/metrics/stats.py @@ -48,7 +48,7 @@ class StageRequestStats: finish_reason: str | None = None request_id: str | None = None postprocess_time_ms: float = 0.0 - diffusion_metrics: dict[str, float] = None + diffusion_metrics: dict[str, float] | None = None audio_generated_frames: int = 0 audio_sample_rate: int = 0 audio_duration_s: float = 0.0 @@ -181,11 +181,11 @@ class OrchestratorAggregator: self.e2e_total_ms = 0.0 self.e2e_total_tokens = 0 self.e2e_count = 0 - self.e2e_done = set() + self.e2e_done: set[str] = set() self.wall_start_ts = float(wall_start_ts) self.last_finish_ts = float(wall_start_ts) - self.stage_first_ts = [None for _ in range(self.num_stages)] - self.stage_last_ts = [None for _ in range(self.num_stages)] + self.stage_first_ts: list[float | None] = [None for _ in range(self.num_stages)] + self.stage_last_ts: list[float | None] = [None for _ in range(self.num_stages)] self.accumulated_gen_time_ms: defaultdict[str, defaultdict[int, float]] = defaultdict( lambda: defaultdict(float) ) # {request_id: {stage_id:accumulated_gen_time_ms}} @@ -618,6 +618,7 @@ class OrchestratorAggregator: final_output_type: str | None = None, ) -> None: stats = self._as_stage_request_stats(stage_id, req_id, metrics, final_output_type) + assert stats.stage_id is not None self.stage_total_tokens[stats.stage_id] += int(stats.num_tokens_out) if stats.stage_id == 0: self.stage_total_tokens[stats.stage_id] += int(stats.num_tokens_in) @@ -775,10 +776,8 @@ class OrchestratorAggregator: final_stage_id_map = self.final_stage_id_for_e2e stage_wall_time_ms = [ - ((self.stage_last_ts[i] - self.stage_first_ts[i]) * 1000.0) - if (self.stage_first_ts[i] is not None and self.stage_last_ts[i] is not None) - else 0.0 - for i in range(self.num_stages) + ((last - first) * 1000.0) if first is not None and last is not None else 0.0 + for first, last in zip(self.stage_first_ts, self.stage_last_ts, strict=True) ] overall_summary = { diff --git a/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py b/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py index 380096af5..4dbedbd19 100644 --- a/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py +++ b/vllm_omni/model_executor/models/moss_tts/audio_tokenizer_v2.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2026 OpenMOSS and the HuggingFace Inc. team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -801,7 +804,16 @@ class MossAudioTokenizerMultiheadAttention(StreamingModule): else: attn_bias = None - x = F.scaled_dot_product_attention(q, k, v, attn_bias, dropout_p=0.0) + streaming_attention = getattr(self, "_streaming_attention", None) + if ( + streaming_attention is not None + and attn_bias is not None + and q.dtype == torch.bfloat16 + and q.shape[-1] == 64 + ): + x = streaming_attention(q, k, v, attn_bias) + else: + x = F.scaled_dot_product_attention(q, k, v, attn_bias, dropout_p=0.0) x = x.transpose(1, 2).reshape(B, T, self.embed_dim) x = apply_weights_per_step(self.out_projs, self.weights_per_step_schedule, x, offset_cpu) diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py index 231e33857..c27949310 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_codec.py @@ -145,15 +145,22 @@ class _MossCodecStreamSession: terminal_slots = terminal_slots or set() if not terminal_slots.issubset(slot_codes): raise ValueError("terminal_slots must be a subset of the decode batch slots.") - step_lengths = {int(codes.shape[1]) for codes in slot_codes.values()} - if len(step_lengths) != 1: - raise ValueError(f"MOSS codec streaming step needs uniform T, got {sorted(step_lengths)}") - (step_t,) = step_lengths + step_t = max(int(codes.shape[1]) for codes in slot_codes.values()) + if any(int(codes.shape[1]) != step_t and slot not in terminal_slots for slot, codes in slot_codes.items()): + raise ValueError("Only terminal codec rows may be padded to a larger step length") slots = list(slot_codes) if any(slot not in self._leased_slots for slot in slots): raise RuntimeError(f"Streaming decode references an unleased state slot: {slots}") codes_step = torch.stack( - [slot_codes[slot].to(device=self._device, dtype=torch.long) for slot in slots], + [ + torch.nn.functional.pad( + slot_codes[slot].to(device=self._device, dtype=torch.long), + (0, step_t - int(slot_codes[slot].shape[1])), + ) + if int(slot_codes[slot].shape[1]) != step_t + else slot_codes[slot].to(device=self._device, dtype=torch.long) + for slot in slots + ], dim=1, ) state_slot_ids = torch.tensor(slots, device=self._device, dtype=torch.long) @@ -197,9 +204,9 @@ class _MossCodecStreamSession: self._reset_slot_ids(terminal_slot_ids) audio = audio_tensor.detach().to("cpu", torch.float32) - audio_length = step_t * self._samples_per_frame out: dict[int, torch.Tensor] = {} for row, slot in enumerate(slots): + audio_length = int(slot_codes[slot].shape[1]) * self._samples_per_frame out[slot] = audio[row, ..., :audio_length].contiguous() return out @@ -518,6 +525,8 @@ class MossTTSCodecDecoder(nn.Module): outputs: dict[int, torch.Tensor] = {} grouped: dict[int, list[tuple[int, str, int, torch.Tensor, bool]]] = {} max_step_frames = max(1, int(self._stream_max_step_frames)) + graph = session._cudagraph_wrapper + coalesce_tails = graph is not None and max(graph.batch_sizes, default=0) >= self._stream_state_capacity for output_index, request_id, codes_nq_t, finished in items: slot = self._stream_req_slots.get(request_id) @@ -548,9 +557,13 @@ class MossTTSCodecDecoder(nn.Module): ) continue - grouped.setdefault(int(codes_nq_t.shape[1]), []).append( - (output_index, request_id, slot, codes_nq_t, finished) - ) + frame_count = int(codes_nq_t.shape[1]) + # Only combine tails that already used the same padded graph. + # Preserve exact-size graphs (including T=1) and the eager path. + group_frames = frame_count + if coalesce_tails and finished: + group_frames = graph._select_frame_size(frame_count, allow_padding=True) or frame_count + grouped.setdefault(group_frames, []).append((output_index, request_id, slot, codes_nq_t, finished)) for group in grouped.values(): plan = {slot: codes_nq_t for _, _, slot, codes_nq_t, _ in group} @@ -756,6 +769,17 @@ class MossTTSCodecDecoder(nn.Module): codec.eval() if device.type != "cpu": codec.decoder.to(dtype=torch.bfloat16) + attention_backend = getattr(self.vllm_config.model_config.hf_config, "codec_attention_backend", "sdpa") + if attention_backend != "sdpa": + if attention_backend != "triton" or device.type != "cuda": + raise ValueError(f"Unsupported codec attention backend/device: {attention_backend}/{device.type}") + from vllm_omni.model_executor.models.moss_tts.audio_tokenizer_v2 import MossAudioTokenizerMultiheadAttention + from vllm_omni.model_executor.models.moss_tts.streaming_attention import masked_attention + + for module in codec.decoder.modules(): + if isinstance(module, MossAudioTokenizerMultiheadAttention): + module._streaming_attention = masked_attention + logger.info("Enabled Triton masked attention for the streaming codec decoder") build_decode_lut = getattr(codec.quantizer, "build_decode_lut", None) if callable(build_decode_lut): lut_dtype = torch.bfloat16 if device.type == "cuda" else torch.float32 diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py index e2d46ce2c..ba19c02d1 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_local_depth.py @@ -1,3 +1,5 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project # Copyright 2026 OpenMOSS and the vLLM-Omni team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). @@ -101,8 +103,13 @@ class _MossTTSLocalAttention(nn.Module): odd = x[..., 1::2] return torch.stack((-odd, even), dim=-1).reshape_as(x) - def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: - """``hidden_states``: ``(B, S, H)``. Re-prefills with a fresh causal mask over ``[0, S)`` every call.""" + def forward( + self, + hidden_states: torch.Tensor, + kv_cache: tuple[torch.Tensor, torch.Tensor] | None = None, + position: int = 0, + ) -> torch.Tensor: + """Run a causal prefix, or one new token using frame-local K/V.""" batch_size, seq_len, _ = hidden_states.shape qkv = self.c_attn(hidden_states) query, key, value = qkv.split(self.embed_dim, dim=-1) @@ -110,14 +117,22 @@ class _MossTTSLocalAttention(nn.Module): key = key.view(batch_size, seq_len, self.n_head, self.head_dim) value = value.view(batch_size, seq_len, self.n_head, self.head_dim) - cos, sin = self._rope_cos_sin(seq_len, hidden_states.device, hidden_states.dtype) + cos, sin = self._rope_cos_sin(position + seq_len, hidden_states.device, hidden_states.dtype) + cos, sin = cos[:, position:], sin[:, position:] query = query * cos + self._rotate_half(query) * sin key = key * cos + self._rotate_half(key) * sin query = query.transpose(1, 2) key = key.transpose(1, 2) value = value.transpose(1, 2) - attn_output = F.scaled_dot_product_attention(query, key, value, is_causal=True) + if kv_cache is not None: + assert seq_len == 1, "Cached Local Depth execution consumes one token at a time" + k_cache, v_cache = kv_cache + k_cache[:, :, position : position + 1].copy_(key) + v_cache[:, :, position : position + 1].copy_(value) + key = k_cache[:, :, : position + 1] + value = v_cache[:, :, : position + 1] + attn_output = F.scaled_dot_product_attention(query, key, value, is_causal=kv_cache is None) attn_output = attn_output.transpose(1, 2).reshape(batch_size, seq_len, self.embed_dim) return self.c_proj(attn_output) @@ -141,8 +156,13 @@ class _MossTTSLocalBlock(nn.Module): self.ln_2 = nn.LayerNorm(hidden_size, eps=eps) self.mlp = _MossTTSLocalMLP(hidden_size, inner_size) - def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: - hidden_states = hidden_states + self.attn(self.ln_1(hidden_states)) + def forward( + self, + hidden_states: torch.Tensor, + kv_cache: tuple[torch.Tensor, torch.Tensor] | None = None, + position: int = 0, + ) -> torch.Tensor: + hidden_states = hidden_states + self.attn(self.ln_1(hidden_states), kv_cache, position) hidden_states = hidden_states + self.mlp(self.ln_2(hidden_states)) return hidden_states @@ -156,11 +176,9 @@ class MossTTSLocalDepthTransformer(nn.Module): and codebook-0's head (``audio_lm_heads[0]``) simultaneously. - codebooks 1..n_vq-1 are sampled sequentially: each sampled code is re-embedded (``audio_embeddings[c]``) and appended as the next - position, re-prefilling the block over the growing (<=n_vq) sequence - with a fresh causal mask each call -- mathematically identical to - incremental KV-cache decoding since attention is strictly causal and - only the last position is ever read, but avoids any cache plumbing - given the trivially short sequence length. + position. K/V for earlier positions are reused within the frame, so + each position's projections and MLP run once. A new frame overwrites + every cache position before reading it, including CUDA Graph replay. """ def __init__(self, gpt2_config, hidden_size: int | None = None) -> None: @@ -174,11 +192,13 @@ class MossTTSLocalDepthTransformer(nn.Module): self.ln_f = nn.LayerNorm(self.hidden_size, eps=eps) self._compiled_forward_prefix = None - def _forward_prefix(self, seq_embeds: torch.Tensor) -> torch.Tensor: - hidden_states = seq_embeds - for block in self.h: - hidden_states = block(hidden_states) - return self.ln_f(hidden_states) + def _forward_prefix( + self, + seq_embeds: torch.Tensor, + kv_cache: tuple[torch.Tensor, torch.Tensor] | None = None, + position: int = 0, + ) -> torch.Tensor: + return self.ln_f(self.h[0](seq_embeds, kv_cache, position)) def setup_compile(self) -> None: if self._compiled_forward_prefix is not None: @@ -189,14 +209,18 @@ class MossTTSLocalDepthTransformer(nn.Module): return self._compiled_forward_prefix = torch.compile( self._forward_prefix, - dynamic=False, + # Share batch/position shapes instead of exhausting Dynamo's + # recompilation budget while warming request-size graph buckets. + dynamic=True, options={"epilogue_fusion": False}, ) - logger.info("MOSS-TTS local depth prefix enabled with torch.compile") + logger.info("MOSS-TTS local depth frame-local KV execution enabled with torch.compile") - def _run_prefix(self, seq_embeds: torch.Tensor) -> torch.Tensor: + def _run_prefix( + self, seq_embeds: torch.Tensor, kv_cache: tuple[torch.Tensor, torch.Tensor], position: int + ) -> torch.Tensor: forward_prefix = self._compiled_forward_prefix or self._forward_prefix - return forward_prefix(seq_embeds) + return forward_prefix(seq_embeds, kv_cache, position) @torch.no_grad() def generate_frame( @@ -240,9 +264,13 @@ class MossTTSLocalDepthTransformer(nn.Module): for block in self.h: block.attn.prepare_rope_cache(n_vq, backbone_last_hidden.device, dtype) - embeds = backbone_last_hidden.new_zeros((batch_size, n_vq, self.hidden_size), dtype=dtype) - embeds[:, 0, :] = backbone_last_hidden.to(dtype) - hidden = self._run_prefix(embeds[:, :1, :]) + attn = self.h[0].attn + cache_shape = (batch_size, attn.n_head, n_vq, attn.head_dim) + kv_cache = ( + backbone_last_hidden.new_empty(cache_shape, dtype=dtype), + backbone_last_hidden.new_empty(cache_shape, dtype=dtype), + ) + hidden = self._run_prefix(backbone_last_hidden[:, None, :].to(dtype), kv_cache, 0) local_hidden = hidden[:, 0, :] binary_logits = local_text_lm_head(local_hidden).float() @@ -292,9 +320,9 @@ class MossTTSLocalDepthTransformer(nn.Module): codes[:, channel_index] = channel_token if channel_index + 1 < n_vq: - embeds[:, channel_index + 1, :] = audio_embeddings[channel_index](channel_token).to(dtype) - hidden = self._run_prefix(embeds[:, : channel_index + 2, :]) - local_hidden = hidden[:, channel_index + 1, :] + embeds = audio_embeddings[channel_index](channel_token).to(dtype)[:, None, :] + hidden = self._run_prefix(embeds, kv_cache, channel_index + 1) + local_hidden = hidden[:, 0, :] return should_continue, codes diff --git a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py index 0495d79d8..9d2d2d171 100644 --- a/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py +++ b/vllm_omni/model_executor/models/moss_tts/modeling_moss_tts_talker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2026 OpenMOSS and the vLLM-Omni team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). @@ -1671,20 +1674,19 @@ class MossTTSLocalTalkerForGeneration(nn.Module): self._batch_state = [(info["audio_state"] if isinstance(info, dict) else {}) for info in info_dicts] self._batch_state_spans = kwargs.get("request_token_spans") - should_values: list[torch.Tensor] = [] - have_mtp_output = False + should_codes: list[torch.Tensor] = [] + should_indices: list[int] = [] per_req_codes: list[torch.Tensor] = [] have_codes = False - for info in info_dicts: + for index, info in enumerate(info_dicts): audio_codes = (info.get("audio_codes", {}) or {}) if isinstance(info, dict) else {} current = audio_codes.pop("current", None) if isinstance(current, torch.Tensor) and current.numel() > 0: codes = current.to(device=hidden.device, dtype=torch.long) if codes.dim() == 1: codes = codes.unsqueeze(0) - should_continue = codes.ne(self.audio_pad_token_id).any(dim=-1) - should_values.append(should_continue.reshape(-1)[:1]) - have_mtp_output = True + should_codes.append(codes[:1]) + should_indices.append(index) # Do not boolean-index a CUDA tensor here. Even for the # common single-row case, ``codes[should_continue]`` must # discover a dynamic output size on the host and therefore @@ -1694,9 +1696,19 @@ class MossTTSLocalTalkerForGeneration(nn.Module): per_req_codes.append(codes) have_codes = True else: - should_values.append(torch.ones((1,), device=hidden.device, dtype=torch.bool)) per_req_codes.append(hidden.new_empty((0, self.n_vq), dtype=torch.long)) - self._batch_should_continue = torch.cat(should_values, dim=0) if have_mtp_output and should_values else None + self._batch_should_continue = None + if should_codes: + active = torch.cat(should_codes, dim=0).ne(self.audio_pad_token_id).any(dim=-1) + if len(should_codes) == len(info_dicts): + self._batch_should_continue = active + else: + self._batch_should_continue = torch.ones(len(info_dicts), device=hidden.device, dtype=torch.bool) + # Keep mixed prefill/decode output packing asynchronous. + indices = torch.tensor(should_indices, device="cpu", dtype=torch.long).to( + hidden.device, non_blocking=True + ) + self._batch_should_continue.index_copy_(0, indices, active) if not have_codes: return OmniOutput(text_hidden_states=hidden, multimodal_outputs={}) diff --git a/vllm_omni/model_executor/models/moss_tts/pipeline.py b/vllm_omni/model_executor/models/moss_tts/pipeline.py index f499654a4..0d6151313 100644 --- a/vllm_omni/model_executor/models/moss_tts/pipeline.py +++ b/vllm_omni/model_executor/models/moss_tts/pipeline.py @@ -1,8 +1,13 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2026 OpenMOSS and the vLLM-Omni team. All rights reserved. # # Licensed under the Apache License, Version 2.0 (the "License"). """Pipeline topology for all MOSS-TTS variants (2-stage: talker → codec).""" +from vllm.sampling_params import RequestOutputKind + from vllm_omni.config.stage_config import ( PipelineConfig, StageExecutionType, @@ -100,6 +105,10 @@ MOSS_TTS_LOCAL_PIPELINE = PipelineConfig( sampling_constraints={ "detokenize": False, "stop_token_ids": [151645], + # The worker connector streams codes directly to the codec. + # This internal stage only needs to publish its terminal + # result; codec audio output remains incremental. + "output_kind": RequestOutputKind.FINAL_ONLY, }, ), StagePipelineConfig( diff --git a/vllm_omni/model_executor/models/moss_tts/streaming_attention.py b/vllm_omni/model_executor/models/moss_tts/streaming_attention.py new file mode 100644 index 000000000..1acce7710 --- /dev/null +++ b/vllm_omni/model_executor/models/moss_tts/streaming_attention.py @@ -0,0 +1,119 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Masked streaming attention preserving ring-cache position semantics.""" + +import torch +from vllm.triton_utils import tl, triton + + +@triton.jit +def _attention( + q_ptr, + k_ptr, + v_ptr, + mask_ptr, + out_ptr, + qs0: tl.constexpr, + qs1: tl.constexpr, + qs2: tl.constexpr, + qs3: tl.constexpr, + ks0: tl.constexpr, + ks1: tl.constexpr, + ks2: tl.constexpr, + ks3: tl.constexpr, + vs0: tl.constexpr, + vs1: tl.constexpr, + vs2: tl.constexpr, + vs3: tl.constexpr, + ms0: tl.constexpr, + ms2: tl.constexpr, + ms3: tl.constexpr, + num_heads: tl.constexpr, + q_len: tl.constexpr, + kv_len: tl.constexpr, + head_dim: tl.constexpr, + block_m: tl.constexpr, + block_n: tl.constexpr, +): + rows = tl.program_id(0) * block_m + tl.arange(0, block_m) + bh = tl.program_id(1) + batch, head = bh // num_heads, bh % num_heads + dims = tl.arange(0, head_dim) + q = tl.load( + q_ptr + batch * qs0 + head * qs1 + rows[:, None] * qs2 + dims[None, :] * qs3, + mask=rows[:, None] < q_len, + other=0, + ) + acc = tl.full((block_m, head_dim), 0.0, tl.float32) + maximum = tl.full((block_m,), -float("inf"), tl.float32) + denominator = tl.full((block_m,), 0.0, tl.float32) + for first in range(tl.cdiv(kv_len, block_n)): + cols = first * block_n + tl.arange(0, block_n) + k = tl.load( + k_ptr + batch * ks0 + head * ks1 + cols[None, :] * ks2 + dims[:, None] * ks3, + mask=cols[None, :] < kv_len, + other=0, + ) + allowed = tl.load( + mask_ptr + batch * ms0 + rows[:, None] * ms2 + cols[None, :] * ms3, + mask=(rows[:, None] < q_len) & (cols[None, :] < kv_len), + other=0, + ) + score = tl.dot(q, k).to(tl.float32) * (head_dim**-0.5) + score = tl.where(allowed & (cols[None, :] < kv_len), score, -float("inf")) + next_max = tl.maximum(maximum, tl.max(score, axis=1)) + safe_max = tl.where(next_max == -float("inf"), 0.0, next_max) + rescale = tl.exp(maximum - safe_max) + p = tl.exp(score - safe_max[:, None]) + denominator = denominator * rescale + tl.sum(p, axis=1) + v = tl.load( + v_ptr + batch * vs0 + head * vs1 + cols[:, None] * vs2 + dims[None, :] * vs3, + mask=cols[:, None] < kv_len, + other=0, + ) + acc = acc * rescale[:, None] + tl.dot(p.to(v.dtype), v) + maximum = next_max + out = acc / tl.where(denominator > 0, denominator, 1.0)[:, None] + tl.store( + out_ptr + ((batch * num_heads + head) * q_len + rows[:, None]) * head_dim + dims[None, :], + out, + mask=rows[:, None] < q_len, + ) + + +@torch.library.custom_op("moss_streaming::masked_attention", mutates_args=()) +def masked_attention(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: + b, h, t, d = q.shape + out = torch.empty(q.shape, device=q.device, dtype=q.dtype) + # Small query tiles expose parallelism for short streaming chunks. Larger + # tiles reuse K/V for steady chunks. Keep 64-key softmax blocks to retain + # the same accumulation granularity across both paths. + bm = 16 if t <= 32 else 64 + num_warps = 8 if k.shape[2] <= 256 else 4 + _attention[(triton.cdiv(t, bm), b * h)]( + q, + k, + v, + mask, + out, + *q.stride(), + *k.stride(), + *v.stride(), + mask.stride(0), + mask.stride(2), + mask.stride(3), + h, + t, + k.shape[2], + d, + bm, + 64, + num_warps=num_warps, + num_stages=2, + ) + return out + + +@masked_attention.register_fake +def _(q: torch.Tensor, k: torch.Tensor, v: torch.Tensor, mask: torch.Tensor) -> torch.Tensor: + return torch.empty(q.shape, device=q.device, dtype=q.dtype) diff --git a/vllm_omni/outputs/__init__.py b/vllm_omni/outputs/__init__.py index 90197f6e6..e74931a20 100644 --- a/vllm_omni/outputs/__init__.py +++ b/vllm_omni/outputs/__init__.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from dataclasses import dataclass, field from typing import Any @@ -52,7 +55,7 @@ class OmniModelRunnerOutput(ModelRunnerOutput): """ multimodal_outputs: list[dict[str, object]] | None = None - inter_stage_outputs: list[dict[str, object]] | None = None + inter_stage_outputs: list[dict[str, Any] | None] | None = None # IDs of requests whose KV cache has been extracted from GPU/NPU to CPU. # The Scheduler can safely free the block tables for these requests. kv_extracted_req_ids: list[str] | None = None diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index d293f6312..811f86f17 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -15,7 +15,7 @@ from collections.abc import Callable, Sequence from contextlib import nullcontext from copy import copy from dataclasses import replace -from typing import Any, NamedTuple +from typing import Any, NamedTuple, cast import numpy as np import torch @@ -87,14 +87,39 @@ def _clone_cuda_tensor_payload(value: Any, sources: list[torch.Tensor]) -> Any: return value.detach().clone() if isinstance(value, dict): return {k: _clone_cuda_tensor_payload(v, sources) for k, v in value.items()} - if isinstance(value, list): - return [_clone_cuda_tensor_payload(v, sources) for v in value] - if isinstance(value, tuple): - return tuple(_clone_cuda_tensor_payload(v, sources) for v in value) + if isinstance(value, (list, tuple)): + if ( + value + and all( + isinstance(v, torch.Tensor) + and v.is_cuda + and v.dtype == value[0].dtype + and v.device == value[0].device + and v.layout == torch.strided + and v.is_contiguous() + for v in value + ) + # Keep frame/state packing bounded; large payloads use individual snapshots. + and sum(v.numel() * v.element_size() for v in value) <= 1024 * 1024 + ): + packed = torch.cat([v.detach().reshape(-1) for v in value]) + sources.append(packed) + return _PackedTensorPayload(packed, [v.shape for v in value], isinstance(value, tuple)) + items = [_clone_cuda_tensor_payload(v, sources) for v in value] + return tuple(items) if isinstance(value, tuple) else items return value def _copy_tensor_payload_to_cpu(value: Any, pin_memory: bool) -> Any: + if isinstance(value, _PackedTensorPayload): + flat = _copy_tensor_payload_to_cpu(value.tensor, pin_memory) + items = [] + offset = 0 + for shape in value.shapes: + count = shape.numel() + items.append(flat[offset : offset + count].view(shape)) + offset += count + return tuple(items) if value.is_tuple else items if isinstance(value, torch.Tensor): if value.device.type != "cuda": return value @@ -110,6 +135,12 @@ def _copy_tensor_payload_to_cpu(value: Any, pin_memory: bool) -> Any: return value +class _PackedTensorPayload(NamedTuple): + tensor: torch.Tensor + shapes: list[torch.Size] + is_tuple: bool + + class _AsyncCPUPayloadSnapshot: def __init__( self, @@ -181,7 +212,7 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): if kwargs: raise TypeError(f"Unexpected OmniAsyncGPUModelRunnerOutput kwargs: {sorted(kwargs)}") - self._model_runner_output = None + self._model_runner_output: OmniModelRunnerOutput | None = None self._invalid_req_indices = invalid_req_indices self.async_copy_ready_event = torch.Event() @@ -211,7 +242,7 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): self._num_nans_cpu = self._num_nans.to("cpu", non_blocking=True) if self._num_nans is not None else None self.async_copy_ready_event.record() - self._model_runner_output_builder = model_runner_output_builder + self._model_runner_output_builder: Callable[[], OmniModelRunnerOutput] | None = model_runner_output_builder self._background_exception: BaseException | None = None self._background_thread: threading.Thread | None = None self._cuda_device = cuda_device @@ -226,6 +257,7 @@ class OmniAsyncGPUModelRunnerOutput(AsyncGPUModelRunnerOutput): if self._model_runner_output is not None: return with record_function_or_nullcontext("omni_async_output:get_output/build_model_runner_output"): + assert self._model_runner_output_builder is not None self._model_runner_output = self._model_runner_output_builder() self._model_runner_output_builder = None @@ -321,6 +353,9 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex outputs per request while keeping Async output semantics. """ + execute_model_state: ExecuteModelState | None + kv_extracted_req_ids: list[str] | None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.input_ids = self._make_buffer(self.max_num_tokens, dtype=torch.int32) @@ -436,6 +471,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex if not isinstance(info, dict): return None val = info.get("omni_final_stage_id") + if val is None: + return None try: return int(val) except (TypeError, ValueError): @@ -1485,7 +1522,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex ) selected_indices.append(idx) selected_req_ids.append(req_id) - selected_req_infos.append(req_info) + selected_req_infos.append(cast(dict[str, Any], req_info)) if not selected_indices: return multimodal_outputs @@ -1552,7 +1589,7 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex def _build_multimodal_outputs( self, - per_req_payloads: list[dict[str, object] | None] | None, + per_req_payloads: Sequence[dict[str, object] | None] | None, ) -> list[dict[str, torch.Tensor] | None] | None: """Build per-request multimodal output payloads (dedicated channel). @@ -1648,8 +1685,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex def _build_omni_step_outputs( self, - pooler_inter: list[dict[str, object]], - pooler_client: list[dict[str, object]], + pooler_inter: Sequence[dict[str, object] | None] | None, + pooler_client: Sequence[dict[str, object] | None] | None, *, defer_full_payload_d2h: bool, ) -> tuple[ @@ -1935,6 +1972,8 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex ) pooler_output.append(flatten_payload(payload)) + pooler_inter: Sequence[dict[str, object] | None] | None + pooler_client: Sequence[dict[str, object] | None] | None pooler_output = pooler_output or [] if self._async_chunk and stage_sends_async_output(self.model_config): pooler_inter, pooler_client = partition_payload_list(pooler_output) diff --git a/vllm_omni/worker/gpu_model_runner.py b/vllm_omni/worker/gpu_model_runner.py index eca15c16e..80d55020f 100644 --- a/vllm_omni/worker/gpu_model_runner.py +++ b/vllm_omni/worker/gpu_model_runner.py @@ -78,6 +78,8 @@ def _filter_mrope_kwargs_for_model(model: object, kwargs: dict[str, Any]) -> dic class OmniGPUModelRunner(GPUModelRunner): + intermediate_tensors: IntermediateTensors | None + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.model_intermediate_buffer: dict[str, dict[str, Any]] = {} @@ -842,7 +844,9 @@ class OmniGPUModelRunner(GPUModelRunner): return None @torch.inference_mode() - def extract_multimodal_outputs(self, hidden_states: torch.Tensor | list[torch.Tensor] | OmniOutput) -> dict: + def extract_multimodal_outputs( + self, hidden_states: torch.Tensor | list[torch.Tensor] | OmniOutput + ) -> tuple[Any, Any]: if ( hasattr(self.model, "have_multimodal_outputs") and self.model.have_multimodal_outputs @@ -1407,14 +1411,14 @@ class OmniGPUModelRunner(GPUModelRunner): def _process_additional_information_updates( self, hidden_states: torch.Tensor, - multimodal_outputs: object, + multimodal_outputs: Any, num_scheduled_tokens_np: np.ndarray, scheduler_output: "SchedulerOutput", combined_hidden_states: dict[str, torch.Tensor] | None = None, - combined_multimodal_outputs: dict[str, object] | None = None, + combined_multimodal_outputs: dict[str, Any] | None = None, req_ids_filter: set[str] | None = None, req_ids: list[str] | None = None, - query_start_loc_cpu: object | None = None, + query_start_loc_cpu: Any = None, ) -> None: """Process model-provided per-request updates and merge into model_intermediate_buffer.""" req_ids = req_ids if req_ids is not None else self.input_batch.req_ids @@ -1476,9 +1480,9 @@ class OmniGPUModelRunner(GPUModelRunner): def _collect_additional_information_for_prefill( self, num_scheduled_tokens_np: np.ndarray, - ) -> dict[str, dict]: + ) -> None: """Overlay per-request prompt_embeds for the prefill portion and collect - additional_information slices for this step. Returns a map req_id -> dict.""" + additional_information slices for this step.""" for req_index, req_id in enumerate(self.input_batch.req_ids): req_state = self.requests[req_id] pe_cpu = getattr(req_state, "prompt_embeds_cpu", None) @@ -1707,9 +1711,9 @@ class OmniGPUModelRunner(GPUModelRunner): # Overlay custom prompt_embeds per request for the prompt portion; # collect additional_information (tensor/list) for prefill portion only - decode_req_ids = [] - decode_start_offsets = [] - decode_batch_items = [] + decode_req_ids: list[str] = [] + decode_start_offsets: list[int] = [] + decode_batch_items: list[tuple[str, int, dict[str, Any]]] = [] batch_decode_preprocess = getattr(self.model, "preprocess_decode_batch", None) def flush_decode_batch() -> None: @@ -1717,6 +1721,7 @@ class OmniGPUModelRunner(GPUModelRunner): if not decode_batch_items: return + assert callable(batch_decode_preprocess) req_ids_b = [item[0] for item in decode_batch_items] start_offsets_b = [item[1] for item in decode_batch_items] req_infos_b = [item[2] for item in decode_batch_items] @@ -1761,7 +1766,7 @@ class OmniGPUModelRunner(GPUModelRunner): # mimo-audio check req_state = self.requests.get(req_id) - req_infos = self._maybe_attach_mimo_audio_req_infos(req_state, req_infos, req_id) + req_infos = cast(dict[str, Any], self._maybe_attach_mimo_audio_req_infos(req_state, req_infos, req_id)) start_offset = int(self.query_start_loc.cpu[req_index]) sched_tokens = int(num_scheduled_tokens_np[req_index]) @@ -1968,11 +1973,29 @@ class OmniGPUModelRunner(GPUModelRunner): if start_offsets is None: id_to_index = self.input_batch.req_id_to_index start_offsets = [int(self.query_start_loc.cpu[id_to_index[req_id]]) for req_id in decode_req_ids] - for idx, (req_id, start_offset) in enumerate(zip(decode_req_ids, start_offsets, strict=True)): - inputs_embeds[start_offset : start_offset + 1] = req_embeds[idx : idx + 1] - if code_predictor_codes is not None: - update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}} - self._update_intermediate_buffer(req_id, update_dict) + if start_offsets == list(range(decode_batch_size)): + inputs_embeds[:decode_batch_size].copy_(req_embeds[:decode_batch_size]) + else: + # A device tensor constructor waits for the preceding MTP graph. + # Enqueue the small host index transfer without synchronizing it. + offsets = torch.tensor(start_offsets, device="cpu", dtype=torch.long).to( + inputs_embeds.device, non_blocking=True + ) + inputs_embeds.index_copy_(0, offsets, req_embeds[:decode_batch_size]) + if code_predictor_codes is not None: + if out_key in getattr(self.model, "gpu_resident_buffer_keys", set()): + # One owned batch snapshot protects all rows from graph reuse. + owned_codes = code_predictor_codes[:decode_batch_size].detach().clone() + for req_id, row in zip(decode_req_ids, owned_codes.split(1), strict=True): + req_state = self.requests.get(req_id) + if req_state is not None: + existing = self.model_intermediate_buffer.setdefault(req_id, {}) + existing.setdefault(out_key[0], {})[out_key[1]] = row + req_state.additional_information_cpu = existing + else: + for idx, req_id in enumerate(decode_req_ids): + update_dict = {out_key[0]: {out_key[1]: code_predictor_codes[idx : idx + 1]}} + self._update_intermediate_buffer(req_id, update_dict) def _model_forward( self, From 51cf142bc1dfa372f2c61cfee44535f61afd93de Mon Sep 17 00:00:00 2001 From: Joshna-Medisetty Date: Wed, 9 Sep 2026 15:11:31 -0700 Subject: [PATCH 82/90] [Bugfix][XPU] Restore N-D output shape for W8A16 FP8 linear (#7301) Signed-off-by: Joshna Medisetty Signed-off-by: Joshna-Medisetty --- .../quantization/test_quantization_fp8.py | 3 +- vllm_omni/platforms/xpu/patch.py | 41 +++++++++++++++++++ vllm_omni/platforms/xpu/platform.py | 5 +++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 vllm_omni/platforms/xpu/patch.py diff --git a/tests/diffusion/quantization/test_quantization_fp8.py b/tests/diffusion/quantization/test_quantization_fp8.py index 0da5b6dd3..5793ad95c 100644 --- a/tests/diffusion/quantization/test_quantization_fp8.py +++ b/tests/diffusion/quantization/test_quantization_fp8.py @@ -300,7 +300,8 @@ def test_single_stage_zimage_fp8_uses_less_memory(): assert mem_fp8 < mem_bf16, f"FP8 ({mem_fp8:.2f} GiB) should use less memory than BF16 ({mem_bf16:.2f} GiB)" -@hardware_test(res={"cuda": "L4"}) +@hardware_test(res={"cuda": "L4", "xpu": "B60"}) +@pytest.mark.advanced_model def test_single_stage_qwen_image_fp8(): """Qwen-Image (random weights) with FP8 generates valid images.""" model = "riverclouds/qwen_image_random" diff --git a/vllm_omni/platforms/xpu/patch.py b/vllm_omni/platforms/xpu/patch.py new file mode 100644 index 000000000..841cf2503 --- /dev/null +++ b/vllm_omni/platforms/xpu/patch.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""XPU-specific patches for upstream vLLM behavior.""" + +from vllm.logger import init_logger + +logger = init_logger(__name__) + + +def _patch_w8a16_fp8_nd_activation() -> None: + """Restore the N-D output shape for the XPU W8A16 FP8 linear kernel. + + ``XPUW8A16FP8LinearKernel.apply_weights`` hands its activation straight to + ``torch.ops._xpu_C.fp8_gemm_w8a16``, skipping the flatten-then-reshape every + other ScaledMM kernel does. The op's registered fake always returns 2-D, so a + 3-D activation makes inductor bake in a 2-D ``assert_size_stride`` that the + rank-3 result trips, killing FLUX.1 ``--quantization fp8`` at startup warmup. + Only >2-D inputs are affected, so LLM (token-flattened, 2-D) paths are not. + """ + try: + from vllm.model_executor.kernels.linear.scaled_mm.xpu import ( + XPUW8A16FP8LinearKernel, + ) + except ImportError: + logger.debug("XPUW8A16FP8LinearKernel not available; skipping FP8 shape patch.") + return + + _original_apply_weights = XPUW8A16FP8LinearKernel.apply_weights + + def _patched_apply_weights(self, layer, x, bias=None): + output_shape = (*x.shape[:-1], layer.weight.shape[1]) + x = x.reshape(-1, x.shape[-1]) + return _original_apply_weights(self, layer, x, bias).view(*output_shape) + + XPUW8A16FP8LinearKernel.apply_weights = _patched_apply_weights + + +def apply_patches() -> None: + """Apply all XPU-specific patches.""" + _patch_w8a16_fp8_nd_activation() diff --git a/vllm_omni/platforms/xpu/platform.py b/vllm_omni/platforms/xpu/platform.py index 3d2cd2d62..df4e7fd55 100644 --- a/vllm_omni/platforms/xpu/platform.py +++ b/vllm_omni/platforms/xpu/platform.py @@ -11,6 +11,7 @@ from vllm.platforms.xpu import XPUPlatform from vllm_omni.diffusion.attention.backends.registry import DiffusionAttentionBackendEnum from vllm_omni.platforms.interface import OmniPlatform, OmniPlatformEnum +from vllm_omni.platforms.xpu.patch import apply_patches logger = init_logger(__name__) @@ -31,6 +32,10 @@ class XPUOmniPlatform(OmniPlatform, XPUPlatform): _omni_enum = OmniPlatformEnum.XPU + def __init__(self): + super().__init__() + apply_patches() + @classmethod def get_omni_ar_worker_cls(cls) -> str: return "vllm_omni.platforms.xpu.worker.xpu_ar_worker.XPUARWorker" From faad918588e616ed073030710e653d4b1d3f3e79 Mon Sep 17 00:00:00 2001 From: Guangjian Dong <163994576+Hiro208@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:00:13 +0800 Subject: [PATCH 83/90] [Doc] Document num_outputs_per_prompt for /v1/videos (#7341) Signed-off-by: Guangjian --- docs/user_guide/examples/online_serving/text_to_video.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/user_guide/examples/online_serving/text_to_video.md b/docs/user_guide/examples/online_serving/text_to_video.md index 2f00d386b..894e2bf0e 100644 --- a/docs/user_guide/examples/online_serving/text_to_video.md +++ b/docs/user_guide/examples/online_serving/text_to_video.md @@ -199,12 +199,21 @@ curl -X POST http://localhost:8091/v1/videos \ | `boundary_ratio` | float | None | Boundary split ratio for low/high DiT (Wan2.2) | | `flow_shift` | float | None | Scheduler flow shift (Wan2.2) | | `seed` | int | None | Random seed (reproducible) | +| `num_outputs_per_prompt` | int | 1 | Number of videos to generate (1-10, MiniMax H3); only the first is returned, see below | | `lora` | object | None | LoRA configuration | | `enable_frame_interpolation` | bool | false | Enable RIFE frame interpolation before MP4 encoding | | `frame_interpolation_exp` | int | 1 | Interpolation exponent; 1=2x temporal resolution, 2=4x | | `frame_interpolation_scale` | float | 1.0 | RIFE inference scale; use 0.5 for high-resolution inputs | | `frame_interpolation_model_path` | str | None | Local directory or Hugging Face repo ID with `flownet.pkl`; defaults to `elfgum/RIFE-4.22.lite` | +!!! note "Only the first output is returned" + `num_outputs_per_prompt` is forwarded to the pipeline, so the model does + generate that many videos and you pay the generation cost for all of them. + `/v1/videos` and `/v1/videos/sync` then return only the **first** one, + because an async video job stores a single file per video id. The server + logs a warning when it discards the extra outputs. Keep this at `1` + unless you have a specific reason to generate videos you will not receive. + ## Frame Interpolation Frame interpolation is an optional post-processing step for `/v1/videos` and From 06374543012ea0e10b970081a81097762acff78e Mon Sep 17 00:00:00 2001 From: Hongsheng Liu Date: Thu, 10 Sep 2026 08:05:47 +0800 Subject: [PATCH 84/90] [Skills] Add perf-evidence isolation, stage-attribution, and realtime-contract requirements (#6820) Signed-off-by: hsliu_ustc Co-authored-by: hsliu_ustc --- .claude/skills/review-pr/SKILL.md | 11 +- .../references/checks/perf-verification.md | 107 ++++++++++++++++++ .../references/process/general-checks.md | 13 +++ .../references/process/review-execution.md | 14 +-- 4 files changed, 133 insertions(+), 12 deletions(-) diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md index 0b19b2b37..5907e13f3 100644 --- a/.claude/skills/review-pr/SKILL.md +++ b/.claude/skills/review-pr/SKILL.md @@ -123,10 +123,11 @@ it changes again, report the churn and wait for a stable target. For a trusted PR head, materialize the pinned head in an isolated detached worktree. A worktree freezes identity but is not a security sandbox. Treat fork -heads as untrusted unless the user and environment policy explicitly establish -otherwise: execute them only in a disposable, secret-free sandbox with restricted -filesystem, network, and resources; without one, use static SHA-addressed reads -and CI evidence only. For a local review, freeze the committed, index, worktree, +heads as untrusted until the user and environment policy explicitly establish +trust: execute their code only after that trust is recorded for this host, +with the trusted SHA noted in the review state and secrets kept out of the +execution scope; without recorded trust, use static SHA-addressed reads and CI +evidence only. For a local review, freeze the committed, index, worktree, and NUL-safe in-scope untracked contents. Follow [review-execution.md](references/process/review-execution.md) for trust gates, state fingerprints, and byte-for-byte staleness checks. @@ -196,7 +197,7 @@ style preferences as simplification findings. Before each validation group, verify the frozen SHA plus the tracked, index, untracked, and ignored-file fingerprint, or recreate a pristine snapshot. On a -trusted head or inside the required sandbox, run an import/version preflight, +head the user explicitly trusted for this host, run an import/version preflight, then the narrowest relevant tests and low-cost static checks. Bind every result to the head SHA, snapshot fingerprint, and environment fingerprint. Never run imports, tests, builds, hooks, or repo-configurable tooling from an untrusted diff --git a/.claude/skills/review-pr/references/checks/perf-verification.md b/.claude/skills/review-pr/references/checks/perf-verification.md index bbfe5a147..027e91589 100644 --- a/.claude/skills/review-pr/references/checks/perf-verification.md +++ b/.claude/skills/review-pr/references/checks/perf-verification.md @@ -22,6 +22,19 @@ Keep exact commands and report variability rather than a cherry-picked run. Use the repository's benchmark when it covers the claim; otherwise use the smallest reproducible workload that reaches the changed production path. +## Isolation per claim + +Isolate one claim per comparison. When a PR stacks several changes (a fused or +restructured load path, a distilled step schedule, a new cache), request one +column per claim at equal steps/workload instead of one blended base-vs-head +delta, and name the unchanged stage or metric that pins equivalence. + +When the changed path is runtime-switchable (feature flag or environment +variable), run every column on the same head and toggle the switch rather than +diffing against a base commit that cannot separate the stacked changes: +base, head with the switch off, head with the switch on, then head with the +switch on plus each further claim. + ## Evidence table | Dimension | Typical evidence | @@ -34,6 +47,100 @@ smallest reproducible workload that reaches the changed production path. Every optimization needs correctness or quality evidence. A faster result on a different workload, precision, seed, or topology is not a valid comparison. +## E2e stage-attribution table + +For an end-to-end latency claim, request the stage-attribution table the +repository's perf PRs use: + +- Rows carry the benchmark's own field names: total e2e, per-stage execution, + postprocess, response encoding, and a request-to-output roll-up. Add a stage + handoff/idle row when the pipeline spans stages — connector transfer and + scheduling gaps are invisible in per-stage latency deltas. +- Sanity rows pin comparability: inference steps, output frames/pixels/samples, + and generated tokens held identical across columns. +- Report P50 and P100 from the stated warmup and measured counts. One + population per number: never mix cold-start and warm samples into one value. +- Name the baseline and candidate commit SHAs, and close with an isolation + statement naming the metric that stayed unchanged ("X remained effectively + unchanged, isolating the gain to Y"). + +When the serving path caches, captures, or regenerates at runtime (CUDA-graph +bucket growth, cache rebuilds, recompilation), medians hide the resulting tail. +Also request the event count and the maximum observed stall. + +Component-level tables pair with, not replace, the e2e table: per-op reference +vs candidate timings (with the numerical-equality result per op) plus one +complete-pipeline check under the production shapes. + +## Proportionality + +When a PR adds a model and explicitly labels its numbers as smoke observations +rather than claims, do not demand the attribution machinery. Ask only for +evidence protecting the model's stated operating contract — for a +realtime/duplex model that is sustained cadence over a realistic session +(enough ticks to leave warmup), with any mid-session capture/regeneration +event and its stall reported, since a cadence that collapses mid-session is a +functional failure, not a perf claim. + +## Realtime/duplex world models + +Request the operating-contract evidence as serving metrics, not claims: + +- Sustained per-tick latency over one realistic session: median, max, and the + argmax tick. +- Stall events with their causes. +- Peak memory at first versus last tick — session-owned state must not grow + per tick. +- Control latency from a submitted action to the tick that applies it. +- Realtime-versus-offline parity under the same controls, using the + repository's video-similarity helpers. Parity plotted over tick index is the + drift-over-horizon curve and the honest measure of session lifetime; + distribution-level suites (FVD, VBench) belong to model evaluation, not the + serving integration. + +Watch the contract boundaries the model documents: if a control input is +tick-API-only, the parity pair must use a horizon both paths can run. + +## Speech-generation (TTS) models + +The streaming contract outranks the average — a synthesis that pauses +mid-stream is broken for realtime use regardless of its averages. Request: + +- The benchmark's own speech metrics over one long utterance (enough audio to + leave warmup, tens of seconds): `audio_ttfp` (time to first packet), + `audio_rtf` (real-time factor; above 1.0 cannot keep up), `e2el`, and + `ttft`/`tpot` where tokens precede audio. +- Mid-utterance stalls as the benchmark's continuity fields, not prose: + `audio_underrun` percentiles and the `audio_continuity_ok` rate + (`vllm_omni/benchmarks/metrics/metrics.py`), computed by + `vllm_omni/benchmarks/audio_continuity.py` — the companion to `audio_ttfp` + and `audio_rtf`. A synthesis that bursts chunks fast enough on average but + underruns mid-stream fails the streaming contract even when the averages + pass. +- Quality as a paired comparison against the reference implementation under + the same text, speaker prompt, and seed; report sample rate and frame + count, and assert output completeness — truncated or dropped trailing audio + is a known failure class, not a quality nuance. +- For realtime or duplex TTS: interruption (barge-in) latency and the + per-session memory trend across turns. + +## Omni models + +Apply the e2e stage-attribution table directly to the voice-to-voice path of +omni (speech-to-speech and multimodal AR) models: + +- TTFT for text output, `audio_ttfp` for audio output, and per-stage + ITL/TPOT cadence across the perception-understanding-generation chain. +- Cross-modal parity — the omni analogue of realtime-versus-offline parity: + the same semantic request through text and audio inputs, compared on output + content or timing. +- For duplex omni sessions: turn-taking latency, interruption handling, the + concurrent session count actually validated, and a per-turn memory trend — + session-owned KV state must not grow per turn. When either turn produces + audio, continuity is judged with the same `audio_underrun` and + `audio_continuity_ok` fields as TTS, since turn-taking latency alone does + not expose mid-utterance underruns. + ## Reviewer verification ladder 1. Run base/head A/B on suitable hardware when affordable. diff --git a/.claude/skills/review-pr/references/process/general-checks.md b/.claude/skills/review-pr/references/process/general-checks.md index 88dfd405e..da86eabc7 100644 --- a/.claude/skills/review-pr/references/process/general-checks.md +++ b/.claude/skills/review-pr/references/process/general-checks.md @@ -121,6 +121,19 @@ Search bounded callers and sibling implementations before reporting dead code, duplication, or a missed consumer. Keep new abstractions only when they have a distinct live caller, invariant, or compatibility need. +Verify absence and equality claims with whole-file evidence. A line window, a +grep for one symbol, or a commit message proves presence, never absence: to +assert that a file matches another version or lacks a change, diff or hash the +full files (for example `git diff : :` or the contents +API's size and SHA fields) before anchoring the finding. A verification +shortcut that would itself be wrong to run is not evidence. + +For a value written or finalized in more than one place, establish which site +executes first on each entry path — and whether the earlier site makes the +later ones unreachable — before naming where a fix belongs. Enumerating all +call sites correctly still misses the bug when a path the enumeration ranked +last actually finalizes first and clears the state the proposed fix checks. + ## Finding bar Anchor each finding to a changed `path:line`; name the trigger or call path, diff --git a/.claude/skills/review-pr/references/process/review-execution.md b/.claude/skills/review-pr/references/process/review-execution.md index 7c0305254..c78532e9d 100644 --- a/.claude/skills/review-pr/references/process/review-execution.md +++ b/.claude/skills/review-pr/references/process/review-execution.md @@ -39,13 +39,13 @@ isolated detached worktree. Run every source read, `rg` search, import, and test from that snapshot, not the caller's checkout. A detached worktree provides snapshot isolation, not a security boundary. -Treat a fork head as untrusted unless the user and environment policy explicitly -establish trust. Never run its imports, tests, builds, hooks, package setup, or -repo-configurable linters/plugins on the reviewer host. Execute them only in a -disposable sandbox or VM with no credentials or inherited secrets, no host agent -or service sockets, minimal read-only host mounts, disabled or explicitly -allowlisted network, resource/time limits, and destruction after the run. If -that boundary is unavailable, limit the review to the remote diff, SHA-addressed +Treat a fork head as untrusted until the user and environment policy +explicitly establish trust. Without recorded trust, never run its imports, +tests, builds, hooks, package setup, or repo-configurable linters/plugins on +the reviewer host. Once the user explicitly trusts this pinned commit for +this host, record the trusted SHA with the review state and execute with +credentials, agent sockets, and other secrets out of scope. Without recorded +trust, limit the review to the remote diff, SHA-addressed reads such as `git show :`, and existing CI evidence; report all executable validation as a gap. From 6e63ea875e9a54f640b52478a567b9393df95892 Mon Sep 17 00:00:00 2001 From: Gao Han Date: Thu, 10 Sep 2026 09:54:20 +0800 Subject: [PATCH 85/90] [Bugfix] Allow LLM replicas on different GPUs to initialize concurrently (#7292) Signed-off-by: Gao Han Co-authored-by: amy-why-3459 --- .../test_stage_runtime_launch_concurrency.py | 149 ++++++++++++++++++ vllm_omni/engine/stage_runtime.py | 63 +++----- 2 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 tests/engine/test_stage_runtime_launch_concurrency.py diff --git a/tests/engine/test_stage_runtime_launch_concurrency.py b/tests/engine/test_stage_runtime_launch_concurrency.py new file mode 100644 index 000000000..f880b9386 --- /dev/null +++ b/tests/engine/test_stage_runtime_launch_concurrency.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Check replica startup ordering without model-loading timing assumptions.""" + +import contextlib +import fcntl +import os +import threading +from collections.abc import Iterator +from pathlib import Path + +import pytest +from pytest_mock import MockerFixture +from vllm.config import VllmConfig +from vllm.sampling_params import SamplingParams +from vllm.v1.engine.utils import CoreEngineProcManager, EngineZmqAddresses + +from vllm_omni.config.stage_config import StageConfig +from vllm_omni.engine import stage_init_utils, stage_runtime +from vllm_omni.engine.stage_engine_startup import StageReplicaResources +from vllm_omni.engine.stage_init_utils import LogicalStageInitPlan, ReplicaInitPlan, StageMetadata + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@pytest.fixture +def launch_runtime( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, mocker: MockerFixture +) -> Iterator[stage_runtime.StageRuntime]: + runtime = stage_runtime.StageRuntime( + stage_configs=[], + model="dummy-model", + config_path="dummy-config", + stage_init_timeout=5, + async_chunk=False, + ) + # These are virtual device IDs: only the real per-device file locking and + # scheduling run. No model, CUDA context, or engine process is created. + monkeypatch.setattr(runtime, "_resolve_replica_physical_devices", lambda _stage, cfg: cfg["devices"]) + monkeypatch.setattr( + stage_init_utils, "device_init_lock_path", lambda device: str(tmp_path / f"device-{device}.lock") + ) + mocker.patch.object( + stage_runtime.StageEngineCoreClientBase, + "make_async_mp_client", + return_value=mocker.Mock(spec=stage_runtime.StageClient), + ) + monkeypatch.setenv("VLLM_OMNI_TEST_LAUNCH_ENV", "parent") + yield runtime + if runtime._stage_init_executor is not None: + runtime._stage_init_executor.shutdown(wait=True) + + +def _plan(stage_id: int, device: str) -> LogicalStageInitPlan: + metadata = StageMetadata( + stage_id=stage_id, + stage_type="llm", + engine_output_type="text", + is_comprehension=False, + requires_multimodal_data=False, + engine_input_source=[], + final_output=False, + final_output_type=None, + default_sampling_params=SamplingParams(), + custom_process_input_func=None, + model_stage="dummy", + runtime_cfg={"devices": device, "env": {"VLLM_OMNI_TEST_LAUNCH_ENV": str(stage_id)}}, + ) + replica = ReplicaInitPlan( + replica_id=0, + num_replicas=1, + launch_mode="local", + stage_cfg=StageConfig(stage_id=stage_id, model_stage="dummy", yaml_runtime=metadata.runtime_cfg), + metadata=metadata, + stage_connector_spec={}, + omni_kv_connector=(None, None, None), + stage_vllm_config=VllmConfig(), + executor_class=object, + engine_args_dict={}, + ) + return LogicalStageInitPlan(stage_idx=stage_id, stage_id=stage_id, replicas=[replica]) + + +def _resources(mocker: MockerFixture) -> StageReplicaResources: + return StageReplicaResources( + manager=mocker.Mock(spec=CoreEngineProcManager), + addresses=EngineZmqAddresses(inputs=["unused-input"], outputs=["unused-output"]), + ) + + +def test_distinct_devices_spawn_before_either_is_ready( + launch_runtime: stage_runtime.StageRuntime, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + spawned = [threading.Event(), threading.Event()] + + @contextlib.contextmanager + def launch(*, stage_id: int, stage_visible_devices: str, **_kwargs: object) -> Iterator[StageReplicaResources]: + assert os.environ["VLLM_OMNI_TEST_LAUNCH_ENV"] == str(stage_id) + spawned[stage_id].set() + yield _resources(mocker) + # The device remains exclusively locked throughout loading/profiling, + # even though another device's replica must be able to start. + with open(stage_init_utils.device_init_lock_path(int(stage_visible_devices)), "r+") as lock: + with pytest.raises(BlockingIOError): + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + assert spawned[1 - stage_id].wait(5), "A different device's spawn was blocked by the READY wait" + + monkeypatch.setattr(stage_runtime, "launch_stage_replica", launch) + clients = launch_runtime._initialize_stage_replicas([_plan(0, "0"), _plan(1, "1")], 5) + assert all(clients[stage][0] is not None for stage in (0, 1)) + assert os.environ["VLLM_OMNI_TEST_LAUNCH_ENV"] == "parent" + + +def test_same_device_waits_for_ready_before_next_spawn( + launch_runtime: stage_runtime.StageRuntime, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + events: list[tuple[int, str]] = [] + + @contextlib.contextmanager + def launch(*, stage_id: int, **_kwargs: object) -> Iterator[StageReplicaResources]: + events.append((stage_id, "spawn")) + yield _resources(mocker) + events.append((stage_id, "ready")) + + monkeypatch.setattr(stage_runtime, "launch_stage_replica", launch) + launch_runtime._initialize_stage_replicas([_plan(0, "0"), _plan(1, "0")], 5) + assert events == [(0, "spawn"), (0, "ready"), (1, "spawn"), (1, "ready")] + assert os.environ["VLLM_OMNI_TEST_LAUNCH_ENV"] == "parent" + + +def test_ready_failure_cleans_up_resources_and_releases_device( + launch_runtime: stage_runtime.StageRuntime, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture +) -> None: + manager = mocker.Mock(spec=CoreEngineProcManager) + resources = StageReplicaResources(manager=manager, addresses=EngineZmqAddresses(inputs=[], outputs=[])) + + @contextlib.contextmanager + def launch(**_kwargs: object) -> Iterator[StageReplicaResources]: + yield resources + raise RuntimeError("READY handshake failed") + + monkeypatch.setattr(stage_runtime, "launch_stage_replica", launch) + with pytest.raises(RuntimeError, match="READY handshake failed"): + launch_runtime._initialize_local_llm_replica(_plan(0, "0").replicas[0], 5) + + manager.shutdown.assert_called_once_with() + assert os.environ["VLLM_OMNI_TEST_LAUNCH_ENV"] == "parent" + with open(stage_init_utils.device_init_lock_path(0), "r+") as lock: + fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) diff --git a/vllm_omni/engine/stage_runtime.py b/vllm_omni/engine/stage_runtime.py index 5d7d07653..53400df5e 100644 --- a/vllm_omni/engine/stage_runtime.py +++ b/vllm_omni/engine/stage_runtime.py @@ -11,7 +11,7 @@ import os import threading import time from collections.abc import Callable, Iterator, Mapping, Sequence -from contextlib import contextmanager +from contextlib import ExitStack, contextmanager from dataclasses import dataclass from typing import Any, cast @@ -149,12 +149,9 @@ class StageRuntime: self.stage_pools: list[StagePool] = [] self._stage_init_executor: concurrent.futures.ThreadPoolExecutor | None = None self._spawn_device_lock = threading.Lock() - # Serialize all LLM replica spawning + handshake across device groups - # to prevent ZMQ port-allocation races (get_engine_zmq_addresses) and - # CUDA-context conflicts when multiple engine core subprocesses - # initialize simultaneously on different GPUs. Matches the old - # AsyncOmniEngine._initialize_llm_replica pattern which used a single - # ``llm_stage_launch_lock`` for all replicas. + # Serialize process spawning and process-global environment overlays. + # Readiness waits use per-device initialization protection so different + # device groups can initialize concurrently. self._replica_launch_lock = threading.Lock() self._init_visible_devices_baseline: str | None = None @@ -778,43 +775,25 @@ class StageRuntime: spawn_device_lock=self._spawn_device_lock, omni_parallel_stage_init=self._parallel_stage_init, ) - # G2 launch lock serializes engine-core *spawning* across replicas - # (ZMQ port-allocation races + simultaneous CUDA context init). - # * Default path: hold it across the whole launch context manager, - # whose __exit__ waits for READY — this serializes the full child - # init (spawn + load + profile + KV + capture). - # * Parallel path: hold it only around the spawn (__enter__); run - # the READY-wait (__exit__) outside the lock so replicas init - # concurrently, coordinated by the child SH/EX device locks. - if self._parallel_stage_init: - # The per-stage runtime.env overlay mutates os.environ - # (process-global), so it must be applied under the launch lock - # and only needs to cover the spawn (__enter__) — children - # inherit the env at spawn time; the READY-wait needs no env. - g2_start = time.perf_counter() + # Only process spawning and the runtime.env overlay need the global + # launch lock. The launch context's exit waits for READY: holding the + # lock there serializes model loading and compilation even across + # different GPUs. Default initialization keeps its per-device EX + # locks until READY; parallel_stage_init uses child phase locks. + g2_start = time.perf_counter() + with ExitStack() as launch_stack: with self._replica_launch_lock: - with stage_runtime_env(plan.metadata.stage_id, plan.metadata.runtime_cfg): - resources = launch_cm.__enter__() - g2_spawned = time.perf_counter() - launch_cm.__exit__(None, None, None) - logger.debug( - "[stage_init] Stage-%s G2 spawn(locked)=%.3fs, READY(unlocked)=%.3fs", - plan.metadata.stage_id, - g2_spawned - g2_start, - time.perf_counter() - g2_spawned, - ) - else: - g2_start = time.perf_counter() - with self._replica_launch_lock, stage_runtime_env(plan.metadata.stage_id, plan.metadata.runtime_cfg): g2_locked = time.perf_counter() - with launch_cm as resources: - pass - logger.debug( - "[stage_init] Stage-%s G2 launch-lock wait=%.3fs, spawn+READY=%.3fs", - plan.metadata.stage_id, - g2_locked - g2_start, - time.perf_counter() - g2_locked, - ) + with stage_runtime_env(plan.metadata.stage_id, plan.metadata.runtime_cfg): + resources = launch_stack.enter_context(launch_cm) + g2_spawned = time.perf_counter() + logger.debug( + "[stage_init] Stage-%s G2 launch-lock wait=%.3fs, spawn=%.3fs, READY=%.3fs", + plan.metadata.stage_id, + g2_locked - g2_start, + g2_spawned - g2_locked, + time.perf_counter() - g2_spawned, + ) logger.info("[StageRuntime] Stage %s engine startup completed", plan.metadata.stage_id) if resources is None: From 3d71a2b3adb1a556d656535795c66295c1b05855 Mon Sep 17 00:00:00 2001 From: andyluo7 <43718156+andyluo7@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:09:39 -0700 Subject: [PATCH 86/90] [CI/Build] Stabilize LTX2 vocoder autocast test on ROCm (#7336) Signed-off-by: andyluo7 --- .../models/ltx2/test_ltx2_vocoder_cuda.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/diffusion/models/ltx2/test_ltx2_vocoder_cuda.py b/tests/diffusion/models/ltx2/test_ltx2_vocoder_cuda.py index d3d885d57..aa022ed61 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_vocoder_cuda.py +++ b/tests/diffusion/models/ltx2/test_ltx2_vocoder_cuda.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project -"""CUDA regression tests for LTX-2 BWE vocoder precision.""" +"""GPU regression tests for LTX-2 BWE vocoder precision.""" import pytest import torch @@ -14,10 +14,13 @@ pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] class _BWEConvVocoder(torch.nn.Module): def __init__(self): super().__init__() + # Match the production LTX vocoder's conv_pre geometry. The previous + # single-channel 1x1 probe intermittently had no MIOpen algorithm. self.conv = torch.nn.Conv1d( - 1, - 1, - kernel_size=1, + 128, + 1024, + kernel_size=7, + padding=3, bias=False, device="cuda", dtype=torch.bfloat16, @@ -33,12 +36,12 @@ class _BWEConvVocoder(torch.nn.Module): return output -@hardware_test(res={"cuda": "L4"}, num_cards=1) +@hardware_test(res={"cuda": "L4", "rocm": "MI325"}, num_cards=1) def test_ltx_bwe_vocoder_runs_real_cuda_autocast_in_fp32(): from vllm_omni.diffusion.models.ltx2.ltx2_runtime import _run_ltx_vocoder vocoder = _BWEConvVocoder() - generated_mel = torch.ones((1, 1, 4), device="cuda", dtype=torch.bfloat16) + generated_mel = torch.ones((1, 128, 64), device="cuda", dtype=torch.bfloat16) output = _run_ltx_vocoder(vocoder, generated_mel) From 35a83a08850f4184e683a82201156038be5ae725 Mon Sep 17 00:00:00 2001 From: Weiming Liao Date: Thu, 10 Sep 2026 10:16:14 +0800 Subject: [PATCH 87/90] [NPU][CI] Add A5 and 310P CI support (#6875) Signed-off-by: Weiming Liao Co-authored-by: wangyu <53896905+yenuo26@users.noreply.github.com> --- .buildkite/common/ci_mirror_hardwares.yml | 51 +++++ .buildkite/common/scripts/upload_pipeline.py | 10 +- .buildkite/npu/bootstrap-upload-steps.yml | 134 +++--------- .buildkite/npu/scripts/build-npu-image.sh | 48 +++++ .buildkite/npu/test-npu-ready.yml | 75 +++++-- docker/Dockerfile.npu | 20 +- docker/Dockerfile.npu.a3 | 36 ---- docker/Dockerfile.npu.ci | 20 +- docker/Dockerfile.npu.ci.a3 | 72 ------- docs/contributing/ci/ci_settings.md | 9 +- docs/contributing/ci/test_writing_guide.md | 4 +- .../installation/npu/npu.inc.md | 198 ++++++++++++++---- pyproject.toml | 2 + tests/diffusion/layers/test_activation.py | 16 +- 14 files changed, 387 insertions(+), 308 deletions(-) create mode 100644 .buildkite/npu/scripts/build-npu-image.sh delete mode 100644 docker/Dockerfile.npu.a3 delete mode 100644 docker/Dockerfile.npu.ci.a3 diff --git a/.buildkite/common/ci_mirror_hardwares.yml b/.buildkite/common/ci_mirror_hardwares.yml index e3c9a427a..d638ecb8d 100644 --- a/.buildkite/common/ci_mirror_hardwares.yml +++ b/.buildkite/common/ci_mirror_hardwares.yml @@ -17,6 +17,8 @@ # a2b3_npu_1 — ascend-a2b3, resource_class npu-1 (A2/B3 CI image) # a2b3_npu_4 — ascend-a2b3, resource_class npu-4 # a3_npu_2 — ascend-a3, resource_class npu-2 (A3 CI image tag …:$BUILDKITE_COMMIT-a3) +# a5_npu_2 — ascend-a5, resource_class npu-2 +# 310p_npu_1 — ascend-310p, resource_class npu-1 # # B200 (b200-k8s): matches vLLM device ``b200-k8s`` from ci-infra # (AgentQueue.B200_K8S + b200_plugin_template). Queue routes the K8s pool; @@ -646,3 +648,52 @@ mirror_hardwares: resource_class: "npu-16" image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a3" plugins: *npu_k8s_plugins + + a5_npu_2: + agents: + queue: "ascend-a5" + resource_class: "npu-2" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a5" + plugins: *npu_k8s_plugins + + a5_npu_4: + agents: + queue: "ascend-a5" + resource_class: "npu-4" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a5" + plugins: *npu_k8s_plugins + + a5_npu_8: + agents: + queue: "ascend-a5" + resource_class: "npu-8" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a5" + plugins: *npu_k8s_plugins + + a5_npu_16: + agents: + queue: "ascend-a5" + resource_class: "npu-16" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-a5" + plugins: *npu_k8s_plugins + + 310p_npu_1: + agents: + queue: "ascend-310p" + resource_class: "npu-1" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-310p" + plugins: *npu_k8s_plugins + + 310p_npu_2: + agents: + queue: "ascend-310p" + resource_class: "npu-2" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-310p" + plugins: *npu_k8s_plugins + + 310p_npu_4: + agents: + queue: "ascend-310p" + resource_class: "npu-4" + image: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry/vllm-omni-ci-npu:${BUILDKITE_COMMIT}-310p" + plugins: *npu_k8s_plugins diff --git a/.buildkite/common/scripts/upload_pipeline.py b/.buildkite/common/scripts/upload_pipeline.py index 53da6f660..046b136d7 100644 --- a/.buildkite/common/scripts/upload_pipeline.py +++ b/.buildkite/common/scripts/upload_pipeline.py @@ -70,7 +70,15 @@ from tests.helpers.mark import ( # noqa: E402 LOG = "upload_pipeline" BOOTSTRAP_STEPS_FILENAME = "bootstrap-upload-steps.yml" -BOOTSTRAP_IMAGE_BUILD_KEYS = frozenset({"image-build", "image-build-a2", "image-build-a3"}) +BOOTSTRAP_IMAGE_BUILD_KEYS = frozenset( + { + "image-build", + "image-build-a2", + "image-build-a3", + "image-build-a5", + "image-build-310p", + } +) BOOTSTRAP_UPLOAD_IF_KEYS = { "upload-ready-pipeline": "ready", "upload-merge-pipeline": "merge", diff --git a/.buildkite/npu/bootstrap-upload-steps.yml b/.buildkite/npu/bootstrap-upload-steps.yml index 64b97f895..398fbc6dc 100644 --- a/.buildkite/npu/bootstrap-upload-steps.yml +++ b/.buildkite/npu/bootstrap-upload-steps.yml @@ -3,10 +3,10 @@ steps: - label: ":buildkit: Build and Push NPU Test Image - A2" key: image-build-a2 - agents: + agents: &npu-image-build-agents queue: "ascend-a2b3" resource_class: "npu-1" - plugins: + plugins: &npu-image-build-plugins - kubernetes: metadata: annotations: @@ -46,123 +46,47 @@ steps: IMAGE_NAME: "vllm-omni-ci-npu" IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" - command: | - set -ex - - echo "--- Building and pushing NPU Test Image" - echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" - echo "buildkitd address: $${BUILDKITD_ADDR}" - - if ! command -v buildctl &> /dev/null; then - echo "Installing buildctl..." - mkdir -p /tmp/buildkit - BUILDKIT_VERSION="v0.29.0" - wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz - tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit - cp /tmp/buildkit/bin/buildctl /usr/local/bin/ - fi - - export DOCKER_CONFIG=/home/user/.docker - buildctl \ - --addr="$${BUILDKITD_ADDR}" \ - --tlscacert=/home/user/.docker/ca.pem \ - --tlscert=/home/user/.docker/cert.pem \ - --tlskey=/home/user/.docker/key.pem \ - build \ - --frontend dockerfile.v0 \ - --local context=. \ - --local dockerfile=./docker \ - --opt filename=Dockerfile.npu.ci \ - --secret id=dockerconfig,src=/home/user/.docker/config.json \ - --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ - --progress=plain - - echo "--- Image pushed successfully" - echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + command: bash .buildkite/npu/scripts/build-npu-image.sh Dockerfile.npu.ci "$${VLLM_IMAGE_TAG}" - label: ":buildkit: Build and Push NPU Test Image - A3" key: image-build-a3 - agents: - queue: "ascend-a2b3" - resource_class: "npu-1" - plugins: - - kubernetes: - metadata: - annotations: - vault.hashicorp.com/agent-init-first: "true" - vault.hashicorp.com/agent-inject: "true" - vault.hashicorp.com/agent-inject-perms-ca.pem: "0400" - vault.hashicorp.com/agent-inject-perms-cert.pem: "0400" - vault.hashicorp.com/agent-inject-perms-config.json: "0400" - vault.hashicorp.com/agent-inject-perms-key.pem: "0400" - vault.hashicorp.com/agent-inject-secret-ca.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-cert.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-config.json: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-secret-key.pem: internal/data/ascend/buildkitd - vault.hashicorp.com/agent-inject-template-ca.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.RootCA }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-cert.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaCert }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-config.json: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.dockerConfig }}\n{{- end }}" - vault.hashicorp.com/agent-inject-template-key.pem: "{{- with secret \"internal/data/ascend/buildkitd\" -}}\n{{ .Data.data.ClientCaKey }}\n{{- end }}" - vault.hashicorp.com/agent-pre-populate-only: "true" - vault.hashicorp.com/agent-run-as-group: "1000" - vault.hashicorp.com/agent-run-as-user: "1000" - vault.hashicorp.com/agent-service-account-token-volume-name: token-vol - vault.hashicorp.com/role: ascend-gha-runners - vault.hashicorp.com/secret-volume-path: /home/user/.docker/ - vault.hashicorp.com/tls-skip-verify: "true" - podSpecPatch: - volumes: - - name: token-vol - projected: - defaultMode: 420 - sources: - - serviceAccountToken: - audience: api - expirationSeconds: 600 - path: token + agents: *npu-image-build-agents + plugins: *npu-image-build-plugins env: VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}-a3" IMAGE_NAME: "vllm-omni-ci-npu" IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" - command: | - set -ex + command: bash .buildkite/npu/scripts/build-npu-image.sh Dockerfile.npu.ci "$${VLLM_IMAGE_TAG}" -a3 - echo "--- Building and pushing NPU Test Image" - echo "Image: $${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" - echo "buildkitd address: $${BUILDKITD_ADDR}" + - label: ":buildkit: Build and Push NPU Test Image - A5" + key: image-build-a5 + agents: *npu-image-build-agents + plugins: *npu-image-build-plugins + env: + VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}-a5" + IMAGE_NAME: "vllm-omni-ci-npu" + IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" + BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" + command: bash .buildkite/npu/scripts/build-npu-image.sh Dockerfile.npu.ci "$${VLLM_IMAGE_TAG}" -a5 - if ! command -v buildctl &> /dev/null; then - echo "Installing buildctl..." - mkdir -p /tmp/buildkit - BUILDKIT_VERSION="v0.29.0" - wget -q "https://github.com/moby/buildkit/releases/download/$${BUILDKIT_VERSION}/buildkit-$${BUILDKIT_VERSION}.linux-arm64.tar.gz" -O /tmp/buildkit.tar.gz - tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit - cp /tmp/buildkit/bin/buildctl /usr/local/bin/ - fi - - export DOCKER_CONFIG=/home/user/.docker - buildctl \ - --addr="$${BUILDKITD_ADDR}" \ - --tlscacert=/home/user/.docker/ca.pem \ - --tlscert=/home/user/.docker/cert.pem \ - --tlskey=/home/user/.docker/key.pem \ - build \ - --frontend dockerfile.v0 \ - --local context=. \ - --local dockerfile=./docker \ - --opt filename=Dockerfile.npu.ci.a3 \ - --secret id=dockerconfig,src=/home/user/.docker/config.json \ - --output type=image,name=$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG},push=true \ - --progress=plain - - echo "--- Image pushed successfully" - echo "$${IMAGE_REGISTRY}/$${IMAGE_NAME}:$${VLLM_IMAGE_TAG}" + - label: ":buildkit: Build and Push NPU Test Image - 310P" + key: image-build-310p + agents: *npu-image-build-agents + plugins: *npu-image-build-plugins + env: + VLLM_IMAGE_TAG: "${BUILDKITE_COMMIT}-310p" + IMAGE_NAME: "vllm-omni-ci-npu" + IMAGE_REGISTRY: "swr.cn-southwest-2.myhuaweicloud.com/modelfoundry" + BUILDKITD_ADDR: "tcp://buildkitd-service.buildkitd:1234" + command: bash .buildkite/npu/scripts/build-npu-image.sh Dockerfile.npu.ci "$${VLLM_IMAGE_TAG}" -310p - label: "Upload Ready Pipeline" depends_on: - image-build-a2 - image-build-a3 + - image-build-a5 + - image-build-310p key: upload-ready-pipeline agents: queue: "cpu_queue_premerge" @@ -174,6 +98,8 @@ steps: depends_on: - image-build-a2 - image-build-a3 + - image-build-a5 + - image-build-310p key: upload-nightly-pipeline commands: - python3 .buildkite/common/scripts/upload_pipeline.py --upload .buildkite/npu/test-npu-nightly.yml diff --git a/.buildkite/npu/scripts/build-npu-image.sh b/.buildkite/npu/scripts/build-npu-image.sh new file mode 100644 index 000000000..415fa7d5e --- /dev/null +++ b/.buildkite/npu/scripts/build-npu-image.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -ex + +dockerfile=${1:?Dockerfile is required} +image_tag=${2:?Image tag is required} +device_suffix=${3:-} + +image_name=${IMAGE_NAME:-vllm-omni-ci-npu} +image_registry=${IMAGE_REGISTRY:-swr.cn-southwest-2.myhuaweicloud.com/modelfoundry} +buildkitd_addr=${BUILDKITD_ADDR:-tcp://buildkitd-service.buildkitd:1234} + +echo "--- Building and pushing NPU Test Image" +echo "Image: ${image_registry}/${image_name}:${image_tag}" +echo "Dockerfile: ${dockerfile}" + +if ! command -v buildctl &> /dev/null; then + mkdir -p /tmp/buildkit + buildkit_version=v0.29.0 + wget -q \ + "https://github.com/moby/buildkit/releases/download/${buildkit_version}/buildkit-${buildkit_version}.linux-arm64.tar.gz" \ + -O /tmp/buildkit.tar.gz + tar -xzf /tmp/buildkit.tar.gz -C /tmp/buildkit + cp /tmp/buildkit/bin/buildctl /usr/local/bin/ +fi + +build_args=() +if [[ -n "${device_suffix}" ]]; then + build_args+=(--opt "build-arg:VLLM_ASCEND_DEVICE_SUFFIX=${device_suffix}") +fi + +export DOCKER_CONFIG=/home/user/.docker +buildctl \ + --addr="${buildkitd_addr}" \ + --tlscacert=/home/user/.docker/ca.pem \ + --tlscert=/home/user/.docker/cert.pem \ + --tlskey=/home/user/.docker/key.pem \ + build \ + --frontend dockerfile.v0 \ + --local context=. \ + --local dockerfile=./docker \ + --opt "filename=${dockerfile}" \ + "${build_args[@]}" \ + --secret id=dockerconfig,src=/home/user/.docker/config.json \ + --output "type=image,name=${image_registry}/${image_name}:${image_tag},push=true" \ + --progress=plain + +echo "--- Image pushed successfully" +echo "${image_registry}/${image_name}:${image_tag}" diff --git a/.buildkite/npu/test-npu-ready.yml b/.buildkite/npu/test-npu-ready.yml index 01fb32879..d475aad5b 100644 --- a/.buildkite/npu/test-npu-ready.yml +++ b/.buildkite/npu/test-npu-ready.yml @@ -1,28 +1,59 @@ steps: - - label: "Platforms Test" - key: ready-platforms-test + - group: ":card_index_dividers: Simple Test" depends_on: upload-ready-pipeline - mirror_hardwares: a3_npu_2 - commands: - - >- - pytest -s -v - tests/platforms/npu/test_minicpmo_code2wav_npugraph.py - -m 'core_model and npu and A3 and omni' - --run-level 'core_model' + steps: + - label: "Platforms Test" + key: ready-platforms-test + mirror_hardwares: a3_npu_2 + commands: + - >- + pytest -s -v + tests/platforms/npu/test_minicpmo_code2wav_npugraph.py + -m 'core_model and npu and A3 and omni' + --run-level 'core_model' - - label: "Diffusion NPU layer tests" - key: ready-diffusion-npu-layer-tests - depends_on: upload-ready-pipeline - mirror_hardwares: a3_npu_2 - commands: - - >- - pytest -s -v - tests/diffusion/layers/test_activation.py - tests/diffusion/layers/test_fused_qk_norm_rope_npu.py - tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py - tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py - -m 'core_model and npu and A3' - --run-level 'core_model' + - label: "Diffusion NPU layer tests - A2" + key: ready-diffusion-npu-layer-tests-a2 + mirror_hardwares: a2b3_npu_1 + commands: + - >- + pytest -s -v + tests/diffusion/layers/test_activation.py + -m 'core_model and npu and A2' + --run-level 'core_model' + + - label: "Diffusion NPU layer tests - A3" + key: ready-diffusion-npu-layer-tests + mirror_hardwares: a3_npu_2 + commands: + - >- + pytest -s -v + tests/diffusion/layers/test_activation.py + tests/diffusion/layers/test_fused_qk_norm_rope_npu.py + tests/diffusion/models/minimax_h3/test_minimax_h3_qwen3vl_swiglu.py + tests/diffusion/models/minimax_h3/test_minimax_h3_modulation.py + -m 'core_model and npu and A3' + --run-level 'core_model' + + - label: "Diffusion NPU layer tests - A5" + key: ready-diffusion-npu-layer-tests-a5 + mirror_hardwares: a5_npu_2 + commands: + - >- + pytest -s -v + tests/diffusion/layers/test_activation.py + -m 'core_model and npu and A5' + --run-level 'core_model' + + - label: "Diffusion NPU layer tests - 310P" + key: ready-diffusion-npu-layer-tests-310p + mirror_hardwares: 310p_npu_1 + commands: + - >- + pytest -s -v + tests/diffusion/layers/test_activation.py + -m 'core_model and npu and 310P' + --run-level 'core_model' - group: ":card_index_dividers: Omni Model Test" key: ready-omni-group diff --git a/docker/Dockerfile.npu b/docker/Dockerfile.npu index 3ebacdc98..0d91c0b32 100644 --- a/docker/Dockerfile.npu +++ b/docker/Dockerfile.npu @@ -1,20 +1,7 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.28.0 -FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} - -# WORKDIR /vllm-workspace/vllm -# RUN git fetch origin --tags && git checkout v0.18.0 - -# WORKDIR /vllm-workspace/vllm-ascend -# RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a -# # Install vllm-ascend -# # Append `libascend_hal.so` path (devlib) to LD_LIBRARY_PATH -# RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \ -# source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ -# source /usr/local/Ascend/nnal/atb/set_env.sh && \ -# export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ -# python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \ -# python3 -m pip cache purge +ARG VLLM_ASCEND_VERSION=v0.28.0 +ARG VLLM_ASCEND_DEVICE_SUFFIX= +FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_VERSION}${VLLM_ASCEND_DEVICE_SUFFIX} ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} @@ -24,6 +11,7 @@ RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ source /usr/local/Ascend/nnal/atb/set_env.sh && \ export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ + python3 -m pip install -v "mindiesd==3.1.0" && \ python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation RUN python3 -m pip install -v --no-deps \ diff --git a/docker/Dockerfile.npu.a3 b/docker/Dockerfile.npu.a3 deleted file mode 100644 index d341d456d..000000000 --- a/docker/Dockerfile.npu.a3 +++ /dev/null @@ -1,36 +0,0 @@ -ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.28.0-a3 -FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} - -# WORKDIR /vllm-workspace/vllm -# RUN git fetch origin --tags && git checkout v0.18.0 - -# WORKDIR /vllm-workspace/vllm-ascend -# RUN git fetch origin releases/v0.18.0 && git checkout d781902ce9dbda8ab1e11bb0f2f0c1bc508fee7a -# # Install vllm-ascend -# # Append `libascend_hal.so` path (devlib) to LD_LIBRARY_PATH -# RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \ -# source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ -# source /usr/local/Ascend/nnal/atb/set_env.sh && \ -# export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ -# python3 -m pip install -v -e /vllm-workspace/vllm-ascend/ --extra-index https://download.pytorch.org/whl/cpu/ && \ -# python3 -m pip cache purge - -ARG APP_DIR=/vllm-workspace/vllm-omni -WORKDIR ${APP_DIR} -COPY . . - -RUN export PIP_EXTRA_INDEX_URL=https://mirrors.huaweicloud.com/ascend/repos/pypi && \ - source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ - source /usr/local/Ascend/nnal/atb/set_env.sh && \ - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ - python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation - -RUN python3 -m pip install -v --no-deps \ - "stepaudio2-minicpmo>=0.1.1" \ - "step-audio2>=1.0.0" - -ENV VLLM_TARGET_DEVICE=npu -ENV VLLM_WORKER_MULTIPROC_METHOD=spawn - -ENTRYPOINT [] diff --git a/docker/Dockerfile.npu.ci b/docker/Dockerfile.npu.ci index 029a905fd..0a150fdd8 100644 --- a/docker/Dockerfile.npu.ci +++ b/docker/Dockerfile.npu.ci @@ -1,10 +1,10 @@ ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.28.0 -FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} +ARG VLLM_ASCEND_VERSION=v0.28.0 +ARG VLLM_ASCEND_DEVICE_SUFFIX= +FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_VERSION}${VLLM_ASCEND_DEVICE_SUFFIX} ARG APP_DIR=/vllm-workspace/vllm-omni WORKDIR ${APP_DIR} -COPY . . RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list @@ -22,11 +22,9 @@ RUN apt-get update && \ rm -rf /var/cache/apt/* && \ rm -rf /var/lib/apt/lists/* -RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ - source /usr/local/Ascend/nnal/atb/set_env.sh && \ - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ - python3 -m pip install -U pip && \ +RUN python3 -m pip install -U pip && \ python3 -m pip install -v \ + "mindiesd==3.1.0" \ "pytest>=7.0.0" \ "pytest-asyncio>=0.21.0" \ "pytest-cov>=4.0.0" \ @@ -48,7 +46,13 @@ RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ "datasets>=3.0.0" \ --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ - --default-timeout=60 && \ + --default-timeout=60 + +COPY . . + +RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ + source /usr/local/Ascend/nnal/atb/set_env.sh && \ + export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation \ --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ diff --git a/docker/Dockerfile.npu.ci.a3 b/docker/Dockerfile.npu.ci.a3 deleted file mode 100644 index fe3741d04..000000000 --- a/docker/Dockerfile.npu.ci.a3 +++ /dev/null @@ -1,72 +0,0 @@ -ARG VLLM_ASCEND_IMAGE=quay.io/atlas-ci/vllm-ascend -ARG VLLM_ASCEND_TAG=v0.28.0-a3 -FROM ${VLLM_ASCEND_IMAGE}:${VLLM_ASCEND_TAG} - -ARG APP_DIR=/vllm-workspace/vllm-omni -WORKDIR ${APP_DIR} -COPY . . - -RUN sed -i "s@^\(deb.*\)https\?://[a-z0-9.-]*\.ubuntu\.com@\1http://cache-service.nginx-pypi-cache.svc.cluster.local:8081@g" /etc/apt/sources.list - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - ffmpeg \ - libgl1 \ - libglib2.0-0 \ - libgomp1 \ - libsm6 \ - libxext6 \ - libxrender1 \ - libxcb1 \ - espeak-ng && \ - rm -rf /var/cache/apt/* && \ - rm -rf /var/lib/apt/lists/* - -RUN source /usr/local/Ascend/ascend-toolkit/set_env.sh && \ - source /usr/local/Ascend/nnal/atb/set_env.sh && \ - export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/Ascend/ascend-toolkit/latest/`uname -i`-linux/devlib && \ - python3 -m pip install -U pip && \ - python3 -m pip install -v \ - "pytest>=7.0.0" \ - "pytest-asyncio>=0.21.0" \ - "pytest-cov>=4.0.0" \ - "pytest-mock>=3.10.0" \ - "openpyxl>=3.0.0" \ - "psutil>=7.2.0" \ - "soundfile>=0.13.1" \ - "pyttsx3>=2.99" \ - "opencc==1.4.1" \ - "opencv-python>=4.12.0.88" \ - "imageio[ffmpeg]>=0.6.0" \ - "jiwer>=3.0.0" \ - "zhon>=2.0.0" \ - "zhconv>=1.4.2" \ - "funasr>=1.0.0" \ - "hyperpyyaml>=1.2.0" \ - "onnx>=1.16.0" \ - "librosa>=0.10.0" \ - "torchmetrics>=1.9.0" \ - "datasets>=3.0.0" \ - --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ - --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ - --default-timeout=60 && \ - python3 -m pip install -v -e /vllm-workspace/vllm-omni/ --no-build-isolation \ - --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ - --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ - --default-timeout=60 - -# MiniCPM-o 4.5 Stage-2 vocoder assets: s3tokenizer plus flashcosyvoice, which -# is shipped by step-audio2. --no-deps keeps pip from replacing the Ascend torch -# build and from shadowing onnxruntime-cann with the vanilla onnxruntime wheel; -# the deps these two actually need are installed above. -RUN python3 -m pip install -v --no-deps \ - "s3tokenizer>=0.3.0" \ - "step-audio2>=1.0.0" \ - --index-url http://cache-service.nginx-pypi-cache.svc.cluster.local/pypi/simple \ - --trusted-host cache-service.nginx-pypi-cache.svc.cluster.local \ - --default-timeout=60 - -ENV VLLM_TARGET_DEVICE=npu -ENV VLLM_WORKER_MULTIPROC_METHOD=spawn - -ENTRYPOINT [] diff --git a/docs/contributing/ci/ci_settings.md b/docs/contributing/ci/ci_settings.md index d578b4b02..885ebd528 100644 --- a/docs/contributing/ci/ci_settings.md +++ b/docs/contributing/ci/ci_settings.md @@ -129,7 +129,7 @@ Canonical layout (prefer these paths for new changes): === "NPU" - **Bootstrap:** [`npu/pipeline-npu.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/pipeline-npu.yml) + [`npu/bootstrap-upload-steps.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/bootstrap-upload-steps.yml)—same split as CUDA; builds A2/B3 and A3 CI images, then uploads child test pipelines. + **Bootstrap:** [`npu/pipeline-npu.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/pipeline-npu.yml) + [`npu/bootstrap-upload-steps.yml`](https://github.com/vllm-project/vllm-omni/blob/main/.buildkite/npu/bootstrap-upload-steps.yml)—same split as CUDA; builds A2/B3, A3, A5, and 310P CI images, then uploads child test pipelines. **Test YAML:** `npu/test-npu-ready.yml` (L2), `test-npu-nightly.yml` (L4). @@ -137,7 +137,12 @@ Canonical layout (prefer these paths for new changes): **Upload:** same as CUDA—`upload_pipeline.py --upload`. - **Hardware in YAML:** `mirror_hardwares` preset (string), expanded to `agents`, top-level `image`, and `plugins`. Presets: `a2b3_npu_1`, `a2b3_npu_4`, `a3_npu_2` in `common/ci_mirror_hardwares.yml`. + **Hardware in YAML:** `mirror_hardwares` preset (string), expanded to `agents`, top-level `image`, and `plugins`. NPU presets in `common/ci_mirror_hardwares.yml` are: + + - A2: `a2b3_npu_1`, `a2b3_npu_4`, `a2b3_npu_8` + - A3: `a3_npu_2`, `a3_npu_4`, `a3_npu_8`, `a3_npu_16` + - A5: `a5_npu_2`, `a5_npu_4`, `a5_npu_8`, `a5_npu_16` + - 310P: `310p_npu_1`, `310p_npu_2`, `310p_npu_4` **Conventions** diff --git a/docs/contributing/ci/test_writing_guide.md b/docs/contributing/ci/test_writing_guide.md index 469e69cac..762863bb3 100644 --- a/docs/contributing/ci/test_writing_guide.md +++ b/docs/contributing/ci/test_writing_guide.md @@ -48,6 +48,8 @@ Defined in `pyproject.toml`: | `MI325` | Tests that require MI325 GPU (AMD/ROCm) * | | `A2` | Tests that require A2 NPU * | | `A3` | Tests that require A3 NPU * | +| `A5` | Tests that require A5 NPU * | +| `310P` | Tests that require 310P NPU * | | `cards_{n}` | Tests that require *n* accelerator cards (`cards_1` … `cards_8`); auto-added from `num_cards` * | | `slow` | Slow tests (may skip in quick CI) | | `benchmark` | Benchmark tests (decorator on runner test functions; perf JSON uses `full_model` + type marker instead) | @@ -76,7 +78,7 @@ def test_video_to_audio() This decorator is intended to make hardware-aware, cross-platform test authoring easier and more robust for CI/CD environments. The `hardware_test` decorator in `vllm-omni/tests/helpers/mark.py` performs the following actions: 1. **Applies platform and resource markers** - Adds the appropriate pytest markers for each specified hardware platform (e.g., `cuda`, `rocm`, `xpu`, `npu`) and resource type (e.g., `L4`, `H100`, `H200`, `B200`, `MI325`, `B60`, `A2`, `A3`). + Adds the appropriate pytest markers for each specified hardware platform (e.g., `cuda`, `rocm`, `xpu`, `npu`) and resource type (e.g., `L4`, `H100`, `H200`, `B200`, `MI325`, `B60`, `A2`, `A3`, `A5`, `310P`). ```python @pytest.mark.cuda @pytest.mark.L4 diff --git a/docs/getting_started/installation/npu/npu.inc.md b/docs/getting_started/installation/npu/npu.inc.md index 0019d00bd..9ff477522 100644 --- a/docs/getting_started/installation/npu/npu.inc.md +++ b/docs/getting_started/installation/npu/npu.inc.md @@ -7,13 +7,34 @@ For detailed hardware and software requirements, please refer to the [vLLM-Ascen The recommended way to use vLLM-Omni on NPU is through the vLLM-Ascend pre-built Docker images: +=== "A2" + + ```bash + export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0 + ``` + +=== "A3" + + ```bash + export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-a3 + ``` + +=== "A5" + + ```bash + export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-a5 + ``` + +=== "310P (Experimental)" + + ```bash + export IMAGE=quay.io/atlas-ci/vllm-ascend:v0.28.0-310p + ``` + ```bash -# vLLM-Ascend image -# Atlas A2: quay.io/atlas-ci/vllm-ascend:v0.28.0 -# Atlas A3: quay.io/atlas-ci/vllm-ascend:v0.28.0-a3 docker run --rm \ --name vllm-omni-npu \ - --shm-size=1g \ + --shm-size=64g \ --device /dev/davinci0 \ --device /dev/davinci1 \ --device /dev/davinci2 \ @@ -27,8 +48,8 @@ docker run --rm \ -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ -v /etc/ascend_install.info:/etc/ascend_install.info \ -v /root/.cache:/root/.cache \ - -p 8000:8000 \ - -it quay.io/atlas-ci/vllm-ascend:v0.28.0-a3 bash + -p 8091:8091 \ + -it "$IMAGE" bash # Inside the container, install vLLM-Omni from source cd /vllm-workspace @@ -72,53 +93,146 @@ Supported images as following. | image name | Hardware | OS | |-|-|-| -| image-tag | Atlas A2 | Ubuntu | -| image-tag-a3 | Atlas A3 | Ubuntu | +| image-tag | A2 | Ubuntu | +| image-tag-a3 | A3 | Ubuntu | +| image-tag-a5 | A5 | Ubuntu | +| image-tag-310p | 310P (experimental) | Ubuntu | Here's an example deployment command that has been verified on 4 x NPUs: -```bash -# Atlas A2: quay.io/ascend/vllm-omni:v0.28.0 -# Atlas A3: quay.io/ascend/vllm-omni:v0.28.0-a3 -docker run --rm \ - --name vllm-omni-a3 \ - --shm-size=4g \ - --device /dev/davinci0 \ - --device /dev/davinci1 \ - --device /dev/davinci2 \ - --device /dev/davinci3 \ - --device /dev/davinci_manager \ - --device /dev/devmm_svm \ - --device /dev/hisi_hdc \ - -v /usr/local/dcmi:/usr/local/dcmi \ - -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ - -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ - -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ - --device /dev/davinci_manager \ - --device /dev/devmm_svm \ - --device /dev/hisi_hdc \ - -v /usr/local/dcmi:/usr/local/dcmi \ - -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ - -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ - -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ - -v /etc/ascend_install.info:/etc/ascend_install.info \ - -v ~/.cache:/root/.cache \ - -p 8091:8091 \ - -it quay.io/ascend/vllm-omni:v0.28.0-a3 bash -``` +=== "A2" + + ```bash + export IMAGE=quay.io/ascend/vllm-omni:v0.28.0 + docker run --rm \ + --name vllm-omni-a2 \ + --shm-size=64g \ + --device /dev/davinci0 \ + --device /dev/davinci1 \ + --device /dev/davinci2 \ + --device /dev/davinci3 \ + --device /dev/davinci_manager \ + --device /dev/devmm_svm \ + --device /dev/hisi_hdc \ + -v /usr/local/dcmi:/usr/local/dcmi \ + -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ + -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ + -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ + -v /etc/ascend_install.info:/etc/ascend_install.info \ + -v ~/.cache:/root/.cache \ + -p 8091:8091 \ + -it "$IMAGE" bash + ``` + +=== "A3" + + ```bash + export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-a3 + docker run --rm \ + --name vllm-omni-a3 \ + --shm-size=64g \ + --device /dev/davinci0 \ + --device /dev/davinci1 \ + --device /dev/davinci2 \ + --device /dev/davinci3 \ + --device /dev/davinci_manager \ + --device /dev/devmm_svm \ + --device /dev/hisi_hdc \ + -v /usr/local/dcmi:/usr/local/dcmi \ + -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ + -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ + -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ + -v /etc/ascend_install.info:/etc/ascend_install.info \ + -v ~/.cache:/root/.cache \ + -p 8091:8091 \ + -it "$IMAGE" bash + ``` + +=== "A5" + + ```bash + export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-a5 + docker run --rm \ + --name vllm-omni-a5 \ + --shm-size=64g \ + --device /dev/davinci0 \ + --device /dev/davinci1 \ + --device /dev/davinci2 \ + --device /dev/davinci3 \ + --device /dev/davinci_manager \ + --device /dev/devmm_svm \ + --device /dev/hisi_hdc \ + -v /usr/local/dcmi:/usr/local/dcmi \ + -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ + -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ + -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ + -v /etc/ascend_install.info:/etc/ascend_install.info \ + -v ~/.cache:/root/.cache \ + -p 8091:8091 \ + -it "$IMAGE" bash + ``` + +=== "310P (Experimental)" + + ```bash + export IMAGE=quay.io/ascend/vllm-omni:v0.28.0-310p + docker run --rm \ + --name vllm-omni-310p \ + --shm-size=64g \ + --device /dev/davinci0 \ + --device /dev/davinci1 \ + --device /dev/davinci2 \ + --device /dev/davinci3 \ + --device /dev/davinci_manager \ + --device /dev/devmm_svm \ + --device /dev/hisi_hdc \ + -v /usr/local/dcmi:/usr/local/dcmi \ + -v /usr/local/bin/npu-smi:/usr/local/bin/npu-smi \ + -v /usr/local/Ascend/driver/lib64/:/usr/local/Ascend/driver/lib64/ \ + -v /usr/local/Ascend/driver/version.info:/usr/local/Ascend/driver/version.info \ + -v /etc/ascend_install.info:/etc/ascend_install.info \ + -v ~/.cache:/root/.cache \ + -p 8091:8091 \ + -it "$IMAGE" bash + ``` !!! tip You can use this docker image to serve models the same way you would with in vLLM! To do so, make sure you overwrite the default entrypoint (`vllm serve --omni`) which works only for models supported in the vLLM-Omni project. -Or build IMAGE from **source code**: +Or build the image from **source code**: ```bash git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni -# A2 -# docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu . -# A3 -docker build -t vllm-omni-dev-image:latest -f ./docker/Dockerfile.npu.a3 . ``` +All hardware variants use `docker/Dockerfile.npu`. Choose the device suffix for your hardware: + +=== "A2" + + ```bash + docker build -t vllm-omni-dev-image:latest -f docker/Dockerfile.npu . + ``` + +=== "A3" + + ```bash + docker build -t vllm-omni-dev-image:latest -f docker/Dockerfile.npu \ + --build-arg VLLM_ASCEND_DEVICE_SUFFIX=-a3 . + ``` + +=== "A5" + + ```bash + docker build -t vllm-omni-dev-image:latest -f docker/Dockerfile.npu \ + --build-arg VLLM_ASCEND_DEVICE_SUFFIX=-a5 . + ``` + +=== "310P (Experimental)" + + ```bash + docker build -t vllm-omni-dev-image:latest -f docker/Dockerfile.npu \ + --build-arg VLLM_ASCEND_DEVICE_SUFFIX=-310p . + ``` + # --8<-- [end:pre-built-images] diff --git a/pyproject.toml b/pyproject.toml index 3ba902977..0393c9c62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -283,6 +283,8 @@ markers = [ "S5000: [hardware-resource] [musa] Tests that require S5000 GPU (Moore Threads/MUSA)", "A2: [hardware-resource] [npu] Tests that require A2 NPU", "A3: [hardware-resource] [npu] Tests that require A3 NPU", + "A5: [hardware-resource] [npu] Tests that require A5 NPU", + "310P: [hardware-resource] [npu] Tests that require 310P NPU", # Tag with [hardware-cards] for tests.helpers.mark.get_supported_card_counts(). "cards_1: [hardware-cards] Tests that require 1 accelerator card", "cards_2: [hardware-cards] Tests that require 2 accelerator cards", diff --git a/tests/diffusion/layers/test_activation.py b/tests/diffusion/layers/test_activation.py index c3205502f..321f2ab8f 100644 --- a/tests/diffusion/layers/test_activation.py +++ b/tests/diffusion/layers/test_activation.py @@ -5,7 +5,7 @@ import pytest import torch import torch.nn.functional as F -from tests.helpers.mark import hardware_test +from tests.helpers.mark import hardware_marks from vllm_omni.diffusion.layers.activation import SiluAndMul pytestmark = [pytest.mark.core_model, pytest.mark.diffusion] @@ -19,9 +19,17 @@ def test_silu_and_mul_native_matches_packed_reference() -> None: torch.testing.assert_close(SiluAndMul().forward_native(packed), F.silu(gate) * up) -@hardware_test(res={"npu": "A3"}, num_cards=1) -def test_silu_and_mul_npu_matches_packed_reference() -> None: - packed = torch.randn(257, 256, device="npu", dtype=torch.bfloat16) +@pytest.mark.parametrize( + "dtype", + [ + pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A2"}, num_cards=1), id="A2-bf16"), + pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A3"}, num_cards=1), id="A3-bf16"), + pytest.param(torch.bfloat16, marks=hardware_marks(res={"npu": "A5"}, num_cards=1), id="A5-bf16"), + pytest.param(torch.float16, marks=hardware_marks(res={"npu": "310P"}, num_cards=1), id="310P-fp16"), + ], +) +def test_silu_and_mul_npu_matches_packed_reference(dtype: torch.dtype) -> None: + packed = torch.randn(257, 256, device="npu", dtype=dtype) gate, up = packed.chunk(2, dim=-1) torch.testing.assert_close( From 4b8dcac68153cc386db1e5fb0226d52518739414 Mon Sep 17 00:00:00 2001 From: Mu GuanLin <1203789601@qq.com> Date: Thu, 10 Sep 2026 11:07:35 +0800 Subject: [PATCH 88/90] [Kernel] Enable LTX DiffVAE fusions on SM100 and SM103 (#7350) Signed-off-by: mglyn <1203789601@qq.com> --- .../models/ltx2/test_ltx2_ops_platform.py | 6 ++++-- vllm_omni/diffusion/models/ltx2/ops/__init__.py | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py b/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py index 6ffff3136..5fca0e5e6 100644 --- a/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py +++ b/tests/diffusion/models/ltx2/test_ltx2_ops_platform.py @@ -37,8 +37,10 @@ def clear_platform_cache(): (90, True, True, True, True), (90, True, False, True, False), (90, False, True, False, False), - (100, True, True, False, False), - (103, True, True, False, False), + (100, True, True, True, False), + (100, True, False, True, False), + (103, True, True, True, False), + (103, True, False, True, False), (110, True, True, False, False), (120, True, True, False, False), (121, True, True, False, False), diff --git a/vllm_omni/diffusion/models/ltx2/ops/__init__.py b/vllm_omni/diffusion/models/ltx2/ops/__init__.py index 93f2ff672..7907fba56 100644 --- a/vllm_omni/diffusion/models/ltx2/ops/__init__.py +++ b/vllm_omni/diffusion/models/ltx2/ops/__init__.py @@ -17,9 +17,10 @@ from vllm_omni.platforms import current_omni_platform # Pointwise fusions are launch/memory-traffic reductions whose first execution # for every runtime key is checked bit-for-bit; failures use the ordinary model -# math. Both paths remain SM90-only until other architectures are validated. -# The FNA schedule is performance-tuned for SM90 and has no silent fallback. -_CUDA_COMPUTE_CAPABILITIES = frozenset({90}) +# math. The FNA schedule is performance-tuned for SM90 and has no silent +# fallback, so qualify the two paths independently. +_FUSION_CUDA_COMPUTE_CAPABILITIES = frozenset({90, 100, 103}) +_FNA_CUDA_COMPUTE_CAPABILITIES = frozenset({90}) @dataclass(frozen=True) @@ -66,9 +67,10 @@ def resolve_ltx2_vae_operators(device: torch.device) -> LTX2VAEOperatorSet | Non if device.type != "cuda" or not current_omni_platform.is_cuda() or not current_omni_platform.is_available(): return None device_index = device.index if device.index is not None else torch.accelerator.current_device_index() - if _cuda_compute_capability(int(device_index)) not in _CUDA_COMPUTE_CAPABILITIES: + capability = _cuda_compute_capability(int(device_index)) + if capability not in _FUSION_CUDA_COMPUTE_CAPABILITIES: return None - return _operator_set(has_tilelang()) + return _operator_set(capability in _FNA_CUDA_COMPUTE_CAPABILITIES and has_tilelang()) def _eligible_operators(tensor: torch.Tensor) -> LTX2VAEOperatorSet | None: @@ -78,7 +80,7 @@ def _eligible_operators(tensor: torch.Tensor) -> LTX2VAEOperatorSet | None: def is_ltx2_fusion_eligible(tensor: torch.Tensor) -> bool: - """Allow self-verifying pointwise fusions on validated SM90 devices.""" + """Allow self-verifying pointwise fusions on qualified CUDA devices.""" return _eligible_operators(tensor) is not None From 333f14e97ebbe78f208380fd82ff4523033d355b Mon Sep 17 00:00:00 2001 From: Sy03 <1370724210@qq.com> Date: Wed, 9 Sep 2026 20:38:11 -0700 Subject: [PATCH 89/90] [Bugfix][MiniCPM-o] Align structured chat content with native omni rendering (#7344) Signed-off-by: Sy03 <1370724210@qq.com> --- .buildkite/cuda/test-merge.yml | 1 + .buildkite/cuda/test-ready.yml | 1 + pyproject.toml | 2 +- recipes/OpenBMB/MiniCPM-o-4_5.md | 21 +++- tests/e2e/online_serving/test_minicpmo_4_5.py | 29 ++++- .../models/minicpmo_4_5/test_chat_template.py | 45 +++++++ .../chat_templates/minicpmo45_native.jinja | 116 ++++++++++++++++++ 7 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 tests/model_executor/models/minicpmo_4_5/test_chat_template.py create mode 100644 vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja diff --git a/.buildkite/cuda/test-merge.yml b/.buildkite/cuda/test-merge.yml index 6bad98f79..5aa49ea6c 100644 --- a/.buildkite/cuda/test-merge.yml +++ b/.buildkite/cuda/test-merge.yml @@ -155,6 +155,7 @@ steps: source_file_dependencies: - tests/e2e/offline_inference/test_minicpmo_4_5.py - tests/e2e/online_serving/test_minicpmo_4_5.py + - vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja - vllm_omni/model_executor/models/minicpmo_4_5/ - vllm_omni/model_executor/models/step_audio2/ - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/ diff --git a/.buildkite/cuda/test-ready.yml b/.buildkite/cuda/test-ready.yml index f54a58fa4..6d8d1e8b9 100644 --- a/.buildkite/cuda/test-ready.yml +++ b/.buildkite/cuda/test-ready.yml @@ -188,6 +188,7 @@ steps: - label: "Omni · MiniCPM-o 4.5 Online Test" source_file_dependencies: - tests/e2e/online_serving/test_minicpmo_4_5.py + - vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja - vllm_omni/model_executor/models/minicpmo_4_5/ - vllm_omni/model_executor/models/step_audio2/ - vllm_omni/model_executor/models/cosyvoice3/code2wav_core/ diff --git a/pyproject.toml b/pyproject.toml index 0393c9c62..2584c8c7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -161,7 +161,7 @@ where = ["."] include = ["vllm_omni*"] [tool.setuptools.package-data] -"vllm_omni" = ["_version.py", "py.typed"] +"vllm_omni" = ["_version.py", "py.typed", "transformers_utils/chat_templates/*.jinja"] [tool.setuptools_scm] # no extra settings needed, presence enables setuptools-scm diff --git a/recipes/OpenBMB/MiniCPM-o-4_5.md b/recipes/OpenBMB/MiniCPM-o-4_5.md index 86f0883f7..fb0934d4f 100644 --- a/recipes/OpenBMB/MiniCPM-o-4_5.md +++ b/recipes/OpenBMB/MiniCPM-o-4_5.md @@ -28,13 +28,13 @@ also provided. - Default deploy configs (auto-loaded by HF `model_type=minicpmo` + `hf_config.version="4.5"`): - - Default single-GPU compatibility layout (auto-loaded): + - Default single-GPU compatibility layout (auto-loaded): [`vllm_omni/deploy/minicpmo_4_5.yaml`](../../vllm_omni/deploy/minicpmo_4_5.yaml) - - Recommended 2-GPU continuous-batching layout: + - Recommended 2-GPU continuous-batching layout: [`vllm_omni/deploy/minicpmo_4_5_2gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_2gpu.yaml), - - 3-GPU layout: + - 3-GPU layout: [`vllm_omni/deploy/minicpmo_4_5_3gpu.yaml`](../../vllm_omni/deploy/minicpmo_4_5_3gpu.yaml) - - 8x RTX 4090 layout: + - 8x RTX 4090 layout: [`vllm_omni/deploy/minicpmo_4_5_8x4090.yaml`](../../vllm_omni/deploy/minicpmo_4_5_8x4090.yaml) - Online example + Gradio demo: [`examples/online_serving/minicpmo/`](../../examples/online_serving/minicpmo/) @@ -126,9 +126,20 @@ video. Use the two-GPU profile for production throughput. ```bash vllm serve openbmb/MiniCPM-o-4_5 --omni \ --trust-remote-code \ + --chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \ + --chat-template-content-format openai \ --host 0.0.0.0 --port 8099 ``` +Run these commands from the repository root. The bundled chat template +matches the Hugging Face `chat(omni_mode=True)` content layout: media and text +parts are concatenated without adding separators, while whitespace explicitly +included in a text part is preserved. This matters for speech generation because +an inserted newline changes the Thinker condition passed to Talker. For image +questions, place the image before the question, as in the native image-chat +layout. Continue to request `use_tts_template: true` and +`enable_thinking: false` for spoken answers. + The deploy config is auto-loaded by the model registry — no `--deploy-config` flag needed for this default single-GPU layout. @@ -271,6 +282,8 @@ uses GPU 5. GPUs 6–7 are left free. vllm serve openbmb/MiniCPM-o-4_5 --omni \ --deploy-config vllm_omni/deploy/minicpmo_4_5_8x4090.yaml \ --trust-remote-code \ + --chat-template vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja \ + --chat-template-content-format openai \ --host 0.0.0.0 --port 8099 ``` diff --git a/tests/e2e/online_serving/test_minicpmo_4_5.py b/tests/e2e/online_serving/test_minicpmo_4_5.py index 290064fc3..5417d2b16 100644 --- a/tests/e2e/online_serving/test_minicpmo_4_5.py +++ b/tests/e2e/online_serving/test_minicpmo_4_5.py @@ -24,6 +24,9 @@ from tests.helpers.stage_config import get_deploy_config_path, modify_stage_conf os.environ["VLLM_WORKER_MULTIPROC_METHOD"] = "spawn" _MODEL = "openbmb/MiniCPM-o-4_5" +_NATIVE_CHAT_TEMPLATE = ( + Path(__file__).resolve().parents[3] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja" +) _CI_DEPLOY = modify_stage_config( get_deploy_config_path("minicpmo_4_5.yaml"), updates={ @@ -63,7 +66,14 @@ test_params = [ model=_MODEL, stage_config_path=_CI_DEPLOY, use_stage_cli=False, - server_args=["--trust-remote-code", "--async-chunk"], + server_args=[ + "--trust-remote-code", + "--async-chunk", + "--chat-template", + str(_NATIVE_CHAT_TEMPLATE), + "--chat-template-content-format", + "openai", + ], env_dict={ "VLLM_CONFIGURE_LOGGING": "1", "VLLM_LOGGING_CONFIG_PATH": str(_PROMPT_LOGGING_CONFIG_PATH), @@ -265,11 +275,18 @@ def test_image_to_text_audio_001(omni_server, openai_client) -> None: Input Setting: stream=True """ image_data_url = f"data:image/jpeg;base64,{generate_synthetic_image(24, 24)['base64']}" - messages = dummy_messages_from_mix_data( - system_prompt=get_system_prompt(), - image_data_url=image_data_url, - content_text=get_prompt("text_image"), - ) + # Match the native image-chat layout explicitly; do not rely on the + # renderer moving media placeholders ahead of text. + messages = [ + get_system_prompt(), + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": image_data_url}}, + {"type": "text", "text": get_prompt("text_image")}, + ], + }, + ] request_config = { "model": omni_server.model, diff --git a/tests/model_executor/models/minicpmo_4_5/test_chat_template.py b/tests/model_executor/models/minicpmo_4_5/test_chat_template.py new file mode 100644 index 000000000..8f1068392 --- /dev/null +++ b/tests/model_executor/models/minicpmo_4_5/test_chat_template.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""MiniCPM structured-content rendering must preserve native part boundaries.""" + +from pathlib import Path + +import pytest +from jinja2 import TemplateError +from transformers.utils.chat_template_utils import _compile_jinja_template + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +TEMPLATE = Path(__file__).resolve().parents[4] / "vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja" + + +@pytest.mark.parametrize( + "parts,expected", + [ + ([{"type": "image"}, {"type": "text", "text": "Question?"}], "(./)Question?"), + ([{"type": "text", "text": "Question?"}, {"type": "image"}], "Question?(./)"), + ([{"type": "image"}, {"type": "text", "text": "\n\nQuestion?"}], "(./)\n\nQuestion?"), + ([{"type": "text", "text": " A "}, {"type": "text", "text": " B "}], " A B "), + ([{"type": "audio"}, {"type": "text", "text": "Transcribe."}], "()Transcribe."), + ([{"type": "video"}, {"type": "image"}], "()(./)"), + ("\nKeep this whitespace.\n", "\nKeep this whitespace.\n"), + ], +) +def test_native_content_concatenation(parts, expected): + rendered = _compile_jinja_template(TEMPLATE.read_text()).render( + messages=[{"role": "user", "content": parts}], + add_generation_prompt=True, + enable_thinking=False, + use_tts_template=True, + ) + assert rendered == ( + f"<|im_start|>user\n{expected}<|im_end|>\n<|im_start|>assistant\n\n\n\n\n<|tts_bos|>" + ) + + +def test_unsupported_part_is_not_silently_discarded(): + with pytest.raises(TemplateError, match="Unsupported MiniCPM-o content part"): + _compile_jinja_template(TEMPLATE.read_text()).render( + messages=[{"role": "user", "content": [{"type": "unknown"}]}], + add_generation_prompt=True, + ) diff --git a/vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja b/vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja new file mode 100644 index 000000000..2f2bc9839 --- /dev/null +++ b/vllm_omni/transformers_utils/chat_templates/minicpmo45_native.jinja @@ -0,0 +1,116 @@ +{# SPDX-License-Identifier: Apache-2.0 + Adapted from OpenBMB/MiniCPM-o-4_5 tokenizer_config.json. + Match chat(omni_mode=True): concatenate structured content parts without + injecting separators, preserving whitespace explicitly supplied as text. +#} +{%- set normalized = namespace(messages=[]) -%} +{%- for message in messages -%} + {%- if message.content is string or message.content is none -%} + {%- set normalized.messages = normalized.messages + [message] -%} + {%- else -%} + {%- set content = namespace(text='') -%} + {%- for part in message.content -%} + {%- if part.type == 'text' -%} + {%- set content.text = content.text + part.text -%} + {%- elif part.type == 'image' -%} + {%- set content.text = content.text + '(./)' -%} + {%- elif part.type == 'audio' -%} + {%- set content.text = content.text + '()' -%} + {%- elif part.type == 'video' -%} + {%- set content.text = content.text + '()' -%} + {%- else -%} + {{- raise_exception('Unsupported MiniCPM-o content part: ' + part.type) -}} + {%- endif -%} + {%- endfor -%} + {%- set normalized.messages = normalized.messages + [dict(message, content=content.text)] -%} + {%- endif -%} +{%- endfor -%} +{%- set messages = normalized.messages -%} +{%- if tools %} + {{- '<|im_start|>system\n' }} + {%- if messages[0].role == 'system' %} + {{- messages[0].content + '\n\n' }} + {%- endif %} + {{- "# Tools\n\nYou may call one or more functions to assist with the user query.\n\nYou are provided with function signatures within XML tags:\n" }} + {%- for tool in tools %} + {{- "\n" }} + {{- tool | tojson }} + {%- endfor %} + {{- "\n\n\nFor each function call, return a json object with function name and arguments within XML tags:\n\n{\"name\": , \"arguments\": }\n<|im_end|>\n" }} +{%- else %} + {%- if messages[0].role == 'system' %} + {{- '<|im_start|>system\n' + messages[0].content + '<|im_end|>\n' }} + {%- endif %} +{%- endif %} +{%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %} +{%- for message in messages[::-1] %} + {%- set index = (messages|length - 1) - loop.index0 %} + {%- if ns.multi_step_tool and message.role == "user" and not(message.content.startswith('') and message.content.endswith('')) %} + {%- set ns.multi_step_tool = false %} + {%- set ns.last_query_index = index %} + {%- endif %} +{%- endfor %} +{%- for message in messages %} + {%- if (message.role == "user") or (message.role == "system" and not loop.first) %} + {{- '<|im_start|>' + message.role + '\n' + message.content + '<|im_end|>' + '\n' }} + {%- elif message.role == "assistant" %} + {%- set content = message.content %} + {%- set reasoning_content = '' %} + {%- if message.reasoning_content is defined and message.reasoning_content is not none %} + {%- set reasoning_content = message.reasoning_content %} + {%- else %} + {%- if '' in message.content %} + {%- set content = message.content.split('')[-1].lstrip('\n') %} + {%- set reasoning_content = message.content.split('')[0].rstrip('\n').split('')[-1].lstrip('\n') %} + {%- endif %} + {%- endif %} + {%- if loop.index0 > ns.last_query_index %} + {%- if loop.last or (not loop.last and reasoning_content) %} + {{- '<|im_start|>' + message.role + '\n\n' + reasoning_content.strip('\n') + '\n\n\n' + content.lstrip('\n') }} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- else %} + {{- '<|im_start|>' + message.role + '\n' + content }} + {%- endif %} + {%- if message.tool_calls %} + {%- for tool_call in message.tool_calls %} + {%- if (loop.first and content) or (not loop.first) %} + {{- '\n' }} + {%- endif %} + {%- if tool_call.function %} + {%- set tool_call = tool_call.function %} + {%- endif %} + {{- '\n{"name": "' }} + {{- tool_call.name }} + {{- '", "arguments": ' }} + {%- if tool_call.arguments is string %} + {{- tool_call.arguments }} + {%- else %} + {{- tool_call.arguments | tojson }} + {%- endif %} + {{- '}\n' }} + {%- endfor %} + {%- endif %} + {{- '<|im_end|>\n' }} + {%- elif message.role == "tool" %} + {%- if loop.first or (messages[loop.index0 - 1].role != "tool") %} + {{- '<|im_start|>user' }} + {%- endif %} + {{- '\n\n' }} + {{- message.content }} + {{- '\n' }} + {%- if loop.last or (messages[loop.index0 + 1].role != "tool") %} + {{- '<|im_end|>\n' }} + {%- endif %} + {%- endif %} +{%- endfor %} +{%- if add_generation_prompt %} + {{- '<|im_start|>assistant\n' }} + {%- if enable_thinking is defined and enable_thinking is false %} + {{- '\n\n\n\n' }} + {%- endif %} + {%- if use_tts_template is defined and use_tts_template is true %} + {{- '<|tts_bos|>' }} + {%- endif %} +{%- endif -%} From aff7d64948f6c0e81e9b3272234623044e215967 Mon Sep 17 00:00:00 2001 From: Zhou Taichang Date: Thu, 10 Sep 2026 12:08:28 +0800 Subject: [PATCH 90/90] [Rebase] Rebase to vLLM 0.29.0 (#7230) Signed-off-by: tzhouam Signed-off-by: Zhou Taichang Co-authored-by: Claude Opus 5 --- .buildkite/common/ci_mirror_hardwares.yml | 27 +- .buildkite/common/scripts/upload_pipeline.py | 13 + docker/Dockerfile.ci | 29 +- docs/configuration/README.md | 2 +- docs/configuration/environment_variables.md | 2 +- docs/getting_started/installation/README.md | 2 +- docs/getting_started/installation/gpu.md | 2 + .../installation/gpu/cuda.inc.md | 22 +- .../installation/gpu/rocm.inc.md | 12 +- docs/getting_started/quickstart.md | 6 +- tests/attention/test_fish_kvcache_attn.py | 17 +- tests/buildkite/test_upload_pipeline.py | 32 + tests/config/test_omni_config.py | 10 + ...t_omni_scheduler_forces_v1_model_runner.py | 59 ++ .../diffusion_kv/test_block_tables.py | 6 +- .../diffusion_kv/test_initialization.py | 8 +- tests/diffusion/diffusion_kv/test_layout.py | 147 ++++ tests/diffusion/diffusion_kv/test_manager.py | 7 +- .../test_paged_attention_adapter.py | 4 +- .../test_paged_attention_adapter_gpu.py | 20 +- tests/diffusion/diffusion_kv/test_request.py | 6 +- .../diffusion_kv/test_spec_discovery.py | 11 +- tests/diffusion/layers/test_adalayernorm.py | 4 +- tests/diffusion/layers/test_rope_broadcast.py | 27 +- .../test_boogu_image_transformer.py | 2 +- .../models/dreamzero/test_qkv_fusion.py | 4 +- .../glm_image/test_glm_image_quantization.py | 4 +- .../test_mistral_encoder_tp.py | 11 +- .../qwen_image/test_qwen_image_rope_utils.py | 29 +- .../sana_video/test_transformer_sana_video.py | 4 +- .../test_transformer_sana_video_sp.py | 10 +- .../test_transformer_sana_video_tp.py | 4 +- .../models/t5_encoder/test_t5_encoder_tp.py | 9 +- .../quantization/test_component_routing.py | 5 +- tests/diffusion/test_diffusion_scheduler.py | 7 +- .../test_diffusers_backend_similarity.py | 1 + .../online_serving/test_dots_tts_expansion.py | 2 + .../test_async_omni_engine_stage_init.py | 89 ++- .../openai/test_batch_chat_completions.py | 5 +- .../openai_api/test_audio_format.py | 4 +- .../openai_api/test_modality_validation.py | 3 +- .../openai_api/test_serving_chat_metrics.py | 3 +- .../openai_api/test_serving_speech.py | 2 +- .../openai_api/test_video_stream_handler.py | 4 +- .../entrypoints/test_stream_finish_reason.py | 3 +- tests/helpers/kv_layout.py | 36 + tests/helpers/serving_chat.py | 3 +- tests/inputs/test_mm_processor.py | 44 ++ tests/inputs/test_preprocess.py | 170 ++++- .../models/glm_image/test_glm_image_ar.py | 37 +- .../minicpmo_4_5/test_audio_embeds_input.py | 8 +- .../test_long_audio_processing.py | 40 +- .../test_processor_prompt_tokenization.py | 102 +++ .../qwen2_5_omni/test_per_video_audio_mask.py | 14 +- .../qwen3_omni/test_qwen3_omni_moe_lora.py | 4 +- .../qwen3_tts/test_qwen3_tts_code2wav.py | 7 +- .../models/test_weight_loader.py | 38 ++ .../test_modelopt_nvfp4_nan_clamp.py | 13 +- .../test_upstream_prompt_update_api.py | 64 ++ tests/platforms/test_platform_detection.py | 38 ++ vllm_omni/benchmarks/patch/patch.py | 2 + vllm_omni/core/sched/omni_scheduler_mixin.py | 11 + .../diffusion/attention/backends/abstract.py | 7 + vllm_omni/diffusion/attention/layer.py | 11 +- .../diffusion/diffusion_kv/initialization.py | 28 +- vllm_omni/diffusion/diffusion_kv/layout.py | 174 +++++ .../diffusion_kv/model_runner_backend.py | 11 +- .../diffusion_kv/paged_attention_adapter.py | 4 +- vllm_omni/diffusion/layers/rope.py | 33 +- .../hunyuan_image3/pipeline_hunyuan_image3.py | 7 +- .../models/ltx2/ltx2_phase_adapter.py | 2 +- .../qwen_image/qwen_image_transformer.py | 35 +- vllm_omni/engine/arg_utils.py | 4 +- .../engine/stage_engine_core_proc_manager.py | 8 +- vllm_omni/engine/stage_init_utils.py | 25 +- vllm_omni/entrypoints/async_omni.py | 8 +- vllm_omni/entrypoints/cli/serve.py | 2 +- vllm_omni/entrypoints/duplex/chat_fallback.py | 2 +- vllm_omni/entrypoints/openai/api_server.py | 29 +- vllm_omni/entrypoints/openai/batch_serving.py | 4 +- .../entrypoints/openai/realtime_connection.py | 5 +- vllm_omni/entrypoints/openai/serving_chat.py | 30 +- .../entrypoints/openai/serving_speech.py | 8 +- .../entrypoints/openai/video_api_utils.py | 4 +- .../openai/video_stream_session.py | 4 +- .../ar_diffusion/kv_cache/manager.py | 5 +- vllm_omni/inputs/mm_processor.py | 39 ++ vllm_omni/inputs/preprocess.py | 274 +++----- .../models/audex/audex_omni_processing.py | 7 +- .../model_executor/models/bagel/bagel.py | 131 ++-- .../models/cosyvoice3/cosyvoice3.py | 52 +- .../models/covo_audio/covo_audio.py | 10 +- .../models/gepard/gepard_talker.py | 10 +- .../models/glm_image/glm_image_ar.py | 547 +++------------ .../model_executor/models/glm_tts/glm_tts.py | 60 +- .../higgs_audio_v3/higgs_audio_v3_talker.py | 8 +- .../hunyuan_image3/_hunyuan_v1_vendored.py | 625 ++++++++++++++++++ .../models/hunyuan_image3/hunyuan_image3.py | 39 +- .../models/mimo_audio/mimo_audio.py | 13 +- .../models/mimo_audio/mimo_audio_llm.py | 40 +- .../ming_flash_omni/ming_flash_omni_talker.py | 14 +- .../ming_flash_omni_thinker.py | 152 ++--- .../minicpmo_4_5/minicpmo_4_5_omni_llm.py | 192 ++---- .../models/minimax_h3/text_encoder.py | 84 ++- .../models/minimax_music3/talker.py | 10 +- .../models/omnivoice/omnivoice.py | 89 ++- .../qwen2_5_omni/qwen2_5_omni_talker.py | 11 +- .../qwen2_5_omni/qwen2_5_omni_thinker.py | 124 ++-- .../models/qwen2_5_omni/qwen2_old.py | 12 +- .../models/qwen3_omni/qwen3_omni_code2wav.py | 10 +- .../qwen3_omni/qwen3_omni_moe_talker.py | 14 +- .../qwen3_omni/qwen3_omni_moe_thinker.py | 184 +----- .../models/qwen3_tts/qwen3_tts_code2wav.py | 12 +- .../models/qwen3_tts/qwen3_tts_talker.py | 10 +- .../models/step_audio2/step_audio2_thinker.py | 57 +- .../voxtral_tts_audio_generation.py | 31 +- vllm_omni/platforms/__init__.py | 8 +- .../reasoning/step_audio_reasoning_parser.py | 2 +- vllm_omni/worker/gpu_ar_model_runner.py | 1 - vllm_omni/worker/sampling_utils.py | 15 +- 120 files changed, 2958 insertions(+), 1690 deletions(-) create mode 100644 tests/core/sched/test_omni_scheduler_forces_v1_model_runner.py create mode 100644 tests/diffusion/diffusion_kv/test_layout.py create mode 100644 tests/helpers/kv_layout.py create mode 100644 tests/inputs/test_mm_processor.py create mode 100644 tests/model_executor/models/omnivoice/test_processor_prompt_tokenization.py create mode 100644 tests/model_executor/models/test_weight_loader.py create mode 100644 tests/model_executor/test_upstream_prompt_update_api.py create mode 100644 tests/platforms/test_platform_detection.py create mode 100644 vllm_omni/diffusion/diffusion_kv/layout.py create mode 100644 vllm_omni/inputs/mm_processor.py create mode 100644 vllm_omni/model_executor/models/hunyuan_image3/_hunyuan_v1_vendored.py diff --git a/.buildkite/common/ci_mirror_hardwares.yml b/.buildkite/common/ci_mirror_hardwares.yml index d638ecb8d..f51f13f1a 100644 --- a/.buildkite/common/ci_mirror_hardwares.yml +++ b/.buildkite/common/ci_mirror_hardwares.yml @@ -28,6 +28,9 @@ # packed onto g6.12xlarge L4 nodes (``vllm.ci/gpu-pool=l4x4``). GPU / CPU / # memory / shm requests scale with card count (1 GPU = 10 CPU / 40Gi / 24Gi shm). # Retry covers lost agents / killed pods, not pytest or OOM (exit 1 / 137). +# The Kubernetes HF secret deliberately uses VLLM_CI_HF_TOKEN. Scheduled +# build-level HF_TOKEN values can override pod env entries with the same name; +# upload_pipeline.py restores the secret-backed value inside the job shell. x-npu-k8s-plugins: &npu_k8s_plugins - kubernetes: @@ -86,7 +89,7 @@ mirror_hardwares: env: - name: HF_HOME value: /fsx/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -137,7 +140,7 @@ mirror_hardwares: env: - name: HF_HOME value: /fsx/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -188,7 +191,7 @@ mirror_hardwares: env: - name: HF_HOME value: /fsx/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -239,7 +242,7 @@ mirror_hardwares: env: - name: HF_HOME value: /fsx/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -273,7 +276,7 @@ mirror_hardwares: env: - name: HF_HOME value: /root/.cache/huggingface - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -308,7 +311,7 @@ mirror_hardwares: env: - name: HF_HOME value: /root/.cache/huggingface - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -343,7 +346,7 @@ mirror_hardwares: env: - name: HF_HOME value: /root/.cache/huggingface - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -378,7 +381,7 @@ mirror_hardwares: env: - name: HF_HOME value: /root/.cache/huggingface - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -428,7 +431,7 @@ mirror_hardwares: value: "0" - name: HF_HOME value: /raid/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -479,7 +482,7 @@ mirror_hardwares: value: "0" - name: HF_HOME value: /raid/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -530,7 +533,7 @@ mirror_hardwares: value: "0" - name: HF_HOME value: /raid/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret @@ -581,7 +584,7 @@ mirror_hardwares: value: "0" - name: HF_HOME value: /raid/hf_cache - - name: HF_TOKEN + - name: VLLM_CI_HF_TOKEN valueFrom: secretKeyRef: name: hf-token-secret diff --git a/.buildkite/common/scripts/upload_pipeline.py b/.buildkite/common/scripts/upload_pipeline.py index 046b136d7..749f35f5a 100644 --- a/.buildkite/common/scripts/upload_pipeline.py +++ b/.buildkite/common/scripts/upload_pipeline.py @@ -87,6 +87,8 @@ BOOTSTRAP_UPLOAD_IF_KEYS = { } E2E_GROUP_MARKER = "E2E Test" CI_MIRROR_HARDWARES_PATH = ROOT / ".buildkite/common/ci_mirror_hardwares.yml" +CUDA_HF_TOKEN_ENV = "VLLM_CI_HF_TOKEN" +CUDA_HF_TOKEN_EXPORT = f'if [ -n "$${{{CUDA_HF_TOKEN_ENV}:-}}" ]; then export HF_TOKEN="$${{{CUDA_HF_TOKEN_ENV}}}"; fi' # Bootstrap Buildkite ``if`` expressions. # ``*_MAIN_IF``: main + env schedule. ``*_LABEL_IF``: PR label (and/or composed with MAIN). @@ -509,6 +511,17 @@ def _expand_mirror_hardwares(step: dict[str, Any]) -> dict[str, Any] | None: expanded = copy.deepcopy(preset) merged = {key: value for key, value in step.items() if key != "mirror_hardwares"} | expanded + # A scheduled build's HF_TOKEN may override a pod env entry with the same + # name. The preset injects the Kubernetes secret under a collision-proof + # alias; restore the conventional name after Buildkite has merged env. + if any(preset_name == chip or preset_name.startswith(f"{chip}_") for chip in _cuda_mirror_chips()): + commands = merged.get("commands") + if isinstance(commands, list): + merged["commands"] = [CUDA_HF_TOKEN_EXPORT, *commands] + elif commands is None: + merged["commands"] = [CUDA_HF_TOKEN_EXPORT] + else: + merged["commands"] = [CUDA_HF_TOKEN_EXPORT, commands] # Preset retry (K8S_RETRY on l4_*) must not clobber a step that opted out. if "retry" in step: merged["retry"] = step["retry"] diff --git a/docker/Dockerfile.ci b/docker/Dockerfile.ci index b84b344a8..a8e46580b 100644 --- a/docker/Dockerfile.ci +++ b/docker/Dockerfile.ci @@ -1,27 +1,10 @@ -# Target vllm version: v0.28.0 (tag v0.28.0 @ 2cf0a6915ce5). -# Keep VLLM_BASE_TAG aligned with the target vLLM release on every rebase. -# -# Release-boundary form: there is no precompiled-wheel pin here, because the -# base image already ships exactly the target vLLM. Everything the runtime -# needs — torch, flashinfer, nvJitLink, numpy — comes from that image as one -# self-consistent set. -# -# This block reappears only when the rebase targets a vLLM commit that is NOT a -# released tag. During the 0.27 rebase, targeting v0.27.0rc1 on top of the -# v0.26.0 base image required reinstalling vLLM from wheels.vllm.ai and then -# hand-repairing the fallout: pinning torch ahead of flashinfer (installing it -# after bound flashinfer's TVM FFI runtime to the old torch and aborted engine -# core init), registering libnvJitLink.so.13 with ldconfig, and re-pinning numpy -# under numba's ceiling. All of it was compensation for a base image and a vLLM -# that disagreed. At a real release boundary they agree, so none of it is -# needed. The 0.28 rebase repeated the cycle: the v0.28.0rc / final-tag wheels -# rode on the v0.27.0 base image with the full reinstall block until the -# official v0.28.0 image shipped, at which point this file dropped back to the -# form you are reading. If you pin an untagged commit again, restore the -# reinstall block — agent/tasks/20_update_wheel_url.sh fails the run rather -# than letting the Dockerfile silently ship the wrong vLLM. +# Target vLLM version: v0.29.0 (tag @ 98dff2a81d74). +# Use the matching release image and its coherent CUDA/PyTorch/FlashInfer +# runtime. Do not reinstall a commit wheel or repair dependencies on top of +# an older base image at a release boundary. For a future unreleased target, +# restore an explicit wheel pin and validate its complete runtime stack. ARG VLLM_BASE_IMAGE=vllm/vllm-openai -ARG VLLM_BASE_TAG=v0.28.0 +ARG VLLM_BASE_TAG=v0.29.0 # We use registry-based layer caching for the dependencies, # where the tag is determined based on the hash of the contents diff --git a/docs/configuration/README.md b/docs/configuration/README.md index cdfb5818a..3d065f9a1 100644 --- a/docs/configuration/README.md +++ b/docs/configuration/README.md @@ -2,7 +2,7 @@ This section lists the most common options for running vLLM-Omni. -For options within a vLLM Engine, please refer to the [vLLM 0.28 configuration guide](https://docs.vllm.ai/en/v0.28.0/configuration/index.html). +For options within a vLLM Engine, please refer to the [vLLM 0.29 configuration guide](https://docs.vllm.ai/en/v0.29.0/configuration/index.html). Each model defines fixed topology in a registered `PipelineConfig` and runtime overrides in a deploy YAML. diff --git a/docs/configuration/environment_variables.md b/docs/configuration/environment_variables.md index 8f944f44d..90b9ffae0 100644 --- a/docs/configuration/environment_variables.md +++ b/docs/configuration/environment_variables.md @@ -151,7 +151,7 @@ their keys only. ## Inherited vLLM variables vLLM-Omni also reads variables through its aligned vLLM dependency. Refer to -the [vLLM 0.28 environment-variable reference](https://docs.vllm.ai/en/v0.28.0/configuration/env_vars.html) +the [vLLM 0.29 environment-variable reference](https://docs.vllm.ai/en/v0.29.0/configuration/env_vars.html) for their definitions. This includes vLLM launch, cache, logging, plugin, ROCm, XPU, ModelScope, and FlashInfer workspace settings. diff --git a/docs/getting_started/installation/README.md b/docs/getting_started/installation/README.md index 448ca81f2..9d2d11e94 100644 --- a/docs/getting_started/installation/README.md +++ b/docs/getting_started/installation/README.md @@ -1,7 +1,7 @@ # Installation !!! important - vLLM-Omni is released against the matching upstream vLLM major/minor version. vLLM-Omni 0.28.x requires vLLM 0.28.x; the stable v0.28.0 instructions pin vLLM 0.28.0. + vLLM-Omni is released against the matching upstream vLLM major/minor version. The 0.29 development line requires vLLM 0.29.x; the source-install instructions pin vLLM 0.29.0. Published vLLM-Omni 0.28.0 wheels and images still require vLLM 0.28.x. vLLM-Omni supports the following hardware platforms: diff --git a/docs/getting_started/installation/gpu.md b/docs/getting_started/installation/gpu.md index 7b8778296..e714a52bf 100644 --- a/docs/getting_started/installation/gpu.md +++ b/docs/getting_started/installation/gpu.md @@ -34,6 +34,8 @@ vLLM-Omni is a Python library that supports the following GPU variants. The libr ### Pre-built wheels +The source-install instructions target vLLM 0.29.0. Until vLLM-Omni 0.29.0 artifacts are published, the CUDA/ROCm pre-built examples below remain on the matching 0.28.0 release pair. + Note: Pre-built wheels are currently available for vLLM-Omni 0.11.0rc1, 0.12.0rc1, 0.14.0rc1, 0.14.0, 0.16.0, 0.18.0, 0.20.0, 0.21.0, 0.22.0, 0.23.0, 0.24.0, 0.25.0, 0.26.0, and 0.28.0. If you need a newer unreleased revision, please [build from source](https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/installation/gpu/#build-wheel-from-source). === "NVIDIA CUDA" diff --git a/docs/getting_started/installation/gpu/cuda.inc.md b/docs/getting_started/installation/gpu/cuda.inc.md index 5e6cd47c9..f6a1004a6 100644 --- a/docs/getting_started/installation/gpu/cuda.inc.md +++ b/docs/getting_started/installation/gpu/cuda.inc.md @@ -5,7 +5,7 @@ # --8<-- [end:requirements] # --8<-- [start:set-up-using-python] -vLLM-Omni depends on the matching major/minor release of vLLM. The vLLM-Omni 0.28.x release line uses vLLM 0.28.x. +vLLM-Omni depends on the matching major/minor release of vLLM. The 0.29 development line uses vLLM 0.29.x. Published 0.28.0 wheels and images use vLLM 0.28.x. !!! note PyTorch installed via `conda` will statically link `NCCL` library, which can cause issues when vLLM tries to use `NCCL`. See for more details. @@ -18,6 +18,8 @@ Therefore, it is recommended to install vLLM and vLLM-Omni with a **fresh new** #### Installation of vLLM +These pre-built wheel instructions install the published vLLM-Omni 0.28.0 release. For the 0.29 development line, use the source-install instructions below. + vLLM-Omni is built based on vLLM. Please install it with command below. ```bash uv pip install vllm==0.28.0 --torch-backend=auto @@ -26,12 +28,12 @@ uv pip install vllm==0.28.0 --torch-backend=auto #### Installation of vLLM-Omni ```bash -uv pip install vllm-omni +uv pip install vllm-omni==0.28.0 ``` To run Gradio demos, also install the optional extras: ```bash -uv pip install 'vllm-omni[demo]' +uv pip install 'vllm-omni[demo]==0.28.0' ``` # --8<-- [end:pre-built-wheels] @@ -39,13 +41,13 @@ uv pip install 'vllm-omni[demo]' # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.28.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.29.0 release version of the library ```bash -uv pip install vllm==0.28.0 --torch-backend=auto +uv pip install vllm==0.29.0 --torch-backend=auto ``` -The 0.28.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. +The 0.29.0 release of vLLM ships CUDA 13.0-compatible binaries by default. If you need a different CUDA variant or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -66,12 +68,12 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.28.0 +git checkout v0.29.0 ``` Set up environment variables to get pre-built wheels. If there are internet problems, just download the whl file manually. And set `VLLM_PRECOMPILED_WHEEL_LOCATION` as your local absolute path of whl file. ```bash -#For CUDA 13.0 (the default for v0.28.0; the wheel filename has no `+cu130` suffix) -export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.28.0/vllm-0.28.0-cp38-abi3-manylinux_2_28_x86_64.whl +#For CUDA 13.0 (the default for v0.29.0; the wheel filename has no `+cu130` suffix) +export VLLM_PRECOMPILED_WHEEL_LOCATION=https://github.com/vllm-project/vllm/releases/download/v0.29.0/vllm-0.29.0-cp38-abi3-manylinux_2_28_x86_64.whl ``` Install vllm with command below (If you have no existing PyTorch). ```bash @@ -131,7 +133,7 @@ If you want to specify the base vLLM version: ```bash DOCKER_BUILDKIT=1 docker build \ -f docker/Dockerfile.cuda \ - --build-arg BASE_IMAGE=vllm/vllm-openai:v0.28.0 \ + --build-arg BASE_IMAGE=vllm/vllm-openai:v0.29.0 \ -t vllm-omni-cuda . ``` diff --git a/docs/getting_started/installation/gpu/rocm.inc.md b/docs/getting_started/installation/gpu/rocm.inc.md index e0f1614ae..d15bba2eb 100644 --- a/docs/getting_started/installation/gpu/rocm.inc.md +++ b/docs/getting_started/installation/gpu/rocm.inc.md @@ -11,6 +11,8 @@ vLLM-Omni current recommends the steps in under setup through Docker Images. #### Installation of vLLM +These pre-built wheel instructions install the published vLLM-Omni 0.28.0 release. For the 0.29 development line, use the source-install instructions below. + vLLM-Omni is built based on vLLM. Please install it with command below. ```bash uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.28.0/rocm723 @@ -22,7 +24,7 @@ uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/roc # we need to add --no-build-isolation as the torch # is not obtained from pypi, we have to install using the # torch installed in our environment -uv pip install vllm-omni +uv pip install vllm-omni==0.28.0 # Optional if want to run Qwen3 TTS uv pip uninstall onnxruntime # should be removed before we can install onnxruntime-rocm @@ -34,13 +36,13 @@ uv pip install onnxruntime-rocm # --8<-- [start:build-wheel-from-source] #### Installation of vLLM -If you do not need to modify source code of vLLM, you can directly install the stable 0.28.0 release version of the library +If you do not need to modify source code of vLLM, you can directly install the stable 0.29.0 release version of the library ```bash -uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.28.0/rocm723 +uv pip install vllm==0.29.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.29.0/rocm723 ``` -The pre-built 0.28.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. +The pre-built 0.29.0 vLLM wheel targets ROCm 7.2.3. If you need a different ROCm stack or want to reuse an existing PyTorch installation, build vLLM from source instead. #### Installation of vLLM-Omni Since vllm-omni is rapidly evolving, it's recommended to install it from source @@ -58,7 +60,7 @@ If you want to check, modify or debug with source code of vLLM, install the libr ```bash git clone https://github.com/vllm-project/vllm.git cd vllm -git checkout v0.28.0 +git checkout v0.29.0 python3 -m pip install -r requirements/rocm.txt python3 setup.py develop ``` diff --git a/docs/getting_started/quickstart.md b/docs/getting_started/quickstart.md index 036c2da17..8363e05ff 100644 --- a/docs/getting_started/quickstart.md +++ b/docs/getting_started/quickstart.md @@ -19,10 +19,10 @@ uv venv --python 3.12 --seed source .venv/bin/activate # On CUDA -uv pip install vllm==0.28.0 --torch-backend=auto +uv pip install vllm==0.29.0 --torch-backend=auto # On ROCm -uv pip install vllm==0.28.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.28.0/rocm723 +uv pip install vllm==0.29.0+rocm723 --extra-index-url https://wheels.vllm.ai/rocm/0.29.0/rocm723 git clone https://github.com/vllm-project/vllm-omni.git cd vllm-omni @@ -34,7 +34,7 @@ For additional installation methods — please see the [installation guide](inst !!! note It is important to install the same major & minor version of vLLM and vLLM Omni, otherwise things may not work as expected. If the versions are misaligned, you will see a warning when you import vLLM Omni. - If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.28.0` and vLLM Omni `0.28.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. + If you are seeing strange behavior with the `vllm` command not handling the `--omni` flag correctly, you most likely have a version mismatch with vLLM < `0.29.0` and vLLM Omni `0.29.0`, as vLLM Omni no longer hijacks the vLLM entrypoint. Updating vLLM should resolve this issue. ## Offline Inference diff --git a/tests/attention/test_fish_kvcache_attn.py b/tests/attention/test_fish_kvcache_attn.py index d38380e13..4a8ea17e8 100644 --- a/tests/attention/test_fish_kvcache_attn.py +++ b/tests/attention/test_fish_kvcache_attn.py @@ -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 @@ -214,11 +214,20 @@ def test_fish_kvcache_backend_wraps_only_model_instance(monkeypatch): assert fish_kvcache_backend.get_fish_kvcache_attn_stats()["small_hit_count"] == 1 -def _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size): +def _vllm_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size, dtype=torch.float16): # Source of truth for the paged KV cache layout the backend must unpack. - from vllm.v1.attention.backends.flash_attn import FlashAttentionBackend + # vLLM 0.29 removed AttentionBackend.get_kv_cache_shape; the per-layer shape + # now comes from the spec, with the content dim expressed in bytes. + from vllm.v1.kv_cache_interface import FullAttentionSpec, compute_layer_kv_cache_shape_bytes - return FlashAttentionBackend.get_kv_cache_shape(num_blocks, block_size, num_kv_heads, head_size) + spec = FullAttentionSpec( + block_size=block_size, + num_kv_heads=num_kv_heads, + head_size=head_size, + dtype=dtype, + ) + num_pages, num_heads, num_states, content_bytes = compute_layer_kv_cache_shape_bytes(spec, num_blocks) + return (num_pages, num_heads, num_states, content_bytes // dtype.itemsize) def test_fish_kvcache_backend_unbinds_kv_on_vllm_cache_layout(monkeypatch): diff --git a/tests/buildkite/test_upload_pipeline.py b/tests/buildkite/test_upload_pipeline.py index 9011a1aef..7492a4b82 100644 --- a/tests/buildkite/test_upload_pipeline.py +++ b/tests/buildkite/test_upload_pipeline.py @@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2] / ".buildkite" / "com from skip_ci import resolve_ci_decision # noqa: E402 from upload_pipeline import ( # noqa: E402 + CUDA_HF_TOKEN_EXPORT, _expand_mirror_hardwares, _get_mirror_hw_selector, _load_bootstrap_steps, @@ -143,6 +144,10 @@ def test_mirror_hardwares_l4_1_expands_to_agents_and_plugins(monkeypatch: pytest container = step["plugins"][0]["kubernetes"]["podSpec"]["containers"][0] assert container["image"].endswith("$BUILDKITE_COMMIT") assert container["resources"]["limits"]["nvidia.com/gpu"] == 1 + env_names = {item["name"] for item in container["env"]} + assert "VLLM_CI_HF_TOKEN" in env_names + assert "HF_TOKEN" not in env_names + assert step["commands"] == [CUDA_HF_TOKEN_EXPORT, "pytest -sv tests/example"] def test_mirror_hardwares_l4_preserves_explicit_retry() -> None: @@ -182,6 +187,33 @@ def test_mirror_hardwares_a2b3_npu_4_expands_agents_image_and_plugins() -> None: assert step["plugins"][0]["kubernetes"]["podSpecPatch"]["imagePullSecrets"] == [ {"name": "swr-secret"}, ] + assert step["commands"] == ["pytest -sv tests/example"] + + +def test_all_cuda_mirror_hardwares_restore_hf_token_at_runtime() -> None: + for name in ( + "l4_1", + "l4_2", + "l4_3", + "l4_4", + "h100_1", + "h100_2", + "h100_3", + "h100_4", + "b200_1", + "b200_2", + "b200_3", + "b200_4", + ): + step = _expand_mirror_hardwares( + {"label": name, "mirror_hardwares": name, "commands": ["pytest -sv tests/example"]}, + ) + assert step is not None + container = step["plugins"][0]["kubernetes"]["podSpec"]["containers"][0] + env_names = {item["name"] for item in container["env"]} + assert "VLLM_CI_HF_TOKEN" in env_names + assert "HF_TOKEN" not in env_names + assert step["commands"][0] == CUDA_HF_TOKEN_EXPORT def _gpu_limit(step: dict) -> int: diff --git a/tests/config/test_omni_config.py b/tests/config/test_omni_config.py index 3e499dc38..40229f521 100644 --- a/tests/config/test_omni_config.py +++ b/tests/config/test_omni_config.py @@ -215,6 +215,16 @@ def test_from_pipeline_config_normalizes_stage_engine_extras_without_expanding_s assert stage.diffusion_config.model_config["default_robot_embodiment"] == "roboarena" +@pytest.mark.parametrize("disabled", [True, False]) +def test_frontend_log_stats_flag_is_not_an_unowned_stage_argument(disabled): + from vllm_omni.engine.stage_init_utils import build_engine_args_dict_from_omni_stage_config + + config = _from_pipeline_key("dots_tts", cli_overrides={"disable_log_stats": disabled}) + assert config.stage_configs + engine_args = build_engine_args_dict_from_omni_stage_config(config.stage_by_id(0), model="test-model") + assert "disable_log_stats" not in engine_args + + def test_from_pipeline_config_applies_cli_overrides_without_stage_config_runtime_bridge(): omni_config = _from_pipeline_key( "qwen3_tts", diff --git a/tests/core/sched/test_omni_scheduler_forces_v1_model_runner.py b/tests/core/sched/test_omni_scheduler_forces_v1_model_runner.py new file mode 100644 index 000000000..fb8546e2d --- /dev/null +++ b/tests/core/sched/test_omni_scheduler_forces_v1_model_runner.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""The omni scheduler must stay on the v1 model runner, like every omni worker. + +vLLM 0.28 resolved this config to v1; 0.29 ends its fallback chain with v2. When +only the workers forced v1, the scheduler took its v2 fast path -- which omits +resumed-request token ids -- while the v1 runner still read them, so resuming a +request raised ``KeyError: `` in ``_update_states`` and killed the engine +core (Multi-Replica Startup Test). + +The default is upstream's to change, so this pins Omni's side of the contract +rather than the default itself. +""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from vllm_omni.core.sched.omni_scheduler_mixin import OmniSchedulerMixin + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _Scheduler(OmniSchedulerMixin): + """Minimal carrier for the shared init hook both omni schedulers call.""" + + def __init__(self, *, use_v2_model_runner: bool) -> None: + self.use_v2_model_runner = use_v2_model_runner + self.vllm_config = SimpleNamespace( + model_config=SimpleNamespace( + async_chunk=False, + stage_id=0, + pooling_output_decoder=None, + ) + ) + self._init_omni_io_scheduling_state() + + +@pytest.mark.parametrize("initial", [True, False]) +def test_scheduler_ends_on_v1_model_runner(initial: bool) -> None: + """Whatever upstream resolves, the omni scheduler runs v1.""" + assert _Scheduler(use_v2_model_runner=initial).use_v2_model_runner is False + + +def test_v2_default_does_not_reach_the_resumed_request_fast_path() -> None: + """The v2 fast path is what dropped resumed-request token ids. + + ``OmniGenerationScheduler`` branches on ``use_v2_model_runner`` when it + assembles SchedulerOutput; forcing it false keeps the v1 assembly that + carries ``all_token_ids`` for resumed requests, which the v1 runner reads. + """ + scheduler = _Scheduler(use_v2_model_runner=True) + + assert not scheduler.use_v2_model_runner, ( + "scheduler would take the v2 fast path while omni workers run v1; " + "resumed requests then KeyError in _update_states" + ) diff --git a/tests/diffusion/diffusion_kv/test_block_tables.py b/tests/diffusion/diffusion_kv/test_block_tables.py index f2002f1c6..aa8e07307 100644 --- a/tests/diffusion/diffusion_kv/test_block_tables.py +++ b/tests/diffusion/diffusion_kv/test_block_tables.py @@ -279,17 +279,17 @@ def _install_native_modules(monkeypatch: pytest.MonkeyPatch, events: list[tuple] group = SimpleNamespace(get_metadata_builder=lambda _index: SimpleNamespace(reorder_batch_threshold=None)) return [[group], [group]], "cg-support", [4, 8] + # vLLM 0.29 dropped attn_groups and cache_dtype from init_kv_cache: the + # layout now rides on the resolved CacheConfig.kv_cache_layout instead. def init_cache( runner_kv_caches, forward_context, kv_cache_config, - attn_groups, device, - cache_dtype, kernel_block_sizes, vllm_config, ): - events.append(("cache", kv_cache_config, attn_groups, kernel_block_sizes)) + events.append(("cache", kv_cache_config, kernel_block_sizes)) runner_kv_caches.extend(["cache-0", "cache-1"]) return {"layer-0": "tensor-0", "layer-1": "tensor-1"} diff --git a/tests/diffusion/diffusion_kv/test_initialization.py b/tests/diffusion/diffusion_kv/test_initialization.py index 861f9564d..b5f914b11 100644 --- a/tests/diffusion/diffusion_kv/test_initialization.py +++ b/tests/diffusion/diffusion_kv/test_initialization.py @@ -299,8 +299,10 @@ def test_minus_one_max_model_len_is_auto_fitted_by_native_cache_sizing() -> None ) assert vllm_config.model_config.original_max_model_len == -1 - # Sixteen physical pages provide ``16 * block_size`` tokens. The native - # backend chooses the block geometry, so keep this assertion backend + # Sixteen physical pages provide ``(16 - 1) * block_size`` tokens: vLLM 0.29 + # subtracts the null block that BlockPool permanently holds back before + # sizing (check_enough_kv_cache_memory), so only 15 pages are usable. The + # native backend chooses the block geometry, so keep this assertion backend # independent instead of assuming the generic 16-token block size. - expected_max_model_len = min(model_limit, spec.block_size * 16) + expected_max_model_len = min(model_limit, spec.block_size * (16 - 1)) assert vllm_config.model_config.max_model_len == expected_max_model_len diff --git a/tests/diffusion/diffusion_kv/test_layout.py b/tests/diffusion/diffusion_kv/test_layout.py new file mode 100644 index 000000000..713849e95 --- /dev/null +++ b/tests/diffusion/diffusion_kv/test_layout.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Physical KV cache layout selection and propagation for the Diffusion cache.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +import torch +from vllm.config import CacheConfig +from vllm.v1.kv_cache_interface import FullAttentionSpec +from vllm.v1.kv_cache_layout import KVCacheLayout + +from vllm_omni.diffusion.diffusion_kv.layout import ( + DEFAULT_DIFFUSION_KV_CACHE_LAYOUT, + adopt_kv_cache_layout, + assert_backend_layout_supported, + build_kv_cache_tensor, + resolve_diffusion_kv_cache_layout, +) + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] + + +def _config(): + return SimpleNamespace(cache_config=CacheConfig()) + + +class _DenseBackend: + @classmethod + def indexes_kv_by_block_stride(cls) -> bool: + return False + + +class _BlockStrideBackend: + @classmethod + def indexes_kv_by_block_stride(cls) -> bool: + return True + + +def test_default_layout_is_not_block_outermost() -> None: + """The default reproduces the pre-0.29 indexes_kv_by_block_stride=False.""" + vllm_config = _config() + layout = resolve_diffusion_kv_cache_layout(vllm_config) + + assert layout is DEFAULT_DIFFUSION_KV_CACHE_LAYOUT + assert layout.is_block_outermost is False + assert vllm_config.cache_config.get_resolved_kv_cache_layout() is layout + + +def test_block_stride_backend_selects_block_outermost_layout() -> None: + layout = resolve_diffusion_kv_cache_layout(_config(), indexes_kv_by_block_stride=True) + + assert layout.is_block_outermost is True + + +def test_resolution_is_idempotent() -> None: + vllm_config = _config() + + assert resolve_diffusion_kv_cache_layout(vllm_config) is resolve_diffusion_kv_cache_layout(vllm_config) + + +def test_contradicting_requirement_raises_instead_of_misaddressing() -> None: + vllm_config = _config() + resolve_diffusion_kv_cache_layout(vllm_config) + + with pytest.raises(ValueError, match="contradicts the attention"): + resolve_diffusion_kv_cache_layout(vllm_config, indexes_kv_by_block_stride=True) + + +def test_preset_layout_is_honoured() -> None: + vllm_config = _config() + vllm_config.cache_config.kv_cache_layout = KVCacheLayout.BLNHC.name + + layout = resolve_diffusion_kv_cache_layout(vllm_config, indexes_kv_by_block_stride=True) + + assert layout is KVCacheLayout.BLNHC + + +def test_backend_assertion_is_quiet_until_the_layout_is_resolved() -> None: + """Workers collect specs before the control plane hands them the layout.""" + vllm_config = _config() + + assert_backend_layout_supported(vllm_config, _BlockStrideBackend) # unresolved: nothing to check + + resolve_diffusion_kv_cache_layout(vllm_config) + assert_backend_layout_supported(vllm_config, _DenseBackend) + assert_backend_layout_supported(vllm_config, None) + with pytest.raises(ValueError, match="contradicts the attention"): + assert_backend_layout_supported(vllm_config, _BlockStrideBackend) + + +def test_worker_adopts_the_layout_carried_on_the_kv_cache_config() -> None: + """Diffusion workers receive only a KVCacheConfig, so the name rides on it.""" + worker = _config() + shipped = SimpleNamespace(kv_cache_layout=KVCacheLayout.BLNHC.name) + + assert adopt_kv_cache_layout(worker, shipped) is KVCacheLayout.BLNHC + assert worker.cache_config.get_resolved_kv_cache_layout() is KVCacheLayout.BLNHC + + +def test_worker_without_a_carried_layout_falls_back_to_the_same_default() -> None: + worker = _config() + + assert adopt_kv_cache_layout(worker, SimpleNamespace(kv_cache_layout=None)) is DEFAULT_DIFFUSION_KV_CACHE_LAYOUT + + +def test_worker_disagreeing_with_the_control_plane_raises() -> None: + worker = _config() + resolve_diffusion_kv_cache_layout(worker) + + with pytest.raises(ValueError, match="disagrees with the layout"): + adopt_kv_cache_layout(worker, SimpleNamespace(kv_cache_layout=KVCacheLayout.BLNHC.name)) + + +def test_block_stride_requirement_propagates_control_plane_to_worker() -> None: + """A block-stride backend must reach the worker as a block-outermost layout. + + Covers the whole seam: the control plane resolves with the requirement, the + name rides on each rank-local KVCacheConfig, the worker adopts it, and the + backend contract check then passes instead of raising. + """ + control_plane = _config() + layout = resolve_diffusion_kv_cache_layout(control_plane, indexes_kv_by_block_stride=True) + assert layout.is_block_outermost is True + + shipped = SimpleNamespace(kv_cache_layout=layout.name) + + worker = _config() + assert adopt_kv_cache_layout(worker, shipped) is layout + # The worker's own backend requirement now agrees with what it was handed. + assert_backend_layout_supported(worker, _BlockStrideBackend) + with pytest.raises(ValueError, match="contradicts the attention"): + assert_backend_layout_supported(worker, _DenseBackend) + + +def test_tensor_strides_come_from_the_upstream_derivation() -> None: + spec = FullAttentionSpec(block_size=16, num_kv_heads=2, head_size=8, dtype=torch.bfloat16) + + tensor = build_kv_cache_tensor(spec, 4, ["layer0", "layer1"]) + + assert tensor.layers == ["layer0", "layer1"] + assert tensor.size == spec.page_size_bytes * 2 * 4 + # Layer-outermost: each layer owns a contiguous run of its own blocks. + assert tensor.layer_stride == spec.page_size_bytes * 4 + assert tensor.block_stride == spec.page_size_bytes diff --git a/tests/diffusion/diffusion_kv/test_manager.py b/tests/diffusion/diffusion_kv/test_manager.py index 5152c2a67..ed39f6523 100644 --- a/tests/diffusion/diffusion_kv/test_manager.py +++ b/tests/diffusion/diffusion_kv/test_manager.py @@ -1,11 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import pytest import torch import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec +from tests.helpers.kv_layout import build_kv_cache_tensor from vllm_omni.diffusion.diffusion_kv.manager import DiffusionKVAdmissionError, DiffusionKVCacheManager from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVContext, DiffusionKVRequest @@ -24,7 +25,7 @@ def _config(num_blocks: int) -> KVCacheConfig: ) return KVCacheConfig( num_blocks=num_blocks, - kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=["layer0"])], + kv_cache_tensors=[build_kv_cache_tensor(spec, num_blocks, ["layer0"])], kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)], ) diff --git a/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py b/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py index 1f242420d..535c1b411 100644 --- a/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py +++ b/tests/diffusion/diffusion_kv/test_paged_attention_adapter.py @@ -15,6 +15,7 @@ import torch from torch import nn from vllm.v1.kv_cache_interface import FullAttentionSpec +from tests.helpers.kv_layout import layout_for_backend from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.diffusion_kv import paged_attention_adapter as adapter_module @@ -1239,7 +1240,8 @@ def test_layer_adapter_accepts_platform_native_backend_and_uses_rank_local_heads assert native_layer.num_heads == 4 assert native_layer.num_kv_heads == 2 assert native_layer.spec.num_kv_heads == 2 - assert native_layer.spec.indexes_kv_by_block_stride is True + # 0.29: the flag no longer rides on the spec, it selects the layout. + assert layout_for_backend(native_layer.attn_backend).is_block_outermost is True assert native_layer._q_scale_float == 1.0 assert native_layer._k_scale_float == 1.0 assert native_layer._v_scale_float == 1.0 diff --git a/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py b/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py index ef5d75f4d..d890dcc7b 100644 --- a/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py +++ b/tests/diffusion/diffusion_kv/test_paged_attention_adapter_gpu.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from __future__ import annotations @@ -10,12 +10,14 @@ import torch from torch import nn from torch.nn import functional as F from vllm.config import AttentionConfig, CacheConfig, VllmConfig, set_current_vllm_config -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec from vllm.v1.worker.gpu.attn_utils import init_attn_backend, init_kv_cache from vllm.v1.worker.gpu.block_table import BlockTables +from tests.helpers.kv_layout import build_kv_cache_tensor from vllm_omni.diffusion.attention.backends.abstract import AttentionMetadata from vllm_omni.diffusion.attention.backends.flash_attn import FlashAttentionImpl +from vllm_omni.diffusion.diffusion_kv.layout import resolve_diffusion_kv_cache_layout from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import ( DiffusionPagedAttentionAdapter, DiffusionPagedAttentionLayerAdapter, @@ -88,12 +90,7 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N canonical_spec = native_layer.spec kv_cache_config = KVCacheConfig( num_blocks=4, - kv_cache_tensors=[ - KVCacheTensor( - size=canonical_spec.page_size_bytes * 4, - shared_by=[_LAYER_NAME], - ) - ], + kv_cache_tensors=[build_kv_cache_tensor(canonical_spec, 4, [_LAYER_NAME])], kv_cache_groups=[ KVCacheGroupSpec( layer_names=[_LAYER_NAME], @@ -101,6 +98,9 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N ) ], ) + # 0.29 reads the resolved physical layout when allocating; the diffusion + # control plane pins it, so this standalone harness must too. + resolve_diffusion_kv_cache_layout(vllm_config) with set_current_vllm_config(vllm_config): attn_groups, _, kernel_block_sizes = init_attn_backend( kv_cache_config, @@ -108,13 +108,13 @@ def test_adapter_executes_native_paged_attention_on_non_contiguous_blocks() -> N device, ) runner_kv_caches: list[torch.Tensor | list[torch.Tensor]] = [] + # vLLM 0.29 dropped attn_groups and cache_dtype here: the layout now + # comes from the resolved CacheConfig.kv_cache_layout inside the call. init_kv_cache( runner_kv_caches, vllm_config.compilation_config.static_forward_context, kv_cache_config, - attn_groups, device, - vllm_config.cache_config.cache_dtype, kernel_block_sizes, vllm_config, ) diff --git a/tests/diffusion/diffusion_kv/test_request.py b/tests/diffusion/diffusion_kv/test_request.py index ae5609b2a..ac9185e09 100644 --- a/tests/diffusion/diffusion_kv/test_request.py +++ b/tests/diffusion/diffusion_kv/test_request.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import pytest import torch @@ -10,10 +10,10 @@ from vllm.v1.kv_cache_interface import ( FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, - KVCacheTensor, ) from vllm.v1.request import RequestStatus +from tests.helpers.kv_layout import build_kv_cache_tensor from vllm_omni.diffusion.diffusion_kv.request import DiffusionKVContext, DiffusionKVRequest pytestmark = [pytest.mark.core_model, pytest.mark.cpu, pytest.mark.diffusion] @@ -119,7 +119,7 @@ def _manager(*, enable_caching: bool) -> KVCacheManager: group = KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec) config = KVCacheConfig( num_blocks=8, - kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["layer0"])], + kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["layer0"])], kv_cache_groups=[group], ) return KVCacheManager( diff --git a/tests/diffusion/diffusion_kv/test_spec_discovery.py b/tests/diffusion/diffusion_kv/test_spec_discovery.py index 9485ed046..1ccef7a9f 100644 --- a/tests/diffusion/diffusion_kv/test_spec_discovery.py +++ b/tests/diffusion/diffusion_kv/test_spec_discovery.py @@ -8,9 +8,10 @@ from unittest.mock import Mock import pytest import torch from torch import nn -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec import vllm_omni.diffusion.worker.diffusion_worker as diffusion_worker_module +from tests.helpers.kv_layout import build_kv_cache_tensor, layout_for_backend from vllm_omni.diffusion.attention.layer import Attention from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode from vllm_omni.diffusion.worker.diffusion_model_runner import DiffusionModelRunner @@ -79,7 +80,9 @@ def test_runner_discovers_native_spec_from_loaded_attention() -> None: assert spec.num_kv_heads == 2 assert spec.head_size == 8 assert spec.dtype is torch.bfloat16 - assert spec.indexes_kv_by_block_stride is True + # vLLM 0.29 moved the block-stride decision off the spec onto the resolved + # physical layout; the backend's declaration must still select one. + assert layout_for_backend(_Backend).is_block_outermost is True assert spec.non_causal is True @@ -112,7 +115,7 @@ def test_runner_retains_matching_rank_local_config() -> None: spec = runner.get_kv_cache_spec()["image_attention"] config = KVCacheConfig( num_blocks=8, - kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["image_attention"])], + kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["image_attention"])], kv_cache_groups=[KVCacheGroupSpec(layer_names=["image_attention"], kv_cache_spec=spec)], ) @@ -126,7 +129,7 @@ def test_runner_rejects_rank_local_config_for_different_layers() -> None: spec = runner.get_kv_cache_spec()["image_attention"] config = KVCacheConfig( num_blocks=8, - kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * 8, shared_by=["other_attention"])], + kv_cache_tensors=[build_kv_cache_tensor(spec, 8, ["other_attention"])], kv_cache_groups=[KVCacheGroupSpec(layer_names=["other_attention"], kv_cache_spec=spec)], ) diff --git a/tests/diffusion/layers/test_adalayernorm.py b/tests/diffusion/layers/test_adalayernorm.py index 5e41b7a26..94cadf5ac 100644 --- a/tests/diffusion/layers/test_adalayernorm.py +++ b/tests/diffusion/layers/test_adalayernorm.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Unit tests for shared AdaLayerNorm layers used by FLUX and other models.""" import os @@ -46,7 +46,7 @@ def _force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) diff --git a/tests/diffusion/layers/test_rope_broadcast.py b/tests/diffusion/layers/test_rope_broadcast.py index 284049347..d7d26350d 100644 --- a/tests/diffusion/layers/test_rope_broadcast.py +++ b/tests/diffusion/layers/test_rope_broadcast.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """ Shape broadcasting regression tests for RotaryEmbedding.forward_native. @@ -16,7 +16,11 @@ import types import pytest import torch -from vllm_omni.diffusion.layers.rope import RotaryEmbedding, apply_rotary_emb_mindiesd +from vllm_omni.diffusion.layers.rope import ( + RotaryEmbedding, + RotaryEmbeddingWan, + apply_rotary_emb_mindiesd, +) pytestmark = [pytest.mark.core_model, pytest.mark.cpu] @@ -239,6 +243,25 @@ class TestRotaryEmbeddingNativeShapeRegression: assert not torch.isnan(output).any() +def test_cuda_paths_fall_back_without_vendored_rotary_kernel() -> None: + """Source/CPU installs need not contain the wheel-vendored CUDA kernel.""" + x = torch.randn(1, 5, 2, 8) + + rope = RotaryEmbedding(is_neox_style=True) + rope.apply_rotary_emb_vllm_flash_attn = None + output = rope.forward_cuda(x, torch.randn(5, 4), torch.randn(5, 4)) + assert output.shape == x.shape + + wan_rope = RotaryEmbeddingWan(is_neox_style=False, half_head_dim=True) + wan_rope.apply_rotary_emb_vllm_flash_attn = None + output = wan_rope.forward_cuda( + x, + torch.randn(1, 5, 1, 4), + torch.randn(1, 5, 1, 4), + ) + assert output.shape == x.shape + + def test_mindie_full_dim_rope_keeps_h3_cos_sin_layout(monkeypatch: pytest.MonkeyPatch) -> None: """H3 passes complete rotary dims to MindIE instead of the CUDA half-dim layout.""" captured: dict[str, object] = {} diff --git a/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py b/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py index 4296c1cde..43e2f12f4 100644 --- a/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py +++ b/tests/diffusion/models/boogu_image/test_boogu_image_transformer.py @@ -59,7 +59,7 @@ def _force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) diff --git a/tests/diffusion/models/dreamzero/test_qkv_fusion.py b/tests/diffusion/models/dreamzero/test_qkv_fusion.py index 16b78693f..d67a01e48 100644 --- a/tests/diffusion/models/dreamzero/test_qkv_fusion.py +++ b/tests/diffusion/models/dreamzero/test_qkv_fusion.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Equivalence test for DreamZero self-attn QKV fusion. @@ -52,7 +52,7 @@ def _force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) diff --git a/tests/diffusion/models/glm_image/test_glm_image_quantization.py b/tests/diffusion/models/glm_image/test_glm_image_quantization.py index 8ab046f0c..1a6170ca2 100644 --- a/tests/diffusion/models/glm_image/test_glm_image_quantization.py +++ b/tests/diffusion/models/glm_image/test_glm_image_quantization.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Unit tests for GLM-Image quantization support (W4A16/AutoRound). These tests verify that the GLM-Image DiT transformer correctly accepts and uses @@ -69,7 +69,7 @@ def _force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) diff --git a/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py b/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py index 20024abe7..62bcf9ce8 100644 --- a/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py +++ b/tests/diffusion/models/mistral_encoder/test_mistral_encoder_tp.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from types import SimpleNamespace import pytest @@ -64,6 +64,13 @@ def setup_tp_group(monkeypatch, mocker): lambda: 0, ) + # vLLM 0.29 added a CUDA-only fused embedding kernel that engages whenever + # tp_size > 1; these tests drive CPU tensors, so keep the portable masked path. + monkeypatch.setattr( + "vllm.model_executor.layers.vocab_parallel_embedding.current_platform.is_cuda", + lambda: False, + ) + mock_tp_group = mocker.MagicMock() mock_tp_group.world_size = 2 mocker.patch("vllm.distributed.parallel_state.get_tp_group", return_value=mock_tp_group) @@ -94,7 +101,7 @@ def setup_tp_group(monkeypatch, mocker): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) with set_current_vllm_config(VllmConfig(device_config=device_config)): diff --git a/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py b/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py index 52f1b6aa3..4f3a81491 100644 --- a/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py +++ b/tests/diffusion/models/qwen_image/test_qwen_image_rope_utils.py @@ -1,12 +1,39 @@ import pytest import torch -from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import QwenEmbedRope +from vllm_omni.diffusion.models.qwen_image.qwen_image_transformer import ( + QwenEmbedRope, + _apply_qwen_image_rotary_emb, +) from vllm_omni.diffusion.models.qwen_image.rope_utils import txt_seq_lens_from_embeds pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.mark.parametrize("dtype", [torch.float32, torch.float16, torch.bfloat16]) +@pytest.mark.parametrize("noncontiguous", [False, True]) +def test_qwen_rotary_preserves_frequency_precision(dtype, noncontiguous): + from diffusers.models.transformers.transformer_qwenimage import apply_rotary_emb_qwen + + generator = torch.Generator().manual_seed(42) + x = torch.randn(2, 17, 3, 128, generator=generator).to(dtype) + if noncontiguous: + x = x.transpose(1, 2).contiguous().transpose(1, 2) + angles = torch.randn(17, 64, generator=generator) + freqs = torch.polar(torch.ones_like(angles), angles) + expected = apply_rotary_emb_qwen(x, freqs, use_real=False) + actual = _apply_qwen_image_rotary_emb(x, freqs) + assert actual.dtype == dtype + assert actual.shape == x.shape + torch.testing.assert_close(actual, expected, rtol=0, atol=0) + + if dtype == torch.bfloat16: + # A regression to early BF16 frequency rounding must be observable. + rounded_freqs = torch.complex(freqs.real.to(dtype).float(), freqs.imag.to(dtype).float()) + rounded = _apply_qwen_image_rotary_emb(x, rounded_freqs) + assert not torch.equal(actual, rounded) + + def test_txt_seq_lens_from_embeds_uses_padded_width_not_valid_token_count(): prompt_embeds = torch.zeros(2, 64, 8) prompt_embeds_mask = torch.zeros(2, 64, dtype=torch.bool) diff --git a/tests/diffusion/models/sana_video/test_transformer_sana_video.py b/tests/diffusion/models/sana_video/test_transformer_sana_video.py index 0bc2ddb8e..9e6404b21 100644 --- a/tests/diffusion/models/sana_video/test_transformer_sana_video.py +++ b/tests/diffusion/models/sana_video/test_transformer_sana_video.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import os @@ -22,7 +22,7 @@ def _init_distributed(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) os.environ.setdefault("MASTER_ADDR", "localhost") os.environ.setdefault("MASTER_PORT", "29501") diff --git a/tests/diffusion/models/sana_video/test_transformer_sana_video_sp.py b/tests/diffusion/models/sana_video/test_transformer_sana_video_sp.py index 3f4c62075..6a0f0548e 100644 --- a/tests/diffusion/models/sana_video/test_transformer_sana_video_sp.py +++ b/tests/diffusion/models/sana_video/test_transformer_sana_video_sp.py @@ -41,8 +41,8 @@ def tp1_group(): os.environ.setdefault("MASTER_ADDR", "localhost") os.environ.setdefault("MASTER_PORT", "29501") - init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://") - initialize_model_parallel() + init_distributed_environment(world_size=1, rank=0, local_rank=0, distributed_init_method="env://", backend="gloo") + initialize_model_parallel(backend="gloo") yield cleanup_dist_env_and_memory() @@ -54,7 +54,7 @@ def force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) @@ -482,8 +482,12 @@ class _LockstepP2PDist: def test_tiny_transformer_sp2_lockstep_matches_dense(tp1_group, force_default_gemm, mocker, task) -> None: """Two lockstep rank threads over uneven frame shards must reproduce the dense tiny-transformer output, and must actually have sharded the work.""" + from vllm_omni.diffusion.attention.backends.sdpa import SDPABackend from vllm_omni.diffusion.models.sana_video import SanaVideoTransformer3DModel + # These CPU tensor tests must not probe a CUDA device during construction. + mocker.patch("vllm_omni.diffusion.attention.selector._cached_get_backend_cls", return_value=SDPABackend) + torch.manual_seed(3) model = SanaVideoTransformer3DModel(**_TINY_SP_CONFIG).eval() for _, param in sorted(model.named_parameters()): diff --git a/tests/diffusion/models/sana_video/test_transformer_sana_video_tp.py b/tests/diffusion/models/sana_video/test_transformer_sana_video_tp.py index 41ca717d6..a44c90146 100644 --- a/tests/diffusion/models/sana_video/test_transformer_sana_video_tp.py +++ b/tests/diffusion/models/sana_video/test_transformer_sana_video_tp.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """TP unit tests for SANA-Video: distributed RMSNorm, sharded attention and sharded GLUMB temporal conv. @@ -51,7 +51,7 @@ def force_default_gemm(monkeypatch): monkeypatch.setattr( "vllm.model_executor.layers.linear.dispatch_unquantized_gemm", - lambda: default_unquantized_gemm, + lambda *_args, **_kwargs: default_unquantized_gemm, ) diff --git a/tests/diffusion/models/t5_encoder/test_t5_encoder_tp.py b/tests/diffusion/models/t5_encoder/test_t5_encoder_tp.py index b36acc68a..cca73fb08 100644 --- a/tests/diffusion/models/t5_encoder/test_t5_encoder_tp.py +++ b/tests/diffusion/models/t5_encoder/test_t5_encoder_tp.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import pytest import torch from transformers import T5Config @@ -54,6 +54,13 @@ def setup_tp_group(monkeypatch, mocker): lambda: 0, ) + # vLLM 0.29 added a CUDA-only fused embedding kernel that engages whenever + # tp_size > 1; these tests drive CPU tensors, so keep the portable masked path. + monkeypatch.setattr( + "vllm.model_executor.layers.vocab_parallel_embedding.current_platform.is_cuda", + lambda: False, + ) + # TP group mock_tp_group = mocker.MagicMock() mock_tp_group.world_size = 2 diff --git a/tests/diffusion/quantization/test_component_routing.py b/tests/diffusion/quantization/test_component_routing.py index 943411779..6aebd6b60 100644 --- a/tests/diffusion/quantization/test_component_routing.py +++ b/tests/diffusion/quantization/test_component_routing.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Tests for component routing for quantization.""" from unittest.mock import MagicMock @@ -372,7 +372,8 @@ class TestComponentApplyVllmMapper: "thinker.model": "thinker.language_model.model", "talker.model": "talker.language_model.model", } - ).get_unstacked_mapper() + # vLLM 0.29 renamed get_unstacked_mapper to get_rename_mapper. + ).get_rename_mapper() config.apply_vllm_mapper(mapper) diff --git a/tests/diffusion/test_diffusion_scheduler.py b/tests/diffusion/test_diffusion_scheduler.py index d6d17c18f..20a2f0384 100644 --- a/tests/diffusion/test_diffusion_scheduler.py +++ b/tests/diffusion/test_diffusion_scheduler.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import asyncio import queue @@ -10,8 +10,9 @@ import pytest import torch import vllm.v1.core.single_type_kv_cache_manager as native_kv_managers from pytest_mock import MockerFixture -from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec, KVCacheTensor +from vllm.v1.kv_cache_interface import FullAttentionSpec, KVCacheConfig, KVCacheGroupSpec +from tests.helpers.kv_layout import build_kv_cache_tensor from vllm_omni.diffusion.data import DiffusionOutput, DiffusionRequestAbortedError from vllm_omni.diffusion.diffusion_engine import DiffusionEngine, DiffusionExecutionMode from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode @@ -104,7 +105,7 @@ def _initialize_paged_scheduler( ) config = KVCacheConfig( num_blocks=num_blocks, - kv_cache_tensors=[KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=["layer0"])], + kv_cache_tensors=[build_kv_cache_tensor(spec, num_blocks, ["layer0"])], kv_cache_groups=[KVCacheGroupSpec(layer_names=["layer0"], kv_cache_spec=spec)], ) scheduler.initialize( diff --git a/tests/e2e/accuracy/test_diffusers_backend_similarity.py b/tests/e2e/accuracy/test_diffusers_backend_similarity.py index be3d7f84c..b12e9c1e7 100644 --- a/tests/e2e/accuracy/test_diffusers_backend_similarity.py +++ b/tests/e2e/accuracy/test_diffusers_backend_similarity.py @@ -164,6 +164,7 @@ def _run_diffusers_wan22_i2v(*, model: str, output_path: Path, conditioning_imag ) pipe.scheduler = UniPCMultistepScheduler.from_config(pipe.scheduler.config, flow_shift=FLOW_SHIFT) pipe.to("cuda") + _set_matched_attention_backend(pipe) _diffusers_dummy_run(pipe) diff --git a/tests/e2e/online_serving/test_dots_tts_expansion.py b/tests/e2e/online_serving/test_dots_tts_expansion.py index 34bc54b8e..e5df55a33 100644 --- a/tests/e2e/online_serving/test_dots_tts_expansion.py +++ b/tests/e2e/online_serving/test_dots_tts_expansion.py @@ -85,5 +85,7 @@ def test_text_only_nonstreaming_pcm_001(omni_server, openai_client) -> None: "voice": "default", "min_audio_bytes": _MIN_AUDIO_BYTES, "min_hnr_db": -2.0, + # dots.tts-soar emits native 48 kHz PCM, which has no rate header. + "expected_sample_rate": 48_000, } openai_client.send_audio_speech_request(request_config) diff --git a/tests/engine/test_async_omni_engine_stage_init.py b/tests/engine/test_async_omni_engine_stage_init.py index e6f7f8a8b..27677bd54 100644 --- a/tests/engine/test_async_omni_engine_stage_init.py +++ b/tests/engine/test_async_omni_engine_stage_init.py @@ -1593,73 +1593,58 @@ def test_model_path_resolver_is_generic_and_model_owned(tmp_path): assert "model_path_resolver" not in engine_args -def test_build_stage0_input_processor_uses_omni_input_preprocessor(monkeypatch): +def test_build_stage0_input_processor_uses_omni_renderer_subclass(monkeypatch): + from vllm.renderers import BaseRenderer + import vllm_omni.engine.stage_init_utils as init_mod + from vllm_omni.inputs.preprocess import OmniRenderer, omni_renderer_cls - class DummyInputProcessor: - def __init__(self, vllm_config, renderer=None): - self.vllm_config = vllm_config - self.renderer = renderer or object() - self.input_preprocessor = None + class _Base(BaseRenderer): + def __init__(self, config, tokenizer): + self.config, self.tokenizer = config, tokenizer - class DummyOmniInputPreprocessor: - def __init__(self, vllm_config, renderer=None): - self.vllm_config = vllm_config - self.renderer = renderer + def render_messages(self, messages, params): # pragma: no cover - abstract stub + raise NotImplementedError - monkeypatch.setattr(init_mod, "InputProcessor", DummyInputProcessor) - monkeypatch.setattr(init_mod, "OmniInputPreprocessor", DummyOmniInputPreprocessor) - - input_processor = build_stage0_input_processor( - types.SimpleNamespace(model_config=types.SimpleNamespace(try_get_generation_config=lambda: {})) - ) - - assert isinstance(input_processor.input_preprocessor, DummyOmniInputPreprocessor) - assert input_processor.input_preprocessor.renderer is input_processor.renderer - - -def test_build_stage0_input_processor_does_not_resolve_tokenizer_when_skipped( - monkeypatch, -): - import vllm_omni.engine.stage_init_utils as init_mod - - token_only_renderer = object() + config = types.SimpleNamespace(model_config=types.SimpleNamespace(try_get_generation_config=lambda: {})) + built = omni_renderer_cls(_Base)(config, "tok") seen = {} class DummyInputProcessor: def __init__(self, vllm_config, renderer=None): - if renderer is None: - raise AssertionError("skip_tokenizer_init must supply a renderer") seen["renderer"] = renderer self.renderer = renderer - self.input_preprocessor = None - - class DummyOmniInputPreprocessor: - def __init__(self, vllm_config, renderer=None): - seen["preprocessor_renderer"] = renderer monkeypatch.setattr(init_mod, "InputProcessor", DummyInputProcessor) - monkeypatch.setattr( - init_mod, - "_build_token_only_renderer", - lambda _config: token_only_renderer, - ) - monkeypatch.setattr( - init_mod, - "OmniInputPreprocessor", - DummyOmniInputPreprocessor, - ) + monkeypatch.setattr(init_mod, "build_omni_renderer", lambda cfg: built if cfg is config else None) + processor = build_stage0_input_processor(config) + assert seen["renderer"] is built + assert isinstance(processor.renderer, OmniRenderer) + assert isinstance(processor.renderer, _Base) + assert not hasattr(processor, "input_preprocessor") + +def test_build_stage0_input_processor_does_not_resolve_tokenizer_when_skipped(monkeypatch): + import vllm_omni.engine.stage_init_utils as init_mod + + original = object() + + class DummyInputProcessor: + def __init__(self, vllm_config, renderer=None): + assert renderer is original + self.renderer = renderer + + def _must_not_resolve(_cfg): + raise AssertionError("tokenizer must not be resolved when skip_tokenizer_init=True") + + monkeypatch.setattr(init_mod, "InputProcessor", DummyInputProcessor) + monkeypatch.setattr(init_mod, "_build_token_only_renderer", lambda _: original) + monkeypatch.setattr(init_mod, "build_omni_renderer", _must_not_resolve) config = types.SimpleNamespace( - model_config=types.SimpleNamespace( - skip_tokenizer_init=True, - try_get_generation_config=lambda: {}, - ) + model_config=types.SimpleNamespace(skip_tokenizer_init=True, try_get_generation_config=lambda: {}) ) - build_stage0_input_processor(config) - - assert seen["renderer"] is token_only_renderer - assert seen["preprocessor_renderer"] is token_only_renderer + processor = build_stage0_input_processor(config) + assert processor.renderer is original def test_inject_kv_stage_info_infers_sender_tp_topology(): diff --git a/tests/entrypoints/openai/test_batch_chat_completions.py b/tests/entrypoints/openai/test_batch_chat_completions.py index 9fc72b2c1..c3866dbc8 100644 --- a/tests/entrypoints/openai/test_batch_chat_completions.py +++ b/tests/entrypoints/openai/test_batch_chat_completions.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + import asyncio from unittest.mock import AsyncMock @@ -12,7 +15,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionResponseChoice, ChatMessage, ) -from vllm.entrypoints.openai.engine.protocol import ErrorResponse, UsageInfo +from vllm.entrypoints.serve.engine.protocol import ErrorResponse, UsageInfo from vllm_omni.entrypoints.openai.batch_serving import OmniOpenAIServingChatBatch diff --git a/tests/entrypoints/openai_api/test_audio_format.py b/tests/entrypoints/openai_api/test_audio_format.py index a9bf76319..9c7eb9d89 100644 --- a/tests/entrypoints/openai_api/test_audio_format.py +++ b/tests/entrypoints/openai_api/test_audio_format.py @@ -248,7 +248,7 @@ class TestResolveAudioFormat: assert result == "pcm" def test_invalid_format_returns_error(self, serving_chat): - from vllm.entrypoints.openai.engine.protocol import ErrorResponse + from vllm.entrypoints.serve.engine.protocol import ErrorResponse request = self._make_request({"format": "aac", "voice": "alloy"}) result = serving_chat._resolve_audio_format(request) @@ -256,7 +256,7 @@ class TestResolveAudioFormat: assert "aac" in result.error.message def test_all_supported_formats_accepted(self, serving_chat): - from vllm.entrypoints.openai.engine.protocol import ErrorResponse + from vllm.entrypoints.serve.engine.protocol import ErrorResponse for fmt in SUPPORTED_CHAT_AUDIO_FORMATS: request = self._make_request({"format": fmt, "voice": "alloy"}) diff --git a/tests/entrypoints/openai_api/test_modality_validation.py b/tests/entrypoints/openai_api/test_modality_validation.py index fde0c0ca1..91198b2e2 100644 --- a/tests/entrypoints/openai_api/test_modality_validation.py +++ b/tests/entrypoints/openai_api/test_modality_validation.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Regression tests for output modality validation in chat completions. Covers: @@ -13,7 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest -from vllm.entrypoints.openai.engine.protocol import ErrorResponse +from vllm.entrypoints.serve.engine.protocol import ErrorResponse pytestmark = [pytest.mark.core_model, pytest.mark.cpu] diff --git a/tests/entrypoints/openai_api/test_serving_chat_metrics.py b/tests/entrypoints/openai_api/test_serving_chat_metrics.py index 2fdf9f646..6e796f480 100644 --- a/tests/entrypoints/openai_api/test_serving_chat_metrics.py +++ b/tests/entrypoints/openai_api/test_serving_chat_metrics.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Unit tests for OmniChatCompletionResponse/StreamResponse metrics field.""" from types import SimpleNamespace @@ -19,7 +20,7 @@ pytestmark = [pytest.mark.core_model, pytest.mark.cpu] def test_omni_chat_completion_response_metrics(): """Test OmniChatCompletionResponse metrics field works correctly.""" - from vllm.entrypoints.openai.engine.protocol import UsageInfo + from vllm.entrypoints.serve.engine.protocol import UsageInfo from vllm_omni.entrypoints.openai.protocol.chat_completion import ( OmniChatCompletionResponse, diff --git a/tests/entrypoints/openai_api/test_serving_speech.py b/tests/entrypoints/openai_api/test_serving_speech.py index 6a5ebcf7d..3922ab4a7 100644 --- a/tests/entrypoints/openai_api/test_serving_speech.py +++ b/tests/entrypoints/openai_api/test_serving_speech.py @@ -25,7 +25,7 @@ from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from pydantic import ValidationError from pytest_mock import MockerFixture -from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse +from vllm.entrypoints.serve.engine.protocol import ErrorInfo, ErrorResponse from vllm_omni.diffusion.request import OmniDiffusionRequest from vllm_omni.diffusion.sched.request_scheduler import RequestScheduler diff --git a/tests/entrypoints/openai_api/test_video_stream_handler.py b/tests/entrypoints/openai_api/test_video_stream_handler.py index e382b5e32..31717d0b7 100644 --- a/tests/entrypoints/openai_api/test_video_stream_handler.py +++ b/tests/entrypoints/openai_api/test_video_stream_handler.py @@ -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 """Runtime behavior tests for VideoStreamHandler. Every test creates a mock WebSocket, drives handle_session through a @@ -281,7 +281,7 @@ class TestHandleQueryErrors: @pytest.mark.asyncio async def test_query_returns_error_response(self): """create_chat_completion returns ErrorResponse → error forwarded.""" - from vllm.entrypoints.openai.engine.protocol import ErrorInfo, ErrorResponse + from vllm.entrypoints.serve.engine.protocol import ErrorInfo, ErrorResponse err = ErrorResponse( error=ErrorInfo( diff --git a/tests/entrypoints/test_stream_finish_reason.py b/tests/entrypoints/test_stream_finish_reason.py index 97ed2370b..87aeb787e 100644 --- a/tests/entrypoints/test_stream_finish_reason.py +++ b/tests/entrypoints/test_stream_finish_reason.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Tests for multi-modal streaming finish_reason behavior (commit 44c799bc). Verifies that the /v1/chat/completions streaming endpoint emits exactly one @@ -411,7 +412,7 @@ async def test_audio_chunk_without_waveform_keeps_stream_alive(): @pytest.mark.asyncio async def test_audio_choice_error_response_is_not_iterated_as_choices(): """Any ErrorResponse from the audio path is skipped, not iterated.""" - from vllm.entrypoints.openai.engine.protocol import ErrorResponse + from vllm.entrypoints.serve.engine.protocol import ErrorResponse serving_chat = build_serving_chat() request = make_request(modalities=["text", "audio"]) diff --git a/tests/helpers/kv_layout.py b/tests/helpers/kv_layout.py new file mode 100644 index 000000000..8351de8da --- /dev/null +++ b/tests/helpers/kv_layout.py @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Shared access to the production ``KVCacheTensor`` layout derivation. + +Tests deliberately go through the same code production uses +(``vllm_omni.diffusion.diffusion_kv.layout``) rather than recomputing strides, +so a regression in that derivation fails the suite instead of being masked by a +parallel test-only copy. +""" + +from types import SimpleNamespace + +from vllm.config import CacheConfig +from vllm.v1.kv_cache_layout import KVCacheLayout + +from vllm_omni.diffusion.diffusion_kv.layout import ( + build_kv_cache_tensor, + resolve_diffusion_kv_cache_layout, +) + +__all__ = ["build_kv_cache_tensor", "layout_for_backend"] + + +def layout_for_backend(attn_backend) -> KVCacheLayout: + """The physical layout ``attn_backend``'s block-stride declaration selects. + + Before 0.29 a backend's ``indexes_kv_by_block_stride()`` was copied onto + ``AttentionSpec``; asserting on the resolved layout is the surviving way to + check that declaration still reaches the cache. + """ + + vllm_config = SimpleNamespace(cache_config=CacheConfig()) + return resolve_diffusion_kv_cache_layout( + vllm_config, + indexes_kv_by_block_stride=attn_backend.indexes_kv_by_block_stride(), + ) diff --git a/tests/helpers/serving_chat.py b/tests/helpers/serving_chat.py index f1f2710b5..45add4b63 100644 --- a/tests/helpers/serving_chat.py +++ b/tests/helpers/serving_chat.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Shared scaffolding for OmniOpenAIServingChat unit tests. Provides factories for a minimal serving-chat instance, chat requests, and @@ -8,11 +9,11 @@ OmniRequestOutput objects, plus SSE stream collection/parsing utilities. import json from unittest.mock import MagicMock +from vllm.entrypoints.generate.base.protocol import DeltaMessage from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionResponseStreamChoice, ) -from vllm.entrypoints.openai.engine.protocol import DeltaMessage from vllm.entrypoints.openai.models.serving import OpenAIServingModels from vllm.outputs import CompletionOutput, RequestOutput diff --git a/tests/inputs/test_mm_processor.py b/tests/inputs/test_mm_processor.py new file mode 100644 index 000000000..813d7f436 --- /dev/null +++ b/tests/inputs/test_mm_processor.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from transformers import BatchFeature + +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +@pytest.mark.parametrize("counts", [{}, {"audio": 1, "image": 0}]) +@pytest.mark.parametrize("original_text", [None, "", "voice cloning text"]) +def test_custom_processor_bridge_preserves_prompt_kwargs_and_passthrough(counts, original_text): + class Processor(OmniMultiModalProcessor): + def _get_mm_fields_config(self, *args): + return {} + + def _get_prompt_updates(self, *args): + return [] + + processor = object.__new__(Processor) + processor.dummy_inputs = SimpleNamespace(get_dummy_text=Mock(return_value="dummy")) + processor._get_hf_mm_data = Mock(return_value=({"audios": ["wave"]}, {"embedding": "preserve"})) + processor._call_hf_processor = Mock(return_value=BatchFeature({"features": "processed"})) + items = Mock() + items.get_all_counts.return_value = counts + kwargs = {"sampling_rate": 16000} + if original_text is not None: + kwargs[processor._OMNI_PROMPT_TEXT_KEY] = original_text + before = dict(kwargs) + result = processor._apply_hf_processor_main(items, kwargs) + items.select.assert_called_once_with({k for k, v in counts.items() if v > 0}) + processor._call_hf_processor.assert_called_once_with( + "dummy" if original_text is None else original_text, + {"audios": ["wave"]}, + {"sampling_rate": 16000}, + {}, + ) + assert dict(result) == {"features": "processed", "embedding": "preserve"} + assert kwargs == before diff --git a/tests/inputs/test_preprocess.py b/tests/inputs/test_preprocess.py index db8817cf2..3fb48a7cc 100644 --- a/tests/inputs/test_preprocess.py +++ b/tests/inputs/test_preprocess.py @@ -1,35 +1,157 @@ # SPDX-License-Identifier: Apache-2.0 -"""Unit tests for OmniInputPreprocessor._process_text routing.""" +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Exercise Omni routing through the active upstream rendering pipeline.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock import pytest -from pytest_mock import MockerFixture +import torch +from vllm.inputs import tokens_input +from vllm.pooling_params import PoolingParams +from vllm.renderers import BaseRenderer +from vllm.renderers.params import TokenizeParams +from vllm.v1.engine.input_processor import InputProcessor + +from vllm_omni.inputs import preprocess as preprocess_mod +from vllm_omni.inputs.preprocess import OmniRenderer, build_omni_renderer, omni_renderer_cls pytestmark = [pytest.mark.core_model, pytest.mark.cpu] -class TestProcessTextMmProcessorKwargsRouting: - """Presence-based routing: an explicit empty ``mm_processor_kwargs`` dict - on the prompt still routes through ``_process_multimodal``. Required for - AR-based image generation (e.g. GLM-Image t2i) where the HF processor - supplies the image-generation scaffold from its own defaults and no extra - kwargs are needed from the caller. - """ +class _Renderer(BaseRenderer): + def __init__(self): + self.model_config = SimpleNamespace(is_encoder_decoder=False, enable_prompt_embeds=True) + self.tokenizer = None + self.default_cmpl_tok_params = TokenizeParams(max_total_tokens=128) + self._tokenize_prompt = lambda prompt, params: {**prompt, "prompt_token_ids": [1, 2, 3]} + self._tokenize_prompt_async = AsyncMock(side_effect=self._tokenize_prompt) + self._process_multimodal = Mock(return_value=tokens_input([1, 2, 3, 99])) + self._process_multimodal_async = AsyncMock(side_effect=self._process_multimodal) - @pytest.fixture - def preprocessor(self, mocker: MockerFixture): - from vllm_omni.inputs.preprocess import OmniInputPreprocessor + def render_messages(self, messages, params): + return messages, {"prompt": "hello"} - instance = object.__new__(OmniInputPreprocessor) - instance._process_multimodal = mocker.MagicMock(return_value={}) - instance._tokenize_prompt = mocker.MagicMock(return_value=[1, 2, 3]) - return instance - def test_empty_mm_processor_kwargs_routes_to_multimodal(self, preprocessor): - preprocessor._process_text({"prompt": "hello", "mm_processor_kwargs": {}}) - assert preprocessor._process_multimodal.called - assert not preprocessor._tokenize_prompt.called +@pytest.fixture +def renderer(): + return omni_renderer_cls(_Renderer)() - def test_missing_mm_processor_kwargs_routes_to_tokenize(self, preprocessor): - preprocessor._process_text({"prompt": "hello"}) - assert preprocessor._tokenize_prompt.called - assert not preprocessor._process_multimodal.called + +@pytest.mark.parametrize("kwargs", [{}, {"target_h": 512, "target_w": 768}]) +@pytest.mark.parametrize("tokenized", [False, True]) +def test_process_inputs_routes_no_media_processor_kwargs(renderer, kwargs, tokenized): + # Keep real process_inputs/render_cmpl; stub unrelated config validation. + processor = object.__new__(InputProcessor) + processor.renderer = renderer + processor.model_config = renderer.model_config + processor.vllm_config = SimpleNamespace( + parallel_config=SimpleNamespace( + data_parallel_size=1, + data_parallel_size_local=1, + local_engines_only=False, + ) + ) + processor._validate_params = Mock() + processor._validate_lora = Mock() + processor._validate_model_inputs = Mock() + prompt: dict[str, object] = {"prompt_token_ids": [1, 2, 3]} if tokenized else {"prompt": "hello"} + prompt.update(mm_processor_kwargs=kwargs, cache_salt="salt") + request = processor.process_inputs("image-request", prompt, PoolingParams(), ("embed",)) + assert request.prompt_token_ids == [1, 2, 3, 99] + assert request.cache_salt == "salt" + renderer._process_multimodal.assert_called_once_with( + [1, 2, 3], + {}, + mm_processor_kwargs=kwargs, + mm_uuids=None, + skip_mm_cache=False, + ) + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.parametrize("kind", ["text", "tokens", "media", "kwargs", "embeds"]) +def test_renderer_preserves_routes_extras_and_cache_policy(renderer, async_mode, kind): + prompt: dict[str, object] = { + "prompt": "hello", + "additional_information": {"speaker": 1}, + "model_intermediate_buffer": {"x": 2}, + } + if kind == "tokens": + prompt["prompt_token_ids"] = [1, 2, 3] + elif kind == "media": + prompt["multi_modal_data"] = {"image": "image"} + elif kind == "kwargs": + prompt["mm_processor_kwargs"] = {} + elif kind == "embeds": + prompt["prompt_embeds"] = torch.ones(3, 4) + if async_mode: + (result,) = asyncio.run(renderer.render_cmpl_async([prompt], skip_mm_cache=True)) + else: + (result,) = renderer.render_cmpl([prompt], skip_mm_cache=True) + assert result["additional_information"] == {"speaker": 1} + assert result["model_intermediate_buffer"] == {"x": 2} + assert result["prompt"] == "hello" + if kind in ("media", "kwargs"): + assert result["prompt_token_ids"] == [1, 2, 3, 99] + assert renderer._process_multimodal.call_args.kwargs["skip_mm_cache"] is True + else: + renderer._process_multimodal.assert_not_called() + if kind == "embeds": + torch.testing.assert_close(result["prompt_embeds"], prompt["prompt_embeds"]) + else: + assert result["prompt_token_ids"] == [1, 2, 3] + + +def test_omni_renderer_is_a_real_subclass_created_once(): + cls = omni_renderer_cls(_Renderer) + assert issubclass(cls, OmniRenderer) and issubclass(cls, _Renderer) + assert omni_renderer_cls(_Renderer) is cls + assert omni_renderer_cls(cls) is cls + assert cls.__name__ == "Omni_Renderer" + instance = cls() + assert isinstance(instance, _Renderer) + assert not hasattr(instance, "_renderer") + + +def test_concrete_renderer_overrides_are_not_suppressed(): + """The old proxy bound BaseRenderer.render_cmpl itself and hid subclass overrides.""" + + class _Custom(_Renderer): + def render_cmpl(self, prompts, *args, **kwargs): + return ["custom"] + + assert omni_renderer_cls(_Custom)().render_cmpl([{"prompt": "x"}]) == ["custom"] + + +class _CtorRenderer(BaseRenderer): + def __init__(self, config, tokenizer): + self.config = config + self.tokenizer = tokenizer + + def render_messages(self, messages, params): + raise NotImplementedError + + +def test_build_omni_renderer_with_explicit_class_skips_registry(monkeypatch): + monkeypatch.setattr(preprocess_mod, "cached_tokenizer_from_config", Mock(side_effect=AssertionError("resolved"))) + config = SimpleNamespace(model_config=SimpleNamespace()) + built = build_omni_renderer(config, renderer_cls=_CtorRenderer, tokenizer=None) + assert isinstance(built, OmniRenderer) and isinstance(built, _CtorRenderer) + assert built.config is config and built.tokenizer is None + + +def test_build_omni_renderer_resolves_like_upstream(monkeypatch): + config = SimpleNamespace(model_config=SimpleNamespace()) + tokenizer = object() + monkeypatch.setattr( + preprocess_mod, "cached_tokenizer_from_config", lambda mc: tokenizer if mc is config.model_config else None + ) + monkeypatch.setattr(preprocess_mod, "tokenizer_args_from_config", lambda mc: ("fake-mode", None)) + load_cls = Mock(return_value=_CtorRenderer) + monkeypatch.setattr(preprocess_mod.RENDERER_REGISTRY, "load_renderer_cls", load_cls) + built = build_omni_renderer(config) + load_cls.assert_called_once_with("fake-mode") + assert isinstance(built, OmniRenderer) and isinstance(built, _CtorRenderer) + assert built.tokenizer is tokenizer diff --git a/tests/model_executor/models/glm_image/test_glm_image_ar.py b/tests/model_executor/models/glm_image/test_glm_image_ar.py index 5ccc9fc62..ae1060839 100644 --- a/tests/model_executor/models/glm_image/test_glm_image_ar.py +++ b/tests/model_executor/models/glm_image/test_glm_image_ar.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Unit tests for GLM-Image AR model: DataParser, processor, and M-RoPE.""" import importlib.util @@ -91,6 +91,41 @@ GlmImageRotaryEmbedding = _ar_mod.GlmImageRotaryEmbedding pytestmark = [pytest.mark.core_model, pytest.mark.cpu] +@pytest.mark.parametrize("num_images", [0, 1, 2]) +@pytest.mark.parametrize("preformatted", [False, True]) +def test_generation_prompt_ids_preserve_hf_target_scaffold(num_images, preformatted): + from types import SimpleNamespace + + from vllm.multimodal.parse import MultiModalDataItems + from vllm.multimodal.processing import BaseMultiModalProcessor, ProcessorInputs + + processor = object.__new__(GlmImageMultiModalProcessor) + # Only the public token attributes of GlmImageProcessor are needed; the + # expected suffix below is what HF's own formatter produces for 512x768. + hf_processor = SimpleNamespace(grid_bos_token="", grid_eos_token="", bos_token="") + tokenizer = SimpleNamespace(encode=lambda text, **kwargs: list(text.encode())) + processor.info = SimpleNamespace( + get_tokenizer=lambda: tokenizer, + get_hf_processor=lambda: hf_processor, + get_hf_config=lambda: _make_hf_config(), + ) + items = MagicMock(spec=MultiModalDataItems) + items.get_all_counts.return_value = {"image": num_images} + suffix = "16 24" + ("13 19" if not num_images else "") + "" + text_ids = list(b"a red bird") + expected = [167855] * num_images + text_ids + list(suffix.encode()) + inputs = ProcessorInputs( + prompt=expected if preformatted else text_ids, + mm_data_items=items, + hf_processor_mm_kwargs={"target_h": 512, "target_w": 768}, + ) + with patch.object(BaseMultiModalProcessor, "apply", side_effect=lambda inputs, timing: inputs) as upstream: + result = processor.apply(inputs, MagicMock()) + assert result.prompt == expected + assert inputs.prompt == (expected if preformatted else text_ids) + upstream.assert_called_once() + + def test_glm_image_ar_omits_hidden_pooler_payload(): assert GlmImageForConditionalGeneration.omni_pooler_payload_include_hidden is False diff --git a/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py b/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py index d25b58e48..48d3bd9a3 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py +++ b/tests/model_executor/models/minicpmo_4_5/test_audio_embeds_input.py @@ -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 """Regression tests for the MiniCPM-o 4.5 ``audio_embeds`` input path. Two defects made the OpenAI ``audio_embeds`` content part unusable with this model: @@ -71,6 +71,12 @@ class _IdentityTokenizer: def decode(self, tokens: str) -> str: return tokens + def convert_tokens_to_ids(self, token: str) -> int: + # vLLM 0.29 prompt updates select placeholder positions by token id, + # so the processor resolves ```` once; the value is irrelevant here. + assert token == "" + return 0 + def _processing_info() -> MiniCPMO45OmniLLMProcessingInfo: processor = _FakeProcessor() diff --git a/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py b/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py index 3e8ee2a63..eaa2ff2b3 100644 --- a/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py +++ b/tests/model_executor/models/minicpmo_4_5/test_long_audio_processing.py @@ -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 """Regression tests for MiniCPM-o 4.5 >30s audio preprocessing. Covers: @@ -43,6 +43,34 @@ _FEAT_DIM = 128 _CHUNK_FRAMES = 3000 +@pytest.mark.parametrize("version", [(2, 5), (4, 5)]) +def test_prompt_batching_delegates_upstream_without_tts_kwargs(version): + from unittest.mock import MagicMock + + info = SimpleNamespace( + get_model_version=lambda: version, + get_hf_processor=MagicMock(return_value=object()), + ctx=SimpleNamespace(call_hf_processor=MagicMock()), + ) + info.ctx.call_hf_processor.side_effect = ( + [{"features": [1]}, {"features": [2]}] if version == (2, 5) else [{"features": [1, 2]}] + ) + kwargs = {"use_tts": True, "sampling_rate": 16000} + result = MiniCPMO45OmniLLMMultiModalProcessor._call_hf_processor_on_prompts( + SimpleNamespace(info=info), ["one", "two"], {"audios": [10, 20]}, kwargs, out_keys={"features"} + ) + assert result == {"features": [1, 2]} + assert kwargs == {"use_tts": True, "sampling_rate": 16000} + for call in info.ctx.call_hf_processor.call_args_list: + assert call.args[2] == {"sampling_rate": 16000} + payloads = [call.args[1] for call in info.ctx.call_hf_processor.call_args_list] + assert payloads == ( + [{"text": "one", "audios": 10}, {"text": "two", "audios": 20}] + if version == (2, 5) + else [{"text": ["one", "two"], "audios": [10, 20]}] + ) + + class TestMiniCPMOFieldConfig: def test_short_audios_stay_batched(self) -> None: # two <=30s audios: one feature entry + one 1-element lens per audio @@ -99,10 +127,10 @@ def _make_processor( processor.info = SimpleNamespace(audio_pattern="()") processor.data_parser = MiniCPMOMultiModalDataParser(target_sr=_SAMPLE_RATE) - def fake_base_call_hf_processor(prompts, mm_data, mm_kwargs, tok_kwargs, *, out_keys): + def fake_call_hf_processor_on_prompts(prompts, mm_data, mm_kwargs, *, out_keys): return {key: fake_hf_outputs[key] for key in out_keys} - processor._base_call_hf_processor = fake_base_call_hf_processor + processor._call_hf_processor_on_prompts = fake_call_hf_processor_on_prompts return processor @@ -120,7 +148,7 @@ class TestProcessAudiosUnpadding: } processor = _make_processor(fake_outputs) - result = processor.process_audios({"audios": [_audio_seconds(45)]}, {}, {}) + result = processor.process_audios({"audios": [_audio_seconds(45)]}, {}) features = result["audio_features"] assert [feat.shape[-1] for feat in features] == chunk_lens @@ -138,7 +166,7 @@ class TestProcessAudiosUnpadding: } processor = _make_processor(fake_outputs) - result = processor.process_audios({"audios": [_audio_seconds(42), _audio_seconds(38)]}, {}, {}) + result = processor.process_audios({"audios": [_audio_seconds(42), _audio_seconds(38)]}, {}) widths = [feat.shape[-1] for feat in result["audio_features"]] assert widths == [_CHUNK_FRAMES, 1200, _CHUNK_FRAMES, 800] @@ -151,7 +179,7 @@ class TestProcessAudiosUnpadding: } processor = _make_processor(fake_outputs) - result = processor.process_audios({"audios": [_audio_seconds(9.8)]}, {}, {}) + result = processor.process_audios({"audios": [_audio_seconds(9.8)]}, {}) features = result["audio_features"] assert len(features) == 1 diff --git a/tests/model_executor/models/omnivoice/test_processor_prompt_tokenization.py b/tests/model_executor/models/omnivoice/test_processor_prompt_tokenization.py new file mode 100644 index 000000000..9c6533c76 --- /dev/null +++ b/tests/model_executor/models/omnivoice/test_processor_prompt_tokenization.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""OmniVoice processor: the prompt is tokenized exactly once per request. + +Upstream ``_cached_apply_hf_processor`` calls ``_apply_hf_processor_main`` for +every request, including text-only requests and requests whose reference audio +is already cached (empty ``mm_items``). These tests pin that only ``apply`` +touches the text tokenizer and that the media path only encodes the audio. +""" + +from types import SimpleNamespace +from unittest.mock import Mock + +import numpy as np +import pytest +import torch +from vllm.multimodal.parse import MultiModalDataItems +from vllm.multimodal.processing import ProcessorInputs + +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor +from vllm_omni.model_executor.models.omnivoice.omnivoice import OmniVoiceMultiModalProcessor + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_PROMPT_IDS = [5, 6, 7] + + +def _make_processor(monkeypatch: pytest.MonkeyPatch) -> OmniVoiceMultiModalProcessor: + processor = object.__new__(OmniVoiceMultiModalProcessor) + tokenizer = SimpleNamespace(decode=Mock(return_value="hello world")) + processor.info = SimpleNamespace( + ctx=SimpleNamespace( + get_hf_config=lambda *_args, **_kwargs: SimpleNamespace(sample_rate=24000), + model_config=SimpleNamespace(model="fake-model"), + ), + get_tokenizer=lambda: tokenizer, + ) + processor.text_tokenizer = Mock(return_value=SimpleNamespace(input_ids=torch.tensor([_PROMPT_IDS]))) + processor.audio_tokenizer = SimpleNamespace(encode=Mock(return_value=torch.zeros(8, 4, dtype=torch.long))) + processor.feature_extractor = None + monkeypatch.setattr(processor, "_ensure_cached_runtime_components", lambda *_args, **_kwargs: None) + return processor + + +def _items(counts: dict[str, int], mm_data: dict[str, object]) -> SimpleNamespace: + return SimpleNamespace( + get_all_counts=lambda: counts, + select=lambda _keys: mm_data, + ) + + +def test_apply_tokenizes_the_prompt_once(monkeypatch: pytest.MonkeyPatch) -> None: + processor = _make_processor(monkeypatch) + captured: dict[str, ProcessorInputs] = {} + + def fake_parent_apply(self, inputs, timing_ctx): + captured["inputs"] = inputs + return "sentinel" + + monkeypatch.setattr(OmniMultiModalProcessor, "apply", fake_parent_apply) + inputs = ProcessorInputs( + prompt=[1, 2], + mm_data_items=MultiModalDataItems({}), + hf_processor_mm_kwargs={"ref_text": "reference"}, + ) + + assert processor.apply(inputs, timing_ctx=None) == "sentinel" + assert captured["inputs"].prompt == _PROMPT_IDS + assert processor.text_tokenizer.call_count == 1 + (text_prompt,), _ = processor.text_tokenizer.call_args + assert text_prompt.endswith("<|text_start|>reference hello world<|text_end|>") + assert captured["inputs"].hf_processor_mm_kwargs["ref_text"] == "reference" + + +def test_media_path_encodes_audio_without_retokenizing(monkeypatch: pytest.MonkeyPatch) -> None: + processor = _make_processor(monkeypatch) + audio = np.zeros(2400, dtype=np.float32) + monkeypatch.setattr(processor, "_get_hf_mm_data", lambda _items: ({"audios": [audio]}, {})) + + out = processor._apply_hf_processor_main(_items({"audio": 1}, {}), {"ref_text": "reference"}) + + assert tuple(out["ref_audio_tokens"].shape) == (8, 4) + assert list(out["ref_audio_len"]) == [4] + assert "input_ids" not in out + assert processor.audio_tokenizer.encode.call_count == 1 + assert processor.text_tokenizer.call_count == 0 + + +def test_media_path_with_no_audio_touches_no_tokenizer(monkeypatch: pytest.MonkeyPatch) -> None: + """Text-only requests and fully cached audio both reach this path with empty items.""" + processor = _make_processor(monkeypatch) + monkeypatch.setattr(processor, "_get_hf_mm_data", lambda _items: ({}, {})) + + out = processor._apply_hf_processor_main(_items({}, {}), {"ref_text": "reference"}) + + assert len(out.keys()) == 0 + assert processor.text_tokenizer.call_count == 0 + assert processor.audio_tokenizer.encode.call_count == 0 + + +def test_legacy_call_hf_processor_is_gone() -> None: + assert not hasattr(OmniVoiceMultiModalProcessor, "_call_hf_processor") diff --git a/tests/model_executor/models/qwen2_5_omni/test_per_video_audio_mask.py b/tests/model_executor/models/qwen2_5_omni/test_per_video_audio_mask.py index d88d918bc..54efc99e4 100644 --- a/tests/model_executor/models/qwen2_5_omni/test_per_video_audio_mask.py +++ b/tests/model_executor/models/qwen2_5_omni/test_per_video_audio_mask.py @@ -266,8 +266,6 @@ def test_cached_apply_hf_processor_expands_pair_miss_for_audio_hit_video_miss(): inputs = MagicMock() inputs.mm_data_items = mm_data_items inputs.hf_processor_mm_kwargs = {"use_audio_in_video": True} - inputs.tokenization_kwargs = {} - inputs.prompt = [1, 2, 3] inputs.get_mm_hashes = MagicMock(return_value={"video": ["v0"], "audio": ["a0"]}) timing_ctx = MagicMock() @@ -307,7 +305,7 @@ def test_cached_apply_hf_processor_expands_pair_miss_for_audio_hit_video_miss(): ) fake_self._paired_cache_keys = Qwen2_5OmniThinkerMultiModalProcessor._paired_cache_keys fake_self.info.parse_mm_data = MagicMock(return_value=SimpleNamespace(name="missing")) - fake_self._apply_hf_processor_main = MagicMock(return_value=([9], {"video_grid_thw": object()}, False)) + fake_self._apply_hf_processor_main = MagicMock(return_value={"video_grid_thw": object()}) fake_self._get_mm_fields_config = MagicMock(return_value={}) fake_self._get_mm_prompt_updates = MagicMock(return_value={"video": [[object()]], "audio": [[object()]]}) fake_self._merge_mm_kwargs = capture_merge @@ -316,13 +314,12 @@ def test_cached_apply_hf_processor_expands_pair_miss_for_audio_hit_video_miss(): "vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker.MultiModalKwargsItems.from_hf_inputs", return_value={"video": [object()], "audio": [object()]}, ): - prompt_ids, mm_info, _ = Qwen2_5OmniThinkerMultiModalProcessor._cached_apply_hf_processor( + mm_info = Qwen2_5OmniThinkerMultiModalProcessor._cached_apply_hf_processor( fake_self, inputs, timing_ctx, ) - assert prompt_ids == [9] # Both modalities were reprocessed as one unit after pair-miss expansion. assert fake_self.info.parse_mm_data.call_args.args[0] == { "video": ["video-0"], @@ -623,10 +620,17 @@ def test_derive_audio_from_video_placeholders_only_pairs_true_videos(): def test_qwen3_processor_inherits_vllm_omni_per_video_helpers(): + from vllm.model_executor.models.qwen3_omni_moe_thinker import ( + Qwen3OmniMoeThinkerMultiModalProcessor as UpstreamQwen3Processor, + ) + assert issubclass( Qwen3OmniMoeThinkerMultiModalProcessor, Qwen2_5OmniThinkerMultiModalProcessor, ) + assert Qwen3OmniMoeThinkerMultiModalProcessor._get_hf_mm_data is UpstreamQwen3Processor._get_hf_mm_data + mro = Qwen3OmniMoeThinkerMultiModalProcessor.__mro__ + assert mro.index(Qwen2_5OmniThinkerMultiModalProcessor) < mro.index(UpstreamQwen3Processor) def test_qwen3_prompt_updates_prefer_hf_second_per_grid_ts_from_outputs(): diff --git a/tests/model_executor/models/qwen3_omni/test_qwen3_omni_moe_lora.py b/tests/model_executor/models/qwen3_omni/test_qwen3_omni_moe_lora.py index 75d81eed5..4ff6d5096 100644 --- a/tests/model_executor/models/qwen3_omni/test_qwen3_omni_moe_lora.py +++ b/tests/model_executor/models/qwen3_omni/test_qwen3_omni_moe_lora.py @@ -113,7 +113,9 @@ def _save_peft_adapter(tmp_path: Path) -> Path: return adapter_dir -def test_peft_expert_lora_loads_into_thinker(tmp_path: Path): +def test_peft_expert_lora_loads_into_thinker(tmp_path: Path, monkeypatch): + # This CPU harness tests weight mapping, not CUDA pinned-memory allocation. + monkeypatch.setattr("vllm.lora.lora_model.PIN_MEMORY", False) adapter_dir = _save_peft_adapter(tmp_path) peft_helper = PEFTHelper.from_local_dir(str(adapter_dir), max_position_embeddings=128) lora_model = LoRAModel.from_local_checkpoint( diff --git a/tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py b/tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py index 45b40d026..64f9ac8de 100644 --- a/tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py +++ b/tests/model_executor/models/qwen3_tts/test_qwen3_tts_code2wav.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from types import SimpleNamespace from unittest.mock import patch @@ -157,7 +157,10 @@ def _load_weights_noop(model: Qwen3TTSCode2Wav) -> set[str]: def __init__(self, *_: object, **__: object): pass - def load_weights(self, _weights: object) -> set[str]: + def load_weights(self, _weights: object, *, mapper: object | None = None) -> set[str]: + # vLLM 0.29 dropped skip_prefixes from AutoWeightsLoader; the model + # now passes the stage exclusions as a WeightsMapper instead. + del mapper return {"decoder.fake_weight"} with ( diff --git a/tests/model_executor/models/test_weight_loader.py b/tests/model_executor/models/test_weight_loader.py new file mode 100644 index 000000000..34d1cd28a --- /dev/null +++ b/tests/model_executor/models/test_weight_loader.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import pytest +import torch +from torch import nn +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +class _TinyModel(nn.Module): + def __init__(self) -> None: + super().__init__() + self.kept = nn.Linear(1, 1, bias=False) + self.skipped = nn.Linear(1, 1, bias=False) + self.rotary_embed = nn.Module() + self.rotary_embed.register_parameter("inv_freq", nn.Parameter(torch.zeros(1))) + + +def test_upstream_mapper_preserves_stage_exclusions_after_renaming() -> None: + model = _TinyModel() + model.skipped.weight.data.zero_() + + loaded = AutoWeightsLoader(model).load_weights( + [ + ("checkpoint.kept.weight", torch.ones(1, 1)), + ("checkpoint.skipped.weight", torch.full((1, 1), 2.0)), + ("rotary_embed.inv_freq", torch.ones(1)), + ], + mapper=WeightsMapper(orig_to_new_prefix={"checkpoint.": ""}) + | WeightsMapper(orig_to_new_prefix={"skipped.": None}, orig_to_new_substr={"rotary_embed.inv_freq": None}), + ) + + assert loaded == {"kept.weight"} + torch.testing.assert_close(model.kept.weight, torch.ones(1, 1)) + torch.testing.assert_close(model.skipped.weight, torch.zeros(1, 1)) + torch.testing.assert_close(model.rotary_embed.inv_freq, torch.zeros(1)) diff --git a/tests/model_executor/quantization/test_modelopt_nvfp4_nan_clamp.py b/tests/model_executor/quantization/test_modelopt_nvfp4_nan_clamp.py index 0e9f23de6..985c64114 100644 --- a/tests/model_executor/quantization/test_modelopt_nvfp4_nan_clamp.py +++ b/tests/model_executor/quantization/test_modelopt_nvfp4_nan_clamp.py @@ -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 the NVFP4 W4A4 weight_scale NaN clamp installed by ``vllm_omni.patch``. @@ -198,7 +198,7 @@ def test_reload_does_not_nest_wrapper(): print("NESTED" if nested else "FLAT") """ ) - result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600) if result.stdout.strip().startswith("SKIP:"): pytest.skip(result.stdout.strip()) assert "FLAT" in result.stdout, ( @@ -265,9 +265,12 @@ def test_env_var_escape_hatch_disables_install(env_value, acceptable): ) env = os.environ.copy() env["VLLM_OMNI_SKIP_NVFP4_NAN_CLAMP"] = env_value - # 120s rather than 60s: cold CI containers can take 30-50s just to - # import vllm_omni → vllm → torch before the subprocess does any work. - result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=120, env=env) + # Generous timeout: cold CI containers can take 30-50s just to import + # vllm_omni → vllm → torch, and heavily loaded shared machines have + # been observed taking >2min for the same import under disk I/O + # contention. The subprocess does a single import + flag print, so 600s + # only bounds pathological hangs, not normal runtime. + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=600, env=env) if result.stdout.strip().startswith("SKIP:"): pytest.skip("modelopt not available in subprocess") # Exact token match on the final stdout line — not a substring test, since diff --git a/tests/model_executor/test_upstream_prompt_update_api.py b/tests/model_executor/test_upstream_prompt_update_api.py new file mode 100644 index 000000000..da31980c2 --- /dev/null +++ b/tests/model_executor/test_upstream_prompt_update_api.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Guard that Omni only calls prompt-update helpers upstream still provides. + +vLLM 0.29 removed ``PromptUpdateDetails.select_text`` in favour of the +token-oriented ``select_token_id`` / ``select_token_ids``. Three MiniCPM-o call +sites survived the rebase and only failed at engine-core startup, where the +traceback surfaced as an opaque "Engine core initialization failed" across eight +CI jobs. + +These helpers are referenced from replacement closures that no unit test drives, +so a static check is what actually catches the next removal. It resolves the +attribute against the installed class rather than a hard-coded list, so it +follows upstream automatically. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import pytest +from vllm.multimodal.processing.processor import PromptUpdateDetails + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + +_OMNI_ROOT = Path(__file__).resolve().parents[2] / "vllm_omni" +_GUARDED = "PromptUpdateDetails" + + +def _referenced_attributes() -> dict[str, set[str]]: + """Map ``PromptUpdateDetails.`` -> source files referencing it.""" + found: dict[str, set[str]] = {} + for path in _OMNI_ROOT.rglob("*.py"): + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except (SyntaxError, UnicodeDecodeError): # pragma: no cover - defensive + continue + for node in ast.walk(tree): + if isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id == _GUARDED: + found.setdefault(node.attr, set()).add(str(path.relative_to(_OMNI_ROOT.parent))) + return found + + +def test_omni_only_uses_prompt_update_helpers_that_exist_upstream() -> None: + referenced = _referenced_attributes() + assert referenced, "expected Omni to reference PromptUpdateDetails somewhere" + + missing = {attr: sorted(files) for attr, files in referenced.items() if not hasattr(PromptUpdateDetails, attr)} + + assert not missing, ( + "Omni calls PromptUpdateDetails helpers that the installed vLLM does not " + f"provide: {missing}. Upstream moved prompt updates to token ids; use " + "select_token_id/select_token_ids with the encoded text." + ) + + +def test_token_oriented_helpers_are_the_ones_upstream_exposes() -> None: + """Pin the replacements, so their removal fails here and not at startup.""" + assert hasattr(PromptUpdateDetails, "select_token_id") + assert hasattr(PromptUpdateDetails, "select_token_ids") + assert not hasattr(PromptUpdateDetails, "select_text"), ( + "select_text is back upstream; re-check the MiniCPM-o replacements." + ) diff --git a/tests/platforms/test_platform_detection.py b/tests/platforms/test_platform_detection.py new file mode 100644 index 000000000..6673a2376 --- /dev/null +++ b/tests/platforms/test_platform_detection.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +import logging + +import pytest + +import vllm_omni.platforms as platforms + +pytestmark = [pytest.mark.core_model, pytest.mark.cpu] + + +def test_failing_oot_plugin_is_logged_and_falls_back(monkeypatch, caplog): + plugin_name = "raising_oot_plugin" + + def raising_plugin(): + raise RuntimeError("injected platform detection failure") + + monkeypatch.setattr(platforms, "builtin_omni_platform_plugins", {}) + monkeypatch.setattr( + platforms, + "load_omni_plugins_by_group", + lambda _group: {plugin_name: raising_plugin}, + ) + + target_logger = logging.getLogger(platforms.__name__) + target_logger.addHandler(caplog.handler) + previous_level = target_logger.level + target_logger.setLevel(logging.DEBUG) + try: + platform_cls = platforms.resolve_current_omni_platform_cls_qualname() + finally: + target_logger.removeHandler(caplog.handler) + target_logger.setLevel(previous_level) + + assert platform_cls == "vllm_omni.platforms.interface.UnspecifiedOmniPlatform" + assert plugin_name in caplog.text + assert "RuntimeError: injected platform detection failure" in caplog.text diff --git a/vllm_omni/benchmarks/patch/patch.py b/vllm_omni/benchmarks/patch/patch.py index 10f0853e0..3313e691f 100644 --- a/vllm_omni/benchmarks/patch/patch.py +++ b/vllm_omni/benchmarks/patch/patch.py @@ -2975,7 +2975,9 @@ async def benchmark( "duration": benchmark_duration, "completed": metrics.completed, "total_input_tokens": metrics.total_input, + "total_input_sequences": metrics.total_input_sequences, "request_throughput": metrics.request_throughput, + "input_sequence_throughput": metrics.input_sequence_throughput, "total_token_throughput": metrics.total_token_throughput, "input_lens": [output.prompt_len for output in outputs], "errors": [output.error for output in outputs], diff --git a/vllm_omni/core/sched/omni_scheduler_mixin.py b/vllm_omni/core/sched/omni_scheduler_mixin.py index aa71245a0..9123b794d 100644 --- a/vllm_omni/core/sched/omni_scheduler_mixin.py +++ b/vllm_omni/core/sched/omni_scheduler_mixin.py @@ -107,6 +107,17 @@ class OmniSchedulerMixin: def _init_omni_io_scheduling_state(self) -> None: """Initialize scheduler state shared by AR and generation stages.""" + # Every omni worker forces the v1 model runner (the v2 runner carries no + # omni hooks), so the scheduler has to agree or the two disagree about + # what the SchedulerOutput carries. vLLM 0.29 defaults this to v2 where + # 0.28 did not, so the scheduler took its v2 fast path -- the one that + # deliberately omits resumed-request token ids -- while the v1 runner + # still read them, and resuming a request raised ``KeyError: `` + # in ``_update_states``. + if getattr(self, "use_v2_model_runner", False): + logger.warning("OMNI scheduler forces v1 model runner for omni hooks.") + self.use_v2_model_runner = False + model_config = self.vllm_config.model_config self.chunk_transfer_adapter = ( OmniChunkTransferAdapter(self.vllm_config) if getattr(model_config, "async_chunk", False) else None diff --git a/vllm_omni/diffusion/attention/backends/abstract.py b/vllm_omni/diffusion/attention/backends/abstract.py index 5b5b8a328..e903960d6 100644 --- a/vllm_omni/diffusion/attention/backends/abstract.py +++ b/vllm_omni/diffusion/attention/backends/abstract.py @@ -121,6 +121,13 @@ class AttentionBackend(ABC): when it unifies cache layouts across layers. Dense diffusion backends conservatively keep the default ``False``; a paged backend should override this only when its kernel actually follows that layout. + + Since vLLM 0.29 this no longer rides on ``AttentionSpec``. It selects + the block-outermost physical layout pinned on + ``CacheConfig.kv_cache_layout`` -- see + ``vllm_omni.diffusion.diffusion_kv.layout`` -- whose + ``is_block_outermost`` is what upstream reads when it sizes + ``KVCacheTensor`` regions. """ return False diff --git a/vllm_omni/diffusion/attention/layer.py b/vllm_omni/diffusion/attention/layer.py index ac541808b..4b8ca8f49 100644 --- a/vllm_omni/diffusion/attention/layer.py +++ b/vllm_omni/diffusion/attention/layer.py @@ -24,6 +24,7 @@ from vllm_omni.diffusion.attention.parallel.ring import RingParallelAttention from vllm_omni.diffusion.attention.selector import get_attn_backend_for_role from vllm_omni.diffusion.config import get_current_diffusion_config_or_none from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode +from vllm_omni.diffusion.diffusion_kv.layout import assert_backend_layout_supported from vllm_omni.diffusion.distributed.parallel_state import get_sp_group from vllm_omni.diffusion.forward_context import ( get_forward_context, @@ -261,15 +262,19 @@ class Attention(nn.Module): return None dtype = self.paged_kv_cache_dtype or vllm_config.model_config.dtype # Keep backend layout discovery under the same config context used by - # upstream vLLM's attention-spec collector. + # upstream vLLM's attention-spec collector. vLLM 0.29 moved the + # block-stride decision off the spec and onto the single physical + # ``CacheConfig.kv_cache_layout`` resolved before the cache is built, + # so the backend's preference is enforced there (see + # ``vllm_omni.diffusion.diffusion_kv.initialization``) rather than + # carried per layer. with set_current_vllm_config(vllm_config): - indexes_kv_by_block_stride = self.attn_backend.indexes_kv_by_block_stride() + assert_backend_layout_supported(vllm_config, self.attn_backend) return FullAttentionSpec( block_size=vllm_config.cache_config.block_size, num_kv_heads=self.num_kv_heads, head_size=self.head_size, dtype=dtype, - indexes_kv_by_block_stride=indexes_kv_by_block_stride, non_causal=not self.causal, ) diff --git a/vllm_omni/diffusion/diffusion_kv/initialization.py b/vllm_omni/diffusion/diffusion_kv/initialization.py index 31aaaa46e..d1a79d934 100644 --- a/vllm_omni/diffusion/diffusion_kv/initialization.py +++ b/vllm_omni/diffusion/diffusion_kv/initialization.py @@ -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 @@ -16,6 +16,7 @@ from vllm.v1.kv_cache_interface import KVCacheConfig, KVCacheSpec from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode +from vllm_omni.diffusion.diffusion_kv.layout import resolve_diffusion_kv_cache_layout from vllm_omni.diffusion.vllm_config import create_diffusion_vllm_config from vllm_omni.platforms import current_omni_platform @@ -28,14 +29,35 @@ def build_native_kv_cache_configs( vllm_config: VllmConfig, worker_specs: list[dict[str, KVCacheSpec]], available_memory: list[int], + *, + indexes_kv_by_block_stride: bool = False, ) -> tuple[list[KVCacheConfig], KVCacheConfig]: - """Build rank-local and Scheduler-native configs using vLLM utilities.""" + """Build rank-local and Scheduler-native configs using vLLM utilities. + + ``indexes_kv_by_block_stride`` carries the native backend requirement that + 0.29 removed from ``AttentionSpec``. Every diffusion backend in tree + declares ``False``, so the default reproduces current behaviour; a caller + that learns otherwise from its workers passes ``True`` here and the + block-outermost layout propagates to every rank-local config. + """ from vllm.v1.core import single_type_kv_cache_manager + # 0.29 sizes every KVCacheTensor region from the resolved physical layout, + # so it must be pinned before get_kv_cache_configs. Idempotent, so callers + # that already resolved it (the control plane) are unaffected. + layout = resolve_diffusion_kv_cache_layout( + vllm_config, + indexes_kv_by_block_stride=indexes_kv_by_block_stride, + ) single_type_kv_cache_manager.register_all_kvcache_specs(vllm_config) worker_configs = get_kv_cache_configs(vllm_config, worker_specs, available_memory) - return worker_configs, generate_scheduler_kv_cache_config(worker_configs) + scheduler_config = generate_scheduler_kv_cache_config(worker_configs) + # Workers only receive a KVCacheConfig, so the resolved layout travels on it + # and is adopted into each worker's own CacheConfig before allocation. + for config in (*worker_configs, scheduler_config): + config.kv_cache_layout = layout.name + return worker_configs, scheduler_config def initialize_diffusion_kv_control_plane( diff --git a/vllm_omni/diffusion/diffusion_kv/layout.py b/vllm_omni/diffusion/diffusion_kv/layout.py new file mode 100644 index 000000000..960df395d --- /dev/null +++ b/vllm_omni/diffusion/diffusion_kv/layout.py @@ -0,0 +1,174 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Physical KV cache layout selection for the native Diffusion paged cache. + +vLLM 0.29 removed ``AttentionSpec.indexes_kv_by_block_stride`` and replaced it +with a single physical layout per model, resolved once and recorded on +``CacheConfig.kv_cache_layout``. ``KVCacheLayout.is_block_outermost`` is the +direct successor of the old per-spec flag: upstream derives +``interleaved_block_stride`` from it when it lays out ``KVCacheTensor`` regions +(``vllm/v1/core/kv_cache_utils.py``). + +Diffusion never reaches vLLM's engine-core resolution. Its backends subclass +Omni's own ``AttentionBackend`` ABC, which upstream's +``get_supported_kv_cache_layouts`` never sees, so the layout is pinned here +before ``get_kv_cache_configs`` runs. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from vllm.v1.kv_cache_interface import KVCacheSpec, KVCacheTensor, compute_layout_strides +from vllm.v1.kv_cache_layout import KVCacheLayout + +if TYPE_CHECKING: + from vllm.config import VllmConfig + +# Layer-outermost, matching the head of upstream's ``_DEFAULT_LAYOUT_PREFERENCE`` +# and reproducing the ``indexes_kv_by_block_stride=False`` that every dense +# diffusion backend declared before 0.29. +DEFAULT_DIFFUSION_KV_CACHE_LAYOUT = KVCacheLayout.LBNHC +# Block-outermost counterpart, for a paged backend whose kernel really does read +# K/V pages by the runtime block stride. +BLOCK_STRIDE_DIFFUSION_KV_CACHE_LAYOUT = KVCacheLayout.BLNHC + + +def resolve_diffusion_kv_cache_layout( + vllm_config: VllmConfig, + *, + indexes_kv_by_block_stride: bool = False, +) -> KVCacheLayout: + """Pin the physical KV layout for the Diffusion cache and return it. + + A layout already present on the config wins, mirroring upstream's + ``resolve_kv_cache_layout``; it is validated against the requirement rather + than silently overwritten, because an inconsistent layout reads K/V + from the wrong offsets instead of raising. + """ + + required_block_outermost = bool(indexes_kv_by_block_stride) + cache_config = getattr(vllm_config, "cache_config", None) + if cache_config is None: + # A config double with nowhere to record the choice; report what the + # requirement implies without pretending it was pinned. + return BLOCK_STRIDE_DIFFUSION_KV_CACHE_LAYOUT if required_block_outermost else DEFAULT_DIFFUSION_KV_CACHE_LAYOUT + + current = getattr(cache_config, "kv_cache_layout", None) + if current is not None: + layout = KVCacheLayout[current] + if layout.is_block_outermost != required_block_outermost: + raise ValueError( + "Diffusion KV cache layout " + f"{layout.name} (is_block_outermost=" + f"{layout.is_block_outermost}) contradicts the attention " + "backend, which requires is_block_outermost=" + f"{required_block_outermost}." + ) + return layout + + layout = BLOCK_STRIDE_DIFFUSION_KV_CACHE_LAYOUT if required_block_outermost else DEFAULT_DIFFUSION_KV_CACHE_LAYOUT + cache_config.kv_cache_layout = layout.name + return layout + + +def adopt_kv_cache_layout(vllm_config: VllmConfig, kv_cache_config) -> KVCacheLayout: + """Adopt the control plane's resolved layout inside a worker. + + The engine core resolves the layout once and every worker adopts it before + allocating (``CacheConfig.kv_cache_layout``). Diffusion workers receive only + a ``KVCacheConfig``, so the name rides along on ``kv_cache_config`` and is + copied onto the worker's own config here. Without this, anything calling + ``get_resolved_kv_cache_layout`` in a worker -- ``init_kv_cache`` does -- + raises. + """ + + name = getattr(kv_cache_config, "kv_cache_layout", None) + if name is None: + # Older configs, or a standalone harness: fall back to the same default + # the control plane pins so both sides still agree. + return resolve_diffusion_kv_cache_layout(vllm_config) + + cache_config = getattr(vllm_config, "cache_config", None) + if cache_config is None: + return KVCacheLayout[name] + + current = getattr(cache_config, "kv_cache_layout", None) + if current is None: + cache_config.kv_cache_layout = name + elif current != name: + raise ValueError( + f"Worker KV cache layout {current!r} disagrees with the layout resolved by the control plane ({name!r})." + ) + return KVCacheLayout[name] + + +def build_kv_cache_tensor( + spec: KVCacheSpec, + num_blocks: int, + layers: Sequence[str], + *, + layout: KVCacheLayout | None = None, + offset: int = 0, +) -> KVCacheTensor: + """A ``KVCacheTensor`` covering ``layers`` for ``num_blocks`` blocks. + + Before 0.29 a tensor named the layers sharing it + (``KVCacheTensor(size=..., shared_by=[...])``); 0.29 replaced that with an + explicit layout, so layer ``l``'s block ``b`` starts at + ``offset + l * layer_stride + b * block_stride``. Note this lays the layers + out at distinct offsets rather than aliasing them, which is what upstream + now does for a single cache group. + + The strides come from upstream's own ``compute_layout_strides``, called the + way ``vllm.v1.core.kv_cache_utils`` calls it, because hand-computed strides + read K/V from the wrong offsets silently instead of raising. + """ + + layout = layout or DEFAULT_DIFFUSION_KV_CACHE_LAYOUT + layer_names = list(layers) + # Mirrors kv_cache_utils: the block stride is pinned up front only for + # block-outermost layouts, where every block repeats the same packing. + bytes_per_block = spec.page_size_bytes * len(layer_names) + interleaved_block_stride = bytes_per_block if layout.is_block_outermost else None + layer_stride, block_stride, _, _, _ = compute_layout_strides( + spec, + num_blocks, + len(layer_names), + layout, + fixed_strides=(None, interleaved_block_stride, None, None, None), + ) + return KVCacheTensor( + size=bytes_per_block * num_blocks, + layers=layer_names, + layer_stride=layer_stride, + block_stride=block_stride, + offset=offset, + ) + + +def assert_backend_layout_supported(vllm_config: VllmConfig, attn_backend: type | None) -> None: + """Fail loudly when a backend's block-stride need contradicts the layout. + + Skipped while the layout is still unresolved -- worker processes collect + specs before the control plane hands them the resolved name -- so this + enforces the contract wherever it is knowable and never crashes a rank that + has simply not been told yet. + """ + + if attn_backend is None: + return + # Spec discovery runs against lightweight config doubles in tests and against + # worker configs that have not been handed the resolved name yet; in both + # cases there is simply nothing to check. + cache_config = getattr(vllm_config, "cache_config", None) + if cache_config is None or getattr(cache_config, "kv_cache_layout", None) is None: + return + indexes_by_block_stride = getattr(attn_backend, "indexes_kv_by_block_stride", None) + if indexes_by_block_stride is None: + return + resolve_diffusion_kv_cache_layout( + vllm_config, + indexes_kv_by_block_stride=indexes_by_block_stride(), + ) diff --git a/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py b/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py index c5ab3fb16..20f4db165 100644 --- a/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py +++ b/vllm_omni/diffusion/diffusion_kv/model_runner_backend.py @@ -18,6 +18,10 @@ from vllm.v1.worker.gpu.block_table import BlockTables from vllm_omni.diffusion.data import OmniDiffusionConfig from vllm_omni.diffusion.diffusion_kv.config import DiffusionKVCacheMode, is_scheduler_paged_kv_mode +from vllm_omni.diffusion.diffusion_kv.layout import ( + adopt_kv_cache_layout, + assert_backend_layout_supported, +) from vllm_omni.diffusion.diffusion_kv.metadata import DiffusionKVMetadata from vllm_omni.diffusion.diffusion_kv.paged_attention_adapter import ( DiffusionPagedAttentionAdapter, @@ -285,13 +289,16 @@ class DiffusionKVModelRunnerBackend: layers=self._kv_cache_layer_adapters, resolve_row=self._resolve_paged_attention_row, ) + # vLLM 0.29 reads the resolved physical layout when allocating, + # and dropped attn_groups/cache_dtype from the signature. + adopt_kv_cache_layout(self.vllm_config, kv_cache_config) + for layer_adapter in self._kv_cache_layer_adapters.values(): + assert_backend_layout_supported(self.vllm_config, getattr(layer_adapter, "attn_backend", None)) init_kv_cache( kv_caches, self.vllm_config.compilation_config.static_forward_context, kv_cache_config, - attn_groups, self.device, - self.vllm_config.cache_config.cache_dtype, kernel_block_sizes, self.vllm_config, ) diff --git a/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py b/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py index 6a7862669..a76a5434c 100644 --- a/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py +++ b/vllm_omni/diffusion/diffusion_kv/paged_attention_adapter.py @@ -105,10 +105,12 @@ class DiffusionPagedAttentionLayerAdapter(AttentionLayerBase): attn_backend, ulysses_degree=ulysses_degree, ) + # vLLM 0.29 carries the block-stride decision on the resolved physical + # ``CacheConfig.kv_cache_layout`` instead of the spec; the backend's + # requirement is enforced against that layout rather than copied here. canonical_spec = replace( spec, num_kv_heads=num_kv_heads, - indexes_kv_by_block_stride=attn_backend.indexes_kv_by_block_stride(), ) self.layer_name = layer_name self.spec = canonical_spec diff --git a/vllm_omni/diffusion/layers/rope.py b/vllm_omni/diffusion/layers/rope.py index 59d2c9a62..37b2ed835 100644 --- a/vllm_omni/diffusion/layers/rope.py +++ b/vllm_omni/diffusion/layers/rope.py @@ -1,3 +1,7 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +from importlib import import_module from importlib.util import find_spec import torch @@ -98,6 +102,7 @@ class RotaryEmbedding(CustomOp): self.interleaved = not is_neox_style self.half_head_dim = half_head_dim self.apply_rotary_emb_flash_attn = None + self.apply_rotary_emb_vllm_flash_attn = None self.has_mindie = False # ``find_spec("flash_attn")`` is True as long as *any* package publishes # the ``flash_attn`` namespace — including ``flash-attn-4``, which ships @@ -111,6 +116,17 @@ class RotaryEmbedding(CustomOp): self.apply_rotary_emb_flash_attn = apply_rotary except ImportError: pass + # CUDA wheels vendor this kernel under vllm_flash_attn, while source + # and CPU-only installations may not contain the generated package. + # Resolve it lazily and retain the native implementation as a portable + # fallback for those environments. + if current_omni_platform.is_cuda(): + try: + self.apply_rotary_emb_vllm_flash_attn = import_module( + "vllm.vllm_flash_attn.layers.rotary" + ).apply_rotary_emb + except ImportError: + pass if find_spec("mindiesd") is not None: self.has_mindie = True @@ -135,12 +151,18 @@ class RotaryEmbedding(CustomOp): cos: torch.Tensor, sin: torch.Tensor, ) -> torch.Tensor: - from vllm.vllm_flash_attn.layers.rotary import apply_rotary_emb - cos, sin = self._prepare_half_head_dim_cos_sin(cos, sin) + if self.apply_rotary_emb_vllm_flash_attn is None: + return apply_rotary_emb_torch( + x, + cos, + sin, + interleaved=self.interleaved, + ) + x, squeezed = _ensure_batch_dim(x) - output = apply_rotary_emb( + output = self.apply_rotary_emb_vllm_flash_attn( x, cos, sin, @@ -231,13 +253,14 @@ class RotaryEmbeddingWan(RotaryEmbedding): cos: torch.Tensor, sin: torch.Tensor, ) -> torch.Tensor: - from vllm.vllm_flash_attn.layers.rotary import apply_rotary_emb + if self.apply_rotary_emb_vllm_flash_attn is None: + return self.forward_native(x, cos, sin) if cos.dim() > 2: cos = cos.reshape(-1, cos.shape[-1]) sin = sin.reshape(-1, sin.shape[-1]) - return apply_rotary_emb( + return self.apply_rotary_emb_vllm_flash_attn( x, cos, sin, diff --git a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py index 6598525da..fcfd48b4f 100644 --- a/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py +++ b/vllm_omni/diffusion/models/hunyuan_image3/pipeline_hunyuan_image3.py @@ -454,11 +454,10 @@ class HunyuanImage3Pipeline( # Note: guidance_emb and timestep_r_emb are no longer skipped # to support HunyuanImage-3.0-Distil and MeanFlow distilled models - loader = AutoWeightsLoader( - self, - skip_prefixes=skip_prefixes, + loader = AutoWeightsLoader(self) + return loader.load_weights( + weights, mapper=WeightsMapper(orig_to_new_prefix={name: None for name in (skip_prefixes or ())}) ) - return loader.load_weights(weights) def prepare_seed(self, seed=None, batch_size=1): # random seed diff --git a/vllm_omni/diffusion/models/ltx2/ltx2_phase_adapter.py b/vllm_omni/diffusion/models/ltx2/ltx2_phase_adapter.py index 6b0433bbe..7228fa655 100644 --- a/vllm_omni/diffusion/models/ltx2/ltx2_phase_adapter.py +++ b/vllm_omni/diffusion/models/ltx2/ltx2_phase_adapter.py @@ -24,7 +24,7 @@ from vllm.distributed import ( tensor_model_parallel_all_reduce, ) from vllm.logger import init_logger -from vllm.model_executor.layers.batch_invariant import linear_batch_invariant +from vllm.model_executor.determinism.batch_invariant import linear_batch_invariant from vllm.model_executor.layers.linear import ( ColumnParallelLinear, QKVParallelLinear, diff --git a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py index c82ace9c4..696728b90 100644 --- a/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py +++ b/vllm_omni/diffusion/models/qwen_image/qwen_image_transformer.py @@ -50,6 +50,17 @@ from vllm_omni.diffusion.layers.rope import RotaryEmbedding logger = init_logger(__name__) +def _apply_qwen_image_rotary_emb(x: torch.Tensor, freqs: torch.Tensor) -> torch.Tensor: + """Rotate interleaved pairs before rounding back to the activation dtype. + + Qwen-Image's reference uses complex FP32 multiplication. Rounding the + frequencies to BF16 before rotation loses positional precision; those + errors accumulate across the denoising steps. + """ + paired = torch.view_as_complex(x.float().reshape(*x.shape[:-1], -1, 2)) + return torch.view_as_real(paired * freqs.unsqueeze(1)).flatten(3).to(x.dtype) + + def _normalize_qwen_image_weight_name(name: str) -> str: name = name.removeprefix("transformer.") if ".to_out.0." in name: @@ -642,15 +653,23 @@ class QwenImageCrossAttention(nn.Module): txt_query = self.norm_added_q(txt_query) txt_key = self.norm_added_k(txt_key) - img_cos = torch.real(vid_freqs).to(img_query.dtype) - img_sin = torch.imag(vid_freqs).to(img_query.dtype) - txt_cos = torch.real(txt_freqs).to(txt_query.dtype) - txt_sin = torch.imag(txt_freqs).to(txt_query.dtype) + if img_query.device.type == "cuda": + img_query = _apply_qwen_image_rotary_emb(img_query, vid_freqs) + img_key = _apply_qwen_image_rotary_emb(img_key, vid_freqs) + txt_query = _apply_qwen_image_rotary_emb(txt_query, txt_freqs) + txt_key = _apply_qwen_image_rotary_emb(txt_key, txt_freqs) + else: + # Retain the platform-specific kernels on other accelerators, + # which may not support complex tensors. + img_cos = torch.real(vid_freqs).to(img_query.dtype) + img_sin = torch.imag(vid_freqs).to(img_query.dtype) + txt_cos = torch.real(txt_freqs).to(txt_query.dtype) + txt_sin = torch.imag(txt_freqs).to(txt_query.dtype) - img_query = self.rope(img_query, img_cos, img_sin) - img_key = self.rope(img_key, img_cos, img_sin) - txt_query = self.rope(txt_query, txt_cos, txt_sin) - txt_key = self.rope(txt_key, txt_cos, txt_sin) + img_query = self.rope(img_query, img_cos, img_sin) + img_key = self.rope(img_key, img_cos, img_sin) + txt_query = self.rope(txt_query, txt_cos, txt_sin) + txt_key = self.rope(txt_key, txt_cos, txt_sin) seq_len_txt = encoder_hidden_states.shape[1] joint_query = torch.cat([txt_query, img_query], dim=1) diff --git a/vllm_omni/engine/arg_utils.py b/vllm_omni/engine/arg_utils.py index bd6a71562..44bd9058c 100644 --- a/vllm_omni/engine/arg_utils.py +++ b/vllm_omni/engine/arg_utils.py @@ -443,8 +443,8 @@ class OmniEngineArgs(EngineArgs): @dataclass class OmniAsyncEngineArgs(AsyncEngineArgs, OmniEngineArgs): @classmethod - def add_cli_args(cls, parser: argparse.ArgumentParser) -> argparse.ArgumentParser: - parser = AsyncEngineArgs.add_cli_args(parser) + def add_cli_args(cls, parser: argparse.ArgumentParser, async_args_only: bool = False) -> argparse.ArgumentParser: + parser = AsyncEngineArgs.add_cli_args(parser, async_args_only=async_args_only) parser = OmniEngineArgs._add_omni_specific_args(parser) return parser diff --git a/vllm_omni/engine/stage_engine_core_proc_manager.py b/vllm_omni/engine/stage_engine_core_proc_manager.py index b14c62d8f..94a3e58b1 100644 --- a/vllm_omni/engine/stage_engine_core_proc_manager.py +++ b/vllm_omni/engine/stage_engine_core_proc_manager.py @@ -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 """Process manager for omni stage engine subprocesses. @@ -83,6 +83,12 @@ class StageEngineCoreProcManager(CoreEngineProcManager): if local_engine_count <= 0: raise ValueError(f"local_engine_count must be > 0, got {local_engine_count}") + # Mirrors the vLLM 0.29 parent __init__: the inherited shutdown() reads + # this to bound how long in-flight requests may drain. Omitting it makes + # shutdown raise AttributeError, which leaves the engine core + # subprocesses alive and hangs interpreter exit until the job timeout. + self._request_shutdown_timeout = vllm_config.shutdown_timeout + context = get_mp_context() common_kwargs: dict[str, object] = { "vllm_config": vllm_config, diff --git a/vllm_omni/engine/stage_init_utils.py b/vllm_omni/engine/stage_init_utils.py index 765549bcb..de97f7aa3 100644 --- a/vllm_omni/engine/stage_init_utils.py +++ b/vllm_omni/engine/stage_init_utils.py @@ -54,7 +54,7 @@ from vllm_omni.engine.arg_utils import OmniEngineArgs from vllm_omni.entrypoints.stage_utils import _to_dict, set_stage_devices from vllm_omni.entrypoints.utils import filter_dataclass_kwargs from vllm_omni.inputs.data import OmniDiffusionSamplingParams, OmniSamplingParams -from vllm_omni.inputs.preprocess import OmniInputPreprocessor +from vllm_omni.inputs.preprocess import build_omni_renderer, omni_renderer_cls from vllm_omni.outputs.output_processor import MultimodalOutputProcessor from vllm_omni.platforms import current_omni_platform from vllm_omni.quantization.inc_config import OmniINCConfig @@ -1549,25 +1549,24 @@ class _TokenOnlyRenderer(BaseRenderer): def _build_token_only_renderer(stage_vllm_config: Any) -> BaseRenderer: - return _TokenOnlyRenderer(stage_vllm_config, tokenizer=None) + return omni_renderer_cls(_TokenOnlyRenderer)(stage_vllm_config, tokenizer=None) def build_stage0_input_processor(stage_vllm_config: Any) -> InputProcessor: - """Build the shared stage-0 input processor.""" + """Build the shared stage-0 input processor. + + The renderer is the Omni subclass of the upstream renderer class that + ``renderer_from_config`` would have picked (or of the token-only renderer + when the stage skips tokenizer initialization), so upstream's + ``InputProcessor`` runs unmodified on top of it. + """ patch_generation_config_if_needed(stage_vllm_config.model_config) if bool(getattr(stage_vllm_config.model_config, "skip_tokenizer_init", False)): - input_processor = InputProcessor( - vllm_config=stage_vllm_config, - renderer=_build_token_only_renderer(stage_vllm_config), - ) + renderer = _build_token_only_renderer(stage_vllm_config) else: - input_processor = InputProcessor(vllm_config=stage_vllm_config) - input_processor.input_preprocessor = OmniInputPreprocessor( - vllm_config=stage_vllm_config, - renderer=input_processor.renderer, - ) - return input_processor + renderer = build_omni_renderer(stage_vllm_config) + return InputProcessor(vllm_config=stage_vllm_config, renderer=renderer) def device_init_lock_path(device_id: int, lock_dir: str = "/tmp") -> str: diff --git a/vllm_omni/entrypoints/async_omni.py b/vllm_omni/entrypoints/async_omni.py index 948f7e53a..0012039e0 100644 --- a/vllm_omni/entrypoints/async_omni.py +++ b/vllm_omni/entrypoints/async_omni.py @@ -51,9 +51,9 @@ from vllm_omni.metrics.stats import OrchestratorAggregator as OrchestratorMetric from vllm_omni.outputs import OmniRequestOutput if TYPE_CHECKING: - from vllm.inputs.preprocess import InputPreprocessor from vllm.tokenizers import TokenizerLike from vllm.v1.engine import PauseMode + from vllm.v1.engine.input_processor import InputProcessor from vllm_omni.inputs.data import OmniInteractionPrompt, OmniPromptType @@ -1714,7 +1714,7 @@ class AsyncOmni(EngineClient, OmniBase): # ==================== EngineClient Interface ==================== - async def get_input_preprocessor(self) -> InputPreprocessor: + async def get_input_preprocessor(self) -> InputProcessor: """Get input preprocessor.""" return self.input_processor @@ -1755,10 +1755,12 @@ class AsyncOmni(EngineClient, OmniBase): """ logger.debug("Weight update start requested (no-op in omni)") - async def finish_weight_update(self) -> None: + async def finish_weight_update(self, weight_version: str | None = None) -> None: """Finish the current weight update. Omni does not currently support weight transfer, so this is a no-op. + ``weight_version`` is accepted for upstream ``EngineClient`` protocol + compatibility (RLHF weight-transfer routers pass it positionally). """ logger.debug("Weight update finish requested (no-op in omni)") diff --git a/vllm_omni/entrypoints/cli/serve.py b/vllm_omni/entrypoints/cli/serve.py index 78b212f98..5c0204a33 100644 --- a/vllm_omni/entrypoints/cli/serve.py +++ b/vllm_omni/entrypoints/cli/serve.py @@ -18,7 +18,7 @@ from typing import Any, cast import uvloop from vllm.entrypoints.cli.types import CLISubcommand -from vllm.entrypoints.openai.cli_args import make_arg_parser, validate_parsed_serve_args +from vllm.entrypoints.launchers.cli_args import make_arg_parser, validate_parsed_serve_args from vllm.entrypoints.serve.utils.api_utils import VLLM_SUBCMD_PARSER_EPILOG from vllm.logger import init_logger diff --git a/vllm_omni/entrypoints/duplex/chat_fallback.py b/vllm_omni/entrypoints/duplex/chat_fallback.py index 6e4d0a0e0..f41d7205b 100644 --- a/vllm_omni/entrypoints/duplex/chat_fallback.py +++ b/vllm_omni/entrypoints/duplex/chat_fallback.py @@ -9,7 +9,7 @@ from collections.abc import AsyncGenerator from typing import Any from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest -from vllm.entrypoints.openai.engine.protocol import ErrorResponse +from vllm.entrypoints.serve.engine.protocol import ErrorResponse from vllm.logger import init_logger from vllm_omni.entrypoints.duplex.protocol import DuplexSession diff --git a/vllm_omni/entrypoints/openai/api_server.py b/vllm_omni/entrypoints/openai/api_server.py index d976eb181..e020be7bf 100644 --- a/vllm_omni/entrypoints/openai/api_server.py +++ b/vllm_omni/entrypoints/openai/api_server.py @@ -34,7 +34,10 @@ from starlette.types import ASGIApp, Receive, Scope, Send from vllm.engine.protocol import EngineClient from vllm.entrypoints.anthropic.serving import AnthropicServingMessages from vllm.entrypoints.chat_utils import ChatTemplateConfig, load_chat_template -from vllm.entrypoints.launcher import serve_http, terminate_if_errored +from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata +from vllm.entrypoints.launchers.cli_args import make_arg_parser +from vllm.entrypoints.launchers.launcher import serve_http, terminate_if_errored +from vllm.entrypoints.launchers.utils.server_utils import get_uvicorn_log_config from vllm.entrypoints.mcp.tool_server import DemoToolServer, MCPToolServer, ToolServer from vllm.entrypoints.openai.api_server import build_app as build_openai_app from vllm.entrypoints.openai.api_server import setup_server as setup_openai_server @@ -43,19 +46,11 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ChatCompletionResponse, ) -from vllm.entrypoints.openai.cli_args import make_arg_parser # yapf conflicts with isort for this block # yapf: disable # yapf: enable from vllm.entrypoints.openai.completion.serving import OpenAIServingCompletion -from vllm.entrypoints.openai.engine.protocol import ( - ErrorResponse, - ModelCard, - ModelList, - ModelPermission, - RequestResponseMetadata, -) from vllm.entrypoints.openai.models.protocol import BaseModelPath from vllm.entrypoints.openai.models.serving import OpenAIServingModels from vllm.entrypoints.openai.responses.serving import OpenAIServingResponses @@ -64,14 +59,13 @@ from vllm.entrypoints.pooling.embed.serving import ServingEmbedding as OpenAISer from vllm.entrypoints.pooling.pooling.serving import ServingPooling from vllm.entrypoints.pooling.scoring.serving import ServingScores from vllm.entrypoints.scale_out.token_in_token_out.serving import ServingTokens - -# vLLM < 0.28 keeps create_error_response under serve.utils (it is not -# re-exported from vllm.entrypoints.serve); 0.28+ moved it under -# serve.exception_handling and re-exports it from the package root. -try: - from vllm.entrypoints.serve import create_error_response -except ImportError: - from vllm.entrypoints.serve.utils.error_response import create_error_response +from vllm.entrypoints.serve import create_error_response +from vllm.entrypoints.serve.engine.protocol import ( + ErrorResponse, + ModelCard, + ModelList, + ModelPermission, +) # vLLM moved `base` from openai.basic.api_router to serve.instrumentator.basic. # Keep a fallback for older/newer upstream layouts during rebase windows. @@ -85,7 +79,6 @@ from vllm.entrypoints.serve.utils.api_utils import ( ) from vllm.entrypoints.serve.utils.orca_metrics import metrics_header from vllm.entrypoints.serve.utils.request_logger import RequestLogger -from vllm.entrypoints.serve.utils.server_utils import get_uvicorn_log_config from vllm.entrypoints.speech_to_text.realtime.serving import OpenAIServingRealtime from vllm.entrypoints.speech_to_text.transcription.serving import ( OpenAIServingTranscription, diff --git a/vllm_omni/entrypoints/openai/batch_serving.py b/vllm_omni/entrypoints/openai/batch_serving.py index bc5ef36ca..74d78dff7 100644 --- a/vllm_omni/entrypoints/openai/batch_serving.py +++ b/vllm_omni/entrypoints/openai/batch_serving.py @@ -1,5 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright contributors to the vLLM project +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import asyncio import time @@ -11,7 +11,7 @@ from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionResponse, ChatCompletionResponseChoice, ) -from vllm.entrypoints.openai.engine.protocol import ( +from vllm.entrypoints.serve.engine.protocol import ( ErrorResponse, UsageInfo, ) diff --git a/vllm_omni/entrypoints/openai/realtime_connection.py b/vllm_omni/entrypoints/openai/realtime_connection.py index aad7ea658..86a8ba031 100644 --- a/vllm_omni/entrypoints/openai/realtime_connection.py +++ b/vllm_omni/entrypoints/openai/realtime_connection.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import asyncio @@ -8,7 +11,7 @@ from typing import cast from uuid import uuid4 import numpy as np -from vllm.entrypoints.openai.engine.protocol import UsageInfo +from vllm.entrypoints.serve.engine.protocol import UsageInfo from vllm.entrypoints.speech_to_text.realtime.connection import RealtimeConnection as VllmRealtimeConnection from vllm.entrypoints.speech_to_text.realtime.protocol import TranscriptionDelta, TranscriptionDone from vllm.logger import init_logger diff --git a/vllm_omni/entrypoints/openai/serving_chat.py b/vllm_omni/entrypoints/openai/serving_chat.py index bfa241a40..f5cb24efd 100644 --- a/vllm_omni/entrypoints/openai/serving_chat.py +++ b/vllm_omni/entrypoints/openai/serving_chat.py @@ -54,8 +54,17 @@ except ImportError: soundfile = None +from vllm.entrypoints.generate.base.protocol import ( + DeltaFunctionCall, + DeltaMessage, + DeltaToolCall, + FunctionCall, + FunctionDefinition, + RequestResponseMetadata, + ToolCall, +) from vllm.entrypoints.generate.base.serving import clamp_prompt_logprobs -from vllm.entrypoints.launcher import terminate_if_errored +from vllm.entrypoints.launchers.launcher import terminate_if_errored from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionNamedToolChoiceParam, ChatCompletionRequest, @@ -69,22 +78,15 @@ from vllm.entrypoints.openai.chat_completion.serving import ( _get_mm_token_counts, _make_prompt_tokens_details, ) -from vllm.entrypoints.openai.engine.protocol import ( - DeltaFunctionCall, - DeltaMessage, - DeltaToolCall, - ErrorInfo, - ErrorResponse, - FunctionCall, - FunctionDefinition, - RequestResponseMetadata, - ToolCall, - UsageInfo, -) from vllm.entrypoints.openai.parser.harmony_utils import ( get_streamable_parser_for_assistant, ) from vllm.entrypoints.openai.responses.protocol import ResponsesRequest +from vllm.entrypoints.serve.engine.protocol import ( + ErrorInfo, + ErrorResponse, + UsageInfo, +) from vllm.entrypoints.serve.engine.typing import ChatLikeRequest from vllm.entrypoints.serve.utils.api_utils import should_include_usage from vllm.entrypoints.serve.utils.tool_calls_utils import maybe_filter_parallel_tool_calls @@ -700,7 +702,7 @@ class OmniOpenAIServingChat(OpenAIServingChat, AudioMixin): if negative_prompt is not None: tprompt["negative_prompt"] = negative_prompt # Always attach mm_processor_kwargs (possibly empty) so - # OmniInputPreprocessor._process_text routes through the + # OmniRenderer routes through the # multimodal processor path. Without it, the preprocessor # falls back to plain _tokenize_prompt and AR-based image-gen # models like GLM-Image never see their image-generation diff --git a/vllm_omni/entrypoints/openai/serving_speech.py b/vllm_omni/entrypoints/openai/serving_speech.py index 88d9f090d..a9256d953 100644 --- a/vllm_omni/entrypoints/openai/serving_speech.py +++ b/vllm_omni/entrypoints/openai/serving_speech.py @@ -24,12 +24,10 @@ import soundfile as sf import torch from fastapi import HTTPException, Request, UploadFile from fastapi.responses import Response, StreamingResponse +from vllm.entrypoints.generate.base.protocol import RequestResponseMetadata from vllm.entrypoints.generate.base.serving import GenerateBaseServing as OpenAIServing -from vllm.entrypoints.launcher import terminate_if_errored -from vllm.entrypoints.openai.engine.protocol import ( - ErrorResponse, - RequestResponseMetadata, -) +from vllm.entrypoints.launchers.launcher import terminate_if_errored +from vllm.entrypoints.serve.engine.protocol import ErrorResponse from vllm.logger import init_logger from vllm.multimodal.media import MediaConnector from vllm.utils import random_uuid diff --git a/vllm_omni/entrypoints/openai/video_api_utils.py b/vllm_omni/entrypoints/openai/video_api_utils.py index 35a39b2ea..0f01fb28f 100644 --- a/vllm_omni/entrypoints/openai/video_api_utils.py +++ b/vllm_omni/entrypoints/openai/video_api_utils.py @@ -156,7 +156,9 @@ def _decode_video_bytes( frames_array, metadata = loader.load_bytes( video_bytes, num_frames=num_frames, - backend="pyav", + # vLLM 0.29 removed the "pyav" decoder backend; "opencv" is + # upstream's default and the remaining CPU-only option. + backend="opencv", keep=keep, ) except Exception as exc: diff --git a/vllm_omni/entrypoints/openai/video_stream_session.py b/vllm_omni/entrypoints/openai/video_stream_session.py index ead3b1ec5..f77fcf74e 100644 --- a/vllm_omni/entrypoints/openai/video_stream_session.py +++ b/vllm_omni/entrypoints/openai/video_stream_session.py @@ -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 """Video streaming session manager and WebSocket handler. Provides ``VideoStreamConfig``, ``VideoStreamSession`` (frame/audio buffer @@ -20,7 +20,7 @@ from fastapi import WebSocket, WebSocketDisconnect from vllm.entrypoints.openai.chat_completion.protocol import ( ChatCompletionRequest, ) -from vllm.entrypoints.openai.engine.protocol import ErrorResponse +from vllm.entrypoints.serve.engine.protocol import ErrorResponse from vllm.logger import init_logger from vllm_omni.entrypoints.openai.video_frame_filter import FrameSimilarityFilter diff --git a/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py index f18a00ada..f4cb416f6 100644 --- a/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py +++ b/vllm_omni/experimental/ar_diffusion/kv_cache/manager.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project """Engine-level KV cache orchestration for AR-Diffusion models. This is the *body* of AR-Diffusion's KV management: it owns a vLLM ``KVCacheManager`` (a @@ -22,10 +23,10 @@ from vllm.v1.kv_cache_interface import ( KVCacheConfig, KVCacheGroupSpec, KVCacheSpec, - KVCacheTensor, ) from vllm.v1.request import RequestStatus +from vllm_omni.diffusion.diffusion_kv.layout import build_kv_cache_tensor from vllm_omni.experimental.ar_diffusion.capability import ARDiffusionKVBranchSpec from vllm_omni.experimental.ar_diffusion.kv_cache.config import ARDiffusionKVConfig from vllm_omni.experimental.ar_diffusion.kv_cache.paged import ( @@ -134,7 +135,7 @@ def build_kv_manager( """ layer_names = list(layer_names) group = KVCacheGroupSpec(layer_names=layer_names, kv_cache_spec=spec) - tensors = [KVCacheTensor(size=spec.page_size_bytes * num_blocks, shared_by=layer_names)] + tensors = [build_kv_cache_tensor(spec, num_blocks, layer_names)] config = KVCacheConfig( num_blocks=num_blocks, kv_cache_tensors=tensors, diff --git a/vllm_omni/inputs/mm_processor.py b/vllm_omni/inputs/mm_processor.py new file mode 100644 index 000000000..bb02fe961 --- /dev/null +++ b/vllm_omni/inputs/mm_processor.py @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + +"""Multimodal data plumbing for Omni's custom, non-HF processors.""" + +from collections.abc import Mapping +from typing import TypeVar + +from transformers import BatchFeature +from vllm.multimodal.parse import MultiModalDataItems +from vllm.multimodal.processing import BaseMultiModalProcessor, BaseProcessingInfo + +_I = TypeVar("_I", bound=BaseProcessingInfo) + + +class OmniMultiModalProcessor(BaseMultiModalProcessor[_I]): + """Share data/passthrough handling, leaving model processing in its owner. + + These processors implement ``_call_hf_processor`` locally, sometimes + without any HF processor object. Prompt tokens are prepared separately + in ``apply``; the upstream base still owns caching and prompt updates. + """ + + _OMNI_PROMPT_TEXT_KEY = "_vllm_omni_original_prompt_text" + + def _apply_hf_processor_main( + self, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], + ) -> BatchFeature: + kwargs = dict(hf_processor_mm_kwargs) + prompt = kwargs.pop(self._OMNI_PROMPT_TEXT_KEY, None) + if prompt is None: + prompt = self.dummy_inputs.get_dummy_text(mm_items.get_all_counts()) + valid_items = mm_items.select({key for key, count in mm_items.get_all_counts().items() if count > 0}) + mm_data, passthrough = self._get_hf_mm_data(valid_items) + result = self._call_hf_processor(str(prompt), dict(mm_data), kwargs, {}) + result.update(passthrough) + return result diff --git a/vllm_omni/inputs/preprocess.py b/vllm_omni/inputs/preprocess.py index 3da1ef897..dc4105b4d 100644 --- a/vllm_omni/inputs/preprocess.py +++ b/vllm_omni/inputs/preprocess.py @@ -1,200 +1,118 @@ -from typing import Any +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +"""Omni renderer: the upstream renderer class with Omni engine-input routing. -from typing_extensions import assert_never -from vllm.inputs import EmbedsInput, MultiModalInput, SingletonInput -from vllm.inputs.preprocess import InputPreprocessor -from vllm.logger import init_logger -from vllm.renderers.inputs import SingletonDictPrompt +``OmniRenderer`` is a mixin. ``omni_renderer_cls`` puts it in front of a +concrete ``BaseRenderer`` subclass, so the Omni instance *is* an upstream +renderer (tokenizer, executors, multimodal cache and every public entry point +come from upstream unchanged) and only ``_process_singleton`` / +``_process_singleton_async`` are extended. ``build_omni_renderer`` resolves +the concrete class the same way upstream ``renderer_from_config`` does. +""" -from vllm_omni.inputs.data import ( - OmniEmbedsPrompt, - OmniTextPrompt, - OmniTokenInputs, - OmniTokensPrompt, - token_inputs_omni, -) +from __future__ import annotations -logger = init_logger(__name__) +from typing import TYPE_CHECKING, cast + +from vllm.renderers import BaseRenderer +from vllm.renderers.registry import RENDERER_REGISTRY +from vllm.tokenizers.registry import cached_tokenizer_from_config, tokenizer_args_from_config + +if TYPE_CHECKING: + from vllm.config import VllmConfig + from vllm.tokenizers import TokenizerLike -class OmniInputPreprocessor(InputPreprocessor): - """Input preprocessor for omni models. +class OmniRenderer: + """Mixin that routes Omni prompts through the upstream renderer. - Extends the base InputPreprocessor to handle omni-specific input - types including prompt embeddings and additional information payloads. - Supports processing tokens, embeddings, text, and multimodal inputs. + It has no base class of its own; ``omni_renderer_cls`` prepends it to a + concrete ``BaseRenderer`` subclass, so ``self`` and ``super()`` are that + renderer at runtime (hence the casts below). + + Two extensions over upstream ``_process_singleton``: + + * a token prompt that carries ``mm_processor_kwargs`` but no + ``multi_modal_data`` still goes through the multimodal processor (AR + image-generation models such as GLM-Image read their target size from + those kwargs); + * Omni-only prompt keys (``prompt``, ``cache_salt``, + ``additional_information``, ``model_intermediate_buffer``) are copied + onto the engine input. """ - def _process_text( - self, - parsed_content: OmniTextPrompt, - tokenization_kwargs: dict[str, Any] | None = None, - *, - mm_uuids: Any | None = None, - ) -> OmniTokenInputs | MultiModalInput: - """Process text prompts with support for mm_processor_kwargs. + @staticmethod + def _with_omni_extras(inputs, prompt): + for key in ("prompt", "cache_salt", "additional_information", "model_intermediate_buffer"): + if key in prompt: + inputs[key] = prompt[key] + return inputs - Extends base class to support mm_processor_kwargs without multi_modal_data. - This is needed for models like GLM-Image where text-to-image generation - requires processor kwargs (target_h, target_w) to format the prompt. - """ - prompt_text = parsed_content["prompt"] - mm_processor_kwargs = parsed_content.get("mm_processor_kwargs") or {} - # When the deprecated raw-prompt path is used, process_inputs does - # not pass mm_uuids to preprocess(). Fall back to reading it from - # the prompt dict so the Renderer's _validate_mm_uuids can see it. - effective_mm_uuids = mm_uuids or parsed_content.get("multi_modal_uuids") + @staticmethod + def _routes_no_media_kwargs(prompt) -> bool: + return "prompt_embeds" not in prompt and "mm_processor_kwargs" in prompt and not prompt.get("multi_modal_data") - inputs: OmniTokenInputs | MultiModalInput - if multi_modal_data := parsed_content.get("multi_modal_data"): - inputs = self._process_multimodal( - prompt_text, - multi_modal_data, - mm_processor_kwargs, - tokenization_kwargs=tokenization_kwargs, - mm_uuids=effective_mm_uuids, - ) - prompt_embeds = parsed_content.get("prompt_embeds") - if prompt_embeds is not None: - inputs["prompt_embeds"] = prompt_embeds - additional_information = parsed_content.get("additional_information") - if additional_information is not None: - inputs["additional_information"] = additional_information - model_intermediate_buffer = parsed_content.get("model_intermediate_buffer") - if model_intermediate_buffer is not None: - inputs["model_intermediate_buffer"] = model_intermediate_buffer - elif "mm_processor_kwargs" in parsed_content: - # Presence — not truthiness. An explicitly-set empty dict still - # signals "route through the multimodal processor" (needed for - # AR-based image-gen where the HF processor supplies its own - # defaults and scaffold). - inputs = self._process_multimodal( - prompt_text, + def _process_singleton(self, prompt, *, skip_mm_cache: bool = False): + renderer = cast(BaseRenderer, self) + if self._routes_no_media_kwargs(prompt): + inputs = renderer._process_multimodal( + prompt["prompt_token_ids"], {}, - mm_processor_kwargs, - tokenization_kwargs=tokenization_kwargs, - mm_uuids=effective_mm_uuids, + mm_processor_kwargs=prompt["mm_processor_kwargs"], + mm_uuids=prompt.get("multi_modal_uuids"), + skip_mm_cache=skip_mm_cache, ) else: - prompt_token_ids = self._tokenize_prompt( - prompt_text, - tokenization_kwargs=tokenization_kwargs, + inputs = cast(BaseRenderer, super())._process_singleton(prompt, skip_mm_cache=skip_mm_cache) + return self._with_omni_extras(inputs, prompt) + + async def _process_singleton_async(self, prompt, *, skip_mm_cache: bool = False): + renderer = cast(BaseRenderer, self) + if self._routes_no_media_kwargs(prompt): + inputs = await renderer._process_multimodal_async( + prompt["prompt_token_ids"], + {}, + mm_processor_kwargs=prompt["mm_processor_kwargs"], + mm_uuids=prompt.get("multi_modal_uuids"), + skip_mm_cache=skip_mm_cache, ) - inputs = token_inputs_omni( - prompt_token_ids, - prompt_embeds=parsed_content.get("prompt_embeds"), - additional_information=parsed_content.get("additional_information"), - model_intermediate_buffer=parsed_content.get("model_intermediate_buffer"), - ) - prompt_embeds = parsed_content.get("prompt_embeds") - if prompt_embeds is not None: - inputs["prompt_embeds"] = prompt_embeds - additional_information = parsed_content.get("additional_information") - if additional_information is not None: - inputs["additional_information"] = additional_information - model_intermediate_buffer = parsed_content.get("model_intermediate_buffer") - if model_intermediate_buffer is not None: - inputs["model_intermediate_buffer"] = model_intermediate_buffer - if cache_salt := parsed_content.get("cache_salt"): - inputs["cache_salt"] = cache_salt - - return inputs - - def _process_tokens( - self, - parsed_content: OmniTokensPrompt, - tokenization_kwargs: dict[str, Any] | None = None, - ) -> OmniTokenInputs | MultiModalInput: - prompt_token_ids = self._truncate_inputs(parsed_content["prompt_token_ids"], tokenization_kwargs) - prompt_embeds = parsed_content.get("prompt_embeds") - additional_information = parsed_content.get("additional_information") - model_intermediate_buffer = parsed_content.get("model_intermediate_buffer") - - multi_modal_data = parsed_content.get("multi_modal_data") - - inputs: OmniTokenInputs | MultiModalInput - if multi_modal_data: - inputs = self._process_multimodal( - prompt_token_ids, - multi_modal_data, - parsed_content.get("mm_processor_kwargs"), - tokenization_kwargs=tokenization_kwargs, - mm_uuids=parsed_content.get("multi_modal_uuids"), - ) - else: - inputs = token_inputs_omni( - prompt_token_ids=prompt_token_ids, - prompt_embeds=prompt_embeds, - additional_information=additional_information, - model_intermediate_buffer=model_intermediate_buffer, - ) - if prompt_embeds is not None: - inputs["prompt_embeds"] = prompt_embeds - if additional_information is not None: - inputs["additional_information"] = additional_information - if model_intermediate_buffer is not None: - inputs["model_intermediate_buffer"] = model_intermediate_buffer - if prompt_text := parsed_content.get("prompt"): - inputs["prompt"] = prompt_text - if cache_salt := parsed_content.get("cache_salt"): - inputs["cache_salt"] = cache_salt + inputs = await cast(BaseRenderer, super())._process_singleton_async(prompt, skip_mm_cache=skip_mm_cache) + return self._with_omni_extras(inputs, prompt) - return inputs - def _process_embeds( - self, - parsed_content: OmniEmbedsPrompt, - ) -> EmbedsInput: - """Process embeddings prompt with omni-specific extensions. +_OMNI_RENDERER_CLASSES: dict[type[BaseRenderer], type[BaseRenderer]] = {} - Extends base _process_embeds to handle additional_information payload - for direct transfer between pipeline stages. - """ - # Call parent implementation for base embeds processing - inputs = super()._process_embeds(parsed_content) - # Add omni-specific additional_information if present - additional_information = parsed_content.get("additional_information") - if additional_information is not None: - inputs["additional_information"] = additional_information # type: ignore[typeddict-unknown-key] - model_intermediate_buffer = parsed_content.get("model_intermediate_buffer") - if model_intermediate_buffer is not None: - inputs["model_intermediate_buffer"] = model_intermediate_buffer # type: ignore[typeddict-unknown-key] +def omni_renderer_cls(base_cls: type[BaseRenderer]) -> type[BaseRenderer]: + """Return the Omni subclass of ``base_cls`` (created once per base class).""" + if issubclass(base_cls, OmniRenderer): + return base_cls + cls = _OMNI_RENDERER_CLASSES.get(base_cls) + if cls is None: + name = f"Omni{base_cls.__name__}" + cls = type(name, (OmniRenderer, base_cls), {"__module__": __name__, "__qualname__": name}) + _OMNI_RENDERER_CLASSES[base_cls] = cls + return cls - return inputs - def _prompt_to_llm_inputs( - self, - prompt: SingletonDictPrompt, - tokenization_kwargs: dict[str, Any] | None = None, - *, - mm_uuids: Any | None = None, - ) -> SingletonInput: - """ - Extract the singleton inputs from a prompt. +def build_omni_renderer( + vllm_config: VllmConfig, + *, + renderer_cls: type[BaseRenderer] | None = None, + tokenizer: TokenizerLike | None = None, +) -> BaseRenderer: + """Build the Omni renderer for ``vllm_config``. - Arguments: - - * prompt: single encoder or decoder input prompt - - Returns: - - * [`SingletonInput`][vllm.inputs.engine.SingletonInput] instance - """ - if "prompt_embeds" in prompt: - return self._process_embeds(prompt) # type: ignore[arg-type] - - if "prompt_token_ids" in prompt: - return self._process_tokens( - prompt, # type: ignore[arg-type] - ) - - if "prompt" in prompt: - return self._process_text( - prompt, # type: ignore[arg-type] - tokenization_kwargs=tokenization_kwargs, - mm_uuids=mm_uuids, - ) - - assert_never(prompt) # type: ignore[arg-type] + With ``renderer_cls`` unset this mirrors upstream ``renderer_from_config``: + the tokenizer and renderer mode come from the model config and the class + from ``RENDERER_REGISTRY``. Pass ``renderer_cls`` (and optionally + ``tokenizer``) to skip that resolution, e.g. for tokenizer-less stages. + """ + model_config = vllm_config.model_config + if renderer_cls is None: + if tokenizer is None: + tokenizer = cached_tokenizer_from_config(model_config) + renderer_mode, *_ = tokenizer_args_from_config(model_config) + renderer_cls = RENDERER_REGISTRY.load_renderer_cls(renderer_mode) + return omni_renderer_cls(renderer_cls)(vllm_config, tokenizer) diff --git a/vllm_omni/model_executor/models/audex/audex_omni_processing.py b/vllm_omni/model_executor/models/audex/audex_omni_processing.py index 5e4c53908..7b80f53f8 100644 --- a/vllm_omni/model_executor/models/audex/audex_omni_processing.py +++ b/vllm_omni/model_executor/models/audex/audex_omni_processing.py @@ -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 # # Adapted from nvidia/Nemotron-Labs-Audex-2B (Apache-2.0): # inference_scripts_vllm/audioqa_scripts/audex_2b_vllm/audio_features.py @@ -33,13 +33,14 @@ from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, PromptReplacement, PromptUpdate, PromptUpdateDetails, ) +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor + SOUND_TOKEN = "" SOUND_START_TOKEN = "" SOUND_END_TOKEN = "" @@ -176,7 +177,7 @@ class AudexDummyInputsBuilder(BaseDummyInputsBuilder[AudexProcessingInfo]): return {"audio": self._get_dummy_audios(length=audio_len, num_audios=num_audios, overrides=overrides)} -class AudexMultiModalProcessor(BaseMultiModalProcessor[AudexProcessingInfo]): +class AudexMultiModalProcessor(OmniMultiModalProcessor[AudexProcessingInfo]): def _call_hf_processor( self, prompt: str, diff --git a/vllm_omni/model_executor/models/bagel/bagel.py b/vllm_omni/model_executor/models/bagel/bagel.py index 495e0037f..30dced9d0 100644 --- a/vllm_omni/model_executor/models/bagel/bagel.py +++ b/vllm_omni/model_executor/models/bagel/bagel.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace from math import isqrt from typing import Any @@ -20,7 +21,7 @@ from vllm.model_executor.model_loader.weight_utils import default_weight_loader from vllm.model_executor.models.bagel import BagelForConditionalGeneration from vllm.model_executor.models.interfaces import MultiModalEmbeddings from vllm.model_executor.models.qwen2 import Qwen2DecoderLayer, Qwen2MLP -from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper from vllm.multimodal import MULTIMODAL_REGISTRY from vllm.multimodal.inputs import ( MultiModalFieldConfig, @@ -221,6 +222,20 @@ class OmniBagelDataParser(MultiModalDataParser): class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingInfo]): IMG2IMG_PLACEHOLDER = "<|fim_middle|>" + def apply(self, inputs, timing_ctx): + num_img2img = inputs.mm_data_items.get_all_counts().get("img2img", 0) + if num_img2img: + token_id = self.info.get_tokenizer().get_vocab().get(self.IMG2IMG_PLACEHOLDER) + if token_id is not None: + prompt_ids = list(inputs.prompt) + missing = num_img2img - prompt_ids.count(token_id) + if missing > 0: + inputs = replace( + inputs, + prompt=[token_id] * missing + prompt_ids, + ) + return super().apply(inputs, timing_ctx) + @staticmethod def _mm_kwargs_for_bagel_img2img_hf(mm_kwargs: Mapping[str, object]) -> dict[str, object]: # OpenAI / GLM-style serving may pass target_h/target_w for output grid sizing. @@ -241,69 +256,51 @@ class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingIn "pixel_values_img2img": MultiModalFieldConfig.batched("img2img"), } - def _call_hf_processor( + def _apply_hf_processor_main( self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], - ) -> "BatchFeature": - has_image = "images" in mm_data - has_img2img = "pixel_values_img2img" in mm_data - - if has_img2img and self.IMG2IMG_PLACEHOLDER not in prompt: - prompt = f"{self.IMG2IMG_PLACEHOLDER}{prompt}" - - if has_image and has_img2img: - outputs = BatchFeature() - - img_data = dict(mm_data) - if "pixel_values_img2img" in img_data: - del img_data["pixel_values_img2img"] - kwargs_img = dict(mm_kwargs) - kwargs_img["is_img2img"] = False - out_img = super()._call_hf_processor(prompt, img_data, kwargs_img, tok_kwargs) - if "pixel_values" in out_img: - outputs["pixel_values"] = out_img["pixel_values"] - for k, v in out_img.items(): - if k != "pixel_values": - outputs[k] = v - - img2img_data = dict(mm_data) - if "images" in img2img_data: - del img2img_data["images"] - img2img_data["images"] = img2img_data.pop("pixel_values_img2img") - kwargs_img2img = self._mm_kwargs_for_bagel_img2img_hf(mm_kwargs) - kwargs_img2img["is_img2img"] = True - out_img2img = super()._call_hf_processor(prompt, img2img_data, kwargs_img2img, tok_kwargs) - if "pixel_values" in out_img2img: - outputs["pixel_values_img2img"] = out_img2img["pixel_values"] - for k, v in out_img2img.items(): - if k not in outputs: - outputs[k] = v - - return outputs - - elif has_img2img: - mm_data = dict(mm_data) - mm_data["images"] = mm_data.pop("pixel_values_img2img") - mm_kwargs = self._mm_kwargs_for_bagel_img2img_hf(mm_kwargs) - mm_kwargs["is_img2img"] = True - outputs = super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs) - if "pixel_values" in outputs: - outputs["pixel_values_img2img"] = outputs.pop("pixel_values") - return outputs - - return super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs) - - def _hf_processor_applies_updates( - self, - prompt_text: str, mm_items: MultiModalDataItems, hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False + ) -> BatchFeature: + valid_mm_items = mm_items.select({key for key, count in mm_items.get_all_counts().items() if count > 0}) + mm_data, passthrough_data = self._get_hf_mm_data(valid_mm_items) + prompt_text = self.dummy_inputs.get_dummy_text(mm_items.get_all_counts()) + processor = self.info.get_hf_processor(**hf_processor_mm_kwargs) + + has_image = "images" in mm_data + has_img2img = "pixel_values_img2img" in mm_data + processed_data = BatchFeature() + + if has_image: + image_kwargs = {**hf_processor_mm_kwargs, "is_img2img": False} + image_outputs = self.info.ctx.call_hf_processor( + processor, + {"text": prompt_text, "images": mm_data["images"]}, + image_kwargs, + ) + processed_data.update(image_outputs) + + if has_img2img: + img2img_kwargs = self._mm_kwargs_for_bagel_img2img_hf(hf_processor_mm_kwargs) + img2img_kwargs["is_img2img"] = True + img2img_outputs = self.info.ctx.call_hf_processor( + processor, + { + "text": prompt_text, + "images": mm_data["pixel_values_img2img"], + }, + img2img_kwargs, + ) + pixel_values = img2img_outputs.pop("pixel_values", None) + if pixel_values is not None: + processed_data["pixel_values_img2img"] = pixel_values + for key, value in img2img_outputs.items(): + processed_data.setdefault(key, value) + + if not has_image and not has_img2img: + processed_data = BatchFeature(dict(passthrough_data)) + else: + processed_data.update(passthrough_data) + return processed_data def _get_prompt_updates( self, @@ -372,7 +369,8 @@ class OmniBagelMultiModalProcessor(BaseMultiModalProcessor[OmniBagelProcessingIn embed_mask = [True] * num_vae_total + [False] + [True] * num_vit_total return PromptUpdateDetails( full=tokens, - is_embed=lambda _tok, _seq, _m=embed_mask: torch.tensor(_m, dtype=torch.bool), + # vLLM 0.29 calls is_embed with just the full token list. + is_embed=lambda _full, _m=embed_mask: torch.tensor(_m, dtype=torch.bool), ) replacements.append( @@ -1102,11 +1100,12 @@ class OmniBagelForConditionalGeneration(BagelForConditionalGeneration): filtered_weights.append((mapped_name, tensor)) loader = AutoWeightsLoader( - self, - skip_prefixes=["vit_pos_embed.pos_embed"], - ignore_unexpected_prefixes=["vae.", "latent_pos_embed.", "time_embedder.", "vae2llm."], + self, ignore_unexpected_prefixes=["vae.", "latent_pos_embed.", "time_embedder.", "vae2llm."] + ) + loaded = loader.load_weights( + filtered_weights, + mapper=(self.hf_to_vllm_mapper) | WeightsMapper(orig_to_new_prefix={"vit_pos_embed.pos_embed": None}), ) - loaded = loader.load_weights(filtered_weights, mapper=self.hf_to_vllm_mapper) loaded |= self._load_moe_gen_weights(moe_gen_weights) diff --git a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py index 84652cada..558e96b65 100644 --- a/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py +++ b/vllm_omni/model_executor/models/cosyvoice3/cosyvoice3.py @@ -2,6 +2,7 @@ # SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project import os from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace from functools import partial from math import gcd from threading import Lock @@ -24,7 +25,6 @@ from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, ProcessorInputs, PromptIndexTargets, @@ -38,6 +38,7 @@ from vllm.v1.sample.ops.topk_topp_sampler import random_sample from vllm.v1.sample.sampler import Sampler from vllm_omni.data_entry_keys import EmbeddingsStruct, OmniPayloadStruct, to_dict, to_struct +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor from vllm_omni.model_executor.models.cosyvoice3.tokenizer import get_qwen_tokenizer from vllm_omni.model_executor.models.cosyvoice3.utils import ( concat_text_with_prompt_ids, @@ -108,7 +109,45 @@ class CosyVoice3MultiModalProcessingInfo(BaseProcessingInfo): ) -class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModalProcessingInfo]): +class CosyVoice3MultiModalProcessor(OmniMultiModalProcessor[CosyVoice3MultiModalProcessingInfo]): + def apply(self, inputs: ProcessorInputs, timing_ctx): + tokenizer = self.info.get_tokenizer() + prompt_text = tokenizer.decode(inputs.prompt, skip_special_tokens=False) + config = self.info.ctx.get_hf_config() + model_dir = self.info.ctx.model_config.model + self._ensure_cached_runtime_components(model_dir, config) + + text_token, text_token_len = extract_text_token( + prompt_text, + self.tokenizer, + config.allowed_special, + ) + if inputs.mm_data_items.get_all_counts().get("audio", 0): + reference_text = inputs.hf_processor_mm_kwargs.get("prompt_text") + if not isinstance(reference_text, str): + raise ValueError(f"prompt text is None : {reference_text}") + prompt_text_token, prompt_text_token_len = extract_text_token( + reference_text, + self.tokenizer, + config.allowed_special, + ) + text_token, _ = concat_text_with_prompt_ids( + text_token, + text_token_len, + prompt_text_token, + prompt_text_token_len, + ) + + inputs = replace( + inputs, + prompt=text_token.reshape(-1).tolist(), + hf_processor_mm_kwargs={ + **inputs.hf_processor_mm_kwargs, + self._OMNI_PROMPT_TEXT_KEY: prompt_text, + }, + ) + return super().apply(inputs, timing_ctx) + def _ensure_cached_runtime_components(self, model_dir: str, config: CosyVoice3Config) -> None: cached_model_dir = getattr(self, "_cached_model_dir", None) if cached_model_dir == model_dir: @@ -376,15 +415,6 @@ class CosyVoice3MultiModalProcessor(BaseMultiModalProcessor[CosyVoice3MultiModal "embedding": MultiModalFieldConfig.batched("audio"), } - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False - def _get_prompt_updates( self, mm_items: MultiModalDataItems, diff --git a/vllm_omni/model_executor/models/covo_audio/covo_audio.py b/vllm_omni/model_executor/models/covo_audio/covo_audio.py index efbe7b0d6..108bd36c5 100644 --- a/vllm_omni/model_executor/models/covo_audio/covo_audio.py +++ b/vllm_omni/model_executor/models/covo_audio/covo_audio.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2026 Tencent. from collections.abc import Iterable, Mapping, Sequence from typing import Any @@ -17,7 +20,6 @@ from vllm.multimodal.inputs import MultiModalFieldConfig from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, ProcessorInputs, PromptReplacement, @@ -26,6 +28,7 @@ from vllm.multimodal.processing import ( from vllm.sequence import IntermediateTensors from vllm.v1.sample.metadata import SamplingMetadata +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.model_executor.models.utils import add_prefix_to_loaded_weights @@ -106,10 +109,7 @@ class CovoAudioDummyInputsBuilder(BaseDummyInputsBuilder[CovoAudioProcessingInfo ) -class CovoAudioMultiModalProcessor(BaseMultiModalProcessor[CovoAudioProcessingInfo]): - def _hf_processor_applies_updates(self, prompt_text, mm_items, hf_processor_mm_kwargs, tokenization_kwargs) -> bool: - return False - +class CovoAudioMultiModalProcessor(OmniMultiModalProcessor[CovoAudioProcessingInfo]): def _call_hf_processor( self, prompt: str, mm_data: Mapping[str, object], mm_kwargs: Mapping[str, Any], tok_kwargs: Mapping[str, object] ) -> BatchFeature: diff --git a/vllm_omni/model_executor/models/gepard/gepard_talker.py b/vllm_omni/model_executor/models/gepard/gepard_talker.py index 02f5da533..37c1e66a9 100644 --- a/vllm_omni/model_executor/models/gepard/gepard_talker.py +++ b/vllm_omni/model_executor/models/gepard/gepard_talker.py @@ -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 """Gepard-1.0 native-AR talker. Single-stage AR TTS on a vLLM-native Qwen3.5 backbone. Each step samples one @@ -22,7 +22,7 @@ import torch.nn as nn from vllm.config import VllmConfig from vllm.logger import init_logger from vllm.model_executor.models.qwen3_5 import Qwen3_5ForCausalLM -from vllm.model_executor.models.utils import AutoWeightsLoader, maybe_prefix +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper, maybe_prefix from vllm.sequence import IntermediateTensors from vllm_omni.model_executor.models.gepard.configuration_gepard import GepardConfig @@ -199,8 +199,10 @@ class GepardTalkerForConditionalGeneration(nn.Module): # NanoCodec loads lazily on first decode, so load_format=dummy and # NeMo-less environments never pay for it. - loader = AutoWeightsLoader(self, skip_prefixes=["mtp.", "ref_compressor."]) - return loader.load_weights(iter(rest)) + loader = AutoWeightsLoader(self) + return loader.load_weights( + iter(rest), mapper=WeightsMapper(orig_to_new_prefix={"mtp.": None, "ref_compressor.": None}) + ) def _get_or_create_state(self, request_id: str) -> _GepardState: st = self._active_states.get(request_id) diff --git a/vllm_omni/model_executor/models/glm_image/glm_image_ar.py b/vllm_omni/model_executor/models/glm_image/glm_image_ar.py index 77aef398b..e97a78c25 100644 --- a/vllm_omni/model_executor/models/glm_image/glm_image_ar.py +++ b/vllm_omni/model_executor/models/glm_image/glm_image_ar.py @@ -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 # Adapted from # https://github.com/huggingface/transformers/blob/main/src/transformers/models/glm_image/modeling_glm_image.py @@ -24,6 +24,7 @@ import math import os from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace from functools import lru_cache from typing import Annotated, Literal @@ -81,7 +82,6 @@ from vllm.multimodal.inputs import ( MultiModalKwargsItems, ) from vllm.multimodal.parse import ( - ImageProcessorItems, MultiModalDataItems, MultiModalDataParser, ) @@ -341,6 +341,30 @@ class GlmImageDummyInputsBuilder(BaseDummyInputsBuilder[GlmImageProcessingInfo]) } +_GLM_IMAGE_GRID_FACTOR = 32 + + +def _build_target_shape_scaffold(processor, *, height: int, width: int, is_text_to_image: bool) -> str: + """Return the grid scaffold HF appends to a GLM-Image generation prompt. + + Mirrors ``GlmImageProcessor._build_prompt_with_target_shape`` (identical in + transformers 5.13 and 5.14) using only the processor's public token + attributes, so this does not depend on a private HF method that a stub or + a future release may not provide. + """ + factor = _GLM_IMAGE_GRID_FACTOR + token_h = (height // factor * factor) // factor + token_w = (width // factor * factor) // factor + grid_bos, grid_eos, bos = processor.grid_bos_token, processor.grid_eos_token, processor.bos_token + scaffold = f"{grid_bos}{token_h} {token_w}{grid_eos}" + if is_text_to_image: + ratio = token_h / token_w + prev_token_h = int(math.sqrt(ratio) * (factor // 2)) + prev_token_w = int(math.sqrt(1 / ratio) * (factor // 2)) + scaffold += f"{grid_bos}{prev_token_h} {prev_token_w}{grid_eos}" + return scaffold + bos + + class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo]): """ Multimodal processor for GLM-Image. @@ -358,265 +382,38 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo return self._apply_hf_processor(inputs, timing_ctx) return super()._cached_apply_hf_processor(inputs, timing_ctx) - def _call_hf_processor( - self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], - ) -> BatchFeature: - """ - Call the HuggingFace processor. - - For text-to-image mode (no images), we need to: - 1. Build the prompt with target grid dimensions - 2. Build the image_grid_thw tensor for M-RoPE position encoding - - For image-to-image mode: - 1. Process source images through the image processor - 2. Build prompt with image placeholders expanded - 3. Build image_grid_thw including source and target grids - """ - processor = self.info.get_hf_processor() - - # Get target dimensions from mm_kwargs or use defaults - target_h = mm_kwargs.get("target_h", 1024) if mm_kwargs else 1024 - target_w = mm_kwargs.get("target_w", 1024) if mm_kwargs else 1024 - - logger.debug( - f"_call_hf_processor: target dimensions for generation: {target_h}x{target_w}, mm_kwargs={mm_kwargs}" - ) - - if not mm_data or not mm_data.get("images"): - # Text-to-image mode - if processor is not None: - # Build messages format expected by processor - messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}] - - # Use apply_chat_template which handles target dimensions - hf_inputs = processor.apply_chat_template( - messages, - tokenize=True, - target_h=target_h, - target_w=target_w, - return_dict=True, - return_tensors="pt", - ) - - return hf_inputs - else: - # Fallback: just tokenize (this won't work properly for generation) - tokenizer = self.info.get_tokenizer() - prompt_ids = tokenizer.encode(prompt) - return BatchFeature(dict(input_ids=[prompt_ids]), tensor_type="pt") - - # Image-to-image mode - # NOTE: Use "images" (plural) - this is what vLLM's ImageProcessorItems.get_processor_data() returns - images = mm_data.get("images") - if not isinstance(images, list): - images = [images] - - logger.debug( - f"_call_hf_processor i2i: num_images={len(images)}, image_types={[type(img).__name__ for img in images]}" - ) - - if processor is not None: - # Build messages with image objects directly in content - # This is how GlmImageProcessor expects images - embedded in the content dict - # NOT as a separate images= parameter - # - # IMPORTANT: Remove <|image|> placeholders from prompt since apply_chat_template - # will automatically insert them for each image in content. Having both leads to - # index out of bounds when processing image_grid_thw. - clean_prompt = prompt.replace("<|image|>", "") - content = [] - for img in images: - content.append({"type": "image", "image": img}) - content.append({"type": "text", "text": clean_prompt}) - messages = [{"role": "user", "content": content}] - - logger.debug(f"_call_hf_processor: calling apply_chat_template with {len(images)} images in content") - - # Use apply_chat_template - processor will process images when they're in content - hf_inputs = processor.apply_chat_template( - messages, - tokenize=True, - target_h=target_h, - target_w=target_w, - return_dict=True, - return_tensors="pt", + def apply(self, inputs, timing_ctx): + """Add the generation scaffold before upstream expands source images.""" + num_images = inputs.mm_data_items.get_all_counts().get("image", 0) + prompt_ids = list(inputs.prompt) + if num_images: + image_token_id = getattr( + self.info.get_hf_config(), + "image_token_id", + 167855, ) + missing = num_images - prompt_ids.count(image_token_id) + if missing > 0: + prompt_ids = [image_token_id] * missing + prompt_ids - logger.debug(f"_call_hf_processor: apply_chat_template returned keys: {list(hf_inputs.keys())}") - - # IMPORTANT (i2i): vLLM multimodal encoder must see source-only grids - # (matching pixel_values and number of images), but M-RoPE needs full - # grids (source + target) to compute correct decode positions. - image_grid_thw = hf_inputs.get("image_grid_thw") - if image_grid_thw is not None: - # Preserve full grids for M-RoPE. - hf_inputs["mrope_image_grid_thw"] = image_grid_thw - - # Expose source-only grids for MM. - # In most i2i requests, we process one prompt at a time here, - # so `len(images)` is the number of source images. - num_source_images = len(images) - if image_grid_thw.shape[0] != num_source_images: - source_grids = image_grid_thw[:num_source_images] - hf_inputs["image_grid_thw"] = source_grids - logger.debug( - "_call_hf_processor: adjusted image_grid_thw for MM from %s to %s (num_source_images=%d)", - tuple(image_grid_thw.shape), - tuple(source_grids.shape), - num_source_images, - ) - - # Debug: Analyze input_ids for image tokens - input_ids = hf_inputs.get("input_ids") - if input_ids is not None: - if hasattr(input_ids, "tolist"): - ids_list = input_ids.tolist() - if isinstance(ids_list[0], list): - ids_list = ids_list[0] # Unbatch - else: - ids_list = list(input_ids) - - # Get image token ID from config - hf_config = self.info.get_hf_config() - image_token_id = getattr(hf_config, "image_token_id", 167855) - - # Count image tokens - image_token_count = ids_list.count(image_token_id) - logger.debug( - f"_call_hf_processor: input_ids length={len(ids_list)}, " - f"image_token_id={image_token_id}, " - f"image_token_count={image_token_count}" - ) - - # Log first/last few tokens to understand structure - logger.debug(f"_call_hf_processor: first 20 tokens: {ids_list[:20]}") - logger.debug(f"_call_hf_processor: last 20 tokens: {ids_list[-20:]}") - - # Find positions of image tokens - image_positions = [i for i, t in enumerate(ids_list) if t == image_token_id] - if image_positions: - logger.debug(f"_call_hf_processor: image token positions (first 10): {image_positions[:10]}") - - return hf_inputs - else: - # Fallback without processor - this is not ideal but prevents crashes - logger.warning("GlmImageProcessor not available, using fallback for i2i") - tokenizer = self.info.get_tokenizer() - hf_config = self.info.get_hf_config() - - # Get image token - image_token_id = getattr(hf_config, "image_token_id", 167855) - try: - image_token = tokenizer.convert_ids_to_tokens(image_token_id) - except Exception: - image_token = "<|image|>" - - # Build prompt with image placeholders - image_placeholders = image_token * len(images) - full_prompt = f"{image_placeholders}{prompt}" - prompt_ids = tokenizer.encode(full_prompt) - return BatchFeature(dict(input_ids=[prompt_ids]), tensor_type="pt") - - def _apply_hf_processor_mm_only( - self, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> BatchFeature: - """ - Apply the HF processor on the multi-modal data only. - - GLM-Image requires special handling because apply_chat_template always - adds a target <|image|> placeholder in addition to source image placeholders. - This causes an IndexError when the HF processor tries to find grid info - for the target placeholder (which doesn't exist for source-only processing). - - Solution: Call the image processor directly to get pixel_values and - image_grid_thw, bypassing apply_chat_template's target handling. - """ - mm_counts = mm_items.get_all_counts() - num_images = mm_counts.get("image", 0) - - if num_images == 0: - # No images - call parent implementation - return super()._apply_hf_processor_mm_only( - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - - # For i2i mode, we need to process images directly with the image processor - # to avoid the apply_chat_template target placeholder issue processor = self.info.get_hf_processor() - image_processor = processor.image_processor - - # Get images from mm_items - images = mm_items.get_items("image", ImageProcessorItems) - image_list = [images.get(i) for i in range(images.get_count())] - - logger.debug(f"_apply_hf_processor_mm_only: processing {len(image_list)} images directly") - - # Process images directly with image processor - image_inputs = image_processor( - images=image_list, - return_tensors="pt", - ) - - # Get grid info for source images only (no target) - pixel_values = image_inputs.get("pixel_values") - image_grid_thw = image_inputs.get("image_grid_thw") - if image_grid_thw is not None and image_grid_thw.shape[0] != num_images: - # Be defensive: some processors may include extra target grids. - image_grid_thw = image_grid_thw[:num_images] - image_inputs["image_grid_thw"] = image_grid_thw - - logger.debug( - f"_apply_hf_processor_mm_only: pixel_values shape=\ - {pixel_values.shape if pixel_values is not None else None}, " - f"image_grid_thw shape={image_grid_thw.shape if image_grid_thw is not None else None}" - ) - - # Build input_ids with image token placeholders - # The _get_prompt_updates returns PromptReplacement(target=[image_token_id], ...) - # which needs to find image tokens in input_ids to replace them. - # We need to include one image_token_id per image so the replacement can work. tokenizer = self.info.get_tokenizer() - image_token_id = tokenizer.convert_tokens_to_ids("<|image|>") - - # Build input_ids: [image_token] * num_images + tokenized text - # This way _apply_prompt_updates can find the image tokens and replace them - dummy_text = self.dummy_inputs.get_dummy_text(mm_counts) - text_ids = tokenizer.encode(dummy_text, add_special_tokens=False) - input_ids = [image_token_id] * num_images + text_ids - - logger.debug( - f"_apply_hf_processor_mm_only: built input_ids with {num_images} image tokens + {len(text_ids)} text tokens" + # Append HF's target-shape scaffold without decoding/re-tokenizing the + # user's tokens. Text-to-image needs both target and preview grids; i2i + # needs only the target. Grid metadata alone does not supply these AR + # tokens. + target_grid = self._build_generation_grids(inputs.hf_processor_mm_kwargs)[0] + suffix = _build_target_shape_scaffold( + processor, + height=int(target_grid[1]) * 32, + width=int(target_grid[2]) * 32, + is_text_to_image=num_images == 0, ) - - return BatchFeature( - dict( - input_ids=[input_ids], - pixel_values=pixel_values, - image_grid_thw=image_grid_thw, - ), - tensor_type="pt", - ) - - def _apply_hf_processor_text_only( - self, prompt_text: str, hf_processor_mm_kwargs: Mapping[str, object], tokenization_kwargs: Mapping[str, object] - ) -> list[int]: - prompt_ids, _, _ = super()._apply_hf_processor_text_mm( - prompt_text=prompt_text, - mm_items=MultiModalDataItems({}), - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - return prompt_ids + suffix_ids = tokenizer.encode(suffix, add_special_tokens=False) + if prompt_ids[-len(suffix_ids) :] != suffix_ids: + prompt_ids.extend(suffix_ids) + inputs = replace(inputs, prompt=prompt_ids) + return super().apply(inputs, timing_ctx) def _build_generation_grids(self, hf_processor_mm_kwargs: Mapping[str, object]) -> torch.Tensor: """Build generation grids for M-RoPE decode positions. @@ -661,171 +458,39 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo def _apply_hf_processor_main( self, - prompt: str | list[int], mm_items: MultiModalDataItems, hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - *, - enable_hf_prompt_update: bool, - ) -> tuple[list[int], BatchFeature, bool]: - """ - Override to handle GLM-Image i2i mode correctly. - - Problem: When vLLM processes cached mm items (enable_hf_prompt_update=False), - the base implementation: - 1. Gets prompt_ids from _apply_hf_processor_text_only (no image tokens) - 2. Gets mm_data from _apply_hf_processor_mm_only - 3. Returns is_update_applied=False - - This causes _apply_prompt_updates to fail because prompt_ids has no image tokens. - - Solution: For i2i mode, we build prompt_ids that include image placeholders, - and return is_update_applied=False so _apply_prompt_updates can expand them. - """ + ) -> BatchFeature: + """Process source images under vLLM's token-only prompt contract.""" mm_counts = mm_items.get_all_counts() num_images = mm_counts.get("image", 0) + if num_images == 0: + return BatchFeature({"mrope_image_grid_thw": self._build_generation_grids(hf_processor_mm_kwargs)}) - logger.debug(f"_apply_hf_processor_main: mm_counts={mm_counts}, num_images={num_images}") + valid_mm_items = mm_items.select({key for key, count in mm_counts.items() if count > 0}) + processor_data, passthrough_data = self._get_hf_mm_data(valid_mm_items) + images = processor_data.get("images") + if not images: + return BatchFeature(dict(passthrough_data)) - if num_images == 0 and isinstance(prompt, str): - # t2i mode or normal flow - use parent implementation - prompt_ids = self._apply_hf_processor_text_only( - prompt_text=prompt, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - mm_processed_data = self._apply_hf_processor_mm_only( - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - - # t2i has no source images, so mm features cannot provide image_grid_thw. - # Provide explicit generation grids for M-RoPE to avoid fallback token parsing - # (which can degrade high-resolution spatial positions, e.g. 1920x1920). - try: - mrope_grid_thw = self._build_generation_grids(hf_processor_mm_kwargs) - mm_processed_data["mrope_image_grid_thw"] = mrope_grid_thw - logger.debug( - "_apply_hf_processor_main t2i: mrope_image_grid_thw=%s", - mrope_grid_thw.tolist(), - ) - except Exception as e: - logger.warning("_apply_hf_processor_main t2i: failed to set mrope_image_grid_thw: %s", e) - - return prompt_ids, mm_processed_data, False - - # i2i mode: use unified HF processor path only. - # This avoids drift between duplicated manual/HF i2i implementations. - logger.debug( - "_apply_hf_processor_main: i2i mode (enable_hf_prompt_update=%s), num_images=%s", - enable_hf_prompt_update, - num_images, + processor = self.info.get_hf_processor() + image_inputs = processor.image_processor( + images=list(images), + return_tensors="pt", ) + image_grid_thw = image_inputs.get("image_grid_thw") + if image_grid_thw is not None: + source_grid_thw = image_grid_thw[:num_images] + image_inputs["image_grid_thw"] = source_grid_thw - if not isinstance(prompt, str): - # Online OpenAI chat preprocessing can arrive here with tokenized - # prompts (list[int]) before serving_chat replaces engine prompt - # with the clean text prompt. Do not fail the whole request. - logger.warning( - "_apply_hf_processor_main i2i: got tokenized prompt type=%s; " - "using compatibility path for preprocessing", - type(prompt).__name__, + target_grid = self._build_generation_grids(hf_processor_mm_kwargs)[:1].to(dtype=source_grid_thw.dtype) + image_inputs["mrope_image_grid_thw"] = torch.cat( + [source_grid_thw, target_grid], + dim=0, ) - prompt_ids = list(prompt) - mm_processed_data = self._apply_hf_processor_mm_only( - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - - # Preserve full grids for M-RoPE decode (source + target), while - # keeping image_grid_thw source-only for MM batching. - try: - source_grid_thw = mm_processed_data.get("image_grid_thw") - if source_grid_thw is not None and isinstance(source_grid_thw, torch.Tensor): - target_h = ( - hf_processor_mm_kwargs.get("target_h") - if isinstance(hf_processor_mm_kwargs.get("target_h"), int) - else None - ) - target_w = ( - hf_processor_mm_kwargs.get("target_w") - if isinstance(hf_processor_mm_kwargs.get("target_w"), int) - else None - ) - if target_h is None or target_w is None: - target_h = ( - hf_processor_mm_kwargs.get("height") - if isinstance(hf_processor_mm_kwargs.get("height"), int) - else 1024 - ) - target_w = ( - hf_processor_mm_kwargs.get("width") - if isinstance(hf_processor_mm_kwargs.get("width"), int) - else 1024 - ) - - factor = 32 - token_h = max(1, target_h // factor) - token_w = max(1, target_w // factor) - target_grid = torch.tensor([[1, token_h, token_w]], dtype=source_grid_thw.dtype) - mm_processed_data["mrope_image_grid_thw"] = torch.cat([source_grid_thw, target_grid], dim=0) - except Exception: - pass - - # Prompt updates will expand image placeholders in this compatibility path. - return prompt_ids, mm_processed_data, False - - images = mm_items.get_items("image", ImageProcessorItems) - image_list = [images.get(i) for i in range(images.get_count())] - if not image_list: - raise ValueError("GLM-Image i2i requires at least one source image in mm_items") - - hf_inputs = self._call_hf_processor( - prompt=prompt, - mm_data={"images": image_list}, - mm_kwargs=hf_processor_mm_kwargs, - tok_kwargs=tokenization_kwargs, - ) - - input_ids = hf_inputs.get("input_ids") - if input_ids is None: - raise ValueError("HF i2i processor returned no input_ids") - - if isinstance(input_ids, torch.Tensor): - prompt_ids = input_ids[0].tolist() if input_ids.dim() > 1 else input_ids.tolist() - else: - prompt_ids = ( - input_ids[0] - if isinstance(input_ids, list) and input_ids and isinstance(input_ids[0], list) - else list(input_ids) - ) - - mm_processed_data = BatchFeature(dict(), tensor_type="pt") - for key in ("pixel_values", "image_grid_thw", "mrope_image_grid_thw"): - value = hf_inputs.get(key) - if value is not None: - mm_processed_data[key] = value - - image_grid_thw = mm_processed_data.get("image_grid_thw") - mrope_grid_thw = mm_processed_data.get("mrope_image_grid_thw") - hf_config = self.info.get_hf_config() - image_token_id = getattr(hf_config, "image_token_id", 167855) - image_token_count = prompt_ids.count(image_token_id) - logger.debug( - "_apply_hf_processor_main i2i(HF): num_images=%s, prompt_len=%s, image_token_count=%s, " - "source_grid_shape=%s, mrope_grid_shape=%s", - num_images, - len(prompt_ids), - image_token_count, - tuple(image_grid_thw.shape) if image_grid_thw is not None else None, - tuple(mrope_grid_thw.shape) if mrope_grid_thw is not None else None, - ) - - # HF processor already expanded image placeholders in input_ids. - return prompt_ids, mm_processed_data, True + image_inputs.update(passthrough_data) + return image_inputs def _get_mm_fields_config( self, @@ -836,7 +501,7 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo Get the multimodal field configuration. For GLM-Image i2i mode: - - image_grid_thw has been sliced in _call_hf_processor to only include source images + - image_grid_thw contains only source grids from _apply_hf_processor_main - pixel_values has shape [total_patches, C, H, W] - only for source images For t2i mode: @@ -844,7 +509,7 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo """ result = {} - # Get image_grid_thw if present (already sliced in _call_hf_processor) + # Get the source-only image_grid_thw if present. image_grid_thw = hf_inputs.get("image_grid_thw") if "pixel_values" in hf_inputs and image_grid_thw is not None: @@ -861,45 +526,21 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo result["pixel_values"] = MultiModalFieldConfig.flat_from_sizes("image", image_grid_sizes) - # Register image_grid_thw - it's been sliced in _call_hf_processor + # Register image_grid_thw - it's been sliced in _apply_hf_processor_main # to only include source image grids, so batching will work correctly result["image_grid_thw"] = MultiModalFieldConfig.batched("image") + if "mrope_image_grid_thw" in hf_inputs: + result["mrope_image_grid_thw"] = MultiModalFieldConfig.shared( + "image", + num_source_images, + keep_on_cpu=True, + ) + logger.debug(f"_get_mm_fields_config: result keys: {list(result.keys())}") return result - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - """ - Return whether the HF processor applies prompt updates. - - For GLM-Image i2i mode, the HF processor's apply_chat_template already - expands <|image|> to N tokens (e.g., 4096 for 64x64 grid). - - By returning True, we tell vLLM that HF processor DID apply prompt updates, - so vLLM will use _find_mm_placeholders to locate the expanded tokens - instead of trying to apply replacements. - - For t2i mode (no images), there are no image placeholders to expand. - """ - # Check if we have images (i2i mode) - num_images = mm_items.get_all_counts().get("image", 0) - if num_images > 0: - logger.debug( - f"_hf_processor_applies_updates: returning True for i2i mode " - f"(num_images={num_images}) - HF processor already expanded tokens" - ) - return True - - # For t2i mode (no images), use default behavior - return True - def _get_prompt_updates( self, mm_items: MultiModalDataItems, @@ -909,17 +550,8 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo """ Get prompt updates for image tokens. - For GLM-Image image-to-image mode, the HF processor's apply_chat_template - already expands each <|image|> placeholder to the correct number of - image tokens (grid_h * grid_w tokens per source image). - - The HF processor does: - 1. Replace each <|image|> with num_image_tokens copies of <|placeholder|> - 2. Replace all <|placeholder|> back to <|image|> - - So the tokenized input already has the expanded tokens. We use - target=[image_token_id] to match each occurrence of the image token, - similar to how Qwen2VL handles this pattern. + Expand each source-image placeholder into grid_h * grid_w image tokens + through vLLM's token-based prompt updates. We use image_grid_thw from out_mm_kwargs to get the actual processed grid size, following the Qwen2VL pattern. This is critical because the HF processor @@ -930,8 +562,7 @@ class GlmImageMultiModalProcessor(BaseMultiModalProcessor[GlmImageProcessingInfo """ hf_config = self.info.get_hf_config() - # Get image token ID - this is the token that appears multiple times - # in the tokenized input after HF processor expansion + # Match the source-image placeholder in the tokenized input. image_token_id = getattr(hf_config, "image_token_id", 167855) # Debug: log mm_items info diff --git a/vllm_omni/model_executor/models/glm_tts/glm_tts.py b/vllm_omni/model_executor/models/glm_tts/glm_tts.py index fb3517036..e98c227a4 100644 --- a/vllm_omni/model_executor/models/glm_tts/glm_tts.py +++ b/vllm_omni/model_executor/models/glm_tts/glm_tts.py @@ -39,7 +39,6 @@ from vllm.multimodal.inputs import MultiModalFieldConfig, MultiModalKwargsItems from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, ProcessorInputs, PromptIndexTargets, @@ -51,6 +50,7 @@ from vllm.v1.outputs import SamplerOutput from vllm.v1.sample.metadata import SamplingMetadata from vllm.v1.sample.sampler import Sampler +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor from vllm_omni.model_executor.models.common.nucleus_ras_sampling import ras_sample_one as _ras_sample_one from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.platforms import current_omni_platform @@ -364,7 +364,7 @@ class GLMTTSMultiModalProcessingInfo(BaseProcessingInfo): ) -class GLMTTSMultiModalProcessor(BaseMultiModalProcessor[GLMTTSMultiModalProcessingInfo]): +class GLMTTSMultiModalProcessor(OmniMultiModalProcessor[GLMTTSMultiModalProcessingInfo]): """GLM-TTS voice-clone processor. Unlike CosyVoice3, GLM-TTS prompt speech tokens are normal Llama vocab IDs @@ -373,6 +373,53 @@ class GLMTTSMultiModalProcessor(BaseMultiModalProcessor[GLMTTSMultiModalProcessi handoff. """ + def apply(self, inputs: ProcessorInputs, timing_ctx): + source_tokenizer = self.info.get_tokenizer() + prompt_text = source_tokenizer.decode( + inputs.prompt, + skip_special_tokens=False, + ) + config = self.info.ctx.get_hf_config() + model_dir = self.info.ctx.model_config.model + self._ensure_cached_runtime_components( + model_dir, + config, + load_voice_clone=False, + ) + + normalized_text = _normalize_glm_tts_processor_text( + self.text_frontend, + prompt_text, + ) + text_ids = self._encode_text(normalized_text) + prompt_parts = [] + if inputs.mm_data_items.get_all_counts().get("audio", 0): + reference_text = inputs.hf_processor_mm_kwargs.get("prompt_text") + if not isinstance(reference_text, str) or not reference_text.strip(): + raise ValueError("GLM-TTS voice cloning requires mm_processor_kwargs['prompt_text'].") + normalized_reference = _normalize_glm_tts_processor_text( + self.text_frontend, + reference_text, + add_trailing_space=True, + ) + prompt_parts.append(self._encode_text(normalized_reference)) + prompt_parts.extend( + [ + text_ids, + torch.tensor([[int(self.special_ids["boa"])]], dtype=torch.long), + ] + ) + prompt_ids = torch.cat(prompt_parts, dim=1).reshape(-1).tolist() + inputs = replace( + inputs, + prompt=prompt_ids, + hf_processor_mm_kwargs={ + **inputs.hf_processor_mm_kwargs, + self._OMNI_PROMPT_TEXT_KEY: prompt_text, + }, + ) + return super().apply(inputs, timing_ctx) + def _ensure_cached_runtime_components( self, model_dir: str, @@ -599,15 +646,6 @@ class GLMTTSMultiModalProcessor(BaseMultiModalProcessor[GLMTTSMultiModalProcessi if key in hf_inputs } - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False - def _cached_apply_hf_processor(self, inputs: ProcessorInputs, timing_ctx: Any): # GLM-TTS builds the actual AR prompt from both the request text and # mm_processor_kwargs["prompt_text"]. The base cache path separates text diff --git a/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py index 8fbac95f7..738e469f1 100644 --- a/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py +++ b/vllm_omni/model_executor/models/higgs_audio_v3/higgs_audio_v3_talker.py @@ -1669,8 +1669,10 @@ class _BackboneWrapper(nn.Module): self.config = config def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - from vllm.model_executor.models.utils import AutoWeightsLoader + from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper skip = ["lm_head."] if getattr(self.config, "tie_word_embeddings", False) else None - loader = AutoWeightsLoader(self, skip_prefixes=skip) - return loader.load_weights(weights) + loader = AutoWeightsLoader(self) + return loader.load_weights( + weights, mapper=WeightsMapper(orig_to_new_prefix={name: None for name in (skip or ())}) + ) diff --git a/vllm_omni/model_executor/models/hunyuan_image3/_hunyuan_v1_vendored.py b/vllm_omni/model_executor/models/hunyuan_image3/_hunyuan_v1_vendored.py new file mode 100644 index 000000000..cd1546505 --- /dev/null +++ b/vllm_omni/model_executor/models/hunyuan_image3/_hunyuan_v1_vendored.py @@ -0,0 +1,625 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project +# +# Vendored from vllm/model_executor/models/hunyuan_v1.py at vllm@2cf0a6915c +# (the upstream base vllm-omni origin/main was rebased against). Upstream +# removed the native HunYuan V1 implementation in #53272 / #53615 (migrated +# to the Transformers modeling backend). vllm-omni's HunyuanImage3 model +# subclasses the removed HunYuanModel / HunYuanSparseMoeBlock / HunYuanMLP, +# so those internals are preserved here to keep the omni diffusion model +# working against current upstream. +# Weight loading, expert mapping, and QKV splitting are owned by Omni's +# HunyuanModel subclass; their unused upstream implementations are omitted. +"""Inference-only HunYuan V1 internals vendored for vllm-omni HunyuanImage3.""" + +from itertools import islice + +import torch +from torch import nn +from transformers import PretrainedConfig +from vllm.compilation.decorators import support_torch_compile +from vllm.config import CacheConfig, VllmConfig, get_current_vllm_config +from vllm.distributed import ( + get_ep_group, + get_pp_group, + get_tensor_model_parallel_world_size, +) +from vllm.model_executor.layers.activation import SiluAndMul +from vllm.model_executor.layers.attention import Attention +from vllm.model_executor.layers.fused_moe import ( + FusedMoEFactory, +) +from vllm.model_executor.layers.layernorm import RMSNorm +from vllm.model_executor.layers.linear import ( + ColumnParallelLinear, + MergedColumnParallelLinear, + QKVParallelLinear, + ReplicatedLinear, + RowParallelLinear, +) +from vllm.model_executor.layers.quantization import QuantizationConfig +from vllm.model_executor.layers.rotary_embedding import get_rope +from vllm.model_executor.layers.vocab_parallel_embedding import ( + VocabParallelEmbedding, +) +from vllm.model_executor.models.interfaces import EagleModelMixin +from vllm.model_executor.models.utils import ( + PPMissingLayer, + make_layers, +) +from vllm.sequence import IntermediateTensors +from vllm.v1.attention.backend import AttentionType + + +def _is_moe(config: PretrainedConfig) -> bool: + num_experts = getattr(config, "num_experts", None) + if isinstance(num_experts, int): + return num_experts > 1 + if isinstance(num_experts, list) and num_experts: + # Ensure all elements are integers before calling max. + if all(isinstance(e, int) for e in num_experts): + return max(num_experts) > 1 + else: + return False + return False + + +def _get_cla_factor(config: PretrainedConfig) -> int: + if not getattr(config, "use_cla", False): + return 1 + return getattr(config, "cla_share_factor", 1) + + +class HunYuanMLP(nn.Module): + def __init__( + self, + hidden_size: int, + intermediate_size: int, + hidden_act: str, + quant_config: QuantizationConfig | None = None, + bias: bool = False, + prefix: str = "", + reduce_results: bool = True, + ) -> None: + super().__init__() + self.gate_up_proj = MergedColumnParallelLinear( + input_size=hidden_size, + output_sizes=[intermediate_size] * 2, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.gate_up_proj", + ) + self.down_proj = RowParallelLinear( + input_size=intermediate_size, + output_size=hidden_size, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.down_proj", + reduce_results=reduce_results, + ) + if hidden_act != "silu": + raise ValueError(f"Unsupported activation: {hidden_act}. Only silu is supported for now.") + self.act_fn = SiluAndMul() + + def forward(self, x): + gate_up, _ = self.gate_up_proj(x) + x = self.act_fn(gate_up) + x, _ = self.down_proj(x) + return x + + +class HunYuanAttention(nn.Module): + def __init__( + self, + config: PretrainedConfig, + hidden_size: int, + num_heads: int, + num_kv_heads: int, + max_position_embeddings: int = 8192, + quant_config: QuantizationConfig | None = None, + bias: bool = False, + cache_config: CacheConfig | None = None, + prefix: str = "", + layer_id: int = -1, + ) -> None: + super().__init__() + self.hidden_size = hidden_size + tp_size = get_tensor_model_parallel_world_size() + self.total_num_heads = num_heads + assert self.total_num_heads % tp_size == 0 + self.num_heads = self.total_num_heads // tp_size + self.total_num_kv_heads = num_kv_heads + if self.total_num_kv_heads >= tp_size: + # Number of KV heads is greater than TP size, so we partition + # the KV heads across multiple tensor parallel GPUs. + assert self.total_num_kv_heads % tp_size == 0 + else: + # Number of KV heads is less than TP size, so we replicate + # the KV heads across multiple tensor parallel GPUs. + assert tp_size % self.total_num_kv_heads == 0 + self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size) + + if hasattr(config, "head_dim") and config.head_dim: + self.head_dim = config.head_dim + elif hasattr(config, "attention_head_dim"): + self.head_dim = config.attention_head_dim + else: + self.head_dim = self.hidden_size // self.total_num_heads + self.q_size = self.num_heads * self.head_dim + self.kv_size = self.num_kv_heads * self.head_dim + self.scaling = self.head_dim**-0.5 + self.max_position_embeddings = max_position_embeddings + self.use_qk_norm = getattr(config, "use_qk_norm", False) + self.layer_id = layer_id + + self.qkv_proj = QKVParallelLinear( + hidden_size=hidden_size, + head_size=self.head_dim, + total_num_heads=self.total_num_heads, + total_num_kv_heads=self.total_num_kv_heads, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.qkv_proj", + ) + + self.o_proj = RowParallelLinear( + input_size=self.total_num_heads * self.head_dim, + output_size=hidden_size, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.o_proj", + ) + + self.rotary_emb = get_rope( + self.head_dim, + max_position=max_position_embeddings, + rope_parameters=config.rope_parameters, + is_neox_style=True, + ) + self.attn = Attention( + self.num_heads, + self.head_dim, + self.scaling, + num_kv_heads=self.num_kv_heads, + cache_config=cache_config, + quant_config=quant_config, + prefix=f"{prefix}.attn", + ) + + if self.use_qk_norm: + self.query_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + self.key_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + + def forward( + self, + positions: torch.Tensor, + hidden_states: torch.Tensor, + kv_states: tuple[torch.Tensor] | None = None, + ) -> torch.Tensor: + qkv, _ = self.qkv_proj(hidden_states) + q, k, v = qkv.split([self.q_size, self.kv_size, self.kv_size], dim=-1) + q, k = self.rotary_emb(positions, q, k) + ori_k = k + if self.use_qk_norm: + q = self.query_layernorm( + q.view(-1, self.num_heads, self.head_dim), + ) + k = self.key_layernorm( + k.view(-1, self.num_kv_heads, self.head_dim), + ) + + attn_output = self.attn(q, k, v) + # For o_proj + attn_output = attn_output.view(q.shape[0], -1) + output, _ = self.o_proj(attn_output) + return output, (ori_k, v) + + +class HunYuanCrossAttention(nn.Module): + def __init__( + self, + config: PretrainedConfig, + hidden_size: int, + num_heads: int, + num_kv_heads: int, + max_position_embeddings: int = 8192, + quant_config: QuantizationConfig | None = None, + bias: bool = False, + cache_config: CacheConfig | None = None, + prefix: str = "", + layer_id: int = -1, + ) -> None: + super().__init__() + self.hidden_size = hidden_size + tp_size = get_tensor_model_parallel_world_size() + self.total_num_heads = num_heads + assert self.total_num_heads % tp_size == 0 + self.num_heads = self.total_num_heads // tp_size + self.total_num_kv_heads = num_kv_heads + if self.total_num_kv_heads >= tp_size: + # Number of KV heads is greater than TP size, so we partition + # the KV heads across multiple tensor parallel GPUs. + assert self.total_num_kv_heads % tp_size == 0 + else: + # Number of KV heads is less than TP size, so we replicate + # the KV heads across multiple tensor parallel GPUs. + assert tp_size % self.total_num_kv_heads == 0 + self.num_kv_heads = max(1, self.total_num_kv_heads // tp_size) + # MistralConfig has an optional head_dim introduced by Mistral-Nemo + if hasattr(config, "head_dim"): + self.head_dim = config.head_dim + elif hasattr(config, "attention_head_dim"): + self.head_dim = config.attention_head_dim + else: + self.head_dim = self.hidden_size // self.total_num_heads + self.q_size = self.num_heads * self.head_dim + self.kv_size = self.num_kv_heads * self.head_dim + self.scaling = self.head_dim**-0.5 + self.max_position_embeddings = max_position_embeddings + self.use_qk_norm = getattr(config, "use_qk_norm", False) + self.layer_id = layer_id + + self.q_proj = ColumnParallelLinear( + hidden_size, + hidden_size, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.q_proj", + ) + + self.o_proj = RowParallelLinear( + input_size=self.total_num_heads * self.head_dim, + output_size=hidden_size, + bias=bias, + quant_config=quant_config, + prefix=f"{prefix}.o_proj", + ) + + self.rotary_emb = get_rope( + self.head_dim, + max_position=max_position_embeddings, + rope_parameters=config.rope_parameters, + is_neox_style=True, + ) + self.attn = Attention( + self.num_heads, + self.head_dim, + self.scaling, + num_kv_heads=self.num_kv_heads, + cache_config=cache_config, + quant_config=quant_config, + prefix=f"{prefix}.attn", + attn_type=AttentionType.ENCODER_DECODER, + ) + + if self.use_qk_norm: + self.query_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + self.key_layernorm = RMSNorm(self.head_dim, eps=config.rms_norm_eps) + + def forward( + self, + positions: torch.Tensor, + hidden_states: torch.Tensor, + kv_states: tuple[torch.Tensor] | None = None, + ) -> torch.Tensor: + assert kv_states is not None + ori_k, v = kv_states # use last layer kv, + k = ori_k + q, _ = self.q_proj(hidden_states) + k_tmp = torch.empty_like(k) # Todo: reduant rotary embedding + q, _ = self.rotary_emb(positions, q, k_tmp) + if self.use_qk_norm: + q = self.query_layernorm( + q.view(-1, self.num_heads, self.head_dim), + ) + k = self.key_layernorm( + k.view(-1, self.num_kv_heads, self.head_dim), + ) + + attn_output = self.attn(q, k, v) + # For o_proj + attn_output = attn_output.view(q.shape[0], -1) + output, _ = self.o_proj(attn_output) + return output, (ori_k, v) + + +class HunYuanSparseMoeBlock(nn.Module): + def __init__( + self, + config: PretrainedConfig, + quant_config: QuantizationConfig | None = None, + layer_id: int = -1, + prefix: str = "", + enable_eplb: bool = False, + ): + super().__init__() + self.tp_size = get_tensor_model_parallel_world_size() + + self.ep_group = get_ep_group().device_group + self.ep_size = self.ep_group.size() + self.n_routed_experts = config.num_experts + + if self.tp_size > config.num_experts: + raise ValueError( + f"Tensor parallel size {self.tp_size} is greater than the number of experts {config.num_experts}." + ) + + # Get layer_id topk if config.moe_topk is a list + if isinstance(config.moe_topk, list): + assert layer_id >= 0 + assert len(config.moe_topk) > layer_id + top_k = config.moe_topk[layer_id] + else: + top_k = config.moe_topk + + # If it is moe, moe_intermediate_size is preferred + intermediate_size = config.intermediate_size + if config.moe_intermediate_size is not None: + intermediate_size = ( + config.moe_intermediate_size + if isinstance(config.moe_intermediate_size, int) + else config.moe_intermediate_size[layer_id] + ) + + # Load balancing settings. + vllm_config = get_current_vllm_config() + eplb_config = vllm_config.parallel_config.eplb_config + self.enable_eplb = enable_eplb + + self.n_logical_experts = self.n_routed_experts + self.n_redundant_experts = eplb_config.num_redundant_experts + self.n_physical_experts = self.n_logical_experts + self.n_redundant_experts + self.n_local_physical_experts = self.n_physical_experts // self.ep_size + self.gate = ReplicatedLinear( + config.hidden_size, + config.num_experts, + bias=False, + quant_config=None, + prefix=f"{prefix}.gate", + ) + if config.use_mixed_mlp_moe > 0: + # Get layer_id num_shared_expert if config.num_shared_expert is + # a list. + if isinstance(config.num_shared_expert, list): + assert layer_id >= 0 + assert len(config.num_shared_expert) > layer_id + num_shared_expert = config.num_shared_expert[layer_id] + else: + num_shared_expert = config.num_shared_expert + + self.shared_mlp = HunYuanMLP( + hidden_size=config.hidden_size, + intermediate_size=config.intermediate_size * num_shared_expert, + hidden_act=config.hidden_act, + quant_config=quant_config, + reduce_results=False, + prefix=f"{prefix}.shared_mlp", + ) + else: + self.shared_mlp = None + + self.experts = FusedMoEFactory( + shared_experts=self.shared_mlp, + num_experts=self.n_routed_experts, + top_k=top_k, + hidden_size=config.hidden_size, + intermediate_size=intermediate_size, + renormalize=top_k > 1, + quant_config=quant_config, + prefix=f"{prefix}.experts", + enable_eplb=self.enable_eplb, + num_redundant_experts=self.n_redundant_experts, + ) + + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor: + # NOTE: hidden_states can have either 1D or 2D shape. + orig_shape = hidden_states.shape + hidden_dim = hidden_states.shape[-1] + hidden_states = hidden_states.view(-1, hidden_dim) + + # router_logits: (num_tokens, n_experts) + router_logits, _ = self.gate(hidden_states) + final_hidden_states = self.experts(hidden_states=hidden_states, router_logits=router_logits) + + return final_hidden_states.view(orig_shape) + + +class HunYuanDecoderLayer(nn.Module): + def __init__( + self, + config: PretrainedConfig, + cache_config: CacheConfig | None = None, + quant_config: QuantizationConfig | None = None, + prefix: str = "", + layer_id: int = -1, + enable_eplb: bool = False, + ) -> None: + super().__init__() + assert layer_id >= 0 + self.layer_id = layer_id + self.hidden_size = config.hidden_size + self.intermediate_size = ( + config.intermediate_size + if isinstance(config.intermediate_size, int) + else config.intermediate_size[layer_id] + ) + max_position_embeddings = getattr(config, "max_position_embeddings", 8192) + attention_bias = getattr(config, "attention_bias", False) or getattr(config, "bias", False) + cla_factor = _get_cla_factor(config) + attention_type = ( + AttentionType.ENCODER_DECODER if layer_id >= 0 and layer_id % cla_factor != 0 else AttentionType.DECODER + ) + if attention_type == AttentionType.DECODER: + self.self_attn = HunYuanAttention( + config=config, + hidden_size=self.hidden_size, + num_heads=config.num_attention_heads, + num_kv_heads=getattr(config, "num_key_value_heads", config.num_attention_heads), + max_position_embeddings=max_position_embeddings, + quant_config=quant_config, + bias=attention_bias, + cache_config=cache_config, + prefix=f"{prefix}.self_attn", + layer_id=layer_id, + ) + elif attention_type == AttentionType.ENCODER_DECODER: + self.self_attn = HunYuanCrossAttention( + config=config, + hidden_size=self.hidden_size, + num_heads=config.num_attention_heads, + num_kv_heads=getattr(config, "num_key_value_heads", config.num_attention_heads), + max_position_embeddings=max_position_embeddings, + quant_config=quant_config, + bias=attention_bias, + cache_config=cache_config, + prefix=f"{prefix}.self_attn", + layer_id=layer_id, + ) + else: + raise RuntimeError(f"Unsupported attention type: {attention_type}") + + if _is_moe(config): + self.mlp = HunYuanSparseMoeBlock( + config=config, + quant_config=quant_config, + layer_id=layer_id, + prefix=f"{prefix}.mlp", + enable_eplb=enable_eplb, + ) + else: + self.mlp = HunYuanMLP( + hidden_size=self.hidden_size, + intermediate_size=self.intermediate_size, + hidden_act=config.hidden_act, + quant_config=quant_config, + bias=getattr(config, "mlp_bias", False), + prefix=f"{prefix}.mlp", + ) + + self.input_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + + def forward( + self, + positions: torch.Tensor, + hidden_states: torch.Tensor, + residual: torch.Tensor | None, + kv_states: tuple[torch.Tensor] | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + # Self Attention + if residual is None: + residual = hidden_states + hidden_states = self.input_layernorm(hidden_states) + else: + hidden_states, residual = self.input_layernorm(hidden_states, residual) + hidden_states, ori_kv_states = self.self_attn( + positions=positions, + hidden_states=hidden_states, + kv_states=kv_states, + ) + + # Fully Connected + hidden_states, residual = self.post_attention_layernorm(hidden_states, residual) + hidden_states = self.mlp(hidden_states) + return hidden_states, residual, ori_kv_states + + +@support_torch_compile( + dynamic_arg_dims={ + "input_ids": 0, + # positions is of shape (xd, seq_len) if xdrope is enabled for hunyuan-vl, + # otherwise (seq_len, ). + "positions": -1, + "intermediate_tensors": 0, + "inputs_embeds": 0, + } +) +class HunYuanModel(nn.Module, EagleModelMixin): + def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""): + super().__init__() + + config = vllm_config.model_config.hf_config + cache_config = vllm_config.cache_config + quant_config = vllm_config.quant_config + + eplb_config = vllm_config.parallel_config.eplb_config + enable_eplb = vllm_config.parallel_config.enable_eplb + self.num_redundant_experts = eplb_config.num_redundant_experts + + self.config = config + self.quant_config = quant_config + + self.vocab_size = config.vocab_size + + if get_pp_group().is_first_rank or (config.tie_word_embeddings and get_pp_group().is_last_rank): + self.embed_tokens = VocabParallelEmbedding( + self.vocab_size, + config.hidden_size, + quant_config=quant_config, + ) + else: + self.embed_tokens = PPMissingLayer() + self.start_layer, self.end_layer, self.layers = make_layers( + config.num_hidden_layers, + lambda prefix: HunYuanDecoderLayer( + config=config, + layer_id=int(prefix.split(".")[-1]), + cache_config=cache_config, + quant_config=quant_config, + prefix=prefix, + enable_eplb=enable_eplb, + ), + prefix=f"{prefix}.layers", + ) + if get_pp_group().is_last_rank: + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps) + else: + self.norm = PPMissingLayer() + + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor: + return self.embed_tokens(input_ids) + + def forward( + self, + input_ids: torch.Tensor | None, + positions: torch.Tensor, + intermediate_tensors: IntermediateTensors | None, + inputs_embeds: torch.Tensor | None = None, + ) -> torch.Tensor | IntermediateTensors: + if get_pp_group().is_first_rank: + if inputs_embeds is not None: + hidden_states = inputs_embeds + else: + hidden_states = self.embed_input_ids(input_ids) + residual = None + else: + assert intermediate_tensors is not None + hidden_states = intermediate_tensors["hidden_states"] + residual = intermediate_tensors["residual"] + + cla_factor = _get_cla_factor(self.config) + prev_kv_states = None + aux_hidden_states = self._maybe_add_hidden_state([], 0, hidden_states, residual) + for i, layer in enumerate(islice(self.layers, self.start_layer, self.end_layer)): + hidden_states, residual, kv_states = layer( + positions, + hidden_states, + residual, + prev_kv_states, + ) + + if getattr(self.config, "use_cla", False) and i % cla_factor == 0: + prev_kv_states = kv_states + else: + prev_kv_states = None + + self._maybe_add_hidden_state(aux_hidden_states, i + 1, hidden_states, residual) + + if not get_pp_group().is_last_rank: + return IntermediateTensors({"hidden_states": hidden_states, "residual": residual}) + + hidden_states, _ = self.norm(hidden_states, residual) + + if len(aux_hidden_states) > 0: + return hidden_states, aux_hidden_states + return hidden_states diff --git a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py index 58b67d8aa..b15a19982 100755 --- a/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py +++ b/vllm_omni/model_executor/models/hunyuan_image3/hunyuan_image3.py @@ -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 gc import math import typing @@ -45,13 +45,6 @@ from vllm.model_executor.model_loader.weight_utils import ( default_weight_loader, maybe_remap_kv_scale_name, ) -from vllm.model_executor.models.hunyuan_v1 import ( - HunYuanMLP, - HunYuanModel, - HunYuanSparseMoeBlock, - _get_cla_factor, - _is_moe, -) from vllm.model_executor.models.interfaces import ( MultiModalEmbeddings, SupportsLoRA, @@ -63,6 +56,7 @@ from vllm.model_executor.models.interfaces import ( from vllm.model_executor.models.utils import ( AutoWeightsLoader, PPMissingLayer, + WeightsMapper, _merge_multimodal_embeddings, is_pp_missing_parameter, maybe_prefix, @@ -78,7 +72,6 @@ from vllm.multimodal.parse import ( ) from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, PromptReplacement, PromptUpdate, @@ -91,6 +84,14 @@ from vllm.v1.outputs import SamplerOutput from vllm.v1.sample.metadata import SamplingMetadata from vllm.v1.sample.sampler import Sampler +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor +from vllm_omni.model_executor.models.hunyuan_image3._hunyuan_v1_vendored import ( + HunYuanMLP, + HunYuanModel, + HunYuanSparseMoeBlock, + _get_cla_factor, + _is_moe, +) from vllm_omni.model_executor.models.hunyuan_image3.autoencoder_kl_3d import AutoencoderKLConv3D from vllm_omni.model_executor.models.hunyuan_image3.siglip2 import LightProjector, Siglip2VisionTransformer @@ -1027,7 +1028,7 @@ class HunyuanImage3DummyInputsBuilder(BaseDummyInputsBuilder[HunyuanImage3Proces } -class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3ProcessingInfo]): +class HunyuanImage3MultiModalProcessor(OmniMultiModalProcessor[HunyuanImage3ProcessingInfo]): """Multimodal processor for HunyuanImage3 model.""" def _call_hf_processor( @@ -1046,15 +1047,6 @@ class HunyuanImage3MultiModalProcessor(BaseMultiModalProcessor[HunyuanImage3Proc batch_feature["vae_generator_seed"] = torch.full((len(images),), int(vae_generator_seed), dtype=torch.long) return batch_feature - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False - def _get_mm_fields_config( self, hf_inputs: BatchFeature, @@ -2180,12 +2172,11 @@ class HunyuanImage3ForConditionalGeneration(nn.Module, SupportsMultiModal, Suppo "timestep_r_emb", ] skip_prefixes.extend(unexpected_keywords) - loader = AutoWeightsLoader( - self, - skip_prefixes=skip_prefixes, - ) + loader = AutoWeightsLoader(self) - loaded_params = loader.load_weights(weights) + loaded_params = loader.load_weights( + weights, mapper=WeightsMapper(orig_to_new_prefix={name: None for name in (skip_prefixes or ())}) + ) return loaded_params def get_language_model(self) -> torch.nn.Module: diff --git a/vllm_omni/model_executor/models/mimo_audio/mimo_audio.py b/vllm_omni/model_executor/models/mimo_audio/mimo_audio.py index 4309d6177..4dbc9a455 100644 --- a/vllm_omni/model_executor/models/mimo_audio/mimo_audio.py +++ b/vllm_omni/model_executor/models/mimo_audio/mimo_audio.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + # Copyright 2025 Xiaomi Corporation. import os from collections.abc import Iterable, Mapping, Sequence @@ -27,14 +30,13 @@ from vllm.multimodal.inputs import ( from vllm.multimodal.parse import AudioProcessorItems, MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, ProcessorInputs, PromptReplacement, PromptUpdate, PromptUpdateDetails, ) -from vllm.multimodal.utils import group_mm_kwargs_by_modality +from vllm.multimodal.utils import group_and_batch_mm_kwargs from vllm.sequence import IntermediateTensors from vllm.utils.cache import LRUCache from vllm.utils.collection_utils import is_list_of @@ -42,6 +44,7 @@ from vllm.utils.platform_utils import is_pin_memory_available from vllm.v1.sample.metadata import SamplingMetadata from vllm.v1.sample.sampler import Sampler +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin from vllm_omni.model_executor.models.mimo_audio.config_mimo_audio import ( NO_INTERLEAVE_NEXT_TOKEN_ID, @@ -348,7 +351,7 @@ class MiMoAudioDataParser(MultiModalDataParser): return AudioProcessorItems(new_audios) -class MiMoAudioLLMMultiModalProcessor(BaseMultiModalProcessor[MiMoAudioLLMProcessingInfo]): +class MiMoAudioLLMMultiModalProcessor(OmniMultiModalProcessor[MiMoAudioLLMProcessingInfo]): def _call_hf_processor( self, prompt: str, @@ -484,7 +487,7 @@ class MiMoAudioLLMMultiModalProcessor(BaseMultiModalProcessor[MiMoAudioLLMProces return [ PromptReplacement( modality="audio", - target=audio_token, + target=[audio_token_id], replacement=get_replacement_mimo_audio, ) ] @@ -626,7 +629,7 @@ class MiMoAudioForConditionalGeneration( ) mm_kwargs.append((mm_feature.modality, mm_item)) - for modality, num_items, mm_kwargs_group in group_mm_kwargs_by_modality( + for modality, num_items, mm_kwargs_group in group_and_batch_mm_kwargs( mm_kwargs, device=self.device, pin_memory=self.pin_memory, diff --git a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py index c90adb467..bb01e522c 100644 --- a/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py +++ b/vllm_omni/model_executor/models/mimo_audio/mimo_audio_llm.py @@ -390,35 +390,25 @@ class MimoAudioDummyInputsBuilder(BaseDummyInputsBuilder[MimoAudioProcessingInfo class MimoAudioMultiModalProcessor(BaseMultiModalProcessor[MimoAudioProcessingInfo]): - def _call_hf_processor( + def _preprocess_hf_mm_data( self, - prompt: str, mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, Any], - tok_kwargs: Mapping[str, object], - ) -> BatchFeature: + hf_processor_mm_kwargs: Mapping[str, object], + ) -> tuple[Mapping[str, object], Mapping[str, object]]: + mm_data, hf_processor_mm_kwargs = super()._preprocess_hf_mm_data( + mm_data, + hf_processor_mm_kwargs, + ) + mm_data = dict(mm_data) audios = mm_data.pop("audios", []) if audios: mm_data["audio"] = audios - - # Text-only input not supported in composite processor - if not mm_data.get("audio", []): - prompt_ids = self.info.get_tokenizer().encode(prompt) - prompt_ids = self._apply_hf_processor_tokens_only(prompt_ids) - return BatchFeature(dict(input_ids=[prompt_ids]), tensor_type="pt") - - feature_extractor = self.info.get_feature_extractor(**mm_kwargs) - mm_kwargs = dict( - **mm_kwargs, - sampling_rate=feature_extractor.sampling_rate, - ) - - return super()._call_hf_processor( - prompt=prompt, - mm_data=mm_data, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, - ) + feature_extractor = self.info.get_feature_extractor(**hf_processor_mm_kwargs) + hf_processor_mm_kwargs = { + **hf_processor_mm_kwargs, + "sampling_rate": feature_extractor.sampling_rate, + } + return mm_data, hf_processor_mm_kwargs def _get_mm_fields_config( self, @@ -480,7 +470,7 @@ class MimoAudioMultiModalProcessor(BaseMultiModalProcessor[MimoAudioProcessingIn return [ PromptReplacement( modality="audio", - target=audio_token, + target=[audio_token_id], replacement=get_replacement_mimo_audio, ) ] diff --git a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_talker.py b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_talker.py index d7e31beb3..d34cce465 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_talker.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_talker.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project # Copyright 2025 The vLLM-Omni team. # Copyright (c) Ant Group. All rights reserved. # Adapted from: @@ -21,7 +22,7 @@ from transformers import AutoTokenizer, Qwen2Config, Qwen2Model from transformers.utils.hub import cached_file from vllm.config import VllmConfig from vllm.logger import init_logger -from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper from vllm.sequence import IntermediateTensors from vllm_omni.model_executor.custom_process_mixin import CustomProcessMixin @@ -509,12 +510,13 @@ class MingFlashOmniTalkerForConditionalGeneration(nn.Module, CustomProcessMixin) if self._standalone: weights = self._iter_talker_safetensors() - loader = AutoWeightsLoader( - self, - skip_prefixes=["audio_vae."], # loaded separately - skip_substrs=["rotary_embed.inv_freq"], # non-persistent buffer + loader = AutoWeightsLoader(self) + loaded = loader.load_weights( + weights, + mapper=WeightsMapper( + orig_to_new_prefix={"audio_vae.": None}, orig_to_new_substr={"rotary_embed.inv_freq": None} + ), ) - loaded = loader.load_weights(weights) logger.info("Loaded %d talker weights from checkpoint", len(loaded)) if self.audio_vae is not None and self._vae_weight_source is not None: diff --git a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py index 03cf87abb..1338f2606 100644 --- a/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py +++ b/vllm_omni/model_executor/models/ming_flash_omni/ming_flash_omni_thinker.py @@ -1,4 +1,5 @@ # SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project # Copyright 2025 The vLLM-Omni team. # Copyright 2024 ANT Group and the HuggingFace Inc. team. # Adapted from Ming repository modeling_bailingmm2.py and processing_bailingmm2.py @@ -8,6 +9,7 @@ import os from collections.abc import Iterable, Iterator, Mapping, Sequence +from dataclasses import replace from typing import Annotated, Any import numpy as np @@ -388,7 +390,10 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO updates.append( PromptReplacement( modality="image", - target=PLACEHOLDER_IMAGE_TOKEN_IN_TEXT, + target=tokenizer.encode( + PLACEHOLDER_IMAGE_TOKEN_IN_TEXT, + add_special_tokens=False, + ), replacement=get_replacement_image, ) ) @@ -396,7 +401,10 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO updates.append( PromptReplacement( modality="video", - target=PLACEHOLDER_VIDEO_TOKEN_IN_TEXT, + target=tokenizer.encode( + PLACEHOLDER_VIDEO_TOKEN_IN_TEXT, + add_special_tokens=False, + ), replacement=get_replacement_video, ) ) @@ -404,7 +412,10 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO updates.append( PromptReplacement( modality="audio", - target=PLACEHOLDER_AUDIO_TOKEN_IN_TEXT, + target=tokenizer.encode( + PLACEHOLDER_AUDIO_TOKEN_IN_TEXT, + add_special_tokens=False, + ), replacement=get_replacement_audio, ) ) @@ -451,90 +462,71 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO return config - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False + def apply(self, inputs, timing_ctx): + """Add Ming image-generation query tokens before prompt updates.""" + modalities = inputs.hf_processor_mm_kwargs.get("modalities") or [] + if "image" in modalities or "img2img" in modalities: + from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import ( + DEFAULT_NUM_QUERY_TOKENS, + IMAGE_PATCH_TOKEN, + ) + + tokenizer = self.info.get_tokenizer() + prompt_ids = list(inputs.prompt) + if "img2img" in modalities: + prompt_ids = ( + tokenizer.encode( + PLACEHOLDER_IMAGE_TOKEN_IN_TEXT, + add_special_tokens=False, + ) + + prompt_ids + ) + + image_patch_ids = tokenizer.encode( + IMAGE_PATCH_TOKEN, + add_special_tokens=False, + ) + if not image_patch_ids or not any( + prompt_ids[index : index + len(image_patch_ids)] == image_patch_ids + for index in range(len(prompt_ids) - len(image_patch_ids) + 1) + ): + image_gen_config = getattr( + self.info.ctx.model_config.hf_config, + "image_gen_config", + None, + ) + num_query_tokens = int( + getattr( + image_gen_config, + "num_query_tokens", + DEFAULT_NUM_QUERY_TOKENS, + ) + ) + suffix = ( + tokenizer.encode("", add_special_tokens=False) + + image_patch_ids * num_query_tokens + + tokenizer.encode("", add_special_tokens=False) + ) + prompt_ids.extend(suffix) + + inputs = replace(inputs, prompt=prompt_ids) + + return super().apply(inputs, timing_ctx) def _apply_hf_processor_main( self, - prompt: str | list[int], mm_items: MultiModalDataItems, hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - *, - enable_hf_prompt_update: bool, - ): - """Apply Ming-specific prompt rewrites before delegating to vllm's - default text / multimodal processing pipeline. - - This is the single point in the base MM-processor call graph where we - still have BOTH the full prompt and the request-level - ``hf_processor_mm_kwargs`` together. Downstream, vllm's MM caching - design splits the work into - :meth:`_apply_hf_processor_text_only` (prompt, empty kwargs) and - :meth:`_apply_hf_processor_mm_only` (dummy text, real kwargs), so a - per-request flag like ``modalities`` cannot reach the real - tokenization from inside :meth:`_call_hf_processor`. - - For Ming-flash-omni-2.0, image-generation requests carry - ``mm_processor_kwargs["modalities"]`` set to ``["image"]`` (t2i) or - ``["img2img"]`` (image edit). We expand the prompt here with the - ``*N`` query-token block so the expanded - form flows through both the tokenization and (eventual) MM paths. - For img2img, we also prepend ```` so the thinker-side prompt - replacement can locate the reference-image placeholder. - """ - modalities = hf_processor_mm_kwargs.get("modalities") or [] - is_image_gen = "image" in modalities or "img2img" in modalities - if isinstance(prompt, str) and is_image_gen: - from vllm_omni.model_executor.models.ming_flash_omni.prompt_utils import ( - DEFAULT_NUM_QUERY_TOKENS, - maybe_expand_image_gen_prompt, - ) - - # img2img: prepend ```` so the ref-image placeholder survives - # MM caching (when the ref image is cache-warm, the thinker sees 0 - # missing items and cannot inject the placeholder itself). - if "img2img" in modalities: - prompt = "" + prompt - - ig = getattr(self.info.ctx.model_config.hf_config, "image_gen_config", None) - num_query_tokens = getattr(ig, "num_query_tokens", DEFAULT_NUM_QUERY_TOKENS) - prompt = maybe_expand_image_gen_prompt(prompt, num_query_tokens=int(num_query_tokens)) - - return super()._apply_hf_processor_main( - prompt=prompt, - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - enable_hf_prompt_update=enable_hf_prompt_update, - ) - - def _call_hf_processor( - self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], ) -> BatchFeature: - """Call sub-processors for multimodal inputs and tokenize. + """Call Ming's modality processors without expanding prompt text. We call the image/audio sub-processors directly (instead of going through `MingFlashOmniProcessor.__call__`) so that the high-level - placeholder tokens remain **unexpanded** in the tokenized output. - - NOTE: Image-gen prompt expansion is handled in - :meth:`_apply_hf_processor_main` — by the time we get here, vllm's - MM caching has already split text/mm processing and ``mm_kwargs`` - is empty on the text-only branch. + placeholder tokens remain under vLLM's prompt-update control. """ + valid_mm_items = mm_items.select({key for key, count in mm_items.get_all_counts().items() if count > 0}) + mm_data, passthrough_data = self._get_hf_mm_data(valid_mm_items) hf_processor = self.info.get_hf_processor() - tokenizer = self.info.get_tokenizer() data: dict[str, object] = {} @@ -577,11 +569,9 @@ class MingFlashOmniThinkerMultiModalProcessor(BaseMultiModalProcessor[MingFlashO ) data.update(audio_outputs) - # Tokenize text with placeholders still intact - text_outputs = tokenizer(prompt, return_tensors="pt", **tok_kwargs) - data.update(text_outputs) - - return BatchFeature(data=data) + processed_data = BatchFeature(data=data) + processed_data.update(passthrough_data) + return processed_data @MULTIMODAL_REGISTRY.register_processor( diff --git a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py index 9dfff5589..315ae159d 100644 --- a/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py +++ b/vllm_omni/model_executor/models/minicpmo_4_5/minicpmo_4_5_omni_llm.py @@ -21,9 +21,8 @@ import inspect import math import os import warnings -from collections import defaultdict from collections.abc import Callable, Iterable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from functools import partial from typing import Annotated, Any, Literal, TypeAlias @@ -3403,84 +3402,46 @@ class MiniCPMOMultiModalDataParser(MultiModalDataParser): class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45OmniLLMProcessingInfo]): """Multimodal processor for MiniCPM-o thinker stage.""" - def _apply_hf_processor_main( - self, - prompt: str | list[int], - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - *, - enable_hf_prompt_update: bool, - ) -> tuple[list[int], BatchFeature, bool]: - """ - MiniCPM-O reimplements this to avoid calling HF processor with text-only - when enable_hf_prompt_update=False. The MiniCPM processor asserts - len(image_tags) == len(image_sizes) and fails if given placeholder text - without corresponding image data (e.g. during profiling/cache-miss path). - """ - use_tts = hf_processor_mm_kwargs.get("use_tts", False) - hf_processor_mm_kwargs = {k: v for k, v in hf_processor_mm_kwargs.items() if k != "use_tts"} - if isinstance(prompt, str): - if use_tts: - prompt = prompt + self._TTS_SUFFIX - if enable_hf_prompt_update: - return self._apply_hf_processor_text_mm( - prompt_text=prompt, - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - tokenizer = self.info.get_tokenizer() - prompt_ids = _encode_tokens(tokenizer, prompt) - else: - prompt_ids = self._apply_hf_processor_tokens_only(prompt) - if use_tts: - tokenizer = self.info.get_tokenizer() - tts_ids = tokenizer.convert_tokens_to_ids(["<|spk_bos|>", "<|spk|>", "<|spk_eos|>", "<|tts_bos|>"]) - prompt_ids = list(prompt_ids) + tts_ids - - mm_processed_data = self._apply_hf_processor_mm_only( - mm_items=mm_items, - hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=tokenization_kwargs, - ) - - return prompt_ids, mm_processed_data, False - _TTS_SUFFIX = "<|spk_bos|><|spk|><|spk_eos|><|tts_bos|>" - def _call_hf_processor( + def apply(self, inputs, timing_ctx): + hf_processor_mm_kwargs = inputs.hf_processor_mm_kwargs + if hf_processor_mm_kwargs.get("use_tts", False): + tokenizer = self.info.get_tokenizer() + tts_ids = tokenizer.convert_tokens_to_ids(["<|spk_bos|>", "<|spk|>", "<|spk_eos|>", "<|tts_bos|>"]) + inputs = replace( + inputs, + prompt=[*inputs.prompt, *tts_ids], + hf_processor_mm_kwargs={ + key: value for key, value in hf_processor_mm_kwargs.items() if key != "use_tts" + }, + ) + return super().apply(inputs, timing_ctx) + + def _apply_hf_processor_main( self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], ) -> BatchFeature: + """ + Process each modality independently because the MiniCPM processor + asserts that image tags and image sizes have matching lengths. + """ + valid_mm_items = mm_items.select({key for key, count in mm_items.get_all_counts().items() if count > 0}) + mm_data, passthrough_data = self._get_hf_mm_data(valid_mm_items) + tokenizer = self.info.get_tokenizer() - - use_tts = mm_kwargs.get("use_tts", False) - mm_kwargs = {k: v for k, v in mm_kwargs.items() if k != "use_tts"} - if use_tts: - prompt = prompt + self._TTS_SUFFIX - - input_ids = torch.tensor([tokenizer.encode(prompt, **tok_kwargs)]) - mm_inputs = self.process_mm_inputs(mm_data, mm_kwargs, tok_kwargs) - - return BatchFeature( + prompt_text = self.dummy_inputs.get_dummy_text(mm_items.get_all_counts()) + input_ids = torch.tensor([tokenizer.encode(prompt_text)]) + mm_inputs = self.process_mm_inputs(mm_data, hf_processor_mm_kwargs) + processed_data = BatchFeature( { "input_ids": input_ids, **mm_inputs, } ) - - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False + processed_data.update(passthrough_data) + return processed_data def get_image_prompt_texts( self, @@ -3528,7 +3489,6 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn self, mm_data: Mapping[str, object], mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], ) -> Mapping[str, NestedTensors]: if (audios := mm_data.get("audios")) is None: return {} @@ -3540,11 +3500,10 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn if isinstance(parsed_audios, MiniCPMOAudioEmbeddingItems): audio_inputs = {} else: - audio_inputs = self._base_call_hf_processor( + audio_inputs = self._call_hf_processor_on_prompts( prompts=[self.info.audio_pattern] * len(parsed_audios), mm_data={"audios": [[audio] for audio in parsed_audios]}, mm_kwargs={**mm_kwargs, "chunk_input": True}, - tok_kwargs=tok_kwargs, out_keys={"audio_features", "audio_feature_lens"}, ) @@ -3569,7 +3528,6 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn self, mm_data: Mapping[str, object], mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], ) -> Mapping[str, NestedTensors]: if (images := mm_data.get("images")) is None: return {} @@ -3581,11 +3539,10 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn if isinstance(parsed_images, MiniCPMVImageEmbeddingItems): image_inputs = {} else: - image_inputs = self._base_call_hf_processor( + image_inputs = self._call_hf_processor_on_prompts( prompts=[self.info.image_pattern] * len(parsed_images), mm_data={"images": [[image] for image in parsed_images]}, mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, out_keys={"pixel_values", "image_sizes", "tgt_sizes"}, ) return image_inputs @@ -3594,7 +3551,6 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn self, mm_data: Mapping[str, object], mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], ) -> Mapping[str, NestedTensors]: if (videos := mm_data.get("videos")) is None: return {} @@ -3606,14 +3562,13 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn if isinstance(parsed_videos, MiniCPMVVideoEmbeddingItems): video_inputs = {} else: - video_inputs = self._base_call_hf_processor( + video_inputs = self._call_hf_processor_on_prompts( prompts=[self.info.image_pattern * len(video) for video in parsed_videos], mm_data={"images": list(parsed_videos)}, mm_kwargs={ **mm_kwargs, "max_slice_nums": self.info.get_video_max_slice_num(), }, - tok_kwargs=tok_kwargs, out_keys={"pixel_values", "image_sizes", "tgt_sizes"}, ) @@ -3625,48 +3580,30 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn self, mm_data: Mapping[str, object], mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], ) -> Mapping[str, NestedTensors]: return { - **self.process_images(mm_data, mm_kwargs, tok_kwargs), - **self.process_videos(mm_data, mm_kwargs, tok_kwargs), - **self.process_audios(mm_data, mm_kwargs, tok_kwargs), + **self.process_images(mm_data, mm_kwargs), + **self.process_videos(mm_data, mm_kwargs), + **self.process_audios(mm_data, mm_kwargs), } - def _base_call_hf_processor( + def _call_hf_processor_on_prompts( self, prompts: list[str], mm_data: Mapping[str, Sequence[object]], mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], *, out_keys: set[str], ) -> dict[str, NestedTensors]: - mm_kwargs = {k: v for k, v in mm_kwargs.items() if k != "use_tts"} - # This processor supports zipping prompt and mm_data together - if self.info.get_model_version() in {(2, 6), (4, 0), (4, 5)}: - inputs = super()._call_hf_processor( - prompt=prompts, # type: ignore - mm_data=mm_data, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, - ) - else: - inputs = defaultdict[str, list[torch.Tensor]](list) + from vllm.model_executor.models.minicpmv import MiniCPMVMultiModalProcessor - for i, prompt in enumerate(prompts): - inputs_one = super()._call_hf_processor( - prompt=prompt, - mm_data={k: v[i] for k, v in mm_data.items()}, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, - ) - - for k, v in inputs_one.items(): - assert len(v) == 1, (k, len(v)) - inputs[k].append(v[0]) - - return {k: inputs[k] for k in out_keys} + return MiniCPMVMultiModalProcessor._call_hf_processor_on_prompts( + self, + prompts, + mm_data, + {key: value for key, value in mm_kwargs.items() if key != "use_tts"}, + out_keys=out_keys, + ) def _get_prompt_updates( self, @@ -3688,6 +3625,17 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn additional_placeholders.append((modality, sub_pattern)) placeholders += additional_placeholders + # vLLM 0.29 removed PromptUpdateDetails.select_text: prompt updates are + # token oriented now, so encode the replacement text and select the unk + # placeholder positions by token id instead of by text. + unk_token_id = tokenizer.convert_tokens_to_ids("") + + def _select_unk_positions(text: str) -> PromptUpdateDetails: + return PromptUpdateDetails.select_token_id( + tokenizer.encode(text, add_special_tokens=False), + unk_token_id, + ) + image_max_slice_nums = hf_processor_mm_kwargs.get("max_slice_nums") image_use_image_id = hf_processor_mm_kwargs.get("use_image_id") @@ -3696,14 +3644,13 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn image_size = images.get_image_size(item_idx) - return PromptUpdateDetails.select_text( + return _select_unk_positions( self.get_image_prompt_texts( image_size, item_idx, max_slice_nums=None if image_max_slice_nums is None else int(image_max_slice_nums), # type: ignore[arg-type] use_image_id=None if image_use_image_id is None else bool(image_use_image_id), - ), - "", + ) ) def get_video_replacement(item_idx: int): @@ -3712,17 +3659,18 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn frame_size = videos.get_frame_size(item_idx) num_frames = videos.get_num_frames(item_idx) - return PromptUpdateDetails.select_text( - self.get_video_prompt_texts(frame_size, num_frames), - "", - ) + return _select_unk_positions(self.get_video_prompt_texts(frame_size, num_frames)) get_replacement = { "image": get_image_replacement, "video": get_video_replacement, } base_updates = [ - PromptReplacement(modality=modality, target=pattern, replacement=get_replacement[modality]) + PromptReplacement( + modality=modality, + target=tokenizer.encode(pattern, add_special_tokens=False), + replacement=get_replacement[modality], + ) for modality, pattern in placeholders ] @@ -3738,16 +3686,16 @@ class MiniCPMO45OmniLLMMultiModalProcessor(BaseMultiModalProcessor[MiniCPMO45Omn else: audio_len = audios.get_audio_length(item_idx) - return PromptUpdateDetails.select_text( - self.get_audio_prompt_texts(audio_len), - "", - ) + return _select_unk_positions(self.get_audio_prompt_texts(audio_len)) return [ *base_updates, PromptReplacement( modality="audio", - target=audio_placeholder, + target=tokenizer.encode( + audio_placeholder, + add_special_tokens=False, + ), replacement=get_audio_replacement, ), ] diff --git a/vllm_omni/model_executor/models/minimax_h3/text_encoder.py b/vllm_omni/model_executor/models/minimax_h3/text_encoder.py index 85583f243..b1933af95 100644 --- a/vllm_omni/model_executor/models/minimax_h3/text_encoder.py +++ b/vllm_omni/model_executor/models/minimax_h3/text_encoder.py @@ -12,6 +12,7 @@ import regex as re import torch import torch.nn as nn from vllm.config import VllmConfig +from vllm.inputs import mm_input from vllm.model_executor.models.qwen3_vl import ( Qwen3VLDummyInputsBuilder, Qwen3VLForConditionalGeneration, @@ -26,10 +27,6 @@ from vllm_omni.model_executor.models.minimax_h3.conditioning import ( MINIMAX_H3_PRESENTATION_TASK_KEY, ) from vllm_omni.model_executor.models.minimax_h3.preprocessing import ( - IMAGE_PAD, - VIDEO_PAD, - VISION_END, - VISION_START, build_minimax_h3_presentation, ) from vllm_omni.model_executor.models.output_templates import OmniOutput @@ -96,58 +93,57 @@ class MiniMaxH3MultiModalProcessor(Qwen3VLMultiModalProcessor): def _apply_hf_processor_main( self, - prompt: str | list[int], mm_items: Any, hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - *, - enable_hf_prompt_update: bool, ): - task_value = hf_processor_mm_kwargs.get(MINIMAX_H3_PRESENTATION_TASK_KEY) - if task_value is None: - return super()._apply_hf_processor_main( - prompt, - mm_items, - hf_processor_mm_kwargs, - tokenization_kwargs, - enable_hf_prompt_update=enable_hf_prompt_update, - ) - if not isinstance(prompt, str): - raise ValueError("MiniMax H3 presentation requires the original prompt text") - - task = str(task_value) - condition_labels = self._condition_labels(hf_processor_mm_kwargs.get(MINIMAX_H3_CONDITION_LABELS_KEY)) base_kwargs = self._base_processor_kwargs(hf_processor_mm_kwargs) - image_count = sum(kind == "image" for kind, _ in condition_labels) - video_count = sum(kind == "video" for kind, _ in condition_labels) - image_slot = f"{VISION_START}{IMAGE_PAD}{VISION_END}" - hf_processor = self.info.get_hf_processor(**base_kwargs) - video_slot = ( - VIDEO_PAD if self._expands_only_video_token(hf_processor) else f"{VISION_START}{VIDEO_PAD}{VISION_END}" - ) - processing_prompt = image_slot * image_count + video_slot * video_count - if not processing_prompt: - processing_prompt = prompt - - _, processed_data, _ = super()._apply_hf_processor_main( - processing_prompt, + return super()._apply_hf_processor_main( mm_items, base_kwargs, - tokenization_kwargs, - enable_hf_prompt_update=enable_hf_prompt_update, ) + + def apply(self, inputs: Any, timing_ctx: Any): + task_value = inputs.hf_processor_mm_kwargs.get(MINIMAX_H3_PRESENTATION_TASK_KEY) + if task_value is None: + return super().apply(inputs, timing_ctx) + + prompt_ids = self._postprocess_prompt(inputs.prompt) + mm_info = self._cached_apply_hf_processor(inputs, timing_ctx) + base_kwargs = self._base_processor_kwargs(inputs.hf_processor_mm_kwargs) + tokenizer = self.info.get_tokenizer() + prompt_text = tokenizer.decode(prompt_ids, skip_special_tokens=False) + condition_labels = self._condition_labels(inputs.hf_processor_mm_kwargs.get(MINIMAX_H3_CONDITION_LABELS_KEY)) + out_mm_data = mm_info.kwargs.get_data() image_processor = self.info.get_image_processor(**base_kwargs) ids, _ = _build_minimax_h3_presentation( - self.info.get_tokenizer(), - prompt=prompt, - task=task, + tokenizer, + prompt=prompt_text, + task=str(task_value), condition_labels=condition_labels, - image_grid_thw=processed_data.get("image_grid_thw"), - video_grid_thw=processed_data.get("video_grid_thw"), - video_timestamps=processed_data.get("timestamps"), + image_grid_thw=out_mm_data.get("image_grid_thw"), + video_grid_thw=out_mm_data.get("video_grid_thw"), + video_timestamps=out_mm_data.get("timestamps"), merge_size=int(image_processor.merge_size), ) - return ids.tolist(), processed_data, True + prompt_ids = ids.tolist() + + mm_item_counts = inputs.mm_data_items.get_all_counts() + self._validate_mm_kwargs(mm_info.kwargs, mm_item_counts) + self._validate_mm_updates(mm_info.prompt_updates, mm_item_counts) + mm_placeholders = self._find_mm_placeholders( + prompt_ids, + mm_info.prompt_updates, + ) + self._validate_mm_placeholders(mm_placeholders, mm_item_counts) + mm_placeholder_ranges = { + modality: [item.to_range() for item in placeholders] for modality, placeholders in mm_placeholders.items() + } + return mm_input( + prompt_token_ids=prompt_ids, + mm_kwargs=mm_info.kwargs, + mm_hashes=mm_info.hashes, + mm_placeholders=mm_placeholder_ranges, + ) def _get_prompt_updates( self, diff --git a/vllm_omni/model_executor/models/minimax_music3/talker.py b/vllm_omni/model_executor/models/minimax_music3/talker.py index c4fb876fa..9baa7315c 100644 --- a/vllm_omni/model_executor/models/minimax_music3/talker.py +++ b/vllm_omni/model_executor/models/minimax_music3/talker.py @@ -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 """Stage-0 talker for MiniMax Music 3. A Qwen3 backbone predicts one audio frame per decode step. Each frame is @@ -37,7 +37,7 @@ from vllm.logger import init_logger from vllm.model_executor.layers.logits_processor import LogitsProcessor from vllm.model_executor.layers.vocab_parallel_embedding import ParallelLMHead from vllm.model_executor.models.qwen3 import Qwen3Model -from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper from vllm.v1.outputs import SamplerOutput from vllm_omni.model_executor.models.output_templates import OmniOutput @@ -980,8 +980,10 @@ class MiniMaxMusic3TalkerForConditionalGeneration(nn.Module): through vLLM. The audio embedding table and the depth decoder live in a separate component of the repo and are loaded explicitly. """ - loader = AutoWeightsLoader(self, skip_prefixes=["audio_embeddings.", "rvq_decoder."]) - loaded = loader.load_weights(weights) + loader = AutoWeightsLoader(self) + loaded = loader.load_weights( + weights, mapper=WeightsMapper(orig_to_new_prefix={"audio_embeddings.": None, "rvq_decoder.": None}) + ) loaded |= load_depth_decoder_weights( self.vllm_config, audio_embeddings=self.audio_embeddings, diff --git a/vllm_omni/model_executor/models/omnivoice/omnivoice.py b/vllm_omni/model_executor/models/omnivoice/omnivoice.py index 26a6f6b59..a0b0d725e 100644 --- a/vllm_omni/model_executor/models/omnivoice/omnivoice.py +++ b/vllm_omni/model_executor/models/omnivoice/omnivoice.py @@ -11,6 +11,7 @@ from __future__ import annotations import os from collections.abc import Iterable, Mapping, Sequence +from dataclasses import replace import numpy as np import torch @@ -28,7 +29,6 @@ from vllm.multimodal.inputs import ( from vllm.multimodal.parse import MultiModalDataItems, MultiModalDataParser from vllm.multimodal.processing import ( BaseDummyInputsBuilder, - BaseMultiModalProcessor, BaseProcessingInfo, ProcessorInputs, PromptIndexTargets, @@ -37,6 +37,7 @@ from vllm.multimodal.processing import ( ) from vllm.sequence import IntermediateTensors +from vllm_omni.inputs.mm_processor import OmniMultiModalProcessor from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.platforms import current_omni_platform from vllm_omni.transformers_utils.configs.omnivoice import OmniVoiceConfig @@ -63,13 +64,29 @@ class OmniVoiceMultiModalProcessingInfo(BaseProcessingInfo): ) -class OmniVoiceMultiModalProcessor(BaseMultiModalProcessor[OmniVoiceMultiModalProcessingInfo]): +class OmniVoiceMultiModalProcessor(OmniMultiModalProcessor[OmniVoiceMultiModalProcessingInfo]): """Processes text + optional reference audio for OmniVoice. For voice cloning: text + reference audio → tokenized reference For auto voice: text only """ + def apply(self, inputs: ProcessorInputs, timing_ctx): + tokenizer = self.info.get_tokenizer() + prompt_text = tokenizer.decode(inputs.prompt, skip_special_tokens=False) + # Tokenize once here; _apply_hf_processor_main below only encodes the + # reference audio, so the media path never re-tokenizes the text. + prompt_ids = self._encode_prompt_text(prompt_text, inputs.hf_processor_mm_kwargs).reshape(-1).tolist() + inputs = replace( + inputs, + prompt=prompt_ids, + hf_processor_mm_kwargs={ + **inputs.hf_processor_mm_kwargs, + self._OMNI_PROMPT_TEXT_KEY: prompt_text, + }, + ) + return super().apply(inputs, timing_ctx) + def _ensure_cached_runtime_components(self, model_dir: str, config: OmniVoiceConfig) -> None: cached_model_dir = getattr(self, "_cached_model_dir", None) if cached_model_dir == model_dir: @@ -97,23 +114,39 @@ class OmniVoiceMultiModalProcessor(BaseMultiModalProcessor[OmniVoiceMultiModalPr self._cached_model_dir = model_dir - def _call_hf_processor( + def _apply_hf_processor_main( self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], ) -> BatchFeature: + """Encode the reference audio, if any; the prompt was tokenized in apply(). + + Upstream calls this for every request, including text-only ones and + ones whose audio is already cached (empty ``mm_items``), so it must not + touch the text tokenizer. + """ + valid_items = mm_items.select({key for key, count in mm_items.get_all_counts().items() if count > 0}) + mm_data, passthrough = self._get_hf_mm_data(valid_items) + audio = self._get_reference_audio(mm_data) + if audio is None: + return BatchFeature(dict(passthrough)) + processed = self._encode_reference_audio(*audio) + processed.update(passthrough) + return processed + + def _get_reference_audio(self, mm_data: Mapping[str, object]) -> tuple[object, int] | None: + audio = mm_data.get("audio", None) + if audio is None: + audios = mm_data.get("audios") + if audios is not None: + audio = audios[0], self.info.ctx.get_hf_config().sample_rate + return audio + + def _encode_prompt_text(self, prompt: str, mm_kwargs: Mapping[str, object]) -> torch.Tensor: config = self.info.ctx.get_hf_config() model_dir = self.info.ctx.model_config.model self._ensure_cached_runtime_components(model_dir, config) - audio = mm_data.get("audio", None) - if audio is None: - audio = mm_data.get("audios") - if audio is not None: - audio = audio[0], config.sample_rate - # Build text prompt with control tokens lang = mm_kwargs.get("lang", None) instruct = mm_kwargs.get("instruct", None) @@ -136,19 +169,14 @@ class OmniVoiceMultiModalProcessor(BaseMultiModalProcessor[OmniVoiceMultiModalPr full_text = prompt text_prompt = f"{style_text}<|text_start|>{full_text}<|text_end|>" - text_tokens = self.text_tokenizer(text_prompt, return_tensors="pt").input_ids.squeeze(0) # [N_text] + return self.text_tokenizer(text_prompt, return_tensors="pt").input_ids.squeeze(0) # [N_text] - if audio is None: - # Text-only path (auto voice mode) - return BatchFeature( - { - "input_ids": text_tokens, - "input_len": [len(text_tokens)], - } - ) + def _encode_reference_audio(self, audio_signal: object, sr: int) -> BatchFeature: + """Voice cloning: encode the reference audio to 8-codebook tokens.""" + config = self.info.ctx.get_hf_config() + model_dir = self.info.ctx.model_config.model + self._ensure_cached_runtime_components(model_dir, config) - # Voice cloning: encode reference audio to tokens - audio_signal, sr = audio if isinstance(audio_signal, np.ndarray): audio_signal = torch.from_numpy(audio_signal).float() if audio_signal.dim() == 1: @@ -169,14 +197,12 @@ class OmniVoiceMultiModalProcessor(BaseMultiModalProcessor[OmniVoiceMultiModalPr if ref_audio_tokens.dim() == 3: ref_audio_tokens = ref_audio_tokens.squeeze(0) # [8, T_ref] - ft = BatchFeature( + return BatchFeature( { - "input_ids": text_tokens, "ref_audio_tokens": ref_audio_tokens, # [8, T_ref] "ref_audio_len": [ref_audio_tokens.shape[1]], } ) - return ft def _get_mm_fields_config( self, @@ -188,15 +214,6 @@ class OmniVoiceMultiModalProcessor(BaseMultiModalProcessor[OmniVoiceMultiModalPr "ref_audio_len": MultiModalFieldConfig.batched("audio"), } - def _hf_processor_applies_updates( - self, - prompt_text: str, - mm_items: MultiModalDataItems, - hf_processor_mm_kwargs: Mapping[str, object], - tokenization_kwargs: Mapping[str, object], - ) -> bool: - return False - def _get_prompt_updates( self, mm_items: MultiModalDataItems, diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_talker.py b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_talker.py index 35d988895..b3e916f25 100644 --- a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_talker.py +++ b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_talker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from collections.abc import Iterable from functools import cached_property @@ -139,11 +142,11 @@ class Qwen2_5OmniTalkerForConditionalGeneration( return self.language_model.sample(logits, sampling_metadata) def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - loader = AutoWeightsLoader( - self, - skip_prefixes=["thinker.", "token2wav."], + loader = AutoWeightsLoader(self) + loaded = loader.load_weights( + weights, + mapper=(self.hf_to_vllm_mapper) | WeightsMapper(orig_to_new_prefix={"thinker.": None, "token2wav.": None}), ) - loaded = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) # Log load summary try: total_bytes = 0 diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py index ba073aad2..6bbefceab 100644 --- a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py +++ b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_5_omni_thinker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """Thin Omni wrapper: reuse upstream Qwen2.5-Omni thinker with minimal overrides.""" import hashlib @@ -9,6 +12,7 @@ import numpy as np import PIL.Image as PILImage import torch from torch import nn +from transformers.feature_extraction_utils import BatchFeature from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import ( Qwen2_5OmniThinkerConfig, ) @@ -360,25 +364,40 @@ class Qwen2_5OmniThinkerMultiModalProcessor( ): """Override to fix use_audio_in_video detection when mm cache returns None.""" - def _call_hf_processor( + def _apply_hf_processor_main( self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], - ): - mm_kwargs = _presampled_videos_hf_kwargs(mm_data, mm_kwargs) - mm_kwargs = _coerce_use_audio_in_video_for_hf_processor(mm_data, mm_kwargs) - hf_inputs = super()._call_hf_processor( - prompt=prompt, - mm_data=mm_data, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, + mm_items: MultiModalDataItems, + hf_processor_mm_kwargs: Mapping[str, object], + ) -> BatchFeature: + """Apply omni-specific HF kwargs before the upstream Qwen2.5-Omni + processor runs. + + Upstream's ``_apply_hf_processor_main`` is fully custom and no longer + routes through the generic ``_preprocess_hf_mm_data`` / + ``_postprocess_hf_mm_data`` hooks, so the omni video pre-sampling and + per-video ``use_audio_in_video`` mask handling have to run here. + """ + valid_mm_items = mm_items.select({k for k, c in mm_items.get_all_counts().items() if c > 0}) + processor_data, _ = self._get_hf_mm_data(valid_mm_items) + + hf_processor_mm_kwargs = _presampled_videos_hf_kwargs( + processor_data, + hf_processor_mm_kwargs, ) - per_video_mask = mm_kwargs.get(_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY) + hf_processor_mm_kwargs = _coerce_use_audio_in_video_for_hf_processor( + processor_data, + hf_processor_mm_kwargs, + ) + + processed_data = super()._apply_hf_processor_main( + mm_items, + hf_processor_mm_kwargs, + ) + + per_video_mask = hf_processor_mm_kwargs.get(_PER_VIDEO_USE_AUDIO_IN_VIDEO_KEY) if per_video_mask is not None: - hf_inputs["use_audio_in_video"] = torch.tensor(per_video_mask) - return hf_inputs + processed_data["use_audio_in_video"] = torch.tensor(per_video_mask) + return processed_data def _get_mm_fields_config( self, @@ -530,17 +549,17 @@ class Qwen2_5OmniThinkerMultiModalProcessor( return [ PromptReplacement( modality="audio", - target=audio_token, + target=[audio_token_id], replacement=get_replacement_qwen2_audio, ), PromptReplacement( modality="image", - target=image_token, + target=[image_token_id], replacement=partial(get_replacement_qwen2_vision, modality="image"), ), PromptReplacement( modality="video", - target=video_token, + target=[video_token_id], replacement=get_replacement_qwen2_video, ), ] @@ -620,7 +639,7 @@ class Qwen2_5OmniThinkerMultiModalProcessor( self, inputs: ProcessorInputs, timing_ctx: TimingContext, - ) -> tuple[list[int], MultiModalProcessingInfo, bool]: + ) -> MultiModalProcessingInfo: """Cache-aware: processes video/audio pairs as units, using pair-specific cache keys. Falls back to standard cache if no pairs.""" @@ -673,16 +692,9 @@ class Qwen2_5OmniThinkerMultiModalProcessor( ) with timing_ctx.record("apply_hf_processor"): - ( - prompt_ids, - mm_missing_processed_data, - is_update_applied, - ) = self._apply_hf_processor_main( - prompt=inputs.prompt, + mm_missing_processed_data = self._apply_hf_processor_main( mm_items=mm_missing_data_items, hf_processor_mm_kwargs=hf_processor_mm_kwargs, - tokenization_kwargs=inputs.tokenization_kwargs, - enable_hf_prompt_update=False, ) mm_missing_kwargs = MultiModalKwargsItems.from_hf_inputs( @@ -714,7 +726,7 @@ class Qwen2_5OmniThinkerMultiModalProcessor( prompt_updates=mm_prompt_updates, ) - return prompt_ids, mm_info, is_update_applied + return mm_info def _derive_audio_from_video_placeholders( self, @@ -785,7 +797,6 @@ class Qwen2_5OmniThinkerMultiModalProcessor( prompt_ids: list[int], mm_kwargs: MultiModalKwargsItems, mm_prompt_updates: MultiModalPromptUpdates, - is_update_applied: bool, ) -> tuple[list[int], Mapping[str, list[PlaceholderFeaturesInfo]]]: mm_item_counts = mm_items.get_all_counts() self._validate_mm_kwargs(mm_kwargs, mm_item_counts) @@ -794,37 +805,27 @@ class Qwen2_5OmniThinkerMultiModalProcessor( video_use_audio_in_video = self._get_video_use_audio_in_video(mm_kwargs, mm_prompt_updates) use_audio_in_video = any(video_use_audio_in_video) - if is_update_applied: - mm_placeholders = self._find_mm_placeholders( + if use_audio_in_video and "audio" in mm_prompt_updates: + filtered_updates = {k: v for k, v in mm_prompt_updates.items() if k != "audio"} + prompt_ids, mm_placeholders = self._apply_prompt_updates( + prompt_ids, + filtered_updates, + ) + mm_placeholders = self._derive_audio_from_video_placeholders( + mm_placeholders, + mm_prompt_updates, + video_use_audio_in_video, + ) + else: + prompt_ids, mm_placeholders = self._apply_prompt_updates( prompt_ids, mm_prompt_updates, ) - self._validate_mm_placeholders( - mm_placeholders, - mm_item_counts, - ) - else: - if use_audio_in_video and "audio" in mm_prompt_updates: - filtered_updates = {k: v for k, v in mm_prompt_updates.items() if k != "audio"} - prompt_ids, mm_placeholders = self._apply_prompt_updates( - prompt_ids, - filtered_updates, - ) - mm_placeholders = self._derive_audio_from_video_placeholders( - mm_placeholders, - mm_prompt_updates, - video_use_audio_in_video, - ) - else: - prompt_ids, mm_placeholders = self._apply_prompt_updates( - prompt_ids, - mm_prompt_updates, - ) - self._validate_mm_placeholders( - mm_placeholders, - mm_item_counts, - ) + self._validate_mm_placeholders( + mm_placeholders, + mm_item_counts, + ) return prompt_ids, mm_placeholders @@ -1397,11 +1398,12 @@ class Qwen2_5OmniThinkerForConditionalGeneration( if self.visual is None: skip_prefixes.extend(["visual."]) - loader = AutoWeightsLoader( - self, - skip_prefixes=skip_prefixes, + loader = AutoWeightsLoader(self) + loaded_weights = loader.load_weights( + weights, + mapper=(self.hf_to_vllm_mapper) + | WeightsMapper(orig_to_new_prefix={name: None for name in (skip_prefixes or ())}), ) - loaded_weights = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) return loaded_weights diff --git a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py index 91eafd2a8..e9e92c904 100644 --- a/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py +++ b/vllm_omni/model_executor/models/qwen2_5_omni/qwen2_old.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from collections.abc import Iterable import torch @@ -20,6 +23,7 @@ from vllm.model_executor.models.interfaces import SupportsLoRA, SupportsPP from vllm.model_executor.models.utils import ( AutoWeightsLoader, PPMissingLayer, + WeightsMapper, is_pp_missing_parameter, make_empty_intermediate_tensors_factory, make_layers, @@ -441,8 +445,8 @@ class Qwen2ForCausalLM(nn.Module, SupportsLoRA, SupportsPP): return next_tokens def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - loader = AutoWeightsLoader( - self, - skip_prefixes=(["lm_head."] if self.config.tie_word_embeddings else None), + loader = AutoWeightsLoader(self) + return loader.load_weights( + weights, + mapper=WeightsMapper(orig_to_new_prefix={"lm_head.": None} if self.config.tie_word_embeddings else {}), ) - return loader.load_weights(weights) diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_code2wav.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_code2wav.py index 5c3129be9..67352eecb 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_code2wav.py +++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_code2wav.py @@ -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 # Copyright 2025 The Qwen team. """Inference-only Qwen3-Omni-Moe Code2Wav model.""" @@ -336,11 +336,11 @@ class Qwen3OmniMoeCode2Wav(nn.Module, Qwen3OmniNestedSupportsQuant): def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: """Load weights from HuggingFace checkpoint.""" - loader = AutoWeightsLoader( - self, - skip_prefixes=["thinker.", "talker."], # Already loaded above + loader = AutoWeightsLoader(self) + loaded = loader.load_weights( + weights, + mapper=(self.hf_to_vllm_mapper) | WeightsMapper(orig_to_new_prefix={"thinker.": None, "talker.": None}), ) - loaded = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) # Log load summary try: diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py index 8337f8dad..ea3e2fcfe 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py +++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_talker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from collections.abc import Iterable import torch @@ -299,13 +302,12 @@ class Qwen3OmniMoeTalkerForConditionalGeneration( to vLLM's internal structure. Code predictor weights are routed to its custom loader for vocab extension support. """ - loader = AutoWeightsLoader( - self, - skip_prefixes=["thinker.", "code2wav."], - # "code_predictor."], - ) + loader = AutoWeightsLoader(self) # Don't apply mapper again since we already did it - loaded = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) + loaded = loader.load_weights( + weights, + mapper=(self.hf_to_vllm_mapper) | WeightsMapper(orig_to_new_prefix={"thinker.": None, "code2wav.": None}), + ) # Log load summary try: diff --git a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py index bbfea498b..b2a7d7abe 100644 --- a/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py +++ b/vllm_omni/model_executor/models/qwen3_omni/qwen3_omni_moe_thinker.py @@ -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 # Copyright 2025 The Qwen team. # Copyright 2023 The vLLM team. # Copyright 2022 EleutherAI and the HuggingFace Inc. team. All rights reserved. @@ -31,10 +31,7 @@ import numpy as np import torch import torch.nn as nn import torch.nn.functional as F -from packaging.version import Version from transformers import PretrainedConfig -from transformers import __version__ as TRANSFORMERS_VERSION -from transformers.feature_extraction_utils import BatchFeature from transformers.models.qwen3_omni_moe.configuration_qwen3_omni_moe import ( Qwen3OmniMoeConfig, Qwen3OmniMoeThinkerConfig, @@ -95,6 +92,9 @@ from vllm.model_executor.models.qwen3_omni_moe_thinker import ( from vllm.model_executor.models.qwen3_omni_moe_thinker import ( Qwen3OmniMoeAudioEncoderLayer as _Qwen3OmniMoeAudioEncoderLayer, ) +from vllm.model_executor.models.qwen3_omni_moe_thinker import ( + Qwen3OmniMoeThinkerMultiModalProcessor as _UpstreamQwen3Processor, +) from vllm.model_executor.models.qwen3_omni_moe_thinker import ( _get_feat_extract_output_lengths, ) @@ -124,7 +124,6 @@ from vllm_omni.model_executor.models.qwen2_5_omni.qwen2_5_omni_thinker import ( Qwen2_5OmniThinkerMultiModalProcessor, _get_request_video_use_audio_in_video, _get_video_second_per_grid_t, - _presampled_videos_hf_kwargs, ) from vllm_omni.model_executor.models.qwen3_omni.quantization import ( Qwen3OmniNestedSupportsQuant, @@ -281,7 +280,12 @@ class Qwen3OmniMoeAudioAttention(_Qwen3OmniMoeAudioAttention): self.scaling = self.head_dim**-0.5 - self.qkv = QKVParallelLinear( + # vLLM 0.29 renamed the audio attention projection to ``qkv_proj`` and + # its stacked mapping rewrites checkpoint ``q_proj``/``k_proj``/ + # ``v_proj`` onto that name, so the module must match or weight loading + # fails with "no module or parameter named ...self_attn.qkv_proj". + # The inherited forward also calls ``self.qkv_proj``. + self.qkv_proj = QKVParallelLinear( hidden_size=self.embed_dim, head_size=self.head_dim, total_num_heads=self.num_heads, @@ -289,7 +293,7 @@ class Qwen3OmniMoeAudioAttention(_Qwen3OmniMoeAudioAttention): bias=True, disable_tp=disable_tp, quant_config=quant_config, - prefix=f"{prefix}.qkv", + prefix=f"{prefix}.qkv_proj", ) self.out_proj = RowParallelLinear( @@ -696,151 +700,10 @@ Qwen3OmniMoeThinkerDummyInputsBuilder = Qwen2_5OmniThinkerDummyInputsBuilder class Qwen3OmniMoeThinkerMultiModalProcessor( Qwen2_5OmniThinkerMultiModalProcessor, + _UpstreamQwen3Processor, ): - def _call_hf_processor( - self, - prompt: str, - mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object], - tok_kwargs: Mapping[str, object], - ) -> BatchFeature: - mm_data = dict(mm_data) - mm_kwargs = dict(_presampled_videos_hf_kwargs(mm_data, mm_kwargs)) - audios = mm_data.pop("audios", []) - - def pad_to_hop_length(x: np.ndarray, hop_length: int) -> np.ndarray: - length = x.shape[-1] - if length % hop_length != 0: - pad_length = hop_length - (length % hop_length) - x = np.pad(x, (0, pad_length), mode="constant", constant_values=0) - return x - - # NOTE: WhisperFeatureExtractor cannot handle empty list of audios - feature_extractor = self.info.get_feature_extractor(**mm_kwargs) - hop_length = feature_extractor.hop_length - if audios: - # NOTE: Qwen3-Omni processor accept "audio" - # To make sure the cache works with padding=True, we pre-padded - # the audio to multiple of hop_length. - mm_data["audio"] = [ - pad_to_hop_length(audio, hop_length) - if isinstance(audio, np.ndarray) - else (pad_to_hop_length(audio[0], hop_length), audio[1]) - for audio in audios - ] - - # TODO(Isotr0py): Remove this patch after upstream fix PR - # released and Transformers version update: - # https://github.com/huggingface/transformers/pull/41473 - mm_kwargs = dict(mm_kwargs) - tok_kwargs = dict(tok_kwargs) - mm_kwargs["audio_kwargs"] = dict(mm_kwargs.get("audio_kwargs") or {}) - mm_kwargs["text_kwargs"] = dict(mm_kwargs.get("text_kwargs") or {}) - if Version(TRANSFORMERS_VERSION) < Version("4.58.0"): - # Extract audio_sample_rate before restructuring - audio_sample_rate = mm_kwargs.pop("audio_sample_rate", None) - - # move truncation to audio_kwargs level to avoid conflict - # with tok_kwargs - mm_kwargs["audio_kwargs"].setdefault("truncation", mm_kwargs.pop("truncation", False)) - mm_kwargs["text_kwargs"].setdefault("truncation", tok_kwargs.pop("truncation", False)) - - # Validate and conditionally pass audio_sample_rate - # WhisperFeatureExtractor has a fixed sampling rate, and vLLM's - # audio loader already resamples audio to the target rate. - # Only pass the value if it matches to avoid unexpected behavior. - if audio_sample_rate is not None: - expected_sr = feature_extractor.sampling_rate - if audio_sample_rate != expected_sr: - logger.warning( - "[%s] audio_sample_rate mismatch: user provided %dHz " - "but model expects %dHz. Ignoring user value. " - "vLLM's audio loader already resampled to %dHz.", - self.__class__.__name__, - audio_sample_rate, - expected_sr, - expected_sr, - ) - else: - # Sample rate matches, safe to pass - mm_kwargs["audio_kwargs"]["audio_sample_rate"] = audio_sample_rate - - hf_inputs = super()._call_hf_processor( - prompt=prompt, - mm_data=mm_data, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, - ) - - if ( - "audio_feature_lengths" in hf_inputs - and "feature_attention_mask" in hf_inputs - and (audios := mm_data.get("audio", [])) - ): - audio_num_frames = [] - for _, audio in enumerate(audios): - audio_length = len(audio[0]) if isinstance(audio, tuple) else len(audio) - num_frame = ( - (audio_length // hop_length) if audio_length % hop_length == 0 else (audio_length // hop_length - 1) - ) - if mm_kwargs.get("truncation", False): - num_frame = min(num_frame, feature_extractor.n_samples // hop_length) - audio_num_frames.append(num_frame) - hf_inputs["feature_attention_mask"] = [torch.ones(num_frame) for num_frame in audio_num_frames] - hf_inputs["audio_feature_lengths"] = torch.tensor(audio_num_frames) - return hf_inputs - - def _maybe_apply_prompt_updates( - self, - mm_items: MultiModalDataItems, - prompt_ids: list[int], - mm_kwargs: MultiModalKwargsItems, - mm_prompt_updates: MultiModalPromptUpdates, - is_update_applied: bool, - ) -> tuple[list[int], str, Mapping[str, list[PlaceholderFeaturesInfo]]]: - """ - Qwen3-Omni reimplements this function to handle `use_audio_in_video`. - """ - mm_item_counts = mm_items.get_all_counts() - self._validate_mm_kwargs(mm_kwargs, mm_item_counts) - - video_use_audio_in_video = self._get_video_use_audio_in_video(mm_kwargs, mm_prompt_updates) - use_audio_in_video = any(video_use_audio_in_video) - - # normal case with `use_audio_in_video=False` - if is_update_applied: - mm_placeholders = self._find_mm_placeholders( - prompt_ids, - mm_prompt_updates, - ) - self._validate_mm_placeholders( - mm_placeholders, - mm_item_counts, - ) - else: - if use_audio_in_video and "audio" in mm_prompt_updates: - filtered_updates = {k: v for k, v in mm_prompt_updates.items() if k != "audio"} - prompt_ids, mm_placeholders = self._apply_prompt_updates( - prompt_ids, - filtered_updates, - ) - mm_placeholders = self._derive_audio_from_video_placeholders( - mm_placeholders, - mm_prompt_updates, - video_use_audio_in_video, - ) - else: - prompt_ids, mm_placeholders = self._apply_prompt_updates( - prompt_ids, - mm_prompt_updates, - ) - - self._validate_mm_placeholders( - mm_placeholders, - mm_item_counts, - ) - - return prompt_ids, mm_placeholders + # Preserve Omni's per-video overrides while inheriting upstream Qwen3's + # audio processing through the shared, cooperative Qwen2.5 base. def get_updates_use_audio_in_video( self, @@ -978,17 +841,17 @@ class Qwen3OmniMoeThinkerMultiModalProcessor( return [ PromptReplacement( modality="audio", - target=audio_token, + target=[audio_token_id], replacement=get_replacement_qwen2_audio, ), PromptReplacement( modality="image", - target=image_token, + target=[image_token_id], replacement=partial(get_replacement_qwen2_vision, modality="image"), ), PromptReplacement( modality="video", - target=video_token, + target=[video_token_id], replacement=get_replacement_qwen2_video, ), ] @@ -1114,7 +977,10 @@ class Qwen3OmniMoeConditionalGenerationMixin(Qwen2_5OmniConditionalGenerationMix audio_input: Qwen2_5OmniAudioFeatureInputs, ) -> tuple[torch.Tensor, ...]: input_features = audio_input["input_features"] - audio_feature_lengths = audio_input["audio_feature_lengths"] + # vLLM 0.29 marks audio_feature_lengths keep_on_cpu, and the audio tower + # derives device placement from feature_lens, so move it explicitly. + # Mirrors upstream Qwen3OmniMoeThinker._process_audio_input. + audio_feature_lengths = audio_input["audio_feature_lengths"].to(input_features.device, non_blocking=True) audio_output_lengths = _get_feat_extract_output_lengths(audio_feature_lengths) @@ -1540,11 +1406,11 @@ class Qwen3OmniMoeThinkerForConditionalGeneration( return self.language_model.compute_logits(hidden_states) def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]: - loader = AutoWeightsLoader( - self, - skip_prefixes=["talker.", "code2wav."], + loader = AutoWeightsLoader(self) + loaded_weights = loader.load_weights( + weights, + mapper=(self.hf_to_vllm_mapper) | WeightsMapper(orig_to_new_prefix={"talker.": None, "code2wav.": None}), ) - loaded_weights = loader.load_weights(weights, mapper=self.hf_to_vllm_mapper) return loaded_weights diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code2wav.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code2wav.py index 87df87889..f1692f73d 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code2wav.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_code2wav.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import os @@ -11,7 +14,7 @@ from vllm.config import VllmConfig from vllm.forward_context import get_forward_context, is_forward_context_available from vllm.logger import init_logger from vllm.model_executor.model_loader import DefaultModelLoader -from vllm.model_executor.models.utils import AutoWeightsLoader +from vllm.model_executor.models.utils import AutoWeightsLoader, WeightsMapper from vllm_omni.model_executor.models.output_templates import OmniOutput from vllm_omni.model_executor.stage_input_processors.chunk_size_utils import parse_chunk_ramp @@ -552,10 +555,9 @@ class Qwen3TTSCode2Wav(nn.Module): subfolder="speech_tokenizer", ) subfolder_weights = model_loader._get_weights_iterator(source) - loaded = AutoWeightsLoader( - self, - skip_prefixes=["encoder."], - ).load_weights(subfolder_weights) + loaded = AutoWeightsLoader(self).load_weights( + subfolder_weights, mapper=WeightsMapper(orig_to_new_prefix={"encoder.": None}) + ) device = self.vllm_config.device_config.device self.decoder.to(device=device, dtype=torch.float32) diff --git a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py index e65334ff5..dfb24516a 100644 --- a/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py +++ b/vllm_omni/model_executor/models/qwen3_tts/qwen3_tts_talker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + from __future__ import annotations import copy @@ -1100,10 +1103,9 @@ class Qwen3TTSTalkerForConditionalGeneration(nn.Module): subfolder="speech_tokenizer", ) subfolder_weights = model_loader._get_weights_iterator(source) - enc_loaded = AutoWeightsLoader( - self, - skip_prefixes=["decoder."], - ).load_weights(subfolder_weights) + enc_loaded = AutoWeightsLoader(self).load_weights( + subfolder_weights, mapper=WeightsMapper(orig_to_new_prefix={"decoder.": None}) + ) loaded |= enc_loaded # AutoWeightsLoader only loads parameters; the encoder's VQ diff --git a/vllm_omni/model_executor/models/step_audio2/step_audio2_thinker.py b/vllm_omni/model_executor/models/step_audio2/step_audio2_thinker.py index b00e24870..6dca8b358 100644 --- a/vllm_omni/model_executor/models/step_audio2/step_audio2_thinker.py +++ b/vllm_omni/model_executor/models/step_audio2/step_audio2_thinker.py @@ -1,3 +1,6 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project + """ Step-Audio2 Thinker - Stage 1 LLM for Audio Understanding @@ -19,6 +22,7 @@ import torch.nn as nn import torch.nn.functional as F from torch.nn.utils.rnn import pad_sequence from transformers import AutoTokenizer +from transformers.feature_extraction_utils import BatchFeature from transformers.processing_utils import ProcessorMixin from vllm.config import VllmConfig from vllm.inputs import MultiModalDataDict @@ -503,6 +507,10 @@ class StepAudio2MultiModalProcessor(BaseMultiModalProcessor[StepAudio2Processing processor = self.info.get_hf_processor(**hf_processor_mm_kwargs) audio_token = getattr(processor, "audio_token", "") + audio_token_ids = self.info.get_tokenizer().encode( + audio_token, + add_special_tokens=False, + ) audio_items = mm_items.get("audio", []) num_audio_items = len(audio_items) if audio_items else 0 @@ -548,27 +556,22 @@ class StepAudio2MultiModalProcessor(BaseMultiModalProcessor[StepAudio2Processing return [ PromptReplacement( modality="audio", - target=audio_token, + target=audio_token_ids, replacement=get_replacement_audio, ) ] - def _call_hf_processor( + def _preprocess_hf_mm_data( self, - prompt: str, mm_data: Mapping[str, object], - mm_kwargs: Mapping[str, object] | None = None, - tok_kwargs: Mapping[str, object] | None = None, - **_: object, - ): - """Call HF processor and post-process outputs""" + hf_processor_mm_kwargs: Mapping[str, object], + ) -> tuple[Mapping[str, object], Mapping[str, object]]: + mm_data, hf_processor_mm_kwargs = super()._preprocess_hf_mm_data( + mm_data, + hf_processor_mm_kwargs, + ) mm_data = dict(mm_data) audios = mm_data.pop("audios", []) - - mm_kwargs = mm_kwargs or {} - tok_kwargs = tok_kwargs or {} - - # CRITICAL: Ensure audios is ALWAYS a list to prevent string iteration if audios: if isinstance(audios, str): mm_data["audio"] = [audios] @@ -576,20 +579,24 @@ class StepAudio2MultiModalProcessor(BaseMultiModalProcessor[StepAudio2Processing mm_data["audio"] = audios else: mm_data["audio"] = [audios] + return mm_data, hf_processor_mm_kwargs - hf_inputs = super()._call_hf_processor( - prompt=prompt, - mm_data=mm_data, - mm_kwargs=mm_kwargs, - tok_kwargs=tok_kwargs, + def _postprocess_hf_mm_data( + self, + mm_data: Mapping[str, object], + hf_processor_mm_kwargs: Mapping[str, object], + processed_data: BatchFeature, + ) -> BatchFeature: + processed_data = super()._postprocess_hf_mm_data( + mm_data, + hf_processor_mm_kwargs, + processed_data, ) - - if "audio_mels" not in hf_inputs: - hf_inputs["audio_mels"] = torch.empty((0, 128, 0)) - if "audio_lens" not in hf_inputs: - hf_inputs["audio_lens"] = torch.tensor([], dtype=torch.int32) - - return hf_inputs + if "audio_mels" not in processed_data: + processed_data["audio_mels"] = torch.empty((0, 128, 0)) + if "audio_lens" not in processed_data: + processed_data["audio_lens"] = torch.tensor([], dtype=torch.int32) + return processed_data # ============================================================================ diff --git a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts_audio_generation.py b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts_audio_generation.py index 4f63bbec1..211159afa 100644 --- a/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts_audio_generation.py +++ b/vllm_omni/model_executor/models/voxtral_tts/voxtral_tts_audio_generation.py @@ -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 math from collections.abc import Iterable, Mapping, Sequence @@ -48,11 +48,9 @@ from vllm.multimodal.parse import AudioProcessorItems, MultiModalDataItems, Mult from vllm.multimodal.processing import BaseDummyInputsBuilder, BaseMultiModalProcessor from vllm.multimodal.processing.processor import ( BaseProcessingInfo, - MultiModalProcessingInfo, ProcessorInputs, PromptReplacement, PromptUpdate, - TimingContext, ) from vllm.sequence import IntermediateTensors from vllm.tokenizers import cached_tokenizer_from_config @@ -871,7 +869,7 @@ class VoxtralTTSMultiModalProcessor(BaseMultiModalProcessor[VoxtralTTSProcessing return [ PromptReplacement( modality="audio", - target="", # Never match the prompt (see below note) + target=[audio_id], replacement=get_replacement, ), ] @@ -899,15 +897,24 @@ class VoxtralTTSMultiModalProcessor(BaseMultiModalProcessor[VoxtralTTSProcessing ) return mm_processed_data - def _cached_apply_hf_processor( + def _maybe_apply_prompt_updates( self, - inputs: ProcessorInputs, - timing_ctx: TimingContext, - ) -> tuple[list[int], MultiModalProcessingInfo, bool]: - prompt_ids, mm_info, _ = super()._cached_apply_hf_processor(inputs, timing_ctx) - - # NOTE: The tokens are already inserted by the chat template - return prompt_ids, mm_info, True + mm_items: MultiModalDataItems, + prompt_ids: list[int], + mm_kwargs: MultiModalKwargsItems, + mm_prompt_updates, + ): + # Voxtral's Mistral chat template has already inserted the complete + # audio-token run, so locate it without applying the replacement again. + mm_item_counts = mm_items.get_all_counts() + self._validate_mm_kwargs(mm_kwargs, mm_item_counts) + self._validate_mm_updates(mm_prompt_updates, mm_item_counts) + mm_placeholders = self._find_mm_placeholders( + prompt_ids, + mm_prompt_updates, + ) + self._validate_mm_placeholders(mm_placeholders, mm_item_counts) + return prompt_ids, mm_placeholders @MULTIMODAL_REGISTRY.register_processor( diff --git a/vllm_omni/platforms/__init__.py b/vllm_omni/platforms/__init__.py index 942eb7c24..d5ee64353 100644 --- a/vllm_omni/platforms/__init__.py +++ b/vllm_omni/platforms/__init__.py @@ -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 logging import traceback @@ -142,7 +142,11 @@ def resolve_current_omni_platform_cls_qualname() -> str: if platform_cls_qualname is not None: activated_plugins.append(name) except Exception: - pass + logger.debug( + "OmniPlatform plugin %s failed during detection.", + name, + exc_info=True, + ) activated_builtin_plugins = list(set(activated_plugins) & set(builtin_omni_platform_plugins.keys())) activated_oot_plugins = list(set(activated_plugins) & set(platform_plugins.keys())) diff --git a/vllm_omni/reasoning/step_audio_reasoning_parser.py b/vllm_omni/reasoning/step_audio_reasoning_parser.py index 81ef63496..ddf5a434f 100644 --- a/vllm_omni/reasoning/step_audio_reasoning_parser.py +++ b/vllm_omni/reasoning/step_audio_reasoning_parser.py @@ -5,7 +5,7 @@ from collections.abc import Iterable, Sequence from itertools import islice from typing import TYPE_CHECKING -from vllm.entrypoints.openai.engine.protocol import DeltaMessage +from vllm.entrypoints.generate.base.protocol import DeltaMessage from vllm.logger import init_logger from vllm.reasoning import ReasoningParser from vllm.tokenizers import TokenizerLike diff --git a/vllm_omni/worker/gpu_ar_model_runner.py b/vllm_omni/worker/gpu_ar_model_runner.py index 811f86f17..6625effe8 100644 --- a/vllm_omni/worker/gpu_ar_model_runner.py +++ b/vllm_omni/worker/gpu_ar_model_runner.py @@ -2128,7 +2128,6 @@ class GPUARModelRunner(OmniGPUModelRunner, OmniConnectorModelRunnerMixin, Duplex elif self.valid_sampled_token_count_event is not None: assert spec_decode_common_attn_metadata is not None next_token_ids, valid_sampled_tokens_count = self.drafter.prepare_next_token_ids_padded( - self.optimistic_seq_lens_cpu, sampled_token_ids, self.requests, self.input_batch, diff --git a/vllm_omni/worker/sampling_utils.py b/vllm_omni/worker/sampling_utils.py index 845881138..39ecae469 100644 --- a/vllm_omni/worker/sampling_utils.py +++ b/vllm_omni/worker/sampling_utils.py @@ -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 """Sampling-state guards shared by the GPU and NPU AR model runners.""" import torch @@ -48,7 +48,7 @@ def sanitize_min_tokens_stop_ids(logitsprocs: LogitsProcessors, logits_vocab: in if not min_toks: continue needs_rebuild = False - for _, _, stop_tok_ids in min_toks.values(): + for _, _, stop_tok_ids, _ in min_toks.values(): oob = [tok for tok in stop_tok_ids if tok >= logits_vocab] if not oob: continue @@ -63,10 +63,19 @@ def sanitize_min_tokens_stop_ids(logitsprocs: LogitsProcessors, logits_vocab: in if needs_rebuild: reqs: list[int] = [] tok_ids: list[int] = [] - for index, (_, _, stop_tok_ids) in min_toks.items(): + restore_reqs: list[int] = [] + restore_tok_ids: list[int] = [] + for index, (_, _, stop_tok_ids, uses_structured_output) in min_toks.items(): reqs.extend([index] * len(stop_tok_ids)) tok_ids.extend(stop_tok_ids) + if uses_structured_output: + restore_reqs.extend([index] * len(stop_tok_ids)) + restore_tok_ids.extend(stop_tok_ids) proc.logits_slice = ( proc._device_tensor(reqs, torch.int32), proc._device_tensor(tok_ids, torch.int32), ) + proc.restore_logits_slice = ( + proc._device_tensor(restore_reqs, torch.int32), + proc._device_tensor(restore_tok_ids, torch.int32), + )